fix(voice): preserve Android transcripts and Claude utterances

This commit is contained in:
Ludo Fourrage committed 2026-11-09 07:27:02 -08:00
1 parent 0cedeb2657
commit 2d7cd46565
11 files changed
+930 -139

No files matched your search

+443
View File
@@ -0,0 +1,443 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { test } from 'vitest';
const source = fs.readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
function result(text, final = true) {
return Object.assign([{ transcript: text }], { isFinal: final });
}
function harness({
platform = 'Android',
ua = 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0',
touch = 5,
delay = 0,
} = {}) {
let now = 1000,
next = 0,
starts = 0,
mic = 0;
const timers = new Map(),
frames = new Map(),
inserted = [],
previews = [],
attrs = {};
const bars = Array.from({ length: 5 }, () => ({
active: false,
classList: {
toggle(_name, on) {
this.owner.active = on;
},
},
}));
for (const bar of bars) bar.classList.owner = bar;
const meter = {
children: bars,
setAttribute(k, v) {
attrs[k] = v;
},
};
class Recognition {
start() {
starts++;
this.results = [];
}
stop() {}
abort() {}
// Chromium Android promotes every native partial into a new final when continuous=true.
partial(text) {
if (this.continuous) this.results.push(result(text));
else this.results = [result(text, false)];
this.onresult({ resultIndex: this.continuous ? this.results.length - 1 : 0, results: this.results });
}
final(text) {
if (this.continuous) this.results.push(result(text));
else this.results = [result(text)];
this.onresult({ resultIndex: this.continuous ? this.results.length - 1 : 0, results: this.results });
}
}
const context = vm.createContext({
console,
window: { SpeechRecognition: Recognition },
navigator: {
userAgent: ua,
platform: platform === 'Windows' ? 'Win32' : 'Linux aarch64',
userAgentData: { platform },
maxTouchPoints: touch,
mediaDevices: {
getUserMedia() {
mic++;
throw Error('second microphone capture');
},
},
},
localStorage: { getItem: () => JSON.stringify({ silenceTimeout: delay }) },
app: { showToast() {} },
WebSocket: { OPEN: 1 },
Date: class extends Date {
static now() {
return now;
}
},
setTimeout(fn, ms) {
timers.set(++next, { fn, ms });
return next;
},
clearTimeout: (id) => timers.delete(id),
clearInterval() {},
requestAnimationFrame(fn) {
frames.set(++next, fn);
return next;
},
cancelAnimationFrame: (id) => frames.delete(id),
});
const voice = vm.runInContext(source + '\nVoiceInput', context);
for (const name of [
'_updateButtons',
'_startDurationTimer',
'_stopDurationTimer',
'_hidePreview',
'_hideVoiceSendBtn',
])
voice[name] = () => {};
voice.previewEl = { querySelector: () => meter, remove() {} };
voice._showPreview = (text) => previews.push(text);
voice._insertText = (text) => inserted.push(text);
voice._initRecognition();
return {
voice,
inserted,
previews,
bars,
attrs,
timers,
frames,
get starts() {
return starts;
},
get mic() {
return mic;
},
frame(delta = 120) {
now += delta;
const jobs = [...frames.values()];
frames.clear();
for (const fn of jobs) fn(now);
},
event(results, index = 0) {
voice._onWebSpeechResult({ results, resultIndex: index });
},
finish() {
voice.stop();
voice._onWebSpeechEnd();
},
};
}
test('Android growing partial phrases and final insert exactly five words', () => {
const h = harness();
h.voice._startWebSpeech();
for (const phrase of ['one', 'one two', 'one two three', 'one two three four', 'one two three four five'])
h.voice.recognition.partial(phrase);
h.voice.recognition.final('one two three four five');
h.finish();
assert.deepEqual(h.inserted, ['one two three four five']);
assert.equal(h.mic, 0);
});
test('Android desktop-site mode also avoids continuous partial-to-final promotion', () => {
const h = harness({ platform: 'Linux', ua: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0' });
assert.equal(h.voice.recognition.continuous, false);
});
test('Windows keeps continuous recognition and repeated snapshots do not append twice', () => {
const h = harness({ platform: 'Windows', ua: 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0' });
h.voice._startWebSpeech();
assert.equal(h.voice.recognition.continuous, true);
h.event([result('yes yes'), result('do it', false)], 0);
h.event([result('yes yes'), result('do it', false)], 0);
h.event([result('yes yes'), result('do it')], 1);
h.event([result('yes yes'), result('do it')], 0);
h.finish();
assert.deepEqual(h.inserted, ['yes yes do it']);
});
test('an interim correction replaces the previous text, including removed interim results', () => {
const h = harness();
h.voice._startWebSpeech();
h.event([result('keep this'), result('wrong', false)]);
h.event([result('keep this'), result('correct', false)], 1);
assert.equal(h.previews.at(-1), 'keep this correct');
h.event([result('keep this')], 1);
h.finish();
assert.deepEqual(h.inserted, ['keep this']);
});
for (const delay of [0, 3, 6, 15, 30])
test(`Android preserves intentional repeated phrases across native ends with ${delay}s silence setting`, () => {
const h = harness({ delay });
h.voice._startWebSpeech();
h.voice.recognition.final('go go');
h.voice._onWebSpeechEnd();
assert.equal(h.starts, 2);
h.voice.recognition.partial('go go');
h.voice.recognition.final('go go');
h.finish();
assert.deepEqual(h.inserted, ['go go go go']);
});
test('speech events animate bars, silence clears them, and Stop cancels animation', () => {
const h = harness();
h.voice._startWebSpeech();
assert.equal(h.bars.filter((b) => b.active).length, 0);
assert.equal(typeof h.voice.recognition.onsoundstart, 'function');
h.voice.recognition.onsoundstart();
h.frame();
const count = h.bars.filter((b) => b.active).length;
assert.ok(count > 0);
h.frame(300);
assert.notEqual(h.bars.filter((b) => b.active).length, count);
h.voice.recognition.onsoundend();
h.frame();
assert.equal(h.bars.filter((b) => b.active).length, 0);
h.voice.recognition.onspeechstart();
h.frame();
assert.ok(h.bars.some((b) => b.active));
h.finish();
assert.equal(h.frames.size, 0);
assert.equal(h.mic, 0);
});
test('engines without sound events briefly animate on transcript updates', () => {
const h = harness();
h.voice._startWebSpeech();
h.voice.recognition.partial('hello');
h.frame();
assert.ok(h.bars.some((b) => b.active));
h.frame(1000);
assert.equal(h.bars.filter((b) => b.active).length, 0);
h.finish();
});
test('a new dictation starts with no words from the previous dictation', () => {
const h = harness();
h.voice._startWebSpeech();
h.voice.recognition.final('first');
h.finish();
h.voice._startWebSpeech();
h.voice.recognition.final('second');
h.finish();
assert.deepEqual(h.inserted, ['first', 'second']);
});
test('default 3-second silence deadline does not stop Android after a 750ms stable interim', () => {
const h = harness({ delay: 3 });
h.voice._startWebSpeech();
h.voice.recognition.partial('five words spoken very clearly');
assert.deepEqual(
[...h.timers.values()].map((t) => t.ms),
[3000]
);
});
test('interim-only native end cannot be reinserted by an old stability callback', () => {
const h = harness({ delay: 3 });
h.voice._startWebSpeech();
h.voice.recognition.partial('five words spoken very clearly');
h.voice._onWebSpeechEnd();
assert.equal(h.starts, 2);
// Execute the callbacks that were queued before the native end, in deadline order.
const pending = [...h.timers.entries()].sort((a, b) => a[1].ms - b[1].ms);
for (const [id, timer] of pending) {
if (h.timers.delete(id)) timer.fn();
}
h.voice._onWebSpeechEnd();
assert.deepEqual(h.inserted, ['five words spoken very clearly']);
});
function stopHarness() {
const timers = new Map(),
inserted = [],
previews = [],
toasts = [];
let next = 0,
micRequests = 0,
stops = 0,
starts = 0;
const context = vm.createContext({
console,
app: { showToast: (text) => toasts.push(text) },
navigator: {
userAgent: 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0',
mediaDevices: {
getUserMedia: async () => {
micRequests++;
return { getTracks: () => [] };
},
},
},
localStorage: { getItem: () => JSON.stringify({ silenceTimeout: 0 }) },
WebSocket: { OPEN: 1 },
setTimeout: (fn, ms) => {
timers.set(++next, { fn, ms });
return next;
},
clearTimeout: (id) => timers.delete(id),
clearInterval() {},
requestAnimationFrame() {
return 1;
},
cancelAnimationFrame() {},
});
const [voice, claude] = vm.runInContext(source + '\n[VoiceInput,ClaudeVoiceProvider]', context);
voice.recognition = {
start() {
starts++;
},
stop() {
stops++;
},
abort() {},
};
voice.supported = true;
for (const name of [
'_updateButtons',
'_startDurationTimer',
'_stopDurationTimer',
'_startLevelMeter',
'_stopLevelMeter',
'_hidePreview',
'_hideVoiceSendBtn',
])
voice[name] = () => {};
voice._showPreview = (text) => previews.push(text);
voice._insertText = (text) => inserted.push(text);
return {
voice,
claude,
timers,
inserted,
previews,
toasts,
get micRequests() {
return micRequests;
},
get stops() {
return stops;
},
get starts() {
return starts;
},
};
}
function stopResult(voice, text, final = true) {
voice._onWebSpeechResult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal: final })] });
}
test('browser recognition owns the microphone without a competing cosmetic recorder', async () => {
const h = stopHarness();
h.voice._startWebSpeech();
await Promise.resolve();
assert.equal(h.starts, 1);
assert.equal(h.micRequests, 0);
stopResult(h.voice, 'live Android words', false);
assert.equal(h.previews.at(-1), 'live Android words');
});
test('tap Stop accepts a delayed final transcript and inserts once on end', () => {
const h = stopHarness();
h.voice._startWebSpeech();
h.voice.stop();
assert.equal(h.stops, 1);
assert.deepEqual(h.inserted, []);
stopResult(h.voice, 'words arriving after stop');
h.voice._onWebSpeechEnd();
h.voice._onWebSpeechEnd();
assert.deepEqual(h.inserted, ['words arriving after stop']);
assert.equal(h.voice.isRecording, false);
assert.equal(h.starts, 1);
});
test('repeated Stop does not finalize early or restart recognition', () => {
const h = stopHarness();
h.voice._startWebSpeech();
h.voice.stop();
h.voice.stop();
stopResult(h.voice, 'last phrase');
h.voice._onWebSpeechEnd();
assert.deepEqual(h.inserted, ['last phrase']);
assert.equal(h.stops, 1);
assert.equal(h.starts, 1);
});
test('stop timeout preserves interim words if the browser never ends', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'interim words', false);
h.voice.stop();
const timeout = [...h.timers.values()].find((t) => t.ms === 3000);
assert.ok(timeout, 'bounded wait for the browser final result');
timeout.fn();
assert.deepEqual(h.inserted, ['interim words']);
assert.equal(h.voice.isRecording, false);
stopResult(h.voice, 'too late');
assert.deepEqual(h.inserted, ['interim words']);
});
test('manual thinking pauses still restart natural recognition ends and retain words', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'first phrase', false);
h.voice._onWebSpeechEnd();
stopResult(h.voice, 'second phrase');
h.voice.stop();
h.voice._onWebSpeechEnd();
assert.deepEqual(h.inserted, ['first phrase second phrase']);
assert.equal(h.starts, 2);
});
test('Claude still accepts its final transcript after finalize', () => {
const h = stopHarness();
h.voice.isRecording = true;
h.voice._activeProvider = 'claude';
h.claude._ws = { readyState: 1 };
let finalized = 0;
h.claude.stop = () => finalized++;
h.voice.stop();
assert.equal(h.voice.isRecording, true);
h.voice._accumulatedFinal = 'Claude final';
h.voice.stop(true);
assert.deepEqual(h.inserted, ['Claude final']);
assert.ok(finalized >= 1);
});
test('failed start ends immediately with an error instead of waiting for a final result', () => {
const h = stopHarness();
h.voice.recognition.start = () => {
throw new Error('fixture failure');
};
h.voice._startWebSpeech();
assert.equal(h.voice.isRecording, false);
assert.match(h.toasts[0], /fixture failure/);
assert.equal(h.timers.size, 0);
});
test('failed stop still inserts available words and clears the finishing timer', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'existing words', false);
h.voice.recognition.stop = () => {
throw new Error('already stopped');
};
h.voice.stop();
assert.deepEqual(h.inserted, ['existing words']);
assert.equal(h.voice.isRecording, false);
assert.equal(h.timers.size, 0);
});
test('cleanup during finishing releases recognition and cannot insert a second result', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'keep these words', false);
h.voice.stop();
h.voice.cleanup();
assert.equal(h.voice.isRecording, false);
assert.equal(h.timers.size, 0);
stopResult(h.voice, 'stale result');
h.voice._onWebSpeechEnd();
assert.deepEqual(h.inserted, ['keep these words']);
assert.equal(h.voice.recognition, null);
});
test('fatal browser error ends immediately without retrying or dropping existing words', () => {
const h = stopHarness();
h.voice._startWebSpeech();
stopResult(h.voice, 'existing words', false);
h.voice._onWebSpeechError({ error: 'network' });
h.voice._onWebSpeechEnd();
assert.equal(h.voice.isRecording, false);
assert.deepEqual(h.inserted, ['existing words']);
assert.equal(h.starts, 1);
assert.equal(h.timers.size, 0);
assert.match(h.toasts[0], /internet/);
});
+85
View File
@@ -0,0 +1,85 @@
// Real Chromium rendering/input; native Android/Windows recognition is simulated.
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { chromium, type Browser } from 'playwright';
import { beforeAll, afterAll, it } from 'vitest';
const source = readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8');
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser?.close();
});
it.each([
[390, 'Android', 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0'],
[820, 'Linux', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0'],
[1292, 'Windows', 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0'],
] as const)('dictation at %ipx (%s)', async (width, platform, ua) => {
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: true, userAgent: ua });
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setContent(
'<button id="mic" style="position:fixed;top:20px;left:20px">Microphone</button><div class="voice-compose-overlay"><textarea class="paste-textarea" aria-label="Transcript"></textarea></div>'
);
await page.addStyleTag({ content: styles });
await page.addScriptTag({
content: `const platform = ${JSON.stringify(platform)};
Object.defineProperty(navigator,'userAgentData',{value:{platform},configurable:true});
Object.defineProperty(navigator,'platform',{value:platform==='Windows'?'Win32':'Linux x86_64',configurable:true});
window.micRequests=0;
Object.defineProperty(navigator,'mediaDevices',{value:{getUserMedia(){window.micRequests++;return Promise.reject(Error('Unexpected second microphone'));}},configurable:true});
window.SpeechRecognition=class{
start(){this.results=[];}
partial(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:platform!=='Windows'&&this.continuous});if(r.isFinal)this.results.push(r);else this.results=[r];this.onresult({resultIndex:r.isFinal?this.results.length-1:0,results:this.results});}
final(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:true});if(platform!=='Windows'&&this.continuous)this.results.push(r);else this.results=[r];this.onresult({resultIndex:0,results:this.results});}
stop(){setTimeout(()=>{this.final(this.text);this.onend();},50);}
abort(){}
};
window.app={activeSessionId:'fixture',showToast(){}};
`,
});
await page.addScriptTag({
content:
source +
`\nwindow.voice=VoiceInput;VoiceInput._getDeepgramConfig=()=>({provider:'webspeech',insertMode:'compose',silenceTimeout:0});document.getElementById('mic').onclick=()=>VoiceInput.toggle();`,
});
await page.click('#mic');
assert.equal(await page.evaluate(() => (window as any).voice.recognition.continuous), platform === 'Windows');
await page.evaluate(() => (window as any).voice.recognition.onsoundstart());
await page.waitForFunction(() => document.querySelectorAll('.voice-level-bar.active').length > 0);
const counts = new Set();
for (let i = 0; i < 8; i++) {
await page.waitForTimeout(90);
counts.add(await page.locator('.voice-level-bar.active').count());
}
assert.ok(counts.size > 1, 'speech bars must animate');
await page.evaluate(() => (window as any).voice.recognition.onsoundend());
await page.waitForFunction(() => document.querySelectorAll('.voice-level-bar.active').length === 0);
await page.evaluate(() => {
const voice = (window as any).voice;
for (const text of ['one', 'one two', 'one two three', 'one two three four', 'one two three four five'])
voice.recognition.partial(text);
voice.recognition.final('one two three four five');
});
assert.equal(
await page.evaluate(() => (window as any).voice._previewTextNode.textContent),
'one two three four five'
);
await page.click('#mic');
await page.waitForFunction(() => document.querySelector('textarea')?.value === 'one two three four five');
assert.equal(await page.evaluate(() => (window as any).voice.isRecording), false);
await page.click('#mic');
await page.evaluate(() => {
const voice = (window as any).voice;
voice.recognition.final('go go');
voice.recognition.onend();
voice.recognition.partial('go go');
});
await page.click('#mic');
await page.waitForFunction(() => document.querySelector('textarea')?.value === 'go go go go');
assert.equal(await page.evaluate(() => (window as any).micRequests), 0);
assert.deepEqual(errors, []);
await page.close();
});
+205 -8
View File
@@ -1,19 +1,27 @@
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
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) {
function load(silenceTimeout?: unknown, overrides: Record<string, unknown> = {}) {
vi.useRealTimers();
vi.useFakeTimers();
const context = vm.createContext({
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>>;
@@ -62,12 +70,15 @@ describe('voice silence timeout', () => {
const voice = load(setting)[2];
expect(voice.getSilenceTimeoutMs(6000)).toBe(6000);
});
it('does not let the iOS stability shortcut stop extended/manual dictation', () => {
for (const setting of [0, 15]) {
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._iosStabilityCheck('thinking');
expect(vi.getTimerCount()).toBe(0);
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', () => {
@@ -76,6 +87,7 @@ describe('voice silence timeout', () => {
voice._recordingStartedAt = Date.now() - 1000;
voice.recognition = { start: vi.fn() };
voice.stop = vi.fn();
voice._hasReceivedResult = true;
voice._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
voice._onWebSpeechEnd();
@@ -97,9 +109,11 @@ describe('voice silence timeout', () => {
expect(voice.isRecording).toBe(true);
expect(voice._insertText).not.toHaveBeenCalled();
const interim = [{ transcript: 'thinking aloud' }];
voice._onWebSpeechResult({ resultIndex: 0, results: [interim] });
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', () => {
@@ -130,7 +144,7 @@ describe('voice silence timeout', () => {
expect(voice._insertText).not.toHaveBeenCalled();
expect(voice.isRecording).toBe(true);
});
it('cancels Safari stability text when a final result supersedes it', () => {
it('keeps a corrected final through the configured silence deadline', () => {
const voice = load(3)[2];
voice.isRecording = true;
voice._showPreview = vi.fn();
@@ -151,3 +165,186 @@ describe('voice silence timeout', () => {
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 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._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._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);
});
});
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();
});
});