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

Restore selectable legacy model generations

Browse files
README.md CHANGED
@@ -11,11 +11,19 @@ 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).
@@ -23,8 +31,15 @@ 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.
 
11
  license: mit
12
  models:
13
  - JacobLinCool/softchart-v18
14
+ - JacobLinCool/softchart-v17
15
+ - JacobLinCool/softchart-v17-small
16
+ - JacobLinCool/softchart-v17-tiny
17
+ - JacobLinCool/softchart-v16
18
+ - JacobLinCool/softchart-v16-small
19
+ - JacobLinCool/softchart-v16-tiny
20
+ - JacobLinCool/softchart-v15
21
+ - JacobLinCool/softchart-planner
22
  datasets:
23
  - JacobLinCool/taiko-1000-parsed-clean
24
  ---
25
 
26
+ # 🥁 SoftChart
27
 
28
  Generate a **Taiko no Tatsujin-style** chart from an uploaded song with the
29
  scratch-trained [SoftChart V1.8 hierarchical model](https://huggingface.co/JacobLinCool/softchart-v18).
 
31
  rhythmic-skeleton auxiliary head, chart generation, and beat/downbeat
32
  estimation. It uses no external pretrained model and no external song planner.
33
 
34
+ V1.8 is the default recommendation, not the only option. Users can select
35
+ V1.5, V1.6, or V1.7—including the small and tiny scaling variants—from the
36
+ interface for direct comparison. Those releases run through their frozen
37
+ generation runtime and original
38
+ [legacy planner](https://huggingface.co/JacobLinCool/softchart-planner); this
39
+ does not change V1.8's self-contained, scratch-only architecture.
40
+
41
+ The Space always uses each selected model's full-song **time generation** path
42
+ for arbitrary uploads. The beat head can estimate BPM and a downbeat grid for TJA
43
  quantization, but that estimated grid is not treated as the trusted rational
44
  meter required by exact slot generation. This distinction prevents an
45
  estimated beat grid from being presented as authored-meter exact timing.
app.py CHANGED
@@ -1,9 +1,9 @@
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
@@ -20,7 +20,17 @@ import torch
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
@@ -31,54 +41,134 @@ from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
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
@@ -119,6 +209,58 @@ def load_logmel(path):
119
  return mel, wav
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def group_quantize(times, phase, grid, min_run=3):
123
  n = len(times)
124
  slots = [0] * n
@@ -156,7 +298,8 @@ def output_tja_path(wave_name, course, directory):
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,10 +351,22 @@ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=Non
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,7 +375,7 @@ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=Non
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,14 +388,41 @@ def render_song_structure(mel, title, course, out_path):
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
@@ -310,10 +492,11 @@ async def homepage():
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
 
@@ -336,6 +519,7 @@ def generate_chart(
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-")
@@ -347,18 +531,25 @@ def generate_chart(
347
  temperature = float(temperature)
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",
359
  "Mapping rhythm, melody, and timbre.",
360
  )
361
- mel, wav = load_logmel(audio_path)
362
 
363
  yield _progress(
364
  "beat", 0.21, "Finding the beat",
@@ -366,7 +557,7 @@ def generate_chart(
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,7 +565,9 @@ def generate_chart(
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"]
@@ -397,9 +590,18 @@ def generate_chart(
397
  if grid is None and abs(bpm - round(bpm)) < 0.06:
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(
@@ -407,15 +609,16 @@ def generate_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(
@@ -428,7 +631,9 @@ def generate_chart(
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",
@@ -446,10 +651,15 @@ def generate_chart(
446
 
447
  grid_rms = round(float(grid["rms_ms"]), 1) if grid is not None else None
448
  metrics = {
 
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
  }
 
1
+ """SoftChart — selectable Taiko chart model generations for HF Spaces.
2
 
3
+ V1.8 is the recommended scratch-trained hierarchical model. V1.5–V1.7 remain
4
+ available through their frozen runtime so users can compare generations.
5
+ Arbitrary uploads always use time generation; an estimated beat grid may
6
+ quantize the exported TJA but is never promoted to a trusted-meter slot path.
7
  """
8
 
9
  import logging
 
20
  from fastapi.responses import FileResponse
21
  from fastapi.staticfiles import StaticFiles
22
 
23
+ from legacy_softchart.generate import (
24
+ generate_song as generate_song_legacy,
25
+ load_hf as load_hf_legacy,
26
+ )
27
+ from legacy_softchart.grid import (
28
+ fit_grid_fixed_bpm as fit_grid_fixed_bpm_legacy,
29
+ fit_grid_piecewise as fit_grid_piecewise_legacy,
30
+ )
31
+ from legacy_softchart.hf import SoftChartPlanner
32
+ from legacy_softchart.tja import append_measure_with_gogo, gogo_measure_mask
33
+ from softchart.generate import generate_song as generate_song_v18, load_hf as load_hf_v18
34
  from softchart.fonts import cjk_font_path
35
  from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
36
  from softchart.preview_audio import synthesize_taiko_preview
 
41
  LOGGER = logging.getLogger("softchart.space")
42
  STATIC_DIR = Path(__file__).with_name("static")
43
 
44
+ PLAN_REPO = "JacobLinCool/softchart-planner"
45
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
46
  EXPECTED_PARAMETERS = 8_985_091
47
 
48
+ MODEL_SPECS = {
49
+ "v1.8": {
50
+ "repo": "JacobLinCool/softchart-v18",
51
+ "display": "V1.8 hierarchical",
52
+ "runtime": "v18",
53
+ },
54
+ "v1.7": {
55
+ "repo": "JacobLinCool/softchart-v17",
56
+ "display": "V1.7",
57
+ "runtime": "legacy",
58
+ },
59
+ "v1.7-small": {
60
+ "repo": "JacobLinCool/softchart-v17-small",
61
+ "display": "V1.7 small",
62
+ "runtime": "legacy",
63
+ },
64
+ "v1.7-tiny": {
65
+ "repo": "JacobLinCool/softchart-v17-tiny",
66
+ "display": "V1.7 tiny",
67
+ "runtime": "legacy",
68
+ },
69
+ "v1.6": {
70
+ "repo": "JacobLinCool/softchart-v16",
71
+ "display": "V1.6",
72
+ "runtime": "legacy",
73
+ },
74
+ "v1.6-small": {
75
+ "repo": "JacobLinCool/softchart-v16-small",
76
+ "display": "V1.6 small",
77
+ "runtime": "legacy",
78
+ },
79
+ "v1.6-tiny": {
80
+ "repo": "JacobLinCool/softchart-v16-tiny",
81
+ "display": "V1.6 tiny",
82
+ "runtime": "legacy",
83
+ },
84
+ "v1.5": {
85
+ "repo": "JacobLinCool/softchart-v15",
86
+ "display": "V1.5",
87
+ "runtime": "legacy",
88
+ },
89
+ }
90
+ DEFAULT_MODEL = "v1.8"
91
+
92
  COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
93
  CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
94
  "roll": "5", "roll_big": "6", "balloon": "7"}
95
  SUB = 96
96
 
97
+ _MODELS = {}
98
+ _PLANNER = {}
99
  _MEL_FB = None
100
  _STFT_WINDOW = None
101
 
102
 
103
+ def get_model(model_choice):
104
+ spec = MODEL_SPECS.get(model_choice)
105
+ if spec is None:
106
+ raise ValueError(
107
+ f"Unknown model {model_choice!r}. Choose one of: "
108
+ f"{', '.join(MODEL_SPECS)}."
109
+ )
110
+ if model_choice not in _MODELS:
111
+ if spec["runtime"] == "v18":
112
+ model = load_hf_v18(spec["repo"], device=DEVICE)
113
+ else:
114
+ model = load_hf_legacy(spec["repo"], device=DEVICE)
115
  capabilities = getattr(model, "_softchart_capabilities", {})
116
+ if spec["runtime"] == "v18":
117
+ required = {
118
+ "aux": True,
119
+ "beat_head": True,
120
+ "dual": True,
121
+ "hierarchical_ctx": True,
122
+ "global_ctx": False,
123
+ "plan": False,
124
+ }
125
+ mismatched = {
126
+ name: capabilities.get(name)
127
+ for name, expected in required.items()
128
+ if capabilities.get(name) is not expected
129
+ }
130
+ parameters = sum(parameter.numel() for parameter in model.parameters())
131
+ if (not getattr(model, "_hierarchical_ctx", False)
132
+ or getattr(model, "_has_plan", False) or mismatched
133
+ or parameters != EXPECTED_PARAMETERS):
134
+ raise RuntimeError(
135
+ f"{spec['repo']} is not the expected hierarchical V1.8 artifact"
136
+ )
137
+ bundle = {
138
+ "model": model,
139
+ "generate": generate_song_v18,
140
+ "fit_grid": fit_grid_piecewise,
141
+ "fit_grid_fixed": fit_grid_fixed_bpm,
142
+ "preprocess": load_logmel_v18,
143
+ "runtime": "v18",
144
+ }
145
+ else:
146
+ if (not getattr(model, "_dual", False) or model.beat is None
147
+ or not getattr(model, "_has_plan", False)):
148
+ raise RuntimeError(
149
+ f"{spec['repo']} is not a supported V1.5–V1.7 artifact"
150
+ )
151
+ bundle = {
152
+ "model": model,
153
+ "generate": generate_song_legacy,
154
+ "fit_grid": fit_grid_piecewise_legacy,
155
+ "fit_grid_fixed": fit_grid_fixed_bpm_legacy,
156
+ "preprocess": load_logmel_legacy,
157
+ "runtime": "legacy",
158
+ }
159
+ _MODELS[model_choice] = bundle
160
+ return _MODELS[model_choice]
161
+
162
+
163
+ def get_legacy_planner():
164
+ if "planner" not in _PLANNER:
165
+ _PLANNER["planner"] = (
166
+ SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
167
+ )
168
+ return _PLANNER["planner"]
169
 
170
 
171
+ def load_logmel_v18(path):
172
  """Apply the exact V1.8 FFmpeg + torch.stft preprocessing contract."""
173
  import librosa
174
  global _MEL_FB, _STFT_WINDOW
 
209
  return mel, wav
210
 
211
 
212
+ def load_logmel_legacy(path):
213
+ """Preserve the preprocessing contract used by V1.5–V1.7."""
214
+ import librosa
215
+
216
+ wav, _ = librosa.load(path, sr=SR, mono=True)
217
+ fb = librosa.filters.mel(
218
+ sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2,
219
+ )
220
+ spec = torch.stft(
221
+ torch.from_numpy(wav), N_FFT, hop_length=HOP,
222
+ window=torch.hann_window(N_FFT), center=True, return_complex=True,
223
+ )
224
+ mel = np.log(fb @ spec.abs().pow(2).numpy() + 1e-5).astype(np.float32)
225
+ return mel, wav
226
+
227
+
228
+ def learned_plan(planner, mel, course, bpm, downbeats=None):
229
+ duration = mel.shape[1] / FPS
230
+ beat = 60.0 / bpm
231
+ edges = (
232
+ list(downbeats[::4]) + [duration]
233
+ if downbeats is not None and len(downbeats) >= 2
234
+ else list(np.arange(0, duration, 4 * beat)) + [duration]
235
+ )
236
+ features = []
237
+ spans = []
238
+ for start, end in zip(edges, edges[1:]):
239
+ segment = mel[:, int(start * FPS):int(end * FPS)]
240
+ if segment.shape[1] < 2:
241
+ continue
242
+ flux = np.maximum(0, np.diff(segment, axis=1)).sum(0)
243
+ features.append(np.concatenate([
244
+ segment.mean(1), segment.std(1),
245
+ [flux.mean(), flux.std(), flux.max()],
246
+ ]))
247
+ spans.append((round(float(start), 3), round(float(end), 3)))
248
+ if not features:
249
+ return None
250
+ course_id = {"easy": 0, "normal": 1, "hard": 2, "oni": 3}[course]
251
+ inputs = torch.tensor(np.array(features), dtype=torch.float32)[None].to(DEVICE)
252
+ with torch.no_grad():
253
+ density_logits, flag_logits = planner(
254
+ inputs, torch.tensor([course_id], device=DEVICE)
255
+ )
256
+ densities = density_logits[0].argmax(-1).cpu().numpy()
257
+ flags = flag_logits[0].argmax(-1).cpu().numpy()
258
+ return [
259
+ [start, end, int(density), int(flag)]
260
+ for (start, end), density, flag in zip(spans, densities, flags)
261
+ ]
262
+
263
+
264
  def group_quantize(times, phase, grid, min_run=3):
265
  n = len(times)
266
  slots = [0] * n
 
298
  return os.path.join(directory, f"{stem}_{course}.tja")
299
 
300
 
301
+ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=None,
302
+ plan=None):
303
  hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
304
  beat = 60.0 / bpm
305
  grid = beat / (SUB / 4)
 
351
  slots = {k - shift: v for k, v in slots.items()}
352
  phase += shift * grid
353
  n_meas = (max(slots) // SUB + 1) if slots else 1
354
+ measure_starts = phase + np.arange(n_meas + 1, dtype=float) * (4 * beat)
355
+ gogo_mask = gogo_measure_mask(plan, measure_starts, n_meas)
356
+ lines = []
357
+ in_gogo = False
358
+ for measure in range(n_meas):
359
+ in_gogo = append_measure_with_gogo(
360
+ lines,
361
+ "".join(
362
+ slots.get(measure * SUB + slot, "0") for slot in range(SUB)
363
+ ) + ",",
364
+ measure,
365
+ gogo_mask,
366
+ in_gogo,
367
+ )
368
+ if in_gogo:
369
+ lines.append("#GOGOEND")
370
  balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
371
  return "\n".join([
372
  f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", f"WAVE:{wave}",
 
375
  "", "#START", *lines, "#END"]) + "\n"
376
 
377
 
378
+ def render_song_structure(mel, title, course, out_path, plan=None):
379
  import matplotlib
380
  matplotlib.use("Agg")
381
  from matplotlib import font_manager
 
388
  matplotlib.rcParams["axes.unicode_minus"] = False
389
 
390
  dur = mel.shape[1] / FPS
391
+ fig = plt.figure(figsize=(13, 4.4 if plan else 3.2))
392
+ grid = fig.add_gridspec(
393
+ 2 if plan else 1, 1,
394
+ height_ratios=[3.0, 1.0] if plan else [1],
395
+ hspace=0.14 if plan else 0.0,
396
+ )
397
+ ax0 = fig.add_subplot(grid[0])
398
  ax0.imshow(mel, aspect="auto", origin="lower",
399
  cmap="magma", extent=[0, dur, 0, N_MELS])
400
  ax0.set_ylabel("mel")
401
  ax0.set_title(f"{title} — {course} | full-song mel spectrogram")
402
  ax0.set_xlim(0, dur)
403
  ax0.grid(axis="x", alpha=0.18)
404
+ if plan:
405
+ ax0.set_xticklabels([])
406
+ ax1 = fig.add_subplot(grid[1], sharex=ax0)
407
+ for start, end, density, flag in plan:
408
+ color = "#d64545" if flag == 2 else (
409
+ "#4a90d9" if flag == 1 else "#999999"
410
+ )
411
+ ax1.bar(
412
+ (start + end) / 2,
413
+ max(density, 0.15),
414
+ width=max((end - start) * 0.92, 0.01),
415
+ color=color,
416
+ alpha=0.85,
417
+ )
418
+ ax1.set_xlim(0, dur)
419
+ ax1.set_ylim(0, 8)
420
+ ax1.set_yticks([0, 4, 8])
421
+ ax1.set_ylabel("plan", fontsize=8)
422
+ ax1.set_xlabel("time (s) — legacy plan: density / gap / climax")
423
+ ax1.grid(axis="x", alpha=0.18)
424
+ else:
425
+ ax0.set_xlabel("time (s)")
426
  fig.savefig(out_path, dpi=130, bbox_inches="tight")
427
  plt.close(fig)
428
  return out_path
 
492
  async def health():
493
  return {
494
  "status": "ok",
495
+ "default_model": DEFAULT_MODEL,
496
+ "available_models": list(MODEL_SPECS),
497
  "device": DEVICE,
498
+ "models_loaded": sorted(_MODELS),
499
+ "planner_loaded": "planner" in _PLANNER,
500
  }
501
 
502
 
 
519
  temperature: float,
520
  top_p: float,
521
  drum_volume: float,
522
+ model_choice: str = DEFAULT_MODEL,
523
  ) -> dict[str, object]:
524
  """Stream the actual inference stages to the custom frontend."""
525
  workdir = tempfile.mkdtemp(prefix="softchart-request-")
 
531
  temperature = float(temperature)
532
  top_p = float(top_p)
533
  drum_volume = float(drum_volume)
534
+ spec = MODEL_SPECS.get(model_choice)
535
+ if spec is None:
536
+ raise ValueError(
537
+ f"Unknown model {model_choice!r}. Choose one of: "
538
+ f"{', '.join(MODEL_SPECS)}."
539
+ )
540
 
541
  yield _progress(
542
  "loading", 0.03, "Preparing",
543
+ f"Loading SoftChart {spec['display']}.",
544
  )
545
+ bundle = get_model(model_choice)
546
+ model = bundle["model"]
547
 
548
  yield _progress(
549
  "audio", 0.11, "Listening",
550
  "Mapping rhythm, melody, and timbre.",
551
  )
552
+ mel, wav = bundle["preprocess"](audio_path)
553
 
554
  yield _progress(
555
  "beat", 0.21, "Finding the beat",
 
557
  )
558
  grid = dbs = None
559
  if use_beat:
560
+ grid = bundle["fit_grid"](model, mel, device=DEVICE)
561
  if grid is not None:
562
  dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
563
  if not grid["ok"]:
 
565
  if bpm_override > 0:
566
  bpm = bpm_override
567
  if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
568
+ fixed_grid = bundle["fit_grid_fixed"](
569
+ model, mel, bpm, device=DEVICE
570
+ )
571
  grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
572
  if grid is not None:
573
  dbs = grid["downbeats"]
 
590
  if grid is None and abs(bpm - round(bpm)) < 0.06:
591
  bpm = float(round(bpm))
592
 
593
+ plan = None
594
+ if bundle["runtime"] == "v18":
595
+ structure_title = "Reading the structure"
596
+ structure_detail = "Building whole-song hierarchical context."
597
+ else:
598
+ structure_title = "Shaping the arc"
599
+ structure_detail = "Applying the original legacy song planner."
600
+ plan = learned_plan(
601
+ get_legacy_planner(), mel, course, bpm, downbeats=dbs
602
+ )
603
  yield _progress(
604
+ "structure", 0.34, structure_title, structure_detail,
 
605
  )
606
 
607
  yield _progress(
 
609
  "Writing playable Taiko patterns.",
610
  )
611
  title = os.path.splitext(wave_name)[0]
612
+ generated = bundle["generate"](
613
  model, mel, course, level=level,
614
  density_bucket=COURSE_DENS[course], greedy=not sampling,
615
  temperature=temperature, top_p=top_p, seed=0, device=DEVICE,
616
+ plan=plan,
617
  )
618
  generated = snap_chart(generated, bpm)
619
  tja = write_tja(
620
  generated, bpm, title, course, level, wave_name, dbs,
621
+ grid_fit=grid, plan=plan,
622
  )
623
 
624
  yield _progress(
 
631
  chart_image_path = os.path.join(workdir, "chart.png")
632
  structure_image_path = os.path.join(workdir, "song-structure.png")
633
  render_tja_image(tja, out_path=chart_image_path)
634
+ render_song_structure(
635
+ mel, title, course, out_path=structure_image_path, plan=plan,
636
+ )
637
 
638
  yield _progress(
639
  "mix", 0.91, "Mixing",
 
651
 
652
  grid_rms = round(float(grid["rms_ms"]), 1) if grid is not None else None
653
  metrics = {
654
+ "model": model_choice,
655
  "bpm": round(float(bpm), 1),
656
  "notes": len(generated["hits"]),
657
  "spans": len(generated["spans"]),
658
+ "timing": (
659
+ "hierarchical time + grid quantization"
660
+ if bundle["runtime"] == "v18"
661
+ else "legacy time + grid quantization"
662
+ ),
663
  "grid_rms_ms": grid_rms,
664
  "preview_hits": int(mix_stats["rendered_hit_count"]),
665
  }
legacy_softchart/__init__.py ADDED
File without changes
legacy_softchart/generate.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference: full-song chart generation with constrained autoregressive decoding.
2
+
3
+ Features:
4
+ - structural constraints via logit masking: TIME/NOTE alternation, strictly
5
+ increasing times, minimum playable gap (4 frames ~= 46 ms);
6
+ - classifier-free guidance (cfg_w > 1): contrasts conditional vs
7
+ condition-UNK logits to strengthen condition adherence;
8
+ - previous-window pattern context (v2 models): two-pass generation, pass 2
9
+ conditions each window on the tail of the previous window's pass-1 output.
10
+ """
11
+
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
+
21
+
22
+ def _dev_type(device):
23
+ return device.split(":")[0] if isinstance(device, str) else device.type
24
+
25
+
26
+ def _autocast(device):
27
+ """bf16 autocast on CUDA; no-op elsewhere (MPS/CPU run fp32)."""
28
+ import contextlib
29
+
30
+ if _dev_type(device) == "cuda":
31
+ return torch.autocast("cuda", dtype=torch.bfloat16)
32
+ return contextlib.nullcontext()
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
+
106
+ def load_hf(repo_or_dir, device="cuda"):
107
+ """Load a Hugging Face SoftChartGenerator (safetensors) and attach the
108
+ capability flags that generate_song() reads, so HF models are drop-in."""
109
+ from .hf import SoftChartGenerator
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]
130
+ if mode is not None: # dual-mode output-semantics switch
131
+ seq.append(v.mode[mode])
132
+ if ctx_types is not None:
133
+ ctx = list(ctx_types)[-CTX_LEN:]
134
+ ctx = [v.unk_cond] * (CTX_LEN - len(ctx)) + [v.note[NOTE_CLASSES[c]] if isinstance(c, int) else v.note[c] for c in ctx]
135
+ seq += ctx + [v.sep]
136
+ if sib_pairs is not None: # fixed 12-event sibling segment (unk-padded)
137
+ from .vocab import SIB_EVENTS
138
+
139
+ seq.append(v.sib)
140
+ pairs = list(sib_pairs)[:SIB_EVENTS]
141
+ for f, c in pairs:
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:
156
+ seq.append(v.sync[sync_band] if sync_band >= 0 else v.unk_cond)
157
+ if plan_slice is not None: # fixed PLAN_SLOTS blocks, unk-padded
158
+ from .vocab import PLAN_SLOTS
159
+
160
+ seq.append(v.plan)
161
+ blocks = list(plan_slice)[:PLAN_SLOTS]
162
+ for d8, fl in blocks:
163
+ seq += [v.pdens[min(7, max(0, int(d8)))], v.pflag[min(2, max(0, int(fl)))]]
164
+ seq += [v.unk_cond] * (2 * (PLAN_SLOTS - len(blocks)))
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
175
+ B = mels.shape[0]
176
+ plen = len(prefixes[0])
177
+ assert all(len(p) == plen for p in prefixes)
178
+ use_cfg = cfg_w and cfg_w > 0 and not greedy
179
+
180
+ gen_device = "cpu" if _dev_type(device) == "mps" else device
181
+ gen = torch.Generator(device=gen_device)
182
+ gen.manual_seed(seed)
183
+ mels_dev = mels.to(device)
184
+ with _autocast(device):
185
+ memory = model.encode(
186
+ mels_dev,
187
+ gsum=gsum.to(device) if gsum is not None else None,
188
+ pos_bucket=pos_buckets.to(device) if pos_buckets is not None else None,
189
+ )
190
+ # clean_phase (v1.6): inject the grid-phase channels into the memory the
191
+ # DECODER reads. No-op for legacy models (in_ch already carried phase).
192
+ memory = model.phase_mem(memory, mels_dev)
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)
204
+ note_ids = torch.tensor(sorted(v.note.values()), device=device)
205
+ # lattice state: previous inter-onset interval (frames); -1 = unknown
206
+ prev_ioi = torch.full((B,), -1, dtype=torch.long, device=device)
207
+ ratios = torch.tensor(LATTICE_RATIOS, device=device)
208
+
209
+ for _ in range(MAX_TGT - plen):
210
+ with _autocast(device):
211
+ logits_all = model.decode(seqs, memory)[:, -1].float()
212
+ if use_cfg:
213
+ logits = logits_all[B:] + cfg_w * (logits_all[:B] - logits_all[B:])
214
+ else:
215
+ logits = logits_all
216
+
217
+ mask = torch.full_like(logits, float("-inf"))
218
+ note_row = torch.full((logits.shape[1],), float("-inf"), device=device)
219
+ note_row[note_ids] = 0.0
220
+ mask[expect_note] = note_row
221
+ idx = (~expect_note).nonzero(as_tuple=True)[0]
222
+ if len(idx):
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")),
230
+ )
231
+ if lattice:
232
+ # soft rhythmic-lattice constraint: once an IOI is established,
233
+ # penalize next onsets whose IOI ratio is not a musical fraction
234
+ # (kills "between two subdivisions" notes at the source)
235
+ pi = prev_ioi[idx]
236
+ active = (pi >= min_gap) & (pi <= 86) # sub-second IOIs only
237
+ if active.any():
238
+ delta = (time_pos - last_time[idx].unsqueeze(1)).float() # (n, W)
239
+ rel = delta / pi.unsqueeze(1).clamp(min=1).float()
240
+ err = (rel.unsqueeze(-1) / ratios - 1.0).abs().min(-1).values
241
+ bad = (err > 0.13) & (rel < 4.0) & (delta > 0)
242
+ pen = torch.where(bad & active.unsqueeze(1),
243
+ torch.full_like(delta, -6.0),
244
+ torch.zeros_like(delta))
245
+ tmask = tmask + pen
246
+ sub[:, v.time0 : v.time0 + WINDOW] = tmask
247
+ mask[idx] = sub
248
+ logits = logits + mask
249
+
250
+ if greedy:
251
+ nxt = logits.argmax(-1)
252
+ else:
253
+ probs = torch.softmax(logits / temperature, dim=-1)
254
+ sp, si = torch.sort(probs, descending=True, dim=-1)
255
+ cum = torch.cumsum(sp, dim=-1)
256
+ keep = cum - sp < top_p
257
+ keep[:, 0] = True
258
+ sp = sp * keep
259
+ sp = sp / sp.sum(-1, keepdim=True)
260
+ if _dev_type(device) == "mps":
261
+ pick = torch.multinomial(sp.cpu(), 1, generator=gen).squeeze(1).to(device)
262
+ else:
263
+ pick = torch.multinomial(sp, 1, generator=gen).squeeze(1)
264
+ nxt = si[torch.arange(B, device=device), pick]
265
+
266
+ nxt = torch.where(done, torch.full_like(nxt, v.pad), nxt)
267
+ step_tok = torch.cat([nxt, nxt], dim=0) if use_cfg else nxt
268
+ seqs = torch.cat([seqs, step_tok.unsqueeze(1)], dim=1)
269
+ is_time = (nxt >= v.time0) & (nxt < v.time0 + WINDOW)
270
+ new_time = nxt - v.time0
271
+ upd = is_time & (last_time >= 0)
272
+ prev_ioi = torch.where(upd, new_time - last_time, prev_ioi)
273
+ last_time = torch.where(is_time, new_time, last_time)
274
+ expect_note = is_time
275
+ done = done | (nxt == v.eos)
276
+ if done.all():
277
+ break
278
+
279
+ out = []
280
+ for b in range(B):
281
+ toks = seqs[b, plen:].tolist()
282
+ events = []
283
+ cur_t = None
284
+ for t in toks:
285
+ if t == v.eos or t == v.pad:
286
+ break
287
+ if v.time0 <= t < v.time0 + WINDOW:
288
+ cur_t = t - v.time0
289
+ elif t in v.id2note and cur_t is not None:
290
+ events.append((cur_t, v.id2note[t]))
291
+ out.append(events)
292
+ return out
293
+
294
+
295
+ def _decode_pass(model, wins, starts, course, level, density_bucket, ctx_lists,
296
+ device, greedy, temperature, top_p, seed, cfg_w, batch_windows,
297
+ gsum=None, T=None, lattice=False, sib_default=False, style=None,
298
+ sync_band=None, plan_blocks=None, plan_default=False, mode=None,
299
+ on_chunk=None):
300
+ all_events = []
301
+ per_window = []
302
+ for i in range(0, len(wins), batch_windows):
303
+ chunk = torch.stack(wins[i : i + batch_windows])
304
+ prefixes = [
305
+ build_prefix(course, level, density_bucket, mode=mode,
306
+ ctx_types=(ctx_lists[i + j] if ctx_lists is not None else None),
307
+ sib_pairs=([] if sib_default else None), style=style,
308
+ sync_band=sync_band,
309
+ plan_slice=(
310
+ [(b[2], b[3]) for b in plan_blocks
311
+ if b[1] > starts[i + j] / FPS
312
+ and b[0] < (starts[i + j] + WINDOW) / FPS]
313
+ if plan_blocks is not None
314
+ else ([] if plan_default else None)))
315
+ for j in range(chunk.shape[0])
316
+ ]
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
330
+ for f, cls in events:
331
+ all_events.append((t_off + f / FPS, cls))
332
+ if on_chunk:
333
+ on_chunk(min(i + batch_windows, len(wins)), len(wins))
334
+ return all_events, per_window
335
+
336
+
337
+ @torch.no_grad()
338
+ def generate_song(model, mel, course, level=None, density_bucket=None, device="cuda",
339
+ greedy=False, temperature=1.0, top_p=0.95, seed=0, batch_windows=8,
340
+ cfg_w=0.0, use_ctx=None, lattice=False, style=None, sync_band=None,
341
+ plan=None, on_progress=None):
342
+ """mel: (n_mels, T). Returns dict with 'hits' and 'spans' in seconds."""
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:
350
+ w = mel[:, s : s + WINDOW]
351
+ if w.shape[1] < WINDOW:
352
+ w = torch.nn.functional.pad(w, (0, WINDOW - w.shape[1]), value=float(np.log(1e-5)))
353
+ if dual: # gridless time mode: phase channels = -1
354
+ w = torch.cat([w, torch.full((2, WINDOW), -1.0)], dim=0)
355
+ wins.append(w)
356
+
357
+ if use_ctx is None:
358
+ use_ctx = getattr(model, "_has_ctx", False)
359
+ sib_default = getattr(model, "_has_sib", False)
360
+ style_val = None
361
+ if getattr(model, "_has_style", False):
362
+ style_val = style if style is not None else -1
363
+ sync_val = None
364
+ if getattr(model, "_has_sync", False):
365
+ sync_val = sync_band if sync_band is not None else -1
366
+ plan_blocks = plan if getattr(model, "_has_plan", False) else None
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,
376
+ None if not use_ctx else [[] for _ in wins], # pass 1: empty ctx
377
+ device, greedy, temperature, top_p, seed, cfg_w, batch_windows,
378
+ gsum=gsum, T=T, lattice=lattice, sib_default=sib_default, style=style_val,
379
+ sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default,
380
+ mode=("time" if dual else None), on_chunk=on_progress)
381
+
382
+ if use_ctx and len(wins) > 1:
383
+ # pass 2: condition each window on the tail of the previous window's pass-1 output
384
+ ctx_lists = [[]]
385
+ for w_ev in per_window[:-1]:
386
+ tail = [NOTE_CLASSES.index(c) for _, c in w_ev if c in HIT_CLASSES][-CTX_LEN:]
387
+ ctx_lists.append(tail)
388
+ all_events, per_window = _decode_pass(
389
+ model, wins, starts, course, level, density_bucket, ctx_lists,
390
+ device, greedy, temperature, top_p, seed, cfg_w, batch_windows,
391
+ gsum=gsum, T=T, lattice=lattice, sib_default=sib_default, style=style_val,
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
421
+ last_hit_t = -1.0
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})
437
+ last_hit_t = t
438
+ elif cls in SPAN_CLASSES:
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)
491
+ ef = (edges[j : j + K + 1] - t0) * FPS
492
+ for m in range(K):
493
+ a, b = ef[m], ef[m + 1]
494
+ i0, i1 = int(np.ceil(a - 1e-6)), min(int(np.ceil(b - 1e-6)), WINDOW)
495
+ if i1 <= i0:
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,
518
+ mode=("slot" if getattr(model, "_dual", False) else None),
519
+ sib_pairs=([] if sib_default else None), style=style_val,
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 = [], [], [], []
540
+ open_span = None
541
+ last_key = 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})
557
+ hits_slots.append((me, sl, cls))
558
+ last_key = (me, sl)
559
+ elif cls in SPAN_CLASSES:
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}
legacy_softchart/grid.py ADDED
@@ -0,0 +1,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Precise global beat-grid estimation from the beat head's activations.
2
+
3
+ Why this exists: the beat head predicts beat/downbeat activations on the
4
+ encoder's 4-frame (~46.4 ms) grid. Raw peak picking + median(diff) is far too
5
+ imprecise for barline anchoring — each peak carries +-23 ms bin-quantization
6
+ noise, a single-peak anchor offsets the whole chart by 1-3 TJA slots, and a
7
+ 0.1% period error drifts ~200 ms over a three-minute song. That is exactly the
8
+ "notes are not on the barline" failure mode.
9
+
10
+ Fix: a TJA needs one rigid grid anyway (constant BPM + one OFFSET), so we
11
+ estimate (period, phase) GLOBALLY from every peak in the song:
12
+
13
+ 1. activation curves over OVERLAPPING windows (hop = WINDOW/2), using BOTH
14
+ channels (ch0 = beat, ch1 = downbeat) — ~5x more evidence than
15
+ downbeats-only;
16
+ 2. sub-bin peak times via parabolic interpolation (46 ms bins -> ~5-10 ms);
17
+ 3. robust periodic regression t_k ~= phase + k*period with integer-index
18
+ assignment and outlier rejection, initialized at bar level and refined at
19
+ beat level (precision grows ~1/(N*span): 100+ bars pins BPM to <0.01);
20
+ 4. integer / half-integer BPM snap, accepted only when the residual does not
21
+ degrade (community charts are almost always integer BPM);
22
+ 5. bar phase chosen by downbeat-activation voting over the meter offsets.
23
+
24
+ Residual statistics are returned so callers can fall back (no barline
25
+ anchoring) when the song does not have a constant tempo.
26
+ """
27
+
28
+ import numpy as np
29
+ import torch
30
+
31
+ from .vocab import FPS, WINDOW
32
+
33
+ BIN = 4.0 / FPS # activation bin length in seconds (~46.4 ms)
34
+
35
+
36
+ @torch.no_grad()
37
+ def beat_activations(model, mel, device="cuda", hop=WINDOW // 2):
38
+ """Averaged beat/downbeat activation curves over overlapping windows.
39
+
40
+ mel: (n_mels, T). Returns (acc, bin_s): acc is (2, ceil(T/factor)) with
41
+ row 0 = beat, row 1 = downbeat; bin_s is the bin length in seconds.
42
+ The head resolution is auto-detected: the classic linear head runs at
43
+ encoder rate (4-frame bins, ~46 ms), the hi-res head at frame rate
44
+ (~11.6 ms bins).
45
+ """
46
+ from .generate import _autocast
47
+
48
+ if isinstance(mel, torch.Tensor):
49
+ mel = mel.numpy()
50
+ T = mel.shape[1]
51
+ L = WINDOW // 4
52
+ acc = cnt = None
53
+ factor = 4
54
+ for st in range(0, max(T - hop, 1), hop):
55
+ w = torch.from_numpy(mel[:, st : st + WINDOW].astype(np.float32))
56
+ if w.shape[1] < WINDOW:
57
+ w = torch.nn.functional.pad(w, (0, WINDOW - w.shape[1]),
58
+ value=float(np.log(1e-5)))
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]
66
+ nbin = (T + factor - 1) // factor
67
+ acc = np.zeros((2, nbin))
68
+ cnt = np.zeros(nbin)
69
+ b0 = st // factor
70
+ n = min(pr.shape[1], nbin - b0) # skip bins past the real song end
71
+ if n > 0:
72
+ acc[:, b0 : b0 + n] += pr[:, :n]
73
+ cnt[b0 : b0 + n] += 1
74
+ if acc is None:
75
+ return np.zeros((2, 1)), factor / FPS
76
+ return acc / np.maximum(cnt, 1), factor / FPS
77
+
78
+
79
+ def _refined_peaks(act, height, min_dist_s, bin_s=BIN):
80
+ """Peak times (seconds) with sub-bin parabolic interpolation."""
81
+ from scipy.signal import find_peaks
82
+
83
+ idx, _ = find_peaks(act, height=height, distance=max(2, int(min_dist_s / bin_s)))
84
+ out = []
85
+ for i in idx:
86
+ dx = 0.0
87
+ if 0 < i < len(act) - 1:
88
+ d = act[i - 1] - 2 * act[i] + act[i + 1]
89
+ if d < -1e-9:
90
+ dx = float(np.clip(0.5 * (act[i - 1] - act[i + 1]) / d, -0.5, 0.5))
91
+ # +0.5: training targets bin FLOOR(t*FPS/div), so a peak at bin i
92
+ # means the event sits at the bin CENTER — without this everything
93
+ # reads one half-bin early
94
+ out.append((i + dx + 0.5) * bin_s)
95
+ return np.asarray(out)
96
+
97
+
98
+ def _fit_periodic(t, period, phase, reject=0.16, iters=6):
99
+ """Robust regression t_i ~= phase + k_i*period with unknown integer k_i.
100
+ Returns (period, phase, inlier_mask, rms_seconds)."""
101
+ t = np.asarray(t, float)
102
+ keep = np.ones(len(t), bool)
103
+ for _ in range(iters):
104
+ k = np.round((t - phase) / period)
105
+ r = t - (phase + k * period)
106
+ keep = np.abs(r) < reject * period
107
+ if keep.sum() < 4:
108
+ return period, phase, keep, float("inf")
109
+ kk, tt = k[keep], t[keep]
110
+ km = kk.mean()
111
+ denom = float(((kk - km) ** 2).sum())
112
+ if denom < 1e-9:
113
+ break
114
+ period = float(((kk - km) * (tt - tt.mean())).sum() / denom)
115
+ phase = float(tt.mean() - period * km)
116
+ r = t - (phase + np.round((t - phase) / period) * period)
117
+ rms = float(np.sqrt(np.mean(r[keep] ** 2)))
118
+ return period, phase, keep, rms
119
+
120
+
121
+ def _refit_phase(t, period, phase, reject=0.22, iters=3):
122
+ """Phase-only refit for a FIXED period (used by the BPM-snap step)."""
123
+ t = np.asarray(t, float)
124
+ keep = np.ones(len(t), bool)
125
+ for _ in range(iters):
126
+ k = np.round((t - phase) / period)
127
+ r = t - (phase + k * period)
128
+ keep = np.abs(r) < reject * period
129
+ if keep.sum() < 4:
130
+ return phase, keep, float("inf")
131
+ phase += float(np.median(r[keep]))
132
+ r = t - (phase + np.round((t - phase) / period) * period)
133
+ rms = float(np.sqrt(np.mean(r[keep] ** 2)))
134
+ return phase, keep, rms
135
+
136
+
137
+ @torch.no_grad()
138
+ def fit_grid(model, mel, device="cuda", meter=None):
139
+ """Estimate a rigid (BPM, barline phase) grid for the whole song.
140
+
141
+ Returns a dict with bpm / beat / bar / phase (a barline time), synthesized
142
+ downbeats+beats covering the song, and fit diagnostics (inlier_frac,
143
+ rms_ms, ok). Returns None when there are not enough peaks to fit.
144
+ """
145
+ if isinstance(mel, torch.Tensor):
146
+ mel_np = mel.numpy()
147
+ else:
148
+ mel_np = mel
149
+ act, bin_s = beat_activations(model, mel_np, device)
150
+ dur = mel_np.shape[1] / FPS
151
+ t_d = _refined_peaks(act[1], height=0.35, min_dist_s=0.9, bin_s=bin_s)
152
+ t_b = _refined_peaks(act[0], height=0.35, min_dist_s=0.22, bin_s=bin_s)
153
+ if len(t_d) < 4:
154
+ return None
155
+
156
+ # 1) bar-level robust fit (several phase inits; first peak may be spurious)
157
+ p0 = float(np.median(np.diff(t_d)))
158
+ fits = [_fit_periodic(t_d, p0, float(ph)) for ph in t_d[:3]]
159
+ p_bar0, phi_d, keep_d, rms_d = min(fits, key=lambda f: f[3])
160
+
161
+ # 2) meter from the bar/beat period ratio; taiko is almost always 4/4
162
+ if meter is None:
163
+ meter = 4
164
+ if len(t_b) >= 8:
165
+ m = p_bar0 / max(float(np.median(np.diff(t_b))), 1e-6)
166
+ if abs(m - 3) < 0.25:
167
+ meter = 3
168
+
169
+ # 3) beat-level refinement with ALL peaks (downbeats are beats too)
170
+ t_all = np.concatenate([t_b, t_d]) if len(t_b) else t_d
171
+ p_beat, phi_beat, keep_a, rms_a = _fit_periodic(
172
+ t_all, p_bar0 / meter, phi_d, reject=0.22)
173
+ if not np.isfinite(rms_a):
174
+ p_beat, phi_beat, rms_a = p_bar0 / meter, phi_d, rms_d
175
+
176
+ # 4) integer / half-integer BPM snap + octave normalization: prefer the
177
+ # common-range notation (a 120 song fit at 60 plays identically but
178
+ # notates ugly), accept only if the residual holds up
179
+ bpm = 60.0 / p_beat
180
+ cands = [round(bpm), round(bpm * 2) / 2]
181
+ if bpm < 80: # octave normalization: prefer common-range notation
182
+ cands = [round(bpm * 2), round(bpm * 2) * 1.0] + cands
183
+ elif bpm > 210:
184
+ cands = [round(bpm / 2)] + cands
185
+ # rank: in [80, 210] first, integers before halves, keep insertion order
186
+ ranked = sorted(dict.fromkeys(cands),
187
+ key=lambda c: (not (80 <= c <= 210), abs(c - round(c)) > 0,
188
+ cands.index(c)))
189
+ best = (rms_a, bpm, p_beat, phi_beat)
190
+ for cand in ranked:
191
+ if not 40 <= cand <= 320 or not (0.4 <= cand / max(bpm, 1e-6) <= 2.5):
192
+ continue
193
+ pb = 60.0 / cand
194
+ ph, _, rms = _refit_phase(t_all, pb, phi_beat)
195
+ if rms <= best[0] + 0.0015: # snap unless it costs >1.5 ms rms
196
+ best = (rms, float(cand), pb, ph)
197
+ break
198
+ rms_a, bpm, p_beat, phi_beat = best
199
+ p_bar = meter * p_beat
200
+
201
+ # 5) bar phase: among the `meter` beat offsets, pick the one whose barline
202
+ # comb collects the most downbeat activation
203
+ nb = act.shape[1]
204
+
205
+ def _comb_score(ph):
206
+ ts = np.arange(ph if ph >= 0 else ph + p_bar, dur, p_bar)
207
+ ix = np.clip(np.round(ts / bin_s).astype(int), 0, nb - 1)
208
+ return float(act[1][ix].sum())
209
+
210
+ base = phi_beat + np.round((phi_d - phi_beat) / p_beat) * p_beat
211
+ offs = [(base + j * p_beat - p_bar * np.floor((base + j * p_beat) / p_bar))
212
+ for j in range(meter)]
213
+ phase = max(offs, key=_comb_score)
214
+
215
+ k_max = int(np.floor((dur - phase) / p_bar)) + 1
216
+ downbeats = phase + np.arange(0, max(k_max, 1)) * p_bar
217
+ downbeats = downbeats[(downbeats >= 0) & (downbeats < dur)]
218
+ beats = phase + np.arange(0, int((dur - phase) / p_beat) + 1) * p_beat
219
+ beats = beats[(beats >= 0) & (beats < dur)]
220
+
221
+ inlier = float(keep_a.mean()) if len(keep_a) else 0.0
222
+ rms_ms = rms_a * 1000.0
223
+ return {
224
+ "bpm": float(bpm), "beat": float(p_beat), "bar": float(p_bar),
225
+ "meter": int(meter), "phase": float(phase),
226
+ "downbeats": downbeats, "beats": beats,
227
+ "n_db_peaks": int(len(t_d)), "n_beat_peaks": int(len(t_b)),
228
+ "inlier_frac": inlier, "rms_ms": float(rms_ms),
229
+ "db_peaks": t_d, # raw refined peaks, for diagnostics/eval
230
+ "ok": bool(inlier >= 0.6 and rms_ms <= 30.0 and len(t_d) >= 8),
231
+ }
232
+
233
+
234
+ @torch.no_grad()
235
+ def fit_grid_fixed_bpm(model, mel, bpm, device="cuda", meter=4):
236
+ """Grid fit with a USER-SUPPLIED BPM: the period is trusted, only the
237
+ phase is estimated (a far easier problem than the free fit — this often
238
+ unlocks the slot-exact path on songs whose free fit fails on octave or
239
+ period confusion). Returns a grid dict or None."""
240
+ if isinstance(mel, torch.Tensor):
241
+ mel = mel.numpy()
242
+ act, bin_s = beat_activations(model, mel, device)
243
+ dur = mel.shape[1] / FPS
244
+ p_beat = 60.0 / float(bpm)
245
+ p_bar = meter * p_beat
246
+ t_d = _refined_peaks(act[1], height=0.30, min_dist_s=0.5, bin_s=bin_s)
247
+ t_b = _refined_peaks(act[0], height=0.30, min_dist_s=0.22, bin_s=bin_s)
248
+ t_all = np.concatenate([t_b, t_d]) if len(t_b) else t_d
249
+ if len(t_all) < 8:
250
+ return None
251
+ # beat-level phase (dense evidence), then bar phase by activation voting
252
+ ph, keep, rms = _refit_phase(t_all, p_beat, float(np.median(t_all[:5])), iters=5)
253
+ if not np.isfinite(rms):
254
+ return None
255
+ nb = act.shape[1]
256
+
257
+ def _score(o):
258
+ ts = np.arange(o - np.floor(o / p_bar) * p_bar, dur, p_bar)
259
+ ix = np.clip(np.round(ts / bin_s).astype(int), 0, nb - 1)
260
+ return float(act[1][ix].sum())
261
+
262
+ phase = max((ph + j * p_beat for j in range(meter)), key=_score)
263
+ phase -= np.floor(phase / p_bar) * p_bar
264
+ downbeats = phase + np.arange(0, int((dur - phase) / p_bar) + 1) * p_bar
265
+ downbeats = downbeats[(downbeats >= 0) & (downbeats < dur)]
266
+ beats = phase + np.arange(0, int((dur - phase) / p_beat) + 1) * p_beat
267
+ beats = beats[(beats >= 0) & (beats < dur)]
268
+ inlier = float(keep.mean()) if len(keep) else 0.0
269
+ return {
270
+ "bpm": float(bpm), "beat": p_beat, "bar": p_bar, "meter": meter,
271
+ "phase": float(phase), "downbeats": downbeats, "beats": beats,
272
+ "n_db_peaks": int(len(t_d)), "n_beat_peaks": int(len(t_b)),
273
+ "inlier_frac": inlier, "rms_ms": float(rms * 1000),
274
+ "db_peaks": t_d, "fixed_bpm": True,
275
+ # period is user-trusted, so the gate only needs the PHASE to be sane
276
+ "ok": bool(inlier >= 0.5 and rms * 1000 <= 40.0),
277
+ }
278
+
279
+
280
+ # --- adaptive piecewise-constant tempo fit ------------------------------------
281
+ # The BPM census over all 1155 songs: 34% are multi-BPM; among them the main
282
+ # tempo covers a median 91% of measures, contiguous-segment count is <=4 for
283
+ # 82% of songs but p99 = 29 (chained ritardando). So segmentation must be
284
+ # ADAPTIVE (change-point DP with a per-segment penalty), not a fixed K.
285
+
286
+
287
+ def _seg_fit(t, reject=0.16):
288
+ """Fit t_k ~= phase + k*period over ONE segment of consecutive downbeat
289
+ peaks (missing peaks allowed: k advances by round(gap/period)).
290
+ Returns (period, phase, sse_seconds^2, n_inlier)."""
291
+ t = np.asarray(t, float)
292
+ if len(t) < 2:
293
+ return None
294
+ d = np.diff(t)
295
+ p0 = float(np.median(d))
296
+ if p0 <= 0:
297
+ return None
298
+ k = np.concatenate([[0], np.cumsum(np.maximum(1, np.round(d / p0)))])
299
+ for _ in range(3):
300
+ km, tm = k.mean(), t.mean()
301
+ denom = float(((k - km) ** 2).sum())
302
+ if denom < 1e-9:
303
+ return None
304
+ p = float(((k - km) * (t - tm)).sum() / denom)
305
+ if p <= 0:
306
+ return None
307
+ phi = float(tm - p * km)
308
+ r = t - (phi + k * p)
309
+ k = k + np.round(-r / p) # re-assign indices after refinement
310
+ r = t - (phi + k * p)
311
+ keep = np.abs(r) < reject * p
312
+ sse = float((r[keep] ** 2).sum()) + float((~keep).sum()) * (reject * p) ** 2
313
+ return p, phi, sse, int(keep.sum())
314
+
315
+
316
+ def fit_grid_piecewise(model, mel, device="cuda", dev_thresh=0.18,
317
+ min_run=3, seg_rms_ms=25.0):
318
+ """Main-grid + spliced exceptions (v2 after the DP version over-segmented).
319
+
320
+ The BPM census says multi-BPM songs are ~91% main-tempo, so: fit the rigid
321
+ grid first (full-song precision, outliers rejected), then find CONTIGUOUS
322
+ runs of downbeat peaks that consistently deviate from it, refit each run
323
+ locally, and splice. Segment count stays small by construction and the
324
+ main grid keeps its precision. Returns None if the main fit fails.
325
+ """
326
+ if isinstance(mel, torch.Tensor):
327
+ mel = mel.numpy()
328
+ g = fit_grid(model, mel, device=device)
329
+ if g is None:
330
+ return None
331
+ t_d = g["db_peaks"]
332
+ if len(t_d) < 8:
333
+ return None
334
+ if not g["ok"]:
335
+ # the single rigid grid failed: true multi-tempo song. Try every
336
+ # coarse split point, fit both halves rigidly, keep the best pair
337
+ # (recursion depth 1 -> up to 2 segments; census: covers most cases).
338
+ best = None
339
+ for cut in range(6, len(t_d) - 6, 3):
340
+ fa = _seg_fit(t_d[:cut])
341
+ fb = _seg_fit(t_d[cut:])
342
+ if fa is None or fb is None:
343
+ continue
344
+ rms = np.sqrt((fa[2] + fb[2]) / len(t_d))
345
+ if best is None or rms < best[0]:
346
+ best = (rms, cut, fa, fb)
347
+ if best is not None and best[0] * 1000 <= 30.0:
348
+ _, cut, (pa, fia, _, na), (pb, fib, _, nb2) = best
349
+ dur = mel.shape[1] / FPS
350
+ t_mid = 0.5 * (t_d[cut - 1] + t_d[cut])
351
+ da = fia + np.arange(0, int(np.ceil((t_mid - fia) / pa)) + 1) * pa
352
+ da = da[(da >= 0) & (da < t_mid)]
353
+ db2 = fib + np.arange(0, int(np.ceil((dur - fib) / pb)) + 1) * pb
354
+ db2 = db2[(db2 >= t_mid) & (db2 < dur)]
355
+ downbeats = np.sort(np.concatenate([da, db2]))
356
+ inl = (na + nb2) / len(t_d)
357
+ return {**g, "downbeats": downbeats,
358
+ "bpm": 240.0 / pa, "beat": 60.0 / (240.0 / pa) if pa else g["beat"],
359
+ "bar": pa, "phase": float(downbeats[0]) if len(downbeats) else g["phase"],
360
+ "segments": [(float(fia), 240.0 / pa), (float(t_mid), 240.0 / pb)],
361
+ "piecewise": True, "n_segments": 2,
362
+ "inlier_frac": float(inl), "rms_ms": float(best[0] * 1000),
363
+ "ok": bool(inl >= 0.7)}
364
+ return {**g, "piecewise": False, "n_segments": 1,
365
+ "segments": [(float(g["phase"]), float(g["bpm"]))]}
366
+ bar = g["bar"]
367
+ phi = g["phase"]
368
+ dur = mel.shape[1] / FPS
369
+ r = t_d - (phi + np.round((t_d - phi) / bar) * bar)
370
+ bad = np.abs(r) > dev_thresh * bar
371
+ # contiguous deviant runs of >= min_run peaks
372
+ runs = []
373
+ i = 0
374
+ while i < len(t_d):
375
+ if bad[i]:
376
+ j = i
377
+ while j + 1 < len(t_d) and bad[j + 1]:
378
+ j += 1
379
+ if j - i + 1 >= min_run:
380
+ runs.append((i, j))
381
+ i = j + 1
382
+ else:
383
+ i += 1
384
+ segments = [(float(phi), float(g["bpm"]))]
385
+ if not runs: # effectively constant: return the rigid fit unchanged
386
+ return {**g, "piecewise": False, "n_segments": 1,
387
+ "segments": segments}
388
+
389
+ spliced = []
390
+ n_bad_fixed = 0
391
+ for i, j in runs:
392
+ f = _seg_fit(t_d[i : j + 1])
393
+ if f is None:
394
+ continue
395
+ p_loc, phi_loc, sse, ninl = f
396
+ rms = np.sqrt(sse / max(j - i + 1, 1)) * 1000
397
+ if rms > seg_rms_ms or not (0.8 <= p_loc <= 8.0):
398
+ continue # local section too messy: leave it to the main grid
399
+ bpm_loc = 240.0 / p_loc
400
+ if abs(bpm_loc - round(bpm_loc)) < 0.35:
401
+ p2 = 240.0 / round(bpm_loc)
402
+ rr = t_d[i:j+1] - (phi_loc + np.round((t_d[i:j+1] - phi_loc) / p2) * p2)
403
+ if float(np.sqrt(np.mean(rr ** 2))) * 1000 <= rms * 1.3 + 4:
404
+ p_loc = p2
405
+ phi_loc = phi_loc + float(np.median(rr))
406
+ bpm_loc = float(round(bpm_loc))
407
+ t_lo = t_d[i] - 0.25 * p_loc
408
+ t_hi = (t_d[j] + p_loc) if j + 1 >= len(t_d) else t_d[j + 1] - 0.25 * bar
409
+ spliced.append((t_lo, t_hi, p_loc, phi_loc, bpm_loc))
410
+ n_bad_fixed += j - i + 1
411
+
412
+ if not spliced: # nothing splice-worthy: keep the rigid fit verbatim
413
+ return {**g, "piecewise": False, "n_segments": 1, "segments": segments}
414
+
415
+ # main-grid barlines outside spliced spans + local barlines inside
416
+ main_db = phi + np.arange(0, int(np.ceil((dur - phi) / bar)) + 1) * bar
417
+ main_db = main_db[(main_db >= 0) & (main_db < dur)]
418
+ keep = np.ones(len(main_db), bool)
419
+ downbeats = []
420
+ for t_lo, t_hi, p_loc, phi_loc, bpm_loc in spliced:
421
+ keep &= ~((main_db >= t_lo) & (main_db < t_hi))
422
+ loc = phi_loc + np.arange(0, int(np.ceil((t_hi - phi_loc) / p_loc)) + 1) * p_loc
423
+ loc = loc[(loc >= max(t_lo, 0)) & (loc < min(t_hi, dur))]
424
+ downbeats.append(loc)
425
+ segments.append((float(max(t_lo, 0.0)), float(bpm_loc)))
426
+ downbeats.append(main_db[keep])
427
+ downbeats = np.concatenate(downbeats)
428
+ downbeats = np.sort(downbeats)
429
+ dk = np.concatenate([[True], np.diff(downbeats) > 0.3])
430
+ downbeats = downbeats[dk]
431
+
432
+ # residual after splicing (all peaks vs nearest final barline)
433
+ err = np.array([np.min(np.abs(downbeats - t)) for t in t_d])
434
+ inlier = float(np.mean(err < dev_thresh * bar))
435
+ rms_ms = float(np.sqrt(np.mean(np.minimum(err, dev_thresh * bar) ** 2)) * 1000)
436
+ return {
437
+ **g,
438
+ "downbeats": downbeats, "segments": sorted(segments),
439
+ "piecewise": bool(spliced), "n_segments": 1 + len(spliced),
440
+ "inlier_frac": inlier, "rms_ms": rms_ms,
441
+ # splices only ADD local corrections to an already-vetted main grid —
442
+ # never downgrade the rigid fit's verdict
443
+ "ok": bool(g["ok"] or (inlier >= 0.75 and rms_ms <= 35.0)),
444
+ }
445
+
446
+
447
+ # --- onset evidence: metrically weighted slot-comb refinement -----------------
448
+ # Audio onsets (spectral flux) are ~4x sharper in time than the beat head's
449
+ # 46 ms bins and 10-20x denser than downbeats. They cannot determine the
450
+ # metrical LEVEL (which line is beat 1) — but given the beat head's fit as an
451
+ # anchor, they lock (period, phase) far more precisely, and they can rescue
452
+ # songs whose beat-head activations are confused but whose percussion is clean.
453
+ # The comb is the TJA slot lattice (beat/24) with metrical-hierarchy weights:
454
+ # occupancy concentrates on strong positions, which is what makes a 16 ms
455
+ # lattice identifiable at all (a flat comb that fine would fit noise).
456
+
457
+ _W24 = np.full(24, 0.05)
458
+ _W24[0] = 1.0 # beat
459
+ _W24[12] = 0.55 # 8th
460
+ _W24[[6, 18]] = 0.30 # 16ths
461
+ _W24[[8, 16]] = 0.22 # 8th triplets
462
+ _W24[[3, 9, 15, 21]] = 0.10 # 32nds
463
+ _W24[[4, 20]] = 0.10 # 16th triplets
464
+
465
+
466
+ def onset_peaks(mel, max_n=3000):
467
+ """Spectral-flux onset times (seconds) with sub-frame refinement."""
468
+ from scipy.signal import find_peaks
469
+
470
+ flux = np.maximum(0, np.diff(mel.astype(np.float32), axis=1)).sum(0)
471
+ flux = np.concatenate([[0.0], flux])
472
+ med = np.median(flux)
473
+ idx, props = find_peaks(flux, height=med * 1.5, distance=max(2, int(0.035 * FPS)))
474
+ if len(idx) > max_n: # keep the strongest
475
+ keep = np.argsort(props["peak_heights"])[-max_n:]
476
+ idx = np.sort(idx[keep])
477
+ out = []
478
+ for i in idx:
479
+ dx = 0.0
480
+ if 0 < i < len(flux) - 1:
481
+ d = flux[i - 1] - 2 * flux[i] + flux[i + 1]
482
+ if d < -1e-9:
483
+ dx = float(np.clip(0.5 * (flux[i - 1] - flux[i + 1]) / d, -0.5, 0.5))
484
+ out.append((i + dx) / FPS)
485
+ return np.asarray(out)
486
+
487
+
488
+ def comb_score(onsets, period_beat, phase, sig=0.012):
489
+ """Sum over onsets of hierarchy-weight x Gaussian(dist to nearest slot)."""
490
+ step = period_beat / 24.0
491
+ x = (onsets - phase) / step
492
+ k = np.round(x)
493
+ dist = (x - k) * step
494
+ w = _W24[(k.astype(int)) % 24]
495
+ return float(np.sum(w * np.exp(-0.5 * (dist / sig) ** 2)))
496
+
497
+
498
+ def onset_polish(grid, mel, span_bpm=0.6, span_ms=30, n_p=25, n_f=31):
499
+ """Local (period, phase) refinement of a trusted fit on onset evidence.
500
+ Returns an updated grid dict (or the original if no improvement)."""
501
+ ons = onset_peaks(mel)
502
+ if len(ons) < 30 or grid is None:
503
+ return grid
504
+ p0, f0 = grid["beat"], grid["phase"]
505
+ base = comb_score(ons, p0, f0)
506
+ best = (base, p0, f0)
507
+ for p in np.linspace(60.0 / (grid["bpm"] + span_bpm), 60.0 / (grid["bpm"] - span_bpm), n_p):
508
+ for f in f0 + np.linspace(-span_ms / 1000, span_ms / 1000, n_f):
509
+ s = comb_score(ons, p, f)
510
+ if s > best[0]:
511
+ best = (s, float(p), float(f))
512
+ s1, p1, f1 = best
513
+ if s1 <= base * 1.02: # <2% gain: keep the beat-head fit
514
+ return grid
515
+ out = dict(grid)
516
+ meter = grid.get("meter", 4)
517
+ out["beat"], out["bpm"], out["bar"] = p1, 60.0 / p1, meter * p1
518
+ # keep the bar phase on the SAME barline, re-expressed on the new beat grid
519
+ out["phase"] = f1 + round((grid["phase"] - f1) / p1) * p1
520
+ dur = mel.shape[1] / FPS
521
+ ph, bar = out["phase"], out["bar"]
522
+ ph -= np.floor(ph / bar) * bar
523
+ out["downbeats"] = np.arange(ph, dur, bar)
524
+ out["beats"] = np.arange(ph - np.floor(ph / p1) * p1, dur, p1)
525
+ out["onset_gain"] = s1 / max(comb_score(ons, p0, f0), 1e-9)
526
+ return out
527
+
528
+
529
+ def onset_rescue(mel, act=None, bpm_range=(65, 210)):
530
+ """Grid estimation from onsets alone (+ optional downbeat activation for
531
+ the bar phase) — for songs where the beat-head fit failed. Returns a grid
532
+ dict with ok=True only when the comb evidence is decisive."""
533
+ ons = onset_peaks(mel)
534
+ if len(ons) < 40:
535
+ return None
536
+ dur = mel.shape[1] / FPS
537
+ # tempo candidates from the IOI histogram (mode + octaves)
538
+ iois = np.diff(ons)
539
+ iois = iois[(iois > 0.08) & (iois < 2.0)]
540
+ if len(iois) < 20:
541
+ return None
542
+ hist, edges = np.histogram(iois, bins=192, range=(0.08, 2.0))
543
+ cand_p = []
544
+ for i in np.argsort(hist)[-6:]:
545
+ c = 0.5 * (edges[i] + edges[i + 1])
546
+ for mult in (0.5, 1.0, 2.0, 4.0):
547
+ p = c * mult
548
+ if 60.0 / bpm_range[1] <= p <= 60.0 / bpm_range[0]:
549
+ cand_p.append(p)
550
+ best = (0.0, None, None)
551
+ for p in sorted(set(np.round(cand_p, 4))):
552
+ for f in np.arange(0.0, p, p / 48):
553
+ s = comb_score(ons, p, f, sig=0.015)
554
+ if s > best[0]:
555
+ best = (s, float(p), float(f))
556
+ if best[1] is None:
557
+ return None
558
+ # fine polish around the winner
559
+ g0 = {"beat": best[1], "bpm": 60.0 / best[1], "phase": best[2], "meter": 4,
560
+ "bar": 4 * best[1]}
561
+ # integer-BPM snap when it costs <2% of the comb score
562
+ bpm = g0["bpm"]
563
+ for candb in (round(bpm), round(bpm * 2) / 2):
564
+ if abs(candb - bpm) < 0.6 and candb > 0:
565
+ pb = 60.0 / candb
566
+ fb = max(np.arange(0.0, pb, pb / 96),
567
+ key=lambda f: comb_score(ons, pb, f))
568
+ if comb_score(ons, pb, fb) >= 0.98 * best[0]:
569
+ g0.update(beat=pb, bpm=float(candb), phase=float(fb), bar=4 * pb)
570
+ break
571
+ # bar phase: downbeat-activation voting among the 4 beat offsets
572
+ ph = g0["phase"]
573
+ if act is not None:
574
+ if isinstance(act, tuple):
575
+ act, _bin = act
576
+ else:
577
+ _bin = BIN
578
+ nb = act.shape[1]
579
+
580
+ def _sc(o):
581
+ ts = np.arange(o - np.floor(o / g0["bar"]) * g0["bar"], dur, g0["bar"])
582
+ ix = np.clip(np.round(ts / _bin).astype(int), 0, nb - 1)
583
+ return float(act[1][ix].sum())
584
+
585
+ ph = max((g0["phase"] + jj * g0["beat"] for jj in range(4)), key=_sc)
586
+ ph -= np.floor(ph / g0["bar"]) * g0["bar"]
587
+ # decisiveness gate: winner must clearly beat the runner-up octave
588
+ alt = comb_score(ons, g0["beat"] * 2, ph) + comb_score(ons, g0["beat"] / 2, ph)
589
+ score = comb_score(ons, g0["beat"], ph)
590
+ ok = bool(score > 0.25 * len(ons) and score > 0.75 * alt)
591
+ return {"bpm": g0["bpm"], "beat": g0["beat"], "bar": g0["bar"], "meter": 4,
592
+ "phase": float(ph),
593
+ "downbeats": np.arange(ph, dur, g0["bar"]),
594
+ "beats": np.arange(ph - np.floor(ph / g0["beat"]) * g0["beat"], dur, g0["beat"]),
595
+ "n_db_peaks": 0, "n_beat_peaks": int(len(ons)),
596
+ "inlier_frac": float(score / max(len(ons), 1)), "rms_ms": -1.0,
597
+ "db_peaks": np.asarray([]), "ok": ok, "rescued": True}
598
+
599
+
600
+ def debias_to_grid(times, phase, step):
601
+ """Remove the generator's systematic latency: median signed offset of
602
+ `times` to the nearest grid subdivision. Returns (shifted_times, offset).
603
+ Relative timing (groove, tuplets) is untouched — this is a global shift."""
604
+ t = np.asarray(times, float)
605
+ if len(t) == 0:
606
+ return t, 0.0
607
+ r = (t - phase + step / 2) % step - step / 2
608
+ off = float(np.median(r))
609
+ return t - off, off
legacy_softchart/hf.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(
23
+ nn.Module,
24
+ PyTorchModelHubMixin,
25
+ repo_url="https://github.com/JacobLinCool/SoftChart",
26
+ license="mit",
27
+ tags=["taiko", "rhythm-game", "chart-generation", "music", "audio-to-symbolic"],
28
+ ):
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
56
+ self.capabilities = capabilities or {}
57
+
58
+ # delegate the generation interface to the inner net
59
+ def encode(self, *a, **k):
60
+ return self.net.encode(*a, **k)
61
+
62
+ def decode(self, *a, **k):
63
+ return self.net.decode(*a, **k)
64
+
65
+ def forward(self, *a, **k):
66
+ return self.net(*a, **k)
67
+
68
+ @property
69
+ def ptr(self):
70
+ return self.net.ptr
71
+
72
+ @property
73
+ def beat(self):
74
+ return self.net.beat
75
+
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)
legacy_softchart/model.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Encoder-decoder Transformer: log-mel windows -> chart token sequences."""
2
+
3
+ import math
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+ from .vocab import N_MELS, VOCAB, WINDOW
9
+
10
+
11
+ def sinusoidal(length, dim):
12
+ pos = torch.arange(length, dtype=torch.float32)[:, None]
13
+ i = torch.arange(dim // 2, dtype=torch.float32)[None]
14
+ angle = pos / torch.pow(10000.0, 2 * i / dim)
15
+ emb = torch.zeros(length, dim)
16
+ emb[:, 0::2] = torch.sin(angle)
17
+ emb[:, 1::2] = torch.cos(angle)
18
+ return emb
19
+
20
+
21
+ class BeatHiRes(nn.Module):
22
+ """Frame-resolution beat/downbeat head: encoder states (T/4) are
23
+ upsampled 4x so supervision and peaks live on the 11.6 ms frame grid
24
+ instead of 46 ms encoder bins (4x finer timing per peak)."""
25
+
26
+ def __init__(self, d):
27
+ super().__init__()
28
+ self.up = nn.ConvTranspose1d(d, d // 2, 4, stride=4)
29
+ self.out = nn.Conv1d(d // 2, 2, 3, padding=1)
30
+
31
+ def forward(self, mem): # (B, L, d) -> (B, 4L, 2)
32
+ h = nn.functional.gelu(self.up(mem.transpose(1, 2)))
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"
39
+ (labels; each distinct label is one physical layer). Returns a list of n
40
+ physical-layer indices, e.g. [0,1,0,1] for 2-unique/4-physical-depth."""
41
+ if spec is None:
42
+ return list(range(n)) # no sharing: every layer unique
43
+ if isinstance(spec, int):
44
+ return [k % spec for k in range(n)]
45
+ labels = [s.strip() for s in str(spec).split(",") if s.strip()]
46
+ assert len(labels) == n, f"pattern {spec} has {len(labels)} labels, need {n}"
47
+ order = {}
48
+ out = []
49
+ for lb in labels:
50
+ if lb not in order:
51
+ order[lb] = len(order)
52
+ out.append(order[lb])
53
+ return out
54
+
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)))
72
+
73
+
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,
91
+ unique_layernorm=False, adapter_rank_ffn=0, depth_emb=False):
92
+ super().__init__()
93
+ # `pattern` is the resolved list of physical-layer indices per depth
94
+ n_phys = max(pattern) + 1
95
+ self.layers = nn.ModuleList([
96
+ nn.TransformerEncoderLayer(d, nhead, ffn, dropout, activation="gelu",
97
+ batch_first=True, norm_first=True)
98
+ for _ in range(n_phys)])
99
+ self.plan = pattern
100
+ self.split = adapter_rank_ffn > 0
101
+ if self.split: # v1.7: per-sublayer adapters (attention rank-a, FFN rank-f)
102
+ self.attn_adapters = nn.ModuleList([
103
+ Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
104
+ for _ in pattern])
105
+ self.ffn_adapters = nn.ModuleList([
106
+ Adapter(d, adapter_rank_ffn) for _ in pattern])
107
+ else: # v1.6: one residual adapter after the whole block
108
+ self.adapters = nn.ModuleList([
109
+ Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
110
+ for _ in pattern])
111
+ self.depth_emb = (nn.Parameter(torch.zeros(len(pattern), d))
112
+ if depth_emb else None)
113
+ self.pos_norms = nn.ModuleList([
114
+ nn.LayerNorm(d) if unique_layernorm else nn.Identity()
115
+ for _ in pattern])
116
+ self.norm = nn.LayerNorm(d)
117
+
118
+ def forward(self, x, src_key_padding_mask=None):
119
+ for k, phys in enumerate(self.plan):
120
+ if self.depth_emb is not None:
121
+ x = x + self.depth_emb[k]
122
+ layer = self.layers[phys]
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)))
130
+ else:
131
+ x = layer(x, src_key_padding_mask=src_key_padding_mask)
132
+ x = self.adapters[k](x)
133
+ x = self.pos_norms[k](x)
134
+ return self.norm(x)
135
+
136
+
137
+ class SharedDecoder(nn.Module):
138
+ """Decoder counterpart of SharedEncoder (see that docstring). Split mode
139
+ puts rank-`adapter_rank` adapters on BOTH attention sublayers (self and
140
+ cross) and rank-`adapter_rank_ffn` on the FFN sublayer."""
141
+
142
+ def __init__(self, d, nhead, ffn, dropout, pattern, adapter_rank=0,
143
+ unique_layernorm=False, adapter_rank_ffn=0, depth_emb=False):
144
+ super().__init__()
145
+ n_phys = max(pattern) + 1
146
+ self.layers = nn.ModuleList([
147
+ nn.TransformerDecoderLayer(d, nhead, ffn, dropout, activation="gelu",
148
+ batch_first=True, norm_first=True)
149
+ for _ in range(n_phys)])
150
+ self.plan = pattern
151
+ self.split = adapter_rank_ffn > 0
152
+ if self.split:
153
+ self.sa_adapters = nn.ModuleList([
154
+ Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
155
+ for _ in pattern])
156
+ self.ca_adapters = nn.ModuleList([
157
+ Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
158
+ for _ in pattern])
159
+ self.ffn_adapters = nn.ModuleList([
160
+ Adapter(d, adapter_rank_ffn) for _ in pattern])
161
+ else:
162
+ self.adapters = nn.ModuleList([
163
+ Adapter(d, adapter_rank) if adapter_rank else nn.Identity()
164
+ for _ in pattern])
165
+ self.depth_emb = (nn.Parameter(torch.zeros(len(pattern), d))
166
+ if depth_emb else None)
167
+ self.pos_norms = nn.ModuleList([
168
+ nn.LayerNorm(d) if unique_layernorm else nn.Identity()
169
+ for _ in pattern])
170
+ self.norm = nn.LayerNorm(d)
171
+
172
+ def forward(self, tgt, memory, tgt_mask=None, tgt_key_padding_mask=None,
173
+ tgt_is_causal=False):
174
+ x = tgt
175
+ for k, phys in enumerate(self.plan):
176
+ if self.depth_emb is not None:
177
+ x = x + self.depth_emb[k]
178
+ layer = self.layers[phys]
179
+ if self.split:
180
+ x = x + self.sa_adapters[k](layer._sa_block(
181
+ layer.norm1(x), tgt_mask, tgt_key_padding_mask, tgt_is_causal))
182
+ x = x + self.ca_adapters[k](layer._mha_block(
183
+ layer.norm2(x), memory, None, None))
184
+ x = x + self.ffn_adapters[k](layer._ff_block(layer.norm3(x)))
185
+ else:
186
+ x = layer(x, memory, tgt_mask=tgt_mask,
187
+ tgt_key_padding_mask=tgt_key_padding_mask,
188
+ tgt_is_causal=tgt_is_causal)
189
+ x = self.adapters[k](x)
190
+ x = self.pos_norms[k](x)
191
+ return self.norm(x)
192
+
193
+
194
+ class ChartModel(nn.Module):
195
+ def __init__(
196
+ self,
197
+ d_model=512,
198
+ nhead=8,
199
+ enc_layers=6,
200
+ dec_layers=6,
201
+ ffn=2048,
202
+ dropout=0.1,
203
+ vocab_size=None,
204
+ max_tgt=None,
205
+ aux=False,
206
+ global_ctx=False,
207
+ func_time=False,
208
+ ptr=False,
209
+ beat=False,
210
+ in_ch=None,
211
+ emb_factor=None,
212
+ clean_phase=False,
213
+ enc_share=None,
214
+ dec_share=None,
215
+ adapter_rank=0,
216
+ unique_layernorm=False,
217
+ adapter_rank_ffn=0,
218
+ depth_emb=False,
219
+ unshare_last_dec=False,
220
+ ):
221
+ super().__init__()
222
+ from .vocab import MAX_TGT
223
+
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
230
+ # forced beat supervision to be masked on slot windows in v1.5.
231
+ self.clean_phase = clean_phase
232
+ self.in_ch = in_ch or N_MELS # legacy slot mode appends 2 grid-phase channels
233
+ front_ch = N_MELS if clean_phase else self.in_ch
234
+
235
+ # conv frontend: (B, front_ch, T) -> (B, T/4, d)
236
+ self.frontend = nn.Sequential(
237
+ nn.Conv1d(front_ch, d_model, 3, stride=2, padding=1),
238
+ nn.GELU(),
239
+ nn.Conv1d(d_model, d_model, 3, stride=2, padding=1),
240
+ nn.GELU(),
241
+ )
242
+ enc_len = WINDOW // 4
243
+ self.register_buffer("enc_pos", sinusoidal(enc_len, d_model), persistent=False)
244
+ self.register_buffer("dec_pos", sinusoidal(max_tgt, d_model), persistent=False)
245
+
246
+ # phase injection (clean_phase only): 2 grid-phase channels at frame
247
+ # rate -> encoder rate (T/4) -> additive conditioning on the memory the
248
+ # DECODER reads. The beat head reads the pre-injection (clean) memory.
249
+ # phase_proj input = [measure_phase, beat_phase, valid_flag]. valid=0 on
250
+ # time-mode windows (their raw phase is -1), giving the decoder a clean
251
+ # zero-phase conditioning there; slot windows pass valid=1 + real phase.
252
+ if clean_phase:
253
+ self.phase_proj = nn.Sequential(
254
+ nn.Conv1d(3, d_model, 3, stride=2, padding=1),
255
+ nn.GELU(),
256
+ nn.Conv1d(d_model, d_model, 3, stride=2, padding=1),
257
+ )
258
+ else:
259
+ self.phase_proj = None
260
+
261
+ # sharing schedule (v1.6-small/tiny): reuse physical layers per pattern.
262
+ # enc_share/dec_share: int (#unique layers) or "E0,E1,E0,E1" label string.
263
+ self.enc_share = enc_share
264
+ self.dec_share = dec_share
265
+ if (enc_share is None and dec_share is None and not adapter_rank
266
+ and not adapter_rank_ffn and not depth_emb and not unshare_last_dec):
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)
275
+ self.decoder = nn.TransformerDecoder(dec_layer, dec_layers,
276
+ nn.LayerNorm(d_model))
277
+ else:
278
+ enc_plan = _parse_pattern(enc_share, enc_layers)
279
+ dec_plan = _parse_pattern(dec_share, dec_layers)
280
+ # v1.7: un-share the LAST decoder layer (pattern-shaping layer in
281
+ # front of the generation head gets its own physical parameters);
282
+ # no-op if that depth is already unique.
283
+ if unshare_last_dec and dec_plan.count(dec_plan[-1]) > 1:
284
+ dec_plan = dec_plan[:-1] + [max(dec_plan) + 1]
285
+ self.encoder = SharedEncoder(d_model, nhead, ffn, dropout, enc_plan,
286
+ adapter_rank, unique_layernorm,
287
+ adapter_rank_ffn, depth_emb)
288
+ self.decoder = SharedDecoder(d_model, nhead, ffn, dropout, dec_plan,
289
+ adapter_rank, unique_layernorm,
290
+ adapter_rank_ffn, depth_emb)
291
+
292
+ # ALBERT-style factorized embeddings (v1.5): vocab->E->d. Position
293
+ # tokens dominate the table (1728/1810 rows), so E=64 cuts the
294
+ # embedding block ~3.5x; logits stay tied via the projected matrix.
295
+ self.emb_factor = emb_factor
296
+ if emb_factor:
297
+ self.tok_emb = nn.Embedding(vocab_size, emb_factor, padding_idx=VOCAB.pad)
298
+ nn.init.normal_(self.tok_emb.weight, std=0.02)
299
+ self.emb_proj = nn.Linear(emb_factor, d_model, bias=False)
300
+ nn.init.normal_(self.emb_proj.weight, std=1.0 / (emb_factor ** 0.5) * 0.16)
301
+ self.out = None
302
+ else:
303
+ self.tok_emb = nn.Embedding(vocab_size, d_model, padding_idx=VOCAB.pad)
304
+ nn.init.normal_(self.tok_emb.weight, std=0.02) # keep tied logits well-scaled
305
+ self.emb_proj = None
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
314
+ # beat/downbeat head: explicit metrical percept on the encoder.
315
+ # beat="hires" builds the frame-resolution head (11.6 ms bins)
316
+ self.beat = (BeatHiRes(d_model) if beat == "hires"
317
+ else (nn.Linear(d_model, 2) if beat else None))
318
+ # song-level context: coarse whole-song summary + window position (v4).
319
+ # Lets the model see beyond the window, so intentional gaps (a breath
320
+ # before a strong section) are informed decisions, not failures.
321
+ # v2 fix (after the v4 negative result): summary chunks get DEDICATED
322
+ # positional embeddings instead of reusing window positions.
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)
333
+ if func_time:
334
+ self.register_buffer("time_basis", sinusoidal(WINDOW, 64), persistent=False)
335
+ self.time_proj = nn.Linear(64, d_model)
336
+ # match the 0.02-std scale of tok_emb rows (basis row norm ~ sqrt(32))
337
+ nn.init.normal_(self.time_proj.weight, std=0.004)
338
+ nn.init.zeros_(self.time_proj.bias)
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]
354
+ p = self.pos_emb(pos_bucket).unsqueeze(1) # (B, 1, d)
355
+ h = torch.cat([p, g, h], dim=1)
356
+ return self.encoder(self.dropout(h))
357
+
358
+ def phase_mem(self, memory, mel):
359
+ """Phase-conditioned copy of `memory` for the DECODER to cross-attend.
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):
377
+ """Full embedding matrix; factorized and/or functional rows resolved."""
378
+ E = self.tok_emb.weight
379
+ if self.emb_proj is not None:
380
+ E = self.emb_proj(E)
381
+ if self.time_proj is not None:
382
+ t = self.time_proj(self.time_basis) # (WINDOW, d)
383
+ E = torch.cat([E[: VOCAB.time0], t, E[VOCAB.time0 + WINDOW :]], dim=0)
384
+ return E
385
+
386
+ def decode(self, tgt_in, memory):
387
+ # tgt_in: (B, L) token ids
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),
395
+ memory,
396
+ tgt_mask=mask,
397
+ tgt_key_padding_mask=pad_mask,
398
+ tgt_is_causal=True,
399
+ )
400
+ return h @ E.T # tied output projection (functional rows included)
401
+
402
+ def enable_ptr(self):
403
+ self.ptr = nn.Linear(self.d_model, self.d_model)
404
+
405
+ def enable_beat(self, hires=False):
406
+ self.beat = BeatHiRes(self.d_model) if hires else nn.Linear(self.d_model, 2)
407
+
408
+ def forward(self, mel, tgt, return_aux=False, gsum=None, pos_bucket=None,
409
+ return_extras=False, in_tgt=None):
410
+ # in_tgt: optional corrupted decoder input (e.g. MASKed types for the
411
+ # skeleton->color infill curriculum); gold targets stay = tgt
412
+ memory = self.encode(mel, gsum=gsum, pos_bucket=pos_bucket) # CLEAN
413
+ dec_mem = self.phase_mem(memory, mel) # phase-conditioned for the decoder
414
+ dec_in = (in_tgt if in_tgt is not None else tgt)[:, :-1]
415
+ L = dec_in.shape[1]
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
423
+ if not (return_aux or return_extras):
424
+ return logits
425
+ outs = [logits]
426
+ # aux/ptr/beat heads read the CLEAN memory (no phase leak)
427
+ aux_mem = memory[:, -(WINDOW // 4):]
428
+ outs.append(self.aux(aux_mem).squeeze(-1) if self.aux is not None else None)
429
+ if return_extras:
430
+ ptr_logits = None
431
+ if self.ptr is not None:
432
+ q = self.ptr(h) # (B, L, d)
433
+ ptr_logits = q @ aux_mem.transpose(1, 2) / _m.sqrt(self.d_model)
434
+ beat_logits = self.beat(aux_mem) if self.beat is not None else None
435
+ outs += [ptr_logits, beat_logits]
436
+ return tuple(outs)
437
+
438
+
439
+ def count_params(m):
440
+ return sum(p.numel() for p in m.parameters() if p.requires_grad)
legacy_softchart/tja.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:",
115
+ "", "#START", *lines, "#END"]) + "\n"
116
+ if out_path:
117
+ with open(out_path, "w") as f:
118
+ f.write(tja)
119
+ return tja
legacy_softchart/vocab.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Token vocabulary for chart sequences.
2
+
3
+ Sequence layout (decoder):
4
+ [BOS] [COURSE_c] [LEVEL_l] [DENS_d] ([TIME_t] [NOTE_e])* [EOS]
5
+ Conditions may be UNK (condition dropout / unspecified at inference).
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ # --- audio / windowing constants (single source of truth) ---
11
+ SR = 22050
12
+ N_FFT = 2048
13
+ 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)
21
+ SLOTS_PER_BEAT = 24 # meter-general lattice (v3): 3/4 -> 72, 5/4 -> 120 slots
22
+ BEATS_MAX = WINDOW // SLOTS_PER_BEAT # 72 beats of token space per window
23
+
24
+ COURSES = ["easy", "normal", "hard", "oni", "ura"]
25
+ N_LEVELS = 12 # levels clamped to 1..12
26
+ N_DENS = 16
27
+ DENS_BUCKET_NPS = 0.75 # bucket width in notes/sec
28
+
29
+ # canonical note classes; parsed TJA note_type strings are mapped onto these
30
+ NOTE_CLASSES = ["don", "ka", "don_big", "ka_big", "roll", "roll_big", "balloon", "end"]
31
+
32
+ # mapping from strings observed in the dataset -> canonical class
33
+ # (observed in taiko-1000-parsed: Don, Ka, DonBig, KaBig, Roll, RollBig,
34
+ # Balloon, BalloonAlt, EndOf)
35
+ NOTE_TYPE_MAP = {
36
+ "Don": "don",
37
+ "Ka": "ka",
38
+ "DonBig": "don_big",
39
+ "KaBig": "ka_big",
40
+ "Roll": "roll",
41
+ "RollBig": "roll_big",
42
+ "Balloon": "balloon",
43
+ "BalloonAlt": "balloon", # kusudama, treated as balloon-class span
44
+ "EndOf": "end",
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class Vocab:
50
+ pad: int = 0
51
+ eos: int = 1
52
+ bos: int = 2
53
+ unk_cond: int = 3
54
+
55
+ def __post_init__(self):
56
+ base = 4
57
+ self.course = {c: base + i for i, c in enumerate(COURSES)}
58
+ base += len(COURSES)
59
+ self.level = {l: base + l - 1 for l in range(1, N_LEVELS + 1)}
60
+ base += N_LEVELS
61
+ self.dens = {d: base + d for d in range(N_DENS)}
62
+ base += N_DENS
63
+ self.note = {n: base + i for i, n in enumerate(NOTE_CLASSES)}
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
71
+ self.sync = {s: base + 10 + s for s in range(6)} # LHL syncopation bands
72
+ # plan-realize tokens: [PLAN] marker + block density (8) + block flags (3)
73
+ self.plan = base + 16
74
+ self.pdens = {d: base + 17 + d for d in range(8)}
75
+ self.pflag = {f: base + 25 + f for f in range(3)} # 0 none / 1 gap / 2 climax
76
+ self.mask = base + 28 # type-infill placeholder
77
+ self.cplx = {c: base + 29 + c for c in range(6)} # rhythmic-complexity band
78
+ # dual-mode models: explicit output-semantics switch (v3). mode "slot"
79
+ # = positions are lattice indices on a supplied grid; "time" = frames.
80
+ self.mode = {"slot": base + 35, "time": base + 36}
81
+ self.size = base + 37
82
+
83
+ self.id2note = {v: k for k, v in self.note.items()}
84
+ self.id2course = {v: k for k, v in self.course.items()}
85
+
86
+ def time(self, frame: int) -> int:
87
+ assert 0 <= frame < WINDOW
88
+ return self.time0 + frame
89
+
90
+ def is_time(self, tok: int) -> bool:
91
+ return self.time0 <= tok < self.time0 + WINDOW
92
+
93
+ def is_note(self, tok: int) -> bool:
94
+ return tok in self.id2note
95
+
96
+ def dens_bucket(self, nps: float) -> int:
97
+ return min(N_DENS - 1, max(0, int(nps / DENS_BUCKET_NPS)))
98
+
99
+
100
+ VOCAB = Vocab()
101
+
102
+
103
+ SIB_EVENTS = 12 # fixed number of sibling-chart events in the prefix (24 tokens)
104
+
105
+
106
+ PLAN_SLOTS = 5 # fixed number of plan blocks in the prefix (2 tokens each)
107
+
108
+
109
+ def complexity_band(frames):
110
+ """IOI-class entropy of a window's hit sequence -> band 0-5 (rhythmic
111
+ complexity independent of raw density)."""
112
+ import math
113
+ hits = sorted(f for f, c in frames if NOTE_CLASSES[c] in ("don","ka","don_big","ka_big"))
114
+ if len(hits) < 4:
115
+ return 0
116
+ iois = [b - a for a, b in zip(hits, hits[1:]) if b > a]
117
+ if not iois:
118
+ return 0
119
+ from collections import Counter
120
+ cls = Counter(int(round(math.log2(max(i,1)) * 2)) for i in iois)
121
+ tot = sum(cls.values())
122
+ ent = -sum((n/tot) * math.log2(n/tot) for n in cls.values())
123
+ return min(5, int(ent / 0.5))
124
+
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).
132
+ ctx_types: optional list of note-class ids from the previous window's tail
133
+ (pattern continuity context, v2). Encoded as [BOS] ctx.. [SEP] conds..
134
+ sib_pairs: optional list of (frame, class_id) events from an easier course of
135
+ the same song (skeleton hint, easy⊂hard). Fixed SIB_EVENTS slots,
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):
144
+ if cond_drop > 0 and rng is not None and rng.random() < cond_drop:
145
+ return vocab.unk_cond
146
+ return tok
147
+
148
+ lvl = max(1, min(N_LEVELS, level if level and level > 0 else 1))
149
+ seq = [vocab.bos]
150
+ if mode is not None: # dual-mode: explicit output-semantics token (never dropped)
151
+ seq.append(vocab.mode[mode])
152
+ if ctx_types is not None: # fixed-length, left-padded with UNK (id < 0 = pad)
153
+ seq += [vocab.note[NOTE_CLASSES[c]] if c >= 0 else vocab.unk_cond
154
+ for c in ctx_types]
155
+ seq.append(vocab.sep)
156
+ if sib_pairs is not None:
157
+ seq.append(vocab.sib)
158
+ pairs = list(sib_pairs)[:SIB_EVENTS]
159
+ for f, c in pairs:
160
+ seq += [vocab.time(int(f)), vocab.note[NOTE_CLASSES[c]]]
161
+ seq += [vocab.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
162
+ seq += [
163
+ maybe(vocab.course[course]),
164
+ maybe(vocab.level[lvl]),
165
+ maybe(vocab.dens[d]),
166
+ ]
167
+ if style is not None: # charting-intent code (style >= 0; -1 = unknown)
168
+ seq.append(maybe(vocab.style[style]) if style >= 0 else vocab.unk_cond)
169
+ if sync_band is not None: # LHL syncopation band (groove-intensity control)
170
+ seq.append(maybe(vocab.sync[sync_band]) if sync_band >= 0 else vocab.unk_cond)
171
+ if complexity is not None: # rhythmic-complexity band (difficulty beyond density)
172
+ seq.append(maybe(vocab.cplx[complexity]) if complexity >= 0 else vocab.unk_cond)
173
+ if plan_slice is not None: # song-level plan blocks overlapping this window
174
+ seq.append(vocab.plan)
175
+ blocks = list(plan_slice)[:PLAN_SLOTS]
176
+ for d8, fl in blocks:
177
+ seq += [maybe(vocab.pdens[min(7, max(0, d8))]),
178
+ maybe(vocab.pflag[min(2, max(0, fl))])]
179
+ seq += [vocab.unk_cond] * (2 * (PLAN_SLOTS - len(blocks)))
180
+ prefix_len = len(seq)
181
+ for f, c in notes:
182
+ seq.append(vocab.time(int(f)))
183
+ seq.append(vocab.note[NOTE_CLASSES[c]])
184
+ seq.append(vocab.eos)
185
+ return seq, prefix_len
186
+
187
+
188
+ def decode_tokens(vocab, tokens):
189
+ """Token ids -> list of (frame_idx, note_class_name). Ignores malformed pairs."""
190
+ out = []
191
+ cur_t = None
192
+ for tok in tokens:
193
+ if vocab.is_time(tok):
194
+ cur_t = tok - vocab.time0
195
+ elif tok in vocab.id2note and cur_t is not None:
196
+ out.append((cur_t, vocab.id2note[tok]))
197
+ elif tok == vocab.eos:
198
+ break
199
+ return out
static/app.css CHANGED
@@ -594,6 +594,29 @@ audio {
594
  font-weight: 750;
595
  }
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  .range {
598
  --range-progress: 88.89%;
599
  width: 100%;
 
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%;
static/app.js CHANGED
@@ -15,6 +15,7 @@ const ui = {
15
  fileMeta: $("#file-meta"),
16
  removeFile: $("#remove-file"),
17
  fileError: $("#file-error"),
 
18
  level: $("#level"),
19
  levelOutput: $("#level-output"),
20
  bpm: $("#bpm"),
@@ -107,6 +108,7 @@ const stageDefaults = {
107
  };
108
 
109
  const metricLabels = {
 
110
  bpm: "BPM",
111
  notes: "Notes",
112
  spans: "Drumroll sections",
@@ -525,7 +527,8 @@ function requestValues() {
525
  ui.sampling.checked,
526
  Number(ui.temperature.value),
527
  Number(ui.topP.value),
528
- Number(ui.drumVolume.value)
 
529
  ];
530
  }
531
 
 
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"),
 
108
  };
109
 
110
  const metricLabels = {
111
+ model: "Model",
112
  bpm: "BPM",
113
  notes: "Notes",
114
  spans: "Drumroll sections",
 
527
  ui.sampling.checked,
528
  Number(ui.temperature.value),
529
  Number(ui.topP.value),
530
+ Number(ui.drumVolume.value),
531
+ ui.model.value
532
  ];
533
  }
534
 
static/index.html CHANGED
@@ -83,9 +83,26 @@
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">
 
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.8 — hierarchical scratch (recommended)">
90
+ <option value="v1.8" selected>V1.8 · 9.0M — best research model</option>
91
+ </optgroup>
92
+ <optgroup label="V1.7 — original planner path">
93
+ <option value="v1.7">V1.7 · 7.9M — best legacy quality</option>
94
+ <option value="v1.7-small">V1.7 small · 3.6M</option>
95
+ <option value="v1.7-tiny">V1.7 tiny · 1.4M — fastest</option>
96
+ </optgroup>
97
+ <optgroup label="V1.6 — original planner path">
98
+ <option value="v1.6">V1.6 · 7.9M</option>
99
+ <option value="v1.6-small">V1.6 small · 3.6M</option>
100
+ <option value="v1.6-tiny">V1.6 tiny · 1.4M</option>
101
+ </optgroup>
102
+ <optgroup label="V1.5 — original planner path">
103
+ <option value="v1.5">V1.5 · 7.9M</option>
104
+ </optgroup>
105
+ </select>
106
  </div>
107
 
108
  <fieldset class="field-group">