Summary
When the mic is muted there is no hands-free way back. Saying "unmute" does nothing, so the user must tap the UI — which defeats hands-free, the one mode where mute is most likely to be used.
Why it does nothing today
The command vocabulary is a closed union of two values:
export type VoiceCommand = "repeat" | "mute"; // packages/sdk/src/voice/convo.ts:106
matchVoiceCommand (convo.ts:231) tests the repeat phrases, then the mute phrases, then returns null. The word "unmute" appears nowhere in the codebase except button tooltips and comments.
Verified on live instances: commandsEnabled: true, muteWords: [], repeatWords: [] — nothing is misconfigured, the feature simply does not exist.
It is a clean no-op rather than a misfire: phraseMatchesTranscript (convo.ts:214) requires a single-word phrase to BE the whole utterance, so "unmute" !== "mute" and saying it cannot accidentally re-mute.
Already done — do not rebuild
The background listener from the original criterion 2 shipped with #153. The always-on control listener runs whenever engaged && !mainRecording (use-voice.ts:1094), and muteFromCommand (:580) clears micOn while leaving convoOn set — so it is live for the entire muted period. Its own comment: "It runs while the agent speaks / thinks / is muted (mic closed)".
The browser is already listening the whole time you are muted and discarding the word purely because it is not in the vocabulary. This is a vocabulary + wiring change, not new audio plumbing.
Implementation map
| Step |
File |
Where |
Add "unmute" to the union |
packages/sdk/src/voice/convo.ts |
:106 |
Add UNMUTE_BY_LANG |
convo.ts |
next to MUTE_BY_LANG :119 — cover the same 10 languages |
| Match it before mute |
convo.ts |
matchVoiceCommand :231 |
unmute?: string[] |
convo.ts |
VoiceCommandWords :110 |
| Ref + hydrate |
packages/sdk/src/voice/use-voice.ts |
:395 (ref), :421 + :813 (hydrate) |
| Pass to the 4 call sites |
use-voice.ts |
:616, :684, :703, :788 |
| Handle the command |
use-voice.ts |
handleControlResult :604 |
| Parse per-instance |
packages/sdk/src/voice/config.ts |
:31 type, :94 parse, :123 profile-fallback pair |
Profile field voiceUnmuteWords |
workers/api/src/lib/preferences.ts |
:63 type, :112 default, :152 parse |
| Settings UI |
store/console/src/components/VoiceFields.tsx |
:40, :63, :272 (mirror the mute-words input) |
Traps
- Reuse the existing unmute path — do not write a new one.
toggleMute (use-voice.ts:1121) already handles the stale-ref problem, with a comment recording the bug it fixed: setting only React state left startListening reading a stale mutedRef.current === true, so "unmute did nothing". A fresh implementation that sets state without flipping the ref reproduces exactly the symptom this ticket is about.
- Only match while muted. Otherwise "unmute" said mid-sentence during a normal turn is a stray trigger. Gate on
mutedRef.current.
- Order matters. Check unmute before mute in
matchVoiceCommand, so a user-configured phrase containing both words resolves the way they expect.
- Custom words REPLACE built-ins (existing behaviour for repeat/mute) — keep that contract for unmute rather than merging.
Known limitation (accept, don't file later)
The control listener is a Web Speech dictation loop and is null on browsers without it — notably iOS Safari. Unmute-by-voice will not work there, the same limitation mute-by-voice already has. State it in the UI copy rather than treating it as a defect.
Acceptance criteria
Related
Summary
When the mic is muted there is no hands-free way back. Saying "unmute" does nothing, so the user must tap the UI — which defeats hands-free, the one mode where mute is most likely to be used.
Why it does nothing today
The command vocabulary is a closed union of two values:
matchVoiceCommand(convo.ts:231) tests the repeat phrases, then the mute phrases, then returnsnull. The word "unmute" appears nowhere in the codebase except button tooltips and comments.Verified on live instances:
commandsEnabled: true,muteWords: [],repeatWords: []— nothing is misconfigured, the feature simply does not exist.It is a clean no-op rather than a misfire:
phraseMatchesTranscript(convo.ts:214) requires a single-word phrase to BE the whole utterance, so"unmute" !== "mute"and saying it cannot accidentally re-mute.Already done — do not rebuild
The background listener from the original criterion 2 shipped with #153. The always-on control listener runs whenever
engaged && !mainRecording(use-voice.ts:1094), andmuteFromCommand(:580) clearsmicOnwhile leavingconvoOnset — so it is live for the entire muted period. Its own comment: "It runs while the agent speaks / thinks / is muted (mic closed)".The browser is already listening the whole time you are muted and discarding the word purely because it is not in the vocabulary. This is a vocabulary + wiring change, not new audio plumbing.
Implementation map
"unmute"to the unionpackages/sdk/src/voice/convo.ts:106UNMUTE_BY_LANGconvo.tsMUTE_BY_LANG:119— cover the same 10 languagesconvo.tsmatchVoiceCommand:231unmute?: string[]convo.tsVoiceCommandWords:110packages/sdk/src/voice/use-voice.ts:395(ref),:421+:813(hydrate)use-voice.ts:616,:684,:703,:788use-voice.tshandleControlResult:604packages/sdk/src/voice/config.ts:31type,:94parse,:123profile-fallback pairvoiceUnmuteWordsworkers/api/src/lib/preferences.ts:63type,:112default,:152parsestore/console/src/components/VoiceFields.tsx:40,:63,:272(mirror the mute-words input)Traps
toggleMute(use-voice.ts:1121) already handles the stale-ref problem, with a comment recording the bug it fixed: setting only React state leftstartListeningreading a stalemutedRef.current === true, so "unmute did nothing". A fresh implementation that sets state without flipping the ref reproduces exactly the symptom this ticket is about.mutedRef.current.matchVoiceCommand, so a user-configured phrase containing both words resolves the way they expect.Known limitation (accept, don't file later)
The control listener is a Web Speech dictation loop and is
nullon browsers without it — notably iOS Safari. Unmute-by-voice will not work there, the same limitation mute-by-voice already has. State it in the UI copy rather than treating it as a defect.Acceptance criteria
voiceUnmuteWordson the profile +unmuteWordsper-instance, with the per-instance value overriding a non-empty profile value (same precedence as mute;config.test.tscovers the pattern).convo.test.ts(matching, ordering vs mute, whole-utterance precision) andconfig.test.ts(profile→instance merge).Related