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 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.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 app.seb3thehacker.gearslip.GearslipLog
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
@@ -67,6 +76,11 @@ object SpeechModels {
val files get() = listOf(encoder, decoder, joiner, "tokens.txt").filter { it.isNotEmpty() } 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 { sealed interface State {
data object Missing : State data object Missing : State
data class Downloading(val percent: Int) : State data class Downloading(val percent: Int) : State
@@ -77,6 +91,8 @@ object SpeechModels {
private const val PREFS = "speech" private const val PREFS = "speech"
private const val KEY_MODEL = "model" private const val KEY_MODEL = "model"
private const val KEY_FINAL = "final_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 worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-speech-download") }
private val states = Model.entries.associateWith { MutableStateFlow<State>(State.Missing) } 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() 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 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 } fun isReady(context: Context, model: Model) = model.files.all { File(dir(context, model), it).isFile }
@@ -128,6 +204,7 @@ object SpeechModels {
cancelled = model cancelled = model
dir(context, model).deleteRecursively() dir(context, model).deleteRecursively()
states.getValue(model).value = State.Missing states.getValue(model).value = State.Missing
updateProgress(context.applicationContext)
if (model == _selected.value || model == _final.value) SpeechEngine.unload() if (model == _selected.value || model == _final.value) SpeechEngine.unload()
} }
@@ -137,6 +214,8 @@ object SpeechModels {
val state = states.getValue(model) val state = states.getValue(model)
if (state.value is State.Downloading || state.value == State.Ready) return if (state.value is State.Downloading || state.value == State.Ready) return
state.value = State.Downloading(0) state.value = State.Downloading(0)
synchronized(batch) { batch.add(model) }
updateProgress(app)
cancelled = null cancelled = null
worker.execute { worker.execute {
val target = dir(app, model) val target = dir(app, model)
@@ -147,6 +226,7 @@ object SpeechModels {
fun progress(read: Long) { fun progress(read: Long) {
if (cancelled == model) throw InterruptedException("cancelled") if (cancelled == model) throw InterruptedException("cancelled")
state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99)) state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99))
updateProgress(app)
} }
if (model.plainFiles) { if (model.plainFiles) {
for (name in model.files) { for (name in model.files) {
@@ -176,6 +256,7 @@ object SpeechModels {
check(partial.renameTo(target)) { "couldn't store the model" } check(partial.renameTo(target)) { "couldn't store the model" }
GearslipLog.i("speech: downloaded ${model.label}") GearslipLog.i("speech: downloaded ${model.label}")
state.value = State.Ready state.value = State.Ready
updateProgress(app)
} catch (e: Exception) { } catch (e: Exception) {
partial.deleteRecursively() partial.deleteRecursively()
if (cancelled == model) { if (cancelled == model) {
@@ -184,6 +265,7 @@ object SpeechModels {
GearslipLog.w("speech: download of ${model.label} failed: ${e.message}") GearslipLog.w("speech: download of ${model.label} failed: ${e.message}")
state.value = State.Failed(e.message ?: e.javaClass.simpleName) state.value = State.Failed(e.message ?: e.javaClass.simpleName)
} }
updateProgress(app)
} }
} }
} }
@@ -1,5 +1,9 @@
package app.seb3thehacker.gearslip.ui 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -100,6 +104,7 @@ fun HomeScreen(
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
StatusCard(status) StatusCard(status)
VoiceDownloadCard()
// The first drive that worked asks once to share it, right under the result. // The first drive that worked asks once to share it, right under the result.
val car = workedCar val car = workedCar
if (car != null && !live) { 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 @Composable
private fun Arrow() = Icon( private fun Arrow() = Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight, Icons.AutoMirrored.Filled.KeyboardArrowRight,
@@ -409,7 +409,7 @@ private fun BadgeStep(
private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) { private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val context = LocalContext.current val context = LocalContext.current
remember { SpeechModels.init(context) } 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 states = needed.map { SpeechModels.state(it).collectAsState().value }
val ready = states.all { it == SpeechModels.State.Ready } val ready = states.all { it == SpeechModels.State.Ready }
val label = when { 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 " + "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.", "into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.",
key, 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, outlined = true,
extra = { CommandDrift(c, cq, cqSp, key) }, extra = { CommandDrift(c, cq, cqSp, key) },
) { MicIcon(c.onPrimaryC) } ) { MicIcon(c.onPrimaryC) }
@@ -1,5 +1,11 @@
package app.seb3thehacker.gearslip.ui 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.foundation.layout.BoxWithConstraints
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat 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 -------- // --- Optional: each is skipped once its actual effect is already in place --------
if (!hasProjectMediaOp(context)) { if (!hasProjectMediaOp(context)) {
add( 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 * 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. * 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 @Composable
private fun AudioChoice() { private fun AudioChoice() {
val usb by CarSettings.pipeAudio.collectAsState() val usb by CarSettings.pipeAudio.collectAsState()