From b56fe675bd1b58faa091bf70916ee9d8c9649b1d Mon Sep 17 00:00:00 2001 From: Yoshio TAKAEDA Date: Wed, 27 May 2026 22:11:10 +0900 Subject: [PATCH 1/2] Pause analyzer animations while the worklet is in sleep mode The worklet already enters a sleep mode after 60s of silence + no user activity (see audioLevelMonitoring in audio-processor.js): it stops running plugin DSP and just passes input to output. The main thread however kept analyzer plugins' per-frame redraw loops running, so a fully-idle EffeTune still consumed visible main-thread CPU on low-power hardware. Propagate the sleep state to every plugin via _setSleepMode() so the analyzer redraw loops can pause too. Each startAnimation() guard already short-circuits when (this.enabled && this._sectionEnabled) is false; this extends the same guard to also short-circuit when this._sleepMode is true. Editing while in sleep is safe: EventManager already forwards any mousedown / mousemove / wheel / click / key / touch event to the worklet as 'userActivity', which wakes the worklet immediately and emits 'sleepModeChanged: false' back, so the redraw loops resume before the first interaction frame the user sees. --- js/audio-manager.js | 11 +++++++++++ plugins/analyzer/level_meter.js | 2 +- plugins/analyzer/oscilloscope.js | 2 +- plugins/analyzer/spectrogram.js | 4 ++-- plugins/analyzer/spectrum_analyzer.js | 2 +- plugins/analyzer/stereo_meter.js | 2 +- plugins/dynamics/auto_leveler.js | 2 +- plugins/dynamics/compressor.js | 2 +- plugins/dynamics/expander.js | 2 +- plugins/dynamics/gate.js | 2 +- plugins/dynamics/multiband_compressor.js | 2 +- plugins/dynamics/multiband_expander.js | 2 +- plugins/dynamics/multiband_transient.js | 2 +- plugins/dynamics/power_amp_sag.js | 2 +- plugins/dynamics/transient_shaper.js | 2 +- plugins/eq/five_band_dynamic_eq.js | 2 +- plugins/plugin-base.js | 24 +++++++++++++++++++++++- 17 files changed, 50 insertions(+), 17 deletions(-) diff --git a/js/audio-manager.js b/js/audio-manager.js index 1f310a2..eb779dd 100644 --- a/js/audio-manager.js +++ b/js/audio-manager.js @@ -284,6 +284,17 @@ 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, diff --git a/plugins/analyzer/level_meter.js b/plugins/analyzer/level_meter.js index 91d2f0a..99a97cd 100644 --- a/plugins/analyzer/level_meter.js +++ b/plugins/analyzer/level_meter.js @@ -281,7 +281,7 @@ class LevelMeterPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) return; const animate = () => { diff --git a/plugins/analyzer/oscilloscope.js b/plugins/analyzer/oscilloscope.js index 2b956fa..78083e2 100644 --- a/plugins/analyzer/oscilloscope.js +++ b/plugins/analyzer/oscilloscope.js @@ -550,7 +550,7 @@ class OscilloscopePlugin extends PluginBase { startAnimation() { if (this.animationFrameId) return; - if (!this.enabled || !this._sectionEnabled) return; // Skip if disabled or section is off. + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; // Skip if disabled or section is off. const animate = () => { if (!this.isVisible) { diff --git a/plugins/analyzer/spectrogram.js b/plugins/analyzer/spectrogram.js index 5804f76..a7e8c84 100644 --- a/plugins/analyzer/spectrogram.js +++ b/plugins/analyzer/spectrogram.js @@ -249,7 +249,7 @@ class SpectrogramPlugin extends PluginBase { process(message) { if (!message?.measurements?.buffer) return; - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (!this.spectrogramBuffer) return; // Check if spectrogramBuffer exists const fftSize = 1 << this.pt; @@ -424,7 +424,7 @@ class SpectrogramPlugin extends PluginBase { } handleIntersect(entries) { /* ... (same as original) ... */ entries.forEach(entry => {this.isVisible = entry.isIntersecting; if (this.isVisible) {this.startAnimation();} else {this.stopAnimation();}}); } - startAnimation() { /* ... (same as original) ... */ if (this.animationFrameId) return; if (!this.enabled || !this._sectionEnabled) return; const animate = () => {if (!this.isVisible) {this.stopAnimation(); return;} this.drawGraph(); this.animationFrameId = requestAnimationFrame(animate);}; animate(); } + startAnimation() { /* ... (same as original) ... */ if (this.animationFrameId) return; if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; const animate = () => {if (!this.isVisible) {this.stopAnimation(); return;} this.drawGraph(); this.animationFrameId = requestAnimationFrame(animate);}; animate(); } stopAnimation() { /* ... (same as original) ... */ if (this.animationFrameId) {cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null;} } cleanup() { /* ... (mostly same, ensure listeners are correctly removed if stored differently) ... */ this.stopAnimation(); diff --git a/plugins/analyzer/spectrum_analyzer.js b/plugins/analyzer/spectrum_analyzer.js index 92f8830..077b250 100644 --- a/plugins/analyzer/spectrum_analyzer.js +++ b/plugins/analyzer/spectrum_analyzer.js @@ -363,7 +363,7 @@ class SpectrumAnalyzerPlugin extends PluginBase { startAnimation() { if (this.animationFrameId) return; - if (!this.enabled || !this._sectionEnabled) return; // Skip if disabled or section is off. + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; // Skip if disabled or section is off. const animate = () => { if (!this.isVisible) { this.stopAnimation(); diff --git a/plugins/analyzer/stereo_meter.js b/plugins/analyzer/stereo_meter.js index 00b2e36..d11d696 100644 --- a/plugins/analyzer/stereo_meter.js +++ b/plugins/analyzer/stereo_meter.js @@ -256,7 +256,7 @@ class StereoMeterPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) return; const animate = () => { diff --git a/plugins/dynamics/auto_leveler.js b/plugins/dynamics/auto_leveler.js index 6457d1e..b08220d 100644 --- a/plugins/dynamics/auto_leveler.js +++ b/plugins/dynamics/auto_leveler.js @@ -381,7 +381,7 @@ class AutoLevelerPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) return; const animate = () => { diff --git a/plugins/dynamics/compressor.js b/plugins/dynamics/compressor.js index fe50fe8..93f8196 100644 --- a/plugins/dynamics/compressor.js +++ b/plugins/dynamics/compressor.js @@ -604,7 +604,7 @@ class CompressorPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; diff --git a/plugins/dynamics/expander.js b/plugins/dynamics/expander.js index 17a96b5..2ffbb17 100644 --- a/plugins/dynamics/expander.js +++ b/plugins/dynamics/expander.js @@ -606,7 +606,7 @@ class ExpanderPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; diff --git a/plugins/dynamics/gate.js b/plugins/dynamics/gate.js index 7384150..295a8b9 100644 --- a/plugins/dynamics/gate.js +++ b/plugins/dynamics/gate.js @@ -735,7 +735,7 @@ class GatePlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; diff --git a/plugins/dynamics/multiband_compressor.js b/plugins/dynamics/multiband_compressor.js index 8322495..40eb75e 100644 --- a/plugins/dynamics/multiband_compressor.js +++ b/plugins/dynamics/multiband_compressor.js @@ -1221,7 +1221,7 @@ class MultibandCompressorPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId); let lastGraphState = null; diff --git a/plugins/dynamics/multiband_expander.js b/plugins/dynamics/multiband_expander.js index e8161c0..084d5af 100644 --- a/plugins/dynamics/multiband_expander.js +++ b/plugins/dynamics/multiband_expander.js @@ -1100,7 +1100,7 @@ class MultibandExpanderPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId); let lastGraphState = null; diff --git a/plugins/dynamics/multiband_transient.js b/plugins/dynamics/multiband_transient.js index 30fd8b0..7ecff27 100644 --- a/plugins/dynamics/multiband_transient.js +++ b/plugins/dynamics/multiband_transient.js @@ -560,7 +560,7 @@ class MultibandTransientPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) return; const animate = () => { diff --git a/plugins/dynamics/power_amp_sag.js b/plugins/dynamics/power_amp_sag.js index 1d7c66e..9f70a5c 100644 --- a/plugins/dynamics/power_amp_sag.js +++ b/plugins/dynamics/power_amp_sag.js @@ -291,7 +291,7 @@ class PowerAmpSagPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) return; const animate = () => { diff --git a/plugins/dynamics/transient_shaper.js b/plugins/dynamics/transient_shaper.js index 0c558d4..c4a4c46 100644 --- a/plugins/dynamics/transient_shaper.js +++ b/plugins/dynamics/transient_shaper.js @@ -160,7 +160,7 @@ class TransientShaperPlugin extends PluginBase { } startAnimation() { - if (!this.enabled || !this._sectionEnabled) return; + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; if (this.animationFrameId) return; const animate = () => { diff --git a/plugins/eq/five_band_dynamic_eq.js b/plugins/eq/five_band_dynamic_eq.js index ae53a60..b332c16 100644 --- a/plugins/eq/five_band_dynamic_eq.js +++ b/plugins/eq/five_band_dynamic_eq.js @@ -1163,7 +1163,7 @@ class FiveBandDynamicEQ extends PluginBase { // --- Animation Loop for Dynamic Graph --- startAnimation() { if (this.animationFrameId) return; // Already running - if (!this.enabled || !this._sectionEnabled) return; // Skip if disabled or section is off. + if (!this.enabled || !this._sectionEnabled || this._sleepMode) return; // Skip if disabled or section is off. if (this.isVisible === false) return; // Skip if off-screen. const animate = () => { if (this.isVisible === false) { diff --git a/plugins/plugin-base.js b/plugins/plugin-base.js index c5e6dd3..9e09419 100644 --- a/plugins/plugin-base.js +++ b/plugins/plugin-base.js @@ -13,6 +13,15 @@ class PluginBase { // together with `enabled` to decide whether the plugin's redraw loop // (startAnimation/stopAnimation) should run. this._sectionEnabled = true; + // Whether the worklet is in sleep mode (no input/output signal and + // no user activity for the configured duration). The AudioManager + // propagates the sleep transitions here so analyzers can also pause + // their main-thread redraw loops while the audio path is idle. + // Any user interaction wakes the worklet up immediately via the + // existing 'userActivity' channel, so editing while in sleep is + // safe -- the wake-up reverts this flag before the user sees a + // stale UI. + this._sleepMode = false; this.id = null; // Will be set by createPlugin this.errorState = null; // Holds error state this.inputBus = null; // Input bus (null = default Main bus, index 0) @@ -534,6 +543,19 @@ class PluginBase { } } + // Called from the AudioManager when the worklet enters or leaves sleep + // mode. While the audio path is idle, there's nothing meaningful for the + // analyzer to redraw, so we pause the main-thread loop too. Any user + // interaction wakes the worklet via 'userActivity', which causes this to + // be called again with sleepMode=false. + _setSleepMode(sleepMode) { + sleepMode = !!sleepMode; + if (this._sleepMode !== sleepMode) { + this._sleepMode = sleepMode; + this._refreshAnimationState(); + } + } + // Start or stop the redraw loop to match the current effective-enabled // state. Plugins that do not expose startAnimation/stopAnimation are // unaffected. @@ -542,7 +564,7 @@ class PluginBase { typeof this.stopAnimation !== 'function') { return; } - if (this.enabled && this._sectionEnabled) { + if (this.enabled && this._sectionEnabled && !this._sleepMode) { this.startAnimation(); } else { this.stopAnimation(); From def9d44b70d35f72002316c1bda266c4cbef95ce Mon Sep 17 00:00:00 2001 From: Yoshio TAKAEDA Date: Thu, 28 May 2026 16:45:16 +0900 Subject: [PATCH 2/2] Release the output device during sleep so the DAC/Amp can idle EffeTune's worklet already enters sleep mode after 60s of input/output silence + no user activity, but the AudioContext stays running, which keeps the OS output stream (and any downstream DAC/Amp) fully awake - wasteful on an always-on low-power host (Raspberry Pi 5 + external DAC). Suspend the AudioContext on sleep so the output device is released and the DAC/Amp can enter its own standby. The catch is that the suspended worklet can no longer detect input returning (its process() stops), which is what normally auto-wakes sleep. To keep auto-wake-on-audio, watch a clone of the live input track with a MediaStreamTrackProcessor that opens no output device (new InputActivityWatcher); on signal - or on any user activity / window-visibility change - resume the context and nudge the worklet awake. Integration details: - AudioContextManager gains setIntentionalSuspend(); its onstatechange auto-resume (added for macOS device-change recovery) skips the resume while the suspend is deliberate, so it doesn't immediately undo us. - EventManager.handleUserActivity and app.js visibilitychange route through AudioManager.wakeFromSleep when sleep-suspended, since the frozen worklet can't act on a userActivity message. - _doReset tears down the watcher and clears the intentional-suspend flag before rebuilding the graph, so a reset during sleep can't leave the new context unable to auto-resume. Intentionally a no-op on macOS: that platform's carefully-tuned HDMI / CoreAudio recovery treats AudioContext suspend/close transitions as failure signals, so a deliberate suspend would collide with it for little gain. If MediaStreamTrackProcessor is unavailable or the input isn't a live MediaStream (e.g. file playback), we skip suspending and keep the status-quo wake-on-input. Co-Authored-By: Claude Opus 4.7 (1M context) --- js/app.js | 13 ++- js/audio-manager.js | 145 ++++++++++++++++++++++++++++- js/audio/audio-context-manager.js | 21 ++++- js/audio/event-manager.js | 7 ++ js/audio/input-activity-watcher.js | 136 +++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 js/audio/input-activity-watcher.js diff --git a/js/app.js b/js/app.js index fee600f..696ceed 100644 --- a/js/app.js +++ b/js/app.js @@ -752,9 +752,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 eb779dd..bed8ce7 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; } @@ -300,6 +307,17 @@ export class AudioManager { 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(); + } } }; } @@ -309,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