Skip to content
sysout.dev

Speech Capture App — Technical Design

1. Architecture Overview

Stage

What happens

Capture

Foreground service + AudioRecord records continuously while the app is backgrounded

Transcription

Saved WAV chunks are passed to whisper.cpp after Stop (on-device Whisper) → raw transcript

Cleanup

Planned follow-on: raw transcript passed to LiteRT-LM (on-device Gemma) → structured text

Output

Raw transcript saved locally; structured cleanup output is planned

KMP module shape:

  • shared — cleanup orchestration, data models, expect/actual interfaces for recording and transcription

  • androidMain — foreground service, AudioRecord, whisper.cpp JNI bridge, LiteRT-LM Kotlin bindings

  • iosMain / desktopMain — stubbed for later (see §5)

2. Speech-to-Text (STT)

2.1 What’s actually happening when you dictate

Two broad families of speech-recognition models exist:

  • Streaming / transducer models (e.g. RNN-T, Zipformer) — process audio in small real-time slices and emit words as you speak. Built for live captions.

  • Encoder-decoder batch models (e.g. Whisper) — process a chunk of audio as a whole and emit the full transcript for that chunk at once. Built for accuracy, not live captions.

Android’s built-in SpeechRecognizer is neither of these directly — it’s an OS-level API that hands audio off to a Google-provided recognizer (cloud or on-device depending on settings) and gives you back discrete "utterance" results. You don’t control the underlying model.

2.2 Why not just use Android’s SpeechRecognizer

Already covered in the earlier discussion, restated for the design record: it’s built around short discrete utterances, not continuous long-form dictation. Its restart-on-silence loop drops a word or two at each restart boundary — the same class of failure as Gboard’s cutoff problem, just less frequent. It’s also a black box: no control over chunking, no offline guarantee across all devices, no ability to bias it toward your own vocabulary.

2.3 What Whisper actually is

Whisper is OpenAI’s open-weight speech-recognition model, trained on a very large multilingual audio/text dataset. It comes in sizes from tiny (~75MB) to large (~3GB), trading size for accuracy. whisper.cpp is a C/C reimplementation that runs the quantized model on CPU without needing Python or a GPU — which is what makes it viable on a phone. There's no official mobile SDK for it; every Android integration is a hand-built JNI bridge over the C library. That’s more integration work than a "just add a dependency" library, but it’s a well-trodden path — plenty of open-source Android apps have done exactly this.

2.4 Streaming vs. chunked — an important decision

This is worth flagging explicitly because it changes the engineering difficulty a lot. Whisper’s architecture was not designed for live streaming: real-world reports of people running whisper.cpp in a "re-transcribe on every 1-2 second buffer" live-captioning loop on Android have measured it running roughly 5x slower than real-time — the repeated re-evaluation of overlapping audio is expensive on a mobile CPU.

The implemented MVP does not show a live transcript. It records continuously, writing a 25-second WAV chunk at a time, but it does not run Whisper while the user is listening. After Stop, it finalizes the current partial chunk, then passes every saved chunk to Whisper in capture order and joins the resulting text.

There is currently no chunk overlap and no boundary de-duplication. That keeps the first pass simple and ensures the entire recording exists before expensive CPU work begins, but it makes boundary accuracy a benchmark item: a word split across two chunks must be assessed on real recordings before this is treated as production-ready.

2.5 Model size choice

The active MVP model is Whisper small.en (about 488 MB). It is downloaded once into app-private storage and then runs locally. medium.en is catalogued as a later option, but is not selected until its accuracy benefit justifies its substantially larger storage, memory, thermal, and processing cost on real devices. base.en was only an integration proof and is no longer the active model.

2.6 Alternatives considered

Option Why it’s an alternative Why not chosen for MVP

Android SpeechRecognizer

Zero integration work, built into the OS

The restart/cutoff problem is the exact issue this project exists to solve — using it defeats the point

Vosk

Mature, lightweight, purpose-built for streaming, ships an official Android AAR (much less integration work than whisper.cpp)

Noticeably lower accuracy than Whisper on open-domain speech, especially technical/jargon-heavy dictation (feature names, bug terminology) — a real cost for this use case

sherpa-onnx (Next-gen Kaldi)

