Spaces:
Sleeping
Sleeping
Add TJA chart image preview
Browse files- README.md +6 -2
- app.py +51 -63
- requirements.txt +1 -0
- softchart/tja_image.py +285 -0
README.md
CHANGED
|
@@ -25,7 +25,11 @@ from the Hugging Face Hub via `from_pretrained`.
|
|
| 25 |
- **SoftChart v15** ([softchart-v15](https://huggingface.co/JacobLinCool/softchart-v15)) — unified slot + time + beat model; slot mode emits exact TJA lattice indices when the beat grid is trusted
|
| 26 |
- **Planner** ([softchart-planner](https://huggingface.co/JacobLinCool/softchart-planner)) — audio → song-level plan (density envelope, breathing gaps, climax)
|
| 27 |
|
| 28 |
-
Upload a song, pick a difficulty, and get a
|
| 29 |
-
TJAPlayer3 / OpenTaiko.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
> Code and weights are **MIT-licensed**.
|
|
|
|
| 25 |
- **SoftChart v15** ([softchart-v15](https://huggingface.co/JacobLinCool/softchart-v15)) — unified slot + time + beat model; slot mode emits exact TJA lattice indices when the beat grid is trusted
|
| 26 |
- **Planner** ([softchart-planner](https://huggingface.co/JacobLinCool/softchart-planner)) — audio → song-level plan (density envelope, breathing gaps, climax)
|
| 27 |
|
| 28 |
+
Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
|
| 29 |
+
full TJA chart image, and a `.tja` playable in TJAPlayer3 / OpenTaiko. The
|
| 30 |
+
generated TJA keeps the uploaded audio filename in its `WAVE` field.
|
| 31 |
+
The chart image uses a server-side renderer modeled on the row layout used by
|
| 32 |
+
MIT-licensed [tja-tools](https://github.com/WHMHammer/tja-tools), without
|
| 33 |
+
shipping its browser/webpack runtime.
|
| 34 |
|
| 35 |
> Code and weights are **MIT-licensed**.
|
app.py
CHANGED
|
@@ -8,6 +8,7 @@ All models load from the Hub via from_pretrained. MIT licensed.
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import os
|
|
|
|
| 11 |
import tempfile
|
| 12 |
|
| 13 |
import gradio as gr
|
|
@@ -19,6 +20,7 @@ from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewis
|
|
| 19 |
from softchart.hf import SoftChartPlanner
|
| 20 |
from softchart.rhythm import snap_chart
|
| 21 |
from softchart.tja import write_tja_slots
|
|
|
|
| 22 |
from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
|
| 23 |
|
| 24 |
V15_REPO = os.environ.get("SC_V15", "JacobLinCool/softchart-v15")
|
|
@@ -26,12 +28,8 @@ PLAN_REPO = os.environ.get("SC_PLAN", "JacobLinCool/softchart-planner")
|
|
| 26 |
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 27 |
|
| 28 |
COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
|
| 29 |
-
DEFAULT_LEVEL = {"easy": 3, "normal": 5, "hard": 7, "oni": 9}
|
| 30 |
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 31 |
"roll": "5", "roll_big": "6", "balloon": "7"}
|
| 32 |
-
STYLE = {"don": ("#e8453c", 55), "ka": ("#4aa8c0", 55),
|
| 33 |
-
"don_big": ("#e8453c", 150), "ka_big": ("#4aa8c0", 150),
|
| 34 |
-
"roll": ("#e8b23a", 80), "roll_big": ("#e8b23a", 150), "balloon": ("#d47fda", 100)}
|
| 35 |
SUB = 96
|
| 36 |
|
| 37 |
_MODELS = {}
|
|
@@ -133,7 +131,19 @@ def group_quantize(times, phase, grid, min_run=3):
|
|
| 133 |
return slots
|
| 134 |
|
| 135 |
|
| 136 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
|
| 138 |
beat = 60.0 / bpm
|
| 139 |
grid = beat / (SUB / 4)
|
|
@@ -188,69 +198,44 @@ def write_tja(gen, bpm, title, course, level, downbeats=None, grid_fit=None):
|
|
| 188 |
lines = ["".join(slots.get(m * SUB + k, "0") for k in range(SUB)) + "," for m in range(n_meas)]
|
| 189 |
balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
|
| 190 |
return "\n".join([
|
| 191 |
-
f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", "WAVE:
|
| 192 |
f"OFFSET:{-phase:.3f}", f"COURSE:{'Oni' if course == 'oni' else course.capitalize()}",
|
| 193 |
f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
| 194 |
"", "#START", *lines, "#END"]) + "\n"
|
| 195 |
|
| 196 |
|
| 197 |
-
def
|
| 198 |
import matplotlib
|
| 199 |
matplotlib.use("Agg")
|
| 200 |
import matplotlib.pyplot as plt
|
| 201 |
|
| 202 |
dur = mel.shape[1] / FPS
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
t0, t1 = f0 / FPS, f1 / FPS
|
| 208 |
-
fig = plt.figure(figsize=(13, 5.2 if plan else 4))
|
| 209 |
-
gs = fig.add_gridspec(3 if plan else 2, 1,
|
| 210 |
-
height_ratios=[2.3, 1, 0.8] if plan else [2.3, 1],
|
| 211 |
-
hspace=0.16 if plan else 0.1)
|
| 212 |
ax0 = fig.add_subplot(gs[0])
|
| 213 |
-
ax0.imshow(mel
|
| 214 |
-
cmap="magma", extent=[
|
| 215 |
-
ax0.set_xticks([])
|
| 216 |
ax0.set_ylabel("mel")
|
| 217 |
-
ax0.set_title(f"{title} — {course} |
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
for a, b, d, f in plan:
|
| 221 |
-
if b < t0 or a > t1:
|
| 222 |
-
continue
|
| 223 |
-
c, al = ("#4a90d9", 0.16) if f == 1 else (
|
| 224 |
-
("#d64545", 0.16) if f == 2 else ("#000000", 0.03 + d / 7 * 0.09))
|
| 225 |
-
ax1.axvspan(max(a, t0), min(b, t1), color=c, alpha=al, lw=0)
|
| 226 |
-
for h in gen["hits"]:
|
| 227 |
-
if t0 <= h["t"] <= t1:
|
| 228 |
-
c, sz = STYLE.get(h["type"], ("#999", 40))
|
| 229 |
-
ax1.scatter(h["t"], 0, c=c, s=sz, edgecolors="k",
|
| 230 |
-
linewidths=1.2 if "big" in h["type"] else 0.5, zorder=3)
|
| 231 |
-
for s in gen["spans"]:
|
| 232 |
-
if s["t1"] >= t0 and s["t0"] <= t1:
|
| 233 |
-
ax1.plot([max(s["t0"], t0), min(s["t1"], t1)], [0, 0],
|
| 234 |
-
c=STYLE.get(s["type"], ("#e8b23a",))[0], lw=6, alpha=0.45, zorder=2)
|
| 235 |
-
ax1.set_xlim(t0, t1)
|
| 236 |
-
ax1.set_ylim(-1, 1)
|
| 237 |
-
ax1.set_yticks([])
|
| 238 |
-
ax1.set_xlabel("" if plan else "time (s)")
|
| 239 |
-
ax1.grid(axis="x", alpha=0.25)
|
| 240 |
if plan:
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
ax2 = fig.add_subplot(gs[2])
|
| 244 |
for a, b, d, f in plan:
|
| 245 |
c = "#d64545" if f == 2 else ("#4a90d9" if f == 1 else "#999999")
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
| 254 |
out = tempfile.mktemp(suffix=".png")
|
| 255 |
fig.savefig(out, dpi=130, bbox_inches="tight")
|
| 256 |
plt.close(fig)
|
|
@@ -316,7 +301,8 @@ def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_pla
|
|
| 316 |
elif auto_plan_on:
|
| 317 |
plan = auto_plan(mel, bpm, dbs)
|
| 318 |
|
| 319 |
-
|
|
|
|
| 320 |
slot_used = False
|
| 321 |
if grid is not None and M["slot"] is not None:
|
| 322 |
# slot-exact path: the decoder emits TJA lattice indices directly —
|
|
@@ -327,7 +313,7 @@ def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_pla
|
|
| 327 |
device=DEVICE, plan=plan,
|
| 328 |
on_progress=lambda d, t: P(0.32 + 0.58 * d / max(t, 1),
|
| 329 |
f"Generating notes (slot-exact)… {d}/{t} windows"))
|
| 330 |
-
tja = write_tja_slots(g, grid, title, course, int(level),
|
| 331 |
slot_used = True
|
| 332 |
else:
|
| 333 |
g = generate_song(
|
|
@@ -337,13 +323,14 @@ def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_pla
|
|
| 337 |
on_progress=lambda d, t: P(0.32 + 0.58 * d / max(t, 1),
|
| 338 |
f"Generating notes… {d}/{t} windows"))
|
| 339 |
g = snap_chart(g, bpm)
|
| 340 |
-
tja = write_tja(g, bpm, title, course, int(level), dbs, grid_fit=grid)
|
| 341 |
P(0.92, "Writing TJA…")
|
| 342 |
-
tja_path =
|
| 343 |
-
with open(tja_path, "w") as f:
|
| 344 |
f.write(tja)
|
| 345 |
P(0.95, "Rendering preview…")
|
| 346 |
-
|
|
|
|
| 347 |
if grid is not None:
|
| 348 |
grid_info = (f" · grid: rms {grid['rms_ms']:.1f}ms ({grid['inlier_frac']:.0%} inlier)"
|
| 349 |
+ (f" · {grid['n_segments']} tempo segs" if grid.get("piecewise") else "")
|
|
@@ -354,7 +341,7 @@ def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_pla
|
|
| 354 |
grid_info = ""
|
| 355 |
info = (f"BPM {bpm:.1f} · {len(g['hits'])} notes · {len(g['spans'])} spans" + grid_info
|
| 356 |
+ (f" · plan: {sum(1 for b in plan if b[3]==1)} gaps, {sum(1 for b in plan if b[3]==2)} climax" if plan else ""))
|
| 357 |
-
return
|
| 358 |
|
| 359 |
|
| 360 |
with gr.Blocks(title="SoftChart — AI Taiko chart generator") as demo:
|
|
@@ -362,7 +349,7 @@ with gr.Blocks(title="SoftChart — AI Taiko chart generator") as demo:
|
|
| 362 |
"# 🥁 SoftChart\n"
|
| 363 |
"Generate a **Taiko no Tatsujin** chart from any song. 8M-param plan-conditioned "
|
| 364 |
"model + learned planner + beat-anchoring — all MIT-licensed.\n\n"
|
| 365 |
-
"Outputs a preview and a playable `.tja`. "
|
| 366 |
"*MIT-licensed.*"
|
| 367 |
)
|
| 368 |
with gr.Row():
|
|
@@ -379,11 +366,12 @@ with gr.Blocks(title="SoftChart — AI Taiko chart generator") as demo:
|
|
| 379 |
sampling = gr.Checkbox(label="Sampling (diverse) vs greedy (best)", value=False)
|
| 380 |
btn = gr.Button("Generate chart", variant="primary")
|
| 381 |
with gr.Column():
|
| 382 |
-
out_img = gr.Image(label="
|
|
|
|
| 383 |
out_info = gr.Textbox(label="Result", interactive=False)
|
| 384 |
out_tja = gr.File(label="Download .tja")
|
| 385 |
btn.click(generate, [audio, course, level, bpm, auto_plan_on, use_beat, use_planner, sampling],
|
| 386 |
-
[out_img, out_tja, out_info])
|
| 387 |
|
| 388 |
if __name__ == "__main__":
|
| 389 |
# show_api=False avoids a gradio_client schema-introspection bug
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import os
|
| 11 |
+
import re
|
| 12 |
import tempfile
|
| 13 |
|
| 14 |
import gradio as gr
|
|
|
|
| 20 |
from softchart.hf import SoftChartPlanner
|
| 21 |
from softchart.rhythm import snap_chart
|
| 22 |
from softchart.tja import write_tja_slots
|
| 23 |
+
from softchart.tja_image import render_tja_image
|
| 24 |
from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
|
| 25 |
|
| 26 |
V15_REPO = os.environ.get("SC_V15", "JacobLinCool/softchart-v15")
|
|
|
|
| 28 |
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 29 |
|
| 30 |
COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
|
|
|
|
| 31 |
CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
|
| 32 |
"roll": "5", "roll_big": "6", "balloon": "7"}
|
|
|
|
|
|
|
|
|
|
| 33 |
SUB = 96
|
| 34 |
|
| 35 |
_MODELS = {}
|
|
|
|
| 131 |
return slots
|
| 132 |
|
| 133 |
|
| 134 |
+
def upload_wave_name(audio_path):
|
| 135 |
+
name = os.path.basename(str(audio_path)).strip()
|
| 136 |
+
name = re.sub(r"[\r\n]+", " ", name)
|
| 137 |
+
return name or "song.ogg"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def output_tja_path(wave_name, course):
|
| 141 |
+
stem = os.path.splitext(os.path.basename(wave_name))[0].strip() or "softchart"
|
| 142 |
+
stem = re.sub(r"[^0-9A-Za-z._ -]+", "_", stem).strip(" ._") or "softchart"
|
| 143 |
+
return os.path.join(tempfile.mkdtemp(), f"{stem}_{course}.tja")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=None):
|
| 147 |
hits = sorted((h["t"], CHAR[h["type"]]) for h in gen["hits"])
|
| 148 |
beat = 60.0 / bpm
|
| 149 |
grid = beat / (SUB / 4)
|
|
|
|
| 198 |
lines = ["".join(slots.get(m * SUB + k, "0") for k in range(SUB)) + "," for m in range(n_meas)]
|
| 199 |
balloons = [10] * sum(1 for s in gen["spans"] if s["type"] == "balloon")
|
| 200 |
return "\n".join([
|
| 201 |
+
f"TITLE:{title} (SoftChart)", f"BPM:{bpm:g}", f"WAVE:{wave}",
|
| 202 |
f"OFFSET:{-phase:.3f}", f"COURSE:{'Oni' if course == 'oni' else course.capitalize()}",
|
| 203 |
f"LEVEL:{level}", f"BALLOON:{','.join(map(str, balloons))}" if balloons else "BALLOON:",
|
| 204 |
"", "#START", *lines, "#END"]) + "\n"
|
| 205 |
|
| 206 |
|
| 207 |
+
def render_audio_plan(mel, title, course, plan=None):
|
| 208 |
import matplotlib
|
| 209 |
matplotlib.use("Agg")
|
| 210 |
import matplotlib.pyplot as plt
|
| 211 |
|
| 212 |
dur = mel.shape[1] / FPS
|
| 213 |
+
fig = plt.figure(figsize=(13, 4.4 if plan else 3.2))
|
| 214 |
+
gs = fig.add_gridspec(2 if plan else 1, 1,
|
| 215 |
+
height_ratios=[3.0, 1.0] if plan else [1],
|
| 216 |
+
hspace=0.14 if plan else 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
ax0 = fig.add_subplot(gs[0])
|
| 218 |
+
ax0.imshow(mel, aspect="auto", origin="lower",
|
| 219 |
+
cmap="magma", extent=[0, dur, 0, N_MELS])
|
|
|
|
| 220 |
ax0.set_ylabel("mel")
|
| 221 |
+
ax0.set_title(f"{title} — {course} | full-song mel spectrogram")
|
| 222 |
+
ax0.set_xlim(0, dur)
|
| 223 |
+
ax0.grid(axis="x", alpha=0.18)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
if plan:
|
| 225 |
+
ax0.set_xticklabels([])
|
| 226 |
+
ax1 = fig.add_subplot(gs[1], sharex=ax0)
|
|
|
|
| 227 |
for a, b, d, f in plan:
|
| 228 |
c = "#d64545" if f == 2 else ("#4a90d9" if f == 1 else "#999999")
|
| 229 |
+
ax1.bar((a + b) / 2, max(d, 0.15), width=max((b - a) * 0.92, 0.01),
|
| 230 |
+
color=c, alpha=0.85)
|
| 231 |
+
ax1.set_xlim(0, dur)
|
| 232 |
+
ax1.set_ylim(0, 8)
|
| 233 |
+
ax1.set_yticks([0, 4, 8])
|
| 234 |
+
ax1.set_ylabel("plan", fontsize=8)
|
| 235 |
+
ax1.set_xlabel("time (s) — plan: grey=density blue=gap red=climax")
|
| 236 |
+
ax1.grid(axis="x", alpha=0.18)
|
| 237 |
+
else:
|
| 238 |
+
ax0.set_xlabel("time (s)")
|
| 239 |
out = tempfile.mktemp(suffix=".png")
|
| 240 |
fig.savefig(out, dpi=130, bbox_inches="tight")
|
| 241 |
plt.close(fig)
|
|
|
|
| 301 |
elif auto_plan_on:
|
| 302 |
plan = auto_plan(mel, bpm, dbs)
|
| 303 |
|
| 304 |
+
wave_name = upload_wave_name(audio)
|
| 305 |
+
title = os.path.splitext(wave_name)[0]
|
| 306 |
slot_used = False
|
| 307 |
if grid is not None and M["slot"] is not None:
|
| 308 |
# slot-exact path: the decoder emits TJA lattice indices directly —
|
|
|
|
| 313 |
device=DEVICE, plan=plan,
|
| 314 |
on_progress=lambda d, t: P(0.32 + 0.58 * d / max(t, 1),
|
| 315 |
f"Generating notes (slot-exact)… {d}/{t} windows"))
|
| 316 |
+
tja = write_tja_slots(g, grid, title, course, int(level), wave_name)
|
| 317 |
slot_used = True
|
| 318 |
else:
|
| 319 |
g = generate_song(
|
|
|
|
| 323 |
on_progress=lambda d, t: P(0.32 + 0.58 * d / max(t, 1),
|
| 324 |
f"Generating notes… {d}/{t} windows"))
|
| 325 |
g = snap_chart(g, bpm)
|
| 326 |
+
tja = write_tja(g, bpm, title, course, int(level), wave_name, dbs, grid_fit=grid)
|
| 327 |
P(0.92, "Writing TJA…")
|
| 328 |
+
tja_path = output_tja_path(wave_name, course)
|
| 329 |
+
with open(tja_path, "w", encoding="utf-8") as f:
|
| 330 |
f.write(tja)
|
| 331 |
P(0.95, "Rendering preview…")
|
| 332 |
+
audio_plan_img = render_audio_plan(mel, title, course, plan=plan)
|
| 333 |
+
tja_img = render_tja_image(tja)
|
| 334 |
if grid is not None:
|
| 335 |
grid_info = (f" · grid: rms {grid['rms_ms']:.1f}ms ({grid['inlier_frac']:.0%} inlier)"
|
| 336 |
+ (f" · {grid['n_segments']} tempo segs" if grid.get("piecewise") else "")
|
|
|
|
| 341 |
grid_info = ""
|
| 342 |
info = (f"BPM {bpm:.1f} · {len(g['hits'])} notes · {len(g['spans'])} spans" + grid_info
|
| 343 |
+ (f" · plan: {sum(1 for b in plan if b[3]==1)} gaps, {sum(1 for b in plan if b[3]==2)} climax" if plan else ""))
|
| 344 |
+
return audio_plan_img, tja_img, tja_path, info
|
| 345 |
|
| 346 |
|
| 347 |
with gr.Blocks(title="SoftChart — AI Taiko chart generator") as demo:
|
|
|
|
| 349 |
"# 🥁 SoftChart\n"
|
| 350 |
"Generate a **Taiko no Tatsujin** chart from any song. 8M-param plan-conditioned "
|
| 351 |
"model + learned planner + beat-anchoring — all MIT-licensed.\n\n"
|
| 352 |
+
"Outputs a full-song mel/plan preview, a full TJA chart image, and a playable `.tja`. "
|
| 353 |
"*MIT-licensed.*"
|
| 354 |
)
|
| 355 |
with gr.Row():
|
|
|
|
| 366 |
sampling = gr.Checkbox(label="Sampling (diverse) vs greedy (best)", value=False)
|
| 367 |
btn = gr.Button("Generate chart", variant="primary")
|
| 368 |
with gr.Column():
|
| 369 |
+
out_img = gr.Image(label="Full-song mel + plan")
|
| 370 |
+
out_chart = gr.Image(label="TJA chart")
|
| 371 |
out_info = gr.Textbox(label="Result", interactive=False)
|
| 372 |
out_tja = gr.File(label="Download .tja")
|
| 373 |
btn.click(generate, [audio, course, level, bpm, auto_plan_on, use_beat, use_planner, sampling],
|
| 374 |
+
[out_img, out_chart, out_tja, out_info])
|
| 375 |
|
| 376 |
if __name__ == "__main__":
|
| 377 |
# show_api=False avoids a gradio_client schema-introspection bug
|
requirements.txt
CHANGED
|
@@ -5,6 +5,7 @@ librosa>=0.10
|
|
| 5 |
numba>=0.60
|
| 6 |
soundfile
|
| 7 |
matplotlib
|
|
|
|
| 8 |
gradio==5.9.1
|
| 9 |
gradio_client==1.5.2
|
| 10 |
pydantic==2.10.6
|
|
|
|
| 5 |
numba>=0.60
|
| 6 |
soundfile
|
| 7 |
matplotlib
|
| 8 |
+
pillow
|
| 9 |
gradio==5.9.1
|
| 10 |
gradio_client==1.5.2
|
| 11 |
pydantic==2.10.6
|
softchart/tja_image.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server-side TJA chart image rendering for the Space.
|
| 2 |
+
|
| 3 |
+
The layout follows the same practical convention as tja-tools: full-chart rows
|
| 4 |
+
with 16 beats per row, measure barlines, colored note heads, and long-note
|
| 5 |
+
spans. SoftChart emits simple single-course TJAs, so this renderer intentionally
|
| 6 |
+
supports the generated subset instead of bundling the browser/webpack canvas
|
| 7 |
+
app at runtime.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
import os
|
| 14 |
+
import tempfile
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
_NOTE_CHARS = set("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
| 21 |
+
_COURSE_NAME = {
|
| 22 |
+
"0": "Easy",
|
| 23 |
+
"1": "Normal",
|
| 24 |
+
"2": "Hard",
|
| 25 |
+
"3": "Oni",
|
| 26 |
+
"4": "Ura",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class ParsedTJA:
|
| 32 |
+
headers: dict[str, str]
|
| 33 |
+
measures: list[str]
|
| 34 |
+
bpm_changes: dict[int, str]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
| 38 |
+
names = (
|
| 39 |
+
"DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf",
|
| 40 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else
|
| 41 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 42 |
+
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else
|
| 43 |
+
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
| 44 |
+
"/System/Library/Fonts/Supplemental/Helvetica Bold.ttf" if bold else
|
| 45 |
+
"/System/Library/Fonts/Supplemental/Helvetica.ttf",
|
| 46 |
+
)
|
| 47 |
+
for name in names:
|
| 48 |
+
try:
|
| 49 |
+
return ImageFont.truetype(name, size=size)
|
| 50 |
+
except OSError:
|
| 51 |
+
pass
|
| 52 |
+
return ImageFont.load_default()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _strip_comment(line: str) -> str:
|
| 56 |
+
return line.split("//", 1)[0].strip()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def parse_tja(tja_text: str) -> ParsedTJA:
|
| 60 |
+
headers: dict[str, str] = {}
|
| 61 |
+
measures: list[str] = []
|
| 62 |
+
bpm_changes: dict[int, str] = {}
|
| 63 |
+
in_chart = False
|
| 64 |
+
measure_buf: list[str] = []
|
| 65 |
+
|
| 66 |
+
def flush_measure() -> None:
|
| 67 |
+
measure = "".join(measure_buf).strip()
|
| 68 |
+
measures.append(measure or "0")
|
| 69 |
+
measure_buf.clear()
|
| 70 |
+
|
| 71 |
+
for raw_line in tja_text.splitlines():
|
| 72 |
+
line = _strip_comment(raw_line)
|
| 73 |
+
if not line:
|
| 74 |
+
continue
|
| 75 |
+
upper = line.upper()
|
| 76 |
+
if upper.startswith("#START"):
|
| 77 |
+
in_chart = True
|
| 78 |
+
continue
|
| 79 |
+
if not in_chart:
|
| 80 |
+
if ":" in line and not line.startswith("#"):
|
| 81 |
+
key, value = line.split(":", 1)
|
| 82 |
+
headers[key.strip().upper()] = value.strip()
|
| 83 |
+
continue
|
| 84 |
+
if upper.startswith("#END"):
|
| 85 |
+
break
|
| 86 |
+
if upper.startswith("#BPMCHANGE"):
|
| 87 |
+
parts = line.split(maxsplit=1)
|
| 88 |
+
if len(parts) == 2:
|
| 89 |
+
bpm_changes[len(measures)] = parts[1].strip()
|
| 90 |
+
continue
|
| 91 |
+
if line.startswith("#"):
|
| 92 |
+
continue
|
| 93 |
+
for ch in line:
|
| 94 |
+
if ch == ",":
|
| 95 |
+
flush_measure()
|
| 96 |
+
elif ch.upper() in _NOTE_CHARS:
|
| 97 |
+
measure_buf.append(ch.upper())
|
| 98 |
+
|
| 99 |
+
if measure_buf:
|
| 100 |
+
flush_measure()
|
| 101 |
+
if not measures:
|
| 102 |
+
measures.append("0")
|
| 103 |
+
return ParsedTJA(headers=headers, measures=measures, bpm_changes=bpm_changes)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _course_label(value: str) -> str:
|
| 107 |
+
return _COURSE_NAME.get(value, value[:1].upper() + value[1:].lower() if value else "")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _text_right(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
|
| 111 |
+
font: ImageFont.ImageFont, fill: str) -> None:
|
| 112 |
+
x, y = xy
|
| 113 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 114 |
+
draw.text((x - (bbox[2] - bbox[0]), y), text, font=font, fill=fill)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _beat_xy(beat: float, row_beats: float, beat_width: int, left: int,
|
| 118 |
+
top: int, row_h: int, note_y: int, rows: int) -> tuple[int, int, int]:
|
| 119 |
+
row = max(0, min(rows - 1, int(math.floor(beat / row_beats))))
|
| 120 |
+
x = int(round(left + (beat - row * row_beats) * beat_width))
|
| 121 |
+
y = top + row * row_h + note_y
|
| 122 |
+
return row, x, y
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _note_events(measures: list[str]) -> tuple[list[tuple[float, str]], list[tuple[float, float, str]]]:
|
| 126 |
+
events: list[tuple[float, str]] = []
|
| 127 |
+
rolls: list[tuple[float, float, str]] = []
|
| 128 |
+
open_roll: tuple[float, str] | None = None
|
| 129 |
+
|
| 130 |
+
for midx, measure in enumerate(measures):
|
| 131 |
+
div = max(len(measure), 1)
|
| 132 |
+
for pos, symbol in enumerate(measure):
|
| 133 |
+
if symbol == "0":
|
| 134 |
+
continue
|
| 135 |
+
beat = midx * 4.0 + 4.0 * pos / div
|
| 136 |
+
if symbol in {"5", "6", "7", "9"}:
|
| 137 |
+
if open_roll is not None:
|
| 138 |
+
rolls.append((open_roll[0], beat, open_roll[1]))
|
| 139 |
+
open_roll = (beat, symbol)
|
| 140 |
+
events.append((beat, symbol))
|
| 141 |
+
elif symbol == "8":
|
| 142 |
+
if open_roll is not None:
|
| 143 |
+
rolls.append((open_roll[0], beat, open_roll[1]))
|
| 144 |
+
open_roll = None
|
| 145 |
+
else:
|
| 146 |
+
events.append((beat, symbol))
|
| 147 |
+
else:
|
| 148 |
+
events.append((beat, symbol))
|
| 149 |
+
|
| 150 |
+
if open_roll is not None:
|
| 151 |
+
rolls.append((open_roll[0], max(4.0 * len(measures), open_roll[0] + 0.25), open_roll[1]))
|
| 152 |
+
return events, rolls
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _draw_roll(draw: ImageDraw.ImageDraw, start: float, end: float, symbol: str,
|
| 156 |
+
row_beats: float, beat_width: int, left: int, top: int, row_h: int,
|
| 157 |
+
note_y: int, rows: int) -> None:
|
| 158 |
+
color = "#b77de1" if symbol in {"7", "9"} else "#e9b83e"
|
| 159 |
+
edge = "#6f5520" if symbol not in {"7", "9"} else "#6c4388"
|
| 160 |
+
start_row = max(0, int(math.floor(start / row_beats)))
|
| 161 |
+
end_row = max(0, int(math.floor(max(end - 1e-6, start) / row_beats)))
|
| 162 |
+
for row in range(start_row, min(end_row, rows - 1) + 1):
|
| 163 |
+
seg0 = max(start, row * row_beats)
|
| 164 |
+
seg1 = min(end, (row + 1) * row_beats)
|
| 165 |
+
if seg1 <= seg0:
|
| 166 |
+
continue
|
| 167 |
+
x0 = int(round(left + (seg0 - row * row_beats) * beat_width))
|
| 168 |
+
x1 = int(round(left + (seg1 - row * row_beats) * beat_width))
|
| 169 |
+
y = top + row * row_h + note_y
|
| 170 |
+
draw.rounded_rectangle((x0, y - 8, max(x1, x0 + 3), y + 8),
|
| 171 |
+
radius=8, fill=color, outline=edge, width=2)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _draw_note(draw: ImageDraw.ImageDraw, x: int, y: int, symbol: str,
|
| 175 |
+
label_font: ImageFont.ImageFont) -> None:
|
| 176 |
+
if symbol in {"1", "3", "A"}:
|
| 177 |
+
fill = "#e34b3f"
|
| 178 |
+
elif symbol in {"2", "4", "B"}:
|
| 179 |
+
fill = "#4aa8c0"
|
| 180 |
+
elif symbol in {"5", "6"}:
|
| 181 |
+
fill = "#e9b83e"
|
| 182 |
+
elif symbol in {"7", "9"}:
|
| 183 |
+
fill = "#b77de1"
|
| 184 |
+
elif symbol == "8":
|
| 185 |
+
fill = "#555555"
|
| 186 |
+
else:
|
| 187 |
+
fill = "#8d96a0"
|
| 188 |
+
|
| 189 |
+
radius = 16 if symbol in {"3", "4", "6", "A", "B"} else 12
|
| 190 |
+
if symbol == "8":
|
| 191 |
+
draw.rectangle((x - 5, y - 12, x + 5, y + 12), fill=fill)
|
| 192 |
+
return
|
| 193 |
+
|
| 194 |
+
draw.ellipse((x - radius, y - radius, x + radius, y + radius),
|
| 195 |
+
fill=fill, outline="#202020", width=2)
|
| 196 |
+
draw.ellipse((x - radius + 4, y - radius + 4, x - radius + 9, y - radius + 9),
|
| 197 |
+
fill="#ffffff")
|
| 198 |
+
label = "B" if symbol in {"3", "4", "6", "A", "B"} else ("R" if symbol in {"5", "6"} else ("P" if symbol in {"7", "9"} else ""))
|
| 199 |
+
if label:
|
| 200 |
+
bbox = draw.textbbox((0, 0), label, font=label_font)
|
| 201 |
+
draw.text((x - (bbox[2] - bbox[0]) / 2, y - (bbox[3] - bbox[1]) / 2 - 1),
|
| 202 |
+
label, font=label_font, fill="#1e1e1e")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def render_tja_image(tja_text: str, out_path: str | None = None) -> str:
|
| 206 |
+
parsed = parse_tja(tja_text)
|
| 207 |
+
headers = parsed.headers
|
| 208 |
+
row_beats = 16.0
|
| 209 |
+
try:
|
| 210 |
+
row_beats = float(headers.get("TTROWBEAT", row_beats))
|
| 211 |
+
except ValueError:
|
| 212 |
+
row_beats = 16.0
|
| 213 |
+
row_beats = min(max(row_beats, 4.0), 32.0)
|
| 214 |
+
|
| 215 |
+
title = headers.get("TITLE", "SoftChart")
|
| 216 |
+
course = _course_label(headers.get("COURSE", ""))
|
| 217 |
+
level = headers.get("LEVEL", "")
|
| 218 |
+
bpm = headers.get("BPM", "")
|
| 219 |
+
wave = headers.get("WAVE", "")
|
| 220 |
+
measures = parsed.measures
|
| 221 |
+
total_beats = max(4.0 * len(measures), 4.0)
|
| 222 |
+
rows = max(1, int(math.ceil(total_beats / row_beats)))
|
| 223 |
+
|
| 224 |
+
left = 72
|
| 225 |
+
right = 30
|
| 226 |
+
beat_width = 56
|
| 227 |
+
header_h = 90
|
| 228 |
+
row_h = 74
|
| 229 |
+
note_y = 45
|
| 230 |
+
footer_h = 22
|
| 231 |
+
width = int(left + row_beats * beat_width + right)
|
| 232 |
+
height = int(header_h + rows * row_h + footer_h)
|
| 233 |
+
|
| 234 |
+
img = Image.new("RGB", (width, height), "#f5f3ee")
|
| 235 |
+
draw = ImageDraw.Draw(img)
|
| 236 |
+
title_font = _font(24, bold=True)
|
| 237 |
+
meta_font = _font(14)
|
| 238 |
+
small_font = _font(11)
|
| 239 |
+
note_font = _font(10, bold=True)
|
| 240 |
+
|
| 241 |
+
draw.rectangle((0, 0, width, header_h - 8), fill="#23272f")
|
| 242 |
+
draw.text((24, 16), title, font=title_font, fill="#ffffff")
|
| 243 |
+
meta = " · ".join(p for p in (course, f"Lv.{level}" if level else "", f"BPM {bpm}" if bpm else "") if p)
|
| 244 |
+
draw.text((24, 51), meta or "Generated TJA chart", font=meta_font, fill="#d8dde7")
|
| 245 |
+
if wave:
|
| 246 |
+
_text_right(draw, (width - 24, 52), f"WAVE {os.path.basename(wave)}", meta_font, "#d8dde7")
|
| 247 |
+
_text_right(draw, (width - 24, 18), "red=don blue=ka yellow=roll purple=balloon",
|
| 248 |
+
small_font, "#b8c0cc")
|
| 249 |
+
|
| 250 |
+
for row in range(rows):
|
| 251 |
+
y0 = header_h + row * row_h
|
| 252 |
+
y1 = y0 + row_h - 12
|
| 253 |
+
fill = "#d8d8d3" if row % 2 == 0 else "#cecec9"
|
| 254 |
+
draw.rounded_rectangle((14, y0, width - 14, y1), radius=8, fill=fill)
|
| 255 |
+
cy = y0 + note_y
|
| 256 |
+
draw.line((left, cy, width - right, cy), fill="#6d6d68", width=1)
|
| 257 |
+
for b in range(int(row_beats) + 1):
|
| 258 |
+
x = int(round(left + b * beat_width))
|
| 259 |
+
is_bar = b % 4 == 0
|
| 260 |
+
draw.line((x, y0 + 8, x, y1 - 6),
|
| 261 |
+
fill="#5c5c58" if is_bar else "#eeeeea",
|
| 262 |
+
width=2 if is_bar else 1)
|
| 263 |
+
if is_bar:
|
| 264 |
+
measure_idx = int(row * row_beats / 4 + b / 4)
|
| 265 |
+
if measure_idx < len(measures):
|
| 266 |
+
draw.text((x + 3, y0 + 9), str(measure_idx + 1),
|
| 267 |
+
font=small_font, fill="#4a4a46")
|
| 268 |
+
|
| 269 |
+
for measure_idx, bpm_value in parsed.bpm_changes.items():
|
| 270 |
+
beat = measure_idx * 4.0
|
| 271 |
+
row, x, _ = _beat_xy(beat, row_beats, beat_width, left, header_h, row_h, note_y, rows)
|
| 272 |
+
draw.text((x + 4, header_h + row * row_h + 26), f"BPM {bpm_value}",
|
| 273 |
+
font=small_font, fill="#8b3d3d")
|
| 274 |
+
|
| 275 |
+
events, rolls = _note_events(measures)
|
| 276 |
+
for start, end, symbol in rolls:
|
| 277 |
+
_draw_roll(draw, start, end, symbol, row_beats, beat_width, left, header_h, row_h, note_y, rows)
|
| 278 |
+
for beat, symbol in events:
|
| 279 |
+
_, x, y = _beat_xy(beat, row_beats, beat_width, left, header_h, row_h, note_y, rows)
|
| 280 |
+
_draw_note(draw, x, y, symbol, note_font)
|
| 281 |
+
|
| 282 |
+
if out_path is None:
|
| 283 |
+
out_path = tempfile.mktemp(suffix="_tja.png")
|
| 284 |
+
img.save(out_path)
|
| 285 |
+
return out_path
|