/** * @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). // The UI reads events.size as the "Step N" counter. private val _stepEvents = MutableStateFlow>(emptyList()) /** @brief Steps detected so far in the current attempt, since the last reset. */ val stepEvents: StateFlow> = _stepEvents.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. * @param isStartingPosition Whether the bowler is currently in the starting position. */ fun onFrame( landmarks: Map, angles: PoseAngles, timestampMs: Long, isStartingPosition: Boolean = false, ) { val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks) val frame = buildPoseFrame( timestampMs = timestampMs, landmarks = landmarks, smoothedAnkleHip = smoothedAnkleHip, angles = angles, ) poseFrameBuffer.add(frame) val result = liveStepDetector.update(frame, isStartingPosition = isStartingPosition) if (result.wasReset) { _stepEvents.value = emptyList() } if (result.newSteps.isNotEmpty()) { _stepEvents.value += result.newSteps } } /** * @brief Manually resets live step detector state and clears detected step events. */ fun resetStepCounter() { liveStepDetector.reset() _stepEvents.value = emptyList() } /** * @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 ) _stepEvents.value = emptyList() } }