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:
@@ -10,6 +10,7 @@ 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
|
||||
@@ -55,6 +56,7 @@ 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
|
||||
@@ -63,6 +65,9 @@ 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)
|
||||
|
||||
@@ -77,6 +82,9 @@ 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()
|
||||
@@ -134,20 +142,23 @@ 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
|
||||
// Interruption — stop TTS, start listening (keep audio focus)
|
||||
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)...")
|
||||
@@ -188,6 +199,7 @@ 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.")
|
||||
@@ -364,6 +376,7 @@ class VoiceLoop(
|
||||
|
||||
fun destroy() {
|
||||
cancelThinkingTimer()
|
||||
audioFocusManager.releaseSessionFocus()
|
||||
syncJob?.cancel()
|
||||
speechManager.destroy()
|
||||
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_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"
|
||||
@@ -89,6 +95,16 @@ 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,8 +2,6 @@ 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
|
||||
@@ -12,14 +10,15 @@ import java.util.Locale
|
||||
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:
|
||||
* - Plays through car audio via USAGE_ASSISTANT
|
||||
* - Ducks music during playback (AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
|
||||
* - Plays through car audio via USAGE_ASSISTANT AudioAttributes
|
||||
* - Queues chunked responses and plays sequentially
|
||||
* - 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(
|
||||
private val context: Context,
|
||||
@@ -37,21 +36,19 @@ 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
|
||||
|
||||
private val audioFocusRequest = AudioFocusRequest.Builder(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
|
||||
).setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
/** AudioAttributes for routing TTS through Android Auto head unit */
|
||||
private val ttsAudioAttributes = 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?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {
|
||||
LatencyTracker.markT5() // t5: First audio frame
|
||||
@@ -62,7 +59,7 @@ class TTSManager(
|
||||
if (queue.isNotEmpty()) {
|
||||
speakNext()
|
||||
} else {
|
||||
audioManager.abandonAudioFocusRequest(audioFocusRequest)
|
||||
// Audio focus release is handled by AudioFocusManager
|
||||
onSpeakingDone()
|
||||
}
|
||||
}
|
||||
@@ -80,6 +77,7 @@ 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
|
||||
@@ -91,7 +89,6 @@ class TTSManager(
|
||||
|
||||
if (queue.size == chunks.size) {
|
||||
// Nothing was playing before — start now
|
||||
audioManager.requestAudioFocus(audioFocusRequest)
|
||||
speakNext()
|
||||
}
|
||||
}
|
||||
@@ -131,11 +128,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() {
|
||||
|
||||
Reference in New Issue
Block a user