Skip to content

Before playing TTS, request audio focus#6978

Draft
ChadKillingsworth wants to merge 3 commits into
home-assistant:mainfrom
ChadKillingsworth:feature/tts-audio-focus
Draft

Before playing TTS, request audio focus#6978
ChadKillingsworth wants to merge 3 commits into
home-assistant:mainfrom
ChadKillingsworth:feature/tts-audio-focus

Conversation

@ChadKillingsworth

@ChadKillingsworth ChadKillingsworth commented Jun 6, 2026

Copy link
Copy Markdown

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

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

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.media classes used in this change are all marked deprecated and instead androidx.media3 helpers are suggested. However, the androidx.media3 equivalent classes are marked with @UnstableAPI and do not appear ready for production use.

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.
Copilot AI review requested due to automatic review settings June 6, 2026 20:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/AudioManagerCompat handling with an OnAudioFocusChangeListener.
  • Refactors playback into a tracked playbackJob with pause/resume behavior on focus changes.
  • Adjusts default AudioAttributes to speech/media and inserts an initial blank utterance to mitigate clipped first syllables.

@ChadKillingsworth ChadKillingsworth changed the title Before playing TTS request audio focus Before playing TTS, request audio focus Jun 6, 2026
}

@Test
@Config(sdk = [26])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not also calling stopTTS?

isTransientLoss = false
val focusGranted = requestAudioFocus()

if (!focusGranted && !hasFocus.value) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +210 to +213
val regained = withTimeoutOrNull(FOCUS_TIMEOUT_MS) {
hasFocus.first { it }
true
} ?: false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have an official documentation or a link to an issue about this assumption?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +251 to +254
val regained = withTimeoutOrNull(FOCUS_TIMEOUT_MS) {
hasFocus.first { it }
true
} ?: false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid duplication.

Timber.d("Audio focus lost temporarily")
hasFocus.value = false
isTransientLoss = true
playbackJob?.cancel()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if it's transient you kill the playbackJob? when it restarts it restarts from the beginning?

@ChadKillingsworth

Copy link
Copy Markdown
Author

Thanks for the review - I'll work on addressing all these comments.

@TimoPtr TimoPtr marked this pull request as draft June 10, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants