On-device speech to text in Flutter: what works

Zayin Krige, 2026-09-29. 6 min read.

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.

ApproachAdded to the appMeasuredDeviceMy 21 languages covered
iOS SpeechTranscriberNothing. iOS manages the language assets11 s clip: 0.4 s. 6 s real recording: 0.2 siPhone 15 Pro8
Android ML Kit basic mode, liveThe ML Kit library. Google downloads the model on first use28.8 s clip: 490 to 670 ms after stop. 5 s real recording: 196 msNokia T2011
Android ML Kit basic mode, from a fileAs above28.8 s clip: 30.3 to 30.4 sNokia T2011
whisper.cpp large-v3-turbo q5_0, my FFI package, Metal4.2 MB of frameworks, plus a 574 MB model download11 s clip: 2.9 s, then 2.4 s. 60 s Polish clip: 10.3 to 12.7 s after the first runiPhone 15 Pro20 (all but Uzbek)
Published whisper_ggml 2.6.0, same model, CPUThe package, plus the same 574 MB model60 s Polish clip: 66.9 s and 101.2 siPhone 15 Pro20

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

What I would tell another Flutter team

  1. Start with the platform recognisers and stream audio to them live. On measured latency nothing else comes close.
  2. Skip speech_to_text if you need file transcription or your own recording, and write a Pigeon plugin instead.
  3. Gate Android on checkStatus() before recording, and test on a real device: the download prompt only showed up for me on the Nokia T20.
  4. 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.