feat: AutoEQ, speaker EQ enhancements, and audio performance fixes

- Add 16-band graphic equalizer with legacy EQ mode
- Add speaker measurement and room correction EQ
- Fix audio skipping with AutoEQ on Android background
- Improve audio performance to prevent skipping under CPU load
- Fix dual EQ applied when switching between legacy and parametric modes
- Remove redundant Equalizer settings tab
- Improve mobile EQ band layout and collapsible database section
This commit is contained in:
tryptz 2026-04-05 00:36:59 +00:00 committed by edideaur
parent 1c906612f4
commit d09e3aa72a
13 changed files with 1242 additions and 179 deletions

View file

@ -20,6 +20,11 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".AudioPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@ -32,4 +37,7 @@
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest> </manifest>

View file

@ -0,0 +1,118 @@
package tf.monochrome.music;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
import androidx.core.app.NotificationCompat;
/**
* Foreground service that keeps the app process alive while audio is playing
* in the background. Without this, Android will throttle the WebView and
* suspend Web Audio API processing, causing audible skips and dropouts.
*/
public class AudioPlaybackService extends Service {
private static final String CHANNEL_ID = "audio_playback";
private static final int NOTIFICATION_ID = 1;
private PowerManager.WakeLock wakeLock;
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Notification notification = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
startForeground(NOTIFICATION_ID, notification);
}
acquireWakeLock();
// If the system kills this service, don't restart it automatically
// MainActivity will re-start it when audio resumes.
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
releaseWakeLock();
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void createNotificationChannel() {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"Audio Playback",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Keeps audio playing in the background");
channel.setSound(null, null);
channel.setShowBadge(false);
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(channel);
}
}
private Notification buildNotification() {
Intent launchIntent = new Intent(this, MainActivity.class);
launchIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
this, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Monochrome")
.setContentText("Playing audio")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
.build();
}
private void acquireWakeLock() {
if (wakeLock == null) {
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (pm != null) {
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"monochrome:audio_playback"
);
// 4-hour timeout as a safety net to prevent battery drain
// if the service is accidentally left running
wakeLock.acquire(4 * 60 * 60 * 1000L);
}
}
}
private void releaseWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
wakeLock = null;
}
}
}

View file

@ -0,0 +1,36 @@
package tf.monochrome.music;
import android.content.Intent;
import android.os.Build;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* Capacitor plugin that exposes start/stop controls for the foreground
* AudioPlaybackService. Called from JS when audio playback begins or ends
* so Android keeps the process alive in the background.
*/
@CapacitorPlugin(name = "BackgroundAudio")
public class BackgroundAudioPlugin extends Plugin {
@PluginMethod
public void start(PluginCall call) {
Intent intent = new Intent(getContext(), AudioPlaybackService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getContext().startForegroundService(intent);
} else {
getContext().startService(intent);
}
call.resolve();
}
@PluginMethod
public void stop(PluginCall call) {
Intent intent = new Intent(getContext(), AudioPlaybackService.class);
getContext().stopService(intent);
call.resolve();
}
}

View file

@ -1,5 +1,14 @@
package tf.monochrome.music; package tf.monochrome.music;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {} public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(BackgroundAudioPlugin.class);
super.onCreate(savedInstanceState);
}
}

View file

