Whisper GGML

On-device speech-to-text for Flutter, powered by whisper.cpp v1.9.1.

pub whisper.cpp Buy Me A Coffee

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 base model on an Apple Silicon Mac.

Supported platforms

Platform Minimum version
Android API 21
iOS 15.6
macOS 10.15
Windows 10 (x64)
Linux x64

Installation

dependencies:
  whisper_ggml: ^2.6.0

Requires Dart 3.7+ (Flutter 3.29+).

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, and gateRmsMin.
  • 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 of model: to run the session against any local ggml model file. For full manual control, startWhisperLiveSession(modelPath: ...) is exported too — no audio stream wiring, you call session.feed(pcm16Bytes) yourself.
  • Session-per-utterance? Pass keepModelLoaded: true and the model is parked in native memory when the session stops, so the next session (or one-shot transcribe) with the same model starts without the multi-second load. See "Keeping the model loaded between transcriptions" below — the same cache, memory trade-off, and releaseModel() 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

Available on both transcribe and transcribeLive:

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 Stops whisper from conditioning on prior-segment transcripts (like Python whisper's condition_on_previous_text=False). Helps against hallucinated repetition on short, independent utterances.
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.

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 tiny up 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: false leaves 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 -O3 on 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 --release for representative performance.

  • The bundled whisper.cpp v1.9.1 is roughly 15× faster than the engine in versions before 2.0.0.

Libraries

whisper_ggml