Upload 27 files
Browse files- .gitattributes +36 -35
- .gitignore +14 -0
- README.md +257 -8
- app.py +669 -0
- assets/placeholder.jpg +3 -0
- backend/__init__.py +1 -0
- backend/coach_model.py +156 -0
- backend/melody_generator.py +89 -0
- backend/prompts.py +25 -0
- backend/schemas.py +56 -0
- backend/state_classifier.py +91 -0
- data/coach_policy_sft.jsonl +8 -0
- data/sample_coach_states.jsonl +4 -0
- frontend/audio_features.js +41 -0
- frontend/face_features.js +47 -0
- frontend/index.html +122 -0
- frontend/overlay_renderer.js +86 -0
- frontend/style.css +472 -0
- frontend/whistle_coach.js +0 -0
- notebooks/01_feature_schema_and_state_classifier.ipynb +34 -0
- notebooks/02_build_coach_policy_dataset.ipynb +32 -0
- notebooks/03_whistle_pitch_to_melody.ipynb +32 -0
- outputs/sample_melodies/.gitkeep +1 -0
- requirements.txt +11 -0
- style.css +1667 -0
- training/README.md +56 -0
- training/finetune_nemotron_policy.py +78 -0
.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:
|
| 3 |
-
|
| 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:
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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()
|