Skip to content

[bug] Muting while a turn is transcribing deletes the words — the bubble is cleared and the in-flight transcript is then dropped or sent by an unrelated 800ms echo timer #420

Description

@serge-ivo

What the user saw

In hands-free voice: finish speaking → the pending bubble shows the words with status Transcribing… → press Mute (or say "mute") → the words vanish from the screen immediately, and depending on timing they are either never sent at all or sent silently a second later with no bubble on screen while it happens.

Expected: muting stops the mic and the agent. It should not cost the user the sentence they already finished saying. Every other path that destroys a pending utterance in this codebase hands the words back to the composer first — mute is the only one that deletes them.

Where it is

packages/sdk/src/voice/use-voice.ts:797-807muteFromCommand, reached by both mute channels (the voice command, and the on-screen button via toggleMute at :1644, wired at store/console/src/pages/InstanceDetail.tsx:1263):

const muteFromCommand = useCallback(() => {
  mutedRef.current = true;
  setMuted(true);
  sttRef.current?.stop();
  ttsRef.current?.cancel();          // stop the agent mid-sentence + drop the queue
  setSpeaking(false);
  speakEndedAtRef.current = Date.now();   // ← leg 2
  stopAudioMonitor();
  setMicOn(false);
  clearVoiceText();                        // ← leg 1
}, [stopAudioMonitor, clearVoiceText]);

The mechanism — two individually-correct decisions

Leg 1 — clearVoiceText() deletes a transcribing utterance whose text has not arrived yet

clearVoiceText() dispatches {type:"clear"}, and reduceDictation (machine.ts:301) returns null. At that moment the dictation is {status:"transcribing", text, heard} — the user has finished speaking and the clip is mid-upload, so the words exist but their final text does not.

The codebase already knows this exact state is worth saving. prepareConversationSwitch (machine.ts:~211-225) singles it out by name:

"A transcribing utterance is the sharpest form of it — they finished speaking and the clip is mid-upload, so the speech exists but its text does not yet. That is recovered to the composer rather than sent (the #175 contract)."

leaveForSwitch (use-voice.ts:1405-1427) does the recover then the clear. muteFromCommand does only the clear.

Blame makes this a two-commit composition, not a single mistake:

Leg 2 — the surviving audio's fate is decided by an unrelated 800 ms timer

Mute deliberately stops the recorder with stop(), not stopDiscard() — that is #228's promise, so "run the tests, mute" still sends the request. So the clip uploads and lands in handleResult, where classifyResult (use-voice.ts:951-955) judges it against readGuard() — which now carries speakEndedAt = <the moment Mute was pressed>. shouldIgnoreResult (machine.ts:52) is isEchoing || paused, and ECHO_GUARD_MS is 800 (machine.ts:18).

Measured against the real modules (packages/sdk/dist/voice/machine.js, node, guard state as muteFromCommand leaves it):

bubble at mute time : {"text":"file the issue about masters games","status":"transcribing", ...}
bubble after mute   : null
switch would recover: "file the issue about masters games"

ECHO_GUARD_MS = 800
  transcript lands  200ms after Mute -> ignore
  transcript lands  700ms after Mute -> ignore
  transcript lands  799ms after Mute -> ignore
  transcript lands  800ms after Mute -> accept
  transcript lands 1200ms after Mute -> accept
  transcript lands 2000ms after Mute -> accept

So:

