diff --git a/js/app.js b/js/app.js index e984ca0..33b7ab0 100644 --- a/js/app.js +++ b/js/app.js @@ -759,9 +759,16 @@ class App { // Auto-resume audio context when page gains focus document.addEventListener('visibilitychange', () => { - if (!document.hidden && this.audioManager.audioContext && - this.audioManager.audioContext.state === 'suspended') { - this.audioManager.audioContext.resume(); + if (document.hidden) return; + const am = this.audioManager; + // If suspended for sleep-mode power saving, go through the wake + // path so the worklet and watcher state are torn down properly. + if (am._sleepSuspended) { + am.wakeFromSleep(); + return; + } + if (am.audioContext && am.audioContext.state === 'suspended') { + am.audioContext.resume(); } }); diff --git a/js/audio-manager.js b/js/audio-manager.js index eddf889..3771851 100644 --- a/js/audio-manager.js +++ b/js/audio-manager.js @@ -4,6 +4,7 @@ import { PipelineProcessor } from './audio/pipeline-processor.js'; import { OfflineProcessor } from './audio/offline-processor.js'; import { AudioEncoder } from './audio/audio-encoder.js'; import { EventManager } from './audio/event-manager.js'; +import { InputActivityWatcher } from './audio/input-activity-watcher.js'; import { getSerializablePluginStateShort, applySerializedState } from './utils/serialization-utils.js'; /** @@ -48,7 +49,13 @@ export class AudioManager { this.isCancelled = false; this._skipAudioInitDuringSampleRateChange = false; this.isFirstLaunch = false; - + + // Sleep-mode output power saving (see _enterSleepPowerSave). True + // while the AudioContext is intentionally suspended so the output + // device (and DAC/Amp) can power down during idle sleep. + this._sleepSuspended = false; + this._inputActivityWatcher = null; + // Set global reference window.audioManager = this; } @@ -284,11 +291,33 @@ export class AudioManager { this.workletNode.port.onmessage = (event) => { const data = event.data; if (data.type === 'sleepModeChanged') { + // Propagate sleep state to every plugin so that + // analyzer-style plugins can pause their main-thread + // redraw loop while the audio path is idle. Plugins + // that don't expose _setSleepMode are unaffected. + if (this.pipeline) { + for (const plugin of this.pipeline) { + if (typeof plugin._setSleepMode === 'function') { + plugin._setSleepMode(data.isSleepMode); + } + } + } // Dispatch sleep mode changed event this.dispatchEvent('sleepModeChanged', { isSleepMode: data.isSleepMode, sampleRate: this.audioContext.sampleRate }); + + // Release the output device while idle so the DAC/Amp + // can power down (non-macOS only; see methods below). + if (data.isSleepMode) { + this._enterSleepPowerSave(); + } else if (this._sleepSuspended) { + // A wake reported by the worklet (only possible + // after we already resumed it) - make sure our + // power-save state is torn down. + this.wakeFromSleep(); + } } }; } @@ -298,7 +327,120 @@ export class AudioManager { return `Audio Error: ${error.message}`; } } - + + /** + * Whether sleep-mode output power saving may run. + * + * Intentionally a no-op on macOS: there, EffeTune carries a + * carefully-tuned audio-device recovery path for HDMI hotplug / + * CoreAudio behavior which, of necessity, treats AudioContext + * suspend/close transitions as failure signals. A deliberate suspend + * would collide with that hard-won machinery for little gain, since + * the power-saving target is a low-power always-on Linux host (Pi + + * DAC). So we simply don't engage it on macOS. + * @returns {boolean} + */ + _sleepPowerSaveSupported() { + if (window.electronAPI?.platform === 'darwin') return false; + return !!this.audioContext; + } + + /** + * Called when the worklet enters sleep mode. Suspends the AudioContext + * so the OS output device - and the downstream DAC/Amp - can power + * down. Auto-wake on returning input is preserved by watching the + * input track with a device-free MediaStreamTrackProcessor (the + * suspended worklet can no longer do it). If that watcher can't be + * established, we keep the context running rather than lose wake-on-input. + */ + async _enterSleepPowerSave() { + if (this._sleepSuspended) return; + if (!this._sleepPowerSaveSupported()) return; + + const track = this.stream?.getAudioTracks?.()[0] ?? null; + if (!track || !InputActivityWatcher.isSupported()) { + // No device-free way to detect input returning (e.g. file + // playback, or API unavailable) - leave the context running so + // the worklet keeps handling wake-on-input (status quo). + return; + } + + this._sleepSuspended = true; + // Mark the suspend as deliberate so the context manager's + // onstatechange handler does not immediately auto-resume it. + this.contextManager.setIntentionalSuspend(true); + try { + await this.audioContext.suspend(); + } catch (e) { + console.warn('[AudioManager] sleep-mode suspend failed:', e); + } + + // We may have been woken (user activity) during the await above. + if (!this._sleepSuspended) { + this.contextManager.setIntentionalSuspend(false); + await this.audioContext.resume().catch(() => {}); + return; + } + + // In HTMLMediaElement output mode the