Talk into the microphone and watch a model size up what you're saying, while you're still saying it.
One model does everything. Granite Switch 4.1 8B is Granite 4.1 8B with speech recognition and adapters built in, all served from one Hugging Face Inference Endpoint:
granite-speech-5.0-470m-turboctc, a 470M English CTC encoder) transcribes
each clip, and the transcript goes into the prompt as ordinary text.guardian-core) screens the words for
harm, profanity and violence.decision) answers the
yes/no questions.Adapters are picked per request by name. Both return a noul: the probability that the answer is yes.
Every request that judges a snippet of speech reuses the same KV cache. Each time the transcript changes, the Space sends 14 requests at once for the default questions: 3 safety checks, 10 yes/no questions and the label. Every one opens with the same user turn holding the words, and only what comes after it differs. So the endpoint prefills the words once, and every request reuses that KV cache.
The adapters don't break the sharing. Both are aLoRAs (activated LoRAs): the adapter switches on at an invocation marker after the words, so the words' turn runs on the plain base model in every request, the label's included. Nothing is computed twice, and the whole board comes back in about 0.2 s: 18 decisions and a label, from one model.
You can see the reuse by timing it. With a long words turn the endpoint had never seen (about 6,000 words, so that prefilling it dominates), the first decision took 0.39 to 1.1 s. A second decision with a different question on the same words took 0.15 to 0.17 s, a guardian-core check 0.23 s and the label 0.21 s; a fresh set of words went back to about 0.4 s. The cached words are shared across questions, across the two adapters and with the base model. Speech recognition is the one step outside it: its prompt holds the audio clip, and the words it returns are what everything else then shares.
This is a remake of the Speech to Decisions demo, which ran on three models through OpenRouter: Parakeet (speech recognition), Jev (the safety check and the yes/no questions) and gpt-oss-120b (the label). Here Granite Switch does all four jobs.
The left column has the microphone and, under it, the live transcript. Tap the microphone to start; the dots around it swell with your voice, and a dotted meter and a timer run while it listens. The right column is the board, which updates about twice a second:
Or play an example. Under the microphone, Pizza Order plays a
24-second recording of a phone order for pizza instead. The clip is
static/examples/pizza-order.mp3, taken from the demo video's audio. It
goes through the same path as the microphone: decoded in the browser, sent
in half-second chunks at the speed it was spoken, and played through the
speakers so you hear what's being judged. It stops by itself at the end, or
press Stop. No microphone is needed. The clip was boosted about 12 dB from
the video's audio: at its original level the room tone between words fell
below the speech threshold, and the board blanked between phrases.
The yes/no questions are editable until you start recording. Each row's question sits in a text field: change it, remove it with the × at the end of its row, or type in the dashed empty row at the end to add one. There are 10 to start with, and you can add your own up to 50 in all. A full board of 50 comes back about as fast as a short one: 53 decisions took 0.25 to 0.28 s, against 0.24 to 0.29 s for 18. While the microphone is on the questions are locked and the rows are plain text, sorted so the likeliest answers sit at the top; they unlock, back in their own order, when you stop. If you edit a question after a recording, the new list is answered about the words already on the page.
Start over in the header clears the transcript and the board and keeps your questions. The i button opens a panel that explains the page.
Silence blanks the board. After 0.5 s with no speech while recording, every judgment is wiped: the label, the safety check and the yes/no bars all show a dash, and a "Waiting for speech" tag appears. No judgment is shown again until you start talking: answers still coming back from before the silence are dropped. When you talk again the board fills back in, and those judgments are about the words said after the silence only, so in practice the board judges the current stretch of speech. The transcript keeps everything.
A phrase short enough to end before the board has judged it ("hello there") would otherwise never be judged: the silence falls while it's still being transcribed. So when a silence falls on words whose judgments never reached the board in full, those words are judged once they're transcribed, and the answers show through the quiet for 3 s, or until you start talking again.
The silence is timed from the last 100 ms frame with speech in it, not from the last half-second chunk, so the board blanks 0.5 s after you stop, not up to a second later.
Every call is a chat completion to the endpoint's OpenAI-compatible API. Both
adapters are aLoRAs, switched on by a control token in the last user turn,
so the adapter is active only from there on. Everything before it runs on the
base model. An adapter is chosen with chat_template_kwargs: {"adapter_name": ...}, and the chat template puts its control token at the adapter's marker:
<guardian> for guardian-core, ### DECISION for decision. Both are
called as the model card
shows.
Each time the words change, one pass sends 3 + N requests at once, one per safety category and one per question, plus the label beside them, and the endpoint batches them. All of them open with the same user turn holding the words (see One KV cache for every judgment).
| Job | Adapter | Prompt | Readout |
|---|---|---|---|
| Safety check | guardian-core | the words, then a user turn opening with <guardian>: the judge instruction, the category's criteria (the Granite Guardian definitions, as Mellea's CRITERIA_BANK has them), and "If the last user's text meets the criteria, return 'yes'; otherwise, return 'no'." Output constrained to {"score": "yes"|"no"} | P("yes") / (P("yes") + P("no")) at the score token |
| Yes/no questions | decision | the words, then a user turn opening with ### DECISION, the question, A: Yes / B: No and "Reply with exactly one option label." | P("A") / (P("A") + P("B")) at the first and only token, from the two label tokens' logprobs, asked for by id with logprob_token_ids (a vLLM extension; the ids come from the endpoint's /tokenize once) |
| Label | none (base model) | the words, then a user turn asking for the topic in 1 to 5 words, as a short noun phrase taken from what they said | the reply's first line, cut to 5 words |
| Speech recognition | none (the ASR cascade) | one user turn holding only the clip, as an input_audio part (base64 WAV); max_tokens: 1, prompt_logprobs: 0 | the prompt as the model saw it, read back from the prompt logprobs: the transcript is the text between user<|end_of_role|> and <|end_of_text|> |
The label runs on its own thread, beside the decisions, so it never holds them up. Up to 3 label requests run at once, the newest answer wins, and a request is given up on after 5 s.
The endpoint has no transcription route. Audio goes in as an input_audio
part of an ordinary chat message; before the model runs, vLLM hands the clip to
the checkpoint's speech recognition model and swaps the clip's <|audio|>
marker in the prompt for the transcript's tokens. So to get the transcript on
its own, the Space sends a user turn holding only the clip, asks for one token,
and reads the prompt back from prompt_logprobs, which spell out every prompt
token the model saw. That costs one generated token, not a repeat of the
transcript. (vLLM's prompt_text field would be simpler, but this endpoint
leaves it empty.)
Each request takes a whole clip and returns its text; there's no streaming mode. The Space gets a live transcript out of it like this:
Transcription and the decisions run on a background thread per session, so the microphone stream never waits on a model.
Measured from a laptop, replaying an 11-second synthesized clip through the app's handlers against a warm endpoint:
| Call | Round trip |
|---|---|
| Speech recognition, 11 s of audio | 0.33 s |
| 15 decisions (3 guardian-core + 12 decision, sent at once) | 0.27 s |
| Base model, one label | 0.11 s |
The status line under the transcript shows the latest timings. Nothing is billed per request: the endpoint bills by the hour while it's running.
The page is hand-built, with no front-end framework, and a small FastAPI server sits behind it. The keys stay on the server.
| File | What it does |
|---|---|
app.py | The server: sessions, the transcription and judgment passes, and the API. |
static/index.html, static/style.css | The page and its styles. |
static/app.js | Microphone capture, posting chunks, and drawing the board. |
Dockerfile | Runs the server with uvicorn on port 7860. |
tests/test_app.py | Offline tests for the readouts and the prompts. |
The API is five routes. Every POST returns the session's whole state as JSON,
and the page redraws from it:
| Route | What it does |
|---|---|
GET /api/config | Model names, safety categories and default questions. It also wakes the endpoint. |
POST /api/session | Sets the yes/no questions, and makes a session if there isn't one. |
POST /api/chunk | Takes a chunk of audio. |
POST /api/stop | Finishes with the audio that's left. |
POST /api/clear | Drops the session and starts a new one. |
The design follows IBM's Granite demo pages, in particular Granite Speech Streaming WebGPU (Apache-2.0): the Carbon design tokens, IBM Plex type, the app card, the record panel with its cluster of dots, the dark info panel, and the tags and pills are adapted from that Space's stylesheet.
To run it locally:
pip install -r requirements.txt
HF_TOKEN=... GRANITE_ENDPOINT_URL=https://<endpoint>/v1 \
PORT=7860 python app.py # http://localhost:7860
pytest tests -q # offline tests
scripts/create_speech_to_decisions_granite_space.py
from the repo root.GRANITE_ENDPOINT_URL: the Granite Switch 4.1 8B endpoint URL, ending in
/v1. The script copies it from HF_ENDPOINT_URL_SWITCH_8B in .env.HF_TOKEN: a token that can call the endpoint.
Without either, the page loads and says which is missing.GRANITE_MODEL_ID (optional): the endpoint's served model name. Read
from /models when it isn't set.VOICE_RMS (optional): the loudness that counts as speech, as a fraction
of full scale. Defaults to 0.01. Lower it for a quiet microphone.QUIET_AFTER_S (optional): how many seconds of silence blank the board.
Defaults to 0.5.MIN_NEW_AUDIO_S (optional): how much new audio triggers the next
transcription. Defaults to 0.5.The browser asks for microphone access, which needs HTTPS. A Space has it; so
does localhost.
asr_model_id in its
config.json) restores case and punctuation and handles other languages.Talk into the microphone and watch a model size up what you're saying, while you're still saying it.
One model does everything. Granite Switch 4.1 8B is Granite 4.1 8B with speech recognition and adapters built in, all served from one Hugging Face Inference Endpoint:
granite-speech-5.0-470m-turboctc, a 470M English CTC encoder) transcribes
each clip, and the transcript goes into the prompt as ordinary text.guardian-core) screens the words for
harm, profanity and violence.decision) answers the
yes/no questions.Adapters are picked per request by name. Both return a noul: the probability that the answer is yes.
Every request that judges a snippet of speech reuses the same KV cache. Each time the transcript changes, the Space sends 14 requests at once for the default questions: 3 safety checks, 10 yes/no questions and the label. Every one opens with the same user turn holding the words, and only what comes after it differs. So the endpoint prefills the words once, and every request reuses that KV cache.
The adapters don't break the sharing. Both are aLoRAs (activated LoRAs): the adapter switches on at an invocation marker after the words, so the words' turn runs on the plain base model in every request, the label's included. Nothing is computed twice, and the whole board comes back in about 0.2 s: 18 decisions and a label, from one model.
You can see the reuse by timing it. With a long words turn the endpoint had never seen (about 6,000 words, so that prefilling it dominates), the first decision took 0.39 to 1.1 s. A second decision with a different question on the same words took 0.15 to 0.17 s, a guardian-core check 0.23 s and the label 0.21 s; a fresh set of words went back to about 0.4 s. The cached words are shared across questions, across the two adapters and with the base model. Speech recognition is the one step outside it: its prompt holds the audio clip, and the words it returns are what everything else then shares.
This is a remake of the Speech to Decisions demo, which ran on three models through OpenRouter: Parakeet (speech recognition), Jev (the safety check and the yes/no questions) and gpt-oss-120b (the label). Here Granite Switch does all four jobs.
The left column has the microphone and, under it, the live transcript. Tap the microphone to start; the dots around it swell with your voice, and a dotted meter and a timer run while it listens. The right column is the board, which updates about twice a second:
Or play an example. Under the microphone, Pizza Order plays a
24-second recording of a phone order for pizza instead. The clip is
static/examples/pizza-order.mp3, taken from the demo video's audio. It
goes through the same path as the microphone: decoded in the browser, sent
in half-second chunks at the speed it was spoken, and played through the
speakers so you hear what's being judged. It stops by itself at the end, or
press Stop. No microphone is needed. The clip was boosted about 12 dB from
the video's audio: at its original level the room tone between words fell
below the speech threshold, and the board blanked between phrases.
The yes/no questions are editable until you start recording. Each row's question sits in a text field: change it, remove it with the × at the end of its row, or type in the dashed empty row at the end to add one. There are 10 to start with, and you can add your own up to 50 in all. A full board of 50 comes back about as fast as a short one: 53 decisions took 0.25 to 0.28 s, against 0.24 to 0.29 s for 18. While the microphone is on the questions are locked and the rows are plain text, sorted so the likeliest answers sit at the top; they unlock, back in their own order, when you stop. If you edit a question after a recording, the new list is answered about the words already on the page.
Start over in the header clears the transcript and the board and keeps your questions. The i button opens a panel that explains the page.
Silence blanks the board. After 0.5 s with no speech while recording, every judgment is wiped: the label, the safety check and the yes/no bars all show a dash, and a "Waiting for speech" tag appears. No judgment is shown again until you start talking: answers still coming back from before the silence are dropped. When you talk again the board fills back in, and those judgments are about the words said after the silence only, so in practice the board judges the current stretch of speech. The transcript keeps everything.
A phrase short enough to end before the board has judged it ("hello there") would otherwise never be judged: the silence falls while it's still being transcribed. So when a silence falls on words whose judgments never reached the board in full, those words are judged once they're transcribed, and the answers show through the quiet for 3 s, or until you start talking again.
The silence is timed from the last 100 ms frame with speech in it, not from the last half-second chunk, so the board blanks 0.5 s after you stop, not up to a second later.
Every call is a chat completion to the endpoint's OpenAI-compatible API. Both
adapters are aLoRAs, switched on by a control token in the last user turn,
so the adapter is active only from there on. Everything before it runs on the
base model. An adapter is chosen with chat_template_kwargs: {"adapter_name": ...}, and the chat template puts its control token at the adapter's marker:
<guardian> for guardian-core, ### DECISION for decision. Both are
called as the model card
shows.
Each time the words change, one pass sends 3 + N requests at once, one per safety category and one per question, plus the label beside them, and the endpoint batches them. All of them open with the same user turn holding the words (see One KV cache for every judgment).
| Job | Adapter | Prompt | Readout |
|---|---|---|---|
| Safety check | guardian-core | the words, then a user turn opening with <guardian>: the judge instruction, the category's criteria (the Granite Guardian definitions, as Mellea's CRITERIA_BANK has them), and "If the last user's text meets the criteria, return 'yes'; otherwise, return 'no'." Output constrained to {"score": "yes"|"no"} | P("yes") / (P("yes") + P("no")) at the score token |
| Yes/no questions | decision | the words, then a user turn opening with ### DECISION, the question, A: Yes / B: No and "Reply with exactly one option label." | P("A") / (P("A") + P("B")) at the first and only token, from the two label tokens' logprobs, asked for by id with logprob_token_ids (a vLLM extension; the ids come from the endpoint's /tokenize once) |
| Label | none (base model) | the words, then a user turn asking for the topic in 1 to 5 words, as a short noun phrase taken from what they said | the reply's first line, cut to 5 words |
| Speech recognition | none (the ASR cascade) | one user turn holding only the clip, as an input_audio part (base64 WAV); max_tokens: 1, prompt_logprobs: 0 | the prompt as the model saw it, read back from the prompt logprobs: the transcript is the text between user<|end_of_role|> and <|end_of_text|> |
The label runs on its own thread, beside the decisions, so it never holds them up. Up to 3 label requests run at once, the newest answer wins, and a request is given up on after 5 s.
The endpoint has no transcription route. Audio goes in as an input_audio
part of an ordinary chat message; before the model runs, vLLM hands the clip to
the checkpoint's speech recognition model and swaps the clip's <|audio|>
marker in the prompt for the transcript's tokens. So to get the transcript on
its own, the Space sends a user turn holding only the clip, asks for one token,
and reads the prompt back from prompt_logprobs, which spell out every prompt
token the model saw. That costs one generated token, not a repeat of the
transcript. (vLLM's prompt_text field would be simpler, but this endpoint
leaves it empty.)
Each request takes a whole clip and returns its text; there's no streaming mode. The Space gets a live transcript out of it like this:
Transcription and the decisions run on a background thread per session, so the microphone stream never waits on a model.
Measured from a laptop, replaying an 11-second synthesized clip through the app's handlers against a warm endpoint:
| Call | Round trip |
|---|---|
| Speech recognition, 11 s of audio | 0.33 s |
| 15 decisions (3 guardian-core + 12 decision, sent at once) | 0.27 s |
| Base model, one label | 0.11 s |
The status line under the transcript shows the latest timings. Nothing is billed per request: the endpoint bills by the hour while it's running.
The page is hand-built, with no front-end framework, and a small FastAPI server sits behind it. The keys stay on the server.
| File | What it does |
|---|---|
app.py | The server: sessions, the transcription and judgment passes, and the API. |
static/index.html, static/style.css | The page and its styles. |
static/app.js | Microphone capture, posting chunks, and drawing the board. |
Dockerfile | Runs the server with uvicorn on port 7860. |
tests/test_app.py | Offline tests for the readouts and the prompts. |
The API is five routes. Every POST returns the session's whole state as JSON,
and the page redraws from it:
| Route | What it does |
|---|---|
GET /api/config | Model names, safety categories and default questions. It also wakes the endpoint. |
POST /api/session | Sets the yes/no questions, and makes a session if there isn't one. |
POST /api/chunk | Takes a chunk of audio. |
POST /api/stop | Finishes with the audio that's left. |
POST /api/clear | Drops the session and starts a new one. |
The design follows IBM's Granite demo pages, in particular Granite Speech Streaming WebGPU (Apache-2.0): the Carbon design tokens, IBM Plex type, the app card, the record panel with its cluster of dots, the dark info panel, and the tags and pills are adapted from that Space's stylesheet.
To run it locally:
pip install -r requirements.txt
HF_TOKEN=... GRANITE_ENDPOINT_URL=https://<endpoint>/v1 \
PORT=7860 python app.py # http://localhost:7860
pytest tests -q # offline tests
scripts/create_speech_to_decisions_granite_space.py
from the repo root.GRANITE_ENDPOINT_URL: the Granite Switch 4.1 8B endpoint URL, ending in
/v1. The script copies it from HF_ENDPOINT_URL_SWITCH_8B in .env.HF_TOKEN: a token that can call the endpoint.
Without either, the page loads and says which is missing.GRANITE_MODEL_ID (optional): the endpoint's served model name. Read
from /models when it isn't set.VOICE_RMS (optional): the loudness that counts as speech, as a fraction
of full scale. Defaults to 0.01. Lower it for a quiet microphone.QUIET_AFTER_S (optional): how many seconds of silence blank the board.
Defaults to 0.5.MIN_NEW_AUDIO_S (optional): how much new audio triggers the next
transcription. Defaults to 0.5.The browser asks for microphone access, which needs HTTPS. A Space has it; so
does localhost.
asr_model_id in its
config.json) restores case and punctuation and handles other languages.