On-device speech to text in Flutter: what works
On-device speech to text in Flutter works best in two layers. First, the phone's own recogniser: Apple's SpeechTranscriber on iOS 26 and later, and Google's ML Kit GenAI speech recognition in basic mode on Android API 31 and later, both called through a platform channel and fed live audio while the user speaks. Second, for the languages the phone cannot transcribe, Whisper on device through whisper.cpp, with the model downloaded only when the user agrees to it.
That is the design I built for VitaMood, a voice journal where you talk for up to a minute each evening and the audio never leaves the phone. Its 21 languages forced the second layer. Every number below comes from my own measurements on that app, with the device named.
Why not the obvious package
The obvious pub.dev package, speech_to_text, handles the live microphone only. Transcribing a recorded file has been an open request since 2021 (issue #205). I needed both: the app keeps a WAV of every check-in as the record, and the Whisper fallback can only work from a file. So both platforms go through a small plugin of my own, device_speech, with Pigeon host APIs on each side.
The recorder streams 16 kHz mono 16-bit PCM. Each chunk goes to the WAV file and to the recogniser in the same pass, so one microphone client feeds both. On Android that meant not using ML Kit's AudioSource.fromMic(): ML Kit would own the microphone and the app would get no WAV. Instead the chunks go into a pipe that ML Kit reads with AudioSource.fromPfd.
What I measured
The phones were an iPhone 15 Pro on iOS 27.0 and a Nokia T20 tablet on Android 13 with a Unisoc T610. Whisper accuracy was measured separately on a Mac, as word error rate on 50 FLEURS test clips per language.
| Approach | Added to the app | Measured | Device | My 21 languages covered |
|---|---|---|---|---|
| iOS SpeechTranscriber | Nothing. iOS manages the language assets | 11 s clip: 0.4 s. 6 s real recording: 0.2 s | iPhone 15 Pro | 8 |
| Android ML Kit basic mode, live | The ML Kit library. Google downloads the model on first use | 28.8 s clip: 490 to 670 ms after stop. 5 s real recording: 196 ms | Nokia T20 | 11 |
| Android ML Kit basic mode, from a file | As above | 28.8 s clip: 30.3 to 30.4 s | Nokia T20 | 11 |
| whisper.cpp large-v3-turbo q5_0, my FFI package, Metal | 4.2 MB of frameworks, plus a 574 MB model download | 11 s clip: 2.9 s, then 2.4 s. 60 s Polish clip: 10.3 to 12.7 s after the first run | iPhone 15 Pro | 20 (all but Uzbek) |
Published whisper_ggml 2.6.0, same model, CPU | The package, plus the same 574 MB model | 60 s Polish clip: 66.9 s and 101.2 s | iPhone 15 Pro | 20 |
On the 11 s clip the phone's recogniser was six to seven times faster than Whisper on the same iPhone. ML Kit only feels fast when it is fed live: from a file it runs at real time, so a 28.8 s clip takes about 30 s, while the same clip streamed during recording is finished well under a second after the user taps stop.
Layer one: the phone's own recogniser
iOS: SpeechAnalyzer and SpeechTranscriber
Apple introduced SpeechAnalyzer in iOS 26 (WWDC25 session 277). The model lives in system storage, so it adds nothing to the app. On the iPhone 15 Pro, SpeechTranscriber.supportedLocales returned 8 of my 21 languages: English, German, Spanish, French, Portuguese, simplified Chinese, Italian and Japanese.
One pleasant surprise: SpeechTranscriber ran on the iPhone 15 Pro without the NSSpeechRecognitionUsageDescription key and without calling SFSpeechRecognizer.requestAuthorization. For speech, the only prompt the user sees is the microphone. Live streaming into SpeechTranscriber builds and runs in the app, but I have not yet timed it on a real iPhone.
Android: ML Kit GenAI speech recognition
ML Kit's GenAI speech recognition API runs on the device, and basic mode needs API 31. It is still alpha: com.google.mlkit:genai-speech-recognition:1.0.0-alpha1. Its basic-mode locale list covers the same 8 languages as iOS plus Russian, Turkish and Polish.
The catch I found on real hardware: on the Nokia T20 the first recognition raised a system prompt to download the speech model, and the check-in screen showed nothing until it was accepted. The fix is to call checkStatus() before showing the recorder. AVAILABLE shows it. DOWNLOADABLE or DOWNLOADING shows a panel that explains the one-time download and starts it only on a tap. After the download, logcat on the T20 confirmed the recogniser ran locally, with server ASR (automatic speech recognition) disabled.
The panel is build-verified; I have not yet watched it complete on the T20. Check language support at runtime on both platforms, with SpeechTranscriber.supportedLocale(equivalentTo:) and checkStatus(), never from a hard-coded table.
Layer two: Whisper on device
That leaves 10 of my 21 languages with no phone recogniser on either platform: Arabic, Dutch, Ukrainian, Romanian, Czech, Greek, Hungarian, Slovak, Serbian and Uzbek. For those the app offers a download of Whisper large-v3-turbo, quantised to q5_0 by the whisper.cpp project: 574 MB. Word error rates on the Mac were 6.3% for Dutch, 7.1% for Ukrainian, 7.6% for Polish, 14.3% for Arabic and 15.9% for Hungarian.
Uzbek needed its own model. Turbo scored a 122.5% word error rate because it writes Kazakh in Cyrillic. A community Whisper small fine-tune for Uzbek, which I converted to ggml q5_1 and host myself, scored 17.7% at 190 MB.
Why my own FFI package
whisper_engine is a Dart FFI package whose build hook compiles whisper.cpp 1.9.4 from a hash-pinned source tarball, with ffigen bindings over the C API. On iOS it uses Metal; on Android it runs on the CPU. The table shows why I did not stop at the published package: the same model took 66.9 s and 101.2 s on the CPU against 10.3 to 12.7 s on Metal. The first Metal run took 28.9 s, of which 18.3 s was shader compilation, cached after that.
The store rules shape the design
Apple's guideline 2.5.2 forbids downloading code that changes an app's functionality, and Google Play's Device and Network Abuse policy forbids downloading executable code from anywhere but Google Play. So the engine ships inside the app and only model files download. Each download resumes over HTTP range requests and is checked against a pinned SHA-256 before it is used.
What I rejected
- Too large: the sherpa_onnx export of turbo at 1,036 MB, Parakeet TDT 0.6B v3 at 671 MB, and the whisper.cpp Core ML encoder at a further 1,173 MB.
- No phone build: faster-whisper, because CTranslate2 has no iOS or Android build. Vosk offers its iOS library only on request, and scored 30.3% on Uzbek.
- Accuracy or licence: Whisper small had 30 to 43% errors in Czech, Greek, Hungarian, Slovak, Romanian and Serbian. Meta MMS and Canary 1B are licensed for non-commercial use only.
What I would tell another Flutter team
- Start with the platform recognisers and stream audio to them live. On measured latency nothing else comes close.
- Skip
speech_to_textif you need file transcription or your own recording, and write a Pigeon plugin instead. - Gate Android on
checkStatus()before recording, and test on a real device: the download prompt only showed up for me on the Nokia T20. - Use whisper.cpp only for the gap, ship the engine, download the model with consent, and use the GPU where you can.
If you are still choosing a framework, my comparison of Flutter and React Native covers why I build these apps in Flutter. More of the apps I have shipped are on the homepage.
I take on fractional CTO and senior architecture contracts through Apex Technology, working remotely. Email info@zayinkrige.com or read the full CV.