diff --git a/README.md b/README.md
index 57b4fed..6cf82e1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# FUTO Voice Input Moonshine
-This personal fork keeps the FUTO voice keyboard experience and uses Moonshine v2 Small Streaming as its default offline recognizer. NVIDIA Parakeet TDT 0.6B V3 and the legacy FUTO Whisper/GGML backend remain selectable from Model Options.
+This personal fork keeps the FUTO voice keyboard experience and uses Moonshine v2 Streaming as its default offline recognizer. Choose the Small model for balanced speed and accuracy or Medium for higher accuracy. NVIDIA Parakeet TDT 0.6B V3 and the legacy FUTO Whisper/GGML backend remain selectable from Model Options.
This fork's Parakeet integration and repository changes were built with AI assistance using Codex (GPT-5).
@@ -8,7 +8,8 @@ The goal is straightforward: keep the FUTO UI and recording flow while adding re
## What Changed
-- Moonshine v2 Small Streaming is the default backend and emits live partial transcripts.
+- Moonshine v2 Small Streaming is the default balanced option and emits live partial transcripts.
+- Moonshine v2 Medium Streaming is available as a higher-accuracy option.
- Parakeet and legacy Whisper/GGML remain selectable backends.
- Batch and streaming recognizers share backend-neutral Kotlin contracts.
- Personal vocabulary entries correct partial and final transcripts; use `heard => preferred` for explicit aliases.
@@ -25,16 +26,17 @@ Moonshine is selected by default, with Parakeet and Whisper/GGML available as fa
## Active Model
-The default backend is:
+The default backend and model are:
```text
Moonshine v2 Small Streaming English
```
-The app downloads the quantized model assets from:
+The app downloads the selected quantized model assets from:
```text
https://download.moonshine.ai/model/small-streaming-en/quantized/
+https://download.moonshine.ai/model/medium-streaming-en/quantized/
```
Model files are downloaded on first use rather than packaged into the APK.
@@ -43,7 +45,7 @@ After installing the APK, the model is downloaded by the app:
1. Open FUTO Voice Input Moonshine Settings.
2. Tap **Model**.
-3. Select **Moonshine v2 Small Streaming**.
+3. Select **Balanced** (Small) or **Higher accuracy** (Medium).
4. Confirm the download.
If you try voice input before downloading the model, the app prompts for the download. Transcription runs offline after installation.
@@ -52,6 +54,7 @@ Downloaded model files are stored in app-private storage:
```text
filesDir/moonshine-small-streaming-en/
+filesDir/moonshine-medium-streaming-en/
```
Parakeet and Whisper/GGML models are also stored in app-private storage and run offline after download.
diff --git a/app/src/main/java/org/futo/voiceinput/AudioRecognizer.kt b/app/src/main/java/org/futo/voiceinput/AudioRecognizer.kt
index 269e2fb..24ac10d 100644
--- a/app/src/main/java/org/futo/voiceinput/AudioRecognizer.kt
+++ b/app/src/main/java/org/futo/voiceinput/AudioRecognizer.kt
@@ -28,6 +28,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.futo.voiceinput.ml.RunState
import org.futo.voiceinput.moonshine.MoonshineBackend
+import org.futo.voiceinput.moonshine.getSelectedMoonshineModelVariant
import org.futo.voiceinput.moonshine.isMoonshineModelDownloaded
import org.futo.voiceinput.settings.ENABLE_30S_LIMIT
import org.futo.voiceinput.settings.IS_VAD_ENABLED
@@ -252,7 +253,8 @@ abstract class AudioRecognizer {
SpeechBackendType.Parakeet -> ParakeetEngineManager.acquire(context)
SpeechBackendType.Moonshine -> {
ParakeetEngineManager.forceClose()
- MoonshineBackend().also { it.load(context) }
+ val variant = context.getSelectedMoonshineModelVariant()
+ MoonshineBackend(variant).also { it.load(context) }
}
SpeechBackendType.WhisperGGML -> {
ParakeetEngineManager.forceClose()
@@ -318,7 +320,8 @@ abstract class AudioRecognizer {
}
}
SpeechBackendType.Moonshine -> {
- if (!context.isMoonshineModelDownloaded()) {
+ val variant = context.getSelectedMoonshineModelVariant()
+ if (!context.isMoonshineModelDownloaded(variant)) {
needMoonshineModelDownload()
return@launch
}
diff --git a/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineBackend.kt b/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineBackend.kt
index 9240b03..f474426 100644
--- a/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineBackend.kt
+++ b/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineBackend.kt
@@ -16,7 +16,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.futo.voiceinput.backend.StreamingSpeechBackend
-class MoonshineBackend : StreamingSpeechBackend {
+class MoonshineBackend(
+ private val variant: MoonshineModelVariant
+) : StreamingSpeechBackend {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var transcriber: Transcriber? = null
@@ -28,8 +30,11 @@ class MoonshineBackend : StreamingSpeechBackend {
override suspend fun load(context: Context) = withContext(Dispatchers.IO) {
transcriber = Transcriber().apply {
loadFromFiles(
- context.applicationContext.moonshineModelDir().absolutePath,
- JNI.MOONSHINE_MODEL_ARCH_SMALL_STREAMING
+ context.applicationContext.moonshineModelDir(variant).absolutePath,
+ when (variant) {
+ MoonshineModelVariant.Small -> JNI.MOONSHINE_MODEL_ARCH_SMALL_STREAMING
+ MoonshineModelVariant.Medium -> JNI.MOONSHINE_MODEL_ARCH_MEDIUM_STREAMING
+ }
)
}
}
diff --git a/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineModel.kt b/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineModel.kt
index 3629486..8b1c9d3 100644
--- a/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineModel.kt
+++ b/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineModel.kt
@@ -8,13 +8,13 @@ import org.futo.voiceinput.downloader.EXTRA_DOWNLOAD_FILE_HASHES
import org.futo.voiceinput.downloader.EXTRA_DOWNLOAD_FILE_NAMES
import org.futo.voiceinput.downloader.EXTRA_DOWNLOAD_FILE_URLS
import org.futo.voiceinput.downloader.EXTRA_TARGET_SUBDIR
+import org.futo.voiceinput.settings.MOONSHINE_MODEL_VARIANT
+import org.futo.voiceinput.settings.getSetting
+import org.futo.voiceinput.settings.getSettingBlocking
import java.io.File
object MoonshineModel {
- const val directoryName = "moonshine-small-streaming-en"
const val completionMarker = ".download_complete"
- private const val baseUrl =
- "https://download.moonshine.ai/model/small-streaming-en/quantized"
val files = listOf(
"adapter.ort",
@@ -27,20 +27,32 @@ object MoonshineModel {
"tokenizer.bin"
)
- fun url(file: String) = "$baseUrl/$file"
}
-fun Context.moonshineModelDir(): File = File(filesDir, MoonshineModel.directoryName)
+fun Context.moonshineModelDir(variant: MoonshineModelVariant): File =
+ File(filesDir, variant.directoryName)
-fun Context.isMoonshineModelDownloaded(): Boolean {
- val directory = moonshineModelDir()
+fun Context.isMoonshineModelDownloaded(variant: MoonshineModelVariant): Boolean {
+ val directory = moonshineModelDir(variant)
return File(directory, MoonshineModel.completionMarker).exists() &&
MoonshineModel.files.all { File(directory, it).exists() }
}
-fun Context.moonshineModelDownloadIntent(): Intent =
+private fun Context.selectedMoonshineModelVariant() =
+ getSettingBlocking(MOONSHINE_MODEL_VARIANT.key, MOONSHINE_MODEL_VARIANT.default)
+ .toMoonshineModelVariant()
+
+suspend fun Context.getSelectedMoonshineModelVariant() =
+ getSetting(MOONSHINE_MODEL_VARIANT).toMoonshineModelVariant()
+
+fun Context.isMoonshineModelDownloaded(): Boolean =
+ isMoonshineModelDownloaded(selectedMoonshineModelVariant())
+
+fun Context.moonshineModelDownloadIntent(
+ variant: MoonshineModelVariant = selectedMoonshineModelVariant()
+): Intent =
Intent(this, DownloadActivity::class.java).apply {
- putExtra(EXTRA_TARGET_SUBDIR, MoonshineModel.directoryName)
+ putExtra(EXTRA_TARGET_SUBDIR, variant.directoryName)
putExtra(EXTRA_COMPLETION_MARKER, MoonshineModel.completionMarker)
putStringArrayListExtra(
EXTRA_DOWNLOAD_FILE_NAMES,
@@ -48,7 +60,7 @@ fun Context.moonshineModelDownloadIntent(): Intent =
)
putStringArrayListExtra(
EXTRA_DOWNLOAD_FILE_URLS,
- ArrayList(MoonshineModel.files.map(MoonshineModel::url))
+ ArrayList(MoonshineModel.files.map { "${variant.baseUrl}/$it" })
)
putStringArrayListExtra(
EXTRA_DOWNLOAD_FILE_HASHES,
@@ -56,6 +68,8 @@ fun Context.moonshineModelDownloadIntent(): Intent =
)
}
-fun Context.startMoonshineModelDownloadActivity() {
- startActivity(moonshineModelDownloadIntent())
+fun Context.startMoonshineModelDownloadActivity(
+ variant: MoonshineModelVariant = selectedMoonshineModelVariant()
+) {
+ startActivity(moonshineModelDownloadIntent(variant))
}
diff --git a/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineModelVariant.kt b/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineModelVariant.kt
new file mode 100644
index 0000000..7edf3f6
--- /dev/null
+++ b/app/src/main/java/org/futo/voiceinput/moonshine/MoonshineModelVariant.kt
@@ -0,0 +1,21 @@
+package org.futo.voiceinput.moonshine
+
+enum class MoonshineModelVariant(
+ val id: String,
+ val directoryName: String,
+ val baseUrl: String
+) {
+ Small(
+ id = "small",
+ directoryName = "moonshine-small-streaming-en",
+ baseUrl = "https://download.moonshine.ai/model/small-streaming-en/quantized"
+ ),
+ Medium(
+ id = "medium",
+ directoryName = "moonshine-medium-streaming-en",
+ baseUrl = "https://download.moonshine.ai/model/medium-streaming-en/quantized"
+ )
+}
+
+fun String.toMoonshineModelVariant(): MoonshineModelVariant =
+ MoonshineModelVariant.entries.firstOrNull { it.id == this } ?: MoonshineModelVariant.Small
diff --git a/app/src/main/java/org/futo/voiceinput/settings/Settings.kt b/app/src/main/java/org/futo/voiceinput/settings/Settings.kt
index 043481e..c77f3e3 100644
--- a/app/src/main/java/org/futo/voiceinput/settings/Settings.kt
+++ b/app/src/main/java/org/futo/voiceinput/settings/Settings.kt
@@ -23,6 +23,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.futo.voiceinput.BuildConfig
+import org.futo.voiceinput.moonshine.MoonshineModelVariant
import org.futo.voiceinput.theme.presets.DevThemeYellow
import org.futo.voiceinput.theme.presets.VoiceInputTheme
@@ -126,6 +127,8 @@ fun String.toSpeechBackendType(): SpeechBackendType {
}
val SPEECH_BACKEND = SettingsKey(stringPreferencesKey("speech_backend"), SpeechBackendType.Moonshine.id)
+val MOONSHINE_MODEL_VARIANT =
+ SettingsKey(stringPreferencesKey("moonshine_model_variant"), MoonshineModelVariant.Small.id)
val PARAKEET_KEEP_WARM = SettingsKey(booleanPreferencesKey("parakeet_keep_warm"), true)
val PARAKEET_KEEP_WARM_TIMEOUT_MS =
SettingsKey(longPreferencesKey("parakeet_keep_warm_timeout_ms"), 5 * 60 * 1000L)
diff --git a/app/src/main/java/org/futo/voiceinput/settings/pages/Models.kt b/app/src/main/java/org/futo/voiceinput/settings/pages/Models.kt
index 1500940..9b319d4 100644
--- a/app/src/main/java/org/futo/voiceinput/settings/pages/Models.kt
+++ b/app/src/main/java/org/futo/voiceinput/settings/pages/Models.kt
@@ -34,13 +34,16 @@ import org.futo.voiceinput.migration.NeedsMigration
import org.futo.voiceinput.parakeet.isParakeetModelDownloaded
import org.futo.voiceinput.parakeet.startParakeetModelDownloadActivity
import org.futo.voiceinput.moonshine.isMoonshineModelDownloaded
+import org.futo.voiceinput.moonshine.MoonshineModelVariant
import org.futo.voiceinput.moonshine.startMoonshineModelDownloadActivity
+import org.futo.voiceinput.moonshine.toMoonshineModelVariant
import org.futo.voiceinput.settings.DISMISS_MIGRATION_TIP
import org.futo.voiceinput.settings.ENABLE_MULTILINGUAL
import org.futo.voiceinput.settings.ENGLISH_MODEL_INDEX
import org.futo.voiceinput.settings.LANGUAGE_TOGGLES
import org.futo.voiceinput.settings.MANUALLY_SELECT_LANGUAGE
import org.futo.voiceinput.settings.MODELS_MIGRATED
+import org.futo.voiceinput.settings.MOONSHINE_MODEL_VARIANT
import org.futo.voiceinput.settings.MULTILINGUAL_MODEL_INDEX
import org.futo.voiceinput.settings.PERSONAL_DICTIONARY
import org.futo.voiceinput.settings.SPEECH_BACKEND
@@ -63,6 +66,7 @@ import org.futo.voiceinput.startModelDownloadActivity
fun modelsSubtitle(): String? {
val context = LocalContext.current
val (backend, _) = useDataStore(SPEECH_BACKEND)
+ val (moonshineVariantId, _) = useDataStore(MOONSHINE_MODEL_VARIANT)
return when (backend.toSpeechBackendType()) {
SpeechBackendType.Parakeet -> {
if (context.isParakeetModelDownloaded(verifyHashes = true)) {
@@ -72,7 +76,7 @@ fun modelsSubtitle(): String? {
}
}
SpeechBackendType.Moonshine -> {
- if (context.isMoonshineModelDownloaded()) {
+ if (context.isMoonshineModelDownloaded(moonshineVariantId.toMoonshineModelVariant())) {
stringResource(R.string.moonshine_model_active_subtitle)
} else {
stringResource(R.string.moonshine_model_download_required)
@@ -155,12 +159,19 @@ fun ParakeetModelStatus() {
fun MoonshineModelStatus() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
- val isDownloaded = remember { mutableStateOf(context.isMoonshineModelDownloaded()) }
+ val modelVariant = useDataStore(MOONSHINE_MODEL_VARIANT)
+ val downloadedVariants = remember(context) {
+ mutableStateOf(
+ MoonshineModelVariant.entries.filter { context.isMoonshineModelDownloaded(it) }.toSet()
+ )
+ }
- DisposableEffect(lifecycleOwner) {
+ DisposableEffect(lifecycleOwner, context) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
- isDownloaded.value = context.isMoonshineModelDownloaded()
+ downloadedVariants.value = MoonshineModelVariant.entries
+ .filter { context.isMoonshineModelDownloaded(it) }
+ .toSet()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -168,21 +179,36 @@ fun MoonshineModelStatus() {
}
ScreenTitle(stringResource(R.string.moonshine_model))
- SettingItem(
- title = stringResource(R.string.moonshine_streaming_model_name),
- subtitle = if (isDownloaded.value) {
- stringResource(R.string.moonshine_model_downloaded)
- } else {
- stringResource(R.string.moonshine_model_download_required)
- },
- onClick = {
- if (!isDownloaded.value) context.startMoonshineModelDownloadActivity()
- },
- icon = {
- RadioButton(selected = isDownloaded.value, onClick = null, enabled = false)
- },
- disabled = false
- ) { }
+ MoonshineModelVariant.entries.forEach { variant ->
+ val selected = modelVariant.value.toMoonshineModelVariant() == variant
+ val downloaded = variant in downloadedVariants.value
+ val (titleResource, descriptionResource) = when (variant) {
+ MoonshineModelVariant.Small ->
+ R.string.moonshine_balanced to R.string.moonshine_small_description
+ MoonshineModelVariant.Medium ->
+ R.string.moonshine_higher_accuracy to R.string.moonshine_medium_description
+ }
+ val title = stringResource(titleResource)
+ val description = stringResource(descriptionResource)
+ val status = stringResource(
+ if (downloaded) R.string.moonshine_model_downloaded
+ else R.string.moonshine_model_download_required
+ )
+ val selectOrDownload = {
+ modelVariant.setValue(variant.id)
+ if (!downloaded) context.startMoonshineModelDownloadActivity(variant)
+ }
+
+ SettingItem(
+ title = title,
+ subtitle = stringResource(R.string.moonshine_model_option_subtitle, description, status),
+ onClick = selectOrDownload,
+ icon = {
+ RadioButton(selected = selected, onClick = selectOrDownload)
+ },
+ disabled = false
+ ) { }
+ }
Tip(stringResource(R.string.moonshine_download_model_tip))
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ee820c5..bd4f4d9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -115,10 +115,14 @@
The selected Whisper model must be downloaded before voice input can run. This may incur data fees on mobile data.
Whisper/GGML model active
Moonshine Model
- Moonshine v2 Small Streaming active
- Moonshine v2 Small Streaming English
+ Moonshine v2 Streaming active
+ Balanced
+ Higher accuracy
+ Moonshine v2 Small Streaming English (~245 MB)
+ Moonshine v2 Medium Streaming English (~429 MB)
+ %1$s — %2$s
Download required
- Downloaded and active
+ Downloaded
Low-latency English transcription that updates while you speak. The model remains offline after download.
The Moonshine streaming model must be downloaded before voice input can run. This may incur data fees on mobile data.
You\'ve been using FUTO Voice Input Moonshine for %d days. If you find this app useful, please consider paying to support future development of FUTO software.
diff --git a/app/src/test/java/org/futo/voiceinput/moonshine/MoonshineModelVariantTest.kt b/app/src/test/java/org/futo/voiceinput/moonshine/MoonshineModelVariantTest.kt
new file mode 100644
index 0000000..e3752f7
--- /dev/null
+++ b/app/src/test/java/org/futo/voiceinput/moonshine/MoonshineModelVariantTest.kt
@@ -0,0 +1,30 @@
+package org.futo.voiceinput.moonshine
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class MoonshineModelVariantTest {
+ @Test
+ fun persistedIdsSelectTheExpectedQuality() {
+ assertEquals(MoonshineModelVariant.Small, "small".toMoonshineModelVariant())
+ assertEquals(MoonshineModelVariant.Medium, "medium".toMoonshineModelVariant())
+ }
+
+ @Test
+ fun unknownIdsFallBackToBalanced() {
+ assertEquals(MoonshineModelVariant.Small, "future-model".toMoonshineModelVariant())
+ }
+
+ @Test
+ fun variantsUseSeparateDownloads() {
+ assertNotEquals(
+ MoonshineModelVariant.Small.directoryName,
+ MoonshineModelVariant.Medium.directoryName
+ )
+ assertNotEquals(
+ MoonshineModelVariant.Small.baseUrl,
+ MoonshineModelVariant.Medium.baseUrl
+ )
+ }
+}
diff --git a/docs/research/streaming-asr-android-options.md b/docs/research/streaming-asr-android-options.md
new file mode 100644
index 0000000..2c833bc
--- /dev/null
+++ b/docs/research/streaming-asr-android-options.md
@@ -0,0 +1,61 @@
+# Offline Streaming ASR Options for Android
+
+Research date: 2026-07-15
+
+## Recommendation
+
+Add **Moonshine Medium Streaming** as an accuracy-focused option and retain Small Streaming as the balanced option. It is the lowest-risk improvement because the app's existing `moonshine-voice` 0.0.68 Android library already exposes `MOONSHINE_MODEL_ARCH_MEDIUM_STREAMING`; only model selection, download metadata, and settings UI need to change.
+
+If Medium still performs poorly on the user's real speech, prototype **NVIDIA Nemotron ASR Streaming 0.6B through sherpa-onnx** as an experimental backend. Do not replace Moonshine with it without an on-device A/B test.
+
+## Comparison
+
+| Model | True streaming | Published accuracy | Download / footprint | Android practicality | Verdict |
+|---|---:|---|---|---|---|
+| Moonshine Small Streaming (current) | Yes | 7.84% OpenASR average; shipped INT8 3.03% LibriSpeech clean | About 245 MB | Already integrated | Balanced baseline |
+| Moonshine Medium Streaming | Yes | 6.65% OpenASR average; shipped INT8 2.37% LibriSpeech clean | 428.6 MiB across official quantized ORT files | Same Android SDK and backend | Best next step |
+| NVIDIA Nemotron ASR Streaming 0.6B | Yes, cache-aware RNNT | 7.07% OpenASR average at 560 ms; 6.93% at 1120 ms | About 632 MB for sherpa-onnx INT8 | Official sherpa-onnx Android APK/model exists; new runtime integration | Best experimental alternative |
+| NVIDIA Parakeet Realtime EOU 120M | Yes | 9.30% OpenASR average at 160 ms | Roughly 120M parameters | NeMo-first; no punctuation/capitalization | Faster endpointing, not an accuracy upgrade |
+| sherpa-onnx streaming Zipformer English | Yes | Competitive LibriSpeech results, but older/narrower evaluation | Around 181 MB INT8 for the 2023 English model | Mature Android/Kotlin support | Fast and small, not a demonstrated robustness upgrade |
+| Vosk small English | Yes | 9.85% LibriSpeech clean | 40 MB; about 300 MB runtime RAM | Mature Android support | Clearly less accurate |
+| Whisper / faster-whisper / whisper.cpp | Buffered or chunked pseudo-streaming | Strong offline models at larger sizes | Varies | Final re-decode is possible, but true low-latency streaming is not its strength | Use only as a second-pass finalizer |
+
+## Why Medium Streaming
+
+Moonshine reports a reduction from 7.84% to 6.65% average WER across the eight OpenASR datasets, about a 15% relative error reduction. On the shipped quantized models' LibriSpeech-clean test, Medium scores 2.37% versus Small's 3.03%, about a 22% relative reduction. The official CPU response-latency comparison reports 107 ms versus 73 ms on a MacBook Pro and 802 ms versus 527 ms on Raspberry Pi 5. These are endpoint-response measurements rather than Android end-to-end latency, so a real phone test is still required.
+
+Medium uses the same streaming architecture and Android library already in the app. The official quantized files total 428.6 MiB, versus roughly 245 MB for Small. The likely tradeoff is approximately twice the model compute and substantially more RAM, but the integration risk is much lower than adding another inference runtime.
+
+Sources:
+
+- [Moonshine Voice benchmarks, quantized accuracy, Android support, and licensing](https://github.com/moonshine-ai/moonshine)
+- [Moonshine Streaming Medium model card](https://huggingface.co/UsefulSensors/moonshine-streaming-medium)
+- [Moonshine v2 paper](https://download.moonshine.ai/docs/moonshine_streaming_paper.pdf)
+
+## Nemotron as the challenger
+
+NVIDIA's 600M-parameter model is genuinely streaming: its cache-aware FastConformer-RNNT processes non-overlapping chunks and supports 80, 160, 560, and 1120 ms operating points. It includes punctuation and capitalization. NVIDIA reports 7.07% average WER at 560 ms and 6.93% at 1120 ms.
+
+Sherpa-onnx provides an INT8 conversion, Kotlin/Java Android support, and a prebuilt arm64 Android APK. Its 560 ms model contains a 623 MB encoder plus small decoder/joiner files; the documented example has RTF 0.16 on the test host, but that is not an Android phone benchmark. This makes Nemotron credible, but larger and riskier than Moonshine Medium.
+
+Sources:
+
+- [NVIDIA Nemotron ASR Streaming model card and WER tables](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b)
+- [Sherpa-onnx Nemotron streaming ONNX and Android documentation](https://k2-fsa.github.io/sherpa/onnx/nemo/nemotron-streaming.html)
+- [Sherpa-onnx platform support](https://github.com/k2-fsa/sherpa-onnx)
+
+## Other options
+
+Parakeet Realtime EOU 120M is optimized for low-latency endpoint detection, but its published 9.30% average WER is worse than Moonshine Small's published 7.84%, and it omits punctuation and capitalization. Vosk's Android-sized English model reports 9.85% on LibriSpeech clean, far behind Moonshine Small's shipped 3.03% on that dataset. Picovoice Cheetah is a polished proprietary Android streaming SDK with real vocabulary boosting, but requires an account/access key and does not provide enough directly comparable current benchmark data to justify replacing an open offline backend.
+
+Sources:
+
+- [NVIDIA Parakeet Realtime EOU 120M model card](https://huggingface.co/nvidia/parakeet_realtime_eou_120m-v1)
+- [Vosk model sizes and WER](https://alphacephei.com/vosk/models)
+- [Picovoice Cheetah Android documentation](https://picovoice.ai/docs/quick-start/cheetah-android/)
+
+## Proposed evaluation
+
+Ship Small and Medium as selectable Moonshine quality levels. Record a small private test set on the target phone containing the phrases that currently fail, then calculate exact substitutions/deletions/insertions for both models. Only invest in Nemotron/sherpa-onnx if Medium does not materially improve those samples.
+
+An optional two-pass mode can preserve live Moonshine text while replacing the final result with Parakeet after recording stops. That may produce the best final accuracy, but it is not true single-model streaming and adds finalization delay.