feat(voice): "Say <stop-phrase> to end the voice chat" notice on voice-mode start (CLI/TUI/desktop, i18n)

One owner for the wording: voice_stop_hint() in tools/voice_mode.py —
sources the phrase from voice.stop_phrases (first entry) so a custom
phrase renders correctly, and returns "" when the feature is disabled
(stop_phrases: []) so no surface shows a hint.

- CLI: printed in /voice on output (style-matched dim notice).
- TUI: voice.toggle action=on now carries stop_hint; the Ink client
  renders it in the "Voice mode enabled" block (older gateways omit
  the field — no hint, no crash).
- Desktop: the renderer voice loop never touches tools/voice_mode.py,
  so the phrase is read from config (voice.stop_phrases → $voiceStopPhrase
  store, seeded in use-hermes-config) and shown as an info toast when a
  voice conversation starts. i18n: en/ja/zh/zh-hant/ar.
This commit is contained in:
Teknium 2026-07-29 00:41:56 -07:00
parent 9e5f1b619c
commit 6fdfdc1597
18 changed files with 201 additions and 3 deletions

View file

@ -7,8 +7,8 @@ import { triggerHaptic } from '@/lib/haptics'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import { notify, notifyError } from '@/store/notifications'
import { $autoSpeakReplies, $voiceStopPhrase, setAutoSpeakReplies } from '@/store/voice-prefs'
import { resumeWakeAfterVoice } from '@/store/wake-word'
import type { ComposerTarget } from '../focus'
@ -208,6 +208,26 @@ export function useComposerVoice({
}
}, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive])
// 'Say "stop" to end the voice chat.' notice when the conversation starts.
// Phrase comes from voice.stop_phrases (first entry) so a custom phrase
// renders correctly; a null phrase (stop_phrases: []) shows no notice.
useEffect(() => {
if (!voiceConversationActive) {
return
}
const phrase = $voiceStopPhrase.get()
if (phrase) {
notify({
id: 'voice-stop-hint',
kind: 'info',
icon: 'mic',
message: t.notifications.voice.sayStopToEnd(phrase)
})
}
}, [t, voiceConversationActive])
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
// Explicit start/end for the on-screen conversation controls (the hotkey uses

View file

@ -14,7 +14,7 @@ import {
setDefaultReasoningEffort,
setIntroPersonality
} from '@/store/session'
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
import { applyAutoSpeakFromConfig, applyVoiceStopPhraseFromConfig } from '@/store/voice-prefs'
const DEFAULT_VOICE_SECONDS = 120
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
@ -105,6 +105,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
applyAutoSpeakFromConfig(config)
applyVoiceStopPhraseFromConfig(config)
} catch {
// Config is nice-to-have; chat still works without it.
}

View file

@ -137,6 +137,7 @@ export const ar = defineLocale({
noSpeechDetected: 'لم يتم اكتشاف كلام',
playbackFailed: 'فشل تشغيل الصوت',
recordingFailed: 'فشل التسجيل',
sayStopToEnd: phrase => `قل "${phrase}" لإنهاء المحادثة الصوتية.`,
transcriptionFailed: 'فشل التفريغ النصي',
transcriptionUnavailable: 'التفريغ النصي غير متاح.',
tryRecordingAgain: 'حاول التسجيل مرة أخرى.',

View file

@ -154,6 +154,7 @@ export const en: Translations = {
noSpeechDetected: 'No speech detected',
playbackFailed: 'Voice playback failed',
recordingFailed: 'Voice recording failed',
sayStopToEnd: phrase => `Say "${phrase}" to end the voice chat.`,
transcriptionFailed: 'Voice transcription failed',
transcriptionUnavailable: 'Voice transcription is not available yet.',
tryRecordingAgain: 'Try recording again.',

View file

@ -155,6 +155,7 @@ export const ja = defineLocale({
noSpeechDetected: '音声が検出されませんでした',
playbackFailed: '音声再生に失敗しました',
recordingFailed: '音声録音に失敗しました',
sayStopToEnd: phrase => `${phrase}」と言うと音声チャットを終了できます。`,
transcriptionFailed: '音声文字起こしに失敗しました',
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
tryRecordingAgain: 'もう一度録音してください。',

View file

@ -195,6 +195,7 @@ export interface Translations {
noSpeechDetected: string
playbackFailed: string
recordingFailed: string
sayStopToEnd: (phrase: string) => string
transcriptionFailed: string
transcriptionUnavailable: string
tryRecordingAgain: string

View file

@ -150,6 +150,7 @@ export const zhHant = defineLocale({
noSpeechDetected: '未偵測到語音',
playbackFailed: '語音播放失敗',
recordingFailed: '語音錄製失敗',
sayStopToEnd: phrase => `說「${phrase}」即可結束語音對話。`,
transcriptionFailed: '語音轉寫失敗',
transcriptionUnavailable: '語音轉寫暫不可用。',
tryRecordingAgain: '請再錄製一次。',

View file

@ -150,6 +150,7 @@ export const zh: Translations = {
noSpeechDetected: '没有检测到语音',
playbackFailed: '语音播放失败',
recordingFailed: '语音录制失败',
sayStopToEnd: phrase => `说“${phrase}”即可结束语音对话。`,
transcriptionFailed: '语音转写失败',
transcriptionUnavailable: '语音转写暂不可用。',
tryRecordingAgain: '请再录一次。',

View file

@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/hermes', () => ({
getHermesConfigRecord: vi.fn(async () => ({})),
saveHermesConfig: vi.fn(async () => undefined)
}))
import { $voiceStopPhrase, applyVoiceStopPhraseFromConfig } from './voice-prefs'
describe('applyVoiceStopPhraseFromConfig', () => {
it('defaults to "stop" when the key is absent (backend default applies)', () => {
applyVoiceStopPhraseFromConfig({ voice: {} })
expect($voiceStopPhrase.get()).toBe('stop')
applyVoiceStopPhraseFromConfig(null)
expect($voiceStopPhrase.get()).toBe('stop')
})
it('uses the first configured phrase so a custom phrase renders correctly', () => {
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: ['goodbye hermes', 'stop'] } })
expect($voiceStopPhrase.get()).toBe('goodbye hermes')
})
it('coerces a bare string like the backend does', () => {
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: 'halt' } })
expect($voiceStopPhrase.get()).toBe('halt')
})
it('null phrase when stop phrases are disabled — no notice is shown', () => {
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: [] } })
expect($voiceStopPhrase.get()).toBeNull()
})
it('malformed entries are skipped; all-blank list disables', () => {
applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: [' ', ''] } })
expect($voiceStopPhrase.get()).toBeNull()
})
})

