diff --git a/app/src/main/java/farm/hobbs/clippyauto/VoiceLoop.kt b/app/src/main/java/farm/hobbs/clippyauto/VoiceLoop.kt index 845d4f2..38a3eef 100644 --- a/app/src/main/java/farm/hobbs/clippyauto/VoiceLoop.kt +++ b/app/src/main/java/farm/hobbs/clippyauto/VoiceLoop.kt @@ -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() diff --git a/app/src/main/java/farm/hobbs/clippyauto/audio/AudioFocusManager.kt b/app/src/main/java/farm/hobbs/clippyauto/audio/AudioFocusManager.kt new file mode 100644 index 0000000..8b1d4a1 --- /dev/null +++ b/app/src/main/java/farm/hobbs/clippyauto/audio/AudioFocusManager.kt @@ -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 +} diff --git a/app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt b/app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt index 508d7b8..a9635ca 100644 --- a/app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt +++ b/app/src/main/java/farm/hobbs/clippyauto/config/AppConfig.kt @@ -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). */ diff --git a/app/src/main/java/farm/hobbs/clippyauto/voice/TTSManager.kt b/app/src/main/java/farm/hobbs/clippyauto/voice/TTSManager.kt index a7264a6..85bd267 100644 --- a/app/src/main/java/farm/hobbs/clippyauto/voice/TTSManager.kt +++ b/app/src/main/java/farm/hobbs/clippyauto/voice/TTSManager.kt @@ -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() - private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - 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() + /** AudioAttributes for routing TTS through Android Auto head unit */ + private val ttsAudioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .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() {