Streaming-native framework with better-maintained Android bindings than raw whisper.cpp; can also run Whisper models under a more mobile-friendly wrapper

Extra framework surface area for MVP; worth keeping as a fallback if the whisper.cpp JNI bridge proves more costly than expected

Commercial on-device ASR+LLM SDKs (e.g. Cactus)

Bundles transcription and LLM cleanup behind one SDK, less glue code

Third-party dependency/lock-in, at odds with the local-first, dependency-light stance this project is explicitly taking

Current decision: whisper.cpp, post-stop chunked transcription with small.en.

3. Local LLM Cleanup

3.1 What "running an LLM locally" actually means

The short version: model weights get compressed (quantized, usually to 4-bit) and the parameter count is kept small (1-4 billion, versus 100B+ for something like GPT-4-class models). That combination is small enough to fit in a phone’s RAM and run on its CPU/GPU/NPU in a few seconds per response. The job here is narrow — reformatting a rough transcript into structured text — not open-ended reasoning, so a small model is genuinely enough; you’re not leaving quality on the table by avoiding a bigger model.

This is Google’s current production on-device inference framework — the successor to the older MediaPipe LLM Inference API, which Google’s own docs now list as maintenance-only. LiteRT-LM is genuinely cross-platform: a single C++ core with official Kotlin (Android), Swift (iOS/macOS), Python, and JavaScript bindings, running on Android, iOS, Web, and Desktop. It supports Gemma, Llama, Phi-4, and Qwen model families via a .litertlm model-bundle format, with GPU acceleration via OpenCL on Android, Metal on iOS, and WebGPU on the web. Given your stated iOS/desktop future plans, this cross-platform reach matters — it’s the strongest alignment of any option here with where you say this is headed.

3.3 Model choice

The existing prototype can invoke a Gemma 4 E2B .litertlm bundle when present, but cleanup is deferred as a product capability until Whisper quality and timing are accepted. The next cleanup pass needs an explicit output contract, prompt, download/storage behaviour, and raw-text fallback rather than simply enabling a model file.

3.4 Alternatives considered

Option Why it’s an alternative Why not chosen for MVP

Gemini Nano / AICore

Deepest OS-level integration, no model download needed

Already ruled out earlier — device support is still narrow (Pixel 8/9 series, Samsung S24+, Snapdragon 8 Gen 3+ only)

llama.cpp + GGUF models

Largest model selection, huge community, very flexible

No official Kotlin/Swift bindings — you’d be hand-building a second native JNI bridge (on top of the one whisper.cpp already needs), and it doesn’t carry the same official cross-platform story LiteRT-LM does

MLC-LLM

Can be faster on GPU via Apache TVM compilation

Heavier toolchain — needs a model-specific compilation step per target platform, steeper setup than LiteRT-LM’s ready-made model bundles

MediaPipe LLM Inference API

Direct predecessor to LiteRT-LM, lots of existing tutorials

Explicitly maintenance-only per Google’s current docs — not a sound foundation for a new project

Recommendation: LiteRT-LM with Gemma 4 (E2B).

4. Persistent Background Recording

Android aggressively restricts background execution to save battery, so a plain background thread will get killed. A foreground service is the sanctioned way around this — it shows a persistent notification and tells the OS "this is user-visible work, don’t kill it." Since Android 14, microphone use from a foreground service requires declaring the FOREGROUND_SERVICE_MICROPHONE type explicitly, in addition to the RECORD_AUDIO runtime permission. A wake lock (or the service’s own foreground status) is also worth double-checking so the CPU doesn’t doze mid-recording on longer sessions.

OEM background-kill risk

Declaring the API correctly isn’t the whole story. Aggressive battery managers on MIUI (Xiaomi), OneUI (Samsung), ColorOS (Oppo), and similar OEM skins are known to kill foreground services anyway unless the user separately grants battery-optimization exemptions and, on some OEMs, "autostart" permission. This is a well-documented pain point across the Android ecosystem (the community site dontkillmyapp.com catalogs per-OEM workarounds). Given you’re testing primarily on Indian-market devices, budget real device testing here — this is exactly the kind of thing that looks fine on a Pixel and silently fails on a Redmi.

