Skip to content

[bug] Repeating "mute" breaks it — one "mute" matches, two or more match nothing and get sent to the agent as a message #456

Closed

Description

@serge-ivo

Saying "mute" once works. Saying it twice does nothing — and ships the word to the agent as a chat message

Live from bd43f4de-… today:

10:58:14  user | "Yes, of course, push, everything has to be pushed."
10:58:54  user | "Mute, mute, mute, mute, mute"

The second message is the command, five times over, and it was delivered to the model as a chat turn.

Measured against the real matcher

"mute"                          split=mute  text=""                             final=mute  partial=mute
"Mute, mute"                    split=null  text="Mute, mute"                   final=null  partial=null
"Mute, mute, mute, mute, mute"  split=null  text="Mute, mute, mute, mute, mute" final=null  partial=null
"mute mute"                     split=null  text="mute mute"                    final=null  partial=null
"Yes push everything, mute"     split=null  text="Yes push everything, mute"    final=null  partial=null

One "mute" matches. Two or more match nothing at all — no command fires, and the full text is sent.

This is the worst possible shape for the failure, because repetition is what a user does when the first attempt appears not to work. The natural recovery action is the one guaranteed to fail, and it publishes the attempt into the conversation. ADR 0001 M1 says mute is reachable in every phase; here it is unreachable precisely when the user is trying hardest to reach it.

Why

convo.ts:834-838, the whole-utterance branch of splitTrailingCommand, requires exact equality after normalisation:

if (commandPhrases(cmd, words, lang).some((p) => normalizeTranscript(p) === norm)) {
    return { command: cmd, text: "" };
}

The built-in phrase is "mute"; the transcript normalises to "mute mute mute mute mute". Not equal → no match. matchVoiceCommand uses the same rule, so the partial path fails identically once a second "mute" lands.

The trailing branch then declines too, because it is restricted to multi-word phrases (:846-853) — deliberately, so "don't forget to mute" stays a message. "mute mute" is not in the built-in phrase list, so nothing matches it either.

Every individual rule here is defensible. The combination means a repeated command is less recognisable than a single one.

Answering the question directly: can it be cut off or filtered?

Yes, and the safest lever is repetition itself as the disambiguator. The reason a bare trailing "mute" is not stripped today is genuine ambiguity — "don't forget to mute" is prose. A repeated bare command word is not ambiguous. No English sentence ends "…, mute mute", and no sentence is "mute mute mute mute mute".

Three changes, cheapest first. (1) alone fixes the reported case.

1. Collapse consecutive repeats before matching (whole-utterance path). In normalizeTranscript, or in a wrapper used by the matcher, fold a run of identical adjacent tokens to one: "mute mute mute mute mute" → "mute". Then the existing exact-equality rule matches and returns {command:"mute", text:""} with no other rule touched. This is narrow — it only affects utterances that are literally a word repeated — and it changes no behaviour for ordinary prose.

2. Strip a repeated trailing command run (trailing path). Extend the multi-word restriction with a repetition clause: a trailing bare command word is kept when it appears once (protecting "don't forget to mute"), and stripped + fired when it appears twice or more:

  • "push everything, mute" → message stays whole, no command (unchanged, correct)
  • "push everything, mute mute" → sends "push everything", fires mute

This is the "smart cut-off" asked for, and it is defensible precisely because repetition is the signal that distinguishes an instruction from a mention.

3. Make the two halves agree. Today the gate can fire mute from a partial (use-voice.ts:336-351) while finalize keeps the word in the message — the inconsistency already noted on #420. Whatever the matcher decides, the same decision should govern both firing and stripping. If mute fired during capture, the word that fired it must not reach the agent.

An open question I could not resolve from the data

Did mute actually fire here? Two possibilities, and they need different fixes:

The transcript cannot distinguish them, which is itself the argument for #425: a gate that fails leaves no trace anywhere.

Alternatives considered and rejected

  • Add "mute mute" to the built-in phrase list. Fixes exactly two repeats and fails at three. The repetition-collapse in (1) handles any count.
  • Match a single bare "mute" anywhere in an utterance. Rejected — this is the rule convo.ts:846-853 exists to prevent, and it would silently truncate "don't forget to mute" while muting. The comment there is right; the fix is to add a repetition signal, not to remove the precision rule.
  • Filter command words out of the message text but still send it. Rejected as a general rule: for a single trailing "mute" we do not know it was a command, so filtering would corrupt a legitimate sentence. Filtering is only safe once the matcher is confident, which is what (1) and (2) establish.
  • Do it in the model prompt ("ignore trailing mute words"). Rejected — a mute that reaches the model has already failed to mute the microphone, which is the actual request.

