JacobLinCool commited on
Commit
19b5e5f
·
verified ·
1 Parent(s): ea99c08

Upload folder using huggingface_hub

Browse files
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JacobLinCool
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,13 +1,33 @@
1
  ---
2
- title: Softchart
3
- emoji: 📚
4
- colorFrom: blue
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SoftChart
3
+ emoji: 🥁
4
+ colorFrom: red
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 4.44.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ models:
12
+ - JacobLinCool/softchart-generator
13
+ - JacobLinCool/softchart-beat
14
+ - JacobLinCool/softchart-planner
15
  ---
16
 
17
+ # 🥁 SoftChart
18
+
19
+ Generate a **Taiko no Tatsujin** chart from any song. An 8M-parameter,
20
+ plan-conditioned encoder–decoder model turns audio into a playable `.tja`,
21
+ with an optional learned planner (song-level structure) and beat-anchoring
22
+ model (barline alignment). All models are MIT-licensed and loaded from the
23
+ Hugging Face Hub via `from_pretrained`.
24
+
25
+ - **Generator** ([softchart-generator](https://huggingface.co/JacobLinCool/softchart-generator)) — audio → chart events, with difficulty / density / plan control
26
+ - **Planner** ([softchart-planner](https://huggingface.co/JacobLinCool/softchart-planner)) — audio → song-level plan (density envelope, breathing gaps, climax)
27
+ - **Beat** ([softchart-beat](https://huggingface.co/JacobLinCool/softchart-beat)) — beat/downbeat detection for barline anchoring
28
+
29
+ Upload a song, pick a difficulty, and get a preview plus a `.tja` playable in
30
+ TJAPlayer3 / OpenTaiko.
31
+
32
+ > Models were trained on charts derived from a commercial game; released for
33
+ > research and personal use. Code and weights are MIT-licensed.
app.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SoftChart — Gradio demo for Hugging Face Spaces.
2
+
3
+ Generate a Taiko no Tatsujin chart from any audio file, using the full system:
4
+ - SoftChartGenerator (main model, plan-conditioned)
5
+ - SoftChartPlanner (auto song-level planning)
6
+ - SoftChartBeat (beat/downbeat for barline anchoring)
7
+ All models load from the Hub via from_pretrained. MIT licensed.
8
+ """
9
+
10
+ import os
11
+ import tempfile
12
+
13
+ import gradio as gr
14
+ import numpy as np
15
+ import torch
16
+
17
+ from softchart.generate import generate_song, load_hf
18
+ from softchart.hf import SoftChartPlanner
19
+ from softchart.rhythm import snap_chart
20
+ from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR, WINDOW
21
+
22
+ GEN_REPO = os.environ.get("SC_GEN", "JacobLinCool/softchart-generator")
23
+ BEAT_REPO = os.environ.get("SC_BEAT", "JacobLinCool/softchart-beat")
24
+ PLAN_REPO = os.environ.get("SC_PLAN", "JacobLinCool/softchart-planner")
25
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
26
+
27
+ COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
28
+ DEFAULT_LEVEL = {"easy": 3, "normal": 5, "hard": 7, "oni": 9}
29
+ CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
30
+ "roll": "5", "roll_big": "6", "balloon": "7"}
31
+ STYLE = {"don": ("#e8453c", 55), "ka": ("#4aa8c0", 55),
32
+ "don_big": ("#e8453c", 150), "ka_big": ("#4aa8c0", 150),
33
+ "roll": ("#e8b23a", 80), "roll_big": ("#e8b23a", 150), "balloon": ("#d47fda", 100)}
34
+ SUB = 96
35
+
36
+ _MODELS = {}
37
+
38
+
39
+ def get_models():
40
+ if not _MODELS:
41
+ _MODELS["gen"] = load_hf(GEN_REPO, device=DEVICE)
42
+ try:
43
+ _MODELS["beat"] = load_hf(BEAT_REPO, device=DEVICE)
44
+ except Exception:
45
+ _MODELS["beat"] = None
46
+ try:
47
+ _MODELS["plan"] = SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
48
+ except Exception:
49
+ _MODELS["plan"] = None
50
+ return _MODELS
51
+
52
+
53
+ def load_logmel(path):
54
+ import librosa
55
+
56
+ wav, _ = librosa.load(path, sr=SR, mono=True)
57
+ fb = librosa.filters.mel(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
58
+ spec = torch.stft(torch.from_numpy(wav), N_FFT, hop_length=HOP,
59
+ window=torch.hann_window(N_FFT), center=True, return_complex=True)
60
+ mel = np.log(fb @ spec.abs().pow(2).numpy() + 1e-5).astype(np.float32)
61
+ return mel, wav
62
+
63
+
64
+ def predict_downbeats(beat_model, mel):
65
+ from scipy.signal import find_peaks
66
+ from softchart.generate import _autocast
67
+
68
+ T = mel.shape[1]
69
+ L = WINDOW // 4
70
+ acc = np.zeros(T // 4 + L)
71
+ cnt = np.zeros(T // 4 + L)
72
+ for st in range(0, max(T - 1, 1), WINDOW):
73
+ w = torch.from_numpy(mel[:, st:st + WINDOW].astype(np.float32))
74
+ if w.shape[1] < WINDOW:
75
+ w = torch.nn.functional.pad(w, (0, WINDOW - w.shape[1]), value=float(np.log(1e-5)))
76
+ with torch.no_grad(), _autocast(DEVICE):
77
+ mem = beat_model.encode(w[None].to(DEVICE))
78
+ pr = torch.sigmoid(beat_model.beat(mem[:, -L:]).float())[0, :, 1].cpu().numpy()
79
+ acc[st // 4: st // 4 + L] += pr
80
+ cnt[st // 4: st // 4 + L] += 1
81
+ acc /= np.maximum(cnt, 1)
82
+ return find_peaks(acc, height=0.4, distance=int(0.8 * FPS / 4))[0] * 4 / FPS
83
+
84
+
85
+ def auto_plan(mel, bpm, downbeats=None):
86
+ T = mel.shape[1]
87
+ dur = T / FPS
88
+ flux = np.concatenate([[0], np.maximum(0, np.diff(mel, axis=1)).sum(0)])
89
+ beat = 60.0 / bpm
90
+ edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
91
+ else list(np.arange(0, dur, 4 * beat)) + [dur]
92
+ vals = [float(flux[int(a * FPS):int(b * FPS)].mean()) if int(b * FPS) > int(a * FPS) else 0.0
93
+ for a, b in zip(edges, edges[1:])]
94
+ if not vals:
95
+ return None
96
+ vals = np.array(vals)
97
+ lo, hi = np.percentile(vals, 15), np.percentile(vals, 92)
98
+ peak = int(np.argmax(vals))
99
+ plan = []
100
+ for i, (a, b) in enumerate(zip(edges, edges[1:])):
101
+ frac = (vals[i] - lo) / max(hi - lo, 1e-6)
102
+ d8 = int(np.clip(round(frac * 7), 0, 7))
103
+ fl = 1 if (vals[i] <= lo and 0 < i < len(vals) - 1) else (2 if i == peak and vals[i] >= hi else 0)
104
+ plan.append([round(a, 3), round(b, 3), d8, fl])
105
+ return plan
106
+
107
+
108
+ def learned_plan(planner, mel, course, bpm, downbeats=None):
109
+ dur = mel.shape[1] / FPS
110
+ beat = 60.0 / bpm
111
+ edges = (list(downbeats[::4]) + [dur]) if (downbeats is not None and len(downbeats) >= 2) \
112
+ else list(np.arange(0, dur, 4 * beat)) + [dur]
113
+ feats, spans = [], []
114
+ for a, b in zip(edges, edges[1:]):
115
+ seg = mel[:, int(a * FPS):int(b * FPS)]
116
+ if seg.shape[1] < 2:
117
+ continue
118
+ fx = np.maximum(0, np.diff(seg, axis=1)).sum(0)
119
+ feats.append(np.concatenate([seg.mean(1), seg.std(1), [fx.mean(), fx.std(), fx.max()]]))
120
+ spans.append((round(float(a), 3), round(float(b), 3)))
121
+ if not feats:
122
+ return None
123
+ cid = {"easy": 0, "normal": 1, "hard": 2, "oni": 3}[course]
124
+ x = torch.tensor(np.array(feats), dtype=torch.float32)[None].to(DEVICE)
125
+ with torch.no_grad():
126
+ pd, pf = planner(x, torch.tensor([cid], device=DEVICE))
127
+ d8 = pd[0].argmax(-1).cpu().numpy()
128
+ fl = pf[0].argmax(-1).cpu().numpy()
129
+ return [[a, b, int(d), int(f)] for (a, b), d, f in zip(spans, d8, fl)]
130
+
131
+
132
+ def group_quantize(times, phase, grid, min_run=3):
133
+ n = len(times)
134
+ slots = [0] * n
135
+ i = 0
136
+ while i < n:
137
+ j = i
138
+ while j + 1 < n:
139
+ ioi = times[j + 1] - times[j]
140
+ ref = (times[j] - times[i]) / (j - i) if j > i else ioi
141
+ if 0.02 < ioi < 1.2 and abs(ioi - ref) < 0.22 * max(ref, 1e-6):
142
+ j += 1
143
+ else:
144
+ break
145
+ if j - i + 1 >= min_run:
146
+ k = max(1, int(round((times[j] - times[i]) / (j - i) / grid)))
147
+ anchor = int(round((times[i] - phase) / grid))
148
+ for m in range(j - i + 1):
149
+ slots[i + m] = anchor + m * k
150
+ else:
151
+ for m in range(i, j + 1):
152
+ slots[m] = int(round((times[m] - phase) / grid))
153
+ i = j + 1
154
+ return slots
155
+
156
+
157
+ def write_tja(gen, bpm, title, course, level, downbeats=None):
158
+ hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
159
+ times = np.array([t for t, _ in hits]) if hits else np.array([0.0])
160
+ beat = 60.0 / bpm
161
+ grid = beat / (SUB / 4)
162
+ cands = np.arange(0, beat, grid / 4)
163
+ phase = float(cands[int(np.argmin([np.mean(np.abs(((times - o) / grid) - np.round((times - o) / grid))) for o in cands]))])
164
+ slots = {}
165
+ for idx, (t, ch) in zip(group_quantize([t for t, _ in hits], phase, grid), hits):
166
+ if idx >= 0 and idx not in slots:
167
+ slots[idx] = ch
168
+ for sp in gen["spans"]:
169
+ i0 = int(round((sp["t0"] - phase) / grid))
170
+ i1 = int(round((sp["t1"] - phase) / grid))
171
+ while i0 in slots:
172
+ i0 += 1
173
+ while i1 in slots or i1 <= i0:
174
+ i1 += 1
175
+ if i0 >= 0:
176
+ slots[i0] = CHAR[sp["type"]]
177
+ slots[i1] = "8"
178
+ if slots:
179
+ first_t = min(slots) * grid + phase
180
+ anchor_t = None
181
+ if downbeats is not None and len(downbeats):
182
+ near = downbeats[downbeats <= first_t + 0.12]
183
+ if len(near) and first_t - near[-1] < 4 * beat:
184
+ anchor_t = near[-1]
185
+ shift = int(round((anchor_t - phase) / grid)) if anchor_t is not None else min(slots)
186
+ if shift:
187
+ slots = {k - shift: v for k, v in slots.items()}
188
+ phase += shift * grid
189
+ n_meas = (max(slots) // SUB + 1) if slots else 1
190
+ lines = ["".join(slots.get(m * SUB + k, "0") for k in range(SUB)) + "," for m in range(n_meas)]
191
+ balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
192
+ return "\n".join([
193
+ f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", "WAVE:song.ogg",
194
+ f"OFFSET:{-phase:.3f}", f"COURSE:{'Oni' if course == 'oni' else course.capitalize()}",
195
+ f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
196
+ "", "#START", *lines, "#END"]) + "\n"
197
+
198
+
199
+ def render(mel, gen, title, course):
200
+ import matplotlib
201
+ matplotlib.use("Agg")
202
+ import matplotlib.pyplot as plt
203
+
204
+ dur = mel.shape[1] / FPS
205
+ t0, t1 = dur * 0.33, min(dur * 0.33 + 26, dur)
206
+ fig = plt.figure(figsize=(13, 4))
207
+ gs = fig.add_gridspec(2, 1, height_ratios=[2.3, 1], hspace=0.1)
208
+ ax0 = fig.add_subplot(gs[0])
209
+ ax0.imshow(mel[:, int(t0 * FPS):int(t1 * FPS)], aspect="auto", origin="lower",
210
+ cmap="magma", extent=[t0, t1, 0, N_MELS])
211
+ ax0.set_xticks([])
212
+ ax0.set_ylabel("mel")
213
+ ax0.set_title(f"{title} — {course} | red=don blue=ka big=large yellow=roll purple=balloon")
214
+ ax1 = fig.add_subplot(gs[1])
215
+ for h in gen["hits"]:
216
+ if t0 <= h["t"] <= t1:
217
+ c, sz = STYLE.get(h["type"], ("#999", 40))
218
+ ax1.scatter(h["t"], 0, c=c, s=sz, edgecolors="k",
219
+ linewidths=1.2 if "big" in h["type"] else 0.5, zorder=3)
220
+ for s in gen["spans"]:
221
+ if s["t1"] >= t0 and s["t0"] <= t1:
222
+ ax1.plot([max(s["t0"], t0), min(s["t1"], t1)], [0, 0],
223
+ c=STYLE.get(s["type"], ("#e8b23a",))[0], lw=6, alpha=0.45, zorder=2)
224
+ ax1.set_xlim(t0, t1)
225
+ ax1.set_ylim(-1, 1)
226
+ ax1.set_yticks([])
227
+ ax1.set_xlabel("time (s)")
228
+ ax1.grid(axis="x", alpha=0.25)
229
+ out = tempfile.mktemp(suffix=".png")
230
+ fig.savefig(out, dpi=130, bbox_inches="tight")
231
+ plt.close(fig)
232
+ return out
233
+
234
+
235
+ def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_planner, sampling):
236
+ if audio is None:
237
+ raise gr.Error("Please upload an audio file.")
238
+ M = get_models()
239
+ mel, wav = load_logmel(audio)
240
+ dbs = None
241
+ if use_beat and M["beat"] is not None:
242
+ dbs = predict_downbeats(M["beat"], mel)
243
+ if bpm_override and bpm_override > 0:
244
+ bpm = float(bpm_override)
245
+ elif dbs is not None and len(dbs) > 4:
246
+ bpm = 60.0 / float(np.median(np.diff(dbs)))
247
+ else:
248
+ import librosa
249
+ bpm = float(np.atleast_1d(librosa.beat.beat_track(y=wav, sr=SR)[0])[0])
250
+ if abs(bpm - round(bpm)) < 0.06:
251
+ bpm = float(round(bpm))
252
+
253
+ plan = None
254
+ if getattr(M["gen"], "_has_plan", False):
255
+ if use_planner and M["plan"] is not None:
256
+ plan = learned_plan(M["plan"], mel, course, bpm, dbs)
257
+ elif auto_plan_on:
258
+ plan = auto_plan(mel, bpm, dbs)
259
+
260
+ g = generate_song(M["gen"], mel, course, level=int(level),
261
+ density_bucket=COURSE_DENS[course], greedy=not sampling,
262
+ seed=0, device=DEVICE, plan=plan)
263
+ g = snap_chart(g, bpm)
264
+ title = os.path.splitext(os.path.basename(audio))[0]
265
+ tja = write_tja(g, bpm, title, course, int(level), dbs)
266
+ tja_path = tempfile.mktemp(suffix=f"_{course}.tja")
267
+ with open(tja_path, "w") as f:
268
+ f.write(tja)
269
+ img = render(mel, g, title, course)
270
+ info = (f"BPM {bpm:.1f} · {len(g['hits'])} notes · {len(g['spans'])} spans"
271
+ + (f" · plan: {sum(1 for b in plan if b[3]==1)} gaps, {sum(1 for b in plan if b[3]==2)} climax" if plan else ""))
272
+ return img, tja_path, info
273
+
274
+
275
+ with gr.Blocks(title="SoftChart — AI Taiko chart generator") as demo:
276
+ gr.Markdown(
277
+ "# 🥁 SoftChart\n"
278
+ "Generate a **Taiko no Tatsujin** chart from any song. 8M-param plan-conditioned "
279
+ "model + learned planner + beat-anchoring — all MIT-licensed, loaded from the Hub.\n\n"
280
+ "Outputs a preview and a playable `.tja` (for TJAPlayer3 / OpenTaiko). "
281
+ "*Models trained on charts derived from a commercial game; for research/personal use.*"
282
+ )
283
+ with gr.Row():
284
+ with gr.Column():
285
+ audio = gr.Audio(type="filepath", label="Song (any format)")
286
+ course = gr.Dropdown(["easy", "normal", "hard", "oni"], value="oni", label="Difficulty (course)")
287
+ level = gr.Slider(1, 10, value=9, step=1, label="Level (1–10 stars)")
288
+ bpm = gr.Number(label="BPM (0 = auto-detect)", value=0)
289
+ with gr.Row():
290
+ auto_plan_on = gr.Checkbox(label="Auto-plan (heuristic structure)", value=True)
291
+ use_planner = gr.Checkbox(label="Learned planner", value=True)
292
+ with gr.Row():
293
+ use_beat = gr.Checkbox(label="Beat-anchor barlines", value=True)
294
+ sampling = gr.Checkbox(label="Sampling (diverse) vs greedy (best)", value=False)
295
+ btn = gr.Button("Generate chart", variant="primary")
296
+ with gr.Column():
297
+ out_img = gr.Image(label="Preview (mel + chart)")
298
+ out_info = gr.Textbox(label="Result", interactive=False)
299
+ out_tja = gr.File(label="Download .tja")
300
+ btn.click(generate, [audio, course, level, bpm, auto_plan_on, use_beat, use_planner, sampling],
301
+ [out_img, out_tja, out_info])
302
+
303
+ if __name__ == "__main__":
304
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.2
2
+ numpy<2.1
3
+ scipy
4
+ librosa>=0.10
5
+ numba>=0.60
6
+ soundfile
7
+ matplotlib
8
+ gradio>=4.44
9
+ huggingface_hub>=0.24
10
+ safetensors
softchart/__init__.py ADDED
File without changes
softchart/baseline.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Onset-detection baseline: spectral-flux peak picking on the cached log-mel
2
+ + band-energy don/ka heuristic.
3
+
4
+ This is the "chart generation is just onset detection" strawman the model must beat.
5
+ Operates on the same cached mel features as the model, so no raw audio is needed.
6
+ """
7
+
8
+ import numpy as np
9
+ from scipy.signal import find_peaks
10
+
11
+ from .vocab import FPS
12
+
13
+
14
+ def baseline_chart(mel, target_nps):
15
+ """mel: (n_mels, T) log-mel (natural log). Returns list of (time_sec, class)."""
16
+ m = mel.astype(np.float32)
17
+ # spectral flux onset envelope
18
+ flux = np.maximum(0.0, np.diff(m, axis=1)).sum(axis=0)
19
+ flux = np.concatenate([[0.0], flux])
20
+ # smooth lightly
21
+ k = np.hanning(5)
22
+ k /= k.sum()
23
+ env = np.convolve(flux, k, mode="same")
24
+
25
+ dur = m.shape[1] / FPS
26
+ want = max(1, int(round(target_nps * dur)))
27
+ peaks, props = find_peaks(env, distance=int(0.09 * FPS), height=0.0)
28
+ if len(peaks) == 0:
29
+ return []
30
+ if len(peaks) > want:
31
+ strongest = np.argsort(props["peak_heights"])[-want:]
32
+ peaks = np.sort(peaks[strongest])
33
+
34
+ # don/ka: low vs high mel-band energy at the onset (linear power domain)
35
+ p = np.exp(m)
36
+ low = p[:40].sum(axis=0) # ~ <500 Hz
37
+ high = p[64:].sum(axis=0) # upper bands
38
+ events = []
39
+ for fr in peaks:
40
+ fr2 = min(fr + 1, m.shape[1] - 1)
41
+ ratio = high[fr2] / (low[fr2] + 1e-9)
42
+ events.append((float(fr / FPS), "ka" if ratio > 0.35 else "don"))
43
+ return events
softchart/data.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyTorch dataset: (mel window, token sequence) pairs from the preprocessed cache."""
2
+
3
+ import json
4
+ import os
5
+ import random
6
+
7
+ import numpy as np
8
+ import torch
9
+ from torch.utils.data import Dataset
10
+
11
+ from .vocab import FPS, MAX_TGT, NOTE_CLASSES, VOCAB, WINDOW, encode_window
12
+
13
+ G_CHUNK = 172 # ~2s per whole-song summary chunk
14
+ G_LEN = 100 # fixed number of summary chunks (covers ~200s songs)
15
+
16
+
17
+ def song_summary(mel):
18
+ """(n_mels, T) log-mel -> (G_LEN, n_mels) coarse whole-song summary."""
19
+ n_mels, T = mel.shape
20
+ n = min(G_LEN, max(1, T // G_CHUNK))
21
+ out = np.full((G_LEN, n_mels), np.log(1e-5), dtype=np.float32)
22
+ for k in range(n):
23
+ out[k] = mel[:, k * G_CHUNK : (k + 1) * G_CHUNK].astype(np.float32).mean(axis=1)
24
+ return out
25
+
26
+
27
+ class ChartWindowDataset(Dataset):
28
+ """One item = one (song, course) chart; a random window is cropped per access."""
29
+
30
+ def __init__(
31
+ self,
32
+ cache_dir,
33
+ song_ids,
34
+ train=True,
35
+ cond_drop=0.15,
36
+ spec_augment=True,
37
+ windows_per_chart=8,
38
+ use_ctx=False,
39
+ aux=False,
40
+ global_ctx=False,
41
+ importance_sampling=False,
42
+ tempo_aug=False,
43
+ sibling=False,
44
+ style=False,
45
+ beat_head=False,
46
+ sync_token=False,
47
+ plan=False,
48
+ mask_infill=0.0,
49
+ complexity=False,
50
+ ):
51
+ self.cache = cache_dir
52
+ self.train = train
53
+ self.cond_drop = cond_drop if train else 0.0
54
+ self.spec_augment = spec_augment and train
55
+ self.use_ctx = use_ctx
56
+ self.aux = aux
57
+ self.global_ctx = global_ctx
58
+ self.importance = importance_sampling and train
59
+ self.tempo_aug = tempo_aug and train
60
+ self.sibling = sibling
61
+ self.beat_head = beat_head
62
+ self.sync_token = sync_token
63
+ self.mask_infill = mask_infill if train else 0.0
64
+ self.complexity = complexity
65
+ self.plans = None
66
+ if plan:
67
+ with open(os.path.join(cache_dir, "plans.json")) as f:
68
+ self.plans = json.load(f)
69
+ self.styles = None
70
+ if style:
71
+ with open(os.path.join(cache_dir, "styles.json")) as f:
72
+ self.styles = json.load(f)["styles"]
73
+ with open(os.path.join(cache_dir, "index.json")) as f:
74
+ index = {e["id"]: e for e in json.load(f)}
75
+ self.items = [] # (sid, course, level, n_frames)
76
+ for sid in song_ids:
77
+ e = index.get(sid)
78
+ if e is None:
79
+ continue
80
+ for c, info in e["courses"].items():
81
+ self.items.append((sid, c, info["level"], e["n_frames"]))
82
+ # virtual epoch length: several windows per chart
83
+ self.mult = windows_per_chart if train else 1
84
+ self._rng = random.Random(int(os.environ.get('SC_DATA_SEED', 1234)))
85
+
86
+ def __len__(self):
87
+ return len(self.items) * self.mult
88
+
89
+ def _load(self, sid):
90
+ mel = np.load(os.path.join(self.cache, f"{sid}.mel.npy"), mmap_mode="r")
91
+ notes = np.load(os.path.join(self.cache, f"{sid}.notes.npz"))
92
+ return mel, notes
93
+
94
+ def _beats(self, sid):
95
+ try:
96
+ z = np.load(os.path.join(self.cache, f"{sid}.beats.npz"))
97
+ return z["beats"], z["downbeats"]
98
+ except Exception:
99
+ return None, None
100
+
101
+ def __getitem__(self, i):
102
+ sid, course, level, n_frames = self.items[i % len(self.items)]
103
+ mel, notes = self._load(sid)
104
+ times = notes[f"{course}_t"]
105
+ classes = notes[f"{course}_c"]
106
+
107
+ # rhythm-preserving tempo augmentation: crop a longer/shorter source
108
+ # window and resample it to WINDOW (mel-domain time stretch); note
109
+ # times scale by the same factor, so all rhythmic relations survive.
110
+ rng0 = random.Random(i * 31337 + (self._rng.randint(0, 1 << 30) if self.train else 0))
111
+ r = 1.0
112
+ if self.tempo_aug and rng0.random() < 0.5:
113
+ r = rng0.uniform(0.9, 1.111)
114
+ src_len = int(round(WINDOW * r))
115
+
116
+ max_start = max(0, n_frames - src_len)
117
+ rng = random.Random(i * 7919 + (self._rng.randint(0, 1 << 30) if self.train else 0))
118
+ if self.train and max_start > 0:
119
+ if self.importance:
120
+ # importance sampling: bias window starts toward note-dense /
121
+ # informative regions (uniform floor keeps sparse regions seen)
122
+ cands = np.arange(0, max_start + 1, int(FPS)) # 1s stride
123
+ lo = np.searchsorted(times, cands / FPS)
124
+ hi = np.searchsorted(times, cands / FPS + WINDOW / FPS)
125
+ w = (hi - lo).astype(np.float64) + 3.0
126
+ start = int(rng.choices(cands.tolist(), weights=w.tolist())[0])
127
+ start = min(max_start, start + rng.randint(0, int(FPS))) # sub-second jitter
128
+ else:
129
+ start = rng.randint(0, max_start)
130
+ else: # deterministic pseudo-random window per item (not always the intro)
131
+ start = (i * 2654435761) % (max_start + 1) if max_start > 0 else 0
132
+
133
+ t0 = start / FPS
134
+ t1 = (start + src_len) / FPS
135
+ sel = (times >= t0) & (times < t1)
136
+ frames = np.round((times[sel] - t0) * FPS / r).astype(np.int64)
137
+ frames = np.clip(frames, 0, WINDOW - 1)
138
+ pairs = list(zip(frames.tolist(), classes[sel].tolist()))
139
+
140
+ sib = None
141
+ if self.sibling: # skeleton hint from the nearest easier course
142
+ order = ["easy", "normal", "hard", "oni", "ura"]
143
+ sib_pairs = []
144
+ if not (self.train and rng0.random() < 0.2): # p=0.2: train without hint
145
+ for c2 in reversed(order[: order.index(course)]):
146
+ if f"{c2}_t" in notes:
147
+ t2, c2c = notes[f"{c2}_t"], notes[f"{c2}_c"]
148
+ s2 = (t2 >= t0) & (t2 < t1) & (c2c <= 3) # hits only
149
+ f2 = np.clip(np.round((t2[s2] - t0) * FPS / r), 0, WINDOW - 1)
150
+ ev = list(zip(f2.astype(np.int64).tolist(), c2c[s2].tolist()))
151
+ if len(ev) > 12: # even subsample to 12 slots
152
+ idx2 = np.linspace(0, len(ev) - 1, 12).astype(int)
153
+ ev = [ev[k] for k in idx2]
154
+ sib_pairs = ev
155
+ break
156
+ sib = sib_pairs
157
+
158
+ ctx = None
159
+ if self.use_ctx:
160
+ from .generate import CTX_LEN, HIT_CLASSES
161
+
162
+ hit_ids = [NOTE_CLASSES.index(c) for c in HIT_CLASSES]
163
+ prev = (times >= t0 - 3.0) & (times < t0)
164
+ tail = [int(c) for c in classes[prev] if int(c) in hit_ids][-CTX_LEN:]
165
+ if self.train and rng.random() < 0.1:
166
+ tail = [] # simulate missing context (first window)
167
+ elif self.train:
168
+ tail = [c if rng.random() > 0.1 else rng.choice(hit_ids) for c in tail]
169
+ ctx = [-1] * (CTX_LEN - len(tail)) + tail
170
+
171
+ style = None
172
+ if self.styles is not None:
173
+ style = self.styles.get(f"{sid}:{course}", -1)
174
+
175
+ sync_band = None
176
+ beats_w = db_w = None
177
+ if self.sync_token or self.beat_head:
178
+ src_sid = sid.split("x")[0] if "x" in sid else sid
179
+ bts, dbs = self._beats(src_sid)
180
+ if bts is not None:
181
+ # window-local, tempo-scaled beat grid
182
+ bsel = (bts >= t0) & (bts < t1)
183
+ beats_w = (bts[bsel] - t0) / r
184
+ dsel = (dbs >= t0) & (dbs < t1)
185
+ db_w = (dbs[dsel] - t0) / r
186
+ if self.sync_token:
187
+ sync_band = lhl_band(
188
+ np.array([f / FPS for f, c in pairs if NOTE_CLASSES[c] != "end"]),
189
+ beats_w, db_w)
190
+
191
+ plan_slice = None
192
+ if self.plans is not None:
193
+ blocks = self.plans.get(f"{sid}:{course}") or []
194
+ # blocks overlapping this window; times already in source timescale,
195
+ # window is [t0, t1) in the same scale (r rescales note frames only,
196
+ # block membership is decided in source time)
197
+ plan_slice = [(b[2], b[3]) for b in blocks if b[1] > t0 and b[0] < t1]
198
+
199
+ cplx = None
200
+ if self.complexity:
201
+ from .vocab import complexity_band
202
+ cplx = complexity_band(pairs)
203
+
204
+ seq, prefix_len = encode_window(
205
+ VOCAB, course, level, pairs, cond_drop=self.cond_drop, rng=rng,
206
+ ctx_types=ctx, sib_pairs=sib, style=style, sync_band=sync_band,
207
+ plan_slice=plan_slice, complexity=cplx,
208
+ )
209
+ in_seq = None
210
+ if self.mask_infill > 0 and rng.random() < self.mask_infill:
211
+ # skeleton->color curriculum: decoder INPUT sees MASK where note
212
+ # types were; gold targets keep the true types
213
+ note_ids = set(VOCAB.note.values())
214
+ in_seq = [VOCAB.mask if (i >= prefix_len and tok in note_ids) else tok
215
+ for i, tok in enumerate(seq)]
216
+ if len(seq) > MAX_TGT: # truncate overly dense windows, keep EOS
217
+ seq = seq[: MAX_TGT - 1] + [VOCAB.eos]
218
+ if VOCAB.is_time(seq[-2]): # do not end on a dangling TIME token
219
+ seq = seq[:-2] + [VOCAB.eos]
220
+
221
+ x = mel[:, start : start + src_len].astype(np.float32)
222
+ if x.shape[1] < src_len:
223
+ x = np.pad(x, ((0, 0), (0, src_len - x.shape[1])), constant_values=np.log(1e-5))
224
+ x = torch.from_numpy(x)
225
+ if src_len != WINDOW: # mel-domain time stretch to the fixed window size
226
+ x = torch.nn.functional.interpolate(
227
+ x[None], size=WINDOW, mode="linear", align_corners=False
228
+ )[0]
229
+ if self.tempo_aug and rng0.random() < 0.3: # mild pitch shift (mel-bin roll)
230
+ k = rng0.randint(-4, 4)
231
+ if k:
232
+ x = torch.roll(x, k, dims=0)
233
+ if k > 0:
234
+ x[:k] = x.min()
235
+ else:
236
+ x[k:] = x.min()
237
+ if self.spec_augment:
238
+ for _ in range(2): # freq masks
239
+ w = rng.randint(0, 12)
240
+ f0 = rng.randint(0, x.shape[0] - w) if w else 0
241
+ if w:
242
+ x[f0 : f0 + w] = x.mean()
243
+ for _ in range(2): # time masks
244
+ w = rng.randint(0, 24)
245
+ f0 = rng.randint(0, x.shape[1] - w) if w else 0
246
+ if w:
247
+ x[:, f0 : f0 + w] = x.mean()
248
+ x = x + (rng.random() * 1.38 - 0.69) # gain +-6 dB in log space
249
+
250
+ aux_t = torch.zeros(0)
251
+ if self.aux: # onset heatmap at encoder resolution (WINDOW // 4)
252
+ aux_t = torch.zeros(WINDOW // 4)
253
+ for f, c in pairs:
254
+ if NOTE_CLASSES[c] != "end":
255
+ aux_t[min(f // 4, WINDOW // 4 - 1)] = 1.0
256
+ aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, 1))
257
+ aux_t = torch.max(aux_t, 0.5 * torch.roll(aux_t, -1))
258
+
259
+ beat_t = torch.zeros(0)
260
+ if self.beat_head:
261
+ beat_t = torch.zeros(WINDOW // 4, 2)
262
+ for arr, ch in ((beats_w, 0), (db_w, 1)):
263
+ if arr is None:
264
+ continue
265
+ for bt in arr:
266
+ k = int(bt * FPS) // 4
267
+ if 0 <= k < WINDOW // 4:
268
+ beat_t[k, ch] = 1.0
269
+ for ch in range(2):
270
+ col = beat_t[:, ch]
271
+ beat_t[:, ch] = torch.max(col, 0.5 * torch.roll(col, 1))
272
+ beat_t[:, ch] = torch.max(beat_t[:, ch], 0.5 * torch.roll(col, -1))
273
+
274
+ gsum = torch.zeros(0)
275
+ posb = 0
276
+ if self.global_ctx:
277
+ gsum = torch.from_numpy(song_summary(np.asarray(mel)))
278
+ posb = min(15, int(16 * start / max(n_frames, 1)))
279
+
280
+ in_t = torch.tensor(in_seq if in_seq is not None else seq, dtype=torch.long)
281
+ return (x, torch.tensor(seq, dtype=torch.long), prefix_len, aux_t, gsum, posb,
282
+ beat_t, in_t)
283
+
284
+
285
+ def lhl_band(note_times, beats, downbeats, band_width=0.6):
286
+ """Longuet-Higgins & Lee (1984) style syncopation, simplified to the
287
+ eighth-note grid, normalized per bar, bucketed into 6 bands."""
288
+ if beats is None or len(beats) < 4 or len(note_times) < 2:
289
+ return -1
290
+ beat_len = float(np.median(np.diff(beats))) if len(beats) > 1 else 0.5
291
+ grid = [] # (time, metrical level)
292
+ dbset = set(np.round(downbeats, 3)) if downbeats is not None else set()
293
+ for b in beats:
294
+ lvl = 3 if round(float(b), 3) in dbset else 2
295
+ grid.append((float(b), lvl))
296
+ grid.append((float(b) + beat_len / 2, 1)) # eighth positions
297
+ grid.sort()
298
+ tol = 0.04
299
+ score = 0.0
300
+ for i, (gt_pos, lvl) in enumerate(grid[:-1]):
301
+ has_note = np.any(np.abs(note_times - gt_pos) < tol)
302
+ nxt_pos, nxt_lvl = grid[i + 1]
303
+ nxt_note = np.any(np.abs(note_times - nxt_pos) < tol)
304
+ if has_note and not nxt_note and nxt_lvl > lvl:
305
+ score += nxt_lvl - lvl
306
+ n_bars = max(1.0, len([g for g in grid if g[1] == 3]))
307
+ return min(5, int(score / n_bars / band_width))
308
+
309
+
310
+ def collate(batch):
311
+ xs, seqs, plens, auxs, gsums, posbs, beats, inseqs = zip(*batch)
312
+ x = torch.stack(xs)
313
+ maxlen = max(len(s) for s in seqs)
314
+ tgt = torch.full((len(seqs), maxlen), VOCAB.pad, dtype=torch.long)
315
+ loss_mask = torch.zeros((len(seqs), maxlen), dtype=torch.bool)
316
+ for i, (s, pl) in enumerate(zip(seqs, plens)):
317
+ tgt[i, : len(s)] = s
318
+ loss_mask[i, pl : len(s)] = True # loss on events + EOS only
319
+ aux = torch.stack(auxs) if auxs[0].numel() else None
320
+ gsum = torch.stack(gsums) if gsums[0].numel() else None
321
+ posb = torch.tensor(posbs, dtype=torch.long)
322
+ beat = torch.stack(beats) if beats[0].numel() else None
323
+ in_tgt = torch.full((len(inseqs), maxlen), VOCAB.pad, dtype=torch.long)
324
+ for i, sq in enumerate(inseqs):
325
+ in_tgt[i, : len(sq)] = sq
326
+ return x, tgt, loss_mask, aux, gsum, posb, beat, in_tgt
327
+
328
+
329
+ def load_split_ids(cache_dir, val_songs=40, seed=42, use_aug=False):
330
+ """Song-level split: train / val (carved from train) / test (provided).
331
+
332
+ Augmented variants (speed-aug entries with "aug": true) are never used for
333
+ val, and variants whose source song is in val are excluded from train
334
+ (no leakage). The base split is stable regardless of augmentation.
335
+ """
336
+ with open(os.path.join(cache_dir, "index.json")) as f:
337
+ index = json.load(f)
338
+ base_train = sorted(e["id"] for e in index
339
+ if e["split"] == "train" and not e.get("aug"))
340
+ test_ids = sorted(e["id"] for e in index if e["split"] == "test")
341
+ rng = random.Random(seed)
342
+ rng.shuffle(base_train)
343
+ val_ids = base_train[:val_songs]
344
+ train_ids = base_train[val_songs:]
345
+ if use_aug:
346
+ val_set = set(val_ids)
347
+ train_ids = train_ids + sorted(
348
+ e["id"] for e in index
349
+ if e.get("aug") and e.get("src") not in val_set)
350
+ return train_ids, val_ids, test_ids
softchart/evaluate.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Objective evaluation metrics for generated charts."""
2
+
3
+ import numpy as np
4
+
5
+ HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
6
+
7
+
8
+ def match_onsets(ref_times, est_times, tol=0.05):
9
+ """Greedy bipartite matching (mir_eval style). Returns list of (ri, ei)."""
10
+ matches = []
11
+ ri, ei = 0, 0
12
+ used_r = set()
13
+ # standard approach: for each est, match nearest unmatched ref within tol
14
+ ref = np.asarray(ref_times)
15
+ est = np.asarray(est_times)
16
+ order = np.argsort(est)
17
+ ref_order = np.argsort(ref)
18
+ ref_sorted = ref[ref_order]
19
+ taken = np.zeros(len(ref), dtype=bool)
20
+ for e_i in order:
21
+ t = est[e_i]
22
+ lo = np.searchsorted(ref_sorted, t - tol)
23
+ hi = np.searchsorted(ref_sorted, t + tol)
24
+ best, best_d = -1, tol + 1
25
+ for k in range(lo, hi):
26
+ rid = ref_order[k]
27
+ if taken[rid]:
28
+ continue
29
+ d = abs(ref_sorted[k] - t)
30
+ if d < best_d:
31
+ best, best_d = rid, d
32
+ if best >= 0:
33
+ taken[best] = True
34
+ matches.append((best, e_i))
35
+ return matches
36
+
37
+
38
+ def onset_prf(ref_times, est_times, tol=0.05):
39
+ if len(ref_times) == 0 and len(est_times) == 0:
40
+ return 1.0, 1.0, 1.0
41
+ if len(ref_times) == 0 or len(est_times) == 0:
42
+ return 0.0, 0.0, 0.0
43
+ m = len(match_onsets(ref_times, est_times, tol))
44
+ p = m / len(est_times)
45
+ r = m / len(ref_times)
46
+ f = 2 * p * r / (p + r) if p + r > 0 else 0.0
47
+ return p, r, f
48
+
49
+
50
+ def type_accuracy(ref, est, tol=0.05):
51
+ """ref/est: list of (time, class). Accuracy + per-class stats on matched onsets."""
52
+ if not ref or not est:
53
+ return None
54
+ rt = [t for t, _ in ref]
55
+ et = [t for t, _ in est]
56
+ matches = match_onsets(rt, et, tol)
57
+ if not matches:
58
+ return None
59
+ correct = sum(1 for ri, ei in matches if ref[ri][1] == est[ei][1])
60
+ per_class = {}
61
+ for ri, ei in matches:
62
+ c = ref[ri][1]
63
+ d = per_class.setdefault(c, [0, 0])
64
+ d[1] += 1
65
+ if est[ei][1] == c:
66
+ d[0] += 1
67
+ # binary don/ka accuracy (big variants folded in)
68
+ fold = lambda c: "don" if "don" in c else ("ka" if "ka" in c else c)
69
+ correct2 = sum(1 for ri, ei in matches if fold(ref[ri][1]) == fold(est[ei][1]))
70
+ return {
71
+ "acc": correct / len(matches),
72
+ "acc_donka": correct2 / len(matches),
73
+ "n_matched": len(matches),
74
+ "per_class": {c: v[0] / v[1] for c, v in per_class.items()},
75
+ }
76
+
77
+
78
+ def density(events, span=None):
79
+ times = [t for t, c in events if c in HIT_CLASSES]
80
+ if len(times) < 2:
81
+ return 0.0
82
+ span = span or (max(times) - min(times))
83
+ return len(times) / max(span, 1e-6) if span > 5 else 0.0
84
+
85
+
86
+ def ioi_hist(events, bpm, bins=None):
87
+ """Inter-onset intervals in beat units, snapped to musical fractions."""
88
+ if bins is None:
89
+ bins = [0.125, 1 / 6, 0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]
90
+ times = sorted(t for t, c in events if c in HIT_CLASSES)
91
+ if len(times) < 3 or not bpm or bpm <= 0:
92
+ return None
93
+ ioi = np.diff(times) * bpm / 60.0 # beats
94
+ hist = np.zeros(len(bins) + 1)
95
+ for x in ioi:
96
+ d = [abs(x - b) / b for b in bins]
97
+ j = int(np.argmin(d))
98
+ if d[j] < 0.2:
99
+ hist[j] += 1
100
+ else:
101
+ hist[-1] += 1 # off-grid
102
+ s = hist.sum()
103
+ return hist / s if s > 0 else None
104
+
105
+
106
+ def js_divergence(p, q, eps=1e-9):
107
+ p = np.asarray(p) + eps
108
+ q = np.asarray(q) + eps
109
+ p, q = p / p.sum(), q / q.sum()
110
+ m = (p + q) / 2
111
+ kl = lambda a, b: float(np.sum(a * np.log(a / b)))
112
+ return 0.5 * kl(p, m) + 0.5 * kl(q, m)
113
+
114
+
115
+ def ngram_dist(events, n=2):
116
+ """Distribution over note-class n-grams (hits only)."""
117
+ seq = [c for _, c in sorted(events) if c in HIT_CLASSES]
118
+ if len(seq) < n + 1:
119
+ return {}
120
+ counts = {}
121
+ for i in range(len(seq) - n + 1):
122
+ g = tuple(seq[i : i + n])
123
+ counts[g] = counts.get(g, 0) + 1
124
+ tot = sum(counts.values())
125
+ return {g: c / tot for g, c in counts.items()}
126
+
127
+
128
+ def ngram_js(ref_events, est_events, n=2):
129
+ pr = ngram_dist(ref_events, n)
130
+ pe = ngram_dist(est_events, n)
131
+ if not pr or not pe:
132
+ return None
133
+ keys = sorted(set(pr) | set(pe))
134
+ return js_divergence([pr.get(k, 0) for k in keys], [pe.get(k, 0) for k in keys])
135
+
136
+
137
+ def distinct_n(events, n=3):
138
+ seq = [c for _, c in sorted(events) if c in HIT_CLASSES]
139
+ if len(seq) < n:
140
+ return None
141
+ grams = [tuple(seq[i : i + n]) for i in range(len(seq) - n + 1)]
142
+ return len(set(grams)) / len(grams)
143
+
144
+
145
+ def spearman(x, y):
146
+ from scipy.stats import spearmanr
147
+
148
+ if len(x) < 2:
149
+ return None
150
+ r = spearmanr(x, y).statistic
151
+ return None if np.isnan(r) else float(r)
softchart/generate.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ model = ChartModel(
49
+ d_model=a.get("d_model", 512), enc_layers=a.get("enc_layers", 6),
50
+ dec_layers=a.get("dec_layers", 6), ffn=a.get("ffn", 2048),
51
+ vocab_size=vocab_size, aux=aux, global_ctx=gctx,
52
+ func_time=a.get("func_time", False),
53
+ ).to(device)
54
+ if any(k.startswith("ptr") for k in sd):
55
+ model.enable_ptr()
56
+ if any(k.startswith("beat") for k in sd):
57
+ model.enable_beat()
58
+ model.to(device) # newly enabled heads default to CPU
59
+ model.load_state_dict(sd)
60
+ model.eval()
61
+ # capability detection: prefer explicit training args (vocab-size inference
62
+ # wrongly added prefix slots to models that never trained with them)
63
+ if a:
64
+ model._has_ctx = bool(a.get("ctx", False))
65
+ model._has_sib = bool(a.get("sibling", False))
66
+ model._has_style = bool(a.get("style", False))
67
+ model._has_sync = bool(a.get("sync_token", False))
68
+ model._has_plan = bool(a.get("plan", False))
69
+ else: # legacy fallback
70
+ model._has_ctx = vocab_size > VOCAB.sep
71
+ model._has_sib = vocab_size > VOCAB.sib
72
+ model._has_style = False
73
+ model._has_sync = False
74
+ model._has_plan = False
75
+ model._has_gctx = gctx
76
+ return model
77
+
78
+
79
+ def load_hf(repo_or_dir, device="cuda"):
80
+ """Load a Hugging Face SoftChartGenerator (safetensors) and attach the
81
+ capability flags that generate_song() reads, so HF models are drop-in."""
82
+ from .hf import SoftChartGenerator
83
+
84
+ hf = SoftChartGenerator.from_pretrained(repo_or_dir).to(device).eval()
85
+ m = hf.net
86
+ caps = hf.capabilities or {}
87
+ m._has_ctx = bool(caps.get("ctx", False))
88
+ m._has_sib = bool(caps.get("sibling", False))
89
+ m._has_style = bool(caps.get("style", False))
90
+ m._has_sync = bool(caps.get("sync_token", False))
91
+ m._has_plan = bool(caps.get("plan", False))
92
+ m._has_gctx = m.gsum_proj is not None
93
+ return m
94
+
95
+
96
+ def build_prefix(course=None, level=None, density_bucket=None, ctx_types=None,
97
+ uncond=False, sib_pairs=None, style=None, sync_band=None,
98
+ plan_slice=None):
99
+ v = VOCAB
100
+ seq = [v.bos]
101
+ if ctx_types is not None:
102
+ ctx = list(ctx_types)[-CTX_LEN:]
103
+ 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]
104
+ seq += ctx + [v.sep]
105
+ if sib_pairs is not None: # fixed 12-event sibling segment (unk-padded)
106
+ from .vocab import SIB_EVENTS
107
+
108
+ seq.append(v.sib)
109
+ pairs = list(sib_pairs)[:SIB_EVENTS]
110
+ for f, c in pairs:
111
+ cls = c if isinstance(c, int) else NOTE_CLASSES.index(c)
112
+ seq += [v.time(int(f)), v.note[NOTE_CLASSES[cls]]]
113
+ seq += [v.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
114
+ if uncond:
115
+ seq += [v.unk_cond] * 3
116
+ else:
117
+ seq += [
118
+ v.course[course] if course else v.unk_cond,
119
+ v.level[max(1, min(N_LEVELS, level))] if level else v.unk_cond,
120
+ v.dens[density_bucket] if density_bucket is not None else v.unk_cond,
121
+ ]
122
+ if style is not None: # -1 = style-capable model, no specific style requested
123
+ seq.append(v.style[style] if style >= 0 else v.unk_cond)
124
+ if sync_band is not None:
125
+ seq.append(v.sync[sync_band] if sync_band >= 0 else v.unk_cond)
126
+ if plan_slice is not None: # fixed PLAN_SLOTS blocks, unk-padded
127
+ from .vocab import PLAN_SLOTS
128
+
129
+ seq.append(v.plan)
130
+ blocks = list(plan_slice)[:PLAN_SLOTS]
131
+ for d8, fl in blocks:
132
+ seq += [v.pdens[min(7, max(0, int(d8)))], v.pflag[min(2, max(0, int(fl)))]]
133
+ seq += [v.unk_cond] * (2 * (PLAN_SLOTS - len(blocks)))
134
+ return seq
135
+
136
+
137
+ @torch.no_grad()
138
+ def decode_windows(model, mels, prefixes, device="cuda", temperature=1.0, top_p=0.95,
139
+ greedy=False, seed=0, cfg_w=0.0, gsum=None, pos_buckets=None,
140
+ lattice=False, n_cond=3):
141
+ """mels: (B, n_mels, WINDOW); prefixes: list of B equal-length token lists.
142
+ Returns list of lists of (frame, note_class)."""
143
+ v = VOCAB
144
+ B = mels.shape[0]
145
+ plen = len(prefixes[0])
146
+ assert all(len(p) == plen for p in prefixes)
147
+ use_cfg = cfg_w and cfg_w > 0 and not greedy
148
+
149
+ gen_device = "cpu" if _dev_type(device) == "mps" else device
150
+ gen = torch.Generator(device=gen_device)
151
+ gen.manual_seed(seed)
152
+ with _autocast(device):
153
+ memory = model.encode(
154
+ mels.to(device),
155
+ gsum=gsum.to(device) if gsum is not None else None,
156
+ pos_bucket=pos_buckets.to(device) if pos_buckets is not None else None,
157
+ )
158
+
159
+ seqs = torch.tensor(prefixes, dtype=torch.long, device=device)
160
+ if use_cfg: # rows B..2B: same ctx, conditions replaced by UNK
161
+ unc = seqs.clone()
162
+ unc[:, -n_cond:] = v.unk_cond
163
+ seqs = torch.cat([seqs, unc], dim=0)
164
+ memory = torch.cat([memory, memory], dim=0)
165
+
166
+ done = torch.zeros(B, dtype=torch.bool, device=device)
167
+ last_time = torch.full((B,), -MIN_GAP_FRAMES, dtype=torch.long, device=device)
168
+ expect_note = torch.zeros(B, dtype=torch.bool, device=device)
169
+ note_ids = torch.tensor(sorted(v.note.values()), device=device)
170
+ # lattice state: previous inter-onset interval (frames); -1 = unknown
171
+ prev_ioi = torch.full((B,), -1, dtype=torch.long, device=device)
172
+ ratios = torch.tensor(LATTICE_RATIOS, device=device)
173
+
174
+ for _ in range(MAX_TGT - plen):
175
+ with _autocast(device):
176
+ logits_all = model.decode(seqs, memory)[:, -1].float()
177
+ if use_cfg:
178
+ logits = logits_all[B:] + cfg_w * (logits_all[:B] - logits_all[B:])
179
+ else:
180
+ logits = logits_all
181
+
182
+ mask = torch.full_like(logits, float("-inf"))
183
+ note_row = torch.full((logits.shape[1],), float("-inf"), device=device)
184
+ note_row[note_ids] = 0.0
185
+ mask[expect_note] = note_row
186
+ idx = (~expect_note).nonzero(as_tuple=True)[0]
187
+ if len(idx):
188
+ time_pos = torch.arange(WINDOW, device=device).unsqueeze(0)
189
+ sub = torch.full((len(idx), logits.shape[1]), float("-inf"), device=device)
190
+ sub[:, v.eos] = 0.0
191
+ ok = time_pos >= (last_time[idx] + MIN_GAP_FRAMES).unsqueeze(1)
192
+ tmask = torch.where(
193
+ ok, torch.zeros_like(sub[:, :WINDOW]),
194
+ torch.full_like(sub[:, :WINDOW], float("-inf")),
195
+ )
196
+ if lattice:
197
+ # soft rhythmic-lattice constraint: once an IOI is established,
198
+ # penalize next onsets whose IOI ratio is not a musical fraction
199
+ # (kills "between two subdivisions" notes at the source)
200
+ pi = prev_ioi[idx]
201
+ active = (pi >= MIN_GAP_FRAMES) & (pi <= 86) # sub-second IOIs only
202
+ if active.any():
203
+ delta = (time_pos - last_time[idx].unsqueeze(1)).float() # (n, W)
204
+ rel = delta / pi.unsqueeze(1).clamp(min=1).float()
205
+ err = (rel.unsqueeze(-1) / ratios - 1.0).abs().min(-1).values
206
+ bad = (err > 0.13) & (rel < 4.0) & (delta > 0)
207
+ pen = torch.where(bad & active.unsqueeze(1),
208
+ torch.full_like(delta, -6.0),
209
+ torch.zeros_like(delta))
210
+ tmask = tmask + pen
211
+ sub[:, v.time0 : v.time0 + WINDOW] = tmask
212
+ mask[idx] = sub
213
+ logits = logits + mask
214
+
215
+ if greedy:
216
+ nxt = logits.argmax(-1)
217
+ else:
218
+ probs = torch.softmax(logits / temperature, dim=-1)
219
+ sp, si = torch.sort(probs, descending=True, dim=-1)
220
+ cum = torch.cumsum(sp, dim=-1)
221
+ keep = cum - sp < top_p
222
+ keep[:, 0] = True
223
+ sp = sp * keep
224
+ sp = sp / sp.sum(-1, keepdim=True)
225
+ if _dev_type(device) == "mps":
226
+ pick = torch.multinomial(sp.cpu(), 1, generator=gen).squeeze(1).to(device)
227
+ else:
228
+ pick = torch.multinomial(sp, 1, generator=gen).squeeze(1)
229
+ nxt = si[torch.arange(B, device=device), pick]
230
+
231
+ nxt = torch.where(done, torch.full_like(nxt, v.pad), nxt)
232
+ step_tok = torch.cat([nxt, nxt], dim=0) if use_cfg else nxt
233
+ seqs = torch.cat([seqs, step_tok.unsqueeze(1)], dim=1)
234
+ is_time = (nxt >= v.time0) & (nxt < v.time0 + WINDOW)
235
+ new_time = nxt - v.time0
236
+ upd = is_time & (last_time >= 0)
237
+ prev_ioi = torch.where(upd, new_time - last_time, prev_ioi)
238
+ last_time = torch.where(is_time, new_time, last_time)
239
+ expect_note = is_time
240
+ done = done | (nxt == v.eos)
241
+ if done.all():
242
+ break
243
+
244
+ out = []
245
+ for b in range(B):
246
+ toks = seqs[b, plen:].tolist()
247
+ events = []
248
+ cur_t = None
249
+ for t in toks:
250
+ if t == v.eos or t == v.pad:
251
+ break
252
+ if v.time0 <= t < v.time0 + WINDOW:
253
+ cur_t = t - v.time0
254
+ elif t in v.id2note and cur_t is not None:
255
+ events.append((cur_t, v.id2note[t]))
256
+ out.append(events)
257
+ return out
258
+
259
+
260
+ def _decode_pass(model, wins, starts, course, level, density_bucket, ctx_lists,
261
+ device, greedy, temperature, top_p, seed, cfg_w, batch_windows,
262
+ gsum=None, T=None, lattice=False, sib_default=False, style=None,
263
+ sync_band=None, plan_blocks=None, plan_default=False):
264
+ all_events = []
265
+ per_window = []
266
+ for i in range(0, len(wins), batch_windows):
267
+ chunk = torch.stack(wins[i : i + batch_windows])
268
+ prefixes = [
269
+ build_prefix(course, level, density_bucket,
270
+ ctx_types=(ctx_lists[i + j] if ctx_lists is not None else None),
271
+ sib_pairs=([] if sib_default else None), style=style,
272
+ sync_band=sync_band,
273
+ plan_slice=(
274
+ [(b[2], b[3]) for b in plan_blocks
275
+ if b[1] > starts[i + j] / FPS
276
+ and b[0] < (starts[i + j] + WINDOW) / FPS]
277
+ if plan_blocks is not None
278
+ else ([] if plan_default else None)))
279
+ for j in range(chunk.shape[0])
280
+ ]
281
+ g = pb = None
282
+ if gsum is not None:
283
+ g = gsum.unsqueeze(0).expand(chunk.shape[0], -1, -1)
284
+ pb = torch.tensor(
285
+ [min(15, int(16 * starts[i + j] / max(T, 1))) for j in range(chunk.shape[0])],
286
+ dtype=torch.long)
287
+ evs = decode_windows(model, chunk, prefixes, device=device, greedy=greedy,
288
+ temperature=temperature, top_p=top_p, seed=seed + i,
289
+ cfg_w=cfg_w, gsum=g, pos_buckets=pb, lattice=lattice,
290
+ n_cond=4 if style is not None else 3)
291
+ for j, events in enumerate(evs):
292
+ per_window.append(events)
293
+ t_off = starts[i + j] / FPS
294
+ for f, cls in events:
295
+ all_events.append((t_off + f / FPS, cls))
296
+ return all_events, per_window
297
+
298
+
299
+ @torch.no_grad()
300
+ def generate_song(model, mel, course, level=None, density_bucket=None, device="cuda",
301
+ greedy=False, temperature=1.0, top_p=0.95, seed=0, batch_windows=8,
302
+ cfg_w=0.0, use_ctx=None, lattice=False, style=None, sync_band=None,
303
+ plan=None):
304
+ """mel: (n_mels, T). Returns dict with 'hits' and 'spans' in seconds."""
305
+ if isinstance(mel, np.ndarray):
306
+ mel = torch.from_numpy(mel.astype(np.float32))
307
+ T = mel.shape[1]
308
+ starts = list(range(0, max(T - 1, 1), WINDOW))
309
+ wins = []
310
+ for s in starts:
311
+ w = mel[:, s : s + WINDOW]
312
+ if w.shape[1] < WINDOW:
313
+ w = torch.nn.functional.pad(w, (0, WINDOW - w.shape[1]), value=float(np.log(1e-5)))
314
+ wins.append(w)
315
+
316
+ if use_ctx is None:
317
+ use_ctx = getattr(model, "_has_ctx", False)
318
+ sib_default = getattr(model, "_has_sib", False)
319
+ style_val = None
320
+ if getattr(model, "_has_style", False):
321
+ style_val = style if style is not None else -1
322
+ sync_val = None
323
+ if getattr(model, "_has_sync", False):
324
+ sync_val = sync_band if sync_band is not None else -1
325
+ plan_blocks = plan if getattr(model, "_has_plan", False) else None
326
+ plan_default = getattr(model, "_has_plan", False) and plan is None
327
+ gsum = None
328
+ if getattr(model, "_has_gctx", False):
329
+ from .data import song_summary
330
+
331
+ gsum = torch.from_numpy(song_summary(mel.numpy()))
332
+
333
+ all_events, per_window = _decode_pass(
334
+ model, wins, starts, course, level, density_bucket,
335
+ None if not use_ctx else [[] for _ in wins], # pass 1: empty ctx
336
+ device, greedy, temperature, top_p, seed, cfg_w, batch_windows,
337
+ gsum=gsum, T=T, lattice=lattice, sib_default=sib_default, style=style_val,
338
+ sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default)
339
+
340
+ if use_ctx and len(wins) > 1:
341
+ # pass 2: condition each window on the tail of the previous window's pass-1 output
342
+ ctx_lists = [[]]
343
+ for w_ev in per_window[:-1]:
344
+ tail = [NOTE_CLASSES.index(c) for _, c in w_ev if c in HIT_CLASSES][-CTX_LEN:]
345
+ ctx_lists.append(tail)
346
+ all_events, per_window = _decode_pass(
347
+ model, wins, starts, course, level, density_bucket, ctx_lists,
348
+ device, greedy, temperature, top_p, seed, cfg_w, batch_windows,
349
+ gsum=gsum, T=T, lattice=lattice, sib_default=sib_default, style=style_val,
350
+ sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default)
351
+
352
+ if True:
353
+ # rescue decoding: decoding occasionally EOSes a whole window early; if a
354
+ # window is near-empty while its audio is musically active, redo it with
355
+ # the opposite mode (sampling was empty -> greedy; greedy was empty ->
356
+ # sampled retry, since a greedy redo would reproduce the same output)
357
+ flux = [float(np.maximum(0, np.diff(w.numpy(), axis=1)).sum()) for w in wins]
358
+ med = float(np.median(flux)) if flux else 0.0
359
+ med_hits = float(np.median([len(ev) for ev in per_window])) if per_window else 0.0
360
+ retry = [i for i, ev in enumerate(per_window)
361
+ if (len(ev) < 4 and flux[i] > 0.3 * med)
362
+ or (len(ev) < 0.4 * med_hits and flux[i] > 0.7 * med)]
363
+ if retry:
364
+ r_evs, _ = _decode_pass(
365
+ model, [wins[i] for i in retry], [starts[i] for i in retry],
366
+ course, level, density_bucket, None, device, not greedy, 0.9,
367
+ top_p, seed + 7, 0.0, batch_windows, gsum=gsum, T=T,
368
+ lattice=lattice, sib_default=sib_default, style=style_val,
369
+ sync_band=sync_val, plan_blocks=plan_blocks, plan_default=plan_default)
370
+ all_events = [e for i, w_ev in enumerate(per_window) if i not in retry
371
+ for e in [(starts[i] / FPS + f / FPS, c) for f, c in w_ev]]
372
+ all_events += r_evs
373
+
374
+ all_events.sort(key=lambda e: e[0])
375
+ hits, spans = [], []
376
+ open_span = None
377
+ last_hit_t = -1.0
378
+ for t, cls in all_events:
379
+ if cls in HIT_CLASSES:
380
+ if open_span is not None:
381
+ # a span whose end never arrived would swallow every following
382
+ # hit (visual audit: 20 s of silence) — force-close at SPAN_MAX
383
+ if t - open_span[0] > SPAN_MAX.get(open_span[1], 6.5):
384
+ spans.append({"t0": round(open_span[0], 4),
385
+ "t1": round(open_span[0] + SPAN_MAX.get(open_span[1], 6.5), 4),
386
+ "type": open_span[1]})
387
+ open_span = None
388
+ else:
389
+ continue # no hits inside an open span
390
+ if t - last_hit_t < 0.025:
391
+ continue
392
+ hits.append({"t": round(t, 4), "type": cls})
393
+ last_hit_t = t
394
+ elif cls in SPAN_CLASSES:
395
+ if open_span is None:
396
+ open_span = (t, cls)
397
+ elif cls == "end":
398
+ if open_span is not None and t - open_span[0] > 0.05:
399
+ t1_span = min(t, open_span[0] + SPAN_MAX.get(open_span[1], 6.5))
400
+ spans.append({"t0": round(open_span[0], 4), "t1": round(t1_span, 4),
401
+ "type": open_span[1]})
402
+ open_span = None
403
+ return {"hits": hits, "spans": spans, "course": course, "level": level,
404
+ "density_bucket": density_bucket}
softchart/hf.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, capabilities=None):
35
+ super().__init__()
36
+ self.net = ChartModel(
37
+ d_model=d_model, enc_layers=enc_layers, dec_layers=dec_layers, ffn=ffn,
38
+ vocab_size=vocab_size, aux=aux, global_ctx=global_ctx, func_time=func_time,
39
+ ptr=ptr, beat=beat,
40
+ )
41
+ # capabilities: which condition axes this checkpoint was trained with,
42
+ # so inference knows which prefix tokens to emit
43
+ self.capabilities = capabilities or {}
44
+
45
+ # delegate the generation interface to the inner net
46
+ def encode(self, *a, **k):
47
+ return self.net.encode(*a, **k)
48
+
49
+ def decode(self, *a, **k):
50
+ return self.net.decode(*a, **k)
51
+
52
+ def forward(self, *a, **k):
53
+ return self.net(*a, **k)
54
+
55
+ @property
56
+ def ptr(self):
57
+ return self.net.ptr
58
+
59
+ @property
60
+ def beat(self):
61
+ return self.net.beat
62
+
63
+ @property
64
+ def tok_emb(self):
65
+ return self.net.tok_emb
66
+
67
+
68
+ class SoftChartPlanner(
69
+ nn.Module,
70
+ PyTorchModelHubMixin,
71
+ repo_url="https://github.com/JacobLinCool/SoftChart",
72
+ license="mit",
73
+ tags=["taiko", "rhythm-game", "planning", "music"],
74
+ ):
75
+ """Song-level plan generator: block audio summaries -> (density, flag) tokens."""
76
+
77
+ def __init__(self, feat_dim=261, d=192, layers=4, n_dens=8, n_flag=3):
78
+ super().__init__()
79
+ self.inp = nn.Linear(feat_dim, d)
80
+ self.course = nn.Embedding(5, d)
81
+ self.register_buffer("pos", sinusoidal(128, d), persistent=False)
82
+ enc = nn.TransformerEncoderLayer(d, 6, d * 4, 0.1, activation="gelu",
83
+ batch_first=True, norm_first=True)
84
+ self.enc = nn.TransformerEncoder(enc, layers, nn.LayerNorm(d))
85
+ self.h_dens = nn.Linear(d, n_dens)
86
+ self.h_flag = nn.Linear(d, n_flag)
87
+
88
+ def forward(self, x, cid):
89
+ h = self.inp(x) + self.course(cid)[:, None] + self.pos[: x.shape[1]]
90
+ h = self.enc(h)
91
+ return self.h_dens(h), self.h_flag(h)
softchart/model.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ChartModel(nn.Module):
22
+ def __init__(
23
+ self,
24
+ d_model=512,
25
+ nhead=8,
26
+ enc_layers=6,
27
+ dec_layers=6,
28
+ ffn=2048,
29
+ dropout=0.1,
30
+ vocab_size=None,
31
+ max_tgt=None,
32
+ aux=False,
33
+ global_ctx=False,
34
+ func_time=False,
35
+ ptr=False,
36
+ beat=False,
37
+ ):
38
+ super().__init__()
39
+ from .vocab import MAX_TGT
40
+
41
+ vocab_size = vocab_size or VOCAB.size
42
+ max_tgt = max_tgt or MAX_TGT
43
+ self.d_model = d_model
44
+
45
+ # conv frontend: (B, n_mels, T) -> (B, T/4, d)
46
+ self.frontend = nn.Sequential(
47
+ nn.Conv1d(N_MELS, d_model, 3, stride=2, padding=1),
48
+ nn.GELU(),
49
+ nn.Conv1d(d_model, d_model, 3, stride=2, padding=1),
50
+ nn.GELU(),
51
+ )
52
+ enc_len = WINDOW // 4
53
+ self.register_buffer("enc_pos", sinusoidal(enc_len, d_model), persistent=False)
54
+ self.register_buffer("dec_pos", sinusoidal(max_tgt, d_model), persistent=False)
55
+
56
+ enc_layer = nn.TransformerEncoderLayer(
57
+ d_model, nhead, ffn, dropout, activation="gelu", batch_first=True, norm_first=True
58
+ )
59
+ self.encoder = nn.TransformerEncoder(enc_layer, enc_layers, nn.LayerNorm(d_model))
60
+ dec_layer = nn.TransformerDecoderLayer(
61
+ d_model, nhead, ffn, dropout, activation="gelu", batch_first=True, norm_first=True
62
+ )
63
+ self.decoder = nn.TransformerDecoder(dec_layer, dec_layers, nn.LayerNorm(d_model))
64
+
65
+ self.tok_emb = nn.Embedding(vocab_size, d_model, padding_idx=VOCAB.pad)
66
+ nn.init.normal_(self.tok_emb.weight, std=0.02) # keep tied logits well-scaled
67
+ self.out = nn.Linear(d_model, vocab_size, bias=False)
68
+ self.out.weight = self.tok_emb.weight # weight tying
69
+ self.dropout = nn.Dropout(dropout)
70
+ # auxiliary per-position onset-heatmap head on the encoder (v2)
71
+ self.aux = nn.Linear(d_model, 1) if aux else None
72
+ # pointer alignment head: each generated note must point to its audio
73
+ # frame (differentiable provenance; explainability that trains alignment)
74
+ self.ptr = nn.Linear(d_model, d_model) if ptr else None
75
+ # beat/downbeat head: explicit metrical percept on the encoder
76
+ self.beat = nn.Linear(d_model, 2) if beat else None
77
+ # song-level context: coarse whole-song summary + window position (v4).
78
+ # Lets the model see beyond the window, so intentional gaps (a breath
79
+ # before a strong section) are informed decisions, not failures.
80
+ # v2 fix (after the v4 negative result): summary chunks get DEDICATED
81
+ # positional embeddings instead of reusing window positions.
82
+ if global_ctx:
83
+ self.gsum_proj = nn.Linear(N_MELS, d_model)
84
+ self.seg_emb = nn.Parameter(torch.zeros(2, d_model))
85
+ self.pos_emb = nn.Embedding(16, d_model) # window position bucket
86
+ self.gpos = nn.Parameter(torch.randn(128, d_model) * 0.02)
87
+ else:
88
+ self.gsum_proj = None
89
+ # functional time embeddings: the 1728 TIME tokens share a sinusoidal
90
+ # basis + small projection instead of free embeddings (fewer params,
91
+ # neighbouring times get similar representations)
92
+ if func_time:
93
+ self.register_buffer("time_basis", sinusoidal(WINDOW, 64), persistent=False)
94
+ self.time_proj = nn.Linear(64, d_model)
95
+ # match the 0.02-std scale of tok_emb rows (basis row norm ~ sqrt(32))
96
+ nn.init.normal_(self.time_proj.weight, std=0.004)
97
+ nn.init.zeros_(self.time_proj.bias)
98
+ else:
99
+ self.time_proj = None
100
+
101
+ def encode(self, mel, gsum=None, pos_bucket=None):
102
+ # mel: (B, n_mels, T); gsum: (B, G, n_mels) whole-song summary chunks;
103
+ # pos_bucket: (B,) window-position bucket in [0, 16)
104
+ h = self.frontend(mel).transpose(1, 2) # (B, T/4, d)
105
+ h = h + self.enc_pos[: h.shape[1]]
106
+ if self.gsum_proj is not None and gsum is not None:
107
+ g = self.gsum_proj(gsum) + self.seg_emb[1] + self.gpos[: gsum.shape[1]]
108
+ h = h + self.seg_emb[0]
109
+ p = self.pos_emb(pos_bucket).unsqueeze(1) # (B, 1, d)
110
+ h = torch.cat([p, g, h], dim=1)
111
+ return self.encoder(self.dropout(h))
112
+
113
+ def emb_matrix(self):
114
+ """Full embedding matrix; TIME rows come from the functional basis."""
115
+ if self.time_proj is None:
116
+ return self.tok_emb.weight
117
+ E = self.tok_emb.weight
118
+ t = self.time_proj(self.time_basis) # (WINDOW, d)
119
+ return torch.cat([E[: VOCAB.time0], t, E[VOCAB.time0 + WINDOW :]], dim=0)
120
+
121
+ def decode(self, tgt_in, memory):
122
+ # tgt_in: (B, L) token ids
123
+ L = tgt_in.shape[1]
124
+ E = self.emb_matrix()
125
+ h = nn.functional.embedding(tgt_in, E) * math.sqrt(self.d_model) + self.dec_pos[:L]
126
+ mask = nn.Transformer.generate_square_subsequent_mask(L, device=tgt_in.device)
127
+ pad_mask = tgt_in == VOCAB.pad
128
+ h = self.decoder(
129
+ self.dropout(h),
130
+ memory,
131
+ tgt_mask=mask,
132
+ tgt_key_padding_mask=pad_mask,
133
+ tgt_is_causal=True,
134
+ )
135
+ return h @ E.T # tied output projection (functional rows included)
136
+
137
+ def enable_ptr(self):
138
+ self.ptr = nn.Linear(self.d_model, self.d_model)
139
+
140
+ def enable_beat(self):
141
+ self.beat = nn.Linear(self.d_model, 2)
142
+
143
+ def forward(self, mel, tgt, return_aux=False, gsum=None, pos_bucket=None,
144
+ return_extras=False, in_tgt=None):
145
+ # in_tgt: optional corrupted decoder input (e.g. MASKed types for the
146
+ # skeleton->color infill curriculum); gold targets stay = tgt
147
+ memory = self.encode(mel, gsum=gsum, pos_bucket=pos_bucket)
148
+ dec_in = (in_tgt if in_tgt is not None else tgt)[:, :-1]
149
+ L = dec_in.shape[1]
150
+ E = self.emb_matrix()
151
+ import math as _m
152
+ h = nn.functional.embedding(dec_in, E) * _m.sqrt(self.d_model) + self.dec_pos[:L]
153
+ dm = nn.Transformer.generate_square_subsequent_mask(L, device=tgt.device)
154
+ h = self.decoder(self.dropout(h), memory, tgt_mask=dm,
155
+ tgt_key_padding_mask=dec_in == VOCAB.pad, tgt_is_causal=True)
156
+ logits = h @ E.T
157
+ if not (return_aux or return_extras):
158
+ return logits
159
+ outs = [logits]
160
+ aux_mem = memory[:, -(WINDOW // 4):]
161
+ outs.append(self.aux(aux_mem).squeeze(-1) if self.aux is not None else None)
162
+ if return_extras:
163
+ ptr_logits = None
164
+ if self.ptr is not None:
165
+ q = self.ptr(h) # (B, L, d)
166
+ ptr_logits = q @ aux_mem.transpose(1, 2) / _m.sqrt(self.d_model)
167
+ beat_logits = self.beat(aux_mem) if self.beat is not None else None
168
+ outs += [ptr_logits, beat_logits]
169
+ return tuple(outs)
170
+
171
+
172
+ def count_params(m):
173
+ return sum(p.numel() for p in m.parameters() if p.requires_grad)
softchart/preprocess.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert the HF dataset into per-song cache files: log-mel (.npy) + notes (.npz).
2
+
3
+ Usage (on the training machine):
4
+ python -m softchart.preprocess --out /workspace/softchart/cache [--split train]
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ from collections import Counter
11
+
12
+ import numpy as np
13
+
14
+ from .vocab import HOP, N_FFT, N_MELS, NOTE_CLASSES, NOTE_TYPE_MAP, SR, COURSES
15
+
16
+
17
+ def to_mono_22k(audio):
18
+ """Handle both datasets<=3 dict audio and datasets>=4 torchcodec AudioDecoder."""
19
+ import torch
20
+
21
+ if isinstance(audio, dict):
22
+ arr = np.asarray(audio["array"], dtype=np.float32)
23
+ if arr.ndim == 2:
24
+ arr = arr.mean(axis=0)
25
+ sr = audio["sampling_rate"]
26
+ else: # torchcodec AudioDecoder
27
+ samples = audio.get_all_samples()
28
+ arr = samples.data.to(torch.float32).mean(dim=0).numpy()
29
+ sr = samples.sample_rate
30
+ if sr != SR:
31
+ import librosa
32
+
33
+ arr = librosa.resample(arr, orig_sr=sr, target_sr=SR, res_type="soxr_hq")
34
+ return torch.from_numpy(np.ascontiguousarray(arr))[None] # (1, T)
35
+
36
+
37
+ _mel_fb = None
38
+ _window = None
39
+
40
+
41
+ def logmel(wav):
42
+ """(1, T) float32 -> (n_mels, frames) float16 log-mel (torch.stft + librosa fb)."""
43
+ global _mel_fb, _window
44
+ import torch
45
+
46
+ if _mel_fb is None:
47
+ import librosa
48
+
49
+ fb = librosa.filters.mel(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=20.0, fmax=SR / 2)
50
+ _mel_fb = torch.from_numpy(fb) # (n_mels, n_fft//2+1)
51
+ _window = torch.hann_window(N_FFT)
52
+ with torch.no_grad():
53
+ spec = torch.stft(
54
+ wav, N_FFT, hop_length=HOP, window=_window, center=True, return_complex=True
55
+ )[0]
56
+ power = spec.abs().pow(2) # (freq, frames)
57
+ m = torch.log(_mel_fb @ power + 1e-5)
58
+ return m.numpy().astype(np.float16)
59
+
60
+
61
+ def extract_notes(course_struct):
62
+ """course struct -> (times, classes, level) or None."""
63
+ if course_struct is None or not course_struct.get("segments"):
64
+ return None
65
+ times, classes, bpms = [], [], []
66
+ unknown = Counter()
67
+ for seg in course_struct["segments"]:
68
+ for n in seg.get("notes") or []:
69
+ nt = n["note_type"]
70
+ canon = NOTE_TYPE_MAP.get(nt) or NOTE_TYPE_MAP.get(nt.lower())
71
+ if canon is None:
72
+ unknown[nt] += 1
73
+ continue
74
+ times.append(n["timestamp"])
75
+ classes.append(NOTE_CLASSES.index(canon))
76
+ if n.get("bpm"):
77
+ bpms.append(n["bpm"])
78
+ if not times:
79
+ return None
80
+ order = np.argsort(times, kind="stable")
81
+ return (
82
+ np.asarray(times, np.float64)[order],
83
+ np.asarray(classes, np.int8)[order],
84
+ int(course_struct.get("level") or 0),
85
+ float(np.median(bpms)) if bpms else 0.0,
86
+ unknown,
87
+ )
88
+
89
+
90
+ def main():
91
+ ap = argparse.ArgumentParser()
92
+ ap.add_argument("--out", default="cache")
93
+ ap.add_argument("--splits", nargs="+", default=["train", "test"])
94
+ args = ap.parse_args()
95
+
96
+ from datasets import Audio, load_dataset
97
+
98
+ ds_raw = load_dataset("JacobLinCool/taiko-1000-parsed")
99
+ # decode directly at the target sample rate (ffmpeg-side resampling);
100
+ # ds_raw kept as fallback for files torchcodec fails to resample
101
+ ds = ds_raw.cast_column("audio", Audio(sampling_rate=SR))
102
+ os.makedirs(args.out, exist_ok=True)
103
+ index = []
104
+ unknown_total = Counter()
105
+
106
+ for split in args.splits:
107
+ d = ds[split]
108
+ for i in range(len(d)):
109
+ row = d[i]
110
+ sid = f"{split}_{i:05d}"
111
+ mel_path = os.path.join(args.out, f"{sid}.mel.npy")
112
+ notes_path = os.path.join(args.out, f"{sid}.notes.npz")
113
+ entry = {
114
+ "id": sid,
115
+ "split": split,
116
+ "title": (row.get("metadata") or {}).get("TITLE", ""),
117
+ "genre": (row.get("metadata") or {}).get("GENRE", ""),
118
+ "courses": {},
119
+ }
120
+ arrays = {}
121
+ for c in COURSES:
122
+ ex = extract_notes(row.get(c))
123
+ if ex is None:
124
+ continue
125
+ times, classes, level, bpm, unknown = ex
126
+ unknown_total.update(unknown)
127
+ arrays[f"{c}_t"] = times
128
+ arrays[f"{c}_c"] = classes
129
+ entry["courses"][c] = {
130
+ "level": level,
131
+ "n_notes": int(len(times)),
132
+ "bpm": bpm,
133
+ }
134
+ if not arrays:
135
+ print(f"skip {sid}: no charts")
136
+ continue
137
+ if not (os.path.exists(mel_path) and os.path.exists(notes_path)):
138
+ try:
139
+ mel = logmel(to_mono_22k(row["audio"]))
140
+ except Exception:
141
+ try: # fallback: decode at native rate, resample with librosa
142
+ mel = logmel(to_mono_22k(ds_raw[split][i]["audio"]))
143
+ print(f"{sid}: used raw-rate fallback", flush=True)
144
+ except Exception as e:
145
+ print(f"skip {sid}: audio decode failed ({e})", flush=True)
146
+ continue
147
+ np.save(mel_path, mel)
148
+ np.savez(notes_path, **arrays)
149
+ entry["n_frames"] = int(mel.shape[1])
150
+ else:
151
+ entry["n_frames"] = int(np.load(mel_path, mmap_mode="r").shape[1])
152
+ index.append(entry)
153
+ if i % 50 == 0:
154
+ print(f"{split} {i}/{len(d)}", flush=True)
155
+
156
+ with open(os.path.join(args.out, "index.json"), "w") as f:
157
+ json.dump(index, f)
158
+ print("unknown note types:", dict(unknown_total))
159
+ print(f"done: {len(index)} songs")
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
softchart/proxies.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Automated proxies for playability, groove, and pattern structure.
2
+
3
+ Every proxy is computable without human input, from (events, bpm[, mel]).
4
+ Together with rhythm.py metrics they form the scorecard that closes the
5
+ evaluate -> diagnose -> improve loop.
6
+
7
+ Proxy design notes:
8
+ - playability = physical feasibility on the drum (min gaps, stream length,
9
+ big-note crowding) + learnable structure (motif reuse, compressibility)
10
+ - groove = alignment with the metrical grid (strong-beat rate, accent placement)
11
+ + audio grounding (onset-envelope energy at note times)
12
+ Reference values come from ground-truth charts of the same course (see scorecard).
13
+ """
14
+
15
+ import zlib
16
+
17
+ import numpy as np
18
+
19
+ HIT_CLASSES = ("don", "ka", "don_big", "ka_big")
20
+ BIG_CLASSES = ("don_big", "ka_big")
21
+
22
+
23
+ def _hits(events):
24
+ return sorted((t, c) for t, c in events if c in HIT_CLASSES)
25
+
26
+
27
+ def min_gap_violation_rate(events, min_gap=0.045):
28
+ """Fraction of consecutive hits closer than a physically playable gap."""
29
+ h = _hits(events)
30
+ if len(h) < 2:
31
+ return 0.0
32
+ ioi = np.diff([t for t, _ in h])
33
+ return float(np.mean(ioi < min_gap))
34
+
35
+
36
+ def stream_p99_nps(events, win=2.0):
37
+ """99th percentile of local density (notes/sec in rolling windows) — stamina proxy."""
38
+ t = np.array([x for x, _ in _hits(events)])
39
+ if len(t) < 4:
40
+ return 0.0
41
+ counts = [np.sum((t >= x) & (t < x + win)) for x in np.arange(t[0], t[-1], 0.5)]
42
+ return float(np.percentile(counts, 99) / win) if counts else 0.0
43
+
44
+
45
+ def big_crowding_rate(events, bpm):
46
+ """Fraction of big notes followed by another hit within 0.45 beat.
47
+
48
+ Big notes are played with both hands; crowding them against the next note
49
+ is physically awkward and rarely done in official charts.
50
+ """
51
+ h = _hits(events)
52
+ bigs = [(i, t) for i, (t, c) in enumerate(h) if c in BIG_CLASSES]
53
+ if not bigs or not bpm or bpm <= 0:
54
+ return None
55
+ thresh = 0.45 * 60.0 / bpm
56
+ crowded = sum(1 for i, t in bigs if i + 1 < len(h) and h[i + 1][0] - t < thresh)
57
+ return crowded / len(bigs)
58
+
59
+
60
+ def big_note_rate(events):
61
+ h = _hits(events)
62
+ if not h:
63
+ return 0.0
64
+ return sum(1 for _, c in h if c in BIG_CLASSES) / len(h)
65
+
66
+
67
+ def _symbol_stream(events, bpm):
68
+ """Quantized (ioi-class, note-type) symbol stream for structure metrics."""
69
+ h = _hits(events)
70
+ if len(h) < 3 or not bpm or bpm <= 0:
71
+ return []
72
+ beat = 60.0 / bpm
73
+ syms = []
74
+ for i in range(1, len(h)):
75
+ ioi = h[i][0] - h[i - 1][0]
76
+ frac = ioi / beat
77
+ # classify IOI into musical classes
78
+ classes = [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1.0, 1.5, 2.0]
79
+ j = int(np.argmin([abs(frac - c) / c for c in classes]))
80
+ ioi_cls = j if abs(frac - classes[j]) / classes[j] < 0.25 else len(classes)
81
+ syms.append((ioi_cls, h[i][1]))
82
+ return syms
83
+
84
+
85
+ def motif_reuse(events, bpm, n=4):
86
+ """Fraction of n-grams (rhythm+type) that occur at least twice in the chart.
87
+
88
+ Human charts repeat and transform motifs; random note salads do not.
89
+ """
90
+ syms = _symbol_stream(events, bpm)
91
+ if len(syms) < n + 2:
92
+ return None
93
+ grams = [tuple(syms[i : i + n]) for i in range(len(syms) - n + 1)]
94
+ from collections import Counter
95
+
96
+ counts = Counter(grams)
97
+ return float(sum(1 for g in grams if counts[g] >= 2) / len(grams))
98
+
99
+
100
+ def compression_ratio(events, bpm):
101
+ """zlib-compressed size / raw size of the symbol stream. Lower = more structured."""
102
+ syms = _symbol_stream(events, bpm)
103
+ if len(syms) < 16:
104
+ return None
105
+ raw = bytes(b for s in syms for b in (s[0], HIT_CLASSES.index(s[1])))
106
+ return float(len(zlib.compress(raw, 9)) / len(raw))
107
+
108
+
109
+ def strong_beat_rate(events, bpm, phase=None, tol=0.03):
110
+ """Fraction of hits on integer beats (downbeat proxy needs measures; beat is enough)."""
111
+ from .rhythm import estimate_phase
112
+
113
+ t = np.array([x for x, _ in _hits(events)])
114
+ if len(t) < 5 or not bpm or bpm <= 0:
115
+ return None
116
+ beat = 60.0 / bpm
117
+ if phase is None:
118
+ phase = estimate_phase(t, beat)
119
+ rel = (t - phase) / beat
120
+ return float(np.mean(np.abs(rel - np.round(rel)) * beat < tol))
121
+
122
+
123
+ def accent_on_strong(events, bpm, phase=None, tol=0.03):
124
+ """Fraction of BIG notes that fall on integer beats — accent placement proxy."""
125
+ from .rhythm import estimate_phase
126
+
127
+ h = _hits(events)
128
+ tb = np.array([t for t, c in h if c in BIG_CLASSES])
129
+ if len(tb) < 3 or not bpm or bpm <= 0:
130
+ return None
131
+ beat = 60.0 / bpm
132
+ if phase is None:
133
+ phase = estimate_phase(np.array([t for t, _ in h]), beat)
134
+ rel = (tb - phase) / beat
135
+ return float(np.mean(np.abs(rel - np.round(rel)) * beat < tol))
136
+
137
+
138
+ def audio_energy_alignment(events, mel, fps):
139
+ """Mean z-scored spectral-flux strength at hit times (GT-free audio grounding)."""
140
+ t = np.array([x for x, _ in _hits(events)])
141
+ if len(t) < 5:
142
+ return None
143
+ flux = np.maximum(0.0, np.diff(mel.astype(np.float32), axis=1)).sum(axis=0)
144
+ flux = (flux - flux.mean()) / (flux.std() + 1e-9)
145
+ fr = np.clip(np.round(t * fps).astype(int), 1, len(flux) - 1)
146
+ return float(np.mean(np.maximum.reduce([flux[fr - 1], flux[fr], flux[np.minimum(fr + 1, len(flux) - 1)]])))
147
+
148
+
149
+ def _pair_spans(events):
150
+ """Pair span-start/end events into (t0, t1) tuples (for GT event streams)."""
151
+ out = []
152
+ open_t = None
153
+ for t, c in sorted(events):
154
+ if c in ("roll", "roll_big", "balloon") and open_t is None:
155
+ open_t = t
156
+ elif c == "end" and open_t is not None:
157
+ out.append((open_t, t))
158
+ open_t = None
159
+ return out
160
+
161
+
162
+ def span_stats(events, spans=None):
163
+ """Span-length distribution + rate. Added after a visual audit found
164
+ generated balloons ~10s vs GT p50 ~1s (a metric blind spot)."""
165
+ if spans is None:
166
+ pairs = _pair_spans(events)
167
+ else:
168
+ pairs = [(s["t0"], s["t1"]) if isinstance(s, dict) else tuple(s) for s in spans]
169
+ h = _hits(events)
170
+ dur_min = max((h[-1][0] - h[0][0]) / 60.0, 1e-6) if len(h) > 1 else None
171
+ if not pairs or dur_min is None:
172
+ return {"span_p90_len": None, "span_per_min": 0.0 if dur_min else None}
173
+ lens = np.array([b - a for a, b in pairs])
174
+ return {"span_p90_len": float(np.percentile(lens, 90)),
175
+ "span_per_min": float(len(pairs) / dur_min)}
176
+
177
+
178
+ def compute_proxies(events, bpm, mel=None, fps=None, spans=None):
179
+ """All proxies for one chart. Returns dict (None where not computable)."""
180
+ from .rhythm import grid_consistency, tuplet_evenness
181
+
182
+ t = [x for x, _ in _hits(events)]
183
+ out = {
184
+ "nps": len(t) / max(t[-1] - t[0], 1e-6) if len(t) > 1 else 0.0,
185
+ "min_gap_violation": min_gap_violation_rate(events),
186
+ "stream_p99_nps": stream_p99_nps(events),
187
+ "big_rate": big_note_rate(events),
188
+ "big_crowding": big_crowding_rate(events, bpm),
189
+ "motif_reuse": motif_reuse(events, bpm),
190
+ "compression": compression_ratio(events, bpm),
191
+ "strong_beat": strong_beat_rate(events, bpm),
192
+ "accent_on_strong": accent_on_strong(events, bpm),
193
+ }
194
+ g = grid_consistency(t, bpm)
195
+ if g:
196
+ out["offgrid_rate"] = 1.0 - g["on_grid_frac"]
197
+ out["grid_dev_ms"] = g["mean_dev_ms"]
198
+ tp = tuplet_evenness(t, bpm)
199
+ if tp:
200
+ out["tuplet_cv"] = tp["cv_mean"]
201
+ if mel is not None and fps:
202
+ out["energy_align"] = audio_energy_alignment(events, mel, fps)
203
+ out.update(span_stats(events, spans))
204
+ return out
205
+
206
+
207
+ # direction: +1 higher is better, -1 lower is better, 0 match GT reference
208
+ PROXY_DIRECTION = {
209
+ "nps": 0, "min_gap_violation": -1, "stream_p99_nps": 0, "big_rate": 0,
210
+ "big_crowding": -1, "motif_reuse": 0, "compression": 0, "strong_beat": 0,
211
+ "accent_on_strong": 0, "offgrid_rate": -1, "grid_dev_ms": -1,
212
+ "tuplet_cv": -1, "energy_align": 1, "span_p90_len": 0, "span_per_min": 0,
213
+ }
214
+
215
+
216
+ def score_against_reference(proxy_row, ref_stats):
217
+ """Normalized deviation of each proxy vs the GT reference distribution.
218
+
219
+ ref_stats: {proxy: {"p25": .., "p50": .., "p75": ..}} per course.
220
+ Returns {proxy: score} where score = robust z of |dev| (0 = at GT median;
221
+ for directional proxies, only penalize the bad direction).
222
+ """
223
+ scores = {}
224
+ for k, v in proxy_row.items():
225
+ if v is None or k not in ref_stats:
226
+ continue
227
+ r = ref_stats[k]
228
+ iqr = max(r["p75"] - r["p25"], 1e-6)
229
+ d = PROXY_DIRECTION.get(k, 0)
230
+ dev = (v - r["p50"]) / iqr
231
+ if d == -1:
232
+ dev = max(0.0, dev) # only worse-than-GT (higher) penalized
233
+ elif d == 1:
234
+ dev = max(0.0, -dev)
235
+ else:
236
+ dev = abs(dev)
237
+ scores[k] = float(dev)
238
+ return scores
softchart/rhythm.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rhythmic-structure utilities: adaptive rational-grid snapping and
2
+ tuplet-evenness / grid-consistency metrics.
3
+
4
+ The model emits note times on an 11.6 ms frame grid; musically, notes live at
5
+ rational subdivisions of the beat (halves, triplets, quintuplets, ...). These
6
+ utilities (a) snap generated notes onto a locally-selected rational grid, and
7
+ (b) measure how "musically exact" a chart's timing is.
8
+ """
9
+
10
+ import numpy as np
11
+
12
+ # subdivisions considered per beat: binary, triple, quint, sept, and compounds
13
+ SUBDIVS = (1, 2, 3, 4, 5, 6, 7, 8, 12, 16)
14
+ COMPLEXITY_PENALTY = 0.002 # seconds of tolerated extra error per log2(subdiv)
15
+
16
+
17
+ def estimate_phase(times, beat, resolution=64):
18
+ """Grid phase (sec) minimizing distance of notes to a fine subdivision grid."""
19
+ times = np.asarray(times)
20
+ if len(times) == 0:
21
+ return 0.0
22
+ fine = beat / 24
23
+ cands = np.arange(0.0, beat, beat / resolution)
24
+ errs = []
25
+ for o in cands:
26
+ d = (times - o) / fine
27
+ errs.append(np.mean(np.abs(d - np.round(d))) * fine)
28
+ return float(cands[int(np.argmin(errs))])
29
+
30
+
31
+ def snap_times(times, bpm, phase=None, local_beats=16, conf_gate_ms=18.0):
32
+ """Snap each note to the best rational subdivision of its local beat.
33
+
34
+ Improvements over naive global snapping (validated by the closed loop):
35
+ - phase is re-estimated *locally* every `local_beats` beats, so tempo drift
36
+ and mid-song BPM changes do not accumulate into cross-beat unevenness;
37
+ - confidence gating: beats whose best-fit residual exceeds `conf_gate_ms`
38
+ are left unsnapped (better raw than snapped-to-the-wrong-grid).
39
+ Returns (snapped_times, info dict).
40
+ """
41
+ times = np.asarray(sorted(times), dtype=np.float64)
42
+ if len(times) == 0 or not bpm or bpm <= 0:
43
+ return times, {"phase": 0.0, "subdiv_hist": {}, "gated_frac": 0.0}
44
+ beat = 60.0 / bpm
45
+ if phase is None:
46
+ phase = estimate_phase(times, beat)
47
+
48
+ snapped = times.copy()
49
+ subdiv_hist = {}
50
+ n_gated = 0
51
+ seg_len = local_beats * beat
52
+ seg_idx = np.floor((times - phase) / seg_len).astype(int)
53
+ for s in np.unique(seg_idx):
54
+ seg_sel = seg_idx == s
55
+ seg_t = times[seg_sel]
56
+ # local phase refinement within this segment (bounded to +-1/8 beat)
57
+ local_phase = phase
58
+ if len(seg_t) >= 6:
59
+ offs = np.arange(-beat / 8, beat / 8, beat / 64)
60
+ fine = beat / 24
61
+ errs = [np.mean(np.abs(((seg_t - phase - o) / fine)
62
+ - np.round((seg_t - phase - o) / fine)) * fine)
63
+ for o in offs]
64
+ local_phase = phase + float(offs[int(np.argmin(errs))])
65
+
66
+ beat_idx = np.floor((seg_t - local_phase) / beat).astype(int)
67
+ seg_snapped = seg_t.copy()
68
+ for k in np.unique(beat_idx):
69
+ sel = beat_idx == k
70
+ t = seg_t[sel]
71
+ t0 = local_phase + k * beat
72
+ rel = (t - t0) / beat # in [0, 1)
73
+ best_d, best_err, best_raw = None, np.inf, np.inf
74
+ for d in SUBDIVS:
75
+ err = np.mean(np.abs(rel - np.round(rel * d) / d)) * beat
76
+ score = err + COMPLEXITY_PENALTY * np.log2(d)
77
+ if score < best_err:
78
+ best_err, best_d, best_raw = score, d, err
79
+ if best_raw * 1000 > conf_gate_ms: # low confidence: keep raw times
80
+ n_gated += int(sel.sum())
81
+ continue
82
+ seg_snapped[sel] = t0 + np.round(rel * best_d) / best_d * beat
83
+ subdiv_hist[int(best_d)] = subdiv_hist.get(int(best_d), 0) + int(sel.sum())
84
+ snapped[seg_sel] = seg_snapped
85
+ return snapped, {"phase": phase, "subdiv_hist": subdiv_hist,
86
+ "gated_frac": n_gated / len(times)}
87
+
88
+
89
+ def snap_chart(gen, bpm, min_gap=0.04):
90
+ """Apply rational snapping to a generate_song() output dict (hits only).
91
+
92
+ Post-snap min-gap collapse: snapping can push two notes into adjacent fine
93
+ slots (< playable gap); merge them instead of emitting unplayable pairs.
94
+ """
95
+ if not gen["hits"]:
96
+ return gen
97
+ times = [h["t"] for h in gen["hits"]]
98
+ snapped, info = snap_times(times, bpm)
99
+ out = dict(gen)
100
+ out["hits"] = []
101
+ last = -1.0
102
+ for h, t in zip(gen["hits"], snapped):
103
+ if t - last < min_gap: # collapse collisions created by snapping
104
+ continue
105
+ out["hits"].append({**h, "t": round(float(t), 4)})
106
+ last = t
107
+ out["snap_info"] = info
108
+ return out
109
+
110
+
111
+ def grid_consistency(times, bpm, tol=0.015):
112
+ """Fraction of notes within tol of the best rational grid + mean deviation.
113
+
114
+ Uses the same per-beat adaptive subdivision selection as snap_times.
115
+ """
116
+ times = np.asarray(sorted(times), dtype=np.float64)
117
+ if len(times) < 5 or not bpm or bpm <= 0:
118
+ return None
119
+ snapped, _ = snap_times(times, bpm)
120
+ dev = np.abs(snapped - times)
121
+ return {
122
+ "on_grid_frac": float(np.mean(dev <= tol)),
123
+ "mean_dev_ms": float(np.mean(dev) * 1000),
124
+ "p95_dev_ms": float(np.percentile(dev, 95) * 1000),
125
+ }
126
+
127
+
128
+ def tuplet_groups(times, bpm, min_len=3):
129
+ """Find runs of >=min_len consecutive IOIs that form one subdivision class.
130
+
131
+ Returns list of (subdiv_class, ioi_array). subdiv_class = round(beat/median_ioi),
132
+ e.g. 2=8ths, 3=8th-triplets, 4=16ths, 5=quintuplets, 6=16th-triplets, 7=septuplets.
133
+ """
134
+ times = np.asarray(sorted(times), dtype=np.float64)
135
+ if len(times) < min_len + 1 or not bpm or bpm <= 0:
136
+ return []
137
+ beat = 60.0 / bpm
138
+ ioi = np.diff(times)
139
+ groups = []
140
+ i = 0
141
+ while i < len(ioi):
142
+ j = i
143
+ while j + 1 < len(ioi) and 0.75 < ioi[j + 1] / max(ioi[i:j + 1].mean(), 1e-9) < 1.33:
144
+ j += 1
145
+ run = ioi[i : j + 1]
146
+ if len(run) >= min_len:
147
+ med = np.median(run)
148
+ if med < 0.95 * beat: # subdivision runs only
149
+ cls = int(round(beat / med))
150
+ if 2 <= cls <= 16:
151
+ groups.append((cls, run))
152
+ i = j + 1
153
+ return groups
154
+
155
+
156
+ def tuplet_evenness(times, bpm, min_len=3):
157
+ """Within-group spacing consistency. Returns per-class and overall stats.
158
+
159
+ CV = std(IOI)/mean(IOI) within a group (0 = perfectly even);
160
+ max_dev_ms = worst absolute deviation from the group's mean IOI.
161
+ """
162
+ groups = tuplet_groups(times, bpm, min_len)
163
+ if not groups:
164
+ return None
165
+ per_class = {}
166
+ cvs, maxdevs = [], []
167
+ for cls, run in groups:
168
+ cv = float(np.std(run) / np.mean(run))
169
+ md = float(np.max(np.abs(run - np.mean(run))) * 1000)
170
+ cvs.append(cv)
171
+ maxdevs.append(md)
172
+ d = per_class.setdefault(cls, {"n": 0, "cv": [], "max_dev_ms": []})
173
+ d["n"] += 1
174
+ d["cv"].append(cv)
175
+ d["max_dev_ms"].append(md)
176
+ return {
177
+ "n_groups": len(groups),
178
+ "cv_mean": float(np.mean(cvs)),
179
+ "max_dev_ms_mean": float(np.mean(maxdevs)),
180
+ "per_class": {
181
+ str(c): {"n": v["n"], "cv": float(np.mean(v["cv"])),
182
+ "max_dev_ms": float(np.mean(v["max_dev_ms"]))}
183
+ for c, v in sorted(per_class.items())
184
+ },
185
+ }
softchart/train.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training loop.
2
+
3
+ python -m softchart.train --cache /workspace/softchart/cache --out runs/base
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import os
10
+ import time
11
+
12
+ import torch
13
+ import torch.nn.functional as F
14
+ from torch.utils.data import DataLoader
15
+
16
+ from .data import ChartWindowDataset, collate, load_split_ids
17
+ from .model import ChartModel, count_params
18
+ from .vocab import VOCAB
19
+
20
+
21
+ # class-balanced weights for rare note types (~1/sqrt(train frequency), capped)
22
+ TYPE_WEIGHTS = {"don": 1.0, "ka": 1.23, "don_big": 3.19, "ka_big": 5.29,
23
+ "roll": 7.18, "roll_big": 8.0, "balloon": 8.0, "end": 5.0}
24
+
25
+
26
+ def build_class_weights(device, cap=8.0):
27
+ w = torch.ones(VOCAB.size, device=device)
28
+ for name, weight in TYPE_WEIGHTS.items():
29
+ w[VOCAB.note[name]] = min(weight, cap)
30
+ return w
31
+
32
+
33
+ def loss_fn(logits, tgt, loss_mask, label_smoothing=0.1, class_weight=None):
34
+ # logits: (B, L-1, V) predicting tgt[:, 1:]
35
+ gold = tgt[:, 1:]
36
+ mask = loss_mask[:, 1:]
37
+ ls = F.cross_entropy(
38
+ logits.reshape(-1, logits.shape[-1]),
39
+ gold.reshape(-1),
40
+ reduction="none",
41
+ label_smoothing=label_smoothing,
42
+ weight=class_weight,
43
+ ).reshape(gold.shape)
44
+ return (ls * mask).sum() / mask.sum().clamp(min=1)
45
+
46
+
47
+ def corrupt_events(tgt, loss_mask, rng_gen, hard=False):
48
+ """Synthetic negative for DPO. hard=True keeps corruptions NEAR the snap
49
+ tolerance (small jitter, few swaps) so negatives resemble the policy's own
50
+ mistakes rather than being trivially separable (fixes dpo_loss->0 collapse)."""
51
+ from .vocab import WINDOW
52
+
53
+ rej = tgt.clone()
54
+ ev = loss_mask.clone()
55
+ t0 = VOCAB.time0
56
+ is_time = (rej >= t0) & (rej < t0 + WINDOW) & ev
57
+ if hard: # +-1..3 frames (~12-35ms, straddles the snap grid) on 60% of notes
58
+ jit = torch.randint(-3, 4, rej.shape, device=rej.device, generator=rng_gen)
59
+ jit = torch.where(jit == 0, torch.ones_like(jit), jit)
60
+ do_t = torch.rand(rej.shape, device=rej.device, generator=rng_gen) < 0.6
61
+ jit = torch.where(do_t, jit, torch.zeros_like(jit))
62
+ swap_p = 0.12
63
+ else:
64
+ jit = torch.randint(-5, 6, rej.shape, device=rej.device, generator=rng_gen)
65
+ jit = torch.where(jit.abs() < 2, jit.sign() * 2, jit)
66
+ swap_p = 0.25
67
+ rej = torch.where(is_time, (rej + jit).clamp(t0, t0 + WINDOW - 1), rej)
68
+ hit0 = VOCAB.note["don"]
69
+ is_hit = (rej >= hit0) & (rej < hit0 + 4) & ev
70
+ swap = torch.randint(0, 4, rej.shape, device=rej.device, generator=rng_gen) + hit0
71
+ do = torch.rand(rej.shape, device=rej.device, generator=rng_gen) < swap_p
72
+ rej = torch.where(is_hit & do, swap, rej)
73
+ return rej
74
+
75
+
76
+ def seq_logprob(model, mel, tgt, mask):
77
+ import torch.nn.functional as F
78
+
79
+ logits = model(mel, tgt)
80
+ lp = F.log_softmax(logits.float(), dim=-1)
81
+ gold = tgt[:, 1:]
82
+ g = lp.gather(-1, gold.unsqueeze(-1)).squeeze(-1)
83
+ return (g * mask[:, 1:]).sum(1)
84
+
85
+
86
+ @torch.no_grad()
87
+ def validate(model, loader, device):
88
+ model.eval()
89
+ tot, n = 0.0, 0
90
+ for mel, tgt, mask, _aux, gsum, posb, _beat, _in in loader:
91
+ mel, tgt, mask = mel.to(device), tgt.to(device), mask.to(device)
92
+ gsum = gsum.to(device) if gsum is not None else None
93
+ with torch.autocast("cuda", dtype=torch.bfloat16):
94
+ logits = model(mel, tgt, gsum=gsum, pos_bucket=posb.to(device))
95
+ l = loss_fn(logits, tgt, mask, label_smoothing=0.0)
96
+ tot += l.item() * mel.shape[0]
97
+ n += mel.shape[0]
98
+ model.train()
99
+ return tot / max(n, 1)
100
+
101
+
102
+ def main():
103
+ ap = argparse.ArgumentParser()
104
+ ap.add_argument("--cache", required=True)
105
+ ap.add_argument("--out", required=True)
106
+ ap.add_argument("--steps", type=int, default=60000)
107
+ ap.add_argument("--batch", type=int, default=32)
108
+ ap.add_argument("--lr", type=float, default=3e-4)
109
+ ap.add_argument("--warmup", type=int, default=1000)
110
+ ap.add_argument("--cond-drop", type=float, default=0.15)
111
+ ap.add_argument("--no-augment", action="store_true")
112
+ ap.add_argument("--balanced", action="store_true", help="class-balanced type loss")
113
+ ap.add_argument("--balance-cap", type=float, default=8.0,
114
+ help="cap for class weights (v3: 2.5 after v2 over-corrected)")
115
+ ap.add_argument("--ctx", action="store_true", help="prev-window context tokens")
116
+ ap.add_argument("--aux", action="store_true", help="onset-heatmap auxiliary head")
117
+ ap.add_argument("--global-ctx", action="store_true",
118
+ help="whole-song summary + window-position context (v4)")
119
+ ap.add_argument("--importance-sampling", action="store_true",
120
+ help="bias window sampling toward informative regions (v4)")
121
+ ap.add_argument("--d-model", type=int, default=512)
122
+ ap.add_argument("--enc-layers", type=int, default=6)
123
+ ap.add_argument("--dec-layers", type=int, default=6)
124
+ ap.add_argument("--ffn", type=int, default=2048)
125
+ ap.add_argument("--func-time", action="store_true",
126
+ help="functional (sinusoidal-basis) TIME token embeddings")
127
+ ap.add_argument("--distill", default=None,
128
+ help="teacher checkpoint for knowledge distillation")
129
+ ap.add_argument("--use-aug", action="store_true",
130
+ help="include waveform speed-augmented variants in training")
131
+ ap.add_argument("--init-from", default=None,
132
+ help="initialize matching weights (decoder/tok_emb) from a checkpoint")
133
+ ap.add_argument("--kd-alpha", type=float, default=0.5, help="CE weight (rest = KL)")
134
+ ap.add_argument("--kd-tau", type=float, default=2.0, help="distillation temperature")
135
+ ap.add_argument("--tempo-aug", action="store_true",
136
+ help="rhythm-preserving tempo/pitch augmentation in mel domain")
137
+ ap.add_argument("--sibling", action="store_true",
138
+ help="easier-course skeleton hint in the prefix (easy subset-of hard)")
139
+ ap.add_argument("--style", action="store_true",
140
+ help="charting-intent style token (cache/styles.json)")
141
+ ap.add_argument("--align", action="store_true",
142
+ help="pointer alignment loss: notes must attend to their onset frame")
143
+ ap.add_argument("--beat-head", action="store_true",
144
+ help="beat/downbeat auxiliary head (labels from TJA measures)")
145
+ ap.add_argument("--beat-weight", type=float, default=0.1,
146
+ help="beat-head loss weight (raise for a dedicated beat model)")
147
+ ap.add_argument("--sync-token", action="store_true",
148
+ help="LHL syncopation-band condition token")
149
+ ap.add_argument("--plan", action="store_true",
150
+ help="song-level plan-block conditioning (cache/plans.json)")
151
+ ap.add_argument("--mask-infill", type=float, default=0.0,
152
+ help="probability of type-mask (skeleton->color) curriculum per window")
153
+ ap.add_argument("--complexity", action="store_true",
154
+ help="rhythmic-complexity band token (density-independent difficulty)")
155
+ ap.add_argument("--dpo", default=None,
156
+ help="reference checkpoint: switch to DPO finetuning with synthetic negatives")
157
+ ap.add_argument("--dpo-beta", type=float, default=0.1)
158
+ ap.add_argument("--dpo-hard", action="store_true",
159
+ help="near-tolerance negatives (resemble policy errors)")
160
+ ap.add_argument("--val-every", type=int, default=2000)
161
+ ap.add_argument("--workers", type=int, default=12)
162
+ ap.add_argument("--resume", default=None)
163
+ ap.add_argument("--seed", type=int, default=1234)
164
+ args = ap.parse_args()
165
+ torch.manual_seed(args.seed)
166
+
167
+ os.makedirs(args.out, exist_ok=True)
168
+ device = "cuda"
169
+ torch.backends.cuda.matmul.allow_tf32 = True
170
+ torch.backends.cudnn.allow_tf32 = True
171
+
172
+ train_ids, val_ids, _ = load_split_ids(args.cache, use_aug=args.use_aug)
173
+ train_ds = ChartWindowDataset(
174
+ args.cache, train_ids, train=True, cond_drop=args.cond_drop,
175
+ spec_augment=not args.no_augment, use_ctx=args.ctx, aux=args.aux,
176
+ global_ctx=args.global_ctx, importance_sampling=args.importance_sampling,
177
+ tempo_aug=args.tempo_aug, sibling=args.sibling, style=args.style,
178
+ beat_head=args.beat_head, sync_token=args.sync_token,
179
+ plan=args.plan, mask_infill=args.mask_infill, complexity=args.complexity,
180
+ )
181
+ val_ds = ChartWindowDataset(args.cache, val_ids, train=False, windows_per_chart=1,
182
+ use_ctx=args.ctx, aux=args.aux, global_ctx=args.global_ctx,
183
+ sibling=args.sibling, style=args.style,
184
+ beat_head=args.beat_head, sync_token=args.sync_token,
185
+ plan=args.plan, complexity=args.complexity)
186
+ print(f"train charts {len(train_ds.items)}, val charts {len(val_ds.items)}")
187
+
188
+ train_loader = DataLoader(
189
+ train_ds, batch_size=args.batch, shuffle=True, collate_fn=collate,
190
+ num_workers=args.workers, pin_memory=True, drop_last=True, persistent_workers=True,
191
+ )
192
+ val_loader = DataLoader(
193
+ val_ds, batch_size=args.batch, shuffle=False, collate_fn=collate, num_workers=4,
194
+ )
195
+
196
+ model = ChartModel(
197
+ d_model=args.d_model, enc_layers=args.enc_layers, dec_layers=args.dec_layers,
198
+ ffn=args.ffn, aux=args.aux, global_ctx=args.global_ctx,
199
+ func_time=args.func_time,
200
+ ).to(device)
201
+ if args.align:
202
+ model.enable_ptr()
203
+ if args.beat_head:
204
+ model.enable_beat()
205
+ model.to(device)
206
+ print(f"params: {count_params(model)/1e6:.1f}M, vocab {VOCAB.size}")
207
+ if args.init_from and os.path.exists(args.init_from):
208
+ src = torch.load(args.init_from, map_location=device)["model"]
209
+ own = model.state_dict()
210
+ hit = {k: v for k, v in src.items()
211
+ if k in own and own[k].shape == v.shape
212
+ and k.startswith(("decoder", "tok_emb", "out", "dec_pos"))}
213
+ own.update(hit)
214
+ model.load_state_dict(own)
215
+ print(f"initialized {len(hit)} tensors from {args.init_from}")
216
+ class_weight = build_class_weights(device, args.balance_cap) if args.balanced else None
217
+
218
+ teacher = None
219
+ if args.distill:
220
+ from .generate import load_model
221
+
222
+ teacher = load_model(args.distill, device=device)
223
+ for p in teacher.parameters():
224
+ p.requires_grad_(False)
225
+ t_vocab = teacher.tok_emb.weight.shape[0]
226
+ print(f"distilling from {args.distill} (teacher vocab {t_vocab})")
227
+ dpo_ref = None
228
+ if args.dpo:
229
+ from .generate import load_model as _lm
230
+
231
+ dpo_ref = _lm(args.dpo, device=device)
232
+ for p_ in dpo_ref.parameters():
233
+ p_.requires_grad_(False)
234
+ # initialize the policy from the reference (vocab may have grown since:
235
+ # copy overlapping embedding rows, everything else matches the 12M arch)
236
+ ref_sd = dpo_ref.state_dict()
237
+ own = model.state_dict()
238
+ for k, v in ref_sd.items():
239
+ if k in own:
240
+ if own[k].shape == v.shape:
241
+ own[k] = v
242
+ elif k == "tok_emb.weight":
243
+ own[k][: v.shape[0]] = v
244
+ model.load_state_dict(own)
245
+ # disable dropout for DPO: logprob-margin noise from dropout on long
246
+ # sequences swamps the beta-scaled objective (verified: init loss 3.3
247
+ # instead of log 2)
248
+ import torch.nn as _nn
249
+
250
+ for m_ in model.modules():
251
+ if isinstance(m_, _nn.Dropout):
252
+ m_.p = 0.0
253
+ print(f"DPO: policy initialized from {args.dpo}, beta={args.dpo_beta}, dropout off")
254
+
255
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.01, betas=(0.9, 0.98))
256
+
257
+ def lr_at(step):
258
+ if step < args.warmup:
259
+ return args.lr * step / args.warmup
260
+ p = (step - args.warmup) / max(1, args.steps - args.warmup)
261
+ return args.lr * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * p)))
262
+
263
+ step = 0
264
+ best_val = float("inf")
265
+ if args.resume and os.path.exists(args.resume):
266
+ ck = torch.load(args.resume, map_location=device)
267
+ model.load_state_dict(ck["model"])
268
+ opt.load_state_dict(ck["opt"])
269
+ step = ck["step"]
270
+ best_val = ck.get("best_val", best_val)
271
+ print(f"resumed from {args.resume} at step {step}")
272
+
273
+ log_path = os.path.join(args.out, "train_log.jsonl")
274
+ model.train()
275
+ t0 = time.time()
276
+ run_loss, run_n = 0.0, 0
277
+ done = False
278
+ while not done:
279
+ for mel, tgt, mask, aux_t, gsum, posb, beat_t, in_tgt in train_loader:
280
+ step += 1
281
+ if step > args.steps:
282
+ done = True
283
+ break
284
+ for g in opt.param_groups:
285
+ g["lr"] = lr_at(step)
286
+ mel, tgt, mask = mel.to(device, non_blocking=True), tgt.to(device), mask.to(device)
287
+ gsum = gsum.to(device) if gsum is not None else None
288
+ posb = posb.to(device)
289
+ in_tgt = in_tgt.to(device)
290
+ if dpo_ref is not None:
291
+ gen_rng = torch.Generator(device=device)
292
+ gen_rng.manual_seed(args.seed * 100003 + step)
293
+ rej = corrupt_events(tgt, mask, gen_rng, hard=args.dpo_hard)
294
+ with torch.autocast("cuda", dtype=torch.bfloat16):
295
+ pc = seq_logprob(model, mel, tgt, mask)
296
+ pr = seq_logprob(model, mel, rej, mask)
297
+ with torch.no_grad():
298
+ rc = seq_logprob(dpo_ref, mel, tgt, mask)
299
+ rr = seq_logprob(dpo_ref, mel, rej, mask)
300
+ loss = -F.logsigmoid(
301
+ args.dpo_beta * ((pc - pr) - (rc - rr))).mean()
302
+ opt.zero_grad(set_to_none=True)
303
+ loss.backward()
304
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
305
+ opt.step()
306
+ run_loss += loss.item()
307
+ run_n += 1
308
+ if step % 100 == 0:
309
+ msg = {"step": step, "dpo_loss": round(run_loss / run_n, 4)}
310
+ print(json.dumps(msg), flush=True)
311
+ with open(log_path, "a") as f:
312
+ f.write(json.dumps(msg) + "\n")
313
+ run_loss, run_n = 0.0, 0
314
+ if step % args.val_every == 0 or step == args.steps:
315
+ ck = {"model": model.state_dict(), "opt": opt.state_dict(),
316
+ "step": step, "best_val": 0.0, "args": vars(args)}
317
+ torch.save(ck, os.path.join(args.out, "last.pt"))
318
+ torch.save(ck, os.path.join(args.out, "best.pt"))
319
+ continue
320
+ with torch.autocast("cuda", dtype=torch.bfloat16):
321
+ if args.align or args.beat_head:
322
+ logits, aux_logits, ptr_logits, beat_logits = model(
323
+ mel, tgt, return_aux=True, return_extras=True,
324
+ gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
325
+ elif args.aux and aux_t is not None:
326
+ logits, aux_logits = model(mel, tgt, return_aux=True,
327
+ gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
328
+ ptr_logits = beat_logits = None
329
+ else:
330
+ logits = model(mel, tgt, gsum=gsum, pos_bucket=posb, in_tgt=in_tgt)
331
+ aux_logits = ptr_logits = beat_logits = None
332
+ aux_loss = 0.0
333
+ if args.aux and aux_t is not None and aux_logits is not None:
334
+ aux_loss = F.binary_cross_entropy_with_logits(
335
+ aux_logits.float(), aux_t.to(device))
336
+ extra = 0.0
337
+ if ptr_logits is not None:
338
+ # pointer alignment: positions whose GOLD token is TIME must
339
+ # point at that frame's encoder position
340
+ gold = tgt[:, 1:]
341
+ is_t = (gold >= VOCAB.time0) & (gold < VOCAB.time0 + 1728) & mask[:, 1:]
342
+ if is_t.any():
343
+ target_pos = ((gold - VOCAB.time0) // 4).clamp(0, ptr_logits.shape[-1] - 1)
344
+ pl = F.cross_entropy(
345
+ ptr_logits.reshape(-1, ptr_logits.shape[-1]).float(),
346
+ target_pos.reshape(-1), reduction="none",
347
+ ).reshape(gold.shape)
348
+ extra = extra + 0.2 * (pl * is_t).sum() / is_t.sum()
349
+ if beat_logits is not None and beat_t is not None:
350
+ extra = extra + args.beat_weight * F.binary_cross_entropy_with_logits(
351
+ beat_logits.float(), beat_t.to(device))
352
+ loss = loss_fn(logits, tgt, mask, class_weight=class_weight) + 0.1 * aux_loss + extra
353
+ if teacher is not None:
354
+ with torch.no_grad():
355
+ # teacher may have a smaller vocab (no SIB/STYLE tokens):
356
+ # clamp unseen ids to UNK and distill over the shared slice
357
+ t_tgt = torch.where(tgt >= t_vocab,
358
+ torch.full_like(tgt, VOCAB.unk_cond), tgt)
359
+ t_logits = teacher(mel, t_tgt)
360
+ V = min(t_logits.shape[-1], logits.shape[-1])
361
+ tau = args.kd_tau
362
+ kl = F.kl_div(
363
+ F.log_softmax(logits[..., :V].float() / tau, dim=-1),
364
+ F.softmax(t_logits[..., :V].float() / tau, dim=-1),
365
+ reduction="none",
366
+ ).sum(-1)
367
+ kl = (kl * mask[:, 1:]).sum() / mask[:, 1:].sum().clamp(min=1)
368
+ loss = args.kd_alpha * loss + (1 - args.kd_alpha) * (tau * tau) * kl
369
+ opt.zero_grad(set_to_none=True)
370
+ loss.backward()
371
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
372
+ opt.step()
373
+ run_loss += loss.item()
374
+ run_n += 1
375
+
376
+ if step % 100 == 0:
377
+ msg = {
378
+ "step": step,
379
+ "loss": round(run_loss / run_n, 4),
380
+ "lr": round(lr_at(step), 6),
381
+ "sec": round(time.time() - t0, 1),
382
+ }
383
+ print(json.dumps(msg), flush=True)
384
+ with open(log_path, "a") as f:
385
+ f.write(json.dumps(msg) + "\n")
386
+ run_loss, run_n = 0.0, 0
387
+
388
+ if step % args.val_every == 0 or step == args.steps:
389
+ vl = validate(model, val_loader, device)
390
+ msg = {"step": step, "val_loss": round(vl, 4)}
391
+ print(json.dumps(msg), flush=True)
392
+ with open(log_path, "a") as f:
393
+ f.write(json.dumps(msg) + "\n")
394
+ ck = {"model": model.state_dict(), "opt": opt.state_dict(),
395
+ "step": step, "best_val": best_val, "args": vars(args)}
396
+ torch.save(ck, os.path.join(args.out, "last.pt"))
397
+ if vl < best_val:
398
+ best_val = vl
399
+ ck["best_val"] = best_val
400
+ torch.save(ck, os.path.join(args.out, "best.pt"))
401
+
402
+ print(f"done. best_val={best_val:.4f}")
403
+
404
+
405
+ if __name__ == "__main__":
406
+ main()
softchart/vocab.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 768 # max decoder length (prefix + events + eos)
18
+
19
+ COURSES = ["easy", "normal", "hard", "oni", "ura"]
20
+ N_LEVELS = 12 # levels clamped to 1..12
21
+ N_DENS = 16
22
+ DENS_BUCKET_NPS = 0.75 # bucket width in notes/sec
23
+
24
+ # canonical note classes; parsed TJA note_type strings are mapped onto these
25
+ NOTE_CLASSES = ["don", "ka", "don_big", "ka_big", "roll", "roll_big", "balloon", "end"]
26
+
27
+ # mapping from strings observed in the dataset -> canonical class
28
+ # (observed in taiko-1000-parsed: Don, Ka, DonBig, KaBig, Roll, RollBig,
29
+ # Balloon, BalloonAlt, EndOf)
30
+ NOTE_TYPE_MAP = {
31
+ "Don": "don",
32
+ "Ka": "ka",
33
+ "DonBig": "don_big",
34
+ "KaBig": "ka_big",
35
+ "Roll": "roll",
36
+ "RollBig": "roll_big",
37
+ "Balloon": "balloon",
38
+ "BalloonAlt": "balloon", # kusudama, treated as balloon-class span
39
+ "EndOf": "end",
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class Vocab:
45
+ pad: int = 0
46
+ eos: int = 1
47
+ bos: int = 2
48
+ unk_cond: int = 3
49
+
50
+ def __post_init__(self):
51
+ base = 4
52
+ self.course = {c: base + i for i, c in enumerate(COURSES)}
53
+ base += len(COURSES)
54
+ self.level = {l: base + l - 1 for l in range(1, N_LEVELS + 1)}
55
+ base += N_LEVELS
56
+ self.dens = {d: base + d for d in range(N_DENS)}
57
+ base += N_DENS
58
+ self.note = {n: base + i for i, n in enumerate(NOTE_CLASSES)}
59
+ base += len(NOTE_CLASSES)
60
+ self.time0 = base
61
+ base += WINDOW
62
+ # v2+ tokens appended at the end so older checkpoints stay loadable
63
+ self.sep = base # separates prev-window context from conditions
64
+ self.sib = base + 1 # marks the sibling-chart (easier course) segment
65
+ self.style = {s: base + 2 + s for s in range(8)} # charting-intent codes
66
+ self.sync = {s: base + 10 + s for s in range(6)} # LHL syncopation bands
67
+ # plan-realize tokens: [PLAN] marker + block density (8) + block flags (3)
68
+ self.plan = base + 16
69
+ self.pdens = {d: base + 17 + d for d in range(8)}
70
+ self.pflag = {f: base + 25 + f for f in range(3)} # 0 none / 1 gap / 2 climax
71
+ self.mask = base + 28 # type-infill placeholder
72
+ self.cplx = {c: base + 29 + c for c in range(6)} # rhythmic-complexity band
73
+ self.size = base + 35
74
+
75
+ self.id2note = {v: k for k, v in self.note.items()}
76
+ self.id2course = {v: k for k, v in self.course.items()}
77
+
78
+ def time(self, frame: int) -> int:
79
+ assert 0 <= frame < WINDOW
80
+ return self.time0 + frame
81
+
82
+ def is_time(self, tok: int) -> bool:
83
+ return self.time0 <= tok < self.time0 + WINDOW
84
+
85
+ def is_note(self, tok: int) -> bool:
86
+ return tok in self.id2note
87
+
88
+ def dens_bucket(self, nps: float) -> int:
89
+ return min(N_DENS - 1, max(0, int(nps / DENS_BUCKET_NPS)))
90
+
91
+
92
+ VOCAB = Vocab()
93
+
94
+
95
+ SIB_EVENTS = 12 # fixed number of sibling-chart events in the prefix (24 tokens)
96
+
97
+
98
+ PLAN_SLOTS = 5 # fixed number of plan blocks in the prefix (2 tokens each)
99
+
100
+
101
+ def complexity_band(frames):
102
+ """IOI-class entropy of a window's hit sequence -> band 0-5 (rhythmic
103
+ complexity independent of raw density)."""
104
+ import math
105
+ hits = sorted(f for f, c in frames if NOTE_CLASSES[c] in ("don","ka","don_big","ka_big"))
106
+ if len(hits) < 4:
107
+ return 0
108
+ iois = [b - a for a, b in zip(hits, hits[1:]) if b > a]
109
+ if not iois:
110
+ return 0
111
+ from collections import Counter
112
+ cls = Counter(int(round(math.log2(max(i,1)) * 2)) for i in iois)
113
+ tot = sum(cls.values())
114
+ ent = -sum((n/tot) * math.log2(n/tot) for n in cls.values())
115
+ return min(5, int(ent / 0.5))
116
+
117
+
118
+ def encode_window(vocab, course, level, notes, cond_drop=0.0, rng=None, ctx_types=None,
119
+ sib_pairs=None, style=None, sync_band=None, plan_slice=None,
120
+ complexity=None):
121
+ """Build a token sequence for one window.
122
+
123
+ notes: list of (frame_idx, note_class_id) sorted by frame, frame in [0, WINDOW).
124
+ ctx_types: optional list of note-class ids from the previous window's tail
125
+ (pattern continuity context, v2). Encoded as [BOS] ctx.. [SEP] conds..
126
+ sib_pairs: optional list of (frame, class_id) events from an easier course of
127
+ the same song (skeleton hint, easy⊂hard). Fixed SIB_EVENTS slots,
128
+ missing slots filled with UNK. Encoded after [SIB].
129
+ Returns (tokens, prefix_len) where loss should be applied after the prefix.
130
+ """
131
+ n_hits = sum(1 for _, c in notes if NOTE_CLASSES[c] not in ("end",))
132
+ nps = n_hits / (WINDOW / FPS)
133
+ d = vocab.dens_bucket(nps)
134
+
135
+ def maybe(tok):
136
+ if cond_drop > 0 and rng is not None and rng.random() < cond_drop:
137
+ return vocab.unk_cond
138
+ return tok
139
+
140
+ lvl = max(1, min(N_LEVELS, level if level and level > 0 else 1))
141
+ seq = [vocab.bos]
142
+ if ctx_types is not None: # fixed-length, left-padded with UNK (id < 0 = pad)
143
+ seq += [vocab.note[NOTE_CLASSES[c]] if c >= 0 else vocab.unk_cond
144
+ for c in ctx_types]
145
+ seq.append(vocab.sep)
146
+ if sib_pairs is not None:
147
+ seq.append(vocab.sib)
148
+ pairs = list(sib_pairs)[:SIB_EVENTS]
149
+ for f, c in pairs:
150
+ seq += [vocab.time(int(f)), vocab.note[NOTE_CLASSES[c]]]
151
+ seq += [vocab.unk_cond] * (2 * (SIB_EVENTS - len(pairs)))
152
+ seq += [
153
+ maybe(vocab.course[course]),
154
+ maybe(vocab.level[lvl]),
155
+ maybe(vocab.dens[d]),
156
+ ]
157
+ if style is not None: # charting-intent code (style >= 0; -1 = unknown)
158
+ seq.append(maybe(vocab.style[style]) if style >= 0 else vocab.unk_cond)
159
+ if sync_band is not None: # LHL syncopation band (groove-intensity control)
160
+ seq.append(maybe(vocab.sync[sync_band]) if sync_band >= 0 else vocab.unk_cond)
161
+ if complexity is not None: # rhythmic-complexity band (difficulty beyond density)
162
+ seq.append(maybe(vocab.cplx[complexity]) if complexity >= 0 else vocab.unk_cond)
163
+ if plan_slice is not None: # song-level plan blocks overlapping this window
164
+ seq.append(vocab.plan)
165
+ blocks = list(plan_slice)[:PLAN_SLOTS]
166
+ for d8, fl in blocks:
167
+ seq += [maybe(vocab.pdens[min(7, max(0, d8))]),
168
+ maybe(vocab.pflag[min(2, max(0, fl))])]
169
+ seq += [vocab.unk_cond] * (2 * (PLAN_SLOTS - len(blocks)))
170
+ prefix_len = len(seq)
171
+ for f, c in notes:
172
+ seq.append(vocab.time(int(f)))
173
+ seq.append(vocab.note[NOTE_CLASSES[c]])
174
+ seq.append(vocab.eos)
175
+ return seq, prefix_len
176
+
177
+
178
+ def decode_tokens(vocab, tokens):
179
+ """Token ids -> list of (frame_idx, note_class_name). Ignores malformed pairs."""
180
+ out = []
181
+ cur_t = None
182
+ for tok in tokens:
183
+ if vocab.is_time(tok):
184
+ cur_t = tok - vocab.time0
185
+ elif tok in vocab.id2note and cur_t is not None:
186
+ out.append((cur_t, vocab.id2note[tok]))
187
+ elif tok == vocab.eos:
188
+ break
189
+ return out