Spaces:
Sleeping
Sleeping
BarScript: serve the fitted (non-uniform) bar timeline by default; report grid source, spread and fit quality in the UI
Browse files- README.md +25 -4
- SC2_INTEGRATION.md +112 -9
- app.py +151 -27
- scripts/test_app_wiring.py +320 -74
- scripts/test_sc2_vendor.py +7 -1
- softchart/barscript_grid.py +446 -0
- softchart/sc2_loader.py +145 -29
- static/app.css +16 -0
- static/app.js +20 -3
README.md
CHANGED
|
@@ -71,10 +71,31 @@ package's contract. A Hub snapshot is additionally verified against the
|
|
| 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
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 takes no song plan,
|
| 75 |
+
so the Structure and AI planner switches do not reach it.
|
| 76 |
+
|
| 77 |
+
BarScript decodes onto a supplied bar timeline and refuses to generate without a
|
| 78 |
+
downbeat rather than anchoring the bars on an arbitrary phase. When the beat fit
|
| 79 |
+
passes its own quality gate, that timeline is the fitted barlines themselves —
|
| 80 |
+
unevenly spaced when the fit found a tempo change — and every note position and
|
| 81 |
+
every `#BPMCHANGE` in the exported TJA is derived from those bar edges. When the
|
| 82 |
+
fit misses the gate the bars are tiled from a single BPM instead and no tempo
|
| 83 |
+
change can be represented. The result says which of the two happened, with the
|
| 84 |
+
fit's inlier fraction and RMS, the number of distinct bar lengths served, and any
|
| 85 |
+
repair applied; a fallback is never silent.
|
| 86 |
+
|
| 87 |
+
How often the barlines are right is a separate question from whether they *can*
|
| 88 |
+
be uneven, and the answer is not good yet. Over 60 held-out songs (30 that change
|
| 89 |
+
tempo per the authored trace, 30 that do not), 16 were served a non-uniform grid
|
| 90 |
+
and only 8 of those actually change tempo — the verdict is a coin flip at this
|
| 91 |
+
sample size. Nine of the 16 span a factor of ≥ 1.9 between their longest and
|
| 92 |
+
shortest bar, the signature of a barline the estimator split in two rather than
|
| 93 |
+
of a tempo change. The interface therefore reports the spread as a measurement
|
| 94 |
+
and states that the two cases are not told apart, instead of announcing that a
|
| 95 |
+
tempo change was captured.
|
| 96 |
+
|
| 97 |
+
Per-bar **time signature** is a different matter again: nothing here estimates
|
| 98 |
+
meter from audio, so one estimated time signature covers the whole song.
|
| 99 |
|
| 100 |
`python spaces/scripts/test_app_wiring.py` checks the routing, the resolution
|
| 101 |
order above, the five difficulties, the Ura TJA header, and — against a recorded
|
SC2_INTEGRATION.md
CHANGED
|
@@ -130,6 +130,87 @@ In the browser: upload audio, pick **BarScript (preview)**, pick a difficulty
|
|
| 130 |
|
| 131 |
---
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
## Tests
|
| 134 |
|
| 135 |
Three suites, all CPU, no network required (the third reads published models
|
|
@@ -142,8 +223,17 @@ python scripts/test_app_wiring.py # routing, resolution order, ura, limitati
|
|
| 142 |
HF_HUB_OFFLINE=1 python scripts/test_space_e2e.py # real weights, real music
|
| 143 |
```
|
| 144 |
|
| 145 |
-
Current status: **all green** —
|
| 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`
|
|
@@ -195,6 +285,12 @@ Every chart, on every difficulty, satisfies:
|
|
| 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**;
|
|
@@ -288,8 +384,10 @@ interface when BarScript is selected.
|
|
| 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 |
-
|
| 292 |
-
BarScript needs a beat grid
|
|
|
|
|
|
|
| 293 |
|
| 294 |
### The Ura warning applies to every model
|
| 295 |
|
|
@@ -339,10 +437,14 @@ substitution entirely.
|
|
| 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. **
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -358,7 +460,8 @@ substitution entirely.
|
|
| 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 |
|
|
|
|
| 130 |
|
| 131 |
---
|
| 132 |
|
| 133 |
+
## The bar timeline
|
| 134 |
+
|
| 135 |
+
Two different things are called "the grid" and they have to be kept apart.
|
| 136 |
+
|
| 137 |
+
| | what it is | who decides it |
|
| 138 |
+
|---|---|---|
|
| 139 |
+
| **bar-local lattice** | how finely a note may sit inside one bar — the shipped `/16` | the release package (`SHIP_GRID_DENOM`), reported as `Chart lattice` |
|
| 140 |
+
| **bar timeline** | where each barline falls in the audio | the caller, reported as `Bar grid` / `Tempo map` |
|
| 141 |
+
|
| 142 |
+
Only the second can express a tempo change, and until now the Space could not.
|
| 143 |
+
`sc2_loader.SoftChart2.generate` synthesized the timeline from one scalar BPM,
|
| 144 |
+
`bar_sec = (240/bpm) * num/den` tiled from the first downbeat, which is uniform
|
| 145 |
+
by construction. It now takes **either** `bpm=` (that same uniform tiling) **or**
|
| 146 |
+
`grid=` (a supplied timeline used verbatim) — exactly one, never both.
|
| 147 |
+
|
| 148 |
+
`softchart/barscript_grid.py` builds the supplied timeline from the beat fit the
|
| 149 |
+
Space already computes. `grid.fit_grid_piecewise` returns downbeats that are
|
| 150 |
+
*not* evenly spaced when it splices in a local tempo, so the fix is to pass those
|
| 151 |
+
times through as the bar edges instead of re-tiling from `fit["bpm"]`. Both the
|
| 152 |
+
decoder and the frozen TJA writer read every time out of `measure_edges` —
|
| 153 |
+
`write_tja_slots` recomputes `60 * quarter_beats[m] / (edges[m+1] - edges[m])`
|
| 154 |
+
per bar and emits `#BPMCHANGE` whenever it moves — so nothing else had to change,
|
| 155 |
+
and neither `softchart/sc2/` nor the shipping tree was touched.
|
| 156 |
+
|
| 157 |
+
The route is chosen by the fit's own gate, not by a new threshold:
|
| 158 |
+
|
| 159 |
+
| | timeline | `grid_source` |
|
| 160 |
+
|---|---|---|
|
| 161 |
+
| fit passes `fit["ok"]` | the fitted downbeats, verbatim | `piecewise` |
|
| 162 |
+
| fit fails the gate, or none | uniform tiling from one BPM | `uniform_fallback` |
|
| 163 |
+
|
| 164 |
+
A manual BPM needs no special case: `fit_grid_fixed_bpm` trusts the user's period
|
| 165 |
+
and estimates only the phase, so its downbeats are evenly spaced and the same
|
| 166 |
+
route yields a uniform timeline at the requested tempo.
|
| 167 |
+
|
| 168 |
+
On top of `fit["ok"]` the builder applies structural checks only — a bar must
|
| 169 |
+
imply 30–400 BPM (a strict superset of what the estimator can emit), the fit
|
| 170 |
+
must cover the audio within four extrapolated bars, and a splice joint shorter
|
| 171 |
+
than one 400 BPM bar is merged within a 5 % budget. Every repair is counted and
|
| 172 |
+
reported.
|
| 173 |
+
|
| 174 |
+
### How well it works, measured
|
| 175 |
+
|
| 176 |
+
60 held-out songs from the `test` split, full length, through the deployed beat
|
| 177 |
+
path (v1.7 beat head + `fit_grid_piecewise`); ground truth for "does this song
|
| 178 |
+
change tempo" is the authored trace.
|
| 179 |
+
|
| 180 |
+
| trace says | served non-uniform | served uniform (piecewise route) | fell back to one BPM |
|
| 181 |
+
|---|---|---|---|
|
| 182 |
+
| changes tempo (n=30) | 8 (27 %) | 14 (47 %) | 8 (27 %) |
|
| 183 |
+
| constant (n=30) | **8 (27 %)** | 16 (53 %) | 6 (20 %) |
|
| 184 |
+
|
| 185 |
+
So 16 songs were served a non-uniform timeline and **8 of them do not change
|
| 186 |
+
tempo** — at this sample size the verdict is a coin flip. Nine of the 16 span a
|
| 187 |
+
factor of ≥ 1.9 between longest and shortest bar, six of those landing within
|
| 188 |
+
1 % of exactly ×2: the signature of a barline the estimator split in two, not of
|
| 189 |
+
a tempo change. Six of the nine wide-spread grids are on trace-constant songs.
|
| 190 |
+
|
| 191 |
+
Two consequences, both deliberate:
|
| 192 |
+
|
| 193 |
+
1. The interface says `follows the estimated barlines`, gives the spread as a
|
| 194 |
+
number, and states that a split barline and a real tempo change are not told
|
| 195 |
+
apart. It does **not** say the tempo change was captured.
|
| 196 |
+
2. No new gate was added to suppress the wide-spread cases. The bound that would
|
| 197 |
+
catch them has to be *relative* to the local bar period — the absolute
|
| 198 |
+
30–400 BPM bound cannot, since a 360 BPM bar beside 180 BPM bars is inside
|
| 199 |
+
it — and specifying and validating such a rule is a design change, not a
|
| 200 |
+
deployment one. Inventing a threshold here and shipping it unvalidated would
|
| 201 |
+
be worse than shipping the measurement.
|
| 202 |
+
|
| 203 |
+
Variable **meter** is not supported at all. `fit_grid` reports a single meter, so
|
| 204 |
+
`measure_num`/`measure_den` are constant and `meter_source` is recorded as
|
| 205 |
+
`fit_estimate`. The grid dict and the TJA writer can both express a per-bar
|
| 206 |
+
`#MEASURE`, but nothing in this stack estimates one from audio; writing one would
|
| 207 |
+
be fabrication. Recomputing the corpus from `trace` over 1 134 songs / 4 449
|
| 208 |
+
charts, 29.0 % of charts change tempo and 52.4 % change meter — the earlier
|
| 209 |
+
combined figure of 62.3 % overstated what this module reaches by about a factor
|
| 210 |
+
of two, and the Space copy of the module says so in its docstring.
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
## Tests
|
| 215 |
|
| 216 |
Three suites, all CPU, no network required (the third reads published models
|
|
|
|
| 223 |
HF_HUB_OFFLINE=1 python scripts/test_space_e2e.py # real weights, real music
|
| 224 |
```
|
| 225 |
|
| 226 |
+
Current status: **all green** — 6 vendor checks, 147 wiring checks, 380
|
| 227 |
+
end-to-end checks (6 songs × 5 difficulties, plus the published-model replay).
|
| 228 |
+
|
| 229 |
+
30 of the wiring checks are new with the bar timeline: that the loader is handed
|
| 230 |
+
a supplied grid and not a scalar BPM, that the served bar edges are bitwise the
|
| 231 |
+
fitted downbeats, that a two-tempo fit comes out with two bar lengths at the
|
| 232 |
+
right tempi, that a constant song and a manual BPM both come out uniform, that a
|
| 233 |
+
failed quality gate falls back and says so with its numbers, that the loader
|
| 234 |
+
refuses an ambiguous `bpm=`+`grid=` call and a grid whose lattice disagrees with
|
| 235 |
+
`grid_denom`, and — through the real decoder and the frozen TJA writer — that a
|
| 236 |
+
two-tempo timeline reaches the file as a header `BPM:120` plus `#BPMCHANGE 150`.
|
| 237 |
|
| 238 |
15 of the wiring checks cover package resolution: that `$SC2_PACKAGE_DIR`
|
| 239 |
outranks a local copy and the Hub, that an unsatisfiable `$SC2_PACKAGE_DIR`
|
|
|
|
| 285 |
- the note and span counts shown in the UI equal the counts recovered by
|
| 286 |
re-parsing the file.
|
| 287 |
|
| 288 |
+
On these six songs the bar timeline is uniform either way: four fits pass their
|
| 289 |
+
gate and produce evenly spaced barlines, two miss it and fall back, so the served
|
| 290 |
+
grids are identical to what shipped before. The excerpts are 60 s taken from 20 %
|
| 291 |
+
into each song, which is why the fits are worse here than on full songs. The
|
| 292 |
+
non-uniform path is measured separately, on full-length audio, above.
|
| 293 |
+
|
| 294 |
### Ura is harder than Oni — but by much less than authored charts
|
| 295 |
|
| 296 |
Note counts per course, same song. Generated rows cover the **60 s excerpt**;
|
|
|
|
| 384 |
| §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. |
|
| 385 |
| corpus | **Ura was trained on 208 charts** against roughly 1 100 per other course; expect lower quality. |
|
| 386 |
|
| 387 |
+
Four operational notes are shown with them: the plan switches do not apply,
|
| 388 |
+
BarScript needs a beat grid, a tempo change is followed only when the beat fit
|
| 389 |
+
passes its gate (and the result says which happened), and a time-signature
|
| 390 |
+
change is never followed.
|
| 391 |
|
| 392 |
### The Ura warning applies to every model
|
| 393 |
|
|
|
|
| 437 |
drumrolls. The ladder above shows ura landing *below* authored density, which
|
| 438 |
is consistent with the knob not delivering what it asks for.
|
| 439 |
|
| 440 |
+
4. **A non-uniform bar timeline is served, but its accuracy is a coin flip.**
|
| 441 |
+
BarScript now decodes onto the fitted barlines, so a tempo change *can*
|
| 442 |
+
survive — see [The bar timeline](#the-bar-timeline). Over 60 held-out songs
|
| 443 |
+
only 8 of the 16 non-uniform timelines belong to a song that actually changes
|
| 444 |
+
tempo, and 9 of the 16 carry a ≥ 1.9× bar-length spread that looks like a
|
| 445 |
+
split barline. The relative bound that would catch those is a design change
|
| 446 |
+
and is not made here; the interface reports the spread and the ambiguity
|
| 447 |
+
instead of claiming a tempo change was captured.
|
| 448 |
|
| 449 |
5. **No human play-testing.** Every claim here is structural or statistical.
|
| 450 |
Whether these charts are *good* — groove, pattern quality, whether the ura
|
|
|
|
| 460 |
| path | role |
|
| 461 |
|---|---|
|
| 462 |
| `spaces/softchart/sc2/` | vendored BarScript serving code, 8 modules, byte-identical to the frozen shipping tree (md5 checked against the package at every load) |
|
| 463 |
+
| `spaces/softchart/sc2_loader.py` | the only bridge: package loading, integrity checks, serving contract, supplied-grid route, `COURSE:` header rewrite, limitation text |
|
| 464 |
+
| `spaces/softchart/barscript_grid.py` | builds the supplied bar timeline from a beat fit, so a tempo change reaches the decoder and the TJA |
|
| 465 |
| `spaces/app.py` | routing, ura, the BarScript branch, `/api/capabilities` |
|
| 466 |
| `spaces/static/` | model menu, ura option, limitation panel |
|
| 467 |
| `spaces/scripts/test_sc2_vendor.py` | vendored tree matches the package; 1.x untouched |
|
app.py
CHANGED
|
@@ -21,6 +21,8 @@ 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
|
|
@@ -414,22 +416,32 @@ def render_audio_plan(mel, title, course, out_path, plan=None):
|
|
| 414 |
return out_path
|
| 415 |
|
| 416 |
|
| 417 |
-
def
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
"""
|
| 431 |
-
if
|
| 432 |
-
offset_sec = float(
|
| 433 |
anchor = "fitted beat grid"
|
| 434 |
elif dbs is not None and len(dbs):
|
| 435 |
offset_sec = float(dbs[0])
|
|
@@ -440,35 +452,131 @@ def generate_sc2(sc2, mel, grid, dbs, bpm, course, level, wave_name, title,
|
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
| 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 "
|
|
@@ -557,6 +665,14 @@ def capabilities():
|
|
| 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 {
|
|
@@ -655,9 +771,14 @@ def generate_chart(
|
|
| 655 |
"beat", 0.21, "Finding the beat",
|
| 656 |
"Finding beats and tempo.",
|
| 657 |
)
|
| 658 |
-
grid
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]:
|
|
@@ -666,6 +787,8 @@ def generate_chart(
|
|
| 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"]
|
|
@@ -718,7 +841,8 @@ def generate_chart(
|
|
| 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,
|
|
|
|
| 722 |
sampling=sampling, temperature=temperature, top_p=top_p,
|
| 723 |
)
|
| 724 |
slot_used = True
|
|
|
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
|
| 23 |
from softchart import sc2_loader
|
| 24 |
+
from softchart.barscript_grid import (GridFitError, barscript_grid_from_fit,
|
| 25 |
+
describe_grid)
|
| 26 |
from softchart.generate import generate_song, generate_song_slot, load_hf
|
| 27 |
from softchart.fonts import cjk_font_path
|
| 28 |
from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
|
|
|
|
| 416 |
return out_path
|
| 417 |
|
| 418 |
|
| 419 |
+
def sc2_bar_grid(sc2, fit, beat_fit_raw, dbs, bpm, duration_sec):
|
| 420 |
+
"""Bar timeline for a BarScript decode: ``(grid, anchor, offset_sec)``.
|
| 421 |
+
|
| 422 |
+
BarScript decodes onto a bar timeline supplied by the caller, so it needs
|
| 423 |
+
the audio time of the first downbeat. That cannot be guessed: a timeline
|
| 424 |
+
anchored on the wrong phase puts every barline in the wrong place, and the
|
| 425 |
+
chart would look like a model defect rather than a missing input. The route
|
| 426 |
+
therefore fails closed when no downbeat is available.
|
| 427 |
+
|
| 428 |
+
``fit`` is the beat fit that PASSED its own quality gate, or None. When one
|
| 429 |
+
is present its downbeats become the bar edges verbatim, so a tempo change
|
| 430 |
+
the fit found survives into the decode and into the TJA's ``#BPMCHANGE``
|
| 431 |
+
lines. ``fit_grid_piecewise`` returns evenly spaced downbeats for a
|
| 432 |
+
constant-tempo song and for the manual-BPM refit (``fit_grid_fixed_bpm``
|
| 433 |
+
trusts the user's period and estimates only the phase), so those two cases
|
| 434 |
+
take the same route and simply come out uniform -- there is no separate
|
| 435 |
+
uniform branch to keep in sync.
|
| 436 |
+
|
| 437 |
+
Without a passing fit the grid is re-tiled from one scalar BPM, which is
|
| 438 |
+
the behaviour this route has always had and cannot represent a tempo
|
| 439 |
+
change. ``beat_fit_raw`` -- the fit that failed the gate, if there was one
|
| 440 |
+
-- is handed to the builder so the fallback records WHY it happened
|
| 441 |
+
(inlier fraction and RMS) instead of reporting a missing fit.
|
| 442 |
"""
|
| 443 |
+
if fit is not None and len(fit.get("downbeats", [])):
|
| 444 |
+
offset_sec = float(fit["downbeats"][0])
|
| 445 |
anchor = "fitted beat grid"
|
| 446 |
elif dbs is not None and len(dbs):
|
| 447 |
offset_sec = float(dbs[0])
|
|
|
|
| 452 |
"them without one. Leave the beat grid switched on, or enter the "
|
| 453 |
"song's BPM in Advanced settings, and try again."
|
| 454 |
)
|
| 455 |
+
try:
|
| 456 |
+
grid = barscript_grid_from_fit(
|
| 457 |
+
fit if fit is not None else beat_fit_raw, duration_sec,
|
| 458 |
+
denom=sc2_loader.SHIP_GRID_DENOM, spec=sc2.spec,
|
| 459 |
+
on_invalid="fallback", fallback_bpm=float(bpm),
|
| 460 |
+
fallback_offset=offset_sec,
|
| 461 |
+
)
|
| 462 |
+
except GridFitError as exc: # only reachable if the fallback itself fails
|
| 463 |
+
raise RuntimeError(
|
| 464 |
+
f"Could not build a bar timeline for this song: {exc}"
|
| 465 |
+
) from exc
|
| 466 |
+
return grid, anchor, offset_sec
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def sc2_grid_metrics(grid, grid_info, anchor, offset_sec):
|
| 470 |
+
"""How the bar timeline is reported to the user.
|
| 471 |
+
|
| 472 |
+
The user's question is "was this song's tempo change handled?", so the
|
| 473 |
+
answer names the route (estimated barlines vs. one uniform tempo), says
|
| 474 |
+
whether the timeline that came out actually varies, and gives the fit's
|
| 475 |
+
quality numbers. A fallback is never silent: it carries the builder's own
|
| 476 |
+
reason string.
|
| 477 |
+
"""
|
| 478 |
+
meta = grid_info.get("grid_meta") or {}
|
| 479 |
+
quality = grid_info.get("fit_quality")
|
| 480 |
+
source = grid_info.get("grid_source", "unknown")
|
| 481 |
+
n_tempi = int(grid_info.get("n_distinct_bar_sec", 1))
|
| 482 |
+
inlier = rms = "n/a"
|
| 483 |
+
if quality is not None:
|
| 484 |
+
if quality["inlier_frac"] is not None:
|
| 485 |
+
inlier = f"{quality['inlier_frac']:.3f}"
|
| 486 |
+
if quality["rms_ms"] is not None:
|
| 487 |
+
rms = f"{quality['rms_ms']:.1f} ms"
|
| 488 |
+
out = {
|
| 489 |
+
"grid_anchor": f"{anchor}, first downbeat {offset_sec:.3f} s",
|
| 490 |
+
"bar_grid": describe_grid(grid),
|
| 491 |
+
}
|
| 492 |
+
if source == "piecewise" and n_tempi > 1:
|
| 493 |
+
# Deliberately NOT "the tempo change was captured". The grid follows
|
| 494 |
+
# the estimated barlines, and a barline the estimator split in two is
|
| 495 |
+
# indistinguishable, from here, from a bar at twice the tempo -- both
|
| 496 |
+
# land as a x2 spread. Measured over 60 held-out songs, 16 were served
|
| 497 |
+
# a non-uniform grid and only 8 of those actually change tempo per the
|
| 498 |
+
# authored trace, 9 of the 16 spanning a factor >= 1.9. So the spread
|
| 499 |
+
# is stated as the measurement it is, with the ambiguity attached.
|
| 500 |
+
spread = grid_info["bar_bpm_max"] / max(grid_info["bar_bpm_min"], 1e-9)
|
| 501 |
+
out["tempo_map"] = (
|
| 502 |
+
f"follows the estimated barlines — {n_tempi} distinct bar lengths, "
|
| 503 |
+
f"{grid_info['bar_bpm_min']:.1f}–{grid_info['bar_bpm_max']:.1f} BPM "
|
| 504 |
+
f"across {grid_info['n_bars']} bars, spread ×{spread:.2f}. "
|
| 505 |
+
f"A barline split in two and a genuine tempo change are not "
|
| 506 |
+
f"told apart here; a spread near ×2 is the signature of both."
|
| 507 |
+
)
|
| 508 |
+
elif source == "piecewise":
|
| 509 |
+
out["tempo_map"] = (
|
| 510 |
+
f"constant — the estimated barlines are evenly spaced at "
|
| 511 |
+
f"{grid_info['bar_bpm_min']:.1f} BPM, so no tempo change was found"
|
| 512 |
+
)
|
| 513 |
+
else:
|
| 514 |
+
# The overwhelmingly common fallback is a fit that missed its own
|
| 515 |
+
# quality gate, and the builder's reason string then repeats the two
|
| 516 |
+
# numbers at full float precision. Those numbers are stated here
|
| 517 |
+
# rounded, from ``fit_quality`` rather than by re-reading the string;
|
| 518 |
+
# every OTHER reason (coverage, joint-repair budget, a malformed
|
| 519 |
+
# downbeat sequence) is quoted verbatim, since nothing else carries it.
|
| 520 |
+
why = (f"the beat fit did not pass its quality gate: inlier {inlier}, "
|
| 521 |
+
f"RMS {rms}") if (quality is not None and not quality["ok"]) \
|
| 522 |
+
else (meta.get("reason") or "no reason recorded")
|
| 523 |
+
out["tempo_map"] = (
|
| 524 |
+
f"flattened to one uniform {grid_info['bpm']:.1f} BPM — a tempo "
|
| 525 |
+
f"change in this song would NOT be represented ({why})"
|
| 526 |
+
)
|
| 527 |
+
if quality is not None:
|
| 528 |
+
out["beat_fit"] = (
|
| 529 |
+
f"{'passed' if quality['ok'] else 'FAILED its quality gate'} — "
|
| 530 |
+
f"inlier {inlier}, RMS {rms}, {quality['n_segments']} tempo "
|
| 531 |
+
f"segment(s) found"
|
| 532 |
+
)
|
| 533 |
+
else:
|
| 534 |
+
out["beat_fit"] = "no beat fit — bars tiled from a single BPM"
|
| 535 |
+
repairs = []
|
| 536 |
+
if meta.get("joint_bars_merged"):
|
| 537 |
+
repairs.append(f"{meta['joint_bars_merged']} splice-joint bar(s) merged")
|
| 538 |
+
if meta.get("tail_bars_extrapolated"):
|
| 539 |
+
repairs.append(f"{meta['tail_bars_extrapolated']} tail bar(s) extrapolated")
|
| 540 |
+
if repairs:
|
| 541 |
+
out["grid_repairs"] = "; ".join(repairs)
|
| 542 |
+
return out
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def generate_sc2(sc2, mel, fit, beat_fit_raw, dbs, bpm, course, level,
|
| 546 |
+
wave_name, title, *, sampling, temperature, top_p, seed=0):
|
| 547 |
+
"""Run the BarScript release. Returns ``(generated, tja, extra_metrics)``.
|
| 548 |
+
|
| 549 |
+
Sampling parameters are left at the package's recorded serving contract
|
| 550 |
+
unless the user turns creative sampling on. Anything overridden is reported
|
| 551 |
+
back, because the model card's measurements describe the recorded values.
|
| 552 |
+
"""
|
| 553 |
+
duration_sec = mel.shape[1] / FPS
|
| 554 |
+
grid, anchor, offset_sec = sc2_bar_grid(
|
| 555 |
+
sc2, fit, beat_fit_raw, dbs, bpm, duration_sec)
|
| 556 |
|
| 557 |
overrides = {}
|
| 558 |
if sampling:
|
| 559 |
overrides = {"greedy": False, "temperature": float(temperature),
|
| 560 |
"top_p": float(top_p)}
|
| 561 |
result = sc2.generate(
|
| 562 |
+
mel, course, level=level, grid=grid,
|
| 563 |
density_bucket=COURSE_DENS[course], seed=seed,
|
| 564 |
grid_denom=sc2_loader.SHIP_GRID_DENOM,
|
| 565 |
title=title, wave=wave_name, **overrides,
|
| 566 |
)
|
| 567 |
grid_info = result["grid"]
|
|
|
|
| 568 |
extra = {
|
| 569 |
+
# the bar-LOCAL lattice: how finely a note may sit inside one bar.
|
| 570 |
+
# Unrelated to the bar timeline reported by tempo_map below.
|
| 571 |
+
"chart_grid": (f"/{grid_info['denom']} per bar"
|
| 572 |
+ ("" if grid_info["triplets_representable"]
|
| 573 |
else " — triplets impossible")),
|
|
|
|
| 574 |
"density_bucket": result["density_bucket"],
|
| 575 |
"sampling": ("user override: " + ", ".join(
|
| 576 |
f"{k}={v}" for k, v in sorted(result["serving_overrides"].items()))
|
| 577 |
) if result["serving_overrides"] else "package defaults",
|
| 578 |
+
**sc2_grid_metrics(grid, grid_info, anchor, offset_sec),
|
| 579 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
if not result["motif"]["verified"]:
|
| 581 |
raise RuntimeError(
|
| 582 |
"BarScript decoded without the motif constraint the release "
|
|
|
|
| 665 |
"#GOGO sections are written.",
|
| 666 |
"BarScript needs a beat grid. Leave the beat grid on, or enter the "
|
| 667 |
"song's BPM, so its bar lattice has a downbeat to sit on.",
|
| 668 |
+
"Tempo changes are followed when the beat fit passes its quality "
|
| 669 |
+
"gate: the estimated barlines become the bars, so the chart and "
|
| 670 |
+
"its #BPMCHANGE lines track the song. If the fit fails the gate "
|
| 671 |
+
"the bars are tiled from one BPM instead and a tempo change is "
|
| 672 |
+
"not represented — the result says which happened.",
|
| 673 |
+
"Time-signature changes are never followed. Nothing in this "
|
| 674 |
+
"system estimates meter per bar from audio, so one estimated "
|
| 675 |
+
"time signature is used for the whole song.",
|
| 676 |
]
|
| 677 |
models.append(entry)
|
| 678 |
return {
|
|
|
|
| 771 |
"beat", 0.21, "Finding the beat",
|
| 772 |
"Finding beats and tempo.",
|
| 773 |
)
|
| 774 |
+
# ``grid`` is the fit that PASSED its own quality gate (None otherwise).
|
| 775 |
+
# ``beat_fit_raw`` keeps the last fit that was actually computed, gate
|
| 776 |
+
# or no gate, so a failed gate can be reported with its numbers instead
|
| 777 |
+
# of disappearing into a silent fallback.
|
| 778 |
+
grid = dbs = beat_fit_raw = None
|
| 779 |
if use_beat:
|
| 780 |
grid = fit_grid_piecewise(models["beat"], mel, device=DEVICE)
|
| 781 |
+
beat_fit_raw = grid
|
| 782 |
if grid is not None:
|
| 783 |
dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
|
| 784 |
if not grid["ok"]:
|
|
|
|
| 787 |
bpm = bpm_override
|
| 788 |
if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
|
| 789 |
fixed_grid = fit_grid_fixed_bpm(models["beat"], mel, bpm, device=DEVICE)
|
| 790 |
+
if fixed_grid is not None:
|
| 791 |
+
beat_fit_raw = fixed_grid
|
| 792 |
grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
|
| 793 |
if grid is not None:
|
| 794 |
dbs = grid["downbeats"]
|
|
|
|
| 841 |
slot_used = grid is not None
|
| 842 |
if sc2_used:
|
| 843 |
generated, tja, extra_metrics = generate_sc2(
|
| 844 |
+
sc2, mel, grid, beat_fit_raw, dbs, bpm, course, level,
|
| 845 |
+
wave_name, title,
|
| 846 |
sampling=sampling, temperature=temperature, top_p=top_p,
|
| 847 |
)
|
| 848 |
slot_used = True
|
scripts/test_app_wiring.py
CHANGED
|
@@ -100,6 +100,38 @@ def fake_grid(bpm=150.0, n_bars=8, phase=0.25):
|
|
| 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"}],
|
|
@@ -115,7 +147,8 @@ TJA_STUB = "\n".join([
|
|
| 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
|
|
@@ -148,8 +181,12 @@ def harness(module, calls, *, course="oni", model_choice=None, sampling=False,
|
|
| 148 |
wav = np.zeros(int(20 * 22050), dtype=np.float32)
|
| 149 |
|
| 150 |
patch("load_logmel", record("load_logmel", lambda: (mel, wav)))
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
@@ -533,24 +570,46 @@ def test_planner_fallback():
|
|
| 533 |
# 4. the BarScript route
|
| 534 |
|
| 535 |
|
| 536 |
-
|
| 537 |
-
"""
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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},
|
|
@@ -559,66 +618,22 @@ def test_sc2_conditions():
|
|
| 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:
|
| 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:
|
|
@@ -627,23 +642,213 @@ def test_sc2_conditions():
|
|
| 627 |
saved_mkdtemp = tempfile.mkdtemp
|
| 628 |
tempfile.mkdtemp = lambda *a, **k: workdir
|
| 629 |
try:
|
| 630 |
-
payload = harness(APP, calls, course=
|
| 631 |
-
model_choice=APP.SC2_MODEL,
|
| 632 |
-
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
if not sc2_loader.is_available():
|
| 648 |
print("SKIP 2.0 real decode: no release package "
|
| 649 |
f"({sc2_loader.default_package_dir()})")
|
|
@@ -665,6 +870,46 @@ def test_sc2_real_decode():
|
|
| 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 |
|
|
@@ -1029,7 +1274,8 @@ def record_golden(app_path):
|
|
| 1029 |
def main():
|
| 1030 |
tests = [test_routing, test_courses, test_course_header,
|
| 1031 |
test_legacy_regression, test_planner_fallback,
|
| 1032 |
-
test_sc2_conditions,
|
|
|
|
| 1033 |
test_capabilities_payload, test_sc2_real_decode]
|
| 1034 |
for test in tests:
|
| 1035 |
print(f"\n--- {test.__name__}")
|
|
|
|
| 100 |
"rms_ms": 7.5, "db_peaks": downbeats, "ok": True}
|
| 101 |
|
| 102 |
|
| 103 |
+
def fake_vartempo_grid(bpm_a=120.0, bpm_b=150.0, bars_a=6, bars_b=5,
|
| 104 |
+
phase=0.25):
|
| 105 |
+
"""A fit whose downbeats are NOT evenly spaced: two tempo sections.
|
| 106 |
+
|
| 107 |
+
This is the shape ``fit_grid_piecewise`` returns after a splice -- the
|
| 108 |
+
local tempo lives in the SPACING of the downbeats, not in any scalar
|
| 109 |
+
field. A grid builder that re-tiles from ``bpm`` cannot reproduce it,
|
| 110 |
+
which is exactly what the piecewise route has to be tested against.
|
| 111 |
+
|
| 112 |
+
The defaults tile the harness's 20 s mel, as a real fit does, so nothing
|
| 113 |
+
here is measuring the grid builder's tail-extrapolation budget by accident.
|
| 114 |
+
"""
|
| 115 |
+
bar_a, bar_b = 4 * 60.0 / bpm_a, 4 * 60.0 / bpm_b
|
| 116 |
+
db_a = phase + np.arange(bars_a) * bar_a
|
| 117 |
+
db_b = db_a[-1] + bar_a + np.arange(bars_b) * bar_b
|
| 118 |
+
downbeats = np.concatenate([db_a, db_b])
|
| 119 |
+
return {"bpm": bpm_a, "beat": 60.0 / bpm_a, "bar": bar_a, "meter": 4,
|
| 120 |
+
"phase": phase, "downbeats": downbeats,
|
| 121 |
+
"beats": downbeats, "n_db_peaks": len(downbeats),
|
| 122 |
+
"n_beat_peaks": 4 * len(downbeats), "inlier_frac": 0.93,
|
| 123 |
+
"rms_ms": 6.2, "db_peaks": downbeats, "ok": True,
|
| 124 |
+
"piecewise": True, "n_segments": 2,
|
| 125 |
+
"segments": [(float(phase), bpm_a), (float(db_b[0]), bpm_b)]}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def fake_failed_grid(bpm=150.0, n_bars=8, phase=0.25):
|
| 129 |
+
"""A fit that did not pass ``grid.fit_grid``'s own quality gate."""
|
| 130 |
+
grid = fake_grid(bpm, n_bars, phase)
|
| 131 |
+
grid.update({"ok": False, "inlier_frac": 0.41, "rms_ms": 62.7})
|
| 132 |
+
return grid
|
| 133 |
+
|
| 134 |
+
|
| 135 |
def fake_generation():
|
| 136 |
return {"hits": [{"t": 1.0, "type": "don"}, {"t": 1.5, "type": "ka"}],
|
| 137 |
"spans": [{"t0": 2.0, "t1": 3.0, "type": "roll"}],
|
|
|
|
| 147 |
|
| 148 |
def harness(module, calls, *, course="oni", model_choice=None, sampling=False,
|
| 149 |
use_planner=True, auto_plan_on=True, use_beat=True, level=9,
|
| 150 |
+
bpm_override=0.0, audio_path=None, workdir=None,
|
| 151 |
+
beat_fit=None, fixed_fit=None):
|
| 152 |
"""Drive ``module.generate_chart`` with every heavy dependency recorded.
|
| 153 |
|
| 154 |
Patches the module in place; the caller restores it. Returns the payload
|
|
|
|
| 181 |
wav = np.zeros(int(20 * 22050), dtype=np.float32)
|
| 182 |
|
| 183 |
patch("load_logmel", record("load_logmel", lambda: (mel, wav)))
|
| 184 |
+
# ``beat_fit`` / ``fixed_fit`` default to the constant-tempo fit the golden
|
| 185 |
+
# trace was recorded against, so passing neither replays legacy behaviour.
|
| 186 |
+
patch("fit_grid_piecewise",
|
| 187 |
+
record("fit_grid_piecewise", beat_fit or fake_grid))
|
| 188 |
+
patch("fit_grid_fixed_bpm",
|
| 189 |
+
record("fit_grid_fixed_bpm", fixed_fit or fake_grid))
|
| 190 |
patch("learned_plan", record("learned_plan", [[0.0, 4.0, 5, 0]]))
|
| 191 |
patch("auto_plan", record("auto_plan", [[0.0, 4.0, 3, 0]]))
|
| 192 |
patch("generate_song_slot", record("generate_song_slot", fake_generation))
|
|
|
|
| 570 |
# 4. the BarScript route
|
| 571 |
|
| 572 |
|
| 573 |
+
class FakeSpec:
|
| 574 |
+
"""Enough of a ``BarscriptSpec`` for the grid builder's provenance record."""
|
| 575 |
+
|
| 576 |
+
labels_version = "test-labels"
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def make_fake_sc2(seen):
|
| 580 |
+
"""A recording stand-in for the loaded release.
|
| 581 |
+
|
| 582 |
+
``generate`` records what it was handed and then builds its ``grid``
|
| 583 |
+
report with the LOADER's own ``_supplied_grid_info``, so the metric text
|
| 584 |
+
the app derives is checked against the real provenance shape rather than
|
| 585 |
+
a stub's idea of it.
|
| 586 |
+
"""
|
| 587 |
|
| 588 |
class FakeSC2:
|
| 589 |
package_dir = "/tmp/nonexistent-package"
|
| 590 |
+
spec = FakeSpec()
|
| 591 |
|
| 592 |
def generate(self, mel, course, level=None, **kwargs):
|
| 593 |
seen["mel"] = mel.shape
|
| 594 |
seen["course"] = course
|
| 595 |
seen["level"] = level
|
| 596 |
seen.update(kwargs)
|
| 597 |
+
grid = kwargs.get("grid")
|
| 598 |
+
if grid is not None:
|
| 599 |
+
sc2_loader._check_supplied_grid(grid, kwargs.get("grid_denom"))
|
| 600 |
+
grid_info = sc2_loader._supplied_grid_info(
|
| 601 |
+
grid, mel.shape[1] / FPS)
|
| 602 |
+
else:
|
| 603 |
+
denom = kwargs.get("grid_denom", 16)
|
| 604 |
+
grid_info = {"source": "bpm", "denom": denom,
|
| 605 |
+
"bpm": kwargs["bpm"],
|
| 606 |
+
"offset_sec": kwargs["offset_sec"],
|
| 607 |
+
"n_bars": 1,
|
| 608 |
+
"triplets_representable": denom % 3 == 0}
|
| 609 |
+
grid_info["profile"] = "default"
|
| 610 |
return {
|
| 611 |
"tja": TJA_STUB, "gen": fake_generation(),
|
| 612 |
+
"grid": grid_info,
|
|
|
|
|
|
|
|
|
|
| 613 |
"serving": {}, "serving_overrides": {
|
| 614 |
k: kwargs[k] for k in ("greedy", "temperature", "top_p")
|
| 615 |
if k in kwargs},
|
|
|
|
| 618 |
"n_notes": 2, "n_spans": 1, "experimental": True,
|
| 619 |
}
|
| 620 |
|
| 621 |
+
return FakeSC2()
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def run_sc2(course="oni", sampling=False, **kw):
|
| 625 |
+
"""One BarScript request against the recording loader.
|
| 626 |
+
|
| 627 |
+
Returns ``(seen, payload, calls)``: the kwargs the loader was handed, the
|
| 628 |
+
final streamed payload, and the ordered call list.
|
| 629 |
+
"""
|
| 630 |
+
seen, calls = {}, []
|
| 631 |
saved_get_sc2, saved_beat = APP.get_sc2, APP.sc2_beat_model
|
| 632 |
saved_available = APP.available_models
|
| 633 |
+
APP.get_sc2 = lambda: make_fake_sc2(seen)
|
| 634 |
APP.sc2_beat_model = lambda: Recorder("beat", [])
|
| 635 |
APP.available_models = lambda: list(APP.MODELS) + [APP.SC2_MODEL]
|
|
|
|
| 636 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
with tempfile.TemporaryDirectory() as audio_dir:
|
| 638 |
audio_path = os.path.join(audio_dir, "song.ogg")
|
| 639 |
with open(audio_path, "wb") as f:
|
|
|
|
| 642 |
saved_mkdtemp = tempfile.mkdtemp
|
| 643 |
tempfile.mkdtemp = lambda *a, **k: workdir
|
| 644 |
try:
|
| 645 |
+
payload = harness(APP, calls, course=course,
|
| 646 |
+
model_choice=APP.SC2_MODEL,
|
| 647 |
+
sampling=sampling, audio_path=audio_path,
|
| 648 |
+
workdir=workdir, **kw)
|
| 649 |
finally:
|
| 650 |
tempfile.mkdtemp = saved_mkdtemp
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
finally:
|
| 652 |
APP.get_sc2, APP.sc2_beat_model = saved_get_sc2, saved_beat
|
| 653 |
APP.available_models = saved_available
|
| 654 |
+
return seen, payload[-1], calls
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
def test_sc2_conditions():
|
| 658 |
+
"""What the app hands the BarScript loader, with the loader itself recorded."""
|
| 659 |
+
ok = True
|
| 660 |
+
for course, sampling in (("oni", False), ("ura", True)):
|
| 661 |
+
seen, last, calls = run_sc2(course=course, sampling=sampling)
|
| 662 |
+
|
| 663 |
+
fit = fake_grid()
|
| 664 |
+
grid = seen.get("grid")
|
| 665 |
+
ok &= check(f"BarScript ({course}): the bar timeline is SUPPLIED, not synthesized",
|
| 666 |
+
isinstance(grid, dict) and seen.get("bpm") is None,
|
| 667 |
+
f"grid={type(grid).__name__} bpm={seen.get('bpm')}")
|
| 668 |
+
ok &= check(f"BarScript ({course}): bar edges come from the fitted downbeats",
|
| 669 |
+
grid is not None
|
| 670 |
+
and np.array_equal(np.asarray(grid["measure_edges"])[:len(fit["downbeats"])],
|
| 671 |
+
np.asarray(fit["downbeats"]))
|
| 672 |
+
and grid["bpm"] == fit["bpm"],
|
| 673 |
+
f"bpm={None if grid is None else grid['bpm']}")
|
| 674 |
+
ok &= check(f"BarScript ({course}): course and density bucket",
|
| 675 |
+
seen.get("course") == course
|
| 676 |
+
and seen.get("density_bucket") == APP.COURSE_DENS[course],
|
| 677 |
+
f"{seen.get('course')} bucket={seen.get('density_bucket')}")
|
| 678 |
+
ok &= check(f"BarScript ({course}): ships on the /16 deployment lattice",
|
| 679 |
+
seen.get("grid_denom") == sc2_loader.SHIP_GRID_DENOM
|
| 680 |
+
and set(grid["bar_denoms"]) == {sc2_loader.SHIP_GRID_DENOM},
|
| 681 |
+
f"{sorted(set(grid['bar_denoms']))}")
|
| 682 |
+
if sampling:
|
| 683 |
+
ok &= check(f"BarScript ({course}): creative sampling is passed as an override",
|
| 684 |
+
seen.get("greedy") is False and seen.get("temperature") == 0.8
|
| 685 |
+
and seen.get("top_p") == 0.9, f"{seen.get('greedy')}")
|
| 686 |
+
else:
|
| 687 |
+
ok &= check(f"BarScript ({course}): package serving contract left alone",
|
| 688 |
+
not {"greedy", "temperature", "top_p"} & set(seen),
|
| 689 |
+
f"{sorted(set(seen) & {'greedy', 'temperature', 'top_p'})}")
|
| 690 |
+
|
| 691 |
+
metrics = last.get("metrics", {})
|
| 692 |
+
ok &= check(f"BarScript ({course}): the /16 lattice and its triplet gap are shown",
|
| 693 |
+
"triplets impossible" in metrics.get("chart_grid", ""),
|
| 694 |
+
metrics.get("chart_grid", ""))
|
| 695 |
+
ok &= check(f"BarScript ({course}): the plan switches are reported as unused",
|
| 696 |
+
metrics.get("plan_source") == "Not used — BarScript takes no plan",
|
| 697 |
+
metrics.get("plan_source", ""))
|
| 698 |
+
names = [c[0] for c in calls]
|
| 699 |
+
ok &= check(f"BarScript ({course}): the 1.x generators are never called",
|
| 700 |
+
not {"generate_song", "generate_song_slot",
|
| 701 |
+
"write_tja_slots", "snap_chart"} & set(names),
|
| 702 |
+
f"{names}")
|
| 703 |
+
|
| 704 |
+
# no downbeat -> refuse, rather than anchor the bars on an arbitrary phase
|
| 705 |
+
_, last, _ = run_sc2(course="oni", use_beat=False, bpm_override=150.0)
|
| 706 |
+
ok &= check("BarScript: fails closed without a downbeat anchor",
|
| 707 |
+
last.get("kind") == "error" and "downbeat" in last.get("detail", ""),
|
| 708 |
+
last.get("detail", ""))
|
| 709 |
+
return ok
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
def test_sc2_tempo_map():
|
| 713 |
+
"""Does a tempo change reach the decoder, and is the outcome reported?
|
| 714 |
+
|
| 715 |
+
The core requirement: every bar is a slot, and the slots have to follow
|
| 716 |
+
the song when it speeds up. That is testable in one place -- the bar
|
| 717 |
+
timeline handed to the loader -- because both the decoder and the TJA
|
| 718 |
+
writer read every time out of ``measure_edges``.
|
| 719 |
+
"""
|
| 720 |
+
ok = True
|
| 721 |
+
|
| 722 |
+
# 1. variable tempo, fit passes its gate -> the non-uniform barlines survive
|
| 723 |
+
seen, last, _ = run_sc2(beat_fit=fake_vartempo_grid)
|
| 724 |
+
grid = seen.get("grid")
|
| 725 |
+
metrics = last.get("metrics", {})
|
| 726 |
+
edges = np.asarray(grid["measure_edges"]) if grid else np.zeros(2)
|
| 727 |
+
bar_sec = np.diff(edges)
|
| 728 |
+
fit = fake_vartempo_grid()
|
| 729 |
+
ok &= check("tempo map: a variable-tempo fit is served as a piecewise grid",
|
| 730 |
+
grid is not None and grid.get("grid_source") == "piecewise",
|
| 731 |
+
f"{None if grid is None else grid.get('grid_source')}")
|
| 732 |
+
inside = np.asarray(fit["downbeats"])
|
| 733 |
+
inside = inside[inside < 20.0] # the harness mel is 20 s
|
| 734 |
+
ok &= check("tempo map: the served bar edges ARE the fitted downbeats (bitwise)",
|
| 735 |
+
grid is not None
|
| 736 |
+
and np.array_equal(edges[:len(inside)], inside)
|
| 737 |
+
and len(edges) == len(inside) + 1,
|
| 738 |
+
f"{len(edges)} edges vs {len(inside)} in-audio downbeats")
|
| 739 |
+
ok &= check("tempo map: the served grid really contains two bar lengths",
|
| 740 |
+
np.unique(np.round(bar_sec, 6)).size == 2,
|
| 741 |
+
f"{sorted(np.unique(np.round(bar_sec, 3)).tolist())}")
|
| 742 |
+
ok &= check("tempo map: 120 and 150 BPM bars both survive",
|
| 743 |
+
abs(60.0 * 4 / bar_sec.min() - 150.0) < 1e-6
|
| 744 |
+
and abs(60.0 * 4 / bar_sec.max() - 120.0) < 1e-6,
|
| 745 |
+
f"{60.0 * 4 / bar_sec.max():.2f}-{60.0 * 4 / bar_sec.min():.2f} BPM")
|
| 746 |
+
ok &= check("tempo map: the UI says the grid follows the estimated barlines",
|
| 747 |
+
metrics.get("tempo_map", "").startswith("follows the estimated barlines")
|
| 748 |
+
and "2 distinct bar lengths" in metrics.get("tempo_map", ""),
|
| 749 |
+
metrics.get("tempo_map", ""))
|
| 750 |
+
ok &= check("tempo map: the UI states the spread and does not claim it is a tempo change",
|
| 751 |
+
"spread ×1.25" in metrics.get("tempo_map", "")
|
| 752 |
+
and "not told apart here" in metrics.get("tempo_map", ""),
|
| 753 |
+
metrics.get("tempo_map", ""))
|
| 754 |
+
ok &= check("tempo map: the UI reports the fit quality that let it through",
|
| 755 |
+
metrics.get("beat_fit", "").startswith("passed")
|
| 756 |
+
and "0.930" in metrics.get("beat_fit", "")
|
| 757 |
+
and "6.2 ms" in metrics.get("beat_fit", ""),
|
| 758 |
+
metrics.get("beat_fit", ""))
|
| 759 |
+
ok &= check("tempo map: the bar-grid line names the source and the BPM range",
|
| 760 |
+
"estimated barlines" in metrics.get("bar_grid", "")
|
| 761 |
+
and "120.0-150.0 BPM" in metrics.get("bar_grid", ""),
|
| 762 |
+
metrics.get("bar_grid", ""))
|
| 763 |
+
|
| 764 |
+
# 2. constant tempo -> same route, and the UI does not claim a tempo change
|
| 765 |
+
seen, last, _ = run_sc2()
|
| 766 |
+
metrics = last.get("metrics", {})
|
| 767 |
+
ok &= check("tempo map: a constant-tempo song takes the same route",
|
| 768 |
+
seen["grid"].get("grid_source") == "piecewise",
|
| 769 |
+
seen["grid"].get("grid_source"))
|
| 770 |
+
ok &= check("tempo map: and is reported as constant, not as a tempo change",
|
| 771 |
+
metrics.get("tempo_map", "").startswith("constant")
|
| 772 |
+
and "150.0 BPM" in metrics.get("tempo_map", ""),
|
| 773 |
+
metrics.get("tempo_map", ""))
|
| 774 |
+
|
| 775 |
+
# 3. the fit fails its own gate -> uniform fallback, stated with its numbers
|
| 776 |
+
seen, last, _ = run_sc2(beat_fit=fake_failed_grid)
|
| 777 |
+
grid, metrics = seen.get("grid"), last.get("metrics", {})
|
| 778 |
+
ok &= check("tempo map: a fit that fails its gate falls back to a uniform grid",
|
| 779 |
+
grid is not None and grid.get("grid_source") == "uniform_fallback",
|
| 780 |
+
f"{None if grid is None else grid.get('grid_source')}")
|
| 781 |
+
ok &= check("tempo map: the fallback is NOT silent -- it says tempo change is lost",
|
| 782 |
+
"NOT be represented" in metrics.get("tempo_map", ""),
|
| 783 |
+
metrics.get("tempo_map", ""))
|
| 784 |
+
ok &= check("tempo map: the fallback carries the gate numbers that caused it",
|
| 785 |
+
"0.41" in metrics.get("tempo_map", "")
|
| 786 |
+
and "62.7" in metrics.get("tempo_map", ""),
|
| 787 |
+
metrics.get("tempo_map", ""))
|
| 788 |
+
ok &= check("tempo map: and the beat-fit line says the gate FAILED",
|
| 789 |
+
"FAILED its quality gate" in metrics.get("beat_fit", ""),
|
| 790 |
+
metrics.get("beat_fit", ""))
|
| 791 |
+
|
| 792 |
+
# 4. manual BPM: the user's period is trusted, so the refit is uniform
|
| 793 |
+
seen, last, calls = run_sc2(beat_fit=fake_vartempo_grid, bpm_override=180.0,
|
| 794 |
+
fixed_fit=lambda: fake_grid(bpm=180.0, n_bars=14))
|
| 795 |
+
grid, metrics = seen.get("grid"), last.get("metrics", {})
|
| 796 |
+
bar_sec = np.diff(np.asarray(grid["measure_edges"]))
|
| 797 |
+
ok &= check("manual BPM: the fixed-BPM refit is still the grid that is served",
|
| 798 |
+
"fit_grid_fixed_bpm" in [c[0] for c in calls]
|
| 799 |
+
and abs(grid["bpm"] - 180.0) < 1e-9,
|
| 800 |
+
f"bpm={grid['bpm']}")
|
| 801 |
+
ok &= check("manual BPM: the served bars are uniform at the requested tempo",
|
| 802 |
+
np.unique(np.round(bar_sec, 6)).size == 1
|
| 803 |
+
and abs(60.0 * 4 / bar_sec[0] - 180.0) < 1e-6,
|
| 804 |
+
f"{60.0 * 4 / bar_sec[0]:.3f} BPM, "
|
| 805 |
+
f"{np.unique(np.round(bar_sec, 6)).size} distinct bar length(s)")
|
| 806 |
+
ok &= check("manual BPM: reported as constant rather than as a tempo change",
|
| 807 |
+
metrics.get("tempo_map", "").startswith("constant"),
|
| 808 |
+
metrics.get("tempo_map", ""))
|
| 809 |
+
return ok
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
def test_sc2_grid_contract():
|
| 813 |
+
"""The loader's own guards on a supplied bar timeline."""
|
| 814 |
+
from softchart.barscript_grid import barscript_grid_from_fit
|
| 815 |
+
|
| 816 |
+
ok = True
|
| 817 |
+
grid = barscript_grid_from_fit(fake_vartempo_grid(), 20.0,
|
| 818 |
+
denom=sc2_loader.SHIP_GRID_DENOM)
|
| 819 |
+
ok &= check("loader: a well-formed supplied grid passes the denominator check",
|
| 820 |
+
sc2_loader._check_supplied_grid(grid, 16) is grid)
|
| 821 |
+
for label, bad, denom in (
|
| 822 |
+
("a missing key", {k: v for k, v in grid.items()
|
| 823 |
+
if k != "measure_edges"}, 16),
|
| 824 |
+
("a lattice that disagrees with grid_denom", grid, 32)):
|
| 825 |
+
try:
|
| 826 |
+
sc2_loader._check_supplied_grid(bad, denom)
|
| 827 |
+
ok &= check(f"loader: refuses {label}", False, "accepted")
|
| 828 |
+
except ValueError as exc:
|
| 829 |
+
ok &= check(f"loader: refuses {label}", True, str(exc)[:70])
|
| 830 |
+
|
| 831 |
+
info = sc2_loader._supplied_grid_info(grid, 20.0)
|
| 832 |
+
ok &= check("loader: reports that the served bars carry two tempi",
|
| 833 |
+
info["n_distinct_bar_sec"] == 2 and info["source"] == "supplied",
|
| 834 |
+
f"{info['n_distinct_bar_sec']} distinct bar lengths")
|
| 835 |
+
ok &= check("loader: carries the builder's provenance through untouched",
|
| 836 |
+
info["grid_source"] == "piecewise"
|
| 837 |
+
and info["fit_quality"]["n_segments"] == 2,
|
| 838 |
+
f"{info['grid_source']}")
|
| 839 |
return ok
|
| 840 |
|
| 841 |
|
| 842 |
def test_sc2_real_decode():
|
| 843 |
+
"""Real 2.0 decodes, if the release package is on disk.
|
| 844 |
+
|
| 845 |
+
The variable-tempo case is the one that matters: it drives the real
|
| 846 |
+
decoder and the real TJA writer with a two-tempo bar timeline and checks
|
| 847 |
+
the timeline came out the other end, rather than asserting on a shape the
|
| 848 |
+
Space made up.
|
| 849 |
+
"""
|
| 850 |
+
from softchart.barscript_grid import barscript_grid_from_fit
|
| 851 |
+
|
| 852 |
if not sc2_loader.is_available():
|
| 853 |
print("SKIP 2.0 real decode: no release package "
|
| 854 |
f"({sc2_loader.default_package_dir()})")
|
|
|
|
| 870 |
ok &= check(f"BarScript real decode ({course}): lattice reported as /16 without triplets",
|
| 871 |
result["grid"]["denom"] == 16
|
| 872 |
and result["grid"]["triplets_representable"] is False)
|
| 873 |
+
|
| 874 |
+
# exactly one of bpm= / grid= -- an ambiguous call must not pick one
|
| 875 |
+
for kwargs, label in (({}, "neither bpm nor grid"),
|
| 876 |
+
({"bpm": 150.0, "grid": {}}, "both bpm and grid")):
|
| 877 |
+
try:
|
| 878 |
+
sc.generate(mel, "oni", level=9, seed=0, **kwargs)
|
| 879 |
+
ok &= check(f"BarScript real decode: refuses {label}", False, "accepted")
|
| 880 |
+
except ValueError as exc:
|
| 881 |
+
ok &= check(f"BarScript real decode: refuses {label}", True, str(exc)[:60])
|
| 882 |
+
|
| 883 |
+
# a real two-tempo decode, end to end through the frozen TJA writer
|
| 884 |
+
duration = mel.shape[1] / FPS
|
| 885 |
+
fit = fake_vartempo_grid(bpm_a=120.0, bpm_b=150.0, bars_a=3, bars_b=3)
|
| 886 |
+
grid = barscript_grid_from_fit(fit, duration,
|
| 887 |
+
denom=sc2_loader.SHIP_GRID_DENOM,
|
| 888 |
+
spec=sc.spec)
|
| 889 |
+
result = sc.generate(mel, "oni", level=9, grid=grid,
|
| 890 |
+
density_bucket=APP.COURSE_DENS["oni"], seed=0,
|
| 891 |
+
grid_denom=sc2_loader.SHIP_GRID_DENOM,
|
| 892 |
+
title="probe", wave="song.ogg")
|
| 893 |
+
info = result["grid"]
|
| 894 |
+
ok &= check("BarScript real decode: a supplied two-tempo grid is served as such",
|
| 895 |
+
info["source"] == "supplied" and info["n_distinct_bar_sec"] == 2,
|
| 896 |
+
f"{info['source']}, {info['n_distinct_bar_sec']} bar lengths")
|
| 897 |
+
# the writer opens at the header BPM and emits #BPMCHANGE only when a bar
|
| 898 |
+
# departs from it, so the two tempi land in two different places
|
| 899 |
+
tempi = [float(ln.split()[1]) for ln in result["tja"].splitlines()
|
| 900 |
+
if ln.startswith("#BPMCHANGE")]
|
| 901 |
+
header_bpm = [float(ln[4:]) for ln in result["tja"].splitlines()
|
| 902 |
+
if ln.startswith("BPM:")]
|
| 903 |
+
ok &= check("BarScript real decode: the second tempo reaches the TJA as #BPMCHANGE",
|
| 904 |
+
any(abs(t - 150.0) < 1e-6 for t in tempi),
|
| 905 |
+
f"header BPM {header_bpm}, #BPMCHANGE "
|
| 906 |
+
f"{sorted({round(t, 3) for t in tempi})}")
|
| 907 |
+
ok &= check("BarScript real decode: the first tempo is the TJA header BPM",
|
| 908 |
+
header_bpm == [120.0], f"{header_bpm}")
|
| 909 |
+
ok &= check("BarScript real decode: TJA OFFSET is the first fitted downbeat",
|
| 910 |
+
any(ln == f"OFFSET:{-float(grid['measure_edges'][0]):.17g}"
|
| 911 |
+
for ln in result["tja"].splitlines()),
|
| 912 |
+
f"first edge {float(grid['measure_edges'][0]):.6f} s")
|
| 913 |
return ok
|
| 914 |
|
| 915 |
|
|
|
|
| 1274 |
def main():
|
| 1275 |
tests = [test_routing, test_courses, test_course_header,
|
| 1276 |
test_legacy_regression, test_planner_fallback,
|
| 1277 |
+
test_sc2_conditions, test_sc2_tempo_map, test_sc2_grid_contract,
|
| 1278 |
+
test_sc2_package_resolution,
|
| 1279 |
test_capabilities_payload, test_sc2_real_decode]
|
| 1280 |
for test in tests:
|
| 1281 |
print(f"\n--- {test.__name__}")
|
scripts/test_sc2_vendor.py
CHANGED
|
@@ -319,9 +319,15 @@ def test_c_legacy_library_untouched():
|
|
| 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 |
|
|
|
|
| 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 |
+
# Space-owned modules that may sit beside the 1.x library. The list is
|
| 323 |
+
# explicit so a module arriving unnoticed is a failure, not a shrug:
|
| 324 |
+
# sc2_loader.py the bridge to softchart.sc2
|
| 325 |
+
# barscript_grid.py builds a BarScript bar timeline from a beat fit,
|
| 326 |
+
# so a tempo change survives into the served grid
|
| 327 |
+
# Neither is imported by any 1.x code path.
|
| 328 |
added = sorted(n for n in os.listdir(lib)
|
| 329 |
if n.endswith(".py") and n not in LEGACY_MD5)
|
| 330 |
+
assert added == ["barscript_grid.py", "sc2_loader.py"], added
|
| 331 |
return {"unchanged": len(LEGACY_MD5), "added": added}
|
| 332 |
|
| 333 |
|
softchart/barscript_grid.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serving-time BarScript grid built from an ESTIMATED beat fit.
|
| 2 |
+
|
| 3 |
+
Space copy
|
| 4 |
+
----------
|
| 5 |
+
Derived from the shipping tree's ``src/softchart/barscript_grid.py``
|
| 6 |
+
(md5 7e810833ed2e5c796c5bcfa6e3c376c3). Two deliberate differences, both
|
| 7 |
+
forced by where this file now lives:
|
| 8 |
+
|
| 9 |
+
1. The two lazy imports point at the vendored 2.0 subpackage
|
| 10 |
+
(``.sc2.generate``, ``.sc2.vocab``) instead of the sibling modules. In the
|
| 11 |
+
Space, ``softchart.generate`` and ``softchart.vocab`` are the SoftChart 1.x
|
| 12 |
+
library with an incompatible token table, so the shipping tree's plain
|
| 13 |
+
``.generate`` / ``.vocab`` would silently bind to the wrong decoder.
|
| 14 |
+
2. The corpus figure quoted below is corrected. The shipping tree's docstring
|
| 15 |
+
said "62.3 % of corpus charts contain a variable-tempo or variable-meter
|
| 16 |
+
bar"; recomputing from ``trace`` over 1 134 songs / 4 449 charts separates
|
| 17 |
+
the two, and only the TEMPO half is what this module addresses:
|
| 18 |
+
**29.0 % of charts change tempo**, 52.4 % change meter. Variable meter is
|
| 19 |
+
explicitly out of scope (see below), so 62.3 % overstated this module's
|
| 20 |
+
reach by roughly a factor of two.
|
| 21 |
+
|
| 22 |
+
Nothing else is changed; the grid arithmetic and the gates are identical.
|
| 23 |
+
|
| 24 |
+
Why this module exists
|
| 25 |
+
----------------------
|
| 26 |
+
The BarScript decoder consumes a *supplied grid* dict --
|
| 27 |
+
``{bpm, downbeats, measure_edges, measure_num, measure_den, bar_denoms}`` --
|
| 28 |
+
and every note time it produces, plus every ``#BPMCHANGE`` the TJA writer
|
| 29 |
+
emits, is derived from ``measure_edges`` alone. The representation is
|
| 30 |
+
therefore exact under tempo change: ``tja.write_tja_slots`` recomputes
|
| 31 |
+
``bpm_m = 60 * quarter_beats[m] / (edges[m+1] - edges[m])`` per bar and writes
|
| 32 |
+
a ``#BPMCHANGE`` whenever it moves.
|
| 33 |
+
|
| 34 |
+
The two shipped grid builders sit at opposite ends of that contract:
|
| 35 |
+
|
| 36 |
+
``generate.barscript_grid_from_trace``
|
| 37 |
+
exact, but needs an AUTHORED chart -- unavailable when serving audio.
|
| 38 |
+
``generate.barscript_grid_from_bpm``
|
| 39 |
+
the deployed serving path: ``bar_sec = (240/bpm)*num/den`` tiled uniformly
|
| 40 |
+
from one scalar BPM. It cannot express a tempo change at all, so on the
|
| 41 |
+
29.0 % of corpus charts that contain a tempo change the served grid is
|
| 42 |
+
wrong by construction and the error accumulates over the song.
|
| 43 |
+
|
| 44 |
+
``barscript_grid_from_fit`` closes that gap without touching either builder or
|
| 45 |
+
the decoder: ``grid.fit_grid_piecewise`` already returns ``downbeats`` that are
|
| 46 |
+
NON-UNIFORM (the spliced local tempi are encoded in the spacing), so the fix is
|
| 47 |
+
to hand those times through as the bar edges instead of re-tiling from
|
| 48 |
+
``fit["bpm"]``.
|
| 49 |
+
|
| 50 |
+
What this module does NOT do
|
| 51 |
+
----------------------------
|
| 52 |
+
Per-bar METER is not estimated. ``fit_grid`` reports a single ``meter``
|
| 53 |
+
(quarter beats per bar, 3 or 4, from the bar/beat period ratio), so the grid
|
| 54 |
+
carries a CONSTANT ``measure_num``/``measure_den`` and ``meter_source`` is
|
| 55 |
+
recorded as ``"fit_estimate"``. A time-signature change inside the song is
|
| 56 |
+
representable by the grid dict and by the TJA writer, but nothing in the
|
| 57 |
+
serving stack can currently detect one from audio; claiming otherwise would be
|
| 58 |
+
a fabricated ``#MEASURE``. Variable TEMPO is supported; variable METER is not.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
import numpy as np
|
| 62 |
+
|
| 63 |
+
# Structural sanity bounds on a single bar, expressed as the implied tempo
|
| 64 |
+
# 60 * quarter_beats_per_bar / bar_seconds. These are NOT a tempo prior: they
|
| 65 |
+
# bracket what a bar can be at all. `grid.fit_grid` only ever snaps BPM inside
|
| 66 |
+
# [40, 320] and only accepts a snap within a factor 0.4-2.5 of the raw fit, so
|
| 67 |
+
# [30, 400] is a strict superset of everything the estimator can emit; a bar
|
| 68 |
+
# outside it is a broken fit, not a slow or fast song. The upper bound also
|
| 69 |
+
# keeps every bar far inside the decoder's WINDOW (1728 frames ~ 20.06 s), which
|
| 70 |
+
# `generate_song_barscript` requires each single bar to fit in.
|
| 71 |
+
GRID_MIN_BPM = 30.0
|
| 72 |
+
GRID_MAX_BPM = 400.0
|
| 73 |
+
|
| 74 |
+
# Bars appended past the last fitted downbeat to reach ``duration_sec``. The
|
| 75 |
+
# fit already tiles to the mel duration, so the normal case needs zero: a
|
| 76 |
+
# request for more means the fit stopped early and the tail would be invented.
|
| 77 |
+
MAX_TAIL_EXTRAP_BARS = 4
|
| 78 |
+
|
| 79 |
+
# Splice joints in `fit_grid_piecewise` can leave one implausibly short bar
|
| 80 |
+
# where a local segment hands back to the main grid (upstream only guarantees
|
| 81 |
+
# a 0.3 s separation, which is below GRID_MAX_BPM at 4/4). Dropping that single
|
| 82 |
+
# barline is a bounded repair; needing it often means the downbeat sequence is
|
| 83 |
+
# not a bar sequence.
|
| 84 |
+
MAX_JOINT_MERGE_FRAC = 0.05
|
| 85 |
+
|
| 86 |
+
SHIP_DENOM = 16 # deployed bar lattice (SOFTCHART2_RELEASE: --grid-bpm-denom 16)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class GridFitError(ValueError):
|
| 90 |
+
"""A fit cannot be turned into a well-formed bar grid.
|
| 91 |
+
|
| 92 |
+
Raised (fail-closed) for malformed downbeat sequences: non-increasing or
|
| 93 |
+
non-finite times, bar spacings outside [GRID_MIN_BPM, GRID_MAX_BPM], or a
|
| 94 |
+
fit that does not cover ``duration_sec``. It is NOT raised for a fit that
|
| 95 |
+
is merely low-quality -- ``fit["ok"] == False`` falls back to the uniform
|
| 96 |
+
grid instead, which is a downgrade the caller can report rather than an
|
| 97 |
+
error it must handle.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _meter_terms(fit):
|
| 102 |
+
"""(numerator, denominator) for the single estimated time signature."""
|
| 103 |
+
meter = fit.get("meter", 4)
|
| 104 |
+
if isinstance(meter, (tuple, list, np.ndarray)):
|
| 105 |
+
if len(meter) != 2:
|
| 106 |
+
raise GridFitError("fit meter must be an int or a (num, den) pair")
|
| 107 |
+
num, den = int(meter[0]), int(meter[1])
|
| 108 |
+
else:
|
| 109 |
+
num, den = int(meter), 4
|
| 110 |
+
if num <= 0 or den <= 0:
|
| 111 |
+
raise GridFitError(f"fit meter {num}/{den} must have positive terms")
|
| 112 |
+
return num, den
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _fit_quality(fit):
|
| 116 |
+
"""Fit diagnostics, JSON-ready, with the keys a raw `fit_grid` lacks."""
|
| 117 |
+
segments = fit.get("segments")
|
| 118 |
+
if segments is None:
|
| 119 |
+
segments = [(float(fit.get("phase", 0.0)), float(fit.get("bpm", 0.0)))]
|
| 120 |
+
return {
|
| 121 |
+
"ok": bool(fit.get("ok", False)),
|
| 122 |
+
"inlier_frac": (None if fit.get("inlier_frac") is None
|
| 123 |
+
else float(fit["inlier_frac"])),
|
| 124 |
+
"rms_ms": (None if fit.get("rms_ms") is None
|
| 125 |
+
else float(fit["rms_ms"])),
|
| 126 |
+
"n_segments": int(fit.get("n_segments", len(segments))),
|
| 127 |
+
"piecewise": bool(fit.get("piecewise", False)),
|
| 128 |
+
"bpm": float(fit.get("bpm", 0.0)),
|
| 129 |
+
"segments": [[float(t), float(b)] for t, b in segments],
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _uniform_fallback(fit, duration_sec, num, den, denom, reason,
|
| 134 |
+
fallback_bpm, fallback_offset, spec_version):
|
| 135 |
+
"""Re-tile uniformly from a scalar BPM (the shipped serving behaviour).
|
| 136 |
+
|
| 137 |
+
This is the only path that loses tempo change, so it is always labelled
|
| 138 |
+
``grid_source == "uniform_fallback"`` and carries ``reason``.
|
| 139 |
+
"""
|
| 140 |
+
from .sc2.generate import barscript_grid_from_bpm
|
| 141 |
+
|
| 142 |
+
bpm = fallback_bpm
|
| 143 |
+
if bpm is None and fit is not None:
|
| 144 |
+
bpm = fit.get("bpm")
|
| 145 |
+
if bpm is None or not np.isfinite(float(bpm)) or float(bpm) <= 0:
|
| 146 |
+
raise GridFitError(
|
| 147 |
+
f"uniform fallback needs a usable BPM ({reason}); pass "
|
| 148 |
+
"fallback_bpm= explicitly")
|
| 149 |
+
offset = fallback_offset
|
| 150 |
+
if offset is None and fit is not None:
|
| 151 |
+
offset = fit.get("phase", 0.0)
|
| 152 |
+
offset = 0.0 if offset is None else float(offset)
|
| 153 |
+
if not np.isfinite(offset) or not 0.0 <= offset < duration_sec:
|
| 154 |
+
offset = 0.0
|
| 155 |
+
grid = barscript_grid_from_bpm(float(bpm), offset, float(duration_sec),
|
| 156 |
+
meter=(num, den), denom=int(denom))
|
| 157 |
+
edges = np.asarray(grid["measure_edges"], np.float64)
|
| 158 |
+
grid["grid_source"] = "uniform_fallback"
|
| 159 |
+
grid["fit_quality"] = None if fit is None else _fit_quality(fit)
|
| 160 |
+
grid["grid_meta"] = {
|
| 161 |
+
"reason": reason,
|
| 162 |
+
"n_bars": len(grid["bar_denoms"]),
|
| 163 |
+
"meter_num": int(num), "meter_den": int(den),
|
| 164 |
+
"meter_source": "fit_estimate" if fit is not None else "caller",
|
| 165 |
+
"meter_varies": False,
|
| 166 |
+
"bar_denom": int(denom),
|
| 167 |
+
"duration_sec": float(duration_sec),
|
| 168 |
+
"first_downbeat_sec": float(edges[0]),
|
| 169 |
+
"terminal_edge_sec": float(edges[-1]),
|
| 170 |
+
"tail_bars_extrapolated": 0,
|
| 171 |
+
"joint_bars_merged": 0,
|
| 172 |
+
"trailing_downbeats_dropped": 0,
|
| 173 |
+
"bar_sec_min": float(np.min(np.diff(edges))),
|
| 174 |
+
"bar_sec_max": float(np.max(np.diff(edges))),
|
| 175 |
+
"spec_labels_version": spec_version,
|
| 176 |
+
}
|
| 177 |
+
return grid
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def barscript_grid_from_fit(fit, duration_sec, *, denom=SHIP_DENOM, spec=None,
|
| 181 |
+
require_ok=True, repair_joints=True,
|
| 182 |
+
on_invalid="raise", fallback_bpm=None,
|
| 183 |
+
fallback_offset=None,
|
| 184 |
+
min_bar_bpm=GRID_MIN_BPM,
|
| 185 |
+
max_bar_bpm=GRID_MAX_BPM):
|
| 186 |
+
"""Build a BarScript supplied grid from ``grid.fit_grid_piecewise`` output.
|
| 187 |
+
|
| 188 |
+
Parameters
|
| 189 |
+
----------
|
| 190 |
+
fit : dict or None
|
| 191 |
+
A ``fit_grid`` / ``fit_grid_fixed_bpm`` / ``fit_grid_piecewise`` result.
|
| 192 |
+
Only ``downbeats``, ``meter``, ``ok``, ``bpm`` and ``phase`` are read
|
| 193 |
+
(the rest is copied into ``fit_quality`` for display). ``None`` means
|
| 194 |
+
beat estimation failed outright: the call falls back to the uniform
|
| 195 |
+
grid and therefore needs ``fallback_bpm``.
|
| 196 |
+
duration_sec : float
|
| 197 |
+
Audio length the grid must cover (normally ``mel.shape[1] / FPS``).
|
| 198 |
+
denom : int
|
| 199 |
+
Per-bar lattice denominator teacher-forced into the ``[BAR]`` headers,
|
| 200 |
+
uniform over the song. Default 16 = the shipped serving decision.
|
| 201 |
+
spec : BarscriptSpec, optional
|
| 202 |
+
Not needed to build the grid (bar geometry is spec-independent); when
|
| 203 |
+
given, its ``labels_version`` is recorded in ``grid_meta`` so a served
|
| 204 |
+
grid carries the label contract it was requested under.
|
| 205 |
+
require_ok : bool
|
| 206 |
+
When True (default) a fit whose own ``ok`` gate failed is not used.
|
| 207 |
+
repair_joints : bool
|
| 208 |
+
Drop a downbeat that sits closer than one ``max_bar_bpm`` bar to its
|
| 209 |
+
predecessor (a splice-joint artefact), up to ``MAX_JOINT_MERGE_FRAC``
|
| 210 |
+
of the bars. The LATER barline is dropped, matching the upstream
|
| 211 |
+
0.3 s de-duplication in ``fit_grid_piecewise``.
|
| 212 |
+
on_invalid : {"raise", "fallback"}
|
| 213 |
+
What to do when the downbeat sequence is malformed. ``"raise"``
|
| 214 |
+
(default) is fail-closed; ``"fallback"`` lets a serving path degrade to
|
| 215 |
+
the uniform grid with the reason recorded instead of erroring.
|
| 216 |
+
|
| 217 |
+
Returns
|
| 218 |
+
-------
|
| 219 |
+
dict
|
| 220 |
+
The six keys the decoder consumes -- ``bpm``, ``downbeats``,
|
| 221 |
+
``measure_edges``, ``measure_num``, ``measure_den``, ``bar_denoms`` --
|
| 222 |
+
exactly as ``barscript_grid_from_bpm`` returns them, plus three
|
| 223 |
+
provenance keys the decoder ignores:
|
| 224 |
+
|
| 225 |
+
``grid_source``
|
| 226 |
+
``"piecewise"`` -- bar edges ARE the fitted downbeat times, so a
|
| 227 |
+
tempo change in the fit survives into the chart and the TJA. (The
|
| 228 |
+
name follows the producer, ``fit_grid_piecewise``; a constant-tempo
|
| 229 |
+
song takes this path too, with ``fit_quality["piecewise"] is
|
| 230 |
+
False`` and ``n_segments == 1``.)
|
| 231 |
+
``"uniform_fallback"`` -- re-tiled from one scalar BPM; ANY tempo
|
| 232 |
+
change in the song is lost. ``grid_meta["reason"]`` says why.
|
| 233 |
+
``fit_quality``
|
| 234 |
+
``{ok, inlier_frac, rms_ms, n_segments, piecewise, bpm, segments}``
|
| 235 |
+
(``None`` when ``fit is None``).
|
| 236 |
+
``grid_meta``
|
| 237 |
+
Bar counts, meter and its source, coverage, and every repair that
|
| 238 |
+
was applied (``joint_bars_merged``, ``tail_bars_extrapolated``,
|
| 239 |
+
``trailing_downbeats_dropped``).
|
| 240 |
+
|
| 241 |
+
Notes
|
| 242 |
+
-----
|
| 243 |
+
``bpm`` is the fit's MAIN tempo and is display/metadata only under
|
| 244 |
+
``"piecewise"``: the decoder reads times from ``measure_edges``, and
|
| 245 |
+
``tja.write_tja_slots`` uses it solely for the header ``BPM:`` field before
|
| 246 |
+
emitting per-bar ``#BPMCHANGE`` from the edges.
|
| 247 |
+
"""
|
| 248 |
+
if on_invalid not in ("raise", "fallback"):
|
| 249 |
+
raise ValueError("on_invalid must be 'raise' or 'fallback'")
|
| 250 |
+
duration_sec = float(duration_sec)
|
| 251 |
+
if not np.isfinite(duration_sec) or duration_sec <= 0:
|
| 252 |
+
raise ValueError("duration_sec must be finite and positive")
|
| 253 |
+
from .sc2.vocab import MAX_BAR_DENOM
|
| 254 |
+
if (isinstance(denom, bool) or not isinstance(denom, (int, np.integer))
|
| 255 |
+
or not 1 <= int(denom) <= MAX_BAR_DENOM):
|
| 256 |
+
raise ValueError(f"denom must be an int in [1, {MAX_BAR_DENOM}]")
|
| 257 |
+
denom = int(denom)
|
| 258 |
+
if not np.isfinite(min_bar_bpm) or not np.isfinite(max_bar_bpm) \
|
| 259 |
+
or not 0 < min_bar_bpm < max_bar_bpm:
|
| 260 |
+
raise ValueError("min_bar_bpm/max_bar_bpm must satisfy 0 < min < max")
|
| 261 |
+
|
| 262 |
+
spec_version = None
|
| 263 |
+
if spec is not None:
|
| 264 |
+
spec_version = getattr(spec, "labels_version", None)
|
| 265 |
+
if spec_version is None:
|
| 266 |
+
raise ValueError("spec must be a BarscriptSpec (no labels_version)")
|
| 267 |
+
|
| 268 |
+
if fit is None:
|
| 269 |
+
return _uniform_fallback(None, duration_sec, 4, 4, denom,
|
| 270 |
+
"beat fit unavailable", fallback_bpm,
|
| 271 |
+
fallback_offset, spec_version)
|
| 272 |
+
num, den = _meter_terms(fit)
|
| 273 |
+
quarter_beats = 4.0 * num / den
|
| 274 |
+
min_bar_sec = 60.0 * quarter_beats / max_bar_bpm
|
| 275 |
+
max_bar_sec = 60.0 * quarter_beats / min_bar_bpm
|
| 276 |
+
|
| 277 |
+
if require_ok and not bool(fit.get("ok", False)):
|
| 278 |
+
return _uniform_fallback(
|
| 279 |
+
fit, duration_sec, num, den, denom,
|
| 280 |
+
"fit.ok is False (inlier_frac "
|
| 281 |
+
f"{fit.get('inlier_frac')!r}, rms_ms {fit.get('rms_ms')!r})",
|
| 282 |
+
fallback_bpm, fallback_offset, spec_version)
|
| 283 |
+
|
| 284 |
+
def _bail(reason):
|
| 285 |
+
if on_invalid == "raise":
|
| 286 |
+
raise GridFitError(reason)
|
| 287 |
+
return _uniform_fallback(fit, duration_sec, num, den, denom, reason,
|
| 288 |
+
fallback_bpm, fallback_offset, spec_version)
|
| 289 |
+
|
| 290 |
+
raw_db = fit.get("downbeats")
|
| 291 |
+
if raw_db is None:
|
| 292 |
+
return _bail("fit has no 'downbeats'")
|
| 293 |
+
try:
|
| 294 |
+
db = np.asarray(raw_db, dtype=np.float64).reshape(-1)
|
| 295 |
+
except (TypeError, ValueError) as e:
|
| 296 |
+
return _bail(f"fit downbeats are not a float array: {e}")
|
| 297 |
+
if db.size < 2:
|
| 298 |
+
return _bail(f"fit has {db.size} downbeat(s); at least 2 are needed "
|
| 299 |
+
"to establish a bar period")
|
| 300 |
+
if not np.all(np.isfinite(db)):
|
| 301 |
+
return _bail("fit downbeats contain non-finite times")
|
| 302 |
+
if np.any(np.diff(db) <= 0):
|
| 303 |
+
bad = int(np.argmax(np.diff(db) <= 0))
|
| 304 |
+
return _bail(f"fit downbeats are not strictly increasing at index "
|
| 305 |
+
f"{bad} ({db[bad]:.6f} -> {db[bad + 1]:.6f})")
|
| 306 |
+
if db[0] < 0:
|
| 307 |
+
return _bail(f"first downbeat {db[0]:.6f}s is negative")
|
| 308 |
+
if db[0] >= duration_sec:
|
| 309 |
+
return _bail(f"first downbeat {db[0]:.6f}s is at or past the audio end "
|
| 310 |
+
f"({duration_sec:.6f}s)")
|
| 311 |
+
if db[0] > max_bar_sec:
|
| 312 |
+
return _bail(f"first downbeat {db[0]:.6f}s leaves an unbarred head "
|
| 313 |
+
f"longer than one bar ({max_bar_sec:.6f}s)")
|
| 314 |
+
|
| 315 |
+
# trailing downbeats past the requested duration: the caller's duration is
|
| 316 |
+
# authoritative for what has to be charted
|
| 317 |
+
n_trailing = int(np.count_nonzero(db >= duration_sec))
|
| 318 |
+
if n_trailing:
|
| 319 |
+
db = db[db < duration_sec]
|
| 320 |
+
if db.size < 2:
|
| 321 |
+
return _bail("fewer than 2 downbeats remain inside duration_sec")
|
| 322 |
+
|
| 323 |
+
# splice-joint repair (drop the LATER barline of an impossibly short bar)
|
| 324 |
+
n_merged = 0
|
| 325 |
+
if repair_joints:
|
| 326 |
+
keep = [0]
|
| 327 |
+
for i in range(1, db.size):
|
| 328 |
+
if db[i] - db[keep[-1]] < min_bar_sec:
|
| 329 |
+
n_merged += 1
|
| 330 |
+
continue
|
| 331 |
+
keep.append(i)
|
| 332 |
+
if n_merged:
|
| 333 |
+
limit = max(1, int(np.floor(MAX_JOINT_MERGE_FRAC * db.size)))
|
| 334 |
+
if n_merged > limit:
|
| 335 |
+
return _bail(
|
| 336 |
+
f"{n_merged} of {db.size} downbeats sit closer than one "
|
| 337 |
+
f"{max_bar_bpm:g} BPM bar ({min_bar_sec * 1e3:.1f} ms); "
|
| 338 |
+
f"above the {limit}-bar joint-repair budget this is not a "
|
| 339 |
+
"bar sequence")
|
| 340 |
+
if len(keep) < 2:
|
| 341 |
+
return _bail("joint repair left fewer than 2 downbeats")
|
| 342 |
+
db = db[np.asarray(keep)]
|
| 343 |
+
|
| 344 |
+
# terminal edge + tail: extend at the LAST observed bar period. The bar
|
| 345 |
+
# after the final downbeat is the only extrapolated one in the normal case;
|
| 346 |
+
# anything beyond that is counted and budgeted.
|
| 347 |
+
tail_period = float(db[-1] - db[-2])
|
| 348 |
+
edges = list(db)
|
| 349 |
+
edges.append(edges[-1] + tail_period)
|
| 350 |
+
n_tail = 0
|
| 351 |
+
while edges[-1] < duration_sec - 1e-9:
|
| 352 |
+
if n_tail >= MAX_TAIL_EXTRAP_BARS:
|
| 353 |
+
covered = (edges[-1] - float(db[0])) / max(duration_sec, 1e-9)
|
| 354 |
+
return _bail(
|
| 355 |
+
f"fit covers only {covered * 100:.1f}% of the audio; "
|
| 356 |
+
f"reaching {duration_sec:.3f}s would take more than "
|
| 357 |
+
f"{MAX_TAIL_EXTRAP_BARS} extrapolated bars")
|
| 358 |
+
edges.append(edges[-1] + tail_period)
|
| 359 |
+
n_tail += 1
|
| 360 |
+
edges = np.asarray(edges, dtype=np.float64)
|
| 361 |
+
|
| 362 |
+
bar_sec = np.diff(edges)
|
| 363 |
+
if np.any(bar_sec <= 0):
|
| 364 |
+
return _bail("bar edges are not strictly increasing after repair")
|
| 365 |
+
too_short = np.flatnonzero(bar_sec < min_bar_sec - 1e-12)
|
| 366 |
+
if too_short.size:
|
| 367 |
+
i = int(too_short[0])
|
| 368 |
+
return _bail(
|
| 369 |
+
f"bar {i} lasts {bar_sec[i] * 1e3:.1f} ms -> "
|
| 370 |
+
f"{60.0 * quarter_beats / bar_sec[i]:.1f} BPM, above the "
|
| 371 |
+
f"{max_bar_bpm:g} BPM structural bound "
|
| 372 |
+
f"({too_short.size} bar(s) affected)")
|
| 373 |
+
too_long = np.flatnonzero(bar_sec > max_bar_sec + 1e-12)
|
| 374 |
+
if too_long.size:
|
| 375 |
+
i = int(too_long[0])
|
| 376 |
+
return _bail(
|
| 377 |
+
f"bar {i} lasts {bar_sec[i]:.3f} s -> "
|
| 378 |
+
f"{60.0 * quarter_beats / bar_sec[i]:.1f} BPM, below the "
|
| 379 |
+
f"{min_bar_bpm:g} BPM structural bound "
|
| 380 |
+
f"({too_long.size} bar(s) affected)")
|
| 381 |
+
|
| 382 |
+
n_bars = len(edges) - 1
|
| 383 |
+
grid = {
|
| 384 |
+
"bpm": float(fit.get("bpm", 60.0 * quarter_beats / float(np.median(bar_sec)))),
|
| 385 |
+
"downbeats": edges[:-1],
|
| 386 |
+
"measure_edges": edges,
|
| 387 |
+
"measure_num": [int(num)] * n_bars,
|
| 388 |
+
"measure_den": [int(den)] * n_bars,
|
| 389 |
+
"bar_denoms": [denom] * n_bars,
|
| 390 |
+
"grid_source": "piecewise",
|
| 391 |
+
"fit_quality": _fit_quality(fit),
|
| 392 |
+
"grid_meta": {
|
| 393 |
+
"reason": None,
|
| 394 |
+
"n_bars": n_bars,
|
| 395 |
+
"meter_num": int(num), "meter_den": int(den),
|
| 396 |
+
# single estimated time signature: per-bar meter is NOT estimable
|
| 397 |
+
# from audio in this stack (see the module docstring)
|
| 398 |
+
"meter_source": "fit_estimate",
|
| 399 |
+
"meter_varies": False,
|
| 400 |
+
"bar_denom": denom,
|
| 401 |
+
"duration_sec": duration_sec,
|
| 402 |
+
"first_downbeat_sec": float(edges[0]),
|
| 403 |
+
"terminal_edge_sec": float(edges[-1]),
|
| 404 |
+
"tail_bars_extrapolated": int(n_tail),
|
| 405 |
+
"joint_bars_merged": int(n_merged),
|
| 406 |
+
"trailing_downbeats_dropped": int(n_trailing),
|
| 407 |
+
"bar_sec_min": float(bar_sec.min()),
|
| 408 |
+
"bar_sec_max": float(bar_sec.max()),
|
| 409 |
+
"bar_bpm_min": float(60.0 * quarter_beats / bar_sec.max()),
|
| 410 |
+
"bar_bpm_max": float(60.0 * quarter_beats / bar_sec.min()),
|
| 411 |
+
"n_distinct_bar_sec": int(
|
| 412 |
+
np.unique(np.round(bar_sec, 6)).size),
|
| 413 |
+
"spec_labels_version": spec_version,
|
| 414 |
+
},
|
| 415 |
+
}
|
| 416 |
+
if not np.isfinite(grid["bpm"]) or grid["bpm"] <= 0:
|
| 417 |
+
grid["bpm"] = float(60.0 * quarter_beats / float(np.median(bar_sec)))
|
| 418 |
+
return grid
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def describe_grid(grid):
|
| 422 |
+
"""One honest line about where a served grid came from, for logs and UI."""
|
| 423 |
+
meta = grid.get("grid_meta") or {}
|
| 424 |
+
q = grid.get("fit_quality")
|
| 425 |
+
src = grid.get("grid_source", "unknown")
|
| 426 |
+
n = meta.get("n_bars", len(grid.get("bar_denoms", [])))
|
| 427 |
+
head = (f"{n} bars, {meta.get('meter_num', '?')}/{meta.get('meter_den', '?')} "
|
| 428 |
+
f"(estimated, constant)")
|
| 429 |
+
if src == "piecewise":
|
| 430 |
+
rng = (f"{meta.get('bar_bpm_min', float('nan')):.1f}-"
|
| 431 |
+
f"{meta.get('bar_bpm_max', float('nan')):.1f} BPM")
|
| 432 |
+
detail = (f"grid from estimated barlines, {rng} across bars, "
|
| 433 |
+
f"{q['n_segments']} tempo segment(s)" if q else
|
| 434 |
+
"grid from estimated barlines")
|
| 435 |
+
if meta.get("joint_bars_merged"):
|
| 436 |
+
detail += f", {meta['joint_bars_merged']} joint bar(s) merged"
|
| 437 |
+
if meta.get("tail_bars_extrapolated"):
|
| 438 |
+
detail += f", {meta['tail_bars_extrapolated']} tail bar(s) extrapolated"
|
| 439 |
+
else:
|
| 440 |
+
detail = (f"UNIFORM {grid['bpm']:.2f} BPM fallback -- tempo changes are "
|
| 441 |
+
f"not represented ({meta.get('reason')})")
|
| 442 |
+
if q is not None:
|
| 443 |
+
inl = "n/a" if q["inlier_frac"] is None else format(q["inlier_frac"], ".3f")
|
| 444 |
+
rms = "n/a" if q["rms_ms"] is None else format(q["rms_ms"], ".1f") + "ms"
|
| 445 |
+
detail += f" [fit ok={q['ok']}, inlier={inl}, rms={rms}]"
|
| 446 |
+
return f"{head}; {detail}"
|
softchart/sc2_loader.py
CHANGED
|
@@ -21,14 +21,30 @@ 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
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
"""
|
| 33 |
|
| 34 |
from __future__ import annotations
|
|
@@ -39,6 +55,7 @@ import os
|
|
| 39 |
import re
|
| 40 |
import threading
|
| 41 |
|
|
|
|
| 42 |
import torch
|
| 43 |
|
| 44 |
from .sc2 import generate as sc2_generate
|
|
@@ -554,6 +571,84 @@ def serving_kwargs(config, profile="default"):
|
|
| 554 |
}
|
| 555 |
|
| 556 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
class MotifConstraint:
|
| 558 |
"""Serve the motif-hard value the package RESOLVED, not a module default.
|
| 559 |
|
|
@@ -752,8 +847,9 @@ class SoftChart2:
|
|
| 752 |
|
| 753 |
# -- generation -------------------------------------------------------
|
| 754 |
|
| 755 |
-
def generate(self, mel, course, level=None, *, bpm,
|
| 756 |
-
offset_sec=0.0, duration_sec=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",
|
|
@@ -766,14 +862,26 @@ class SoftChart2:
|
|
| 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
|
|
|
|
| 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
|
|
@@ -797,6 +905,10 @@ class SoftChart2:
|
|
| 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:
|
|
@@ -814,22 +926,26 @@ class SoftChart2:
|
|
| 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
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 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
|
|
|
|
| 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 ``/16`` bar-local lattice decoded with
|
| 25 |
+
``bar_denoms='supplied'`` -- the ``SHIP_DECISION`` ruling, and the ``default``
|
| 26 |
+
serving profile. The package also carries a ``deploy_bpm_grid`` profile
|
| 27 |
+
(model-emitted denominators masked to divisors of 96); the model card does NOT
|
| 28 |
+
recommend it for this checkpoint (§2.8). A ``/16`` grid makes triplets
|
| 29 |
+
arithmetically impossible: 3 does not divide 16.
|
| 30 |
+
|
| 31 |
+
Two things called "grid" meet here and must not be confused. ``/16`` is the
|
| 32 |
+
BAR-LOCAL lattice: how finely a note may be placed inside one bar. The BAR
|
| 33 |
+
TIMELINE -- where each barline falls in the audio -- is a separate object, and
|
| 34 |
+
``generate`` accepts it two ways:
|
| 35 |
+
|
| 36 |
+
``bpm=`` + ``offset_sec=``
|
| 37 |
+
the grid is synthesized as a uniform tiling, ``bar_sec = (240/bpm)*num/den``
|
| 38 |
+
from the first downbeat. One scalar tempo for the whole song; a tempo
|
| 39 |
+
change cannot be expressed.
|
| 40 |
+
``grid=``
|
| 41 |
+
a caller-built supplied grid whose ``measure_edges`` are used verbatim.
|
| 42 |
+
``softchart.barscript_grid.barscript_grid_from_fit`` builds one from an
|
| 43 |
+
estimated beat fit, so non-uniform barlines (and therefore tempo change)
|
| 44 |
+
survive into both the decode and the ``#BPMCHANGE`` lines of the TJA.
|
| 45 |
+
|
| 46 |
+
The returned dict always reports the grid that was actually used, including
|
| 47 |
+
which of the two routes produced it.
|
| 48 |
"""
|
| 49 |
|
| 50 |
from __future__ import annotations
|
|
|
|
| 55 |
import re
|
| 56 |
import threading
|
| 57 |
|
| 58 |
+
import numpy as np
|
| 59 |
import torch
|
| 60 |
|
| 61 |
from .sc2 import generate as sc2_generate
|
|
|
|
| 571 |
}
|
| 572 |
|
| 573 |
|
| 574 |
+
# ---------------------------------------------------------------------------
|
| 575 |
+
# supplied bar timelines
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
SUPPLIED_GRID_KEYS = ("bpm", "downbeats", "measure_edges", "measure_num",
|
| 579 |
+
"measure_den", "bar_denoms")
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
def _check_supplied_grid(grid, grid_denom):
|
| 583 |
+
"""Shape-check a caller-built supplied grid before it reaches the decoder.
|
| 584 |
+
|
| 585 |
+
The decoder's own ``generate._barscript_grid`` validates the geometry
|
| 586 |
+
(edges increasing, one edge per bar plus the terminal one, denominators in
|
| 587 |
+
range) and is the authority on it, so this does not duplicate that work.
|
| 588 |
+
What it adds is the check the decoder cannot make: that ``grid_denom`` --
|
| 589 |
+
the lattice the CALLER believes it is serving, and the number reported to
|
| 590 |
+
the user -- actually agrees with the denominators in the grid. A grid
|
| 591 |
+
built for a different lattice than the one being advertised would decode
|
| 592 |
+
fine and be described wrongly.
|
| 593 |
+
"""
|
| 594 |
+
if not isinstance(grid, dict):
|
| 595 |
+
raise ValueError("grid must be a dict")
|
| 596 |
+
missing = [k for k in SUPPLIED_GRID_KEYS if k not in grid]
|
| 597 |
+
if missing:
|
| 598 |
+
raise ValueError(f"supplied grid is missing {missing}; a BarScript "
|
| 599 |
+
f"grid needs {list(SUPPLIED_GRID_KEYS)}")
|
| 600 |
+
denoms = [int(d) for d in grid["bar_denoms"]]
|
| 601 |
+
if not denoms:
|
| 602 |
+
raise ValueError("supplied grid has no bars")
|
| 603 |
+
if grid_denom is not None and set(denoms) != {int(grid_denom)}:
|
| 604 |
+
raise ValueError(
|
| 605 |
+
f"supplied grid carries bar_denoms {sorted(set(denoms))} but "
|
| 606 |
+
f"grid_denom={int(grid_denom)} was requested; pass "
|
| 607 |
+
"grid_denom=None to serve a grid whose lattice varies per bar")
|
| 608 |
+
return grid
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def _supplied_grid_info(grid, duration_sec):
|
| 612 |
+
"""Provenance for a supplied grid, including whether tempo actually varies.
|
| 613 |
+
|
| 614 |
+
``n_distinct_bar_sec`` is the load-bearing field: a supplied grid is the
|
| 615 |
+
only route that CAN carry a tempo change, but taking it does not mean a
|
| 616 |
+
change was found. Bar lengths are compared at microsecond resolution --
|
| 617 |
+
the same rounding ``barscript_grid.barscript_grid_from_fit`` records -- so
|
| 618 |
+
a grid that is uniform to within float noise reports one distinct length.
|
| 619 |
+
"""
|
| 620 |
+
edges = np.asarray(grid["measure_edges"], dtype=np.float64)
|
| 621 |
+
bar_sec = np.diff(edges)
|
| 622 |
+
quarter = np.asarray([4.0 * int(n) / int(d) for n, d in
|
| 623 |
+
zip(grid["measure_num"], grid["measure_den"])],
|
| 624 |
+
dtype=np.float64)
|
| 625 |
+
bar_bpm = 60.0 * quarter / bar_sec
|
| 626 |
+
denoms = [int(d) for d in grid["bar_denoms"]]
|
| 627 |
+
meters = sorted({(int(n), int(d)) for n, d in
|
| 628 |
+
zip(grid["measure_num"], grid["measure_den"])})
|
| 629 |
+
info = {
|
| 630 |
+
"source": "supplied",
|
| 631 |
+
"denom": denoms[0] if len(set(denoms)) == 1 else None,
|
| 632 |
+
"bpm": float(grid["bpm"]),
|
| 633 |
+
"offset_sec": float(edges[0]),
|
| 634 |
+
"duration_sec": float(duration_sec),
|
| 635 |
+
"meter": [meters[0][0], meters[0][1]],
|
| 636 |
+
"meter_varies": len(meters) > 1,
|
| 637 |
+
"n_bars": len(denoms),
|
| 638 |
+
"triplets_representable": all(d % 3 == 0 for d in denoms),
|
| 639 |
+
"n_distinct_bar_sec": int(np.unique(np.round(bar_sec, 6)).size),
|
| 640 |
+
"bar_bpm_min": float(bar_bpm.min()),
|
| 641 |
+
"bar_bpm_max": float(bar_bpm.max()),
|
| 642 |
+
"terminal_edge_sec": float(edges[-1]),
|
| 643 |
+
}
|
| 644 |
+
# provenance the grid builder attached, carried through untouched so the
|
| 645 |
+
# caller does not have to hold on to the builder's return value
|
| 646 |
+
for key in ("grid_source", "fit_quality", "grid_meta"):
|
| 647 |
+
if key in grid:
|
| 648 |
+
info[key] = grid[key]
|
| 649 |
+
return info
|
| 650 |
+
|
| 651 |
+
|
| 652 |
class MotifConstraint:
|
| 653 |
"""Serve the motif-hard value the package RESOLVED, not a module default.
|
| 654 |
|
|
|
|
| 847 |
|
| 848 |
# -- generation -------------------------------------------------------
|
| 849 |
|
| 850 |
+
def generate(self, mel, course, level=None, *, bpm=None,
|
| 851 |
+
offset_sec=0.0, duration_sec=None, grid=None,
|
| 852 |
+
density_bucket=None,
|
| 853 |
density_nps=None, meter=(4, 4), grid_denom=SHIP_GRID_DENOM,
|
| 854 |
seed=0, big_rate=None, span_rate=None, stream=None,
|
| 855 |
sync_band=None, title="SoftChart", wave="song.ogg",
|
|
|
|
| 862 |
``course`` one of ``COURSES`` (easy/normal/hard/oni/ura), or ``None``
|
| 863 |
to decode unconditioned.
|
| 864 |
``level`` TJA level, clamped to 1..12 by the prefix builder.
|
| 865 |
+
``bpm`` tempo of the SYNTHESIZED grid. Mutually exclusive with
|
| 866 |
+
``grid``; exactly one of the two must be given.
|
| 867 |
``offset_sec`` audio time of the FIRST DOWNBEAT, in seconds. This is
|
| 868 |
+
the negative of the TJA ``OFFSET`` field. Only meaningful
|
| 869 |
+
on the ``bpm`` route.
|
| 870 |
+
``grid`` a ready-made supplied grid -- ``{bpm, downbeats,
|
| 871 |
+
measure_edges, measure_num, measure_den, bar_denoms}`` --
|
| 872 |
+
used VERBATIM instead of tiling one from ``bpm``. This is
|
| 873 |
+
the route that can carry a tempo change: the decoder and
|
| 874 |
+
``write_tja_slots`` both read bar times out of
|
| 875 |
+
``measure_edges``, so non-uniform bars stay non-uniform.
|
| 876 |
+
Build one with
|
| 877 |
+
``softchart.barscript_grid.barscript_grid_from_fit``.
|
| 878 |
``duration_sec`` defaults to ``mel.shape[1] / FPS``.
|
| 879 |
``density_bucket`` DENS bucket 0..15, or ``density_nps`` in notes per
|
| 880 |
second, which is bucketed with the package's width.
|
| 881 |
``grid_denom`` bar-local lattice denominator teacher-forced into every
|
| 882 |
+
[BAR] header. 16 is the shipped deployment grid. With a
|
| 883 |
+
supplied ``grid`` the grid's own ``bar_denoms`` govern and
|
| 884 |
+
``grid_denom`` is only cross-checked against them.
|
| 885 |
``overrides`` serving parameters (``temperature``, ``top_p``,
|
| 886 |
``min_onset_gap_sec``, ...). The package's recorded
|
| 887 |
values apply unless one is named here; anything overridden
|
|
|
|
| 905 |
course = None if course is None else str(course).lower()
|
| 906 |
if mel.ndim != 2:
|
| 907 |
raise ValueError("mel must be (n_mels, T)")
|
| 908 |
+
if (bpm is None) == (grid is None):
|
| 909 |
+
raise ValueError(
|
| 910 |
+
"pass exactly one of bpm= (synthesize a uniform grid) or "
|
| 911 |
+
"grid= (use a supplied grid verbatim)")
|
| 912 |
if duration_sec is None:
|
| 913 |
duration_sec = mel.shape[1] / FPS
|
| 914 |
if density_bucket is None and density_nps is not None:
|
|
|
|
| 926 |
if k in kwargs and v is not None and v != kwargs[k]}
|
| 927 |
kwargs.update({k: v for k, v in overrides.items() if v is not None})
|
| 928 |
|
| 929 |
+
if grid is None:
|
| 930 |
+
grid = barscript_grid_from_bpm(bpm, float(offset_sec),
|
| 931 |
+
float(duration_sec), meter=meter,
|
| 932 |
+
denom=int(grid_denom))
|
| 933 |
+
grid_info = {
|
| 934 |
+
"source": "bpm",
|
| 935 |
+
"denom": int(grid_denom),
|
| 936 |
+
"bpm": float(bpm),
|
| 937 |
+
"offset_sec": float(offset_sec),
|
| 938 |
+
"duration_sec": float(duration_sec),
|
| 939 |
+
"meter": [int(meter[0]), int(meter[1])],
|
| 940 |
+
"n_bars": len(grid["bar_denoms"]),
|
| 941 |
+
"triplets_representable": (int(grid_denom) % 3 == 0),
|
| 942 |
+
}
|
| 943 |
+
else:
|
| 944 |
+
grid = _check_supplied_grid(grid, grid_denom)
|
| 945 |
+
grid_info = _supplied_grid_info(grid, float(duration_sec))
|
| 946 |
+
grid_info["bar_denoms"] = kwargs["bar_denoms"]
|
| 947 |
+
grid_info["bar_denom_mask"] = kwargs["bar_denom_mask"]
|
| 948 |
+
grid_info["profile"] = profile
|
| 949 |
|
| 950 |
# config.serving_basis: span_bucket0_profile is null in this release
|
| 951 |
# and is a KNOWN GAP (§2.7), not a tuned choice. A package that ever
|
static/app.css
CHANGED
|
@@ -1299,6 +1299,22 @@ audio {
|
|
| 1299 |
font-weight: 760;
|
| 1300 |
}
|
| 1301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1302 |
.preview-tabs {
|
| 1303 |
display: flex;
|
| 1304 |
gap: 20px;
|
|
|
|
| 1299 |
font-weight: 760;
|
| 1300 |
}
|
| 1301 |
|
| 1302 |
+
/* Sentence-valued metrics (the bar timeline report). They take the whole row
|
| 1303 |
+
and wrap, because a truncated "tempo changes are NOT represented" would be
|
| 1304 |
+
worse than not showing it at all. */
|
| 1305 |
+
.metric--wide {
|
| 1306 |
+
grid-column: 1 / -1;
|
| 1307 |
+
}
|
| 1308 |
+
|
| 1309 |
+
.metric--wide dd {
|
| 1310 |
+
overflow: visible;
|
| 1311 |
+
font-size: 12.5px;
|
| 1312 |
+
font-weight: 620;
|
| 1313 |
+
line-height: 1.5;
|
| 1314 |
+
text-overflow: clip;
|
| 1315 |
+
white-space: normal;
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
.preview-tabs {
|
| 1319 |
display: flex;
|
| 1320 |
gap: 20px;
|
static/app.js
CHANGED
|
@@ -127,9 +127,22 @@ const metricLabels = {
|
|
| 127 |
grid_anchor: "Grid anchor",
|
| 128 |
density_bucket: "Density bucket",
|
| 129 |
sampling: "Sampling",
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
@@ -435,7 +448,11 @@ function metricValue(key, value) {
|
|
| 435 |
if (typeof value === "boolean") return value ? "Yes" : "No";
|
| 436 |
if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
| 437 |
if (typeof value === "object") return JSON.stringify(value);
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
}
|
| 440 |
|
| 441 |
function renderMetrics(metrics) {
|
|
@@ -455,7 +472,7 @@ function renderMetrics(metrics) {
|
|
| 455 |
const wrapper = document.createElement("div");
|
| 456 |
const term = document.createElement("dt");
|
| 457 |
const description = document.createElement("dd");
|
| 458 |
-
wrapper.className = "metric";
|
| 459 |
term.textContent = label;
|
| 460 |
description.textContent = metricValue(key, value);
|
| 461 |
description.title = description.textContent;
|
|
|
|
| 127 |
grid_anchor: "Grid anchor",
|
| 128 |
density_bucket: "Density bucket",
|
| 129 |
sampling: "Sampling",
|
| 130 |
+
// The bar timeline: where the barlines fell, whether the song's tempo change
|
| 131 |
+
// survived into them, and how well the beat fit did. The server sends these
|
| 132 |
+
// as finished sentences -- the interface must not restate or soften them.
|
| 133 |
+
// They come last so the full-width rows land under the compact tiles.
|
| 134 |
+
beat_fit: "Beat fit",
|
| 135 |
+
bar_grid: "Bar grid",
|
| 136 |
+
tempo_map: "Tempo map",
|
| 137 |
+
grid_repairs: "Grid repairs"
|
| 138 |
};
|
| 139 |
|
| 140 |
+
// Metrics whose value is a sentence rather than a number or a short label.
|
| 141 |
+
// A compact tile would ellipsis them away, and these are exactly the ones a
|
| 142 |
+
// user has to be able to read: whether this song's tempo change was handled.
|
| 143 |
+
const wideMetrics = new Set(["beat_fit", "bar_grid", "tempo_map",
|
| 144 |
+
"grid_repairs"]);
|
| 145 |
+
|
| 146 |
// Filled from /api/capabilities: the server owns the model list and the model
|
| 147 |
// card's limitation text, so the interface never restates either from memory.
|
| 148 |
let capabilities = null;
|
|
|
|
| 448 |
if (typeof value === "boolean") return value ? "Yes" : "No";
|
| 449 |
if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
| 450 |
if (typeof value === "object") return JSON.stringify(value);
|
| 451 |
+
// Underscores are prettified only in single-token enum values. Sentences
|
| 452 |
+
// from the server quote identifiers verbatim (`inlier_frac`, `rms_ms`, a
|
| 453 |
+
// fit's own reason string); rewriting those would misquote the server.
|
| 454 |
+
const text = String(value);
|
| 455 |
+
return text.includes(" ") ? text : text.replaceAll("_", " ");
|
| 456 |
}
|
| 457 |
|
| 458 |
function renderMetrics(metrics) {
|
|
|
|
| 472 |
const wrapper = document.createElement("div");
|
| 473 |
const term = document.createElement("dt");
|
| 474 |
const description = document.createElement("dd");
|
| 475 |
+
wrapper.className = wideMetrics.has(key) ? "metric metric--wide" : "metric";
|
| 476 |
term.textContent = label;
|
| 477 |
description.textContent = metricValue(key, value);
|
| 478 |
description.title = description.textContent;
|