voice: one button downloads Kroko and Canary in setup, with progress on Home and in a silent notification

This commit is contained in:
Seb3thehacker committed 2026-10-13 16:56:56 -07:00
1 parent eba8ac1127
commit c029a0894a
4 files changed
+145 -2

No files matched your search

@@ -1,6 +1,15 @@
package app.seb3thehacker.gearslip.speech
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.seb3thehacker.gearslip.GearslipActivity
import app.seb3thehacker.gearslip.GearslipLog
import java.io.File
import java.net.HttpURLConnection
@@ -67,6 +76,11 @@ object SpeechModels {
val files get() = listOf(encoder, decoder, joiner, "tokens.txt").filter { it.isNotEmpty() }
}
/** What setup offers in one tap: Kroko for the live words, Canary for the final ones. */
val RECOMMENDED = listOf(Model.KROKO, Model.CANARY)
fun downloadRecommended(context: Context) = RECOMMENDED.forEach { download(context, it) }
sealed interface State {
data object Missing : State
data class Downloading(val percent: Int) : State
@@ -77,6 +91,8 @@ object SpeechModels {
private const val PREFS = "speech"
private const val KEY_MODEL = "model"
private const val KEY_FINAL = "final_model"
private const val CHANNEL = "voice_download"
private const val NOTIFICATION_ID = 4_312
private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-speech-download") }
private val states = Model.entries.associateWith { MutableStateFlow<State>(State.Missing) }
@@ -106,6 +122,66 @@ object SpeechModels {
fun state(model: Model): StateFlow<State> = states.getValue(model).asStateFlow()
/**
* The models fetched since the last batch finished, so the percent covers all of them and
* doesn't jump back to 0 when the second one starts.
*/
private val batch = mutableSetOf<Model>()
private val _progress = MutableStateFlow<Int?>(null)
/** Percent through the current downloads, for Home and the notification; null when none run. */
val progress: StateFlow<Int?> = _progress.asStateFlow()
private fun updateProgress(context: Context) {
val percent = synchronized(batch) {
if (batch.none { states.getValue(it).value is State.Downloading }) {
batch.clear()
null
} else {
val done = batch.sumOf { m ->
m.downloadMb * when (val st = states.getValue(m).value) {
is State.Downloading -> st.percent
State.Ready -> 100
else -> 0
}
}
done / batch.sumOf { it.downloadMb }
}
}
if (percent == _progress.value) return
_progress.value = percent
showProgress(context, percent)
}
/** A silent notification with a progress bar, so the download is visible with the app closed. */
private fun showProgress(context: Context, percent: Int?) {
val manager = NotificationManagerCompat.from(context)
if (percent == null) {
manager.cancel(NOTIFICATION_ID)
return
}
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Voice download", NotificationManager.IMPORTANCE_LOW),
)
val open = PendingIntent.getActivity(
context, NOTIFICATION_ID,
Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("Downloading voice")
.setContentText("$percent%")
.setProgress(100, percent, false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setContentIntent(open)
.build()
runCatching { manager.notify(NOTIFICATION_ID, notification) }
}
fun dir(context: Context, model: Model) = File(File(context.filesDir, "speech"), model.id)
fun isReady(context: Context, model: Model) = model.files.all { File(dir(context, model), it).isFile }
@@ -128,6 +204,7 @@ object SpeechModels {
cancelled = model
dir(context, model).deleteRecursively()
states.getValue(model).value = State.Missing
updateProgress(context.applicationContext)
if (model == _selected.value || model == _final.value) SpeechEngine.unload()
}
@@ -137,6 +214,8 @@ object SpeechModels {
val state = states.getValue(model)
if (state.value is State.Downloading || state.value == State.Ready) return
state.value = State.Downloading(0)
synchronized(batch) { batch.add(model) }
updateProgress(app)
cancelled = null
worker.execute {
val target = dir(app, model)
@@ -147,6 +226,7 @@ object SpeechModels {
fun progress(read: Long) {
if (cancelled == model) throw InterruptedException("cancelled")
state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99))
updateProgress(app)
}
if (model.plainFiles) {
for (name in model.files) {
@@ -176,6 +256,7 @@ object SpeechModels {
check(partial.renameTo(target)) { "couldn't store the model" }
GearslipLog.i("speech: downloaded ${model.label}")
state.value = State.Ready
updateProgress(app)
} catch (e: Exception) {
partial.deleteRecursively()
if (cancelled == model) {
@@ -184,6 +265,7 @@ object SpeechModels {
GearslipLog.w("speech: download of ${model.label} failed: ${e.message}")
state.value = State.Failed(e.message ?: e.javaClass.simpleName)
}
updateProgress(app)
}
}
}
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.ui
import app.seb3thehacker.gearslip.speech.SpeechModels
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -100,6 +104,7 @@ fun HomeScreen(
)
Spacer(Modifier.height(16.dp))
StatusCard(status)
VoiceDownloadCard()
// The first drive that worked asks once to share it, right under the result.
val car = workedCar
if (car != null && !live) {
@@ -142,6 +147,20 @@ fun HomeScreen(
}
}
/** While the voice models download, how far along they are; nothing otherwise. */
@Composable
private fun VoiceDownloadCard() {
val progress by SpeechModels.progress.collectAsStateWithLifecycle()
val percent = progress ?: return
Spacer(Modifier.height(16.dp))
SettingsCard {
Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Downloading voice, $percent%", style = MaterialTheme.typography.bodyLarge)
LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
private fun Arrow() = Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
@@ -409,7 +409,7 @@ private fun BadgeStep(
private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val context = LocalContext.current
remember { SpeechModels.init(context) }
val needed = listOf(SpeechModels.Model.KROKO, SpeechModels.Model.CANARY)
val needed = SpeechModels.RECOMMENDED
val states = needed.map { SpeechModels.state(it).collectAsState().value }
val ready = states.all { it == SpeechModels.State.Ready }
val label = when {
@@ -423,7 +423,7 @@ private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUn
"Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " +
"into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.",
key,
action = if (ready) null else label to { needed.forEach { SpeechModels.download(context, it) } },
action = if (ready) null else label to { SpeechModels.downloadRecommended(context) },
outlined = true,
extra = { CommandDrift(c, cq, cqSp, key) },
) { MicIcon(c.onPrimaryC) }
@@ -1,5 +1,11 @@
package app.seb3thehacker.gearslip.ui
import app.seb3thehacker.gearslip.speech.SpeechModels
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
@@ -301,6 +307,23 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
),
)
// --- Voice: both recommended models in one tap, skipped once they're on the phone ---
if (!SpeechModels.RECOMMENDED.all { SpeechModels.isReady(context, it) }) {
add(
GuidePage(
title = "Voice.",
explanation = "Ask for directions, music, or a call by voice. Your phone turns speech " +
"into text by itself, and nothing you say leaves it. It needs " +
"${SpeechModels.RECOMMENDED.sumOf { it.downloadMb }} MB, downloaded once. Use Wi-Fi if you can.",
icon = { MicIcon(it) },
optional = true,
actionLabel = "Download voice",
onAction = { SpeechModels.downloadRecommended(context) },
content = { VoiceDownloadStatus() },
),
)
}
// --- Optional: each is skipped once its actual effect is already in place --------
if (!hasProjectMediaOp(context)) {
add(
@@ -364,6 +387,25 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
* Bluetooth or USB for the car's music, as two cards. Bluetooth comes first and is picked for a
* driver who hasn't chosen yet: it never asks for consent, and the map app's voice reaches the car.
*/
/** How the voice download is going: a bar while it runs, a line once it's done or failed. */
@Composable
private fun VoiceDownloadStatus() {
val context = LocalContext.current
val progress by SpeechModels.progress.collectAsState()
val states = SpeechModels.RECOMMENDED.map { SpeechModels.state(it).collectAsState().value }
val line = when {
progress != null -> "Downloading, $progress%. You can carry on; it keeps going."
states.all { it == SpeechModels.State.Ready } -> "Voice is ready."
states.any { it is SpeechModels.State.Failed } -> "The download didn't finish. Tap Download voice to try again."
else -> null
}
LaunchedEffect(Unit) { SpeechModels.init(context) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
progress?.let { LinearProgressIndicator(progress = { it / 100f }, modifier = Modifier.fillMaxWidth()) }
line?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
@Composable
private fun AudioChoice() {
val usb by CarSettings.pipeAudio.collectAsState()