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