Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});

Expand Down
156 changes: 154 additions & 2 deletions js/audio-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
}
}
};
}
Expand All @@ -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 <audio> element holds its own
// sink open, so pause it too. Context-sink / direct modes route
// through audioContext.destination, which suspend() already released.
const io = this.ioManager;
if (io?.audioElement && !io.audioContextSinkMode && !io.directOutputMode) {
try { io.audioElement.pause(); } catch { /* ignore */ }
}

if (!this._inputActivityWatcher) {
// Mirror the worklet's silence test: AC peak-to-peak above
// 2x the -84 dB amplitude threshold counts as signal.
const acThreshold = 2 * Math.pow(10, -84 / 20);
this._inputActivityWatcher = new InputActivityWatcher(acThreshold);
}
const watching = this._inputActivityWatcher.start(track, () => this.wakeFromSleep());
if (!watching) {
// Lost our only wake-on-input mechanism - resume rather than
// risk staying asleep with no audio path.
this.wakeFromSleep();
return;
}
const mode = io?.directOutputMode ? 'direct'
: io?.audioContextSinkMode ? 'audioContextSink'
: io?.audioElement ? 'mediaElement' : 'default';
console.log(`[AudioManager] sleep power-save active (output mode: ${mode}); output device released`);
}

/**
* Resume from sleep-mode power saving: re-open the output device and
* nudge the worklet awake. Safe to call repeatedly. Driven by the input
* watcher, by user activity, and by window-visibility changes.
*/
async wakeFromSleep() {
if (!this._sleepSuspended) return;
this._sleepSuspended = false;

if (this._inputActivityWatcher) this._inputActivityWatcher.stop();
this.contextManager.setIntentionalSuspend(false);

try {
await this.audioContext.resume();
} catch (e) {
console.warn('[AudioManager] sleep-mode resume failed:', e);
}

const io = this.ioManager;
if (io?.audioElement && !io.audioContextSinkMode && !io.directOutputMode) {
try { await io.audioElement.play(); } catch { /* ignore */ }
}

// Nudge the worklet: clears its cached sleep state, resets the
// inactivity timers, and makes it emit sleepModeChanged:false so the
// UI and analyzer redraw loops resume normally.
if (this.workletNode) {
this.workletNode.port.postMessage({ type: 'userActivity' });
}
console.log('[AudioManager] sleep power-save: woke; output device resumed');
}

/**
* Update properties exposed for backward compatibility
*/
Expand Down Expand Up @@ -413,6 +555,16 @@ export class AudioManager {
* then rebuilds context → worklet → pipeline.
*/
async _doReset(audioPreferences = null) {
// Tear down any sleep-mode power-save state before rebuilding the
// audio graph: the watcher holds a clone of the old input track, and
// a lingering intentional-suspend flag would otherwise suppress
// legitimate auto-resume on the freshly created context.
if (this._sleepSuspended) {
this._sleepSuspended = false;
if (this._inputActivityWatcher) this._inputActivityWatcher.stop();
this.contextManager.setIntentionalSuspend(false);
}

// Clean up audio I/O
this.ioManager.cleanupAudio();

Expand Down
21 changes: 20 additions & 1 deletion js/audio/audio-context-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,25 @@ export class AudioContextManager {
this.silenceGain = null;
this.isFirstLaunch = false;
this._skipAudioInitDuringSampleRateChange = false;

// True while the context is suspended on purpose for sleep-mode
// power saving, so onstatechange knows not to auto-resume it.
this._intentionalSuspend = false;

// Initialize global variable if not already set
if (typeof window.originalConnectMethod === 'undefined') {
window.originalConnectMethod = null;
}
}

/**
* Mark (or clear) an intentional suspend for sleep-mode power saving.
* While set, the onstatechange handler will not auto-resume a
* 'suspended' context, since that suspend was deliberate.
* @param {boolean} value
*/
setIntentionalSuspend(value) {
this._intentionalSuspend = !!value;
}

/**
* Initialize the audio context
Expand Down Expand Up @@ -91,6 +104,12 @@ export class AudioContextManager {
this.audioContext.onstatechange = () => {
const state = this.audioContext?.state;
if (state === 'suspended') {
// A deliberate suspend for sleep-mode power saving must
// not be auto-resumed; AudioManager.wakeFromSleep owns
// the resume in that case.
if (this._intentionalSuspend) {
return;
}
this.audioContext.resume().catch(err =>
console.warn('[AudioContext] resume after suspended failed:', err)
);
Expand Down
7 changes: 7 additions & 0 deletions js/audio/event-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export class EventManager {
* Handle user activity events
*/
handleUserActivity() {
// If the context is suspended for sleep-mode power saving, the
// worklet is frozen and can't act on the message, so wake from the
// main thread (wakeFromSleep re-posts userActivity after resuming).
if (this.audioManager._sleepSuspended) {
this.audioManager.wakeFromSleep();
return;
}
// Notify audio processor about user activity
if (this.audioManager.workletNode) {
this.audioManager.workletNode.port.postMessage({
Expand Down
Loading