Speech-to-Text in Flutter Web: Driving SpeechRecognition from Dart
Add voice input to a Flutter web app with the Web Speech API's SpeechRecognition: interop wiring, interim transcripts, error codes, on-device mode, and a native fallback.
Published on • November 11, 2026
AI Assistant

The Web Speech API has two halves. SpeechSynthesis reads text out loud — that is the text-to-speech post — and SpeechRecognition does the reverse, turning microphone audio into text. Flutter web ships no first-party wrapper for the input side, so you call the browser directly through dart:js_interop while native builds keep using speech_to_text. This post wires both halves behind one small service interface.
Know what the browser gives you
SpeechRecognition is the controller for the recognition service. You configure it with continuous (keep listening across results), interimResults (emit partial transcripts while the user is still talking), lang, and maxAlternatives, then call start() and stop(). The result event hands you event.results, a list of SpeechRecognitionResult objects, each carrying isFinal plus alternatives with transcript and confidence.
Support is narrower than it looks. Chromium browsers ship it, Safari has supported it since 14.1 on the desktop and 14.5 on iOS, and Firefox desktop still has it disabled by default, so feature-detect instead of assuming. Chrome’s recognizer is also cloud-backed: your audio is sent to a web service, so recognition needs a network connection. In practice you start recognition from inside a tap handler, so the microphone permission prompt has a user gesture behind it.
Build the recognizer through interop
package:web exposes typed bindings for SpeechRecognition, but Chrome and Safari still publish the prefixed webkitSpeechRecognition constructor, so pick whichever global exists at runtime:
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:web/web.dart' as web;
web.SpeechRecognition createRecognizer() {
final window = web.window as JSObject;
final ctor = (window.getProperty('SpeechRecognition'.toJS) ??
window.getProperty('webkitSpeechRecognition'.toJS))! as JSFunction;
final recognition = ctor.callAsConstructor<web.SpeechRecognition>();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.maxAlternatives = 1;
return recognition;
}
Then call recognition.start() directly inside your widget’s button handler — never from initState or a timer — so the microphone prompt has a user gesture behind it, and stop() from the same button when the session should end.
Read final and interim results
The result event fires repeatedly. In continuous mode event.results accumulates every result from the session, so read the whole list each time and only append entries where isFinal is true, keeping interim text in a separate “draft” line:
void bindResults(
web.SpeechRecognition recognition,
void Function(String text, bool isFinal) onText,
) {
recognition.onresult = ((web.SpeechRecognitionEvent event) {
final results = event.results;
for (var i = 0; i < results.length; i++) {
final result = results[i];
final best = result[0];
onText(best.transcript, result.isFinal);
}
}).toJS;
}
Confidence comes back as a double per alternative and is worth logging early: engines happily return 0 on some platforms, so never gate your UI on it.
Map the error codes
The error event carries a stable string code, and end fires after almost every session — error or not — so treat end as “stop the microphone UI” and error as the reason:
void bindErrors(
web.SpeechRecognition recognition,
void Function(String message) onError,
) {
recognition.onerror = ((web.SpeechRecognitionErrorEvent event) {
final code = '${event.error}';
final message = switch (code) {
'not-allowed' => 'Microphone permission denied',
'no-speech' => 'No speech detected',
'audio-capture' => 'No microphone found',
'network' => 'Recognition needs a network connection',
'aborted' => 'Recognition aborted',
_ => 'Recognition failed: $code',
};
onError(message);
}).toJS;
}
not-allowed is the one that surprises people: it appears when the page has no recent user activation or the microphone permission was denied, so the fix is UI, not code.
Opt into on-device recognition
By default Chrome’s recognition runs in the cloud. You can request local processing instead by setting processLocally before start() and checking language pack availability first — available() resolves to available, downloadable, downloading, or unavailable, and install() fetches the pack:
recognition.processLocally = true;
const state = await SpeechRecognition.available({
langs: ['en-US'],
processLocally: true,
});
if (state === 'available') recognition.start();
else if (state !== 'unavailable') {
await SpeechRecognition.install({ langs: ['en-US'], processLocally: true });
}
Both static methods are gated by the on-device-speech-recognition Permissions-Policy directive, whose default allowlist is self, so embedded cross-origin frames need the header widened explicitly. If the pack is missing when you start, you get a language-not-supported error rather than silent cloud fallback.
Keep native builds on speech_to_text
speech_to_text supports Android, iOS, macOS, Windows, and web, but its web build only mirrors what the browser exposes and targets short commands rather than continuous dictation. The clean split is a conditional import: native code keeps the plugin, web reaches for the raw API above.
// speech_service.dart — the only file widgets import
import 'speech_stub.dart'
if (dart.library.io) 'speech_native.dart'
if (dart.library.js_interop) 'speech_web.dart';
SpeechService createSpeechService() => makeSpeechService();
// speech_native.dart
SpeechService makeSpeechService() => _NativeSpeechService();
// speech_web.dart
SpeechService makeSpeechService() => _WebSpeechService();
Each implementation exposes the same start/stop/onResult shape, so widgets never branch on platform and flutter build ios never pulls in the web interop path.
Takeaway: Drive SpeechRecognition from Dart with dart:js_interop, handle the prefixed constructor and the five error codes, start it from a tap, and keep a conditional-import shim so native builds stay on speech_to_text.