kalamishere commited on
Commit
3cc5b15
·
0 Parent(s):

Initial deploy

Browse files
Files changed (22) hide show
  1. .gitignore +26 -0
  2. .hfignore +23 -0
  3. DEPLOY_HF.md +120 -0
  4. README.md +141 -0
  5. app.py +1428 -0
  6. crate.py +158 -0
  7. design/Audio Brief v1 (three directions).dc.html +590 -0
  8. design/Audio Brief v2.dc.html +550 -0
  9. design/README.md +199 -0
  10. design/support.js +1581 -0
  11. docs/local-gen-server-example.py +124 -0
  12. models.py +168 -0
  13. narrative.py +680 -0
  14. outputs.py +403 -0
  15. pipeline.py +460 -0
  16. requirements.txt +41 -0
  17. sa3.py +147 -0
  18. sa3_roundtrip_test.py +312 -0
  19. share.py +348 -0
  20. theme.py +829 -0
  21. wallet.py +281 -0
  22. waveform.py +47 -0
.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # audio-brief — exclusions for both git and HF Spaces (.hfignore mirrors)
2
+ .venv/
3
+ venv/
4
+ env/
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ .DS_Store
9
+
10
+ # Local crate store — per-session generated audio
11
+ crate/
12
+ outputs/
13
+
14
+ # Local wallet (desktop only — never present on HF Spaces)
15
+ *.pollinations.json
16
+
17
+ # Editor / IDE
18
+ .idea/
19
+ .vscode/
20
+ *.swp
21
+
22
+ # Local handoff docs (kept in private repo, not pushed to HF Space)
23
+ HANDOFF_*.md
24
+
25
+ # Round-trip test artifacts
26
+ sa3_roundtrip_*/
.hfignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mirror of .gitignore — Hugging Face's huggingface_hub respects this when
2
+ # pushing to a Space. Same exclusions: dev venv, crate store, wallets,
3
+ # editor cruft, internal handoff docs.
4
+ .venv/
5
+ venv/
6
+ env/
7
+ __pycache__/
8
+ *.pyc
9
+ *.pyo
10
+ .DS_Store
11
+
12
+ crate/
13
+ outputs/
14
+
15
+ *.pollinations.json
16
+
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+
21
+ HANDOFF_*.md
22
+
23
+ sa3_roundtrip_*/
DEPLOY_HF.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy to Hugging Face Spaces
2
+
3
+ The Space directory **is** the `audio-brief/` folder. Push that subtree to a
4
+ new Gradio Space and you're live. Below is the cleanest path; nothing here
5
+ needs root or special HF privileges, just a free account at huggingface.co.
6
+
7
+ ## 1. Create the Space
8
+
9
+ Visit <https://huggingface.co/new-space> →
10
+
11
+ | field | value |
12
+ |---|---|
13
+ | Owner | your HF account |
14
+ | Space name | `audio-brief` (or whatever) |
15
+ | License | MIT |
16
+ | Select the Space SDK | **Gradio** |
17
+ | Space hardware | **CPU basic — Free** (16 GB RAM, 2 vCPU) |
18
+ | Public/private | your call (public lets others try it without authing) |
19
+
20
+ After creation HF shows the git remote URL — keep it handy:
21
+ `https://huggingface.co/spaces/<owner>/audio-brief.git`
22
+
23
+ ## 2. Push from this directory
24
+
25
+ The `README.md` already has the required YAML front-matter (`title`,
26
+ `sdk: gradio`, `sdk_version: 6.19.0`, `app_file: app.py`). The `.hfignore`
27
+ keeps `.venv/`, `__pycache__/`, the local crate store, and internal
28
+ handoff docs out of the push.
29
+
30
+ ```bash
31
+ cd /Users/kalam/ableton-v1/audio-brief
32
+
33
+ # One-time: initialise an HF-only git remote in this subfolder
34
+ git init -b main
35
+ git remote add hf https://huggingface.co/spaces/<owner>/audio-brief
36
+
37
+ # Stage everything except .gitignore exclusions
38
+ git add .
39
+ git commit -m "Initial deploy"
40
+ git push hf main
41
+ ```
42
+
43
+ If you'd rather use `huggingface_hub`'s uploader (skips the local-git step):
44
+
45
+ ```bash
46
+ pip install huggingface_hub
47
+ huggingface-cli login
48
+ huggingface-cli upload spaces/<owner>/audio-brief . \
49
+ --repo-type=space \
50
+ --commit-message="Initial deploy"
51
+ ```
52
+
53
+ The `huggingface-cli upload` command respects `.hfignore`.
54
+
55
+ ## 3. Wait for the first build (~8–12 min cold)
56
+
57
+ HF builds the image from `requirements.txt`. The heavy installs are
58
+ `torch`, `torchcodec`, `demucs`, `basic-pitch[onnx]` — together ~2 GB
59
+ of binary deps. On free CPU hardware the cold install takes 8–12 min.
60
+ Subsequent rebuilds reuse the layer cache and are seconds.
61
+
62
+ Watch the build log on the Space's web page; the first time you'll see:
63
+
64
+ - `pip install` of every line in requirements.txt
65
+ - demucs warm-up on first analysis call (models download to ~/.cache)
66
+
67
+ ## 4. Pollinations OAuth callback
68
+
69
+ The wallet pill kicks off an OAuth redirect to
70
+ `enter.pollinations.ai/authorize?redirect_url=…` and Pollinations bounces
71
+ back with `#api_key=sk_…` in the fragment. **The redirect URL is computed
72
+ client-side from `window.location` — it just works on `<owner>-<space>.hf.space`,
73
+ no extra config.**
74
+
75
+ If you'd like attribution on the Pollinations consent screen ("audio·brief is
76
+ asking to use your wallet"), grab a publishable `pk_…` key from
77
+ `enter.pollinations.ai/apps` and set it as a Space Secret:
78
+
79
+ - Space settings → **Variables and secrets** → New secret
80
+ - Key: `ABV1_POLLINATIONS_APP_KEY`
81
+ - Value: `pk_…`
82
+
83
+ ## 5. First-run smoke test
84
+
85
+ Once the Space shows "Running":
86
+
87
+ 1. Open the Space URL.
88
+ 2. Click the coral **pollen pill** top-right → OAuth flow → returns connected.
89
+ 3. Type `dnb, dark atmospheric, 174 BPM, sub-heavy` → pick **Cue · 15s** →
90
+ **Generate**. You should hear a clip within ~30 s and see it appear in
91
+ the crate strip below.
92
+ 4. Click a tile → **Use for analysis →** → switch to **Analysis** tab.
93
+ The waveform should render and the 6-up metric grid should populate.
94
+ 5. Edit the derived prompt if you want, then **Regenerate · 5 variants**.
95
+ Five variants stream in sequentially; the session-spend pill in the top
96
+ bar should reach `~1.04 ◆ session` (one initial gen + five variants).
97
+
98
+ If any of those steps fails, check the Space's build log AND the runtime
99
+ log (Space → Logs). The pipeline degrades gracefully — if demucs or
100
+ basic-pitch fails on the Linux image, the brief still ships with the
101
+ librosa-only stages, just without bass-MIDI or stem separation.
102
+
103
+ ## 6. Known caveats
104
+
105
+ - **Multi-user `/tmp` crate**: `crate.py` writes to `/tmp/audio-brief-crate/`,
106
+ which on Spaces is **shared between visitors** during the container's
107
+ lifetime. Generated audio is not private. For an MVP this is acceptable
108
+ noise; v2 should namespace by session.
109
+ - **Session wallet only**: keys never persist to disk on Spaces — they live
110
+ in `gr.State`, scoped per browser session. Refresh = reconnect.
111
+ - **Cold start**: a Space that's been idle for hours takes ~30 s to wake;
112
+ the first request after wake-up will be slow.
113
+ - **Build timeout**: HF's hard cap is 1 hour for free CPU; we're well
114
+ under. If you ever swap to a base image with CUDA, expect longer builds.
115
+
116
+ ## 7. Iterating
117
+
118
+ After the first deploy, edits are a `git push hf main` (or `huggingface-cli
119
+ upload .`) away. HF rebuilds only when `requirements.txt` changes; pure code
120
+ edits hot-restart the Gradio process in seconds.
README.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: audio-brief
3
+ emoji: 🎚️
4
+ colorFrom: red
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 6.19.0
8
+ python_version: "3.11"
9
+ app_file: app.py
10
+ pinned: false
11
+ license: mit
12
+ short_description: Recreate audio consistently with AI — SA3 + measured grounding.
13
+ ---
14
+
15
+ # audio·brief
16
+
17
+ **A workbench for recreating audio consistently with AI.** Generate a take with
18
+ Stable Audio 3, get a *measured + LLM* read of what makes it tick (BPM, key,
19
+ sections, loudness), then re-prompt for 5 variants that feel like cousins of
20
+ the source.
21
+
22
+ The wedge: every brief is grounded in **real measured numbers** — not just
23
+ "what the model heard". The variant generations stay close to the source
24
+ because the prompt is rebuilt from those numbers + the original intent.
25
+
26
+ ## How it works
27
+
28
+ 1. **Generate** — type a prompt; SA3 returns a clip. (Or upload your own.)
29
+ 2. **Analyse** — `librosa` + `pyloudnorm` + `demucs` produce BPM, key, sections,
30
+ LUFS-I / LRA / true-peak, plus an LLM brief.
31
+ 3. **Regenerate** — the derived prompt blends your intent with the measured arc;
32
+ five SA3 variants stream in.
33
+ 4. **Compare** (optional) — pit two measured-grounded models against one
34
+ audio-only model (gemini) on the same brief to see what grounding gives you.
35
+
36
+ ## Connect Pollinations (BYOP — Bring Your Own Pollen)
37
+
38
+ This Space calls Pollinations for both SA3 generation and the narrative LLM.
39
+ **Click the pollen pill in the top-right** to OAuth-connect your Pollinations
40
+ wallet — you keep control of your balance and model allowlist on the consent
41
+ screen; this Space never sees your spend.
42
+
43
+ - SA3 cost: ~0.04 ◆ per call (flat across durations).
44
+ - Regenerate · 5 variants: ~0.20 ◆.
45
+ - Disconnect via the pill at any time.
46
+
47
+ Per-session keys are stored **in memory only** when running on Hugging Face
48
+ Spaces (multi-user safe — `SPACE_ID` env var triggers the no-disk path).
49
+
50
+ ## Local development
51
+
52
+ Requires Python 3.11.
53
+
54
+ ```bash
55
+ cd audio-brief
56
+ python3.11 -m venv .venv
57
+ source .venv/bin/activate
58
+ pip install -r requirements.txt
59
+ python app.py
60
+ ```
61
+
62
+ Opens at http://localhost:7860.
63
+
64
+ ## Local gen server (advanced, power users)
65
+
66
+ If you have a local audio-gen model (MLX SA3, MusicGen, AceStep, etc.) on your
67
+ machine, expose it as `POST /generate` returning audio bytes and pick "Local
68
+ server" from the Model dropdown. The browser fetches your localhost directly;
69
+ your local model never leaves your machine. See
70
+ [`docs/local-gen-server-example.py`](docs/local-gen-server-example.py) for a
71
+ 40-line FastAPI reference.
72
+
73
+ CORS: your server must allow this origin (or `*` during dev).
74
+
75
+ ## Files
76
+
77
+ ```
78
+ app.py Gradio UI — entrypoint
79
+ pipeline.py 9-stage analysis pipeline (librosa + demucs + …)
80
+ sa3.py Pollinations Stable Audio 3 wrapper
81
+ wallet.py Pollinations OAuth wallet (session-only on Spaces)
82
+ narrative.py LLM narrative + compare via Pollinations text API
83
+ crate.py Per-tile crate store (/tmp/audio-brief-crate)
84
+ outputs.py SA3 prompt builders + Ableton clip-plan formatters
85
+ waveform.py Waveform PNG with section markers
86
+ theme.py Design tokens + custom CSS
87
+ docs/ Local-gen server reference
88
+ design/ Design handoff (Claude Design v2 — Direction B refined)
89
+ ```
90
+
91
+ ## What's in / what's out (v1)
92
+
93
+ **In**
94
+ - librosa BPM with `start_bpm` prior (mandatory for dnb-tempo material)
95
+ - librosa key detection (Krumhansl-Schmuckler over chroma)
96
+ - librosa section boundaries (agglomerative on chroma)
97
+ - pyloudnorm LUFS-I / LRA / true-peak
98
+ - demucs `htdemucs` stem split (subprocess — RAM-isolated)
99
+ - basic-pitch on bass.wav (subprocess — RAM-isolated)
100
+ - pollinations narrative + SA3 gen
101
+
102
+ **Out — deferred to v2**
103
+ - essentia tagging (graceful skip if not installed)
104
+ - LAION-CLAP similarity embedding (graceful skip if not installed)
105
+ - Ableton MCP wiring — v1 emits clip-plan JSON; v2 adds the sender
106
+ - Batch upload
107
+ - Section-coloured waveform overlays + intent/measured token tagging in
108
+ the derived prompt — designed (see `design/README.md`) but needs the
109
+ Path B (Docker + React) rewrite
110
+
111
+ ## Env-var overrides
112
+
113
+ For CI / headless / per-run overrides — wallet UI is the primary path.
114
+
115
+ | var | default | notes |
116
+ |---|---|---|
117
+ | `POLLINATIONS_API_KEY` | wallet file | wins over the wallet (desktop only) |
118
+ | `POLLINATIONS_MODEL` | `claude` | also: `openai`, `gemini-3-flash` — see `GET /v1/models` |
119
+ | `POLLINATIONS_URL` | `https://gen.pollinations.ai/v1/chat/completions` | — |
120
+ | `ABV1_POLLINATIONS_APP_KEY` | — | publishable `pk_…` from enter.pollinations.ai — attributes usage to this app on the consent screen |
121
+
122
+ ## Memory & concurrency
123
+
124
+ Sequential pipeline, never concurrent. Demucs and basic-pitch run as
125
+ subprocesses so their RAM is freed between stages. Peak RSS stays under
126
+ ~4 GB on M3 8GB and well within HF Spaces free tier's 16 GB.
127
+
128
+ ## Validation checklist
129
+
130
+ - [ ] Drop a 30 s dnb wav, get a single page in <90 s, BPM within 1 of ground truth
131
+ - [ ] Three copy outputs paste cleanly into SA3 and Ableton
132
+ - [ ] Generate via Pollinations SA3 returns audio of the requested length
133
+ - [ ] Regenerate · 5 variants streams in sequentially, each lands in the crate
134
+ - [ ] Disconnect via the pollen pill clears the session key
135
+ - [ ] On HF Spaces, session keys never touch disk (verify with `SPACE_ID` set)
136
+
137
+ ## Inspiration
138
+
139
+ [ReferenceMix](https://referencemix.vercel.app) by Dani Ever Hadani — same
140
+ shape but text-only input. audio·brief grounds in real audio measurements
141
+ instead of "what the model heard".
app.py ADDED
@@ -0,0 +1,1428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """audio-brief MVP v1 — Gradio UI.
2
+
3
+ Run: python app.py
4
+
5
+ Drop a file → 40-60 s later you get a one-page brief: a client-facing
6
+ paragraph, the structured data, and three copy-paste outputs for the next
7
+ workflow step (SA3 variation, SA3 match-style, Ableton clip plan).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ import traceback
14
+ from pathlib import Path
15
+
16
+ import gradio as gr
17
+
18
+ import outputs
19
+ import share
20
+ import waveform
21
+ import wallet
22
+ import sa3
23
+ import crate
24
+ from narrative import write_brief, write_mix_chain, write_audio_only, write_sa3_prompt_blended
25
+ from pipeline import BPM_PRIORS, Analysis, analyze, to_json
26
+ from theme import (
27
+ THEME, CUSTOM_CSS,
28
+ brand_html, pollen_pill_html, metric_grid_html,
29
+ session_spend_pill_html, cost_pip_html,
30
+ )
31
+
32
+
33
+ GENRE_CHOICES = [
34
+ "default", "dnb", "jungle", "trap", "hip-hop", "house",
35
+ "deep-house", "techno", "ambient", "downtempo", "pop", "rock",
36
+ ]
37
+
38
+ # Model dropdowns are populated at startup from Pollinations' /v1/models
39
+ # catalog (see models.py), filtered by modality. A/B get text→text models;
40
+ # C gets audio-input multimodals only. Falls back to a curated list if the
41
+ # catalog fetch fails.
42
+ import models as _models
43
+ TEXT_MODELS = _models.text_models()
44
+ AUDIO_MODEL_CHOICES = _models.audio_model_choices() # [(label, value), ...]
45
+
46
+ DEFAULT_MODEL_A = "claude" if "claude" in TEXT_MODELS else (TEXT_MODELS[0] if TEXT_MODELS else "openai")
47
+ DEFAULT_MODEL_B = "openai-large" if "openai-large" in TEXT_MODELS else (
48
+ "openai" if "openai" in TEXT_MODELS else (TEXT_MODELS[1] if len(TEXT_MODELS) > 1 else "openai")
49
+ )
50
+ DEFAULT_MODEL_C = "gemini" if "gemini" in [v for _, v in AUDIO_MODEL_CHOICES] else "openai-audio"
51
+
52
+
53
+ def _short_caption(a: Analysis) -> str:
54
+ parts = []
55
+ if a.bpm:
56
+ parts.append(f"{int(round(a.bpm))} BPM")
57
+ if a.key and a.key_mode:
58
+ parts.append(f"{a.key} {a.key_mode}")
59
+ if a.tags_mood:
60
+ parts.append(a.tags_mood[0]["label"])
61
+ if a.tags_genre:
62
+ parts.append(a.tags_genre[0]["label"])
63
+ if a.duration_s:
64
+ parts.append(f"{int(round(a.duration_s))}s")
65
+ return " · ".join(parts) if parts else "(empty)"
66
+
67
+
68
+ def _metrics_html(a: Analysis | None) -> str:
69
+ """6-up metric tile grid for the Analysis tab.
70
+ Renders the measured numbers the design highlights: BPM, KEY, LUFS,
71
+ TRUE PK, LRA, LENGTH. Empty placeholders when a stage hasn't filled
72
+ the value yet."""
73
+ if a is None:
74
+ return metric_grid_html([
75
+ ("BPM", "—"), ("KEY", "—"), ("LUFS", "—"),
76
+ ("TRUE PK", "—"), ("LRA", "—"), ("LENGTH", "—"),
77
+ ])
78
+ bpm = "—" if a.bpm is None else f"{a.bpm:.0f}"
79
+ if a.key and a.key_mode:
80
+ key = f"{a.key} {a.key_mode[:3]}"
81
+ elif a.key:
82
+ key = a.key
83
+ else:
84
+ key = "—"
85
+ # LUFS-I and true peak are always negative dB; render with a real minus sign.
86
+ lufs = "—" if a.lufs_i is None else f"−{abs(a.lufs_i):.1f}"
87
+ peak = "—" if a.true_peak_db is None else f"−{abs(a.true_peak_db):.1f}"
88
+ lra = "—" if a.lufs_lra is None else f"{a.lufs_lra:.1f}"
89
+ if a.duration_s:
90
+ secs = int(round(a.duration_s))
91
+ length = f"{secs // 60}:{secs % 60:02d}"
92
+ else:
93
+ length = "—"
94
+ return metric_grid_html([
95
+ ("BPM", bpm), ("KEY", key), ("LUFS", lufs),
96
+ ("TRUE PK", peak), ("LRA", lra), ("LENGTH", length),
97
+ ])
98
+
99
+
100
+ def _data_table(a: Analysis) -> list[list[str]]:
101
+ rows = [
102
+ ["duration", f"{a.duration_s} s"],
103
+ ["bpm", str(a.bpm)],
104
+ ["bpm_prior", str(a.bpm_prior)],
105
+ ["key", f"{a.key} {a.key_mode}".strip()],
106
+ ["key_correlation", str(a.key_correlation)],
107
+ ["lufs_i", str(a.lufs_i)],
108
+ ["lufs_lra", str(a.lufs_lra)],
109
+ ["true_peak_db", str(a.true_peak_db)],
110
+ ["voiceover_present", str(a.voiceover_present)],
111
+ ["sections", str(len(a.sections))],
112
+ ["downbeats", str(len(a.downbeats))],
113
+ ["stems", ", ".join(sorted(a.stems)) or "(none)"],
114
+ ["bass_midi", a.bass_midi_path or "(none)"],
115
+ ]
116
+ return rows
117
+
118
+
119
+ def _sections_table(a: Analysis) -> list[list[str]]:
120
+ return [
121
+ [s["label"], str(s["start"]), str(s["end"]), str(s["length"])]
122
+ for s in a.sections
123
+ ]
124
+
125
+
126
+ def _tags_table(a: Analysis) -> list[list[str]]:
127
+ out: list[list[str]] = []
128
+ for kind, items in (
129
+ ("genre", a.tags_genre),
130
+ ("mood", a.tags_mood),
131
+ ("instrument", a.tags_instrument),
132
+ ):
133
+ for t in items[:5]:
134
+ out.append([kind, t.get("label", "?"), f"{t.get('score', 0):.3f}"])
135
+ return out
136
+
137
+
138
+ def _format_errors(a: Analysis) -> str:
139
+ if not a.errors:
140
+ return "no errors"
141
+ return "\n".join(f"• {e['stage']}: {e['error']}" for e in a.errors)
142
+
143
+
144
+ def _format_timings(a: Analysis) -> str:
145
+ if not a.timings:
146
+ return "no timings"
147
+ total = sum(a.timings.values())
148
+ rows = [f"{k:>14} {v:>6.2f} s" for k, v in a.timings.items()]
149
+ rows.append(f"{'total':>14} {total:>6.2f} s")
150
+ return "\n".join(rows)
151
+
152
+
153
+ def _brief_payload(a: Analysis) -> dict:
154
+ return {
155
+ "bpm": a.bpm,
156
+ "key": f"{a.key} {a.key_mode}" if a.key else None,
157
+ "duration_s": a.duration_s,
158
+ "sections": a.sections,
159
+ "lufs_i": a.lufs_i,
160
+ "lufs_lra": a.lufs_lra,
161
+ "voiceover_present": a.voiceover_present,
162
+ "top_genre": a.top_genre(),
163
+ "top_mood": a.top_mood(),
164
+ "top_instrument": a.top_instrument(),
165
+ "stems_found": sorted(a.stems),
166
+ }
167
+
168
+
169
+ def _chain_payload(a: Analysis) -> dict:
170
+ return {
171
+ **_brief_payload(a),
172
+ "true_peak_db": a.true_peak_db,
173
+ "stem_stats": a.stem_stats,
174
+ }
175
+
176
+
177
+ def run_brief(audio_path: str | None, bpm_mode: str, bpm_prior_choice: str, bpm_prior_num: float, model_choice: str, api_key: str = ""):
178
+ """Streaming generator — yields partial outputs as each stage finishes so
179
+ the UI fills in progressively instead of all-at-once after a long wait.
180
+
181
+ Output tuple (14 slots):
182
+ paragraph, caption, wave, data_tbl, sections_tbl, tags_tbl,
183
+ errors, timings, sa3_var, sa3_match, clip_plan, mix_chain,
184
+ raw_json, state
185
+ """
186
+ # Waveform Image is hidden by default; show it only once we have a real
187
+ # rendered PNG (stages 2+). On error or pre-analysis we keep it hidden.
188
+ wave_hide = gr.update(value=None, visible=False)
189
+
190
+ if not audio_path:
191
+ empty = "(drop a file first)"
192
+ yield (empty, empty, wave_hide, _metrics_html(None), [], [], [], empty, empty, empty, empty, empty, empty, "{}", None)
193
+ return
194
+
195
+ # Stage 1 — announce; clear stale state.
196
+ yield ("_analyzing audio…_", "running pipeline", wave_hide, _metrics_html(None), [], [], [],
197
+ "", "", "(waiting on analysis)", "(waiting on analysis)",
198
+ "(waiting on analysis)", "(waiting on analysis)", "{}", None)
199
+
200
+ prior: float | str
201
+ if bpm_mode and bpm_mode.startswith("Manual") and bpm_prior_num and bpm_prior_num > 0:
202
+ prior = float(bpm_prior_num)
203
+ else:
204
+ prior = bpm_prior_choice or "default"
205
+
206
+ try:
207
+ a = analyze(audio_path, bpm_prior=prior, run_tags=False, run_embedding=False)
208
+ except Exception as e:
209
+ traceback.print_exc()
210
+ err = f"pipeline failed: {type(e).__name__}: {e}"
211
+ yield (err, err, wave_hide, _metrics_html(None), [], [], [], err, err, err, err, err, err, "{}", None)
212
+ return
213
+
214
+ # Stage 2 — analysis done; render every deterministic output. LLM still pending.
215
+ caption = _short_caption(a)
216
+ wave_png = waveform.render(a)
217
+ # Reveal the waveform component now that we have a rendered PNG to show.
218
+ wave_show = gr.update(value=wave_png, visible=True)
219
+ sa3_var_partial = outputs.sa3_variation_prompt(a, "")
220
+ sa3_match_partial = outputs.sa3_match_style_prompt(a, "")
221
+ clip_plan = outputs.ableton_clip_plan(a)
222
+ raw = to_json(a)
223
+ state = {"analysis": a, "model": model_choice, "audio_path": audio_path,
224
+ "brief_payload": _brief_payload(a), "chain_payload": _chain_payload(a)}
225
+
226
+ yield (f"_generating narrative via **{model_choice}**…_", caption, wave_show,
227
+ _metrics_html(a),
228
+ _data_table(a), _sections_table(a), _tags_table(a),
229
+ _format_errors(a), _format_timings(a),
230
+ sa3_var_partial, sa3_match_partial, clip_plan,
231
+ f"_generating mix chain via **{model_choice}**…_", raw, state)
232
+
233
+ # Stage 3 — fire both LLM calls in parallel, yield whichever finishes first.
234
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
235
+
236
+ paragraph = "(narrative pending)"
237
+ llm_chain = ""
238
+ sa3_var_final = sa3_var_partial
239
+ sa3_match_final = sa3_match_partial
240
+ mix_chain_final = f"_generating mix chain via **{model_choice}**…_"
241
+
242
+ with ThreadPoolExecutor(max_workers=2) as pool:
243
+ fut_brief = pool.submit(write_brief, _brief_payload(a), model=model_choice, api_key=api_key)
244
+ fut_chain = pool.submit(write_mix_chain, _chain_payload(a), model=model_choice, api_key=api_key)
245
+ pending = {fut_brief, fut_chain}
246
+ while pending:
247
+ done, pending = wait(pending, return_when=FIRST_COMPLETED)
248
+ for fut in done:
249
+ try:
250
+ res = fut.result()
251
+ # write_brief / write_mix_chain now return (text, resolved_model)
252
+ text = res[0] if isinstance(res, tuple) else res
253
+ except Exception as e:
254
+ text = f"(failed — {type(e).__name__}: {e})"
255
+ if fut is fut_brief:
256
+ paragraph = text
257
+ safe = text if not text.startswith("(") else ""
258
+ sa3_var_final = outputs.sa3_variation_prompt(a, safe)
259
+ sa3_match_final = outputs.sa3_match_style_prompt(a, safe)
260
+ else:
261
+ llm_chain = text
262
+ mix_chain_final = outputs.mix_chain_text(a, llm_chain)
263
+
264
+ yield (paragraph, caption, wave_show,
265
+ _metrics_html(a),
266
+ _data_table(a), _sections_table(a), _tags_table(a),
267
+ _format_errors(a), _format_timings(a),
268
+ sa3_var_final, sa3_match_final, clip_plan,
269
+ mix_chain_final, raw, state)
270
+
271
+
272
+ def _status_badge(state: str, t_s: float | None, ok_count: int, fail_count: int) -> str:
273
+ """One-line status: ⏳ running / ✓ done / ⚠ partial / ✗ failed."""
274
+ if state == "running":
275
+ return "⏳ _running…_"
276
+ if state == "done":
277
+ if fail_count == 0:
278
+ return f"✅ **done in {t_s:.1f}s**"
279
+ if ok_count == 0:
280
+ return f"❌ **both calls failed** · {t_s:.1f}s"
281
+ return f"⚠️ **partial** · {ok_count}/{ok_count + fail_count} ok · {t_s:.1f}s"
282
+ return ""
283
+
284
+
285
+ def _compare_header(label: str, model: str, badge: str, resolved: str | None = None) -> str:
286
+ # When Pollinations resolves an alias (`gemini` → `gemini-3.5-flash`),
287
+ # show the resolved name in parens so the user sees what actually ran.
288
+ if resolved and resolved != model:
289
+ model_str = f"`{model}` → `{resolved}`"
290
+ else:
291
+ model_str = f"`{model}`"
292
+ return f"### {label} · {model_str}\n\n{badge}"
293
+
294
+
295
+ def _fail_cell(label: str, reason: str) -> str:
296
+ """Distinct visual for a failed cell — blockquote + ❌ icon."""
297
+ return f"> ❌ **{label} failed**\n>\n> `{reason}`"
298
+
299
+
300
+ def export_scorecard(compare_state: dict | None):
301
+ # Guard: compare_state is populated on the FIRST yield (before any LLM
302
+ # call returns), so without the done check a click during a running
303
+ # Compare would export a card full of "_running…_" cells.
304
+ if not compare_state or not compare_state.get("done"):
305
+ gr.Warning("Run side-by-side hasn't finished yet — wait for all three columns to show ✅ before exporting.")
306
+ return None
307
+ return share.render_scorecard_png(compare_state["analysis"], compare_state["columns"])
308
+
309
+
310
+ def export_report(compare_state: dict | None):
311
+ if not compare_state or not compare_state.get("done"):
312
+ gr.Warning("Run side-by-side hasn't finished yet — wait for all three columns to show ✅ before exporting.")
313
+ return None
314
+ return share.render_full_report_md(compare_state["analysis"], compare_state["columns"])
315
+
316
+
317
+ def _columns_from_local(local: dict) -> list[dict]:
318
+ """Flatten the streaming `local` dict into the column structure that
319
+ share.render_scorecard_png / render_full_report_md expects."""
320
+ out = []
321
+ for col in ("a", "b", "c"):
322
+ s = local[col]
323
+ out.append({
324
+ "col": s["label"],
325
+ "model": s["model"],
326
+ "brief": s.get("brief", ""),
327
+ "chain": s.get("raw_chain", ""),
328
+ "mode": "audio-only" if col == "c" else "measured",
329
+ "elapsed_s": s.get("t"),
330
+ })
331
+ return out
332
+
333
+
334
+ def run_compare(state: dict | None, model_a: str, model_b: str, model_c: str, api_key: str = ""):
335
+ """Three-column comparison.
336
+
337
+ Column A: measured analysis + LLM brief+chain (model_a)
338
+ Column B: measured analysis + LLM brief+chain (model_b)
339
+ Column C: AUDIO-ONLY — model_c receives the raw audio file with NO
340
+ measurements and has to guess BPM/key/loudness by ear. This is
341
+ the demo of what the audio-brief wedge buys.
342
+
343
+ Outputs (11 slots):
344
+ a_header, a_brief,
345
+ b_header, b_brief,
346
+ c_header, c_brief,
347
+ comparison_table,
348
+ a_timing, b_timing, c_timing,
349
+ compare_state (gr.State with analysis + columns for export)
350
+ """
351
+ if not state or not state.get("brief_payload"):
352
+ msg = "_Run **analyze** first — Compare reuses the most recent analysis._"
353
+ yield (
354
+ _compare_header("A · measured", model_a, "_no analysis yet_"), msg,
355
+ _compare_header("B · measured", model_b, "_no analysis yet_"), msg,
356
+ _compare_header("C · audio-only", model_c, "_no analysis yet_"), msg,
357
+ "_Run analyze, then click **Run side-by-side**._",
358
+ "", "", "",
359
+ None,
360
+ )
361
+ return
362
+
363
+ from narrative import LLMError
364
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
365
+ import time
366
+
367
+ a = state["analysis"]
368
+ brief_payload = state["brief_payload"]
369
+ chain_payload = state["chain_payload"]
370
+ audio_path = state.get("audio_path")
371
+
372
+ t0 = time.perf_counter()
373
+ local: dict[str, dict] = {
374
+ "a": {"model": model_a, "resolved": None, "label": "A · measured", "brief": "_running…_",
375
+ "chain": "_running…_", "raw_chain": "", "brief_ok": None, "chain_ok": None, "t": None},
376
+ "b": {"model": model_b, "resolved": None, "label": "B · measured", "brief": "_running…_",
377
+ "chain": "_running…_", "raw_chain": "", "brief_ok": None, "chain_ok": None, "t": None},
378
+ "c": {"model": model_c, "resolved": None, "label": "C · audio-only", "brief": "_running… (sending raw audio)_",
379
+ "chain": "_running…_", "raw_chain": "", "brief_ok": None, "chain_ok": None, "t": None},
380
+ }
381
+
382
+ def status_for(side: str) -> str:
383
+ s = local[side]
384
+ if s["brief_ok"] is None or s["chain_ok"] is None:
385
+ return _status_badge("running", None, 0, 0)
386
+ ok = int(bool(s["brief_ok"])) + int(bool(s["chain_ok"]))
387
+ fail = 2 - ok
388
+ return _status_badge("done", s["t"], ok, fail)
389
+
390
+ def _briefs_for_table() -> dict:
391
+ return {
392
+ col: (local[col]["brief"] if local[col]["brief_ok"] else None)
393
+ for col in ("a", "b", "c")
394
+ }
395
+
396
+ def _chains_for_table() -> dict:
397
+ # Pass the raw LLM chain (not the formatted one with the measured
398
+ # header prepended) so the parser can split on `## ` headings.
399
+ return {
400
+ col: (local[col].get("raw_chain") if local[col]["chain_ok"] else None)
401
+ for col in ("a", "b", "c")
402
+ }
403
+
404
+ def _labels_for_table() -> dict:
405
+ return {
406
+ "a": f"A · {local['a']['model']}",
407
+ "b": f"B · {local['b']['model']}",
408
+ "c": f"C · {local['c']['model']} (audio-only)",
409
+ }
410
+
411
+ def render(*, done: bool = False):
412
+ # The `done` flag gates the export buttons — until the final yield,
413
+ # compare_state.done is False and clicking Download… returns nothing
414
+ # rather than exporting a partial scorecard with "_running…_" cells.
415
+ return (
416
+ _compare_header(local["a"]["label"], local["a"]["model"], status_for("a"), local["a"].get("resolved")),
417
+ local["a"]["brief"],
418
+ _compare_header(local["b"]["label"], local["b"]["model"], status_for("b"), local["b"].get("resolved")),
419
+ local["b"]["brief"],
420
+ _compare_header(local["c"]["label"], local["c"]["model"], status_for("c"), local["c"].get("resolved")),
421
+ local["c"]["brief"],
422
+ outputs.compare_table_markdown(_briefs_for_table(), _chains_for_table(), _labels_for_table()),
423
+ "", "", "",
424
+ {"analysis": a, "columns": _columns_from_local(local), "done": done},
425
+ )
426
+
427
+ yield render()
428
+
429
+ def _audio_only_call():
430
+ """Wrap write_audio_only so the pool sees a single dict-returning task."""
431
+ if not audio_path:
432
+ raise RuntimeError("no audio_path in state — re-run analyze")
433
+ return write_audio_only(audio_path, model=model_c, no_cache=True, api_key=api_key)
434
+
435
+ with ThreadPoolExecutor(max_workers=5) as pool:
436
+ futs: dict = {
437
+ pool.submit(write_brief, brief_payload, model=model_a, no_cache=True, api_key=api_key): ("a", "brief"),
438
+ pool.submit(write_mix_chain, chain_payload, model=model_a, no_cache=True, api_key=api_key): ("a", "chain"),
439
+ pool.submit(write_brief, brief_payload, model=model_b, no_cache=True, api_key=api_key): ("b", "brief"),
440
+ pool.submit(write_mix_chain, chain_payload, model=model_b, no_cache=True, api_key=api_key): ("b", "chain"),
441
+ pool.submit(_audio_only_call): ("c", "both"), # single call → both brief+chain
442
+ }
443
+ pending = set(futs)
444
+ while pending:
445
+ done, pending = wait(pending, return_when=FIRST_COMPLETED)
446
+ for fut in done:
447
+ side, kind = futs[fut]
448
+ try:
449
+ res = fut.result()
450
+ ok = True
451
+ except LLMError as e:
452
+ res = _fail_cell(kind if kind != "both" else "audio-only", str(e))
453
+ ok = False
454
+ except Exception as e: # noqa: BLE001
455
+ res = _fail_cell(kind if kind != "both" else "audio-only", f"{type(e).__name__}: {e}")
456
+ ok = False
457
+
458
+ if kind == "both":
459
+ # Audio-only returns {"brief", "chain", "model_resolved"} on success.
460
+ if ok and isinstance(res, dict):
461
+ local[side]["brief"] = res.get("brief", "_(no brief)_")
462
+ raw = res.get("chain", "")
463
+ local[side]["raw_chain"] = raw
464
+ local[side]["chain"] = outputs.normalize_chain_markdown(raw)
465
+ local[side]["brief_ok"] = True
466
+ local[side]["chain_ok"] = True
467
+ local[side]["resolved"] = res.get("model_resolved")
468
+ else:
469
+ local[side]["brief"] = res
470
+ local[side]["chain"] = "_(audio-only call failed — see brief cell)_"
471
+ local[side]["raw_chain"] = ""
472
+ local[side]["brief_ok"] = False
473
+ local[side]["chain_ok"] = False
474
+ else:
475
+ # write_brief / write_mix_chain now return (text, resolved_model).
476
+ if ok and isinstance(res, tuple):
477
+ text, resolved = res
478
+ if not local[side].get("resolved"):
479
+ local[side]["resolved"] = resolved
480
+ res = text
481
+ if kind == "chain" and ok:
482
+ local[side]["raw_chain"] = res
483
+ res = outputs.mix_chain_text(a, res)
484
+ elif kind == "chain":
485
+ local[side]["raw_chain"] = ""
486
+ local[side][kind] = res
487
+ local[side][f"{kind}_ok"] = ok
488
+
489
+ # Stamp final time when both calls (or the single audio-only call)
490
+ # for this side have landed.
491
+ if (local[side]["brief_ok"] is not None
492
+ and local[side]["chain_ok"] is not None
493
+ and local[side]["t"] is None):
494
+ local[side]["t"] = time.perf_counter() - t0
495
+
496
+ yield render(done=not pending)
497
+
498
+
499
+ def _wallet_status_md() -> str:
500
+ key = wallet.get_key()
501
+ if not key:
502
+ return "**Pollinations wallet:** not connected — narrative will fall back to (or fail). Click *Connect* to authorise abv1 with your Pollinations balance."
503
+ info = wallet.load_wallet().get("user", {}) or {}
504
+ who = info.get("preferred_username") or info.get("name") or "connected"
505
+ masked = key[:6] + "…" + key[-4:] if len(key) > 12 else "•••"
506
+ return f"**Pollinations wallet:** connected as **{who}** (`{masked}`)."
507
+
508
+
509
+ def _topbar_html(session_key: str = "", session_spend: float = 0.0) -> str:
510
+ """Top bar HTML: brand wordmark left, pollen pill (clickable wallet) right.
511
+
512
+ The pill IS the wallet button:
513
+ - disconnected → click triggers OAuth redirect via inline JS
514
+ - connected → click triggers the hidden #wallet-disconnect-trigger
515
+ Pollen balance: Pollinations userinfo doesn't always include it; we
516
+ show the balance if present, otherwise a connected dot.
517
+
518
+ `session_key` is the per-visitor key from gr.State (HF Spaces mode).
519
+ Desktop falls back to env vars and the on-disk wallet.
520
+ `session_spend` renders a small `0.84 ◆ session` pill left of the
521
+ wallet pill when > 0 (per v2 design — cost transparency)."""
522
+ key = wallet.get_key(session_key=session_key)
523
+ if not key:
524
+ return (
525
+ '<div class="dc-topbar">'
526
+ f'<div>{brand_html()}</div>'
527
+ '<div style="display:flex;align-items:center;gap:10px;">'
528
+ f'{pollen_pill_html(balance=None, connected=False)}'
529
+ '<div class="dc-avatar"></div>'
530
+ '</div>'
531
+ '</div>'
532
+ )
533
+ # On HF Spaces we don't have userinfo cached; on desktop, wallet file
534
+ # may have userinfo from the connect flow.
535
+ info = wallet.load_wallet().get("user", {}) or {}
536
+ b = info.get("balance") or info.get("pollen") or info.get("credits")
537
+ try:
538
+ balance = float(b) if b is not None else None
539
+ except (TypeError, ValueError):
540
+ balance = None
541
+ return (
542
+ '<div class="dc-topbar">'
543
+ f'<div>{brand_html()}</div>'
544
+ '<div style="display:flex;align-items:center;gap:10px;">'
545
+ f'{session_spend_pill_html(session_spend, connected=True)}'
546
+ f'{pollen_pill_html(balance=balance, connected=True)}'
547
+ '<div class="dc-avatar"></div>'
548
+ '</div>'
549
+ '</div>'
550
+ )
551
+
552
+
553
+ def save_key_from_fragment(api_key: str):
554
+ """Page-load handler: if Pollinations redirected back to us with
555
+ #api_key=sk_… in the URL fragment, JS strips it and passes the key
556
+ here. We persist it to the wallet file and refresh the status pill.
557
+ Matches the abv1 OAuth pattern (standalone/public/index.html:4959)."""
558
+ import time
559
+ if api_key and api_key.startswith("sk_"):
560
+ try:
561
+ info = wallet.userinfo(api_key)
562
+ except Exception:
563
+ info = {}
564
+ # save_wallet is a no-op on HF Spaces; persists to disk on desktop.
565
+ wallet.save_wallet({
566
+ "api_key": api_key,
567
+ "user": info,
568
+ "scope": "generate account:usage",
569
+ "saved_at": int(time.time()),
570
+ })
571
+ # Render the connected pill regardless of where the key is stored.
572
+ # session_spend resets to 0 here — a fresh connect starts the
573
+ # session-spend counter from zero.
574
+ return _topbar_html(session_key=api_key, session_spend=0.0), api_key, 0.0
575
+ return _topbar_html(), "", 0.0
576
+
577
+
578
+ def disconnect_wallet():
579
+ wallet.clear_wallet()
580
+ # Disconnect also clears the session-spend pill — it's per-visit.
581
+ return _topbar_html(), "", 0.0
582
+
583
+
584
+ # Pollinations SA3 cost: flat per call regardless of duration (verified
585
+ # 2026-06-23, see sa3.py). Each /audio/{text} hit on stable-audio-3-medium
586
+ # is 0.04 pollen. Used to tick the session-spend pill and render cost pips.
587
+ SA3_COST_PER_CALL = 0.04
588
+
589
+
590
+ def tick_session_spend(curr: float, delta: float, api_key: str = ""):
591
+ """Increment the session-spend counter and refresh the top bar so the
592
+ `0.84 ◆ session` pill updates after a gen lands.
593
+
594
+ Returns (topbar_html, new_spend) — both used as outputs in a `.then(...)`
595
+ chain after each Pollinations-billing handler."""
596
+ try:
597
+ new = float(curr or 0.0) + float(delta or 0.0)
598
+ except (TypeError, ValueError):
599
+ new = 0.0
600
+ return _topbar_html(session_key=api_key, session_spend=new), new
601
+
602
+
603
+ # ── MVP2 · Generate-tab handlers ────────────────────────────────────────────
604
+
605
+ def _tile_meta_md(tile: crate.Tile | None) -> str:
606
+ if not tile:
607
+ return "_no tile selected_"
608
+ parent_line = ""
609
+ if tile.parent_id:
610
+ parent_tile = crate.get_tile(tile.parent_id)
611
+ if parent_tile:
612
+ parent_line = f"\n- **descended from**: `[{tile.parent_id}]` {parent_tile.label}"
613
+ return (
614
+ f"**`[{tile.id}]` {tile.label}**\n\n"
615
+ f"- model: `{tile.model}`\n"
616
+ f"- duration: {tile.duration_s:.1f}s\n"
617
+ f"- prompt: _{tile.source_prompt}_"
618
+ f"{parent_line}"
619
+ )
620
+
621
+
622
+ def generate_sa3(prompt: str, model: str, duration: float,
623
+ api_key: str = "", local_gen_url: str = "", local_gen_b64: str = ""):
624
+ """Click handler for the Generate button.
625
+
626
+ Two paths:
627
+ - `model == "local-server"`: a JS prelude on the click already fetched
628
+ the user's localhost gen server and base64-encoded the audio bytes
629
+ in `local_gen_b64`. We just decode and write to the crate. No
630
+ Pollinations call.
631
+ - else: existing Pollinations SA3 flow via sa3.generate(api_key=...).
632
+ """
633
+ import sys as _sys, traceback as _tb, base64 as _b64
634
+ print(f"[gen] click: model={model} dur={duration} prompt={prompt[:80]!r}",
635
+ file=_sys.stderr, flush=True)
636
+ # Hidden placeholders (no value, not visible) — used in every failure
637
+ # path so the latest-gen + selected-tile + meta stay collapsed when
638
+ # nothing valid landed. Keeps the layout clean on error (v2 spec).
639
+ _audio_hide = gr.update(value=None, visible=False)
640
+ _meta_hide = gr.update(value="", visible=False)
641
+
642
+ if not (prompt or "").strip():
643
+ return ("_❌ Enter a prompt first._", _audio_hide,
644
+ gr.update(choices=crate.tile_choices()),
645
+ _audio_hide, _meta_hide, _crate_header_html())
646
+
647
+ duration = int(duration or 10)
648
+
649
+ # ── Local server path ────────────────────────────────────────────────
650
+ if model == "local-server":
651
+ if not local_gen_b64:
652
+ url = (local_gen_url or "http://localhost:7864").rstrip("/")
653
+ return (f"_❌ Local gen server didn't return audio. Is it running at `{url}/generate`?_",
654
+ _audio_hide, gr.update(choices=crate.tile_choices()),
655
+ _audio_hide, _meta_hide, _crate_header_html())
656
+ try:
657
+ raw = _b64.b64decode(local_gen_b64)
658
+ except Exception as e:
659
+ return (f"_❌ Local audio decode failed: {e}_",
660
+ _audio_hide, gr.update(choices=crate.tile_choices()),
661
+ _audio_hide, _meta_hide, _crate_header_html())
662
+ # Guess extension — wav vs mp3 by magic bytes; default .wav for raw PCM containers.
663
+ ext = "wav" if raw[:4] == b"RIFF" else ("mp3" if raw[:3] == b"ID3" or (raw and raw[0] == 0xFF) else "wav")
664
+ out_path = crate.CRATE_DIR / f"local-{crate.new_id()}.{ext}"
665
+ out_path.write_bytes(raw)
666
+ tile = crate.add_tile(
667
+ audio_path=str(out_path),
668
+ source_prompt=prompt,
669
+ model="local-server",
670
+ duration_s=float(duration),
671
+ )
672
+ new_audio = crate.CRATE_DIR / f"{tile.id}.{ext}"
673
+ try:
674
+ out_path.rename(new_audio)
675
+ tile.audio_path = str(new_audio)
676
+ tile.save()
677
+ except Exception:
678
+ pass
679
+ status = (f"✅ Local gen `[{tile.id}]` **{tile.label}** "
680
+ f"({len(raw)/1024:.0f} KB) — model `local-server`")
681
+ return (status,
682
+ gr.update(value=tile.audio_path, visible=True),
683
+ gr.update(choices=crate.tile_choices(), value=tile.id),
684
+ gr.update(value=tile.audio_path, visible=True),
685
+ gr.update(value=_tile_meta_md(tile), visible=True),
686
+ _crate_header_html())
687
+
688
+ # ── Pollinations path (default) ──────────────────────────────────────
689
+ if not wallet.get_key(session_key=api_key):
690
+ return ("_❌ Click the pollen pill (top right) to connect a Pollinations wallet first._",
691
+ _audio_hide, gr.update(choices=crate.tile_choices()),
692
+ _audio_hide, _meta_hide, _crate_header_html())
693
+
694
+ out_path = crate.CRATE_DIR / f"{crate.new_id()}.mp3"
695
+ print(f"[gen] calling sa3.generate → {out_path}", file=_sys.stderr, flush=True)
696
+ try:
697
+ info = sa3.generate(prompt, model=model, duration=duration, out_path=out_path,
698
+ api_key=api_key)
699
+ except sa3.SA3Error as e:
700
+ print(f"[gen] SA3Error: {e}", file=_sys.stderr, flush=True)
701
+ return (f"_❌ {e}_", _audio_hide,
702
+ gr.update(choices=crate.tile_choices()),
703
+ _audio_hide, _meta_hide, _crate_header_html())
704
+ except Exception as e:
705
+ print(f"[gen] {type(e).__name__}: {e}", file=_sys.stderr, flush=True)
706
+ _tb.print_exc(file=_sys.stderr)
707
+ return (f"_❌ unexpected {type(e).__name__}: {e}_", _audio_hide,
708
+ gr.update(choices=crate.tile_choices()),
709
+ _audio_hide, _meta_hide, _crate_header_html())
710
+ print(f"[gen] sa3 ok: {info}", file=_sys.stderr, flush=True)
711
+
712
+ tile = crate.add_tile(
713
+ audio_path=info["path"],
714
+ source_prompt=prompt,
715
+ model=model,
716
+ duration_s=float(duration),
717
+ )
718
+ # Move the audio file to a name that matches the tile id for tidiness.
719
+ new_audio = crate.CRATE_DIR / f"{tile.id}.mp3"
720
+ try:
721
+ Path(info["path"]).rename(new_audio)
722
+ tile.audio_path = str(new_audio)
723
+ tile.save()
724
+ except Exception:
725
+ pass
726
+
727
+ status = (f"✅ Generated `[{tile.id}]` **{tile.label}** "
728
+ f"({info['bytes']/1024:.0f} KB, {info['wall_s']:.1f}s wall, model `{info['model']}`)")
729
+ choices = crate.tile_choices()
730
+ return (status,
731
+ gr.update(value=tile.audio_path, visible=True),
732
+ gr.update(choices=choices, value=tile.id),
733
+ gr.update(value=tile.audio_path, visible=True),
734
+ gr.update(value=_tile_meta_md(tile), visible=True),
735
+ _crate_header_html())
736
+
737
+
738
+ def regenerate_variants(prompt_text: str, last_run_state, api_key: str = ""):
739
+ """Spawn 5 SA3 variants from the (possibly edited) derived prompt.
740
+
741
+ Streams output: each variant fills its audio slot as it finishes.
742
+ Variants are added to the crate with parent_id set to the source tile
743
+ when one is known (i.e. the user came from Generate → Use for analysis).
744
+
745
+ `api_key` is the session-scoped Pollinations token from gr.State.
746
+
747
+ Output tuple (6 slots): var1, var2, var3, var4, var5, status_md
748
+ """
749
+ import sys as _sys, traceback as _tb
750
+
751
+ paths: list[str | None] = [None, None, None, None, None]
752
+
753
+ if not (prompt_text or "").strip():
754
+ yield (*paths, "_❌ Enter a prompt to regenerate from._")
755
+ return
756
+ if not wallet.get_key(session_key=api_key):
757
+ yield (*paths, "_❌ Connect a Pollinations wallet first (click the pollen pill, top right)._")
758
+ return
759
+
760
+ # Try to thread parent lineage from last_run state (set when the user
761
+ # routed in via "Use for analysis" — `audio_path` is the source tile's
762
+ # audio_path, and we can resolve it back to its crate tile.id).
763
+ parent_id: str | None = None
764
+ if isinstance(last_run_state, dict):
765
+ src_path = last_run_state.get("audio_path", "")
766
+ for t in crate.list_tiles():
767
+ if t.audio_path == src_path:
768
+ parent_id = t.id
769
+ break
770
+
771
+ duration = 15 # fixed for v1 — variants are short clips
772
+ model = sa3.DEFAULT_MODEL
773
+
774
+ for i in range(5):
775
+ yield (*paths, f"_generating variant {i+1}/5 via `{model}` ({duration}s)…_")
776
+ out_path = crate.CRATE_DIR / f"{crate.new_id()}.mp3"
777
+ try:
778
+ info = sa3.generate(prompt_text, model=model, duration=duration,
779
+ out_path=out_path, api_key=api_key)
780
+ except sa3.SA3Error as e:
781
+ print(f"[regen v{i+1}] SA3Error: {e}", file=_sys.stderr, flush=True)
782
+ yield (*paths, f"_❌ variant {i+1}/5: {e}_")
783
+ continue
784
+ except Exception as e:
785
+ print(f"[regen v{i+1}] {type(e).__name__}: {e}", file=_sys.stderr, flush=True)
786
+ _tb.print_exc(file=_sys.stderr)
787
+ yield (*paths, f"_❌ variant {i+1}/5: unexpected {type(e).__name__}_")
788
+ continue
789
+
790
+ tile = crate.add_tile(
791
+ audio_path=info["path"],
792
+ source_prompt=prompt_text,
793
+ parent_id=parent_id,
794
+ model=model,
795
+ duration_s=float(duration),
796
+ )
797
+ # Rename file to match tile id, same as generate_sa3.
798
+ new_audio = crate.CRATE_DIR / f"{tile.id}.mp3"
799
+ try:
800
+ Path(info["path"]).rename(new_audio)
801
+ tile.audio_path = str(new_audio)
802
+ tile.save()
803
+ except Exception:
804
+ pass
805
+ paths[i] = tile.audio_path
806
+
807
+ parent_note = ""
808
+ if parent_id:
809
+ parent_note = f" (descended from `[{parent_id}]`)"
810
+ yield (*paths, f"✅ 5 variants ready — added to the crate{parent_note}.")
811
+
812
+
813
+ def _crate_header_html() -> str:
814
+ """Header above the crate chip strip: 'CRATE · N takes' or empty CTA.
815
+ Updated by every handler that mutates the crate.
816
+
817
+ Empty state per v2 design: a dashed card with a clear next-step
818
+ instruction, not a thin "empty — …" hint that gets lost."""
819
+ tiles = crate.list_tiles()
820
+ n = len(tiles)
821
+ if n == 0:
822
+ return (
823
+ '<div style="margin-top:10px;padding:18px 16px;'
824
+ 'border:1px dashed #2A2F38;border-radius:12px;background:#121419;'
825
+ 'display:flex;flex-direction:column;gap:6px;align-items:flex-start;">'
826
+ '<div class="dc-label">CRATE</div>'
827
+ '<div style="font-family:\'Space Grotesk\';font-size:14px;color:#C9CDD4;">'
828
+ 'Your crate is empty.</div>'
829
+ '<div style="font-family:JetBrains Mono;font-size:11px;color:#5E6671;">'
830
+ 'Generate a take above to start — variants will appear here as you regenerate.'
831
+ '</div></div>'
832
+ )
833
+ return (
834
+ '<div style="display:flex;align-items:baseline;gap:9px;margin-top:10px;">'
835
+ '<div class="dc-label">CRATE</div>'
836
+ '<div style="font-family:JetBrains Mono;font-size:11px;color:#5E6671;">'
837
+ f'{n} take{"s" if n != 1 else ""}'
838
+ '</div></div>'
839
+ )
840
+
841
+
842
+ def refresh_crate():
843
+ """Re-scan the crate dir and refresh the chip strip + header."""
844
+ return gr.update(choices=crate.tile_choices()), _crate_header_html()
845
+
846
+
847
+ def select_tile(tile_id: str | None):
848
+ """Selecting a tile from the dropdown previews it + shows metadata.
849
+ Returns gr.update wrappers so the preview/meta show only when a tile
850
+ is actually selected (otherwise they stay hidden — see v2 empty states)."""
851
+ if not tile_id:
852
+ return gr.update(value=None, visible=False), gr.update(value="", visible=False)
853
+ tile = crate.get_tile(tile_id)
854
+ if not tile:
855
+ return (gr.update(value=None, visible=False),
856
+ gr.update(value="_tile not found (was it deleted elsewhere?)_", visible=True))
857
+ return (gr.update(value=tile.audio_path, visible=True),
858
+ gr.update(value=_tile_meta_md(tile), visible=True))
859
+
860
+
861
+ def delete_tile(tile_id: str | None):
862
+ if not tile_id:
863
+ return (gr.update(choices=crate.tile_choices()),
864
+ gr.update(value=None, visible=False),
865
+ gr.update(value="", visible=False),
866
+ _crate_header_html())
867
+ crate.delete_tile(tile_id)
868
+ return (gr.update(choices=crate.tile_choices(), value=None),
869
+ gr.update(value=None, visible=False),
870
+ gr.update(value="", visible=False),
871
+ _crate_header_html())
872
+
873
+
874
+ def send_tile_to_analysis(tile_id: str | None):
875
+ """Bridge from Generate → Analysis-flow upload box. Returns
876
+ (audio_path, gen_status_md) so the Generate tab gives a visible nudge
877
+ pointing the user at the Analysis tab where the brief lands."""
878
+ if not tile_id:
879
+ return None, "_❌ Pick a tile from the dropdown first._"
880
+ tile = crate.get_tile(tile_id)
881
+ if not tile:
882
+ return None, "_❌ Tile not found — try Refresh._"
883
+ return (
884
+ tile.audio_path,
885
+ f"🔬 Analysing `[{tile.id}]` **{tile.label}** — switch to the **Analysis** tab"
886
+ f" to see the brief, or **Compare with Gemini** for the side-by-side.",
887
+ )
888
+
889
+
890
+ def build_ui() -> gr.Blocks:
891
+ with gr.Blocks(title="audio·brief") as demo:
892
+ # Top bar — brand + pollen pill + avatar. Dynamic on connect via the
893
+ # wallet handlers below (they output to `topbar` to refresh the pill).
894
+ topbar = gr.HTML(_topbar_html())
895
+
896
+ # Wallet UI is the pollen pill in the top bar. We keep two hidden
897
+ # Gradio elements that the JS in the pill targets:
898
+ # - #wallet-disconnect-trigger : button the pill clicks on disconnect
899
+ # - _fragment_key : hidden textbox demo.load reads to
900
+ # capture the api_key after the OAuth
901
+ # redirect returns with #api_key=...
902
+ disconnect_btn = gr.Button("", elem_id="wallet-disconnect-trigger")
903
+ _fragment_key = gr.Textbox(visible=False, value="")
904
+
905
+ # Per-session Pollinations wallet key — populated by save_key_from_fragment
906
+ # after OAuth redirect. On HF Spaces this is the ONLY place the key
907
+ # lives (never written to disk). Every Pollinations-calling handler
908
+ # takes it as an input and threads it down through narrative.py / sa3.py.
909
+ api_key_state = gr.State("")
910
+
911
+ # Running session-spend in pollen. Bumped after every Pollinations
912
+ # gen lands; rendered as a small pill in the top bar (`0.84 ◆ session`).
913
+ # Cleared on disconnect/connect so each visit starts fresh.
914
+ session_spend_state = gr.State(0.0)
915
+
916
+ # Shared state — holds the most recent Analysis + payloads so the
917
+ # Compare tab can re-run the same brief through different models.
918
+ last_run = gr.State(None)
919
+
920
+ # Upload-from-disk path lives in a collapsed accordion. The primary
921
+ # MVP2 flow is Generate → "Use for analysis" on a crate tile, so this
922
+ # is the secondary entry point — kept accessible but out of the way.
923
+ with gr.Accordion("Upload audio from disk", open=False):
924
+ with gr.Row():
925
+ audio_in = gr.Audio(type="filepath", label="reference audio")
926
+ with gr.Column(scale=0):
927
+ bpm_mode = gr.Radio(
928
+ choices=["Auto (genre)", "Manual BPM"],
929
+ value="Auto (genre)",
930
+ label="BPM prior",
931
+ info="Auto seeds the beat tracker from genre; Manual locks it to a number.",
932
+ )
933
+ with gr.Group(visible=True) as genre_grp:
934
+ genre = gr.Dropdown(
935
+ GENRE_CHOICES, value="default", label="Genre",
936
+ info="Hint so the beat tracker doesn't lock onto half-tempo.",
937
+ )
938
+ with gr.Group(visible=False) as manual_grp:
939
+ bpm_num = gr.Number(
940
+ value=120, label="BPM",
941
+ info="Used as the start_bpm prior — final BPM may still differ.",
942
+ )
943
+ model_dd = gr.Dropdown(
944
+ TEXT_MODELS, value=DEFAULT_MODEL_A, label="Narrative model",
945
+ info="Pollinations text model. Compare tab can pit any two against each other.",
946
+ )
947
+ run_btn = gr.Button("analyze", variant="primary")
948
+
949
+ # ── MVP2 · Generate tab ──────────────────────────────────────────────
950
+ # Prompt → Pollinations SA3 → crate tile → optionally Analyse to
951
+ # feed the Compare tab. The crate is a session-scoped file store
952
+ # at /tmp/audio-brief-crate/; tiles persist across UI refreshes
953
+ # but not across reboots. Lineage tag on each tile shows which
954
+ # earlier tile it descended from (when re-generated from a blend).
955
+ with gr.Tab("Generate"):
956
+
957
+ with gr.Row():
958
+ with gr.Column(scale=4):
959
+ gen_prompt = gr.Textbox(
960
+ label="Prompt",
961
+ lines=3,
962
+ placeholder="drum and bass, dark atmospheric jungle, 174 BPM, heavy bass…",
963
+ )
964
+ with gr.Column(scale=1):
965
+ # SA3 is the only enabled Pollinations gen model right now.
966
+ # "Local server" routes through the browser bridge (JS
967
+ # prelude on the gen button fetches the user's localhost
968
+ # gen server — see docs/local-gen-server-example.py).
969
+ # Greyed-out "(soon)" choices snap back to SA3.
970
+ gen_model = gr.Dropdown(
971
+ choices=[
972
+ ("stable-audio-3-medium", "stable-audio-3-medium"),
973
+ ("Local server (your machine)", "local-server"),
974
+ ("stable-audio-3-large (soon)", "stable-audio-3-large"),
975
+ ("AceStep (soon)", "acestep"),
976
+ ("ElevenMusic (soon)", "elevenmusic"),
977
+ ],
978
+ value=sa3.DEFAULT_MODEL,
979
+ label="Model",
980
+ info="~0.04 pollen per gen",
981
+ )
982
+ # Duration as chip-row — thumb-friendly on phones/iPads.
983
+ # Values cover the common cases: short cue → full track.
984
+ gen_duration = gr.Radio(
985
+ choices=[
986
+ ("Cue · 15s", 15),
987
+ ("Loop · 30s", 30),
988
+ ("Track · 90s", 90),
989
+ ("Long · 180s", 180),
990
+ ],
991
+ value=15,
992
+ label="Duration",
993
+ info="Flat cost per gen — pick what you need.",
994
+ )
995
+ # Variation HOLD chips — design placeholder. Pollinations
996
+ # doesn't expose CFG for SA3 yet (see sa3.py header). We
997
+ # render the shape so users see it's coming, but the
998
+ # group is disabled (opacity 0.45, pointer-events none).
999
+ # Wire to a real param when the API surfaces it.
1000
+ gr.HTML(
1001
+ '<div class="dc-var-hold">'
1002
+ '<div class="head">'
1003
+ '<div class="label">Variation spread</div>'
1004
+ '<div class="tag">awaiting CFG API · disabled</div>'
1005
+ '</div>'
1006
+ '<div class="chips">'
1007
+ '<div class="chip">tight</div>'
1008
+ '<div class="chip">balanced</div>'
1009
+ '<div class="chip">wild</div>'
1010
+ '</div>'
1011
+ '</div>'
1012
+ )
1013
+ gen_btn = gr.Button("Generate", variant="primary", size="lg")
1014
+ # Cost pip — `~0.04 ◆ per call · flat`. Sits under the
1015
+ # button so users see what they're about to spend.
1016
+ gr.HTML(cost_pip_html(SA3_COST_PER_CALL))
1017
+
1018
+ gen_status = gr.Markdown("")
1019
+ # Latest gen — hidden until the first gen lands (per v2 design:
1020
+ # empty bordered boxes are noise). generate_sa3 returns
1021
+ # gr.update(visible=True, value=path) on success to reveal it.
1022
+ gen_audio = gr.Audio(label="Latest gen", interactive=False,
1023
+ type="filepath", visible=False)
1024
+
1025
+ # Local-server settings — only relevant when the Model dropdown
1026
+ # is set to "Local server". The browser fetches this URL
1027
+ # directly from the user's machine; HF Spaces never touches it.
1028
+ with gr.Accordion("Local gen server (advanced)", open=False):
1029
+ gr.Markdown(
1030
+ "Run a small HTTP server on your machine that exposes "
1031
+ "`POST /generate` with `{prompt, duration}` and returns audio bytes "
1032
+ "(WAV or MP3). When you select **Local server** as the model, the "
1033
+ "browser fetches that endpoint directly and uploads the audio "
1034
+ "here — your local model never leaves your machine. "
1035
+ "See [`docs/local-gen-server-example.py`](https://github.com/abv1/audio-brief/blob/main/docs/local-gen-server-example.py) "
1036
+ "for a 40-line FastAPI reference. Your local server must allow "
1037
+ "CORS from this origin (or use `*`)."
1038
+ )
1039
+ local_gen_url = gr.Textbox(
1040
+ value="http://localhost:7864",
1041
+ label="Local server URL",
1042
+ info="Base URL of your local gen server. The browser POSTs to <URL>/generate.",
1043
+ )
1044
+ # Hidden slot — JS prelude on gen_btn writes the b64 audio
1045
+ # from the localhost fetch here before the Python handler runs.
1046
+ local_gen_b64 = gr.Textbox(visible=False, value="")
1047
+
1048
+ # Crate header — count + empty-state hint. Updated by every
1049
+ # handler that touches the crate (gen, delete, regen, use-for-
1050
+ # analysis) so the user always sees current size.
1051
+ crate_header = gr.HTML(_crate_header_html())
1052
+ # Chip strip — horizontal scrolling Radio. Each chip = one tile.
1053
+ # Clicking selects + previews + populates lineage for variants.
1054
+ crate_picker = gr.Radio(
1055
+ choices=crate.tile_choices(),
1056
+ show_label=False,
1057
+ container=False,
1058
+ interactive=True,
1059
+ elem_classes=["dc-crate-strip"],
1060
+ )
1061
+ with gr.Row(elem_classes=["dc-crate-row"]):
1062
+ use_for_analysis_btn = gr.Button("Use for analysis →", variant="primary", scale=4)
1063
+ delete_tile_btn = gr.Button("✕", elem_classes=["dc-icon-btn"], scale=0, min_width=42)
1064
+ # Refresh button hidden — gen/delete/regen auto-update via outputs.
1065
+ refresh_crate_btn = gr.Button("", visible=False)
1066
+ # Selected tile + meta — hidden until a tile is selected.
1067
+ # select_tile / delete_tile flip visibility via gr.update.
1068
+ crate_preview = gr.Audio(label="Selected tile", interactive=False,
1069
+ type="filepath", show_label=False,
1070
+ visible=False)
1071
+ crate_meta = gr.Markdown("", visible=False)
1072
+
1073
+ # ── Analysis tab — visible so the user sees the brief + waveform + ─
1074
+ # measurements after clicking "Use for analysis" on a tile or the top
1075
+ # analyze button. The Data / Use / Raw tabs stay hidden; this is the
1076
+ # one MVP1-derived view kept in MVP2 because it's where the user
1077
+ # confirms what the LLM heard.
1078
+ with gr.Tab("Analysis"):
1079
+ caption = gr.Markdown()
1080
+ # 6-up metric tile grid: BPM / KEY / LUFS / TRUE PK / LRA / LENGTH.
1081
+ # Empty placeholder shown until the pipeline fills it in.
1082
+ metrics_html = gr.HTML(_metrics_html(None))
1083
+ paragraph = gr.Markdown()
1084
+ # Waveform — hidden until analysis runs. run_brief returns a
1085
+ # gr.update(visible=True, value=...) wrapper so the empty
1086
+ # bordered box doesn't sit there pre-analysis.
1087
+ wave = gr.Image(label="waveform + sections", show_label=True,
1088
+ height=240, visible=False)
1089
+
1090
+ # ── Derived prompt card ───────────────────────────────────────
1091
+ # Coral-bordered card: the editable SA3 prompt the LLM derived
1092
+ # from the brief + measurements. User can tweak, then Regenerate
1093
+ # spawns 5 SA3 variants → playable inline + added to crate.
1094
+ with gr.Column(elem_classes=["dc-derived-card"]):
1095
+ gr.HTML('<div class="dc-label coral">DERIVED PROMPT</div>')
1096
+ sa3_var = gr.Textbox(
1097
+ show_label=False,
1098
+ lines=4,
1099
+ placeholder="Run analyze first — the blended SA3 prompt lands here. You can edit it before regenerating.",
1100
+ )
1101
+ regen_btn = gr.Button(
1102
+ "Regenerate · 5 variants ▸",
1103
+ variant="primary", size="lg",
1104
+ )
1105
+ # Cost pip for the batch — 5 × SA3 flat cost.
1106
+ gr.HTML(cost_pip_html(SA3_COST_PER_CALL * 5, suffix="5 variants · flat"))
1107
+ regen_status = gr.Markdown("")
1108
+ gr.HTML('<div class="dc-label" style="margin-top:14px;">5 VARIANTS</div>')
1109
+ with gr.Row(elem_classes=["dc-variant-row"]):
1110
+ var1 = gr.Audio(label="v1", interactive=False, type="filepath", scale=1)
1111
+ var2 = gr.Audio(label="v2", interactive=False, type="filepath", scale=1)
1112
+ var3 = gr.Audio(label="v3", interactive=False, type="filepath", scale=1)
1113
+ var4 = gr.Audio(label="v4", interactive=False, type="filepath", scale=1)
1114
+ var5 = gr.Audio(label="v5", interactive=False, type="filepath", scale=1)
1115
+
1116
+ with gr.Tab("Data", visible=False):
1117
+ data_tbl = gr.Dataframe(
1118
+ headers=["field", "value"], label="measurements",
1119
+ interactive=False, wrap=True,
1120
+ )
1121
+ sections_tbl = gr.Dataframe(
1122
+ headers=["label", "start_s", "end_s", "length_s"],
1123
+ label="sections", interactive=False,
1124
+ )
1125
+ tags_tbl = gr.Dataframe(
1126
+ headers=["kind", "label", "score"],
1127
+ label="tags (top-5 per kind)", interactive=False,
1128
+ )
1129
+ errors_box = gr.Textbox(label="stage errors", lines=4, interactive=False)
1130
+ timings_box = gr.Textbox(label="timings", lines=6, interactive=False)
1131
+
1132
+ with gr.Tab("Use", visible=False):
1133
+ # sa3_var moved up into the visible derived-prompt card in Analysis.
1134
+ gr.Markdown("Copy-paste outputs for the next workflow step.")
1135
+ sa3_match = gr.Textbox(label="SA3 match-style prompt", lines=10)
1136
+ clip_plan = gr.Textbox(label="Ableton clip plan (JSON)", lines=14)
1137
+ mix_chain = gr.Textbox(label="Mix chain — Ableton starting point", lines=20)
1138
+
1139
+ with gr.Tab("Compare with Gemini"):
1140
+ gr.Markdown(
1141
+ "Three-way comparison. **A** and **B** receive the measured analysis JSON;"
1142
+ " **C** receives the raw audio with **no measurements** — it has to guess BPM,"
1143
+ " key, loudness by ear. C is the wedge demo: see what the LLM does without your"
1144
+ " pipeline. Click *analyze* first, then come back here."
1145
+ )
1146
+ # Button on its own row above the selectors so the three model
1147
+ # dropdowns line up cleanly with A/B/C status headers, briefs,
1148
+ # and the mix-chain table columns below.
1149
+ with gr.Row():
1150
+ compare_btn = gr.Button("Run side-by-side", variant="primary", size="lg")
1151
+ with gr.Row(equal_height=True):
1152
+ model_a_dd = gr.Dropdown(TEXT_MODELS, value=DEFAULT_MODEL_A,
1153
+ label="A · measured", scale=1)
1154
+ model_b_dd = gr.Dropdown(TEXT_MODELS, value=DEFAULT_MODEL_B,
1155
+ label="B · measured", scale=1)
1156
+ model_c_dd = gr.Dropdown(AUDIO_MODEL_CHOICES, value=DEFAULT_MODEL_C,
1157
+ label="C · audio-only", scale=1,
1158
+ info="Audio-input multimodal model — listens to the raw audio with no measurements supplied. gemini is the default.")
1159
+
1160
+ # Status headers — one Markdown per column for the model-name +
1161
+ # status badge. Briefs stay in three columns below because they're
1162
+ # short paragraphs that read fine side-by-side.
1163
+ with gr.Row(equal_height=False):
1164
+ a_header = gr.Markdown(_compare_header("A · measured", DEFAULT_MODEL_A, "_click Run_"))
1165
+ b_header = gr.Markdown(_compare_header("B · measured", DEFAULT_MODEL_B, "_click Run_"))
1166
+ c_header = gr.Markdown(_compare_header("C · audio-only", DEFAULT_MODEL_C, "_click Run_"))
1167
+ with gr.Row(equal_height=False):
1168
+ a_brief = gr.Markdown("_(pending)_")
1169
+ b_brief = gr.Markdown("_(pending)_")
1170
+ c_brief = gr.Markdown("_(pending)_")
1171
+
1172
+ # The mix-chain section now lives in ONE wide markdown table —
1173
+ # rows = sections, columns = models. Forces vertical alignment
1174
+ # even when one model's section is 5 items and another's is 2.
1175
+ gr.Markdown("### Mix chain comparison")
1176
+ comparison_table = gr.Markdown("_click **Run side-by-side** to populate._")
1177
+
1178
+ # Export the comparison as two separate artifacts:
1179
+ # - PNG scorecard for messaging / DM (short, screenshot-sized)
1180
+ # - Markdown full report for email / Notion / archive
1181
+ gr.Markdown("### Share")
1182
+ with gr.Row():
1183
+ scorecard_btn = gr.Button("📸 Download scorecard (PNG)", variant="secondary")
1184
+ report_btn = gr.Button("📄 Download full report (MD)", variant="secondary")
1185
+ with gr.Row():
1186
+ scorecard_file = gr.File(label="Scorecard image", interactive=False)
1187
+ report_file = gr.File(label="Full report", interactive=False)
1188
+
1189
+ # Hidden slots — kept so the output tuple's shape matches between
1190
+ # the empty-state and running-state yields.
1191
+ a_timing = gr.Markdown(visible=False)
1192
+ b_timing = gr.Markdown(visible=False)
1193
+ c_timing = gr.Markdown(visible=False)
1194
+ compare_state = gr.State(None)
1195
+
1196
+ with gr.Tab("Raw", visible=False):
1197
+ raw_json = gr.Code(label="full analysis JSON", language="json", lines=24)
1198
+
1199
+ bpm_mode.change(
1200
+ fn=lambda m: (gr.update(visible=m.startswith("Auto")),
1201
+ gr.update(visible=m.startswith("Manual"))),
1202
+ inputs=[bpm_mode],
1203
+ outputs=[genre_grp, manual_grp],
1204
+ )
1205
+
1206
+ # Snap picks of disabled "(soon)" models back to SA3. SA3 and
1207
+ # local-server are the two real options; everything else 500s or
1208
+ # is text-only (elevenmusic).
1209
+ _ALLOWED_GEN_MODELS = {sa3.DEFAULT_MODEL, "local-server"}
1210
+ def _enforce_allowed_gen(v):
1211
+ return v if v in _ALLOWED_GEN_MODELS else sa3.DEFAULT_MODEL
1212
+ gen_model.change(fn=_enforce_allowed_gen, inputs=[gen_model], outputs=[gen_model])
1213
+
1214
+ run_btn.click(
1215
+ fn=run_brief,
1216
+ inputs=[audio_in, bpm_mode, genre, bpm_num, model_dd, api_key_state],
1217
+ outputs=[
1218
+ paragraph, caption, wave,
1219
+ metrics_html,
1220
+ data_tbl, sections_tbl, tags_tbl,
1221
+ errors_box, timings_box,
1222
+ sa3_var, sa3_match, clip_plan, mix_chain,
1223
+ raw_json, last_run,
1224
+ ],
1225
+ api_name="analyze",
1226
+ )
1227
+
1228
+ compare_btn.click(
1229
+ fn=run_compare,
1230
+ inputs=[last_run, model_a_dd, model_b_dd, model_c_dd, api_key_state],
1231
+ outputs=[
1232
+ a_header, a_brief,
1233
+ b_header, b_brief,
1234
+ c_header, c_brief,
1235
+ comparison_table,
1236
+ a_timing, b_timing, c_timing,
1237
+ compare_state,
1238
+ ],
1239
+ api_name="compare",
1240
+ )
1241
+
1242
+ scorecard_btn.click(
1243
+ fn=export_scorecard,
1244
+ inputs=[compare_state],
1245
+ outputs=[scorecard_file],
1246
+ api_name="export_scorecard",
1247
+ )
1248
+ report_btn.click(
1249
+ fn=export_report,
1250
+ inputs=[compare_state],
1251
+ outputs=[report_file],
1252
+ api_name="export_report",
1253
+ )
1254
+
1255
+ # Connect: pure client-side navigation to enter.pollinations.ai —
1256
+ # no popup, no orphan tab. Same-tab redirect; Pollinations sends the
1257
+ # user back to this URL with #api_key=… in the fragment, picked up
1258
+ # by the demo.load handler below. Pattern matches abv1 (see
1259
+ # standalone/public/index.html:4946-5047).
1260
+ # ── Generate-tab wiring ──────────────────────────────────────────
1261
+ # JS prelude: if the user picked "local-server", fetch their
1262
+ # localhost gen endpoint from the browser BEFORE calling the
1263
+ # Python handler, base64-encode the audio bytes, and pass them
1264
+ # through as the last input. Otherwise pass through unchanged
1265
+ # and let generate_sa3 take the Pollinations path.
1266
+ gen_click = gen_btn.click(
1267
+ fn=generate_sa3,
1268
+ inputs=[gen_prompt, gen_model, gen_duration,
1269
+ api_key_state, local_gen_url, local_gen_b64],
1270
+ outputs=[gen_status, gen_audio, crate_picker, crate_preview, crate_meta, crate_header],
1271
+ js="""
1272
+ async (prompt, model, duration, apiKey, localUrl, _ignored_b64) => {
1273
+ if (model !== 'local-server') {
1274
+ return [prompt, model, duration, apiKey, localUrl, ''];
1275
+ }
1276
+ const base = (localUrl || 'http://localhost:7864').replace(/\\/$/,'');
1277
+ const url = base + '/generate';
1278
+ try {
1279
+ const r = await fetch(url, {
1280
+ method: 'POST',
1281
+ headers: {'Content-Type': 'application/json'},
1282
+ body: JSON.stringify({
1283
+ prompt: prompt || '',
1284
+ duration: parseInt(duration)||15,
1285
+ }),
1286
+ });
1287
+ if (!r.ok) {
1288
+ console.error('local gen HTTP', r.status);
1289
+ return [prompt, model, duration, apiKey, localUrl, ''];
1290
+ }
1291
+ const buf = await r.arrayBuffer();
1292
+ const bytes = new Uint8Array(buf);
1293
+ let bin = '';
1294
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
1295
+ const b64 = btoa(bin);
1296
+ return [prompt, model, duration, apiKey, localUrl, b64];
1297
+ } catch (e) {
1298
+ console.error('local gen fetch failed:', e);
1299
+ return [prompt, model, duration, apiKey, localUrl, ''];
1300
+ }
1301
+ }
1302
+ """,
1303
+ api_name="sa3_generate",
1304
+ )
1305
+ # Tick the session-spend pill (~0.04 ◆ per SA3 call). Skip when the
1306
+ # user picked the local-server model — that path never bills Pollinations.
1307
+ gen_click.then(
1308
+ fn=lambda curr, model, key: tick_session_spend(
1309
+ curr, 0.0 if model == "local-server" else SA3_COST_PER_CALL, key,
1310
+ ),
1311
+ inputs=[session_spend_state, gen_model, api_key_state],
1312
+ outputs=[topbar, session_spend_state],
1313
+ )
1314
+ refresh_crate_btn.click(
1315
+ fn=refresh_crate,
1316
+ inputs=[],
1317
+ outputs=[crate_picker, crate_header],
1318
+ )
1319
+ crate_picker.change(
1320
+ fn=select_tile,
1321
+ inputs=[crate_picker],
1322
+ outputs=[crate_preview, crate_meta],
1323
+ )
1324
+ delete_tile_btn.click(
1325
+ fn=delete_tile,
1326
+ inputs=[crate_picker],
1327
+ outputs=[crate_picker, crate_preview, crate_meta, crate_header],
1328
+ )
1329
+ # Bridge to existing Compare flow: writes the tile's audio path
1330
+ # into the top-level audio_in component, surfaces a status nudge,
1331
+ # then auto-runs analyze. The Analysis tab is where the brief lands.
1332
+ use_for_analysis_btn.click(
1333
+ fn=send_tile_to_analysis,
1334
+ inputs=[crate_picker],
1335
+ outputs=[audio_in, gen_status],
1336
+ ).then(
1337
+ fn=run_brief,
1338
+ inputs=[audio_in, bpm_mode, genre, bpm_num, model_dd, api_key_state],
1339
+ outputs=[
1340
+ paragraph, caption, wave,
1341
+ metrics_html,
1342
+ data_tbl, sections_tbl, tags_tbl,
1343
+ errors_box, timings_box,
1344
+ sa3_var, sa3_match, clip_plan, mix_chain,
1345
+ raw_json, last_run,
1346
+ ],
1347
+ )
1348
+
1349
+ # Regenerate 5 variants from the (possibly edited) derived prompt.
1350
+ # Streams output: each audio slot fills in as the corresponding SA3
1351
+ # call completes. Variants also get added to the crate with a
1352
+ # parent_id back to the source tile when one's known.
1353
+ regen_chain = regen_btn.click(
1354
+ fn=regenerate_variants,
1355
+ inputs=[sa3_var, last_run, api_key_state],
1356
+ outputs=[var1, var2, var3, var4, var5, regen_status],
1357
+ api_name="regenerate_variants",
1358
+ ).then(
1359
+ # After all 5 land, refresh the crate dropdown so the new tiles
1360
+ # appear under it.
1361
+ fn=refresh_crate,
1362
+ inputs=[],
1363
+ outputs=[crate_picker, crate_header],
1364
+ )
1365
+ # 5 variants × SA3 flat cost. Honest about cost — sequential gen.
1366
+ regen_chain.then(
1367
+ fn=lambda curr, key: tick_session_spend(curr, SA3_COST_PER_CALL * 5, key),
1368
+ inputs=[session_spend_state, api_key_state],
1369
+ outputs=[topbar, session_spend_state],
1370
+ )
1371
+
1372
+ # No connect_btn anymore — the pollen pill itself has the inline
1373
+ # window.location redirect to enter.pollinations.ai (see theme.py
1374
+ # pollen_pill_html). disconnect_btn is hidden, JS-triggered.
1375
+ disconnect_btn.click(
1376
+ fn=disconnect_wallet,
1377
+ inputs=[],
1378
+ outputs=[topbar, api_key_state, session_spend_state],
1379
+ api_name="disconnect_wallet",
1380
+ )
1381
+
1382
+ # On page load, check the URL fragment for #api_key=… that the
1383
+ # Pollinations redirect leaves us. The js callback strips the
1384
+ # fragment from the URL bar and returns the key as the input arg.
1385
+ # On HF Spaces, the key flows into api_key_state (per-session, in
1386
+ # memory only). On desktop it ALSO gets written to disk via wallet.
1387
+ demo.load(
1388
+ fn=save_key_from_fragment,
1389
+ inputs=[_fragment_key],
1390
+ outputs=[topbar, api_key_state, session_spend_state],
1391
+ js="""
1392
+ () => {
1393
+ const hash = window.location.hash || '';
1394
+ const m = hash.match(/api_key=([^&]+)/);
1395
+ if (m) {
1396
+ history.replaceState(null, '', window.location.pathname + window.location.search);
1397
+ return m[1];
1398
+ }
1399
+ return '';
1400
+ }
1401
+ """,
1402
+ )
1403
+
1404
+ return demo
1405
+
1406
+
1407
+ def main() -> int:
1408
+ import os
1409
+ demo = build_ui()
1410
+ demo.queue(default_concurrency_limit=4)
1411
+ # Honour PORT / GRADIO_SERVER_PORT so the harness's autoPort assignment
1412
+ # actually takes effect (Gradio does NOT read PORT on its own).
1413
+ port_env = os.environ.get("GRADIO_SERVER_PORT") or os.environ.get("PORT")
1414
+ port = int(port_env) if port_env else None
1415
+ # Allow Gradio to serve audio from the crate dir. Without this, every
1416
+ # gen → audio component returns InvalidPathError because /tmp/audio-brief-crate
1417
+ # is outside cwd and the platform temp dir.
1418
+ demo.launch(
1419
+ theme=THEME,
1420
+ css=CUSTOM_CSS,
1421
+ server_port=port,
1422
+ allowed_paths=[str(crate.CRATE_DIR)],
1423
+ )
1424
+ return 0
1425
+
1426
+
1427
+ if __name__ == "__main__":
1428
+ sys.exit(main())
crate.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crate — a session-scoped file store for SA3-generated audio + metadata.
2
+
3
+ A crate is a list of `Tile`s. Each tile pairs an audio file (the gen output)
4
+ with the prompt that produced it and the optional parent tile it descended
5
+ from. The Generate → Analyse → Blend → Re-gen loop walks this graph.
6
+
7
+ Storage: `/tmp/audio-brief-crate/{audio.mp3, metadata.json}`. Survives
8
+ in-process refreshes; cleared on reboot or by the user. We keep it flat
9
+ and on /tmp on purpose: MVP2 PoC, not a permanent library.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import secrets
15
+ import time
16
+ from dataclasses import asdict, dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ CRATE_DIR = Path("/tmp/audio-brief-crate")
21
+ CRATE_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+
24
+ @dataclass
25
+ class Tile:
26
+ id: str # short hex id, e.g. "a3f9c2"
27
+ audio_path: str # absolute path to mp3 (or wav)
28
+ source_prompt: str # the SA3 prompt that produced this tile
29
+ parent_id: str | None = None # crate id of the tile this descended from
30
+ model: str = "" # which SA3 model variant
31
+ duration_s: float = 0.0
32
+ created_at: int = field(default_factory=lambda: int(time.time()))
33
+ label: str = "" # short auto-derived display name
34
+
35
+ @property
36
+ def meta_path(self) -> Path:
37
+ return CRATE_DIR / f"{self.id}.json"
38
+
39
+ def save(self) -> None:
40
+ self.meta_path.write_text(json.dumps(asdict(self), indent=2))
41
+
42
+ @classmethod
43
+ def from_json(cls, path: Path) -> "Tile":
44
+ return cls(**json.loads(path.read_text()))
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Label derivation — first 4 meaningful words of the prompt
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _STOP = {"a", "an", "the", "and", "or", "with", "of", "in", "on", "at", "for",
52
+ "to", "from", "by"}
53
+
54
+
55
+ def _derive_label(prompt: str) -> str:
56
+ words = [w for w in prompt.lower().split() if w.strip(",.;:!?") not in _STOP]
57
+ # Take first 4 substantive words, max 30 chars total.
58
+ short = " ".join(words[:4])
59
+ return (short[:30].strip(",. ") or "untitled")
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # CRUD
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def new_id() -> str:
67
+ """Random 6-hex-char id — short enough to read in UI, long enough to
68
+ avoid collision across a few hundred tiles per session."""
69
+ return secrets.token_hex(3)
70
+
71
+
72
+ def add_tile(
73
+ audio_path: str,
74
+ source_prompt: str,
75
+ *,
76
+ parent_id: str | None = None,
77
+ model: str = "",
78
+ duration_s: float = 0.0,
79
+ ) -> Tile:
80
+ tid = new_id()
81
+ tile = Tile(
82
+ id=tid,
83
+ audio_path=audio_path,
84
+ source_prompt=source_prompt,
85
+ parent_id=parent_id,
86
+ model=model,
87
+ duration_s=duration_s,
88
+ label=_derive_label(source_prompt),
89
+ )
90
+ tile.save()
91
+ return tile
92
+
93
+
94
+ def list_tiles() -> list[Tile]:
95
+ """Return all tiles, newest first."""
96
+ tiles: list[Tile] = []
97
+ for p in CRATE_DIR.glob("*.json"):
98
+ try:
99
+ tiles.append(Tile.from_json(p))
100
+ except Exception:
101
+ continue
102
+ tiles.sort(key=lambda t: -t.created_at)
103
+ return tiles
104
+
105
+
106
+ def get_tile(tile_id: str) -> Tile | None:
107
+ p = CRATE_DIR / f"{tile_id}.json"
108
+ if not p.exists():
109
+ return None
110
+ try:
111
+ return Tile.from_json(p)
112
+ except Exception:
113
+ return None
114
+
115
+
116
+ def delete_tile(tile_id: str) -> bool:
117
+ """Remove the metadata JSON and the audio file. Returns True if removed."""
118
+ tile = get_tile(tile_id)
119
+ if not tile:
120
+ return False
121
+ try:
122
+ Path(tile.audio_path).unlink(missing_ok=True)
123
+ except Exception:
124
+ pass
125
+ try:
126
+ tile.meta_path.unlink()
127
+ return True
128
+ except Exception:
129
+ return False
130
+
131
+
132
+ def clear_all() -> int:
133
+ """Remove every tile in the crate. Returns count removed."""
134
+ n = 0
135
+ for p in CRATE_DIR.glob("*.json"):
136
+ try:
137
+ tile = Tile.from_json(p)
138
+ Path(tile.audio_path).unlink(missing_ok=True)
139
+ p.unlink()
140
+ n += 1
141
+ except Exception:
142
+ continue
143
+ return n
144
+
145
+
146
+ def tile_choices() -> list[tuple[str, str]]:
147
+ """For a Gradio Dropdown: returns [(display_label, tile_id), ...].
148
+ Includes lineage marker when a tile descends from another."""
149
+ out: list[tuple[str, str]] = []
150
+ tiles = list_tiles()
151
+ id_to_label = {t.id: t.label for t in tiles}
152
+ for t in tiles:
153
+ lineage = ""
154
+ if t.parent_id and t.parent_id in id_to_label:
155
+ lineage = f" ← {id_to_label[t.parent_id]}"
156
+ display = f"[{t.id}] {t.label}{lineage}"
157
+ out.append((display, t.id))
158
+ return out
design/Audio Brief v1 (three directions).dc.html ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <script src="./support.js"></script>
7
+ </head>
8
+ <body>
9
+ <x-dc>
10
+ <helmet>
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
13
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;600&amp;display=swap" rel="stylesheet">
14
+ <meta name="design_doc_mode" content="canvas">
15
+ <style>
16
+ *{box-sizing:border-box;}
17
+ body{margin:0;font-family:'Space Grotesk',sans-serif;}
18
+ ::-webkit-scrollbar{width:8px;height:8px;}
19
+ ::-webkit-scrollbar-thumb{background:#2A2F38;border-radius:4px;}
20
+ ::-webkit-scrollbar-track{background:transparent;}
21
+ </style>
22
+ </helmet>
23
+
24
+ <!-- ============ INTRO / LEGEND ============ -->
25
+ <div style="position:absolute;left:0px;top:0px;width:940px;">
26
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">audio·brief — three layout directions</div>
27
+ <div style="background:#0F1115;border:1px solid #20242C;border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.22);padding:34px 38px;color:#F2EFE9;">
28
+ <div style="display:flex;align-items:flex-end;gap:14px;">
29
+ <div style="display:flex;align-items:flex-end;gap:3px;height:34px;">
30
+ <div style="width:5px;height:13px;background:#FF6A3D;border-radius:1px;"></div>
31
+ <div style="width:5px;height:30px;background:#FF6A3D;border-radius:1px;"></div>
32
+ <div style="width:5px;height:20px;background:#5BE0C8;border-radius:1px;"></div>
33
+ <div style="width:5px;height:26px;background:#5BE0C8;border-radius:1px;"></div>
34
+ </div>
35
+ <div style="font:600 34px 'Space Grotesk';letter-spacing:-.02em;line-height:1;">audio<span style="color:#5BE0C8;">·</span>brief</div>
36
+ </div>
37
+ <div style="font:400 16px/1.5 'Space Grotesk';color:#99A0AB;max-width:620px;margin-top:18px;">A workbench for recreating audio consistently with AI. Generate a take, get a measured read of what makes it tick, then re-prompt for five variations that feel like cousins of the source.</div>
38
+
39
+ <div style="display:flex;gap:28px;margin-top:26px;padding-top:22px;border-top:1px solid #20242C;">
40
+ <div style="display:flex;align-items:center;gap:10px;">
41
+ <div style="width:14px;height:14px;border-radius:4px;background:#FF6A3D;"></div>
42
+ <div><div style="font:600 13px 'Space Grotesk';">Coral — generative</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">prompt · gen · variants</div></div>
43
+ </div>
44
+ <div style="display:flex;align-items:center;gap:10px;">
45
+ <div style="width:14px;height:14px;border-radius:4px;background:#5BE0C8;"></div>
46
+ <div><div style="font:600 13px 'Space Grotesk';">Mint — measured</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">bpm · key · sections · loudness</div></div>
47
+ </div>
48
+ <div style="display:flex;align-items:center;gap:10px;">
49
+ <div style="width:14px;height:14px;border-radius:4px;background:#FFC24B;"></div>
50
+ <div><div style="font:600 13px 'Space Grotesk';">Amber — your anchor</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">the favourited take we blend from</div></div>
51
+ </div>
52
+ </div>
53
+
54
+ <div style="display:flex;gap:14px;margin-top:24px;">
55
+ <div style="flex:1;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:14px 16px;">
56
+ <div style="font:600 12px 'JetBrains Mono';color:#FF8C5A;">A · STUDIO RAIL</div>
57
+ <div style="font:400 13px/1.45 'Space Grotesk';color:#99A0AB;margin-top:6px;">Crate always-on as a left rail. Tabs for the work. Best for living in your library.</div>
58
+ </div>
59
+ <div style="flex:1;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:14px 16px;">
60
+ <div style="font:600 12px 'JetBrains Mono';color:#FF8C5A;">B · CRATE DOCK</div>
61
+ <div style="font:400 13px/1.45 'Space Grotesk';color:#99A0AB;margin-top:6px;">Full-width work area, crate as a horizontal dock below — like a DAW clip tray.</div>
62
+ </div>
63
+ <div style="flex:1;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:14px 16px;">
64
+ <div style="font:600 12px 'JetBrains Mono';color:#FF8C5A;">C · LOOP FLOW</div>
65
+ <div style="font:400 13px/1.45 'Space Grotesk';color:#99A0AB;margin-top:6px;">No tabs — the loop is the nav. Family tree + a pipeline you flow through. Compare is a step.</div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- ============ FRAME A — STUDIO RAIL ============ -->
72
+ <div style="position:absolute;left:0px;top:540px;width:1440px;">
73
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">A · Studio Rail — Generate · desktop</div>
74
+ <div data-screen-label="A · Studio Rail / Generate" style="height:900px;background:#0F1115;border:1px solid #20242C;border-radius:14px;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;display:flex;flex-direction:column;">
75
+
76
+ <!-- top bar -->
77
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:56px;border-bottom:1px solid #20242C;background:#121419;flex:none;">
78
+ <div style="display:flex;align-items:center;gap:11px;">
79
+ <div style="display:flex;align-items:flex-end;gap:2px;height:18px;"><div style="width:3px;height:7px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:16px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:10px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:13px;background:#5BE0C8;border-radius:1px;"></div></div>
80
+ <div style="font:600 16px 'Space Grotesk';letter-spacing:-.01em;">audio<span style="color:#5BE0C8;">·</span>brief</div>
81
+ </div>
82
+ <div style="display:flex;gap:4px;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:4px;">
83
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';background:rgba(255,106,61,0.14);color:#FF8C5A;">Generate</div>
84
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Analysis</div>
85
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Compare</div>
86
+ </div>
87
+ <div style="display:flex;align-items:center;gap:9px;">
88
+ <div style="display:flex;align-items:center;gap:7px;background:#171A20;border:1px solid #2A2F38;border-radius:20px;padding:6px 13px;"><span style="color:#FFC24B;font-size:13px;">◆</span><span style="font:600 13px 'JetBrains Mono';color:#F2EFE9;">4.20</span><span style="font:500 10px 'JetBrains Mono';color:#5E6671;">POLLEN</span></div>
89
+ <div style="width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#FF6A3D,#5BE0C8);"></div>
90
+ </div>
91
+ </div>
92
+
93
+ <div style="flex:1;display:flex;min-height:0;">
94
+ <!-- CRATE RAIL -->
95
+ <div style="width:326px;flex:none;border-right:1px solid #20242C;background:#121419;display:flex;flex-direction:column;min-height:0;">
96
+ <div style="padding:18px 18px 14px;flex:none;">
97
+ <div style="display:flex;align-items:center;justify-content:space-between;">
98
+ <div style="display:flex;align-items:baseline;gap:9px;"><div style="font:600 14px 'Space Grotesk';">Crate</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">18 takes</div></div>
99
+ <div style="font:500 11px 'JetBrains Mono';color:#5E6671;">recent ▾</div>
100
+ </div>
101
+ <div style="display:flex;gap:6px;margin-top:13px;">
102
+ <div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:14px;background:rgba(255,106,61,0.14);color:#FF8C5A;">All</div>
103
+ <div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:14px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">★ Faves</div>
104
+ <div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:14px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">SA3</div>
105
+ </div>
106
+ </div>
107
+ <div style="flex:1;overflow-y:auto;padding:0 14px 18px;display:flex;flex-direction:column;gap:8px;min-height:0;">
108
+ <sc-for list="{{ tiles }}" as="tile" hint-placeholder-count="8">
109
+ <div style="margin-left: {{ tile.indent }};position:relative;">
110
+ <sc-if value="{{ tile.isVar }}" hint-placeholder-val="{{ false }}">
111
+ <div style="position:absolute;left:-15px;top:-12px;height:34px;width:13px;border-left:1.5px solid #2A2F38;border-bottom:1.5px solid #2A2F38;border-bottom-left-radius:9px;"></div>
112
+ </sc-if>
113
+ <div style="{{ tile.cardStyle }}">
114
+ <div style="display:flex;align-items:flex-end;gap:1.5px;height:26px;margin-bottom:9px;">
115
+ <sc-for list="{{ tile.bars }}" as="b" hint-placeholder-count="30"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.88;"></div></sc-for>
116
+ </div>
117
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
118
+ <div style="font:600 13px 'Space Grotesk';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ tile.title }}</div>
119
+ <div style="font-size:13px;color: {{ tile.star }};flex:none;">★</div>
120
+ </div>
121
+ <div style="display:flex;align-items:center;gap:8px;margin-top:7px;">
122
+ <span style="font:500 10px 'JetBrains Mono';color:#99A0AB;">{{ tile.bpm }} BPM</span>
123
+ <span style="width:3px;height:3px;border-radius:50%;background:#3A3F47;"></span>
124
+ <span style="font:500 10px 'JetBrains Mono';color:#99A0AB;">{{ tile.key }}</span>
125
+ <span style="width:3px;height:3px;border-radius:50%;background:#3A3F47;"></span>
126
+ <span style="font:500 10px 'JetBrains Mono';color:#99A0AB;">{{ tile.dur }}</span>
127
+ <div style="flex:1;"></div>
128
+ <span style="font:500 9px 'JetBrains Mono';padding:2px 7px;border-radius:5px;background: {{ tile.modelBg }};color: {{ tile.modelColor }};">{{ tile.model }}</span>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </sc-for>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- MAIN — Generate -->
137
+ <div style="flex:1;overflow-y:auto;padding:24px 26px;min-height:0;">
138
+ <!-- composer -->
139
+ <div style="background:#14171D;border:1px solid #232831;border-radius:14px;padding:18px;">
140
+ <div style="display:flex;align-items:center;justify-content:space-between;">
141
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;">PROMPT</div>
142
+ <div style="font:500 11px 'JetBrains Mono';color:#5E6671;">blended from ★ Sub Cathedral · v2</div>
143
+ </div>
144
+ <div style="margin-top:11px;font:400 17px/1.5 'Space Grotesk';color:#F2EFE9;">dark, atmospheric jungle — 160bpm, sub-heavy, lo-fi tape hiss, <span style="color:#5BE0C8;">18s sustained core then stutter outro</span><span style="display:inline-block;width:2px;height:18px;background:#FF6A3D;vertical-align:-3px;margin-left:2px;"></span></div>
145
+ <div style="display:flex;align-items:center;gap:9px;margin-top:18px;">
146
+ <div style="display:flex;align-items:center;gap:7px;background:#171A20;border:1px solid #2A2F38;border-radius:8px;padding:8px 12px;font:500 12px 'JetBrains Mono';color:#C9CDD4;">SA3 <span style="color:#5E6671;">▾</span></div>
147
+ <div style="display:flex;align-items:center;gap:7px;background:#171A20;border:1px solid #2A2F38;border-radius:8px;padding:8px 12px;font:500 12px 'JetBrains Mono';color:#C9CDD4;">0:48 <span style="color:#5E6671;">▾</span></div>
148
+ <div style="display:flex;align-items:center;gap:7px;background:#171A20;border:1px solid #2A2F38;border-radius:8px;padding:8px 12px;font:500 12px 'JetBrains Mono';color:#C9CDD4;">5 variants <span style="color:#5E6671;">▾</span></div>
149
+ <div style="flex:1;"></div>
150
+ <sc-if value="{{ showCost }}" hint-placeholder-val="{{ true }}"><div style="font:500 12px 'JetBrains Mono';color:#5E6671;">~0.20 <span style="color:#FFC24B;">◆</span></div></sc-if>
151
+ <div style="display:flex;align-items:center;gap:8px;background:#FF6A3D;color:#140B07;border-radius:9px;padding:10px 20px;font:600 13px 'Space Grotesk';">Generate ▸</div>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- batch header -->
156
+ <div style="display:flex;align-items:center;justify-content:space-between;margin:26px 2px 14px;">
157
+ <div style="display:flex;align-items:center;gap:11px;">
158
+ <div style="font:600 15px 'Space Grotesk';">Latest batch</div>
159
+ <div style="font:500 11px 'JetBrains Mono';padding:3px 9px;border-radius:6px;background:rgba(91,224,200,0.12);color:#5BE0C8;">94% avg match</div>
160
+ </div>
161
+ <div style="font:500 11px 'JetBrains Mono';color:#5E6671;">5 variants · from ★ v2 · 2m ago</div>
162
+ </div>
163
+
164
+ <!-- 5 variant cards -->
165
+ <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;">
166
+ <sc-for list="{{ batch }}" as="v" hint-placeholder-count="5">
167
+ <div style="{{ v.cardStyle }}">
168
+ <div style="display:flex;align-items:center;justify-content:space-between;">
169
+ <div style="font:600 13px 'Space Grotesk';">{{ v.lbl }}</div>
170
+ <div style="font-size:13px;color: {{ v.star }};">★</div>
171
+ </div>
172
+ <div style="display:flex;align-items:flex-end;gap:1.5px;height:40px;margin:12px 0;">
173
+ <sc-for list="{{ v.bars }}" as="b" hint-placeholder-count="48"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.9;"></div></sc-for>
174
+ </div>
175
+ <div style="display:flex;align-items:center;gap:10px;">
176
+ <div style="width:26px;height:26px;border-radius:50%;border:1.5px solid #FF6A3D;color:#FF6A3D;display:flex;align-items:center;justify-content:center;font-size:9px;flex:none;">▶</div>
177
+ <div style="flex:1;height:3px;border-radius:2px;background:#262B34;position:relative;"><div style="position:absolute;left:0;top:0;bottom:0;width:30%;background:#FF6A3D;border-radius:2px;"></div></div>
178
+ </div>
179
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-top:12px;">
180
+ <div style="font:500 10px 'JetBrains Mono';color:#99A0AB;">{{ v.bpm }} · {{ v.key }}</div>
181
+ <div style="font:600 10px 'JetBrains Mono';color: {{ v.matchColor }};">{{ v.matchPct }}</div>
182
+ </div>
183
+ <div style="margin-top:11px;text-align:center;font:600 11px 'JetBrains Mono';color:#C9CDD4;border:1px solid #2A2F38;border-radius:7px;padding:7px;">Use ▸</div>
184
+ </div>
185
+ </sc-for>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- ============ FRAME B — CRATE DOCK ============ -->
193
+ <div style="position:absolute;left:1600px;top:540px;width:1440px;">
194
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">B · Crate Dock — Analysis · desktop</div>
195
+ <div data-screen-label="B · Crate Dock / Analysis" style="height:900px;background:#0F1115;border:1px solid #20242C;border-radius:14px;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;display:flex;flex-direction:column;">
196
+
197
+ <!-- top bar -->
198
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:56px;border-bottom:1px solid #20242C;background:#121419;flex:none;">
199
+ <div style="display:flex;align-items:center;gap:11px;">
200
+ <div style="display:flex;align-items:flex-end;gap:2px;height:18px;"><div style="width:3px;height:7px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:16px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:10px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:13px;background:#5BE0C8;border-radius:1px;"></div></div>
201
+ <div style="font:600 16px 'Space Grotesk';letter-spacing:-.01em;">audio<span style="color:#5BE0C8;">·</span>brief</div>
202
+ </div>
203
+ <div style="display:flex;gap:4px;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:4px;">
204
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Generate</div>
205
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';background:rgba(91,224,200,0.14);color:#5BE0C8;">Analyse</div>
206
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Compare</div>
207
+ </div>
208
+ <div style="display:flex;align-items:center;gap:9px;">
209
+ <div style="display:flex;align-items:center;gap:7px;background:#171A20;border:1px solid #2A2F38;border-radius:20px;padding:6px 13px;"><span style="color:#FFC24B;font-size:13px;">◆</span><span style="font:600 13px 'JetBrains Mono';color:#F2EFE9;">4.20</span><span style="font:500 10px 'JetBrains Mono';color:#5E6671;">POLLEN</span></div>
210
+ <div style="width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#FF6A3D,#5BE0C8);"></div>
211
+ </div>
212
+ </div>
213
+
214
+ <!-- work area -->
215
+ <div style="flex:1;display:flex;gap:22px;padding:22px 24px;min-height:0;">
216
+ <!-- left: waveform + measured -->
217
+ <div style="flex:1.55;display:flex;flex-direction:column;min-width:0;">
218
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
219
+ <div style="display:flex;align-items:center;gap:11px;"><div style="font:600 16px 'Space Grotesk';">Sub Cathedral · v2</div><div style="font:500 11px 'JetBrains Mono';padding:3px 8px;border-radius:6px;background:rgba(255,194,75,0.12);color:#FFC24B;">★ anchor</div></div>
220
+ <div style="display:flex;align-items:center;gap:10px;"><div style="width:30px;height:30px;border-radius:50%;border:1.5px solid #5BE0C8;color:#5BE0C8;display:flex;align-items:center;justify-content:center;font-size:11px;">▶</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">analysed in 41s · local</div></div>
221
+ </div>
222
+
223
+ <!-- waveform -->
224
+ <div style="background:#121419;border:1px solid #232831;border-radius:14px;padding:18px;">
225
+ <div style="position:relative;">
226
+ <div style="display:flex;align-items:flex-end;gap:1px;height:148px;">
227
+ <sc-for list="{{ bigBars }}" as="b" hint-placeholder-count="128"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.9;"></div></sc-for>
228
+ </div>
229
+ <div style="position:absolute;left:43%;top:-4px;bottom:-4px;width:2px;background:#F2EFE9;opacity:.55;"></div>
230
+ <div style="position:absolute;left:43%;top:-9px;width:9px;height:9px;border-radius:50%;background:#F2EFE9;transform:translateX(-50%);"></div>
231
+ </div>
232
+ <div style="display:flex;gap:5px;margin-top:14px;">
233
+ <sc-for list="{{ secs }}" as="s" hint-placeholder-count="4">
234
+ <div style="flex: {{ s.grow }};border-top:2px solid {{ s.color }};padding-top:8px;">
235
+ <div style="font:600 12px 'Space Grotesk';">{{ s.label }}</div>
236
+ <div style="font:500 10px 'JetBrains Mono';color:#5E6671;margin-top:2px;">{{ s.time }}</div>
237
+ </div>
238
+ </sc-for>
239
+ </div>
240
+ </div>
241
+
242
+ <!-- measured stat row -->
243
+ <div style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-top:14px;">
244
+ <sc-for list="{{ measured }}" as="m" hint-placeholder-count="6">
245
+ <div style="background:#121419;border:1px solid #232831;border-radius:10px;padding:12px 13px;">
246
+ <div style="font:500 10px 'JetBrains Mono';letter-spacing:.1em;color:#5E6671;">{{ m.k }}</div>
247
+ <div style="font:600 21px 'JetBrains Mono';color:#F2EFE9;margin-top:8px;">{{ m.v }}</div>
248
+ </div>
249
+ </sc-for>
250
+ </div>
251
+ </div>
252
+
253
+ <!-- right: the read + derived prompt -->
254
+ <div style="flex:1;display:flex;flex-direction:column;gap:14px;min-width:0;">
255
+ <div style="background:#121419;border:1px solid #232831;border-radius:14px;padding:18px;flex:none;">
256
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5BE0C8;">THE READ — MEASURED + LLM</div>
257
+ <div style="font:400 14px/1.6 'Space Grotesk';color:#C9CDD4;margin-top:12px;">A brooding jungle cut at <span style="color:#F2EFE9;">162 BPM in F minor</span>. Sparse mint-lit intro gives way to a sub-heavy build, then an <span style="color:#FF8C5A;">18-second sustained core</span> driven by a compressed breakbeat. Tails off into three stutter outros — gritty, tape-saturated, lo-fi. Loud and dense at <span style="color:#F2EFE9;">−9.2 LUFS</span> with controlled dynamics.</div>
258
+ </div>
259
+
260
+ <div style="background:#14171D;border:1px solid rgba(255,106,61,0.4);border-radius:14px;padding:18px;flex:1;display:flex;flex-direction:column;min-height:0;">
261
+ <div style="display:flex;align-items:center;justify-content:space-between;">
262
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#FF8C5A;">DERIVED PROMPT</div>
263
+ <div style="font:500 10px 'JetBrains Mono';color:#5E6671;">blended · editable</div>
264
+ </div>
265
+ <div style="margin-top:12px;font:400 14px/1.55 'Space Grotesk';color:#F2EFE9;background:#0F1115;border:1px solid #232831;border-radius:10px;padding:13px;flex:1;">dark atmospheric jungle, sub-heavy, lo-fi tape hiss, gritty compressed breakbeat — 162bpm F min, 18s sustained core then 3 stutter outros<span style="display:inline-block;width:2px;height:16px;background:#FF6A3D;vertical-align:-2px;margin-left:1px;"></span></div>
266
+ <div style="display:flex;flex-wrap:wrap;gap:7px;margin-top:13px;">
267
+ <div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:14px;background:#171A20;border:1px solid #2A2F38;color:#C9CDD4;">+ more lo-fi</div>
268
+ <div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:14px;background:#171A20;border:1px solid #2A2F38;color:#C9CDD4;">+ brighter</div>
269
+ <div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:14px;background:#171A20;border:1px solid #2A2F38;color:#C9CDD4;">+ reverb</div>
270
+ <div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:14px;background:#171A20;border:1px solid #2A2F38;color:#C9CDD4;">− shorten</div>
271
+ </div>
272
+ <div style="display:flex;align-items:center;gap:10px;margin-top:15px;">
273
+ <div style="flex:1;display:flex;align-items:center;justify-content:center;gap:8px;background:#FF6A3D;color:#140B07;border-radius:9px;padding:11px;font:600 13px 'Space Grotesk';">Regenerate · 5 variants ▸</div>
274
+ <sc-if value="{{ showCost }}" hint-placeholder-val="{{ true }}"><div style="font:500 12px 'JetBrains Mono';color:#5E6671;white-space:nowrap;">~0.20 <span style="color:#FFC24B;">◆</span></div></sc-if>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ </div>
279
+
280
+ <!-- CRATE DOCK -->
281
+ <div style="flex:none;height:158px;border-top:1px solid #20242C;background:#121419;padding:13px 18px;display:flex;flex-direction:column;">
282
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;">
283
+ <div style="display:flex;align-items:baseline;gap:9px;"><div style="font:600 13px 'Space Grotesk';">Crate</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">18 takes · drag to dock</div></div>
284
+ <div style="display:flex;gap:6px;"><div style="font:500 11px 'JetBrains Mono';padding:4px 10px;border-radius:13px;background:rgba(91,224,200,0.14);color:#5BE0C8;">All</div><div style="font:500 11px 'JetBrains Mono';padding:4px 10px;border-radius:13px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">★ Faves</div></div>
285
+ </div>
286
+ <div style="flex:1;display:flex;gap:11px;overflow-x:auto;padding-bottom:4px;">
287
+ <sc-for list="{{ tiles }}" as="tile" hint-placeholder-count="8">
288
+ <div style="{{ tile.dockStyle }}">
289
+ <div style="display:flex;align-items:flex-end;gap:1.5px;height:22px;margin-bottom:8px;">
290
+ <sc-for list="{{ tile.bars }}" as="b" hint-placeholder-count="30"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.85;"></div></sc-for>
291
+ </div>
292
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:6px;"><div style="font:600 12px 'Space Grotesk';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ tile.title }}</div><div style="font-size:11px;color: {{ tile.star }};flex:none;">★</div></div>
293
+ <div style="font:500 10px 'JetBrains Mono';color:#99A0AB;margin-top:5px;">{{ tile.bpm }} · {{ tile.key }} · {{ tile.dur }}</div>
294
+ </div>
295
+ </sc-for>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+
301
+ <!-- ============ FRAME C — LOOP FLOW ============ -->
302
+ <div style="position:absolute;left:3200px;top:540px;width:1440px;">
303
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">C · Loop Flow — no tabs · desktop</div>
304
+ <div data-screen-label="C · Loop Flow" style="height:900px;background:#0F1115;border:1px solid #20242C;border-radius:14px;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;display:flex;flex-direction:column;">
305
+
306
+ <!-- top bar (no tabs) -->
307
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:56px;border-bottom:1px solid #20242C;background:#121419;flex:none;">
308
+ <div style="display:flex;align-items:center;gap:11px;">
309
+ <div style="display:flex;align-items:flex-end;gap:2px;height:18px;"><div style="width:3px;height:7px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:16px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:10px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:13px;background:#5BE0C8;border-radius:1px;"></div></div>
310
+ <div style="font:600 16px 'Space Grotesk';letter-spacing:-.01em;">audio<span style="color:#5BE0C8;">·</span>brief</div>
311
+ <div style="font:500 11px 'JetBrains Mono';color:#5E6671;margin-left:6px;padding:3px 9px;border-radius:6px;background:#171A20;border:1px solid #20242C;">Session · Sub Cathedral</div>
312
+ </div>
313
+ <div style="display:flex;gap:4px;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:4px;">
314
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';background:rgba(255,106,61,0.14);color:#FF8C5A;">Loop</div>
315
+ <div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Library</div>
316
+ </div>
317
+ <div style="display:flex;align-items:center;gap:9px;">
318
+ <div style="display:flex;align-items:center;gap:7px;background:#171A20;border:1px solid #2A2F38;border-radius:20px;padding:6px 13px;"><span style="color:#FFC24B;font-size:13px;">◆</span><span style="font:600 13px 'JetBrains Mono';color:#F2EFE9;">4.20</span><span style="font:500 10px 'JetBrains Mono';color:#5E6671;">POLLEN</span></div>
319
+ <div style="width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#FF6A3D,#5BE0C8);"></div>
320
+ </div>
321
+ </div>
322
+
323
+ <div style="flex:1;display:flex;min-height:0;">
324
+ <!-- family tree -->
325
+ <div style="width:286px;flex:none;border-right:1px solid #20242C;background:#121419;padding:18px 16px;overflow-y:auto;">
326
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:14px;">FAMILY TREE</div>
327
+ <sc-for list="{{ tiles }}" as="tile" hint-placeholder-count="8">
328
+ <div style="margin-left: {{ tile.indent }};position:relative;margin-bottom:7px;">
329
+ <sc-if value="{{ tile.isVar }}" hint-placeholder-val="{{ false }}"><div style="position:absolute;left:-14px;top:-9px;height:26px;width:12px;border-left:1.5px solid #2A2F38;border-bottom:1.5px solid #2A2F38;border-bottom-left-radius:8px;"></div></sc-if>
330
+ <div style="{{ tile.treeStyle }}">
331
+ <div style="width:7px;height:7px;border-radius:50%;background: {{ tile.barColor }};flex:none;"></div>
332
+ <div style="font:600 12px 'Space Grotesk';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">{{ tile.title }}</div>
333
+ <div style="font:500 10px 'JetBrains Mono';color:#5E6671;flex:none;">{{ tile.bpm }}</div>
334
+ <div style="font-size:11px;color: {{ tile.star }};flex:none;">★</div>
335
+ </div>
336
+ </div>
337
+ </sc-for>
338
+ </div>
339
+
340
+ <!-- loop pipeline -->
341
+ <div style="flex:1;overflow-y:auto;padding:24px 26px;min-height:0;">
342
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:16px;">THE LOOP</div>
343
+ <div style="display:flex;align-items:stretch;gap:0;">
344
+ <!-- stage 1 -->
345
+ <div style="flex:1;background:#14171D;border:1px solid rgba(255,106,61,0.35);border-radius:12px;padding:15px;">
346
+ <div style="display:flex;align-items:center;gap:8px;"><div style="width:20px;height:20px;border-radius:50%;background:#FF6A3D;color:#140B07;font:600 11px 'JetBrains Mono';display:flex;align-items:center;justify-content:center;">1</div><div style="font:600 12px 'Space Grotesk';color:#FF8C5A;">Prompt</div></div>
347
+ <div style="font:400 12px/1.45 'Space Grotesk';color:#99A0AB;margin-top:9px;">"dark atmospheric jungle, 160bpm"</div>
348
+ </div>
349
+ <div style="display:flex;align-items:center;color:#3A3F47;font-size:18px;padding:0 4px;">→</div>
350
+ <!-- stage 2 -->
351
+ <div style="flex:1;background:#14171D;border:1px solid #232831;border-radius:12px;padding:15px;">
352
+ <div style="display:flex;align-items:center;gap:8px;"><div style="width:20px;height:20px;border-radius:50%;background:#FF6A3D;color:#140B07;font:600 11px 'JetBrains Mono';display:flex;align-items:center;justify-content:center;">2</div><div style="font:600 12px 'Space Grotesk';">Pick take</div></div>
353
+ <div style="display:flex;align-items:flex-end;gap:1.5px;height:24px;margin-top:11px;"><sc-for list="{{ pickBars }}" as="b" hint-placeholder-count="34"><div style="flex:1;height: {{ b.h }};background:#FF6A3D;border-radius:1px;opacity:.85;"></div></sc-for></div>
354
+ <div style="font:500 10px 'JetBrains Mono';color:#99A0AB;margin-top:8px;">★ v2 · 162 · F min</div>
355
+ </div>
356
+ <div style="display:flex;align-items:center;color:#3A3F47;font-size:18px;padding:0 4px;">→</div>
357
+ <!-- stage 3 -->
358
+ <div style="flex:1;background:#14171D;border:1px solid rgba(91,224,200,0.35);border-radius:12px;padding:15px;">
359
+ <div style="display:flex;align-items:center;gap:8px;"><div style="width:20px;height:20px;border-radius:50%;background:#5BE0C8;color:#06201B;font:600 11px 'JetBrains Mono';display:flex;align-items:center;justify-content:center;">3</div><div style="font:600 12px 'Space Grotesk';color:#5BE0C8;">Measure</div></div>
360
+ <div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:11px;"><span style="font:500 10px 'JetBrains Mono';color:#C9CDD4;background:#171A20;border:1px solid #2A2F38;padding:3px 7px;border-radius:5px;">162 BPM</span><span style="font:500 10px 'JetBrains Mono';color:#C9CDD4;background:#171A20;border:1px solid #2A2F38;padding:3px 7px;border-radius:5px;">F min</span><span style="font:500 10px 'JetBrains Mono';color:#C9CDD4;background:#171A20;border:1px solid #2A2F38;padding:3px 7px;border-radius:5px;">4 sec</span><span style="font:500 10px 'JetBrains Mono';color:#C9CDD4;background:#171A20;border:1px solid #2A2F38;padding:3px 7px;border-radius:5px;">−9.2</span></div>
361
+ </div>
362
+ </div>
363
+
364
+ <div style="display:flex;align-items:stretch;gap:0;margin-top:14px;">
365
+ <!-- stage 4 -->
366
+ <div style="flex:1;background:#14171D;border:1px solid rgba(255,106,61,0.35);border-radius:12px;padding:15px;">
367
+ <div style="display:flex;align-items:center;gap:8px;"><div style="width:20px;height:20px;border-radius:50%;background:#FF6A3D;color:#140B07;font:600 11px 'JetBrains Mono';display:flex;align-items:center;justify-content:center;">4</div><div style="font:600 12px 'Space Grotesk';color:#FF8C5A;">Derive prompt</div></div>
368
+ <div style="font:400 12px/1.45 'Space Grotesk';color:#C9CDD4;margin-top:9px;">intent <span style="color:#5BE0C8;">+</span> measured arc → blended, editable</div>
369
+ </div>
370
+ <div style="display:flex;align-items:center;color:#3A3F47;font-size:18px;padding:0 4px;">→</div>
371
+ <!-- stage 5 -->
372
+ <div style="flex:1.6;background:#14171D;border:1px solid #232831;border-radius:12px;padding:15px;">
373
+ <div style="display:flex;align-items:center;justify-content:space-between;"><div style="display:flex;align-items:center;gap:8px;"><div style="width:20px;height:20px;border-radius:50%;background:#FF6A3D;color:#140B07;font:600 11px 'JetBrains Mono';display:flex;align-items:center;justify-content:center;">5</div><div style="font:600 12px 'Space Grotesk';">5 variants</div></div><div style="font:600 10px 'JetBrains Mono';color:#5BE0C8;">94% avg</div></div>
374
+ <div style="display:flex;gap:7px;margin-top:11px;">
375
+ <sc-for list="{{ batch }}" as="v" hint-placeholder-count="5">
376
+ <div style="flex:1;background:#0F1115;border:1px solid #232831;border-radius:8px;padding:8px;">
377
+ <div style="display:flex;align-items:flex-end;gap:1px;height:18px;"><sc-for list="{{ v.barsMini }}" as="b" hint-placeholder-count="14"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;"></div></sc-for></div>
378
+ <div style="font:600 9px 'JetBrains Mono';color: {{ v.matchColor }};margin-top:6px;text-align:center;">{{ v.matchPct }}</div>
379
+ </div>
380
+ </sc-for>
381
+ </div>
382
+ </div>
383
+ </div>
384
+
385
+ <!-- loop back arrow -->
386
+ <div style="display:flex;align-items:center;gap:9px;margin:16px 0 22px;color:#5E6671;font:500 11px 'JetBrains Mono';"><div style="flex:1;height:1px;background:#20242C;"></div><span>↻ favourite a variant to start the next loop</span><div style="flex:1;height:1px;background:#20242C;"></div></div>
387
+
388
+ <!-- validate vs gemini -->
389
+ <div style="background:#121419;border:1px solid #232831;border-radius:14px;padding:18px;">
390
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;">
391
+ <div style="display:flex;align-items:center;gap:10px;"><div style="font:600 14px 'Space Grotesk';">Validate vs Gemini</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;padding:3px 8px;border-radius:6px;background:#171A20;border:1px solid #20242C;">optional step</div></div>
392
+ <div style="display:flex;gap:7px;"><div style="font:600 11px 'JetBrains Mono';color:#C9CDD4;border:1px solid #2A2F38;border-radius:7px;padding:6px 11px;">PNG</div><div style="font:600 11px 'JetBrains Mono';color:#C9CDD4;border:1px solid #2A2F38;border-radius:7px;padding:6px 11px;">Report ↓</div></div>
393
+ </div>
394
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
395
+ <sc-for list="{{ compare }}" as="c" hint-placeholder-count="3">
396
+ <div style="background:#0F1115;border:1px solid #232831;border-radius:11px;padding:14px;">
397
+ <div style="display:flex;align-items:center;justify-content:space-between;"><div style="font:600 13px 'Space Grotesk';color: {{ c.tone }};">{{ c.name }}</div><div style="font:600 16px 'JetBrains Mono';">{{ c.score }}</div></div>
398
+ <div style="font:500 10px 'JetBrains Mono';color:#5E6671;margin-top:3px;">{{ c.sub }}</div>
399
+ <div style="height:5px;border-radius:3px;background:#1E222A;margin-top:11px;position:relative;overflow:hidden;"><div style="position:absolute;left:0;top:0;bottom:0;width: {{ c.scorePct }};background: {{ c.tone }};border-radius:3px;"></div></div>
400
+ <div style="font:400 11px/1.45 'Space Grotesk';color:#99A0AB;margin-top:11px;">{{ c.note }}</div>
401
+ </div>
402
+ </sc-for>
403
+ </div>
404
+ </div>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- ============ FRAME D — MOBILE ============ -->
411
+ <div style="position:absolute;left:4800px;top:540px;width:390px;">
412
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">D · Mobile — Generate</div>
413
+ <div data-screen-label="D · Mobile / Generate" style="height:844px;width:390px;background:#0F1115;border:1px solid #20242C;border-radius:32px;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;display:flex;flex-direction:column;position:relative;">
414
+ <!-- status bar -->
415
+ <div style="height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 22px;flex:none;"><div style="font:600 13px 'JetBrains Mono';">9:41</div><div style="display:flex;align-items:flex-end;gap:2px;height:12px;"><div style="width:3px;height:5px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:8px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:11px;background:#5BE0C8;border-radius:1px;"></div></div></div>
416
+ <!-- top -->
417
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:6px 18px 14px;flex:none;">
418
+ <div style="display:flex;align-items:center;gap:9px;"><div style="display:flex;align-items:flex-end;gap:2px;height:16px;"><div style="width:3px;height:6px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:14px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:9px;background:#5BE0C8;border-radius:1px;"></div></div><div style="font:600 15px 'Space Grotesk';">audio<span style="color:#5BE0C8;">·</span>brief</div></div>
419
+ <div style="display:flex;align-items:center;gap:6px;background:#171A20;border:1px solid #2A2F38;border-radius:18px;padding:5px 11px;"><span style="color:#FFC24B;font-size:12px;">◆</span><span style="font:600 12px 'JetBrains Mono';">4.20</span></div>
420
+ </div>
421
+
422
+ <div style="flex:1;overflow-y:auto;padding:0 16px 90px;min-height:0;">
423
+ <!-- composer -->
424
+ <div style="background:#14171D;border:1px solid #232831;border-radius:14px;padding:15px;">
425
+ <div style="font:600 10px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;">PROMPT</div>
426
+ <div style="font:400 15px/1.5 'Space Grotesk';margin-top:9px;">dark, atmospheric jungle — 160bpm, sub-heavy, lo-fi <span style="display:inline-block;width:2px;height:15px;background:#FF6A3D;vertical-align:-2px;"></span></div>
427
+ <div style="display:flex;gap:7px;margin-top:13px;flex-wrap:wrap;"><div style="font:500 11px 'JetBrains Mono';background:#171A20;border:1px solid #2A2F38;border-radius:7px;padding:7px 10px;color:#C9CDD4;">SA3 ▾</div><div style="font:500 11px 'JetBrains Mono';background:#171A20;border:1px solid #2A2F38;border-radius:7px;padding:7px 10px;color:#C9CDD4;">0:48 ▾</div><div style="font:500 11px 'JetBrains Mono';background:#171A20;border:1px solid #2A2F38;border-radius:7px;padding:7px 10px;color:#C9CDD4;">5 ▾</div></div>
428
+ <div style="display:flex;align-items:center;justify-content:center;gap:8px;background:#FF6A3D;color:#140B07;border-radius:9px;padding:12px;font:600 14px 'Space Grotesk';margin-top:13px;">Generate ▸ <span style="font:500 11px 'JetBrains Mono';opacity:.7;">~0.20 ◆</span></div>
429
+ </div>
430
+
431
+ <div style="display:flex;align-items:center;justify-content:space-between;margin:20px 4px 12px;"><div style="font:600 14px 'Space Grotesk';">Latest batch</div><div style="font:500 10px 'JetBrains Mono';color:#5BE0C8;">94% avg</div></div>
432
+ <div style="display:flex;flex-direction:column;gap:11px;">
433
+ <sc-for list="{{ batchM }}" as="v" hint-placeholder-count="3">
434
+ <div style="{{ v.cardStyle }}">
435
+ <div style="display:flex;align-items:center;justify-content:space-between;"><div style="font:600 13px 'Space Grotesk';">{{ v.lbl }}</div><div style="font:600 10px 'JetBrains Mono';color: {{ v.matchColor }};">{{ v.matchPct }} match</div></div>
436
+ <div style="display:flex;align-items:center;gap:11px;margin-top:11px;">
437
+ <div style="width:30px;height:30px;border-radius:50%;border:1.5px solid #FF6A3D;color:#FF6A3D;display:flex;align-items:center;justify-content:center;font-size:10px;flex:none;">▶</div>
438
+ <div style="flex:1;display:flex;align-items:flex-end;gap:1.5px;height:28px;"><sc-for list="{{ v.bars }}" as="b" hint-placeholder-count="48"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.9;"></div></sc-for></div>
439
+ <div style="font-size:13px;color: {{ v.star }};flex:none;">★</div>
440
+ </div>
441
+ <div style="font:500 10px 'JetBrains Mono';color:#99A0AB;margin-top:9px;">{{ v.bpm }} · {{ v.key }} · {{ v.dur }}</div>
442
+ </div>
443
+ </sc-for>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- crate bottom sheet handle -->
448
+ <div style="position:absolute;left:0;right:0;bottom:0;background:#121419;border-top:1px solid #2A2F38;border-radius:18px 18px 0 0;padding:14px 18px 22px;flex:none;">
449
+ <div style="width:36px;height:4px;border-radius:2px;background:#2A2F38;margin:0 auto 12px;"></div>
450
+ <div style="display:flex;align-items:center;justify-content:space-between;"><div style="display:flex;align-items:center;gap:9px;"><div style="font:600 14px 'Space Grotesk';">Crate</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">18 takes</div></div><div style="font:500 12px 'JetBrains Mono';color:#FF8C5A;">▴</div></div>
451
+ <div style="display:flex;gap:9px;margin-top:12px;overflow-x:auto;">
452
+ <sc-for list="{{ tilesM }}" as="tile" hint-placeholder-count="4">
453
+ <div style="{{ tile.dockStyle }}">
454
+ <div style="display:flex;align-items:flex-end;gap:1.5px;height:18px;margin-bottom:7px;"><sc-for list="{{ tile.bars }}" as="b" hint-placeholder-count="30"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.85;"></div></sc-for></div>
455
+ <div style="font:600 11px 'Space Grotesk';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ tile.title }}</div>
456
+ <div style="font:500 9px 'JetBrains Mono';color:#99A0AB;margin-top:3px;">{{ tile.bpm }} · {{ tile.key }}</div>
457
+ </div>
458
+ </sc-for>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ </div>
463
+ </x-dc>
464
+ <script type="text/x-dc" data-dc-script data-props="{&quot;showCost&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;showLineage&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;}}">
465
+ class Component extends DCLogic {
466
+ constructor(props){ super(props); this.init(); }
467
+
468
+ mkrng(seed){ let s = seed % 2147483647; if (s <= 0) s += 2147483646; return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; }
469
+
470
+ wave(seed, n, color){
471
+ const r = this.mkrng(seed); const a = [];
472
+ for (let i = 0; i < n; i++){
473
+ const t = i / (n - 1);
474
+ const env = Math.sin(t * Math.PI) * 0.5 + 0.62;
475
+ let h = (0.3 + 0.7 * r()) * env;
476
+ h = Math.max(0.12, Math.min(1, h));
477
+ a.push({ h: Math.round(h * 100) + '%', color });
478
+ }
479
+ return a;
480
+ }
481
+
482
+ bigWave(){
483
+ const secs = [
484
+ { label:'Intro', color:'#2BB89E', grow:0.17, time:'0:00 – 0:08', amp:0.5 },
485
+ { label:'Build', color:'#5BE0C8', grow:0.205, time:'0:08 – 0:18', amp:0.74 },
486
+ { label:'Core', color:'#FF6A3D', grow:0.375, time:'0:18 – 0:36', amp:1.0 },
487
+ { label:'Outro', color:'#FF8C5A', grow:0.25, time:'0:36 – 0:48', amp:0.56 },
488
+ ];
489
+ const bounds = [0, 0.17, 0.375, 0.75, 1.0];
490
+ const r = this.mkrng(424242); const n = 128; const bars = [];
491
+ for (let i = 0; i < n; i++){
492
+ const t = i / (n - 1);
493
+ let si = 0;
494
+ for (let k = 0; k < 4; k++){ if (t >= bounds[k] && t < bounds[k+1]) si = k; }
495
+ if (t >= bounds[4]) si = 3;
496
+ const s = secs[si];
497
+ const local = (t - bounds[si]) / (bounds[si+1] - bounds[si]);
498
+ const env = Math.sin(Math.max(0, Math.min(1, local)) * Math.PI) * 0.42 + 0.72;
499
+ let h = (0.28 + 0.72 * r()) * s.amp * env;
500
+ h = Math.max(0.08, Math.min(1, h));
501
+ bars.push({ h: Math.round(h * 100) + '%', color: s.color });
502
+ }
503
+ return { bars, secs: secs.map(s => ({ label:s.label, color:s.color, grow:s.grow, time:s.time })) };
504
+ }
505
+
506
+ init(){
507
+ const C = { coral:'#FF6A3D', mint:'#5BE0C8', amber:'#FFC24B' };
508
+
509
+ const raw = [
510
+ { id:'T-07', title:'Sub Cathedral', bpm:162, key:'F min', dur:'0:48', model:'SA3', fav:true, depth:0, sel:false },
511
+ { id:'V-07b',title:'Sub Cathedral · v2', bpm:160, key:'F min', dur:'0:52', model:'SA3', fav:false, depth:1, sel:true },
512
+ { id:'V-07a',title:'Sub Cathedral · v1', bpm:162, key:'F min', dur:'0:46', model:'SA3', fav:false, depth:1, sel:false },
513
+ { id:'V-07c',title:'Sub Cathedral · v3', bpm:163, key:'G min', dur:'0:44', model:'SA3', fav:false, depth:1, sel:false },
514
+ { id:'T-05', title:'Amen Rinse', bpm:174, key:'A min', dur:'1:02', model:'AceStep', fav:false, depth:0, sel:false },
515
+ { id:'V-05a',title:'Amen Rinse · v1', bpm:174, key:'A min', dur:'0:58', model:'AceStep', fav:false, depth:1, sel:false },
516
+ { id:'T-03', title:'Halflight Dub', bpm:140, key:'C min', dur:'0:38', model:'SA3', fav:false, depth:0, sel:false },
517
+ { id:'T-01', title:'First Pass', bpm:158, key:'D min', dur:'0:30', model:'SA3', fav:false, depth:0, sel:false },
518
+ ];
519
+ raw.forEach((t, i) => {
520
+ t.indent = t.depth ? '28px' : '0px';
521
+ t.isVar = t.depth > 0;
522
+ t.barColor = t.fav ? C.amber : (t.depth ? C.mint : C.coral);
523
+ t.bars = this.wave(7 + i * 13, 30, t.barColor);
524
+ t.star = t.fav ? C.amber : '#3A3F47';
525
+ t.modelColor = t.model === 'SA3' ? '#FF8C5A' : '#5BE0C8';
526
+ t.modelBg = t.model === 'SA3' ? 'rgba(255,106,61,0.12)' : 'rgba(91,224,200,0.12)';
527
+ const sel = t.sel;
528
+ t.cardStyle = 'border-radius:10px;padding:10px 11px 10px;cursor:pointer;border:1px solid ' + (sel ? 'rgba(255,106,61,0.55)' : '#22272F') + ';background:' + (sel ? 'rgba(255,106,61,0.08)' : '#171A20') + ';';
529
+ t.dockStyle = 'flex:none;width:170px;border-radius:10px;padding:11px;cursor:pointer;border:1px solid ' + (sel ? 'rgba(255,106,61,0.55)' : '#22272F') + ';background:' + (sel ? 'rgba(255,106,61,0.08)' : '#171A20') + ';';
530
+ t.treeStyle = 'display:flex;align-items:center;gap:8px;border-radius:8px;padding:7px 9px;cursor:pointer;border:1px solid ' + (sel ? 'rgba(255,106,61,0.5)' : 'transparent') + ';background:' + (sel ? 'rgba(255,106,61,0.07)' : 'transparent') + ';';
531
+ });
532
+ this.tiles = raw;
533
+ this.tilesM = raw.slice(0, 4).map(t => ({ ...t, dockStyle: 'flex:none;width:150px;border-radius:10px;padding:10px;border:1px solid ' + (t.sel ? 'rgba(255,106,61,0.55)' : '#22272F') + ';background:' + (t.sel ? 'rgba(255,106,61,0.08)' : '#171A20') + ';' }));
534
+
535
+ const bdef = [
536
+ { lbl:'v1', bpm:162, key:'F min', dur:'0:46', match:88, fav:false },
537
+ { lbl:'v2', bpm:160, key:'F min', dur:'0:52', match:94, fav:true },
538
+ { lbl:'v3', bpm:159, key:'F min', dur:'0:49', match:82, fav:false },
539
+ { lbl:'v4', bpm:163, key:'G min', dur:'0:44', match:79, fav:false },
540
+ { lbl:'v5', bpm:161, key:'F min', dur:'0:51', match:90, fav:false },
541
+ ];
542
+ this.batch = bdef.map((v, i) => {
543
+ const col = v.fav ? C.amber : C.coral;
544
+ return {
545
+ ...v,
546
+ bars: this.wave(101 + i * 17, 48, col),
547
+ barsMini: this.wave(101 + i * 17, 14, col),
548
+ matchPct: v.match + '%',
549
+ matchColor: v.match >= 90 ? '#5BE0C8' : (v.match >= 85 ? '#FF8C5A' : '#99A0AB'),
550
+ star: v.fav ? C.amber : '#3A3F47',
551
+ cardStyle: 'background:' + (v.fav ? 'rgba(255,194,75,0.07)' : '#14171D') + ';border:1px solid ' + (v.fav ? 'rgba(255,194,75,0.5)' : '#232831') + ';border-radius:12px;padding:13px;',
552
+ };
553
+ });
554
+ this.batchM = this.batch.slice(0, 3);
555
+
556
+ this.pickBars = this.wave(555, 34, '#FF6A3D');
557
+
558
+ const w = this.bigWave();
559
+ this.bigBars = w.bars; this.secs = w.secs;
560
+ this.measured = [
561
+ { k:'BPM', v:'162' }, { k:'KEY', v:'F min' }, { k:'LUFS', v:'−9.2' },
562
+ { k:'TRUE PK', v:'−0.8' }, { k:'LRA', v:'6.4' }, { k:'LENGTH', v:'0:48' },
563
+ ];
564
+
565
+ this.compare = [
566
+ { name:'A · claude', sub:'measured-grounded', tone:'#FF6A3D', score:92, scorePct:'92%', note:'Names the 18s sustained core and the stutter outro. BPM nailed.' },
567
+ { name:'B · openai-large', sub:'measured-grounded', tone:'#5BE0C8', score:88, scorePct:'88%', note:'Same arc, looser genre call — adds useful texture words.' },
568
+ { name:'C · gemini', sub:'audio-only', tone:'#7A828D', score:61, scorePct:'61%', note:'Vague structure, BPM drift, genre confusion.' },
569
+ ];
570
+ }
571
+
572
+ renderVals(){
573
+ return {
574
+ tiles: this.tiles,
575
+ tilesM: this.tilesM,
576
+ batch: this.batch,
577
+ batchM: this.batchM,
578
+ pickBars: this.pickBars,
579
+ bigBars: this.bigBars,
580
+ secs: this.secs,
581
+ measured: this.measured,
582
+ compare: this.compare,
583
+ showCost: this.props.showCost ?? true,
584
+ showLineage: this.props.showLineage ?? true,
585
+ };
586
+ }
587
+ }
588
+ </script>
589
+ </body>
590
+ </html>
design/Audio Brief v2.dc.html ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <script src="./support.js"></script>
7
+ </head>
8
+ <body>
9
+ <x-dc>
10
+ <helmet>
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
14
+ <meta name="design_doc_mode" content="canvas">
15
+ <style>
16
+ *{box-sizing:border-box;}
17
+ body{margin:0;font-family:'Space Grotesk',sans-serif;}
18
+ ::-webkit-scrollbar{width:8px;height:8px;}
19
+ ::-webkit-scrollbar-thumb{background:#2A2F38;border-radius:4px;}
20
+ ::-webkit-scrollbar-track{background:transparent;}
21
+ @keyframes abpulse{0%,100%{opacity:.45;}50%{opacity:1;}}
22
+ @keyframes abshimmer{0%{background-position:-200px 0;}100%{background-position:200px 0;}}
23
+ .ab-stream{background:linear-gradient(90deg,#171A20 0%,#222732 50%,#171A20 100%);background-size:400px 100%;animation:abshimmer 1.2s linear infinite;}
24
+ .ab-pulse{animation:abpulse 1.1s ease-in-out infinite;}
25
+ </style>
26
+ </helmet>
27
+
28
+ <!-- ===================== SYSTEM + SPEC BAND ===================== -->
29
+ <div style="position:absolute;left:0px;top:0px;width:1240px;">
30
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">audio·brief v2 — Direction B refined · component + spec doc</div>
31
+ <div style="background:#0F1115;border:1px solid #20242C;border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.22);padding:32px 36px;color:#F2EFE9;">
32
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:30px;flex-wrap:wrap;">
33
+ <!-- brand + pitch -->
34
+ <div style="flex:1;min-width:380px;">
35
+ <div style="display:flex;align-items:flex-end;gap:13px;">
36
+ <div style="display:flex;align-items:flex-end;gap:3px;height:32px;"><div style="width:5px;height:12px;background:#FF6A3D;border-radius:1px;"></div><div style="width:5px;height:29px;background:#FF6A3D;border-radius:1px;"></div><div style="width:5px;height:19px;background:#5BE0C8;border-radius:1px;"></div><div style="width:5px;height:25px;background:#5BE0C8;border-radius:1px;"></div></div>
37
+ <div style="font:600 32px 'Space Grotesk';letter-spacing:-.02em;line-height:1;">audio<span style="color:#5BE0C8;">·</span>brief</div>
38
+ </div>
39
+ <div style="font:400 15px/1.55 'Space Grotesk';color:#99A0AB;max-width:520px;margin-top:16px;">Generate a take, get a <span style="color:#5BE0C8;">measured</span> + LLM read of what makes it tick, then re-prompt for five <span style="color:#FF8C5A;">variants</span> that feel like cousins of the source. Every brief is grounded in real numbers — not just what the model heard.</div>
40
+ <div style="display:flex;gap:9px;margin-top:18px;">
41
+ <div style="font:500 11px 'JetBrains Mono';padding:6px 11px;border-radius:7px;background:#14171D;border:1px solid #232831;color:#C9CDD4;">1200px max · centered</div>
42
+ <div style="font:500 11px 'JetBrains Mono';padding:6px 11px;border-radius:7px;background:#14171D;border:1px solid #232831;color:#C9CDD4;">HF Spaces</div>
43
+ <div style="font:500 11px 'JetBrains Mono';padding:6px 11px;border-radius:7px;background:rgba(91,224,200,0.1);border:1px solid rgba(91,224,200,0.3);color:#5BE0C8;">◐ Path A feasible</div>
44
+ <div style="font:500 11px 'JetBrains Mono';padding:6px 11px;border-radius:7px;background:rgba(255,106,61,0.1);border:1px solid rgba(255,106,61,0.3);color:#FF8C5A;">◆ Path B ideal</div>
45
+ </div>
46
+ </div>
47
+ <!-- type spec -->
48
+ <div style="width:300px;flex:none;">
49
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:14px;">TYPE</div>
50
+ <div style="font:600 22px 'Space Grotesk';">Space Grotesk <span style="font:400 12px 'JetBrains Mono';color:#5E6671;">— body · headings</span></div>
51
+ <div style="font:500 16px 'JetBrains Mono';color:#C9CDD4;margin-top:8px;">JetBrains Mono <span style="font:400 12px 'JetBrains Mono';color:#5E6671;">— 162 BPM · F min</span></div>
52
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:16px;font:500 11px 'JetBrains Mono';color:#5E6671;">
53
+ <div>h1 · 22px</div><div>body · 14px</div>
54
+ <div>h2 · 16px</div><div>meta · 11–12px</div>
55
+ <div style="grid-column:1/3;color:#99A0AB;">LABELS · 11px · 0.12em · uppercase mono</div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <!-- palette -->
60
+ <div style="display:flex;gap:24px;margin-top:26px;padding-top:22px;border-top:1px solid #20242C;flex-wrap:wrap;">
61
+ <div style="flex:1;min-width:160px;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:10px;">SEMANTIC</div>
62
+ <div style="display:flex;gap:10px;">
63
+ <div><div style="display:flex;gap:3px;"><div style="width:26px;height:26px;border-radius:5px;background:#FF6A3D;"></div><div style="width:26px;height:26px;border-radius:5px;background:#FF8C5A;"></div></div><div style="font:600 11px 'Space Grotesk';margin-top:6px;">Coral</div><div style="font:400 10px 'JetBrains Mono';color:#5E6671;">generative</div></div>
64
+ <div><div style="display:flex;gap:3px;"><div style="width:26px;height:26px;border-radius:5px;background:#5BE0C8;"></div><div style="width:26px;height:26px;border-radius:5px;background:#2BB89E;"></div></div><div style="font:600 11px 'Space Grotesk';margin-top:6px;">Mint</div><div style="font:400 10px 'JetBrains Mono';color:#5E6671;">measured</div></div>
65
+ <div><div style="display:flex;gap:3px;"><div style="width:26px;height:26px;border-radius:5px;background:#FFC24B;"></div></div><div style="font:600 11px 'Space Grotesk';margin-top:6px;">Amber</div><div style="font:400 10px 'JetBrains Mono';color:#5E6671;">anchor</div></div>
66
+ </div>
67
+ </div>
68
+ <div style="flex:1;min-width:200px;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:10px;">BACKGROUND RAMP</div>
69
+ <div style="display:flex;gap:0;border-radius:6px;overflow:hidden;border:1px solid #232831;">
70
+ <div style="flex:1;height:42px;background:#0F1115;"></div><div style="flex:1;height:42px;background:#121419;"></div><div style="flex:1;height:42px;background:#14171D;"></div><div style="flex:1;height:42px;background:#171A20;"></div>
71
+ </div>
72
+ <div style="display:flex;justify-content:space-between;font:400 10px 'JetBrains Mono';color:#5E6671;margin-top:5px;"><span>0F1115 page</span><span>171A20 input</span></div>
73
+ </div>
74
+ <div style="flex:1;min-width:200px;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:10px;">TEXT</div>
75
+ <div style="display:flex;flex-direction:column;gap:3px;">
76
+ <div style="font:500 13px 'Space Grotesk';color:#F2EFE9;">F2EFE9 cream — body</div>
77
+ <div style="font:500 13px 'Space Grotesk';color:#C9CDD4;">C9CDD4 — secondary</div>
78
+ <div style="font:500 13px 'Space Grotesk';color:#99A0AB;">99A0AB — muted</div>
79
+ <div style="font:500 13px 'JetBrains Mono';color:#5E6671;">5E6671 — labels</div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- ===================== TOP BAR STATES ===================== -->
87
+ <div style="position:absolute;left:0px;top:560px;width:1200px;">
88
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Top bar — 56px · wallet pill = the wallet UI</div>
89
+ <div style="display:flex;flex-direction:column;gap:14px;">
90
+ <!-- connected -->
91
+ <div data-screen-label="Top bar / connected" style="border:1px solid #20242C;border-radius:12px;overflow:hidden;box-shadow:0 14px 40px rgba(0,0,0,.2);">
92
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:56px;background:#121419;border-bottom:1px solid #20242C;color:#F2EFE9;">
93
+ <div style="display:flex;align-items:center;gap:11px;"><div style="display:flex;align-items:flex-end;gap:2px;height:18px;"><div style="width:3px;height:7px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:16px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:10px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:13px;background:#5BE0C8;border-radius:1px;"></div></div><div style="font:600 16px 'Space Grotesk';">audio<span style="color:#5BE0C8;">·</span>brief</div></div>
94
+ <div style="display:flex;gap:4px;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:4px;"><div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Generate</div><div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';background:rgba(91,224,200,0.14);color:#5BE0C8;">Analyse</div><div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Compare</div></div>
95
+ <div style="display:flex;align-items:center;gap:10px;">
96
+ <sc-if value="{{ showSession }}" hint-placeholder-val="{{ true }}"><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">0.84 ◆ session</div></sc-if>
97
+ <div style="display:flex;align-items:center;gap:7px;background:#171A20;border:1px solid #2A2F38;border-radius:20px;padding:6px 13px;"><span style="color:#FFC24B;font-size:13px;">◆</span><span style="font:600 13px 'JetBrains Mono';color:#F2EFE9;">4.20</span><span style="font:500 10px 'JetBrains Mono';color:#5E6671;">POLLEN</span></div>
98
+ <div style="width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#FF6A3D,#5BE0C8);"></div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ <!-- disconnected -->
103
+ <div data-screen-label="Top bar / disconnected" style="border:1px solid #20242C;border-radius:12px;overflow:hidden;box-shadow:0 14px 40px rgba(0,0,0,.2);">
104
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:56px;background:#121419;border-bottom:1px solid #20242C;color:#F2EFE9;">
105
+ <div style="display:flex;align-items:center;gap:11px;"><div style="display:flex;align-items:flex-end;gap:2px;height:18px;"><div style="width:3px;height:7px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:16px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:10px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:13px;background:#5BE0C8;border-radius:1px;"></div></div><div style="font:600 16px 'Space Grotesk';">audio<span style="color:#5BE0C8;">·</span>brief</div></div>
106
+ <div style="display:flex;gap:4px;background:#14171D;border:1px solid #20242C;border-radius:10px;padding:4px;"><div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';background:rgba(255,106,61,0.14);color:#FF8C5A;">Generate</div><div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Analyse</div><div style="padding:7px 16px;border-radius:7px;font:500 13px 'Space Grotesk';color:#99A0AB;">Compare</div></div>
107
+ <div style="display:flex;align-items:center;gap:8px;background:rgba(255,106,61,0.12);border:1px solid rgba(255,106,61,0.45);border-radius:20px;padding:7px 15px;"><span style="color:#FF8C5A;font-size:12px;">◆</span><span style="font:600 12px 'Space Grotesk';color:#FF8C5A;">connect pollinations</span></div>
108
+ </div>
109
+ </div>
110
+ <div style="font:400 11px/1.5 'JetBrains Mono';color:#5E6671;padding:0 2px;">◐ Path A: pill is <span style="color:#99A0AB;">gr.HTML</span> + JS; click → same-tab redirect to <span style="color:#99A0AB;">enter.pollinations.ai</span>, return URL fragment <span style="color:#99A0AB;">#api_key=sk_…</span> captured into session. Per-visitor only on Spaces.</div>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- ===================== GENERATE COMPOSER (desktop) ===================== -->
115
+ <div style="position:absolute;left:0px;top:900px;width:1200px;">
116
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Generate composer — desktop · loaded</div>
117
+ <div data-screen-label="Generate / composer" style="background:#0F1115;border:1px solid #20242C;border-radius:14px;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;padding:28px;">
118
+ <div style="max-width:760px;margin:0 auto;">
119
+ <!-- prompt box -->
120
+ <div style="background:#171A20;border:1px solid #232831;border-radius:14px;padding:18px 18px 14px;">
121
+ <div style="display:flex;align-items:center;justify-content:space-between;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;">PROMPT</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">142 / 600</div></div>
122
+ <div style="margin-top:11px;font:400 17px/1.5 'Space Grotesk';color:#F2EFE9;">dark, atmospheric jungle — sub-heavy, lo-fi tape hiss, rolling amen break<span style="display:inline-block;width:2px;height:18px;background:#FF6A3D;vertical-align:-3px;margin-left:1px;" class="ab-pulse"></span></div>
123
+ </div>
124
+
125
+ <!-- controls row 1: model + duration -->
126
+ <div style="display:flex;gap:22px;margin-top:18px;flex-wrap:wrap;">
127
+ <div style="flex:1;min-width:220px;">
128
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:9px;">MODEL</div>
129
+ <div style="position:relative;">
130
+ <div style="display:flex;align-items:center;justify-content:space-between;background:#171A20;border:1px solid #2A2F38;border-radius:9px;padding:11px 14px;"><div style="display:flex;align-items:center;gap:9px;"><span style="width:7px;height:7px;border-radius:50%;background:#FF6A3D;"></span><span style="font:500 13px 'JetBrains Mono';color:#F2EFE9;">SA3 · stable-audio-3-medium</span></div><span style="color:#5E6671;">▾</span></div>
131
+ <!-- dropdown open -->
132
+ <div style="position:absolute;left:0;right:0;top:52px;background:#14171D;border:1px solid #2A2F38;border-radius:9px;padding:6px;box-shadow:0 20px 50px rgba(0,0,0,.45);z-index:5;">
133
+ <sc-for list="{{ models }}" as="m" hint-placeholder-count="4">
134
+ <div style="{{ m.rowStyle }}">
135
+ <div style="display:flex;align-items:center;gap:9px;"><span style="width:7px;height:7px;border-radius:50%;background: {{ m.dot }};"></span><span style="font:500 13px 'JetBrains Mono';color: {{ m.txt }};">{{ m.name }}</span></div>
136
+ <span style="font:500 10px 'JetBrains Mono';color: {{ m.tagColor }};">{{ m.tag }}</span>
137
+ </div>
138
+ </sc-for>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ <div style="flex:1;min-width:260px;">
143
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:9px;">DURATION</div>
144
+ <div style="display:flex;gap:7px;">
145
+ <sc-for list="{{ durations }}" as="d" hint-placeholder-count="4"><div style="{{ d.style }}"><div style="font:600 12px 'Space Grotesk';">{{ d.name }}</div><div style="font:500 10px 'JetBrains Mono';color: {{ d.subColor }};">{{ d.secs }}</div></div></sc-for>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- controls row 2: variants (hold) -->
151
+ <div style="margin-top:18px;">
152
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:9px;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;">VARIATION SPREAD</div><div style="font:500 9px 'JetBrains Mono';padding:2px 7px;border-radius:5px;background:#171A20;border:1px solid #2A2F38;color:#5E6671;">awaiting CFG API · disabled</div></div>
153
+ <div style="display:flex;gap:7px;opacity:.45;pointer-events:none;">
154
+ <div style="font:600 12px 'JetBrains Mono';padding:9px 18px;border-radius:8px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">tight</div>
155
+ <div style="font:600 12px 'JetBrains Mono';padding:9px 18px;border-radius:8px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">balanced</div>
156
+ <div style="font:600 12px 'JetBrains Mono';padding:9px 18px;border-radius:8px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">wild</div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- generate button + cost -->
161
+ <div style="display:flex;align-items:center;gap:14px;margin-top:22px;">
162
+ <div style="width:360px;display:flex;align-items:center;justify-content:center;gap:10px;background:#FF6A3D;color:#140B07;border-radius:11px;padding:14px;font:600 15px 'Space Grotesk';box-shadow:0 8px 24px rgba(255,106,61,0.28);">Generate <span style="font-size:13px;">▸</span></div>
163
+ <sc-if value="{{ showCost }}" hint-placeholder-val="{{ true }}"><div style="font:500 13px 'JetBrains Mono';color:#99A0AB;">~0.04 <span style="color:#FFC24B;">◆</span> <span style="color:#5E6671;">per call · flat</span></div></sc-if>
164
+ </div>
165
+ <div style="font:400 11px 'JetBrains Mono';color:#5E6671;margin-top:18px;border-top:1px solid #20242C;padding-top:14px;">◐ Path A: prompt = <span style="color:#99A0AB;">gr.Textbox</span>, model = <span style="color:#99A0AB;">gr.Dropdown</span> (soon-rows need gr.HTML), duration = <span style="color:#99A0AB;">gr.Radio</span> styled to chips, button = <span style="color:#99A0AB;">gr.Button</span> capped at 360px. ◆ Path B: variant spread + cost pip are custom.</div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- ===================== ANALYSIS HERO (desktop) ===================== -->
171
+ <div style="position:absolute;left:0px;top:1560px;width:1200px;">
172
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Analysis — desktop · the wedge · loaded</div>
173
+ <div data-screen-label="Analysis / loaded" style="background:#0F1115;border:1px solid #20242C;border-radius:14px;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;padding:26px 28px;">
174
+ <!-- header -->
175
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;">
176
+ <div style="display:flex;align-items:center;gap:12px;"><div style="font:600 22px 'Space Grotesk';letter-spacing:-.01em;">Sub Cathedral · v2</div><div style="font:500 11px 'JetBrains Mono';padding:4px 9px;border-radius:6px;background:rgba(255,194,75,0.12);color:#FFC24B;">★ anchor</div><div style="font:500 11px 'JetBrains Mono';padding:4px 9px;border-radius:6px;background:rgba(91,224,200,0.1);color:#5BE0C8;">analysed in 41s · local</div></div>
177
+ <div style="display:flex;align-items:center;gap:9px;"><div style="font:600 12px 'JetBrains Mono';color:#C9CDD4;border:1px solid #2A2F38;border-radius:8px;padding:8px 13px;">Validate vs Gemini</div><div style="width:34px;height:34px;border-radius:50%;border:1.5px solid #5BE0C8;color:#5BE0C8;display:flex;align-items:center;justify-content:center;font-size:12px;">▶</div></div>
178
+ </div>
179
+
180
+ <div style="display:flex;gap:22px;">
181
+ <!-- LEFT: waveform + metrics + read -->
182
+ <div style="flex:1.5;min-width:0;display:flex;flex-direction:column;gap:14px;">
183
+ <!-- waveform -->
184
+ <div style="background:#121419;border:1px solid #232831;border-radius:14px;padding:18px;">
185
+ <div style="position:relative;">
186
+ <div style="display:flex;align-items:flex-end;gap:1px;height:150px;"><sc-for list="{{ bigBars }}" as="b" hint-placeholder-count="128"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.9;"></div></sc-for></div>
187
+ <div style="position:absolute;left:43%;top:-4px;bottom:-4px;width:2px;background:#F2EFE9;opacity:.55;"></div>
188
+ <div style="position:absolute;left:43%;top:-9px;width:9px;height:9px;border-radius:50%;background:#F2EFE9;transform:translateX(-50%);"></div>
189
+ </div>
190
+ <div style="display:flex;gap:5px;margin-top:14px;"><sc-for list="{{ secs }}" as="s" hint-placeholder-count="4"><div style="flex: {{ s.grow }};border-top:2px solid {{ s.color }};padding-top:8px;"><div style="font:600 12px 'Space Grotesk';">{{ s.label }}</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;margin-top:2px;">{{ s.time }}</div></div></sc-for></div>
191
+ </div>
192
+ <!-- 6-up metrics -->
193
+ <div style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;"><sc-for list="{{ measured }}" as="m" hint-placeholder-count="6"><div style="background:#121419;border:1px solid #232831;border-radius:10px;padding:12px 13px;"><div style="font:500 10px 'JetBrains Mono';letter-spacing:.1em;color:#5E6671;">{{ m.k }}</div><div style="font:600 21px 'JetBrains Mono';color:#F2EFE9;margin-top:8px;">{{ m.v }}</div></div></sc-for></div>
194
+ <!-- the read -->
195
+ <div style="background:#121419;border:1px solid #232831;border-radius:14px;padding:18px;">
196
+ <div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5BE0C8;">THE READ — MEASURED + LLM</div>
197
+ <div style="font:400 14px/1.65 'Space Grotesk';color:#C9CDD4;margin-top:12px;">A brooding jungle cut at <span style="color:#5BE0C8;">162 BPM in F minor</span>. A sparse, mint-lit intro gives way to a sub-heavy build, then an <span style="color:#FF8C5A;">18-second sustained core</span> driven by a compressed amen break. It tails off into <span style="color:#FF8C5A;">three stutter outros</span> — gritty, tape-saturated, lo-fi. Loud and dense at <span style="color:#5BE0C8;">−9.2 LUFS</span> with controlled <span style="color:#5BE0C8;">6.4 LU</span> dynamics.</div>
198
+ </div>
199
+ </div>
200
+
201
+ <!-- RIGHT: derived prompt + variants -->
202
+ <div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:14px;">
203
+ <!-- derived prompt -->
204
+ <div style="background:#14171D;border:1px solid rgba(255,106,61,0.4);border-radius:14px;padding:18px;">
205
+ <div style="display:flex;align-items:center;justify-content:space-between;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#FF8C5A;">DERIVED PROMPT</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;">editable</div></div>
206
+ <!-- tagged prompt -->
207
+ <div style="margin-top:12px;font:400 14px/1.75 'Space Grotesk';color:#F2EFE9;background:#0F1115;border:1px solid #232831;border-radius:10px;padding:14px;"><sc-for list="{{ promptTokens }}" as="t" hint-placeholder-count="11"><span style="{{ t.style }}">{{ t.text }}</span></sc-for><span style="display:inline-block;width:2px;height:15px;background:#FF6A3D;vertical-align:-2px;" class="ab-pulse"></span></div>
208
+ <!-- legend -->
209
+ <div style="display:flex;gap:16px;margin-top:11px;"><div style="display:flex;align-items:center;gap:6px;"><span style="width:14px;height:0;border-bottom:2px solid #FF8C5A;"></span><span style="font:500 10px 'JetBrains Mono';color:#99A0AB;">your intent</span></div><div style="display:flex;align-items:center;gap:6px;"><span style="width:14px;height:0;border-bottom:2px solid #5BE0C8;"></span><span style="font:500 10px 'JetBrains Mono';color:#99A0AB;">measured arc</span></div></div>
210
+ <!-- nudge chips -->
211
+ <div style="display:flex;flex-wrap:wrap;gap:7px;margin-top:13px;"><sc-for list="{{ nudges }}" as="n" hint-placeholder-count="4"><div style="font:500 11px 'JetBrains Mono';padding:6px 11px;border-radius:14px;background:#171A20;border:1px solid #2A2F38;color:#C9CDD4;">{{ n }}</div></sc-for></div>
212
+ <!-- regen -->
213
+ <div style="display:flex;align-items:center;gap:11px;margin-top:16px;"><div style="flex:1;display:flex;align-items:center;justify-content:center;gap:8px;background:#FF6A3D;color:#140B07;border-radius:10px;padding:12px;font:600 13px 'Space Grotesk';">Regenerate · 5 variants ▸</div><sc-if value="{{ showCost }}" hint-placeholder-val="{{ true }}"><div style="font:500 12px 'JetBrains Mono';color:#99A0AB;white-space:nowrap;">~0.20 <span style="color:#FFC24B;">◆</span></div></sc-if></div>
214
+ </div>
215
+
216
+ <!-- variants (streaming) -->
217
+ <div style="background:#121419;border:1px solid #232831;border-radius:14px;padding:16px;">
218
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:13px;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;">5 VARIANTS</div><div style="font:500 10px 'JetBrains Mono';color:#FF8C5A;">generating 3 / 5 · sequential</div></div>
219
+ <div style="display:flex;flex-direction:column;gap:9px;"><sc-for list="{{ streamVars }}" as="v" hint-placeholder-count="5"><div style="{{ v.wrap }}">
220
+ <sc-if value="{{ v.done }}" hint-placeholder-val="{{ true }}">
221
+ <div style="display:flex;align-items:center;gap:11px;">
222
+ <div style="width:28px;height:28px;border-radius:50%;border:1.5px solid #FF6A3D;color:#FF6A3D;display:flex;align-items:center;justify-content:center;font-size:9px;flex:none;">▶</div>
223
+ <div style="flex:1;display:flex;align-items:flex-end;gap:1.5px;height:26px;"><sc-for list="{{ v.bars }}" as="b" hint-placeholder-count="40"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.9;"></div></sc-for></div>
224
+ <div style="text-align:right;flex:none;"><div style="font:600 11px 'JetBrains Mono';color: {{ v.matchColor }};">{{ v.matchPct }}</div><div style="font:500 9px 'JetBrains Mono';color:#5E6671;">{{ v.bpm }}</div></div>
225
+ <div style="font-size:13px;color: {{ v.star }};flex:none;">★</div>
226
+ </div>
227
+ </sc-if>
228
+ <sc-if value="{{ v.gen }}" hint-placeholder-val="{{ false }}">
229
+ <div style="display:flex;align-items:center;gap:11px;"><div style="width:28px;height:28px;border-radius:50%;border:1.5px solid #2A2F38;flex:none;"></div><div class="ab-stream" style="flex:1;height:26px;border-radius:5px;"></div><div style="font:500 10px 'JetBrains Mono';color:#FF8C5A;flex:none;" class="ab-pulse">{{ v.lbl }} · gen</div></div>
230
+ </sc-if>
231
+ <sc-if value="{{ v.queued }}" hint-placeholder-val="{{ false }}">
232
+ <div style="display:flex;align-items:center;gap:11px;opacity:.5;"><div style="width:28px;height:28px;border-radius:50%;border:1.5px dashed #2A2F38;flex:none;"></div><div style="flex:1;height:26px;border-radius:5px;border:1.5px dashed #2A2F38;"></div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;flex:none;">{{ v.lbl }} · queued</div></div>
233
+ </sc-if>
234
+ </div></sc-for></div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+
239
+ <!-- crate dock -->
240
+ <div style="margin-top:18px;border-top:1px solid #20242C;padding-top:16px;">
241
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;"><div style="display:flex;align-items:baseline;gap:10px;"><div style="font:600 13px 'Space Grotesk';">Crate</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;">18 takes · drag to reorder</div></div><div style="display:flex;gap:6px;"><sc-for list="{{ filters }}" as="f" hint-placeholder-count="4"><div style="{{ f.style }}">{{ f.label }}</div></sc-for><div style="font:500 11px 'JetBrains Mono';padding:5px 11px;border-radius:13px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">View all ▦</div></div></div>
242
+ <div style="display:flex;gap:11px;overflow-x:auto;padding-bottom:4px;"><sc-for list="{{ tiles }}" as="tile" hint-placeholder-count="8"><div style="{{ tile.dockStyle }}">
243
+ <sc-if value="{{ tile.isVar }}" hint-placeholder-val="{{ false }}"><div style="font:500 9px 'JetBrains Mono';color:#5E6671;margin-bottom:4px;">↳ from {{ tile.parent }}</div></sc-if>
244
+ <div style="display:flex;align-items:flex-end;gap:1.5px;height:22px;margin-bottom:8px;"><sc-for list="{{ tile.bars }}" as="b" hint-placeholder-count="30"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.85;"></div></sc-for></div>
245
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:6px;"><div style="font:600 12px 'Space Grotesk';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ tile.title }}</div><div style="font-size:11px;color: {{ tile.star }};flex:none;">★</div></div>
246
+ <div style="display:flex;align-items:center;gap:6px;margin-top:6px;"><span style="font:500 10px 'JetBrains Mono';color:#99A0AB;">{{ tile.bpm }} · {{ tile.key }}</span><div style="flex:1;"></div><span style="font:500 9px 'JetBrains Mono';padding:2px 6px;border-radius:5px;background: {{ tile.modelBg }};color: {{ tile.modelColor }};">{{ tile.model }}</span></div>
247
+ </div></sc-for></div>
248
+ </div>
249
+ </div>
250
+ </div>
251
+
252
+ <!-- ===================== COMPARE / VALIDATE ===================== -->
253
+ <div style="position:absolute;left:0px;top:3320px;width:1200px;">
254
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Validate vs Gemini — triggered from Analysis · the marketing artifact</div>
255
+ <div data-screen-label="Compare / Validate" style="background:#0F1115;border:1px solid #20242C;border-radius:14px;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;padding:26px 28px;">
256
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
257
+ <div style="display:flex;align-items:center;gap:11px;"><div style="font:600 18px 'Space Grotesk';">Does measured grounding beat audio-only?</div><div style="font:500 11px 'JetBrains Mono';color:#5E6671;border:1px solid #2A2F38;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;cursor:help;">i</div></div>
258
+ <div style="display:flex;gap:7px;"><div style="font:600 11px 'JetBrains Mono';color:#C9CDD4;border:1px solid #2A2F38;border-radius:7px;padding:7px 12px;">PNG scorecard</div><div style="font:600 11px 'JetBrains Mono';color:#C9CDD4;border:1px solid #2A2F38;border-radius:7px;padding:7px 12px;">Report ↓</div></div>
259
+ </div>
260
+ <div style="font:400 12px 'Space Grotesk';color:#99A0AB;margin-bottom:20px;">Same track, three reads. A &amp; B are grounded in the measured numbers; C listens only.</div>
261
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px;">
262
+ <sc-for list="{{ compare }}" as="c" hint-placeholder-count="3"><div style="{{ c.cardStyle }}">
263
+ <div style="display:flex;align-items:center;justify-content:space-between;"><div><div style="font:600 14px 'Space Grotesk';color: {{ c.tone }};">{{ c.name }}</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;margin-top:2px;">{{ c.sub }}</div></div><div style="font:600 24px 'JetBrains Mono';color: {{ c.tone }};">{{ c.score }}</div></div>
264
+ <div style="height:5px;border-radius:3px;background:#1E222A;margin-top:13px;position:relative;overflow:hidden;"><div style="position:absolute;left:0;top:0;bottom:0;width: {{ c.scorePct }};background: {{ c.tone }};border-radius:3px;"></div></div>
265
+ <div style="font:400 12px/1.55 'Space Grotesk';color:#C9CDD4;margin-top:14px;">{{ c.note }}</div>
266
+ <div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:13px;"><sc-for list="{{ c.tags }}" as="tg" hint-placeholder-count="3"><span style="font:500 9px 'JetBrains Mono';padding:3px 7px;border-radius:5px;background:#171A20;border:1px solid #2A2F38;color: {{ tg.color }};">{{ tg.t }}</span></sc-for></div>
267
+ </div></sc-for>
268
+ </div>
269
+ <div style="font:400 11px 'JetBrains Mono';color:#5E6671;margin-top:18px;border-top:1px solid #20242C;padding-top:14px;">◐ Path A: three <span style="color:#99A0AB;">gr.HTML</span> columns + a styled mix-chain table. Pre-run shows ONE dotted placeholder card, not three empty slots. Demoted from a standing tab → button on Analysis.</div>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- ===================== RIGHT COL: component states ===================== -->
274
+ <div style="position:absolute;left:1320px;top:560px;width:520px;">
275
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Component states — 120ms transitions</div>
276
+ <div style="background:#0F1115;border:1px solid #20242C;border-radius:14px;box-shadow:0 20px 50px rgba(0,0,0,.24);color:#F2EFE9;padding:22px 24px;display:flex;flex-direction:column;gap:22px;">
277
+ <!-- buttons -->
278
+ <div><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:12px;">PRIMARY BUTTON</div>
279
+ <div style="display:flex;flex-direction:column;gap:9px;">
280
+ <div style="display:flex;align-items:center;gap:12px;"><div style="width:130px;text-align:center;background:#FF6A3D;color:#140B07;border-radius:9px;padding:10px;font:600 12px 'Space Grotesk';">Generate ▸</div><div style="font:400 11px 'JetBrains Mono';color:#5E6671;">default</div></div>
281
+ <div style="display:flex;align-items:center;gap:12px;"><div style="width:130px;text-align:center;background:#FF8C5A;color:#140B07;border-radius:9px;padding:10px;font:600 12px 'Space Grotesk';box-shadow:0 8px 22px rgba(255,106,61,0.35);">Generate ▸</div><div style="font:400 11px 'JetBrains Mono';color:#5E6671;">hover · lift</div></div>
282
+ <div style="display:flex;align-items:center;gap:12px;"><div style="width:130px;text-align:center;background:#E55A30;color:#140B07;border-radius:9px;padding:10px;font:600 12px 'Space Grotesk';transform:scale(.97);">Generate ▸</div><div style="font:400 11px 'JetBrains Mono';color:#5E6671;">active · 0.97</div></div>
283
+ <div style="display:flex;align-items:center;gap:12px;"><div style="width:130px;text-align:center;background:#171A20;color:#FF8C5A;border:1px solid rgba(255,106,61,0.4);border-radius:9px;padding:10px;font:600 12px 'Space Grotesk';"><span class="ab-pulse">generating…</span></div><div style="font:400 11px 'JetBrains Mono';color:#5E6671;">loading · pulse</div></div>
284
+ <div style="display:flex;align-items:center;gap:12px;"><div style="width:130px;text-align:center;background:#1A1D23;color:#5E6671;border-radius:9px;padding:10px;font:600 12px 'Space Grotesk';">Generate ▸</div><div style="font:400 11px 'JetBrains Mono';color:#5E6671;">disabled</div></div>
285
+ </div>
286
+ </div>
287
+ <!-- chips -->
288
+ <div style="border-top:1px solid #20242C;padding-top:18px;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:12px;">CHIP / TAB</div>
289
+ <div style="display:flex;flex-wrap:wrap;gap:9px;">
290
+ <div style="font:500 12px 'JetBrains Mono';padding:8px 14px;border-radius:8px;background:#171A20;border:1px solid #2A2F38;color:#99A0AB;">default</div>
291
+ <div style="font:500 12px 'JetBrains Mono';padding:8px 14px;border-radius:8px;background:rgba(91,224,200,0.14);border:1px solid rgba(91,224,200,0.4);color:#5BE0C8;">selected</div>
292
+ <div style="font:500 12px 'JetBrains Mono';padding:8px 14px;border-radius:8px;background:#1E222A;border:1px solid #3A3F47;color:#C9CDD4;">hover</div>
293
+ <div style="font:500 12px 'JetBrains Mono';padding:8px 14px;border-radius:8px;background:#171A20;border:1px solid #2A2F38;color:#5E6671;opacity:.5;">disabled</div>
294
+ </div>
295
+ </div>
296
+ <!-- audio player -->
297
+ <div style="border-top:1px solid #20242C;padding-top:18px;"><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:12px;">AUDIO PLAYER</div>
298
+ <div style="display:flex;flex-direction:column;gap:11px;">
299
+ <div style="display:flex;align-items:center;gap:11px;"><div style="width:30px;height:30px;border-radius:50%;border:1.5px solid #FF6A3D;color:#FF6A3D;display:flex;align-items:center;justify-content:center;font-size:10px;flex:none;">▶</div><div style="flex:1;height:3px;border-radius:2px;background:#262B34;"></div><div style="font:400 10px 'JetBrains Mono';color:#5E6671;">paused</div></div>
300
+ <div style="display:flex;align-items:center;gap:11px;"><div style="width:30px;height:30px;border-radius:50%;background:#FF6A3D;color:#140B07;display:flex;align-items:center;justify-content:center;font-size:9px;flex:none;">❚❚</div><div style="flex:1;height:3px;border-radius:2px;background:#262B34;position:relative;"><div style="position:absolute;left:0;top:0;bottom:0;width:46%;background:#FF6A3D;border-radius:2px;"></div></div><div style="font:400 10px 'JetBrains Mono';color:#FF8C5A;">playing</div></div>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ <!-- ===================== RIGHT COL: empty states ===================== -->
307
+ <div style="position:absolute;left:1320px;top:1240px;width:520px;">
308
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Empty states — hide, don't show empty boxes</div>
309
+ <div style="background:#0F1115;border:1px solid #20242C;border-radius:14px;box-shadow:0 20px 50px rgba(0,0,0,.24);color:#F2EFE9;padding:22px 24px;display:flex;flex-direction:column;gap:14px;">
310
+ <!-- crate empty -->
311
+ <div style="border:1.5px dashed #2A2F38;border-radius:12px;padding:26px;text-align:center;">
312
+ <div style="display:flex;align-items:flex-end;gap:3px;height:22px;justify-content:center;opacity:.4;"><div style="width:4px;height:9px;background:#5E6671;border-radius:1px;"></div><div style="width:4px;height:18px;background:#5E6671;border-radius:1px;"></div><div style="width:4px;height:13px;background:#5E6671;border-radius:1px;"></div></div>
313
+ <div style="font:600 14px 'Space Grotesk';margin-top:13px;">Your crate is empty</div>
314
+ <div style="font:400 12px 'Space Grotesk';color:#99A0AB;margin-top:4px;">Generate a take or drop in a clip to start.</div>
315
+ <div style="display:flex;gap:8px;justify-content:center;margin-top:14px;"><div style="font:600 11px 'Space Grotesk';background:#FF6A3D;color:#140B07;border-radius:8px;padding:8px 14px;">Generate</div><div style="font:600 11px 'Space Grotesk';color:#C9CDD4;border:1px solid #2A2F38;border-radius:8px;padding:8px 14px;">Upload clip</div></div>
316
+ </div>
317
+ <!-- compare pre-run -->
318
+ <div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;margin-bottom:8px;">COMPARE · pre-run — one placeholder, not 3 empty slots</div>
319
+ <div style="border:1.5px dashed #2A2F38;border-radius:12px;padding:20px;text-align:center;"><div style="font:500 12px 'JetBrains Mono';color:#99A0AB;">Run a comparison to see A / B / C side-by-side</div><div style="font:600 11px 'Space Grotesk';color:#5BE0C8;border:1px solid rgba(91,224,200,0.4);border-radius:8px;padding:8px 14px;display:inline-block;margin-top:12px;">Validate vs Gemini</div></div>
320
+ </div>
321
+ <div style="font:400 11px/1.6 'JetBrains Mono';color:#5E6671;">Latest gen, selected tile, variants grid &amp; waveform image all <span style="color:#99A0AB;">hide entirely</span> until they have content — no ♫ placeholder boxes.</div>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- ===================== RIGHT COL: local bridge ===================== -->
326
+ <div style="position:absolute;left:1320px;top:1720px;width:520px;">
327
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Local model bridge — power-user · advanced accordion</div>
328
+ <div style="background:#0F1115;border:1px solid #20242C;border-radius:14px;box-shadow:0 20px 50px rgba(0,0,0,.24);color:#F2EFE9;padding:22px 24px;">
329
+ <div style="display:flex;align-items:center;justify-content:space-between;background:#14171D;border:1px solid #232831;border-radius:9px;padding:12px 14px;"><div style="font:500 12px 'JetBrains Mono';color:#C9CDD4;">Local gen server (advanced)</div><span style="color:#5E6671;">▾</span></div>
330
+ <div style="margin-top:12px;display:flex;flex-direction:column;gap:11px;">
331
+ <div><div style="font:600 11px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin-bottom:7px;">SERVER URL</div><div style="display:flex;align-items:center;gap:9px;background:#171A20;border:1px solid #2A2F38;border-radius:8px;padding:10px 13px;"><span style="width:7px;height:7px;border-radius:50%;background:#5BE0C8;"></span><span style="font:500 12px 'JetBrains Mono';color:#F2EFE9;">http://localhost:7864</span></div></div>
332
+ <div style="display:flex;align-items:center;gap:9px;background:rgba(91,224,200,0.08);border:1px solid rgba(91,224,200,0.3);border-radius:8px;padding:10px 13px;"><span style="width:8px;height:8px;border-radius:50%;background:#5BE0C8;" class="ab-pulse"></span><span style="font:500 11px 'JetBrains Mono';color:#5BE0C8;">running on your machine — gens stay local, then upload for analysis</span></div>
333
+ </div>
334
+ <div style="font:400 11px 'JetBrains Mono';color:#5E6671;margin-top:14px;">Model badge on these tiles = <span style="color:#5BE0C8;">mint pill</span> ("local") vs coral "SA3". Status dot pulses mint while the local path is active.</div>
335
+ </div>
336
+ </div>
337
+
338
+ <!-- ===================== MOBILE: Generate ===================== -->
339
+ <div style="position:absolute;left:1980px;top:560px;width:375px;">
340
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Mobile 375 · Generate</div>
341
+ <div data-screen-label="Mobile / Generate" style="height:780px;width:375px;background:#0F1115;border:1px solid #20242C;border-radius:30px;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;display:flex;flex-direction:column;position:relative;">
342
+ <div style="height:42px;display:flex;align-items:center;justify-content:space-between;padding:0 22px;flex:none;"><div style="font:600 13px 'JetBrains Mono';">9:41</div><div style="display:flex;align-items:flex-end;gap:2px;height:11px;"><div style="width:3px;height:5px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:8px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:11px;background:#5BE0C8;border-radius:1px;"></div></div></div>
343
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:6px 16px 14px;flex:none;border-bottom:1px solid #20242C;"><div style="display:flex;align-items:center;gap:8px;"><div style="display:flex;align-items:flex-end;gap:2px;height:15px;"><div style="width:3px;height:6px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:13px;background:#FF6A3D;border-radius:1px;"></div><div style="width:3px;height:9px;background:#5BE0C8;border-radius:1px;"></div></div><div style="font:600 15px 'Space Grotesk';">audio<span style="color:#5BE0C8;">·</span>brief</div></div><div style="display:flex;align-items:center;gap:6px;background:#171A20;border:1px solid #2A2F38;border-radius:18px;padding:6px 11px;"><span style="color:#FFC24B;font-size:11px;">◆</span><span style="font:600 11px 'JetBrains Mono';">4.20</span></div></div>
344
+ <div style="flex:1;overflow-y:auto;padding:16px;min-height:0;">
345
+ <div style="background:#171A20;border:1px solid #232831;border-radius:13px;padding:14px;"><div style="font:600 10px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;">PROMPT</div><div style="font:400 15px/1.5 'Space Grotesk';margin-top:8px;">dark, atmospheric jungle — sub-heavy, lo-fi<span style="display:inline-block;width:2px;height:15px;background:#FF6A3D;vertical-align:-2px;" class="ab-pulse"></span></div></div>
346
+ <div style="font:600 10px 'JetBrains Mono';letter-spacing:.12em;color:#5E6671;margin:16px 2px 8px;">DURATION</div>
347
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:7px;"><div style="background:#171A20;border:1px solid #2A2F38;border-radius:8px;padding:11px;min-height:44px;"><div style="font:600 12px 'Space Grotesk';">Cue</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;">15s</div></div><div style="background:rgba(255,106,61,0.1);border:1px solid rgba(255,106,61,0.45);border-radius:8px;padding:11px;min-height:44px;"><div style="font:600 12px 'Space Grotesk';color:#FF8C5A;">Loop</div><div style="font:500 10px 'JetBrains Mono';color:#FF8C5A;">30s</div></div><div style="background:#171A20;border:1px solid #2A2F38;border-radius:8px;padding:11px;min-height:44px;"><div style="font:600 12px 'Space Grotesk';">Track</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;">90s</div></div><div style="background:#171A20;border:1px solid #2A2F38;border-radius:8px;padding:11px;min-height:44px;"><div style="font:600 12px 'Space Grotesk';">Long</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;">180s</div></div></div>
348
+ <div style="display:flex;align-items:center;justify-content:center;gap:8px;background:#FF6A3D;color:#140B07;border-radius:10px;padding:14px;font:600 14px 'Space Grotesk';margin-top:16px;min-height:44px;">Generate ▸ <span style="font:500 11px 'JetBrains Mono';opacity:.7;">~0.04 ◆</span></div>
349
+ </div>
350
+ <!-- crate sheet -->
351
+ <div style="flex:none;background:#121419;border-top:1px solid #2A2F38;border-radius:18px 18px 0 0;padding:13px 16px 20px;"><div style="width:36px;height:4px;border-radius:2px;background:#2A2F38;margin:0 auto 11px;"></div><div style="display:flex;align-items:center;justify-content:space-between;"><div style="display:flex;align-items:center;gap:8px;"><div style="font:600 13px 'Space Grotesk';">Crate</div><div style="font:500 10px 'JetBrains Mono';color:#5E6671;">18</div></div><div style="font:500 11px 'JetBrains Mono';color:#FF8C5A;">▴</div></div>
352
+ <div style="display:flex;gap:9px;margin-top:11px;overflow-x:auto;"><sc-for list="{{ tilesM }}" as="tile" hint-placeholder-count="3"><div style="{{ tile.mDock }}"><div style="display:flex;align-items:flex-end;gap:1.5px;height:18px;margin-bottom:6px;"><sc-for list="{{ tile.bars }}" as="b" hint-placeholder-count="30"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.85;"></div></sc-for></div><div style="font:600 11px 'Space Grotesk';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ tile.title }}</div><div style="font:500 9px 'JetBrains Mono';color:#99A0AB;margin-top:3px;">{{ tile.bpm }} · {{ tile.key }}</div></div></sc-for></div>
353
+ </div>
354
+ </div>
355
+ </div>
356
+
357
+ <!-- ===================== MOBILE: Analysis ===================== -->
358
+ <div style="position:absolute;left:1980px;top:1400px;width:375px;">
359
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Mobile 375 · Analysis — stacked</div>
360
+ <div data-screen-label="Mobile / Analysis" style="height:820px;width:375px;background:#0F1115;border:1px solid #20242C;border-radius:30px;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.28);color:#F2EFE9;display:flex;flex-direction:column;position:relative;">
361
+ <div style="height:42px;display:flex;align-items:center;justify-content:space-between;padding:0 22px;flex:none;"><div style="font:600 13px 'JetBrains Mono';">9:41</div><div style="display:flex;align-items:flex-end;gap:2px;height:11px;"><div style="width:3px;height:5px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:8px;background:#5BE0C8;border-radius:1px;"></div><div style="width:3px;height:11px;background:#5BE0C8;border-radius:1px;"></div></div></div>
362
+ <div style="flex:1;overflow-y:auto;padding:8px 16px 20px;min-height:0;">
363
+ <div style="display:flex;align-items:center;gap:9px;margin-bottom:14px;"><div style="font:600 17px 'Space Grotesk';">Sub Cathedral · v2</div><div style="font:500 9px 'JetBrains Mono';padding:3px 7px;border-radius:5px;background:rgba(255,194,75,0.12);color:#FFC24B;">★</div></div>
364
+ <div style="background:#121419;border:1px solid #232831;border-radius:12px;padding:14px;"><div style="display:flex;align-items:flex-end;gap:1px;height:80px;"><sc-for list="{{ bigBarsM }}" as="b" hint-placeholder-count="64"><div style="flex:1;height: {{ b.h }};background: {{ b.color }};border-radius:1px;opacity:.9;"></div></sc-for></div></div>
365
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:12px;"><sc-for list="{{ measured }}" as="m" hint-placeholder-count="6"><div style="background:#121419;border:1px solid #232831;border-radius:9px;padding:10px;"><div style="font:500 9px 'JetBrains Mono';color:#5E6671;">{{ m.k }}</div><div style="font:600 16px 'JetBrains Mono';margin-top:5px;">{{ m.v }}</div></div></sc-for></div>
366
+ <div style="background:#14171D;border:1px solid rgba(255,106,61,0.4);border-radius:12px;padding:14px;margin-top:12px;"><div style="font:600 10px 'JetBrains Mono';letter-spacing:.12em;color:#FF8C5A;">DERIVED PROMPT</div><div style="margin-top:9px;font:400 13px/1.7 'Space Grotesk';background:#0F1115;border:1px solid #232831;border-radius:9px;padding:11px;"><sc-for list="{{ promptTokens }}" as="t" hint-placeholder-count="11"><span style="{{ t.style }}">{{ t.text }}</span></sc-for></div><div style="display:flex;align-items:center;justify-content:center;gap:7px;background:#FF6A3D;color:#140B07;border-radius:9px;padding:12px;font:600 13px 'Space Grotesk';margin-top:12px;min-height:44px;">Regenerate · 5 ▸ <span style="font:500 10px 'JetBrains Mono';opacity:.7;">~0.20 ◆</span></div></div>
367
+ </div>
368
+ </div>
369
+ </div>
370
+
371
+ <!-- ===================== SPEC SHEET ===================== -->
372
+ <div style="position:absolute;left:1980px;top:2280px;width:375px;">
373
+ <div data-drags-parent="1" style="font:600 13px 'JetBrains Mono';letter-spacing:.04em;color:#4A5158;margin-bottom:14px;">Spec sheet — px</div>
374
+ <div style="background:#0F1115;border:1px solid #20242C;border-radius:14px;box-shadow:0 20px 50px rgba(0,0,0,.24);color:#C9CDD4;padding:22px 24px;font:500 12px/1.9 'JetBrains Mono';">
375
+ <div style="color:#5E6671;letter-spacing:.12em;font-size:11px;margin-bottom:8px;">RADII</div>
376
+ <div>card 14 · panel 12 · input 8–9 · chip 8 · pill 20 · circle 50%</div>
377
+ <div style="color:#5E6671;letter-spacing:.12em;font-size:11px;margin:14px 0 8px;">PADDING</div>
378
+ <div>panel 18 · card 14–16 · input 10–14 · chip 6–8 / 11–14</div>
379
+ <div style="color:#5E6671;letter-spacing:.12em;font-size:11px;margin:14px 0 8px;">GAP</div>
380
+ <div>frame cols 22 · card stack 14 · chip row 7 · meta 6</div>
381
+ <div style="color:#5E6671;letter-spacing:.12em;font-size:11px;margin:14px 0 8px;">BORDERS</div>
382
+ <div>faint 20242C · default 232831 · chip 2A2F38</div>
383
+ <div style="color:#5E6671;letter-spacing:.12em;font-size:11px;margin:14px 0 8px;">HEIGHTS</div>
384
+ <div>top bar 56 · button 44 min · touch 44×44 · waveform 150</div>
385
+ </div>
386
+ </div>
387
+ </x-dc>
388
+ <script type="text/x-dc" data-dc-script data-props="{&quot;showCost&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;showSession&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;}}">
389
+ class Component extends DCLogic {
390
+ constructor(props){ super(props); this.init(); }
391
+
392
+ mkrng(seed){ let s = seed % 2147483647; if (s <= 0) s += 2147483646; return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; }
393
+
394
+ wave(seed, n, color){
395
+ const r = this.mkrng(seed); const a = [];
396
+ for (let i = 0; i < n; i++){
397
+ const t = i / (n - 1);
398
+ const env = Math.sin(t * Math.PI) * 0.5 + 0.62;
399
+ let h = (0.3 + 0.7 * r()) * env;
400
+ h = Math.max(0.12, Math.min(1, h));
401
+ a.push({ h: Math.round(h * 100) + '%', color });
402
+ }
403
+ return a;
404
+ }
405
+
406
+ bigWave(n){
407
+ const secs = [
408
+ { label:'Intro', color:'#2BB89E', grow:0.17, time:'0:00 – 0:08', amp:0.5 },
409
+ { label:'Build', color:'#5BE0C8', grow:0.205, time:'0:08 – 0:18', amp:0.74 },
410
+ { label:'Core', color:'#FF6A3D', grow:0.375, time:'0:18 – 0:36', amp:1.0 },
411
+ { label:'Outro', color:'#FF8C5A', grow:0.25, time:'0:36 – 0:48', amp:0.56 },
412
+ ];
413
+ const bounds = [0, 0.17, 0.375, 0.75, 1.0];
414
+ const r = this.mkrng(424242); const bars = [];
415
+ for (let i = 0; i < n; i++){
416
+ const t = i / (n - 1);
417
+ let si = 0;
418
+ for (let k = 0; k < 4; k++){ if (t >= bounds[k] && t < bounds[k+1]) si = k; }
419
+ if (t >= bounds[4]) si = 3;
420
+ const s = secs[si];
421
+ const local = (t - bounds[si]) / (bounds[si+1] - bounds[si]);
422
+ const env = Math.sin(Math.max(0, Math.min(1, local)) * Math.PI) * 0.42 + 0.72;
423
+ let h = (0.28 + 0.72 * r()) * s.amp * env;
424
+ h = Math.max(0.08, Math.min(1, h));
425
+ bars.push({ h: Math.round(h * 100) + '%', color: s.color });
426
+ }
427
+ return { bars, secs: secs.map(s => ({ label:s.label, color:s.color, grow:s.grow, time:s.time })) };
428
+ }
429
+
430
+ init(){
431
+ const C = { coral:'#FF6A3D', mint:'#5BE0C8', amber:'#FFC24B' };
432
+
433
+ // crate tiles
434
+ const raw = [
435
+ { title:'Sub Cathedral', bpm:162, key:'F min', model:'SA3', fav:true, depth:0, sel:false, parent:'' },
436
+ { title:'Sub Cathedral · v2', bpm:160, key:'F min', model:'SA3', fav:false, depth:1, sel:true, parent:'Sub Cathedral' },
437
+ { title:'Sub Cathedral · v1', bpm:162, key:'F min', model:'SA3', fav:false, depth:1, sel:false, parent:'Sub Cathedral' },
438
+ { title:'Amen Rinse', bpm:174, key:'A min', model:'local', fav:false, depth:0, sel:false, parent:'' },
439
+ { title:'Halflight Dub', bpm:140, key:'C min', model:'SA3', fav:false, depth:0, sel:false, parent:'' },
440
+ { title:'First Pass', bpm:158, key:'D min', model:'SA3', fav:false, depth:0, sel:false, parent:'' },
441
+ ];
442
+ raw.forEach((t, i) => {
443
+ t.isVar = t.depth > 0;
444
+ const col = t.fav ? C.amber : (t.depth ? C.mint : C.coral);
445
+ t.bars = this.wave(7 + i * 13, 30, col);
446
+ t.star = t.fav ? C.amber : '#3A3F47';
447
+ t.model = t.model === 'SA3' ? 'SA3' : 'local';
448
+ t.modelColor = t.model === 'SA3' ? '#FF8C5A' : '#5BE0C8';
449
+ t.modelBg = t.model === 'SA3' ? 'rgba(255,106,61,0.12)' : 'rgba(91,224,200,0.12)';
450
+ const b = t.sel ? 'rgba(255,106,61,0.55)' : '#22272F';
451
+ const bg = t.sel ? 'rgba(255,106,61,0.08)' : '#171A20';
452
+ t.dockStyle = 'flex:none;width:172px;border-radius:10px;padding:11px;cursor:pointer;border:1px solid ' + b + ';background:' + bg + ';';
453
+ t.mDock = 'flex:none;width:148px;border-radius:10px;padding:10px;border:1px solid ' + b + ';background:' + bg + ';';
454
+ });
455
+ this.tiles = raw;
456
+ this.tilesM = raw.slice(0, 3);
457
+
458
+ // model dropdown
459
+ const md = [
460
+ { name:'SA3 · stable-audio-3-medium', dot:'#FF6A3D', sel:true, tag:'ready', tagColor:'#5BE0C8' },
461
+ { name:'AceStep', dot:'#3A3F47', sel:false, tag:'soon', tagColor:'#5E6671' },
462
+ { name:'ElevenMusic', dot:'#3A3F47', sel:false, tag:'soon', tagColor:'#5E6671' },
463
+ { name:'Local server', dot:'#5BE0C8', sel:false, tag:'advanced',tagColor:'#5BE0C8' },
464
+ ];
465
+ this.models = md.map(m => ({
466
+ ...m,
467
+ txt: m.tag === 'soon' ? '#5E6671' : '#F2EFE9',
468
+ rowStyle: 'display:flex;align-items:center;justify-content:space-between;padding:9px 10px;border-radius:7px;' + (m.sel ? 'background:rgba(255,106,61,0.1);' : '') + (m.tag === 'soon' ? 'opacity:.55;' : 'cursor:pointer;'),
469
+ }));
470
+
471
+ // durations
472
+ const dz = [ ['Cue','15s',false],['Loop','30s',true],['Track','90s',false],['Long','180s',false] ];
473
+ this.durations = dz.map(([name,secs,sel]) => ({
474
+ name, secs,
475
+ subColor: sel ? '#FF8C5A' : '#5E6671',
476
+ style: 'flex:1;border-radius:8px;padding:10px 8px;min-height:44px;text-align:center;border:1px solid ' + (sel ? 'rgba(255,106,61,0.45)' : '#2A2F38') + ';background:' + (sel ? 'rgba(255,106,61,0.1)' : '#171A20') + ';color:' + (sel ? '#FF8C5A' : '#C9CDD4') + ';',
477
+ }));
478
+
479
+ // filters
480
+ const fz = [ ['All',true],['★ Faves',false],['SA3',false],['Local',false] ];
481
+ this.filters = fz.map(([label,sel]) => ({
482
+ label,
483
+ style: 'font:500 11px \'JetBrains Mono\';padding:5px 11px;border-radius:13px;' + (sel ? 'background:rgba(255,106,61,0.14);color:#FF8C5A;' : 'background:#171A20;border:1px solid #2A2F38;color:#99A0AB;'),
484
+ }));
485
+
486
+ // big waveform
487
+ const w = this.bigWave(128); this.bigBars = w.bars; this.secs = w.secs;
488
+ this.bigBarsM = this.bigWave(64).bars;
489
+ this.measured = [
490
+ { k:'BPM', v:'162' }, { k:'KEY', v:'F min' }, { k:'LUFS', v:'−9.2' },
491
+ { k:'TRUE PK', v:'−0.8' }, { k:'LRA', v:'6.4' }, { k:'LENGTH', v:'0:48' },
492
+ ];
493
+
494
+ // derived-prompt tagged tokens — coral underline = intent, mint = measured
495
+ const ul = (c) => 'border-bottom:2px solid ' + c + ';padding-bottom:1px;';
496
+ const T = (text, src) => ({ text, style: src === 'i' ? ul('#FF8C5A') : (src === 'm' ? ul('#5BE0C8') : '') });
497
+ this.promptTokens = [
498
+ T('dark', 'i'), T(' ', 'p'), T('atmospheric', 'i'), T(' ', 'p'), T('jungle', 'i'), T(', ', 'p'),
499
+ T('sub-heavy', 'i'), T(', ', 'p'), T('lo-fi tape hiss', 'i'), T(' — ', 'p'),
500
+ T('compressed amen break', 'm'), T(', ', 'p'), T('162bpm F min', 'm'), T(', ', 'p'),
501
+ T('18s sustained core', 'm'), T(' then ', 'p'), T('3 stutter outros', 'm'),
502
+ ];
503
+ this.nudges = ['+ more lo-fi', '+ brighter', '+ reverb', '− shorten'];
504
+
505
+ // streaming variants (sequential)
506
+ const sv = [
507
+ { lbl:'v1', bpm:'162', match:88, fav:false, state:'done' },
508
+ { lbl:'v2', bpm:'160', match:94, fav:true, state:'done' },
509
+ { lbl:'v3', bpm:'', match:0, fav:false, state:'gen' },
510
+ { lbl:'v4', bpm:'', match:0, fav:false, state:'queued' },
511
+ { lbl:'v5', bpm:'', match:0, fav:false, state:'queued' },
512
+ ];
513
+ this.streamVars = sv.map((v, i) => ({
514
+ lbl: v.lbl, bpm: v.bpm,
515
+ done: v.state === 'done', gen: v.state === 'gen', queued: v.state === 'queued',
516
+ bars: this.wave(201 + i * 19, 40, v.fav ? C.amber : C.coral),
517
+ matchPct: v.match + '%',
518
+ matchColor: v.match >= 90 ? '#5BE0C8' : (v.match >= 85 ? '#FF8C5A' : '#99A0AB'),
519
+ star: v.fav ? C.amber : '#3A3F47',
520
+ wrap: 'border-radius:10px;padding:10px 11px;border:1px solid ' + (v.fav ? 'rgba(255,194,75,0.45)' : '#232831') + ';background:' + (v.fav ? 'rgba(255,194,75,0.06)' : '#171A20') + ';',
521
+ }));
522
+
523
+ // compare
524
+ const cmp = [
525
+ { name:'A · claude', sub:'measured-grounded', tone:'#FF6A3D', score:92, scorePct:'92%', note:'Names the 18s sustained core and the three stutter outros. BPM and key nailed from the numbers.', tags:[['18s core','#FF8C5A'],['162 BPM','#5BE0C8'],['amen','#FF8C5A']] },
526
+ { name:'B · openai-large', sub:'measured-grounded', tone:'#5BE0C8', score:88, scorePct:'88%', note:'Same structural arc, looser genre call — but adds useful texture vocabulary.', tags:[['arc ✓','#5BE0C8'],['texture','#FF8C5A']] },
527
+ { name:'C · gemini', sub:'audio-only', tone:'#7A828D', score:61, scorePct:'61%', note:'Vague on structure, BPM drifts, genre confusion. No grounding to anchor a regen prompt.', tags:[['BPM drift','#7A828D'],['vague','#7A828D']] },
528
+ ];
529
+ this.compare = cmp.map(c => ({
530
+ ...c,
531
+ tags: c.tags.map(([t, color]) => ({ t, color })),
532
+ cardStyle: 'background:#121419;border:1px solid ' + (c.tone === '#7A828D' ? '#232831' : c.tone + '55') + ';border-radius:12px;padding:16px;',
533
+ }));
534
+ }
535
+
536
+ renderVals(){
537
+ return {
538
+ tiles: this.tiles, tilesM: this.tilesM,
539
+ models: this.models, durations: this.durations, filters: this.filters,
540
+ bigBars: this.bigBars, bigBarsM: this.bigBarsM, secs: this.secs, measured: this.measured,
541
+ promptTokens: this.promptTokens, nudges: this.nudges,
542
+ streamVars: this.streamVars, compare: this.compare,
543
+ showCost: this.props.showCost ?? true,
544
+ showSession: this.props.showSession ?? true,
545
+ };
546
+ }
547
+ }
548
+ </script>
549
+ </body>
550
+ </html>
design/README.md ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Handoff: audio·brief — Direction B (refined)
2
+
3
+ ## Overview
4
+ **audio·brief** is a workbench for recreating audio consistently with AI. The user generates a take with Stable Audio 3 (SA3), gets a **measured + LLM read** of what makes it tick (BPM, key, sections, loudness), then re-prompts for **5 variants** that feel like cousins of the source. The wedge: every brief is grounded in real measured numbers — not just "what the model heard" — and variant generations stay close to the source because the prompt is rebuilt from those numbers + the original intent.
5
+
6
+ This package documents the **refined Direction B** design (the chosen direction from an earlier A/B/C exploration).
7
+
8
+ ## About the Design Files
9
+ The files in this bundle are **design references created in HTML** — prototypes showing intended look and behavior, **not production code to copy directly**. They are authored as "Design Components" (a streaming-template format); treat them as visual + behavioral specs.
10
+
11
+ The task is to **recreate these designs in the target codebase's environment**, using its established patterns. Two parallel deployment paths are in play (both ship on **Hugging Face Spaces**):
12
+
13
+ | | Path A — Gradio Space | Path B — Docker Space + React/Svelte |
14
+ |---|---|---|
15
+ | timeline | ship now | 1–2 weeks |
16
+ | vocabulary | `gr.Radio`, `gr.Dropdown`, `gr.Audio`, `gr.HTML`, `gr.Tab` styled via CSS overlay | full component framework, no constraints |
17
+
18
+ **Implement the ideal (Path B), and respect the Path A compromises.** Every surface in the mock carries an inline note: `◐ Path A` (Gradio-feasible via gr.* + CSS) vs `◆ Path B` (needs a custom/React component). Those notes are reproduced per-surface below.
19
+
20
+ ## Fidelity
21
+ **High-fidelity (hifi).** Final colors, typography, spacing, and interaction states are specified. Recreate the UI pixel-accurately using the target environment's libraries. All hex values, px measurements, and font specs below are authoritative.
22
+
23
+ ---
24
+
25
+ ## Design Tokens
26
+
27
+ ### Color — semantic families (3)
28
+ | token | hex | meaning / usage |
29
+ |---|---|---|
30
+ | coral / coral-bright | `#FF6A3D` / `#FF8C5A` | **generative** axis — prompts, gens, variants, "create" actions, primary buttons |
31
+ | mint / mint-deep | `#5BE0C8` / `#2BB89E` | **measured** axis — BPM, key, sections, loudness; mint-deep used only for the darkest section (Intro) |
32
+ | amber | `#FFC24B` | **anchor** — the favourited (★) take we blend from; wallet diamond `◆` |
33
+ | slate (compare C) | `#7A828D` | the un-grounded "audio-only" column in the Validate view |
34
+
35
+ ### Color — background ramp (darkest → lightest)
36
+ `#0F1115` page → `#121419` panels → `#14171D` cards → `#171A20` inputs
37
+
38
+ ### Color — borders
39
+ `#20242C` faint (dividers) → `#232831` default (cards) → `#2A2F38` chip/input
40
+
41
+ ### Color — text
42
+ `#F2EFE9` cream (body) → `#C9CDD4` secondary → `#99A0AB` muted → `#5E6671` uppercase mono labels
43
+
44
+ ### Typography
45
+ - **Space Grotesk** (400/500/600/700) — body + headings
46
+ - **JetBrains Mono** (400/500/600) — numbers, uppercase labels, code, micro-meta
47
+ - Sizes: h1 22px / h2 16–18px / body 14px / meta 11–12px
48
+ - **Uppercase mono labels**: 11px, weight 600, `letter-spacing: 0.12em`, color `#5E6671` — used for section dividers + field titles
49
+
50
+ ### Radii
51
+ card 14 · panel 12 · input 8–9 · chip 8 · pill 20 · circle 50%
52
+
53
+ ### Padding
54
+ panel 18 · card 14–16 · input 10–14 · chip 6–8 (vertical) / 11–14 (horizontal)
55
+
56
+ ### Gap
57
+ frame columns 22 · card stack 14 · chip row 7 · meta items 6
58
+
59
+ ### Heights / sizing
60
+ top bar **56** · primary button min **44** · **touch target 44×44 min** · waveform **150** (desktop) / 80 (mobile)
61
+
62
+ ### Layout constraints
63
+ - Max content width **1200px**, centered, dark gutters
64
+ - Breakpoints: 375 (phone), 768 (iPad portrait), 1024 (iPad landscape)
65
+
66
+ ### Animation
67
+ - Chip/tab selection: **120ms** transitions, snappy
68
+ - Tabs slide; loading **pulses**, never spins
69
+ - `pulse` keyframe: opacity 0.45 → 1 → 0.45 over ~1.1s (text cursor, generating states, live status dot)
70
+ - `shimmer` keyframe: a left-to-right gradient sweep over ~1.2s on the streaming variant placeholders
71
+ - Consider a small visual pulse on Generate success (no sound design)
72
+
73
+ ### Brand mark
74
+ 4 small vertical bars (2 coral, 2 mint, varying heights: ~7/16/10/13px at 18px container) + "audio·brief" wordmark with a **mint `·`** separator. Used in top bar, favicon, and scorecard PNG exports.
75
+
76
+ ---
77
+
78
+ ## Screens / Views
79
+
80
+ ### 1. Top bar (56px)
81
+ - **Layout**: full-width flex, space-between, `#121419` bg, `#20242C` bottom border, 0 20px padding.
82
+ - **Left**: brand mark + wordmark.
83
+ - **Center**: 3-tab pill group (Generate / Analyse / Compare) inside a `#14171D` rounded-10 container with 4px padding; each tab 7px 16px, radius 7. Active tab = tinted bg + colored text (coral tint for Generate, mint tint for Analyse). Inactive text `#99A0AB`.
84
+ - **Right**: optional `0.84 ◆ session` running total (muted), then the **wallet pill** + 30px gradient avatar (coral→mint).
85
+ - **Wallet pill IS the wallet UI** (no separate settings page):
86
+ - **Connected**: `#171A20` bg, `#2A2F38` border, radius 20, 6px 13px. Amber `◆` + balance (`4.20`, JetBrains Mono 600 13px) + `POLLEN` label (mono 10px `#5E6671`).
87
+ - **Disconnected**: coral-tinted CTA — `rgba(255,106,61,0.12)` bg, `rgba(255,106,61,0.45)` border, coral `◆` + "connect pollinations" text (`#FF8C5A`).
88
+ - **Behavior**: click → **same-tab** OAuth redirect to `enter.pollinations.ai`; on return the URL fragment carries `#api_key=sk_…`, captured into session state. No persistent wallet on Spaces (multi-user) — per-visitor session only.
89
+ - `◐ Path A`: gr.HTML + JS for the pill; tabs = gr.Tab.
90
+
91
+ ### 2. Generate composer (`◐ Path A OK, needs polish`)
92
+ Centered, max ~760px column.
93
+ - **Prompt box**: `#171A20` input, `#232831` border, radius 14, padding 18. Label "PROMPT" (mono label spec) + char counter `142 / 600` top-right. Multi-line textbox; placeholder reads well in mono. Blinking coral text cursor (2px wide, `pulse`).
94
+ - **Model dropdown** (`◐` for the control; `◆` for the "soon" rows): `#171A20` input, radius 9. Selected row shows a 7px **coral** dot + `SA3 · stable-audio-3-medium`. Open menu (`#14171D`, shadow): rows are —
95
+ - `SA3 · stable-audio-3-medium` — coral dot, tag `ready` (mint), selected (coral tint bg)
96
+ - `AceStep` — grey dot, tag `soon`, **opacity 0.55, not clickable**
97
+ - `ElevenMusic` — grey dot, tag `soon`, disabled
98
+ - `Local server` — **mint** dot, tag `advanced` (mint)
99
+ - **Duration chips** (`◐` gr.Radio styled): 4 chips in a flex row, each `flex:1`, min-height 44, radius 8. `Cue 15s` / `Loop 30s` / `Track 90s` / `Long 180s`. Selected = coral-tinted bg + border + `#FF8C5A` text; name in Space Grotesk 600 12px, secs in mono 10px.
100
+ - **Variation spread** (`◆ HOLD — Pollinations doesn't expose CFG yet`): label + `awaiting CFG API · disabled` tag. Three chips `tight / balanced / wild`, **entire group opacity 0.45, pointer-events none**. Design only — wire when API supports.
101
+ - **Generate button**: coral `#FF6A3D`, text `#140B07`, radius 11, **width capped 360px** (not stretched), padding 14, Space Grotesk 600 15px, soft coral glow shadow.
102
+ - **Cost pip** (`◆`): inline beside button — `~0.04 ◆ per call · flat` (mono, muted; amber diamond). SA3 is 0.04 pollen/call flat regardless of duration.
103
+
104
+ ### 3. Analysis view — THE WEDGE (most important) (`◆ Path B for the rich version`)
105
+ This is where the user lands after picking a tile + "Use for analysis →". Two-column layout (left `flex:1.5`, right `flex:1`, gap 22), then a crate dock spanning the bottom.
106
+
107
+ **Header (full width)**: tile name (h1 22px) + amber `★ anchor` badge + mint status pill `analysed in 41s · local`. Right side: **`Validate vs Gemini`** outline button + a 34px circular mint play button.
108
+
109
+ **Left column:**
110
+ - **Waveform card** (`#121419`, border `#232831`, radius 14, padding 18): 150px-tall bar waveform, **section-colored** — Intro = mint-deep `#2BB89E`, Build = mint `#5BE0C8`, Core = coral `#FF6A3D`, Outro = coral-bright `#FF8C5A`. A cream playhead line + dot overlay at the current position. Below: section labels with a 2px colored top-border + label (Space Grotesk 600 12px) + time range (mono 10px `#5E6671`), widths proportional to section length.
111
+ - **6-up metric grid** (`◆ already implemented, looks right`): 6 equal cards, each label (mono 10px `#5E6671`) + value (JetBrains Mono 600 21px `#F2EFE9`). Values: BPM `162` / KEY `F min` / LUFS `−9.2` / TRUE PK `−0.8` / LRA `6.4` / LENGTH `0:48`.
112
+ - **THE READ card** — label "THE READ — MEASURED + LLM" (mint). Brief paragraph (Space Grotesk 14px, line-height 1.65, `#C9CDD4`) with **inline-colored numbers**: mint for measurements (`162 BPM in F minor`, `−9.2 LUFS`, `6.4 LU`), coral for arc descriptors (`18-second sustained core`, `three stutter outros`).
113
+
114
+ **Right column:**
115
+ - **DERIVED PROMPT card** (`#14171D`, **coral border** `rgba(255,106,61,0.4)`, radius 14): label "DERIVED PROMPT" (coral) + `editable`. Editable prompt area (`#0F1115` inset, radius 10, line-height 1.75). **The key feature — intent vs measured tagging**: each phrase is underlined (2px border-bottom) by source — **coral `#FF8C5A` underline = the user's original intent**, **mint `#5BE0C8` underline = derived from the measured arc**. A small legend below explains both. Example blended prompt: _dark / atmospheric / jungle / sub-heavy / lo-fi tape hiss_ (coral) — _compressed amen break / 162bpm F min / 18s sustained core / 3 stutter outros_ (mint).
116
+ - **Nudge chips**: `+ more lo-fi` `+ brighter` `+ reverb` `− shorten` (mono 11px, `#171A20` chips).
117
+ - **Regenerate button**: coral, full-width, `Regenerate · 5 variants ▸`, with `~0.20 ◆` cost beside it.
118
+ - **5 VARIANTS card** — variants stream in **sequentially** (honest about cost), shown as a vertical list with three row states:
119
+ - **done**: circular coral play button + mini-waveform + match % (mint ≥90, coral ≥85, else muted) + BPM + ★. The ★ favourite (anchor) gets an amber-tinted row.
120
+ - **generating**: empty circle + **shimmer bar** + `v3 · gen` (coral, pulsing).
121
+ - **queued**: dashed circle + dashed bar + `v4 · queued` (opacity 0.5).
122
+ - Header shows `generating 3 / 5 · sequential`.
123
+
124
+ **Crate dock (bottom, full width)**: see #4.
125
+
126
+ ### 4. Crate (`◐ Path A: chip-row Radio` / `◆ Path B: the real version below`)
127
+ Horizontal scrolling strip of tile cards (172px wide each, radius 10).
128
+ - **Tile card**: lineage line (`↳ from Sub Cathedral` mono 9px) for variants, then a 22px mini-waveform thumbnail (color = amber if fav, mint if variant, coral if root), title (Space Grotesk 600 12px, ellipsis), `★` (amber if fav else `#3A3F47`), and a meta row: `162 · F min` + a **model badge pill** (coral pill for SA3, **mint pill for local-server**).
129
+ - **Selected tile**: coral-tinted bg + coral border.
130
+ - **Header**: `Crate` + `18 takes · drag to reorder`, filter chips `All / ★ Faves / SA3 / Local` (selected = coral tint), and a `View all ▦` affordance.
131
+ - **Behavior**: tap a tile to audition (per-tile play) without leaving the crate; click `★` to favourite/anchor; drag to reorder; variants indent under their parent.
132
+ - **Scale answer (50+ tiles)**: strip stays horizontal; `View all ▦` expands to a **grid drawer**. Filter chips do the heavy lifting before that.
133
+ - `◆ Path B` needs: inline waveform thumbnails (a `waveform.py` renders PNGs), per-tile play, drag-reorder, lineage indenting.
134
+
135
+ ### 5. Validate vs Gemini (`◐ Path A: three gr.HTML columns`)
136
+ The marketing artifact — **demoted from a standing tab to a button triggered from Analysis**.
137
+ - **Header**: one-line question `Does measured grounding beat audio-only?` + an `i` tooltip (the long intro paragraph lives in the tooltip, not on the page). Export buttons: `PNG scorecard`, `Report ↓`.
138
+ - **3 columns**: `A · claude` (measured-grounded, coral, score 92), `B · openai-large` (measured-grounded, mint, score 88), `C · gemini` (audio-only, slate, score 61). Each: name + sub + big mono score, a score bar, a note paragraph, and small finding tags.
139
+ - **Empty state (pre-run)**: show **ONE dotted-pill placeholder card** with a `Validate vs Gemini` CTA — never three empty slots.
140
+ - A mix-chain comparison table sits below (already styled in the current build).
141
+
142
+ ### 6. Empty states (currently weak — design properly)
143
+ **Rule: hide, don't show empty bordered boxes with a ♫ icon.**
144
+ - **Crate empty** → dashed card: faint bars icon + "Your crate is empty" + "Generate a take or drop in a clip to start." + `Generate` / `Upload clip` buttons.
145
+ - **Latest gen** → **hidden entirely** until first gen.
146
+ - **Selected tile** → **hidden entirely** until a selection exists.
147
+ - **5 variants grid** → **hidden** until first regen.
148
+ - **Waveform image** → **hidden** until analysis runs.
149
+ - **Compare pre-run** → single dotted placeholder card (above).
150
+
151
+ ### 7. Local model bridge (power-user, advanced)
152
+ - Collapsed accordion row: `Local gen server (advanced)`.
153
+ - Expanded: **SERVER URL** field, default `http://localhost:7864`, with a mint status dot.
154
+ - Active-path banner (mint tint): _"running on your machine — gens stay local, then upload for analysis"_, with a **pulsing mint dot**.
155
+ - Flow: when "Local server" is the chosen model + Generate is clicked, the **browser** fetches the user's localhost, gets audio bytes, uploads to the backend for crate-add + analysis.
156
+ - Affordance for "your own machine": tiles from this path get the **mint model badge** ("local") vs coral "SA3"; the gen status dot pulses mint while the local path is active.
157
+
158
+ ---
159
+
160
+ ## Interactions & Behavior
161
+ - **Tabs**: 3-way (Generate / Analyse / Compare). Selecting slides the active indicator (120ms).
162
+ - **Generate**: click → sequential variant generation; show per-row done/generating/queued states; pulse on success.
163
+ - **Use for analysis →**: routes a selected crate tile into the Analysis view.
164
+ - **Validate vs Gemini**: triggered from Analysis header; opens the 3-column comparison; exports PNG + markdown report.
165
+ - **Favourite (★)**: anchors a take; the anchor's amber tint propagates wherever the take appears (tile, variant row, header badge). The anchor is what the next blend derives from.
166
+ - **Nudge chips**: append/modify the derived prompt before regen ("+ more lo-fi") — should be one keystroke, not a full re-derivation.
167
+ - **Component states** (all need hover / active / disabled / loading):
168
+ - **Primary button**: default coral → hover coral-bright + lift shadow → active scale 0.97 + darker → loading `#171A20` bg + coral pulsing text → disabled `#1A1D23` + `#5E6671` text.
169
+ - **Chip/tab**: default `#171A20`/`#2A2F38` → selected mint-tint (or coral-tint by axis) → hover `#1E222A`/`#3A3F47` → disabled opacity 0.5.
170
+ - **Audio player**: paused = outline coral play; playing = filled coral pause + progress fill.
171
+ - **Responsive**: at 375px, columns stack; crate becomes a **bottom sheet** with a drag handle; metric grid goes 3-up; touch targets ≥44px.
172
+
173
+ ## State Management
174
+ - `wallet`: { connected, apiKey (session only), balance, sessionSpend }
175
+ - `crate`: ordered list of takes — each { id, title, bpm, key, durationSec, model('SA3'|'local'), parentId, isFavourite, waveformPng, audioUrl }
176
+ - `selectedTakeId` → drives Analysis
177
+ - `analysis`: { bpm, key, lufs, truePeak, lra, lengthSec, sections[{label,startSec,endSec}], readText, derivedPrompt: tokens[{text, source: 'intent'|'measured'|'plain'}] }
178
+ - `generation`: { status, prompt, model, durationPreset, variants[{id, state:'queued'|'generating'|'done', matchPct, ...}] } — variants generate **sequentially**.
179
+ - `compare`: { status:'idle'|'running'|'done', columns[{model, mode:'measured'|'audio-only', score, note, tags}] }
180
+ - **Cost transparency (answer)**: show **both** — a per-action preview pip (`~0.04 ◆`, `~0.20 ◆`) AND a session running total in the top bar (`0.84 ◆ session`).
181
+ - **Catalog**: Pollinations model catalog can drift — fetch at startup.
182
+
183
+ ## Data / API notes (from the brief, not design calls)
184
+ - Audio analysis pipeline (librosa BPM/key/sections, pyloudnorm LUFS/LRA/peak, demucs stems, basic-pitch MIDI) — local Python, ~30–60s/track. Tags via `essentia-tensorflow` is one install away (+~200MB weights).
185
+ - LLM brief / blend / mix-chain: Pollinations text API (40+ models). Audio gen: Pollinations SA3, AceStep, ElevenMusic — already remote.
186
+ - Crate persists across reloads in `/tmp/audio-brief-crate/` today.
187
+
188
+ ## Assets
189
+ - No raster assets shipped. Waveforms in the mock are procedurally drawn bars; in production they come from `waveform.py` PNGs (section-colored). Brand mark is pure CSS (4 bars + text). Icons are unicode glyphs (`▸ ★ ◆ ▶ ❚❚ ▾ ▴ ↳ ▦`) — swap for the codebase's icon set.
190
+ - Fonts: Space Grotesk + JetBrains Mono (Google Fonts).
191
+ - Inspiration reference: ReferenceMix (referencemix.vercel.app) — same shape but text-only input; audio·brief grounds in real measurements.
192
+
193
+ ## Files
194
+ - `Audio Brief v2.dc.html` — **the refined Direction B spec** documented above (system band, top-bar states, Generate composer, Analysis hero, Validate, crate, component states, empty states, local bridge, mobile 375 frames, px spec sheet).
195
+ - `Audio Brief v1 (three directions).dc.html` — earlier A/B/C exploration (Studio Rail / Crate Dock / Loop Flow) for context on rejected directions.
196
+ - `support.js` — runtime for the `.dc.html` design format (needed only to open the HTML files locally; **not** something to port).
197
+
198
+ ### Opening the design files
199
+ Open either `.dc.html` in a browser (they're self-contained alongside `support.js`). They render as a pannable canvas of frames. Use them as the visual source of truth; read exact values from this README.
design/support.js ADDED
@@ -0,0 +1,1581 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`.
2
+ "use strict";
3
+ (() => {
4
+ var __defProp = Object.defineProperty;
5
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
7
+
8
+ // src/react.ts
9
+ function getReact() {
10
+ const R = window.React;
11
+ if (!R) throw new Error("dc-runtime: window.React is not available yet");
12
+ return R;
13
+ }
14
+ function getReactDOM() {
15
+ const RD = window.ReactDOM;
16
+ if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet");
17
+ return RD;
18
+ }
19
+ var h = ((...args) => getReact().createElement(
20
+ ...args
21
+ ));
22
+
23
+ // src/parse.ts
24
+ function parseDcDocument(doc) {
25
+ const dc = doc.querySelector("x-dc");
26
+ if (!dc) return null;
27
+ const scriptEl = doc.querySelector("script[data-dc-script]");
28
+ const { props, preview } = parseDataProps(
29
+ scriptEl?.getAttribute("data-props") ?? null
30
+ );
31
+ return {
32
+ template: dc.innerHTML,
33
+ js: scriptEl ? scriptEl.textContent || "" : "",
34
+ props,
35
+ preview
36
+ };
37
+ }
38
+ function parseDcText(src) {
39
+ const openMatch = /<x-dc(?:\s[^>]*)?>/.exec(src);
40
+ if (!openMatch) return null;
41
+ const close = src.lastIndexOf("</x-dc>");
42
+ if (close === -1 || close < openMatch.index) return null;
43
+ const template = src.slice(openMatch.index + openMatch[0].length, close);
44
+ const doc = new DOMParser().parseFromString(src, "text/html");
45
+ const scriptEl = doc.querySelector("script[data-dc-script]");
46
+ const { props, preview } = parseDataProps(
47
+ scriptEl?.getAttribute("data-props") ?? null
48
+ );
49
+ return {
50
+ template,
51
+ js: scriptEl ? scriptEl.textContent || "" : "",
52
+ props,
53
+ preview
54
+ };
55
+ }
56
+ function parseDataProps(raw) {
57
+ if (!raw) return { props: null, preview: null };
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch {
62
+ return { props: null, preview: null };
63
+ }
64
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
65
+ return { props: null, preview: null };
66
+ }
67
+ const obj = parsed;
68
+ const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null;
69
+ const rest = {};
70
+ for (const k of Object.keys(obj)) {
71
+ if (k[0] !== "$") rest[k] = obj[k];
72
+ }
73
+ return { props: Object.keys(rest).length ? rest : null, preview };
74
+ }
75
+ function dcNameFromPath(pathname) {
76
+ let p = pathname || "";
77
+ try {
78
+ p = decodeURIComponent(p);
79
+ } catch {
80
+ }
81
+ const base = p.split("/").pop() || "Root";
82
+ return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root";
83
+ }
84
+
85
+ // src/boot.ts
86
+ var BASE_CSS = `
87
+ .sc-placeholder{background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5);
88
+ border-radius:2px;box-sizing:border-box;overflow:hidden}
89
+ @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}}
90
+ html.sc-dc-streaming .sc-placeholder,
91
+ html.sc-dc-streaming .sc-interp.sc-missing{position:relative;
92
+ background:color-mix(in srgb,currentColor 5%,transparent);
93
+ border-color:transparent}
94
+ html.sc-dc-streaming .sc-placeholder::before,
95
+ html.sc-dc-streaming .sc-interp.sc-missing::before{content:'';
96
+ position:absolute;inset:0;pointer-events:none;
97
+ background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%);
98
+ background-size:400% 100%;animation:sc-shine 1.4s ease infinite}
99
+ html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before,
100
+ html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none;
101
+ background:color-mix(in srgb,currentColor 8%,transparent)}
102
+ .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace;
103
+ color:rgba(0,0,0,.7);word-break:break-word}
104
+ .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden;
105
+ vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5);
106
+ border-radius:2px;box-sizing:border-box;color:transparent;
107
+ user-select:none}
108
+ .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em;
109
+ color:rgba(0,0,0,.5);background:rgba(0,0,0,.05);border-radius:3px;
110
+ padding:0 3px}
111
+ .sc-host.sc-has-error{position:relative}
112
+ .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch;
113
+ padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace;
114
+ border-radius:4px;white-space:pre-wrap;pointer-events:none}
115
+ /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both
116
+ in sync until dc-runtime regains a build step. */
117
+ @media print {
118
+ @page { margin: 0.5cm; }
119
+ figure, table { break-inside: avoid; }
120
+ #dc-root, #dc-root > .sc-host { height: auto; }
121
+ *, *::before, *::after {
122
+ print-color-adjust: exact; -webkit-print-color-adjust: exact;
123
+ backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
124
+ animation-delay: -99s !important; animation-duration: .001s !important;
125
+ animation-iteration-count: 1 !important; animation-fill-mode: both !important;
126
+ animation-play-state: running !important; transition-duration: 0s !important;
127
+ }
128
+ }
129
+ `;
130
+ var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}";
131
+ function rootNameForDocument(doc, loc) {
132
+ let bootPath = loc.pathname || "";
133
+ if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) {
134
+ try {
135
+ bootPath = new URL(doc.baseURI || "/").pathname;
136
+ } catch {
137
+ }
138
+ }
139
+ return dcNameFromPath(bootPath);
140
+ }
141
+ function safeDecode(s) {
142
+ try {
143
+ return decodeURIComponent(s);
144
+ } catch {
145
+ return s;
146
+ }
147
+ }
148
+ function boot(runtime, doc = document) {
149
+ const parsed = parseDcDocument(doc);
150
+ if (!parsed) return null;
151
+ const React = getReact();
152
+ const rootName = rootNameForDocument(doc, location);
153
+ runtime.markFetched(rootName);
154
+ runtime.setRootName(rootName);
155
+ runtime.adoptParsed(rootName, parsed);
156
+ fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => {
157
+ const raw = t ? parseDcText(t) : null;
158
+ if (raw?.template) runtime.updateHtml(rootName, raw.template);
159
+ }).catch(() => {
160
+ });
161
+ const dc = doc.querySelector("x-dc");
162
+ const hostEl = doc.createElement("div");
163
+ hostEl.id = "dc-root";
164
+ dc.replaceWith(hostEl);
165
+ if (!parsed.preview) {
166
+ const s = doc.createElement("style");
167
+ s.textContent = FULL_PAGE_CSS;
168
+ doc.head.appendChild(s);
169
+ }
170
+ const Root = runtime.getDC(rootName);
171
+ const entry = runtime.registry.get(rootName);
172
+ function StandaloneRoot() {
173
+ const [, setTick] = React.useState(0);
174
+ React.useEffect(() => {
175
+ const sub = () => setTick((n) => n + 1);
176
+ entry.subs.add(sub);
177
+ return () => {
178
+ entry.subs.delete(sub);
179
+ };
180
+ }, []);
181
+ return h(Root, entry.propOverrides || null);
182
+ }
183
+ const ReactDOM = getReactDOM();
184
+ if (ReactDOM.createRoot)
185
+ ReactDOM.createRoot(hostEl).render(h(StandaloneRoot));
186
+ else ReactDOM.render(h(StandaloneRoot), hostEl);
187
+ return rootName;
188
+ }
189
+
190
+ // src/expr.ts
191
+ var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/;
192
+ var NUMBER_RE = /^-?\d+(\.\d+)?$/;
193
+ function resolve(vals, src) {
194
+ const expr = String(src).trim();
195
+ if (!expr) return void 0;
196
+ if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) {
197
+ return resolve(vals, expr.slice(1, -1));
198
+ }
199
+ const eq = findTopLevelEquality(expr);
200
+ if (eq) {
201
+ const lv = resolve(vals, expr.slice(0, eq.index));
202
+ const rv = resolve(vals, expr.slice(eq.index + eq.op.length));
203
+ switch (eq.op) {
204
+ case "===":
205
+ return lv === rv;
206
+ case "!==":
207
+ return lv !== rv;
208
+ case "==":
209
+ return lv == rv;
210
+ default:
211
+ return lv != rv;
212
+ }
213
+ }
214
+ if (expr[0] === "!") return !resolve(vals, expr.slice(1));
215
+ if (expr === "true") return true;
216
+ if (expr === "false") return false;
217
+ if (expr === "null") return null;
218
+ if (expr === "undefined") return void 0;
219
+ if (NUMBER_RE.test(expr)) return Number(expr);
220
+ if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) {
221
+ return expr.slice(1, -1);
222
+ }
223
+ return resolvePath(vals, expr);
224
+ }
225
+ function parensWrapWhole(expr) {
226
+ let depth = 0;
227
+ for (let i = 0; i < expr.length - 1; i++) {
228
+ if (expr[i] === "(") depth++;
229
+ else if (expr[i] === ")") {
230
+ depth--;
231
+ if (depth === 0) return false;
232
+ }
233
+ }
234
+ return true;
235
+ }
236
+ function findTopLevelEquality(expr) {
237
+ let depth = 0;
238
+ for (let i = 0; i < expr.length; i++) {
239
+ const c = expr[i];
240
+ if (c === "[" || c === "(") depth++;
241
+ else if (c === "]" || c === ")") depth--;
242
+ else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") {
243
+ if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue;
244
+ if (!expr.slice(0, i).trim()) continue;
245
+ const op = expr[i + 2] === "=" ? c + "==" : c + "=";
246
+ return { index: i, op };
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+ function resolvePath(vals, expr) {
252
+ const head = expr.match(IDENT_RE);
253
+ if (!head) return void 0;
254
+ let cur = vals == null ? void 0 : vals[head[0]];
255
+ let i = head[0].length;
256
+ while (i < expr.length) {
257
+ if (expr[i] === ".") {
258
+ const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/);
259
+ if (!m) return void 0;
260
+ cur = cur == null ? void 0 : cur[m[0]];
261
+ i += 1 + m[0].length;
262
+ } else if (expr[i] === "[") {
263
+ let depth = 1;
264
+ let j = i + 1;
265
+ while (j < expr.length && depth > 0) {
266
+ if (expr[j] === "[") depth++;
267
+ else if (expr[j] === "]") {
268
+ depth--;
269
+ if (depth === 0) break;
270
+ }
271
+ j++;
272
+ }
273
+ if (depth !== 0) return void 0;
274
+ const key = resolve(vals, expr.slice(i + 1, j));
275
+ cur = cur == null ? void 0 : cur[key];
276
+ i = j + 1;
277
+ } else {
278
+ return void 0;
279
+ }
280
+ }
281
+ return cur;
282
+ }
283
+
284
+ // src/encode.ts
285
+ var CAMEL_ATTR = "sc-camel-";
286
+ var RAW_WRAP = {
287
+ select: "sc-raw-select",
288
+ table: "sc-raw-table",
289
+ tbody: "sc-raw-tbody",
290
+ thead: "sc-raw-thead",
291
+ tfoot: "sc-raw-tfoot",
292
+ tr: "sc-raw-tr",
293
+ td: "sc-raw-td",
294
+ th: "sc-raw-th",
295
+ caption: "sc-raw-caption"
296
+ };
297
+ var RAW_UNWRAP = Object.fromEntries(
298
+ Object.entries(RAW_WRAP).map(([k, v]) => [v, k])
299
+ );
300
+ var EVENT_MAP = {
301
+ onclick: "onClick",
302
+ onchange: "onChange",
303
+ oninput: "onInput",
304
+ onsubmit: "onSubmit",
305
+ onkeydown: "onKeyDown",
306
+ onkeyup: "onKeyUp",
307
+ onkeypress: "onKeyPress",
308
+ onmousedown: "onMouseDown",
309
+ onmouseup: "onMouseUp",
310
+ onmouseenter: "onMouseEnter",
311
+ onmouseleave: "onMouseLeave",
312
+ onfocus: "onFocus",
313
+ onblur: "onBlur",
314
+ ondoubleclick: "onDoubleClick",
315
+ oncontextmenu: "onContextMenu"
316
+ };
317
+ var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
318
+ var IMPORT_SELF_CLOSE_RE = new RegExp(
319
+ "<(x-import|dc-import)(" + ATTRS + ")/>",
320
+ "gi"
321
+ );
322
+ var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g;
323
+ function encodeCase(html) {
324
+ html = html.replace(
325
+ IMPORT_SELF_CLOSE_RE,
326
+ (_, t, a) => "<" + t + a + "></" + t + ">"
327
+ );
328
+ html = html.replace(/<helmet(\s|>)/gi, "<sc-helmet$1");
329
+ html = html.replace(/<\/helmet\s*>/gi, "</sc-helmet>");
330
+ html = html.replace(
331
+ CAMEL_ATTR_RE,
332
+ (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq
333
+ );
334
+ for (const [real, alias] of Object.entries(RAW_WRAP)) {
335
+ html = html.replace(
336
+ new RegExp("(</?)" + real + "(?=[\\s>])", "gi"),
337
+ "$1" + alias
338
+ );
339
+ }
340
+ return html;
341
+ }
342
+ function kebabToCamel(s) {
343
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
344
+ }
345
+ function cssToObj(css) {
346
+ const o = {};
347
+ for (const decl of css.split(";")) {
348
+ const i = decl.indexOf(":");
349
+ if (i < 0) continue;
350
+ const prop = decl.slice(0, i).trim();
351
+ o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim();
352
+ }
353
+ return o;
354
+ }
355
+ function compileAttr(raw) {
356
+ const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/);
357
+ if (whole) {
358
+ const path = whole[1];
359
+ return (vals) => resolve(vals, path);
360
+ }
361
+ if (raw.includes("{{")) {
362
+ const parts = raw.split(/\{\{([\s\S]+?)\}\}/g);
363
+ return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join("");
364
+ }
365
+ return () => raw;
366
+ }
367
+
368
+ // src/compile.ts
369
+ function collectProps(node, kind, host) {
370
+ const propGetters = [];
371
+ const pseudoClasses = [];
372
+ let hintSize = null;
373
+ for (const { name, value } of [...node.attributes]) {
374
+ if (name === "sc-name" || name === "data-dc-tpl") continue;
375
+ let key = name;
376
+ if (key.startsWith(CAMEL_ATTR))
377
+ key = kebabToCamel(key.slice(CAMEL_ATTR.length));
378
+ if (key === "hint-size") {
379
+ hintSize = value;
380
+ continue;
381
+ }
382
+ if (key.startsWith("style-")) {
383
+ pseudoClasses.push(host.pseudoClass(key.slice(6), value));
384
+ continue;
385
+ }
386
+ if (kind !== "dom") {
387
+ if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-"))))
388
+ key = kebabToCamel(key);
389
+ } else {
390
+ if (key === "class") key = "className";
391
+ else if (key === "for") key = "htmlFor";
392
+ else if (key.startsWith("on"))
393
+ key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3);
394
+ }
395
+ propGetters.push([key, compileAttr(value)]);
396
+ }
397
+ return { propGetters, pseudoClasses, hintSize };
398
+ }
399
+ var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([
400
+ "position",
401
+ "left",
402
+ "right",
403
+ "top",
404
+ "bottom",
405
+ "inset",
406
+ "width",
407
+ "height",
408
+ "z-index",
409
+ "transform"
410
+ ]);
411
+ function hostPositionStyle(style) {
412
+ const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null;
413
+ if (!all) return void 0;
414
+ const out = {};
415
+ for (const [k, v] of Object.entries(all)) {
416
+ const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
417
+ if (HOST_STYLE_PROPS.has(kebab)) out[k] = v;
418
+ }
419
+ return Object.keys(out).length ? out : void 0;
420
+ }
421
+ function compileTemplate(html, host) {
422
+ const tpl = document.createElement("template");
423
+ //! nosemgrep: direct-inner-html-assignment
424
+ tpl.innerHTML = encodeCase(html);
425
+ let tplN = 0;
426
+ (function stamp(node) {
427
+ if (node.nodeType === Node.ELEMENT_NODE) {
428
+ node.setAttribute("data-dc-tpl", String(tplN++));
429
+ }
430
+ for (const c of node.childNodes) stamp(c);
431
+ })(tpl.content);
432
+ const builders = walkChildren(tpl.content, host);
433
+ const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i)));
434
+ render.__annotated = tpl.innerHTML;
435
+ return render;
436
+ }
437
+ function walkChildren(node, host) {
438
+ return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null);
439
+ }
440
+ function walk(node, host) {
441
+ if (node.nodeType === Node.TEXT_NODE) return walkText(node);
442
+ if (node.nodeType !== Node.ELEMENT_NODE) return null;
443
+ const el = node;
444
+ const tag = el.tagName.toLowerCase();
445
+ if (tag === "sc-for") return walkFor(el, host);
446
+ if (tag === "sc-if") return walkIf(el, host);
447
+ if (tag === "x-import") return walkXImport(el, host);
448
+ if (tag === "sc-helmet") return host.helmet(el);
449
+ if (tag === "dc-import") return walkComponent(el, host);
450
+ return walkElement(el, host);
451
+ }
452
+ var warnedHoles = /* @__PURE__ */ new Set();
453
+ function warnUnresolved(ctx, what) {
454
+ const key = (ctx?.__name || "?") + "\0" + what;
455
+ if (warnedHoles.has(key)) return;
456
+ warnedHoles.add(key);
457
+ console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what);
458
+ }
459
+ function walkText(node) {
460
+ const txt = node.nodeValue ?? "";
461
+ if (!txt.includes("{{")) {
462
+ if (!txt.trim() && !txt.includes(" ")) return null;
463
+ return () => txt;
464
+ }
465
+ const parts = txt.split(/\{\{([\s\S]+?)\}\}/g);
466
+ return (vals, ctx, key) => h(
467
+ getReact().Fragment,
468
+ { key },
469
+ ...parts.map((p, i) => {
470
+ if (!(i & 1)) return p;
471
+ const v = resolve(vals, p);
472
+ if (v === void 0) {
473
+ if (!ctx?.__streamingNow) {
474
+ if (document.body?.hasAttribute("data-dc-editor-on")) {
475
+ return h(
476
+ "span",
477
+ { key: i, className: "sc-interp sc-unresolved" },
478
+ "{{ " + p.trim() + " }}"
479
+ );
480
+ }
481
+ warnUnresolved(
482
+ ctx,
483
+ "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty"
484
+ );
485
+ return null;
486
+ }
487
+ return h(
488
+ "span",
489
+ { key: i, className: "sc-interp sc-missing" },
490
+ p.trim()
491
+ );
492
+ }
493
+ if (getReact().isValidElement(v) || Array.isArray(v)) {
494
+ return h(getReact().Fragment, { key: i }, v);
495
+ }
496
+ if (v === null || typeof v === "boolean") return null;
497
+ return h("span", { key: i, className: "sc-interp" }, String(v));
498
+ })
499
+ );
500
+ }
501
+ function walkFor(el, host) {
502
+ const listGet = compileAttr(el.getAttribute("list") || "");
503
+ const asName = el.getAttribute("as") || "item";
504
+ const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10);
505
+ const kids = walkChildren(el, host);
506
+ const listSrc = el.getAttribute("list") || "";
507
+ return (vals, ctx, key) => {
508
+ let list = listGet(vals);
509
+ if (!Array.isArray(list)) {
510
+ if (!ctx?.__streamingNow) {
511
+ if (list !== void 0 && list !== null) {
512
+ warnUnresolved(
513
+ ctx,
514
+ 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")"
515
+ );
516
+ }
517
+ list = [];
518
+ } else {
519
+ list = hintN > 0 ? Array(hintN).fill(void 0) : [];
520
+ }
521
+ }
522
+ return h(
523
+ getReact().Fragment,
524
+ { key },
525
+ list.map((item, i) => {
526
+ const sub = { ...vals, [asName]: item, $index: i };
527
+ return h(
528
+ getReact().Fragment,
529
+ { key: i },
530
+ kids.map((b, j) => b(sub, ctx, j))
531
+ );
532
+ })
533
+ );
534
+ };
535
+ }
536
+ function walkIf(el, host) {
537
+ const valGet = compileAttr(el.getAttribute("value") || "");
538
+ const hintRaw = el.getAttribute("hint-placeholder-val");
539
+ const hintGet = hintRaw != null ? compileAttr(hintRaw) : null;
540
+ const kids = walkChildren(el, host);
541
+ return (vals, ctx, key) => {
542
+ let v = valGet(vals);
543
+ if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals);
544
+ return v ? h(
545
+ getReact().Fragment,
546
+ { key },
547
+ kids.map((b, j) => b(vals, ctx, j))
548
+ ) : null;
549
+ };
550
+ }
551
+ function walkComponent(el, host) {
552
+ const name = el.getAttribute("name") || el.getAttribute("component") || "";
553
+ el.removeAttribute("name");
554
+ el.removeAttribute("component");
555
+ const tplId = el.getAttribute("data-dc-tpl");
556
+ const styleRaw = el.getAttribute("style");
557
+ el.removeAttribute("style");
558
+ const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
559
+ const { propGetters, hintSize } = collectProps(el, "dc-import", host);
560
+ const kids = walkChildren(el, host);
561
+ return (vals, ctx, key) => {
562
+ const props = {
563
+ key,
564
+ __hintSize: hintSize,
565
+ __tplId: tplId,
566
+ __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0
567
+ };
568
+ for (const [k, g] of propGetters) {
569
+ const v = g(vals);
570
+ if (k === "dcProps") {
571
+ if (v && typeof v === "object") Object.assign(props, v);
572
+ continue;
573
+ }
574
+ props[k] = v;
575
+ }
576
+ if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j));
577
+ return h(host.component(name), props);
578
+ };
579
+ }
580
+ function walkXImport(el, host) {
581
+ const globalNameGet = compileAttr(
582
+ el.getAttribute("component-from-global-scope") || ""
583
+ );
584
+ const exportNameGet = compileAttr(el.getAttribute("component") || "");
585
+ const url = el.getAttribute("from") || "";
586
+ const kind = /\.(jsx|tsx)(\?|#|$)/i.test(url) ? "jsx" : "js";
587
+ const tplId = el.getAttribute("data-dc-tpl");
588
+ const styleRaw = el.getAttribute("style");
589
+ el.removeAttribute("style");
590
+ const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
591
+ const wrap = tplId != null || styleGet != null;
592
+ const { propGetters, hintSize } = collectProps(el, "x-import", host);
593
+ const hasContent = el.children.length > 0 || !!(el.textContent || "").trim();
594
+ const kids = hasContent ? walkChildren(el, host) : [];
595
+ const urlBindable = url.includes("{{");
596
+ if (url && !urlBindable) host.loadExternal(kind, url);
597
+ const evalName = (g, vals) => {
598
+ const v = g(vals);
599
+ const s = v == null ? "" : String(v);
600
+ return s.includes("{{") ? "" : s;
601
+ };
602
+ return (vals, ctx, key) => {
603
+ const globalName = evalName(globalNameGet, vals);
604
+ const name = globalName || evalName(exportNameGet, vals);
605
+ const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name);
606
+ const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0;
607
+ const wrapper = wrap ? {
608
+ key,
609
+ className: "sc-host-x",
610
+ "data-dc-tpl": tplId,
611
+ style: hostStyle || { display: "contents" }
612
+ } : null;
613
+ if (!C) {
614
+ const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name);
615
+ const ph = host.placeholder({
616
+ key: wrapper ? void 0 : key,
617
+ name,
618
+ hintSize,
619
+ error
620
+ });
621
+ return wrapper ? h("div", wrapper, ph) : ph;
622
+ }
623
+ const props = wrapper ? {} : { key };
624
+ let unresolvedHole = false;
625
+ for (const [k, g] of propGetters) {
626
+ if (k === "component" || k === "componentFromGlobalScope" || k === "from") {
627
+ continue;
628
+ }
629
+ const v = g(vals);
630
+ if (v === void 0) unresolvedHole = true;
631
+ if (k === "dcProps") {
632
+ if (v && typeof v === "object") Object.assign(props, v);
633
+ continue;
634
+ }
635
+ props[k] = v;
636
+ }
637
+ if (unresolvedHole && ctx?.__htmlStreamingNow) {
638
+ const ph = host.placeholder({
639
+ key: wrapper ? void 0 : key,
640
+ name,
641
+ hintSize,
642
+ error: null
643
+ });
644
+ return wrapper ? h("div", wrapper, ph) : ph;
645
+ }
646
+ if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j));
647
+ return wrapper ? h("div", wrapper, h(C, props)) : h(C, props);
648
+ };
649
+ }
650
+ function walkElement(el, host) {
651
+ const realTag = RAW_UNWRAP[el.localName] || el.localName;
652
+ const tplId = el.getAttribute("data-dc-tpl");
653
+ const { propGetters, pseudoClasses } = collectProps(el, "dom", host);
654
+ const kids = walkChildren(el, host);
655
+ return (vals, ctx, key) => {
656
+ const props = { key, "data-dc-tpl": tplId };
657
+ for (const [k, g] of propGetters) {
658
+ let v = g(vals);
659
+ if (k === "style" && typeof v === "string") v = cssToObj(v);
660
+ if ((k === "value" || k === "checked") && v === void 0) {
661
+ v = k === "checked" ? false : "";
662
+ }
663
+ props[k] = v;
664
+ }
665
+ if (pseudoClasses.length) {
666
+ props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" ");
667
+ }
668
+ return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j)));
669
+ };
670
+ }
671
+
672
+ // src/logic.ts
673
+ var StreamableLogic = class {
674
+ constructor(props) {
675
+ __publicField(this, "props");
676
+ __publicField(this, "state", {});
677
+ /** Back-pointer to the wrapper component, installed after construction. */
678
+ __publicField(this, "__host");
679
+ this.props = props || {};
680
+ }
681
+ setState(update, cb) {
682
+ this.__host && this.__host.__setLogicState(update, cb);
683
+ }
684
+ forceUpdate() {
685
+ this.__host && this.__host.forceUpdate();
686
+ }
687
+ componentDidMount() {
688
+ }
689
+ componentDidUpdate(_prevProps) {
690
+ }
691
+ componentWillUnmount() {
692
+ }
693
+ /** The flat object the template renders against (merged over props). */
694
+ renderVals() {
695
+ return {};
696
+ }
697
+ };
698
+ function evalDcLogic(src) {
699
+ //! nosemgrep: eval-and-function-constructor
700
+ const fn = new Function(
701
+ "DCLogic",
702
+ "StreamableLogic",
703
+ "React",
704
+ src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;'
705
+ );
706
+ return fn(StreamableLogic, StreamableLogic, getReact());
707
+ }
708
+
709
+ // src/component.ts
710
+ function shallowEqual(a, b) {
711
+ if (!b) return false;
712
+ const ak = Object.keys(a).filter((k) => k !== "children");
713
+ const bk = Object.keys(b).filter((k) => k !== "children");
714
+ if (ak.length !== bk.length) return false;
715
+ for (const k of ak) if (a[k] !== b[k]) return false;
716
+ return true;
717
+ }
718
+ function Placeholder({
719
+ name,
720
+ hintSize,
721
+ streaming,
722
+ error
723
+ }) {
724
+ const [w, hgt] = (hintSize || "100%,60px").split(",");
725
+ return h(
726
+ "div",
727
+ {
728
+ className: "sc-placeholder" + (streaming ? " sc-streaming" : ""),
729
+ style: { width: w.trim(), height: hgt && hgt.trim() },
730
+ title: name
731
+ },
732
+ error ? h(
733
+ "div",
734
+ { className: "sc-placeholder-error" },
735
+ (name ? name + ": " : "") + error
736
+ ) : null
737
+ );
738
+ }
739
+ function hintToMin(hint) {
740
+ if (!hint) return void 0;
741
+ const [w, hgt] = hint.split(",");
742
+ return { minWidth: w.trim(), minHeight: hgt && hgt.trim() };
743
+ }
744
+ function createComponentFactory(registry, ensureFetched) {
745
+ const React = getReact();
746
+ const AncestorContext = React.createContext([]);
747
+ class StreamableComponent extends React.Component {
748
+ constructor(props) {
749
+ super(props);
750
+ __publicField(this, "__name");
751
+ __publicField(this, "__sub");
752
+ __publicField(this, "__needsDidMount", false);
753
+ /** Snapshot of the registry's streaming flags taken at render time —
754
+ * builders read it off the RenderCtx (this) to pick placeholder vs
755
+ * render-nothing for unresolved values. */
756
+ __publicField(this, "__streamingNow", false);
757
+ __publicField(this, "__htmlStreamingNow", false);
758
+ /** When a construct throws, remember the (class, registry.ver, props)
759
+ * triple so render-time reconcile doesn't re-attempt it on every parent
760
+ * re-render. A registry bump (new class, template, external module
761
+ * resolving via bumpAll) changes `ver` and breaks the memo so an
762
+ * env-dependent constructor can self-heal. */
763
+ __publicField(this, "__failedLogic", null);
764
+ __publicField(this, "__failedUserProps", null);
765
+ __publicField(this, "__failedVer", -1);
766
+ /** Per-instance constructor error — kept here (not on the registry entry)
767
+ * so one instance's successful construct can't hide a sibling's failure,
768
+ * and a construct can never wipe an eval error `updateJs` recorded on
769
+ * `r.logicError`. */
770
+ __publicField(this, "__ctorError", null);
771
+ __publicField(this, "logic");
772
+ this.__name = props.__name;
773
+ this.state = { __v: 0, __err: null };
774
+ this.__sub = () => {
775
+ if (this.state.__err) this.setState({ __err: null });
776
+ this.forceUpdate();
777
+ };
778
+ this.__makeLogic(registry.get(this.__name).Logic, null);
779
+ ensureFetched(this.__name);
780
+ }
781
+ /** Error-boundary hook: a render crash anywhere in this DC's subtree
782
+ * (its own template, an x-import'd component, a child DC without its
783
+ * own deeper boundary) lands here instead of unmounting the page. */
784
+ static getDerivedStateFromError(e) {
785
+ return { __err: e instanceof Error && e.message ? e.message : String(e) };
786
+ }
787
+ componentDidCatch(e, info) {
788
+ console.error(
789
+ "[dc-runtime] render error in <" + this.__name + ">:",
790
+ e,
791
+ info?.componentStack || ""
792
+ );
793
+ }
794
+ /** Instantiate the logic class (or the no-op base) and adopt `prevState`
795
+ * over its initial state — used both at mount and on hot-swap. */
796
+ __makeLogic(Logic, prevState) {
797
+ const L = Logic || StreamableLogic;
798
+ try {
799
+ this.logic = new L(this.__userProps());
800
+ this.__failedLogic = null;
801
+ this.__failedUserProps = null;
802
+ this.__ctorError = null;
803
+ } catch (e) {
804
+ console.error(e);
805
+ this.__failedLogic = Logic;
806
+ this.__failedUserProps = this.__userProps();
807
+ this.__failedVer = registry.get(this.__name).ver;
808
+ this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e));
809
+ this.logic = new StreamableLogic(
810
+ this.__userProps()
811
+ );
812
+ }
813
+ this.logic.__host = this;
814
+ if (prevState)
815
+ this.logic.state = { ...this.logic.state || {}, ...prevState };
816
+ }
817
+ /** The props the author's logic + template see — internal __-prefixed
818
+ * wiring stripped. */
819
+ __userProps() {
820
+ const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props;
821
+ return rest;
822
+ }
823
+ __setLogicState(update, cb) {
824
+ const prev = this.logic.state;
825
+ const patch = typeof update === "function" ? update(prev) : update;
826
+ this.logic.state = { ...prev, ...patch };
827
+ this.setState((s) => ({ __v: s.__v + 1 }), cb);
828
+ }
829
+ /** Swap the logic instance when the registry's Logic class changed
830
+ * (streaming completion, hot reload). State carries over; didMount
831
+ * re-fires after the swap commits so refs exist. */
832
+ __reconcileLogic() {
833
+ const r = registry.get(this.__name);
834
+ const Next = r.Logic;
835
+ const Cur = this.logic.constructor;
836
+ if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) {
837
+ return;
838
+ }
839
+ if (!this.__needsDidMount) {
840
+ try {
841
+ this.logic.componentWillUnmount();
842
+ } catch (e) {
843
+ console.error(e);
844
+ }
845
+ }
846
+ this.__makeLogic(Next, this.logic.state);
847
+ this.__needsDidMount = true;
848
+ }
849
+ componentDidMount() {
850
+ registry.get(this.__name).subs.add(this.__sub);
851
+ try {
852
+ this.logic.componentDidMount();
853
+ } catch (e) {
854
+ console.error(e);
855
+ }
856
+ }
857
+ componentDidUpdate(prevProps) {
858
+ this.logic.props = this.__userProps();
859
+ if (this.__needsDidMount) {
860
+ if (this.state.__err || !registry.get(this.__name).tpl) return;
861
+ this.__needsDidMount = false;
862
+ try {
863
+ this.logic.componentDidMount();
864
+ } catch (e) {
865
+ console.error(e);
866
+ }
867
+ } else {
868
+ try {
869
+ this.logic.componentDidUpdate(prevProps);
870
+ } catch (e) {
871
+ console.error(e);
872
+ }
873
+ }
874
+ }
875
+ componentWillUnmount() {
876
+ registry.get(this.__name).subs.delete(this.__sub);
877
+ if (!this.__needsDidMount) {
878
+ try {
879
+ this.logic.componentWillUnmount();
880
+ } catch (e) {
881
+ console.error(e);
882
+ }
883
+ }
884
+ }
885
+ render() {
886
+ const r = registry.get(this.__name);
887
+ const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : "");
888
+ const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0;
889
+ const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0;
890
+ const hostBase = {
891
+ className: cls,
892
+ style: hostStyle,
893
+ "data-sc-name": this.__name,
894
+ "data-dc-tpl": this.props.__tplId
895
+ };
896
+ const chain = Array.isArray(this.context) ? this.context : [];
897
+ if (chain.includes(this.__name)) {
898
+ const cycle = [
899
+ ...chain.slice(chain.indexOf(this.__name)),
900
+ this.__name
901
+ ].join(" \u2192 ");
902
+ return h(
903
+ "div",
904
+ { ...hostBase, className: cls + " sc-has-error" },
905
+ h(Placeholder, {
906
+ name: this.__name,
907
+ hintSize: this.props.__hintSize,
908
+ error: "circular import: " + cycle
909
+ })
910
+ );
911
+ }
912
+ if (this.state.__err) {
913
+ return h(
914
+ "div",
915
+ { ...hostBase, className: cls + " sc-has-error" },
916
+ h(
917
+ "div",
918
+ { className: "sc-logic-error", "data-omelette-chrome": "" },
919
+ this.__name + ": " + this.state.__err
920
+ ),
921
+ h(Placeholder, {
922
+ name: this.__name,
923
+ hintSize: this.props.__hintSize,
924
+ error: this.state.__err
925
+ })
926
+ );
927
+ }
928
+ this.__reconcileLogic();
929
+ if (!r.tpl) {
930
+ return h(
931
+ "div",
932
+ hostBase,
933
+ h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize })
934
+ );
935
+ }
936
+ const userProps = this.__userProps();
937
+ this.logic.props = userProps;
938
+ let vals = userProps;
939
+ let renderErr = r.logicError || this.__ctorError;
940
+ try {
941
+ vals = { ...userProps, ...this.logic.renderVals() || {} };
942
+ } catch (e) {
943
+ console.error(e);
944
+ renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e));
945
+ }
946
+ this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming);
947
+ this.__htmlStreamingNow = !!r.htmlStreaming;
948
+ return h(
949
+ "div",
950
+ { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") },
951
+ renderErr && h(
952
+ "div",
953
+ { className: "sc-logic-error", "data-omelette-chrome": "" },
954
+ renderErr
955
+ ),
956
+ h(
957
+ AncestorContext.Provider,
958
+ { value: [...chain, this.__name] },
959
+ r.tpl(vals, this)
960
+ )
961
+ );
962
+ }
963
+ }
964
+ __publicField(StreamableComponent, "contextType", AncestorContext);
965
+ const named = /* @__PURE__ */ new Map();
966
+ function getDC(name) {
967
+ const hit = named.get(name);
968
+ if (hit) return hit;
969
+ function Dispatcher(p) {
970
+ const [, setTick] = React.useState(0);
971
+ React.useEffect(() => {
972
+ const sub = () => setTick((n) => n + 1);
973
+ registry.get(name).subs.add(sub);
974
+ return () => {
975
+ registry.get(name).subs.delete(sub);
976
+ };
977
+ }, []);
978
+ ensureFetched(name);
979
+ return h(StreamableComponent, { ...p, __name: name });
980
+ }
981
+ Dispatcher.displayName = name;
982
+ named.set(name, Dispatcher);
983
+ return Dispatcher;
984
+ }
985
+ return {
986
+ getDC,
987
+ StreamableComponent
988
+ };
989
+ }
990
+
991
+ // src/external.ts
992
+ var isCustomElementName = (n) => !n.includes(".") && n.includes("-");
993
+ function isRenderableType(g) {
994
+ if (typeof g === "function") return !isElementClass(g);
995
+ return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol";
996
+ }
997
+ function resolveDottedPath(root, name) {
998
+ let cur = root;
999
+ for (const seg of name.split(".")) {
1000
+ if (cur == null) return void 0;
1001
+ cur = cur[seg];
1002
+ }
1003
+ return cur;
1004
+ }
1005
+ var BABEL_URL = "https://unpkg.com/@babel/standalone@7.26.4/babel.min.js";
1006
+ var GLOBAL_POLL_INTERVAL_MS = 50;
1007
+ var GLOBAL_POLL_TIMEOUT_MS = 3e4;
1008
+ function createExternalModules(onResolved) {
1009
+ const cache = /* @__PURE__ */ new Map();
1010
+ let babelLoading = null;
1011
+ const reportedMissing = /* @__PURE__ */ new Map();
1012
+ const polling = /* @__PURE__ */ new Set();
1013
+ function ensureBabel() {
1014
+ if (window.Babel) return Promise.resolve();
1015
+ if (babelLoading) return babelLoading;
1016
+ babelLoading = new Promise((res, rej) => {
1017
+ const s = document.createElement("script");
1018
+ s.src = BABEL_URL;
1019
+ s.crossOrigin = "anonymous";
1020
+ s.onload = () => res();
1021
+ s.onerror = rej;
1022
+ document.head.appendChild(s);
1023
+ });
1024
+ return babelLoading;
1025
+ }
1026
+ function load(kind, url) {
1027
+ if (cache.has(url)) return;
1028
+ cache.set(url, null);
1029
+ console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")");
1030
+ const ready = kind === "jsx" ? ensureBabel() : Promise.resolve();
1031
+ ready.then(() => fetch(url)).then((r) => {
1032
+ if (!r.ok) throw new Error("HTTP " + r.status);
1033
+ return r.text();
1034
+ }).then((src) => {
1035
+ const code = kind === "jsx" ? window.Babel.transform(src, {
1036
+ filename: url,
1037
+ presets: ["react", "typescript"]
1038
+ }).code : src;
1039
+ const module = { exports: {} };
1040
+ const before = new Set(Object.keys(window));
1041
+ //! nosemgrep: eval-and-function-constructor
1042
+ new Function("React", "module", "exports", "require", code)(
1043
+ getReact(),
1044
+ module,
1045
+ module.exports,
1046
+ () => ({})
1047
+ );
1048
+ const globals = {};
1049
+ for (const k of Object.keys(window)) {
1050
+ if (!before.has(k) && typeof window[k] === "function") {
1051
+ globals[k] = window[k];
1052
+ }
1053
+ }
1054
+ cache.set(url, { mod: module.exports, globals });
1055
+ console.info(
1056
+ "[dc-runtime] x-import: loaded",
1057
+ url,
1058
+ "\u2014 exports:",
1059
+ Object.keys(module.exports),
1060
+ "window globals:",
1061
+ Object.keys(globals)
1062
+ );
1063
+ onResolved();
1064
+ }).catch((e) => {
1065
+ cache.set(url, {
1066
+ mod: {},
1067
+ globals: {},
1068
+ error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e))
1069
+ });
1070
+ console.error(
1071
+ "[dc-runtime] x-import: FAILED to load",
1072
+ url,
1073
+ "(" + kind + ")",
1074
+ e
1075
+ );
1076
+ onResolved();
1077
+ });
1078
+ }
1079
+ function resolve2(url, name) {
1080
+ const entry = cache.get(url);
1081
+ if (!entry) return null;
1082
+ const { mod, globals } = entry;
1083
+ const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default;
1084
+ if (typeof C === "function") return C;
1085
+ const key = url + "\0" + name;
1086
+ if (!reportedMissing.has(key)) {
1087
+ reportedMissing.set(
1088
+ key,
1089
+ entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")"
1090
+ );
1091
+ console.error(
1092
+ "[dc-runtime] x-import: module",
1093
+ url,
1094
+ "loaded but has no component named",
1095
+ JSON.stringify(name),
1096
+ "\u2014 available exports:",
1097
+ Object.keys(mod),
1098
+ "window globals:",
1099
+ Object.keys(globals),
1100
+ ". The module must `module.exports = {" + name + "}` or set `window." + name + "`."
1101
+ );
1102
+ }
1103
+ return null;
1104
+ }
1105
+ function waitForGlobal(name) {
1106
+ if (polling.has(name)) return;
1107
+ polling.add(name);
1108
+ const started = Date.now();
1109
+ const isCE = isCustomElementName(name);
1110
+ const tick = () => {
1111
+ const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name));
1112
+ if (found) {
1113
+ polling.delete(name);
1114
+ onResolved();
1115
+ return;
1116
+ }
1117
+ if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) {
1118
+ console.warn(
1119
+ "[dc-runtime] x-import: global",
1120
+ JSON.stringify(name),
1121
+ "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms"
1122
+ );
1123
+ return;
1124
+ }
1125
+ setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
1126
+ };
1127
+ setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
1128
+ }
1129
+ function resolveGlobal(url, name) {
1130
+ const isCE = isCustomElementName(name);
1131
+ if (!url) {
1132
+ if (isCE) {
1133
+ if (customElements.get(name)) return name;
1134
+ waitForGlobal(name);
1135
+ return null;
1136
+ }
1137
+ const g2 = resolveDottedPath(window, name);
1138
+ if (isRenderableType(g2)) return g2;
1139
+ waitForGlobal(name);
1140
+ return null;
1141
+ }
1142
+ const entry = cache.get(url);
1143
+ if (!entry) return null;
1144
+ if (isCE && customElements.get(name)) return name;
1145
+ const g = entry.globals[name] ?? resolveDottedPath(window, name);
1146
+ if (isRenderableType(g)) return g;
1147
+ if (name.includes(".")) return null;
1148
+ const key = url + "\0global\0" + name;
1149
+ if (!reportedMissing.has(key)) {
1150
+ reportedMissing.set(key, null);
1151
+ if (isCE && !customElements.get(name)) {
1152
+ console.warn(
1153
+ "[dc-runtime] x-import:",
1154
+ url,
1155
+ "loaded but no custom element",
1156
+ JSON.stringify(name),
1157
+ "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element."
1158
+ );
1159
+ }
1160
+ }
1161
+ return name;
1162
+ }
1163
+ function getError(url, name) {
1164
+ const entry = cache.get(url);
1165
+ if (entry?.error) return entry.error;
1166
+ return reportedMissing.get(url + "\0" + name) || null;
1167
+ }
1168
+ return { load, resolve: resolve2, resolveGlobal, getError };
1169
+ }
1170
+ function isElementClass(g) {
1171
+ try {
1172
+ return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement;
1173
+ } catch {
1174
+ return false;
1175
+ }
1176
+ }
1177
+
1178
+ // src/atomics.ts
1179
+ var ATOMIC_CSS = (
1180
+ // layout
1181
+ ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}"
1182
+ );
1183
+
1184
+ // src/helmet.ts
1185
+ var DESIGN_DOC_MODE_RE = /<meta\b[^>]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i;
1186
+ var CANVAS_BG = "#f0eee9";
1187
+ function createHelmetManager(doc, isStreaming) {
1188
+ const mounted = /* @__PURE__ */ new Set();
1189
+ const live = /* @__PURE__ */ new Map();
1190
+ let designDocMode = null;
1191
+ let canvasStyleEl = null;
1192
+ function postDesignMode(mode) {
1193
+ if (window.parent === window) return;
1194
+ try {
1195
+ window.parent.postMessage({ type: "__dc_design_mode", mode }, "*");
1196
+ } catch {
1197
+ }
1198
+ }
1199
+ function setDesignDocMode(mode) {
1200
+ if (mode === designDocMode) return;
1201
+ designDocMode = mode;
1202
+ postDesignMode(mode);
1203
+ if (mode === "canvas") {
1204
+ doc.documentElement.setAttribute("data-dc-canvas", "");
1205
+ canvasStyleEl = doc.createElement("style");
1206
+ canvasStyleEl.setAttribute("data-dc-canvas", "");
1207
+ canvasStyleEl.textContent = `html,body{background:${CANVAS_BG}}#dc-root>.sc-host{position:relative}`;
1208
+ doc.head.appendChild(canvasStyleEl);
1209
+ } else {
1210
+ doc.documentElement.removeAttribute("data-dc-canvas");
1211
+ canvasStyleEl?.remove();
1212
+ canvasStyleEl = null;
1213
+ }
1214
+ }
1215
+ window.addEventListener("message", (e) => {
1216
+ if (!designDocMode || (e.data && e.data.type) !== "__dc_probe") return;
1217
+ postDesignMode(designDocMode);
1218
+ });
1219
+ function compile(node) {
1220
+ const raw = [...node.children];
1221
+ const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null;
1222
+ if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) {
1223
+ mounted.add("__dc-atomics");
1224
+ const el = doc.createElement("style");
1225
+ el.id = "__dc-atomics";
1226
+ el.textContent = ATOMIC_CSS;
1227
+ doc.head.appendChild(el);
1228
+ }
1229
+ return (_vals, ctx) => {
1230
+ const name = ctx && ctx.__name || "";
1231
+ const streaming = !!(name && isStreaming(name));
1232
+ for (let i = 0; i < raw.length; i++) {
1233
+ const child = raw[i];
1234
+ const tag = child.tagName;
1235
+ const mayBePartial = streaming && !helmetClosed && i === raw.length - 1;
1236
+ if (tag === "SCRIPT") {
1237
+ if (mayBePartial) continue;
1238
+ const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || "");
1239
+ if (mounted.has(key)) continue;
1240
+ mounted.add(key);
1241
+ const el = doc.createElement("script");
1242
+ for (const { name: an, value } of [...child.attributes])
1243
+ el.setAttribute(an, value);
1244
+ if (child.textContent) el.textContent = child.textContent;
1245
+ doc.head.appendChild(el);
1246
+ } else if (tag === "LINK" || tag === "META") {
1247
+ if (mayBePartial) continue;
1248
+ const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML);
1249
+ if (mounted.has(key)) continue;
1250
+ mounted.add(key);
1251
+ doc.head.appendChild(child.cloneNode(true));
1252
+ } else {
1253
+ const key = name + "|" + i;
1254
+ let el = live.get(key);
1255
+ if (!el || el.tagName !== tag) {
1256
+ if (el) el.remove();
1257
+ el = doc.createElement(tag.toLowerCase());
1258
+ live.set(key, el);
1259
+ doc.head.appendChild(el);
1260
+ }
1261
+ for (const { name: an, value } of [...child.attributes]) {
1262
+ if (el.getAttribute(an) !== value) el.setAttribute(an, value);
1263
+ }
1264
+ if (el.textContent !== child.textContent)
1265
+ el.textContent = child.textContent;
1266
+ }
1267
+ }
1268
+ return null;
1269
+ };
1270
+ }
1271
+ return { compile, setDesignDocMode };
1272
+ }
1273
+
1274
+ // src/pseudo.ts
1275
+ function createPseudoSheet(doc) {
1276
+ let el = null;
1277
+ const cache = /* @__PURE__ */ new Map();
1278
+ let n = 0;
1279
+ return (pseudo, css) => {
1280
+ const k = pseudo + "|" + css;
1281
+ const hit = cache.get(k);
1282
+ if (hit) return hit;
1283
+ if (!el) {
1284
+ el = doc.createElement("style");
1285
+ doc.head.appendChild(el);
1286
+ }
1287
+ const cls = "scp" + (n++).toString(36);
1288
+ const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo;
1289
+ el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length);
1290
+ cache.set(k, cls);
1291
+ return cls;
1292
+ };
1293
+ }
1294
+
1295
+ // src/registry.ts
1296
+ function createRegistry() {
1297
+ const entries = /* @__PURE__ */ Object.create(null);
1298
+ function get(name) {
1299
+ return entries[name] || (entries[name] = {
1300
+ html: "",
1301
+ tpl: null,
1302
+ Logic: null,
1303
+ jsStreaming: false,
1304
+ htmlStreaming: false,
1305
+ ver: 0,
1306
+ subs: /* @__PURE__ */ new Set(),
1307
+ fetched: false
1308
+ });
1309
+ }
1310
+ function bump(name) {
1311
+ const r = get(name);
1312
+ r.ver++;
1313
+ for (const fn of r.subs) fn();
1314
+ }
1315
+ return {
1316
+ entries,
1317
+ get,
1318
+ bump,
1319
+ bumpAll() {
1320
+ for (const n in entries) bump(n);
1321
+ }
1322
+ };
1323
+ }
1324
+
1325
+ // src/runtime.ts
1326
+ var COMPONENT_DIR = ".";
1327
+ function createRuntime(doc = document) {
1328
+ const registry = createRegistry();
1329
+ const pseudoClass = createPseudoSheet(doc);
1330
+ const helmet = createHelmetManager(
1331
+ doc,
1332
+ (name) => registry.get(name).htmlStreaming
1333
+ );
1334
+ const external = createExternalModules(() => registry.bumpAll());
1335
+ const factory = createComponentFactory(registry, ensureFetched);
1336
+ const host = {
1337
+ component: (name) => factory.getDC(name),
1338
+ placeholder: (props) => h(Placeholder, props),
1339
+ helmet: (node) => helmet.compile(node),
1340
+ loadExternal: (kind, url) => external.load(kind, url),
1341
+ resolveExternal: (url, name) => external.resolve(url, name),
1342
+ resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name),
1343
+ resolveExternalError: (url, name) => external.getError(url, name),
1344
+ pseudoClass
1345
+ };
1346
+ function ensureFetched(name) {
1347
+ const r = registry.get(name);
1348
+ if (r.fetched) return;
1349
+ r.fetched = true;
1350
+ const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html";
1351
+ fetch(url).then((res) => {
1352
+ if (!res.ok) {
1353
+ console.error(
1354
+ "[dc-runtime] sibling fetch for <" + name + "/> failed:",
1355
+ url,
1356
+ "returned",
1357
+ res.status,
1358
+ "\u2014 the reference renders as an empty placeholder."
1359
+ );
1360
+ return "";
1361
+ }
1362
+ return res.text();
1363
+ }).then((t) => {
1364
+ if (!t) return;
1365
+ const parsed = parseDcText(t);
1366
+ if (!parsed) {
1367
+ console.error(
1368
+ "[dc-runtime] sibling fetch for <" + name + "/>:",
1369
+ url,
1370
+ "has no <x-dc> block \u2014 not a Design Component."
1371
+ );
1372
+ return;
1373
+ }
1374
+ if (parsed.props) r.propsMeta = parsed.props;
1375
+ if (parsed.preview) r.preview = parsed.preview;
1376
+ if (parsed.template && !r.html) updateHtml(name, parsed.template);
1377
+ if (parsed.js && !r.Logic) updateJs(name, parsed.js);
1378
+ }).catch(
1379
+ (e) => console.error(
1380
+ "[dc-runtime] sibling fetch for <" + name + "/> threw:",
1381
+ url,
1382
+ e
1383
+ )
1384
+ );
1385
+ }
1386
+ let rootName = null;
1387
+ function updateHtml(name, html) {
1388
+ const r = registry.get(name);
1389
+ r.html = html;
1390
+ if (name === rootName) {
1391
+ const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null;
1392
+ if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode);
1393
+ }
1394
+ try {
1395
+ r.tpl = compileTemplate(html, host);
1396
+ } catch (e) {
1397
+ console.error("[dc-runtime] template compile FAILED for", name, e);
1398
+ }
1399
+ registry.bump(name);
1400
+ }
1401
+ function updateJs(name, src) {
1402
+ const r = registry.get(name);
1403
+ const seq = r.jsSeq = (r.jsSeq || 0) + 1;
1404
+ try {
1405
+ const Cls = evalDcLogic(src);
1406
+ if (r.jsSeq !== seq) return;
1407
+ if (typeof Cls !== "function") {
1408
+ r.logicError = name + ".dc.html: <script data-dc-script> must define `class Component extends DCLogic`";
1409
+ } else {
1410
+ r.logicError = null;
1411
+ r.Logic = Cls;
1412
+ }
1413
+ } catch (e) {
1414
+ if (r.jsSeq !== seq) return;
1415
+ console.error(
1416
+ "[dc-runtime] logic class eval FAILED for",
1417
+ name,
1418
+ "\u2014 the template renders with props only.",
1419
+ e
1420
+ );
1421
+ r.logicError = name + ": " + (e instanceof Error && e.message ? e.message : String(e));
1422
+ }
1423
+ registry.bump(name);
1424
+ }
1425
+ function setStreaming(name, kind, on) {
1426
+ const r = registry.get(name);
1427
+ if (kind === "html") r.htmlStreaming = !!on;
1428
+ else r.jsStreaming = !!on;
1429
+ let any = false;
1430
+ for (const n in registry.entries) {
1431
+ const e = registry.entries[n];
1432
+ if (e && (e.htmlStreaming || e.jsStreaming)) {
1433
+ any = true;
1434
+ break;
1435
+ }
1436
+ }
1437
+ doc.documentElement.classList.toggle("sc-dc-streaming", any);
1438
+ registry.bump(name);
1439
+ }
1440
+ function dcUpdate(name, kind, content, streaming) {
1441
+ if (streaming) registry.get(name).fetched = true;
1442
+ if (kind === "html") {
1443
+ setStreaming(name, "html", !!streaming);
1444
+ updateHtml(name, content);
1445
+ } else if (kind === "js") {
1446
+ setStreaming(name, "js", !!streaming);
1447
+ if (!streaming) updateJs(name, content);
1448
+ } else if (kind === "props") {
1449
+ const { props, preview } = parseDataProps(content);
1450
+ const r = registry.get(name);
1451
+ r.propsMeta = props ?? void 0;
1452
+ r.preview = preview;
1453
+ registry.bump(name);
1454
+ }
1455
+ }
1456
+ function setProps(name, overrides) {
1457
+ registry.get(name).propOverrides = overrides && typeof overrides === "object" ? { ...overrides } : null;
1458
+ registry.bump(name);
1459
+ }
1460
+ function adoptParsed(name, parsed) {
1461
+ if (!parsed) return;
1462
+ const r = registry.get(name);
1463
+ if (parsed.props) r.propsMeta = parsed.props;
1464
+ if (parsed.preview) r.preview = parsed.preview;
1465
+ if (parsed.template) updateHtml(name, parsed.template);
1466
+ if (parsed.js) updateJs(name, parsed.js);
1467
+ }
1468
+ return {
1469
+ registry,
1470
+ getDC: factory.getDC,
1471
+ updateHtml,
1472
+ updateJs,
1473
+ dcUpdate,
1474
+ setProps,
1475
+ adoptParsed,
1476
+ setRootName: (name) => {
1477
+ rootName = name;
1478
+ },
1479
+ markFetched: (name) => {
1480
+ registry.get(name).fetched = true;
1481
+ },
1482
+ annotatedTemplate: (name) => {
1483
+ const r = registry.get(name);
1484
+ return r.tpl && r.tpl.__annotated || null;
1485
+ },
1486
+ templateSource: (name) => registry.get(name).html || null,
1487
+ StreamableLogic
1488
+ };
1489
+ }
1490
+
1491
+ // src/index.ts
1492
+ var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js";
1493
+ var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z";
1494
+ var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js";
1495
+ var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1";
1496
+ function hideRawTemplate() {
1497
+ const s = document.createElement("style");
1498
+ s.textContent = "x-dc{display:none!important}";
1499
+ document.head.appendChild(s);
1500
+ }
1501
+ function loadScript(src, integrity) {
1502
+ return new Promise((resolve2, reject) => {
1503
+ //! nosemgrep: create-script-element
1504
+ const s = document.createElement("script");
1505
+ s.src = src;
1506
+ s.integrity = integrity;
1507
+ s.crossOrigin = "anonymous";
1508
+ s.async = false;
1509
+ s.onload = () => resolve2();
1510
+ s.onerror = () => reject(new Error(`failed to load ${src}`));
1511
+ document.head.appendChild(s);
1512
+ });
1513
+ }
1514
+ function loadReactUmd() {
1515
+ const w = window;
1516
+ if (w.React && w.ReactDOM) return Promise.resolve();
1517
+ return Promise.all([
1518
+ loadScript(REACT_URL, REACT_SRI),
1519
+ loadScript(REACT_DOM_URL, REACT_DOM_SRI)
1520
+ ]).then(() => void 0);
1521
+ }
1522
+ function init() {
1523
+ const runtime = createRuntime(document);
1524
+ let rootName = "Root";
1525
+ const baseCss = document.createElement("style");
1526
+ baseCss.textContent = BASE_CSS;
1527
+ document.head.prepend(baseCss);
1528
+ const notifyHost = () => {
1529
+ if (window.parent === window) return;
1530
+ const r = runtime.registry.entries[rootName];
1531
+ try {
1532
+ window.parent.postMessage(
1533
+ {
1534
+ type: "__dc_booted",
1535
+ rootName,
1536
+ propsMeta: r && r.propsMeta || null,
1537
+ preview: r && r.preview || null
1538
+ },
1539
+ "*"
1540
+ );
1541
+ } catch {
1542
+ }
1543
+ };
1544
+ const api = {
1545
+ __dcUpdate: (name, kind, content, streaming) => {
1546
+ runtime.dcUpdate(name, kind, content, streaming);
1547
+ if (name === rootName && !streaming && kind === "props") notifyHost();
1548
+ },
1549
+ __dcSetProps: (name, overrides) => runtime.setProps(name, overrides),
1550
+ /** Name of the component currently mounted as the page root — DC tools
1551
+ * push their template-stream here when targeting "the open page". */
1552
+ __dcRootName: () => rootName,
1553
+ /** Editor bridge — the encoded, `data-dc-tpl`-annotated template source.
1554
+ * The host editor parses this into its own template DOM so it can map a
1555
+ * rendered node (carrying the same `data-dc-tpl`) back to the source
1556
+ * node that emitted it. Returns the encoded form (`<sc-comp>`,
1557
+ * `sc-camel-*` attrs); the editor decodes on serialize. */
1558
+ __dcAnnotatedTemplate: (name) => runtime.annotatedTemplate(name),
1559
+ /** Editor bridge — the *original* (decoded) template source. */
1560
+ __dcTemplateSource: (name) => runtime.templateSource(name),
1561
+ __dcBoot: () => {
1562
+ rootName = boot(runtime, document) ?? rootName;
1563
+ notifyHost();
1564
+ },
1565
+ __dcRegistry: runtime.registry.entries,
1566
+ getDC: (name) => runtime.getDC(name),
1567
+ // `DCLogic` is the documented base class name; `StreamableLogic` is the
1568
+ // implementation alias kept for any project that already references it.
1569
+ DCLogic: runtime.StreamableLogic,
1570
+ StreamableLogic: runtime.StreamableLogic
1571
+ };
1572
+ Object.assign(window, api);
1573
+ if (document.readyState !== "loading") api.__dcBoot();
1574
+ else document.addEventListener("DOMContentLoaded", () => api.__dcBoot());
1575
+ }
1576
+ hideRawTemplate();
1577
+ loadReactUmd().then(init).catch((err) => {
1578
+ console.error("[dc] failed to load React or boot:", err);
1579
+ throw err;
1580
+ });
1581
+ })();
docs/local-gen-server-example.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local audio-gen server — reference example for audio-brief's "Local server" model.
2
+
3
+ When you select "Local server (your machine)" in audio-brief's Generate tab,
4
+ your browser POSTs `{prompt, duration}` to `<this server>/generate` and
5
+ expects audio bytes back (WAV or MP3). The audio never leaves your machine
6
+ on its way to a remote gen provider — the HF Spaces backend only receives
7
+ the resulting audio (uploaded by the browser, not the server).
8
+
9
+ Run me:
10
+ pip install fastapi uvicorn
11
+ # plus whatever your gen backend needs (mlx-audio, diffusers, etc.)
12
+ python local-gen-server-example.py
13
+ # serves at http://localhost:7864
14
+
15
+ CORS:
16
+ The browser fetch comes from `https://<your-space>.hf.space` — that's
17
+ a different origin from `http://localhost:7864`, so this server MUST
18
+ return permissive CORS headers. The example below does `*`.
19
+
20
+ Mixed-content note:
21
+ Some browsers block HTTP fetches from HTTPS pages. Chrome treats
22
+ `http://localhost` as a secure exception; Safari is stricter. If
23
+ Safari blocks, either run this server with a self-signed cert on
24
+ HTTPS, or test in Chrome.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import io
29
+ import os
30
+ import subprocess
31
+ from pathlib import Path
32
+
33
+ from fastapi import FastAPI
34
+ from fastapi.middleware.cors import CORSMiddleware
35
+ from fastapi.responses import Response
36
+ from pydantic import BaseModel
37
+
38
+ app = FastAPI()
39
+
40
+ # CORS: allow the audio-brief Space (or any origin during dev). Tighten
41
+ # the allow_origins list to just your Space URL before sharing this
42
+ # beyond your own use.
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=["*"],
46
+ allow_methods=["POST", "OPTIONS"],
47
+ allow_headers=["*"],
48
+ )
49
+
50
+
51
+ class GenRequest(BaseModel):
52
+ prompt: str
53
+ duration: int = 15
54
+
55
+
56
+ def run_mlx_sa3(prompt: str, duration: int) -> bytes:
57
+ """Example: shell out to the MLX SA3 binary that ships with Apple
58
+ Silicon. Adjust the path and CLI to whatever your local gen tool wants."""
59
+ binary = os.environ.get("SA3_BIN", str(Path.home() / "sa3_mlx" / "sa3"))
60
+ if not Path(binary).exists():
61
+ raise RuntimeError(
62
+ f"SA3_BIN not found at {binary}. "
63
+ "Set SA3_BIN or swap run_mlx_sa3 for your own gen function."
64
+ )
65
+ out_path = Path("/tmp") / f"local-gen-{os.getpid()}.wav"
66
+ subprocess.run(
67
+ [binary, "--prompt", prompt, "--duration", str(duration), "--out", str(out_path)],
68
+ check=True, timeout=300,
69
+ )
70
+ return out_path.read_bytes()
71
+
72
+
73
+ def run_stub_sine(prompt: str, duration: int) -> bytes:
74
+ """Fallback: render a single sine tone so you can verify the bridge
75
+ works end-to-end before plugging in a real gen backend."""
76
+ import math, struct, wave
77
+ sr = 44100
78
+ n_samples = int(sr * duration)
79
+ freq = 440.0
80
+ buf = io.BytesIO()
81
+ with wave.open(buf, "wb") as w:
82
+ w.setnchannels(1)
83
+ w.setsampwidth(2)
84
+ w.setframerate(sr)
85
+ for i in range(n_samples):
86
+ v = int(20000 * math.sin(2.0 * math.pi * freq * i / sr))
87
+ w.writeframesraw(struct.pack("<h", v))
88
+ return buf.getvalue()
89
+
90
+
91
+ @app.post("/generate")
92
+ def generate(req: GenRequest):
93
+ """Generate audio from a prompt. Returns audio/wav (or audio/mpeg if
94
+ your backend produces MP3). The browser uploads these bytes to
95
+ audio-brief's HF Spaces backend for crate-add + analysis."""
96
+ # Pick your backend:
97
+ backend = os.environ.get("LOCAL_GEN_BACKEND", "stub").lower()
98
+ try:
99
+ if backend == "mlx-sa3":
100
+ audio_bytes = run_mlx_sa3(req.prompt, req.duration)
101
+ else:
102
+ # Default to the stub sine so first-time users can verify the
103
+ # bridge before wiring real gen.
104
+ audio_bytes = run_stub_sine(req.prompt, req.duration)
105
+ except Exception as e:
106
+ return Response(content=f"local gen failed: {e}".encode(), status_code=500)
107
+
108
+ media_type = "audio/wav" # change to "audio/mpeg" if your backend yields MP3
109
+ return Response(content=audio_bytes, media_type=media_type)
110
+
111
+
112
+ @app.get("/")
113
+ def root():
114
+ return {
115
+ "service": "audio-brief local gen example",
116
+ "endpoint": "POST /generate {prompt, duration}",
117
+ "backend": os.environ.get("LOCAL_GEN_BACKEND", "stub"),
118
+ }
119
+
120
+
121
+ if __name__ == "__main__":
122
+ import uvicorn
123
+ port = int(os.environ.get("PORT", 7864))
124
+ uvicorn.run("local-gen-server-example:app", host="127.0.0.1", port=port, reload=False)
models.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pollinations model catalog — fetched once per session, filtered by modality.
2
+
3
+ The hardcoded model list approach was brittle: aliases came and went, and
4
+ several entries were quietly stale (`claude-haiku-4.5`, `gemini-2.5-pro`).
5
+ This module talks to `/v1/models` at startup, caches the result for the
6
+ session, and exposes two filtered views for the UI dropdowns.
7
+
8
+ Defaults to a small curated fallback list if Pollinations is unreachable so
9
+ the app still boots offline.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+
16
+ import requests
17
+
18
+ MODELS_URL = "https://gen.pollinations.ai/v1/models"
19
+
20
+ # Curated fallback — small, safe set. Used only if the catalog fetch fails.
21
+ # Same IDs as the previous hardcoded list, minus stale entries codex flagged.
22
+ _FALLBACK_TEXT = [
23
+ "claude-fast", "claude", "claude-opus-4.7", "claude-large",
24
+ "openai-fast", "openai", "openai-large",
25
+ "gpt-5.4-mini", "gpt-5.4",
26
+ "deepseek", "deepseek-pro",
27
+ "grok", "grok-large",
28
+ "qwen-large", "qwen-coder",
29
+ "gemma", "step-flash", "step-3.5-flash",
30
+ ]
31
+ _FALLBACK_AUDIO = ["openai-audio", "openai-audio-large", "gemini", "gemini-3-flash"]
32
+
33
+ # Models that are audio-input but only do transcription (whisper, scribe,
34
+ # universal-*) — useless for our brief-style narrative output. Exclude them
35
+ # from the C dropdown so the user doesn't pick a transcription-only model and
36
+ # get a flat dump of lyrics back instead of a structured brief.
37
+ _TRANSCRIPTION_ONLY = {"whisper", "scribe", "universal-2", "universal-3-pro"}
38
+
39
+ # Models marked as gemini-style audio-input but designed for live realtime
40
+ # session APIs, not single-shot chat completion. Skip in our use case.
41
+ _REALTIME_ONLY = {"gpt-realtime-2"}
42
+
43
+ # Audio-input gemini models support tool/code execution and routinely
44
+ # burn token budget on tool round-trips. We still let the user pick them
45
+ # (codex P5: tag as experimental) but mark them visibly. gemini-search-*
46
+ # pair audio with Google Search grounding — also experimental in our use.
47
+ _TOOLS_RISKY_AUDIO = {"gemini", "gemini-3-flash", "gemini-flash-lite-3.1",
48
+ "gemini-large", "gemini-search-fast", "gemini-search-large"}
49
+
50
+ # Preferred order for the audio dropdown — openai-audio family first because
51
+ # they're pure listen-and-answer with no tool loop. Then experimentals.
52
+ _AUDIO_PREFERRED = ["openai-audio", "openai-audio-large"]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Catalog fetch (cached)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ _cache: list[dict[str, Any]] | None = None
60
+
61
+
62
+ def _auth_header() -> dict[str, str]:
63
+ for env in ("POLLINATIONS_API_KEY", "POLLINATIONS_TOKEN"):
64
+ v = (os.environ.get(env) or "").strip()
65
+ if v:
66
+ return {"Authorization": f"Bearer {v}"}
67
+ try:
68
+ from wallet import stored_key
69
+ k = (stored_key() or "").strip()
70
+ if k:
71
+ return {"Authorization": f"Bearer {k}"}
72
+ except Exception:
73
+ pass
74
+ return {}
75
+
76
+
77
+ def fetch_catalog(force: bool = False, timeout: float = 4.0) -> list[dict[str, Any]]:
78
+ """Return the cached /v1/models payload (data array). Fetches once per
79
+ session unless force=True. Returns [] on failure — callers must handle."""
80
+ global _cache
81
+ if _cache is not None and not force:
82
+ return _cache
83
+ try:
84
+ r = requests.get(MODELS_URL, headers=_auth_header(), timeout=timeout)
85
+ r.raise_for_status()
86
+ _cache = (r.json() or {}).get("data") or []
87
+ except Exception:
88
+ _cache = []
89
+ return _cache
90
+
91
+
92
+ def text_models() -> list[str]:
93
+ """Models accepting text input and producing text output, suitable for
94
+ the measured-brief A/B columns. Excludes audio-input variants (those live
95
+ in audio_models()) and transcription-only models."""
96
+ catalog = fetch_catalog()
97
+ if not catalog:
98
+ return list(_FALLBACK_TEXT)
99
+ ids: list[str] = []
100
+ for m in catalog:
101
+ mid = m.get("id") or ""
102
+ if not mid:
103
+ continue
104
+ inp = m.get("input_modalities") or []
105
+ out = m.get("output_modalities") or []
106
+ # text->text models, no audio input. Vision-capable models with
107
+ # image input are fine (they just won't be sent images here).
108
+ if "text" in out and "audio" not in inp:
109
+ # Skip search/embedding/coder oddities by endpoint check
110
+ endpoints = m.get("supported_endpoints") or []
111
+ if "/v1/chat/completions" not in endpoints:
112
+ continue
113
+ ids.append(mid)
114
+ # Sort with curated favourites first (claude / openai / gemini-fast / etc.)
115
+ return _sort_with_favourites(ids, _FALLBACK_TEXT)
116
+
117
+
118
+ def audio_models() -> list[tuple[str, bool]]:
119
+ """Models that accept audio INPUT and produce text — for the audio-only
120
+ C column. Returns list of (id, is_experimental) tuples; experimental
121
+ models are gemini ones that use tool/code-execution and may eat the
122
+ token budget without producing a prose answer."""
123
+ catalog = fetch_catalog()
124
+ if not catalog:
125
+ return [(m, False) for m in _FALLBACK_AUDIO[:2]] + \
126
+ [(m, True) for m in _FALLBACK_AUDIO[2:]]
127
+ pairs: list[tuple[str, bool]] = []
128
+ for m in catalog:
129
+ mid = m.get("id") or ""
130
+ if not mid or mid in _TRANSCRIPTION_ONLY or mid in _REALTIME_ONLY:
131
+ continue
132
+ inp = m.get("input_modalities") or []
133
+ out = m.get("output_modalities") or []
134
+ if "audio" not in inp or "text" not in out:
135
+ continue
136
+ endpoints = m.get("supported_endpoints") or []
137
+ if "/v1/chat/completions" not in endpoints:
138
+ continue
139
+ is_experimental = mid in _TOOLS_RISKY_AUDIO
140
+ pairs.append((mid, is_experimental))
141
+ # Stable ordering — non-experimental first, with curated favourites at
142
+ # the very top within their group.
143
+ def _sort_key(p: tuple[str, bool]) -> tuple[int, int, str]:
144
+ mid, exp = p
145
+ pref_idx = _AUDIO_PREFERRED.index(mid) if mid in _AUDIO_PREFERRED else 999
146
+ return (int(exp), pref_idx, mid)
147
+ pairs.sort(key=_sort_key)
148
+ return pairs
149
+
150
+
151
+ def audio_model_choices() -> list[tuple[str, str]]:
152
+ """UI-friendly form: (display_label, value) pairs.
153
+
154
+ No more `· experimental` suffix on gemini — the salvage-from-content-blocks
155
+ path + tool_choice:none fallback in narrative.py mean gemini's code-exec
156
+ behaviour no longer silently swallows the prose answer. Treating all
157
+ audio-input models as first-class makes the Compare default cleaner.
158
+ """
159
+ return [(mid, mid) for mid, _exp in audio_models()]
160
+
161
+
162
+ def _sort_with_favourites(ids: list[str], favourites: list[str]) -> list[str]:
163
+ """Stable sort: keep `favourites` order at the front, everything else
164
+ alphabetical after. Misses in favourites are silently skipped."""
165
+ seen = set(ids)
166
+ head = [m for m in favourites if m in seen]
167
+ tail = sorted(m for m in ids if m not in head)
168
+ return head + tail
narrative.py ADDED
@@ -0,0 +1,680 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM narrative — pollinations.ai.
2
+
3
+ Uses the canonical OpenAI-compatible chat-completions endpoint at
4
+ `https://gen.pollinations.ai/v1/chat/completions`. Authenticated requests
5
+ unlock the full model list (incl. `claude`, `claude-fast`, gemini-3-flash,
6
+ etc.); see https://gen.pollinations.ai/docs.
7
+
8
+ Env vars:
9
+ POLLINATIONS_API_KEY bearer token from enter.pollinations.ai (preferred)
10
+ POLLINATIONS_TOKEN legacy alias for POLLINATIONS_API_KEY
11
+ POLLINATIONS_MODEL model id (default: "claude")
12
+ POLLINATIONS_URL endpoint override (default: canonical /v1/chat/completions)
13
+
14
+ Without an API key the canonical endpoint returns 401. As a dev fallback for
15
+ no-auth smoke tests, set
16
+ POLLINATIONS_URL=https://text.pollinations.ai/openai
17
+ POLLINATIONS_MODEL=openai
18
+ which serves the anonymous GPT-OSS-20B tier (legacy text API, deprecated).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from typing import Any
25
+
26
+ import requests
27
+
28
+ DEFAULT_URL = "https://gen.pollinations.ai/v1/chat/completions"
29
+ DEFAULT_MODEL = "claude"
30
+
31
+ POLLINATIONS_URL = os.environ.get("POLLINATIONS_URL", DEFAULT_URL)
32
+ POLLINATIONS_MODEL = os.environ.get("POLLINATIONS_MODEL", DEFAULT_MODEL)
33
+
34
+ SYSTEM = (
35
+ "You are writing a sync-music brief for a producer. Given measured analysis"
36
+ " of an audio reference, write 2-3 sentences a music supervisor would"
37
+ " understand: feel, energy arc, and what makes it distinctive. Stay strictly"
38
+ " inside the measurements — in particular, if voiceover_present is false do"
39
+ " NOT mention vocals or voice; if a field is null, do not invent a value."
40
+ " Plain prose, no markdown, no preamble."
41
+ )
42
+
43
+ # Mix-chain prompt — emits an Ableton-ready starting chain anchored in the
44
+ # measurements. Every plugin pick must trace back to a number we measured.
45
+ #
46
+ # Concept credit: inspired by ReferenceMix by Dani Ever Hadani —
47
+ # https://www.linkedin.com/feed/update/urn:li:activity:7474784688038047744/
48
+ # https://referencemix.vercel.app/
49
+ # ReferenceMix takes a TEXT description of a reference track (e.g.
50
+ # "Billie Eilish — Happier Than Ever / vocal") and asks Claude to write
51
+ # a full EQ / compression / saturation / reverb chain from its training
52
+ # knowledge — no audio file, no measurements. We borrow the output shape
53
+ # but ground it in actual measured audio analysis instead of recognition.
54
+ SYSTEM_MIX_CHAIN = (
55
+ "You are an experienced mix engineer recommending an Ableton Live starting"
56
+ " chain for a producer who wants their track to land near the reference."
57
+ " You receive measured analysis of the reference, including per-stem RMS,"
58
+ " peak, and spectral centroid. Your job: emit a short markdown spec with a"
59
+ " ## Master bus section and one ## <stem name> section per stem actually"
60
+ " present. Each section is a numbered list of Ableton stock devices (EQ"
61
+ " Eight, Compressor, Glue Compressor, Drum Buss, Saturator, Limiter, Reverb,"
62
+ " Auto Filter — Ableton stock only) with concrete starting values (Hz, dB,"
63
+ " ratios, ms). Formatting rule: bold every Ableton device name using"
64
+ " markdown `**Name**` (e.g. `**EQ Eight**`, `**Glue Compressor**`) followed"
65
+ " by an em-dash and its parameters, so devices stand out in the rendered"
66
+ " chain. End with a 2-sentence 'Why:' paragraph that ties two or three"
67
+ " specific picks back to the measured numbers (e.g. 'LRA is 6 dB so the bus"
68
+ " compressor sits at 2:1, not 4:1'). Hard rules: do not invent measurements;"
69
+ " do not suggest sidechain compression unless drums AND bass are both"
70
+ " present; do not add a de-esser unless voiceover_present is true; do not"
71
+ " recommend a limiter ceiling higher than the measured true_peak_db plus"
72
+ " 0.5 dB headroom; if voiceover_present is false, never mention vocals."
73
+ " No preamble, no apology, no 'starting point' caveats — just the chain."
74
+ )
75
+
76
+
77
+ def _auth_token(api_key: str | None = None) -> str:
78
+ """Resolve auth: explicit api_key arg (HF Spaces session) wins, then env
79
+ vars (deploy override), then the on-disk wallet (desktop only).
80
+ Returns "" if nothing is set."""
81
+ try:
82
+ from wallet import get_key
83
+ return (get_key(session_key=api_key) or "").strip()
84
+ except Exception:
85
+ return ""
86
+
87
+
88
+ class LLMError(RuntimeError):
89
+ """Raised when a pollinations call fails or returns empty content. The
90
+ string form is short and structured so the UI can show it in a cell:
91
+ 'HTTP 503 · gateway timeout' or 'empty response · model returned no content'."""
92
+
93
+
94
+ def _chat(system: str, user_payload: dict[str, Any], *, max_tokens: int, temperature: float, timeout: float, model: str | None = None, no_cache: bool = False, api_key: str | None = None) -> tuple[str, str]:
95
+ """Returns (text, resolved_model). resolved_model is the model id the
96
+ server actually billed for — Pollinations resolves aliases like
97
+ `gemini` → `gemini-3.5-flash` server-side, so the dropdown selection
98
+ and the actual billing line can differ.
99
+
100
+ `api_key` carries the HF Spaces session-scoped wallet token. Desktop
101
+ callers may omit it — the function falls back to env vars and the
102
+ on-disk wallet via _auth_token()."""
103
+ user_content = dict(user_payload)
104
+ if no_cache:
105
+ # Pollinations caches keyed on payload — a nonce forces a fresh call.
106
+ # Without this, A vs B compare can return identical cached responses
107
+ # and pretend models agree when they were just hitting the same row.
108
+ import secrets
109
+ user_content["_nonce"] = secrets.token_hex(4)
110
+
111
+ payload = {
112
+ "model": model or POLLINATIONS_MODEL,
113
+ "messages": [
114
+ {"role": "system", "content": system},
115
+ {"role": "user", "content": json.dumps(user_content, default=str)},
116
+ ],
117
+ "max_tokens": max_tokens,
118
+ "temperature": temperature,
119
+ }
120
+ headers = {"Content-Type": "application/json"}
121
+ token = _auth_token(api_key)
122
+ if token:
123
+ headers["Authorization"] = f"Bearer {token}"
124
+
125
+ try:
126
+ r = requests.post(POLLINATIONS_URL, headers=headers, json=payload, timeout=timeout)
127
+ except requests.exceptions.Timeout:
128
+ raise LLMError(f"timeout after {timeout:.0f}s · model {model or POLLINATIONS_MODEL} unreachable")
129
+ except requests.exceptions.RequestException as e:
130
+ raise LLMError(f"network · {type(e).__name__}")
131
+
132
+ if r.status_code == 401:
133
+ raise LLMError(
134
+ "HTTP 401 · auth required — connect a wallet in the UI or set POLLINATIONS_API_KEY"
135
+ )
136
+ if r.status_code == 402:
137
+ raise LLMError(_format_402_error(r))
138
+ if r.status_code == 429:
139
+ raise LLMError("HTTP 429 · rate limited — connect a wallet or slow down")
140
+ if r.status_code in (502, 503, 504):
141
+ raise LLMError(f"HTTP {r.status_code} · upstream gateway error — retry, or switch model")
142
+ if not r.ok:
143
+ # Last-ditch — pull a short error message from the body if present.
144
+ snippet = ""
145
+ try:
146
+ j = r.json()
147
+ snippet = (j.get("error") or {}).get("message") if isinstance(j.get("error"), dict) else j.get("error")
148
+ snippet = str(snippet or "")[:120]
149
+ except Exception:
150
+ snippet = r.text[:120]
151
+ raise LLMError(f"HTTP {r.status_code} · {snippet}")
152
+
153
+ try:
154
+ data = r.json()
155
+ except Exception:
156
+ raise LLMError("invalid response · non-JSON body")
157
+ choice = (data.get("choices") or [{}])[0]
158
+ message = choice.get("message") or {}
159
+ txt, blocks = _normalize_message(message)
160
+ finish = choice.get("finish_reason") or ""
161
+ if not txt:
162
+ # No prose? Try block-salvage before giving up — gemini sometimes
163
+ # answers in code_execution_result blocks rather than message.content.
164
+ salvaged = _salvage_from_content_blocks(blocks)
165
+ if salvaged:
166
+ txt = salvaged
167
+ else:
168
+ raise LLMError(f"empty response · finish_reason={finish or 'unspecified'}")
169
+ if finish == "length":
170
+ # Codex review P3: non-empty content + finish_reason=length means
171
+ # the model was cut off mid-output — for mix chains this can drop
172
+ # the trailing Why paragraph or whole sections. Append a visible
173
+ # warning so the cell reader knows the chain may be incomplete.
174
+ txt = (
175
+ txt
176
+ + "\n\n*(⚠ response truncated at max_tokens — try a smaller model"
177
+ " or raise BRIEF_MAX_TOKENS / CHAIN_MAX_TOKENS in narrative.py)*"
178
+ )
179
+ resolved = str(data.get("model") or model or POLLINATIONS_MODEL)
180
+ return txt, resolved
181
+
182
+
183
+ def _format_402_error(r) -> str:
184
+ """Parse Pollinations' 402 body to surface balance/cost numerically rather
185
+ than dumping the raw JSON snippet. Body shape:
186
+ {"success": false, "error": {"message": "Insufficient balance. ..."}}
187
+ """
188
+ try:
189
+ j = r.json()
190
+ msg = (j.get("error") or {}).get("message") or ""
191
+ except Exception:
192
+ msg = ""
193
+ # Pull "costs ~0.0156 pollen" and "available balance is 0.0000" out of msg
194
+ import re as _re
195
+ cost_m = _re.search(r"costs?\s*~?(\d+\.\d+)", msg)
196
+ bal_m = _re.search(r"balance\s+is\s*(\d+\.\d+)", msg)
197
+ if cost_m and bal_m:
198
+ return (
199
+ f"💸 out of pollen · need {cost_m.group(1)}, have {bal_m.group(1)}"
200
+ f" — top up at enter.pollinations.ai"
201
+ )
202
+ return "HTTP 402 · insufficient pollen — top up at enter.pollinations.ai"
203
+
204
+
205
+ # Token budgets are sized for *reasoning-mode* models (Gemini 3.x family,
206
+ # grok-reasoning, etc.) which burn output budget on internal thinking before
207
+ # emitting `content`. If max_tokens is too small they hit finish_reason=length
208
+ # during the thinking pass and return an empty content cell. Claude doesn't
209
+ # pad — over-allocating costs nothing there.
210
+ BRIEF_MAX_TOKENS = 1500 # ~200 visible output, ~1300 reasoning headroom
211
+ CHAIN_MAX_TOKENS = 6000 # ~1500 visible output, ~4500 reasoning headroom
212
+ AUDIO_MAX_TOKENS = 9000 # multimodal: audio input itself uses tokens, reasoning
213
+ # tends to inflate, so we need more headroom than CHAIN
214
+
215
+
216
+ def write_brief(analysis: dict[str, Any], timeout: float = 30.0, model: str | None = None, no_cache: bool = False, api_key: str | None = None) -> tuple[str, str]:
217
+ """Return (text, resolved_model). Raises LLMError on failure."""
218
+ return _chat(SYSTEM, analysis, max_tokens=BRIEF_MAX_TOKENS, temperature=0.5, timeout=timeout, model=model, no_cache=no_cache, api_key=api_key)
219
+
220
+
221
+ def write_mix_chain(analysis: dict[str, Any], timeout: float = 120.0, model: str | None = None, no_cache: bool = False, api_key: str | None = None) -> tuple[str, str]:
222
+ """Return (text, resolved_model). Raises LLMError on failure."""
223
+ return _chat(SYSTEM_MIX_CHAIN, analysis, max_tokens=CHAIN_MAX_TOKENS, temperature=0.3, timeout=timeout, model=model, no_cache=no_cache, api_key=api_key)
224
+
225
+
226
+ SYSTEM_SA3_PROMPT = (
227
+ "You convert an audio brief into a SINGLE-LINE text-to-audio prompt suitable"
228
+ " for Stable Audio 3 (SA3). The user gives you measured analysis and a"
229
+ " short LLM brief describing the reference; you must write a ONE-LINE prompt"
230
+ " (≤30 words, comma-separated descriptors, no markdown, no preamble) that"
231
+ " would re-generate a *cousin* of the reference.\n\n"
232
+ "Required components, in this order:\n"
233
+ " 1. Genre / sub-genre — INFER from BPM if no genre tag is given. Examples:\n"
234
+ " 170-180 BPM with drums+bass → 'drum and bass' or 'jungle'\n"
235
+ " 140-160 BPM hard → 'hardcore' or 'gabber'\n"
236
+ " 120-130 BPM 4/4 → 'house' or 'techno'\n"
237
+ " 70-90 BPM swung → 'hip-hop' or 'trap'\n"
238
+ " 60-90 BPM ambient → 'downtempo' / 'lo-fi'\n"
239
+ " 2. Mood / texture — 2-3 evocative adjectives (dark, atmospheric, lo-fi, gritty, etc.) drawn from the brief.\n"
240
+ " 3. BPM (anchor explicitly: 'NNN BPM').\n"
241
+ " 4. Key if non-trivial (e.g. 'in F minor').\n"
242
+ " 5. Instrumentation — bass / drums / synths / vocals as present in stems.\n"
243
+ " 6. Structural hint — 'continuous loop, no intro no outro, full energy throughout'\n"
244
+ " UNLESS the brief mentions a clear arc, in which case describe it.\n\n"
245
+ "Output the line itself, NOTHING ELSE. No quotes. No 'Prompt:'. No newlines."
246
+ )
247
+
248
+
249
+ SYSTEM_SA3_PROMPT_LONG = (
250
+ "You rewrite an audio brief paragraph into a DENSE, single-paragraph SA3"
251
+ " text-to-audio prompt (50-90 words). Keep every sonic descriptor; drop"
252
+ " everything that's not about sound itself.\n\n"
253
+ "STRIP these (they confuse SA3 with off-topic signal):\n"
254
+ " - Audience framing ('ideal for action cuts', 'product launches', 'sports promos').\n"
255
+ " - Duration claims ('this 30-second cue', '8-second outro').\n"
256
+ " - Editorial verbs ('makes it', 'creates a', 'feels ready for').\n"
257
+ " - Mix-engineer measurements ('-22 LUFS', 'LRA 1.71') — except quote BPM and key.\n"
258
+ " - Suggestions about what the user could do with it.\n\n"
259
+ "KEEP and amplify these:\n"
260
+ " - Genre / sub-genre (infer from BPM range if absent: 170-180 + drums+bass = DnB/jungle).\n"
261
+ " - Mood adjectives (dark, aggressive, hypnotic, etc.).\n"
262
+ " - Tempo (BPM explicit).\n"
263
+ " - Key (e.g. 'in C major').\n"
264
+ " - Instrumentation (heavy bass, breakbeat drums, synth pads, vocals).\n"
265
+ " - Texture / production quality (lo-fi, gritty, polished, raw, compressed).\n"
266
+ " - Structural character (continuous loop, build-and-drop, stutter outro).\n\n"
267
+ "Output ONE paragraph, comma-and-period-separated descriptors. No bullets,"
268
+ " no markdown, no 'Prompt:' prefix, no quotation marks. Start with the"
269
+ " genre, then BPM and key early, then mood/instrumentation/texture/structure."
270
+ )
271
+
272
+
273
+ SYSTEM_SA3_PROMPT_BLENDED = (
274
+ "You merge a user's original SA3 prompt with an audio brief describing what"
275
+ " the model actually produced. Output a single SA3 prompt (60-100 words) that"
276
+ " a producer would send to get *more like the one I picked* — preserving the"
277
+ " user's intent vocabulary AND locking in the structural arc the analysis"
278
+ " measured.\n\n"
279
+ "RULES:\n"
280
+ " - KEEP every aesthetic/genre/mood word from the user's original prompt"
281
+ " (e.g. 'dark', 'atmospheric', 'lo-fi', 'jungle', 'heavy bass'). The brief"
282
+ " may have missed these; the original is authoritative for vibe.\n"
283
+ " - REPLACE the original prompt's BPM with the MEASURED BPM if different"
284
+ " (the model may have drifted; we want to lock the actual tempo).\n"
285
+ " - REPLACE the original's generic structural language ('continuous loop',"
286
+ " 'no intro no outro') with the brief's specific measured arc"
287
+ " (e.g. 'brief 2s intro, 18s sustained core, 3 rapid stutter-cut outros').\n"
288
+ " - DROP everything in the brief that's audience framing ('ideal for action"
289
+ " cuts', 'product launches'), duration claims ('30-second cue'), and"
290
+ " mix-engineer numbers (LUFS, LRA) — except the measured BPM and key.\n"
291
+ " - Format: dense single paragraph, comma/period separated. No bullets,"
292
+ " no quotes, no 'Prompt:' prefix, no markdown.\n\n"
293
+ "STRUCTURE the output as:\n"
294
+ " [user's intent vocabulary] · [measured BPM + key] · [specific arc from brief]"
295
+ " · [instrumentation/texture from both sides]"
296
+ )
297
+
298
+
299
+ def write_sa3_prompt_blended(
300
+ original_prompt: str,
301
+ brief_payload: dict[str, Any],
302
+ brief_text: str,
303
+ *,
304
+ model: str | None = None,
305
+ timeout: float = 30.0,
306
+ no_cache: bool = False,
307
+ api_key: str | None = None,
308
+ ) -> tuple[str, str]:
309
+ """Merge a user's original SA3 prompt with an audio-brief description.
310
+
311
+ Use case: user runs a batch of gens from a prompt, picks the take they
312
+ like, runs it through audio-brief, then wants 'more like this one' —
313
+ preserving their aesthetic vocabulary AND the structural arc the chosen
314
+ take happened to have. Brief-only re-gen loses aesthetic; original-only
315
+ loses arc; blended keeps both.
316
+ """
317
+ payload = {
318
+ "user_original_prompt": original_prompt,
319
+ "measured_analysis": brief_payload,
320
+ "brief_paragraph": brief_text,
321
+ }
322
+ return _chat(
323
+ SYSTEM_SA3_PROMPT_BLENDED, payload,
324
+ max_tokens=500, temperature=0.4,
325
+ timeout=timeout, model=model, no_cache=no_cache, api_key=api_key,
326
+ )
327
+
328
+
329
+ def write_sa3_prompt_long(
330
+ brief_payload: dict[str, Any],
331
+ brief_text: str,
332
+ *,
333
+ model: str | None = None,
334
+ timeout: float = 30.0,
335
+ no_cache: bool = False,
336
+ api_key: str | None = None,
337
+ ) -> tuple[str, str]:
338
+ """A longer, denser SA3 prompt — keeps every sonic descriptor from the
339
+ brief paragraph, strips audience/duration/editorial framing.
340
+
341
+ Use to test whether richer prompts help SA3 anchor closer to the source
342
+ or dilute the signal vs. the shorter `write_sa3_prompt` compressor.
343
+ """
344
+ payload = {"analysis": brief_payload, "brief_paragraph": brief_text}
345
+ return _chat(
346
+ SYSTEM_SA3_PROMPT_LONG, payload,
347
+ max_tokens=400, temperature=0.4,
348
+ timeout=timeout, model=model, no_cache=no_cache, api_key=api_key,
349
+ )
350
+
351
+
352
+ def write_sa3_prompt(
353
+ brief_payload: dict[str, Any],
354
+ brief_text: str,
355
+ *,
356
+ model: str | None = None,
357
+ timeout: float = 30.0,
358
+ no_cache: bool = False,
359
+ api_key: str | None = None,
360
+ ) -> tuple[str, str]:
361
+ """Compress (measured analysis + LLM brief) into a single-line SA3 prompt.
362
+
363
+ Returns (prompt, resolved_model). Raises LLMError on failure.
364
+
365
+ This replaces regex-extracting adjectives from the brief — that approach
366
+ threw away most of the signal and missed genre entirely (e.g. 172 BPM
367
+ with drums + bass IS drum and bass, but no regex catches that).
368
+ """
369
+ payload = {
370
+ "analysis": brief_payload,
371
+ "brief_paragraph": brief_text,
372
+ }
373
+ return _chat(
374
+ SYSTEM_SA3_PROMPT, payload,
375
+ max_tokens=300, temperature=0.5,
376
+ timeout=timeout, model=model, no_cache=no_cache, api_key=api_key,
377
+ )
378
+
379
+
380
+ # Audio-only system prompt — the model receives the file and *no measurements*,
381
+ # and must estimate everything by listening. Used by the Compare tab's third
382
+ # column to demonstrate what the wedge buys: the LLM has to guess BPM/key/LUFS
383
+ # instead of being told them.
384
+ SYSTEM_AUDIO_ONLY = (
385
+ "You receive a short audio reference clip and NO measurements. Listen carefully and"
386
+ " produce TWO clearly separated sections. Use these EXACT marker strings on their own"
387
+ " lines (do not replace them with ## or ### markdown headers):\n\n"
388
+ "=== BRIEF ===\n"
389
+ "[2-3 sentences a music supervisor would understand: feel, energy arc, what makes it"
390
+ " distinctive. Estimate BPM, key, loudness by ear — mark each estimate with 'est.'.]\n\n"
391
+ "=== MIX CHAIN ===\n"
392
+ "[Markdown with a ## Master bus section and one ## <stem> section per audible instrument"
393
+ " family you can pick out (drums, bass, vocals if present, other). Each section is a"
394
+ " numbered list of Ableton stock devices (EQ Eight, Compressor, Glue Compressor, Drum"
395
+ " Buss, Saturator, Limiter, Reverb) with concrete starting values. Bold every device name"
396
+ " with markdown `**Name**` (e.g. `**EQ Eight**`) followed by an em-dash and its"
397
+ " parameters. EXACTLY ONE '**Why:**' paragraph appears at the very end, AFTER every stem"
398
+ " section — never one Why per section. The single trailing Why is 2 sentences that name"
399
+ " what you heard. No preamble. No apology. Do NOT narrate"
400
+ " 'I will now construct the output' — just emit the two sections. If you can't tell the"
401
+ " BPM or key by ear, say so explicitly in the brief instead of guessing wildly."
402
+ )
403
+
404
+
405
+ import re as _re
406
+
407
+ _MIX_CHAIN_HEADER_RE = _re.compile(
408
+ r"^\s*#{1,4}\s*mix\s*chain.*$",
409
+ _re.IGNORECASE | _re.MULTILINE,
410
+ )
411
+ _FIRST_STEM_HEADER_RE = _re.compile(
412
+ r"^\s*#{1,4}\s+(master[\s-]*bus|master|drums?|bass|vocals?|other)\b",
413
+ _re.IGNORECASE | _re.MULTILINE,
414
+ )
415
+ _BRIEF_HEADER_LINE_RE = _re.compile(
416
+ r"^\s*(?:===\s*BRIEF\s*===|#{1,4}\s*brief)\s*$",
417
+ _re.IGNORECASE | _re.MULTILINE,
418
+ )
419
+
420
+
421
+ def _split_brief_and_chain(txt: str) -> tuple[str, str]:
422
+ """Best-effort split of the audio-only model's output into brief + chain.
423
+ Tries several formats in priority order because models routinely ignore
424
+ exotic markers like '=== MIX CHAIN ===' in favour of '### Mix Chain' or
425
+ just jump straight into '## Master bus'."""
426
+ # 1. Honored marker exactly as prompted.
427
+ if "=== MIX CHAIN ===" in txt:
428
+ head, tail = txt.split("=== MIX CHAIN ===", 1)
429
+ return _BRIEF_HEADER_LINE_RE.sub("", head).strip(), tail.strip()
430
+ # 2. Some 'Mix Chain' header in any markdown depth.
431
+ m = _MIX_CHAIN_HEADER_RE.search(txt)
432
+ if m:
433
+ return (
434
+ _BRIEF_HEADER_LINE_RE.sub("", txt[: m.start()]).strip(),
435
+ txt[m.end():].lstrip(),
436
+ )
437
+ # 3. Last resort: split at the first canonical-stem heading (## Master,
438
+ # ## Drums, etc.) — chain content starts there.
439
+ m = _FIRST_STEM_HEADER_RE.search(txt)
440
+ if m:
441
+ return (
442
+ _BRIEF_HEADER_LINE_RE.sub("", txt[: m.start()]).strip(),
443
+ txt[m.start():].strip(),
444
+ )
445
+ # 4. Nothing found — treat entire output as brief.
446
+ return _BRIEF_HEADER_LINE_RE.sub("", txt).strip(), ""
447
+
448
+
449
+ def _compress_for_multimodal(audio_path: str, max_duration_s: float = 30.0) -> tuple[bytes, str]:
450
+ """Resample to 16 kHz mono WAV and (optionally) trim to a representative
451
+ head segment. Raw audio at 44.1 kHz stereo blows past pollinations' request
452
+ size limit (~3.4 MB for 30 s) — at 16 kHz mono the same clip is ~960 KB
453
+ raw / ~1.3 MB base64-encoded, well inside any reasonable body limit.
454
+ 16 kHz is plenty for genre / tempo identification."""
455
+ import io
456
+ import librosa
457
+ import soundfile as sf
458
+
459
+ y, sr = librosa.load(audio_path, sr=16000, mono=True, duration=max_duration_s)
460
+ buf = io.BytesIO()
461
+ sf.write(buf, y, sr, format="WAV", subtype="PCM_16")
462
+ return buf.getvalue(), "wav"
463
+
464
+
465
+ def _normalize_message(message: dict) -> tuple[str, list]:
466
+ """Pollinations response shapes vary by upstream provider:
467
+ - OpenAI-style: message.content is a str
468
+ - Anthropic-style: message.content is a list of {type: "text", text: ...}
469
+ - Gemini-style: message.content is "" + message.content_blocks holds
470
+ executable_code / code_execution_result blocks
471
+
472
+ Return (text, blocks):
473
+ - `text` is the best-effort prose answer (str list flattened, "" if none)
474
+ - `blocks` is the raw content_blocks list (may be empty) so callers
475
+ that need to introspect tool-use specifically still can.
476
+ """
477
+ content = message.get("content")
478
+ blocks = message.get("content_blocks") or []
479
+ if isinstance(content, str):
480
+ return content.strip(), blocks
481
+ if isinstance(content, list):
482
+ # List-of-blocks form — flatten {type:"text", text:...} entries.
483
+ parts: list[str] = []
484
+ for blk in content:
485
+ if isinstance(blk, dict):
486
+ if blk.get("type") == "text" and blk.get("text"):
487
+ parts.append(str(blk["text"]))
488
+ elif blk.get("text"):
489
+ parts.append(str(blk["text"]))
490
+ elif isinstance(blk, str):
491
+ parts.append(blk)
492
+ return ("\n".join(parts)).strip(), blocks
493
+ return "", blocks
494
+
495
+
496
+ def _salvage_from_content_blocks(blocks: list) -> str:
497
+ """When the prose answer is empty but the model spent its turn running
498
+ code (Pollinations gemini's code-execution tool), extract any human-
499
+ readable text from the block list so the cell isn't completely blank.
500
+ Handles both nested ({"code_execution_result": {"output": ...}}) and
501
+ flat ({"output": ...}) shapes the API has historically returned.
502
+
503
+ Returns "" if nothing salvageable exists.
504
+ """
505
+ if not isinstance(blocks, list):
506
+ return ""
507
+ parts: list[str] = []
508
+ for blk in blocks:
509
+ if not isinstance(blk, dict):
510
+ continue
511
+ btype = blk.get("type") or ""
512
+ if btype == "text" and blk.get("text"):
513
+ parts.append(str(blk["text"]))
514
+ elif btype == "code_execution_result":
515
+ # Nested shape (current): blk["code_execution_result"]["output"]
516
+ inner = blk.get("code_execution_result") or {}
517
+ out = inner.get("output") or inner.get("stdout") or ""
518
+ # Flat shape (occasionally seen): blk["output"]
519
+ if not out:
520
+ out = blk.get("output") or blk.get("stdout") or ""
521
+ if out:
522
+ parts.append("```\n" + str(out).strip() + "\n```")
523
+ return "\n\n".join(p for p in parts if p.strip())
524
+
525
+
526
+ def write_audio_only(audio_path: str, *, model: str | None = None, timeout: float = 180.0, no_cache: bool = False, api_key: str | None = None) -> dict[str, str]:
527
+ """Send the raw audio file directly to a multimodal model and ask for the
528
+ same brief + chain — but with no measurements supplied. The model has to
529
+ guess BPM/key/LUFS by listening.
530
+
531
+ Returns {"brief": str, "chain": str}. Raises LLMError on failure.
532
+ """
533
+ import base64
534
+
535
+ audio_bytes, fmt = _compress_for_multimodal(audio_path)
536
+ audio_b64 = base64.b64encode(audio_bytes).decode("ascii")
537
+
538
+ base_text = "Analyse this audio reference. Output both sections in the required format."
539
+
540
+ headers = {"Content-Type": "application/json"}
541
+ token = _auth_token(api_key)
542
+ if token:
543
+ headers["Authorization"] = f"Bearer {token}"
544
+
545
+ # Reasoning models (gemini-3-flash, claude-*) sometimes burn the entire
546
+ # output budget on internal thinking and return finish_reason=stop with
547
+ # content="". A second attempt usually lands different reasoning depth
548
+ # and produces visible content. Retry once on empty-string content.
549
+ last_finish = ""
550
+ last_usage: dict[str, Any] = {}
551
+ last_blocks: list = []
552
+ last_used_tools = False
553
+ last_resolved = model or "openai-audio"
554
+ # codex P5: try `tool_choice: "none"` to stop tool-using models (gemini
555
+ # code execution) from blowing through the token budget. Pollinations
556
+ # may not honour the field for all upstreams; if a 400 comes back, retry
557
+ # without it and remember not to send it again on this call.
558
+ include_tool_choice = True
559
+ for attempt in (0, 1):
560
+ # Fresh nonce per attempt — Pollinations caches by request hash, so a
561
+ # static nonce would replay the same empty response on retry. (codex
562
+ # review patch set 4 · item 4.)
563
+ if no_cache:
564
+ import secrets
565
+ user_text = base_text + f"\n_nonce: {secrets.token_hex(4)}_"
566
+ else:
567
+ user_text = base_text
568
+
569
+ payload: dict[str, Any] = {
570
+ "model": model or "openai-audio",
571
+ "messages": [
572
+ {"role": "system", "content": SYSTEM_AUDIO_ONLY},
573
+ {"role": "user", "content": [
574
+ {"type": "text", "text": user_text},
575
+ {"type": "input_audio", "input_audio": {"data": audio_b64, "format": fmt}},
576
+ ]},
577
+ ],
578
+ "max_tokens": AUDIO_MAX_TOKENS,
579
+ "temperature": 0.3,
580
+ }
581
+ if include_tool_choice:
582
+ payload["tool_choice"] = "none"
583
+
584
+ try:
585
+ r = requests.post(POLLINATIONS_URL, headers=headers, json=payload, timeout=timeout)
586
+ except requests.exceptions.Timeout:
587
+ raise LLMError(f"timeout after {timeout:.0f}s · audio-only call unreachable")
588
+ except requests.exceptions.RequestException as e:
589
+ raise LLMError(f"network · {type(e).__name__}")
590
+
591
+ if r.status_code == 401:
592
+ raise LLMError("HTTP 401 · auth required — connect wallet")
593
+ if r.status_code == 402:
594
+ raise LLMError(_format_402_error(r))
595
+ if r.status_code in (415, 422):
596
+ raise LLMError(f"HTTP {r.status_code} · model `{model}` doesn't accept audio input — try openai-audio or openai-audio-large")
597
+ # codex P5: if Pollinations rejects tool_choice (400), retry once
598
+ # without it. Some upstreams don't honour the OpenAI tool_choice field.
599
+ if r.status_code == 400 and include_tool_choice:
600
+ body = r.text.lower()
601
+ if "tool_choice" in body or "unsupported" in body or "invalid" in body:
602
+ include_tool_choice = False
603
+ continue
604
+ if not r.ok:
605
+ snippet = r.text[:120]
606
+ raise LLMError(f"HTTP {r.status_code} · {snippet}")
607
+
608
+ try:
609
+ data = r.json()
610
+ except Exception:
611
+ raise LLMError("invalid response · non-JSON body")
612
+
613
+ choice = (data.get("choices") or [{}])[0]
614
+ message = choice.get("message") or {}
615
+ txt, last_blocks = _normalize_message(message)
616
+ last_finish = choice.get("finish_reason") or ""
617
+ last_usage = data.get("usage") or {}
618
+ last_used_tools = bool(last_blocks)
619
+ last_resolved = str(data.get("model") or model or "openai-audio")
620
+ if txt:
621
+ finish = last_finish
622
+ break
623
+ # empty content — only retry if it looks like a reasoning-budget miss,
624
+ # i.e. finish_reason=stop or length. Other stop reasons (content_filter)
625
+ # won't be fixed by retrying.
626
+ if last_finish not in ("stop", "length") or attempt == 1:
627
+ break
628
+ else:
629
+ txt = ""
630
+ finish = last_finish
631
+
632
+ if not txt:
633
+ # Last-resort fallback — Pollinations gemini sometimes wraps the model
634
+ # in a code-execution tool loop. The `content` field is the prose
635
+ # answer, but `content_blocks` contains the tool round-trips: their
636
+ # `code_execution_result` blocks usually hold stdout from the code the
637
+ # model ran (BPM, key estimates). Surface that so the cell isn't
638
+ # blank, with a clear caveat that this came from gemini's own tool
639
+ # run, not from the prose answer we asked for.
640
+ salvaged = _salvage_from_content_blocks(last_blocks)
641
+ if salvaged:
642
+ warning = (
643
+ "_(⚠ model produced no prose answer — likely the gemini code"
644
+ " execution tool ran instead. Salvaged tool stdout below; rerun"
645
+ " to get a proper brief + chain.)_\n\n"
646
+ )
647
+ txt = warning + salvaged
648
+ finish = last_finish
649
+ else:
650
+ details = (last_usage.get("completion_tokens_details") or {})
651
+ rtok = details.get("reasoning_tokens")
652
+ ctok = last_usage.get("completion_tokens")
653
+ hint_parts: list[str] = []
654
+ if rtok: hint_parts.append(f"reasoning_tokens={rtok}")
655
+ if last_used_tools: hint_parts.append("tool-use was active")
656
+ if ctok and rtok and ctok > rtok * 3:
657
+ hint_parts.append(f"completion={ctok} (mostly tool round-trips)")
658
+ hint = (" · " + " · ".join(hint_parts)) if hint_parts else ""
659
+ raise LLMError(
660
+ f"empty response · finish_reason={last_finish or 'unspecified'}{hint}"
661
+ f" — the gemini route on Pollinations likes to execute code; try"
662
+ f" `openai-audio` for a pure listen-and-answer model, or rerun."
663
+ )
664
+
665
+ if not txt: # belt-and-braces — fallback didn't help
666
+ raise LLMError(f"empty response · finish_reason={last_finish or 'unspecified'}")
667
+
668
+ brief, chain = _split_brief_and_chain(txt)
669
+ if not chain:
670
+ chain = "_(model didn't produce a separate chain section — see brief cell for its full reply)_"
671
+ if finish == "length":
672
+ # Mark whichever section ended up holding the tail — typically the
673
+ # chain — as truncated so the user doesn't take an incomplete spec
674
+ # at face value. (codex review P3.)
675
+ warning = (
676
+ "\n\n*(⚠ audio-only response truncated at max_tokens — chain may"
677
+ " be missing trailing sections or the Why paragraph)*"
678
+ )
679
+ chain = chain + warning if chain.strip() != "" else brief + warning
680
+ return {"brief": brief, "chain": chain, "model_resolved": last_resolved}
outputs.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Copy-paste outputs produced from an Analysis — Tab 3 of the UI."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ from pipeline import Analysis
10
+
11
+
12
+ # Accept 2–4 `#` levels so we stay consistent with narrative._FIRST_STEM_HEADER_RE.
13
+ # Some models emit `### Master bus` instead of `## Master bus`; without
14
+ # matching that here, splitting succeeds upstream but parsing fails and
15
+ # every C cell shows `—`. (codex review P3.)
16
+ _HEADING_RE = re.compile(r"^#{1,4}\s+\S")
17
+ _HEADING_CAPTURE_RE = re.compile(r"^#{1,4}\s+(.+?)\s*$")
18
+ _WHY_RE = re.compile(r"^\*?\*?Why:?\*?\*?", re.IGNORECASE)
19
+
20
+
21
+ # Canonical section order for the Compare table. Anything not in this list
22
+ # falls in after, alphabetised. "__why__" is appended last.
23
+ CANONICAL_SECTIONS = ["master bus", "drums", "bass", "vocals", "other"]
24
+ SECTION_ALIASES = {
25
+ "master": "master bus",
26
+ "masterbus": "master bus",
27
+ "master buss": "master bus",
28
+ }
29
+
30
+
31
+ def _fold_extras_into_other(sections: dict[str, str]) -> dict[str, str]:
32
+ """When the audio-only model invents instrument sections that aren't in
33
+ the demucs stem split (Guitar, Synth/Fx, Horns, Keys, etc.), collapse
34
+ them into the `other` row of the comparison table as bold sub-headings.
35
+ Keeps the row count constant across columns even when the audio-only
36
+ column heard more granularity inside what the stems labelled `other`.
37
+
38
+ The original section title becomes a bold sub-heading inside the merged
39
+ cell, so the producer can still see exactly which instruments the model
40
+ identified by ear."""
41
+ if not sections:
42
+ return sections
43
+ canonical = set(CANONICAL_SECTIONS) | {"__why__"}
44
+ extras = {k: v for k, v in sections.items() if k not in canonical}
45
+ if not extras:
46
+ return sections
47
+ result = {k: v for k, v in sections.items() if k in canonical}
48
+ parts: list[str] = []
49
+ existing_other = result.get("other", "").strip()
50
+ if existing_other:
51
+ parts.append(existing_other)
52
+ for name, body in extras.items():
53
+ body = (body or "").strip()
54
+ if not body:
55
+ continue
56
+ parts.append(f"**{name.title()}**\n{body}")
57
+ result["other"] = "\n\n".join(parts) if parts else result.get("other", "")
58
+ return result
59
+
60
+
61
+ def parse_chain_sections(chain: str) -> dict[str, str]:
62
+ """Split a chain markdown into {section_name: body} for table assembly.
63
+ Section names lowercased and aliased to a canonical form so different
64
+ models' heading styles ('Master Bus' / 'master bus' / 'Master') collapse
65
+ onto the same row in the comparison table. A trailing 'Why:' paragraph
66
+ is captured under the key '__why__'."""
67
+ if not chain:
68
+ return {}
69
+ sections: dict[str, str] = {}
70
+ current_name: str | None = None
71
+ current_lines: list[str] = []
72
+ why_lines: list[str] = []
73
+ in_why = False
74
+
75
+ def flush_current() -> None:
76
+ nonlocal current_name, current_lines
77
+ if current_name is not None:
78
+ # Strip a trailing `---` divider that the LLM emitted between
79
+ # sections — we'd otherwise show it as text inside the cell.
80
+ while current_lines and (current_lines[-1].strip() == ""
81
+ or current_lines[-1].strip() == "---"):
82
+ current_lines.pop()
83
+ sections[current_name] = "\n".join(current_lines).strip()
84
+ current_name = None
85
+ current_lines = []
86
+
87
+ # Don't flush a Why block until we know if it's trailing (global) or
88
+ # mid-spec (per-section). When another heading follows, the Why was
89
+ # per-section and gets folded back into the previous section's body.
90
+ for ln in chain.split("\n"):
91
+ m = _HEADING_CAPTURE_RE.match(ln)
92
+ if m:
93
+ # In-flight Why turned out to be per-section, not global.
94
+ if in_why and why_lines:
95
+ current_lines.extend(why_lines)
96
+ why_lines = []
97
+ in_why = False
98
+ flush_current()
99
+ in_why = False
100
+ name = m.group(1).strip().lower().rstrip(":")
101
+ current_name = SECTION_ALIASES.get(name, name)
102
+ continue
103
+ if _WHY_RE.match(ln.strip()) and current_name is not None:
104
+ if in_why and why_lines:
105
+ # Second Why inside the same section — fold earlier block back.
106
+ current_lines.extend(why_lines)
107
+ why_lines = []
108
+ in_why = True
109
+ why_lines.append(ln)
110
+ continue
111
+ if in_why:
112
+ why_lines.append(ln)
113
+ elif current_name is not None:
114
+ current_lines.append(ln)
115
+
116
+ # End of chain reached. Whatever Why is still buffered IS the trailing
117
+ # global Why — flush the in-progress section first (without the Why),
118
+ # then attach it.
119
+ flush_current()
120
+ if why_lines:
121
+ sections["__why__"] = "\n".join(why_lines).strip()
122
+ return sections
123
+
124
+
125
+ _INLINE_BOLD = re.compile(r"\*\*([^*\n]+?)\*\*")
126
+ _INLINE_ITALIC = re.compile(r"(?<!\*)\*([^*\n]+?)\*(?!\*)")
127
+ _INLINE_CODE = re.compile(r"`([^`\n]+?)`")
128
+ # Match either the literal `>` (in case _inline_md_to_html ever sees pre-escape
129
+ # text again) or `&gt;` — _cell_md runs html.escape() before us, so blockquote
130
+ # markers arrive as `&gt;`. Without this, escaping would silently kill the
131
+ # blockquote rendering. (codex review patch set 4 · item 3.)
132
+ _BLOCKQUOTE = re.compile(r"^(?:>|&gt;)\s*(.+)$", re.MULTILINE)
133
+
134
+
135
+ def _inline_md_to_html(s: str) -> str:
136
+ """Convert inline markdown (bold, italic, code, blockquote) to HTML.
137
+ Necessary because markdown inside HTML <td> blocks is NOT recursively
138
+ parsed by Gradio's renderer — without this, `**EQ Eight**` shows as
139
+ literal asterisks rather than bold.
140
+
141
+ The caller MUST html.escape() the input first — this function injects
142
+ raw HTML tags, and any unescaped `<` from the LLM would otherwise become
143
+ a browser-rendered tag (XSS risk per codex review P1)."""
144
+ s = _INLINE_BOLD.sub(r"<strong>\1</strong>", s)
145
+ s = _INLINE_ITALIC.sub(r"<em>\1</em>", s)
146
+ s = _INLINE_CODE.sub(r"<code>\1</code>", s)
147
+ # Claude likes blockquotes for side-notes; render those italicised
148
+ # so they read as asides without an actual <blockquote> block.
149
+ s = _BLOCKQUOTE.sub(r"<em style=\"color:#666\">\1</em>", s)
150
+ return s
151
+
152
+
153
+ def _cell_md(content: str) -> str:
154
+ """Collapse a multi-line markdown chunk into a single table cell.
155
+
156
+ Order matters: escape FIRST so any `<img onerror=…>` or other tags an
157
+ LLM emitted become text, THEN apply the markdown→HTML transforms
158
+ (which only insert tags around content the regex matched, all of which
159
+ was already escaped). Tables don't allow real newlines, so the final
160
+ step swaps them for `<br>`."""
161
+ if not content:
162
+ return "—"
163
+ escaped = html.escape(content, quote=False)
164
+ rendered = _inline_md_to_html(escaped)
165
+ return rendered.replace("\n\n", "<br><br>").replace("\n", "<br>")
166
+
167
+
168
+ def compare_table_markdown(
169
+ briefs: dict[str, str | None],
170
+ chains: dict[str, str | None],
171
+ labels: dict[str, str],
172
+ ) -> str:
173
+ """Render a 3-way side-by-side comparison as a single markdown table.
174
+
175
+ briefs[col] — paragraph text, or None while still running
176
+ chains[col] — raw chain markdown, or None while still running
177
+ labels[col] — column header text (e.g. 'A · claude')
178
+ col ∈ {'a','b','c'}
179
+ """
180
+ parsed: dict[str, dict[str, str] | None] = {}
181
+ for col in ("a", "b", "c"):
182
+ ch = chains.get(col)
183
+ if ch:
184
+ sections = parse_chain_sections(ch)
185
+ sections = _fold_extras_into_other(sections)
186
+ parsed[col] = sections
187
+ else:
188
+ parsed[col] = None
189
+
190
+ all_sections: set[str] = set()
191
+ for p in parsed.values():
192
+ if p:
193
+ all_sections.update(p.keys())
194
+
195
+ ordered = [s for s in CANONICAL_SECTIONS if s in all_sections]
196
+ if "__why__" in all_sections:
197
+ ordered.append("__why__")
198
+
199
+ # HTML table — markdown tables can't set column widths, and Gradio's
200
+ # default rendering squeezes a 4-word label column to ~3 char wide,
201
+ # which produces "Ma ste r Bu s" vertical-letter labels. <col> tags fix it.
202
+ parts: list[str] = []
203
+ parts.append('<table style="width:100%; border-collapse:collapse; table-layout:fixed">')
204
+ parts.append('<colgroup>')
205
+ parts.append('<col style="width:8%">')
206
+ parts.append('<col style="width:30.66%">')
207
+ parts.append('<col style="width:30.66%">')
208
+ parts.append('<col style="width:30.66%">')
209
+ parts.append('</colgroup>')
210
+
211
+ cell_style = 'style="vertical-align:top; padding:8px 10px; border:1px solid #ddd; font-size:13px"'
212
+ label_style = 'style="vertical-align:top; padding:8px 10px; border:1px solid #ddd; font-weight:600; background:#f6f7f9; white-space:nowrap"'
213
+ header_style = 'style="text-align:left; padding:8px 10px; border:1px solid #ddd; background:#eef0f4"'
214
+
215
+ parts.append("<thead><tr>")
216
+ parts.append(f'<th {header_style}></th>')
217
+ parts.append(f'<th {header_style}>{labels["a"]}</th>')
218
+ parts.append(f'<th {header_style}>{labels["b"]}</th>')
219
+ parts.append(f'<th {header_style}>{labels["c"]}</th>')
220
+ parts.append("</tr></thead><tbody>")
221
+
222
+ def row(label: str, a: str, b: str, c: str) -> None:
223
+ parts.append("<tr>")
224
+ parts.append(f'<td {label_style}>{label}</td>')
225
+ for v in (a, b, c):
226
+ parts.append(f'<td {cell_style}>{v}</td>')
227
+ parts.append("</tr>")
228
+
229
+ # Brief row
230
+ brief_cells = []
231
+ for col in ("a", "b", "c"):
232
+ b = briefs.get(col)
233
+ brief_cells.append(_cell_md(b) if b else "<em>running…</em>")
234
+ row("Brief", brief_cells[0], brief_cells[1], brief_cells[2])
235
+
236
+ for section in ordered:
237
+ title = "Why" if section == "__why__" else section.title()
238
+ cells = []
239
+ for col in ("a", "b", "c"):
240
+ p = parsed[col]
241
+ if p is None:
242
+ cells.append("<em>running…</em>")
243
+ continue
244
+ content = p.get(section, "")
245
+ cells.append(_cell_md(content) if content else "—")
246
+ row(title, cells[0], cells[1], cells[2])
247
+
248
+ if not ordered:
249
+ row("<em>no sections yet</em>", "", "", "")
250
+
251
+ parts.append("</tbody></table>")
252
+ return "".join(parts)
253
+
254
+
255
+ def normalize_chain_markdown(chain: str) -> str:
256
+ """Insert horizontal-rule dividers before every ## heading (except the
257
+ first) so each section reads as a discrete block regardless of which LLM
258
+ produced it. Strips any pre-existing trailing divider before inserting
259
+ ours so we don't double-stack when the model already emitted one."""
260
+ if not chain:
261
+ return chain
262
+ lines = chain.split("\n")
263
+ out: list[str] = []
264
+ seen_heading = False
265
+ for ln in lines:
266
+ if _HEADING_RE.match(ln):
267
+ if seen_heading:
268
+ # Strip trailing blanks AND any pre-existing `---` divider so
269
+ # we don't double up when the LLM already wrote one.
270
+ while out and (out[-1].strip() == "" or out[-1].strip() == "---"):
271
+ out.pop()
272
+ out.extend(["", "---", ""])
273
+ seen_heading = True
274
+ out.append(ln)
275
+ return "\n".join(out)
276
+
277
+
278
+ def _key_str(a: Analysis) -> str:
279
+ if a.key and a.key_mode:
280
+ return f"{a.key} {a.key_mode}"
281
+ return a.key or "unknown"
282
+
283
+
284
+ def _section_summary(a: Analysis) -> str:
285
+ if not a.sections:
286
+ return "single span"
287
+ parts = [f"{round(s['length'])}s {s['label']}" for s in a.sections]
288
+ return " / ".join(parts)
289
+
290
+
291
+ def sa3_variation_prompt(a: Analysis, brief_paragraph: str = "") -> str:
292
+ """Prompt aimed at SA3 (or similar text-to-audio gen) for *variations*
293
+ that should keep the measured BPM/key and overall feel."""
294
+ lines = [
295
+ "# SA3 variation prompt",
296
+ f"BPM: {a.bpm or '?'} (anchor)",
297
+ f"Key: {_key_str(a)}",
298
+ f"Sections: {_section_summary(a)}",
299
+ ]
300
+ if a.tags_genre:
301
+ lines.append(f"Genre: {', '.join(t['label'] for t in a.tags_genre[:3])}")
302
+ if a.tags_mood:
303
+ lines.append(f"Mood: {', '.join(t['label'] for t in a.tags_mood[:3])}")
304
+ if a.tags_instrument:
305
+ lines.append(f"Instruments: {', '.join(t['label'] for t in a.tags_instrument[:3])}")
306
+ if a.voiceover_present is not None:
307
+ lines.append(f"Voiceover present: {'yes' if a.voiceover_present else 'no'}")
308
+ if brief_paragraph:
309
+ lines.append("")
310
+ lines.append(brief_paragraph)
311
+ lines.append("")
312
+ lines.append("Generate a fresh variation that keeps the BPM and key, shifts arrangement, retains the mood profile above.")
313
+ return "\n".join(lines)
314
+
315
+
316
+ def sa3_match_style_prompt(a: Analysis, brief_paragraph: str = "") -> str:
317
+ """Prompt for a brand-new text-to-audio gen that should *sound like* this
318
+ reference but with arbitrary content."""
319
+ descriptors = []
320
+ if a.tags_mood:
321
+ descriptors += [t["label"] for t in a.tags_mood[:2]]
322
+ if a.tags_genre:
323
+ descriptors += [t["label"] for t in a.tags_genre[:2]]
324
+ if a.tags_instrument:
325
+ descriptors += [t["label"] for t in a.tags_instrument[:2]]
326
+ descriptor_str = ", ".join(descriptors) if descriptors else "(no tags — describe from brief)"
327
+
328
+ return (
329
+ f"{descriptor_str}\n"
330
+ f"BPM {a.bpm or '?'}, key {_key_str(a)}\n"
331
+ f"Arrangement arc: {_section_summary(a)}\n"
332
+ f"LUFS-I {a.lufs_i if a.lufs_i is not None else '?'}, peak {a.true_peak_db if a.true_peak_db is not None else '?'} dB\n"
333
+ f"\n{brief_paragraph}".rstrip()
334
+ )
335
+
336
+
337
+ def mix_chain_text(a: Analysis, llm_chain: str) -> str:
338
+ """Prepend a deterministic measurement header to the LLM-generated chain
339
+ so the numbers are never paraphrased — only the chain decisions are."""
340
+ bits = []
341
+ if a.bpm:
342
+ bits.append(f"{int(round(a.bpm))} BPM")
343
+ if a.key and a.key_mode:
344
+ bits.append(f"{a.key} {a.key_mode}")
345
+ if a.lufs_i is not None:
346
+ bits.append(f"LUFS-I {a.lufs_i}")
347
+ if a.lufs_lra is not None:
348
+ bits.append(f"LRA {a.lufs_lra}")
349
+ if a.true_peak_db is not None:
350
+ bits.append(f"true peak {a.true_peak_db} dB")
351
+ if a.voiceover_present is not None:
352
+ bits.append("vocals" if a.voiceover_present else "instrumental")
353
+
354
+ header = "# Mix chain — measured starting point\n\n"
355
+ header += f"**Reference:** {' · '.join(bits)}\n\n"
356
+ if a.stem_stats:
357
+ header += "**Stems:** "
358
+ header += ", ".join(
359
+ f"{name} (RMS {s['rms_db']} dB, centroid {int(s['centroid_hz'])} Hz)"
360
+ for name, s in a.stem_stats.items()
361
+ )
362
+ header += "\n\n"
363
+ header += "---\n\n"
364
+ return header + normalize_chain_markdown(
365
+ llm_chain or "_(chain unavailable — pollinations narrative call failed)_"
366
+ )
367
+
368
+
369
+ def ableton_clip_plan(a: Analysis) -> str:
370
+ """JSON describing tracks, plugin suggestions and clips ready for an MCP
371
+ sender (v2). v1 only emits the document — there's no sender yet."""
372
+ plan: dict[str, Any] = {
373
+ "schema": "audio-brief.clip-plan/v1",
374
+ "tempo": a.bpm,
375
+ "key": _key_str(a),
376
+ "tracks": [],
377
+ "scene_markers": [
378
+ {"time": s["start"], "name": s["label"]} for s in a.sections
379
+ ],
380
+ }
381
+
382
+ plugin_for = {
383
+ "drums": "Drum Rack (Impulse fallback)",
384
+ "bass": "Operator — sub-bass init",
385
+ "vocals": "Simpler — warp:complex",
386
+ "other": "Simpler — warp:complex",
387
+ }
388
+ for stem_name, wav_path in a.stems.items():
389
+ plan["tracks"].append({
390
+ "name": stem_name,
391
+ "audio_clip": wav_path,
392
+ "plugin": plugin_for.get(stem_name, "Simpler"),
393
+ })
394
+
395
+ if a.bass_midi_path:
396
+ plan["midi_tracks"] = [{
397
+ "name": "bass (midi)",
398
+ "midi_file": a.bass_midi_path,
399
+ "instrument": "Operator — sub-bass init",
400
+ "notes_preview_count": len(a.bass_notes),
401
+ }]
402
+
403
+ return json.dumps(plan, indent=2)
pipeline.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """audio-brief MVP v1 — single-process pipeline with subprocess islands.
2
+
3
+ Sequential by design: load model → run → del → gc → next stage. Each stage
4
+ catches its own errors and stores them in the result dict so one bad stage
5
+ never kills the brief.
6
+
7
+ Stage map (mirrors HANDOFF_AUDIO_BRIEF_MVP.md):
8
+ 1 decode / resample — soundfile + librosa
9
+ 2 BPM, key, beat grid — librosa with start_bpm prior
10
+ 3 sections + downbeats — madmom if installed, librosa fallback
11
+ 4 loudness (LUFS/peak/LRA) — pyloudnorm
12
+ 5 stems — demucs subprocess (htdemucs)
13
+ 6 bass-stem MIDI — basic-pitch subprocess
14
+ 7 tagging — essentia models if installed, else skipped
15
+ 8 similarity embedding — laion_clap if installed, else skipped
16
+ 9 narrative — pollinations (in narrative.py, called by app)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import gc
21
+ import json
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ import traceback
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import numpy as np
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # BPM priors keyed on coarse genre hints. Producers can override in the UI.
38
+ # ---------------------------------------------------------------------------
39
+ BPM_PRIORS: dict[str, float] = {
40
+ "dnb": 174.0, "drum-and-bass": 174.0, "jungle": 165.0,
41
+ "trap": 140.0, "hip-hop": 90.0, "boom-bap": 90.0,
42
+ "house": 124.0, "deep-house": 122.0,
43
+ "techno": 130.0, "minimal": 125.0,
44
+ "ambient": 80.0, "downtempo": 90.0,
45
+ "pop": 110.0, "rock": 120.0, "default": 120.0,
46
+ }
47
+
48
+ KEY_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
49
+
50
+
51
+ @dataclass
52
+ class StageError:
53
+ stage: str
54
+ error: str
55
+
56
+ def to_dict(self) -> dict[str, str]:
57
+ return {"stage": self.stage, "error": self.error}
58
+
59
+
60
+ @dataclass
61
+ class Analysis:
62
+ source_path: str
63
+ duration_s: float = 0.0
64
+ sample_rate: int = 44100
65
+ bpm: float | None = None
66
+ bpm_prior: float | None = None
67
+ key: str | None = None
68
+ key_mode: str | None = None
69
+ key_correlation: float | None = None
70
+ beats: list[float] = field(default_factory=list)
71
+ downbeats: list[float] = field(default_factory=list)
72
+ sections: list[dict[str, Any]] = field(default_factory=list)
73
+ lufs_i: float | None = None
74
+ lufs_lra: float | None = None
75
+ true_peak_db: float | None = None
76
+ stems: dict[str, str] = field(default_factory=dict) # name -> wav path
77
+ stem_stats: dict[str, dict[str, float]] = field(default_factory=dict) # name -> {rms_db, peak_db, centroid_hz}
78
+ bass_midi_path: str | None = None
79
+ bass_notes: list[dict[str, Any]] = field(default_factory=list)
80
+ voiceover_present: bool | None = None
81
+ tags_genre: list[dict[str, Any]] = field(default_factory=list)
82
+ tags_mood: list[dict[str, Any]] = field(default_factory=list)
83
+ tags_instrument: list[dict[str, Any]] = field(default_factory=list)
84
+ embedding_path: str | None = None
85
+ timings: dict[str, float] = field(default_factory=dict)
86
+ errors: list[dict[str, str]] = field(default_factory=list)
87
+ workdir: str | None = None
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ d = self.__dict__.copy()
91
+ return d
92
+
93
+ def top_genre(self) -> str | None:
94
+ return self.tags_genre[0]["label"] if self.tags_genre else None
95
+
96
+ def top_mood(self) -> str | None:
97
+ return self.tags_mood[0]["label"] if self.tags_mood else None
98
+
99
+ def top_instrument(self) -> str | None:
100
+ return self.tags_instrument[0]["label"] if self.tags_instrument else None
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Stage runner — central try/except so one stage failure can't kill the brief.
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def _run_stage(a: Analysis, name: str, fn, *args, **kw) -> None:
108
+ t0 = time.perf_counter()
109
+ try:
110
+ fn(a, *args, **kw)
111
+ except Exception as e: # noqa: BLE001 — analytic pipeline, log and continue
112
+ a.errors.append(StageError(name, f"{type(e).__name__}: {e}").to_dict())
113
+ sys.stderr.write(f"[audio-brief] stage {name} failed: {e}\n")
114
+ traceback.print_exc()
115
+ finally:
116
+ a.timings[name] = round(time.perf_counter() - t0, 2)
117
+ gc.collect()
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Stage 1 — decode + resample to mono 44.1 kHz.
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def _stage_decode(a: Analysis) -> tuple[np.ndarray, int]:
125
+ import librosa
126
+ y, sr = librosa.load(a.source_path, sr=44100, mono=True)
127
+ a.sample_rate = sr
128
+ a.duration_s = round(float(len(y)) / sr, 3)
129
+ return y, sr
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Stage 2 — BPM + key + beats (librosa).
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def _stage_bpm_key(a: Analysis, y: np.ndarray, sr: int, bpm_prior: float) -> None:
137
+ import librosa
138
+ a.bpm_prior = float(bpm_prior)
139
+
140
+ # Beat tracking with explicit start_bpm prior — librosa's default tracker
141
+ # locks onto half/triplet feels on dnb-tempo material without one.
142
+ tempo, beat_frames = librosa.beat.beat_track(
143
+ y=y, sr=sr, start_bpm=bpm_prior, tightness=100,
144
+ )
145
+ a.bpm = round(float(np.asarray(tempo).item()), 2)
146
+ a.beats = [round(float(t), 4) for t in librosa.frames_to_time(beat_frames, sr=sr)]
147
+
148
+ # Krumhansl-Schmuckler key detection over averaged chroma.
149
+ chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
150
+ chroma_mean = np.mean(chroma, axis=1)
151
+ major = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
152
+ minor = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
153
+
154
+ best_corr, best_key, best_mode = -1.0, "C", "major"
155
+ for i in range(12):
156
+ for prof, mode in ((major, "major"), (minor, "minor")):
157
+ rolled = np.roll(prof, i)
158
+ corr = float(np.corrcoef(chroma_mean, rolled)[0, 1])
159
+ if corr > best_corr:
160
+ best_corr, best_key, best_mode = corr, KEY_NAMES[i], mode
161
+ a.key = best_key
162
+ a.key_mode = best_mode
163
+ a.key_correlation = round(best_corr, 3)
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Stage 3 — sections + downbeats. Tries madmom; falls back to librosa.
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def _stage_sections(a: Analysis, y: np.ndarray, sr: int) -> None:
171
+ # Downbeats: madmom if available — but it has fussy deps on Apple Silicon
172
+ # and we don't want a hard failure here.
173
+ try:
174
+ from madmom.features.downbeats import RNNDownBeatProcessor, DBNDownBeatTrackingProcessor
175
+
176
+ act = RNNDownBeatProcessor()(a.source_path)
177
+ proc = DBNDownBeatTrackingProcessor(beats_per_bar=[3, 4], fps=100)
178
+ beats = proc(act)
179
+ a.downbeats = [round(float(t), 4) for (t, pos) in beats if int(pos) == 1]
180
+ except Exception:
181
+ # Fallback: take every 4th beat.
182
+ if a.beats:
183
+ a.downbeats = [round(float(t), 4) for t in a.beats[::4]]
184
+
185
+ # Sections: agglomerative clustering on chroma — coarse but reliable.
186
+ import librosa
187
+ bound_frames = librosa.segment.agglomerative(
188
+ librosa.feature.chroma_cqt(y=y, sr=sr), k=6,
189
+ )
190
+ bound_times = librosa.frames_to_time(bound_frames, sr=sr)
191
+ edges = sorted(set([0.0] + [round(float(t), 3) for t in bound_times] + [a.duration_s]))
192
+
193
+ a.sections = []
194
+ for i in range(len(edges) - 1):
195
+ start, end = edges[i], edges[i + 1]
196
+ if end - start < 1.5: # discard sub-bar fragments
197
+ continue
198
+ a.sections.append({
199
+ "start": round(start, 3),
200
+ "end": round(end, 3),
201
+ "length": round(end - start, 3),
202
+ "label": f"S{i + 1}",
203
+ })
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Stage 4 — loudness (LUFS-I, true peak, LRA).
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def _stage_loudness(a: Analysis, y: np.ndarray, sr: int) -> None:
211
+ import pyloudnorm as pyln
212
+ meter = pyln.Meter(sr)
213
+ a.lufs_i = round(float(meter.integrated_loudness(y)), 2)
214
+ try:
215
+ a.lufs_lra = round(float(meter.loudness_range(y)), 2)
216
+ except Exception:
217
+ a.lufs_lra = None
218
+ peak = float(np.max(np.abs(y))) if len(y) else 0.0
219
+ a.true_peak_db = round(20.0 * float(np.log10(peak)) if peak > 0 else -120.0, 2)
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Stage 5 — stem split via demucs subprocess.
224
+ # ---------------------------------------------------------------------------
225
+
226
+ def _stage_stems(a: Analysis) -> None:
227
+ if not a.workdir:
228
+ return
229
+ out_root = Path(a.workdir) / "stems"
230
+ out_root.mkdir(parents=True, exist_ok=True)
231
+
232
+ # demucs CLI writes to {out}/{model}/{track_name}/{stem}.wav
233
+ cmd = [
234
+ sys.executable, "-m", "demucs.separate",
235
+ "-n", "htdemucs",
236
+ "-o", str(out_root),
237
+ a.source_path,
238
+ ]
239
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
240
+ if proc.returncode != 0:
241
+ raise RuntimeError(f"demucs exit {proc.returncode}: {proc.stderr[-400:]}")
242
+
243
+ track_name = Path(a.source_path).stem
244
+ stem_dir = out_root / "htdemucs" / track_name
245
+ if not stem_dir.is_dir():
246
+ # demucs sometimes sanitises track names — pick the first subdir.
247
+ candidates = [p for p in (out_root / "htdemucs").iterdir() if p.is_dir()]
248
+ if candidates:
249
+ stem_dir = candidates[0]
250
+
251
+ for wav in sorted(stem_dir.glob("*.wav")):
252
+ a.stems[wav.stem] = str(wav)
253
+
254
+ # Per-stem stats — RMS, peak, spectral centroid. Cheap; gives the LLM
255
+ # something to ground per-stem chain decisions on.
256
+ import soundfile as sf
257
+ import librosa
258
+ for name, wav_path in a.stems.items():
259
+ try:
260
+ data, sr = sf.read(wav_path)
261
+ if data.ndim > 1:
262
+ data = data.mean(axis=1)
263
+ if not len(data):
264
+ continue
265
+ rms = float(np.sqrt(np.mean(np.square(data))))
266
+ peak = float(np.max(np.abs(data)))
267
+ centroid = float(np.mean(
268
+ librosa.feature.spectral_centroid(y=data.astype(np.float32), sr=sr)
269
+ )) if rms > 1e-5 else 0.0
270
+ a.stem_stats[name] = {
271
+ "rms_db": round(20.0 * float(np.log10(rms + 1e-12)), 2),
272
+ "peak_db": round(20.0 * float(np.log10(peak + 1e-12)), 2),
273
+ "centroid_hz": round(centroid, 1),
274
+ }
275
+ except Exception:
276
+ continue
277
+
278
+ # Voiceover-present heuristic — vocals RMS above a small threshold.
279
+ voc = a.stems.get("vocals")
280
+ if voc and a.stem_stats.get("vocals"):
281
+ a.voiceover_present = bool(a.stem_stats["vocals"]["rms_db"] > -40.0)
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Stage 6 — basic-pitch on bass stem.
286
+ # ---------------------------------------------------------------------------
287
+
288
+ def _stage_bass_midi(a: Analysis) -> None:
289
+ bass = a.stems.get("bass")
290
+ if not bass or not a.workdir:
291
+ return
292
+ out_dir = Path(a.workdir) / "midi"
293
+ out_dir.mkdir(parents=True, exist_ok=True)
294
+ # basic-pitch >= 0.5 needs an explicit backend + --save-midi. ONNX with
295
+ # CoreML acceleration is the fastest option on Apple Silicon.
296
+ cmd = [
297
+ sys.executable, "-m", "basic_pitch.predict",
298
+ "--save-midi",
299
+ "--model-serialization", "onnx",
300
+ str(out_dir),
301
+ bass,
302
+ ]
303
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
304
+ if proc.returncode != 0:
305
+ raise RuntimeError(f"basic-pitch exit {proc.returncode}: {proc.stderr[-400:]}")
306
+ midis = sorted(out_dir.glob("*.mid"))
307
+ if midis:
308
+ a.bass_midi_path = str(midis[0])
309
+ # Parse a coarse note list — used by the clip-plan output.
310
+ try:
311
+ import importlib
312
+ mido = importlib.import_module("mido")
313
+ mid = mido.MidiFile(a.bass_midi_path)
314
+ tempo = 500000 # default 120 BPM
315
+ ticks_per_beat = mid.ticks_per_beat or 480
316
+ notes: list[dict[str, Any]] = []
317
+ open_notes: dict[int, float] = {}
318
+ t_seconds = 0.0
319
+ for msg in mid:
320
+ t_seconds += msg.time
321
+ if msg.type == "set_tempo":
322
+ tempo = msg.tempo
323
+ elif msg.type == "note_on" and msg.velocity > 0:
324
+ open_notes[msg.note] = t_seconds
325
+ elif msg.type in ("note_off",) or (msg.type == "note_on" and msg.velocity == 0):
326
+ start = open_notes.pop(msg.note, None)
327
+ if start is not None:
328
+ notes.append({
329
+ "pitch": int(msg.note),
330
+ "start": round(start, 4),
331
+ "end": round(t_seconds, 4),
332
+ })
333
+ if len(notes) >= 256:
334
+ break
335
+ a.bass_notes = notes
336
+ _ = tempo, ticks_per_beat # silence unused-warning
337
+ except Exception:
338
+ pass
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Stage 7 — tagging (essentia). Optional — gracefully skipped if missing.
343
+ # ---------------------------------------------------------------------------
344
+
345
+ def _stage_tags(a: Analysis, y: np.ndarray, sr: int) -> None:
346
+ try:
347
+ import essentia.standard as es # noqa: F401
348
+ except Exception:
349
+ a.errors.append({"stage": "tags", "error": "essentia not installed — skipping"})
350
+ return
351
+ # The essentia model files are large downloads — we don't bundle them in
352
+ # v1. If the user wants tagging, they can drop the .pb models alongside
353
+ # this file and wire them up. For now, leave the tag lists empty so the
354
+ # narrative still gets useful structural data.
355
+ a.errors.append({"stage": "tags", "error": "essentia present but model files not configured — skipping"})
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Stage 8 — CLAP similarity embedding. Optional.
360
+ # ---------------------------------------------------------------------------
361
+
362
+ def _stage_embedding(a: Analysis, y: np.ndarray, sr: int) -> None:
363
+ try:
364
+ import laion_clap # noqa: F401
365
+ except Exception:
366
+ a.errors.append({"stage": "embedding", "error": "laion_clap not installed — skipping"})
367
+ return
368
+ try:
369
+ # Music-specific weights — LAION publishes a music+AudioSet checkpoint
370
+ # that beats the generic one on music tasks. Falls back to the default
371
+ # download if the env var isn't set.
372
+ from laion_clap import CLAP_Module
373
+ ckpt = os.environ.get("LAION_CLAP_MUSIC_CKPT", "").strip()
374
+ model = CLAP_Module(enable_fusion=False, amodel="HTSAT-base")
375
+ if ckpt:
376
+ model.load_ckpt(ckpt)
377
+ else:
378
+ model.load_ckpt() # downloads the default generic checkpoint
379
+ emb = model.get_audio_embedding_from_data(x=y[None, :], use_tensor=False)[0]
380
+ if a.workdir:
381
+ p = Path(a.workdir) / "embedding.npy"
382
+ np.save(p, emb)
383
+ a.embedding_path = str(p)
384
+ except Exception as e: # noqa: BLE001
385
+ a.errors.append({"stage": "embedding", "error": str(e)})
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Top-level orchestrator.
390
+ # ---------------------------------------------------------------------------
391
+
392
+ def analyze(
393
+ source_path: str,
394
+ *,
395
+ bpm_prior: float | str | None = None,
396
+ workdir: str | None = None,
397
+ run_stems: bool = True,
398
+ run_midi: bool = True,
399
+ run_tags: bool = False,
400
+ run_embedding: bool = False,
401
+ ) -> Analysis:
402
+ """Run the full pipeline. `bpm_prior` accepts a number or a genre slug
403
+ (see BPM_PRIORS)."""
404
+ if not os.path.isfile(source_path):
405
+ raise FileNotFoundError(source_path)
406
+
407
+ if workdir is None:
408
+ workdir = tempfile.mkdtemp(prefix="audio-brief-")
409
+ Path(workdir).mkdir(parents=True, exist_ok=True)
410
+
411
+ a = Analysis(source_path=source_path, workdir=workdir)
412
+
413
+ # Resolve BPM prior.
414
+ if isinstance(bpm_prior, str):
415
+ prior = BPM_PRIORS.get(bpm_prior.lower().strip(), BPM_PRIORS["default"])
416
+ elif isinstance(bpm_prior, (int, float)) and bpm_prior > 0:
417
+ prior = float(bpm_prior)
418
+ else:
419
+ prior = BPM_PRIORS["default"]
420
+
421
+ # Stage 1 — decode. If this fails everything else is moot.
422
+ try:
423
+ y, sr = _stage_decode(a)
424
+ except Exception as e:
425
+ a.errors.append({"stage": "decode", "error": f"{type(e).__name__}: {e}"})
426
+ return a
427
+
428
+ _run_stage(a, "bpm_key", _stage_bpm_key, y, sr, prior)
429
+ _run_stage(a, "sections", _stage_sections, y, sr)
430
+ _run_stage(a, "loudness", _stage_loudness, y, sr)
431
+
432
+ # Free decoded audio before launching heavy subprocesses.
433
+ del y
434
+ gc.collect()
435
+
436
+ if run_stems:
437
+ _run_stage(a, "stems", _stage_stems)
438
+ if run_midi:
439
+ _run_stage(a, "bass_midi", _stage_bass_midi)
440
+
441
+ # Re-decode for any stage that still needs the raw audio. Cheaper than
442
+ # holding it across the demucs subprocess.
443
+ if run_tags or run_embedding:
444
+ try:
445
+ import librosa
446
+ y2, sr2 = librosa.load(source_path, sr=44100, mono=True)
447
+ if run_tags:
448
+ _run_stage(a, "tags", _stage_tags, y2, sr2)
449
+ if run_embedding:
450
+ _run_stage(a, "embedding", _stage_embedding, y2, sr2)
451
+ del y2
452
+ gc.collect()
453
+ except Exception as e:
454
+ a.errors.append({"stage": "reload", "error": str(e)})
455
+
456
+ return a
457
+
458
+
459
+ def to_json(a: Analysis) -> str:
460
+ return json.dumps(a.to_dict(), indent=2, default=str)
requirements.txt ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # audio-brief — runtime dependencies.
2
+ # Targets: Python 3.11 on Hugging Face Spaces (Linux x86) AND Apple Silicon.
3
+ #
4
+ # Heavy ML stages (demucs, basic-pitch) run as subprocesses so peak RAM
5
+ # stays bounded and one crash doesn't take the UI down. Each pipeline
6
+ # stage has its own try/except — if a dep fails on the target platform
7
+ # the stage degrades to "skipped" rather than crashing the brief.
8
+
9
+ # UI — pinned to the version we test against locally. HF Spaces install
10
+ # is faster when sdk_version (in README front-matter) matches this pin.
11
+ gradio==6.19.0
12
+
13
+ # Decode / DSP
14
+ librosa>=0.10.1
15
+ soundfile>=0.12
16
+ numpy>=1.24
17
+ scipy>=1.10
18
+ matplotlib>=3.7
19
+
20
+ # Loudness
21
+ pyloudnorm>=0.1.1
22
+
23
+ # Stem split (subprocess — RAM-isolated)
24
+ demucs>=4.0
25
+
26
+ # Bass-stem MIDI (subprocess — RAM-isolated). basic-pitch>=0.5 made
27
+ # backends opt-in; the ONNX path is smallest. On Apple Silicon onnxruntime
28
+ # brings CoreMLExecutionProvider for ~free GPU; on HF Spaces Linux it
29
+ # stays CPU but still works.
30
+ basic-pitch[onnx]>=0.5
31
+ # torchaudio 2.11+ moved file IO to torchcodec; demucs uses torchaudio
32
+ # under the hood and breaks without it.
33
+ torchcodec>=0.14
34
+
35
+ # LLM narrative + SA3 gen (Pollinations text + audio APIs)
36
+ requests>=2.31
37
+
38
+ # Optional — graceful-degrade if missing. Uncomment to enable.
39
+ # essentia-tensorflow # tagging (stage 7) — Linux wheels only
40
+ # laion-clap # similarity embedding (stage 8)
41
+ # madmom # better section boundaries (stage 3) — librosa fallback is fine
sa3.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pollinations Stable Audio 3 — text-to-audio generation wrapper.
2
+
3
+ Replaces the local `~/sa3_mlx/sa3` binary path used by the earlier
4
+ `sa3_roundtrip_test.py`. Calling Pollinations means MVP2 doesn't require
5
+ the user to install the MLX SA3 weights locally — wallet is enough.
6
+
7
+ Endpoint shape (verified live 2026-06-23):
8
+ GET https://gen.pollinations.ai/audio/{url-encoded-prompt}
9
+ ?model=stable-audio-3-medium # or stable-audio-3-large / acestep / elevenmusic
10
+ &duration=N # seconds, 5..60
11
+ Authorization: Bearer sk_...
12
+ → audio/mpeg (MP3)
13
+
14
+ `elevenmusic` is catalogued with `input_modalities: [text, audio]` but the
15
+ public API only exposes the text-only endpoint as of probe date — audio
16
+ conditioning is not actually usable. Stick to SA3 models for now.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import time
22
+ import urllib.parse
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import requests
27
+
28
+ POLLINATIONS_AUDIO_URL = "https://gen.pollinations.ai/audio"
29
+
30
+ # Models verified live in /v1/models on 2026-06-23 with output_modalities=audio
31
+ # and supported_endpoints=['/audio/{text}'].
32
+ # `stable-audio-3-large` is in the catalog but returns HTTP 500 on probe AND
33
+ # we don't yet know its pollen cost (medium is 0.0376/call flat). Hidden
34
+ # until both are clarified — see TODO comments.
35
+ AVAILABLE_MODELS = [
36
+ "stable-audio-3-medium", # confirmed cost: 0.0376 pollen/call, 5-180s
37
+ # Hidden for now — focus is SA3. Re-enable here when ready to expose
38
+ # alternative gen models to users; UI dropdown follows this list.
39
+ # "stable-audio-3-large", # disabled: HTTP 500 on probe + cost unverified
40
+ # "acestep",
41
+ # "elevenmusic", # text-only despite catalog claim
42
+ ]
43
+ DEFAULT_MODEL = "stable-audio-3-medium"
44
+
45
+ # Variation / CFG knob removed — Pollinations' /audio/{text} OpenAPI does
46
+ # not expose cfg_scale, guidance, or temperature for stable-audio. The
47
+ # only knobs they surface are: seconds (1-380), steps (1-100 medium,
48
+ # 4-8 large), negative_prompt (large only), seed. If/when Pollinations
49
+ # adds a CFG/variation param, restore the chip presets — but until then,
50
+ # every gen uses Pollinations' internal defaults.
51
+ # Tracked: file a feature request on github.com/pollinations/pollinations
52
+ # proposing CFG exposure for SA3.
53
+
54
+ # Duration ranges, in seconds. The Generate tab toggles between these two
55
+ # via a radio. Per-call cost is flat across durations (verified on -medium),
56
+ # so longer just means longer wait, not more pollen — but we still split the
57
+ # slider so users don't accidentally bump from 30s → 180s by dragging.
58
+ SHORT_RANGE = (5, 60) # "short cue"
59
+ LONG_RANGE = (60, 180) # "full track" — 1-3 min
60
+ SHORT_DEFAULT = 15
61
+ LONG_DEFAULT = 90
62
+ ABSOLUTE_MAX_S = 180 # Pollinations cap (180s probed OK, beyond untested)
63
+
64
+
65
+ class SA3Error(RuntimeError):
66
+ """Raised when Pollinations SA3 gen fails."""
67
+
68
+
69
+ def _auth_token(api_key: str | None = None) -> str:
70
+ """Resolve auth via wallet.get_key — session arg wins, then env, then
71
+ on-disk wallet (None on HF Spaces)."""
72
+ try:
73
+ from wallet import get_key
74
+ return (get_key(session_key=api_key) or "").strip()
75
+ except Exception:
76
+ return ""
77
+
78
+
79
+ def generate(
80
+ prompt: str,
81
+ *,
82
+ model: str = DEFAULT_MODEL,
83
+ duration: int = 10,
84
+ out_path: str | Path,
85
+ timeout: float = 180.0,
86
+ api_key: str | None = None,
87
+ ) -> dict[str, Any]:
88
+ """Generate audio from a text prompt, write to out_path, return timing.
89
+
90
+ Raises SA3Error on HTTP failure or unexpected content type.
91
+ Returns: {"path": str, "bytes": int, "wall_s": float, "model": str}
92
+ """
93
+ if not prompt.strip():
94
+ raise SA3Error("empty prompt")
95
+ if duration < 5 or duration > ABSOLUTE_MAX_S:
96
+ raise SA3Error(f"duration must be 5..{ABSOLUTE_MAX_S}, got {duration}")
97
+
98
+ encoded = urllib.parse.quote(prompt)
99
+ # IMPORTANT: stable-audio reads `seconds`, not `duration`. The
100
+ # `duration` query param is documented as elevenmusic-only — we used
101
+ # to send it and Pollinations silently ignored it on SA3, so every
102
+ # clip came back at the service default (likely ~30s), regardless of
103
+ # what the UI asked for. Source: gen.pollinations.ai/openapi.json,
104
+ # path /audio/{text}.
105
+ url = (
106
+ f"{POLLINATIONS_AUDIO_URL}/{encoded}"
107
+ f"?model={model}&seconds={duration}"
108
+ )
109
+
110
+ headers = {}
111
+ token = _auth_token(api_key)
112
+ if token:
113
+ headers["Authorization"] = f"Bearer {token}"
114
+
115
+ t0 = time.perf_counter()
116
+ try:
117
+ r = requests.get(url, headers=headers, timeout=timeout)
118
+ except requests.exceptions.Timeout:
119
+ raise SA3Error(f"timeout after {timeout:.0f}s — model {model} unreachable")
120
+ except requests.exceptions.RequestException as e:
121
+ raise SA3Error(f"network · {type(e).__name__}: {e}")
122
+ wall = time.perf_counter() - t0
123
+
124
+ if r.status_code == 401:
125
+ raise SA3Error("HTTP 401 · connect a Pollinations wallet first")
126
+ if r.status_code == 402:
127
+ # Reuse narrative's 402 formatter so the cell reads identically.
128
+ from narrative import _format_402_error
129
+ raise SA3Error(_format_402_error(r))
130
+ if not r.ok:
131
+ snippet = r.text[:150]
132
+ raise SA3Error(f"HTTP {r.status_code} · {snippet}")
133
+
134
+ ct = r.headers.get("content-type", "")
135
+ if not ct.startswith("audio"):
136
+ raise SA3Error(f"unexpected content-type {ct!r} (expected audio/*)")
137
+
138
+ out_path = Path(out_path)
139
+ out_path.parent.mkdir(parents=True, exist_ok=True)
140
+ out_path.write_bytes(r.content)
141
+
142
+ return {
143
+ "path": str(out_path),
144
+ "bytes": len(r.content),
145
+ "wall_s": wall,
146
+ "model": model,
147
+ }
sa3_roundtrip_test.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SA3 round-trip test: generate source audio from a prompt, analyse it via
2
+ audio-brief, derive a re-gen prompt from the analysis, then generate 5
3
+ variants. Output: an HTML page with all 6 audio players side-by-side.
4
+
5
+ Usage:
6
+ cd audio-brief
7
+ ./.venv/bin/python sa3_roundtrip_test.py
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ SOURCE_PROMPT = (
18
+ "drum and bass, dark and atmospheric, 174 BPM, lo-fi instrumental jungle,"
19
+ " heavy bass, continuous loop, no intro no outro, full energy from bar one,"
20
+ " sustained throughout"
21
+ )
22
+ SECONDS = 30
23
+ OUT_DIR = Path("/tmp/sa3_roundtrip")
24
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+ REPO_ROOT = Path("/Users/kalam/ableton-v1")
27
+
28
+
29
+ def step(msg: str) -> None:
30
+ print(f"\n=== {msg} ===", flush=True)
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # 1) Generate source
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def gen_sa3(prompt: str, out_wav: Path, seconds: int = SECONDS) -> dict:
38
+ """Invoke SA3 via the engine helper. Returns timing dict."""
39
+ js = (
40
+ f"const {{ runSA3Local }} = require('{REPO_ROOT}/engine/audio-providers');"
41
+ f"runSA3Local({{ prompt: process.argv[1], seconds: {seconds},"
42
+ f" dit: 'sm-music', outPath: process.argv[2] }})"
43
+ f".then(r => {{ console.log(JSON.stringify(r)); process.exit(0); }})"
44
+ f".catch(e => {{ console.error(e.message || e); process.exit(1); }});"
45
+ )
46
+ t0 = time.perf_counter()
47
+ r = subprocess.run(
48
+ ["node", "-e", js, prompt, str(out_wav)],
49
+ cwd=str(REPO_ROOT),
50
+ capture_output=True, text=True, timeout=240,
51
+ )
52
+ dt = time.perf_counter() - t0
53
+ if r.returncode != 0:
54
+ raise RuntimeError(f"SA3 gen failed: {r.stderr.strip()}")
55
+ try:
56
+ import json
57
+ info = json.loads(r.stdout.strip().splitlines()[-1])
58
+ except Exception:
59
+ info = {}
60
+ info["wall_s"] = dt
61
+ return info
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # 2) Analyse via pipeline
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def analyse(audio_path: Path):
69
+ """Run the audio-brief pipeline. Skip embedding (expensive, not needed
70
+ for the suggestion). Keep tags + sections — they shape the SA3 prompt."""
71
+ from pipeline import analyze
72
+ return analyze(str(audio_path), bpm_prior="dnb", run_tags=True, run_embedding=False)
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # 3) Build SA3 re-gen prompt from analysis + LLM brief
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def build_regen_prompt(a, brief_text: str) -> str:
80
+ """Build a single-line SA3-compatible prompt from the Analysis.
81
+ SA3 expects a free-form description, not the markdown block that
82
+ sa3_variation_prompt() emits — so we synthesise a concise sentence."""
83
+ parts: list[str] = []
84
+
85
+ # Lead with genre / mood / instrument tags if present
86
+ descriptors: list[str] = []
87
+ if a.tags_genre:
88
+ descriptors += [t["label"] for t in a.tags_genre[:2]]
89
+ if a.tags_mood:
90
+ descriptors += [t["label"] for t in a.tags_mood[:2]]
91
+ if a.tags_instrument:
92
+ descriptors += [t["label"] for t in a.tags_instrument[:2]]
93
+ if descriptors:
94
+ parts.append(", ".join(descriptors))
95
+
96
+ if a.bpm:
97
+ parts.append(f"{int(round(a.bpm))} BPM")
98
+ if a.key and a.key_mode:
99
+ parts.append(f"key of {a.key} {a.key_mode}")
100
+
101
+ # Pull a couple of evocative adjectives from the LLM brief if it
102
+ # mentions ones the tag model didn't catch (e.g. "atmospheric", "lo-fi").
103
+ if brief_text:
104
+ import re
105
+ candidates = re.findall(
106
+ r"\b(dark|atmospheric|lo-?fi|hi-?fi|gritty|smooth|aggressive|"
107
+ r"warm|cold|driving|chilled|raw|polished|moody|euphoric|"
108
+ r"melancholy|hypnotic|punchy|tight|loose|sparse|dense|"
109
+ r"organic|synthetic|cinematic|filmic)\b", brief_text, re.IGNORECASE)
110
+ # Take unique, lowercase, up to 3
111
+ seen = []
112
+ for c in candidates:
113
+ cl = c.lower()
114
+ if cl not in seen and cl not in " ".join(parts).lower():
115
+ seen.append(cl)
116
+ if len(seen) >= 3: break
117
+ if seen:
118
+ parts.append(", ".join(seen))
119
+
120
+ # Loop hint matching original prompt's style
121
+ parts.append("continuous loop, no intro no outro, full energy throughout")
122
+
123
+ return ", ".join(parts)
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # 4) Generate variants
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def gen_variants(prompt: str, n: int = 5, name_prefix: str = "variant") -> list[Path]:
131
+ out_paths: list[Path] = []
132
+ for i in range(1, n + 1):
133
+ out = OUT_DIR / f"{name_prefix}_{i}.wav"
134
+ step(f"{name_prefix} {i}/{n}")
135
+ info = gen_sa3(prompt, out)
136
+ print(f" → {out.name} ({info.get('wall_s', 0):.1f}s wall, "
137
+ f"SA3 reported {info.get('ms', 0)}ms)")
138
+ out_paths.append(out)
139
+ return out_paths
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # 5) HTML comparison page
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def build_html(source: Path, short_variants: list[Path], long_variants: list[Path],
147
+ blended_variants: list[Path],
148
+ source_prompt: str, short_prompt: str, long_prompt: str,
149
+ blended_prompt: str,
150
+ analysis_summary: str, brief_text: str) -> Path:
151
+ import html as _html
152
+
153
+ def _rows(variants: list[Path], label: str) -> str:
154
+ return "".join(
155
+ f'<tr><td><b>{label} {i+1}</b></td>'
156
+ f'<td><audio src="{v.name}" controls preload="metadata" '
157
+ f'style="width:100%"></audio></td></tr>'
158
+ for i, v in enumerate(variants)
159
+ )
160
+ short_rows = _rows(short_variants, "Short")
161
+ long_rows = _rows(long_variants, "Long")
162
+ blended_rows = _rows(blended_variants, "Blend")
163
+ page = f"""<!doctype html>
164
+ <meta charset="utf-8">
165
+ <title>SA3 round-trip · short vs long prompt</title>
166
+ <style>
167
+ body {{ font-family: -apple-system, sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }}
168
+ h2 {{ margin-top: 1.5em; }}
169
+ table {{ width: 100%; border-collapse: collapse; margin-bottom: 1.5em; }}
170
+ td {{ padding: .5em; border-bottom: 1px solid #eee; vertical-align: middle; }}
171
+ pre {{ background: #f6f6f6; padding: 1em; border-radius: 6px; white-space: pre-wrap; overflow-x: auto; font-size: 13px; }}
172
+ .source td {{ background: #fff8e1; }}
173
+ .short td {{ background: #e8f5e9; }}
174
+ .long td {{ background: #e3f2fd; }}
175
+ .blend td {{ background: #fce4ec; }}
176
+ audio {{ height: 32px; }}
177
+ .grid {{ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1em; }}
178
+ .grid h3 {{ margin: 0 0 .5em 0; }}
179
+ </style>
180
+
181
+ <h1>SA3 round-trip · short vs long vs blended prompt</h1>
182
+ <p>Source generated from a prompt, analysed by audio-brief, then THREE re-gen prompts derived: a tight one-liner from the brief, a dense paragraph from the brief, and a <b>blended</b> prompt that merges the user's original intent vocabulary with the brief's measured arc. 5 variants per prompt. The blended row tests the workflow "I have an input prompt → batch gen → pick a favorite → analyse it → give me more like that one without losing my original vibe".</p>
183
+
184
+ <h2>Source (your prompt)</h2>
185
+ <pre>{_html.escape(source_prompt)}</pre>
186
+ <table><tr class="source"><td><b>Source</b></td>
187
+ <td><audio src="{source.name}" controls preload="metadata" style="width:100%"></audio></td></tr></table>
188
+
189
+ <h2>Audio-brief analysis</h2>
190
+ <pre>{_html.escape(analysis_summary)}</pre>
191
+
192
+ <h2>LLM brief (sentence-level summary)</h2>
193
+ <pre>{_html.escape(brief_text)}</pre>
194
+
195
+ <h2>Re-derived SA3 prompts</h2>
196
+ <div class="grid">
197
+ <div>
198
+ <h3>Short · brief-only (≤30 words)</h3>
199
+ <pre>{_html.escape(short_prompt)}</pre>
200
+ </div>
201
+ <div>
202
+ <h3>Long · brief-only (50-90 words)</h3>
203
+ <pre>{_html.escape(long_prompt)}</pre>
204
+ </div>
205
+ <div>
206
+ <h3>Blended · intent + arc (60-100 words)</h3>
207
+ <pre>{_html.escape(blended_prompt)}</pre>
208
+ </div>
209
+ </div>
210
+
211
+ <h2>Variants — Short prompt</h2>
212
+ <table class="short">{short_rows}</table>
213
+
214
+ <h2>Variants — Long prompt</h2>
215
+ <table class="long">{long_rows}</table>
216
+
217
+ <h2>Variants — Blended prompt (original intent + measured arc)</h2>
218
+ <table class="blend">{blended_rows}</table>
219
+ """
220
+ p = OUT_DIR / "index.html"
221
+ p.write_text(page)
222
+ return p
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # main
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def main() -> int:
230
+ src = OUT_DIR / "source.wav"
231
+
232
+ if src.exists() and os.environ.get("REUSE_SOURCE"):
233
+ step(f"1/5 · reusing existing source {src} ({src.stat().st_size:,} bytes)")
234
+ else:
235
+ step("1/5 · generating source (~30-60s)")
236
+ info = gen_sa3(SOURCE_PROMPT, src)
237
+ print(f" → {src} ({src.stat().st_size:,} bytes, {info.get('wall_s', 0):.1f}s wall)")
238
+
239
+ step("2/5 · analysing source")
240
+ a = analyse(src)
241
+ summary_lines = [
242
+ f"BPM : {a.bpm}",
243
+ f"Key : {a.key} {a.key_mode}",
244
+ f"Duration : {a.duration_s:.1f}s",
245
+ f"LUFS-I : {a.lufs_i}",
246
+ f"Sections : {len(a.sections or [])}",
247
+ f"Stems : {', '.join(sorted(a.stems.keys())) if a.stems else 'n/a'}",
248
+ f"Tags · genre : {[t['label'] for t in (a.tags_genre or [])[:5]]}",
249
+ f"Tags · mood : {[t['label'] for t in (a.tags_mood or [])[:5]]}",
250
+ f"Tags · instr. : {[t['label'] for t in (a.tags_instrument or [])[:5]]}",
251
+ f"Stage errors : {a.errors or 'none'}",
252
+ ]
253
+ analysis_summary = "\n".join(summary_lines)
254
+ print(analysis_summary)
255
+
256
+ step("3/5 · LLM brief")
257
+ from narrative import write_brief
258
+ # write_brief expects the brief-shaped dict, not the full JSON dump.
259
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
260
+ from app import _brief_payload
261
+ brief_text, brief_model = write_brief(_brief_payload(a), model="claude", no_cache=True)
262
+ print(f" model resolved → {brief_model}")
263
+ print(" " + brief_text.replace("\n", "\n "))
264
+
265
+ step("4/7 · deriving SHORT SA3 prompt (LLM one-liner)")
266
+ from narrative import write_sa3_prompt, write_sa3_prompt_long, write_sa3_prompt_blended
267
+ short_prompt, sa3_prompt_model = write_sa3_prompt(
268
+ _brief_payload(a), brief_text, model="claude", no_cache=True,
269
+ )
270
+ short_prompt = " ".join(short_prompt.splitlines()).strip()
271
+ print(f" model → {sa3_prompt_model}")
272
+ print(f" → {short_prompt}")
273
+ (OUT_DIR / "suggested_prompt_short.txt").write_text(short_prompt)
274
+
275
+ step("5/7 · deriving LONG SA3 prompt (dense paragraph from brief only)")
276
+ long_prompt, _ = write_sa3_prompt_long(
277
+ _brief_payload(a), brief_text, model="claude", no_cache=True,
278
+ )
279
+ long_prompt = " ".join(long_prompt.splitlines()).strip()
280
+ print(f" → {long_prompt}")
281
+ (OUT_DIR / "suggested_prompt_long.txt").write_text(long_prompt)
282
+
283
+ step("6/7 · deriving BLENDED SA3 prompt (original intent + measured arc)")
284
+ blended_prompt, _ = write_sa3_prompt_blended(
285
+ SOURCE_PROMPT, _brief_payload(a), brief_text,
286
+ model="claude", no_cache=True,
287
+ )
288
+ blended_prompt = " ".join(blended_prompt.splitlines()).strip()
289
+ print(f" → {blended_prompt}")
290
+ (OUT_DIR / "suggested_prompt_blended.txt").write_text(blended_prompt)
291
+
292
+ step("7/7 · generating 5+5+5 variants (~60s total)")
293
+ short_variants = gen_variants(short_prompt, n=5, name_prefix="short")
294
+ long_variants = gen_variants(long_prompt, n=5, name_prefix="long")
295
+ blended_variants = gen_variants(blended_prompt, n=5, name_prefix="blend")
296
+
297
+ # Tidy old `variant_*.wav` files from earlier-script runs so the page
298
+ # only shows the current three sets — avoids confusion.
299
+ for old in OUT_DIR.glob("variant_*.wav"):
300
+ old.unlink()
301
+
302
+ page = build_html(src, short_variants, long_variants, blended_variants,
303
+ SOURCE_PROMPT, short_prompt, long_prompt, blended_prompt,
304
+ analysis_summary, brief_text)
305
+ print(f"\nDone → open {page}")
306
+ # Try to open in default browser
307
+ subprocess.run(["open", str(page)], check=False)
308
+ return 0
309
+
310
+
311
+ if __name__ == "__main__":
312
+ sys.exit(main())
share.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Producer-facing exports — the screenshot artifact and the long report.
2
+
3
+ `render_scorecard_png(...)` — single PNG (1200×900) showing the wedge contrast.
4
+ For DM / Slack / Twitter sharing.
5
+
6
+ `render_full_report_md(...)` — multi-page markdown of every brief + chain.
7
+ For email or "read at your own pace" sharing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pipeline import Analysis
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Pull BPM/key/loudness estimates out of the audio-only model's brief.
21
+ # The audio-only system prompt asks it to mark estimates with 'est.', so we
22
+ # scan for "BPM: 118", "Key: G major", "Loudness: -9.5 LUFS" patterns and
23
+ # extract them — produces the comparison-vs-measured verdicts.
24
+ # ---------------------------------------------------------------------------
25
+
26
+ # Strict patterns — match the canonical form we ask the model to emit:
27
+ # "BPM: 118", "Key: D major", "Loudness: -11 LUFS"
28
+ _BPM_RE = re.compile(r"BPM[:\s]+(?:est\.?\s*)?(\d{2,3})", re.IGNORECASE)
29
+ _KEY_RE = re.compile(r"Key[:\s]+(?:est\.?\s*)?([A-G][#b♯♭]?\s*(?:major|minor|maj|min)?)", re.IGNORECASE)
30
+ _LUFS_RE = re.compile(r"Loudness[:\s]+(?:est\.?\s*)?(-?\d+(?:\.\d+)?)\s*(?:dB\s*)?LUFS", re.IGNORECASE)
31
+
32
+ # Loose fallback — models often write inline estimates like
33
+ # "(est. 118 BPM, est. D major, est. -11 dB LUFS)"
34
+ # inside the brief paragraph. Try strict first, then fall back so the
35
+ # scorecard's C column isn't empty whenever the model breaks form.
36
+ _BPM_FALLBACK_RE = re.compile(r"(?:est\.?\s*)?(\d{2,3})\s*BPM\b", re.IGNORECASE)
37
+ _KEY_FALLBACK_RE = re.compile(r"(?:est\.?\s*)?\b([A-G][#b♯♭]?)\s+(major|minor|maj|min)\b", re.IGNORECASE)
38
+ _LUFS_FALLBACK_RE = re.compile(r"(?:est\.?\s*)?(-?\d+(?:\.\d+)?)\s*(?:dB\s*)?LUFS\b", re.IGNORECASE)
39
+
40
+
41
+ def _extract_audio_only_estimates(brief_or_chain: str) -> dict[str, str | None]:
42
+ text = brief_or_chain or ""
43
+
44
+ bpm_m = _BPM_RE.search(text) or _BPM_FALLBACK_RE.search(text)
45
+ lufs_m = _LUFS_RE.search(text) or _LUFS_FALLBACK_RE.search(text)
46
+
47
+ key_m = _KEY_RE.search(text)
48
+ if key_m and key_m.group(1).strip():
49
+ key_str = key_m.group(1).strip()
50
+ else:
51
+ fb = _KEY_FALLBACK_RE.search(text)
52
+ key_str = (fb.group(1) + " " + fb.group(2)) if fb else None
53
+
54
+ return {
55
+ "bpm": bpm_m.group(1) if bpm_m else None,
56
+ "key": key_str,
57
+ "lufs": lufs_m.group(1) if lufs_m else None,
58
+ }
59
+
60
+
61
+ # Safe formatting helpers — the pipeline records stage failures in
62
+ # Analysis.errors and continues with None values for the failed stage's
63
+ # outputs. Scorecard rendering must not crash when bpm/key/lufs is None.
64
+ # (codex review P2.)
65
+
66
+
67
+ def _fmt_num(v: float | int | None, fmt: str, *, fallback: str = "unknown") -> str:
68
+ if v is None:
69
+ return fallback
70
+ try:
71
+ return format(v, fmt)
72
+ except (ValueError, TypeError):
73
+ return fallback
74
+
75
+
76
+ def _has(v: Any) -> bool:
77
+ return v is not None
78
+
79
+
80
+ def _short(s: str, n: int = 280) -> str:
81
+ s = (s or "").strip()
82
+ if len(s) <= n:
83
+ return s
84
+ return s[: n - 1].rstrip() + "…"
85
+
86
+
87
+ def _wrap_for_card(text: str, width_chars: int = 46, max_lines: int = 4) -> str:
88
+ """Hard-wrap text to fit a fixed scorecard box. matplotlib's `wrap=True`
89
+ wraps at figure edges, not at column boundaries, so 3-column text
90
+ overflows into the neighbour. Wrap manually with textwrap then clamp
91
+ to max_lines with ellipsis."""
92
+ import textwrap
93
+ text = (text or "").strip()
94
+ if not text:
95
+ return ""
96
+ wrapped = textwrap.fill(text, width=width_chars, break_long_words=False,
97
+ break_on_hyphens=False)
98
+ lines = wrapped.split("\n")
99
+ if len(lines) > max_lines:
100
+ lines = lines[:max_lines]
101
+ last = lines[-1]
102
+ if len(last) > width_chars - 1:
103
+ last = last[: width_chars - 1].rstrip()
104
+ lines[-1] = last.rstrip(".,;:") + "…"
105
+ return "\n".join(lines)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # PNG scorecard via matplotlib. Designed for 1200×900 — fits a phone screen,
110
+ # fits a Slack DM preview, fits a Twitter card.
111
+ # ---------------------------------------------------------------------------
112
+
113
+ def render_scorecard_png(
114
+ a: Analysis,
115
+ columns: list[dict[str, Any]],
116
+ out_path: str | None = None,
117
+ ) -> str:
118
+ """Render a 1200×900 PNG scorecard.
119
+
120
+ columns: list of three dicts, each with keys:
121
+ col — 'A · measured' | 'B · measured' | 'C · audio-only'
122
+ model — model id (e.g. 'claude')
123
+ brief — paragraph text
124
+ mode — 'measured' or 'audio-only'
125
+
126
+ Returns the output file path.
127
+ """
128
+ import matplotlib
129
+ matplotlib.use("Agg")
130
+ import matplotlib.pyplot as plt
131
+
132
+ if out_path is None:
133
+ out_path = str(Path(tempfile.gettempdir()) / "audio-brief-scorecard.png")
134
+
135
+ fig = plt.figure(figsize=(12, 9), dpi=110)
136
+ ax = fig.add_axes([0, 0, 1, 1])
137
+ ax.set_xlim(0, 100)
138
+ ax.set_ylim(0, 100)
139
+ ax.axis("off")
140
+ fig.patch.set_facecolor("#fafbfc")
141
+
142
+ # ── Title ────────────────────────────────────────────────────────────
143
+ ax.text(5, 94, "audio-brief — wedge comparison", fontsize=22, fontweight="bold", color="#1a1a1a")
144
+ duration = _fmt_num(a.duration_s, ".0f", fallback="?") + "s" if _has(a.duration_s) else "duration unknown"
145
+ voice_tag = (
146
+ " · vocals" if a.voiceover_present is True
147
+ else " · instrumental" if a.voiceover_present is False
148
+ else ""
149
+ )
150
+ ax.text(5, 90,
151
+ f"Source: {Path(a.source_path).name} · {duration}{voice_tag}",
152
+ fontsize=11, color="#666")
153
+
154
+ # ── Measured ground-truth strip — every field tolerates None so a
155
+ # half-failed pipeline (e.g. loudness stage threw) still renders.
156
+ measured_label = "MEASURED (pipeline ground truth)"
157
+ ax.text(5, 84, measured_label, fontsize=10, fontweight="bold", color="#0a8050",
158
+ family="monospace")
159
+ key_str = f"{a.key} {a.key_mode}" if (a.key and a.key_mode) else "key unknown"
160
+ measured_line = (
161
+ f" {_fmt_num(a.bpm, '.0f')} BPM · {key_str} · "
162
+ f"{_fmt_num(a.lufs_i, '.2f')} LUFS-I · LRA {_fmt_num(a.lufs_lra, '.2f')} · "
163
+ f"true peak {_fmt_num(a.true_peak_db, '.2f')} dB"
164
+ )
165
+ ax.text(5, 81, measured_line, fontsize=13, color="#0a8050", family="monospace")
166
+ ax.plot([5, 95], [78, 78], color="#dee", lw=0.8)
167
+
168
+ # ── Three-column model headers ───────────────────────────────────────
169
+ col_x = [22, 50, 78] # column centers
170
+ for i, c in enumerate(columns):
171
+ ax.text(col_x[i], 73, c["col"], ha="center", fontsize=11, fontweight="bold", color="#333")
172
+ ax.text(col_x[i], 70, c["model"], ha="center", fontsize=10, color="#888", family="monospace")
173
+ mode_color = "#0a8050" if c["mode"] == "measured" else "#c63b3b"
174
+ mode_text = "fed measurements" if c["mode"] == "measured" else "audio-only (no measurements)"
175
+ ax.text(col_x[i], 67, mode_text, ha="center", fontsize=9, color=mode_color, style="italic")
176
+
177
+ # ── Verdict table — BPM / Key / LUFS ─────────────────────────────────
178
+ row_labels = ["BPM", "Key", "LUFS-I"]
179
+ measured_vals = [
180
+ _fmt_num(a.bpm, ".0f"),
181
+ f"{a.key} {a.key_mode}" if (a.key and a.key_mode) else "unknown",
182
+ _fmt_num(a.lufs_i, ".2f"),
183
+ ]
184
+
185
+ # For each column, pull either the measured value (always exact for A/B
186
+ # because they were given the number) or the audio-only estimate for C.
187
+ audio_only_col = next((c for c in columns if c["mode"] == "audio-only"), None)
188
+ est = _extract_audio_only_estimates(
189
+ (audio_only_col or {}).get("brief", "") + "\n" + (audio_only_col or {}).get("chain", "")
190
+ ) if audio_only_col else {"bpm": None, "key": None, "lufs": None}
191
+
192
+ def cell_for(col_mode: str, row: str) -> tuple[str, str]:
193
+ """Return (value_str, status: 'ok'|'miss'|'unknown'). Δ is signed
194
+ (estimate − measured) so a negative value means the model was below
195
+ the truth — at-a-glance directionally useful for a producer."""
196
+ if col_mode == "measured":
197
+ # If the measured value itself failed in the pipeline, mark the
198
+ # cell unknown rather than claiming an exact match against None.
199
+ val = measured_vals[row_labels.index(row)]
200
+ return val, ("ok" if val != "unknown" else "unknown")
201
+ if row == "BPM":
202
+ v = est["bpm"]
203
+ if v is None or a.bpm is None:
204
+ return v if v is not None else "—", "unknown"
205
+ try:
206
+ delta = float(v) - float(a.bpm)
207
+ return f"{v} (Δ {delta:+.0f})", "ok" if abs(delta) <= 3 else "miss"
208
+ except Exception:
209
+ return v, "unknown"
210
+ if row == "Key":
211
+ v = est["key"]
212
+ if v is None or not (a.key and a.key_mode):
213
+ return v if v is not None else "—", "unknown"
214
+ measured_key = f"{a.key} {a.key_mode}".lower().strip()
215
+ return v, "ok" if measured_key in v.lower() else "miss"
216
+ if row == "LUFS-I":
217
+ v = est["lufs"]
218
+ if v is None or a.lufs_i is None:
219
+ return v if v is not None else "—", "unknown"
220
+ try:
221
+ delta = float(v) - float(a.lufs_i)
222
+ return f"{v} (Δ {delta:+.1f})", "ok" if abs(delta) <= 1.5 else "miss"
223
+ except Exception:
224
+ return v, "unknown"
225
+ return "—", "unknown"
226
+
227
+ y_top = 60
228
+ row_h = 7
229
+ ax.plot([5, 95], [y_top + 2, y_top + 2], color="#dee", lw=0.8)
230
+ for r, label in enumerate(row_labels):
231
+ y = y_top - r * row_h
232
+ ax.text(5, y, label, fontsize=12, fontweight="bold", color="#222")
233
+ for i, c in enumerate(columns):
234
+ val, status = cell_for(c["mode"], label)
235
+ marker = {"ok": "✓", "miss": "✗", "unknown": "—"}[status]
236
+ color = {"ok": "#0a8050", "miss": "#c63b3b", "unknown": "#888"}[status]
237
+ ax.text(col_x[i], y, f"{marker} {val}", ha="center", fontsize=12,
238
+ color=color, family="monospace")
239
+ ax.plot([5, 95], [y - row_h + 2, y - row_h + 2], color="#eee", lw=0.5)
240
+
241
+ # ── Brief excerpts ───────────────────────────────────────────────────
242
+ brief_y = y_top - len(row_labels) * row_h - 4
243
+ ax.text(5, brief_y, "BRIEF EXCERPT", fontsize=10, fontweight="bold", color="#666")
244
+ # Hard-wrap each brief so it fits its column. ~46 chars × 4 lines is
245
+ # tight enough that boxes don't overlap and tall enough to convey the
246
+ # paragraph's tone.
247
+ for i, c in enumerate(columns):
248
+ text = _wrap_for_card(c.get("brief", ""), width_chars=46, max_lines=4)
249
+ ax.text(col_x[i], brief_y - 3.5, text,
250
+ ha="center", va="top", fontsize=8.5, color="#222",
251
+ linespacing=1.35,
252
+ bbox=dict(boxstyle="round,pad=0.6", facecolor="#fff", edgecolor="#e5e7eb"))
253
+
254
+ # ── Wedge takeaway ───────────────────────────────────────────────────
255
+ misses: list[str] = []
256
+ for row in row_labels:
257
+ _, status = cell_for("audio-only", row)
258
+ if status == "miss":
259
+ misses.append(row)
260
+ if misses:
261
+ takeaway = (
262
+ f"Audio-only got {', '.join(misses)} wrong. Measurements anchor "
263
+ "correctness — the LLM sounds confident either way; only the "
264
+ "pipeline ensures it's right."
265
+ )
266
+ else:
267
+ takeaway = (
268
+ "Audio-only matched on the basics this time — but the chain it "
269
+ "generated still rests on its own perception of stems and instruments, "
270
+ "not the demucs split. Measurements + listening is the actual product."
271
+ )
272
+ # Move WEDGE lower-left into a clear strip below the brief boxes.
273
+ # Hard-wrap so it stays inside the card edges.
274
+ ax.text(5, 8, "WEDGE", fontsize=10, fontweight="bold", color="#c63b3b")
275
+ ax.text(5, 4, _wrap_for_card(takeaway, width_chars=120, max_lines=3),
276
+ fontsize=10, color="#222", linespacing=1.4, va="top")
277
+
278
+ fig.savefig(out_path, dpi=110, facecolor="#fafbfc", bbox_inches=None, pad_inches=0)
279
+ plt.close(fig)
280
+ return out_path
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Full markdown report. The user can save it, paste into Notion, convert to
285
+ # PDF in their browser print dialog, etc.
286
+ # ---------------------------------------------------------------------------
287
+
288
+ def render_full_report_md(
289
+ a: Analysis,
290
+ columns: list[dict[str, Any]],
291
+ out_path: str | None = None,
292
+ ) -> str:
293
+ """Write a single markdown file with the full comparison content.
294
+
295
+ Returns the file path.
296
+ """
297
+ if out_path is None:
298
+ out_path = str(Path(tempfile.gettempdir()) / "audio-brief-report.md")
299
+
300
+ lines: list[str] = []
301
+ lines.append(f"# audio-brief — full comparison report\n")
302
+ duration = f"{a.duration_s:.0f}s" if _has(a.duration_s) else "unknown"
303
+ lines.append(f"**Source:** `{Path(a.source_path).name}` · {duration}\n")
304
+ lines.append("\n## Measured ground truth\n")
305
+ lines.append(f"- **BPM:** {a.bpm if _has(a.bpm) else 'unknown'}")
306
+ key_line = (
307
+ f"{a.key} {a.key_mode} (Krumhansl corr {a.key_correlation})"
308
+ if (a.key and a.key_mode) else "unknown"
309
+ )
310
+ lines.append(f"- **Key:** {key_line}")
311
+ lines.append(f"- **Duration:** {duration}")
312
+ lines.append(f"- **LUFS-I:** {a.lufs_i if _has(a.lufs_i) else 'unknown'}")
313
+ lines.append(f"- **LRA:** {a.lufs_lra if _has(a.lufs_lra) else 'unknown'}")
314
+ lines.append(f"- **True peak:** {a.true_peak_db if _has(a.true_peak_db) else 'unknown'} dB")
315
+ voice = (
316
+ "yes" if a.voiceover_present is True
317
+ else "no" if a.voiceover_present is False
318
+ else "unknown"
319
+ )
320
+ lines.append(f"- **Voiceover present:** {voice}")
321
+ if a.stems:
322
+ lines.append(f"- **Stems:** {', '.join(sorted(a.stems))}")
323
+ if a.stem_stats:
324
+ lines.append("\n### Per-stem stats\n")
325
+ lines.append("| stem | RMS dB | peak dB | centroid Hz |")
326
+ lines.append("|---|---|---|---|")
327
+ for name, s in a.stem_stats.items():
328
+ lines.append(f"| {name} | {s.get('rms_db')} | {s.get('peak_db')} | {s.get('centroid_hz')} |")
329
+ if a.sections:
330
+ lines.append("\n### Sections\n")
331
+ lines.append("| label | start (s) | end (s) | length (s) |")
332
+ lines.append("|---|---|---|---|")
333
+ for s in a.sections:
334
+ lines.append(f"| {s['label']} | {s['start']} | {s['end']} | {s['length']} |")
335
+
336
+ for c in columns:
337
+ lines.append(f"\n---\n")
338
+ mode = "fed measurements" if c["mode"] == "measured" else "audio-only · no measurements"
339
+ lines.append(f"\n## {c['col']} — `{c['model']}` _({mode})_\n")
340
+ if c.get("elapsed_s") is not None:
341
+ lines.append(f"_elapsed: {c['elapsed_s']:.1f}s_\n")
342
+ lines.append("### Brief\n")
343
+ lines.append(c.get("brief", "_(no brief)_"))
344
+ lines.append("\n\n### Mix chain\n")
345
+ lines.append(c.get("chain", "_(no chain)_"))
346
+
347
+ Path(out_path).write_text("\n".join(lines))
348
+ return out_path
theme.py ADDED
@@ -0,0 +1,829 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """audio·brief design tokens — lifted from Audio Brief.dc.html (Crate Dock).
2
+
3
+ Three semantic colour families:
4
+ - coral #FF6A3D — generative (prompts, gens, variants, "create")
5
+ - mint #5BE0C8 — measured (BPM, key, sections, loudness, "observe")
6
+ - amber #FFC24B — anchor (your favourited take we blend from)
7
+
8
+ Fonts: Space Grotesk for body/heading, JetBrains Mono for numbers + labels.
9
+ Background system: near-black (#0F1115) → panel (#121419) → card (#14171D) →
10
+ input (#171A20). Borders step from #20242C (faint) to #2A2F38 (chip).
11
+
12
+ The theme exposes Gradio's tokens; the CSS adds layout primitives the
13
+ design needs that Gradio doesn't model (mono labels, metric tile grid,
14
+ brand mark, pollen pill). All custom classes prefixed `dc-`.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import gradio as gr
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Colour ramps — c500 anchors the brand colour; c50→c950 fade for hover/text
23
+ # ---------------------------------------------------------------------------
24
+
25
+ CORAL = gr.themes.Color(
26
+ c50="#FFE7DD",
27
+ c100="#FFD0BC",
28
+ c200="#FFB89A",
29
+ c300="#FFA279",
30
+ c400="#FF8C5A",
31
+ c500="#FF6A3D", # brand coral
32
+ c600="#E55731",
33
+ c700="#CC4424",
34
+ c800="#A23217",
35
+ c900="#78210B",
36
+ c950="#140B07", # dark text on coral button
37
+ )
38
+
39
+ MINT = gr.themes.Color(
40
+ c50="#E1FBF6",
41
+ c100="#C2F5EA",
42
+ c200="#A2EFDE",
43
+ c300="#83E9D3",
44
+ c400="#5BE0C8", # brand mint (light)
45
+ c500="#5BE0C8", # brand mint
46
+ c600="#42C7AE",
47
+ c700="#2BB89E",
48
+ c800="#1B8C76",
49
+ c900="#0F5949",
50
+ c950="#06201B",
51
+ )
52
+
53
+ # Warm-grey neutral — matches design's panel + text greys.
54
+ ZINC = gr.themes.Color(
55
+ c50="#F2EFE9", # primary text (cream)
56
+ c100="#E3E0D9",
57
+ c200="#C9CDD4", # secondary text
58
+ c300="#99A0AB", # muted text
59
+ c400="#7A828D",
60
+ c500="#5E6671", # faint label text
61
+ c600="#4A5158",
62
+ c700="#3A3F47",
63
+ c800="#2A2F38", # chip border
64
+ c900="#20242C", # divider border
65
+ c950="#0F1115", # page bg
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Theme object
71
+ # ---------------------------------------------------------------------------
72
+
73
+ THEME = gr.themes.Base(
74
+ primary_hue=CORAL,
75
+ secondary_hue=MINT,
76
+ neutral_hue=ZINC,
77
+ radius_size=gr.themes.sizes.radius_lg,
78
+ spacing_size=gr.themes.sizes.spacing_md,
79
+ text_size=gr.themes.sizes.text_md,
80
+ font=[gr.themes.GoogleFont("Space Grotesk"), "system-ui", "sans-serif"],
81
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
82
+ ).set(
83
+ # surfaces
84
+ body_background_fill="#0F1115",
85
+ body_text_color="#F2EFE9",
86
+ body_text_color_subdued="#99A0AB",
87
+ background_fill_primary="#0F1115",
88
+ background_fill_secondary="#121419",
89
+ block_background_fill="#14171D",
90
+ block_border_color="#232831",
91
+ block_border_width="1px",
92
+ block_radius="14px",
93
+ block_label_background_fill="transparent",
94
+ block_label_text_color="#99A0AB",
95
+ block_label_text_size="11px",
96
+ block_label_text_weight="600",
97
+ block_title_text_color="#F2EFE9",
98
+ block_title_text_weight="600",
99
+ panel_background_fill="#121419",
100
+ panel_border_color="#20242C",
101
+ border_color_primary="#232831",
102
+ border_color_accent="#FF6A3D",
103
+
104
+ # buttons
105
+ button_primary_background_fill="#FF6A3D",
106
+ button_primary_background_fill_hover="#FF8C5A",
107
+ button_primary_text_color="#140B07",
108
+ button_primary_border_color="#FF6A3D",
109
+ button_secondary_background_fill="#171A20",
110
+ button_secondary_background_fill_hover="#1B1F26",
111
+ button_secondary_text_color="#C9CDD4",
112
+ button_secondary_border_color="#2A2F38",
113
+ button_cancel_background_fill="#171A20",
114
+ button_cancel_text_color="#99A0AB",
115
+ button_cancel_border_color="#2A2F38",
116
+ button_large_radius="9px",
117
+ button_small_radius="7px",
118
+
119
+ # inputs
120
+ input_background_fill="#0F1115",
121
+ input_background_fill_focus="#121419",
122
+ input_border_color="#232831",
123
+ input_border_color_focus="#FF6A3D",
124
+ input_placeholder_color="#5E6671",
125
+ input_radius="9px",
126
+ slider_color="#FF6A3D",
127
+
128
+ # tables / dataframes
129
+ table_border_color="#232831",
130
+ table_even_background_fill="#14171D",
131
+ table_odd_background_fill="#121419",
132
+ table_row_focus="rgba(255,106,61,0.08)",
133
+ table_text_color="#C9CDD4",
134
+
135
+ # code / markdown
136
+ code_background_fill="#0F1115",
137
+ link_text_color="#5BE0C8",
138
+ link_text_color_hover="#FF8C5A",
139
+
140
+ # accent token used by various states
141
+ color_accent="#5BE0C8",
142
+ color_accent_soft="rgba(91,224,200,0.14)",
143
+ )
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # CSS — what Gradio's theme tokens don't cover.
148
+ # Adds: font import, scrollbar, brand mark, pollen pill, metric tiles,
149
+ # inline-coloured number helpers, label mono-uppercase.
150
+ # ---------------------------------------------------------------------------
151
+
152
+ CUSTOM_CSS = """
153
+ /* ---- Fonts (Gradio's font= already injects these, kept as belt-and-braces) */
154
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
155
+
156
+ body { font-family: 'Space Grotesk', system-ui, sans-serif !important; }
157
+ code, kbd, pre, .mono, .dc-mono { font-family: 'JetBrains Mono', ui-monospace, monospace !important; }
158
+
159
+ /* ---- Scrollbar — slim, neutral */
160
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
161
+ ::-webkit-scrollbar-thumb { background: #2A2F38; border-radius: 4px; }
162
+ ::-webkit-scrollbar-track { background: transparent; }
163
+
164
+ /* ---- Page container — dark base, cap content width so the layout stays
165
+ focused on wide monitors. Gradio v6 wraps content in `.wrap > main.contain`
166
+ (no .gradio-container class), so we target main.contain. 1200px feels
167
+ right for the composer + crate; waveform + grids fill this width.
168
+ Below 1200px viewport, the container naturally shrinks. */
169
+ main.contain, .wrap > main {
170
+ background: #0F1115 !important;
171
+ max-width: 1200px !important;
172
+ margin: 0 auto !important;
173
+ padding: 14px 18px !important;
174
+ box-sizing: border-box !important;
175
+ }
176
+ body { background: #0F1115 !important; }
177
+
178
+ /* Hide Gradio's footer ("Use via API" etc.) — clutter */
179
+ footer { display: none !important; }
180
+
181
+ /* ---- Action button width cap — primary buttons should size to content,
182
+ not stretch to the row width. ~360px is the design's big-CTA width.
183
+ Prevents the "Use for analysis" button from ballooning to 1900px on
184
+ a wide monitor when its sibling chip strip is full-width. */
185
+ .gr-button.lg, .gr-button-lg, button.lg, button.gr-button-large {
186
+ max-width: 360px !important;
187
+ margin-left: 0 !important;
188
+ }
189
+ /* Primary "Use for analysis →" in the crate row — cap its growth */
190
+ .dc-crate-row .gr-button-primary, .dc-crate-row button[class*="primary"] {
191
+ max-width: 320px !important;
192
+ flex: 0 0 auto !important;
193
+ }
194
+
195
+ /* ---- Headings — Space Grotesk weight/letter-spacing per design */
196
+ h1, h2, h3 {
197
+ font-family: 'Space Grotesk', sans-serif !important;
198
+ font-weight: 600;
199
+ letter-spacing: -0.01em;
200
+ color: #F2EFE9;
201
+ }
202
+ h1 { font-size: 22px; letter-spacing: -0.02em; }
203
+ h2 { font-size: 16px; }
204
+ h3 { font-size: 13px; color: #C9CDD4; }
205
+
206
+ /* ---- Brand wordmark — used in top bar HTML block */
207
+ .dc-brand {
208
+ display: inline-flex;
209
+ align-items: center;
210
+ gap: 9px;
211
+ font-family: 'Space Grotesk', sans-serif;
212
+ font-weight: 600;
213
+ font-size: 16px;
214
+ letter-spacing: -0.01em;
215
+ color: #F2EFE9;
216
+ }
217
+ .dc-brand .dot { color: #5BE0C8; margin: 0 1px; }
218
+ .dc-brand-bars {
219
+ display: inline-flex;
220
+ align-items: flex-end;
221
+ gap: 2px;
222
+ height: 18px;
223
+ }
224
+ .dc-brand-bars > span { width: 3px; border-radius: 1px; display: inline-block; }
225
+ .dc-brand-bars .b1 { height: 7px; background: #FF6A3D; }
226
+ .dc-brand-bars .b2 { height: 16px; background: #FF6A3D; }
227
+ .dc-brand-bars .b3 { height: 10px; background: #5BE0C8; }
228
+ .dc-brand-bars .b4 { height: 13px; background: #5BE0C8; }
229
+
230
+ /* ---- Pollen balance pill — doubles as wallet button */
231
+ .dc-pollen-pill {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: 7px;
235
+ background: #171A20;
236
+ border: 1px solid #2A2F38;
237
+ border-radius: 20px;
238
+ padding: 6px 13px;
239
+ cursor: pointer;
240
+ transition: border-color 120ms, background 120ms;
241
+ user-select: none;
242
+ }
243
+ .dc-pollen-pill:hover { border-color: #FF6A3D; }
244
+ .dc-pollen-pill .diamond { color: #FFC24B; font-size: 13px; line-height: 1; }
245
+ .dc-pollen-pill .balance {
246
+ font-family: 'JetBrains Mono', monospace;
247
+ font-weight: 600; font-size: 13px; color: #F2EFE9;
248
+ }
249
+ .dc-pollen-pill .lbl {
250
+ font-family: 'JetBrains Mono', monospace;
251
+ font-weight: 500; font-size: 10px; color: #5E6671;
252
+ }
253
+ /* Not-connected state — coral CTA tint to invite the click */
254
+ .dc-pollen-pill.not-connected {
255
+ background: rgba(255,106,61,0.08);
256
+ border-color: rgba(255,106,61,0.55);
257
+ }
258
+ .dc-pollen-pill.not-connected:hover {
259
+ background: rgba(255,106,61,0.14);
260
+ border-color: #FF6A3D;
261
+ }
262
+ .dc-pollen-pill.not-connected .diamond { color: #FF8C5A; }
263
+ .dc-pollen-pill.not-connected .balance { color: #FF8C5A; }
264
+ .dc-pollen-pill.not-connected .lbl { color: rgba(255,140,90,0.55); }
265
+
266
+ /* ---- Session spend pill — running total ◆ pollen this visit. Sits to the
267
+ left of the wallet pill. Muted by default (it's reference info, not
268
+ a CTA); only shown when there's actual spend > 0. */
269
+ .dc-session-pill {
270
+ display: inline-flex;
271
+ align-items: center;
272
+ gap: 6px;
273
+ padding: 4px 10px;
274
+ background: transparent;
275
+ border: 1px solid #20242C;
276
+ border-radius: 14px;
277
+ font-family: 'JetBrains Mono', monospace;
278
+ font-size: 11px;
279
+ color: #5E6671;
280
+ user-select: none;
281
+ }
282
+ .dc-session-pill .amt { font-weight: 600; color: #99A0AB; }
283
+ .dc-session-pill .diamond { color: rgba(255,194,75,0.65); font-size: 11px; }
284
+ .dc-session-pill .lbl { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; }
285
+
286
+ /* ---- Inline cost pip — small inline annotation next to the Generate /
287
+ Regenerate buttons. Reads "~0.04 ◆ per call · flat". */
288
+ .dc-cost-pip {
289
+ display: inline-flex;
290
+ align-items: center;
291
+ gap: 5px;
292
+ padding: 4px 10px;
293
+ background: #14171D;
294
+ border: 1px solid #20242C;
295
+ border-radius: 7px;
296
+ font-family: 'JetBrains Mono', monospace;
297
+ font-size: 11px;
298
+ color: #99A0AB;
299
+ margin: 8px 0;
300
+ width: fit-content;
301
+ }
302
+ .dc-cost-pip .amt { color: #C9CDD4; font-weight: 600; }
303
+ .dc-cost-pip .diamond { color: #FFC24B; }
304
+ .dc-cost-pip .sep { color: #3A3F47; }
305
+
306
+ /* ---- Variation HOLD chips — placeholder for the CFG control Pollinations
307
+ doesn't expose yet. Entire group disabled at 0.45 opacity so users
308
+ can see the shape coming, but can't click. Reactivate when Pollinations
309
+ adds a CFG/guidance param to /audio/{text}. */
310
+ .dc-var-hold {
311
+ opacity: 0.45;
312
+ pointer-events: none;
313
+ user-select: none;
314
+ margin: 10px 0;
315
+ }
316
+ .dc-var-hold .head {
317
+ display: flex; align-items: center; gap: 8px;
318
+ margin-bottom: 6px;
319
+ }
320
+ .dc-var-hold .head .label {
321
+ font-family: 'JetBrains Mono', monospace;
322
+ font-size: 11px;
323
+ font-weight: 600;
324
+ letter-spacing: 0.12em;
325
+ text-transform: uppercase;
326
+ color: #5E6671;
327
+ }
328
+ .dc-var-hold .head .tag {
329
+ font-family: 'JetBrains Mono', monospace;
330
+ font-size: 10px;
331
+ color: #5E6671;
332
+ padding: 2px 8px;
333
+ border: 1px solid #20242C;
334
+ border-radius: 6px;
335
+ background: #14171D;
336
+ }
337
+ .dc-var-hold .chips { display: flex; gap: 6px; }
338
+ .dc-var-hold .chip {
339
+ flex: 1;
340
+ text-align: center;
341
+ padding: 8px 10px;
342
+ background: #14171D;
343
+ border: 1px solid #20242C;
344
+ border-radius: 8px;
345
+ font-family: 'Space Grotesk', sans-serif;
346
+ font-weight: 600;
347
+ font-size: 12px;
348
+ color: #99A0AB;
349
+ }
350
+
351
+ /* Hidden trigger button — never visible but JS-clickable from the pill */
352
+ #wallet-disconnect-trigger { display: none !important; }
353
+
354
+ /* ---- Icon button — single-glyph buttons like ✕ delete, ⟳ refresh */
355
+ .dc-icon-btn button, button.dc-icon-btn {
356
+ background: #171A20 !important;
357
+ border: 1px solid #2A2F38 !important;
358
+ color: #99A0AB !important;
359
+ font-size: 16px !important;
360
+ padding: 0 !important;
361
+ min-width: 42px !important;
362
+ width: 42px !important;
363
+ height: 42px !important;
364
+ border-radius: 8px !important;
365
+ display: inline-flex !important;
366
+ align-items: center !important;
367
+ justify-content: center !important;
368
+ }
369
+ .dc-icon-btn button:hover, button.dc-icon-btn:hover {
370
+ color: #FF6A3D !important;
371
+ border-color: #FF6A3D !important;
372
+ }
373
+
374
+ /* ---- Crate row — primary + icon stay on one line under the chip strip */
375
+ .dc-crate-row { align-items: center !important; gap: 10px !important; margin-top: 6px !important; }
376
+
377
+ /* ---- Crate chip strip — Radio rendered as horizontal scrollable tiles.
378
+ This is what makes the collection visible at a glance instead of
379
+ hiding behind a dropdown. Each tile is a tap-sized chip; the row
380
+ scrolls horizontally on overflow (touch + wheel + scrollbar). */
381
+ .dc-crate-strip .wrap {
382
+ display: flex !important;
383
+ flex-direction: row !important;
384
+ flex-wrap: nowrap !important;
385
+ overflow-x: auto !important;
386
+ gap: 8px !important;
387
+ padding: 4px 2px 10px !important;
388
+ scrollbar-width: thin;
389
+ }
390
+ .dc-crate-strip .wrap label {
391
+ flex: none !important;
392
+ text-transform: none !important;
393
+ letter-spacing: 0 !important;
394
+ font-family: 'Space Grotesk', sans-serif !important;
395
+ font-weight: 600 !important;
396
+ font-size: 12px !important;
397
+ padding: 10px 13px !important;
398
+ min-width: 170px !important;
399
+ max-width: 220px !important;
400
+ background: #171A20 !important;
401
+ border: 1px solid #22272F !important;
402
+ border-radius: 10px !important;
403
+ color: #C9CDD4 !important;
404
+ cursor: pointer !important;
405
+ line-height: 1.35 !important;
406
+ white-space: normal !important;
407
+ overflow: hidden !important;
408
+ text-overflow: ellipsis !important;
409
+ display: -webkit-box !important;
410
+ -webkit-line-clamp: 2 !important;
411
+ -webkit-box-orient: vertical !important;
412
+ }
413
+ .dc-crate-strip .wrap label.selected {
414
+ background: rgba(255,106,61,0.08) !important;
415
+ border-color: rgba(255,106,61,0.55) !important;
416
+ color: #FF8C5A !important;
417
+ }
418
+ .dc-crate-strip .wrap label:hover {
419
+ border-color: #FF6A3D !important;
420
+ }
421
+
422
+ /* ---- Avatar circle (gradient coral→mint) */
423
+ .dc-avatar {
424
+ width: 30px; height: 30px; border-radius: 50%;
425
+ background: linear-gradient(135deg, #FF6A3D, #5BE0C8);
426
+ display: inline-block;
427
+ }
428
+
429
+ /* ---- Top bar layout — used by T1b */
430
+ .dc-topbar {
431
+ display: flex;
432
+ align-items: center;
433
+ justify-content: space-between;
434
+ height: 56px;
435
+ padding: 0 20px;
436
+ border-bottom: 1px solid #20242C;
437
+ background: #121419;
438
+ margin: -14px -18px 14px; /* extend to container edges */
439
+ border-radius: 0;
440
+ }
441
+
442
+ /* ---- Uppercase mono section label */
443
+ .dc-label {
444
+ font-family: 'JetBrains Mono', monospace;
445
+ font-size: 11px;
446
+ font-weight: 600;
447
+ letter-spacing: 0.12em;
448
+ text-transform: uppercase;
449
+ color: #5E6671;
450
+ }
451
+ .dc-label.coral { color: #FF8C5A; }
452
+ .dc-label.mint { color: #5BE0C8; }
453
+ .dc-label.amber { color: #FFC24B; }
454
+
455
+ /* ---- Metric tile grid — T1c */
456
+ .dc-metric-grid {
457
+ display: grid;
458
+ grid-template-columns: repeat(6, 1fr);
459
+ gap: 10px;
460
+ margin: 14px 0;
461
+ }
462
+ @media (max-width: 900px) {
463
+ .dc-metric-grid { grid-template-columns: repeat(3, 1fr); }
464
+ }
465
+ .dc-metric {
466
+ background: #121419;
467
+ border: 1px solid #232831;
468
+ border-radius: 10px;
469
+ padding: 12px 13px;
470
+ }
471
+ .dc-metric-k {
472
+ font-family: 'JetBrains Mono', monospace;
473
+ font-size: 10px;
474
+ font-weight: 500;
475
+ letter-spacing: 0.1em;
476
+ color: #5E6671;
477
+ text-transform: uppercase;
478
+ }
479
+ .dc-metric-v {
480
+ font-family: 'JetBrains Mono', monospace;
481
+ font-size: 21px;
482
+ font-weight: 600;
483
+ color: #F2EFE9;
484
+ margin-top: 8px;
485
+ line-height: 1.1;
486
+ }
487
+
488
+ /* ---- Inline-coloured numbers in brief paragraph — T1 design polish */
489
+ .dc-coral { color: #FF8C5A; }
490
+ .dc-mint { color: #5BE0C8; }
491
+ .dc-amber { color: #FFC24B; }
492
+ .dc-cream { color: #F2EFE9; font-weight: 600; }
493
+
494
+ /* ---- Coral-bordered derived prompt card — T1d */
495
+ .dc-derived-card {
496
+ background: #14171D;
497
+ border: 1px solid rgba(255,106,61,0.4);
498
+ border-radius: 14px;
499
+ padding: 18px;
500
+ margin-top: 14px;
501
+ }
502
+ .dc-derived-card .dc-prompt-box {
503
+ background: #0F1115;
504
+ border: 1px solid #232831;
505
+ border-radius: 10px;
506
+ padding: 13px;
507
+ font-size: 14px;
508
+ line-height: 1.55;
509
+ color: #F2EFE9;
510
+ margin-top: 12px;
511
+ }
512
+
513
+ /* ---- Chip toggles (modifier pills below derived prompt) — T1d */
514
+ .dc-chips {
515
+ display: flex;
516
+ flex-wrap: wrap;
517
+ gap: 7px;
518
+ margin-top: 13px;
519
+ }
520
+ .dc-chip {
521
+ font-family: 'JetBrains Mono', monospace;
522
+ font-size: 11px;
523
+ font-weight: 500;
524
+ padding: 5px 11px;
525
+ border-radius: 14px;
526
+ background: #171A20;
527
+ border: 1px solid #2A2F38;
528
+ color: #C9CDD4;
529
+ cursor: pointer;
530
+ user-select: none;
531
+ }
532
+ .dc-chip:hover { border-color: #FF6A3D; color: #FF8C5A; }
533
+ .dc-chip.active { background: rgba(255,106,61,0.14); color: #FF8C5A; border-color: rgba(255,106,61,0.55); }
534
+
535
+ /* ---- Variant card (5-grid below composer) — T1e */
536
+ .dc-variant-grid {
537
+ display: grid;
538
+ grid-template-columns: repeat(5, 1fr);
539
+ gap: 12px;
540
+ margin-top: 14px;
541
+ }
542
+ @media (max-width: 1100px) {
543
+ .dc-variant-grid { grid-template-columns: repeat(2, 1fr); }
544
+ }
545
+ .dc-variant {
546
+ background: #14171D;
547
+ border: 1px solid #232831;
548
+ border-radius: 12px;
549
+ padding: 13px;
550
+ }
551
+ .dc-variant.anchor {
552
+ background: rgba(255,194,75,0.07);
553
+ border-color: rgba(255,194,75,0.5);
554
+ }
555
+ .dc-variant .match {
556
+ font-family: 'JetBrains Mono', monospace;
557
+ font-size: 10px;
558
+ font-weight: 600;
559
+ }
560
+ .dc-variant .match.high { color: #5BE0C8; }
561
+ .dc-variant .match.mid { color: #FF8C5A; }
562
+ .dc-variant .match.low { color: #99A0AB; }
563
+
564
+ /* ---- Crate dock — T2a horizontal strip at bottom of work area */
565
+ .dc-crate-dock {
566
+ background: #121419;
567
+ border-top: 1px solid #20242C;
568
+ padding: 13px 18px;
569
+ margin: 14px -18px -14px; /* extend to container edges */
570
+ }
571
+ .dc-crate-strip {
572
+ display: flex;
573
+ gap: 11px;
574
+ overflow-x: auto;
575
+ padding-bottom: 4px;
576
+ }
577
+ .dc-tile {
578
+ flex: none;
579
+ width: 170px;
580
+ border-radius: 10px;
581
+ padding: 11px;
582
+ cursor: pointer;
583
+ border: 1px solid #22272F;
584
+ background: #171A20;
585
+ }
586
+ .dc-tile.selected {
587
+ border-color: rgba(255,106,61,0.55);
588
+ background: rgba(255,106,61,0.08);
589
+ }
590
+ .dc-tile.anchor {
591
+ border-color: rgba(255,194,75,0.5);
592
+ background: rgba(255,194,75,0.07);
593
+ }
594
+
595
+ /* ---- Tab pill row (Gradio v6 uses .tab-container .tab-wrapper) */
596
+ .tab-wrapper {
597
+ border-bottom: none !important;
598
+ margin-bottom: 14px !important;
599
+ }
600
+ .tab-container {
601
+ display: inline-flex !important;
602
+ background: #14171D !important;
603
+ border: 1px solid #20242C !important;
604
+ border-radius: 10px !important;
605
+ padding: 4px !important;
606
+ gap: 4px !important;
607
+ box-shadow: none !important;
608
+ }
609
+ .tab-container button {
610
+ background: transparent !important;
611
+ border: none !important;
612
+ border-bottom: none !important;
613
+ color: #99A0AB !important;
614
+ padding: 7px 16px !important;
615
+ border-radius: 7px !important;
616
+ font-family: 'Space Grotesk', sans-serif !important;
617
+ font-weight: 500 !important;
618
+ font-size: 13px !important;
619
+ box-shadow: none !important;
620
+ transition: background 120ms, color 120ms;
621
+ }
622
+ .tab-container button.selected {
623
+ background: rgba(255,106,61,0.14) !important;
624
+ color: #FF8C5A !important;
625
+ border-bottom: none !important;
626
+ outline: none !important;
627
+ }
628
+ /* Kill Gradio's mint underline on the selected tab (it's a ::after bar) */
629
+ .tab-container button::after,
630
+ .tab-container button.selected::after {
631
+ display: none !important;
632
+ content: none !important;
633
+ background: transparent !important;
634
+ }
635
+ .tab-container button:hover:not(.selected) {
636
+ color: #C9CDD4 !important;
637
+ }
638
+ /* Tab content panel — strip Gradio's default container chrome */
639
+ .tabitem {
640
+ border: none !important;
641
+ padding: 18px 0 !important;
642
+ background: transparent !important;
643
+ }
644
+
645
+ /* ---- Buttons — tighter padding, sharper shadows */
646
+ .gr-button {
647
+ font-family: 'Space Grotesk', sans-serif !important;
648
+ font-weight: 600 !important;
649
+ letter-spacing: 0 !important;
650
+ box-shadow: none !important;
651
+ transition: all 120ms ease !important;
652
+ }
653
+ .gr-button.gr-button-primary {
654
+ background: #FF6A3D !important;
655
+ color: #140B07 !important;
656
+ border: none !important;
657
+ }
658
+ .gr-button.gr-button-primary:hover {
659
+ background: #FF8C5A !important;
660
+ }
661
+ .gr-button.gr-button-secondary {
662
+ background: #171A20 !important;
663
+ border: 1px solid #2A2F38 !important;
664
+ color: #C9CDD4 !important;
665
+ }
666
+
667
+ /* ---- Field titles — Gradio v6 emits span.has-info inside the block */
668
+ span.has-info {
669
+ color: #99A0AB !important;
670
+ font-family: 'JetBrains Mono', monospace !important;
671
+ font-size: 11px !important;
672
+ font-weight: 600 !important;
673
+ letter-spacing: 0.08em !important;
674
+ text-transform: uppercase !important;
675
+ margin-bottom: 6px !important;
676
+ display: block !important;
677
+ }
678
+ /* Info subtitle below field title — quieter.
679
+ Targets both Gradio v6's `.info-text` (Slider/Number) and `.info` (other). */
680
+ .info, .info-text {
681
+ color: #5E6671 !important;
682
+ font-size: 11px !important;
683
+ font-family: 'Space Grotesk', sans-serif !important;
684
+ text-transform: none !important;
685
+ letter-spacing: 0 !important;
686
+ font-weight: 400 !important;
687
+ line-height: 1.45 !important;
688
+ }
689
+ /* Radio choice labels */
690
+ .wrap label {
691
+ background: #171A20 !important;
692
+ border: 1px solid #2A2F38 !important;
693
+ border-radius: 7px !important;
694
+ color: #99A0AB !important;
695
+ font-family: 'JetBrains Mono', monospace !important;
696
+ font-size: 11px !important;
697
+ font-weight: 500 !important;
698
+ letter-spacing: 0.04em !important;
699
+ text-transform: uppercase !important;
700
+ padding: 6px 12px !important;
701
+ }
702
+ .wrap label.selected {
703
+ background: rgba(255,106,61,0.14) !important;
704
+ border-color: rgba(255,106,61,0.55) !important;
705
+ color: #FF8C5A !important;
706
+ }
707
+
708
+ /* ---- Markdown / prose — readable on dark */
709
+ .prose {
710
+ max-width: none !important;
711
+ color: #C9CDD4;
712
+ }
713
+ .prose strong { color: #F2EFE9; }
714
+ .prose a { color: #5BE0C8; }
715
+ .prose code {
716
+ background: #0F1115;
717
+ border: 1px solid #232831;
718
+ border-radius: 4px;
719
+ padding: 1px 5px;
720
+ font-size: 12px;
721
+ }
722
+ """
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # Helper HTML snippets — reusable building blocks for the layout.
727
+ # Functions return strings; callers wrap in gr.HTML(value=...).
728
+ # ---------------------------------------------------------------------------
729
+
730
+ def brand_html() -> str:
731
+ """audio·brief wordmark + 4-bar icon."""
732
+ return (
733
+ '<div class="dc-brand">'
734
+ '<span class="dc-brand-bars">'
735
+ '<span class="b1"></span><span class="b2"></span>'
736
+ '<span class="b3"></span><span class="b4"></span>'
737
+ '</span>'
738
+ 'audio<span class="dot">·</span>brief'
739
+ '</div>'
740
+ )
741
+
742
+
743
+ def pollen_pill_html(balance: float | None = None, connected: bool = False) -> str:
744
+ """Top-right pollen balance pill — doubles as the wallet button.
745
+
746
+ - Not connected: pill is coral-tinted with text "connect", clicking
747
+ triggers the same-tab OAuth redirect to enter.pollinations.ai.
748
+ - Connected: pill shows the live balance; clicking it dispatches a
749
+ click to the hidden `#wallet-disconnect-trigger` button, which the
750
+ Gradio app wires to the disconnect_wallet handler.
751
+
752
+ `connected` lets a connected user see the pill even when balance is
753
+ unknown (Pollinations userinfo doesn't always return a numeric balance).
754
+ """
755
+ if not connected:
756
+ # Click → window.location swap to enter.pollinations.ai
757
+ on_connect = (
758
+ "var u='https://enter.pollinations.ai/authorize?redirect_url='"
759
+ "+encodeURIComponent(window.location.origin+window.location.pathname)"
760
+ "+'&expiry=30&permissions=usage';window.location.href=u;"
761
+ )
762
+ return (
763
+ f'<div class="dc-pollen-pill not-connected" onclick="{on_connect}" '
764
+ 'title="Connect your Pollinations wallet">'
765
+ '<span class="diamond">◇</span>'
766
+ '<span class="balance">connect</span>'
767
+ '<span class="lbl">POLLINATIONS</span>'
768
+ '</div>'
769
+ )
770
+ # Connected — click triggers the hidden disconnect Gradio button.
771
+ on_disconnect = (
772
+ "var b=document.querySelector('#wallet-disconnect-trigger button')"
773
+ "||document.querySelector('#wallet-disconnect-trigger');"
774
+ "if(b)b.click();"
775
+ )
776
+ balance_text = f"{balance:.2f}" if balance is not None else "•••"
777
+ return (
778
+ f'<div class="dc-pollen-pill connected" onclick="{on_disconnect}" '
779
+ 'title="Click to disconnect">'
780
+ '<span class="diamond">◆</span>'
781
+ f'<span class="balance">{balance_text}</span>'
782
+ '<span class="lbl">POLLEN</span>'
783
+ '</div>'
784
+ )
785
+
786
+
787
+ def session_spend_pill_html(spend: float, connected: bool) -> str:
788
+ """Tiny pill that lives left of the wallet pill: `0.84 ◆ session`.
789
+
790
+ Hidden when not connected (no signal to track), or when spend is 0
791
+ (the empty pill is noise — the wallet pill alone is enough)."""
792
+ if not connected or not spend or spend <= 0:
793
+ return ""
794
+ return (
795
+ '<div class="dc-session-pill" '
796
+ 'title="Pollen spent this session — resets on disconnect or reload">'
797
+ f'<span class="amt">{spend:.2f}</span>'
798
+ '<span class="diamond">◆</span>'
799
+ '<span class="lbl">session</span>'
800
+ '</div>'
801
+ )
802
+
803
+
804
+ def cost_pip_html(cost: float, suffix: str = "per call · flat") -> str:
805
+ """Inline cost pip — `~0.04 ◆ per call · flat`. Used next to Generate /
806
+ Regenerate buttons so users see the cost before they click."""
807
+ return (
808
+ '<div class="dc-cost-pip">'
809
+ f'<span>~</span><span class="amt">{cost:.2f}</span>'
810
+ '<span class="diamond">◆</span>'
811
+ f'<span class="sep">·</span><span>{suffix}</span>'
812
+ '</div>'
813
+ )
814
+
815
+
816
+ def metric_tile_html(label: str, value: str) -> str:
817
+ """One cell in the 6-up metric grid."""
818
+ return (
819
+ f'<div class="dc-metric">'
820
+ f'<div class="dc-metric-k">{label}</div>'
821
+ f'<div class="dc-metric-v">{value}</div>'
822
+ f'</div>'
823
+ )
824
+
825
+
826
+ def metric_grid_html(metrics: list[tuple[str, str]]) -> str:
827
+ """Render the full 6-metric grid given (label, value) pairs."""
828
+ cells = "".join(metric_tile_html(k, v) for k, v in metrics)
829
+ return f'<div class="dc-metric-grid">{cells}</div>'
wallet.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pollinations BYOP (Bring Your Own Pollen) wallet — device-flow login.
2
+
3
+ The user clicks "Connect Pollinations" in the UI; we request a device code,
4
+ open the verification URL in their browser, and poll until they approve.
5
+ The resulting `sk_...` user-authorized key persists to
6
+ `~/.config/abv1/pollinations.json` so future runs are silent.
7
+
8
+ API: https://enter.pollinations.ai — see BYOP section of the pollinations docs.
9
+
10
+ Key resolution order at narrative-call time:
11
+ 1. `POLLINATIONS_API_KEY` env var (explicit override wins)
12
+ 2. `POLLINATIONS_TOKEN` env var (legacy alias)
13
+ 3. ~/.config/abv1/pollinations.json (wallet)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import tempfile
20
+ import time
21
+ import webbrowser
22
+ from pathlib import Path
23
+ from typing import Any, Iterator
24
+
25
+ import requests
26
+
27
+ DEVICE_CODE_URL = "https://enter.pollinations.ai/api/device/code"
28
+ DEVICE_TOKEN_URL = "https://enter.pollinations.ai/api/device/token"
29
+ USERINFO_URL = "https://enter.pollinations.ai/api/device/userinfo"
30
+
31
+ WALLET_PATH = Path.home() / ".config" / "abv1" / "pollinations.json"
32
+
33
+ # HF Spaces detection — when running there, every visitor is a different
34
+ # user, so the wallet must NOT persist to disk (shared filesystem). The
35
+ # key lives in gr.State (per-session, in-process memory) and is passed
36
+ # explicitly through the call chain. Desktop path is unchanged.
37
+ IS_HF_SPACE = bool(os.environ.get("SPACE_ID"))
38
+
39
+ # Optional — set to the abv1 App Key (`pk_...`) at enter.pollinations.ai so
40
+ # usage is attributed to abv1. Without it the consent screen shows the
41
+ # redirect hostname instead and traffic isn't attributed.
42
+ DEFAULT_CLIENT_ID = os.environ.get("ABV1_POLLINATIONS_APP_KEY", "").strip() or None
43
+
44
+ DEFAULT_SCOPE = "generate account:usage"
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Persistence
49
+ # ---------------------------------------------------------------------------
50
+
51
+ def load_wallet() -> dict[str, Any]:
52
+ """Return the on-disk wallet dict, or {} if none. Always {} on HF Spaces."""
53
+ if IS_HF_SPACE:
54
+ return {}
55
+ try:
56
+ return json.loads(WALLET_PATH.read_text())
57
+ except Exception:
58
+ return {}
59
+
60
+
61
+ def save_wallet(data: dict[str, Any]) -> None:
62
+ """Atomically write the wallet file with 0o600 mode from the moment of
63
+ creation. The previous implementation called `write_text()` then chmod —
64
+ a short window existed where the file lived at umask-default mode (often
65
+ world-readable). Codex review P2.
66
+
67
+ On HF Spaces this is a no-op — the wallet must NOT persist to the shared
68
+ filesystem. The key lives in gr.State for the duration of the session
69
+ and is passed explicitly to every Pollinations-calling function."""
70
+ if IS_HF_SPACE:
71
+ return
72
+ parent = WALLET_PATH.parent
73
+ parent.mkdir(parents=True, exist_ok=True)
74
+ try:
75
+ parent.chmod(0o700)
76
+ except Exception:
77
+ pass
78
+
79
+ payload = json.dumps(data, indent=2).encode()
80
+ # mkstemp creates with mode 0o600 by default on POSIX; we set it
81
+ # explicitly via fchmod for portability against weird umasks.
82
+ fd, tmp_path = tempfile.mkstemp(prefix=".pollinations-", suffix=".tmp",
83
+ dir=str(parent))
84
+ try:
85
+ os.fchmod(fd, 0o600)
86
+ with os.fdopen(fd, "wb") as f:
87
+ f.write(payload)
88
+ os.replace(tmp_path, WALLET_PATH)
89
+ except Exception:
90
+ try:
91
+ os.unlink(tmp_path)
92
+ except FileNotFoundError:
93
+ pass
94
+ raise
95
+
96
+
97
+ def clear_wallet() -> None:
98
+ """No-op on HF Spaces (nothing was written). Unlink on desktop."""
99
+ if IS_HF_SPACE:
100
+ return
101
+ try:
102
+ WALLET_PATH.unlink()
103
+ except FileNotFoundError:
104
+ pass
105
+
106
+
107
+ def stored_key() -> str | None:
108
+ """Returns None on HF Spaces (no disk wallet). Reads file on desktop."""
109
+ if IS_HF_SPACE:
110
+ return None
111
+ return load_wallet().get("api_key")
112
+
113
+
114
+ def get_key(session_key: str | None = None) -> str | None:
115
+ """Resolve an api key for outbound Pollinations calls. Order:
116
+ 1. `session_key` passed in (HF Spaces — value lives in gr.State)
117
+ 2. POLLINATIONS_API_KEY env var (deploy override)
118
+ 3. POLLINATIONS_TOKEN env var (legacy alias)
119
+ 4. On disk wallet (desktop only — None on Spaces)
120
+ """
121
+ if session_key and session_key.strip():
122
+ return session_key.strip()
123
+ for env in ("POLLINATIONS_API_KEY", "POLLINATIONS_TOKEN"):
124
+ v = os.environ.get(env, "").strip()
125
+ if v:
126
+ return v
127
+ return stored_key()
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Device flow
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def request_device_code(
135
+ *,
136
+ client_id: str | None = None,
137
+ scope: str = DEFAULT_SCOPE,
138
+ timeout: float = 15.0,
139
+ ) -> dict[str, Any]:
140
+ """POST /api/device/code → {device_code, user_code, verification_uri, …}."""
141
+ body: dict[str, Any] = {"scope": scope}
142
+ cid = client_id or DEFAULT_CLIENT_ID
143
+ if cid:
144
+ body["client_id"] = cid
145
+ r = requests.post(DEVICE_CODE_URL, json=body, timeout=timeout)
146
+ r.raise_for_status()
147
+ return r.json()
148
+
149
+
150
+ def poll_token(device_code: str, *, timeout: float = 10.0) -> dict[str, Any]:
151
+ """One poll. Returns {"access_token": "sk_…"} on success, or
152
+ {"error": "authorization_pending" | "slow_down" | …} while waiting."""
153
+ r = requests.post(
154
+ DEVICE_TOKEN_URL,
155
+ json={"device_code": device_code},
156
+ timeout=timeout,
157
+ )
158
+ # `400 + authorization_pending` is the in-progress case — not a hard error.
159
+ try:
160
+ return r.json()
161
+ except Exception:
162
+ r.raise_for_status()
163
+ return {"error": "invalid_response"}
164
+
165
+
166
+ def userinfo(api_key: str, *, timeout: float = 10.0) -> dict[str, Any]:
167
+ r = requests.get(
168
+ USERINFO_URL,
169
+ headers={"Authorization": f"Bearer {api_key}"},
170
+ timeout=timeout,
171
+ )
172
+ r.raise_for_status()
173
+ return r.json()
174
+
175
+
176
+ def connect_iter(
177
+ *,
178
+ client_id: str | None = None,
179
+ scope: str = DEFAULT_SCOPE,
180
+ open_browser: bool = False, # codex P7: never auto-tab; UI shows link + code
181
+ max_wait_s: int = 600,
182
+ ) -> Iterator[dict[str, Any]]:
183
+ """Generator that drives the full device flow, yielding status events.
184
+
185
+ Yields dicts with one of these shapes:
186
+ {"stage": "code", "user_code", "verification_uri", "verification_uri_complete", "expires_in"}
187
+ {"stage": "polling", "elapsed_s"}
188
+ {"stage": "connected", "api_key", "user"}
189
+ {"stage": "error", "message"}
190
+ """
191
+ try:
192
+ code = request_device_code(client_id=client_id, scope=scope)
193
+ except Exception as e: # noqa: BLE001
194
+ yield {"stage": "error", "message": f"device/code request failed: {e}"}
195
+ return
196
+
197
+ device_code = code["device_code"]
198
+ user_code = code["user_code"]
199
+ verification_uri = code.get("verification_uri", "https://enter.pollinations.ai/device")
200
+ complete_uri = code.get("verification_uri_complete", verification_uri)
201
+ interval = max(1, int(code.get("interval", 5)))
202
+ expires_in = int(code.get("expires_in", 1800))
203
+
204
+ yield {
205
+ "stage": "code",
206
+ "user_code": user_code,
207
+ "verification_uri": verification_uri,
208
+ "verification_uri_complete": complete_uri,
209
+ "expires_in": expires_in,
210
+ }
211
+
212
+ if open_browser:
213
+ try:
214
+ webbrowser.open(complete_uri)
215
+ except Exception:
216
+ pass
217
+
218
+ t0 = time.time()
219
+ while True:
220
+ elapsed = int(time.time() - t0)
221
+ if elapsed > min(max_wait_s, expires_in):
222
+ yield {"stage": "error", "message": "timed out waiting for approval"}
223
+ return
224
+ try:
225
+ tok = poll_token(device_code)
226
+ except Exception as e: # noqa: BLE001
227
+ yield {"stage": "error", "message": f"poll failed: {e}"}
228
+ return
229
+
230
+ err = tok.get("error")
231
+ if err == "authorization_pending":
232
+ yield {"stage": "polling", "elapsed_s": elapsed}
233
+ time.sleep(interval)
234
+ continue
235
+ if err == "slow_down":
236
+ interval += 2
237
+ yield {"stage": "polling", "elapsed_s": elapsed}
238
+ time.sleep(interval)
239
+ continue
240
+ if err:
241
+ # Pollinations puts the error code in `error`; dump only that,
242
+ # not the whole token-endpoint body (codex P2: response bodies
243
+ # could leak adjacent secrets / future fields).
244
+ yield {"stage": "error", "message": f"auth declined: {err}"}
245
+ return
246
+
247
+ api_key = tok.get("access_token")
248
+ if not api_key:
249
+ # Same rule — surface a generic message, not the raw payload.
250
+ yield {"stage": "error", "message": "no access_token in token response"}
251
+ return
252
+
253
+ info: dict[str, Any] = {}
254
+ try:
255
+ info = userinfo(api_key)
256
+ except Exception:
257
+ pass
258
+
259
+ save_wallet({
260
+ "api_key": api_key,
261
+ "user": info,
262
+ "scope": tok.get("scope", scope),
263
+ "saved_at": int(time.time()),
264
+ })
265
+ # Codex P2: never expose the raw sk_… in the event stream — only
266
+ # masked metadata. Anyone watching events (logs, future UI changes)
267
+ # gets only what they need to confirm success.
268
+ yield {
269
+ "stage": "connected",
270
+ "key_masked": (api_key[:6] + "…" + api_key[-4:]) if len(api_key) > 12 else "•••",
271
+ "user": info,
272
+ }
273
+ return
274
+
275
+
276
+ def connect_blocking(**kw: Any) -> dict[str, Any]:
277
+ """Drain connect_iter and return the final event."""
278
+ last: dict[str, Any] = {"stage": "error", "message": "no events"}
279
+ for ev in connect_iter(**kw):
280
+ last = ev
281
+ return last
waveform.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Waveform PNG with section markers overlaid — used in Tab 1."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import tempfile
6
+
7
+ import numpy as np
8
+
9
+ from pipeline import Analysis
10
+
11
+
12
+ def render(a: Analysis) -> str | None:
13
+ """Write a waveform PNG with vertical section dividers. Returns path
14
+ or None if rendering can't run (no decoder, no matplotlib)."""
15
+ try:
16
+ import librosa
17
+ import matplotlib
18
+ matplotlib.use("Agg")
19
+ import matplotlib.pyplot as plt
20
+ except Exception:
21
+ return None
22
+
23
+ try:
24
+ y, sr = librosa.load(a.source_path, sr=22050, mono=True)
25
+ except Exception:
26
+ return None
27
+
28
+ fig, ax = plt.subplots(figsize=(10, 2.2), dpi=110)
29
+ t = np.linspace(0, len(y) / sr, num=len(y))
30
+ ax.plot(t, y, linewidth=0.4, color="#222")
31
+ ax.set_xlim(0, max(t[-1], 0.001))
32
+ ax.set_ylim(-1, 1)
33
+ ax.set_yticks([])
34
+ ax.set_xlabel("seconds")
35
+
36
+ for s in a.sections:
37
+ ax.axvline(s["start"], color="#d33", linewidth=0.8, alpha=0.6)
38
+ ax.text(s["start"] + 0.05, 0.85, s["label"], fontsize=7, color="#d33")
39
+
40
+ for db in a.downbeats[:64]:
41
+ ax.axvline(db, color="#88a", linewidth=0.3, alpha=0.35)
42
+
43
+ fig.tight_layout()
44
+ out = os.path.join(a.workdir or tempfile.gettempdir(), "waveform.png")
45
+ fig.savefig(out)
46
+ plt.close(fig)
47
+ return out