From 308de1fd4d9c631b6d92d2e998c7eb7664863fc3 Mon Sep 17 00:00:00 2001 From: Ludo Fourrage Date: Mon, 9 Nov 2026 07:57:14 -0800 Subject: [PATCH] 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 --- .changeset/quiet-voice-pauses.md | 2 +- docs/wiki/Input-And-Voice.md | 6 +- docs/wiki/Settings-Reference.md | 5 +- src/web/public/voice-input.js | 30 ++++--- test/agent-skill-setting-ui.test.ts | 2 +- test/settings-admin-only-ui.test.ts | 2 +- test/settings-save-failure-ui.test.ts | 2 +- test/tab-cli-logos-setting.test.ts | 2 +- test/voice-input-results.test.ts | 122 +++++++++++++------------- test/voice-input.browser.test.ts | 18 ++-- test/voice-silence-timeout.test.ts | 46 +++++++++- 11 files changed, 139 insertions(+), 98 deletions(-) diff --git a/.changeset/quiet-voice-pauses.md b/.changeset/quiet-voice-pauses.md index 1730e86a..19a52205 100644 --- a/.changeset/quiet-voice-pauses.md +++ b/.changeset/quiet-voice-pauses.md @@ -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. diff --git a/docs/wiki/Input-And-Voice.md b/docs/wiki/Input-And-Voice.md index 7ad8ba15..d15e7864 100644 --- a/docs/wiki/Input-And-Voice.md +++ b/docs/wiki/Input-And-Voice.md @@ -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 diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e952788..bc6a0ab4 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -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 diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index b33b9a04..517b3b7f 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -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; diff --git a/test/agent-skill-setting-ui.test.ts b/test/agent-skill-setting-ui.test.ts index 05ff2563..0a02f927 100644 --- a/test/agent-skill-setting-ui.test.ts +++ b/test/agent-skill-setting-ui.test.ts @@ -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, diff --git a/test/settings-admin-only-ui.test.ts b/test/settings-admin-only-ui.test.ts index 30bfcc9a..16d1480e 100644 --- a/test/settings-admin-only-ui.test.ts +++ b/test/settings-admin-only-ui.test.ts @@ -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, diff --git a/test/settings-save-failure-ui.test.ts b/test/settings-save-failure-ui.test.ts index f18fd083..0a9a1ea5 100644 --- a/test/settings-save-failure-ui.test.ts +++ b/test/settings-save-failure-ui.test.ts @@ -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'), diff --git a/test/tab-cli-logos-setting.test.ts b/test/tab-cli-logos-setting.test.ts index af5fe73f..488fed36 100644 --- a/test/tab-cli-logos-setting.test.ts +++ b/test/tab-cli-logos-setting.test.ts @@ -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, diff --git a/test/voice-input-results.test.ts b/test/voice-input-results.test.ts index 772b6d3b..49e03c4d 100644 --- a/test/voice-input-results.test.ts +++ b/test/voice-input-results.test.ts @@ -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/); }); diff --git a/test/voice-input.browser.test.ts b/test/voice-input.browser.test.ts index fc3d1718..e3803faa 100644 --- a/test/voice-input.browser.test.ts +++ b/test/voice-input.browser.test.ts @@ -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(); }); diff --git a/test/voice-silence-timeout.test.ts b/test/voice-silence-timeout.test.ts index f68e2283..58d7a683 100644 --- a/test/voice-silence-timeout.test.ts +++ b/test/voice-silence-timeout.test.ts @@ -10,6 +10,8 @@ function load(silenceTimeout?: unknown, overrides: Record = {}) 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', () => {