Files
PinPoint/app/src/main/java/com/example/jnicpp/bowling/AudioFeedbackEngine.kt
T
Khalil Belabadia 9fcdc13699 Audio feedback update
Added the following files :

- AudioCue.kt
- AudioFeedbackEngine
- AudioFeedbackSettings

Edited files :

- BowlingCameraActivity (added the triggerAudiofeedback function)
2026-09-07 22:16:00 +08:00

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")
}
}