mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
voice: play songs by name, a help list, and the phone's speech recognition as an option; Spotify connects without the wait
This commit is contained in:
1 parent
7163598352
commit
af9e9a7571
8 files changed
+231
-11
No files matched your search
@@ -17,9 +17,15 @@ import kotlinx.coroutines.withContext
|
|||||||
object AssistantCommands {
|
object AssistantCommands {
|
||||||
|
|
||||||
suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String {
|
suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String {
|
||||||
val heard = heardRaw.lowercase().trim()
|
val heard = heardRaw.lowercase().trim().trimEnd('.', '?', '!')
|
||||||
|
VoiceHelp.hide()
|
||||||
|
if (heard in HELP_WORDS || heard.startsWith("what can i say") || heard.startsWith("what can you do")) {
|
||||||
|
VoiceHelp.show()
|
||||||
|
return "Here's what you can say."
|
||||||
|
}
|
||||||
|
|
||||||
navigateCommand(context, navigator, frame, heard)?.let { return it }
|
navigateCommand(context, navigator, frame, heard)?.let { return it }
|
||||||
|
playSearchCommand(heard)?.let { return it }
|
||||||
mediaCommand(heard)?.let { return it }
|
mediaCommand(heard)?.let { return it }
|
||||||
navCommand(navigator, heard)?.let { return it }
|
navCommand(navigator, heard)?.let { return it }
|
||||||
infoCommand(heard)?.let { return it }
|
infoCommand(heard)?.let { return it }
|
||||||
@@ -75,6 +81,24 @@ object AssistantCommands {
|
|||||||
return "Navigating to $destination with ${chosen.label}."
|
return "Navigating to $destination with ${chosen.label}."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "play X" with something after it: a song, artist, album or playlist, asked of the music app
|
||||||
|
* that's open through its own search ([CarMedia.search]). A bare "play", or "play music",
|
||||||
|
* falls through to resume.
|
||||||
|
*/
|
||||||
|
private fun playSearchCommand(heard: String): String? {
|
||||||
|
if (!heard.startsWith("play ")) return null
|
||||||
|
val query = heard.removePrefix("play ").trim().removeSuffix(".").trim()
|
||||||
|
if (query.isEmpty() || query in RESUME_WORDS) return null
|
||||||
|
val media = CarServices.media
|
||||||
|
val app = media.appLabel ?: return "Open a music app first, then ask again."
|
||||||
|
if (!media.canSearchLibrary && !media.now.value.canSearch) {
|
||||||
|
return "$app ignores voice searches. Pick something on its screen instead."
|
||||||
|
}
|
||||||
|
media.playBestMatch(query)
|
||||||
|
return "Looking for $query on $app."
|
||||||
|
}
|
||||||
|
|
||||||
private fun mediaCommand(heard: String): String? {
|
private fun mediaCommand(heard: String): String? {
|
||||||
val media = CarServices.media
|
val media = CarServices.media
|
||||||
return when {
|
return when {
|
||||||
@@ -179,4 +203,8 @@ object AssistantCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val NAVIGATE_TRIGGERS = listOf("navigate to ", "directions to ", "take me to ", "drive to ")
|
private val NAVIGATE_TRIGGERS = listOf("navigate to ", "directions to ", "take me to ", "drive to ")
|
||||||
|
|
||||||
|
private val HELP_WORDS = setOf("help", "commands", "voice commands", "list commands", "show commands")
|
||||||
|
|
||||||
|
private val RESUME_WORDS = setOf("music", "the music", "something", "it", "again", "that")
|
||||||
}
|
}
|
||||||
@@ -190,6 +190,9 @@ object CarAssistant {
|
|||||||
onSpeaking: () -> Unit = {},
|
onSpeaking: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = appContext ?: return onError("Speech isn't ready yet.")
|
val context = appContext ?: return onError("Speech isn't ready yet.")
|
||||||
|
if (CarSettings.systemSpeech.value && SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||||
|
return systemListen(context, onPartial, onHeard, onResult, onError)
|
||||||
|
}
|
||||||
SpeechEngine.listen(
|
SpeechEngine.listen(
|
||||||
context,
|
context,
|
||||||
cue = cue,
|
cue = cue,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ object CarSettings {
|
|||||||
private const val KEY_APP_ORDER = "car_app_order"
|
private const val KEY_APP_ORDER = "car_app_order"
|
||||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||||
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
||||||
|
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
||||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||||
private const val KEY_MEDIA_SEARCH = "car_media_search_enabled"
|
private const val KEY_MEDIA_SEARCH = "car_media_search_enabled"
|
||||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||||
@@ -73,6 +74,7 @@ object CarSettings {
|
|||||||
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||||
private val voiceAssistantFlow = MutableStateFlow(false)
|
private val voiceAssistantFlow = MutableStateFlow(false)
|
||||||
|
private val systemSpeechFlow = MutableStateFlow(false)
|
||||||
private val carSensorsFlow = MutableStateFlow(false)
|
private val carSensorsFlow = MutableStateFlow(false)
|
||||||
private val mediaSearchFlow = MutableStateFlow(false)
|
private val mediaSearchFlow = MutableStateFlow(false)
|
||||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||||
@@ -177,6 +179,9 @@ object CarSettings {
|
|||||||
*/
|
*/
|
||||||
val voiceAssistantEnabled: StateFlow<Boolean> = voiceAssistantFlow
|
val voiceAssistantEnabled: StateFlow<Boolean> = voiceAssistantFlow
|
||||||
|
|
||||||
|
/** Listen with the phone's own speech service instead of Gearslip's on-device model. */
|
||||||
|
val systemSpeech: StateFlow<Boolean> = systemSpeechFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vehicle data is experimental and off by default: the launcher's tile for it, and the
|
* Vehicle data is experimental and off by default: the launcher's tile for it, and the
|
||||||
* voice assistant's "vehicle data" command, only show up once this and
|
* voice assistant's "vehicle data" command, only show up once this and
|
||||||
@@ -227,6 +232,7 @@ object CarSettings {
|
|||||||
.split(",").filter { it.isNotEmpty() }
|
.split(",").filter { it.isNotEmpty() }
|
||||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||||
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
||||||
|
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
||||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||||
mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "false") == "true"
|
mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "false") == "true"
|
||||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||||
@@ -293,6 +299,11 @@ object CarSettings {
|
|||||||
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
|
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSystemSpeech(on: Boolean) {
|
||||||
|
systemSpeechFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
fun setVoiceAssistantEnabled(on: Boolean) {
|
fun setVoiceAssistantEnabled(on: Boolean) {
|
||||||
voiceAssistantFlow.value = on
|
voiceAssistantFlow.value = on
|
||||||
AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString())
|
AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString())
|
||||||
|
|||||||
@@ -220,6 +220,18 @@ private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolea
|
|||||||
checked = voiceAssistant,
|
checked = voiceAssistant,
|
||||||
) { CarSettings.setVoiceAssistantEnabled(it) }
|
) { CarSettings.setVoiceAssistantEnabled(it) }
|
||||||
|
|
||||||
|
if (voiceAssistant) {
|
||||||
|
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||||
|
ExperimentalFeatureRow(
|
||||||
|
title = "Use the phone's speech recognition",
|
||||||
|
description = "Understands more words, like place and brand names, but listens " +
|
||||||
|
"through the phone's microphone, not the car's. On most phones this is Google's, " +
|
||||||
|
"which may send your voice to Google. GrapheneOS has none, so it stays with " +
|
||||||
|
"Gearslip's own.",
|
||||||
|
checked = systemSpeech,
|
||||||
|
) { CarSettings.setSystemSpeech(it) }
|
||||||
|
}
|
||||||
|
|
||||||
ExperimentalFeatureRow(
|
ExperimentalFeatureRow(
|
||||||
title = "Vehicle data",
|
title = "Vehicle data",
|
||||||
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
|
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ fun CarUi() {
|
|||||||
modifier = Modifier.align(Alignment.TopEnd),
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
)
|
)
|
||||||
// Above the popup: a call is more urgent than any notification.
|
// Above the popup: a call is more urgent than any notification.
|
||||||
|
VoiceHelpOverlay(Modifier.align(Alignment.Center))
|
||||||
CallOverlay(Modifier.align(Alignment.TopCenter))
|
CallOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
AssistantOverlay(Modifier.align(Alignment.TopCenter))
|
AssistantOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/** What the assistant understands, shown on the car screen when the driver says "help". */
|
||||||
|
object VoiceHelp {
|
||||||
|
private val _shown = MutableStateFlow(false)
|
||||||
|
val shown: StateFlow<Boolean> = _shown.asStateFlow()
|
||||||
|
|
||||||
|
fun show() { _shown.value = true }
|
||||||
|
fun hide() { _shown.value = false }
|
||||||
|
|
||||||
|
/** Say this, and this happens. Kept in step with [AssistantCommands]. */
|
||||||
|
val COMMANDS = listOf(
|
||||||
|
"Navigate to…" to "Directions there. Add \"with Organic Maps\" to pick the app",
|
||||||
|
"Play…" to "A song, artist, or album in the open music app",
|
||||||
|
"Pause · Resume · Next · Previous" to "Control the music",
|
||||||
|
"What's playing" to "The song and artist",
|
||||||
|
"Call…" to "A contact by name",
|
||||||
|
"Open…" to "Any app on this screen",
|
||||||
|
"Go home · Open apps · Settings" to "Move around Gearslip",
|
||||||
|
"What time is it · Weather" to "The time, or the forecast",
|
||||||
|
"Help" to "This list",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The command list over the car screen: read it at a glance, tap the cross or wait to dismiss. */
|
||||||
|
@Composable
|
||||||
|
fun VoiceHelpOverlay(modifier: Modifier = Modifier) {
|
||||||
|
val shown by VoiceHelp.shown.collectAsState()
|
||||||
|
if (!shown) return
|
||||||
|
// A list left up would cover the map for the rest of the drive.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(30_000)
|
||||||
|
VoiceHelp.hide()
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
modifier = modifier.padding(16.dp).widthIn(max = 640.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
"You can say",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f).padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
GsIconButton(Icons.Filled.Close, "Close", VoiceHelp::hide)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.verticalScroll(rememberScrollState()).padding(top = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
VoiceHelp.COMMANDS.forEach { (say, does) ->
|
||||||
|
Column {
|
||||||
|
Text(say, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(does, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,9 @@ class CarMedia(private val context: Context) {
|
|||||||
private val main = Handler(Looper.getMainLooper())
|
private val main = Handler(Looper.getMainLooper())
|
||||||
private var connecting: MediaApp? = null
|
private var connecting: MediaApp? = null
|
||||||
|
|
||||||
|
/** The music app Gearslip is attached to, for the assistant to name; null when there's none. */
|
||||||
|
val appLabel: String? get() = connecting?.label?.takeIf { controller != null }
|
||||||
|
|
||||||
private val _phase = MutableStateFlow(Phase.IDLE)
|
private val _phase = MutableStateFlow(Phase.IDLE)
|
||||||
val phase: StateFlow<Phase> = _phase.asStateFlow()
|
val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||||
|
|
||||||
@@ -236,6 +239,12 @@ class CarMedia(private val context: Context) {
|
|||||||
connecting = app
|
connecting = app
|
||||||
_phase.value = Phase.CONNECTING
|
_phase.value = Phase.CONNECTING
|
||||||
_browse.value = BrowseState()
|
_browse.value = BrowseState()
|
||||||
|
// Known to refuse its library (Spotify): asking only costs the seconds it takes to say no,
|
||||||
|
// so go straight to its playback session.
|
||||||
|
if (KnownApps.playerWorks(app.component.packageName)) {
|
||||||
|
attachToSession(app)
|
||||||
|
return
|
||||||
|
}
|
||||||
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
|
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
|
||||||
override fun onConnected() = this@CarMedia.onConnected(app)
|
override fun onConnected() = this@CarMedia.onConnected(app)
|
||||||
override fun onConnectionFailed() {
|
override fun onConnectionFailed() {
|
||||||
@@ -273,7 +282,12 @@ class CarMedia(private val context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
val limit = if (launched) SESSION_RETRIES_LAUNCHED else SESSION_RETRIES
|
// A known session-only app that isn't running won't start by waiting: say so at once.
|
||||||
|
val limit = when {
|
||||||
|
launched -> SESSION_RETRIES_LAUNCHED
|
||||||
|
KnownApps.playerWorks(app.component.packageName) -> 0
|
||||||
|
else -> SESSION_RETRIES
|
||||||
|
}
|
||||||
// The app only publishes its session once its service has started; give it a moment.
|
// The app only publishes its session once its service has started; give it a moment.
|
||||||
if (attempt < limit) {
|
if (attempt < limit) {
|
||||||
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
|
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
|
||||||
@@ -454,7 +468,7 @@ class CarMedia(private val context: Context) {
|
|||||||
}
|
}
|
||||||
val component = searchComponent
|
val component = searchComponent
|
||||||
if (component == null) {
|
if (component == null) {
|
||||||
_results.value = Results(q, emptyList(), supported = false)
|
noLibrarySearch(q)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resultsQuery = q
|
resultsQuery = q
|
||||||
@@ -476,13 +490,13 @@ class CarMedia(private val context: Context) {
|
|||||||
override fun onConnectionFailed() {
|
override fun onConnectionFailed() {
|
||||||
GearslipLog.w("media: no library connection for search")
|
GearslipLog.w("media: no library connection for search")
|
||||||
searchBrowser = null
|
searchBrowser = null
|
||||||
resultsQuery?.let { _results.value = Results(it, emptyList(), supported = false) }
|
resultsQuery?.let { noLibrarySearch(it) }
|
||||||
}
|
}
|
||||||
}, null)
|
}, null)
|
||||||
searchBrowser = created
|
searchBrowser = created
|
||||||
runCatching { created.connect() }.onFailure {
|
runCatching { created.connect() }.onFailure {
|
||||||
searchBrowser = null
|
searchBrowser = null
|
||||||
_results.value = Results(q, emptyList(), supported = false)
|
noLibrarySearch(q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,18 +506,42 @@ class CarMedia(private val context: Context) {
|
|||||||
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
|
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
|
||||||
if (query != resultsQuery) return
|
if (query != resultsQuery) return
|
||||||
GearslipLog.i("media: library search found ${items.size}")
|
GearslipLog.i("media: library search found ${items.size}")
|
||||||
_results.value = Results(query, items.map(::entryOfCompat))
|
val entries = items.map(::entryOfCompat)
|
||||||
|
if (query == playFirstFor) {
|
||||||
|
playFirstFor = null
|
||||||
|
val first = entries.firstOrNull { it.playable } ?: entries.firstOrNull()
|
||||||
|
if (first == null) {
|
||||||
|
clearResults()
|
||||||
|
search(query)
|
||||||
|
} else {
|
||||||
|
GearslipLog.i("media: playing the library's best match")
|
||||||
|
clearResults()
|
||||||
|
searchBefore = _now.value
|
||||||
|
_search.value = Search(query)
|
||||||
|
main.removeCallbacks(searchTimeout)
|
||||||
|
controller?.transportControls?.playFromMediaId(first.id, null)
|
||||||
|
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_results.value = Results(query, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(query: String, extras: Bundle?) {
|
override fun onError(query: String, extras: Bundle?) {
|
||||||
if (query != resultsQuery) return
|
if (query != resultsQuery) return
|
||||||
|
if (query == playFirstFor) {
|
||||||
|
playFirstFor = null
|
||||||
|
clearResults()
|
||||||
|
search(query)
|
||||||
|
return
|
||||||
|
}
|
||||||
GearslipLog.i("media: the app has no library search")
|
GearslipLog.i("media: the app has no library search")
|
||||||
_results.value = Results(query, emptyList(), supported = false)
|
_results.value = Results(query, emptyList(), supported = false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
GearslipLog.w("media: library search failed: ${it.message}")
|
GearslipLog.w("media: library search failed: ${it.message}")
|
||||||
_results.value = Results(q, emptyList(), supported = false)
|
noLibrarySearch(q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +558,36 @@ class CarMedia(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The library can't search: a voice "play X" falls back to the app's own search; a typed one says so. */
|
||||||
|
private fun noLibrarySearch(q: String) {
|
||||||
|
if (q == playFirstFor) {
|
||||||
|
playFirstFor = null
|
||||||
|
clearResults()
|
||||||
|
search(q)
|
||||||
|
} else {
|
||||||
|
_results.value = Results(q, emptyList(), supported = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A voice "play X" waiting on the library search, to play its first match instead of listing them. */
|
||||||
|
private var playFirstFor: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the best match for [query], for voice. Asks the app's library first and plays its top
|
||||||
|
* result, which apps like ViVi honour where they ignore [search]; without a library search,
|
||||||
|
* falls back to [search].
|
||||||
|
*/
|
||||||
|
fun playBestMatch(query: String) {
|
||||||
|
val q = query.trim()
|
||||||
|
if (q.isEmpty()) return
|
||||||
|
if (!canSearchLibrary) {
|
||||||
|
search(q)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playFirstFor = q
|
||||||
|
searchLibrary(q)
|
||||||
|
}
|
||||||
|
|
||||||
/** The connected app's service, for the search connection; null when nothing is connected. */
|
/** The connected app's service, for the search connection; null when nothing is connected. */
|
||||||
private var searchComponent: android.content.ComponentName? = null
|
private var searchComponent: android.content.ComponentName? = null
|
||||||
private var searchBrowser: MediaBrowserCompat? = null
|
private var searchBrowser: MediaBrowserCompat? = null
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ import kotlin.math.sqrt
|
|||||||
*/
|
*/
|
||||||
object SpeechEngine {
|
object SpeechEngine {
|
||||||
|
|
||||||
/** FUTO's ACFT Whisper tiny (Apache-2.0), re-quantized to 5 bits, inside the APK. */
|
/**
|
||||||
private const val MODEL = "speech/tiny_acft_q5_1.bin"
|
* FUTO's ACFT Whisper (Apache-2.0), re-quantized to 5 bits, inside the APK: base when the build
|
||||||
|
* carries it (more accurate, about twice as slow), tiny otherwise.
|
||||||
|
*/
|
||||||
|
private val MODELS = listOf("speech/base_acft_q5_1.bin", "speech/tiny_acft_q5_1.bin")
|
||||||
|
|
||||||
/** The error for an engine that can't run at all, which callers may answer with a fallback. */
|
/** The error for an engine that can't run at all, which callers may answer with a fallback. */
|
||||||
const val NOT_LOADED = "The speech model didn't load."
|
const val NOT_LOADED = "The speech model didn't load."
|
||||||
@@ -258,12 +261,14 @@ object SpeechEngine {
|
|||||||
libraryLoaded = true
|
libraryLoaded = true
|
||||||
}
|
}
|
||||||
val started = SystemClock.uptimeMillis()
|
val started = SystemClock.uptimeMillis()
|
||||||
handle = nativeInitAsset(context.assets, MODEL)
|
val model = MODELS.firstOrNull { name -> runCatching { context.assets.open(name).close() }.isSuccess }
|
||||||
|
?: run { GearslipLog.w("speech: no model in the build"); return false }
|
||||||
|
handle = nativeInitAsset(context.assets, model)
|
||||||
if (handle == 0L) {
|
if (handle == 0L) {
|
||||||
GearslipLog.w("speech: couldn't load the model")
|
GearslipLog.w("speech: couldn't load the model")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
GearslipLog.i("speech: loaded the model in ${SystemClock.uptimeMillis() - started} ms")
|
GearslipLog.i("speech: loaded $model in ${SystemClock.uptimeMillis() - started} ms")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user