-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Before playing TTS, request audio focus #6978
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,13 +5,19 @@ import android.media.AudioAttributes | |
| import android.media.AudioManager | ||
| import android.widget.Toast | ||
| import androidx.core.content.getSystemService | ||
| import androidx.media.AudioAttributesCompat | ||
| import androidx.media.AudioFocusRequestCompat | ||
| import androidx.media.AudioManagerCompat | ||
| import io.homeassistant.companion.android.common.R | ||
| import io.homeassistant.companion.android.common.notifications.NotificationData | ||
| import java.util.UUID | ||
| import kotlinx.coroutines.CancellationException | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.cancelChildren | ||
| import kotlinx.coroutines.flow.MutableStateFlow | ||
| import kotlinx.coroutines.flow.first | ||
| import kotlinx.coroutines.launch | ||
| import timber.log.Timber | ||
|
|
||
|
|
@@ -30,6 +36,38 @@ class TextToSpeechClient(private val applicationContext: Context, private val te | |
| private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + mainJob) | ||
|
|
||
| private var isPlaying = false | ||
| private var playbackJob: Job? = null | ||
| private val hasFocus = MutableStateFlow(false) | ||
| private var isTransientLoss = false | ||
| private var currentUtterance: Utterance? = null | ||
| private var focusRequest: AudioFocusRequestCompat? = null | ||
|
|
||
| private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> | ||
| mainScope.launch { | ||
| when (focusChange) { | ||
| AudioManager.AUDIOFOCUS_GAIN -> { | ||
| Timber.d("Audio focus gained") | ||
| hasFocus.value = true | ||
| if (utteranceQueue.isNotEmpty() && !isPlaying) { | ||
| startPlayback() | ||
| } | ||
| } | ||
| AudioManager.AUDIOFOCUS_LOSS -> { | ||
| Timber.d("Audio focus lost permanently") | ||
| stopTTS() | ||
| } | ||
| AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, | ||
| AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, | ||
| -> { | ||
| Timber.d("Audio focus lost temporarily") | ||
| isTransientLoss = true | ||
| playbackJob?.cancel() | ||
| playbackJob = null | ||
| textToSpeechEngine.release() | ||
| } | ||
|
ChadKillingsworth marked this conversation as resolved.
Outdated
ChadKillingsworth marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Queues a text message to be played back if [data] with a [TextToSpeechData.TTS_TEXT] key is provided. | ||
|
|
@@ -57,7 +95,7 @@ class TextToSpeechClient(private val applicationContext: Context, private val te | |
| ), | ||
| ) | ||
| if (!isPlaying) { | ||
| play() | ||
| startPlayback() | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -67,39 +105,155 @@ class TextToSpeechClient(private val applicationContext: Context, private val te | |
| */ | ||
| fun stopTTS() { | ||
| Timber.d("stopped TTS") | ||
| playbackJob?.cancel() | ||
| playbackJob = null | ||
| mainJob.cancelChildren() | ||
| utteranceQueue.clear() | ||
| textToSpeechEngine.release() | ||
| abandonAudioFocus() | ||
| isPlaying = false | ||
| } | ||
|
|
||
| private fun startPlayback() { | ||
| if (isPlaying) return | ||
| isPlaying = true | ||
| playbackJob = mainScope.launch { | ||
| try { | ||
| play() | ||
| } finally { | ||
| isPlaying = false | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun requestAudioFocus(): Boolean { | ||
| return try { | ||
| val audioManager = applicationContext.getSystemService<AudioManager>() ?: return false | ||
|
|
||
| val audioAttributes = utteranceQueue.firstOrNull()?.audioAttributes | ||
| val compatAttributes = if (audioAttributes != null) AudioAttributesCompat.wrap(audioAttributes) else null | ||
| val usage = if (audioAttributes != | ||
| null | ||
| ) { | ||
| compatAttributes?.usage ?: AudioAttributesCompat.USAGE_MEDIA | ||
| } else { | ||
| AudioAttributesCompat.USAGE_MEDIA | ||
| } | ||
| val contentType = if (audioAttributes != | ||
| null | ||
| ) { | ||
| compatAttributes?.contentType ?: AudioAttributesCompat.CONTENT_TYPE_SPEECH | ||
| } else { | ||
| AudioAttributesCompat.CONTENT_TYPE_SPEECH | ||
| } | ||
|
|
||
| val audioAttributesCompat = AudioAttributesCompat.Builder() | ||
| .setUsage(usage) | ||
| .setContentType(contentType) | ||
| .build() | ||
|
|
||
| val request = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) | ||
| .setAudioAttributes(audioAttributesCompat) | ||
| .setWillPauseWhenDucked(false) | ||
| .setOnAudioFocusChangeListener(focusListener) | ||
| .build() | ||
|
|
||
| focusRequest = request | ||
|
|
||
| val result = AudioManagerCompat.requestAudioFocus(audioManager, request) | ||
| if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { | ||
| hasFocus.value = true | ||
| true | ||
| } else { | ||
| hasFocus.value = false | ||
| false | ||
| } | ||
| } catch (e: Throwable) { | ||
| Timber.w(e, "Failed to request audio focus") | ||
| hasFocus.value = false | ||
| false | ||
| } | ||
| } | ||
|
|
||
| private fun abandonAudioFocus() { | ||
| val audioManager = applicationContext.getSystemService<AudioManager>() ?: return | ||
| focusRequest?.let { request -> | ||
| try { | ||
| AudioManagerCompat.abandonAudioFocusRequest(audioManager, request) | ||
| } catch (e: Exception) { | ||
| Timber.w(e, "Failed to abandon audio focus") | ||
| } | ||
| } | ||
| focusRequest = null | ||
| } | ||
|
|
||
| /** | ||
| * 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() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function grew too big it needs to be split |
||
| isPlaying = true | ||
| isTransientLoss = false | ||
| requestAudioFocus() | ||
|
|
||
| if (!hasFocus.value) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| Timber.d("Audio focus not granted yet, waiting...") | ||
| hasFocus.first { it } | ||
| } | ||
|
ChadKillingsworth marked this conversation as resolved.
|
||
|
|
||
| textToSpeechEngine.initialize().onFailure { throwable -> | ||
| Timber.e( | ||
| throwable, | ||
| "Failed to initialize engine.", | ||
| ) | ||
| handleError(applicationContext.getString(R.string.tts_error_init)) | ||
| utteranceQueue.clear() | ||
| abandonAudioFocus() | ||
| }.onSuccess { | ||
| while (utteranceQueue.isNotEmpty()) { | ||
| utteranceQueue.removeFirst().let { utterance -> | ||
| try { | ||
| // Over bluetooth connections, the first syllable or even word can be cut off. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
The car head unit would play:
It was very reproducible. Tried multiple things to avoid it and this was the technique that did not rely on magic numbers. |
||
| // Adding an initial empty utterance seems to fix this. | ||
| // Testing shows this is more effective than utilizing the | ||
| // textToSpeach.playSilentUtterance method. | ||
| if (utteranceQueue.isNotEmpty()) { | ||
|
ChadKillingsworth marked this conversation as resolved.
|
||
| utteranceQueue.addFirst( | ||
| Utterance( | ||
| id = UUID.randomUUID().toString(), | ||
| text = " ", | ||
| streamVolumeAdjustment = utteranceQueue.first().streamVolumeAdjustment, | ||
| audioAttributes = utteranceQueue.first().audioAttributes, | ||
| ), | ||
| ) | ||
| } | ||
| while (utteranceQueue.isNotEmpty()) { | ||
| if (!hasFocus.value) { | ||
| Timber.d("Audio focus lost before playing utterance, waiting...") | ||
| hasFocus.first { it } | ||
| } | ||
|
|
||
| val utterance = utteranceQueue.removeFirst() | ||
| currentUtterance = utterance | ||
| textToSpeechEngine.play(utterance).onFailure { throwable -> | ||
| Timber.e(throwable, "Failed to play utterance '${utterance.id}'") | ||
| handleError( | ||
| applicationContext.getString(R.string.tts_error_utterance, utterance.text), | ||
| ) | ||
| } | ||
| currentUtterance = null | ||
| } | ||
| } catch (e: CancellationException) { | ||
| if (isTransientLoss) { | ||
| currentUtterance?.let { | ||
| utteranceQueue.addFirst(it) | ||
| } | ||
| } | ||
| throw e | ||
| } finally { | ||
| textToSpeechEngine.release() | ||
| if (utteranceQueue.isEmpty()) { | ||
| abandonAudioFocus() | ||
| } | ||
| } | ||
| textToSpeechEngine.release() | ||
| } | ||
| isPlaying = false | ||
| } | ||
|
|
||
| private fun handleError(msg: String) { | ||
|
|
@@ -136,7 +290,10 @@ class TextToSpeechClient(private val applicationContext: Context, private val te | |
| .setUsage(AudioAttributes.USAGE_ALARM) | ||
| .build() | ||
| } else { | ||
| AudioAttributes.Builder().build() | ||
| AudioAttributes.Builder() | ||
| .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) | ||
| .setUsage(AudioAttributes.USAGE_MEDIA) | ||
| .build() | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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?