Skip to content
Draft
Changes from 1 commit
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,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

Expand All @@ -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()

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()
}
Comment thread
ChadKillingsworth marked this conversation as resolved.
Outdated
Comment thread
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.
Expand Down Expand Up @@ -57,7 +95,7 @@ class TextToSpeechClient(private val applicationContext: Context, private val te
),
)
if (!isPlaying) {
play()
startPlayback()
}
}
}
Expand All @@ -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

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

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

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...")
hasFocus.first { it }
}
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
// textToSpeach.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...")
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) {
Expand Down Expand Up @@ -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()
}
}
}
Expand Down
Loading