Add session-level audio focus to pause stereo during conversations

- Add AudioFocusManager: holds audio focus for entire conversation
  with 4-second grace period after TTS finishes
- Default to MODE_PAUSE (AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) so
  stereo fully stops instead of just ducking
- Wire into VoiceLoop: acquire on PTT, schedule release on TTS done,
  immediate release on wrap-up and destroy
- Remove per-utterance focus from TTSManager (was causing stereo
  to bounce on/off between each exchange)
- Add musicBehavior property to AppConfig (pause/duck/none)
This commit is contained in:
2026-03-10 13:04:39 +00:00
parent bdd478ce8a
commit a14c885e38
4 changed files with 234 additions and 19 deletions
@@ -10,6 +10,7 @@ import farm.hobbs.clippyauto.matrix.MatrixRestClient
import farm.hobbs.clippyauto.push.ClippyResponseBus import farm.hobbs.clippyauto.push.ClippyResponseBus
import farm.hobbs.clippyauto.push.ResponseDedup import farm.hobbs.clippyauto.push.ResponseDedup
import farm.hobbs.clippyauto.session.SessionManager import farm.hobbs.clippyauto.session.SessionManager
import farm.hobbs.clippyauto.audio.AudioFocusManager
import farm.hobbs.clippyauto.voice.* import farm.hobbs.clippyauto.voice.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -55,6 +56,7 @@ class VoiceLoop(
private lateinit var speechManager: SpeechManager private lateinit var speechManager: SpeechManager
private lateinit var ttsManager: TTSManager private lateinit var ttsManager: TTSManager
private lateinit var audioCues: AudioCueManager private lateinit var audioCues: AudioCueManager
private lateinit var audioFocusManager: AudioFocusManager
private var syncJob: Job? = null private var syncJob: Job? = null
private var thinkingTimerRunnable: Runnable? = null private var thinkingTimerRunnable: Runnable? = null
@@ -63,6 +65,9 @@ class VoiceLoop(
var onQuietModeResponse: ((String) -> Unit)? = null var onQuietModeResponse: ((String) -> Unit)? = null
fun initialize() { fun initialize() {
// Initialize session-level audio focus (pauses stereo during conversation)
audioFocusManager = AudioFocusManager(context)
// Initialize audio cues // Initialize audio cues
audioCues = AudioCueManager(context) audioCues = AudioCueManager(context)
@@ -77,6 +82,9 @@ class VoiceLoop(
onSpeakingDone = { onSpeakingDone = {
stateMachine.transition(VoiceState.IDLE) stateMachine.transition(VoiceState.IDLE)
onStateChange(VoiceState.IDLE, "Tap to talk to Clippy") 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() ttsManager.initialize()
@@ -134,20 +142,23 @@ class VoiceLoop(
when (stateMachine.state.value) { when (stateMachine.state.value) {
VoiceState.IDLE -> { VoiceState.IDLE -> {
LatencyTracker.markT0(sessionManager.sessionId ?: "") // t0: PTT press LatencyTracker.markT0(sessionManager.sessionId ?: "") // t0: PTT press
audioFocusManager.acquireSessionFocus() // Pause stereo for conversation
stateMachine.transition(VoiceState.LISTENING) stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening() speechManager.startListening()
onStateChange(VoiceState.LISTENING, "🎙️ Listening...") onStateChange(VoiceState.LISTENING, "🎙️ Listening...")
} }
VoiceState.SPEAKING -> { VoiceState.SPEAKING -> {
// Interruption — stop TTS, start listening // Interruption — stop TTS, start listening (keep audio focus)
ttsManager.interrupt() ttsManager.interrupt()
audioCues.stopThinking() audioCues.stopThinking()
audioFocusManager.acquireSessionFocus() // Re-acquire/cancel pending release
stateMachine.transition(VoiceState.LISTENING) stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening() speechManager.startListening()
onStateChange(VoiceState.LISTENING, "🎙️ Listening...") onStateChange(VoiceState.LISTENING, "🎙️ Listening...")
} }
VoiceState.QUIET -> { VoiceState.QUIET -> {
// In quiet mode, talk button still works for input // In quiet mode, talk button still works for input
audioFocusManager.acquireSessionFocus()
stateMachine.transition(VoiceState.LISTENING) stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening() speechManager.startListening()
onStateChange(VoiceState.LISTENING, "🎙️ Listening (quiet mode)...") onStateChange(VoiceState.LISTENING, "🎙️ Listening (quiet mode)...")
@@ -188,6 +199,7 @@ class VoiceLoop(
is VoiceCommandProcessor.CommandResult.WrapUp -> { is VoiceCommandProcessor.CommandResult.WrapUp -> {
onStateChange(VoiceState.PROCESSING, "📋 Wrapping up...") onStateChange(VoiceState.PROCESSING, "📋 Wrapping up...")
audioFocusManager.releaseSessionFocus() // Let stereo resume
scope.launch { scope.launch {
// Send wrap-up signal to Matrix — Clippy will post summary // Send wrap-up signal to Matrix — Clippy will post summary
sendToMatrix("[wrap up] Jeremy requested session close and summary.") sendToMatrix("[wrap up] Jeremy requested session close and summary.")
@@ -364,6 +376,7 @@ class VoiceLoop(
fun destroy() { fun destroy() {
cancelThinkingTimer() cancelThinkingTimer()
audioFocusManager.releaseSessionFocus()
syncJob?.cancel() syncJob?.cancel()
speechManager.destroy() speechManager.destroy()
ttsManager.shutdown() ttsManager.shutdown()
@@ -0,0 +1,189 @@
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,6 +24,12 @@ object AppConfig {
private const val KEY_MATRIX_PASS = "matrix_password" private const val KEY_MATRIX_PASS = "matrix_password"
private const val KEY_MATRIX_TOKEN = "matrix_access_token" private const val KEY_MATRIX_TOKEN = "matrix_access_token"
private const val KEY_INITIALIZED = "config_initialized" 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) // Defaults (v1 — will be replaced by SetupActivity input)
private const val DEFAULT_HOMESERVER = "https://mess.hobbs.farm" private const val DEFAULT_HOMESERVER = "https://mess.hobbs.farm"
@@ -89,6 +95,16 @@ object AppConfig {
prefs?.edit()?.putString(KEY_MATRIX_TOKEN, value)?.apply() 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). * Update credentials (for future SetupActivity use).
*/ */
@@ -2,8 +2,6 @@ package farm.hobbs.clippyauto.voice
import android.content.Context import android.content.Context
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import farm.hobbs.clippyauto.util.LatencyTracker import farm.hobbs.clippyauto.util.LatencyTracker
@@ -12,14 +10,15 @@ import java.util.Locale
import java.util.UUID import java.util.UUID
/** /**
* Manages text-to-speech output with queuing, chunking, and audio focus. * Manages text-to-speech output with queuing and chunking.
* *
* Key behaviors: * Key behaviors:
* - Plays through car audio via USAGE_ASSISTANT * - Plays through car audio via USAGE_ASSISTANT AudioAttributes
* - Ducks music during playback (AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
* - Queues chunked responses and plays sequentially * - Queues chunked responses and plays sequentially
* - Splits responses > MAX_CHUNK_CHARS into sentence-level chunks * - Splits responses > MAX_CHUNK_CHARS into sentence-level chunks
* - Supports 200ms fade on interruption *
* Audio focus is managed at the session level by AudioFocusManager
* (not per-utterance here) to avoid the stereo bouncing on/off.
*/ */
class TTSManager( class TTSManager(
private val context: Context, private val context: Context,
@@ -37,21 +36,19 @@ class TTSManager(
private var tts: TextToSpeech? = null private var tts: TextToSpeech? = null
private var isReady = false private var isReady = false
private val queue = LinkedList<String>() private val queue = LinkedList<String>()
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val audioFocusRequest = AudioFocusRequest.Builder( /** AudioAttributes for routing TTS through Android Auto head unit */
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK private val ttsAudioAttributes = AudioAttributes.Builder()
).setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT) .setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build() .build()
).build()
fun initialize() { fun initialize() {
tts = TextToSpeech(context) { status -> tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) { if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.US tts?.language = Locale.US
// Route TTS audio through Android Auto head unit
tts?.setAudioAttributes(ttsAudioAttributes)
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) { override fun onStart(utteranceId: String?) {
LatencyTracker.markT5() // t5: First audio frame LatencyTracker.markT5() // t5: First audio frame
@@ -62,7 +59,7 @@ class TTSManager(
if (queue.isNotEmpty()) { if (queue.isNotEmpty()) {
speakNext() speakNext()
} else { } else {
audioManager.abandonAudioFocusRequest(audioFocusRequest) // Audio focus release is handled by AudioFocusManager
onSpeakingDone() onSpeakingDone()
} }
} }
@@ -80,6 +77,7 @@ class TTSManager(
/** /**
* Queue text for TTS playback. Automatically chunks long text. * Queue text for TTS playback. Automatically chunks long text.
* If nothing is currently playing, starts immediately. * If nothing is currently playing, starts immediately.
* Note: Audio focus should be acquired by AudioFocusManager before calling this.
*/ */
fun speak(text: String) { fun speak(text: String) {
if (!isReady) return if (!isReady) return
@@ -91,7 +89,6 @@ class TTSManager(
if (queue.size == chunks.size) { if (queue.size == chunks.size) {
// Nothing was playing before — start now // Nothing was playing before — start now
audioManager.requestAudioFocus(audioFocusRequest)
speakNext() speakNext()
} }
} }
@@ -131,11 +128,11 @@ class TTSManager(
/** /**
* Interrupt current speech (for when Jeremy starts talking). * Interrupt current speech (for when Jeremy starts talking).
* Clears the queue so unplayed chunks are dropped. * Clears the queue so unplayed chunks are dropped.
* Note: Audio focus is NOT released here — AudioFocusManager handles that.
*/ */
fun interrupt() { fun interrupt() {
queue.clear() queue.clear()
tts?.stop() tts?.stop()
audioManager.abandonAudioFocusRequest(audioFocusRequest)
} }
fun shutdown() { fun shutdown() {