JacobLinCool commited on
Commit
6ced104
·
1 Parent(s): e97569f

Deploy hierarchical SoftChart V1.8

Browse files
README.md CHANGED
@@ -10,47 +10,39 @@ app_file: app.py
10
  pinned: false
11
  license: mit
12
  models:
13
- - JacobLinCool/softchart-v17
14
- - JacobLinCool/softchart-v17-small
15
- - JacobLinCool/softchart-v17-tiny
16
- - JacobLinCool/softchart-v16
17
- - JacobLinCool/softchart-v16-small
18
- - JacobLinCool/softchart-v16-tiny
19
- - JacobLinCool/softchart-v15
20
- - JacobLinCool/softchart-planner
21
  ---
22
 
23
- # 🥁 SoftChart
24
 
25
- Generate a **Taiko no Tatsujin** chart from any song. A unified 7.9M-parameter
26
- encoder–decoder model handles beat/downbeat fitting, slot-exact generation, and
27
- gridless time generation; an optional planner supplies song-level density,
28
- breathing gaps, and climax structure. All models are MIT-licensed and loaded
29
- from the Hugging Face Hub via `from_pretrained`.
30
 
31
- - **SoftChart v15** ([softchart-v15](https://huggingface.co/JacobLinCool/softchart-v15)) — unified slot + time + beat model; slot mode emits exact TJA lattice indices when the beat grid is trusted
32
- - **Planner** ([softchart-planner](https://huggingface.co/JacobLinCool/softchart-planner)) audio song-level plan (density envelope, breathing gaps, climax)
 
 
 
33
 
34
- The Space uses Gradio Server mode with a fully custom, responsive interface.
35
- Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
36
- full TJA chart image, a `.tja` playable in TJAPlayer3 / OpenTaiko, and a
37
- synchronized WAV mix that lets you hear the generated chart performed over
38
- the source music immediately. Its Don, Big Don, and Katsu voices are original
39
- modal-resonator syntheses built from seeded excitation noise, decaying drum
40
- modes, pitch sweeps, and band-limited stick transients. Reference recordings
41
- are used only for offline envelope and level calibration; they are not copied,
42
- mixed, embedded, or required by the Space.
43
 
44
- The generation API streams the real processing stages to the interface:
45
- audio analysis, beat-grid fitting, song-level planning, note generation,
46
- chart rendering, and taiko preview mixing. The final audio is timed from the
47
- exported TJA itself, including `OFFSET` and `#BPMCHANGE`, so the audition and
48
- downloadable chart share the same timing.
49
 
50
- The generated TJA keeps the uploaded audio filename in its `WAVE` field.
51
- Planner climax blocks are emitted as TJA `#GOGOSTART` / `#GOGOEND` sections.
52
- The chart image uses a server-side renderer modeled on the row layout used by
53
- MIT-licensed [tja-tools](https://github.com/WHMHammer/tja-tools), without
54
- shipping its browser/webpack runtime.
55
 
56
- > Code and weights are **MIT-licensed**.
 
 
 
10
  pinned: false
11
  license: mit
12
  models:
13
+ - JacobLinCool/softchart-v18
14
+ datasets:
15
+ - JacobLinCool/taiko-1000-parsed-clean
 
 
 
 
 
16
  ---
17
 
18
+ # 🥁 SoftChart V1.8
19
 
20
+ Generate a **Taiko no Tatsujin-style** chart from an uploaded song with the
21
+ scratch-trained [SoftChart V1.8 hierarchical model](https://huggingface.co/JacobLinCool/softchart-v18).
22
+ The 8,985,091-parameter model combines whole-song hierarchical context, a local
23
+ rhythmic-skeleton auxiliary head, chart generation, and beat/downbeat
24
+ estimation. It uses no external pretrained model and no external song planner.
25
 
26
+ The Space always uses V1.8's full-song **time generation** path for arbitrary
27
+ uploads. The beat head can estimate BPM and a downbeat grid for TJA
28
+ quantization, but that estimated grid is not treated as the trusted rational
29
+ meter required by exact slot generation. This distinction prevents an
30
+ estimated beat grid from being presented as authored-meter exact timing.
31
 
32
+ Audio preprocessing follows the released model contract: FFmpeg decodes
33
+ stereo float32 at 22,050 Hz, channels are averaged arithmetically, and a
34
+ periodic-Hann STFT is projected to 128 log-mel bins. Upload a song, choose a
35
+ difficulty, and receive:
 
 
 
 
 
36
 
37
+ - a playable `.tja` for TJAPlayer3 / OpenTaiko;
38
+ - a rendered chart image;
39
+ - the full-song mel structure view used by the hierarchy;
40
+ - a synchronized WAV audition with synthesized Taiko hits.
 
41
 
42
+ The audition is rendered from the exported TJA, including its `OFFSET`, so the
43
+ preview and downloadable chart use the same timing. Taiko voices are original
44
+ modal-resonator syntheses included under the project's MIT license.
 
 
45
 
46
+ The model and Space code are **MIT-licensed**. Evaluation scope, metrics, and
47
+ known limitations are documented on the
48
+ [V1.8 model card](https://huggingface.co/JacobLinCool/softchart-v18).
app.py CHANGED
@@ -1,16 +1,16 @@
1
- """SoftChart — custom Gradio Server app for Hugging Face Spaces.
2
 
3
- Generate a Taiko no Tatsujin chart from any audio file, using the full system:
4
- - SoftChartGenerator (main model, plan-conditioned)
5
- - SoftChartPlanner (auto song-level planning)
6
- - SoftChartBeat (beat/downbeat for barline anchoring)
7
- All models load from the Hub via from_pretrained. MIT licensed.
8
  """
9
 
10
  import logging
11
  import os
12
  import re
13
  import shutil
 
14
  import tempfile
15
  from pathlib import Path
16
 
@@ -20,123 +20,103 @@ import torch
20
  from fastapi.responses import FileResponse
21
  from fastapi.staticfiles import StaticFiles
22
 
23
- from softchart.generate import generate_song, generate_song_slot, load_hf
24
  from softchart.fonts import cjk_font_path
25
  from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
26
- from softchart.hf import SoftChartPlanner
27
  from softchart.preview_audio import synthesize_taiko_preview
28
  from softchart.rhythm import snap_chart
29
- from softchart.tja import append_measure_with_gogo, gogo_measure_mask, write_tja_slots
30
  from softchart.tja_image import render_tja_image
31
  from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
32
 
33
  LOGGER = logging.getLogger("softchart.space")
34
  STATIC_DIR = Path(__file__).with_name("static")
35
 
36
- PLAN_REPO = os.environ.get("SC_PLAN", "JacobLinCool/softchart-planner")
37
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
38
-
39
- # Selectable generators: three generations x three sizes (plus the v1.5 single
40
- # model). All are dual (slot + time), beat- and plan-conditioned, MIT licensed.
41
- # https://huggingface.co/collections/JacobLinCool/softchart-generators
42
- MODELS = {
43
- "v1.7": "JacobLinCool/softchart-v17",
44
- "v1.7-small": "JacobLinCool/softchart-v17-small",
45
- "v1.7-tiny": "JacobLinCool/softchart-v17-tiny",
46
- "v1.6": "JacobLinCool/softchart-v16",
47
- "v1.6-small": "JacobLinCool/softchart-v16-small",
48
- "v1.6-tiny": "JacobLinCool/softchart-v16-tiny",
49
- "v1.5": "JacobLinCool/softchart-v15",
50
- }
51
- DEFAULT_MODEL = os.environ.get("SC_MODEL", "v1.7")
52
 
53
  COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
54
  CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
55
  "roll": "5", "roll_big": "6", "balloon": "7"}
56
  SUB = 96
57
 
58
- _MODELS = {} # repo id -> {"gen","slot","beat"}, loaded lazily and cached
59
- _PLANNER = {} # the planner is model-agnostic and shared across generators
60
-
61
-
62
- def get_models(model_choice=DEFAULT_MODEL, *, include_planner=False):
63
- repo = MODELS.get(model_choice)
64
- if repo is None:
65
- raise ValueError(
66
- f"unknown model {model_choice!r}; choose one of {list(MODELS)}"
67
- )
68
- if repo not in _MODELS:
69
- u = load_hf(repo, device=DEVICE)
70
- if (not getattr(u, "_dual", False) or u.beat is None
71
- or not getattr(u, "_has_plan", False)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  raise RuntimeError(
73
- f"{repo} must provide dual generation, beat, and plan conditioning"
 
74
  )
75
- _MODELS[repo] = {"gen": u, "slot": u, "beat": u}
76
- models = dict(_MODELS[repo])
77
- if include_planner:
78
- if "plan" not in _PLANNER:
79
- _PLANNER["plan"] = SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
80
- models["plan"] = _PLANNER["plan"]
81
- return models
82
 
83
 
84
  def load_logmel(path):
 
85
  import librosa
86
-
87
- wav, _ = librosa.load(path, sr=SR, mono=True)
88
- fb = librosa.filters.mel(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
89
- spec = torch.stft(torch.from_numpy(wav), N_FFT, hop_length=HOP,
90
- window=torch.hann_window(N_FFT), center=True, return_complex=True)
91
- mel = np.log(fb @ spec.abs().pow(2).numpy() + 1e-5).astype(np.float32)
92
- return mel, wav
93
-
94
-
95
- def auto_plan(mel, bpm, downbeats=None):
96
- T = mel.shape[1]
97
- dur = T / FPS
98
- flux = np.concatenate([[0], np.maximum(0, np.diff(mel, axis=1)).sum(0)])
99
- beat = 60.0 / bpm
100
- edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
101
- else list(np.arange(0, dur, 4 * beat)) + [dur]
102
- vals = [float(flux[int(a * FPS):int(b * FPS)].mean()) if int(b * FPS) > int(a * FPS) else 0.0
103
- for a, b in zip(edges, edges[1:])]
104
- if not vals:
105
- return None
106
- vals = np.array(vals)
107
- lo, hi = np.percentile(vals, 15), np.percentile(vals, 92)
108
- peak = int(np.argmax(vals))
109
- plan = []
110
- for i, (a, b) in enumerate(zip(edges, edges[1:])):
111
- frac = (vals[i] - lo) / max(hi - lo, 1e-6)
112
- d8 = int(np.clip(round(frac * 7), 0, 7))
113
- fl = 1 if (vals[i] <= lo and 0 < i < len(vals) - 1) else (2 if i == peak and vals[i] >= hi else 0)
114
- plan.append([round(a, 3), round(b, 3), d8, fl])
115
- return plan
116
-
117
-
118
- def learned_plan(planner, mel, course, bpm, downbeats=None):
119
- dur = mel.shape[1] / FPS
120
- beat = 60.0 / bpm
121
- edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
122
- else list(np.arange(0, dur, 4 * beat)) + [dur]
123
- feats, spans = [], []
124
- for a, b in zip(edges, edges[1:]):
125
- seg = mel[:, int(a * FPS):int(b * FPS)]
126
- if seg.shape[1] < 2:
127
- continue
128
- fx = np.maximum(0, np.diff(seg, axis=1)).sum(0)
129
- feats.append(np.concatenate([seg.mean(1), seg.std(1), [fx.mean(), fx.std(), fx.max()]]))
130
- spans.append((round(float(a), 3), round(float(b), 3)))
131
- if not feats:
132
- return None
133
- cid = {"easy": 0, "normal": 1, "hard": 2, "oni": 3}[course]
134
- x = torch.tensor(np.array(feats), dtype=torch.float32)[None].to(DEVICE)
135
  with torch.no_grad():
136
- pd, pf = planner(x, torch.tensor([cid], device=DEVICE))
137
- d8 = pd[0].argmax(-1).cpu().numpy()
138
- fl = pf[0].argmax(-1).cpu().numpy()
139
- return [[a, b, int(d), int(f)] for (a, b), d, f in zip(spans, d8, fl)]
 
 
 
140
 
141
 
142
  def group_quantize(times, phase, grid, min_run=3):
@@ -176,8 +156,7 @@ def output_tja_path(wave_name, course, directory):
176
  return os.path.join(directory, f"{stem}_{course}.tja")
177
 
178
 
179
- def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=None,
180
- plan=None):
181
  hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
182
  beat = 60.0 / bpm
183
  grid = beat / (SUB / 4)
@@ -229,17 +208,10 @@ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=Non
229
  slots = {k - shift: v for k, v in slots.items()}
230
  phase += shift * grid
231
  n_meas = (max(slots) // SUB + 1) if slots else 1
232
- measure_starts = phase + np.arange(n_meas + 1, dtype=float) * (4 * beat)
233
- gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas)
234
- lines = []
235
- in_gogo = False
236
- for m in range(n_meas):
237
- in_gogo = append_measure_with_gogo(
238
- lines,
239
- "".join(slots.get(m * SUB + k, "0") for k in range(SUB)) + ",",
240
- m, gogo_mask, in_gogo)
241
- if in_gogo:
242
- lines.append("#GOGOEND")
243
  balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
244
  return "\n".join([
245
  f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", f"WAVE:{wave}",
@@ -248,7 +220,7 @@ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=Non
248
  "", "#START", *lines, "#END"]) + "\n"
249
 
250
 
251
- def render_audio_plan(mel, title, course, out_path, plan=None):
252
  import matplotlib
253
  matplotlib.use("Agg")
254
  from matplotlib import font_manager
@@ -261,32 +233,14 @@ def render_audio_plan(mel, title, course, out_path, plan=None):
261
  matplotlib.rcParams["axes.unicode_minus"] = False
262
 
263
  dur = mel.shape[1] / FPS
264
- fig = plt.figure(figsize=(13, 4.4 if plan else 3.2))
265
- gs = fig.add_gridspec(2 if plan else 1, 1,
266
- height_ratios=[3.0, 1.0] if plan else [1],
267
- hspace=0.14 if plan else 0.0)
268
- ax0 = fig.add_subplot(gs[0])
269
  ax0.imshow(mel, aspect="auto", origin="lower",
270
  cmap="magma", extent=[0, dur, 0, N_MELS])
271
  ax0.set_ylabel("mel")
272
  ax0.set_title(f"{title} — {course} | full-song mel spectrogram")
273
  ax0.set_xlim(0, dur)
274
  ax0.grid(axis="x", alpha=0.18)
275
- if plan:
276
- ax0.set_xticklabels([])
277
- ax1 = fig.add_subplot(gs[1], sharex=ax0)
278
- for a, b, d, f in plan:
279
- c = "#d64545" if f == 2 else ("#4a90d9" if f == 1 else "#999999")
280
- ax1.bar((a + b) / 2, max(d, 0.15), width=max((b - a) * 0.92, 0.01),
281
- color=c, alpha=0.85)
282
- ax1.set_xlim(0, dur)
283
- ax1.set_ylim(0, 8)
284
- ax1.set_yticks([0, 4, 8])
285
- ax1.set_ylabel("plan", fontsize=8)
286
- ax1.set_xlabel("time (s) — plan: grey=density blue=gap red=climax")
287
- ax1.grid(axis="x", alpha=0.18)
288
- else:
289
- ax0.set_xlabel("time (s)")
290
  fig.savefig(out_path, dpi=130, bbox_inches="tight")
291
  plt.close(fig)
292
  return out_path
@@ -354,7 +308,13 @@ async def homepage():
354
 
355
  @app.get("/health", include_in_schema=False)
356
  async def health():
357
- return {"status": "ok", "device": DEVICE, "models_loaded": "gen" in _MODELS}
 
 
 
 
 
 
358
 
359
 
360
  @app.api(
@@ -371,14 +331,11 @@ def generate_chart(
371
  course: str,
372
  level: int,
373
  bpm_override: float,
374
- auto_plan_on: bool,
375
  use_beat: bool,
376
- use_planner: bool,
377
  sampling: bool,
378
  temperature: float,
379
  top_p: float,
380
  drum_volume: float,
381
- model_choice: str = DEFAULT_MODEL,
382
  ) -> dict[str, object]:
383
  """Stream the actual inference stages to the custom frontend."""
384
  workdir = tempfile.mkdtemp(prefix="softchart-request-")
@@ -391,15 +348,11 @@ def generate_chart(
391
  top_p = float(top_p)
392
  drum_volume = float(drum_volume)
393
 
394
- if model_choice not in MODELS:
395
- raise ValueError(
396
- f"Unknown model {model_choice!r}. Choose one of: {', '.join(MODELS)}."
397
- )
398
  yield _progress(
399
  "loading", 0.03, "Preparing",
400
- f"Loading SoftChart {model_choice}.",
401
  )
402
- models = get_models(model_choice, include_planner=use_planner)
403
 
404
  yield _progress(
405
  "audio", 0.11, "Listening",
@@ -413,7 +366,7 @@ def generate_chart(
413
  )
414
  grid = dbs = None
415
  if use_beat:
416
- grid = fit_grid_piecewise(models["beat"], mel, device=DEVICE)
417
  if grid is not None:
418
  dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
419
  if not grid["ok"]:
@@ -421,7 +374,7 @@ def generate_chart(
421
  if bpm_override > 0:
422
  bpm = bpm_override
423
  if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
424
- fixed_grid = fit_grid_fixed_bpm(models["beat"], mel, bpm, device=DEVICE)
425
  grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
426
  if grid is not None:
427
  dbs = grid["downbeats"]
@@ -445,43 +398,25 @@ def generate_chart(
445
  bpm = float(round(bpm))
446
 
447
  yield _progress(
448
- "plan", 0.34, "Shaping the arc",
449
- "Planning density and climaxes.",
450
  )
451
- plan = None
452
- if getattr(models["gen"], "_has_plan", False):
453
- if use_planner:
454
- plan = learned_plan(models["plan"], mel, course, bpm, dbs)
455
- elif auto_plan_on:
456
- plan = auto_plan(mel, bpm, dbs)
457
 
458
  yield _progress(
459
  "generate", 0.47, "Writing the chart",
460
  "Writing playable Taiko patterns.",
461
  )
462
  title = os.path.splitext(wave_name)[0]
463
- slot_used = grid is not None
464
- if slot_used:
465
- generated = generate_song_slot(
466
- models["slot"], mel, grid, course, level=level,
467
- density_bucket=COURSE_DENS[course], greedy=not sampling, seed=0,
468
- temperature=temperature, top_p=top_p, device=DEVICE, plan=plan,
469
- )
470
- tja = write_tja_slots(
471
- generated, grid, title, course, level, wave_name, plan=plan,
472
- )
473
- else:
474
- generated = generate_song(
475
- models["gen"], mel, course, level=level,
476
- density_bucket=COURSE_DENS[course], greedy=not sampling,
477
- temperature=temperature, top_p=top_p, seed=0,
478
- device=DEVICE, plan=plan,
479
- )
480
- generated = snap_chart(generated, bpm)
481
- tja = write_tja(
482
- generated, bpm, title, course, level, wave_name, dbs,
483
- grid_fit=grid, plan=plan,
484
- )
485
 
486
  yield _progress(
487
  "export", 0.81, "Rendering",
@@ -491,9 +426,9 @@ def generate_chart(
491
  with open(tja_path, "w", encoding="utf-8") as output_file:
492
  output_file.write(tja)
493
  chart_image_path = os.path.join(workdir, "chart.png")
494
- plan_image_path = os.path.join(workdir, "song-plan.png")
495
  render_tja_image(tja, out_path=chart_image_path)
496
- render_audio_plan(mel, title, course, plan=plan, out_path=plan_image_path)
497
 
498
  yield _progress(
499
  "mix", 0.91, "Mixing",
@@ -514,7 +449,7 @@ def generate_chart(
514
  "bpm": round(float(bpm), 1),
515
  "notes": len(generated["hits"]),
516
  "spans": len(generated["spans"]),
517
- "timing": "slot-exact" if slot_used else "time-quantized",
518
  "grid_rms_ms": grid_rms,
519
  "preview_hits": int(mix_stats["rendered_hit_count"]),
520
  }
@@ -529,7 +464,9 @@ def generate_chart(
529
  "tja": _file_data(tja_path, mime_type="text/plain"),
530
  "audio": _file_data(preview_path, mime_type="audio/wav"),
531
  "chart_image": _file_data(chart_image_path, mime_type="image/png"),
532
- "plan_image": _file_data(plan_image_path, mime_type="image/png"),
 
 
533
  },
534
  }
535
  except Exception as exc:
 
1
+ """SoftChart V1.8 hierarchical Taiko chart generation for HF Spaces.
2
 
3
+ The single scratch-trained model owns whole-song hierarchy, local chart
4
+ generation, and beat/downbeat estimation. Arbitrary uploads always use the
5
+ model's time path; an estimated beat grid may quantize the exported TJA but is
6
+ never promoted to the trusted-meter slot path.
 
7
  """
8
 
9
  import logging
10
  import os
11
  import re
12
  import shutil
13
+ import subprocess
14
  import tempfile
15
  from pathlib import Path
16
 
 
20
  from fastapi.responses import FileResponse
21
  from fastapi.staticfiles import StaticFiles
22
 
23
+ from softchart.generate import generate_song, load_hf
24
  from softchart.fonts import cjk_font_path
25
  from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
 
26
  from softchart.preview_audio import synthesize_taiko_preview
27
  from softchart.rhythm import snap_chart
 
28
  from softchart.tja_image import render_tja_image
29
  from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
30
 
31
  LOGGER = logging.getLogger("softchart.space")
32
  STATIC_DIR = Path(__file__).with_name("static")
33
 
34
+ MODEL_REPO = "JacobLinCool/softchart-v18"
35
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
36
+ EXPECTED_PARAMETERS = 8_985_091
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
39
  CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
40
  "roll": "5", "roll_big": "6", "balloon": "7"}
41
  SUB = 96
42
 
43
+ _MODEL = {}
44
+ _MEL_FB = None
45
+ _STFT_WINDOW = None
46
+
47
+
48
+ def get_model():
49
+ if "generator" not in _MODEL:
50
+ model = load_hf(MODEL_REPO, device=DEVICE)
51
+ capabilities = getattr(model, "_softchart_capabilities", {})
52
+ required = {
53
+ "aux": True,
54
+ "beat_head": True,
55
+ "dual": True,
56
+ "hierarchical_ctx": True,
57
+ "global_ctx": False,
58
+ "plan": False,
59
+ }
60
+ mismatched = {
61
+ name: capabilities.get(name)
62
+ for name, expected in required.items()
63
+ if capabilities.get(name) is not expected
64
+ }
65
+ if (not getattr(model, "_dual", False) or model.beat is None
66
+ or not getattr(model, "_hierarchical_ctx", False)
67
+ or getattr(model, "_has_plan", False) or mismatched):
68
+ raise RuntimeError(
69
+ f"{MODEL_REPO} is not the expected hierarchical V1.8 artifact"
70
+ )
71
+ parameters = sum(parameter.numel() for parameter in model.parameters())
72
+ if parameters != EXPECTED_PARAMETERS:
73
  raise RuntimeError(
74
+ f"{MODEL_REPO} has {parameters:,} parameters; expected "
75
+ f"{EXPECTED_PARAMETERS:,}"
76
  )
77
+ _MODEL["generator"] = model
78
+ return _MODEL["generator"]
 
 
 
 
 
79
 
80
 
81
  def load_logmel(path):
82
+ """Apply the exact V1.8 FFmpeg + torch.stft preprocessing contract."""
83
  import librosa
84
+ global _MEL_FB, _STFT_WINDOW
85
+
86
+ command = [
87
+ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
88
+ "-i", os.fspath(path), "-vn", "-ac", "2", "-ar", str(SR),
89
+ "-f", "f32le", "-acodec", "pcm_f32le", "pipe:1",
90
+ ]
91
+ proc = subprocess.run(
92
+ command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
93
+ )
94
+ if proc.returncode != 0:
95
+ detail = proc.stderr.decode("utf-8", errors="replace").strip()
96
+ raise ValueError(f"FFmpeg could not decode this upload: {detail}")
97
+ decoded = np.frombuffer(proc.stdout, dtype="<f4")
98
+ if decoded.size % 2:
99
+ raise ValueError("Decoded stereo audio contains an incomplete frame.")
100
+ wav = decoded.reshape(-1, 2).mean(axis=1, dtype=np.float32)
101
+ if wav.size < SR:
102
+ raise ValueError("Audio must be at least one second long.")
103
+ if not np.isfinite(wav).all():
104
+ raise ValueError("Decoded audio contains non-finite samples.")
105
+ if _MEL_FB is None:
106
+ fb = librosa.filters.mel(
107
+ sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2,
108
+ )
109
+ _MEL_FB = torch.from_numpy(fb)
110
+ _STFT_WINDOW = torch.hann_window(N_FFT)
111
+ wav_tensor = torch.from_numpy(np.ascontiguousarray(wav))[None]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  with torch.no_grad():
113
+ spec = torch.stft(
114
+ wav_tensor, N_FFT, hop_length=HOP, window=_STFT_WINDOW,
115
+ center=True, return_complex=True,
116
+ )[0]
117
+ mel = torch.log(_MEL_FB @ spec.abs().pow(2) + 1e-5)
118
+ mel = mel.numpy().astype(np.float32)
119
+ return mel, wav
120
 
121
 
122
  def group_quantize(times, phase, grid, min_run=3):
 
156
  return os.path.join(directory, f"{stem}_{course}.tja")
157
 
158
 
159
+ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=None):
 
160
  hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
161
  beat = 60.0 / bpm
162
  grid = beat / (SUB / 4)
 
208
  slots = {k - shift: v for k, v in slots.items()}
209
  phase += shift * grid
210
  n_meas = (max(slots) // SUB + 1) if slots else 1
211
+ lines = [
212
+ "".join(slots.get(m * SUB + k, "0") for k in range(SUB)) + ","
213
+ for m in range(n_meas)
214
+ ]
 
 
 
 
 
 
 
215
  balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
216
  return "\n".join([
217
  f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", f"WAVE:{wave}",
 
220
  "", "#START", *lines, "#END"]) + "\n"
221
 
222
 
223
+ def render_song_structure(mel, title, course, out_path):
224
  import matplotlib
225
  matplotlib.use("Agg")
226
  from matplotlib import font_manager
 
233
  matplotlib.rcParams["axes.unicode_minus"] = False
234
 
235
  dur = mel.shape[1] / FPS
236
+ fig, ax0 = plt.subplots(figsize=(13, 3.2))
 
 
 
 
237
  ax0.imshow(mel, aspect="auto", origin="lower",
238
  cmap="magma", extent=[0, dur, 0, N_MELS])
239
  ax0.set_ylabel("mel")
240
  ax0.set_title(f"{title} — {course} | full-song mel spectrogram")
241
  ax0.set_xlim(0, dur)
242
  ax0.grid(axis="x", alpha=0.18)
243
+ ax0.set_xlabel("time (s)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  fig.savefig(out_path, dpi=130, bbox_inches="tight")
245
  plt.close(fig)
246
  return out_path
 
308
 
309
  @app.get("/health", include_in_schema=False)
310
  async def health():
311
+ return {
312
+ "status": "ok",
313
+ "version": "1.8",
314
+ "model": MODEL_REPO,
315
+ "device": DEVICE,
316
+ "model_loaded": "generator" in _MODEL,
317
+ }
318
 
319
 
320
  @app.api(
 
331
  course: str,
332
  level: int,
333
  bpm_override: float,
 
334
  use_beat: bool,
 
335
  sampling: bool,
336
  temperature: float,
337
  top_p: float,
338
  drum_volume: float,
 
339
  ) -> dict[str, object]:
340
  """Stream the actual inference stages to the custom frontend."""
341
  workdir = tempfile.mkdtemp(prefix="softchart-request-")
 
348
  top_p = float(top_p)
349
  drum_volume = float(drum_volume)
350
 
 
 
 
 
351
  yield _progress(
352
  "loading", 0.03, "Preparing",
353
+ "Loading SoftChart V1.8 hierarchical.",
354
  )
355
+ model = get_model()
356
 
357
  yield _progress(
358
  "audio", 0.11, "Listening",
 
366
  )
367
  grid = dbs = None
368
  if use_beat:
369
+ grid = fit_grid_piecewise(model, mel, device=DEVICE)
370
  if grid is not None:
371
  dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
372
  if not grid["ok"]:
 
374
  if bpm_override > 0:
375
  bpm = bpm_override
376
  if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
377
+ fixed_grid = fit_grid_fixed_bpm(model, mel, bpm, device=DEVICE)
378
  grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
379
  if grid is not None:
380
  dbs = grid["downbeats"]
 
398
  bpm = float(round(bpm))
399
 
400
  yield _progress(
401
+ "structure", 0.34, "Reading the structure",
402
+ "Building whole-song hierarchical context.",
403
  )
 
 
 
 
 
 
404
 
405
  yield _progress(
406
  "generate", 0.47, "Writing the chart",
407
  "Writing playable Taiko patterns.",
408
  )
409
  title = os.path.splitext(wave_name)[0]
410
+ generated = generate_song(
411
+ model, mel, course, level=level,
412
+ density_bucket=COURSE_DENS[course], greedy=not sampling,
413
+ temperature=temperature, top_p=top_p, seed=0, device=DEVICE,
414
+ )
415
+ generated = snap_chart(generated, bpm)
416
+ tja = write_tja(
417
+ generated, bpm, title, course, level, wave_name, dbs,
418
+ grid_fit=grid,
419
+ )
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
  yield _progress(
422
  "export", 0.81, "Rendering",
 
426
  with open(tja_path, "w", encoding="utf-8") as output_file:
427
  output_file.write(tja)
428
  chart_image_path = os.path.join(workdir, "chart.png")
429
+ structure_image_path = os.path.join(workdir, "song-structure.png")
430
  render_tja_image(tja, out_path=chart_image_path)
431
+ render_song_structure(mel, title, course, out_path=structure_image_path)
432
 
433
  yield _progress(
434
  "mix", 0.91, "Mixing",
 
449
  "bpm": round(float(bpm), 1),
450
  "notes": len(generated["hits"]),
451
  "spans": len(generated["spans"]),
452
+ "timing": "hierarchical time + grid quantization",
453
  "grid_rms_ms": grid_rms,
454
  "preview_hits": int(mix_stats["rendered_hit_count"]),
455
  }
 
464
  "tja": _file_data(tja_path, mime_type="text/plain"),
465
  "audio": _file_data(preview_path, mime_type="audio/wav"),
466
  "chart_image": _file_data(chart_image_path, mime_type="image/png"),
467
+ "structure_image": _file_data(
468
+ structure_image_path, mime_type="image/png"
469
+ ),
470
  },
471
  }
472
  except Exception as exc:
packages.txt CHANGED
@@ -1 +1,2 @@
 
1
  fonts-noto-cjk
 
1
+ ffmpeg
2
  fonts-noto-cjk
softchart/baseline.py DELETED
@@ -1,43 +0,0 @@
1
- """Onset-detection baseline: spectral-flux peak picking on the cached log-mel
2
- + band-energy don/ka heuristic.
3
-
4
- This is the "chart generation is just onset detection" strawman the model must beat.
5
- Operates on the same cached mel features as the model, so no raw audio is needed.
6
- """
7
-
8
- import numpy as np
9
- from scipy.signal import find_peaks
10
-
11
- from .vocab import FPS
12
-
13
-
14
- def baseline_chart(mel, target_nps):
15
- """mel: (n_mels, T) log-mel (natural log). Returns list of (time_sec, class)."""
16
- m = mel.astype(np.float32)
17
- # spectral flux onset envelope
18
- flux = np.maximum(0.0, np.diff(m, axis=1)).sum(axis=0)
19
- flux = np.concatenate([[0.0], flux])
20
- # smooth lightly
21
- k = np.hanning(5)
22
- k /= k.sum()
23
- env = np.convolve(flux, k, mode="same")
24
-
25
- dur = m.shape[1] / FPS
26
- want = max(1, int(round(target_nps * dur)))
27
- peaks, props = find_peaks(env, distance=int(0.09 * FPS), height=0.0)
28
- if len(peaks) == 0:
29
- return []
30
- if len(peaks) > want:
31
- strongest = np.argsort(props["peak_heights"])[-want:]
32
- peaks = np.sort(peaks[strongest])
33
-
34
- # don/ka: low vs high mel-band energy at the onset (linear power domain)
35
- p = np.exp(m)
36
- low = p[:40].sum(axis=0) # ~ <500 Hz
37
- high = p[64:].sum(axis=0) # upper bands
38
- events = []
39
- for fr in peaks:
40
- fr2 = min(fr + 1, m.shape[1] - 1)
41
- ratio = high[fr2] / (low[fr2] + 1e-9)
42
- events.append((float(fr / FPS), "ka" if ratio > 0.35 else "don"))
43
- return events
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/checkpoint.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strict generator-checkpoint metadata and architecture reconstruction.
2
+
3
+ V1.8 condition semantics cannot be recovered from tensor shapes. A missing
4
+ ``dual`` or ``ctx`` flag changes the decoder prefix while all weights still
5
+ load successfully, so release loading must reject incomplete or contradictory
6
+ metadata instead of guessing from state-dict keys.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections.abc import Mapping
13
+ from numbers import Integral, Real
14
+
15
+ import torch
16
+
17
+ from .vocab import N_MELS, VOCAB
18
+
19
+
20
+ REQUIRED_GENERATOR_ARGS = frozenset({
21
+ "d_model", "nhead", "dropout", "enc_layers", "dec_layers", "ffn",
22
+ "emb_factor", "func_time", "aux", "global_ctx", "hierarchical_ctx",
23
+ "section_layers", "section_ffn", "clean_phase", "enc_share",
24
+ "dec_share", "adapter_rank", "unique_layernorm", "adapter_rank_ffn",
25
+ "depth_emb", "unshare_last_dec", "align", "beat_head", "beat_hires",
26
+ "ctx", "sibling", "style", "sync_token", "plan", "mask_infill",
27
+ "complexity", "slot", "dual",
28
+ })
29
+
30
+ _BOOL_ARGS = frozenset({
31
+ "func_time", "aux", "global_ctx", "hierarchical_ctx", "clean_phase",
32
+ "unique_layernorm", "depth_emb", "unshare_last_dec", "align",
33
+ "beat_head", "beat_hires", "ctx", "sibling", "style", "sync_token",
34
+ "plan", "complexity", "slot", "dual",
35
+ })
36
+
37
+ GENERATOR_CAPABILITY_KEYS = (
38
+ "ctx", "sibling", "style", "sync_token", "plan", "beat_head", "align",
39
+ "aux", "func_time", "mask_infill", "complexity", "slot", "dual",
40
+ "global_ctx", "hierarchical_ctx",
41
+ )
42
+
43
+
44
+ def _state_has(state: Mapping[str, torch.Tensor], prefix: str) -> bool:
45
+ return any(key.startswith(prefix) for key in state)
46
+
47
+
48
+ def _require_presence(
49
+ state: Mapping[str, torch.Tensor], prefix: str, expected: bool, label: str,
50
+ ) -> None:
51
+ present = _state_has(state, prefix)
52
+ if present != expected:
53
+ raise ValueError(
54
+ f"checkpoint args/state disagree for {label}: "
55
+ f"metadata={expected}, state={present}"
56
+ )
57
+
58
+
59
+ def _positive_int(args: Mapping[str, object], name: str) -> int:
60
+ value = args[name]
61
+ if not isinstance(value, Integral) or isinstance(value, bool) or int(value) <= 0:
62
+ raise ValueError(f"checkpoint arg {name} must be a positive integer")
63
+ return int(value)
64
+
65
+
66
+ def _nonnegative_int(args: Mapping[str, object], name: str) -> int:
67
+ value = args[name]
68
+ if not isinstance(value, Integral) or isinstance(value, bool) or int(value) < 0:
69
+ raise ValueError(f"checkpoint arg {name} must be a non-negative integer")
70
+ return int(value)
71
+
72
+
73
+ def parse_generator_checkpoint(checkpoint: Mapping[str, object]) -> tuple[
74
+ Mapping[str, torch.Tensor], dict, dict, dict
75
+ ]:
76
+ """Validate and return ``state, args, model_config, capabilities``.
77
+
78
+ The returned configuration is suitable for ``ChartModel``. Tensor shapes
79
+ are still checked a second time by its strict ``load_state_dict`` call.
80
+ """
81
+ if not isinstance(checkpoint, Mapping):
82
+ raise ValueError("generator checkpoint must be a mapping")
83
+ state = checkpoint.get("model")
84
+ args = checkpoint.get("args")
85
+ if not isinstance(state, Mapping) or not state:
86
+ raise ValueError("generator checkpoint model must be a non-empty mapping")
87
+ if not isinstance(args, Mapping):
88
+ raise ValueError("generator checkpoint args must be a mapping")
89
+ if not all(isinstance(key, str) and isinstance(value, torch.Tensor)
90
+ for key, value in state.items()):
91
+ raise ValueError("generator state must map string keys to tensors")
92
+ args = dict(args)
93
+ missing = sorted(REQUIRED_GENERATOR_ARGS - set(args))
94
+ if missing:
95
+ raise ValueError(f"generator checkpoint lacks required args: {missing}")
96
+
97
+ for name in _BOOL_ARGS:
98
+ if not isinstance(args[name], bool):
99
+ raise ValueError(f"checkpoint arg {name} must be boolean")
100
+ for name in ("d_model", "nhead", "enc_layers", "dec_layers", "ffn",
101
+ "section_layers"):
102
+ _positive_int(args, name)
103
+ for name in ("emb_factor", "section_ffn", "adapter_rank",
104
+ "adapter_rank_ffn"):
105
+ _nonnegative_int(args, name)
106
+ dropout = args["dropout"]
107
+ if (not isinstance(dropout, Real) or isinstance(dropout, bool)
108
+ or not math.isfinite(float(dropout)) or not 0.0 <= float(dropout) < 1.0):
109
+ raise ValueError("checkpoint arg dropout must be finite in [0, 1)")
110
+ if args["enc_share"] is not None and not isinstance(args["enc_share"], (str, int)):
111
+ raise ValueError("checkpoint arg enc_share must be null, integer, or string")
112
+ if args["dec_share"] is not None and not isinstance(args["dec_share"], (str, int)):
113
+ raise ValueError("checkpoint arg dec_share must be null, integer, or string")
114
+ if args["global_ctx"] and args["hierarchical_ctx"]:
115
+ raise ValueError("global_ctx and hierarchical_ctx are mutually exclusive")
116
+ if args["hierarchical_ctx"] and not args["aux"]:
117
+ raise ValueError("hierarchical_ctx requires aux skeleton supervision")
118
+ if args["beat_hires"] and not args["beat_head"]:
119
+ raise ValueError("beat_hires requires beat_head")
120
+ if (args["slot"] or args["dual"]) and not args["clean_phase"]:
121
+ raise ValueError("V1.8 slot/dual checkpoints require clean_phase")
122
+ if args["complexity"]:
123
+ raise ValueError(
124
+ "complexity-conditioned checkpoints are not supported by the "
125
+ "release generation API"
126
+ )
127
+
128
+ required_tensors = ("tok_emb.weight", "frontend.0.weight")
129
+ absent = [name for name in required_tensors if name not in state]
130
+ if absent:
131
+ raise ValueError(f"generator state lacks required tensors: {absent}")
132
+ token_weight = state["tok_emb.weight"]
133
+ front_weight = state["frontend.0.weight"]
134
+ if token_weight.ndim != 2 or front_weight.ndim != 3:
135
+ raise ValueError("generator embedding/frontend tensors have invalid rank")
136
+
137
+ d_model = int(args["d_model"])
138
+ nhead = int(args["nhead"])
139
+ if d_model % nhead:
140
+ raise ValueError("checkpoint d_model must be divisible by nhead")
141
+ vocab_size, embedding_width = map(int, token_weight.shape)
142
+ if vocab_size != VOCAB.size:
143
+ raise ValueError(
144
+ f"checkpoint vocab has {vocab_size} rows; current exact vocabulary "
145
+ f"requires {VOCAB.size}"
146
+ )
147
+ emb_factor = int(args["emb_factor"])
148
+ expected_width = emb_factor or d_model
149
+ if embedding_width != expected_width:
150
+ raise ValueError(
151
+ "checkpoint args/state disagree for emb_factor: "
152
+ f"expected width {expected_width}, state has {embedding_width}"
153
+ )
154
+ if int(front_weight.shape[0]) != d_model:
155
+ raise ValueError("frontend output channels disagree with d_model")
156
+ expected_front_channels = (
157
+ N_MELS if args["clean_phase"]
158
+ else (N_MELS + 2 if (args["slot"] or args["dual"]) else N_MELS)
159
+ )
160
+ if int(front_weight.shape[1]) != expected_front_channels:
161
+ raise ValueError(
162
+ "frontend input channels disagree with clean slot/time metadata: "
163
+ f"expected {expected_front_channels}, state has {front_weight.shape[1]}"
164
+ )
165
+
166
+ _require_presence(state, "emb_proj.", emb_factor > 0, "emb_factor")
167
+ _require_presence(
168
+ state, "section_encoder.", args["hierarchical_ctx"], "hierarchical_ctx")
169
+ _require_presence(
170
+ state, "skeleton_proj.", args["hierarchical_ctx"], "hierarchical skeleton")
171
+ _require_presence(state, "gsum_proj.", args["global_ctx"], "global_ctx")
172
+ _require_presence(state, "aux.", args["aux"], "aux")
173
+ _require_presence(state, "phase_proj.", args["clean_phase"], "clean_phase")
174
+ _require_presence(state, "time_proj.", args["func_time"], "func_time")
175
+ _require_presence(state, "ptr.", args["align"], "align")
176
+ _require_presence(state, "beat.", args["beat_head"], "beat_head")
177
+ _require_presence(
178
+ state, "beat.up.", args["beat_head"] and args["beat_hires"], "beat_hires")
179
+
180
+ beat: bool | str = (
181
+ "hires" if args["beat_head"] and args["beat_hires"]
182
+ else bool(args["beat_head"])
183
+ )
184
+ model_config = {
185
+ "d_model": d_model,
186
+ "nhead": nhead,
187
+ "dropout": float(dropout),
188
+ "enc_layers": int(args["enc_layers"]),
189
+ "dec_layers": int(args["dec_layers"]),
190
+ "ffn": int(args["ffn"]),
191
+ "vocab_size": vocab_size,
192
+ "aux": bool(args["aux"]),
193
+ "global_ctx": bool(args["global_ctx"]),
194
+ "hierarchical_ctx": bool(args["hierarchical_ctx"]),
195
+ "section_layers": int(args["section_layers"]),
196
+ "section_ffn": int(args["section_ffn"]) or None,
197
+ "func_time": bool(args["func_time"]),
198
+ "ptr": bool(args["align"]),
199
+ "beat": beat,
200
+ "in_ch": N_MELS + 2 if (args["slot"] or args["dual"]) else N_MELS,
201
+ "emb_factor": emb_factor or None,
202
+ "clean_phase": bool(args["clean_phase"]),
203
+ "enc_share": args["enc_share"],
204
+ "dec_share": args["dec_share"],
205
+ "adapter_rank": int(args["adapter_rank"]),
206
+ "unique_layernorm": bool(args["unique_layernorm"]),
207
+ "adapter_rank_ffn": int(args["adapter_rank_ffn"]),
208
+ "depth_emb": bool(args["depth_emb"]),
209
+ "unshare_last_dec": bool(args["unshare_last_dec"]),
210
+ }
211
+ capabilities = {
212
+ name: (bool(args[name]) if name != "hierarchical_ctx"
213
+ else bool(args["hierarchical_ctx"]))
214
+ for name in GENERATOR_CAPABILITY_KEYS
215
+ }
216
+ return state, args, model_config, capabilities
217
+
218
+
219
+ def attach_generator_capabilities(model, capabilities: Mapping[str, bool]) -> None:
220
+ """Attach the exact semantic flags consumed by generation."""
221
+ missing = sorted(set(GENERATOR_CAPABILITY_KEYS) - set(capabilities))
222
+ if missing:
223
+ raise ValueError(f"generator capabilities are incomplete: {missing}")
224
+ invalid = sorted(
225
+ name for name in GENERATOR_CAPABILITY_KEYS
226
+ if not isinstance(capabilities[name], bool)
227
+ )
228
+ if invalid:
229
+ raise ValueError(f"generator capabilities must be boolean: {invalid}")
230
+ model._has_ctx = bool(capabilities["ctx"])
231
+ model._has_sib = bool(capabilities["sibling"])
232
+ model._has_style = bool(capabilities["style"])
233
+ model._has_sync = bool(capabilities["sync_token"])
234
+ model._has_plan = bool(capabilities["plan"])
235
+ model._dual = bool(capabilities["dual"])
236
+ model._slot = bool(capabilities["slot"]) or model._dual
237
+ model._hierarchical_ctx = bool(capabilities["hierarchical_ctx"])
238
+ model._has_gctx = bool(capabilities["global_ctx"] or model._hierarchical_ctx)
softchart/data.py DELETED
@@ -1,590 +0,0 @@
1
- """PyTorch dataset: (mel window, token sequence) pairs from the preprocessed cache."""
2
-
3
- import json
4
- import os
5
- import random
6
-
7
- import numpy as np
8
- import torch
9
- from torch.utils.data import Dataset
10
-
11
- from .vocab import (FPS, MAX_TGT, MEAS_MAX, NOTE_CLASSES, SLOTS, VOCAB, WINDOW,
12
- encode_window)
13
-
14
- G_CHUNK = 172 # ~2s per whole-song summary chunk
15
- G_LEN = 100 # fixed number of summary chunks (covers ~200s songs)
16
-
17
-
18
- def song_summary(mel):
19
- """(n_mels, T) log-mel -> (G_LEN, n_mels) coarse whole-song summary."""
20
- n_mels, T = mel.shape
21
- n = min(G_LEN, max(1, T // G_CHUNK))
22
- out = np.full((G_LEN, n_mels), np.log(1e-5), dtype=np.float32)
23
- for k in range(n):
24
- out[k] = mel[:, k * G_CHUNK : (k + 1) * G_CHUNK].astype(np.float32).mean(axis=1)
25
- return out
26
-
27
-
28
- class ChartWindowDataset(Dataset):
29
- """One item = one (song, course) chart; a random window is cropped per access."""
30
-
31
- def __init__(
32
- self,
33
- cache_dir,
34
- song_ids,
35
- train=True,
36
- cond_drop=0.15,
37
- spec_augment=True,
38
- windows_per_chart=8,
39
- use_ctx=False,
40
- aux=False,
41
- global_ctx=False,
42
- importance_sampling=False,
43
- tempo_aug=False,
44
- sibling=False,
45
- style=False,
46
- beat_head=False,
47
- sync_token=False,
48
- plan=False,
49
- mask_infill=0.0,
50
- complexity=False,
51
- slot=False,
52
- dual=False,
53
- dual_slot_p=0.65,
54
- beat_hires=False,
55
- ):
56
- self.cache = cache_dir
57
- self.train = train
58
- self.slot = slot
59
- self.dual = dual # mixed slot/time batches with an explicit mode token
60
- self.dual_slot_p = dual_slot_p
61
- self._meas_cache = {}
62
- self.cond_drop = cond_drop if train else 0.0
63
- self.spec_augment = spec_augment and train
64
- self.use_ctx = use_ctx
65
- self.aux = aux
66
- self.global_ctx = global_ctx
67
- self.importance = importance_sampling and train
68
- self.tempo_aug = tempo_aug and train
69
- self.sibling = sibling
70
- self.beat_head = beat_head
71
- self.beat_hires = beat_hires
72
- self.sync_token = sync_token
73
- self.mask_infill = mask_infill if train else 0.0
74
- self.complexity = complexity
75
- self.plans = None
76
- if plan:
77
- with open(os.path.join(cache_dir, "plans.json")) as f:
78
- self.plans = json.load(f)
79
- self.styles = None
80
- if style:
81
- with open(os.path.join(cache_dir, "styles.json")) as f:
82
- self.styles = json.load(f)["styles"]
83
- with open(os.path.join(cache_dir, "index.json")) as f:
84
- index = {e["id"]: e for e in json.load(f)}
85
- self.items = [] # (sid, course, level, n_frames)
86
- for sid in song_ids:
87
- e = index.get(sid)
88
- if e is None:
89
- continue
90
- for c, info in e["courses"].items():
91
- self.items.append((sid, c, info["level"], e["n_frames"]))
92
- if self.slot and not self.dual: # pure slot mode needs a grid per song
93
- keep = []
94
- for it in self.items:
95
- src = it[0].split("x")[0] if "x" in it[0] else it[0]
96
- if os.path.exists(os.path.join(cache_dir, f"{src}.beats.npz")):
97
- keep.append(it)
98
- self.items = keep
99
- # virtual epoch length: several windows per chart
100
- self.mult = windows_per_chart if train else 1
101
- self._rng = random.Random(int(os.environ.get('SC_DATA_SEED', 1234)))
102
-
103
- def __len__(self):
104
- return len(self.items) * self.mult
105
-
106
- def _load(self, sid):
107
- mel = np.load(os.path.join(self.cache, f"{sid}.mel.npy"), mmap_mode="r")
108
- notes = np.load(os.path.join(self.cache, f"{sid}.notes.npz"))
109
- return mel, notes
110
-
111
- def _beats(self, sid):
112
- try:
113
- z = np.load(os.path.join(self.cache, f"{sid}.beats.npz"))
114
- return z["beats"], z["downbeats"]
115
- except Exception:
116
- return None, None
117
-
118
- def _measures(self, sid):
119
- """Slot mode: (downbeat_times, beats_per_measure, ok_mask) — measure m
120
- spans [db[m], db[m+1]). ok = sane meter (2..6 beats) and duration.
121
- 4/4-only models pass meter_filter=4 at window time."""
122
- if sid in self._meas_cache:
123
- return self._meas_cache[sid]
124
- bts, dbs = self._beats(sid)
125
- out = None
126
- if dbs is not None and len(dbs) >= 3:
127
- db = np.asarray(dbs, np.float64)
128
- durs = np.diff(db)
129
- if bts is not None and len(bts):
130
- nb = np.searchsorted(bts, db[1:] - 1e-4) - np.searchsorted(bts, db[:-1] - 1e-4)
131
- else:
132
- nb = np.full(len(durs), 4)
133
- ok = (nb >= 2) & (nb <= 6) & (durs > 0.5) & (durs < 8.0)
134
- if ok.any():
135
- out = (db, nb.astype(np.int64), ok)
136
- self._meas_cache[sid] = out
137
- return out
138
-
139
- def _getitem_slot(self, i):
140
- """Slot-token window: measure-aligned crop; note positions are exact
141
- TJA lattice indices instead of audio frames. Lattice (v3) is
142
- SLOTS_PER_BEAT per beat, so any meter is exact (4/4 -> 96/measure,
143
- 3/4 -> 72). Slot labels are tempo-INVARIANT, so tempo augmentation
144
- only touches the mel. Two phase channels (measure/beat sawtooth) are
145
- appended so the decoder reads the grid instead of inferring it."""
146
- from .vocab import BEATS_MAX, SLOTS_PER_BEAT
147
-
148
- sid, course, level, n_frames = self.items[i % len(self.items)]
149
- src_sid = sid.split("x")[0] if "x" in sid else sid
150
- tab = self._measures(src_sid)
151
- if tab is None: # unusable grid: deterministic redirect to another item
152
- return self._getitem_slot((i + 9973) % len(self.items))
153
- db, nbm, okm = tab
154
- mel, notes = self._load(sid)
155
- times = notes[f"{course}_t"]
156
- classes = notes[f"{course}_c"]
157
-
158
- rng0 = random.Random(i * 31337 + (self._rng.randint(0, 1 << 30) if self.train else 0))
159
- rng = random.Random(i * 7919 + (self._rng.randint(0, 1 << 30) if self.train else 0))
160
- r = 1.0
161
- if self.tempo_aug and rng0.random() < 0.5:
162
- r = rng0.uniform(0.9, 1.111)
163
-
164
- # start measure: importance-weighted among valid measures
165
- cand = np.nonzero(okm)[0]
166
- cand = cand[db[cand] * FPS < max(n_frames - 1, 1)]
167
- if len(cand) == 0:
168
- return self._getitem_slot((i + 9973) % len(self.items))
169
- if self.train:
170
- if self.importance:
171
- lo = np.searchsorted(times, db[cand])
172
- hi = np.searchsorted(times, db[cand] + WINDOW / FPS * r)
173
- w = (hi - lo).astype(np.float64) + 3.0
174
- j = int(rng.choices(cand.tolist(), weights=w.tolist())[0])
175
- else:
176
- j = int(rng.choice(cand.tolist()))
177
- else:
178
- j = int(cand[(i * 2654435761) % len(cand)])
179
-
180
- # extend K whole measures while valid, inside audio, and within both
181
- # the frame window and the 72-beat token budget
182
- K = 0
183
- nbeats = 0
184
- while (j + K < len(okm) and okm[j + K]
185
- and nbeats + nbm[j + K] <= BEATS_MAX
186
- and (db[j + K + 1] - db[j]) * FPS / r <= WINDOW
187
- and db[j + K + 1] * FPS <= n_frames + FPS):
188
- nbeats += nbm[j + K]
189
- K += 1
190
- if K == 0:
191
- return self._getitem_slot((i + 9973) % len(self.items))
192
- t0, t_end = db[j], db[j + K]
193
- boff = np.concatenate([[0], np.cumsum(nbm[j : j + K])]) # beat offsets
194
-
195
- def to_slot(t, m):
196
- a, b = db[j + m], db[j + m + 1]
197
- s = int(np.floor(SLOTS_PER_BEAT * nbm[j + m] * (t - a) / (b - a) + 0.5))
198
- return min(boff[m] * SLOTS_PER_BEAT + s, WINDOW - 1)
199
-
200
- # notes -> exact lattice indices; keep frame positions for the aux head
201
- pairs, aux_frames = [], []
202
- seen = set()
203
- for m in range(K):
204
- a, b = db[j + m], db[j + m + 1]
205
- sel = (times >= a - 1e-4) & (times < b - 1e-4)
206
- for t, c in zip(times[sel], classes[sel]):
207
- g = to_slot(t, m)
208
- if (g, int(c)) not in seen:
209
- seen.add((g, int(c)))
210
- pairs.append((g, int(c)))
211
- aux_frames.append((t - t0) / r * FPS)
212
- pairs_sorted = sorted(range(len(pairs)), key=lambda k: pairs[k][0])
213
- pairs = [pairs[k] for k in pairs_sorted]
214
- aux_frames = [aux_frames[k] for k in pairs_sorted]
215
-
216
- sib = None
217
- if self.sibling:
218
- order = ["easy", "normal", "hard", "oni", "ura"]
219
- sib_pairs = []
220
- if not (self.train and rng0.random() < 0.2):
221
- for c2 in reversed(order[: order.index(course)]):
222
- if f"{c2}_t" in notes:
223
- t2, c2c = notes[f"{c2}_t"], notes[f"{c2}_c"]
224
- ev = []
225
- for m in range(K):
226
- a, b = db[j + m], db[j + m + 1]
227
- s2 = (t2 >= a - 1e-4) & (t2 < b - 1e-4) & (c2c <= 3)
228
- for t, c in zip(t2[s2], c2c[s2]):
229
- ev.append((to_slot(t, m), int(c)))
230
- if len(ev) > 12:
231
- idx2 = np.linspace(0, len(ev) - 1, 12).astype(int)
232
- ev = [ev[k] for k in idx2]
233
- sib_pairs = ev
234
- break
235
- sib = sib_pairs
236
-
237
- style = None
238
- if self.styles is not None:
239
- style = self.styles.get(f"{sid}:{course}", -1)
240
-
241
- plan_slice = None
242
- if self.plans is not None:
243
- blocks = self.plans.get(f"{sid}:{course}") or []
244
- plan_slice = [(b[2], b[3]) for b in blocks if b[1] > t0 and b[0] < t_end]
245
-
246
- seq, prefix_len = encode_window(
247
- VOCAB, course, level, pairs, cond_drop=self.cond_drop, rng=rng,
248
- sib_pairs=sib, style=style, plan_slice=plan_slice,
249
- mode=("slot" if self.dual else None),
250
- )
251
- if len(seq) > MAX_TGT:
252
- seq = seq[: MAX_TGT - 1] + [VOCAB.eos]
253
- if VOCAB.is_time(seq[-2]):
254
- seq = seq[:-2] + [VOCAB.eos]
255
-
256
- # mel crop [t0, t_end) -> stretch by r -> pad to WINDOW
257
- f0 = int(round(t0 * FPS))
258
- src_len = max(1, int(round((t_end - t0) * FPS)))
259
- x = mel[:, f0 : f0 + src_len].astype(np.float32)
260
- if x.shape[1] < src_len:
261
- x = np.pad(x, ((0, 0), (0, src_len - x.shape[1])), constant_values=np.log(1e-5))
262
- x = torch.from_numpy(x)
263
- tgt_len = min(WINDOW, max(1, int(round(src_len / r))))
264
- if tgt_len != src_len:
265
- x = torch.nn.functional.interpolate(
266
- x[None], size=tgt_len, mode="linear", align_corners=False)[0]
267
- if x.shape[1] < WINDOW:
268
- x = torch.nn.functional.pad(x, (0, WINDOW - x.shape[1]),
269
- value=float(np.log(1e-5)))
270
- if self.tempo_aug and rng0.random() < 0.3:
271
- k = rng0.randint(-4, 4)
272
- if k:
273
- x = torch.roll(x, k, dims=0)
274
- if k > 0:
275
- x[:k] = x.min()
276
- else:
277
- x[k:] = x.min()
278
- if self.spec_augment:
279
- for _ in range(2):
280
- w = rng.randint(0, 12)
281
- fq = rng.randint(0, x.shape[0] - w) if w else 0
282
- if w:
283
- x[fq : fq + w] = x.mean()
284
- for _ in range(2):
285
- w = rng.randint(0, 24)
286
- fq = rng.randint(0, x.shape[1] - w) if w else 0
287
- if w:
288
- x[:, fq : fq + w] = x.mean()
289
- x = x + (rng.random() * 1.38 - 0.69)
290
- # phase channels AFTER augmentation (the grid is clean metadata):
291
- # ch0 = measure phase 0..1, ch1 = beat phase 0..1; -1 marks padding
292
- ph = torch.full((2, WINDOW), -1.0)
293
- edges = (db[j : j + K + 1] - t0) / r * FPS
294
- for m in range(K):
295
- a, b = edges[m], edges[m + 1]
296
- i0, i1 = int(np.ceil(a - 1e-6)), min(int(np.ceil(b - 1e-6)), WINDOW)
297
- if i1 <= i0:
298
- continue
299
- frac = (torch.arange(i0, i1, dtype=torch.float32) - a) / max(b - a, 1e-6)
300
- ph[0, i0:i1] = frac
301
- ph[1, i0:i1] = (frac * float(nbm[j + m])) % 1.0
302
- x = torch.cat([x, ph], dim=0)
303
-
304
- aux_t = torch.zeros(0)
305
- if self.aux:
306
- aux_t = torch.zeros(WINDOW // 4)
307
- for f, (g, c) in zip(aux_frames, pairs):
308
- if NOTE_CLASSES[c] != "end":
309
- aux_t[min(max(int(f), 0) // 4, WINDOW // 4 - 1)] = 1.0
310
- aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, 1))
311
- aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, -1))
312
-
313
- beat_t = torch.zeros(0)
314
- if self.beat_head:
315
- # measured LEAK (v15a): slot windows carry grid-phase INPUT
316
- # channels, so a beat head trained here just copies the channels
317
- # and collapses at inference (p50 723 ms). Mask beat supervision
318
- # (-1) on slot windows; the head learns from gridless time-mode
319
- # windows in dual training instead.
320
- res = WINDOW if self.beat_hires else WINDOW // 4
321
- beat_t = torch.full((res, 2), -1.0)
322
-
323
- in_t = torch.tensor(seq, dtype=torch.long)
324
- return (x, torch.tensor(seq, dtype=torch.long), prefix_len, aux_t,
325
- torch.zeros(0), 0, beat_t, in_t)
326
-
327
- def __getitem__(self, i):
328
- if self.dual:
329
- sid = self.items[i % len(self.items)][0]
330
- src = sid.split("x")[0] if "x" in sid else sid
331
- rngm = random.Random(i * 104729 + (self._rng.randint(0, 1 << 30) if self.train else 0))
332
- if self._measures(src) is not None and (not self.train or rngm.random() < self.dual_slot_p):
333
- return self._getitem_slot(i)
334
- # else: fall through to the time path (mode token + blank grid channels)
335
- elif self.slot:
336
- return self._getitem_slot(i)
337
- sid, course, level, n_frames = self.items[i % len(self.items)]
338
- mel, notes = self._load(sid)
339
- times = notes[f"{course}_t"]
340
- classes = notes[f"{course}_c"]
341
-
342
- # rhythm-preserving tempo augmentation: crop a longer/shorter source
343
- # window and resample it to WINDOW (mel-domain time stretch); note
344
- # times scale by the same factor, so all rhythmic relations survive.
345
- rng0 = random.Random(i * 31337 + (self._rng.randint(0, 1 << 30) if self.train else 0))
346
- r = 1.0
347
- if self.tempo_aug and rng0.random() < 0.5:
348
- r = rng0.uniform(0.9, 1.111)
349
- src_len = int(round(WINDOW * r))
350
-
351
- max_start = max(0, n_frames - src_len)
352
- rng = random.Random(i * 7919 + (self._rng.randint(0, 1 << 30) if self.train else 0))
353
- if self.train and max_start > 0:
354
- if self.importance:
355
- # importance sampling: bias window starts toward note-dense /
356
- # informative regions (uniform floor keeps sparse regions seen)
357
- cands = np.arange(0, max_start + 1, int(FPS)) # 1s stride
358
- lo = np.searchsorted(times, cands / FPS)
359
- hi = np.searchsorted(times, cands / FPS + WINDOW / FPS)
360
- w = (hi - lo).astype(np.float64) + 3.0
361
- start = int(rng.choices(cands.tolist(), weights=w.tolist())[0])
362
- start = min(max_start, start + rng.randint(0, int(FPS))) # sub-second jitter
363
- else:
364
- start = rng.randint(0, max_start)
365
- else: # deterministic pseudo-random window per item (not always the intro)
366
- start = (i * 2654435761) % (max_start + 1) if max_start > 0 else 0
367
-
368
- t0 = start / FPS
369
- t1 = (start + src_len) / FPS
370
- sel = (times >= t0) & (times < t1)
371
- frames = np.round((times[sel] - t0) * FPS / r).astype(np.int64)
372
- frames = np.clip(frames, 0, WINDOW - 1)
373
- pairs = list(zip(frames.tolist(), classes[sel].tolist()))
374
-
375
- sib = None
376
- if self.sibling: # skeleton hint from the nearest easier course
377
- order = ["easy", "normal", "hard", "oni", "ura"]
378
- sib_pairs = []
379
- if not (self.train and rng0.random() < 0.2): # p=0.2: train without hint
380
- for c2 in reversed(order[: order.index(course)]):
381
- if f"{c2}_t" in notes:
382
- t2, c2c = notes[f"{c2}_t"], notes[f"{c2}_c"]
383
- s2 = (t2 >= t0) & (t2 < t1) & (c2c <= 3) # hits only
384
- f2 = np.clip(np.round((t2[s2] - t0) * FPS / r), 0, WINDOW - 1)
385
- ev = list(zip(f2.astype(np.int64).tolist(), c2c[s2].tolist()))
386
- if len(ev) > 12: # even subsample to 12 slots
387
- idx2 = np.linspace(0, len(ev) - 1, 12).astype(int)
388
- ev = [ev[k] for k in idx2]
389
- sib_pairs = ev
390
- break
391
- sib = sib_pairs
392
-
393
- ctx = None
394
- if self.use_ctx:
395
- from .generate import CTX_LEN, HIT_CLASSES
396
-
397
- hit_ids = [NOTE_CLASSES.index(c) for c in HIT_CLASSES]
398
- prev = (times >= t0 - 3.0) & (times < t0)
399
- tail = [int(c) for c in classes[prev] if int(c) in hit_ids][-CTX_LEN:]
400
- if self.train and rng.random() < 0.1:
401
- tail = [] # simulate missing context (first window)
402
- elif self.train:
403
- tail = [c if rng.random() > 0.1 else rng.choice(hit_ids) for c in tail]
404
- ctx = [-1] * (CTX_LEN - len(tail)) + tail
405
-
406
- style = None
407
- if self.styles is not None:
408
- style = self.styles.get(f"{sid}:{course}", -1)
409
-
410
- sync_band = None
411
- beats_w = db_w = None
412
- if self.sync_token or self.beat_head:
413
- src_sid = sid.split("x")[0] if "x" in sid else sid
414
- bts, dbs = self._beats(src_sid)
415
- if bts is not None:
416
- # window-local, tempo-scaled beat grid
417
- bsel = (bts >= t0) & (bts < t1)
418
- beats_w = (bts[bsel] - t0) / r
419
- dsel = (dbs >= t0) & (dbs < t1)
420
- db_w = (dbs[dsel] - t0) / r
421
- if self.sync_token:
422
- sync_band = lhl_band(
423
- np.array([f / FPS for f, c in pairs if NOTE_CLASSES[c] != "end"]),
424
- beats_w, db_w)
425
-
426
- plan_slice = None
427
- if self.plans is not None:
428
- blocks = self.plans.get(f"{sid}:{course}") or []
429
- # blocks overlapping this window; times already in source timescale,
430
- # window is [t0, t1) in the same scale (r rescales note frames only,
431
- # block membership is decided in source time)
432
- plan_slice = [(b[2], b[3]) for b in blocks if b[1] > t0 and b[0] < t1]
433
-
434
- cplx = None
435
- if self.complexity:
436
- from .vocab import complexity_band
437
- cplx = complexity_band(pairs)
438
-
439
- seq, prefix_len = encode_window(
440
- VOCAB, course, level, pairs, cond_drop=self.cond_drop, rng=rng,
441
- ctx_types=ctx, sib_pairs=sib, style=style, sync_band=sync_band,
442
- plan_slice=plan_slice, complexity=cplx,
443
- mode=("time" if self.dual else None),
444
- )
445
- in_seq = None
446
- if self.mask_infill > 0 and rng.random() < self.mask_infill:
447
- # skeleton->color curriculum: decoder INPUT sees MASK where note
448
- # types were; gold targets keep the true types
449
- note_ids = set(VOCAB.note.values())
450
- in_seq = [VOCAB.mask if (i >= prefix_len and tok in note_ids) else tok
451
- for i, tok in enumerate(seq)]
452
- if len(seq) > MAX_TGT: # truncate overly dense windows, keep EOS
453
- seq = seq[: MAX_TGT - 1] + [VOCAB.eos]
454
- if VOCAB.is_time(seq[-2]): # do not end on a dangling TIME token
455
- seq = seq[:-2] + [VOCAB.eos]
456
-
457
- x = mel[:, start : start + src_len].astype(np.float32)
458
- if x.shape[1] < src_len:
459
- x = np.pad(x, ((0, 0), (0, src_len - x.shape[1])), constant_values=np.log(1e-5))
460
- x = torch.from_numpy(x)
461
- if src_len != WINDOW: # mel-domain time stretch to the fixed window size
462
- x = torch.nn.functional.interpolate(
463
- x[None], size=WINDOW, mode="linear", align_corners=False
464
- )[0]
465
- if self.tempo_aug and rng0.random() < 0.3: # mild pitch shift (mel-bin roll)
466
- k = rng0.randint(-4, 4)
467
- if k:
468
- x = torch.roll(x, k, dims=0)
469
- if k > 0:
470
- x[:k] = x.min()
471
- else:
472
- x[k:] = x.min()
473
- if self.spec_augment:
474
- for _ in range(2): # freq masks
475
- w = rng.randint(0, 12)
476
- f0 = rng.randint(0, x.shape[0] - w) if w else 0
477
- if w:
478
- x[f0 : f0 + w] = x.mean()
479
- for _ in range(2): # time masks
480
- w = rng.randint(0, 24)
481
- f0 = rng.randint(0, x.shape[1] - w) if w else 0
482
- if w:
483
- x[:, f0 : f0 + w] = x.mean()
484
- x = x + (rng.random() * 1.38 - 0.69) # gain +-6 dB in log space
485
- if self.dual: # time mode carries no grid: phase channels = -1
486
- x = torch.cat([x, torch.full((2, WINDOW), -1.0)], dim=0)
487
-
488
- aux_t = torch.zeros(0)
489
- if self.aux: # onset heatmap at encoder resolution (WINDOW // 4)
490
- aux_t = torch.zeros(WINDOW // 4)
491
- for f, c in pairs:
492
- if NOTE_CLASSES[c] != "end":
493
- aux_t[min(f // 4, WINDOW // 4 - 1)] = 1.0
494
- aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, 1))
495
- aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, -1))
496
-
497
- beat_t = torch.zeros(0)
498
- if self.beat_head:
499
- res = WINDOW if self.beat_hires else WINDOW // 4
500
- div = 1 if self.beat_hires else 4
501
- beat_t = torch.zeros(res, 2)
502
- for arr, ch in ((beats_w, 0), (db_w, 1)):
503
- if arr is None:
504
- continue
505
- for bt in arr:
506
- k = int(bt * FPS) // div
507
- if 0 <= k < res:
508
- beat_t[k, ch] = 1.0
509
- for ch in range(2):
510
- col = beat_t[:, ch]
511
- beat_t[:, ch] = torch.max(col, 0.5 * torch.roll(col, 1))
512
- beat_t[:, ch] = torch.max(beat_t[:, ch], 0.5 * torch.roll(col, -1))
513
-
514
- gsum = torch.zeros(0)
515
- posb = 0
516
- if self.global_ctx:
517
- gsum = torch.from_numpy(song_summary(np.asarray(mel)))
518
- posb = min(15, int(16 * start / max(n_frames, 1)))
519
-
520
- in_t = torch.tensor(in_seq if in_seq is not None else seq, dtype=torch.long)
521
- return (x, torch.tensor(seq, dtype=torch.long), prefix_len, aux_t, gsum, posb,
522
- beat_t, in_t)
523
-
524
-
525
- def lhl_band(note_times, beats, downbeats, band_width=0.6):
526
- """Longuet-Higgins & Lee (1984) style syncopation, simplified to the
527
- eighth-note grid, normalized per bar, bucketed into 6 bands."""
528
- if beats is None or len(beats) < 4 or len(note_times) < 2:
529
- return -1
530
- beat_len = float(np.median(np.diff(beats))) if len(beats) > 1 else 0.5
531
- grid = [] # (time, metrical level)
532
- dbset = set(np.round(downbeats, 3)) if downbeats is not None else set()
533
- for b in beats:
534
- lvl = 3 if round(float(b), 3) in dbset else 2
535
- grid.append((float(b), lvl))
536
- grid.append((float(b) + beat_len / 2, 1)) # eighth positions
537
- grid.sort()
538
- tol = 0.04
539
- score = 0.0
540
- for i, (gt_pos, lvl) in enumerate(grid[:-1]):
541
- has_note = np.any(np.abs(note_times - gt_pos) < tol)
542
- nxt_pos, nxt_lvl = grid[i + 1]
543
- nxt_note = np.any(np.abs(note_times - nxt_pos) < tol)
544
- if has_note and not nxt_note and nxt_lvl > lvl:
545
- score += nxt_lvl - lvl
546
- n_bars = max(1.0, len([g for g in grid if g[1] == 3]))
547
- return min(5, int(score / n_bars / band_width))
548
-
549
-
550
- def collate(batch):
551
- xs, seqs, plens, auxs, gsums, posbs, beats, inseqs = zip(*batch)
552
- x = torch.stack(xs)
553
- maxlen = max(len(s) for s in seqs)
554
- tgt = torch.full((len(seqs), maxlen), VOCAB.pad, dtype=torch.long)
555
- loss_mask = torch.zeros((len(seqs), maxlen), dtype=torch.bool)
556
- for i, (s, pl) in enumerate(zip(seqs, plens)):
557
- tgt[i, : len(s)] = s
558
- loss_mask[i, pl : len(s)] = True # loss on events + EOS only
559
- aux = torch.stack(auxs) if auxs[0].numel() else None
560
- gsum = torch.stack(gsums) if gsums[0].numel() else None
561
- posb = torch.tensor(posbs, dtype=torch.long)
562
- beat = torch.stack(beats) if beats[0].numel() else None
563
- in_tgt = torch.full((len(inseqs), maxlen), VOCAB.pad, dtype=torch.long)
564
- for i, sq in enumerate(inseqs):
565
- in_tgt[i, : len(sq)] = sq
566
- return x, tgt, loss_mask, aux, gsum, posb, beat, in_tgt
567
-
568
-
569
- def load_split_ids(cache_dir, val_songs=40, seed=42, use_aug=False):
570
- """Song-level split: train / val (carved from train) / test (provided).
571
-
572
- Augmented variants (speed-aug entries with "aug": true) are never used for
573
- val, and variants whose source song is in val are excluded from train
574
- (no leakage). The base split is stable regardless of augmentation.
575
- """
576
- with open(os.path.join(cache_dir, "index.json")) as f:
577
- index = json.load(f)
578
- base_train = sorted(e["id"] for e in index
579
- if e["split"] == "train" and not e.get("aug"))
580
- test_ids = sorted(e["id"] for e in index if e["split"] == "test")
581
- rng = random.Random(seed)
582
- rng.shuffle(base_train)
583
- val_ids = base_train[:val_songs]
584
- train_ids = base_train[val_songs:]
585
- if use_aug:
586
- val_set = set(val_ids)
587
- train_ids = train_ids + sorted(
588
- e["id"] for e in index
589
- if e.get("aug") and e.get("src") not in val_set)
590
- return train_ids, val_ids, test_ids
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/evaluate.py DELETED
@@ -1,151 +0,0 @@
1
- """Objective evaluation metrics for generated charts."""
2
-
3
- import numpy as np
4
-
5
- HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
6
-
7
-
8
- def match_onsets(ref_times, est_times, tol=0.05):
9
- """Greedy bipartite matching (mir_eval style). Returns list of (ri, ei)."""
10
- matches = []
11
- ri, ei = 0, 0
12
- used_r = set()
13
- # standard approach: for each est, match nearest unmatched ref within tol
14
- ref = np.asarray(ref_times)
15
- est = np.asarray(est_times)
16
- order = np.argsort(est)
17
- ref_order = np.argsort(ref)
18
- ref_sorted = ref[ref_order]
19
- taken = np.zeros(len(ref), dtype=bool)
20
- for e_i in order:
21
- t = est[e_i]
22
- lo = np.searchsorted(ref_sorted, t - tol)
23
- hi = np.searchsorted(ref_sorted, t + tol)
24
- best, best_d = -1, tol + 1
25
- for k in range(lo, hi):
26
- rid = ref_order[k]
27
- if taken[rid]:
28
- continue
29
- d = abs(ref_sorted[k] - t)
30
- if d < best_d:
31
- best, best_d = rid, d
32
- if best >= 0:
33
- taken[best] = True
34
- matches.append((best, e_i))
35
- return matches
36
-
37
-
38
- def onset_prf(ref_times, est_times, tol=0.05):
39
- if len(ref_times) == 0 and len(est_times) == 0:
40
- return 1.0, 1.0, 1.0
41
- if len(ref_times) == 0 or len(est_times) == 0:
42
- return 0.0, 0.0, 0.0
43
- m = len(match_onsets(ref_times, est_times, tol))
44
- p = m / len(est_times)
45
- r = m / len(ref_times)
46
- f = 2 * p * r / (p + r) if p + r > 0 else 0.0
47
- return p, r, f
48
-
49
-
50
- def type_accuracy(ref, est, tol=0.05):
51
- """ref/est: list of (time, class). Accuracy + per-class stats on matched onsets."""
52
- if not ref or not est:
53
- return None
54
- rt = [t for t, _ in ref]
55
- et = [t for t, _ in est]
56
- matches = match_onsets(rt, et, tol)
57
- if not matches:
58
- return None
59
- correct = sum(1 for ri, ei in matches if ref[ri][1] == est[ei][1])
60
- per_class = {}
61
- for ri, ei in matches:
62
- c = ref[ri][1]
63
- d = per_class.setdefault(c, [0, 0])
64
- d[1] += 1
65
- if est[ei][1] == c:
66
- d[0] += 1
67
- # binary don/ka accuracy (big variants folded in)
68
- fold = lambda c: "don" if "don" in c else ("ka" if "ka" in c else c)
69
- correct2 = sum(1 for ri, ei in matches if fold(ref[ri][1]) == fold(est[ei][1]))
70
- return {
71
- "acc": correct / len(matches),
72
- "acc_donka": correct2 / len(matches),
73
- "n_matched": len(matches),
74
- "per_class": {c: v[0] / v[1] for c, v in per_class.items()},
75
- }
76
-
77
-
78
- def density(events, span=None):
79
- times = [t for t, c in events if c in HIT_CLASSES]
80
- if len(times) < 2:
81
- return 0.0
82
- span = span or (max(times) - min(times))
83
- return len(times) / max(span, 1e-6) if span > 5 else 0.0
84
-
85
-
86
- def ioi_hist(events, bpm, bins=None):
87
- """Inter-onset intervals in beat units, snapped to musical fractions."""
88
- if bins is None:
89
- bins = [0.125, 1 / 6, 0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]
90
- times = sorted(t for t, c in events if c in HIT_CLASSES)
91
- if len(times) < 3 or not bpm or bpm <= 0:
92
- return None
93
- ioi = np.diff(times) * bpm / 60.0 # beats
94
- hist = np.zeros(len(bins) + 1)
95
- for x in ioi:
96
- d = [abs(x - b) / b for b in bins]
97
- j = int(np.argmin(d))
98
- if d[j] < 0.2:
99
- hist[j] += 1
100
- else:
101
- hist[-1] += 1 # off-grid
102
- s = hist.sum()
103
- return hist / s if s > 0 else None
104
-
105
-
106
- def js_divergence(p, q, eps=1e-9):
107
- p = np.asarray(p) + eps
108
- q = np.asarray(q) + eps
109
- p, q = p / p.sum(), q / q.sum()
110
- m = (p + q) / 2
111
- kl = lambda a, b: float(np.sum(a * np.log(a / b)))
112
- return 0.5 * kl(p, m) + 0.5 * kl(q, m)
113
-
114
-
115
- def ngram_dist(events, n=2):
116
- """Distribution over note-class n-grams (hits only)."""
117
- seq = [c for _, c in sorted(events) if c in HIT_CLASSES]
118
- if len(seq) < n + 1:
119
- return {}
120
- counts = {}
121
- for i in range(len(seq) - n + 1):
122
- g = tuple(seq[i : i + n])
123
- counts[g] = counts.get(g, 0) + 1
124
- tot = sum(counts.values())
125
- return {g: c / tot for g, c in counts.items()}
126
-
127
-
128
- def ngram_js(ref_events, est_events, n=2):
129
- pr = ngram_dist(ref_events, n)
130
- pe = ngram_dist(est_events, n)
131
- if not pr or not pe:
132
- return None
133
- keys = sorted(set(pr) | set(pe))
134
- return js_divergence([pr.get(k, 0) for k in keys], [pe.get(k, 0) for k in keys])
135
-
136
-
137
- def distinct_n(events, n=3):
138
- seq = [c for _, c in sorted(events) if c in HIT_CLASSES]
139
- if len(seq) < n:
140
- return None
141
- grams = [tuple(seq[i : i + n]) for i in range(len(seq) - n + 1)]
142
- return len(set(grams)) / len(grams)
143
-
144
-
145
- def spearman(x, y):
146
- from scipy.stats import spearmanr
147
-
148
- if len(x) < 2:
149
- return None
150
- r = spearmanr(x, y).statistic
151
- return None if np.isnan(r) else float(r)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/generate.py CHANGED
@@ -12,9 +12,26 @@ Features:
12
  import numpy as np
13
  import torch
14
 
 
 
 
15
  from .model import ChartModel
16
  from .vocab import FPS, MAX_TGT, N_LEVELS, NOTE_CLASSES, VOCAB, WINDOW
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
19
  SPAN_CLASSES = ("roll", "roll_big", "balloon")
20
 
@@ -33,73 +50,15 @@ def _autocast(device):
33
  CTX_LEN = 12 # fixed-length previous-window context (note-type tokens)
34
  MIN_GAP_FRAMES = 4 # ~46 ms minimum inter-note gap enforced during decoding
35
  LATTICE_RATIOS = (1 / 3, 0.5, 2 / 3, 1.0, 4 / 3, 1.5, 2.0, 3.0) # allowed IOI ratios
36
- # GT span-length p99 per class (train stats); generated spans beyond this are
37
- # truncated — a visual audit found generated balloons up to ~10s vs GT p50 ~1s
38
- SPAN_MAX = {"roll": 3.5, "roll_big": 4.0, "balloon": 6.5}
39
 
40
 
41
  def load_model(ckpt_path, device="cuda"):
42
- ck = torch.load(ckpt_path, map_location=device)
43
- sd = ck["model"]
44
- vocab_size = sd["tok_emb.weight"].shape[0]
45
- aux = any(k.startswith("aux") for k in sd)
46
- gctx = any(k.startswith("gsum_proj") for k in sd)
47
- a = ck.get("args", {}) or {}
48
- front_ch = sd["frontend.0.weight"].shape[1] # 128 (clean) or 130 (legacy slot)
49
- emb_w = sd["tok_emb.weight"].shape[1]
50
- d_model = a.get("d_model", 512)
51
- # clean_phase (v1.6): encoder frontend is 128ch and a phase_proj is present.
52
- clean_phase = bool(a.get("clean_phase", False)) or ("phase_proj.0.weight" in sd)
53
- # legacy slot models fed phase to the encoder (in_ch=130); clean models keep
54
- # in_ch=130 at the data level but only 128 reach the frontend.
55
- in_ch = 130 if (clean_phase or front_ch == 130) else front_ch
56
- # v1.7 arch flags: prefer saved args; fall back to state-dict keys so
57
- # args-less checkpoints of the new arch still load
58
- adapter_rank_ffn = int(a.get("adapter_rank_ffn", 0) or 0)
59
- if not adapter_rank_ffn and "decoder.ffn_adapters.0.down.weight" in sd:
60
- adapter_rank_ffn = sd["decoder.ffn_adapters.0.down.weight"].shape[0]
61
- depth_emb = bool(a.get("depth_emb", False)) or ("decoder.depth_emb" in sd)
62
- model = ChartModel(
63
- d_model=d_model, nhead=a.get("nhead", 8),
64
- enc_layers=a.get("enc_layers", 6),
65
- dec_layers=a.get("dec_layers", 6), ffn=a.get("ffn", 2048),
66
- vocab_size=vocab_size, aux=aux, global_ctx=gctx,
67
- func_time=a.get("func_time", False), in_ch=in_ch,
68
- emb_factor=emb_w if emb_w != d_model else None,
69
- clean_phase=clean_phase,
70
- enc_share=a.get("enc_share") or None,
71
- dec_share=a.get("dec_share") or None,
72
- adapter_rank=int(a.get("adapter_rank", 0) or 0),
73
- unique_layernorm=bool(a.get("unique_layernorm", False)),
74
- adapter_rank_ffn=adapter_rank_ffn, depth_emb=depth_emb,
75
- unshare_last_dec=bool(a.get("unshare_last_dec", False)),
76
- ).to(device)
77
- if any(k.startswith("ptr") for k in sd):
78
- model.enable_ptr()
79
- if any(k.startswith("beat") for k in sd):
80
- model.enable_beat(hires=any(k.startswith("beat.up") for k in sd))
81
- model.to(device) # newly enabled heads default to CPU
82
- model.load_state_dict(sd)
83
  model.eval()
84
- # capability detection: prefer explicit training args (vocab-size inference
85
- # wrongly added prefix slots to models that never trained with them)
86
- if a:
87
- model._has_ctx = bool(a.get("ctx", False))
88
- model._has_sib = bool(a.get("sibling", False))
89
- model._has_style = bool(a.get("style", False))
90
- model._has_sync = bool(a.get("sync_token", False))
91
- model._has_plan = bool(a.get("plan", False))
92
- model._dual = bool(a.get("dual", False))
93
- model._slot = bool(a.get("slot", False)) or model._dual
94
- else: # legacy fallback
95
- model._has_ctx = vocab_size > VOCAB.sep
96
- model._has_sib = vocab_size > VOCAB.sib
97
- model._has_style = False
98
- model._has_sync = False
99
- model._has_plan = False
100
- model._slot = False
101
- model._dual = False
102
- model._has_gctx = gctx
103
  return model
104
 
105
 
@@ -110,20 +69,23 @@ def load_hf(repo_or_dir, device="cuda"):
110
 
111
  hf = SoftChartGenerator.from_pretrained(repo_or_dir).to(device).eval()
112
  m = hf.net
113
- caps = hf.capabilities or {}
114
- m._has_ctx = bool(caps.get("ctx", False))
115
- m._has_sib = bool(caps.get("sibling", False))
116
- m._has_style = bool(caps.get("style", False))
117
- m._has_sync = bool(caps.get("sync_token", False))
118
- m._has_plan = bool(caps.get("plan", False))
119
- m._dual = bool(caps.get("dual", False))
120
- m._slot = bool(caps.get("slot", False)) or m._dual
121
- m._has_gctx = m.gsum_proj is not None
 
 
 
122
  return m
123
 
124
 
125
  def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
126
- uncond=False, sib_pairs=None, style=None, sync_band=None,
127
  plan_slice=None, mode=None):
128
  v = VOCAB
129
  seq = [v.bos]
@@ -142,14 +104,11 @@ def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
142
  cls = c if isinstance(c, int) else NOTE_CLASSES.index(c)
143
  seq += [v.time(int(f)), v.note[NOTE_CLASSES[cls]]]
144
  seq += [v.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
145
- if uncond:
146
- seq += [v.unk_cond] * 3
147
- else:
148
- seq += [
149
- v.course[course] if course else v.unk_cond,
150
- v.level[max(1, min(N_LEVELS, level))] if level else v.unk_cond,
151
- v.dens[density_bucket] if density_bucket is not None else v.unk_cond,
152
- ]
153
  if style is not None: # -1 = style-capable model, no specific style requested
154
  seq.append(v.style[style] if style >= 0 else v.unk_cond)
155
  if sync_band is not None:
@@ -165,10 +124,38 @@ def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
165
  return seq
166
 
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  @torch.no_grad()
169
  def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=0.95,
170
  greedy=False, seed=0, cfg_w=0.0, gsum=None, pos_buckets=None,
171
- lattice=False, n_cond=3, min_gap=MIN_GAP_FRAMES):
 
172
  """mels: (B, n_mels, WINDOW); prefixes: list of B equal-length token lists.
173
  Returns list of lists of (frame, note_class)."""
174
  v = VOCAB
@@ -193,11 +180,21 @@ def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=
193
 
194
  seqs = torch.tensor(prefixes, dtype=torch.long, device=device)
195
  if use_cfg: # rows B..2B: same ctx, conditions replaced by UNK
196
- unc = seqs.clone()
197
- unc[:, -n_cond:] = v.unk_cond
198
  seqs = torch.cat([seqs, unc], dim=0)
199
  memory = torch.cat([memory, memory], dim=0)
200
 
 
 
 
 
 
 
 
 
 
 
 
201
  done = torch.zeros(B, dtype=torch.bool, device=device)
202
  last_time = torch.full((B,), -min_gap, dtype=torch.long, device=device)
203
  expect_note = torch.zeros(B, dtype=torch.bool, device=device)
@@ -223,7 +220,8 @@ def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=
223
  time_pos = torch.arange(WINDOW, device=device).unsqueeze(0)
224
  sub = torch.full((len(idx), logits.shape[1]), float("-inf"), device=device)
225
  sub[:, v.eos] = 0.0
226
- ok = time_pos >= (last_time[idx] + min_gap).unsqueeze(1)
 
227
  tmask = torch.where(
228
  ok, torch.zeros_like(sub[:, :WINDOW]),
229
  torch.full_like(sub[:, :WINDOW], float("-inf")),
@@ -317,13 +315,19 @@ def _decode_pass(model, wins, starts, course, level, density_bucket, ctx_lists,
317
  g = pb = None
318
  if gsum is not None:
319
  g = gsum.unsqueeze(0).expand(chunk.shape[0], -1, -1)
 
 
 
320
  pb = torch.tensor(
321
- [min(15, int(16 * starts[i + j] / max(T, 1))) for j in range(chunk.shape[0])],
 
322
  dtype=torch.long)
 
 
323
  evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
324
  temperature=temperature, top_p=top_p, seed=seed + i,
325
  cfg_w=cfg_w, gsum=g, pos_buckets=pb, lattice=lattice,
326
- n_cond=4 if style is not None else 3)
327
  for j, events in enumerate(evs):
328
  per_window.append(events)
329
  t_off = starts[i + j] / FPS
@@ -343,7 +347,7 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
343
  if isinstance(mel, np.ndarray):
344
  mel = torch.from_numpy(mel.astype(np.float32))
345
  T = mel.shape[1]
346
- starts = list(range(0, max(T - 1, 1), WINDOW))
347
  dual = getattr(model, "_dual", False)
348
  wins = []
349
  for s in starts:
@@ -367,9 +371,7 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
367
  plan_default = getattr(model, "_has_plan", False) and plan is None
368
  gsum = None
369
  if getattr(model, "_has_gctx", False):
370
- from .data import song_summary
371
-
372
- gsum = torch.from_numpy(song_summary(mel.numpy()))
373
 
374
  all_events, per_window = _decode_pass(
375
  model, wins, starts, course, level, density_bucket,
@@ -392,29 +394,6 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
392
  sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default,
393
  mode=("time" if dual else None))
394
 
395
- if True:
396
- # rescue decoding: decoding occasionally EOSes a whole window early; if a
397
- # window is near-empty while its audio is musically active, redo it with
398
- # the opposite mode (sampling was empty -> greedy; greedy was empty ->
399
- # sampled retry, since a greedy redo would reproduce the same output)
400
- flux = [float(np.maximum(0, np.diff(w.numpy(), axis=1)).sum()) for w in wins]
401
- med = float(np.median(flux)) if flux else 0.0
402
- med_hits = float(np.median([len(ev) for ev in per_window])) if per_window else 0.0
403
- retry = [i for i, ev in enumerate(per_window)
404
- if (len(ev) < 4 and flux[i] > 0.3 * med)
405
- or (len(ev) < 0.4 * med_hits and flux[i] > 0.7 * med)]
406
- if retry:
407
- r_evs, _ = _decode_pass(
408
- model, [wins[i] for i in retry], [starts[i] for i in retry],
409
- course, level, density_bucket, None, device, not greedy, 0.9,
410
- top_p, seed + 7, 0.0, batch_windows, gsum=gsum, T=T,
411
- lattice=lattice, sib_default=sib_default, style=style_val,
412
- sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default,
413
- mode=("time" if dual else None))
414
- all_events = [e for i, w_ev in enumerate(per_window) if i not in retry
415
- for e in [(starts[i] / FPS + f / FPS, c) for f, c in w_ev]]
416
- all_events += r_evs
417
-
418
  all_events.sort(key=lambda e: e[0])
419
  hits, spans = [], []
420
  open_span = None
@@ -422,15 +401,10 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
422
  for t, cls in all_events:
423
  if cls in HIT_CLASSES:
424
  if open_span is not None:
425
- # a span whose end never arrived would swallow every following
426
- # hit (visual audit: 20 s of silence) force-close at SPAN_MAX
427
- if t - open_span[0] > SPAN_MAX.get(open_span[1], 6.5):
428
- spans.append({"t0": round(open_span[0], 4),
429
- "t1": round(open_span[0] + SPAN_MAX.get(open_span[1], 6.5), 4),
430
- "type": open_span[1]})
431
- open_span = None
432
- else:
433
- continue # no hits inside an open span
434
  if t - last_hit_t < 0.025:
435
  continue
436
  hits.append({"t": round(t, 4), "type": cls})
@@ -439,52 +413,102 @@ def generate_song(model, mel, course, level=None, density_bucket=None, device="c
439
  if open_span is None:
440
  open_span = (t, cls)
441
  elif cls == "end":
442
- if open_span is not None and t - open_span[0] > 0.05:
443
- t1_span = min(t, open_span[0] + SPAN_MAX.get(open_span[1], 6.5))
444
- spans.append({"t0": round(open_span[0], 4), "t1": round(t1_span, 4),
445
- "type": open_span[1]})
 
 
 
446
  open_span = None
447
  return {"hits": hits, "spans": spans, "course": course, "level": level,
448
  "density_bucket": density_bucket}
449
 
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  @torch.no_grad()
452
  def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None,
453
  device="cuda", greedy=False, temperature=1.0, top_p=0.95,
454
  seed=0, batch_windows=8, plan=None, on_progress=None):
455
- """Slot-mode generation: windows are anchored at the fitted barlines and the
456
- decoder emits exact TJA lattice indices (measure*96 + slot). No
457
- quantization step exists — 'hits_slots' ARE the chart.
458
 
459
- mel: (n_mels, T); grid: fit_grid() result (must be trustworthy).
 
 
 
 
 
 
460
  Returns {hits, spans (seconds, for rendering/metrics),
461
  hits_slots [(measure, slot, cls)], spans_slots, n_measures}.
462
  """
463
- from .vocab import MEAS_MAX, SLOTS
464
 
465
  if isinstance(mel, np.ndarray):
466
  mel = torch.from_numpy(mel.astype(np.float32))
467
  T = mel.shape[1]
468
- dur = T / FPS
469
  db = np.asarray(grid["downbeats"], np.float64)
470
- bar = float(grid["bar"])
471
- edges = np.append(db, db[-1] + bar) # measure m spans [edges[m], edges[m+1])
472
-
473
- wins, metas = [], [] # meta = (first_measure_idx, K)
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  j = 0
475
  while j < len(db):
476
  K = 0
477
- while (j + K < len(db) and K < MEAS_MAX
 
 
478
  and (edges[j + K + 1] - edges[j]) * FPS <= WINDOW):
 
479
  K += 1
480
  if K == 0:
481
- break
 
 
 
482
  t0 = edges[j]
483
  f0 = int(round(t0 * FPS))
484
  src = max(1, int(round((edges[j + K] - t0) * FPS)))
485
- x = mel[:, f0 : f0 + src]
486
- if x.shape[1] < src:
487
- x = torch.nn.functional.pad(x, (0, src - x.shape[1]), value=float(np.log(1e-5)))
 
 
 
 
 
488
  if x.shape[1] < WINDOW:
489
  x = torch.nn.functional.pad(x, (0, WINDOW - x.shape[1]), value=float(np.log(1e-5)))
490
  ph = torch.full((2, WINDOW), -1.0)
@@ -496,22 +520,29 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
496
  continue
497
  frac = (torch.arange(i0, i1, dtype=torch.float32) - a) / max(b - a, 1e-6)
498
  ph[0, i0:i1] = frac
499
- ph[1, i0:i1] = (frac * 4) % 1.0
500
  wins.append(torch.cat([x, ph], dim=0))
501
- metas.append((j, K))
 
 
 
 
502
  j += K
503
 
504
  sib_default = getattr(model, "_has_sib", False)
505
  style_val = -1 if getattr(model, "_has_style", False) else None
506
  plan_blocks = plan if getattr(model, "_has_plan", False) else None
507
  plan_default = getattr(model, "_has_plan", False) and plan is None
 
 
 
508
 
509
  events = [] # (measure_global, slot, cls) in decode order
510
  for i0 in range(0, len(wins), batch_windows):
511
  chunk = torch.stack(wins[i0 : i0 + batch_windows])
512
  prefixes = []
513
  for b_ in range(chunk.shape[0]):
514
- jj, KK = metas[i0 + b_]
515
  t0, t1 = edges[jj], edges[jj + KK]
516
  prefixes.append(build_prefix(
517
  course, level, density_bucket,
@@ -520,20 +551,36 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
520
  plan_slice=([(b[2], b[3]) for b in plan_blocks
521
  if b[1] > t0 and b[0] < t1] if plan_blocks is not None
522
  else ([] if plan_default else None))))
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
524
  temperature=temperature, top_p=top_p, seed=seed + i0,
525
- n_cond=4 if style_val is not None else 3, min_gap=2)
 
526
  for b_, w_ev in enumerate(evs):
527
- jj, KK = metas[i0 + b_]
528
  for g, cls in w_ev:
529
- if g < KK * SLOTS:
530
- events.append((jj + g // SLOTS, g % SLOTS, cls))
 
531
  if on_progress:
532
  on_progress(min(i0 + batch_windows, len(wins)), len(wins))
533
 
534
  events.sort(key=lambda e: (e[0], e[1]))
535
  def _t(me, sl):
536
- return float(edges[me] + sl / SLOTS * (edges[me + 1] - edges[me])) \
 
537
  if me < len(edges) - 1 else float(edges[-1])
538
 
539
  hits, spans, hits_slots, spans_slots = [], [], [], []
@@ -542,15 +589,11 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
542
  for me, sl, cls in events:
543
  t = _t(me, sl)
544
  if cls in HIT_CLASSES:
 
 
 
545
  if open_span is not None:
546
- if t - open_span[2] > SPAN_MAX.get(open_span[3], 6.5):
547
- t1s = open_span[2] + SPAN_MAX.get(open_span[3], 6.5)
548
- spans.append({"t0": round(open_span[2], 4), "t1": round(t1s, 4),
549
- "type": open_span[3]})
550
- spans_slots.append((open_span[0], open_span[1], me, sl, open_span[3]))
551
- open_span = None
552
- else:
553
- continue
554
  if (me, sl) == last_key:
555
  continue
556
  hits.append({"t": round(t, 4), "type": cls})
@@ -560,13 +603,21 @@ def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None
560
  if open_span is None:
561
  open_span = (me, sl, t, cls)
562
  elif cls == "end":
563
- if open_span is not None and t - open_span[2] > 0.05:
564
- t1s = min(t, open_span[2] + SPAN_MAX.get(open_span[3], 6.5))
565
- spans.append({"t0": round(open_span[2], 4), "t1": round(t1s, 4),
566
- "type": open_span[3]})
567
- spans_slots.append((open_span[0], open_span[1], me, sl, open_span[3]))
 
 
 
 
568
  open_span = None
569
  n_meas = (metas[-1][0] + metas[-1][1]) if metas else 0
570
  return {"hits": hits, "spans": spans, "hits_slots": hits_slots,
571
  "spans_slots": spans_slots, "n_measures": n_meas,
 
 
 
 
572
  "course": course, "level": level, "density_bucket": density_bucket}
 
12
  import numpy as np
13
  import torch
14
 
15
+ from .checkpoint import (attach_generator_capabilities,
16
+ parse_generator_checkpoint)
17
+ from .meter import resolve_meter_layout
18
  from .model import ChartModel
19
  from .vocab import FPS, MAX_TGT, N_LEVELS, NOTE_CLASSES, VOCAB, WINDOW
20
 
21
+ G_LEN = 100
22
+
23
+
24
+ def song_summary(mel):
25
+ """Summarize a complete song into fixed relative-time sections."""
26
+ n_mels, frames = mel.shape
27
+ if frames < G_LEN:
28
+ raise ValueError(f"song has only {frames} mel frames; need at least {G_LEN}")
29
+ edges = np.linspace(0, frames, G_LEN + 1, dtype=np.int64)
30
+ summary = np.empty((G_LEN, n_mels), dtype=np.float32)
31
+ for index, (start, end) in enumerate(zip(edges[:-1], edges[1:])):
32
+ summary[index] = mel[:, start:end].astype(np.float32).mean(axis=1)
33
+ return summary
34
+
35
  HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
36
  SPAN_CLASSES = ("roll", "roll_big", "balloon")
37
 
 
50
  CTX_LEN = 12 # fixed-length previous-window context (note-type tokens)
51
  MIN_GAP_FRAMES = 4 # ~46 ms minimum inter-note gap enforced during decoding
52
  LATTICE_RATIOS = (1 / 3, 0.5, 2 / 3, 1.0, 4 / 3, 1.5, 2.0, 3.0) # allowed IOI ratios
 
 
 
53
 
54
 
55
  def load_model(ckpt_path, device="cuda"):
56
+ checkpoint = torch.load(ckpt_path, map_location=device, weights_only=False)
57
+ state, _args, config, capabilities = parse_generator_checkpoint(checkpoint)
58
+ model = ChartModel(**config).to(device)
59
+ model.load_state_dict(state, strict=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  model.eval()
61
+ attach_generator_capabilities(model, capabilities)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  return model
63
 
64
 
 
69
 
70
  hf = SoftChartGenerator.from_pretrained(repo_or_dir).to(device).eval()
71
  m = hf.net
72
+ caps = hf.capabilities
73
+ if not isinstance(caps, dict):
74
+ raise ValueError("HF generator capabilities must be an explicit mapping")
75
+ attach_generator_capabilities(m, caps)
76
+ m._softchart_capabilities = dict(caps)
77
+ if (m.section_encoder is not None) != m._hierarchical_ctx:
78
+ raise ValueError("HF hierarchy capability disagrees with its architecture")
79
+ if bool(m.gsum_proj is not None) != bool(caps.get("global_ctx", False)):
80
+ # New V1.8 artifacts include global_ctx explicitly. Requiring it here
81
+ # prevents a semantically different flat-global model from loading as
82
+ # a local-only generator.
83
+ raise ValueError("HF global-context capability disagrees with architecture")
84
  return m
85
 
86
 
87
  def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
88
+ sib_pairs=None, style=None, sync_band=None,
89
  plan_slice=None, mode=None):
90
  v = VOCAB
91
  seq = [v.bos]
 
104
  cls = c if isinstance(c, int) else NOTE_CLASSES.index(c)
105
  seq += [v.time(int(f)), v.note[NOTE_CLASSES[cls]]]
106
  seq += [v.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
107
+ seq += [
108
+ v.course[course] if course else v.unk_cond,
109
+ v.level[max(1, min(N_LEVELS, level))] if level else v.unk_cond,
110
+ v.dens[density_bucket] if density_bucket is not None else v.unk_cond,
111
+ ]
 
 
 
112
  if style is not None: # -1 = style-capable model, no specific style requested
113
  seq.append(v.style[style] if style >= 0 else v.unk_cond)
114
  if sync_band is not None:
 
124
  return seq
125
 
126
 
127
+ def _conditioning_token_ids():
128
+ """Token ids whose *values* CFG is allowed to remove.
129
+
130
+ Structural markers (BOS/SEP/SIB/PLAN/mode), prior-window note context, and
131
+ sibling-chart events deliberately stay intact. Positional masking such as
132
+ ``prefix[:, -n:]`` is wrong as soon as a variable-size plan or another
133
+ field follows the basic course/level/density tuple.
134
+ """
135
+ fields = (
136
+ VOCAB.course, VOCAB.level, VOCAB.dens, VOCAB.style, VOCAB.sync,
137
+ VOCAB.cplx, VOCAB.pdens, VOCAB.pflag,
138
+ )
139
+ return frozenset(tok for field in fields for tok in field.values())
140
+
141
+
142
+ _CONDITIONING_IDS = _conditioning_token_ids()
143
+
144
+
145
+ def _make_unconditional_prefixes(prefixes):
146
+ """Return CFG prefixes with named condition values replaced by UNK."""
147
+ unc = prefixes.clone()
148
+ is_condition = torch.zeros_like(unc, dtype=torch.bool)
149
+ for tok in _CONDITIONING_IDS:
150
+ is_condition |= unc == tok
151
+ return unc.masked_fill(is_condition, VOCAB.unk_cond)
152
+
153
+
154
  @torch.no_grad()
155
  def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=0.95,
156
  greedy=False, seed=0, cfg_w=0.0, gsum=None, pos_buckets=None,
157
+ lattice=False, min_gap=MIN_GAP_FRAMES,
158
+ position_limits=None):
159
  """mels: (B, n_mels, WINDOW); prefixes: list of B equal-length token lists.
160
  Returns list of lists of (frame, note_class)."""
161
  v = VOCAB
 
180
 
181
  seqs = torch.tensor(prefixes, dtype=torch.long, device=device)
182
  if use_cfg: # rows B..2B: same ctx, conditions replaced by UNK
183
+ unc = _make_unconditional_prefixes(seqs)
 
184
  seqs = torch.cat([seqs, unc], dim=0)
185
  memory = torch.cat([memory, memory], dim=0)
186
 
187
+ if position_limits is None:
188
+ position_limits = torch.full((B,), WINDOW, dtype=torch.long, device=device)
189
+ else:
190
+ position_limits = torch.as_tensor(position_limits, dtype=torch.long,
191
+ device=device)
192
+ if position_limits.shape != (B,):
193
+ raise ValueError(f"position_limits must have shape ({B},), got "
194
+ f"{tuple(position_limits.shape)}")
195
+ if ((position_limits < 1) | (position_limits > WINDOW)).any():
196
+ raise ValueError(f"position_limits must be within [1, {WINDOW}]")
197
+
198
  done = torch.zeros(B, dtype=torch.bool, device=device)
199
  last_time = torch.full((B,), -min_gap, dtype=torch.long, device=device)
200
  expect_note = torch.zeros(B, dtype=torch.bool, device=device)
 
220
  time_pos = torch.arange(WINDOW, device=device).unsqueeze(0)
221
  sub = torch.full((len(idx), logits.shape[1]), float("-inf"), device=device)
222
  sub[:, v.eos] = 0.0
223
+ ok = ((time_pos >= (last_time[idx] + min_gap).unsqueeze(1))
224
+ & (time_pos < position_limits[idx].unsqueeze(1)))
225
  tmask = torch.where(
226
  ok, torch.zeros_like(sub[:, :WINDOW]),
227
  torch.full_like(sub[:, :WINDOW], float("-inf")),
 
315
  g = pb = None
316
  if gsum is not None:
317
  g = gsum.unsqueeze(0).expand(chunk.shape[0], -1, -1)
318
+ n_pos = (100 if getattr(model, "_hierarchical_ctx", False)
319
+ else getattr(getattr(model, "pos_emb", None),
320
+ "num_embeddings", 16))
321
  pb = torch.tensor(
322
+ [min(n_pos - 1, int(n_pos * starts[i + j] / max(T, 1)))
323
+ for j in range(chunk.shape[0])],
324
  dtype=torch.long)
325
+ limits = [min(WINDOW, max(1, int(T) - starts[i + j]))
326
+ for j in range(chunk.shape[0])]
327
  evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
328
  temperature=temperature, top_p=top_p, seed=seed + i,
329
  cfg_w=cfg_w, gsum=g, pos_buckets=pb, lattice=lattice,
330
+ min_gap=MIN_GAP_FRAMES, position_limits=limits)
331
  for j, events in enumerate(evs):
332
  per_window.append(events)
333
  t_off = starts[i + j] / FPS
 
347
  if isinstance(mel, np.ndarray):
348
  mel = torch.from_numpy(mel.astype(np.float32))
349
  T = mel.shape[1]
350
+ starts = list(range(0, max(T, 1), WINDOW))
351
  dual = getattr(model, "_dual", False)
352
  wins = []
353
  for s in starts:
 
371
  plan_default = getattr(model, "_has_plan", False) and plan is None
372
  gsum = None
373
  if getattr(model, "_has_gctx", False):
374
+ gsum = torch.from_numpy(song_summary(mel.detach().cpu().numpy()))
 
 
375
 
376
  all_events, per_window = _decode_pass(
377
  model, wins, starts, course, level, density_bucket,
 
394
  sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default,
395
  mode=("time" if dual else None))
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  all_events.sort(key=lambda e: e[0])
398
  hits, spans = [], []
399
  open_span = None
 
401
  for t, cls in all_events:
402
  if cls in HIT_CLASSES:
403
  if open_span is not None:
404
+ # Missing END is malformed decoder output. Drop the orphaned
405
+ # span and preserve the authored hit; inventing an endpoint or
406
+ # swallowing hits would silently rewrite the sequence.
407
+ open_span = None
 
 
 
 
 
408
  if t - last_hit_t < 0.025:
409
  continue
410
  hits.append({"t": round(t, 4), "type": cls})
 
413
  if open_span is None:
414
  open_span = (t, cls)
415
  elif cls == "end":
416
+ if open_span is not None:
417
+ if t - open_span[0] > 0.05:
418
+ spans.append({"t0": round(open_span[0], 4), "t1": round(t, 4),
419
+ "type": open_span[1]})
420
+ # END always consumes the open span. A too-short span is
421
+ # malformed and dropped; keeping it open would let a later END
422
+ # close the wrong start and swallow subsequent span starts.
423
  open_span = None
424
  return {"hits": hits, "spans": spans, "course": course, "level": level,
425
  "density_bucket": density_bucket}
426
 
427
 
428
+ def _grid_edges(grid, downbeats):
429
+ """Return explicitly supplied measure edges without inventing a tail."""
430
+ if len(downbeats) == 0:
431
+ raise ValueError("slot generation requires at least one downbeat")
432
+ if not np.all(np.isfinite(downbeats)) or (np.diff(downbeats) <= 0).any():
433
+ raise ValueError("grid downbeats must be finite and strictly increasing")
434
+ if "measure_edges" not in grid:
435
+ raise ValueError(
436
+ "slot generation requires explicit measure_edges (starts plus a "
437
+ "trusted terminal edge); terminal duration is never extrapolated"
438
+ )
439
+ edges = np.asarray(grid["measure_edges"], dtype=np.float64)
440
+ if (len(edges) != len(downbeats) + 1
441
+ or not np.allclose(edges[:-1], downbeats, rtol=0, atol=1e-6)
442
+ or not np.all(np.isfinite(edges))
443
+ or (np.diff(edges) <= 0).any()):
444
+ raise ValueError("grid measure_edges must be valid starts plus one end")
445
+ return edges
446
+
447
+
448
  @torch.no_grad()
449
  def generate_song_slot(model, mel, grid, course, level=None, density_bucket=None,
450
  device="cuda", greedy=False, temperature=1.0, top_p=0.95,
451
  seed=0, batch_windows=8, plan=None, on_progress=None):
452
+ """Meter-aware generation on the exact 96-slots-per-4/4 TJA lattice.
 
 
453
 
454
+ Decoder positions are slot-linear across complete measures. Rational
455
+ meters such as 1/16, 5/8, 7/8, and 8/4 are represented by
456
+ ``96 * numerator / denominator`` slots. No quantization step exists:
457
+ ``hits_slots`` are exact per-measure lattice positions.
458
+
459
+ mel: (n_mels, T); grid: trusted rational layout with explicit
460
+ ``measure_edges`` including its terminal edge.
461
  Returns {hits, spans (seconds, for rendering/metrics),
462
  hits_slots [(measure, slot, cls)], spans_slots, n_measures}.
463
  """
464
+ from .vocab import SLOTS
465
 
466
  if isinstance(mel, np.ndarray):
467
  mel = torch.from_numpy(mel.astype(np.float32))
468
  T = mel.shape[1]
 
469
  db = np.asarray(grid["downbeats"], np.float64)
470
+ measure_slots, measure_num, measure_den, quarter_beats = \
471
+ resolve_meter_layout(grid, len(db))
472
+ if "measure_slot_safe" in grid:
473
+ slot_safe = np.asarray(grid["measure_slot_safe"])
474
+ if (slot_safe.dtype != np.bool_ or slot_safe.ndim != 1
475
+ or len(slot_safe) != len(db)):
476
+ raise ValueError(
477
+ "measure_slot_safe must be one boolean per measure")
478
+ unsafe = np.flatnonzero(~slot_safe)
479
+ if len(unsafe):
480
+ raise ValueError(
481
+ "slot generation refuses unsafe BPM/DELAY/off-lattice "
482
+ f"measures: {unsafe.astype(int).tolist()}")
483
+ edges = _grid_edges(grid, db)
484
+
485
+ # meta = (first_measure_idx, measure_count, cumulative local slot offsets)
486
+ wins, metas = [], []
487
  j = 0
488
  while j < len(db):
489
  K = 0
490
+ nslots = 0
491
+ while (j + K < len(db)
492
+ and nslots + int(measure_slots[j + K]) <= WINDOW
493
  and (edges[j + K + 1] - edges[j]) * FPS <= WINDOW):
494
+ nslots += int(measure_slots[j + K])
495
  K += 1
496
  if K == 0:
497
+ raise ValueError(
498
+ f"measure {j} does not fit the {WINDOW}-frame/{WINDOW}-slot "
499
+ "slot window"
500
+ )
501
  t0 = edges[j]
502
  f0 = int(round(t0 * FPS))
503
  src = max(1, int(round((edges[j + K] - t0) * FPS)))
504
+ # Negative chart offsets are real; pad the pre-audio region instead of
505
+ # letting Python's negative slice wrap around to the song tail.
506
+ x = torch.full((mel.shape[0], src), float(np.log(1e-5)),
507
+ dtype=mel.dtype, device=mel.device)
508
+ src0, src1 = max(0, f0), min(T, f0 + src)
509
+ if src1 > src0:
510
+ dst0 = src0 - f0
511
+ x[:, dst0:dst0 + (src1 - src0)] = mel[:, src0:src1]
512
  if x.shape[1] < WINDOW:
513
  x = torch.nn.functional.pad(x, (0, WINDOW - x.shape[1]), value=float(np.log(1e-5)))
514
  ph = torch.full((2, WINDOW), -1.0)
 
520
  continue
521
  frac = (torch.arange(i0, i1, dtype=torch.float32) - a) / max(b - a, 1e-6)
522
  ph[0, i0:i1] = frac
523
+ ph[1, i0:i1] = (frac * float(quarter_beats[j + m])) % 1.0
524
  wins.append(torch.cat([x, ph], dim=0))
525
+ local_offsets = np.concatenate([
526
+ np.array([0], dtype=np.int64),
527
+ np.cumsum(measure_slots[j:j + K], dtype=np.int64),
528
+ ])
529
+ metas.append((j, K, local_offsets))
530
  j += K
531
 
532
  sib_default = getattr(model, "_has_sib", False)
533
  style_val = -1 if getattr(model, "_has_style", False) else None
534
  plan_blocks = plan if getattr(model, "_has_plan", False) else None
535
  plan_default = getattr(model, "_has_plan", False) and plan is None
536
+ gsum = None
537
+ if getattr(model, "_has_gctx", False):
538
+ gsum = torch.from_numpy(song_summary(mel.detach().cpu().numpy()))
539
 
540
  events = [] # (measure_global, slot, cls) in decode order
541
  for i0 in range(0, len(wins), batch_windows):
542
  chunk = torch.stack(wins[i0 : i0 + batch_windows])
543
  prefixes = []
544
  for b_ in range(chunk.shape[0]):
545
+ jj, KK, _ = metas[i0 + b_]
546
  t0, t1 = edges[jj], edges[jj + KK]
547
  prefixes.append(build_prefix(
548
  course, level, density_bucket,
 
551
  plan_slice=([(b[2], b[3]) for b in plan_blocks
552
  if b[1] > t0 and b[0] < t1] if plan_blocks is not None
553
  else ([] if plan_default else None))))
554
+ limits = [int(metas[i0 + b_][2][-1])
555
+ for b_ in range(chunk.shape[0])]
556
+ g = pb = None
557
+ if gsum is not None:
558
+ g = gsum.unsqueeze(0).expand(chunk.shape[0], -1, -1)
559
+ n_pos = (100 if getattr(model, "_hierarchical_ctx", False)
560
+ else getattr(getattr(model, "pos_emb", None),
561
+ "num_embeddings", 16))
562
+ pb = torch.tensor([
563
+ min(n_pos - 1, max(0, int(
564
+ n_pos * edges[metas[i0 + b_][0]] * FPS / max(T, 1))))
565
+ for b_ in range(chunk.shape[0])
566
+ ], dtype=torch.long)
567
  evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
568
  temperature=temperature, top_p=top_p, seed=seed + i0,
569
+ min_gap=2, position_limits=limits,
570
+ gsum=g, pos_buckets=pb)
571
  for b_, w_ev in enumerate(evs):
572
+ jj, KK, offsets = metas[i0 + b_]
573
  for g, cls in w_ev:
574
+ if g < offsets[-1]:
575
+ local_m = int(np.searchsorted(offsets[1:], g, side="right"))
576
+ events.append((jj + local_m, int(g - offsets[local_m]), cls))
577
  if on_progress:
578
  on_progress(min(i0 + batch_windows, len(wins)), len(wins))
579
 
580
  events.sort(key=lambda e: (e[0], e[1]))
581
  def _t(me, sl):
582
+ slots_in_measure = int(measure_slots[me])
583
+ return float(edges[me] + sl / slots_in_measure * (edges[me + 1] - edges[me])) \
584
  if me < len(edges) - 1 else float(edges[-1])
585
 
586
  hits, spans, hits_slots, spans_slots = [], [], [], []
 
589
  for me, sl, cls in events:
590
  t = _t(me, sl)
591
  if cls in HIT_CLASSES:
592
+ # A hit is never a valid implicit span terminator. Keeping the
593
+ # authored hit and dropping the malformed, unclosed span avoids
594
+ # inventing an end position that the decoder did not emit.
595
  if open_span is not None:
596
+ open_span = None
 
 
 
 
 
 
 
597
  if (me, sl) == last_key:
598
  continue
599
  hits.append({"t": round(t, 4), "type": cls})
 
603
  if open_span is None:
604
  open_span = (me, sl, t, cls)
605
  elif cls == "end":
606
+ if open_span is not None:
607
+ if t - open_span[2] > 0.05:
608
+ # The explicit END slot is authoritative. Derive both
609
+ # preview seconds and TJA coordinates from that same slot;
610
+ # duration caps must not move either representation.
611
+ spans.append({"t0": round(open_span[2], 4), "t1": round(t, 4),
612
+ "type": open_span[3]})
613
+ spans_slots.append(
614
+ (open_span[0], open_span[1], me, sl, open_span[3]))
615
  open_span = None
616
  n_meas = (metas[-1][0] + metas[-1][1]) if metas else 0
617
  return {"hits": hits, "spans": spans, "hits_slots": hits_slots,
618
  "spans_slots": spans_slots, "n_measures": n_meas,
619
+ "measure_slots": measure_slots[:n_meas].tolist(),
620
+ "measure_num": measure_num[:n_meas].tolist(),
621
+ "measure_den": measure_den[:n_meas].tolist(),
622
+ "slots_per_4_4": SLOTS,
623
  "course": course, "level": level, "density_bucket": density_bucket}
softchart/grid.py CHANGED
@@ -59,7 +59,9 @@ def beat_activations(model, mel, device="cuda", hop=WINDOW // 2):
59
  if w.shape[0] < model.in_ch: # dual/slot models: blank grid channels
60
  w = torch.cat([w, torch.full((model.in_ch - w.shape[0], WINDOW), -1.0)])
61
  with _autocast(device):
62
- mem = model.encode(w[None].to(device))
 
 
63
  pr = torch.sigmoid(model.beat(mem[:, -L:]).float())[0].cpu().numpy().T
64
  if acc is None:
65
  factor = WINDOW // pr.shape[1]
 
59
  if w.shape[0] < model.in_ch: # dual/slot models: blank grid channels
60
  w = torch.cat([w, torch.full((model.in_ch - w.shape[0], WINDOW), -1.0)])
61
  with _autocast(device):
62
+ # Beat/downbeat estimation must remain local and phase-free even
63
+ # when the chart decoder also owns a whole-song section encoder.
64
+ mem = model.encode_local(w[None].to(device))
65
  pr = torch.sigmoid(model.beat(mem[:, -L:]).float())[0].cpu().numpy().T
66
  if acc is None:
67
  factor = WINDOW // pr.shape[1]
softchart/hf.py CHANGED
@@ -1,22 +1,17 @@
1
  """Hugging Face Hub-compatible wrappers (safetensors + from_pretrained/push_to_hub).
2
 
3
- Three model types, all <=16M params, MIT-licensed:
4
- SoftChartGenerator audio log-mel -> chart event tokens (the main model; pr12)
5
- SoftChartBeat audio log-mel -> beat/downbeat activations (barline anchor)
6
- SoftChartPlanner block features -> per-block plan tokens (auto-planning)
7
 
8
  Usage:
9
  from softchart.hf import SoftChartGenerator
10
  gen = SoftChartGenerator.from_pretrained("JacobLinCool/softchart-generator")
11
  """
12
 
13
- import torch
14
  import torch.nn as nn
15
  from huggingface_hub import PyTorchModelHubMixin
16
 
17
- from .model import ChartModel, sinusoidal
18
-
19
- _CARD = "See https://github.com/JacobLinCool/SoftChart — MIT licensed."
20
 
21
 
22
  class SoftChartGenerator(
@@ -29,27 +24,21 @@ class SoftChartGenerator(
29
  """Encoder-decoder chart generator. Config is stored as config.json and the
30
  full architecture (condition tokens, heads) is reconstructed on load."""
31
 
32
- def __init__(self, d_model=256, enc_layers=4, dec_layers=4, ffn=1024,
33
- vocab_size=1802, aux=True, global_ctx=False, func_time=False,
 
34
  ptr=False, beat=False, in_ch=None, emb_factor=None,
35
- capabilities=None, nhead=8, clean_phase=False,
36
- enc_share=None, dec_share=None, adapter_rank=0,
37
- unique_layernorm=False, adapter_rank_ffn=0, depth_emb=False,
38
- unshare_last_dec=False):
39
  super().__init__()
40
- # nhead and the v1.6/v1.7 sharing flags (enc_share/dec_share/adapter_rank/
41
- # unique_layernorm/adapter_rank_ffn/depth_emb/unshare_last_dec) and
42
- # clean_phase must be persisted in config.json: from_pretrained rebuilds
43
- # the architecture from these kwargs alone, before any weights are loaded.
44
- # Defaults reproduce the pre-v1.6 unshared architecture for older repos.
45
  self.net = ChartModel(
46
- d_model=d_model, nhead=nhead, enc_layers=enc_layers, dec_layers=dec_layers,
47
- ffn=ffn, vocab_size=vocab_size, aux=aux, global_ctx=global_ctx,
48
- func_time=func_time, ptr=ptr, beat=beat, in_ch=in_ch, emb_factor=emb_factor,
49
- clean_phase=clean_phase, enc_share=enc_share, dec_share=dec_share,
50
- adapter_rank=adapter_rank, unique_layernorm=unique_layernorm,
51
- adapter_rank_ffn=adapter_rank_ffn, depth_emb=depth_emb,
52
- unshare_last_dec=unshare_last_dec,
53
  )
54
  # capabilities: which condition axes this checkpoint was trained with,
55
  # so inference knows which prefix tokens to emit
@@ -76,29 +65,3 @@ class SoftChartGenerator(
76
  @property
77
  def tok_emb(self):
78
  return self.net.tok_emb
79
-
80
-
81
- class SoftChartPlanner(
82
- nn.Module,
83
- PyTorchModelHubMixin,
84
- repo_url="https://github.com/JacobLinCool/SoftChart",
85
- license="mit",
86
- tags=["taiko", "rhythm-game", "planning", "music"],
87
- ):
88
- """Song-level plan generator: block audio summaries -> (density, flag) tokens."""
89
-
90
- def __init__(self, feat_dim=261, d=192, layers=4, n_dens=8, n_flag=3):
91
- super().__init__()
92
- self.inp = nn.Linear(feat_dim, d)
93
- self.course = nn.Embedding(5, d)
94
- self.register_buffer("pos", sinusoidal(128, d), persistent=False)
95
- enc = nn.TransformerEncoderLayer(d, 6, d * 4, 0.1, activation="gelu",
96
- batch_first=True, norm_first=True)
97
- self.enc = nn.TransformerEncoder(enc, layers, nn.LayerNorm(d))
98
- self.h_dens = nn.Linear(d, n_dens)
99
- self.h_flag = nn.Linear(d, n_flag)
100
-
101
- def forward(self, x, cid):
102
- h = self.inp(x) + self.course(cid)[:, None] + self.pos[: x.shape[1]]
103
- h = self.enc(h)
104
- return self.h_dens(h), self.h_flag(h)
 
1
  """Hugging Face Hub-compatible wrappers (safetensors + from_pretrained/push_to_hub).
2
 
3
+ Space runtime wrapper for the unified V1.8 generator.
 
 
 
4
 
5
  Usage:
6
  from softchart.hf import SoftChartGenerator
7
  gen = SoftChartGenerator.from_pretrained("JacobLinCool/softchart-generator")
8
  """
9
 
 
10
  import torch.nn as nn
11
  from huggingface_hub import PyTorchModelHubMixin
12
 
13
+ from .model import ChartModel
14
+ from .vocab import VOCAB
 
15
 
16
 
17
  class SoftChartGenerator(
 
24
  """Encoder-decoder chart generator. Config is stored as config.json and the
25
  full architecture (condition tokens, heads) is reconstructed on load."""
26
 
27
+ def __init__(self, d_model=256, nhead=8, enc_layers=4, dec_layers=4, ffn=1024,
28
+ dropout=0.1,
29
+ vocab_size=VOCAB.size, aux=True, global_ctx=False, func_time=False,
30
  ptr=False, beat=False, in_ch=None, emb_factor=None,
31
+ hierarchical_ctx=False, section_layers=2, section_ffn=None,
32
+ clean_phase=False,
33
+ capabilities=None):
 
34
  super().__init__()
 
 
 
 
 
35
  self.net = ChartModel(
36
+ d_model=d_model, nhead=nhead, enc_layers=enc_layers,
37
+ dec_layers=dec_layers, ffn=ffn, dropout=dropout,
38
+ vocab_size=vocab_size, aux=aux, global_ctx=global_ctx, func_time=func_time,
39
+ ptr=ptr, beat=beat, in_ch=in_ch, emb_factor=emb_factor,
40
+ hierarchical_ctx=hierarchical_ctx, section_layers=section_layers,
41
+ section_ffn=section_ffn, clean_phase=clean_phase,
 
42
  )
43
  # capabilities: which condition axes this checkpoint was trained with,
44
  # so inference knows which prefix tokens to emit
 
65
  @property
66
  def tok_emb(self):
67
  return self.net.tok_emb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/meter.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exact rational-meter helpers for the 96-slot-per-4/4 TJA lattice."""
2
+
3
+ from fractions import Fraction
4
+ from math import gcd
5
+ from numbers import Real
6
+
7
+ import numpy as np
8
+
9
+ from .vocab import SLOTS, WINDOW
10
+
11
+
12
+ def _expand(value, n_measures, name, dtype):
13
+ array = np.asarray(value)
14
+ if array.ndim == 0:
15
+ array = np.full(n_measures, array.item())
16
+ else:
17
+ array = array.reshape(-1)
18
+ if len(array) != n_measures:
19
+ raise ValueError(
20
+ f"{name} must contain one value per measure "
21
+ f"({n_measures}), got {len(array)}"
22
+ )
23
+ if np.issubdtype(np.dtype(dtype), np.integer):
24
+ info = np.iinfo(dtype)
25
+ values = []
26
+ for raw in array.flat:
27
+ if isinstance(raw, (bool, np.bool_)):
28
+ raise ValueError(f"{name} must contain finite integers")
29
+ if isinstance(raw, (int, np.integer)):
30
+ integer = int(raw)
31
+ elif isinstance(raw, (float, np.floating)):
32
+ number = float(raw)
33
+ if not np.isfinite(number) or not number.is_integer():
34
+ raise ValueError(f"{name} must contain finite integers")
35
+ integer = int(number)
36
+ else:
37
+ raise ValueError(f"{name} must contain finite integers")
38
+ if not info.min <= integer <= info.max:
39
+ raise ValueError(f"{name} is outside the {np.dtype(dtype)} range")
40
+ values.append(integer)
41
+ return np.asarray(values, dtype=dtype).reshape(array.shape)
42
+ values = []
43
+ for raw in array.flat:
44
+ if isinstance(raw, (bool, np.bool_)) or not isinstance(raw, Real):
45
+ raise ValueError(f"{name} must contain numeric values")
46
+ values.append(float(raw))
47
+ return np.asarray(values, dtype=dtype).reshape(array.shape)
48
+
49
+
50
+ def rational_meter_arrays(measure_num, measure_den, n_measures=None):
51
+ """Validate TJA ``#MEASURE`` fractions and return lattice properties.
52
+
53
+ Returns ``(slots, numerator, denominator, quarter_beats)``. A meter is
54
+ representable exactly iff ``96 * numerator / denominator`` is an integer;
55
+ no rounding or coercion is permitted.
56
+ """
57
+ num = np.asarray(measure_num)
58
+ den = np.asarray(measure_den)
59
+ if n_measures is None:
60
+ if num.ndim == 0 and den.ndim == 0:
61
+ n_measures = 1
62
+ else:
63
+ n_measures = max(num.size, den.size)
64
+ num = _expand(num, n_measures, "measure_num", np.int64)
65
+ den = _expand(den, n_measures, "measure_den", np.int64)
66
+ if (num <= 0).any() or (den <= 0).any():
67
+ raise ValueError("measure numerator and denominator must be positive")
68
+
69
+ slot_values = []
70
+ for numerator, denominator in zip(num.tolist(), den.tolist()):
71
+ common = gcd(int(numerator), int(denominator))
72
+ reduced_num = int(numerator) // common
73
+ reduced_den = int(denominator) // common
74
+ if SLOTS % reduced_den:
75
+ raise ValueError(
76
+ "meter is not exactly representable on the 96-slot lattice")
77
+ slot_count = (SLOTS // reduced_den) * reduced_num
78
+ if not 1 <= slot_count <= WINDOW:
79
+ raise ValueError(
80
+ f"measure lattice length must be within 1..{WINDOW} slots")
81
+ slot_values.append(slot_count)
82
+ slots = np.asarray(slot_values, dtype=np.int64)
83
+ quarter_beats = 4.0 * num / den
84
+ return slots, num, den, quarter_beats.astype(np.float64)
85
+
86
+
87
+ def resolve_meter_layout(grid, n_measures):
88
+ """Resolve exact per-measure layout from a generation grid.
89
+
90
+ Preferred input is ``measure_num``/``measure_den`` with optional
91
+ ``measure_slots`` for an integrity check. A uniform ``meter`` or a
92
+ ``beats_per_measure`` array remains a supported grid API: each value is a
93
+ quarter-note count and is converted to an exact reduced TJA fraction.
94
+ """
95
+ has_num = "measure_num" in grid
96
+ has_den = "measure_den" in grid
97
+ if has_num != has_den:
98
+ raise ValueError("measure_num and measure_den must be provided together")
99
+ if has_num:
100
+ num = _expand(grid["measure_num"], n_measures, "measure_num", np.int64)
101
+ den = _expand(grid["measure_den"], n_measures, "measure_den", np.int64)
102
+ else:
103
+ raw = grid.get("beats_per_measure", grid.get("meter"))
104
+ if raw is None:
105
+ raise ValueError(
106
+ "slot generation requires measure_num/measure_den or an "
107
+ "explicit uniform meter/beats_per_measure grid"
108
+ )
109
+ quarter = _expand(raw, n_measures, "beats_per_measure", np.float64)
110
+ if not np.isfinite(quarter).all() or (quarter <= 0).any():
111
+ raise ValueError("quarter beats per measure must be positive and finite")
112
+ fractions = [Fraction(float(q)).limit_denominator(768) for q in quarter]
113
+ if any(abs(float(f) - q) > 1e-9
114
+ for f, q in zip(fractions, quarter)):
115
+ raise ValueError("quarter-beat meter values must be exact rationals")
116
+ num = np.asarray([f.numerator for f in fractions], dtype=np.int64)
117
+ den = np.asarray([4 * f.denominator for f in fractions], dtype=np.int64)
118
+
119
+ slots, num, den, quarter = rational_meter_arrays(num, den, n_measures)
120
+ if "measure_slots" in grid:
121
+ supplied = _expand(
122
+ grid["measure_slots"], n_measures, "measure_slots", np.int64)
123
+ if not np.array_equal(supplied, slots):
124
+ raise ValueError("measure_slots disagrees with measure_num/measure_den")
125
+ return slots, num, den, quarter
softchart/model.py CHANGED
@@ -33,6 +33,42 @@ class BeatHiRes(nn.Module):
33
  return self.out(h).transpose(1, 2)
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def _parse_pattern(spec, n):
37
  """Layer-sharing schedule. spec is either an int (number of UNIQUE
38
  physical layers, cycled to length n) or a comma string like "E0,E1,E0,E1"
@@ -55,17 +91,18 @@ def _parse_pattern(spec, n):
55
 
56
  class Adapter(nn.Module):
57
  """Rank-r residual adapter applied after a (possibly reused) transformer
58
- block: x + gate * up(gelu(down(x))). gate init 0 so the whole stack starts
59
- identical to hard weight sharing; the adapter only differentiates a reused
60
- physical layer as training moves the gate off zero."""
 
61
 
62
  def __init__(self, d, rank):
63
  super().__init__()
64
  self.down = nn.Linear(d, rank, bias=False)
65
  self.up = nn.Linear(rank, d, bias=False)
66
  nn.init.normal_(self.down.weight, std=0.02)
67
- nn.init.zeros_(self.up.weight)
68
- self.gate = nn.Parameter(torch.zeros(1))
69
 
70
  def forward(self, x):
71
  return x + self.gate * self.up(nn.functional.gelu(self.down(x)))
@@ -74,17 +111,17 @@ class Adapter(nn.Module):
74
  class SharedEncoder(nn.Module):
75
  """Encoder whose physical layers are reused according to a sharing pattern.
76
  Each POSITION in the depth keeps its own (a) rank-r adapter and (b) final
77
- LayerNorm so ΔW starts at 0 (hard sharing) but every position can drift.
78
  With pattern=None this is exactly n unique layers + a norm, matching the
79
  stock nn.TransformerEncoder param count (adapters/pos-norms are opt-in).
80
 
81
- v1.7 depth-diversity options (all default-off, init == v1.6 behaviour):
82
  depth_emb: per-depth learned d-vector ADDED to the block input at each
83
  reuse (params = depth x d, zero-init so init == hard sharing).
84
  adapter_rank_ffn > 0: SPLIT adapters — instead of one post-block adapter,
85
  each depth owns a rank-`adapter_rank` adapter on the attention sublayer
86
  output and a rank-`adapter_rank_ffn` adapter on the FFN sublayer output
87
- (patterning lives in the FFN). Gate init 0 keeps init == hard sharing.
88
  """
89
 
90
  def __init__(self, d, nhead, ffn, dropout, pattern, adapter_rank=0,
@@ -123,7 +160,7 @@ class SharedEncoder(nn.Module):
123
  if self.split:
124
  # norm_first decomposition of nn.TransformerEncoderLayer with
125
  # per-depth adapters applied to each sublayer OUTPUT (Houlsby
126
- # placement); at gate=0 this is bit-equal to the stock layer
127
  x = x + self.attn_adapters[k](
128
  layer._sa_block(layer.norm1(x), None, src_key_padding_mask))
129
  x = x + self.ffn_adapters[k](layer._ff_block(layer.norm2(x)))
@@ -204,6 +241,9 @@ class ChartModel(nn.Module):
204
  max_tgt=None,
205
  aux=False,
206
  global_ctx=False,
 
 
 
207
  func_time=False,
208
  ptr=False,
209
  beat=False,
@@ -224,6 +264,10 @@ class ChartModel(nn.Module):
224
  vocab_size = vocab_size or VOCAB.size
225
  max_tgt = max_tgt or MAX_TGT
226
  self.d_model = d_model
 
 
 
 
227
  # clean_phase (v1.6): the encoder never sees the 2 grid-phase channels
228
  # (in_ch stays N_MELS), and phase is injected into the DECODER memory
229
  # only, via a small projection. This removes the condition leak that
@@ -267,8 +311,9 @@ class ChartModel(nn.Module):
267
  enc_layer = nn.TransformerEncoderLayer(
268
  d_model, nhead, ffn, dropout, activation="gelu",
269
  batch_first=True, norm_first=True)
270
- self.encoder = nn.TransformerEncoder(enc_layer, enc_layers,
271
- nn.LayerNorm(d_model))
 
272
  dec_layer = nn.TransformerDecoderLayer(
273
  d_model, nhead, ffn, dropout, activation="gelu",
274
  batch_first=True, norm_first=True)
@@ -306,8 +351,17 @@ class ChartModel(nn.Module):
306
  self.out = nn.Linear(d_model, vocab_size, bias=False)
307
  self.out.weight = self.tok_emb.weight # weight tying
308
  self.dropout = nn.Dropout(dropout)
309
- # auxiliary per-position onset-heatmap head on the encoder (v2)
 
 
 
 
 
310
  self.aux = nn.Linear(d_model, 1) if aux else None
 
 
 
 
311
  # pointer alignment head: each generated note must point to its audio
312
  # frame (differentiable provenance; explainability that trains alignment)
313
  self.ptr = nn.Linear(d_model, d_model) if ptr else None
@@ -323,10 +377,18 @@ class ChartModel(nn.Module):
323
  if global_ctx:
324
  self.gsum_proj = nn.Linear(N_MELS, d_model)
325
  self.seg_emb = nn.Parameter(torch.zeros(2, d_model))
326
- self.pos_emb = nn.Embedding(16, d_model) # window position bucket
327
  self.gpos = nn.Parameter(torch.randn(128, d_model) * 0.02)
328
  else:
329
  self.gsum_proj = None
 
 
 
 
 
 
 
 
330
  # functional time embeddings: the 1728 TIME tokens share a sinusoidal
331
  # basis + small projection instead of free embeddings (fewer params,
332
  # neighbouring times get similar representations)
@@ -339,15 +401,31 @@ class ChartModel(nn.Module):
339
  else:
340
  self.time_proj = None
341
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  def encode(self, mel, gsum=None, pos_bucket=None):
343
  # mel: (B, C, T). clean_phase: C may be N_MELS (audio only) or N_MELS+2
344
  # (audio + 2 grid-phase channels); only the audio channels reach the
345
  # encoder, so the returned memory is CLEAN (phase-free) and safe for the
346
- # beat head. gsum: (B, G, n_mels) whole-song summary chunks;
347
- # pos_bucket: (B,) window-position bucket in [0, 16).
348
- audio = mel[:, :N_MELS] if self.clean_phase else mel
349
- h = self.frontend(audio).transpose(1, 2) # (B, T/4, d)
350
- h = h + self.enc_pos[: h.shape[1]]
 
 
 
 
351
  if self.gsum_proj is not None and gsum is not None:
352
  g = self.gsum_proj(gsum) + self.seg_emb[1] + self.gpos[: gsum.shape[1]]
353
  h = h + self.seg_emb[0]
@@ -360,17 +438,25 @@ class ChartModel(nn.Module):
360
  Adds a projection of the grid-phase channels to the audio-frame slice
361
  of memory; the beat head keeps reading the clean `memory`. No-op unless
362
  clean_phase is on and phase channels are present. Time-mode windows
363
- (raw phase = -1) get valid=0 and zeroed phase -> ~zero conditioning."""
364
- nch = N_MELS
365
- if not self.clean_phase or mel.shape[1] < nch + 2:
366
- return memory
367
- ph = mel[:, nch:nch + 2] # (B, 2, T) in [0,1); -1 marks no-grid frames
368
- valid = (ph[:, :1] >= 0).float() # 1 where a grid exists, else 0
369
- ph_in = torch.cat([ph.clamp(min=0.0), valid], dim=1) # (B, 3, T)
370
- p = self.phase_proj(ph_in).transpose(1, 2) # (B, T/4, d)
371
- L = p.shape[1]
372
  out = memory.clone()
373
- out[:, -L:] = out[:, -L:] + p # audio frames are the last L memory slots
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  return out
375
 
376
  def emb_matrix(self):
@@ -388,7 +474,7 @@ class ChartModel(nn.Module):
388
  L = tgt_in.shape[1]
389
  E = self.emb_matrix()
390
  h = nn.functional.embedding(tgt_in, E) * math.sqrt(self.d_model) + self.dec_pos[:L]
391
- mask = nn.Transformer.generate_square_subsequent_mask(L, device=tgt_in.device)
392
  pad_mask = tgt_in == VOCAB.pad
393
  h = self.decoder(
394
  self.dropout(h),
@@ -416,7 +502,7 @@ class ChartModel(nn.Module):
416
  E = self.emb_matrix()
417
  import math as _m
418
  h = nn.functional.embedding(dec_in, E) * _m.sqrt(self.d_model) + self.dec_pos[:L]
419
- dm = nn.Transformer.generate_square_subsequent_mask(L, device=tgt.device)
420
  h = self.decoder(self.dropout(h), dec_mem, tgt_mask=dm,
421
  tgt_key_padding_mask=dec_in == VOCAB.pad, tgt_is_causal=True)
422
  logits = h @ E.T
 
33
  return self.out(h).transpose(1, 2)
34
 
35
 
36
+ class HierarchicalSectionEncoder(nn.Module):
37
+ """Encode a fixed relative-time summary of the complete song.
38
+
39
+ The local encoder remains dedicated to the current 20-second window. This
40
+ separate branch models section-to-section structure, marks the section that
41
+ contains the current window, and exposes its states directly to decoder
42
+ cross-attention. It is trained from scratch with the chart objective; no
43
+ external audio representation is used.
44
+ """
45
+
46
+ def __init__(self, d, nhead, ffn, dropout, layers=2, sections=100):
47
+ super().__init__()
48
+ self.sections = sections
49
+ self.inp = nn.Linear(N_MELS, d)
50
+ self.pos = nn.Parameter(torch.randn(sections, d) * 0.02)
51
+ self.current = nn.Parameter(torch.randn(d) * 0.02)
52
+ layer = nn.TransformerEncoderLayer(
53
+ d, nhead, ffn, dropout, activation="gelu",
54
+ batch_first=True, norm_first=True,
55
+ )
56
+ self.encoder = nn.TransformerEncoder(
57
+ layer, layers, nn.LayerNorm(d), enable_nested_tensor=False)
58
+
59
+ def forward(self, summary, position):
60
+ if summary.ndim != 3 or summary.shape[1:] != (self.sections, N_MELS):
61
+ raise ValueError(
62
+ f"expected song summary (B,{self.sections},{N_MELS}), "
63
+ f"got {tuple(summary.shape)}"
64
+ )
65
+ position = position.to(summary.device).long().clamp(0, self.sections - 1)
66
+ h = self.inp(summary) + self.pos
67
+ marker = torch.zeros_like(h)
68
+ marker[torch.arange(h.shape[0], device=h.device), position] = self.current
69
+ return self.encoder(h + marker)
70
+
71
+
72
  def _parse_pattern(spec, n):
73
  """Layer-sharing schedule. spec is either an int (number of UNIQUE
74
  physical layers, cycled to length n) or a comma string like "E0,E1,E0,E1"
 
91
 
92
  class Adapter(nn.Module):
93
  """Rank-r residual adapter applied after a (possibly reused) transformer
94
+ block: x + gate * up(gelu(down(x))). Both projections and the gate start on
95
+ an active gradient path. The up projection is initialized very small so the
96
+ initial adapter is a near-identity perturbation without the dead-gradient
97
+ state caused by zero-initializing both ``up`` and ``gate``."""
98
 
99
  def __init__(self, d, rank):
100
  super().__init__()
101
  self.down = nn.Linear(d, rank, bias=False)
102
  self.up = nn.Linear(rank, d, bias=False)
103
  nn.init.normal_(self.down.weight, std=0.02)
104
+ nn.init.normal_(self.up.weight, std=1e-3)
105
+ self.gate = nn.Parameter(torch.ones(1))
106
 
107
  def forward(self, x):
108
  return x + self.gate * self.up(nn.functional.gelu(self.down(x)))
 
111
  class SharedEncoder(nn.Module):
112
  """Encoder whose physical layers are reused according to a sharing pattern.
113
  Each POSITION in the depth keeps its own (a) rank-r adapter and (b) final
114
+ LayerNorm, so every logical depth can specialize while reusing the block.
115
  With pattern=None this is exactly n unique layers + a norm, matching the
116
  stock nn.TransformerEncoder param count (adapters/pos-norms are opt-in).
117
 
118
+ v1.7 depth-diversity options (all default-off):
119
  depth_emb: per-depth learned d-vector ADDED to the block input at each
120
  reuse (params = depth x d, zero-init so init == hard sharing).
121
  adapter_rank_ffn > 0: SPLIT adapters — instead of one post-block adapter,
122
  each depth owns a rank-`adapter_rank` adapter on the attention sublayer
123
  output and a rank-`adapter_rank_ffn` adapter on the FFN sublayer output
124
+ (patterning lives in the FFN).
125
  """
126
 
127
  def __init__(self, d, nhead, ffn, dropout, pattern, adapter_rank=0,
 
160
  if self.split:
161
  # norm_first decomposition of nn.TransformerEncoderLayer with
162
  # per-depth adapters applied to each sublayer OUTPUT (Houlsby
163
+ # placement)
164
  x = x + self.attn_adapters[k](
165
  layer._sa_block(layer.norm1(x), None, src_key_padding_mask))
166
  x = x + self.ffn_adapters[k](layer._ff_block(layer.norm2(x)))
 
241
  max_tgt=None,
242
  aux=False,
243
  global_ctx=False,
244
+ hierarchical_ctx=False,
245
+ section_layers=2,
246
+ section_ffn=None,
247
  func_time=False,
248
  ptr=False,
249
  beat=False,
 
264
  vocab_size = vocab_size or VOCAB.size
265
  max_tgt = max_tgt or MAX_TGT
266
  self.d_model = d_model
267
+ if global_ctx and hierarchical_ctx:
268
+ raise ValueError("global_ctx and hierarchical_ctx are mutually exclusive")
269
+ self.hierarchical_ctx = bool(hierarchical_ctx)
270
+ self._hierarchical_ctx = self.hierarchical_ctx
271
  # clean_phase (v1.6): the encoder never sees the 2 grid-phase channels
272
  # (in_ch stays N_MELS), and phase is injected into the DECODER memory
273
  # only, via a small projection. This removes the condition leak that
 
311
  enc_layer = nn.TransformerEncoderLayer(
312
  d_model, nhead, ffn, dropout, activation="gelu",
313
  batch_first=True, norm_first=True)
314
+ self.encoder = nn.TransformerEncoder(
315
+ enc_layer, enc_layers, nn.LayerNorm(d_model),
316
+ enable_nested_tensor=False)
317
  dec_layer = nn.TransformerDecoderLayer(
318
  d_model, nhead, ffn, dropout, activation="gelu",
319
  batch_first=True, norm_first=True)
 
351
  self.out = nn.Linear(d_model, vocab_size, bias=False)
352
  self.out.weight = self.tok_emb.weight # weight tying
353
  self.dropout = nn.Dropout(dropout)
354
+ # Auxiliary local rhythmic skeleton. In the hierarchical model its
355
+ # predicted occupancy is also projected back into decoder memory, so
356
+ # the event realizer is explicitly conditioned on a coarse audio-led
357
+ # skeleton rather than learning placement and pattern in one flat pass.
358
+ if hierarchical_ctx and not aux:
359
+ raise ValueError("hierarchical_ctx requires the auxiliary skeleton head")
360
  self.aux = nn.Linear(d_model, 1) if aux else None
361
+ self.skeleton_proj = nn.Linear(1, d_model) if hierarchical_ctx else None
362
+ if self.skeleton_proj is not None:
363
+ nn.init.zeros_(self.skeleton_proj.weight)
364
+ nn.init.zeros_(self.skeleton_proj.bias)
365
  # pointer alignment head: each generated note must point to its audio
366
  # frame (differentiable provenance; explainability that trains alignment)
367
  self.ptr = nn.Linear(d_model, d_model) if ptr else None
 
377
  if global_ctx:
378
  self.gsum_proj = nn.Linear(N_MELS, d_model)
379
  self.seg_emb = nn.Parameter(torch.zeros(2, d_model))
380
+ self.pos_emb = nn.Embedding(100, d_model) # relative song position
381
  self.gpos = nn.Parameter(torch.randn(128, d_model) * 0.02)
382
  else:
383
  self.gsum_proj = None
384
+ if hierarchical_ctx:
385
+ self.section_encoder = HierarchicalSectionEncoder(
386
+ d_model, nhead, section_ffn or (2 * d_model), dropout,
387
+ layers=section_layers,
388
+ )
389
+ self.hier_seg_emb = nn.Parameter(torch.zeros(2, d_model))
390
+ else:
391
+ self.section_encoder = None
392
  # functional time embeddings: the 1728 TIME tokens share a sinusoidal
393
  # basis + small projection instead of free embeddings (fewer params,
394
  # neighbouring times get similar representations)
 
401
  else:
402
  self.time_proj = None
403
 
404
+ def _local_input(self, mel):
405
+ audio = mel[:, :N_MELS] if self.clean_phase else mel
406
+ h = self.frontend(audio).transpose(1, 2) # (B, T/4, d)
407
+ return h + self.enc_pos[: h.shape[1]]
408
+
409
+ def encode_local(self, mel):
410
+ """Encode only the current window for beat/downbeat estimation."""
411
+ h = self._local_input(mel)
412
+ if self.section_encoder is not None:
413
+ h = h + self.hier_seg_emb[0]
414
+ return self.encoder(self.dropout(h))
415
+
416
  def encode(self, mel, gsum=None, pos_bucket=None):
417
  # mel: (B, C, T). clean_phase: C may be N_MELS (audio only) or N_MELS+2
418
  # (audio + 2 grid-phase channels); only the audio channels reach the
419
  # encoder, so the returned memory is CLEAN (phase-free) and safe for the
420
+ # beat head. gsum: (B, 100, n_mels) complete-song section summaries;
421
+ # pos_bucket: (B,) current relative song section in [0, 100).
422
+ h = self._local_input(mel)
423
+ if self.section_encoder is not None:
424
+ if gsum is None or pos_bucket is None:
425
+ raise ValueError("hierarchical_ctx requires whole-song summary and position")
426
+ local = self.encoder(self.dropout(h + self.hier_seg_emb[0]))
427
+ sections = self.section_encoder(gsum, pos_bucket) + self.hier_seg_emb[1]
428
+ return torch.cat([sections, local], dim=1)
429
  if self.gsum_proj is not None and gsum is not None:
430
  g = self.gsum_proj(gsum) + self.seg_emb[1] + self.gpos[: gsum.shape[1]]
431
  h = h + self.seg_emb[0]
 
438
  Adds a projection of the grid-phase channels to the audio-frame slice
439
  of memory; the beat head keeps reading the clean `memory`. No-op unless
440
  clean_phase is on and phase channels are present. Time-mode windows
441
+ (raw phase = -1) get valid=0 and exactly zero phase conditioning."""
 
 
 
 
 
 
 
 
442
  out = memory.clone()
443
+ L = WINDOW // 4
444
+ nch = N_MELS
445
+ if self.clean_phase and mel.shape[1] >= nch + 2:
446
+ ph = mel[:, nch:nch + 2] # [0,1); -1 marks no-grid frames
447
+ valid = (ph[:, :1] >= 0).float()
448
+ ph_in = torch.cat([ph.clamp(min=0.0), valid], dim=1)
449
+ p = self.phase_proj(ph_in)
450
+ # Mask *after* both biased convolutions. Masking only their input
451
+ # leaves a learned bias residual in gridless time-mode windows.
452
+ valid_out = nn.functional.adaptive_max_pool1d(valid, p.shape[-1])
453
+ p = (p * valid_out).transpose(1, 2)
454
+ L = p.shape[1]
455
+ out[:, -L:] = out[:, -L:] + p
456
+ if self.skeleton_proj is not None:
457
+ clean_local = memory[:, -L:]
458
+ skeleton = torch.sigmoid(self.aux(clean_local))
459
+ out[:, -L:] = out[:, -L:] + self.skeleton_proj(skeleton)
460
  return out
461
 
462
  def emb_matrix(self):
 
474
  L = tgt_in.shape[1]
475
  E = self.emb_matrix()
476
  h = nn.functional.embedding(tgt_in, E) * math.sqrt(self.d_model) + self.dec_pos[:L]
477
+ mask = torch.ones((L, L), dtype=torch.bool, device=tgt_in.device).triu(1)
478
  pad_mask = tgt_in == VOCAB.pad
479
  h = self.decoder(
480
  self.dropout(h),
 
502
  E = self.emb_matrix()
503
  import math as _m
504
  h = nn.functional.embedding(dec_in, E) * _m.sqrt(self.d_model) + self.dec_pos[:L]
505
+ dm = torch.ones((L, L), dtype=torch.bool, device=tgt.device).triu(1)
506
  h = self.decoder(self.dropout(h), dec_mem, tgt_mask=dm,
507
  tgt_key_padding_mask=dec_in == VOCAB.pad, tgt_is_causal=True)
508
  logits = h @ E.T
softchart/preprocess.py DELETED
@@ -1,156 +0,0 @@
1
- """Convert the HF dataset into per-song cache files: log-mel (.npy) + notes (.npz).
2
-
3
- Usage (on the training machine):
4
- python -m softchart.preprocess --out /workspace/softchart/cache [--split train]
5
- """
6
-
7
- import argparse
8
- import json
9
- import os
10
- from collections import Counter
11
-
12
- import numpy as np
13
-
14
- from .vocab import HOP, N_FFT, N_MELS, NOTE_CLASSES, NOTE_TYPE_MAP, SR, COURSES
15
-
16
-
17
- def to_mono_22k(audio):
18
- """Handle both datasets<=3 dict audio and datasets>=4 torchcodec AudioDecoder."""
19
- import torch
20
-
21
- if isinstance(audio, dict):
22
- arr = np.asarray(audio["array"], dtype=np.float32)
23
- if arr.ndim == 2:
24
- arr = arr.mean(axis=0)
25
- sr = audio["sampling_rate"]
26
- else: # torchcodec AudioDecoder
27
- samples = audio.get_all_samples()
28
- arr = samples.data.to(torch.float32).mean(dim=0).numpy()
29
- sr = samples.sample_rate
30
- if sr != SR:
31
- import librosa
32
-
33
- arr = librosa.resample(arr, orig_sr=sr, target_sr=SR, res_type="soxr_hq")
34
- return torch.from_numpy(np.ascontiguousarray(arr))[None] # (1, T)
35
-
36
-
37
- _mel_fb = None
38
- _window = None
39
-
40
-
41
- def logmel(wav):
42
- """(1, T) float32 -> (n_mels, frames) float16 log-mel (torch.stft + librosa fb)."""
43
- global _mel_fb, _window
44
- import torch
45
-
46
- if _mel_fb is None:
47
- import librosa
48
-
49
- fb = librosa.filters.mel(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
50
- _mel_fb = torch.from_numpy(fb) # (n_mels, n_fft//2+1)
51
- _window = torch.hann_window(N_FFT)
52
- with torch.no_grad():
53
- spec = torch.stft(
54
- wav, N_FFT, hop_length=HOP, window=_window, center=True, return_complex=True
55
- )[0]
56
- power = spec.abs().pow(2) # (freq, frames)
57
- m = torch.log(_mel_fb @ power + 1e-5)
58
- return m.numpy().astype(np.float16)
59
-
60
-
61
- def extract_notes(course_struct):
62
- """course struct -> (times, classes, level) or None."""
63
- if course_struct is None or not course_struct.get("segments"):
64
- return None
65
- times, classes, bpms = [], [], []
66
- unknown = Counter()
67
- for seg in course_struct["segments"]:
68
- for n in seg.get("notes") or []:
69
- nt = n["note_type"]
70
- canon = NOTE_TYPE_MAP.get(nt) or NOTE_TYPE_MAP.get(nt.lower())
71
- if canon is None:
72
- unknown[nt] += 1
73
- continue
74
- times.append(n["timestamp"])
75
- classes.append(NOTE_CLASSES.index(canon))
76
- if n.get("bpm"):
77
- bpms.append(n["bpm"])
78
- if not times:
79
- return None
80
- order = np.argsort(times, kind="stable")
81
- return (
82
- np.asarray(times, np.float64)[order],
83
- np.asarray(classes, np.int8)[order],
84
- int(course_struct.get("level") or 0),
85
- float(np.median(bpms)) if bpms else 0.0,
86
- unknown,
87
- )
88
-
89
-
90
- def main():
91
- ap = argparse.ArgumentParser()
92
- ap.add_argument("--out", default="cache")
93
- ap.add_argument("--splits", nargs="+", default=["train", "test"])
94
- args = ap.parse_args()
95
-
96
- from datasets import Audio, load_dataset
97
-
98
- ds = load_dataset("JacobLinCool/taiko-1000-parsed")
99
- ds = ds.cast_column("audio", Audio(sampling_rate=SR))
100
- os.makedirs(args.out, exist_ok=True)
101
- index = []
102
- unknown_total = Counter()
103
-
104
- for split in args.splits:
105
- d = ds[split]
106
- for i in range(len(d)):
107
- row = d[i]
108
- sid = f"{split}_{i:05d}"
109
- mel_path = os.path.join(args.out, f"{sid}.mel.npy")
110
- notes_path = os.path.join(args.out, f"{sid}.notes.npz")
111
- entry = {
112
- "id": sid,
113
- "split": split,
114
- "title": (row.get("metadata") or {}).get("TITLE", ""),
115
- "genre": (row.get("metadata") or {}).get("GENRE", ""),
116
- "courses": {},
117
- }
118
- arrays = {}
119
- for c in COURSES:
120
- ex = extract_notes(row.get(c))
121
- if ex is None:
122
- continue
123
- times, classes, level, bpm, unknown = ex
124
- unknown_total.update(unknown)
125
- arrays[f"{c}_t"] = times
126
- arrays[f"{c}_c"] = classes
127
- entry["courses"][c] = {
128
- "level": level,
129
- "n_notes": int(len(times)),
130
- "bpm": bpm,
131
- }
132
- if not arrays:
133
- print(f"skip {sid}: no charts")
134
- continue
135
- if not (os.path.exists(mel_path) and os.path.exists(notes_path)):
136
- try:
137
- mel = logmel(to_mono_22k(row["audio"]))
138
- except Exception as e:
139
- raise RuntimeError(f"{sid}: target-rate audio decode failed") from e
140
- np.save(mel_path, mel)
141
- np.savez(notes_path, **arrays)
142
- entry["n_frames"] = int(mel.shape[1])
143
- else:
144
- entry["n_frames"] = int(np.load(mel_path, mmap_mode="r").shape[1])
145
- index.append(entry)
146
- if i % 50 == 0:
147
- print(f"{split} {i}/{len(d)}", flush=True)
148
-
149
- with open(os.path.join(args.out, "index.json"), "w") as f:
150
- json.dump(index, f)
151
- print("unknown note types:", dict(unknown_total))
152
- print(f"done: {len(index)} songs")
153
-
154
-
155
- if __name__ == "__main__":
156
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/proxies.py DELETED
@@ -1,238 +0,0 @@
1
- """Automated proxies for playability, groove, and pattern structure.
2
-
3
- Every proxy is computable without human input, from (events, bpm[, mel]).
4
- Together with rhythm.py metrics they form the scorecard that closes the
5
- evaluate -> diagnose -> improve loop.
6
-
7
- Proxy design notes:
8
- - playability = physical feasibility on the drum (min gaps, stream length,
9
- big-note crowding) + learnable structure (motif reuse, compressibility)
10
- - groove = alignment with the metrical grid (strong-beat rate, accent placement)
11
- + audio grounding (onset-envelope energy at note times)
12
- Reference values come from ground-truth charts of the same course (see scorecard).
13
- """
14
-
15
- import zlib
16
-
17
- import numpy as np
18
-
19
- HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
20
- BIG_CLASSES = ("don_big", "ka_big")
21
-
22
-
23
- def _hits(events):
24
- return sorted((t, c) for t, c in events if c in HIT_CLASSES)
25
-
26
-
27
- def min_gap_violation_rate(events, min_gap=0.045):
28
- """Fraction of consecutive hits closer than a physically playable gap."""
29
- h = _hits(events)
30
- if len(h) < 2:
31
- return 0.0
32
- ioi = np.diff([t for t, _ in h])
33
- return float(np.mean(ioi < min_gap))
34
-
35
-
36
- def stream_p99_nps(events, win=2.0):
37
- """99th percentile of local density (notes/sec in rolling windows) — stamina proxy."""
38
- t = np.array([x for x, _ in _hits(events)])
39
- if len(t) < 4:
40
- return 0.0
41
- counts = [np.sum((t >= x) & (t < x + win)) for x in np.arange(t[0], t[-1], 0.5)]
42
- return float(np.percentile(counts, 99) / win) if counts else 0.0
43
-
44
-
45
- def big_crowding_rate(events, bpm):
46
- """Fraction of big notes followed by another hit within 0.45 beat.
47
-
48
- Big notes are played with both hands; crowding them against the next note
49
- is physically awkward and rarely done in official charts.
50
- """
51
- h = _hits(events)
52
- bigs = [(i, t) for i, (t, c) in enumerate(h) if c in BIG_CLASSES]
53
- if not bigs or not bpm or bpm <= 0:
54
- return None
55
- thresh = 0.45 * 60.0 / bpm
56
- crowded = sum(1 for i, t in bigs if i + 1 < len(h) and h[i + 1][0] - t < thresh)
57
- return crowded / len(bigs)
58
-
59
-
60
- def big_note_rate(events):
61
- h = _hits(events)
62
- if not h:
63
- return 0.0
64
- return sum(1 for _, c in h if c in BIG_CLASSES) / len(h)
65
-
66
-
67
- def _symbol_stream(events, bpm):
68
- """Quantized (ioi-class, note-type) symbol stream for structure metrics."""
69
- h = _hits(events)
70
- if len(h) < 3 or not bpm or bpm <= 0:
71
- return []
72
- beat = 60.0 / bpm
73
- syms = []
74
- for i in range(1, len(h)):
75
- ioi = h[i][0] - h[i - 1][0]
76
- frac = ioi / beat
77
- # classify IOI into musical classes
78
- classes = [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1.0, 1.5, 2.0]
79
- j = int(np.argmin([abs(frac - c) / c for c in classes]))
80
- ioi_cls = j if abs(frac - classes[j]) / classes[j] < 0.25 else len(classes)
81
- syms.append((ioi_cls, h[i][1]))
82
- return syms
83
-
84
-
85
- def motif_reuse(events, bpm, n=4):
86
- """Fraction of n-grams (rhythm+type) that occur at least twice in the chart.
87
-
88
- Human charts repeat and transform motifs; random note salads do not.
89
- """
90
- syms = _symbol_stream(events, bpm)
91
- if len(syms) < n + 2:
92
- return None
93
- grams = [tuple(syms[i : i + n]) for i in range(len(syms) - n + 1)]
94
- from collections import Counter
95
-
96
- counts = Counter(grams)
97
- return float(sum(1 for g in grams if counts[g] >= 2) / len(grams))
98
-
99
-
100
- def compression_ratio(events, bpm):
101
- """zlib-compressed size / raw size of the symbol stream. Lower = more structured."""
102
- syms = _symbol_stream(events, bpm)
103
- if len(syms) < 16:
104
- return None
105
- raw = bytes(b for s in syms for b in (s[0], HIT_CLASSES.index(s[1])))
106
- return float(len(zlib.compress(raw, 9)) / len(raw))
107
-
108
-
109
- def strong_beat_rate(events, bpm, phase=None, tol=0.03):
110
- """Fraction of hits on integer beats (downbeat proxy needs measures; beat is enough)."""
111
- from .rhythm import estimate_phase
112
-
113
- t = np.array([x for x, _ in _hits(events)])
114
- if len(t) < 5 or not bpm or bpm <= 0:
115
- return None
116
- beat = 60.0 / bpm
117
- if phase is None:
118
- phase = estimate_phase(t, beat)
119
- rel = (t - phase) / beat
120
- return float(np.mean(np.abs(rel - np.round(rel)) * beat < tol))
121
-
122
-
123
- def accent_on_strong(events, bpm, phase=None, tol=0.03):
124
- """Fraction of BIG notes that fall on integer beats — accent placement proxy."""
125
- from .rhythm import estimate_phase
126
-
127
- h = _hits(events)
128
- tb = np.array([t for t, c in h if c in BIG_CLASSES])
129
- if len(tb) < 3 or not bpm or bpm <= 0:
130
- return None
131
- beat = 60.0 / bpm
132
- if phase is None:
133
- phase = estimate_phase(np.array([t for t, _ in h]), beat)
134
- rel = (tb - phase) / beat
135
- return float(np.mean(np.abs(rel - np.round(rel)) * beat < tol))
136
-
137
-
138
- def audio_energy_alignment(events, mel, fps):
139
- """Mean z-scored spectral-flux strength at hit times (GT-free audio grounding)."""
140
- t = np.array([x for x, _ in _hits(events)])
141
- if len(t) < 5:
142
- return None
143
- flux = np.maximum(0.0, np.diff(mel.astype(np.float32), axis=1)).sum(axis=0)
144
- flux = (flux - flux.mean()) / (flux.std() + 1e-9)
145
- fr = np.clip(np.round(t * fps).astype(int), 1, len(flux) - 1)
146
- return float(np.mean(np.maximum.reduce([flux[fr - 1], flux[fr], flux[np.minimum(fr + 1, len(flux) - 1)]])))
147
-
148
-
149
- def _pair_spans(events):
150
- """Pair span-start/end events into (t0, t1) tuples (for GT event streams)."""
151
- out = []
152
- open_t = None
153
- for t, c in sorted(events):
154
- if c in ("roll", "roll_big", "balloon") and open_t is None:
155
- open_t = t
156
- elif c == "end" and open_t is not None:
157
- out.append((open_t, t))
158
- open_t = None
159
- return out
160
-
161
-
162
- def span_stats(events, spans=None):
163
- """Span-length distribution + rate. Added after a visual audit found
164
- generated balloons ~10s vs GT p50 ~1s (a metric blind spot)."""
165
- if spans is None:
166
- pairs = _pair_spans(events)
167
- else:
168
- pairs = [(s["t0"], s["t1"]) if isinstance(s, dict) else tuple(s) for s in spans]
169
- h = _hits(events)
170
- dur_min = max((h[-1][0] - h[0][0]) / 60.0, 1e-6) if len(h) > 1 else None
171
- if not pairs or dur_min is None:
172
- return {"span_p90_len": None, "span_per_min": 0.0 if dur_min else None}
173
- lens = np.array([b - a for a, b in pairs])
174
- return {"span_p90_len": float(np.percentile(lens, 90)),
175
- "span_per_min": float(len(pairs) / dur_min)}
176
-
177
-
178
- def compute_proxies(events, bpm, mel=None, fps=None, spans=None):
179
- """All proxies for one chart. Returns dict (None where not computable)."""
180
- from .rhythm import grid_consistency, tuplet_evenness
181
-
182
- t = [x for x, _ in _hits(events)]
183
- out = {
184
- "nps": len(t) / max(t[-1] - t[0], 1e-6) if len(t) > 1 else 0.0,
185
- "min_gap_violation": min_gap_violation_rate(events),
186
- "stream_p99_nps": stream_p99_nps(events),
187
- "big_rate": big_note_rate(events),
188
- "big_crowding": big_crowding_rate(events, bpm),
189
- "motif_reuse": motif_reuse(events, bpm),
190
- "compression": compression_ratio(events, bpm),
191
- "strong_beat": strong_beat_rate(events, bpm),
192
- "accent_on_strong": accent_on_strong(events, bpm),
193
- }
194
- g = grid_consistency(t, bpm)
195
- if g:
196
- out["offgrid_rate"] = 1.0 - g["on_grid_frac"]
197
- out["grid_dev_ms"] = g["mean_dev_ms"]
198
- tp = tuplet_evenness(t, bpm)
199
- if tp:
200
- out["tuplet_cv"] = tp["cv_mean"]
201
- if mel is not None and fps:
202
- out["energy_align"] = audio_energy_alignment(events, mel, fps)
203
- out.update(span_stats(events, spans))
204
- return out
205
-
206
-
207
- # direction: +1 higher is better, -1 lower is better, 0 match GT reference
208
- PROXY_DIRECTION = {
209
- "nps": 0, "min_gap_violation": -1, "stream_p99_nps": 0, "big_rate": 0,
210
- "big_crowding": -1, "motif_reuse": 0, "compression": 0, "strong_beat": 0,
211
- "accent_on_strong": 0, "offgrid_rate": -1, "grid_dev_ms": -1,
212
- "tuplet_cv": -1, "energy_align": 1, "span_p90_len": 0, "span_per_min": 0,
213
- }
214
-
215
-
216
- def score_against_reference(proxy_row, ref_stats):
217
- """Normalized deviation of each proxy vs the GT reference distribution.
218
-
219
- ref_stats: {proxy: {"p25": .., "p50": .., "p75": ..}} per course.
220
- Returns {proxy: score} where score = robust z of |dev| (0 = at GT median;
221
- for directional proxies, only penalize the bad direction).
222
- """
223
- scores = {}
224
- for k, v in proxy_row.items():
225
- if v is None or k not in ref_stats:
226
- continue
227
- r = ref_stats[k]
228
- iqr = max(r["p75"] - r["p25"], 1e-6)
229
- d = PROXY_DIRECTION.get(k, 0)
230
- dev = (v - r["p50"]) / iqr
231
- if d == -1:
232
- dev = max(0.0, dev) # only worse-than-GT (higher) penalized
233
- elif d == 1:
234
- dev = max(0.0, -dev)
235
- else:
236
- dev = abs(dev)
237
- scores[k] = float(dev)
238
- return scores
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/tja.py CHANGED
@@ -1,114 +1,143 @@
1
- """TJA emission for slot-mode charts: slot indices ARE the chart.
2
 
3
  No quantization happens here — generate_song_slot() already produced exact
4
- lattice positions (measure, slot in 0..95), so this writer just prints them.
5
- Preview rendering and the .tja are identical by construction. Piecewise
6
- grids additionally emit #BPMCHANGE lines between measures.
7
  """
8
 
9
  import numpy as np
10
 
 
11
  from .vocab import SLOTS
12
 
13
  CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
14
  "roll": "5", "roll_big": "6", "balloon": "7"}
15
 
16
 
17
- def grid_measure_starts(grid, n_measures):
18
- """Return measure boundary times for a slot grid, extending if needed."""
19
- db = np.asarray(grid.get("downbeats", []), dtype=float)
20
- if len(db) >= n_measures + 1:
21
- return db[: n_measures + 1]
22
- if len(db) >= 2:
23
- step = float(np.median(np.diff(db)))
24
- start = float(db[0])
25
- else:
26
- step = 240.0 / float(grid["bpm"])
27
- start = float(db[0]) if len(db) else 0.0
28
- return start + np.arange(n_measures + 1, dtype=float) * step
29
 
30
 
31
- def gogo_measure_mask(plan, measure_starts, n_measures):
32
- """Map plan climax blocks (flag == 2) to TJA measures."""
33
- mask = [False] * n_measures
34
- if not plan:
35
- return mask
36
- starts = np.asarray(measure_starts, dtype=float)
37
- if len(starts) < n_measures + 1:
38
- return mask
39
- for block in plan:
40
- if len(block) < 4 or int(block[3]) != 2:
41
- continue
42
- a, b = float(block[0]), float(block[1])
43
- if b <= a:
44
- continue
45
- for m in range(n_measures):
46
- if starts[m] < b and starts[m + 1] > a:
47
- mask[m] = True
48
- return mask
49
-
50
 
51
- def append_measure_with_gogo(lines, measure_line, measure_idx, gogo_mask, in_gogo):
52
- """Append a measure line, opening/closing #GOGO commands at boundaries."""
53
- want_gogo = bool(gogo_mask[measure_idx]) if measure_idx < len(gogo_mask) else False
54
- if in_gogo and not want_gogo:
55
- lines.append("#GOGOEND")
56
- in_gogo = False
57
- if want_gogo and not in_gogo:
58
- lines.append("#GOGOSTART")
59
- in_gogo = True
60
- lines.append(measure_line)
61
- return in_gogo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- def write_tja_slots(gen, grid, title, course, level, wave, out_path=None,
65
- balloon_count=10, plan=None):
66
- """gen: generate_song_slot() result; grid: fit_grid() result (or a dict
67
- with downbeats+bpm). Returns the TJA text (and writes it if out_path)."""
68
  slots = {}
69
  for me, sl, cls in gen["hits_slots"]:
70
- slots.setdefault((me, sl), CHAR[cls])
 
 
 
 
71
  for m0, s0, m1, s1, typ in gen.get("spans_slots", []):
72
- a = (m0, s0)
73
- while a in slots: # span start yields to hits: next free slot
74
- a = (a[0] + (a[1] + 1) // SLOTS, (a[1] + 1) % SLOTS)
75
- b = (m1, s1)
76
- while b in slots or b <= a:
77
- b = (b[0] + (b[1] + 1) // SLOTS, (b[1] + 1) % SLOTS)
 
 
 
 
78
  slots[a] = CHAR[typ]
79
  slots[b] = "8"
80
- n_meas = max(gen.get("n_measures", 0),
81
- (max(m for m, _ in slots) + 1) if slots else 1)
82
- # piecewise-tempo grids: emit #BPMCHANGE whenever the per-measure BPM
83
- # (from consecutive fitted barlines) moves; TJA measure lines are unchanged
84
- db = np.asarray(grid.get("downbeats", []), float) if grid.get("piecewise") else None
85
- measure_starts = grid_measure_starts(grid, n_meas)
86
- gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas)
 
 
 
 
 
 
 
 
 
87
  lines = []
88
- cur_bpm = float(grid["bpm"])
89
- in_gogo = False
 
 
 
90
  for m in range(n_meas):
91
- if in_gogo and not gogo_mask[m]:
92
- lines.append("#GOGOEND")
93
- in_gogo = False
94
- if db is not None and m + 1 < len(db):
95
- bpm_m = 240.0 / (db[m + 1] - db[m])
96
- if abs(bpm_m - round(bpm_m)) < 0.05:
97
- bpm_m = float(round(bpm_m))
98
- if abs(bpm_m - cur_bpm) > 0.05:
99
- lines.append(f"#BPMCHANGE {bpm_m:g}")
100
- cur_bpm = bpm_m
101
- in_gogo = append_measure_with_gogo(
102
- lines, "".join(slots.get((m, k), "0") for k in range(SLOTS)) + ",",
103
- m, gogo_mask, in_gogo)
104
- if in_gogo:
105
- lines.append("#GOGOEND")
 
106
  balloons = [balloon_count] * sum(1 for s in gen.get("spans_slots", [])
107
  if s[4] == "balloon")
108
- offset = float(grid["downbeats"][0]) if len(grid.get("downbeats", [])) else 0.0
109
  tja = "\n".join([
110
- f"TITLE:{title} (SoftChart)", f"BPM:{grid['bpm']:g}", f"WAVE:{wave}",
111
- f"OFFSET:{-offset:.3f}",
112
  f"COURSE:{'Oni' if course == 'oni' else str(course).capitalize()}",
113
  f"LEVEL:{level}",
114
  f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
 
1
+ """Meter-aware TJA emission for slot-mode charts.
2
 
3
  No quantization happens here — generate_song_slot() already produced exact
4
+ lattice positions (96 slots per 4/4 measure), so this writer just prints them.
5
+ Preview rendering and the .tja are identical by construction. Authored measure
6
+ edges determine exact per-measure tempo; meter changes emit #MEASURE directives.
7
  """
8
 
9
  import numpy as np
10
 
11
+ from .meter import resolve_meter_layout
12
  from .vocab import SLOTS
13
 
14
  CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
15
  "roll": "5", "roll_big": "6", "balloon": "7"}
16
 
17
 
18
+ def _format_number(value):
19
+ """Emit enough digits for a float to round-trip through the TJA text."""
20
+ value = float(value)
21
+ if not np.isfinite(value):
22
+ raise ValueError("TJA numeric fields must be finite")
23
+ if value == 0.0:
24
+ value = 0.0 # avoid a cosmetic negative zero in OFFSET
25
+ return format(value, ".17g")
 
 
 
 
26
 
27
 
28
+ def write_tja_slots(gen, grid, title, course, level, wave, out_path=None,
29
+ balloon_count=10):
30
+ """Serialize a slot generation on an explicit trusted meter timeline.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
+ ``grid`` must contain exact rational meter metadata and ``measure_edges``
33
+ with one trusted terminal edge. Returns the TJA text and optionally writes
34
+ it to ``out_path``.
35
+ """
36
+ if int(gen.get("slots_per_4_4", SLOTS)) != SLOTS:
37
+ raise ValueError(f"expected {SLOTS} slots per 4/4 measure")
38
+ n_meas = max(int(gen.get("n_measures", 0)),
39
+ (max((m for m, _, _ in gen.get("hits_slots", [])), default=-1) + 1),
40
+ (max((m1 for _, _, m1, _, _ in gen.get("spans_slots", [])),
41
+ default=-1) + 1), 1)
42
+ fields = ("measure_slots", "measure_num", "measure_den")
43
+ gen_has_layout = any(name in gen for name in fields)
44
+ grid_has_layout = any(name in grid for name in fields) \
45
+ or "meter" in grid or "beats_per_measure" in grid
46
+ gen_layout = resolve_meter_layout(gen, n_meas) if gen_has_layout else None
47
+ grid_layout = resolve_meter_layout(grid, n_meas) if grid_has_layout else None
48
+ if gen_layout is not None and grid_layout is not None:
49
+ gen_slots, gen_num, gen_den, _ = gen_layout
50
+ grid_slots, grid_num, grid_den, _ = grid_layout
51
+ same_fraction = all(
52
+ int(gn) * int(gd) == int(gdn) * int(gnd)
53
+ for gn, gnd, gdn, gd in zip(
54
+ gen_num, gen_den, grid_num, grid_den)
55
+ )
56
+ if not np.array_equal(gen_slots, grid_slots) or not same_fraction:
57
+ raise ValueError("generated meter layout disagrees with the timing grid")
58
+ layout = grid_layout if grid_layout is not None else gen_layout
59
+ if layout is None:
60
+ raise ValueError(
61
+ "TJA slot export requires exact meter metadata; refusing to assume 4/4")
62
+ slots_per_measure, measure_num, measure_den, quarter_beats = layout
63
+ offsets = np.concatenate([[0], np.cumsum(slots_per_measure)])
64
 
65
+ def flat(measure, slot):
66
+ measure, slot = int(measure), int(slot)
67
+ if not 0 <= measure < n_meas:
68
+ raise ValueError(f"measure index {measure} is outside 0..{n_meas - 1}")
69
+ if not 0 <= slot < slots_per_measure[measure]:
70
+ raise ValueError(
71
+ f"slot {slot} is outside measure {measure}'s "
72
+ f"0..{slots_per_measure[measure] - 1} range"
73
+ )
74
+ return int(offsets[measure] + slot)
75
 
 
 
 
 
76
  slots = {}
77
  for me, sl, cls in gen["hits_slots"]:
78
+ position = flat(me, sl)
79
+ if position in slots:
80
+ raise ValueError(
81
+ f"slot collision at measure {int(me)}, slot {int(sl)}")
82
+ slots[position] = CHAR[cls]
83
  for m0, s0, m1, s1, typ in gen.get("spans_slots", []):
84
+ a = flat(m0, s0)
85
+ b = flat(m1, s1)
86
+ if b <= a:
87
+ raise ValueError("span end must be strictly after its start")
88
+ if a in slots:
89
+ raise ValueError(
90
+ f"slot collision at span start measure {int(m0)}, slot {int(s0)}")
91
+ if b in slots:
92
+ raise ValueError(
93
+ f"slot collision at span end measure {int(m1)}, slot {int(s1)}")
94
  slots[a] = CHAR[typ]
95
  slots[b] = "8"
96
+
97
+ # Slot output is timing-exact only when every start and the terminal edge
98
+ # are explicit. Never fall back to BPM/downbeat extrapolation here: that
99
+ # would make the serialized TJA disagree with the lattice used to decode.
100
+ if "measure_edges" not in grid:
101
+ raise ValueError(
102
+ "slot TJA export requires explicit measure_edges including the "
103
+ "trusted terminal edge"
104
+ )
105
+ tempo_edges = np.asarray(grid["measure_edges"], dtype=float)
106
+ if (tempo_edges.ndim != 1 or len(tempo_edges) != n_meas + 1
107
+ or not np.all(np.isfinite(tempo_edges))
108
+ or np.any(np.diff(tempo_edges) <= 0)):
109
+ raise ValueError(
110
+ "measure_edges must contain one finite, increasing edge per "
111
+ "measure plus the final edge")
112
  lines = []
113
+ base_bpm = float(grid["bpm"])
114
+ if not np.isfinite(base_bpm) or base_bpm <= 0:
115
+ raise ValueError("grid bpm must be positive and finite")
116
+ cur_bpm = base_bpm
117
+ cur_meter = (4, 4)
118
  for m in range(n_meas):
119
+ duration = float(tempo_edges[m + 1] - tempo_edges[m])
120
+ if not np.isfinite(duration) or duration <= 0:
121
+ raise ValueError(f"measure {m} duration must be positive and finite")
122
+ bpm_m = 60.0 * float(quarter_beats[m]) / duration
123
+ # No integer snap and no perceptual threshold: even a small tempo
124
+ # delta accumulates into a real chart/audio timing error.
125
+ if bpm_m != cur_bpm:
126
+ lines.append(f"#BPMCHANGE {_format_number(bpm_m)}")
127
+ cur_bpm = bpm_m
128
+ meter = (int(measure_num[m]), int(measure_den[m]))
129
+ if meter != cur_meter:
130
+ lines.append(f"#MEASURE {meter[0]}/{meter[1]}")
131
+ cur_meter = meter
132
+ start = int(offsets[m])
133
+ lines.append("".join(slots.get(start + k, "0")
134
+ for k in range(int(slots_per_measure[m]))) + ",")
135
  balloons = [balloon_count] * sum(1 for s in gen.get("spans_slots", [])
136
  if s[4] == "balloon")
137
+ offset = float(tempo_edges[0])
138
  tja = "\n".join([
139
+ f"TITLE:{title} (SoftChart)", f"BPM:{_format_number(base_bpm)}",
140
+ f"WAVE:{wave}", f"OFFSET:{_format_number(-offset)}",
141
  f"COURSE:{'Oni' if course == 'oni' else str(course).capitalize()}",
142
  f"LEVEL:{level}",
143
  f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
softchart/train.py DELETED
@@ -1,433 +0,0 @@
1
- """Training loop.
2
-
3
- python -m softchart.train --cache /workspace/softchart/cache --out runs/base
4
- """
5
-
6
- import argparse
7
- import json
8
- import math
9
- import os
10
- import time
11
-
12
- import torch
13
- import torch.nn.functional as F
14
- from torch.utils.data import DataLoader
15
-
16
- from .data import ChartWindowDataset, collate, load_split_ids
17
- from .model import ChartModel, count_params
18
- from .vocab import VOCAB
19
-
20
-
21
- # class-balanced weights for rare note types (~1/sqrt(train frequency), capped)
22
- TYPE_WEIGHTS = {"don": 1.0, "ka": 1.23, "don_big": 3.19, "ka_big": 5.29,
23
- "roll": 7.18, "roll_big": 8.0, "balloon": 8.0, "end": 5.0}
24
-
25
-
26
- def build_class_weights(device, cap=8.0):
27
- w = torch.ones(VOCAB.size, device=device)
28
- for name, weight in TYPE_WEIGHTS.items():
29
- w[VOCAB.note[name]] = min(weight, cap)
30
- return w
31
-
32
-
33
- def loss_fn(logits, tgt, loss_mask, label_smoothing=0.1, class_weight=None):
34
- # logits: (B, L-1, V) predicting tgt[:, 1:]
35
- gold = tgt[:, 1:]
36
- mask = loss_mask[:, 1:]
37
- ls = F.cross_entropy(
38
- logits.reshape(-1, logits.shape[-1]),
39
- gold.reshape(-1),
40
- reduction="none",
41
- label_smoothing=label_smoothing,
42
- weight=class_weight,
43
- ).reshape(gold.shape)
44
- return (ls * mask).sum() / mask.sum().clamp(min=1)
45
-
46
-
47
- def corrupt_events(tgt, loss_mask, rng_gen, hard=False):
48
- """Synthetic negative for DPO. hard=True keeps corruptions NEAR the snap
49
- tolerance (small jitter, few swaps) so negatives resemble the policy's own
50
- mistakes rather than being trivially separable (fixes dpo_loss->0 collapse)."""
51
- from .vocab import WINDOW
52
-
53
- rej = tgt.clone()
54
- ev = loss_mask.clone()
55
- t0 = VOCAB.time0
56
- is_time = (rej >= t0) & (rej < t0 + WINDOW) & ev
57
- if hard: # +-1..3 frames (~12-35ms, straddles the snap grid) on 60% of notes
58
- jit = torch.randint(-3, 4, rej.shape, device=rej.device, generator=rng_gen)
59
- jit = torch.where(jit == 0, torch.ones_like(jit), jit)
60
- do_t = torch.rand(rej.shape, device=rej.device, generator=rng_gen) < 0.6
61
- jit = torch.where(do_t, jit, torch.zeros_like(jit))
62
- swap_p = 0.12
63
- else:
64
- jit = torch.randint(-5, 6, rej.shape, device=rej.device, generator=rng_gen)
65
- jit = torch.where(jit.abs() < 2, jit.sign() * 2, jit)
66
- swap_p = 0.25
67
- rej = torch.where(is_time, (rej + jit).clamp(t0, t0 + WINDOW - 1), rej)
68
- hit0 = VOCAB.note["don"]
69
- is_hit = (rej >= hit0) & (rej < hit0 + 4) & ev
70
- swap = torch.randint(0, 4, rej.shape, device=rej.device, generator=rng_gen) + hit0
71
- do = torch.rand(rej.shape, device=rej.device, generator=rng_gen) < swap_p
72
- rej = torch.where(is_hit & do, swap, rej)
73
- return rej
74
-
75
-
76
- def seq_logprob(model, mel, tgt, mask):
77
- import torch.nn.functional as F
78
-
79
- logits = model(mel, tgt)
80
- lp = F.log_softmax(logits.float(), dim=-1)
81
- gold = tgt[:, 1:]
82
- g = lp.gather(-1, gold.unsqueeze(-1)).squeeze(-1)
83
- return (g * mask[:, 1:]).sum(1)
84
-
85
-
86
- @torch.no_grad()
87
- def validate(model, loader, device):
88
- model.eval()
89
- tot, n = 0.0, 0
90
- for mel, tgt, mask, _aux, gsum, posb, _beat, _in in loader:
91
- mel, tgt, mask = mel.to(device), tgt.to(device), mask.to(device)
92
- gsum = gsum.to(device) if gsum is not None else None
93
- with torch.autocast("cuda", dtype=torch.bfloat16):
94
- logits = model(mel, tgt, gsum=gsum, pos_bucket=posb.to(device))
95
- l = loss_fn(logits, tgt, mask, label_smoothing=0.0)
96
- tot += l.item() * mel.shape[0]
97
- n += mel.shape[0]
98
- model.train()
99
- return tot / max(n, 1)
100
-
101
-
102
- def main():
103
- ap = argparse.ArgumentParser()
104
- ap.add_argument("--cache", required=True)
105
- ap.add_argument("--out", required=True)
106
- ap.add_argument("--steps", type=int, default=60000)
107
- ap.add_argument("--batch", type=int, default=32)
108
- ap.add_argument("--lr", type=float, default=3e-4)
109
- ap.add_argument("--warmup", type=int, default=1000)
110
- ap.add_argument("--cond-drop", type=float, default=0.15)
111
- ap.add_argument("--no-augment", action="store_true")
112
- ap.add_argument("--balanced", action="store_true", help="class-balanced type loss")
113
- ap.add_argument("--balance-cap", type=float, default=8.0,
114
- help="cap for class weights (v3: 2.5 after v2 over-corrected)")
115
- ap.add_argument("--ctx", action="store_true", help="prev-window context tokens")
116
- ap.add_argument("--aux", action="store_true", help="onset-heatmap auxiliary head")
117
- ap.add_argument("--global-ctx", action="store_true",
118
- help="whole-song summary + window-position context (v4)")
119
- ap.add_argument("--importance-sampling", action="store_true",
120
- help="bias window sampling toward informative regions (v4)")
121
- ap.add_argument("--d-model", type=int, default=512)
122
- ap.add_argument("--enc-layers", type=int, default=6)
123
- ap.add_argument("--dec-layers", type=int, default=6)
124
- ap.add_argument("--ffn", type=int, default=2048)
125
- ap.add_argument("--func-time", action="store_true",
126
- help="functional (sinusoidal-basis) TIME token embeddings")
127
- ap.add_argument("--emb-factor", type=int, default=0,
128
- help="ALBERT-style factorized embeddings (e.g. 64); 0 = off")
129
- ap.add_argument("--distill", default=None,
130
- help="teacher checkpoint for knowledge distillation")
131
- ap.add_argument("--use-aug", action="store_true",
132
- help="include waveform speed-augmented variants in training")
133
- ap.add_argument("--init-from", default=None,
134
- help="initialize matching weights (decoder/tok_emb) from a checkpoint")
135
- ap.add_argument("--kd-alpha", type=float, default=0.5, help="CE weight (rest = KL)")
136
- ap.add_argument("--kd-tau", type=float, default=2.0, help="distillation temperature")
137
- ap.add_argument("--tempo-aug", action="store_true",
138
- help="rhythm-preserving tempo/pitch augmentation in mel domain")
139
- ap.add_argument("--sibling", action="store_true",
140
- help="easier-course skeleton hint in the prefix (easy subset-of hard)")
141
- ap.add_argument("--style", action="store_true",
142
- help="charting-intent style token (cache/styles.json)")
143
- ap.add_argument("--align", action="store_true",
144
- help="pointer alignment loss: notes must attend to their onset frame")
145
- ap.add_argument("--beat-head", action="store_true",
146
- help="beat/downbeat auxiliary head (labels from TJA measures)")
147
- ap.add_argument("--beat-weight", type=float, default=0.1,
148
- help="beat-head loss weight (raise for a dedicated beat model)")
149
- ap.add_argument("--beat-hires", action="store_true",
150
- help="frame-resolution beat head (11.6 ms bins instead of 46 ms)")
151
- ap.add_argument("--sync-token", action="store_true",
152
- help="LHL syncopation-band condition token")
153
- ap.add_argument("--plan", action="store_true",
154
- help="song-level plan-block conditioning (cache/plans.json)")
155
- ap.add_argument("--mask-infill", type=float, default=0.0,
156
- help="probability of type-mask (skeleton->color) curriculum per window")
157
- ap.add_argument("--complexity", action="store_true",
158
- help="rhythmic-complexity band token (density-independent difficulty)")
159
- ap.add_argument("--slot", action="store_true",
160
- help="slot-token mode: notes as exact TJA lattice indices "
161
- "(measure*96+slot), measure-aligned windows, grid-phase "
162
- "input channels — zero quantization error by construction")
163
- ap.add_argument("--dual-slot-p", type=float, default=0.65,
164
- help="dual mode: probability of a slot window (rest = time)")
165
- ap.add_argument("--dual", action="store_true",
166
- help="dual-mode: mixed slot/time windows with an explicit "
167
- "MODE token; one model serves both the grid-exact path "
168
- "and the gridless time path")
169
- ap.add_argument("--dpo", default=None,
170
- help="reference checkpoint: switch to DPO finetuning with synthetic negatives")
171
- ap.add_argument("--dpo-beta", type=float, default=0.1)
172
- ap.add_argument("--dpo-hard", action="store_true",
173
- help="near-tolerance negatives (resemble policy errors)")
174
- ap.add_argument("--val-every", type=int, default=2000)
175
- ap.add_argument("--workers", type=int, default=12)
176
- ap.add_argument("--resume", default=None)
177
- ap.add_argument("--seed", type=int, default=1234)
178
- ap.add_argument("--data-frac", type=float, default=1.0,
179
- help="fraction of train songs to use (data-scaling ablation); "
180
- "val/test splits are unaffected")
181
- args = ap.parse_args()
182
- torch.manual_seed(args.seed)
183
-
184
- os.makedirs(args.out, exist_ok=True)
185
- device = "cuda"
186
- torch.backends.cuda.matmul.allow_tf32 = True
187
- torch.backends.cudnn.allow_tf32 = True
188
-
189
- train_ids, val_ids, _ = load_split_ids(args.cache, use_aug=args.use_aug,
190
- frac=args.data_frac)
191
- train_ds = ChartWindowDataset(
192
- args.cache, train_ids, train=True, cond_drop=args.cond_drop,
193
- spec_augment=not args.no_augment, use_ctx=args.ctx, aux=args.aux,
194
- global_ctx=args.global_ctx, importance_sampling=args.importance_sampling,
195
- tempo_aug=args.tempo_aug, sibling=args.sibling, style=args.style,
196
- beat_head=args.beat_head, sync_token=args.sync_token,
197
- plan=args.plan, mask_infill=args.mask_infill, complexity=args.complexity,
198
- slot=args.slot, dual=args.dual, dual_slot_p=args.dual_slot_p,
199
- beat_hires=args.beat_hires,
200
- )
201
- val_ds = ChartWindowDataset(args.cache, val_ids, train=False, windows_per_chart=1,
202
- use_ctx=args.ctx, aux=args.aux, global_ctx=args.global_ctx,
203
- sibling=args.sibling, style=args.style,
204
- beat_head=args.beat_head, sync_token=args.sync_token,
205
- plan=args.plan, complexity=args.complexity,
206
- slot=args.slot, dual=args.dual,
207
- beat_hires=args.beat_hires)
208
- print(f"train charts {len(train_ds.items)}, val charts {len(val_ds.items)}")
209
-
210
- train_loader = DataLoader(
211
- train_ds, batch_size=args.batch, shuffle=True, collate_fn=collate,
212
- num_workers=args.workers, pin_memory=True, drop_last=True, persistent_workers=True,
213
- )
214
- val_loader = DataLoader(
215
- val_ds, batch_size=args.batch, shuffle=False, collate_fn=collate, num_workers=4,
216
- )
217
-
218
- from .vocab import N_MELS as _NM
219
- model = ChartModel(
220
- d_model=args.d_model, enc_layers=args.enc_layers, dec_layers=args.dec_layers,
221
- ffn=args.ffn, aux=args.aux, global_ctx=args.global_ctx,
222
- func_time=args.func_time, in_ch=(_NM + 2) if (args.slot or args.dual) else None,
223
- emb_factor=args.emb_factor or None,
224
- ).to(device)
225
- if args.align:
226
- model.enable_ptr()
227
- if args.beat_head:
228
- model.enable_beat(hires=args.beat_hires)
229
- model.to(device)
230
- print(f"params: {count_params(model)/1e6:.1f}M, vocab {VOCAB.size}")
231
- if args.init_from and os.path.exists(args.init_from):
232
- src = torch.load(args.init_from, map_location=device)["model"]
233
- own = model.state_dict()
234
- hit = {k: v for k, v in src.items()
235
- if k in own and own[k].shape == v.shape
236
- and k.startswith(("decoder", "tok_emb", "out", "dec_pos"))}
237
- own.update(hit)
238
- model.load_state_dict(own)
239
- print(f"initialized {len(hit)} tensors from {args.init_from}")
240
- class_weight = build_class_weights(device, args.balance_cap) if args.balanced else None
241
-
242
- teacher = None
243
- if args.distill:
244
- from .generate import load_model
245
-
246
- teacher = load_model(args.distill, device=device)
247
- for p in teacher.parameters():
248
- p.requires_grad_(False)
249
- t_vocab = teacher.tok_emb.weight.shape[0]
250
- print(f"distilling from {args.distill} (teacher vocab {t_vocab})")
251
- dpo_ref = None
252
- if args.dpo:
253
- from .generate import load_model as _lm
254
-
255
- dpo_ref = _lm(args.dpo, device=device)
256
- for p_ in dpo_ref.parameters():
257
- p_.requires_grad_(False)
258
- # initialize the policy from the reference (vocab may have grown since:
259
- # copy overlapping embedding rows, everything else matches the 12M arch)
260
- ref_sd = dpo_ref.state_dict()
261
- own = model.state_dict()
262
- for k, v in ref_sd.items():
263
- if k in own:
264
- if own[k].shape == v.shape:
265
- own[k] = v
266
- elif k == "tok_emb.weight":
267
- own[k][: v.shape[0]] = v
268
- model.load_state_dict(own)
269
- # disable dropout for DPO: logprob-margin noise from dropout on long
270
- # sequences swamps the beta-scaled objective (verified: init loss 3.3
271
- # instead of log 2)
272
- import torch.nn as _nn
273
-
274
- for m_ in model.modules():
275
- if isinstance(m_, _nn.Dropout):
276
- m_.p = 0.0
277
- print(f"DPO: policy initialized from {args.dpo}, beta={args.dpo_beta}, dropout off")
278
-
279
- opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.01, betas=(0.9, 0.98))
280
-
281
- def lr_at(step):
282
- if step < args.warmup:
283
- return args.lr * step / args.warmup
284
- p = (step - args.warmup) / max(1, args.steps - args.warmup)
285
- return args.lr * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * p)))
286
-
287
- step = 0
288
- best_val = float("inf")
289
- if args.resume and os.path.exists(args.resume):
290
- ck = torch.load(args.resume, map_location=device)
291
- model.load_state_dict(ck["model"])
292
- opt.load_state_dict(ck["opt"])
293
- step = ck["step"]
294
- best_val = ck.get("best_val", best_val)
295
- print(f"resumed from {args.resume} at step {step}")
296
-
297
- log_path = os.path.join(args.out, "train_log.jsonl")
298
- model.train()
299
- t0 = time.time()
300
- run_loss, run_n = 0.0, 0
301
- done = False
302
- while not done:
303
- for mel, tgt, mask, aux_t, gsum, posb, beat_t, in_tgt in train_loader:
304
- step += 1
305
- if step > args.steps:
306
- done = True
307
- break
308
- for g in opt.param_groups:
309
- g["lr"] = lr_at(step)
310
- mel, tgt, mask = mel.to(device, non_blocking=True), tgt.to(device), mask.to(device)
311
- gsum = gsum.to(device) if gsum is not None else None
312
- posb = posb.to(device)
313
- in_tgt = in_tgt.to(device)
314
- if dpo_ref is not None:
315
- gen_rng = torch.Generator(device=device)
316
- gen_rng.manual_seed(args.seed * 100003 + step)
317
- rej = corrupt_events(tgt, mask, gen_rng, hard=args.dpo_hard)
318
- with torch.autocast("cuda", dtype=torch.bfloat16):
319
- pc = seq_logprob(model, mel, tgt, mask)
320
- pr = seq_logprob(model, mel, rej, mask)
321
- with torch.no_grad():
322
- rc = seq_logprob(dpo_ref, mel, tgt, mask)
323
- rr = seq_logprob(dpo_ref, mel, rej, mask)
324
- loss = -F.logsigmoid(
325
- args.dpo_beta * ((pc - pr) - (rc - rr))).mean()
326
- opt.zero_grad(set_to_none=True)
327
- loss.backward()
328
- torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
329
- opt.step()
330
- run_loss += loss.item()
331
- run_n += 1
332
- if step % 100 == 0:
333
- msg = {"step": step, "dpo_loss": round(run_loss / run_n, 4)}
334
- print(json.dumps(msg), flush=True)
335
- with open(log_path, "a") as f:
336
- f.write(json.dumps(msg) + "\n")
337
- run_loss, run_n = 0.0, 0
338
- if step % args.val_every == 0 or step == args.steps:
339
- ck = {"model": model.state_dict(), "opt": opt.state_dict(),
340
- "step": step, "best_val": 0.0, "args": vars(args)}
341
- torch.save(ck, os.path.join(args.out, "last.pt"))
342
- torch.save(ck, os.path.join(args.out, "best.pt"))
343
- continue
344
- with torch.autocast("cuda", dtype=torch.bfloat16):
345
- if args.align or args.beat_head:
346
- logits, aux_logits, ptr_logits, beat_logits = model(
347
- mel, tgt, return_aux=True, return_extras=True,
348
- gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
349
- elif args.aux and aux_t is not None:
350
- logits, aux_logits = model(mel, tgt, return_aux=True,
351
- gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
352
- ptr_logits = beat_logits = None
353
- else:
354
- logits = model(mel, tgt, gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
355
- aux_logits = ptr_logits = beat_logits = None
356
- aux_loss = 0.0
357
- if args.aux and aux_t is not None and aux_logits is not None:
358
- aux_loss = F.binary_cross_entropy_with_logits(
359
- aux_logits.float(), aux_t.to(device))
360
- extra = 0.0
361
- if ptr_logits is not None:
362
- # pointer alignment: positions whose GOLD token is TIME must
363
- # point at that frame's encoder position
364
- gold = tgt[:, 1:]
365
- is_t = (gold >= VOCAB.time0) & (gold < VOCAB.time0 + 1728) & mask[:, 1:]
366
- if is_t.any():
367
- target_pos = ((gold - VOCAB.time0) // 4).clamp(0, ptr_logits.shape[-1] - 1)
368
- pl = F.cross_entropy(
369
- ptr_logits.reshape(-1, ptr_logits.shape[-1]).float(),
370
- target_pos.reshape(-1), reduction="none",
371
- ).reshape(gold.shape)
372
- extra = extra + 0.2 * (pl * is_t).sum() / is_t.sum()
373
- if beat_logits is not None and beat_t is not None:
374
- bt = beat_t.to(device)
375
- bm = bt >= 0 # -1 marks masked (grid-visible) windows
376
- if bm.any():
377
- extra = extra + args.beat_weight * F.binary_cross_entropy_with_logits(
378
- beat_logits.float()[bm], bt[bm])
379
- loss = loss_fn(logits, tgt, mask, class_weight=class_weight) + 0.1 * aux_loss + extra
380
- if teacher is not None:
381
- with torch.no_grad():
382
- # teacher may have a smaller vocab (no SIB/STYLE tokens):
383
- # clamp unseen ids to UNK and distill over the shared slice
384
- t_tgt = torch.where(tgt >= t_vocab,
385
- torch.full_like(tgt, VOCAB.unk_cond), tgt)
386
- t_logits = teacher(mel, t_tgt)
387
- V = min(t_logits.shape[-1], logits.shape[-1])
388
- tau = args.kd_tau
389
- kl = F.kl_div(
390
- F.log_softmax(logits[..., :V].float() / tau, dim=-1),
391
- F.softmax(t_logits[..., :V].float() / tau, dim=-1),
392
- reduction="none",
393
- ).sum(-1)
394
- kl = (kl * mask[:, 1:]).sum() / mask[:, 1:].sum().clamp(min=1)
395
- loss = args.kd_alpha * loss + (1 - args.kd_alpha) * (tau * tau) * kl
396
- opt.zero_grad(set_to_none=True)
397
- loss.backward()
398
- torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
399
- opt.step()
400
- run_loss += loss.item()
401
- run_n += 1
402
-
403
- if step % 100 == 0:
404
- msg = {
405
- "step": step,
406
- "loss": round(run_loss / run_n, 4),
407
- "lr": round(lr_at(step), 6),
408
- "sec": round(time.time() - t0, 1),
409
- }
410
- print(json.dumps(msg), flush=True)
411
- with open(log_path, "a") as f:
412
- f.write(json.dumps(msg) + "\n")
413
- run_loss, run_n = 0.0, 0
414
-
415
- if step % args.val_every == 0 or step == args.steps:
416
- vl = validate(model, val_loader, device)
417
- msg = {"step": step, "val_loss": round(vl, 4)}
418
- print(json.dumps(msg), flush=True)
419
- with open(log_path, "a") as f:
420
- f.write(json.dumps(msg) + "\n")
421
- ck = {"model": model.state_dict(), "opt": opt.state_dict(),
422
- "step": step, "best_val": best_val, "args": vars(args)}
423
- torch.save(ck, os.path.join(args.out, "last.pt"))
424
- if vl < best_val:
425
- best_val = vl
426
- ck["best_val"] = best_val
427
- torch.save(ck, os.path.join(args.out, "best.pt"))
428
-
429
- print(f"done. best_val={best_val:.4f}")
430
-
431
-
432
- if __name__ == "__main__":
433
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/vocab.py CHANGED
@@ -5,7 +5,9 @@ Sequence layout (decoder):
5
  Conditions may be UNK (condition dropout / unspecified at inference).
6
  """
7
 
 
8
  from dataclasses import dataclass
 
9
 
10
  # --- audio / windowing constants (single source of truth) ---
11
  SR = 22050
@@ -14,7 +16,7 @@ HOP = 256
14
  N_MELS = 128
15
  FPS = SR / HOP # 86.1328125 frames/sec
16
  WINDOW = 1728 # frames per training window (~20.06 s)
17
- MAX_TGT = 1536 # max decoder length (prefix + events + eos)
18
 
19
  SLOTS = 96 # TJA lattice: slots per 4/4 measure (LCM of 16ths/triplets/32nds)
20
  MEAS_MAX = WINDOW // SLOTS # max whole measures per window in slot mode (18)
@@ -64,7 +66,7 @@ class Vocab:
64
  base += len(NOTE_CLASSES)
65
  self.time0 = base
66
  base += WINDOW
67
- # Extended condition tokens append after the original token ids.
68
  self.sep = base # separates prev-window context from conditions
69
  self.sib = base + 1 # marks the sibling-chart (easier course) segment
70
  self.style = {s: base + 2 + s for s in range(8)} # charting-intent codes
@@ -125,7 +127,7 @@ def complexity_band(frames):
125
 
126
  def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_types=None,
127
  sib_pairs=None, style=None, sync_band=None, plan_slice=None,
128
- complexity=None, mode=None):
129
  """Build a token sequence for one window.
130
 
131
  notes: list of (frame_idx, note_class_id) sorted by frame, frame in [0, WINDOW).
@@ -136,8 +138,15 @@ def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_type
136
  missing slots filled with UNK. Encoded after [SIB].
137
  Returns (tokens, prefix_len) where loss should be applied after the prefix.
138
  """
 
 
 
 
 
 
 
139
  n_hits = sum(1 for _, c in notes if NOTE_CLASSES[c] not in ("end",))
140
- nps = n_hits / (WINDOW / FPS)
141
  d = vocab.dens_bucket(nps)
142
 
143
  def maybe(tok):
 
5
  Conditions may be UNK (condition dropout / unspecified at inference).
6
  """
7
 
8
+ import math
9
  from dataclasses import dataclass
10
+ from numbers import Real
11
 
12
  # --- audio / windowing constants (single source of truth) ---
13
  SR = 22050
 
16
  N_MELS = 128
17
  FPS = SR / HOP # 86.1328125 frames/sec
18
  WINDOW = 1728 # frames per training window (~20.06 s)
19
+ MAX_TGT = 768 # max decoder length (prefix + events + eos)
20
 
21
  SLOTS = 96 # TJA lattice: slots per 4/4 measure (LCM of 16ths/triplets/32nds)
22
  MEAS_MAX = WINDOW // SLOTS # max whole measures per window in slot mode (18)
 
66
  base += len(NOTE_CLASSES)
67
  self.time0 = base
68
  base += WINDOW
69
+ # v2+ tokens appended at the end so older checkpoints stay loadable
70
  self.sep = base # separates prev-window context from conditions
71
  self.sib = base + 1 # marks the sibling-chart (easier course) segment
72
  self.style = {s: base + 2 + s for s in range(8)} # charting-intent codes
 
127
 
128
  def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_types=None,
129
  sib_pairs=None, style=None, sync_band=None, plan_slice=None,
130
+ complexity=None, mode=None, duration_seconds=None):
131
  """Build a token sequence for one window.
132
 
133
  notes: list of (frame_idx, note_class_id) sorted by frame, frame in [0, WINDOW).
 
138
  missing slots filled with UNK. Encoded after [SIB].
139
  Returns (tokens, prefix_len) where loss should be applied after the prefix.
140
  """
141
+ if duration_seconds is None:
142
+ duration_seconds = WINDOW / FPS
143
+ if (not isinstance(duration_seconds, Real)
144
+ or isinstance(duration_seconds, bool)
145
+ or not math.isfinite(duration_seconds)
146
+ or duration_seconds <= 0):
147
+ raise ValueError("duration_seconds must be positive and finite")
148
  n_hits = sum(1 for _, c in notes if NOTE_CLASSES[c] not in ("end",))
149
+ nps = n_hits / float(duration_seconds)
150
  d = vocab.dens_bucket(nps)
151
 
152
  def maybe(tok):
static/app.css CHANGED
@@ -594,29 +594,6 @@ audio {
594
  font-weight: 750;
595
  }
596
 
597
- .model-select {
598
- width: 100%;
599
- margin-top: 8px;
600
- padding: 10px 12px;
601
- color: var(--ink);
602
- background: var(--surface-solid);
603
- border: 1px solid var(--line);
604
- border-radius: 10px;
605
- font-size: 13px;
606
- font-weight: 600;
607
- cursor: pointer;
608
- transition: border-color .18s, box-shadow .18s;
609
- }
610
-
611
- .model-select:hover {
612
- border-color: var(--line-strong);
613
- }
614
-
615
- .model-select:focus-visible {
616
- outline: 3px solid rgba(39, 111, 145, .35);
617
- outline-offset: 1px;
618
- }
619
-
620
  .range {
621
  --range-progress: 88.89%;
622
  width: 100%;
@@ -1081,7 +1058,7 @@ audio {
1081
  .loading-core,
1082
  .metronome,
1083
  .beat-pulses circle,
1084
- .plan-cursor,
1085
  .note,
1086
  .export-sheet,
1087
  .mix-wave,
@@ -1119,19 +1096,19 @@ audio {
1119
  .is-active .beat-pulses circle:nth-child(5) { animation-delay: -1.6s; }
1120
 
1121
  /* Plan */
1122
- .plan-bars rect { fill: currentColor; opacity: .13; }
1123
- .plan-bars .plan-climax { fill: var(--red); opacity: .42; }
1124
- .is-active .plan-bars rect { animation: plan-rise 1.9s var(--ease) infinite alternate both; transform-box: fill-box; transform-origin: center bottom; }
1125
- .is-active .plan-bars rect:nth-child(2) { animation-delay: .12s; }
1126
- .is-active .plan-bars rect:nth-child(3) { animation-delay: .24s; }
1127
- .is-active .plan-bars rect:nth-child(4) { animation-delay: .36s; }
1128
- .is-active .plan-bars rect:nth-child(5) { animation-delay: .48s; }
1129
- .is-active .plan-bars rect:nth-child(6) { animation-delay: .6s; }
1130
- .is-active .plan-bars rect:nth-child(7) { animation-delay: .72s; }
1131
- .is-active .plan-bars rect:nth-child(8) { animation-delay: .84s; }
1132
- .plan-path { stroke-dasharray: 520; stroke-dashoffset: 520; }
1133
- .is-active .plan-path { animation: plan-draw 2.2s var(--ease) infinite; }
1134
- .is-active .plan-cursor { animation: plan-cursor 2.2s var(--ease) infinite; }
1135
 
1136
  /* Generate */
1137
  .note { fill: var(--red); transform: translate(560px, 140px); }
@@ -1483,17 +1460,17 @@ footer a:hover {
1483
  48%, 100% { opacity: .16; transform: scale(.75); }
1484
  }
1485
 
1486
- @keyframes plan-rise {
1487
  0% { opacity: .12; transform: scaleY(.45); }
1488
  100% { opacity: .35; transform: scaleY(1); }
1489
  }
1490
 
1491
- @keyframes plan-draw {
1492
  0% { stroke-dashoffset: 520; }
1493
  75%, 100% { stroke-dashoffset: 0; }
1494
  }
1495
 
1496
- @keyframes plan-cursor {
1497
  0% { transform: translate(0, 0); opacity: 0; }
1498
  8% { opacity: 1; }
1499
  12% { transform: translate(0, 0); }
 
594
  font-weight: 750;
595
  }
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  .range {
598
  --range-progress: 88.89%;
599
  width: 100%;
 
1058
  .loading-core,
1059
  .metronome,
1060
  .beat-pulses circle,
1061
+ .structure-cursor,
1062
  .note,
1063
  .export-sheet,
1064
  .mix-wave,
 
1096
  .is-active .beat-pulses circle:nth-child(5) { animation-delay: -1.6s; }
1097
 
1098
  /* Plan */
1099
+ .structure-bars rect { fill: currentColor; opacity: .13; }
1100
+ .structure-bars .structure-peak { fill: var(--red); opacity: .42; }
1101
+ .is-active .structure-bars rect { animation: structure-rise 1.9s var(--ease) infinite alternate both; transform-box: fill-box; transform-origin: center bottom; }
1102
+ .is-active .structure-bars rect:nth-child(2) { animation-delay: .12s; }
1103
+ .is-active .structure-bars rect:nth-child(3) { animation-delay: .24s; }
1104
+ .is-active .structure-bars rect:nth-child(4) { animation-delay: .36s; }
1105
+ .is-active .structure-bars rect:nth-child(5) { animation-delay: .48s; }
1106
+ .is-active .structure-bars rect:nth-child(6) { animation-delay: .6s; }
1107
+ .is-active .structure-bars rect:nth-child(7) { animation-delay: .72s; }
1108
+ .is-active .structure-bars rect:nth-child(8) { animation-delay: .84s; }
1109
+ .structure-path { stroke-dasharray: 520; stroke-dashoffset: 520; }
1110
+ .is-active .structure-path { animation: structure-draw 2.2s var(--ease) infinite; }
1111
+ .is-active .structure-cursor { animation: structure-cursor 2.2s var(--ease) infinite; }
1112
 
1113
  /* Generate */
1114
  .note { fill: var(--red); transform: translate(560px, 140px); }
 
1460
  48%, 100% { opacity: .16; transform: scale(.75); }
1461
  }
1462
 
1463
+ @keyframes structure-rise {
1464
  0% { opacity: .12; transform: scaleY(.45); }
1465
  100% { opacity: .35; transform: scaleY(1); }
1466
  }
1467
 
1468
+ @keyframes structure-draw {
1469
  0% { stroke-dashoffset: 520; }
1470
  75%, 100% { stroke-dashoffset: 0; }
1471
  }
1472
 
1473
+ @keyframes structure-cursor {
1474
  0% { transform: translate(0, 0); opacity: 0; }
1475
  8% { opacity: 1; }
1476
  12% { transform: translate(0, 0); }
static/app.js CHANGED
@@ -15,13 +15,10 @@ const ui = {
15
  fileMeta: $("#file-meta"),
16
  removeFile: $("#remove-file"),
17
  fileError: $("#file-error"),
18
- model: $("#model"),
19
  level: $("#level"),
20
  levelOutput: $("#level-output"),
21
  bpm: $("#bpm"),
22
- autoPlan: $("#auto-plan"),
23
  useBeat: $("#use-beat"),
24
- usePlanner: $("#use-planner"),
25
  sampling: $("#sampling"),
26
  samplingParameters: $("#sampling-parameters"),
27
  temperature: $("#temperature"),
@@ -53,13 +50,13 @@ const ui = {
53
  metricsGrid: $("#metrics-grid"),
54
  previewBlock: $(".preview-block"),
55
  chartTab: $("#chart-tab"),
56
- planTab: $("#plan-tab"),
57
  chartPreview: $("#chart-preview"),
58
- planPreview: $("#plan-preview"),
59
  chartImage: $("#chart-image"),
60
- planImage: $("#plan-image"),
61
  chartEmpty: $("#chart-empty"),
62
- planEmpty: $("#plan-empty"),
63
  downloadTja: $("#download-tja"),
64
  downloadAudio: $("#download-audio"),
65
  newChartButton: $("#new-chart-button"),
@@ -82,10 +79,10 @@ const stageDefaults = {
82
  title: "Finding the beat",
83
  detail: "Finding beats and tempo."
84
  },
85
- plan: {
86
- eyebrow: "PLANNING",
87
- title: "Shaping the arc",
88
- detail: "Planning density and climaxes."
89
  },
90
  generate: {
91
  eyebrow: "COMPOSING",
@@ -410,10 +407,6 @@ function setDownload(anchor, fileValue, defaultName) {
410
 
411
  function metricValue(key, value) {
412
  if (value === null || value === undefined || value === "") return "—";
413
- if (key === "timing") {
414
- if (value === "slot-exact") return "Grid-exact";
415
- if (value === "time-quantized") return "Time-quantized";
416
- }
417
  if (key === "grid_rms_ms" && Number.isFinite(Number(value))) return `${Number(value).toFixed(1)} ms`;
418
  if (key === "bpm" && Number.isFinite(Number(value))) return Number(value).toFixed(1);
419
  if (typeof value === "boolean") return value ? "Yes" : "No";
@@ -451,14 +444,14 @@ function renderMetrics(metrics) {
451
  function activatePreview(name, focus = false) {
452
  const chartActive = name === "chart";
453
  ui.chartTab.classList.toggle("is-active", chartActive);
454
- ui.planTab.classList.toggle("is-active", !chartActive);
455
  ui.chartTab.setAttribute("aria-selected", String(chartActive));
456
- ui.planTab.setAttribute("aria-selected", String(!chartActive));
457
  ui.chartTab.tabIndex = chartActive ? 0 : -1;
458
- ui.planTab.tabIndex = chartActive ? -1 : 0;
459
  ui.chartPreview.hidden = !chartActive;
460
- ui.planPreview.hidden = chartActive;
461
- if (focus) (chartActive ? ui.chartTab : ui.planTab).focus();
462
  }
463
 
464
  function renderResult(payload) {
@@ -475,11 +468,15 @@ function renderResult(payload) {
475
 
476
  renderMetrics(payload.metrics);
477
  const hasChart = setImage(ui.chartImage, ui.chartEmpty, files.chart_image);
478
- const hasPlan = setImage(ui.planImage, ui.planEmpty, files.plan_image);
479
- ui.previewBlock.hidden = !hasChart && !hasPlan;
 
 
 
 
480
  ui.chartTab.hidden = !hasChart;
481
- ui.planTab.hidden = !hasPlan;
482
- activatePreview(hasChart ? "chart" : "plan");
483
 
484
  setDownload(ui.downloadTja, files.tja, "softchart.tja");
485
  setDownload(ui.downloadAudio, files.audio, "softchart-taiko-preview.wav");
@@ -524,14 +521,11 @@ function requestValues() {
524
  selectedCourse ? selectedCourse.value : "oni",
525
  Number.parseInt(ui.level.value, 10),
526
  Number.isFinite(bpm) ? bpm : 0,
527
- ui.autoPlan.checked,
528
  ui.useBeat.checked,
529
- ui.usePlanner.checked,
530
  ui.sampling.checked,
531
  Number(ui.temperature.value),
532
  Number(ui.topP.value),
533
- Number(ui.drumVolume.value),
534
- ui.model ? ui.model.value : "v1.7"
535
  ];
536
  }
537
 
@@ -657,13 +651,13 @@ function bindEvents() {
657
  });
658
 
659
  ui.chartTab.addEventListener("click", () => activatePreview("chart"));
660
- ui.planTab.addEventListener("click", () => activatePreview("plan"));
661
  $$(".preview-tab").forEach((tab) => {
662
  tab.addEventListener("keydown", (event) => {
663
  if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
664
  event.preventDefault();
665
- const next = tab === ui.chartTab ? "plan" : "chart";
666
- const target = next === "chart" ? ui.chartTab : ui.planTab;
667
  if (!target.hidden) activatePreview(next, true);
668
  });
669
  });
 
15
  fileMeta: $("#file-meta"),
16
  removeFile: $("#remove-file"),
17
  fileError: $("#file-error"),
 
18
  level: $("#level"),
19
  levelOutput: $("#level-output"),
20
  bpm: $("#bpm"),
 
21
  useBeat: $("#use-beat"),
 
22
  sampling: $("#sampling"),
23
  samplingParameters: $("#sampling-parameters"),
24
  temperature: $("#temperature"),
 
50
  metricsGrid: $("#metrics-grid"),
51
  previewBlock: $(".preview-block"),
52
  chartTab: $("#chart-tab"),
53
+ structureTab: $("#structure-tab"),
54
  chartPreview: $("#chart-preview"),
55
+ structurePreview: $("#structure-preview"),
56
  chartImage: $("#chart-image"),
57
+ structureImage: $("#structure-image"),
58
  chartEmpty: $("#chart-empty"),
59
+ structureEmpty: $("#structure-empty"),
60
  downloadTja: $("#download-tja"),
61
  downloadAudio: $("#download-audio"),
62
  newChartButton: $("#new-chart-button"),
 
79
  title: "Finding the beat",
80
  detail: "Finding beats and tempo."
81
  },
82
+ structure: {
83
+ eyebrow: "STRUCTURE",
84
+ title: "Reading the structure",
85
+ detail: "Building whole-song hierarchical context."
86
  },
87
  generate: {
88
  eyebrow: "COMPOSING",
 
407
 
408
  function metricValue(key, value) {
409
  if (value === null || value === undefined || value === "") return "—";
 
 
 
 
410
  if (key === "grid_rms_ms" && Number.isFinite(Number(value))) return `${Number(value).toFixed(1)} ms`;
411
  if (key === "bpm" && Number.isFinite(Number(value))) return Number(value).toFixed(1);
412
  if (typeof value === "boolean") return value ? "Yes" : "No";
 
444
  function activatePreview(name, focus = false) {
445
  const chartActive = name === "chart";
446
  ui.chartTab.classList.toggle("is-active", chartActive);
447
+ ui.structureTab.classList.toggle("is-active", !chartActive);
448
  ui.chartTab.setAttribute("aria-selected", String(chartActive));
449
+ ui.structureTab.setAttribute("aria-selected", String(!chartActive));
450
  ui.chartTab.tabIndex = chartActive ? 0 : -1;
451
+ ui.structureTab.tabIndex = chartActive ? -1 : 0;
452
  ui.chartPreview.hidden = !chartActive;
453
+ ui.structurePreview.hidden = chartActive;
454
+ if (focus) (chartActive ? ui.chartTab : ui.structureTab).focus();
455
  }
456
 
457
  function renderResult(payload) {
 
468
 
469
  renderMetrics(payload.metrics);
470
  const hasChart = setImage(ui.chartImage, ui.chartEmpty, files.chart_image);
471
+ const hasStructure = setImage(
472
+ ui.structureImage,
473
+ ui.structureEmpty,
474
+ files.structure_image
475
+ );
476
+ ui.previewBlock.hidden = !hasChart && !hasStructure;
477
  ui.chartTab.hidden = !hasChart;
478
+ ui.structureTab.hidden = !hasStructure;
479
+ activatePreview(hasChart ? "chart" : "structure");
480
 
481
  setDownload(ui.downloadTja, files.tja, "softchart.tja");
482
  setDownload(ui.downloadAudio, files.audio, "softchart-taiko-preview.wav");
 
521
  selectedCourse ? selectedCourse.value : "oni",
522
  Number.parseInt(ui.level.value, 10),
523
  Number.isFinite(bpm) ? bpm : 0,
 
524
  ui.useBeat.checked,
 
525
  ui.sampling.checked,
526
  Number(ui.temperature.value),
527
  Number(ui.topP.value),
528
+ Number(ui.drumVolume.value)
 
529
  ];
530
  }
531
 
 
651
  });
652
 
653
  ui.chartTab.addEventListener("click", () => activatePreview("chart"));
654
+ ui.structureTab.addEventListener("click", () => activatePreview("structure"));
655
  $$(".preview-tab").forEach((tab) => {
656
  tab.addEventListener("keydown", (event) => {
657
  if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
658
  event.preventDefault();
659
+ const next = tab === ui.chartTab ? "structure" : "chart";
660
+ const target = next === "chart" ? ui.chartTab : ui.structureTab;
661
  if (!target.hidden) activatePreview(next, true);
662
  });
663
  });
static/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta name="description" content="SoftChart turns music into playable, musical Taiko charts with an instant drum-mix audition.">
7
  <meta name="theme-color" content="#f6f3ec">
8
  <title>SoftChart — AI Taiko Chart Studio</title>
9
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='25' fill='%23e14b3f'/%3E%3Ccircle cx='32' cy='32' r='18' fill='%23fff8ed'/%3E%3Ccircle cx='32' cy='32' r='9' fill='%23e14b3f'/%3E%3C/svg%3E">
@@ -34,11 +34,11 @@
34
  <section class="hero" aria-labelledby="hero-title">
35
  <div class="hero-copy">
36
  <h1 id="hero-title">Music in.<br><em>Taiko out.</em></h1>
37
- <p class="hero-lede">Upload a track. Get a playable TJA. Hear it instantly.</p>
38
  </div>
39
  <ul class="hero-features" aria-label="Key features">
40
- <li><strong>Beat-locked</strong></li>
41
- <li><strong>Structure-aware</strong></li>
42
  <li><strong>Instant audition</strong></li>
43
  </ul>
44
  </section>
@@ -83,23 +83,9 @@
83
  <div class="form-section split-rule">
84
  <div class="field-group">
85
  <div class="label-row">
86
- <label for="model">Model</label>
 
87
  </div>
88
- <select id="model" class="model-select">
89
- <optgroup label="v1.7 — richest patterns (recommended)">
90
- <option value="v1.7" selected>v1.7 · 7.9M — best quality</option>
91
- <option value="v1.7-small">v1.7-small · 3.6M</option>
92
- <option value="v1.7-tiny">v1.7-tiny · 1.4M — fastest</option>
93
- </optgroup>
94
- <optgroup label="v1.6 — scaling ladder">
95
- <option value="v1.6">v1.6 · 7.9M</option>
96
- <option value="v1.6-small">v1.6-small · 3.6M</option>
97
- <option value="v1.6-tiny">v1.6-tiny · 1.4M</option>
98
- </optgroup>
99
- <optgroup label="v1.5 — single model">
100
- <option value="v1.5">v1.5 · 7.9M</option>
101
- </optgroup>
102
- </select>
103
  </div>
104
 
105
  <fieldset class="field-group">
@@ -138,21 +124,11 @@
138
  </summary>
139
  <div class="advanced-body">
140
  <div class="toggle-list">
141
- <label class="toggle-card compact">
142
- <span><strong>Structure</strong></span>
143
- <input id="auto-plan" type="checkbox" checked>
144
- <span class="switch" aria-hidden="true"></span>
145
- </label>
146
  <label class="toggle-card compact">
147
  <span><strong>Beat grid</strong></span>
148
  <input id="use-beat" type="checkbox" checked>
149
  <span class="switch" aria-hidden="true"></span>
150
  </label>
151
- <label class="toggle-card compact">
152
- <span><strong>AI planner</strong></span>
153
- <input id="use-planner" type="checkbox" checked>
154
- <span class="switch" aria-hidden="true"></span>
155
- </label>
156
  </div>
157
  <label class="toggle-card compact">
158
  <span><strong>Creative sampling</strong></span>
@@ -237,20 +213,20 @@
237
  </svg>
238
  </div>
239
 
240
- <div class="stage-visual" data-stage="plan" aria-hidden="true">
241
  <svg viewBox="0 0 520 280">
242
- <g class="plan-bars">
243
  <rect x="42" y="166" width="45" height="60" rx="5"/>
244
  <rect x="96" y="143" width="45" height="83" rx="5"/>
245
  <rect x="150" y="153" width="45" height="73" rx="5"/>
246
  <rect x="204" y="105" width="45" height="121" rx="5"/>
247
  <rect x="258" y="127" width="45" height="99" rx="5"/>
248
  <rect x="312" y="83" width="45" height="143" rx="5"/>
249
- <rect x="366" y="53" width="45" height="173" rx="5" class="plan-climax"/>
250
  <rect x="420" y="118" width="45" height="108" rx="5"/>
251
  </g>
252
- <path class="plan-path" d="M64 158 118 135l54 10 54-49 54 22 54-43 54-31 54 65" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
253
- <circle class="plan-cursor" cx="64" cy="158" r="7" fill="currentColor"/>
254
  </svg>
255
  </div>
256
 
@@ -351,16 +327,16 @@
351
  <div class="preview-block">
352
  <div class="preview-tabs" role="tablist" aria-label="Preview type">
353
  <button id="chart-tab" class="preview-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="chart-preview">Chart preview</button>
354
- <button id="plan-tab" class="preview-tab" type="button" role="tab" aria-selected="false" aria-controls="plan-preview" tabindex="-1">Song structure</button>
355
  </div>
356
  <div class="preview-frame">
357
  <div id="chart-preview" role="tabpanel" aria-labelledby="chart-tab">
358
  <img id="chart-image" alt="Preview of the generated Taiko chart">
359
  <p class="preview-empty" id="chart-empty" hidden>No chart preview is available.</p>
360
  </div>
361
- <div id="plan-preview" role="tabpanel" aria-labelledby="plan-tab" hidden>
362
- <img id="plan-image" alt="Song energy and chart section plan">
363
- <p class="preview-empty" id="plan-empty" hidden>No song structure preview is available.</p>
364
  </div>
365
  </div>
366
  </div>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="SoftChart V1.8 turns music into playable Taiko charts with a scratch-trained hierarchical model.">
7
  <meta name="theme-color" content="#f6f3ec">
8
  <title>SoftChart — AI Taiko Chart Studio</title>
9
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='25' fill='%23e14b3f'/%3E%3Ccircle cx='32' cy='32' r='18' fill='%23fff8ed'/%3E%3Ccircle cx='32' cy='32' r='9' fill='%23e14b3f'/%3E%3C/svg%3E">
 
34
  <section class="hero" aria-labelledby="hero-title">
35
  <div class="hero-copy">
36
  <h1 id="hero-title">Music in.<br><em>Taiko out.</em></h1>
37
+ <p class="hero-lede">Hierarchical V1.8 listens across the whole song, writes a playable TJA, and lets you hear it instantly.</p>
38
  </div>
39
  <ul class="hero-features" aria-label="Key features">
40
+ <li><strong>V1.8 scratch model</strong></li>
41
+ <li><strong>Whole-song hierarchy</strong></li>
42
  <li><strong>Instant audition</strong></li>
43
  </ul>
44
  </section>
 
83
  <div class="form-section split-rule">
84
  <div class="field-group">
85
  <div class="label-row">
86
+ <span>Model</span>
87
+ <strong class="field-hint">V1.8 · Hierarchical · 9.0M</strong>
88
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  </div>
90
 
91
  <fieldset class="field-group">
 
124
  </summary>
125
  <div class="advanced-body">
126
  <div class="toggle-list">
 
 
 
 
 
127
  <label class="toggle-card compact">
128
  <span><strong>Beat grid</strong></span>
129
  <input id="use-beat" type="checkbox" checked>
130
  <span class="switch" aria-hidden="true"></span>
131
  </label>
 
 
 
 
 
132
  </div>
133
  <label class="toggle-card compact">
134
  <span><strong>Creative sampling</strong></span>
 
213
  </svg>
214
  </div>
215
 
216
+ <div class="stage-visual" data-stage="structure" aria-hidden="true">
217
  <svg viewBox="0 0 520 280">
218
+ <g class="structure-bars">
219
  <rect x="42" y="166" width="45" height="60" rx="5"/>
220
  <rect x="96" y="143" width="45" height="83" rx="5"/>
221
  <rect x="150" y="153" width="45" height="73" rx="5"/>
222
  <rect x="204" y="105" width="45" height="121" rx="5"/>
223
  <rect x="258" y="127" width="45" height="99" rx="5"/>
224
  <rect x="312" y="83" width="45" height="143" rx="5"/>
225
+ <rect x="366" y="53" width="45" height="173" rx="5" class="structure-peak"/>
226
  <rect x="420" y="118" width="45" height="108" rx="5"/>
227
  </g>
228
+ <path class="structure-path" d="M64 158 118 135l54 10 54-49 54 22 54-43 54-31 54 65" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
229
+ <circle class="structure-cursor" cx="64" cy="158" r="7" fill="currentColor"/>
230
  </svg>
231
  </div>
232
 
 
327
  <div class="preview-block">
328
  <div class="preview-tabs" role="tablist" aria-label="Preview type">
329
  <button id="chart-tab" class="preview-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="chart-preview">Chart preview</button>
330
+ <button id="structure-tab" class="preview-tab" type="button" role="tab" aria-selected="false" aria-controls="structure-preview" tabindex="-1">Song structure</button>
331
  </div>
332
  <div class="preview-frame">
333
  <div id="chart-preview" role="tabpanel" aria-labelledby="chart-tab">
334
  <img id="chart-image" alt="Preview of the generated Taiko chart">
335
  <p class="preview-empty" id="chart-empty" hidden>No chart preview is available.</p>
336
  </div>
337
+ <div id="structure-preview" role="tabpanel" aria-labelledby="structure-tab" hidden>
338
+ <img id="structure-image" alt="Full-song mel spectrogram used for hierarchical context">
339
+ <p class="preview-empty" id="structure-empty" hidden>No song structure preview is available.</p>
340
  </div>
341
  </div>
342
  </div>