"""Rule-based state classifier for browser-extracted face and audio features.""" from __future__ import annotations from typing import Any from .schemas import AudioFeatures, FaceFeatures, PracticeState def _num(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def classify_practice_state(payload: dict[str, Any]) -> dict[str, Any]: """Convert raw feature JSON into a stable practice state. This is intentionally simple and deterministic. Nemotron can reason over the resulting state, but the app still works when no model is available. """ face_payload = payload.get("face", payload.get("face_features", {})) or {} audio_payload = payload.get("audio", payload.get("audio_features", {})) or {} face = FaceFeatures( face_visible=bool(face_payload.get("face_visible", face_payload.get("visible", False))), face_centered=bool(face_payload.get("face_centered", face_payload.get("centered", False))), mouth_opening_ratio=_num(face_payload.get("mouth_opening_ratio", face_payload.get("openingRatio"))), lip_roundness_score=_num(face_payload.get("lip_roundness_score", face_payload.get("puckerScore"))), upper_lip_lift_score=_num(face_payload.get("upper_lip_lift_score", face_payload.get("upperLipLiftScore"))), jaw_stability_score=_num(face_payload.get("jaw_stability_score", face_payload.get("jawScore"))), mouth_symmetry_score=_num(face_payload.get("mouth_symmetry_score", face_payload.get("symmetry"))), mouth_shape_score=_num(face_payload.get("mouth_shape_score", face_payload.get("score"))), ) audio = AudioFeatures( rms_volume=_num(audio_payload.get("rms_volume", audio_payload.get("rms"))), airflow_score=_num(audio_payload.get("airflow_score", audio_payload.get("airflow"))), peak_frequency_hz=_num(audio_payload.get("peak_frequency_hz", audio_payload.get("peakFrequency"))), pitch_stability_score=_num(audio_payload.get("pitch_stability_score", audio_payload.get("tone"))), stable_duration_ms=int(_num(audio_payload.get("stable_duration_ms", audio_payload.get("stableDurationMs")), 0)), ) state = "idle" active_step = "start" confidence = 0.0 if not face.face_visible or not face.face_centered: state = "no_face" active_step = "align_face" confidence = 0.75 elif face.mouth_opening_ratio > 0.34 or face.jaw_stability_score < 0.45: state = "mouth_too_open" active_step = "small_opening" confidence = 0.72 elif face.lip_roundness_score < 0.52: state = "not_rounded" active_step = "round_lips" confidence = 0.74 elif face.mouth_symmetry_score < 0.58: state = "asymmetric_mouth" active_step = "center_mouth" confidence = 0.62 elif audio.airflow_score < 0.32: state = "mouth_ready_no_airflow" active_step = "gentle_airflow" confidence = 0.7 elif audio.pitch_stability_score < 0.58: state = "airflow_no_tone" active_step = "narrow_air_stream" confidence = 0.68 else: state = "stable_whistle" active_step = "record_melody" confidence = 0.84 success_trigger = ( state == "stable_whistle" and audio.stable_duration_ms >= 2000 and audio.peak_frequency_hz >= 700 ) practice_state = PracticeState( state=state, active_step=active_step, face=face, audio=audio, success_trigger=success_trigger, confidence=confidence, ) return practice_state.to_dict()