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

+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)