From 07d164ac7496545739bf6317e9ce02e4f8c13bf0 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 1 Oct 2026 21:24:42 -0700 Subject: [PATCH] voice reply improcement --- .../gearslip/car/CarAssistant.kt | 15 ++-- .../gearslip/speech/SpeechEngine.kt | 7 +- .../gearslip/speech/VoiceFocus.kt | 69 +++++++++++++++++++ 3 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/speech/VoiceFocus.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt index 4577b41..cf4b27a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt @@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat import app.seb3thehacker.gearslip.call.CallUiState import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.speech.SpeechEngine +import app.seb3thehacker.gearslip.speech.VoiceFocus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -119,15 +120,19 @@ object CarAssistant { _state.value = _state.value.copy(phase = Phase.IDLE) return } + appContext?.let(VoiceFocus::acquire) engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) {} override fun onDone(utteranceId: String?) { + VoiceFocus.release() _state.value = _state.value.copy(phase = Phase.IDLE) } @Deprecated("Deprecated in Java, no replacement on this API level") override fun onError(utteranceId: String?) { + VoiceFocus.release() _state.value = _state.value.copy(phase = Phase.IDLE) } + override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() } }) engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant") } @@ -138,17 +143,19 @@ object CarAssistant { */ fun say(text: String, onDone: () -> Unit) { val engine = tts - if (engine == null || !ttsReady || text.isBlank()) { + val context = appContext + if (engine == null || context == null || !ttsReady || text.isBlank()) { mainHandler.post(onDone) return } + VoiceFocus.acquire(context) engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) {} - override fun onDone(utteranceId: String?) { mainHandler.post(onDone) } + override fun onDone(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) } @Deprecated("Deprecated in Java, no replacement on this API level") - override fun onError(utteranceId: String?) { mainHandler.post(onDone) } + override fun onError(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) } // Stopped part way: whoever stopped it has already moved on. - override fun onStop(utteranceId: String?, interrupted: Boolean) {} + override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() } }) engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say") } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechEngine.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechEngine.kt index 3626c15..8694e23 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechEngine.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechEngine.kt @@ -85,13 +85,16 @@ object SpeechEngine { worker.execute { if (id != run) return@execute if (!ensureLoaded(app)) return@execute post { onError(NOT_LOADED) } - if (cue) cue() - if (id != run) return@execute + VoiceFocus.acquire(app) val audio = try { + if (cue) cue() + if (id != run) return@execute record(id) { post(onSpeaking) } } catch (t: Throwable) { GearslipLog.e("speech: recording failed", t) return@execute post { onError("The microphone isn't available right now.") } + } finally { + VoiceFocus.release() } if (id != run) return@execute if (audio == null) return@execute post { onError("Didn't catch that.") } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/VoiceFocus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/VoiceFocus.kt new file mode 100644 index 0000000..e79f2e5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/VoiceFocus.kt @@ -0,0 +1,69 @@ +package app.seb3thehacker.gearslip.speech + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import app.seb3thehacker.gearslip.GearslipLog + +/** + * Pauses the music while Gearslip talks or listens. Without it the microphone heard the song and + * Whisper wrote down lyrics, and a message read aloud had to compete with the music. + * + * Transient exclusive focus makes music apps pause rather than duck, and they resume on their + * own once it's given back. The release waits a moment, so a read-aloud message followed by + * "Do you want to reply?" and the microphone is one pause, not three. + */ +object VoiceFocus { + + private const val RELEASE_DELAY_MS = 1_500L + + private val main = Handler(Looper.getMainLooper()) + private var request: AudioFocusRequest? = null + private var audio: AudioManager? = null + private var holders = 0 + + private val releaseNow = Runnable { + val r = request ?: return@Runnable + audio?.abandonAudioFocusRequest(r) + request = null + GearslipLog.i("voice: gave audio focus back") + } + + /** Pauses the music until a matching [release]. Safe from any thread. */ + fun acquire(context: Context) { + val app = context.applicationContext + main.post { + holders++ + main.removeCallbacks(releaseNow) + if (request != null) return@post + val manager = app.getSystemService(AudioManager::class.java) ?: return@post + val r = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setOnAudioFocusChangeListener { } + .build() + val granted = manager.requestAudioFocus(r) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + GearslipLog.i("voice: audio focus ${if (granted) "granted, music paused" else "refused"}") + if (granted) { + audio = manager + request = r + } + } + } + + /** Lets the music come back shortly, unless something else takes focus again first. */ + fun release() { + main.post { + if (holders == 0) return@post + holders-- + if (holders == 0) main.postDelayed(releaseNow, RELEASE_DELAY_MS) + } + } +}