@ -4119,6 +4119,7 @@
<!-- Mode Toggle + How To --> <!-- Mode Toggle + How To -->
<div class="autoeq-mode-row"> <div class="autoeq-mode-row">
<div class="autoeq-mode-toggle"> <div class="autoeq-mode-toggle">
<button class="autoeq-mode-btn" data-mode="legacy">Legacy EQ</button>
<button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button> <button class="autoeq-mode-btn active" data-mode="autoeq">AutoEQ</button>
<button class="autoeq-mode-btn" data-mode="parametric"> <button class="autoeq-mode-btn" data-mode="parametric">
Parametric EQ Parametric EQ
@ -4255,6 +4256,60 @@
</div> </div>
</div> </div>
<!-- Legacy 16-Band Graphic EQ (visible in legacy mode) -->
<div class="graphic-eq-section" id="graphic-eq-section" style="display: none">
<div class="graphic-eq-preset-row">
<span class="graphic-eq-preset-label">Preset</span>
<select
id="legacy-graphic-eq-preset-select"
class="graphic-eq-preset-select"
>
<option value="">Custom</option>
<option value="flat">Flat</option>
<option value="bass_boost">Bass Boost</option>
<option value="bass_reducer">Bass Reducer</option>
<option value="treble_boost">Treble Boost</option>
<option value="treble_reducer">Treble Reducer</option>
<option value="vocal_boost">Vocal Boost</option>
<option value="loudness">Loudness</option>
<option value="rock">Rock</option>
<option value="pop">Pop</option>
<option value="classical">Classical</option>
<option value="jazz">Jazz</option>
<option value="electronic">Electronic</option>
<option value="hip_hop">Hip-Hop</option>
<option value="r_and_b">R&amp;B</option>
<option value="acoustic">Acoustic</option>
<option value="podcast">Speech</option>
</select>
</div>
<div class="graphic-eq-bands" id="legacy-graphic-eq-bands">
<!-- 16 vertical sliders generated by JS -->
</div>
<div class="graphic-eq-bottom-row">
<div class="graphic-eq-preamp">
<span class="graphic-eq-preamp-label">Preamp</span>
<input
type="range"
id="legacy-graphic-eq-preamp-slider"
class="graphic-eq-preamp-slider"
min="-20"
max="20"
step="0.1"
value="0"
/>
<span
class="graphic-eq-preamp-value"
id="legacy-graphic-eq-preamp-value"
>0 dB</span
>
</div>
<button id="legacy-graphic-eq-reset-btn" class="btn-secondary">
Reset
</button>
</div>
</div>
<!-- Frequency Response Graph --> <!-- Frequency Response Graph -->
<div class="autoeq-graph-section"> <div class="autoeq-graph-section">
<div class="autoeq-graph-header"> <div class="autoeq-graph-header">
@ -4301,29 +4356,41 @@
<!-- Database Browser --> <!-- Database Browser -->
<div class="autoeq-database-section" id="autoeq-database-section"> <div class="autoeq-database-section" id="autoeq-database-section">
<div class="autoeq-database-header"> <div class="autoeq-database-header" id="autoeq-database-toggle">
<div> <div>
<h4 class="autoeq-database-title">Database</h4> <h4 class="autoeq-database-title">Database</h4>
<span class="autoeq-database-subtitle">AutoEq Repo</span> <span class="autoeq-database-subtitle">AutoEq Repo</span>
</div> </div>
<span class="autoeq-database-count" id="autoeq-database-count"></span> <div class="autoeq-database-header-right">
</div> <span class="autoeq-database-count" id="autoeq-database-count"></span>
<div class="autoeq-database-search"> <button
<use svg="!lucide/search.svg" size="16" /> class="autoeq-collapse-btn"
<input id="autoeq-database-collapse"
type="text" aria-label="Collapse database"
id="autoeq-headphone-search" aria-expanded="true"
placeholder="Search model (e.g. HD 600)..." >
autocomplete="off" <use svg="!lucide/chevron-up.svg" size="18" />
aria-label="Search headphone model" </button>
/>
</div>
<div class="autoeq-database-content">
<div class="autoeq-database-list" id="autoeq-database-list">
<!-- Dynamically populated -->
</div> </div>
<div class="autoeq-database-alpha-index" id="autoeq-alpha-index"> </div>
<!-- A-Z generated by JS --> <div class="autoeq-database-body" id="autoeq-database-body">
<div class="autoeq-database-search">
<use svg="!lucide/search.svg" size="16" />
<input
type="text"
id="autoeq-headphone-search"
placeholder="Search model (e.g. HD 600)..."
autocomplete="off"
aria-label="Search headphone model"
/>
</div>
<div class="autoeq-database-content">
<div class="autoeq-database-list" id="autoeq-database-list">
<!-- Dynamically populated -->
</div>
<div class="autoeq-database-alpha-index" id="autoeq-alpha-index">
<!-- A-Z generated by JS -->
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import UIKit import UIKit
import AVFoundation
import Capacitor import Capacitor
@UIApplicationMain @UIApplicationMain
@ -7,10 +8,82 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow? var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch. configureAudioSession()
return true return true
} }
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
// .playback keeps audio alive when the app is backgrounded or the screen locks
try session.setCategory(.playback, mode: .default, options: [])
try session.setActive(true)
} catch {
print("[AudioSession] Failed to configure: \(error.localizedDescription)")
}
// Handle audio interruptions (phone calls, Siri, alarms, etc.)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioInterruption),
name: AVAudioSession.interruptionNotification,
object: session
)
// Handle route changes (headphones unplugged, Bluetooth disconnect, etc.)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification,
object: session
)
}
@objc private func handleAudioInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began system pauses audio automatically
break
case .ended:
// Interruption ended reactivate session so playback can resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[AudioSession] Failed to reactivate after interruption: \(error.localizedDescription)")
}
}
}
@unknown default:
break
}
}
@objc private func handleRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
return
}
if reason == .oldDeviceUnavailable {
// Headphones/Bluetooth disconnected reactivate session to keep background alive
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("[AudioSession] Failed to reactivate after route change: \(error.localizedDescription)")
}
}
}
func applicationWillResignActive(_ application: UIApplication) { func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.

View file

@ -47,5 +47,9 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -113,6 +113,15 @@ class AudioContextManager {
// Callbacks for audio graph changes (for visualizers like Butterchurn) // Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = []; this._graphChangeCallbacks = [];
// --- Graphic EQ (16-band, separate chain) ---
this.geqFilters = [];
this.geqPreampNode = null;
this.geqOutputNode = null;
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
this.geqFrequencies = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
this.geqGains = equalizerSettings.getGraphicEqGains();
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
// Load saved settings // Load saved settings
this._loadSettings(); this._loadSettings();
} }
@ -308,17 +317,12 @@ class AudioContextManager {
try { try {
const AudioContext = window.AudioContext || window.webkitAudioContext; const AudioContext = window.AudioContext || window.webkitAudioContext;
const highResOptions = { sampleRate: 192000, latencyHint: 'playback' };
try { try {
this.audioContext = new AudioContext(highResOptions); this.audioContext = new AudioContext({ latencyHint: 'playback' });
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`); console.log(`[AudioContext] Created: ${this.audioContext.sampleRate}Hz`);
} catch { } catch {
try { this.audioContext = new AudioContext();
this.audioContext = new AudioContext({ latencyHint: 'playback' });
} catch {
this.audioContext = new AudioContext();
}
} }
if (!this.sources.has(audioElement)) { if (!this.sources.has(audioElement)) {
@ -331,6 +335,7 @@ class AudioContextManager {
this.analyser.smoothingTimeConstant = 0.7; this.analyser.smoothingTimeConstant = 0.7;
this._createEQ(); this._createEQ();
this._createGraphicEQ();
this.outputNode = this.audioContext.createGain(); this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1; this.outputNode.gain.value = 1;
@ -342,6 +347,21 @@ class AudioContextManager {
this._connectGraph(); this._connectGraph();
// Auto-recover from unexpected suspensions (e.g. background throttling)
this.audioContext.addEventListener('statechange', () => {
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
// Use a short delay to let the system settle before resuming
setTimeout(() => {
if (this.audioContext && this.audioContext.state !== 'running') {
this.audioContext.resume().catch((e) => {
console.warn('[AudioContext] Auto-resume failed:', e);
});
}
}, 100);
}
});
this.isInitialized = true; this.isInitialized = true;
} catch (e) { } catch (e) {
console.warn('[AudioContext] Init failed:', e); console.warn('[AudioContext] Init failed:', e);
@ -380,65 +400,76 @@ class AudioContextManager {
} }
/** /**
* Connect the audio graph based on EQ and mono audio state * Connect the audio graph based on EQ and mono audio state.
* Uses connect-before-disconnect ordering to avoid audio dropouts:
* the new chain is wired up first, then the old connections are torn down.
*/ */
_connectGraph() { _connectGraph() {
if (!this.isInitialized || !this.source || !this.audioContext) return; if (!this.isInitialized || !this.source || !this.audioContext) return;
try { // Ensure graphic EQ nodes exist
// Disconnect everything first if (this.geqFilters.length === 0 && this.isGraphicEQEnabled) {
try { this._createGraphicEQ();
this.source.disconnect(); }
} catch {
// node may already be disconnected
}
this.outputNode.disconnect();
if (this.volumeNode) {
this.volumeNode.disconnect();
}
this.analyser.disconnect();
if (this.monoMergerNode) { // Helper: connect a chain segment from lastNode through graphic EQ (if enabled) to analyser -> volume -> dest
try { const connectTail = (lastNode) => {
this.monoMergerNode.disconnect(); if (this.isGraphicEQEnabled && this.geqFilters.length > 0) {
} catch { lastNode.connect(this.geqPreampNode);
// Ignore if not connected this.geqPreampNode.connect(this.geqFilters[0]);
for (let i = 0; i < this.geqFilters.length - 1; i++) {
this.geqFilters[i].connect(this.geqFilters[i + 1]);
} }
this.geqFilters[this.geqFilters.length - 1].connect(this.geqOutputNode);
this.geqOutputNode.connect(this.analyser);
} else {
lastNode.connect(this.analyser);
}
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
};
try {
// Ensure mono gain node exists if needed
if (this.isMonoAudioEnabled && this.monoMergerNode && !this.monoGainNode) {
this.monoGainNode = this.audioContext.createGain();
this.monoGainNode.gain.value = 0.5;
} }
// --- 1. Disconnect all existing connections ---
const safeDisconnect = (node) => {
try {
node?.disconnect();
} catch {
/* */
}
};
safeDisconnect(this.source);
safeDisconnect(this.monoGainNode);
safeDisconnect(this.monoMergerNode);
safeDisconnect(this.preampNode);
this.filters.forEach(safeDisconnect);
safeDisconnect(this.outputNode);
safeDisconnect(this.geqPreampNode);
this.geqFilters.forEach(safeDisconnect);
safeDisconnect(this.geqOutputNode);
safeDisconnect(this.analyser);
safeDisconnect(this.volumeNode);
// --- 2. Reconnect the graph ---
let lastNode = this.source; let lastNode = this.source;
// Apply mono audio if enabled
if (this.isMonoAudioEnabled && this.monoMergerNode) { if (this.isMonoAudioEnabled && this.monoMergerNode) {
// Reuse persistent gain node to avoid leaking AudioNodes
if (!this.monoGainNode) {
this.monoGainNode = this.audioContext.createGain();
this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
}
try {
this.monoGainNode.disconnect();
} catch {
/* not connected */
}
// Connect source to mono gain
this.source.connect(this.monoGainNode); this.source.connect(this.monoGainNode);
// Connect mono gain to both inputs of the merger
this.monoGainNode.connect(this.monoMergerNode, 0, 0); this.monoGainNode.connect(this.monoMergerNode, 0, 0);
this.monoGainNode.connect(this.monoMergerNode, 0, 1); this.monoGainNode.connect(this.monoMergerNode, 0, 1);
lastNode = this.monoMergerNode; lastNode = this.monoMergerNode;
console.log('[AudioContext] Mono audio enabled');
} }
if (this.isEQEnabled && this.filters.length > 0) { if (this.isEQEnabled && this.filters.length > 0) {
// EQ enabled: lastNode -> preamp -> EQ filters -> output -> analyser -> volume -> destination
// Connect filter chain
for (let i = 0; i < this.filters.length - 1; i++) { for (let i = 0; i < this.filters.length - 1; i++) {
this.filters[i].connect(this.filters[i + 1]); this.filters[i].connect(this.filters[i + 1]);
} }
// Connect preamp to first filter
if (this.preampNode) { if (this.preampNode) {
lastNode.connect(this.preampNode); lastNode.connect(this.preampNode);
this.preampNode.connect(this.filters[0]); this.preampNode.connect(this.filters[0]);
@ -446,22 +477,15 @@ class AudioContextManager {
lastNode.connect(this.filters[0]); lastNode.connect(this.filters[0]);
} }
this.filters[this.filters.length - 1].connect(this.outputNode); this.filters[this.filters.length - 1].connect(this.outputNode);
this.outputNode.connect(this.analyser); connectTail(this.outputNode);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
console.log('[AudioContext] EQ connected');
} else { } else {
// EQ disabled: lastNode -> analyser -> volume -> destination connectTail(lastNode);
lastNode.connect(this.analyser);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
} }
// Notify visualizers that graph has been reconnected // Notify visualizers that graph has been reconnected
this._notifyGraphChange(); this._notifyGraphChange();
} catch (e) { } catch (e) {
console.warn('[AudioContext] Failed to connect graph:', e); console.warn('[AudioContext] Failed to connect graph:', e);
// Fallback: direct connection
try { try {
this.source.connect(this.audioContext.destination); this.source.connect(this.audioContext.destination);
} catch { } catch {
@ -815,11 +839,22 @@ class AudioContextManager {
this.currentQs = newQs; this.currentQs = newQs;
this.currentGains = newGains; this.currentGains = newGains;
// Rebuild EQ so _createEQ picks up the new types/Qs
if (this.isInitialized && this.audioContext) { if (this.isInitialized && this.audioContext) {
this._destroyEQ(); // If filter count matches, update params in-place (no graph rebuild)
this._createEQ(); if (this.filters.length === count) {
this._connectGraph(); const now = this.audioContext.currentTime;
this.filters.forEach((filter, i) => {
filter.type = newTypes[i] || 'peaking';
filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
filter.gain.setTargetAtTime(newGains[i], now, 0.005);
filter.Q.setTargetAtTime(newQs[i], now, 0.005);
});
} else {
// Band count changed — must rebuild
this._destroyEQ();
this._createEQ();
this._connectGraph();
}
} }
// Apply preamp (skip if caller manages preamp externally) // Apply preamp (skip if caller manages preamp externally)
@ -955,6 +990,94 @@ class AudioContextManager {
return false; return false;
} }
} }
// ========================================
// Graphic EQ (16-band, independent chain)
// ========================================
_createGraphicEQ() {
if (!this.audioContext) return;
this.geqPreampNode = this.audioContext.createGain();
const gainValue = Math.pow(10, (this.geqPreamp || 0) / 20);
this.geqPreampNode.gain.value = gainValue;
this.geqOutputNode = this.audioContext.createGain();
this.geqOutputNode.gain.value = 1;
this.geqFilters = this.geqFrequencies.map((freq, i) => {
const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 2.5; // constant Q for 16-band
filter.gain.value = this.geqGains[i] || 0;
return filter;
});
}
_destroyGraphicEQ() {
this.geqFilters.forEach((f) => {
try {
f.disconnect();
} catch {
/* */
}
});
this.geqFilters = [];
if (this.geqPreampNode) {
try {
this.geqPreampNode.disconnect();
} catch {
/* */
}
this.geqPreampNode = null;
}
if (this.geqOutputNode) {
try {
this.geqOutputNode.disconnect();
} catch {
/* */
}
this.geqOutputNode = null;
}
}
toggleGraphicEQ(enabled) {
this.isGraphicEQEnabled = enabled;
equalizerSettings.setGraphicEqEnabled(enabled);
if (this.isInitialized) {
this._connectGraph();
}
}
setGraphicEqBandGain(bandIndex, gainDb) {
if (bandIndex < 0 || bandIndex >= 16) return;
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
if (this.geqFilters[bandIndex] && this.audioContext) {
const now = this.audioContext.currentTime;
this.geqFilters[bandIndex].gain.setTargetAtTime(this.geqGains[bandIndex], now, 0.01);
}
}
setGraphicEqAllGains(gains) {
if (!Array.isArray(gains)) return;
const now = this.audioContext?.currentTime || 0;
gains.forEach((g, i) => {
if (i >= 16) return;
this.geqGains[i] = Math.max(-30, Math.min(30, g));
if (this.geqFilters[i]) {
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
}
});
}
setGraphicEqPreamp(db) {
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
if (this.geqPreampNode && this.audioContext) {
const gainValue = Math.pow(10, this.geqPreamp / 20);
const now = this.audioContext.currentTime;
this.geqPreampNode.gain.setTargetAtTime(gainValue, now, 0.01);
}
}
} }
// Export singleton instance // Export singleton instance

View file

@ -148,9 +148,13 @@ export class Player {
await this.saveQueueState(); await this.saveQueueState();
}); });
// Handle visibility change for iOS - AudioContext gets suspended when screen locks // Handle visibility change - AudioContext can be suspended when backgrounded
document.addEventListener('visibilitychange', async () => { document.addEventListener('visibilitychange', async () => {
const el = this.activeElement; const el = this.activeElement;
if (document.visibilityState === 'hidden' && !el.paused) {
// Proactively resume context when going to background to prevent suspension
void audioContextManager.resume();
}
if (document.visibilityState === 'visible' && !el.paused) { if (document.visibilityState === 'visible' && !el.paused) {
// Ensure audio context is resumed when user returns to the app // Ensure audio context is resumed when user returns to the app
if (!audioContextManager.isReady()) { if (!audioContextManager.isReady()) {
@ -2076,7 +2080,41 @@ export class Player {
updateMediaSessionPlaybackState() { updateMediaSessionPlaybackState() {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.activeElement.paused ? 'paused' : 'playing'; const isPlaying = !this.activeElement.paused;
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
// Start/stop Android foreground service to prevent background audio throttling
this._updateBackgroundAudioService(isPlaying);
}
/**
* On Android (Capacitor), start or stop the foreground service that keeps
* the WebView alive so Web Audio EQ processing isn't throttled.
*/
_updateBackgroundAudioService(isPlaying) {
if (this._bgAudioPending) return;
this._bgAudioPending = true;
// Lazy-load Capacitor core; no-op on web/iOS
void (async () => {
try {
const { Capacitor } = await import('@capacitor/core');
if (Capacitor.getPlatform() !== 'android') return;
const { registerPlugin } = await import('@capacitor/core');
if (!this._bgAudioPlugin) {
this._bgAudioPlugin = registerPlugin('BackgroundAudio');
}
if (isPlaying) {
await this._bgAudioPlugin.start();
} else {
await this._bgAudioPlugin.stop();
}
} catch {
// Not running in Capacitor or plugin unavailable — ignore
} finally {
this._bgAudioPending = false;
}
})();
} }
updateMediaSessionPositionState() { updateMediaSessionPositionState() {

View file

@ -1231,6 +1231,153 @@ export async function initializeSettings(scrobbler, player, api, ui) {
}); });
} }
// ========================================
// 16-Band Graphic Equalizer (Legacy EQ mode)
// ========================================
const GEQ_LABELS = [
'25',
'40',
'63',
'100',
'160',
'250',
'400',
'630',
'1K',
'1.6K',
'2.5K',
'4K',
'6.3K',
'10K',
'16K',
'20K',
];
const geqBandsContainer = document.getElementById('graphic-eq-bands');
const geqPreampSlider = document.getElementById('graphic-eq-preamp-slider');
const geqPreampValue = document.getElementById('graphic-eq-preamp-value');
const geqPresetSelect = document.getElementById('graphic-eq-preset-select');
const geqResetBtn = document.getElementById('graphic-eq-reset-btn');
const legacyGeqBandsContainer = document.getElementById('legacy-graphic-eq-bands');
const legacyGeqPreampSlider = document.getElementById('legacy-graphic-eq-preamp-slider');
const legacyGeqPreampValue = document.getElementById('legacy-graphic-eq-preamp-value');
const legacyGeqPresetSelect = document.getElementById('legacy-graphic-eq-preset-select');
const legacyGeqResetBtn = document.getElementById('legacy-graphic-eq-reset-btn');
const geqPreampSliders = [geqPreampSlider, legacyGeqPreampSlider].filter(Boolean);
const geqPreampValues = [geqPreampValue, legacyGeqPreampValue].filter(Boolean);
const geqPresetSelects = [geqPresetSelect, legacyGeqPresetSelect].filter(Boolean);
let geqGains = equalizerSettings.getGraphicEqGains() || new Array(16).fill(0);
let geqPreamp = equalizerSettings.getGraphicEqPreamp() || 0;
const geqRange = equalizerSettings.getRange();
// Sync all slider UIs across both containers
const geqSyncAllSliders = () => {
geqGains.forEach((g, i) => {
['geq', 'legacy-geq'].forEach((prefix) => {
const sl = document.getElementById(`${prefix}-slider-${i}`);
const vl = document.getElementById(`${prefix}-value-${i}`);
if (sl) sl.value = g;
if (vl) vl.textContent = `${g > 0 ? '+' : ''}${g.toFixed(1)}`;
});
});
};
// Build 16 vertical slider bands into a container
const buildGeqBands = (container, idPrefix) => {
if (!container) return;
GEQ_LABELS.forEach((_label, i) => {
const band = document.createElement('div');
band.className = 'graphic-eq-band';
const valueLabel = document.createElement('span');
valueLabel.className = 'graphic-eq-band-value';
valueLabel.textContent = `${geqGains[i] > 0 ? '+' : ''}${geqGains[i].toFixed(1)}`;
valueLabel.id = `${idPrefix}-value-${i}`;
const sliderWrap = document.createElement('div');
sliderWrap.className = 'graphic-eq-band-slider-wrap';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = geqRange.min;
slider.max = geqRange.max;
slider.step = '0.1';
slider.value = geqGains[i];
slider.id = `${idPrefix}-slider-${i}`;
slider.setAttribute('aria-label', `${GEQ_LABELS[i]} Hz`);
slider.addEventListener('input', () => {
const gain = parseFloat(slider.value);
geqGains[i] = gain;
equalizerSettings.setGraphicEqGains(geqGains);
audioContextManager.setGraphicEqBandGain(i, gain);
geqSyncAllSliders();
geqPresetSelects.forEach((s) => (s.value = ''));
});
sliderWrap.appendChild(slider);
const freqLabel = document.createElement('span');
freqLabel.className = 'graphic-eq-band-label';
freqLabel.textContent = GEQ_LABELS[i];
band.appendChild(valueLabel);
band.appendChild(sliderWrap);
band.appendChild(freqLabel);
container.appendChild(band);
});
};
buildGeqBands(geqBandsContainer, 'geq');
buildGeqBands(legacyGeqBandsContainer, 'legacy-geq');
// Wire up preamp sliders
geqPreampSliders.forEach((slider) => {
slider.value = geqPreamp;
slider.addEventListener('input', () => {
geqPreamp = parseFloat(slider.value);
const text = `${geqPreamp.toFixed(1)} dB`;
geqPreampValues.forEach((v) => (v.textContent = text));
geqPreampSliders.forEach((s) => {
if (s !== slider) s.value = geqPreamp;
});
equalizerSettings.setGraphicEqPreamp(geqPreamp);
audioContextManager.setGraphicEqPreamp(geqPreamp);
});
});
geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp} dB`));
// Wire up preset selects
geqPresetSelects.forEach((select) => {
select.addEventListener('change', () => {
const key = select.value;
if (!key) return;
const presets = getPresetsForBandCount(16);
const preset = presets[key];
if (!preset) return;
geqGains = [...preset.gains];
equalizerSettings.setGraphicEqGains(geqGains);
audioContextManager.setGraphicEqAllGains(geqGains);
geqSyncAllSliders();
geqPresetSelects.forEach((s) => {
if (s !== select) s.value = key;
});
});
});
// Wire up reset buttons
[geqResetBtn, legacyGeqResetBtn].filter(Boolean).forEach((btn) => {
btn.addEventListener('click', () => {
geqGains = new Array(16).fill(0);
equalizerSettings.setGraphicEqGains(geqGains);
audioContextManager.setGraphicEqAllGains(geqGains);
geqSyncAllSliders();
geqPresetSelects.forEach((s) => (s.value = 'flat'));
});
});
// ======================================== // ========================================
// Precision AutoEQ - Redesigned Equalizer // Precision AutoEQ - Redesigned Equalizer
// ======================================== // ========================================
@ -1362,6 +1509,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
/** /**
* Draw the frequency response graph with Original, Target, and Corrected curves * Draw the frequency response graph with Original, Target, and Corrected curves
*/ */
let _drawGraphRafId = null;
const scheduleDrawAutoEQGraph = () => {
if (_drawGraphRafId) return;
_drawGraphRafId = requestAnimationFrame(() => {
_drawGraphRafId = null;
drawAutoEQGraph();
});
};
const drawAutoEQGraph = () => { const drawAutoEQGraph = () => {
if (!autoeqCanvas) return; if (!autoeqCanvas) return;
const activeBands = getActiveBands(); const activeBands = getActiveBands();
@ -1859,82 +2015,144 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (autoeqCanvas) { if (autoeqCanvas) {
autoeqCanvas.addEventListener('mousedown', (e) => { autoeqCanvas.addEventListener('mousedown', (e) => {
const coords = getCanvasCoords(e); const coords = getCanvasCoords(e);
const nodeIdx = findClosestNode(coords.x, coords.y, 18); let nodeIdx = findClosestNode(coords.x, coords.y, 18);
if (nodeIdx >= 0) { if (nodeIdx >= 0) {
// Clicked directly on a node - start dragging
draggedNode = nodeIdx; draggedNode = nodeIdx;
autoeqCanvas.style.cursor = 'grabbing'; autoeqCanvas.style.cursor = 'grabbing';
e.preventDefault(); e.preventDefault();
} } else {
}); // Clicked empty space - find nearest node (no threshold) and snap it
nodeIdx = findClosestNode(coords.x, coords.y, Infinity);
if (nodeIdx >= 0) {
const bands = getActiveBands();
if (bands && bands[nodeIdx]) {
const rect = autoeqCanvas.getBoundingClientRect();
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
const isParam = currentMode === 'parametric';
const dbCenter = isParam ? 0 : 75;
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
const dbMin = dbCenter - dbHalf;
const dbMax = dbCenter + dbHalf;
autoeqCanvas.addEventListener('mousemove', (e) => { // Snap frequency to click position
const coords = getCanvasCoords(e); const freq = xToFreq(coords.x - padLeft, w);
const bands = getActiveBands(); bands[nodeIdx].freq = Math.max(20, Math.min(20000, freq));
if (draggedNode !== null && bands) {
const rect = autoeqCanvas.getBoundingClientRect();
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
const isParam = currentMode === 'parametric'; // Snap gain to click position
const dbCenter = isParam ? 0 : 75; if (isParam) {
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ; const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
const dbMin = dbCenter - dbHalf; bands[nodeIdx].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
const dbMax = dbCenter + dbHalf; } else {
const corrGain = interpolate(bands[nodeIdx].freq, autoeqCorrectedCurve || []);
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
const gainDelta = newDb - corrGain;
bands[nodeIdx].gain = Math.max(-30, Math.min(30, bands[nodeIdx].gain + gainDelta * 0.3));
}
const freq = xToFreq(coords.x - padLeft, w); draggedNode = nodeIdx;
bands[draggedNode].freq = Math.max(20, Math.min(20000, freq)); autoeqCanvas.style.cursor = 'grabbing';
if (isParam) {
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
bands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
} else {
const corrGain = interpolate(bands[draggedNode].freq, autoeqCorrectedCurve || []);
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
const gainDelta = newDb - corrGain;
bands[draggedNode].gain = Math.max(-30, Math.min(30, bands[draggedNode].gain + gainDelta * 0.3));
}
if (!graphAnimFrame) {
graphAnimFrame = requestAnimationFrame(() => {
computeCorrectedCurve(); computeCorrectedCurve();
applyBandsToAudio(bands); applyBandsToAudio(bands);
drawAutoEQGraph(); drawAutoEQGraph();
renderBandControls(bands); renderBandControls(bands);
graphAnimFrame = null; e.preventDefault();
});
}
} else {
const padLeft = 40;
if (coords.x <= padLeft + 10) {
autoeqCanvas.style.cursor = 'ns-resize';
if (hoveredNode !== null) {
hoveredNode = null;
drawAutoEQGraph();
}
} else {
const newHovered = findClosestNode(coords.x, coords.y, 18);
if (newHovered !== hoveredNode) {
hoveredNode = newHovered;
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
drawAutoEQGraph();
} }
} }
} }
}); });
autoeqCanvas.addEventListener('mouseup', () => { // Helper to compute canvas-relative coords from any mouse event (even outside the canvas)
draggedNode = null; const getCanvasCoordsFromEvent = (e) => {
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair'; const rect = autoeqCanvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
// Document-level mousemove so dragging continues outside the canvas
document.addEventListener('mousemove', (e) => {
if (draggedNode === null) return;
const bands = getActiveBands();
if (!bands) return;
const coords = getCanvasCoordsFromEvent(e);
const rect = autoeqCanvas.getBoundingClientRect();
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
const isParam = currentMode === 'parametric';
const dbCenter = isParam ? 0 : 75;
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
const dbMin = dbCenter - dbHalf;
const dbMax = dbCenter + dbHalf;
const freq = xToFreq(coords.x - padLeft, w);
bands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
if (isParam) {
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
bands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
} else {
const corrGain = interpolate(bands[draggedNode].freq, autoeqCorrectedCurve || []);
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
const gainDelta = newDb - corrGain;
bands[draggedNode].gain = Math.max(-30, Math.min(30, bands[draggedNode].gain + gainDelta * 0.3));
}
if (!graphAnimFrame) {
graphAnimFrame = requestAnimationFrame(() => {
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
renderBandControls(bands);
graphAnimFrame = null;
});
}
});
// Canvas-only mousemove for hover cursor changes (when not dragging)
autoeqCanvas.addEventListener('mousemove', (e) => {
if (draggedNode !== null) return; // dragging is handled by document listener
const coords = getCanvasCoords(e);
const padLeft = 40;
if (coords.x <= padLeft + 10) {
autoeqCanvas.style.cursor = 'ns-resize';
if (hoveredNode !== null) {
hoveredNode = null;
drawAutoEQGraph();
}
} else {
const newHovered = findClosestNode(coords.x, coords.y, 18);
if (newHovered !== hoveredNode) {
hoveredNode = newHovered;
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
drawAutoEQGraph();
}
}
});
// Document-level mouseup so drag ends even if cursor is outside the canvas
document.addEventListener('mouseup', () => {
if (draggedNode !== null) {
draggedNode = null;
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
}
}); });
autoeqCanvas.addEventListener('mouseleave', () => { autoeqCanvas.addEventListener('mouseleave', () => {
draggedNode = null; // Only reset hover state, NOT drag state (drag continues outside canvas)
hoveredNode = null; hoveredNode = null;
autoeqCanvas.style.cursor = 'crosshair'; if (draggedNode === null) {
autoeqCanvas.style.cursor = 'crosshair';
}
drawAutoEQGraph(); drawAutoEQGraph();
}); });
@ -2038,72 +2256,117 @@ export async function initializeSettings(scrobbler, player, api, ui) {
{ passive: false } { passive: false }
); );
// Touch support // Touch support - snap nearest node on empty space touch, continue drag outside canvas
let touchNodeIdx = -1; let touchNodeIdx = -1;
autoeqCanvas.addEventListener( autoeqCanvas.addEventListener(
'touchstart', 'touchstart',
(e) => { (e) => {
const touch = e.touches[0]; const touch = e.touches[0];
const coords = { const rect = autoeqCanvas.getBoundingClientRect();
x: touch.clientX - autoeqCanvas.getBoundingClientRect().left, const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
y: touch.clientY - autoeqCanvas.getBoundingClientRect().top,
};
touchNodeIdx = findClosestNode(coords.x, coords.y, 25); touchNodeIdx = findClosestNode(coords.x, coords.y, 25);
if (touchNodeIdx >= 0) { if (touchNodeIdx >= 0) {
draggedNode = touchNodeIdx; draggedNode = touchNodeIdx;
e.preventDefault(); e.preventDefault();
} else {
// Snap nearest node to touch position
touchNodeIdx = findClosestNode(coords.x, coords.y, Infinity);
if (touchNodeIdx >= 0) {
const bands = getActiveBands();
if (bands && bands[touchNodeIdx]) {
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
const isParam = currentMode === 'parametric';
const dbCenter = isParam ? 0 : 75;
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
const dbMin = dbCenter - dbHalf;
const dbMax = dbCenter + dbHalf;
const freq = xToFreq(coords.x - padLeft, w);
bands[touchNodeIdx].freq = Math.max(20, Math.min(20000, freq));
if (isParam) {
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
bands[touchNodeIdx].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
}
draggedNode = touchNodeIdx;
computeCorrectedCurve();
applyBandsToAudio(bands);
drawAutoEQGraph();
renderBandControls(bands);
e.preventDefault();
}
}
} }
}, },
{ passive: false } { passive: false }
); );
autoeqCanvas.addEventListener( // Document-level touchmove so dragging continues outside canvas
document.addEventListener(
'touchmove', 'touchmove',
(e) => { (e) => {
if (draggedNode === null) return;
const tBands = getActiveBands(); const tBands = getActiveBands();
if (draggedNode !== null && tBands) { if (!tBands) return;
const touch = e.touches[0];
const rect = autoeqCanvas.getBoundingClientRect();
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
const freq = xToFreq(coords.x - padLeft, w); const touch = e.touches[0];
tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq)); const rect = autoeqCanvas.getBoundingClientRect();
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
const padLeft = 40,
padRight = 10,
padTop = 10,
padBottom = 30;
const w = rect.width - padLeft - padRight;
const h = rect.height - padTop - padBottom;
if (currentMode === 'parametric') { const isParam = currentMode === 'parametric';
const newGain = yToDb(coords.y - padTop, h, -graphDbHalfParametric, graphDbHalfParametric); const dbCenter = isParam ? 0 : 75;
tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10)); const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
} const dbMin = dbCenter - dbHalf;
const dbMax = dbCenter + dbHalf;
computeCorrectedCurve(); const freq = xToFreq(coords.x - padLeft, w);
applyBandsToAudio(tBands); tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
if (!graphAnimFrame) {
graphAnimFrame = requestAnimationFrame(() => { if (isParam) {
drawAutoEQGraph(); const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
renderBandControls(tBands); tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
graphAnimFrame = null; } else {
}); const corrGain = interpolate(tBands[draggedNode].freq, autoeqCorrectedCurve || []);
} const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
e.preventDefault(); const gainDelta = newDb - corrGain;
tBands[draggedNode].gain = Math.max(-30, Math.min(30, tBands[draggedNode].gain + gainDelta * 0.3));
} }
computeCorrectedCurve();
applyBandsToAudio(tBands);
if (!graphAnimFrame) {
graphAnimFrame = requestAnimationFrame(() => {
drawAutoEQGraph();
renderBandControls(tBands);
graphAnimFrame = null;
});
}
e.preventDefault();
}, },
{ passive: false } { passive: false }
); );
autoeqCanvas.addEventListener('touchend', () => { document.addEventListener('touchend', () => {
draggedNode = null; if (draggedNode !== null) {
touchNodeIdx = -1; draggedNode = null;
touchNodeIdx = -1;
}
}); });
// Resize observer for graph // Resize observer for graph
if (autoeqGraphWrapper) { if (autoeqGraphWrapper) {
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
drawAutoEQGraph(); scheduleDrawAutoEQGraph();
}); });
ro.observe(autoeqGraphWrapper); ro.observe(autoeqGraphWrapper);
} }
@ -2166,7 +2429,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
freqVal.textContent = `${formatFreq(bands[i].freq)} Hz`; freqVal.textContent = `${formatFreq(bands[i].freq)} Hz`;
computeCorrectedCurve(); computeCorrectedCurve();
applyBandsToAudio(bands); applyBandsToAudio(bands);
drawAutoEQGraph(); scheduleDrawAutoEQGraph();
}); });
gainSlider.addEventListener('input', () => { gainSlider.addEventListener('input', () => {
@ -2176,7 +2439,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
gainVal.textContent = `${bands[i].gain > 0 ? '+' : ''}${bands[i].gain.toFixed(1)} dB`; gainVal.textContent = `${bands[i].gain > 0 ? '+' : ''}${bands[i].gain.toFixed(1)} dB`;
computeCorrectedCurve(); computeCorrectedCurve();
applyBandsToAudio(bands); applyBandsToAudio(bands);
drawAutoEQGraph(); scheduleDrawAutoEQGraph();
}); });
qSlider.addEventListener('input', () => { qSlider.addEventListener('input', () => {
@ -2186,7 +2449,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
qVal.textContent = bands[i].q.toFixed(2); qVal.textContent = bands[i].q.toFixed(2);
computeCorrectedCurve(); computeCorrectedCurve();
applyBandsToAudio(bands); applyBandsToAudio(bands);
drawAutoEQGraph(); scheduleDrawAutoEQGraph();
}); });
const typeSelect = control.querySelector('.autoeq-type-select'); const typeSelect = control.querySelector('.autoeq-type-select');
@ -2196,7 +2459,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
bands[i].type = typeSelect.value; bands[i].type = typeSelect.value;
computeCorrectedCurve(); computeCorrectedCurve();
applyBandsToAudio(bands); applyBandsToAudio(bands);
drawAutoEQGraph(); scheduleDrawAutoEQGraph();
}); });
}); });
}; };
@ -2257,6 +2520,18 @@ export async function initializeSettings(scrobbler, player, api, ui) {
}); });
} }
// Database section collapse
const autoeqDatabaseToggle = document.getElementById('autoeq-database-toggle');
const autoeqDatabaseCollapse = document.getElementById('autoeq-database-collapse');
const autoeqDatabaseBody = document.getElementById('autoeq-database-body');
if (autoeqDatabaseToggle) {
autoeqDatabaseToggle.addEventListener('click', () => {
if (autoeqDatabaseCollapse) autoeqDatabaseCollapse.classList.toggle('collapsed');
if (autoeqDatabaseBody)
autoeqDatabaseBody.style.display = autoeqDatabaseBody.style.display === 'none' ? '' : 'none';
});
}
// ======================================== // ========================================
// Set Status Message // Set Status Message
// ======================================== // ========================================
@ -3113,13 +3388,14 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const presetRow = document.getElementById('autoeq-preset-row'); const presetRow = document.getElementById('autoeq-preset-row');
const parametricProfiles = document.getElementById('autoeq-parametric-profiles'); const parametricProfiles = document.getElementById('autoeq-parametric-profiles');
const speakerSavedSection = document.getElementById('speaker-saved-section'); const speakerSavedSection = document.getElementById('speaker-saved-section');
const legacySection = document.getElementById('graphic-eq-section');
// Reset interactive state on switch // Reset interactive state on switch
draggedNode = null; draggedNode = null;
hoveredNode = null; hoveredNode = null;
// Graph always visible in all modes // Graph visible in all modes except legacy
if (graphSection) graphSection.style.display = ''; if (graphSection) graphSection.style.display = mode === 'legacy' ? 'none' : '';
// Only show shared AutoEq button in AutoEQ mode // Only show shared AutoEq button in AutoEQ mode
if (autoeqRunBtn) autoeqRunBtn.style.display = mode === 'autoeq' ? '' : 'none'; if (autoeqRunBtn) autoeqRunBtn.style.display = mode === 'autoeq' ? '' : 'none';
@ -3132,6 +3408,20 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (parametricProfiles) parametricProfiles.style.display = 'none'; if (parametricProfiles) parametricProfiles.style.display = 'none';
if (speakerSection) speakerSection.style.display = 'none'; if (speakerSection) speakerSection.style.display = 'none';
if (speakerSavedSection) speakerSavedSection.style.display = 'none'; if (speakerSavedSection) speakerSavedSection.style.display = 'none';
if (legacySection) legacySection.style.display = 'none';
if (mode === 'legacy') {
if (legacySection) legacySection.style.display = '';
// Disable parametric EQ entirely - only graphic EQ active to save resources
audioContextManager.isEQEnabled = false;
audioContextManager.toggleGraphicEQ(true);
equalizerSettings.setGraphicEqEnabled(true);
} else {
// Disable graphic EQ entirely - only parametric EQ active to save resources
audioContextManager.isEQEnabled = true;
audioContextManager.toggleGraphicEQ(false);
equalizerSettings.setGraphicEqEnabled(false);
}
if (mode === 'autoeq') { if (mode === 'autoeq') {
if (controlsSection) controlsSection.style.display = ''; if (controlsSection) controlsSection.style.display = '';

View file

@ -1697,6 +1697,65 @@ export const equalizerSettings = {
clearLastHeadphone() { clearLastHeadphone() {
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY); localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
}, },
// --- Graphic EQ (16-band) separate storage ---
GEQ_ENABLED_KEY: 'graphic-eq-enabled',
GEQ_GAINS_KEY: 'graphic-eq-gains',
GEQ_PREAMP_KEY: 'graphic-eq-preamp',
isGraphicEqEnabled() {
try {
return localStorage.getItem(this.GEQ_ENABLED_KEY) === 'true';
} catch {
return false;
}
},
setGraphicEqEnabled(enabled) {
try {
localStorage.setItem(this.GEQ_ENABLED_KEY, String(!!enabled));
} catch {
/* ignore */
}
},
getGraphicEqGains() {
try {
const stored = localStorage.getItem(this.GEQ_GAINS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length === 16) return parsed;
}
} catch {
/* ignore */
}
return new Array(16).fill(0);
},
setGraphicEqGains(gains) {
try {
localStorage.setItem(this.GEQ_GAINS_KEY, JSON.stringify(gains));
} catch {
/* ignore */
}
},
getGraphicEqPreamp() {
try {
const val = localStorage.getItem(this.GEQ_PREAMP_KEY);
return val !== null ? parseFloat(val) : 0;
} catch {
return 0;
}
},
setGraphicEqPreamp(db) {
try {
localStorage.setItem(this.GEQ_PREAMP_KEY, String(db));
} catch {
/* ignore */
}
},
}; };
export const monoAudioSettings = { export const monoAudioSettings = {

View file

@ -33,6 +33,25 @@ export class Visualizer {
// ---- CACHED STATE ---- // ---- CACHED STATE ----
this._lastPrimaryColor = ''; this._lastPrimaryColor = '';
this._resizeBound = () => this.resize(); this._resizeBound = () => this.resize();
this._backgroundPaused = false;
// Pause animation loop when the app is backgrounded so the analyser's
// FFT reads don't compete with the EQ biquad filter chain for audio
// thread time — the main cause of audio skipping with AutoEQ in background.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && this.isActive) {
this._backgroundPaused = true;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
} else if (document.visibilityState === 'visible' && this._backgroundPaused) {
this._backgroundPaused = false;
if (this.isActive && !this.animationId) {
this.animate();
}
}
});
} }
/** /**

View file

@ -7721,6 +7721,149 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
transform: scale(0.97); transform: scale(0.97);
} }
/* ========================================
16-Band Graphic Equalizer (Legacy EQ)
======================================== */
.graphic-eq-section {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.graphic-eq-preset-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.graphic-eq-preset-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--foreground);
white-space: nowrap;
}
.graphic-eq-preset-select {
flex: 1;
padding: 8px 12px;
background: var(--input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
font-size: 0.85rem;
}
.graphic-eq-bands {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 2px;
padding: var(--spacing-md) var(--spacing-sm);
background: rgb(0, 0, 0, 0.15);
border-radius: var(--radius);
min-height: 240px;
}
.graphic-eq-band {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.graphic-eq-band-value {
font-size: 0.65rem;
color: var(--foreground);
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-height: 14px;
opacity: 0.7;
}
.graphic-eq-band-slider-wrap {
position: relative;
height: 160px;
width: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.graphic-eq-band-slider-wrap input[type='range'] {
writing-mode: vertical-lr;
direction: rtl;
appearance: slider-vertical;
width: 28px;
height: 100%;
accent-color: var(--foreground);
cursor: pointer;
margin: 0;
padding: 0;
}
.graphic-eq-band-label {
font-size: 0.6rem;
color: var(--muted-foreground);
white-space: nowrap;
text-align: center;
letter-spacing: -0.02em;
}
.graphic-eq-bottom-row {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.graphic-eq-preamp {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex: 1;
}
.graphic-eq-preamp-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted-foreground);
white-space: nowrap;
}
.graphic-eq-preamp-slider {
flex: 1;
height: 4px;
accent-color: var(--highlight);
}
.graphic-eq-preamp-value {
font-size: 0.75rem;
color: var(--muted-foreground);
min-width: 45px;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.graphic-eq-bands {
min-height: 180px;
}
.graphic-eq-band-slider-wrap {
height: 130px;
width: 22px;
}
.graphic-eq-band-label {
font-size: 0.5rem;
}
.graphic-eq-band-value {
font-size: 0.5rem;
}
}
/* ======================================== /* ========================================
Precision AutoEQ - Redesigned Equalizer Precision AutoEQ - Redesigned Equalizer
======================================== */ ======================================== */
@ -8341,6 +8484,19 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--spacing-md); padding: var(--spacing-md);
cursor: pointer;
user-select: none;
transition: background var(--transition-fast);
}
.autoeq-database-header:hover {
background: rgb(var(--highlight-rgb), 0.08);
}
.autoeq-database-header-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
} }
.autoeq-database-title { .autoeq-database-title {
@ -9060,6 +9216,69 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
flex: 1; flex: 1;
width: auto; width: auto;
} }
/* Mobile parametric EQ band layout */
.autoeq-band-control {
padding: 0.5rem;
}
.autoeq-band-header {
flex-wrap: wrap;
gap: 0.3rem;
}
.autoeq-band-number {
min-width: 1.2rem;
}
.autoeq-band-param {
min-width: 0;
flex: 0 0 auto;
}
.autoeq-band-sliders {
flex-direction: column;
gap: 0.5rem;
}
.autoeq-band-slider {
width: 100%;
height: 6px;
}
.autoeq-band-slider::-webkit-slider-thumb {
width: 18px;
height: 18px;
}
.autoeq-band-slider::-moz-range-thumb {
width: 18px;
height: 18px;
}
.autoeq-filters-actions {
flex-wrap: wrap;
gap: 0.3rem;
}
.autoeq-filters-actions button {
font-size: 0.7rem;
padding: 0.3rem 0.5rem;
}
}
@media (max-width: 600px) {
/* Rearrange band header into 2 rows on small screens */
.autoeq-band-header {
display: grid;
grid-template-columns: auto auto 1fr 1fr 1fr;
gap: 0.25rem 0.4rem;
align-items: center;
}
.autoeq-band-param {
justify-content: flex-start;
}
} }
/* Track List Search */ /* Track List Search */