Added the following files : - AudioCue.kt - AudioFeedbackEngine - AudioFeedbackSettings Edited files : - BowlingCameraActivity (added the triggerAudiofeedback function)
129 lines
4.8 KiB
Kotlin
129 lines
4.8 KiB
Kotlin
/**
|
|
* @file AudioFeedbackEngine.kt
|
|
* @brief TextToSpeech-backed engine for delivering spoken coaching cues.
|
|
*/
|
|
package com.example.jnicpp.bowling
|
|
|
|
import android.content.Context
|
|
import android.os.Bundle
|
|
import android.speech.tts.TextToSpeech
|
|
import android.util.Log
|
|
import java.util.Locale
|
|
|
|
/**
|
|
* @brief Delivers spoken [AudioCue]s to the user during a live bowling session.
|
|
*
|
|
* Wraps Android's [TextToSpeech] and adds:
|
|
* - A [QueuePolicy]-driven contention model (interrupt / queue / discard).
|
|
* - Per-call respect for [AudioFeedbackSettings.isMuted] and
|
|
* [AudioFeedbackSettings.volume], so a mute or volume change anywhere in
|
|
* the app takes effect on the very next cue without restarting the engine.
|
|
*
|
|
* **Lifecycle**: TTS initialisation is asynchronous. Any [speak] calls
|
|
* arriving before initialisation completes are silently dropped -- the first
|
|
* few frames of a session are not worth queuing since the bowler is still in
|
|
* their starting stance. Call [release] from `Activity.onDestroy` to stop
|
|
* in-progress speech and shut TTS down cleanly.
|
|
*
|
|
* **Threading**: [speak] and [release] must be called on the **main thread**,
|
|
* matching how ML Kit delivers [PoseAnalyzer] results (see [PoseAnalyzer]'s
|
|
* class doc). TTS internally dispatches synthesis to its own thread.
|
|
*
|
|
* @param context Application context used to construct [TextToSpeech].
|
|
* @param settings Live audio preferences; read on every [speak] call.
|
|
*/
|
|
class AudioFeedbackEngine(
|
|
context: Context,
|
|
private val settings: AudioFeedbackSettings
|
|
) {
|
|
|
|
companion object {
|
|
private const val TAG = "AudioFeedbackEngine"
|
|
}
|
|
|
|
// Null until init { ... } assigns it; never null after that.
|
|
private var tts: TextToSpeech? = null
|
|
|
|
// Set true only once TTS signals SUCCESS -- guards against speak() calls
|
|
// arriving before the engine is usable.
|
|
@Volatile
|
|
private var ready = false
|
|
|
|
init {
|
|
tts = TextToSpeech(context.applicationContext) { status ->
|
|
if (status == TextToSpeech.SUCCESS) {
|
|
// setLanguage is safe here: the callback is delivered on the
|
|
// main thread, and tts is already assigned by this point.
|
|
val result = tts?.setLanguage(Locale.getDefault())
|
|
if (result == TextToSpeech.LANG_MISSING_DATA ||
|
|
result == TextToSpeech.LANG_NOT_SUPPORTED
|
|
) {
|
|
Log.w(TAG, "TTS locale not supported: ${Locale.getDefault()}, falling back to ENGLISH")
|
|
tts?.setLanguage(Locale.ENGLISH)
|
|
}
|
|
ready = true
|
|
Log.d(TAG, "TTS initialized")
|
|
} else {
|
|
Log.w(TAG, "TTS initialization failed (status=$status); audio feedback disabled")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Speaks [cue] according to [policy].
|
|
*
|
|
* Silently no-ops when:
|
|
* - TTS has not finished initialising yet.
|
|
* - [AudioFeedbackSettings.isMuted] is true.
|
|
* - [policy] is [QueuePolicy.DISCARD_IF_BUSY] and TTS is already speaking.
|
|
*
|
|
* [AudioFeedbackSettings.volume] is applied as TTS's `KEY_PARAM_VOLUME`
|
|
* parameter so it scales within the system stream volume rather than
|
|
* replacing it.
|
|
*
|
|
* @param cue The coaching cue to speak.
|
|
* @param policy How to handle contention with an already-playing cue.
|
|
* Defaults to [QueuePolicy.DISCARD_IF_BUSY] to prevent cue
|
|
* spam across many consecutive frames with the same issue.
|
|
*/
|
|
fun speak(cue: AudioCue, policy: QueuePolicy = QueuePolicy.DISCARD_IF_BUSY) {
|
|
if (!ready) return
|
|
if (settings.isMuted) return
|
|
|
|
val engine = tts ?: return
|
|
|
|
val queueMode: Int = when (policy) {
|
|
QueuePolicy.INTERRUPT -> TextToSpeech.QUEUE_FLUSH
|
|
QueuePolicy.QUEUE -> TextToSpeech.QUEUE_ADD
|
|
QueuePolicy.DISCARD_IF_BUSY -> {
|
|
if (engine.isSpeaking) return
|
|
TextToSpeech.QUEUE_ADD
|
|
}
|
|
}
|
|
|
|
val params = Bundle().apply {
|
|
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, settings.volume)
|
|
}
|
|
|
|
// utteranceId lets TTS callbacks (if added later) identify which cue
|
|
// finished. Using the simple class name avoids UUID allocation per frame.
|
|
engine.speak(cue.text, queueMode, params, cue::class.java.simpleName)
|
|
}
|
|
|
|
/**
|
|
* @brief Stops in-progress speech, flushes the TTS queue, and shuts the
|
|
* engine down. Must be called from `Activity.onDestroy`.
|
|
*
|
|
* After this call, [speak] will silently no-op (because [ready] is false
|
|
* again), so it is safe to call [release] before all references to this
|
|
* engine are dropped.
|
|
*/
|
|
fun release() {
|
|
ready = false
|
|
tts?.stop()
|
|
tts?.shutdown()
|
|
tts = null
|
|
Log.d(TAG, "TTS released")
|
|
}
|
|
}
|