fix(voice): make dictation silence timeout configurable

This commit is contained in:
Ludo Fourrage committed 2026-11-09 07:26:44 -08:00
1 parent f62c1f7e2f
commit 0cedeb2657
7 files changed
+260 -34

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
Add a configurable voice silence timeout and manual-stop dictation so thinking pauses do not cut off recording.
+8
View File
@@ -78,6 +78,14 @@ sequences, so word jumps and history keys keep working.
| **Deepgram** | A Deepgram API key. | Nova-3, with automatic silence detection. |
| **Web Speech** | Nothing. | Browser-provided, quality varies. |
### Thinking pauses
In **App Settings → Voice → Stop after silence**, choose 3, 6, 15 or 30 seconds,
or **Manual** to keep listening until you tap the mic again (`Ctrl+Shift+V`).
**Provider default** preserves the existing timeout: 6 seconds for Claude and
3 seconds for Deepgram or Web Speech. The timeout counts from the latest transcript
update. Connection failures or microphone errors can still end a recording.
### Dictating through your Claude login
Off by default; enable it in **App Settings → Voice**.
+14
View File
@@ -3456,6 +3456,20 @@
<option value="compose">Compose dialog</option>
</select>
</div>
<div class="set-row has-field" data-search="voice silence pause timeout manual stop recording">
<div class="set-row-text">
<label class="set-row-label" for="voiceSilenceTimeout">Stop after silence</label>
<span class="set-row-desc">Allow time to think between phrases. Manual keeps listening until you tap the mic again.</span>
</div>
<select id="voiceSilenceTimeout" class="set-select">
<option value="default">Provider default</option>
<option value="3">3 seconds</option>
<option value="6">6 seconds</option>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="0">Manual</option>
</select>
</div>
<div class="set-row has-field" data-search="voice language">
<div class="set-row-text">
<span class="set-row-label">Language</span>
+4
View File
@@ -810,6 +810,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || DEFAULT_VOICE_KEYTERMS;
document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct';
document.getElementById('voiceProvider').value = voiceCfg.provider || 'auto';
document.getElementById('voiceSilenceTimeout').value = [0, 3, 6, 15, 30].includes(voiceCfg.silenceTimeout)
? String(voiceCfg.silenceTimeout) : 'default';
document.getElementById('appSettingsClaudeVoice').checked = settings.claudeVoiceEnabled ?? false;
// Reset key visibility to hidden
const keyInput = document.getElementById('voiceDeepgramKey');
@@ -3297,6 +3299,8 @@ Object.assign(CodemanApp.prototype, {
language: document.getElementById('voiceLanguage').value,
keyterms: document.getElementById('voiceKeyterms').value.trim(),
insertMode: document.getElementById('voiceInsertMode').value,
silenceTimeout: document.getElementById('voiceSilenceTimeout').value === 'default'
? undefined : Number(document.getElementById('voiceSilenceTimeout').value),
};
VoiceInput._saveDeepgramConfig(voiceSettings);
+74 -34
View File
@@ -14,7 +14,7 @@
* auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary.
*
* - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap
* again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording
* again to stop. Auto-stops after configurable silence. Shows floating preview overlay with recording
* indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct"
* (inject into local echo overlay or PTY) and "compose" (editable textarea overlay).
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
@@ -216,9 +216,12 @@ const DeepgramProvider = {
_resetSilenceTimeout() {
clearTimeout(this._silenceTimeout);
this._silenceTimeout = null;
const delay = VoiceInput.getSilenceTimeoutMs(3000);
if (delay === 0) return;
this._silenceTimeout = setTimeout(() => {
this.stop();
}, 3000);
}, delay);
},
stop() {
@@ -455,7 +458,10 @@ const ClaudeVoiceProvider = {
_resetSilenceTimeout() {
clearTimeout(this._silenceTimeout);
this._silenceTimeout = setTimeout(() => this.stop(), this.SILENCE_MS);
this._silenceTimeout = null;
const delay = VoiceInput.getSilenceTimeoutMs(this.SILENCE_MS);
if (delay === 0) return;
this._silenceTimeout = setTimeout(() => this.stop(), delay);
},
/**
@@ -545,6 +551,7 @@ const VoiceInput = {
_lastTranscript: '',
_stabilityTimer: null,
_accumulatedFinal: '',
_pendingInterim: '',
_activeProvider: null, // 'deepgram' | 'webspeech' | null
_recordingStartedAt: 0, // timestamp when recording started
_retryCount: 0, // auto-retry counter for premature Web Speech API ends
@@ -599,6 +606,12 @@ const VoiceInput = {
}
},
/** Seconds in settings; zero means stop only when the user asks. */
getSilenceTimeoutMs(fallback) {
const seconds = this._getDeepgramConfig().silenceTimeout;
return [0, 3, 6, 15, 30].includes(seconds) ? seconds * 1000 : fallback;
},
_saveDeepgramConfig(config) {
localStorage.setItem('codeman-voice-settings', JSON.stringify(config));
},
@@ -706,6 +719,7 @@ const VoiceInput = {
this.isRecording = true;
this._activeProvider = 'claude';
this._accumulatedFinal = '';
this._pendingInterim = '';
this._lastTranscript = '';
this._hasReceivedResult = false;
this._recordingStartedAt = Date.now();
@@ -728,22 +742,19 @@ const VoiceInput = {
// Each frame is the WHOLE running transcript, so replace rather than append.
this._accumulatedFinal = text;
if (isFinal) {
this._hidePreview();
this._insertText(text);
this.stop();
this.stop(true);
} else {
this._showPreview(text, 'claude');
}
},
onError: (msg) => {
const wasRecording = this.isRecording;
this.stop();
this.stop(true);
if (wasRecording) app.showToast(msg, 'error');
},
onEnd: () => {
if (this.isRecording) {
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
this.stop();
this.stop(true);
}
}
});
@@ -756,6 +767,7 @@ const VoiceInput = {
this.isRecording = true;
this._activeProvider = 'deepgram';
this._accumulatedFinal = '';
this._pendingInterim = '';
this._lastTranscript = '';
this._hasReceivedResult = false;
this._recordingStartedAt = Date.now();
@@ -777,12 +789,12 @@ const VoiceInput = {
if (!this.isRecording) return;
this._hasReceivedResult = true;
if (isFinal) {
this._accumulatedFinal += text;
this._hidePreview();
this._insertText(this._accumulatedFinal);
this.stop();
this._accumulatedFinal += (this._accumulatedFinal ? ' ' : '') + text;
this._pendingInterim = '';
this._showPreview(this._accumulatedFinal, 'deepgram');
} else {
const display = this._accumulatedFinal + text;
this._pendingInterim = (this._accumulatedFinal ? ' ' : '') + text;
const display = this._accumulatedFinal + this._pendingInterim;
this._showPreview(display, 'deepgram');
}
},
@@ -793,9 +805,6 @@ const VoiceInput = {
},
onEnd: () => {
if (this.isRecording) {
if (this._accumulatedFinal) {
this._insertText(this._accumulatedFinal);
}
this.stop();
}
}
@@ -819,6 +828,7 @@ const VoiceInput = {
this.isRecording = true;
this._activeProvider = 'webspeech';
this._accumulatedFinal = '';
this._pendingInterim = '';
this._lastTranscript = '';
this._hasReceivedResult = false;
this._recordingStartedAt = Date.now();
@@ -849,9 +859,19 @@ const VoiceInput = {
if (navigator.vibrate) navigator.vibrate(50);
},
stop() {
stop(claudeFinished = false) {
if (!this.isRecording) return;
// Claude delivers its final transcript after finalize. Keep accepting it
// until that frame (or onEnd) completes this recording.
if (this._activeProvider === 'claude' && !claudeFinished && ClaudeVoiceProvider._ws?.readyState === WebSocket.OPEN) {
ClaudeVoiceProvider.stop();
return;
}
this.isRecording = false;
const text = this._accumulatedFinal + this._pendingInterim;
this._accumulatedFinal = '';
this._pendingInterim = '';
if (text) this._insertText(text);
clearTimeout(this.silenceTimeout);
clearTimeout(this._stabilityTimer);
this.silenceTimeout = null;
@@ -901,11 +921,14 @@ const VoiceInput = {
}
if (finalText) {
clearTimeout(this._stabilityTimer);
this._stabilityTimer = null;
this._accumulatedFinal += finalText;
this._hidePreview();
this._insertText(this._accumulatedFinal);
this.stop();
} else if (interim) {
this._pendingInterim = interim;
this._showPreview(this._accumulatedFinal + interim);
}
if (interim) {
this._pendingInterim = interim;
const display = this._accumulatedFinal + interim;
this._showPreview(display);
// iOS Safari workaround: isFinal is always false.
@@ -918,6 +941,9 @@ const VoiceInput = {
// During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them
if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return;
// Browser silence events must not override an extended/manual deadline.
if (event.error === 'no-speech' && this.getSilenceTimeoutMs(3000) !== 3000) return;
const wasRecording = this.isRecording;
this.stop();
if (!wasRecording) return;
@@ -953,16 +979,30 @@ const VoiceInput = {
this.recognition.start();
} catch (_e) {
// If restart fails, fall through to stop
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
this.stop();
}
return;
}
// Genuine end — finalize any accumulated text
if (this._accumulatedFinal) {
this._insertText(this._accumulatedFinal);
// Some browsers end recognition independently of our silence timer. For an
// extended/manual session, restart without resetting the original deadline.
const delay = this.getSilenceTimeoutMs(3000);
if (delay === 0 || delay > 3000) {
try {
// Each recognition run starts a fresh result list. Retain Safari's
// interim-only phrase before the next run can replace it.
if (this._pendingInterim) {
this._accumulatedFinal += this._pendingInterim + ' ';
this._pendingInterim = '';
}
this.recognition.start();
return;
} catch (_e) {
// A failed restart ends recording normally.
}
}
// Genuine end — finalize any accumulated text
this.stop();
},
@@ -1138,26 +1178,25 @@ const VoiceInput = {
_resetSilenceTimeout() {
clearTimeout(this.silenceTimeout);
this.silenceTimeout = null;
const delay = this.getSilenceTimeoutMs(3000);
if (delay === 0) return;
this.silenceTimeout = setTimeout(() => {
if (this.isRecording) {
// Finalize any accumulated text before stopping
if (this._accumulatedFinal) {
this._insertText(this._accumulatedFinal);
}
this.stop();
}
}, 3000);
}, delay);
},
_iosStabilityCheck(transcript) {
// The Safari shortcut must not override the chosen thinking pause.
if (this.getSilenceTimeoutMs(3000) !== 3000) return;
if (transcript !== this._lastTranscript) {
this._lastTranscript = transcript;
clearTimeout(this._stabilityTimer);
this._stabilityTimer = setTimeout(() => {
if (this.isRecording) {
const finalText = this._accumulatedFinal + transcript;
this._hidePreview();
this._insertText(finalText);
this._pendingInterim = transcript;
this.stop();
}
}, 750);
@@ -1340,6 +1379,7 @@ const VoiceInput = {
this.silenceTimeout = null;
this._stabilityTimer = null;
this._accumulatedFinal = '';
this._pendingInterim = '';
this._lastTranscript = '';
this._retryCount = 0;
this._hasReceivedResult = false;
+2
View File
@@ -1783,6 +1783,8 @@ export const SettingsUpdateSchema = z
language: z.string().max(20).optional(),
keyterms: z.string().max(500).optional(),
insertMode: z.string().max(20).optional(),
/** Silence timeout in seconds; 0 disables automatic stopping. Omitted keeps provider defaults. */
silenceTimeout: z.union([z.literal(0), z.literal(3), z.literal(6), z.literal(15), z.literal(30)]).optional(),
})
.optional(),
// Run mode preference (cross-device sync)
+153
View File
@@ -0,0 +1,153 @@
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const source = readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
function load(silenceTimeout?: unknown) {
vi.useRealTimers();
vi.useFakeTimers();
const context = vm.createContext({
setTimeout,
clearTimeout,
console,
navigator: {},
WebSocket: { OPEN: 1 },
localStorage: { getItem: () => JSON.stringify({ silenceTimeout }) },
});
const providers = vm.runInContext(`${source}\n[DeepgramProvider, ClaudeVoiceProvider, VoiceInput]`, context);
return providers as Array<Record<string, any>>;
}
describe('voice silence timeout', () => {
for (const [index, name, legacy] of [
[0, 'Deepgram', 3000],
[1, 'Claude', 6000],
[2, 'browser', 3000],
] as const) {
it(`${name} keeps its default and accepts longer thinking pauses`, () => {
for (const [setting, delay] of [
[undefined, legacy],
[15, 15000],
[30, 30000],
] as const) {
const provider = load(setting)[index];
provider.isRecording = true;
provider.stop = vi.fn();
provider._resetSilenceTimeout();
vi.advanceTimersByTime(delay - 1);
expect(provider.stop).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(provider.stop).toHaveBeenCalledOnce();
}
});
it(`${name} resets its deadline and cancels it in manual mode`, () => {
const providers = load(15);
const provider = providers[index];
provider.isRecording = true;
provider.stop = vi.fn();
provider._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
provider._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
expect(provider.stop).not.toHaveBeenCalled();
providers[2]._getDeepgramConfig = () => ({ silenceTimeout: 0 });
provider._resetSilenceTimeout();
vi.advanceTimersByTime(120000);
expect(provider.stop).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
}
it.each([null, -1, '15', 1.5, 999])('ignores invalid local values: %s', (setting) => {
const voice = load(setting)[2];
expect(voice.getSilenceTimeoutMs(6000)).toBe(6000);
});
it('does not let the iOS stability shortcut stop extended/manual dictation', () => {
for (const setting of [0, 15]) {
const voice = load(setting)[2];
voice.isRecording = true;
voice._iosStabilityCheck('thinking');
expect(vi.getTimerCount()).toBe(0);
}
});
it('restarts browser recognition without extending the silence deadline', () => {
const voice = load(15)[2];
voice.isRecording = true;
voice._recordingStartedAt = Date.now() - 1000;
voice.recognition = { start: vi.fn() };
voice.stop = vi.fn();
voice._resetSilenceTimeout();
vi.advanceTimersByTime(10000);
voice._onWebSpeechEnd();
expect(voice.recognition.start).toHaveBeenCalledOnce();
vi.advanceTimersByTime(5000);
expect(voice.stop).toHaveBeenCalledOnce();
});
it('keeps final browser phrases and Safari interim text until an explicit stop', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._activeProvider = 'webspeech';
for (const method of ['_showPreview', '_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
voice[method] = vi.fn();
voice._insertText = vi.fn();
voice.recognition = { stop: vi.fn() };
const result = [{ transcript: 'first phrase ' }];
Object.assign(result, { isFinal: true });
voice._onWebSpeechResult({ resultIndex: 0, results: [result] });
expect(voice.isRecording).toBe(true);
expect(voice._insertText).not.toHaveBeenCalled();
const interim = [{ transcript: 'thinking aloud' }];
voice._onWebSpeechResult({ resultIndex: 0, results: [interim] });
voice.stop();
voice.stop();
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('first phrase thinking aloud');
});
it('preserves mixed final/interim results across browser recognition restarts', () => {
const voice = load(0)[2];
voice.isRecording = true;
voice._recordingStartedAt = Date.now() - 1000;
voice._showPreview = vi.fn();
voice.recognition = { start: vi.fn() };
const final = Object.assign([{ transcript: 'first ' }], { isFinal: true });
const interim = [{ transcript: 'second' }];
voice._onWebSpeechResult({ resultIndex: 0, results: [final, interim] });
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second');
voice._onWebSpeechEnd();
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'third' }]] });
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second third');
});
it('ignores repeated stop requests while waiting for Claude final text', () => {
const [_, provider, voice] = load(0);
voice.isRecording = true;
voice._activeProvider = 'claude';
provider._ws = { readyState: 1 };
provider.stop = vi.fn(() => {
provider._finalized = true;
});
voice._insertText = vi.fn();
voice.stop();
voice.stop();
expect(voice._insertText).not.toHaveBeenCalled();
expect(voice.isRecording).toBe(true);
});
it('cancels Safari stability text when a final result supersedes it', () => {
const voice = load(3)[2];
voice.isRecording = true;
voice._showPreview = vi.fn();
voice.stop = vi.fn();
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'hello' }]] });
const final = Object.assign([{ transcript: 'hello' }], { isFinal: true });
voice._onWebSpeechResult({ resultIndex: 0, results: [final] });
vi.advanceTimersByTime(750);
expect(voice.stop).not.toHaveBeenCalled();
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('hello');
});
it('round-trips the setting through server validation', () => {
for (const silenceTimeout of [0, 3, 6, 15, 30]) {
expect(SettingsUpdateSchema.parse({ voiceSettings: { silenceTimeout } }).voiceSettings).toEqual({
silenceTimeout,
});
}
expect(SettingsUpdateSchema.safeParse({ voiceSettings: { silenceTimeout: -1 } }).success).toBe(false);
});
});