Whisper GGML
On-device speech-to-text for Flutter, powered by whisper.cpp v1.9.1.
Transcribe audio files or transcribe live while the user speaks — fully on-device, no server, no API keys.
Highlights
- 🎙 Live transcription — partial transcripts stream in while recording, refined as more audio arrives. The model loads once per session and an adaptive energy gate keeps silence from producing hallucinated text.
- 📄 File transcription — one call to transcribe a recording.
- 📦 Offline-first — models download once and are cached, or ship them in your app's assets for fully offline use (see the example app).
- 🌍 99 languages — pick one (
'en','fr','de', …) or use'auto'to detect. - 🎛 Decoding controls — vocabulary biasing, context conditioning, and non-speech token suppression exposed from whisper.cpp.
- ⚡ Fast — whisper.cpp v1.9.1 with Accelerate on Apple platforms;
an 11-second clip transcribes in ~0.4 s with the
basemodel on an Apple Silicon Mac.
Supported platforms
| Platform | Minimum version |
|---|---|
| Android | API 24 |
| iOS | 15.6 |
| macOS | 10.15 |
| Windows | 10 (x64) |
| Linux | x64 |
Installation
dependencies:
whisper_ggml: ^2.8.1
Requires Dart 3.7+ (Flutter 3.29+). On iOS, set the deployment target to
15.6 or later both in ios/Podfile (platform :ios, '15.6') and in
Xcode (Runner → General → Minimum Deployments).
Quick start
import 'package:whisper_ggml/whisper_ggml.dart';
final controller = WhisperController();
final result = await controller.transcribe(
model: WhisperModel.tiny,
audioPath: '/path/to/audio.wav',
lang: 'en',
);
print(result?.transcription.text);
Pass onProgress: (percent) => ... to receive transcription progress
as a 0–100 percentage (coarse steps) while inference runs.
Pass withSegments: true to also get per-segment timestamps in
result.transcription.segments (each segment has fromTs/toTs
Durations and its text); add splitOnWord: true for one segment
per word instead of per phrase.
Speaker-turn detection (diarization)
With the tinydiarize model (WhisperModel.smallEnTdrz, English only),
diarize: true marks the segments after which the speaker changes:
final result = await controller.transcribe(
model: WhisperModel.smallEnTdrz,
audioPath: '/path/to/audio.wav',
lang: 'en',
diarize: true,
withSegments: true,
);
for (final segment in result?.transcription.segments ?? []) {
print('${segment.text}${segment.speakerTurnNext ? ' [speaker turn]' : ''}');
}
This detects turn boundaries; it does not label or count speakers. With
regular models diarize has no effect.
The model is downloaded automatically on first use. Non-WAV input is
converted with the bundled FFmpeg — except on Windows and Linux, where
FFmpeg is not bundled: an ffmpeg executable on PATH is used when
present (on Linux, apt install ffmpeg or equivalent), otherwise the
input must already be a 16 kHz mono WAV (the format the record package
produces with AudioEncoder.wav, sampleRate: 16000, numChannels: 1).
Live (streaming) transcription
transcribeLive accepts any stream of 16 kHz mono little-endian PCM16
audio and emits progressively refined transcripts while the audio flows.
With the record package:
final pcmStream = await recorder.startStream(const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 16000,
numChannels: 1,
));
final session = await controller.transcribeLive(
model: WhisperModel.base,
pcm16Stream: pcmStream,
lang: 'en',
);
session.partials.listen((text) {
print(text); // full transcript so far, not a delta
});
// Later — stop recording, finalize, and free the model:
await recorder.stop();
final finalText = await session.stop();
Good to know:
- The model stays loaded for the whole session; inference runs on a background isolate and never blocks the UI.
- An adaptive energy gate keeps silence away from the decoder, which
otherwise hallucinates on silent audio. For unusually loud rooms or quiet
speakers, tune
gateNoiseFloorCap,gateVoiceRatio, andgateRmsMin. - Only one live session can run at a time.
- Real non-speech sounds (knocks, clicks) may transcribe as bracketed
annotations like
[door slams]. - Bring your own model: pass
modelPath:instead ofmodel:to run the session against any local ggml model file. For full manual control,startWhisperLiveSession(modelPath: ...)is exported too — no audio stream wiring, you callsession.feed(pcm16Bytes)yourself. - Session-per-utterance? Pass
keepModelLoaded: trueand the model is parked in native memory when the session stops, so the next session (or one-shottranscribe) with the same model starts without the multi-second load. See "Keeping the model loaded between transcriptions" below — the same cache, memory trade-off, andreleaseModel()apply.
Models
| Model | Multilingual | English-only |
|---|---|---|
| tiny | WhisperModel.tiny |
WhisperModel.tinyEn |
| base | WhisperModel.base |
WhisperModel.baseEn |
| small | WhisperModel.small |
WhisperModel.smallEn |
| medium | WhisperModel.medium |
WhisperModel.mediumEn |
| large-v3 | WhisperModel.large |
— |
Smaller models are faster; larger models are more accurate. tiny and
base are good defaults for live transcription; small is a strong
accuracy/speed balance for file transcription on modern phones.
Decoding options
initialPrompt and suppressNonSpeechTokens work with both transcribe
and transcribeLive; noContext and maxContext are transcribe-only.
| Option | Default | What it does |
|---|---|---|
initialPrompt |
null |
Biases decoding toward the vocabulary, names, and punctuation it contains — useful for domain-specific terms that otherwise get misrecognised. Decoding also mimics the prompt's style: an unpunctuated prompt tends to produce unpunctuated output. |
noContext |
false |
Clears decoded text that an earlier transcription left on the model context. It does not stop the 30-second windows of one recording from conditioning on each other — use maxContext for that. |
maxContext |
null |
Caps how many previously decoded tokens condition each following 30-second window (whisper.cpp's --max-context). 0 decodes every window on its own — the closest match to Python whisper's condition_on_previous_text=False — and stops one hallucinated phrase from repeating through a long recording. whisper.cpp feeds initialPrompt through the same budget, so 0 also disables it. null keeps whisper.cpp's default. |
suppressNonSpeechTokens |
false |
Suppresses bracketed annotations such as [BLANK_AUDIO] or [music]. Side effect: real sounds may decode as plausible-looking words instead, which is why the example keeps it off. |
Trimming silence with voice-activity detection (VAD)
Push-to-talk and hold-to-record clips carry silence at the head and tail,
and whisper tends to hallucinate over it or repeat the previous sentence.
transcribe can run whisper.cpp's built-in Silero VAD
first, so only the detected speech is decoded. Download a Silero ggml model
(~1 MB, e.g. ggml-silero-v5.1.2.bin from
ggml-org/whisper-vad), ship
or download it like the whisper model, and pass its path:
final result = await controller.transcribe(
model: WhisperModel.base,
audioPath: audioPath,
vadModelPath: '/path/to/ggml-silero-v5.1.2.bin',
vadSpeechPadMs: 100, // optional; keeps clipped first/last words intact
);
Good to know:
- VAD is off unless
vadModelPathis set. Segment timestamps still refer to the original recording. - Audio with no detected speech returns an empty transcript.
- A
vadModelPaththat cannot be opened fails the request with an error. vadSpeechPadMsoverrides the padding around each speech segment; around 100 ms works well for dictation, much larger values can garble segment boundaries.- Applies to
transcribeonly; live sessions have their own energy gate. - With
keepModelLoaded, keep the VAD setting the same across requests: a parked model that ran VAD is only reused by requests with the same VAD model, anything else loads the model fresh.
Keeping the model loaded between transcriptions
By default every transcribe call loads the model from disk (seconds for
the small models and up) and frees it when the request completes. For
push-to-talk dictation, voice notes, and other short repeated requests
against the same model, that load dominates the per-utterance latency.
Pass keepModelLoaded: true to park the loaded model in native memory
instead — the next transcription with the same model skips the load:
final result = await controller.transcribe(
model: WhisperModel.base,
audioPath: audioPath,
keepModelLoaded: true, // model stays resident for the next request
);
// When dictation is over, free the parked model:
await controller.releaseModel();
Good to know:
-
A reused model transcribes exactly like a freshly loaded one — no text or decoder state carries over between requests.
-
The parked model keeps its weights in RAM (from ~100 MB for
tinyup to several GB for the large models) until you release it. On phones, release it when dictation ends rather than keeping it forever. -
One model stays resident per process; parking a different model replaces and frees the previous one. A request for a different model with
keepModelLoaded: falseleaves the parked one alone. -
Requests stay fully concurrent: a second transcription that arrives while the parked model is in use simply loads its own copy, exactly as before.
-
The native engine is compiled with
-O3on all platforms, including debug builds on iOS/macOS, Windows (/O2), and Linux — transcription speed there is close to release. -
Windows and Linux x64 builds target AVX2 by default, like upstream whisper.cpp's standard x64 binaries (supported by virtually every x64 CPU since ~2013). For very old CPUs, build with
-DWHISPER_GGML_AVX2=OFF. -
Android debug builds run the Dart layer in JIT mode; use
--releasefor representative performance. -
The bundled whisper.cpp v1.9.1 is roughly 15× faster than the engine in versions before 2.0.0.