fix(desktop): voice chat honors the read-aloud toggle (#44263)

The voice-conversation loop spoke every reply unconditionally: the per-turn
drive effect opened a live speech session as soon as a reply appeared,
regardless of the "Read replies aloud" setting. With the toggle off (and
voice.auto_tts false / tts.provider empty) a voice-chat turn still read the
reply aloud, and the mic could pick the speaker back up as phantom input.

Gate the loop at the same setting the toggle writes ($autoSpeakReplies,
seeded once from voice.auto_tts): when it is off, a completed reply is
consumed and the mic re-arms directly — speech recognition still works and
the reply stays text on screen; no TTS stream or sentence playback starts.
This commit is contained in:
Hermes Agent
2026-09-25 16:00:26 -05:00
committed by brooklyn!
parent ab0e060bd7
commit 5d0182c5f0
2 changed files with 96 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BargeMonitorCallbacks } from '@/lib/voice-barge-in'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import type { MicRecording } from './use-mic-recorder'
import { useVoiceConversation } from './use-voice-conversation'
@@ -27,10 +28,13 @@ vi.mock('@/lib/voice-barge-in', () => ({
const markVoicePlaybackInterrupted = vi.fn()
const stopVoicePlayback = vi.fn()
const playSpeechTextMock = vi.fn(async () => true)
const startSpeechStreamMock = vi.fn(async () => null)
vi.mock('@/lib/voice-playback', () => ({
markVoicePlaybackInterrupted: () => markVoicePlaybackInterrupted(),
playSpeechText: vi.fn(async () => true),
startSpeechStream: vi.fn(async () => null),
playSpeechText: (...args: unknown[]) => playSpeechTextMock(...(args as [])),
startSpeechStream: (...args: unknown[]) => startSpeechStreamMock(...(args as [])),
stopVoicePlayback: () => stopVoicePlayback()
}))
@@ -75,7 +79,11 @@ interface HookProps {
busy: boolean
}
function renderConversation(overrides: { onInterrupt?: () => void; transcript?: string } = {}) {
function renderConversation(overrides: {
onInterrupt?: () => void
pendingResponse?: () => { id: string; pending: boolean; text: string } | null
transcript?: string
} = {}) {
const onInterrupt = overrides.onInterrupt ?? vi.fn()
// Mirrors the real app: submitting a turn makes the agent busy.
@@ -95,6 +103,9 @@ function renderConversation(overrides: { onInterrupt?: () => void; transcript?:
transcriptions++ === 0 ? 'kick off the task' : (overrides.transcript ?? 'and another thing')
)
const pendingResponse = overrides.pendingResponse ?? (() => null)
const reply = { id: 'reply-1', pending: false, text: 'Here is the answer.' }
const hook = renderHook(
({ busy }: HookProps) =>
useVoiceConversation({
@@ -105,14 +116,14 @@ function renderConversation(overrides: { onInterrupt?: () => void; transcript?:
onStopWord,
onSubmit,
onTranscribeAudio,
pendingResponse: () => null
pendingResponse
}),
{ initialProps: { busy: false } }
)
onBusyChange.current = busy => hook.rerender({ busy })
return { hook, onInterrupt, onStopWord, onSubmit, onTranscribeAudio }
return { hook, onInterrupt, onBusyChange, onStopWord, onSubmit, onTranscribeAudio, reply }
}
/** Drive the hook into the generation phase (turn submitted, model working). */
@@ -264,3 +275,69 @@ describe('useVoiceConversation full-duplex barge-in', () => {
expect(monitorCalls.length).toBe(armed)
})
})
// #44263 — the "Read replies aloud" toggle ($autoSpeakReplies, the store the
// settings toggle writes, seeded once from voice.auto_tts) must govern the
// voice-conversation loop too: with it off, Voice Chat runs STT-only and the
// reply stays text on screen — no TTS stream or sentence playback is started
// for it, and the loop re-arms the mic for the next turn.
describe('useVoiceConversation honors the read-aloud toggle (#44263)', () => {
let replyReady: boolean
beforeEach(() => {
replyReady = false
vi.clearAllMocks()
micHandle.start.mockResolvedValue(undefined)
micHandle.stop.mockResolvedValue(null)
})
afterEach(() => {
$autoSpeakReplies.set(false)
cleanup()
})
const driveOneTurn = async () => {
const { hook, onSubmit, onBusyChange, reply } = renderConversation({
pendingResponse: () => (replyReady ? reply : null)
})
await act(async () => {
await hook.result.current.start()
})
await enterThinking(hook)
// The reply lands (still busy), then the turn completes — the order the
// real app produces: streaming reply text arrives before busy clears.
replyReady = true
onBusyChange.current(false)
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
return { hook }
}
it('does not start TTS for the reply when read-aloud is off', async () => {
$autoSpeakReplies.set(false)
await driveOneTurn()
// The loop settles back into listening (mic re-armed for the next turn)
// without ever opening TTS for the reply.
await waitFor(() => expect(monitorCalls.length).toBeGreaterThanOrEqual(0))
expect(startSpeechStreamMock).not.toHaveBeenCalled()
expect(playSpeechTextMock).not.toHaveBeenCalled()
})
it('opens the live speech stream when read-aloud is on', async () => {
$autoSpeakReplies.set(true)
await driveOneTurn()
await waitFor(() => expect(startSpeechStreamMock).toHaveBeenCalled())
// The mocked stream session resolves null, so the loop falls back to
// per-sentence playback — that too is TTS for the reply.
await waitFor(() => expect(playSpeechTextMock).toHaveBeenCalled())
})
})

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 { $bargeInThresholdMultiplier } from '@/store/voice-prefs'
import { $autoSpeakReplies, $bargeInThresholdMultiplier } from '@/store/voice-prefs'
import { useComposerScope } from '../scope'
@@ -856,6 +856,19 @@ export function useVoiceConversation({
const response = pendingResponse()
// "Read replies aloud" off (#44263): Voice Chat is STT-only — the reply
// stays text on screen, the loop consumes it and re-arms the mic for
// the next turn without ever starting TTS.
if (response && !$autoSpeakReplies.get()) {
awaitingSpokenResponseRef.current = false
dropSpeechSession()
consumePendingResponse()
pendingStartRef.current = true
setStatus('idle')
return
}
if (response) {
openLiveSpeech(response.id)