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
19 changes: 19 additions & 0 deletions openless-all/app/scripts/audio-cue-listener-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
120 changes: 107 additions & 13 deletions openless-all/app/scripts/audio-cue-runtime.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
Expand All @@ -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 = {};
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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');
Expand All @@ -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);
Expand Down
18 changes: 12 additions & 6 deletions openless-all/app/src/components/AudioCue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -67,7 +70,10 @@ export function AudioCueListener() {
const { listen } = await import('@tauri-apps/api/event');
listen<UserPreferences>('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();
Expand All @@ -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
Expand Down
68 changes: 43 additions & 25 deletions openless-all/app/src/lib/audioCue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ let stopSeq = 0;
const DEFERRED_CUE_LATE_THRESHOLD_MS = 400;
const RESUME_TIMEOUT_MS = 250;
let recoveryTimer: ReturnType<typeof setTimeout> | undefined;
let stopTimer: ReturnType<typeof setTimeout> | undefined;

function clearStopTimer(): void {
clearTimeout(stopTimer);
stopTimer = undefined;
}

function clearRecoveryTimer(): void {
clearTimeout(recoveryTimer);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -255,39 +282,18 @@ function scheduleCueVoices(ctx: AudioContext): void {
} catch {
// noop
}
if (activeVoices.length === 0) onComplete();
};
} catch {
// One tone failing to create/schedule must not affect the rest.
}
}
}

/**
* 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();
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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);
}
};

Expand Down