/** * @file StepCountingSession.kt * @brief Owns pose-frame buffering and live step counting for one recording attempt. */ package com.example.jnicpp.bowling import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** * @brief Bundles everything [CameraViewModel] needs to buffer pose frames * and run live step counting during a recording, so that ViewModel * stays a thin state machine rather than also holding this * machinery directly. * * Knows nothing about CameraX/ML Kit or Android component lifecycle -- * same reasoning as [CameraXController] and [PoseAnalyzer] -- so it's * trivially unit-testable and reusable if a second recording surface is * ever added. */ class StepCountingSession { // Extra SMA smoothing for ankle/hip landmarks specifically, on top of // PoseLandmarkSmoother's per-frame EMA -- see AnkleHipMovingAverageFilter. private val ankleHipSmoother = AnkleHipMovingAverageFilter() // Live, incremental step counting -- see LiveStepDetector. Resets // mid-recording when the bowler holds a hand raised for the gesture's // full hold duration, so one recording can capture several practice // approaches back to back. Rebuilt fresh (not just .reset()) in // startNewSession from whatever DetectorSettings are current at that // moment, so tuning changes made in ParameterEditorActivity take // effect on the very next recording without needing an app restart. private var liveStepDetector = LiveStepDetector() // Time-ordered pose samples for the current/most recent recording // session, one appended per analyzed frame while actually recording -- // see [onFrame]. Cleared at the start of each new session (see // [startNewSession]). Exposed as a read-only snapshot; // StepDetector.detect() can consume it once a recording finishes. private val poseFrameBuffer = mutableListOf() /** @brief Time-ordered pose samples buffered for the current/most recent recording session. */ val poseFrames: List get() = poseFrameBuffer // Steps detected so far in the current attempt (since the last reset, // whether that reset was a new session starting or the bowler // completing the hand-raise reset gesture mid-recording). The UI reads // events.size as the "Step N" counter. Stays populated after recording // stops so the last attempt's count remains visible. private val _stepEvents = MutableStateFlow>(emptyList()) /** @brief Steps detected so far in the current attempt, since the last reset. */ val stepEvents: StateFlow> = _stepEvents.asStateFlow() // How far through the hold-to-reset gesture the bowler currently is -- // see LiveStepDetector.Result.handRaiseProgress. Drives the on-screen // hold indicator so a reset is never a surprise; 0 whenever not recording. private val _handRaiseProgress = MutableStateFlow(0f) /** @brief Progress toward the hold-to-reset gesture, from 0 to 1. */ val handRaiseProgress: StateFlow = _handRaiseProgress.asStateFlow() /** * @brief Feeds one analyzed frame's landmarks/angles into buffering and * live step counting. Only meant to be called while a recording * is actually in progress -- see [CameraViewModel.onPoseFrameUpdated]. * @param landmarks EMA-smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant. * @param angles Joint angles computed for this same frame. * @param timestampMs Wall-clock time this frame was analyzed, in milliseconds. */ fun onFrame(landmarks: Map, angles: PoseAngles, timestampMs: Long) { val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks) val frame = buildPoseFrame( timestampMs = timestampMs, landmarks = landmarks, smoothedAnkleHip = smoothedAnkleHip, angles = angles ) poseFrameBuffer.add(frame) val result = liveStepDetector.update(frame) if (result.wasReset) { _stepEvents.value = emptyList() } if (result.newSteps.isNotEmpty()) { _stepEvents.value = _stepEvents.value + result.newSteps } _handRaiseProgress.value = result.handRaiseProgress } /** * @brief Clears all buffering/detection state and rebuilds the step * detector from [settings]; call when a new recording starts. * @param settings Tuning parameters to build this session's [LiveStepDetector] with. */ fun startNewSession(settings: DetectorSettings = DetectorSettings.DEFAULT) { poseFrameBuffer.clear() ankleHipSmoother.reset() liveStepDetector = LiveStepDetector( minSpacingMs = settings.minSpacingMs, minProminenceRatio = settings.minProminenceRatio, maxFrameJumpRatio = settings.maxFrameJumpRatio, handRaiseHoldMs = settings.handRaiseHoldMs, handRaiseMarginRatio = settings.handRaiseMarginRatio ) _stepEvents.value = emptyList() _handRaiseProgress.value = 0f } }