/** * @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") } }