View file

@ -12,6 +12,31 @@ export function applyAutoSpeakFromConfig(config: { voice?: { auto_tts?: unknown
$autoSpeakReplies.set(Boolean(config?.voice?.auto_tts))
}
// First configured `voice.stop_phrases` entry — drives the "Say "stop" to end
// the voice chat" notice shown when a voice conversation starts. `null` means
// the user disabled stop phrases (`stop_phrases: []`), so no notice is shown.
// Defaults to "stop" (the backend default) before config loads.
export const $voiceStopPhrase = atom<string | null>('stop')
/** Seed the stop-phrase atom from a loaded config payload (mount / refresh). */
export function applyVoiceStopPhraseFromConfig(
config: { voice?: { stop_phrases?: unknown } | null } | null | undefined
) {
const raw = config?.voice?.stop_phrases
if (raw === undefined) {
// Key absent — backend default applies.
$voiceStopPhrase.set('stop')
return
}
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []
const first = list.map(entry => String(entry).trim()).find(entry => entry.length > 0)
$voiceStopPhrase.set(first ?? null)
}
/**
* Flip the preference and persist it. Optimistic the atom updates instantly and
* reverts if the config write fails. Read-modify-writes the whole record (the

View file

@ -331,6 +331,7 @@ export interface HermesConfig {
voice?: {
max_recording_seconds?: number
auto_tts?: boolean
stop_phrases?: unknown
}
}

9
cli.py
View file

@ -12283,6 +12283,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_ptt_display = self._voice_record_key_label()
_cprint(f"\n{_ACCENT}Voice mode enabled{tts_status}{_RST}")
_cprint(f" {_DIM}{_ptt_display} to start/stop recording{_RST}")
# Spoken-stop hint sourced from voice.stop_phrases (first entry); the
# helper returns "" when stop phrases are disabled — show no hint then.
try:
from tools.voice_mode import voice_stop_hint
_stop_hint = voice_stop_hint()
except Exception:
_stop_hint = ""
if _stop_hint:
_cprint(f" {_DIM}{_stop_hint}{_RST}")
_cprint(f" {_DIM}/voice tts to toggle speech output{_RST}")
_cprint(f" {_DIM}/voice off to disable voice mode{_RST}")

View file

@ -1188,6 +1188,47 @@ def test_voice_toggle_returns_configured_record_key(monkeypatch):
assert status_resp["result"]["record_key"] == "ctrl+o"
def test_voice_toggle_on_carries_stop_hint(monkeypatch):
"""voice.toggle action=on returns the spoken-stop hint for clients to
render sourced from voice.stop_phrases so a custom phrase shows
correctly, and empty when the feature is disabled (stop_phrases: [])."""
monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}})
monkeypatch.setitem(
sys.modules,
"tools.voice_mode",
types.SimpleNamespace(
check_voice_requirements=lambda: {"available": True, "details": ""},
voice_stop_hint=lambda: 'Say "halt" to end the voice chat.',
),
)
monkeypatch.setenv("HERMES_VOICE", "0")
on_resp = server.dispatch(
{"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}}
)
assert on_resp["result"]["stop_hint"] == 'Say "halt" to end the voice chat.'
# Disabled stop phrases → empty hint, clients show nothing.
monkeypatch.setitem(
sys.modules,
"tools.voice_mode",
types.SimpleNamespace(
check_voice_requirements=lambda: {"available": True, "details": ""},
voice_stop_hint=lambda: "",
),
)
on_resp = server.dispatch(
{"id": "voice-on2", "method": "voice.toggle", "params": {"action": "on"}}
)
assert on_resp["result"]["stop_hint"] == ""
# off carries no hint text (mode is ending).
off_resp = server.dispatch(
{"id": "voice-off", "method": "voice.toggle", "params": {"action": "off"}}
)
assert off_resp["result"]["stop_hint"] == ""
def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
"""Round-3 Copilot review regression on #19835.

View file

@ -17,9 +17,29 @@ from tools.voice_mode import (
DEFAULT_VOICE_STOP_PHRASES,
_load_voice_stop_phrases,
is_voice_stop_phrase,
voice_stop_hint,
)
class TestVoiceStopHint:
"""The 'Say "stop" to end the voice chat.' hint shown on voice-mode start."""
def test_default_phrase(self):
with patch("tools.voice_mode._load_voice_stop_phrases", return_value=("stop",)):
assert voice_stop_hint() == 'Say "stop" to end the voice chat.'
def test_custom_phrase_uses_first_entry(self):
with patch(
"tools.voice_mode._load_voice_stop_phrases",
return_value=("goodbye hermes", "stop"),
):
assert voice_stop_hint() == 'Say "goodbye hermes" to end the voice chat.'
def test_disabled_phrases_show_no_hint(self):
with patch("tools.voice_mode._load_voice_stop_phrases", return_value=()):
assert voice_stop_hint() == ""
class TestIsVoiceStopPhrase:
@pytest.mark.parametrize("utterance", [
"stop", "Stop", "STOP", "stop.", "Stop!", " stop ", '"Stop."', "stop?",

View file

@ -1161,6 +1161,21 @@ def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None)
return cleaned in stop_phrases
def voice_stop_hint() -> str:
"""One-line 'Say "stop" to end the voice chat.' hint for voice-mode start.
Sources the phrase from ``voice.stop_phrases`` (first entry) so a custom
phrase renders correctly; returns "" when stop phrases are disabled
(``stop_phrases: []``) so surfaces show no hint at all. Every surface
that announces voice-mode start (CLI /voice on, TUI, desktop) uses this
one owner instead of hardcoding the wording.
"""
phrases = _load_voice_stop_phrases()
if not phrases:
return ""
return f'Say "{phrases[0]}" to end the voice chat.'
# ============================================================================
# STT dispatch
# ============================================================================

View file

@ -18287,6 +18287,18 @@ def _(rid, params: dict) -> dict:
# persisted stale toggle.
os.environ["HERMES_VOICE"] = "1" if enabled else "0"
stop_hint = ""
if enabled:
# Spoken-stop hint for the client to render on voice-mode start.
# Sourced from voice.stop_phrases (custom phrases render
# correctly); empty when the feature is disabled.
try:
from tools.voice_mode import voice_stop_hint
stop_hint = voice_stop_hint()
except Exception:
stop_hint = ""
if not enabled:
# Disabling the mode must tear the continuous loop down; the
# loop holds the microphone and would otherwise keep running.
@ -18310,6 +18322,7 @@ def _(rid, params: dict) -> dict:
"enabled": enabled,
"record_key": _voice_record_key(),
"tts": _voice_tts_enabled(),
"stop_hint": stop_hint,
},
)

View file

@ -380,6 +380,14 @@ export const sessionCommands: SlashCommand[] = [
const tts = r.tts ? ' (TTS enabled)' : ''
ctx.transcript.sys(`Voice mode enabled${tts}`)
ctx.transcript.sys(` ${recordKeyLabel} to start/stop recording`)
// Spoken-stop hint — backend-sourced from voice.stop_phrases so a
// custom phrase renders correctly; absent/empty means the feature
// is disabled (stop_phrases: []) and no hint is shown.
if (r.stop_hint) {
ctx.transcript.sys(` ${r.stop_hint}`)
}
ctx.transcript.sys(' /voice tts to toggle speech output')
ctx.transcript.sys(' /voice off to disable voice mode')
} else {

View file

@ -394,6 +394,7 @@ export interface VoiceToggleResponse {
details?: string
enabled?: boolean
record_key?: string
stop_hint?: string
stt_available?: boolean
tts?: boolean
}