Spaces:
Sleeping
Sleeping
Add BarScript (experimental preview) model + ura difficulty; vendored sc2 subpackage
Browse files- README.md +82 -39
- SC2_INTEGRATION.md +373 -0
- app.py +410 -292
- packages.txt +0 -1
- scripts/fixtures/app_baseline.py +551 -0
- scripts/fixtures/legacy_call_trace.json +0 -0
- scripts/test_app_wiring.py +1054 -0
- scripts/test_sc2_vendor.py +504 -0
- scripts/test_space_e2e.py +658 -0
- softchart/baseline.py +43 -0
- softchart/data.py +590 -0
- softchart/evaluate.py +151 -0
- softchart/generate.py +163 -214
- softchart/grid.py +1 -3
- softchart/hf.py +52 -15
- softchart/model.py +30 -116
- softchart/preprocess.py +156 -0
- softchart/proxies.py +238 -0
- softchart/sc2/__init__.py +36 -0
- softchart/sc2/barscript.py +1153 -0
- softchart/sc2/checkpoint.py +337 -0
- softchart/sc2/generate.py +0 -0
- softchart/sc2/meter.py +125 -0
- softchart/sc2/model.py +615 -0
- softchart/sc2/tja.py +148 -0
- softchart/sc2/trace.py +519 -0
- softchart/sc2/vocab.py +301 -0
- softchart/sc2_loader.py +944 -0
- softchart/tja.py +84 -113
- softchart/train.py +433 -0
- softchart/vocab.py +4 -13
- static/app.css +54 -18
- static/app.js +128 -32
- static/index.html +44 -26
README.md
CHANGED
|
@@ -10,7 +10,6 @@ app_file: app.py
|
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
models:
|
| 13 |
-
- JacobLinCool/softchart-v18
|
| 14 |
- JacobLinCool/softchart-v17
|
| 15 |
- JacobLinCool/softchart-v17-small
|
| 16 |
- JacobLinCool/softchart-v17-tiny
|
|
@@ -19,45 +18,89 @@ models:
|
|
| 19 |
- JacobLinCool/softchart-v16-tiny
|
| 20 |
- JacobLinCool/softchart-v15
|
| 21 |
- JacobLinCool/softchart-planner
|
| 22 |
-
|
| 23 |
-
- JacobLinCool/taiko-1000-parsed-clean
|
| 24 |
---
|
| 25 |
|
| 26 |
# 🥁 SoftChart
|
| 27 |
|
| 28 |
-
Generate a **Taiko no Tatsujin
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
models:
|
|
|
|
| 13 |
- JacobLinCool/softchart-v17
|
| 14 |
- JacobLinCool/softchart-v17-small
|
| 15 |
- JacobLinCool/softchart-v17-tiny
|
|
|
|
| 18 |
- JacobLinCool/softchart-v16-tiny
|
| 19 |
- JacobLinCool/softchart-v15
|
| 20 |
- JacobLinCool/softchart-planner
|
| 21 |
+
- JacobLinCool/softchart-barscript
|
|
|
|
| 22 |
---
|
| 23 |
|
| 24 |
# 🥁 SoftChart
|
| 25 |
|
| 26 |
+
Generate a **Taiko no Tatsujin** chart from any song. A unified 7.9M-parameter
|
| 27 |
+
encoder–decoder model handles beat/downbeat fitting, slot-exact generation, and
|
| 28 |
+
gridless time generation; an optional planner supplies song-level density,
|
| 29 |
+
breathing gaps, and climax structure. All models are MIT-licensed and loaded
|
| 30 |
+
from the Hugging Face Hub via `from_pretrained`.
|
| 31 |
+
|
| 32 |
+
- **SoftChart v15** ([softchart-v15](https://huggingface.co/JacobLinCool/softchart-v15)) — unified slot + time + beat model; slot mode emits exact TJA lattice indices when the beat grid is trusted
|
| 33 |
+
- **Planner** ([softchart-planner](https://huggingface.co/JacobLinCool/softchart-planner)) — audio → song-level plan (density envelope, breathing gaps, climax)
|
| 34 |
+
|
| 35 |
+
## Difficulties
|
| 36 |
+
|
| 37 |
+
Easy, Normal, Hard, Oni and Ura. Each requests a density bucket measured on the
|
| 38 |
+
1 155-song corpus — median note rate 1.33 / 2.01 / 3.40 / 5.27 / 6.60 per second
|
| 39 |
+
— and Ura is written with the TJA header `COURSE:Edit`, the literal used by all
|
| 40 |
+
229 Ura charts in the corpus.
|
| 41 |
+
|
| 42 |
+
Two caveats apply to Ura on every model. It was trained on 208 charts against
|
| 43 |
+
roughly 1 100 per other course, so expect lower quality. And the song planner
|
| 44 |
+
was trained on the four base courses only, so for Ura it is asked for the Oni
|
| 45 |
+
plan instead; the interface says so before you generate, and the result reports
|
| 46 |
+
which plan it used. Turning the AI planner off avoids the substitution.
|
| 47 |
+
|
| 48 |
+
## BarScript (experimental preview)
|
| 49 |
+
|
| 50 |
+
The model menu also offers **`BarScript (preview)`**, the next-generation
|
| 51 |
+
model. It is published to the private repo
|
| 52 |
+
[`JacobLinCool/softchart-barscript`](https://huggingface.co/JacobLinCool/softchart-barscript)
|
| 53 |
+
and resolved in one order, most explicit first:
|
| 54 |
+
|
| 55 |
+
1. `$SC2_PACKAGE_DIR`, if set — an explicit request, and an error if that
|
| 56 |
+
directory holds no package;
|
| 57 |
+
2. a local copy at `spaces/models/sc2_c1_v13_s1234_best/`;
|
| 58 |
+
3. the Hub repo, downloaded on first use.
|
| 59 |
+
|
| 60 |
+
Because the repo is **private**, the download needs a token: set `HF_TOKEN`
|
| 61 |
+
(on a Space, *Settings → Variables and secrets*) or run `hf auth login`. With
|
| 62 |
+
no token the loader says so in those words — a missing credential and a missing
|
| 63 |
+
model have different fixes, and reporting the first as the second sends people
|
| 64 |
+
looking in the wrong place. `SC2_NO_HUB=1` pins the Space to on-disk packages.
|
| 65 |
+
Whichever source wins, the same gates run before a note is decoded: eight
|
| 66 |
+
vendored serving modules checked by md5 against the package's own record, the
|
| 67 |
+
token table cross-checked, and the Space's log-mel front end matched to the
|
| 68 |
+
package's contract. A Hub snapshot is additionally verified against the
|
| 69 |
+
`release_manifest.json` that travels with it.
|
| 70 |
+
|
| 71 |
+
BarScript is under evaluation and has known defects. The interface lists all of
|
| 72 |
+
them verbatim from the model card when the option is selected, including a
|
| 73 |
+
non-nested difficulty ladder, over-dense output, and a uniform `/16` deployment
|
| 74 |
+
lattice on which triplets are arithmetically impossible. It decodes onto a bar
|
| 75 |
+
lattice built from the fitted BPM and first downbeat, and refuses to generate
|
| 76 |
+
without one rather than anchoring the bars on an arbitrary phase. It takes no
|
| 77 |
+
song plan, so the Structure and AI planner switches do not reach it.
|
| 78 |
+
|
| 79 |
+
`python spaces/scripts/test_app_wiring.py` checks the routing, the resolution
|
| 80 |
+
order above, the five difficulties, the Ura TJA header, and — against a recorded
|
| 81 |
+
trace of the app as it stood before BarScript was wired in — that the seven
|
| 82 |
+
published models still behave identically, call for call and byte for byte.
|
| 83 |
+
|
| 84 |
+
The Space uses Gradio Server mode with a fully custom, responsive interface.
|
| 85 |
+
Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
|
| 86 |
+
full TJA chart image, a `.tja` playable in TJAPlayer3 / OpenTaiko, and a
|
| 87 |
+
synchronized WAV mix that lets you hear the generated chart performed over
|
| 88 |
+
the source music immediately. Its Don, Big Don, and Katsu voices are original
|
| 89 |
+
modal-resonator syntheses built from seeded excitation noise, decaying drum
|
| 90 |
+
modes, pitch sweeps, and band-limited stick transients. Reference recordings
|
| 91 |
+
are used only for offline envelope and level calibration; they are not copied,
|
| 92 |
+
mixed, embedded, or required by the Space.
|
| 93 |
+
|
| 94 |
+
The generation API streams the real processing stages to the interface:
|
| 95 |
+
audio analysis, beat-grid fitting, song-level planning, note generation,
|
| 96 |
+
chart rendering, and taiko preview mixing. The final audio is timed from the
|
| 97 |
+
exported TJA itself, including `OFFSET` and `#BPMCHANGE`, so the audition and
|
| 98 |
+
downloadable chart share the same timing.
|
| 99 |
+
|
| 100 |
+
The generated TJA keeps the uploaded audio filename in its `WAVE` field.
|
| 101 |
+
Planner climax blocks are emitted as TJA `#GOGOSTART` / `#GOGOEND` sections.
|
| 102 |
+
The chart image uses a server-side renderer modeled on the row layout used by
|
| 103 |
+
MIT-licensed [tja-tools](https://github.com/WHMHammer/tja-tools), without
|
| 104 |
+
shipping its browser/webpack runtime.
|
| 105 |
+
|
| 106 |
+
> Code and weights are **MIT-licensed**.
|
SC2_INTEGRATION.md
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BarScript in the Space — integration and verification
|
| 2 |
+
|
| 3 |
+
BarScript (run `sc2_c1_v13_s1234_best`, 9.1 M parameters) is selectable in the
|
| 4 |
+
Space as **`BarScript (preview)`**, alongside the seven published 1.x models,
|
| 5 |
+
which are unchanged. Ura is now a fifth difficulty on every model.
|
| 6 |
+
|
| 7 |
+
`BarScript` is the release name; `sc2_c1_v13_s1234` is the run that produced
|
| 8 |
+
this checkpoint, and it stays that way everywhere it is recorded — in the
|
| 9 |
+
package directory name, the manifest, and every md5 and sha in the provenance
|
| 10 |
+
chain. Renaming those would break the trail back to the training run.
|
| 11 |
+
|
| 12 |
+
BarScript is an **experimental preview**, published to the **private** repo
|
| 13 |
+
[`JacobLinCool/softchart-barscript`](https://huggingface.co/JacobLinCool/softchart-barscript).
|
| 14 |
+
It has measured defects that are shown in the interface and repeated in full
|
| 15 |
+
below. Read [Known limitations](#known-limitations-of-barscript) before drawing
|
| 16 |
+
any conclusion from a chart it produces.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Running it locally
|
| 21 |
+
|
| 22 |
+
### 1. Environment
|
| 23 |
+
|
| 24 |
+
Python 3.11, from `spaces/`:
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
python3.11 -m venv .venv && source .venv/bin/activate
|
| 28 |
+
pip install -r requirements.txt
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
On Apple Silicon the pinned `torch==2.13.0+cpu` has no wheel (that build is
|
| 32 |
+
published for Linux and Windows only). Install the platform build instead and
|
| 33 |
+
take the rest of the file as-is:
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
pip install torch==2.13.0
|
| 37 |
+
pip install "numpy<2.1" scipy "librosa>=0.10" "numba>=0.60" soundfile \
|
| 38 |
+
matplotlib pillow gradio==6.20.0 gradio_client==2.5.0 \
|
| 39 |
+
"pydantic>=2.10,<3" "huggingface_hub>=1.2,<2.0" safetensors
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Everything below was verified on this stack (torch 2.13.0, gradio 6.20.0,
|
| 43 |
+
numpy 2.0.2, librosa 0.11.0), CPU only.
|
| 44 |
+
|
| 45 |
+
### 2. Point the Space at the BarScript package
|
| 46 |
+
|
| 47 |
+
`sc2_loader.resolve_package_dir()` looks in one order, most explicit first, and
|
| 48 |
+
reports which leg won as `describe()["package_source"]`:
|
| 49 |
+
|
| 50 |
+
| # | source | `package_source` | when |
|
| 51 |
+
|---|---|---|---|
|
| 52 |
+
| 1 | `$SC2_PACKAGE_DIR` | `env` | whenever it is set |
|
| 53 |
+
| 2 | `spaces/models/sc2_c1_v13_s1234_best/`, then the build artifact in the development worktree | `local` | a package is on disk |
|
| 54 |
+
| 3 | the Hub repo, via `snapshot_download` | `hub` | neither of the above |
|
| 55 |
+
|
| 56 |
+
`$SC2_PACKAGE_DIR` that holds no package **raises** rather than falling through:
|
| 57 |
+
naming a directory is a request for that directory, and quietly downloading a
|
| 58 |
+
different copy instead would hide the typo.
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
export SC2_PACKAGE_DIR=/path/to/sc2_c1_v13_s1234_best # optional
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Running from this checkout, leg 2 finds the build artifact at
|
| 65 |
+
`.codex/worktrees/v18-8h/artifacts/sc2/sc2_c1_v13_s1234_best/` and nothing is
|
| 66 |
+
downloaded.
|
| 67 |
+
|
| 68 |
+
**The Hub repo is private, so leg 3 needs a token.** Set `HF_TOKEN`, or run
|
| 69 |
+
`hf auth login`; the loader also accepts `$HUGGING_FACE_HUB_TOKEN` and the
|
| 70 |
+
token cache. With no token it fails with a message naming the credential and
|
| 71 |
+
the three ways to supply one, rather than reporting a missing model — the two
|
| 72 |
+
have different fixes. `SC2_NO_HUB=1` disables leg 3 entirely, which is how a
|
| 73 |
+
deployment proves it is not silently depending on a download.
|
| 74 |
+
|
| 75 |
+
Only the seven files of the release package are fetched (`PACKAGE_FILES` is
|
| 76 |
+
passed to `snapshot_download` as an allow-list), and the snapshot is checked
|
| 77 |
+
against the `release_manifest.json` that travels with it before it is served —
|
| 78 |
+
those bytes crossed a network, and the manifest records the sha256 of each file
|
| 79 |
+
as built. Local packages are not re-hashed; that check is for the transport.
|
| 80 |
+
|
| 81 |
+
Whichever leg wins, the package directory is the whole source of truth from
|
| 82 |
+
that point on: the eight vendored serving modules are checked by md5 against
|
| 83 |
+
`config.json:code.module_md5`, the token table is cross-checked, the schema is
|
| 84 |
+
validated, and the Space's log-mel front end is matched to the package's
|
| 85 |
+
contract. A downloaded package and a local one are served identically or not
|
| 86 |
+
at all.
|
| 87 |
+
|
| 88 |
+
If no leg produces a package, the BarScript entry does not appear in the model
|
| 89 |
+
menu and the seven published models work as usual. Nothing fails.
|
| 90 |
+
|
| 91 |
+
### 3. Start the Space
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
cd spaces
|
| 95 |
+
python app.py # http://localhost:7860
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Check it came up, and that BarScript is being offered:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
curl -s localhost:7860/health
|
| 102 |
+
# {"status":"ok","device":"cpu","models_loaded":false}
|
| 103 |
+
|
| 104 |
+
curl -s localhost:7860/api/capabilities | python -m json.tool
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
`/api/capabilities` is the source of the model menu, the experimental
|
| 108 |
+
labelling, and the limitation text. The BarScript entry carries
|
| 109 |
+
`"experimental": true` and all eight model-card limitations; the frontend
|
| 110 |
+
renders them verbatim and cannot paraphrase them.
|
| 111 |
+
|
| 112 |
+
The published models are downloaded from the Hub on first use. With them
|
| 113 |
+
already in the local cache you can run fully offline:
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
HF_HUB_OFFLINE=1 python app.py
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
### 4. Generate a chart
|
| 120 |
+
|
| 121 |
+
In the browser: upload audio, pick **BarScript (preview)**, pick a difficulty
|
| 122 |
+
(including **Ura**), generate. Two controls behave differently on BarScript:
|
| 123 |
+
|
| 124 |
+
- **Beat grid must stay on** (or enter the BPM in Advanced settings). BarScript
|
| 125 |
+
builds its bars from a downbeat and cannot place them without one; the route
|
| 126 |
+
fails with an explanatory message rather than guessing a phase.
|
| 127 |
+
- **Structure / AI planner switches are ignored.** BarScript takes no plan
|
| 128 |
+
token, so the result reports `Not used — BarScript takes no plan` instead of
|
| 129 |
+
a plan source.
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## Tests
|
| 134 |
+
|
| 135 |
+
Three suites, all CPU, no network required (the third reads published models
|
| 136 |
+
from the local HuggingFace cache; the Hub leg of the second is mocked).
|
| 137 |
+
|
| 138 |
+
```bash
|
| 139 |
+
cd spaces
|
| 140 |
+
python scripts/test_sc2_vendor.py # vendored BarScript tree matches the package
|
| 141 |
+
python scripts/test_app_wiring.py # routing, resolution order, ura, limitations
|
| 142 |
+
HF_HUB_OFFLINE=1 python scripts/test_space_e2e.py # real weights, real music
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
Current status: **all green** — 7 vendor checks, 117 wiring checks, 380
|
| 146 |
+
end-to-end checks.
|
| 147 |
+
|
| 148 |
+
15 of the wiring checks cover package resolution: that `$SC2_PACKAGE_DIR`
|
| 149 |
+
outranks a local copy and the Hub, that an unsatisfiable `$SC2_PACKAGE_DIR`
|
| 150 |
+
raises instead of falling through, that a local copy outranks the Hub, that the
|
| 151 |
+
Hub is reached only when neither is present, that `SC2_NO_HUB` disables it, that
|
| 152 |
+
a missing token is reported as a credentials problem before any request is made,
|
| 153 |
+
that the request carries the resolved token and an allow-list of exactly the
|
| 154 |
+
package files, and that a downloaded package is refused when a vendored module
|
| 155 |
+
md5, the schema, the vocabulary or the manifest disagrees. The Hub is mocked
|
| 156 |
+
throughout — the subject is the precedence rule, and a suite that downloaded
|
| 157 |
+
18 MB to assert one would be testing the network instead. The real download is
|
| 158 |
+
exercised separately.
|
| 159 |
+
|
| 160 |
+
`test_space_e2e.py` is the only one that decodes real music. It drives
|
| 161 |
+
`app.generate_chart` itself — the same generator the frontend consumes — and
|
| 162 |
+
inspects the artifacts it produced. Options:
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
python scripts/test_space_e2e.py --songs 2 --seconds 30 # quicker
|
| 166 |
+
python scripts/test_space_e2e.py --skip-legacy # BarScript only
|
| 167 |
+
python scripts/test_space_e2e.py --strict-ladder # fail if ladder not monotone
|
| 168 |
+
python scripts/test_space_e2e.py --audio-dir ../other # your own audio
|
| 169 |
+
python scripts/test_space_e2e.py --json report.json
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
Fixtures come from the held-out `test` split of `JacobLinCool/taiko-1000-parsed`
|
| 173 |
+
if it is in the local HuggingFace cache: real music the models never trained
|
| 174 |
+
on, shipping authored charts for all five courses, which gives the difficulty
|
| 175 |
+
ladder a reference. Nothing is downloaded; with no cache and no `--audio-dir`
|
| 176 |
+
the suite reports that and exits.
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## What the end-to-end run shows
|
| 181 |
+
|
| 182 |
+
Six held-out songs (120–210 BPM), 60 s excerpts, five difficulties, greedy
|
| 183 |
+
decoding — 30 real BarScript decodes, median 3.3 s each on CPU.
|
| 184 |
+
|
| 185 |
+
Every chart, on every difficulty, satisfies:
|
| 186 |
+
|
| 187 |
+
- re-parses with `softchart.sc2.trace` into exactly one course section;
|
| 188 |
+
- `COURSE:` header is the literal the corpus uses, **and** resolves back to the
|
| 189 |
+
course requested;
|
| 190 |
+
- bars are all 96 digits, and the bar count follows tempo and excerpt length to
|
| 191 |
+
within 1.5 bars — the lattice is anchored, not drifting;
|
| 192 |
+
- every note sits on the shipped /16 lattice (0 off-lattice notes in 30 charts);
|
| 193 |
+
- drumroll spans are balanced and correctly ordered — no unclosed and no nested
|
| 194 |
+
spans anywhere;
|
| 195 |
+
- the note and span counts shown in the UI equal the counts recovered by
|
| 196 |
+
re-parsing the file.
|
| 197 |
+
|
| 198 |
+
### Ura is harder than Oni — but by much less than authored charts
|
| 199 |
+
|
| 200 |
+
Note counts per course, same song. Generated rows cover the **60 s excerpt**;
|
| 201 |
+
authored rows cover the **whole chart**, so only the shape of each row — the
|
| 202 |
+
step from one course to the next — is comparable between them, not the
|
| 203 |
+
absolute numbers. (Duration-normalised rates follow in the next table.)
|
| 204 |
+
|
| 205 |
+
| song | easy | normal | hard | oni | **ura** |
|
| 206 |
+
|---|---|---|---|---|---|
|
| 207 |
+
| Doppelgangers | 88 | 146 | 219 | 300 | **351** |
|
| 208 |
+
| *(authored)* | *136* | *253* | *368* | *634* | *929* |
|
| 209 |
+
| 美しく忙しきドナウ | 89 | 141 | 218 | 317 | **348** |
|
| 210 |
+
| *(authored)* | *174* | *247* | *449* | *682* | *1038* |
|
| 211 |
+
| 零の交響曲 | 79 | 178 | 293 | 341 | **375** |
|
| 212 |
+
| *(authored)* | *163* | *214* | *434* | *648* | *893* |
|
| 213 |
+
| 雨とペトラ | 92 | 152 | 291 | 369 | **402** |
|
| 214 |
+
| *(authored)* | *117* | *198* | *335* | *464* | *591* |
|
| 215 |
+
| スプラトゥーン2 メドレー | 82 | 132 | 219 | 336 | **373** |
|
| 216 |
+
| *(authored)* | *105* | *144* | *245* | *441* | *757* |
|
| 217 |
+
| さんぽ | 75 | 137 | 218 | 311 | **316** |
|
| 218 |
+
| *(authored)* | *87* | *107* | *249* | *371* | *816* |
|
| 219 |
+
|
| 220 |
+
**Ura came out denser than Oni on 6 of 6 songs, and the whole ladder
|
| 221 |
+
easy < normal < hard < oni < ura was monotone on 6 of 6.** So ura is wired
|
| 222 |
+
correctly and does produce a harder chart.
|
| 223 |
+
|
| 224 |
+
The size of the step is the weak part. Generated oni→ura is **1.02x–1.17x
|
| 225 |
+
(median 1.10x)**; authored oni→ura on the same six songs is **1.27x–2.20x
|
| 226 |
+
(median 1.49x)**. The gap is widest exactly where the authors stretched
|
| 227 |
+
furthest: on さんぽ they went 371→816 notes (2.20x) while the model went
|
| 228 |
+
311→316 (1.02x). This is the 208-chart training deficit showing up, and it is the
|
| 229 |
+
reason `--strict-ladder` is off by default: monotonicity here is a model
|
| 230 |
+
result to be reported, not a wiring property to be assumed. Do not read
|
| 231 |
+
"monotone on 6/6" as evidence that the difficulty family is nested — the model
|
| 232 |
+
card measures that separately (§2.1) and finds it is not.
|
| 233 |
+
|
| 234 |
+
Median notes-per-second, generated vs authored across the six songs:
|
| 235 |
+
|
| 236 |
+
| | easy | normal | hard | oni | ura |
|
| 237 |
+
|---|---|---|---|---|---|
|
| 238 |
+
| generated | 1.45 | 2.43 | 3.69 | 5.46 | 6.10 |
|
| 239 |
+
| authored | 1.08 | 1.76 | 2.91 | 4.56 | 6.70 |
|
| 240 |
+
|
| 241 |
+
BarScript is denser than authored on the four lower courses and **sparser than
|
| 242 |
+
authored on ura** — the one course where it should be densest is the one where
|
| 243 |
+
it falls short.
|
| 244 |
+
|
| 245 |
+
### Note spacing
|
| 246 |
+
|
| 247 |
+
The model card reports 102 sub-40 ms gaps across 29 charts (§2.9). **None of
|
| 248 |
+
the 30 charts here contains one**; the tightest gap measured was 76.9 ms.
|
| 249 |
+
|
| 250 |
+
This is a property of the deployment grid, not evidence that the defect is
|
| 251 |
+
gone. On a uniform /16 lattice the minimum possible spacing is `15 / BPM`
|
| 252 |
+
seconds, so a sub-40 ms gap is arithmetically impossible below **375 BPM**.
|
| 253 |
+
These songs run 120–210 BPM. The model card's figure was measured on authored
|
| 254 |
+
grids with finer denominators. On very fast songs the defect can return.
|
| 255 |
+
|
| 256 |
+
### The seven published models are unchanged
|
| 257 |
+
|
| 258 |
+
Same six songs, real Hub weights, v1.7 and v1.5 x {hard, oni} — 24
|
| 259 |
+
comparisons of the current `app.py` against
|
| 260 |
+
`scripts/fixtures/app_baseline.py`, the app as it stood before this change
|
| 261 |
+
(pinned by sha256 `a21c5853…` to the same source the wiring suite's golden
|
| 262 |
+
trace was recorded from).
|
| 263 |
+
|
| 264 |
+
- **24/24 produced byte-identical TJA text.**
|
| 265 |
+
- 24/24 agree on every metric the old app reported.
|
| 266 |
+
- The metrics panel gained three display-only fields — `model`, `difficulty`,
|
| 267 |
+
`plan_source` — and dropped none. These are the new UI surface (model name,
|
| 268 |
+
the TJA course literal, plan provenance), not a change in generation.
|
| 269 |
+
|
| 270 |
+
The wiring suite covers the same question from the other side: 35 mocked
|
| 271 |
+
scenarios comparing call arguments. This one compares the charts themselves.
|
| 272 |
+
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
## Known limitations of BarScript
|
| 276 |
+
|
| 277 |
+
Verbatim from `MODEL_CARD_sc2_c1_v13_s1234.md`. All eight are shown in the
|
| 278 |
+
interface when BarScript is selected.
|
| 279 |
+
|
| 280 |
+
| | |
|
| 281 |
+
|---|---|
|
| 282 |
+
| §0 | The champion comparison is **inconclusive**: the challenger was run at one seed. |
|
| 283 |
+
| §2.1 | The difficulty ladder is **not nested**. Independently decoded courses share 0.658/0.667/0.660 of their notes with the next harder course; authored charts share 0.95–0.99. |
|
| 284 |
+
| §2.9 | Notes come out **too dense**: 102 sub-40 ms gaps across 29 charts (authored: 2 across 1), 19 of them below the series record of 29.41 ms, and up to 17 notes inside one second. |
|
| 285 |
+
| §2.7 | One drumroll in seven is a **micro-span** (14.7 % vs 3.8 % authored). |
|
| 286 |
+
| §2.12 | **Fine-lattice and tuplet placement** is much worse than average: 0.345 hit rate at positions whose denominator is divisible by 3. |
|
| 287 |
+
| §2.8 | The deployment grid is a uniform **/16 lattice**, so **triplets are arithmetically impossible** — 3 does not divide 16. |
|
| 288 |
+
| §2.4 | At the top of the **density knob**, drumrolls are squeezed out: span yield falls to 33 % of authored. The highest setting is not recommended. |
|
| 289 |
+
| corpus | **Ura was trained on 208 charts** against roughly 1 100 per other course; expect lower quality. |
|
| 290 |
+
|
| 291 |
+
Two operational notes are shown with them: the plan switches do not apply, and
|
| 292 |
+
BarScript needs a beat grid.
|
| 293 |
+
|
| 294 |
+
### The Ura warning applies to every model
|
| 295 |
+
|
| 296 |
+
Ura appears on all eight models, and the 208-chart notice is shown whichever
|
| 297 |
+
one is selected — the shortage is in the training corpus, not in BarScript.
|
| 298 |
+
|
| 299 |
+
The **song planner was never trained on Ura**. `scripts/train_planner.py` builds
|
| 300 |
+
its dataset with `courses=("easy","normal","hard","oni")`, so embedding row 4
|
| 301 |
+
never received a gradient; probing the published checkpoint gives a mean
|
| 302 |
+
predicted density bucket of 1.471 for ura, between normal and hard, when ura is
|
| 303 |
+
the densest course in the corpus. Ura therefore queries the planner with the
|
| 304 |
+
**Oni** course id. This is disclosed before generation and again in the result
|
| 305 |
+
(`AI planner (Oni plan reused for Ura)`). Turning the planner off avoids the
|
| 306 |
+
substitution entirely.
|
| 307 |
+
|
| 308 |
+
---
|
| 309 |
+
|
| 310 |
+
## Not done
|
| 311 |
+
|
| 312 |
+
1. **A deployed Space needs an `HF_TOKEN` secret, which has not been set.** The
|
| 313 |
+
package is now on the Hub, and the download path is verified: with every
|
| 314 |
+
on-disk copy hidden, `sc2_loader` fetched all seven files from
|
| 315 |
+
`JacobLinCool/softchart-barscript`, passed the md5 and vocabulary gates, and
|
| 316 |
+
charted a held-out song through `app.generate_chart` in 6.0 s on CPU.
|
| 317 |
+
|
| 318 |
+
What is missing is the credential on the deployment. The repo is **private**,
|
| 319 |
+
so the running Space needs a read token in `HF_TOKEN` (*Settings → Variables
|
| 320 |
+
and secrets → New secret*). Until that exists, a deployed Space reaches
|
| 321 |
+
leg 3, finds no token, and reports exactly that.
|
| 322 |
+
|
| 323 |
+
Two alternatives avoid the secret: copy the package to
|
| 324 |
+
`spaces/models/sc2_c1_v13_s1234_best/` so it travels with the Space (leg 2,
|
| 325 |
+
18 MB in the Space repo), or make the model repo public — which publishes the
|
| 326 |
+
weights and the model card, including its KNOWN LIMITATIONS, and is a
|
| 327 |
+
separate decision from this one.
|
| 328 |
+
|
| 329 |
+
2. **The BarScript beat front end is unevaluated.** `sc2_beat_model()` borrows
|
| 330 |
+
v1.7's beat head to get BPM and downbeats for its lattice. The model card's
|
| 331 |
+
numbers were measured on *authored* grids, so no published figure describes
|
| 332 |
+
BarScript on a detected grid. The end-to-end suite confirms the resulting bar
|
| 333 |
+
structure is self-consistent, which is not the same as confirming the
|
| 334 |
+
barlines match the music.
|
| 335 |
+
|
| 336 |
+
3. **Density bucket 8 for ura is unvalidated on BarScript.** The bucket map is
|
| 337 |
+
measured from the corpus and matches the rest of the repo, but §2.5 says
|
| 338 |
+
absolute bucket calibration is off and §2.4 warns the top of the knob starves
|
| 339 |
+
drumrolls. The ladder above shows ura landing *below* authored density, which
|
| 340 |
+
is consistent with the knob not delivering what it asks for.
|
| 341 |
+
|
| 342 |
+
4. **Piecewise-tempo songs are flattened.** BarScript decodes one uniform tempo; when
|
| 343 |
+
the grid fit finds several segments the result discloses this under
|
| 344 |
+
`tempo_map`. The six test songs are single-tempo, so the effect is
|
| 345 |
+
unquantified.
|
| 346 |
+
|
| 347 |
+
5. **No human play-testing.** Every claim here is structural or statistical.
|
| 348 |
+
Whether these charts are *good* — groove, pattern quality, whether the ura
|
| 349 |
+
step feels like a step — has not been assessed by a player.
|
| 350 |
+
|
| 351 |
+
6. **No GPU run.** Everything was verified on CPU, which is sufficient for
|
| 352 |
+
correctness; the remote machine was not needed.
|
| 353 |
+
|
| 354 |
+
---
|
| 355 |
+
|
| 356 |
+
## Layout
|
| 357 |
+
|
| 358 |
+
| path | role |
|
| 359 |
+
|---|---|
|
| 360 |
+
| `spaces/softchart/sc2/` | vendored BarScript serving code, 8 modules, byte-identical to the frozen shipping tree (md5 checked against the package at every load) |
|
| 361 |
+
| `spaces/softchart/sc2_loader.py` | the only bridge: package loading, integrity checks, serving contract, `COURSE:` header rewrite, limitation text |
|
| 362 |
+
| `spaces/app.py` | routing, ura, the BarScript branch, `/api/capabilities` |
|
| 363 |
+
| `spaces/static/` | model menu, ura option, limitation panel |
|
| 364 |
+
| `spaces/scripts/test_sc2_vendor.py` | vendored tree matches the package; 1.x untouched |
|
| 365 |
+
| `spaces/scripts/test_app_wiring.py` | routing and published-model regression, under mocks |
|
| 366 |
+
| `spaces/scripts/test_space_e2e.py` | real weights on real music, through the app entry point |
|
| 367 |
+
| `spaces/scripts/fixtures/app_baseline.py` | the pre-change app, the regression baseline |
|
| 368 |
+
|
| 369 |
+
The BarScript modules live in their own subpackage because the two generations have
|
| 370 |
+
incompatible token tables (`VOCAB.size` 2071 vs 1810, `MAX_TGT` 960 vs 1536).
|
| 371 |
+
All BarScript imports are package-relative, so `softchart.sc2.generate` resolves
|
| 372 |
+
`from .vocab import VOCAB` to `softchart.sc2.vocab` and can never reach the 1.x
|
| 373 |
+
table, or the reverse.
|
app.py
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 1 |
-
"""SoftChart —
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import logging
|
| 10 |
import os
|
| 11 |
import re
|
| 12 |
import shutil
|
| 13 |
-
import subprocess
|
| 14 |
import tempfile
|
| 15 |
from pathlib import Path
|
| 16 |
|
|
@@ -20,245 +20,245 @@ import torch
|
|
| 20 |
from fastapi.responses import FileResponse
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
|
| 23 |
-
from
|
| 24 |
-
|
| 25 |
-
load_hf as load_hf_legacy,
|
| 26 |
-
)
|
| 27 |
-
from legacy_softchart.grid import (
|
| 28 |
-
fit_grid_fixed_bpm as fit_grid_fixed_bpm_legacy,
|
| 29 |
-
fit_grid_piecewise as fit_grid_piecewise_legacy,
|
| 30 |
-
)
|
| 31 |
-
from legacy_softchart.hf import SoftChartPlanner
|
| 32 |
-
from legacy_softchart.tja import append_measure_with_gogo, gogo_measure_mask
|
| 33 |
-
from softchart.generate import generate_song as generate_song_v18, load_hf as load_hf_v18
|
| 34 |
from softchart.fonts import cjk_font_path
|
| 35 |
from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
|
|
|
|
| 36 |
from softchart.preview_audio import synthesize_taiko_preview
|
| 37 |
from softchart.rhythm import snap_chart
|
|
|
|
| 38 |
from softchart.tja_image import render_tja_image
|
| 39 |
from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
|
| 40 |
|
| 41 |
LOGGER = logging.getLogger("softchart.space")
|
| 42 |
STATIC_DIR = Path(__file__).with_name("static")
|
| 43 |
|
| 44 |
-
PLAN_REPO = "JacobLinCool/softchart-planner"
|
| 45 |
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
"v1.
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
},
|
| 59 |
-
"v1.7-small": {
|
| 60 |
-
"repo": "JacobLinCool/softchart-v17-small",
|
| 61 |
-
"display": "V1.7 small",
|
| 62 |
-
"runtime": "legacy",
|
| 63 |
-
},
|
| 64 |
-
"v1.7-tiny": {
|
| 65 |
-
"repo": "JacobLinCool/softchart-v17-tiny",
|
| 66 |
-
"display": "V1.7 tiny",
|
| 67 |
-
"runtime": "legacy",
|
| 68 |
-
},
|
| 69 |
-
"v1.6": {
|
| 70 |
-
"repo": "JacobLinCool/softchart-v16",
|
| 71 |
-
"display": "V1.6",
|
| 72 |
-
"runtime": "legacy",
|
| 73 |
-
},
|
| 74 |
-
"v1.6-small": {
|
| 75 |
-
"repo": "JacobLinCool/softchart-v16-small",
|
| 76 |
-
"display": "V1.6 small",
|
| 77 |
-
"runtime": "legacy",
|
| 78 |
-
},
|
| 79 |
-
"v1.6-tiny": {
|
| 80 |
-
"repo": "JacobLinCool/softchart-v16-tiny",
|
| 81 |
-
"display": "V1.6 tiny",
|
| 82 |
-
"runtime": "legacy",
|
| 83 |
-
},
|
| 84 |
-
"v1.5": {
|
| 85 |
-
"repo": "JacobLinCool/softchart-v15",
|
| 86 |
-
"display": "V1.5",
|
| 87 |
-
"runtime": "legacy",
|
| 88 |
-
},
|
| 89 |
}
|
| 90 |
-
DEFAULT_MODEL = "v1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
|
| 93 |
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 94 |
"roll": "5", "roll_big": "6", "balloon": "7"}
|
| 95 |
SUB = 96
|
| 96 |
|
| 97 |
-
_MODELS = {}
|
| 98 |
-
_PLANNER = {}
|
| 99 |
-
|
| 100 |
-
_STFT_WINDOW = None
|
| 101 |
|
| 102 |
|
| 103 |
-
def
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
raise ValueError(
|
| 107 |
-
f"
|
| 108 |
-
f"{', '.join(MODEL_SPECS)}."
|
| 109 |
)
|
| 110 |
-
if
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
_PLANNER["planner"] = (
|
| 166 |
-
SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
|
| 167 |
)
|
| 168 |
-
return
|
| 169 |
|
| 170 |
|
| 171 |
-
def
|
| 172 |
-
"""
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
]
|
| 181 |
-
proc = subprocess.run(
|
| 182 |
-
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
|
| 183 |
-
)
|
| 184 |
-
if proc.returncode != 0:
|
| 185 |
-
detail = proc.stderr.decode("utf-8", errors="replace").strip()
|
| 186 |
-
raise ValueError(f"FFmpeg could not decode this upload: {detail}")
|
| 187 |
-
decoded = np.frombuffer(proc.stdout, dtype="<f4")
|
| 188 |
-
if decoded.size % 2:
|
| 189 |
-
raise ValueError("Decoded stereo audio contains an incomplete frame.")
|
| 190 |
-
wav = decoded.reshape(-1, 2).mean(axis=1, dtype=np.float32)
|
| 191 |
-
if wav.size < SR:
|
| 192 |
-
raise ValueError("Audio must be at least one second long.")
|
| 193 |
-
if not np.isfinite(wav).all():
|
| 194 |
-
raise ValueError("Decoded audio contains non-finite samples.")
|
| 195 |
-
if _MEL_FB is None:
|
| 196 |
-
fb = librosa.filters.mel(
|
| 197 |
-
sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2,
|
| 198 |
-
)
|
| 199 |
-
_MEL_FB = torch.from_numpy(fb)
|
| 200 |
-
_STFT_WINDOW = torch.hann_window(N_FFT)
|
| 201 |
-
wav_tensor = torch.from_numpy(np.ascontiguousarray(wav))[None]
|
| 202 |
-
with torch.no_grad():
|
| 203 |
-
spec = torch.stft(
|
| 204 |
-
wav_tensor, N_FFT, hop_length=HOP, window=_STFT_WINDOW,
|
| 205 |
-
center=True, return_complex=True,
|
| 206 |
-
)[0]
|
| 207 |
-
mel = torch.log(_MEL_FB @ spec.abs().pow(2) + 1e-5)
|
| 208 |
-
mel = mel.numpy().astype(np.float32)
|
| 209 |
-
return mel, wav
|
| 210 |
|
| 211 |
|
| 212 |
-
def
|
| 213 |
-
"""Preserve the preprocessing contract used by V1.5–V1.7."""
|
| 214 |
import librosa
|
| 215 |
|
| 216 |
wav, _ = librosa.load(path, sr=SR, mono=True)
|
| 217 |
-
fb = librosa.filters.mel(
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
spec = torch.stft(
|
| 221 |
-
torch.from_numpy(wav), N_FFT, hop_length=HOP,
|
| 222 |
-
window=torch.hann_window(N_FFT), center=True, return_complex=True,
|
| 223 |
-
)
|
| 224 |
mel = np.log(fb @ spec.abs().pow(2).numpy() + 1e-5).astype(np.float32)
|
| 225 |
return mel, wav
|
| 226 |
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
def learned_plan(planner, mel, course, bpm, downbeats=None):
|
| 229 |
-
|
| 230 |
beat = 60.0 / bpm
|
| 231 |
-
edges = (
|
| 232 |
-
list(
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
spans = []
|
| 238 |
-
for start, end in zip(edges, edges[1:]):
|
| 239 |
-
segment = mel[:, int(start * FPS):int(end * FPS)]
|
| 240 |
-
if segment.shape[1] < 2:
|
| 241 |
continue
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
]))
|
| 247 |
-
spans.append((round(float(start), 3), round(float(end), 3)))
|
| 248 |
-
if not features:
|
| 249 |
return None
|
| 250 |
-
|
| 251 |
-
|
| 252 |
with torch.no_grad():
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
flags = flag_logits[0].argmax(-1).cpu().numpy()
|
| 258 |
-
return [
|
| 259 |
-
[start, end, int(density), int(flag)]
|
| 260 |
-
for (start, end), density, flag in zip(spans, densities, flags)
|
| 261 |
-
]
|
| 262 |
|
| 263 |
|
| 264 |
def group_quantize(times, phase, grid, min_run=3):
|
|
@@ -355,27 +355,22 @@ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=Non
|
|
| 355 |
gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas)
|
| 356 |
lines = []
|
| 357 |
in_gogo = False
|
| 358 |
-
for
|
| 359 |
in_gogo = append_measure_with_gogo(
|
| 360 |
lines,
|
| 361 |
-
"".join(
|
| 362 |
-
|
| 363 |
-
) + ",",
|
| 364 |
-
measure,
|
| 365 |
-
gogo_mask,
|
| 366 |
-
in_gogo,
|
| 367 |
-
)
|
| 368 |
if in_gogo:
|
| 369 |
lines.append("#GOGOEND")
|
| 370 |
balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
|
| 371 |
return "\n".join([
|
| 372 |
f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", f"WAVE:{wave}",
|
| 373 |
-
f"OFFSET:{-phase:.3f}", f"COURSE:{
|
| 374 |
f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
| 375 |
"", "#START", *lines, "#END"]) + "\n"
|
| 376 |
|
| 377 |
|
| 378 |
-
def
|
| 379 |
import matplotlib
|
| 380 |
matplotlib.use("Agg")
|
| 381 |
from matplotlib import font_manager
|
|
@@ -389,12 +384,10 @@ def render_song_structure(mel, title, course, out_path, plan=None):
|
|
| 389 |
|
| 390 |
dur = mel.shape[1] / FPS
|
| 391 |
fig = plt.figure(figsize=(13, 4.4 if plan else 3.2))
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
)
|
| 397 |
-
ax0 = fig.add_subplot(grid[0])
|
| 398 |
ax0.imshow(mel, aspect="auto", origin="lower",
|
| 399 |
cmap="magma", extent=[0, dur, 0, N_MELS])
|
| 400 |
ax0.set_ylabel("mel")
|
|
@@ -403,23 +396,16 @@ def render_song_structure(mel, title, course, out_path, plan=None):
|
|
| 403 |
ax0.grid(axis="x", alpha=0.18)
|
| 404 |
if plan:
|
| 405 |
ax0.set_xticklabels([])
|
| 406 |
-
ax1 = fig.add_subplot(
|
| 407 |
-
for
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
ax1.bar(
|
| 412 |
-
(start + end) / 2,
|
| 413 |
-
max(density, 0.15),
|
| 414 |
-
width=max((end - start) * 0.92, 0.01),
|
| 415 |
-
color=color,
|
| 416 |
-
alpha=0.85,
|
| 417 |
-
)
|
| 418 |
ax1.set_xlim(0, dur)
|
| 419 |
ax1.set_ylim(0, 8)
|
| 420 |
ax1.set_yticks([0, 4, 8])
|
| 421 |
ax1.set_ylabel("plan", fontsize=8)
|
| 422 |
-
ax1.set_xlabel("time (s)
|
| 423 |
ax1.grid(axis="x", alpha=0.18)
|
| 424 |
else:
|
| 425 |
ax0.set_xlabel("time (s)")
|
|
@@ -428,6 +414,69 @@ def render_song_structure(mel, title, course, out_path, plan=None):
|
|
| 428 |
return out_path
|
| 429 |
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
def _progress(stage, fraction, title, detail):
|
| 432 |
return {
|
| 433 |
"kind": "progress",
|
|
@@ -473,6 +522,50 @@ def _file_data(path, *, name=None, mime_type=None):
|
|
| 473 |
).model_dump()
|
| 474 |
|
| 475 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
app = gr.Server(
|
| 477 |
title="SoftChart",
|
| 478 |
description="Conditional Taiko chart generation with synchronized audio preview.",
|
|
@@ -490,14 +583,12 @@ async def homepage():
|
|
| 490 |
|
| 491 |
@app.get("/health", include_in_schema=False)
|
| 492 |
async def health():
|
| 493 |
-
return {
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
"planner_loaded": "planner" in _PLANNER,
|
| 500 |
-
}
|
| 501 |
|
| 502 |
|
| 503 |
@app.api(
|
|
@@ -514,7 +605,9 @@ def generate_chart(
|
|
| 514 |
course: str,
|
| 515 |
level: int,
|
| 516 |
bpm_override: float,
|
|
|
|
| 517 |
use_beat: bool,
|
|
|
|
| 518 |
sampling: bool,
|
| 519 |
temperature: float,
|
| 520 |
top_p: float,
|
|
@@ -531,25 +624,32 @@ def generate_chart(
|
|
| 531 |
temperature = float(temperature)
|
| 532 |
top_p = float(top_p)
|
| 533 |
drum_volume = float(drum_volume)
|
| 534 |
-
|
| 535 |
-
|
|
|
|
| 536 |
raise ValueError(
|
| 537 |
-
f"Unknown model {model_choice!r}. Choose one of: "
|
| 538 |
-
f"{', '.join(MODEL_SPECS)}."
|
| 539 |
)
|
| 540 |
-
|
| 541 |
yield _progress(
|
| 542 |
"loading", 0.03, "Preparing",
|
| 543 |
-
f"Loading SoftChart {
|
| 544 |
)
|
| 545 |
-
|
| 546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
|
| 548 |
yield _progress(
|
| 549 |
"audio", 0.11, "Listening",
|
| 550 |
"Mapping rhythm, melody, and timbre.",
|
| 551 |
)
|
| 552 |
-
mel, wav =
|
| 553 |
|
| 554 |
yield _progress(
|
| 555 |
"beat", 0.21, "Finding the beat",
|
|
@@ -557,7 +657,7 @@ def generate_chart(
|
|
| 557 |
)
|
| 558 |
grid = dbs = None
|
| 559 |
if use_beat:
|
| 560 |
-
grid =
|
| 561 |
if grid is not None:
|
| 562 |
dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
|
| 563 |
if not grid["ok"]:
|
|
@@ -565,9 +665,7 @@ def generate_chart(
|
|
| 565 |
if bpm_override > 0:
|
| 566 |
bpm = bpm_override
|
| 567 |
if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
|
| 568 |
-
fixed_grid =
|
| 569 |
-
model, mel, bpm, device=DEVICE
|
| 570 |
-
)
|
| 571 |
grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
|
| 572 |
if grid is not None:
|
| 573 |
dbs = grid["downbeats"]
|
|
@@ -590,36 +688,61 @@ def generate_chart(
|
|
| 590 |
if grid is None and abs(bpm - round(bpm)) < 0.06:
|
| 591 |
bpm = float(round(bpm))
|
| 592 |
|
| 593 |
-
plan = None
|
| 594 |
-
if bundle["runtime"] == "v18":
|
| 595 |
-
structure_title = "Reading the structure"
|
| 596 |
-
structure_detail = "Building whole-song hierarchical context."
|
| 597 |
-
else:
|
| 598 |
-
structure_title = "Shaping the arc"
|
| 599 |
-
structure_detail = "Applying the original legacy song planner."
|
| 600 |
-
plan = learned_plan(
|
| 601 |
-
get_legacy_planner(), mel, course, bpm, downbeats=dbs
|
| 602 |
-
)
|
| 603 |
yield _progress(
|
| 604 |
-
"
|
|
|
|
| 605 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
|
| 607 |
yield _progress(
|
| 608 |
"generate", 0.47, "Writing the chart",
|
| 609 |
"Writing playable Taiko patterns.",
|
| 610 |
)
|
| 611 |
title = os.path.splitext(wave_name)[0]
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
|
| 624 |
yield _progress(
|
| 625 |
"export", 0.81, "Rendering",
|
|
@@ -629,11 +752,9 @@ def generate_chart(
|
|
| 629 |
with open(tja_path, "w", encoding="utf-8") as output_file:
|
| 630 |
output_file.write(tja)
|
| 631 |
chart_image_path = os.path.join(workdir, "chart.png")
|
| 632 |
-
|
| 633 |
render_tja_image(tja, out_path=chart_image_path)
|
| 634 |
-
|
| 635 |
-
mel, title, course, out_path=structure_image_path, plan=plan,
|
| 636 |
-
)
|
| 637 |
|
| 638 |
yield _progress(
|
| 639 |
"mix", 0.91, "Mixing",
|
|
@@ -651,17 +772,16 @@ def generate_chart(
|
|
| 651 |
|
| 652 |
grid_rms = round(float(grid["rms_ms"]), 1) if grid is not None else None
|
| 653 |
metrics = {
|
| 654 |
-
"model": model_choice,
|
| 655 |
"bpm": round(float(bpm), 1),
|
| 656 |
"notes": len(generated["hits"]),
|
| 657 |
"spans": len(generated["spans"]),
|
| 658 |
-
"timing":
|
| 659 |
-
"hierarchical time + grid quantization"
|
| 660 |
-
if bundle["runtime"] == "v18"
|
| 661 |
-
else "legacy time + grid quantization"
|
| 662 |
-
),
|
| 663 |
"grid_rms_ms": grid_rms,
|
| 664 |
"preview_hits": int(mix_stats["rendered_hit_count"]),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
}
|
| 666 |
yield {
|
| 667 |
"kind": "complete",
|
|
@@ -674,9 +794,7 @@ def generate_chart(
|
|
| 674 |
"tja": _file_data(tja_path, mime_type="text/plain"),
|
| 675 |
"audio": _file_data(preview_path, mime_type="audio/wav"),
|
| 676 |
"chart_image": _file_data(chart_image_path, mime_type="image/png"),
|
| 677 |
-
"
|
| 678 |
-
structure_image_path, mime_type="image/png"
|
| 679 |
-
),
|
| 680 |
},
|
| 681 |
}
|
| 682 |
except Exception as exc:
|
|
|
|
| 1 |
+
"""SoftChart — custom Gradio Server app for Hugging Face Spaces.
|
| 2 |
|
| 3 |
+
Generate a Taiko no Tatsujin chart from any audio file, using the full system:
|
| 4 |
+
- SoftChartGenerator (main model, plan-conditioned)
|
| 5 |
+
- SoftChartPlanner (auto song-level planning)
|
| 6 |
+
- SoftChartBeat (beat/downbeat for barline anchoring)
|
| 7 |
+
All models load from the Hub via from_pretrained. MIT licensed.
|
| 8 |
"""
|
| 9 |
|
| 10 |
import logging
|
| 11 |
import os
|
| 12 |
import re
|
| 13 |
import shutil
|
|
|
|
| 14 |
import tempfile
|
| 15 |
from pathlib import Path
|
| 16 |
|
|
|
|
| 20 |
from fastapi.responses import FileResponse
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
|
| 23 |
+
from softchart import sc2_loader
|
| 24 |
+
from softchart.generate import generate_song, generate_song_slot, load_hf
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from softchart.fonts import cjk_font_path
|
| 26 |
from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
|
| 27 |
+
from softchart.hf import SoftChartPlanner
|
| 28 |
from softchart.preview_audio import synthesize_taiko_preview
|
| 29 |
from softchart.rhythm import snap_chart
|
| 30 |
+
from softchart.tja import append_measure_with_gogo, gogo_measure_mask, write_tja_slots
|
| 31 |
from softchart.tja_image import render_tja_image
|
| 32 |
from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
|
| 33 |
|
| 34 |
LOGGER = logging.getLogger("softchart.space")
|
| 35 |
STATIC_DIR = Path(__file__).with_name("static")
|
| 36 |
|
| 37 |
+
PLAN_REPO = os.environ.get("SC_PLAN", "JacobLinCool/softchart-planner")
|
| 38 |
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 39 |
+
|
| 40 |
+
# Selectable generators: three generations x three sizes (plus the v1.5 single
|
| 41 |
+
# model). All are dual (slot + time), beat- and plan-conditioned, MIT licensed.
|
| 42 |
+
# https://huggingface.co/collections/JacobLinCool/softchart-generators
|
| 43 |
+
MODELS = {
|
| 44 |
+
"v1.7": "JacobLinCool/softchart-v17",
|
| 45 |
+
"v1.7-small": "JacobLinCool/softchart-v17-small",
|
| 46 |
+
"v1.7-tiny": "JacobLinCool/softchart-v17-tiny",
|
| 47 |
+
"v1.6": "JacobLinCool/softchart-v16",
|
| 48 |
+
"v1.6-small": "JacobLinCool/softchart-v16-small",
|
| 49 |
+
"v1.6-tiny": "JacobLinCool/softchart-v16-tiny",
|
| 50 |
+
"v1.5": "JacobLinCool/softchart-v15",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
+
DEFAULT_MODEL = os.environ.get("SC_MODEL", "v1.7")
|
| 53 |
+
|
| 54 |
+
# BarScript is the experimental next-generation model. It is served through
|
| 55 |
+
# softchart/sc2_loader.py, which resolves a release package from $SC2_PACKAGE_DIR,
|
| 56 |
+
# a local directory, or its own Hub repo -- a resolution order the 1.x path does
|
| 57 |
+
# not share. It is deliberately absent from MODELS: that dict is the *legacy*
|
| 58 |
+
# routing table, every entry of which must be loadable by SoftChartGenerator.
|
| 59 |
+
# from_pretrained, and BarScript's architecture cannot be built that way.
|
| 60 |
+
SC2_MODEL = "BarScript (preview)"
|
| 61 |
+
|
| 62 |
+
MODEL_LABELS = {
|
| 63 |
+
"v1.7": ("v1.7 — richest patterns (recommended)", "v1.7 · 7.9M — best quality"),
|
| 64 |
+
"v1.7-small": ("v1.7 — richest patterns (recommended)", "v1.7-small · 3.6M"),
|
| 65 |
+
"v1.7-tiny": ("v1.7 — richest patterns (recommended)", "v1.7-tiny · 1.4M — fastest"),
|
| 66 |
+
"v1.6": ("v1.6 — scaling ladder", "v1.6 · 7.9M"),
|
| 67 |
+
"v1.6-small": ("v1.6 — scaling ladder", "v1.6-small · 3.6M"),
|
| 68 |
+
"v1.6-tiny": ("v1.6 — scaling ladder", "v1.6-tiny · 1.4M"),
|
| 69 |
+
"v1.5": ("v1.5 — single model", "v1.5 · 7.9M"),
|
| 70 |
+
SC2_MODEL: ("BarScript — EXPERIMENTAL PREVIEW, under evaluation",
|
| 71 |
+
"BarScript (preview) — experimental, known defects"),
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Density bucket (width 0.75 notes/sec) requested per course. Measured on all
|
| 75 |
+
# 1 155 songs of JacobLinCool/taiko-1000-parsed: median chart-span note rate is
|
| 76 |
+
# 1.33 / 2.01 / 3.40 / 5.27 / 6.60 notes per second for easy / normal / hard /
|
| 77 |
+
# oni / ura, which buckets to 1 / 2 / 4 / 7 / 8. The first four reproduce the
|
| 78 |
+
# values this app already shipped; ura extends the same measurement and agrees
|
| 79 |
+
# with the map the rest of the repo uses (scripts/infer.py, closed_loop.py,
|
| 80 |
+
# eval_slot.py, gen_samples.py).
|
| 81 |
+
COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7, "ura": 8}
|
| 82 |
+
|
| 83 |
+
# TJA `COURSE:` header literals, measured rather than assumed. Of the 1 155
|
| 84 |
+
# songs in the corpus, 229 carry an ura chart, and every one writes its header
|
| 85 |
+
# as `Edit` (223) or the numeric `4` (6); the string "Ura" never appears -- it
|
| 86 |
+
# is the dataset's normalized course NAME. `str(course).capitalize()`, which
|
| 87 |
+
# both TJA writers use, would emit the invalid `COURSE:Ura`.
|
| 88 |
+
# softchart/tja.py is vendored library code this app does not own, so the slot
|
| 89 |
+
# writer's header is normalized here instead. For the four courses that shipped
|
| 90 |
+
# before, this map reproduces the writers' output exactly, so normalization is
|
| 91 |
+
# a no-op on them (asserted in scripts/test_app_wiring.py).
|
| 92 |
+
TJA_COURSE_HEADER = {"easy": "Easy", "normal": "Normal", "hard": "Hard",
|
| 93 |
+
"oni": "Oni", "ura": "Edit"}
|
| 94 |
+
|
| 95 |
+
# Course ids the shipped planner was TRAINED on. scripts/train_planner.py builds
|
| 96 |
+
# its dataset with courses=("easy","normal","hard","oni") and has no other call
|
| 97 |
+
# site, so embedding row 4 (ura) only ever received weight decay. Probing the
|
| 98 |
+
# published checkpoint confirms it: mean predicted density bucket per course id
|
| 99 |
+
# is 0.573 / 1.126 / 2.274 / 3.789 for the trained ids -- monotone, as expected
|
| 100 |
+
# -- and 1.471 for id 4, i.e. it would ask for a SPARSER plan for ura than for
|
| 101 |
+
# oni, when ura is the densest course in the corpus. Row 4 is therefore not used.
|
| 102 |
+
PLANNER_COURSE_ID = {"easy": 0, "normal": 1, "hard": 2, "oni": 3}
|
| 103 |
+
# Courses the planner cannot answer for, and the trained course substituted for
|
| 104 |
+
# them. Oni is ura's direct sibling and the highest trained density contour.
|
| 105 |
+
# The substitution is reported to the user, never applied silently.
|
| 106 |
+
PLANNER_COURSE_FALLBACK = {"ura": "oni"}
|
| 107 |
|
|
|
|
| 108 |
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 109 |
"roll": "5", "roll_big": "6", "balloon": "7"}
|
| 110 |
SUB = 96
|
| 111 |
|
| 112 |
+
_MODELS = {} # repo id -> {"gen","slot","beat"}, loaded lazily and cached
|
| 113 |
+
_PLANNER = {} # the planner is model-agnostic and shared across generators
|
| 114 |
+
_SC2 = {} # the BarScript release package, loaded lazily and cached
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
+
def is_sc2_model(model_choice):
|
| 118 |
+
"""Whether this choice routes to BarScript instead of the 1.x Hub table."""
|
| 119 |
+
return model_choice == SC2_MODEL
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def available_models():
|
| 123 |
+
"""Selectable model names. BarScript appears only when its package resolves."""
|
| 124 |
+
names = list(MODELS)
|
| 125 |
+
if sc2_loader.is_available():
|
| 126 |
+
names.append(SC2_MODEL)
|
| 127 |
+
return names
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def get_models(model_choice=DEFAULT_MODEL, *, include_planner=False):
|
| 131 |
+
repo = MODELS.get(model_choice)
|
| 132 |
+
if repo is None:
|
| 133 |
raise ValueError(
|
| 134 |
+
f"unknown model {model_choice!r}; choose one of {list(MODELS)}"
|
|
|
|
| 135 |
)
|
| 136 |
+
if repo not in _MODELS:
|
| 137 |
+
u = load_hf(repo, device=DEVICE)
|
| 138 |
+
if (not getattr(u, "_dual", False) or u.beat is None
|
| 139 |
+
or not getattr(u, "_has_plan", False)):
|
| 140 |
+
raise RuntimeError(
|
| 141 |
+
f"{repo} must provide dual generation, beat, and plan conditioning"
|
| 142 |
+
)
|
| 143 |
+
_MODELS[repo] = {"gen": u, "slot": u, "beat": u}
|
| 144 |
+
models = dict(_MODELS[repo])
|
| 145 |
+
if include_planner:
|
| 146 |
+
if "plan" not in _PLANNER:
|
| 147 |
+
_PLANNER["plan"] = SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
|
| 148 |
+
models["plan"] = _PLANNER["plan"]
|
| 149 |
+
return models
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def get_sc2():
|
| 153 |
+
"""Load (and cache) the BarScript release package.
|
| 154 |
+
|
| 155 |
+
The log-mel front end is checked against the package's own contract on
|
| 156 |
+
every load: the Space computes mel itself, and a silent drift there would
|
| 157 |
+
hand the encoder a distribution it never saw.
|
| 158 |
+
"""
|
| 159 |
+
if "sc2" not in _SC2:
|
| 160 |
+
sc = sc2_loader.load(device=DEVICE)
|
| 161 |
+
sc2_loader.check_preprocessing(
|
| 162 |
+
sc.package_dir, sr=SR, n_fft=N_FFT, hop_length=HOP,
|
| 163 |
+
n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
|
| 164 |
+
_SC2["sc2"] = sc
|
| 165 |
+
return _SC2["sc2"]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def sc2_beat_model():
|
| 169 |
+
"""Beat/downbeat model for the BarScript route.
|
| 170 |
+
|
| 171 |
+
BarScript has no beat head of its own; it needs an external BPM and downbeat to
|
| 172 |
+
build its bar lattice, so the default generator's beat model supplies them.
|
| 173 |
+
"""
|
| 174 |
+
return get_models(DEFAULT_MODEL)["beat"]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def planner_course(course):
|
| 178 |
+
"""``(course_the_planner_is_asked_for, was_substituted)``.
|
| 179 |
+
|
| 180 |
+
Raises for a course with neither a trained id nor a documented substitute,
|
| 181 |
+
so an unsupported course fails closed instead of landing on an arbitrary
|
| 182 |
+
embedding row.
|
| 183 |
+
"""
|
| 184 |
+
if course in PLANNER_COURSE_ID:
|
| 185 |
+
return course, False
|
| 186 |
+
fallback = PLANNER_COURSE_FALLBACK.get(course)
|
| 187 |
+
if fallback is None:
|
| 188 |
+
raise ValueError(
|
| 189 |
+
f"The song planner was not trained for {course}. Turn the AI "
|
| 190 |
+
"planner off, or pick another difficulty."
|
|
|
|
|
|
|
| 191 |
)
|
| 192 |
+
return fallback, True
|
| 193 |
|
| 194 |
|
| 195 |
+
def normalize_course_header(text, course):
|
| 196 |
+
"""Rewrite the TJA ``COURSE:`` header to the literal players' tools expect."""
|
| 197 |
+
header = TJA_COURSE_HEADER.get(str(course).lower())
|
| 198 |
+
if header is None:
|
| 199 |
+
raise ValueError(f"Unsupported difficulty: {course}")
|
| 200 |
+
out, n = re.subn(r"(?m)^COURSE:.*$", f"COURSE:{header}", text, count=1)
|
| 201 |
+
if n != 1:
|
| 202 |
+
raise RuntimeError("the generated TJA has no COURSE header to normalize")
|
| 203 |
+
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
|
| 206 |
+
def load_logmel(path):
|
|
|
|
| 207 |
import librosa
|
| 208 |
|
| 209 |
wav, _ = librosa.load(path, sr=SR, mono=True)
|
| 210 |
+
fb = librosa.filters.mel(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
|
| 211 |
+
spec = torch.stft(torch.from_numpy(wav), N_FFT, hop_length=HOP,
|
| 212 |
+
window=torch.hann_window(N_FFT), center=True, return_complex=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
mel = np.log(fb @ spec.abs().pow(2).numpy() + 1e-5).astype(np.float32)
|
| 214 |
return mel, wav
|
| 215 |
|
| 216 |
|
| 217 |
+
def auto_plan(mel, bpm, downbeats=None):
|
| 218 |
+
T = mel.shape[1]
|
| 219 |
+
dur = T / FPS
|
| 220 |
+
flux = np.concatenate([[0], np.maximum(0, np.diff(mel, axis=1)).sum(0)])
|
| 221 |
+
beat = 60.0 / bpm
|
| 222 |
+
edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
|
| 223 |
+
else list(np.arange(0, dur, 4 * beat)) + [dur]
|
| 224 |
+
vals = [float(flux[int(a * FPS):int(b * FPS)].mean()) if int(b * FPS) > int(a * FPS) else 0.0
|
| 225 |
+
for a, b in zip(edges, edges[1:])]
|
| 226 |
+
if not vals:
|
| 227 |
+
return None
|
| 228 |
+
vals = np.array(vals)
|
| 229 |
+
lo, hi = np.percentile(vals, 15), np.percentile(vals, 92)
|
| 230 |
+
peak = int(np.argmax(vals))
|
| 231 |
+
plan = []
|
| 232 |
+
for i, (a, b) in enumerate(zip(edges, edges[1:])):
|
| 233 |
+
frac = (vals[i] - lo) / max(hi - lo, 1e-6)
|
| 234 |
+
d8 = int(np.clip(round(frac * 7), 0, 7))
|
| 235 |
+
fl = 1 if (vals[i] <= lo and 0 < i < len(vals) - 1) else (2 if i == peak and vals[i] >= hi else 0)
|
| 236 |
+
plan.append([round(a, 3), round(b, 3), d8, fl])
|
| 237 |
+
return plan
|
| 238 |
+
|
| 239 |
+
|
| 240 |
def learned_plan(planner, mel, course, bpm, downbeats=None):
|
| 241 |
+
dur = mel.shape[1] / FPS
|
| 242 |
beat = 60.0 / bpm
|
| 243 |
+
edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
|
| 244 |
+
else list(np.arange(0, dur, 4 * beat)) + [dur]
|
| 245 |
+
feats, spans = [], []
|
| 246 |
+
for a, b in zip(edges, edges[1:]):
|
| 247 |
+
seg = mel[:, int(a * FPS):int(b * FPS)]
|
| 248 |
+
if seg.shape[1] < 2:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
continue
|
| 250 |
+
fx = np.maximum(0, np.diff(seg, axis=1)).sum(0)
|
| 251 |
+
feats.append(np.concatenate([seg.mean(1), seg.std(1), [fx.mean(), fx.std(), fx.max()]]))
|
| 252 |
+
spans.append((round(float(a), 3), round(float(b), 3)))
|
| 253 |
+
if not feats:
|
|
|
|
|
|
|
|
|
|
| 254 |
return None
|
| 255 |
+
cid = PLANNER_COURSE_ID[planner_course(course)[0]]
|
| 256 |
+
x = torch.tensor(np.array(feats), dtype=torch.float32)[None].to(DEVICE)
|
| 257 |
with torch.no_grad():
|
| 258 |
+
pd, pf = planner(x, torch.tensor([cid], device=DEVICE))
|
| 259 |
+
d8 = pd[0].argmax(-1).cpu().numpy()
|
| 260 |
+
fl = pf[0].argmax(-1).cpu().numpy()
|
| 261 |
+
return [[a, b, int(d), int(f)] for (a, b), d, f in zip(spans, d8, fl)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
|
| 264 |
def group_quantize(times, phase, grid, min_run=3):
|
|
|
|
| 355 |
gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas)
|
| 356 |
lines = []
|
| 357 |
in_gogo = False
|
| 358 |
+
for m in range(n_meas):
|
| 359 |
in_gogo = append_measure_with_gogo(
|
| 360 |
lines,
|
| 361 |
+
"".join(slots.get(m * SUB + k, "0") for k in range(SUB)) + ",",
|
| 362 |
+
m, gogo_mask, in_gogo)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
if in_gogo:
|
| 364 |
lines.append("#GOGOEND")
|
| 365 |
balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
|
| 366 |
return "\n".join([
|
| 367 |
f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", f"WAVE:{wave}",
|
| 368 |
+
f"OFFSET:{-phase:.3f}", f"COURSE:{TJA_COURSE_HEADER[course]}",
|
| 369 |
f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
| 370 |
"", "#START", *lines, "#END"]) + "\n"
|
| 371 |
|
| 372 |
|
| 373 |
+
def render_audio_plan(mel, title, course, out_path, plan=None):
|
| 374 |
import matplotlib
|
| 375 |
matplotlib.use("Agg")
|
| 376 |
from matplotlib import font_manager
|
|
|
|
| 384 |
|
| 385 |
dur = mel.shape[1] / FPS
|
| 386 |
fig = plt.figure(figsize=(13, 4.4 if plan else 3.2))
|
| 387 |
+
gs = fig.add_gridspec(2 if plan else 1, 1,
|
| 388 |
+
height_ratios=[3.0, 1.0] if plan else [1],
|
| 389 |
+
hspace=0.14 if plan else 0.0)
|
| 390 |
+
ax0 = fig.add_subplot(gs[0])
|
|
|
|
|
|
|
| 391 |
ax0.imshow(mel, aspect="auto", origin="lower",
|
| 392 |
cmap="magma", extent=[0, dur, 0, N_MELS])
|
| 393 |
ax0.set_ylabel("mel")
|
|
|
|
| 396 |
ax0.grid(axis="x", alpha=0.18)
|
| 397 |
if plan:
|
| 398 |
ax0.set_xticklabels([])
|
| 399 |
+
ax1 = fig.add_subplot(gs[1], sharex=ax0)
|
| 400 |
+
for a, b, d, f in plan:
|
| 401 |
+
c = "#d64545" if f == 2 else ("#4a90d9" if f == 1 else "#999999")
|
| 402 |
+
ax1.bar((a + b) / 2, max(d, 0.15), width=max((b - a) * 0.92, 0.01),
|
| 403 |
+
color=c, alpha=0.85)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
ax1.set_xlim(0, dur)
|
| 405 |
ax1.set_ylim(0, 8)
|
| 406 |
ax1.set_yticks([0, 4, 8])
|
| 407 |
ax1.set_ylabel("plan", fontsize=8)
|
| 408 |
+
ax1.set_xlabel("time (s) — plan: grey=density blue=gap red=climax")
|
| 409 |
ax1.grid(axis="x", alpha=0.18)
|
| 410 |
else:
|
| 411 |
ax0.set_xlabel("time (s)")
|
|
|
|
| 414 |
return out_path
|
| 415 |
|
| 416 |
|
| 417 |
+
def generate_sc2(sc2, mel, grid, dbs, bpm, course, level, wave_name, title,
|
| 418 |
+
*, sampling, temperature, top_p, seed=0):
|
| 419 |
+
"""Run the BarScript release. Returns ``(generated, tja, extra_metrics)``.
|
| 420 |
+
|
| 421 |
+
BarScript decodes onto a bar lattice supplied by the caller, so it needs both a
|
| 422 |
+
tempo and the audio time of the first downbeat. Neither can be guessed: a
|
| 423 |
+
lattice anchored on the wrong phase puts every barline in the wrong place,
|
| 424 |
+
and the chart would look like a model defect rather than a missing input.
|
| 425 |
+
The route therefore fails closed when no downbeat is available.
|
| 426 |
+
|
| 427 |
+
Sampling parameters are left at the package's recorded serving contract
|
| 428 |
+
unless the user turns creative sampling on. Anything overridden is reported
|
| 429 |
+
back, because the model card's measurements describe the recorded values.
|
| 430 |
+
"""
|
| 431 |
+
if grid is not None and len(grid.get("downbeats", [])):
|
| 432 |
+
offset_sec = float(grid["downbeats"][0])
|
| 433 |
+
anchor = "fitted beat grid"
|
| 434 |
+
elif dbs is not None and len(dbs):
|
| 435 |
+
offset_sec = float(dbs[0])
|
| 436 |
+
anchor = "detected downbeats"
|
| 437 |
+
else:
|
| 438 |
+
raise ValueError(
|
| 439 |
+
"BarScript builds its bars from a downbeat and cannot place "
|
| 440 |
+
"them without one. Leave the beat grid switched on, or enter the "
|
| 441 |
+
"song's BPM in Advanced settings, and try again."
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
overrides = {}
|
| 445 |
+
if sampling:
|
| 446 |
+
overrides = {"greedy": False, "temperature": float(temperature),
|
| 447 |
+
"top_p": float(top_p)}
|
| 448 |
+
result = sc2.generate(
|
| 449 |
+
mel, course, level=level, bpm=float(bpm), offset_sec=offset_sec,
|
| 450 |
+
density_bucket=COURSE_DENS[course], seed=seed,
|
| 451 |
+
grid_denom=sc2_loader.SHIP_GRID_DENOM,
|
| 452 |
+
title=title, wave=wave_name, **overrides,
|
| 453 |
+
)
|
| 454 |
+
grid_info = result["grid"]
|
| 455 |
+
segments = int(grid.get("n_segments", 1)) if grid is not None else 1
|
| 456 |
+
extra = {
|
| 457 |
+
"chart_grid": (f"/{grid_info['denom']} uniform"
|
| 458 |
+
+ ("" if grid_info["triplets_representable"]
|
| 459 |
+
else " — triplets impossible")),
|
| 460 |
+
"grid_anchor": f"{anchor}, first downbeat {offset_sec:.3f} s",
|
| 461 |
+
"density_bucket": result["density_bucket"],
|
| 462 |
+
"sampling": ("user override: " + ", ".join(
|
| 463 |
+
f"{k}={v}" for k, v in sorted(result["serving_overrides"].items()))
|
| 464 |
+
) if result["serving_overrides"] else "package defaults",
|
| 465 |
+
}
|
| 466 |
+
if segments > 1:
|
| 467 |
+
extra["tempo_map"] = (
|
| 468 |
+
f"flattened — the fit found {segments} tempo segments, BarScript "
|
| 469 |
+
f"decodes "
|
| 470 |
+
f"one uniform tempo ({bpm:.1f} BPM)"
|
| 471 |
+
)
|
| 472 |
+
if not result["motif"]["verified"]:
|
| 473 |
+
raise RuntimeError(
|
| 474 |
+
"BarScript decoded without the motif constraint the release "
|
| 475 |
+
"package resolved; refusing to present the chart."
|
| 476 |
+
)
|
| 477 |
+
return result["gen"], result["tja"], extra
|
| 478 |
+
|
| 479 |
+
|
| 480 |
def _progress(stage, fraction, title, detail):
|
| 481 |
return {
|
| 482 |
"kind": "progress",
|
|
|
|
| 522 |
).model_dump()
|
| 523 |
|
| 524 |
|
| 525 |
+
def _ura_notices():
|
| 526 |
+
"""What a user picking Ura has to be told, whichever model is selected."""
|
| 527 |
+
corpus = {key: text for key, _, text in sc2_loader.KNOWN_LIMITATIONS}["ura"]
|
| 528 |
+
return [
|
| 529 |
+
{"when": "always", "text": corpus},
|
| 530 |
+
{"when": "planner",
|
| 531 |
+
"text": "The song planner was never trained on Ura, so its Oni plan "
|
| 532 |
+
"is used instead. Turn the AI planner off to avoid the "
|
| 533 |
+
"substitution."},
|
| 534 |
+
]
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def capabilities():
|
| 538 |
+
"""Everything the interface needs to build its menus honestly.
|
| 539 |
+
|
| 540 |
+
The BarScript entry is present only when its package resolves, and it carries
|
| 541 |
+
the model card's limitations verbatim so the interface cannot paraphrase
|
| 542 |
+
them into something milder.
|
| 543 |
+
"""
|
| 544 |
+
models = []
|
| 545 |
+
for name in available_models():
|
| 546 |
+
group, label = MODEL_LABELS.get(name, (name, name))
|
| 547 |
+
entry = {"value": name, "group": group, "label": label,
|
| 548 |
+
"experimental": is_sc2_model(name), "limitations": []}
|
| 549 |
+
if is_sc2_model(name):
|
| 550 |
+
entry["limitations"] = [
|
| 551 |
+
{"section": section, "text": text}
|
| 552 |
+
for _, section, text in sc2_loader.KNOWN_LIMITATIONS
|
| 553 |
+
]
|
| 554 |
+
entry["notes"] = [
|
| 555 |
+
"Song structure planning does not apply: BarScript takes no plan, "
|
| 556 |
+
"so the Structure and AI planner switches are ignored and no "
|
| 557 |
+
"#GOGO sections are written.",
|
| 558 |
+
"BarScript needs a beat grid. Leave the beat grid on, or enter the "
|
| 559 |
+
"song's BPM, so its bar lattice has a downbeat to sit on.",
|
| 560 |
+
]
|
| 561 |
+
models.append(entry)
|
| 562 |
+
return {
|
| 563 |
+
"default_model": DEFAULT_MODEL,
|
| 564 |
+
"models": models,
|
| 565 |
+
"course_notices": {"ura": _ura_notices()},
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
|
| 569 |
app = gr.Server(
|
| 570 |
title="SoftChart",
|
| 571 |
description="Conditional Taiko chart generation with synchronized audio preview.",
|
|
|
|
| 583 |
|
| 584 |
@app.get("/health", include_in_schema=False)
|
| 585 |
async def health():
|
| 586 |
+
return {"status": "ok", "device": DEVICE, "models_loaded": "gen" in _MODELS}
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
@app.get("/api/capabilities", include_in_schema=False)
|
| 590 |
+
async def api_capabilities():
|
| 591 |
+
return capabilities()
|
|
|
|
|
|
|
| 592 |
|
| 593 |
|
| 594 |
@app.api(
|
|
|
|
| 605 |
course: str,
|
| 606 |
level: int,
|
| 607 |
bpm_override: float,
|
| 608 |
+
auto_plan_on: bool,
|
| 609 |
use_beat: bool,
|
| 610 |
+
use_planner: bool,
|
| 611 |
sampling: bool,
|
| 612 |
temperature: float,
|
| 613 |
top_p: float,
|
|
|
|
| 624 |
temperature = float(temperature)
|
| 625 |
top_p = float(top_p)
|
| 626 |
drum_volume = float(drum_volume)
|
| 627 |
+
|
| 628 |
+
choices = available_models()
|
| 629 |
+
if model_choice not in choices:
|
| 630 |
raise ValueError(
|
| 631 |
+
f"Unknown model {model_choice!r}. Choose one of: {', '.join(choices)}."
|
|
|
|
| 632 |
)
|
| 633 |
+
sc2_used = is_sc2_model(model_choice)
|
| 634 |
yield _progress(
|
| 635 |
"loading", 0.03, "Preparing",
|
| 636 |
+
f"Loading SoftChart {model_choice}.",
|
| 637 |
)
|
| 638 |
+
if sc2_used:
|
| 639 |
+
# BarScript lives in its own token space and cannot share a checkpoint
|
| 640 |
+
# with the 1.x route; it borrows only the beat model, which it has
|
| 641 |
+
# no equivalent of. include_planner is not honoured -- see below.
|
| 642 |
+
sc2 = get_sc2()
|
| 643 |
+
models = {"beat": sc2_beat_model()}
|
| 644 |
+
else:
|
| 645 |
+
sc2 = None
|
| 646 |
+
models = get_models(model_choice, include_planner=use_planner)
|
| 647 |
|
| 648 |
yield _progress(
|
| 649 |
"audio", 0.11, "Listening",
|
| 650 |
"Mapping rhythm, melody, and timbre.",
|
| 651 |
)
|
| 652 |
+
mel, wav = load_logmel(audio_path)
|
| 653 |
|
| 654 |
yield _progress(
|
| 655 |
"beat", 0.21, "Finding the beat",
|
|
|
|
| 657 |
)
|
| 658 |
grid = dbs = None
|
| 659 |
if use_beat:
|
| 660 |
+
grid = fit_grid_piecewise(models["beat"], mel, device=DEVICE)
|
| 661 |
if grid is not None:
|
| 662 |
dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
|
| 663 |
if not grid["ok"]:
|
|
|
|
| 665 |
if bpm_override > 0:
|
| 666 |
bpm = bpm_override
|
| 667 |
if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
|
| 668 |
+
fixed_grid = fit_grid_fixed_bpm(models["beat"], mel, bpm, device=DEVICE)
|
|
|
|
|
|
|
| 669 |
grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
|
| 670 |
if grid is not None:
|
| 671 |
dbs = grid["downbeats"]
|
|
|
|
| 688 |
if grid is None and abs(bpm - round(bpm)) < 0.06:
|
| 689 |
bpm = float(round(bpm))
|
| 690 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
yield _progress(
|
| 692 |
+
"plan", 0.34, "Shaping the arc",
|
| 693 |
+
"Planning density and climaxes.",
|
| 694 |
)
|
| 695 |
+
plan = None
|
| 696 |
+
plan_source = "None"
|
| 697 |
+
if sc2_used:
|
| 698 |
+
# BarScript's decoder takes no plan token, so neither switch reaches it.
|
| 699 |
+
# Reporting a plan that nothing consumed would be a lie in the UI.
|
| 700 |
+
plan_source = "Not used — BarScript takes no plan"
|
| 701 |
+
elif getattr(models["gen"], "_has_plan", False):
|
| 702 |
+
if use_planner:
|
| 703 |
+
asked, substituted = planner_course(course)
|
| 704 |
+
plan = learned_plan(models["plan"], mel, course, bpm, dbs)
|
| 705 |
+
plan_source = (f"AI planner ({asked.capitalize()} plan reused "
|
| 706 |
+
f"for {course.capitalize()})" if substituted
|
| 707 |
+
else "AI planner")
|
| 708 |
+
elif auto_plan_on:
|
| 709 |
+
plan = auto_plan(mel, bpm, dbs)
|
| 710 |
+
plan_source = "Energy heuristic"
|
| 711 |
|
| 712 |
yield _progress(
|
| 713 |
"generate", 0.47, "Writing the chart",
|
| 714 |
"Writing playable Taiko patterns.",
|
| 715 |
)
|
| 716 |
title = os.path.splitext(wave_name)[0]
|
| 717 |
+
extra_metrics = {}
|
| 718 |
+
slot_used = grid is not None
|
| 719 |
+
if sc2_used:
|
| 720 |
+
generated, tja, extra_metrics = generate_sc2(
|
| 721 |
+
sc2, mel, grid, dbs, bpm, course, level, wave_name, title,
|
| 722 |
+
sampling=sampling, temperature=temperature, top_p=top_p,
|
| 723 |
+
)
|
| 724 |
+
slot_used = True
|
| 725 |
+
elif slot_used:
|
| 726 |
+
generated = generate_song_slot(
|
| 727 |
+
models["slot"], mel, grid, course, level=level,
|
| 728 |
+
density_bucket=COURSE_DENS[course], greedy=not sampling, seed=0,
|
| 729 |
+
temperature=temperature, top_p=top_p, device=DEVICE, plan=plan,
|
| 730 |
+
)
|
| 731 |
+
tja = normalize_course_header(write_tja_slots(
|
| 732 |
+
generated, grid, title, course, level, wave_name, plan=plan,
|
| 733 |
+
), course)
|
| 734 |
+
else:
|
| 735 |
+
generated = generate_song(
|
| 736 |
+
models["gen"], mel, course, level=level,
|
| 737 |
+
density_bucket=COURSE_DENS[course], greedy=not sampling,
|
| 738 |
+
temperature=temperature, top_p=top_p, seed=0,
|
| 739 |
+
device=DEVICE, plan=plan,
|
| 740 |
+
)
|
| 741 |
+
generated = snap_chart(generated, bpm)
|
| 742 |
+
tja = write_tja(
|
| 743 |
+
generated, bpm, title, course, level, wave_name, dbs,
|
| 744 |
+
grid_fit=grid, plan=plan,
|
| 745 |
+
)
|
| 746 |
|
| 747 |
yield _progress(
|
| 748 |
"export", 0.81, "Rendering",
|
|
|
|
| 752 |
with open(tja_path, "w", encoding="utf-8") as output_file:
|
| 753 |
output_file.write(tja)
|
| 754 |
chart_image_path = os.path.join(workdir, "chart.png")
|
| 755 |
+
plan_image_path = os.path.join(workdir, "song-plan.png")
|
| 756 |
render_tja_image(tja, out_path=chart_image_path)
|
| 757 |
+
render_audio_plan(mel, title, course, plan=plan, out_path=plan_image_path)
|
|
|
|
|
|
|
| 758 |
|
| 759 |
yield _progress(
|
| 760 |
"mix", 0.91, "Mixing",
|
|
|
|
| 772 |
|
| 773 |
grid_rms = round(float(grid["rms_ms"]), 1) if grid is not None else None
|
| 774 |
metrics = {
|
|
|
|
| 775 |
"bpm": round(float(bpm), 1),
|
| 776 |
"notes": len(generated["hits"]),
|
| 777 |
"spans": len(generated["spans"]),
|
| 778 |
+
"timing": "slot-exact" if slot_used else "time-quantized",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
"grid_rms_ms": grid_rms,
|
| 780 |
"preview_hits": int(mix_stats["rendered_hit_count"]),
|
| 781 |
+
"model": model_choice,
|
| 782 |
+
"difficulty": f"{course.capitalize()} (TJA {TJA_COURSE_HEADER[course]})",
|
| 783 |
+
"plan_source": plan_source,
|
| 784 |
+
**extra_metrics,
|
| 785 |
}
|
| 786 |
yield {
|
| 787 |
"kind": "complete",
|
|
|
|
| 794 |
"tja": _file_data(tja_path, mime_type="text/plain"),
|
| 795 |
"audio": _file_data(preview_path, mime_type="audio/wav"),
|
| 796 |
"chart_image": _file_data(chart_image_path, mime_type="image/png"),
|
| 797 |
+
"plan_image": _file_data(plan_image_path, mime_type="image/png"),
|
|
|
|
|
|
|
| 798 |
},
|
| 799 |
}
|
| 800 |
except Exception as exc:
|
packages.txt
CHANGED
|
@@ -1,2 +1 @@
|
|
| 1 |
-
ffmpeg
|
| 2 |
fonts-noto-cjk
|
|
|
|
|
|
|
| 1 |
fonts-noto-cjk
|
scripts/fixtures/app_baseline.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SoftChart — custom Gradio Server app for Hugging Face Spaces.
|
| 2 |
+
|
| 3 |
+
Generate a Taiko no Tatsujin chart from any audio file, using the full system:
|
| 4 |
+
- SoftChartGenerator (main model, plan-conditioned)
|
| 5 |
+
- SoftChartPlanner (auto song-level planning)
|
| 6 |
+
- SoftChartBeat (beat/downbeat for barline anchoring)
|
| 7 |
+
All models load from the Hub via from_pretrained. MIT licensed.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import shutil
|
| 14 |
+
import tempfile
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
from fastapi.responses import FileResponse
|
| 21 |
+
from fastapi.staticfiles import StaticFiles
|
| 22 |
+
|
| 23 |
+
from softchart.generate import generate_song, generate_song_slot, load_hf
|
| 24 |
+
from softchart.fonts import cjk_font_path
|
| 25 |
+
from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
|
| 26 |
+
from softchart.hf import SoftChartPlanner
|
| 27 |
+
from softchart.preview_audio import synthesize_taiko_preview
|
| 28 |
+
from softchart.rhythm import snap_chart
|
| 29 |
+
from softchart.tja import append_measure_with_gogo, gogo_measure_mask, write_tja_slots
|
| 30 |
+
from softchart.tja_image import render_tja_image
|
| 31 |
+
from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
|
| 32 |
+
|
| 33 |
+
LOGGER = logging.getLogger("softchart.space")
|
| 34 |
+
STATIC_DIR = Path(__file__).with_name("static")
|
| 35 |
+
|
| 36 |
+
PLAN_REPO = os.environ.get("SC_PLAN", "JacobLinCool/softchart-planner")
|
| 37 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 38 |
+
|
| 39 |
+
# Selectable generators: three generations x three sizes (plus the v1.5 single
|
| 40 |
+
# model). All are dual (slot + time), beat- and plan-conditioned, MIT licensed.
|
| 41 |
+
# https://huggingface.co/collections/JacobLinCool/softchart-generators
|
| 42 |
+
MODELS = {
|
| 43 |
+
"v1.7": "JacobLinCool/softchart-v17",
|
| 44 |
+
"v1.7-small": "JacobLinCool/softchart-v17-small",
|
| 45 |
+
"v1.7-tiny": "JacobLinCool/softchart-v17-tiny",
|
| 46 |
+
"v1.6": "JacobLinCool/softchart-v16",
|
| 47 |
+
"v1.6-small": "JacobLinCool/softchart-v16-small",
|
| 48 |
+
"v1.6-tiny": "JacobLinCool/softchart-v16-tiny",
|
| 49 |
+
"v1.5": "JacobLinCool/softchart-v15",
|
| 50 |
+
}
|
| 51 |
+
DEFAULT_MODEL = os.environ.get("SC_MODEL", "v1.7")
|
| 52 |
+
|
| 53 |
+
COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
|
| 54 |
+
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 55 |
+
"roll": "5", "roll_big": "6", "balloon": "7"}
|
| 56 |
+
SUB = 96
|
| 57 |
+
|
| 58 |
+
_MODELS = {} # repo id -> {"gen","slot","beat"}, loaded lazily and cached
|
| 59 |
+
_PLANNER = {} # the planner is model-agnostic and shared across generators
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_models(model_choice=DEFAULT_MODEL, *, include_planner=False):
|
| 63 |
+
repo = MODELS.get(model_choice)
|
| 64 |
+
if repo is None:
|
| 65 |
+
raise ValueError(
|
| 66 |
+
f"unknown model {model_choice!r}; choose one of {list(MODELS)}"
|
| 67 |
+
)
|
| 68 |
+
if repo not in _MODELS:
|
| 69 |
+
u = load_hf(repo, device=DEVICE)
|
| 70 |
+
if (not getattr(u, "_dual", False) or u.beat is None
|
| 71 |
+
or not getattr(u, "_has_plan", False)):
|
| 72 |
+
raise RuntimeError(
|
| 73 |
+
f"{repo} must provide dual generation, beat, and plan conditioning"
|
| 74 |
+
)
|
| 75 |
+
_MODELS[repo] = {"gen": u, "slot": u, "beat": u}
|
| 76 |
+
models = dict(_MODELS[repo])
|
| 77 |
+
if include_planner:
|
| 78 |
+
if "plan" not in _PLANNER:
|
| 79 |
+
_PLANNER["plan"] = SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
|
| 80 |
+
models["plan"] = _PLANNER["plan"]
|
| 81 |
+
return models
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_logmel(path):
|
| 85 |
+
import librosa
|
| 86 |
+
|
| 87 |
+
wav, _ = librosa.load(path, sr=SR, mono=True)
|
| 88 |
+
fb = librosa.filters.mel(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
|
| 89 |
+
spec = torch.stft(torch.from_numpy(wav), N_FFT, hop_length=HOP,
|
| 90 |
+
window=torch.hann_window(N_FFT), center=True, return_complex=True)
|
| 91 |
+
mel = np.log(fb @ spec.abs().pow(2).numpy() + 1e-5).astype(np.float32)
|
| 92 |
+
return mel, wav
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def auto_plan(mel, bpm, downbeats=None):
|
| 96 |
+
T = mel.shape[1]
|
| 97 |
+
dur = T / FPS
|
| 98 |
+
flux = np.concatenate([[0], np.maximum(0, np.diff(mel, axis=1)).sum(0)])
|
| 99 |
+
beat = 60.0 / bpm
|
| 100 |
+
edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
|
| 101 |
+
else list(np.arange(0, dur, 4 * beat)) + [dur]
|
| 102 |
+
vals = [float(flux[int(a * FPS):int(b * FPS)].mean()) if int(b * FPS) > int(a * FPS) else 0.0
|
| 103 |
+
for a, b in zip(edges, edges[1:])]
|
| 104 |
+
if not vals:
|
| 105 |
+
return None
|
| 106 |
+
vals = np.array(vals)
|
| 107 |
+
lo, hi = np.percentile(vals, 15), np.percentile(vals, 92)
|
| 108 |
+
peak = int(np.argmax(vals))
|
| 109 |
+
plan = []
|
| 110 |
+
for i, (a, b) in enumerate(zip(edges, edges[1:])):
|
| 111 |
+
frac = (vals[i] - lo) / max(hi - lo, 1e-6)
|
| 112 |
+
d8 = int(np.clip(round(frac * 7), 0, 7))
|
| 113 |
+
fl = 1 if (vals[i] <= lo and 0 < i < len(vals) - 1) else (2 if i == peak and vals[i] >= hi else 0)
|
| 114 |
+
plan.append([round(a, 3), round(b, 3), d8, fl])
|
| 115 |
+
return plan
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def learned_plan(planner, mel, course, bpm, downbeats=None):
|
| 119 |
+
dur = mel.shape[1] / FPS
|
| 120 |
+
beat = 60.0 / bpm
|
| 121 |
+
edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
|
| 122 |
+
else list(np.arange(0, dur, 4 * beat)) + [dur]
|
| 123 |
+
feats, spans = [], []
|
| 124 |
+
for a, b in zip(edges, edges[1:]):
|
| 125 |
+
seg = mel[:, int(a * FPS):int(b * FPS)]
|
| 126 |
+
if seg.shape[1] < 2:
|
| 127 |
+
continue
|
| 128 |
+
fx = np.maximum(0, np.diff(seg, axis=1)).sum(0)
|
| 129 |
+
feats.append(np.concatenate([seg.mean(1), seg.std(1), [fx.mean(), fx.std(), fx.max()]]))
|
| 130 |
+
spans.append((round(float(a), 3), round(float(b), 3)))
|
| 131 |
+
if not feats:
|
| 132 |
+
return None
|
| 133 |
+
cid = {"easy": 0, "normal": 1, "hard": 2, "oni": 3}[course]
|
| 134 |
+
x = torch.tensor(np.array(feats), dtype=torch.float32)[None].to(DEVICE)
|
| 135 |
+
with torch.no_grad():
|
| 136 |
+
pd, pf = planner(x, torch.tensor([cid], device=DEVICE))
|
| 137 |
+
d8 = pd[0].argmax(-1).cpu().numpy()
|
| 138 |
+
fl = pf[0].argmax(-1).cpu().numpy()
|
| 139 |
+
return [[a, b, int(d), int(f)] for (a, b), d, f in zip(spans, d8, fl)]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def group_quantize(times, phase, grid, min_run=3):
|
| 143 |
+
n = len(times)
|
| 144 |
+
slots = [0] * n
|
| 145 |
+
i = 0
|
| 146 |
+
while i < n:
|
| 147 |
+
j = i
|
| 148 |
+
while j + 1 < n:
|
| 149 |
+
ioi = times[j + 1] - times[j]
|
| 150 |
+
ref = (times[j] - times[i]) / (j - i) if j > i else ioi
|
| 151 |
+
if 0.02 < ioi < 1.2 and abs(ioi - ref) < 0.22 * max(ref, 1e-6):
|
| 152 |
+
j += 1
|
| 153 |
+
else:
|
| 154 |
+
break
|
| 155 |
+
if j - i + 1 >= min_run:
|
| 156 |
+
k = max(1, int(round((times[j] - times[i]) / (j - i) / grid)))
|
| 157 |
+
anchor = int(round((times[i] - phase) / grid))
|
| 158 |
+
for m in range(j - i + 1):
|
| 159 |
+
slots[i + m] = anchor + m * k
|
| 160 |
+
else:
|
| 161 |
+
for m in range(i, j + 1):
|
| 162 |
+
slots[m] = int(round((times[m] - phase) / grid))
|
| 163 |
+
i = j + 1
|
| 164 |
+
return slots
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def upload_wave_name(audio_path):
|
| 168 |
+
name = os.path.basename(str(audio_path).replace("\\", "/")).strip()
|
| 169 |
+
name = re.sub(r"[\x00-\x1f\x7f]+", " ", name).strip()
|
| 170 |
+
return name or "song.ogg"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def output_tja_path(wave_name, course, directory):
|
| 174 |
+
stem = os.path.splitext(os.path.basename(wave_name))[0].strip() or "softchart"
|
| 175 |
+
stem = re.sub(r"[<>:\"/\\|?*\x00-\x1f]+", "_", stem).strip(" ._") or "softchart"
|
| 176 |
+
return os.path.join(directory, f"{stem}_{course}.tja")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=None,
|
| 180 |
+
plan=None):
|
| 181 |
+
hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
|
| 182 |
+
beat = 60.0 / bpm
|
| 183 |
+
grid = beat / (SUB / 4)
|
| 184 |
+
bias = 0.0
|
| 185 |
+
if grid_fit is not None and hits:
|
| 186 |
+
# authoritative fitted grid: barlines ARE the fitted downbeats.
|
| 187 |
+
# De-bias the generator's systematic latency (global shift only),
|
| 188 |
+
# then anchor slot 0 on the last fitted barline at/before the first note.
|
| 189 |
+
times, bias = debias_to_grid([t for t, _ in hits], grid_fit["phase"], grid)
|
| 190 |
+
phase = grid_fit["phase"] + float(np.floor((times[0] - grid_fit["phase"]) / (4 * beat))) * 4 * beat
|
| 191 |
+
q_times = list(times)
|
| 192 |
+
else:
|
| 193 |
+
times = np.array([t for t, _ in hits]) if hits else np.array([0.0])
|
| 194 |
+
cands = np.arange(0, beat, grid / 4)
|
| 195 |
+
phase = float(cands[int(np.argmin([np.mean(np.abs(((times - o) / grid) - np.round((times - o) / grid))) for o in cands]))])
|
| 196 |
+
q_times = [t for t, _ in hits]
|
| 197 |
+
slot_idx = group_quantize(q_times, phase, grid)
|
| 198 |
+
if slot_idx and min(slot_idx) < 0:
|
| 199 |
+
# note quantized just before the anchor barline: pull back whole bars
|
| 200 |
+
# so nothing is dropped (barline alignment is preserved mod SUB)
|
| 201 |
+
nb = int(np.ceil(-min(slot_idx) / SUB))
|
| 202 |
+
slot_idx = [s + nb * SUB for s in slot_idx]
|
| 203 |
+
phase -= nb * SUB * grid
|
| 204 |
+
slots = {}
|
| 205 |
+
for idx, (t, ch) in zip(slot_idx, hits):
|
| 206 |
+
if idx >= 0 and idx not in slots:
|
| 207 |
+
slots[idx] = ch
|
| 208 |
+
for sp in gen["spans"]:
|
| 209 |
+
i0 = int(round((sp["t0"] - bias - phase) / grid))
|
| 210 |
+
i1 = int(round((sp["t1"] - bias - phase) / grid))
|
| 211 |
+
while i0 in slots:
|
| 212 |
+
i0 += 1
|
| 213 |
+
while i1 in slots or i1 <= i0:
|
| 214 |
+
i1 += 1
|
| 215 |
+
if i0 >= 0:
|
| 216 |
+
slots[i0] = CHAR[sp["type"]]
|
| 217 |
+
slots[i1] = "8"
|
| 218 |
+
if slots and grid_fit is None:
|
| 219 |
+
# Gridless anchoring: shift so the first note sits on a detected
|
| 220 |
+
# downbeat if one is nearby, otherwise on the first barline.
|
| 221 |
+
first_t = min(slots) * grid + phase
|
| 222 |
+
anchor_t = None
|
| 223 |
+
if downbeats is not None and len(downbeats):
|
| 224 |
+
near = downbeats[downbeats <= first_t + 0.12]
|
| 225 |
+
if len(near) and first_t - near[-1] < 4 * beat:
|
| 226 |
+
anchor_t = near[-1]
|
| 227 |
+
shift = int(round((anchor_t - phase) / grid)) if anchor_t is not None else min(slots)
|
| 228 |
+
if shift:
|
| 229 |
+
slots = {k - shift: v for k, v in slots.items()}
|
| 230 |
+
phase += shift * grid
|
| 231 |
+
n_meas = (max(slots) // SUB + 1) if slots else 1
|
| 232 |
+
measure_starts = phase + np.arange(n_meas + 1, dtype=float) * (4 * beat)
|
| 233 |
+
gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas)
|
| 234 |
+
lines = []
|
| 235 |
+
in_gogo = False
|
| 236 |
+
for m in range(n_meas):
|
| 237 |
+
in_gogo = append_measure_with_gogo(
|
| 238 |
+
lines,
|
| 239 |
+
"".join(slots.get(m * SUB + k, "0") for k in range(SUB)) + ",",
|
| 240 |
+
m, gogo_mask, in_gogo)
|
| 241 |
+
if in_gogo:
|
| 242 |
+
lines.append("#GOGOEND")
|
| 243 |
+
balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
|
| 244 |
+
return "\n".join([
|
| 245 |
+
f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", f"WAVE:{wave}",
|
| 246 |
+
f"OFFSET:{-phase:.3f}", f"COURSE:{'Oni' if course == 'oni' else course.capitalize()}",
|
| 247 |
+
f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
| 248 |
+
"", "#START", *lines, "#END"]) + "\n"
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def render_audio_plan(mel, title, course, out_path, plan=None):
|
| 252 |
+
import matplotlib
|
| 253 |
+
matplotlib.use("Agg")
|
| 254 |
+
from matplotlib import font_manager
|
| 255 |
+
import matplotlib.pyplot as plt
|
| 256 |
+
|
| 257 |
+
font_path = cjk_font_path()
|
| 258 |
+
if font_path is not None:
|
| 259 |
+
font_manager.fontManager.addfont(font_path)
|
| 260 |
+
matplotlib.rcParams["font.family"] = font_manager.FontProperties(fname=font_path).get_name()
|
| 261 |
+
matplotlib.rcParams["axes.unicode_minus"] = False
|
| 262 |
+
|
| 263 |
+
dur = mel.shape[1] / FPS
|
| 264 |
+
fig = plt.figure(figsize=(13, 4.4 if plan else 3.2))
|
| 265 |
+
gs = fig.add_gridspec(2 if plan else 1, 1,
|
| 266 |
+
height_ratios=[3.0, 1.0] if plan else [1],
|
| 267 |
+
hspace=0.14 if plan else 0.0)
|
| 268 |
+
ax0 = fig.add_subplot(gs[0])
|
| 269 |
+
ax0.imshow(mel, aspect="auto", origin="lower",
|
| 270 |
+
cmap="magma", extent=[0, dur, 0, N_MELS])
|
| 271 |
+
ax0.set_ylabel("mel")
|
| 272 |
+
ax0.set_title(f"{title} — {course} | full-song mel spectrogram")
|
| 273 |
+
ax0.set_xlim(0, dur)
|
| 274 |
+
ax0.grid(axis="x", alpha=0.18)
|
| 275 |
+
if plan:
|
| 276 |
+
ax0.set_xticklabels([])
|
| 277 |
+
ax1 = fig.add_subplot(gs[1], sharex=ax0)
|
| 278 |
+
for a, b, d, f in plan:
|
| 279 |
+
c = "#d64545" if f == 2 else ("#4a90d9" if f == 1 else "#999999")
|
| 280 |
+
ax1.bar((a + b) / 2, max(d, 0.15), width=max((b - a) * 0.92, 0.01),
|
| 281 |
+
color=c, alpha=0.85)
|
| 282 |
+
ax1.set_xlim(0, dur)
|
| 283 |
+
ax1.set_ylim(0, 8)
|
| 284 |
+
ax1.set_yticks([0, 4, 8])
|
| 285 |
+
ax1.set_ylabel("plan", fontsize=8)
|
| 286 |
+
ax1.set_xlabel("time (s) — plan: grey=density blue=gap red=climax")
|
| 287 |
+
ax1.grid(axis="x", alpha=0.18)
|
| 288 |
+
else:
|
| 289 |
+
ax0.set_xlabel("time (s)")
|
| 290 |
+
fig.savefig(out_path, dpi=130, bbox_inches="tight")
|
| 291 |
+
plt.close(fig)
|
| 292 |
+
return out_path
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _progress(stage, fraction, title, detail):
|
| 296 |
+
return {
|
| 297 |
+
"kind": "progress",
|
| 298 |
+
"stage": stage,
|
| 299 |
+
"progress": fraction,
|
| 300 |
+
"title": title,
|
| 301 |
+
"detail": detail,
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _uploaded_file(value, original_name):
|
| 306 |
+
if value is None:
|
| 307 |
+
raise ValueError("Upload a music file to begin.")
|
| 308 |
+
if not isinstance(original_name, str) or not original_name.strip():
|
| 309 |
+
raise ValueError("The upload is missing its original filename. Please select it again.")
|
| 310 |
+
data = value if isinstance(value, gr.FileData) else gr.FileData.model_validate(value)
|
| 311 |
+
path = os.path.realpath(data.path)
|
| 312 |
+
if not os.path.isfile(path):
|
| 313 |
+
raise ValueError("The uploaded file is no longer available. Please select it again.")
|
| 314 |
+
return path, upload_wave_name(original_name)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _validate_controls(course, level, bpm, temperature, top_p, drum_volume):
|
| 318 |
+
if course not in COURSE_DENS:
|
| 319 |
+
raise ValueError(f"Unsupported difficulty: {course}")
|
| 320 |
+
if not 1 <= int(level) <= 10:
|
| 321 |
+
raise ValueError("Level must be between 1 and 10 stars.")
|
| 322 |
+
if bpm != 0 and not 30 <= float(bpm) <= 400:
|
| 323 |
+
raise ValueError("Manual BPM must be between 30 and 400; use 0 for auto-detection.")
|
| 324 |
+
if not 0.2 <= float(temperature) <= 1.2:
|
| 325 |
+
raise ValueError("Temperature must be between 0.2 and 1.2.")
|
| 326 |
+
if not 0.5 <= float(top_p) <= 1.0:
|
| 327 |
+
raise ValueError("Top-p must be between 0.5 and 1.0.")
|
| 328 |
+
if not 0 <= float(drum_volume) <= 1.5:
|
| 329 |
+
raise ValueError("Taiko volume must be between 0% and 150%.")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _file_data(path, *, name=None, mime_type=None):
|
| 333 |
+
return gr.FileData(
|
| 334 |
+
path=os.path.realpath(path),
|
| 335 |
+
orig_name=name or os.path.basename(path),
|
| 336 |
+
mime_type=mime_type,
|
| 337 |
+
).model_dump()
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
app = gr.Server(
|
| 341 |
+
title="SoftChart",
|
| 342 |
+
description="Conditional Taiko chart generation with synchronized audio preview.",
|
| 343 |
+
)
|
| 344 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
@app.get("/", include_in_schema=False)
|
| 348 |
+
async def homepage():
|
| 349 |
+
return FileResponse(
|
| 350 |
+
STATIC_DIR / "index.html",
|
| 351 |
+
headers={"Cache-Control": "no-cache"},
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
@app.get("/health", include_in_schema=False)
|
| 356 |
+
async def health():
|
| 357 |
+
return {"status": "ok", "device": DEVICE, "models_loaded": "gen" in _MODELS}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@app.api(
|
| 361 |
+
name="generate_chart",
|
| 362 |
+
description="Generate a TJA chart, visual previews, and a synchronized taiko audio mix.",
|
| 363 |
+
concurrency_limit=1,
|
| 364 |
+
concurrency_id="generation-gpu",
|
| 365 |
+
queue=True,
|
| 366 |
+
stream_every=0.1,
|
| 367 |
+
)
|
| 368 |
+
def generate_chart(
|
| 369 |
+
audio: gr.FileData,
|
| 370 |
+
audio_name: str,
|
| 371 |
+
course: str,
|
| 372 |
+
level: int,
|
| 373 |
+
bpm_override: float,
|
| 374 |
+
auto_plan_on: bool,
|
| 375 |
+
use_beat: bool,
|
| 376 |
+
use_planner: bool,
|
| 377 |
+
sampling: bool,
|
| 378 |
+
temperature: float,
|
| 379 |
+
top_p: float,
|
| 380 |
+
drum_volume: float,
|
| 381 |
+
model_choice: str = DEFAULT_MODEL,
|
| 382 |
+
) -> dict[str, object]:
|
| 383 |
+
"""Stream the actual inference stages to the custom frontend."""
|
| 384 |
+
workdir = tempfile.mkdtemp(prefix="softchart-request-")
|
| 385 |
+
try:
|
| 386 |
+
audio_path, wave_name = _uploaded_file(audio, audio_name)
|
| 387 |
+
_validate_controls(course, level, bpm_override, temperature, top_p, drum_volume)
|
| 388 |
+
level = int(level)
|
| 389 |
+
bpm_override = float(bpm_override)
|
| 390 |
+
temperature = float(temperature)
|
| 391 |
+
top_p = float(top_p)
|
| 392 |
+
drum_volume = float(drum_volume)
|
| 393 |
+
|
| 394 |
+
if model_choice not in MODELS:
|
| 395 |
+
raise ValueError(
|
| 396 |
+
f"Unknown model {model_choice!r}. Choose one of: {', '.join(MODELS)}."
|
| 397 |
+
)
|
| 398 |
+
yield _progress(
|
| 399 |
+
"loading", 0.03, "Preparing",
|
| 400 |
+
f"Loading SoftChart {model_choice}.",
|
| 401 |
+
)
|
| 402 |
+
models = get_models(model_choice, include_planner=use_planner)
|
| 403 |
+
|
| 404 |
+
yield _progress(
|
| 405 |
+
"audio", 0.11, "Listening",
|
| 406 |
+
"Mapping rhythm, melody, and timbre.",
|
| 407 |
+
)
|
| 408 |
+
mel, wav = load_logmel(audio_path)
|
| 409 |
+
|
| 410 |
+
yield _progress(
|
| 411 |
+
"beat", 0.21, "Finding the beat",
|
| 412 |
+
"Finding beats and tempo.",
|
| 413 |
+
)
|
| 414 |
+
grid = dbs = None
|
| 415 |
+
if use_beat:
|
| 416 |
+
grid = fit_grid_piecewise(models["beat"], mel, device=DEVICE)
|
| 417 |
+
if grid is not None:
|
| 418 |
+
dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
|
| 419 |
+
if not grid["ok"]:
|
| 420 |
+
grid = None
|
| 421 |
+
if bpm_override > 0:
|
| 422 |
+
bpm = bpm_override
|
| 423 |
+
if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
|
| 424 |
+
fixed_grid = fit_grid_fixed_bpm(models["beat"], mel, bpm, device=DEVICE)
|
| 425 |
+
grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
|
| 426 |
+
if grid is not None:
|
| 427 |
+
dbs = grid["downbeats"]
|
| 428 |
+
elif grid is not None:
|
| 429 |
+
bpm = float(grid["bpm"])
|
| 430 |
+
elif dbs is not None and len(dbs) > 4:
|
| 431 |
+
period = float(np.median(np.diff(dbs)))
|
| 432 |
+
if period <= 0:
|
| 433 |
+
raise RuntimeError("The beat model returned an invalid downbeat interval.")
|
| 434 |
+
bpm = 240.0 / period
|
| 435 |
+
while bpm >= 210:
|
| 436 |
+
bpm /= 2.0
|
| 437 |
+
while bpm < 70:
|
| 438 |
+
bpm *= 2.0
|
| 439 |
+
else:
|
| 440 |
+
import librosa
|
| 441 |
+
bpm = float(np.atleast_1d(librosa.beat.beat_track(y=wav, sr=SR)[0])[0])
|
| 442 |
+
if not np.isfinite(bpm) or bpm <= 0:
|
| 443 |
+
raise RuntimeError("Could not determine a reliable BPM. Enter it in Advanced settings.")
|
| 444 |
+
if grid is None and abs(bpm - round(bpm)) < 0.06:
|
| 445 |
+
bpm = float(round(bpm))
|
| 446 |
+
|
| 447 |
+
yield _progress(
|
| 448 |
+
"plan", 0.34, "Shaping the arc",
|
| 449 |
+
"Planning density and climaxes.",
|
| 450 |
+
)
|
| 451 |
+
plan = None
|
| 452 |
+
if getattr(models["gen"], "_has_plan", False):
|
| 453 |
+
if use_planner:
|
| 454 |
+
plan = learned_plan(models["plan"], mel, course, bpm, dbs)
|
| 455 |
+
elif auto_plan_on:
|
| 456 |
+
plan = auto_plan(mel, bpm, dbs)
|
| 457 |
+
|
| 458 |
+
yield _progress(
|
| 459 |
+
"generate", 0.47, "Writing the chart",
|
| 460 |
+
"Writing playable Taiko patterns.",
|
| 461 |
+
)
|
| 462 |
+
title = os.path.splitext(wave_name)[0]
|
| 463 |
+
slot_used = grid is not None
|
| 464 |
+
if slot_used:
|
| 465 |
+
generated = generate_song_slot(
|
| 466 |
+
models["slot"], mel, grid, course, level=level,
|
| 467 |
+
density_bucket=COURSE_DENS[course], greedy=not sampling, seed=0,
|
| 468 |
+
temperature=temperature, top_p=top_p, device=DEVICE, plan=plan,
|
| 469 |
+
)
|
| 470 |
+
tja = write_tja_slots(
|
| 471 |
+
generated, grid, title, course, level, wave_name, plan=plan,
|
| 472 |
+
)
|
| 473 |
+
else:
|
| 474 |
+
generated = generate_song(
|
| 475 |
+
models["gen"], mel, course, level=level,
|
| 476 |
+
density_bucket=COURSE_DENS[course], greedy=not sampling,
|
| 477 |
+
temperature=temperature, top_p=top_p, seed=0,
|
| 478 |
+
device=DEVICE, plan=plan,
|
| 479 |
+
)
|
| 480 |
+
generated = snap_chart(generated, bpm)
|
| 481 |
+
tja = write_tja(
|
| 482 |
+
generated, bpm, title, course, level, wave_name, dbs,
|
| 483 |
+
grid_fit=grid, plan=plan,
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
yield _progress(
|
| 487 |
+
"export", 0.81, "Rendering",
|
| 488 |
+
"Building the TJA and previews.",
|
| 489 |
+
)
|
| 490 |
+
tja_path = output_tja_path(wave_name, course, workdir)
|
| 491 |
+
with open(tja_path, "w", encoding="utf-8") as output_file:
|
| 492 |
+
output_file.write(tja)
|
| 493 |
+
chart_image_path = os.path.join(workdir, "chart.png")
|
| 494 |
+
plan_image_path = os.path.join(workdir, "song-plan.png")
|
| 495 |
+
render_tja_image(tja, out_path=chart_image_path)
|
| 496 |
+
render_audio_plan(mel, title, course, plan=plan, out_path=plan_image_path)
|
| 497 |
+
|
| 498 |
+
yield _progress(
|
| 499 |
+
"mix", 0.91, "Mixing",
|
| 500 |
+
"Mixing Taiko with your track.",
|
| 501 |
+
)
|
| 502 |
+
stem = Path(tja_path).stem
|
| 503 |
+
preview_path = os.path.join(workdir, f"{stem}_taiko-preview.wav")
|
| 504 |
+
mix_stats = synthesize_taiko_preview(
|
| 505 |
+
audio_path,
|
| 506 |
+
tja,
|
| 507 |
+
preview_path,
|
| 508 |
+
drum_gain=drum_volume,
|
| 509 |
+
sample_rate=44100,
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
grid_rms = round(float(grid["rms_ms"]), 1) if grid is not None else None
|
| 513 |
+
metrics = {
|
| 514 |
+
"bpm": round(float(bpm), 1),
|
| 515 |
+
"notes": len(generated["hits"]),
|
| 516 |
+
"spans": len(generated["spans"]),
|
| 517 |
+
"timing": "slot-exact" if slot_used else "time-quantized",
|
| 518 |
+
"grid_rms_ms": grid_rms,
|
| 519 |
+
"preview_hits": int(mix_stats["rendered_hit_count"]),
|
| 520 |
+
}
|
| 521 |
+
yield {
|
| 522 |
+
"kind": "complete",
|
| 523 |
+
"stage": "complete",
|
| 524 |
+
"progress": 1.0,
|
| 525 |
+
"title": "Ready",
|
| 526 |
+
"detail": "Play it or download it.",
|
| 527 |
+
"metrics": metrics,
|
| 528 |
+
"files": {
|
| 529 |
+
"tja": _file_data(tja_path, mime_type="text/plain"),
|
| 530 |
+
"audio": _file_data(preview_path, mime_type="audio/wav"),
|
| 531 |
+
"chart_image": _file_data(chart_image_path, mime_type="image/png"),
|
| 532 |
+
"plan_image": _file_data(plan_image_path, mime_type="image/png"),
|
| 533 |
+
},
|
| 534 |
+
}
|
| 535 |
+
except Exception as exc:
|
| 536 |
+
LOGGER.exception("SoftChart generation failed")
|
| 537 |
+
detail = (str(exc) if isinstance(exc, (ValueError, RuntimeError))
|
| 538 |
+
else "The server could not complete this chart. Please try again shortly.")
|
| 539 |
+
yield {
|
| 540 |
+
"kind": "error",
|
| 541 |
+
"stage": "error",
|
| 542 |
+
"progress": 0.0,
|
| 543 |
+
"title": "Generation did not complete",
|
| 544 |
+
"detail": detail,
|
| 545 |
+
}
|
| 546 |
+
finally:
|
| 547 |
+
shutil.rmtree(workdir, ignore_errors=True)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
if __name__ == "__main__":
|
| 551 |
+
app.launch(max_file_size="200mb")
|
scripts/fixtures/legacy_call_trace.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
scripts/test_app_wiring.py
ADDED
|
@@ -0,0 +1,1054 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Acceptance tests for the Space's model menu and generation routing.
|
| 2 |
+
|
| 3 |
+
Runs on CPU with no network: every model is a recorder, and the only real
|
| 4 |
+
inference is one optional BarScript decode when the release package is on
|
| 5 |
+
disk (see ``default_package_dir``).
|
| 6 |
+
|
| 7 |
+
The tests answer four questions the wiring change has to settle:
|
| 8 |
+
|
| 9 |
+
1. Do all eight model choices reach the right loader?
|
| 10 |
+
2. Do all five difficulties survive validation, and does ura export the TJA
|
| 11 |
+
``COURSE:`` literal the corpus actually uses?
|
| 12 |
+
3. Do the seven published models still behave exactly as before? Every heavy
|
| 13 |
+
dependency is a recorder, so a run reduces to an ordered list of calls with
|
| 14 |
+
their arguments plus the reported metrics. ``fixtures/legacy_call_trace.json``
|
| 15 |
+
holds that list as captured from the app BEFORE the wiring change, over 35
|
| 16 |
+
scenarios (7 models x 5 control combinations), and the current app has to
|
| 17 |
+
reproduce it call for call.
|
| 18 |
+
|
| 19 |
+
Re-record with ``--record OLD_APP.py``, pointing at the app revision the
|
| 20 |
+
trace should describe. Do not re-record to make a failure go away: a
|
| 21 |
+
difference here is a behaviour change on the published models, which is the
|
| 22 |
+
thing this file exists to catch.
|
| 23 |
+
4. Does the BarScript route hand the loader the conditions the interface
|
| 24 |
+
promised, and does the package resolve in the documented order?
|
| 25 |
+
|
| 26 |
+
python spaces/scripts/test_app_wiring.py
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import copy
|
| 31 |
+
import importlib.util
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import sys
|
| 35 |
+
import tempfile
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
|
| 39 |
+
SPACES = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 40 |
+
if SPACES not in sys.path:
|
| 41 |
+
sys.path.insert(0, SPACES)
|
| 42 |
+
|
| 43 |
+
import app as APP # noqa: E402
|
| 44 |
+
from softchart import sc2_loader # noqa: E402
|
| 45 |
+
|
| 46 |
+
FPS = 22050 / 256
|
| 47 |
+
LEGACY_COURSES = ("easy", "normal", "hard", "oni")
|
| 48 |
+
ALL_COURSES = LEGACY_COURSES + ("ura",)
|
| 49 |
+
GOLDEN = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
| 50 |
+
"fixtures", "legacy_call_trace.json")
|
| 51 |
+
|
| 52 |
+
# The control combinations the published models are replayed under. Between
|
| 53 |
+
# them they cover both writers (slot-exact and time-quantized), both plan
|
| 54 |
+
# sources, greedy and sampled decoding, and the manual-BPM refit.
|
| 55 |
+
LEGACY_SCENARIOS = [
|
| 56 |
+
{"course": "oni", "use_planner": True, "sampling": False},
|
| 57 |
+
{"course": "hard", "use_planner": False, "auto_plan_on": True},
|
| 58 |
+
{"course": "easy", "use_planner": True, "sampling": True, "level": 3},
|
| 59 |
+
# beat grid off with a manual BPM: no fitted grid, so this is the only
|
| 60 |
+
# combination that reaches the time-quantized writer (app.write_tja).
|
| 61 |
+
{"course": "normal", "use_beat": False, "bpm_override": 140.0,
|
| 62 |
+
"use_planner": False, "auto_plan_on": False},
|
| 63 |
+
{"course": "oni", "bpm_override": 180.0, "use_planner": True},
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
RESULTS = []
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def check(name, ok, detail=""):
|
| 70 |
+
RESULTS.append((name, bool(ok), detail))
|
| 71 |
+
print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
|
| 72 |
+
return bool(ok)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# recorders
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class Recorder:
|
| 80 |
+
"""Stand-in for a loaded model; only its identity and calls matter."""
|
| 81 |
+
|
| 82 |
+
def __init__(self, tag, calls):
|
| 83 |
+
self.tag = tag
|
| 84 |
+
self.calls = calls
|
| 85 |
+
self._dual = True
|
| 86 |
+
self._has_plan = True
|
| 87 |
+
self.beat = object()
|
| 88 |
+
|
| 89 |
+
def __repr__(self):
|
| 90 |
+
return f"<Recorder {self.tag}>"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def fake_grid(bpm=150.0, n_bars=8, phase=0.25):
|
| 94 |
+
bar = 4 * 60.0 / bpm
|
| 95 |
+
downbeats = phase + np.arange(n_bars + 1) * bar
|
| 96 |
+
return {"bpm": bpm, "beat": 60.0 / bpm, "bar": bar, "meter": 4,
|
| 97 |
+
"phase": phase, "downbeats": downbeats,
|
| 98 |
+
"beats": phase + np.arange(4 * n_bars) * (60.0 / bpm),
|
| 99 |
+
"n_db_peaks": 24, "n_beat_peaks": 96, "inlier_frac": 0.9,
|
| 100 |
+
"rms_ms": 7.5, "db_peaks": downbeats, "ok": True}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def fake_generation():
|
| 104 |
+
return {"hits": [{"t": 1.0, "type": "don"}, {"t": 1.5, "type": "ka"}],
|
| 105 |
+
"spans": [{"t0": 2.0, "t1": 3.0, "type": "roll"}],
|
| 106 |
+
"hits_slots": [(0, 0, "don"), (0, 24, "ka"), (1, 0, "don_big")],
|
| 107 |
+
"spans_slots": [(2, 0, 2, 48, "roll")],
|
| 108 |
+
"n_measures": 4}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
TJA_STUB = "\n".join([
|
| 112 |
+
"TITLE:stub (SoftChart)", "BPM:150", "WAVE:song.ogg", "OFFSET:-0.250",
|
| 113 |
+
"COURSE:Oni", "LEVEL:9", "BALLOON:", "", "#START", "1020," * 1, "#END"]) + "\n"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def harness(module, calls, *, course="oni", model_choice=None, sampling=False,
|
| 117 |
+
use_planner=True, auto_plan_on=True, use_beat=True, level=9,
|
| 118 |
+
bpm_override=0.0, audio_path=None, workdir=None):
|
| 119 |
+
"""Drive ``module.generate_chart`` with every heavy dependency recorded.
|
| 120 |
+
|
| 121 |
+
Patches the module in place; the caller restores it. Returns the payload
|
| 122 |
+
list yielded by the endpoint.
|
| 123 |
+
"""
|
| 124 |
+
saved = {}
|
| 125 |
+
|
| 126 |
+
def patch(name, value):
|
| 127 |
+
saved[name] = getattr(module, name)
|
| 128 |
+
setattr(module, name, value)
|
| 129 |
+
|
| 130 |
+
def record(name, result=None, positional=()):
|
| 131 |
+
def fn(*a, **k):
|
| 132 |
+
calls.append((name, _repr_args(a), _repr_kwargs(k)))
|
| 133 |
+
return result() if callable(result) else copy.deepcopy(result)
|
| 134 |
+
return fn
|
| 135 |
+
|
| 136 |
+
def passthrough(name, real):
|
| 137 |
+
"""Record the call, then run the real thing.
|
| 138 |
+
|
| 139 |
+
Used for the TJA writers: their output is the artifact under
|
| 140 |
+
comparison, so a stub would only prove the stub is stable.
|
| 141 |
+
"""
|
| 142 |
+
def fn(*a, **k):
|
| 143 |
+
calls.append((name, _repr_args(a), _repr_kwargs(k)))
|
| 144 |
+
return real(*a, **k)
|
| 145 |
+
return fn
|
| 146 |
+
|
| 147 |
+
mel = np.zeros((128, int(20 * FPS)), dtype=np.float32)
|
| 148 |
+
wav = np.zeros(int(20 * 22050), dtype=np.float32)
|
| 149 |
+
|
| 150 |
+
patch("load_logmel", record("load_logmel", lambda: (mel, wav)))
|
| 151 |
+
patch("fit_grid_piecewise", record("fit_grid_piecewise", fake_grid))
|
| 152 |
+
patch("fit_grid_fixed_bpm", record("fit_grid_fixed_bpm", fake_grid))
|
| 153 |
+
patch("learned_plan", record("learned_plan", [[0.0, 4.0, 5, 0]]))
|
| 154 |
+
patch("auto_plan", record("auto_plan", [[0.0, 4.0, 3, 0]]))
|
| 155 |
+
patch("generate_song_slot", record("generate_song_slot", fake_generation))
|
| 156 |
+
patch("generate_song", record("generate_song", fake_generation))
|
| 157 |
+
patch("snap_chart", record("snap_chart", fake_generation))
|
| 158 |
+
patch("write_tja_slots", passthrough("write_tja_slots", module.write_tja_slots))
|
| 159 |
+
patch("render_tja_image", record("render_tja_image", lambda: _touch(workdir, "chart.png")))
|
| 160 |
+
patch("render_audio_plan", record("render_audio_plan", lambda: _touch(workdir, "song-plan.png")))
|
| 161 |
+
patch("synthesize_taiko_preview",
|
| 162 |
+
record("synthesize_taiko_preview",
|
| 163 |
+
lambda: _preview(workdir, calls)))
|
| 164 |
+
|
| 165 |
+
def get_models(choice=module.DEFAULT_MODEL, *, include_planner=False):
|
| 166 |
+
calls.append(("get_models", [choice], {"include_planner": include_planner}))
|
| 167 |
+
models = {k: Recorder(f"{choice}:{k}", calls) for k in ("gen", "slot", "beat")}
|
| 168 |
+
if include_planner:
|
| 169 |
+
models["plan"] = Recorder(f"{choice}:plan", calls)
|
| 170 |
+
return models
|
| 171 |
+
|
| 172 |
+
patch("get_models", get_models)
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
payloads = list(module.generate_chart(
|
| 176 |
+
{"path": audio_path, "meta": {"_type": "gradio.FileData"}},
|
| 177 |
+
"song.ogg", course, level, bpm_override, auto_plan_on, use_beat,
|
| 178 |
+
use_planner, sampling, 0.8, 0.9, 0.8,
|
| 179 |
+
model_choice or module.DEFAULT_MODEL,
|
| 180 |
+
))
|
| 181 |
+
finally:
|
| 182 |
+
for name, value in saved.items():
|
| 183 |
+
setattr(module, name, value)
|
| 184 |
+
return payloads
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _repr_args(args):
|
| 188 |
+
out = []
|
| 189 |
+
for a in args:
|
| 190 |
+
if isinstance(a, np.ndarray):
|
| 191 |
+
out.append(f"ndarray{a.shape}")
|
| 192 |
+
elif isinstance(a, dict) and "downbeats" in a:
|
| 193 |
+
out.append(f"grid(bpm={a['bpm']})")
|
| 194 |
+
else:
|
| 195 |
+
out.append(repr(a))
|
| 196 |
+
return out
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _repr_kwargs(kwargs):
|
| 200 |
+
return {k: _repr_args([v])[0] for k, v in sorted(kwargs.items())}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _touch(workdir, name):
|
| 204 |
+
path = os.path.join(workdir, name)
|
| 205 |
+
with open(path, "wb") as f:
|
| 206 |
+
f.write(b"\x89PNG\r\n\x1a\n")
|
| 207 |
+
return path
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _preview(workdir, calls):
|
| 211 |
+
for entry in os.listdir(workdir):
|
| 212 |
+
if entry.endswith(".tja"):
|
| 213 |
+
stem = entry[:-4]
|
| 214 |
+
break
|
| 215 |
+
else:
|
| 216 |
+
stem = "softchart"
|
| 217 |
+
with open(os.path.join(workdir, f"{stem}_taiko-preview.wav"), "wb") as f:
|
| 218 |
+
f.write(b"RIFF")
|
| 219 |
+
return {"rendered_hit_count": 3}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
# 1. routing
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def test_routing():
|
| 227 |
+
calls = []
|
| 228 |
+
loaded = []
|
| 229 |
+
|
| 230 |
+
def fake_load_hf(repo, device="cpu"):
|
| 231 |
+
loaded.append(repo)
|
| 232 |
+
return Recorder(repo, calls)
|
| 233 |
+
|
| 234 |
+
saved_load_hf, saved_cache = APP.load_hf, dict(APP._MODELS)
|
| 235 |
+
APP.load_hf = fake_load_hf
|
| 236 |
+
APP._MODELS.clear()
|
| 237 |
+
try:
|
| 238 |
+
for name, repo in APP.MODELS.items():
|
| 239 |
+
APP.get_models(name)
|
| 240 |
+
if loaded[-1] != repo:
|
| 241 |
+
return check("routing: published models -> Hub repo", False,
|
| 242 |
+
f"{name} loaded {loaded[-1]}")
|
| 243 |
+
ok = check("routing: published models -> Hub repo",
|
| 244 |
+
loaded == list(APP.MODELS.values()),
|
| 245 |
+
f"{len(loaded)} repos in order")
|
| 246 |
+
finally:
|
| 247 |
+
APP.load_hf = saved_load_hf
|
| 248 |
+
APP._MODELS.clear()
|
| 249 |
+
APP._MODELS.update(saved_cache)
|
| 250 |
+
|
| 251 |
+
ok &= check("routing: BarScript label is not a 1.x Hub repo",
|
| 252 |
+
APP.SC2_MODEL not in APP.MODELS,
|
| 253 |
+
f"MODELS has {len(APP.MODELS)} entries")
|
| 254 |
+
|
| 255 |
+
names = APP.available_models()
|
| 256 |
+
ok &= check("routing: menu = 7 published + BarScript when the package resolves",
|
| 257 |
+
names[:7] == list(APP.MODELS)
|
| 258 |
+
and (names[7:] == [APP.SC2_MODEL]) == sc2_loader.is_available(),
|
| 259 |
+
f"{names}")
|
| 260 |
+
|
| 261 |
+
# the BarScript choice must reach sc2_loader.load and never load_hf
|
| 262 |
+
calls = []
|
| 263 |
+
saved_load, saved_load_hf = sc2_loader.load, APP.load_hf
|
| 264 |
+
saved_sc2 = dict(APP._SC2)
|
| 265 |
+
APP._SC2.clear()
|
| 266 |
+
|
| 267 |
+
class FakeSC2:
|
| 268 |
+
package_dir = "/tmp/nonexistent-package"
|
| 269 |
+
|
| 270 |
+
def fake_sc2_load(package_dir=None, device="cpu"):
|
| 271 |
+
calls.append(("sc2_loader.load", device))
|
| 272 |
+
return FakeSC2()
|
| 273 |
+
|
| 274 |
+
def boom(*a, **k):
|
| 275 |
+
calls.append(("load_hf", a))
|
| 276 |
+
raise AssertionError("BarScript must not reach the 1.x Hub loader")
|
| 277 |
+
|
| 278 |
+
saved_check = sc2_loader.check_preprocessing
|
| 279 |
+
sc2_loader.check_preprocessing = lambda *a, **k: calls.append(("check_preprocessing", k))
|
| 280 |
+
APP.sc2_loader.load = fake_sc2_load
|
| 281 |
+
APP.load_hf = boom
|
| 282 |
+
try:
|
| 283 |
+
APP.get_sc2()
|
| 284 |
+
ok &= check("routing: BarScript -> sc2_loader, front end checked",
|
| 285 |
+
[c[0] for c in calls] == ["sc2_loader.load", "check_preprocessing"],
|
| 286 |
+
f"{[c[0] for c in calls]}")
|
| 287 |
+
front_end = dict(calls[1][1])
|
| 288 |
+
ok &= check("routing: BarScript front-end check uses the app's own mel settings",
|
| 289 |
+
front_end == {"sr": APP.SR, "n_fft": APP.N_FFT,
|
| 290 |
+
"hop_length": APP.HOP, "n_mels": APP.N_MELS,
|
| 291 |
+
"fmin": 20.0, "fmax": APP.SR / 2},
|
| 292 |
+
f"{front_end}")
|
| 293 |
+
finally:
|
| 294 |
+
sc2_loader.load = saved_load
|
| 295 |
+
APP.sc2_loader.load = saved_load
|
| 296 |
+
sc2_loader.check_preprocessing = saved_check
|
| 297 |
+
APP.load_hf = saved_load_hf
|
| 298 |
+
APP._SC2.clear()
|
| 299 |
+
APP._SC2.update(saved_sc2)
|
| 300 |
+
return ok
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
# 2. difficulties
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_courses():
|
| 308 |
+
ok = True
|
| 309 |
+
for course in ALL_COURSES:
|
| 310 |
+
try:
|
| 311 |
+
APP._validate_controls(course, 9, 0, 0.8, 0.9, 0.8)
|
| 312 |
+
passed = True
|
| 313 |
+
except Exception as exc: # noqa: BLE001
|
| 314 |
+
passed = False
|
| 315 |
+
detail = str(exc)
|
| 316 |
+
ok &= check(f"difficulty: {course} passes validation", passed,
|
| 317 |
+
"" if passed else detail)
|
| 318 |
+
try:
|
| 319 |
+
APP._validate_controls("ex", 9, 0, 0.8, 0.9, 0.8)
|
| 320 |
+
rejected = False
|
| 321 |
+
except ValueError:
|
| 322 |
+
rejected = True
|
| 323 |
+
ok &= check("difficulty: an unknown course is still rejected", rejected)
|
| 324 |
+
|
| 325 |
+
ok &= check("difficulty: density map covers all five courses",
|
| 326 |
+
sorted(APP.COURSE_DENS) == sorted(ALL_COURSES),
|
| 327 |
+
f"{APP.COURSE_DENS}")
|
| 328 |
+
ok &= check("difficulty: the four shipped buckets are unchanged",
|
| 329 |
+
{c: APP.COURSE_DENS[c] for c in LEGACY_COURSES}
|
| 330 |
+
== {"easy": 1, "normal": 2, "hard": 4, "oni": 7})
|
| 331 |
+
ok &= check("difficulty: ura is denser than oni", APP.COURSE_DENS["ura"] > APP.COURSE_DENS["oni"],
|
| 332 |
+
f"ura={APP.COURSE_DENS['ura']} oni={APP.COURSE_DENS['oni']}")
|
| 333 |
+
|
| 334 |
+
# the rest of the repo already fixed this map; the app was the outlier
|
| 335 |
+
canonical = {}
|
| 336 |
+
with open(os.path.join(os.path.dirname(SPACES), "scripts", "infer.py"),
|
| 337 |
+
encoding="utf-8") as f:
|
| 338 |
+
for line in f:
|
| 339 |
+
if line.startswith("COURSE_DENS = "):
|
| 340 |
+
canonical = eval(line.split("=", 1)[1].strip()) # noqa: S307
|
| 341 |
+
break
|
| 342 |
+
ok &= check("difficulty: density map agrees with scripts/infer.py",
|
| 343 |
+
canonical == APP.COURSE_DENS, f"{canonical}")
|
| 344 |
+
return ok
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def test_course_header():
|
| 348 |
+
ok = check("TJA header: app map matches the loader's measured map",
|
| 349 |
+
APP.TJA_COURSE_HEADER == sc2_loader.TJA_COURSE_HEADER,
|
| 350 |
+
f"{APP.TJA_COURSE_HEADER}")
|
| 351 |
+
ok &= check("TJA header: ura is Edit, not Ura",
|
| 352 |
+
APP.TJA_COURSE_HEADER["ura"] == "Edit")
|
| 353 |
+
|
| 354 |
+
grid = fake_grid()
|
| 355 |
+
gen = fake_generation()
|
| 356 |
+
for course in ALL_COURSES:
|
| 357 |
+
text = APP.write_tja(gen, 150.0, "song", course, 9, "song.ogg",
|
| 358 |
+
downbeats=grid["downbeats"], grid_fit=grid)
|
| 359 |
+
want = f"COURSE:{APP.TJA_COURSE_HEADER[course]}"
|
| 360 |
+
ok &= check(f"TJA header: time writer emits {want} for {course}",
|
| 361 |
+
want in text.splitlines())
|
| 362 |
+
|
| 363 |
+
from softchart.tja import write_tja_slots as vendored
|
| 364 |
+
for course in ALL_COURSES:
|
| 365 |
+
raw = vendored(gen, grid, "song", course, 9, "song.ogg")
|
| 366 |
+
fixed = APP.normalize_course_header(raw, course)
|
| 367 |
+
want = f"COURSE:{APP.TJA_COURSE_HEADER[course]}"
|
| 368 |
+
ok &= check(f"TJA header: slot writer normalized to {want} for {course}",
|
| 369 |
+
want in fixed.splitlines())
|
| 370 |
+
if course in LEGACY_COURSES:
|
| 371 |
+
# normalization must be a no-op on everything that shipped before
|
| 372 |
+
ok &= check(f"TJA header: normalization is a no-op for {course}",
|
| 373 |
+
fixed == raw)
|
| 374 |
+
else:
|
| 375 |
+
ok &= check("TJA header: the vendored writer really did emit COURSE:Ura",
|
| 376 |
+
"COURSE:Ura" in raw.splitlines(),
|
| 377 |
+
"so the rewrite is load-bearing")
|
| 378 |
+
return ok
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
# ---------------------------------------------------------------------------
|
| 382 |
+
# 3. legacy regression: before/after call trace
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def scenario_name(model, scenario):
|
| 386 |
+
return (f"{model} / {scenario['course']}"
|
| 387 |
+
f"{' / bpm' if scenario.get('bpm_override') else ''}"
|
| 388 |
+
f"{' / sampling' if scenario.get('sampling') else ''}"
|
| 389 |
+
f"{' / no-beat' if scenario.get('use_beat') is False else ''}"
|
| 390 |
+
f"{' / heuristic-plan' if scenario.get('auto_plan_on') and not scenario.get('use_planner') else ''}")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def scrub(value, replacements):
|
| 394 |
+
"""Replace this run's temp directories with stable tokens.
|
| 395 |
+
|
| 396 |
+
The trace records the arguments verbatim -- including the generated TJA
|
| 397 |
+
text handed to the chart renderer, which is what makes this a byte-level
|
| 398 |
+
comparison of the writers -- so only the paths need normalizing.
|
| 399 |
+
"""
|
| 400 |
+
if isinstance(value, str):
|
| 401 |
+
for real, token in replacements:
|
| 402 |
+
value = value.replace(real, token)
|
| 403 |
+
return value
|
| 404 |
+
if isinstance(value, list):
|
| 405 |
+
return [scrub(v, replacements) for v in value]
|
| 406 |
+
if isinstance(value, tuple):
|
| 407 |
+
return tuple(scrub(v, replacements) for v in value)
|
| 408 |
+
if isinstance(value, dict):
|
| 409 |
+
return {k: scrub(v, replacements) for k, v in value.items()}
|
| 410 |
+
return value
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def run_one(module, model, scenario):
|
| 414 |
+
"""``(call trace, metrics)`` for one published-model run."""
|
| 415 |
+
calls = []
|
| 416 |
+
with tempfile.TemporaryDirectory() as audio_dir:
|
| 417 |
+
audio_path = os.path.join(audio_dir, "song.ogg")
|
| 418 |
+
with open(audio_path, "wb") as f:
|
| 419 |
+
f.write(b"OggS")
|
| 420 |
+
workdir = tempfile.mkdtemp(prefix="softchart-work-")
|
| 421 |
+
saved_mkdtemp = tempfile.mkdtemp
|
| 422 |
+
tempfile.mkdtemp = lambda *a, **k: workdir
|
| 423 |
+
try:
|
| 424 |
+
payload = harness(module, calls, model_choice=model,
|
| 425 |
+
audio_path=audio_path, workdir=workdir,
|
| 426 |
+
**scenario)
|
| 427 |
+
finally:
|
| 428 |
+
tempfile.mkdtemp = saved_mkdtemp
|
| 429 |
+
replacements = [(os.path.realpath(workdir), "<WORKDIR>"),
|
| 430 |
+
(workdir, "<WORKDIR>"),
|
| 431 |
+
(os.path.realpath(audio_dir), "<AUDIODIR>"),
|
| 432 |
+
(audio_dir, "<AUDIODIR>")]
|
| 433 |
+
return scrub(calls, replacements), payload[-1].get("metrics", {})
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def legacy_trace(module):
|
| 437 |
+
trace = {}
|
| 438 |
+
for model in module.MODELS:
|
| 439 |
+
for scenario in LEGACY_SCENARIOS:
|
| 440 |
+
calls, metrics = run_one(module, model, scenario)
|
| 441 |
+
trace[scenario_name(model, scenario)] = {
|
| 442 |
+
"calls": calls, "metrics": metrics}
|
| 443 |
+
return trace
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def test_legacy_regression():
|
| 447 |
+
if not os.path.isfile(GOLDEN):
|
| 448 |
+
return check("regression: published models unchanged", False,
|
| 449 |
+
f"no golden trace at {GOLDEN}")
|
| 450 |
+
with open(GOLDEN, encoding="utf-8") as f:
|
| 451 |
+
golden = json.load(f)
|
| 452 |
+
recorded = golden["scenarios"]
|
| 453 |
+
|
| 454 |
+
ok = check("regression: golden trace covers every published model "
|
| 455 |
+
"and control combination",
|
| 456 |
+
len(recorded) == len(APP.MODELS) * len(LEGACY_SCENARIOS),
|
| 457 |
+
f"{len(recorded)} scenarios")
|
| 458 |
+
|
| 459 |
+
for model in APP.MODELS:
|
| 460 |
+
for scenario in LEGACY_SCENARIOS:
|
| 461 |
+
name = scenario_name(model, scenario)
|
| 462 |
+
want = recorded.get(name)
|
| 463 |
+
if want is None:
|
| 464 |
+
ok &= check(f"regression: {name}", False, "not in the golden trace")
|
| 465 |
+
continue
|
| 466 |
+
calls, metrics = run_one(APP, model, scenario)
|
| 467 |
+
# json round-trips tuples to lists; compare in the golden's shape
|
| 468 |
+
calls = json.loads(json.dumps(calls))
|
| 469 |
+
if calls != want["calls"]:
|
| 470 |
+
diff = next((f"{b!r} != {a!r}" for a, b in
|
| 471 |
+
zip(want["calls"], calls) if a != b),
|
| 472 |
+
f"{len(want['calls'])} vs {len(calls)} calls")
|
| 473 |
+
ok &= check(f"regression: {name}", False, diff)
|
| 474 |
+
continue
|
| 475 |
+
# metrics gained keys; every key the old app reported must match
|
| 476 |
+
drift = {k: (v, metrics.get(k)) for k, v in want["metrics"].items()
|
| 477 |
+
if metrics.get(k) != v}
|
| 478 |
+
ok &= check(f"regression: {name}", not drift,
|
| 479 |
+
"" if not drift else f"{drift}")
|
| 480 |
+
return ok
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def test_planner_fallback():
|
| 484 |
+
ok = True
|
| 485 |
+
for course in LEGACY_COURSES:
|
| 486 |
+
ok &= check(f"planner: {course} uses its own trained id",
|
| 487 |
+
APP.planner_course(course) == (course, False))
|
| 488 |
+
ok &= check("planner: ura falls back to the oni plan",
|
| 489 |
+
APP.planner_course("ura") == ("oni", True))
|
| 490 |
+
|
| 491 |
+
# the cid that actually reaches the model
|
| 492 |
+
seen = []
|
| 493 |
+
|
| 494 |
+
class FakePlanner:
|
| 495 |
+
def __call__(self, x, cid):
|
| 496 |
+
import torch
|
| 497 |
+
seen.append(int(cid[0]))
|
| 498 |
+
n = x.shape[1]
|
| 499 |
+
return torch.zeros(1, n, 8), torch.zeros(1, n, 3)
|
| 500 |
+
|
| 501 |
+
mel = np.zeros((128, int(30 * FPS)), dtype=np.float32)
|
| 502 |
+
for course in ALL_COURSES:
|
| 503 |
+
APP.learned_plan(FakePlanner(), mel, course, 150.0)
|
| 504 |
+
ok &= check("planner: course ids 0-3 for the trained courses, 3 for ura",
|
| 505 |
+
seen == [0, 1, 2, 3, 3], f"{seen}")
|
| 506 |
+
|
| 507 |
+
# and the substitution is disclosed, not silent
|
| 508 |
+
calls = []
|
| 509 |
+
with tempfile.TemporaryDirectory() as audio_dir:
|
| 510 |
+
audio_path = os.path.join(audio_dir, "song.ogg")
|
| 511 |
+
with open(audio_path, "wb") as f:
|
| 512 |
+
f.write(b"OggS")
|
| 513 |
+
workdir = tempfile.mkdtemp(prefix="softchart-work-")
|
| 514 |
+
saved = tempfile.mkdtemp
|
| 515 |
+
tempfile.mkdtemp = lambda *a, **k: workdir
|
| 516 |
+
try:
|
| 517 |
+
payload = harness(APP, calls, course="ura", use_planner=True,
|
| 518 |
+
audio_path=audio_path, workdir=workdir)
|
| 519 |
+
finally:
|
| 520 |
+
tempfile.mkdtemp = saved
|
| 521 |
+
plan_source = payload[-1].get("metrics", {}).get("plan_source", "")
|
| 522 |
+
ok &= check("planner: the ura substitution is reported in the metrics",
|
| 523 |
+
"Oni plan reused for Ura" in plan_source, plan_source)
|
| 524 |
+
|
| 525 |
+
notices = APP.capabilities()["course_notices"]["ura"]
|
| 526 |
+
ok &= check("planner: the substitution is disclosed before generating",
|
| 527 |
+
any(n["when"] == "planner" and "Oni plan" in n["text"]
|
| 528 |
+
for n in notices))
|
| 529 |
+
return ok
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
# ---------------------------------------------------------------------------
|
| 533 |
+
# 4. the BarScript route
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def test_sc2_conditions():
|
| 537 |
+
"""What the app hands the BarScript loader, with the loader itself recorded."""
|
| 538 |
+
seen = {}
|
| 539 |
+
|
| 540 |
+
class FakeSC2:
|
| 541 |
+
package_dir = "/tmp/nonexistent-package"
|
| 542 |
+
|
| 543 |
+
def generate(self, mel, course, level=None, **kwargs):
|
| 544 |
+
seen["mel"] = mel.shape
|
| 545 |
+
seen["course"] = course
|
| 546 |
+
seen["level"] = level
|
| 547 |
+
seen.update(kwargs)
|
| 548 |
+
return {
|
| 549 |
+
"tja": TJA_STUB, "gen": fake_generation(),
|
| 550 |
+
"grid": {"source": "bpm", "denom": kwargs.get("grid_denom", 16),
|
| 551 |
+
"bpm": kwargs["bpm"], "offset_sec": kwargs["offset_sec"],
|
| 552 |
+
"triplets_representable": kwargs.get("grid_denom", 16) % 3 == 0,
|
| 553 |
+
"profile": "default"},
|
| 554 |
+
"serving": {}, "serving_overrides": {
|
| 555 |
+
k: kwargs[k] for k in ("greedy", "temperature", "top_p")
|
| 556 |
+
if k in kwargs},
|
| 557 |
+
"motif": {"verified": True}, "counters": {},
|
| 558 |
+
"density_bucket": kwargs.get("density_bucket"),
|
| 559 |
+
"n_notes": 2, "n_spans": 1, "experimental": True,
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
saved_get_sc2, saved_beat = APP.get_sc2, APP.sc2_beat_model
|
| 563 |
+
saved_available = APP.available_models
|
| 564 |
+
APP.get_sc2 = lambda: FakeSC2()
|
| 565 |
+
APP.sc2_beat_model = lambda: Recorder("beat", [])
|
| 566 |
+
APP.available_models = lambda: list(APP.MODELS) + [APP.SC2_MODEL]
|
| 567 |
+
ok = True
|
| 568 |
+
try:
|
| 569 |
+
for course, sampling in (("oni", False), ("ura", True)):
|
| 570 |
+
seen.clear()
|
| 571 |
+
calls = []
|
| 572 |
+
with tempfile.TemporaryDirectory() as audio_dir:
|
| 573 |
+
audio_path = os.path.join(audio_dir, "song.ogg")
|
| 574 |
+
with open(audio_path, "wb") as f:
|
| 575 |
+
f.write(b"OggS")
|
| 576 |
+
workdir = tempfile.mkdtemp(prefix="softchart-work-")
|
| 577 |
+
saved_mkdtemp = tempfile.mkdtemp
|
| 578 |
+
tempfile.mkdtemp = lambda *a, **k: workdir
|
| 579 |
+
try:
|
| 580 |
+
payload = harness(APP, calls, course=course,
|
| 581 |
+
model_choice=APP.SC2_MODEL,
|
| 582 |
+
sampling=sampling, audio_path=audio_path,
|
| 583 |
+
workdir=workdir)
|
| 584 |
+
finally:
|
| 585 |
+
tempfile.mkdtemp = saved_mkdtemp
|
| 586 |
+
|
| 587 |
+
grid = fake_grid()
|
| 588 |
+
ok &= check(f"BarScript ({course}): bpm and first-downbeat offset from the fitted grid",
|
| 589 |
+
seen.get("bpm") == grid["bpm"]
|
| 590 |
+
and abs(seen.get("offset_sec", -1) - float(grid["downbeats"][0])) < 1e-9,
|
| 591 |
+
f"bpm={seen.get('bpm')} offset={seen.get('offset_sec')}")
|
| 592 |
+
ok &= check(f"BarScript ({course}): course and density bucket",
|
| 593 |
+
seen.get("course") == course
|
| 594 |
+
and seen.get("density_bucket") == APP.COURSE_DENS[course],
|
| 595 |
+
f"{seen.get('course')} bucket={seen.get('density_bucket')}")
|
| 596 |
+
ok &= check(f"BarScript ({course}): ships on the /16 deployment lattice",
|
| 597 |
+
seen.get("grid_denom") == sc2_loader.SHIP_GRID_DENOM)
|
| 598 |
+
if sampling:
|
| 599 |
+
ok &= check(f"BarScript ({course}): creative sampling is passed as an override",
|
| 600 |
+
seen.get("greedy") is False and seen.get("temperature") == 0.8
|
| 601 |
+
and seen.get("top_p") == 0.9, f"{seen.get('greedy')}")
|
| 602 |
+
else:
|
| 603 |
+
ok &= check(f"BarScript ({course}): package serving contract left alone",
|
| 604 |
+
not {"greedy", "temperature", "top_p"} & set(seen),
|
| 605 |
+
f"{sorted(set(seen) & {'greedy', 'temperature', 'top_p'})}")
|
| 606 |
+
|
| 607 |
+
metrics = payload[-1].get("metrics", {})
|
| 608 |
+
ok &= check(f"BarScript ({course}): the /16 lattice and its triplet gap are shown",
|
| 609 |
+
"triplets impossible" in metrics.get("chart_grid", ""),
|
| 610 |
+
metrics.get("chart_grid", ""))
|
| 611 |
+
ok &= check(f"BarScript ({course}): the plan switches are reported as unused",
|
| 612 |
+
metrics.get("plan_source") == "Not used — BarScript takes no plan",
|
| 613 |
+
metrics.get("plan_source", ""))
|
| 614 |
+
names = [c[0] for c in calls]
|
| 615 |
+
ok &= check(f"BarScript ({course}): the 1.x generators are never called",
|
| 616 |
+
not {"generate_song", "generate_song_slot",
|
| 617 |
+
"write_tja_slots", "snap_chart"} & set(names),
|
| 618 |
+
f"{names}")
|
| 619 |
+
|
| 620 |
+
# no downbeat -> refuse, rather than anchor the bars on an arbitrary phase
|
| 621 |
+
calls = []
|
| 622 |
+
with tempfile.TemporaryDirectory() as audio_dir:
|
| 623 |
+
audio_path = os.path.join(audio_dir, "song.ogg")
|
| 624 |
+
with open(audio_path, "wb") as f:
|
| 625 |
+
f.write(b"OggS")
|
| 626 |
+
workdir = tempfile.mkdtemp(prefix="softchart-work-")
|
| 627 |
+
saved_mkdtemp = tempfile.mkdtemp
|
| 628 |
+
tempfile.mkdtemp = lambda *a, **k: workdir
|
| 629 |
+
try:
|
| 630 |
+
payload = harness(APP, calls, course="oni", use_beat=False,
|
| 631 |
+
model_choice=APP.SC2_MODEL, bpm_override=150.0,
|
| 632 |
+
audio_path=audio_path, workdir=workdir)
|
| 633 |
+
finally:
|
| 634 |
+
tempfile.mkdtemp = saved_mkdtemp
|
| 635 |
+
last = payload[-1]
|
| 636 |
+
ok &= check("BarScript: fails closed without a downbeat anchor",
|
| 637 |
+
last.get("kind") == "error" and "downbeat" in last.get("detail", ""),
|
| 638 |
+
last.get("detail", ""))
|
| 639 |
+
finally:
|
| 640 |
+
APP.get_sc2, APP.sc2_beat_model = saved_get_sc2, saved_beat
|
| 641 |
+
APP.available_models = saved_available
|
| 642 |
+
return ok
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def test_sc2_real_decode():
|
| 646 |
+
"""One real 2.0 decode, if the release package is on disk."""
|
| 647 |
+
if not sc2_loader.is_available():
|
| 648 |
+
print("SKIP 2.0 real decode: no release package "
|
| 649 |
+
f"({sc2_loader.default_package_dir()})")
|
| 650 |
+
return True
|
| 651 |
+
sc = APP.get_sc2()
|
| 652 |
+
rng = np.random.default_rng(0)
|
| 653 |
+
mel = (rng.standard_normal((128, int(12 * FPS))).astype(np.float32) * 0.6 - 4.0)
|
| 654 |
+
ok = True
|
| 655 |
+
for course in ("oni", "ura"):
|
| 656 |
+
result = sc.generate(mel, course, level=9, bpm=150.0, offset_sec=0.25,
|
| 657 |
+
density_bucket=APP.COURSE_DENS[course], seed=0,
|
| 658 |
+
grid_denom=sc2_loader.SHIP_GRID_DENOM,
|
| 659 |
+
title="probe", wave="song.ogg")
|
| 660 |
+
header = [ln for ln in result["tja"].splitlines() if ln.startswith("COURSE:")]
|
| 661 |
+
ok &= check(f"BarScript real decode ({course}): TJA header",
|
| 662 |
+
header == [f"COURSE:{APP.TJA_COURSE_HEADER[course]}"], f"{header}")
|
| 663 |
+
ok &= check(f"BarScript real decode ({course}): motif constraint served",
|
| 664 |
+
result["motif"]["verified"])
|
| 665 |
+
ok &= check(f"BarScript real decode ({course}): lattice reported as /16 without triplets",
|
| 666 |
+
result["grid"]["denom"] == 16
|
| 667 |
+
and result["grid"]["triplets_representable"] is False)
|
| 668 |
+
return ok
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
class _EnvPatch:
|
| 672 |
+
"""Set/clear environment variables for the duration of a block."""
|
| 673 |
+
|
| 674 |
+
def __init__(self, **values):
|
| 675 |
+
self.values = values
|
| 676 |
+
self.saved = {}
|
| 677 |
+
|
| 678 |
+
def __enter__(self):
|
| 679 |
+
for key, value in self.values.items():
|
| 680 |
+
self.saved[key] = os.environ.get(key)
|
| 681 |
+
if value is None:
|
| 682 |
+
os.environ.pop(key, None)
|
| 683 |
+
else:
|
| 684 |
+
os.environ[key] = value
|
| 685 |
+
return self
|
| 686 |
+
|
| 687 |
+
def __exit__(self, *exc):
|
| 688 |
+
for key, value in self.saved.items():
|
| 689 |
+
if value is None:
|
| 690 |
+
os.environ.pop(key, None)
|
| 691 |
+
else:
|
| 692 |
+
os.environ[key] = value
|
| 693 |
+
return False
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
def _stub_package(root, name):
|
| 697 |
+
"""A directory that looks like a package to the resolver (config.json)."""
|
| 698 |
+
path = os.path.join(root, name)
|
| 699 |
+
os.makedirs(path, exist_ok=True)
|
| 700 |
+
with open(os.path.join(path, "config.json"), "w", encoding="utf-8") as f:
|
| 701 |
+
json.dump({"schema_version": "stub"}, f)
|
| 702 |
+
return path
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def _real_package_copy(dest, *, weights_stub=True):
|
| 706 |
+
"""The shipped package minus its weights, for gate tests.
|
| 707 |
+
|
| 708 |
+
``verify_code`` and the schema check run before a single tensor is read, so
|
| 709 |
+
an empty ``model.safetensors`` is enough to prove they fire -- and keeps the
|
| 710 |
+
test off an 18 MB copy. Returns ``None`` when no package is on disk.
|
| 711 |
+
"""
|
| 712 |
+
src = sc2_loader.default_package_dir()
|
| 713 |
+
if not os.path.isfile(os.path.join(src, "config.json")):
|
| 714 |
+
return None
|
| 715 |
+
os.makedirs(dest, exist_ok=True)
|
| 716 |
+
for name in ("config.json", "spec.json", "vocab.json",
|
| 717 |
+
"preprocessor_config.json"):
|
| 718 |
+
with open(os.path.join(src, name), "rb") as f:
|
| 719 |
+
payload = f.read()
|
| 720 |
+
with open(os.path.join(dest, name), "wb") as f:
|
| 721 |
+
f.write(payload)
|
| 722 |
+
if weights_stub:
|
| 723 |
+
open(os.path.join(dest, "model.safetensors"), "wb").close()
|
| 724 |
+
return dest
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
def test_sc2_package_resolution():
|
| 728 |
+
"""$SC2_PACKAGE_DIR -> local directory -> Hub, and the token error.
|
| 729 |
+
|
| 730 |
+
The Hub leg is mocked throughout: the point is the ORDER and what each leg
|
| 731 |
+
is handed, and a suite that downloaded 18 MB to assert a precedence rule
|
| 732 |
+
would be testing the network instead. One real download is exercised
|
| 733 |
+
separately, outside this suite.
|
| 734 |
+
"""
|
| 735 |
+
ok = True
|
| 736 |
+
workdir = tempfile.mkdtemp(prefix="sc2-resolve-")
|
| 737 |
+
env_pkg = _stub_package(workdir, "from_env")
|
| 738 |
+
local_pkg = _stub_package(workdir, "from_local")
|
| 739 |
+
hub_pkg = _stub_package(workdir, "from_hub")
|
| 740 |
+
empty = os.path.join(workdir, "empty")
|
| 741 |
+
os.makedirs(empty, exist_ok=True)
|
| 742 |
+
|
| 743 |
+
calls = []
|
| 744 |
+
real_local_candidates = sc2_loader.local_candidates
|
| 745 |
+
real_download = sc2_loader.download_from_hub
|
| 746 |
+
|
| 747 |
+
def fake_download(repo_id=None, revision=None, token=None):
|
| 748 |
+
calls.append({"repo_id": repo_id or sc2_loader.hub_repo_id(),
|
| 749 |
+
"revision": revision, "token": token})
|
| 750 |
+
return hub_pkg
|
| 751 |
+
|
| 752 |
+
try:
|
| 753 |
+
sc2_loader.local_candidates = lambda: (local_pkg,)
|
| 754 |
+
sc2_loader.download_from_hub = fake_download
|
| 755 |
+
|
| 756 |
+
# 1. the explicit request wins over both a local package and the Hub
|
| 757 |
+
del calls[:]
|
| 758 |
+
with _EnvPatch(SC2_PACKAGE_DIR=env_pkg, SC2_NO_HUB=None):
|
| 759 |
+
got, source = sc2_loader.resolve_package_dir()
|
| 760 |
+
ok &= check("resolve: $SC2_PACKAGE_DIR outranks local and Hub",
|
| 761 |
+
(got, source) == (env_pkg, "env") and not calls,
|
| 762 |
+
f"{source} -> {os.path.basename(got)}, hub calls {len(calls)}")
|
| 763 |
+
|
| 764 |
+
# 2. an explicit request that cannot be satisfied is an ERROR, not a
|
| 765 |
+
# quiet download of some other copy
|
| 766 |
+
del calls[:]
|
| 767 |
+
with _EnvPatch(SC2_PACKAGE_DIR=empty, SC2_NO_HUB=None):
|
| 768 |
+
try:
|
| 769 |
+
sc2_loader.resolve_package_dir()
|
| 770 |
+
failed, detail = False, "no error raised"
|
| 771 |
+
except sc2_loader.PackageError as exc:
|
| 772 |
+
failed = empty in str(exc) and not calls
|
| 773 |
+
detail = str(exc)[:110]
|
| 774 |
+
ok &= check("resolve: an unsatisfiable $SC2_PACKAGE_DIR raises and "
|
| 775 |
+
"never falls through to the Hub", failed, detail)
|
| 776 |
+
|
| 777 |
+
# 3. a local package outranks the Hub
|
| 778 |
+
del calls[:]
|
| 779 |
+
with _EnvPatch(SC2_PACKAGE_DIR=None, SC2_NO_HUB=None):
|
| 780 |
+
got, source = sc2_loader.resolve_package_dir()
|
| 781 |
+
ok &= check("resolve: a local package outranks the Hub",
|
| 782 |
+
(got, source) == (local_pkg, "local") and not calls,
|
| 783 |
+
f"{source} -> {os.path.basename(got)}, hub calls {len(calls)}")
|
| 784 |
+
|
| 785 |
+
# 4. with neither, the Hub is used
|
| 786 |
+
del calls[:]
|
| 787 |
+
sc2_loader.local_candidates = lambda: (empty,)
|
| 788 |
+
with _EnvPatch(SC2_PACKAGE_DIR=None, SC2_NO_HUB=None):
|
| 789 |
+
got, source = sc2_loader.resolve_package_dir()
|
| 790 |
+
ok &= check("resolve: with no local package the Hub is used",
|
| 791 |
+
(got, source) == (hub_pkg, "hub") and len(calls) == 1,
|
| 792 |
+
f"{source} -> {os.path.basename(got)}, hub calls {len(calls)}")
|
| 793 |
+
|
| 794 |
+
# 5. SC2_NO_HUB pins the Space to disk
|
| 795 |
+
del calls[:]
|
| 796 |
+
with _EnvPatch(SC2_PACKAGE_DIR=None, SC2_NO_HUB="1"):
|
| 797 |
+
try:
|
| 798 |
+
sc2_loader.resolve_package_dir()
|
| 799 |
+
pinned, detail = False, "no error raised"
|
| 800 |
+
except sc2_loader.PackageError as exc:
|
| 801 |
+
pinned = not calls and "SC2_NO_HUB" in str(exc)
|
| 802 |
+
detail = str(exc)[:110]
|
| 803 |
+
ok &= check("resolve: $SC2_NO_HUB disables the Hub leg", pinned, detail)
|
| 804 |
+
finally:
|
| 805 |
+
sc2_loader.local_candidates = real_local_candidates
|
| 806 |
+
sc2_loader.download_from_hub = real_download
|
| 807 |
+
|
| 808 |
+
# 6. the private repo with no token names the credential, not a missing
|
| 809 |
+
# model, and never reaches the network
|
| 810 |
+
downloads = []
|
| 811 |
+
import huggingface_hub
|
| 812 |
+
|
| 813 |
+
real_snapshot = huggingface_hub.snapshot_download
|
| 814 |
+
real_get_token = huggingface_hub.get_token
|
| 815 |
+
|
| 816 |
+
# download_from_hub checks the snapshot is a COMPLETE package before it
|
| 817 |
+
# hands the directory back, so the mocked snapshot must look like one.
|
| 818 |
+
hub_complete = _stub_package(workdir, "hub_complete")
|
| 819 |
+
for _name in sc2_loader.REQUIRED_FILES:
|
| 820 |
+
open(os.path.join(hub_complete, _name), "a").close()
|
| 821 |
+
|
| 822 |
+
def fake_snapshot(**kwargs):
|
| 823 |
+
downloads.append(kwargs)
|
| 824 |
+
return hub_complete
|
| 825 |
+
|
| 826 |
+
try:
|
| 827 |
+
huggingface_hub.snapshot_download = fake_snapshot
|
| 828 |
+
huggingface_hub.get_token = lambda: None
|
| 829 |
+
del downloads[:]
|
| 830 |
+
with _EnvPatch(HF_TOKEN=None, HUGGING_FACE_HUB_TOKEN=None):
|
| 831 |
+
try:
|
| 832 |
+
sc2_loader.download_from_hub()
|
| 833 |
+
named, detail = False, "no error raised"
|
| 834 |
+
except sc2_loader.PackageError as exc:
|
| 835 |
+
text = str(exc)
|
| 836 |
+
named = ("HF_TOKEN" in text and "PRIVATE" in text
|
| 837 |
+
and "SC2_PACKAGE_DIR" in text and not downloads)
|
| 838 |
+
detail = text[:130]
|
| 839 |
+
ok &= check("hub: a missing token is reported as a credentials "
|
| 840 |
+
"problem, before any request", named, detail)
|
| 841 |
+
|
| 842 |
+
# 7. the token that IS found is the one passed to the Hub, and only the
|
| 843 |
+
# package's own files are requested
|
| 844 |
+
del downloads[:]
|
| 845 |
+
with _EnvPatch(HF_TOKEN="hf_test_token", HUGGING_FACE_HUB_TOKEN=None,
|
| 846 |
+
SC2_REPO_REVISION=None):
|
| 847 |
+
sc2_loader.download_from_hub()
|
| 848 |
+
sent = downloads[0] if downloads else {}
|
| 849 |
+
ok &= check("hub: request carries the resolved token, the repo id and "
|
| 850 |
+
"an allow-list of exactly the package files",
|
| 851 |
+
len(downloads) == 1
|
| 852 |
+
and sent.get("token") == "hf_test_token"
|
| 853 |
+
and sent.get("repo_id") == sc2_loader.DEFAULT_HUB_REPO
|
| 854 |
+
and sent.get("repo_type") == "model"
|
| 855 |
+
and sorted(sent.get("allow_patterns") or []) ==
|
| 856 |
+
sorted(sc2_loader.PACKAGE_FILES),
|
| 857 |
+
f"repo={sent.get('repo_id')} "
|
| 858 |
+
f"patterns={len(sent.get('allow_patterns') or [])}")
|
| 859 |
+
|
| 860 |
+
# 8. an auth failure is translated, not passed through raw. The Hub's
|
| 861 |
+
# own exception classes take a response object, so the two shapes the
|
| 862 |
+
# loader keys on -- the class NAME and an HTTP status in the text --
|
| 863 |
+
# are raised directly rather than half-built.
|
| 864 |
+
class _RepoNotFound(Exception):
|
| 865 |
+
pass
|
| 866 |
+
|
| 867 |
+
_RepoNotFound.__name__ = "RepositoryNotFoundError"
|
| 868 |
+
|
| 869 |
+
for label, exc_obj in (("by exception class", _RepoNotFound("not found")),
|
| 870 |
+
("by HTTP status", Exception("401 Client Error"))):
|
| 871 |
+
def refusing_snapshot(_exc=exc_obj, **kwargs):
|
| 872 |
+
raise _exc
|
| 873 |
+
|
| 874 |
+
huggingface_hub.snapshot_download = refusing_snapshot
|
| 875 |
+
with _EnvPatch(HF_TOKEN="hf_wrong_token"):
|
| 876 |
+
try:
|
| 877 |
+
sc2_loader.download_from_hub()
|
| 878 |
+
translated, detail = False, "no error raised"
|
| 879 |
+
except sc2_loader.PackageError as exc:
|
| 880 |
+
translated = "token" in str(exc).lower()
|
| 881 |
+
detail = str(exc)[:130]
|
| 882 |
+
ok &= check(f"hub: a private-repo refusal ({label}) is explained "
|
| 883 |
+
"as a token problem", translated, detail)
|
| 884 |
+
finally:
|
| 885 |
+
huggingface_hub.snapshot_download = real_snapshot
|
| 886 |
+
huggingface_hub.get_token = real_get_token
|
| 887 |
+
|
| 888 |
+
# 9. the integrity gates run on a downloaded package exactly as on a local
|
| 889 |
+
# one: eight vendored modules by md5, then the package schema
|
| 890 |
+
staged = _real_package_copy(os.path.join(workdir, "downloaded"))
|
| 891 |
+
if staged is None:
|
| 892 |
+
ok &= check("hub: integrity gates run on the downloaded package", True,
|
| 893 |
+
"skipped -- no package on disk to stage from")
|
| 894 |
+
else:
|
| 895 |
+
with open(os.path.join(staged, "config.json"), encoding="utf-8") as f:
|
| 896 |
+
good = json.load(f)
|
| 897 |
+
ok &= check("hub: the staged package records all eight vendored "
|
| 898 |
+
"module md5s",
|
| 899 |
+
len(sc2_loader.verify_code(good)) == 8,
|
| 900 |
+
f"{len(sc2_loader.VENDORED_MODULES)} modules")
|
| 901 |
+
|
| 902 |
+
def _stage(mutate, name):
|
| 903 |
+
path = os.path.join(workdir, name)
|
| 904 |
+
_real_package_copy(path)
|
| 905 |
+
cfg = copy.deepcopy(good)
|
| 906 |
+
mutate(cfg)
|
| 907 |
+
with open(os.path.join(path, "config.json"), "w",
|
| 908 |
+
encoding="utf-8") as f:
|
| 909 |
+
json.dump(cfg, f)
|
| 910 |
+
return path
|
| 911 |
+
|
| 912 |
+
def _drift(cfg):
|
| 913 |
+
cfg["code"]["module_md5"]["src/softchart/generate.py"] = "0" * 32
|
| 914 |
+
|
| 915 |
+
cases = [
|
| 916 |
+
("a vendored module whose md5 drifted", _drift, "generate.py"),
|
| 917 |
+
("an unknown package schema",
|
| 918 |
+
lambda c: c.update({"schema_version": "something_else"}),
|
| 919 |
+
"schema"),
|
| 920 |
+
("a vocabulary that is not this checkpoint's",
|
| 921 |
+
lambda c: c["vocab"].update({"mapping_sha256": "0" * 64}),
|
| 922 |
+
"vocab"),
|
| 923 |
+
]
|
| 924 |
+
for label, mutate, needle in cases:
|
| 925 |
+
path = _stage(mutate, "bad_" + needle.replace(".", "_"))
|
| 926 |
+
real_download = sc2_loader.download_from_hub
|
| 927 |
+
try:
|
| 928 |
+
sc2_loader.download_from_hub = lambda **kw: path
|
| 929 |
+
with _EnvPatch(SC2_PACKAGE_DIR=None, SC2_NO_HUB=None):
|
| 930 |
+
sc2_loader.local_candidates = lambda: (empty,)
|
| 931 |
+
try:
|
| 932 |
+
sc2_loader.load()
|
| 933 |
+
refused, detail = False, "loaded anyway"
|
| 934 |
+
except sc2_loader.PackageError as exc:
|
| 935 |
+
refused = needle in str(exc)
|
| 936 |
+
detail = str(exc)[:110]
|
| 937 |
+
finally:
|
| 938 |
+
sc2_loader.download_from_hub = real_download
|
| 939 |
+
sc2_loader.local_candidates = real_local_candidates
|
| 940 |
+
ok &= check(f"hub: refuses a downloaded package with {label}",
|
| 941 |
+
refused, detail)
|
| 942 |
+
|
| 943 |
+
# 10. a snapshot whose bytes disagree with its own manifest is refused
|
| 944 |
+
tampered = os.path.join(workdir, "tampered")
|
| 945 |
+
_real_package_copy(tampered)
|
| 946 |
+
with open(os.path.join(tampered, "release_manifest.json"), "w",
|
| 947 |
+
encoding="utf-8") as f:
|
| 948 |
+
json.dump({"files": {"spec.json": {"sha256": "0" * 64,
|
| 949 |
+
"size_bytes": 2618}}}, f)
|
| 950 |
+
try:
|
| 951 |
+
sc2_loader.verify_manifest(tampered, source="test")
|
| 952 |
+
caught, detail = False, "accepted a mismatched manifest"
|
| 953 |
+
except sc2_loader.PackageError as exc:
|
| 954 |
+
caught, detail = "spec.json" in str(exc), str(exc)[:110]
|
| 955 |
+
ok &= check("hub: a snapshot that disagrees with its own "
|
| 956 |
+
"release_manifest.json is refused", caught, detail)
|
| 957 |
+
|
| 958 |
+
# 11. the menu follows the same order
|
| 959 |
+
with _EnvPatch(SC2_PACKAGE_DIR=empty, SC2_NO_HUB=None):
|
| 960 |
+
unsatisfiable = sc2_loader.is_available()
|
| 961 |
+
with _EnvPatch(SC2_PACKAGE_DIR=None, SC2_NO_HUB="1"):
|
| 962 |
+
sc2_loader.local_candidates = lambda: (empty,)
|
| 963 |
+
try:
|
| 964 |
+
pinned_off = sc2_loader.is_available()
|
| 965 |
+
finally:
|
| 966 |
+
sc2_loader.local_candidates = real_local_candidates
|
| 967 |
+
ok &= check("menu: is_available reflects the same resolution order",
|
| 968 |
+
unsatisfiable is False and pinned_off is False,
|
| 969 |
+
f"unsatisfiable $SC2_PACKAGE_DIR -> {unsatisfiable}, "
|
| 970 |
+
f"no package and $SC2_NO_HUB -> {pinned_off}")
|
| 971 |
+
return ok
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def test_capabilities_payload():
|
| 975 |
+
caps = APP.capabilities()
|
| 976 |
+
values = [m["value"] for m in caps["models"]]
|
| 977 |
+
ok = check("UI: capabilities lists exactly the selectable models",
|
| 978 |
+
values == APP.available_models(), f"{values}")
|
| 979 |
+
ok &= check("UI: published models carry no limitation list",
|
| 980 |
+
all(not m["limitations"] for m in caps["models"]
|
| 981 |
+
if not m["experimental"]))
|
| 982 |
+
experimental = [m for m in caps["models"] if m["experimental"]]
|
| 983 |
+
if sc2_loader.is_available():
|
| 984 |
+
card = [t for _, _, t in sc2_loader.KNOWN_LIMITATIONS]
|
| 985 |
+
shown = [entry["text"] for entry in experimental[0]["limitations"]]
|
| 986 |
+
ok &= check("UI: all eight model-card limitations shown verbatim",
|
| 987 |
+
shown == card, f"{len(shown)}/{len(card)}")
|
| 988 |
+
ok &= check("UI: the BarScript entry is labelled experimental",
|
| 989 |
+
"EXPERIMENTAL" in experimental[0]["group"],
|
| 990 |
+
experimental[0]["group"])
|
| 991 |
+
ura = [n["text"] for n in caps["course_notices"]["ura"]]
|
| 992 |
+
ok &= check("UI: the ura training-data notice is shown for every model",
|
| 993 |
+
any("208" in t for t in ura), f"{len(ura)} notices")
|
| 994 |
+
return ok
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
def record_golden(app_path):
|
| 998 |
+
"""Capture the published-model trace from ``app_path`` into the fixture."""
|
| 999 |
+
app_path = os.path.abspath(app_path)
|
| 1000 |
+
spec = importlib.util.spec_from_file_location("app_baseline", app_path)
|
| 1001 |
+
module = importlib.util.module_from_spec(spec)
|
| 1002 |
+
sys.modules["app_baseline"] = module
|
| 1003 |
+
spec.loader.exec_module(module)
|
| 1004 |
+
import hashlib
|
| 1005 |
+
|
| 1006 |
+
with open(app_path, "rb") as f:
|
| 1007 |
+
digest = hashlib.sha256(f.read()).hexdigest()
|
| 1008 |
+
payload = {
|
| 1009 |
+
"_comment": "Call trace of the seven published models, recorded from "
|
| 1010 |
+
"spaces/app.py as it stood BEFORE the 2.0 / ura wiring "
|
| 1011 |
+
"change (source_sha256 identifies that exact text). "
|
| 1012 |
+
"Regenerating this file from a newer app defeats its "
|
| 1013 |
+
"purpose: the point is that the published models still do "
|
| 1014 |
+
"what they did here, call for call and byte for byte -- "
|
| 1015 |
+
"the arguments include the full generated TJA.",
|
| 1016 |
+
"source_app": os.path.basename(app_path),
|
| 1017 |
+
"source_sha256": digest,
|
| 1018 |
+
"models": list(module.MODELS),
|
| 1019 |
+
"scenarios": legacy_trace(module),
|
| 1020 |
+
}
|
| 1021 |
+
os.makedirs(os.path.dirname(GOLDEN), exist_ok=True)
|
| 1022 |
+
with open(GOLDEN, "w", encoding="utf-8") as f:
|
| 1023 |
+
json.dump(payload, f, indent=1, ensure_ascii=False, sort_keys=True)
|
| 1024 |
+
f.write("\n")
|
| 1025 |
+
print(f"recorded {len(payload['scenarios'])} scenarios -> {GOLDEN}")
|
| 1026 |
+
return 0
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
def main():
|
| 1030 |
+
tests = [test_routing, test_courses, test_course_header,
|
| 1031 |
+
test_legacy_regression, test_planner_fallback,
|
| 1032 |
+
test_sc2_conditions, test_sc2_package_resolution,
|
| 1033 |
+
test_capabilities_payload, test_sc2_real_decode]
|
| 1034 |
+
for test in tests:
|
| 1035 |
+
print(f"\n--- {test.__name__}")
|
| 1036 |
+
try:
|
| 1037 |
+
test()
|
| 1038 |
+
except Exception as exc: # noqa: BLE001
|
| 1039 |
+
import traceback
|
| 1040 |
+
traceback.print_exc()
|
| 1041 |
+
check(test.__name__, False, f"{type(exc).__name__}: {exc}")
|
| 1042 |
+
failed = [name for name, ok, _ in RESULTS if not ok]
|
| 1043 |
+
print(f"\n{len(RESULTS) - len(failed)}/{len(RESULTS)} checks passed")
|
| 1044 |
+
for name in failed:
|
| 1045 |
+
print(f" FAILED: {name}")
|
| 1046 |
+
return 1 if failed else 0
|
| 1047 |
+
|
| 1048 |
+
|
| 1049 |
+
if __name__ == "__main__":
|
| 1050 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 1051 |
+
parser.add_argument("--record", metavar="OLD_APP.py",
|
| 1052 |
+
help="re-record the golden legacy trace from this app")
|
| 1053 |
+
args = parser.parse_args()
|
| 1054 |
+
sys.exit(record_golden(args.record) if args.record else main())
|
scripts/test_sc2_vendor.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU acceptance for the vendored BarScript serving path.
|
| 2 |
+
|
| 3 |
+
Three questions, and nothing that needs a GPU or the network:
|
| 4 |
+
|
| 5 |
+
(a) does the BarScript model load from the release package ALONE -- with the
|
| 6 |
+
development worktree stripped out of ``sys.path`` -- and does it read
|
| 7 |
+
nothing outside that package?
|
| 8 |
+
(b) does it generate a chart whose TJA the vendored parser reads back?
|
| 9 |
+
(c) is the 1.x serving path the Space's seven published models use bit-for-bit
|
| 10 |
+
unaffected: the library files untouched, the public signatures unchanged,
|
| 11 |
+
the two namespaces isolated, and a real 1.x decode identical before and
|
| 12 |
+
after ``softchart.sc2`` is imported?
|
| 13 |
+
|
| 14 |
+
Run: python spaces/scripts/test_sc2_vendor.py
|
| 15 |
+
pytest spaces/scripts/test_sc2_vendor.py
|
| 16 |
+
|
| 17 |
+
(c)'s decode check needs ``JacobLinCool/softchart-v17`` in the local Hub
|
| 18 |
+
cache; it reports SKIP, and does not fail, when the cache is cold, because it
|
| 19 |
+
never goes to the network.
|
| 20 |
+
|
| 21 |
+
The order-sensitive checks run in subprocesses of this file (``--case``): a
|
| 22 |
+
1.x baseline must be measured in a process where ``softchart.sc2`` was never
|
| 23 |
+
imported, and the package-only load must happen before anything else can put
|
| 24 |
+
the worktree back on the path.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import hashlib
|
| 31 |
+
import inspect
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import subprocess
|
| 35 |
+
import sys
|
| 36 |
+
import warnings
|
| 37 |
+
|
| 38 |
+
SPACES_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 39 |
+
REPO_ROOT = os.path.dirname(SPACES_DIR)
|
| 40 |
+
SC2_DIR = os.path.join(SPACES_DIR, "softchart", "sc2")
|
| 41 |
+
LEGACY_REPO = os.environ.get("SC_LEGACY_TEST_REPO", "JacobLinCool/softchart-v17")
|
| 42 |
+
|
| 43 |
+
if SPACES_DIR not in sys.path:
|
| 44 |
+
sys.path.insert(0, SPACES_DIR)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# (c) tripwires: the 1.x library as it stood when 2.0 was vendored in.
|
| 49 |
+
# Vendoring added files; it changed none. A mismatch here means someone edited
|
| 50 |
+
# a module the seven published checkpoints decode through, and their charts are
|
| 51 |
+
# no longer the baseline the user keeps them around to compare against.
|
| 52 |
+
|
| 53 |
+
LEGACY_MD5 = {
|
| 54 |
+
"__init__.py": "d41d8cd98f00b204e9800998ecf8427e",
|
| 55 |
+
"baseline.py": "875b63450c59bb62f5d50965ec7b218c",
|
| 56 |
+
"data.py": "72e19832477dd49e2bb0dc731e17b3ac",
|
| 57 |
+
"evaluate.py": "13840e66287b1113202d4ea0152f62ba",
|
| 58 |
+
"fonts.py": "a6b1c5c1d3f9ab852fb551d49031d3a2",
|
| 59 |
+
"generate.py": "b3ad2aa451ed413f1d9d64eafb072cd8",
|
| 60 |
+
"grid.py": "570ae31b7120369781cd02c20de74ffd",
|
| 61 |
+
"hf.py": "522c2257a402bd0d41727c6dee6732db",
|
| 62 |
+
"model.py": "5e8ee5091629c9ff718d7c136d5f97bc",
|
| 63 |
+
"preprocess.py": "1d765a119d909f2aa74d594f4ca6b9c0",
|
| 64 |
+
"preview_audio.py": "b03aec51b160ccb33d8d553dd936d2c4",
|
| 65 |
+
"proxies.py": "918c72c22d48e4ccb762e0fe814d0bfe",
|
| 66 |
+
"rhythm.py": "11cca7c58809b339f7384dcc0bf1fcaa",
|
| 67 |
+
"tja.py": "8e3905fc3b432026f034548ba8422b06",
|
| 68 |
+
"tja_image.py": "afa678700e033206d9b80f9510dfc844",
|
| 69 |
+
"train.py": "9993791112402dfee700e62cb860c470",
|
| 70 |
+
"vocab.py": "46c4df35768ea7eee5cebccf63cc6c9d",
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
# The 1.x entry points app.py calls. Signature text, not just presence: a
|
| 74 |
+
# reordered or renamed keyword is a silent behaviour change for the app.
|
| 75 |
+
LEGACY_SIGNATURES = {
|
| 76 |
+
"generate.generate_song": (
|
| 77 |
+
"(model, mel, course, level=None, density_bucket=None, device='cuda', "
|
| 78 |
+
"greedy=False, temperature=1.0, top_p=0.95, seed=0, batch_windows=8, "
|
| 79 |
+
"cfg_w=0.0, use_ctx=None, lattice=False, style=None, sync_band=None, "
|
| 80 |
+
"plan=None, on_progress=None)"),
|
| 81 |
+
"generate.generate_song_slot": (
|
| 82 |
+
"(model, mel, grid, course, level=None, density_bucket=None, "
|
| 83 |
+
"device='cuda', greedy=False, temperature=1.0, top_p=0.95, seed=0, "
|
| 84 |
+
"batch_windows=8, plan=None, on_progress=None)"),
|
| 85 |
+
"generate.load_hf": "(repo_or_dir, device='cuda')",
|
| 86 |
+
"tja.write_tja_slots": (
|
| 87 |
+
"(gen, grid, title, course, level, wave, out_path=None, "
|
| 88 |
+
"balloon_count=10, plan=None)"),
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
# 1.x constants the app reads, and the vocabulary geometry every published
|
| 92 |
+
# checkpoint's embedding table is indexed by.
|
| 93 |
+
LEGACY_CONSTANTS = {
|
| 94 |
+
"SR": 22050, "N_FFT": 2048, "HOP": 256, "N_MELS": 128,
|
| 95 |
+
"WINDOW": 1728, "MAX_TGT": 1536, "SLOTS": 96, "N_DENS": 16,
|
| 96 |
+
"N_LEVELS": 12, "VOCAB_SIZE": 1810,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
MEL_FRAMES = 1500 # ~17.4 s at 86.13 fps -- one decode window plus change
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _md5(path):
|
| 103 |
+
h = hashlib.md5()
|
| 104 |
+
with open(path, "rb") as f:
|
| 105 |
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
| 106 |
+
h.update(chunk)
|
| 107 |
+
return h.hexdigest()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _fake_mel(frames=MEL_FRAMES, seed=0):
|
| 111 |
+
"""A deterministic stand-in for a log-mel spectrogram.
|
| 112 |
+
|
| 113 |
+
The acceptance here is mechanical -- loads, decodes, exports, re-parses --
|
| 114 |
+
so the audio need only be well-formed and reproducible. Chart quality is
|
| 115 |
+
the model card's subject, not this file's.
|
| 116 |
+
"""
|
| 117 |
+
import numpy as np
|
| 118 |
+
|
| 119 |
+
rng = np.random.default_rng(seed)
|
| 120 |
+
t = np.arange(frames) / 86.1328125
|
| 121 |
+
pulse = np.sin(2 * np.pi * 2.0 * t)[None, :] + 1.0
|
| 122 |
+
return np.log(1e-5 + np.abs(rng.standard_normal((128, frames)) * 0.1
|
| 123 |
+
+ pulse)).astype(np.float32)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _run_case(case):
|
| 127 |
+
"""Run one order-sensitive case in a clean subprocess of this file."""
|
| 128 |
+
env = dict(os.environ, HF_HUB_OFFLINE="1", PYTHONWARNINGS="ignore")
|
| 129 |
+
proc = subprocess.run([sys.executable, os.path.abspath(__file__),
|
| 130 |
+
"--case", case],
|
| 131 |
+
capture_output=True, text=True, env=env,
|
| 132 |
+
cwd=REPO_ROOT)
|
| 133 |
+
if proc.returncode != 0:
|
| 134 |
+
raise AssertionError(
|
| 135 |
+
f"case {case!r} failed (exit {proc.returncode}):\n"
|
| 136 |
+
f"{proc.stdout[-4000:]}\n{proc.stderr[-4000:]}")
|
| 137 |
+
for line in reversed(proc.stdout.splitlines()):
|
| 138 |
+
if line.startswith("RESULT "):
|
| 139 |
+
return json.loads(line[len("RESULT "):])
|
| 140 |
+
raise AssertionError(f"case {case!r} printed no RESULT:\n{proc.stdout}")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# (a) package-only load
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def case_package_only_load():
|
| 148 |
+
"""Load with the development worktree removed from the path, and watch every read.
|
| 149 |
+
|
| 150 |
+
``sys.path`` is scrubbed of anything under ``.codex/worktrees`` first, so
|
| 151 |
+
an accidental ``import softchart.<module>`` resolving to the frozen
|
| 152 |
+
shipping tree would fail rather than quietly succeed. Python-level file
|
| 153 |
+
opens are recorded; every one that lands inside this repository must be
|
| 154 |
+
inside the package directory or the vendored ``softchart/sc2`` tree.
|
| 155 |
+
"""
|
| 156 |
+
import builtins
|
| 157 |
+
|
| 158 |
+
# Put the development worktree on the path first, so the scrub has something
|
| 159 |
+
# real to remove: a load that only passes because the worktree happened to
|
| 160 |
+
# be absent would prove nothing.
|
| 161 |
+
worktree_src = os.path.join(REPO_ROOT, ".codex", "worktrees", "v18-8h",
|
| 162 |
+
"src")
|
| 163 |
+
if os.path.isdir(worktree_src):
|
| 164 |
+
sys.path.insert(0, worktree_src)
|
| 165 |
+
removed = [p for p in sys.path if ".codex" + os.sep + "worktrees" in p]
|
| 166 |
+
sys.path[:] = [p for p in sys.path if p not in removed]
|
| 167 |
+
for name in [n for n in sys.modules
|
| 168 |
+
if n == "softchart" or n.startswith("softchart.")]:
|
| 169 |
+
del sys.modules[name]
|
| 170 |
+
|
| 171 |
+
# modules that exist only in the worktree tree must now be unreachable
|
| 172 |
+
try:
|
| 173 |
+
__import__("softchart.barscript")
|
| 174 |
+
worktree_reachable = True
|
| 175 |
+
except ImportError:
|
| 176 |
+
worktree_reachable = False
|
| 177 |
+
for name in [n for n in sys.modules
|
| 178 |
+
if n == "softchart" or n.startswith("softchart.")]:
|
| 179 |
+
del sys.modules[name]
|
| 180 |
+
|
| 181 |
+
opened = []
|
| 182 |
+
real_open = builtins.open
|
| 183 |
+
|
| 184 |
+
def watched_open(file, *a, **kw):
|
| 185 |
+
try:
|
| 186 |
+
opened.append(os.path.abspath(os.fspath(file)))
|
| 187 |
+
except TypeError:
|
| 188 |
+
pass
|
| 189 |
+
return real_open(file, *a, **kw)
|
| 190 |
+
|
| 191 |
+
from softchart import sc2_loader as loader
|
| 192 |
+
|
| 193 |
+
package_dir = os.path.abspath(loader.default_package_dir())
|
| 194 |
+
builtins.open = watched_open
|
| 195 |
+
try:
|
| 196 |
+
model, config, spec = loader.load_release(package_dir, device="cpu")
|
| 197 |
+
finally:
|
| 198 |
+
builtins.open = real_open
|
| 199 |
+
|
| 200 |
+
stray = [p for p in opened
|
| 201 |
+
if p.startswith(REPO_ROOT + os.sep)
|
| 202 |
+
and not p.startswith(package_dir + os.sep)
|
| 203 |
+
and not p.startswith(SC2_DIR + os.sep)]
|
| 204 |
+
outside = [m.__file__ for name, m in sys.modules.items()
|
| 205 |
+
if name.startswith("softchart.sc2.")
|
| 206 |
+
and getattr(m, "__file__", None)
|
| 207 |
+
and not os.path.abspath(m.__file__).startswith(SC2_DIR + os.sep)]
|
| 208 |
+
|
| 209 |
+
return {
|
| 210 |
+
"package_dir": package_dir,
|
| 211 |
+
"worktree_entries_removed": removed,
|
| 212 |
+
"worktree_src_exists": os.path.isdir(worktree_src),
|
| 213 |
+
"worktree_reachable_after_scrub": worktree_reachable,
|
| 214 |
+
"release_name": config["release_name"],
|
| 215 |
+
"parameters": int(config["weights"]["parameters"]),
|
| 216 |
+
"vocab_size": int(config["vocab"]["size"]),
|
| 217 |
+
"has_sync_forced": bool(getattr(model, "_has_sync", False)),
|
| 218 |
+
"has_axis_knobs": bool(getattr(model, "_has_axis_knobs", False)),
|
| 219 |
+
"motif_hard": config["train_provenance"]["motif_constraint"][
|
| 220 |
+
"resolved_motif_hard"],
|
| 221 |
+
"spec_labels_version": spec.labels_version,
|
| 222 |
+
"files_opened_in_repo": len([p for p in opened
|
| 223 |
+
if p.startswith(REPO_ROOT + os.sep)]),
|
| 224 |
+
"stray_reads": stray,
|
| 225 |
+
"sc2_modules_outside_vendor_dir": outside,
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_a_package_only_load():
|
| 230 |
+
r = _run_case("package-only-load")
|
| 231 |
+
if r["worktree_src_exists"]:
|
| 232 |
+
assert r["worktree_entries_removed"], "the scrub removed nothing"
|
| 233 |
+
assert r["worktree_reachable_after_scrub"] is False, (
|
| 234 |
+
"the development worktree is still importable after the scrub; the load below "
|
| 235 |
+
"proves nothing")
|
| 236 |
+
assert not r["stray_reads"], f"read outside the package: {r['stray_reads']}"
|
| 237 |
+
assert not r["sc2_modules_outside_vendor_dir"], r[
|
| 238 |
+
"sc2_modules_outside_vendor_dir"]
|
| 239 |
+
assert r["vocab_size"] == 2071, r["vocab_size"]
|
| 240 |
+
# config.serving_prefix_fix: the recorded sync_token flag is wrong and the
|
| 241 |
+
# loader must override it, or every sync bucket decodes identically.
|
| 242 |
+
assert r["has_sync_forced"] is True
|
| 243 |
+
assert r["has_axis_knobs"] is True
|
| 244 |
+
assert r["motif_hard"] is False, "package resolved motif_hard off"
|
| 245 |
+
assert r["spec_labels_version"] == "barscript_labels_v1"
|
| 246 |
+
return r
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ---------------------------------------------------------------------------
|
| 250 |
+
# (b) generate and re-parse
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def test_b_generate_and_reparse():
|
| 254 |
+
from softchart import sc2_loader as loader
|
| 255 |
+
from softchart.sc2.trace import chart_to_trace, parse_tja
|
| 256 |
+
|
| 257 |
+
sc = loader.load(device="cpu")
|
| 258 |
+
|
| 259 |
+
# the front end app.py computes must be the one the package was trained on
|
| 260 |
+
from softchart.vocab import HOP, N_FFT, N_MELS, SR
|
| 261 |
+
|
| 262 |
+
loader.check_preprocessing(sc.package_dir, sr=SR, n_fft=N_FFT,
|
| 263 |
+
hop_length=HOP, n_mels=N_MELS, fmin=20.0,
|
| 264 |
+
fmax=SR / 2)
|
| 265 |
+
|
| 266 |
+
out = {}
|
| 267 |
+
for course, level, dens in (("oni", 8, 6), ("ura", 10, 6), ("easy", 3, 1)):
|
| 268 |
+
r = sc.generate(_fake_mel(), course, level=level, bpm=150.0,
|
| 269 |
+
offset_sec=0.12, density_bucket=dens, seed=7,
|
| 270 |
+
title="vendor-acceptance", wave="acceptance.ogg")
|
| 271 |
+
charts = parse_tja(r["tja"])
|
| 272 |
+
assert len(charts) == 1, f"{course}: expected one chart section"
|
| 273 |
+
# chart_to_trace is the real acceptance: it rebuilds bar edges, tempo
|
| 274 |
+
# and meter from the exported text and raises on anything inconsistent.
|
| 275 |
+
trace = chart_to_trace(charts[0])
|
| 276 |
+
assert len(trace.bars) == r["grid"]["n_bars"], (
|
| 277 |
+
len(trace.bars), r["grid"]["n_bars"])
|
| 278 |
+
n_notes = sum(c in "1234" for b in charts[0].bars for c in b.digits)
|
| 279 |
+
n_spans = sum(c in "567" for b in charts[0].bars for c in b.digits)
|
| 280 |
+
assert n_spans == r["n_spans"], (n_spans, r["n_spans"])
|
| 281 |
+
|
| 282 |
+
assert r["grid"]["denom"] == 16, r["grid"]
|
| 283 |
+
assert r["grid"]["triplets_representable"] is False
|
| 284 |
+
assert r["grid"]["bar_denoms"] == "supplied", r["grid"]
|
| 285 |
+
assert r["serving"]["min_onset_gap_sec"] == 0.02, r["serving"]
|
| 286 |
+
assert r["serving"]["temperature"] == 1.0 and r["serving"]["top_p"] == 0.95
|
| 287 |
+
assert r["serving_overrides"] == {}
|
| 288 |
+
assert r["motif"]["verified"] is True, r["motif"]
|
| 289 |
+
assert r["experimental"] is True
|
| 290 |
+
assert n_notes == r["n_notes"], (n_notes, r["n_notes"])
|
| 291 |
+
|
| 292 |
+
header = [ln for ln in r["tja"].splitlines() if ln.startswith("COURSE:")]
|
| 293 |
+
assert header == [f"COURSE:{loader.TJA_COURSE_HEADER[course]}"], header
|
| 294 |
+
assert charts[0].course == course, (charts[0].course, course)
|
| 295 |
+
out[course] = {"n_notes": r["n_notes"], "n_spans": r["n_spans"],
|
| 296 |
+
"n_bars": r["grid"]["n_bars"], "header": header[0],
|
| 297 |
+
"reparsed_notes": n_notes}
|
| 298 |
+
|
| 299 |
+
# same seed, same conditions, same chart
|
| 300 |
+
a = sc.generate(_fake_mel(), "oni", level=8, bpm=150.0, offset_sec=0.12,
|
| 301 |
+
density_bucket=6, seed=7)["tja"]
|
| 302 |
+
b = sc.generate(_fake_mel(), "oni", level=8, bpm=150.0, offset_sec=0.12,
|
| 303 |
+
density_bucket=6, seed=7)["tja"]
|
| 304 |
+
assert a == b, "BarScript decode is not reproducible at a fixed seed"
|
| 305 |
+
out["reproducible"] = True
|
| 306 |
+
return out
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ---------------------------------------------------------------------------
|
| 310 |
+
# (c) the 1.x path is untouched
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def test_c_legacy_library_untouched():
|
| 314 |
+
lib = os.path.join(SPACES_DIR, "softchart")
|
| 315 |
+
drift = {name: _md5(os.path.join(lib, name))
|
| 316 |
+
for name in sorted(LEGACY_MD5)
|
| 317 |
+
if _md5(os.path.join(lib, name)) != LEGACY_MD5[name]}
|
| 318 |
+
assert not drift, (
|
| 319 |
+
"1.x library modules changed since 2.0 was vendored in; the seven "
|
| 320 |
+
f"published models are no longer a fixed baseline: {drift}")
|
| 321 |
+
|
| 322 |
+
added = sorted(n for n in os.listdir(lib)
|
| 323 |
+
if n.endswith(".py") and n not in LEGACY_MD5)
|
| 324 |
+
assert added == ["sc2_loader.py"], added
|
| 325 |
+
return {"unchanged": len(LEGACY_MD5), "added": added}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def test_c_legacy_api_unchanged():
|
| 329 |
+
from softchart import generate as v1_generate
|
| 330 |
+
from softchart import tja as v1_tja
|
| 331 |
+
from softchart import vocab as v1_vocab
|
| 332 |
+
|
| 333 |
+
mods = {"generate": v1_generate, "tja": v1_tja}
|
| 334 |
+
bad = {}
|
| 335 |
+
for key, want in LEGACY_SIGNATURES.items():
|
| 336 |
+
mod, fn = key.split(".")
|
| 337 |
+
got = str(inspect.signature(getattr(mods[mod], fn)))
|
| 338 |
+
got = got.replace('"', "'")
|
| 339 |
+
if got != want:
|
| 340 |
+
bad[key] = got
|
| 341 |
+
assert not bad, f"1.x public signatures changed: {bad}"
|
| 342 |
+
|
| 343 |
+
got = {k: getattr(v1_vocab, k) for k in LEGACY_CONSTANTS if k != "VOCAB_SIZE"}
|
| 344 |
+
got["VOCAB_SIZE"] = v1_vocab.VOCAB.size
|
| 345 |
+
assert got == LEGACY_CONSTANTS, f"1.x constants changed: {got}"
|
| 346 |
+
return got
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def test_c_namespaces_isolated():
|
| 350 |
+
"""The two token tables coexist and neither can reach the other."""
|
| 351 |
+
from softchart import vocab as v1_vocab
|
| 352 |
+
from softchart.sc2 import vocab as v2_vocab
|
| 353 |
+
|
| 354 |
+
assert v1_vocab is not v2_vocab
|
| 355 |
+
assert v1_vocab.VOCAB.size == 1810 and v2_vocab.VOCAB.size == 2071
|
| 356 |
+
assert v1_vocab.MAX_TGT == 1536 and v2_vocab.MAX_TGT == 960
|
| 357 |
+
|
| 358 |
+
# every sc2 module resolves its imports inside the sc2 package
|
| 359 |
+
from softchart.sc2 import generate as v2_generate
|
| 360 |
+
from softchart.sc2 import model as v2_model
|
| 361 |
+
|
| 362 |
+
assert os.path.abspath(v2_generate.__file__).startswith(SC2_DIR + os.sep)
|
| 363 |
+
assert v2_generate.VOCAB is v2_vocab.VOCAB
|
| 364 |
+
assert v2_model.__name__ == "softchart.sc2.model"
|
| 365 |
+
|
| 366 |
+
from softchart import generate as v1_generate
|
| 367 |
+
|
| 368 |
+
assert v1_generate.VOCAB is v1_vocab.VOCAB
|
| 369 |
+
assert v1_generate is not v2_generate
|
| 370 |
+
return {"v1_vocab": v1_vocab.VOCAB.size, "v2_vocab": v2_vocab.VOCAB.size}
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _legacy_decode():
|
| 374 |
+
"""One 1.x decode, in both the time and the slot regime app.py uses."""
|
| 375 |
+
import numpy as np
|
| 376 |
+
|
| 377 |
+
from softchart.generate import (generate_song, generate_song_slot, load_hf)
|
| 378 |
+
|
| 379 |
+
model = load_hf(LEGACY_REPO, device="cpu")
|
| 380 |
+
mel = _fake_mel()
|
| 381 |
+
dur = mel.shape[1] / 86.1328125
|
| 382 |
+
time_gen = generate_song(model, mel, "oni", level=8, density_bucket=7,
|
| 383 |
+
device="cpu", seed=0)
|
| 384 |
+
bar = 240.0 / 150.0
|
| 385 |
+
edges = np.arange(0.0, dur + bar, bar)
|
| 386 |
+
grid = {"bpm": 150.0, "downbeats": edges[:-1], "bar": bar,
|
| 387 |
+
"measure_edges": edges}
|
| 388 |
+
slot_gen = generate_song_slot(model, mel, grid, "oni", level=8,
|
| 389 |
+
density_bucket=7, device="cpu", seed=0)
|
| 390 |
+
payload = {
|
| 391 |
+
"time": [time_gen["hits"], time_gen["spans"]],
|
| 392 |
+
"slot": [[list(map(_jsonable, h)) for h in slot_gen["hits_slots"]],
|
| 393 |
+
[list(map(_jsonable, s)) for s in slot_gen["spans_slots"]]],
|
| 394 |
+
}
|
| 395 |
+
blob = json.dumps(payload, sort_keys=True, default=_jsonable)
|
| 396 |
+
return {"sha256": hashlib.sha256(blob.encode()).hexdigest(),
|
| 397 |
+
"n_time_hits": len(time_gen["hits"]),
|
| 398 |
+
"n_slot_hits": len(slot_gen["hits_slots"])}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _jsonable(x):
|
| 402 |
+
import numpy as np
|
| 403 |
+
|
| 404 |
+
if isinstance(x, (np.integer,)):
|
| 405 |
+
return int(x)
|
| 406 |
+
if isinstance(x, (np.floating,)):
|
| 407 |
+
return float(x)
|
| 408 |
+
return x
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def case_legacy_baseline():
|
| 412 |
+
return _legacy_decode()
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def case_legacy_after_sc2():
|
| 416 |
+
"""The same 1.x decode, after 2.0 is fully imported AND loaded.
|
| 417 |
+
|
| 418 |
+
This is the regression the user's comparison depends on: 2.0 living in the
|
| 419 |
+
same process must not move a single note of a 1.x chart.
|
| 420 |
+
"""
|
| 421 |
+
from softchart import sc2_loader as loader
|
| 422 |
+
|
| 423 |
+
sc = loader.load(device="cpu")
|
| 424 |
+
sc.generate(_fake_mel(600), "oni", level=8, bpm=150.0, offset_sec=0.0,
|
| 425 |
+
density_bucket=6, seed=1)
|
| 426 |
+
out = _legacy_decode()
|
| 427 |
+
out["sc2_release"] = sc.release_name
|
| 428 |
+
return out
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _legacy_weights_cached():
|
| 432 |
+
try:
|
| 433 |
+
from huggingface_hub import try_to_load_from_cache
|
| 434 |
+
except Exception:
|
| 435 |
+
return False
|
| 436 |
+
for name in ("model.safetensors", "config.json"):
|
| 437 |
+
hit = try_to_load_from_cache(LEGACY_REPO, name)
|
| 438 |
+
if not isinstance(hit, str):
|
| 439 |
+
return False
|
| 440 |
+
return True
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def test_c_legacy_decode_unchanged():
|
| 444 |
+
if not _legacy_weights_cached():
|
| 445 |
+
return {"skipped": f"{LEGACY_REPO} is not in the local Hub cache"}
|
| 446 |
+
before = _run_case("legacy-baseline")
|
| 447 |
+
after = _run_case("legacy-after-sc2")
|
| 448 |
+
assert before["sha256"] == after["sha256"], (
|
| 449 |
+
"a 1.x decode changed once softchart.sc2 was imported: "
|
| 450 |
+
f"{before} != {after}")
|
| 451 |
+
return {"sha256": before["sha256"], "n_time_hits": before["n_time_hits"],
|
| 452 |
+
"n_slot_hits": before["n_slot_hits"],
|
| 453 |
+
"sc2_release": after["sc2_release"]}
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
# ---------------------------------------------------------------------------
|
| 457 |
+
|
| 458 |
+
CASES = {
|
| 459 |
+
"package-only-load": case_package_only_load,
|
| 460 |
+
"legacy-baseline": case_legacy_baseline,
|
| 461 |
+
"legacy-after-sc2": case_legacy_after_sc2,
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
CHECKS = (
|
| 465 |
+
("(a) package-only load", test_a_package_only_load),
|
| 466 |
+
("(b) generate + re-parse", test_b_generate_and_reparse),
|
| 467 |
+
("(c) 1.x library untouched", test_c_legacy_library_untouched),
|
| 468 |
+
("(c) 1.x API unchanged", test_c_legacy_api_unchanged),
|
| 469 |
+
("(c) namespaces isolated", test_c_namespaces_isolated),
|
| 470 |
+
("(c) 1.x decode unchanged", test_c_legacy_decode_unchanged),
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def main(argv=None):
|
| 475 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 476 |
+
ap.add_argument("--case", choices=sorted(CASES),
|
| 477 |
+
help="internal: run one subprocess case and print RESULT")
|
| 478 |
+
args = ap.parse_args(argv)
|
| 479 |
+
warnings.filterwarnings("ignore")
|
| 480 |
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
| 481 |
+
|
| 482 |
+
if args.case:
|
| 483 |
+
print("RESULT " + json.dumps(CASES[args.case](), default=str))
|
| 484 |
+
return 0
|
| 485 |
+
|
| 486 |
+
failures = []
|
| 487 |
+
for label, check in CHECKS:
|
| 488 |
+
try:
|
| 489 |
+
detail = check()
|
| 490 |
+
except Exception as exc: # noqa: BLE001 - a report, not a traceback
|
| 491 |
+
failures.append(label)
|
| 492 |
+
print(f"FAIL {label}\n {type(exc).__name__}: {exc}")
|
| 493 |
+
else:
|
| 494 |
+
mark = "SKIP" if isinstance(detail, dict) and "skipped" in detail \
|
| 495 |
+
else "PASS"
|
| 496 |
+
print(f"{mark} {label}\n "
|
| 497 |
+
+ json.dumps(detail, default=str)[:600])
|
| 498 |
+
print(("FAILED: " + ", ".join(failures)) if failures
|
| 499 |
+
else "all checks passed")
|
| 500 |
+
return 1 if failures else 0
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
if __name__ == "__main__":
|
| 504 |
+
raise SystemExit(main())
|
scripts/test_space_e2e.py
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end acceptance for the Space: real weights, real music, real app entry.
|
| 2 |
+
|
| 3 |
+
The other two suites stop short of this on purpose. ``test_sc2_vendor.py``
|
| 4 |
+
proves the vendored 2.0 tree is the one the release package describes, and
|
| 5 |
+
``test_app_wiring.py`` proves the routing by replaying every model as a
|
| 6 |
+
recorder over a synthetic mel. Neither one ever decodes real music, so
|
| 7 |
+
neither can say whether a chart comes out of ``app.generate_chart`` intact.
|
| 8 |
+
|
| 9 |
+
This file drives ``app.generate_chart`` itself -- the same generator the
|
| 10 |
+
frontend consumes, with a ``gr.FileData`` upload -- on held-out songs, and
|
| 11 |
+
inspects the artifacts it actually produced.
|
| 12 |
+
|
| 13 |
+
Two questions:
|
| 14 |
+
|
| 15 |
+
1. **Does 2.0 produce a well-formed chart on every difficulty?** For each
|
| 16 |
+
song x {easy, normal, hard, oni, ura} the emitted TJA is re-parsed with
|
| 17 |
+
``softchart.sc2.trace`` and checked for: a single course section whose
|
| 18 |
+
``COURSE:`` header resolves back to the course that was asked for (this is
|
| 19 |
+
what makes ``COURSE:Edit`` -> ``ura`` a semantic check and not a string
|
| 20 |
+
comparison); a bar count consistent with the tempo and excerpt length;
|
| 21 |
+
96-digit bars whose every note sits on the shipped /16 lattice; balanced,
|
| 22 |
+
properly ordered drumroll spans; and a note total that agrees with the
|
| 23 |
+
count the UI reported.
|
| 24 |
+
|
| 25 |
+
The suite also RECORDS the difficulty ladder rather than assuming it. The
|
| 26 |
+
model card (§2.1) says the ladder is not nested, and ura carries 208
|
| 27 |
+
training charts against ~1 100 elsewhere, so "ura is harder than oni" is
|
| 28 |
+
an open question. ``--strict-ladder`` turns the ladder into a hard
|
| 29 |
+
failure; by default it is reported, because a monotonicity failure here is
|
| 30 |
+
a MODEL result to be published, not a wiring bug to be hidden.
|
| 31 |
+
|
| 32 |
+
2. **Do the published models still behave exactly as before?** Same songs,
|
| 33 |
+
same controls, run through both the current ``app.py`` and
|
| 34 |
+
``fixtures/app_baseline.py`` -- the app as it stood BEFORE the 2.0/ura
|
| 35 |
+
change, pinned by sha256 to the same source the wiring suite's golden
|
| 36 |
+
trace was recorded from. Both run with REAL Hub weights, and the
|
| 37 |
+
comparison is over the full TJA text plus the reported metrics. The
|
| 38 |
+
wiring suite compares call arguments under mocks; this one compares
|
| 39 |
+
generated charts.
|
| 40 |
+
|
| 41 |
+
Fixtures. Preferred source is the held-out ``test`` split of
|
| 42 |
+
``JacobLinCool/taiko-1000-parsed`` if it is in the local HuggingFace cache:
|
| 43 |
+
those songs are real music the models never trained on, and they ship
|
| 44 |
+
authored charts for all five courses, which gives the difficulty ladder a
|
| 45 |
+
reference to be read against. Otherwise pass ``--audio-dir`` with any
|
| 46 |
+
directory of audio files (the ladder is then reported without a reference).
|
| 47 |
+
Nothing is downloaded.
|
| 48 |
+
|
| 49 |
+
python spaces/scripts/test_space_e2e.py
|
| 50 |
+
python spaces/scripts/test_space_e2e.py --songs 2 --skip-legacy
|
| 51 |
+
python spaces/scripts/test_space_e2e.py --audio-dir ../other --json report.json
|
| 52 |
+
|
| 53 |
+
Runs on CPU. Legacy models are read from the local HuggingFace cache; export
|
| 54 |
+
``HF_HUB_OFFLINE=1`` to prove no network is involved.
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
import argparse
|
| 58 |
+
import collections
|
| 59 |
+
import glob
|
| 60 |
+
import hashlib
|
| 61 |
+
import io
|
| 62 |
+
import json
|
| 63 |
+
import os
|
| 64 |
+
import statistics
|
| 65 |
+
import sys
|
| 66 |
+
import tempfile
|
| 67 |
+
import time
|
| 68 |
+
|
| 69 |
+
SPACES = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 70 |
+
if SPACES not in sys.path:
|
| 71 |
+
sys.path.insert(0, SPACES)
|
| 72 |
+
|
| 73 |
+
FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures")
|
| 74 |
+
BASELINE_APP = os.path.join(FIXTURES, "app_baseline.py")
|
| 75 |
+
GOLDEN = os.path.join(FIXTURES, "legacy_call_trace.json")
|
| 76 |
+
|
| 77 |
+
ALL_COURSES = ("easy", "normal", "hard", "oni", "ura")
|
| 78 |
+
# Published models replayed against the baseline. v1.7 is the default the UI
|
| 79 |
+
# recommends and v1.5 is the oldest still selectable, so between them they
|
| 80 |
+
# cover both ends of the published range; both are full-size, so a difference
|
| 81 |
+
# cannot hide behind a small model's noise.
|
| 82 |
+
LEGACY_MODELS = ("v1.7", "v1.5")
|
| 83 |
+
LEGACY_COURSES = ("hard", "oni")
|
| 84 |
+
|
| 85 |
+
# The TJA export lattice: write_tja_slots emits SUB=96 digits per 4/4 bar.
|
| 86 |
+
SUB = 96
|
| 87 |
+
# The deployment grid (sc2_loader.SHIP_GRID_DENOM). 96/16 = 6, so a note that
|
| 88 |
+
# is on the /16 lattice sits at a digit index divisible by 6.
|
| 89 |
+
SHIP_DENOM = 16
|
| 90 |
+
|
| 91 |
+
SPAN_OPEN = {"5", "6", "7"} # roll, big roll, balloon
|
| 92 |
+
SPAN_CLOSE = "8"
|
| 93 |
+
HIT_CHARS = {"1", "2", "3", "4"}
|
| 94 |
+
|
| 95 |
+
# The TJA `COURSE:` literal each course must be WRITTEN as, measured over all
|
| 96 |
+
# 1 155 songs of JacobLinCool/taiko-1000-parsed: the 229 ura charts are headed
|
| 97 |
+
# `Edit` (223) or the numeric `4` (6), and the string "Ura" occurs zero times
|
| 98 |
+
# anywhere in the corpus.
|
| 99 |
+
#
|
| 100 |
+
# The literal is asserted separately from the re-parse because
|
| 101 |
+
# ``softchart.sc2.trace`` is deliberately lenient -- its alias table maps
|
| 102 |
+
# "ura" -> ura -- so a chart written `COURSE:Ura` round-trips through this
|
| 103 |
+
# repo's own parser and would pass a semantic-only check while still being
|
| 104 |
+
# the exact defect the header rewrite exists to prevent.
|
| 105 |
+
CORPUS_COURSE_LITERAL = {"easy": "Easy", "normal": "Normal", "hard": "Hard",
|
| 106 |
+
"oni": "Oni", "ura": "Edit"}
|
| 107 |
+
|
| 108 |
+
RESULTS = []
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def check(name, ok, detail=""):
|
| 112 |
+
RESULTS.append((name, bool(ok), detail))
|
| 113 |
+
print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
|
| 114 |
+
return bool(ok)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def note(name, detail):
|
| 118 |
+
"""Record an observation that is reported but never fails the suite."""
|
| 119 |
+
print(f"NOTE {name} -- {detail}")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# fixtures
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def dataset_test_shards():
|
| 127 |
+
pattern = os.path.expanduser(
|
| 128 |
+
"~/.cache/huggingface/hub/datasets--JacobLinCool--taiko-1000-parsed/"
|
| 129 |
+
"snapshots/*/data/test-*.parquet")
|
| 130 |
+
return sorted(glob.glob(pattern))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def songs_from_dataset(out_dir, n_songs, seconds):
|
| 134 |
+
"""Held-out songs with authored charts, decoded from the local cache.
|
| 135 |
+
|
| 136 |
+
Only songs carrying BOTH an oni and an ura chart are taken: those are the
|
| 137 |
+
ones that can say what the authored oni->ura step actually looks like on
|
| 138 |
+
the same music the model is being asked about.
|
| 139 |
+
"""
|
| 140 |
+
import soundfile as sf
|
| 141 |
+
import pyarrow.parquet as pq
|
| 142 |
+
|
| 143 |
+
picked = {}
|
| 144 |
+
seen_titles = set()
|
| 145 |
+
for shard in dataset_test_shards():
|
| 146 |
+
table = pq.read_table(shard)
|
| 147 |
+
for i in range(table.num_rows):
|
| 148 |
+
if len(picked) >= n_songs:
|
| 149 |
+
break
|
| 150 |
+
meta = table["metadata"][i].as_py()
|
| 151 |
+
title = (meta.get("TITLE") or "").strip()
|
| 152 |
+
if not title or title in seen_titles:
|
| 153 |
+
continue
|
| 154 |
+
authored = {}
|
| 155 |
+
for course in ALL_COURSES:
|
| 156 |
+
chart = table[course][i].as_py()
|
| 157 |
+
if not chart:
|
| 158 |
+
continue
|
| 159 |
+
times = sorted(
|
| 160 |
+
ev["timestamp"] for seg in chart["segments"]
|
| 161 |
+
for ev in seg["notes"]
|
| 162 |
+
if ev["note_type"] in ("Don", "Ka", "DonBig", "KaBig"))
|
| 163 |
+
span = (times[-1] - times[0]) if len(times) > 1 else 0.0
|
| 164 |
+
authored[course] = {
|
| 165 |
+
"level": chart["level"], "n_notes": len(times),
|
| 166 |
+
"nps": (len(times) / span) if span else 0.0}
|
| 167 |
+
if "ura" not in authored or "oni" not in authored:
|
| 168 |
+
continue
|
| 169 |
+
try:
|
| 170 |
+
bpm = float(meta["BPM"])
|
| 171 |
+
except (TypeError, ValueError, KeyError):
|
| 172 |
+
continue
|
| 173 |
+
audio = table["audio"][i].as_py()
|
| 174 |
+
wav, sr = sf.read(io.BytesIO(audio["bytes"]), dtype="float32",
|
| 175 |
+
always_2d=True)
|
| 176 |
+
wav = wav.mean(axis=1)
|
| 177 |
+
# Start 20 % in: intros are often sparse or silent, and a chart
|
| 178 |
+
# generated over silence would test nothing.
|
| 179 |
+
start = int(len(wav) * 0.20)
|
| 180 |
+
seg = wav[start:start + int(seconds * sr)]
|
| 181 |
+
if len(seg) < int(seconds * sr * 0.9):
|
| 182 |
+
continue
|
| 183 |
+
seen_titles.add(title)
|
| 184 |
+
slug = f"song{len(picked):02d}"
|
| 185 |
+
path = os.path.join(out_dir, slug + ".wav")
|
| 186 |
+
sf.write(path, seg, sr)
|
| 187 |
+
picked[slug] = {"title": title, "path": path, "bpm": bpm,
|
| 188 |
+
"seconds": len(seg) / sr, "authored": authored,
|
| 189 |
+
"source": "taiko-1000-parsed/test"}
|
| 190 |
+
if len(picked) >= n_songs:
|
| 191 |
+
break
|
| 192 |
+
return picked
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def songs_from_dir(audio_dir, out_dir, n_songs, seconds):
|
| 196 |
+
"""Fallback fixture: any directory of audio, with no authored reference."""
|
| 197 |
+
import librosa
|
| 198 |
+
import soundfile as sf
|
| 199 |
+
|
| 200 |
+
files = []
|
| 201 |
+
for ext in ("*.ogg", "*.mp3", "*.wav", "*.flac", "*.m4a"):
|
| 202 |
+
files.extend(glob.glob(os.path.join(audio_dir, ext)))
|
| 203 |
+
picked = {}
|
| 204 |
+
for path in sorted(files)[:n_songs]:
|
| 205 |
+
wav, sr = librosa.load(path, sr=None, mono=True)
|
| 206 |
+
start = int(len(wav) * 0.20)
|
| 207 |
+
seg = wav[start:start + int(seconds * sr)]
|
| 208 |
+
if len(seg) < int(seconds * sr * 0.5):
|
| 209 |
+
continue
|
| 210 |
+
slug = f"song{len(picked):02d}"
|
| 211 |
+
out = os.path.join(out_dir, slug + ".wav")
|
| 212 |
+
sf.write(out, seg, sr)
|
| 213 |
+
picked[slug] = {"title": os.path.basename(path), "path": out,
|
| 214 |
+
"bpm": None, "seconds": len(seg) / sr, "authored": {},
|
| 215 |
+
"source": os.path.abspath(audio_dir)}
|
| 216 |
+
return picked
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def build_fixtures(out_dir, n_songs, seconds, audio_dir=None):
|
| 220 |
+
if audio_dir:
|
| 221 |
+
songs = songs_from_dir(audio_dir, out_dir, n_songs, seconds)
|
| 222 |
+
origin = f"--audio-dir {audio_dir}"
|
| 223 |
+
elif dataset_test_shards():
|
| 224 |
+
songs = songs_from_dataset(out_dir, n_songs, seconds)
|
| 225 |
+
origin = "taiko-1000-parsed test split (held out)"
|
| 226 |
+
else:
|
| 227 |
+
return {}, "none"
|
| 228 |
+
return songs, origin
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ---------------------------------------------------------------------------
|
| 232 |
+
# driving the app
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def run_app(module, song, course, model, *, level=8, bpm=0.0,
|
| 236 |
+
auto_plan_on=False, use_beat=True, use_planner=False,
|
| 237 |
+
sampling=False, temperature=0.7, top_p=0.95, drum_volume=0.8):
|
| 238 |
+
"""Consume ``generate_chart`` and capture the artifacts it produced.
|
| 239 |
+
|
| 240 |
+
The app deletes its working directory in a ``finally`` once the generator
|
| 241 |
+
is closed, so the TJA has to be read while it is still suspended at the
|
| 242 |
+
completion yield.
|
| 243 |
+
"""
|
| 244 |
+
import gradio as gr
|
| 245 |
+
|
| 246 |
+
name = os.path.basename(song["path"])
|
| 247 |
+
payload = gr.FileData(path=song["path"], orig_name=name,
|
| 248 |
+
mime_type="audio/wav").model_dump()
|
| 249 |
+
started = time.time()
|
| 250 |
+
stages = []
|
| 251 |
+
out = {"model": model, "course": course, "song": song["title"]}
|
| 252 |
+
for event in module.generate_chart(
|
| 253 |
+
payload, name, course, level, bpm, auto_plan_on, use_beat,
|
| 254 |
+
use_planner, sampling, temperature, top_p, drum_volume, model):
|
| 255 |
+
stages.append(event.get("stage"))
|
| 256 |
+
if event["kind"] == "error":
|
| 257 |
+
out.update(kind="error", detail=event.get("detail"))
|
| 258 |
+
break
|
| 259 |
+
if event["kind"] == "complete":
|
| 260 |
+
with open(event["files"]["tja"]["path"], encoding="utf-8") as f:
|
| 261 |
+
tja = f.read()
|
| 262 |
+
out.update(kind="complete", tja=tja, metrics=dict(event["metrics"]),
|
| 263 |
+
files={k: os.path.basename(v["path"])
|
| 264 |
+
for k, v in event["files"].items()})
|
| 265 |
+
break
|
| 266 |
+
out["stages"] = stages
|
| 267 |
+
out["seconds"] = round(time.time() - started, 2)
|
| 268 |
+
return out
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ---------------------------------------------------------------------------
|
| 272 |
+
# chart inspection
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def inspect_tja(text, course):
|
| 276 |
+
"""Structural facts about one generated TJA, from a real re-parse."""
|
| 277 |
+
from softchart.sc2 import trace as T
|
| 278 |
+
|
| 279 |
+
facts = {}
|
| 280 |
+
charts = T.parse_tja(text) # raises on anything it will not guess about
|
| 281 |
+
facts["n_sections"] = len(charts)
|
| 282 |
+
chart = charts[0]
|
| 283 |
+
facts["parsed_course"] = chart.course
|
| 284 |
+
facts["level"] = chart.level
|
| 285 |
+
facts["n_bars"] = len(chart.bars)
|
| 286 |
+
facts["bar_lengths"] = sorted(set(len(b.digits) for b in chart.bars))
|
| 287 |
+
header = [ln for ln in text.splitlines() if ln.startswith("COURSE:")]
|
| 288 |
+
facts["course_header"] = header[0] if header else None
|
| 289 |
+
|
| 290 |
+
# Every character that is not '0' must land on the shipped lattice.
|
| 291 |
+
off_lattice = 0
|
| 292 |
+
counts = collections.Counter()
|
| 293 |
+
for bar in chart.bars:
|
| 294 |
+
stride = len(bar.digits) // SHIP_DENOM if len(bar.digits) else 0
|
| 295 |
+
for pos, ch in enumerate(bar.digits):
|
| 296 |
+
if ch == "0":
|
| 297 |
+
continue
|
| 298 |
+
counts[ch] += 1
|
| 299 |
+
if not stride or pos % stride:
|
| 300 |
+
off_lattice += 1
|
| 301 |
+
facts["off_lattice_notes"] = off_lattice
|
| 302 |
+
facts["note_chars"] = dict(counts)
|
| 303 |
+
facts["n_hits"] = sum(v for k, v in counts.items() if k in HIT_CHARS)
|
| 304 |
+
|
| 305 |
+
# Span balance, in emission order across the whole chart.
|
| 306 |
+
depth = 0
|
| 307 |
+
bad_order = 0
|
| 308 |
+
n_spans = 0
|
| 309 |
+
for bar in chart.bars:
|
| 310 |
+
for ch in bar.digits:
|
| 311 |
+
if ch in SPAN_OPEN:
|
| 312 |
+
if depth:
|
| 313 |
+
bad_order += 1
|
| 314 |
+
depth += 1
|
| 315 |
+
n_spans += 1
|
| 316 |
+
elif ch == SPAN_CLOSE:
|
| 317 |
+
if not depth:
|
| 318 |
+
bad_order += 1
|
| 319 |
+
else:
|
| 320 |
+
depth -= 1
|
| 321 |
+
facts["n_spans"] = n_spans
|
| 322 |
+
facts["unclosed_spans"] = depth
|
| 323 |
+
facts["misordered_spans"] = bad_order
|
| 324 |
+
|
| 325 |
+
# A full trace conversion is the strongest re-parse available: it resolves
|
| 326 |
+
# every note to an exact bar-local Fraction and a wall-clock time.
|
| 327 |
+
tr = T.chart_to_trace(chart)
|
| 328 |
+
times = T.trace_to_times(tr)
|
| 329 |
+
facts["n_trace_notes"] = len(times)
|
| 330 |
+
facts["bpm"] = tr.bpm
|
| 331 |
+
facts["offset"] = tr.offset
|
| 332 |
+
return facts
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def note_times(text):
|
| 336 |
+
"""Wall-clock times of the hit notes, for density statistics."""
|
| 337 |
+
from softchart.sc2 import trace as T
|
| 338 |
+
|
| 339 |
+
chart = T.parse_tja(text)[0]
|
| 340 |
+
out = []
|
| 341 |
+
for entry in T.trace_to_times(T.chart_to_trace(chart)):
|
| 342 |
+
t = entry[0] if isinstance(entry, (tuple, list)) else entry.get("t")
|
| 343 |
+
kind = entry[1] if isinstance(entry, (tuple, list)) else entry.get("type")
|
| 344 |
+
if str(kind) in HIT_CHARS:
|
| 345 |
+
out.append(float(t))
|
| 346 |
+
return sorted(out)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ---------------------------------------------------------------------------
|
| 350 |
+
# test 1: BarScript on all five difficulties
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def test_sc2_all_courses(songs, records, strict_ladder=False):
|
| 354 |
+
import app as APP
|
| 355 |
+
from softchart import sc2_loader
|
| 356 |
+
|
| 357 |
+
if not sc2_loader.is_available():
|
| 358 |
+
check("BarScript release package present", False,
|
| 359 |
+
f"no package at {sc2_loader.default_package_dir()}")
|
| 360 |
+
return
|
| 361 |
+
check("BarScript release package present", True, sc2_loader.default_package_dir())
|
| 362 |
+
|
| 363 |
+
per_song = {}
|
| 364 |
+
for slug, song in sorted(songs.items()):
|
| 365 |
+
per_song[slug] = {}
|
| 366 |
+
for course in ALL_COURSES:
|
| 367 |
+
tag = f"BarScript {song['title'][:22]} / {course}"
|
| 368 |
+
result = run_app(APP, song, course, APP.SC2_MODEL)
|
| 369 |
+
records.append({"phase": "sc2", "slug": slug, **{
|
| 370 |
+
k: v for k, v in result.items() if k != "tja"}})
|
| 371 |
+
if result["kind"] != "complete":
|
| 372 |
+
check(f"{tag}: completes", False, str(result.get("detail"))[:160])
|
| 373 |
+
continue
|
| 374 |
+
|
| 375 |
+
facts = inspect_tja(result["tja"], course)
|
| 376 |
+
records[-1]["facts"] = facts
|
| 377 |
+
metrics = result["metrics"]
|
| 378 |
+
|
| 379 |
+
ok = check(f"{tag}: completes", True,
|
| 380 |
+
f"{metrics['notes']} notes, {result['seconds']}s")
|
| 381 |
+
ok &= check(f"{tag}: single course section",
|
| 382 |
+
facts["n_sections"] == 1, f"{facts['n_sections']}")
|
| 383 |
+
# Two independent header checks. The literal is what a Taiko
|
| 384 |
+
# simulator reads; the re-parse is what it means.
|
| 385 |
+
ok &= check(f"{tag}: COURSE header is the literal the corpus uses",
|
| 386 |
+
facts["course_header"]
|
| 387 |
+
== f"COURSE:{CORPUS_COURSE_LITERAL[course]}",
|
| 388 |
+
f"{facts['course_header']!r}")
|
| 389 |
+
ok &= check(f"{tag}: COURSE header resolves to the course asked for",
|
| 390 |
+
facts["parsed_course"] == course,
|
| 391 |
+
f"{facts['course_header']} -> {facts['parsed_course']}")
|
| 392 |
+
ok &= check(f"{tag}: bars are {SUB}-digit",
|
| 393 |
+
facts["bar_lengths"] == [SUB], f"{facts['bar_lengths']}")
|
| 394 |
+
ok &= check(f"{tag}: every note on the /{SHIP_DENOM} lattice",
|
| 395 |
+
facts["off_lattice_notes"] == 0,
|
| 396 |
+
f"{facts['off_lattice_notes']} off-lattice")
|
| 397 |
+
ok &= check(f"{tag}: no unclosed drumroll",
|
| 398 |
+
facts["unclosed_spans"] == 0 and
|
| 399 |
+
facts["misordered_spans"] == 0,
|
| 400 |
+
f"open={facts['unclosed_spans']} "
|
| 401 |
+
f"misordered={facts['misordered_spans']}")
|
| 402 |
+
ok &= check(f"{tag}: re-parsed note count matches the UI",
|
| 403 |
+
facts["n_hits"] == metrics["notes"],
|
| 404 |
+
f"{facts['n_hits']} vs {metrics['notes']}")
|
| 405 |
+
ok &= check(f"{tag}: span count matches the UI",
|
| 406 |
+
facts["n_spans"] == metrics["spans"],
|
| 407 |
+
f"{facts['n_spans']} vs {metrics['spans']}")
|
| 408 |
+
|
| 409 |
+
# Bar count must follow from tempo and length, or the lattice is
|
| 410 |
+
# anchored wrong and every barline is in the wrong place.
|
| 411 |
+
bar_sec = 4.0 * 60.0 / float(metrics["bpm"])
|
| 412 |
+
expected = (song["seconds"] - abs(facts["offset"])) / bar_sec
|
| 413 |
+
ok &= check(f"{tag}: bar count follows tempo and length",
|
| 414 |
+
abs(facts["n_bars"] - expected) <= 1.5,
|
| 415 |
+
f"{facts['n_bars']} bars, expected ~{expected:.1f} "
|
| 416 |
+
f"at {metrics['bpm']} BPM")
|
| 417 |
+
ok &= check(f"{tag}: reported as slot-exact on the /16 grid",
|
| 418 |
+
metrics["timing"] == "slot-exact"
|
| 419 |
+
and "/16" in metrics.get("chart_grid", ""),
|
| 420 |
+
f"{metrics['timing']}, {metrics.get('chart_grid')}")
|
| 421 |
+
|
| 422 |
+
times = note_times(result["tja"])
|
| 423 |
+
span = (times[-1] - times[0]) if len(times) > 1 else 0.0
|
| 424 |
+
per_song[slug][course] = {
|
| 425 |
+
"n_notes": facts["n_hits"], "n_spans": facts["n_spans"],
|
| 426 |
+
"nps": (len(times) / span) if span else 0.0,
|
| 427 |
+
"min_gap_ms": (min((b - a) for a, b in zip(times, times[1:]))
|
| 428 |
+
* 1000.0) if len(times) > 1 else None}
|
| 429 |
+
|
| 430 |
+
records.append({"phase": "sc2_ladder", "generated": per_song,
|
| 431 |
+
"authored": {slug: song["authored"]
|
| 432 |
+
for slug, song in songs.items()}})
|
| 433 |
+
ladder_report(songs, per_song, strict_ladder)
|
| 434 |
+
return per_song
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def ladder_report(songs, per_song, strict_ladder):
|
| 438 |
+
"""Report the generated difficulty ladder against the authored one."""
|
| 439 |
+
print("\n--- difficulty ladder (BarScript, generated vs authored)")
|
| 440 |
+
header = f"{'song':26s} " + " ".join(f"{c:>13s}" for c in ALL_COURSES)
|
| 441 |
+
print(header)
|
| 442 |
+
ura_gt_oni = []
|
| 443 |
+
monotone = []
|
| 444 |
+
for slug, song in sorted(songs.items()):
|
| 445 |
+
got = per_song.get(slug) or {}
|
| 446 |
+
if not got:
|
| 447 |
+
continue
|
| 448 |
+
row = f"{song['title'][:25]:26s} "
|
| 449 |
+
for course in ALL_COURSES:
|
| 450 |
+
entry = got.get(course)
|
| 451 |
+
row += f"{entry['n_notes']:6d}/{entry['nps']:5.2f} " if entry else f"{'--':>13s} "
|
| 452 |
+
print(row)
|
| 453 |
+
if song["authored"]:
|
| 454 |
+
arow = f"{' (authored)':26s} "
|
| 455 |
+
for course in ALL_COURSES:
|
| 456 |
+
a = song["authored"].get(course)
|
| 457 |
+
arow += f"{a['n_notes']:6d}/{a['nps']:5.2f} " if a else f"{'--':>13s} "
|
| 458 |
+
print(arow)
|
| 459 |
+
counts = [got[c]["n_notes"] for c in ALL_COURSES if c in got]
|
| 460 |
+
if len(counts) == len(ALL_COURSES):
|
| 461 |
+
monotone.append(all(x < y for x, y in zip(counts, counts[1:])))
|
| 462 |
+
ura_gt_oni.append(got["ura"]["n_notes"] > got["oni"]["n_notes"])
|
| 463 |
+
|
| 464 |
+
if not ura_gt_oni:
|
| 465 |
+
return
|
| 466 |
+
n_ura = sum(ura_gt_oni)
|
| 467 |
+
n_mono = sum(monotone)
|
| 468 |
+
detail = (f"ura denser than oni on {n_ura}/{len(ura_gt_oni)} songs; "
|
| 469 |
+
f"fully monotone easy<normal<hard<oni<ura on "
|
| 470 |
+
f"{n_mono}/{len(monotone)}")
|
| 471 |
+
authored = [s for s in songs.values() if s["authored"].get("ura")
|
| 472 |
+
and s["authored"].get("oni")]
|
| 473 |
+
if authored:
|
| 474 |
+
a_ura = sum(1 for s in authored
|
| 475 |
+
if s["authored"]["ura"]["n_notes"]
|
| 476 |
+
> s["authored"]["oni"]["n_notes"])
|
| 477 |
+
detail += f"; authored reference: ura denser on {a_ura}/{len(authored)}"
|
| 478 |
+
if strict_ladder:
|
| 479 |
+
check("BarScript: ura is harder than oni on every song",
|
| 480 |
+
n_ura == len(ura_gt_oni), detail)
|
| 481 |
+
check("BarScript: the difficulty ladder is monotone on every song",
|
| 482 |
+
n_mono == len(monotone), detail)
|
| 483 |
+
else:
|
| 484 |
+
note("BarScript difficulty ladder (reported, not asserted -- MODEL_CARD "
|
| 485 |
+
"§2.1 says the ladder is not nested)", detail)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
# ---------------------------------------------------------------------------
|
| 489 |
+
# test 2: published models unchanged, on real audio and real weights
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def load_baseline():
|
| 493 |
+
"""Import the pre-change app, pinned to the wiring suite's provenance.
|
| 494 |
+
|
| 495 |
+
The baseline mounts its static assets from ``Path(__file__).with_name
|
| 496 |
+
("static")``, so it is staged in a scratch directory next to a link to
|
| 497 |
+
the Space's real ``static/`` rather than imported from ``fixtures/``.
|
| 498 |
+
"""
|
| 499 |
+
import importlib.util
|
| 500 |
+
import shutil
|
| 501 |
+
|
| 502 |
+
if not os.path.isfile(BASELINE_APP):
|
| 503 |
+
return None, "fixtures/app_baseline.py is missing"
|
| 504 |
+
with open(BASELINE_APP, "rb") as f:
|
| 505 |
+
digest = hashlib.sha256(f.read()).hexdigest()
|
| 506 |
+
expected = None
|
| 507 |
+
if os.path.isfile(GOLDEN):
|
| 508 |
+
with open(GOLDEN, encoding="utf-8") as f:
|
| 509 |
+
expected = json.load(f).get("source_sha256")
|
| 510 |
+
if expected and digest != expected:
|
| 511 |
+
return None, (f"baseline sha256 {digest[:16]} does not match the "
|
| 512 |
+
f"golden trace's source {expected[:16]}")
|
| 513 |
+
|
| 514 |
+
stage = tempfile.mkdtemp(prefix="softchart-baseline-")
|
| 515 |
+
staged = os.path.join(stage, "app_baseline.py")
|
| 516 |
+
shutil.copyfile(BASELINE_APP, staged)
|
| 517 |
+
os.symlink(os.path.join(SPACES, "static"), os.path.join(stage, "static"))
|
| 518 |
+
spec = importlib.util.spec_from_file_location("app_pre_sc2", staged)
|
| 519 |
+
module = importlib.util.module_from_spec(spec)
|
| 520 |
+
sys.modules["app_pre_sc2"] = module
|
| 521 |
+
spec.loader.exec_module(module)
|
| 522 |
+
return module, digest
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
def test_legacy_unchanged(songs, records):
|
| 526 |
+
import app as APP
|
| 527 |
+
|
| 528 |
+
baseline, info = load_baseline()
|
| 529 |
+
if baseline is None:
|
| 530 |
+
check("legacy baseline available", False, info)
|
| 531 |
+
return
|
| 532 |
+
check("legacy baseline available", True,
|
| 533 |
+
f"fixtures/app_baseline.py sha256 {info[:16]}, matches the golden "
|
| 534 |
+
f"trace source")
|
| 535 |
+
|
| 536 |
+
for model in LEGACY_MODELS:
|
| 537 |
+
for course in LEGACY_COURSES:
|
| 538 |
+
for slug, song in sorted(songs.items()):
|
| 539 |
+
tag = f"{model} {song['title'][:22]} / {course}"
|
| 540 |
+
try:
|
| 541 |
+
now = run_app(APP, song, course, model)
|
| 542 |
+
was = run_app(baseline, song, course, model)
|
| 543 |
+
except Exception as exc: # noqa: BLE001
|
| 544 |
+
check(f"{tag}: runs", False, f"{type(exc).__name__}: {exc}")
|
| 545 |
+
continue
|
| 546 |
+
if now["kind"] != "complete" or was["kind"] != "complete":
|
| 547 |
+
check(f"{tag}: both apps complete", False,
|
| 548 |
+
f"now={now['kind']} baseline={was['kind']} "
|
| 549 |
+
f"{now.get('detail') or was.get('detail')}")
|
| 550 |
+
continue
|
| 551 |
+
same_tja = now["tja"] == was["tja"]
|
| 552 |
+
# The metrics panel legitimately GAINED fields in this change
|
| 553 |
+
# (the model name, the TJA course literal, the plan source).
|
| 554 |
+
# What may not change is any value the baseline already
|
| 555 |
+
# reported, so the shared keys are compared exactly and the
|
| 556 |
+
# added ones are recorded separately.
|
| 557 |
+
shared = set(was["metrics"]) & set(now["metrics"])
|
| 558 |
+
same_shared = all(was["metrics"][k] == now["metrics"][k]
|
| 559 |
+
for k in shared)
|
| 560 |
+
added = sorted(set(now["metrics"]) - set(was["metrics"]))
|
| 561 |
+
dropped = sorted(set(was["metrics"]) - set(now["metrics"]))
|
| 562 |
+
records.append({
|
| 563 |
+
"phase": "legacy", "slug": slug, "model": model,
|
| 564 |
+
"course": course, "identical_tja": same_tja,
|
| 565 |
+
"identical_shared_metrics": same_shared,
|
| 566 |
+
"metrics_added": added, "metrics_dropped": dropped,
|
| 567 |
+
"notes": now["metrics"]["notes"],
|
| 568 |
+
"sha256": hashlib.sha256(
|
| 569 |
+
now["tja"].encode("utf-8")).hexdigest()[:16]})
|
| 570 |
+
check(f"{tag}: TJA byte-identical to the pre-change app",
|
| 571 |
+
same_tja,
|
| 572 |
+
f"{len(now['tja'])} bytes, sha "
|
| 573 |
+
f"{records[-1]['sha256']}" if same_tja else
|
| 574 |
+
_first_difference(was["tja"], now["tja"]))
|
| 575 |
+
check(f"{tag}: every metric the old app reported is unchanged",
|
| 576 |
+
same_shared and not dropped,
|
| 577 |
+
f"{len(shared)} shared keys equal"
|
| 578 |
+
+ (f", added {added}" if added else "")
|
| 579 |
+
+ (f", DROPPED {dropped}" if dropped else "")
|
| 580 |
+
if same_shared and not dropped else
|
| 581 |
+
_dict_difference(was["metrics"], now["metrics"]))
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def _first_difference(a, b):
|
| 585 |
+
for i, (x, y) in enumerate(zip(a, b)):
|
| 586 |
+
if x != y:
|
| 587 |
+
return f"first differs at byte {i}: {a[i:i+40]!r} vs {b[i:i+40]!r}"
|
| 588 |
+
return f"lengths {len(a)} vs {len(b)}"
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _dict_difference(a, b):
|
| 592 |
+
keys = sorted(set(a) | set(b))
|
| 593 |
+
return "; ".join(f"{k}: {a.get(k)!r} -> {b.get(k)!r}"
|
| 594 |
+
for k in keys if a.get(k) != b.get(k))
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
# ---------------------------------------------------------------------------
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def main():
|
| 601 |
+
parser = argparse.ArgumentParser(
|
| 602 |
+
description=__doc__,
|
| 603 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 604 |
+
parser.add_argument("--songs", type=int, default=6,
|
| 605 |
+
help="number of songs (default 6)")
|
| 606 |
+
parser.add_argument("--seconds", type=float, default=60.0,
|
| 607 |
+
help="excerpt length in seconds (default 60)")
|
| 608 |
+
parser.add_argument("--audio-dir",
|
| 609 |
+
help="use this directory of audio instead of the "
|
| 610 |
+
"held-out dataset split")
|
| 611 |
+
parser.add_argument("--skip-legacy", action="store_true",
|
| 612 |
+
help="skip the published-model regression")
|
| 613 |
+
parser.add_argument("--skip-sc2", action="store_true",
|
| 614 |
+
help="skip the BarScript five-difficulty run")
|
| 615 |
+
parser.add_argument("--strict-ladder", action="store_true",
|
| 616 |
+
help="fail if the generated ladder is not monotone")
|
| 617 |
+
parser.add_argument("--json", help="write a machine-readable report here")
|
| 618 |
+
args = parser.parse_args()
|
| 619 |
+
|
| 620 |
+
workdir = tempfile.mkdtemp(prefix="softchart-e2e-")
|
| 621 |
+
records = []
|
| 622 |
+
try:
|
| 623 |
+
songs, origin = build_fixtures(workdir, args.songs, args.seconds,
|
| 624 |
+
args.audio_dir)
|
| 625 |
+
if not songs:
|
| 626 |
+
print("no audio fixtures available: the taiko-1000-parsed test "
|
| 627 |
+
"split is not in the local HuggingFace cache and no "
|
| 628 |
+
"--audio-dir was given")
|
| 629 |
+
return 2
|
| 630 |
+
print(f"fixtures: {len(songs)} songs x {args.seconds:.0f}s from {origin}")
|
| 631 |
+
for slug, song in sorted(songs.items()):
|
| 632 |
+
print(f" {slug} {song['title'][:40]:42s} {song['bpm'] or '?'} BPM")
|
| 633 |
+
|
| 634 |
+
if not args.skip_sc2:
|
| 635 |
+
print("\n--- test_sc2_all_courses")
|
| 636 |
+
test_sc2_all_courses(songs, records, args.strict_ladder)
|
| 637 |
+
if not args.skip_legacy:
|
| 638 |
+
print("\n--- test_legacy_unchanged")
|
| 639 |
+
test_legacy_unchanged(songs, records)
|
| 640 |
+
finally:
|
| 641 |
+
import shutil
|
| 642 |
+
shutil.rmtree(workdir, ignore_errors=True)
|
| 643 |
+
|
| 644 |
+
failed = [n for n, ok, _ in RESULTS if not ok]
|
| 645 |
+
print(f"\n{len(RESULTS) - len(failed)}/{len(RESULTS)} checks passed")
|
| 646 |
+
for name in failed:
|
| 647 |
+
print(f" FAILED: {name}")
|
| 648 |
+
if args.json:
|
| 649 |
+
with open(args.json, "w", encoding="utf-8") as f:
|
| 650 |
+
json.dump({"checks": [{"name": n, "ok": ok, "detail": d}
|
| 651 |
+
for n, ok, d in RESULTS],
|
| 652 |
+
"records": records}, f, indent=1, ensure_ascii=False)
|
| 653 |
+
print(f"report -> {args.json}")
|
| 654 |
+
return 1 if failed else 0
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
if __name__ == "__main__":
|
| 658 |
+
sys.exit(main())
|
softchart/baseline.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Onset-detection baseline: spectral-flux peak picking on the cached log-mel
|
| 2 |
+
+ band-energy don/ka heuristic.
|
| 3 |
+
|
| 4 |
+
This is the "chart generation is just onset detection" strawman the model must beat.
|
| 5 |
+
Operates on the same cached mel features as the model, so no raw audio is needed.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from scipy.signal import find_peaks
|
| 10 |
+
|
| 11 |
+
from .vocab import FPS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def baseline_chart(mel, target_nps):
|
| 15 |
+
"""mel: (n_mels, T) log-mel (natural log). Returns list of (time_sec, class)."""
|
| 16 |
+
m = mel.astype(np.float32)
|
| 17 |
+
# spectral flux onset envelope
|
| 18 |
+
flux = np.maximum(0.0, np.diff(m, axis=1)).sum(axis=0)
|
| 19 |
+
flux = np.concatenate([[0.0], flux])
|
| 20 |
+
# smooth lightly
|
| 21 |
+
k = np.hanning(5)
|
| 22 |
+
k /= k.sum()
|
| 23 |
+
env = np.convolve(flux, k, mode="same")
|
| 24 |
+
|
| 25 |
+
dur = m.shape[1] / FPS
|
| 26 |
+
want = max(1, int(round(target_nps * dur)))
|
| 27 |
+
peaks, props = find_peaks(env, distance=int(0.09 * FPS), height=0.0)
|
| 28 |
+
if len(peaks) == 0:
|
| 29 |
+
return []
|
| 30 |
+
if len(peaks) > want:
|
| 31 |
+
strongest = np.argsort(props["peak_heights"])[-want:]
|
| 32 |
+
peaks = np.sort(peaks[strongest])
|
| 33 |
+
|
| 34 |
+
# don/ka: low vs high mel-band energy at the onset (linear power domain)
|
| 35 |
+
p = np.exp(m)
|
| 36 |
+
low = p[:40].sum(axis=0) # ~ <500 Hz
|
| 37 |
+
high = p[64:].sum(axis=0) # upper bands
|
| 38 |
+
events = []
|
| 39 |
+
for fr in peaks:
|
| 40 |
+
fr2 = min(fr + 1, m.shape[1] - 1)
|
| 41 |
+
ratio = high[fr2] / (low[fr2] + 1e-9)
|
| 42 |
+
events.append((float(fr / FPS), "ka" if ratio > 0.35 else "don"))
|
| 43 |
+
return events
|
softchart/data.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PyTorch dataset: (mel window, token sequence) pairs from the preprocessed cache."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from torch.utils.data import Dataset
|
| 10 |
+
|
| 11 |
+
from .vocab import (FPS, MAX_TGT, MEAS_MAX, NOTE_CLASSES, SLOTS, VOCAB, WINDOW,
|
| 12 |
+
encode_window)
|
| 13 |
+
|
| 14 |
+
G_CHUNK = 172 # ~2s per whole-song summary chunk
|
| 15 |
+
G_LEN = 100 # fixed number of summary chunks (covers ~200s songs)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def song_summary(mel):
|
| 19 |
+
"""(n_mels, T) log-mel -> (G_LEN, n_mels) coarse whole-song summary."""
|
| 20 |
+
n_mels, T = mel.shape
|
| 21 |
+
n = min(G_LEN, max(1, T // G_CHUNK))
|
| 22 |
+
out = np.full((G_LEN, n_mels), np.log(1e-5), dtype=np.float32)
|
| 23 |
+
for k in range(n):
|
| 24 |
+
out[k] = mel[:, k * G_CHUNK : (k + 1) * G_CHUNK].astype(np.float32).mean(axis=1)
|
| 25 |
+
return out
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ChartWindowDataset(Dataset):
|
| 29 |
+
"""One item = one (song, course) chart; a random window is cropped per access."""
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
cache_dir,
|
| 34 |
+
song_ids,
|
| 35 |
+
train=True,
|
| 36 |
+
cond_drop=0.15,
|
| 37 |
+
spec_augment=True,
|
| 38 |
+
windows_per_chart=8,
|
| 39 |
+
use_ctx=False,
|
| 40 |
+
aux=False,
|
| 41 |
+
global_ctx=False,
|
| 42 |
+
importance_sampling=False,
|
| 43 |
+
tempo_aug=False,
|
| 44 |
+
sibling=False,
|
| 45 |
+
style=False,
|
| 46 |
+
beat_head=False,
|
| 47 |
+
sync_token=False,
|
| 48 |
+
plan=False,
|
| 49 |
+
mask_infill=0.0,
|
| 50 |
+
complexity=False,
|
| 51 |
+
slot=False,
|
| 52 |
+
dual=False,
|
| 53 |
+
dual_slot_p=0.65,
|
| 54 |
+
beat_hires=False,
|
| 55 |
+
):
|
| 56 |
+
self.cache = cache_dir
|
| 57 |
+
self.train = train
|
| 58 |
+
self.slot = slot
|
| 59 |
+
self.dual = dual # mixed slot/time batches with an explicit mode token
|
| 60 |
+
self.dual_slot_p = dual_slot_p
|
| 61 |
+
self._meas_cache = {}
|
| 62 |
+
self.cond_drop = cond_drop if train else 0.0
|
| 63 |
+
self.spec_augment = spec_augment and train
|
| 64 |
+
self.use_ctx = use_ctx
|
| 65 |
+
self.aux = aux
|
| 66 |
+
self.global_ctx = global_ctx
|
| 67 |
+
self.importance = importance_sampling and train
|
| 68 |
+
self.tempo_aug = tempo_aug and train
|
| 69 |
+
self.sibling = sibling
|
| 70 |
+
self.beat_head = beat_head
|
| 71 |
+
self.beat_hires = beat_hires
|
| 72 |
+
self.sync_token = sync_token
|
| 73 |
+
self.mask_infill = mask_infill if train else 0.0
|
| 74 |
+
self.complexity = complexity
|
| 75 |
+
self.plans = None
|
| 76 |
+
if plan:
|
| 77 |
+
with open(os.path.join(cache_dir, "plans.json")) as f:
|
| 78 |
+
self.plans = json.load(f)
|
| 79 |
+
self.styles = None
|
| 80 |
+
if style:
|
| 81 |
+
with open(os.path.join(cache_dir, "styles.json")) as f:
|
| 82 |
+
self.styles = json.load(f)["styles"]
|
| 83 |
+
with open(os.path.join(cache_dir, "index.json")) as f:
|
| 84 |
+
index = {e["id"]: e for e in json.load(f)}
|
| 85 |
+
self.items = [] # (sid, course, level, n_frames)
|
| 86 |
+
for sid in song_ids:
|
| 87 |
+
e = index.get(sid)
|
| 88 |
+
if e is None:
|
| 89 |
+
continue
|
| 90 |
+
for c, info in e["courses"].items():
|
| 91 |
+
self.items.append((sid, c, info["level"], e["n_frames"]))
|
| 92 |
+
if self.slot and not self.dual: # pure slot mode needs a grid per song
|
| 93 |
+
keep = []
|
| 94 |
+
for it in self.items:
|
| 95 |
+
src = it[0].split("x")[0] if "x" in it[0] else it[0]
|
| 96 |
+
if os.path.exists(os.path.join(cache_dir, f"{src}.beats.npz")):
|
| 97 |
+
keep.append(it)
|
| 98 |
+
self.items = keep
|
| 99 |
+
# virtual epoch length: several windows per chart
|
| 100 |
+
self.mult = windows_per_chart if train else 1
|
| 101 |
+
self._rng = random.Random(int(os.environ.get('SC_DATA_SEED', 1234)))
|
| 102 |
+
|
| 103 |
+
def __len__(self):
|
| 104 |
+
return len(self.items) * self.mult
|
| 105 |
+
|
| 106 |
+
def _load(self, sid):
|
| 107 |
+
mel = np.load(os.path.join(self.cache, f"{sid}.mel.npy"), mmap_mode="r")
|
| 108 |
+
notes = np.load(os.path.join(self.cache, f"{sid}.notes.npz"))
|
| 109 |
+
return mel, notes
|
| 110 |
+
|
| 111 |
+
def _beats(self, sid):
|
| 112 |
+
try:
|
| 113 |
+
z = np.load(os.path.join(self.cache, f"{sid}.beats.npz"))
|
| 114 |
+
return z["beats"], z["downbeats"]
|
| 115 |
+
except Exception:
|
| 116 |
+
return None, None
|
| 117 |
+
|
| 118 |
+
def _measures(self, sid):
|
| 119 |
+
"""Slot mode: (downbeat_times, beats_per_measure, ok_mask) — measure m
|
| 120 |
+
spans [db[m], db[m+1]). ok = sane meter (2..6 beats) and duration.
|
| 121 |
+
4/4-only models pass meter_filter=4 at window time."""
|
| 122 |
+
if sid in self._meas_cache:
|
| 123 |
+
return self._meas_cache[sid]
|
| 124 |
+
bts, dbs = self._beats(sid)
|
| 125 |
+
out = None
|
| 126 |
+
if dbs is not None and len(dbs) >= 3:
|
| 127 |
+
db = np.asarray(dbs, np.float64)
|
| 128 |
+
durs = np.diff(db)
|
| 129 |
+
if bts is not None and len(bts):
|
| 130 |
+
nb = np.searchsorted(bts, db[1:] - 1e-4) - np.searchsorted(bts, db[:-1] - 1e-4)
|
| 131 |
+
else:
|
| 132 |
+
nb = np.full(len(durs), 4)
|
| 133 |
+
ok = (nb >= 2) & (nb <= 6) & (durs > 0.5) & (durs < 8.0)
|
| 134 |
+
if ok.any():
|
| 135 |
+
out = (db, nb.astype(np.int64), ok)
|
| 136 |
+
self._meas_cache[sid] = out
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
def _getitem_slot(self, i):
|
| 140 |
+
"""Slot-token window: measure-aligned crop; note positions are exact
|
| 141 |
+
TJA lattice indices instead of audio frames. Lattice (v3) is
|
| 142 |
+
SLOTS_PER_BEAT per beat, so any meter is exact (4/4 -> 96/measure,
|
| 143 |
+
3/4 -> 72). Slot labels are tempo-INVARIANT, so tempo augmentation
|
| 144 |
+
only touches the mel. Two phase channels (measure/beat sawtooth) are
|
| 145 |
+
appended so the decoder reads the grid instead of inferring it."""
|
| 146 |
+
from .vocab import BEATS_MAX, SLOTS_PER_BEAT
|
| 147 |
+
|
| 148 |
+
sid, course, level, n_frames = self.items[i % len(self.items)]
|
| 149 |
+
src_sid = sid.split("x")[0] if "x" in sid else sid
|
| 150 |
+
tab = self._measures(src_sid)
|
| 151 |
+
if tab is None: # unusable grid: deterministic redirect to another item
|
| 152 |
+
return self._getitem_slot((i + 9973) % len(self.items))
|
| 153 |
+
db, nbm, okm = tab
|
| 154 |
+
mel, notes = self._load(sid)
|
| 155 |
+
times = notes[f"{course}_t"]
|
| 156 |
+
classes = notes[f"{course}_c"]
|
| 157 |
+
|
| 158 |
+
rng0 = random.Random(i * 31337 + (self._rng.randint(0, 1 << 30) if self.train else 0))
|
| 159 |
+
rng = random.Random(i * 7919 + (self._rng.randint(0, 1 << 30) if self.train else 0))
|
| 160 |
+
r = 1.0
|
| 161 |
+
if self.tempo_aug and rng0.random() < 0.5:
|
| 162 |
+
r = rng0.uniform(0.9, 1.111)
|
| 163 |
+
|
| 164 |
+
# start measure: importance-weighted among valid measures
|
| 165 |
+
cand = np.nonzero(okm)[0]
|
| 166 |
+
cand = cand[db[cand] * FPS < max(n_frames - 1, 1)]
|
| 167 |
+
if len(cand) == 0:
|
| 168 |
+
return self._getitem_slot((i + 9973) % len(self.items))
|
| 169 |
+
if self.train:
|
| 170 |
+
if self.importance:
|
| 171 |
+
lo = np.searchsorted(times, db[cand])
|
| 172 |
+
hi = np.searchsorted(times, db[cand] + WINDOW / FPS * r)
|
| 173 |
+
w = (hi - lo).astype(np.float64) + 3.0
|
| 174 |
+
j = int(rng.choices(cand.tolist(), weights=w.tolist())[0])
|
| 175 |
+
else:
|
| 176 |
+
j = int(rng.choice(cand.tolist()))
|
| 177 |
+
else:
|
| 178 |
+
j = int(cand[(i * 2654435761) % len(cand)])
|
| 179 |
+
|
| 180 |
+
# extend K whole measures while valid, inside audio, and within both
|
| 181 |
+
# the frame window and the 72-beat token budget
|
| 182 |
+
K = 0
|
| 183 |
+
nbeats = 0
|
| 184 |
+
while (j + K < len(okm) and okm[j + K]
|
| 185 |
+
and nbeats + nbm[j + K] <= BEATS_MAX
|
| 186 |
+
and (db[j + K + 1] - db[j]) * FPS / r <= WINDOW
|
| 187 |
+
and db[j + K + 1] * FPS <= n_frames + FPS):
|
| 188 |
+
nbeats += nbm[j + K]
|
| 189 |
+
K += 1
|
| 190 |
+
if K == 0:
|
| 191 |
+
return self._getitem_slot((i + 9973) % len(self.items))
|
| 192 |
+
t0, t_end = db[j], db[j + K]
|
| 193 |
+
boff = np.concatenate([[0], np.cumsum(nbm[j : j + K])]) # beat offsets
|
| 194 |
+
|
| 195 |
+
def to_slot(t, m):
|
| 196 |
+
a, b = db[j + m], db[j + m + 1]
|
| 197 |
+
s = int(np.floor(SLOTS_PER_BEAT * nbm[j + m] * (t - a) / (b - a) + 0.5))
|
| 198 |
+
return min(boff[m] * SLOTS_PER_BEAT + s, WINDOW - 1)
|
| 199 |
+
|
| 200 |
+
# notes -> exact lattice indices; keep frame positions for the aux head
|
| 201 |
+
pairs, aux_frames = [], []
|
| 202 |
+
seen = set()
|
| 203 |
+
for m in range(K):
|
| 204 |
+
a, b = db[j + m], db[j + m + 1]
|
| 205 |
+
sel = (times >= a - 1e-4) & (times < b - 1e-4)
|
| 206 |
+
for t, c in zip(times[sel], classes[sel]):
|
| 207 |
+
g = to_slot(t, m)
|
| 208 |
+
if (g, int(c)) not in seen:
|
| 209 |
+
seen.add((g, int(c)))
|
| 210 |
+
pairs.append((g, int(c)))
|
| 211 |
+
aux_frames.append((t - t0) / r * FPS)
|
| 212 |
+
pairs_sorted = sorted(range(len(pairs)), key=lambda k: pairs[k][0])
|
| 213 |
+
pairs = [pairs[k] for k in pairs_sorted]
|
| 214 |
+
aux_frames = [aux_frames[k] for k in pairs_sorted]
|
| 215 |
+
|
| 216 |
+
sib = None
|
| 217 |
+
if self.sibling:
|
| 218 |
+
order = ["easy", "normal", "hard", "oni", "ura"]
|
| 219 |
+
sib_pairs = []
|
| 220 |
+
if not (self.train and rng0.random() < 0.2):
|
| 221 |
+
for c2 in reversed(order[: order.index(course)]):
|
| 222 |
+
if f"{c2}_t" in notes:
|
| 223 |
+
t2, c2c = notes[f"{c2}_t"], notes[f"{c2}_c"]
|
| 224 |
+
ev = []
|
| 225 |
+
for m in range(K):
|
| 226 |
+
a, b = db[j + m], db[j + m + 1]
|
| 227 |
+
s2 = (t2 >= a - 1e-4) & (t2 < b - 1e-4) & (c2c <= 3)
|
| 228 |
+
for t, c in zip(t2[s2], c2c[s2]):
|
| 229 |
+
ev.append((to_slot(t, m), int(c)))
|
| 230 |
+
if len(ev) > 12:
|
| 231 |
+
idx2 = np.linspace(0, len(ev) - 1, 12).astype(int)
|
| 232 |
+
ev = [ev[k] for k in idx2]
|
| 233 |
+
sib_pairs = ev
|
| 234 |
+
break
|
| 235 |
+
sib = sib_pairs
|
| 236 |
+
|
| 237 |
+
style = None
|
| 238 |
+
if self.styles is not None:
|
| 239 |
+
style = self.styles.get(f"{sid}:{course}", -1)
|
| 240 |
+
|
| 241 |
+
plan_slice = None
|
| 242 |
+
if self.plans is not None:
|
| 243 |
+
blocks = self.plans.get(f"{sid}:{course}") or []
|
| 244 |
+
plan_slice = [(b[2], b[3]) for b in blocks if b[1] > t0 and b[0] < t_end]
|
| 245 |
+
|
| 246 |
+
seq, prefix_len = encode_window(
|
| 247 |
+
VOCAB, course, level, pairs, cond_drop=self.cond_drop, rng=rng,
|
| 248 |
+
sib_pairs=sib, style=style, plan_slice=plan_slice,
|
| 249 |
+
mode=("slot" if self.dual else None),
|
| 250 |
+
)
|
| 251 |
+
if len(seq) > MAX_TGT:
|
| 252 |
+
seq = seq[: MAX_TGT - 1] + [VOCAB.eos]
|
| 253 |
+
if VOCAB.is_time(seq[-2]):
|
| 254 |
+
seq = seq[:-2] + [VOCAB.eos]
|
| 255 |
+
|
| 256 |
+
# mel crop [t0, t_end) -> stretch by r -> pad to WINDOW
|
| 257 |
+
f0 = int(round(t0 * FPS))
|
| 258 |
+
src_len = max(1, int(round((t_end - t0) * FPS)))
|
| 259 |
+
x = mel[:, f0 : f0 + src_len].astype(np.float32)
|
| 260 |
+
if x.shape[1] < src_len:
|
| 261 |
+
x = np.pad(x, ((0, 0), (0, src_len - x.shape[1])), constant_values=np.log(1e-5))
|
| 262 |
+
x = torch.from_numpy(x)
|
| 263 |
+
tgt_len = min(WINDOW, max(1, int(round(src_len / r))))
|
| 264 |
+
if tgt_len != src_len:
|
| 265 |
+
x = torch.nn.functional.interpolate(
|
| 266 |
+
x[None], size=tgt_len, mode="linear", align_corners=False)[0]
|
| 267 |
+
if x.shape[1] < WINDOW:
|
| 268 |
+
x = torch.nn.functional.pad(x, (0, WINDOW - x.shape[1]),
|
| 269 |
+
value=float(np.log(1e-5)))
|
| 270 |
+
if self.tempo_aug and rng0.random() < 0.3:
|
| 271 |
+
k = rng0.randint(-4, 4)
|
| 272 |
+
if k:
|
| 273 |
+
x = torch.roll(x, k, dims=0)
|
| 274 |
+
if k > 0:
|
| 275 |
+
x[:k] = x.min()
|
| 276 |
+
else:
|
| 277 |
+
x[k:] = x.min()
|
| 278 |
+
if self.spec_augment:
|
| 279 |
+
for _ in range(2):
|
| 280 |
+
w = rng.randint(0, 12)
|
| 281 |
+
fq = rng.randint(0, x.shape[0] - w) if w else 0
|
| 282 |
+
if w:
|
| 283 |
+
x[fq : fq + w] = x.mean()
|
| 284 |
+
for _ in range(2):
|
| 285 |
+
w = rng.randint(0, 24)
|
| 286 |
+
fq = rng.randint(0, x.shape[1] - w) if w else 0
|
| 287 |
+
if w:
|
| 288 |
+
x[:, fq : fq + w] = x.mean()
|
| 289 |
+
x = x + (rng.random() * 1.38 - 0.69)
|
| 290 |
+
# phase channels AFTER augmentation (the grid is clean metadata):
|
| 291 |
+
# ch0 = measure phase 0..1, ch1 = beat phase 0..1; -1 marks padding
|
| 292 |
+
ph = torch.full((2, WINDOW), -1.0)
|
| 293 |
+
edges = (db[j : j + K + 1] - t0) / r * FPS
|
| 294 |
+
for m in range(K):
|
| 295 |
+
a, b = edges[m], edges[m + 1]
|
| 296 |
+
i0, i1 = int(np.ceil(a - 1e-6)), min(int(np.ceil(b - 1e-6)), WINDOW)
|
| 297 |
+
if i1 <= i0:
|
| 298 |
+
continue
|
| 299 |
+
frac = (torch.arange(i0, i1, dtype=torch.float32) - a) / max(b - a, 1e-6)
|
| 300 |
+
ph[0, i0:i1] = frac
|
| 301 |
+
ph[1, i0:i1] = (frac * float(nbm[j + m])) % 1.0
|
| 302 |
+
x = torch.cat([x, ph], dim=0)
|
| 303 |
+
|
| 304 |
+
aux_t = torch.zeros(0)
|
| 305 |
+
if self.aux:
|
| 306 |
+
aux_t = torch.zeros(WINDOW // 4)
|
| 307 |
+
for f, (g, c) in zip(aux_frames, pairs):
|
| 308 |
+
if NOTE_CLASSES[c] != "end":
|
| 309 |
+
aux_t[min(max(int(f), 0) // 4, WINDOW // 4 - 1)] = 1.0
|
| 310 |
+
aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, 1))
|
| 311 |
+
aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, -1))
|
| 312 |
+
|
| 313 |
+
beat_t = torch.zeros(0)
|
| 314 |
+
if self.beat_head:
|
| 315 |
+
# measured LEAK (v15a): slot windows carry grid-phase INPUT
|
| 316 |
+
# channels, so a beat head trained here just copies the channels
|
| 317 |
+
# and collapses at inference (p50 723 ms). Mask beat supervision
|
| 318 |
+
# (-1) on slot windows; the head learns from gridless time-mode
|
| 319 |
+
# windows in dual training instead.
|
| 320 |
+
res = WINDOW if self.beat_hires else WINDOW // 4
|
| 321 |
+
beat_t = torch.full((res, 2), -1.0)
|
| 322 |
+
|
| 323 |
+
in_t = torch.tensor(seq, dtype=torch.long)
|
| 324 |
+
return (x, torch.tensor(seq, dtype=torch.long), prefix_len, aux_t,
|
| 325 |
+
torch.zeros(0), 0, beat_t, in_t)
|
| 326 |
+
|
| 327 |
+
def __getitem__(self, i):
|
| 328 |
+
if self.dual:
|
| 329 |
+
sid = self.items[i % len(self.items)][0]
|
| 330 |
+
src = sid.split("x")[0] if "x" in sid else sid
|
| 331 |
+
rngm = random.Random(i * 104729 + (self._rng.randint(0, 1 << 30) if self.train else 0))
|
| 332 |
+
if self._measures(src) is not None and (not self.train or rngm.random() < self.dual_slot_p):
|
| 333 |
+
return self._getitem_slot(i)
|
| 334 |
+
# else: fall through to the time path (mode token + blank grid channels)
|
| 335 |
+
elif self.slot:
|
| 336 |
+
return self._getitem_slot(i)
|
| 337 |
+
sid, course, level, n_frames = self.items[i % len(self.items)]
|
| 338 |
+
mel, notes = self._load(sid)
|
| 339 |
+
times = notes[f"{course}_t"]
|
| 340 |
+
classes = notes[f"{course}_c"]
|
| 341 |
+
|
| 342 |
+
# rhythm-preserving tempo augmentation: crop a longer/shorter source
|
| 343 |
+
# window and resample it to WINDOW (mel-domain time stretch); note
|
| 344 |
+
# times scale by the same factor, so all rhythmic relations survive.
|
| 345 |
+
rng0 = random.Random(i * 31337 + (self._rng.randint(0, 1 << 30) if self.train else 0))
|
| 346 |
+
r = 1.0
|
| 347 |
+
if self.tempo_aug and rng0.random() < 0.5:
|
| 348 |
+
r = rng0.uniform(0.9, 1.111)
|
| 349 |
+
src_len = int(round(WINDOW * r))
|
| 350 |
+
|
| 351 |
+
max_start = max(0, n_frames - src_len)
|
| 352 |
+
rng = random.Random(i * 7919 + (self._rng.randint(0, 1 << 30) if self.train else 0))
|
| 353 |
+
if self.train and max_start > 0:
|
| 354 |
+
if self.importance:
|
| 355 |
+
# importance sampling: bias window starts toward note-dense /
|
| 356 |
+
# informative regions (uniform floor keeps sparse regions seen)
|
| 357 |
+
cands = np.arange(0, max_start + 1, int(FPS)) # 1s stride
|
| 358 |
+
lo = np.searchsorted(times, cands / FPS)
|
| 359 |
+
hi = np.searchsorted(times, cands / FPS + WINDOW / FPS)
|
| 360 |
+
w = (hi - lo).astype(np.float64) + 3.0
|
| 361 |
+
start = int(rng.choices(cands.tolist(), weights=w.tolist())[0])
|
| 362 |
+
start = min(max_start, start + rng.randint(0, int(FPS))) # sub-second jitter
|
| 363 |
+
else:
|
| 364 |
+
start = rng.randint(0, max_start)
|
| 365 |
+
else: # deterministic pseudo-random window per item (not always the intro)
|
| 366 |
+
start = (i * 2654435761) % (max_start + 1) if max_start > 0 else 0
|
| 367 |
+
|
| 368 |
+
t0 = start / FPS
|
| 369 |
+
t1 = (start + src_len) / FPS
|
| 370 |
+
sel = (times >= t0) & (times < t1)
|
| 371 |
+
frames = np.round((times[sel] - t0) * FPS / r).astype(np.int64)
|
| 372 |
+
frames = np.clip(frames, 0, WINDOW - 1)
|
| 373 |
+
pairs = list(zip(frames.tolist(), classes[sel].tolist()))
|
| 374 |
+
|
| 375 |
+
sib = None
|
| 376 |
+
if self.sibling: # skeleton hint from the nearest easier course
|
| 377 |
+
order = ["easy", "normal", "hard", "oni", "ura"]
|
| 378 |
+
sib_pairs = []
|
| 379 |
+
if not (self.train and rng0.random() < 0.2): # p=0.2: train without hint
|
| 380 |
+
for c2 in reversed(order[: order.index(course)]):
|
| 381 |
+
if f"{c2}_t" in notes:
|
| 382 |
+
t2, c2c = notes[f"{c2}_t"], notes[f"{c2}_c"]
|
| 383 |
+
s2 = (t2 >= t0) & (t2 < t1) & (c2c <= 3) # hits only
|
| 384 |
+
f2 = np.clip(np.round((t2[s2] - t0) * FPS / r), 0, WINDOW - 1)
|
| 385 |
+
ev = list(zip(f2.astype(np.int64).tolist(), c2c[s2].tolist()))
|
| 386 |
+
if len(ev) > 12: # even subsample to 12 slots
|
| 387 |
+
idx2 = np.linspace(0, len(ev) - 1, 12).astype(int)
|
| 388 |
+
ev = [ev[k] for k in idx2]
|
| 389 |
+
sib_pairs = ev
|
| 390 |
+
break
|
| 391 |
+
sib = sib_pairs
|
| 392 |
+
|
| 393 |
+
ctx = None
|
| 394 |
+
if self.use_ctx:
|
| 395 |
+
from .generate import CTX_LEN, HIT_CLASSES
|
| 396 |
+
|
| 397 |
+
hit_ids = [NOTE_CLASSES.index(c) for c in HIT_CLASSES]
|
| 398 |
+
prev = (times >= t0 - 3.0) & (times < t0)
|
| 399 |
+
tail = [int(c) for c in classes[prev] if int(c) in hit_ids][-CTX_LEN:]
|
| 400 |
+
if self.train and rng.random() < 0.1:
|
| 401 |
+
tail = [] # simulate missing context (first window)
|
| 402 |
+
elif self.train:
|
| 403 |
+
tail = [c if rng.random() > 0.1 else rng.choice(hit_ids) for c in tail]
|
| 404 |
+
ctx = [-1] * (CTX_LEN - len(tail)) + tail
|
| 405 |
+
|
| 406 |
+
style = None
|
| 407 |
+
if self.styles is not None:
|
| 408 |
+
style = self.styles.get(f"{sid}:{course}", -1)
|
| 409 |
+
|
| 410 |
+
sync_band = None
|
| 411 |
+
beats_w = db_w = None
|
| 412 |
+
if self.sync_token or self.beat_head:
|
| 413 |
+
src_sid = sid.split("x")[0] if "x" in sid else sid
|
| 414 |
+
bts, dbs = self._beats(src_sid)
|
| 415 |
+
if bts is not None:
|
| 416 |
+
# window-local, tempo-scaled beat grid
|
| 417 |
+
bsel = (bts >= t0) & (bts < t1)
|
| 418 |
+
beats_w = (bts[bsel] - t0) / r
|
| 419 |
+
dsel = (dbs >= t0) & (dbs < t1)
|
| 420 |
+
db_w = (dbs[dsel] - t0) / r
|
| 421 |
+
if self.sync_token:
|
| 422 |
+
sync_band = lhl_band(
|
| 423 |
+
np.array([f / FPS for f, c in pairs if NOTE_CLASSES[c] != "end"]),
|
| 424 |
+
beats_w, db_w)
|
| 425 |
+
|
| 426 |
+
plan_slice = None
|
| 427 |
+
if self.plans is not None:
|
| 428 |
+
blocks = self.plans.get(f"{sid}:{course}") or []
|
| 429 |
+
# blocks overlapping this window; times already in source timescale,
|
| 430 |
+
# window is [t0, t1) in the same scale (r rescales note frames only,
|
| 431 |
+
# block membership is decided in source time)
|
| 432 |
+
plan_slice = [(b[2], b[3]) for b in blocks if b[1] > t0 and b[0] < t1]
|
| 433 |
+
|
| 434 |
+
cplx = None
|
| 435 |
+
if self.complexity:
|
| 436 |
+
from .vocab import complexity_band
|
| 437 |
+
cplx = complexity_band(pairs)
|
| 438 |
+
|
| 439 |
+
seq, prefix_len = encode_window(
|
| 440 |
+
VOCAB, course, level, pairs, cond_drop=self.cond_drop, rng=rng,
|
| 441 |
+
ctx_types=ctx, sib_pairs=sib, style=style, sync_band=sync_band,
|
| 442 |
+
plan_slice=plan_slice, complexity=cplx,
|
| 443 |
+
mode=("time" if self.dual else None),
|
| 444 |
+
)
|
| 445 |
+
in_seq = None
|
| 446 |
+
if self.mask_infill > 0 and rng.random() < self.mask_infill:
|
| 447 |
+
# skeleton->color curriculum: decoder INPUT sees MASK where note
|
| 448 |
+
# types were; gold targets keep the true types
|
| 449 |
+
note_ids = set(VOCAB.note.values())
|
| 450 |
+
in_seq = [VOCAB.mask if (i >= prefix_len and tok in note_ids) else tok
|
| 451 |
+
for i, tok in enumerate(seq)]
|
| 452 |
+
if len(seq) > MAX_TGT: # truncate overly dense windows, keep EOS
|
| 453 |
+
seq = seq[: MAX_TGT - 1] + [VOCAB.eos]
|
| 454 |
+
if VOCAB.is_time(seq[-2]): # do not end on a dangling TIME token
|
| 455 |
+
seq = seq[:-2] + [VOCAB.eos]
|
| 456 |
+
|
| 457 |
+
x = mel[:, start : start + src_len].astype(np.float32)
|
| 458 |
+
if x.shape[1] < src_len:
|
| 459 |
+
x = np.pad(x, ((0, 0), (0, src_len - x.shape[1])), constant_values=np.log(1e-5))
|
| 460 |
+
x = torch.from_numpy(x)
|
| 461 |
+
if src_len != WINDOW: # mel-domain time stretch to the fixed window size
|
| 462 |
+
x = torch.nn.functional.interpolate(
|
| 463 |
+
x[None], size=WINDOW, mode="linear", align_corners=False
|
| 464 |
+
)[0]
|
| 465 |
+
if self.tempo_aug and rng0.random() < 0.3: # mild pitch shift (mel-bin roll)
|
| 466 |
+
k = rng0.randint(-4, 4)
|
| 467 |
+
if k:
|
| 468 |
+
x = torch.roll(x, k, dims=0)
|
| 469 |
+
if k > 0:
|
| 470 |
+
x[:k] = x.min()
|
| 471 |
+
else:
|
| 472 |
+
x[k:] = x.min()
|
| 473 |
+
if self.spec_augment:
|
| 474 |
+
for _ in range(2): # freq masks
|
| 475 |
+
w = rng.randint(0, 12)
|
| 476 |
+
f0 = rng.randint(0, x.shape[0] - w) if w else 0
|
| 477 |
+
if w:
|
| 478 |
+
x[f0 : f0 + w] = x.mean()
|
| 479 |
+
for _ in range(2): # time masks
|
| 480 |
+
w = rng.randint(0, 24)
|
| 481 |
+
f0 = rng.randint(0, x.shape[1] - w) if w else 0
|
| 482 |
+
if w:
|
| 483 |
+
x[:, f0 : f0 + w] = x.mean()
|
| 484 |
+
x = x + (rng.random() * 1.38 - 0.69) # gain +-6 dB in log space
|
| 485 |
+
if self.dual: # time mode carries no grid: phase channels = -1
|
| 486 |
+
x = torch.cat([x, torch.full((2, WINDOW), -1.0)], dim=0)
|
| 487 |
+
|
| 488 |
+
aux_t = torch.zeros(0)
|
| 489 |
+
if self.aux: # onset heatmap at encoder resolution (WINDOW // 4)
|
| 490 |
+
aux_t = torch.zeros(WINDOW // 4)
|
| 491 |
+
for f, c in pairs:
|
| 492 |
+
if NOTE_CLASSES[c] != "end":
|
| 493 |
+
aux_t[min(f // 4, WINDOW // 4 - 1)] = 1.0
|
| 494 |
+
aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, 1))
|
| 495 |
+
aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, -1))
|
| 496 |
+
|
| 497 |
+
beat_t = torch.zeros(0)
|
| 498 |
+
if self.beat_head:
|
| 499 |
+
res = WINDOW if self.beat_hires else WINDOW // 4
|
| 500 |
+
div = 1 if self.beat_hires else 4
|
| 501 |
+
beat_t = torch.zeros(res, 2)
|
| 502 |
+
for arr, ch in ((beats_w, 0), (db_w, 1)):
|
| 503 |
+
if arr is None:
|
| 504 |
+
continue
|
| 505 |
+
for bt in arr:
|
| 506 |
+
k = int(bt * FPS) // div
|
| 507 |
+
if 0 <= k < res:
|
| 508 |
+
beat_t[k, ch] = 1.0
|
| 509 |
+
for ch in range(2):
|
| 510 |
+
col = beat_t[:, ch]
|
| 511 |
+
beat_t[:, ch] = torch.max(col, 0.5 * torch.roll(col, 1))
|
| 512 |
+
beat_t[:, ch] = torch.max(beat_t[:, ch], 0.5 * torch.roll(col, -1))
|
| 513 |
+
|
| 514 |
+
gsum = torch.zeros(0)
|
| 515 |
+
posb = 0
|
| 516 |
+
if self.global_ctx:
|
| 517 |
+
gsum = torch.from_numpy(song_summary(np.asarray(mel)))
|
| 518 |
+
posb = min(15, int(16 * start / max(n_frames, 1)))
|
| 519 |
+
|
| 520 |
+
in_t = torch.tensor(in_seq if in_seq is not None else seq, dtype=torch.long)
|
| 521 |
+
return (x, torch.tensor(seq, dtype=torch.long), prefix_len, aux_t, gsum, posb,
|
| 522 |
+
beat_t, in_t)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
def lhl_band(note_times, beats, downbeats, band_width=0.6):
|
| 526 |
+
"""Longuet-Higgins & Lee (1984) style syncopation, simplified to the
|
| 527 |
+
eighth-note grid, normalized per bar, bucketed into 6 bands."""
|
| 528 |
+
if beats is None or len(beats) < 4 or len(note_times) < 2:
|
| 529 |
+
return -1
|
| 530 |
+
beat_len = float(np.median(np.diff(beats))) if len(beats) > 1 else 0.5
|
| 531 |
+
grid = [] # (time, metrical level)
|
| 532 |
+
dbset = set(np.round(downbeats, 3)) if downbeats is not None else set()
|
| 533 |
+
for b in beats:
|
| 534 |
+
lvl = 3 if round(float(b), 3) in dbset else 2
|
| 535 |
+
grid.append((float(b), lvl))
|
| 536 |
+
grid.append((float(b) + beat_len / 2, 1)) # eighth positions
|
| 537 |
+
grid.sort()
|
| 538 |
+
tol = 0.04
|
| 539 |
+
score = 0.0
|
| 540 |
+
for i, (gt_pos, lvl) in enumerate(grid[:-1]):
|
| 541 |
+
has_note = np.any(np.abs(note_times - gt_pos) < tol)
|
| 542 |
+
nxt_pos, nxt_lvl = grid[i + 1]
|
| 543 |
+
nxt_note = np.any(np.abs(note_times - nxt_pos) < tol)
|
| 544 |
+
if has_note and not nxt_note and nxt_lvl > lvl:
|
| 545 |
+
score += nxt_lvl - lvl
|
| 546 |
+
n_bars = max(1.0, len([g for g in grid if g[1] == 3]))
|
| 547 |
+
return min(5, int(score / n_bars / band_width))
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def collate(batch):
|
| 551 |
+
xs, seqs, plens, auxs, gsums, posbs, beats, inseqs = zip(*batch)
|
| 552 |
+
x = torch.stack(xs)
|
| 553 |
+
maxlen = max(len(s) for s in seqs)
|
| 554 |
+
tgt = torch.full((len(seqs), maxlen), VOCAB.pad, dtype=torch.long)
|
| 555 |
+
loss_mask = torch.zeros((len(seqs), maxlen), dtype=torch.bool)
|
| 556 |
+
for i, (s, pl) in enumerate(zip(seqs, plens)):
|
| 557 |
+
tgt[i, : len(s)] = s
|
| 558 |
+
loss_mask[i, pl : len(s)] = True # loss on events + EOS only
|
| 559 |
+
aux = torch.stack(auxs) if auxs[0].numel() else None
|
| 560 |
+
gsum = torch.stack(gsums) if gsums[0].numel() else None
|
| 561 |
+
posb = torch.tensor(posbs, dtype=torch.long)
|
| 562 |
+
beat = torch.stack(beats) if beats[0].numel() else None
|
| 563 |
+
in_tgt = torch.full((len(inseqs), maxlen), VOCAB.pad, dtype=torch.long)
|
| 564 |
+
for i, sq in enumerate(inseqs):
|
| 565 |
+
in_tgt[i, : len(sq)] = sq
|
| 566 |
+
return x, tgt, loss_mask, aux, gsum, posb, beat, in_tgt
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def load_split_ids(cache_dir, val_songs=40, seed=42, use_aug=False):
|
| 570 |
+
"""Song-level split: train / val (carved from train) / test (provided).
|
| 571 |
+
|
| 572 |
+
Augmented variants (speed-aug entries with "aug": true) are never used for
|
| 573 |
+
val, and variants whose source song is in val are excluded from train
|
| 574 |
+
(no leakage). The base split is stable regardless of augmentation.
|
| 575 |
+
"""
|
| 576 |
+
with open(os.path.join(cache_dir, "index.json")) as f:
|
| 577 |
+
index = json.load(f)
|
| 578 |
+
base_train = sorted(e["id"] for e in index
|
| 579 |
+
if e["split"] == "train" and not e.get("aug"))
|
| 580 |
+
test_ids = sorted(e["id"] for e in index if e["split"] == "test")
|
| 581 |
+
rng = random.Random(seed)
|
| 582 |
+
rng.shuffle(base_train)
|
| 583 |
+
val_ids = base_train[:val_songs]
|
| 584 |
+
train_ids = base_train[val_songs:]
|
| 585 |
+
if use_aug:
|
| 586 |
+
val_set = set(val_ids)
|
| 587 |
+
train_ids = train_ids + sorted(
|
| 588 |
+
e["id"] for e in index
|
| 589 |
+
if e.get("aug") and e.get("src") not in val_set)
|
| 590 |
+
return train_ids, val_ids, test_ids
|
softchart/evaluate.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Objective evaluation metrics for generated charts."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def match_onsets(ref_times, est_times, tol=0.05):
|
| 9 |
+
"""Greedy bipartite matching (mir_eval style). Returns list of (ri, ei)."""
|
| 10 |
+
matches = []
|
| 11 |
+
ri, ei = 0, 0
|
| 12 |
+
used_r = set()
|
| 13 |
+
# standard approach: for each est, match nearest unmatched ref within tol
|
| 14 |
+
ref = np.asarray(ref_times)
|
| 15 |
+
est = np.asarray(est_times)
|
| 16 |
+
order = np.argsort(est)
|
| 17 |
+
ref_order = np.argsort(ref)
|
| 18 |
+
ref_sorted = ref[ref_order]
|
| 19 |
+
taken = np.zeros(len(ref), dtype=bool)
|
| 20 |
+
for e_i in order:
|
| 21 |
+
t = est[e_i]
|
| 22 |
+
lo = np.searchsorted(ref_sorted, t - tol)
|
| 23 |
+
hi = np.searchsorted(ref_sorted, t + tol)
|
| 24 |
+
best, best_d = -1, tol + 1
|
| 25 |
+
for k in range(lo, hi):
|
| 26 |
+
rid = ref_order[k]
|
| 27 |
+
if taken[rid]:
|
| 28 |
+
continue
|
| 29 |
+
d = abs(ref_sorted[k] - t)
|
| 30 |
+
if d < best_d:
|
| 31 |
+
best, best_d = rid, d
|
| 32 |
+
if best >= 0:
|
| 33 |
+
taken[best] = True
|
| 34 |
+
matches.append((best, e_i))
|
| 35 |
+
return matches
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def onset_prf(ref_times, est_times, tol=0.05):
|
| 39 |
+
if len(ref_times) == 0 and len(est_times) == 0:
|
| 40 |
+
return 1.0, 1.0, 1.0
|
| 41 |
+
if len(ref_times) == 0 or len(est_times) == 0:
|
| 42 |
+
return 0.0, 0.0, 0.0
|
| 43 |
+
m = len(match_onsets(ref_times, est_times, tol))
|
| 44 |
+
p = m / len(est_times)
|
| 45 |
+
r = m / len(ref_times)
|
| 46 |
+
f = 2 * p * r / (p + r) if p + r > 0 else 0.0
|
| 47 |
+
return p, r, f
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def type_accuracy(ref, est, tol=0.05):
|
| 51 |
+
"""ref/est: list of (time, class). Accuracy + per-class stats on matched onsets."""
|
| 52 |
+
if not ref or not est:
|
| 53 |
+
return None
|
| 54 |
+
rt = [t for t, _ in ref]
|
| 55 |
+
et = [t for t, _ in est]
|
| 56 |
+
matches = match_onsets(rt, et, tol)
|
| 57 |
+
if not matches:
|
| 58 |
+
return None
|
| 59 |
+
correct = sum(1 for ri, ei in matches if ref[ri][1] == est[ei][1])
|
| 60 |
+
per_class = {}
|
| 61 |
+
for ri, ei in matches:
|
| 62 |
+
c = ref[ri][1]
|
| 63 |
+
d = per_class.setdefault(c, [0, 0])
|
| 64 |
+
d[1] += 1
|
| 65 |
+
if est[ei][1] == c:
|
| 66 |
+
d[0] += 1
|
| 67 |
+
# binary don/ka accuracy (big variants folded in)
|
| 68 |
+
fold = lambda c: "don" if "don" in c else ("ka" if "ka" in c else c)
|
| 69 |
+
correct2 = sum(1 for ri, ei in matches if fold(ref[ri][1]) == fold(est[ei][1]))
|
| 70 |
+
return {
|
| 71 |
+
"acc": correct / len(matches),
|
| 72 |
+
"acc_donka": correct2 / len(matches),
|
| 73 |
+
"n_matched": len(matches),
|
| 74 |
+
"per_class": {c: v[0] / v[1] for c, v in per_class.items()},
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def density(events, span=None):
|
| 79 |
+
times = [t for t, c in events if c in HIT_CLASSES]
|
| 80 |
+
if len(times) < 2:
|
| 81 |
+
return 0.0
|
| 82 |
+
span = span or (max(times) - min(times))
|
| 83 |
+
return len(times) / max(span, 1e-6) if span > 5 else 0.0
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def ioi_hist(events, bpm, bins=None):
|
| 87 |
+
"""Inter-onset intervals in beat units, snapped to musical fractions."""
|
| 88 |
+
if bins is None:
|
| 89 |
+
bins = [0.125, 1 / 6, 0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]
|
| 90 |
+
times = sorted(t for t, c in events if c in HIT_CLASSES)
|
| 91 |
+
if len(times) < 3 or not bpm or bpm <= 0:
|
| 92 |
+
return None
|
| 93 |
+
ioi = np.diff(times) * bpm / 60.0 # beats
|
| 94 |
+
hist = np.zeros(len(bins) + 1)
|
| 95 |
+
for x in ioi:
|
| 96 |
+
d = [abs(x - b) / b for b in bins]
|
| 97 |
+
j = int(np.argmin(d))
|
| 98 |
+
if d[j] < 0.2:
|
| 99 |
+
hist[j] += 1
|
| 100 |
+
else:
|
| 101 |
+
hist[-1] += 1 # off-grid
|
| 102 |
+
s = hist.sum()
|
| 103 |
+
return hist / s if s > 0 else None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def js_divergence(p, q, eps=1e-9):
|
| 107 |
+
p = np.asarray(p) + eps
|
| 108 |
+
q = np.asarray(q) + eps
|
| 109 |
+
p, q = p / p.sum(), q / q.sum()
|
| 110 |
+
m = (p + q) / 2
|
| 111 |
+
kl = lambda a, b: float(np.sum(a * np.log(a / b)))
|
| 112 |
+
return 0.5 * kl(p, m) + 0.5 * kl(q, m)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def ngram_dist(events, n=2):
|
| 116 |
+
"""Distribution over note-class n-grams (hits only)."""
|
| 117 |
+
seq = [c for _, c in sorted(events) if c in HIT_CLASSES]
|
| 118 |
+
if len(seq) < n + 1:
|
| 119 |
+
return {}
|
| 120 |
+
counts = {}
|
| 121 |
+
for i in range(len(seq) - n + 1):
|
| 122 |
+
g = tuple(seq[i : i + n])
|
| 123 |
+
counts[g] = counts.get(g, 0) + 1
|
| 124 |
+
tot = sum(counts.values())
|
| 125 |
+
return {g: c / tot for g, c in counts.items()}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def ngram_js(ref_events, est_events, n=2):
|
| 129 |
+
pr = ngram_dist(ref_events, n)
|
| 130 |
+
pe = ngram_dist(est_events, n)
|
| 131 |
+
if not pr or not pe:
|
| 132 |
+
return None
|
| 133 |
+
keys = sorted(set(pr) | set(pe))
|
| 134 |
+
return js_divergence([pr.get(k, 0) for k in keys], [pe.get(k, 0) for k in keys])
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def distinct_n(events, n=3):
|
| 138 |
+
seq = [c for _, c in sorted(events) if c in HIT_CLASSES]
|
| 139 |
+
if len(seq) < n:
|
| 140 |
+
return None
|
| 141 |
+
grams = [tuple(seq[i : i + n]) for i in range(len(seq) - n + 1)]
|
| 142 |
+
return len(set(grams)) / len(grams)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def spearman(x, y):
|
| 146 |
+
from scipy.stats import spearmanr
|
| 147 |
+
|
| 148 |
+
if len(x) < 2:
|
| 149 |
+
return None
|
| 150 |
+
r = spearmanr(x, y).statistic
|
| 151 |
+
return None if np.isnan(r) else float(r)
|
softchart/generate.py
CHANGED
|
@@ -12,26 +12,9 @@ Features:
|
|
| 12 |
import numpy as np
|
| 13 |
import torch
|
| 14 |
|
| 15 |
-
from .checkpoint import (attach_generator_capabilities,
|
| 16 |
-
parse_generator_checkpoint)
|
| 17 |
-
from .meter import resolve_meter_layout
|
| 18 |
from .model import ChartModel
|
| 19 |
from .vocab import FPS, MAX_TGT, N_LEVELS, NOTE_CLASSES, VOCAB, WINDOW
|
| 20 |
|
| 21 |
-
G_LEN = 100
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def song_summary(mel):
|
| 25 |
-
"""Summarize a complete song into fixed relative-time sections."""
|
| 26 |
-
n_mels, frames = mel.shape
|
| 27 |
-
if frames < G_LEN:
|
| 28 |
-
raise ValueError(f"song has only {frames} mel frames; need at least {G_LEN}")
|
| 29 |
-
edges = np.linspace(0, frames, G_LEN + 1, dtype=np.int64)
|
| 30 |
-
summary = np.empty((G_LEN, n_mels), dtype=np.float32)
|
| 31 |
-
for index, (start, end) in enumerate(zip(edges[:-1], edges[1:])):
|
| 32 |
-
summary[index] = mel[:, start:end].astype(np.float32).mean(axis=1)
|
| 33 |
-
return summary
|
| 34 |
-
|
| 35 |
HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
|
| 36 |
SPAN_CLASSES = ("roll", "roll_big", "balloon")
|
| 37 |
|
|
@@ -50,15 +33,73 @@ def _autocast(device):
|
|
| 50 |
CTX_LEN = 12 # fixed-length previous-window context (note-type tokens)
|
| 51 |
MIN_GAP_FRAMES = 4 # ~46 ms minimum inter-note gap enforced during decoding
|
| 52 |
LATTICE_RATIOS = (1 / 3, 0.5, 2 / 3, 1.0, 4 / 3, 1.5, 2.0, 3.0) # allowed IOI ratios
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def load_model(ckpt_path, device="cuda"):
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
model.eval()
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
return model
|
| 63 |
|
| 64 |
|
|
@@ -69,23 +110,20 @@ def load_hf(repo_or_dir, device="cuda"):
|
|
| 69 |
|
| 70 |
hf = SoftChartGenerator.from_pretrained(repo_or_dir).to(device).eval()
|
| 71 |
m = hf.net
|
| 72 |
-
caps = hf.capabilities
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
m.
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
# prevents a semantically different flat-global model from loading as
|
| 82 |
-
# a local-only generator.
|
| 83 |
-
raise ValueError("HF global-context capability disagrees with architecture")
|
| 84 |
return m
|
| 85 |
|
| 86 |
|
| 87 |
def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
|
| 88 |
-
sib_pairs=None, style=None, sync_band=None,
|
| 89 |
plan_slice=None, mode=None):
|
| 90 |
v = VOCAB
|
| 91 |
seq = [v.bos]
|
|
@@ -104,11 +142,14 @@ def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
|
|
| 104 |
cls = c if isinstance(c, int) else NOTE_CLASSES.index(c)
|
| 105 |
seq += [v.time(int(f)), v.note[NOTE_CLASSES[cls]]]
|
| 106 |
seq += [v.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 112 |
if style is not None: # -1 = style-capable model, no specific style requested
|
| 113 |
seq.append(v.style[style] if style >= 0 else v.unk_cond)
|
| 114 |
if sync_band is not None:
|
|
@@ -124,38 +165,10 @@ def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
|
|
| 124 |
return seq
|
| 125 |
|
| 126 |
|
| 127 |
-
def _conditioning_token_ids():
|
| 128 |
-
"""Token ids whose *values* CFG is allowed to remove.
|
| 129 |
-
|
| 130 |
-
Structural markers (BOS/SEP/SIB/PLAN/mode), prior-window note context, and
|
| 131 |
-
sibling-chart events deliberately stay intact. Positional masking such as
|
| 132 |
-
``prefix[:, -n:]`` is wrong as soon as a variable-size plan or another
|
| 133 |
-
field follows the basic course/level/density tuple.
|
| 134 |
-
"""
|
| 135 |
-
fields = (
|
| 136 |
-
VOCAB.course, VOCAB.level, VOCAB.dens, VOCAB.style, VOCAB.sync,
|
| 137 |
-
VOCAB.cplx, VOCAB.pdens, VOCAB.pflag,
|
| 138 |
-
)
|
| 139 |
-
return frozenset(tok for field in fields for tok in field.values())
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
_CONDITIONING_IDS = _conditioning_token_ids()
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def _make_unconditional_prefixes(prefixes):
|
| 146 |
-
"""Return CFG prefixes with named condition values replaced by UNK."""
|
| 147 |
-
unc = prefixes.clone()
|
| 148 |
-
is_condition = torch.zeros_like(unc, dtype=torch.bool)
|
| 149 |
-
for tok in _CONDITIONING_IDS:
|
| 150 |
-
is_condition |= unc == tok
|
| 151 |
-
return unc.masked_fill(is_condition, VOCAB.unk_cond)
|
| 152 |
-
|
| 153 |
-
|
| 154 |
@torch.no_grad()
|
| 155 |
def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=0.95,
|
| 156 |
greedy=False, seed=0, cfg_w=0.0, gsum=None, pos_buckets=None,
|
| 157 |
-
lattice=False, min_gap=MIN_GAP_FRAMES
|
| 158 |
-
position_limits=None):
|
| 159 |
"""mels: (B, n_mels, WINDOW); prefixes: list of B equal-length token lists.
|
| 160 |
Returns list of lists of (frame, note_class)."""
|
| 161 |
v = VOCAB
|
|
@@ -180,21 +193,11 @@ def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=
|
|
| 180 |
|
| 181 |
seqs = torch.tensor(prefixes, dtype=torch.long, device=device)
|
| 182 |
if use_cfg: # rows B..2B: same ctx, conditions replaced by UNK
|
| 183 |
-
unc =
|
|
|
|
| 184 |
seqs = torch.cat([seqs, unc], dim=0)
|
| 185 |
memory = torch.cat([memory, memory], dim=0)
|
| 186 |
|
| 187 |
-
if position_limits is None:
|
| 188 |
-
position_limits = torch.full((B,), WINDOW, dtype=torch.long, device=device)
|
| 189 |
-
else:
|
| 190 |
-
position_limits = torch.as_tensor(position_limits, dtype=torch.long,
|
| 191 |
-
device=device)
|
| 192 |
-
if position_limits.shape != (B,):
|
| 193 |
-
raise ValueError(f"position_limits must have shape ({B},), got "
|
| 194 |
-
f"{tuple(position_limits.shape)}")
|
| 195 |
-
if ((position_limits < 1) | (position_limits > WINDOW)).any():
|
| 196 |
-
raise ValueError(f"position_limits must be within [1, {WINDOW}]")
|
| 197 |
-
|
| 198 |
done = torch.zeros(B, dtype=torch.bool, device=device)
|
| 199 |
last_time = torch.full((B,), -min_gap, dtype=torch.long, device=device)
|
| 200 |
expect_note = torch.zeros(B, dtype=torch.bool, device=device)
|
|
@@ -220,8 +223,7 @@ def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=
|
|
| 220 |
time_pos = torch.arange(WINDOW, device=device).unsqueeze(0)
|
| 221 |
sub = torch.full((len(idx), logits.shape[1]), float("-inf"), device=device)
|
| 222 |
sub[:, v.eos] = 0.0
|
| 223 |
-
ok =
|
| 224 |
-
& (time_pos < position_limits[idx].unsqueeze(1)))
|
| 225 |
tmask = torch.where(
|
| 226 |
ok, torch.zeros_like(sub[:, :WINDOW]),
|
| 227 |
torch.full_like(sub[:, :WINDOW], float("-inf")),
|
|
@@ -315,19 +317,13 @@ def _decode_pass(model, wins, starts, course, level, density_bucket, ctx_lists,
|
|
| 315 |
g = pb = None
|
| 316 |
if gsum is not None:
|
| 317 |
g = gsum.unsqueeze(0).expand(chunk.shape[0], -1, -1)
|
| 318 |
-
n_pos = (100 if getattr(model, "_hierarchical_ctx", False)
|
| 319 |
-
else getattr(getattr(model, "pos_emb", None),
|
| 320 |
-
"num_embeddings", 16))
|
| 321 |
pb = torch.tensor(
|
| 322 |
-
[min(
|
| 323 |
-
for j in range(chunk.shape[0])],
|
| 324 |
dtype=torch.long)
|
| 325 |
-
limits = [min(WINDOW, max(1, int(T) - starts[i + j]))
|
| 326 |
-
for j in range(chunk.shape[0])]
|
| 327 |
evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
|
| 328 |
temperature=temperature, top_p=top_p, seed=seed + i,
|
| 329 |
cfg_w=cfg_w, gsum=g, pos_buckets=pb, lattice=lattice,
|
| 330 |
-
|
| 331 |
for j, events in enumerate(evs):
|
| 332 |
per_window.append(events)
|
| 333 |
t_off = starts[i + j] / FPS
|
|
@@ -347,7 +343,7 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
|
|
| 347 |
if isinstance(mel, np.ndarray):
|
| 348 |
mel = torch.from_numpy(mel.astype(np.float32))
|
| 349 |
T = mel.shape[1]
|
| 350 |
-
starts = list(range(0, max(T, 1), WINDOW))
|
| 351 |
dual = getattr(model, "_dual", False)
|
| 352 |
wins = []
|
| 353 |
for s in starts:
|
|
@@ -371,7 +367,9 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
|
|
| 371 |
plan_default = getattr(model, "_has_plan", False) and plan is None
|
| 372 |
gsum = None
|
| 373 |
if getattr(model, "_has_gctx", False):
|
| 374 |
-
|
|
|
|
|
|
|
| 375 |
|
| 376 |
all_events, per_window = _decode_pass(
|
| 377 |
model, wins, starts, course, level, density_bucket,
|
|
@@ -394,6 +392,29 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
|
|
| 394 |
sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default,
|
| 395 |
mode=("time" if dual else None))
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
all_events.sort(key=lambda e: e[0])
|
| 398 |
hits, spans = [], []
|
| 399 |
open_span = None
|
|
@@ -401,10 +422,15 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
|
|
| 401 |
for t, cls in all_events:
|
| 402 |
if cls in HIT_CLASSES:
|
| 403 |
if open_span is not None:
|
| 404 |
-
#
|
| 405 |
-
#
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
if t - last_hit_t < 0.025:
|
| 409 |
continue
|
| 410 |
hits.append({"t": round(t, 4), "type": cls})
|
|
@@ -413,102 +439,52 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
|
|
| 413 |
if open_span is None:
|
| 414 |
open_span = (t, cls)
|
| 415 |
elif cls == "end":
|
| 416 |
-
if open_span is not None:
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
# END always consumes the open span. A too-short span is
|
| 421 |
-
# malformed and dropped; keeping it open would let a later END
|
| 422 |
-
# close the wrong start and swallow subsequent span starts.
|
| 423 |
open_span = None
|
| 424 |
return {"hits": hits, "spans": spans, "course": course, "level": level,
|
| 425 |
"density_bucket": density_bucket}
|
| 426 |
|
| 427 |
|
| 428 |
-
def _grid_edges(grid, downbeats):
|
| 429 |
-
"""Return explicitly supplied measure edges without inventing a tail."""
|
| 430 |
-
if len(downbeats) == 0:
|
| 431 |
-
raise ValueError("slot generation requires at least one downbeat")
|
| 432 |
-
if not np.all(np.isfinite(downbeats)) or (np.diff(downbeats) <= 0).any():
|
| 433 |
-
raise ValueError("grid downbeats must be finite and strictly increasing")
|
| 434 |
-
if "measure_edges" not in grid:
|
| 435 |
-
raise ValueError(
|
| 436 |
-
"slot generation requires explicit measure_edges (starts plus a "
|
| 437 |
-
"trusted terminal edge); terminal duration is never extrapolated"
|
| 438 |
-
)
|
| 439 |
-
edges = np.asarray(grid["measure_edges"], dtype=np.float64)
|
| 440 |
-
if (len(edges) != len(downbeats) + 1
|
| 441 |
-
or not np.allclose(edges[:-1], downbeats, rtol=0, atol=1e-6)
|
| 442 |
-
or not np.all(np.isfinite(edges))
|
| 443 |
-
or (np.diff(edges) <= 0).any()):
|
| 444 |
-
raise ValueError("grid measure_edges must be valid starts plus one end")
|
| 445 |
-
return edges
|
| 446 |
-
|
| 447 |
-
|
| 448 |
@torch.no_grad()
|
| 449 |
def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None,
|
| 450 |
device="cuda", greedy=False, temperature=1.0, top_p=0.95,
|
| 451 |
seed=0, batch_windows=8, plan=None, on_progress=None):
|
| 452 |
-
"""
|
|
|
|
|
|
|
| 453 |
|
| 454 |
-
|
| 455 |
-
meters such as 1/16, 5/8, 7/8, and 8/4 are represented by
|
| 456 |
-
``96 * numerator / denominator`` slots. No quantization step exists:
|
| 457 |
-
``hits_slots`` are exact per-measure lattice positions.
|
| 458 |
-
|
| 459 |
-
mel: (n_mels, T); grid: trusted rational layout with explicit
|
| 460 |
-
``measure_edges`` including its terminal edge.
|
| 461 |
Returns {hits, spans (seconds, for rendering/metrics),
|
| 462 |
hits_slots [(measure, slot, cls)], spans_slots, n_measures}.
|
| 463 |
"""
|
| 464 |
-
from .vocab import SLOTS
|
| 465 |
|
| 466 |
if isinstance(mel, np.ndarray):
|
| 467 |
mel = torch.from_numpy(mel.astype(np.float32))
|
| 468 |
T = mel.shape[1]
|
|
|
|
| 469 |
db = np.asarray(grid["downbeats"], np.float64)
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
if (slot_safe.dtype != np.bool_ or slot_safe.ndim != 1
|
| 475 |
-
or len(slot_safe) != len(db)):
|
| 476 |
-
raise ValueError(
|
| 477 |
-
"measure_slot_safe must be one boolean per measure")
|
| 478 |
-
unsafe = np.flatnonzero(~slot_safe)
|
| 479 |
-
if len(unsafe):
|
| 480 |
-
raise ValueError(
|
| 481 |
-
"slot generation refuses unsafe BPM/DELAY/off-lattice "
|
| 482 |
-
f"measures: {unsafe.astype(int).tolist()}")
|
| 483 |
-
edges = _grid_edges(grid, db)
|
| 484 |
-
|
| 485 |
-
# meta = (first_measure_idx, measure_count, cumulative local slot offsets)
|
| 486 |
-
wins, metas = [], []
|
| 487 |
j = 0
|
| 488 |
while j < len(db):
|
| 489 |
K = 0
|
| 490 |
-
|
| 491 |
-
while (j + K < len(db)
|
| 492 |
-
and nslots + int(measure_slots[j + K]) <= WINDOW
|
| 493 |
and (edges[j + K + 1] - edges[j]) * FPS <= WINDOW):
|
| 494 |
-
nslots += int(measure_slots[j + K])
|
| 495 |
K += 1
|
| 496 |
if K == 0:
|
| 497 |
-
|
| 498 |
-
f"measure {j} does not fit the {WINDOW}-frame/{WINDOW}-slot "
|
| 499 |
-
"slot window"
|
| 500 |
-
)
|
| 501 |
t0 = edges[j]
|
| 502 |
f0 = int(round(t0 * FPS))
|
| 503 |
src = max(1, int(round((edges[j + K] - t0) * FPS)))
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
dtype=mel.dtype, device=mel.device)
|
| 508 |
-
src0, src1 = max(0, f0), min(T, f0 + src)
|
| 509 |
-
if src1 > src0:
|
| 510 |
-
dst0 = src0 - f0
|
| 511 |
-
x[:, dst0:dst0 + (src1 - src0)] = mel[:, src0:src1]
|
| 512 |
if x.shape[1] < WINDOW:
|
| 513 |
x = torch.nn.functional.pad(x, (0, WINDOW - x.shape[1]), value=float(np.log(1e-5)))
|
| 514 |
ph = torch.full((2, WINDOW), -1.0)
|
|
@@ -520,29 +496,22 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
|
|
| 520 |
continue
|
| 521 |
frac = (torch.arange(i0, i1, dtype=torch.float32) - a) / max(b - a, 1e-6)
|
| 522 |
ph[0, i0:i1] = frac
|
| 523 |
-
ph[1, i0:i1] = (frac *
|
| 524 |
wins.append(torch.cat([x, ph], dim=0))
|
| 525 |
-
|
| 526 |
-
np.array([0], dtype=np.int64),
|
| 527 |
-
np.cumsum(measure_slots[j:j + K], dtype=np.int64),
|
| 528 |
-
])
|
| 529 |
-
metas.append((j, K, local_offsets))
|
| 530 |
j += K
|
| 531 |
|
| 532 |
sib_default = getattr(model, "_has_sib", False)
|
| 533 |
style_val = -1 if getattr(model, "_has_style", False) else None
|
| 534 |
plan_blocks = plan if getattr(model, "_has_plan", False) else None
|
| 535 |
plan_default = getattr(model, "_has_plan", False) and plan is None
|
| 536 |
-
gsum = None
|
| 537 |
-
if getattr(model, "_has_gctx", False):
|
| 538 |
-
gsum = torch.from_numpy(song_summary(mel.detach().cpu().numpy()))
|
| 539 |
|
| 540 |
events = [] # (measure_global, slot, cls) in decode order
|
| 541 |
for i0 in range(0, len(wins), batch_windows):
|
| 542 |
chunk = torch.stack(wins[i0 : i0 + batch_windows])
|
| 543 |
prefixes = []
|
| 544 |
for b_ in range(chunk.shape[0]):
|
| 545 |
-
jj, KK
|
| 546 |
t0, t1 = edges[jj], edges[jj + KK]
|
| 547 |
prefixes.append(build_prefix(
|
| 548 |
course, level, density_bucket,
|
|
@@ -551,36 +520,20 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
|
|
| 551 |
plan_slice=([(b[2], b[3]) for b in plan_blocks
|
| 552 |
if b[1] > t0 and b[0] < t1] if plan_blocks is not None
|
| 553 |
else ([] if plan_default else None))))
|
| 554 |
-
limits = [int(metas[i0 + b_][2][-1])
|
| 555 |
-
for b_ in range(chunk.shape[0])]
|
| 556 |
-
g = pb = None
|
| 557 |
-
if gsum is not None:
|
| 558 |
-
g = gsum.unsqueeze(0).expand(chunk.shape[0], -1, -1)
|
| 559 |
-
n_pos = (100 if getattr(model, "_hierarchical_ctx", False)
|
| 560 |
-
else getattr(getattr(model, "pos_emb", None),
|
| 561 |
-
"num_embeddings", 16))
|
| 562 |
-
pb = torch.tensor([
|
| 563 |
-
min(n_pos - 1, max(0, int(
|
| 564 |
-
n_pos * edges[metas[i0 + b_][0]] * FPS / max(T, 1))))
|
| 565 |
-
for b_ in range(chunk.shape[0])
|
| 566 |
-
], dtype=torch.long)
|
| 567 |
evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
|
| 568 |
temperature=temperature, top_p=top_p, seed=seed + i0,
|
| 569 |
-
|
| 570 |
-
gsum=g, pos_buckets=pb)
|
| 571 |
for b_, w_ev in enumerate(evs):
|
| 572 |
-
jj, KK
|
| 573 |
for g, cls in w_ev:
|
| 574 |
-
if g <
|
| 575 |
-
|
| 576 |
-
events.append((jj + local_m, int(g - offsets[local_m]), cls))
|
| 577 |
if on_progress:
|
| 578 |
on_progress(min(i0 + batch_windows, len(wins)), len(wins))
|
| 579 |
|
| 580 |
events.sort(key=lambda e: (e[0], e[1]))
|
| 581 |
def _t(me, sl):
|
| 582 |
-
|
| 583 |
-
return float(edges[me] + sl / slots_in_measure * (edges[me + 1] - edges[me])) \
|
| 584 |
if me < len(edges) - 1 else float(edges[-1])
|
| 585 |
|
| 586 |
hits, spans, hits_slots, spans_slots = [], [], [], []
|
|
@@ -589,11 +542,15 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
|
|
| 589 |
for me, sl, cls in events:
|
| 590 |
t = _t(me, sl)
|
| 591 |
if cls in HIT_CLASSES:
|
| 592 |
-
# A hit is never a valid implicit span terminator. Keeping the
|
| 593 |
-
# authored hit and dropping the malformed, unclosed span avoids
|
| 594 |
-
# inventing an end position that the decoder did not emit.
|
| 595 |
if open_span is not None:
|
| 596 |
-
open_span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
if (me, sl) == last_key:
|
| 598 |
continue
|
| 599 |
hits.append({"t": round(t, 4), "type": cls})
|
|
@@ -603,21 +560,13 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
|
|
| 603 |
if open_span is None:
|
| 604 |
open_span = (me, sl, t, cls)
|
| 605 |
elif cls == "end":
|
| 606 |
-
if open_span is not None:
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
spans.append({"t0": round(open_span[2], 4), "t1": round(t, 4),
|
| 612 |
-
"type": open_span[3]})
|
| 613 |
-
spans_slots.append(
|
| 614 |
-
(open_span[0], open_span[1], me, sl, open_span[3]))
|
| 615 |
open_span = None
|
| 616 |
n_meas = (metas[-1][0] + metas[-1][1]) if metas else 0
|
| 617 |
return {"hits": hits, "spans": spans, "hits_slots": hits_slots,
|
| 618 |
"spans_slots": spans_slots, "n_measures": n_meas,
|
| 619 |
-
"measure_slots": measure_slots[:n_meas].tolist(),
|
| 620 |
-
"measure_num": measure_num[:n_meas].tolist(),
|
| 621 |
-
"measure_den": measure_den[:n_meas].tolist(),
|
| 622 |
-
"slots_per_4_4": SLOTS,
|
| 623 |
"course": course, "level": level, "density_bucket": density_bucket}
|
|
|
|
| 12 |
import numpy as np
|
| 13 |
import torch
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
from .model import ChartModel
|
| 16 |
from .vocab import FPS, MAX_TGT, N_LEVELS, NOTE_CLASSES, VOCAB, WINDOW
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
|
| 19 |
SPAN_CLASSES = ("roll", "roll_big", "balloon")
|
| 20 |
|
|
|
|
| 33 |
CTX_LEN = 12 # fixed-length previous-window context (note-type tokens)
|
| 34 |
MIN_GAP_FRAMES = 4 # ~46 ms minimum inter-note gap enforced during decoding
|
| 35 |
LATTICE_RATIOS = (1 / 3, 0.5, 2 / 3, 1.0, 4 / 3, 1.5, 2.0, 3.0) # allowed IOI ratios
|
| 36 |
+
# GT span-length p99 per class (train stats); generated spans beyond this are
|
| 37 |
+
# truncated — a visual audit found generated balloons up to ~10s vs GT p50 ~1s
|
| 38 |
+
SPAN_MAX = {"roll": 3.5, "roll_big": 4.0, "balloon": 6.5}
|
| 39 |
|
| 40 |
|
| 41 |
def load_model(ckpt_path, device="cuda"):
|
| 42 |
+
ck = torch.load(ckpt_path, map_location=device)
|
| 43 |
+
sd = ck["model"]
|
| 44 |
+
vocab_size = sd["tok_emb.weight"].shape[0]
|
| 45 |
+
aux = any(k.startswith("aux") for k in sd)
|
| 46 |
+
gctx = any(k.startswith("gsum_proj") for k in sd)
|
| 47 |
+
a = ck.get("args", {}) or {}
|
| 48 |
+
front_ch = sd["frontend.0.weight"].shape[1] # 128 (clean) or 130 (legacy slot)
|
| 49 |
+
emb_w = sd["tok_emb.weight"].shape[1]
|
| 50 |
+
d_model = a.get("d_model", 512)
|
| 51 |
+
# clean_phase (v1.6): encoder frontend is 128ch and a phase_proj is present.
|
| 52 |
+
clean_phase = bool(a.get("clean_phase", False)) or ("phase_proj.0.weight" in sd)
|
| 53 |
+
# legacy slot models fed phase to the encoder (in_ch=130); clean models keep
|
| 54 |
+
# in_ch=130 at the data level but only 128 reach the frontend.
|
| 55 |
+
in_ch = 130 if (clean_phase or front_ch == 130) else front_ch
|
| 56 |
+
# v1.7 arch flags: prefer saved args; fall back to state-dict keys so
|
| 57 |
+
# args-less checkpoints of the new arch still load
|
| 58 |
+
adapter_rank_ffn = int(a.get("adapter_rank_ffn", 0) or 0)
|
| 59 |
+
if not adapter_rank_ffn and "decoder.ffn_adapters.0.down.weight" in sd:
|
| 60 |
+
adapter_rank_ffn = sd["decoder.ffn_adapters.0.down.weight"].shape[0]
|
| 61 |
+
depth_emb = bool(a.get("depth_emb", False)) or ("decoder.depth_emb" in sd)
|
| 62 |
+
model = ChartModel(
|
| 63 |
+
d_model=d_model, nhead=a.get("nhead", 8),
|
| 64 |
+
enc_layers=a.get("enc_layers", 6),
|
| 65 |
+
dec_layers=a.get("dec_layers", 6), ffn=a.get("ffn", 2048),
|
| 66 |
+
vocab_size=vocab_size, aux=aux, global_ctx=gctx,
|
| 67 |
+
func_time=a.get("func_time", False), in_ch=in_ch,
|
| 68 |
+
emb_factor=emb_w if emb_w != d_model else None,
|
| 69 |
+
clean_phase=clean_phase,
|
| 70 |
+
enc_share=a.get("enc_share") or None,
|
| 71 |
+
dec_share=a.get("dec_share") or None,
|
| 72 |
+
adapter_rank=int(a.get("adapter_rank", 0) or 0),
|
| 73 |
+
unique_layernorm=bool(a.get("unique_layernorm", False)),
|
| 74 |
+
adapter_rank_ffn=adapter_rank_ffn, depth_emb=depth_emb,
|
| 75 |
+
unshare_last_dec=bool(a.get("unshare_last_dec", False)),
|
| 76 |
+
).to(device)
|
| 77 |
+
if any(k.startswith("ptr") for k in sd):
|
| 78 |
+
model.enable_ptr()
|
| 79 |
+
if any(k.startswith("beat") for k in sd):
|
| 80 |
+
model.enable_beat(hires=any(k.startswith("beat.up") for k in sd))
|
| 81 |
+
model.to(device) # newly enabled heads default to CPU
|
| 82 |
+
model.load_state_dict(sd)
|
| 83 |
model.eval()
|
| 84 |
+
# capability detection: prefer explicit training args (vocab-size inference
|
| 85 |
+
# wrongly added prefix slots to models that never trained with them)
|
| 86 |
+
if a:
|
| 87 |
+
model._has_ctx = bool(a.get("ctx", False))
|
| 88 |
+
model._has_sib = bool(a.get("sibling", False))
|
| 89 |
+
model._has_style = bool(a.get("style", False))
|
| 90 |
+
model._has_sync = bool(a.get("sync_token", False))
|
| 91 |
+
model._has_plan = bool(a.get("plan", False))
|
| 92 |
+
model._dual = bool(a.get("dual", False))
|
| 93 |
+
model._slot = bool(a.get("slot", False)) or model._dual
|
| 94 |
+
else: # legacy fallback
|
| 95 |
+
model._has_ctx = vocab_size > VOCAB.sep
|
| 96 |
+
model._has_sib = vocab_size > VOCAB.sib
|
| 97 |
+
model._has_style = False
|
| 98 |
+
model._has_sync = False
|
| 99 |
+
model._has_plan = False
|
| 100 |
+
model._slot = False
|
| 101 |
+
model._dual = False
|
| 102 |
+
model._has_gctx = gctx
|
| 103 |
return model
|
| 104 |
|
| 105 |
|
|
|
|
| 110 |
|
| 111 |
hf = SoftChartGenerator.from_pretrained(repo_or_dir).to(device).eval()
|
| 112 |
m = hf.net
|
| 113 |
+
caps = hf.capabilities or {}
|
| 114 |
+
m._has_ctx = bool(caps.get("ctx", False))
|
| 115 |
+
m._has_sib = bool(caps.get("sibling", False))
|
| 116 |
+
m._has_style = bool(caps.get("style", False))
|
| 117 |
+
m._has_sync = bool(caps.get("sync_token", False))
|
| 118 |
+
m._has_plan = bool(caps.get("plan", False))
|
| 119 |
+
m._dual = bool(caps.get("dual", False))
|
| 120 |
+
m._slot = bool(caps.get("slot", False)) or m._dual
|
| 121 |
+
m._has_gctx = m.gsum_proj is not None
|
|
|
|
|
|
|
|
|
|
| 122 |
return m
|
| 123 |
|
| 124 |
|
| 125 |
def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
|
| 126 |
+
uncond=False, sib_pairs=None, style=None, sync_band=None,
|
| 127 |
plan_slice=None, mode=None):
|
| 128 |
v = VOCAB
|
| 129 |
seq = [v.bos]
|
|
|
|
| 142 |
cls = c if isinstance(c, int) else NOTE_CLASSES.index(c)
|
| 143 |
seq += [v.time(int(f)), v.note[NOTE_CLASSES[cls]]]
|
| 144 |
seq += [v.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
|
| 145 |
+
if uncond:
|
| 146 |
+
seq += [v.unk_cond] * 3
|
| 147 |
+
else:
|
| 148 |
+
seq += [
|
| 149 |
+
v.course[course] if course else v.unk_cond,
|
| 150 |
+
v.level[max(1, min(N_LEVELS, level))] if level else v.unk_cond,
|
| 151 |
+
v.dens[density_bucket] if density_bucket is not None else v.unk_cond,
|
| 152 |
+
]
|
| 153 |
if style is not None: # -1 = style-capable model, no specific style requested
|
| 154 |
seq.append(v.style[style] if style >= 0 else v.unk_cond)
|
| 155 |
if sync_band is not None:
|
|
|
|
| 165 |
return seq
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
@torch.no_grad()
|
| 169 |
def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=0.95,
|
| 170 |
greedy=False, seed=0, cfg_w=0.0, gsum=None, pos_buckets=None,
|
| 171 |
+
lattice=False, n_cond=3, min_gap=MIN_GAP_FRAMES):
|
|
|
|
| 172 |
"""mels: (B, n_mels, WINDOW); prefixes: list of B equal-length token lists.
|
| 173 |
Returns list of lists of (frame, note_class)."""
|
| 174 |
v = VOCAB
|
|
|
|
| 193 |
|
| 194 |
seqs = torch.tensor(prefixes, dtype=torch.long, device=device)
|
| 195 |
if use_cfg: # rows B..2B: same ctx, conditions replaced by UNK
|
| 196 |
+
unc = seqs.clone()
|
| 197 |
+
unc[:, -n_cond:] = v.unk_cond
|
| 198 |
seqs = torch.cat([seqs, unc], dim=0)
|
| 199 |
memory = torch.cat([memory, memory], dim=0)
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
done = torch.zeros(B, dtype=torch.bool, device=device)
|
| 202 |
last_time = torch.full((B,), -min_gap, dtype=torch.long, device=device)
|
| 203 |
expect_note = torch.zeros(B, dtype=torch.bool, device=device)
|
|
|
|
| 223 |
time_pos = torch.arange(WINDOW, device=device).unsqueeze(0)
|
| 224 |
sub = torch.full((len(idx), logits.shape[1]), float("-inf"), device=device)
|
| 225 |
sub[:, v.eos] = 0.0
|
| 226 |
+
ok = time_pos >= (last_time[idx] + min_gap).unsqueeze(1)
|
|
|
|
| 227 |
tmask = torch.where(
|
| 228 |
ok, torch.zeros_like(sub[:, :WINDOW]),
|
| 229 |
torch.full_like(sub[:, :WINDOW], float("-inf")),
|
|
|
|
| 317 |
g = pb = None
|
| 318 |
if gsum is not None:
|
| 319 |
g = gsum.unsqueeze(0).expand(chunk.shape[0], -1, -1)
|
|
|
|
|
|
|
|
|
|
| 320 |
pb = torch.tensor(
|
| 321 |
+
[min(15, int(16 * starts[i + j] / max(T, 1))) for j in range(chunk.shape[0])],
|
|
|
|
| 322 |
dtype=torch.long)
|
|
|
|
|
|
|
| 323 |
evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
|
| 324 |
temperature=temperature, top_p=top_p, seed=seed + i,
|
| 325 |
cfg_w=cfg_w, gsum=g, pos_buckets=pb, lattice=lattice,
|
| 326 |
+
n_cond=4 if style is not None else 3)
|
| 327 |
for j, events in enumerate(evs):
|
| 328 |
per_window.append(events)
|
| 329 |
t_off = starts[i + j] / FPS
|
|
|
|
| 343 |
if isinstance(mel, np.ndarray):
|
| 344 |
mel = torch.from_numpy(mel.astype(np.float32))
|
| 345 |
T = mel.shape[1]
|
| 346 |
+
starts = list(range(0, max(T - 1, 1), WINDOW))
|
| 347 |
dual = getattr(model, "_dual", False)
|
| 348 |
wins = []
|
| 349 |
for s in starts:
|
|
|
|
| 367 |
plan_default = getattr(model, "_has_plan", False) and plan is None
|
| 368 |
gsum = None
|
| 369 |
if getattr(model, "_has_gctx", False):
|
| 370 |
+
from .data import song_summary
|
| 371 |
+
|
| 372 |
+
gsum = torch.from_numpy(song_summary(mel.numpy()))
|
| 373 |
|
| 374 |
all_events, per_window = _decode_pass(
|
| 375 |
model, wins, starts, course, level, density_bucket,
|
|
|
|
| 392 |
sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default,
|
| 393 |
mode=("time" if dual else None))
|
| 394 |
|
| 395 |
+
if True:
|
| 396 |
+
# rescue decoding: decoding occasionally EOSes a whole window early; if a
|
| 397 |
+
# window is near-empty while its audio is musically active, redo it with
|
| 398 |
+
# the opposite mode (sampling was empty -> greedy; greedy was empty ->
|
| 399 |
+
# sampled retry, since a greedy redo would reproduce the same output)
|
| 400 |
+
flux = [float(np.maximum(0, np.diff(w.numpy(), axis=1)).sum()) for w in wins]
|
| 401 |
+
med = float(np.median(flux)) if flux else 0.0
|
| 402 |
+
med_hits = float(np.median([len(ev) for ev in per_window])) if per_window else 0.0
|
| 403 |
+
retry = [i for i, ev in enumerate(per_window)
|
| 404 |
+
if (len(ev) < 4 and flux[i] > 0.3 * med)
|
| 405 |
+
or (len(ev) < 0.4 * med_hits and flux[i] > 0.7 * med)]
|
| 406 |
+
if retry:
|
| 407 |
+
r_evs, _ = _decode_pass(
|
| 408 |
+
model, [wins[i] for i in retry], [starts[i] for i in retry],
|
| 409 |
+
course, level, density_bucket, None, device, not greedy, 0.9,
|
| 410 |
+
top_p, seed + 7, 0.0, batch_windows, gsum=gsum, T=T,
|
| 411 |
+
lattice=lattice, sib_default=sib_default, style=style_val,
|
| 412 |
+
sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default,
|
| 413 |
+
mode=("time" if dual else None))
|
| 414 |
+
all_events = [e for i, w_ev in enumerate(per_window) if i not in retry
|
| 415 |
+
for e in [(starts[i] / FPS + f / FPS, c) for f, c in w_ev]]
|
| 416 |
+
all_events += r_evs
|
| 417 |
+
|
| 418 |
all_events.sort(key=lambda e: e[0])
|
| 419 |
hits, spans = [], []
|
| 420 |
open_span = None
|
|
|
|
| 422 |
for t, cls in all_events:
|
| 423 |
if cls in HIT_CLASSES:
|
| 424 |
if open_span is not None:
|
| 425 |
+
# a span whose end never arrived would swallow every following
|
| 426 |
+
# hit (visual audit: 20 s of silence) — force-close at SPAN_MAX
|
| 427 |
+
if t - open_span[0] > SPAN_MAX.get(open_span[1], 6.5):
|
| 428 |
+
spans.append({"t0": round(open_span[0], 4),
|
| 429 |
+
"t1": round(open_span[0] + SPAN_MAX.get(open_span[1], 6.5), 4),
|
| 430 |
+
"type": open_span[1]})
|
| 431 |
+
open_span = None
|
| 432 |
+
else:
|
| 433 |
+
continue # no hits inside an open span
|
| 434 |
if t - last_hit_t < 0.025:
|
| 435 |
continue
|
| 436 |
hits.append({"t": round(t, 4), "type": cls})
|
|
|
|
| 439 |
if open_span is None:
|
| 440 |
open_span = (t, cls)
|
| 441 |
elif cls == "end":
|
| 442 |
+
if open_span is not None and t - open_span[0] > 0.05:
|
| 443 |
+
t1_span = min(t, open_span[0] + SPAN_MAX.get(open_span[1], 6.5))
|
| 444 |
+
spans.append({"t0": round(open_span[0], 4), "t1": round(t1_span, 4),
|
| 445 |
+
"type": open_span[1]})
|
|
|
|
|
|
|
|
|
|
| 446 |
open_span = None
|
| 447 |
return {"hits": hits, "spans": spans, "course": course, "level": level,
|
| 448 |
"density_bucket": density_bucket}
|
| 449 |
|
| 450 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
@torch.no_grad()
|
| 452 |
def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None,
|
| 453 |
device="cuda", greedy=False, temperature=1.0, top_p=0.95,
|
| 454 |
seed=0, batch_windows=8, plan=None, on_progress=None):
|
| 455 |
+
"""Slot-mode generation: windows are anchored at the fitted barlines and the
|
| 456 |
+
decoder emits exact TJA lattice indices (measure*96 + slot). No
|
| 457 |
+
quantization step exists — 'hits_slots' ARE the chart.
|
| 458 |
|
| 459 |
+
mel: (n_mels, T); grid: fit_grid() result (must be trustworthy).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
Returns {hits, spans (seconds, for rendering/metrics),
|
| 461 |
hits_slots [(measure, slot, cls)], spans_slots, n_measures}.
|
| 462 |
"""
|
| 463 |
+
from .vocab import MEAS_MAX, SLOTS
|
| 464 |
|
| 465 |
if isinstance(mel, np.ndarray):
|
| 466 |
mel = torch.from_numpy(mel.astype(np.float32))
|
| 467 |
T = mel.shape[1]
|
| 468 |
+
dur = T / FPS
|
| 469 |
db = np.asarray(grid["downbeats"], np.float64)
|
| 470 |
+
bar = float(grid["bar"])
|
| 471 |
+
edges = np.append(db, db[-1] + bar) # measure m spans [edges[m], edges[m+1])
|
| 472 |
+
|
| 473 |
+
wins, metas = [], [] # meta = (first_measure_idx, K)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
j = 0
|
| 475 |
while j < len(db):
|
| 476 |
K = 0
|
| 477 |
+
while (j + K < len(db) and K < MEAS_MAX
|
|
|
|
|
|
|
| 478 |
and (edges[j + K + 1] - edges[j]) * FPS <= WINDOW):
|
|
|
|
| 479 |
K += 1
|
| 480 |
if K == 0:
|
| 481 |
+
break
|
|
|
|
|
|
|
|
|
|
| 482 |
t0 = edges[j]
|
| 483 |
f0 = int(round(t0 * FPS))
|
| 484 |
src = max(1, int(round((edges[j + K] - t0) * FPS)))
|
| 485 |
+
x = mel[:, f0 : f0 + src]
|
| 486 |
+
if x.shape[1] < src:
|
| 487 |
+
x = torch.nn.functional.pad(x, (0, src - x.shape[1]), value=float(np.log(1e-5)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
if x.shape[1] < WINDOW:
|
| 489 |
x = torch.nn.functional.pad(x, (0, WINDOW - x.shape[1]), value=float(np.log(1e-5)))
|
| 490 |
ph = torch.full((2, WINDOW), -1.0)
|
|
|
|
| 496 |
continue
|
| 497 |
frac = (torch.arange(i0, i1, dtype=torch.float32) - a) / max(b - a, 1e-6)
|
| 498 |
ph[0, i0:i1] = frac
|
| 499 |
+
ph[1, i0:i1] = (frac * 4) % 1.0
|
| 500 |
wins.append(torch.cat([x, ph], dim=0))
|
| 501 |
+
metas.append((j, K))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
j += K
|
| 503 |
|
| 504 |
sib_default = getattr(model, "_has_sib", False)
|
| 505 |
style_val = -1 if getattr(model, "_has_style", False) else None
|
| 506 |
plan_blocks = plan if getattr(model, "_has_plan", False) else None
|
| 507 |
plan_default = getattr(model, "_has_plan", False) and plan is None
|
|
|
|
|
|
|
|
|
|
| 508 |
|
| 509 |
events = [] # (measure_global, slot, cls) in decode order
|
| 510 |
for i0 in range(0, len(wins), batch_windows):
|
| 511 |
chunk = torch.stack(wins[i0 : i0 + batch_windows])
|
| 512 |
prefixes = []
|
| 513 |
for b_ in range(chunk.shape[0]):
|
| 514 |
+
jj, KK = metas[i0 + b_]
|
| 515 |
t0, t1 = edges[jj], edges[jj + KK]
|
| 516 |
prefixes.append(build_prefix(
|
| 517 |
course, level, density_bucket,
|
|
|
|
| 520 |
plan_slice=([(b[2], b[3]) for b in plan_blocks
|
| 521 |
if b[1] > t0 and b[0] < t1] if plan_blocks is not None
|
| 522 |
else ([] if plan_default else None))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
|
| 524 |
temperature=temperature, top_p=top_p, seed=seed + i0,
|
| 525 |
+
n_cond=4 if style_val is not None else 3, min_gap=2)
|
|
|
|
| 526 |
for b_, w_ev in enumerate(evs):
|
| 527 |
+
jj, KK = metas[i0 + b_]
|
| 528 |
for g, cls in w_ev:
|
| 529 |
+
if g < KK * SLOTS:
|
| 530 |
+
events.append((jj + g // SLOTS, g % SLOTS, cls))
|
|
|
|
| 531 |
if on_progress:
|
| 532 |
on_progress(min(i0 + batch_windows, len(wins)), len(wins))
|
| 533 |
|
| 534 |
events.sort(key=lambda e: (e[0], e[1]))
|
| 535 |
def _t(me, sl):
|
| 536 |
+
return float(edges[me] + sl / SLOTS * (edges[me + 1] - edges[me])) \
|
|
|
|
| 537 |
if me < len(edges) - 1 else float(edges[-1])
|
| 538 |
|
| 539 |
hits, spans, hits_slots, spans_slots = [], [], [], []
|
|
|
|
| 542 |
for me, sl, cls in events:
|
| 543 |
t = _t(me, sl)
|
| 544 |
if cls in HIT_CLASSES:
|
|
|
|
|
|
|
|
|
|
| 545 |
if open_span is not None:
|
| 546 |
+
if t - open_span[2] > SPAN_MAX.get(open_span[3], 6.5):
|
| 547 |
+
t1s = open_span[2] + SPAN_MAX.get(open_span[3], 6.5)
|
| 548 |
+
spans.append({"t0": round(open_span[2], 4), "t1": round(t1s, 4),
|
| 549 |
+
"type": open_span[3]})
|
| 550 |
+
spans_slots.append((open_span[0], open_span[1], me, sl, open_span[3]))
|
| 551 |
+
open_span = None
|
| 552 |
+
else:
|
| 553 |
+
continue
|
| 554 |
if (me, sl) == last_key:
|
| 555 |
continue
|
| 556 |
hits.append({"t": round(t, 4), "type": cls})
|
|
|
|
| 560 |
if open_span is None:
|
| 561 |
open_span = (me, sl, t, cls)
|
| 562 |
elif cls == "end":
|
| 563 |
+
if open_span is not None and t - open_span[2] > 0.05:
|
| 564 |
+
t1s = min(t, open_span[2] + SPAN_MAX.get(open_span[3], 6.5))
|
| 565 |
+
spans.append({"t0": round(open_span[2], 4), "t1": round(t1s, 4),
|
| 566 |
+
"type": open_span[3]})
|
| 567 |
+
spans_slots.append((open_span[0], open_span[1], me, sl, open_span[3]))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
open_span = None
|
| 569 |
n_meas = (metas[-1][0] + metas[-1][1]) if metas else 0
|
| 570 |
return {"hits": hits, "spans": spans, "hits_slots": hits_slots,
|
| 571 |
"spans_slots": spans_slots, "n_measures": n_meas,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
"course": course, "level": level, "density_bucket": density_bucket}
|
softchart/grid.py
CHANGED
|
@@ -59,9 +59,7 @@ def beat_activations(model, mel, device="cuda", hop=WINDOW // 2):
|
|
| 59 |
if w.shape[0] < model.in_ch: # dual/slot models: blank grid channels
|
| 60 |
w = torch.cat([w, torch.full((model.in_ch - w.shape[0], WINDOW), -1.0)])
|
| 61 |
with _autocast(device):
|
| 62 |
-
|
| 63 |
-
# when the chart decoder also owns a whole-song section encoder.
|
| 64 |
-
mem = model.encode_local(w[None].to(device))
|
| 65 |
pr = torch.sigmoid(model.beat(mem[:, -L:]).float())[0].cpu().numpy().T
|
| 66 |
if acc is None:
|
| 67 |
factor = WINDOW // pr.shape[1]
|
|
|
|
| 59 |
if w.shape[0] < model.in_ch: # dual/slot models: blank grid channels
|
| 60 |
w = torch.cat([w, torch.full((model.in_ch - w.shape[0], WINDOW), -1.0)])
|
| 61 |
with _autocast(device):
|
| 62 |
+
mem = model.encode(w[None].to(device))
|
|
|
|
|
|
|
| 63 |
pr = torch.sigmoid(model.beat(mem[:, -L:]).float())[0].cpu().numpy().T
|
| 64 |
if acc is None:
|
| 65 |
factor = WINDOW // pr.shape[1]
|
softchart/hf.py
CHANGED
|
@@ -1,17 +1,22 @@
|
|
| 1 |
"""Hugging Face Hub-compatible wrappers (safetensors + from_pretrained/push_to_hub).
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
Usage:
|
| 6 |
from softchart.hf import SoftChartGenerator
|
| 7 |
gen = SoftChartGenerator.from_pretrained("JacobLinCool/softchart-generator")
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 10 |
import torch.nn as nn
|
| 11 |
from huggingface_hub import PyTorchModelHubMixin
|
| 12 |
|
| 13 |
-
from .model import ChartModel
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class SoftChartGenerator(
|
|
@@ -24,21 +29,27 @@ class SoftChartGenerator(
|
|
| 24 |
"""Encoder-decoder chart generator. Config is stored as config.json and the
|
| 25 |
full architecture (condition tokens, heads) is reconstructed on load."""
|
| 26 |
|
| 27 |
-
def __init__(self, d_model=256,
|
| 28 |
-
|
| 29 |
-
vocab_size=VOCAB.size, aux=True, global_ctx=False, func_time=False,
|
| 30 |
ptr=False, beat=False, in_ch=None, emb_factor=None,
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 34 |
super().__init__()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
self.net = ChartModel(
|
| 36 |
-
d_model=d_model, nhead=nhead, enc_layers=enc_layers,
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
| 42 |
)
|
| 43 |
# capabilities: which condition axes this checkpoint was trained with,
|
| 44 |
# so inference knows which prefix tokens to emit
|
|
@@ -65,3 +76,29 @@ class SoftChartGenerator(
|
|
| 65 |
@property
|
| 66 |
def tok_emb(self):
|
| 67 |
return self.net.tok_emb
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Hugging Face Hub-compatible wrappers (safetensors + from_pretrained/push_to_hub).
|
| 2 |
|
| 3 |
+
Three model types, all <=16M params, MIT-licensed:
|
| 4 |
+
SoftChartGenerator audio log-mel -> chart event tokens (the main model; pr12)
|
| 5 |
+
SoftChartBeat audio log-mel -> beat/downbeat activations (barline anchor)
|
| 6 |
+
SoftChartPlanner block features -> per-block plan tokens (auto-planning)
|
| 7 |
|
| 8 |
Usage:
|
| 9 |
from softchart.hf import SoftChartGenerator
|
| 10 |
gen = SoftChartGenerator.from_pretrained("JacobLinCool/softchart-generator")
|
| 11 |
"""
|
| 12 |
|
| 13 |
+
import torch
|
| 14 |
import torch.nn as nn
|
| 15 |
from huggingface_hub import PyTorchModelHubMixin
|
| 16 |
|
| 17 |
+
from .model import ChartModel, sinusoidal
|
| 18 |
+
|
| 19 |
+
_CARD = "See https://github.com/JacobLinCool/SoftChart — MIT licensed."
|
| 20 |
|
| 21 |
|
| 22 |
class SoftChartGenerator(
|
|
|
|
| 29 |
"""Encoder-decoder chart generator. Config is stored as config.json and the
|
| 30 |
full architecture (condition tokens, heads) is reconstructed on load."""
|
| 31 |
|
| 32 |
+
def __init__(self, d_model=256, enc_layers=4, dec_layers=4, ffn=1024,
|
| 33 |
+
vocab_size=1802, aux=True, global_ctx=False, func_time=False,
|
|
|
|
| 34 |
ptr=False, beat=False, in_ch=None, emb_factor=None,
|
| 35 |
+
capabilities=None, nhead=8, clean_phase=False,
|
| 36 |
+
enc_share=None, dec_share=None, adapter_rank=0,
|
| 37 |
+
unique_layernorm=False, adapter_rank_ffn=0, depth_emb=False,
|
| 38 |
+
unshare_last_dec=False):
|
| 39 |
super().__init__()
|
| 40 |
+
# nhead and the v1.6/v1.7 sharing flags (enc_share/dec_share/adapter_rank/
|
| 41 |
+
# unique_layernorm/adapter_rank_ffn/depth_emb/unshare_last_dec) and
|
| 42 |
+
# clean_phase must be persisted in config.json: from_pretrained rebuilds
|
| 43 |
+
# the architecture from these kwargs alone, before any weights are loaded.
|
| 44 |
+
# Defaults reproduce the pre-v1.6 unshared architecture for older repos.
|
| 45 |
self.net = ChartModel(
|
| 46 |
+
d_model=d_model, nhead=nhead, enc_layers=enc_layers, dec_layers=dec_layers,
|
| 47 |
+
ffn=ffn, vocab_size=vocab_size, aux=aux, global_ctx=global_ctx,
|
| 48 |
+
func_time=func_time, ptr=ptr, beat=beat, in_ch=in_ch, emb_factor=emb_factor,
|
| 49 |
+
clean_phase=clean_phase, enc_share=enc_share, dec_share=dec_share,
|
| 50 |
+
adapter_rank=adapter_rank, unique_layernorm=unique_layernorm,
|
| 51 |
+
adapter_rank_ffn=adapter_rank_ffn, depth_emb=depth_emb,
|
| 52 |
+
unshare_last_dec=unshare_last_dec,
|
| 53 |
)
|
| 54 |
# capabilities: which condition axes this checkpoint was trained with,
|
| 55 |
# so inference knows which prefix tokens to emit
|
|
|
|
| 76 |
@property
|
| 77 |
def tok_emb(self):
|
| 78 |
return self.net.tok_emb
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class SoftChartPlanner(
|
| 82 |
+
nn.Module,
|
| 83 |
+
PyTorchModelHubMixin,
|
| 84 |
+
repo_url="https://github.com/JacobLinCool/SoftChart",
|
| 85 |
+
license="mit",
|
| 86 |
+
tags=["taiko", "rhythm-game", "planning", "music"],
|
| 87 |
+
):
|
| 88 |
+
"""Song-level plan generator: block audio summaries -> (density, flag) tokens."""
|
| 89 |
+
|
| 90 |
+
def __init__(self, feat_dim=261, d=192, layers=4, n_dens=8, n_flag=3):
|
| 91 |
+
super().__init__()
|
| 92 |
+
self.inp = nn.Linear(feat_dim, d)
|
| 93 |
+
self.course = nn.Embedding(5, d)
|
| 94 |
+
self.register_buffer("pos", sinusoidal(128, d), persistent=False)
|
| 95 |
+
enc = nn.TransformerEncoderLayer(d, 6, d * 4, 0.1, activation="gelu",
|
| 96 |
+
batch_first=True, norm_first=True)
|
| 97 |
+
self.enc = nn.TransformerEncoder(enc, layers, nn.LayerNorm(d))
|
| 98 |
+
self.h_dens = nn.Linear(d, n_dens)
|
| 99 |
+
self.h_flag = nn.Linear(d, n_flag)
|
| 100 |
+
|
| 101 |
+
def forward(self, x, cid):
|
| 102 |
+
h = self.inp(x) + self.course(cid)[:, None] + self.pos[: x.shape[1]]
|
| 103 |
+
h = self.enc(h)
|
| 104 |
+
return self.h_dens(h), self.h_flag(h)
|
softchart/model.py
CHANGED
|
@@ -33,42 +33,6 @@ class BeatHiRes(nn.Module):
|
|
| 33 |
return self.out(h).transpose(1, 2)
|
| 34 |
|
| 35 |
|
| 36 |
-
class HierarchicalSectionEncoder(nn.Module):
|
| 37 |
-
"""Encode a fixed relative-time summary of the complete song.
|
| 38 |
-
|
| 39 |
-
The local encoder remains dedicated to the current 20-second window. This
|
| 40 |
-
separate branch models section-to-section structure, marks the section that
|
| 41 |
-
contains the current window, and exposes its states directly to decoder
|
| 42 |
-
cross-attention. It is trained from scratch with the chart objective; no
|
| 43 |
-
external audio representation is used.
|
| 44 |
-
"""
|
| 45 |
-
|
| 46 |
-
def __init__(self, d, nhead, ffn, dropout, layers=2, sections=100):
|
| 47 |
-
super().__init__()
|
| 48 |
-
self.sections = sections
|
| 49 |
-
self.inp = nn.Linear(N_MELS, d)
|
| 50 |
-
self.pos = nn.Parameter(torch.randn(sections, d) * 0.02)
|
| 51 |
-
self.current = nn.Parameter(torch.randn(d) * 0.02)
|
| 52 |
-
layer = nn.TransformerEncoderLayer(
|
| 53 |
-
d, nhead, ffn, dropout, activation="gelu",
|
| 54 |
-
batch_first=True, norm_first=True,
|
| 55 |
-
)
|
| 56 |
-
self.encoder = nn.TransformerEncoder(
|
| 57 |
-
layer, layers, nn.LayerNorm(d), enable_nested_tensor=False)
|
| 58 |
-
|
| 59 |
-
def forward(self, summary, position):
|
| 60 |
-
if summary.ndim != 3 or summary.shape[1:] != (self.sections, N_MELS):
|
| 61 |
-
raise ValueError(
|
| 62 |
-
f"expected song summary (B,{self.sections},{N_MELS}), "
|
| 63 |
-
f"got {tuple(summary.shape)}"
|
| 64 |
-
)
|
| 65 |
-
position = position.to(summary.device).long().clamp(0, self.sections - 1)
|
| 66 |
-
h = self.inp(summary) + self.pos
|
| 67 |
-
marker = torch.zeros_like(h)
|
| 68 |
-
marker[torch.arange(h.shape[0], device=h.device), position] = self.current
|
| 69 |
-
return self.encoder(h + marker)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
def _parse_pattern(spec, n):
|
| 73 |
"""Layer-sharing schedule. spec is either an int (number of UNIQUE
|
| 74 |
physical layers, cycled to length n) or a comma string like "E0,E1,E0,E1"
|
|
@@ -91,18 +55,17 @@ def _parse_pattern(spec, n):
|
|
| 91 |
|
| 92 |
class Adapter(nn.Module):
|
| 93 |
"""Rank-r residual adapter applied after a (possibly reused) transformer
|
| 94 |
-
block: x + gate * up(gelu(down(x))).
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
state caused by zero-initializing both ``up`` and ``gate``."""
|
| 98 |
|
| 99 |
def __init__(self, d, rank):
|
| 100 |
super().__init__()
|
| 101 |
self.down = nn.Linear(d, rank, bias=False)
|
| 102 |
self.up = nn.Linear(rank, d, bias=False)
|
| 103 |
nn.init.normal_(self.down.weight, std=0.02)
|
| 104 |
-
nn.init.
|
| 105 |
-
self.gate = nn.Parameter(torch.
|
| 106 |
|
| 107 |
def forward(self, x):
|
| 108 |
return x + self.gate * self.up(nn.functional.gelu(self.down(x)))
|
|
@@ -111,17 +74,17 @@ class Adapter(nn.Module):
|
|
| 111 |
class SharedEncoder(nn.Module):
|
| 112 |
"""Encoder whose physical layers are reused according to a sharing pattern.
|
| 113 |
Each POSITION in the depth keeps its own (a) rank-r adapter and (b) final
|
| 114 |
-
LayerNorm
|
| 115 |
With pattern=None this is exactly n unique layers + a norm, matching the
|
| 116 |
stock nn.TransformerEncoder param count (adapters/pos-norms are opt-in).
|
| 117 |
|
| 118 |
-
v1.7 depth-diversity options (all default-off):
|
| 119 |
depth_emb: per-depth learned d-vector ADDED to the block input at each
|
| 120 |
reuse (params = depth x d, zero-init so init == hard sharing).
|
| 121 |
adapter_rank_ffn > 0: SPLIT adapters — instead of one post-block adapter,
|
| 122 |
each depth owns a rank-`adapter_rank` adapter on the attention sublayer
|
| 123 |
output and a rank-`adapter_rank_ffn` adapter on the FFN sublayer output
|
| 124 |
-
(patterning lives in the FFN).
|
| 125 |
"""
|
| 126 |
|
| 127 |
def __init__(self, d, nhead, ffn, dropout, pattern, adapter_rank=0,
|
|
@@ -160,7 +123,7 @@ class SharedEncoder(nn.Module):
|
|
| 160 |
if self.split:
|
| 161 |
# norm_first decomposition of nn.TransformerEncoderLayer with
|
| 162 |
# per-depth adapters applied to each sublayer OUTPUT (Houlsby
|
| 163 |
-
# placement)
|
| 164 |
x = x + self.attn_adapters[k](
|
| 165 |
layer._sa_block(layer.norm1(x), None, src_key_padding_mask))
|
| 166 |
x = x + self.ffn_adapters[k](layer._ff_block(layer.norm2(x)))
|
|
@@ -241,9 +204,6 @@ class ChartModel(nn.Module):
|
|
| 241 |
max_tgt=None,
|
| 242 |
aux=False,
|
| 243 |
global_ctx=False,
|
| 244 |
-
hierarchical_ctx=False,
|
| 245 |
-
section_layers=2,
|
| 246 |
-
section_ffn=None,
|
| 247 |
func_time=False,
|
| 248 |
ptr=False,
|
| 249 |
beat=False,
|
|
@@ -264,10 +224,6 @@ class ChartModel(nn.Module):
|
|
| 264 |
vocab_size = vocab_size or VOCAB.size
|
| 265 |
max_tgt = max_tgt or MAX_TGT
|
| 266 |
self.d_model = d_model
|
| 267 |
-
if global_ctx and hierarchical_ctx:
|
| 268 |
-
raise ValueError("global_ctx and hierarchical_ctx are mutually exclusive")
|
| 269 |
-
self.hierarchical_ctx = bool(hierarchical_ctx)
|
| 270 |
-
self._hierarchical_ctx = self.hierarchical_ctx
|
| 271 |
# clean_phase (v1.6): the encoder never sees the 2 grid-phase channels
|
| 272 |
# (in_ch stays N_MELS), and phase is injected into the DECODER memory
|
| 273 |
# only, via a small projection. This removes the condition leak that
|
|
@@ -311,9 +267,8 @@ class ChartModel(nn.Module):
|
|
| 311 |
enc_layer = nn.TransformerEncoderLayer(
|
| 312 |
d_model, nhead, ffn, dropout, activation="gelu",
|
| 313 |
batch_first=True, norm_first=True)
|
| 314 |
-
self.encoder = nn.TransformerEncoder(
|
| 315 |
-
|
| 316 |
-
enable_nested_tensor=False)
|
| 317 |
dec_layer = nn.TransformerDecoderLayer(
|
| 318 |
d_model, nhead, ffn, dropout, activation="gelu",
|
| 319 |
batch_first=True, norm_first=True)
|
|
@@ -351,17 +306,8 @@ class ChartModel(nn.Module):
|
|
| 351 |
self.out = nn.Linear(d_model, vocab_size, bias=False)
|
| 352 |
self.out.weight = self.tok_emb.weight # weight tying
|
| 353 |
self.dropout = nn.Dropout(dropout)
|
| 354 |
-
#
|
| 355 |
-
# predicted occupancy is also projected back into decoder memory, so
|
| 356 |
-
# the event realizer is explicitly conditioned on a coarse audio-led
|
| 357 |
-
# skeleton rather than learning placement and pattern in one flat pass.
|
| 358 |
-
if hierarchical_ctx and not aux:
|
| 359 |
-
raise ValueError("hierarchical_ctx requires the auxiliary skeleton head")
|
| 360 |
self.aux = nn.Linear(d_model, 1) if aux else None
|
| 361 |
-
self.skeleton_proj = nn.Linear(1, d_model) if hierarchical_ctx else None
|
| 362 |
-
if self.skeleton_proj is not None:
|
| 363 |
-
nn.init.zeros_(self.skeleton_proj.weight)
|
| 364 |
-
nn.init.zeros_(self.skeleton_proj.bias)
|
| 365 |
# pointer alignment head: each generated note must point to its audio
|
| 366 |
# frame (differentiable provenance; explainability that trains alignment)
|
| 367 |
self.ptr = nn.Linear(d_model, d_model) if ptr else None
|
|
@@ -377,18 +323,10 @@ class ChartModel(nn.Module):
|
|
| 377 |
if global_ctx:
|
| 378 |
self.gsum_proj = nn.Linear(N_MELS, d_model)
|
| 379 |
self.seg_emb = nn.Parameter(torch.zeros(2, d_model))
|
| 380 |
-
self.pos_emb = nn.Embedding(
|
| 381 |
self.gpos = nn.Parameter(torch.randn(128, d_model) * 0.02)
|
| 382 |
else:
|
| 383 |
self.gsum_proj = None
|
| 384 |
-
if hierarchical_ctx:
|
| 385 |
-
self.section_encoder = HierarchicalSectionEncoder(
|
| 386 |
-
d_model, nhead, section_ffn or (2 * d_model), dropout,
|
| 387 |
-
layers=section_layers,
|
| 388 |
-
)
|
| 389 |
-
self.hier_seg_emb = nn.Parameter(torch.zeros(2, d_model))
|
| 390 |
-
else:
|
| 391 |
-
self.section_encoder = None
|
| 392 |
# functional time embeddings: the 1728 TIME tokens share a sinusoidal
|
| 393 |
# basis + small projection instead of free embeddings (fewer params,
|
| 394 |
# neighbouring times get similar representations)
|
|
@@ -401,31 +339,15 @@ class ChartModel(nn.Module):
|
|
| 401 |
else:
|
| 402 |
self.time_proj = None
|
| 403 |
|
| 404 |
-
def _local_input(self, mel):
|
| 405 |
-
audio = mel[:, :N_MELS] if self.clean_phase else mel
|
| 406 |
-
h = self.frontend(audio).transpose(1, 2) # (B, T/4, d)
|
| 407 |
-
return h + self.enc_pos[: h.shape[1]]
|
| 408 |
-
|
| 409 |
-
def encode_local(self, mel):
|
| 410 |
-
"""Encode only the current window for beat/downbeat estimation."""
|
| 411 |
-
h = self._local_input(mel)
|
| 412 |
-
if self.section_encoder is not None:
|
| 413 |
-
h = h + self.hier_seg_emb[0]
|
| 414 |
-
return self.encoder(self.dropout(h))
|
| 415 |
-
|
| 416 |
def encode(self, mel, gsum=None, pos_bucket=None):
|
| 417 |
# mel: (B, C, T). clean_phase: C may be N_MELS (audio only) or N_MELS+2
|
| 418 |
# (audio + 2 grid-phase channels); only the audio channels reach the
|
| 419 |
# encoder, so the returned memory is CLEAN (phase-free) and safe for the
|
| 420 |
-
# beat head. gsum: (B,
|
| 421 |
-
# pos_bucket: (B,)
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
raise ValueError("hierarchical_ctx requires whole-song summary and position")
|
| 426 |
-
local = self.encoder(self.dropout(h + self.hier_seg_emb[0]))
|
| 427 |
-
sections = self.section_encoder(gsum, pos_bucket) + self.hier_seg_emb[1]
|
| 428 |
-
return torch.cat([sections, local], dim=1)
|
| 429 |
if self.gsum_proj is not None and gsum is not None:
|
| 430 |
g = self.gsum_proj(gsum) + self.seg_emb[1] + self.gpos[: gsum.shape[1]]
|
| 431 |
h = h + self.seg_emb[0]
|
|
@@ -438,25 +360,17 @@ class ChartModel(nn.Module):
|
|
| 438 |
Adds a projection of the grid-phase channels to the audio-frame slice
|
| 439 |
of memory; the beat head keeps reading the clean `memory`. No-op unless
|
| 440 |
clean_phase is on and phase channels are present. Time-mode windows
|
| 441 |
-
(raw phase = -1) get valid=0 and
|
| 442 |
-
out = memory.clone()
|
| 443 |
-
L = WINDOW // 4
|
| 444 |
nch = N_MELS
|
| 445 |
-
if self.clean_phase
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
L = p.shape[1]
|
| 455 |
-
out[:, -L:] = out[:, -L:] + p
|
| 456 |
-
if self.skeleton_proj is not None:
|
| 457 |
-
clean_local = memory[:, -L:]
|
| 458 |
-
skeleton = torch.sigmoid(self.aux(clean_local))
|
| 459 |
-
out[:, -L:] = out[:, -L:] + self.skeleton_proj(skeleton)
|
| 460 |
return out
|
| 461 |
|
| 462 |
def emb_matrix(self):
|
|
@@ -474,7 +388,7 @@ class ChartModel(nn.Module):
|
|
| 474 |
L = tgt_in.shape[1]
|
| 475 |
E = self.emb_matrix()
|
| 476 |
h = nn.functional.embedding(tgt_in, E) * math.sqrt(self.d_model) + self.dec_pos[:L]
|
| 477 |
-
mask =
|
| 478 |
pad_mask = tgt_in == VOCAB.pad
|
| 479 |
h = self.decoder(
|
| 480 |
self.dropout(h),
|
|
@@ -502,7 +416,7 @@ class ChartModel(nn.Module):
|
|
| 502 |
E = self.emb_matrix()
|
| 503 |
import math as _m
|
| 504 |
h = nn.functional.embedding(dec_in, E) * _m.sqrt(self.d_model) + self.dec_pos[:L]
|
| 505 |
-
dm =
|
| 506 |
h = self.decoder(self.dropout(h), dec_mem, tgt_mask=dm,
|
| 507 |
tgt_key_padding_mask=dec_in == VOCAB.pad, tgt_is_causal=True)
|
| 508 |
logits = h @ E.T
|
|
|
|
| 33 |
return self.out(h).transpose(1, 2)
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def _parse_pattern(spec, n):
|
| 37 |
"""Layer-sharing schedule. spec is either an int (number of UNIQUE
|
| 38 |
physical layers, cycled to length n) or a comma string like "E0,E1,E0,E1"
|
|
|
|
| 55 |
|
| 56 |
class Adapter(nn.Module):
|
| 57 |
"""Rank-r residual adapter applied after a (possibly reused) transformer
|
| 58 |
+
block: x + gate * up(gelu(down(x))). gate init 0 so the whole stack starts
|
| 59 |
+
identical to hard weight sharing; the adapter only differentiates a reused
|
| 60 |
+
physical layer as training moves the gate off zero."""
|
|
|
|
| 61 |
|
| 62 |
def __init__(self, d, rank):
|
| 63 |
super().__init__()
|
| 64 |
self.down = nn.Linear(d, rank, bias=False)
|
| 65 |
self.up = nn.Linear(rank, d, bias=False)
|
| 66 |
nn.init.normal_(self.down.weight, std=0.02)
|
| 67 |
+
nn.init.zeros_(self.up.weight)
|
| 68 |
+
self.gate = nn.Parameter(torch.zeros(1))
|
| 69 |
|
| 70 |
def forward(self, x):
|
| 71 |
return x + self.gate * self.up(nn.functional.gelu(self.down(x)))
|
|
|
|
| 74 |
class SharedEncoder(nn.Module):
|
| 75 |
"""Encoder whose physical layers are reused according to a sharing pattern.
|
| 76 |
Each POSITION in the depth keeps its own (a) rank-r adapter and (b) final
|
| 77 |
+
LayerNorm — so ΔW starts at 0 (hard sharing) but every position can drift.
|
| 78 |
With pattern=None this is exactly n unique layers + a norm, matching the
|
| 79 |
stock nn.TransformerEncoder param count (adapters/pos-norms are opt-in).
|
| 80 |
|
| 81 |
+
v1.7 depth-diversity options (all default-off, init == v1.6 behaviour):
|
| 82 |
depth_emb: per-depth learned d-vector ADDED to the block input at each
|
| 83 |
reuse (params = depth x d, zero-init so init == hard sharing).
|
| 84 |
adapter_rank_ffn > 0: SPLIT adapters — instead of one post-block adapter,
|
| 85 |
each depth owns a rank-`adapter_rank` adapter on the attention sublayer
|
| 86 |
output and a rank-`adapter_rank_ffn` adapter on the FFN sublayer output
|
| 87 |
+
(patterning lives in the FFN). Gate init 0 keeps init == hard sharing.
|
| 88 |
"""
|
| 89 |
|
| 90 |
def __init__(self, d, nhead, ffn, dropout, pattern, adapter_rank=0,
|
|
|
|
| 123 |
if self.split:
|
| 124 |
# norm_first decomposition of nn.TransformerEncoderLayer with
|
| 125 |
# per-depth adapters applied to each sublayer OUTPUT (Houlsby
|
| 126 |
+
# placement); at gate=0 this is bit-equal to the stock layer
|
| 127 |
x = x + self.attn_adapters[k](
|
| 128 |
layer._sa_block(layer.norm1(x), None, src_key_padding_mask))
|
| 129 |
x = x + self.ffn_adapters[k](layer._ff_block(layer.norm2(x)))
|
|
|
|
| 204 |
max_tgt=None,
|
| 205 |
aux=False,
|
| 206 |
global_ctx=False,
|
|
|
|
|
|
|
|
|
|
| 207 |
func_time=False,
|
| 208 |
ptr=False,
|
| 209 |
beat=False,
|
|
|
|
| 224 |
vocab_size = vocab_size or VOCAB.size
|
| 225 |
max_tgt = max_tgt or MAX_TGT
|
| 226 |
self.d_model = d_model
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
# clean_phase (v1.6): the encoder never sees the 2 grid-phase channels
|
| 228 |
# (in_ch stays N_MELS), and phase is injected into the DECODER memory
|
| 229 |
# only, via a small projection. This removes the condition leak that
|
|
|
|
| 267 |
enc_layer = nn.TransformerEncoderLayer(
|
| 268 |
d_model, nhead, ffn, dropout, activation="gelu",
|
| 269 |
batch_first=True, norm_first=True)
|
| 270 |
+
self.encoder = nn.TransformerEncoder(enc_layer, enc_layers,
|
| 271 |
+
nn.LayerNorm(d_model))
|
|
|
|
| 272 |
dec_layer = nn.TransformerDecoderLayer(
|
| 273 |
d_model, nhead, ffn, dropout, activation="gelu",
|
| 274 |
batch_first=True, norm_first=True)
|
|
|
|
| 306 |
self.out = nn.Linear(d_model, vocab_size, bias=False)
|
| 307 |
self.out.weight = self.tok_emb.weight # weight tying
|
| 308 |
self.dropout = nn.Dropout(dropout)
|
| 309 |
+
# auxiliary per-position onset-heatmap head on the encoder (v2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
self.aux = nn.Linear(d_model, 1) if aux else None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
# pointer alignment head: each generated note must point to its audio
|
| 312 |
# frame (differentiable provenance; explainability that trains alignment)
|
| 313 |
self.ptr = nn.Linear(d_model, d_model) if ptr else None
|
|
|
|
| 323 |
if global_ctx:
|
| 324 |
self.gsum_proj = nn.Linear(N_MELS, d_model)
|
| 325 |
self.seg_emb = nn.Parameter(torch.zeros(2, d_model))
|
| 326 |
+
self.pos_emb = nn.Embedding(16, d_model) # window position bucket
|
| 327 |
self.gpos = nn.Parameter(torch.randn(128, d_model) * 0.02)
|
| 328 |
else:
|
| 329 |
self.gsum_proj = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
# functional time embeddings: the 1728 TIME tokens share a sinusoidal
|
| 331 |
# basis + small projection instead of free embeddings (fewer params,
|
| 332 |
# neighbouring times get similar representations)
|
|
|
|
| 339 |
else:
|
| 340 |
self.time_proj = None
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
def encode(self, mel, gsum=None, pos_bucket=None):
|
| 343 |
# mel: (B, C, T). clean_phase: C may be N_MELS (audio only) or N_MELS+2
|
| 344 |
# (audio + 2 grid-phase channels); only the audio channels reach the
|
| 345 |
# encoder, so the returned memory is CLEAN (phase-free) and safe for the
|
| 346 |
+
# beat head. gsum: (B, G, n_mels) whole-song summary chunks;
|
| 347 |
+
# pos_bucket: (B,) window-position bucket in [0, 16).
|
| 348 |
+
audio = mel[:, :N_MELS] if self.clean_phase else mel
|
| 349 |
+
h = self.frontend(audio).transpose(1, 2) # (B, T/4, d)
|
| 350 |
+
h = h + self.enc_pos[: h.shape[1]]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
if self.gsum_proj is not None and gsum is not None:
|
| 352 |
g = self.gsum_proj(gsum) + self.seg_emb[1] + self.gpos[: gsum.shape[1]]
|
| 353 |
h = h + self.seg_emb[0]
|
|
|
|
| 360 |
Adds a projection of the grid-phase channels to the audio-frame slice
|
| 361 |
of memory; the beat head keeps reading the clean `memory`. No-op unless
|
| 362 |
clean_phase is on and phase channels are present. Time-mode windows
|
| 363 |
+
(raw phase = -1) get valid=0 and zeroed phase -> ~zero conditioning."""
|
|
|
|
|
|
|
| 364 |
nch = N_MELS
|
| 365 |
+
if not self.clean_phase or mel.shape[1] < nch + 2:
|
| 366 |
+
return memory
|
| 367 |
+
ph = mel[:, nch:nch + 2] # (B, 2, T) in [0,1); -1 marks no-grid frames
|
| 368 |
+
valid = (ph[:, :1] >= 0).float() # 1 where a grid exists, else 0
|
| 369 |
+
ph_in = torch.cat([ph.clamp(min=0.0), valid], dim=1) # (B, 3, T)
|
| 370 |
+
p = self.phase_proj(ph_in).transpose(1, 2) # (B, T/4, d)
|
| 371 |
+
L = p.shape[1]
|
| 372 |
+
out = memory.clone()
|
| 373 |
+
out[:, -L:] = out[:, -L:] + p # audio frames are the last L memory slots
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
return out
|
| 375 |
|
| 376 |
def emb_matrix(self):
|
|
|
|
| 388 |
L = tgt_in.shape[1]
|
| 389 |
E = self.emb_matrix()
|
| 390 |
h = nn.functional.embedding(tgt_in, E) * math.sqrt(self.d_model) + self.dec_pos[:L]
|
| 391 |
+
mask = nn.Transformer.generate_square_subsequent_mask(L, device=tgt_in.device)
|
| 392 |
pad_mask = tgt_in == VOCAB.pad
|
| 393 |
h = self.decoder(
|
| 394 |
self.dropout(h),
|
|
|
|
| 416 |
E = self.emb_matrix()
|
| 417 |
import math as _m
|
| 418 |
h = nn.functional.embedding(dec_in, E) * _m.sqrt(self.d_model) + self.dec_pos[:L]
|
| 419 |
+
dm = nn.Transformer.generate_square_subsequent_mask(L, device=tgt.device)
|
| 420 |
h = self.decoder(self.dropout(h), dec_mem, tgt_mask=dm,
|
| 421 |
tgt_key_padding_mask=dec_in == VOCAB.pad, tgt_is_causal=True)
|
| 422 |
logits = h @ E.T
|
softchart/preprocess.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert the HF dataset into per-song cache files: log-mel (.npy) + notes (.npz).
|
| 2 |
+
|
| 3 |
+
Usage (on the training machine):
|
| 4 |
+
python -m softchart.preprocess --out /workspace/softchart/cache [--split train]
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import argparse
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from collections import Counter
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from .vocab import HOP, N_FFT, N_MELS, NOTE_CLASSES, NOTE_TYPE_MAP, SR, COURSES
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def to_mono_22k(audio):
|
| 18 |
+
"""Handle both datasets<=3 dict audio and datasets>=4 torchcodec AudioDecoder."""
|
| 19 |
+
import torch
|
| 20 |
+
|
| 21 |
+
if isinstance(audio, dict):
|
| 22 |
+
arr = np.asarray(audio["array"], dtype=np.float32)
|
| 23 |
+
if arr.ndim == 2:
|
| 24 |
+
arr = arr.mean(axis=0)
|
| 25 |
+
sr = audio["sampling_rate"]
|
| 26 |
+
else: # torchcodec AudioDecoder
|
| 27 |
+
samples = audio.get_all_samples()
|
| 28 |
+
arr = samples.data.to(torch.float32).mean(dim=0).numpy()
|
| 29 |
+
sr = samples.sample_rate
|
| 30 |
+
if sr != SR:
|
| 31 |
+
import librosa
|
| 32 |
+
|
| 33 |
+
arr = librosa.resample(arr, orig_sr=sr, target_sr=SR, res_type="soxr_hq")
|
| 34 |
+
return torch.from_numpy(np.ascontiguousarray(arr))[None] # (1, T)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_mel_fb = None
|
| 38 |
+
_window = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def logmel(wav):
|
| 42 |
+
"""(1, T) float32 -> (n_mels, frames) float16 log-mel (torch.stft + librosa fb)."""
|
| 43 |
+
global _mel_fb, _window
|
| 44 |
+
import torch
|
| 45 |
+
|
| 46 |
+
if _mel_fb is None:
|
| 47 |
+
import librosa
|
| 48 |
+
|
| 49 |
+
fb = librosa.filters.mel(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
|
| 50 |
+
_mel_fb = torch.from_numpy(fb) # (n_mels, n_fft//2+1)
|
| 51 |
+
_window = torch.hann_window(N_FFT)
|
| 52 |
+
with torch.no_grad():
|
| 53 |
+
spec = torch.stft(
|
| 54 |
+
wav, N_FFT, hop_length=HOP, window=_window, center=True, return_complex=True
|
| 55 |
+
)[0]
|
| 56 |
+
power = spec.abs().pow(2) # (freq, frames)
|
| 57 |
+
m = torch.log(_mel_fb @ power + 1e-5)
|
| 58 |
+
return m.numpy().astype(np.float16)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def extract_notes(course_struct):
|
| 62 |
+
"""course struct -> (times, classes, level) or None."""
|
| 63 |
+
if course_struct is None or not course_struct.get("segments"):
|
| 64 |
+
return None
|
| 65 |
+
times, classes, bpms = [], [], []
|
| 66 |
+
unknown = Counter()
|
| 67 |
+
for seg in course_struct["segments"]:
|
| 68 |
+
for n in seg.get("notes") or []:
|
| 69 |
+
nt = n["note_type"]
|
| 70 |
+
canon = NOTE_TYPE_MAP.get(nt) or NOTE_TYPE_MAP.get(nt.lower())
|
| 71 |
+
if canon is None:
|
| 72 |
+
unknown[nt] += 1
|
| 73 |
+
continue
|
| 74 |
+
times.append(n["timestamp"])
|
| 75 |
+
classes.append(NOTE_CLASSES.index(canon))
|
| 76 |
+
if n.get("bpm"):
|
| 77 |
+
bpms.append(n["bpm"])
|
| 78 |
+
if not times:
|
| 79 |
+
return None
|
| 80 |
+
order = np.argsort(times, kind="stable")
|
| 81 |
+
return (
|
| 82 |
+
np.asarray(times, np.float64)[order],
|
| 83 |
+
np.asarray(classes, np.int8)[order],
|
| 84 |
+
int(course_struct.get("level") or 0),
|
| 85 |
+
float(np.median(bpms)) if bpms else 0.0,
|
| 86 |
+
unknown,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def main():
|
| 91 |
+
ap = argparse.ArgumentParser()
|
| 92 |
+
ap.add_argument("--out", default="cache")
|
| 93 |
+
ap.add_argument("--splits", nargs="+", default=["train", "test"])
|
| 94 |
+
args = ap.parse_args()
|
| 95 |
+
|
| 96 |
+
from datasets import Audio, load_dataset
|
| 97 |
+
|
| 98 |
+
ds = load_dataset("JacobLinCool/taiko-1000-parsed")
|
| 99 |
+
ds = ds.cast_column("audio", Audio(sampling_rate=SR))
|
| 100 |
+
os.makedirs(args.out, exist_ok=True)
|
| 101 |
+
index = []
|
| 102 |
+
unknown_total = Counter()
|
| 103 |
+
|
| 104 |
+
for split in args.splits:
|
| 105 |
+
d = ds[split]
|
| 106 |
+
for i in range(len(d)):
|
| 107 |
+
row = d[i]
|
| 108 |
+
sid = f"{split}_{i:05d}"
|
| 109 |
+
mel_path = os.path.join(args.out, f"{sid}.mel.npy")
|
| 110 |
+
notes_path = os.path.join(args.out, f"{sid}.notes.npz")
|
| 111 |
+
entry = {
|
| 112 |
+
"id": sid,
|
| 113 |
+
"split": split,
|
| 114 |
+
"title": (row.get("metadata") or {}).get("TITLE", ""),
|
| 115 |
+
"genre": (row.get("metadata") or {}).get("GENRE", ""),
|
| 116 |
+
"courses": {},
|
| 117 |
+
}
|
| 118 |
+
arrays = {}
|
| 119 |
+
for c in COURSES:
|
| 120 |
+
ex = extract_notes(row.get(c))
|
| 121 |
+
if ex is None:
|
| 122 |
+
continue
|
| 123 |
+
times, classes, level, bpm, unknown = ex
|
| 124 |
+
unknown_total.update(unknown)
|
| 125 |
+
arrays[f"{c}_t"] = times
|
| 126 |
+
arrays[f"{c}_c"] = classes
|
| 127 |
+
entry["courses"][c] = {
|
| 128 |
+
"level": level,
|
| 129 |
+
"n_notes": int(len(times)),
|
| 130 |
+
"bpm": bpm,
|
| 131 |
+
}
|
| 132 |
+
if not arrays:
|
| 133 |
+
print(f"skip {sid}: no charts")
|
| 134 |
+
continue
|
| 135 |
+
if not (os.path.exists(mel_path) and os.path.exists(notes_path)):
|
| 136 |
+
try:
|
| 137 |
+
mel = logmel(to_mono_22k(row["audio"]))
|
| 138 |
+
except Exception as e:
|
| 139 |
+
raise RuntimeError(f"{sid}: target-rate audio decode failed") from e
|
| 140 |
+
np.save(mel_path, mel)
|
| 141 |
+
np.savez(notes_path, **arrays)
|
| 142 |
+
entry["n_frames"] = int(mel.shape[1])
|
| 143 |
+
else:
|
| 144 |
+
entry["n_frames"] = int(np.load(mel_path, mmap_mode="r").shape[1])
|
| 145 |
+
index.append(entry)
|
| 146 |
+
if i % 50 == 0:
|
| 147 |
+
print(f"{split} {i}/{len(d)}", flush=True)
|
| 148 |
+
|
| 149 |
+
with open(os.path.join(args.out, "index.json"), "w") as f:
|
| 150 |
+
json.dump(index, f)
|
| 151 |
+
print("unknown note types:", dict(unknown_total))
|
| 152 |
+
print(f"done: {len(index)} songs")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
main()
|
softchart/proxies.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Automated proxies for playability, groove, and pattern structure.
|
| 2 |
+
|
| 3 |
+
Every proxy is computable without human input, from (events, bpm[, mel]).
|
| 4 |
+
Together with rhythm.py metrics they form the scorecard that closes the
|
| 5 |
+
evaluate -> diagnose -> improve loop.
|
| 6 |
+
|
| 7 |
+
Proxy design notes:
|
| 8 |
+
- playability = physical feasibility on the drum (min gaps, stream length,
|
| 9 |
+
big-note crowding) + learnable structure (motif reuse, compressibility)
|
| 10 |
+
- groove = alignment with the metrical grid (strong-beat rate, accent placement)
|
| 11 |
+
+ audio grounding (onset-envelope energy at note times)
|
| 12 |
+
Reference values come from ground-truth charts of the same course (see scorecard).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import zlib
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
|
| 20 |
+
BIG_CLASSES = ("don_big", "ka_big")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _hits(events):
|
| 24 |
+
return sorted((t, c) for t, c in events if c in HIT_CLASSES)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def min_gap_violation_rate(events, min_gap=0.045):
|
| 28 |
+
"""Fraction of consecutive hits closer than a physically playable gap."""
|
| 29 |
+
h = _hits(events)
|
| 30 |
+
if len(h) < 2:
|
| 31 |
+
return 0.0
|
| 32 |
+
ioi = np.diff([t for t, _ in h])
|
| 33 |
+
return float(np.mean(ioi < min_gap))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def stream_p99_nps(events, win=2.0):
|
| 37 |
+
"""99th percentile of local density (notes/sec in rolling windows) — stamina proxy."""
|
| 38 |
+
t = np.array([x for x, _ in _hits(events)])
|
| 39 |
+
if len(t) < 4:
|
| 40 |
+
return 0.0
|
| 41 |
+
counts = [np.sum((t >= x) & (t < x + win)) for x in np.arange(t[0], t[-1], 0.5)]
|
| 42 |
+
return float(np.percentile(counts, 99) / win) if counts else 0.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def big_crowding_rate(events, bpm):
|
| 46 |
+
"""Fraction of big notes followed by another hit within 0.45 beat.
|
| 47 |
+
|
| 48 |
+
Big notes are played with both hands; crowding them against the next note
|
| 49 |
+
is physically awkward and rarely done in official charts.
|
| 50 |
+
"""
|
| 51 |
+
h = _hits(events)
|
| 52 |
+
bigs = [(i, t) for i, (t, c) in enumerate(h) if c in BIG_CLASSES]
|
| 53 |
+
if not bigs or not bpm or bpm <= 0:
|
| 54 |
+
return None
|
| 55 |
+
thresh = 0.45 * 60.0 / bpm
|
| 56 |
+
crowded = sum(1 for i, t in bigs if i + 1 < len(h) and h[i + 1][0] - t < thresh)
|
| 57 |
+
return crowded / len(bigs)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def big_note_rate(events):
|
| 61 |
+
h = _hits(events)
|
| 62 |
+
if not h:
|
| 63 |
+
return 0.0
|
| 64 |
+
return sum(1 for _, c in h if c in BIG_CLASSES) / len(h)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _symbol_stream(events, bpm):
|
| 68 |
+
"""Quantized (ioi-class, note-type) symbol stream for structure metrics."""
|
| 69 |
+
h = _hits(events)
|
| 70 |
+
if len(h) < 3 or not bpm or bpm <= 0:
|
| 71 |
+
return []
|
| 72 |
+
beat = 60.0 / bpm
|
| 73 |
+
syms = []
|
| 74 |
+
for i in range(1, len(h)):
|
| 75 |
+
ioi = h[i][0] - h[i - 1][0]
|
| 76 |
+
frac = ioi / beat
|
| 77 |
+
# classify IOI into musical classes
|
| 78 |
+
classes = [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1.0, 1.5, 2.0]
|
| 79 |
+
j = int(np.argmin([abs(frac - c) / c for c in classes]))
|
| 80 |
+
ioi_cls = j if abs(frac - classes[j]) / classes[j] < 0.25 else len(classes)
|
| 81 |
+
syms.append((ioi_cls, h[i][1]))
|
| 82 |
+
return syms
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def motif_reuse(events, bpm, n=4):
|
| 86 |
+
"""Fraction of n-grams (rhythm+type) that occur at least twice in the chart.
|
| 87 |
+
|
| 88 |
+
Human charts repeat and transform motifs; random note salads do not.
|
| 89 |
+
"""
|
| 90 |
+
syms = _symbol_stream(events, bpm)
|
| 91 |
+
if len(syms) < n + 2:
|
| 92 |
+
return None
|
| 93 |
+
grams = [tuple(syms[i : i + n]) for i in range(len(syms) - n + 1)]
|
| 94 |
+
from collections import Counter
|
| 95 |
+
|
| 96 |
+
counts = Counter(grams)
|
| 97 |
+
return float(sum(1 for g in grams if counts[g] >= 2) / len(grams))
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def compression_ratio(events, bpm):
|
| 101 |
+
"""zlib-compressed size / raw size of the symbol stream. Lower = more structured."""
|
| 102 |
+
syms = _symbol_stream(events, bpm)
|
| 103 |
+
if len(syms) < 16:
|
| 104 |
+
return None
|
| 105 |
+
raw = bytes(b for s in syms for b in (s[0], HIT_CLASSES.index(s[1])))
|
| 106 |
+
return float(len(zlib.compress(raw, 9)) / len(raw))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def strong_beat_rate(events, bpm, phase=None, tol=0.03):
|
| 110 |
+
"""Fraction of hits on integer beats (downbeat proxy needs measures; beat is enough)."""
|
| 111 |
+
from .rhythm import estimate_phase
|
| 112 |
+
|
| 113 |
+
t = np.array([x for x, _ in _hits(events)])
|
| 114 |
+
if len(t) < 5 or not bpm or bpm <= 0:
|
| 115 |
+
return None
|
| 116 |
+
beat = 60.0 / bpm
|
| 117 |
+
if phase is None:
|
| 118 |
+
phase = estimate_phase(t, beat)
|
| 119 |
+
rel = (t - phase) / beat
|
| 120 |
+
return float(np.mean(np.abs(rel - np.round(rel)) * beat < tol))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def accent_on_strong(events, bpm, phase=None, tol=0.03):
|
| 124 |
+
"""Fraction of BIG notes that fall on integer beats — accent placement proxy."""
|
| 125 |
+
from .rhythm import estimate_phase
|
| 126 |
+
|
| 127 |
+
h = _hits(events)
|
| 128 |
+
tb = np.array([t for t, c in h if c in BIG_CLASSES])
|
| 129 |
+
if len(tb) < 3 or not bpm or bpm <= 0:
|
| 130 |
+
return None
|
| 131 |
+
beat = 60.0 / bpm
|
| 132 |
+
if phase is None:
|
| 133 |
+
phase = estimate_phase(np.array([t for t, _ in h]), beat)
|
| 134 |
+
rel = (tb - phase) / beat
|
| 135 |
+
return float(np.mean(np.abs(rel - np.round(rel)) * beat < tol))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def audio_energy_alignment(events, mel, fps):
|
| 139 |
+
"""Mean z-scored spectral-flux strength at hit times (GT-free audio grounding)."""
|
| 140 |
+
t = np.array([x for x, _ in _hits(events)])
|
| 141 |
+
if len(t) < 5:
|
| 142 |
+
return None
|
| 143 |
+
flux = np.maximum(0.0, np.diff(mel.astype(np.float32), axis=1)).sum(axis=0)
|
| 144 |
+
flux = (flux - flux.mean()) / (flux.std() + 1e-9)
|
| 145 |
+
fr = np.clip(np.round(t * fps).astype(int), 1, len(flux) - 1)
|
| 146 |
+
return float(np.mean(np.maximum.reduce([flux[fr - 1], flux[fr], flux[np.minimum(fr + 1, len(flux) - 1)]])))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _pair_spans(events):
|
| 150 |
+
"""Pair span-start/end events into (t0, t1) tuples (for GT event streams)."""
|
| 151 |
+
out = []
|
| 152 |
+
open_t = None
|
| 153 |
+
for t, c in sorted(events):
|
| 154 |
+
if c in ("roll", "roll_big", "balloon") and open_t is None:
|
| 155 |
+
open_t = t
|
| 156 |
+
elif c == "end" and open_t is not None:
|
| 157 |
+
out.append((open_t, t))
|
| 158 |
+
open_t = None
|
| 159 |
+
return out
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def span_stats(events, spans=None):
|
| 163 |
+
"""Span-length distribution + rate. Added after a visual audit found
|
| 164 |
+
generated balloons ~10s vs GT p50 ~1s (a metric blind spot)."""
|
| 165 |
+
if spans is None:
|
| 166 |
+
pairs = _pair_spans(events)
|
| 167 |
+
else:
|
| 168 |
+
pairs = [(s["t0"], s["t1"]) if isinstance(s, dict) else tuple(s) for s in spans]
|
| 169 |
+
h = _hits(events)
|
| 170 |
+
dur_min = max((h[-1][0] - h[0][0]) / 60.0, 1e-6) if len(h) > 1 else None
|
| 171 |
+
if not pairs or dur_min is None:
|
| 172 |
+
return {"span_p90_len": None, "span_per_min": 0.0 if dur_min else None}
|
| 173 |
+
lens = np.array([b - a for a, b in pairs])
|
| 174 |
+
return {"span_p90_len": float(np.percentile(lens, 90)),
|
| 175 |
+
"span_per_min": float(len(pairs) / dur_min)}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def compute_proxies(events, bpm, mel=None, fps=None, spans=None):
|
| 179 |
+
"""All proxies for one chart. Returns dict (None where not computable)."""
|
| 180 |
+
from .rhythm import grid_consistency, tuplet_evenness
|
| 181 |
+
|
| 182 |
+
t = [x for x, _ in _hits(events)]
|
| 183 |
+
out = {
|
| 184 |
+
"nps": len(t) / max(t[-1] - t[0], 1e-6) if len(t) > 1 else 0.0,
|
| 185 |
+
"min_gap_violation": min_gap_violation_rate(events),
|
| 186 |
+
"stream_p99_nps": stream_p99_nps(events),
|
| 187 |
+
"big_rate": big_note_rate(events),
|
| 188 |
+
"big_crowding": big_crowding_rate(events, bpm),
|
| 189 |
+
"motif_reuse": motif_reuse(events, bpm),
|
| 190 |
+
"compression": compression_ratio(events, bpm),
|
| 191 |
+
"strong_beat": strong_beat_rate(events, bpm),
|
| 192 |
+
"accent_on_strong": accent_on_strong(events, bpm),
|
| 193 |
+
}
|
| 194 |
+
g = grid_consistency(t, bpm)
|
| 195 |
+
if g:
|
| 196 |
+
out["offgrid_rate"] = 1.0 - g["on_grid_frac"]
|
| 197 |
+
out["grid_dev_ms"] = g["mean_dev_ms"]
|
| 198 |
+
tp = tuplet_evenness(t, bpm)
|
| 199 |
+
if tp:
|
| 200 |
+
out["tuplet_cv"] = tp["cv_mean"]
|
| 201 |
+
if mel is not None and fps:
|
| 202 |
+
out["energy_align"] = audio_energy_alignment(events, mel, fps)
|
| 203 |
+
out.update(span_stats(events, spans))
|
| 204 |
+
return out
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# direction: +1 higher is better, -1 lower is better, 0 match GT reference
|
| 208 |
+
PROXY_DIRECTION = {
|
| 209 |
+
"nps": 0, "min_gap_violation": -1, "stream_p99_nps": 0, "big_rate": 0,
|
| 210 |
+
"big_crowding": -1, "motif_reuse": 0, "compression": 0, "strong_beat": 0,
|
| 211 |
+
"accent_on_strong": 0, "offgrid_rate": -1, "grid_dev_ms": -1,
|
| 212 |
+
"tuplet_cv": -1, "energy_align": 1, "span_p90_len": 0, "span_per_min": 0,
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def score_against_reference(proxy_row, ref_stats):
|
| 217 |
+
"""Normalized deviation of each proxy vs the GT reference distribution.
|
| 218 |
+
|
| 219 |
+
ref_stats: {proxy: {"p25": .., "p50": .., "p75": ..}} per course.
|
| 220 |
+
Returns {proxy: score} where score = robust z of |dev| (0 = at GT median;
|
| 221 |
+
for directional proxies, only penalize the bad direction).
|
| 222 |
+
"""
|
| 223 |
+
scores = {}
|
| 224 |
+
for k, v in proxy_row.items():
|
| 225 |
+
if v is None or k not in ref_stats:
|
| 226 |
+
continue
|
| 227 |
+
r = ref_stats[k]
|
| 228 |
+
iqr = max(r["p75"] - r["p25"], 1e-6)
|
| 229 |
+
d = PROXY_DIRECTION.get(k, 0)
|
| 230 |
+
dev = (v - r["p50"]) / iqr
|
| 231 |
+
if d == -1:
|
| 232 |
+
dev = max(0.0, dev) # only worse-than-GT (higher) penalized
|
| 233 |
+
elif d == 1:
|
| 234 |
+
dev = max(0.0, -dev)
|
| 235 |
+
else:
|
| 236 |
+
dev = abs(dev)
|
| 237 |
+
scores[k] = float(dev)
|
| 238 |
+
return scores
|
softchart/sc2/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SoftChart 2.0 serving code, vendored verbatim into its own namespace.
|
| 2 |
+
|
| 3 |
+
The Space already ships a SoftChart 1.x library at ``softchart.*``. The 2.0
|
| 4 |
+
modules carry the same file names but an incompatible token table
|
| 5 |
+
(``VOCAB.size`` 2071 vs 1.x, ``MAX_TGT`` 960 vs 1536), so they cannot share a
|
| 6 |
+
package: overwriting ``softchart/vocab.py`` or ``softchart/model.py`` would
|
| 7 |
+
break every 1.x checkpoint the Space serves. They live here instead, as a
|
| 8 |
+
sibling subpackage. Both trees are importable in one process and neither can
|
| 9 |
+
see the other -- every module below uses package-relative imports only, so
|
| 10 |
+
``from .vocab import VOCAB`` inside ``softchart.sc2.generate`` resolves to
|
| 11 |
+
``softchart.sc2.vocab`` and never to ``softchart.vocab``.
|
| 12 |
+
|
| 13 |
+
The eight modules are BYTE-IDENTICAL copies of the frozen shipping tree at
|
| 14 |
+
``.codex/worktrees/v18-8h/src/softchart/``. Their md5s are the ones the
|
| 15 |
+
release package recorded in ``config.json:code.module_md5``:
|
| 16 |
+
|
| 17 |
+
barscript.py 9afc715a56ac28e9ec5675d64cd0536c
|
| 18 |
+
checkpoint.py 10e215e86ac77ada9046a164bffa0644
|
| 19 |
+
generate.py 73b6e49b00fb60f3229c8539bc7a1029
|
| 20 |
+
meter.py 18ccd6a003086f81276f2a916ada4c64
|
| 21 |
+
model.py eace5d89abeebf0edcf28d10b5ad3e68
|
| 22 |
+
tja.py 360b95e19dabe925bf8eab03fa3fd110
|
| 23 |
+
trace.py e707f7204f0d86deacca6b6325f031f1
|
| 24 |
+
vocab.py 0f948a0efc52f5ce60e8219fcaeb136c
|
| 25 |
+
|
| 26 |
+
Verbatim is the point: ``softchart/sc2_loader.py`` checks these md5s against
|
| 27 |
+
the package's own record at load time, so serving code that has drifted from
|
| 28 |
+
the code the checkpoint was evaluated under is refused rather than served.
|
| 29 |
+
Any behaviour change belongs upstream in the shipping tree, not here.
|
| 30 |
+
|
| 31 |
+
Not copied, because the BarScript serving path never reaches them:
|
| 32 |
+
``data.py`` (``generate.generate_song`` imports ``song_summary`` lazily; that is
|
| 33 |
+
the 1.x time-mode decoder), ``hf.py`` (``generate.load_hf``; this Space loads
|
| 34 |
+
2.0 from a local directory and must not need a Hub client), and the training,
|
| 35 |
+
evaluation and grid-fitting modules.
|
| 36 |
+
"""
|
softchart/sc2/barscript.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BarScript encoding layer: SoftChart 2.0 bar-scoped multi-stage stream.
|
| 2 |
+
|
| 3 |
+
Implements the content layer of SOFTCHART2_DESIGN.md on top of the slot+ S0
|
| 4 |
+
command trace (softchart.trace): the trace -> token reordering of §1.1, the
|
| 5 |
+
deterministic stage-label derivation of §3 (versioned ``barscript_labels_v1``),
|
| 6 |
+
the grammar FSM v2 with the §8.1 plan->decode hard constraints, and the
|
| 7 |
+
deterministic re-zip inverse that the UL v2 symbolizer consumes (§1.2).
|
| 8 |
+
|
| 9 |
+
Per-bar token layout (§1.1)
|
| 10 |
+
---------------------------
|
| 11 |
+
::
|
| 12 |
+
|
| 13 |
+
[BAR] BAR_DENOM_D <- header stage
|
| 14 |
+
DENS_D8_d SPAN_BUDGET_s BAR_SYNC_y MOTIF_m <- plan stage (4 slots)
|
| 15 |
+
( SPAN_START? SPAN_END? POS_i )* <- skeleton stage
|
| 16 |
+
[REAL] ( TYPE (SPAN_DUR_b)? )* <- realization stage
|
| 17 |
+
|
| 18 |
+
followed by the next ``[BAR]`` or ``EOS``. POSITION tokens reuse the
|
| 19 |
+
``vocab.time0`` family as BAR-LOCAL lattice indices ``i`` with exact position
|
| 20 |
+
``i / D`` (v3 slot-mode precedent; keeps hit token counts identical to the
|
| 21 |
+
flat interleaved encoding, §1.1 "hit 的 token 總數不變"). ``D`` is the bar's
|
| 22 |
+
rational-lattice denominator = LCM of the reduced denominators of the bar's
|
| 23 |
+
note positions (SLOTPLUS_DESIGN §2.1 "authored 分割的最小公倍細分"), which is
|
| 24 |
+
canonical under authored zero-padding; ``D = 1`` for empty bars; bars needing
|
| 25 |
+
``D > MAX_BAR_DENOM`` fail closed (OffLatticeError), never snapped.
|
| 26 |
+
|
| 27 |
+
Realization emits exactly one TYPE token per skeleton position, in position
|
| 28 |
+
order. A skeleton position preceded by ``SPAN_START`` realizes as a span
|
| 29 |
+
class (roll / roll_big / balloon) followed by its duration bucket token
|
| 30 |
+
(§1.3 span duration head supervision); a position preceded by ``SPAN_END``
|
| 31 |
+
realizes as the ``end`` class; unmarked positions realize as hit classes.
|
| 32 |
+
|
| 33 |
+
Label derivation (``barscript_labels_v1``, all pure functions of the trace
|
| 34 |
+
and a frozen BarscriptSpec; §3)
|
| 35 |
+
-------------------------------
|
| 36 |
+
- DENS_d8: count of non-``end`` onsets in the bar, binned by
|
| 37 |
+
``spec.dens_edges`` (bucket = number of edges <= count; the same edges give
|
| 38 |
+
the FSM's [L, U] legal note-count interval, §8.1).
|
| 39 |
+
- SPAN_BUDGET: number of span starts in the bar, capped at 3+ .
|
| 40 |
+
- Span duration: exact length in quarter-note beats (meter-state weighted
|
| 41 |
+
across bars, tempo-independent), binned on the ``spec.span_dur_edges`` log
|
| 42 |
+
grid.
|
| 43 |
+
- SYNC: Longuet-Higgins & Lee note-rest syncopation banded by
|
| 44 |
+
``spec.sync_edges``. Deterministic variant frozen here: metric weight
|
| 45 |
+
w(0) = 0, w(beat) = -1, w(other) = -(1 + ceil(log2(q))) with q the reduced
|
| 46 |
+
within-beat denominator; onsets are the bar's non-``end`` notes; rest
|
| 47 |
+
candidates live on the eighth-note grid (precedent: data.py lhl_band
|
| 48 |
+
"simplified to the eighth-note grid") plus the next bar's downbeat when
|
| 49 |
+
that downbeat carries no onset; each onset scores max(w(rest)) - w(onset)
|
| 50 |
+
over rests strictly before the next onset, when positive.
|
| 51 |
+
- MOTIF_REF: bar occupancy signature = ordered tuple of exact positions of
|
| 52 |
+
non-``end`` onsets (type-agnostic: occupancy is a placement decision; the
|
| 53 |
+
realizer owns types). Exact match against bar b-δ for δ in (1, 2, 4, 8)
|
| 54 |
+
ascending -> REPEAT_δ; else normalized Levenshtein distance (over position
|
| 55 |
+
sequences, / max length) minimized over available δ (ties -> smaller δ)
|
| 56 |
+
<= spec.motif_tau -> VAR_δ; else NEW. Bars with no δ-predecessor are NEW.
|
| 57 |
+
|
| 58 |
+
Grammar FSM v2 (§1.2, §8.1)
|
| 59 |
+
---------------------------
|
| 60 |
+
``BarscriptFSM`` returns a per-step legal-token mask over the vocab. Hard
|
| 61 |
+
constraints: header -> plan (4 fixed slots; REPEAT_δ/VAR_δ masked when fewer
|
| 62 |
+
than δ bars precede) -> skeleton (positions strictly increasing on the bar's
|
| 63 |
+
D grid; DENS bucket bounds the count of non-end positions to [L, U]: [REAL]
|
| 64 |
+
is masked below L, new countable positions are masked at U; SPAN_BUDGET is a
|
| 65 |
+
consumed budget masking SPAN_START at zero, 3+ = unbounded upper bucket) ->
|
| 66 |
+
[REAL] -> exactly N_onset type tokens role-constrained by the skeleton.
|
| 67 |
+
Cross-bar open-span carry: while a span is open, SPAN_START and plain
|
| 68 |
+
positions are masked (a span may not swallow hits), SPAN_END is the only way
|
| 69 |
+
to place content, EOS is masked (no orphan starts), and the state survives
|
| 70 |
+
[BAR] boundaries (multi-bar rolls/balloons). Position masks reserve room so
|
| 71 |
+
the DENS lower bound stays reachable; as a documented fail-open escape hatch
|
| 72 |
+
(generation robustness, never exercised by gold sequences) [REAL] becomes
|
| 73 |
+
legal when no skeleton token could otherwise be emitted.
|
| 74 |
+
|
| 75 |
+
MOTIF_REF hard constraint (``motif_hard=True``, the default)
|
| 76 |
+
------------------------------------------------------------
|
| 77 |
+
A committed ``REPEAT_δ`` plan token compiles into the skeleton exactly the
|
| 78 |
+
way DENS and SPAN_BUDGET already do: the bar's countable (non-``end``)
|
| 79 |
+
positions are forced to reproduce, index for index, the occupancy signature
|
| 80 |
+
of bar ``b-δ`` — the same signature the label derivation compares
|
| 81 |
+
(``occupancy_signature`` / ``motif_ref``), so a gold ``REPEAT_δ`` bar
|
| 82 |
+
satisfies it by construction and teacher forcing is untouched. ``VAR_δ``
|
| 83 |
+
(normalized edit distance <= τ, not an equality) and ``NEW`` stay soft.
|
| 84 |
+
|
| 85 |
+
The signature is a tuple of EXACT rational positions, so it is
|
| 86 |
+
denominator-free: a bar whose ``BAR_DENOM`` differs from its δ-predecessor's
|
| 87 |
+
still counts as a REPEAT whenever every predecessor position lands on this
|
| 88 |
+
bar's lattice (``bar_labels`` never looks at ``D``; the FSM matches that
|
| 89 |
+
reading and only requires representability).
|
| 90 |
+
|
| 91 |
+
Conflicts are resolved by PREVENTION at the plan stage, which is where the
|
| 92 |
+
priority order lives. ``BAR_DENOM`` (header), ``DENS`` (plan0) and
|
| 93 |
+
``SPAN_BUDGET`` (plan1) are all committed before ``MOTIF`` (plan3), so at
|
| 94 |
+
plan3 the FSM masks every ``REPEAT_δ`` it could not honour: signature not
|
| 95 |
+
representable on this bar's ``D``, ``len(signature)`` outside the DENS
|
| 96 |
+
``[L, U]`` interval, or a carried open span that would have to close before
|
| 97 |
+
a first target position sitting at index 0. ``NEW`` is always legal, so the
|
| 98 |
+
plan mask is never empty and the skeleton stage can never dead-end. Within
|
| 99 |
+
the skeleton the carry rule keeps absolute priority (a span still may not
|
| 100 |
+
swallow hits): while a span is open only ``SPAN_END`` places content, and its
|
| 101 |
+
position is additionally bounded to stay strictly before the next forced
|
| 102 |
+
target. ``SPAN_START`` is offered only where the following target leaves a
|
| 103 |
+
slot for the close, so committing to a span can never strand the FSM.
|
| 104 |
+
Redundant references are also masked: ``REPEAT_δ`` is illegal when a smaller
|
| 105 |
+
available δ' has the identical signature, because the realized bar would
|
| 106 |
+
relabel as ``REPEAT_δ'`` — gold always emits the minimal δ, so this is a
|
| 107 |
+
pure canonicalization. ``motif_hard=False`` restores the pre-v18 soft
|
| 108 |
+
behaviour for ablations.
|
| 109 |
+
|
| 110 |
+
Scope rulings (documented, with authority)
|
| 111 |
+
------------------------------------------
|
| 112 |
+
- slot+ header state tokens (TEMPO/MEASURE/DELAY/k families) are the time
|
| 113 |
+
layer's vocabulary (SLOTPLUS_DESIGN §3.2 puts their values on regression /
|
| 114 |
+
classification heads; §9.2 defers their token families). This content
|
| 115 |
+
layer anchors them at the ``[BAR]`` header insertion point but does not
|
| 116 |
+
invent them: TEMPO/MEASURE/DELAY events pass through encoding untouched
|
| 117 |
+
(they move no lattice position, so the (pos, type) re-zip is exact without
|
| 118 |
+
them) and labels that need meter (beats, LHL) read it from the trace.
|
| 119 |
+
- ``barscript_to_events`` restores the (bar, pos, type-class) event stream
|
| 120 |
+
only (the task's re-zip inverse); '9' kusudama collapses into the balloon
|
| 121 |
+
class exactly as in vocab.NOTE_TYPE_MAP, so equality checks against the
|
| 122 |
+
flat encoding and against the trace are at note-class level.
|
| 123 |
+
- Span-semantics normalization (coordinator ruling 2026-07-25, full-corpus
|
| 124 |
+
diagnostic ``span_diag.json``: 328 fail-closed charts, 98.2% one pattern;
|
| 125 |
+
BALLOON header counts are the metadata evidence that the game engine treats
|
| 126 |
+
these as decorations of the OPEN span, not new spans — implicit
|
| 127 |
+
close+reopen would break the balloon counts): while a span is open, a
|
| 128 |
+
SAME-FAMILY span-start event is dropped and counted (balloon family:
|
| 129 |
+
balloon/kusudama chars are interchangeable, so any balloon-class start
|
| 130 |
+
inside an open balloon; roll family: exactly roll->roll). A span-end event
|
| 131 |
+
with no open span is likewise dropped and counted (engine ignores it).
|
| 132 |
+
The dropped events vanish from the encoding entirely: lattice denominators,
|
| 133 |
+
DENS counts, occupancy signatures and the re-zip reference stream are all
|
| 134 |
+
computed on the NORMALIZED stream (``normalized_events``); the flat B0
|
| 135 |
+
encoding applies the identical normalization so both arms share one data
|
| 136 |
+
population. Counts return via ``return_stats=True`` /
|
| 137 |
+
``normalized_events``. Everything else stays fail-closed: cross-family
|
| 138 |
+
restarts (e.g. roll open + balloon start), roll_big->roll_big (unobserved,
|
| 139 |
+
not sanctioned by the ruling — pending adjudication if it ever appears),
|
| 140 |
+
hits strictly inside an open span, and spans left open at end of chart.
|
| 141 |
+
trace.py stays untouched (S0 verbatim contract): normalization lives
|
| 142 |
+
strictly in this encoding layer.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
import json
|
| 146 |
+
import math
|
| 147 |
+
from dataclasses import dataclass, field
|
| 148 |
+
from fractions import Fraction
|
| 149 |
+
|
| 150 |
+
import numpy as np
|
| 151 |
+
|
| 152 |
+
from .trace import NoteEvent, note_class
|
| 153 |
+
from .vocab import (MAX_BAR_DENOM, MOTIF_DELTAS, MOTIF_REF_NAMES, N_BAR_SYNC,
|
| 154 |
+
N_DENS_D8, N_SPAN_BUDGET, N_SPAN_DUR_BUCKETS,
|
| 155 |
+
NOTE_CLASSES, STAGE_HEADER, STAGE_PLAN, STAGE_REALIZATION,
|
| 156 |
+
STAGE_SKELETON, VOCAB, WINDOW)
|
| 157 |
+
|
| 158 |
+
HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
|
| 159 |
+
SPAN_CLASSES = ("roll", "roll_big", "balloon")
|
| 160 |
+
|
| 161 |
+
LABELS_VERSION = "barscript_labels_v1"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class BarscriptError(ValueError):
|
| 165 |
+
"""A construct the BarScript layer refuses to guess about."""
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class OffLatticeError(BarscriptError):
|
| 169 |
+
"""Bar needs a lattice denominator beyond MAX_BAR_DENOM (fail-closed)."""
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class SpanStructureError(BarscriptError):
|
| 173 |
+
"""Authored span structure is inconsistent (orphan start/end, swallowed
|
| 174 |
+
hit inside an open span). Fail-closed and counted, never repaired."""
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class IllegalTokenError(BarscriptError):
|
| 178 |
+
"""A token outside the FSM's legal mask was stepped."""
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ---------------------------------------------------------------------------
|
| 182 |
+
# spec
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _as_tuple(x):
|
| 186 |
+
return tuple(x) if isinstance(x, (list, tuple)) else x
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@dataclass(frozen=True)
|
| 190 |
+
class BarscriptSpec:
|
| 191 |
+
"""Frozen label-derivation parameters (§3: bucket edges and τ are NOT
|
| 192 |
+
hard-coded; they load from the spec artifact built on the train split).
|
| 193 |
+
|
| 194 |
+
Defaults are DRAFT values for local development; the frozen artifact from
|
| 195 |
+
scripts/build_barscript_spec.py supersedes them before training.
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
labels_version: str = LABELS_VERSION
|
| 199 |
+
# 7 strictly increasing positive ints -> 8 DENS_d8 buckets over per-bar
|
| 200 |
+
# non-end onset counts; bucket b = number of edges <= count, so bucket b
|
| 201 |
+
# spans [edges[b-1], edges[b]-1] (b=0: [0, edges[0]-1]; top: unbounded).
|
| 202 |
+
dens_edges: tuple = (1, 3, 5, 8, 11, 14, 18)
|
| 203 |
+
# 15 strictly increasing floats (quarter-note beats, log-ish grid) -> 16
|
| 204 |
+
# span-duration buckets; frozen grid anchors on authored p50/p90/p99.
|
| 205 |
+
span_dur_edges: tuple = (0.25, 0.375, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0,
|
| 206 |
+
6.0, 8.0, 12.0, 16.0, 24.0, 32.0)
|
| 207 |
+
# 5 strictly increasing positive ints -> 6 LHL bands over per-bar scores.
|
| 208 |
+
sync_edges: tuple = (1, 2, 3, 5, 8)
|
| 209 |
+
# normalized-edit-distance threshold for VAR (frozen after the τ sweep).
|
| 210 |
+
motif_tau: float = 0.25
|
| 211 |
+
motif_deltas: tuple = MOTIF_DELTAS
|
| 212 |
+
provenance: dict = field(default_factory=dict, compare=False)
|
| 213 |
+
|
| 214 |
+
def __post_init__(self):
|
| 215 |
+
object.__setattr__(self, "dens_edges", _as_tuple(self.dens_edges))
|
| 216 |
+
object.__setattr__(self, "span_dur_edges",
|
| 217 |
+
_as_tuple(self.span_dur_edges))
|
| 218 |
+
object.__setattr__(self, "sync_edges", _as_tuple(self.sync_edges))
|
| 219 |
+
object.__setattr__(self, "motif_deltas",
|
| 220 |
+
_as_tuple(self.motif_deltas))
|
| 221 |
+
if self.labels_version != LABELS_VERSION:
|
| 222 |
+
raise BarscriptError(
|
| 223 |
+
f"unsupported labels_version {self.labels_version!r} "
|
| 224 |
+
f"(this code implements {LABELS_VERSION!r})")
|
| 225 |
+
for name, edges, n in (("dens_edges", self.dens_edges, N_DENS_D8),
|
| 226 |
+
("span_dur_edges", self.span_dur_edges,
|
| 227 |
+
N_SPAN_DUR_BUCKETS),
|
| 228 |
+
("sync_edges", self.sync_edges, N_BAR_SYNC)):
|
| 229 |
+
if len(edges) != n - 1:
|
| 230 |
+
raise BarscriptError(
|
| 231 |
+
f"{name} must have {n - 1} edges, got {len(edges)}")
|
| 232 |
+
if any(b <= a for a, b in zip(edges, edges[1:])):
|
| 233 |
+
raise BarscriptError(f"{name} must be strictly increasing")
|
| 234 |
+
if self.dens_edges[0] < 1 or any(
|
| 235 |
+
int(e) != e for e in self.dens_edges):
|
| 236 |
+
raise BarscriptError("dens_edges must be positive integers")
|
| 237 |
+
if self.motif_deltas != MOTIF_DELTAS:
|
| 238 |
+
raise BarscriptError(
|
| 239 |
+
f"motif_deltas is frozen to {MOTIF_DELTAS} by the vocab "
|
| 240 |
+
"MOTIF_REF token family")
|
| 241 |
+
if not (0.0 <= self.motif_tau <= 1.0):
|
| 242 |
+
raise BarscriptError("motif_tau must be in [0, 1]")
|
| 243 |
+
|
| 244 |
+
# --- deterministic binning -------------------------------------------
|
| 245 |
+
def dens_bucket(self, count):
|
| 246 |
+
return sum(1 for e in self.dens_edges if e <= count)
|
| 247 |
+
|
| 248 |
+
def dens_bounds(self, bucket):
|
| 249 |
+
"""Legal non-end onset count interval [L, U] for a DENS bucket
|
| 250 |
+
(U is None for the unbounded top bucket). §8.1 hard constraint."""
|
| 251 |
+
lo = 0 if bucket == 0 else int(self.dens_edges[bucket - 1])
|
| 252 |
+
hi = (None if bucket >= len(self.dens_edges)
|
| 253 |
+
else int(self.dens_edges[bucket]) - 1)
|
| 254 |
+
return lo, hi
|
| 255 |
+
|
| 256 |
+
def span_budget_bucket(self, count):
|
| 257 |
+
return min(count, N_SPAN_BUDGET - 1)
|
| 258 |
+
|
| 259 |
+
def span_dur_bucket(self, beats):
|
| 260 |
+
return sum(1 for e in self.span_dur_edges if e <= float(beats))
|
| 261 |
+
|
| 262 |
+
def sync_band(self, score):
|
| 263 |
+
return min(sum(1 for e in self.sync_edges if e <= score),
|
| 264 |
+
N_BAR_SYNC - 1)
|
| 265 |
+
|
| 266 |
+
# --- (de)serialization ------------------------------------------------
|
| 267 |
+
def to_dict(self):
|
| 268 |
+
return {"labels_version": self.labels_version,
|
| 269 |
+
"dens_edges": list(self.dens_edges),
|
| 270 |
+
"span_dur_edges": list(self.span_dur_edges),
|
| 271 |
+
"sync_edges": list(self.sync_edges),
|
| 272 |
+
"motif_tau": self.motif_tau,
|
| 273 |
+
"motif_deltas": list(self.motif_deltas),
|
| 274 |
+
"provenance": dict(self.provenance)}
|
| 275 |
+
|
| 276 |
+
@classmethod
|
| 277 |
+
def from_dict(cls, d):
|
| 278 |
+
if "spec" in d and "labels_version" not in d:
|
| 279 |
+
d = d["spec"] # accept the build_barscript_spec.py artifact
|
| 280 |
+
known = {"labels_version", "dens_edges", "span_dur_edges",
|
| 281 |
+
"sync_edges", "motif_tau", "motif_deltas", "provenance"}
|
| 282 |
+
return cls(**{k: v for k, v in d.items() if k in known})
|
| 283 |
+
|
| 284 |
+
@classmethod
|
| 285 |
+
def load(cls, path):
|
| 286 |
+
with open(path, encoding="utf-8") as f:
|
| 287 |
+
return cls.from_dict(json.load(f))
|
| 288 |
+
|
| 289 |
+
def save(self, path):
|
| 290 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 291 |
+
json.dump(self.to_dict(), f, indent=2)
|
| 292 |
+
f.write("\n")
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ---------------------------------------------------------------------------
|
| 296 |
+
# trace -> per-bar content
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _bar_notes(trace):
|
| 300 |
+
"""Per-bar ordered [(pos: Fraction, class: str)] over trace.bars."""
|
| 301 |
+
bars = [[] for _ in trace.bars]
|
| 302 |
+
for ev in trace.events:
|
| 303 |
+
if isinstance(ev, NoteEvent):
|
| 304 |
+
cls = note_class(ev.type)
|
| 305 |
+
if cls is None:
|
| 306 |
+
raise BarscriptError(
|
| 307 |
+
f"bar {ev.bar}: unknown note char {ev.type!r}")
|
| 308 |
+
bars[ev.bar].append((ev.pos, cls))
|
| 309 |
+
return bars
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def bar_lattice_denom(notes):
|
| 313 |
+
"""Canonical lattice denominator: LCM of reduced position denominators
|
| 314 |
+
(1 for an empty bar). Fail-closed above MAX_BAR_DENOM (never snapped)."""
|
| 315 |
+
d = 1
|
| 316 |
+
for pos, _cls in notes:
|
| 317 |
+
d = d * pos.denominator // math.gcd(d, pos.denominator)
|
| 318 |
+
if d > MAX_BAR_DENOM:
|
| 319 |
+
raise OffLatticeError(
|
| 320 |
+
f"bar lattice denominator {d} exceeds support "
|
| 321 |
+
f"{MAX_BAR_DENOM} (fail-closed)")
|
| 322 |
+
return d
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _same_family_restart(open_cls, cls):
|
| 326 |
+
"""Sanctioned decoration pattern (ruling 2026-07-25): balloon-family
|
| 327 |
+
starts are interchangeable inside an open balloon (7/9 chars both map to
|
| 328 |
+
the balloon class); in the roll family exactly roll->roll is sanctioned.
|
| 329 |
+
roll_big->roll_big and all cross-family restarts stay fail-closed."""
|
| 330 |
+
return ((open_cls == "balloon" and cls == "balloon")
|
| 331 |
+
or (open_cls == "roll" and cls == "roll"))
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _normalize_spans(trace, bars_notes):
|
| 335 |
+
"""Span-semantics normalization + global span pairing (fail-closed for
|
| 336 |
+
everything the ruling does not sanction; module docstring).
|
| 337 |
+
|
| 338 |
+
Returns (norm_bars, spans, start_index, stats): norm_bars mirrors
|
| 339 |
+
bars_notes with ignored events dropped; spans carry exact (bar, pos)
|
| 340 |
+
endpoints and duration in quarter-note beats; start_index maps each kept
|
| 341 |
+
span start's (bar, pos) to its span dict; stats counts
|
| 342 |
+
{"ignored_start", "ignored_end"}.
|
| 343 |
+
"""
|
| 344 |
+
beats = [Fraction(4 * b.num, b.den) for b in trace.bars]
|
| 345 |
+
norm_bars = [[] for _ in bars_notes]
|
| 346 |
+
spans = []
|
| 347 |
+
start_index = {}
|
| 348 |
+
stats = {"ignored_start": 0, "ignored_end": 0}
|
| 349 |
+
open_span = None
|
| 350 |
+
for bar_i, notes in enumerate(bars_notes):
|
| 351 |
+
for pos, cls in notes:
|
| 352 |
+
if cls in SPAN_CLASSES:
|
| 353 |
+
if open_span is None:
|
| 354 |
+
open_span = {"bar": bar_i, "pos": pos, "class": cls}
|
| 355 |
+
norm_bars[bar_i].append((pos, cls))
|
| 356 |
+
elif _same_family_restart(open_span["class"], cls):
|
| 357 |
+
stats["ignored_start"] += 1 # decoration; engine ignores
|
| 358 |
+
else:
|
| 359 |
+
raise SpanStructureError(
|
| 360 |
+
f"bar {bar_i} pos {pos}: {cls} span start while the "
|
| 361 |
+
f"{open_span['class']} span opened at bar "
|
| 362 |
+
f"{open_span['bar']} is open (not a sanctioned "
|
| 363 |
+
"same-family decoration)")
|
| 364 |
+
elif cls == "end":
|
| 365 |
+
if open_span is None:
|
| 366 |
+
stats["ignored_end"] += 1 # orphan end; engine ignores
|
| 367 |
+
continue
|
| 368 |
+
dur = Fraction(0)
|
| 369 |
+
for b in range(open_span["bar"], bar_i + 1):
|
| 370 |
+
lo = open_span["pos"] if b == open_span["bar"] else Fraction(0)
|
| 371 |
+
hi = pos if b == bar_i else Fraction(1)
|
| 372 |
+
dur += (hi - lo) * beats[b]
|
| 373 |
+
span = dict(open_span, end_bar=bar_i, end_pos=pos,
|
| 374 |
+
dur_beats=dur)
|
| 375 |
+
spans.append(span)
|
| 376 |
+
start_index[(open_span["bar"], open_span["pos"])] = span
|
| 377 |
+
open_span = None
|
| 378 |
+
norm_bars[bar_i].append((pos, cls))
|
| 379 |
+
else:
|
| 380 |
+
if open_span is not None:
|
| 381 |
+
raise SpanStructureError(
|
| 382 |
+
f"bar {bar_i} pos {pos}: hit inside the span opened "
|
| 383 |
+
f"at bar {open_span['bar']} (swallowed hit)")
|
| 384 |
+
norm_bars[bar_i].append((pos, cls))
|
| 385 |
+
if open_span is not None:
|
| 386 |
+
raise SpanStructureError(
|
| 387 |
+
f"span opened at bar {open_span['bar']} pos {open_span['pos']} "
|
| 388 |
+
"never closes (orphan start)")
|
| 389 |
+
return norm_bars, spans, start_index, stats
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def normalized_events(trace):
|
| 393 |
+
"""Post-normalization (bar, pos, class) stream + normalization stats.
|
| 394 |
+
|
| 395 |
+
This is the re-zip REFERENCE stream: both the flat and the BarScript
|
| 396 |
+
encodings reproduce exactly these events. Equal to trace_events(trace)
|
| 397 |
+
with stats == zeros for charts that need no normalization."""
|
| 398 |
+
norm_bars, _spans, _idx, stats = _normalize_spans(trace, _bar_notes(trace))
|
| 399 |
+
events = [(b, pos, cls) for b, notes in enumerate(norm_bars)
|
| 400 |
+
for pos, cls in notes]
|
| 401 |
+
return events, stats
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
# --- LHL syncopation (barscript_labels_v1 frozen variant) ------------------
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def metric_weight(pos, num):
|
| 408 |
+
"""LHL metric weight of an in-bar position (Fraction in [0,1)); num =
|
| 409 |
+
metrical beats per bar. 0 = downbeat, -1 = beat, deeper = weaker."""
|
| 410 |
+
if pos == 0:
|
| 411 |
+
return 0
|
| 412 |
+
beat = pos * num
|
| 413 |
+
if beat.denominator == 1:
|
| 414 |
+
return -1
|
| 415 |
+
frac = beat - (beat.numerator // beat.denominator)
|
| 416 |
+
q = frac.denominator # >= 2
|
| 417 |
+
return -(1 + (q - 1).bit_length()) # 1 + ceil(log2(q))
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def lhl_score(onsets, num, next_downbeat_has_onset):
|
| 421 |
+
"""Bar-level LHL note-rest syncopation score (non-negative int)."""
|
| 422 |
+
ons = sorted(set(onsets))
|
| 423 |
+
if not ons:
|
| 424 |
+
return 0
|
| 425 |
+
onset_set = set(ons)
|
| 426 |
+
grid = [Fraction(k, 2 * num) for k in range(1, 2 * num)]
|
| 427 |
+
score = 0
|
| 428 |
+
for i, p in enumerate(ons):
|
| 429 |
+
bound = ons[i + 1] if i + 1 < len(ons) else Fraction(1)
|
| 430 |
+
best = None
|
| 431 |
+
for q in grid:
|
| 432 |
+
if p < q < bound and q not in onset_set:
|
| 433 |
+
w = metric_weight(q, num)
|
| 434 |
+
if best is None or w > best:
|
| 435 |
+
best = w
|
| 436 |
+
if i == len(ons) - 1 and not next_downbeat_has_onset:
|
| 437 |
+
best = 0 if best is None else max(best, 0)
|
| 438 |
+
wp = metric_weight(p, num)
|
| 439 |
+
if best is not None and best > wp:
|
| 440 |
+
score += best - wp
|
| 441 |
+
return score
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# --- motif reference -------------------------------------------------------
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def occupancy_signature(notes):
|
| 448 |
+
"""Ordered tuple of exact positions of the bar's non-end onsets."""
|
| 449 |
+
return tuple(pos for pos, cls in notes if cls != "end")
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def norm_edit_distance(a, b):
|
| 453 |
+
"""Levenshtein over position sequences, normalized by max length
|
| 454 |
+
(0.0 for two empty sequences)."""
|
| 455 |
+
if not a and not b:
|
| 456 |
+
return 0.0
|
| 457 |
+
if not a or not b:
|
| 458 |
+
return 1.0
|
| 459 |
+
prev = list(range(len(b) + 1))
|
| 460 |
+
for i, x in enumerate(a, 1):
|
| 461 |
+
cur = [i]
|
| 462 |
+
for j, y in enumerate(b, 1):
|
| 463 |
+
cur.append(min(prev[j] + 1, cur[-1] + 1,
|
| 464 |
+
prev[j - 1] + (0 if x == y else 1)))
|
| 465 |
+
prev = cur
|
| 466 |
+
return prev[-1] / max(len(a), len(b))
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def motif_ref(signatures, bar_i, tau, deltas=MOTIF_DELTAS):
|
| 470 |
+
"""(name, distance) for bar bar_i against its δ-predecessors.
|
| 471 |
+
|
| 472 |
+
Exact match at the smallest available δ wins REPEAT_δ; otherwise the
|
| 473 |
+
minimal normalized edit distance over available δ (ties -> smaller δ)
|
| 474 |
+
yields VAR_δ when <= τ; otherwise NEW. distance is None for NEW-by-
|
| 475 |
+
no-predecessor, 0.0 for REPEAT, the winning distance otherwise.
|
| 476 |
+
"""
|
| 477 |
+
sig = signatures[bar_i]
|
| 478 |
+
avail = [d for d in deltas if bar_i - d >= 0]
|
| 479 |
+
if not avail:
|
| 480 |
+
return "new", None
|
| 481 |
+
for d in avail:
|
| 482 |
+
if signatures[bar_i - d] == sig:
|
| 483 |
+
return f"repeat_{d}", 0.0
|
| 484 |
+
best_d, best = None, None
|
| 485 |
+
for d in avail:
|
| 486 |
+
dist = norm_edit_distance(sig, signatures[bar_i - d])
|
| 487 |
+
if best is None or dist < best:
|
| 488 |
+
best_d, best = d, dist
|
| 489 |
+
if best is not None and best <= tau:
|
| 490 |
+
return f"var_{best_d}", best
|
| 491 |
+
return "new", best
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
# ---------------------------------------------------------------------------
|
| 495 |
+
# labels
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def _bar_labels_impl(trace, spec):
|
| 499 |
+
"""(labels, normalization stats); labels are computed on the NORMALIZED
|
| 500 |
+
note stream (ignored decorations do not shape lattice denominators, DENS
|
| 501 |
+
counts, occupancy signatures or LHL scores)."""
|
| 502 |
+
norm_bars, _spans, start_index, stats = _normalize_spans(
|
| 503 |
+
trace, _bar_notes(trace))
|
| 504 |
+
signatures = [occupancy_signature(n) for n in norm_bars]
|
| 505 |
+
out = []
|
| 506 |
+
for i, notes in enumerate(norm_bars):
|
| 507 |
+
denom = bar_lattice_denom(notes)
|
| 508 |
+
onset_pos = [pos for pos, cls in notes if cls != "end"]
|
| 509 |
+
n_onsets = len(onset_pos)
|
| 510 |
+
next_db = (i + 1 < len(norm_bars)
|
| 511 |
+
and any(pos == 0 and cls != "end"
|
| 512 |
+
for pos, cls in norm_bars[i + 1]))
|
| 513 |
+
score = lhl_score(onset_pos, trace.bars[i].num, next_db)
|
| 514 |
+
ref, dist = motif_ref(signatures, i, spec.motif_tau,
|
| 515 |
+
spec.motif_deltas)
|
| 516 |
+
spans_here = []
|
| 517 |
+
for pos, cls in notes:
|
| 518 |
+
if cls in SPAN_CLASSES:
|
| 519 |
+
sp = start_index[(i, pos)]
|
| 520 |
+
spans_here.append({
|
| 521 |
+
"pos": pos, "class": cls,
|
| 522 |
+
"dur_beats": sp["dur_beats"],
|
| 523 |
+
"dur_bucket": spec.span_dur_bucket(sp["dur_beats"]),
|
| 524 |
+
"end_bar": sp["end_bar"], "end_pos": sp["end_pos"]})
|
| 525 |
+
out.append({
|
| 526 |
+
"bar": i, "denom": denom, "notes": notes,
|
| 527 |
+
"n_onsets": n_onsets,
|
| 528 |
+
"dens_bucket": spec.dens_bucket(n_onsets),
|
| 529 |
+
"n_span_starts": len(spans_here),
|
| 530 |
+
"span_budget_bucket": spec.span_budget_bucket(len(spans_here)),
|
| 531 |
+
"sync_score": score, "sync_band": spec.sync_band(score),
|
| 532 |
+
"motif_ref": ref, "motif_distance": dist,
|
| 533 |
+
"spans": spans_here,
|
| 534 |
+
})
|
| 535 |
+
return out, stats
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def bar_labels(trace, spec):
|
| 539 |
+
"""Deterministic per-bar labels (barscript_labels_v1). Pure function of
|
| 540 |
+
(trace, spec); §3. Returns a list with one dict per trace bar (computed
|
| 541 |
+
on the normalized note stream; use normalized_events for the stats)."""
|
| 542 |
+
return _bar_labels_impl(trace, spec)[0]
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
# ---------------------------------------------------------------------------
|
| 546 |
+
# encoding
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def _pos_token(pos, denom):
|
| 550 |
+
idx = pos * denom
|
| 551 |
+
if idx.denominator != 1:
|
| 552 |
+
raise OffLatticeError(
|
| 553 |
+
f"position {pos} does not sit on the /{denom} lattice")
|
| 554 |
+
idx = int(idx)
|
| 555 |
+
if not 0 <= idx < min(denom, WINDOW):
|
| 556 |
+
raise BarscriptError(f"position index {idx} out of [0, {denom})")
|
| 557 |
+
return VOCAB.time(idx)
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def trace_to_barscript(trace, spec, return_stats=False):
|
| 561 |
+
"""Encode a trace as the BarScript stream. Returns (token_ids, stage_ids)
|
| 562 |
+
(parallel lists; stages use vocab.STAGE_*), or (token_ids, stage_ids,
|
| 563 |
+
stats) with ``return_stats=True`` where stats carries the span
|
| 564 |
+
normalization counters. Ends with EOS; the window prefix ([BOS],
|
| 565 |
+
conditions) is the train-side builder's job."""
|
| 566 |
+
labels, stats = _bar_labels_impl(trace, spec)
|
| 567 |
+
v = VOCAB
|
| 568 |
+
tokens, stages = [], []
|
| 569 |
+
|
| 570 |
+
def put(tok, stage):
|
| 571 |
+
tokens.append(tok)
|
| 572 |
+
stages.append(stage)
|
| 573 |
+
|
| 574 |
+
for lab in labels:
|
| 575 |
+
denom = lab["denom"]
|
| 576 |
+
put(v.bar, STAGE_HEADER)
|
| 577 |
+
put(v.bar_denom[denom], STAGE_HEADER)
|
| 578 |
+
put(v.bar_dens[lab["dens_bucket"]], STAGE_PLAN)
|
| 579 |
+
put(v.span_budget[lab["span_budget_bucket"]], STAGE_PLAN)
|
| 580 |
+
put(v.bar_sync[lab["sync_band"]], STAGE_PLAN)
|
| 581 |
+
put(v.motif[lab["motif_ref"]], STAGE_PLAN)
|
| 582 |
+
for pos, cls in lab["notes"]:
|
| 583 |
+
if cls in SPAN_CLASSES:
|
| 584 |
+
put(v.span_start, STAGE_SKELETON)
|
| 585 |
+
elif cls == "end":
|
| 586 |
+
put(v.span_end, STAGE_SKELETON)
|
| 587 |
+
put(_pos_token(pos, denom), STAGE_SKELETON)
|
| 588 |
+
put(v.real, STAGE_REALIZATION)
|
| 589 |
+
span_iter = iter(lab["spans"])
|
| 590 |
+
for pos, cls in lab["notes"]:
|
| 591 |
+
put(v.note[cls], STAGE_REALIZATION)
|
| 592 |
+
if cls in SPAN_CLASSES:
|
| 593 |
+
put(v.span_dur[next(span_iter)["dur_bucket"]],
|
| 594 |
+
STAGE_REALIZATION)
|
| 595 |
+
put(v.eos, STAGE_HEADER)
|
| 596 |
+
if return_stats:
|
| 597 |
+
return tokens, stages, stats
|
| 598 |
+
return tokens, stages
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def trace_to_flat(trace, return_stats=False):
|
| 602 |
+
"""Flat interleaved encoding (the B0 control arm's ordering): per bar
|
| 603 |
+
[BAR] BAR_DENOM_D then (POS, TYPE) pairs in stream order. Same position
|
| 604 |
+
and type token families as BarScript; no plan/skeleton/duration tokens.
|
| 605 |
+
Applies the IDENTICAL span normalization as trace_to_barscript so both
|
| 606 |
+
arms train on one data population."""
|
| 607 |
+
norm_bars, _spans, _idx, stats = _normalize_spans(trace, _bar_notes(trace))
|
| 608 |
+
v = VOCAB
|
| 609 |
+
tokens = []
|
| 610 |
+
for notes in norm_bars:
|
| 611 |
+
denom = bar_lattice_denom(notes)
|
| 612 |
+
tokens += [v.bar, v.bar_denom[denom]]
|
| 613 |
+
for pos, cls in notes:
|
| 614 |
+
tokens += [_pos_token(pos, denom), v.note[cls]]
|
| 615 |
+
tokens.append(v.eos)
|
| 616 |
+
if return_stats:
|
| 617 |
+
return tokens, stats
|
| 618 |
+
return tokens
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
# ---------------------------------------------------------------------------
|
| 622 |
+
# re-zip (decoding back to events)
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _tok_family(tok):
|
| 626 |
+
v = VOCAB
|
| 627 |
+
if tok == v.bar:
|
| 628 |
+
return "bar"
|
| 629 |
+
if tok == v.real:
|
| 630 |
+
return "real"
|
| 631 |
+
if tok == v.eos:
|
| 632 |
+
return "eos"
|
| 633 |
+
if tok == v.span_start:
|
| 634 |
+
return "span_start"
|
| 635 |
+
if tok == v.span_end:
|
| 636 |
+
return "span_end"
|
| 637 |
+
if v.time0 <= tok < v.time0 + WINDOW:
|
| 638 |
+
return "pos"
|
| 639 |
+
if tok in v.id2note:
|
| 640 |
+
return "note"
|
| 641 |
+
for fam in ("bar_denom", "bar_dens", "span_budget", "bar_sync", "motif",
|
| 642 |
+
"span_dur"):
|
| 643 |
+
if tok in getattr(v, fam).values():
|
| 644 |
+
return fam
|
| 645 |
+
return "other"
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def barscript_to_events(token_ids):
|
| 649 |
+
"""Deterministic re-zip inverse: BarScript tokens -> ordered
|
| 650 |
+
[(bar_index, pos: Fraction, note_class)] (§1.2 "skeleton+realization
|
| 651 |
+
確定性 re-zip 回 (pos,type) 事件流"). Plan tokens are skipped; skeleton
|
| 652 |
+
and realization are zipped 1:1 in position order. Raises BarscriptError
|
| 653 |
+
on malformed structure."""
|
| 654 |
+
v = VOCAB
|
| 655 |
+
events = []
|
| 656 |
+
i, n = 0, len(token_ids)
|
| 657 |
+
bar_i = -1
|
| 658 |
+
denom_rev = {t: d for d, t in v.bar_denom.items()}
|
| 659 |
+
while i < n:
|
| 660 |
+
tok = token_ids[i]
|
| 661 |
+
if tok == v.eos:
|
| 662 |
+
return events
|
| 663 |
+
if tok != v.bar:
|
| 664 |
+
raise BarscriptError(f"token {i}: expected [BAR] or EOS")
|
| 665 |
+
bar_i += 1
|
| 666 |
+
i += 1
|
| 667 |
+
if i >= n or token_ids[i] not in denom_rev:
|
| 668 |
+
raise BarscriptError(f"token {i}: expected BAR_DENOM after [BAR]")
|
| 669 |
+
denom = denom_rev[token_ids[i]]
|
| 670 |
+
i += 1
|
| 671 |
+
# plan stage: skip plan-family tokens
|
| 672 |
+
while i < n and _tok_family(token_ids[i]) in (
|
| 673 |
+
"bar_dens", "span_budget", "bar_sync", "motif"):
|
| 674 |
+
i += 1
|
| 675 |
+
# skeleton stage
|
| 676 |
+
skeleton = [] # (idx, role)
|
| 677 |
+
while i < n:
|
| 678 |
+
fam = _tok_family(token_ids[i])
|
| 679 |
+
if fam == "real":
|
| 680 |
+
i += 1
|
| 681 |
+
break
|
| 682 |
+
role = "hit"
|
| 683 |
+
if fam in ("span_start", "span_end"):
|
| 684 |
+
role = fam
|
| 685 |
+
i += 1
|
| 686 |
+
fam = _tok_family(token_ids[i]) if i < n else None
|
| 687 |
+
if fam != "pos":
|
| 688 |
+
raise BarscriptError(
|
| 689 |
+
f"token {i}: expected a POSITION token in skeleton")
|
| 690 |
+
idx = token_ids[i] - v.time0
|
| 691 |
+
if idx >= denom or (skeleton and idx <= skeleton[-1][0]):
|
| 692 |
+
raise BarscriptError(
|
| 693 |
+
f"token {i}: position {idx} not strictly increasing "
|
| 694 |
+
f"on the /{denom} grid")
|
| 695 |
+
skeleton.append((idx, role))
|
| 696 |
+
i += 1
|
| 697 |
+
else:
|
| 698 |
+
raise BarscriptError("missing [REAL] before end of stream")
|
| 699 |
+
# realization stage: exactly one type per skeleton entry
|
| 700 |
+
for idx, role in skeleton:
|
| 701 |
+
if i >= n or token_ids[i] not in v.id2note:
|
| 702 |
+
raise BarscriptError(
|
| 703 |
+
f"token {i}: expected a TYPE token in realization")
|
| 704 |
+
cls = v.id2note[token_ids[i]]
|
| 705 |
+
i += 1
|
| 706 |
+
if role == "span_start":
|
| 707 |
+
if cls not in SPAN_CLASSES:
|
| 708 |
+
raise BarscriptError(
|
| 709 |
+
f"token {i - 1}: SPAN_START position realized as "
|
| 710 |
+
f"{cls!r}")
|
| 711 |
+
if i < n and _tok_family(token_ids[i]) == "span_dur":
|
| 712 |
+
i += 1
|
| 713 |
+
elif role == "span_end":
|
| 714 |
+
if cls != "end":
|
| 715 |
+
raise BarscriptError(
|
| 716 |
+
f"token {i - 1}: SPAN_END position realized as "
|
| 717 |
+
f"{cls!r}")
|
| 718 |
+
elif cls not in HIT_CLASSES:
|
| 719 |
+
raise BarscriptError(
|
| 720 |
+
f"token {i - 1}: unmarked position realized as {cls!r}")
|
| 721 |
+
events.append((bar_i, Fraction(idx, denom), cls))
|
| 722 |
+
raise BarscriptError("stream ended without EOS")
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def flat_to_events(token_ids):
|
| 726 |
+
"""Flat-encoding inverse: [(bar_index, pos: Fraction, note_class)]."""
|
| 727 |
+
v = VOCAB
|
| 728 |
+
denom_rev = {t: d for d, t in v.bar_denom.items()}
|
| 729 |
+
events = []
|
| 730 |
+
bar_i, denom = -1, None
|
| 731 |
+
i, n = 0, len(token_ids)
|
| 732 |
+
while i < n:
|
| 733 |
+
tok = token_ids[i]
|
| 734 |
+
if tok == v.eos:
|
| 735 |
+
return events
|
| 736 |
+
if tok == v.bar:
|
| 737 |
+
bar_i += 1
|
| 738 |
+
i += 1
|
| 739 |
+
if i >= n or token_ids[i] not in denom_rev:
|
| 740 |
+
raise BarscriptError(
|
| 741 |
+
f"token {i}: expected BAR_DENOM after [BAR]")
|
| 742 |
+
denom = denom_rev[token_ids[i]]
|
| 743 |
+
i += 1
|
| 744 |
+
continue
|
| 745 |
+
if _tok_family(tok) != "pos" or denom is None:
|
| 746 |
+
raise BarscriptError(f"token {i}: expected [BAR]/POS/EOS")
|
| 747 |
+
idx = tok - v.time0
|
| 748 |
+
i += 1
|
| 749 |
+
if i >= n or token_ids[i] not in v.id2note:
|
| 750 |
+
raise BarscriptError(f"token {i}: expected TYPE after POSITION")
|
| 751 |
+
events.append((bar_i, Fraction(idx, denom), v.id2note[token_ids[i]]))
|
| 752 |
+
i += 1
|
| 753 |
+
raise BarscriptError("stream ended without EOS")
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
def trace_events(trace):
|
| 757 |
+
"""RAW [(bar, pos, class)] stream straight from the trace (no span
|
| 758 |
+
normalization; the re-zip reference is normalized_events)."""
|
| 759 |
+
return [(ev.bar, ev.pos, note_class(ev.type))
|
| 760 |
+
for ev in trace.events if isinstance(ev, NoteEvent)]
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
# ---------------------------------------------------------------------------
|
| 764 |
+
# UL v2 symbolizer (re-zip consumer)
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def ul_symbols(events):
|
| 768 |
+
"""Interleaved (IOI, class) symbol stream over a (bar, pos, class) event
|
| 769 |
+
stream — the same normalization as train.py _ngram_symbols (class ids
|
| 770 |
+
0..7; IOI = delta to the previous event's global position; the first
|
| 771 |
+
event carries no IOI symbol), lifted to the exact rational lattice.
|
| 772 |
+
Global position is bar + pos in bar units; any deterministic
|
| 773 |
+
symbolization gives identical statistics for identical event streams."""
|
| 774 |
+
syms = []
|
| 775 |
+
prev = None
|
| 776 |
+
for bar, pos, cls in events:
|
| 777 |
+
g = Fraction(bar) + pos
|
| 778 |
+
if prev is not None:
|
| 779 |
+
syms.append(("ioi", g - prev))
|
| 780 |
+
syms.append(("cls", NOTE_CLASSES.index(cls)))
|
| 781 |
+
prev = g
|
| 782 |
+
return syms
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def ul_4gram_counts(events):
|
| 786 |
+
"""Counter of 4-grams over the UL symbol stream (the statistic the
|
| 787 |
+
unlikelihood objective conditions on)."""
|
| 788 |
+
from collections import Counter
|
| 789 |
+
syms = ul_symbols(events)
|
| 790 |
+
return Counter(tuple(syms[i:i + 4]) for i in range(len(syms) - 3))
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
# ---------------------------------------------------------------------------
|
| 794 |
+
# grammar FSM v2
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
class BarscriptFSM:
|
| 798 |
+
"""Stepwise legal-next-token mask for the BarScript grammar (§1.2 FSM v2
|
| 799 |
+
row + §8.1 plan->decode hard constraints; cross-bar open-span carry).
|
| 800 |
+
|
| 801 |
+
Usage: ``mask = fsm.legal_mask(); fsm.step(tok)``. ``step`` raises
|
| 802 |
+
IllegalTokenError (with a state summary) when tok is outside the mask.
|
| 803 |
+
``gold_masks`` below batch-precomputes masks for training.
|
| 804 |
+
"""
|
| 805 |
+
|
| 806 |
+
def __init__(self, spec, vocab=VOCAB, motif_hard=True):
|
| 807 |
+
self.spec = spec
|
| 808 |
+
self.v = vocab
|
| 809 |
+
self.motif_hard = bool(motif_hard)
|
| 810 |
+
self._denom_rev = {t: d for d, t in vocab.bar_denom.items()}
|
| 811 |
+
self._motif_rev = {t: m for m, t in vocab.motif.items()}
|
| 812 |
+
self.reset()
|
| 813 |
+
|
| 814 |
+
def reset(self):
|
| 815 |
+
self.phase = "start" # start|denom|plan0..plan3|skel|skel_pos|real|real_dur
|
| 816 |
+
self.bar_i = -1
|
| 817 |
+
self.denom = None
|
| 818 |
+
self.dens_lo = self.dens_hi = None
|
| 819 |
+
self.budget = None # remaining SPAN_START budget (None = unbounded)
|
| 820 |
+
self.open_span = False # cross-bar carry state
|
| 821 |
+
self.last_pos = -1
|
| 822 |
+
self.n_count = 0 # non-end skeleton positions this bar
|
| 823 |
+
self.pending = None # marker awaiting its position ("span_start"/"span_end")
|
| 824 |
+
self.skeleton = [] # (idx, role) for realization typing
|
| 825 |
+
self.real_i = 0
|
| 826 |
+
self.done = False
|
| 827 |
+
# MOTIF_REF hard constraint state
|
| 828 |
+
self.sigs = [] # per completed bar: occupancy signature (Fractions)
|
| 829 |
+
self.motif_ref = None # committed MOTIF_REF name for the current bar
|
| 830 |
+
self.repeat_target = None # forced countable indices (None = soft bar)
|
| 831 |
+
self.repeat_i = 0 # how many forced targets are already placed
|
| 832 |
+
|
| 833 |
+
# -- MOTIF_REF occupancy bookkeeping -----------------------------------
|
| 834 |
+
def _record_bar_sig(self):
|
| 835 |
+
"""Append the completed bar's occupancy signature (exact rational
|
| 836 |
+
positions of countable skeleton entries). Mirrors
|
| 837 |
+
``occupancy_signature`` on the normalized note stream: span starts
|
| 838 |
+
count (they are onsets), span ends do not."""
|
| 839 |
+
sig = tuple(Fraction(idx, self.denom)
|
| 840 |
+
for idx, role in self.skeleton if role != "span_end")
|
| 841 |
+
while len(self.sigs) < self.bar_i: # defensive (truncated stream)
|
| 842 |
+
self.sigs.append(())
|
| 843 |
+
if len(self.sigs) == self.bar_i:
|
| 844 |
+
self.sigs.append(sig)
|
| 845 |
+
else:
|
| 846 |
+
self.sigs[self.bar_i] = sig
|
| 847 |
+
|
| 848 |
+
def _repeat_indices(self, delta):
|
| 849 |
+
"""Forced countable indices for REPEAT_δ on this bar's lattice, or
|
| 850 |
+
None when the reference cannot be honoured under the already
|
| 851 |
+
committed BAR_DENOM / DENS / open-span state (plan-stage
|
| 852 |
+
prevention)."""
|
| 853 |
+
j = self.bar_i - delta
|
| 854 |
+
if j < 0 or j >= len(self.sigs) or self.denom is None:
|
| 855 |
+
return None
|
| 856 |
+
sig = self.sigs[j]
|
| 857 |
+
n = len(sig)
|
| 858 |
+
if n < self.dens_lo or (self.dens_hi is not None and n > self.dens_hi):
|
| 859 |
+
return None
|
| 860 |
+
limit = min(self.denom, WINDOW)
|
| 861 |
+
idxs = []
|
| 862 |
+
prev = -1
|
| 863 |
+
for pos in sig:
|
| 864 |
+
x = pos * self.denom
|
| 865 |
+
if x.denominator != 1:
|
| 866 |
+
return None # off this bar's lattice
|
| 867 |
+
i = int(x)
|
| 868 |
+
if not (0 <= i < limit) or i <= prev:
|
| 869 |
+
return None
|
| 870 |
+
idxs.append(i)
|
| 871 |
+
prev = i
|
| 872 |
+
if self.open_span and idxs and idxs[0] == 0:
|
| 873 |
+
return None # the carried span has nowhere to close first
|
| 874 |
+
return idxs
|
| 875 |
+
|
| 876 |
+
def _repeat_redundant(self, delta):
|
| 877 |
+
"""True when a smaller available δ' carries the identical signature:
|
| 878 |
+
the realized bar would relabel as REPEAT_δ', so REPEAT_δ is masked
|
| 879 |
+
(gold always emits the minimal δ, so this never touches gold)."""
|
| 880 |
+
sig = self.sigs[self.bar_i - delta]
|
| 881 |
+
return any(d < delta and 0 <= self.bar_i - d < len(self.sigs)
|
| 882 |
+
and self.sigs[self.bar_i - d] == sig
|
| 883 |
+
for d in self.spec.motif_deltas)
|
| 884 |
+
|
| 885 |
+
def _repeat_remaining(self):
|
| 886 |
+
return (self.repeat_target is not None
|
| 887 |
+
and self.repeat_i < len(self.repeat_target))
|
| 888 |
+
|
| 889 |
+
def _repeat_span_start_ok(self):
|
| 890 |
+
"""A span opened on the current target must leave a slot to close in
|
| 891 |
+
before the NEXT target (the last target may carry across the bar)."""
|
| 892 |
+
tgt, k = self.repeat_target, self.repeat_i
|
| 893 |
+
return k + 1 >= len(tgt) or tgt[k + 1] - tgt[k] >= 2
|
| 894 |
+
|
| 895 |
+
def _repeat_end_hi(self):
|
| 896 |
+
"""Highest legal SPAN_END index: strictly before the next forced
|
| 897 |
+
target (or the bar's last slot once the targets are exhausted)."""
|
| 898 |
+
if self._repeat_remaining():
|
| 899 |
+
return self.repeat_target[self.repeat_i] - 1
|
| 900 |
+
return self.denom - 1
|
| 901 |
+
|
| 902 |
+
# -- masks -------------------------------------------------------------
|
| 903 |
+
def _legal_positions(self):
|
| 904 |
+
"""Legal position indices for a plain (countable) skeleton position:
|
| 905 |
+
strictly increasing, on-grid, leaving room for the DENS lower bound
|
| 906 |
+
to stay reachable. Under a committed REPEAT_δ the position is fully
|
| 907 |
+
determined by the reference occupancy."""
|
| 908 |
+
if self.denom is None:
|
| 909 |
+
return range(0)
|
| 910 |
+
if self.repeat_target is not None:
|
| 911 |
+
if not self._repeat_remaining():
|
| 912 |
+
return range(0)
|
| 913 |
+
nxt = self.repeat_target[self.repeat_i]
|
| 914 |
+
return range(nxt, nxt + 1)
|
| 915 |
+
lo = self.last_pos + 1
|
| 916 |
+
hi = self.denom - 1
|
| 917 |
+
need_after = max(self.dens_lo - (self.n_count + 1), 0)
|
| 918 |
+
return range(lo, min(hi, self.denom - 1 - need_after) + 1)
|
| 919 |
+
|
| 920 |
+
def _skel_mask_repeat(self, mask):
|
| 921 |
+
"""Skeleton mask under a committed REPEAT_δ: countable positions are
|
| 922 |
+
forced onto the reference occupancy, [REAL] stays masked until every
|
| 923 |
+
target is placed, and the open-span carry keeps priority."""
|
| 924 |
+
v = self.v
|
| 925 |
+
remain = self._repeat_remaining()
|
| 926 |
+
any_skel = False
|
| 927 |
+
if self.open_span:
|
| 928 |
+
if self.last_pos + 1 <= self._repeat_end_hi():
|
| 929 |
+
mask[v.span_end] = True
|
| 930 |
+
any_skel = True
|
| 931 |
+
elif remain:
|
| 932 |
+
mask[v.time0 + self.repeat_target[self.repeat_i]] = True
|
| 933 |
+
any_skel = True
|
| 934 |
+
if ((self.budget is None or self.budget > 0)
|
| 935 |
+
and self._repeat_span_start_ok()):
|
| 936 |
+
mask[v.span_start] = True
|
| 937 |
+
if not remain or not any_skel:
|
| 938 |
+
# targets met -> [REAL] closes the skeleton; the `not any_skel`
|
| 939 |
+
# arm is the same fail-open escape hatch as the soft path.
|
| 940 |
+
mask[v.real] = True
|
| 941 |
+
|
| 942 |
+
def _skel_mask(self, mask):
|
| 943 |
+
v = self.v
|
| 944 |
+
if self.repeat_target is not None:
|
| 945 |
+
self._skel_mask_repeat(mask)
|
| 946 |
+
return
|
| 947 |
+
room = self.last_pos < self.denom - 1
|
| 948 |
+
can_count = self.dens_hi is None or self.n_count < self.dens_hi
|
| 949 |
+
any_skel = False
|
| 950 |
+
if self.open_span:
|
| 951 |
+
# carry: only SPAN_END may place content (no swallowed hits, no
|
| 952 |
+
# nested spans); the span may also pass through ([REAL] below).
|
| 953 |
+
if room:
|
| 954 |
+
mask[v.span_end] = True
|
| 955 |
+
any_skel = True
|
| 956 |
+
else:
|
| 957 |
+
if can_count:
|
| 958 |
+
legal = self._legal_positions()
|
| 959 |
+
if len(legal):
|
| 960 |
+
for i in legal:
|
| 961 |
+
mask[v.time0 + i] = True
|
| 962 |
+
any_skel = True
|
| 963 |
+
# SPAN_START is only offered when a countable position
|
| 964 |
+
# exists for it to land on: skel_pos reuses the same
|
| 965 |
+
# _legal_positions() range, so gating on non-empty legal
|
| 966 |
+
# keeps the never-dead-end guarantee (a room/budget-only
|
| 967 |
+
# gate could open SPAN_START into an empty position mask
|
| 968 |
+
# when the DENS room reservation exhausts the range,
|
| 969 |
+
# e.g. a carried span closing late in a high-L bar).
|
| 970 |
+
if self.budget is None or self.budget > 0:
|
| 971 |
+
mask[v.span_start] = True
|
| 972 |
+
if self.n_count >= self.dens_lo or not any_skel:
|
| 973 |
+
# fail-open escape hatch (docstring): [REAL] is always legal when
|
| 974 |
+
# nothing else is, so generation can never dead-end. Gold
|
| 975 |
+
# sequences satisfy [L, U] by construction and never need it.
|
| 976 |
+
mask[v.real] = True
|
| 977 |
+
|
| 978 |
+
def legal_mask(self):
|
| 979 |
+
v = self.v
|
| 980 |
+
mask = np.zeros(v.size, dtype=bool)
|
| 981 |
+
if self.done:
|
| 982 |
+
return mask
|
| 983 |
+
ph = self.phase
|
| 984 |
+
if ph == "start":
|
| 985 |
+
mask[v.bar] = True
|
| 986 |
+
if not self.open_span:
|
| 987 |
+
mask[v.eos] = True
|
| 988 |
+
elif ph == "denom":
|
| 989 |
+
for t in v.bar_denom.values():
|
| 990 |
+
mask[t] = True
|
| 991 |
+
elif ph == "plan0":
|
| 992 |
+
for t in v.bar_dens.values():
|
| 993 |
+
mask[t] = True
|
| 994 |
+
elif ph == "plan1":
|
| 995 |
+
for t in v.span_budget.values():
|
| 996 |
+
mask[t] = True
|
| 997 |
+
elif ph == "plan2":
|
| 998 |
+
for t in v.bar_sync.values():
|
| 999 |
+
mask[t] = True
|
| 1000 |
+
elif ph == "plan3":
|
| 1001 |
+
mask[v.motif["new"]] = True
|
| 1002 |
+
for name in MOTIF_REF_NAMES[1:]:
|
| 1003 |
+
delta = int(name.rsplit("_", 1)[1])
|
| 1004 |
+
if self.bar_i - delta < 0:
|
| 1005 |
+
continue
|
| 1006 |
+
if self.motif_hard and name.startswith("repeat_"):
|
| 1007 |
+
# plan-stage prevention: never commit to a reference the
|
| 1008 |
+
# skeleton stage could not reproduce, and never offer a
|
| 1009 |
+
# δ that would relabel as a smaller one.
|
| 1010 |
+
if (self._repeat_indices(delta) is None
|
| 1011 |
+
or self._repeat_redundant(delta)):
|
| 1012 |
+
continue
|
| 1013 |
+
mask[v.motif[name]] = True
|
| 1014 |
+
elif ph == "skel":
|
| 1015 |
+
self._skel_mask(mask)
|
| 1016 |
+
elif ph == "skel_pos":
|
| 1017 |
+
lo = self.last_pos + 1
|
| 1018 |
+
if self.pending == "span_end":
|
| 1019 |
+
# closing position: strictly increasing is the legality rule
|
| 1020 |
+
# (same-bar closes sit after the start; cross-bar closes are
|
| 1021 |
+
# later by construction); no DENS room reservation (an end
|
| 1022 |
+
# position is not countable). Under a committed REPEAT_δ the
|
| 1023 |
+
# close must also land before the next forced target.
|
| 1024 |
+
hi = (self._repeat_end_hi() if self.repeat_target is not None
|
| 1025 |
+
else self.denom - 1)
|
| 1026 |
+
for i in range(lo, hi + 1):
|
| 1027 |
+
mask[v.time0 + i] = True
|
| 1028 |
+
else:
|
| 1029 |
+
for i in self._legal_positions():
|
| 1030 |
+
mask[v.time0 + i] = True
|
| 1031 |
+
elif ph == "real":
|
| 1032 |
+
idx, role = self.skeleton[self.real_i]
|
| 1033 |
+
if role == "span_start":
|
| 1034 |
+
for cls in SPAN_CLASSES:
|
| 1035 |
+
mask[v.note[cls]] = True
|
| 1036 |
+
elif role == "span_end":
|
| 1037 |
+
mask[v.note["end"]] = True
|
| 1038 |
+
else:
|
| 1039 |
+
for cls in HIT_CLASSES:
|
| 1040 |
+
mask[v.note[cls]] = True
|
| 1041 |
+
elif ph == "real_dur":
|
| 1042 |
+
for t in v.span_dur.values():
|
| 1043 |
+
mask[t] = True
|
| 1044 |
+
return mask
|
| 1045 |
+
|
| 1046 |
+
def state_summary(self):
|
| 1047 |
+
motif = self.motif_ref or "-"
|
| 1048 |
+
if self.repeat_target is not None:
|
| 1049 |
+
motif += f"{self.repeat_target}@{self.repeat_i}"
|
| 1050 |
+
return (f"bar={self.bar_i} phase={self.phase} denom={self.denom} "
|
| 1051 |
+
f"dens=[{self.dens_lo},{self.dens_hi}] count={self.n_count} "
|
| 1052 |
+
f"budget={self.budget} open_span={self.open_span} "
|
| 1053 |
+
f"last_pos={self.last_pos} real_i={self.real_i} "
|
| 1054 |
+
f"motif={motif}")
|
| 1055 |
+
|
| 1056 |
+
# -- transition --------------------------------------------------------
|
| 1057 |
+
def step(self, tok):
|
| 1058 |
+
mask = self.legal_mask()
|
| 1059 |
+
if not (0 <= tok < len(mask)) or not mask[tok]:
|
| 1060 |
+
raise IllegalTokenError(
|
| 1061 |
+
f"token {tok} illegal at [{self.state_summary()}]")
|
| 1062 |
+
v = self.v
|
| 1063 |
+
ph = self.phase
|
| 1064 |
+
if ph == "start":
|
| 1065 |
+
if tok == v.eos:
|
| 1066 |
+
self.done = True
|
| 1067 |
+
else:
|
| 1068 |
+
self.bar_i += 1
|
| 1069 |
+
self.phase = "denom"
|
| 1070 |
+
elif ph == "denom":
|
| 1071 |
+
self.denom = self._denom_rev[tok]
|
| 1072 |
+
self.last_pos = -1
|
| 1073 |
+
self.n_count = 0
|
| 1074 |
+
self.skeleton = []
|
| 1075 |
+
self.real_i = 0
|
| 1076 |
+
self.pending = None
|
| 1077 |
+
self.motif_ref = None
|
| 1078 |
+
self.repeat_target = None
|
| 1079 |
+
self.repeat_i = 0
|
| 1080 |
+
self.phase = "plan0"
|
| 1081 |
+
elif ph == "plan0":
|
| 1082 |
+
bucket = next(d for d, t in v.bar_dens.items() if t == tok)
|
| 1083 |
+
self.dens_lo, self.dens_hi = self.spec.dens_bounds(bucket)
|
| 1084 |
+
self.phase = "plan1"
|
| 1085 |
+
elif ph == "plan1":
|
| 1086 |
+
b = next(s for s, t in v.span_budget.items() if t == tok)
|
| 1087 |
+
self.budget = None if b == N_SPAN_BUDGET - 1 else b
|
| 1088 |
+
self.phase = "plan2"
|
| 1089 |
+
elif ph == "plan2":
|
| 1090 |
+
self.phase = "plan3"
|
| 1091 |
+
elif ph == "plan3":
|
| 1092 |
+
name = self._motif_rev[tok]
|
| 1093 |
+
self.motif_ref = name
|
| 1094 |
+
if self.motif_hard and name.startswith("repeat_"):
|
| 1095 |
+
self.repeat_target = self._repeat_indices(
|
| 1096 |
+
int(name.rsplit("_", 1)[1]))
|
| 1097 |
+
self.repeat_i = 0
|
| 1098 |
+
self.phase = "skel"
|
| 1099 |
+
elif ph == "skel":
|
| 1100 |
+
if tok == v.real:
|
| 1101 |
+
self._record_bar_sig()
|
| 1102 |
+
self.phase = "real" if self.skeleton else "start"
|
| 1103 |
+
elif tok == v.span_start:
|
| 1104 |
+
self.pending = "span_start"
|
| 1105 |
+
self.phase = "skel_pos"
|
| 1106 |
+
elif tok == v.span_end:
|
| 1107 |
+
self.pending = "span_end"
|
| 1108 |
+
self.phase = "skel_pos"
|
| 1109 |
+
else: # plain position
|
| 1110 |
+
self._add_pos(tok - v.time0, "hit")
|
| 1111 |
+
elif ph == "skel_pos":
|
| 1112 |
+
role = self.pending
|
| 1113 |
+
self.pending = None
|
| 1114 |
+
self._add_pos(tok - v.time0, role)
|
| 1115 |
+
self.phase = "skel"
|
| 1116 |
+
elif ph == "real":
|
| 1117 |
+
_idx, role = self.skeleton[self.real_i]
|
| 1118 |
+
if role == "span_start":
|
| 1119 |
+
self.phase = "real_dur"
|
| 1120 |
+
else:
|
| 1121 |
+
self.real_i += 1
|
| 1122 |
+
if self.real_i == len(self.skeleton):
|
| 1123 |
+
self.phase = "start"
|
| 1124 |
+
elif ph == "real_dur":
|
| 1125 |
+
self.real_i += 1
|
| 1126 |
+
self.phase = "real" if self.real_i < len(self.skeleton) else "start"
|
| 1127 |
+
|
| 1128 |
+
def _add_pos(self, idx, role):
|
| 1129 |
+
self.last_pos = idx
|
| 1130 |
+
self.skeleton.append((idx, role))
|
| 1131 |
+
if role == "span_end":
|
| 1132 |
+
self.open_span = False
|
| 1133 |
+
return
|
| 1134 |
+
if role == "span_start":
|
| 1135 |
+
self.open_span = True
|
| 1136 |
+
if self.budget is not None:
|
| 1137 |
+
self.budget -= 1
|
| 1138 |
+
self.n_count += 1
|
| 1139 |
+
if self.repeat_target is not None:
|
| 1140 |
+
self.repeat_i += 1
|
| 1141 |
+
|
| 1142 |
+
|
| 1143 |
+
def gold_masks(token_ids, spec, motif_hard=True):
|
| 1144 |
+
"""Precompute the (L, V) bool legal-mask array for a gold sequence (the
|
| 1145 |
+
batch path the trainer consumes; the stepwise FSM is the correctness
|
| 1146 |
+
oracle it is verified against). Raises IllegalTokenError if the sequence
|
| 1147 |
+
leaves the grammar."""
|
| 1148 |
+
fsm = BarscriptFSM(spec, motif_hard=motif_hard)
|
| 1149 |
+
masks = np.zeros((len(token_ids), VOCAB.size), dtype=bool)
|
| 1150 |
+
for i, tok in enumerate(token_ids):
|
| 1151 |
+
masks[i] = fsm.legal_mask()
|
| 1152 |
+
fsm.step(tok)
|
| 1153 |
+
return masks
|
softchart/sc2/checkpoint.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Strict generator-checkpoint metadata and architecture reconstruction.
|
| 2 |
+
|
| 3 |
+
V1.8 condition semantics cannot be recovered from tensor shapes. A missing
|
| 4 |
+
``dual`` or ``ctx`` flag changes the decoder prefix while all weights still
|
| 5 |
+
load successfully, so release loading must reject incomplete or contradictory
|
| 6 |
+
metadata instead of guessing from state-dict keys.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
from collections.abc import Mapping
|
| 13 |
+
from numbers import Integral, Real
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
from .vocab import N_MELS, VOCAB
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# SoftChart 2.0 checkpoint args (2026-07-25 interface ruling): part of the
|
| 21 |
+
# REQUIRED set + presence checks. Pre-2.0 checkpoints lack the keys entirely,
|
| 22 |
+
# so parse_generator_checkpoint BACKFILLS them to False before validation —
|
| 23 |
+
# older checkpoints keep loading bit-identically. ``axis_knobs`` is a pure
|
| 24 |
+
# capability flag (big_rate/span_rate/stream prefix conditioning; no state
|
| 25 |
+
# keys of its own).
|
| 26 |
+
V2_MODEL_ARGS = frozenset({
|
| 27 |
+
"stage_emb", "span_duration_head", "tempo_head", "rich_section_stats",
|
| 28 |
+
"axis_knobs",
|
| 29 |
+
})
|
| 30 |
+
|
| 31 |
+
REQUIRED_GENERATOR_ARGS = frozenset({
|
| 32 |
+
"d_model", "nhead", "dropout", "enc_layers", "dec_layers", "ffn",
|
| 33 |
+
"emb_factor", "func_time", "aux", "global_ctx", "hierarchical_ctx",
|
| 34 |
+
"section_layers", "section_ffn", "clean_phase", "enc_share",
|
| 35 |
+
"dec_share", "adapter_rank", "unique_layernorm", "adapter_rank_ffn",
|
| 36 |
+
"depth_emb", "unshare_last_dec", "align", "beat_head", "beat_hires",
|
| 37 |
+
"ctx", "sibling", "style", "sync_token", "plan", "mask_infill",
|
| 38 |
+
"complexity", "slot", "dual",
|
| 39 |
+
}) | V2_MODEL_ARGS
|
| 40 |
+
|
| 41 |
+
_BOOL_ARGS = frozenset({
|
| 42 |
+
"func_time", "aux", "global_ctx", "hierarchical_ctx", "clean_phase",
|
| 43 |
+
"unique_layernorm", "depth_emb", "unshare_last_dec", "align",
|
| 44 |
+
"beat_head", "beat_hires", "ctx", "sibling", "style", "sync_token",
|
| 45 |
+
"plan", "complexity", "slot", "dual",
|
| 46 |
+
}) | V2_MODEL_ARGS
|
| 47 |
+
|
| 48 |
+
GENERATOR_CAPABILITY_KEYS = (
|
| 49 |
+
"ctx", "sibling", "style", "sync_token", "plan", "beat_head", "align",
|
| 50 |
+
"aux", "func_time", "mask_infill", "complexity", "slot", "dual",
|
| 51 |
+
"global_ctx", "hierarchical_ctx", "axis_knobs",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
VOCAB_EXPANSION_SEED = 20260725 # deterministic init for appended token rows
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def expand_vocab_rows(state, vocab_size, seed=VOCAB_EXPANSION_SEED):
|
| 58 |
+
"""Grow ``tok_emb.weight`` (and a tied ``out.weight``) to ``vocab_size``
|
| 59 |
+
rows, initializing the appended rows N(0, 0.02) exactly like ChartModel's
|
| 60 |
+
embedding init (deterministic via ``seed``).
|
| 61 |
+
|
| 62 |
+
The vocabulary only ever grows append-only (vocab.py contract), so an
|
| 63 |
+
older checkpoint's rows are a prefix of the current table. Returns a new
|
| 64 |
+
mapping; tensors that need no change are passed through untouched.
|
| 65 |
+
Raises when the checkpoint has MORE rows than requested (a newer-vocab
|
| 66 |
+
checkpoint cannot be truncated to an older runtime).
|
| 67 |
+
"""
|
| 68 |
+
if "tok_emb.weight" not in state:
|
| 69 |
+
raise ValueError("state has no tok_emb.weight to expand")
|
| 70 |
+
old_rows = int(state["tok_emb.weight"].shape[0])
|
| 71 |
+
if old_rows > vocab_size:
|
| 72 |
+
raise ValueError(
|
| 73 |
+
f"checkpoint vocab has {old_rows} rows; cannot shrink to "
|
| 74 |
+
f"{vocab_size} (newer-vocab checkpoint under an older runtime)")
|
| 75 |
+
if old_rows == vocab_size:
|
| 76 |
+
return state
|
| 77 |
+
out = dict(state)
|
| 78 |
+
gen = torch.Generator(device="cpu").manual_seed(seed)
|
| 79 |
+
grown = None
|
| 80 |
+
for key in ("tok_emb.weight", "out.weight"):
|
| 81 |
+
w = state.get(key)
|
| 82 |
+
if w is None:
|
| 83 |
+
continue
|
| 84 |
+
if int(w.shape[0]) != old_rows:
|
| 85 |
+
raise ValueError(
|
| 86 |
+
f"{key} has {int(w.shape[0])} rows but tok_emb.weight has "
|
| 87 |
+
f"{old_rows}; refusing to expand an inconsistent state")
|
| 88 |
+
if grown is None: # one shared block keeps tied weights tied
|
| 89 |
+
grown = torch.empty(
|
| 90 |
+
vocab_size - old_rows, int(w.shape[1]), dtype=torch.float32,
|
| 91 |
+
).normal_(mean=0.0, std=0.02, generator=gen)
|
| 92 |
+
out[key] = torch.cat(
|
| 93 |
+
[w, grown.to(device=w.device, dtype=w.dtype)], dim=0)
|
| 94 |
+
return out
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _state_has(state: Mapping[str, torch.Tensor], prefix: str) -> bool:
|
| 98 |
+
return any(key.startswith(prefix) for key in state)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _require_presence(
|
| 102 |
+
state: Mapping[str, torch.Tensor], prefix: str, expected: bool, label: str,
|
| 103 |
+
) -> None:
|
| 104 |
+
present = _state_has(state, prefix)
|
| 105 |
+
if present != expected:
|
| 106 |
+
raise ValueError(
|
| 107 |
+
f"checkpoint args/state disagree for {label}: "
|
| 108 |
+
f"metadata={expected}, state={present}"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _positive_int(args: Mapping[str, object], name: str) -> int:
|
| 113 |
+
value = args[name]
|
| 114 |
+
if not isinstance(value, Integral) or isinstance(value, bool) or int(value) <= 0:
|
| 115 |
+
raise ValueError(f"checkpoint arg {name} must be a positive integer")
|
| 116 |
+
return int(value)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _nonnegative_int(args: Mapping[str, object], name: str) -> int:
|
| 120 |
+
value = args[name]
|
| 121 |
+
if not isinstance(value, Integral) or isinstance(value, bool) or int(value) < 0:
|
| 122 |
+
raise ValueError(f"checkpoint arg {name} must be a non-negative integer")
|
| 123 |
+
return int(value)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def parse_generator_checkpoint(checkpoint: Mapping[str, object]) -> tuple[
|
| 127 |
+
Mapping[str, torch.Tensor], dict, dict, dict
|
| 128 |
+
]:
|
| 129 |
+
"""Validate and return ``state, args, model_config, capabilities``.
|
| 130 |
+
|
| 131 |
+
The returned configuration is suitable for ``ChartModel``. Tensor shapes
|
| 132 |
+
are still checked a second time by its strict ``load_state_dict`` call.
|
| 133 |
+
"""
|
| 134 |
+
if not isinstance(checkpoint, Mapping):
|
| 135 |
+
raise ValueError("generator checkpoint must be a mapping")
|
| 136 |
+
state = checkpoint.get("model")
|
| 137 |
+
args = checkpoint.get("args")
|
| 138 |
+
if not isinstance(state, Mapping) or not state:
|
| 139 |
+
raise ValueError("generator checkpoint model must be a non-empty mapping")
|
| 140 |
+
if not isinstance(args, Mapping):
|
| 141 |
+
raise ValueError("generator checkpoint args must be a mapping")
|
| 142 |
+
if not all(isinstance(key, str) and isinstance(value, torch.Tensor)
|
| 143 |
+
for key, value in state.items()):
|
| 144 |
+
raise ValueError("generator state must map string keys to tensors")
|
| 145 |
+
args = dict(args)
|
| 146 |
+
for name in V2_MODEL_ARGS: # pre-2.0 checkpoints: backfill, keep loading
|
| 147 |
+
args.setdefault(name, False)
|
| 148 |
+
missing = sorted(REQUIRED_GENERATOR_ARGS - set(args))
|
| 149 |
+
if missing:
|
| 150 |
+
raise ValueError(f"generator checkpoint lacks required args: {missing}")
|
| 151 |
+
|
| 152 |
+
for name in _BOOL_ARGS:
|
| 153 |
+
if not isinstance(args[name], bool):
|
| 154 |
+
raise ValueError(f"checkpoint arg {name} must be boolean")
|
| 155 |
+
for name in ("d_model", "nhead", "enc_layers", "dec_layers", "ffn",
|
| 156 |
+
"section_layers"):
|
| 157 |
+
_positive_int(args, name)
|
| 158 |
+
for name in ("emb_factor", "section_ffn", "adapter_rank",
|
| 159 |
+
"adapter_rank_ffn"):
|
| 160 |
+
_nonnegative_int(args, name)
|
| 161 |
+
dropout = args["dropout"]
|
| 162 |
+
if (not isinstance(dropout, Real) or isinstance(dropout, bool)
|
| 163 |
+
or not math.isfinite(float(dropout)) or not 0.0 <= float(dropout) < 1.0):
|
| 164 |
+
raise ValueError("checkpoint arg dropout must be finite in [0, 1)")
|
| 165 |
+
if args["enc_share"] is not None and not isinstance(args["enc_share"], (str, int)):
|
| 166 |
+
raise ValueError("checkpoint arg enc_share must be null, integer, or string")
|
| 167 |
+
if args["dec_share"] is not None and not isinstance(args["dec_share"], (str, int)):
|
| 168 |
+
raise ValueError("checkpoint arg dec_share must be null, integer, or string")
|
| 169 |
+
if args["global_ctx"] and args["hierarchical_ctx"]:
|
| 170 |
+
raise ValueError("global_ctx and hierarchical_ctx are mutually exclusive")
|
| 171 |
+
if args["hierarchical_ctx"] and not args["aux"]:
|
| 172 |
+
raise ValueError("hierarchical_ctx requires aux skeleton supervision")
|
| 173 |
+
if args["beat_hires"] and not args["beat_head"]:
|
| 174 |
+
raise ValueError("beat_hires requires beat_head")
|
| 175 |
+
if (args["slot"] or args["dual"]) and not args["clean_phase"]:
|
| 176 |
+
raise ValueError("V1.8 slot/dual checkpoints require clean_phase")
|
| 177 |
+
if args["complexity"]:
|
| 178 |
+
raise ValueError(
|
| 179 |
+
"complexity-conditioned checkpoints are not supported by the "
|
| 180 |
+
"release generation API"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
required_tensors = ("tok_emb.weight", "frontend.0.weight")
|
| 184 |
+
absent = [name for name in required_tensors if name not in state]
|
| 185 |
+
if absent:
|
| 186 |
+
raise ValueError(f"generator state lacks required tensors: {absent}")
|
| 187 |
+
token_weight = state["tok_emb.weight"]
|
| 188 |
+
front_weight = state["frontend.0.weight"]
|
| 189 |
+
if token_weight.ndim != 2 or front_weight.ndim != 3:
|
| 190 |
+
raise ValueError("generator embedding/frontend tensors have invalid rank")
|
| 191 |
+
|
| 192 |
+
d_model = int(args["d_model"])
|
| 193 |
+
nhead = int(args["nhead"])
|
| 194 |
+
if d_model % nhead:
|
| 195 |
+
raise ValueError("checkpoint d_model must be divisible by nhead")
|
| 196 |
+
vocab_size, embedding_width = map(int, token_weight.shape)
|
| 197 |
+
if vocab_size > VOCAB.size:
|
| 198 |
+
raise ValueError(
|
| 199 |
+
f"checkpoint vocab has {vocab_size} rows; current vocabulary has "
|
| 200 |
+
f"only {VOCAB.size} (newer-vocab checkpoint under an older runtime)"
|
| 201 |
+
)
|
| 202 |
+
if vocab_size < VOCAB.size:
|
| 203 |
+
# Append-only vocab growth (SoftChart 2.0): older checkpoints load by
|
| 204 |
+
# deterministically initializing the appended token rows.
|
| 205 |
+
state = expand_vocab_rows(state, VOCAB.size)
|
| 206 |
+
token_weight = state["tok_emb.weight"]
|
| 207 |
+
vocab_size = VOCAB.size
|
| 208 |
+
emb_factor = int(args["emb_factor"])
|
| 209 |
+
expected_width = emb_factor or d_model
|
| 210 |
+
if embedding_width != expected_width:
|
| 211 |
+
raise ValueError(
|
| 212 |
+
"checkpoint args/state disagree for emb_factor: "
|
| 213 |
+
f"expected width {expected_width}, state has {embedding_width}"
|
| 214 |
+
)
|
| 215 |
+
if int(front_weight.shape[0]) != d_model:
|
| 216 |
+
raise ValueError("frontend output channels disagree with d_model")
|
| 217 |
+
expected_front_channels = (
|
| 218 |
+
N_MELS if args["clean_phase"]
|
| 219 |
+
else (N_MELS + 2 if (args["slot"] or args["dual"]) else N_MELS)
|
| 220 |
+
)
|
| 221 |
+
if int(front_weight.shape[1]) != expected_front_channels:
|
| 222 |
+
raise ValueError(
|
| 223 |
+
"frontend input channels disagree with clean slot/time metadata: "
|
| 224 |
+
f"expected {expected_front_channels}, state has {front_weight.shape[1]}"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
_require_presence(state, "emb_proj.", emb_factor > 0, "emb_factor")
|
| 228 |
+
_require_presence(
|
| 229 |
+
state, "section_encoder.", args["hierarchical_ctx"], "hierarchical_ctx")
|
| 230 |
+
_require_presence(
|
| 231 |
+
state, "skeleton_proj.", args["hierarchical_ctx"], "hierarchical skeleton")
|
| 232 |
+
_require_presence(state, "gsum_proj.", args["global_ctx"], "global_ctx")
|
| 233 |
+
_require_presence(state, "aux.", args["aux"], "aux")
|
| 234 |
+
_require_presence(state, "phase_proj.", args["clean_phase"], "clean_phase")
|
| 235 |
+
_require_presence(state, "time_proj.", args["func_time"], "func_time")
|
| 236 |
+
_require_presence(state, "ptr.", args["align"], "align")
|
| 237 |
+
_require_presence(state, "beat.", args["beat_head"], "beat_head")
|
| 238 |
+
_require_presence(
|
| 239 |
+
state, "beat.up.", args["beat_head"] and args["beat_hires"], "beat_hires")
|
| 240 |
+
|
| 241 |
+
# SoftChart 2.0 presence checks (state-key prefixes per the 2026-07-25
|
| 242 |
+
# interface ruling; axis_knobs carries no state keys)
|
| 243 |
+
_require_presence(state, "stage_emb", args["stage_emb"], "stage_emb")
|
| 244 |
+
_require_presence(state, "span_dur.", args["span_duration_head"],
|
| 245 |
+
"span_duration_head")
|
| 246 |
+
_require_presence(state, "tempo_logp.", args["tempo_head"], "tempo_head")
|
| 247 |
+
_require_presence(state, "tempo_k.", args["tempo_head"], "tempo_head")
|
| 248 |
+
_require_presence(
|
| 249 |
+
state, "section_encoder.inp_rich.",
|
| 250 |
+
args["hierarchical_ctx"] and args["rich_section_stats"],
|
| 251 |
+
"rich_section_stats")
|
| 252 |
+
max_tgt = args.get("max_tgt")
|
| 253 |
+
if max_tgt is not None and (not isinstance(max_tgt, Integral)
|
| 254 |
+
or isinstance(max_tgt, bool)
|
| 255 |
+
or int(max_tgt) <= 0):
|
| 256 |
+
raise ValueError("checkpoint arg max_tgt must be a positive integer")
|
| 257 |
+
|
| 258 |
+
beat: bool | str = (
|
| 259 |
+
"hires" if args["beat_head"] and args["beat_hires"]
|
| 260 |
+
else bool(args["beat_head"])
|
| 261 |
+
)
|
| 262 |
+
model_config = {
|
| 263 |
+
"d_model": d_model,
|
| 264 |
+
"nhead": nhead,
|
| 265 |
+
"dropout": float(dropout),
|
| 266 |
+
"enc_layers": int(args["enc_layers"]),
|
| 267 |
+
"dec_layers": int(args["dec_layers"]),
|
| 268 |
+
"ffn": int(args["ffn"]),
|
| 269 |
+
"vocab_size": vocab_size,
|
| 270 |
+
"aux": bool(args["aux"]),
|
| 271 |
+
"global_ctx": bool(args["global_ctx"]),
|
| 272 |
+
"hierarchical_ctx": bool(args["hierarchical_ctx"]),
|
| 273 |
+
"section_layers": int(args["section_layers"]),
|
| 274 |
+
"section_ffn": int(args["section_ffn"]) or None,
|
| 275 |
+
"func_time": bool(args["func_time"]),
|
| 276 |
+
"ptr": bool(args["align"]),
|
| 277 |
+
"beat": beat,
|
| 278 |
+
"in_ch": N_MELS + 2 if (args["slot"] or args["dual"]) else N_MELS,
|
| 279 |
+
"emb_factor": emb_factor or None,
|
| 280 |
+
"clean_phase": bool(args["clean_phase"]),
|
| 281 |
+
"enc_share": args["enc_share"],
|
| 282 |
+
"dec_share": args["dec_share"],
|
| 283 |
+
"adapter_rank": int(args["adapter_rank"]),
|
| 284 |
+
"unique_layernorm": bool(args["unique_layernorm"]),
|
| 285 |
+
"adapter_rank_ffn": int(args["adapter_rank_ffn"]),
|
| 286 |
+
"depth_emb": bool(args["depth_emb"]),
|
| 287 |
+
"unshare_last_dec": bool(args["unshare_last_dec"]),
|
| 288 |
+
"stage_emb": bool(args["stage_emb"]),
|
| 289 |
+
"span_duration_head": bool(args["span_duration_head"]),
|
| 290 |
+
"tempo_head": bool(args["tempo_head"]),
|
| 291 |
+
"rich_section_stats": bool(args["rich_section_stats"]),
|
| 292 |
+
}
|
| 293 |
+
if max_tgt is not None:
|
| 294 |
+
model_config["max_tgt"] = int(max_tgt)
|
| 295 |
+
capabilities = {
|
| 296 |
+
name: (bool(args[name]) if name != "hierarchical_ctx"
|
| 297 |
+
else bool(args["hierarchical_ctx"]))
|
| 298 |
+
for name in GENERATOR_CAPABILITY_KEYS
|
| 299 |
+
}
|
| 300 |
+
# BarScript prefixes ALWAYS carry the sync slot -- [BOS] course level dens
|
| 301 |
+
# SYNC big_rate span_rate stream -- but train.py records the v1.8
|
| 302 |
+
# `sync_token` flag, which sc2_train.sh never sets. A loader that trusted
|
| 303 |
+
# the recorded False would build a 7-token prefix for an 8-token model and
|
| 304 |
+
# silently collapse every sync bucket onto one conditioning: no error
|
| 305 |
+
# raised, just a dead knob. Derive it from the mode here, at the single
|
| 306 |
+
# point where capabilities are read off the checkpoint, so every consumer
|
| 307 |
+
# (attach_generator_capabilities, the packager, the HF path) agrees.
|
| 308 |
+
# v1.8 artifacts (mode absent, or slot/time) keep the recorded flag.
|
| 309 |
+
if str(args.get("mode", "")) in ("b0", "c1"):
|
| 310 |
+
capabilities["sync_token"] = True
|
| 311 |
+
return state, args, model_config, capabilities
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def attach_generator_capabilities(model, capabilities: Mapping[str, bool]) -> None:
|
| 315 |
+
"""Attach the exact semantic flags consumed by generation."""
|
| 316 |
+
capabilities = dict(capabilities)
|
| 317 |
+
# pre-2.0 capability dicts (released HF repos) lack the new knob flag
|
| 318 |
+
capabilities.setdefault("axis_knobs", False)
|
| 319 |
+
missing = sorted(set(GENERATOR_CAPABILITY_KEYS) - set(capabilities))
|
| 320 |
+
if missing:
|
| 321 |
+
raise ValueError(f"generator capabilities are incomplete: {missing}")
|
| 322 |
+
invalid = sorted(
|
| 323 |
+
name for name in GENERATOR_CAPABILITY_KEYS
|
| 324 |
+
if not isinstance(capabilities[name], bool)
|
| 325 |
+
)
|
| 326 |
+
if invalid:
|
| 327 |
+
raise ValueError(f"generator capabilities must be boolean: {invalid}")
|
| 328 |
+
model._has_axis_knobs = bool(capabilities["axis_knobs"])
|
| 329 |
+
model._has_ctx = bool(capabilities["ctx"])
|
| 330 |
+
model._has_sib = bool(capabilities["sibling"])
|
| 331 |
+
model._has_style = bool(capabilities["style"])
|
| 332 |
+
model._has_sync = bool(capabilities["sync_token"])
|
| 333 |
+
model._has_plan = bool(capabilities["plan"])
|
| 334 |
+
model._dual = bool(capabilities["dual"])
|
| 335 |
+
model._slot = bool(capabilities["slot"]) or model._dual
|
| 336 |
+
model._hierarchical_ctx = bool(capabilities["hierarchical_ctx"])
|
| 337 |
+
model._has_gctx = bool(capabilities["global_ctx"] or model._hierarchical_ctx)
|
softchart/sc2/generate.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
softchart/sc2/meter.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exact rational-meter helpers for the 96-slot-per-4/4 TJA lattice."""
|
| 2 |
+
|
| 3 |
+
from fractions import Fraction
|
| 4 |
+
from math import gcd
|
| 5 |
+
from numbers import Real
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from .vocab import SLOTS, WINDOW
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _expand(value, n_measures, name, dtype):
|
| 13 |
+
array = np.asarray(value)
|
| 14 |
+
if array.ndim == 0:
|
| 15 |
+
array = np.full(n_measures, array.item())
|
| 16 |
+
else:
|
| 17 |
+
array = array.reshape(-1)
|
| 18 |
+
if len(array) != n_measures:
|
| 19 |
+
raise ValueError(
|
| 20 |
+
f"{name} must contain one value per measure "
|
| 21 |
+
f"({n_measures}), got {len(array)}"
|
| 22 |
+
)
|
| 23 |
+
if np.issubdtype(np.dtype(dtype), np.integer):
|
| 24 |
+
info = np.iinfo(dtype)
|
| 25 |
+
values = []
|
| 26 |
+
for raw in array.flat:
|
| 27 |
+
if isinstance(raw, (bool, np.bool_)):
|
| 28 |
+
raise ValueError(f"{name} must contain finite integers")
|
| 29 |
+
if isinstance(raw, (int, np.integer)):
|
| 30 |
+
integer = int(raw)
|
| 31 |
+
elif isinstance(raw, (float, np.floating)):
|
| 32 |
+
number = float(raw)
|
| 33 |
+
if not np.isfinite(number) or not number.is_integer():
|
| 34 |
+
raise ValueError(f"{name} must contain finite integers")
|
| 35 |
+
integer = int(number)
|
| 36 |
+
else:
|
| 37 |
+
raise ValueError(f"{name} must contain finite integers")
|
| 38 |
+
if not info.min <= integer <= info.max:
|
| 39 |
+
raise ValueError(f"{name} is outside the {np.dtype(dtype)} range")
|
| 40 |
+
values.append(integer)
|
| 41 |
+
return np.asarray(values, dtype=dtype).reshape(array.shape)
|
| 42 |
+
values = []
|
| 43 |
+
for raw in array.flat:
|
| 44 |
+
if isinstance(raw, (bool, np.bool_)) or not isinstance(raw, Real):
|
| 45 |
+
raise ValueError(f"{name} must contain numeric values")
|
| 46 |
+
values.append(float(raw))
|
| 47 |
+
return np.asarray(values, dtype=dtype).reshape(array.shape)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def rational_meter_arrays(measure_num, measure_den, n_measures=None):
|
| 51 |
+
"""Validate TJA ``#MEASURE`` fractions and return lattice properties.
|
| 52 |
+
|
| 53 |
+
Returns ``(slots, numerator, denominator, quarter_beats)``. A meter is
|
| 54 |
+
representable exactly iff ``96 * numerator / denominator`` is an integer;
|
| 55 |
+
no rounding or coercion is permitted.
|
| 56 |
+
"""
|
| 57 |
+
num = np.asarray(measure_num)
|
| 58 |
+
den = np.asarray(measure_den)
|
| 59 |
+
if n_measures is None:
|
| 60 |
+
if num.ndim == 0 and den.ndim == 0:
|
| 61 |
+
n_measures = 1
|
| 62 |
+
else:
|
| 63 |
+
n_measures = max(num.size, den.size)
|
| 64 |
+
num = _expand(num, n_measures, "measure_num", np.int64)
|
| 65 |
+
den = _expand(den, n_measures, "measure_den", np.int64)
|
| 66 |
+
if (num <= 0).any() or (den <= 0).any():
|
| 67 |
+
raise ValueError("measure numerator and denominator must be positive")
|
| 68 |
+
|
| 69 |
+
slot_values = []
|
| 70 |
+
for numerator, denominator in zip(num.tolist(), den.tolist()):
|
| 71 |
+
common = gcd(int(numerator), int(denominator))
|
| 72 |
+
reduced_num = int(numerator) // common
|
| 73 |
+
reduced_den = int(denominator) // common
|
| 74 |
+
if SLOTS % reduced_den:
|
| 75 |
+
raise ValueError(
|
| 76 |
+
"meter is not exactly representable on the 96-slot lattice")
|
| 77 |
+
slot_count = (SLOTS // reduced_den) * reduced_num
|
| 78 |
+
if not 1 <= slot_count <= WINDOW:
|
| 79 |
+
raise ValueError(
|
| 80 |
+
f"measure lattice length must be within 1..{WINDOW} slots")
|
| 81 |
+
slot_values.append(slot_count)
|
| 82 |
+
slots = np.asarray(slot_values, dtype=np.int64)
|
| 83 |
+
quarter_beats = 4.0 * num / den
|
| 84 |
+
return slots, num, den, quarter_beats.astype(np.float64)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def resolve_meter_layout(grid, n_measures):
|
| 88 |
+
"""Resolve exact per-measure layout from a generation grid.
|
| 89 |
+
|
| 90 |
+
Preferred input is ``measure_num``/``measure_den`` with optional
|
| 91 |
+
``measure_slots`` for an integrity check. A uniform ``meter`` or a
|
| 92 |
+
``beats_per_measure`` array remains a supported grid API: each value is a
|
| 93 |
+
quarter-note count and is converted to an exact reduced TJA fraction.
|
| 94 |
+
"""
|
| 95 |
+
has_num = "measure_num" in grid
|
| 96 |
+
has_den = "measure_den" in grid
|
| 97 |
+
if has_num != has_den:
|
| 98 |
+
raise ValueError("measure_num and measure_den must be provided together")
|
| 99 |
+
if has_num:
|
| 100 |
+
num = _expand(grid["measure_num"], n_measures, "measure_num", np.int64)
|
| 101 |
+
den = _expand(grid["measure_den"], n_measures, "measure_den", np.int64)
|
| 102 |
+
else:
|
| 103 |
+
raw = grid.get("beats_per_measure", grid.get("meter"))
|
| 104 |
+
if raw is None:
|
| 105 |
+
raise ValueError(
|
| 106 |
+
"slot generation requires measure_num/measure_den or an "
|
| 107 |
+
"explicit uniform meter/beats_per_measure grid"
|
| 108 |
+
)
|
| 109 |
+
quarter = _expand(raw, n_measures, "beats_per_measure", np.float64)
|
| 110 |
+
if not np.isfinite(quarter).all() or (quarter <= 0).any():
|
| 111 |
+
raise ValueError("quarter beats per measure must be positive and finite")
|
| 112 |
+
fractions = [Fraction(float(q)).limit_denominator(768) for q in quarter]
|
| 113 |
+
if any(abs(float(f) - q) > 1e-9
|
| 114 |
+
for f, q in zip(fractions, quarter)):
|
| 115 |
+
raise ValueError("quarter-beat meter values must be exact rationals")
|
| 116 |
+
num = np.asarray([f.numerator for f in fractions], dtype=np.int64)
|
| 117 |
+
den = np.asarray([4 * f.denominator for f in fractions], dtype=np.int64)
|
| 118 |
+
|
| 119 |
+
slots, num, den, quarter = rational_meter_arrays(num, den, n_measures)
|
| 120 |
+
if "measure_slots" in grid:
|
| 121 |
+
supplied = _expand(
|
| 122 |
+
grid["measure_slots"], n_measures, "measure_slots", np.int64)
|
| 123 |
+
if not np.array_equal(supplied, slots):
|
| 124 |
+
raise ValueError("measure_slots disagrees with measure_num/measure_den")
|
| 125 |
+
return slots, num, den, quarter
|
softchart/sc2/model.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encoder-decoder Transformer: log-mel windows -> chart token sequences."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
|
| 8 |
+
from .vocab import (
|
| 9 |
+
N_MELS, N_SPAN_DUR_BUCKETS, N_STAGES, N_TEMPO_K, VOCAB, WINDOW,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def sinusoidal(length, dim):
|
| 14 |
+
pos = torch.arange(length, dtype=torch.float32)[:, None]
|
| 15 |
+
i = torch.arange(dim // 2, dtype=torch.float32)[None]
|
| 16 |
+
angle = pos / torch.pow(10000.0, 2 * i / dim)
|
| 17 |
+
emb = torch.zeros(length, dim)
|
| 18 |
+
emb[:, 0::2] = torch.sin(angle)
|
| 19 |
+
emb[:, 1::2] = torch.cos(angle)
|
| 20 |
+
return emb
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class BeatHiRes(nn.Module):
|
| 24 |
+
"""Frame-resolution beat/downbeat head: encoder states (T/4) are
|
| 25 |
+
upsampled 4x so supervision and peaks live on the 11.6 ms frame grid
|
| 26 |
+
instead of 46 ms encoder bins (4x finer timing per peak)."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, d):
|
| 29 |
+
super().__init__()
|
| 30 |
+
self.up = nn.ConvTranspose1d(d, d // 2, 4, stride=4)
|
| 31 |
+
self.out = nn.Conv1d(d // 2, 2, 3, padding=1)
|
| 32 |
+
|
| 33 |
+
def forward(self, mem): # (B, L, d) -> (B, 4L, 2)
|
| 34 |
+
h = nn.functional.gelu(self.up(mem.transpose(1, 2)))
|
| 35 |
+
return self.out(h).transpose(1, 2)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class HierarchicalSectionEncoder(nn.Module):
|
| 39 |
+
"""Encode a fixed relative-time summary of the complete song.
|
| 40 |
+
|
| 41 |
+
The local encoder remains dedicated to the current 20-second window. This
|
| 42 |
+
separate branch models section-to-section structure, marks the section that
|
| 43 |
+
contains the current window, and exposes its states directly to decoder
|
| 44 |
+
cross-attention. It is trained from scratch with the chart objective; no
|
| 45 |
+
external audio representation is used.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(self, d, nhead, ffn, dropout, layers=2, sections=100,
|
| 49 |
+
rich_stats=False):
|
| 50 |
+
super().__init__()
|
| 51 |
+
self.sections = sections
|
| 52 |
+
# rich_stats (SoftChart 2.0, default off): each section summary is the
|
| 53 |
+
# concat of four frame statistics [mean, std, max, last-first] instead
|
| 54 |
+
# of the mean alone. The rich projection lives under its OWN key
|
| 55 |
+
# (inp_rich) so v1.8 checkpoints (inp only) load with strict=False.
|
| 56 |
+
self.rich_stats = bool(rich_stats)
|
| 57 |
+
if self.rich_stats:
|
| 58 |
+
self.inp = None
|
| 59 |
+
self.inp_rich = nn.Linear(4 * N_MELS, d)
|
| 60 |
+
else:
|
| 61 |
+
self.inp = nn.Linear(N_MELS, d)
|
| 62 |
+
self.inp_rich = None
|
| 63 |
+
self.pos = nn.Parameter(torch.randn(sections, d) * 0.02)
|
| 64 |
+
self.current = nn.Parameter(torch.randn(d) * 0.02)
|
| 65 |
+
layer = nn.TransformerEncoderLayer(
|
| 66 |
+
d, nhead, ffn, dropout, activation="gelu",
|
| 67 |
+
batch_first=True, norm_first=True,
|
| 68 |
+
)
|
| 69 |
+
self.encoder = nn.TransformerEncoder(
|
| 70 |
+
layer, layers, nn.LayerNorm(d), enable_nested_tensor=False)
|
| 71 |
+
|
| 72 |
+
def forward(self, summary, position):
|
| 73 |
+
feat = 4 * N_MELS if self.rich_stats else N_MELS
|
| 74 |
+
if summary.ndim != 3 or summary.shape[1:] != (self.sections, feat):
|
| 75 |
+
raise ValueError(
|
| 76 |
+
f"expected song summary (B,{self.sections},{feat}), "
|
| 77 |
+
f"got {tuple(summary.shape)}"
|
| 78 |
+
)
|
| 79 |
+
position = position.to(summary.device).long().clamp(0, self.sections - 1)
|
| 80 |
+
proj = self.inp_rich if self.rich_stats else self.inp
|
| 81 |
+
h = proj(summary) + self.pos
|
| 82 |
+
marker = torch.zeros_like(h)
|
| 83 |
+
marker[torch.arange(h.shape[0], device=h.device), position] = self.current
|
| 84 |
+
return self.encoder(h + marker)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _parse_pattern(spec, n):
|
| 88 |
+
"""Layer-sharing schedule. spec is either an int (number of UNIQUE
|
| 89 |
+
physical layers, cycled to length n) or a comma string like "E0,E1,E0,E1"
|
| 90 |
+
(labels; each distinct label is one physical layer). Returns a list of n
|
| 91 |
+
physical-layer indices, e.g. [0,1,0,1] for 2-unique/4-physical-depth."""
|
| 92 |
+
if spec is None:
|
| 93 |
+
return list(range(n)) # no sharing: every layer unique
|
| 94 |
+
if isinstance(spec, int):
|
| 95 |
+
return [k % spec for k in range(n)]
|
| 96 |
+
labels = [s.strip() for s in str(spec).split(",") if s.strip()]
|
| 97 |
+
assert len(labels) == n, f"pattern {spec} has {len(labels)} labels, need {n}"
|
| 98 |
+
order = {}
|
| 99 |
+
out = []
|
| 100 |
+
for lb in labels:
|
| 101 |
+
if lb not in order:
|
| 102 |
+
order[lb] = len(order)
|
| 103 |
+
out.append(order[lb])
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class Adapter(nn.Module):
|
| 108 |
+
"""Rank-r residual adapter applied after a (possibly reused) transformer
|
| 109 |
+
block: x + gate * up(gelu(down(x))). Both projections and the gate start on
|
| 110 |
+
an active gradient path. The up projection is initialized very small so the
|
| 111 |
+
initial adapter is a near-identity perturbation without the dead-gradient
|
| 112 |
+
state caused by zero-initializing both ``up`` and ``gate``."""
|
| 113 |
+
|
| 114 |
+
def __init__(self, d, rank):
|
| 115 |
+
super().__init__()
|
| 116 |
+
self.down = nn.Linear(d, rank, bias=False)
|
| 117 |
+
self.up = nn.Linear(rank, d, bias=False)
|
| 118 |
+
nn.init.normal_(self.down.weight, std=0.02)
|
| 119 |
+
nn.init.normal_(self.up.weight, std=1e-3)
|
| 120 |
+
self.gate = nn.Parameter(torch.ones(1))
|
| 121 |
+
|
| 122 |
+
def forward(self, x):
|
| 123 |
+
return x + self.gate * self.up(nn.functional.gelu(self.down(x)))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class SharedEncoder(nn.Module):
|
| 127 |
+
"""Encoder whose physical layers are reused according to a sharing pattern.
|
| 128 |
+
Each POSITION in the depth keeps its own (a) rank-r adapter and (b) final
|
| 129 |
+
LayerNorm, so every logical depth can specialize while reusing the block.
|
| 130 |
+
With pattern=None this is exactly n unique layers + a norm, matching the
|
| 131 |
+
stock nn.TransformerEncoder param count (adapters/pos-norms are opt-in).
|
| 132 |
+
|
| 133 |
+
v1.7 depth-diversity options (all default-off):
|
| 134 |
+
depth_emb: per-depth learned d-vector ADDED to the block input at each
|
| 135 |
+
reuse (params = depth x d, zero-init so init == hard sharing).
|
| 136 |
+
adapter_rank_ffn > 0: SPLIT adapters — instead of one post-block adapter,
|
| 137 |
+
each depth owns a rank-`adapter_rank` adapter on the attention sublayer
|
| 138 |
+
output and a rank-`adapter_rank_ffn` adapter on the FFN sublayer output
|
| 139 |
+
(patterning lives in the FFN).
|
| 140 |
+
"""
|
| 141 |
+
|
| 142 |
+
def __init__(self, d, nhead, ffn, dropout, pattern, adapter_rank=0,
|
| 143 |
+
unique_layernorm=False, adapter_rank_ffn=0, depth_emb=False):
|
| 144 |
+
super().__init__()
|
| 145 |
+
# `pattern` is the resolved list of physical-layer indices per depth
|
| 146 |
+
n_phys = max(pattern) + 1
|
| 147 |
+
self.layers = nn.ModuleList([
|
| 148 |
+
nn.TransformerEncoderLayer(d, nhead, ffn, dropout, activation="gelu",
|
| 149 |
+
batch_first=True, norm_first=True)
|
| 150 |
+
for _ in range(n_phys)])
|
| 151 |
+
self.plan = pattern
|
| 152 |
+
self.split = adapter_rank_ffn > 0
|
| 153 |
+
if self.split: # v1.7: per-sublayer adapters (attention rank-a, FFN rank-f)
|
| 154 |
+
self.attn_adapters = nn.ModuleList([
|
| 155 |
+
Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
|
| 156 |
+
for _ in pattern])
|
| 157 |
+
self.ffn_adapters = nn.ModuleList([
|
| 158 |
+
Adapter(d, adapter_rank_ffn) for _ in pattern])
|
| 159 |
+
else: # v1.6: one residual adapter after the whole block
|
| 160 |
+
self.adapters = nn.ModuleList([
|
| 161 |
+
Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
|
| 162 |
+
for _ in pattern])
|
| 163 |
+
self.depth_emb = (nn.Parameter(torch.zeros(len(pattern), d))
|
| 164 |
+
if depth_emb else None)
|
| 165 |
+
self.pos_norms = nn.ModuleList([
|
| 166 |
+
nn.LayerNorm(d) if unique_layernorm else nn.Identity()
|
| 167 |
+
for _ in pattern])
|
| 168 |
+
self.norm = nn.LayerNorm(d)
|
| 169 |
+
|
| 170 |
+
def forward(self, x, src_key_padding_mask=None):
|
| 171 |
+
for k, phys in enumerate(self.plan):
|
| 172 |
+
if self.depth_emb is not None:
|
| 173 |
+
x = x + self.depth_emb[k]
|
| 174 |
+
layer = self.layers[phys]
|
| 175 |
+
if self.split:
|
| 176 |
+
# norm_first decomposition of nn.TransformerEncoderLayer with
|
| 177 |
+
# per-depth adapters applied to each sublayer OUTPUT (Houlsby
|
| 178 |
+
# placement)
|
| 179 |
+
x = x + self.attn_adapters[k](
|
| 180 |
+
layer._sa_block(layer.norm1(x), None, src_key_padding_mask))
|
| 181 |
+
x = x + self.ffn_adapters[k](layer._ff_block(layer.norm2(x)))
|
| 182 |
+
else:
|
| 183 |
+
x = layer(x, src_key_padding_mask=src_key_padding_mask)
|
| 184 |
+
x = self.adapters[k](x)
|
| 185 |
+
x = self.pos_norms[k](x)
|
| 186 |
+
return self.norm(x)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class SharedDecoder(nn.Module):
|
| 190 |
+
"""Decoder counterpart of SharedEncoder (see that docstring). Split mode
|
| 191 |
+
puts rank-`adapter_rank` adapters on BOTH attention sublayers (self and
|
| 192 |
+
cross) and rank-`adapter_rank_ffn` on the FFN sublayer."""
|
| 193 |
+
|
| 194 |
+
def __init__(self, d, nhead, ffn, dropout, pattern, adapter_rank=0,
|
| 195 |
+
unique_layernorm=False, adapter_rank_ffn=0, depth_emb=False):
|
| 196 |
+
super().__init__()
|
| 197 |
+
n_phys = max(pattern) + 1
|
| 198 |
+
self.layers = nn.ModuleList([
|
| 199 |
+
nn.TransformerDecoderLayer(d, nhead, ffn, dropout, activation="gelu",
|
| 200 |
+
batch_first=True, norm_first=True)
|
| 201 |
+
for _ in range(n_phys)])
|
| 202 |
+
self.plan = pattern
|
| 203 |
+
self.split = adapter_rank_ffn > 0
|
| 204 |
+
if self.split:
|
| 205 |
+
self.sa_adapters = nn.ModuleList([
|
| 206 |
+
Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
|
| 207 |
+
for _ in pattern])
|
| 208 |
+
self.ca_adapters = nn.ModuleList([
|
| 209 |
+
Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
|
| 210 |
+
for _ in pattern])
|
| 211 |
+
self.ffn_adapters = nn.ModuleList([
|
| 212 |
+
Adapter(d, adapter_rank_ffn) for _ in pattern])
|
| 213 |
+
else:
|
| 214 |
+
self.adapters = nn.ModuleList([
|
| 215 |
+
Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
|
| 216 |
+
for _ in pattern])
|
| 217 |
+
self.depth_emb = (nn.Parameter(torch.zeros(len(pattern), d))
|
| 218 |
+
if depth_emb else None)
|
| 219 |
+
self.pos_norms = nn.ModuleList([
|
| 220 |
+
nn.LayerNorm(d) if unique_layernorm else nn.Identity()
|
| 221 |
+
for _ in pattern])
|
| 222 |
+
self.norm = nn.LayerNorm(d)
|
| 223 |
+
|
| 224 |
+
def forward(self, tgt, memory, tgt_mask=None, tgt_key_padding_mask=None,
|
| 225 |
+
tgt_is_causal=False):
|
| 226 |
+
x = tgt
|
| 227 |
+
for k, phys in enumerate(self.plan):
|
| 228 |
+
if self.depth_emb is not None:
|
| 229 |
+
x = x + self.depth_emb[k]
|
| 230 |
+
layer = self.layers[phys]
|
| 231 |
+
if self.split:
|
| 232 |
+
x = x + self.sa_adapters[k](layer._sa_block(
|
| 233 |
+
layer.norm1(x), tgt_mask, tgt_key_padding_mask, tgt_is_causal))
|
| 234 |
+
x = x + self.ca_adapters[k](layer._mha_block(
|
| 235 |
+
layer.norm2(x), memory, None, None))
|
| 236 |
+
x = x + self.ffn_adapters[k](layer._ff_block(layer.norm3(x)))
|
| 237 |
+
else:
|
| 238 |
+
x = layer(x, memory, tgt_mask=tgt_mask,
|
| 239 |
+
tgt_key_padding_mask=tgt_key_padding_mask,
|
| 240 |
+
tgt_is_causal=tgt_is_causal)
|
| 241 |
+
x = self.adapters[k](x)
|
| 242 |
+
x = self.pos_norms[k](x)
|
| 243 |
+
return self.norm(x)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class ChartModel(nn.Module):
|
| 247 |
+
def __init__(
|
| 248 |
+
self,
|
| 249 |
+
d_model=512,
|
| 250 |
+
nhead=8,
|
| 251 |
+
enc_layers=6,
|
| 252 |
+
dec_layers=6,
|
| 253 |
+
ffn=2048,
|
| 254 |
+
dropout=0.1,
|
| 255 |
+
vocab_size=None,
|
| 256 |
+
max_tgt=None,
|
| 257 |
+
aux=False,
|
| 258 |
+
global_ctx=False,
|
| 259 |
+
hierarchical_ctx=False,
|
| 260 |
+
section_layers=2,
|
| 261 |
+
section_ffn=None,
|
| 262 |
+
func_time=False,
|
| 263 |
+
ptr=False,
|
| 264 |
+
beat=False,
|
| 265 |
+
in_ch=None,
|
| 266 |
+
emb_factor=None,
|
| 267 |
+
clean_phase=False,
|
| 268 |
+
enc_share=None,
|
| 269 |
+
dec_share=None,
|
| 270 |
+
adapter_rank=0,
|
| 271 |
+
unique_layernorm=False,
|
| 272 |
+
adapter_rank_ffn=0,
|
| 273 |
+
depth_emb=False,
|
| 274 |
+
unshare_last_dec=False,
|
| 275 |
+
stage_emb=False,
|
| 276 |
+
span_duration_head=False,
|
| 277 |
+
tempo_head=False,
|
| 278 |
+
rich_section_stats=False,
|
| 279 |
+
):
|
| 280 |
+
# SoftChart 2.0 flags (all default-off; defaults are bit-identical to
|
| 281 |
+
# v1.8 in parameters and forward outputs):
|
| 282 |
+
# stage_emb: 4 learned d_model vectors (header/plan/skeleton/
|
| 283 |
+
# realization), zero-init, added to decoder inputs per the
|
| 284 |
+
# caller-supplied `stage_ids` (skipped entirely when None).
|
| 285 |
+
# span_duration_head: Linear(d, N_SPAN_DUR_BUCKETS) over decoder
|
| 286 |
+
# states; supervised at SPAN_START realization steps
|
| 287 |
+
# (SOFTCHART2_DESIGN §1.3).
|
| 288 |
+
# tempo_head: slot+ TEMPO factorization heads — log-p regression
|
| 289 |
+
# Linear(d, 1) and octave-k classification Linear(d, N_TEMPO_K)
|
| 290 |
+
# over decoder states; supervised at TEMPO steps
|
| 291 |
+
# (SLOTPLUS_DESIGN §2.2/§3.2/§3.3).
|
| 292 |
+
# rich_section_stats: HierarchicalSectionEncoder consumes
|
| 293 |
+
# [mean, std, max, last-first] concat (4*N_MELS) per section.
|
| 294 |
+
super().__init__()
|
| 295 |
+
from .vocab import MAX_TGT
|
| 296 |
+
|
| 297 |
+
vocab_size = vocab_size or VOCAB.size
|
| 298 |
+
max_tgt = max_tgt or MAX_TGT
|
| 299 |
+
self.d_model = d_model
|
| 300 |
+
if global_ctx and hierarchical_ctx:
|
| 301 |
+
raise ValueError("global_ctx and hierarchical_ctx are mutually exclusive")
|
| 302 |
+
self.hierarchical_ctx = bool(hierarchical_ctx)
|
| 303 |
+
self._hierarchical_ctx = self.hierarchical_ctx
|
| 304 |
+
# clean_phase (v1.6): the encoder never sees the 2 grid-phase channels
|
| 305 |
+
# (in_ch stays N_MELS), and phase is injected into the DECODER memory
|
| 306 |
+
# only, via a small projection. This removes the condition leak that
|
| 307 |
+
# forced beat supervision to be masked on slot windows in v1.5.
|
| 308 |
+
self.clean_phase = clean_phase
|
| 309 |
+
self.in_ch = in_ch or N_MELS # legacy slot mode appends 2 grid-phase channels
|
| 310 |
+
front_ch = N_MELS if clean_phase else self.in_ch
|
| 311 |
+
|
| 312 |
+
# conv frontend: (B, front_ch, T) -> (B, T/4, d)
|
| 313 |
+
self.frontend = nn.Sequential(
|
| 314 |
+
nn.Conv1d(front_ch, d_model, 3, stride=2, padding=1),
|
| 315 |
+
nn.GELU(),
|
| 316 |
+
nn.Conv1d(d_model, d_model, 3, stride=2, padding=1),
|
| 317 |
+
nn.GELU(),
|
| 318 |
+
)
|
| 319 |
+
enc_len = WINDOW // 4
|
| 320 |
+
self.register_buffer("enc_pos", sinusoidal(enc_len, d_model), persistent=False)
|
| 321 |
+
self.register_buffer("dec_pos", sinusoidal(max_tgt, d_model), persistent=False)
|
| 322 |
+
|
| 323 |
+
# phase injection (clean_phase only): 2 grid-phase channels at frame
|
| 324 |
+
# rate -> encoder rate (T/4) -> additive conditioning on the memory the
|
| 325 |
+
# DECODER reads. The beat head reads the pre-injection (clean) memory.
|
| 326 |
+
# phase_proj input = [measure_phase, beat_phase, valid_flag]. valid=0 on
|
| 327 |
+
# time-mode windows (their raw phase is -1), giving the decoder a clean
|
| 328 |
+
# zero-phase conditioning there; slot windows pass valid=1 + real phase.
|
| 329 |
+
if clean_phase:
|
| 330 |
+
self.phase_proj = nn.Sequential(
|
| 331 |
+
nn.Conv1d(3, d_model, 3, stride=2, padding=1),
|
| 332 |
+
nn.GELU(),
|
| 333 |
+
nn.Conv1d(d_model, d_model, 3, stride=2, padding=1),
|
| 334 |
+
)
|
| 335 |
+
else:
|
| 336 |
+
self.phase_proj = None
|
| 337 |
+
|
| 338 |
+
# sharing schedule (v1.6-small/tiny): reuse physical layers per pattern.
|
| 339 |
+
# enc_share/dec_share: int (#unique layers) or "E0,E1,E0,E1" label string.
|
| 340 |
+
self.enc_share = enc_share
|
| 341 |
+
self.dec_share = dec_share
|
| 342 |
+
if (enc_share is None and dec_share is None and not adapter_rank
|
| 343 |
+
and not adapter_rank_ffn and not depth_emb and not unshare_last_dec):
|
| 344 |
+
enc_layer = nn.TransformerEncoderLayer(
|
| 345 |
+
d_model, nhead, ffn, dropout, activation="gelu",
|
| 346 |
+
batch_first=True, norm_first=True)
|
| 347 |
+
self.encoder = nn.TransformerEncoder(
|
| 348 |
+
enc_layer, enc_layers, nn.LayerNorm(d_model),
|
| 349 |
+
enable_nested_tensor=False)
|
| 350 |
+
dec_layer = nn.TransformerDecoderLayer(
|
| 351 |
+
d_model, nhead, ffn, dropout, activation="gelu",
|
| 352 |
+
batch_first=True, norm_first=True)
|
| 353 |
+
self.decoder = nn.TransformerDecoder(dec_layer, dec_layers,
|
| 354 |
+
nn.LayerNorm(d_model))
|
| 355 |
+
else:
|
| 356 |
+
enc_plan = _parse_pattern(enc_share, enc_layers)
|
| 357 |
+
dec_plan = _parse_pattern(dec_share, dec_layers)
|
| 358 |
+
# v1.7: un-share the LAST decoder layer (pattern-shaping layer in
|
| 359 |
+
# front of the generation head gets its own physical parameters);
|
| 360 |
+
# no-op if that depth is already unique.
|
| 361 |
+
if unshare_last_dec and dec_plan.count(dec_plan[-1]) > 1:
|
| 362 |
+
dec_plan = dec_plan[:-1] + [max(dec_plan) + 1]
|
| 363 |
+
self.encoder = SharedEncoder(d_model, nhead, ffn, dropout, enc_plan,
|
| 364 |
+
adapter_rank, unique_layernorm,
|
| 365 |
+
adapter_rank_ffn, depth_emb)
|
| 366 |
+
self.decoder = SharedDecoder(d_model, nhead, ffn, dropout, dec_plan,
|
| 367 |
+
adapter_rank, unique_layernorm,
|
| 368 |
+
adapter_rank_ffn, depth_emb)
|
| 369 |
+
|
| 370 |
+
# ALBERT-style factorized embeddings (v1.5): vocab->E->d. Position
|
| 371 |
+
# tokens dominate the table (1728/1810 rows), so E=64 cuts the
|
| 372 |
+
# embedding block ~3.5x; logits stay tied via the projected matrix.
|
| 373 |
+
self.emb_factor = emb_factor
|
| 374 |
+
if emb_factor:
|
| 375 |
+
self.tok_emb = nn.Embedding(vocab_size, emb_factor, padding_idx=VOCAB.pad)
|
| 376 |
+
nn.init.normal_(self.tok_emb.weight, std=0.02)
|
| 377 |
+
self.emb_proj = nn.Linear(emb_factor, d_model, bias=False)
|
| 378 |
+
nn.init.normal_(self.emb_proj.weight, std=1.0 / (emb_factor ** 0.5) * 0.16)
|
| 379 |
+
self.out = None
|
| 380 |
+
else:
|
| 381 |
+
self.tok_emb = nn.Embedding(vocab_size, d_model, padding_idx=VOCAB.pad)
|
| 382 |
+
nn.init.normal_(self.tok_emb.weight, std=0.02) # keep tied logits well-scaled
|
| 383 |
+
self.emb_proj = None
|
| 384 |
+
self.out = nn.Linear(d_model, vocab_size, bias=False)
|
| 385 |
+
self.out.weight = self.tok_emb.weight # weight tying
|
| 386 |
+
self.dropout = nn.Dropout(dropout)
|
| 387 |
+
# Auxiliary local rhythmic skeleton. In the hierarchical model its
|
| 388 |
+
# predicted occupancy is also projected back into decoder memory, so
|
| 389 |
+
# the event realizer is explicitly conditioned on a coarse audio-led
|
| 390 |
+
# skeleton rather than learning placement and pattern in one flat pass.
|
| 391 |
+
if hierarchical_ctx and not aux:
|
| 392 |
+
raise ValueError("hierarchical_ctx requires the auxiliary skeleton head")
|
| 393 |
+
self.aux = nn.Linear(d_model, 1) if aux else None
|
| 394 |
+
self.skeleton_proj = nn.Linear(1, d_model) if hierarchical_ctx else None
|
| 395 |
+
if self.skeleton_proj is not None:
|
| 396 |
+
nn.init.zeros_(self.skeleton_proj.weight)
|
| 397 |
+
nn.init.zeros_(self.skeleton_proj.bias)
|
| 398 |
+
# pointer alignment head: each generated note must point to its audio
|
| 399 |
+
# frame (differentiable provenance; explainability that trains alignment)
|
| 400 |
+
self.ptr = nn.Linear(d_model, d_model) if ptr else None
|
| 401 |
+
# beat/downbeat head: explicit metrical percept on the encoder.
|
| 402 |
+
# beat="hires" builds the frame-resolution head (11.6 ms bins)
|
| 403 |
+
self.beat = (BeatHiRes(d_model) if beat == "hires"
|
| 404 |
+
else (nn.Linear(d_model, 2) if beat else None))
|
| 405 |
+
# song-level context: coarse whole-song summary + window position (v4).
|
| 406 |
+
# Lets the model see beyond the window, so intentional gaps (a breath
|
| 407 |
+
# before a strong section) are informed decisions, not failures.
|
| 408 |
+
# v2 fix (after the v4 negative result): summary chunks get DEDICATED
|
| 409 |
+
# positional embeddings instead of reusing window positions.
|
| 410 |
+
if global_ctx:
|
| 411 |
+
self.gsum_proj = nn.Linear(N_MELS, d_model)
|
| 412 |
+
self.seg_emb = nn.Parameter(torch.zeros(2, d_model))
|
| 413 |
+
self.pos_emb = nn.Embedding(100, d_model) # relative song position
|
| 414 |
+
self.gpos = nn.Parameter(torch.randn(128, d_model) * 0.02)
|
| 415 |
+
else:
|
| 416 |
+
self.gsum_proj = None
|
| 417 |
+
if rich_section_stats and not hierarchical_ctx:
|
| 418 |
+
raise ValueError("rich_section_stats requires hierarchical_ctx")
|
| 419 |
+
if hierarchical_ctx:
|
| 420 |
+
self.section_encoder = HierarchicalSectionEncoder(
|
| 421 |
+
d_model, nhead, section_ffn or (2 * d_model), dropout,
|
| 422 |
+
layers=section_layers, rich_stats=rich_section_stats,
|
| 423 |
+
)
|
| 424 |
+
self.hier_seg_emb = nn.Parameter(torch.zeros(2, d_model))
|
| 425 |
+
else:
|
| 426 |
+
self.section_encoder = None
|
| 427 |
+
# functional time embeddings: the 1728 TIME tokens share a sinusoidal
|
| 428 |
+
# basis + small projection instead of free embeddings (fewer params,
|
| 429 |
+
# neighbouring times get similar representations)
|
| 430 |
+
if func_time:
|
| 431 |
+
self.register_buffer("time_basis", sinusoidal(WINDOW, 64), persistent=False)
|
| 432 |
+
self.time_proj = nn.Linear(64, d_model)
|
| 433 |
+
# match the 0.02-std scale of tok_emb rows (basis row norm ~ sqrt(32))
|
| 434 |
+
nn.init.normal_(self.time_proj.weight, std=0.004)
|
| 435 |
+
nn.init.zeros_(self.time_proj.bias)
|
| 436 |
+
else:
|
| 437 |
+
self.time_proj = None
|
| 438 |
+
# --- SoftChart 2.0 additions (all None when their flag is off, so
|
| 439 |
+
# v1.8 state dicts and RNG init order are untouched by default) ---
|
| 440 |
+
# BarScript stage embedding: zero-init so a flags-on model with any
|
| 441 |
+
# stage_ids starts bit-identical to the flags-off decoder input.
|
| 442 |
+
self.stage_emb = (nn.Parameter(torch.zeros(N_STAGES, d_model))
|
| 443 |
+
if stage_emb else None)
|
| 444 |
+
# span duration head: log-grid bucket logits at SPAN_START steps
|
| 445 |
+
self.span_dur = (nn.Linear(d_model, N_SPAN_DUR_BUCKETS)
|
| 446 |
+
if span_duration_head else None)
|
| 447 |
+
# slot+ TEMPO heads: canonical-period log-p regression + octave-k class
|
| 448 |
+
if tempo_head:
|
| 449 |
+
self.tempo_logp = nn.Linear(d_model, 1)
|
| 450 |
+
self.tempo_k = nn.Linear(d_model, N_TEMPO_K)
|
| 451 |
+
else:
|
| 452 |
+
self.tempo_logp = None
|
| 453 |
+
self.tempo_k = None
|
| 454 |
+
|
| 455 |
+
def _local_input(self, mel):
|
| 456 |
+
audio = mel[:, :N_MELS] if self.clean_phase else mel
|
| 457 |
+
h = self.frontend(audio).transpose(1, 2) # (B, T/4, d)
|
| 458 |
+
return h + self.enc_pos[: h.shape[1]]
|
| 459 |
+
|
| 460 |
+
def encode_local(self, mel):
|
| 461 |
+
"""Encode only the current window for beat/downbeat estimation."""
|
| 462 |
+
h = self._local_input(mel)
|
| 463 |
+
if self.section_encoder is not None:
|
| 464 |
+
h = h + self.hier_seg_emb[0]
|
| 465 |
+
return self.encoder(self.dropout(h))
|
| 466 |
+
|
| 467 |
+
def encode(self, mel, gsum=None, pos_bucket=None):
|
| 468 |
+
# mel: (B, C, T). clean_phase: C may be N_MELS (audio only) or N_MELS+2
|
| 469 |
+
# (audio + 2 grid-phase channels); only the audio channels reach the
|
| 470 |
+
# encoder, so the returned memory is CLEAN (phase-free) and safe for the
|
| 471 |
+
# beat head. gsum: (B, 100, n_mels) complete-song section summaries
|
| 472 |
+
# ((B, 100, 4*n_mels) with rich_section_stats: [mean,std,max,last-first]);
|
| 473 |
+
# pos_bucket: (B,) current relative song section in [0, 100).
|
| 474 |
+
h = self._local_input(mel)
|
| 475 |
+
if self.section_encoder is not None:
|
| 476 |
+
if gsum is None or pos_bucket is None:
|
| 477 |
+
raise ValueError("hierarchical_ctx requires whole-song summary and position")
|
| 478 |
+
local = self.encoder(self.dropout(h + self.hier_seg_emb[0]))
|
| 479 |
+
sections = self.section_encoder(gsum, pos_bucket) + self.hier_seg_emb[1]
|
| 480 |
+
return torch.cat([sections, local], dim=1)
|
| 481 |
+
if self.gsum_proj is not None and gsum is not None:
|
| 482 |
+
g = self.gsum_proj(gsum) + self.seg_emb[1] + self.gpos[: gsum.shape[1]]
|
| 483 |
+
h = h + self.seg_emb[0]
|
| 484 |
+
p = self.pos_emb(pos_bucket).unsqueeze(1) # (B, 1, d)
|
| 485 |
+
h = torch.cat([p, g, h], dim=1)
|
| 486 |
+
return self.encoder(self.dropout(h))
|
| 487 |
+
|
| 488 |
+
def phase_mem(self, memory, mel):
|
| 489 |
+
"""Phase-conditioned copy of `memory` for the DECODER to cross-attend.
|
| 490 |
+
Adds a projection of the grid-phase channels to the audio-frame slice
|
| 491 |
+
of memory; the beat head keeps reading the clean `memory`. No-op unless
|
| 492 |
+
clean_phase is on and phase channels are present. Time-mode windows
|
| 493 |
+
(raw phase = -1) get valid=0 and exactly zero phase conditioning."""
|
| 494 |
+
out = memory.clone()
|
| 495 |
+
L = WINDOW // 4
|
| 496 |
+
nch = N_MELS
|
| 497 |
+
if self.clean_phase and mel.shape[1] >= nch + 2:
|
| 498 |
+
ph = mel[:, nch:nch + 2] # [0,1); -1 marks no-grid frames
|
| 499 |
+
valid = (ph[:, :1] >= 0).float()
|
| 500 |
+
ph_in = torch.cat([ph.clamp(min=0.0), valid], dim=1)
|
| 501 |
+
p = self.phase_proj(ph_in)
|
| 502 |
+
# Mask *after* both biased convolutions. Masking only their input
|
| 503 |
+
# leaves a learned bias residual in gridless time-mode windows.
|
| 504 |
+
valid_out = nn.functional.adaptive_max_pool1d(valid, p.shape[-1])
|
| 505 |
+
p = (p * valid_out).transpose(1, 2)
|
| 506 |
+
L = p.shape[1]
|
| 507 |
+
out[:, -L:] = out[:, -L:] + p
|
| 508 |
+
if self.skeleton_proj is not None:
|
| 509 |
+
clean_local = memory[:, -L:]
|
| 510 |
+
skeleton = torch.sigmoid(self.aux(clean_local))
|
| 511 |
+
out[:, -L:] = out[:, -L:] + self.skeleton_proj(skeleton)
|
| 512 |
+
return out
|
| 513 |
+
|
| 514 |
+
def emb_matrix(self):
|
| 515 |
+
"""Full embedding matrix; factorized and/or functional rows resolved."""
|
| 516 |
+
E = self.tok_emb.weight
|
| 517 |
+
if self.emb_proj is not None:
|
| 518 |
+
E = self.emb_proj(E)
|
| 519 |
+
if self.time_proj is not None:
|
| 520 |
+
t = self.time_proj(self.time_basis) # (WINDOW, d)
|
| 521 |
+
E = torch.cat([E[: VOCAB.time0], t, E[VOCAB.time0 + WINDOW :]], dim=0)
|
| 522 |
+
return E
|
| 523 |
+
|
| 524 |
+
def _add_stage(self, h, stage_ids):
|
| 525 |
+
"""Add BarScript stage embeddings to embedded decoder inputs.
|
| 526 |
+
|
| 527 |
+
stage_ids: (B, L) ints in [0, N_STAGES) aligned position-for-position
|
| 528 |
+
with the embedded tokens; None skips the addition entirely (decoder
|
| 529 |
+
input is bit-identical to v1.8)."""
|
| 530 |
+
if stage_ids is None:
|
| 531 |
+
return h
|
| 532 |
+
if self.stage_emb is None:
|
| 533 |
+
raise ValueError("stage_ids supplied but stage_emb flag is off")
|
| 534 |
+
if stage_ids.shape != h.shape[:2]:
|
| 535 |
+
raise ValueError(
|
| 536 |
+
f"stage_ids shape {tuple(stage_ids.shape)} does not match "
|
| 537 |
+
f"decoder input {tuple(h.shape[:2])}"
|
| 538 |
+
)
|
| 539 |
+
return h + self.stage_emb[stage_ids.long()]
|
| 540 |
+
|
| 541 |
+
def decode(self, tgt_in, memory, stage_ids=None, return_hidden=False):
|
| 542 |
+
# tgt_in: (B, L) token ids; stage_ids (optional): (B, L) BarScript
|
| 543 |
+
# stage ids aligned with tgt_in. return_hidden=True also returns the
|
| 544 |
+
# decoder states so callers can apply span_dur/tempo heads during AR
|
| 545 |
+
# generation.
|
| 546 |
+
L = tgt_in.shape[1]
|
| 547 |
+
E = self.emb_matrix()
|
| 548 |
+
h = nn.functional.embedding(tgt_in, E) * math.sqrt(self.d_model) + self.dec_pos[:L]
|
| 549 |
+
h = self._add_stage(h, stage_ids)
|
| 550 |
+
mask = torch.ones((L, L), dtype=torch.bool, device=tgt_in.device).triu(1)
|
| 551 |
+
pad_mask = tgt_in == VOCAB.pad
|
| 552 |
+
h = self.decoder(
|
| 553 |
+
self.dropout(h),
|
| 554 |
+
memory,
|
| 555 |
+
tgt_mask=mask,
|
| 556 |
+
tgt_key_padding_mask=pad_mask,
|
| 557 |
+
tgt_is_causal=True,
|
| 558 |
+
)
|
| 559 |
+
logits = h @ E.T # tied output projection (functional rows included)
|
| 560 |
+
return (logits, h) if return_hidden else logits
|
| 561 |
+
|
| 562 |
+
def enable_ptr(self):
|
| 563 |
+
self.ptr = nn.Linear(self.d_model, self.d_model)
|
| 564 |
+
|
| 565 |
+
def enable_beat(self, hires=False):
|
| 566 |
+
self.beat = BeatHiRes(self.d_model) if hires else nn.Linear(self.d_model, 2)
|
| 567 |
+
|
| 568 |
+
def forward(self, mel, tgt, return_aux=False, gsum=None, pos_bucket=None,
|
| 569 |
+
return_extras=False, in_tgt=None, stage_ids=None,
|
| 570 |
+
return_v2_heads=False):
|
| 571 |
+
# in_tgt: optional corrupted decoder input (e.g. MASKed types for the
|
| 572 |
+
# skeleton->color infill curriculum); gold targets stay = tgt
|
| 573 |
+
# stage_ids: optional (B, L) BarScript stage ids aligned with tgt
|
| 574 |
+
# (sliced [:, :-1] alongside the decoder input); None skips.
|
| 575 |
+
# return_v2_heads: returns the fixed 7-tuple (logits, aux, ptr, beat,
|
| 576 |
+
# span_dur_logits, tempo_logp, tempo_k_logits); entries whose head
|
| 577 |
+
# is disabled are None. Default False keeps v1.8 return shapes.
|
| 578 |
+
return_extras = return_extras or return_v2_heads
|
| 579 |
+
memory = self.encode(mel, gsum=gsum, pos_bucket=pos_bucket) # CLEAN
|
| 580 |
+
dec_mem = self.phase_mem(memory, mel) # phase-conditioned for the decoder
|
| 581 |
+
dec_in = (in_tgt if in_tgt is not None else tgt)[:, :-1]
|
| 582 |
+
L = dec_in.shape[1]
|
| 583 |
+
E = self.emb_matrix()
|
| 584 |
+
import math as _m
|
| 585 |
+
h = nn.functional.embedding(dec_in, E) * _m.sqrt(self.d_model) + self.dec_pos[:L]
|
| 586 |
+
h = self._add_stage(h, None if stage_ids is None else stage_ids[:, :-1])
|
| 587 |
+
dm = torch.ones((L, L), dtype=torch.bool, device=tgt.device).triu(1)
|
| 588 |
+
h = self.decoder(self.dropout(h), dec_mem, tgt_mask=dm,
|
| 589 |
+
tgt_key_padding_mask=dec_in == VOCAB.pad, tgt_is_causal=True)
|
| 590 |
+
logits = h @ E.T
|
| 591 |
+
if not (return_aux or return_extras):
|
| 592 |
+
return logits
|
| 593 |
+
outs = [logits]
|
| 594 |
+
# aux/ptr/beat heads read the CLEAN memory (no phase leak)
|
| 595 |
+
aux_mem = memory[:, -(WINDOW // 4):]
|
| 596 |
+
outs.append(self.aux(aux_mem).squeeze(-1) if self.aux is not None else None)
|
| 597 |
+
if return_extras:
|
| 598 |
+
ptr_logits = None
|
| 599 |
+
if self.ptr is not None:
|
| 600 |
+
q = self.ptr(h) # (B, L, d)
|
| 601 |
+
ptr_logits = q @ aux_mem.transpose(1, 2) / _m.sqrt(self.d_model)
|
| 602 |
+
beat_logits = self.beat(aux_mem) if self.beat is not None else None
|
| 603 |
+
outs += [ptr_logits, beat_logits]
|
| 604 |
+
if return_v2_heads:
|
| 605 |
+
# per-step logits over ALL decoder steps; training masks them to
|
| 606 |
+
# SPAN_START steps (span_dur) / TEMPO steps (log-p, k)
|
| 607 |
+
outs.append(self.span_dur(h) if self.span_dur is not None else None)
|
| 608 |
+
outs.append(self.tempo_logp(h).squeeze(-1)
|
| 609 |
+
if self.tempo_logp is not None else None)
|
| 610 |
+
outs.append(self.tempo_k(h) if self.tempo_k is not None else None)
|
| 611 |
+
return tuple(outs)
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def count_params(m):
|
| 615 |
+
return sum(p.numel() for p in m.parameters() if p.requires_grad)
|
softchart/sc2/tja.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Meter-aware TJA emission for slot-mode charts.
|
| 2 |
+
|
| 3 |
+
No quantization happens here — generate_song_slot() already produced exact
|
| 4 |
+
lattice positions (96 slots per 4/4 measure), so this writer just prints them.
|
| 5 |
+
Preview rendering and the .tja are identical by construction. Authored measure
|
| 6 |
+
edges determine exact per-measure tempo; meter changes emit #MEASURE directives.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .meter import resolve_meter_layout
|
| 12 |
+
from .vocab import SLOTS
|
| 13 |
+
|
| 14 |
+
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 15 |
+
"roll": "5", "roll_big": "6", "balloon": "7"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _format_number(value):
|
| 19 |
+
"""Emit enough digits for a float to round-trip through the TJA text."""
|
| 20 |
+
value = float(value)
|
| 21 |
+
if not np.isfinite(value):
|
| 22 |
+
raise ValueError("TJA numeric fields must be finite")
|
| 23 |
+
if value == 0.0:
|
| 24 |
+
value = 0.0 # avoid a cosmetic negative zero in OFFSET
|
| 25 |
+
return format(value, ".17g")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def write_tja_slots(gen, grid, title, course, level, wave, out_path=None,
|
| 29 |
+
balloon_count=10):
|
| 30 |
+
"""Serialize a slot generation on an explicit trusted meter timeline.
|
| 31 |
+
|
| 32 |
+
``grid`` must contain exact rational meter metadata and ``measure_edges``
|
| 33 |
+
with one trusted terminal edge. Returns the TJA text and optionally writes
|
| 34 |
+
it to ``out_path``.
|
| 35 |
+
"""
|
| 36 |
+
if int(gen.get("slots_per_4_4", SLOTS)) != SLOTS:
|
| 37 |
+
raise ValueError(f"expected {SLOTS} slots per 4/4 measure")
|
| 38 |
+
n_meas = max(int(gen.get("n_measures", 0)),
|
| 39 |
+
(max((m for m, _, _ in gen.get("hits_slots", [])), default=-1) + 1),
|
| 40 |
+
(max((m1 for _, _, m1, _, _ in gen.get("spans_slots", [])),
|
| 41 |
+
default=-1) + 1), 1)
|
| 42 |
+
fields = ("measure_slots", "measure_num", "measure_den")
|
| 43 |
+
gen_has_layout = any(name in gen for name in fields)
|
| 44 |
+
grid_has_layout = any(name in grid for name in fields) \
|
| 45 |
+
or "meter" in grid or "beats_per_measure" in grid
|
| 46 |
+
gen_layout = resolve_meter_layout(gen, n_meas) if gen_has_layout else None
|
| 47 |
+
grid_layout = resolve_meter_layout(grid, n_meas) if grid_has_layout else None
|
| 48 |
+
if gen_layout is not None and grid_layout is not None:
|
| 49 |
+
gen_slots, gen_num, gen_den, _ = gen_layout
|
| 50 |
+
grid_slots, grid_num, grid_den, _ = grid_layout
|
| 51 |
+
same_fraction = all(
|
| 52 |
+
int(gn) * int(gd) == int(gdn) * int(gnd)
|
| 53 |
+
for gn, gnd, gdn, gd in zip(
|
| 54 |
+
gen_num, gen_den, grid_num, grid_den)
|
| 55 |
+
)
|
| 56 |
+
if not np.array_equal(gen_slots, grid_slots) or not same_fraction:
|
| 57 |
+
raise ValueError("generated meter layout disagrees with the timing grid")
|
| 58 |
+
layout = grid_layout if grid_layout is not None else gen_layout
|
| 59 |
+
if layout is None:
|
| 60 |
+
raise ValueError(
|
| 61 |
+
"TJA slot export requires exact meter metadata; refusing to assume 4/4")
|
| 62 |
+
slots_per_measure, measure_num, measure_den, quarter_beats = layout
|
| 63 |
+
offsets = np.concatenate([[0], np.cumsum(slots_per_measure)])
|
| 64 |
+
|
| 65 |
+
def flat(measure, slot):
|
| 66 |
+
measure, slot = int(measure), int(slot)
|
| 67 |
+
if not 0 <= measure < n_meas:
|
| 68 |
+
raise ValueError(f"measure index {measure} is outside 0..{n_meas - 1}")
|
| 69 |
+
if not 0 <= slot < slots_per_measure[measure]:
|
| 70 |
+
raise ValueError(
|
| 71 |
+
f"slot {slot} is outside measure {measure}'s "
|
| 72 |
+
f"0..{slots_per_measure[measure] - 1} range"
|
| 73 |
+
)
|
| 74 |
+
return int(offsets[measure] + slot)
|
| 75 |
+
|
| 76 |
+
slots = {}
|
| 77 |
+
for me, sl, cls in gen["hits_slots"]:
|
| 78 |
+
position = flat(me, sl)
|
| 79 |
+
if position in slots:
|
| 80 |
+
raise ValueError(
|
| 81 |
+
f"slot collision at measure {int(me)}, slot {int(sl)}")
|
| 82 |
+
slots[position] = CHAR[cls]
|
| 83 |
+
for m0, s0, m1, s1, typ in gen.get("spans_slots", []):
|
| 84 |
+
a = flat(m0, s0)
|
| 85 |
+
b = flat(m1, s1)
|
| 86 |
+
if b <= a:
|
| 87 |
+
raise ValueError("span end must be strictly after its start")
|
| 88 |
+
if a in slots:
|
| 89 |
+
raise ValueError(
|
| 90 |
+
f"slot collision at span start measure {int(m0)}, slot {int(s0)}")
|
| 91 |
+
if b in slots:
|
| 92 |
+
raise ValueError(
|
| 93 |
+
f"slot collision at span end measure {int(m1)}, slot {int(s1)}")
|
| 94 |
+
slots[a] = CHAR[typ]
|
| 95 |
+
slots[b] = "8"
|
| 96 |
+
|
| 97 |
+
# Slot output is timing-exact only when every start and the terminal edge
|
| 98 |
+
# are explicit. Never fall back to BPM/downbeat extrapolation here: that
|
| 99 |
+
# would make the serialized TJA disagree with the lattice used to decode.
|
| 100 |
+
if "measure_edges" not in grid:
|
| 101 |
+
raise ValueError(
|
| 102 |
+
"slot TJA export requires explicit measure_edges including the "
|
| 103 |
+
"trusted terminal edge"
|
| 104 |
+
)
|
| 105 |
+
tempo_edges = np.asarray(grid["measure_edges"], dtype=float)
|
| 106 |
+
if (tempo_edges.ndim != 1 or len(tempo_edges) != n_meas + 1
|
| 107 |
+
or not np.all(np.isfinite(tempo_edges))
|
| 108 |
+
or np.any(np.diff(tempo_edges) <= 0)):
|
| 109 |
+
raise ValueError(
|
| 110 |
+
"measure_edges must contain one finite, increasing edge per "
|
| 111 |
+
"measure plus the final edge")
|
| 112 |
+
lines = []
|
| 113 |
+
base_bpm = float(grid["bpm"])
|
| 114 |
+
if not np.isfinite(base_bpm) or base_bpm <= 0:
|
| 115 |
+
raise ValueError("grid bpm must be positive and finite")
|
| 116 |
+
cur_bpm = base_bpm
|
| 117 |
+
cur_meter = (4, 4)
|
| 118 |
+
for m in range(n_meas):
|
| 119 |
+
duration = float(tempo_edges[m + 1] - tempo_edges[m])
|
| 120 |
+
if not np.isfinite(duration) or duration <= 0:
|
| 121 |
+
raise ValueError(f"measure {m} duration must be positive and finite")
|
| 122 |
+
bpm_m = 60.0 * float(quarter_beats[m]) / duration
|
| 123 |
+
# No integer snap and no perceptual threshold: even a small tempo
|
| 124 |
+
# delta accumulates into a real chart/audio timing error.
|
| 125 |
+
if bpm_m != cur_bpm:
|
| 126 |
+
lines.append(f"#BPMCHANGE {_format_number(bpm_m)}")
|
| 127 |
+
cur_bpm = bpm_m
|
| 128 |
+
meter = (int(measure_num[m]), int(measure_den[m]))
|
| 129 |
+
if meter != cur_meter:
|
| 130 |
+
lines.append(f"#MEASURE {meter[0]}/{meter[1]}")
|
| 131 |
+
cur_meter = meter
|
| 132 |
+
start = int(offsets[m])
|
| 133 |
+
lines.append("".join(slots.get(start + k, "0")
|
| 134 |
+
for k in range(int(slots_per_measure[m]))) + ",")
|
| 135 |
+
balloons = [balloon_count] * sum(1 for s in gen.get("spans_slots", [])
|
| 136 |
+
if s[4] == "balloon")
|
| 137 |
+
offset = float(tempo_edges[0])
|
| 138 |
+
tja = "\n".join([
|
| 139 |
+
f"TITLE:{title} (SoftChart)", f"BPM:{_format_number(base_bpm)}",
|
| 140 |
+
f"WAVE:{wave}", f"OFFSET:{_format_number(-offset)}",
|
| 141 |
+
f"COURSE:{'Oni' if course == 'oni' else str(course).capitalize()}",
|
| 142 |
+
f"LEVEL:{level}",
|
| 143 |
+
f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
| 144 |
+
"", "#START", *lines, "#END"]) + "\n"
|
| 145 |
+
if out_path:
|
| 146 |
+
with open(out_path, "w") as f:
|
| 147 |
+
f.write(tja)
|
| 148 |
+
return tja
|
softchart/sc2/trace.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Command-trace parser: authored TJA -> exact rational command stream (slot+ S0).
|
| 2 |
+
|
| 3 |
+
A trace is the timing fragment of one TJA course represented as an ordered
|
| 4 |
+
command stream over the authored-bar rational lattice (SLOTPLUS_DESIGN.md
|
| 5 |
+
section 2 and 3): events are NOTE(pos, type), TEMPO(value), DELAY(dur) and
|
| 6 |
+
MEASURE(num/den); tempo/meter are persistent state that only events change,
|
| 7 |
+
and every bar-internal position is an exact ``fractions.Fraction`` whose
|
| 8 |
+
denominator is the authored row's note-character count. Nothing here goes
|
| 9 |
+
through floating point on the position axis; absolute times are derived
|
| 10 |
+
quantities (``trace_to_times``) computed with exact rational arithmetic and
|
| 11 |
+
converted to float only at the boundary.
|
| 12 |
+
|
| 13 |
+
Schema
|
| 14 |
+
------
|
| 15 |
+
Parse layer (lossless per ``#START``/``#END`` play section):
|
| 16 |
+
|
| 17 |
+
``ChartCommand``
|
| 18 |
+
``offset`` number of note characters of the bar emitted before the
|
| 19 |
+
command line (0 .. len(digits)),
|
| 20 |
+
``name`` command name, uppercased, without the leading ``#``,
|
| 21 |
+
``args`` verbatim argument text.
|
| 22 |
+
``ChartBar``
|
| 23 |
+
``digits`` the bar's note characters with commas removed (may be
|
| 24 |
+
empty: an authored empty measure),
|
| 25 |
+
``commands`` list of ChartCommand in source order (all commands are
|
| 26 |
+
kept here, including GOGO/SCROLL, for losslessness).
|
| 27 |
+
``Chart``
|
| 28 |
+
``headers`` dict of raw header text (keys uppercased),
|
| 29 |
+
``course`` canonical course name (easy/normal/hard/oni/ura),
|
| 30 |
+
``level`` int level (0 when absent),
|
| 31 |
+
``bpm_text`` authored header BPM, verbatim ("120" when absent),
|
| 32 |
+
``offset_text`` authored header OFFSET, verbatim ("0" when absent);
|
| 33 |
+
bar 0 starts at absolute time ``-OFFSET`` seconds,
|
| 34 |
+
``start_args`` arguments of ``#START`` ("" or "P1"/"P2"),
|
| 35 |
+
``bars`` list of ChartBar,
|
| 36 |
+
``trailing_commands`` commands after the last completed bar that precede
|
| 37 |
+
``#END`` without any note characters.
|
| 38 |
+
|
| 39 |
+
Trace layer (the command stream itself):
|
| 40 |
+
|
| 41 |
+
``NoteEvent`` ``bar``, ``pos`` (Fraction in [0, 1)), ``type`` (the raw TJA
|
| 42 |
+
note character, '1'..'9', kept verbatim; see NOTE_CHAR_CLASS).
|
| 43 |
+
``TempoEvent`` ``bar``, ``pos``, ``bpm`` (float), ``authored`` (verbatim
|
| 44 |
+
directive argument), and the (k, p) tempo-octave
|
| 45 |
+
factorisation of SLOTPLUS_DESIGN section 2.2: the authored
|
| 46 |
+
beat period ``60/bpm`` equals ``p * 2**k`` with ``p`` inside
|
| 47 |
+
one octave window.
|
| 48 |
+
``DelayEvent`` ``bar``, ``pos``, ``seconds`` (signed float), ``authored``.
|
| 49 |
+
``MeterEvent`` ``bar``, ``num``, ``den`` (ints), ``authored_num``,
|
| 50 |
+
``authored_den`` (verbatim). Meter events sit at bar
|
| 51 |
+
boundaries only (grammar, SLOTPLUS_DESIGN section 3.2).
|
| 52 |
+
``BarInfo`` ``index``, ``denom`` (authored row note-character count; 0
|
| 53 |
+
for an empty measure), ``num``/``den`` (meter state effective
|
| 54 |
+
for the bar) and ``k`` (tempo-octave notation state at the
|
| 55 |
+
bar start) -- the per-bar notation coset of section 2.3.
|
| 56 |
+
``Trace`` ``course``, ``level``, ``bpm``/``bpm_authored`` (initial
|
| 57 |
+
tempo state), ``offset``/``offset_authored``, ``bars`` (list
|
| 58 |
+
of BarInfo) and ``events`` (the ordered command stream).
|
| 59 |
+
|
| 60 |
+
Semantics rulings (documented because raw TJA leaves them implicit):
|
| 61 |
+
|
| 62 |
+
- ``#BPMCHANGE``/``#DELAY`` are legal mid-bar; their position is the exact
|
| 63 |
+
fraction ``written_digits / row_length``. ``#MEASURE`` is bar-boundary
|
| 64 |
+
only (SLOTPLUS_DESIGN section 3.2): a mid-row ``#MEASURE`` takes effect at
|
| 65 |
+
the next bar's start (the current bar's length is already determined by
|
| 66 |
+
its meter). Commands written after every note character of a bar but
|
| 67 |
+
before its terminating comma are timing-equivalent to the next bar's
|
| 68 |
+
position 0 and are carried there; if no next bar exists, one empty bar is
|
| 69 |
+
synthesized so the trace stays a lossless verbatim carrier.
|
| 70 |
+
- ``#GOGOSTART``/``#GOGOEND``/``#SCROLL`` and other non-timing commands are
|
| 71 |
+
excluded from the trace (SLOTPLUS_DESIGN section 3.1); they remain in the
|
| 72 |
+
Chart layer and never affect reconstructed times.
|
| 73 |
+
- Branch commands (``#BRANCHSTART`` etc.) fail closed with
|
| 74 |
+
``BranchUnsupportedError``: a branched section is three parallel note
|
| 75 |
+
streams and cannot be represented as one digit stream (upstream
|
| 76 |
+
taiko-1000-parsed is already flattened to a single stream).
|
| 77 |
+
- ``#DELAY`` is a signed second value added to the timeline at its position.
|
| 78 |
+
A negative delay that makes reconstructed note times non-monotonic raises
|
| 79 |
+
``NonMonotonicTimeError`` (fail-closed, SLOTPLUS_DESIGN section 3.1).
|
| 80 |
+
- Tempo-octave labels: the song's initial k is pinned from the header BPM by
|
| 81 |
+
the canonical window ``[PERIOD_LO, PERIOD_HI)``; each subsequent TEMPO
|
| 82 |
+
event keeps ``p`` log-nearest to the previous canonical period, so k moves
|
| 83 |
+
only on ~x2/x0.5 notation switches (SLOTPLUS_DESIGN section 2.2). (k, p)
|
| 84 |
+
are annotation: time reconstruction always uses the authored value.
|
| 85 |
+
|
| 86 |
+
Round-trip contract (SLOTPLUS_DESIGN section 8-S0): the trace carries every
|
| 87 |
+
authored TEMPO/MEASURE/DELAY value verbatim and reproduces every note's
|
| 88 |
+
absolute time exactly (rational arithmetic; decimal header/directive text is
|
| 89 |
+
parsed as exact decimal fractions).
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
import math
|
| 93 |
+
from dataclasses import dataclass, field
|
| 94 |
+
from fractions import Fraction
|
| 95 |
+
|
| 96 |
+
# Canonical tempo-octave window (draft values per SLOTPLUS_DESIGN section
|
| 97 |
+
# 2.2: [1.0, 2.0) seconds ~ BPM (120, 240]; re-derived and frozen before S1).
|
| 98 |
+
PERIOD_LO = 1.0
|
| 99 |
+
PERIOD_HI = 2.0
|
| 100 |
+
|
| 101 |
+
# Timing-bearing TJA commands (the trace's command alphabet besides NOTE).
|
| 102 |
+
TIMING_COMMANDS = ("BPMCHANGE", "MEASURE", "DELAY")
|
| 103 |
+
# Branch structure diverges into parallel streams: fail closed.
|
| 104 |
+
BRANCH_COMMANDS = ("BRANCHSTART", "BRANCHEND", "N", "E", "M")
|
| 105 |
+
|
| 106 |
+
# Raw TJA note characters -> canonical note classes (vocab.NOTE_CLASSES).
|
| 107 |
+
# '9' (kusudama) is balloon-class, mirroring vocab.NOTE_TYPE_MAP.
|
| 108 |
+
NOTE_CHAR_CLASS = {"1": "don", "2": "ka", "3": "don_big", "4": "ka_big",
|
| 109 |
+
"5": "roll", "6": "roll_big", "7": "balloon", "8": "end",
|
| 110 |
+
"9": "balloon"}
|
| 111 |
+
|
| 112 |
+
_COURSE_ALIASES = {
|
| 113 |
+
"easy": "easy", "0": "easy",
|
| 114 |
+
"normal": "normal", "1": "normal",
|
| 115 |
+
"hard": "hard", "2": "hard",
|
| 116 |
+
"oni": "oni", "3": "oni",
|
| 117 |
+
"edit": "ura", "ura": "ura", "4": "ura",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class TraceError(ValueError):
|
| 122 |
+
"""A TJA construct the trace layer refuses to guess about."""
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class BranchUnsupportedError(TraceError):
|
| 126 |
+
"""#BRANCHSTART sections fail closed (parallel streams, upstream-flattened)."""
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class NonMonotonicTimeError(TraceError):
|
| 130 |
+
"""A negative #DELAY produced non-monotonic note times (fail-closed)."""
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@dataclass(frozen=True)
|
| 134 |
+
class ChartCommand:
|
| 135 |
+
offset: int
|
| 136 |
+
name: str
|
| 137 |
+
args: str
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@dataclass
|
| 141 |
+
class ChartBar:
|
| 142 |
+
digits: str
|
| 143 |
+
commands: list
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@dataclass
|
| 147 |
+
class Chart:
|
| 148 |
+
headers: dict
|
| 149 |
+
course: str
|
| 150 |
+
level: int
|
| 151 |
+
bpm_text: str
|
| 152 |
+
offset_text: str
|
| 153 |
+
start_args: str
|
| 154 |
+
bars: list
|
| 155 |
+
trailing_commands: list = field(default_factory=list)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@dataclass(frozen=True)
|
| 159 |
+
class NoteEvent:
|
| 160 |
+
bar: int
|
| 161 |
+
pos: Fraction
|
| 162 |
+
type: str
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@dataclass(frozen=True)
|
| 166 |
+
class TempoEvent:
|
| 167 |
+
bar: int
|
| 168 |
+
pos: Fraction
|
| 169 |
+
bpm: float
|
| 170 |
+
authored: str
|
| 171 |
+
k: int
|
| 172 |
+
p: float
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@dataclass(frozen=True)
|
| 176 |
+
class DelayEvent:
|
| 177 |
+
bar: int
|
| 178 |
+
pos: Fraction
|
| 179 |
+
seconds: float
|
| 180 |
+
authored: str
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@dataclass(frozen=True)
|
| 184 |
+
class MeterEvent:
|
| 185 |
+
bar: int
|
| 186 |
+
num: int
|
| 187 |
+
den: int
|
| 188 |
+
authored_num: str
|
| 189 |
+
authored_den: str
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@dataclass(frozen=True)
|
| 193 |
+
class BarInfo:
|
| 194 |
+
index: int
|
| 195 |
+
denom: int
|
| 196 |
+
num: int
|
| 197 |
+
den: int
|
| 198 |
+
k: int
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@dataclass
|
| 202 |
+
class Trace:
|
| 203 |
+
course: str
|
| 204 |
+
level: int
|
| 205 |
+
bpm: float
|
| 206 |
+
bpm_authored: str
|
| 207 |
+
offset: float
|
| 208 |
+
offset_authored: str
|
| 209 |
+
bars: list
|
| 210 |
+
events: list
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def note_class(char):
|
| 214 |
+
"""Canonical note class for a raw TJA note character (None if unknown)."""
|
| 215 |
+
return NOTE_CHAR_CLASS.get(char)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _fraction_text(text):
|
| 219 |
+
"""Exact rational value of authored numeric text (decimal-exact when the
|
| 220 |
+
text is decimal; otherwise the exact rational of the parsed float)."""
|
| 221 |
+
try:
|
| 222 |
+
return Fraction(text)
|
| 223 |
+
except (ValueError, ZeroDivisionError):
|
| 224 |
+
return Fraction(float(text))
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _build_chart(headers, start_args, bars, trailing):
|
| 228 |
+
course_text = headers.get("COURSE", "Oni").strip()
|
| 229 |
+
course = _COURSE_ALIASES.get(course_text.lower(), course_text.lower())
|
| 230 |
+
level_text = headers.get("LEVEL", "").strip()
|
| 231 |
+
try:
|
| 232 |
+
level = int(float(level_text)) if level_text else 0
|
| 233 |
+
except ValueError:
|
| 234 |
+
level = 0
|
| 235 |
+
bpm_text = headers.get("BPM", "").strip() or "120"
|
| 236 |
+
offset_text = headers.get("OFFSET", "").strip() or "0"
|
| 237 |
+
return Chart(dict(headers), course, level, bpm_text, offset_text,
|
| 238 |
+
start_args, bars, trailing)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def parse_tja(text):
|
| 242 |
+
"""Parse TJA text into one Chart per #START/#END play section.
|
| 243 |
+
|
| 244 |
+
Headers accumulate across the file (TJA semantics: BPM/OFFSET persist
|
| 245 |
+
unless re-declared before the next course section). Comments (``//``)
|
| 246 |
+
are stripped everywhere; a measure may span several lines and a line may
|
| 247 |
+
hold several comma-terminated measures.
|
| 248 |
+
"""
|
| 249 |
+
charts = []
|
| 250 |
+
headers = {}
|
| 251 |
+
in_body = False
|
| 252 |
+
start_args = ""
|
| 253 |
+
bars = commands = None
|
| 254 |
+
digits = ""
|
| 255 |
+
for raw in text.lstrip("\ufeff").splitlines():
|
| 256 |
+
cut = raw.find("//")
|
| 257 |
+
line = (raw[:cut] if cut >= 0 else raw).strip()
|
| 258 |
+
if not line:
|
| 259 |
+
continue
|
| 260 |
+
if not in_body:
|
| 261 |
+
if line.startswith("#"):
|
| 262 |
+
head = line[1:].split(None, 1)
|
| 263 |
+
if head and head[0].upper() == "START":
|
| 264 |
+
in_body = True
|
| 265 |
+
start_args = head[1].strip() if len(head) > 1 else ""
|
| 266 |
+
bars, digits, commands = [], "", []
|
| 267 |
+
# other pre-body commands carry no timing; ignored
|
| 268 |
+
elif ":" in line:
|
| 269 |
+
key, value = line.split(":", 1)
|
| 270 |
+
headers[key.strip().upper()] = value.strip()
|
| 271 |
+
continue
|
| 272 |
+
if line.startswith("#"):
|
| 273 |
+
head = line[1:].split(None, 1)
|
| 274 |
+
name = head[0].upper() if head else ""
|
| 275 |
+
args = head[1].strip() if len(head) > 1 else ""
|
| 276 |
+
if name == "END":
|
| 277 |
+
trailing = []
|
| 278 |
+
if digits:
|
| 279 |
+
bars.append(ChartBar(digits, commands))
|
| 280 |
+
else:
|
| 281 |
+
trailing = commands
|
| 282 |
+
charts.append(_build_chart(headers, start_args, bars, trailing))
|
| 283 |
+
in_body = False
|
| 284 |
+
bars, digits, commands = None, "", None
|
| 285 |
+
continue
|
| 286 |
+
if name in BRANCH_COMMANDS:
|
| 287 |
+
raise BranchUnsupportedError(
|
| 288 |
+
f"branch command #{name} is not supported (fail-closed)")
|
| 289 |
+
commands.append(ChartCommand(len(digits), name, args))
|
| 290 |
+
continue
|
| 291 |
+
for ch in line:
|
| 292 |
+
if ch == ",":
|
| 293 |
+
bars.append(ChartBar(digits, commands))
|
| 294 |
+
digits, commands = "", []
|
| 295 |
+
elif ch.isspace():
|
| 296 |
+
continue
|
| 297 |
+
elif ch.isascii() and ch.isalnum():
|
| 298 |
+
digits += ch
|
| 299 |
+
else:
|
| 300 |
+
raise TraceError(
|
| 301 |
+
f"unexpected character {ch!r} in note line {raw!r}")
|
| 302 |
+
if in_body:
|
| 303 |
+
raise TraceError("unterminated #START section (missing #END)")
|
| 304 |
+
return charts
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def parse_tja_course(text, course):
|
| 308 |
+
"""Return the first Chart whose canonical course matches ``course``."""
|
| 309 |
+
wanted = _COURSE_ALIASES.get(str(course).strip().lower(),
|
| 310 |
+
str(course).strip().lower())
|
| 311 |
+
for chart in parse_tja(text):
|
| 312 |
+
if chart.course == wanted:
|
| 313 |
+
return chart
|
| 314 |
+
raise TraceError(f"no {wanted!r} course section in this TJA")
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def chart_to_trace(chart):
|
| 318 |
+
"""Convert a parsed Chart into its command Trace.
|
| 319 |
+
|
| 320 |
+
Positions are exact ``Fraction(written_digits, row_length)``; authored
|
| 321 |
+
numeric text is kept verbatim on every event. Non-timing commands are
|
| 322 |
+
excluded (they cannot move any note time); branch charts never reach
|
| 323 |
+
here (parse_tja fails closed).
|
| 324 |
+
"""
|
| 325 |
+
try:
|
| 326 |
+
bpm0 = float(chart.bpm_text)
|
| 327 |
+
except ValueError:
|
| 328 |
+
raise TraceError(f"unparseable header BPM {chart.bpm_text!r}") from None
|
| 329 |
+
if not math.isfinite(bpm0) or bpm0 <= 0:
|
| 330 |
+
raise TraceError(f"header BPM must be finite and positive: {chart.bpm_text!r}")
|
| 331 |
+
try:
|
| 332 |
+
offset0 = float(chart.offset_text)
|
| 333 |
+
except ValueError:
|
| 334 |
+
raise TraceError(
|
| 335 |
+
f"unparseable header OFFSET {chart.offset_text!r}") from None
|
| 336 |
+
if not math.isfinite(offset0):
|
| 337 |
+
raise TraceError(f"header OFFSET must be finite: {chart.offset_text!r}")
|
| 338 |
+
|
| 339 |
+
state = {"num": 4, "den": 4}
|
| 340 |
+
period0 = 60.0 / bpm0
|
| 341 |
+
# Pin the song's initial octave label from the canonical window
|
| 342 |
+
# (SLOTPLUS_DESIGN section 2.2); with PERIOD_LO = 1.0 this is floor(log2).
|
| 343 |
+
state["k"] = math.floor(math.log2(period0 / PERIOD_LO))
|
| 344 |
+
state["p"] = period0 / 2.0 ** state["k"]
|
| 345 |
+
|
| 346 |
+
events = []
|
| 347 |
+
bars = []
|
| 348 |
+
|
| 349 |
+
def emit(bar_index, pos, cmd):
|
| 350 |
+
if cmd.name == "BPMCHANGE":
|
| 351 |
+
try:
|
| 352 |
+
value = float(cmd.args)
|
| 353 |
+
except ValueError:
|
| 354 |
+
raise TraceError(
|
| 355 |
+
f"bar {bar_index}: unparseable #BPMCHANGE {cmd.args!r}"
|
| 356 |
+
) from None
|
| 357 |
+
if not math.isfinite(value) or value <= 0:
|
| 358 |
+
raise TraceError(
|
| 359 |
+
f"bar {bar_index}: #BPMCHANGE must be finite and positive: "
|
| 360 |
+
f"{cmd.args!r}")
|
| 361 |
+
period = 60.0 / value
|
| 362 |
+
# k moves only when the new period leaves the +-half-octave band
|
| 363 |
+
# around the previous canonical period p (~x2/x0.5 switches).
|
| 364 |
+
k = round(math.log2(period / state["p"]))
|
| 365 |
+
state["k"], state["p"] = k, period / 2.0 ** k
|
| 366 |
+
events.append(TempoEvent(bar_index, pos, value, cmd.args, k,
|
| 367 |
+
state["p"]))
|
| 368 |
+
elif cmd.name == "MEASURE":
|
| 369 |
+
parts = cmd.args.split("/")
|
| 370 |
+
if len(parts) != 2:
|
| 371 |
+
raise TraceError(
|
| 372 |
+
f"bar {bar_index}: unparseable #MEASURE {cmd.args!r}")
|
| 373 |
+
authored_num, authored_den = parts[0].strip(), parts[1].strip()
|
| 374 |
+
try:
|
| 375 |
+
num, den = int(authored_num), int(authored_den)
|
| 376 |
+
except ValueError:
|
| 377 |
+
raise TraceError(
|
| 378 |
+
f"bar {bar_index}: #MEASURE terms must be integers: "
|
| 379 |
+
f"{cmd.args!r}") from None
|
| 380 |
+
if num <= 0 or den <= 0:
|
| 381 |
+
raise TraceError(
|
| 382 |
+
f"bar {bar_index}: #MEASURE terms must be positive: "
|
| 383 |
+
f"{cmd.args!r}")
|
| 384 |
+
state["num"], state["den"] = num, den
|
| 385 |
+
events.append(MeterEvent(bar_index, num, den, authored_num,
|
| 386 |
+
authored_den))
|
| 387 |
+
else: # DELAY
|
| 388 |
+
try:
|
| 389 |
+
seconds = float(cmd.args)
|
| 390 |
+
except ValueError:
|
| 391 |
+
raise TraceError(
|
| 392 |
+
f"bar {bar_index}: unparseable #DELAY {cmd.args!r}"
|
| 393 |
+
) from None
|
| 394 |
+
if not math.isfinite(seconds):
|
| 395 |
+
raise TraceError(
|
| 396 |
+
f"bar {bar_index}: #DELAY must be finite: {cmd.args!r}")
|
| 397 |
+
events.append(DelayEvent(bar_index, pos, seconds, cmd.args))
|
| 398 |
+
|
| 399 |
+
carry = [] # timing commands owed to the next bar's position 0
|
| 400 |
+
source_bars = list(chart.bars)
|
| 401 |
+
trailing = [c for c in chart.trailing_commands if c.name in TIMING_COMMANDS]
|
| 402 |
+
index = 0
|
| 403 |
+
while index < len(source_bars) or carry or trailing:
|
| 404 |
+
if index < len(source_bars):
|
| 405 |
+
bar = source_bars[index]
|
| 406 |
+
else:
|
| 407 |
+
# Synthesized empty bar hosting end-of-chart carried commands so
|
| 408 |
+
# their authored values stay in the trace (module docstring).
|
| 409 |
+
bar = ChartBar("", [])
|
| 410 |
+
length = len(bar.digits)
|
| 411 |
+
local, carry_out = [], []
|
| 412 |
+
for cmd in bar.commands:
|
| 413 |
+
if cmd.name not in TIMING_COMMANDS:
|
| 414 |
+
continue
|
| 415 |
+
if (cmd.name == "MEASURE" and cmd.offset > 0) or \
|
| 416 |
+
(length > 0 and cmd.offset >= length):
|
| 417 |
+
carry_out.append(cmd)
|
| 418 |
+
else:
|
| 419 |
+
local.append(cmd)
|
| 420 |
+
if index >= len(source_bars):
|
| 421 |
+
local = list(trailing)
|
| 422 |
+
trailing = []
|
| 423 |
+
head = carry + [c for c in local if c.offset == 0]
|
| 424 |
+
tail = [c for c in local if c.offset > 0]
|
| 425 |
+
carry = carry_out
|
| 426 |
+
for cmd in head:
|
| 427 |
+
emit(index, Fraction(0), cmd)
|
| 428 |
+
bars.append(BarInfo(index, length, state["num"], state["den"],
|
| 429 |
+
state["k"]))
|
| 430 |
+
ti = 0
|
| 431 |
+
for i, ch in enumerate(bar.digits):
|
| 432 |
+
while ti < len(tail) and tail[ti].offset <= i:
|
| 433 |
+
emit(index, Fraction(tail[ti].offset, length), tail[ti])
|
| 434 |
+
ti += 1
|
| 435 |
+
if ch != "0":
|
| 436 |
+
events.append(NoteEvent(index, Fraction(i, length), ch))
|
| 437 |
+
# remaining tail commands all had offset == length and were carried
|
| 438 |
+
index += 1
|
| 439 |
+
|
| 440 |
+
return Trace(chart.course, chart.level, bpm0, chart.bpm_text,
|
| 441 |
+
offset0, chart.offset_text, bars, events)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _iter_timed(trace):
|
| 445 |
+
"""Walk the command stream with exact rational time.
|
| 446 |
+
|
| 447 |
+
Yields ("edge", bar_index, t) at every bar start plus the terminal edge
|
| 448 |
+
(bar_index == len(bars)), and ("note", NoteEvent, t) for every note, all
|
| 449 |
+
in stream order. The terminal edge is evaluated from trace state, never
|
| 450 |
+
extrapolated (SLOTPLUS_DESIGN section 8-S0).
|
| 451 |
+
"""
|
| 452 |
+
t = -_fraction_text(trace.offset_authored)
|
| 453 |
+
tau = Fraction(60) / _fraction_text(trace.bpm_authored)
|
| 454 |
+
num, den = 4, 4
|
| 455 |
+
bar_i = 0
|
| 456 |
+
cursor = Fraction(0)
|
| 457 |
+
n_bars = len(trace.bars)
|
| 458 |
+
yield "edge", 0, t
|
| 459 |
+
for ev in trace.events:
|
| 460 |
+
while bar_i < ev.bar:
|
| 461 |
+
t += (1 - cursor) * Fraction(4 * num, den) * tau
|
| 462 |
+
bar_i += 1
|
| 463 |
+
cursor = Fraction(0)
|
| 464 |
+
yield "edge", bar_i, t
|
| 465 |
+
if isinstance(ev, MeterEvent):
|
| 466 |
+
if cursor != 0:
|
| 467 |
+
raise TraceError(
|
| 468 |
+
f"bar {ev.bar}: MEASURE away from the bar boundary is "
|
| 469 |
+
"outside the grammar")
|
| 470 |
+
num, den = ev.num, ev.den
|
| 471 |
+
continue
|
| 472 |
+
if ev.pos != cursor:
|
| 473 |
+
t += (ev.pos - cursor) * Fraction(4 * num, den) * tau
|
| 474 |
+
cursor = ev.pos
|
| 475 |
+
if isinstance(ev, TempoEvent):
|
| 476 |
+
tau = Fraction(60) / _fraction_text(ev.authored)
|
| 477 |
+
elif isinstance(ev, DelayEvent):
|
| 478 |
+
t += _fraction_text(ev.authored)
|
| 479 |
+
else:
|
| 480 |
+
yield "note", ev, t
|
| 481 |
+
while bar_i < n_bars:
|
| 482 |
+
t += (1 - cursor) * Fraction(4 * num, den) * tau
|
| 483 |
+
bar_i += 1
|
| 484 |
+
cursor = Fraction(0)
|
| 485 |
+
yield "edge", bar_i, t
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def trace_to_times(trace, exact=False, check_monotonic=True):
|
| 489 |
+
"""Reconstruct every note's absolute time (seconds) and type from a trace.
|
| 490 |
+
|
| 491 |
+
Returns a list of (time, type_char) in stream order; times are floats, or
|
| 492 |
+
exact Fractions with ``exact=True``. Raises NonMonotonicTimeError when a
|
| 493 |
+
negative #DELAY makes note times non-increasing (fail-closed).
|
| 494 |
+
"""
|
| 495 |
+
out = []
|
| 496 |
+
last = None
|
| 497 |
+
for kind, payload, t in _iter_timed(trace):
|
| 498 |
+
if kind != "note":
|
| 499 |
+
continue
|
| 500 |
+
if check_monotonic and last is not None and t <= last:
|
| 501 |
+
raise NonMonotonicTimeError(
|
| 502 |
+
f"non-monotonic note time at bar {payload.bar} "
|
| 503 |
+
f"pos {payload.pos}: {float(t)} after {float(last)}")
|
| 504 |
+
last = t
|
| 505 |
+
out.append((t if exact else float(t), payload.type))
|
| 506 |
+
return out
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def bar_edge_times(trace, exact=False):
|
| 510 |
+
"""Absolute times of every bar start plus the terminal edge.
|
| 511 |
+
|
| 512 |
+
Returns ``len(trace.bars) + 1`` values; the terminal edge comes from
|
| 513 |
+
evaluating trace state across the final bar, not from extrapolation.
|
| 514 |
+
"""
|
| 515 |
+
edges = []
|
| 516 |
+
for kind, _payload, t in _iter_timed(trace):
|
| 517 |
+
if kind == "edge":
|
| 518 |
+
edges.append(t if exact else float(t))
|
| 519 |
+
return edges
|
softchart/sc2/vocab.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token vocabulary for chart sequences.
|
| 2 |
+
|
| 3 |
+
Sequence layout (decoder):
|
| 4 |
+
[BOS] [COURSE_c] [LEVEL_l] [DENS_d] ([TIME_t] [NOTE_e])* [EOS]
|
| 5 |
+
Conditions may be UNK (condition dropout / unspecified at inference).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from numbers import Real
|
| 11 |
+
|
| 12 |
+
# --- audio / windowing constants (single source of truth) ---
|
| 13 |
+
SR = 22050
|
| 14 |
+
N_FFT = 2048
|
| 15 |
+
HOP = 256
|
| 16 |
+
N_MELS = 128
|
| 17 |
+
FPS = SR / HOP # 86.1328125 frames/sec
|
| 18 |
+
WINDOW = 1728 # frames per training window (~20.06 s)
|
| 19 |
+
MAX_TGT = 960 # max decoder length (prefix + events + eos); 960 covers densest ura
|
| 20 |
+
# BarScript window (912 content tokens + prefix, train-split measured) with headroom.
|
| 21 |
+
# Frozen 2026-07-25 (spec ruling); was 768 through v1.8.
|
| 22 |
+
|
| 23 |
+
SLOTS = 96 # TJA lattice: slots per 4/4 measure (LCM of 16ths/triplets/32nds)
|
| 24 |
+
MEAS_MAX = WINDOW // SLOTS # max whole measures per window in slot mode (18)
|
| 25 |
+
SLOTS_PER_BEAT = 24 # meter-general lattice (v3): 3/4 -> 72, 5/4 -> 120 slots
|
| 26 |
+
BEATS_MAX = WINDOW // SLOTS_PER_BEAT # 72 beats of token space per window
|
| 27 |
+
|
| 28 |
+
COURSES = ["easy", "normal", "hard", "oni", "ura"]
|
| 29 |
+
N_LEVELS = 12 # levels clamped to 1..12
|
| 30 |
+
N_DENS = 16
|
| 31 |
+
DENS_BUCKET_NPS = 0.75 # bucket width in notes/sec
|
| 32 |
+
|
| 33 |
+
# canonical note classes; parsed TJA note_type strings are mapped onto these
|
| 34 |
+
NOTE_CLASSES = ["don", "ka", "don_big", "ka_big", "roll", "roll_big", "balloon", "end"]
|
| 35 |
+
|
| 36 |
+
# --- SoftChart 2.0 BarScript constants (SOFTCHART2_DESIGN §1.2/§8.1) ---
|
| 37 |
+
# Per-bar plan-token families. These are DISTINCT from the window-prefix
|
| 38 |
+
# condition families (pdens/sync): plan tokens live inside the bar stream at
|
| 39 |
+
# bar granularity, the older families are window-level prefix conditions.
|
| 40 |
+
N_DENS_D8 = 8 # per-bar onset-count density bucket (DENS_d8)
|
| 41 |
+
N_SPAN_BUDGET = 4 # per-bar span-start budget
|
| 42 |
+
N_BAR_SYNC = 6 # per-bar LHL syncopation band
|
| 43 |
+
MOTIF_DELTAS = (1, 2, 4, 8) # lookback (bars) for REPEAT/VAR references
|
| 44 |
+
MOTIF_REF_NAMES = (
|
| 45 |
+
("new",)
|
| 46 |
+
+ tuple(f"repeat_{d}" for d in MOTIF_DELTAS)
|
| 47 |
+
+ tuple(f"var_{d}" for d in MOTIF_DELTAS)
|
| 48 |
+
) # 9: NEW, REPEAT x {1,2,4,8}, VAR x {1,2,4,8}
|
| 49 |
+
N_MOTIF_REF = len(MOTIF_REF_NAMES)
|
| 50 |
+
# split-axis condition buckets (prefix conditions, per-axis CFG)
|
| 51 |
+
N_BIG_RATE = 8
|
| 52 |
+
N_SPAN_RATE = 8
|
| 53 |
+
N_STREAM = 6
|
| 54 |
+
# span duration buckets (log grid anchored on authored p50/p90/p99).
|
| 55 |
+
# TENTATIVE: the design doc fixes the grid family but not the bucket count;
|
| 56 |
+
# 16 is a placeholder pending the frozen label distribution report.
|
| 57 |
+
N_SPAN_DUR_BUCKETS = 16
|
| 58 |
+
# per-bar rational-lattice denominator support (BarScript skeleton positions
|
| 59 |
+
# are bar-local indices on a D_m grid, D_m = LCM of the bar's reduced event
|
| 60 |
+
# denominators; SLOTPLUS_DESIGN §2.1). 1..192 is a draft SUPERSET: it covers
|
| 61 |
+
# dyadic 64ths (denom 192) and the observed 5/7/9/11 tuplet families; the
|
| 62 |
+
# frozen support set comes from the S0 census. Bars needing D_m > 192 stay
|
| 63 |
+
# fail-closed (counted), never snapped.
|
| 64 |
+
MAX_BAR_DENOM = 192
|
| 65 |
+
|
| 66 |
+
# BarScript stage ids (stage embedding routing; SOFTCHART2_DESIGN §1.1)
|
| 67 |
+
STAGE_HEADER = 0
|
| 68 |
+
STAGE_PLAN = 1
|
| 69 |
+
STAGE_SKELETON = 2
|
| 70 |
+
STAGE_REALIZATION = 3
|
| 71 |
+
N_STAGES = 4
|
| 72 |
+
|
| 73 |
+
# slot+ TEMPO factorization heads (SLOTPLUS_DESIGN §2.2/§3.2/§3.3):
|
| 74 |
+
# beat period = p * 2^k with canonical p in [TEMPO_P_LO, TEMPO_P_HI) seconds
|
| 75 |
+
# (draft window; frozen from bpm_map before S1). The regression head predicts
|
| 76 |
+
# log p (no hard clamp); k is a classification label. TENTATIVE: the spec
|
| 77 |
+
# leaves k in Z; classification needs finite support, so k is clamped to
|
| 78 |
+
# [TEMPO_K_MIN, TEMPO_K_MIN + N_TEMPO_K).
|
| 79 |
+
TEMPO_P_LO = 1.0
|
| 80 |
+
TEMPO_P_HI = 2.0
|
| 81 |
+
TEMPO_K_MIN = -3
|
| 82 |
+
N_TEMPO_K = 7 # k in {-3..+3}
|
| 83 |
+
|
| 84 |
+
# mapping from strings observed in the dataset -> canonical class
|
| 85 |
+
# (observed in taiko-1000-parsed: Don, Ka, DonBig, KaBig, Roll, RollBig,
|
| 86 |
+
# Balloon, BalloonAlt, EndOf)
|
| 87 |
+
NOTE_TYPE_MAP = {
|
| 88 |
+
"Don": "don",
|
| 89 |
+
"Ka": "ka",
|
| 90 |
+
"DonBig": "don_big",
|
| 91 |
+
"KaBig": "ka_big",
|
| 92 |
+
"Roll": "roll",
|
| 93 |
+
"RollBig": "roll_big",
|
| 94 |
+
"Balloon": "balloon",
|
| 95 |
+
"BalloonAlt": "balloon", # kusudama, treated as balloon-class span
|
| 96 |
+
"EndOf": "end",
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class Vocab:
|
| 102 |
+
pad: int = 0
|
| 103 |
+
eos: int = 1
|
| 104 |
+
bos: int = 2
|
| 105 |
+
unk_cond: int = 3
|
| 106 |
+
|
| 107 |
+
def __post_init__(self):
|
| 108 |
+
base = 4
|
| 109 |
+
self.course = {c: base + i for i, c in enumerate(COURSES)}
|
| 110 |
+
base += len(COURSES)
|
| 111 |
+
self.level = {l: base + l - 1 for l in range(1, N_LEVELS + 1)}
|
| 112 |
+
base += N_LEVELS
|
| 113 |
+
self.dens = {d: base + d for d in range(N_DENS)}
|
| 114 |
+
base += N_DENS
|
| 115 |
+
self.note = {n: base + i for i, n in enumerate(NOTE_CLASSES)}
|
| 116 |
+
base += len(NOTE_CLASSES)
|
| 117 |
+
self.time0 = base
|
| 118 |
+
base += WINDOW
|
| 119 |
+
# v2+ tokens appended at the end so older checkpoints stay loadable
|
| 120 |
+
self.sep = base # separates prev-window context from conditions
|
| 121 |
+
self.sib = base + 1 # marks the sibling-chart (easier course) segment
|
| 122 |
+
self.style = {s: base + 2 + s for s in range(8)} # charting-intent codes
|
| 123 |
+
self.sync = {s: base + 10 + s for s in range(6)} # LHL syncopation bands
|
| 124 |
+
# plan-realize tokens: [PLAN] marker + block density (8) + block flags (3)
|
| 125 |
+
self.plan = base + 16
|
| 126 |
+
self.pdens = {d: base + 17 + d for d in range(8)}
|
| 127 |
+
self.pflag = {f: base + 25 + f for f in range(3)} # 0 none / 1 gap / 2 climax
|
| 128 |
+
self.mask = base + 28 # type-infill placeholder
|
| 129 |
+
self.cplx = {c: base + 29 + c for c in range(6)} # rhythmic-complexity band
|
| 130 |
+
# dual-mode models: explicit output-semantics switch (v3). mode "slot"
|
| 131 |
+
# = positions are lattice indices on a supplied grid; "time" = frames.
|
| 132 |
+
self.mode = {"slot": base + 35, "time": base + 36}
|
| 133 |
+
# --- SoftChart 2.0 BarScript tokens (append-only; older checkpoints
|
| 134 |
+
# keep every pre-existing id). SOFTCHART2_DESIGN §1.2/§8.1. ---
|
| 135 |
+
b2 = base + 37
|
| 136 |
+
self.bar = b2 # [BAR] header-stage marker (TEMPO/MEASURE/k anchor)
|
| 137 |
+
self.real = b2 + 1 # [REAL] realization-stage marker
|
| 138 |
+
self.bar_dens = {d: b2 + 2 + d for d in range(N_DENS_D8)}
|
| 139 |
+
self.span_budget = {s: b2 + 10 + s for s in range(N_SPAN_BUDGET)}
|
| 140 |
+
self.bar_sync = {s: b2 + 14 + s for s in range(N_BAR_SYNC)}
|
| 141 |
+
self.motif = {m: b2 + 20 + i for i, m in enumerate(MOTIF_REF_NAMES)}
|
| 142 |
+
self.big_rate = {r: b2 + 29 + r for r in range(N_BIG_RATE)}
|
| 143 |
+
self.span_rate = {r: b2 + 37 + r for r in range(N_SPAN_RATE)}
|
| 144 |
+
self.stream = {s: b2 + 45 + s for s in range(N_STREAM)}
|
| 145 |
+
self.span_dur = {b: b2 + 51 + b for b in range(N_SPAN_DUR_BUCKETS)}
|
| 146 |
+
# --- BarScript skeleton markers + notation denominator (append-only,
|
| 147 |
+
# 2026-07-24; SOFTCHART2_DESIGN §1.1 skeleton stage, §1.3 span object).
|
| 148 |
+
# Skeleton POSITION tokens reuse the time0 family as BAR-LOCAL lattice
|
| 149 |
+
# indices (v3 slot-mode precedent: "positions are lattice indices on a
|
| 150 |
+
# supplied grid"), so hit token counts match the flat encoding exactly.
|
| 151 |
+
b3 = b2 + 51 + N_SPAN_DUR_BUCKETS
|
| 152 |
+
self.span_start = b3 # skeleton marker: next position opens a span
|
| 153 |
+
self.span_end = b3 + 1 # skeleton marker: next position closes the span
|
| 154 |
+
self.bar_denom = {d: b3 + 2 + (d - 1)
|
| 155 |
+
for d in range(1, MAX_BAR_DENOM + 1)}
|
| 156 |
+
self.size = b3 + 2 + MAX_BAR_DENOM
|
| 157 |
+
|
| 158 |
+
self.id2note = {v: k for k, v in self.note.items()}
|
| 159 |
+
self.id2course = {v: k for k, v in self.course.items()}
|
| 160 |
+
|
| 161 |
+
def time(self, frame: int) -> int:
|
| 162 |
+
assert 0 <= frame < WINDOW
|
| 163 |
+
return self.time0 + frame
|
| 164 |
+
|
| 165 |
+
def is_time(self, tok: int) -> bool:
|
| 166 |
+
return self.time0 <= tok < self.time0 + WINDOW
|
| 167 |
+
|
| 168 |
+
def is_note(self, tok: int) -> bool:
|
| 169 |
+
return tok in self.id2note
|
| 170 |
+
|
| 171 |
+
def dens_bucket(self, nps: float) -> int:
|
| 172 |
+
return min(N_DENS - 1, max(0, int(nps / DENS_BUCKET_NPS)))
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
VOCAB = Vocab()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def v2_token_ids(vocab=VOCAB):
|
| 179 |
+
"""Explicit name -> id map for every SoftChart 2.0 appended token."""
|
| 180 |
+
ids = {"BAR": vocab.bar, "REAL": vocab.real}
|
| 181 |
+
ids.update({f"DENS_D8_{d}": t for d, t in vocab.bar_dens.items()})
|
| 182 |
+
ids.update({f"SPAN_BUDGET_{s}": t for s, t in vocab.span_budget.items()})
|
| 183 |
+
ids.update({f"BAR_SYNC_{s}": t for s, t in vocab.bar_sync.items()})
|
| 184 |
+
ids.update({f"MOTIF_{m.upper()}": t for m, t in vocab.motif.items()})
|
| 185 |
+
ids.update({f"BIG_RATE_{r}": t for r, t in vocab.big_rate.items()})
|
| 186 |
+
ids.update({f"SPAN_RATE_{r}": t for r, t in vocab.span_rate.items()})
|
| 187 |
+
ids.update({f"STREAM_{s}": t for s, t in vocab.stream.items()})
|
| 188 |
+
ids.update({f"SPAN_DUR_{b}": t for b, t in vocab.span_dur.items()})
|
| 189 |
+
ids["SPAN_START"] = vocab.span_start
|
| 190 |
+
ids["SPAN_END"] = vocab.span_end
|
| 191 |
+
ids.update({f"BAR_DENOM_{d}": t for d, t in vocab.bar_denom.items()})
|
| 192 |
+
return ids
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
V2_TOKEN_IDS = v2_token_ids()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
SIB_EVENTS = 12 # fixed number of sibling-chart events in the prefix (24 tokens)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
PLAN_SLOTS = 5 # fixed number of plan blocks in the prefix (2 tokens each)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def complexity_band(frames):
|
| 205 |
+
"""IOI-class entropy of a window's hit sequence -> band 0-5 (rhythmic
|
| 206 |
+
complexity independent of raw density)."""
|
| 207 |
+
import math
|
| 208 |
+
hits = sorted(f for f, c in frames if NOTE_CLASSES[c] in ("don","ka","don_big","ka_big"))
|
| 209 |
+
if len(hits) < 4:
|
| 210 |
+
return 0
|
| 211 |
+
iois = [b - a for a, b in zip(hits, hits[1:]) if b > a]
|
| 212 |
+
if not iois:
|
| 213 |
+
return 0
|
| 214 |
+
from collections import Counter
|
| 215 |
+
cls = Counter(int(round(math.log2(max(i,1)) * 2)) for i in iois)
|
| 216 |
+
tot = sum(cls.values())
|
| 217 |
+
ent = -sum((n/tot) * math.log2(n/tot) for n in cls.values())
|
| 218 |
+
return min(5, int(ent / 0.5))
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_types=None,
|
| 222 |
+
sib_pairs=None, style=None, sync_band=None, plan_slice=None,
|
| 223 |
+
complexity=None, mode=None, duration_seconds=None):
|
| 224 |
+
"""Build a token sequence for one window.
|
| 225 |
+
|
| 226 |
+
notes: list of (frame_idx, note_class_id) sorted by frame, frame in [0, WINDOW).
|
| 227 |
+
ctx_types: optional list of note-class ids from the previous window's tail
|
| 228 |
+
(pattern continuity context, v2). Encoded as [BOS] ctx.. [SEP] conds..
|
| 229 |
+
sib_pairs: optional list of (frame, class_id) events from an easier course of
|
| 230 |
+
the same song (skeleton hint, easy⊂hard). Fixed SIB_EVENTS slots,
|
| 231 |
+
missing slots filled with UNK. Encoded after [SIB].
|
| 232 |
+
Returns (tokens, prefix_len) where loss should be applied after the prefix.
|
| 233 |
+
"""
|
| 234 |
+
if duration_seconds is None:
|
| 235 |
+
duration_seconds = WINDOW / FPS
|
| 236 |
+
if (not isinstance(duration_seconds, Real)
|
| 237 |
+
or isinstance(duration_seconds, bool)
|
| 238 |
+
or not math.isfinite(duration_seconds)
|
| 239 |
+
or duration_seconds <= 0):
|
| 240 |
+
raise ValueError("duration_seconds must be positive and finite")
|
| 241 |
+
n_hits = sum(1 for _, c in notes if NOTE_CLASSES[c] not in ("end",))
|
| 242 |
+
nps = n_hits / float(duration_seconds)
|
| 243 |
+
d = vocab.dens_bucket(nps)
|
| 244 |
+
|
| 245 |
+
def maybe(tok):
|
| 246 |
+
if cond_drop > 0 and rng is not None and rng.random() < cond_drop:
|
| 247 |
+
return vocab.unk_cond
|
| 248 |
+
return tok
|
| 249 |
+
|
| 250 |
+
lvl = max(1, min(N_LEVELS, level if level and level > 0 else 1))
|
| 251 |
+
seq = [vocab.bos]
|
| 252 |
+
if mode is not None: # dual-mode: explicit output-semantics token (never dropped)
|
| 253 |
+
seq.append(vocab.mode[mode])
|
| 254 |
+
if ctx_types is not None: # fixed-length, left-padded with UNK (id < 0 = pad)
|
| 255 |
+
seq += [vocab.note[NOTE_CLASSES[c]] if c >= 0 else vocab.unk_cond
|
| 256 |
+
for c in ctx_types]
|
| 257 |
+
seq.append(vocab.sep)
|
| 258 |
+
if sib_pairs is not None:
|
| 259 |
+
seq.append(vocab.sib)
|
| 260 |
+
pairs = list(sib_pairs)[:SIB_EVENTS]
|
| 261 |
+
for f, c in pairs:
|
| 262 |
+
seq += [vocab.time(int(f)), vocab.note[NOTE_CLASSES[c]]]
|
| 263 |
+
seq += [vocab.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
|
| 264 |
+
seq += [
|
| 265 |
+
maybe(vocab.course[course]),
|
| 266 |
+
maybe(vocab.level[lvl]),
|
| 267 |
+
maybe(vocab.dens[d]),
|
| 268 |
+
]
|
| 269 |
+
if style is not None: # charting-intent code (style >= 0; -1 = unknown)
|
| 270 |
+
seq.append(maybe(vocab.style[style]) if style >= 0 else vocab.unk_cond)
|
| 271 |
+
if sync_band is not None: # LHL syncopation band (groove-intensity control)
|
| 272 |
+
seq.append(maybe(vocab.sync[sync_band]) if sync_band >= 0 else vocab.unk_cond)
|
| 273 |
+
if complexity is not None: # rhythmic-complexity band (difficulty beyond density)
|
| 274 |
+
seq.append(maybe(vocab.cplx[complexity]) if complexity >= 0 else vocab.unk_cond)
|
| 275 |
+
if plan_slice is not None: # song-level plan blocks overlapping this window
|
| 276 |
+
seq.append(vocab.plan)
|
| 277 |
+
blocks = list(plan_slice)[:PLAN_SLOTS]
|
| 278 |
+
for d8, fl in blocks:
|
| 279 |
+
seq += [maybe(vocab.pdens[min(7, max(0, d8))]),
|
| 280 |
+
maybe(vocab.pflag[min(2, max(0, fl))])]
|
| 281 |
+
seq += [vocab.unk_cond] * (2 * (PLAN_SLOTS - len(blocks)))
|
| 282 |
+
prefix_len = len(seq)
|
| 283 |
+
for f, c in notes:
|
| 284 |
+
seq.append(vocab.time(int(f)))
|
| 285 |
+
seq.append(vocab.note[NOTE_CLASSES[c]])
|
| 286 |
+
seq.append(vocab.eos)
|
| 287 |
+
return seq, prefix_len
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def decode_tokens(vocab, tokens):
|
| 291 |
+
"""Token ids -> list of (frame_idx, note_class_name). Ignores malformed pairs."""
|
| 292 |
+
out = []
|
| 293 |
+
cur_t = None
|
| 294 |
+
for tok in tokens:
|
| 295 |
+
if vocab.is_time(tok):
|
| 296 |
+
cur_t = tok - vocab.time0
|
| 297 |
+
elif tok in vocab.id2note and cur_t is not None:
|
| 298 |
+
out.append((cur_t, vocab.id2note[tok]))
|
| 299 |
+
elif tok == vocab.eos:
|
| 300 |
+
break
|
| 301 |
+
return out
|
softchart/sc2_loader.py
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load and serve the BarScript release package.
|
| 2 |
+
|
| 3 |
+
This is the only bridge between the Space and ``softchart.sc2``. It reads a
|
| 4 |
+
release package (``config.json``, ``spec.json``, ``model.safetensors``) and
|
| 5 |
+
returns an object whose ``generate`` call produces TJA text.
|
| 6 |
+
|
| 7 |
+
The package is resolved in one order, most explicit first -- ``$SC2_PACKAGE_DIR``,
|
| 8 |
+
a local directory, then the Hub repo (see ``resolve_package_dir``). Whichever
|
| 9 |
+
source wins, the package directory is the whole source of truth from that point
|
| 10 |
+
on: every integrity check and every serving parameter is read out of it, so a
|
| 11 |
+
downloaded package and a local one are served identically or not at all.
|
| 12 |
+
|
| 13 |
+
The Hub repo is **private**, so the download needs a token. When none can be
|
| 14 |
+
found the loader says exactly that, rather than reporting a missing model --
|
| 15 |
+
a token problem and an absent package have different fixes.
|
| 16 |
+
|
| 17 |
+
Every serving parameter comes from ``config.json``. The package records not
|
| 18 |
+
just the values but the reasoning behind each one in ``config.json:
|
| 19 |
+
serving_basis``, so a constant re-typed in the Space would be a second,
|
| 20 |
+
unaudited contract. ``serving_kwargs`` therefore mirrors the package's own
|
| 21 |
+
``scripts/sc2_package.py:serving_kwargs`` and passes the recorded values
|
| 22 |
+
explicitly into the decoder rather than letting module defaults apply.
|
| 23 |
+
|
| 24 |
+
Deployment grid: a uniform ``/16`` lattice synthesized from user BPM and
|
| 25 |
+
offset, decoded with ``bar_denoms='supplied'`` -- the ``SHIP_DECISION``
|
| 26 |
+
ruling, and the ``default`` serving profile. The package also carries a
|
| 27 |
+
``deploy_bpm_grid`` profile (model-emitted denominators masked to divisors of
|
| 28 |
+
96); the model card does NOT recommend it for this checkpoint (§2.8). A
|
| 29 |
+
``/16`` grid makes triplets arithmetically impossible: 3 does not divide 16.
|
| 30 |
+
|
| 31 |
+
The returned dict always reports the grid that was actually used.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import hashlib
|
| 37 |
+
import json
|
| 38 |
+
import os
|
| 39 |
+
import re
|
| 40 |
+
import threading
|
| 41 |
+
|
| 42 |
+
import torch
|
| 43 |
+
|
| 44 |
+
from .sc2 import generate as sc2_generate
|
| 45 |
+
from .sc2.barscript import BarscriptSpec
|
| 46 |
+
from .sc2.checkpoint import (GENERATOR_CAPABILITY_KEYS,
|
| 47 |
+
attach_generator_capabilities)
|
| 48 |
+
from .sc2.generate import (SlotExportError, barscript_grid_from_bpm,
|
| 49 |
+
generate_song_barscript, require_slot_export)
|
| 50 |
+
from .sc2.model import ChartModel
|
| 51 |
+
from .sc2.tja import write_tja_slots
|
| 52 |
+
from .sc2.vocab import (COURSES, DENS_BUCKET_NPS, FPS, N_DENS, SLOTS,
|
| 53 |
+
V2_TOKEN_IDS, VOCAB, WINDOW)
|
| 54 |
+
|
| 55 |
+
CONFIG_SCHEMA = "softchart2_release_config_v1"
|
| 56 |
+
|
| 57 |
+
# The Hub repo the release is published to. PRIVATE: reading it needs a token
|
| 58 |
+
# (see resolve_token). Overridable so a fork or a re-upload does not need a code
|
| 59 |
+
# change, but the default is the published release.
|
| 60 |
+
DEFAULT_HUB_REPO = "JacobLinCool/softchart-barscript"
|
| 61 |
+
|
| 62 |
+
# The release's own directory name. This is the RUN identifier, not the public
|
| 63 |
+
# name: it is what the package, its manifest and every provenance record call
|
| 64 |
+
# this checkpoint, so it stays as built.
|
| 65 |
+
RELEASE_DIRNAME = "sc2_c1_v13_s1234_best"
|
| 66 |
+
|
| 67 |
+
# Exactly the files the release package consists of. snapshot_download is given
|
| 68 |
+
# this as its allow-list, so a repo that later grows unrelated files cannot pull
|
| 69 |
+
# them into the Space.
|
| 70 |
+
PACKAGE_FILES = ("MODEL_CARD.md", "config.json", "model.safetensors",
|
| 71 |
+
"preprocessor_config.json", "release_manifest.json",
|
| 72 |
+
"spec.json", "vocab.json")
|
| 73 |
+
|
| 74 |
+
# Files without which the package cannot be served at all.
|
| 75 |
+
REQUIRED_FILES = ("config.json", "model.safetensors", "preprocessor_config.json",
|
| 76 |
+
"spec.json", "vocab.json")
|
| 77 |
+
|
| 78 |
+
# The uniform bar lattice the release ships on (SHIP_DECISION / MODEL_CARD §3,
|
| 79 |
+
# "Recommended deployment configuration": --grid bpm --grid-bpm-denom 16).
|
| 80 |
+
SHIP_GRID_DENOM = 16
|
| 81 |
+
|
| 82 |
+
# md5 of every vendored BarScript module, as recorded by the release package in
|
| 83 |
+
# config.json:code.module_md5. Checked at load time -- see verify_code().
|
| 84 |
+
VENDORED_MODULES = ("barscript.py", "checkpoint.py", "generate.py",
|
| 85 |
+
"meter.py", "model.py", "tja.py", "trace.py", "vocab.py")
|
| 86 |
+
|
| 87 |
+
# TJA `COURSE:` header literals, measured on the corpus rather than assumed.
|
| 88 |
+
# Over the 1 155 songs of JacobLinCool/taiko-1000-parsed, 229 carry an `ura`
|
| 89 |
+
# chart, and every one of them writes that chart's header as `Edit` (223) or
|
| 90 |
+
# the numeric `4` (6). The string "Ura" appears zero times: it is the
|
| 91 |
+
# dataset's normalized course NAME, not the TJA header literal. `tja.py`
|
| 92 |
+
# derives the header as `str(course).capitalize()`, which would emit
|
| 93 |
+
# `COURSE:Ura`, so the header is rewritten here -- in code this Space owns --
|
| 94 |
+
# and the vendored module stays byte-identical to the shipping tree.
|
| 95 |
+
TJA_COURSE_HEADER = {"easy": "Easy", "normal": "Normal", "hard": "Hard",
|
| 96 |
+
"oni": "Oni", "ura": "Edit"}
|
| 97 |
+
|
| 98 |
+
# Straight from MODEL_CARD.md §2. These are the limitations a user must see
|
| 99 |
+
# before reading anything into a generated chart; the section numbers point at
|
| 100 |
+
# the measurement. Do not soften them.
|
| 101 |
+
KNOWN_LIMITATIONS = (
|
| 102 |
+
("champion", "§0",
|
| 103 |
+
"The champion comparison is INCONCLUSIVE: the challenger was run at one "
|
| 104 |
+
"seed."),
|
| 105 |
+
("nesting", "§2.1",
|
| 106 |
+
"The difficulty ladder is not nested. Independently decoded courses "
|
| 107 |
+
"share 0.658/0.667/0.660 of their notes with the next harder course; "
|
| 108 |
+
"authored charts share 0.95-0.99."),
|
| 109 |
+
("density", "§2.9",
|
| 110 |
+
"Notes come out too dense: 102 sub-40 ms gaps across 29 charts (authored: "
|
| 111 |
+
"2 across 1), 19 of them below the series record of 29.41 ms, and up to "
|
| 112 |
+
"17 notes inside one second."),
|
| 113 |
+
("micro_spans", "§2.7",
|
| 114 |
+
"One drumroll in seven is a micro-span (14.7 % vs 3.8 % authored)."),
|
| 115 |
+
("tuplets", "§2.12",
|
| 116 |
+
"Fine-lattice and tuplet placement is much worse than average: 0.345 hit "
|
| 117 |
+
"rate at positions whose denominator is divisible by 3."),
|
| 118 |
+
("grid", "§2.8",
|
| 119 |
+
"The deployment grid is a uniform /16 lattice, so triplets are "
|
| 120 |
+
"arithmetically impossible -- 3 does not divide 16."),
|
| 121 |
+
("density_knob", "§2.4",
|
| 122 |
+
"At the top of the density knob, drumrolls are squeezed out: span yield "
|
| 123 |
+
"falls to 33 % of authored. The highest setting is not recommended."),
|
| 124 |
+
("ura", "corpus",
|
| 125 |
+
"Ura was trained on 208 charts against roughly 1 100 per other course; "
|
| 126 |
+
"expect lower quality."),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class PackageError(RuntimeError):
|
| 131 |
+
"""The package directory is missing, incomplete or inconsistent."""
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
# locating the package
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def deploy_package_dir():
|
| 139 |
+
"""``spaces/models/<release>`` -- the copy that travels with the Space.
|
| 140 |
+
|
| 141 |
+
Named separately from :func:`local_candidates` so it stays available as the
|
| 142 |
+
path to quote in a message even when the candidate list is empty.
|
| 143 |
+
"""
|
| 144 |
+
spaces_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 145 |
+
return os.path.join(spaces_dir, "models", RELEASE_DIRNAME)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def local_candidates():
|
| 149 |
+
"""On-disk directories searched for the package, in order.
|
| 150 |
+
|
| 151 |
+
1. :func:`deploy_package_dir`.
|
| 152 |
+
2. the frozen build artifact in the development worktree, for local runs
|
| 153 |
+
from this checkout.
|
| 154 |
+
"""
|
| 155 |
+
spaces_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 156 |
+
repo_root = os.path.dirname(spaces_dir)
|
| 157 |
+
return (
|
| 158 |
+
deploy_package_dir(),
|
| 159 |
+
os.path.join(repo_root, ".codex", "worktrees", "v18-8h", "artifacts",
|
| 160 |
+
"sc2", RELEASE_DIRNAME),
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def default_package_dir():
|
| 165 |
+
"""The package directory reachable WITHOUT the network.
|
| 166 |
+
|
| 167 |
+
``$SC2_PACKAGE_DIR`` if set, else the first populated local candidate, else
|
| 168 |
+
the deploy location as a fallback so callers always get a path to name in a
|
| 169 |
+
message. This deliberately never downloads: it is what ``is_available``
|
| 170 |
+
and the test suites use to ask whether a package is already on disk.
|
| 171 |
+
Full resolution, Hub included, is :func:`resolve_package_dir`.
|
| 172 |
+
"""
|
| 173 |
+
env = os.environ.get("SC2_PACKAGE_DIR")
|
| 174 |
+
if env:
|
| 175 |
+
return os.path.abspath(os.path.expanduser(env))
|
| 176 |
+
for candidate in local_candidates():
|
| 177 |
+
if os.path.isfile(os.path.join(candidate, "config.json")):
|
| 178 |
+
return candidate
|
| 179 |
+
return deploy_package_dir()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
# the Hub fallback
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def hub_repo_id():
|
| 187 |
+
"""The Hub repo the release is published to."""
|
| 188 |
+
return os.environ.get("SC2_REPO_ID", DEFAULT_HUB_REPO)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def hub_enabled():
|
| 192 |
+
"""Whether the Hub is allowed as a source at all.
|
| 193 |
+
|
| 194 |
+
``SC2_NO_HUB=1`` pins the Space to on-disk packages, which is how a
|
| 195 |
+
deployment proves it is not silently depending on a download.
|
| 196 |
+
"""
|
| 197 |
+
if os.environ.get("SC2_NO_HUB", "").strip().lower() in ("1", "true", "yes"):
|
| 198 |
+
return False
|
| 199 |
+
return bool(hub_repo_id())
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def resolve_token(token=None):
|
| 203 |
+
"""A Hub token, or ``None``.
|
| 204 |
+
|
| 205 |
+
Explicit argument, then ``$HF_TOKEN`` / ``$HUGGING_FACE_HUB_TOKEN``, then
|
| 206 |
+
whatever ``hf auth login`` cached. On a Space the first is what a secret
|
| 207 |
+
named ``HF_TOKEN`` becomes; locally the last is the usual case.
|
| 208 |
+
"""
|
| 209 |
+
for candidate in (token, os.environ.get("HF_TOKEN"),
|
| 210 |
+
os.environ.get("HUGGING_FACE_HUB_TOKEN")):
|
| 211 |
+
if candidate:
|
| 212 |
+
return candidate
|
| 213 |
+
try:
|
| 214 |
+
from huggingface_hub import get_token
|
| 215 |
+
except ImportError:
|
| 216 |
+
return None
|
| 217 |
+
try:
|
| 218 |
+
return get_token() or None
|
| 219 |
+
except Exception:
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _no_token_error(repo_id):
|
| 224 |
+
return PackageError(
|
| 225 |
+
f"BarScript is published as a PRIVATE Hub repo ({repo_id}) and no "
|
| 226 |
+
"HuggingFace token was found, so its weights cannot be downloaded. "
|
| 227 |
+
"This is a credentials problem, not a missing model. Fix it in one of "
|
| 228 |
+
"three ways: (1) set $HF_TOKEN to a token with read access to "
|
| 229 |
+
f"{repo_id} -- on a Space that is Settings -> Variables and secrets -> "
|
| 230 |
+
"New secret, named HF_TOKEN; (2) run `hf auth login` on this machine; "
|
| 231 |
+
"or (3) point $SC2_PACKAGE_DIR at a local copy of the release package "
|
| 232 |
+
"and skip the download entirely. Looked in: $HF_TOKEN, "
|
| 233 |
+
"$HUGGING_FACE_HUB_TOKEN, and the HuggingFace token cache.")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _hub_download_error(repo_id, exc):
|
| 237 |
+
kind = type(exc).__name__
|
| 238 |
+
hint = ""
|
| 239 |
+
if kind in ("RepositoryNotFoundError", "GatedRepoError") \
|
| 240 |
+
or "401" in str(exc) or "403" in str(exc):
|
| 241 |
+
hint = (" The repo is PRIVATE, so this is what a token that is absent, "
|
| 242 |
+
"expired, or lacking read access on it looks like -- the Hub "
|
| 243 |
+
"reports a private repo as missing to callers who may not see "
|
| 244 |
+
"it. Check the token before concluding the repo is gone.")
|
| 245 |
+
elif kind == "LocalEntryNotFoundError" or "offline" in str(exc).lower():
|
| 246 |
+
hint = (" Offline mode is on and the package is not in the local "
|
| 247 |
+
"HuggingFace cache. Unset $HF_HUB_OFFLINE, or point "
|
| 248 |
+
"$SC2_PACKAGE_DIR at a local copy.")
|
| 249 |
+
return PackageError(
|
| 250 |
+
f"could not download the BarScript package from {repo_id}: "
|
| 251 |
+
f"{kind}: {exc}.{hint}")
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def hub_snapshot_cached(repo_id=None, revision=None):
|
| 255 |
+
"""The cached snapshot directory for the repo, or ``None``. No network."""
|
| 256 |
+
repo_id = repo_id or hub_repo_id()
|
| 257 |
+
try:
|
| 258 |
+
from huggingface_hub import try_to_load_from_cache
|
| 259 |
+
except ImportError:
|
| 260 |
+
return None
|
| 261 |
+
try:
|
| 262 |
+
hit = try_to_load_from_cache(repo_id, "config.json",
|
| 263 |
+
revision=revision or None)
|
| 264 |
+
except Exception:
|
| 265 |
+
return None
|
| 266 |
+
if isinstance(hit, str) and os.path.isfile(hit):
|
| 267 |
+
return os.path.dirname(hit)
|
| 268 |
+
return None
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def download_from_hub(repo_id=None, revision=None, token=None):
|
| 272 |
+
"""Fetch the release package from the Hub and return its directory.
|
| 273 |
+
|
| 274 |
+
Only :data:`PACKAGE_FILES` are fetched. The snapshot is then checked
|
| 275 |
+
against the ``release_manifest.json`` that travels with it, because these
|
| 276 |
+
bytes crossed a network: the manifest records the sha256 of every file as
|
| 277 |
+
built, so a truncated or substituted file is caught here rather than
|
| 278 |
+
surfacing later as an unexplained decode. Local packages are not
|
| 279 |
+
re-hashed -- this check exists for the transport, and adding it to the
|
| 280 |
+
on-disk paths would change how they already behave.
|
| 281 |
+
"""
|
| 282 |
+
repo_id = repo_id or hub_repo_id()
|
| 283 |
+
revision = revision or os.environ.get("SC2_REPO_REVISION") or None
|
| 284 |
+
resolved = resolve_token(token)
|
| 285 |
+
if resolved is None:
|
| 286 |
+
raise _no_token_error(repo_id)
|
| 287 |
+
try:
|
| 288 |
+
from huggingface_hub import snapshot_download
|
| 289 |
+
except ImportError as exc:
|
| 290 |
+
raise PackageError(
|
| 291 |
+
"huggingface_hub is required to download the BarScript package "
|
| 292 |
+
f"from {repo_id}; install it or set $SC2_PACKAGE_DIR") from exc
|
| 293 |
+
try:
|
| 294 |
+
path = snapshot_download(repo_id=repo_id, repo_type="model",
|
| 295 |
+
revision=revision, token=resolved,
|
| 296 |
+
allow_patterns=list(PACKAGE_FILES))
|
| 297 |
+
except Exception as exc: # noqa: BLE001 -- re-raised as PackageError
|
| 298 |
+
raise _hub_download_error(repo_id, exc) from exc
|
| 299 |
+
missing = [n for n in REQUIRED_FILES
|
| 300 |
+
if not os.path.isfile(os.path.join(path, n))]
|
| 301 |
+
if missing:
|
| 302 |
+
raise PackageError(
|
| 303 |
+
f"the Hub repo {repo_id} is not a complete release package; "
|
| 304 |
+
f"missing {missing}")
|
| 305 |
+
verify_manifest(path, source=repo_id)
|
| 306 |
+
return path
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def verify_manifest(package_dir, *, source="package"):
|
| 310 |
+
"""Check a package's files against its own ``release_manifest.json``.
|
| 311 |
+
|
| 312 |
+
A missing manifest is accepted -- not every package carries one -- but a
|
| 313 |
+
manifest that disagrees with the bytes beside it is refused.
|
| 314 |
+
"""
|
| 315 |
+
manifest_path = os.path.join(package_dir, "release_manifest.json")
|
| 316 |
+
if not os.path.isfile(manifest_path):
|
| 317 |
+
return None
|
| 318 |
+
with open(manifest_path, encoding="utf-8") as f:
|
| 319 |
+
manifest = json.load(f)
|
| 320 |
+
bad = []
|
| 321 |
+
for name, meta in (manifest.get("files") or {}).items():
|
| 322 |
+
path = os.path.join(package_dir, name)
|
| 323 |
+
if not os.path.isfile(path):
|
| 324 |
+
if name in REQUIRED_FILES:
|
| 325 |
+
bad.append(f"{name}: missing")
|
| 326 |
+
continue
|
| 327 |
+
size = os.path.getsize(path)
|
| 328 |
+
if size != int(meta["size_bytes"]):
|
| 329 |
+
bad.append(f"{name}: {size} bytes, manifest says {meta['size_bytes']}")
|
| 330 |
+
continue
|
| 331 |
+
digest = hashlib.sha256()
|
| 332 |
+
with open(path, "rb") as f:
|
| 333 |
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
| 334 |
+
digest.update(chunk)
|
| 335 |
+
if digest.hexdigest() != meta["sha256"]:
|
| 336 |
+
bad.append(f"{name}: sha256 {digest.hexdigest()[:16]} != "
|
| 337 |
+
f"{meta['sha256'][:16]}")
|
| 338 |
+
if bad:
|
| 339 |
+
raise PackageError(
|
| 340 |
+
f"the release package from {source} does not match its own "
|
| 341 |
+
"release_manifest.json: " + "; ".join(bad))
|
| 342 |
+
return manifest
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def resolve_package_dir(*, allow_hub=True, token=None):
|
| 346 |
+
"""``(package_dir, source)`` -- the full resolution order.
|
| 347 |
+
|
| 348 |
+
1. ``$SC2_PACKAGE_DIR`` -- an explicit request. If it holds no package this
|
| 349 |
+
RAISES rather than falling through: someone who names a directory wants
|
| 350 |
+
that directory, and silently downloading a different copy instead would
|
| 351 |
+
hide the typo.
|
| 352 |
+
2. a populated local candidate (:func:`local_candidates`).
|
| 353 |
+
3. the Hub repo, downloaded (:func:`download_from_hub`).
|
| 354 |
+
|
| 355 |
+
``source`` is one of ``"env"``, ``"local"``, ``"hub"`` and is reported by
|
| 356 |
+
``SoftChart2.describe`` so a running Space can say where its weights came
|
| 357 |
+
from.
|
| 358 |
+
"""
|
| 359 |
+
env = os.environ.get("SC2_PACKAGE_DIR")
|
| 360 |
+
if env:
|
| 361 |
+
path = os.path.abspath(os.path.expanduser(env))
|
| 362 |
+
if not os.path.isfile(os.path.join(path, "config.json")):
|
| 363 |
+
raise PackageError(
|
| 364 |
+
f"$SC2_PACKAGE_DIR points at {path!r}, which has no "
|
| 365 |
+
"config.json. Unset it to fall back to a local copy or the "
|
| 366 |
+
f"Hub repo {hub_repo_id()}, or point it at a release package.")
|
| 367 |
+
return path, "env"
|
| 368 |
+
for candidate in local_candidates():
|
| 369 |
+
if os.path.isfile(os.path.join(candidate, "config.json")):
|
| 370 |
+
return candidate, "local"
|
| 371 |
+
if allow_hub and hub_enabled():
|
| 372 |
+
return download_from_hub(token=token), "hub"
|
| 373 |
+
raise PackageError(
|
| 374 |
+
"no BarScript release package found. Searched $SC2_PACKAGE_DIR and "
|
| 375 |
+
f"{list(local_candidates())}"
|
| 376 |
+
+ ("; the Hub fallback is disabled by $SC2_NO_HUB."
|
| 377 |
+
if not hub_enabled() else "; the Hub fallback was not attempted."))
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# ---------------------------------------------------------------------------
|
| 381 |
+
# integrity
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _canonical_sha256(value):
|
| 385 |
+
payload = json.dumps(value, ensure_ascii=False, sort_keys=True,
|
| 386 |
+
separators=(",", ":"), allow_nan=False).encode("utf-8")
|
| 387 |
+
return hashlib.sha256(payload).hexdigest()
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _file_md5(path):
|
| 391 |
+
h = hashlib.md5()
|
| 392 |
+
with open(path, "rb") as f:
|
| 393 |
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
| 394 |
+
h.update(chunk)
|
| 395 |
+
return h.hexdigest()
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def vocab_tokens():
|
| 399 |
+
"""``tokens_by_id`` rebuilt from the VENDORED vocabulary.
|
| 400 |
+
|
| 401 |
+
Mirrors ``scripts/sc2_package.py:vocab_contract`` field for field, so its
|
| 402 |
+
sha256 can be compared with the one the package recorded. A mismatch
|
| 403 |
+
means the vendored ``vocab.py`` is not the table this checkpoint's
|
| 404 |
+
embedding rows were trained against, and the ids in every prefix would be
|
| 405 |
+
silently wrong.
|
| 406 |
+
"""
|
| 407 |
+
tokens = [None] * VOCAB.size
|
| 408 |
+
|
| 409 |
+
def assign(token_id, name):
|
| 410 |
+
token_id = int(token_id)
|
| 411 |
+
if not 0 <= token_id < VOCAB.size:
|
| 412 |
+
raise PackageError(f"token id {token_id} out of range")
|
| 413 |
+
if tokens[token_id] is not None:
|
| 414 |
+
raise PackageError(f"duplicate token id {token_id}")
|
| 415 |
+
tokens[token_id] = name
|
| 416 |
+
|
| 417 |
+
for name in ("pad", "eos", "bos", "unk_cond"):
|
| 418 |
+
assign(getattr(VOCAB, name), name.upper())
|
| 419 |
+
for prefix, mapping in (("COURSE", VOCAB.course), ("LEVEL", VOCAB.level),
|
| 420 |
+
("DENS", VOCAB.dens), ("NOTE", VOCAB.note)):
|
| 421 |
+
for value, token_id in mapping.items():
|
| 422 |
+
assign(token_id, f"{prefix}_{value}")
|
| 423 |
+
for frame in range(WINDOW):
|
| 424 |
+
assign(VOCAB.time0 + frame, f"TIME_{frame}")
|
| 425 |
+
assign(VOCAB.sep, "SEP")
|
| 426 |
+
assign(VOCAB.sib, "SIB")
|
| 427 |
+
for prefix, mapping in (("STYLE", VOCAB.style), ("SYNC", VOCAB.sync),
|
| 428 |
+
("PDENS", VOCAB.pdens), ("PFLAG", VOCAB.pflag),
|
| 429 |
+
("CPLX", VOCAB.cplx), ("MODE", VOCAB.mode)):
|
| 430 |
+
for value, token_id in mapping.items():
|
| 431 |
+
assign(token_id, f"{prefix}_{value}")
|
| 432 |
+
assign(VOCAB.plan, "PLAN")
|
| 433 |
+
assign(VOCAB.mask, "MASK")
|
| 434 |
+
for name, token_id in V2_TOKEN_IDS.items():
|
| 435 |
+
assign(token_id, name)
|
| 436 |
+
missing = [i for i, t in enumerate(tokens) if t is None]
|
| 437 |
+
if missing:
|
| 438 |
+
raise PackageError(f"vocabulary mapping has unmapped ids: {missing[:8]}")
|
| 439 |
+
return tokens
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def verify_code(config):
|
| 443 |
+
"""Refuse to serve with vendored modules that drifted from the package.
|
| 444 |
+
|
| 445 |
+
The package's numbers describe ONE tree of serving code, identified by md5
|
| 446 |
+
in ``config.json:code.module_md5``. Vendored copies that no longer match
|
| 447 |
+
are a different decoder, and nothing the model card says would still apply.
|
| 448 |
+
"""
|
| 449 |
+
recorded = config.get("code", {}).get("module_md5", {})
|
| 450 |
+
here = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sc2")
|
| 451 |
+
bad = []
|
| 452 |
+
for name in VENDORED_MODULES:
|
| 453 |
+
want = recorded.get(f"src/softchart/{name}")
|
| 454 |
+
if want is None:
|
| 455 |
+
bad.append(f"{name}: package records no md5")
|
| 456 |
+
continue
|
| 457 |
+
got = _file_md5(os.path.join(here, name))
|
| 458 |
+
if got != want:
|
| 459 |
+
bad.append(f"{name}: {got} != {want}")
|
| 460 |
+
if bad:
|
| 461 |
+
raise PackageError(
|
| 462 |
+
"vendored BarScript serving code does not match the release "
|
| 463 |
+
"package; re-copy from the frozen shipping tree: " + "; ".join(bad))
|
| 464 |
+
return {f"softchart/sc2/{n}": recorded[f"src/softchart/{n}"]
|
| 465 |
+
for n in VENDORED_MODULES}
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def verify_vocab(config, package_dir):
|
| 469 |
+
"""Cross-check the vendored token table against the package's record."""
|
| 470 |
+
tokens = vocab_tokens()
|
| 471 |
+
got = _canonical_sha256(tokens)
|
| 472 |
+
want = config["vocab"]["mapping_sha256"]
|
| 473 |
+
if got != want:
|
| 474 |
+
raise PackageError(
|
| 475 |
+
f"vendored vocab mapping sha256 {got} != package {want}")
|
| 476 |
+
if int(config["vocab"]["size"]) != VOCAB.size:
|
| 477 |
+
raise PackageError(
|
| 478 |
+
f"vendored vocab size {VOCAB.size} != package "
|
| 479 |
+
f"{config['vocab']['size']}")
|
| 480 |
+
shipped = os.path.join(package_dir, "vocab.json")
|
| 481 |
+
if os.path.isfile(shipped):
|
| 482 |
+
with open(shipped, encoding="utf-8") as f:
|
| 483 |
+
if json.load(f)["tokens_by_id"] != tokens:
|
| 484 |
+
raise PackageError("vocab.json disagrees with the vendored "
|
| 485 |
+
"token table")
|
| 486 |
+
if int(config["vocab"]["window_frames"]) != WINDOW \
|
| 487 |
+
or int(config["vocab"]["slots_per_4_4"]) != SLOTS:
|
| 488 |
+
raise PackageError("vendored WINDOW/SLOTS disagree with the package")
|
| 489 |
+
return got
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def preprocessing_contract(package_dir):
|
| 493 |
+
"""The audio front end the package was trained and evaluated under."""
|
| 494 |
+
path = os.path.join(package_dir, "preprocessor_config.json")
|
| 495 |
+
with open(path, encoding="utf-8") as f:
|
| 496 |
+
return json.load(f)
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def check_preprocessing(package_dir, *, sr, n_fft, hop_length, n_mels,
|
| 500 |
+
fmin, fmax):
|
| 501 |
+
"""Assert a caller's log-mel front end matches the package contract.
|
| 502 |
+
|
| 503 |
+
The Space computes its own mel (``app.load_logmel``); if any parameter
|
| 504 |
+
drifts, the encoder sees a distribution it never trained on and the chart
|
| 505 |
+
degrades silently. Raises ``PackageError`` listing every mismatch.
|
| 506 |
+
"""
|
| 507 |
+
c = preprocessing_contract(package_dir)
|
| 508 |
+
want = {
|
| 509 |
+
"sample_rate_hz": float(c["audio"]["sample_rate_hz"]),
|
| 510 |
+
"n_fft": float(c["stft"]["n_fft"]),
|
| 511 |
+
"hop_length": float(c["stft"]["hop_length"]),
|
| 512 |
+
"n_mels": float(c["mel"]["n_mels"]),
|
| 513 |
+
"fmin_hz": float(c["mel"]["fmin_hz"]),
|
| 514 |
+
"fmax_hz": float(c["mel"]["fmax_hz"]),
|
| 515 |
+
}
|
| 516 |
+
got = {"sample_rate_hz": float(sr), "n_fft": float(n_fft),
|
| 517 |
+
"hop_length": float(hop_length), "n_mels": float(n_mels),
|
| 518 |
+
"fmin_hz": float(fmin), "fmax_hz": float(fmax)}
|
| 519 |
+
bad = [f"{k}: {got[k]} != {want[k]}" for k in want if got[k] != want[k]]
|
| 520 |
+
if bad:
|
| 521 |
+
raise PackageError("log-mel front end disagrees with the package "
|
| 522 |
+
"contract: " + "; ".join(bad))
|
| 523 |
+
return c
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# ---------------------------------------------------------------------------
|
| 527 |
+
# the serving contract, read from the package
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def serving_kwargs(config, profile="default"):
|
| 531 |
+
"""``generate_song_barscript`` keyword arguments this release ships.
|
| 532 |
+
|
| 533 |
+
Field-for-field the same function as ``scripts/sc2_package.py:
|
| 534 |
+
serving_kwargs``: values are passed EXPLICITLY so the contract the package
|
| 535 |
+
recorded is the one that reaches the decoder, and never a module default
|
| 536 |
+
that happens to agree today.
|
| 537 |
+
"""
|
| 538 |
+
profiles = config.get("serving_profiles") or {"default": {"overrides": {}}}
|
| 539 |
+
if profile not in profiles:
|
| 540 |
+
raise PackageError(f"unknown serving profile {profile!r}; "
|
| 541 |
+
f"have {sorted(profiles)}")
|
| 542 |
+
s = dict(config["serving"], **profiles[profile].get("overrides", {}))
|
| 543 |
+
return {
|
| 544 |
+
"span_min_sec": s["span_min_sec"],
|
| 545 |
+
"span_min_beats": s["span_min_beats"],
|
| 546 |
+
"degenerate_bar_sec": s["degenerate_bar_sec"],
|
| 547 |
+
"min_onset_gap_sec": s["min_onset_gap_sec"],
|
| 548 |
+
"bar_denoms": s["bar_denoms"],
|
| 549 |
+
"bar_denom_mask": s["bar_denom_mask"],
|
| 550 |
+
"greedy": s["greedy"],
|
| 551 |
+
"temperature": s["temperature"],
|
| 552 |
+
"plan_temperature": s["plan_temperature"],
|
| 553 |
+
"top_p": s["top_p"],
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
class MotifConstraint:
|
| 558 |
+
"""Serve the motif-hard value the package RESOLVED, not a module default.
|
| 559 |
+
|
| 560 |
+
``generate._decode_barscript_window`` constructs ``BarscriptFSM(spec)``
|
| 561 |
+
and exposes no parameter, so the module default would otherwise be the
|
| 562 |
+
only value a decode could get. The vendored ``generate.py`` is
|
| 563 |
+
byte-identical to the shipping tree and stays that way, so the serving
|
| 564 |
+
value is injected by wrapping ``BarscriptFSM.__init__`` for the duration
|
| 565 |
+
of the decode -- the same mechanism, for the same reason, as
|
| 566 |
+
``scripts/sc2_package.py`` and ``scripts/sc2_generate_eval.py``. The
|
| 567 |
+
class object is untouched, so ``isinstance`` checks and any caller that
|
| 568 |
+
passes ``motif_hard`` explicitly keep working.
|
| 569 |
+
|
| 570 |
+
``observed_state`` reports what was SERVED, so a decode that never built
|
| 571 |
+
an FSM cannot be reported as compliant.
|
| 572 |
+
"""
|
| 573 |
+
|
| 574 |
+
def __init__(self, motif_hard):
|
| 575 |
+
self.motif_hard = bool(motif_hard)
|
| 576 |
+
self.n_constructed = 0
|
| 577 |
+
self.observed = set()
|
| 578 |
+
|
| 579 |
+
def __enter__(self):
|
| 580 |
+
from .sc2 import barscript as bs_mod
|
| 581 |
+
|
| 582 |
+
self._mod = bs_mod
|
| 583 |
+
self._orig = bs_mod.BarscriptFSM.__init__
|
| 584 |
+
orig, owner = self._orig, self
|
| 585 |
+
|
| 586 |
+
def init(fsm, spec, vocab=bs_mod.VOCAB, motif_hard=None):
|
| 587 |
+
hard = owner.motif_hard if motif_hard is None else bool(motif_hard)
|
| 588 |
+
owner.n_constructed += 1
|
| 589 |
+
owner.observed.add(hard)
|
| 590 |
+
orig(fsm, spec, vocab, hard)
|
| 591 |
+
|
| 592 |
+
bs_mod.BarscriptFSM.__init__ = init
|
| 593 |
+
return self
|
| 594 |
+
|
| 595 |
+
def __exit__(self, *exc):
|
| 596 |
+
self._mod.BarscriptFSM.__init__ = self._orig
|
| 597 |
+
return False
|
| 598 |
+
|
| 599 |
+
def observed_state(self):
|
| 600 |
+
return {"n_fsm_constructed": self.n_constructed,
|
| 601 |
+
"observed_motif_hard": sorted(self.observed),
|
| 602 |
+
"verified": bool(self.n_constructed > 0
|
| 603 |
+
and self.observed == {self.motif_hard})}
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def motif_context(config):
|
| 607 |
+
"""The serving FSM configuration this package resolved at build time."""
|
| 608 |
+
return MotifConstraint(
|
| 609 |
+
config["train_provenance"]["motif_constraint"]["resolved_motif_hard"])
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
# ---------------------------------------------------------------------------
|
| 613 |
+
# loading
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def load_release(package_dir, device="cpu"):
|
| 617 |
+
"""Rebuild the model from a package, reading nothing outside it.
|
| 618 |
+
|
| 619 |
+
Returns ``(model, config, spec)``. Ports ``scripts/sc2_package.py:
|
| 620 |
+
load_release`` onto the vendored namespace; the checkpoint file, the
|
| 621 |
+
architecture arguments, the capability flags and the label spec all come
|
| 622 |
+
from ``package_dir``.
|
| 623 |
+
"""
|
| 624 |
+
package_dir = os.path.abspath(package_dir)
|
| 625 |
+
config_path = os.path.join(package_dir, "config.json")
|
| 626 |
+
if not os.path.isfile(config_path):
|
| 627 |
+
raise PackageError(
|
| 628 |
+
f"no BarScript package at {package_dir!r} (config.json "
|
| 629 |
+
"missing). Point $SC2_PACKAGE_DIR at a release directory, or "
|
| 630 |
+
f"leave it unset to download {hub_repo_id()} from the Hub.")
|
| 631 |
+
with open(config_path, encoding="utf-8") as f:
|
| 632 |
+
config = json.load(f)
|
| 633 |
+
if config.get("schema_version") != CONFIG_SCHEMA:
|
| 634 |
+
raise PackageError(
|
| 635 |
+
f"unsupported package schema {config.get('schema_version')!r}")
|
| 636 |
+
|
| 637 |
+
verify_code(config)
|
| 638 |
+
verify_vocab(config, package_dir)
|
| 639 |
+
|
| 640 |
+
from safetensors.torch import load_file
|
| 641 |
+
|
| 642 |
+
weights = os.path.join(package_dir, config["weights"]["file"])
|
| 643 |
+
state = load_file(weights)
|
| 644 |
+
# Weights ship bfloat16 (config.weights.cast_from == float32); the forward
|
| 645 |
+
# pass runs under autocast, so they are restored to float32 storage, as
|
| 646 |
+
# the package's own loader does.
|
| 647 |
+
state = {k: (v.to(torch.float32) if v.is_floating_point() else v)
|
| 648 |
+
for k, v in state.items()}
|
| 649 |
+
model = ChartModel(**config["model"]).to(device)
|
| 650 |
+
model.load_state_dict(state, strict=True)
|
| 651 |
+
model.eval()
|
| 652 |
+
|
| 653 |
+
caps = dict(config["capabilities"])
|
| 654 |
+
missing = sorted(set(GENERATOR_CAPABILITY_KEYS) - set(caps))
|
| 655 |
+
if missing:
|
| 656 |
+
raise PackageError(f"package capabilities are incomplete: {missing}")
|
| 657 |
+
attach_generator_capabilities(model, caps)
|
| 658 |
+
model._has_axis_knobs = bool(caps.get("axis_knobs", False))
|
| 659 |
+
# config.serving_prefix_fix: train.py records sync_token=False while the
|
| 660 |
+
# training prefix carries the SYNC slot. A loader that trusts the recorded
|
| 661 |
+
# flag drops the slot and every sync bucket reaches the decoder as an
|
| 662 |
+
# identical prefix.
|
| 663 |
+
model._has_sync = True
|
| 664 |
+
|
| 665 |
+
spec = BarscriptSpec.load(os.path.join(package_dir, "spec.json"))
|
| 666 |
+
return model, config, spec
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
# ---------------------------------------------------------------------------
|
| 670 |
+
# the interface the app calls
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
def _course_header(text, course):
|
| 674 |
+
header = TJA_COURSE_HEADER.get(str(course).lower())
|
| 675 |
+
if header is None:
|
| 676 |
+
return text
|
| 677 |
+
out, n = re.subn(r"(?m)^COURSE:.*$", f"COURSE:{header}", text, count=1)
|
| 678 |
+
if n != 1:
|
| 679 |
+
raise PackageError("exported TJA has no COURSE header to normalize")
|
| 680 |
+
return out
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
class SoftChart2:
|
| 684 |
+
"""A loaded BarScript release, ready to generate.
|
| 685 |
+
|
| 686 |
+
Construct via :func:`load` (which caches by directory). ``generate`` is
|
| 687 |
+
the whole serving surface.
|
| 688 |
+
"""
|
| 689 |
+
|
| 690 |
+
def __init__(self, model, config, spec, package_dir, device):
|
| 691 |
+
self.model = model
|
| 692 |
+
self.config = config
|
| 693 |
+
self.spec = spec
|
| 694 |
+
self.package_dir = package_dir
|
| 695 |
+
self.device = device
|
| 696 |
+
# Where the package came from: "env", "local", "hub" or "explicit".
|
| 697 |
+
# Set by load(); a directly constructed instance has not been resolved.
|
| 698 |
+
self.source = "explicit"
|
| 699 |
+
self._lock = threading.Lock()
|
| 700 |
+
|
| 701 |
+
# -- description ------------------------------------------------------
|
| 702 |
+
|
| 703 |
+
@property
|
| 704 |
+
def release_name(self):
|
| 705 |
+
return self.config.get("release_name", "sc2")
|
| 706 |
+
|
| 707 |
+
@property
|
| 708 |
+
def parameters(self):
|
| 709 |
+
return int(self.config["weights"]["parameters"])
|
| 710 |
+
|
| 711 |
+
@property
|
| 712 |
+
def courses(self):
|
| 713 |
+
return list(COURSES)
|
| 714 |
+
|
| 715 |
+
def serving(self, profile="default"):
|
| 716 |
+
return serving_kwargs(self.config, profile)
|
| 717 |
+
|
| 718 |
+
def limitations(self):
|
| 719 |
+
"""``[(key, section, text)]`` -- MODEL_CARD §2, for the UI."""
|
| 720 |
+
return list(KNOWN_LIMITATIONS)
|
| 721 |
+
|
| 722 |
+
def describe(self):
|
| 723 |
+
return {
|
| 724 |
+
"release_name": self.release_name,
|
| 725 |
+
"parameters": self.parameters,
|
| 726 |
+
"experimental": True,
|
| 727 |
+
"package_dir": self.package_dir,
|
| 728 |
+
"package_source": self.source,
|
| 729 |
+
"hub_repo": hub_repo_id() if self.source == "hub" else None,
|
| 730 |
+
"source_seed": self.config.get("source", {}).get("seed"),
|
| 731 |
+
"source_step": self.config.get("source", {}).get("step"),
|
| 732 |
+
"weights_dtype": self.config["weights"]["dtype"],
|
| 733 |
+
"grid": {"source": "bpm", "denom": SHIP_GRID_DENOM,
|
| 734 |
+
"bar_denoms": self.serving()["bar_denoms"]},
|
| 735 |
+
"serving": self.serving(),
|
| 736 |
+
"motif_hard": self.config["train_provenance"][
|
| 737 |
+
"motif_constraint"]["resolved_motif_hard"],
|
| 738 |
+
"limitations": self.limitations(),
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
# -- conditions -------------------------------------------------------
|
| 742 |
+
|
| 743 |
+
def density_bucket_from_nps(self, nps):
|
| 744 |
+
"""Notes-per-second -> DENS bucket, using the package's bucket width.
|
| 745 |
+
|
| 746 |
+
MODEL_CARD §2.5: absolute bucket calibration is off on the density
|
| 747 |
+
axis, so treat this as a knob, not a promise of a note count.
|
| 748 |
+
"""
|
| 749 |
+
width = float(preprocessing_contract(self.package_dir).get(
|
| 750 |
+
"density_bucket_nps", DENS_BUCKET_NPS))
|
| 751 |
+
return int(min(N_DENS - 1, max(0, int(float(nps) / width))))
|
| 752 |
+
|
| 753 |
+
# -- generation -------------------------------------------------------
|
| 754 |
+
|
| 755 |
+
def generate(self, mel, course, level=None, *, bpm,
|
| 756 |
+
offset_sec=0.0, duration_sec=None, density_bucket=None,
|
| 757 |
+
density_nps=None, meter=(4, 4), grid_denom=SHIP_GRID_DENOM,
|
| 758 |
+
seed=0, big_rate=None, span_rate=None, stream=None,
|
| 759 |
+
sync_band=None, title="SoftChart", wave="song.ogg",
|
| 760 |
+
balloon_count=10, profile="default", out_path=None,
|
| 761 |
+
on_progress=None, **overrides):
|
| 762 |
+
"""Generate one chart and return it as TJA text plus provenance.
|
| 763 |
+
|
| 764 |
+
``mel`` (n_mels, T) natural-log mel, the front end described by
|
| 765 |
+
``preprocessor_config.json`` (see ``check_preprocessing``).
|
| 766 |
+
``course`` one of ``COURSES`` (easy/normal/hard/oni/ura), or ``None``
|
| 767 |
+
to decode unconditioned.
|
| 768 |
+
``level`` TJA level, clamped to 1..12 by the prefix builder.
|
| 769 |
+
``bpm`` tempo of the synthesized grid.
|
| 770 |
+
``offset_sec`` audio time of the FIRST DOWNBEAT, in seconds. This is
|
| 771 |
+
the negative of the TJA ``OFFSET`` field.
|
| 772 |
+
``duration_sec`` defaults to ``mel.shape[1] / FPS``.
|
| 773 |
+
``density_bucket`` DENS bucket 0..15, or ``density_nps`` in notes per
|
| 774 |
+
second, which is bucketed with the package's width.
|
| 775 |
+
``grid_denom`` bar-local lattice denominator teacher-forced into every
|
| 776 |
+
[BAR] header. 16 is the shipped deployment grid.
|
| 777 |
+
``overrides`` serving parameters (``temperature``, ``top_p``,
|
| 778 |
+
``min_onset_gap_sec``, ...). The package's recorded
|
| 779 |
+
values apply unless one is named here; anything overridden
|
| 780 |
+
is reported back under ``serving_overrides``, because the
|
| 781 |
+
model card's numbers no longer describe that decode.
|
| 782 |
+
|
| 783 |
+
Returns a dict: ``tja`` (text), ``grid`` (the lattice actually used),
|
| 784 |
+
``serving`` (the kwargs that reached the decoder), ``serving_overrides``,
|
| 785 |
+
``motif`` (what the FSM was actually served), ``counters`` (decoder
|
| 786 |
+
clamp/floor tallies), ``gen`` (the raw generation), plus ``course``,
|
| 787 |
+
``level``, ``density_bucket``, ``seed``, ``n_notes``, ``n_spans``,
|
| 788 |
+
``duration_sec`` and ``release_name``.
|
| 789 |
+
|
| 790 |
+
Raises ``SlotExportError`` when a decoded position does not sit on the
|
| 791 |
+
96-slot TJA export lattice; the generation is intact but no TJA can be
|
| 792 |
+
written for it.
|
| 793 |
+
"""
|
| 794 |
+
if course is not None and str(course).lower() not in COURSES:
|
| 795 |
+
raise ValueError(f"unknown course {course!r}; "
|
| 796 |
+
f"choose one of {COURSES}")
|
| 797 |
+
course = None if course is None else str(course).lower()
|
| 798 |
+
if mel.ndim != 2:
|
| 799 |
+
raise ValueError("mel must be (n_mels, T)")
|
| 800 |
+
if duration_sec is None:
|
| 801 |
+
duration_sec = mel.shape[1] / FPS
|
| 802 |
+
if density_bucket is None and density_nps is not None:
|
| 803 |
+
density_bucket = self.density_bucket_from_nps(density_nps)
|
| 804 |
+
if density_bucket is not None:
|
| 805 |
+
density_bucket = int(density_bucket)
|
| 806 |
+
if not 0 <= density_bucket < N_DENS:
|
| 807 |
+
raise ValueError(f"density_bucket must be in [0, {N_DENS})")
|
| 808 |
+
|
| 809 |
+
kwargs = serving_kwargs(self.config, profile)
|
| 810 |
+
unknown = sorted(set(overrides) - set(kwargs))
|
| 811 |
+
if unknown:
|
| 812 |
+
raise ValueError(f"not serving parameters: {unknown}")
|
| 813 |
+
applied = {k: v for k, v in overrides.items()
|
| 814 |
+
if k in kwargs and v is not None and v != kwargs[k]}
|
| 815 |
+
kwargs.update({k: v for k, v in overrides.items() if v is not None})
|
| 816 |
+
|
| 817 |
+
grid = barscript_grid_from_bpm(bpm, float(offset_sec),
|
| 818 |
+
float(duration_sec), meter=meter,
|
| 819 |
+
denom=int(grid_denom))
|
| 820 |
+
grid_info = {
|
| 821 |
+
"source": "bpm",
|
| 822 |
+
"denom": int(grid_denom),
|
| 823 |
+
"bpm": float(bpm),
|
| 824 |
+
"offset_sec": float(offset_sec),
|
| 825 |
+
"duration_sec": float(duration_sec),
|
| 826 |
+
"meter": [int(meter[0]), int(meter[1])],
|
| 827 |
+
"n_bars": len(grid["bar_denoms"]),
|
| 828 |
+
"bar_denoms": kwargs["bar_denoms"],
|
| 829 |
+
"bar_denom_mask": kwargs["bar_denom_mask"],
|
| 830 |
+
"triplets_representable": (int(grid_denom) % 3 == 0),
|
| 831 |
+
"profile": profile,
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
# config.serving_basis: span_bucket0_profile is null in this release
|
| 835 |
+
# and is a KNOWN GAP (§2.7), not a tuned choice. A package that ever
|
| 836 |
+
# ships one needs a builder here, so refuse rather than drop it.
|
| 837 |
+
if self.config["serving"].get("span_bucket0_profile") is not None:
|
| 838 |
+
raise PackageError(
|
| 839 |
+
"package ships a span_bucket0_profile; this loader cannot "
|
| 840 |
+
"build one and will not serve without it")
|
| 841 |
+
|
| 842 |
+
motif = motif_context(self.config)
|
| 843 |
+
with self._lock:
|
| 844 |
+
with motif:
|
| 845 |
+
gen = generate_song_barscript(
|
| 846 |
+
self.model, mel, grid, course, level=level,
|
| 847 |
+
density_bucket=density_bucket, spec=self.spec,
|
| 848 |
+
device=self.device, seed=int(seed), sync_band=sync_band,
|
| 849 |
+
big_rate=big_rate, span_rate=span_rate, stream=stream,
|
| 850 |
+
span_bucket0_profile=None,
|
| 851 |
+
on_progress=on_progress, **kwargs)
|
| 852 |
+
|
| 853 |
+
require_slot_export(gen)
|
| 854 |
+
tja = write_tja_slots(gen, grid, title=title, course=course or "oni",
|
| 855 |
+
level=level or 0, wave=wave,
|
| 856 |
+
balloon_count=balloon_count)
|
| 857 |
+
tja = _course_header(tja, course or "oni")
|
| 858 |
+
if out_path:
|
| 859 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 860 |
+
f.write(tja)
|
| 861 |
+
|
| 862 |
+
return {
|
| 863 |
+
"tja": tja,
|
| 864 |
+
"grid": grid_info,
|
| 865 |
+
"serving": kwargs,
|
| 866 |
+
"serving_overrides": applied,
|
| 867 |
+
"motif": motif.observed_state(),
|
| 868 |
+
"counters": {k: int(v) for k, v in gen["counters"].items()},
|
| 869 |
+
"gen": gen,
|
| 870 |
+
"course": course,
|
| 871 |
+
"level": level,
|
| 872 |
+
"density_bucket": density_bucket,
|
| 873 |
+
"seed": int(seed),
|
| 874 |
+
"n_notes": len(gen["hits"]),
|
| 875 |
+
"n_spans": len(gen["spans"]),
|
| 876 |
+
"duration_sec": float(duration_sec),
|
| 877 |
+
"release_name": self.release_name,
|
| 878 |
+
"experimental": True,
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
_LOADED = {}
|
| 883 |
+
_LOAD_LOCK = threading.Lock()
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def load(package_dir=None, device="cpu", *, token=None):
|
| 887 |
+
"""Load (and cache) the release package.
|
| 888 |
+
|
| 889 |
+
With no ``package_dir`` the source is chosen by :func:`resolve_package_dir`,
|
| 890 |
+
which may download from the Hub. Either way the package that wins is put
|
| 891 |
+
through the same gate: ``load_release`` verifies the eight vendored serving
|
| 892 |
+
modules by md5 against ``config.json:code.module_md5``, cross-checks the
|
| 893 |
+
vendored token table, and rejects an unknown package schema.
|
| 894 |
+
"""
|
| 895 |
+
if package_dir is None:
|
| 896 |
+
package_dir, source = resolve_package_dir(token=token)
|
| 897 |
+
else:
|
| 898 |
+
package_dir, source = os.path.abspath(package_dir), "explicit"
|
| 899 |
+
key = (package_dir, device)
|
| 900 |
+
with _LOAD_LOCK:
|
| 901 |
+
if key not in _LOADED:
|
| 902 |
+
model, config, spec = load_release(package_dir, device=device)
|
| 903 |
+
release = SoftChart2(model, config, spec, package_dir, device)
|
| 904 |
+
release.source = source
|
| 905 |
+
_LOADED[key] = release
|
| 906 |
+
return _LOADED[key]
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
def is_available(package_dir=None):
|
| 910 |
+
"""Whether the model can be offered, without loading any weights.
|
| 911 |
+
|
| 912 |
+
True when a package is on disk, and also when the Hub fallback has a real
|
| 913 |
+
chance of producing one -- the repo is already cached, or a token exists and
|
| 914 |
+
the process is not offline. Offering the entry on a resolvable-but-not-yet-
|
| 915 |
+
downloaded Hub is deliberate: the failure a user then sees names the missing
|
| 916 |
+
token, whereas hiding the entry would tell them the model does not exist.
|
| 917 |
+
"""
|
| 918 |
+
if package_dir is not None:
|
| 919 |
+
return os.path.isfile(os.path.join(package_dir, "config.json"))
|
| 920 |
+
if os.path.isfile(os.path.join(default_package_dir(), "config.json")):
|
| 921 |
+
return True
|
| 922 |
+
if os.environ.get("SC2_PACKAGE_DIR"):
|
| 923 |
+
# An explicit request that is not satisfiable: do not quietly answer it
|
| 924 |
+
# from the Hub. resolve_package_dir raises with the path that was named.
|
| 925 |
+
return False
|
| 926 |
+
if not hub_enabled():
|
| 927 |
+
return False
|
| 928 |
+
if hub_snapshot_cached() is not None:
|
| 929 |
+
return True
|
| 930 |
+
offline = os.environ.get("HF_HUB_OFFLINE", "").strip().lower() \
|
| 931 |
+
in ("1", "true", "yes")
|
| 932 |
+
return (not offline) and resolve_token() is not None
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
__all__ = ["SoftChart2", "SlotExportError", "PackageError", "load",
|
| 936 |
+
"load_release", "is_available", "default_package_dir",
|
| 937 |
+
"local_candidates", "resolve_package_dir", "download_from_hub",
|
| 938 |
+
"hub_repo_id", "hub_enabled", "hub_snapshot_cached",
|
| 939 |
+
"resolve_token", "verify_manifest",
|
| 940 |
+
"serving_kwargs", "motif_context", "check_preprocessing",
|
| 941 |
+
"preprocessing_contract", "verify_code", "verify_vocab",
|
| 942 |
+
"vocab_tokens", "KNOWN_LIMITATIONS", "TJA_COURSE_HEADER",
|
| 943 |
+
"SHIP_GRID_DENOM", "DEFAULT_HUB_REPO", "PACKAGE_FILES",
|
| 944 |
+
"REQUIRED_FILES", "sc2_generate"]
|
softchart/tja.py
CHANGED
|
@@ -1,143 +1,114 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
No quantization happens here — generate_song_slot() already produced exact
|
| 4 |
-
lattice positions (
|
| 5 |
-
Preview rendering and the .tja are identical by construction.
|
| 6 |
-
|
| 7 |
"""
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
|
| 11 |
-
from .meter import resolve_meter_layout
|
| 12 |
from .vocab import SLOTS
|
| 13 |
|
| 14 |
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 15 |
"roll": "5", "roll_big": "6", "balloon": "7"}
|
| 16 |
|
| 17 |
|
| 18 |
-
def
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
if
|
| 22 |
-
|
| 23 |
-
if
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
-
def
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
gen_has_layout = any(name in gen for name in fields)
|
| 44 |
-
grid_has_layout = any(name in grid for name in fields) \
|
| 45 |
-
or "meter" in grid or "beats_per_measure" in grid
|
| 46 |
-
gen_layout = resolve_meter_layout(gen, n_meas) if gen_has_layout else None
|
| 47 |
-
grid_layout = resolve_meter_layout(grid, n_meas) if grid_has_layout else None
|
| 48 |
-
if gen_layout is not None and grid_layout is not None:
|
| 49 |
-
gen_slots, gen_num, gen_den, _ = gen_layout
|
| 50 |
-
grid_slots, grid_num, grid_den, _ = grid_layout
|
| 51 |
-
same_fraction = all(
|
| 52 |
-
int(gn) * int(gd) == int(gdn) * int(gnd)
|
| 53 |
-
for gn, gnd, gdn, gd in zip(
|
| 54 |
-
gen_num, gen_den, grid_num, grid_den)
|
| 55 |
-
)
|
| 56 |
-
if not np.array_equal(gen_slots, grid_slots) or not same_fraction:
|
| 57 |
-
raise ValueError("generated meter layout disagrees with the timing grid")
|
| 58 |
-
layout = grid_layout if grid_layout is not None else gen_layout
|
| 59 |
-
if layout is None:
|
| 60 |
-
raise ValueError(
|
| 61 |
-
"TJA slot export requires exact meter metadata; refusing to assume 4/4")
|
| 62 |
-
slots_per_measure, measure_num, measure_den, quarter_beats = layout
|
| 63 |
-
offsets = np.concatenate([[0], np.cumsum(slots_per_measure)])
|
| 64 |
|
| 65 |
-
def flat(measure, slot):
|
| 66 |
-
measure, slot = int(measure), int(slot)
|
| 67 |
-
if not 0 <= measure < n_meas:
|
| 68 |
-
raise ValueError(f"measure index {measure} is outside 0..{n_meas - 1}")
|
| 69 |
-
if not 0 <= slot < slots_per_measure[measure]:
|
| 70 |
-
raise ValueError(
|
| 71 |
-
f"slot {slot} is outside measure {measure}'s "
|
| 72 |
-
f"0..{slots_per_measure[measure] - 1} range"
|
| 73 |
-
)
|
| 74 |
-
return int(offsets[measure] + slot)
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
slots = {}
|
| 77 |
for me, sl, cls in gen["hits_slots"]:
|
| 78 |
-
|
| 79 |
-
if position in slots:
|
| 80 |
-
raise ValueError(
|
| 81 |
-
f"slot collision at measure {int(me)}, slot {int(sl)}")
|
| 82 |
-
slots[position] = CHAR[cls]
|
| 83 |
for m0, s0, m1, s1, typ in gen.get("spans_slots", []):
|
| 84 |
-
a =
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
f"slot collision at span start measure {int(m0)}, slot {int(s0)}")
|
| 91 |
-
if b in slots:
|
| 92 |
-
raise ValueError(
|
| 93 |
-
f"slot collision at span end measure {int(m1)}, slot {int(s1)}")
|
| 94 |
slots[a] = CHAR[typ]
|
| 95 |
slots[b] = "8"
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
#
|
| 99 |
-
#
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
"trusted terminal edge"
|
| 104 |
-
)
|
| 105 |
-
tempo_edges = np.asarray(grid["measure_edges"], dtype=float)
|
| 106 |
-
if (tempo_edges.ndim != 1 or len(tempo_edges) != n_meas + 1
|
| 107 |
-
or not np.all(np.isfinite(tempo_edges))
|
| 108 |
-
or np.any(np.diff(tempo_edges) <= 0)):
|
| 109 |
-
raise ValueError(
|
| 110 |
-
"measure_edges must contain one finite, increasing edge per "
|
| 111 |
-
"measure plus the final edge")
|
| 112 |
lines = []
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
raise ValueError("grid bpm must be positive and finite")
|
| 116 |
-
cur_bpm = base_bpm
|
| 117 |
-
cur_meter = (4, 4)
|
| 118 |
for m in range(n_meas):
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
lines.
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
lines.append(""
|
| 134 |
-
for k in range(int(slots_per_measure[m]))) + ",")
|
| 135 |
balloons = [balloon_count] * sum(1 for s in gen.get("spans_slots", [])
|
| 136 |
if s[4] == "balloon")
|
| 137 |
-
offset = float(
|
| 138 |
tja = "\n".join([
|
| 139 |
-
f"TITLE:{title} (SoftChart)", f"BPM:{
|
| 140 |
-
f"
|
| 141 |
f"COURSE:{'Oni' if course == 'oni' else str(course).capitalize()}",
|
| 142 |
f"LEVEL:{level}",
|
| 143 |
f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
|
|
|
| 1 |
+
"""TJA emission for slot-mode charts: slot indices ARE the chart.
|
| 2 |
|
| 3 |
No quantization happens here — generate_song_slot() already produced exact
|
| 4 |
+
lattice positions (measure, slot in 0..95), so this writer just prints them.
|
| 5 |
+
Preview rendering and the .tja are identical by construction. Piecewise
|
| 6 |
+
grids additionally emit #BPMCHANGE lines between measures.
|
| 7 |
"""
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
|
|
|
|
| 11 |
from .vocab import SLOTS
|
| 12 |
|
| 13 |
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 14 |
"roll": "5", "roll_big": "6", "balloon": "7"}
|
| 15 |
|
| 16 |
|
| 17 |
+
def grid_measure_starts(grid, n_measures):
|
| 18 |
+
"""Return measure boundary times for a slot grid, extending if needed."""
|
| 19 |
+
db = np.asarray(grid.get("downbeats", []), dtype=float)
|
| 20 |
+
if len(db) >= n_measures + 1:
|
| 21 |
+
return db[: n_measures + 1]
|
| 22 |
+
if len(db) >= 2:
|
| 23 |
+
step = float(np.median(np.diff(db)))
|
| 24 |
+
start = float(db[0])
|
| 25 |
+
else:
|
| 26 |
+
step = 240.0 / float(grid["bpm"])
|
| 27 |
+
start = float(db[0]) if len(db) else 0.0
|
| 28 |
+
return start + np.arange(n_measures + 1, dtype=float) * step
|
| 29 |
|
| 30 |
|
| 31 |
+
def gogo_measure_mask(plan, measure_starts, n_measures):
|
| 32 |
+
"""Map plan climax blocks (flag == 2) to TJA measures."""
|
| 33 |
+
mask = [False] * n_measures
|
| 34 |
+
if not plan:
|
| 35 |
+
return mask
|
| 36 |
+
starts = np.asarray(measure_starts, dtype=float)
|
| 37 |
+
if len(starts) < n_measures + 1:
|
| 38 |
+
return mask
|
| 39 |
+
for block in plan:
|
| 40 |
+
if len(block) < 4 or int(block[3]) != 2:
|
| 41 |
+
continue
|
| 42 |
+
a, b = float(block[0]), float(block[1])
|
| 43 |
+
if b <= a:
|
| 44 |
+
continue
|
| 45 |
+
for m in range(n_measures):
|
| 46 |
+
if starts[m] < b and starts[m + 1] > a:
|
| 47 |
+
mask[m] = True
|
| 48 |
+
return mask
|
| 49 |
+
|
| 50 |
|
| 51 |
+
def append_measure_with_gogo(lines, measure_line, measure_idx, gogo_mask, in_gogo):
|
| 52 |
+
"""Append a measure line, opening/closing #GOGO commands at boundaries."""
|
| 53 |
+
want_gogo = bool(gogo_mask[measure_idx]) if measure_idx < len(gogo_mask) else False
|
| 54 |
+
if in_gogo and not want_gogo:
|
| 55 |
+
lines.append("#GOGOEND")
|
| 56 |
+
in_gogo = False
|
| 57 |
+
if want_gogo and not in_gogo:
|
| 58 |
+
lines.append("#GOGOSTART")
|
| 59 |
+
in_gogo = True
|
| 60 |
+
lines.append(measure_line)
|
| 61 |
+
return in_gogo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
+
def write_tja_slots(gen, grid, title, course, level, wave, out_path=None,
|
| 65 |
+
balloon_count=10, plan=None):
|
| 66 |
+
"""gen: generate_song_slot() result; grid: fit_grid() result (or a dict
|
| 67 |
+
with downbeats+bpm). Returns the TJA text (and writes it if out_path)."""
|
| 68 |
slots = {}
|
| 69 |
for me, sl, cls in gen["hits_slots"]:
|
| 70 |
+
slots.setdefault((me, sl), CHAR[cls])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
for m0, s0, m1, s1, typ in gen.get("spans_slots", []):
|
| 72 |
+
a = (m0, s0)
|
| 73 |
+
while a in slots: # span start yields to hits: next free slot
|
| 74 |
+
a = (a[0] + (a[1] + 1) // SLOTS, (a[1] + 1) % SLOTS)
|
| 75 |
+
b = (m1, s1)
|
| 76 |
+
while b in slots or b <= a:
|
| 77 |
+
b = (b[0] + (b[1] + 1) // SLOTS, (b[1] + 1) % SLOTS)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
slots[a] = CHAR[typ]
|
| 79 |
slots[b] = "8"
|
| 80 |
+
n_meas = max(gen.get("n_measures", 0),
|
| 81 |
+
(max(m for m, _ in slots) + 1) if slots else 1)
|
| 82 |
+
# piecewise-tempo grids: emit #BPMCHANGE whenever the per-measure BPM
|
| 83 |
+
# (from consecutive fitted barlines) moves; TJA measure lines are unchanged
|
| 84 |
+
db = np.asarray(grid.get("downbeats", []), float) if grid.get("piecewise") else None
|
| 85 |
+
measure_starts = grid_measure_starts(grid, n_meas)
|
| 86 |
+
gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
lines = []
|
| 88 |
+
cur_bpm = float(grid["bpm"])
|
| 89 |
+
in_gogo = False
|
|
|
|
|
|
|
|
|
|
| 90 |
for m in range(n_meas):
|
| 91 |
+
if in_gogo and not gogo_mask[m]:
|
| 92 |
+
lines.append("#GOGOEND")
|
| 93 |
+
in_gogo = False
|
| 94 |
+
if db is not None and m + 1 < len(db):
|
| 95 |
+
bpm_m = 240.0 / (db[m + 1] - db[m])
|
| 96 |
+
if abs(bpm_m - round(bpm_m)) < 0.05:
|
| 97 |
+
bpm_m = float(round(bpm_m))
|
| 98 |
+
if abs(bpm_m - cur_bpm) > 0.05:
|
| 99 |
+
lines.append(f"#BPMCHANGE {bpm_m:g}")
|
| 100 |
+
cur_bpm = bpm_m
|
| 101 |
+
in_gogo = append_measure_with_gogo(
|
| 102 |
+
lines, "".join(slots.get((m, k), "0") for k in range(SLOTS)) + ",",
|
| 103 |
+
m, gogo_mask, in_gogo)
|
| 104 |
+
if in_gogo:
|
| 105 |
+
lines.append("#GOGOEND")
|
|
|
|
| 106 |
balloons = [balloon_count] * sum(1 for s in gen.get("spans_slots", [])
|
| 107 |
if s[4] == "balloon")
|
| 108 |
+
offset = float(grid["downbeats"][0]) if len(grid.get("downbeats", [])) else 0.0
|
| 109 |
tja = "\n".join([
|
| 110 |
+
f"TITLE:{title} (SoftChart)", f"BPM:{grid['bpm']:g}", f"WAVE:{wave}",
|
| 111 |
+
f"OFFSET:{-offset:.3f}",
|
| 112 |
f"COURSE:{'Oni' if course == 'oni' else str(course).capitalize()}",
|
| 113 |
f"LEVEL:{level}",
|
| 114 |
f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
softchart/train.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training loop.
|
| 2 |
+
|
| 3 |
+
python -m softchart.train --cache /workspace/softchart/cache --out runs/base
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import math
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
from torch.utils.data import DataLoader
|
| 15 |
+
|
| 16 |
+
from .data import ChartWindowDataset, collate, load_split_ids
|
| 17 |
+
from .model import ChartModel, count_params
|
| 18 |
+
from .vocab import VOCAB
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# class-balanced weights for rare note types (~1/sqrt(train frequency), capped)
|
| 22 |
+
TYPE_WEIGHTS = {"don": 1.0, "ka": 1.23, "don_big": 3.19, "ka_big": 5.29,
|
| 23 |
+
"roll": 7.18, "roll_big": 8.0, "balloon": 8.0, "end": 5.0}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_class_weights(device, cap=8.0):
|
| 27 |
+
w = torch.ones(VOCAB.size, device=device)
|
| 28 |
+
for name, weight in TYPE_WEIGHTS.items():
|
| 29 |
+
w[VOCAB.note[name]] = min(weight, cap)
|
| 30 |
+
return w
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def loss_fn(logits, tgt, loss_mask, label_smoothing=0.1, class_weight=None):
|
| 34 |
+
# logits: (B, L-1, V) predicting tgt[:, 1:]
|
| 35 |
+
gold = tgt[:, 1:]
|
| 36 |
+
mask = loss_mask[:, 1:]
|
| 37 |
+
ls = F.cross_entropy(
|
| 38 |
+
logits.reshape(-1, logits.shape[-1]),
|
| 39 |
+
gold.reshape(-1),
|
| 40 |
+
reduction="none",
|
| 41 |
+
label_smoothing=label_smoothing,
|
| 42 |
+
weight=class_weight,
|
| 43 |
+
).reshape(gold.shape)
|
| 44 |
+
return (ls * mask).sum() / mask.sum().clamp(min=1)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def corrupt_events(tgt, loss_mask, rng_gen, hard=False):
|
| 48 |
+
"""Synthetic negative for DPO. hard=True keeps corruptions NEAR the snap
|
| 49 |
+
tolerance (small jitter, few swaps) so negatives resemble the policy's own
|
| 50 |
+
mistakes rather than being trivially separable (fixes dpo_loss->0 collapse)."""
|
| 51 |
+
from .vocab import WINDOW
|
| 52 |
+
|
| 53 |
+
rej = tgt.clone()
|
| 54 |
+
ev = loss_mask.clone()
|
| 55 |
+
t0 = VOCAB.time0
|
| 56 |
+
is_time = (rej >= t0) & (rej < t0 + WINDOW) & ev
|
| 57 |
+
if hard: # +-1..3 frames (~12-35ms, straddles the snap grid) on 60% of notes
|
| 58 |
+
jit = torch.randint(-3, 4, rej.shape, device=rej.device, generator=rng_gen)
|
| 59 |
+
jit = torch.where(jit == 0, torch.ones_like(jit), jit)
|
| 60 |
+
do_t = torch.rand(rej.shape, device=rej.device, generator=rng_gen) < 0.6
|
| 61 |
+
jit = torch.where(do_t, jit, torch.zeros_like(jit))
|
| 62 |
+
swap_p = 0.12
|
| 63 |
+
else:
|
| 64 |
+
jit = torch.randint(-5, 6, rej.shape, device=rej.device, generator=rng_gen)
|
| 65 |
+
jit = torch.where(jit.abs() < 2, jit.sign() * 2, jit)
|
| 66 |
+
swap_p = 0.25
|
| 67 |
+
rej = torch.where(is_time, (rej + jit).clamp(t0, t0 + WINDOW - 1), rej)
|
| 68 |
+
hit0 = VOCAB.note["don"]
|
| 69 |
+
is_hit = (rej >= hit0) & (rej < hit0 + 4) & ev
|
| 70 |
+
swap = torch.randint(0, 4, rej.shape, device=rej.device, generator=rng_gen) + hit0
|
| 71 |
+
do = torch.rand(rej.shape, device=rej.device, generator=rng_gen) < swap_p
|
| 72 |
+
rej = torch.where(is_hit & do, swap, rej)
|
| 73 |
+
return rej
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def seq_logprob(model, mel, tgt, mask):
|
| 77 |
+
import torch.nn.functional as F
|
| 78 |
+
|
| 79 |
+
logits = model(mel, tgt)
|
| 80 |
+
lp = F.log_softmax(logits.float(), dim=-1)
|
| 81 |
+
gold = tgt[:, 1:]
|
| 82 |
+
g = lp.gather(-1, gold.unsqueeze(-1)).squeeze(-1)
|
| 83 |
+
return (g * mask[:, 1:]).sum(1)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@torch.no_grad()
|
| 87 |
+
def validate(model, loader, device):
|
| 88 |
+
model.eval()
|
| 89 |
+
tot, n = 0.0, 0
|
| 90 |
+
for mel, tgt, mask, _aux, gsum, posb, _beat, _in in loader:
|
| 91 |
+
mel, tgt, mask = mel.to(device), tgt.to(device), mask.to(device)
|
| 92 |
+
gsum = gsum.to(device) if gsum is not None else None
|
| 93 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 94 |
+
logits = model(mel, tgt, gsum=gsum, pos_bucket=posb.to(device))
|
| 95 |
+
l = loss_fn(logits, tgt, mask, label_smoothing=0.0)
|
| 96 |
+
tot += l.item() * mel.shape[0]
|
| 97 |
+
n += mel.shape[0]
|
| 98 |
+
model.train()
|
| 99 |
+
return tot / max(n, 1)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def main():
|
| 103 |
+
ap = argparse.ArgumentParser()
|
| 104 |
+
ap.add_argument("--cache", required=True)
|
| 105 |
+
ap.add_argument("--out", required=True)
|
| 106 |
+
ap.add_argument("--steps", type=int, default=60000)
|
| 107 |
+
ap.add_argument("--batch", type=int, default=32)
|
| 108 |
+
ap.add_argument("--lr", type=float, default=3e-4)
|
| 109 |
+
ap.add_argument("--warmup", type=int, default=1000)
|
| 110 |
+
ap.add_argument("--cond-drop", type=float, default=0.15)
|
| 111 |
+
ap.add_argument("--no-augment", action="store_true")
|
| 112 |
+
ap.add_argument("--balanced", action="store_true", help="class-balanced type loss")
|
| 113 |
+
ap.add_argument("--balance-cap", type=float, default=8.0,
|
| 114 |
+
help="cap for class weights (v3: 2.5 after v2 over-corrected)")
|
| 115 |
+
ap.add_argument("--ctx", action="store_true", help="prev-window context tokens")
|
| 116 |
+
ap.add_argument("--aux", action="store_true", help="onset-heatmap auxiliary head")
|
| 117 |
+
ap.add_argument("--global-ctx", action="store_true",
|
| 118 |
+
help="whole-song summary + window-position context (v4)")
|
| 119 |
+
ap.add_argument("--importance-sampling", action="store_true",
|
| 120 |
+
help="bias window sampling toward informative regions (v4)")
|
| 121 |
+
ap.add_argument("--d-model", type=int, default=512)
|
| 122 |
+
ap.add_argument("--enc-layers", type=int, default=6)
|
| 123 |
+
ap.add_argument("--dec-layers", type=int, default=6)
|
| 124 |
+
ap.add_argument("--ffn", type=int, default=2048)
|
| 125 |
+
ap.add_argument("--func-time", action="store_true",
|
| 126 |
+
help="functional (sinusoidal-basis) TIME token embeddings")
|
| 127 |
+
ap.add_argument("--emb-factor", type=int, default=0,
|
| 128 |
+
help="ALBERT-style factorized embeddings (e.g. 64); 0 = off")
|
| 129 |
+
ap.add_argument("--distill", default=None,
|
| 130 |
+
help="teacher checkpoint for knowledge distillation")
|
| 131 |
+
ap.add_argument("--use-aug", action="store_true",
|
| 132 |
+
help="include waveform speed-augmented variants in training")
|
| 133 |
+
ap.add_argument("--init-from", default=None,
|
| 134 |
+
help="initialize matching weights (decoder/tok_emb) from a checkpoint")
|
| 135 |
+
ap.add_argument("--kd-alpha", type=float, default=0.5, help="CE weight (rest = KL)")
|
| 136 |
+
ap.add_argument("--kd-tau", type=float, default=2.0, help="distillation temperature")
|
| 137 |
+
ap.add_argument("--tempo-aug", action="store_true",
|
| 138 |
+
help="rhythm-preserving tempo/pitch augmentation in mel domain")
|
| 139 |
+
ap.add_argument("--sibling", action="store_true",
|
| 140 |
+
help="easier-course skeleton hint in the prefix (easy subset-of hard)")
|
| 141 |
+
ap.add_argument("--style", action="store_true",
|
| 142 |
+
help="charting-intent style token (cache/styles.json)")
|
| 143 |
+
ap.add_argument("--align", action="store_true",
|
| 144 |
+
help="pointer alignment loss: notes must attend to their onset frame")
|
| 145 |
+
ap.add_argument("--beat-head", action="store_true",
|
| 146 |
+
help="beat/downbeat auxiliary head (labels from TJA measures)")
|
| 147 |
+
ap.add_argument("--beat-weight", type=float, default=0.1,
|
| 148 |
+
help="beat-head loss weight (raise for a dedicated beat model)")
|
| 149 |
+
ap.add_argument("--beat-hires", action="store_true",
|
| 150 |
+
help="frame-resolution beat head (11.6 ms bins instead of 46 ms)")
|
| 151 |
+
ap.add_argument("--sync-token", action="store_true",
|
| 152 |
+
help="LHL syncopation-band condition token")
|
| 153 |
+
ap.add_argument("--plan", action="store_true",
|
| 154 |
+
help="song-level plan-block conditioning (cache/plans.json)")
|
| 155 |
+
ap.add_argument("--mask-infill", type=float, default=0.0,
|
| 156 |
+
help="probability of type-mask (skeleton->color) curriculum per window")
|
| 157 |
+
ap.add_argument("--complexity", action="store_true",
|
| 158 |
+
help="rhythmic-complexity band token (density-independent difficulty)")
|
| 159 |
+
ap.add_argument("--slot", action="store_true",
|
| 160 |
+
help="slot-token mode: notes as exact TJA lattice indices "
|
| 161 |
+
"(measure*96+slot), measure-aligned windows, grid-phase "
|
| 162 |
+
"input channels — zero quantization error by construction")
|
| 163 |
+
ap.add_argument("--dual-slot-p", type=float, default=0.65,
|
| 164 |
+
help="dual mode: probability of a slot window (rest = time)")
|
| 165 |
+
ap.add_argument("--dual", action="store_true",
|
| 166 |
+
help="dual-mode: mixed slot/time windows with an explicit "
|
| 167 |
+
"MODE token; one model serves both the grid-exact path "
|
| 168 |
+
"and the gridless time path")
|
| 169 |
+
ap.add_argument("--dpo", default=None,
|
| 170 |
+
help="reference checkpoint: switch to DPO finetuning with synthetic negatives")
|
| 171 |
+
ap.add_argument("--dpo-beta", type=float, default=0.1)
|
| 172 |
+
ap.add_argument("--dpo-hard", action="store_true",
|
| 173 |
+
help="near-tolerance negatives (resemble policy errors)")
|
| 174 |
+
ap.add_argument("--val-every", type=int, default=2000)
|
| 175 |
+
ap.add_argument("--workers", type=int, default=12)
|
| 176 |
+
ap.add_argument("--resume", default=None)
|
| 177 |
+
ap.add_argument("--seed", type=int, default=1234)
|
| 178 |
+
ap.add_argument("--data-frac", type=float, default=1.0,
|
| 179 |
+
help="fraction of train songs to use (data-scaling ablation); "
|
| 180 |
+
"val/test splits are unaffected")
|
| 181 |
+
args = ap.parse_args()
|
| 182 |
+
torch.manual_seed(args.seed)
|
| 183 |
+
|
| 184 |
+
os.makedirs(args.out, exist_ok=True)
|
| 185 |
+
device = "cuda"
|
| 186 |
+
torch.backends.cuda.matmul.allow_tf32 = True
|
| 187 |
+
torch.backends.cudnn.allow_tf32 = True
|
| 188 |
+
|
| 189 |
+
train_ids, val_ids, _ = load_split_ids(args.cache, use_aug=args.use_aug,
|
| 190 |
+
frac=args.data_frac)
|
| 191 |
+
train_ds = ChartWindowDataset(
|
| 192 |
+
args.cache, train_ids, train=True, cond_drop=args.cond_drop,
|
| 193 |
+
spec_augment=not args.no_augment, use_ctx=args.ctx, aux=args.aux,
|
| 194 |
+
global_ctx=args.global_ctx, importance_sampling=args.importance_sampling,
|
| 195 |
+
tempo_aug=args.tempo_aug, sibling=args.sibling, style=args.style,
|
| 196 |
+
beat_head=args.beat_head, sync_token=args.sync_token,
|
| 197 |
+
plan=args.plan, mask_infill=args.mask_infill, complexity=args.complexity,
|
| 198 |
+
slot=args.slot, dual=args.dual, dual_slot_p=args.dual_slot_p,
|
| 199 |
+
beat_hires=args.beat_hires,
|
| 200 |
+
)
|
| 201 |
+
val_ds = ChartWindowDataset(args.cache, val_ids, train=False, windows_per_chart=1,
|
| 202 |
+
use_ctx=args.ctx, aux=args.aux, global_ctx=args.global_ctx,
|
| 203 |
+
sibling=args.sibling, style=args.style,
|
| 204 |
+
beat_head=args.beat_head, sync_token=args.sync_token,
|
| 205 |
+
plan=args.plan, complexity=args.complexity,
|
| 206 |
+
slot=args.slot, dual=args.dual,
|
| 207 |
+
beat_hires=args.beat_hires)
|
| 208 |
+
print(f"train charts {len(train_ds.items)}, val charts {len(val_ds.items)}")
|
| 209 |
+
|
| 210 |
+
train_loader = DataLoader(
|
| 211 |
+
train_ds, batch_size=args.batch, shuffle=True, collate_fn=collate,
|
| 212 |
+
num_workers=args.workers, pin_memory=True, drop_last=True, persistent_workers=True,
|
| 213 |
+
)
|
| 214 |
+
val_loader = DataLoader(
|
| 215 |
+
val_ds, batch_size=args.batch, shuffle=False, collate_fn=collate, num_workers=4,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
from .vocab import N_MELS as _NM
|
| 219 |
+
model = ChartModel(
|
| 220 |
+
d_model=args.d_model, enc_layers=args.enc_layers, dec_layers=args.dec_layers,
|
| 221 |
+
ffn=args.ffn, aux=args.aux, global_ctx=args.global_ctx,
|
| 222 |
+
func_time=args.func_time, in_ch=(_NM + 2) if (args.slot or args.dual) else None,
|
| 223 |
+
emb_factor=args.emb_factor or None,
|
| 224 |
+
).to(device)
|
| 225 |
+
if args.align:
|
| 226 |
+
model.enable_ptr()
|
| 227 |
+
if args.beat_head:
|
| 228 |
+
model.enable_beat(hires=args.beat_hires)
|
| 229 |
+
model.to(device)
|
| 230 |
+
print(f"params: {count_params(model)/1e6:.1f}M, vocab {VOCAB.size}")
|
| 231 |
+
if args.init_from and os.path.exists(args.init_from):
|
| 232 |
+
src = torch.load(args.init_from, map_location=device)["model"]
|
| 233 |
+
own = model.state_dict()
|
| 234 |
+
hit = {k: v for k, v in src.items()
|
| 235 |
+
if k in own and own[k].shape == v.shape
|
| 236 |
+
and k.startswith(("decoder", "tok_emb", "out", "dec_pos"))}
|
| 237 |
+
own.update(hit)
|
| 238 |
+
model.load_state_dict(own)
|
| 239 |
+
print(f"initialized {len(hit)} tensors from {args.init_from}")
|
| 240 |
+
class_weight = build_class_weights(device, args.balance_cap) if args.balanced else None
|
| 241 |
+
|
| 242 |
+
teacher = None
|
| 243 |
+
if args.distill:
|
| 244 |
+
from .generate import load_model
|
| 245 |
+
|
| 246 |
+
teacher = load_model(args.distill, device=device)
|
| 247 |
+
for p in teacher.parameters():
|
| 248 |
+
p.requires_grad_(False)
|
| 249 |
+
t_vocab = teacher.tok_emb.weight.shape[0]
|
| 250 |
+
print(f"distilling from {args.distill} (teacher vocab {t_vocab})")
|
| 251 |
+
dpo_ref = None
|
| 252 |
+
if args.dpo:
|
| 253 |
+
from .generate import load_model as _lm
|
| 254 |
+
|
| 255 |
+
dpo_ref = _lm(args.dpo, device=device)
|
| 256 |
+
for p_ in dpo_ref.parameters():
|
| 257 |
+
p_.requires_grad_(False)
|
| 258 |
+
# initialize the policy from the reference (vocab may have grown since:
|
| 259 |
+
# copy overlapping embedding rows, everything else matches the 12M arch)
|
| 260 |
+
ref_sd = dpo_ref.state_dict()
|
| 261 |
+
own = model.state_dict()
|
| 262 |
+
for k, v in ref_sd.items():
|
| 263 |
+
if k in own:
|
| 264 |
+
if own[k].shape == v.shape:
|
| 265 |
+
own[k] = v
|
| 266 |
+
elif k == "tok_emb.weight":
|
| 267 |
+
own[k][: v.shape[0]] = v
|
| 268 |
+
model.load_state_dict(own)
|
| 269 |
+
# disable dropout for DPO: logprob-margin noise from dropout on long
|
| 270 |
+
# sequences swamps the beta-scaled objective (verified: init loss 3.3
|
| 271 |
+
# instead of log 2)
|
| 272 |
+
import torch.nn as _nn
|
| 273 |
+
|
| 274 |
+
for m_ in model.modules():
|
| 275 |
+
if isinstance(m_, _nn.Dropout):
|
| 276 |
+
m_.p = 0.0
|
| 277 |
+
print(f"DPO: policy initialized from {args.dpo}, beta={args.dpo_beta}, dropout off")
|
| 278 |
+
|
| 279 |
+
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.01, betas=(0.9, 0.98))
|
| 280 |
+
|
| 281 |
+
def lr_at(step):
|
| 282 |
+
if step < args.warmup:
|
| 283 |
+
return args.lr * step / args.warmup
|
| 284 |
+
p = (step - args.warmup) / max(1, args.steps - args.warmup)
|
| 285 |
+
return args.lr * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * p)))
|
| 286 |
+
|
| 287 |
+
step = 0
|
| 288 |
+
best_val = float("inf")
|
| 289 |
+
if args.resume and os.path.exists(args.resume):
|
| 290 |
+
ck = torch.load(args.resume, map_location=device)
|
| 291 |
+
model.load_state_dict(ck["model"])
|
| 292 |
+
opt.load_state_dict(ck["opt"])
|
| 293 |
+
step = ck["step"]
|
| 294 |
+
best_val = ck.get("best_val", best_val)
|
| 295 |
+
print(f"resumed from {args.resume} at step {step}")
|
| 296 |
+
|
| 297 |
+
log_path = os.path.join(args.out, "train_log.jsonl")
|
| 298 |
+
model.train()
|
| 299 |
+
t0 = time.time()
|
| 300 |
+
run_loss, run_n = 0.0, 0
|
| 301 |
+
done = False
|
| 302 |
+
while not done:
|
| 303 |
+
for mel, tgt, mask, aux_t, gsum, posb, beat_t, in_tgt in train_loader:
|
| 304 |
+
step += 1
|
| 305 |
+
if step > args.steps:
|
| 306 |
+
done = True
|
| 307 |
+
break
|
| 308 |
+
for g in opt.param_groups:
|
| 309 |
+
g["lr"] = lr_at(step)
|
| 310 |
+
mel, tgt, mask = mel.to(device, non_blocking=True), tgt.to(device), mask.to(device)
|
| 311 |
+
gsum = gsum.to(device) if gsum is not None else None
|
| 312 |
+
posb = posb.to(device)
|
| 313 |
+
in_tgt = in_tgt.to(device)
|
| 314 |
+
if dpo_ref is not None:
|
| 315 |
+
gen_rng = torch.Generator(device=device)
|
| 316 |
+
gen_rng.manual_seed(args.seed * 100003 + step)
|
| 317 |
+
rej = corrupt_events(tgt, mask, gen_rng, hard=args.dpo_hard)
|
| 318 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 319 |
+
pc = seq_logprob(model, mel, tgt, mask)
|
| 320 |
+
pr = seq_logprob(model, mel, rej, mask)
|
| 321 |
+
with torch.no_grad():
|
| 322 |
+
rc = seq_logprob(dpo_ref, mel, tgt, mask)
|
| 323 |
+
rr = seq_logprob(dpo_ref, mel, rej, mask)
|
| 324 |
+
loss = -F.logsigmoid(
|
| 325 |
+
args.dpo_beta * ((pc - pr) - (rc - rr))).mean()
|
| 326 |
+
opt.zero_grad(set_to_none=True)
|
| 327 |
+
loss.backward()
|
| 328 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 329 |
+
opt.step()
|
| 330 |
+
run_loss += loss.item()
|
| 331 |
+
run_n += 1
|
| 332 |
+
if step % 100 == 0:
|
| 333 |
+
msg = {"step": step, "dpo_loss": round(run_loss / run_n, 4)}
|
| 334 |
+
print(json.dumps(msg), flush=True)
|
| 335 |
+
with open(log_path, "a") as f:
|
| 336 |
+
f.write(json.dumps(msg) + "\n")
|
| 337 |
+
run_loss, run_n = 0.0, 0
|
| 338 |
+
if step % args.val_every == 0 or step == args.steps:
|
| 339 |
+
ck = {"model": model.state_dict(), "opt": opt.state_dict(),
|
| 340 |
+
"step": step, "best_val": 0.0, "args": vars(args)}
|
| 341 |
+
torch.save(ck, os.path.join(args.out, "last.pt"))
|
| 342 |
+
torch.save(ck, os.path.join(args.out, "best.pt"))
|
| 343 |
+
continue
|
| 344 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 345 |
+
if args.align or args.beat_head:
|
| 346 |
+
logits, aux_logits, ptr_logits, beat_logits = model(
|
| 347 |
+
mel, tgt, return_aux=True, return_extras=True,
|
| 348 |
+
gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
|
| 349 |
+
elif args.aux and aux_t is not None:
|
| 350 |
+
logits, aux_logits = model(mel, tgt, return_aux=True,
|
| 351 |
+
gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
|
| 352 |
+
ptr_logits = beat_logits = None
|
| 353 |
+
else:
|
| 354 |
+
logits = model(mel, tgt, gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
|
| 355 |
+
aux_logits = ptr_logits = beat_logits = None
|
| 356 |
+
aux_loss = 0.0
|
| 357 |
+
if args.aux and aux_t is not None and aux_logits is not None:
|
| 358 |
+
aux_loss = F.binary_cross_entropy_with_logits(
|
| 359 |
+
aux_logits.float(), aux_t.to(device))
|
| 360 |
+
extra = 0.0
|
| 361 |
+
if ptr_logits is not None:
|
| 362 |
+
# pointer alignment: positions whose GOLD token is TIME must
|
| 363 |
+
# point at that frame's encoder position
|
| 364 |
+
gold = tgt[:, 1:]
|
| 365 |
+
is_t = (gold >= VOCAB.time0) & (gold < VOCAB.time0 + 1728) & mask[:, 1:]
|
| 366 |
+
if is_t.any():
|
| 367 |
+
target_pos = ((gold - VOCAB.time0) // 4).clamp(0, ptr_logits.shape[-1] - 1)
|
| 368 |
+
pl = F.cross_entropy(
|
| 369 |
+
ptr_logits.reshape(-1, ptr_logits.shape[-1]).float(),
|
| 370 |
+
target_pos.reshape(-1), reduction="none",
|
| 371 |
+
).reshape(gold.shape)
|
| 372 |
+
extra = extra + 0.2 * (pl * is_t).sum() / is_t.sum()
|
| 373 |
+
if beat_logits is not None and beat_t is not None:
|
| 374 |
+
bt = beat_t.to(device)
|
| 375 |
+
bm = bt >= 0 # -1 marks masked (grid-visible) windows
|
| 376 |
+
if bm.any():
|
| 377 |
+
extra = extra + args.beat_weight * F.binary_cross_entropy_with_logits(
|
| 378 |
+
beat_logits.float()[bm], bt[bm])
|
| 379 |
+
loss = loss_fn(logits, tgt, mask, class_weight=class_weight) + 0.1 * aux_loss + extra
|
| 380 |
+
if teacher is not None:
|
| 381 |
+
with torch.no_grad():
|
| 382 |
+
# teacher may have a smaller vocab (no SIB/STYLE tokens):
|
| 383 |
+
# clamp unseen ids to UNK and distill over the shared slice
|
| 384 |
+
t_tgt = torch.where(tgt >= t_vocab,
|
| 385 |
+
torch.full_like(tgt, VOCAB.unk_cond), tgt)
|
| 386 |
+
t_logits = teacher(mel, t_tgt)
|
| 387 |
+
V = min(t_logits.shape[-1], logits.shape[-1])
|
| 388 |
+
tau = args.kd_tau
|
| 389 |
+
kl = F.kl_div(
|
| 390 |
+
F.log_softmax(logits[..., :V].float() / tau, dim=-1),
|
| 391 |
+
F.softmax(t_logits[..., :V].float() / tau, dim=-1),
|
| 392 |
+
reduction="none",
|
| 393 |
+
).sum(-1)
|
| 394 |
+
kl = (kl * mask[:, 1:]).sum() / mask[:, 1:].sum().clamp(min=1)
|
| 395 |
+
loss = args.kd_alpha * loss + (1 - args.kd_alpha) * (tau * tau) * kl
|
| 396 |
+
opt.zero_grad(set_to_none=True)
|
| 397 |
+
loss.backward()
|
| 398 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 399 |
+
opt.step()
|
| 400 |
+
run_loss += loss.item()
|
| 401 |
+
run_n += 1
|
| 402 |
+
|
| 403 |
+
if step % 100 == 0:
|
| 404 |
+
msg = {
|
| 405 |
+
"step": step,
|
| 406 |
+
"loss": round(run_loss / run_n, 4),
|
| 407 |
+
"lr": round(lr_at(step), 6),
|
| 408 |
+
"sec": round(time.time() - t0, 1),
|
| 409 |
+
}
|
| 410 |
+
print(json.dumps(msg), flush=True)
|
| 411 |
+
with open(log_path, "a") as f:
|
| 412 |
+
f.write(json.dumps(msg) + "\n")
|
| 413 |
+
run_loss, run_n = 0.0, 0
|
| 414 |
+
|
| 415 |
+
if step % args.val_every == 0 or step == args.steps:
|
| 416 |
+
vl = validate(model, val_loader, device)
|
| 417 |
+
msg = {"step": step, "val_loss": round(vl, 4)}
|
| 418 |
+
print(json.dumps(msg), flush=True)
|
| 419 |
+
with open(log_path, "a") as f:
|
| 420 |
+
f.write(json.dumps(msg) + "\n")
|
| 421 |
+
ck = {"model": model.state_dict(), "opt": opt.state_dict(),
|
| 422 |
+
"step": step, "best_val": best_val, "args": vars(args)}
|
| 423 |
+
torch.save(ck, os.path.join(args.out, "last.pt"))
|
| 424 |
+
if vl < best_val:
|
| 425 |
+
best_val = vl
|
| 426 |
+
ck["best_val"] = best_val
|
| 427 |
+
torch.save(ck, os.path.join(args.out, "best.pt"))
|
| 428 |
+
|
| 429 |
+
print(f"done. best_val={best_val:.4f}")
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
if __name__ == "__main__":
|
| 433 |
+
main()
|
softchart/vocab.py
CHANGED
|
@@ -5,9 +5,7 @@ Sequence layout (decoder):
|
|
| 5 |
Conditions may be UNK (condition dropout / unspecified at inference).
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
import math
|
| 9 |
from dataclasses import dataclass
|
| 10 |
-
from numbers import Real
|
| 11 |
|
| 12 |
# --- audio / windowing constants (single source of truth) ---
|
| 13 |
SR = 22050
|
|
@@ -16,7 +14,7 @@ HOP = 256
|
|
| 16 |
N_MELS = 128
|
| 17 |
FPS = SR / HOP # 86.1328125 frames/sec
|
| 18 |
WINDOW = 1728 # frames per training window (~20.06 s)
|
| 19 |
-
MAX_TGT =
|
| 20 |
|
| 21 |
SLOTS = 96 # TJA lattice: slots per 4/4 measure (LCM of 16ths/triplets/32nds)
|
| 22 |
MEAS_MAX = WINDOW // SLOTS # max whole measures per window in slot mode (18)
|
|
@@ -66,7 +64,7 @@ class Vocab:
|
|
| 66 |
base += len(NOTE_CLASSES)
|
| 67 |
self.time0 = base
|
| 68 |
base += WINDOW
|
| 69 |
-
#
|
| 70 |
self.sep = base # separates prev-window context from conditions
|
| 71 |
self.sib = base + 1 # marks the sibling-chart (easier course) segment
|
| 72 |
self.style = {s: base + 2 + s for s in range(8)} # charting-intent codes
|
|
@@ -127,7 +125,7 @@ def complexity_band(frames):
|
|
| 127 |
|
| 128 |
def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_types=None,
|
| 129 |
sib_pairs=None, style=None, sync_band=None, plan_slice=None,
|
| 130 |
-
complexity=None, mode=None
|
| 131 |
"""Build a token sequence for one window.
|
| 132 |
|
| 133 |
notes: list of (frame_idx, note_class_id) sorted by frame, frame in [0, WINDOW).
|
|
@@ -138,15 +136,8 @@ def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_type
|
|
| 138 |
missing slots filled with UNK. Encoded after [SIB].
|
| 139 |
Returns (tokens, prefix_len) where loss should be applied after the prefix.
|
| 140 |
"""
|
| 141 |
-
if duration_seconds is None:
|
| 142 |
-
duration_seconds = WINDOW / FPS
|
| 143 |
-
if (not isinstance(duration_seconds, Real)
|
| 144 |
-
or isinstance(duration_seconds, bool)
|
| 145 |
-
or not math.isfinite(duration_seconds)
|
| 146 |
-
or duration_seconds <= 0):
|
| 147 |
-
raise ValueError("duration_seconds must be positive and finite")
|
| 148 |
n_hits = sum(1 for _, c in notes if NOTE_CLASSES[c] not in ("end",))
|
| 149 |
-
nps = n_hits /
|
| 150 |
d = vocab.dens_bucket(nps)
|
| 151 |
|
| 152 |
def maybe(tok):
|
|
|
|
| 5 |
Conditions may be UNK (condition dropout / unspecified at inference).
|
| 6 |
"""
|
| 7 |
|
|
|
|
| 8 |
from dataclasses import dataclass
|
|
|
|
| 9 |
|
| 10 |
# --- audio / windowing constants (single source of truth) ---
|
| 11 |
SR = 22050
|
|
|
|
| 14 |
N_MELS = 128
|
| 15 |
FPS = SR / HOP # 86.1328125 frames/sec
|
| 16 |
WINDOW = 1728 # frames per training window (~20.06 s)
|
| 17 |
+
MAX_TGT = 1536 # max decoder length (prefix + events + eos)
|
| 18 |
|
| 19 |
SLOTS = 96 # TJA lattice: slots per 4/4 measure (LCM of 16ths/triplets/32nds)
|
| 20 |
MEAS_MAX = WINDOW // SLOTS # max whole measures per window in slot mode (18)
|
|
|
|
| 64 |
base += len(NOTE_CLASSES)
|
| 65 |
self.time0 = base
|
| 66 |
base += WINDOW
|
| 67 |
+
# Extended condition tokens append after the original token ids.
|
| 68 |
self.sep = base # separates prev-window context from conditions
|
| 69 |
self.sib = base + 1 # marks the sibling-chart (easier course) segment
|
| 70 |
self.style = {s: base + 2 + s for s in range(8)} # charting-intent codes
|
|
|
|
| 125 |
|
| 126 |
def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_types=None,
|
| 127 |
sib_pairs=None, style=None, sync_band=None, plan_slice=None,
|
| 128 |
+
complexity=None, mode=None):
|
| 129 |
"""Build a token sequence for one window.
|
| 130 |
|
| 131 |
notes: list of (frame_idx, note_class_id) sorted by frame, frame in [0, WINDOW).
|
|
|
|
| 136 |
missing slots filled with UNK. Encoded after [SIB].
|
| 137 |
Returns (tokens, prefix_len) where loss should be applied after the prefix.
|
| 138 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
n_hits = sum(1 for _, c in notes if NOTE_CLASSES[c] not in ("end",))
|
| 140 |
+
nps = n_hits / (WINDOW / FPS)
|
| 141 |
d = vocab.dens_bucket(nps)
|
| 142 |
|
| 143 |
def maybe(tok):
|
static/app.css
CHANGED
|
@@ -531,7 +531,7 @@ audio {
|
|
| 531 |
|
| 532 |
.course-selector {
|
| 533 |
display: grid;
|
| 534 |
-
grid-template-columns: repeat(
|
| 535 |
padding: 4px;
|
| 536 |
background: var(--paper-deep);
|
| 537 |
border-radius: 13px;
|
|
@@ -617,6 +617,42 @@ audio {
|
|
| 617 |
outline-offset: 1px;
|
| 618 |
}
|
| 619 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
.range {
|
| 621 |
--range-progress: 88.89%;
|
| 622 |
width: 100%;
|
|
@@ -1081,7 +1117,7 @@ audio {
|
|
| 1081 |
.loading-core,
|
| 1082 |
.metronome,
|
| 1083 |
.beat-pulses circle,
|
| 1084 |
-
.
|
| 1085 |
.note,
|
| 1086 |
.export-sheet,
|
| 1087 |
.mix-wave,
|
|
@@ -1119,19 +1155,19 @@ audio {
|
|
| 1119 |
.is-active .beat-pulses circle:nth-child(5) { animation-delay: -1.6s; }
|
| 1120 |
|
| 1121 |
/* Plan */
|
| 1122 |
-
.
|
| 1123 |
-
.
|
| 1124 |
-
.is-active .
|
| 1125 |
-
.is-active .
|
| 1126 |
-
.is-active .
|
| 1127 |
-
.is-active .
|
| 1128 |
-
.is-active .
|
| 1129 |
-
.is-active .
|
| 1130 |
-
.is-active .
|
| 1131 |
-
.is-active .
|
| 1132 |
-
.
|
| 1133 |
-
.is-active .
|
| 1134 |
-
.is-active .
|
| 1135 |
|
| 1136 |
/* Generate */
|
| 1137 |
.note { fill: var(--red); transform: translate(560px, 140px); }
|
|
@@ -1483,17 +1519,17 @@ footer a:hover {
|
|
| 1483 |
48%, 100% { opacity: .16; transform: scale(.75); }
|
| 1484 |
}
|
| 1485 |
|
| 1486 |
-
@keyframes
|
| 1487 |
0% { opacity: .12; transform: scaleY(.45); }
|
| 1488 |
100% { opacity: .35; transform: scaleY(1); }
|
| 1489 |
}
|
| 1490 |
|
| 1491 |
-
@keyframes
|
| 1492 |
0% { stroke-dashoffset: 520; }
|
| 1493 |
75%, 100% { stroke-dashoffset: 0; }
|
| 1494 |
}
|
| 1495 |
|
| 1496 |
-
@keyframes
|
| 1497 |
0% { transform: translate(0, 0); opacity: 0; }
|
| 1498 |
8% { opacity: 1; }
|
| 1499 |
12% { transform: translate(0, 0); }
|
|
|
|
| 531 |
|
| 532 |
.course-selector {
|
| 533 |
display: grid;
|
| 534 |
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
| 535 |
padding: 4px;
|
| 536 |
background: var(--paper-deep);
|
| 537 |
border-radius: 13px;
|
|
|
|
| 617 |
outline-offset: 1px;
|
| 618 |
}
|
| 619 |
|
| 620 |
+
/* Known limitations of the selected model / difficulty. Rendered verbatim from
|
| 621 |
+
the server's /api/capabilities, which reads the model card. */
|
| 622 |
+
.model-notices {
|
| 623 |
+
padding: 12px 14px;
|
| 624 |
+
margin-top: 10px;
|
| 625 |
+
color: var(--red-deep);
|
| 626 |
+
background: var(--red-soft);
|
| 627 |
+
border-radius: 11px;
|
| 628 |
+
font-size: 11px;
|
| 629 |
+
line-height: 1.5;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.model-notices strong {
|
| 633 |
+
display: block;
|
| 634 |
+
margin-bottom: 6px;
|
| 635 |
+
font-size: 11px;
|
| 636 |
+
letter-spacing: .04em;
|
| 637 |
+
text-transform: uppercase;
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
.model-notices ul {
|
| 641 |
+
padding-left: 16px;
|
| 642 |
+
margin: 0;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
.model-notices li + li {
|
| 646 |
+
margin-top: 5px;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.model-notices .notice-section {
|
| 650 |
+
margin-right: 4px;
|
| 651 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
| 652 |
+
font-weight: 750;
|
| 653 |
+
opacity: .75;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
.range {
|
| 657 |
--range-progress: 88.89%;
|
| 658 |
width: 100%;
|
|
|
|
| 1117 |
.loading-core,
|
| 1118 |
.metronome,
|
| 1119 |
.beat-pulses circle,
|
| 1120 |
+
.plan-cursor,
|
| 1121 |
.note,
|
| 1122 |
.export-sheet,
|
| 1123 |
.mix-wave,
|
|
|
|
| 1155 |
.is-active .beat-pulses circle:nth-child(5) { animation-delay: -1.6s; }
|
| 1156 |
|
| 1157 |
/* Plan */
|
| 1158 |
+
.plan-bars rect { fill: currentColor; opacity: .13; }
|
| 1159 |
+
.plan-bars .plan-climax { fill: var(--red); opacity: .42; }
|
| 1160 |
+
.is-active .plan-bars rect { animation: plan-rise 1.9s var(--ease) infinite alternate both; transform-box: fill-box; transform-origin: center bottom; }
|
| 1161 |
+
.is-active .plan-bars rect:nth-child(2) { animation-delay: .12s; }
|
| 1162 |
+
.is-active .plan-bars rect:nth-child(3) { animation-delay: .24s; }
|
| 1163 |
+
.is-active .plan-bars rect:nth-child(4) { animation-delay: .36s; }
|
| 1164 |
+
.is-active .plan-bars rect:nth-child(5) { animation-delay: .48s; }
|
| 1165 |
+
.is-active .plan-bars rect:nth-child(6) { animation-delay: .6s; }
|
| 1166 |
+
.is-active .plan-bars rect:nth-child(7) { animation-delay: .72s; }
|
| 1167 |
+
.is-active .plan-bars rect:nth-child(8) { animation-delay: .84s; }
|
| 1168 |
+
.plan-path { stroke-dasharray: 520; stroke-dashoffset: 520; }
|
| 1169 |
+
.is-active .plan-path { animation: plan-draw 2.2s var(--ease) infinite; }
|
| 1170 |
+
.is-active .plan-cursor { animation: plan-cursor 2.2s var(--ease) infinite; }
|
| 1171 |
|
| 1172 |
/* Generate */
|
| 1173 |
.note { fill: var(--red); transform: translate(560px, 140px); }
|
|
|
|
| 1519 |
48%, 100% { opacity: .16; transform: scale(.75); }
|
| 1520 |
}
|
| 1521 |
|
| 1522 |
+
@keyframes plan-rise {
|
| 1523 |
0% { opacity: .12; transform: scaleY(.45); }
|
| 1524 |
100% { opacity: .35; transform: scaleY(1); }
|
| 1525 |
}
|
| 1526 |
|
| 1527 |
+
@keyframes plan-draw {
|
| 1528 |
0% { stroke-dashoffset: 520; }
|
| 1529 |
75%, 100% { stroke-dashoffset: 0; }
|
| 1530 |
}
|
| 1531 |
|
| 1532 |
+
@keyframes plan-cursor {
|
| 1533 |
0% { transform: translate(0, 0); opacity: 0; }
|
| 1534 |
8% { opacity: 1; }
|
| 1535 |
12% { transform: translate(0, 0); }
|
static/app.js
CHANGED
|
@@ -16,10 +16,16 @@ const ui = {
|
|
| 16 |
removeFile: $("#remove-file"),
|
| 17 |
fileError: $("#file-error"),
|
| 18 |
model: $("#model"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
level: $("#level"),
|
| 20 |
levelOutput: $("#level-output"),
|
| 21 |
bpm: $("#bpm"),
|
|
|
|
| 22 |
useBeat: $("#use-beat"),
|
|
|
|
| 23 |
sampling: $("#sampling"),
|
| 24 |
samplingParameters: $("#sampling-parameters"),
|
| 25 |
temperature: $("#temperature"),
|
|
@@ -51,13 +57,13 @@ const ui = {
|
|
| 51 |
metricsGrid: $("#metrics-grid"),
|
| 52 |
previewBlock: $(".preview-block"),
|
| 53 |
chartTab: $("#chart-tab"),
|
| 54 |
-
|
| 55 |
chartPreview: $("#chart-preview"),
|
| 56 |
-
|
| 57 |
chartImage: $("#chart-image"),
|
| 58 |
-
|
| 59 |
chartEmpty: $("#chart-empty"),
|
| 60 |
-
|
| 61 |
downloadTja: $("#download-tja"),
|
| 62 |
downloadAudio: $("#download-audio"),
|
| 63 |
newChartButton: $("#new-chart-button"),
|
|
@@ -80,10 +86,10 @@ const stageDefaults = {
|
|
| 80 |
title: "Finding the beat",
|
| 81 |
detail: "Finding beats and tempo."
|
| 82 |
},
|
| 83 |
-
|
| 84 |
-
eyebrow: "
|
| 85 |
-
title: "
|
| 86 |
-
detail: "
|
| 87 |
},
|
| 88 |
generate: {
|
| 89 |
eyebrow: "COMPOSING",
|
|
@@ -108,15 +114,26 @@ const stageDefaults = {
|
|
| 108 |
};
|
| 109 |
|
| 110 |
const metricLabels = {
|
| 111 |
-
model: "Model",
|
| 112 |
bpm: "BPM",
|
| 113 |
notes: "Notes",
|
| 114 |
spans: "Drumroll sections",
|
| 115 |
timing: "Timing mode",
|
| 116 |
grid_rms_ms: "Grid error",
|
| 117 |
-
preview_hits: "Audition hits"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
};
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
let client = null;
|
| 121 |
let connectPromise = null;
|
| 122 |
let currentFile = null;
|
|
@@ -409,6 +426,10 @@ function setDownload(anchor, fileValue, defaultName) {
|
|
| 409 |
|
| 410 |
function metricValue(key, value) {
|
| 411 |
if (value === null || value === undefined || value === "") return "—";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
if (key === "grid_rms_ms" && Number.isFinite(Number(value))) return `${Number(value).toFixed(1)} ms`;
|
| 413 |
if (key === "bpm" && Number.isFinite(Number(value))) return Number(value).toFixed(1);
|
| 414 |
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
@@ -424,11 +445,11 @@ function renderMetrics(metrics) {
|
|
| 424 |
return;
|
| 425 |
}
|
| 426 |
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
ui.metricsGrid.hidden = entries.length === 0;
|
| 433 |
entries.forEach(([key, label, value]) => {
|
| 434 |
const wrapper = document.createElement("div");
|
|
@@ -446,14 +467,14 @@ function renderMetrics(metrics) {
|
|
| 446 |
function activatePreview(name, focus = false) {
|
| 447 |
const chartActive = name === "chart";
|
| 448 |
ui.chartTab.classList.toggle("is-active", chartActive);
|
| 449 |
-
ui.
|
| 450 |
ui.chartTab.setAttribute("aria-selected", String(chartActive));
|
| 451 |
-
ui.
|
| 452 |
ui.chartTab.tabIndex = chartActive ? 0 : -1;
|
| 453 |
-
ui.
|
| 454 |
ui.chartPreview.hidden = !chartActive;
|
| 455 |
-
ui.
|
| 456 |
-
if (focus) (chartActive ? ui.chartTab : ui.
|
| 457 |
}
|
| 458 |
|
| 459 |
function renderResult(payload) {
|
|
@@ -470,15 +491,11 @@ function renderResult(payload) {
|
|
| 470 |
|
| 471 |
renderMetrics(payload.metrics);
|
| 472 |
const hasChart = setImage(ui.chartImage, ui.chartEmpty, files.chart_image);
|
| 473 |
-
const
|
| 474 |
-
|
| 475 |
-
ui.structureEmpty,
|
| 476 |
-
files.structure_image
|
| 477 |
-
);
|
| 478 |
-
ui.previewBlock.hidden = !hasChart && !hasStructure;
|
| 479 |
ui.chartTab.hidden = !hasChart;
|
| 480 |
-
ui.
|
| 481 |
-
activatePreview(hasChart ? "chart" : "
|
| 482 |
|
| 483 |
setDownload(ui.downloadTja, files.tja, "softchart.tja");
|
| 484 |
setDownload(ui.downloadAudio, files.audio, "softchart-taiko-preview.wav");
|
|
@@ -514,6 +531,74 @@ function handlePayload(payload) {
|
|
| 514 |
}
|
| 515 |
}
|
| 516 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
function requestValues() {
|
| 518 |
const selectedCourse = $('input[name="course"]:checked', ui.form);
|
| 519 |
const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value);
|
|
@@ -523,12 +608,14 @@ function requestValues() {
|
|
| 523 |
selectedCourse ? selectedCourse.value : "oni",
|
| 524 |
Number.parseInt(ui.level.value, 10),
|
| 525 |
Number.isFinite(bpm) ? bpm : 0,
|
|
|
|
| 526 |
ui.useBeat.checked,
|
|
|
|
| 527 |
ui.sampling.checked,
|
| 528 |
Number(ui.temperature.value),
|
| 529 |
Number(ui.topP.value),
|
| 530 |
Number(ui.drumVolume.value),
|
| 531 |
-
ui.model.value
|
| 532 |
];
|
| 533 |
}
|
| 534 |
|
|
@@ -644,6 +731,11 @@ function bindEvents() {
|
|
| 644 |
range.addEventListener("input", updateRanges);
|
| 645 |
});
|
| 646 |
ui.sampling.addEventListener("change", () => setSamplingEnabled(ui.sampling.checked));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
ui.cancelButton.addEventListener("click", cancelGeneration);
|
| 648 |
ui.retryButton.addEventListener("click", () => ui.form.requestSubmit());
|
| 649 |
ui.newChartButton.addEventListener("click", () => {
|
|
@@ -654,13 +746,13 @@ function bindEvents() {
|
|
| 654 |
});
|
| 655 |
|
| 656 |
ui.chartTab.addEventListener("click", () => activatePreview("chart"));
|
| 657 |
-
ui.
|
| 658 |
$$(".preview-tab").forEach((tab) => {
|
| 659 |
tab.addEventListener("keydown", (event) => {
|
| 660 |
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
|
| 661 |
event.preventDefault();
|
| 662 |
-
const next = tab === ui.chartTab ? "
|
| 663 |
-
const target = next === "chart" ? ui.chartTab : ui.
|
| 664 |
if (!target.hidden) activatePreview(next, true);
|
| 665 |
});
|
| 666 |
});
|
|
@@ -674,6 +766,10 @@ bindEvents();
|
|
| 674 |
updateRanges();
|
| 675 |
setSamplingEnabled(ui.sampling.checked);
|
| 676 |
resetProcess();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
connect().catch(() => {
|
| 678 |
ui.queueLabel.textContent = "We will reconnect when you start generation";
|
| 679 |
});
|
|
|
|
| 16 |
removeFile: $("#remove-file"),
|
| 17 |
fileError: $("#file-error"),
|
| 18 |
model: $("#model"),
|
| 19 |
+
experimentalModels: $("#experimental-models"),
|
| 20 |
+
modelNotices: $("#model-notices"),
|
| 21 |
+
modelNoticesTitle: $("#model-notices-title"),
|
| 22 |
+
modelNoticesList: $("#model-notices-list"),
|
| 23 |
level: $("#level"),
|
| 24 |
levelOutput: $("#level-output"),
|
| 25 |
bpm: $("#bpm"),
|
| 26 |
+
autoPlan: $("#auto-plan"),
|
| 27 |
useBeat: $("#use-beat"),
|
| 28 |
+
usePlanner: $("#use-planner"),
|
| 29 |
sampling: $("#sampling"),
|
| 30 |
samplingParameters: $("#sampling-parameters"),
|
| 31 |
temperature: $("#temperature"),
|
|
|
|
| 57 |
metricsGrid: $("#metrics-grid"),
|
| 58 |
previewBlock: $(".preview-block"),
|
| 59 |
chartTab: $("#chart-tab"),
|
| 60 |
+
planTab: $("#plan-tab"),
|
| 61 |
chartPreview: $("#chart-preview"),
|
| 62 |
+
planPreview: $("#plan-preview"),
|
| 63 |
chartImage: $("#chart-image"),
|
| 64 |
+
planImage: $("#plan-image"),
|
| 65 |
chartEmpty: $("#chart-empty"),
|
| 66 |
+
planEmpty: $("#plan-empty"),
|
| 67 |
downloadTja: $("#download-tja"),
|
| 68 |
downloadAudio: $("#download-audio"),
|
| 69 |
newChartButton: $("#new-chart-button"),
|
|
|
|
| 86 |
title: "Finding the beat",
|
| 87 |
detail: "Finding beats and tempo."
|
| 88 |
},
|
| 89 |
+
plan: {
|
| 90 |
+
eyebrow: "PLANNING",
|
| 91 |
+
title: "Shaping the arc",
|
| 92 |
+
detail: "Planning density and climaxes."
|
| 93 |
},
|
| 94 |
generate: {
|
| 95 |
eyebrow: "COMPOSING",
|
|
|
|
| 114 |
};
|
| 115 |
|
| 116 |
const metricLabels = {
|
|
|
|
| 117 |
bpm: "BPM",
|
| 118 |
notes: "Notes",
|
| 119 |
spans: "Drumroll sections",
|
| 120 |
timing: "Timing mode",
|
| 121 |
grid_rms_ms: "Grid error",
|
| 122 |
+
preview_hits: "Audition hits",
|
| 123 |
+
model: "Model",
|
| 124 |
+
difficulty: "Difficulty",
|
| 125 |
+
plan_source: "Song plan",
|
| 126 |
+
chart_grid: "Chart lattice",
|
| 127 |
+
grid_anchor: "Grid anchor",
|
| 128 |
+
density_bucket: "Density bucket",
|
| 129 |
+
sampling: "Sampling",
|
| 130 |
+
tempo_map: "Tempo map"
|
| 131 |
};
|
| 132 |
|
| 133 |
+
// Filled from /api/capabilities: the server owns the model list and the model
|
| 134 |
+
// card's limitation text, so the interface never restates either from memory.
|
| 135 |
+
let capabilities = null;
|
| 136 |
+
|
| 137 |
let client = null;
|
| 138 |
let connectPromise = null;
|
| 139 |
let currentFile = null;
|
|
|
|
| 426 |
|
| 427 |
function metricValue(key, value) {
|
| 428 |
if (value === null || value === undefined || value === "") return "—";
|
| 429 |
+
if (key === "timing") {
|
| 430 |
+
if (value === "slot-exact") return "Grid-exact";
|
| 431 |
+
if (value === "time-quantized") return "Time-quantized";
|
| 432 |
+
}
|
| 433 |
if (key === "grid_rms_ms" && Number.isFinite(Number(value))) return `${Number(value).toFixed(1)} ms`;
|
| 434 |
if (key === "bpm" && Number.isFinite(Number(value))) return Number(value).toFixed(1);
|
| 435 |
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
|
|
| 445 |
return;
|
| 446 |
}
|
| 447 |
|
| 448 |
+
// Only metrics the server actually reported. A key it omitted (the 2.0
|
| 449 |
+
// lattice fields on a 1.x run, say) is not a missing value to show as "—".
|
| 450 |
+
const entries = Object.entries(metricLabels)
|
| 451 |
+
.filter(([key]) => key in metrics)
|
| 452 |
+
.map(([key, label]) => [key, label, metrics[key]]);
|
| 453 |
ui.metricsGrid.hidden = entries.length === 0;
|
| 454 |
entries.forEach(([key, label, value]) => {
|
| 455 |
const wrapper = document.createElement("div");
|
|
|
|
| 467 |
function activatePreview(name, focus = false) {
|
| 468 |
const chartActive = name === "chart";
|
| 469 |
ui.chartTab.classList.toggle("is-active", chartActive);
|
| 470 |
+
ui.planTab.classList.toggle("is-active", !chartActive);
|
| 471 |
ui.chartTab.setAttribute("aria-selected", String(chartActive));
|
| 472 |
+
ui.planTab.setAttribute("aria-selected", String(!chartActive));
|
| 473 |
ui.chartTab.tabIndex = chartActive ? 0 : -1;
|
| 474 |
+
ui.planTab.tabIndex = chartActive ? -1 : 0;
|
| 475 |
ui.chartPreview.hidden = !chartActive;
|
| 476 |
+
ui.planPreview.hidden = chartActive;
|
| 477 |
+
if (focus) (chartActive ? ui.chartTab : ui.planTab).focus();
|
| 478 |
}
|
| 479 |
|
| 480 |
function renderResult(payload) {
|
|
|
|
| 491 |
|
| 492 |
renderMetrics(payload.metrics);
|
| 493 |
const hasChart = setImage(ui.chartImage, ui.chartEmpty, files.chart_image);
|
| 494 |
+
const hasPlan = setImage(ui.planImage, ui.planEmpty, files.plan_image);
|
| 495 |
+
ui.previewBlock.hidden = !hasChart && !hasPlan;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
ui.chartTab.hidden = !hasChart;
|
| 497 |
+
ui.planTab.hidden = !hasPlan;
|
| 498 |
+
activatePreview(hasChart ? "chart" : "plan");
|
| 499 |
|
| 500 |
setDownload(ui.downloadTja, files.tja, "softchart.tja");
|
| 501 |
setDownload(ui.downloadAudio, files.audio, "softchart-taiko-preview.wav");
|
|
|
|
| 531 |
}
|
| 532 |
}
|
| 533 |
|
| 534 |
+
function selectedCourseValue() {
|
| 535 |
+
const selected = $('input[name="course"]:checked', ui.form);
|
| 536 |
+
return selected ? selected.value : "oni";
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
function selectedModelValue() {
|
| 540 |
+
return ui.model ? ui.model.value : "v1.7";
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
// The model card's wording, verbatim. Anything shown here is a defect the
|
| 544 |
+
// tester is meant to recognise as already known, so it is never paraphrased.
|
| 545 |
+
function renderNotices() {
|
| 546 |
+
if (!capabilities || !ui.modelNotices) return;
|
| 547 |
+
const model = (capabilities.models || []).find((m) => m.value === selectedModelValue());
|
| 548 |
+
const course = selectedCourseValue();
|
| 549 |
+
const courseNotices = (capabilities.course_notices || {})[course] || [];
|
| 550 |
+
const plannerOn = ui.usePlanner ? ui.usePlanner.checked : false;
|
| 551 |
+
const items = [];
|
| 552 |
+
|
| 553 |
+
if (model && model.experimental) {
|
| 554 |
+
(model.limitations || []).forEach((entry) => items.push(entry));
|
| 555 |
+
(model.notes || []).forEach((text) => items.push({ text }));
|
| 556 |
+
}
|
| 557 |
+
courseNotices.forEach((entry) => {
|
| 558 |
+
if (entry.when === "planner" && !plannerOn) return;
|
| 559 |
+
if (entry.when === "planner" && model && model.experimental) return;
|
| 560 |
+
items.push({ text: entry.text });
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
ui.modelNoticesList.replaceChildren();
|
| 564 |
+
items.forEach((entry) => {
|
| 565 |
+
const li = document.createElement("li");
|
| 566 |
+
if (entry.section) {
|
| 567 |
+
const tag = document.createElement("span");
|
| 568 |
+
tag.className = "notice-section";
|
| 569 |
+
tag.textContent = entry.section;
|
| 570 |
+
li.append(tag);
|
| 571 |
+
}
|
| 572 |
+
li.append(document.createTextNode(entry.text));
|
| 573 |
+
ui.modelNoticesList.append(li);
|
| 574 |
+
});
|
| 575 |
+
ui.modelNoticesTitle.textContent = model && model.experimental
|
| 576 |
+
? "Experimental — known limitations"
|
| 577 |
+
: "Known limitations";
|
| 578 |
+
ui.modelNotices.hidden = items.length === 0;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
async function loadCapabilities() {
|
| 582 |
+
const response = await fetch("/api/capabilities", { headers: { Accept: "application/json" } });
|
| 583 |
+
if (!response.ok) throw new Error(`capabilities ${response.status}`);
|
| 584 |
+
capabilities = await response.json();
|
| 585 |
+
|
| 586 |
+
const experimental = (capabilities.models || []).filter((m) => m.experimental);
|
| 587 |
+
if (ui.experimentalModels && experimental.length) {
|
| 588 |
+
ui.experimentalModels.replaceChildren();
|
| 589 |
+
experimental.forEach((model) => {
|
| 590 |
+
const option = document.createElement("option");
|
| 591 |
+
option.value = model.value;
|
| 592 |
+
option.textContent = model.label;
|
| 593 |
+
ui.experimentalModels.append(option);
|
| 594 |
+
});
|
| 595 |
+
if (experimental[0].group) ui.experimentalModels.label = experimental[0].group;
|
| 596 |
+
ui.experimentalModels.disabled = false;
|
| 597 |
+
ui.experimentalModels.hidden = false;
|
| 598 |
+
}
|
| 599 |
+
renderNotices();
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
function requestValues() {
|
| 603 |
const selectedCourse = $('input[name="course"]:checked', ui.form);
|
| 604 |
const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value);
|
|
|
|
| 608 |
selectedCourse ? selectedCourse.value : "oni",
|
| 609 |
Number.parseInt(ui.level.value, 10),
|
| 610 |
Number.isFinite(bpm) ? bpm : 0,
|
| 611 |
+
ui.autoPlan.checked,
|
| 612 |
ui.useBeat.checked,
|
| 613 |
+
ui.usePlanner.checked,
|
| 614 |
ui.sampling.checked,
|
| 615 |
Number(ui.temperature.value),
|
| 616 |
Number(ui.topP.value),
|
| 617 |
Number(ui.drumVolume.value),
|
| 618 |
+
ui.model ? ui.model.value : "v1.7"
|
| 619 |
];
|
| 620 |
}
|
| 621 |
|
|
|
|
| 731 |
range.addEventListener("input", updateRanges);
|
| 732 |
});
|
| 733 |
ui.sampling.addEventListener("change", () => setSamplingEnabled(ui.sampling.checked));
|
| 734 |
+
if (ui.model) ui.model.addEventListener("change", renderNotices);
|
| 735 |
+
if (ui.usePlanner) ui.usePlanner.addEventListener("change", renderNotices);
|
| 736 |
+
$$('input[name="course"]', ui.form).forEach((radio) => {
|
| 737 |
+
radio.addEventListener("change", renderNotices);
|
| 738 |
+
});
|
| 739 |
ui.cancelButton.addEventListener("click", cancelGeneration);
|
| 740 |
ui.retryButton.addEventListener("click", () => ui.form.requestSubmit());
|
| 741 |
ui.newChartButton.addEventListener("click", () => {
|
|
|
|
| 746 |
});
|
| 747 |
|
| 748 |
ui.chartTab.addEventListener("click", () => activatePreview("chart"));
|
| 749 |
+
ui.planTab.addEventListener("click", () => activatePreview("plan"));
|
| 750 |
$$(".preview-tab").forEach((tab) => {
|
| 751 |
tab.addEventListener("keydown", (event) => {
|
| 752 |
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
|
| 753 |
event.preventDefault();
|
| 754 |
+
const next = tab === ui.chartTab ? "plan" : "chart";
|
| 755 |
+
const target = next === "chart" ? ui.chartTab : ui.planTab;
|
| 756 |
if (!target.hidden) activatePreview(next, true);
|
| 757 |
});
|
| 758 |
});
|
|
|
|
| 766 |
updateRanges();
|
| 767 |
setSamplingEnabled(ui.sampling.checked);
|
| 768 |
resetProcess();
|
| 769 |
+
loadCapabilities().catch(() => {
|
| 770 |
+
// The seven published models are in the markup already; only the
|
| 771 |
+
// experimental entry and the limitation text depend on this call.
|
| 772 |
+
});
|
| 773 |
connect().catch(() => {
|
| 774 |
ui.queueLabel.textContent = "We will reconnect when you start generation";
|
| 775 |
});
|
static/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<meta name="description" content="SoftChart
|
| 7 |
<meta name="theme-color" content="#f6f3ec">
|
| 8 |
<title>SoftChart — AI Taiko Chart Studio</title>
|
| 9 |
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='25' fill='%23e14b3f'/%3E%3Ccircle cx='32' cy='32' r='18' fill='%23fff8ed'/%3E%3Ccircle cx='32' cy='32' r='9' fill='%23e14b3f'/%3E%3C/svg%3E">
|
|
@@ -34,11 +34,11 @@
|
|
| 34 |
<section class="hero" aria-labelledby="hero-title">
|
| 35 |
<div class="hero-copy">
|
| 36 |
<h1 id="hero-title">Music in.<br><em>Taiko out.</em></h1>
|
| 37 |
-
<p class="hero-lede">
|
| 38 |
</div>
|
| 39 |
<ul class="hero-features" aria-label="Key features">
|
| 40 |
-
<li><strong>
|
| 41 |
-
<li><strong>
|
| 42 |
<li><strong>Instant audition</strong></li>
|
| 43 |
</ul>
|
| 44 |
</section>
|
|
@@ -86,23 +86,30 @@
|
|
| 86 |
<label for="model">Model</label>
|
| 87 |
</div>
|
| 88 |
<select id="model" class="model-select">
|
| 89 |
-
<optgroup label="
|
| 90 |
-
<option value="v1.
|
|
|
|
|
|
|
| 91 |
</optgroup>
|
| 92 |
-
<optgroup label="
|
| 93 |
-
<option value="v1.
|
| 94 |
-
<option value="v1.
|
| 95 |
-
<option value="v1.
|
| 96 |
</optgroup>
|
| 97 |
-
<optgroup label="
|
| 98 |
-
<option value="v1.
|
| 99 |
-
<option value="v1.6-small">V1.6 small · 3.6M</option>
|
| 100 |
-
<option value="v1.6-tiny">V1.6 tiny · 1.4M</option>
|
| 101 |
-
</optgroup>
|
| 102 |
-
<optgroup label="V1.5 — original planner path">
|
| 103 |
-
<option value="v1.5">V1.5 · 7.9M</option>
|
| 104 |
</optgroup>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</div>
|
| 107 |
|
| 108 |
<fieldset class="field-group">
|
|
@@ -112,6 +119,7 @@
|
|
| 112 |
<label><input type="radio" name="course" value="normal"><span><small>N</small>Normal</span></label>
|
| 113 |
<label><input type="radio" name="course" value="hard"><span><small>H</small>Hard</span></label>
|
| 114 |
<label><input type="radio" name="course" value="oni" checked><span><small>O</small>Oni</span></label>
|
|
|
|
| 115 |
</div>
|
| 116 |
</fieldset>
|
| 117 |
|
|
@@ -141,11 +149,21 @@
|
|
| 141 |
</summary>
|
| 142 |
<div class="advanced-body">
|
| 143 |
<div class="toggle-list">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
<label class="toggle-card compact">
|
| 145 |
<span><strong>Beat grid</strong></span>
|
| 146 |
<input id="use-beat" type="checkbox" checked>
|
| 147 |
<span class="switch" aria-hidden="true"></span>
|
| 148 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
</div>
|
| 150 |
<label class="toggle-card compact">
|
| 151 |
<span><strong>Creative sampling</strong></span>
|
|
@@ -230,20 +248,20 @@
|
|
| 230 |
</svg>
|
| 231 |
</div>
|
| 232 |
|
| 233 |
-
<div class="stage-visual" data-stage="
|
| 234 |
<svg viewBox="0 0 520 280">
|
| 235 |
-
<g class="
|
| 236 |
<rect x="42" y="166" width="45" height="60" rx="5"/>
|
| 237 |
<rect x="96" y="143" width="45" height="83" rx="5"/>
|
| 238 |
<rect x="150" y="153" width="45" height="73" rx="5"/>
|
| 239 |
<rect x="204" y="105" width="45" height="121" rx="5"/>
|
| 240 |
<rect x="258" y="127" width="45" height="99" rx="5"/>
|
| 241 |
<rect x="312" y="83" width="45" height="143" rx="5"/>
|
| 242 |
-
<rect x="366" y="53" width="45" height="173" rx="5" class="
|
| 243 |
<rect x="420" y="118" width="45" height="108" rx="5"/>
|
| 244 |
</g>
|
| 245 |
-
<path class="
|
| 246 |
-
<circle class="
|
| 247 |
</svg>
|
| 248 |
</div>
|
| 249 |
|
|
@@ -344,16 +362,16 @@
|
|
| 344 |
<div class="preview-block">
|
| 345 |
<div class="preview-tabs" role="tablist" aria-label="Preview type">
|
| 346 |
<button id="chart-tab" class="preview-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="chart-preview">Chart preview</button>
|
| 347 |
-
<button id="
|
| 348 |
</div>
|
| 349 |
<div class="preview-frame">
|
| 350 |
<div id="chart-preview" role="tabpanel" aria-labelledby="chart-tab">
|
| 351 |
<img id="chart-image" alt="Preview of the generated Taiko chart">
|
| 352 |
<p class="preview-empty" id="chart-empty" hidden>No chart preview is available.</p>
|
| 353 |
</div>
|
| 354 |
-
<div id="
|
| 355 |
-
<img id="
|
| 356 |
-
<p class="preview-empty" id="
|
| 357 |
</div>
|
| 358 |
</div>
|
| 359 |
</div>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<meta name="description" content="SoftChart turns music into playable, musical Taiko charts with an instant drum-mix audition.">
|
| 7 |
<meta name="theme-color" content="#f6f3ec">
|
| 8 |
<title>SoftChart — AI Taiko Chart Studio</title>
|
| 9 |
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='25' fill='%23e14b3f'/%3E%3Ccircle cx='32' cy='32' r='18' fill='%23fff8ed'/%3E%3Ccircle cx='32' cy='32' r='9' fill='%23e14b3f'/%3E%3C/svg%3E">
|
|
|
|
| 34 |
<section class="hero" aria-labelledby="hero-title">
|
| 35 |
<div class="hero-copy">
|
| 36 |
<h1 id="hero-title">Music in.<br><em>Taiko out.</em></h1>
|
| 37 |
+
<p class="hero-lede">Upload a track. Get a playable TJA. Hear it instantly.</p>
|
| 38 |
</div>
|
| 39 |
<ul class="hero-features" aria-label="Key features">
|
| 40 |
+
<li><strong>Beat-locked</strong></li>
|
| 41 |
+
<li><strong>Structure-aware</strong></li>
|
| 42 |
<li><strong>Instant audition</strong></li>
|
| 43 |
</ul>
|
| 44 |
</section>
|
|
|
|
| 86 |
<label for="model">Model</label>
|
| 87 |
</div>
|
| 88 |
<select id="model" class="model-select">
|
| 89 |
+
<optgroup label="v1.7 — richest patterns (recommended)">
|
| 90 |
+
<option value="v1.7" selected>v1.7 · 7.9M — best quality</option>
|
| 91 |
+
<option value="v1.7-small">v1.7-small · 3.6M</option>
|
| 92 |
+
<option value="v1.7-tiny">v1.7-tiny · 1.4M — fastest</option>
|
| 93 |
</optgroup>
|
| 94 |
+
<optgroup label="v1.6 — scaling ladder">
|
| 95 |
+
<option value="v1.6">v1.6 · 7.9M</option>
|
| 96 |
+
<option value="v1.6-small">v1.6-small · 3.6M</option>
|
| 97 |
+
<option value="v1.6-tiny">v1.6-tiny · 1.4M</option>
|
| 98 |
</optgroup>
|
| 99 |
+
<optgroup label="v1.5 — single model">
|
| 100 |
+
<option value="v1.5">v1.5 · 7.9M</option>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</optgroup>
|
| 102 |
+
<!-- BarScript is the experimental preview line, served from its
|
| 103 |
+
own release package. The group stays disabled and hidden
|
| 104 |
+
until /api/capabilities reports the package resolved; the
|
| 105 |
+
label below is a placeholder that app.js overwrites with the
|
| 106 |
+
group name the API returns. -->
|
| 107 |
+
<optgroup label="BarScript — EXPERIMENTAL PREVIEW, under evaluation" id="experimental-models" hidden disabled></optgroup>
|
| 108 |
</select>
|
| 109 |
+
<div class="model-notices" id="model-notices" hidden>
|
| 110 |
+
<strong id="model-notices-title">Known limitations</strong>
|
| 111 |
+
<ul id="model-notices-list"></ul>
|
| 112 |
+
</div>
|
| 113 |
</div>
|
| 114 |
|
| 115 |
<fieldset class="field-group">
|
|
|
|
| 119 |
<label><input type="radio" name="course" value="normal"><span><small>N</small>Normal</span></label>
|
| 120 |
<label><input type="radio" name="course" value="hard"><span><small>H</small>Hard</span></label>
|
| 121 |
<label><input type="radio" name="course" value="oni" checked><span><small>O</small>Oni</span></label>
|
| 122 |
+
<label><input type="radio" name="course" value="ura"><span><small>U</small>Ura</span></label>
|
| 123 |
</div>
|
| 124 |
</fieldset>
|
| 125 |
|
|
|
|
| 149 |
</summary>
|
| 150 |
<div class="advanced-body">
|
| 151 |
<div class="toggle-list">
|
| 152 |
+
<label class="toggle-card compact">
|
| 153 |
+
<span><strong>Structure</strong></span>
|
| 154 |
+
<input id="auto-plan" type="checkbox" checked>
|
| 155 |
+
<span class="switch" aria-hidden="true"></span>
|
| 156 |
+
</label>
|
| 157 |
<label class="toggle-card compact">
|
| 158 |
<span><strong>Beat grid</strong></span>
|
| 159 |
<input id="use-beat" type="checkbox" checked>
|
| 160 |
<span class="switch" aria-hidden="true"></span>
|
| 161 |
</label>
|
| 162 |
+
<label class="toggle-card compact">
|
| 163 |
+
<span><strong>AI planner</strong></span>
|
| 164 |
+
<input id="use-planner" type="checkbox" checked>
|
| 165 |
+
<span class="switch" aria-hidden="true"></span>
|
| 166 |
+
</label>
|
| 167 |
</div>
|
| 168 |
<label class="toggle-card compact">
|
| 169 |
<span><strong>Creative sampling</strong></span>
|
|
|
|
| 248 |
</svg>
|
| 249 |
</div>
|
| 250 |
|
| 251 |
+
<div class="stage-visual" data-stage="plan" aria-hidden="true">
|
| 252 |
<svg viewBox="0 0 520 280">
|
| 253 |
+
<g class="plan-bars">
|
| 254 |
<rect x="42" y="166" width="45" height="60" rx="5"/>
|
| 255 |
<rect x="96" y="143" width="45" height="83" rx="5"/>
|
| 256 |
<rect x="150" y="153" width="45" height="73" rx="5"/>
|
| 257 |
<rect x="204" y="105" width="45" height="121" rx="5"/>
|
| 258 |
<rect x="258" y="127" width="45" height="99" rx="5"/>
|
| 259 |
<rect x="312" y="83" width="45" height="143" rx="5"/>
|
| 260 |
+
<rect x="366" y="53" width="45" height="173" rx="5" class="plan-climax"/>
|
| 261 |
<rect x="420" y="118" width="45" height="108" rx="5"/>
|
| 262 |
</g>
|
| 263 |
+
<path class="plan-path" d="M64 158 118 135l54 10 54-49 54 22 54-43 54-31 54 65" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
| 264 |
+
<circle class="plan-cursor" cx="64" cy="158" r="7" fill="currentColor"/>
|
| 265 |
</svg>
|
| 266 |
</div>
|
| 267 |
|
|
|
|
| 362 |
<div class="preview-block">
|
| 363 |
<div class="preview-tabs" role="tablist" aria-label="Preview type">
|
| 364 |
<button id="chart-tab" class="preview-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="chart-preview">Chart preview</button>
|
| 365 |
+
<button id="plan-tab" class="preview-tab" type="button" role="tab" aria-selected="false" aria-controls="plan-preview" tabindex="-1">Song structure</button>
|
| 366 |
</div>
|
| 367 |
<div class="preview-frame">
|
| 368 |
<div id="chart-preview" role="tabpanel" aria-labelledby="chart-tab">
|
| 369 |
<img id="chart-image" alt="Preview of the generated Taiko chart">
|
| 370 |
<p class="preview-empty" id="chart-empty" hidden>No chart preview is available.</p>
|
| 371 |
</div>
|
| 372 |
+
<div id="plan-preview" role="tabpanel" aria-labelledby="plan-tab" hidden>
|
| 373 |
+
<img id="plan-image" alt="Song energy and chart section plan">
|
| 374 |
+
<p class="preview-empty" id="plan-empty" hidden>No song structure preview is available.</p>
|
| 375 |
</div>
|
| 376 |
</div>
|
| 377 |
</div>
|