<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sarbador_</title>
    <description>The latest articles on DEV Community by sarbador_ (@sarbador__867a7c5a92414d4).</description>
    <link>https://dev.to/sarbador__867a7c5a92414d4</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1756739%2F43b94bb8-7302-411f-b2af-a8c2d4ae12e4.jpg</url>
      <title>DEV Community: sarbador_</title>
      <link>https://dev.to/sarbador__867a7c5a92414d4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sarbador__867a7c5a92414d4"/>
    <language>en</language>
    <item>
      <title>Building an offline speech-to-text app with Flutter, FFI, whisper.cpp and FFmpeg</title>
      <dc:creator>sarbador_</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:18:26 +0000</pubDate>
      <link>https://dev.to/sarbador__867a7c5a92414d4/building-an-offline-speech-to-text-app-with-flutter-ffi-whispercpp-and-ffmpeg-3nj3</link>
      <guid>https://dev.to/sarbador__867a7c5a92414d4/building-an-offline-speech-to-text-app-with-flutter-ffi-whispercpp-and-ffmpeg-3nj3</guid>
      <description>&lt;p&gt;Cloud transcription is convenient until you have a recording you'd rather not upload: a lecture, a client call, a voice note. So I built &lt;strong&gt;&lt;a href="https://github.com/sb-dor/Extracter" rel="noopener noreferrer"&gt;Extracter&lt;/a&gt;&lt;/strong&gt;, an open-source Flutter app that turns any audio or video file into text entirely on the device. No account, no analytics, and no network at all once a model is downloaded.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae3f1fp46ashydjjaxhh.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae3f1fp46ashydjjaxhh.gif" alt="Extracter demo" width="440" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It runs on Android, iOS and macOS today (Windows and Linux build scripts exist but haven't been tested yet). This post walks through how it works, and the one native bug that taught me more about Dart isolates than any documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;audio/video file → FFmpeg (decode-only) → 16 kHz mono PCM → whisper.cpp → segments → Flutter UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The heavy lifting lives in a small FFI plugin, &lt;code&gt;extracter_native&lt;/code&gt;, with one C ABI shared by every platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ex_job_start(media, model, language, translate, threads)&lt;/code&gt; starts a job on a native worker thread and returns an id.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ex_job_cancel(id)&lt;/code&gt; is idempotent.&lt;/li&gt;
&lt;li&gt;The worker posts &lt;strong&gt;events&lt;/strong&gt; back to Dart: progress, a segment with start and end timestamps, logs, and exactly one &lt;code&gt;DONE&lt;/code&gt; as the last event.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dart wraps that in a repository and a controller. The UI only sees a stream of segments, which is why the transcript appears line by line while a file is still being processed.&lt;/p&gt;

&lt;h2&gt;
  
  
  FFmpeg: decode anything, ship nothing extra
&lt;/h2&gt;

&lt;p&gt;"Any format" was the requirement, so FFmpeg was the obvious choice. But I didn't want encoders, network protocols or GPL code in the app. The build is decode-only and LGPL, dynamically linked, with flags like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--disable-programs --disable-encoders --disable-muxers
--disable-network --disable-protocols --enable-protocol=file
--disable-gpl --disable-nonfree --disable-hwaccels
--disable-decoders --enable-decoder=&amp;lt;all ~200 audio decoders&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For video files we simply pick the best audio stream. Playlist-style demuxers (HLS, concat, DASH) are disabled so a file can never make the app open other files or URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeding whisper.cpp long audio
&lt;/h2&gt;

&lt;p&gt;Whisper works on 30-second windows internally, but you still have to decide how much audio to hand it at once. Extracter feeds chunks of about three minutes and cuts each one at the quietest point in its last 15 seconds (by RMS energy), so a sentence is rarely split across chunks. Timestamps stay monotonic across chunks, and long silent gaps advance the clock without wasting time transcribing silence.&lt;/p&gt;

&lt;p&gt;On Apple devices whisper.cpp uses Metal and Accelerate; elsewhere it uses the optimised CPU path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: NativeCallable and dying isolates
&lt;/h2&gt;

&lt;p&gt;The first version delivered events with &lt;code&gt;NativeCallable.listener&lt;/code&gt;: native code got a C function pointer and called it from the worker thread. It worked perfectly in every test, until I generated store screenshots on Android.&lt;/p&gt;

&lt;p&gt;The app crashed with &lt;code&gt;SIGSEGV&lt;/code&gt;, and the backtrace pointed at the line where the worker called the callback. Here's what happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A transcription is running on a native thread.&lt;/li&gt;
&lt;li&gt;The user presses Back on the main screen. Android finishes the activity, and the Flutter engine shuts down the Dart isolate.&lt;/li&gt;
&lt;li&gt;The isolate takes the &lt;code&gt;NativeCallable&lt;/code&gt; trampoline with it.&lt;/li&gt;
&lt;li&gt;The worker thread is still running and posts its next event, jumping to memory that no longer exists.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Even worse, when the app was reopened, the new isolate called &lt;code&gt;ex_init&lt;/code&gt; again, and the native side refused to replace the callback, so new jobs never reported back.&lt;/p&gt;

&lt;p&gt;The fix was to stop calling into Dart and &lt;strong&gt;post to a port instead&lt;/strong&gt;. Dart passes &lt;code&gt;NativeApi.postCObject&lt;/code&gt; and a &lt;code&gt;RawReceivePort&lt;/code&gt;'s native port to &lt;code&gt;ex_init&lt;/code&gt;, and the worker posts each event's address as an int64:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;ex_cobject&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kDartCObjectInt64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_int64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;reinterpret_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;intptr_t&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;ex_event_free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// dead isolate: native frees it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Posting to the port of a dead isolate just returns &lt;code&gt;false&lt;/code&gt;, so nothing crashes. Each job remembers the port of the isolate that started it, and a new &lt;code&gt;ex_init&lt;/code&gt; cancels jobs left over from an old isolate. I reproduced the crash with a tiny &lt;code&gt;flutter run&lt;/code&gt; target (start a job, &lt;code&gt;SystemNavigator.pop()&lt;/code&gt;, relaunch), and after the fix the same process survived three isolates in a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models
&lt;/h2&gt;

&lt;p&gt;Models come from a pinned Hugging Face revision of &lt;code&gt;ggerganov/whisper.cpp&lt;/code&gt; and are verified by size and SHA-256 before they're installed. Base (60 MB) is the recommended default and Large v3 Turbo (574 MB) is the most accurate; models that need more memory than the device has are locked in the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the app
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;History&lt;/strong&gt; is stored in SQLite (Drift) with FTS5 full-text search across all transcripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; to TXT, SRT and VTT, or copy as clean paragraphs, with or without &lt;code&gt;[00:00:12 → 00:00:18]&lt;/code&gt; timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store screenshots and the demo GIF above are generated by integration tests&lt;/strong&gt;, run with &lt;code&gt;flutter drive&lt;/code&gt; on a real emulator with real transcription, then composed with a small Python script.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Code (MIT): &lt;a href="https://github.com/sb-dor/Extracter" rel="noopener noreferrer"&gt;https://github.com/sb-dor/Extracter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Android APK: &lt;a href="https://github.com/sb-dor/Extracter/releases/latest" rel="noopener noreferrer"&gt;https://github.com/sb-dor/Extracter/releases/latest&lt;/a&gt; (Google Play is in review)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Contributions are very welcome, especially Windows and Linux builds and translations. There are a few &lt;code&gt;good first issue&lt;/code&gt; tickets to start with. If you have questions about the FFI side, ask in the comments!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was written with AI assistance and reviewed by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
