Before playing TTS, request audio focus#6978
Conversation
Allows text-to-speech notifications and commands to temporarily interrupt other audio sources on Android Auto. Also add audio usage and content type for standard TTS content.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds audio-focus-aware playback control to the TTS client to better handle interruptions (e.g., Bluetooth/other media) and avoid clipped starts.
Changes:
- Introduces
AudioFocusRequestCompat/AudioManagerCompathandling with anOnAudioFocusChangeListener. - Refactors playback into a tracked
playbackJobwith pause/resume behavior on focus changes. - Adjusts default
AudioAttributesto speech/media and inserts an initial blank utterance to mitigate clipped first syllables.
| } | ||
|
|
||
| @Test | ||
| @Config(sdk = [26]) |
There was a problem hiding this comment.
I think you can pass 2 versions to Config 23 and 26 so you test the 2 versions in only one test.
| try { | ||
| play() | ||
| } finally { | ||
| isPlaying = false |
There was a problem hiding this comment.
You are setting a boolean potentially from another thread, you should ensure that isPlaying is read and write always from the same thread to ensure concurrency issues.
| } | ||
| AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { | ||
| Timber.d("Audio focus lost temporarily") | ||
| hasFocus.value = false |
| isTransientLoss = false | ||
| val focusGranted = requestAudioFocus() | ||
|
|
||
| if (!focusGranted && !hasFocus.value) { |
There was a problem hiding this comment.
requestAudioFocus does return what should be in hasFocus no? otherwise only use hasFocus and only set the hasFocus in the callback. by setting the value from multiple place it's a receipt for making debugging harder.
| return | ||
| } | ||
|
|
||
| if (!hasFocus.value) { |
There was a problem hiding this comment.
hasFocus value could have change in between the moment you check in line 200 and now. You probably want a snapshot of the value when starting play. Or you need to document explicitly why you check again.
| val regained = withTimeoutOrNull(FOCUS_TIMEOUT_MS) { | ||
| hasFocus.first { it } | ||
| true | ||
| } ?: false |
There was a problem hiding this comment.
I'm kinda against this, where does this timeout come from? What are you waiting for? Why 10s and not 1h if it is interrupted by a call for instance.
There was a problem hiding this comment.
I'm really on the fence on this myself honestly. How useful is a delayed notification? How long of a delay until that usefulness is almost zero? In real life, how often will this even occur?
| * Plays each queued [Utterance] in sequence until [utteranceQueue] is empty. | ||
| * There can be further additions to the queue while a message is playing which will be picked up in the running playback loop. | ||
| */ | ||
| private suspend fun play() { |
There was a problem hiding this comment.
This function grew too big it needs to be split
| while (utteranceQueue.isNotEmpty()) { | ||
| utteranceQueue.removeFirst().let { utterance -> | ||
| try { | ||
| // Over bluetooth connections, the first syllable or even word can be cut off. |
There was a problem hiding this comment.
Do you have an official documentation or a link to an issue about this assumption?
There was a problem hiding this comment.
No official docs. I find lots of reddit and stackoverflow posts talking about it. I experienced it 100% of the time on a real device and none of the time on the emmulator (real device was bluetooth - emmulator was port forwarding).
The test messages sent with audio focus logic were:
"Notification was sent"
The car head unit would play:
"cation was sent"
It was very reproducible. Tried multiple things to avoid it and this was the technique that did not rely on magic numbers.
| val regained = withTimeoutOrNull(FOCUS_TIMEOUT_MS) { | ||
| hasFocus.first { it } | ||
| true | ||
| } ?: false |
| Timber.d("Audio focus lost temporarily") | ||
| hasFocus.value = false | ||
| isTransientLoss = true | ||
| playbackJob?.cancel() |
There was a problem hiding this comment.
So if it's transient you kill the playbackJob? when it restarts it restarts from the beginning?
|
Thanks for the review - I'll work on addressing all these comments. |
Summary
When using Android Auto, TTS notifications are routed to the phone speakers instead of the car unless the car head unit audio source is actually "Android Auto". When the car is on any other audio source (including local audio such as the radio or even muted or silenced), TTS content plays through the phone speakers. Most apps, including assistant apps, do not function this way. They will temporarily interrupt any other audio source to play a notification, but then return control to the original source.
To fix this behavior, this change requests audio focus for the companion app temporarily to play the notification then relinquishes focus back to the prior source. See https://developer.android.com/media/optimize/audio-focus
This is related to #5432 but I'm not sure if the assistant functionality uses the textToSpeech engine. If not, a similar technique could be utilized.
Checklist
Any other notes
I tested this both on emulators as well as real devices paired to a real head unit. When using a real device, the audio correctly routed but the first syllable (or two) were cut off. To address this, an empty utterance is injected to the front of the queue after which the notifications played perfectly.
When not using an alarm stream, the audio attributes did not specify a content type or usage. Without this, Android Auto routing was not working at all. This introduces a default content type of "Speech" and usage of "Media" for TTS content (except when using the Alarm media stream).
The
androidx.mediaclasses used in this change are all marked deprecated and insteadandroidx.media3helpers are suggested. However, theandroidx.media3equivalent classes are marked with@UnstableAPIand do not appear ready for production use.