JacobLinCool commited on
Commit
fbe34db
·
verified ·
1 Parent(s): 48549a5

Add TJA chart image preview

Browse files
Files changed (4) hide show
  1. README.md +6 -2
  2. app.py +51 -63
  3. requirements.txt +1 -0
  4. 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 preview plus a `.tja` playable in
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 write_tja(gen, bpm, title, course, level, downbeats=None, grid_fit=None):
 
 
 
 
 
 
 
 
 
 
 
 
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:song.ogg",
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 render(mel, gen, title, course, plan=None):
198
  import matplotlib
199
  matplotlib.use("Agg")
200
  import matplotlib.pyplot as plt
201
 
202
  dur = mel.shape[1] / FPS
203
- # frame-exact window: extent matches the sliced pixels exactly, so the
204
- # mel image, the note axis, and the plan shading share one clock
205
- f0 = int(dur * 0.33 * FPS)
206
- f1 = min(f0 + int(26 * FPS), mel.shape[1])
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[:, f0:f1], aspect="auto", origin="lower",
214
- cmap="magma", extent=[t0, t1, 0, N_MELS])
215
- ax0.set_xticks([])
216
  ax0.set_ylabel("mel")
217
- ax0.set_title(f"{title} — {course} | red=don blue=ka big=large yellow=roll purple=balloon")
218
- ax1 = fig.add_subplot(gs[1])
219
- if plan: # in-window plan shading: density = grey depth, gap/climax tinted
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
- # full-song plan strip: what the planner asked for (density envelope,
242
- # gaps, climax), with the zoomed preview window marked
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
- ax2.bar((a + b) / 2, max(d, 0.15), width=(b - a) * 0.92, color=c, alpha=0.85)
247
- ax2.axvspan(t0, t1, color="#e8b23a", alpha=0.18, lw=0)
248
- ax2.set_xlim(0, dur)
249
- ax2.set_ylim(0, 8)
250
- ax2.set_yticks([0, 4, 8])
251
- ax2.set_ylabel("plan", fontsize=8)
252
- ax2.set_xlabel("time (s) — plan: grey=density blue=gap red=climax, "
253
- "yellow band = preview window above")
 
 
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
- title = os.path.splitext(os.path.basename(audio))[0]
 
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), "song.ogg")
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 = tempfile.mktemp(suffix=f"_{course}.tja")
343
- with open(tja_path, "w") as f:
344
  f.write(tja)
345
  P(0.95, "Rendering preview…")
346
- img = render(mel, g, title, course, plan=plan)
 
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 img, tja_path, info
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="Preview (mel + chart)")
 
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