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

BarScript: serve the fitted (non-uniform) bar timeline by default; report grid source, spread and fit quality in the UI

Browse files
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 decodes onto a bar
75
- lattice built from the fitted BPM and first downbeat, and refuses to generate
76
- without one rather than anchoring the bars on an arbitrary phase. It takes no
77
- song plan, so the Structure and AI planner switches do not reach it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  `python spaces/scripts/test_app_wiring.py` checks the routing, the resolution
80
  order above, the five difficulties, the Ura TJA header, and — against a recorded
 
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** — 7 vendor checks, 117 wiring checks, 380
146
- end-to-end checks.
 
 
 
 
 
 
 
 
 
147
 
148
  15 of the wiring checks cover package resolution: that `$SC2_PACKAGE_DIR`
149
  outranks a local copy and the Hub, that an unsatisfiable `$SC2_PACKAGE_DIR`
@@ -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
- Two operational notes are shown with them: the plan switches do not apply, and
292
- BarScript needs a beat grid.
 
 
293
 
294
  ### The Ura warning applies to every model
295
 
@@ -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. **Piecewise-tempo songs are flattened.** BarScript decodes one uniform tempo; when
343
- the grid fit finds several segments the result discloses this under
344
- `tempo_map`. The six test songs are single-tempo, so the effect is
345
- unquantified.
 
 
 
 
346
 
347
  5. **No human play-testing.** Every claim here is structural or statistical.
348
  Whether these charts are *good* — groove, pattern quality, whether the ura
@@ -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 generate_sc2(sc2, mel, grid, dbs, bpm, course, level, wave_name, title,
418
- *, sampling, temperature, top_p, seed=0):
419
- """Run the BarScript release. Returns ``(generated, tja, extra_metrics)``.
420
-
421
- BarScript decodes onto a bar lattice supplied by the caller, so it needs both a
422
- tempo and the audio time of the first downbeat. Neither can be guessed: a
423
- lattice anchored on the wrong phase puts every barline in the wrong place,
424
- and the chart would look like a model defect rather than a missing input.
425
- The route therefore fails closed when no downbeat is available.
426
-
427
- Sampling parameters are left at the package's recorded serving contract
428
- unless the user turns creative sampling on. Anything overridden is reported
429
- back, because the model card's measurements describe the recorded values.
 
 
 
 
 
 
 
 
 
 
430
  """
431
- if grid is not None and len(grid.get("downbeats", [])):
432
- offset_sec = float(grid["downbeats"][0])
433
  anchor = "fitted beat grid"
434
  elif dbs is not None and len(dbs):
435
  offset_sec = float(dbs[0])
@@ -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, bpm=float(bpm), offset_sec=offset_sec,
450
  density_bucket=COURSE_DENS[course], seed=seed,
451
  grid_denom=sc2_loader.SHIP_GRID_DENOM,
452
  title=title, wave=wave_name, **overrides,
453
  )
454
  grid_info = result["grid"]
455
- segments = int(grid.get("n_segments", 1)) if grid is not None else 1
456
  extra = {
457
- "chart_grid": (f"/{grid_info['denom']} uniform"
 
 
458
  + ("" if grid_info["triplets_representable"]
459
  else " — triplets impossible")),
460
- "grid_anchor": f"{anchor}, first downbeat {offset_sec:.3f} s",
461
  "density_bucket": result["density_bucket"],
462
  "sampling": ("user override: " + ", ".join(
463
  f"{k}={v}" for k, v in sorted(result["serving_overrides"].items()))
464
  ) if result["serving_overrides"] else "package defaults",
 
465
  }
466
- if segments > 1:
467
- extra["tempo_map"] = (
468
- f"flattened — the fit found {segments} tempo segments, BarScript "
469
- f"decodes "
470
- f"one uniform tempo ({bpm:.1f} BPM)"
471
- )
472
  if not result["motif"]["verified"]:
