Discovery
Surfaced while implementing #6788 (voice WS unification). Two watch(isSpeaking, ...) watchers exist in useVoiceConversation.ts:
- Line 540 — created inside
_dispatchTranscript after walkie-talkie/hands-free TTS, calls _resumeAutoListening() and self-unwatches when fired.
- Line 820 — top-level watcher in the composable factory, calls
_onSpeakingDone() on every TTS completion (which itself calls _resumeAutoListening() for full-duplex / hands-free).
Both fire when isSpeaking flips false. For walkie-talkie/hands-free responses the inner watcher unsubscribes after the first fire — but the outer watcher fires every time, so during a single TTS burst:
- Inner watcher:
_resumeAutoListening()
- Outer watcher:
_onSpeakingDone() → _resumeAutoListening() again
Result: state transitions like 'speaking' → 'idle' → 'listening' → 'idle' → 'listening' instead of a clean single transition. In hands-free mode this triggers the TTS-cooldown timer twice; in walkie-talkie it's mostly harmless but could double-trigger error-prone listening starts on slow devices.
Why it matters
This may be one of the contributors to the user's voice-stuttering reports during testing of #6745/#6781. We focused on the dual-WS issue (#6788) and pipeline-depth (#6811), but this watcher race is independent and untouched.
Suggested fix
Single watch(isSpeaking, ...) at the composable factory level. The conditional inside it should branch:
- If we just finished walkie-talkie one-shot: clear state, don't auto-resume
- If we're in full-duplex / hands-free: auto-resume listening
Move the auto-unwatch logic out (it's a workaround for the duplicate watcher).
Acceptance
Discovery
Surfaced while implementing #6788 (voice WS unification). Two
watch(isSpeaking, ...)watchers exist in useVoiceConversation.ts:_dispatchTranscriptafter walkie-talkie/hands-free TTS, calls_resumeAutoListening()and self-unwatches when fired._onSpeakingDone()on every TTS completion (which itself calls_resumeAutoListening()for full-duplex / hands-free).Both fire when
isSpeakingflips false. For walkie-talkie/hands-free responses the inner watcher unsubscribes after the first fire — but the outer watcher fires every time, so during a single TTS burst:_resumeAutoListening()_onSpeakingDone()→_resumeAutoListening()againResult: state transitions like
'speaking' → 'idle' → 'listening' → 'idle' → 'listening'instead of a clean single transition. In hands-free mode this triggers the TTS-cooldown timer twice; in walkie-talkie it's mostly harmless but could double-trigger error-prone listening starts on slow devices.Why it matters
This may be one of the contributors to the user's voice-stuttering reports during testing of #6745/#6781. We focused on the dual-WS issue (#6788) and pipeline-depth (#6811), but this watcher race is independent and untouched.
Suggested fix
Single
watch(isSpeaking, ...)at the composable factory level. The conditional inside it should branch:Move the auto-unwatch logic out (it's a workaround for the duplicate watcher).
Acceptance
watch(isSpeaking, ...)inuseVoiceConversation.ts'speaking' → 'idle'per TTS burst_ttsCooldownTimerset exactly once per TTS completion in hands-free mode