Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c016249
Keep ai.onnxruntime classes from R8 stripping in release builds
Jul 14, 2026
4e4ad02
Declare TTS_SERVICE queries element for API 30+ package visibility
Jul 14, 2026
dec64aa
Fix TTS speaking wrong language via Voice API selection
Jul 14, 2026
c5e497f
Prepare packaging for bundled offline model assets
Jul 14, 2026
234e6a8
Provision ASR/NLLB models from bundled APK assets when present
Jul 14, 2026
37ac67a
Auto-provision NLLB at launch instead of requiring a manual download
Jul 14, 2026
c9f47cf
Default offlineMode to true
Jul 14, 2026
f05f765
Default mic muting during TTS on, with an acoustic-tail cooldown
Jul 14, 2026
e01f778
Keep both conversation-direction ASR models warm in a small pool
Jul 14, 2026
fb74ad6
Add SettingsRepository.swapLanguages()
Jul 14, 2026
60ff1d6
Fix OOM kill from dual-warming both ASR models on every launch
Jul 14, 2026
3f1f744
Add language-swap button to MainScreen
Jul 14, 2026
ea1e3f0
Reduce ASR pool thrashing and add a locale retry for TTS engine restarts
Jul 14, 2026
5dec126
Document RAM requirements for offline mode
Jul 14, 2026
69f68db
Make ConversationRecognizerPool concurrency-safe and evict stale lang…
Jul 15, 2026
a5ef1c9
Flush in-flight ASR text on stop and pause instead of dropping it
Jul 15, 2026
636d27f
Add isPaused state for the pause/resume UI
Jul 15, 2026
a192cf4
Stop pipeline lifecycle from cancelling work it should let finish
Jul 15, 2026
130198c
Fix onTrimMemory over-reacting to ordinary backgrounding
Jul 15, 2026
aa4f615
Add pause/resume, unblock Ready from punctuation, release models befo…
Jul 15, 2026
610fa58
Rework the record button: tap pauses/resumes, long-press stops
Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ keystore.properties
*.apk
*.aab

# Bundled model assets (fetched locally, not committed — multi-GB binaries)
app/src/main/assets/models/

