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:
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user