Skip to content

discovery(voice): two isSpeaking watchers race on TTS completion (useVoiceConversation) #6823

Description

@mrveiss

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:

  1. Inner watcher: _resumeAutoListening()
  2. 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

  • One watch(isSpeaking, ...) in useVoiceConversation.ts
  • State transitions: exactly one 'speaking' → 'idle' per TTS burst
  • _ttsCooldownTimer set exactly once per TTS completion in hands-free mode

Activity

  1. github-actions commented on May 9, 2026

    @github-actions
    Contributor

    PR #7334 (merged to Dev_new_gui) references this issue with a close keyword.

    fix(voice): collapse 2 isSpeaking watchers into 1 (#6823)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  2. mrveiss commented on May 9, 2026

    @mrveiss
    OwnerAuthor

    ✅ Closed — implementation merged to Dev_new_gui.

    Fix commit: `669f66a0a` → squash-merged via PR #7334

    Acceptance Criteria:

    • ✅ Exactly one `watch(isSpeaking, ...)` in `useVoiceConversation.ts` — verified by grep. The factory-level watcher is the single source of truth.
    • ✅ State transitions: exactly one `speaking → idle` per TTS burst — eliminated the inner watcher in `_dispatchTranscript` that fired alongside the outer watcher.
    • ✅ `_ttsCooldownTimer` set exactly once per TTS completion in hands-free mode — outer watcher routes through `_resumeAutoListening` (which has cooldown), not the redundant `_onSpeakingDone` (which omitted it). `_onSpeakingDone` is now removed entirely (zero remaining callers).

    Approach: The two helpers `_resumeAutoListening` and `_onSpeakingDone` were 95% duplicate; the only difference was `_resumeAutoListening` set the hands-free TTS cooldown timer (`_ttsCooldownTimer`) which is critical for avoiding echo feedback loops. Replacing the outer watcher's `_onSpeakingDone()` call with `_resumeAutoListening()` is strictly better — same semantics for full-duplex/walkie-talkie, plus correct cooldown for hands-free.

    Walkie-talkie semantics preserved: `_resumeAutoListening` only resumes for full-duplex / hands-free per existing logic at lines 463-475. Walkie-talkie one-shot mode stays idle as before.

    Wiring verified: `vue-tsc --noEmit -p tsconfig.app.json` — file type-clean (other pre-existing TS errors in App.vue, CommandPalette etc. are unrelated to this change).

    Discovery issues filed: none — the fix removed code rather than adding new patterns.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions