Bug
Speak a turn in hands-free; if an agent message arrives before your transcript lands, everything you said is silently discarded.
Root cause — found, and it is not a re-render
The original "fix ideas" pointed at React state being wiped by a re-render. That is not it: the in-progress transcript lives in pendingTextRef (a ref) and, in Whisper mode, in the MediaRecorder itself. A re-render cannot touch either.
The actual path:
- An agent message arrives →
maybeSpeakResponse (use-voice.ts:626) → speakAndResume (:600).
speakAndResume sets pausedForThinkingRef.current = true and calls sttRef.current.stop() (:608-609).
stop() — unlike stopDiscard() — does finalise: the clip uploads and transcribes, so the user's words survive the network round-trip.
- The transcript comes back and hits
if (shouldIgnoreResult(readGuard(), Date.now())) return; (:772). shouldIgnoreResult is isEchoing(...) || s.paused (machine.ts:51), and paused is still true because the agent is mid-sentence.
So the turn is transcribed successfully and then dropped one line before it would have been sent. Nothing logs it; the user sees their words vanish.
Why the guard exists (do not simply remove it)
That paused check is load-bearing and its comment says so: a late Whisper transcript must not "fall through and send a turn the user already abandoned" — e.g. after the mode was switched off. And the echo half stops the agent transcribing its own TTS. A naive if (!paused) removal reintroduces both.
The distinction the guard cannot currently make: audio captured BEFORE the pause began is the user's real turn; audio captured DURING the pause is echo or an abandoned turn. Same flag, two meanings.
Suggested direction
Stamp the capture, not the result. Record when the current recording STARTED (lastListenStartRef already exists, :1029) and when the pause began; a result whose capture started before the pause is the user's speech and should be delivered even if it lands late. Only results whose capture overlaps the pause are echo/abandoned.
Deliver it rather than send it blind: appending the recovered transcript to the composer, so the user can see it survived and press send, is safer than auto-sending a turn into a conversation that has since moved on.
Acceptance criteria
Reproduce
Hands-free, sttMode: openai. Start a long sentence, and have the agent emit any reply (e.g. a second browser tab sending a message to the same instance) before you stop speaking.
Bug
Speak a turn in hands-free; if an agent message arrives before your transcript lands, everything you said is silently discarded.
Root cause — found, and it is not a re-render
The original "fix ideas" pointed at React state being wiped by a re-render. That is not it: the in-progress transcript lives in
pendingTextRef(a ref) and, in Whisper mode, in theMediaRecorderitself. A re-render cannot touch either.The actual path:
maybeSpeakResponse(use-voice.ts:626) →speakAndResume(:600).speakAndResumesetspausedForThinkingRef.current = trueand callssttRef.current.stop()(:608-609).stop()— unlikestopDiscard()— does finalise: the clip uploads and transcribes, so the user's words survive the network round-trip.if (shouldIgnoreResult(readGuard(), Date.now())) return;(:772).shouldIgnoreResultisisEchoing(...) || s.paused(machine.ts:51), andpausedis still true because the agent is mid-sentence.So the turn is transcribed successfully and then dropped one line before it would have been sent. Nothing logs it; the user sees their words vanish.
Why the guard exists (do not simply remove it)
That
pausedcheck is load-bearing and its comment says so: a late Whisper transcript must not "fall through and send a turn the user already abandoned" — e.g. after the mode was switched off. And the echo half stops the agent transcribing its own TTS. A naiveif (!paused)removal reintroduces both.The distinction the guard cannot currently make: audio captured BEFORE the pause began is the user's real turn; audio captured DURING the pause is echo or an abandoned turn. Same flag, two meanings.
Suggested direction
Stamp the capture, not the result. Record when the current recording STARTED (
lastListenStartRefalready exists,:1029) and when the pause began; a result whose capture started before the pause is the user's speech and should be delivered even if it lands late. Only results whose capture overlaps the pause are echo/abandoned.Deliver it rather than send it blind: appending the recovered transcript to the composer, so the user can see it survived and press send, is safer than auto-sending a turn into a conversation that has since moved on.
Acceptance criteria
Reproduce
Hands-free,
sttMode: openai. Start a long sentence, and have the agent emit any reply (e.g. a second browser tab sending a message to the same instance) before you stop speaking.