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);
}
};