Skip to content

[Feat]: On-device push-to-talk voice input (Whisper STT) - #786

Draft
a-ghorbani wants to merge 17 commits into
mainfrom
feature/TASK-20260619-2246
Draft

[Feat]: On-device push-to-talk voice input (Whisper STT)#786
a-ghorbani wants to merge 17 commits into
mainfrom
feature/TASK-20260619-2246

Conversation

@a-ghorbani

Copy link
Copy Markdown
Owner

Summary

Adds multilingual, fully on-device speech-to-text as push-to-talk voice input in the chat composer. Hold the mic button, speak, release — the transcribed text is appended into the input field for the user to review and send. Nothing is auto-sent. The whole path runs locally via Whisper; the only network access is the on-demand model download.

Addresses voice-input demand from #698 (Russian voice models / Whisper), #427 (download local TTS and STT to chat with a local LLM — hands-free / driving), and #655 (Add Voice Mode). Companion to the existing on-device TTS, completing the voice loop.

Feature

  • Mic button in the composer, self-gating: shown only when the availability gate is open; at-rest mic when the selected model tier is installed, a setup affordance (routes to Settings) when the gate is open but no model is installed.
  • Availability gate mirrors the TTS device-memory gate: a boolean | null user override over a one-time getTotalMemory() check, surfaced as a "Voice input" toggle in App Settings with a below-threshold helper line on low-memory devices.
  • Three Whisper tiers (base q5_1 / small q8_0 default / large-v3-turbo q5_0), each its own on-disk install with a version sentinel and disk-preflighted download, modeled on the existing neural-model (TTS) downloader. The ASR model is deliberately not routed through the LLM model pipeline (a CoreML .mlmodelc directory doesn't fit the file-oriented GGUF candidate shape, and it would leak a non-chat entry into every LLM-model surface).
  • Push-to-talk capture FSM with a mandatory energy-VAD gate before the decoder (Whisper hallucinates on silence, so sub-floor buffers are never decoded). Native capture is released on press-out, error, max-duration, backgrounding, and unmount.
  • Transcript is appended to existing composer text (never overwrites, never auto-sends).
  • Permissions: NSMicrophoneUsageDescription (iOS), RECORD_AUDIO runtime permission (Android).

whisper.rn / llama.rn ggml coexistence

The main technical risk was linking whisper.rn's ggml fork alongside llama.rn's. A nm -D scan confirmed the two forks use disjoint symbol namespaces — llama.rn exports lm_ggml_* (with zero bare C-ABI ggml_*/whisper_*; the only whisper-substring symbols are llama.rn's own mangled CLIP/multimodal encoder), while whisper.rn's source fork uses wsp_ggml_*. The prefixes cannot collide. The clean iOS Release build under use_frameworks! :linkage => :static required no symbol-namespacing fallback; the only Podfile change was forcing whisper-rn to static linkage (same fix already applied to llama-rn) to resolve a use_frameworks! ggml-header copy collision.

Native verification (NATIVE_CHANGES)

  • pod install clean; ios/Podfile.lock committed (whisper-rn 0.6.0, llama-rn 0.12.4).
  • iOS Release build: succeeded (static link, no duplicate-symbol errors).
  • Android Release build: succeeded (both prebuilt .so coexist; namespaced ggml, no collision).

Tests

Full suite green: 252 suites, 3702 passing / 2 skipped, global coverage ~75% statements (≥ 60% threshold). New ASR modules: energyVad, MicButton, constants and mic-permission mapping at 100%; usePushToTalk ~88%; ASRStore ~76%. Coverage spans the gate formula, capture FSM transitions, VAD silence rejection, permission grant/deny/blocked mapping, forced re-download on stale sentinel, append-not-overwrite composer seam, and the Settings toggle.

A prod-bundle DCE check in CI asserts the E2E-only automation host used for deterministic visual capture is stripped from production builds.

Visual / manual caveat

Three at-rest UI states (mic at rest, gate-open-not-installed setup affordance, low-memory Settings toggle) are captured on an iOS simulator. The two microphone-dependent states (active recording, transcript appended) cannot be driven on a simulator — the simulator yields no audio frames, the same documented limitation that blocks E2E coverage of the Apple Shortcuts feature — so they are covered by unit tests and to be confirmed by manual on-device capture during physical-device verification.

Generated by PocketPal Dev Team

Add whisper.rn, @fugood/react-native-audio-pcm-stream, and
react-native-permissions for on-device voice input. Wire the iOS
NSMicrophoneUsageDescription, the Android RECORD_AUDIO permission, and a
microphone-permission helper (granted/denied/blocked mapping).

whisper.rn and llama.rn both vendor a ggml fork under disjoint symbol
namespaces (wsp_ggml_* vs lm_ggml_*). Under use_frameworks! :linkage =>
:static their framework header-copy phases collide on identically-named
ggml headers, so force whisper-rn to static_library alongside the existing
llama-rn static workaround.
Add the on-device speech-recognition service: per-tier Whisper model
manifests (base q5_1, small q8_0, large-v3-turbo q5_0), a whisper.rn-backed
engine with per-tier on-disk install + version sentinel (sentinel is the
final download write so an interrupted install never reports ready), and an
energy-VAD gate that rejects sub-floor buffers before the decoder. Whisper
hallucinates on silence, so gating is load-bearing. Transcription runs
entirely on-device; the only network call is the HuggingFace model
download.
Add a MobX store mirroring the TTS subsystem: a memory-derived availability
gate with a persisted boolean|null user override, per-tier download
orchestration (reclaim before disk preflight, sentinel-gated install), and a
transient push-to-talk capture state machine. The store never touches the
LLM ModelStore. Initialize it at app boot alongside the TTS store.
Own the push-to-talk capture lifecycle: request mic permission, capture
16 kHz mono PCM bounded by a max-record cap, run the energy-VAD gate on
release, transcribe on-device, and hand the transcript to a callback (never
auto-send). Native capture is released on press-out, error, max-ms, app
background, and unmount so the microphone is never leaked.
Add a push-to-talk mic button to the composer right-controls that self-gates
on the availability gate, routes to Settings when the model is not installed,
and appends the transcript to the composer (never auto-sent). ChatView gains
an appendTranscript seam that appends to the current input text rather than
overwriting it. Settings gains a voice-input toggle (memory-derived default
with user override) plus per-tier model install/remove controls. Add a
dedicated microphone icon and the en.json strings.
Cover the energy-VAD gate (silence/too-short rejection, PCM decode), the
ASRStore availability gate + per-tier download + capture FSM, the whisper
engine sentinel gating + offline transcribe path, the push-to-talk capture
lifecycle (permission deny, VAD reject, gate-closed, release on unmount),
and the composer mic button (self-gating, install routing, append-not-send).
Add jest mocks for whisper.rn, the audio pcm-stream, react-native-permissions,
and an ASR store mock.
Whisper model files (up to ~547 MB) under files/asr/ must not be uploaded to
cloud backup (25 MB limit) or device-transfer snapshots, matching the existing
tts/ exclusion.
Adds an __E2E__-gated pocketpal://asr?cmd=state::<...> host that forces the
voice-input store into the mic-at-rest, not-installed, and low-memory gate
states so a simulator visual-capture spec can screenshot them without a real
microphone, model download, or low-memory hardware. Registers the
ASR_AUTOMATION_STATE marker in the prod-bundle DCE grep.
Add voice-input append-not-overwrite seam tests, mic-permission mapping

tests, and ASR settings-toggle tests; register the voiceInput locale

section and align the asrStore mock setters with MobX runInAction.
@a-ghorbani a-ghorbani added the enhancement New feature or request label Jun 19, 2026
- Treat native AudioRecord.stop() as void (the .d.ts Promise type is wrong);
  wrap in Promise.resolve so teardown/transcribe is not aborted.
- Guard onPressIn against re-entrant presses, release during the permission
  dialog, and Android audio-init rejection.
- Distinguish a blocked microphone permission from a plain denial.
- Release the whisper context once transcription settles.
- Release the whisper context when the app backgrounds so it doesn't sit
  resident with the LLM (only 'background', not transient 'inactive').
- Auto-select a tier on successful download; reselect a remaining ready tier
  (or the default) when the active tier is deleted.
- Surface the disk-space preflight shortfall on the download-error channel
  instead of silently reverting to not-installed.
Render a transient snackbar keyed off the ASR capture error so denied,
blocked, too-short, and transcribe failures are visible. A blocked
microphone permission offers an open-Settings action.
When a tier download is blocked by the disk-space preflight, the Settings
row now renders how much space to free up alongside the retry control.
Add error-capture, error-blocked, and error-disk commands so the composer
error snackbar, the open-Settings route, and the Settings insufficient-storage
line can be captured without a real microphone. Stays behind the E2E gate.
The onPressIn entry guard bailed on any captureState other than 'idle',
but 'error' persists until the snackbar's 4 s auto-dismiss. An immediate
retry (the common 'too short' case) silently no-opped on a mic button
that still looked enabled. Treat 'error' as a re-armable resting state;
requesting_perm clears lastError, dismissing the stale error snackbar.
The insufficient-storage line showed the raw model size, but the
download preflight requires estimated * headroom free, so freeing the
shown amount still failed the gate. Add a single ASR_DISK_HEADROOM_FACTOR
constant shared by the preflight writer and the Settings message, and
display the headroom-inclusive figure. Also assert the fallback tier is
not ready in the delete-reselect test.
@a-ghorbani

Copy link
Copy Markdown
Owner Author

Independent review — APPROVE (2 rounds)

A multi-lens independent review (security, mobile, performance, QA, architecture, data, UX, local-invariants) ran over two rounds. All findings are resolved; every lens approves.

Round 1 → REQUEST_CHANGES. Substantive issues, since fixed:

  • Push-to-talk was non-functional on a real device. AudioRecord.stop() returns undefined (void) on both iOS and Android, but the code did .stop().catch() → synchronous TypeError in teardown → transcription never ran and the mic was never released. CI was green only because a test mock returned a resolved Promise, contradicting the native contract. Fixed with Promise.resolve(...), and the mock was corrected so a test now reproduces the defect.
  • Whisper context was never released → it stayed resident (~400–450 MB) alongside the LLM context, an OOM risk on 4 GB devices. Now released on app background (mirroring the TTS engine) and after each transcription.
  • Installing a non-default model tier bricked the mic (no active-tier selection) — now auto-selected on download/delete.
  • Capture errors and blocked-permission recovery were never surfaced — now shown via a composer snackbar with an Open-Settings action; insufficient-storage is surfaced in Settings with a preflight-accurate figure.
  • Several capture-lifecycle races hardened (press-out during the permission prompt, re-entrant press, unhandled Android init rejection).

Round 2 → APPROVE after a small regression in the re-entrancy guard was caught and fixed.

Privacy/offline invariant verified throughout: no audio, PCM, or transcript leaves the device; the only network call is the model download. whisper.rn + llama.rn ggml forks coexist cleanly (disjoint wsp_ggml_* / lm_ggml_* symbol namespaces); iOS + Android Release builds verified.

Full test suite green (3719 passed). Remaining gate before merge: a manual on-device smoke of the mic paths — hold→speak→release inserts text, blocked-permission → Open Settings, and context-release-on-background — since the microphone can't be driven in a simulator.

Generated by PocketPal Dev Team

@anantmi3

Copy link
Copy Markdown

When is this feature going to be released?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants