Skip to content

Fix release crashes, TTS bugs, and add fully-offline bundling + conversation-swap mode#8

Open
7MS8 wants to merge 21 commits into
IliyaBrook:masterfrom
7MS8:fix/tts-crashes-offline-mode-conversation-swap
Open

Fix release crashes, TTS bugs, and add fully-offline bundling + conversation-swap mode#8
7MS8 wants to merge 21 commits into
IliyaBrook:masterfrom
7MS8:fix/tts-crashes-offline-mode-conversation-swap

Conversation

@7MS8

@7MS8 7MS8 commented Jul 14, 2026

Copy link
Copy Markdown

Summary

  • Release-build crash fixes:
    • ONNX Runtime classes were being stripped by R8 in release builds, causing a JNI abort inside OrtSession.run (crashed NLLB offline translation). Fixed with a ProGuard keep rule.
    • Missing <queries> manifest declaration for android.intent.action.TTS_SERVICE meant TextToSpeech couldn't see any installed TTS engine on API 30+ (package visibility), so TTS silently failed to initialize.
  • TTS correctness fixes:
    • TTS could end up speaking the wrong language/voice because the "already initialized" re-init path silently discarded setLanguage()'s result with no fallback or logging, and the app never used the Voice API (some engines only fully honor setVoice()). Now both paths share one applyLocale() that checks the result, explicitly matches a Voice, and surfaces a UI warning on fallback.
    • Added a short retry for locales that come back "unsupported" right after an external TTS engine's own process restart (observed happening under system memory pressure), instead of permanently giving up.
    • Fixed a microphone feedback loop on the microphone audio source: muteMicDuringTts defaulted off, so the mic picked up the phone's own TTS playback acoustically and re-transcribed/re-translated it. Now on by default, plus a short cooldown after each utterance to cover the acoustic tail.
  • Fully-offline bundling: ASR models and NLLB can now be provisioned from bundled APK assets (fast local copy) instead of always requiring a runtime download, with the existing HTTP download path kept as a fallback for any language not bundled. Offline mode defaults on. (Model asset files themselves are not part of this diff — they're fetched/bundled at build time and gitignored.)
  • Conversation-direction swap: new swap button (source ⇄ target) for quick bidirectional conversations, backed by a small warm-recognizer pool so switching between two already-used languages doesn't pay a full model reload. Includes a couple of follow-up fixes found during testing on a memory-constrained device (an over-eager memory-pressure trim was thrashing the pool, and eagerly warming both directions at every launch was contributing to low-memory kills — both fixed).
  • Docs: documented RAM expectations for offline mode (NLLB + warm ASR pool + a memory-hungry third-party neural TTS engine were observed being killed by the Android low-memory killer under pressure on an 8GB device).

All commits are kept small and independently reviewable/revertable — happy to split into separate PRs if that's preferred.

Test plan

  • Release build (assembleRelease) installs and runs without crashing on a Pixel 7a (GrapheneOS)
  • TTS speaks in the correct language/voice for multiple source/target pairs
  • Fully-offline flow verified in airplane mode (ASR + NLLB + TTS)
  • Conversation swap tested mid-session, including repeated swaps, with no crash and stable memory

Hi, all this code was produced by claude code. I just did testing and architecture. Thank you for your work and effort, just wanted to give back. Kind regards, Michael

michael added 21 commits July 14, 2026 12:54
R8 was obfuscating/removing ONNX Runtime's Java classes, which its native
JNI layer (libonnxruntime4j_jni.so) looks up by name via reflection. This
caused a JNI abort ("java_class == null" in GetMethodID) inside
OrtSession.run, crashing the NLLB offline translation path in release
builds.
Without this, TextToSpeech cannot see any installed TTS engine on
Android 11+ and initialization silently fails with status ERROR.
TtsEngine.initialize() had two divergent code paths: the cold-init path
checked setLanguage()'s result and fell back to English on missing data,
but the warm re-init path (hit every time the target language changes
after the first call) discarded the result entirely with no fallback or
logging. Some TTS engines also only fully honor the modern Voice API, so
setLanguage() alone can report success without actually switching the
active synthesis voice.

Both paths now go through a shared applyLocale() that checks the
setLanguage() result, explicitly selects a matching offline Voice, logs
the outcome, and surfaces a warning via TranslationUiState when the
requested language falls back to English.
Gitignore app/src/main/assets/models/ (fetched locally, not committed —
multi-GB ASR/NLLB model binaries) and configure the release build to
store them uncompressed (required for AssetManager.openFd()-based
extraction progress sizing) with a larger Gradle heap to package them.

No model files or extraction logic yet — this only prepares the build
config ahead of that.
Adds AssetModelProvisioner, which extracts model files from
app/src/main/assets/models/ (if bundled) into internal storage — the
native ASR/ONNX code needs real filesystem paths, not zipped asset
streams. ModelDownloader and NllbModelManager both try this fast local
copy first, falling back to the existing HTTP download unchanged when
a language's assets aren't bundled (e.g. a language added later without
a matching assets rebuild).

NllbModelManager.ensureModelAvailable() also gains an optional warmup
callback so status-transition/error-handling boilerplate around
initializing an NllbTranslator is shared rather than duplicated per
call site.

No model files are committed — app/src/main/assets/models/ is
gitignored and fetched independently per build.
Previously, offline-mode pre-loading only touched the NLLB model if it
was already downloaded, silently skipping provisioning otherwise and
requiring a manual trip to Settings. With bundled assets making
provisioning a fast local copy rather than a multi-hundred-MB network
download, it's now safe to call ensureModelAvailable() unconditionally
whenever offline mode is enabled.

SettingsViewModel's manual "download" button now goes through the same
shared warmup-callback path added to NllbModelManager.
Only affects fresh installs / cleared app data. Paired with bundled
model assets, this makes the app fully offline-capable out of the box
with no user setup required.
Previously muteMicDuringTts defaulted to false, so on the microphone
source the mic kept picking up the phone's own TTS playback
acoustically, which got transcribed as gibberish and re-translated —
a voice feedback loop. isSpeaking now also stays true for a short
cooldown after each utterance ends, covering the speaker/room echo
tail that would otherwise still leak into the next recognition window.
Adds ConversationRecognizerPool, holding up to 2 SherpaOnnxRecognizer
instances keyed by language (LRU-evicted beyond that). Switching the
source language previously always released and reinitialized the ASR
recognizer (a multi-second reload); now TranslationService and
MainViewModel acquire from this pool instead, so once both directions
of a conversation have been loaded once, switching between them is a
cache hit.

SherpaOnnxRecognizer is no longer Hilt-managed (the pool creates plain
instances directly, since @singleton is incompatible with holding more
than one loaded language at a time) — the now-unused bindSpeechRecognizer
binding is removed from AppModule.

MainViewModel.preloadPipelineComponents() also pre-warms the target
language's ASR model at launch (when it has one), not just the source,
so both directions are ready before a swap is ever requested.
Atomically flips source/target language in one DataStore edit, instead
of two separate update calls, to avoid a torn intermediate state.
Eagerly pre-warming the target language's ASR model at every app
launch (on top of the already-loaded source model and, in offline
mode, NLLB's ~1GB) pushed a loaded device over its memory watermark
and got the whole process killed by the Android low-memory killer
("min watermark is breached and swap is low") — reproduced on a
Pixel 7a mid-conversation.

The warm pool now only builds up lazily: the source language loads as
before, and the target only gets pre-warmed the first time
swapLanguages() is actually used, not on every launch regardless of
whether the user ever swaps.

As a second line of defense, ConversationRecognizerPool.trimToMostRecentlyUsed()
releases the least-recently-used warm recognizer in response to
Application.onTrimMemory(), so once both directions do get warmed
during a long conversation, the pool shrinks itself back to one under
memory pressure instead of the system killing the whole process.
Icon button between the source/target language labels, flipping the
conversation direction via MainViewModel.swapLanguages(). Disabled
when the current target language has no ASR model, since there'd be
nothing to recognize a reply in that language.
Two distinct issues surfaced testing conversation swaps on a
memory-constrained device:

1. onTrimMemory reacted at TRIM_MEMORY_RUNNING_LOW, which fires very
   often under system-wide (not just this app's own) memory pressure —
   discarding the other direction's warm ASR recognizer right after it
   loaded and forcing a reload on essentially every swap. Now only
   reacts at TRIM_MEMORY_RUNNING_CRITICAL.

2. The external TTS engine app itself can be killed by the system
   under memory pressure (observed directly: "Kill 'org.woheller69.
   ttsengine' ... min watermark is breached"). After Android
   auto-restarts and rebinds it, there's a window where it hasn't
   finished reloading its voice data yet, so setLanguage() reports the
   requested language as missing even though it's actually installed —
   TtsEngine now retries once after a short delay before permanently
   falling back to English.
Offline mode (NLLB) plus the ASR conversation-swap cache and a
memory-hungry third-party neural TTS engine were observed getting
killed by the Android low-memory killer on an 8GB Pixel 7a under
normal background app load. Document a 6-8GB RAM recommendation for
offline mode so users aren't surprised by this on constrained devices.
…uages

acquire() had no synchronization: a language swap's pre-warm call and the
subsequent pipeline restart's own acquire() could race, each seeing the
pool empty and building a duplicate SherpaOnnxRecognizer for the same
language, with the second write silently orphaning the first. Observed
crashing the pipeline (JobCancellationException followed by a colliding
AudioRecord IllegalStateException). Added a Mutex around acquire/reconcile.

Also add reconcile(desiredLanguages), releasing any warm recognizer left
over from a since-changed source/target setting -- previously a stale
entry from before a settings change stayed resident indefinitely
alongside the new one.
Stop (and now pause) tore down the recognizer's collection loop via plain
cancellation, discarding whatever was still being transcribed if the user
pressed Stop/Pause before sustained silence or Sherpa's own endpoint
detection had a chance to finalize a segment -- the most common case,
since users typically stop right after finishing a sentence rather than
waiting through an extra pause first.

The recognition coroutine's finally block now recomputes and flushes
whatever's buffered, wrapped in withContext(NonCancellable) so the
(already-cancelling) coroutine can still emit it.

Also add pauseAndFlush(), returning the flushed text directly rather than
via recognizedSegments -- that flow's collector is gated on "not paused",
and a normally-emitted segment would arrive there right as isPaused turns
true, silently dropped by the same guard it needs to get past.
Plumbing only -- TranslationService will set this once pause becomes a
distinct action from stop.
Several related fixes to how starting/stopping/pausing tear down (or
don't) in-flight work:

- Reconcile the ASR pool to the current source/target before acquiring a
  recognizer, so a language changed via Settings or a swap doesn't leave
  its old recognizer orphaned but resident (see ConversationRecognizerPool
  commit) -- this is what was causing an OOM kill shortly after launch.

- Translation coroutines now run on serviceScope directly instead of as
  children of the per-session pipelineJob, tracked in
  pendingTranslationJobs. stopPipeline() used to cancel pipelineJob
  immediately, tearing down a translation already in flight for the
  segment recognized right before Stop was pressed -- it now gives those
  jobs a bounded grace period to reach TTS before finalizing the stop.

- pausePipeline/resumePipeline rewritten: pause now flushes and translates
  whatever the user was mid-sentence saying (via
  SpeechRecognizer.pauseAndFlush(), see previous commit) instead of
  silently dropping it, and resume re-opens audio capture from scratch
  (extracted into beginListening()) since pausing tears down the
  underlying AudioRecord too.
Checked level >= TRIM_MEMORY_RUNNING_CRITICAL, but the docstring's stated
intent was "only RUNNING_CRITICAL" -- >= also matched TRIM_MEMORY_UI_HIDDEN
(20) and everything above it, which fire on any simple app switch or
screen lock with no memory pressure implied, unlike RUNNING_CRITICAL (15)
specifically. The pool was being trimmed and reloaded on ordinary
backgrounding instead of only under real pressure.
…re reload

- pauseTranslation()/resumeTranslation() send the existing ACTION_PAUSE/
  ACTION_RESUME intents, exposing isPaused as a StateFlow for the UI.

- Punctuation model download+extraction is no longer awaited during
  preload: it's optional (English sentence-casing only), but the pipeline
  wouldn't reach ModelStatus.Ready until it finished, and its download has
  been observed stalling for minutes under memory/CPU pressure from the
  ASR+NLLB loads happening alongside it, making the app appear hung. Now
  fire-and-forget: it wires into the recognizer whenever/if it lands.

- releaseWarmRecognizersThen() releases every warm ASR recognizer before
  running a start or swap, called unconditionally rather than only under
  low memory: loading a new ASR model on top of an already-warm one was
  observed reliably tipping memory-constrained devices into a full
  system-wide low-memory-killer cascade. swapLanguages() calls this at the
  point it's safe to (after stopping and waiting out any running session),
  trading the pool's instant swap-back for a full reload every time (a few
  seconds) -- confirmed an acceptable default on a memory-constrained
  device.
The button previously toggled start/stop on every tap. It's now a Surface
with combinedClickable (FloatingActionButton's own onClick fought with an
overlaid combinedClickable, silently swallowing every tap when tried
first): short tap starts, or pauses/resumes while running; long-press
fully stops. Ending the session is otherwise only triggered by a language
swap, which needs a real restart anyway.

Icon reflects current state, not the next action: Mic while actively
recording, Pause once actually paused (this was backwards in an earlier
iteration -- tapping to start showed the Pause icon immediately). The
"Listening..." status line likewise switches to a static "Paused" label
and icon instead of a misleading spinner.
@7MS8

7MS8 commented Jul 15, 2026

Copy link
Copy Markdown
Author

Follow-up fixes from live device testing (2026-07-15)

Testing this PR's changes on a memory-constrained device (Pixel 7a, GrapheneOS,
8GB RAM) surfaced several more bugs, all fixed in 7 new commits on this branch:

  • ConversationRecognizerPool race condition: acquire() had no
    synchronization, so a language swap's pre-warm call could race the pipeline
    restart's own acquire, each building a duplicate recognizer for the same
    language and crashing the pipeline. Added a Mutex.
  • Stale ASR languages never released: changing source/target language (via
    Settings or swap) left the old language's recognizer warm indefinitely
    alongside the new one, contributing to OOM kills. Added pool reconciliation
    before every session start.
  • Stop/Pause silently dropped the last thing said: both tore down the
    recognizer via plain cancellation, discarding whatever was still being
    transcribed if pressed before silence/endpoint detection finalized a segment
    (the common case). The recognizer now flushes in-flight text on both
    stop and pause; pause specifically returns it directly rather than through
    the normal segment flow (which is gated on "not paused" and would otherwise
    drop it right as that flag flips).
  • Record button reworked: tap now starts, or pauses/resumes while running;
    long-press fully stops. Previously every tap toggled start/stop, so pressing
    it while listening ended the whole session instead of just pausing.
  • onTrimMemory over-triggering: checked level >= TRIM_MEMORY_RUNNING_CRITICAL,
    which also matched TRIM_MEMORY_UI_HIDDEN and above -- levels that fire on
    ordinary backgrounding (task switch, screen lock) with no memory pressure
    implied. The ASR pool was being trimmed and reloaded on every simple app
    switch instead of only under real pressure.
  • Punctuation model blocked Ready: its (optional) download+extraction was
    awaited during preload, and was observed stalling for minutes under
    memory/CPU pressure from ASR+NLLB loading alongside it, making the whole app
    appear hung. Now fire-and-forget.
  • Always release warm ASR models before loading the next one: previously
    the pool tried to keep both conversation directions warm even under memory
    pressure, which reliably triggered a full system-wide low-memory-killer
    cascade on this device (dozens of unrelated apps and system services
    killed). Now unconditional: every start/swap releases whatever's warm first,
    trading the pool's instant swap-back for a full reload (a few seconds) --
    confirmed a good default trade on memory-constrained hardware.

All still small, independently revertable commits, same as the rest of this PR.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant