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
This commit is contained in:
Brooklyn Nicholson
2026-09-27 13:06:46 -05:00
committed by brooklyn!
parent ac9a850eb9
commit c04e9a1d0d
6 changed files with 92 additions and 4 deletions

View File

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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}
})
})

View File

@@ -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<number>(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<boolean>(true)

View File

@@ -487,6 +487,7 @@ export interface HermesConfig {
stop_phrases?: unknown
thinking_sound?: unknown
barge_in_threshold_multiplier?: unknown
silence_duration?: unknown
}
}