Skip to content

Commit

Permalink
If TTS speak call fails, restart TTS and retry all messages in queue
Browse files Browse the repository at this point in the history
  • Loading branch information
pilot51 committed Jan 1, 2024
1 parent 467a088 commit f471efb
Show file tree
Hide file tree
Showing 5 changed files with 88 additions and 66 deletions.
1 change: 1 addition & 0 deletions app/src/main/java/com/pilot51/voicenotify/IgnoreReason.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ enum class IgnoreReason(
EMPTY_MSG(R.string.reason_empty_msg),
IDENTICAL(R.string.reason_identical),
TTS_FAILED(R.string.reason_tts_failed),
TTS_RESTARTED(R.string.reason_tts_restarted),
TTS_INTERRUPTED(R.string.reason_tts_interrupted),
TTS_LENGTH_LIMIT(R.string.reason_tts_length_limit);

Expand Down
49 changes: 13 additions & 36 deletions app/src/main/java/com/pilot51/voicenotify/NotificationInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@ data class NotificationInfo(
private val contentInfoText: String?
/** Calendar representing the time that this instance of NotificationInfo was created. */
val calendar: Calendar
/** Set of reasons this notification was ignored. */
private val ignoreReasons: MutableSet<IgnoreReason> = HashSet()
/** Indicates if the notification was silenced (interrupted). */
var isSilenced = false
private set
/** Set of reasons this notification was ignored, or an empty set if not ignored. */
val ignoreReasons = linkedSetOf<IgnoreReason>()
/**
* Indicates if the notification was interrupted during speech.
* Used to set the color of the ignore reasons in [NotifyList].
* Set to `true` to make the ignore message yellow.
* Default is red for never spoken.
*/
var isInterrupted = false
/** The Ignore Repeats setting in seconds, set by [.addIgnoreReasonIdentical]. */
private var ignoreRepeatSeconds = -1
/** The message that was or shall be spoken. */
Expand Down Expand Up @@ -139,6 +143,10 @@ data class NotificationInfo(
*/
fun getIgnoreReasonsAsText(): String {
var text = ignoreReasons.joinToString()
// If message ends with period and isn't last, replaces comma after it with newline
.replace("., ", ".\n")
// If message ends with period and isn't first, replaces comma before it with newline
.replace(Regex(", (.+\\.)$"), "\n$1")
if (ignoreReasons.contains(IgnoreReason.IDENTICAL)) {
text = MessageFormat.format(text, ignoreRepeatSeconds)
}
Expand All @@ -152,14 +160,6 @@ data class NotificationInfo(
val time: String
get() = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).format(calendar.time)

/**
* Used to indicate the color of the ignore reasons in [NotifyList].
* Call this to set it yellow for silenced (interrupted). Default is red for never spoken.
*/
fun setSilenced() {
isSilenced = true
}

/**
* Set this notification as ignored for being identical to a previous notification within the configured time
* @param ignoreRepeatTime The time in seconds that identical notifications are not to be spoken.
Expand All @@ -169,29 +169,6 @@ data class NotificationInfo(
ignoreReasons.add(IgnoreReason.IDENTICAL)
}

/**
* Add a reason this notification was ignored.
* @param reason The ignore reason to add.
*/
fun addIgnoreReason(reason: IgnoreReason) {
ignoreReasons.add(reason)
}

/**
* Add a set of reasons this notification was ignored.
* @param reasons The ignore reasons to add.
*/
fun addIgnoreReasons(reasons: Set<IgnoreReason>?) {
ignoreReasons.addAll(reasons!!)
}

/**
* @return Set of reasons this notification was ignored, or an empty set if not ignored.
*/
fun getIgnoreReasons(): Set<IgnoreReason> {
return ignoreReasons
}

// For some reason this is needed to force list state update with simple object copy
override fun equals(other: Any?) = super.equals(other)
override fun hashCode() = super.hashCode()
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/pilot51/voicenotify/NotifyList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,11 @@ private fun Item(item: NotificationInfo) {
textAlign = TextAlign.Center
)
}
if (item.getIgnoreReasons().isNotEmpty()) {
if (item.ignoreReasons.isNotEmpty()) {
Text(
text = item.getIgnoreReasonsAsText(),
modifier = Modifier.fillMaxWidth(),
color = if (item.isSilenced) Color.Yellow else Color.Red,
color = if (item.isInterrupted) Color.Yellow else Color.Red,
textAlign = TextAlign.Center
)
}
Expand Down
97 changes: 70 additions & 27 deletions app/src/main/java/com/pilot51/voicenotify/Service.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ import kotlinx.coroutines.launch
import java.util.*

class Service : NotificationListenerService() {
private val lastMsg: MutableMap<App?, String?> = HashMap()
private val lastMsgTime: MutableMap<App?, Long> = HashMap()
private val lastMsg = mutableMapOf<App?, String?>()
private val lastMsgTime = mutableMapOf<App?, Long>()
private var tts: TextToSpeech? = null
private var shouldRequestFocus = false
private lateinit var audioMan: AudioManager
Expand All @@ -96,6 +96,10 @@ class Service : NotificationListenerService() {
.setLegacyStreamType(AudioManager.STREAM_MUSIC).build())
.build()
} else null
private val ttsParams: Bundle
get() = Bundle().apply {
putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, getSelectedAudioStream())
}

/**
* this is used to determine if we are the first, middle, or last thing to be spoken at the moment, for enabling/disabling shake and audio focus request
Expand All @@ -104,13 +108,13 @@ class Service : NotificationListenerService() {
* if the list is empty when we finish speaking a message, we untrigger them.
*/
@SuppressLint("UseSparseArrays")
private val ttsQueue: MutableMap<Long, NotificationInfo> = HashMap()
private val ttsQueue = linkedMapOf<Long, NotificationInfo>()
private val statusListener = object : OnStatusChangeListener {
override fun onStatusChanged() {
if (isSuspended.value && tts != null) {
synchronized(ttsQueue) {
for (info in ttsQueue.values) {
info.addIgnoreReason(IgnoreReason.SUSPENDED)
info.ignoreReasons.add(IgnoreReason.SUSPENDED)
}
}
tts!!.stop()
Expand All @@ -119,8 +123,15 @@ class Service : NotificationListenerService() {
}

override fun onCreate() {
initTts()
super.onCreate()
}

private fun initTts(onInit: (() -> Unit)? = null) {
tts = TextToSpeech(applicationContext, OnInitListener { status ->
if (status != TextToSpeech.SUCCESS) {
if (status == TextToSpeech.SUCCESS) {
onInit?.invoke()
} else {
tts = null
val errorMsg = getString(R.string.error_tts_init, status)
Log.w(TAG, errorMsg)
Expand All @@ -133,9 +144,9 @@ class Service : NotificationListenerService() {
if (interrupted) {
val info = ttsQueue[utteranceId.toLong()]
if (info != null) {
info.setSilenced()
if (info.getIgnoreReasons().isEmpty()) {
info.addIgnoreReason(IgnoreReason.TTS_INTERRUPTED)
info.isInterrupted = true
if (info.ignoreReasons.isEmpty()) {
info.ignoreReasons.add(IgnoreReason.TTS_INTERRUPTED)
}
NotifyList.updateInfo(info)
}
Expand Down Expand Up @@ -165,7 +176,38 @@ class Service : NotificationListenerService() {
}
})
})
super.onCreate()
}

private fun restartTts() {
synchronized(ttsQueue) {
ttsQueue.values.forEach {
if (it.ignoreReasons.contains(IgnoreReason.TTS_FAILED)) return@forEach
it.ignoreReasons.add(IgnoreReason.TTS_RESTARTED)
it.isInterrupted = true
NotifyList.updateInfo(it)
}
}
tts?.shutdown()
initTts {
synchronized(ttsQueue) {
val queueIterator = ttsQueue.iterator()
queueIterator.forEach {
val info = it.value
val isFailed = tts!!.speak(
info.ttsMessage, TextToSpeech.QUEUE_ADD, ttsParams, it.key.toString()
) != TextToSpeech.SUCCESS
if (isFailed) {
Log.e(TAG, "Error adding notification to queue after TTS restart. Not retrying again.")
info.ignoreReasons.add(IgnoreReason.TTS_FAILED)
info.isInterrupted = false
queueIterator.remove()
} else if (info.ignoreReasons.contains(IgnoreReason.TTS_FAILED)) {
info.isInterrupted = true
}
NotifyList.updateInfo(info)
}
}
}
}

override fun onNotificationPosted(sbn: StatusBarNotification) {
Expand All @@ -179,26 +221,26 @@ class Service : NotificationListenerService() {
val msgTime = info.calendar.timeInMillis
val ttsMsg = info.ttsMessage
if (app != null && !app.enabled) {
info.addIgnoreReason(IgnoreReason.APP)
info.ignoreReasons.add(IgnoreReason.APP)
}
if (ttsMsg.isNullOrEmpty()
&& prefs.getBoolean(KEY_IGNORE_EMPTY, DEFAULT_IGNORE_EMPTY)) {
info.addIgnoreReason(IgnoreReason.EMPTY_MSG)
info.ignoreReasons.add(IgnoreReason.EMPTY_MSG)
}
if (ttsMsg != null) {
val requireStrings = prefs.getString(KEY_REQUIRE_STRINGS, null)?.split("\n")
val stringRequired = requireStrings?.all {
it.isNotEmpty() && !ttsMsg.contains(it, true)
} ?: false
if (stringRequired) {
info.addIgnoreReason(IgnoreReason.STRING_REQUIRED)
info.ignoreReasons.add(IgnoreReason.STRING_REQUIRED)
}
val ignoreStrings = prefs.getString(KEY_IGNORE_STRINGS, null)?.split("\n")
val stringIgnored = ignoreStrings?.any {
it.isNotEmpty() && ttsMsg.contains(it, true)
} ?: false
if (stringIgnored) {
info.addIgnoreReason(IgnoreReason.STRING_IGNORED)
info.ignoreReasons.add(IgnoreReason.STRING_IGNORED)
}
}
val ignoreRepeat = try {
Expand All @@ -214,7 +256,7 @@ class Service : NotificationListenerService() {
}
}
NotifyList.addNotification(info)
if (info.getIgnoreReasons().isEmpty()) {
if (info.ignoreReasons.isEmpty()) {
val delay = try {
prefs.getString(KEY_TTS_DELAY, null)
?.takeIf { it.isNotEmpty() }?.toDouble()?.takeUnless { it < 0.0 } ?: 0.0
Expand Down Expand Up @@ -243,7 +285,7 @@ class Service : NotificationListenerService() {
if (ignoreReasons.isNotEmpty()) {
Log.i(TAG, "Notification ignored for reason(s): "
+ ignoreReasons.joinToString())
info.addIgnoreReasons(ignoreReasons)
info.ignoreReasons.addAll(ignoreReasons)
return
}
CoroutineScope(Dispatchers.Main).launch {
Expand All @@ -268,10 +310,14 @@ class Service : NotificationListenerService() {
private fun speak(info: NotificationInfo) {
if (tts == null) {
Log.w(TAG, "Speak failed due to service destroyed")
info.addIgnoreReason(IgnoreReason.SERVICE_STOPPED)
info.ignoreReasons.add(IgnoreReason.SERVICE_STOPPED)
NotifyList.updateInfo(info)
return
}
if ((info.ttsMessage?.length ?: 0) > TextToSpeech.getMaxSpeechInputLength()) {
info.ignoreReasons.add(IgnoreReason.TTS_LENGTH_LIMIT)
info.isInterrupted = true
}
val notificationTime = info.calendar.timeInMillis
synchronized(ttsQueue) {
if (ttsQueue.isEmpty()) { //if there are no messages in the queue, start up shake detection and audio focus requesting
Expand All @@ -291,21 +337,18 @@ class Service : NotificationListenerService() {
ttsQueue.put(notificationTime, info)
}
//once the message is in our queue, send it to the real one with the necessary parameters
val audioStream = getSelectedAudioStream()
val utteranceId = notificationTime.toString()
val ttsParams = Bundle()
ttsParams.putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, audioStream)
val isSpeakFailed = tts!!.speak(
info.ttsMessage, TextToSpeech.QUEUE_ADD, ttsParams, utteranceId
) != TextToSpeech.SUCCESS
if (isSpeakFailed) {
Log.e(TAG, "Error adding notification to TTS queue")
info.addIgnoreReason(IgnoreReason.TTS_FAILED)
synchronized(ttsQueue) { ttsQueue.remove(notificationTime) }
Log.e(TAG, "Error adding notification to TTS queue. Attempting to restart TTS.")
info.ignoreReasons.add(IgnoreReason.TTS_FAILED)
info.isInterrupted = false
restartTts()
}
if ((info.ttsMessage?.length ?: 0) > TextToSpeech.getMaxSpeechInputLength()) {
info.addIgnoreReason(IgnoreReason.TTS_LENGTH_LIMIT)
if (!isSpeakFailed) info.setSilenced()
if (info.ignoreReasons.isNotEmpty()) {
NotifyList.updateInfo(info)
}
}

Expand Down Expand Up @@ -393,7 +436,7 @@ class Service : NotificationListenerService() {
Log.i(TAG, "TTS silenced by shake")
synchronized(ttsQueue) {
for (info in ttsQueue.values) {
info.addIgnoreReason(IgnoreReason.SHAKE)
info.ignoreReasons.add(IgnoreReason.SHAKE)
NotifyList.updateInfo(info)
}
}
Expand Down Expand Up @@ -501,7 +544,7 @@ class Service : NotificationListenerService() {
+ ignoreReasons.joinToString())
synchronized(ttsQueue) {
for (info in ttsQueue.values) {
info.addIgnoreReasons(ignoreReasons)
info.ignoreReasons.addAll(ignoreReasons)
}
}
tts!!.stop()
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@
<string name="reason_shake">Silenced by shake</string>
<string name="reason_suspended">Voice Notify suspended by widget</string>
<string name="reason_service_stopped">Service stopped</string>
<string name="reason_tts_failed">TTS failed. Please try restarting the Voice Notify service.</string>
<string name="reason_tts_failed">TTS failed. Restart and retry attempted.\nThis text should be yellow if retry successful or red if not.\nPlease try restarting the Voice Notify service if notifications fail to be spoken.</string>
<string name="reason_tts_restarted">TTS restarted while message in queue or speaking. Re-queued.</string>
<string name="reason_tts_interrupted">TTS interrupted for unknown reason</string>
<string name="reason_tts_length_limit">Message exceeded TTS length limit</string>
</resources>

0 comments on commit f471efb

Please sign in to comment.