JacobLinCool commited on
Commit
d27cb1d
·
verified ·
1 Parent(s): be60cf4

Add BarScript (experimental preview) model + ura difficulty; vendored sc2 subpackage

Browse files
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
- datasets:
23
- - JacobLinCool/taiko-1000-parsed-clean
24
  ---
25
 
26
  # 🥁 SoftChart
27
 
28
- Generate a **Taiko no Tatsujin-style** chart from an uploaded song with the
29
- scratch-trained [SoftChart V1.8 hierarchical model](https://huggingface.co/JacobLinCool/softchart-v18).
30
- The 8,985,091-parameter model combines whole-song hierarchical context, a local
31
- rhythmic-skeleton auxiliary head, chart generation, and beat/downbeat
32
- estimation. It uses no external pretrained model and no external song planner.
33
-
34
- V1.8 is the default recommendation, not the only option. Users can select
35
- V1.5, V1.6, or V1.7—including the small and tiny scaling variants—from the
36
- interface for direct comparison. Those releases run through their frozen
37
- generation runtime and original
38
- [legacy planner](https://huggingface.co/JacobLinCool/softchart-planner); this
39
- does not change V1.8's self-contained, scratch-only architecture.
40
-
41
- The Space always uses each selected model's full-song **time generation** path
42
- for arbitrary uploads. The beat head can estimate BPM and a downbeat grid for TJA
43
- quantization, but that estimated grid is not treated as the trusted rational
44
- meter required by exact slot generation. This distinction prevents an
45
- estimated beat grid from being presented as authored-meter exact timing.
46
-
47
- Audio preprocessing follows the released model contract: FFmpeg decodes
48
- stereo float32 at 22,050 Hz, channels are averaged arithmetically, and a
49
- periodic-Hann STFT is projected to 128 log-mel bins. Upload a song, choose a
50
- difficulty, and receive:
51
-
52
- - a playable `.tja` for TJAPlayer3 / OpenTaiko;
53
- - a rendered chart image;
54
- - the full-song mel structure view used by the hierarchy;
55
- - a synchronized WAV audition with synthesized Taiko hits.
56
-
57
- The audition is rendered from the exported TJA, including its `OFFSET`, so the
58
- preview and downloadable chart use the same timing. Taiko voices are original
59
- modal-resonator syntheses included under the project's MIT license.
60
-
61
- The model and Space code are **MIT-licensed**. Evaluation scope, metrics, and
62
- known limitations are documented on the
63
- [V1.8 model card](https://huggingface.co/JacobLinCool/softchart-v18).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 — selectable Taiko chart model generations for HF Spaces.
2
 
3
- V1.8 is the recommended scratch-trained hierarchical model. V1.5–V1.7 remain
4
- available through their frozen runtime so users can compare generations.
5
- Arbitrary uploads always use time generation; an estimated beat grid may
6
- quantize the exported TJA but is never promoted to a trusted-meter slot path.
 
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 legacy_softchart.generate import (
24
- generate_song as generate_song_legacy,
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
- EXPECTED_PARAMETERS = 8_985_091
47
-
48
- MODEL_SPECS = {
49
- "v1.8": {
50
- "repo": "JacobLinCool/softchart-v18",
51
- "display": "V1.8 hierarchical",
52
- "runtime": "v18",
53
- },
54
- "v1.7": {
55
- "repo": "JacobLinCool/softchart-v17",
56
- "display": "V1.7",
57
- "runtime": "legacy",
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.8"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- _MEL_FB = None
100
- _STFT_WINDOW = None
101
 
102
 
103
- def get_model(model_choice):
104
- spec = MODEL_SPECS.get(model_choice)
105
- if spec is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  raise ValueError(
107
- f"Unknown model {model_choice!r}. Choose one of: "
108
- f"{', '.join(MODEL_SPECS)}."
109
  )
110
- if model_choice not in _MODELS:
111
- if spec["runtime"] == "v18":
112
- model = load_hf_v18(spec["repo"], device=DEVICE)
113
- else:
114
- model = load_hf_legacy(spec["repo"], device=DEVICE)
115
- capabilities = getattr(model, "_softchart_capabilities", {})
116
- if spec["runtime"] == "v18":
117
- required = {
118
- "aux": True,
119
- "beat_head": True,
120
- "dual": True,
121
- "hierarchical_ctx": True,
122
- "global_ctx": False,
123
- "plan": False,
124
- }
125
- mismatched = {
126
- name: capabilities.get(name)
127
- for name, expected in required.items()
128
- if capabilities.get(name) is not expected
129
- }
130
- parameters = sum(parameter.numel() for parameter in model.parameters())
131
- if (not getattr(model, "_hierarchical_ctx", False)
132
- or getattr(model, "_has_plan", False) or mismatched
133
- or parameters != EXPECTED_PARAMETERS):
134
- raise RuntimeError(
135
- f"{spec['repo']} is not the expected hierarchical V1.8 artifact"
136
- )
137
- bundle = {
138
- "model": model,
139
- "generate": generate_song_v18,
140
- "fit_grid": fit_grid_piecewise,
141
- "fit_grid_fixed": fit_grid_fixed_bpm,
142
- "preprocess": load_logmel_v18,
143
- "runtime": "v18",
144
- }
145
- else:
146
- if (not getattr(model, "_dual", False) or model.beat is None
147
- or not getattr(model, "_has_plan", False)):
148
- raise RuntimeError(
149
- f"{spec['repo']} is not a supported V1.5–V1.7 artifact"
150
- )
151
- bundle = {
152
- "model": model,
153
- "generate": generate_song_legacy,
154
- "fit_grid": fit_grid_piecewise_legacy,
155
- "fit_grid_fixed": fit_grid_fixed_bpm_legacy,
156
- "preprocess": load_logmel_legacy,
157
- "runtime": "legacy",
158
- }
159
- _MODELS[model_choice] = bundle
160
- return _MODELS[model_choice]
161
-
162
-
163
- def get_legacy_planner():
164
- if "planner" not in _PLANNER:
165
- _PLANNER["planner"] = (
166
- SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
167
  )
168
- return _PLANNER["planner"]
169
 
170
 
171
- def load_logmel_v18(path):
172
- """Apply the exact V1.8 FFmpeg + torch.stft preprocessing contract."""
173
- import librosa
174
- global _MEL_FB, _STFT_WINDOW
175
-
176
- command = [
177
- "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
178
- "-i", os.fspath(path), "-vn", "-ac", "2", "-ar", str(SR),
179
- "-f", "f32le", "-acodec", "pcm_f32le", "pipe:1",
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 load_logmel_legacy(path):
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
- sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2,
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
- duration = mel.shape[1] / FPS
230
  beat = 60.0 / bpm
231
- edges = (
232
- list(downbeats[::4]) + [duration]
233
- if downbeats is not None and len(downbeats) >= 2
234
- else list(np.arange(0, duration, 4 * beat)) + [duration]
235
- )
236
- features = []
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
- flux = np.maximum(0, np.diff(segment, axis=1)).sum(0)
243
- features.append(np.concatenate([
244
- segment.mean(1), segment.std(1),
245
- [flux.mean(), flux.std(), flux.max()],
246
- ]))
247
- spans.append((round(float(start), 3), round(float(end), 3)))
248
- if not features:
249
  return None
250
- course_id = {"easy": 0, "normal": 1, "hard": 2, "oni": 3}[course]
251
- inputs = torch.tensor(np.array(features), dtype=torch.float32)[None].to(DEVICE)
252
  with torch.no_grad():
253
- density_logits, flag_logits = planner(
254
- inputs, torch.tensor([course_id], device=DEVICE)
255
- )
256
- densities = density_logits[0].argmax(-1).cpu().numpy()
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 measure in range(n_meas):
359
  in_gogo = append_measure_with_gogo(
360
  lines,
361
- "".join(
362
- slots.get(measure * SUB + slot, "0") for slot in range(SUB)
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:{'Oni' if course == 'oni' else course.capitalize()}",
374
  f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
375
  "", "#START", *lines, "#END"]) + "\n"
376
 
377
 
378
- def render_song_structure(mel, title, course, out_path, plan=None):
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
- grid = fig.add_gridspec(
393
- 2 if plan else 1, 1,
394
- height_ratios=[3.0, 1.0] if plan else [1],
395
- hspace=0.14 if plan else 0.0,
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(grid[1], sharex=ax0)
407
- for start, end, density, flag in plan:
408
- color = "#d64545" if flag == 2 else (
409
- "#4a90d9" if flag == 1 else "#999999"
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) — legacy plan: density / gap / climax")
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
- "status": "ok",
495
- "default_model": DEFAULT_MODEL,
496
- "available_models": list(MODEL_SPECS),
497
- "device": DEVICE,
498
- "models_loaded": sorted(_MODELS),
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
- spec = MODEL_SPECS.get(model_choice)
535
- if spec is None:
 
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 {spec['display']}.",
544
  )
545
- bundle = get_model(model_choice)
546
- model = bundle["model"]
 
 
 
 
 
 
 
547
 
548
  yield _progress(
549
  "audio", 0.11, "Listening",
550
  "Mapping rhythm, melody, and timbre.",
551
  )
552
- mel, wav = bundle["preprocess"](audio_path)
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 = bundle["fit_grid"](model, mel, device=DEVICE)
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 = bundle["fit_grid_fixed"](
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
- "structure", 0.34, structure_title, structure_detail,
 
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
- generated = bundle["generate"](
613
- model, mel, course, level=level,
614
- density_bucket=COURSE_DENS[course], greedy=not sampling,
615
- temperature=temperature, top_p=top_p, seed=0, device=DEVICE,
616
- plan=plan,
617
- )
618
- generated = snap_chart(generated, bpm)
619
- tja = write_tja(
620
- generated, bpm, title, course, level, wave_name, dbs,
621
- grid_fit=grid, plan=plan,
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
- structure_image_path = os.path.join(workdir, "song-structure.png")
633
  render_tja_image(tja, out_path=chart_image_path)
634
- render_song_structure(
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
- "structure_image": _file_data(
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
- checkpoint = torch.load(ckpt_path, map_location=device, weights_only=False)
57
- state, _args, config, capabilities = parse_generator_checkpoint(checkpoint)
58
- model = ChartModel(**config).to(device)
59
- model.load_state_dict(state, strict=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  model.eval()
61
- attach_generator_capabilities(model, capabilities)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if not isinstance(caps, dict):
74
- raise ValueError("HF generator capabilities must be an explicit mapping")
75
- attach_generator_capabilities(m, caps)
76
- m._softchart_capabilities = dict(caps)
77
- if (m.section_encoder is not None) != m._hierarchical_ctx:
78
- raise ValueError("HF hierarchy capability disagrees with its architecture")
79
- if bool(m.gsum_proj is not None) != bool(caps.get("global_ctx", False)):
80
- # New V1.8 artifacts include global_ctx explicitly. Requiring it here
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
- seq += [
108
- v.course[course] if course else v.unk_cond,
109
- v.level[max(1, min(N_LEVELS, level))] if level else v.unk_cond,
110
- v.dens[density_bucket] if density_bucket is not None else v.unk_cond,
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 = _make_unconditional_prefixes(seqs)
 
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 = ((time_pos >= (last_time[idx] + min_gap).unsqueeze(1))
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(n_pos - 1, int(n_pos * starts[i + j] / max(T, 1)))
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
- min_gap=MIN_GAP_FRAMES, position_limits=limits)
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
- gsum = torch.from_numpy(song_summary(mel.detach().cpu().numpy()))
 
 
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
- # Missing END is malformed decoder output. Drop the orphaned
405
- # span and preserve the authored hit; inventing an endpoint or
406
- # swallowing hits would silently rewrite the sequence.
407
- open_span = None
 
 
 
 
 
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
- if t - open_span[0] > 0.05:
418
- spans.append({"t0": round(open_span[0], 4), "t1": round(t, 4),
419
- "type": open_span[1]})
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
- """Meter-aware generation on the exact 96-slots-per-4/4 TJA lattice.
 
 
453
 
454
- Decoder positions are slot-linear across complete measures. Rational
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
- measure_slots, measure_num, measure_den, quarter_beats = \
471
- resolve_meter_layout(grid, len(db))
472
- if "measure_slot_safe" in grid:
473
- slot_safe = np.asarray(grid["measure_slot_safe"])
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
- nslots = 0
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
- raise ValueError(
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
- # Negative chart offsets are real; pad the pre-audio region instead of
505
- # letting Python's negative slice wrap around to the song tail.
506
- x = torch.full((mel.shape[0], src), float(np.log(1e-5)),
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 * float(quarter_beats[j + m])) % 1.0
524
  wins.append(torch.cat([x, ph], dim=0))
525
- local_offsets = np.concatenate([
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, _ = metas[i0 + b_]
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
- min_gap=2, position_limits=limits,
570
- gsum=g, pos_buckets=pb)
571
  for b_, w_ev in enumerate(evs):
572
- jj, KK, offsets = metas[i0 + b_]
573
  for g, cls in w_ev:
574
- if g < offsets[-1]:
575
- local_m = int(np.searchsorted(offsets[1:], g, side="right"))
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
- slots_in_measure = int(measure_slots[me])
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 = None
 
 
 
 
 
 
 
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
- if t - open_span[2] > 0.05:
608
- # The explicit END slot is authoritative. Derive both
609
- # preview seconds and TJA coordinates from that same slot;
610
- # duration caps must not move either representation.
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
- # Beat/downbeat estimation must remain local and phase-free even
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
- Space runtime wrapper for the unified V1.8 generator.
 
 
 
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
- from .vocab import VOCAB
 
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, nhead=8, enc_layers=4, dec_layers=4, ffn=1024,
28
- dropout=0.1,
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
- hierarchical_ctx=False, section_layers=2, section_ffn=None,
32
- clean_phase=False,
33
- capabilities=None):
 
34
  super().__init__()
 
 
 
 
 
35
  self.net = ChartModel(
36
- d_model=d_model, nhead=nhead, enc_layers=enc_layers,
37
- dec_layers=dec_layers, ffn=ffn, dropout=dropout,
38
- vocab_size=vocab_size, aux=aux, global_ctx=global_ctx, func_time=func_time,
39
- ptr=ptr, beat=beat, in_ch=in_ch, emb_factor=emb_factor,
40
- hierarchical_ctx=hierarchical_ctx, section_layers=section_layers,
41
- section_ffn=section_ffn, clean_phase=clean_phase,
 
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))). Both projections and the gate start on
95
- an active gradient path. The up projection is initialized very small so the
96
- initial adapter is a near-identity perturbation without the dead-gradient
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.normal_(self.up.weight, std=1e-3)
105
- self.gate = nn.Parameter(torch.ones(1))
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, so every logical depth can specialize while reusing the block.
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
- enc_layer, enc_layers, nn.LayerNorm(d_model),
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
- # Auxiliary local rhythmic skeleton. In the hierarchical model its
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(100, d_model) # relative song position
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, 100, n_mels) complete-song section summaries;
421
- # pos_bucket: (B,) current relative song section in [0, 100).
422
- h = self._local_input(mel)
423
- if self.section_encoder is not None:
424
- if gsum is None or pos_bucket is None:
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 exactly zero phase conditioning."""
442
- out = memory.clone()
443
- L = WINDOW // 4
444
  nch = N_MELS
445
- if self.clean_phase and mel.shape[1] >= nch + 2:
446
- ph = mel[:, nch:nch + 2] # [0,1); -1 marks no-grid frames
447
- valid = (ph[:, :1] >= 0).float()
448
- ph_in = torch.cat([ph.clamp(min=0.0), valid], dim=1)
449
- p = self.phase_proj(ph_in)
450
- # Mask *after* both biased convolutions. Masking only their input
451
- # leaves a learned bias residual in gridless time-mode windows.
452
- valid_out = nn.functional.adaptive_max_pool1d(valid, p.shape[-1])
453
- p = (p * valid_out).transpose(1, 2)
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 = torch.ones((L, L), dtype=torch.bool, device=tgt_in.device).triu(1)
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 = torch.ones((L, L), dtype=torch.bool, device=tgt.device).triu(1)
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
- """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:",
 
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 = 768 # max decoder length (prefix + events + eos)
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
- # v2+ tokens appended at the end so older checkpoints stay loadable
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, duration_seconds=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 / float(duration_seconds)
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(4, 1fr);
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
- .structure-cursor,
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
- .structure-bars rect { fill: currentColor; opacity: .13; }
1123
- .structure-bars .structure-peak { fill: var(--red); opacity: .42; }
1124
- .is-active .structure-bars rect { animation: structure-rise 1.9s var(--ease) infinite alternate both; transform-box: fill-box; transform-origin: center bottom; }
1125
- .is-active .structure-bars rect:nth-child(2) { animation-delay: .12s; }
1126
- .is-active .structure-bars rect:nth-child(3) { animation-delay: .24s; }
1127
- .is-active .structure-bars rect:nth-child(4) { animation-delay: .36s; }
1128
- .is-active .structure-bars rect:nth-child(5) { animation-delay: .48s; }
1129
- .is-active .structure-bars rect:nth-child(6) { animation-delay: .6s; }
1130
- .is-active .structure-bars rect:nth-child(7) { animation-delay: .72s; }
1131
- .is-active .structure-bars rect:nth-child(8) { animation-delay: .84s; }
1132
- .structure-path { stroke-dasharray: 520; stroke-dashoffset: 520; }
1133
- .is-active .structure-path { animation: structure-draw 2.2s var(--ease) infinite; }
1134
- .is-active .structure-cursor { animation: structure-cursor 2.2s var(--ease) infinite; }
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 structure-rise {
1487
  0% { opacity: .12; transform: scaleY(.45); }
1488
  100% { opacity: .35; transform: scaleY(1); }
1489
  }
1490
 
1491
- @keyframes structure-draw {
1492
  0% { stroke-dashoffset: 520; }
1493
  75%, 100% { stroke-dashoffset: 0; }
1494
  }
1495
 
1496
- @keyframes structure-cursor {
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
- structureTab: $("#structure-tab"),
55
  chartPreview: $("#chart-preview"),
56
- structurePreview: $("#structure-preview"),
57
  chartImage: $("#chart-image"),
58
- structureImage: $("#structure-image"),
59
  chartEmpty: $("#chart-empty"),
60
- structureEmpty: $("#structure-empty"),
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
- structure: {
84
- eyebrow: "STRUCTURE",
85
- title: "Reading the structure",
86
- detail: "Building whole-song hierarchical context."
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
- const entries = Object.entries(metricLabels).map(([key, label]) => [
428
- key,
429
- label,
430
- metrics[key]
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.structureTab.classList.toggle("is-active", !chartActive);
450
  ui.chartTab.setAttribute("aria-selected", String(chartActive));
451
- ui.structureTab.setAttribute("aria-selected", String(!chartActive));
452
  ui.chartTab.tabIndex = chartActive ? 0 : -1;
453
- ui.structureTab.tabIndex = chartActive ? -1 : 0;
454
  ui.chartPreview.hidden = !chartActive;
455
- ui.structurePreview.hidden = chartActive;
456
- if (focus) (chartActive ? ui.chartTab : ui.structureTab).focus();
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 hasStructure = setImage(
474
- ui.structureImage,
475
- ui.structureEmpty,
476
- files.structure_image
477
- );
478
- ui.previewBlock.hidden = !hasChart && !hasStructure;
479
  ui.chartTab.hidden = !hasChart;
480
- ui.structureTab.hidden = !hasStructure;
481
- activatePreview(hasChart ? "chart" : "structure");
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.structureTab.addEventListener("click", () => activatePreview("structure"));
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 ? "structure" : "chart";
663
- const target = next === "chart" ? ui.chartTab : ui.structureTab;
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 V1.8 turns music into playable Taiko charts with a scratch-trained hierarchical model.">
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">Hierarchical V1.8 listens across the whole song, writes a playable TJA, and lets you hear it instantly.</p>
38
  </div>
39
  <ul class="hero-features" aria-label="Key features">
40
- <li><strong>V1.8 scratch model</strong></li>
41
- <li><strong>Whole-song hierarchy</strong></li>
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="V1.8 — hierarchical scratch (recommended)">
90
- <option value="v1.8" selected>V1.8 · 9.0M — best research model</option>
 
 
91
  </optgroup>
92
- <optgroup label="V1.7 — original planner path">
93
- <option value="v1.7">V1.7 · 7.9M — best legacy quality</option>
94
- <option value="v1.7-small">V1.7 small · 3.6M</option>
95
- <option value="v1.7-tiny">V1.7 tiny · 1.4M — fastest</option>
96
  </optgroup>
97
- <optgroup label="V1.6 — original planner path">
98
- <option value="v1.6">V1.6 · 7.9M</option>
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="structure" aria-hidden="true">
234
  <svg viewBox="0 0 520 280">
235
- <g class="structure-bars">
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="structure-peak"/>
243
  <rect x="420" y="118" width="45" height="108" rx="5"/>
244
  </g>
245
- <path class="structure-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"/>
246
- <circle class="structure-cursor" cx="64" cy="158" r="7" fill="currentColor"/>
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="structure-tab" class="preview-tab" type="button" role="tab" aria-selected="false" aria-controls="structure-preview" tabindex="-1">Song structure</button>
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="structure-preview" role="tabpanel" aria-labelledby="structure-tab" hidden>
355
- <img id="structure-image" alt="Full-song mel spectrogram used for hierarchical context">
356
- <p class="preview-empty" id="structure-empty" hidden>No song structure preview is available.</p>
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>