-
Notifications
You must be signed in to change notification settings - Fork 641
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fixed TTS queuing mechanism and volume override resets (#4480)
fixed TTS queuing mechanism and volume override resets A new TextToSpeechEngine abstraction has been introduced, along with a default Android TTS implementation and a TextToSpeechClient as the entry point. The client now features an independent queue, allowing for better control over the start and finish of each utterance and separating it from the engine, which focuses solely on playback. This resolves issues with interrupting utterances and volume overrides not resetting correctly when utterances are queued or force-stopped.
- Loading branch information
1 parent
851b1c7
commit 2281e64
Showing
8 changed files
with
369 additions
and
127 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 0 additions & 114 deletions
114
common/src/main/java/io/homeassistant/companion/android/common/util/TextToSpeech.kt
This file was deleted.
Oops, something went wrong.
89 changes: 89 additions & 0 deletions
89
...main/java/io/homeassistant/companion/android/common/util/tts/AndroidTextToSpeechEngine.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
package io.homeassistant.companion.android.common.util.tts | ||
|
||
import android.content.Context | ||
import android.speech.tts.TextToSpeech | ||
import android.speech.tts.UtteranceProgressListener | ||
import android.util.Log | ||
import kotlin.coroutines.resume | ||
import kotlinx.coroutines.suspendCancellableCoroutine | ||
import kotlinx.coroutines.sync.Mutex | ||
import kotlinx.coroutines.sync.withLock | ||
|
||
private const val TAG = "AndroidTTSEngine" | ||
|
||
/** | ||
* Implementation of [TextToSpeechEngine] that uses the default [TextToSpeech] engine found on the device. | ||
*/ | ||
class AndroidTextToSpeechEngine(private val applicationContext: Context) : TextToSpeechEngine { | ||
|
||
private val initMutex = Mutex() | ||
private var textToSpeech: TextToSpeech? = null | ||
private var lastVolumeOverridingUtterance: Utterance? = null | ||
|
||
override suspend fun initialize(): Result<Unit> = initMutex.withLock { | ||
if (textToSpeech != null) { | ||
Result.success(Unit) | ||
} else { | ||
suspendCancellableCoroutine { continuation -> | ||
textToSpeech = TextToSpeech(applicationContext) { code -> | ||
if (code == TextToSpeech.SUCCESS) { | ||
continuation.resume(Result.success(Unit)) | ||
} else { | ||
textToSpeech?.shutdown() | ||
textToSpeech = null | ||
continuation.resume( | ||
Result.failure(RuntimeException("Failed to initialize TTS client. Code: $code.")) | ||
) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
override suspend fun play(utterance: Utterance): Result<Unit> { | ||
val textToSpeech = initMutex.withLock { textToSpeech } | ||
return suspendCancellableCoroutine { continuation -> | ||
if (textToSpeech == null) { | ||
continuation.resume(Result.failure(IllegalStateException("TextToSpeechEngine not initialized."))) | ||
} else { | ||
textToSpeech.setAudioAttributes(utterance.audioAttributes) | ||
val listener = object : UtteranceProgressListener() { | ||
override fun onStart(p0: String?) { | ||
utterance.streamVolumeAdjustment.overrideVolume() | ||
lastVolumeOverridingUtterance = utterance | ||
} | ||
|
||
override fun onDone(p0: String?) { | ||
Log.d(TAG, "Done speaking; utterance ID: $p0") | ||
utterance.streamVolumeAdjustment.resetVolume() | ||
continuation.resume(Result.success(Unit)) | ||
} | ||
|
||
@Deprecated("Deprecated in Java") | ||
override fun onError(utteranceId: String?) { | ||
utterance.streamVolumeAdjustment.resetVolume() | ||
continuation.resume(Result.failure(RuntimeException("Playback error; utterance ID: $utteranceId"))) | ||
} | ||
|
||
override fun onError(utteranceId: String?, errorCode: Int) { | ||
utterance.streamVolumeAdjustment.resetVolume() | ||
continuation.resume(Result.failure(RuntimeException("Playback error; utterance ID: $utteranceId; error code: $errorCode"))) | ||
} | ||
} | ||
textToSpeech.setOnUtteranceProgressListener(listener) | ||
textToSpeech.speak(utterance.text, TextToSpeech.QUEUE_FLUSH, null, utterance.id) | ||
Log.d(TAG, "Speaking; utterance ID: ${utterance.id}") | ||
} | ||
} | ||
} | ||
|
||
override fun release() { | ||
if (textToSpeech?.isSpeaking == true) { | ||
// resets the volume back if the playback was interrupted | ||
lastVolumeOverridingUtterance?.streamVolumeAdjustment?.resetVolume() | ||
} | ||
textToSpeech?.stop() | ||
textToSpeech?.shutdown() | ||
textToSpeech = null | ||
} | ||
} |
106 changes: 106 additions & 0 deletions
106
common/src/main/java/io/homeassistant/companion/android/common/util/tts/TextToSpeech.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
package io.homeassistant.companion.android.common.util.tts | ||
|
||
import android.media.AudioAttributes | ||
import android.media.AudioManager | ||
|
||
object TextToSpeechData { | ||
const val TTS = "TTS" | ||
const val TTS_TEXT = "tts_text" | ||
|
||
const val COMMAND_STOP_TTS = "command_stop_tts" | ||
} | ||
|
||
/** | ||
* Interface for a text to speech engine. | ||
*/ | ||
interface TextToSpeechEngine { | ||
|
||
/** | ||
* Suspends until the engine is initialized. | ||
* | ||
* If already initialized, a successful [Result] returns immediately. | ||
* | ||
* @return success or initialization error [Throwable] | ||
*/ | ||
suspend fun initialize(): Result<Unit> | ||
|
||
/** | ||
* Suspends until the engine finishes the playback. | ||
* | ||
* @return success or playback error [Throwable] | ||
*/ | ||
suspend fun play(utterance: Utterance): Result<Unit> | ||
|
||
/** | ||
* Stops all playback and releases engines resources. | ||
*/ | ||
fun release() | ||
} | ||
|
||
/** | ||
* Data model for an utterance to be played. | ||
* | ||
* @param id a unique identifier | ||
* @param text message to be synthesized | ||
* @param streamVolumeAdjustment utility object to adjust the volume ahead of this utterance's playback, | ||
* and reset it back after it's finished | ||
* @param audioAttributes attributes to be set for the media player responsible for the audio playback | ||
*/ | ||
data class Utterance( | ||
val id: String, | ||
val text: String, | ||
val streamVolumeAdjustment: StreamVolumeAdjustment, | ||
val audioAttributes: AudioAttributes | ||
) | ||
|
||
/** | ||
* Utility object to adjust the volume ahead of this utterance's playback, and reset it back after it's finished. | ||
*/ | ||
sealed class StreamVolumeAdjustment { | ||
|
||
/** | ||
* Applies volume adjustment. | ||
*/ | ||
abstract fun overrideVolume() | ||
|
||
/** | ||
* Resets the volume back to pre-adjustment levels. Does nothing if [overrideVolume] wasn't called before. | ||
*/ | ||
abstract fun resetVolume() | ||
|
||
/** | ||
* Object that does no adjustments to audio stream's volume level. | ||
*/ | ||
data object None : StreamVolumeAdjustment() { | ||
override fun overrideVolume() { | ||
// no-op | ||
} | ||
|
||
override fun resetVolume() { | ||
// no-op | ||
} | ||
} | ||
|
||
/** | ||
* Object that maximizes the volume of a specific [streamId]. | ||
*/ | ||
class Maximize( | ||
private val audioManager: AudioManager, | ||
private val streamId: Int | ||
) : StreamVolumeAdjustment() { | ||
private val maxVolume: Int = audioManager.getStreamMaxVolume(streamId) | ||
private var resetVolume: Int? = null | ||
|
||
override fun overrideVolume() { | ||
resetVolume = audioManager.getStreamVolume(streamId) | ||
audioManager.setStreamVolume(streamId, maxVolume, 0) | ||
} | ||
|
||
override fun resetVolume() { | ||
resetVolume?.let { volume -> | ||
audioManager.setStreamVolume(streamId, volume, 0) | ||
} | ||
resetVolume = null | ||
} | ||
} | ||
} |
Oops, something went wrong.