diff --git a/openless-all/app/scripts/audio-cue-listener-contract.test.mjs b/openless-all/app/scripts/audio-cue-listener-contract.test.mjs new file mode 100644 index 000000000..319d18c27 --- /dev/null +++ b/openless-all/app/scripts/audio-cue-listener-contract.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const source = await readFile(new URL('../src/components/AudioCue.tsx', import.meta.url), 'utf8'); + +// The always-alive capsule listener must never open output just by mounting, even when +// the persisted preference is disabled. Runtime playback/recovery is covered separately. +assert.doesNotMatch(source, /primeAudioCue/); +assert.equal((source.match(/playRecordStartCue\(\)/g) ?? []).length, 1); +assert.match(source, /if \(audioCueEnabledRef\.current\) playRecordStartCue\(\)/); +assert.match(source, /state === 'recording' && prev !== 'recording'/); +assert.match(source, /!isAndroid\(\) && nativeCueOwner === false/); +assert.equal( + (source.match(/if \(!audioCueEnabledRef\.current\) disposeAudioCue\(\)/g) ?? []).length, + 2, + 'both initial settings and live preference changes must dispose disabled audio', +); +assert.match(source, /return \(\) => disposeAudioCue\(\)/); +console.log('[audioCue.listener.test] lazy startup, preference and ownership contracts passed'); diff --git a/openless-all/app/scripts/audio-cue-runtime.test.mjs b/openless-all/app/scripts/audio-cue-runtime.test.mjs index f854901b2..3472ba603 100644 --- a/openless-all/app/scripts/audio-cue-runtime.test.mjs +++ b/openless-all/app/scripts/audio-cue-runtime.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { mock } from 'node:test'; import { tsImport } from 'tsx/esm/api'; -const { playRecordStartCue, stopAudioCue } = await tsImport( +const { disposeAudioCue, playRecordStartCue, stopAudioCue } = await tsImport( '../src/lib/audioCue.ts', import.meta.url, ); @@ -21,6 +21,7 @@ class FakeContext { this.state = FakeContext.initialState; this.frozen = false; this.voices = 0; + this.oscillators = []; this.closeCalls = 0; this.resumeImpl = FakeContext.initialResumeImpl; this.destination = {}; @@ -36,13 +37,15 @@ class FakeContext { } createOscillator() { this.voices++; - return { + const oscillator = { frequency: { setValueAtTime() {} }, connect: (gain) => gain, start() {}, stop() {}, disconnect() {}, }; + this.oscillators.push(oscillator); + return oscillator; } createGain() { return { @@ -80,7 +83,6 @@ try { releaseOldResume(); await Promise.resolve(); assert.equal(stuck.voices, 0, 'late resolution cannot play through a discarded context'); - tick(315); // Every request invalidates old work, including a new synchronous playback. healthy.state = 'suspended'; let releaseSuperseded; @@ -91,14 +93,12 @@ try { releaseSuperseded(); await Promise.resolve(); assert.equal(healthy.voices, 4, 'a superseded resume must not duplicate the newer cue'); - tick(315); healthy.frozen = true; playRecordStartCue(); tick(315); const recovered = FakeContext.instances[2]; assert.equal(healthy.closeCalls, 1, 'running with a frozen clock must recover'); assert.equal(recovered.voices, 2); - tick(315); // The original stop/time boundary survives recreation, so a late retry stays silent. recovered.state = 'suspended'; playRecordStartCue(); @@ -109,7 +109,6 @@ try { playRecordStartCue(); const next = FakeContext.instances[3]; assert.equal(next.voices, 2, 'the next recording still works after a dropped cue'); - tick(315); next.state = 'suspended'; next.resumeImpl = () => { throw new Error('audio interrupted'); @@ -132,21 +131,116 @@ try { releaseRetry(); await Promise.resolve(); assert.equal(retry.voices, 0, 'a recreated context cannot reset the late-cue deadline'); + assert.equal(retry.closeCalls, 1, 'a late successful resume must release its silent output'); + FakeContext.initialState = 'running'; playRecordStartCue(); - assert.equal(retry.voices, 2, 'dropping the old cue must not disable the next recording'); - tick(315); + const fresh = FakeContext.instances[7]; + assert.equal(fresh.voices, 2, 'dropping the old cue must not disable the next recording'); - retry.state = 'suspended'; - retry.resumeImpl = () => Promise.reject(new Error('device changed')); + fresh.state = 'suspended'; + fresh.resumeImpl = () => Promise.reject(new Error('device changed')); FakeContext.initialState = 'running'; playRecordStartCue(); await Promise.resolve(); - assert.equal(retry.closeCalls, 1, 'asynchronous resume rejection also releases the context'); - assert.equal(FakeContext.instances[7].voices, 2, 'device-change recovery plays only one cue'); + assert.equal(fresh.closeCalls, 1, 'asynchronous resume rejection also releases the context'); + const finalCue = FakeContext.instances[8]; + assert.equal(finalCue.voices, 2, 'device-change recovery plays only one cue'); tick(315); + assert.equal(finalCue.closeCalls, 1, 'natural cue completion releases the output device'); + + // No startup priming: import/disposal does not create a context. Every new preview or + // recording can still play after the previous context was closed. + const idleCount = FakeContext.instances.length; + disposeAudioCue(); + tick(1000); + assert.equal(FakeContext.instances.length, idleCount, 'idle cleanup never creates audio'); + playRecordStartCue(); + const stopped = FakeContext.instances.at(-1); + assert.equal(stopped.voices, 2); + stopAudioCue(); + tick(30); + assert.equal(stopped.closeCalls, 1, 'stopping a playing cue releases output after its ramp'); + + playRecordStartCue(); + const rapid = FakeContext.instances.at(-1); + stopAudioCue(); + tick(10); + playRecordStartCue(); + tick(20); + assert.equal(rapid.closeCalls, 0, 'old stop cleanup cannot close a newer cue'); + tick(295); + assert.equal(rapid.closeCalls, 1, 'the newer cue also releases output when finished'); + + // Disabling the preference / unmounting is stronger than a recording stop: a pending + // resume must never revive audio or start a recovery retry after disposal. + let releaseDisposed; + FakeContext.initialState = 'suspended'; + FakeContext.initialResumeImpl = () => new Promise((resolve) => (releaseDisposed = resolve)); + playRecordStartCue(); + const disposed = FakeContext.instances.at(-1); + disposeAudioCue(); + const disposedCount = FakeContext.instances.length; + disposed.state = 'running'; + releaseDisposed(); + await Promise.resolve(); + tick(1000); + assert.equal(disposed.closeCalls, 1); + assert.equal(disposed.voices, 0, 'disabled/unmounted listeners cannot play a late cue'); + assert.equal(FakeContext.instances.length, disposedCount, 'disposal cancels recovery timers'); + + // A quick recording still gets feedback when resume settles within the original deadline. + let releaseQuick; + FakeContext.initialResumeImpl = () => new Promise((resolve) => (releaseQuick = resolve)); + playRecordStartCue(); + const quick = FakeContext.instances.at(-1); + stopAudioCue(); + tick(100); + quick.state = 'running'; + releaseQuick(); + await Promise.resolve(); + assert.equal(quick.voices, 2, 'quick recordings keep start feedback without startup priming'); + tick(315); + assert.equal(quick.closeCalls, 1); + + FakeContext.initialState = 'running'; + playRecordStartCue(); + const ended = FakeContext.instances.at(-1); + ended.oscillators[0].onended(); + assert.equal(ended.closeCalls, 0, 'one ending tone cannot cut off the remaining tone'); + ended.oscillators[1].onended(); + assert.equal(ended.closeCalls, 1, 'the final tone releases output without waiting for a timer'); + + playRecordStartCue(); + const superseded = FakeContext.instances.at(-1); + const oldVoices = [...superseded.oscillators]; + playRecordStartCue(); + for (const voice of oldVoices) voice.onended(); + assert.equal(superseded.closeCalls, 0, 'old onended callbacks cannot close a newer cue'); + tick(315); + assert.equal(superseded.closeCalls, 1); + + playRecordStartCue(); + const disabledWhilePlaying = FakeContext.instances.at(-1); + disposeAudioCue(); + assert.equal(disabledWhilePlaying.closeCalls, 1, 'disabling an active cue releases immediately'); + tick(1000); + assert.equal(disabledWhilePlaying.closeCalls, 1, 'stale timers cannot dispose twice'); + + const originalCreateOscillator = FakeContext.prototype.createOscillator; + FakeContext.prototype.createOscillator = () => { + throw new Error('audio node creation failed'); + }; + try { + assert.doesNotThrow(playRecordStartCue); + const failedSynthesis = FakeContext.instances.at(-1); + tick(315); + assert.equal(failedSynthesis.closeCalls, 1, 'failed synthesis cannot leak a silent output'); + } finally { + FakeContext.prototype.createOscillator = originalCreateOscillator; + } console.log('[audioCue.runtime.test] playback recovery assertions passed'); } finally { - stopAudioCue(); + disposeAudioCue(); mock.restoreAll(); mock.timers.reset(); if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx index e8ca5a425..5de5084b9 100644 --- a/openless-all/app/src/components/AudioCue.tsx +++ b/openless-all/app/src/components/AudioCue.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import { isAndroid, isTauri } from '../lib/ipc'; -import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue'; +import { disposeAudioCue, playRecordStartCue, stopAudioCue } from '../lib/audioCue'; import type { CapsuleState, UserPreferences } from '../lib/types'; interface CapsulePayload { @@ -48,7 +48,10 @@ export function AudioCueListener() { try { const { getSettings } = await import('../lib/ipc'); const prefs = await getSettings(); - if (!cancelled) audioCueEnabledRef.current = prefs.audioCueOnRecord !== false; + if (!cancelled) { + audioCueEnabledRef.current = prefs.audioCueOnRecord !== false; + if (!audioCueEnabledRef.current) disposeAudioCue(); + } } catch { // Read failure: keep the default true } @@ -67,7 +70,10 @@ export function AudioCueListener() { const { listen } = await import('@tauri-apps/api/event'); listen('prefs:changed', (event) => { const next = event.payload; - if (next) audioCueEnabledRef.current = next.audioCueOnRecord !== false; + if (next) { + audioCueEnabledRef.current = next.audioCueOnRecord !== false; + if (!audioCueEnabledRef.current) disposeAudioCue(); + } }) .then((fn) => { if (cancelled) fn(); @@ -81,10 +87,10 @@ export function AudioCueListener() { }; }, [audioCueRuntimeEnabled]); - // Prime the AudioContext + // Do not prime on mount: even a silent running context holds the output device open. + // Dispose pending resumes as well as active voices when the owner changes or unmounts. useEffect(() => { - if (!isTauri || !audioCueRuntimeEnabled) return; - primeAudioCue(); + return () => disposeAudioCue(); }, [audioCueRuntimeEnabled]); // Listen for capsule state edges diff --git a/openless-all/app/src/lib/audioCue.ts b/openless-all/app/src/lib/audioCue.ts index 967fd6a34..f3787a415 100644 --- a/openless-all/app/src/lib/audioCue.ts +++ b/openless-all/app/src/lib/audioCue.ts @@ -69,6 +69,12 @@ let stopSeq = 0; const DEFERRED_CUE_LATE_THRESHOLD_MS = 400; const RESUME_TIMEOUT_MS = 250; let recoveryTimer: ReturnType | undefined; +let stopTimer: ReturnType | undefined; + +function clearStopTimer(): void { + clearTimeout(stopTimer); + stopTimer = undefined; +} function clearRecoveryTimer(): void { clearTimeout(recoveryTimer); @@ -217,13 +223,34 @@ function stopVoices(): void { /** Close/stop the cue: stop playing nodes and mark "a stop happened meanwhile" so pending resume callbacks can judge lateness. */ export function stopAudioCue(): void { stopSeq++; + const ctx = sharedCtx; + const wasPlaying = activeVoices.length > 0; stopVoices(); + // Allow the short release ramp to finish. A pending resume keeps its existing bounded + // recovery window so quick recordings still receive their start feedback. + if (ctx && wasPlaying) { + clearRecoveryTimer(); + clearStopTimer(); + const myPlay = playSeq; + stopTimer = setTimeout(() => { + if (myPlay === playSeq && sharedCtx === ctx) discardContext(ctx); + }, 30); + } +} + +/** Disable/unmount: cancel even pending resumes, without creating an audio context. */ +export function disposeAudioCue(): void { + playSeq++; + stopSeq++; + clearRecoveryTimer(); + clearStopTimer(); + if (sharedCtx) discardContext(sharedCtx); } // Actually schedule the synthesis nodes. Must be called while the AudioContext is running // (not suspended): when suspended, currentTime is frozen at the pause moment, so nodes would // be scheduled at stale timestamps -> silent and piling up. -function scheduleCueVoices(ctx: AudioContext): void { +function scheduleCueVoices(ctx: AudioContext, onComplete: () => void): void { // On rapid hotkey presses, stop the previous round first to keep overlap from building up. // Use stopVoices, not stopAudioCue: this must not invalidate its own generation. stopVoices(); @@ -255,6 +282,7 @@ function scheduleCueVoices(ctx: AudioContext): void { } catch { // noop } + if (activeVoices.length === 0) onComplete(); }; } catch { // One tone failing to create/schedule must not affect the rest. @@ -262,32 +290,10 @@ function scheduleCueVoices(ctx: AudioContext): void { } } -/** - * Prime the AudioContext: create and resume early so the ctx is already running when recording - * actually starts and playRecordStartCue can schedule synchronously, bypassing the - * suspended->resume async race — the root cause of lost cues on quick recordings. - * Note: under a strict autoplay policy a resume without a user gesture may be rejected - * (silently degraded), in which case priming does nothing and the playSeq/stopSeq + late - * threshold fallback in playRecordStartCue applies. Tauri WebViews are usually lenient about - * the first resume, so priming typically lets the common path take the synchronous branch. - * Call once when the capsule window mounts. - */ -export function primeAudioCue(): void { - // getContext already rebuilds a closed ctx; here we only need to wake suspended/interrupted. - const ctx = getContext(); - if (!ctx) return; - if (audioContextActionForState(ctx.state) === 'resume') { - try { - void ctx.resume().catch(() => undefined); - } catch { - discardContext(ctx); - } - } -} - /** Play the "recording started" cue. Silently degrades when Web Audio is unavailable or the context is suspended and cannot be resumed. */ export function playRecordStartCue(): void { clearRecoveryTimer(); + clearStopTimer(); const myPlay = ++playSeq; const stopAtRequest = stopSeq; const requestedAt = nowMs(); @@ -318,12 +324,21 @@ function playRecordStartCueOnce( }; const schedule = () => { const startedAt = ctx.currentTime; - scheduleCueVoices(ctx); + scheduleCueVoices(ctx, () => { + if (myPlay !== playSeq || sharedCtx !== ctx) return; + clearRecoveryTimer(); + discardContext(ctx); + }); // Some audio interruptions still report running while the audio clock is frozen; // proactively retire the ctx before the next keypress. recoveryTimer = setTimeout( () => { + if (myPlay !== playSeq || sharedCtx !== ctx) return; if (ctx.currentTime <= startedAt) recover(); + // A connected, silent AudioContext still holds the system output device (and on + // macOS prevents idle sleep). Close after the cue tail and clock-health check, + // including when onended is delayed or synthesis failed. Next play is lazy. + else discardContext(ctx); }, cueTotalDurationMs(recordStartCueTones()) + 50, ); @@ -351,6 +366,9 @@ function playRecordStartCueOnce( } else if (!runningAfterResume) { // Even if this round is already late, clear the bad context so the next recording can recover. recover(); + } else { + // A resumed but already-late cue must not leave a silent output stream alive. + discardContext(ctx); } };