Voice commands: press to talk, say it back¶
Exercise voice-commands-annex.pdf · PDF
You press a button and say "new note homework". The words appear on the screen while you speak, the note is created, and the computer answers out loud: "Created note Homework." Then you say "open the pod bay doors". The app answers: I heard "open the pod bay doors". That is not a command. Did the ear fail, or the vocabulary?
The ear worked perfectly. Those are two different problems, and keeping them apart is what this page is about. In the last page your app opened its eyes. Now it gets an ear and a mouth, with nothing to install, and it learns the most useful sentence in voice design: "I did not understand."
What you will learn
- The difference between speech recognition and speech synthesis, and the object for each
- Which browsers have speech recognition, and where your voice is processed in each case
- How to design a voice vocabulary: what the user says, what the app does, what it answers
- How to start a recognition, read its interim and final results, and handle its error codes
- How to turn a transcript into a command with a pure function, and reuse it for a typed fallback
- The trap: speech is not always processed on your computer, unlike the video in the last page
An ear and a mouth¶
The Web Speech API is the part of the browser that handles voice. It has two separate halves: speech recognition turns speech into text, and speech synthesis turns text into speech.
| Speech recognition (the ear) | Speech synthesis (the mouth) | |
|---|---|---|
| Direction | Speech → text | Text → speech |
| Object | new SpeechRecognition() |
window.speechSynthesis + new SpeechSynthesisUtterance(text) |
| In the pipeline | Sensor (microphone) + model | Feedback |
| Permission | Asks for the microphone | Needs no permission |
Which browsers?¶
The two halves are not equally supported. This table decides which browser you test in (checked against MDN's compatibility data, November 2026):
| Browser | Recognition | Synthesis |
|---|---|---|
| Chrome, Edge | Yes: SpeechRecognition since version 139 (August 2025). Older versions only have the prefixed name webkitSpeechRecognition |
Yes |
| Safari (14.1+) | Yes, but only as webkitSpeechRecognition |
Yes |
| Firefox on desktop | Arriving now. For years it had none. Mozilla announced in autumn 2026 that it intends to ship it on desktop, on-device only. MDN lists version 160 and Mozilla's announcement says 161, both due in November 2026 | Yes |
| Firefox on Android | Not yet | Yes |
So: use Chrome or Edge 139+ for this unit. The mouth works almost everywhere. And browser support changes while you are reading this, so your code must never assume: it asks "do you have an ear?" before using it.
A prefix is not a different API
A prefix like webkit is how browsers used to ship features that were not finished. webkitSpeechRecognition and SpeechRecognition are the same class under two names, which is why your code looks for both.
Where does your voice go?¶
The video in the last page was analysed in the tab. When you press Speak in Chrome, where does your voice go?
By default, to a server. MDN puts it bluntly: "Your audio is sent to a web service for recognition processing, so it won't work offline." Two consequences: without internet you get the error network, and your words leave the room.
Since Chrome 139 a page can ask for on-device recognition instead, with recognition.processLocally = true. Then neither the audio nor the transcript goes to a third-party service, at the price of a one-time language pack download per language.
| Where it runs | Your audio | Works offline? |
|---|---|---|
Chrome, default (processLocally is false) |
Sent to a recognition service | No: error network |
Chrome, processLocally = true |
Stays on the device | Yes, once the language pack is installed. Without it: error language-not-supported |
| Firefox on desktop (as announced) | Stays on the device | Check once it ships |
| Other browsers | Read that browser's documentation | Do not promise what you have not checked |
Careful: it is not the same as the camera
You may think everything in this unit runs in your browser. Not speech in Chrome. Always know where each sense is processed, and say it on the page, because the user cannot tell. A line like "In Chrome, what you say is sent to a recognition service" is honest. "Your voice stays on this computer" would be false, unless you set processLocally.
Synthesis is usually local too, but not always. Some voices come from a remote service, and then the text you ask it to read travels. Each voice has a localService property that tells you which kind it is.
Asking for on-device recognition
available() and install() are static methods of the class. Call this from the click on your button. It only works in browsers with on-device support, so check first.
// Returns a recognition that never leaves the device, or null if that is not possible here.
export async function localRecognition(lang: string) {
if (typeof SpeechRecognition === 'undefined' || !('available' in SpeechRecognition)) return null;
const options = { langs: [lang], processLocally: true };
const availability = await SpeechRecognition.available(options);
if (availability === 'unavailable') return null;
if (availability !== 'available' && !(await SpeechRecognition.install(options))) return null;
const recognition = new SpeechRecognition();
recognition.lang = lang;
recognition.processLocally = true;
return recognition;
}
This unit uses the default instead, and says so on the page. It is one line of change when privacy matters more than setup.
Radio rules¶
Think of a walkie-talkie. Radio operators follow three rules, because they can never be sure the other side heard correctly. Your component follows the same three:
| Radio rule | Why | In NoteBox |
|---|---|---|
| Press to talk | Nobody talks over anybody | The Speak button: one sentence, then the microphone closes |
| Read back | The sender knows what was understood | "Deleted Shopping list." shown and spoken |
| "Say again" | Never act on a guess | I heard "...". That is not a command. Nothing is deleted |
The first rule is also a privacy rule. An app that listens all the time is an app nobody trusts. Setting continuous = true to "get rid of the button" would keep the microphone open, and the app would end up hearing its own answers.
A recognition is a short story¶
A recognition is told by events:
flowchart LR
B["<b>Press Speak</b><br>start() is called"] --> S["<b>start</b><br>microphone open"]
S --> R["<b>result</b><br>several times, words so far<br>(the last has isFinal)"]
S --> E["<b>error</b><br>event.error has a code"]
R --> N["<b>end</b><br>always the last one"]
E --> N
| Event | When it fires |
|---|---|
start |
The service begins listening. The first time, this comes after the permission prompt |
result |
Words arrived. With interimResults = true it fires several times while the user speaks, with interim results. The last one is the final result: isFinal === true |
error |
Something failed. event.error is a code such as not-allowed, audio-capture, network or no-speech |
end |
The service has disconnected, with or without a result |
The text is at event.results[i][0].transcript: a list of results, each with a list of alternatives. [0] is the most likely one.
Write the vocabulary first¶
A voice vocabulary is the table of everything your app understands: what the user says (several phrasings), what the app does, and what it answers.
| Intent | The user says | The app answers (shown and spoken) |
|---|---|---|
| New | "new note ‹title›", "add note ‹title›", "create note called ‹title›" | "Created note ‹Title›." |
| Delete | "delete note", "delete this note", "remove the note" | "Deleted ‹title›. Say undo to bring it back." |
| Undo | "undo" | "Restored ‹title›." or "Nothing to undo." |
| Next | "next", "next note" | The title of the note now selected |
| Previous | "previous", "previous note", "back" | The title of the note now selected |
Delete answers "Say undo to bring it back" for a reason: this is forgiveness, from Apple's guidelines. A misheard word must never cost a note.
The rules for writing yours:
- 2 to 4 words per command. Short words are misheard: "next note", not "n".
- No two commands that sound alike, such as "delete" and "repeat".
- One destructive command at most, with an Undo row.
- Every command already exists as a button. Voice is a second door, not the only one.
Then say your commands aloud, fast. If you stumble, the ear will too.
The ear¶
Plain JavaScript projects can skip this step. TypeScript's built-in types do not include SpeechRecognition yet, so add them with one install and one line:
Now ask the browser for its ear, in src/lib/nui/speech.ts:
// Chrome, Edge 139+: SpeechRecognition. Older Chromium, Safari: webkitSpeechRecognition.
// A browser with neither gets undefined, and the page offers the typed fallback.
export function recognitionClass(): typeof SpeechRecognition | undefined {
if (typeof window === 'undefined') return undefined; // server-side rendering
const w = window as typeof window & { webkitSpeechRecognition?: typeof SpeechRecognition };
return w.SpeechRecognition ?? w.webkitSpeechRecognition;
}
Why the typeof window check? SvelteKit also renders the page on the server, and a server has no window and no microphone.
Your first transcript needs only this, in a new VoiceButton.svelte:
<script lang="ts">
import { recognitionClass } from '#lib/nui/speech.js';
let heard = $state('');
function listen() {
const Recognition = recognitionClass();
if (!Recognition) return; // later, this becomes a real state
const recognition = new Recognition();
recognition.lang = 'en-US'; // or 'es-ES', 'ca-ES': never let the browser guess
recognition.continuous = false; // one sentence, then stop
recognition.interimResults = true; // show the words while they are spoken
recognition.onresult = (event) => {
heard = event.results[event.results.length - 1][0].transcript;
};
recognition.onerror = (event) => console.log(event.error);
recognition.start(); // the browser asks for the microphone
}
</script>
<button type="button" onclick={listen}>Speak</button>
<p>{heard}</p>
See your own words on the screen before you go on. No microphone? Borrow one for this step only: from the next section on, you can work with the keyboard.
From words to a command¶
The ear returns free text: "New note, shopping list." Your app needs a decision. Put that decision in a pure function: a function whose answer depends only on what you give it, with no microphone inside and no side effects. Then you can test it with Vitest and feed it from a text box.
src/lib/nui/commands.ts:
export type Command =
| { type: 'new'; title: string }
| { type: 'delete' }
| { type: 'undo' }
| { type: 'next' }
| { type: 'previous' };
export function parseCommand(transcript: string): Command | null {
// "New note, shopping list." -> "new note shopping list"
const text = transcript.toLowerCase().replace(/[.,!?]/g, '').replace(/\s+/g, ' ').trim();
const created = text.match(/^(?:new|add|create) note(?: called)?(?: (.+))?$/);
if (created) return { type: 'new', title: created[1] ?? '' };
if (/^(delete|remove)( this| the)? note$/.test(text)) return { type: 'delete' };
if (/^undo$/.test(text)) return { type: 'undo' };
if (/^next( note)?$/.test(text)) return { type: 'next' };
if (/^(previous|back)( note)?$/.test(text)) return { type: 'previous' };
return null; // heard something, but it is not a command
}
Three things to notice:
- Normalise first. The recogniser adds capitals and punctuation, and it makes mistakes ("knew note").
^and$make the whole sentence match, so "do not delete note" does nothing.nullis an honest answer. It is the "say again" of the radio.
Because it is pure, the tests are three lines of real value:
import { expect, test } from 'vitest';
import { parseCommand } from './commands';
test('capitals and punctuation do not matter', () => {
expect(parseCommand('New note, shopping list.')).toEqual({ type: 'new', title: 'shopping list' });
});
test('a sentence that merely contains a command does nothing', () => {
expect(parseCommand('do not delete note')).toBeNull();
});
The typed fallback¶
One handle(transcript) function, two ways in: the final result of the ear, and a small form.
<form onsubmit={submitTyped}>
<label for="typed-command">Type a command</label>
<input id="typed-command" bind:value={typed} autocomplete="off" />
<button type="submit">Run</button>
</form>
Careful: the typed box is not a debugging tool
It stays. It is the way in for someone who cannot speak, who is in a library, whose voice the recogniser struggles with, or whose browser has no recognition. It also keeps you within WCAG 2.1.1 Keyboard: every function of your app must work from a keyboard.
Six states¶
Like the camera, the voice button has states, and the user must always know which one it is in.
| State | When | What the user reads |
|---|---|---|
| Idle | Nothing is being recorded | A hint with an example command |
| Listening | start fired |
The button changes its colour and its word to "Listening". Interim words in italics |
| Recognised | parseCommand returned a command |
The answer from the vocabulary table |
| Not a command | parseCommand returned null |
I heard "...". That is not a command. Try "..." |
| Problem | error fired |
One message per code, each saying what to do next |
| Unsupported | recognitionClass() returned undefined |
Button disabled, not hidden. "This browser has no speech recognition. Type the command below." |
And one message per error code, in speech.ts:
event.error |
Message |
|---|---|
not-allowed, service-not-allowed |
Microphone blocked. Allow it from the icon in the address bar, or type the command below. |
audio-capture |
No microphone found. Plug one in, or type the command below. |
network |
Speech recognition needs the internet in this browser. Type the command below. |
no-speech |
I did not hear anything. Press the button and try again. |
language-not-supported |
This language is not supported for recognition. Type the command below. |
any other (aborted, phrases-not-supported...) |
Something went wrong with speech recognition. Type the command below. |
Two pieces of code matter most. First, support is checked in $effect, which runs only in the browser, after the component is mounted. Checked at the top of the script, it would run on the server too, where there is no window. The same $effect releases the microphone when you leave the page. Second, the message sits inside <div role="status" aria-live="polite">, so a screen reader announces every change (WCAG 4.1.3).
Common mistakes:
- The state is shown only by colour. The red button must also change its word.
- The button stays on "Listening" after silence. Use
end: if the status is stilllistening, go back to idle. - Clicking twice throws
InvalidStateError.start()cannot be called twice. Disable the button while listening.
The mouth¶
Add this to speech.ts:
export function speak(text: string, lang = 'en-US') {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return;
window.speechSynthesis.cancel(); // do not queue behind an old sentence
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang;
utterance.rate = 1; // 0.1 to 10; pitch 0 to 2; volume 0 to 1
window.speechSynthesis.speak(utterance);
}
The same sentence is shown and spoken: the user who cannot hear reads it, the user who is not looking hears it. That is the read-back of the radio.
Why do we not speak the "not a command" message? A machine that talks every time it fails is a machine you mute. And why is it safe to speak right after listening? With continuous = false, recognition stops after one final result, so the app does not take orders from its own voice.
The whole component¶
Here is everything together. To try it, you need a small note box, a list with its buttons, and a page.
src/lib/nui/speech.ts: add the error messages
// What the user reads for each SpeechRecognitionErrorEvent.error code.
export function speechProblem(code: string): string {
switch (code) {
case 'not-allowed':
case 'service-not-allowed':
return 'Microphone blocked. Allow it from the icon in the address bar, or type the command below.';
case 'audio-capture':
return 'No microphone found. Plug one in, or type the command below.';
case 'network':
return 'Speech recognition needs the internet in this browser. Type the command below.';
case 'no-speech':
return 'I did not hear anything. Press the button and try again.';
case 'language-not-supported':
return 'This language is not supported for recognition. Type the command below.';
default:
return 'Something went wrong with speech recognition. Type the command below.';
}
}
src/lib/components/VoiceButton.svelte: the six states
<script lang="ts">
import { parseCommand, type Command } from '#lib/nui/commands.js';
import { recognitionClass, speak, speechProblem } from '#lib/nui/speech.js';
type Status = 'idle' | 'listening' | 'recognised' | 'unknown' | 'error' | 'unsupported';
type Props = {
/** Runs the command and returns the sentence to show and say back. */
oncommand: (command: Command) => string;
lang?: string;
};
let { oncommand, lang = 'en-US' }: Props = $props();
const hint = 'Press Speak and say a command, for example "new note homework".';
let status = $state<Status>('idle');
let heard = $state('');
let message = $state(hint);
let typed = $state('');
let recognition: SpeechRecognition | undefined;
// Runs in the browser only (never during server-side rendering), once the component is mounted.
$effect(() => {
if (!recognitionClass()) {
status = 'unsupported';
message = 'This browser has no speech recognition. Type the command below.';
}
return () => recognition?.abort(); // leaving the page: release the microphone
});
function handle(transcript: string) {
heard = transcript;
const command = parseCommand(transcript);
if (!command) {
status = 'unknown';
message = `I heard "${transcript}". That is not a command. Try "new note homework".`;
return;
}
status = 'recognised';
message = oncommand(command);
speak(message, lang);
}
function listen() {
const Recognition = recognitionClass();
if (!Recognition || status === 'listening') return;
recognition = new Recognition();
recognition.lang = lang;
recognition.continuous = false; // one sentence, then stop: push-to-talk
recognition.interimResults = true; // show the words while they are being spoken
recognition.onstart = () => {
status = 'listening';
heard = '';
message = 'Listening...';
};
recognition.onresult = (event) => {
const result = event.results[event.results.length - 1];
heard = result[0].transcript;
if (result.isFinal) handle(heard);
};
recognition.onerror = (event) => {
status = 'error';
message = speechProblem(event.error);
};
recognition.onend = () => {
// Ended while still "listening": nothing final arrived.
if (status === 'listening') {
status = 'idle';
message = hint;
}
};
recognition.start(); // the browser asks for the microphone the first time
}
function submitTyped(event: SubmitEvent) {
event.preventDefault();
if (typed.trim()) handle(typed);
typed = '';
}
</script>
<section class="voice" data-status={status}>
<button
type="button"
class="mic"
onclick={listen}
disabled={status === 'unsupported' || status === 'listening'}
>
{status === 'listening' ? 'Listening' : 'Speak'}
</button>
<!-- Announced by screen readers every time it changes -->
<div class="status" role="status" aria-live="polite">
{#if heard && status === 'listening'}<p class="heard">{heard}</p>{/if}
<p>{message}</p>
</div>
<!-- The same vocabulary without a microphone: fallback and accessibility in one -->
<form onsubmit={submitTyped}>
<label for="typed-command">Type a command</label>
<input id="typed-command" bind:value={typed} autocomplete="off" />
<button type="submit">Run</button>
</form>
</section>
<style>
.voice {
display: grid;
gap: 0.75rem;
border: 3px solid #111;
padding: 1rem;
}
.voice[data-status='listening'] .mic {
background: #d62828;
color: #fff;
}
.heard {
font-style: italic;
}
.status p {
margin: 0;
}
</style>
src/lib/notes.svelte.ts: a small note box
// NoteBox state: one shared, reactive box of notes (Svelte 5 runes in a .svelte.ts module).
export type Note = { id: number; title: string; body: string; pinned: boolean };
class NoteBox {
notes = $state<Note[]>([
{ id: 1, title: 'Shopping list', body: 'Milk, eggs, bread.', pinned: false },
{ id: 2, title: 'Recipe', body: 'Two eggs, a pinch of salt.', pinned: true },
{ id: 3, title: 'Ideas', body: 'An app you can talk to.', pinned: false }
]);
index = $state(0);
lastDeleted = $state<Note | null>(null);
current = $derived<Note | undefined>(this.notes[this.index]);
#nextId = 4;
add(title: string) {
this.notes.push({ id: this.#nextId++, title: title || 'Untitled', body: '', pinned: false });
this.index = this.notes.length - 1;
}
// Forgiveness: delete at once, keep the note for Undo.
remove() {
if (!this.current) return;
this.lastDeleted = this.current;
this.notes.splice(this.index, 1);
this.index = Math.max(0, Math.min(this.index, this.notes.length - 1));
}
undo() {
if (!this.lastDeleted) return;
this.notes.push(this.lastDeleted);
this.index = this.notes.length - 1;
this.lastDeleted = null;
}
next() {
if (this.notes.length) this.index = (this.index + 1) % this.notes.length;
}
previous() {
if (this.notes.length) this.index = (this.index - 1 + this.notes.length) % this.notes.length;
}
togglePin() {
if (this.current) this.current.pinned = !this.current.pinned;
}
}
export const box = new NoteBox();
src/lib/components/NoteList.svelte: the notes and their buttons
<script lang="ts">
import { box } from '#lib/notes.svelte.js';
</script>
<section aria-label="Notes">
<ul>
{#each box.notes as note, i (note.id)}
<li>
<button type="button" aria-current={i === box.index} onclick={() => (box.index = i)}>
{note.title}{note.pinned ? ' (pinned)' : ''}
</button>
</li>
{:else}
<li>No notes yet.</li>
{/each}
</ul>
{#if box.current}
<article>
<h2>{box.current.title}</h2>
<textarea bind:value={box.current.body} aria-label="Note text" rows="4"></textarea>
</article>
{/if}
<!-- Every voice or gesture action also exists as a button -->
<button type="button" onclick={() => box.add('')}>New</button>
<button type="button" onclick={() => box.previous()}>Previous</button>
<button type="button" onclick={() => box.next()}>Next</button>
<button type="button" onclick={() => box.togglePin()} disabled={!box.current}>Pin</button>
<button type="button" onclick={() => box.remove()} disabled={!box.current}>Delete</button>
{#if box.lastDeleted}
<p role="status">
{box.lastDeleted.title} deleted
<button type="button" onclick={() => box.undo()}>Undo</button>
</p>
{/if}
</section>
src/routes/voice/+page.svelte: the page
<script lang="ts">
import NoteList from '#lib/components/NoteList.svelte';
import VoiceButton from '#lib/components/VoiceButton.svelte';
import { box } from '#lib/notes.svelte.js';
import type { Command } from '#lib/nui/commands.js';
// Runs the command and returns the sentence the app says back.
function run(command: Command): string {
switch (command.type) {
case 'new': {
const title = command.title.charAt(0).toUpperCase() + command.title.slice(1);
box.add(title);
return `Created note ${box.current?.title}.`;
}
case 'delete': {
const title = box.current?.title;
if (!title) return 'There is no note to delete.';
box.remove();
return `Deleted ${title}. Say undo to bring it back.`;
}
case 'undo':
if (!box.lastDeleted) return 'Nothing to undo.';
box.undo();
return `Restored ${box.current?.title}.`;
case 'next':
box.next();
return box.current?.title ?? 'No notes.';
case 'previous':
box.previous();
return box.current?.title ?? 'No notes.';
}
}
</script>
<h1>Voice</h1>
<p>In Chrome, what you say is sent to a recognition service to turn it into text. The microphone only listens after you press Speak.</p>
<VoiceButton oncommand={run} />
<NoteList />
Swap seats with a classmate: can they run two of your commands using only what the screen says? Then block the microphone from the address bar and press Speak. If the message tells them what to do, you are done.
Key takeaways¶
- The Web Speech API has two halves: speech recognition (
SpeechRecognition, the ear) and speech synthesis (speechSynthesis, the mouth). Listening needs permission; speaking does not. - Chrome and Edge 139+ have it unprefixed, Safari only as
webkitSpeechRecognition, and Firefox is only now arriving on desktop. Always check before you use it. - In Chrome, recognition goes to a server by default: no internet, no ear.
processLocally = truekeeps it on the device, after a language pack download. Say on the page which one you use. - Radio rules: press to talk, read back, "say again". Never act on a guess.
- A voice vocabulary lists what the user says, what the app does and what it answers. Every command also exists as a button.
- A pure function turns the transcript into a command, or
null. It feeds both the ear and the typed fallback, which stays for good.
Quick check¶
1. Which object turns text into spoken words?
-
SpeechRecognition, through itsresultevent -
speechSynthesis, with aSpeechSynthesisUtterance -
webkitSpeechRecognition, in Safari and old Chrome -
getUserMedia, withaudioset to true
Speech synthesis is the mouth: window.speechSynthesis.speak(new SpeechSynthesisUtterance(text)). Speech recognition, under either name, is the ear: it goes the other way.
2. A user presses Speak in Chrome with the default settings, on a train with no internet. What happens?
- The
errorevent fires with the codenetwork - The speech is processed on the device and works as usual
- The speech is kept and sent later, when the connection returns
- The
errorevent fires with the codenot-allowed
By default, Chrome sends the audio to a server-based recognition service, so with no connection you get network. On-device recognition needs processLocally = true and a language pack. not-allowed is about permission.
3. You want your app's speech recognition to work in every browser that has it. How should your code get the class?
- Check the browser's name, then choose the matching class
- Use
new SpeechRecognition()directly inside atryblock - Use
window.SpeechRecognition, or elsewindow.webkitSpeechRecognition - Use
window.webkitSpeechRecognitionalone, as every browser has it
Feature detection: ask for the class, under both names, and treat undefined as "no ear". Safari only has the prefixed name, and support changes over time, so browser names age badly.
4. Why should parseCommand be a pure function?
- Because the browser refuses to run a function with side effects
- Because it makes the speech recogniser more accurate
- Because a pure function needs no microphone permission from the user
- Because it can be tested alone and also fed from a text box
A pure function only depends on its input: text in, command out. So Vitest can check it without a microphone, and the typed fallback can reuse the same code.
5. The app hears “delete my milk note”, and only “delete note” is in the vocabulary. What should it do?
- Delete the current note, since the sentence is close enough
- Stay silent, so the user is not annoyed by a message
- Show what it heard and say that it is not a command
- Report a
no-speecherror and ask the user to reload
This is the radio's "say again": never act on a guess, least of all for a destructive command. The ear worked, so it is not an error of the ear. The vocabulary simply has no such command.
6. Which statement about where speech is processed is accurate?
- The Web Speech API always keeps your voice inside the browser tab
- The Web Speech API always sends your voice to Google
- Only speech synthesis can ever involve a remote service
- It depends on the browser and on settings such as
processLocally
Chrome uses a server by default and the device with processLocally = true. Firefox has announced on-device only. Even synthesis can use remote voices, as localService shows. So check each case, and tell the user.
7. A cooking app adds voice control so that people with dirty hands can use it. Pick the two good design decisions.
- Keep every voice action available as a button or from the keyboard
- Answer each recognised command with a short sentence, shown and spoken
- Keep the microphone open all the time, so nobody has to touch anything
- Hide the voice button in browsers that have no speech recognition
Voice is a second door: some users cannot speak, or their browser has no ear. The read-back tells them what was understood. An always-open microphone is the opposite of press to talk, and a disabled button with a message explains itself where a hidden one does not.
The ear and the mouth are working. Next the app watches your whole body, so wear something you can raise your arms in: Pose detection.