| '''Whistle Coach - audio-first Hugging Face Space.
|
|
|
| The app uses MIT AST audio classification for whistle confidence and
|
| librosa.pyin for pitch tracking. Camera guidance is intentionally visual-only.
|
| '''
|
|
|
| from __future__ import annotations
|
|
|
| import base64
|
| import io
|
| import json
|
| import math
|
| from pathlib import Path
|
| from typing import Any
|
|
|
| import gradio as gr |
| import librosa |
| import numpy as np |
| import soundfile as sf |
| from scipy.signal import resample_poly |
| from transformers import pipeline |
|
|
| try: |
| import spaces |
| except Exception: |
| class _SpacesFallback: |
| @staticmethod |
| def GPU(*decorator_args, **decorator_kwargs): |
| if ( |
| decorator_args |
| and callable(decorator_args[0]) |
| and len(decorator_args) == 1 |
| and not decorator_kwargs |
| ): |
| return decorator_args[0] |
|
|
| def decorator(function): |
| return function |
|
|
| return decorator |
|
|
| spaces = _SpacesFallback() |
|
|
| MODEL_ID = "MIT/ast-finetuned-audioset-10-10-0.4593" |
| AST_PARAMETERS = "86.6M" |
| AST_DEVICE = "cpu" |
| TARGET_SR = 16_000 |
| MIN_AUDIO_SECONDS = 0.25 |
| MAX_ANALYSIS_SECONDS = 4.0 |
|
|
| BASE_DIR = Path(__file__).resolve().parent
|
| MELODY_DIR = BASE_DIR / "outputs" / "melodies"
|
| MELODY_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
| MODEL_LOAD_ERROR: str | None = None
|
| try:
|
| audio_classifier = pipeline( |
| "audio-classification", |
| model=MODEL_ID, |
| device=-1, |
| ) |
| except Exception as exc:
|
| audio_classifier = None
|
| MODEL_LOAD_ERROR = f"{type(exc).__name__}: {exc}"
|
|
|
| NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
| STATE_ORDER = ["no_sound", "breath_noise", "tiny_whistle", "stable_pitch", "melody_ready"]
|
| STATE_LABELS = {
|
| "no_sound": "No sound",
|
| "breath_noise": "Breath noise",
|
| "tiny_whistle": "Tiny whistle",
|
| "stable_pitch": "Stable pitch",
|
| "melody_ready": "Melody",
|
| }
|
| GARDEN_STAGES = {
|
| "no_sound": ("soil", "Soil", "Rest, breathe, and start softly."),
|
| "breath_noise": ("wind", "Wind", "Air is moving. Make the opening smaller."),
|
| "tiny_whistle": ("sprout", "Sprout", "A tiny tone appeared. Freeze that shape."),
|
| "stable_pitch": ("flower", "Flower", "Hold the tone and keep it gentle."),
|
| "melody_ready": ("melody", "Melody flower", "You can shape notes into a melody."),
|
| }
|
|
|
|
|
| def clamp(value: float, minimum: float = 0.0, maximum: float = 100.0) -> float:
|
| return float(min(maximum, max(minimum, value)))
|
|
|
|
|
| def hz_to_note(hz: float | None) -> str:
|
| if hz is None or not np.isfinite(hz) or hz <= 0:
|
| return "--"
|
| midi = int(round(69 + 12 * math.log2(float(hz) / 440.0)))
|
| octave = midi // 12 - 1
|
| return f"{NOTE_NAMES[midi % 12]}{octave}"
|
|
|
|
|
| def hz_to_midi(hz: float) -> int:
|
| return int(round(69 + 12 * math.log2(float(hz) / 440.0)))
|
|
|
|
|
| def midi_to_hz(midi: int) -> float:
|
| return 440.0 * (2 ** ((midi - 69) / 12))
|
|
|
|
|
| def normalize_audio_array(data: np.ndarray) -> np.ndarray:
|
| y = np.asarray(data)
|
| if y.ndim > 1:
|
| y = np.mean(y, axis=1)
|
| if np.issubdtype(y.dtype, np.integer):
|
| peak = float(np.iinfo(y.dtype).max)
|
| y = y.astype(np.float32) / max(peak, 1.0)
|
| else:
|
| y = y.astype(np.float32)
|
| max_abs = float(np.max(np.abs(y))) if y.size else 0.0
|
| if max_abs > 1.5:
|
| y = y / max_abs
|
| y = np.nan_to_num(y, nan=0.0, posinf=0.0, neginf=0.0)
|
| return np.clip(y, -1.0, 1.0).astype(np.float32)
|
|
|
|
|
| def load_audio_input(audio_input: Any) -> tuple[int, np.ndarray]:
|
| '''Accept Gradio Audio numpy tuples, file paths, or path dictionaries.'''
|
| if audio_input is None:
|
| return TARGET_SR, np.zeros(0, dtype=np.float32)
|
|
|
| if isinstance(audio_input, tuple) and len(audio_input) == 2:
|
| sample_rate, data = audio_input
|
| return int(sample_rate), normalize_audio_array(np.asarray(data))
|
|
|
| if isinstance(audio_input, dict):
|
| path = audio_input.get("path") or audio_input.get("name")
|
| if path:
|
| data, sample_rate = sf.read(path, always_2d=False)
|
| return int(sample_rate), normalize_audio_array(np.asarray(data))
|
|
|
| if isinstance(audio_input, (str, Path)):
|
| data, sample_rate = sf.read(str(audio_input), always_2d=False)
|
| return int(sample_rate), normalize_audio_array(np.asarray(data))
|
|
|
| return TARGET_SR, np.zeros(0, dtype=np.float32)
|
|
|
|
|
| def resample_to_target(sample_rate: int, y: np.ndarray) -> np.ndarray:
|
| if y.size == 0:
|
| return y.astype(np.float32)
|
| if sample_rate == TARGET_SR:
|
| return y.astype(np.float32)
|
| gcd = math.gcd(int(sample_rate), TARGET_SR)
|
| up = TARGET_SR // gcd
|
| down = int(sample_rate) // gcd
|
| return resample_poly(y, up, down).astype(np.float32)
|
|
|
|
|
| def latest_window(y: np.ndarray, seconds: float = MAX_ANALYSIS_SECONDS) -> np.ndarray:
|
| max_samples = int(TARGET_SR * seconds)
|
| if y.size <= max_samples:
|
| return y
|
| return y[-max_samples:]
|
|
|
|
|
| def classify_with_ast(y16: np.ndarray) -> tuple[float, list[dict[str, Any]], str | None]:
|
| '''Run the real HF AST classifier and return whistle confidence.'''
|
| if audio_classifier is None:
|
| return 0.0, [], MODEL_LOAD_ERROR or "Audio model is not loaded."
|
| if y16.size < int(TARGET_SR * MIN_AUDIO_SECONDS):
|
| return 0.0, [], None
|
|
|
| try:
|
| predictions = audio_classifier(
|
| {"array": y16.astype(np.float32), "sampling_rate": TARGET_SR},
|
| top_k=8,
|
| )
|
| except TypeError:
|
| predictions = audio_classifier(
|
| {"array": y16.astype(np.float32), "sampling_rate": TARGET_SR}
|
| )
|
| except Exception as exc:
|
| return 0.0, [], f"AST inference failed: {type(exc).__name__}: {exc}"
|
|
|
| if isinstance(predictions, dict):
|
| predictions = [predictions]
|
| if predictions and isinstance(predictions[0], list):
|
| predictions = predictions[0]
|
|
|
| labels: list[dict[str, Any]] = []
|
| whistle_score = 0.0
|
| top_non_whistle = 0.0
|
| for item in predictions[:8]:
|
| label = str(item.get("label", ""))
|
| score = float(item.get("score", 0.0) or 0.0)
|
| labels.append({"label": label, "score": round(score, 4)})
|
| normalized = label.lower().replace("_", " ").replace("-", " ")
|
| if "whistl" in normalized or "whistle" in normalized:
|
| whistle_score = max(whistle_score, score)
|
| else:
|
| top_non_whistle = max(top_non_whistle, score)
|
|
|
| if whistle_score > 0:
|
| confidence = whistle_score * 100.0
|
| else:
|
| confidence = clamp((1.0 - top_non_whistle) * 12.0, 0.0, 12.0)
|
| return round(confidence, 2), labels, None
|
|
|
|
|
| def longest_stable_duration(f0: np.ndarray, voiced: np.ndarray, hop_length: int) -> float:
|
| longest = 0.0
|
| current: list[float] = []
|
|
|
| def finish_run(values: list[float]) -> float:
|
| if len(values) < 3:
|
| return 0.0
|
| arr = np.asarray(values, dtype=np.float64)
|
| center = float(np.nanmedian(arr))
|
| if center <= 0:
|
| return 0.0
|
| cents = 1200.0 * np.log2(arr / center)
|
| if float(np.nanstd(cents)) > 95.0:
|
| return 0.0
|
| return len(values) * hop_length / TARGET_SR
|
|
|
| for hz, is_voiced in zip(f0, voiced):
|
| if bool(is_voiced) and np.isfinite(hz) and hz > 0:
|
| current.append(float(hz))
|
| else:
|
| longest = max(longest, finish_run(current))
|
| current = []
|
| longest = max(longest, finish_run(current))
|
| return round(float(longest), 2)
|
|
|
|
|
| def analyze_pitch(y16: np.ndarray) -> dict[str, Any]:
|
| hop_length = 256
|
| frame_length = 2048
|
| if y16.size < frame_length:
|
| return {
|
| "pitch_detected": False,
|
| "pitch_note": "--",
|
| "mean_pitch_hz": None,
|
| "stability_score": 0,
|
| "stable_duration": 0.0,
|
| "pitch_contour": [],
|
| "voiced_ratio": 0.0,
|
| "pitch_std_cents": None,
|
| "pitch_movement_semitones": 0.0,
|
| }
|
|
|
| try:
|
| f0, voiced_flag, voiced_prob = librosa.pyin(
|
| y16, |
| fmin=librosa.note_to_hz("C5"), |
| fmax=librosa.note_to_hz("C8"), |
| sr=TARGET_SR, |
| frame_length=frame_length, |
| hop_length=hop_length, |
| ) |
| except Exception:
|
| return {
|
| "pitch_detected": False,
|
| "pitch_note": "--",
|
| "mean_pitch_hz": None,
|
| "stability_score": 0,
|
| "stable_duration": 0.0,
|
| "pitch_contour": [],
|
| "voiced_ratio": 0.0,
|
| "pitch_std_cents": None,
|
| "pitch_movement_semitones": 0.0,
|
| }
|
|
|
| voiced = np.asarray(voiced_flag, dtype=bool) & np.isfinite(f0)
|
| valid_f0 = np.asarray(f0)[voiced]
|
| voiced_ratio = float(np.mean(voiced)) if len(voiced) else 0.0
|
| pitch_detected = bool(valid_f0.size >= 3 and voiced_ratio >= 0.06)
|
|
|
| mean_pitch: float | None = None
|
| pitch_std_cents: float | None = None
|
| pitch_movement = 0.0
|
| if valid_f0.size:
|
| mean_pitch = float(np.nanmedian(valid_f0))
|
| if mean_pitch > 0 and valid_f0.size > 1:
|
| cents = 1200.0 * np.log2(valid_f0 / mean_pitch)
|
| pitch_std_cents = float(np.nanstd(cents))
|
| pitch_movement = float(np.nanmax(cents) - np.nanmin(cents)) / 100.0
|
|
|
| std_score = 0.0
|
| if pitch_std_cents is not None:
|
| std_score = clamp(100.0 - (pitch_std_cents * 1.35), 0.0, 100.0)
|
| stability_score = clamp((voiced_ratio * 62.0) + (std_score * 0.38))
|
| stable_duration = longest_stable_duration(np.asarray(f0), voiced, hop_length)
|
|
|
| times = librosa.frames_to_time(np.arange(len(f0)), sr=TARGET_SR, hop_length=hop_length)
|
| contour: list[dict[str, Any]] = []
|
| valid_indices = np.where(np.isfinite(f0) & voiced)[0]
|
| if valid_indices.size:
|
| step = max(1, int(math.ceil(valid_indices.size / 90)))
|
| for idx in valid_indices[::step]:
|
| hz = float(f0[idx])
|
| contour.append({
|
| "time_ms": int(round(float(times[idx]) * 1000)),
|
| "hz": round(hz, 2),
|
| "note": hz_to_note(hz),
|
| })
|
|
|
| return {
|
| "pitch_detected": pitch_detected,
|
| "pitch_note": hz_to_note(mean_pitch),
|
| "mean_pitch_hz": round(mean_pitch, 2) if mean_pitch else None,
|
| "stability_score": int(round(stability_score)) if pitch_detected else 0,
|
| "stable_duration": stable_duration if pitch_detected else 0.0,
|
| "pitch_contour": contour,
|
| "voiced_ratio": round(voiced_ratio, 3),
|
| "pitch_std_cents": round(pitch_std_cents, 2) if pitch_std_cents is not None else None,
|
| "pitch_movement_semitones": round(pitch_movement, 2),
|
| }
|
|
|
|
|
| def analyze_airflow(y16: np.ndarray) -> dict[str, float]:
|
| if y16.size == 0:
|
| return {"rms": 0.0, "db": -120.0, "airflow_score": 0.0, "noise_score": 0.0}
|
| rms = float(np.sqrt(np.mean(np.square(y16))))
|
| db = float(20.0 * np.log10(rms + 1e-9))
|
| airflow_score = clamp((db + 55.0) / 32.0 * 100.0)
|
| try:
|
| flatness = float(np.nanmean(librosa.feature.spectral_flatness(y=y16, n_fft=1024, hop_length=256)))
|
| zcr = float(np.nanmean(librosa.feature.zero_crossing_rate(y16, frame_length=1024, hop_length=256)))
|
| except Exception:
|
| flatness = 0.0
|
| zcr = 0.0
|
| noise_score = clamp((flatness * 180.0) + (zcr * 110.0))
|
| return {
|
| "rms": round(rms, 5),
|
| "db": round(db, 2),
|
| "airflow_score": int(round(airflow_score)),
|
| "noise_score": round(noise_score, 2),
|
| }
|
|
|
|
|
| def choose_state_and_feedback(result: dict[str, Any]) -> tuple[str, str, str]:
|
| airflow = float(result["airflow_score"])
|
| confidence = float(result["whistle_confidence"])
|
| pitch_detected = bool(result["pitch_detected"])
|
| stable_duration = float(result["stable_duration"] or 0.0)
|
| noise_score = float(result.get("noise_score", 0.0) or 0.0)
|
| movement = float(result.get("pitch_movement_semitones", 0.0) or 0.0)
|
|
|
| if pitch_detected and stable_duration >= 1.0 and movement >= 2.0: |
| return ( |
| "melody_ready", |
| "Nice - you are changing notes. Try making a melody.", |
| "Move the pitch slowly, like stepping between two nearby notes.",
|
| )
|
| if pitch_detected and stable_duration >= 1.0:
|
| return (
|
| "stable_pitch",
|
| "Great! Hold this tone longer.",
|
| "Stay still for one more second before trying to move the pitch.",
|
| )
|
| if pitch_detected:
|
| return (
|
| "tiny_whistle",
|
| "Tiny whistle found. Freeze this mouth shape.", |
| "Use less air now; keep the same lip opening and hold it steady.", |
| ) |
| if airflow < 10: |
| return ( |
| "no_sound", |
| "Blow a little more, but stay gentle.", |
| "Keep your lips relaxed, then send a small stream of air through the center.", |
| ) |
| if confidence >= 25: |
| return ( |
| "tiny_whistle", |
| "You are close. Make the air stream narrower.",
|
| "Round the lips a little more and soften the airflow.",
|
| )
|
| if airflow >= 35 or noise_score >= 18:
|
| return (
|
| "breath_noise",
|
| "You are producing air noise. Make the lip opening smaller and soften the airflow.",
|
| "Try a quiet 'yuh yuh yuh' breath with a smaller opening.",
|
| )
|
| return (
|
| "no_sound",
|
| "Blow a little more, but stay gentle.",
|
| "Stay relaxed. The first goal is a soft, steady stream of air.",
|
| )
|
|
|
|
|
| def analyze_audio(audio_input: Any) -> dict[str, Any]:
|
| '''Analyze a Gradio Audio input using AST and librosa.pyin.'''
|
| sample_rate, y = load_audio_input(audio_input)
|
| y16 = latest_window(resample_to_target(sample_rate, y))
|
| duration = y16.size / TARGET_SR if TARGET_SR else 0.0
|
|
|
| airflow = analyze_airflow(y16)
|
| whistle_confidence, ast_labels, ast_error = classify_with_ast(y16)
|
| pitch = analyze_pitch(y16)
|
|
|
| result: dict[str, Any] = {
|
| "airflow_score": int(airflow["airflow_score"]),
|
| "whistle_confidence": int(round(whistle_confidence)),
|
| "pitch_detected": bool(pitch["pitch_detected"]),
|
| "pitch_note": pitch["pitch_note"],
|
| "mean_pitch_hz": pitch["mean_pitch_hz"],
|
| "stability_score": int(pitch["stability_score"]),
|
| "stable_duration": float(pitch["stable_duration"]),
|
| "state": "no_sound",
|
| "coach_feedback": "Blow a little more, but stay gentle.",
|
| "next_tip": "Start with a soft, narrow stream of air.",
|
| "pitch_contour": pitch["pitch_contour"],
|
| "audio_seconds": round(duration, 2),
|
| "rms": airflow["rms"],
|
| "db": airflow["db"],
|
| "noise_score": airflow["noise_score"],
|
| "voiced_ratio": pitch["voiced_ratio"],
|
| "pitch_std_cents": pitch["pitch_std_cents"],
|
| "pitch_movement_semitones": pitch["pitch_movement_semitones"],
|
| "ast_top_labels": ast_labels,
|
| "ast_error": ast_error, |
| "model_stack": { |
| "audio_model": f"{MODEL_ID} ({AST_PARAMETERS}, {AST_DEVICE})", |
| "pitch_tracking": "librosa.pyin C5-C8", |
| "visual_assistant": "MediaPipe visible mouth guidance only; no tongue detection", |
| }, |
| } |
|
|
| state, feedback, next_tip = choose_state_and_feedback(result)
|
| result["state"] = state
|
| result["coach_feedback"] = feedback
|
| result["next_tip"] = next_tip
|
| return result
|
|
|
|
|
| def contour_to_note_sequence(contour: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| notes: list[dict[str, Any]] = []
|
| current_midi: int | None = None
|
| start_ms = 0
|
| last_ms = 0
|
| for sample in contour:
|
| hz = float(sample.get("hz", sample.get("frequency_hz", 0)) or 0)
|
| t_ms = int(sample.get("time_ms", sample.get("t", 0)) or 0)
|
| if hz < 120:
|
| continue
|
| midi = hz_to_midi(hz)
|
| if current_midi is None:
|
| current_midi = midi
|
| start_ms = t_ms
|
| elif abs(midi - current_midi) > 1:
|
| notes.append({"midi": current_midi, "duration_ms": max(180, last_ms - start_ms)})
|
| current_midi = midi
|
| start_ms = t_ms
|
| last_ms = t_ms
|
| if current_midi is not None:
|
| notes.append({"midi": current_midi, "duration_ms": max(260, last_ms - start_ms)})
|
| return notes[:12]
|
|
|
|
|
| def generate_melody_from_contour(contour: list[dict[str, Any]]) -> tuple[str | None, str]:
|
| notes = contour_to_note_sequence(contour)
|
| if not notes:
|
| return None, ""
|
| sample_rate = 22_050
|
| samples: list[np.ndarray] = []
|
| for note in notes:
|
| hz = midi_to_hz(int(note["midi"]))
|
| duration = min(0.75, max(0.18, int(note["duration_ms"]) / 1000.0))
|
| t = np.linspace(0.0, duration, int(sample_rate * duration), endpoint=False)
|
| tone = np.sin(2 * np.pi * hz * t) + 0.25 * np.sin(2 * np.pi * hz * 2 * t)
|
| fade = min(220, max(12, len(tone) // 8))
|
| envelope = np.ones_like(tone)
|
| envelope[:fade] = np.linspace(0.0, 1.0, fade)
|
| envelope[-fade:] = np.linspace(1.0, 0.0, fade)
|
| samples.append((tone * envelope * 0.23).astype(np.float32))
|
| samples.append(np.zeros(int(sample_rate * 0.045), dtype=np.float32))
|
| wav = np.concatenate(samples) if samples else np.zeros(1, dtype=np.float32)
|
| output_path = MELODY_DIR / "whistle_melody.wav"
|
| sf.write(output_path, wav, sample_rate)
|
| names = [hz_to_note(midi_to_hz(int(note["midi"]))) for note in notes]
|
| return str(output_path), " - ".join(names)
|
|
|
|
|
| FRONTEND_DIR = BASE_DIR / "frontend"
|
| HTML_PATH = FRONTEND_DIR / "index.html"
|
| STYLE_PATH = FRONTEND_DIR / "style.css"
|
| SCRIPT_PATHS = [
|
| FRONTEND_DIR / "policy.js", |
| FRONTEND_DIR / "landmarks.js", |
| FRONTEND_DIR / "audio_features.js", |
| FRONTEND_DIR / "face_features.js", |
| FRONTEND_DIR / "overlay_renderer.js", |
| FRONTEND_DIR / "whistle_coach.js", |
| ] |
|
|
|
|
| def load_frontend_html() -> str:
|
| return HTML_PATH.read_text(encoding="utf-8")
|
|
|
|
|
| def load_frontend_css() -> str:
|
| return STYLE_PATH.read_text(encoding="utf-8") if STYLE_PATH.exists() else ""
|
|
|
|
|
| def load_js_bundle() -> str: |
| parts: list[str] = [] |
| for script_path in SCRIPT_PATHS: |
| if script_path.exists(): |
| parts.append(script_path.read_text(encoding="utf-8")) |
| return "\n\n".join(parts) |
|
|
|
|
| @spaces.GPU(duration=1) |
| def zerogpu_startup_probe() -> str: |
| """Tiny hidden hook so ZeroGPU Spaces start without charging live audio calls.""" |
| return "ready" |
|
|
|
|
| def analyze_audio_window(audio_payload_json: str) -> dict[str, Any]: |
| """Decode a browser-captured WAV window and run the real AST/librosa stack.""" |
| if not audio_payload_json: |
| result = empty_result() |
| result["ast_error"] = MODEL_LOAD_ERROR |
| return result
|
|
|
| try:
|
| payload = json.loads(audio_payload_json)
|
| encoded = payload.get("data_base64") or ""
|
| audio_bytes = base64.b64decode(encoded)
|
| data, sample_rate = sf.read(io.BytesIO(audio_bytes), always_2d=False)
|
| except Exception as exc:
|
| result = empty_result()
|
| result["ast_error"] = f"Audio decode failed: {type(exc).__name__}: {exc}"
|
| result["coach_feedback"] = "The microphone window could not be decoded. Try again."
|
| result["next_tip"] = "Keep the browser microphone permission enabled."
|
| return result
|
|
|
| result = analyze_audio((int(sample_rate), data))
|
| result["source"] = "browser_live_window"
|
| return result
|
|
|
|
|
| def empty_result() -> dict[str, Any]:
|
| return {
|
| "airflow_score": 0,
|
| "whistle_confidence": 0,
|
| "pitch_detected": False,
|
| "pitch_note": "--",
|
| "mean_pitch_hz": None,
|
| "stability_score": 0,
|
| "stable_duration": 0.0,
|
| "state": "no_sound",
|
| "coach_feedback": "Ready when you are.", |
| "next_tip": "The coach listens to each audio window and updates feedback.",
|
| "pitch_contour": [],
|
| "rms": 0,
|
| "db": -120,
|
| "voiced_ratio": 0,
|
| "pitch_std_cents": None,
|
| "pitch_movement_semitones": 0,
|
| "ast_top_labels": [],
|
| "ast_error": MODEL_LOAD_ERROR, |
| "model_stack": { |
| "audio_model": f"{MODEL_ID} ({AST_PARAMETERS}, {AST_DEVICE})", |
| "pitch_tracking": "librosa.pyin C5-C8", |
| "visual_assistant": "MediaPipe visible mouth guidance only; no tongue detection", |
| }, |
| } |
|
|
|
|
| def coach_reason(practice_state_json: str, history_json: str = "[]") -> dict[str, Any]:
|
| """Small rule-based policy kept for the old frontend trace panel."""
|
| try:
|
| practice_state = json.loads(practice_state_json or "{}")
|
| except json.JSONDecodeError:
|
| practice_state = {}
|
| state_name = practice_state.get("state", "idle")
|
| audio = practice_state.get("audio", {}) if isinstance(practice_state, dict) else {}
|
| airflow = float(audio.get("airflow_score", 0) or 0)
|
| stability = float(audio.get("pitch_stability_score", 0) or 0)
|
|
|
| if stability >= 0.58:
|
| message = "Great! Hold this tone longer."
|
| drill = "Hold a steady tone"
|
| success = True
|
| elif airflow >= 0.55:
|
| message = "You are producing air noise. Make the lip opening smaller and soften the airflow."
|
| drill = "Make the air stream narrower"
|
| success = False
|
| elif airflow >= 0.25:
|
| message = "You are close. Make the air stream narrower."
|
| drill = "Gentle airflow"
|
| success = False
|
| else:
|
| message = "Blow a little more, but stay gentle."
|
| drill = "Gentle airflow"
|
| success = False
|
|
|
| return {
|
| "coach_message": message,
|
| "next_drill": drill,
|
| "success_trigger": success,
|
| "model_source": "local rule fallback; AST/librosa panel uses live audio model",
|
| "agent_trace": [
|
| {"step": "Observe", "detail": f"Browser practice state: {state_name}."},
|
| {"step": "Listen", "detail": "Backend AST/librosa analyzes one-second audio windows."},
|
| {"step": "Coach", "detail": drill},
|
| ],
|
| }
|
|
|
|
|
| def generate_melody(pitch_contour_json: str) -> dict[str, Any]:
|
| try:
|
| contour = json.loads(pitch_contour_json or "[]")
|
| except json.JSONDecodeError:
|
| contour = []
|
| path, sequence = generate_melody_from_contour(contour if isinstance(contour, list) else [])
|
| return {"path": path, "sequence": sequence, "ready": bool(path)}
|
|
|
|
|
| with gr.Blocks( |
| title="Whistle Coach", |
| fill_width=True, |
| ) as demo: |
| gr.HTML(load_frontend_html(), container=False, padding=False) |
|
|
| zerogpu_trigger = gr.Button(visible=False) |
| zerogpu_status = gr.Textbox(visible=False) |
| zerogpu_trigger.click( |
| zerogpu_startup_probe, |
| outputs=zerogpu_status, |
| api_name="zerogpu_startup_probe", |
| show_progress="hidden", |
| ) |
|
|
| audio_payload = gr.Textbox(visible=False) |
| audio_result = gr.JSON(visible=False) |
| audio_payload.submit(
|
| analyze_audio_window,
|
| inputs=audio_payload,
|
| outputs=audio_result,
|
| api_name="analyze_audio_window",
|
| show_progress="hidden",
|
| )
|
|
|
| practice_state = gr.Textbox(visible=False)
|
| coach_history = gr.Textbox(visible=False)
|
| coach_output = gr.JSON(visible=False)
|
| practice_state.submit(
|
| coach_reason,
|
| inputs=[practice_state, coach_history],
|
| outputs=coach_output,
|
| api_name="coach_reason",
|
| show_progress="hidden",
|
| )
|
|
|
| melody_contour = gr.Textbox(visible=False)
|
| melody_output = gr.JSON(visible=False)
|
| melody_contour.submit(
|
| generate_melody,
|
| inputs=melody_contour,
|
| outputs=melody_output,
|
| api_name="generate_melody",
|
| show_progress="hidden",
|
| )
|
|
|
|
|
| if __name__ == "__main__":
|
| demo.queue(default_concurrency_limit=1).launch(
|
| theme=gr.themes.Soft(),
|
| css=load_frontend_css(),
|
| js=load_js_bundle(),
|
| )
|
|
|