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
122 changes: 85 additions & 37 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 All @@ -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;
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/scripts/audio-cue-window-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ const listener = await readFile(new URL('../src/components/AudioCue.tsx', import
assert.match(app, /if \(isCapsule\)[\s\S]*?<AudioCueListener \/>/);
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');
3 changes: 3 additions & 0 deletions openless-all/app/scripts/capsule-ready-replay.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -570,13 +570,15 @@ for (const overlay of [false, true]) {
const bus = eventBus();
let plays = 0;
let stops = 0;
let disposals = 0;
const scope = {
isTauri: true,
audioCueRuntimeEnabled: true,
prevStateRef: { current: 'idle' },
audioCueEnabledRef: { current: true },
playRecordStartCue: () => plays++,
stopAudioCue: () => stops++,
disposeAudioCue: () => disposals++,
require(name) {
if (name === '@tauri-apps/api/webviewWindow')
return { getCurrentWebviewWindow: () => ({ label: 'main' }) };
Expand All @@ -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(
Expand Down
28 changes: 13 additions & 15 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 } 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 All @@ -17,21 +17,23 @@ interface CapsulePayload {
}

export function AudioCueListener() {
const audioCueEnabledRef = useRef<boolean>(true);
const audioCueEnabledRef = useRef(false);
const audioCuePreferenceChangedRef = useRef(false);
const prevStateRef = useRef<CapsuleState>('idle' as CapsuleState);
const audioCueRuntimeEnabled = !isAndroid();

// Read settings (enabled by default)
// Read settings before playing cues.
useEffect(() => {
if (!isTauri || !audioCueRuntimeEnabled) return;
let cancelled = false;
(async () => {
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 () => {
Expand All @@ -48,7 +50,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 (cancelled || !next) return;
audioCuePreferenceChangedRef.current = true;
audioCueEnabledRef.current = next.audioCueOnRecord !== false;
if (!audioCueEnabledRef.current) disposeAudioCue();
})
.then((fn) => {
if (cancelled) fn();
Expand All @@ -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;
Expand All @@ -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<CapsulePayload>(
Expand All @@ -107,7 +105,7 @@ export function AudioCueListener() {
return () => {
cancelled = true;
unlisten?.();
stopAudioCue();
disposeAudioCue();
};
}, [audioCueRuntimeEnabled]);

Expand Down
Loading