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>
This commit is contained in:
Ludo FourrageandClaude Opus 5.5 committed 2026-11-09 07:57:14 -08:00
1 parent 2d7cd46565
commit 308de1fd4d
11 files changed
+139 -98

No files matched your search

+1 -1
View File
@@ -6,4 +6,4 @@ Add a configurable voice silence timeout and manual-stop dictation so thinking p
Provider-default Deepgram and Web Speech final events now wait for the 3-second silence deadline instead of stopping immediately. Claude keeps recording across utterance endpoints and preserves final words after Stop, with a bounded wait on failed connections.
Fix repeated words and lost final transcripts on Chrome for Android, animate browser speech activity without a competing microphone capture, and preserve partial text when reconnecting. Bound repeated empty browser recognition restarts.
Fix repeated words and lost final transcripts on Chrome for Android, animate browser speech activity without a competing microphone capture, and preserve partial text when reconnecting. Bound restarts of a browser recognizer that keeps ending instantly without a result, while silent runs of normal length keep 30-second and Manual dictation going.
+4 -2
View File
@@ -87,8 +87,10 @@ Speech. Final transcript events no longer stop Deepgram or Web Speech immediatel
they wait for the configured silence deadline. The timeout counts from the latest
transcript update. A browser can still end its native recognition run; extended
and Manual dictation restart it while preserving the text and silence deadline.
Connection failures, microphone errors, or repeated empty recognition runs can
still end a recording.
A silent run that lasts its full length never ends one, so 30 seconds and Manual
survive long pauses. Connection failures, microphone errors, or a recognizer that
keeps ending within a second of starting without hearing anything can still end
a recording.
On Chrome for Android, live words replace earlier partial results without repeating
them in the input. The browser microphone bars indicate speech activity; streaming
+3 -2
View File
@@ -247,8 +247,9 @@ Discord group for the two-way bot (token, IDs, thread and ping options; see
### Voice
Active provider and the engine behind it, insert mode, language, domain keywords to bias
recognition, the Deepgram API key, and the opt-in switch for transcribing through this
server's Claude login, with its live credential status. See
recognition, **Stop after silence** (provider default, 3, 6, 15 or 30 seconds, or Manual),
the Deepgram API key, and the opt-in switch for transcribing through this server's Claude
login, with its live credential status. See
[Input And Voice](Input-And-Voice).
### Shortcuts
+16 -14
View File
@@ -573,6 +573,7 @@ const ClaudeVoiceProvider = {
*/
const VoiceInput = {
SILENCE_TIMEOUT_SECONDS: Object.freeze([0, 3, 6, 15, 30]),
WEB_SPEECH_INSTANT_END_MS: 1000, // an empty run shorter than this counts toward the retry cap
recognition: null,
isRecording: false,
supported: false,
@@ -589,8 +590,9 @@ const VoiceInput = {
_webSpeechRestartTimer: null,
_activeProvider: null, // 'claude' | 'deepgram' | 'webspeech' | null
_recordingStartedAt: 0, // timestamp when recording started
_retryCount: 0, // consecutive empty Web Speech API runs
_retryCount: 0, // consecutive Web Speech runs that ended empty right after starting
_hasReceivedResult: false, // whether this native recognition run received speech
_webSpeechRunStartedAt: 0, // when the current native recognition run was started
_durationInterval: null, // timer for updating elapsed time display
_analyser: null, // AudioContext analyser for level meter
_analyserSource: null, // MediaStreamSource for level meter
@@ -700,9 +702,13 @@ const VoiceInput = {
// Single-utterance mode keeps refinements provisional; onend resumes the mic
// while our own silence deadline controls the overall dictation session.
// Android tablets in desktop-site mode may identify as touch-capable Linux.
// ChromeOS also reports Linux but says CrOS, so it keeps continuous mode.
// A Linux laptop with a touchscreen still matches; single-utterance works
// there too, with one native restart per phrase.
const ua = navigator.userAgent || '';
this._webSpeechSingleUtterance = navigator.userAgentData?.platform === 'Android'
|| /Android/i.test(navigator.userAgent || '')
|| (navigator.maxTouchPoints > 0 && /Linux/i.test(navigator.platform || ''));
|| /Android/i.test(ua)
|| (navigator.maxTouchPoints > 0 && /Linux/i.test(navigator.platform || '') && !/CrOS/.test(ua));
this.recognition.continuous = !this._webSpeechSingleUtterance;
this.recognition.interimResults = true;
this.recognition.lang = 'en-US';
@@ -884,6 +890,7 @@ const VoiceInput = {
this._showPreview('Listening...');
this._startDurationTimer();
try {
this._webSpeechRunStartedAt = Date.now();
this.recognition.start();
} catch (e) {
// InvalidStateError = already started — ignore. Other errors = genuine failure.
@@ -950,12 +957,6 @@ const VoiceInput = {
ClaudeVoiceProvider._cleanup();
} else if (this._activeProvider === 'deepgram') {
DeepgramProvider.stop();
} else if (this._activeProvider === 'webspeech') {
// Stop the mic stream we opened for the level meter
if (this._webSpeechStream) {
this._webSpeechStream.getTracks().forEach(t => t.stop());
this._webSpeechStream = null;
}
}
this._activeProvider = null;
@@ -1030,8 +1031,12 @@ const VoiceInput = {
return;
}
// Only a run that ends empty right after start() counts toward the cap: that
// is a recognizer that cannot listen. A silent run of normal length is the
// engine's own no-speech window, and 30 s or Manual must outlast many of them.
const empty = !this._hasReceivedResult;
this._retryCount = empty ? this._retryCount + 1 : 0;
const instant = empty && Date.now() - this._webSpeechRunStartedAt < this.WEB_SPEECH_INSTANT_END_MS;
this._retryCount = instant ? this._retryCount + 1 : 0;
if (this._retryCount > 2) {
this.stop(true);
return;
@@ -1048,6 +1053,7 @@ const VoiceInput = {
this._webSpeechRestartTimer = null;
if (!this.isRecording || this._webSpeechStopping) return;
this._hasReceivedResult = false;
this._webSpeechRunStartedAt = Date.now();
try { this.recognition.start(); } catch (_e) { this.stop(true); }
};
// A broken or unavailable recognizer must not spin forever in Manual mode.
@@ -1420,10 +1426,6 @@ const VoiceInput = {
this._activeProvider = null;
this._stopDurationTimer();
this._stopLevelMeter();
if (this._webSpeechStream) {
this._webSpeechStream.getTracks().forEach(t => t.stop());
this._webSpeechStream = null;
}
if (this.previewEl) {
this.previewEl.remove();
this.previewEl = null;
+1 -1
View File
@@ -96,7 +96,7 @@ async function boot(stored?: Settings) {
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
+1 -1
View File
@@ -146,7 +146,7 @@ async function boot(who: Who, stored: Settings, server: Settings | null) {
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
+1 -1
View File
@@ -159,7 +159,7 @@ async function boot(
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
read('constants.js'),
+1 -1
View File
@@ -125,7 +125,7 @@ async function boot(device: Device = 'desktop', stored?: Settings) {
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
+59 -63
View File
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { test } from 'vitest';
import { expect, 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 });
@@ -139,33 +138,33 @@ test('Android growing partial phrases and final insert exactly five words', () =
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);
expect(h.inserted).toEqual(['one two three four five']);
expect(h.mic).toBe(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);
expect(h.voice.recognition.continuous).toBe(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);
expect(h.voice.recognition.continuous).toBe(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']);
expect(h.inserted).toEqual(['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');
expect(h.previews.at(-1)).toBe('keep this correct');
h.event([result('keep this')], 1);
h.finish();
assert.deepEqual(h.inserted, ['keep this']);
expect(h.inserted).toEqual(['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`, () => {
@@ -173,41 +172,41 @@ for (const delay of [0, 3, 6, 15, 30])
h.voice._startWebSpeech();
h.voice.recognition.final('go go');
h.voice._onWebSpeechEnd();
assert.equal(h.starts, 2);
expect(h.starts).toBe(2);
h.voice.recognition.partial('go go');
h.voice.recognition.final('go go');
h.finish();
assert.deepEqual(h.inserted, ['go go go go']);
expect(h.inserted).toEqual(['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');
expect(h.bars.filter((b) => b.active).length).toBe(0);
expect(typeof h.voice.recognition.onsoundstart).toBe('function');
h.voice.recognition.onsoundstart();
h.frame();
const count = h.bars.filter((b) => b.active).length;
assert.ok(count > 0);
expect(count).toBeGreaterThan(0);
h.frame(300);
assert.notEqual(h.bars.filter((b) => b.active).length, count);
expect(h.bars.filter((b) => b.active).length).not.toBe(count);
h.voice.recognition.onsoundend();
h.frame();
assert.equal(h.bars.filter((b) => b.active).length, 0);
expect(h.bars.filter((b) => b.active).length).toBe(0);
h.voice.recognition.onspeechstart();
h.frame();
assert.ok(h.bars.some((b) => b.active));
expect(h.bars.some((b) => b.active)).toBe(true);
h.finish();
assert.equal(h.frames.size, 0);
assert.equal(h.mic, 0);
expect(h.frames.size).toBe(0);
expect(h.mic).toBe(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));
expect(h.bars.some((b) => b.active)).toBe(true);
h.frame(1000);
assert.equal(h.bars.filter((b) => b.active).length, 0);
expect(h.bars.filter((b) => b.active).length).toBe(0);
h.finish();
});
test('a new dictation starts with no words from the previous dictation', () => {
@@ -218,30 +217,27 @@ test('a new dictation starts with no words from the previous dictation', () => {
h.voice._startWebSpeech();
h.voice.recognition.final('second');
h.finish();
assert.deepEqual(h.inserted, ['first', 'second']);
expect(h.inserted).toEqual(['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]
);
expect([...h.timers.values()].map((t) => t.ms)).toEqual([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);
expect(h.starts).toBe(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']);
expect(h.inserted).toEqual(['five words spoken very clearly']);
});
function stopHarness() {
@@ -326,23 +322,23 @@ test('browser recognition owns the microphone without a competing cosmetic recor
const h = stopHarness();
h.voice._startWebSpeech();
await Promise.resolve();
assert.equal(h.starts, 1);
assert.equal(h.micRequests, 0);
expect(h.starts).toBe(1);
expect(h.micRequests).toBe(0);
stopResult(h.voice, 'live Android words', false);
assert.equal(h.previews.at(-1), 'live Android words');
expect(h.previews.at(-1)).toBe('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, []);
expect(h.stops).toBe(1);
expect(h.inserted).toEqual([]);
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);
expect(h.inserted).toEqual(['words arriving after stop']);
expect(h.voice.isRecording).toBe(false);
expect(h.starts).toBe(1);
});
test('repeated Stop does not finalize early or restart recognition', () => {
const h = stopHarness();
@@ -351,9 +347,9 @@ test('repeated Stop does not finalize early or restart recognition', () => {
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);
expect(h.inserted).toEqual(['last phrase']);
expect(h.stops).toBe(1);
expect(h.starts).toBe(1);
});
test('stop timeout preserves interim words if the browser never ends', () => {
const h = stopHarness();
@@ -361,12 +357,12 @@ test('stop timeout preserves interim words if the browser never ends', () => {
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');
expect(timeout, 'bounded wait for the browser final result').toBeTruthy();
timeout.fn();
assert.deepEqual(h.inserted, ['interim words']);
assert.equal(h.voice.isRecording, false);
expect(h.inserted).toEqual(['interim words']);
expect(h.voice.isRecording).toBe(false);
stopResult(h.voice, 'too late');
assert.deepEqual(h.inserted, ['interim words']);
expect(h.inserted).toEqual(['interim words']);
});
test('manual thinking pauses still restart natural recognition ends and retain words', () => {
const h = stopHarness();
@@ -376,8 +372,8 @@ test('manual thinking pauses still restart natural recognition ends and retain w
stopResult(h.voice, 'second phrase');
h.voice.stop();
h.voice._onWebSpeechEnd();
assert.deepEqual(h.inserted, ['first phrase second phrase']);
assert.equal(h.starts, 2);
expect(h.inserted).toEqual(['first phrase second phrase']);
expect(h.starts).toBe(2);
});
test('Claude still accepts its final transcript after finalize', () => {
const h = stopHarness();
@@ -387,11 +383,11 @@ test('Claude still accepts its final transcript after finalize', () => {
let finalized = 0;
h.claude.stop = () => finalized++;
h.voice.stop();
assert.equal(h.voice.isRecording, true);
expect(h.voice.isRecording).toBe(true);
h.voice._accumulatedFinal = 'Claude final';
h.voice.stop(true);
assert.deepEqual(h.inserted, ['Claude final']);
assert.ok(finalized >= 1);
expect(h.inserted).toEqual(['Claude final']);
expect(finalized).toBeGreaterThanOrEqual(1);
});
test('failed start ends immediately with an error instead of waiting for a final result', () => {
@@ -400,9 +396,9 @@ test('failed start ends immediately with an error instead of waiting for a final
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);
expect(h.voice.isRecording).toBe(false);
expect(h.toasts[0]).toMatch(/fixture failure/);
expect(h.timers.size).toBe(0);
});
test('failed stop still inserts available words and clears the finishing timer', () => {
const h = stopHarness();
@@ -412,9 +408,9 @@ test('failed stop still inserts available words and clears the finishing timer',
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);
expect(h.inserted).toEqual(['existing words']);
expect(h.voice.isRecording).toBe(false);
expect(h.timers.size).toBe(0);
});
test('cleanup during finishing releases recognition and cannot insert a second result', () => {
const h = stopHarness();
@@ -422,12 +418,12 @@ test('cleanup during finishing releases recognition and cannot insert a second r
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);
expect(h.voice.isRecording).toBe(false);
expect(h.timers.size).toBe(0);
stopResult(h.voice, 'stale result');
h.voice._onWebSpeechEnd();
assert.deepEqual(h.inserted, ['keep these words']);
assert.equal(h.voice.recognition, null);
expect(h.inserted).toEqual(['keep these words']);
expect(h.voice.recognition).toBe(null);
});
test('fatal browser error ends immediately without retrying or dropping existing words', () => {
const h = stopHarness();
@@ -435,9 +431,9 @@ test('fatal browser error ends immediately without retrying or dropping existing
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/);
expect(h.voice.isRecording).toBe(false);
expect(h.inserted).toEqual(['existing words']);
expect(h.starts).toBe(1);
expect(h.timers.size).toBe(0);
expect(h.toasts[0]).toMatch(/internet/);
});
+7 -11
View File
@@ -1,8 +1,7 @@
// 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';
import { afterAll, beforeAll, expect, 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;
@@ -46,7 +45,7 @@ it.each([
`\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');
expect(await page.evaluate(() => (window as any).voice.recognition.continuous)).toBe(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();
@@ -54,7 +53,7 @@ it.each([
await page.waitForTimeout(90);
counts.add(await page.locator('.voice-level-bar.active').count());
}
assert.ok(counts.size > 1, 'speech bars must animate');
expect(counts.size, 'speech bars must animate').toBeGreaterThan(1);
await page.evaluate(() => (window as any).voice.recognition.onsoundend());
await page.waitForFunction(() => document.querySelectorAll('.voice-level-bar.active').length === 0);
await page.evaluate(() => {
@@ -63,13 +62,10 @@ it.each([
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'
);
expect(await page.evaluate(() => (window as any).voice._previewTextNode.textContent)).toBe('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);
expect(await page.evaluate(() => (window as any).voice.isRecording)).toBe(false);
await page.click('#mic');
await page.evaluate(() => {
const voice = (window as any).voice;
@@ -79,7 +75,7 @@ it.each([
});
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, []);
expect(await page.evaluate(() => (window as any).micRequests)).toBe(0);
expect(errors).toEqual([]);
await page.close();
});
+45 -1
View File
@@ -10,6 +10,8 @@ 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,
@@ -241,12 +243,13 @@ describe('Claude utterances and teardown', () => {
});
describe('empty browser recognition runs', () => {
it('backs off and stops after repeated empty ends instead of a tight restart loop', () => {
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);
@@ -266,6 +269,7 @@ describe('empty browser recognition runs', () => {
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' }]] });
@@ -291,6 +295,46 @@ describe('empty browser recognition runs', () => {
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', () => {