WhistleBloom / backend /state_classifier.py
ZhaoJingzi's picture
Upload 27 files
a1772f6 verified
Raw
History Blame Contribute Delete
3.78 kB
"""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()