feat(voice): desktop speech-stream sessions with barge-in capture

/api/audio/speak-stream WebSocket: one socket + one Web Audio clock per
reply. The renderer feeds raw LLM deltas as they arrive; the server
cuts sentences with the shared chunker and streams int16 PCM back while
generation continues — speech overlaps generation with no per-sentence
connection or synthesis gaps. Falls back to the POST data-URL path for
old backends / non-chunked providers.

Barge-in runs a MediaRecorder on the monitor's stream the whole time
playback is live (rotated while quiet to bound pre-roll); talking over
the agent cuts playback and the complete utterance goes straight to
transcription and submit. /api/audio/transcribe returns 200/"" for
no-speech results so quiet turns re-listen instead of toasting a 400.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 17:47:25 -05:00
parent 68e1fedd2d
commit 93e9061f15
7 changed files with 1185 additions and 161 deletions

View File

@ -1,7 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
import {
playSpeechText,
type SpeechStreamSession,
startSpeechStream,
stopVoicePlayback
} from '@/lib/voice-playback'
import { notify, notifyError } from '@/store/notifications'
import { useMicRecorder } from './use-mic-recorder'
@ -44,7 +50,9 @@ export function useVoiceConversation({
const awaitingSpokenResponseRef = useRef(false)
const responseIdRef = useRef<string | null>(null)
const spokenSourceLengthRef = useRef(0)
const speechBufferRef = useRef('')
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
const bargeCapturePendingRef = useRef(false)
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
@ -74,60 +82,13 @@ export function useVoiceConversation({
}
}
const resetSpeechBuffer = () => {
const dropSpeechSession = () => {
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = null
bargeCapturePendingRef.current = false
speechSessionRef.current = null
responseIdRef.current = null
spokenSourceLengthRef.current = 0
speechBufferRef.current = ''
}
const appendSpeechText = (text: string) => {
if (!text) {
return
}
speechBufferRef.current = `${speechBufferRef.current}${text}`
}
const takeSpeechChunk = (force = false): string | null => {
const buffer = speechBufferRef.current.replace(/\s+/g, ' ').trim()
if (!buffer) {
speechBufferRef.current = ''
return null
}
const sentence = buffer.match(/^(.+?[.!?。!?])(?:\s+|$)/)
if (sentence?.[1] && (sentence[1].length >= 8 || force)) {
const chunk = sentence[1].trim()
speechBufferRef.current = buffer.slice(sentence[1].length).trim()
return chunk
}
if (!force && buffer.length > 220) {
const softBoundary = Math.max(
buffer.lastIndexOf(', ', 180),
buffer.lastIndexOf('; ', 180),
buffer.lastIndexOf(': ', 180)
)
if (softBoundary > 80) {
const chunk = buffer.slice(0, softBoundary + 1).trim()
speechBufferRef.current = buffer.slice(softBoundary + 1).trim()
return chunk
}
}
if (!force) {
return null
}
speechBufferRef.current = ''
return buffer
}
const handleTurn = useCallback(
@ -167,7 +128,7 @@ export function useVoiceConversation({
}
awaitingSpokenResponseRef.current = true
resetSpeechBuffer()
dropSpeechSession()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
@ -193,6 +154,10 @@ export function useVoiceConversation({
return
}
if (bargeCapturePendingRef.current) {
return // the barge monitor is mid-capture and owns the mic
}
if (statusRef.current !== 'idle') {
return
}
@ -220,24 +185,237 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
const settleAfterSpeech = useCallback(
(barged: boolean) => {
if (barged || !awaitingSpokenResponseRef.current) {
awaitingSpokenResponseRef.current = false
consumePendingResponse()
}
if (bargeCapturePendingRef.current) {
// The barge monitor is still capturing the user's interruption — it
// owns the next turn. Keep it alive and don't re-open the mic; the
// utterance callback transcribes and submits when they go quiet.
speechSessionRef.current = null
responseIdRef.current = null
spokenSourceLengthRef.current = 0
setStatus('listening')
return
}
dropSpeechSession()
if (enabledRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
},
[consumePendingResponse]
)
/**
* Submit the utterance the barge monitor captured the user's interruption
* from its first syllable, no re-listen round trip. Empty/failed captures
* fall back to normal listening.
*/
const submitCapturedUtterance = useCallback(
async (audio: Blob | null) => {
const resumeListening = () => {
if (enabledRef.current && !mutedRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
if (!audio || !onTranscribeAudio) {
resumeListening()
return
}
setStatus('transcribing')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
const transcript = (await onTranscribeAudio(audio)).trim()
if (!transcript) {
resumeListening()
return
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
consumePendingResponse()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, voiceCopy.transcriptionFailed)
resumeListening()
}
},
[voiceCopy.playbackFailed]
[consumePendingResponse, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
)
/** Barge-in monitor wiring shared by the live and fallback speech paths. */
const openBargeMonitor = useCallback(
(onBarge: () => void) =>
monitorSpeechDuringPlayback({
onSpeech: () => {
bargeCapturePendingRef.current = true
onBarge()
stopVoicePlayback()
},
onUtterance: audio => {
bargeCapturePendingRef.current = false
stopBargeMonitorRef.current = null
void submitCapturedUtterance(audio)
}
}),
[submitCapturedUtterance]
)
/** Push any new reply text into the live session; finish when complete. */
const feedSpeechSession = useCallback(
(responseId: string) => {
const session = speechSessionRef.current
if (!session || responseIdRef.current !== responseId) {
return
}
const response = pendingResponse()
if (response && response.id === responseId) {
if (response.text.length > spokenSourceLengthRef.current) {
session.append(response.text.slice(spokenSourceLengthRef.current))
spokenSourceLengthRef.current = response.text.length
}
if (!response.pending && !busyRef.current) {
session.finish()
}
} else if (!busyRef.current) {
// Reply consumed/vanished while we were speaking — close out the turn.
session.finish()
}
},
[pendingResponse]
)
/** Whole-text fallback: wait for the reply to complete, then speak it. */
const awaitFallbackSpeech = useCallback(
(responseId: string) => {
const poll = () => {
if (responseIdRef.current !== responseId) {
return
}
const response = pendingResponse()
if (!response || response.id !== responseId) {
settleAfterSpeech(false)
return
}
if (response.pending || busyRef.current) {
window.setTimeout(poll, 250)
return
}
let barged = false
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = openBargeMonitor(() => {
barged = true
})
void playSpeechText(response.text, { source: 'voice-conversation' })
.catch(error => notifyError(error, voiceCopy.playbackFailed))
.finally(() => {
if (responseIdRef.current === responseId) {
awaitingSpokenResponseRef.current = false
settleAfterSpeech(barged)
}
})
}
poll()
},
[openBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed]
)
/**
* Live-speak the streaming reply: one speech session per response, fed
* incremental text as the assistant generates it. Audio overlaps generation
* no wait for the full reply, no per-sentence gaps.
*/
const openLiveSpeech = useCallback(
(responseId: string) => {
responseIdRef.current = responseId
spokenSourceLengthRef.current = 0
setStatus('speaking')
let barged = false
// VAD barge-in: the user talking over the reply cuts playback, drops
// the not-yet-spoken remainder, AND keeps capturing — the interruption
// is transcribed from its first syllable instead of losing the opening
// words to a mic re-open.
stopBargeMonitorRef.current = openBargeMonitor(() => {
barged = true
})
void (async () => {
const session = await startSpeechStream({ source: 'voice-conversation' })
// The session may resolve after the loop moved on (barge, disable).
if (responseIdRef.current !== responseId) {
if (session) {
stopVoicePlayback()
}
return
}
if (!session) {
// No streaming backend/provider: speak the whole reply once it lands.
speechSessionRef.current = null
awaitFallbackSpeech(responseId)
return
}
speechSessionRef.current = session
// Timer-driven feed: reply text flows into the session at delta rate
// regardless of React render cadence.
const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150)
feedSpeechSession(responseId)
const outcome = await session.done
window.clearInterval(feedTimer)
if (responseIdRef.current !== responseId) {
return
}
if (outcome === 'fallback') {
awaitFallbackSpeech(responseId)
return
}
awaitingSpokenResponseRef.current = false
settleAfterSpeech(barged)
})()
},
[awaitFallbackSpeech, feedSpeechSession, openBargeMonitor, settleAfterSpeech]
)
const start = useCallback(async () => {
@ -254,7 +432,7 @@ export function useVoiceConversation({
setMuted(false)
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
dropSpeechSession()
consumePendingResponse()
pendingStartRef.current = true
await startListening()
@ -274,7 +452,7 @@ export function useVoiceConversation({
handle.cancel()
turnClosingRef.current = false
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
dropSpeechSession()
consumePendingResponse()
setMuted(false)
setStatus('idle')
@ -325,8 +503,9 @@ export function useVoiceConversation({
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, stopTurn])
// Drive the loop: after a voice-submitted turn, speak stable chunks as the
// assistant stream grows. Otherwise start listening when idle between turns.
// Drive the loop: when a voice-submitted reply appears, open a live speech
// session (which feeds itself from then on). Otherwise start listening when
// idle between turns.
useEffect(() => {
if (!enabled || muted) {
return
@ -336,38 +515,15 @@ export function useVoiceConversation({
const response = pendingResponse()
if (response) {
if (response.id !== responseIdRef.current) {
resetSpeechBuffer()
responseIdRef.current = response.id
}
openLiveSpeech(response.id)
if (response.text.length > spokenSourceLengthRef.current) {
appendSpeechText(response.text.slice(spokenSourceLengthRef.current))
spokenSourceLengthRef.current = response.text.length
}
const chunk = takeSpeechChunk(!response.pending && !busy)
if (chunk) {
void speak(chunk)
return
}
if (!response.pending && !busy) {
awaitingSpokenResponseRef.current = false
consumePendingResponse()
resetSpeechBuffer()
pendingStartRef.current = true
setStatus('idle')
return
}
return
}
if (!busy && status === 'thinking') {
// Turn finished without any speakable reply (tool-only, error).
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
dropSpeechSession()
pendingStartRef.current = true
setStatus('idle')
@ -382,7 +538,7 @@ export function useVoiceConversation({
if (pendingStartRef.current) {
void startListening()
}
}, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status])
}, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status])
useEffect(() => {
if (enabled && !wasEnabledRef.current) {

View File

@ -6,6 +6,7 @@ import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { setMutableRef } from '@/lib/mutable-ref'
import { isVoicePlaybackActive, stopVoicePlayback } from '@/lib/voice-playback'
import {
$composerAttachments,
clearComposerAttachments,
@ -141,6 +142,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return false
}
// Typing barge-in: a new send silences any in-flight spoken reply.
if (isVoicePlaybackActive()) {
stopVoicePlayback()
}
// Queue drains carry their source session explicitly. A background drain
// must never inherit the currently selected session after the user moves
// to another chat.

View File

@ -0,0 +1,238 @@
// VAD barge-in: watch the mic while TTS plays, fire the moment the user talks
// over it, and CAPTURE what they say. Detection alone loses the first words —
// by the time sustained speech trips the trigger and a fresh recorder spins
// up, "stop, actually—" has become "actually—". So a MediaRecorder runs on
// the monitor's stream the whole time (pre-roll), and once tripped it keeps
// rolling until the user goes quiet, delivering the complete utterance.
//
// Echo cancellation strips the app's own speaker output from the capture, the
// noise floor is calibrated while playback is already audible, and the
// sustained window filters coughs/thumps — mirrors
// tools/voice_mode.listen_for_speech on the Python surfaces.
const CALIBRATION_MS = 400
const SUSTAINED_MS = 300
const MIN_TRIGGER_LEVEL = 0.075 // matches the voice loop's silenceLevel
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
const UTTERANCE_MAX_MS = 30_000
export interface BargeMonitorCallbacks {
/** Sustained speech detected — cut playback now. */
onSpeech: () => void
/**
* The interrupting utterance, complete from its first syllable (pre-roll
* included), delivered once the user goes quiet. `null` when capture was
* unavailable fall back to normal listening.
*/
onUtterance?: (audio: Blob | null) => void
}
export function monitorSpeechDuringPlayback(callbacks: BargeMonitorCallbacks): () => void {
let disposed = false
let stream: MediaStream | null = null
let context: AudioContext | null = null
let frame: number | null = null
let recorder: MediaRecorder | null = null
let chunks: Blob[] = []
let mimeType = ''
const cleanup = () => {
disposed = true
if (frame !== null) {
window.cancelAnimationFrame(frame)
frame = null
}
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null
recorder.onstop = null
try {
recorder.stop()
} catch {
// already stopped
}
}
recorder = null
chunks = []
void context?.close().catch(() => undefined)
context = null
stream?.getTracks().forEach(track => track.stop())
stream = null
}
const startSegment = () => {
if (!stream || typeof MediaRecorder === 'undefined') {
return
}
mimeType =
['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'].find(type =>
MediaRecorder.isTypeSupported(type)
) ?? ''
try {
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
} catch {
recorder = null
return
}
chunks = []
recorder.ondataavailable = event => {
if (event.data.size > 0) {
chunks.push(event.data)
}
}
recorder.start(250)
}
/** Restart the recorder to drop stale pre-roll — only valid while quiet. */
const rotateSegment = () => {
if (!recorder || recorder.state === 'inactive') {
return
}
recorder.ondataavailable = null
recorder.onstop = null
try {
recorder.stop()
} catch {
// already stopped
}
startSegment()
}
const finishCapture = () => {
const active = recorder
const type = active?.mimeType || mimeType || 'audio/webm'
if (!active || active.state === 'inactive') {
cleanup()
callbacks.onUtterance?.(chunks.length ? new Blob(chunks, { type }) : null)
return
}
active.onstop = () => {
const audio = chunks.length ? new Blob(chunks, { type }) : null
cleanup()
callbacks.onUtterance?.(audio)
}
active.stop()
}
void (async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
})
if (disposed) {
cleanup()
return
}
startSegment()
context = new AudioContext()
const analyser = context.createAnalyser()
analyser.fftSize = 256
context.createMediaStreamSource(stream).connect(analyser)
const data = new Uint8Array(analyser.fftSize)
const startedAt = Date.now()
const floorSamples: number[] = []
let segmentStartedAt = Date.now()
let speechStartedAt: number | null = null
let tripped = false
let trippedAt = 0
let quietSince: number | null = null
const tick = () => {
if (disposed) {
return
}
analyser.getByteTimeDomainData(data)
let sum = 0
for (const value of data) {
const centered = value - 128
sum += centered * centered
}
const level = Math.min(1, Math.sqrt(sum / data.length) / 42)
const now = Date.now()
if (!tripped && now - startedAt < CALIBRATION_MS) {
floorSamples.push(level)
} else if (!tripped) {
const floor = floorSamples.length ? [...floorSamples].sort((a, b) => a - b)[floorSamples.length >> 1] : 0
const trigger = Math.max(MIN_TRIGGER_LEVEL, floor * 3.5)
if (level >= trigger) {
speechStartedAt ??= now
if (now - speechStartedAt >= SUSTAINED_MS) {
tripped = true
trippedAt = now
quietSince = null
callbacks.onSpeech()
if (!callbacks.onUtterance || !recorder) {
cleanup()
callbacks.onUtterance?.(null)
return
}
}
} else {
speechStartedAt = null
// Bound the pre-roll while quiet so the utterance blob doesn't
// accumulate the whole playback (rotating mid-speech would lose
// the onset — the whole point).
if (now - segmentStartedAt >= PRE_ROLL_RESTART_MS) {
rotateSegment()
segmentStartedAt = now
}
}
} else {
// Tripped: keep recording until the user goes quiet (endpoint).
// Playback is already stopped, so plain silence-vs-speech works.
if (level >= MIN_TRIGGER_LEVEL) {
quietSince = null
} else {
quietSince ??= now
}
if ((quietSince && now - quietSince >= UTTERANCE_SILENCE_MS) || now - trippedAt >= UTTERANCE_MAX_MS) {
finishCapture()
return
}
}
frame = window.requestAnimationFrame(tick)
}
tick()
} catch {
cleanup()
}
})()
return cleanup
}

View File

@ -1,3 +1,5 @@
import { resolveGatewayWsUrl } from '@hermes/shared'
import { speakText } from '@/hermes'
import {
$voicePlayback,
@ -58,6 +60,321 @@ export function stopVoicePlayback() {
})
}
// ---------------------------------------------------------------------------
// Streaming path — /api/audio/speak-stream WebSocket, raw int16 PCM frames
// scheduled through Web Audio. Speech starts on the provider's first chunk
// instead of after full synthesis + base64 transfer.
// ---------------------------------------------------------------------------
async function resolveSpeakStreamUrl(): Promise<null | string> {
const desktop = window.hermesDesktop
if (!desktop?.getConnection) {
return null
}
try {
// Mint a fresh credential (single-use ticket in OAuth mode), then swap the
// gateway endpoint for the PCM one — auth is shared across WS routes.
const wsUrl = await resolveGatewayWsUrl(desktop, await desktop.getConnection())
const url = new URL(wsUrl)
if (!url.pathname.endsWith('/api/ws')) {
return null
}
url.pathname = url.pathname.replace(/\/api\/ws$/, '/api/audio/speak-stream')
return url.toString()
} catch {
return null
}
}
export interface SpeechStreamSession {
/** Feed more reply text as it streams in. Safe after `finish` (no-op). */
append: (text: string) => void
/** No more text coming — resolves `done` once the audio drains. */
finish: () => void
/**
* 'done' audio fully played (or barged via stopVoicePlayback)
* 'fallback' no audio ever produced; caller should speak the accumulated
* text through `playSpeechText` instead.
*/
done: Promise<'done' | 'fallback'>
}
/**
* Open a live speech session: one WebSocket + one AudioContext for a whole
* reply. Text is appended as LLM deltas arrive; the server cuts sentences and
* streams PCM back while generation continues, so speech overlaps the text
* stream (ChatGPT-style) with no per-sentence connection or synthesis gaps.
*/
function openSpeechStream(wsUrl: string, options: VoicePlaybackOptions): SpeechStreamSession {
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
let context: AudioContext | null = null
let streamRate = 24_000
let nextStartAt = 0
let carry: null | Uint8Array = null
let started = false
let settled = false
let finished = false
const pendingSends: string[] = []
let settle: (value: 'done' | 'fallback') => void = () => undefined
const done = new Promise<'done' | 'fallback'>(resolve => {
settle = value => {
if (settled) {
return
}
settled = true
currentStop = null
try {
ws.close()
} catch {
// already closed
}
void context?.close().catch(() => undefined)
context = null
resolve(value)
}
})
const send = (frame: object) => {
const data = JSON.stringify(frame)
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
} else if (ws.readyState === WebSocket.CONNECTING) {
pendingSends.push(data)
}
}
// stopVoicePlayback() → immediate barge-in: kill the socket (the server
// aborts synthesis on disconnect) and the audio context (cuts sound now).
currentStop = () => settle('done')
const finishWhenDrained = () => {
const remainingMs = context ? Math.max(0, nextStartAt - context.currentTime) * 1_000 : 0
window.setTimeout(() => settle('done'), remainingMs + 100)
}
const schedule = (data: ArrayBuffer) => {
if (!context) {
return
}
// Provider chunks are not sample-aligned — carry any odd byte over.
let bytes = new Uint8Array(data)
if (carry) {
const joined = new Uint8Array(carry.length + bytes.length)
joined.set(carry)
joined.set(bytes, carry.length)
bytes = joined
carry = null
}
const usable = bytes.length - (bytes.length % 2)
if (bytes.length !== usable) {
carry = bytes.slice(usable)
}
if (!usable) {
return
}
const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, usable / 2)
const buffer = context.createBuffer(1, pcm.length, streamRate)
const channel = buffer.getChannelData(0)
for (let index = 0; index < pcm.length; index += 1) {
channel[index] = pcm[index] / 32_768
}
const source = context.createBufferSource()
source.buffer = buffer
source.connect(context.destination)
const startAt = Math.max(context.currentTime + 0.05, nextStartAt)
source.start(startAt)
nextStartAt = startAt + buffer.duration
if (!started) {
started = true
setVoicePlaybackState(currentState('speaking', options))
}
}
ws.onopen = () => {
pendingSends.splice(0).forEach(data => ws.send(data))
}
ws.onmessage = event => {
if (typeof event.data !== 'string') {
schedule(event.data as ArrayBuffer)
return
}
let frame: { channels?: number; sample_rate?: number; type?: string }
try {
frame = JSON.parse(event.data) as typeof frame
} catch {
return
}
if (frame.type === 'start') {
streamRate = frame.sample_rate || 24_000
context = new AudioContext()
nextStartAt = 0
} else if (frame.type === 'end') {
finishWhenDrained()
} else if (frame.type === 'fallback') {
settle(started ? 'done' : 'fallback')
}
}
// A drop before any audio means the endpoint is unavailable (old backend,
// auth, network) → fall back. After audio started, replaying the whole
// message via POST would stutter — treat what played as the playback.
ws.onerror = () => settle(started ? 'done' : 'fallback')
ws.onclose = () => (started ? finishWhenDrained() : settle('fallback'))
return {
// Raw deltas — the server strips markdown/emoji per *sentence*, which is
// the only safe granularity when constructs span delta boundaries.
append: text => {
if (text && !finished && !settled) {
send({ text })
}
},
finish: () => {
if (!finished && !settled) {
finished = true
send({ done: true })
}
},
done
}
}
/**
* Live-speak an in-progress reply: open a session, then `append` deltas and
* `finish` when generation completes. Resolves null when streaming is
* unavailable (old backend / non-chunked provider) the caller falls back to
* whole-text `playSpeechText`.
*/
export async function startSpeechStream(options: VoicePlaybackOptions): Promise<null | SpeechStreamSession> {
const wsUrl = await resolveSpeakStreamUrl()
if (!wsUrl) {
return null
}
stopVoicePlayback()
setVoicePlaybackState(currentState('preparing', options))
const session = openSpeechStream(wsUrl, options)
void session.done.then(outcome => {
if (outcome === 'done') {
setVoicePlaybackState(currentState('idle'))
}
})
return session
}
/** One-shot playback of complete text over the streaming WS. */
function playSpeechStream(wsUrl: string, text: string, options: VoicePlaybackOptions): Promise<'fallback' | 'played'> {
const session = openSpeechStream(wsUrl, options)
session.append(text)
session.finish()
return session.done.then(outcome => (outcome === 'done' ? 'played' : 'fallback'))
}
async function playSpeechDataUrl(
speakableText: string,
options: VoicePlaybackOptions,
isCurrent: () => boolean
): Promise<boolean> {
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
return true
}
export async function playSpeechText(text: string, options: VoicePlaybackOptions): Promise<boolean> {
stopVoicePlayback()
@ -73,72 +390,35 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions
setVoicePlaybackState(currentState('preparing', options))
try {
const response = await speakText(speakableText)
// Streaming first; the POST data-URL path is the fallback for backends
// without the WS endpoint or providers without a chunked API.
const streamUrl = await resolveSpeakStreamUrl()
if (streamUrl && isCurrent()) {
const outcome = await playSpeechStream(streamUrl, speakableText, options)
if (outcome === 'played') {
if (!isCurrent()) {
return false
}
setVoicePlaybackState(currentState('idle'))
return true
}
}
if (!isCurrent()) {
return false
}
const audio = new Audio(response.data_url)
currentAudio = audio
setVoicePlaybackState(currentState('speaking', options, audio))
const played = await playSpeechDataUrl(speakableText, options, isCurrent)
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
if (played) {
setVoicePlaybackState(currentState('idle'))
}
currentAudio = null
setVoicePlaybackState(currentState('idle'))
return true
return played
} catch (error) {
if (isCurrent()) {
currentStop = null

View File

@ -9,6 +9,7 @@ Usage:
python -m hermes_cli.main web --port 8080
"""
import contextlib
from contextlib import asynccontextmanager, contextmanager
import asyncio
@ -28,6 +29,7 @@ import json
import logging
import mimetypes
import os
import queue
import re
import secrets
import shlex
@ -4269,10 +4271,14 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest):
tmp.write(audio_bytes)
temp_path = tmp.name
from tools.transcription_tools import transcribe_audio
# transcribe_recording (not raw transcribe_audio): filters Whisper
# hallucinations and maps provider "empty transcript" errors to a
# successful empty result — the live voice loop treats "" as silence
# and re-listens instead of surfacing a 400 on every quiet turn.
from tools.voice_mode import transcribe_recording
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, transcribe_audio, temp_path)
result = await loop.run_in_executor(None, transcribe_recording, temp_path)
except HTTPException:
raise
except Exception as exc:
@ -4464,6 +4470,151 @@ async def speak_text(payload: TTSSpeakRequest):
}
def _split_text_for_speak_stream(text: str, cap: int) -> list:
"""Split *text* into provider-cap-sized pieces on sentence boundaries."""
from tools.tts_streaming import SENTENCE_BOUNDARY_RE as _SENTENCE_BOUNDARY_RE
cap = cap if cap and cap > 0 else 4000
pieces, buf = [], ""
for sentence in filter(str.strip, _SENTENCE_BOUNDARY_RE.split(text)):
while len(sentence) > cap:
pieces.append(sentence[:cap])
sentence = sentence[cap:]
if buf and len(buf) + len(sentence) + 1 > cap:
pieces.append(buf)
buf = sentence
else:
buf = f"{buf} {sentence}" if buf else sentence
if buf:
pieces.append(buf)
return pieces
@app.websocket("/api/audio/speak-stream")
async def speak_stream_ws(ws: "WebSocket") -> None:
"""Streaming TTS for the desktop: text in, raw int16 PCM frames out.
The socket is a per-reply speech *session*: the client feeds text
incrementally as LLM deltas arrive, the server cuts sentences
(``SentenceChunker`` same cutter as the CLI/TUI speaker pipeline) and
streams each one's PCM the moment it's ready. Speech overlaps generation,
exactly like the tokensentenceTTS pipelining the realtime-voice
literature converges on.
Protocol:
client ``{"text": "..."}`` frames (incremental; may combine with done),
``{"done": true}`` when the reply is complete,
``{"stop": true}`` or disconnect = barge-in
server ``{"type": "start", "sample_rate": N, "channels": 1}``,
binary PCM frames, then ``{"type": "end"}``
server ``{"type": "fallback"}`` when the configured provider has no
chunked API the client uses the POST endpoint instead.
"""
if not _ws_auth_ok(ws):
await ws.close(code=4401)
return
if not _ws_request_is_allowed(ws):
await ws.close(code=4403)
return
await ws.accept()
loop = asyncio.get_running_loop()
def _resolve():
from tools.tts_streaming import resolve_streaming_provider
from tools.tts_tool import _get_provider, _load_tts_config, _resolve_max_text_length
cfg = _load_tts_config()
streamer = resolve_streaming_provider(cfg)
cap = _resolve_max_text_length(_get_provider(cfg), cfg) if streamer else 0
return streamer, cap
try:
streamer, cap = await loop.run_in_executor(None, _resolve)
except Exception:
_log.exception("speak-stream provider resolution failed")
streamer, cap = None, 0
if streamer is None:
with contextlib.suppress(Exception):
await ws.send_json({"type": "fallback"})
await ws.close()
return
await ws.send_json(
{"type": "start", "sample_rate": streamer.sample_rate, "channels": streamer.channels}
)
stop = threading.Event()
text_q: queue.Queue = queue.Queue() # str deltas; None = end-of-text
chunks: asyncio.Queue = asyncio.Queue() # PCM out; None = synthesis done
def _produce():
from tools.tts_streaming import SentenceChunker
from tools.tts_tool import _strip_markdown_for_tts
chunker = SentenceChunker()
def _sentences():
while not stop.is_set():
delta = text_q.get()
if delta is None:
yield from chunker.flush()
return
yield from chunker.feed(delta)
try:
for sentence in _sentences():
cleaned = _strip_markdown_for_tts(sentence)
if not cleaned:
continue
for piece in _split_text_for_speak_stream(cleaned, cap):
for chunk in streamer.stream(piece):
if stop.is_set():
return
loop.call_soon_threadsafe(chunks.put_nowait, chunk)
except Exception as exc:
_log.warning("speak-stream synthesis failed: %s", exc)
finally:
loop.call_soon_threadsafe(chunks.put_nowait, None)
threading.Thread(target=_produce, daemon=True).start()
async def _pump_client():
# Text frames feed synthesis; done ends the text; stop/disconnect
# (or any unparseable frame) is barge-in.
try:
while True:
frame = json.loads(await ws.receive_text())
if frame.get("text"):
text_q.put(str(frame["text"]))
if frame.get("stop"):
break
if frame.get("done"):
text_q.put(None)
except Exception:
pass
stop.set()
text_q.put(None) # unblock the producer
pump = asyncio.ensure_future(_pump_client())
try:
while True:
chunk = await chunks.get()
if chunk is None:
break
await ws.send_bytes(chunk)
if not stop.is_set():
await ws.send_json({"type": "end"})
except (WebSocketDisconnect, RuntimeError):
pass
finally:
stop.set()
text_q.put(None)
pump.cancel()
with contextlib.suppress(Exception):
await ws.close()
@app.get("/api/actions/{name}/status")
async def get_action_status(name: str, lines: int = 200):
"""Tail an action log and report whether the process is still running."""

View File

@ -2192,7 +2192,7 @@ class TestWebServerEndpoints:
captured = {}
def fake_transcribe_audio(path):
def fake_transcribe_audio(path, model=None):
captured["path"] = path
return {
"success": True,
@ -2219,6 +2219,36 @@ class TestWebServerEndpoints:
assert captured["path"].endswith(".webm")
assert not Path(captured["path"]).exists()
def test_audio_transcription_no_speech_is_not_an_error(self, monkeypatch):
"""A provider hearing silence (empty transcript) must return 200/""
the live voice loop treats it as a quiet turn and re-listens, instead
of surfacing a 400 toast on every pause (the ElevenLabs empty-
transcript spam)."""
import tools.transcription_tools as transcription_tools
monkeypatch.setattr(
transcription_tools,
"transcribe_audio",
lambda path, model=None: {
"success": False,
"transcript": "",
"error": "ElevenLabs STT returned empty transcript",
"no_speech": True,
},
)
resp = self.client.post(
"/api/audio/transcribe",
json={
"data_url": "data:audio/webm;base64,aGVsbG8=",
"mime_type": "audio/webm",
},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert resp.json()["transcript"] == ""
def test_audio_transcription_rejects_invalid_base64(self):
resp = self.client.post(
"/api/audio/transcribe",

View File

@ -0,0 +1,163 @@
"""/api/audio/speak-stream — desktop streaming TTS over WebSocket."""
from __future__ import annotations
import json
from urllib.parse import urlencode
import pytest
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from hermes_cli import web_server
@pytest.fixture
def stream_client(monkeypatch, _isolate_hermes_home):
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
client = TestClient(web_server.app)
try:
yield client
finally:
close = getattr(client, "close", None)
if close is not None:
close()
if previous_auth_required is None:
if hasattr(web_server.app.state, "auth_required"):
delattr(web_server.app.state, "auth_required")
else:
web_server.app.state.auth_required = previous_auth_required
def _url(token: str | None = None) -> str:
return f"/api/audio/speak-stream?{urlencode({'token': token or web_server._SESSION_TOKEN})}"
class _FakeStreamer:
sample_rate = 24000
channels = 1
def __init__(self, chunks):
self.chunks = chunks
self.requests: list[str] = []
def stream(self, text):
self.requests.append(text)
yield from self.chunks
def _patch_provider(monkeypatch, streamer, cap=4000):
monkeypatch.setattr("tools.tts_streaming.resolve_streaming_provider", lambda cfg: streamer)
monkeypatch.setattr("tools.tts_tool._load_tts_config", lambda: {})
monkeypatch.setattr("tools.tts_tool._get_provider", lambda cfg: "fake")
monkeypatch.setattr("tools.tts_tool._resolve_max_text_length", lambda provider, cfg: cap)
def test_rejects_bad_token(stream_client):
with pytest.raises(WebSocketDisconnect) as exc:
with stream_client.websocket_connect(_url(token="wrong")):
pass
assert exc.value.code == 4401
def test_fallback_frame_when_no_streaming_provider(stream_client, monkeypatch):
_patch_provider(monkeypatch, None)
with stream_client.websocket_connect(_url()) as conn:
assert conn.receive_json() == {"type": "fallback"}
def test_streams_pcm_frames_then_end(stream_client, monkeypatch):
streamer = _FakeStreamer([b"\x01\x02\x03\x04", b"\x05\x06"])
_patch_provider(monkeypatch, streamer)
with stream_client.websocket_connect(_url()) as conn:
start = conn.receive_json()
assert start == {"type": "start", "sample_rate": 24000, "channels": 1}
conn.send_text(json.dumps({"text": "Hello there.", "done": True}))
assert conn.receive_bytes() == b"\x01\x02\x03\x04"
assert conn.receive_bytes() == b"\x05\x06"
assert conn.receive_json() == {"type": "end"}
assert streamer.requests == ["Hello there."]
def test_incremental_deltas_are_cut_into_sentences(stream_client, monkeypatch):
"""Text fed across frames is chunked and synthesized while more arrives."""
streamer = _FakeStreamer([b"\x00\x00"])
_patch_provider(monkeypatch, streamer)
with stream_client.websocket_connect(_url()) as conn:
assert conn.receive_json()["type"] == "start"
conn.send_text(json.dumps({"text": "This is the first full"}))
conn.send_text(json.dumps({"text": " sentence of the reply. And"}))
# The first sentence is complete — PCM must arrive before `done`.
assert conn.receive_bytes() == b"\x00\x00"
conn.send_text(json.dumps({"text": " here is the second one.", "done": True}))
assert conn.receive_bytes() == b"\x00\x00"
assert conn.receive_json() == {"type": "end"}
assert streamer.requests == [
"This is the first full sentence of the reply.",
"And here is the second one.",
]
def test_stop_frame_cuts_synthesis(stream_client, monkeypatch):
streamer = _FakeStreamer([b"\x00\x00"])
_patch_provider(monkeypatch, streamer)
with stream_client.websocket_connect(_url()) as conn:
assert conn.receive_json()["type"] == "start"
conn.send_text(json.dumps({"stop": True}))
# Socket closes without an "end" frame — barge-in, not completion.
with pytest.raises(WebSocketDisconnect):
conn.receive_text()
assert streamer.requests == []
def test_long_text_is_split_across_provider_requests(stream_client, monkeypatch):
streamer = _FakeStreamer([b"\x00\x00"])
_patch_provider(monkeypatch, streamer, cap=24)
with stream_client.websocket_connect(_url()) as conn:
assert conn.receive_json()["type"] == "start"
conn.send_text(
json.dumps(
{"text": "First sentence here. Second sentence here. Third one.", "done": True}
)
)
# One PCM frame per split piece, then end.
frames = 0
while True:
message = conn.receive()
if message.get("bytes") is not None:
frames += 1
else:
assert json.loads(message["text"]) == {"type": "end"}
break
assert len(streamer.requests) > 1
assert frames == len(streamer.requests)
# Nothing lost in the split: every sentence reached the provider.
joined = " ".join(streamer.requests)
for fragment in ("First sentence here.", "Second sentence here.", "Third one."):
assert fragment in joined
def test_split_text_respects_cap_and_preserves_content():
text = "Alpha beta. Gamma delta epsilon. Zeta eta theta iota kappa."
pieces = web_server._split_text_for_speak_stream(text, 30)
assert pieces
assert all(len(piece) <= 30 for piece in pieces)
joined = " ".join(pieces)
for word in text.replace(".", "").split():
assert word in joined
def test_split_text_hard_splits_oversized_sentence():
pieces = web_server._split_text_for_speak_stream("x" * 100, 30)
assert all(len(piece) <= 30 for piece in pieces)
assert sum(len(piece) for piece in pieces) == 100