From 078202c66c85cee979db012d2a576636d72b7b00 Mon Sep 17 00:00:00 2001 From: Theigrams Date: Tue, 10 Nov 2026 21:59:46 +0800 Subject: [PATCH] fix(audio): release Web Audio output after recording cues --- .../app/scripts/audio-cue-runtime.test.mjs | 122 ++++++++++++------ .../audio-cue-window-contract.test.mjs | 2 + .../app/scripts/capsule-ready-replay.test.mjs | 3 + openless-all/app/src/components/AudioCue.tsx | 28 ++-- openless-all/app/src/lib/audioCue.ts | 81 ++++++------ 5 files changed, 139 insertions(+), 97 deletions(-) diff --git a/openless-all/app/scripts/audio-cue-runtime.test.mjs b/openless-all/app/scripts/audio-cue-runtime.test.mjs index f854901b2..5490358d8 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 { @@ -60,27 +63,53 @@ FakeContext.instances = []; FakeContext.initialState = 'running'; FakeContext.initialResumeImpl = () => new Promise(() => {}); const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); +const latestContext = () => FakeContext.instances.at(-1); Object.defineProperty(globalThis, 'window', { configurable: true, value: { AudioContext: FakeContext }, }); try { + stopAudioCue(); + disposeAudioCue(); + assert.equal(FakeContext.instances.length, 0, 'idle cleanup never opens audio output'); + + playRecordStartCue(); + const natural = latestContext(); + natural.oscillators[0].onended(); + assert.equal(natural.closeCalls, 0, 'keep the output open for the second tone'); + natural.oscillators[1].onended(); + tick(315); + assert.equal(natural.closeCalls, 1, 'the last tone closes output and cancels the fallback'); + + playRecordStartCue(); + const stopped = latestContext(); + const oldOscillators = [...stopped.oscillators]; + stopAudioCue(); + tick(10); + playRecordStartCue(); + assert.equal(latestContext(), stopped, 'a quick new cue can reuse the releasing context'); + for (const oscillator of oldOscillators) oscillator.onended(); + tick(30); + assert.equal(stopped.closeCalls, 0, 'old callbacks cannot close a newer cue'); + stopAudioCue(); + tick(30); + assert.equal(stopped.closeCalls, 1, 'ending recording releases the output after the tail'); + FakeContext.initialState = 'suspended'; playRecordStartCue(); - const stuck = FakeContext.instances[0]; + const stuck = latestContext(); let releaseOldResume; stuck.resumeImpl = () => new Promise((resolve) => (releaseOldResume = resolve)); playRecordStartCue(); FakeContext.initialState = 'running'; tick(250); - const healthy = FakeContext.instances[1]; + const healthy = latestContext(); assert.equal(stuck.closeCalls, 1, 'a hung resume must close the old context'); assert.equal(healthy.voices, 2, 'the same recording recovers its cue'); stuck.state = 'running'; 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; @@ -92,61 +121,80 @@ try { await Promise.resolve(); assert.equal(healthy.voices, 4, 'a superseded resume must not duplicate the newer cue'); tick(315); - healthy.frozen = true; + assert.equal(healthy.closeCalls, 1, 'missing onended still releases the output'); + playRecordStartCue(); + const frozen = latestContext(); + frozen.frozen = true; tick(315); - const recovered = FakeContext.instances[2]; - assert.equal(healthy.closeCalls, 1, 'running with a frozen clock must recover'); + const recovered = latestContext(); + assert.equal(frozen.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(); - stopAudioCue(); - tick(1000); - assert.equal(recovered.closeCalls, 1, 'even a dropped late cue discards its broken context'); - assert.equal(FakeContext.instances.length, 3, 'stopped recordings do not replay late'); - 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'); - }; - FakeContext.initialState = 'suspended'; - assert.doesNotThrow(playRecordStartCue); - tick(250); - assert.equal(FakeContext.instances.length, 5, 'recovery retries at most once per request'); - assert.equal(FakeContext.instances[4].closeCalls, 1, 'failed retry releases audio resources'); // The 400ms deadline belongs to the recording, not to each resume attempt. + FakeContext.initialState = 'suspended'; + FakeContext.initialResumeImpl = () => new Promise(() => {}); playRecordStartCue(); + const beforeRetry = latestContext(); let releaseRetry; FakeContext.initialResumeImpl = () => new Promise((resolve) => (releaseRetry = resolve)); tick(250); - const retry = FakeContext.instances[6]; + const retry = latestContext(); + assert.notEqual(retry, beforeRetry); stopAudioCue(); tick(151); retry.state = 'running'; 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 still releases the output'); + + let releaseQuick; + FakeContext.initialResumeImpl = () => new Promise((resolve) => (releaseQuick = resolve)); playRecordStartCue(); - assert.equal(retry.voices, 2, 'dropping the old cue must not disable the next recording'); + const quick = latestContext(); + stopAudioCue(); + tick(50); + quick.state = 'running'; + releaseQuick(); + await Promise.resolve(); + assert.equal(quick.voices, 2, 'a quick recording keeps its deferred feedback'); tick(315); - retry.state = 'suspended'; - retry.resumeImpl = () => Promise.reject(new Error('device changed')); - FakeContext.initialState = 'running'; playRecordStartCue(); + const disabled = latestContext(); + const beforeDisable = FakeContext.instances.length; + disposeAudioCue(); + assert.equal(disabled.closeCalls, 1, 'disabling closes a pending context immediately'); + disabled.state = 'running'; + releaseQuick(); + await Promise.resolve(); + tick(500); + assert.equal(disabled.voices, 0, 'a late resume cannot play after disable'); + assert.equal(disabled.state, 'closed', 'a late resume cannot reopen the discarded output'); + assert.equal(FakeContext.instances.length, beforeDisable, 'disable also cancels recovery'); + + FakeContext.initialResumeImpl = () => Promise.reject(new Error('device changed')); + playRecordStartCue(); + const rejected = latestContext(); + FakeContext.initialState = 'running'; 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(rejected.closeCalls, 1, 'asynchronous resume rejection releases the context'); + assert.equal(latestContext().voices, 2, 'device-change recovery plays one cue'); tick(315); - console.log('[audioCue.runtime.test] playback recovery assertions passed'); + + FakeContext.initialState = 'suspended'; + FakeContext.initialResumeImpl = () => { + throw new Error('audio interrupted'); + }; + const beforeFailure = FakeContext.instances.length; + assert.doesNotThrow(playRecordStartCue); + assert.equal(FakeContext.instances.length, beforeFailure + 2, 'retry at most once per request'); + assert.equal(latestContext().closeCalls, 1, 'failed retry releases audio resources'); + console.log('[audioCue.runtime.test] playback recovery and output release assertions passed'); } finally { - stopAudioCue(); + disposeAudioCue(); mock.restoreAll(); mock.timers.reset(); if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); diff --git a/openless-all/app/scripts/audio-cue-window-contract.test.mjs b/openless-all/app/scripts/audio-cue-window-contract.test.mjs index a69e810f4..ad5328aaa 100644 --- a/openless-all/app/scripts/audio-cue-window-contract.test.mjs +++ b/openless-all/app/scripts/audio-cue-window-contract.test.mjs @@ -11,5 +11,7 @@ const listener = await readFile(new URL('../src/components/AudioCue.tsx', import assert.match(app, /if \(isCapsule\)[\s\S]*?/); assert.doesNotMatch(shell, /AudioCueListener/); assert.match(listener, /currentWindow\.label !== 'capsule'/); +assert.doesNotMatch(listener, /primeAudioCue/); +assert.match(listener, /if \(!audioCueEnabledRef\.current\) disposeAudioCue\(\);/); assert.match(listener, /stopAudioCue\(\);/); console.log('audio cue is scoped to the resident capsule window'); diff --git a/openless-all/app/scripts/capsule-ready-replay.test.mjs b/openless-all/app/scripts/capsule-ready-replay.test.mjs index cb89accf5..05a4d9cf0 100644 --- a/openless-all/app/scripts/capsule-ready-replay.test.mjs +++ b/openless-all/app/scripts/capsule-ready-replay.test.mjs @@ -570,6 +570,7 @@ for (const overlay of [false, true]) { const bus = eventBus(); let plays = 0; let stops = 0; + let disposals = 0; const scope = { isTauri: true, audioCueRuntimeEnabled: true, @@ -577,6 +578,7 @@ for (const overlay of [false, true]) { audioCueEnabledRef: { current: true }, playRecordStartCue: () => plays++, stopAudioCue: () => stops++, + disposeAudioCue: () => disposals++, require(name) { if (name === '@tauri-apps/api/webviewWindow') return { getCurrentWebviewWindow: () => ({ label: 'main' }) }; @@ -593,6 +595,7 @@ for (const overlay of [false, true]) { assert.equal(plays, 0, 'opening the main window must not play the recording cue'); assert.equal(stops, 0); cleanup(); + assert.equal(disposals, 1, 'unmount must dispose pending audio work'); assert.equal(bus.size, 0); } console.log( diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx index 3ed1c3bf5..5fd969ea0 100644 --- a/openless-all/app/src/components/AudioCue.tsx +++ b/openless-all/app/src/components/AudioCue.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef } 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 { @@ -17,11 +17,12 @@ interface CapsulePayload { } export function AudioCueListener() { - const audioCueEnabledRef = useRef(true); + const audioCueEnabledRef = useRef(false); + const audioCuePreferenceChangedRef = useRef(false); const prevStateRef = useRef('idle' as CapsuleState); const audioCueRuntimeEnabled = !isAndroid(); - // Read settings (enabled by default) + // Read settings before playing cues. useEffect(() => { if (!isTauri || !audioCueRuntimeEnabled) return; let cancelled = false; @@ -29,9 +30,10 @@ export function AudioCueListener() { try { const { getSettings } = await import('../lib/ipc'); const prefs = await getSettings(); - if (!cancelled) audioCueEnabledRef.current = prefs.audioCueOnRecord !== false; + if (cancelled || audioCuePreferenceChangedRef.current) return; + audioCueEnabledRef.current = prefs.audioCueOnRecord !== false; } catch { - // Read failure: keep the default true + // Leave the cue off until the preference is known. } })(); return () => { @@ -48,7 +50,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 (cancelled || !next) return; + audioCuePreferenceChangedRef.current = true; + audioCueEnabledRef.current = next.audioCueOnRecord !== false; + if (!audioCueEnabledRef.current) disposeAudioCue(); }) .then((fn) => { if (cancelled) fn(); @@ -62,12 +67,6 @@ export function AudioCueListener() { }; }, [audioCueRuntimeEnabled]); - // Prime the AudioContext - useEffect(() => { - if (!isTauri || !audioCueRuntimeEnabled) return; - primeAudioCue(); - }, [audioCueRuntimeEnabled]); - // Listen for capsule state edges useEffect(() => { if (!isTauri || !audioCueRuntimeEnabled) return; @@ -79,8 +78,7 @@ export function AudioCueListener() { if (cancelled) return; const currentWindow = getCurrentWebviewWindow(); // The cue belongs to the resident recording capsule only. The main window must not - // prime an AudioContext or replay the current recording edge when it is opened during - // an active session. + // replay the current recording edge when it is opened during an active session. if (currentWindow.label !== 'capsule') return; const target = { kind: 'WebviewWindow' as const, label: currentWindow.label }; listen( @@ -107,7 +105,7 @@ export function AudioCueListener() { return () => { cancelled = true; unlisten?.(); - stopAudioCue(); + disposeAudioCue(); }; }, [audioCueRuntimeEnabled]); diff --git a/openless-all/app/src/lib/audioCue.ts b/openless-all/app/src/lib/audioCue.ts index 967fd6a34..e40c62d51 100644 --- a/openless-all/app/src/lib/audioCue.ts +++ b/openless-all/app/src/lib/audioCue.ts @@ -1,9 +1,6 @@ // Record start cue: synthesizes a short rising two-tone with the Web Audio API on the fly; // no audio files are bundled. -// Two operations: -// - playRecordStartCue() play (called when the record hotkey is pressed and recording starts) -// - stopAudioCue() close/stop (called on leaving recording, or on rapid hotkey presses -// to avoid overlapping audio) +// Create an AudioContext only when a cue is requested, and release it when the cue ends. // // Triggered in the capsule window (always alive, receives capsule:state events); the settings // page "preview" reuses the same code. @@ -68,6 +65,7 @@ let stopSeq = 0; // gets feedback. const DEFERRED_CUE_LATE_THRESHOLD_MS = 400; const RESUME_TIMEOUT_MS = 250; +const STOP_RELEASE_MS = 30; let recoveryTimer: ReturnType | undefined; function clearRecoveryTimer(): void { @@ -181,11 +179,10 @@ function getContext(): AudioContext | null { return sharedCtx; } -// Discard an unwakeable / dead AudioContext: clear the module singleton (the next getContext -// rebuilds a fresh one) and its attached activeVoices, and best-effort close to release the -// underlying audio resources. Ignore if already closed or close fails. +// Clear the current context and its pending work before closing the system audio output. function discardContext(ctx: AudioContext): void { if (sharedCtx === ctx) { + clearRecoveryTimer(); stopVoices(); sharedCtx = null; } @@ -214,20 +211,26 @@ function stopVoices(): void { activeVoices = []; } -/** Close/stop the cue: stop playing nodes and mark "a stop happened meanwhile" so pending resume callbacks can judge lateness. */ +/** End active playback, while allowing an in-time deferred cue for a quick recording. */ export function stopAudioCue(): void { stopSeq++; + const ctx = sharedCtx; + if (!ctx || activeVoices.length === 0) return; stopVoices(); + clearRecoveryTimer(); + recoveryTimer = setTimeout(() => discardContext(ctx), STOP_RELEASE_MS); +} + +/** Cancel even a pending cue when the listener is disabled or unmounted. */ +export function disposeAudioCue(): void { + playSeq++; + 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 { - // 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(); - +function scheduleCueVoices(ctx: AudioContext, onComplete: () => void): void { const base = ctx.currentTime + 0.01; for (const tone of recordStartCueTones()) { try { @@ -248,13 +251,16 @@ function scheduleCueVoices(ctx: AudioContext): void { const voice: ActiveVoice = { osc, gain }; activeVoices.push(voice); osc.onended = () => { - activeVoices = activeVoices.filter((v) => v !== voice); try { osc.disconnect(); gain.disconnect(); } catch { // noop } + // A previous cue's callback can arrive after stop or after a new cue starts. + if (!activeVoices.includes(voice)) return; + activeVoices = activeVoices.filter((v) => v !== voice); + if (activeVoices.length === 0) onComplete(); }; } catch { // One tone failing to create/schedule must not affect the rest. @@ -262,32 +268,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(); + stopVoices(); const myPlay = ++playSeq; const stopAtRequest = stopSeq; const requestedAt = nowMs(); @@ -318,12 +302,13 @@ function playRecordStartCueOnce( }; const schedule = () => { const startedAt = ctx.currentTime; - scheduleCueVoices(ctx); - // Some audio interruptions still report running while the audio clock is frozen; - // proactively retire the ctx before the next keypress. + scheduleCueVoices(ctx, () => discardContext(ctx)); + // If onended never fires, still release the output. A frozen clock means playback + // never started, so recover once while the cue is still due. recoveryTimer = setTimeout( () => { if (ctx.currentTime <= startedAt) recover(); + else discardContext(ctx); }, cueTotalDurationMs(recordStartCueTones()) + 50, ); @@ -334,11 +319,16 @@ function playRecordStartCueOnce( return; } - // resume may never settle. A timeout also enters the recovery path; a late Promise must not - // touch the old context again. + // resume may never settle. A timeout also enters the recovery path. let settled = false; const settle = (runningAfterResume: boolean): void => { - if (settled || myPlay !== playSeq || sharedCtx !== ctx) return; + if (sharedCtx !== ctx) { + // WebKit can report a late resume after close() completed. Close that context again + // without touching the current cue or scheduling playback. + if (runningAfterResume) discardContext(ctx); + return; + } + if (settled || myPlay !== playSeq) return; settled = true; clearRecoveryTimer(); const action = cueActionAfterResume({ @@ -348,9 +338,10 @@ function playRecordStartCueOnce( }); if (action === 'schedule') { schedule(); - } else if (!runningAfterResume) { - // Even if this round is already late, clear the bad context so the next recording can recover. + } else if (action === 'recreate-retry') { recover(); + } else { + discardContext(ctx); } };