-
-
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 all commits
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,14 +5,21 @@ 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 kotlinx.coroutines.withTimeoutOrNull | ||
| import timber.log.Timber | ||
|
|
||
| /** | ||
|
|
@@ -30,6 +37,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") | ||
| hasFocus.value = false | ||
| stopTTS() | ||
| } | ||
| AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { | ||
| Timber.d("Audio focus lost temporarily") | ||
| hasFocus.value = false | ||
| isTransientLoss = true | ||
| playbackJob?.cancel() | ||
|
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. So if it's transient you kill the playbackJob? when it restarts it restarts from the beginning? |
||
| playbackJob = null | ||
| textToSpeechEngine.release() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Queues a text message to be played back if [data] with a [TextToSpeechData.TTS_TEXT] key is provided. | ||
|
|
@@ -57,7 +96,7 @@ class TextToSpeechClient(private val applicationContext: Context, private val te | |
| ), | ||
| ) | ||
| if (!isPlaying) { | ||
| play() | ||
| startPlayback() | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -67,39 +106,186 @@ 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() | ||
| hasFocus.value = false | ||
| 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) | ||
| .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 | ||
| try { | ||
| val request = focusRequest | ||
| if (request != null) { | ||
| 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 | ||
| val focusGranted = requestAudioFocus() | ||
|
|
||
| if (!focusGranted && !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.e("Audio focus request denied") | ||
| handleError(applicationContext.getString(R.string.tts_error_focus_denied)) | ||
| utteranceQueue.clear() | ||
| abandonAudioFocus() | ||
| return | ||
| } | ||
|
|
||
| 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...") | ||
| val regained = withTimeoutOrNull(FOCUS_TIMEOUT_MS) { | ||
| hasFocus.first { it } | ||
| true | ||
| } ?: false | ||
|
Comment on lines
+210
to
+213
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. 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.
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. 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? |
||
|
|
||
| if (!regained) { | ||
| Timber.e("Timed out waiting for initial audio focus") | ||
| handleError(applicationContext.getString(R.string.tts_error_focus_denied)) | ||
| utteranceQueue.clear() | ||
| abandonAudioFocus() | ||
| return | ||
| } | ||
| } | ||
|
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 | ||
| // textToSpeech.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...") | ||
| val regained = withTimeoutOrNull(FOCUS_TIMEOUT_MS) { | ||
| hasFocus.first { it } | ||
| true | ||
| } ?: false | ||
|
Comment on lines
+251
to
+254
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. Let's avoid duplication. |
||
|
|
||
| if (!regained) { | ||
| Timber.e("Timed out waiting to regain audio focus") | ||
| handleError(applicationContext.getString(R.string.tts_error_focus_denied)) | ||
| utteranceQueue.clear() | ||
| abandonAudioFocus() | ||
| return@onSuccess | ||
| } | ||
| } | ||
|
|
||
| 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) { | ||
|
|
@@ -113,6 +299,8 @@ class TextToSpeechClient(private val applicationContext: Context, private val te | |
| } | ||
|
|
||
| private companion object { | ||
| private const val FOCUS_TIMEOUT_MS = 10000L | ||
|
|
||
| private fun getStreamVolumeAdjustment(context: Context, data: Map<String, String>): StreamVolumeAdjustment { | ||
| val audioManager = context.getSystemService<AudioManager>() | ||
| return if ( | ||
|
|
@@ -136,7 +324,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.
Why not also calling
stopTTS?