@tapestry-ui/dictation
v0.2.0
Published
Speak instead of typing. A headless Preact hook over the browser's SpeechRecognition, with the iPhone quirks handled and a provider shape you can swap for a server transcriber.
Maintainers
Readme
@tapestry-ui/dictation
Speak instead of typing. A utility, not a component — it renders nothing.
Part of Tapestry UI.
npm i @tapestry-ui/dictationconst dictation = useBrowserDictation({
lang: locale.value, // dictate in whatever tongue the app speaks
onResult: (text) => setDraft(text), // one utterance, committed
onFault: setTrouble,
});
{dictation.supported && (
<button aria-pressed={dictation.listening}
onClick={() => dictation.listening ? dictation.cancel() : dictation.start()}>
{dictation.listening ? 'Stop' : 'Speak'}
</button>
)}
{dictation.listening && <p>{dictation.partial}</p>}Know where the audio goes
On-device first, by default. Chrome 139+ exposes
SpeechRecognition.available({ processLocally: true }) and install(), and
this hook uses them: if a language pack is there it recognises locally, if one
is downloadable it fetches it (preparing is true while it does), and only
otherwise does it fall back to the cloud. Pass preferOnDevice: false to skip
that.
That default earns itself twice. The audio never leaves the machine — and it
keeps working when the cloud recogniser is unreachable, which is what a bare
network fault usually means.
Where it does fall back: Safari routes to Apple, Chrome to Google, and nothing in the API tells you which happened.
If that is the wrong posture for your data, do not use this hook. Implement
the Dictation interface against a transcriber you control — record with
MediaRecorder, send the audio to your own server, return the text. The
interface is the point of the package; the browser-backed hook is just the
implementation that costs nothing.
Three things this exists to handle
It is never continuous, and that is not a preference. With
continuous = true, Safari on iOS holds the microphone open and never delivers
a final result. So this is single-shot: one utterance, then it stops itself.
It prefers the unprefixed name. Chrome now has SpeechRecognition natively
as well as webkitSpeechRecognition. If you are stubbing it in a test, stub the
one the hook will actually find — recogniserClass() is exported so you can ask.
Faults are distinguished, because only one is actionable.
| fault | means |
|---|---|
| unsupported | no recogniser in this browser — hide the button, don't disable it |
| denied | permission refused. The only one a person can fix. |
| no-microphone | permission given, no device |
| silence | it listened, nothing was said |
| network | the recogniser was unreachable — a blocker, a VPN, a Chromium fork with no API key, or a regional outage. Not the speaker's fault and rarely fixable by them. |
| failed | anything else the browser reported |
"Something went wrong" would hide the one message worth showing.
The shape
interface Dictation {
readonly supported: boolean;
readonly listening: boolean;
readonly preparing: boolean; // fetching an on-device language pack
readonly partial: string; // heard so far, before it commits
readonly fault: DictationFault | null;
start(): void;
cancel(): void; // abandon this utterance, deliver nothing
}A fault clears on the next start(). Unmounting mid-utterance releases the
microphone. faultFor, transcriptOf and recogniserClass are exported and
pure — that is where the logic lives and where the tests point.
Don't send on someone's behalf
The hook delivers text. It does not submit anything. A recogniser deciding someone has finished talking is not the same as them deciding, and a voice feature that fires off a half-sentence loses trust on its second use. Put the transcript in the box and let them press the button.
@tapestry-ui/ask does exactly that: pass it a dictation provider and a
prefill, and a dictated sentence lands in the composer, editable, unsent.
License
MIT © Brandon Minton
