1 Commits

Author SHA1 Message Date
spirit 6d7e058cb0 Fix TTS audio routing for Android Auto
Set AudioAttributes (USAGE_ASSISTANT) on the TTS engine itself,
not just on the AudioFocusRequest. Without this, TTS output goes
to the phone speaker instead of routing through Android Auto's
head unit.
2026-03-10 12:46:36 +00:00
4 changed files with 20 additions and 233 deletions
@@ -10,7 +10,6 @@ import farm.hobbs.clippyauto.matrix.MatrixRestClient
import farm.hobbs.clippyauto.push.ClippyResponseBus
import farm.hobbs.clippyauto.push.ResponseDedup
import farm.hobbs.clippyauto.session.SessionManager
import farm.hobbs.clippyauto.audio.AudioFocusManager
import farm.hobbs.clippyauto.voice.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
@@ -56,7 +55,6 @@ class VoiceLoop(
private lateinit var speechManager: SpeechManager
private lateinit var ttsManager: TTSManager
private lateinit var audioCues: AudioCueManager
private lateinit var audioFocusManager: AudioFocusManager
private var syncJob: Job? = null
private var thinkingTimerRunnable: Runnable? = null
@@ -65,9 +63,6 @@ class VoiceLoop(
var onQuietModeResponse: ((String) -> Unit)? = null
fun initialize() {
// Initialize session-level audio focus (pauses stereo during conversation)
audioFocusManager = AudioFocusManager(context)
// Initialize audio cues
audioCues = AudioCueManager(context)
@@ -82,9 +77,6 @@ class VoiceLoop(
onSpeakingDone = {
stateMachine.transition(VoiceState.IDLE)
onStateChange(VoiceState.IDLE, "Tap to talk to Clippy")
// Schedule delayed focus release (4s grace period)
// If Jeremy taps talk again, acquireSessionFocus() cancels this
audioFocusManager.scheduleRelease()
}
)
ttsManager.initialize()
@@ -142,23 +134,20 @@ class VoiceLoop(
when (stateMachine.state.value) {
VoiceState.IDLE -> {
LatencyTracker.markT0(sessionManager.sessionId ?: "") // t0: PTT press
audioFocusManager.acquireSessionFocus() // Pause stereo for conversation
stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening()
onStateChange(VoiceState.LISTENING, "🎙️ Listening...")
}
VoiceState.SPEAKING -> {
// Interruption — stop TTS, start listening (keep audio focus)
// Interruption — stop TTS, start listening
ttsManager.interrupt()
audioCues.stopThinking()
audioFocusManager.acquireSessionFocus() // Re-acquire/cancel pending release
stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening()
onStateChange(VoiceState.LISTENING, "🎙️ Listening...")
}
VoiceState.QUIET -> {
// In quiet mode, talk button still works for input
audioFocusManager.acquireSessionFocus()
stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening()
onStateChange(VoiceState.LISTENING, "🎙️ Listening (quiet mode)...")
@@ -199,7 +188,6 @@ class VoiceLoop(
is VoiceCommandProcessor.CommandResult.WrapUp -> {
onStateChange(VoiceState.PROCESSING, "📋 Wrapping up...")
audioFocusManager.releaseSessionFocus() // Let stereo resume
scope.launch {
// Send wrap-up signal to Matrix — Clippy will post summary
sendToMatrix("[wrap up] Jeremy requested session close and summary.")
@@ -376,7 +364,6 @@ class VoiceLoop(
fun destroy() {
cancelThinkingTimer()
audioFocusManager.releaseSessionFocus()
syncJob?.cancel()
speechManager.destroy()
ttsManager.shutdown()
@@ -1,189 +0,0 @@
package farm.hobbs.clippyauto.audio
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import farm.hobbs.clippyauto.config.AppConfig
/**
* Manages audio focus for the entire ClippyAuto session.
*
* Instead of grabbing/releasing focus per STT/TTS cycle (which causes
* the car stereo to bounce on and off), this holds focus for the whole
* conversation and releases after a configurable grace period.
*
* Supports three music behavior modes:
* - PAUSE: AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE — stereo fully stops
* - DUCK: AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK — stereo lowers volume
* - NONE: No audio focus changes — stereo keeps playing normally
*/
class AudioFocusManager(context: Context) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var focusRequest: AudioFocusRequest? = null
private var hasFocus = false
private val handler = Handler(Looper.getMainLooper())
private var releaseRunnable: Runnable? = null
companion object {
private const val TAG = "AudioFocusManager"
/** Grace period after TTS finishes before releasing focus */
private const val RELEASE_GRACE_MS = 4000L // 4 seconds
// Music behavior modes (stored in AppConfig)
const val MODE_PAUSE = 0 // Full exclusive — stereo stops
const val MODE_DUCK = 1 // Duck — stereo lowers volume
const val MODE_NONE = 2 // No change — stereo keeps playing
}
private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d(TAG, "Audio focus gained")
hasFocus = true
}
AudioManager.AUDIOFOCUS_LOSS -> {
Log.d(TAG, "Audio focus lost permanently")
hasFocus = false
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.d(TAG, "Audio focus lost transiently")
// Keep our state — we'll re-request if needed
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Log.d(TAG, "Audio focus ducked by another app")
}
}
}
/**
* Grab audio focus for the session.
* Call once when the user taps the talk button.
* Cancels any pending release from a previous grace period.
*/
fun acquireSessionFocus(): Boolean {
val mode = AppConfig.musicBehavior
// If user chose "no change", don't touch audio focus at all
if (mode == MODE_NONE) {
Log.d(TAG, "Music behavior = NONE, skipping audio focus")
return true
}
// Cancel any pending delayed release
cancelPendingRelease()
if (hasFocus) {
Log.d(TAG, "Already have focus, skipping acquire")
return true
}
val focusGain = when (mode) {
MODE_PAUSE -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
MODE_DUCK -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
else -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = AudioFocusRequest.Builder(focusGain)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setOnAudioFocusChangeListener(focusChangeListener)
.setAcceptsDelayedFocusGain(true)
.setWillPauseWhenDucked(mode == MODE_PAUSE)
.build()
focusRequest = request
val result = audioManager.requestAudioFocus(request)
when (result) {
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
Log.d(TAG, "Session audio focus acquired (mode=$mode)")
hasFocus = true
true
}
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
Log.d(TAG, "Audio focus delayed")
true
}
else -> {
Log.w(TAG, "Audio focus request failed: $result")
false
}
}
} else {
@Suppress("DEPRECATION")
val result = audioManager.requestAudioFocus(
focusChangeListener,
AudioManager.STREAM_MUSIC,
focusGain
)
hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
hasFocus
}
}
/**
* Schedule a delayed release of audio focus.
* Call when TTS finishes playing. If the user taps talk again
* within the grace period, the release is cancelled.
*/
fun scheduleRelease() {
if (AppConfig.musicBehavior == MODE_NONE) return
cancelPendingRelease()
releaseRunnable = Runnable {
releaseSessionFocus()
Log.d(TAG, "Audio focus released after grace period")
}
handler.postDelayed(releaseRunnable!!, RELEASE_GRACE_MS)
Log.d(TAG, "Scheduled focus release in ${RELEASE_GRACE_MS}ms")
}
/**
* Cancel a pending delayed release.
* Call when the user starts talking again before the grace period expires.
*/
private fun cancelPendingRelease() {
releaseRunnable?.let {
handler.removeCallbacks(it)
releaseRunnable = null
Log.d(TAG, "Cancelled pending focus release")
}
}
/**
* Immediately release audio focus.
* Call on session end, "wrap it up", or app close.
*/
fun releaseSessionFocus() {
cancelPendingRelease()
if (!hasFocus && focusRequest == null) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
focusRequest?.let {
audioManager.abandonAudioFocusRequest(it)
}
focusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(focusChangeListener)
}
hasFocus = false
Log.d(TAG, "Session audio focus released")
}
fun hasFocus(): Boolean = hasFocus
}
@@ -24,12 +24,6 @@ object AppConfig {
private const val KEY_MATRIX_PASS = "matrix_password"
private const val KEY_MATRIX_TOKEN = "matrix_access_token"
private const val KEY_INITIALIZED = "config_initialized"
private const val KEY_MUSIC_BEHAVIOR = "music_behavior"
// Music behavior modes
const val MUSIC_PAUSE = 0 // Fully stop stereo during conversation
const val MUSIC_DUCK = 1 // Lower stereo volume
const val MUSIC_NONE = 2 // Leave stereo alone
// Defaults (v1 — will be replaced by SetupActivity input)
private const val DEFAULT_HOMESERVER = "https://mess.hobbs.farm"
@@ -95,16 +89,6 @@ object AppConfig {
prefs?.edit()?.putString(KEY_MATRIX_TOKEN, value)?.apply()
}
/**
* Music behavior during Clippy conversations.
* Default: MUSIC_PAUSE (fully stop stereo so mic gets clean audio)
*/
var musicBehavior: Int
get() = prefs?.getInt(KEY_MUSIC_BEHAVIOR, MUSIC_PAUSE) ?: MUSIC_PAUSE
set(value) {
prefs?.edit()?.putInt(KEY_MUSIC_BEHAVIOR, value)?.apply()
}
/**
* Update credentials (for future SetupActivity use).
*/
@@ -2,6 +2,8 @@ package farm.hobbs.clippyauto.voice
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import farm.hobbs.clippyauto.util.LatencyTracker
@@ -10,15 +12,14 @@ import java.util.Locale
import java.util.UUID
/**
* Manages text-to-speech output with queuing and chunking.
* Manages text-to-speech output with queuing, chunking, and audio focus.
*
* Key behaviors:
* - Plays through car audio via USAGE_ASSISTANT AudioAttributes
* - Plays through car audio via USAGE_ASSISTANT
* - Ducks music during playback (AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
* - Queues chunked responses and plays sequentially
* - Splits responses > MAX_CHUNK_CHARS into sentence-level chunks
*
* Audio focus is managed at the session level by AudioFocusManager
* (not per-utterance here) to avoid the stereo bouncing on/off.
* - Supports 200ms fade on interruption
*/
class TTSManager(
private val context: Context,
@@ -36,19 +37,23 @@ class TTSManager(
private var tts: TextToSpeech? = null
private var isReady = false
private val queue = LinkedList<String>()
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
/** AudioAttributes for routing TTS through Android Auto head unit */
private val ttsAudioAttributes = AudioAttributes.Builder()
private val audioFocusRequest = AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
).setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
).build()
fun initialize() {
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.US
// Route TTS audio through Android Auto head unit
tts?.setAudioAttributes(ttsAudioAttributes)
tts?.setAudioAttributes(audioFocusRequest.audioAttributes)
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
LatencyTracker.markT5() // t5: First audio frame
@@ -59,7 +64,7 @@ class TTSManager(
if (queue.isNotEmpty()) {
speakNext()
} else {
// Audio focus release is handled by AudioFocusManager
audioManager.abandonAudioFocusRequest(audioFocusRequest)
onSpeakingDone()
}
}
@@ -77,7 +82,6 @@ class TTSManager(
/**
* Queue text for TTS playback. Automatically chunks long text.
* If nothing is currently playing, starts immediately.
* Note: Audio focus should be acquired by AudioFocusManager before calling this.
*/
fun speak(text: String) {
if (!isReady) return
@@ -89,6 +93,7 @@ class TTSManager(
if (queue.size == chunks.size) {
// Nothing was playing before — start now
audioManager.requestAudioFocus(audioFocusRequest)
speakNext()
}
}
@@ -128,11 +133,11 @@ class TTSManager(
/**
* Interrupt current speech (for when Jeremy starts talking).
* Clears the queue so unplayed chunks are dropped.
* Note: Audio focus is NOT released here — AudioFocusManager handles that.
*/
fun interrupt() {
queue.clear()
tts?.stop()
audioManager.abandonAudioFocusRequest(audioFocusRequest)
}
fun shutdown() {