Whisper latency is ~1-2 s (the code's own estimate, :1121), so both branches are routinely reachable. The 800 ms line has nothing to do with mute: it exists to protect against a cancelled TTS tail.

Leg 3 (related, same root) — mute does not silence the next reply

maybeSpeakResponse (:766-775) has no muted check. When leg 2 sends a turn after mute, the agent's reply is spoken aloud while the UI says "Muted". ADR 0001 M2: "Mute silences both directions at once… Muting an agent that keeps talking is not mute." Today mute cancels only the speech in flight at that instant; it is not a state that suppresses speech that starts afterwards.

Why the console logs show nothing

Checked production for this account: GET /v1/admin/errors?q=voice returns 4 rows total — one noise/kept, three AudioContext suspended. Nothing corresponding to a dropped turn. That is the finding, not the absence of one: the ignore branch is the one silent drop path in the pipeline, so a lost turn leaves no trace anywhere by construction.

What to do — cheapest first

1. One line — stop arming the echo tail when nothing was speaking. Read speaking before cancel() and arm conditionally:

const wasSpeaking = !!ttsRef.current?.speaking;
ttsRef.current?.cancel();
setSpeaking(false);
if (wasSpeaking) speakEndedAtRef.current = Date.now();

The line was added in #153 to cover a cancelled TTS tail. Mute also closes the mic, so when TTS was not speaking the guard protects nothing and its only remaining effect is the 800 ms lottery above. This alone makes the outcome deterministic and preserves the control listener's raised bar during a real cancelled utterance (ADR 0001 M3 — a higher bar, not a closed door).

2. Mute must resolve the pending utterance, not delete it. Give muteFromCommand the leaveForSwitch treatment (:1412-1418) — same helpers, already tested:

const pending = prepareConversationSwitch({ mode, ttsSpeaking, dictation: dictationRef.current }).recoverText;
if (pending) {
  const noise = planNoiseRejection(pending, { gate: gateSnapshot() });
  if (noise.action === "discard") reportClientError("voice", noise.report, { transcript: pending.slice(0,200), path: "mute" });
  else onRecoveredTextRef.current?.(pending);
}
clearVoiceText();

onRecoveredText is already wired in both consumers (InstanceDetail.tsx:287, CodingTab.tsx:195). Worth extracting the recoverText derivation out of prepareConversationSwitch into a named pendingUtterance(dictation) so mute isn't calling a function named "switch".

3. Make the in-flight transcript's destination explicit, not timing-derived. With (1) applied, a post-mute transcript always reaches accept and is sent. Set a flag at mute time that handleResult consults so the arriving transcript is classified recover (→ composer) rather than accept (→ sent). Carve-out for #228: the trailing-command sites (finalize's plan.command === "mute" at :1078, and the gate's onInterim at :350) must keep sending — "run the tests, mute" is a request plus a request for quiet, and #228 exists because latching there threw the request away. Cleanest shape: a parameter — muteFromCommand({ pendingTurn: "send" }) at those two sites, "recover" everywhere else.

4. Make muted suppress TTS that starts latermaybeSpeakResponse: if (mutedRef.current) { setPaused(false); return; }.

Alternatives considered and rejected

Open question for the owner

After a standalone mute during transcribing, should the finished turn be sent or recovered to the composer? I would recover (reasons above, and it keeps leg 3 from mattering). The counter-argument is that the user had finished the sentence and mute was only about what happens next. Worth a decision because it is the one place fixes (3) and (4) could be simplified away.

Acceptance criteria

  • Mute while a transcribing utterance is pending: the words appear in the composer (or are sent, per the decision above) — never nothing.
  • The outcome is identical whether the transcript lands 200 ms or 2000 ms after the mute; no dependence on ECHO_GUARD_MS.
  • "run the tests, mute" (Voice: mute is unreachable while the user is speaking in Whisper STT mode — works in dictation, silently not in OpenAI #228) still sends "run the tests" and does not also leave a copy in the composer.
  • No turn is dropped without a durable log row — the verdict === "ignore" branch at :955 gets a reportClientError like every sibling drop path.
  • While muted, an arriving reply is not spoken aloud.
  • Mute still closes the mic and cancels in-flight + queued TTS immediately (ADR 0001 M1/M2/M4 unchanged; the control listener is untouched, so M3 is unchanged).

Regression risk

  • (1) could let a real cancelled-TTS tail be transcribed — only if the mic were open at cancel time, which the conditional preserves exactly. Guard: a test asserting the tail is armed when ttsSpeaking was true at mute and not when it was false.
  • (2)/(3) risk double-delivery (composer and send). Guard: the Voice: mute is unreachable while the user is speaking in Whisper STT mode — works in dictation, silently not in OpenAI #228 test above, plus one asserting a standalone mute lands the turn in exactly one place.
  • (4) risk of silencing a reply the user wanted. Contained: "repeat" re-speaks it after unmute.
  • Nothing here re-applies a guard to the control path or closes a mute channel, so ADR 0001 M1–M4 all hold; (1) and (4) strengthen M2. Note for whoever ships this: ADR 0001's M2 paragraph says "(Today: muteFromCommand … clears the pending capture.)" — that parenthetical is descriptive of the buggy line, not normative, and should be corrected in the same PR so the next reader doesn't take it as a requirement.

Reproduction status

Mechanism verified by reading + a measurement against the real machine.js decision functions (output above). Not reproduced end-to-end in a browser — it needs a live microphone and a mid-upload button press, which Playwright cannot drive. Recorded as such.

Related: #228 (mute must not eat the words), #281 (the pending utterance; the commit that introduced this), #377 (a rejected turn erased with nothing logged — same class, different path), #175 (the recover-to-composer contract), #153/#388/ADR 0001 (mute reachability).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions