voice reply improcement

This commit is contained in:
Seb3thehacker committed 2026-10-01 21:24:42 -07:00
1 parent 01a7ae1930
commit 07d164ac74
3 files changed
+85 -6

No files matched your search

@@ -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")
}
@@ -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.") }
@@ -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)
}
}
}