ZhaoJingzi commited on
Commit
a1772f6
·
verified ·
1 Parent(s): fc53938

Upload 27 files

Browse files
.gitattributes CHANGED
@@ -1,35 +1,36 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/placeholder.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ .venv/
6
+ venv/
7
+ env/
8
+ .env
9
+ .env.*
10
+
11
+ .gradio/
12
+ .cache/
13
+
14
+ outputs/melodies/*.wav
README.md CHANGED
@@ -1,15 +1,264 @@
1
  ---
2
- title: WhistleBloom
3
- emoji: 🐢
4
- colorFrom: red
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- license: mit
12
- short_description: Whistle coach
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Whistle Coach
3
+ colorFrom: green
 
4
  colorTo: purple
5
  sdk: gradio
 
 
6
  app_file: app.py
7
  pinned: false
8
+ license: apache-2.0
9
+ tags:
10
+ - build-small
11
+ - backyard-ai
12
+ - off-brand
13
+ - tiny-titan
14
+ - best-agent
15
+ - best-demo
16
+ - openai-codex
17
+ - nvidia-nemotron
18
+ - voice
19
+ - audio
20
+ - audio-classification
21
+ - gradio
22
  ---
23
 
24
+ # Whistle Coach
25
+
26
+ **Whistle Coach is an audio-first AI practice coach for one tiny human skill: learning your first whistle.**
27
+
28
+ Most people learn to whistle through awkward trial and error: round the lips, blow too hard, lose the shape, try again. Whistle Coach turns that loop into a small, live, embodied AI coach. It watches only the visible lower-face posture needed for guidance, listens for breath and pitch, diagnoses the current blocker, and grows a tiny garden as the first stable whistle turns into a melody.
29
+
30
+ This is intentionally small. It is not a giant general assistant. It is a narrow feedback system for one body skill, built from small models, signal processing, browser-side feature extraction, and a compact coach policy.
31
+
32
+ ## Submission Links
33
+
34
+ - **Live Space:** this Hugging Face Space
35
+ - **Demo video:** TODO - paste public demo video link before final submission
36
+ - **Social post:** TODO - paste public social post link before final submission
37
+ - **Primary track:** Backyard AI
38
+ - **Requested bonus consideration:** Off Brand, Tiny Titan, Best Agent, Best Demo, OpenAI Codex
39
+ - **Sponsor path:** NVIDIA / Nemotron policy path is included; enable the Nemotron endpoint secrets before final judging if entering the NVIDIA sponsor prize.
40
+
41
+ ## Why It Fits Build Small
42
+
43
+ Build Small is about useful, local-feeling, tinkerable AI under the 32B parameter cap. Whistle Coach fits that spirit in four ways:
44
+
45
+ 1. **A small personal problem:** learning to whistle is tiny, specific, and surprisingly hard to debug alone.
46
+ 2. **Small models do real work:** the runtime audio classifier is MIT AST at 86.6M parameters; the optional coach policy path targets Nemotron Nano 4B.
47
+ 3. **AI is core, not decorative:** the app converts live sensor signals into practice states, then chooses the next micro-drill.
48
+ 4. **The UI is custom, playful, and not default Gradio:** the live camera stage, listening panel, coach overlays, garden reward, and melody export are a bespoke browser experience served through a Gradio Space.
49
+
50
+ ## What Judges Should Try
51
+
52
+ 1. Open the Space and click **Start practice**.
53
+ 2. Allow camera and microphone access.
54
+ 3. Put your mouth in the dotted oval.
55
+ 4. Try a soft "yuh yuh yuh" breath with rounded lips.
56
+ 5. Watch the listening panel update:
57
+ - airflow
58
+ - whistle confidence
59
+ - pitch detected
60
+ - stability
61
+ 6. Follow the coach's one-step correction.
62
+ 7. Hold a stable pitch long enough to grow the garden and unlock a tiny melody preview.
63
+
64
+ Even if the room is noisy or the microphone is imperfect, the app should still show why it is coaching the next action.
65
+
66
+ ## The Core Loop
67
+
68
+ ```text
69
+ camera + microphone
70
+ |
71
+ v
72
+ browser feature extraction
73
+ |
74
+ v
75
+ practice state
76
+ |
77
+ v
78
+ coach policy
79
+ |
80
+ v
81
+ one micro-drill + visual cue + audio feedback
82
+ |
83
+ v
84
+ garden progress + melody reward
85
+ ```
86
+
87
+ The goal is not to give a lecture about whistling. The goal is to keep the user inside a tight practice loop: observe, diagnose, adjust, try again.
88
+
89
+ ## Model And Signal Stack
90
+
91
+ | Layer | Role | Why it is small |
92
+ | --- | --- | --- |
93
+ | `MIT/ast-finetuned-audioset-10-10-0.4593` | Whistle-like audio confidence | 86.6M parameters |
94
+ | `librosa.pyin` | F0 / pitch stability | deterministic signal processing |
95
+ | MediaPipe Face Landmarker | visible lower-face posture guidance | browser-side visual assistant |
96
+ | Nemotron Nano 4B policy path | optional coach reasoning / LoRA target | under the 32B cap |
97
+ | Rule fallback | deterministic coach when no hosted policy is configured | keeps the Space reliable |
98
+
99
+ The deployed app does not fake model confidence. If AST is unavailable, the UI reports that clearly and falls back to pitch/airflow coaching rather than pretending a whistle score exists.
100
+
101
+ ## What Makes The AI Core
102
+
103
+ Whistle Coach is not a static tutorial and not a camera filter. The app builds a structured practice state from live features:
104
+
105
+ - face visible and centered
106
+ - mouth opening ratio
107
+ - lip roundness
108
+ - mouth symmetry
109
+ - jaw stability
110
+ - audio airflow estimate
111
+ - whistle confidence
112
+ - pitch stability
113
+ - stable duration
114
+
115
+ The coach then maps that state to the next action:
116
+
117
+ ```json
118
+ {
119
+ "state": "airflow_no_tone",
120
+ "active_step": "narrow_air_stream",
121
+ "coach_message": "You are close. Make the air stream narrower.",
122
+ "overlay_cues": [{"type": "air_stream", "direction": "narrower"}],
123
+ "next_drill": "narrow_air_stream"
124
+ }
125
+ ```
126
+
127
+ That is the AI-native part: every coaching sentence is grounded in the current attempt rather than prewritten lesson order.
128
+
129
+ ## Agentic Coach Trace
130
+
131
+ The optional coach policy is shaped as a compact agent:
132
+
133
+ ```text
134
+ Observe -> Diagnose -> Plan -> Act
135
+ ```
136
+
137
+ Every response can include a trace like:
138
+
139
+ ```json
140
+ [
141
+ {"step": "Observe", "action": "read_state", "detail": "mouth 76%, airflow 68%, tone 18%"},
142
+ {"step": "Diagnose", "action": "airflow_no_tone", "detail": "Air is moving but pitch is not stable."},
143
+ {"step": "Plan", "action": "narrow_air_stream", "detail": "Ask for a thinner air channel."},
144
+ {"step": "Act", "action": "coach_message", "detail": "You are close. Make the air stream narrower."}
145
+ ]
146
+ ```
147
+
148
+ This makes the app eligible for Best Agent consideration: the coach is not just a chatbot, it is a stateful sensor-to-action loop with explicit decisions.
149
+
150
+ ## Tiny Titan Angle
151
+
152
+ The core runtime is intentionally tiny:
153
+
154
+ - MIT AST: 86.6M parameters
155
+ - optional Nemotron Nano policy: 4B target
156
+ - no model in the app exceeds the 32B Build Small limit
157
+ - the SFT scaffold trains a narrow LoRA adapter on structured features, not raw media
158
+
159
+ The project is designed around the idea that a small, specialized model can be better for embodied coaching than a huge general model.
160
+
161
+ ## Off Brand / Custom UI
162
+
163
+ The experience intentionally pushes past default Gradio:
164
+
165
+ - full custom HTML/CSS/JS frontend mounted inside Gradio
166
+ - responsive three-panel practice layout
167
+ - live camera stage with mouth target and lower-face overlays
168
+ - audio listening panel with live confidence meters
169
+ - garden growth reward
170
+ - generated downloadable melody from the user's pitch contour
171
+
172
+ The UI is meant to feel like a small practice companion, not a dashboard wrapped around a model endpoint.
173
+
174
+ ## Privacy And Safety
175
+
176
+ Whistle Coach is playful, but it is careful:
177
+
178
+ - webcam frames stay in the browser for MediaPipe feature extraction
179
+ - the app uses derived mouth-shape features for coaching, not stored face images
180
+ - microphone snippets are analyzed for whistle confidence and pitch, but not saved as a dataset
181
+ - the included training data stores only structured practice states and coach decisions
182
+ - the app does not claim tongue detection
183
+ - this is not medical, speech therapy, or professional voice training software
184
+
185
+ ## Repository Map
186
+
187
+ ```text
188
+ app.py Gradio Space, AST loading, audio analysis, pYIN, melody API
189
+ frontend/index.html custom Gradio-mounted UI shell
190
+ frontend/style.css responsive custom interface
191
+ frontend/whistle_coach.js camera/audio loop, garden, overlays, API calls
192
+ frontend/audio_features.js browser audio feature payload helpers
193
+ frontend/face_features.js mouth-shape feature payload helpers
194
+ frontend/overlay_renderer.js practice-state messages and overlay cues
195
+ backend/state_classifier.py deterministic practice-state classifier
196
+ backend/coach_model.py optional Nemotron-compatible coach policy wrapper
197
+ backend/melody_generator.py pitch contour to downloadable WAV melody
198
+ data/coach_policy_sft.jsonl seed SFT data for coach policy
199
+ training/finetune_nemotron_policy.py LoRA fine-tuning scaffold
200
+ ```
201
+
202
+ ## Running Locally
203
+
204
+ ```bash
205
+ python -m venv .venv
206
+ source .venv/bin/activate
207
+ pip install -r requirements.txt
208
+ python app.py
209
+ ```
210
+
211
+ Open the local Gradio URL. Camera and microphone access require `localhost` or HTTPS in modern browsers.
212
+
213
+ ## Optional Nemotron Coach Policy
214
+
215
+ The Space works without external secrets. By default, the deterministic fallback keeps the live coach reliable.
216
+
217
+ To enable a hosted Nemotron-compatible coach endpoint, configure these Space secrets:
218
+
219
+ ```text
220
+ NEMOTRON_API_URL
221
+ NEMOTRON_API_KEY
222
+ NEMOTRON_MODEL
223
+ ```
224
+
225
+ The recommended fine-tuning target is:
226
+
227
+ ```text
228
+ nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16
229
+ ```
230
+
231
+ The training scaffold maps:
232
+
233
+ ```text
234
+ practice_state history -> coach_response JSON
235
+ ```
236
+
237
+ This keeps the learning problem narrow, auditable, and small.
238
+
239
+ ## Known Limitations
240
+
241
+ - Whistle confidence depends on microphone quality and background noise.
242
+ - "Airflow" is inferred from audio energy and breath-like spectral features; it is not physical airflow measurement.
243
+ - Pitch detection works best once a stable tone appears.
244
+ - MediaPipe can guide visible mouth posture, but it cannot detect tongue position.
245
+ - Some browsers ask for camera and microphone permissions differently; the UI is responsive, but permissions remain browser-controlled.
246
+
247
+ ## Hackathon Checklist
248
+
249
+ - [x] Gradio Space
250
+ - [x] Custom UI beyond default Gradio
251
+ - [x] Models under 32B parameters
252
+ - [x] Audio-first AI loop
253
+ - [x] Agent-style coach trace
254
+ - [x] Privacy notes
255
+ - [x] Local run instructions
256
+ - [ ] Public demo video link
257
+ - [ ] Public social post link
258
+ - [ ] Final confirmation that requested frontmatter tags match the exact awards being entered
259
+
260
+ ## Credits
261
+
262
+ Built for the Build Small Hackathon.
263
+
264
+ Whistle Coach was developed with OpenAI Codex as a coding partner, with the goal of showing how a small, embodied AI loop can make one tiny real-world skill easier, more delightful, and more teachable.
app.py ADDED
@@ -0,0 +1,669 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ '''Whistle Coach - audio-first Hugging Face Space.
2
+
3
+ The app uses MIT AST audio classification for whistle confidence and
4
+ librosa.pyin for pitch tracking. Camera guidance is intentionally visual-only.
5
+ '''
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import io
11
+ import json
12
+ import math
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import gradio as gr
17
+ import librosa
18
+ import numpy as np
19
+ import soundfile as sf
20
+ from scipy.signal import resample_poly
21
+ from transformers import pipeline
22
+
23
+ try:
24
+ import spaces
25
+ except Exception:
26
+ class _SpacesFallback:
27
+ @staticmethod
28
+ def GPU(*decorator_args, **decorator_kwargs):
29
+ if (
30
+ decorator_args
31
+ and callable(decorator_args[0])
32
+ and len(decorator_args) == 1
33
+ and not decorator_kwargs
34
+ ):
35
+ return decorator_args[0]
36
+
37
+ def decorator(function):
38
+ return function
39
+
40
+ return decorator
41
+
42
+ spaces = _SpacesFallback()
43
+
44
+ MODEL_ID = "MIT/ast-finetuned-audioset-10-10-0.4593"
45
+ AST_PARAMETERS = "86.6M"
46
+ AST_DEVICE = "cpu"
47
+ TARGET_SR = 16_000
48
+ MIN_AUDIO_SECONDS = 0.25
49
+ MAX_ANALYSIS_SECONDS = 4.0
50
+
51
+ BASE_DIR = Path(__file__).resolve().parent
52
+ MELODY_DIR = BASE_DIR / "outputs" / "melodies"
53
+ MELODY_DIR.mkdir(parents=True, exist_ok=True)
54
+
55
+ MODEL_LOAD_ERROR: str | None = None
56
+ try:
57
+ audio_classifier = pipeline(
58
+ "audio-classification",
59
+ model=MODEL_ID,
60
+ device=-1,
61
+ )
62
+ except Exception as exc:
63
+ audio_classifier = None
64
+ MODEL_LOAD_ERROR = f"{type(exc).__name__}: {exc}"
65
+
66
+ NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
67
+ STATE_ORDER = ["no_sound", "breath_noise", "tiny_whistle", "stable_pitch", "melody_ready"]
68
+ STATE_LABELS = {
69
+ "no_sound": "No sound",
70
+ "breath_noise": "Breath noise",
71
+ "tiny_whistle": "Tiny whistle",
72
+ "stable_pitch": "Stable pitch",
73
+ "melody_ready": "Melody",
74
+ }
75
+ GARDEN_STAGES = {
76
+ "no_sound": ("soil", "Soil", "Rest, breathe, and start softly."),
77
+ "breath_noise": ("wind", "Wind", "Air is moving. Make the opening smaller."),
78
+ "tiny_whistle": ("sprout", "Sprout", "A tiny tone appeared. Freeze that shape."),
79
+ "stable_pitch": ("flower", "Flower", "Hold the tone and keep it gentle."),
80
+ "melody_ready": ("melody", "Melody flower", "You can shape notes into a melody."),
81
+ }
82
+
83
+
84
+ def clamp(value: float, minimum: float = 0.0, maximum: float = 100.0) -> float:
85
+ return float(min(maximum, max(minimum, value)))
86
+
87
+
88
+ def hz_to_note(hz: float | None) -> str:
89
+ if hz is None or not np.isfinite(hz) or hz <= 0:
90
+ return "--"
91
+ midi = int(round(69 + 12 * math.log2(float(hz) / 440.0)))
92
+ octave = midi // 12 - 1
93
+ return f"{NOTE_NAMES[midi % 12]}{octave}"
94
+
95
+
96
+ def hz_to_midi(hz: float) -> int:
97
+ return int(round(69 + 12 * math.log2(float(hz) / 440.0)))
98
+
99
+
100
+ def midi_to_hz(midi: int) -> float:
101
+ return 440.0 * (2 ** ((midi - 69) / 12))
102
+
103
+
104
+ def normalize_audio_array(data: np.ndarray) -> np.ndarray:
105
+ y = np.asarray(data)
106
+ if y.ndim > 1:
107
+ y = np.mean(y, axis=1)
108
+ if np.issubdtype(y.dtype, np.integer):
109
+ peak = float(np.iinfo(y.dtype).max)
110
+ y = y.astype(np.float32) / max(peak, 1.0)
111
+ else:
112
+ y = y.astype(np.float32)
113
+ max_abs = float(np.max(np.abs(y))) if y.size else 0.0
114
+ if max_abs > 1.5:
115
+ y = y / max_abs
116
+ y = np.nan_to_num(y, nan=0.0, posinf=0.0, neginf=0.0)
117
+ return np.clip(y, -1.0, 1.0).astype(np.float32)
118
+
119
+
120
+ def load_audio_input(audio_input: Any) -> tuple[int, np.ndarray]:
121
+ '''Accept Gradio Audio numpy tuples, file paths, or path dictionaries.'''
122
+ if audio_input is None:
123
+ return TARGET_SR, np.zeros(0, dtype=np.float32)
124
+
125
+ if isinstance(audio_input, tuple) and len(audio_input) == 2:
126
+ sample_rate, data = audio_input
127
+ return int(sample_rate), normalize_audio_array(np.asarray(data))
128
+
129
+ if isinstance(audio_input, dict):
130
+ path = audio_input.get("path") or audio_input.get("name")
131
+ if path:
132
+ data, sample_rate = sf.read(path, always_2d=False)
133
+ return int(sample_rate), normalize_audio_array(np.asarray(data))
134
+
135
+ if isinstance(audio_input, (str, Path)):
136
+ data, sample_rate = sf.read(str(audio_input), always_2d=False)
137
+ return int(sample_rate), normalize_audio_array(np.asarray(data))
138
+
139
+ return TARGET_SR, np.zeros(0, dtype=np.float32)
140
+
141
+
142
+ def resample_to_target(sample_rate: int, y: np.ndarray) -> np.ndarray:
143
+ if y.size == 0:
144
+ return y.astype(np.float32)
145
+ if sample_rate == TARGET_SR:
146
+ return y.astype(np.float32)
147
+ gcd = math.gcd(int(sample_rate), TARGET_SR)
148
+ up = TARGET_SR // gcd
149
+ down = int(sample_rate) // gcd
150
+ return resample_poly(y, up, down).astype(np.float32)
151
+
152
+
153
+ def latest_window(y: np.ndarray, seconds: float = MAX_ANALYSIS_SECONDS) -> np.ndarray:
154
+ max_samples = int(TARGET_SR * seconds)
155
+ if y.size <= max_samples:
156
+ return y
157
+ return y[-max_samples:]
158
+
159
+
160
+ def classify_with_ast(y16: np.ndarray) -> tuple[float, list[dict[str, Any]], str | None]:
161
+ '''Run the real HF AST classifier and return whistle confidence.'''
162
+ if audio_classifier is None:
163
+ return 0.0, [], MODEL_LOAD_ERROR or "Audio model is not loaded."
164
+ if y16.size < int(TARGET_SR * MIN_AUDIO_SECONDS):
165
+ return 0.0, [], None
166
+
167
+ try:
168
+ predictions = audio_classifier(
169
+ {"array": y16.astype(np.float32), "sampling_rate": TARGET_SR},
170
+ top_k=8,
171
+ )
172
+ except TypeError:
173
+ predictions = audio_classifier(
174
+ {"array": y16.astype(np.float32), "sampling_rate": TARGET_SR}
175
+ )
176
+ except Exception as exc:
177
+ return 0.0, [], f"AST inference failed: {type(exc).__name__}: {exc}"
178
+
179
+ if isinstance(predictions, dict):
180
+ predictions = [predictions]
181
+ if predictions and isinstance(predictions[0], list):
182
+ predictions = predictions[0]
183
+
184
+ labels: list[dict[str, Any]] = []
185
+ whistle_score = 0.0
186
+ top_non_whistle = 0.0
187
+ for item in predictions[:8]:
188
+ label = str(item.get("label", ""))
189
+ score = float(item.get("score", 0.0) or 0.0)
190
+ labels.append({"label": label, "score": round(score, 4)})
191
+ normalized = label.lower().replace("_", " ").replace("-", " ")
192
+ if "whistl" in normalized or "whistle" in normalized:
193
+ whistle_score = max(whistle_score, score)
194
+ else:
195
+ top_non_whistle = max(top_non_whistle, score)
196
+
197
+ if whistle_score > 0:
198
+ confidence = whistle_score * 100.0
199
+ else:
200
+ confidence = clamp((1.0 - top_non_whistle) * 12.0, 0.0, 12.0)
201
+ return round(confidence, 2), labels, None
202
+
203
+
204
+ def longest_stable_duration(f0: np.ndarray, voiced: np.ndarray, hop_length: int) -> float:
205
+ longest = 0.0
206
+ current: list[float] = []
207
+
208
+ def finish_run(values: list[float]) -> float:
209
+ if len(values) < 3:
210
+ return 0.0
211
+ arr = np.asarray(values, dtype=np.float64)
212
+ center = float(np.nanmedian(arr))
213
+ if center <= 0:
214
+ return 0.0
215
+ cents = 1200.0 * np.log2(arr / center)
216
+ if float(np.nanstd(cents)) > 95.0:
217
+ return 0.0
218
+ return len(values) * hop_length / TARGET_SR
219
+
220
+ for hz, is_voiced in zip(f0, voiced):
221
+ if bool(is_voiced) and np.isfinite(hz) and hz > 0:
222
+ current.append(float(hz))
223
+ else:
224
+ longest = max(longest, finish_run(current))
225
+ current = []
226
+ longest = max(longest, finish_run(current))
227
+ return round(float(longest), 2)
228
+
229
+
230
+ def analyze_pitch(y16: np.ndarray) -> dict[str, Any]:
231
+ hop_length = 256
232
+ frame_length = 2048
233
+ if y16.size < frame_length:
234
+ return {
235
+ "pitch_detected": False,
236
+ "pitch_note": "--",
237
+ "mean_pitch_hz": None,
238
+ "stability_score": 0,
239
+ "stable_duration": 0.0,
240
+ "pitch_contour": [],
241
+ "voiced_ratio": 0.0,
242
+ "pitch_std_cents": None,
243
+ "pitch_movement_semitones": 0.0,
244
+ }
245
+
246
+ try:
247
+ f0, voiced_flag, voiced_prob = librosa.pyin(
248
+ y16,
249
+ fmin=librosa.note_to_hz("C5"),
250
+ fmax=librosa.note_to_hz("C8"),
251
+ sr=TARGET_SR,
252
+ frame_length=frame_length,
253
+ hop_length=hop_length,
254
+ )
255
+ except Exception:
256
+ return {
257
+ "pitch_detected": False,
258
+ "pitch_note": "--",
259
+ "mean_pitch_hz": None,
260
+ "stability_score": 0,
261
+ "stable_duration": 0.0,
262
+ "pitch_contour": [],
263
+ "voiced_ratio": 0.0,
264
+ "pitch_std_cents": None,
265
+ "pitch_movement_semitones": 0.0,
266
+ }
267
+
268
+ voiced = np.asarray(voiced_flag, dtype=bool) & np.isfinite(f0)
269
+ valid_f0 = np.asarray(f0)[voiced]
270
+ voiced_ratio = float(np.mean(voiced)) if len(voiced) else 0.0
271
+ pitch_detected = bool(valid_f0.size >= 3 and voiced_ratio >= 0.06)
272
+
273
+ mean_pitch: float | None = None
274
+ pitch_std_cents: float | None = None
275
+ pitch_movement = 0.0
276
+ if valid_f0.size:
277
+ mean_pitch = float(np.nanmedian(valid_f0))
278
+ if mean_pitch > 0 and valid_f0.size > 1:
279
+ cents = 1200.0 * np.log2(valid_f0 / mean_pitch)
280
+ pitch_std_cents = float(np.nanstd(cents))
281
+ pitch_movement = float(np.nanmax(cents) - np.nanmin(cents)) / 100.0
282
+
283
+ std_score = 0.0
284
+ if pitch_std_cents is not None:
285
+ std_score = clamp(100.0 - (pitch_std_cents * 1.35), 0.0, 100.0)
286
+ stability_score = clamp((voiced_ratio * 62.0) + (std_score * 0.38))
287
+ stable_duration = longest_stable_duration(np.asarray(f0), voiced, hop_length)
288
+
289
+ times = librosa.frames_to_time(np.arange(len(f0)), sr=TARGET_SR, hop_length=hop_length)
290
+ contour: list[dict[str, Any]] = []
291
+ valid_indices = np.where(np.isfinite(f0) & voiced)[0]
292
+ if valid_indices.size:
293
+ step = max(1, int(math.ceil(valid_indices.size / 90)))
294
+ for idx in valid_indices[::step]:
295
+ hz = float(f0[idx])
296
+ contour.append({
297
+ "time_ms": int(round(float(times[idx]) * 1000)),
298
+ "hz": round(hz, 2),
299
+ "note": hz_to_note(hz),
300
+ })
301
+
302
+ return {
303
+ "pitch_detected": pitch_detected,
304
+ "pitch_note": hz_to_note(mean_pitch),
305
+ "mean_pitch_hz": round(mean_pitch, 2) if mean_pitch else None,
306
+ "stability_score": int(round(stability_score)) if pitch_detected else 0,
307
+ "stable_duration": stable_duration if pitch_detected else 0.0,
308
+ "pitch_contour": contour,
309
+ "voiced_ratio": round(voiced_ratio, 3),
310
+ "pitch_std_cents": round(pitch_std_cents, 2) if pitch_std_cents is not None else None,
311
+ "pitch_movement_semitones": round(pitch_movement, 2),
312
+ }
313
+
314
+
315
+ def analyze_airflow(y16: np.ndarray) -> dict[str, float]:
316
+ if y16.size == 0:
317
+ return {"rms": 0.0, "db": -120.0, "airflow_score": 0.0, "noise_score": 0.0}
318
+ rms = float(np.sqrt(np.mean(np.square(y16))))
319
+ db = float(20.0 * np.log10(rms + 1e-9))
320
+ airflow_score = clamp((db + 55.0) / 32.0 * 100.0)
321
+ try:
322
+ flatness = float(np.nanmean(librosa.feature.spectral_flatness(y=y16, n_fft=1024, hop_length=256)))
323
+ zcr = float(np.nanmean(librosa.feature.zero_crossing_rate(y16, frame_length=1024, hop_length=256)))
324
+ except Exception:
325
+ flatness = 0.0
326
+ zcr = 0.0
327
+ noise_score = clamp((flatness * 180.0) + (zcr * 110.0))
328
+ return {
329
+ "rms": round(rms, 5),
330
+ "db": round(db, 2),
331
+ "airflow_score": int(round(airflow_score)),
332
+ "noise_score": round(noise_score, 2),
333
+ }
334
+
335
+
336
+ def choose_state_and_feedback(result: dict[str, Any]) -> tuple[str, str, str]:
337
+ airflow = float(result["airflow_score"])
338
+ confidence = float(result["whistle_confidence"])
339
+ pitch_detected = bool(result["pitch_detected"])
340
+ stable_duration = float(result["stable_duration"] or 0.0)
341
+ noise_score = float(result.get("noise_score", 0.0) or 0.0)
342
+ movement = float(result.get("pitch_movement_semitones", 0.0) or 0.0)
343
+
344
+ if pitch_detected and stable_duration >= 1.0 and movement >= 2.0:
345
+ return (
346
+ "melody_ready",
347
+ "Nice - you are changing notes. Try making a melody.",
348
+ "Move the pitch slowly, like stepping between two nearby notes.",
349
+ )
350
+ if pitch_detected and stable_duration >= 1.0:
351
+ return (
352
+ "stable_pitch",
353
+ "Great! Hold this tone longer.",
354
+ "Stay still for one more second before trying to move the pitch.",
355
+ )
356
+ if pitch_detected:
357
+ return (
358
+ "tiny_whistle",
359
+ "Tiny whistle found. Freeze this mouth shape.",
360
+ "Use less air now; keep the same lip opening and hold it steady.",
361
+ )
362
+ if airflow < 10:
363
+ return (
364
+ "no_sound",
365
+ "Blow a little more, but stay gentle.",
366
+ "Keep your lips relaxed, then send a small stream of air through the center.",
367
+ )
368
+ if confidence >= 25:
369
+ return (
370
+ "tiny_whistle",
371
+ "You are close. Make the air stream narrower.",
372
+ "Round the lips a little more and soften the airflow.",
373
+ )
374
+ if airflow >= 35 or noise_score >= 18:
375
+ return (
376
+ "breath_noise",
377
+ "You are producing air noise. Make the lip opening smaller and soften the airflow.",
378
+ "Try a quiet 'yuh yuh yuh' breath with a smaller opening.",
379
+ )
380
+ return (
381
+ "no_sound",
382
+ "Blow a little more, but stay gentle.",
383
+ "Stay relaxed. The first goal is a soft, steady stream of air.",
384
+ )
385
+
386
+
387
+ def analyze_audio(audio_input: Any) -> dict[str, Any]:
388
+ '''Analyze a Gradio Audio input using AST and librosa.pyin.'''
389
+ sample_rate, y = load_audio_input(audio_input)
390
+ y16 = latest_window(resample_to_target(sample_rate, y))
391
+ duration = y16.size / TARGET_SR if TARGET_SR else 0.0
392
+
393
+ airflow = analyze_airflow(y16)
394
+ whistle_confidence, ast_labels, ast_error = classify_with_ast(y16)
395
+ pitch = analyze_pitch(y16)
396
+
397
+ result: dict[str, Any] = {
398
+ "airflow_score": int(airflow["airflow_score"]),
399
+ "whistle_confidence": int(round(whistle_confidence)),
400
+ "pitch_detected": bool(pitch["pitch_detected"]),
401
+ "pitch_note": pitch["pitch_note"],
402
+ "mean_pitch_hz": pitch["mean_pitch_hz"],
403
+ "stability_score": int(pitch["stability_score"]),
404
+ "stable_duration": float(pitch["stable_duration"]),
405
+ "state": "no_sound",
406
+ "coach_feedback": "Blow a little more, but stay gentle.",
407
+ "next_tip": "Start with a soft, narrow stream of air.",
408
+ "pitch_contour": pitch["pitch_contour"],
409
+ "audio_seconds": round(duration, 2),
410
+ "rms": airflow["rms"],
411
+ "db": airflow["db"],
412
+ "noise_score": airflow["noise_score"],
413
+ "voiced_ratio": pitch["voiced_ratio"],
414
+ "pitch_std_cents": pitch["pitch_std_cents"],
415
+ "pitch_movement_semitones": pitch["pitch_movement_semitones"],
416
+ "ast_top_labels": ast_labels,
417
+ "ast_error": ast_error,
418
+ "model_stack": {
419
+ "audio_model": f"{MODEL_ID} ({AST_PARAMETERS}, {AST_DEVICE})",
420
+ "pitch_tracking": "librosa.pyin C5-C8",
421
+ "visual_assistant": "MediaPipe visible mouth guidance only; no tongue detection",
422
+ },
423
+ }
424
+
425
+ state, feedback, next_tip = choose_state_and_feedback(result)
426
+ result["state"] = state
427
+ result["coach_feedback"] = feedback
428
+ result["next_tip"] = next_tip
429
+ return result
430
+
431
+
432
+ def contour_to_note_sequence(contour: list[dict[str, Any]]) -> list[dict[str, Any]]:
433
+ notes: list[dict[str, Any]] = []
434
+ current_midi: int | None = None
435
+ start_ms = 0
436
+ last_ms = 0
437
+ for sample in contour:
438
+ hz = float(sample.get("hz", sample.get("frequency_hz", 0)) or 0)
439
+ t_ms = int(sample.get("time_ms", sample.get("t", 0)) or 0)
440
+ if hz < 120:
441
+ continue
442
+ midi = hz_to_midi(hz)
443
+ if current_midi is None:
444
+ current_midi = midi
445
+ start_ms = t_ms
446
+ elif abs(midi - current_midi) > 1:
447
+ notes.append({"midi": current_midi, "duration_ms": max(180, last_ms - start_ms)})
448
+ current_midi = midi
449
+ start_ms = t_ms
450
+ last_ms = t_ms
451
+ if current_midi is not None:
452
+ notes.append({"midi": current_midi, "duration_ms": max(260, last_ms - start_ms)})
453
+ return notes[:12]
454
+
455
+
456
+ def generate_melody_from_contour(contour: list[dict[str, Any]]) -> tuple[str | None, str]:
457
+ notes = contour_to_note_sequence(contour)
458
+ if not notes:
459
+ return None, ""
460
+ sample_rate = 22_050
461
+ samples: list[np.ndarray] = []
462
+ for note in notes:
463
+ hz = midi_to_hz(int(note["midi"]))
464
+ duration = min(0.75, max(0.18, int(note["duration_ms"]) / 1000.0))
465
+ t = np.linspace(0.0, duration, int(sample_rate * duration), endpoint=False)
466
+ tone = np.sin(2 * np.pi * hz * t) + 0.25 * np.sin(2 * np.pi * hz * 2 * t)
467
+ fade = min(220, max(12, len(tone) // 8))
468
+ envelope = np.ones_like(tone)
469
+ envelope[:fade] = np.linspace(0.0, 1.0, fade)
470
+ envelope[-fade:] = np.linspace(1.0, 0.0, fade)
471
+ samples.append((tone * envelope * 0.23).astype(np.float32))
472
+ samples.append(np.zeros(int(sample_rate * 0.045), dtype=np.float32))
473
+ wav = np.concatenate(samples) if samples else np.zeros(1, dtype=np.float32)
474
+ output_path = MELODY_DIR / "whistle_melody.wav"
475
+ sf.write(output_path, wav, sample_rate)
476
+ names = [hz_to_note(midi_to_hz(int(note["midi"]))) for note in notes]
477
+ return str(output_path), " - ".join(names)
478
+
479
+
480
+ FRONTEND_DIR = BASE_DIR / "frontend"
481
+ HTML_PATH = FRONTEND_DIR / "index.html"
482
+ STYLE_PATH = FRONTEND_DIR / "style.css"
483
+ SCRIPT_PATHS = [
484
+ FRONTEND_DIR / "policy.js",
485
+ FRONTEND_DIR / "landmarks.js",
486
+ FRONTEND_DIR / "audio_features.js",
487
+ FRONTEND_DIR / "face_features.js",
488
+ FRONTEND_DIR / "overlay_renderer.js",
489
+ FRONTEND_DIR / "whistle_coach.js",
490
+ ]
491
+
492
+
493
+ def load_frontend_html() -> str:
494
+ return HTML_PATH.read_text(encoding="utf-8")
495
+
496
+
497
+ def load_frontend_css() -> str:
498
+ return STYLE_PATH.read_text(encoding="utf-8") if STYLE_PATH.exists() else ""
499
+
500
+
501
+ def load_js_bundle() -> str:
502
+ parts: list[str] = []
503
+ for script_path in SCRIPT_PATHS:
504
+ if script_path.exists():
505
+ parts.append(script_path.read_text(encoding="utf-8"))
506
+ return "\n\n".join(parts)
507
+
508
+
509
+ @spaces.GPU(duration=1)
510
+ def zerogpu_startup_probe() -> str:
511
+ """Tiny hidden hook so ZeroGPU Spaces start without charging live audio calls."""
512
+ return "ready"
513
+
514
+
515
+ def analyze_audio_window(audio_payload_json: str) -> dict[str, Any]:
516
+ """Decode a browser-captured WAV window and run the real AST/librosa stack."""
517
+ if not audio_payload_json:
518
+ result = empty_result()
519
+ result["ast_error"] = MODEL_LOAD_ERROR
520
+ return result
521
+
522
+ try:
523
+ payload = json.loads(audio_payload_json)
524
+ encoded = payload.get("data_base64") or ""
525
+ audio_bytes = base64.b64decode(encoded)
526
+ data, sample_rate = sf.read(io.BytesIO(audio_bytes), always_2d=False)
527
+ except Exception as exc:
528
+ result = empty_result()
529
+ result["ast_error"] = f"Audio decode failed: {type(exc).__name__}: {exc}"
530
+ result["coach_feedback"] = "The microphone window could not be decoded. Try again."
531
+ result["next_tip"] = "Keep the browser microphone permission enabled."
532
+ return result
533
+
534
+ result = analyze_audio((int(sample_rate), data))
535
+ result["source"] = "browser_live_window"
536
+ return result
537
+
538
+
539
+ def empty_result() -> dict[str, Any]:
540
+ return {
541
+ "airflow_score": 0,
542
+ "whistle_confidence": 0,
543
+ "pitch_detected": False,
544
+ "pitch_note": "--",
545
+ "mean_pitch_hz": None,
546
+ "stability_score": 0,
547
+ "stable_duration": 0.0,
548
+ "state": "no_sound",
549
+ "coach_feedback": "Ready when you are.",
550
+ "next_tip": "The coach listens to each audio window and updates feedback.",
551
+ "pitch_contour": [],
552
+ "rms": 0,
553
+ "db": -120,
554
+ "voiced_ratio": 0,
555
+ "pitch_std_cents": None,
556
+ "pitch_movement_semitones": 0,
557
+ "ast_top_labels": [],
558
+ "ast_error": MODEL_LOAD_ERROR,
559
+ "model_stack": {
560
+ "audio_model": f"{MODEL_ID} ({AST_PARAMETERS}, {AST_DEVICE})",
561
+ "pitch_tracking": "librosa.pyin C5-C8",
562
+ "visual_assistant": "MediaPipe visible mouth guidance only; no tongue detection",
563
+ },
564
+ }
565
+
566
+
567
+ def coach_reason(practice_state_json: str, history_json: str = "[]") -> dict[str, Any]:
568
+ """Small rule-based policy kept for the old frontend trace panel."""
569
+ try:
570
+ practice_state = json.loads(practice_state_json or "{}")
571
+ except json.JSONDecodeError:
572
+ practice_state = {}
573
+ state_name = practice_state.get("state", "idle")
574
+ audio = practice_state.get("audio", {}) if isinstance(practice_state, dict) else {}
575
+ airflow = float(audio.get("airflow_score", 0) or 0)
576
+ stability = float(audio.get("pitch_stability_score", 0) or 0)
577
+
578
+ if stability >= 0.58:
579
+ message = "Great! Hold this tone longer."
580
+ drill = "Hold a steady tone"
581
+ success = True
582
+ elif airflow >= 0.55:
583
+ message = "You are producing air noise. Make the lip opening smaller and soften the airflow."
584
+ drill = "Make the air stream narrower"
585
+ success = False
586
+ elif airflow >= 0.25:
587
+ message = "You are close. Make the air stream narrower."
588
+ drill = "Gentle airflow"
589
+ success = False
590
+ else:
591
+ message = "Blow a little more, but stay gentle."
592
+ drill = "Gentle airflow"
593
+ success = False
594
+
595
+ return {
596
+ "coach_message": message,
597
+ "next_drill": drill,
598
+ "success_trigger": success,
599
+ "model_source": "local rule fallback; AST/librosa panel uses live audio model",
600
+ "agent_trace": [
601
+ {"step": "Observe", "detail": f"Browser practice state: {state_name}."},
602
+ {"step": "Listen", "detail": "Backend AST/librosa analyzes one-second audio windows."},
603
+ {"step": "Coach", "detail": drill},
604
+ ],
605
+ }
606
+
607
+
608
+ def generate_melody(pitch_contour_json: str) -> dict[str, Any]:
609
+ try:
610
+ contour = json.loads(pitch_contour_json or "[]")
611
+ except json.JSONDecodeError:
612
+ contour = []
613
+ path, sequence = generate_melody_from_contour(contour if isinstance(contour, list) else [])
614
+ return {"path": path, "sequence": sequence, "ready": bool(path)}
615
+
616
+
617
+ with gr.Blocks(
618
+ title="Whistle Coach",
619
+ fill_width=True,
620
+ ) as demo:
621
+ gr.HTML(load_frontend_html(), container=False, padding=False)
622
+
623
+ zerogpu_trigger = gr.Button(visible=False)
624
+ zerogpu_status = gr.Textbox(visible=False)
625
+ zerogpu_trigger.click(
626
+ zerogpu_startup_probe,
627
+ outputs=zerogpu_status,
628
+ api_name="zerogpu_startup_probe",
629
+ show_progress="hidden",
630
+ )
631
+
632
+ audio_payload = gr.Textbox(visible=False)
633
+ audio_result = gr.JSON(visible=False)
634
+ audio_payload.submit(
635
+ analyze_audio_window,
636
+ inputs=audio_payload,
637
+ outputs=audio_result,
638
+ api_name="analyze_audio_window",
639
+ show_progress="hidden",
640
+ )
641
+
642
+ practice_state = gr.Textbox(visible=False)
643
+ coach_history = gr.Textbox(visible=False)
644
+ coach_output = gr.JSON(visible=False)
645
+ practice_state.submit(
646
+ coach_reason,
647
+ inputs=[practice_state, coach_history],
648
+ outputs=coach_output,
649
+ api_name="coach_reason",
650
+ show_progress="hidden",
651
+ )
652
+
653
+ melody_contour = gr.Textbox(visible=False)
654
+ melody_output = gr.JSON(visible=False)
655
+ melody_contour.submit(
656
+ generate_melody,
657
+ inputs=melody_contour,
658
+ outputs=melody_output,
659
+ api_name="generate_melody",
660
+ show_progress="hidden",
661
+ )
662
+
663
+
664
+ if __name__ == "__main__":
665
+ demo.queue(default_concurrency_limit=1).launch(
666
+ theme=gr.themes.Soft(),
667
+ css=load_frontend_css(),
668
+ js=load_js_bundle(),
669
+ )
assets/placeholder.jpg ADDED

Git LFS Details

  • SHA256: b64d116295e9847b884c40b0489c50c8101aeaabd1c3e612e52a9cda78efca6f
  • Pointer size: 131 Bytes
  • Size of remote file: 219 kB
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Backend modules for Whistle Coach."""
backend/coach_model.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nemotron coach reasoning wrapper with rule-based fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ from .prompts import SYSTEM_PROMPT, build_coach_prompt
11
+ from .schemas import CoachResponse
12
+
13
+
14
+ class NemotronCoach:
15
+ """Small replaceable coach-reasoning module.
16
+
17
+ V1 keeps this optional. Set NEMOTRON_API_URL, NEMOTRON_API_KEY, and
18
+ NEMOTRON_MODEL to call a hosted Nemotron-compatible chat endpoint. Without
19
+ those variables, the deterministic fallback keeps the Space fully working.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self.api_url = os.getenv("NEMOTRON_API_URL", "").strip()
24
+ self.api_key = os.getenv("NEMOTRON_API_KEY", "").strip()
25
+ self.model = os.getenv("NEMOTRON_MODEL", "nvidia/nemotron-nano-4b-v1")
26
+
27
+ def reason(self, practice_state: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
28
+ if self.api_url and self.api_key:
29
+ try:
30
+ return self._call_nemotron(practice_state, history)
31
+ except Exception as error: # noqa: BLE001 - fallback is intentional.
32
+ fallback = self._fallback(practice_state)
33
+ fallback["diagnosis"] += f" Nemotron fallback used: {error.__class__.__name__}."
34
+ return fallback
35
+ return self._fallback(practice_state)
36
+
37
+ def _call_nemotron(self, practice_state: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
38
+ payload = {
39
+ "model": self.model,
40
+ "messages": [
41
+ {"role": "system", "content": SYSTEM_PROMPT},
42
+ {"role": "user", "content": build_coach_prompt(practice_state, history)},
43
+ ],
44
+ "temperature": 0.2,
45
+ "max_tokens": 220,
46
+ }
47
+ request = urllib.request.Request(
48
+ self.api_url,
49
+ data=json.dumps(payload).encode("utf-8"),
50
+ headers={
51
+ "Authorization": f"Bearer {self.api_key}",
52
+ "Content-Type": "application/json",
53
+ },
54
+ method="POST",
55
+ )
56
+ with urllib.request.urlopen(request, timeout=10) as response:
57
+ raw = json.loads(response.read().decode("utf-8"))
58
+
59
+ content = raw.get("choices", [{}])[0].get("message", {}).get("content", "{}")
60
+ parsed = self._parse_model_json(content)
61
+ parsed["model_source"] = "nemotron"
62
+ return self._normalize_response(parsed, practice_state)
63
+
64
+ def _parse_model_json(self, content: str) -> dict[str, Any]:
65
+ """Parse strict JSON, tolerating fenced responses from hosted endpoints."""
66
+ content = content.strip()
67
+ if content.startswith("```"):
68
+ content = content.strip("`")
69
+ if content.startswith("json"):
70
+ content = content[4:].strip()
71
+ start = content.find("{")
72
+ end = content.rfind("}")
73
+ if start >= 0 and end > start:
74
+ content = content[start : end + 1]
75
+ return json.loads(content)
76
+
77
+ def _normalize_response(self, response: dict[str, Any], practice_state: dict[str, Any]) -> dict[str, Any]:
78
+ fallback = self._fallback(practice_state)
79
+ normalized = {**fallback, **response}
80
+ normalized["overlay_cues"] = list(normalized.get("overlay_cues") or [])
81
+ normalized["agent_trace"] = list(normalized.get("agent_trace") or fallback["agent_trace"])
82
+ normalized["success_trigger"] = bool(practice_state.get("success_trigger", normalized.get("success_trigger", False)))
83
+ return normalized
84
+
85
+ def _fallback(self, practice_state: dict[str, Any]) -> dict[str, Any]:
86
+ state = practice_state.get("state", "idle")
87
+ active_step = practice_state.get("active_step", "start")
88
+ success = bool(practice_state.get("success_trigger", False))
89
+ face = practice_state.get("face", {}) or {}
90
+ audio = practice_state.get("audio", {}) or {}
91
+
92
+ messages = {
93
+ "idle": "Press Start Practice when you are ready.",
94
+ "no_face": "Please face the camera and center your mouth in the frame.",
95
+ "mouth_too_open": "Make the opening smaller and relax your jaw.",
96
+ "not_rounded": "Pull both lip corners inward, like saying 'u'.",
97
+ "asymmetric_mouth": "Keep both lip corners level and centered.",
98
+ "mouth_ready_no_airflow": "Good mouth shape. Now blow gently.",
99
+ "airflow_no_tone": "You are close. Make the air stream narrower.",
100
+ "stable_whistle": "Great! Hold that whistle steady.",
101
+ }
102
+ drills = {
103
+ "idle": "start_session",
104
+ "no_face": "center_face",
105
+ "mouth_too_open": "small_opening_hold",
106
+ "not_rounded": "lip_rounding",
107
+ "asymmetric_mouth": "corner_leveling",
108
+ "mouth_ready_no_airflow": "gentle_airflow",
109
+ "airflow_no_tone": "narrow_air_stream",
110
+ "stable_whistle": "steady_tone_hold",
111
+ }
112
+ cues = {
113
+ "mouth_too_open": [{"type": "mouth_opening", "direction": "smaller"}],
114
+ "not_rounded": [{"type": "lip_corners", "direction": "inward"}],
115
+ "asymmetric_mouth": [{"type": "symmetry", "direction": "level"}],
116
+ "airflow_no_tone": [{"type": "air_stream", "direction": "narrower"}],
117
+ "stable_whistle": [{"type": "success", "direction": "hold"}],
118
+ }
119
+ trace = [
120
+ {
121
+ "step": "Observe",
122
+ "action": "read_state",
123
+ "detail": (
124
+ f"mouth {round(float(face.get('mouth_shape_score', 0)) * 100)}%, "
125
+ f"airflow {round(float(audio.get('airflow_score', 0)) * 100)}%, "
126
+ f"tone {round(float(audio.get('pitch_stability_score', 0)) * 100)}%"
127
+ ),
128
+ },
129
+ {
130
+ "step": "Diagnose",
131
+ "action": state,
132
+ "detail": f"Current blocker is {state}.",
133
+ },
134
+ {
135
+ "step": "Plan",
136
+ "action": drills.get(state, "observe"),
137
+ "detail": f"Focus on one micro-drill: {drills.get(state, 'observe')}.",
138
+ },
139
+ {
140
+ "step": "Act",
141
+ "action": "coach_message",
142
+ "detail": messages.get(state, "Keep adjusting one small thing at a time."),
143
+ },
144
+ ]
145
+ response = CoachResponse(
146
+ coach_message=messages.get(state, "Keep adjusting one small thing at a time."),
147
+ diagnosis=f"Rule fallback classified state as {state}.",
148
+ overlay_cues=cues.get(state, []),
149
+ active_step=active_step,
150
+ agent_trace=trace,
151
+ next_drill=drills.get(state, "observe"),
152
+ difficulty_adjustment="easier" if state in {"no_face", "mouth_too_open"} else "hold",
153
+ success_trigger=success,
154
+ model_source="rule_fallback",
155
+ )
156
+ return response.to_dict()
backend/melody_generator.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Whistle pitch contour to simple downloadable WAV melody."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import wave
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+
11
+ SAMPLE_RATE = 22050
12
+ A4_HZ = 440.0
13
+
14
+
15
+ def hz_to_midi(hz: float) -> int:
16
+ if hz <= 0:
17
+ return 69
18
+ return int(round(69 + 12 * math.log2(hz / A4_HZ)))
19
+
20
+
21
+ def midi_to_hz(midi: int) -> float:
22
+ return A4_HZ * (2 ** ((midi - 69) / 12))
23
+
24
+
25
+ def quantize_pitch_contour(pitch_contour: Iterable[dict]) -> list[dict]:
26
+ """Convert browser pitch samples into a compact note list."""
27
+ notes: list[dict] = []
28
+ current_midi: int | None = None
29
+ start_ms = 0
30
+ last_ms = 0
31
+
32
+ for sample in pitch_contour:
33
+ hz = float(sample.get("frequency_hz", sample.get("hz", 0)) or 0)
34
+ t_ms = int(sample.get("time_ms", sample.get("t", 0)) or 0)
35
+ if hz < 120:
36
+ continue
37
+ midi = hz_to_midi(hz)
38
+ if current_midi is None:
39
+ current_midi = midi
40
+ start_ms = t_ms
41
+ elif abs(midi - current_midi) > 1:
42
+ duration = max(160, last_ms - start_ms)
43
+ notes.append({"midi": current_midi, "duration_ms": duration})
44
+ current_midi = midi
45
+ start_ms = t_ms
46
+ last_ms = t_ms
47
+
48
+ if current_midi is not None:
49
+ notes.append({"midi": current_midi, "duration_ms": max(300, last_ms - start_ms)})
50
+
51
+ if not notes:
52
+ notes = [
53
+ {"midi": 72, "duration_ms": 360},
54
+ {"midi": 76, "duration_ms": 360},
55
+ {"midi": 79, "duration_ms": 520},
56
+ ]
57
+ return notes[:12]
58
+
59
+
60
+ def generate_melody_wav(pitch_contour: list[dict], output_path: Path) -> Path:
61
+ """Generate a tiny sine-wave arrangement from quantized whistle notes."""
62
+ notes = quantize_pitch_contour(pitch_contour)
63
+ samples: list[int] = []
64
+ amplitude = 0.24
65
+
66
+ for note in notes:
67
+ hz = midi_to_hz(int(note["midi"]))
68
+ duration_samples = int(SAMPLE_RATE * (int(note["duration_ms"]) / 1000))
69
+ fade_samples = max(1, int(SAMPLE_RATE * 0.025))
70
+ for index in range(duration_samples):
71
+ envelope = 1.0
72
+ if index < fade_samples:
73
+ envelope = index / fade_samples
74
+ elif duration_samples - index < fade_samples:
75
+ envelope = (duration_samples - index) / fade_samples
76
+ value = math.sin(2 * math.pi * hz * index / SAMPLE_RATE)
77
+ value += 0.35 * math.sin(2 * math.pi * hz * 2 * index / SAMPLE_RATE)
78
+ samples.append(int(32767 * amplitude * envelope * value))
79
+
80
+ samples.extend([0] * int(SAMPLE_RATE * 0.04))
81
+
82
+ output_path.parent.mkdir(parents=True, exist_ok=True)
83
+ with wave.open(str(output_path), "wb") as wav:
84
+ wav.setnchannels(1)
85
+ wav.setsampwidth(2)
86
+ wav.setframerate(SAMPLE_RATE)
87
+ for sample in samples:
88
+ wav.writeframesraw(int(sample).to_bytes(2, "little", signed=True))
89
+ return output_path
backend/prompts.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt templates for the Nemotron coach reasoning model."""
2
+
3
+ SYSTEM_PROMPT = """You are Whistle Coach, a tiny body-skill coach.
4
+ You receive structured real-time practice_state JSON from a browser pipeline.
5
+ Act as a compact embodied-skill policy model: observe the face/audio state,
6
+ diagnose the blocker, choose exactly one next drill, and produce a traceable
7
+ coaching action.
8
+
9
+ Be playful but concrete. Do not make medical claims. Do not mention hidden
10
+ model internals. Keep output JSON-only with these keys:
11
+ coach_message, diagnosis, overlay_cues, active_step, agent_trace, next_drill,
12
+ difficulty_adjustment, success_trigger.
13
+
14
+ agent_trace must be a list of 4 compact objects with step/action/detail:
15
+ Observe, Diagnose, Plan, Act."""
16
+
17
+
18
+ def build_coach_prompt(practice_state: dict, history: list[dict]) -> str:
19
+ return (
20
+ "Current practice_state JSON:\n"
21
+ f"{practice_state}\n\n"
22
+ "Short history, newest last:\n"
23
+ f"{history[-6:]}\n\n"
24
+ "Return compact JSON only."
25
+ )
backend/schemas.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared schemas for Whistle Coach's embodied AI pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class FaceFeatures:
11
+ face_visible: bool = False
12
+ face_centered: bool = False
13
+ mouth_opening_ratio: float = 0.0
14
+ lip_roundness_score: float = 0.0
15
+ upper_lip_lift_score: float = 0.0
16
+ jaw_stability_score: float = 0.0
17
+ mouth_symmetry_score: float = 0.0
18
+ mouth_shape_score: float = 0.0
19
+
20
+
21
+ @dataclass
22
+ class AudioFeatures:
23
+ rms_volume: float = 0.0
24
+ airflow_score: float = 0.0
25
+ peak_frequency_hz: float = 0.0
26
+ pitch_stability_score: float = 0.0
27
+ stable_duration_ms: int = 0
28
+
29
+
30
+ @dataclass
31
+ class PracticeState:
32
+ state: str = "idle"
33
+ active_step: str = "start"
34
+ face: FaceFeatures = field(default_factory=FaceFeatures)
35
+ audio: AudioFeatures = field(default_factory=AudioFeatures)
36
+ success_trigger: bool = False
37
+ confidence: float = 0.0
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return asdict(self)
41
+
42
+
43
+ @dataclass
44
+ class CoachResponse:
45
+ coach_message: str
46
+ diagnosis: str
47
+ overlay_cues: list[dict[str, Any]]
48
+ active_step: str
49
+ agent_trace: list[dict[str, Any]] = field(default_factory=list)
50
+ next_drill: str = "observe"
51
+ difficulty_adjustment: str = "hold"
52
+ success_trigger: bool = False
53
+ model_source: str = "rule_fallback"
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ return asdict(self)
backend/state_classifier.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rule-based state classifier for browser-extracted face and audio features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .schemas import AudioFeatures, FaceFeatures, PracticeState
8
+
9
+
10
+ def _num(value: Any, default: float = 0.0) -> float:
11
+ try:
12
+ return float(value)
13
+ except (TypeError, ValueError):
14
+ return default
15
+
16
+
17
+ def classify_practice_state(payload: dict[str, Any]) -> dict[str, Any]:
18
+ """Convert raw feature JSON into a stable practice state.
19
+
20
+ This is intentionally simple and deterministic. Nemotron can reason over
21
+ the resulting state, but the app still works when no model is available.
22
+ """
23
+ face_payload = payload.get("face", payload.get("face_features", {})) or {}
24
+ audio_payload = payload.get("audio", payload.get("audio_features", {})) or {}
25
+
26
+ face = FaceFeatures(
27
+ face_visible=bool(face_payload.get("face_visible", face_payload.get("visible", False))),
28
+ face_centered=bool(face_payload.get("face_centered", face_payload.get("centered", False))),
29
+ mouth_opening_ratio=_num(face_payload.get("mouth_opening_ratio", face_payload.get("openingRatio"))),
30
+ lip_roundness_score=_num(face_payload.get("lip_roundness_score", face_payload.get("puckerScore"))),
31
+ upper_lip_lift_score=_num(face_payload.get("upper_lip_lift_score", face_payload.get("upperLipLiftScore"))),
32
+ jaw_stability_score=_num(face_payload.get("jaw_stability_score", face_payload.get("jawScore"))),
33
+ mouth_symmetry_score=_num(face_payload.get("mouth_symmetry_score", face_payload.get("symmetry"))),
34
+ mouth_shape_score=_num(face_payload.get("mouth_shape_score", face_payload.get("score"))),
35
+ )
36
+ audio = AudioFeatures(
37
+ rms_volume=_num(audio_payload.get("rms_volume", audio_payload.get("rms"))),
38
+ airflow_score=_num(audio_payload.get("airflow_score", audio_payload.get("airflow"))),
39
+ peak_frequency_hz=_num(audio_payload.get("peak_frequency_hz", audio_payload.get("peakFrequency"))),
40
+ pitch_stability_score=_num(audio_payload.get("pitch_stability_score", audio_payload.get("tone"))),
41
+ stable_duration_ms=int(_num(audio_payload.get("stable_duration_ms", audio_payload.get("stableDurationMs")), 0)),
42
+ )
43
+
44
+ state = "idle"
45
+ active_step = "start"
46
+ confidence = 0.0
47
+
48
+ if not face.face_visible or not face.face_centered:
49
+ state = "no_face"
50
+ active_step = "align_face"
51
+ confidence = 0.75
52
+ elif face.mouth_opening_ratio > 0.34 or face.jaw_stability_score < 0.45:
53
+ state = "mouth_too_open"
54
+ active_step = "small_opening"
55
+ confidence = 0.72
56
+ elif face.lip_roundness_score < 0.52:
57
+ state = "not_rounded"
58
+ active_step = "round_lips"
59
+ confidence = 0.74
60
+ elif face.mouth_symmetry_score < 0.58:
61
+ state = "asymmetric_mouth"
62
+ active_step = "center_mouth"
63
+ confidence = 0.62
64
+ elif audio.airflow_score < 0.32:
65
+ state = "mouth_ready_no_airflow"
66
+ active_step = "gentle_airflow"
67
+ confidence = 0.7
68
+ elif audio.pitch_stability_score < 0.58:
69
+ state = "airflow_no_tone"
70
+ active_step = "narrow_air_stream"
71
+ confidence = 0.68
72
+ else:
73
+ state = "stable_whistle"
74
+ active_step = "record_melody"
75
+ confidence = 0.84
76
+
77
+ success_trigger = (
78
+ state == "stable_whistle"
79
+ and audio.stable_duration_ms >= 2000
80
+ and audio.peak_frequency_hz >= 700
81
+ )
82
+
83
+ practice_state = PracticeState(
84
+ state=state,
85
+ active_step=active_step,
86
+ face=face,
87
+ audio=audio,
88
+ success_trigger=success_trigger,
89
+ confidence=confidence,
90
+ )
91
+ return practice_state.to_dict()
data/coach_policy_sft.jsonl ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"no_face\",\"active_step\":\"align_face\",\"face\":{\"face_visible\":false,\"face_centered\":false,\"mouth_shape_score\":0.0},\"audio\":{\"airflow_score\":0.0,\"pitch_stability_score\":0.0},\"success_trigger\":false}. Short history: []. Return compact JSON only.","output":"{\"coach_message\":\"Center your face first, then we will tune the mouth shape.\",\"diagnosis\":\"The coach cannot see a centered lower face, so mouth feedback would be unreliable.\",\"overlay_cues\":[],\"active_step\":\"align_face\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"No centered face is visible; mouth and audio scores are near zero.\"},{\"step\":\"Diagnose\",\"action\":\"no_face\",\"detail\":\"The blocker is sensor alignment, not whistle technique.\"},{\"step\":\"Plan\",\"action\":\"center_face\",\"detail\":\"Ask for a stable camera view before coaching lips or airflow.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"Center your face first, then we will tune the mouth shape.\"}],\"next_drill\":\"center_face\",\"difficulty_adjustment\":\"easier\",\"success_trigger\":false}"}
2
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"mouth_too_open\",\"active_step\":\"small_opening\",\"face\":{\"face_visible\":true,\"face_centered\":true,\"mouth_opening_ratio\":0.44,\"lip_roundness_score\":0.61,\"jaw_stability_score\":0.38,\"mouth_symmetry_score\":0.82,\"mouth_shape_score\":0.45},\"audio\":{\"airflow_score\":0.12,\"pitch_stability_score\":0.0},\"success_trigger\":false}. Short history: [{\"state\":\"no_face\"}]. Return compact JSON only.","output":"{\"coach_message\":\"Make the opening smaller and let your jaw float instead of dropping.\",\"diagnosis\":\"The lips are visible but the aperture is too large for a narrow whistle jet.\",\"overlay_cues\":[{\"type\":\"mouth_opening\",\"direction\":\"smaller\",\"label\":\"Smaller opening\"}],\"active_step\":\"small_opening\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"Mouth opening is high and jaw stability is low.\"},{\"step\":\"Diagnose\",\"action\":\"mouth_too_open\",\"detail\":\"A wide aperture spreads the air stream before tone can form.\"},{\"step\":\"Plan\",\"action\":\"small_opening_hold\",\"detail\":\"Coach one small adjustment: reduce opening without clenching.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"Make the opening smaller and let your jaw float instead of dropping.\"}],\"next_drill\":\"small_opening_hold\",\"difficulty_adjustment\":\"easier\",\"success_trigger\":false}"}
3
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"not_rounded\",\"active_step\":\"round_lips\",\"face\":{\"face_visible\":true,\"face_centered\":true,\"mouth_opening_ratio\":0.18,\"lip_roundness_score\":0.31,\"jaw_stability_score\":0.76,\"mouth_symmetry_score\":0.85,\"mouth_shape_score\":0.48},\"audio\":{\"airflow_score\":0.05,\"pitch_stability_score\":0.0},\"success_trigger\":false}. Short history: [{\"state\":\"mouth_too_open\"}]. Return compact JSON only.","output":"{\"coach_message\":\"Pull both lip corners inward like a tiny 'u', but keep the opening relaxed.\",\"diagnosis\":\"The opening is small enough, but the lips are not rounded enough to focus airflow.\",\"overlay_cues\":[{\"type\":\"lip_corners\",\"direction\":\"inward\",\"label\":\"Rounder lips\"}],\"active_step\":\"round_lips\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"Opening is controlled, roundness is low, and tone is absent.\"},{\"step\":\"Diagnose\",\"action\":\"not_rounded\",\"detail\":\"The air has no focused lip tunnel yet.\"},{\"step\":\"Plan\",\"action\":\"lip_rounding\",\"detail\":\"Keep the aperture small while moving the corners inward.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"Pull both lip corners inward like a tiny 'u', but keep the opening relaxed.\"}],\"next_drill\":\"lip_rounding\",\"difficulty_adjustment\":\"hold\",\"success_trigger\":false}"}
4
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"asymmetric_mouth\",\"active_step\":\"center_mouth\",\"face\":{\"face_visible\":true,\"face_centered\":true,\"mouth_opening_ratio\":0.17,\"lip_roundness_score\":0.68,\"jaw_stability_score\":0.73,\"mouth_symmetry_score\":0.41,\"mouth_shape_score\":0.53},\"audio\":{\"airflow_score\":0.22,\"pitch_stability_score\":0.0},\"success_trigger\":false}. Short history: [{\"state\":\"not_rounded\"}]. Return compact JSON only.","output":"{\"coach_message\":\"Level the two mouth corners, then blow through the center line.\",\"diagnosis\":\"Lip roundness is improving, but asymmetric corners push the air stream sideways.\",\"overlay_cues\":[{\"type\":\"symmetry\",\"direction\":\"level\",\"label\":\"Level corners\"}],\"active_step\":\"center_mouth\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"Roundness is acceptable but mouth symmetry is low.\"},{\"step\":\"Diagnose\",\"action\":\"asymmetric_mouth\",\"detail\":\"The air stream is likely off center.\"},{\"step\":\"Plan\",\"action\":\"corner_leveling\",\"detail\":\"Stabilize both corners before adding more airflow.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"Level the two mouth corners, then blow through the center line.\"}],\"next_drill\":\"corner_leveling\",\"difficulty_adjustment\":\"hold\",\"success_trigger\":false}"}
5
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"mouth_ready_no_airflow\",\"active_step\":\"gentle_airflow\",\"face\":{\"face_visible\":true,\"face_centered\":true,\"mouth_opening_ratio\":0.15,\"lip_roundness_score\":0.77,\"jaw_stability_score\":0.81,\"mouth_symmetry_score\":0.86,\"mouth_shape_score\":0.78},\"audio\":{\"airflow_score\":0.08,\"pitch_stability_score\":0.0},\"success_trigger\":false}. Short history: [{\"state\":\"asymmetric_mouth\"}]. Return compact JSON only.","output":"{\"coach_message\":\"Nice shape. Add a soft, steady breath without changing your lips.\",\"diagnosis\":\"The mouth shape is ready, but there is not enough detected airflow to create tone.\",\"overlay_cues\":[{\"type\":\"air_stream\",\"direction\":\"gentle\",\"label\":\"Gentle breath\"}],\"active_step\":\"gentle_airflow\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"Mouth score is high and airflow is low.\"},{\"step\":\"Diagnose\",\"action\":\"mouth_ready_no_airflow\",\"detail\":\"The next blocker is breath initiation, not lip shape.\"},{\"step\":\"Plan\",\"action\":\"gentle_airflow\",\"detail\":\"Ask for a soft steady exhale while preserving shape.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"Nice shape. Add a soft, steady breath without changing your lips.\"}],\"next_drill\":\"gentle_airflow\",\"difficulty_adjustment\":\"hold\",\"success_trigger\":false}"}
6
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"airflow_no_tone\",\"active_step\":\"narrow_air_stream\",\"face\":{\"face_visible\":true,\"face_centered\":true,\"mouth_opening_ratio\":0.16,\"lip_roundness_score\":0.72,\"jaw_stability_score\":0.8,\"mouth_symmetry_score\":0.88,\"mouth_shape_score\":0.76},\"audio\":{\"airflow_score\":0.68,\"pitch_stability_score\":0.18,\"peak_frequency_hz\":0},\"success_trigger\":false}. Short history: [{\"state\":\"mouth_ready_no_airflow\"},{\"state\":\"airflow_no_tone\"}]. Return compact JSON only.","output":"{\"coach_message\":\"You have airflow; make it thinner, like threading it through a pinhole.\",\"diagnosis\":\"Airflow is strong enough, but it is too broad or noisy to lock into a stable pitch.\",\"overlay_cues\":[{\"type\":\"air_stream\",\"direction\":\"narrower\",\"label\":\"Narrower air\"}],\"active_step\":\"narrow_air_stream\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"Airflow is high while pitch stability remains low.\"},{\"step\":\"Diagnose\",\"action\":\"airflow_no_tone\",\"detail\":\"The breath exists but is not focused into tone.\"},{\"step\":\"Plan\",\"action\":\"narrow_air_stream\",\"detail\":\"Reduce the air channel instead of blowing harder.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"You have airflow; make it thinner, like threading it through a pinhole.\"}],\"next_drill\":\"narrow_air_stream\",\"difficulty_adjustment\":\"hold\",\"success_trigger\":false}"}
7
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"tone_starting\",\"active_step\":\"hold_tone\",\"face\":{\"face_visible\":true,\"face_centered\":true,\"mouth_opening_ratio\":0.15,\"lip_roundness_score\":0.78,\"jaw_stability_score\":0.82,\"mouth_symmetry_score\":0.9,\"mouth_shape_score\":0.83},\"audio\":{\"airflow_score\":0.62,\"pitch_stability_score\":0.64,\"peak_frequency_hz\":1260,\"stable_duration_ms\":900},\"success_trigger\":false}. Short history: [{\"state\":\"airflow_no_tone\"},{\"state\":\"tone_starting\"}]. Return compact JSON only.","output":"{\"coach_message\":\"That is the tone. Freeze the shape and hold it for one more second.\",\"diagnosis\":\"A whistle tone has started, but the hold time is not long enough for success.\",\"overlay_cues\":[{\"type\":\"success\",\"direction\":\"hold\",\"label\":\"Hold tone\"}],\"active_step\":\"hold_tone\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"Pitch is present and stable for under two seconds.\"},{\"step\":\"Diagnose\",\"action\":\"tone_starting\",\"detail\":\"The skill is emerging; changing shape now would break it.\"},{\"step\":\"Plan\",\"action\":\"steady_tone_hold\",\"detail\":\"Reinforce stillness and duration rather than new corrections.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"That is the tone. Freeze the shape and hold it for one more second.\"}],\"next_drill\":\"steady_tone_hold\",\"difficulty_adjustment\":\"harder\",\"success_trigger\":false}"}
8
+ {"input":"You are Whistle Coach. Current practice_state JSON: {\"state\":\"stable_whistle\",\"active_step\":\"record_melody\",\"face\":{\"face_visible\":true,\"face_centered\":true,\"mouth_opening_ratio\":0.15,\"lip_roundness_score\":0.78,\"jaw_stability_score\":0.84,\"mouth_symmetry_score\":0.9,\"mouth_shape_score\":0.83},\"audio\":{\"airflow_score\":0.64,\"pitch_stability_score\":0.82,\"peak_frequency_hz\":1760,\"stable_duration_ms\":2400},\"success_trigger\":true}. Short history: [{\"state\":\"airflow_no_tone\"},{\"state\":\"tone_starting\"},{\"state\":\"stable_whistle\"}]. Return compact JSON only.","output":"{\"coach_message\":\"Great, keep it steady while I turn that whistle into a tiny melody.\",\"diagnosis\":\"The mouth shape, airflow, pitch stability, and hold duration meet the success condition.\",\"overlay_cues\":[{\"type\":\"success\",\"direction\":\"hold\",\"label\":\"Melody ready\"}],\"active_step\":\"record_melody\",\"agent_trace\":[{\"step\":\"Observe\",\"action\":\"read_state\",\"detail\":\"Stable pitch has held for more than two seconds.\"},{\"step\":\"Diagnose\",\"action\":\"stable_whistle\",\"detail\":\"The user reached the target whistle condition.\"},{\"step\":\"Plan\",\"action\":\"record_melody\",\"detail\":\"Capture the pitch contour as the reward artifact.\"},{\"step\":\"Act\",\"action\":\"coach_message\",\"detail\":\"Great, keep it steady while I turn that whistle into a tiny melody.\"}],\"next_drill\":\"record_melody\",\"difficulty_adjustment\":\"complete\",\"success_trigger\":true}"}
data/sample_coach_states.jsonl ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {"state":"no_face","active_step":"align_face","face":{"face_visible":false,"face_centered":false,"mouth_shape_score":0.0},"audio":{"airflow_score":0.0,"pitch_stability_score":0.0},"success_trigger":false}
2
+ {"state":"not_rounded","active_step":"round_lips","face":{"face_visible":true,"face_centered":true,"mouth_opening_ratio":0.18,"lip_roundness_score":0.31,"mouth_shape_score":0.48},"audio":{"airflow_score":0.05,"pitch_stability_score":0.0},"success_trigger":false}
3
+ {"state":"airflow_no_tone","active_step":"narrow_air_stream","face":{"face_visible":true,"face_centered":true,"mouth_opening_ratio":0.16,"lip_roundness_score":0.71,"mouth_shape_score":0.76},"audio":{"airflow_score":0.68,"pitch_stability_score":0.18,"peak_frequency_hz":0},"success_trigger":false}
4
+ {"state":"stable_whistle","active_step":"record_melody","face":{"face_visible":true,"face_centered":true,"mouth_opening_ratio":0.15,"lip_roundness_score":0.78,"mouth_shape_score":0.83},"audio":{"airflow_score":0.64,"pitch_stability_score":0.82,"peak_frequency_hz":1760,"stable_duration_ms":2400},"success_trigger":true}
frontend/audio_features.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.WhistleCoach = window.WhistleCoach || {};
2
+
3
+ window.WhistleCoach.AudioFeatures = (() => {
4
+ const clamp = (value, minimum = 0, maximum = 1) =>
5
+ Math.min(maximum, Math.max(minimum, value));
6
+
7
+ function toFeaturePayload(audio, stableDurationMs = 0) {
8
+ return {
9
+ rms_volume: Number(audio?.rms || 0),
10
+ airflow_score: Number(audio?.airflow || 0),
11
+ peak_frequency_hz: Number(audio?.peakFrequency || 0),
12
+ pitch_stability_score: Number(audio?.tone || 0),
13
+ stable_duration_ms: Math.round(stableDurationMs || 0),
14
+ };
15
+ }
16
+
17
+ function classifyAudio(features) {
18
+ if (features.airflow_score < 0.32) return "no_airflow";
19
+ if (features.pitch_stability_score < 0.58) return "airflow_no_tone";
20
+ if (features.stable_duration_ms >= 2000) return "stable_whistle";
21
+ return "tone_starting";
22
+ }
23
+
24
+ function pitchToMidi(frequencyHz) {
25
+ if (!frequencyHz || frequencyHz <= 0) return null;
26
+ return Math.round(69 + 12 * Math.log2(frequencyHz / 440));
27
+ }
28
+
29
+ function midiToName(midi) {
30
+ if (midi === null) return "";
31
+ const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
32
+ return `${names[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`;
33
+ }
34
+
35
+ return {
36
+ toFeaturePayload,
37
+ classifyAudio,
38
+ pitchToMidi,
39
+ midiToName,
40
+ };
41
+ })();
frontend/face_features.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.WhistleCoach = window.WhistleCoach || {};
2
+
3
+ window.WhistleCoach.FaceFeatures = (() => {
4
+ const clamp = (value, minimum = 0, maximum = 1) =>
5
+ Math.min(maximum, Math.max(minimum, value));
6
+
7
+ function toFeaturePayload(face) {
8
+ return {
9
+ face_visible: Boolean(face?.visible),
10
+ face_centered: Boolean(face?.centered),
11
+ mouth_opening_ratio: Number(face?.openingRatio || 0),
12
+ lip_roundness_score: Number(face?.puckerScore || 0),
13
+ upper_lip_lift_score: Number(face?.upperLipLiftScore || 0),
14
+ jaw_stability_score: Number(face?.jawScore || 0),
15
+ mouth_symmetry_score: Number(face?.symmetry || 0),
16
+ mouth_shape_score: Number(face?.score || 0),
17
+ };
18
+ }
19
+
20
+ function classifyMouthShape(features) {
21
+ if (!features.face_visible || !features.face_centered) return "no_face";
22
+ if (features.mouth_opening_ratio > 0.34 || features.jaw_stability_score < 0.45) {
23
+ return "mouth_too_open";
24
+ }
25
+ if (features.lip_roundness_score < 0.52) return "not_rounded";
26
+ if (features.mouth_symmetry_score < 0.58) return "asymmetric_mouth";
27
+ if (features.mouth_shape_score >= 0.62) return "mouth_ready";
28
+ return "adjust_mouth";
29
+ }
30
+
31
+ function smoothFeature(previous, next, alpha = 0.28) {
32
+ if (!previous) return next;
33
+ const output = { ...next };
34
+ for (const [key, value] of Object.entries(next)) {
35
+ if (typeof value === "number") {
36
+ output[key] = clamp(previous[key] * (1 - alpha) + value * alpha);
37
+ }
38
+ }
39
+ return output;
40
+ }
41
+
42
+ return {
43
+ toFeaturePayload,
44
+ classifyMouthShape,
45
+ smoothFeature,
46
+ };
47
+ })();
frontend/index.html ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <main id="whistle-coach-app" class="whistle-app">
2
+ <header class="wc-header">
3
+ <div class="brand-lockup">
4
+ <div class="brand-mark" aria-hidden="true">
5
+ <svg viewBox="0 0 64 64" role="img">
6
+ <path d="M15 36c7-10 18-15 33-12" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>
7
+ <path d="M16 47c10-8 22-10 35-6" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" opacity=".58"/>
8
+ <circle cx="13" cy="29" r="5" fill="currentColor"/>
9
+ </svg>
10
+ </div>
11
+ <div>
12
+ <h1 style="color:#17213a">Whistle Coach <span>🌱</span></h1>
13
+ <p style="color:#52607d">Audio-first AI coach for your first whistle</p>
14
+ </div>
15
+ </div>
16
+ </header>
17
+
18
+ <section class="wc-layout" aria-label="Whistle Coach live practice">
19
+ <aside class="left-rail">
20
+ <section class="side-card flow-card">
21
+ <p class="card-kicker">🌱 BEGINNER FLOW</p>
22
+ <ol class="flow-list">
23
+ <li class="done"><span>1</span>Relax & posture</li>
24
+ <li class="done"><span>2</span>Round your lips</li>
25
+ <li class="done"><span>3</span>Small opening</li>
26
+ <li class="active"><span>4</span>Gentle airflow</li>
27
+ <li><span>5</span>Find tiny whistle</li>
28
+ <li><span>6</span>Hold a steady tone</li>
29
+ <li><span>7</span>Play a simple melody</li>
30
+ </ol>
31
+ <div class="steps-progress"><strong>4 / 7 steps</strong><span><i></i></span></div>
32
+ </section>
33
+
34
+ <section class="side-card journey-card">
35
+ <p class="card-kicker">🌱 YOUR WHISTLE JOURNEY</p>
36
+ <div class="journey-row">
37
+ <div class="progress-ring"><strong id="garden-growth">0%</strong></div>
38
+ <div>
39
+ <strong id="journey-title">You’re growing!</strong>
40
+ <p id="coach-message" aria-live="polite">Your flowers grow each time you hold a whistle.</p>
41
+ </div>
42
+ <div class="plant-pot" aria-hidden="true" data-growth-stage="seed">
43
+ <span class="plant-stem"></span>
44
+ <span class="plant-leaf plant-leaf-left"></span>
45
+ <span class="plant-leaf plant-leaf-right"></span>
46
+ <span class="plant-flower"></span>
47
+ <span class="plant-soil"></span>
48
+ </div>
49
+ </div>
50
+ </section>
51
+ </aside>
52
+
53
+ <section class="center-stage">
54
+ <article id="camera-stage" class="camera-card" data-feedback="red" tabindex="0" aria-label="Start live whistle practice">
55
+ <video id="practice-video" autoplay muted playsinline aria-label="Live webcam preview"></video>
56
+ <div id="camera-placeholder" class="camera-placeholder">
57
+ <div class="start-panel">
58
+ <div class="placeholder-face" aria-hidden="true"></div>
59
+ <strong>Start practice</strong>
60
+ <span>Put your mouth in the dotted oval, round your lips, then blow gently.</span>
61
+ <button id="center-start-practice" class="center-start-button" type="button">Start</button>
62
+ </div>
63
+ </div>
64
+ <canvas id="mouth-overlay" aria-hidden="true"></canvas>
65
+ <canvas id="garden-canvas" aria-hidden="true"></canvas>
66
+ <div class="live-guide" aria-hidden="true">
67
+ <strong>Put your mouth in the oval</strong>
68
+ <span>Round your lips, keep the opening small, then blow softly.</span>
69
+ </div>
70
+
71
+ <span id="session-state" class="live-chip" data-state="idle"><i></i><span class="session-copy">Ready</span></span>
72
+ <div id="coach-card" class="coach-bubble" data-state="idle"><span>🌱</span><strong id="hero-feedback">Ready for live practice</strong></div>
73
+ <div id="permission-note" class="permission-note">Camera and microphone stay in your browser.</div>
74
+ </article>
75
+ </section>
76
+
77
+ <aside class="right-rail">
78
+ <section class="listening-panel">
79
+ <div class="panel-heading"><span>▮▮</span><div><strong>AI LISTENING PANEL</strong><p>Audio is the core signal</p></div></div>
80
+
81
+ <section id="airflow-indicator" class="indicator-card green" data-state="red">
82
+ <div class="indicator-topline"><div class="indicator-name">≋ <span>Airflow</span></div><strong class="indicator-score"><span>0</span><small>/100</small></strong></div>
83
+ <div class="score-track"><span class="score-fill"></span></div>
84
+ <p class="indicator-detail">Waiting for microphone</p>
85
+ </section>
86
+
87
+ <section id="whistle-indicator" class="indicator-card orange" data-state="red">
88
+ <div class="indicator-topline"><div class="indicator-name">✧ <span>Whistle confidence (AST)</span></div><strong class="indicator-score"><span>0</span><small>/100</small></strong></div>
89
+ <div class="score-track"><span class="score-fill"></span></div>
90
+ <p class="indicator-detail">MIT AST listens for whistle-like sound.</p>
91
+ </section>
92
+
93
+ <section id="tone-indicator" class="indicator-card purple" data-state="red">
94
+ <div class="indicator-topline"><div class="indicator-name">♫ <span>Pitch detected (F0)</span></div><strong class="indicator-score"><span>0</span><small></small></strong></div>
95
+ <div class="score-track"><span class="score-fill"></span></div>
96
+ <p class="indicator-detail">librosa.pyin waits for F0.</p>
97
+ </section>
98
+
99
+ <section id="stability-indicator" class="indicator-card blue" data-state="red">
100
+ <div class="indicator-topline"><div class="indicator-name">≋ <span>Stability</span></div><strong class="indicator-score"><span>0</span><small>/100</small></strong></div>
101
+ <div class="score-track"><span class="score-fill"></span></div>
102
+ <p class="indicator-detail">Keep airflow steady.</p>
103
+ </section>
104
+
105
+ <p id="technical-status" class="technical-status">Detection loads when live practice begins.</p>
106
+ </section>
107
+ </aside>
108
+ </section>
109
+
110
+ <section id="mouth-indicator" class="indicator-card hidden-meter" data-state="red" aria-hidden="true">
111
+ <div class="indicator-topline"><div class="indicator-name"><span>Mouth shape</span></div><strong class="indicator-score"><span>0</span><small>/100</small></strong></div>
112
+ <div class="score-track"><span class="score-fill"></span></div>
113
+ <p class="indicator-detail">Waiting for a face</p>
114
+ </section>
115
+
116
+ <code id="practice-state-json" hidden>{"state":"idle"}</code>
117
+ <ol id="coach-trace-list" hidden></ol>
118
+ <span id="garden-growth-stage" hidden>0%</span>
119
+ <span id="garden-growth-fill" hidden></span>
120
+ <span id="garden-plants" hidden>0</span>
121
+ <span id="garden-streak" hidden>0.0</span>
122
+ </main>
frontend/overlay_renderer.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.WhistleCoach = window.WhistleCoach || {};
2
+
3
+ window.WhistleCoach.OverlayRenderer = (() => {
4
+ function overlayCuesForState(practiceState) {
5
+ const state = practiceState?.state || "idle";
6
+ if (state === "mouth_too_open") {
7
+ return [{ type: "mouth_opening", direction: "smaller", label: "Smaller opening" }];
8
+ }
9
+ if (state === "not_rounded") {
10
+ return [{ type: "lip_corners", direction: "inward", label: "Rounder" }];
11
+ }
12
+ if (state === "asymmetric_mouth") {
13
+ return [{ type: "symmetry", direction: "level", label: "Level corners" }];
14
+ }
15
+ if (state === "airflow_no_tone") {
16
+ return [{ type: "air_stream", direction: "narrower", label: "Narrower air" }];
17
+ }
18
+ if (state === "stable_whistle") {
19
+ return [{ type: "success", direction: "hold", label: "Hold tone" }];
20
+ }
21
+ return [];
22
+ }
23
+
24
+ function messageForState(practiceState) {
25
+ const state = practiceState?.state || "idle";
26
+ const messages = {
27
+ idle: "Ready when you are.",
28
+ no_face: "Please face the camera and center your mouth.",
29
+ mouth_too_open: "Make the opening smaller and relax your jaw.",
30
+ not_rounded: "Pull both lip corners inward, like saying 'u'.",
31
+ asymmetric_mouth: "Keep both lip corners level and centered.",
32
+ adjust_mouth: "Make the opening smaller and rounder.",
33
+ mouth_ready_no_airflow: "Good mouth shape. Now blow gently.",
34
+ airflow_no_tone: "You are close. Make the air stream narrower.",
35
+ tone_starting: "Nice. Hold the tone steady.",
36
+ stable_whistle: "Great! You made a stable whistle.",
37
+ };
38
+ return messages[state] || messages.idle;
39
+ }
40
+
41
+ function buildPracticeState(faceFeatures, audioFeatures) {
42
+ let state = "idle";
43
+ let activeStep = "start";
44
+
45
+ if (!faceFeatures.face_visible || !faceFeatures.face_centered) {
46
+ state = "no_face";
47
+ activeStep = "align_face";
48
+ } else if (faceFeatures.mouth_opening_ratio > 0.34 || faceFeatures.jaw_stability_score < 0.45) {
49
+ state = "mouth_too_open";
50
+ activeStep = "small_opening";
51
+ } else if (faceFeatures.lip_roundness_score < 0.52) {
52
+ state = "not_rounded";
53
+ activeStep = "round_lips";
54
+ } else if (faceFeatures.mouth_symmetry_score < 0.58) {
55
+ state = "asymmetric_mouth";
56
+ activeStep = "center_mouth";
57
+ } else if (audioFeatures.airflow_score < 0.32) {
58
+ state = "mouth_ready_no_airflow";
59
+ activeStep = "gentle_airflow";
60
+ } else if (audioFeatures.pitch_stability_score < 0.58) {
61
+ state = "airflow_no_tone";
62
+ activeStep = "narrow_air_stream";
63
+ } else if (audioFeatures.stable_duration_ms < 2000) {
64
+ state = "tone_starting";
65
+ activeStep = "hold_tone";
66
+ } else {
67
+ state = "stable_whistle";
68
+ activeStep = "record_melody";
69
+ }
70
+
71
+ return {
72
+ state,
73
+ active_step: activeStep,
74
+ face: faceFeatures,
75
+ audio: audioFeatures,
76
+ overlay_cues: overlayCuesForState({ state }),
77
+ success_trigger: state === "stable_whistle",
78
+ };
79
+ }
80
+
81
+ return {
82
+ overlayCuesForState,
83
+ messageForState,
84
+ buildPracticeState,
85
+ };
86
+ })();
frontend/style.css ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --ink: #17213a;
3
+ --muted: #3f4b64;
4
+ --line: #e3e7f1;
5
+ --purple: #6b4df6;
6
+ --purple-soft: #efeaff;
7
+ --green: #48b95e;
8
+ --green-soft: #e8f8ea;
9
+ --orange: #ff7a34;
10
+ --blue: #4387f4;
11
+ --panel: rgba(255,255,255,.92);
12
+ --shadow: 0 18px 45px rgba(74, 61, 128, .12);
13
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ }
15
+
16
+ html,
17
+ body,
18
+ gradio-app,
19
+ .gradio-container,
20
+ .main,
21
+ .wrap,
22
+ .contain {
23
+ background: #fbfcff !important;
24
+ }
25
+
26
+ html,
27
+ body {
28
+ min-height: 100%;
29
+ margin: 0;
30
+ color: var(--ink);
31
+ }
32
+
33
+ .gradio-container {
34
+ max-width: none !important;
35
+ min-height: 100vh !important;
36
+ padding: 0 !important;
37
+ }
38
+
39
+ footer,
40
+ .footer {
41
+ display: none !important;
42
+ }
43
+
44
+ .whistle-app,
45
+ .whistle-app * {
46
+ box-sizing: border-box;
47
+ }
48
+ .whistle-app {
49
+ --wc-page-pad: clamp(16px, 1.65vw, 30px);
50
+ --wc-gap: clamp(14px, 1.15vw, 22px);
51
+ width: 100%;
52
+ max-width: none;
53
+ min-height: 100vh;
54
+ margin: 0;
55
+ display: grid;
56
+ grid-template-rows: auto 1fr;
57
+ color: var(--ink) !important;
58
+ padding: var(--wc-page-pad);
59
+ overflow: visible;
60
+ background: radial-gradient(circle at 18% 8%, rgba(126, 96, 255, .10), transparent 27%), radial-gradient(circle at 86% 18%, rgba(77, 185, 94, .10), transparent 26%), #fbfcff;
61
+ }
62
+
63
+ .wc-header { width: min(100%, 1800px); margin: 0 auto clamp(18px, 2vh, 28px); display: flex; justify-content: flex-start; align-items: center; gap: 22px; }
64
+ .brand-lockup { display: flex; align-items: center; gap: 18px; min-width: 0; }
65
+ .brand-mark { width: 72px; height: 72px; display: grid; place-items: center; border-radius: 20px; background: linear-gradient(145deg, #8c6bff, #5945e8); color: #121c33; box-shadow: 0 18px 32px rgba(91, 69, 232, .28); }
66
+ .brand-mark svg { width: 42px; height: 42px; }
67
+ .brand-lockup h1 { color: var(--ink); margin: 0 0 4px; font-size: 30px; line-height: 1.05; letter-spacing: 0; }
68
+ .brand-lockup p { margin: 0; color: #38435c !important; font-size: 17px; font-weight: 650; }
69
+ .wc-layout { width: min(100%, 1800px); margin: 0 auto; display: grid; grid-template-columns: minmax(250px, .78fr) minmax(460px, 1.75fr) minmax(270px, .86fr); gap: var(--wc-gap); align-items: stretch; min-height: 0; overflow: visible; }
70
+ .left-rail, .right-rail { min-width: 0; display: grid; gap: 16px; align-content: start; overflow: visible; padding-bottom: 4px; }
71
+ .center-stage { min-width: 0; overflow: visible; }
72
+ .side-card, .listening-panel, .garden-panel { color: var(--ink); border: 1px solid var(--line); border-radius: 22px; background: var(--panel); box-shadow: var(--shadow); }
73
+ .whistle-app .side-card,
74
+ .whistle-app .listening-panel,
75
+ .whistle-app .garden-panel {
76
+ opacity: 1 !important;
77
+ }
78
+ .side-card h2,
79
+ .side-card strong,
80
+ .side-card p,
81
+ .side-card li,
82
+ .listening-panel strong,
83
+ .listening-panel p,
84
+ .indicator-name,
85
+ .indicator-score,
86
+ .garden-panel h3,
87
+ .garden-panel strong,
88
+ .garden-panel small {
89
+ opacity: 1 !important;
90
+ }
91
+ .side-card h2,
92
+ .panel-heading strong,
93
+ .indicator-name,
94
+ .garden-panel h3 {
95
+ color: var(--ink) !important;
96
+ }
97
+ .whistle-app .card-kicker,
98
+ .whistle-app .panel-heading strong,
99
+ .whistle-app .indicator-name,
100
+ .whistle-app .indicator-name span,
101
+ .whistle-app .indicator-score,
102
+ .whistle-app .journey-row strong,
103
+ .whistle-app .steps-progress strong {
104
+ color: #111a33 !important;
105
+ opacity: 1 !important;
106
+ }
107
+ .whistle-app .indicator-card .indicator-topline,
108
+ .whistle-app .indicator-card .indicator-topline * {
109
+ opacity: 1 !important;
110
+ }
111
+ .side-card p,
112
+ .flow-list li,
113
+ .panel-heading p,
114
+ .indicator-detail,
115
+ .technical-status,
116
+ .garden-step,
117
+ .melody-card {
118
+ color: #36425c !important;
119
+ }
120
+ .side-card { padding: clamp(18px, 1.4vw, 24px) clamp(16px, 1.25vw, 22px); }
121
+ .current-card { background: linear-gradient(145deg, rgba(246,241,255,.96), rgba(255,255,255,.94)); }
122
+ .card-kicker { margin: 0 0 18px; font-size: 13px; font-weight: 900; letter-spacing: .08em; color: #17213a; }
123
+ .current-card h2 { margin: 0 0 12px; font-size: 22px; color: #111a33 !important; letter-spacing: 0; }
124
+ .step-copy { margin: 0 0 18px; font-size: 18px; line-height: 1.45; color: #344061 !important; }
125
+ .focus-chip { display: inline-flex; align-items: center; gap: 8px; padding: 10px 15px; border-radius: 999px; background: #e7ddff; color: #5a3ce7 !important; font-weight: 900; }
126
+ .flow-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 7px; }
127
+ .flow-list li { min-height: 31px; display: grid; grid-template-columns: 28px 1fr 20px; align-items: center; gap: 10px; color: #36425c; font-weight: 800; border-radius: 15px; padding: 3px 7px; }
128
+ .flow-list li span { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 50%; background: #f0f2f6; color: #1b243a; font-size: 13px; font-weight: 900; }
129
+ .flow-list li.done::after { content: "✓"; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; background: #ddf4e3; color: #38a750; }
130
+ .flow-list li.active { background: #ebe4ff; color: #5a3ce7 !important; }
131
+ .flow-list li.active span { background: var(--purple); color: white; }
132
+ .flow-list li:not(.done):not(.active)::after { content: ""; width: 12px; height: 12px; border: 2px solid #9faabd; border-radius: 50%; justify-self: center; }
133
+ .steps-progress { display: flex; align-items: center; gap: 16px; margin-top: 18px; color: #35415a; }
134
+ .steps-progress span { flex: 1; height: 7px; background: #e7e9ef; border-radius: 999px; overflow: hidden; }
135
+ .steps-progress i { display: block; width: 57%; height: 100%; background: var(--purple); border-radius: inherit; }
136
+ .journey-row { display: grid; grid-template-columns: minmax(68px, 86px) minmax(0, 1fr) 56px; align-items: center; gap: 14px; }
137
+ .progress-ring { width: 76px; height: 76px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--green) 0deg, var(--green) 0deg, #e9edf3 0deg); border: 9px solid #eef1f5; color: var(--green); font-size: 20px; transition: background .3s ease; }
138
+ .journey-row p { margin: 8px 0 0; line-height: 1.45; color: #35415a; font-weight: 750; }
139
+ .plant-pot {
140
+ --stem-growth: .08;
141
+ --leaf-growth: .1;
142
+ --leaf-opacity: 0;
143
+ --flower-growth: 0;
144
+ --flower-opacity: 0;
145
+ position: relative;
146
+ width: 58px;
147
+ height: 76px;
148
+ justify-self: center;
149
+ }
150
+ .plant-stem {
151
+ position: absolute;
152
+ left: 50%;
153
+ bottom: 20px;
154
+ width: 8px;
155
+ height: 50px;
156
+ border-radius: 999px;
157
+ background: linear-gradient(#93dd45, #3aa85d);
158
+ transform: translateX(-50%) scaleY(var(--stem-growth));
159
+ transform-origin: bottom;
160
+ transition: transform .35s ease;
161
+ }
162
+ .plant-leaf {
163
+ position: absolute;
164
+ left: 50%;
165
+ bottom: 39px;
166
+ width: 30px;
167
+ height: 17px;
168
+ border-radius: 999px 0 999px 0;
169
+ background: linear-gradient(135deg, #afe85b, #58bd43);
170
+ opacity: var(--leaf-opacity);
171
+ transition: opacity .25s ease, transform .35s ease;
172
+ }
173
+ .plant-leaf-left {
174
+ transform: translateX(-95%) rotate(200deg) scale(var(--leaf-growth));
175
+ transform-origin: right center;
176
+ }
177
+ .plant-leaf-right {
178
+ transform: translateX(-6%) rotate(-20deg) scale(var(--leaf-growth));
179
+ transform-origin: left center;
180
+ }
181
+ .plant-flower {
182
+ position: absolute;
183
+ left: 50%;
184
+ top: 1px;
185
+ width: 30px;
186
+ height: 30px;
187
+ opacity: var(--flower-opacity);
188
+ transform: translateX(-50%) scale(var(--flower-growth));
189
+ transition: opacity .25s ease, transform .35s ease;
190
+ }
191
+ .plant-flower::before {
192
+ content: "";
193
+ position: absolute;
194
+ inset: 0;
195
+ border-radius: 50%;
196
+ background:
197
+ radial-gradient(circle at 50% 50%, #8d5b20 0 18%, transparent 19%),
198
+ radial-gradient(ellipse at 50% 8%, #ffd95f 0 24%, transparent 25%),
199
+ radial-gradient(ellipse at 92% 50%, #ff8a65 0 24%, transparent 25%),
200
+ radial-gradient(ellipse at 50% 92%, #f05a9b 0 24%, transparent 25%),
201
+ radial-gradient(ellipse at 8% 50%, #87d8ff 0 24%, transparent 25%);
202
+ }
203
+ .plant-soil {
204
+ position: absolute;
205
+ left: 50%;
206
+ bottom: 3px;
207
+ width: 42px;
208
+ height: 24px;
209
+ border-radius: 24px 24px 6px 6px;
210
+ background: linear-gradient(145deg, #8f594f, #65443f);
211
+ transform: translateX(-50%);
212
+ box-shadow: inset 0 3px 8px rgba(255, 255, 255, .12);
213
+ }
214
+ .plant-pot[data-growth-stage="bloom"] .plant-flower {
215
+ filter: drop-shadow(0 5px 8px rgba(240, 90, 155, .25));
216
+ }
217
+
218
+ .camera-card { position: relative; height: clamp(500px, calc(100vh - 170px), 720px); min-height: 0; overflow: hidden; border-radius: 22px; border: 1px solid #d7dce7; background: linear-gradient(145deg, #f8fbff, #eaf4ef); box-shadow: var(--shadow); cursor: pointer; }
219
+ @supports (height: 100svh) {
220
+ .camera-card { height: clamp(500px, calc(100svh - 170px), 720px); }
221
+ }
222
+ .camera-card::before {
223
+ content: "";
224
+ position: absolute;
225
+ inset: 0;
226
+ z-index: 2;
227
+ pointer-events: none;
228
+ opacity: 0;
229
+ background:
230
+ linear-gradient(180deg, rgba(255, 255, 255, .22), rgba(246, 252, 248, .1)),
231
+ radial-gradient(circle at 50% 58%, rgba(255, 255, 255, .2), transparent 42%);
232
+ transition: opacity .25s ease;
233
+ }
234
+ .camera-card:focus-visible { outline: 4px solid rgba(107, 77, 246, .32); outline-offset: 4px; }
235
+ .camera-card[data-active="true"] { cursor: default; }
236
+ .camera-card[data-active="true"]::before { opacity: 1; }
237
+ #practice-video, #mouth-overlay, #garden-canvas, .camera-placeholder { position: absolute; inset: 0; width: 100%; height: 100%; }
238
+ #practice-video { z-index: 0; object-fit: cover; filter: grayscale(.82) contrast(.96) brightness(1.12); display: none; transform: scaleX(-1); }
239
+ #mouth-overlay, #garden-canvas { pointer-events: none; z-index: 4; }
240
+ #garden-canvas { opacity: 0; transition: opacity .35s ease; }
241
+ #garden-canvas[data-visible="true"] { opacity: .94; }
242
+ .camera-placeholder { display: grid; place-items: center; color: #14203a; background: linear-gradient(135deg, #f7f9ff, #e9f7ef); z-index: 3; text-align: center; padding: clamp(24px, 4vh, 42px) clamp(20px, 3vw, 34px) 88px; }
243
+ .start-panel { width: min(520px, 92%); display: grid; justify-items: center; gap: clamp(10px, 1.4vh, 14px); }
244
+ .placeholder-face {
245
+ position: relative;
246
+ width: min(190px, 42vw);
247
+ height: min(220px, 30vh);
248
+ min-height: 150px;
249
+ border-radius: 48% 48% 42% 42%;
250
+ background: linear-gradient(#dce4ee, #9baabd);
251
+ opacity: .92;
252
+ filter: grayscale(1);
253
+ }
254
+ .placeholder-face::after {
255
+ content: "";
256
+ position: absolute;
257
+ left: 50%;
258
+ top: 67%;
259
+ width: 76px;
260
+ height: 46px;
261
+ border: 4px dashed rgba(107, 77, 246, .72);
262
+ border-radius: 50%;
263
+ transform: translate(-50%, -50%);
264
+ }
265
+ .whistle-app .camera-placeholder .start-panel strong { color: #111a33 !important; font-size: clamp(28px, 4vw, 44px); line-height: 1.05; font-weight: 950; text-shadow: none !important; }
266
+ .whistle-app .camera-placeholder .start-panel span { max-width: 460px; color: #33405a !important; font-size: clamp(16px, 1.35vw, 18px); line-height: 1.45; font-weight: 800; text-shadow: none !important; }
267
+ .center-start-button {
268
+ min-width: 148px;
269
+ height: 54px;
270
+ margin-top: 8px;
271
+ border: 0;
272
+ border-radius: 999px;
273
+ color: white;
274
+ background: linear-gradient(145deg, #7f62ff, #4d38db);
275
+ box-shadow: 0 14px 28px rgba(0, 0, 0, .28);
276
+ font: inherit;
277
+ font-size: 18px;
278
+ font-weight: 900;
279
+ cursor: pointer;
280
+ }
281
+ .center-start-button:focus-visible { outline: 3px solid rgba(255, 255, 255, .62); outline-offset: 3px; }
282
+ .live-guide {
283
+ position: absolute;
284
+ left: 50%;
285
+ bottom: 28px;
286
+ z-index: 8;
287
+ width: min(480px, calc(100% - 48px));
288
+ padding: 14px 18px;
289
+ border: 1px solid rgba(107, 77, 246, .24);
290
+ border-radius: 18px;
291
+ background: rgba(255, 255, 255, .98);
292
+ color: #101a31 !important;
293
+ text-align: center;
294
+ pointer-events: none;
295
+ opacity: 0;
296
+ transform: translateX(-50%) translateY(8px);
297
+ transition: opacity .2s ease, transform .2s ease;
298
+ backdrop-filter: blur(12px);
299
+ box-shadow: 0 16px 32px rgba(34, 43, 72, .18);
300
+ text-shadow: none !important;
301
+ }
302
+ .whistle-app .camera-card .live-guide,
303
+ .whistle-app .camera-card .live-guide * {
304
+ color: #101a31 !important;
305
+ text-shadow: none !important;
306
+ }
307
+ .whistle-app .camera-card .live-guide strong {
308
+ display: block;
309
+ font-size: 20px;
310
+ line-height: 1.2;
311
+ font-weight: 950;
312
+ }
313
+ .whistle-app .camera-card .live-guide span {
314
+ display: block;
315
+ margin-top: 6px;
316
+ color: #24304a !important;
317
+ line-height: 1.35;
318
+ font-weight: 850;
319
+ }
320
+ .camera-card[data-active="true"] .live-guide {
321
+ opacity: 1;
322
+ transform: translateX(-50%) translateY(0);
323
+ }
324
+ .camera-card[data-mouth-in-oval="true"] .live-guide {
325
+ opacity: 0 !important;
326
+ transform: translateX(-50%) translateY(8px);
327
+ visibility: hidden !important;
328
+ }
329
+ .live-chip { position: absolute; top: 24px; left: 24px; z-index: 8; display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border-radius: 999px; background: rgba(255,255,255,.9); color: #14203a; font-weight: 900; backdrop-filter: blur(16px); box-shadow: 0 10px 24px rgba(31, 42, 68, .14); }
330
+ .live-chip i { width: 13px; height: 13px; border-radius: 50%; background: #32d45d; box-shadow: 0 0 0 7px rgba(50,212,93,.16); }
331
+ .camera-rule { position: absolute; top: 24px; right: 20px; z-index: 8; padding: 10px 13px; border-radius: 999px; background: rgba(30,30,30,.62); color: white; font-weight: 800; backdrop-filter: blur(12px); }
332
+ .camera-rule b { display: inline-grid; place-items: center; width: 20px; height: 20px; margin-left: 9px; border: 1px solid rgba(255,255,255,.75); border-radius: 50%; font-size: 13px; }
333
+ .coach-bubble { position: absolute; z-index: 8; top: 48px; left: 50%; transform: translateX(-50%); width: min(560px, 68%); min-height: 105px; padding: 20px 30px; display: grid; place-items: center; text-align: center; border-radius: 28px; border: 3px solid #84e295; background: linear-gradient(145deg, rgba(39,119,49,.94), rgba(23,92,42,.94)); color: white; font-size: 26px; line-height: 1.28; box-shadow: 0 14px 28px rgba(21, 52, 30, .28); text-shadow: 0 1px 2px rgba(0,0,0,.22); }
334
+ .coach-bubble span { position: absolute; top: -20px; font-size: 31px; }
335
+ .permission-note { position: absolute; left: 24px; bottom: 138px; z-index: 7; color: #17213a; background: rgba(255,255,255,.9); border: 1px solid rgba(107,77,246,.16); padding: 9px 12px; border-radius: 14px; font-weight: 800; box-shadow: 0 10px 24px rgba(31,42,68,.1); }
336
+ .camera-card:not([data-active="true"]) .permission-note { left: 50%; bottom: 22px; width: min(520px, calc(100% - 48px)); transform: translateX(-50%); text-align: center; }
337
+ .mouth-tips { position: absolute; z-index: 8; left: 28px; top: 285px; display: grid; gap: 14px; }
338
+ .mouth-tips span { width: 170px; height: 54px; display: flex; align-items: center; gap: 12px; padding: 0 16px; border-radius: 14px; background: rgba(255,255,255,.22); color: white; border: 1px solid rgba(255,255,255,.58); font-weight: 800; backdrop-filter: blur(10px); }
339
+ .mouth-tips i { width: 23px; height: 23px; border-radius: 50%; border: 3px solid #7de785; box-shadow: inset 0 0 0 4px rgba(92,83,221,.58); }
340
+ .mouth-tips .face-dot::after { content: ""; display: block; width: 5px; height: 5px; margin: 6px auto; border-radius: 50%; background: #7de785; }
341
+ .practice-dock { position: absolute; z-index: 9; left: 9%; right: 9%; bottom: 22px; min-height: 108px; display: grid; grid-template-columns: 88px minmax(170px, 1fr) 300px 146px 58px; align-items: center; gap: 16px; padding: 18px 20px; border-radius: 25px; background: rgba(20,22,27,.80); color: white; backdrop-filter: blur(16px); box-shadow: 0 18px 35px rgba(0,0,0,.35); }
342
+ .mic-orb { width: 76px; height: 76px; border: 10px solid rgba(117,95,236,.42); border-radius: 50%; background: linear-gradient(145deg, #8565ff, #5d43de); color: white; font-size: 33px; cursor: pointer; }
343
+ .dock-copy strong { display: block; font-size: 22px; margin-bottom: 8px; }
344
+ .dock-copy p { margin: 0; color: rgba(255,255,255,.95); font-weight: 650; }
345
+ .pulse-wave { height: 44px; display: flex; align-items: center; justify-content: center; gap: 5px; }
346
+ .pulse-wave span { width: 4px; height: 12px; border-radius: 999px; background: #7b5dff; animation: pulse 1s ease-in-out infinite; }
347
+ .pulse-wave span:nth-child(2n) { height: 24px; animation-delay: .1s; }
348
+ .pulse-wave span:nth-child(3n) { height: 34px; animation-delay: .2s; }
349
+ .rest-pill, .reset-button { height: 50px; border: 1px solid rgba(255,255,255,.72); border-radius: 999px; background: rgba(255,255,255,.09); color: white; font-size: 15px; font-weight: 900; cursor: pointer; }
350
+ .rest-pill:disabled { opacity: .55; cursor: not-allowed; }
351
+ .reset-button { width: 58px; font-size: 12px; }
352
+ @keyframes pulse { 50% { transform: scaleY(1.8); opacity: .72; } }
353
+
354
+ .listening-panel { padding: clamp(18px, 1.35vw, 22px) clamp(14px, 1vw, 16px); display: grid; gap: 12px; align-content: start; overflow: visible; }
355
+ .panel-heading { display: flex; gap: 12px; align-items: start; padding: 0 10px 8px; }
356
+ .panel-heading > span { color: var(--purple); font-weight: 900; }
357
+ .panel-heading strong { letter-spacing: .06em; }
358
+ .panel-heading p { margin: 7px 0 0; color: #35415a; font-weight: 700; }
359
+ .indicator-card { padding: 20px 18px; border: 1px solid var(--line); border-radius: 15px; background: white; box-shadow: 0 8px 20px rgba(32,41,75,.04); }
360
+ .indicator-topline { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; }
361
+ .indicator-name { min-width: 0; display: flex; align-items: center; gap: 9px; font-size: clamp(17px, 1.25vw, 18px); font-weight: 900; }
362
+ .indicator-score { flex: 0 0 auto; font-size: clamp(21px, 1.6vw, 24px); color: var(--green); }
363
+ .indicator-score small { font-size: 14px; color: #4b5871; margin-left: 3px; font-weight: 850; }
364
+ .score-track { height: 12px; background: #e8ebf1; border-radius: 999px; overflow: hidden; margin: 18px 0 12px; }
365
+ .score-fill { display: block; width: 0; height: 100%; border-radius: inherit; background: var(--green); transition: width .25s ease; }
366
+ .indicator-card.orange .indicator-score { color: var(--orange); }
367
+ .indicator-card.orange .score-fill { background: var(--orange); }
368
+ .indicator-card.purple .indicator-score { color: var(--purple); }
369
+ .indicator-card.purple .score-fill { background: var(--purple); }
370
+ .indicator-card.blue .indicator-score { color: var(--blue); }
371
+ .indicator-card.blue .score-fill { background: var(--blue); }
372
+ .indicator-detail { margin: 0; color: #303c56; line-height: 1.45; min-height: 22px; font-weight: 750; }
373
+ .stack-note { margin-top: 8px; display: grid; grid-template-columns: 42px 1fr; gap: 12px; align-items: center; padding: 17px; border: 1px solid #e3dcff; border-radius: 16px; background: #f7f3ff; color: #33405a; line-height: 1.45; font-weight: 700; }
374
+ .stack-note span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: #efe8ff; color: var(--purple); }
375
+ .stack-note p, .technical-status { margin: 0; }
376
+ .technical-status { color: #46526a; padding: 0 10px; font-size: 13px; font-weight: 650; }
377
+
378
+ .garden-panel { margin-top: 18px; padding: 25px 30px; max-width: calc(100% - 366px); margin-left: 342px; }
379
+ .garden-panel h3 { margin: 0 0 20px; font-size: 15px; letter-spacing: .06em; }
380
+ .garden-line { position: relative; display: grid; grid-template-columns: repeat(5, 1fr); gap: 20px; align-items: end; }
381
+ .garden-line::before { content: ""; position: absolute; left: 7%; right: 7%; top: 52px; height: 3px; background: #cbd2dd; }
382
+ .garden-step { position: relative; z-index: 1; display: grid; justify-items: center; gap: 8px; color: #414c64; }
383
+ .garden-step i { font-style: normal; font-size: 42px; filter: grayscale(.55); opacity: .7; }
384
+ .garden-step small { font-size: 15px; font-weight: 800; }
385
+ .garden-step .soil { width: 15px; height: 15px; display: grid; place-items: center; border-radius: 50%; color: transparent; background: #c5ccd7; border: 3px solid white; box-shadow: 0 0 0 2px #c5ccd7; order: 2; }
386
+ .garden-step.reached i, .garden-step.active i { filter: none; opacity: 1; }
387
+ .garden-step.reached .soil, .garden-step.active .soil { background: var(--green); box-shadow: 0 0 0 2px var(--green); }
388
+ .melody-card { margin-top: 16px; display: flex; align-items: center; gap: 14px; color: #34405a; font-weight: 700; }
389
+ .melody-card button, .melody-card a { border: 1px solid var(--line); background: white; color: var(--purple); border-radius: 999px; padding: 8px 14px; font-weight: 900; text-decoration: none; }
390
+ .melody-card button:disabled { color: #667187; }
391
+ .hidden-meter { display: none !important; }
392
+
393
+ @media (max-width: 1500px) {
394
+ .wc-layout {
395
+ grid-template-columns: minmax(240px, .72fr) minmax(440px, 1.58fr) minmax(260px, .82fr);
396
+ }
397
+ .flow-list li { grid-template-columns: 26px minmax(0, 1fr) 18px; }
398
+ .journey-row { grid-template-columns: 74px minmax(0, 1fr) 48px; gap: 12px; }
399
+ .progress-ring { width: 70px; height: 70px; font-size: 18px; }
400
+ .plant-pot { width: 50px; }
401
+ .indicator-card { padding: 18px 16px; }
402
+ }
403
+
404
+ @media (max-height: 760px) and (min-width: 901px) {
405
+ .whistle-app { --wc-page-pad: clamp(12px, 1.3vw, 22px); --wc-gap: clamp(12px, 1vw, 18px); }
406
+ .wc-header { margin-bottom: 14px; }
407
+ .brand-mark { width: 58px; height: 58px; border-radius: 17px; }
408
+ .brand-mark svg { width: 34px; height: 34px; }
409
+ .brand-lockup h1 { font-size: 25px; }
410
+ .brand-lockup p { font-size: 15px; }
411
+ .camera-card { height: clamp(430px, calc(100vh - 112px), 600px); }
412
+ .side-card { padding: 16px 15px; }
413
+ .flow-list { gap: 4px; }
414
+ .flow-list li { min-height: 28px; }
415
+ .listening-panel { padding: 16px 14px; gap: 10px; }
416
+ .indicator-card { padding: 16px 14px; }
417
+ .score-track { margin: 12px 0 10px; }
418
+ }
419
+
420
+ @media (max-width: 1280px) {
421
+ .wc-layout { grid-template-columns: minmax(260px, 300px) minmax(420px, 1fr); align-items: start; overflow-y: auto; }
422
+ .right-rail { grid-column: 1 / -1; }
423
+ .listening-panel { height: auto; min-height: auto; grid-template-columns: repeat(4, minmax(0, 1fr)); }
424
+ .panel-heading, .stack-note, .technical-status { grid-column: 1 / -1; }
425
+ .garden-panel { max-width: none; margin-left: 0; }
426
+ .camera-card { height: clamp(480px, calc(100vh - 160px), 680px); }
427
+ }
428
+ @media (max-width: 900px) {
429
+ .whistle-app {
430
+ height: auto;
431
+ min-height: 0;
432
+ max-height: none;
433
+ overflow: visible;
434
+ padding: 18px;
435
+ }
436
+ .wc-header, .wc-layout { display: grid; grid-template-columns: 1fr; }
437
+ .wc-layout { min-height: 0; align-items: start; overflow: visible; padding-bottom: 18px; }
438
+ .left-rail,
439
+ .right-rail,
440
+ .center-stage {
441
+ overflow: visible;
442
+ }
443
+ .brand-lockup { min-width: 0; }
444
+ .brand-mark { width: 60px; height: 60px; }
445
+ .camera-card { height: clamp(420px, 62vh, 540px); }
446
+ .camera-placeholder { padding-bottom: 82px; }
447
+ .practice-dock { left: 14px; right: 14px; grid-template-columns: 64px 1fr 112px; }
448
+ .pulse-wave, .reset-button { display: none; }
449
+ .mic-orb { width: 58px; height: 58px; border-width: 7px; font-size: 24px; }
450
+ .listening-panel { height: auto; overflow: visible; grid-template-columns: 1fr; }
451
+ .garden-line { gap: 6px; }
452
+ }
453
+
454
+ @media (max-width: 620px) {
455
+ .whistle-app { --wc-page-pad: 12px; }
456
+ .brand-lockup { gap: 12px; }
457
+ .brand-mark { width: 52px; height: 52px; border-radius: 15px; }
458
+ .brand-mark svg { width: 31px; height: 31px; }
459
+ .brand-lockup h1 { font-size: 24px; }
460
+ .brand-lockup p { font-size: 14px; }
461
+ .side-card,
462
+ .listening-panel { border-radius: 18px; }
463
+ .journey-row { grid-template-columns: 68px minmax(0, 1fr); }
464
+ .plant-pot { display: none; }
465
+ .camera-card { height: clamp(390px, 68vh, 500px); border-radius: 18px; }
466
+ .placeholder-face { width: min(150px, 48vw); height: min(176px, 28vh); }
467
+ .camera-card:not([data-active="true"]) .permission-note { width: calc(100% - 28px); bottom: 14px; }
468
+ }
469
+
470
+ @media (max-height: 760px) and (min-width: 901px) {
471
+ .camera-card { height: clamp(430px, calc(100vh - 112px), 600px); }
472
+ }
frontend/whistle_coach.js ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/01_feature_schema_and_state_classifier.ipynb ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Feature schema and state classifier\n",
8
+ "\n",
9
+ "This notebook documents the V1 feature schema used by Whistle Coach: lower-face features from MediaPipe, audio features from WebAudio, and the rule-based practice state classifier."
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "code",
14
+ "execution_count": null,
15
+ "metadata": {},
16
+ "outputs": [],
17
+ "source": [
18
+ "from backend.state_classifier import classify_practice_state\n",
19
+ "\n",
20
+ "sample = {\n",
21
+ " 'face': {'face_visible': True, 'face_centered': True, 'mouth_opening_ratio': 0.16, 'lip_roundness_score': 0.72, 'jaw_stability_score': 0.8, 'mouth_symmetry_score': 0.9, 'mouth_shape_score': 0.78},\n",
22
+ " 'audio': {'airflow_score': 0.65, 'pitch_stability_score': 0.2, 'peak_frequency_hz': 0, 'stable_duration_ms': 0},\n",
23
+ "}\n",
24
+ "classify_practice_state(sample)\n"
25
+ ]
26
+ }
27
+ ],
28
+ "metadata": {
29
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
30
+ "language_info": {"name": "python", "version": "3.10"}
31
+ },
32
+ "nbformat": 4,
33
+ "nbformat_minor": 5
34
+ }
notebooks/02_build_coach_policy_dataset.ipynb ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Build a coach policy dataset\n",
8
+ "\n",
9
+ "V1 uses base Nemotron plus a rule fallback. Later, this notebook can turn recorded `practice_state -> coach_response` pairs into a small supervised dataset for a LoRA coach policy."
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "code",
14
+ "execution_count": null,
15
+ "metadata": {},
16
+ "outputs": [],
17
+ "source": [
18
+ "import json\n",
19
+ "from pathlib import Path\n",
20
+ "\n",
21
+ "rows = [json.loads(line) for line in Path('data/sample_coach_states.jsonl').read_text().splitlines()]\n",
22
+ "rows[:2]\n"
23
+ ]
24
+ }
25
+ ],
26
+ "metadata": {
27
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
28
+ "language_info": {"name": "python", "version": "3.10"}
29
+ },
30
+ "nbformat": 4,
31
+ "nbformat_minor": 5
32
+ }
notebooks/03_whistle_pitch_to_melody.ipynb ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Whistle pitch to melody\n",
8
+ "\n",
9
+ "This notebook sketches the pitch-contour-to-melody module. The app records a short stable whistle contour, quantizes it into notes, then writes a small WAV arrangement."
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "code",
14
+ "execution_count": null,
15
+ "metadata": {},
16
+ "outputs": [],
17
+ "source": [
18
+ "from pathlib import Path\n",
19
+ "from backend.melody_generator import generate_melody_wav, quantize_pitch_contour\n",
20
+ "\n",
21
+ "contour = [{'time_ms': i * 120, 'frequency_hz': hz} for i, hz in enumerate([880, 900, 990, 990, 1175, 1175])]\n",
22
+ "quantize_pitch_contour(contour), generate_melody_wav(contour, Path('outputs/sample_melodies/notebook_demo.wav'))\n"
23
+ ]
24
+ }
25
+ ],
26
+ "metadata": {
27
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
28
+ "language_info": {"name": "python", "version": "3.10"}
29
+ },
30
+ "nbformat": 4,
31
+ "nbformat_minor": 5
32
+ }
outputs/sample_melodies/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio
2
+ spaces
3
+ transformers
4
+ torch
5
+ torchaudio
6
+ librosa
7
+ numpy
8
+ scipy
9
+ soundfile
10
+ mediapipe
11
+ opencv-python
style.css ADDED
@@ -0,0 +1,1667 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --wc-ink: #17263b;
3
+ --wc-muted: #657083;
4
+ --wc-cream: #fffaf0;
5
+ --wc-paper: #ffffff;
6
+ --wc-border: #eadfce;
7
+ --wc-red: #f05a4f;
8
+ --wc-red-soft: #fff0ed;
9
+ --wc-yellow: #f3b51b;
10
+ --wc-yellow-soft: #fff7d9;
11
+ --wc-green: #20a879;
12
+ --wc-green-soft: #e9f8f2;
13
+ --wc-blue: #62c9e8;
14
+ --wc-navy: #1c3150;
15
+ --wc-shadow: 0 20px 60px rgba(28, 49, 80, 0.12);
16
+ }
17
+
18
+ body,
19
+ .gradio-container {
20
+ background:
21
+ radial-gradient(circle at 10% 3%, rgba(243, 181, 27, 0.18), transparent 28rem),
22
+ radial-gradient(circle at 92% 14%, rgba(98, 201, 232, 0.14), transparent 30rem),
23
+ radial-gradient(circle at 70% 100%, rgba(32, 168, 121, 0.12), transparent 30rem),
24
+ #f8f5ed !important;
25
+ color: var(--wc-ink);
26
+ }
27
+
28
+ .gradio-container,
29
+ .main,
30
+ .wrap {
31
+ max-width: none !important;
32
+ padding: 0 !important;
33
+ }
34
+
35
+ .whistle-app,
36
+ .whistle-app * {
37
+ box-sizing: border-box;
38
+ }
39
+
40
+ .whistle-app {
41
+ width: 100%;
42
+ min-width: 1080px;
43
+ min-height: 100vh;
44
+ padding: 24px clamp(24px, 3vw, 48px) 18px;
45
+ color: var(--wc-ink);
46
+ font-family:
47
+ Inter, ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, -apple-system,
48
+ BlinkMacSystemFont, sans-serif;
49
+ }
50
+
51
+ .app-header {
52
+ width: min(1540px, 100%);
53
+ margin: 0 auto 18px;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: space-between;
57
+ gap: 24px;
58
+ }
59
+
60
+ .brand-lockup {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 15px;
64
+ }
65
+
66
+ .brand-mark {
67
+ width: 60px;
68
+ height: 60px;
69
+ display: grid;
70
+ place-items: center;
71
+ flex: 0 0 auto;
72
+ border-radius: 20px;
73
+ color: white;
74
+ background: linear-gradient(145deg, var(--wc-red), #ff875d);
75
+ box-shadow: 0 11px 26px rgba(240, 90, 79, 0.26);
76
+ transform: rotate(-3deg);
77
+ }
78
+
79
+ .brand-mark svg {
80
+ width: 40px;
81
+ height: 40px;
82
+ }
83
+
84
+ .eyebrow,
85
+ .step-label {
86
+ margin: 0 0 3px;
87
+ color: var(--wc-red);
88
+ font-size: 0.7rem;
89
+ font-weight: 850;
90
+ letter-spacing: 0.13em;
91
+ text-transform: uppercase;
92
+ }
93
+
94
+ .app-header h1 {
95
+ margin: 0;
96
+ color: var(--wc-navy);
97
+ font-size: clamp(1.9rem, 2.6vw, 2.75rem);
98
+ font-weight: 900;
99
+ letter-spacing: -0.045em;
100
+ line-height: 1;
101
+ }
102
+
103
+ .subtitle {
104
+ margin: 7px 0 0;
105
+ color: var(--wc-muted);
106
+ font-size: 0.98rem;
107
+ font-weight: 650;
108
+ }
109
+
110
+ .controls {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 9px;
114
+ padding: 7px;
115
+ border: 1px solid var(--wc-border);
116
+ border-radius: 18px;
117
+ background: rgba(255, 255, 255, 0.84);
118
+ box-shadow: 0 8px 28px rgba(28, 49, 80, 0.06);
119
+ backdrop-filter: blur(12px);
120
+ }
121
+
122
+ .control-button {
123
+ min-height: 44px;
124
+ padding: 0 17px;
125
+ border: 0;
126
+ border-radius: 12px;
127
+ cursor: pointer;
128
+ font: inherit;
129
+ font-size: 0.88rem;
130
+ font-weight: 850;
131
+ transition:
132
+ transform 150ms ease,
133
+ box-shadow 150ms ease,
134
+ opacity 150ms ease;
135
+ }
136
+
137
+ .control-button:hover:not(:disabled) {
138
+ transform: translateY(-1px);
139
+ }
140
+
141
+ .control-button:focus-visible {
142
+ outline: 3px solid rgba(28, 49, 80, 0.22);
143
+ outline-offset: 2px;
144
+ }
145
+
146
+ .control-button:disabled {
147
+ cursor: not-allowed;
148
+ opacity: 0.45;
149
+ }
150
+
151
+ .control-button.primary {
152
+ color: white;
153
+ background: var(--wc-navy);
154
+ box-shadow: 0 7px 16px rgba(28, 49, 80, 0.2);
155
+ }
156
+
157
+ .control-button.secondary {
158
+ color: #9b342f;
159
+ background: var(--wc-red-soft);
160
+ }
161
+
162
+ .control-button.ghost {
163
+ color: var(--wc-navy);
164
+ background: transparent;
165
+ }
166
+
167
+ .workspace {
168
+ width: min(1540px, 100%);
169
+ margin: 0 auto;
170
+ }
171
+
172
+ .garden-workspace {
173
+ display: grid;
174
+ grid-template-columns: minmax(720px, 1fr) minmax(320px, 0.35fr);
175
+ gap: 18px;
176
+ align-items: stretch;
177
+ }
178
+
179
+ .panel {
180
+ min-width: 0;
181
+ padding: 18px;
182
+ border: 1px solid rgba(234, 223, 206, 0.96);
183
+ border-radius: 27px;
184
+ background: rgba(255, 255, 255, 0.92);
185
+ box-shadow: var(--wc-shadow);
186
+ }
187
+
188
+ .game-panel {
189
+ padding: 20px;
190
+ }
191
+
192
+ .side-stack {
193
+ display: grid;
194
+ gap: 18px;
195
+ grid-template-rows: auto auto;
196
+ align-content: start;
197
+ }
198
+
199
+ .panel-heading {
200
+ min-height: 50px;
201
+ margin-bottom: 13px;
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: space-between;
205
+ gap: 12px;
206
+ }
207
+
208
+ .panel-heading.compact {
209
+ min-height: auto;
210
+ justify-content: flex-start;
211
+ }
212
+
213
+ .panel-heading h2 {
214
+ margin: 0;
215
+ color: var(--wc-navy);
216
+ font-size: 1.22rem;
217
+ font-weight: 900;
218
+ letter-spacing: -0.025em;
219
+ }
220
+
221
+ .session-pill {
222
+ display: inline-flex;
223
+ align-items: center;
224
+ gap: 7px;
225
+ padding: 7px 10px;
226
+ border-radius: 999px;
227
+ color: var(--wc-muted);
228
+ background: #f3f1ec;
229
+ font-size: 0.73rem;
230
+ font-weight: 850;
231
+ }
232
+
233
+ .session-dot {
234
+ width: 8px;
235
+ height: 8px;
236
+ border-radius: 50%;
237
+ background: #a6a39c;
238
+ }
239
+
240
+ .session-pill[data-state="loading"] {
241
+ color: #876307;
242
+ background: var(--wc-yellow-soft);
243
+ }
244
+
245
+ .session-pill[data-state="loading"] .session-dot {
246
+ background: var(--wc-yellow);
247
+ animation: wc-pulse 1s infinite;
248
+ }
249
+
250
+ .session-pill[data-state="live"] {
251
+ color: #116d50;
252
+ background: var(--wc-green-soft);
253
+ }
254
+
255
+ .session-pill[data-state="live"] .session-dot {
256
+ background: var(--wc-green);
257
+ box-shadow: 0 0 0 4px rgba(32, 168, 121, 0.15);
258
+ }
259
+
260
+ .session-pill[data-state="error"] {
261
+ color: #9b342f;
262
+ background: var(--wc-red-soft);
263
+ }
264
+
265
+ .session-pill[data-state="error"] .session-dot {
266
+ background: var(--wc-red);
267
+ }
268
+
269
+ @keyframes wc-pulse {
270
+ 50% {
271
+ opacity: 0.35;
272
+ }
273
+ }
274
+
275
+ .camera-stage {
276
+ position: relative;
277
+ aspect-ratio: 16 / 9;
278
+ overflow: hidden;
279
+ border: 5px solid var(--wc-red);
280
+ border-radius: 25px;
281
+ background:
282
+ linear-gradient(180deg, rgba(23, 38, 59, 0.12), rgba(23, 38, 59, 0.42)),
283
+ #182536;
284
+ box-shadow: inset 0 -100px 90px rgba(5, 18, 20, 0.3);
285
+ transition:
286
+ border-color 180ms ease,
287
+ box-shadow 180ms ease;
288
+ }
289
+
290
+ .camera-stage[data-feedback="yellow"] {
291
+ border-color: var(--wc-yellow);
292
+ box-shadow:
293
+ 0 0 0 5px rgba(243, 181, 27, 0.13),
294
+ inset 0 -100px 90px rgba(5, 18, 20, 0.25);
295
+ }
296
+
297
+ .camera-stage[data-feedback="green"] {
298
+ border-color: var(--wc-green);
299
+ box-shadow:
300
+ 0 0 0 5px rgba(32, 168, 121, 0.14),
301
+ inset 0 -100px 90px rgba(5, 18, 20, 0.18);
302
+ }
303
+
304
+ #camera-placeholder,
305
+ #practice-video,
306
+ #mouth-overlay,
307
+ #garden-canvas {
308
+ position: absolute;
309
+ inset: 0;
310
+ width: 100%;
311
+ height: 100%;
312
+ }
313
+
314
+ #camera-placeholder {
315
+ z-index: 1;
316
+ display: grid;
317
+ place-items: center;
318
+ padding: clamp(22px, 4vw, 52px);
319
+ overflow: hidden;
320
+ }
321
+
322
+ .start-screen {
323
+ background:
324
+ radial-gradient(circle at 28% 28%, rgba(98, 201, 232, 0.28), transparent 13rem),
325
+ radial-gradient(circle at 77% 78%, rgba(32, 168, 121, 0.3), transparent 14rem),
326
+ linear-gradient(135deg, #182536, #263a58 52%, #203143);
327
+ color: white;
328
+ }
329
+
330
+ .start-orbit {
331
+ position: absolute;
332
+ inset: 16%;
333
+ border: 1px dashed rgba(255, 255, 255, 0.24);
334
+ border-radius: 999px;
335
+ animation: wc-orbit 9s linear infinite;
336
+ }
337
+
338
+ .start-orbit span {
339
+ position: absolute;
340
+ width: 16px;
341
+ height: 16px;
342
+ border-radius: 50%;
343
+ background: #98f1c5;
344
+ box-shadow: 0 0 28px rgba(152, 241, 197, 0.8);
345
+ }
346
+
347
+ .start-orbit span:nth-child(1) {
348
+ top: -8px;
349
+ left: 48%;
350
+ }
351
+
352
+ .start-orbit span:nth-child(2) {
353
+ right: 8%;
354
+ bottom: 14%;
355
+ background: #ffe27a;
356
+ }
357
+
358
+ .start-orbit span:nth-child(3) {
359
+ left: 9%;
360
+ bottom: 19%;
361
+ background: #8ad7ff;
362
+ }
363
+
364
+ .start-copy {
365
+ position: relative;
366
+ z-index: 2;
367
+ width: min(560px, 88%);
368
+ padding: 28px 30px;
369
+ border: 1px solid rgba(255, 255, 255, 0.28);
370
+ border-radius: 26px;
371
+ background: rgba(23, 38, 59, 0.66);
372
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.22);
373
+ text-align: center;
374
+ backdrop-filter: blur(13px);
375
+ }
376
+
377
+ .start-kicker {
378
+ margin: 0 0 8px;
379
+ color: #98f1c5;
380
+ font-size: 0.72rem;
381
+ font-weight: 900;
382
+ letter-spacing: 0.14em;
383
+ text-transform: uppercase;
384
+ }
385
+
386
+ .start-copy h3 {
387
+ margin: 0;
388
+ font-size: clamp(1.4rem, 3vw, 2.35rem);
389
+ font-weight: 950;
390
+ letter-spacing: -0.045em;
391
+ line-height: 1.04;
392
+ }
393
+
394
+ .start-copy p:last-child {
395
+ margin: 13px auto 0;
396
+ max-width: 440px;
397
+ color: rgba(255, 255, 255, 0.76);
398
+ font-size: 0.84rem;
399
+ font-weight: 650;
400
+ line-height: 1.55;
401
+ }
402
+
403
+ @keyframes wc-orbit {
404
+ to {
405
+ transform: rotate(360deg);
406
+ }
407
+ }
408
+
409
+ #practice-video {
410
+ z-index: 2;
411
+ display: none;
412
+ object-fit: cover;
413
+ transform: scaleX(-1);
414
+ }
415
+
416
+ #mouth-overlay {
417
+ z-index: 4;
418
+ pointer-events: none;
419
+ }
420
+
421
+ #garden-canvas {
422
+ z-index: 3;
423
+ pointer-events: none;
424
+ }
425
+
426
+ .permission-note {
427
+ position: absolute;
428
+ z-index: 7;
429
+ left: 50%;
430
+ bottom: 92px;
431
+ width: min(88%, 430px);
432
+ padding: 13px 16px;
433
+ border: 1px solid rgba(255, 255, 255, 0.32);
434
+ border-radius: 16px;
435
+ color: white;
436
+ background: rgba(23, 38, 59, 0.82);
437
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
438
+ text-align: center;
439
+ transform: translateX(-50%);
440
+ backdrop-filter: blur(9px);
441
+ }
442
+
443
+ .permission-note strong,
444
+ .permission-note span {
445
+ display: block;
446
+ }
447
+
448
+ .permission-note strong {
449
+ margin-bottom: 3px;
450
+ font-size: 0.82rem;
451
+ }
452
+
453
+ .permission-note span {
454
+ color: rgba(255, 255, 255, 0.78);
455
+ font-size: 0.72rem;
456
+ }
457
+
458
+ .target-legend {
459
+ position: absolute;
460
+ z-index: 6;
461
+ top: 13px;
462
+ left: 13px;
463
+ display: flex;
464
+ align-items: center;
465
+ gap: 7px;
466
+ padding: 7px 10px;
467
+ border-radius: 999px;
468
+ color: white;
469
+ background: rgba(23, 38, 59, 0.66);
470
+ font-size: 0.68rem;
471
+ font-weight: 800;
472
+ backdrop-filter: blur(8px);
473
+ }
474
+
475
+ .legend-swatch {
476
+ width: 18px;
477
+ height: 9px;
478
+ border: 2px dashed white;
479
+ border-radius: 50%;
480
+ }
481
+
482
+ .garden-hud {
483
+ position: absolute;
484
+ z-index: 6;
485
+ right: 14px;
486
+ bottom: 14px;
487
+ width: min(310px, calc(100% - 28px));
488
+ padding: 13px;
489
+ border: 1px solid rgba(255, 255, 255, 0.35);
490
+ border-radius: 18px;
491
+ color: white;
492
+ background: rgba(23, 38, 59, 0.72);
493
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
494
+ backdrop-filter: blur(10px);
495
+ }
496
+
497
+ .garden-hud > div:first-child,
498
+ .garden-stats {
499
+ display: flex;
500
+ align-items: center;
501
+ justify-content: space-between;
502
+ gap: 12px;
503
+ }
504
+
505
+ .garden-hud-label,
506
+ .garden-stats {
507
+ color: rgba(255, 255, 255, 0.72);
508
+ font-size: 0.69rem;
509
+ font-weight: 750;
510
+ }
511
+
512
+ #garden-growth {
513
+ color: #98f1c5;
514
+ font-size: 1.25rem;
515
+ font-weight: 950;
516
+ }
517
+
518
+ .garden-track {
519
+ height: 8px;
520
+ margin: 10px 0 9px;
521
+ overflow: hidden;
522
+ border-radius: 999px;
523
+ background: rgba(255, 255, 255, 0.18);
524
+ }
525
+
526
+ #garden-growth-fill {
527
+ display: block;
528
+ width: 0;
529
+ height: 100%;
530
+ border-radius: inherit;
531
+ background: linear-gradient(90deg, #7ee7a7, #ffe27a, #ff9d6e);
532
+ transition: width 120ms linear;
533
+ }
534
+
535
+ .garden-stats strong {
536
+ color: white;
537
+ font-size: 0.82rem;
538
+ }
539
+
540
+ .camera-hint {
541
+ margin: 13px 3px 0;
542
+ color: var(--wc-muted);
543
+ font-size: 0.78rem;
544
+ line-height: 1.55;
545
+ }
546
+
547
+ .status-panel,
548
+ .coach-panel {
549
+ display: flex;
550
+ flex-direction: column;
551
+ }
552
+
553
+ .coach-panel {
554
+ order: -1;
555
+ }
556
+
557
+ .indicator-stack {
558
+ display: grid;
559
+ gap: 11px;
560
+ }
561
+
562
+ .indicator-card {
563
+ --indicator: var(--wc-red);
564
+ --indicator-soft: var(--wc-red-soft);
565
+ padding: 14px;
566
+ border: 1px solid color-mix(in srgb, var(--indicator) 24%, white);
567
+ border-radius: 17px;
568
+ background: var(--indicator-soft);
569
+ transition:
570
+ border-color 180ms ease,
571
+ background 180ms ease;
572
+ }
573
+
574
+ .indicator-card[data-state="yellow"] {
575
+ --indicator: var(--wc-yellow);
576
+ --indicator-soft: var(--wc-yellow-soft);
577
+ }
578
+
579
+ .indicator-card[data-state="green"] {
580
+ --indicator: var(--wc-green);
581
+ --indicator-soft: var(--wc-green-soft);
582
+ }
583
+
584
+ .indicator-topline,
585
+ .indicator-name {
586
+ display: flex;
587
+ align-items: center;
588
+ }
589
+
590
+ .indicator-topline {
591
+ justify-content: space-between;
592
+ gap: 10px;
593
+ }
594
+
595
+ .indicator-name {
596
+ gap: 8px;
597
+ color: var(--wc-navy);
598
+ font-size: 0.81rem;
599
+ font-weight: 850;
600
+ }
601
+
602
+ .status-light {
603
+ width: 10px;
604
+ height: 10px;
605
+ border-radius: 50%;
606
+ background: var(--indicator);
607
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--indicator) 17%, transparent);
608
+ }
609
+
610
+ .indicator-score {
611
+ color: var(--indicator);
612
+ font-size: 1.12rem;
613
+ font-weight: 950;
614
+ }
615
+
616
+ .indicator-score small {
617
+ margin-left: 2px;
618
+ color: var(--wc-muted);
619
+ font-size: 0.6rem;
620
+ font-weight: 750;
621
+ }
622
+
623
+ .score-track {
624
+ height: 7px;
625
+ margin: 12px 0 9px;
626
+ overflow: hidden;
627
+ border-radius: 99px;
628
+ background: rgba(23, 38, 59, 0.09);
629
+ }
630
+
631
+ .score-fill {
632
+ display: block;
633
+ width: 0;
634
+ height: 100%;
635
+ border-radius: inherit;
636
+ background: var(--indicator);
637
+ transition: width 140ms linear;
638
+ }
639
+
640
+ .indicator-detail {
641
+ min-height: 1.1em;
642
+ margin: 0;
643
+ color: var(--wc-muted);
644
+ font-size: 0.68rem;
645
+ font-weight: 650;
646
+ }
647
+
648
+ .signal-note {
649
+ margin-top: 13px;
650
+ padding: 13px 14px;
651
+ border: 1px dashed #ded7ca;
652
+ border-radius: 15px;
653
+ background: #faf8f3;
654
+ }
655
+
656
+ .signal-note strong,
657
+ .signal-note span {
658
+ display: block;
659
+ }
660
+
661
+ .signal-note strong {
662
+ margin-bottom: 3px;
663
+ color: var(--wc-navy);
664
+ font-size: 0.73rem;
665
+ }
666
+
667
+ .signal-note span {
668
+ color: var(--wc-muted);
669
+ font-size: 0.67rem;
670
+ line-height: 1.45;
671
+ }
672
+
673
+ .coach-card {
674
+ position: relative;
675
+ min-height: 150px;
676
+ padding: 20px 18px;
677
+ display: flex;
678
+ flex-direction: column;
679
+ align-items: center;
680
+ justify-content: center;
681
+ gap: 14px;
682
+ overflow: hidden;
683
+ border-radius: 21px;
684
+ background:
685
+ radial-gradient(circle at 85% 12%, rgba(243, 181, 27, 0.32), transparent 5rem),
686
+ var(--wc-yellow-soft);
687
+ text-align: center;
688
+ transition: background 180ms ease;
689
+ }
690
+
691
+ .coach-card[data-state="red"] {
692
+ background:
693
+ radial-gradient(circle at 85% 12%, rgba(240, 90, 79, 0.2), transparent 5rem),
694
+ var(--wc-red-soft);
695
+ }
696
+
697
+ .coach-card[data-state="green"] {
698
+ background:
699
+ radial-gradient(circle at 85% 12%, rgba(32, 168, 121, 0.2), transparent 5rem),
700
+ var(--wc-green-soft);
701
+ }
702
+
703
+ .coach-face {
704
+ position: relative;
705
+ width: 60px;
706
+ height: 54px;
707
+ flex: 0 0 auto;
708
+ border: 3px solid var(--wc-navy);
709
+ border-radius: 48% 48% 44% 44%;
710
+ background: #ffd970;
711
+ box-shadow: 5px 6px 0 rgba(28, 49, 80, 0.11);
712
+ transform: rotate(-2deg);
713
+ }
714
+
715
+ .coach-eye {
716
+ position: absolute;
717
+ top: 19px;
718
+ width: 6px;
719
+ height: 7px;
720
+ border-radius: 50%;
721
+ background: var(--wc-navy);
722
+ }
723
+
724
+ .coach-eye.left {
725
+ left: 15px;
726
+ }
727
+
728
+ .coach-eye.right {
729
+ right: 15px;
730
+ }
731
+
732
+ .coach-mouth {
733
+ position: absolute;
734
+ left: 50%;
735
+ bottom: 10px;
736
+ width: 10px;
737
+ height: 8px;
738
+ border: 3px solid var(--wc-navy);
739
+ border-radius: 50%;
740
+ transform: translateX(-50%);
741
+ }
742
+
743
+ #coach-message {
744
+ max-width: 290px;
745
+ margin: 0;
746
+ color: var(--wc-navy);
747
+ font-size: clamp(0.98rem, 1.25vw, 1.16rem);
748
+ font-weight: 900;
749
+ line-height: 1.35;
750
+ letter-spacing: -0.02em;
751
+ }
752
+
753
+ .mini-guide {
754
+ margin-top: 13px;
755
+ padding: 15px;
756
+ border: 1px solid var(--wc-border);
757
+ border-radius: 18px;
758
+ background: #fcfbf8;
759
+ }
760
+
761
+ .mini-guide-title {
762
+ margin: 0 0 10px;
763
+ color: var(--wc-navy);
764
+ font-size: 0.74rem;
765
+ font-weight: 900;
766
+ text-transform: uppercase;
767
+ letter-spacing: 0.08em;
768
+ }
769
+
770
+ .mini-guide ol {
771
+ display: grid;
772
+ gap: 9px;
773
+ margin: 0;
774
+ padding: 0;
775
+ list-style: none;
776
+ }
777
+
778
+ .mini-guide li {
779
+ display: flex;
780
+ align-items: center;
781
+ gap: 9px;
782
+ color: var(--wc-muted);
783
+ font-size: 0.73rem;
784
+ font-weight: 650;
785
+ }
786
+
787
+ .mini-guide li span {
788
+ width: 22px;
789
+ height: 22px;
790
+ display: grid;
791
+ place-items: center;
792
+ flex: 0 0 auto;
793
+ border-radius: 8px;
794
+ color: white;
795
+ background: var(--wc-navy);
796
+ font-size: 0.65rem;
797
+ font-weight: 850;
798
+ }
799
+
800
+ .technical-status {
801
+ margin: auto 3px 0;
802
+ padding-top: 13px;
803
+ color: var(--wc-muted);
804
+ font-size: 0.65rem;
805
+ line-height: 1.45;
806
+ }
807
+
808
+ .app-footer {
809
+ width: min(1540px, 100%);
810
+ margin: 14px auto 0;
811
+ color: #858a93;
812
+ font-size: 0.68rem;
813
+ text-align: center;
814
+ }
815
+
816
+ @media (max-width: 1220px) {
817
+ .whistle-app {
818
+ padding-inline: 20px;
819
+ }
820
+
821
+ .garden-workspace {
822
+ grid-template-columns: minmax(650px, 1fr) 310px;
823
+ }
824
+ }
825
+
826
+ @media (prefers-reduced-motion: reduce) {
827
+ .whistle-app *,
828
+ .whistle-app *::before,
829
+ .whistle-app *::after {
830
+ scroll-behavior: auto !important;
831
+ animation-duration: 0.01ms !important;
832
+ animation-iteration-count: 1 !important;
833
+ transition-duration: 0.01ms !important;
834
+ }
835
+ }
836
+
837
+ /* Reference-style UI refresh: centered camera, left coach, right signal cards. */
838
+ .whistle-app {
839
+ min-width: 1180px;
840
+ padding: 26px 34px 16px;
841
+ background: rgba(255, 255, 255, 0.72);
842
+ }
843
+
844
+ .app-header {
845
+ width: min(1500px, 100%);
846
+ margin-bottom: 18px;
847
+ }
848
+
849
+ .brand-mark {
850
+ border-radius: 16px;
851
+ background: linear-gradient(145deg, #5147ff, #6b5cff);
852
+ box-shadow: 0 14px 28px rgba(81, 71, 255, 0.22);
853
+ transform: none;
854
+ }
855
+
856
+ .eyebrow {
857
+ display: none;
858
+ }
859
+
860
+ .app-header h1 {
861
+ color: #121328;
862
+ font-size: clamp(2.2rem, 3vw, 3.3rem);
863
+ }
864
+
865
+ .subtitle {
866
+ color: #4f46e5;
867
+ font-size: 1.05rem;
868
+ }
869
+
870
+ .header-badges {
871
+ display: flex;
872
+ align-items: center;
873
+ gap: 14px;
874
+ }
875
+
876
+ .tech-badge,
877
+ .about-badge {
878
+ min-height: 40px;
879
+ display: inline-flex;
880
+ align-items: center;
881
+ gap: 8px;
882
+ padding: 0 18px;
883
+ border: 1px solid rgba(226, 232, 240, 0.9);
884
+ border-radius: 999px;
885
+ color: #25283b;
886
+ background: rgba(255, 255, 255, 0.9);
887
+ box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
888
+ font-size: 0.78rem;
889
+ font-weight: 850;
890
+ }
891
+
892
+ .about-badge::before {
893
+ content: "i";
894
+ width: 18px;
895
+ height: 18px;
896
+ display: grid;
897
+ place-items: center;
898
+ border: 2px solid currentColor;
899
+ border-radius: 50%;
900
+ font-size: 0.72rem;
901
+ font-weight: 950;
902
+ }
903
+
904
+ .coach-workspace {
905
+ display: grid;
906
+ grid-template-columns: 280px minmax(980px, 1fr);
907
+ gap: 18px;
908
+ align-items: stretch;
909
+ }
910
+
911
+ .coach-sidebar,
912
+ .signal-sidebar {
913
+ min-width: 0;
914
+ }
915
+
916
+ .coach-sidebar {
917
+ display: flex;
918
+ flex-direction: column;
919
+ gap: 15px;
920
+ padding: 18px;
921
+ }
922
+
923
+ .camera-card {
924
+ min-width: 0;
925
+ position: relative;
926
+ }
927
+
928
+ .camera-stage {
929
+ border: 0;
930
+ border-radius: 26px;
931
+ background: #101827;
932
+ box-shadow:
933
+ 0 22px 64px rgba(15, 23, 42, 0.16),
934
+ inset 0 -110px 90px rgba(5, 18, 20, 0.26);
935
+ }
936
+
937
+ .camera-stage[data-feedback="yellow"],
938
+ .camera-stage[data-feedback="green"] {
939
+ border-color: transparent;
940
+ }
941
+
942
+ #mouth-overlay {
943
+ z-index: 5;
944
+ }
945
+
946
+ #garden-canvas {
947
+ z-index: 4;
948
+ }
949
+
950
+ .live-pill {
951
+ position: absolute;
952
+ z-index: 8;
953
+ top: 16px;
954
+ left: 16px;
955
+ color: white;
956
+ background: rgba(15, 23, 42, 0.78);
957
+ backdrop-filter: blur(12px);
958
+ }
959
+
960
+ .target-legend {
961
+ top: auto;
962
+ left: 18px;
963
+ bottom: 18px;
964
+ width: 210px;
965
+ border-radius: 14px;
966
+ background: rgba(15, 23, 42, 0.78);
967
+ }
968
+
969
+ .legend-swatch {
970
+ border-color: #32eb84;
971
+ }
972
+
973
+ .garden-hud {
974
+ right: 18px;
975
+ bottom: 18px;
976
+ width: 310px;
977
+ border-radius: 20px;
978
+ background: rgba(255, 255, 255, 0.83);
979
+ color: #121328;
980
+ }
981
+
982
+ .garden-hud-label,
983
+ .garden-stats {
984
+ color: #5f6678;
985
+ }
986
+
987
+ #garden-growth-stage {
988
+ color: var(--wc-green);
989
+ }
990
+
991
+ .camera-hint {
992
+ position: absolute;
993
+ z-index: 8;
994
+ left: 50%;
995
+ bottom: 14px;
996
+ width: min(500px, 62%);
997
+ margin: 0;
998
+ padding: 10px 15px;
999
+ border-radius: 12px;
1000
+ color: white;
1001
+ background: rgba(15, 23, 42, 0.76);
1002
+ text-align: center;
1003
+ transform: translateX(-50%);
1004
+ backdrop-filter: blur(10px);
1005
+ }
1006
+
1007
+ .coach-card {
1008
+ min-height: 116px;
1009
+ padding: 16px;
1010
+ flex-direction: row;
1011
+ justify-content: flex-start;
1012
+ text-align: left;
1013
+ border: 1px solid rgba(34, 197, 94, 0.18);
1014
+ background: #eafff0;
1015
+ }
1016
+
1017
+ .coach-card[data-state="red"] {
1018
+ background: #fff1ed;
1019
+ }
1020
+
1021
+ .coach-card[data-state="green"] {
1022
+ background: #eafff0;
1023
+ }
1024
+
1025
+ .coach-face {
1026
+ width: 42px;
1027
+ height: 40px;
1028
+ border-width: 2px;
1029
+ border-radius: 16px;
1030
+ background: #d8ffe4;
1031
+ box-shadow: none;
1032
+ }
1033
+
1034
+ .coach-eye {
1035
+ top: 14px;
1036
+ }
1037
+
1038
+ .coach-eye.left {
1039
+ left: 10px;
1040
+ }
1041
+
1042
+ .coach-eye.right {
1043
+ right: 10px;
1044
+ }
1045
+
1046
+ .coach-mouth {
1047
+ bottom: 7px;
1048
+ }
1049
+
1050
+ #coach-message {
1051
+ font-size: 0.94rem;
1052
+ line-height: 1.45;
1053
+ }
1054
+
1055
+ .mini-guide {
1056
+ margin-top: 0;
1057
+ border-color: rgba(226, 232, 240, 0.9);
1058
+ background: rgba(255, 255, 255, 0.82);
1059
+ }
1060
+
1061
+ .mini-guide li {
1062
+ padding: 8px 9px;
1063
+ border-radius: 11px;
1064
+ background: #f8fafc;
1065
+ }
1066
+
1067
+ .mini-guide li span {
1068
+ background: #dffbea;
1069
+ color: #138a5a;
1070
+ }
1071
+
1072
+ .progress-card {
1073
+ display: flex;
1074
+ align-items: center;
1075
+ gap: 14px;
1076
+ padding: 15px;
1077
+ border: 1px solid rgba(226, 232, 240, 0.9);
1078
+ border-radius: 18px;
1079
+ background: rgba(255, 255, 255, 0.82);
1080
+ }
1081
+
1082
+ .progress-ring {
1083
+ width: 70px;
1084
+ height: 70px;
1085
+ display: grid;
1086
+ place-items: center;
1087
+ flex: 0 0 auto;
1088
+ border: 8px solid #e6e8ff;
1089
+ border-top-color: #5b50ff;
1090
+ border-radius: 50%;
1091
+ }
1092
+
1093
+ .progress-ring strong {
1094
+ color: #4f46e5;
1095
+ font-size: 1rem;
1096
+ font-weight: 950;
1097
+ }
1098
+
1099
+ .progress-card p:last-child {
1100
+ margin: 3px 0 0;
1101
+ color: var(--wc-muted);
1102
+ font-size: 0.73rem;
1103
+ line-height: 1.45;
1104
+ }
1105
+
1106
+ .melody-card,
1107
+ .state-card,
1108
+ .trace-card {
1109
+ padding: 15px;
1110
+ border: 1px solid rgba(226, 232, 240, 0.9);
1111
+ border-radius: 18px;
1112
+ background: rgba(255, 255, 255, 0.82);
1113
+ }
1114
+
1115
+ .melody-card p:nth-child(2),
1116
+ .state-card code {
1117
+ margin: 3px 0 0;
1118
+ color: var(--wc-muted);
1119
+ font-size: 0.72rem;
1120
+ line-height: 1.45;
1121
+ }
1122
+
1123
+ .melody-download {
1124
+ display: inline-flex;
1125
+ margin-top: 10px;
1126
+ padding: 9px 12px;
1127
+ border-radius: 999px;
1128
+ color: white;
1129
+ background: linear-gradient(135deg, #554bff, #6b5cff);
1130
+ font-size: 0.75rem;
1131
+ font-weight: 900;
1132
+ text-decoration: none;
1133
+ }
1134
+
1135
+ .state-card code {
1136
+ display: block;
1137
+ white-space: pre-wrap;
1138
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1139
+ }
1140
+
1141
+ .trace-card ol {
1142
+ display: grid;
1143
+ gap: 9px;
1144
+ margin: 10px 0 0;
1145
+ padding: 0;
1146
+ list-style: none;
1147
+ }
1148
+
1149
+ .trace-card li {
1150
+ display: grid;
1151
+ gap: 3px;
1152
+ padding: 10px 11px;
1153
+ border: 1px solid rgba(148, 163, 184, 0.2);
1154
+ border-radius: 14px;
1155
+ background: rgba(248, 250, 252, 0.86);
1156
+ }
1157
+
1158
+ .trace-card strong {
1159
+ color: #334155;
1160
+ font-size: 0.72rem;
1161
+ font-weight: 950;
1162
+ text-transform: uppercase;
1163
+ }
1164
+
1165
+ .trace-card span {
1166
+ color: var(--wc-muted);
1167
+ font-size: 0.72rem;
1168
+ line-height: 1.35;
1169
+ }
1170
+
1171
+ .signal-sidebar {
1172
+ display: flex;
1173
+ flex-direction: column;
1174
+ gap: 14px;
1175
+ }
1176
+
1177
+ .indicator-stack {
1178
+ gap: 14px;
1179
+ }
1180
+
1181
+ .indicator-card {
1182
+ padding: 18px;
1183
+ border: 1px solid rgba(226, 232, 240, 0.86);
1184
+ border-radius: 22px;
1185
+ background: rgba(255, 255, 255, 0.78);
1186
+ box-shadow: 0 18px 44px rgba(15, 23, 42, 0.1);
1187
+ backdrop-filter: blur(12px);
1188
+ }
1189
+
1190
+ .indicator-name {
1191
+ font-size: 0.95rem;
1192
+ }
1193
+
1194
+ .indicator-score {
1195
+ font-size: 1.5rem;
1196
+ }
1197
+
1198
+ .score-track {
1199
+ height: 8px;
1200
+ margin: 14px 0 10px;
1201
+ background: linear-gradient(90deg, #2bd873, #ffe45e, #ff444a);
1202
+ }
1203
+
1204
+ .score-fill {
1205
+ background: rgba(255, 255, 255, 0.82);
1206
+ box-shadow: -999px 0 0 999px var(--indicator);
1207
+ }
1208
+
1209
+ .signal-note {
1210
+ margin-top: 0;
1211
+ background: rgba(255, 255, 255, 0.82);
1212
+ }
1213
+
1214
+ .bottom-controls {
1215
+ width: min(1120px, 100%);
1216
+ margin: 20px auto 0;
1217
+ display: grid;
1218
+ grid-template-columns: 1fr 1fr 0.65fr;
1219
+ gap: 18px;
1220
+ padding: 0;
1221
+ border: 0;
1222
+ background: transparent;
1223
+ box-shadow: none;
1224
+ }
1225
+
1226
+ .bottom-controls .control-button {
1227
+ min-height: 56px;
1228
+ border-radius: 17px;
1229
+ font-size: 1rem;
1230
+ }
1231
+
1232
+ .bottom-controls .primary {
1233
+ background: linear-gradient(135deg, #554bff, #6b5cff);
1234
+ }
1235
+
1236
+ .bottom-controls .secondary {
1237
+ color: white;
1238
+ background: linear-gradient(135deg, #ff4048, #ff3038);
1239
+ }
1240
+
1241
+ .bottom-controls .ghost {
1242
+ color: #33384f;
1243
+ background: white;
1244
+ box-shadow: 0 14px 34px rgba(15, 23, 42, 0.08);
1245
+ }
1246
+
1247
+ /* Game-like UI pass inspired by the provided reference. */
1248
+ .whistle-app {
1249
+ background:
1250
+ radial-gradient(circle at 50% 0%, rgba(107, 92, 255, 0.08), transparent 34rem),
1251
+ #fbfaf8;
1252
+ }
1253
+
1254
+ .coach-workspace {
1255
+ grid-template-columns: 270px minmax(1040px, 1fr);
1256
+ }
1257
+
1258
+ .camera-stage {
1259
+ border: 7px solid rgba(255, 255, 255, 0.92);
1260
+ border-radius: 30px;
1261
+ background: #111;
1262
+ box-shadow:
1263
+ 0 30px 72px rgba(21, 24, 40, 0.16),
1264
+ 0 0 0 1px rgba(20, 25, 38, 0.08);
1265
+ }
1266
+
1267
+ .camera-stage[data-feedback="yellow"],
1268
+ .camera-stage[data-feedback="green"] {
1269
+ box-shadow:
1270
+ 0 30px 72px rgba(21, 24, 40, 0.16),
1271
+ 0 0 0 1px rgba(20, 25, 38, 0.08);
1272
+ }
1273
+
1274
+ #practice-video {
1275
+ filter: grayscale(1) contrast(1.12) brightness(0.88);
1276
+ transform: scaleX(-1);
1277
+ }
1278
+
1279
+ #mouth-overlay,
1280
+ #garden-canvas {
1281
+ mix-blend-mode: normal;
1282
+ }
1283
+
1284
+ .target-legend {
1285
+ bottom: auto;
1286
+ top: 17px;
1287
+ left: auto;
1288
+ right: 18px;
1289
+ width: auto;
1290
+ min-width: 150px;
1291
+ justify-content: center;
1292
+ color: white;
1293
+ background: rgba(14, 18, 26, 0.72);
1294
+ }
1295
+
1296
+ .garden-hud,
1297
+ .compact-signals,
1298
+ .state-card {
1299
+ display: none;
1300
+ }
1301
+
1302
+ .camera-hint {
1303
+ bottom: 138px;
1304
+ width: min(520px, 58%);
1305
+ background: rgba(15, 17, 24, 0.72);
1306
+ }
1307
+
1308
+ .live-pill {
1309
+ background: rgba(255, 255, 255, 0.86);
1310
+ color: #111827;
1311
+ }
1312
+
1313
+ .live-pill .session-dot {
1314
+ background: #4ade80;
1315
+ }
1316
+
1317
+ .action-sidebar {
1318
+ display: flex;
1319
+ flex-direction: column;
1320
+ gap: 18px;
1321
+ }
1322
+
1323
+ .action-stack {
1324
+ display: grid;
1325
+ gap: 18px;
1326
+ }
1327
+
1328
+ .melody-action {
1329
+ min-height: 122px;
1330
+ display: grid;
1331
+ grid-template-columns: 62px 1fr;
1332
+ align-items: center;
1333
+ gap: 18px;
1334
+ padding: 22px;
1335
+ border: 1px solid rgba(226, 232, 240, 0.9);
1336
+ border-radius: 24px;
1337
+ color: #182039;
1338
+ background: rgba(255, 255, 255, 0.92);
1339
+ box-shadow: 0 18px 48px rgba(20, 24, 40, 0.08);
1340
+ text-align: left;
1341
+ text-decoration: none;
1342
+ font: inherit;
1343
+ }
1344
+
1345
+ .melody-action strong {
1346
+ font-size: 1.05rem;
1347
+ font-weight: 900;
1348
+ }
1349
+
1350
+ .melody-action small,
1351
+ .melody-action select {
1352
+ grid-column: 2;
1353
+ color: #71798b;
1354
+ font-size: 0.73rem;
1355
+ }
1356
+
1357
+ .melody-action select {
1358
+ width: 100%;
1359
+ margin-top: 8px;
1360
+ padding: 8px 10px;
1361
+ border: 1px solid #e5e7eb;
1362
+ border-radius: 12px;
1363
+ background: #f8fafc;
1364
+ font-weight: 800;
1365
+ }
1366
+
1367
+ .action-icon {
1368
+ width: 62px;
1369
+ height: 62px;
1370
+ display: grid;
1371
+ place-items: center;
1372
+ border-radius: 50%;
1373
+ font-size: 1.7rem;
1374
+ font-weight: 950;
1375
+ }
1376
+
1377
+ .record-action .action-icon {
1378
+ color: #ff4052;
1379
+ background: #ffe4e8;
1380
+ box-shadow: inset 0 0 0 1px rgba(255, 64, 82, 0.25);
1381
+ }
1382
+
1383
+ .style-action .action-icon {
1384
+ color: #9656ff;
1385
+ background: #efe5ff;
1386
+ }
1387
+
1388
+ .generating-action .action-icon {
1389
+ color: #615dff;
1390
+ background: #e8e8ff;
1391
+ }
1392
+
1393
+ .generating-action[data-active="true"] .action-icon {
1394
+ animation: wc-pulse 0.8s infinite;
1395
+ }
1396
+
1397
+ .generating-action[data-active="done"] .action-icon,
1398
+ .play-action .action-icon {
1399
+ color: #20b970;
1400
+ background: #ddfaeb;
1401
+ }
1402
+
1403
+ .download-action .action-icon {
1404
+ color: #228be6;
1405
+ background: #e1f1ff;
1406
+ }
1407
+
1408
+ .melody-action:disabled,
1409
+ .download-action[aria-disabled="true"] {
1410
+ opacity: 0.58;
1411
+ cursor: not-allowed;
1412
+ }
1413
+
1414
+ .download-action.ready {
1415
+ opacity: 1;
1416
+ }
1417
+
1418
+ .signal-note {
1419
+ display: none;
1420
+ }
1421
+
1422
+ /* Mobile and compact pass: keep the game view usable on phones. */
1423
+ .whistle-app {
1424
+ min-width: 0;
1425
+ font-family:
1426
+ "Avenir Next", Avenir, "DIN Alternate", "Arial Rounded MT Bold",
1427
+ "Helvetica Neue", Arial, system-ui, sans-serif;
1428
+ letter-spacing: 0.005em;
1429
+ }
1430
+
1431
+ .app-header h1,
1432
+ .melody-action strong,
1433
+ .coach-card,
1434
+ .mini-guide,
1435
+ .control-button {
1436
+ letter-spacing: 0.01em;
1437
+ }
1438
+
1439
+ @media (max-width: 980px) {
1440
+ body,
1441
+ .gradio-container {
1442
+ overflow-x: hidden !important;
1443
+ }
1444
+
1445
+ .whistle-app {
1446
+ width: 100%;
1447
+ min-width: 0;
1448
+ padding: 12px;
1449
+ }
1450
+
1451
+ .app-header {
1452
+ align-items: flex-start;
1453
+ flex-direction: column;
1454
+ gap: 12px;
1455
+ margin-bottom: 12px;
1456
+ }
1457
+
1458
+ .brand-lockup {
1459
+ gap: 11px;
1460
+ }
1461
+
1462
+ .brand-mark {
1463
+ width: 46px;
1464
+ height: 46px;
1465
+ border-radius: 15px;
1466
+ }
1467
+
1468
+ .brand-mark svg {
1469
+ width: 31px;
1470
+ height: 31px;
1471
+ }
1472
+
1473
+ .app-header h1 {
1474
+ font-size: 1.75rem;
1475
+ letter-spacing: -0.035em;
1476
+ }
1477
+
1478
+ .subtitle {
1479
+ margin-top: 4px;
1480
+ font-size: 0.84rem;
1481
+ }
1482
+
1483
+ .header-badges {
1484
+ width: 100%;
1485
+ justify-content: flex-start;
1486
+ gap: 8px;
1487
+ overflow-x: auto;
1488
+ padding-bottom: 2px;
1489
+ }
1490
+
1491
+ .tech-badge,
1492
+ .about-badge {
1493
+ min-height: 34px;
1494
+ flex: 0 0 auto;
1495
+ padding: 0 12px;
1496
+ border-radius: 999px;
1497
+ font-size: 0.68rem;
1498
+ white-space: nowrap;
1499
+ }
1500
+
1501
+ .coach-workspace {
1502
+ width: 100%;
1503
+ min-width: 0;
1504
+ display: grid;
1505
+ grid-template-columns: minmax(0, 1fr);
1506
+ gap: 12px;
1507
+ }
1508
+
1509
+ .camera-card {
1510
+ order: 1;
1511
+ min-width: 0;
1512
+ }
1513
+
1514
+ .coach-sidebar {
1515
+ order: 2;
1516
+ min-width: 0;
1517
+ padding: 12px;
1518
+ border-radius: 20px;
1519
+ }
1520
+
1521
+ .action-sidebar {
1522
+ order: 3;
1523
+ min-width: 0;
1524
+ }
1525
+
1526
+ .camera-stage {
1527
+ width: 100%;
1528
+ min-width: 0;
1529
+ aspect-ratio: 9 / 12.8;
1530
+ border-width: 4px;
1531
+ border-radius: 22px;
1532
+ }
1533
+
1534
+ .live-pill,
1535
+ .target-legend {
1536
+ top: 10px;
1537
+ padding: 6px 9px;
1538
+ border-radius: 12px;
1539
+ font-size: 0.62rem;
1540
+ }
1541
+
1542
+ .live-pill {
1543
+ left: 10px;
1544
+ }
1545
+
1546
+ .target-legend {
1547
+ right: 10px;
1548
+ min-width: 0;
1549
+ }
1550
+
1551
+ .camera-hint {
1552
+ bottom: 88px;
1553
+ width: min(86%, 410px);
1554
+ padding: 8px 10px;
1555
+ border-radius: 12px;
1556
+ font-size: 0.62rem;
1557
+ }
1558
+
1559
+ .coach-card {
1560
+ min-height: 96px;
1561
+ padding: 16px;
1562
+ border-radius: 18px;
1563
+ }
1564
+
1565
+ #coach-message {
1566
+ font-size: 0.84rem;
1567
+ }
1568
+
1569
+ .mini-guide ol {
1570
+ grid-template-columns: 1fr 1fr;
1571
+ gap: 8px;
1572
+ }
1573
+
1574
+ .progress-card,
1575
+ .melody-card {
1576
+ padding: 14px;
1577
+ border-radius: 18px;
1578
+ }
1579
+
1580
+ .action-stack {
1581
+ display: grid;
1582
+ grid-template-columns: repeat(5, minmax(86px, 1fr));
1583
+ gap: 10px;
1584
+ overflow-x: auto;
1585
+ padding-bottom: 2px;
1586
+ }
1587
+
1588
+ .melody-action {
1589
+ min-height: 88px;
1590
+ grid-template-columns: 1fr;
1591
+ justify-items: center;
1592
+ gap: 7px;
1593
+ padding: 12px 10px;
1594
+ border-radius: 18px;
1595
+ text-align: center;
1596
+ }
1597
+
1598
+ .action-icon {
1599
+ width: 42px;
1600
+ height: 42px;
1601
+ font-size: 1.1rem;
1602
+ }
1603
+
1604
+ .melody-action strong {
1605
+ font-size: 0.72rem;
1606
+ }
1607
+
1608
+ .melody-action small,
1609
+ .melody-action select {
1610
+ grid-column: 1;
1611
+ margin-top: 0;
1612
+ font-size: 0.58rem;
1613
+ }
1614
+
1615
+ .bottom-controls {
1616
+ position: sticky;
1617
+ bottom: 8px;
1618
+ z-index: 20;
1619
+ width: 100%;
1620
+ grid-template-columns: 1fr 1fr 0.85fr;
1621
+ gap: 8px;
1622
+ margin-top: 12px;
1623
+ padding: 0;
1624
+ }
1625
+
1626
+ .bottom-controls .control-button {
1627
+ min-height: 44px;
1628
+ border-radius: 14px;
1629
+ font-size: 0.78rem;
1630
+ }
1631
+
1632
+ .technical-status,
1633
+ .app-footer {
1634
+ display: none;
1635
+ }
1636
+ }
1637
+
1638
+ @media (max-width: 560px) {
1639
+ .whistle-app {
1640
+ padding: 10px;
1641
+ }
1642
+
1643
+ .camera-stage {
1644
+ aspect-ratio: 9 / 14;
1645
+ }
1646
+
1647
+ .coach-face {
1648
+ display: none;
1649
+ }
1650
+
1651
+ .coach-card {
1652
+ min-height: auto;
1653
+ }
1654
+
1655
+ .mini-guide {
1656
+ display: none;
1657
+ }
1658
+
1659
+ .action-stack {
1660
+ grid-template-columns: repeat(5, minmax(78px, 1fr));
1661
+ }
1662
+
1663
+ .melody-action {
1664
+ min-height: 82px;
1665
+ padding: 10px 8px;
1666
+ }
1667
+ }
training/README.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nemotron Coach Policy Fine-Tuning
2
+
3
+ Whistle Coach fine-tunes a small Nemotron policy model on structured practice
4
+ states instead of raw camera or microphone data. The model learns this narrow
5
+ mapping:
6
+
7
+ ```text
8
+ practice_state history -> coach policy JSON
9
+ ```
10
+
11
+ The expected output schema is:
12
+
13
+ ```json
14
+ {
15
+ "coach_message": "one actionable sentence",
16
+ "diagnosis": "short blocker diagnosis",
17
+ "overlay_cues": [{"type": "lip_corners", "direction": "inward"}],
18
+ "active_step": "round_lips",
19
+ "agent_trace": [
20
+ {"step": "Observe", "action": "read_state", "detail": "..."},
21
+ {"step": "Diagnose", "action": "not_rounded", "detail": "..."},
22
+ {"step": "Plan", "action": "lip_rounding", "detail": "..."},
23
+ {"step": "Act", "action": "coach_message", "detail": "..."}
24
+ ],
25
+ "next_drill": "lip_rounding",
26
+ "difficulty_adjustment": "hold",
27
+ "success_trigger": false
28
+ }
29
+ ```
30
+
31
+ ## Recommended model
32
+
33
+ - `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` for the tiny coach policy.
34
+ - `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16` as an optional teacher model for
35
+ generating additional high-quality labels.
36
+
37
+ Both are under the Build Small 32B parameter cap. The 4B model is the intended
38
+ fine-tuned runtime policy.
39
+
40
+ ## Local/Modal training shape
41
+
42
+ The Space itself does not install training dependencies. Run fine-tuning in a
43
+ separate environment with:
44
+
45
+ ```bash
46
+ pip install transformers datasets peft trl accelerate bitsandbytes
47
+ python training/finetune_nemotron_policy.py
48
+ ```
49
+
50
+ The script reads `data/coach_policy_sft.jsonl` and writes LoRA adapters to
51
+ `outputs/nemotron-whistle-coach-lora/`.
52
+
53
+ ## Privacy
54
+
55
+ The dataset stores derived numeric features and coach decisions only. It does
56
+ not store webcam frames, face images, or microphone audio.
training/finetune_nemotron_policy.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoRA fine-tuning scaffold for the Whistle Coach policy model.
2
+
3
+ Run this outside the CPU Space, for example on a local GPU box or Modal.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from datasets import load_dataset
12
+ from peft import LoraConfig
13
+ from transformers import AutoModelForCausalLM, AutoTokenizer
14
+ from trl import SFTConfig, SFTTrainer
15
+
16
+
17
+ BASE_MODEL = os.getenv("BASE_MODEL", "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
18
+ DATA_PATH = Path(os.getenv("DATA_PATH", "data/coach_policy_sft.jsonl"))
19
+ OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "outputs/nemotron-whistle-coach-lora"))
20
+
21
+
22
+ def format_example(example: dict) -> str:
23
+ return (
24
+ "<|user|>\n"
25
+ f"{example['input']}\n"
26
+ "<|assistant|>\n"
27
+ f"{example['output']}"
28
+ )
29
+
30
+
31
+ def main() -> None:
32
+ dataset = load_dataset("json", data_files=str(DATA_PATH), split="train")
33
+ dataset = dataset.map(lambda row: {"text": format_example(row)})
34
+
35
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
36
+ if tokenizer.pad_token is None:
37
+ tokenizer.pad_token = tokenizer.eos_token
38
+
39
+ model = AutoModelForCausalLM.from_pretrained(
40
+ BASE_MODEL,
41
+ device_map="auto",
42
+ trust_remote_code=True,
43
+ )
44
+
45
+ peft_config = LoraConfig(
46
+ r=16,
47
+ lora_alpha=32,
48
+ lora_dropout=0.05,
49
+ bias="none",
50
+ task_type="CAUSAL_LM",
51
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
52
+ )
53
+
54
+ trainer = SFTTrainer(
55
+ model=model,
56
+ tokenizer=tokenizer,
57
+ train_dataset=dataset,
58
+ peft_config=peft_config,
59
+ args=SFTConfig(
60
+ output_dir=str(OUTPUT_DIR),
61
+ dataset_text_field="text",
62
+ max_seq_length=1536,
63
+ per_device_train_batch_size=1,
64
+ gradient_accumulation_steps=8,
65
+ learning_rate=2e-4,
66
+ num_train_epochs=3,
67
+ logging_steps=5,
68
+ save_strategy="epoch",
69
+ report_to="none",
70
+ ),
71
+ )
72
+ trainer.train()
73
+ trainer.save_model(str(OUTPUT_DIR))
74
+ tokenizer.save_pretrained(str(OUTPUT_DIR))
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()