Merge branch 'Gabriel' into merge-testing

This commit is contained in:
Gabriel Low
2026-09-11 11:18:35 +08:00
9 changed files with 473 additions and 4 deletions
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
@@ -0,0 +1,66 @@
/**
* @file AudioCue.kt
* @brief Spoken coaching cues and queue policy for [AudioFeedbackEngine].
*/
package com.example.jnicpp.bowling
/**
* @brief A spoken coaching cue delivered by [AudioFeedbackEngine].
*
* [text] is handed verbatim to TTS. [priority] is informational for callers
* deciding which [QueuePolicy] to apply, higher numbers are more urgent
* (e.g. a timing correction mid-approach outranks a general encouragement).
*
* Extend this sealed class to add new domain-specific cues without touching
* the engine; the engine only cares about [text] and the policy the caller
* chooses.
*/
sealed class AudioCue(val text: String, val priority: Int) {
// -- Arm / elbow cues (medium priority) --
/** Elbow angle too acute -- arm is over-bent during swing. */
object StraightenArm : AudioCue("Straighten your bowling arm", priority = 5)
/** Elbow locking out too early, arm rigid before release point. */
object BendElbow : AudioCue("Bend your elbow slightly", priority = 5)
// -- Shoulder cues (medium priority) --
/** Bowling-side shoulder rising, disrupting swing plane. */
object LowerShoulder : AudioCue("Keep your shoulder down", priority = 5)
/** Shoulder plane collapsing -- both shoulders dropping together. */
object LevelShoulders : AudioCue("Level your shoulders", priority = 5)
// -- Approach / timing cues (higher priority) --
/** Approach tempo too fast; bowler rushing the delivery. */
object SlowDown : AudioCue("Slow down your approach", priority = 7)
/** Positive reinforcement -- form looks good this frame. */
object GoodForm : AudioCue("Good form, keep it up", priority = 3)
// -- Escape hatch for one-off or dynamically constructed messages --
/**
* @brief Arbitrary spoken message not covered by the predefined cues above.
* @param message The text to speak.
* @param p Priority; defaults to medium (5).
*/
data class Custom(val message: String, val p: Int = 5) : AudioCue(message, p)
}
/**
* @brief Controls how [AudioFeedbackEngine] handles a new cue when TTS is already busy.
*
* Choose the policy at the call site based on how time-sensitive the cue is:
*
* | Policy | Behaviour |
* |-------------------|------------------------------------------------------------------|
* | [INTERRUPT] | Flushes the TTS queue and speaks immediately. Use for urgent |
* | | corrections that must be heard right now. |
* | [QUEUE] | Appended after whatever is currently playing. Use for cues |
* | | that can wait their turn (e.g. a sequence of tips post-throw). |
* | [DISCARD_IF_BUSY] | Silently dropped if TTS is already speaking. The default for |
* | | live per-frame cues, preventing the same note from stacking up |
* | | across many frames while TTS works through an earlier one. |
*/
enum class QueuePolicy {
INTERRUPT,
QUEUE,
DISCARD_IF_BUSY
}
@@ -0,0 +1,128 @@
/**
* @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")
}
}
@@ -0,0 +1,54 @@
/**
* @file AudioFeedbackSettings.kt
* @brief SharedPreferences-backed user preferences for audio coaching feedback.
*/
package com.example.jnicpp.bowling
import android.content.Context
/**
* @brief Persisted audio-feedback preferences: mute toggle and volume level.
*
* Reads and writes go straight to SharedPreferences via `apply()` (async
* disk write), so property access is safe on any thread. [AudioFeedbackEngine]
* reads these on every [AudioFeedbackEngine.speak] call, meaning a mute or
* volume change takes effect for the very next cue without restarting the engine.
*
* Instantiate once per app session (e.g. in the Activity that owns
* [AudioFeedbackEngine]) and pass the same instance wherever settings need
* to be read or written.
*/
class AudioFeedbackSettings(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/**
* @brief Whether all audio feedback is silenced.
*
* When true, [AudioFeedbackEngine.speak] is a no-op regardless of
* [volume]. Defaults to false (audio on).
*/
var isMuted: Boolean
get() = prefs.getBoolean(KEY_MUTED, false)
set(value) { prefs.edit().putBoolean(KEY_MUTED, value).apply() }
/**
* @brief Playback volume in [0.0, 1.0].
*
* Maps directly to TTS's `KEY_PARAM_VOLUME`; 1.0 means full stream
* volume, 0.0 is silent (distinct from [isMuted] -- a volume of 0.0
* with mute off still triggers TTS but produces no audible output,
* whereas mute suppresses the TTS call entirely). Defaults to 1.0.
* Values outside [0.0, 1.0] are clamped on write.
*/
var volume: Float
get() = prefs.getFloat(KEY_VOLUME, DEFAULT_VOLUME)
set(value) { prefs.edit().putFloat(KEY_VOLUME, value.coerceIn(0f, 1f)).apply() }
companion object {
private const val PREFS_NAME = "audio_feedback_prefs"
private const val KEY_MUTED = "muted"
private const val KEY_VOLUME = "volume"
private const val DEFAULT_VOLUME = 1.0f
}
}
@@ -62,6 +62,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
private lateinit var cameraXController: CameraXController private lateinit var cameraXController: CameraXController
private var lensFacing = CameraSelector.LENS_FACING_BACK private var lensFacing = CameraSelector.LENS_FACING_BACK
private lateinit var audioFeedbackSettings: AudioFeedbackSettings
private lateinit var audioFeedbackEngine: AudioFeedbackEngine
// Minimum gap between two audio cues, in milliseconds. Prevents the same
// coaching note from re-triggering the moment TTS finishes if the posture
// issue persists across many frames. DISCARD_IF_BUSY handles in-flight
// overlap; this cooldown handles the gap immediately after TTS goes silent.
private var lastCueMs = 0L
private val cueCooldownMs = 3_000L
// Throttled diagnostic for step-count troubleshooting: confirms whether // Throttled diagnostic for step-count troubleshooting: confirms whether
// ankles are actually clearing PoseSkeletonRenderer.MIN_LIKELIHOOD, since // ankles are actually clearing PoseSkeletonRenderer.MIN_LIKELIHOOD, since
// LiveStepDetector silently sees nothing for a foot until they do. // LiveStepDetector silently sees nothing for a foot until they do.
@@ -129,6 +139,15 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
textStepCountBig = binding.textStepCountBig, textStepCountBig = binding.textStepCountBig,
) )
feedbackUI = FeedbackUI(binding.root) feedbackUI = FeedbackUI(binding.root)
audioFeedbackSettings = AudioFeedbackSettings(applicationContext)
audioFeedbackEngine = AudioFeedbackEngine(applicationContext, audioFeedbackSettings)
// Reflect persisted mute state onto the switch before attaching the
// listener so the initial setChecked doesn't trigger the callback.
binding.switchAudio.isChecked = !audioFeedbackSettings.isMuted
binding.switchAudio.setOnCheckedChangeListener { _, isChecked ->
audioFeedbackSettings.isMuted = !isChecked
}
binding.poseOverlay.attachFeedback(feedbackUI) binding.poseOverlay.attachFeedback(feedbackUI)
binding.btnGrantPermissions.setOnClickListener { binding.btnGrantPermissions.setOnClickListener {
@@ -528,6 +547,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
override fun onPoseResult(result: PoseAnalyzer.PoseFrameResult) { override fun onPoseResult(result: PoseAnalyzer.PoseFrameResult) {
binding.poseOverlay.update(result) binding.poseOverlay.update(result)
viewModel.onPoseFrameUpdated(result.landmarks, result.angles) viewModel.onPoseFrameUpdated(result.landmarks, result.angles)
triggerAudioFeedback(result.angles)
val frameTimestampMs = System.currentTimeMillis() val frameTimestampMs = System.currentTimeMillis()
debugSessionLogger.log( debugSessionLogger.log(
@@ -554,12 +574,55 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
} }
} }
/**
* @brief Evaluates [angles] against basic form thresholds and speaks a
* coaching cue when a limit is exceeded.
*
* Only one cue is emitted per call (the highest-priority issue found
* first). A [cueCooldownMs] guard prevents the same note from
* re-firing the instant TTS goes silent after speaking it.
* [QueuePolicy.DISCARD_IF_BUSY] means any cue arriving while TTS is
* mid-sentence is silently dropped at the engine level, so live
* per-frame calls here never stack up.
*
* Angle thresholds below are starting-point estimates; they should be
* calibrated against recorded sessions once the posture-analysis
* milestone establishes target ranges per bowling phase.
*
* @param angles Joint angles computed for the current frame.
*/
private fun triggerAudioFeedback(angles: PoseAngles) {
val now = System.currentTimeMillis()
if (now - lastCueMs < cueCooldownMs) return
// Prefer the dominant (right) arm; fall back to left if right is
// not detected. Null means neither arm was reliably seen this frame.
val elbowAngle = angles.rightElbow ?: angles.leftElbow
val shoulderAngle = angles.rightShoulder ?: angles.leftShoulder
val cue: AudioCue? = when {
// Arm locked straight well before the release point.
elbowAngle != null && elbowAngle > 160f -> AudioCue.BendElbow
// Arm over-bent; disrupts swing plane and release.
elbowAngle != null && elbowAngle < 80f -> AudioCue.StraightenArm
// Bowling-side shoulder lifting during the swing.
shoulderAngle != null && shoulderAngle > 140f -> AudioCue.LowerShoulder
else -> null
}
if (cue != null) {
audioFeedbackEngine.speak(cue, QueuePolicy.DISCARD_IF_BUSY)
lastCueMs = now
}
}
/** @brief Releases the camera controller, shuts down the analysis executor, and closes any open debug trace file. */ /** @brief Releases the camera controller, shuts down the analysis executor, and closes any open debug trace file. */
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
cameraXController.release() cameraXController.release()
cameraExecutor.shutdown() cameraExecutor.shutdown()
debugSessionLogger.stop() debugSessionLogger.stop()
audioFeedbackEngine.release()
} }
/** /**
@@ -167,7 +167,7 @@
tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°" tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°"
app:layout_constraintBottom_toTopOf="@id/switch_pose" app:layout_constraintBottom_toTopOf="@id/switch_pose"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="12dp" android:layout_marginBottom="8dp"
android:layout_marginStart="24dp" /> android:layout_marginStart="24dp" />
<!-- Delivery Phase Badge --> <!-- Delivery Phase Badge -->
@@ -201,6 +201,20 @@
app:layout_constraintBottom_toBottomOf="@id/btn_record" app:layout_constraintBottom_toBottomOf="@id/btn_record"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
<!-- Audio feedback mute toggle. Remains enabled during recording since
muting does not affect the recording pipeline, only TTS output. -->
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_audio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="32dp"
android:text="@string/toggle_audio"
android:textColor="@color/white"
app:layout_constraintTop_toBottomOf="@id/switch_pose"
app:layout_constraintStart_toStartOf="parent" />
<!-- Side column instead of a bottom bar: vertically centered, hugging the end edge. -->
<Button <Button
android:id="@+id/btn_record" android:id="@+id/btn_record"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -192,7 +192,7 @@
tools:visibility="visible" /> tools:visibility="visible" />
</LinearLayout> </LinearLayout>
<!-- Bottom Left: Body Angles Readout (above Pose Switch) --> <!-- Bottom Left: Body Angles Readout (above Audio Toggle) -->
<TextView <TextView
android:id="@+id/text_pose_metrics" android:id="@+id/text_pose_metrics"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -206,12 +206,27 @@
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" tools:visibility="visible"
tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°" tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°"
app:layout_constraintBottom_toTopOf="@id/switch_pose" app:layout_constraintBottom_toTopOf="@id/switch_audio"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="12dp" android:layout_marginBottom="8dp"
android:layout_marginStart="16dp" /> android:layout_marginStart="16dp" />
<!-- BOTTOM CONTROLS: Pose Overlay Switch & Record Button --> <!-- BOTTOM CONTROLS: Pose Overlay Switch & Record Button -->
<!-- Audio feedback mute toggle. Remains enabled during recording since
muting does not affect the recording pipeline, only TTS output. -->
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_audio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/toggle_audio"
android:textColor="@color/white"
app:layout_constraintBottom_toTopOf="@id/switch_pose"
app:layout_constraintStart_toStartOf="@id/switch_pose" />
<!-- Live pose overlay + baked-in-recording toggle. Only togglable while
not recording (see BowlingCameraActivity#renderRecordingState) since
the recording pipeline picks its pose mode once at start. -->
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_pose" android:id="@+id/switch_pose"
android:layout_width="wrap_content" android:layout_width="wrap_content"
+1
View File
@@ -8,6 +8,7 @@
<string name="open_settings">Open settings</string> <string name="open_settings">Open settings</string>
<string name="record">Record</string> <string name="record">Record</string>
<string name="toggle_pose">Pose</string> <string name="toggle_pose">Pose</string>
<string name="toggle_audio">Audio</string>
<string name="stop_recording">Stop recording</string> <string name="stop_recording">Stop recording</string>
<string name="switch_camera">Switch camera</string> <string name="switch_camera">Switch camera</string>
<string name="back">Back</string> <string name="back">Back</string>