# Android Studio
captures/
.externalNativeBuild/
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ or **offline** (NLLB-200-distilled-600M via ONNX Runtime, no internet needed aft
- **Android SDK 36** (compileSdk)
- **Device or emulator** with API 29+ (Android 10 required for AudioPlaybackCapture)

### Device RAM

- **Online mode** (Yandex API translation): a single ~26--190 MB ASR model resident -- fine on any modern device.
- **Offline mode** (NLLB-200 translation): NLLB itself uses **~900 MB--1 GB RAM** once loaded, on top of the active
ASR model(s). **At least 6--8 GB of total device RAM is recommended** for offline mode to run reliably alongside
normal background app usage.
- Switching translation direction keeps up to two ASR models warm at once (source + target language) for fast
swaps, adding up to ~350 MB more in the worst case (e.g. English + Spanish).
- On memory-constrained devices, or devices with many background apps, the Android low-memory killer can terminate
the app (offline mode + a memory-hungry third-party neural TTS engine were observed being killed under pressure
on an 8 GB Pixel 7a with many other apps installed). The app defends against this by shrinking its own ASR cache
under memory pressure (`ComponentCallbacks2.onTrimMemory`), but a chronically RAM-starved device may still see
occasional TTS voice hiccups or ASR reloads.

## Build & Run

```bash
Expand Down
10 changes: 10 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ android {
}
}

androidResources {
// Bundled ASR/NLLB model files (app/src/main/assets/models/) are large,
// already-dense binaries (quantized ONNX weights) or small text/config
// files read via AssetManager.openFd() for first-launch-extraction
// progress sizing (openFd requires STORED, i.e. uncompressed, zip
// entries). No other .onnx/.txt/.model/.vocab/.json assets exist in
// this project.
noCompress += listOf("onnx", "txt", "model", "vocab", "json")
}

lint {
// onnxruntime-android:1.17.1 native lib is not 16KB-aligned, but the APK
// uses libonnxruntime.so from sherpa-onnx.aar (via pickFirsts), not Maven.
Expand Down
6 changes: 6 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
# Add project specific ProGuard rules here.
-keep class com.k2fsa.sherpa.onnx.** { *; }

# ONNX Runtime's native JNI code (libonnxruntime4j_jni.so) looks up Java classes/methods
# by name via reflection (GetMethodID). R8 stripping/renaming them causes a JNI abort
# (java_class == null) inside OrtSession.run, crashing the NLLB offline translation path.
-keep class ai.onnxruntime.** { *; }
-dontwarn ai.onnxruntime.**
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
<!-- Keep alive -->
<uses-permission android:name="android.permission.WAKE_LOCK" />

<!-- Required on API 30+ package visibility: without this, TextToSpeech
cannot see any installed TTS engine and init fails with status ERROR. -->
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>

<application
android:name=".InstantVoiceTranslateApp"
android:allowBackup="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,53 @@ package com.example.instantvoicetranslate
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.util.Log
import com.example.instantvoicetranslate.asr.ConversationRecognizerPool
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

@HiltAndroidApp
class InstantVoiceTranslateApp : Application() {

companion object {
const val CHANNEL_ID = "translation_service"
private const val TAG = "InstantVoiceTranslateApp"
}

@Inject lateinit var recognizerPool: ConversationRecognizerPool

override fun onCreate() {
super.onCreate()

createNotificationChannel()
}

/**
* Under severe memory pressure, shrink the ASR warm-pool back to one
* language before the system decides to kill this whole process instead.
*
* Reacts only to RUNNING_CRITICAL, not the much more frequent
* RUNNING_LOW/MODERATE levels: on a device that's chronically close to
* its memory watermark (many background apps, a memory-hungry external
* TTS engine also competing for RAM), those fire on essentially every
* turn of a conversation, which was thrashing the pool -- discarding the
* other direction's warm recognizer right after it was loaded and
* forcing a reload on the very next swap.
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
// Must be == not >=: TRIM_MEMORY_UI_HIDDEN (20) and everything above
// it fire on ordinary backgrounding (task switch, screen lock) with
// no memory pressure implied, unlike RUNNING_CRITICAL (15) which is
// specifically the foreground-process pressure signal. `>=` matched
// UI_HIDDEN too, so the pool was being trimmed and reloaded on every
// simple app switch instead of only under real pressure.
if (level == TRIM_MEMORY_RUNNING_CRITICAL) {
Log.w(TAG, "onTrimMemory(level=$level): trimming ASR recognizer pool")
recognizerPool.trimToMostRecentlyUsed()
}
}

private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.example.instantvoicetranslate.asr

import android.util.Log
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Singleton

/**
* Keeps up to [MAX_WARM] ASR recognizers loaded simultaneously, keyed by
* language, so swapping the conversation direction between two languages
* doesn't pay the multi-second model-load cost every time.
*
* [SherpaOnnxRecognizer] has no shared/static native state — each instance
* owns its own native handles — so holding several concurrently is safe;
* this pool exists purely to bound memory by evicting the least-recently
* acquired language once the cap is reached.
*/
@Singleton
class ConversationRecognizerPool @javax.inject.Inject constructor() {

companion object {
private const val TAG = "ConversationRecognizerPool"
private const val MAX_WARM = 2
}

// Insertion order == recency: re-inserting a key on cache hit moves it to
// the end, so the front entry is always the least-recently-used one.
private val pool = LinkedHashMap<String, SherpaOnnxRecognizer>()

// Guards `pool` against concurrent acquire()/reconcile() calls. Without
// this, a swap (which pre-warms the new source directly from
// MainViewModel while the pipeline restart's own acquire() call races it
// from TranslationService) could both see the language missing and each
// build+initialize their own SherpaOnnxRecognizer, with the second
// write silently orphaning the first -- observed as a crashed pipeline
// (JobCancellationException + a subsequent AudioRecord IllegalStateException
// from the colliding native lifecycles).
private val mutex = Mutex()

suspend fun acquire(language: String, modelDir: String): SpeechRecognizer = mutex.withLock {
pool[language]?.let { existing ->
if (existing.isReady.value) {
// Move to most-recently-used position.
pool.remove(language)
pool[language] = existing
return@withLock existing
}
pool.remove(language)
}

if (pool.size >= MAX_WARM) {
val lruLanguage = pool.keys.first()
pool.remove(lruLanguage)?.release()
Log.i(TAG, "Evicted warm recognizer for '$lruLanguage' to make room for '$language'")
}

val recognizer = SherpaOnnxRecognizer()
recognizer.initialize(modelDir, language)
pool[language] = recognizer
recognizer
}

fun isWarm(language: String): Boolean = pool[language]?.isReady?.value == true

/**
* Releases any warm recognizer whose language is not in [desiredLanguages].
*
* Settings can change the active source/target language (via the
* Settings screen or the swap button) without ever releasing whatever
* was warmed under the old settings, since neither of those call sites
* knows what's still relevant. Left unchecked, a stale recognizer from
* before the change stays resident indefinitely alongside the new one --
* on top of NLLB (~1GB in offline mode) this was observed pushing a
* loaded device over its memory watermark and getting the whole process
* killed. Call this right before a translation session actually starts,
* when the true set of languages worth keeping warm is known.
*/
suspend fun reconcile(desiredLanguages: Set<String>) = mutex.withLock {
val stale = pool.keys.filterNot { it in desiredLanguages }
stale.forEach { language ->
pool.remove(language)?.release()
Log.i(TAG, "Released stale warm recognizer for '$language', no longer relevant to current settings")
}
}

/**
* Releases every warm recognizer except the most-recently-used one, in
* response to a system memory-pressure signal (see
* [InstantVoiceTranslateApp.onTrimMemory]). Keeping two ~30-190MB ASR
* models plus NLLB (~1GB) resident can otherwise push a loaded device
* over its memory watermark and get the whole process killed by the
* Android low-memory killer instead of just this pool shrinking back to
* one warm language.
*/
fun trimToMostRecentlyUsed() {
while (pool.size > 1) {
val lruLanguage = pool.keys.first()
pool.remove(lruLanguage)?.release()
Log.i(TAG, "Trimmed warm recognizer for '$lruLanguage' due to memory pressure")
}
}

fun releaseAll() {
pool.values.forEach { it.release() }
pool.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import com.k2fsa.sherpa.onnx.OnlinePunctuation
import com.k2fsa.sherpa.onnx.OnlinePunctuationConfig
import com.k2fsa.sherpa.onnx.OnlinePunctuationModelConfig
import com.k2fsa.sherpa.onnx.OnlineTransducerModelConfig
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
Expand All @@ -26,11 +28,14 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SherpaOnnxRecognizer @Inject constructor() : SpeechRecognizer {
/**
* Not Hilt-managed: instances are created directly (plain constructor) by
* [ConversationRecognizerPool], which can hold more than one warm instance
* at a time (one per conversation direction) — incompatible with a
* `@Singleton` binding.
*/
class SherpaOnnxRecognizer : SpeechRecognizer {

companion object {
private const val TAG = "SherpaOnnxRecognizer"
Expand Down Expand Up @@ -93,6 +98,16 @@ class SherpaOnnxRecognizer @Inject constructor() : SpeechRecognizer {
private var recognitionScope: CoroutineScope? = null
private var recognitionJob: Job? = null

/**
* Set by [pauseAndFlush] just before cancelling, so the recognition
* coroutine's finally block delivers the flushed text here instead of
* emitting it to [recognizedSegments]. That flow is gated on "not
* paused" by its collector -- by the time a normally-emitted segment
* got there, isPaused would already be true, silently dropping it.
*/
@Volatile
private var pendingFlushDeferred: CompletableDeferred<String?>? = null

override suspend fun initialize(modelDir: String, language: String): Unit = withContext(Dispatchers.IO) {
try {
val langConfig = ModelDownloader.getLanguageConfig(language)
Expand Down Expand Up @@ -290,6 +305,34 @@ class SherpaOnnxRecognizer @Inject constructor() : SpeechRecognizer {
} catch (e: Exception) {
Log.e(TAG, "Recognition error", e)
} finally {
// Flush whatever's still buffered so far. Stop is commonly
// pressed right after the user finishes speaking, before
// sustained silence/endpoint detection above ever gets a
// chance to fire -- without this, that last utterance is
// simply discarded instead of being translated. NonCancellable
// because this coroutine's job is normally already cancelling
// by the time we get here (see stopRecognition()), and a plain
// suspend call would otherwise throw immediately.
withContext(NonCancellable) {
var flushed: String? = null
try {
val currentText = rec.getResult(stream).text.trim()
val fullText = combineText(emittedText, currentText)
if (fullText.isNotBlank()) {
flushed = applyPunctuation(fullText)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to flush final segment", e)
}
val flushDeferred = pendingFlushDeferred
if (flushDeferred != null) {
pendingFlushDeferred = null
flushDeferred.complete(flushed)
} else if (flushed != null) {
Log.i(TAG, "Stop: flushing final segment: $flushed")
_recognizedSegments.emit(flushed)
}
}
stream.release()
}
}
Expand Down Expand Up @@ -354,6 +397,18 @@ class SherpaOnnxRecognizer @Inject constructor() : SpeechRecognizer {
_partialText.value = ""
}

override suspend fun pauseAndFlush(): String? {
val job = recognitionJob ?: return null
val deferred = CompletableDeferred<String?>()
pendingFlushDeferred = deferred
recognitionJob = null
job.cancel()
recognitionScope?.cancel()
recognitionScope = null
_partialText.value = ""
return deferred.await()
}

override fun release() {
stopRecognition()
recognizer?.release()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ interface SpeechRecognizer {
/** Stop ongoing recognition. */
fun stopRecognition()

/**
* Stops recognition and returns whatever was still being transcribed as
* a final segment (or null if nothing was pending), WITHOUT emitting it
* to [recognizedSegments]. For use by pause: that flow's collector
* drops anything recognized while paused, which would otherwise
* silently discard this in-flight utterance too.
*/
suspend fun pauseAndFlush(): String? = null

/** Initialize punctuation restoration model (optional, no-op by default). */
fun initializePunctuation(modelDir: String) {}

Expand Down
Loading