Voice control: mic -> STT -> intent -> production MCP
How to read this page
A Plain and a Clear version of this page have not been written yet. What follows is the document itself.
Precise — the source document
This is the document. Rendered from the repository at the commit above, with nothing rewritten for the web. A gate re-renders it on every deploy and fails the build if a single byte differs.
What this is: the voice-control grammar for the operator pedalboard. The operator speaks; the browser
captures the mic; an STT engine (whisper / faster-whisper, the same engine as uni-bcast-captions)
transcribes to text; an intent resolver maps the text to one production-MCP tools/call; the MCP
fires it under the same audit + session-gating as a button click. Anything the deterministic grammar does
not match falls through to an LLM that proposes a tool call (still gated).
This pairs with DESIGN.md (the panel) and liveview-route.md (the LiveView). The fixed tool surface is
in docs/UNI_PRODUCTION_PLATFORM.md; this doc adds no verbs. Voice has no extra privilege - it routes
through the identical MCP path, so gated verbs stay gated and a "go live" spoken command still needs a
human approval.
Evidence posture: DESIGN/REFERENCE, status pending. Nothing here runs yet. Honesty footer at the
foot.
1. The pipeline
mic (getUserMedia / operator headset)
-> STT (whisper-cpp / faster-whisper; the captions engine reused) -> transcript text
-> NORMALIZE (lowercase, strip filler, number words -> digits)
-> GRAMMAR MATCH (the table in section 2; deterministic, fast, no network)
|- hit -> { tool, args } -> confirm-if-risky -> MCP tools/call
|- miss -> LLM FALLBACK (section 4): free text -> Claude -> proposed { tool, args }
-> ALWAYS show the proposal to the operator before firing -> MCP tools/call
-> the same audit + session-gating as a click (gated verbs park pending; no extra privilege)
-> the panel's read poll reflects the result
Three honesty rules baked into the pipeline:
- Show what was heard. The recognized transcript is displayed (the footer "last heard" line) before any irreversible action; low-confidence STT (below a threshold) is shown but not auto-fired.
- Confirm the risky ones by voice or tap. Outward-facing verbs (
start_broadcast,stop_broadcast,admit_guest,schedule) require an explicit second confirmation - spoken ("confirm go live") or a tap - on top of the MCP human-gate. Voice cannot bypass the gate. - The MCP is still the authority. The intent resolver only chooses a tool + args; it never mutates show state directly. Gating + audit happen in the MCP.
2. The deterministic grammar (spoken command -> tool call)
Patterns are matched case-insensitively against the normalized transcript. {...} are captured slots.
Scene words map to the fixed scene set COLONY GLASS GUESTS CLIP NEWSDESK TITLE STANDBY PIP.
| Spoken (examples) | Intent | MCP tool | Args | Gating |
|---|---|---|---|---|
| "cut to colony" / "take colony" / "go to the colony" | cut | cut_to |
{scene:"COLONY", transition:"cut"} |
session-auth |
| "fade to glass" / "dissolve to glass over 600" | cut (fade) | cut_to |
{scene:"GLASS", transition:"fade", ms:600} |
session-auth |
| "cut to guests" / "panel" / "two shot" | cut | cut_to |
{scene:"GUESTS"} |
session-auth |
| "go to standby" / "drop to standby" | cut | cut_to |
{scene:"STANDBY"} |
session-auth |
| "picture in picture" / "p i p" | cut | cut_to |
{scene:"PIP"} |
session-auth |
| "music up" / "music to twenty percent" | level | set_music_volume |
{level:0.20} |
session-auth |
| "music down" / "quieter" | level (delta) | set_music_volume |
{level: cur-0.05} |
session-auth |
| "duck the music" / "duck" | duck on | duck |
{on:true} |
session-auth |
| "unduck" / "music back up" / "bring the music back" | duck off | duck |
{on:false} |
session-auth |
| "narrate {text}" / "say {text}" / "read this: {text}" | narrate | narrate |
{text, lang:"en"} |
session-auth |
| "narrate in spanish {text}" / "in hindi say {text}" | narrate (lang) | narrate |
`{text, lang:"es" | "hi"}` |
| "lower third for {name}" / "name strap {name}" | overlay | set_overlay |
{layer:"lowerThird", payload:{visible:true, title:"{name}", kicker:"UNI EXPERT", tone:"ok"}} |
session-auth |
| "clear the lower third" / "hide the name" | overlay hide | set_overlay |
{layer:"lowerThird", payload:{visible:false}} |
session-auth |
| "ticker {text}" / "add to the ticker {text}" | overlay ticker | set_overlay |
{layer:"ticker", payload:[...,{text,tone:"ok"}]} |
session-auth |
| "title card {text}" / "full screen title {text}" | overlay title | set_overlay |
{layer:"title", payload:{visible:true, text:"{text}"}} |
session-auth |
| "captions on" / "captions off" | overlay caption | set_overlay |
`{layer:"caption", payload:{visible:true | false}}` |
| "roll {clipId}" / "roll BnB phase one" / "play the BnB phase one clip" | roll | roll_clip |
{clipId:"BNB-P1", mode:"cut"} |
session-auth |
| "queue {clipId}" / "line up {clipId}" | roll (queue) | roll_clip |
{clipId, mode:"queue"} |
session-auth |
| "start the interview" / "interview segment" | segment | start_segment |
{template:"Interview", params:{}} |
session-auth |
| "panel of three" / "go to panel" | segment / layout | start_segment |
{template:"Panel"} |
session-auth |
| "talking head" / "single shot" | layout | set_layout |
{template:"talking-head"} |
session-auth |
| "drop the guest" / "send {name} to the green room" | remove | remove_guest |
{guestId:"{resolved}"} |
session-auth |
| "admit the guest" / "bring {name} up" / "send {name} to air" | admit | admit_guest |
{guestId:"{resolved}"} |
HUMAN-GATED |
| "save this slot" / "schedule this run of show" | schedule | schedule |
{slot, runOfShow} |
HUMAN-GATED |
| "go live" / "we are live" / "start the broadcast" | go live | start_broadcast |
{target:"youtube"} (dry-run then confirm) |
HUMAN-GATED + 2-step confirm |
| "stop the broadcast" / "we are off air" / "kill the stream" | stop | stop_broadcast |
{} (dry-run then confirm) |
HUMAN-GATED + 2-step confirm |
| "open a session" / "open the live session" | session | open_session |
{verbs:[in-show]} (operator pre-auth) |
one human act |
| "close the session" | session | close_session |
{} |
- |
| "what's on air" / "show state" | read | get_show_state |
{} |
read (never gated) |
Slot resolution:
- Scenes: a small synonym map ("the colony"->COLONY, "the os"/"glass"/"cockpit"->GLASS, "guests"/"panel"->GUESTS, "clip"->CLIP, "news desk"->NEWSDESK, "title"->TITLE, "standby"->STANDBY, "pip"/"picture in picture"->PIP).
- Clip ids: spoken phrases map to catalog ids via the catalog's title/alias index (e.g. "BnB phase
one" -> the catalog row whose alias is "BnB phase 1" ->
clipId BNB-P1). The resolver fetches the catalog vialist_clipsand fuzzy-matches; ambiguity is read back ("did you mean BNB-P1 or BNB-P10?"). - Guest names: matched against
list_guestsnames; ambiguity is read back. - Numbers/percent: "twenty percent" -> 0.20; "over six hundred" -> ms:600.
A reference resolver (deterministic, no LLM, runs in the browser before any fallback):
// voice-intents.js (reference) - returns {tool, args, risky} or null on a miss.
function resolveIntent(transcript, ctx) { // ctx = { music, clips, guests }
const t = transcript.toLowerCase().trim();
const scene = matchScene(t); // synonym map -> one of the 8 scene names
if (/^(cut|take|go) to|^take /.test(t) && scene)
return { tool: "cut_to", args: { scene, transition: /fade|dissolve/.test(t) ? "fade" : "cut",
...(msFrom(t) ? { ms: msFrom(t) } : {}) } };
if (/duck the music|^duck\b/.test(t)) return { tool: "duck", args: { on: true } };
if (/unduck|music back|bring the music/.test(t)) return { tool: "duck", args: { on: false } };
if (/music (up|down|to)/.test(t)) {
const pct = percentFrom(t); const cur = ctx.music?.volume ?? 0.18;
const level = pct != null ? pct : /down|quieter/.test(t) ? cur - 0.05 : cur + 0.05;
return { tool: "set_music_volume", args: { level: clamp01(level) } };
}
let m;
if ((m = t.match(/^(?:narrate|say|read this:?)\s+(.+)/)))
return { tool: "narrate", args: { text: cap(m[1]), lang: langFrom(t) } };
if ((m = t.match(/lower third for (.+)|name strap (.+)/)))
return { tool: "set_overlay", args: { layer: "lowerThird",
payload: { visible: true, kicker: "UNI EXPERT", title: cap(m[1]||m[2]), tone: "ok" } } };
if (/clear the lower third|hide the name/.test(t))
return { tool: "set_overlay", args: { layer: "lowerThird", payload: { visible: false } } };
if ((m = t.match(/^(?:roll|play)\s+(.+)/))) {
const clipId = resolveClip(m[1], ctx.clips); if (clipId)
return { tool: "roll_clip", args: { clipId, mode: /queue|line up/.test(t) ? "queue" : "cut" } };
}
if (/admit the guest|bring .* up|send .* to air/.test(t)) {
const guestId = resolveGuest(t, ctx.guests);
return { tool: "admit_guest", args: { guestId }, risky: true }; // human-gated
}
if (/go live|we are live|start the broadcast/.test(t))
return { tool: "start_broadcast", args: { target: "youtube" }, risky: true }; // gated + 2-step
if (/stop the broadcast|off air|kill the stream/.test(t))
return { tool: "stop_broadcast", args: {}, risky: true }; // gated + 2-step
return null; // -> LLM fallback
}
risky:true intents always require the spoken/tap confirmation AND remain MCP-human-gated. The browser
then calls mcp(tool, args) (the same function in control.html / the same handle_event path in
ControlLive).
3. Confirmation + safety behaviour
- Read-back before firing irreversible verbs. For any
riskyintent the UI speaks/shows "Going live to YouTube - say 'confirm' or tap GO LIVE" and waits. Only a second explicit confirm starts the MCP 2-step (dryRunthenconfirm), which is itself human-gated. - STT confidence gate. Below a confidence threshold the transcript is shown but the intent is parked, not fired ("did you say 'cut to colony'? tap to confirm"). Mishears never cut the show silently.
- Session scope. When no live session is open, even in-show voice verbs park pending (the MCP gate). Opening a session ("open the live session") is itself a spoken intent that sets the operator allowlist - operator pre-authorization, not agent self-approval.
- No voice privilege escalation. The voice path cannot reach a verb the click path cannot; both share the identical MCP call + gating. There is no "voice override" of the human gate on outward verbs.
4. LLM fallback (free text -> tool call)
For anything the grammar misses ("bring up Dr Rivera's lower third and start the interview, but keep the music low"), the transcript (or the typed command box) goes to an LLM (Claude over the MCP - the same on-air persona infra, but here used only as an intent compiler). Contract:
- Input: the free-text command + a compact tool catalog (the fixed verb table + current
ctxfrom the read tools: scenes, clips, guests, music level) so the LLM resolves slots against real ids. - Output: a strict JSON array of
{tool, args}proposals from the fixed verb set only - the LLM is system-prompted that it may emit no verb outside the table and may not invent scene/clip ids. - Gate: the proposals are shown to the operator as queued chips ("set_overlay lowerThird Dr Rivera
- start_segment Interview - set_music_volume 0.10"); the operator confirms (tap or "do it") before any
are fired. Each then runs through the MCP with its normal gating - so an LLM that proposes
start_broadcaststill hits the human gate + 2-step.
- start_segment Interview - set_music_volume 0.10"); the operator confirms (tap or "do it") before any
are fired. Each then runs through the MCP with its normal gating - so an LLM that proposes
- Honesty: the LLM compiles intent; it does not act. It cannot self-approve and cannot bypass the gate. Multi-step proposals are fired in order, each audited; a denied step stops the chain (no auto-retry).
Reference call shape (the resolver posts to the MCP's intent/LLM path; the exact tool name is fixed by the
MCP server - command / an intent tool - and this resolver matches it, adding no verb):
POST /prod-mcp
{ "method":"tools/call",
"params":{ "name":"command",
"arguments":{ "text":"lower third for Dr Rivera then start the interview, music low",
"context":{ "scene":"NEWSDESK", "music":{"volume":0.18},
"guests":[{"guestId":"g_rivera","name":"Dr A. Rivera"}] } } } }
The MCP/producer returns the proposed {tool,args} list (or, if configured to act, runs them through the
gate). The UI always surfaces the proposals before any outward verb fires.
Status (honest)
- This is a DESIGN/REFERENCE grammar, status
pending. No STT capture, intent resolver, or LLM fallback is deployed; the JS above is reference target shape, not running code. - No banned-unqualified word is used as a claim (no: verified, proven, guaranteed, isolated, secure, 100%, certified, real). That voice has no extra privilege over the click path, and that risky verbs cannot be voice-bypassed, is the intended contract, pending confirmation by a captured run (GAP G-PA, Class-Sec).
- The tool names, args, scene set, and gating are taken as captured from
docs/UNI_PRODUCTION_PLATFORM.md; the overlay payloads matchproduction/schemas/broadcast.schema.json. The grammar invents no verb and no scene/clip id. - STT/intent latency and accuracy are unmeasured and overlap GAP G-CAP (real-time multilingual caption
latency/quality pending measurement); voice-command latency is
pendinglikewise. - Live-appliance safety: the voice path never targets the business stack (
solutionwright-*, odoo, jitsi, cloudflared, portainer); the producer/LLM only proposes and cannot self-approve. Every action routes through the human approval gate exactly as the click path does.
sha256 ba810069b4c4c2eb — of the original file, so what was ingested stays checkable.