mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
voice: start listening sooner, say so while it gets ready, and scroll the help list from anywhere on it
This commit is contained in:
1 parent
3ca87d1afc
commit
bc8f2372b1
4 files changed
+46
-23
No files matched your search
@@ -53,14 +53,14 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||
val state by CarAssistant.state.collectAsState()
|
||||
if (state.phase == CarAssistant.Phase.IDLE) return
|
||||
val context = LocalContext.current
|
||||
val listening = state.phase == CarAssistant.Phase.LISTENING
|
||||
val listening = state.phase == CarAssistant.Phase.LISTENING && state.ready
|
||||
val glow = MaterialTheme.colorScheme.primary
|
||||
val glow2 = MaterialTheme.colorScheme.tertiary
|
||||
val breathe by rememberInfiniteTransition(label = "glow").animateFloat(
|
||||
0.55f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "glow",
|
||||
)
|
||||
val strength = when (state.phase) {
|
||||
CarAssistant.Phase.LISTENING -> breathe
|
||||
CarAssistant.Phase.LISTENING -> if (state.ready) breathe else 0.3f
|
||||
CarAssistant.Phase.ERROR -> 0.3f
|
||||
else -> 0.6f
|
||||
}
|
||||
@@ -87,7 +87,7 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||
PulsingMic(listening)
|
||||
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val (small, big) = when (state.phase) {
|
||||
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { "Listening…" }
|
||||
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" }
|
||||
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
|
||||
else -> state.heard to state.reply
|
||||
}
|
||||
@@ -96,7 +96,7 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||
}
|
||||
Text(
|
||||
big,
|
||||
color = if (big == "Listening…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
|
||||
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
|
||||
@@ -38,6 +38,8 @@ object CarAssistant {
|
||||
val heard: String = "",
|
||||
val reply: String = "",
|
||||
val missingPermission: Boolean = false,
|
||||
/** False from the press until the microphone is actually open, so the overlay doesn't claim to listen early. */
|
||||
val ready: Boolean = false,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
@@ -89,6 +91,7 @@ object CarAssistant {
|
||||
tts?.stop()
|
||||
_state.value = State(Phase.LISTENING)
|
||||
listenOnce(
|
||||
onListening = { _state.value = _state.value.copy(ready = true) },
|
||||
onPartial = { text -> _state.value = _state.value.copy(heard = text) },
|
||||
onHeard = { _state.value = _state.value.copy(phase = Phase.THINKING) },
|
||||
onResult = { text ->
|
||||
@@ -174,7 +177,7 @@ object CarAssistant {
|
||||
onSpeaking: () -> Unit = {},
|
||||
) {
|
||||
cancel()
|
||||
listenOnce(onPartial, onHeard = {}, onResult, onError, cue, onSpeaking)
|
||||
listenOnce(onPartial, onHeard = {}, onResult = onResult, onError = onError, cue = cue, onSpeaking = onSpeaking)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,6 +186,7 @@ object CarAssistant {
|
||||
*/
|
||||
private fun listenOnce(
|
||||
onPartial: (String) -> Unit,
|
||||
onListening: () -> Unit = {},
|
||||
onHeard: () -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -191,13 +195,14 @@ object CarAssistant {
|
||||
) {
|
||||
val context = appContext ?: return onError("Speech isn't ready yet.")
|
||||
if (CarSettings.systemSpeech.value && SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
return systemListen(context, onPartial, onHeard, onResult, onError)
|
||||
return systemListen(context, onListening, onPartial, onHeard, onResult, onError)
|
||||
}
|
||||
SpeechEngine.listen(
|
||||
context,
|
||||
cue = cue,
|
||||
onSpeaking = onSpeaking,
|
||||
onPartial = onPartial,
|
||||
onListening = onListening,
|
||||
onHeard = onHeard,
|
||||
onResult = onResult,
|
||||
onError = onError,
|
||||
@@ -206,6 +211,7 @@ object CarAssistant {
|
||||
|
||||
private fun systemListen(
|
||||
context: Context,
|
||||
onListening: () -> Unit,
|
||||
onPartial: (String) -> Unit,
|
||||
onHeard: () -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
@@ -214,7 +220,7 @@ object CarAssistant {
|
||||
val r = SpeechRecognizer.createSpeechRecognizer(context)
|
||||
recognizer = r
|
||||
r.setRecognitionListener(object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {}
|
||||
override fun onReadyForSpeech(params: Bundle?) = onListening()
|
||||
override fun onBeginningOfSpeech() {}
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
@@ -76,12 +76,13 @@ fun VoiceHelpOverlay(modifier: Modifier = Modifier) {
|
||||
)
|
||||
GsIconButton(Icons.Filled.Close, "Close", VoiceHelp::hide)
|
||||
}
|
||||
// Full width, so a drag anywhere on the card scrolls, not just one on the words.
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()).padding(top = 8.dp),
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
VoiceHelp.COMMANDS.forEach { (say, does) ->
|
||||
Column {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(say, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(does, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ object SpeechEngine {
|
||||
private const val CUE_TAIL_MS = 250L
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-speech") }
|
||||
/**
|
||||
* Parakeet large takes seconds to load. On [worker], the first press after connecting
|
||||
* waited behind it before listening at all; here it loads while the driver talks.
|
||||
*/
|
||||
private val finalWorker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-speech-final") }
|
||||
private val finalLock = Object()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
private var recognizer: OnlineRecognizer? = null
|
||||
@@ -76,9 +82,10 @@ object SpeechEngine {
|
||||
fun warm(context: Context) {
|
||||
val app = context.applicationContext
|
||||
SpeechModels.init(app)
|
||||
// Kroko first, alone: loading both at once slowed it from a fraction of a second to six.
|
||||
worker.execute {
|
||||
ensureLoaded(app)
|
||||
ensureFinalLoaded(app)
|
||||
finalWorker.execute { ensureFinalLoaded(app) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +96,13 @@ object SpeechEngine {
|
||||
recognizer?.release()
|
||||
recognizer = null
|
||||
loadedModel = null
|
||||
finalRecognizer?.release()
|
||||
finalRecognizer = null
|
||||
loadedFinal = null
|
||||
}
|
||||
finalWorker.execute {
|
||||
synchronized(finalLock) {
|
||||
finalRecognizer?.release()
|
||||
finalRecognizer = null
|
||||
loadedFinal = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +117,7 @@ object SpeechEngine {
|
||||
cue: Boolean = true,
|
||||
onSpeaking: () -> Unit = {},
|
||||
onPartial: (String) -> Unit = {},
|
||||
onListening: () -> Unit = {},
|
||||
onHeard: () -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -125,6 +137,7 @@ object SpeechEngine {
|
||||
val text = try {
|
||||
if (cue) cue()
|
||||
if (id != run) return@execute
|
||||
post(onListening)
|
||||
stream(app, r, id, onSpeaking = { post(onSpeaking) }, onPartial = { post { onPartial(it) } }, onStopped = { post(onHeard) })
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("speech: listening failed", t)
|
||||
@@ -201,17 +214,20 @@ object SpeechEngine {
|
||||
"speech: $audioMs ms of audio, decoding took $decodeMs ms " +
|
||||
"(${"%.2f".format(decodeMs.toFloat() / max(1L, audioMs))} of real time)",
|
||||
)
|
||||
if (last.isEmpty() || id != run) return last
|
||||
val final = ensureFinalLoaded(context) ?: return last
|
||||
if (last.isEmpty() || id != run) { phrase.fill(0f); return last }
|
||||
onStopped()
|
||||
val started = SystemClock.uptimeMillis()
|
||||
val reread = final.createStream().run {
|
||||
try {
|
||||
acceptWaveform(phrase.copyOf(held), RATE)
|
||||
final.decode(this)
|
||||
clean(final.getResult(this).text)
|
||||
} finally {
|
||||
release()
|
||||
// Held for the whole read, so a model swap in settings can't free it mid-decode.
|
||||
val reread = synchronized(finalLock) {
|
||||
val final = ensureFinalLoaded(context) ?: run { phrase.fill(0f); return last }
|
||||
final.createStream().run {
|
||||
try {
|
||||
acceptWaveform(phrase.copyOf(held), RATE)
|
||||
final.decode(this)
|
||||
clean(final.getResult(this).text)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
phrase.fill(0f)
|
||||
@@ -222,7 +238,7 @@ object SpeechEngine {
|
||||
}
|
||||
|
||||
/** The model that rereads the phrase, when one is chosen and downloaded; null otherwise. */
|
||||
private fun ensureFinalLoaded(context: Context): OfflineRecognizer? {
|
||||
private fun ensureFinalLoaded(context: Context): OfflineRecognizer? = synchronized(finalLock) {
|
||||
val model = SpeechModels.final.value ?: return null
|
||||
if (finalRecognizer != null && loadedFinal == model) return finalRecognizer
|
||||
finalRecognizer?.release()
|
||||
|
||||
Reference in new issue
Block a user