Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- LICENSE +21 -0
- README.md +27 -7
- app.py +304 -0
- requirements.txt +10 -0
- softchart/__init__.py +0 -0
- softchart/baseline.py +43 -0
- softchart/data.py +350 -0
- softchart/evaluate.py +151 -0
- softchart/generate.py +404 -0
- softchart/hf.py +91 -0
- softchart/model.py +173 -0
- softchart/preprocess.py +163 -0
- softchart/proxies.py +238 -0
- softchart/rhythm.py +185 -0
- softchart/train.py +406 -0
- softchart/vocab.py +189 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|