From c04e9a1d0dfa4abbefe4b256428e645abaacfe88 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 27 Sep 2026 13:06:46 -0500 Subject: [PATCH] fix(desktop): honour a user-set voice.silence_duration in the voice loop The desktop voice conversation hardcoded its silence hold at 1,250 ms and ignored the voice.silence_duration config key the CLI, TUI and gateway capture paths already honour, so users who pause mid-thought were cut off with no way to raise the threshold, and fast local STT/TTS stacks sat through a rigid extra second of dead air. Seed a $voiceSilenceMs atom from /api/config on every refresh (the same useHermesConfig path as stop_phrases / barge-in sensitivity): a value the user actually changed (differing from /api/config/defaults) overrides the tuned desktop hold; an untouched install keeps 1.25 s, because /api/config merges DEFAULT_CONFIG and would otherwise raise the hold to 3 s for everyone. Malformed or non-positive values fall back like the gateway's shape-safe lookup, and booleans never coerce to 1 s. The barge-in utterance endpoint reads the same atom live per frame, so barge-in capture and the voice loop stay matched across config refreshes. Supersedes #83572 (live-atom read, no backend-default comparison, would triple the hold to 3 s for untouched installs) and #85772 (same, plus accepts booleans as 1 s). Fixes https://github.com/NousResearch/hermes-agent/issues/83518 Fixes https://github.com/NousResearch/hermes-agent/issues/124760 --- .../composer/hooks/use-voice-conversation.ts | 7 ++- .../app/session/hooks/use-hermes-config.ts | 2 + apps/desktop/src/lib/voice-barge-in.ts | 8 +++- apps/desktop/src/store/voice-prefs.test.ts | 45 +++++++++++++++++++ apps/desktop/src/store/voice-prefs.ts | 33 ++++++++++++++ apps/desktop/src/types/hermes.ts | 1 + 6 files changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 278f42ecae..41c8d55b14 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -14,7 +14,7 @@ import { import { isVoiceStopCommand } from '@/lib/voice-stop-word' import { notify, notifyError } from '@/store/notifications' import { $voicePlayback } from '@/store/voice-playback' -import { $autoSpeakReplies, $bargeInThresholdMultiplier } from '@/store/voice-prefs' +import { $autoSpeakReplies, $bargeInThresholdMultiplier, $voiceSilenceMs } from '@/store/voice-prefs' import { useComposerScope } from '../scope' @@ -290,9 +290,12 @@ export function useVoiceConversation({ try { // VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI. + // `silenceMs` honours `voice.silence_duration` (seeded by useHermesConfig): only a + // user-set value overrides the desktop's tuned 1.25 s hold, which every turn sits + // through as dead air. await handle.start({ silenceLevel: 0.075, - silenceMs: 1_250, + silenceMs: $voiceSilenceMs.get(), idleSilenceMs: 12_000, onError: error => { notifyError(error, voiceCopy.microphoneFailed) diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 63bbe7cefd..d1c7aa5f71 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -22,6 +22,7 @@ import { applyAutoSpeakFromConfig, applyBargeInThresholdFromConfig, applyThinkingSoundFromConfig, + applyVoiceSilenceMsFromConfig, applyVoiceStopPhraseFromConfig } from '@/store/voice-prefs' import { setChatFontFamilyFromConfig } from '@/themes/chat-font' @@ -154,6 +155,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { applyVoiceStopPhraseFromConfig(config, defaults) applyBargeInThresholdFromConfig(config) applyThinkingSoundFromConfig(config) + applyVoiceSilenceMsFromConfig(config, defaults) // Resolved server-side (mode + whether a key resolves); non-critical. void refreshVoiceLiveStatus().catch(() => undefined) } catch { diff --git a/apps/desktop/src/lib/voice-barge-in.ts b/apps/desktop/src/lib/voice-barge-in.ts index 179163e937..22d5582f10 100644 --- a/apps/desktop/src/lib/voice-barge-in.ts +++ b/apps/desktop/src/lib/voice-barge-in.ts @@ -24,6 +24,7 @@ // trigger) so intra-word energy dips don't reset progress. import { closeMeterContext, meterContextsClosed } from '@/lib/mic-meter-context' +import { $voiceSilenceMs } from '@/store/voice-prefs' const CALIBRATION_MS = 400 const SUSTAINED_MS = 300 @@ -41,7 +42,10 @@ const PLAYBACK_GRACE_MS = 500 const PLAYBACK_GAP_FOR_GRACE_MS = 1_000 const FLOOR_SAMPLE_CAP = 200 // ~3s of quiet-phase levels at rAF cadence const PRE_ROLL_RESTART_MS = 5_000 // cap pre-roll: restart the recorder while quiet -const UTTERANCE_SILENCE_MS = 1_250 // matches the voice loop's silenceMs +// The utterance endpoint shares the voice loop's `silenceMs` ($voiceSilenceMs, +// seeded from `voice.silence_duration`) so barge-in capture ends on the same +// silence window the loop uses; read live per frame so a config refresh +// mid-turn keeps the two matched. const UTTERANCE_MAX_MS = 30_000 export interface BargeMonitorCallbacks { @@ -347,7 +351,7 @@ export function monitorSpeechDuringPlayback(callbacks: BargeMonitorCallbacks): ( quietSince ??= now } - if ((quietSince && now - quietSince >= UTTERANCE_SILENCE_MS) || now - trippedAt >= UTTERANCE_MAX_MS) { + if ((quietSince && now - quietSince >= $voiceSilenceMs.get()) || now - trippedAt >= UTTERANCE_MAX_MS) { finishCapture() return diff --git a/apps/desktop/src/store/voice-prefs.test.ts b/apps/desktop/src/store/voice-prefs.test.ts index 3cf9c7e94f..b708cc1bfd 100644 --- a/apps/desktop/src/store/voice-prefs.test.ts +++ b/apps/desktop/src/store/voice-prefs.test.ts @@ -10,9 +10,11 @@ import { isVoiceStopCommand } from '@/lib/voice-stop-word' import { $bargeInThresholdMultiplier, + $voiceSilenceMs, $voiceStopPhrase, $voiceStopPhraseConfig, applyBargeInThresholdFromConfig, + applyVoiceSilenceMsFromConfig, applyVoiceStopPhraseFromConfig } from './voice-prefs' @@ -171,3 +173,46 @@ describe('applyBargeInThresholdFromConfig', () => { expect($bargeInThresholdMultiplier.get()).toBeNull() }) }) + +// `voice.silence_duration` drives the desktop loop the way it drives the +// CLI/TUI capture paths, but only when the user actually changed it: `/api/config` +// merges DEFAULT_CONFIG, so an untouched install reports the backend default +// (3.0) rather than omitting the key, and reading that unconditionally would +// triple the hold for everyone (the loop was tuned to 1.25 s). +describe('applyVoiceSilenceMsFromConfig', () => { + const backendDefault = { voice: { silence_duration: 3.0 } } + + it('a user-set silence_duration overrides the desktop hold (seconds to ms)', () => { + applyVoiceSilenceMsFromConfig({ voice: { silence_duration: 0.7 } }, backendDefault) + expect($voiceSilenceMs.get()).toBe(700) + + applyVoiceSilenceMsFromConfig({ voice: { silence_duration: 10 } }, backendDefault) + expect($voiceSilenceMs.get()).toBe(10_000) + + applyVoiceSilenceMsFromConfig({ voice: { silence_duration: '2' } }, backendDefault) + expect($voiceSilenceMs.get()).toBe(2_000) + }) + + it('an untouched install keeps the tuned 1.25 s desktop hold', () => { + applyVoiceSilenceMsFromConfig(backendDefault, backendDefault) + expect($voiceSilenceMs.get()).toBe(1_250) + + // Defaults endpoint unavailable: the backend default is still recognisable. + applyVoiceSilenceMsFromConfig(backendDefault, {}) + expect($voiceSilenceMs.get()).toBe(1_250) + + applyVoiceSilenceMsFromConfig({ voice: {} }, backendDefault) + expect($voiceSilenceMs.get()).toBe(1_250) + + applyVoiceSilenceMsFromConfig(null) + expect($voiceSilenceMs.get()).toBe(1_250) + }) + + it('malformed or non-positive values keep the default like the gateway lookup', () => { + for (const raw of [0, -1, true, 'quiet', null, {}]) { + applyVoiceSilenceMsFromConfig({ voice: { silence_duration: 0.7 } }, backendDefault) + applyVoiceSilenceMsFromConfig({ voice: { silence_duration: raw } }, backendDefault) + expect($voiceSilenceMs.get()).toBe(1_250) + } + }) +}) diff --git a/apps/desktop/src/store/voice-prefs.ts b/apps/desktop/src/store/voice-prefs.ts index bb8cf68e1d..8c7d9b1019 100644 --- a/apps/desktop/src/store/voice-prefs.ts +++ b/apps/desktop/src/store/voice-prefs.ts @@ -102,6 +102,39 @@ export function applyBargeInThresholdFromConfig(config: ConfigPayload) { $bargeInThresholdMultiplier.set(Number.isFinite(value) && value > 0 ? value : null) } +// `voice.silence_duration` (seconds) — how long the user must stay quiet +// before the conversation loop treats the utterance as finished. Documented +// default 3.0 (hermes_cli/config_defaults.py), honoured by the CLI/TUI/gateway +// capture paths (cli_voice_mixin.py, tui_gateway/methods_voice.py) but +// previously hardcoded to 1.25 s in the desktop renderer's mic loop, so a +// mid-thought pause cut the turn off and `hermes config set` had no effect. +// Stored in ms because that is what the loop's timers consume. +// +// `/api/config` merges DEFAULT_CONFIG, so an untouched install reports the +// backend default (3.0) rather than omitting the key; only a value the user +// actually changed (≠ the /api/config/defaults payload) overrides the +// desktop's tuned default — reading it unconditionally would triple the hold +// for everyone who never touched the key. +const DESKTOP_SILENCE_MS_DEFAULT = 1_250 +const BACKEND_SILENCE_SECONDS_DEFAULT = 3.0 + +export const $voiceSilenceMs = atom(DESKTOP_SILENCE_MS_DEFAULT) + +function silenceSeconds(raw: unknown): number | null { + // `true` must not coerce to 1 s — YAML hands back a bare boolean for a typo'd value. + const value = typeof raw === 'number' ? raw : typeof raw === 'boolean' ? NaN : Number(raw) + + return Number.isFinite(value) && value > 0 ? value : null +} + +/** Seed the silence window from a loaded config payload (mount / refresh). */ +export function applyVoiceSilenceMsFromConfig(config: ConfigPayload, defaults?: ConfigPayload) { + const seconds = silenceSeconds(voiceValue(config, 'silence_duration')) + const defaultSeconds = silenceSeconds(voiceValue(defaults, 'silence_duration')) ?? BACKEND_SILENCE_SECONDS_DEFAULT + + $voiceSilenceMs.set(seconds !== null && seconds !== defaultSeconds ? seconds * 1_000 : DESKTOP_SILENCE_MS_DEFAULT) +} + // `voice.thinking_sound` — ambient bubble blips while the agent works during a // voice conversation (default on, matching the backend default). export const $thinkingSoundEnabled = atom(true) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 7ce387c89e..366da0f8c4 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -487,6 +487,7 @@ export interface HermesConfig { stop_phrases?: unknown thinking_sound?: unknown barge_in_threshold_multiplier?: unknown + silence_duration?: unknown } }