Spaces:
Sleeping
Sleeping
Commit ·
3cc5b15
0
Parent(s):
Initial deploy
Browse files- .gitignore +26 -0
- .hfignore +23 -0
- DEPLOY_HF.md +120 -0
- README.md +141 -0
- app.py +1428 -0
- crate.py +158 -0
- design/Audio Brief v1 (three directions).dc.html +590 -0
- design/Audio Brief v2.dc.html +550 -0
- design/README.md +199 -0
- design/support.js +1581 -0
- docs/local-gen-server-example.py +124 -0
- models.py +168 -0
- narrative.py +680 -0
- outputs.py +403 -0
- pipeline.py +460 -0
- requirements.txt +41 -0
- sa3.py +147 -0
- sa3_roundtrip_test.py +312 -0
- share.py +348 -0
- theme.py +829 -0
- wallet.py +281 -0
- 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&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 |
+
</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="{"showCost":{"editor":"boolean","default":true,"tsType":"boolean"},"showLineage":{"editor":"boolean","default":true,"tsType":"boolean"}}">
|
| 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 & 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 & 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="{"showCost":{"editor":"boolean","default":true,"tsType":"boolean"},"showSession":{"editor":"boolean","default":true,"tsType":"boolean"}}">
|
| 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 `>` — _cell_md runs html.escape() before us, so blockquote
|
| 130 |
+
# markers arrive as `>`. Without this, escaping would silently kill the
|
| 131 |
+
# blockquote rendering. (codex review patch set 4 · item 3.)
|
| 132 |
+
_BLOCKQUOTE = re.compile(r"^(?:>|>)\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
|