Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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

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 = 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?

playbackJob = null
textToSpeechEngine.release()
}
}
}
}

/**
* Queues a text message to be played back if [data] with a [TextToSpeechData.TTS_TEXT] key is provided.
Expand Down Expand Up @@ -57,7 +96,7 @@ class TextToSpeechClient(private val applicationContext: Context, private val te
),
)
if (!isPlaying) {
play()
startPlayback()
}
}
}
Expand All @@ -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

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.

}
}
}

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

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

isPlaying = true
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.

Timber.e("Audio focus request denied")
handleError(applicationContext.getString(R.string.tts_error_focus_denied))
utteranceQueue.clear()
abandonAudioFocus()
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.

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

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?


if (!regained) {
Timber.e("Timed out waiting for initial audio focus")
handleError(applicationContext.getString(R.string.tts_error_focus_denied))
utteranceQueue.clear()
abandonAudioFocus()
return
}
}
Comment thread
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.

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.

// Adding an initial empty utterance seems to fix this.
// Testing shows this is more effective than utilizing the
// textToSpeech.playSilentUtterance method.
if (utteranceQueue.isNotEmpty()) {
Comment thread
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

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.


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) {
Expand All @@ -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 (
Expand All @@ -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()
}
}
}
Expand Down
1 change: 1 addition & 0 deletions common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,7 @@
<string name="trust_server_summary">Allow this server to enable and manage sensors and send notification commands</string>
<string name="tts_error_utterance">Unable to process notification \"%1$s\" as text to speech.</string>
<string name="tts_error_init">Failed to initialize a text to speech engine.</string>
<string name="tts_error_focus_denied">Could not obtain audio focus for text to speech.</string>
<string name="tts_no_text">Please set the text for text to speech to process</string>
<string name="unknown_address">Unknown address</string>
<string name="update_shortcut">Update shortcut data</string>
Expand Down
Loading
Loading