5. KMP Module Layout

Layer Android (now) iOS/Desktop (future)

Recording

AudioRecord + foreground service

iOS: AVAudioEngine; Desktop: platform audio APIs

Transcription

whisper.cpp via JNI

iOS: WhisperKit (native Swift port of Whisper — the natural counterpart to whisper.cpp; other apps doing exactly this whisper.cpp-on-Android + WhisperKit-on-iOS split already exist in the wild)

Cleanup LLM

LiteRT-LM Kotlin bindings

LiteRT-LM Swift bindings (iOS/macOS) / Python or C++ (Desktop) — same framework, official bindings on both sides

The LLM layer is close to "write once" across platforms because LiteRT-LM’s bindings are official and maintained by Google on both sides. The STT layer is the one place where Android and iOS will genuinely diverge (whisper.cpp vs. WhisperKit) — worth keeping that interface behind a clean expect/actual boundary in the shared module so the divergence is contained.

6. Data Flow

[Mic] -> AudioRecord (foreground service)
      -> sequential 25s WAV chunks (plus final partial chunk)
      -> user presses Stop -> durable FIFO queue
      -> one whisper.cpp worker processes queued recordings in order -> raw transcript
      -> local storage
      -> optional future LiteRT-LM cleanup -> structured text

7. Risks & Open Questions

  • whisper.cpp has no official mobile SDK — the JNI/CMake bridge is custom work and must be validated on physical hardware

  • Chunked post-stop transcription means no on-screen live captions and no transcription while recording — this is deliberate for the MVP

  • The current chunks have no overlap, so boundary accuracy needs a real-recording test before calling long-form capture reliable

  • The current small.en baseline on a Motorola Edge 50 Fusion is roughly 4x real time for a short sample; long, thermal, and accuracy benchmarks remain open

  • GGML OpenMP is currently disabled because its Android runtime was not packaged safely; it needs a standalone packaging/startup/benchmark experiment

  • OEM background-kill behavior needs testing on real Xiaomi/Samsung/Oppo hardware, not just emulators or a Pixel

  • The first-run small.en download is about 488 MB. It is atomic but not yet resumable or Wi-Fi-aware

  • Capture can continue while one saved recording transcribes. The durable FIFO queue intentionally keeps only one Whisper worker active, avoiding competing native inference workloads while preserving the next thought immediately.

9. Cross-app shared model storage (not yet built)

Both WhisperTranscriber and LlmCleanupEngine in the implementation take a plain modelPath: String — RecordingService is the only place that resolves those paths right now (filesDir/models/…​, app-private). That’s a deliberate seam: swapping in shared storage later only touches that one resolution point, not the transcription/cleanup engines themselves.

For actually sharing a downloaded model across sysout apps, BlobStoreManager (API 30+) is the right mechanism, confirming the shape of it:

  • Blobs are addressed by a BlobHandle — a SHA-256 digest plus a label, an expiry time, and a tag. All four must match for two apps to agree they’re looking at the same blob, so the exact label/tag/expiry convention needs to be shared across the sysout apps' codebases, not just the digest.

  • The contributing app commits a session and picks an access mode: allowPackageAccess() (named packages), allowSameSignatureAccess(), or allowPublicAccess(). allowSameSignatureAccess() is the natural fit for a same-developer app portfolio — but only if all the sysout apps are actually signed with the same key. Worth confirming that before assuming this path works, since many per-app setups use distinct keys by default.

  • A consuming app checks for the blob first (blobStoreManager.openBlob(blobHandle)), and only downloads its own copy if that throws.

This is a cross-app decision, not really an In My Head-specific one — Homey and future apps would want the same convention. Worth its own short design pass once there’s an actual second app that wants to share a model, rather than building it into this one first and having the others conform to whatever this app happened to pick.

10. Current checkpoint and next step

The foreground-service capture, local model download, post-stop Whisper transcription, raw-transcript persistence, failure retention/retry, and transcription diagnostics are working on Android. The immediate next step is a repeatable physical-device benchmark and accuracy gate for small.en, including longer recordings, Indian/US English accents, sequential speakers, and thermal behaviour. Gemma cleanup follows that gate; UI/UX work follows the forthcoming user design decisions.