Files
Codeman/test/voice-silence-timeout.test.ts
T
Ludo FourrageandClaude Opus 5.5 308de1fd4d fix(voice): 30 s and Manual outlast silent Web Speech runs
_onWebSpeechEnd counted every run that ended without a result toward the
two-retry cap, however long the run lasted. Under 30 s or Manual, each
no-speech window of the engine is one silent run, so the third ended the
recording at about 18 s of silence, before the deadline, and Manual
stopped on its own. Both recognition.start() sites now stamp the run start;
an empty run counts toward the cap only when it ended within a second of
starting (a recognizer that cannot listen), and any other run resets it.
The 250 ms backoff is unchanged.

The vm harness in test/voice-silence-timeout.test.ts now passes the test's
Date into the context, whose own Date ignores fake timers.

Also from review: the VoiceInput stubs of the four settings-ui.js tests
carry SILENCE_TIMEOUT_SECONDS; Settings-Reference lists Stop after silence;
the touchscreen Linux fallback leaves out ChromeOS and says a touch Linux
laptop still matches; the dead _webSpeechStream teardown is gone; the two
voice tests use vitest expect instead of node:assert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:57:14 -08:00

395 lines
15 KiB
TypeScript

import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
afterEach(() => vi.useRealTimers());
const source = readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
function load(silenceTimeout?: unknown, overrides: Record<string, unknown> = {}) {
vi.useRealTimers();
vi.useFakeTimers();
const context = vm.createContext({
// The context's own Date ignores fake timers; run lengths must follow them.
Date,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
requestAnimationFrame: () => 1,
cancelAnimationFrame: () => {},
app: { showToast: vi.fn() },
console,
navigator: {},
WebSocket: { OPEN: 1 },
localStorage: { getItem: () => JSON.stringify({ silenceTimeout }) },
...overrides,
});
const providers = vm.runInContext(`${source}\n[DeepgramProvider, ClaudeVoiceProvider, VoiceInput]`, context);
return providers as Array<Record<string, any>>;
}
describe('voice silence timeout', () => {
for (const [index, name, legacy] of [
[0, 'Deepgram', 3000],
[1, 'Claude', 6000],
[2, 'browser', 3000],
] as const) {
it(`${name} keeps its default and accepts longer thinking pauses`, () => {
for (const [setting, delay] of [
[undefined, legacy],
[15, 15000],
[30, 30000],
] as const) {
const provider = load(setting)[index];
provider.isRecording = true;
provider.stop = vi.fn();
provider._resetSilenceTimeout();
vi.advanceTimersByTime(delay - 1);
expect(provider.stop).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(provider.stop).toHaveBeenCalledOnce();
}
});
it(`${name} resets its deadline and cancels it in manual mode`, () => {
const providers = load(15);
const provider = providers[index];
provider.isRecording = true;
provider.stop = vi.fn();
provider._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
provider._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
expect(provider.stop).not.toHaveBeenCalled();
providers[2]._getDeepgramConfig = () => ({ silenceTimeout: 0 });
provider._resetSilenceTimeout();
vi.advanceTimersByTime(120000);
expect(provider.stop).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
}
it.each([null, -1, '15', 1.5, 999])('ignores invalid local values: %s', (setting) => {
const voice = load(setting)[2];
expect(voice.getSilenceTimeoutMs(6000)).toBe(6000);
});
it('does not stop interim dictation at the former 750ms stability deadline', () => {
for (const setting of [0, 3, 15]) {
const voice = load(setting)[2];
voice.isRecording = true;
voice._showPreview = vi.fn();
voice.stop = vi.fn();
voice._onWebSpeechResult({ results: [[{ transcript: 'thinking' }]] });
vi.advanceTimersByTime(750);
expect(voice.stop).not.toHaveBeenCalled();
}
});
it('restarts browser recognition without extending the silence deadline', () => {
const voice = load(15)[2];
voice.isRecording = true;
voice._recordingStartedAt = Date.now() - 1000;
voice.recognition = { start: vi.fn() };
voice.stop = vi.fn();
voice._hasReceivedResult = true;
voice._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
voice._onWebSpeechEnd();
expect(voice.recognition.start).toHaveBeenCalledOnce();
vi.advanceTimersByTime(5000);
expect(voice.stop).toHaveBeenCalledOnce();
});
it('keeps final browser phrases and Safari interim text until an explicit stop', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
for (const method of ['_showPreview', '_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
voice[method] = vi.fn();
voice._insertText = vi.fn();
voice.recognition = { stop: vi.fn() };
const result = [{ transcript: 'first phrase ' }];
Object.assign(result, { isFinal: true });
voice._onWebSpeechResult({ resultIndex: 0, results: [result] });
expect(voice.isRecording).toBe(true);
expect(voice._insertText).not.toHaveBeenCalled();
const interim = [{ transcript: 'thinking aloud' }];
voice._onWebSpeechResult({ resultIndex: 1, results: [result, interim] });
voice.stop();
voice.stop();
expect(voice._insertText).not.toHaveBeenCalled();
voice._onWebSpeechEnd();
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('first phrase thinking aloud');
});
it('preserves mixed final/interim results across browser recognition restarts', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._recordingStartedAt = Date.now() - 1000;
voice._showPreview = vi.fn();
voice.recognition = { start: vi.fn() };
const final = Object.assign([{ transcript: 'first ' }], { isFinal: true });
const interim = [{ transcript: 'second' }];
voice._onWebSpeechResult({ resultIndex: 0, results: [final, interim] });
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second');
voice._onWebSpeechEnd();
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'third' }]] });
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second third');
});
it('ignores repeated stop requests while waiting for Claude final text', () => {
const [_, provider, voice] = load(0);
voice.isRecording = true;
voice._activeProvider = 'claude';
provider._ws = { readyState: 1 };
provider.stop = vi.fn(() => {
provider._finalized = true;
});
voice._insertText = vi.fn();
voice.stop();
voice.stop();
expect(voice._insertText).not.toHaveBeenCalled();
expect(voice.isRecording).toBe(true);
});
it('keeps a corrected final through the configured silence deadline', () => {
const voice = load(3)[2];
voice.isRecording = true;
voice._showPreview = vi.fn();
voice.stop = vi.fn();
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'hello' }]] });
const final = Object.assign([{ transcript: 'hello' }], { isFinal: true });
voice._onWebSpeechResult({ resultIndex: 0, results: [final] });
vi.advanceTimersByTime(750);
expect(voice.stop).not.toHaveBeenCalled();
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('hello');
});
it('round-trips the setting through server validation', () => {
for (const silenceTimeout of [0, 3, 6, 15, 30]) {
expect(SettingsUpdateSchema.parse({ voiceSettings: { silenceTimeout } }).voiceSettings).toEqual({
silenceTimeout,
});
}
expect(SettingsUpdateSchema.safeParse({ voiceSettings: { silenceTimeout: -1 } }).success).toBe(false);
});
});
function claudeSession(setting: number) {
const [, provider, voice] = load(setting);
voice._claudeStatus = { available: true };
for (const method of [
'_showPreview',
'_startDurationTimer',
'_stopDurationTimer',
'_stopLevelMeter',
'_updateButtons',
'_hidePreview',
'_hideVoiceSendBtn',
])
voice[method] = vi.fn();
voice._insertText = vi.fn();
provider.start = vi.fn((opts) => {
provider._finalized = false;
provider._ws = { readyState: 1, send: vi.fn(), close: vi.fn() };
provider._onResult = opts.onResult;
provider._onEnd = opts.onEnd;
provider._resetSilenceTimeout();
});
voice._startClaude();
return { provider, voice };
}
describe('Claude utterances and teardown', () => {
it.each([0, 15])('keeps two final utterances through a thinking pause with setting %s', (setting) => {
const { provider, voice } = claudeSession(setting);
provider._onResult('first phrase', false);
provider._onResult('first phrase', true);
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(2000);
provider._onResult('second phrase', false);
voice.stop();
provider._onResult('second phrase', true);
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('first phrase second phrase');
expect(voice.isRecording).toBe(false);
});
it('salvages partial text and leaves the mic idle on SSE cleanup', () => {
const { provider, voice } = claudeSession(0);
provider._onResult('keep this partial', false);
voice.cleanup();
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('keep this partial');
expect(voice.isRecording).toBe(false);
expect(voice._activeProvider).toBeNull();
expect(provider._ws).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('bounds finalization triggered by the provider silence timer too', () => {
const { provider, voice } = claudeSession(3);
provider._onResult('before silence', false);
vi.advanceTimersByTime(3000);
expect(provider._finalized).toBe(true);
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(5000);
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('before silence');
expect(voice.isRecording).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('bounds a dead-link finalization wait and repeated taps cannot extend it', () => {
const { provider, voice } = claudeSession(0);
provider._onResult('salvage on timeout', false);
voice.stop();
vi.advanceTimersByTime(4000);
voice.stop();
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(1000);
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('salvage on timeout');
expect(voice.isRecording).toBe(false);
expect(provider._ws).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
});
describe('empty browser recognition runs', () => {
it('backs off and stops after repeated instant empty ends instead of a tight restart loop', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
voice.recognition = { start: vi.fn() };
voice.stop = vi.fn();
voice._webSpeechRunStartedAt = Date.now();
voice._onWebSpeechEnd();
expect(voice.recognition.start).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(voice.recognition.start).toHaveBeenCalledTimes(1);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
expect(voice.recognition.start).toHaveBeenCalledTimes(2);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(10000);
expect(voice.recognition.start).toHaveBeenCalledTimes(2);
expect(voice.stop).toHaveBeenCalledWith(true);
});
it('resets the empty-run budget after actual speech', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
voice.recognition = { start: vi.fn() };
voice._showPreview = vi.fn();
voice.stop = vi.fn();
voice._webSpeechRunStartedAt = Date.now();
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
voice._onWebSpeechResult({ results: [[{ transcript: 'a real phrase' }]] });
voice._onWebSpeechEnd();
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
expect(voice.recognition.start).toHaveBeenCalledTimes(4);
expect(voice.stop).not.toHaveBeenCalled();
expect(voice._accumulatedFinal).toBe('a real phrase');
});
it('does not restart the microphone after Stop during an empty-run backoff', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
for (const method of ['_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
voice[method] = vi.fn();
voice.recognition = { start: vi.fn(), stop: vi.fn() };
voice._onWebSpeechEnd();
voice.stop();
vi.advanceTimersByTime(10000);
expect(voice.recognition.start).not.toHaveBeenCalled();
expect(voice.isRecording).toBe(false);
});
function speakThenFallSilent(setting: number) {
const voice = load(setting)[2];
for (const method of ['_showPreview', '_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
voice[method] = vi.fn();
voice._insertText = vi.fn();
voice.recognition = { start: vi.fn(), stop: vi.fn(), abort: vi.fn() };
voice.isRecording = true;
voice._activeProvider = 'webspeech';
voice._webSpeechRunStartedAt = Date.now();
voice._resetSilenceTimeout();
voice._onWebSpeechResult({ results: [Object.assign([{ transcript: 'one phrase' }], { isFinal: true })] });
voice._onWebSpeechEnd();
// Each silent run lasts the engine's whole no-speech window, then backs off.
const silentRun = () => {
vi.advanceTimersByTime(6000);
voice._onWebSpeechEnd();
vi.advanceTimersByTime(250);
};
return { voice, silentRun };
}
it.each([30, 0])('keeps listening through silent 6 s runs with setting %s', (setting) => {
const { voice, silentRun } = speakThenFallSilent(setting);
for (let run = 0; run < 4; run++) silentRun();
expect(voice.isRecording).toBe(true);
expect(voice._retryCount).toBe(0);
expect(voice.recognition.start).toHaveBeenCalledTimes(5);
expect(voice._insertText).not.toHaveBeenCalled();
});
it('ends 30 s dictation at its deadline, not at the third silent run', () => {
const { voice, silentRun } = speakThenFallSilent(30);
for (let run = 0; run < 4; run++) silentRun();
// 25 s of silence so far; the deadline fires at 30 s and waits for the final.
vi.advanceTimersByTime(30000 - 4 * 6250 - 1);
expect(voice.isRecording).toBe(true);
vi.advanceTimersByTime(1);
voice._onWebSpeechEnd();
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('one phrase');
expect(voice.isRecording).toBe(false);
});
});
describe('Claude acquisition cancellation', () => {
function pendingMicrophone() {
let grant!: (value: unknown) => void;
const microphone = new Promise((resolve) => {
grant = resolve;
});
const track = { stop: vi.fn() };
const stream = { getTracks: () => [track] };
const sockets: any[] = [];
class Socket {
static OPEN = 1;
static CONNECTING = 0;
readyState = 0;
close = vi.fn();
constructor() {
sockets.push(this);
}
}
const [, provider] = load(0, {
navigator: { mediaDevices: { getUserMedia: () => microphone } },
URLSearchParams,
WebSocket: Socket,
location: { protocol: 'https:', host: 'fixture.invalid' },
window: {},
});
const onStream = vi.fn();
const onError = vi.fn();
return { provider, grant, track, stream, sockets, onStream, onError };
}
it('releases a microphone granted after cleanup without opening a socket', async () => {
const h = pendingMicrophone();
const started = h.provider.start({ onStream: h.onStream, onError: h.onError });
h.provider._cleanup();
h.grant(h.stream);
await started;
expect(h.track.stop).toHaveBeenCalledOnce();
expect(h.sockets).toHaveLength(0);
expect(h.onStream).not.toHaveBeenCalled();
expect(h.onError).not.toHaveBeenCalled();
expect(h.provider._stream).toBeNull();
});
it('cancels a connecting socket and detaches its capture callback', async () => {
const h = pendingMicrophone();
const started = h.provider.start({ onStream: h.onStream, onError: h.onError });
h.grant(h.stream);
await started;
const socket = h.sockets[0];
expect(socket.onopen).toBeTypeOf('function');
h.provider._cleanup();
expect(socket.onopen).toBeNull();
expect(socket.close).toHaveBeenCalledOnce();
expect(h.track.stop).toHaveBeenCalledOnce();
expect(h.provider._ws).toBeNull();
});
});