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:
committed by
brooklyn!
parent
ac9a850eb9
commit
c04e9a1d0d
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -487,6 +487,7 @@ export interface HermesConfig {
|
||||
stop_phrases?: unknown
|
||||
thinking_sound?: unknown
|
||||
barge_in_threshold_multiplier?: unknown
|
||||
silence_duration?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user