Spaces:
Running on Zero
Running on Zero
| """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() | |