473
  raise RuntimeError(
474
  "BarScript decoded without the motif constraint the release "
@@ -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 = dbs = None
 
 
 
 
659
  if use_beat:
660
  grid = fit_grid_piecewise(models["beat"], mel, device=DEVICE)
 
661
  if grid is not None:
662
  dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
663
  if not grid["ok"]:
@@ -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, wave_name, title,
 
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
- patch("fit_grid_piecewise", record("fit_grid_piecewise", fake_grid))
152
- patch("fit_grid_fixed_bpm", record("fit_grid_fixed_bpm", fake_grid))
 
 
 
 
153
  patch("learned_plan", record("learned_plan", [[0.0, 4.0, 5, 0]]))
154
  patch("auto_plan", record("auto_plan", [[0.0, 4.0, 3, 0]]))
155
  patch("generate_song_slot", record("generate_song_slot", fake_generation))
@@ -533,24 +570,46 @@ def test_planner_fallback():
533
  # 4. the BarScript route
534
 
535
 
536
- def test_sc2_conditions():
537
- """What the app hands the BarScript loader, with the loader itself recorded."""
538
- seen = {}
 
 
 
 
 
 
 
 
 
 
 
539
 
540
  class FakeSC2:
541
  package_dir = "/tmp/nonexistent-package"
 
542
 
543
  def generate(self, mel, course, level=None, **kwargs):
544
  seen["mel"] = mel.shape
545
  seen["course"] = course
546
  seen["level"] = level
547
  seen.update(kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  return {
549
  "tja": TJA_STUB, "gen": fake_generation(),
550
- "grid": {"source": "bpm", "denom": kwargs.get("grid_denom", 16),
551
- "bpm": kwargs["bpm"], "offset_sec": kwargs["offset_sec"],
552
- "triplets_representable": kwargs.get("grid_denom", 16) % 3 == 0,
553
- "profile": "default"},
554
  "serving": {}, "serving_overrides": {
555
  k: kwargs[k] for k in ("greedy", "temperature", "top_p")
556
  if k in kwargs},
@@ -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: FakeSC2()
565
  APP.sc2_beat_model = lambda: Recorder("beat", [])
566
  APP.available_models = lambda: list(APP.MODELS) + [APP.SC2_MODEL]
567
- ok = True
568
  try:
569
- for course, sampling in (("oni", False), ("ura", True)):
570
- seen.clear()
571
- calls = []
572
- with tempfile.TemporaryDirectory() as audio_dir:
573
- audio_path = os.path.join(audio_dir, "song.ogg")
574
- with open(audio_path, "wb") as f:
575
- f.write(b"OggS")
576
- workdir = tempfile.mkdtemp(prefix="softchart-work-")
577
- saved_mkdtemp = tempfile.mkdtemp
578
- tempfile.mkdtemp = lambda *a, **k: workdir
579
- try:
580
- payload = harness(APP, calls, course=course,
581
- model_choice=APP.SC2_MODEL,
582
- sampling=sampling, audio_path=audio_path,
583
- workdir=workdir)
584
- finally:
585
- tempfile.mkdtemp = saved_mkdtemp
586
-
587
- grid = fake_grid()
588
- ok &= check(f"BarScript ({course}): bpm and first-downbeat offset from the fitted grid",
589
- seen.get("bpm") == grid["bpm"]
590
- and abs(seen.get("offset_sec", -1) - float(grid["downbeats"][0])) < 1e-9,
591
- f"bpm={seen.get('bpm')} offset={seen.get('offset_sec')}")
592
- ok &= check(f"BarScript ({course}): course and density bucket",
593
- seen.get("course") == course
594
- and seen.get("density_bucket") == APP.COURSE_DENS[course],
595
- f"{seen.get('course')} bucket={seen.get('density_bucket')}")
596
- ok &= check(f"BarScript ({course}): ships on the /16 deployment lattice",
597
- seen.get("grid_denom") == sc2_loader.SHIP_GRID_DENOM)
598
- if sampling:
599
- ok &= check(f"BarScript ({course}): creative sampling is passed as an override",
600
- seen.get("greedy") is False and seen.get("temperature") == 0.8
601
- and seen.get("top_p") == 0.9, f"{seen.get('greedy')}")
602
- else:
603
- ok &= check(f"BarScript ({course}): package serving contract left alone",
604
- not {"greedy", "temperature", "top_p"} & set(seen),
605
- f"{sorted(set(seen) & {'greedy', 'temperature', 'top_p'})}")
606
-
607
- metrics = payload[-1].get("metrics", {})
608
- ok &= check(f"BarScript ({course}): the /16 lattice and its triplet gap are shown",
609
- "triplets impossible" in metrics.get("chart_grid", ""),
610
- metrics.get("chart_grid", ""))
611
- ok &= check(f"BarScript ({course}): the plan switches are reported as unused",
612
- metrics.get("plan_source") == "Not used — BarScript takes no plan",
613
- metrics.get("plan_source", ""))
614
- names = [c[0] for c in calls]
615
- ok &= check(f"BarScript ({course}): the 1.x generators are never called",
616
- not {"generate_song", "generate_song_slot",
617
- "write_tja_slots", "snap_chart"} & set(names),
618
- f"{names}")
619
-
620
- # no downbeat -> refuse, rather than anchor the bars on an arbitrary phase
621
- calls = []
622
  with tempfile.TemporaryDirectory() as audio_dir:
623
  audio_path = os.path.join(audio_dir, "song.ogg")
624
  with open(audio_path, "wb") as f:
@@ -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="oni", use_beat=False,
631
- model_choice=APP.SC2_MODEL, bpm_override=150.0,
632
- audio_path=audio_path, workdir=workdir)
 
633
  finally:
634
  tempfile.mkdtemp = saved_mkdtemp
635
- last = payload[-1]
636
- ok &= check("BarScript: fails closed without a downbeat anchor",
637
- last.get("kind") == "error" and "downbeat" in last.get("detail", ""),
638
- last.get("detail", ""))
639
  finally:
640
  APP.get_sc2, APP.sc2_beat_model = saved_get_sc2, saved_beat
641
  APP.available_models = saved_available
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  return ok
643
 
644
 
645
  def test_sc2_real_decode():
646
- """One real 2.0 decode, if the release package is on disk."""
 
 
 
 
 
 
 
 
647
  if not sc2_loader.is_available():
648
  print("SKIP 2.0 real decode: no release package "
649
  f"({sc2_loader.default_package_dir()})")
@@ -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, test_sc2_package_resolution,
 
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 uniform ``/16`` lattice synthesized from user BPM and
25
- offset, decoded with ``bar_denoms='supplied'`` -- the ``SHIP_DECISION``
26
- ruling, and the ``default`` serving profile. The package also carries a
27
- ``deploy_bpm_grid`` profile (model-emitted denominators masked to divisors of
28
- 96); the model card does NOT recommend it for this checkpoint (§2.8). A
29
- ``/16`` grid makes triplets arithmetically impossible: 3 does not divide 16.
30
-
31
- The returned dict always reports the grid that was actually used.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  """
33
 
34
  from __future__ import annotations
@@ -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, density_bucket=None,
 
757
  density_nps=None, meter=(4, 4), grid_denom=SHIP_GRID_DENOM,
758
  seed=0, big_rate=None, span_rate=None, stream=None,
759
  sync_band=None, title="SoftChart", wave="song.ogg",
@@ -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 synthesized grid.
 
770
  ``offset_sec`` audio time of the FIRST DOWNBEAT, in seconds. This is
771
- the negative of the TJA ``OFFSET`` field.
 
 
 
 
 
 
 
 
 
772
  ``duration_sec`` defaults to ``mel.shape[1] / FPS``.
773
  ``density_bucket`` DENS bucket 0..15, or ``density_nps`` in notes per
774
  second, which is bucketed with the package's width.
775
  ``grid_denom`` bar-local lattice denominator teacher-forced into every
776
- [BAR] header. 16 is the shipped deployment grid.
 
 
777
  ``overrides`` serving parameters (``temperature``, ``top_p``,
778
  ``min_onset_gap_sec``, ...). The package's recorded
779
  values apply unless one is named here; anything overridden
@@ -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 = barscript_grid_from_bpm(bpm, float(offset_sec),
818
- float(duration_sec), meter=meter,
819
- denom=int(grid_denom))
820
- grid_info = {
821
- "source": "bpm",
822
- "denom": int(grid_denom),
823
- "bpm": float(bpm),
824
- "offset_sec": float(offset_sec),
825
- "duration_sec": float(duration_sec),
826
- "meter": [int(meter[0]), int(meter[1])],
827
- "n_bars": len(grid["bar_denoms"]),
828
- "bar_denoms": kwargs["bar_denoms"],
829
- "bar_denom_mask": kwargs["bar_denom_mask"],
830
- "triplets_representable": (int(grid_denom) % 3 == 0),
831
- "profile": profile,
832
- }
 
 
 
 
833
 
834
  # config.serving_basis: span_bucket0_profile is null in this release
835
  # and is a KNOWN GAP (§2.7), not a tuned choice. A package that ever
 
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
- tempo_map: "Tempo map"
 
 
 
 
 
 
 
131
  };
132
 
 
 
 
 
 
 
133
  // Filled from /api/capabilities: the server owns the model list and the model
134
  // card's limitation text, so the interface never restates either from memory.
135
  let capabilities = null;
@@ -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
- return String(value).replaceAll("_", " ");
 
 
 
 
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;