hermes-agent/apps/desktop/src/lib/voice-playback.ts
Brooklyn Nicholson 8d1706ae5c fix(desktop): wire Ctrl+B voice, declutter voice settings, stop endless TTS hang
Three voice-mode papercuts in the desktop app:

1. Ctrl+B did nothing. The docs + `voice.record_key` advertise Ctrl+B to
   talk, but the desktop never bound it (only ⌘B = sidebar existed). Add a
   rebindable `composer.voice` action that toggles the voice conversation,
   defaulting to ⌃B on macOS (distinct from ⌘B; off-macOS `ctrl` folds to
   the sidebar chord, so it ships unbound there to avoid stealing it). The
   global keybind reaches the composer through a new focus-bus event.

2. The Voice settings page rendered every provider's options at once (~30
   fields). Filter to the *selected* TTS/STT provider's sub-fields; STT
   provider fields hide when STT is off. Picking "edge" now shows just the
   Edge voice, making it obvious voice chat also needs STT enabled.

3. Voice mode could hang "speaking" forever. Free Edge TTS sometimes returns
   audio that never fires `playing`/`ended`/`error`, so the playback promise
   never settled. Add a stall watchdog (rearmed on each progress tick, so
   long speech is never cut off) that rejects a stuck stream, letting the
   loop recover with a clear error.
2026-06-24 18:26:14 -05:00

155 lines
3.5 KiB
TypeScript

import { speakText } from '@/hermes'
import {
$voicePlayback,
setVoicePlaybackState,
type VoicePlaybackSource,
type VoicePlaybackState
} from '@/store/voice-playback'
import { sanitizeTextForSpeech } from './speech-text'
// Free Edge TTS occasionally hands back audio that never fires `playing`/`ended`
// nor `error` — leaving voice mode stuck "speaking" forever. Reject if playback
// fails to start or stalls mid-stream for this long (rearmed on each progress
// tick, so legitimately long speech is never cut off).
const PLAYBACK_STALL_MS = 15_000
let currentAudio: HTMLAudioElement | null = null
let currentStop: (() => void) | null = null
let sequence = 0
function currentState(
status: VoicePlaybackState['status'],
options?: VoicePlaybackOptions,
audioElement: HTMLAudioElement | null = null
): VoicePlaybackState {
return {
audioElement,
messageId: options?.messageId ?? null,
sequence,
source: options?.source ?? null,
status
}
}
export interface VoicePlaybackOptions {
messageId?: string | null
source: VoicePlaybackSource
}
export function stopVoicePlayback() {
sequence += 1
currentStop?.()
currentStop = null
if (currentAudio) {
currentAudio.pause()
currentAudio.src = ''
currentAudio.load()
currentAudio = null
}
setVoicePlaybackState({
audioElement: null,
messageId: null,
sequence,
source: null,
status: 'idle'
})
}
export async function playSpeechText(text: string, options: VoicePlaybackOptions): Promise<boolean> {
stopVoicePlayback()
const speakableText = sanitizeTextForSpeech(text)
if (!speakableText) {
return false
}
const ownSequence = sequence
const isCurrent = () => ownSequence === sequence
setVoicePlaybackState(currentState('preparing', options))
try {
const response = await speakText(speakableText)
if (!isCurrent()) {
return false
}
const audio = new Audio(response.data_url)
currentAudio = audio
setVoicePlaybackState(currentState('speaking', options, audio))
await new Promise<void>((resolve, reject) => {
let stall: number | null = null
const cleanup = () => {
if (stall !== null) {
window.clearTimeout(stall)
stall = null
}
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('error', onError)
audio.removeEventListener('timeupdate', armStall)
currentStop = null
}
const armStall = () => {
if (stall !== null) {
window.clearTimeout(stall)
}
stall = window.setTimeout(() => {
cleanup()
reject(new Error('Playback stalled'))
}, PLAYBACK_STALL_MS)
}
const onEnded = () => {
cleanup()
resolve()
}
const onError = () => {
cleanup()
reject(new Error('Playback failed'))
}
currentStop = () => {
cleanup()
resolve()
}
audio.addEventListener('ended', onEnded, { once: true })
audio.addEventListener('error', onError, { once: true })
audio.addEventListener('timeupdate', armStall)
armStall()
void audio.play().catch(onError)
})
if (!isCurrent()) {
return false
}
currentAudio = null
setVoicePlaybackState(currentState('idle'))
return true
} catch (error) {
if (isCurrent()) {
currentStop = null
currentAudio = null
setVoicePlaybackState(currentState('idle'))
}
throw error
}
}
export function isVoicePlaybackActive() {
return $voicePlayback.get().status !== 'idle'
}