mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
1 parent
2d7cd46565
commit
308de1fd4d
11 files changed
+139
-98
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user