Acceptance criteria

  • "mute", "mute mute", and "mute mute mute mute mute" all fire mute and send no message.
  • "don't forget to mute" is still sent whole and fires nothing.
  • "push everything, mute mute" sends "push everything" and fires mute.
  • The same rule governs the partial/gate path and finalize, so a fired command never reaches the agent as text.
  • Equivalent coverage for the other repeatable commands (unmute, stop), in every configured language.

Regression risk

  • Repetition-collapsing inside normalizeTranscript would affect every consumer of it, including transcript comparison and the noise filter, where a legitimate doubled word ("very very") should survive. Safer to collapse only when evaluating a command match, not in the shared normaliser.
  • convo.test.ts holds the precision cases for the single-word rule; the new repetition cases belong beside them so the two rules are read together.
  • mute-invariant.test.ts asserts reachability per phase, not per phrasing. Its guard table is the natural place to add "a repeated mute utterance dispatches mute" so this cannot silently regress.

Related: #420 (the same finalize path; my earlier comment there covers the single trailing "mute" that fires but is not stripped), #425 (why we cannot tell whether the gate ran), ADR 0001 M1 (mute reachable at every moment), #228 (why the trailing word must not simply be discarded with the turn).

Activity

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in c95479d, and the last acceptance item in 37b6524. Both live; CI + both deploys green.

    Fix (1) and fix (2) as you specified them; fix (3) needed evidence rather than a matcher rule and is #457's step 3, now also delivered.

    "mute" / "mute mute" / "mute mute mute mute mute"  → mute, nothing sent
    "don't forget to mute"                             → sent whole, nothing fired
    "push everything, mute"                            → sent whole, nothing fired
    "push everything, mute mute"                       → sends "push everything", mutes
    

    Where the collapse lives, and where it does not

    collapseRepeatedRuns is in normalize.ts, not inside normalizeSpeech — your regression note called this exactly right. It sits in that file because that is where a reader would be tempted to fold the repetition once and for all, so that is where the reason not to has to be. The shared normaliser still feeds the transcript comparison and the noise filter, where "very very" must survive.

    Two rules, because adjacent-token folding only sees a script that writes spaces: identical adjacent tokens, plus a single spaceless token that is one unit repeated — which is how "静音静音静音" and "停停" arrive from an engine that emits no separator. Covered in all ten languages, derived from the phrase tables rather than transcribed.

    The collapse reopened #385, and #385's own tests caught it

    Worth recording, because "strictly additive" is exactly what made it dangerous. An account with stopSpeechKeyword: "stop stop" says its own keyword; the fold reads it as "stop"; and "stop" is still a built-in EXIT phrase, because #385 correctly reserved only the phrases that contain the keyword. So hands-free was torn down by the destructive reading the user never chose, through the door the fold had just opened.

    foldedReading therefore declines to fold anything the user has bound, and stripRepeatedTail declines a run that is a bound phrase. boundByUser is one predicate that reservedElsewhere now delegates to, so the two readings of "the user bound this" cannot drift.

    Everything else is additive: the raw transcript is matched first and unchanged; the folded reading is a second attempt; the new trailing pass runs last so it cannot preempt a rule above it.

    Your open question, answered

    Did mute actually fire here?

    For the reported utterance ("Mute, mute, mute, mute, mute" as a whole turn) — measured on the shipped matcher, matchVoiceCommand returned null for two repeats and above, on the partial path as well as the final. So no command fired at any point, and (1)/(2) are the fix. The gate-was-dead branch is not needed to explain this one; #425 stands on its own merits.

    Verification

    • Red first on origin/main: 7 failures, including Mute, mute: expected null to be 'mute' and the M1 guard's "mute mute" reaches nothing via the main transcription path. The two precision-preserving tests pass there, because they hold ground rather than take it.
    • mute-invariant.test.ts gains the repetition case. Why it was missing is worth naming: reachable() asks whether any phrasing reaches mute — the right question for M1 — and SAYS_MUTE's doubled form matched nothing while .some hid it behind the bare word that did. The new assertion is per phrasing, per phase, at one/two/five repetitions.
    • 6959/6959 unit at the time, 7112 now; typechecks, biome, six guards, docs:drift.

    Acceptance criteria

    Closing.

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

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