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