JacobLinCool commited on
Commit
045c87e
·
verified ·
1 Parent(s): ec5d701

Launch bold Server Mode UI and Taiko audition

Browse files
Files changed (7) hide show
  1. README.md +14 -3
  2. app.py +278 -168
  3. requirements.txt +4 -4
  4. softchart/preview_audio.py +547 -0
  5. static/app.css +1694 -0
  6. static/app.js +680 -0
  7. static/index.html +361 -0
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🥁
4
  colorFrom: red
5
  colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 5.9.1
8
  python_version: "3.11"
9
  app_file: app.py
10
  pinned: false
@@ -25,9 +25,20 @@ 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 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
  Planner climax blocks are emitted as TJA `#GOGOSTART` / `#GOGOEND` sections.
32
  The chart image uses a server-side renderer modeled on the row layout used by
33
  MIT-licensed [tja-tools](https://github.com/WHMHammer/tja-tools), without
 
4
  colorFrom: red
5
  colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.20.0
8
  python_version: "3.11"
9
  app_file: app.py
10
  pinned: false
 
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
+ The Space uses Gradio Server mode with a fully custom, responsive interface.
29
  Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
30
+ full TJA chart image, a `.tja` playable in TJAPlayer3 / OpenTaiko, and a
31
+ synchronized WAV mix that lets you hear the generated chart performed over
32
+ the source music immediately. The game-style wadaiko kit is synthesized by
33
+ SoftChart and does not contain audio taken from a commercial game.
34
+
35
+ The generation API streams the real processing stages to the interface:
36
+ audio analysis, beat-grid fitting, song-level planning, note generation,
37
+ chart rendering, and taiko preview mixing. The final audio is timed from the
38
+ exported TJA itself, including `OFFSET` and `#BPMCHANGE`, so the audition and
39
+ downloadable chart share the same timing.
40
+
41
+ The generated TJA keeps the uploaded audio filename in its `WAVE` field.
42
  Planner climax blocks are emitted as TJA `#GOGOSTART` / `#GOGOEND` sections.
43
  The chart image uses a server-side renderer modeled on the row layout used by
44
  MIT-licensed [tja-tools](https://github.com/WHMHammer/tja-tools), without
app.py CHANGED
@@ -1,4 +1,4 @@
1
- """SoftChart — Gradio demo for Hugging Face Spaces.
2
 
3
  Generate a Taiko no Tatsujin chart from any audio file, using the full system:
4
  - SoftChartGenerator (main model, plan-conditioned)
@@ -7,23 +7,32 @@ Generate a Taiko no Tatsujin chart from any audio file, using the full system:
7
  All models load from the Hub via from_pretrained. MIT licensed.
8
  """
9
 
 
10
  import os
11
  import re
 
12
  import tempfile
 
13
 
14
  import gradio as gr
15
  import numpy as np
16
  import torch
 
 
17
 
18
  from softchart.generate import generate_song, generate_song_slot, load_hf
19
  from softchart.fonts import cjk_font_path
20
  from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
21
  from softchart.hf import SoftChartPlanner
 
22
  from softchart.rhythm import snap_chart
23
  from softchart.tja import append_measure_with_gogo, gogo_measure_mask, write_tja_slots
24
  from softchart.tja_image import render_tja_image
25
  from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
26
 
 
 
 
27
  V15_REPO = os.environ.get("SC_V15", "JacobLinCool/softchart-v15")
28
  PLAN_REPO = os.environ.get("SC_PLAN", "JacobLinCool/softchart-planner")
29
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
@@ -32,22 +41,21 @@ COURSE_DENS = {"easy": 1, "normal": 2, "hard": 4, "oni": 7}
32
  CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
33
  "roll": "5", "roll_big": "6", "balloon": "7"}
34
  SUB = 96
35
- DEFAULT_TEMPERATURE = 0.8
36
- DEFAULT_TOP_P = 0.9
37
 
38
  _MODELS = {}
39
 
40
 
41
- def get_models():
42
- if not _MODELS:
43
  u = load_hf(V15_REPO, device=DEVICE)
44
- if not getattr(u, "_dual", False) or u.beat is None:
45
- raise RuntimeError(f"{V15_REPO} must be a dual-mode generator with a beat head")
 
 
 
46
  _MODELS.update(gen=u, slot=u, beat=u)
47
- try:
48
- _MODELS["plan"] = SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
49
- except Exception:
50
- _MODELS["plan"] = None
51
  return _MODELS
52
 
53
 
@@ -135,15 +143,15 @@ def group_quantize(times, phase, grid, min_run=3):
135
 
136
 
137
  def upload_wave_name(audio_path):
138
- name = os.path.basename(str(audio_path)).strip()
139
- name = re.sub(r"[\r\n]+", " ", name)
140
  return name or "song.ogg"
141
 
142
 
143
- def output_tja_path(wave_name, course):
144
  stem = os.path.splitext(os.path.basename(wave_name))[0].strip() or "softchart"
145
- stem = re.sub(r"[^0-9A-Za-z._ -]+", "_", stem).strip(" ._") or "softchart"
146
- return os.path.join(tempfile.mkdtemp(), f"{stem}_{course}.tja")
147
 
148
 
149
  def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=None,
@@ -218,7 +226,7 @@ def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=Non
218
  "", "#START", *lines, "#END"]) + "\n"
219
 
220
 
221
- def render_audio_plan(mel, title, course, plan=None):
222
  import matplotlib
223
  matplotlib.use("Agg")
224
  from matplotlib import font_manager
@@ -257,158 +265,260 @@ def render_audio_plan(mel, title, course, plan=None):
257
  ax1.grid(axis="x", alpha=0.18)
258
  else:
259
  ax0.set_xlabel("time (s)")
260
- out = tempfile.mktemp(suffix=".png")
261
- fig.savefig(out, dpi=130, bbox_inches="tight")
262
  plt.close(fig)
263
- return out
264
-
265
-
266
- def generate(audio, course, level, bpm_override, auto_plan_on, use_beat, use_planner,
267
- sampling, temperature, top_p, progress=gr.Progress()):
268
- if audio is None:
269
- raise gr.Error("Please upload an audio file.")
270
-
271
- def P(frac, desc): # progress is a no-op when called outside a Gradio event
272
- try:
273
- progress(frac, desc=desc)
274
- except Exception:
275
- pass
276
-
277
- P(0.01, "Loading models (first run downloads them from the Hub)…")
278
- M = get_models()
279
- P(0.08, "Computing log-mel…")
280
- mel, wav = load_logmel(audio)
281
- grid = dbs = None
282
- if use_beat and M["beat"] is not None:
283
- P(0.14, "Fitting beat grid…")
284
- # global robust (period, phase) fit over the whole song — much more
285
- # precise than per-peak use (each raw peak carries ~±23 ms bin noise)
286
- grid = fit_grid_piecewise(M["beat"], mel, device=DEVICE)
287
- if grid is not None:
288
- # rigid synthesized barlines when the fit is trustworthy; raw peaks
289
- # (plan-block edges only, no anchoring) when it is not
290
- dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
291
- if not grid["ok"]:
292
- grid = None
293
- if bpm_override and bpm_override > 0:
294
- bpm = float(bpm_override)
295
- if use_beat and M["beat"] is not None and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
296
- # the user KNOWS the tempo: phase-only fit with a trusted period —
297
- # much easier than the free fit, often unlocks the slot-exact path
298
- g2 = fit_grid_fixed_bpm(M["beat"], mel, bpm, device=DEVICE)
299
- grid = g2 if (g2 is not None and g2["ok"]) else None
300
- if grid is not None:
301
- dbs = grid["downbeats"]
302
- elif grid is not None:
303
- bpm = grid["bpm"] # already integer-snapped when the residual allows
304
- elif dbs is not None and len(dbs) > 4:
305
- period = float(np.median(np.diff(dbs))) # downbeat gap = one 4/4 bar
306
- bpm = 240.0 / period if period > 0 else 0.0
307
- while bpm >= 210: # octave guard
308
- bpm /= 2.0
309
- while 0 < bpm < 70:
310
- bpm *= 2.0
311
- else:
312
- import librosa
313
- bpm = float(np.atleast_1d(librosa.beat.beat_track(y=wav, sr=SR)[0])[0])
314
- if grid is None and abs(bpm - round(bpm)) < 0.06:
315
- bpm = float(round(bpm))
316
- temperature = float(np.clip(temperature or DEFAULT_TEMPERATURE, 0.2, 1.2))
317
- top_p = float(np.clip(top_p or DEFAULT_TOP_P, 0.5, 1.0))
318
-
319
- plan = None
320
- P(0.28, "Planning song structure…")
321
- if any(getattr(m, "_has_plan", False) for m in (M["gen"], M["slot"]) if m is not None):
322
- if use_planner and M["plan"] is not None:
323
- plan = learned_plan(M["plan"], mel, course, bpm, dbs)
324
- elif auto_plan_on:
325
- plan = auto_plan(mel, bpm, dbs)
326
-
327
- wave_name = upload_wave_name(audio)
328
- title = os.path.splitext(wave_name)[0]
329
- slot_used = False
330
- if grid is not None and M["slot"] is not None:
331
- # slot-exact path: the decoder emits TJA lattice indices directly —
332
- # no quantization, tuplets even by construction
333
- g = generate_song_slot(
334
- M["slot"], mel, grid, course, level=int(level),
335
- density_bucket=COURSE_DENS[course], greedy=not sampling, seed=0,
336
- temperature=temperature, top_p=top_p, device=DEVICE, plan=plan,
337
- on_progress=lambda d, t: P(0.32 + 0.58 * d / max(t, 1),
338
- f"Generating notes (slot-exact)… {d}/{t} windows"))
339
- tja = write_tja_slots(g, grid, title, course, int(level), wave_name,
340
- plan=plan)
341
- slot_used = True
342
- else:
343
- g = generate_song(
344
- M["gen"], mel, course, level=int(level),
345
- density_bucket=COURSE_DENS[course], greedy=not sampling,
346
- temperature=temperature, top_p=top_p, seed=0, device=DEVICE, plan=plan,
347
- on_progress=lambda d, t: P(0.32 + 0.58 * d / max(t, 1),
348
- f"Generating notes… {d}/{t} windows"))
349
- g = snap_chart(g, bpm)
350
- tja = write_tja(g, bpm, title, course, int(level), wave_name, dbs,
351
- grid_fit=grid, plan=plan)
352
- P(0.92, "Writing TJA…")
353
- tja_path = output_tja_path(wave_name, course)
354
- with open(tja_path, "w", encoding="utf-8") as f:
355
- f.write(tja)
356
- P(0.95, "Rendering preview…")
357
- audio_plan_img = render_audio_plan(mel, title, course, plan=plan)
358
- tja_img = render_tja_image(tja)
359
- if grid is not None:
360
- grid_info = (f" · grid: rms {grid['rms_ms']:.1f}ms ({grid['inlier_frac']:.0%} inlier)"
361
- + (f" · {grid['n_segments']} tempo segs" if grid.get("piecewise") else "")
362
- + (" · slot-exact" if slot_used else ""))
363
- elif use_beat and M["beat"] is not None:
364
- grid_info = " · grid: unreliable, barline anchoring off"
365
- else:
366
- grid_info = ""
367
- sampling_info = f" · sampling T {temperature:.2f}, top-p {top_p:.2f}" if sampling else ""
368
- info = (f"BPM {bpm:.1f} · {len(g['hits'])} notes · {len(g['spans'])} spans" + grid_info + sampling_info
369
- + (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 ""))
370
- return tja_path, info, tja_img, audio_plan_img
371
-
372
-
373
- with gr.Blocks(title="SoftChart — AI Taiko chart generator") as demo:
374
- gr.Markdown(
375
- "# 🥁 SoftChart\n"
376
- "Generate a **Taiko no Tatsujin** chart from any song. 8M-param plan-conditioned "
377
- "model + learned planner + beat-anchoring — all MIT-licensed.\n\n"
378
- "Outputs a full-song mel/plan preview, a full TJA chart image, and a playable `.tja`. "
379
- "*MIT-licensed.*"
380
  )
381
- with gr.Row():
382
- with gr.Column():
383
- audio = gr.Audio(type="filepath", label="Song (any format)")
384
- course = gr.Dropdown(["easy", "normal", "hard", "oni"], value="oni", label="Difficulty (course)")
385
- level = gr.Slider(1, 10, value=9, step=1, label="Level (1–10 stars)")
386
- bpm = gr.Number(label="BPM (0 = auto-detect)", value=0)
387
- with gr.Row():
388
- auto_plan_on = gr.Checkbox(label="Auto-plan (heuristic structure)", value=True)
389
- use_planner = gr.Checkbox(label="Learned planner", value=True)
390
- with gr.Row():
391
- use_beat = gr.Checkbox(label="Beat-anchor barlines", value=True)
392
- sampling = gr.Checkbox(label="Sampling (diverse) vs greedy (best)", value=False)
393
- with gr.Row():
394
- temperature = gr.Slider(0.2, 1.2, value=DEFAULT_TEMPERATURE, step=0.05,
395
- label="Temperature", interactive=False)
396
- top_p = gr.Slider(0.5, 1.0, value=DEFAULT_TOP_P, step=0.01,
397
- label="Top-p", interactive=False)
398
- btn = gr.Button("Generate chart", variant="primary")
399
- with gr.Column():
400
- out_tja = gr.File(label="Download .tja")
401
- out_info = gr.Textbox(label="Result", interactive=False)
402
- out_chart = gr.Image(label="TJA chart")
403
- out_img = gr.Image(label="Full-song mel + plan")
404
- sampling.change(
405
- lambda enabled: (gr.update(interactive=enabled), gr.update(interactive=enabled)),
406
- sampling, [temperature, top_p])
407
- btn.click(generate, [audio, course, level, bpm, auto_plan_on, use_beat, use_planner,
408
- sampling, temperature, top_p],
409
- [out_tja, out_info, out_chart, out_img])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
 
411
  if __name__ == "__main__":
412
- # show_api=False avoids a gradio_client schema-introspection bug
413
- # ("bool is not iterable") that can appear on some versions
414
- demo.launch(show_api=False)
 
1
+ """SoftChart — custom Gradio Server app for Hugging Face Spaces.
2
 
3
  Generate a Taiko no Tatsujin chart from any audio file, using the full system:
4
  - SoftChartGenerator (main model, plan-conditioned)
 
7
  All models load from the Hub via from_pretrained. MIT licensed.
8
  """
9
 
10
+ import logging
11
  import os
12
  import re
13
+ import shutil
14
  import tempfile
15
+ from pathlib import Path
16
 
17
  import gradio as gr
18
  import numpy as np
19
  import torch
20
+ from fastapi.responses import FileResponse
21
+ from fastapi.staticfiles import StaticFiles
22
 
23
  from softchart.generate import generate_song, generate_song_slot, load_hf
24
  from softchart.fonts import cjk_font_path
25
  from softchart.grid import debias_to_grid, fit_grid_fixed_bpm, fit_grid_piecewise
26
  from softchart.hf import SoftChartPlanner
27
+ from softchart.preview_audio import synthesize_taiko_preview
28
  from softchart.rhythm import snap_chart
29
  from softchart.tja import append_measure_with_gogo, gogo_measure_mask, write_tja_slots
30
  from softchart.tja_image import render_tja_image
31
  from softchart.vocab import FPS, HOP, N_FFT, N_MELS, SR
32
 
33
+ LOGGER = logging.getLogger("softchart.space")
34
+ STATIC_DIR = Path(__file__).with_name("static")
35
+
36
  V15_REPO = os.environ.get("SC_V15", "JacobLinCool/softchart-v15")
37
  PLAN_REPO = os.environ.get("SC_PLAN", "JacobLinCool/softchart-planner")
38
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
 
41
  CHAR = {"don": "1", "ka": "2", "don_big": "3", "ka_big": "4",
42
  "roll": "5", "roll_big": "6", "balloon": "7"}
43
  SUB = 96
 
 
44
 
45
  _MODELS = {}
46
 
47
 
48
+ def get_models(*, include_planner=False):
49
+ if "gen" not in _MODELS:
50
  u = load_hf(V15_REPO, device=DEVICE)
51
+ if (not getattr(u, "_dual", False) or u.beat is None
52
+ or not getattr(u, "_has_plan", False)):
53
+ raise RuntimeError(
54
+ f"{V15_REPO} must provide dual generation, beat, and plan conditioning"
55
+ )
56
  _MODELS.update(gen=u, slot=u, beat=u)
57
+ if include_planner and "plan" not in _MODELS:
58
+ _MODELS["plan"] = SoftChartPlanner.from_pretrained(PLAN_REPO).to(DEVICE).eval()
 
 
59
  return _MODELS
60
 
61
 
 
143
 
144
 
145
  def upload_wave_name(audio_path):
146
+ name = os.path.basename(str(audio_path).replace("\\", "/")).strip()
147
+ name = re.sub(r"[\x00-\x1f\x7f]+", " ", name).strip()
148
  return name or "song.ogg"
149
 
150
 
151
+ def output_tja_path(wave_name, course, directory):
152
  stem = os.path.splitext(os.path.basename(wave_name))[0].strip() or "softchart"
153
+ stem = re.sub(r"[<>:\"/\\|?*\x00-\x1f]+", "_", stem).strip(" ._") or "softchart"
154
+ return os.path.join(directory, f"{stem}_{course}.tja")
155
 
156
 
157
  def write_tja(gen, bpm, title, course, level, wave, downbeats=None, grid_fit=None,
 
226
  "", "#START", *lines, "#END"]) + "\n"
227
 
228
 
229
+ def render_audio_plan(mel, title, course, out_path, plan=None):
230
  import matplotlib
231
  matplotlib.use("Agg")
232
  from matplotlib import font_manager
 
265
  ax1.grid(axis="x", alpha=0.18)
266
  else:
267
  ax0.set_xlabel("time (s)")
268
+ fig.savefig(out_path, dpi=130, bbox_inches="tight")
 
269
  plt.close(fig)
270
+ return out_path
271
+
272
+
273
+ def _progress(stage, fraction, title, detail):
274
+ return {
275
+ "kind": "progress",
276
+ "stage": stage,
277
+ "progress": fraction,
278
+ "title": title,
279
+ "detail": detail,
280
+ }
281
+
282
+
283
+ def _uploaded_file(value, original_name):
284
+ if value is None:
285
+ raise ValueError("Upload a music file to begin.")
286
+ if not isinstance(original_name, str) or not original_name.strip():
287
+ raise ValueError("The upload is missing its original filename. Please select it again.")
288
+ data = value if isinstance(value, gr.FileData) else gr.FileData.model_validate(value)
289
+ path = os.path.realpath(data.path)
290
+ if not os.path.isfile(path):
291
+ raise ValueError("The uploaded file is no longer available. Please select it again.")
292
+ return path, upload_wave_name(original_name)
293
+
294
+
295
+ def _validate_controls(course, level, bpm, temperature, top_p, drum_volume):
296
+ if course not in COURSE_DENS:
297
+ raise ValueError(f"Unsupported difficulty: {course}")
298
+ if not 1 <= int(level) <= 10:
299
+ raise ValueError("Level must be between 1 and 10 stars.")
300
+ if bpm != 0 and not 30 <= float(bpm) <= 400:
301
+ raise ValueError("Manual BPM must be between 30 and 400; use 0 for auto-detection.")
302
+ if not 0.2 <= float(temperature) <= 1.2:
303
+ raise ValueError("Temperature must be between 0.2 and 1.2.")
304
+ if not 0.5 <= float(top_p) <= 1.0:
305
+ raise ValueError("Top-p must be between 0.5 and 1.0.")
306
+ if not 0 <= float(drum_volume) <= 1.5:
307
+ raise ValueError("Taiko volume must be between 0% and 150%.")
308
+
309
+
310
+ def _file_data(path, *, name=None, mime_type=None):
311
+ return gr.FileData(
312
+ path=os.path.realpath(path),
313
+ orig_name=name or os.path.basename(path),
314
+ mime_type=mime_type,
315
+ ).model_dump()
316
+
317
+
318
+ app = gr.Server(
319
+ title="SoftChart",
320
+ description="Conditional Taiko chart generation with synchronized audio preview.",
321
+ )
322
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
323
+
324
+
325
+ @app.get("/", include_in_schema=False)
326
+ async def homepage():
327
+ return FileResponse(
328
+ STATIC_DIR / "index.html",
329
+ headers={"Cache-Control": "no-cache"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  )
331
+
332
+
333
+ @app.get("/health", include_in_schema=False)
334
+ async def health():
335
+ return {"status": "ok", "device": DEVICE, "models_loaded": "gen" in _MODELS}
336
+
337
+
338
+ @app.api(
339
+ name="generate_chart",
340
+ description="Generate a TJA chart, visual previews, and a synchronized taiko audio mix.",
341
+ concurrency_limit=1,
342
+ concurrency_id="generation-gpu",
343
+ queue=True,
344
+ stream_every=0.1,
345
+ )
346
+ def generate_chart(
347
+ audio: gr.FileData,
348
+ audio_name: str,
349
+ course: str,
350
+ level: int,
351
+ bpm_override: float,
352
+ auto_plan_on: bool,
353
+ use_beat: bool,
354
+ use_planner: bool,
355
+ sampling: bool,
356
+ temperature: float,
357
+ top_p: float,
358
+ drum_volume: float,
359
+ ) -> dict[str, object]:
360
+ """Stream the actual inference stages to the custom frontend."""
361
+ workdir = tempfile.mkdtemp(prefix="softchart-request-")
362
+ try:
363
+ audio_path, wave_name = _uploaded_file(audio, audio_name)
364
+ _validate_controls(course, level, bpm_override, temperature, top_p, drum_volume)
365
+ level = int(level)
366
+ bpm_override = float(bpm_override)
367
+ temperature = float(temperature)
368
+ top_p = float(top_p)
369
+ drum_volume = float(drum_volume)
370
+
371
+ yield _progress(
372
+ "loading", 0.03, "Preparing",
373
+ "Loading SoftChart.",
374
+ )
375
+ models = get_models(include_planner=use_planner)
376
+
377
+ yield _progress(
378
+ "audio", 0.11, "Listening",
379
+ "Mapping rhythm, melody, and timbre.",
380
+ )
381
+ mel, wav = load_logmel(audio_path)
382
+
383
+ yield _progress(
384
+ "beat", 0.21, "Finding the beat",
385
+ "Finding beats and tempo.",
386
+ )
387
+ grid = dbs = None
388
+ if use_beat:
389
+ grid = fit_grid_piecewise(models["beat"], mel, device=DEVICE)
390
+ if grid is not None:
391
+ dbs = grid["downbeats"] if grid["ok"] else grid["db_peaks"]
392
+ if not grid["ok"]:
393
+ grid = None
394
+ if bpm_override > 0:
395
+ bpm = bpm_override
396
+ if use_beat and (grid is None or abs(grid["bpm"] - bpm) > 0.5):
397
+ fixed_grid = fit_grid_fixed_bpm(models["beat"], mel, bpm, device=DEVICE)
398
+ grid = fixed_grid if fixed_grid is not None and fixed_grid["ok"] else None
399
+ if grid is not None:
400
+ dbs = grid["downbeats"]
401
+ elif grid is not None:
402
+ bpm = float(grid["bpm"])
403
+ elif dbs is not None and len(dbs) > 4:
404
+ period = float(np.median(np.diff(dbs)))
405
+ if period <= 0:
406
+ raise RuntimeError("The beat model returned an invalid downbeat interval.")
407
+ bpm = 240.0 / period
408
+ while bpm >= 210:
409
+ bpm /= 2.0
410
+ while bpm < 70:
411
+ bpm *= 2.0
412
+ else:
413
+ import librosa
414
+ bpm = float(np.atleast_1d(librosa.beat.beat_track(y=wav, sr=SR)[0])[0])
415
+ if not np.isfinite(bpm) or bpm <= 0:
416
+ raise RuntimeError("Could not determine a reliable BPM. Enter it in Advanced settings.")
417
+ if grid is None and abs(bpm - round(bpm)) < 0.06:
418
+ bpm = float(round(bpm))
419
+
420
+ yield _progress(
421
+ "plan", 0.34, "Shaping the arc",
422
+ "Planning density and climaxes.",
423
+ )
424
+ plan = None
425
+ if getattr(models["gen"], "_has_plan", False):
426
+ if use_planner:
427
+ plan = learned_plan(models["plan"], mel, course, bpm, dbs)
428
+ elif auto_plan_on:
429
+ plan = auto_plan(mel, bpm, dbs)
430
+
431
+ yield _progress(
432
+ "generate", 0.47, "Writing the chart",
433
+ "Writing playable Taiko patterns.",
434
+ )
435
+ title = os.path.splitext(wave_name)[0]
436
+ slot_used = grid is not None
437
+ if slot_used:
438
+ generated = generate_song_slot(
439
+ models["slot"], mel, grid, course, level=level,
440
+ density_bucket=COURSE_DENS[course], greedy=not sampling, seed=0,
441
+ temperature=temperature, top_p=top_p, device=DEVICE, plan=plan,
442
+ )
443
+ tja = write_tja_slots(
444
+ generated, grid, title, course, level, wave_name, plan=plan,
445
+ )
446
+ else:
447
+ generated = generate_song(
448
+ models["gen"], mel, course, level=level,
449
+ density_bucket=COURSE_DENS[course], greedy=not sampling,
450
+ temperature=temperature, top_p=top_p, seed=0,
451
+ device=DEVICE, plan=plan,
452
+ )
453
+ generated = snap_chart(generated, bpm)
454
+ tja = write_tja(
455
+ generated, bpm, title, course, level, wave_name, dbs,
456
+ grid_fit=grid, plan=plan,
457
+ )
458
+
459
+ yield _progress(
460
+ "export", 0.81, "Rendering",
461
+ "Building the TJA and previews.",
462
+ )
463
+ tja_path = output_tja_path(wave_name, course, workdir)
464
+ with open(tja_path, "w", encoding="utf-8") as output_file:
465
+ output_file.write(tja)
466
+ chart_image_path = os.path.join(workdir, "chart.png")
467
+ plan_image_path = os.path.join(workdir, "song-plan.png")
468
+ render_tja_image(tja, out_path=chart_image_path)
469
+ render_audio_plan(mel, title, course, plan=plan, out_path=plan_image_path)
470
+
471
+ yield _progress(
472
+ "mix", 0.91, "Mixing",
473
+ "Mixing Taiko with your track.",
474
+ )
475
+ stem = Path(tja_path).stem
476
+ preview_path = os.path.join(workdir, f"{stem}_taiko-preview.wav")
477
+ mix_stats = synthesize_taiko_preview(
478
+ audio_path,
479
+ tja,
480
+ preview_path,
481
+ drum_gain=drum_volume,
482
+ sample_rate=44100,
483
+ )
484
+
485
+ grid_rms = round(float(grid["rms_ms"]), 1) if grid is not None else None
486
+ metrics = {
487
+ "bpm": round(float(bpm), 1),
488
+ "notes": len(generated["hits"]),
489
+ "spans": len(generated["spans"]),
490
+ "timing": "slot-exact" if slot_used else "time-quantized",
491
+ "grid_rms_ms": grid_rms,
492
+ "preview_hits": int(mix_stats["rendered_hit_count"]),
493
+ }
494
+ yield {
495
+ "kind": "complete",
496
+ "stage": "complete",
497
+ "progress": 1.0,
498
+ "title": "Ready",
499
+ "detail": "Play it or download it.",
500
+ "metrics": metrics,
501
+ "files": {
502
+ "tja": _file_data(tja_path, mime_type="text/plain"),
503
+ "audio": _file_data(preview_path, mime_type="audio/wav"),
504
+ "chart_image": _file_data(chart_image_path, mime_type="image/png"),
505
+ "plan_image": _file_data(plan_image_path, mime_type="image/png"),
506
+ },
507
+ }
508
+ except Exception as exc:
509
+ LOGGER.exception("SoftChart generation failed")
510
+ detail = (str(exc) if isinstance(exc, (ValueError, RuntimeError))
511
+ else "The server could not complete this chart. Please try again shortly.")
512
+ yield {
513
+ "kind": "error",
514
+ "stage": "error",
515
+ "progress": 0.0,
516
+ "title": "Generation did not complete",
517
+ "detail": detail,
518
+ }
519
+ finally:
520
+ shutil.rmtree(workdir, ignore_errors=True)
521
+
522
 
523
  if __name__ == "__main__":
524
+ app.launch(max_file_size="200mb")
 
 
requirements.txt CHANGED
@@ -6,8 +6,8 @@ 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
12
- huggingface_hub>=0.26,<1.0
13
  safetensors
 
6
  soundfile
7
  matplotlib
8
  pillow
9
+ gradio==6.20.0
10
+ gradio_client==2.5.0
11
+ pydantic>=2.10,<3
12
+ huggingface_hub>=1.2,<2.0
13
  safetensors
softchart/preview_audio.py ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic game-style wadaiko preview synthesis.
2
+
3
+ The chart is the timing authority: :func:`parse_tja_events` converts the
4
+ repo-emitted TJA subset to audio-clock events, including OFFSET, measure-local
5
+ subdivisions, and piecewise BPM changes. Drum timbres are synthesized from
6
+ membrane/rim resonances and filtered noise; no recorded or proprietary game
7
+ assets are used.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import os
14
+ import wave
15
+ from dataclasses import dataclass
16
+ from fractions import Fraction
17
+
18
+ import numpy as np
19
+ from scipy.signal import butter, resample_poly, sosfilt
20
+
21
+
22
+ _HIT_KIND = {"1": "don", "2": "ka", "3": "don_big", "4": "ka_big"}
23
+ _SPAN_KIND = {"5": "roll", "6": "roll_big", "7": "balloon"}
24
+ _ALLOWED_COMMANDS = {"#GOGOSTART", "#GOGOEND"}
25
+ _PCM16_CEILING = int(math.floor(32767 * 10.0 ** (-1.0 / 20.0)))
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TaikoEvent:
30
+ """One chart event on the source-audio clock.
31
+
32
+ ``duration`` is non-zero for rolls and balloons. ``hit_count`` is one for
33
+ ordinary notes, the BALLOON header value for balloons, and zero for rolls
34
+ whose autoplay cadence is derived from their duration.
35
+ """
36
+
37
+ time: float
38
+ kind: str
39
+ duration: float = 0.0
40
+ hit_count: int = 1
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _OpenSpan:
45
+ time: float
46
+ kind: str
47
+ hit_count: int
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _Strike:
52
+ time: float
53
+ kind: str
54
+ pan: float
55
+ duck_depth: float
56
+ autoplay: bool
57
+
58
+
59
+ def _finite_float(value: str, field: str) -> float:
60
+ try:
61
+ result = float(value)
62
+ except (TypeError, ValueError) as exc:
63
+ raise ValueError(f"TJA {field} must be numeric, got {value!r}") from exc
64
+ if not math.isfinite(result):
65
+ raise ValueError(f"TJA {field} must be finite")
66
+ return result
67
+
68
+
69
+ def _strip_comment(line: str) -> str:
70
+ return line.split("//", 1)[0].strip()
71
+
72
+
73
+ def parse_tja_events(tja_text: str) -> list[TaikoEvent]:
74
+ """Parse the single-course TJA subset emitted by SoftChart.
75
+
76
+ Event times use the source-audio clock. TJA's OFFSET convention therefore
77
+ becomes ``audio_time = score_time - OFFSET``. Every measure is 4/4, and a
78
+ measure's character count defines its exact subdivision. ``#BPMCHANGE``
79
+ is accepted at measure boundaries, matching :mod:`softchart.tja`.
80
+
81
+ The parser is intentionally strict. A malformed/unterminated span or an
82
+ unsupported timing command raises ``ValueError`` instead of producing an
83
+ audibly plausible but incorrectly synchronized preview.
84
+ """
85
+
86
+ if not isinstance(tja_text, str) or not tja_text.strip():
87
+ raise ValueError("TJA text must be a non-empty string")
88
+
89
+ lines = [_strip_comment(line) for line in tja_text.splitlines()]
90
+ headers: dict[str, str] = {}
91
+ start_index: int | None = None
92
+ for index, line in enumerate(lines):
93
+ if not line:
94
+ continue
95
+ upper = line.upper()
96
+ if upper.startswith("#START"):
97
+ start_index = index + 1
98
+ break
99
+ if line.startswith("#"):
100
+ raise ValueError(f"unsupported command before #START: {line}")
101
+ if ":" in line:
102
+ key, value = line.split(":", 1)
103
+ headers[key.strip().upper()] = value.strip()
104
+
105
+ if start_index is None:
106
+ raise ValueError("TJA chart is missing #START")
107
+ if "BPM" not in headers:
108
+ raise ValueError("TJA chart is missing BPM")
109
+ if "OFFSET" not in headers:
110
+ raise ValueError("TJA chart is missing OFFSET")
111
+
112
+ bpm = _finite_float(headers["BPM"], "BPM")
113
+ offset = _finite_float(headers["OFFSET"], "OFFSET")
114
+ if bpm <= 0.0:
115
+ raise ValueError("TJA BPM must be greater than zero")
116
+
117
+ balloon_counts: list[int] = []
118
+ balloon_field = headers.get("BALLOON", "").strip()
119
+ if balloon_field:
120
+ try:
121
+ balloon_counts = [int(part.strip()) for part in balloon_field.split(",")]
122
+ except ValueError as exc:
123
+ raise ValueError("TJA BALLOON values must be integers") from exc
124
+ if any(count <= 0 for count in balloon_counts):
125
+ raise ValueError("TJA BALLOON values must be greater than zero")
126
+
127
+ score_time = 0.0
128
+ measure_buffer: list[str] = []
129
+ events: list[TaikoEvent] = []
130
+ open_span: _OpenSpan | None = None
131
+ balloon_index = 0
132
+ measure_count = 0
133
+ found_end = False
134
+
135
+ def finish_measure() -> None:
136
+ nonlocal score_time, open_span, balloon_index, measure_count
137
+ if not measure_buffer:
138
+ raise ValueError("TJA contains an empty measure")
139
+ measure = "".join(measure_buffer)
140
+ measure_buffer.clear()
141
+ division = len(measure)
142
+ measure_seconds = 240.0 / bpm
143
+
144
+ for position, symbol in enumerate(measure):
145
+ if symbol == "0":
146
+ continue
147
+ event_time = score_time + measure_seconds * position / division - offset
148
+ if symbol in _HIT_KIND:
149
+ events.append(TaikoEvent(time=event_time, kind=_HIT_KIND[symbol]))
150
+ elif symbol in _SPAN_KIND:
151
+ if open_span is not None:
152
+ raise ValueError("TJA contains overlapping roll/balloon spans")
153
+ hit_count = 0
154
+ if symbol == "7":
155
+ if balloon_index >= len(balloon_counts):
156
+ raise ValueError("TJA balloon has no matching BALLOON count")
157
+ hit_count = balloon_counts[balloon_index]
158
+ balloon_index += 1
159
+ open_span = _OpenSpan(event_time, _SPAN_KIND[symbol], hit_count)
160
+ elif symbol == "8":
161
+ if open_span is None:
162
+ raise ValueError("TJA contains a span end (8) without a start")
163
+ duration = event_time - open_span.time
164
+ if duration <= 0.0:
165
+ raise ValueError("TJA roll/balloon duration must be positive")
166
+ events.append(TaikoEvent(open_span.time, open_span.kind,
167
+ duration, open_span.hit_count))
168
+ open_span = None
169
+ else:
170
+ raise ValueError(f"unsupported TJA note symbol: {symbol}")
171
+
172
+ score_time += measure_seconds
173
+ measure_count += 1
174
+
175
+ for line in lines[start_index:]:
176
+ if not line:
177
+ continue
178
+ upper = line.upper()
179
+ if upper.startswith("#END"):
180
+ if measure_buffer:
181
+ raise ValueError("TJA measure before #END is missing a comma")
182
+ found_end = True
183
+ break
184
+ if upper.startswith("#BPMCHANGE"):
185
+ if measure_buffer:
186
+ raise ValueError("#BPMCHANGE is only supported at measure boundaries")
187
+ parts = line.split(maxsplit=1)
188
+ if len(parts) != 2:
189
+ raise ValueError("#BPMCHANGE requires a BPM value")
190
+ bpm = _finite_float(parts[1], "#BPMCHANGE")
191
+ if bpm <= 0.0:
192
+ raise ValueError("#BPMCHANGE BPM must be greater than zero")
193
+ continue
194
+ if upper in _ALLOWED_COMMANDS:
195
+ if measure_buffer:
196
+ raise ValueError(f"{line} is only supported at measure boundaries")
197
+ continue
198
+ if line.startswith("#"):
199
+ raise ValueError(f"unsupported command in preview TJA: {line}")
200
+
201
+ for char in line:
202
+ if char.isspace():
203
+ continue
204
+ if char == ",":
205
+ finish_measure()
206
+ elif char in "012345678":
207
+ measure_buffer.append(char)
208
+ else:
209
+ raise ValueError(f"unsupported character in TJA measure: {char!r}")
210
+
211
+ if not found_end:
212
+ raise ValueError("TJA chart is missing #END")
213
+ if measure_count == 0:
214
+ raise ValueError("TJA chart has no measures")
215
+ if open_span is not None:
216
+ raise ValueError("TJA roll/balloon span is missing its end marker (8)")
217
+ return sorted(events, key=lambda event: event.time)
218
+
219
+
220
+ def _filtered_noise(rng: np.random.Generator, length: int, sample_rate: int,
221
+ low_hz: float, high_hz: float) -> np.ndarray:
222
+ nyquist = sample_rate / 2.0
223
+ low = max(20.0, min(low_hz, nyquist * 0.90))
224
+ high = max(low + 20.0, min(high_hz, nyquist * 0.97))
225
+ sos = butter(3, (low, high), btype="bandpass", fs=sample_rate, output="sos")
226
+ noise = sosfilt(sos, rng.standard_normal(length)).astype(np.float64)
227
+ rms = float(np.sqrt(np.mean(noise * noise)))
228
+ return noise / max(rms, 1e-12)
229
+
230
+
231
+ def _falling_mode(t: np.ndarray, start_hz: float, end_hz: float,
232
+ pitch_tau: float, decay: float) -> np.ndarray:
233
+ phase = 2.0 * np.pi * (
234
+ end_hz * t + (start_hz - end_hz) * pitch_tau * (1.0 - np.exp(-t / pitch_tau))
235
+ )
236
+ return np.sin(phase) * np.exp(-t / decay)
237
+
238
+
239
+ def _add_reflection(signal: np.ndarray, sample_rate: int, delay_seconds: float,
240
+ gain: float) -> None:
241
+ delay = max(1, int(round(delay_seconds * sample_rate)))
242
+ if delay < len(signal):
243
+ signal[delay:] += gain * signal[:-delay]
244
+
245
+
246
+ def _make_don(sample_rate: int, variant: int, big: bool = False,
247
+ tap: bool = False) -> np.ndarray:
248
+ duration = 0.19 if tap else (0.62 if big else 0.43)
249
+ n = int(round(duration * sample_rate))
250
+ t = np.arange(n, dtype=np.float64) / sample_rate
251
+ rng = np.random.default_rng(12_710 + 101 * variant + 1_009 * int(big) + 7_003 * int(tap))
252
+ pitch = (0.965, 0.985, 1.010, 1.030)[variant % 4] * (0.84 if big else 1.0)
253
+ if tap:
254
+ pitch *= 1.10
255
+ attack = 1.0 - np.exp(-t / 0.00075)
256
+
257
+ modes = np.zeros(n, dtype=np.float64)
258
+ frequencies = (104.0, 166.0, 248.0, 362.0)
259
+ weights = (1.00, 0.54, 0.31, 0.17)
260
+ decays = (0.27, 0.19, 0.125, 0.080)
261
+ decay_scale = 0.55 if tap else (1.35 if big else 1.0)
262
+ for frequency, weight, decay in zip(frequencies, weights, decays):
263
+ end_hz = frequency * pitch
264
+ modes += weight * _falling_mode(
265
+ t, end_hz * (1.24 + 0.015 * variant), end_hz,
266
+ 0.017 if tap else 0.026, decay * decay_scale,
267
+ )
268
+
269
+ skin = _filtered_noise(rng, n, sample_rate, 150.0, 3_600.0)
270
+ skin *= np.exp(-t / (0.012 if tap else 0.021))
271
+ stick = _filtered_noise(rng, n, sample_rate, 900.0, 7_200.0)
272
+ stick *= np.exp(-t / 0.0045)
273
+ signal = attack * modes + 0.26 * skin + 0.075 * stick
274
+ if big:
275
+ signal += 0.34 * _falling_mode(t, 89.0, 66.0, 0.038, 0.38)
276
+ _add_reflection(signal, sample_rate, 0.0083, 0.075 if tap else 0.11)
277
+ _add_reflection(signal, sample_rate, 0.0171, 0.035 if tap else 0.065)
278
+ signal *= np.exp(-np.maximum(t - duration * 0.78, 0.0) / 0.035)
279
+ target = 0.43 if tap else (0.94 if big else 0.78)
280
+ signal *= target / max(float(np.max(np.abs(signal))), 1e-12)
281
+ return signal.astype(np.float32)
282
+
283
+
284
+ def _make_ka(sample_rate: int, variant: int, big: bool = False) -> np.ndarray:
285
+ duration = 0.35 if big else 0.25
286
+ n = int(round(duration * sample_rate))
287
+ t = np.arange(n, dtype=np.float64) / sample_rate
288
+ rng = np.random.default_rng(37_901 + 131 * variant + 2_003 * int(big))
289
+ pitch = (0.970, 0.992, 1.015, 1.035)[variant % 4] * (0.90 if big else 1.0)
290
+ attack = 1.0 - np.exp(-t / 0.00035)
291
+
292
+ rim = _filtered_noise(rng, n, sample_rate, 620.0, 9_200.0)
293
+ rim *= np.exp(-t / (0.024 if big else 0.016))
294
+ modes = np.zeros(n, dtype=np.float64)
295
+ for frequency, weight, decay in (
296
+ (535.0, 0.48, 0.105), (885.0, 0.36, 0.074),
297
+ (1_410.0, 0.27, 0.050), (2_270.0, 0.16, 0.032),
298
+ ):
299
+ phase = 2.0 * np.pi * frequency * pitch * t + variant * 0.31
300
+ modes += weight * np.sin(phase) * np.exp(-t / (decay * (1.25 if big else 1.0)))
301
+ signal = 0.72 * rim + attack * modes
302
+ _add_reflection(signal, sample_rate, 0.0047, 0.16)
303
+ _add_reflection(signal, sample_rate, 0.0119, 0.085 if big else 0.060)
304
+ signal *= np.exp(-np.maximum(t - duration * 0.72, 0.0) / 0.025)
305
+ target = 0.90 if big else 0.70
306
+ signal *= target / max(float(np.max(np.abs(signal))), 1e-12)
307
+ return signal.astype(np.float32)
308
+
309
+
310
+ _BANKS: dict[int, dict[str, tuple[np.ndarray, ...]]] = {}
311
+
312
+
313
+ def _sample_bank(sample_rate: int) -> dict[str, tuple[np.ndarray, ...]]:
314
+ bank = _BANKS.get(sample_rate)
315
+ if bank is None:
316
+ bank = {
317
+ "don": tuple(_make_don(sample_rate, i) for i in range(4)),
318
+ "ka": tuple(_make_ka(sample_rate, i) for i in range(4)),
319
+ "don_big": tuple(_make_don(sample_rate, i, big=True) for i in range(4)),
320
+ "ka_big": tuple(_make_ka(sample_rate, i, big=True) for i in range(4)),
321
+ "roll": tuple(_make_don(sample_rate, i, tap=True) for i in range(4)),
322
+ "roll_big": tuple(_make_don(sample_rate, i, big=True, tap=True) for i in range(4)),
323
+ "balloon": tuple(_make_don(sample_rate, i, tap=True) for i in range(4)),
324
+ }
325
+ _BANKS[sample_rate] = bank
326
+ return bank
327
+
328
+
329
+ def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
330
+ strikes: list[_Strike] = []
331
+ autoplay_count = 0
332
+ ordinary_index = 0
333
+ for event in events:
334
+ if event.kind in {"don", "ka", "don_big", "ka_big"}:
335
+ if event.kind == "ka":
336
+ pan = -0.28 if ordinary_index % 2 == 0 else 0.28
337
+ elif event.kind == "ka_big":
338
+ pan = -0.10 if ordinary_index % 2 == 0 else 0.10
339
+ else:
340
+ pan = -0.035 if ordinary_index % 2 == 0 else 0.035
341
+ depth = 0.20 if event.kind.endswith("_big") else 0.145
342
+ strikes.append(_Strike(event.time, event.kind, pan, depth, False))
343
+ ordinary_index += 1
344
+ continue
345
+
346
+ if event.kind == "balloon":
347
+ times = event.time + np.arange(event.hit_count, dtype=np.float64) * (
348
+ event.duration / event.hit_count
349
+ )
350
+ kind = "balloon"
351
+ depth = 0.070
352
+ else:
353
+ interval = 0.074 if event.kind == "roll_big" else 0.086
354
+ count = max(1, int(math.ceil(max(0.0, event.duration - 0.018) / interval)))
355
+ times = event.time + np.arange(count, dtype=np.float64) * interval
356
+ times = times[times < event.time + event.duration - 0.008]
357
+ if len(times) == 0:
358
+ times = np.asarray([event.time])
359
+ kind = event.kind
360
+ depth = 0.085 if event.kind == "roll_big" else 0.060
361
+ for index, time in enumerate(times):
362
+ pan_width = 0.24 if kind == "roll_big" else 0.18
363
+ pan = -pan_width if index % 2 == 0 else pan_width
364
+ strikes.append(_Strike(float(time), kind, pan, depth, True))
365
+ autoplay_count += 1
366
+ strikes.sort(key=lambda strike: strike.time)
367
+ return strikes, autoplay_count
368
+
369
+
370
+ def _load_stereo_audio(audio_path: str, sample_rate: int) -> np.ndarray:
371
+ import soundfile as sf
372
+
373
+ audio, source_rate = sf.read(audio_path, dtype="float32", always_2d=True)
374
+ if source_rate <= 0 or audio.shape[0] == 0:
375
+ raise ValueError("input audio must contain at least one sample")
376
+ if not np.all(np.isfinite(audio)):
377
+ raise ValueError("input audio contains non-finite samples")
378
+
379
+ channels = audio.shape[1]
380
+ if channels == 1:
381
+ audio = np.repeat(audio, 2, axis=1)
382
+ elif channels > 2:
383
+ ambience = np.mean(audio[:, 2:], axis=1)
384
+ left = (audio[:, 0] + 0.5 * ambience) / 1.5
385
+ right = (audio[:, 1] + 0.5 * ambience) / 1.5
386
+ audio = np.stack((left, right), axis=1)
387
+ else:
388
+ audio = audio[:, :2]
389
+
390
+ if source_rate != sample_rate:
391
+ ratio = Fraction(sample_rate, int(source_rate)).limit_denominator()
392
+ expected_frames = int(round(len(audio) * sample_rate / source_rate))
393
+ audio = resample_poly(
394
+ audio, ratio.numerator, ratio.denominator, axis=0,
395
+ window=("kaiser", 10.0), padtype="line",
396
+ )
397
+ if len(audio) < expected_frames:
398
+ audio = np.pad(audio, ((0, expected_frames - len(audio)), (0, 0)))
399
+ audio = audio[:expected_frames]
400
+ return np.ascontiguousarray(audio, dtype=np.float32)
401
+
402
+
403
+ def _place_strike(drums: np.ndarray, sample: np.ndarray, strike: _Strike,
404
+ sample_rate: int) -> bool:
405
+ start = int(round(strike.time * sample_rate))
406
+ sample_start = max(0, -start)
407
+ output_start = max(0, start)
408
+ count = min(len(sample) - sample_start, len(drums) - output_start)
409
+ if count <= 0:
410
+ return False
411
+
412
+ angle = (float(np.clip(strike.pan, -1.0, 1.0)) + 1.0) * np.pi / 4.0
413
+ left_gain, right_gain = math.cos(angle), math.sin(angle)
414
+ segment = sample[sample_start:sample_start + count]
415
+ drums[output_start:output_start + count, 0] += segment * left_gain
416
+ drums[output_start:output_start + count, 1] += segment * right_gain
417
+
418
+ # A quiet, opposite-side early reflection creates believable width while
419
+ # preserving a precise, dry transient at the chart time.
420
+ reflection_delay = int(round(0.011 * sample_rate))
421
+ reflected_start = output_start + reflection_delay
422
+ reflected_count = min(count, len(drums) - reflected_start)
423
+ if reflected_count > 0:
424
+ reflected = segment[:reflected_count] * 0.095
425
+ drums[reflected_start:reflected_start + reflected_count, 0] += reflected * right_gain
426
+ drums[reflected_start:reflected_start + reflected_count, 1] += reflected * left_gain
427
+ return True
428
+
429
+
430
+ def _duck_envelope(frame_count: int, strikes: list[_Strike], sample_rate: int,
431
+ drum_gain: float) -> tuple[np.ndarray, float]:
432
+ reduction = np.zeros(frame_count, dtype=np.float32)
433
+ attack_frames = max(1, int(round(0.006 * sample_rate)))
434
+ release_frames = max(1, int(round(0.115 * sample_rate)))
435
+ attack = 0.5 - 0.5 * np.cos(np.linspace(0.0, np.pi, attack_frames, dtype=np.float32))
436
+ release = np.exp(-np.arange(release_frames, dtype=np.float32) /
437
+ max(1.0, 0.042 * sample_rate))
438
+ shape = np.concatenate((attack, release))
439
+ gain_scale = min(drum_gain / 0.8, 1.0) if drum_gain > 0.0 else 0.0
440
+
441
+ for strike in strikes:
442
+ hit_frame = int(round(strike.time * sample_rate))
443
+ start = hit_frame - attack_frames
444
+ source_start = max(0, -start)
445
+ output_start = max(0, start)
446
+ count = min(len(shape) - source_start, frame_count - output_start)
447
+ if count <= 0:
448
+ continue
449
+ envelope = shape[source_start:source_start + count] * strike.duck_depth * gain_scale
450
+ target = reduction[output_start:output_start + count]
451
+ np.maximum(target, envelope, out=target)
452
+
453
+ np.minimum(reduction, 0.32, out=reduction)
454
+ gain = 1.0 - reduction
455
+ max_duck_db = -20.0 * math.log10(max(float(np.min(gain)), 1e-12))
456
+ return gain, max_duck_db
457
+
458
+
459
+ def _write_pcm16_wav(out_path: str, pcm: np.ndarray, sample_rate: int) -> None:
460
+ parent = os.path.dirname(os.path.abspath(out_path))
461
+ os.makedirs(parent, exist_ok=True)
462
+ with wave.open(out_path, "wb") as wav:
463
+ wav.setnchannels(2)
464
+ wav.setsampwidth(2)
465
+ wav.setframerate(sample_rate)
466
+ wav.writeframes(np.ascontiguousarray(pcm.astype("<i2", copy=False)).tobytes())
467
+
468
+
469
+ def synthesize_taiko_preview(
470
+ audio_path: str,
471
+ tja_text: str,
472
+ out_path: str,
473
+ drum_gain: float = 0.8,
474
+ sample_rate: int = 44_100,
475
+ ) -> dict[str, float | int]:
476
+ """Mix a synchronized stereo PCM16 WAV preview and return numeric stats.
477
+
478
+ Music is held at 2 dB of mix headroom and briefly ducked under each drum
479
+ transient. The final mix uses transparent global peak reduction when
480
+ needed and is quantized below a -1 dBFS sample-peak ceiling.
481
+ """
482
+
483
+ if not isinstance(sample_rate, int) or sample_rate < 16_000 or sample_rate > 192_000:
484
+ raise ValueError("sample_rate must be an integer from 16000 to 192000")
485
+ if not math.isfinite(drum_gain) or not 0.0 <= drum_gain <= 2.0:
486
+ raise ValueError("drum_gain must be finite and between 0.0 and 2.0")
487
+ if not out_path:
488
+ raise ValueError("out_path must be provided")
489
+
490
+ events = parse_tja_events(tja_text)
491
+ music = _load_stereo_audio(audio_path, sample_rate)
492
+ strikes, autoplay_count = _expand_strikes(events)
493
+ bank = _sample_bank(sample_rate)
494
+ source_frames = len(music)
495
+ output_frames = source_frames
496
+ for index, strike in enumerate(strikes):
497
+ sample = bank[strike.kind][index % len(bank[strike.kind])]
498
+ output_frames = max(
499
+ output_frames,
500
+ max(0, int(round(strike.time * sample_rate)) + len(sample)),
501
+ )
502
+ if output_frames > source_frames:
503
+ music = np.pad(music, ((0, output_frames - source_frames), (0, 0)))
504
+ drums = np.zeros_like(music)
505
+ audible_strikes: list[_Strike] = []
506
+
507
+ for index, strike in enumerate(strikes):
508
+ samples = bank[strike.kind]
509
+ sample = samples[index % len(samples)]
510
+ if _place_strike(drums, sample, strike, sample_rate):
511
+ audible_strikes.append(strike)
512
+
513
+ duck_gain, max_duck_db = _duck_envelope(
514
+ len(music), audible_strikes, sample_rate, drum_gain,
515
+ )
516
+ music_gain = 10.0 ** (-2.0 / 20.0)
517
+ mixed = music * (music_gain * duck_gain[:, None])
518
+ mixed += drums * float(drum_gain)
519
+
520
+ pre_limit_peak = float(np.max(np.abs(mixed))) if mixed.size else 0.0
521
+ float_ceiling = _PCM16_CEILING / 32767.0
522
+ limiter_gain = min(1.0, float_ceiling / pre_limit_peak) if pre_limit_peak > 0.0 else 1.0
523
+ mixed *= limiter_gain
524
+ np.clip(mixed, -float_ceiling, float_ceiling, out=mixed)
525
+ pcm = np.rint(mixed * 32767.0).astype(np.int16)
526
+ _write_pcm16_wav(out_path, pcm, sample_rate)
527
+
528
+ pcm_peak = int(np.max(np.abs(pcm.astype(np.int32)))) if pcm.size else 0
529
+ peak_dbfs = 20.0 * math.log10(pcm_peak / 32768.0) if pcm_peak else -120.0
530
+ gain_reduction_db = -20.0 * math.log10(max(limiter_gain, 1e-12))
531
+ audible_autoplay = sum(1 for strike in audible_strikes if strike.autoplay)
532
+ return {
533
+ "sample_rate": sample_rate,
534
+ "frames": len(music),
535
+ "duration_seconds": len(music) / sample_rate,
536
+ "source_frames": source_frames,
537
+ "event_count": len(events),
538
+ "rendered_hit_count": len(audible_strikes),
539
+ "autoplay_hit_count": audible_autoplay,
540
+ "scheduled_autoplay_hit_count": autoplay_count,
541
+ "peak_dbfs": peak_dbfs,
542
+ "gain_reduction_db": gain_reduction_db,
543
+ "max_duck_db": max_duck_db,
544
+ }
545
+
546
+
547
+ __all__ = ["TaikoEvent", "parse_tja_events", "synthesize_taiko_preview"]
static/app.css ADDED
@@ -0,0 +1,1694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @charset "UTF-8";
2
+
3
+ :root {
4
+ color-scheme: light;
5
+ --paper: #f6f3ec;
6
+ --paper-deep: #eee9df;
7
+ --surface: rgba(255, 253, 248, 0.92);
8
+ --surface-solid: #fffdf8;
9
+ --ink: #1f2023;
10
+ --ink-soft: #626166;
11
+ --ink-faint: #8f8c89;
12
+ --line: #ded9cf;
13
+ --line-strong: #c9c2b7;
14
+ --red: #d9473e;
15
+ --red-deep: #ac2e2c;
16
+ --red-soft: #f7ddda;
17
+ --blue: #276f91;
18
+ --blue-soft: #d9ecf4;
19
+ --green: #2d7657;
20
+ --green-soft: #dcece3;
21
+ --shadow: 0 18px 60px rgba(52, 42, 31, 0.08);
22
+ --radius-lg: 26px;
23
+ --radius-md: 16px;
24
+ --radius-sm: 10px;
25
+ --ease: cubic-bezier(.22, .8, .25, 1);
26
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif;
27
+ font-synthesis: none;
28
+ }
29
+
30
+ * {
31
+ box-sizing: border-box;
32
+ }
33
+
34
+ html {
35
+ scroll-behavior: smooth;
36
+ background: var(--paper);
37
+ }
38
+
39
+ body {
40
+ min-width: 320px;
41
+ margin: 0;
42
+ color: var(--ink);
43
+ background:
44
+ radial-gradient(circle at 11% 2%, rgba(217, 71, 62, .06), transparent 28rem),
45
+ radial-gradient(circle at 95% 28%, rgba(39, 111, 145, .055), transparent 30rem),
46
+ var(--paper);
47
+ line-height: 1.55;
48
+ -webkit-font-smoothing: antialiased;
49
+ }
50
+
51
+ body::before {
52
+ position: fixed;
53
+ z-index: -1;
54
+ inset: 0;
55
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.035'/%3E%3C/svg%3E");
56
+ content: "";
57
+ pointer-events: none;
58
+ }
59
+
60
+ button,
61
+ input,
62
+ summary {
63
+ font: inherit;
64
+ }
65
+
66
+ button,
67
+ summary,
68
+ label,
69
+ [role="button"] {
70
+ -webkit-tap-highlight-color: transparent;
71
+ }
72
+
73
+ button,
74
+ [role="button"],
75
+ summary {
76
+ cursor: pointer;
77
+ }
78
+
79
+ button:focus-visible,
80
+ input:focus-visible,
81
+ summary:focus-visible,
82
+ a:focus-visible,
83
+ [role="button"]:focus-visible {
84
+ outline: 3px solid rgba(39, 111, 145, .35);
85
+ outline-offset: 3px;
86
+ }
87
+
88
+ img {
89
+ display: block;
90
+ max-width: 100%;
91
+ }
92
+
93
+ [hidden] {
94
+ display: none !important;
95
+ }
96
+
97
+ .sr-only {
98
+ position: absolute !important;
99
+ width: 1px !important;
100
+ height: 1px !important;
101
+ padding: 0 !important;
102
+ margin: -1px !important;
103
+ overflow: hidden !important;
104
+ clip: rect(0, 0, 0, 0) !important;
105
+ white-space: nowrap !important;
106
+ border: 0 !important;
107
+ }
108
+
109
+ .skip-link {
110
+ position: absolute;
111
+ z-index: 100;
112
+ top: 10px;
113
+ left: 10px;
114
+ padding: 9px 14px;
115
+ color: white;
116
+ background: var(--ink);
117
+ border-radius: 8px;
118
+ transform: translateY(-160%);
119
+ transition: transform .2s;
120
+ }
121
+
122
+ .skip-link:focus {
123
+ transform: translateY(0);
124
+ }
125
+
126
+ .site-header {
127
+ width: min(1420px, calc(100% - 48px));
128
+ height: 86px;
129
+ margin: 0 auto;
130
+ display: flex;
131
+ align-items: center;
132
+ justify-content: space-between;
133
+ border-bottom: 1px solid rgba(31, 32, 35, .12);
134
+ }
135
+
136
+ .brand {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ gap: 10px;
140
+ color: var(--ink);
141
+ text-decoration: none;
142
+ font-size: 19px;
143
+ font-weight: 760;
144
+ letter-spacing: -.03em;
145
+ }
146
+
147
+ .brand-mark {
148
+ width: 38px;
149
+ height: 38px;
150
+ color: var(--red);
151
+ }
152
+
153
+ .brand-edition {
154
+ margin-left: 2px;
155
+ padding-left: 12px;
156
+ color: var(--ink-faint);
157
+ border-left: 1px solid var(--line-strong);
158
+ font-size: 9px;
159
+ font-weight: 720;
160
+ letter-spacing: .18em;
161
+ }
162
+
163
+ .connection {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+ color: var(--ink-soft);
168
+ font-size: 12px;
169
+ font-weight: 650;
170
+ }
171
+
172
+ .connection-dot {
173
+ width: 7px;
174
+ height: 7px;
175
+ background: #d39334;
176
+ border-radius: 99px;
177
+ box-shadow: 0 0 0 4px rgba(211, 147, 52, .12);
178
+ animation: connection-pulse 1.6s ease-in-out infinite;
179
+ }
180
+
181
+ .connection.is-ready .connection-dot {
182
+ background: var(--green);
183
+ box-shadow: 0 0 0 4px rgba(45, 118, 87, .12);
184
+ animation: none;
185
+ }
186
+
187
+ .connection.is-error .connection-dot {
188
+ background: var(--red);
189
+ box-shadow: 0 0 0 4px rgba(217, 71, 62, .12);
190
+ animation: none;
191
+ }
192
+
193
+ main {
194
+ width: min(1420px, calc(100% - 48px));
195
+ margin: 0 auto;
196
+ }
197
+
198
+ .hero {
199
+ display: block;
200
+ padding: 96px 2.5vw 82px;
201
+ }
202
+
203
+ .eyebrow,
204
+ .section-kicker,
205
+ .process-eyebrow {
206
+ margin: 0;
207
+ color: var(--red);
208
+ font-size: 10px;
209
+ line-height: 1.2;
210
+ font-weight: 800;
211
+ letter-spacing: .17em;
212
+ }
213
+
214
+ .eyebrow span {
215
+ padding: 0 5px;
216
+ color: var(--line-strong);
217
+ }
218
+
219
+ .hero h1 {
220
+ max-width: 1080px;
221
+ margin: 0;
222
+ font-family: "Noto Serif TC", "Songti TC", "PMingLiU", serif;
223
+ font-size: clamp(64px, 8vw, 112px);
224
+ font-weight: 760;
225
+ line-height: .92;
226
+ letter-spacing: -.065em;
227
+ }
228
+
229
+ .hero h1 em {
230
+ color: var(--red);
231
+ font-style: normal;
232
+ }
233
+
234
+ .hero-lede {
235
+ max-width: 540px;
236
+ margin: 28px 0 0;
237
+ color: var(--ink-soft);
238
+ font-size: clamp(16px, 1.5vw, 20px);
239
+ font-weight: 650;
240
+ }
241
+
242
+ .hero-features {
243
+ display: flex;
244
+ flex-wrap: wrap;
245
+ gap: 13px 28px;
246
+ padding: 0;
247
+ margin: 44px 0 0;
248
+ list-style: none;
249
+ }
250
+
251
+ .hero-features li {
252
+ position: relative;
253
+ display: flex;
254
+ gap: 10px;
255
+ align-items: center;
256
+ padding-left: 17px;
257
+ }
258
+
259
+ .hero-features li::before {
260
+ position: absolute;
261
+ left: 0;
262
+ width: 6px;
263
+ height: 6px;
264
+ background: var(--red);
265
+ border-radius: 50%;
266
+ content: "";
267
+ }
268
+
269
+ .hero-features strong {
270
+ color: var(--ink);
271
+ font-size: 11px;
272
+ font-weight: 800;
273
+ letter-spacing: .13em;
274
+ text-transform: uppercase;
275
+ }
276
+
277
+ .studio-grid {
278
+ display: grid;
279
+ grid-template-columns: minmax(380px, 460px) minmax(0, 1fr);
280
+ gap: 28px;
281
+ align-items: start;
282
+ padding-bottom: 100px;
283
+ }
284
+
285
+ .panel {
286
+ background: var(--surface);
287
+ border: 1.5px solid rgba(31, 32, 35, .12);
288
+ border-radius: 26px;
289
+ box-shadow: var(--shadow);
290
+ backdrop-filter: blur(18px);
291
+ }
292
+
293
+ .setup-panel,
294
+ .process-panel,
295
+ .result-panel {
296
+ padding: clamp(28px, 2.7vw, 38px);
297
+ }
298
+
299
+ .setup-panel {
300
+ position: sticky;
301
+ top: 20px;
302
+ }
303
+
304
+ .output-column {
305
+ display: grid;
306
+ gap: 22px;
307
+ }
308
+
309
+ .panel-heading {
310
+ display: flex;
311
+ justify-content: space-between;
312
+ align-items: flex-start;
313
+ gap: 20px;
314
+ margin-bottom: 22px;
315
+ }
316
+
317
+ .panel-heading h2 {
318
+ margin: 0;
319
+ font-family: "Noto Serif TC", "Songti TC", "PMingLiU", serif;
320
+ font-size: clamp(28px, 2.7vw, 38px);
321
+ line-height: 1.08;
322
+ letter-spacing: -.045em;
323
+ }
324
+
325
+ .step-chip,
326
+ .status-chip {
327
+ flex: none;
328
+ padding: 7px 10px;
329
+ color: var(--ink-faint);
330
+ background: var(--paper-deep);
331
+ border-radius: 99px;
332
+ font-size: 9px;
333
+ line-height: 1;
334
+ font-weight: 800;
335
+ letter-spacing: .13em;
336
+ }
337
+
338
+ .status-chip.is-active {
339
+ color: var(--blue);
340
+ background: var(--blue-soft);
341
+ }
342
+
343
+ .status-chip.is-complete,
344
+ .complete-chip {
345
+ color: var(--green);
346
+ background: var(--green-soft);
347
+ }
348
+
349
+ .status-chip.is-error {
350
+ color: var(--red-deep);
351
+ background: var(--red-soft);
352
+ }
353
+
354
+ .form-section {
355
+ padding: 2px 0 20px;
356
+ }
357
+
358
+ .split-rule {
359
+ padding-top: 20px;
360
+ border-top: 1px solid var(--line);
361
+ }
362
+
363
+ .label-row {
364
+ display: flex;
365
+ align-items: baseline;
366
+ justify-content: space-between;
367
+ gap: 12px;
368
+ margin-bottom: 10px;
369
+ }
370
+
371
+ label,
372
+ legend,
373
+ .group-label {
374
+ color: var(--ink);
375
+ font-size: 13px;
376
+ font-weight: 700;
377
+ }
378
+
379
+ .field-hint,
380
+ .field-note {
381
+ color: var(--ink-faint);
382
+ font-size: 10.5px;
383
+ }
384
+
385
+ .dropzone {
386
+ position: relative;
387
+ display: flex;
388
+ min-height: 190px;
389
+ flex-direction: column;
390
+ align-items: center;
391
+ justify-content: center;
392
+ color: var(--ink-soft);
393
+ background:
394
+ radial-gradient(circle at 50% 30%, rgba(217, 71, 62, .12), transparent 58%),
395
+ rgba(246, 243, 236, .72);
396
+ border: 1.5px solid rgba(217, 71, 62, .2);
397
+ border-radius: var(--radius-md);
398
+ transition: color .22s, background-color .22s, transform .22s var(--ease);
399
+ }
400
+
401
+ .dropzone:hover,
402
+ .dropzone.is-dragging {
403
+ color: var(--red);
404
+ background-color: rgba(247, 221, 218, .38);
405
+ transform: translateY(-2px);
406
+ }
407
+
408
+ #file-input {
409
+ position: absolute;
410
+ width: 1px;
411
+ height: 1px;
412
+ opacity: 0;
413
+ pointer-events: none;
414
+ }
415
+
416
+ .upload-icon {
417
+ width: 54px;
418
+ height: 54px;
419
+ margin-bottom: 10px;
420
+ }
421
+
422
+ .drop-title {
423
+ color: var(--ink);
424
+ font-size: 16px;
425
+ font-weight: 700;
426
+ }
427
+
428
+ .drop-subtitle {
429
+ margin-top: 2px;
430
+ color: var(--ink-faint);
431
+ font-size: 11px;
432
+ }
433
+
434
+ .source-card {
435
+ padding: 14px;
436
+ background: var(--paper);
437
+ border: 1px solid var(--line);
438
+ border-radius: var(--radius-md);
439
+ }
440
+
441
+ .source-info {
442
+ display: grid;
443
+ grid-template-columns: 38px minmax(0, 1fr) 32px;
444
+ gap: 10px;
445
+ align-items: center;
446
+ margin-bottom: 10px;
447
+ }
448
+
449
+ .file-badge {
450
+ display: grid;
451
+ width: 38px;
452
+ height: 38px;
453
+ place-items: center;
454
+ color: var(--red);
455
+ background: var(--red-soft);
456
+ border-radius: 50%;
457
+ font-weight: 800;
458
+ }
459
+
460
+ .file-copy {
461
+ min-width: 0;
462
+ }
463
+
464
+ .file-copy strong,
465
+ .file-copy span {
466
+ display: block;
467
+ overflow: hidden;
468
+ text-overflow: ellipsis;
469
+ white-space: nowrap;
470
+ }
471
+
472
+ .file-copy strong {
473
+ font-size: 12px;
474
+ }
475
+
476
+ .file-copy span {
477
+ color: var(--ink-faint);
478
+ font-size: 10px;
479
+ }
480
+
481
+ .icon-button {
482
+ display: grid;
483
+ width: 32px;
484
+ height: 32px;
485
+ padding: 0;
486
+ place-items: center;
487
+ color: var(--ink-soft);
488
+ background: transparent;
489
+ border: 0;
490
+ border-radius: 50%;
491
+ }
492
+
493
+ .icon-button:hover {
494
+ color: var(--red);
495
+ background: var(--red-soft);
496
+ }
497
+
498
+ .icon-button svg {
499
+ width: 18px;
500
+ fill: none;
501
+ stroke: currentColor;
502
+ stroke-width: 1.8;
503
+ stroke-linecap: round;
504
+ }
505
+
506
+ audio {
507
+ display: block;
508
+ width: 100%;
509
+ height: 38px;
510
+ color-scheme: light;
511
+ }
512
+
513
+ .field-error {
514
+ margin: 8px 0 0;
515
+ color: var(--red-deep);
516
+ font-size: 11px;
517
+ font-weight: 650;
518
+ }
519
+
520
+ .field-group {
521
+ min-width: 0;
522
+ padding: 0;
523
+ margin: 0;
524
+ border: 0;
525
+ }
526
+
527
+ .field-group legend {
528
+ padding: 0;
529
+ margin-bottom: 11px;
530
+ }
531
+
532
+ .course-selector {
533
+ display: grid;
534
+ grid-template-columns: repeat(4, 1fr);
535
+ padding: 4px;
536
+ background: var(--paper-deep);
537
+ border-radius: 13px;
538
+ }
539
+
540
+ .course-selector label {
541
+ position: relative;
542
+ }
543
+
544
+ .course-selector input {
545
+ position: absolute;
546
+ opacity: 0;
547
+ }
548
+
549
+ .course-selector span {
550
+ display: flex;
551
+ min-height: 55px;
552
+ flex-direction: column;
553
+ align-items: center;
554
+ justify-content: center;
555
+ color: var(--ink-soft);
556
+ border: 1px solid transparent;
557
+ border-radius: 10px;
558
+ font-size: 12px;
559
+ transition: color .18s, background .18s, box-shadow .18s;
560
+ }
561
+
562
+ .course-selector small {
563
+ margin-bottom: 1px;
564
+ color: var(--ink-faint);
565
+ font-family: "Noto Serif TC", "Songti TC", serif;
566
+ font-size: 9px;
567
+ font-weight: 650;
568
+ }
569
+
570
+ .course-selector input:checked + span {
571
+ color: var(--red-deep);
572
+ background: var(--surface-solid);
573
+ border-color: rgba(217, 71, 62, .14);
574
+ box-shadow: 0 4px 14px rgba(52, 42, 31, .07);
575
+ }
576
+
577
+ .course-selector input:focus-visible + span {
578
+ outline: 3px solid rgba(39, 111, 145, .35);
579
+ outline-offset: 2px;
580
+ }
581
+
582
+ .two-column-fields,
583
+ .parameter-grid {
584
+ display: grid;
585
+ grid-template-columns: 1fr 1fr;
586
+ gap: 24px;
587
+ margin-top: 24px;
588
+ }
589
+
590
+ .value-output {
591
+ color: var(--red-deep);
592
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
593
+ font-size: 11px;
594
+ font-weight: 750;
595
+ }
596
+
597
+ .range {
598
+ --range-progress: 88.89%;
599
+ width: 100%;
600
+ height: 18px;
601
+ padding: 0;
602
+ margin: 3px 0;
603
+ appearance: none;
604
+ background: transparent;
605
+ }
606
+
607
+ .range::-webkit-slider-runnable-track {
608
+ height: 3px;
609
+ background: linear-gradient(90deg, var(--red) var(--range-progress), var(--line) var(--range-progress));
610
+ border-radius: 99px;
611
+ }
612
+
613
+ .range::-moz-range-track {
614
+ height: 3px;
615
+ background: linear-gradient(90deg, var(--red) var(--range-progress), var(--line) var(--range-progress));
616
+ border-radius: 99px;
617
+ }
618
+
619
+ .range::-webkit-slider-thumb {
620
+ width: 17px;
621
+ height: 17px;
622
+ margin-top: -7px;
623
+ appearance: none;
624
+ background: var(--surface-solid);
625
+ border: 4px solid var(--red);
626
+ border-radius: 50%;
627
+ box-shadow: 0 1px 5px rgba(31, 32, 35, .2);
628
+ }
629
+
630
+ .range::-moz-range-thumb {
631
+ width: 10px;
632
+ height: 10px;
633
+ background: var(--surface-solid);
634
+ border: 4px solid var(--red);
635
+ border-radius: 50%;
636
+ box-shadow: 0 1px 5px rgba(31, 32, 35, .2);
637
+ }
638
+
639
+ .range:disabled {
640
+ opacity: .4;
641
+ }
642
+
643
+ .range:disabled::-webkit-slider-thumb {
644
+ border-color: var(--ink-faint);
645
+ }
646
+
647
+ .range-labels {
648
+ display: flex;
649
+ justify-content: space-between;
650
+ color: var(--ink-faint);
651
+ font-size: 9px;
652
+ }
653
+
654
+ .input-with-unit {
655
+ display: flex;
656
+ align-items: center;
657
+ overflow: hidden;
658
+ background: var(--paper);
659
+ border: 1px solid var(--line);
660
+ border-radius: 10px;
661
+ }
662
+
663
+ .input-with-unit:focus-within {
664
+ border-color: var(--blue);
665
+ box-shadow: 0 0 0 3px rgba(39, 111, 145, .12);
666
+ }
667
+
668
+ .input-with-unit input {
669
+ width: 100%;
670
+ min-width: 0;
671
+ padding: 10px 4px 10px 12px;
672
+ color: var(--ink);
673
+ background: transparent;
674
+ border: 0;
675
+ outline: 0;
676
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
677
+ font-size: 12px;
678
+ }
679
+
680
+ .input-with-unit span {
681
+ flex: none;
682
+ padding: 0 11px;
683
+ color: var(--ink-faint);
684
+ font-size: 9px;
685
+ }
686
+
687
+ .toggle-list {
688
+ display: grid;
689
+ gap: 8px;
690
+ }
691
+
692
+ .toggle-card {
693
+ display: grid;
694
+ grid-template-columns: minmax(0, 1fr) auto;
695
+ grid-template-areas: "copy switch";
696
+ gap: 16px;
697
+ align-items: center;
698
+ padding: 12px 13px;
699
+ background: rgba(246, 243, 236, .65);
700
+ border: 1px solid transparent;
701
+ border-radius: 12px;
702
+ transition: border-color .2s, background .2s;
703
+ }
704
+
705
+ .toggle-card:hover {
706
+ background: var(--paper);
707
+ border-color: var(--line);
708
+ }
709
+
710
+ .toggle-card > span:first-child {
711
+ grid-area: copy;
712
+ }
713
+
714
+ .toggle-card strong,
715
+ .toggle-card small {
716
+ display: block;
717
+ }
718
+
719
+ .toggle-card strong {
720
+ font-size: 12px;
721
+ }
722
+
723
+ .toggle-card small {
724
+ margin-top: 2px;
725
+ color: var(--ink-faint);
726
+ font-size: 9.5px;
727
+ font-weight: 500;
728
+ }
729
+
730
+ .toggle-card input {
731
+ position: absolute;
732
+ opacity: 0;
733
+ pointer-events: none;
734
+ }
735
+
736
+ .switch {
737
+ position: relative;
738
+ grid-area: switch;
739
+ width: 37px;
740
+ height: 21px;
741
+ background: var(--line-strong);
742
+ border-radius: 99px;
743
+ transition: background .2s;
744
+ }
745
+
746
+ .switch::after {
747
+ position: absolute;
748
+ top: 3px;
749
+ left: 3px;
750
+ width: 15px;
751
+ height: 15px;
752
+ background: white;
753
+ border-radius: 50%;
754
+ box-shadow: 0 1px 4px rgba(31, 32, 35, .2);
755
+ content: "";
756
+ transition: transform .22s var(--ease);
757
+ }
758
+
759
+ .toggle-card input:checked + .switch {
760
+ background: var(--red);
761
+ }
762
+
763
+ .toggle-card input:checked + .switch::after {
764
+ transform: translateX(16px);
765
+ }
766
+
767
+ .toggle-card input:focus-visible + .switch {
768
+ outline: 3px solid rgba(39, 111, 145, .35);
769
+ outline-offset: 3px;
770
+ }
771
+
772
+ .advanced {
773
+ padding-bottom: 20px;
774
+ }
775
+
776
+ .advanced summary {
777
+ display: flex;
778
+ align-items: center;
779
+ justify-content: space-between;
780
+ list-style: none;
781
+ }
782
+
783
+ .advanced summary::-webkit-details-marker {
784
+ display: none;
785
+ }
786
+
787
+ .advanced summary strong,
788
+ .advanced summary small {
789
+ display: block;
790
+ }
791
+
792
+ .advanced summary strong {
793
+ font-size: 13px;
794
+ }
795
+
796
+ .advanced summary small {
797
+ margin-top: 2px;
798
+ color: var(--ink-faint);
799
+ font-size: 10px;
800
+ }
801
+
802
+ .advanced summary svg {
803
+ width: 20px;
804
+ fill: none;
805
+ stroke: currentColor;
806
+ stroke-linecap: round;
807
+ stroke-linejoin: round;
808
+ stroke-width: 1.5;
809
+ transition: transform .22s;
810
+ }
811
+
812
+ .advanced[open] summary svg {
813
+ transform: rotate(180deg);
814
+ }
815
+
816
+ .advanced-body {
817
+ padding-top: 16px;
818
+ }
819
+
820
+ .advanced-body > .toggle-card {
821
+ margin-top: 8px;
822
+ }
823
+
824
+ .toggle-card.compact {
825
+ padding: 11px 12px;
826
+ }
827
+
828
+ .parameter-grid {
829
+ margin-top: 20px;
830
+ transition: opacity .2s;
831
+ }
832
+
833
+ .parameter-grid[aria-disabled="true"] {
834
+ opacity: .48;
835
+ }
836
+
837
+ .drum-volume-field {
838
+ padding-top: 21px;
839
+ margin-top: 22px;
840
+ border-top: 1px solid var(--line);
841
+ }
842
+
843
+ .field-note {
844
+ margin: 6px 0 0;
845
+ }
846
+
847
+ .primary-button,
848
+ .secondary-button {
849
+ display: flex;
850
+ width: 100%;
851
+ align-items: center;
852
+ justify-content: center;
853
+ gap: 12px;
854
+ border-radius: 13px;
855
+ font-weight: 750;
856
+ transition: background .2s, color .2s, transform .2s var(--ease), box-shadow .2s;
857
+ }
858
+
859
+ .primary-button {
860
+ min-height: 64px;
861
+ color: white;
862
+ background: var(--red);
863
+ border: 1px solid var(--red);
864
+ box-shadow: 0 10px 22px rgba(172, 46, 44, .2);
865
+ }
866
+
867
+ .primary-button:hover:not(:disabled) {
868
+ background: var(--red-deep);
869
+ border-color: var(--red-deep);
870
+ box-shadow: 0 12px 26px rgba(172, 46, 44, .28);
871
+ transform: translateY(-2px);
872
+ }
873
+
874
+ .primary-button:disabled {
875
+ cursor: not-allowed;
876
+ opacity: .6;
877
+ }
878
+
879
+ .primary-button svg {
880
+ width: 20px;
881
+ fill: none;
882
+ stroke: currentColor;
883
+ stroke-linecap: round;
884
+ stroke-linejoin: round;
885
+ stroke-width: 1.8;
886
+ }
887
+
888
+ .primary-button.is-running svg {
889
+ animation: button-arrow 1s ease-in-out infinite;
890
+ }
891
+
892
+ .submit-note {
893
+ margin: 9px 0 0;
894
+ color: var(--ink-faint);
895
+ font-size: 9.5px;
896
+ text-align: center;
897
+ }
898
+
899
+ .process-panel {
900
+ overflow: hidden;
901
+ }
902
+
903
+ .stage-canvas {
904
+ position: relative;
905
+ overflow: hidden;
906
+ aspect-ratio: 1.55 / 1;
907
+ color: var(--red);
908
+ background:
909
+ radial-gradient(circle at 32% 38%, rgba(217, 71, 62, .18), transparent 45%),
910
+ radial-gradient(circle at 72% 64%, rgba(39, 111, 145, .12), transparent 48%),
911
+ linear-gradient(145deg, #f7eee7, #f1eee9 55%, #e9eee9);
912
+ border: 1px solid rgba(201, 194, 183, .72);
913
+ border-radius: 22px;
914
+ }
915
+
916
+ .stage-canvas::after {
917
+ position: absolute;
918
+ inset: 0;
919
+ background: linear-gradient(110deg, transparent 35%, rgba(255, 255, 255, .35) 50%, transparent 65%);
920
+ content: "";
921
+ pointer-events: none;
922
+ transform: translateX(-120%);
923
+ }
924
+
925
+ .stage-canvas.is-running::after {
926
+ animation: canvas-sheen 4s ease-in-out infinite;
927
+ }
928
+
929
+ .stage-visual {
930
+ position: absolute;
931
+ inset: 0;
932
+ display: grid;
933
+ place-items: center;
934
+ visibility: hidden;
935
+ opacity: 0;
936
+ transform: scale(.98);
937
+ transition: opacity .35s var(--ease), transform .35s var(--ease), visibility 0s .35s;
938
+ }
939
+
940
+ .stage-visual.is-active {
941
+ visibility: visible;
942
+ opacity: 1;
943
+ transform: scale(1);
944
+ transition-delay: 0s;
945
+ }
946
+
947
+ .stage-visual svg {
948
+ width: 100%;
949
+ height: 100%;
950
+ }
951
+
952
+ .process-copy {
953
+ padding: 18px 2px 0;
954
+ }
955
+
956
+ .process-title-row {
957
+ display: flex;
958
+ justify-content: space-between;
959
+ gap: 18px;
960
+ }
961
+
962
+ .process-eyebrow {
963
+ display: none;
964
+ }
965
+
966
+ .process-title-row h3 {
967
+ margin: 0;
968
+ font-size: clamp(22px, 2.2vw, 30px);
969
+ line-height: 1.1;
970
+ letter-spacing: -.035em;
971
+ }
972
+
973
+ .progress-number {
974
+ color: var(--red);
975
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
976
+ font-size: 28px;
977
+ font-variant-numeric: tabular-nums;
978
+ }
979
+
980
+ .process-copy > p {
981
+ min-height: 2.8em;
982
+ margin: 9px 0 22px;
983
+ color: var(--ink-soft);
984
+ font-size: 12px;
985
+ }
986
+
987
+ .progress-track {
988
+ width: 100%;
989
+ height: 4px;
990
+ overflow: hidden;
991
+ background: var(--line);
992
+ border-radius: 99px;
993
+ }
994
+
995
+ .progress-track span {
996
+ display: block;
997
+ width: 0;
998
+ height: 100%;
999
+ background: linear-gradient(90deg, var(--red), #ed7b53);
1000
+ border-radius: inherit;
1001
+ transition: width .5s var(--ease);
1002
+ }
1003
+
1004
+ .queue-row {
1005
+ display: flex;
1006
+ min-height: 28px;
1007
+ align-items: end;
1008
+ justify-content: space-between;
1009
+ gap: 12px;
1010
+ padding-top: 9px;
1011
+ color: var(--ink-faint);
1012
+ font-size: 9.5px;
1013
+ }
1014
+
1015
+ .text-button {
1016
+ padding: 2px 0;
1017
+ color: var(--ink-soft);
1018
+ background: transparent;
1019
+ border: 0;
1020
+ font-size: 10px;
1021
+ font-weight: 700;
1022
+ text-decoration: underline;
1023
+ text-underline-offset: 3px;
1024
+ }
1025
+
1026
+ .text-button:hover {
1027
+ color: var(--red);
1028
+ }
1029
+
1030
+ .process-error {
1031
+ display: grid;
1032
+ grid-template-columns: 1fr auto;
1033
+ gap: 3px 14px;
1034
+ padding: 14px 16px;
1035
+ margin-top: 20px;
1036
+ color: var(--red-deep);
1037
+ background: var(--red-soft);
1038
+ border-radius: 12px;
1039
+ font-size: 11px;
1040
+ }
1041
+
1042
+ .process-error strong,
1043
+ .process-error span {
1044
+ grid-column: 1;
1045
+ }
1046
+
1047
+ .process-error .text-button {
1048
+ grid-row: 1 / 3;
1049
+ grid-column: 2;
1050
+ align-self: center;
1051
+ color: var(--red-deep);
1052
+ }
1053
+
1054
+ /* Loading */
1055
+ .loading-orbit,
1056
+ .loading-dot,
1057
+ .loading-core,
1058
+ .metronome,
1059
+ .beat-pulses circle,
1060
+ .plan-cursor,
1061
+ .note,
1062
+ .export-sheet,
1063
+ .mix-wave,
1064
+ .mix-drum,
1065
+ .mix-spark,
1066
+ .complete-rings,
1067
+ .complete-check,
1068
+ .confetti {
1069
+ transform-box: fill-box;
1070
+ transform-origin: center;
1071
+ }
1072
+
1073
+ .is-active .orbit-a { animation: orbit-spin 9s linear infinite; }
1074
+ .is-active .orbit-b { animation: orbit-spin-reverse 6s linear infinite; }
1075
+ .is-active .loading-core { animation: core-breathe 1.7s ease-in-out infinite; }
1076
+ .is-active .dot-a { animation: dot-breathe 1.2s ease-in-out infinite; }
1077
+ .is-active .dot-b { animation: dot-breathe 1.2s .3s ease-in-out infinite; }
1078
+
1079
+ /* Audio */
1080
+ .audio-line {
1081
+ stroke-dasharray: 720;
1082
+ stroke-dashoffset: 720;
1083
+ }
1084
+ .is-active .audio-line { animation: draw-line 2.4s var(--ease) infinite; }
1085
+ .audio-playhead { transform: translateX(24px); }
1086
+ .is-active .audio-playhead { animation: audio-scan 2.4s var(--ease) infinite; }
1087
+
1088
+ /* Beat */
1089
+ .metronome { transform-origin: 0 100%; }
1090
+ .is-active .metronome { animation: metronome 1s ease-in-out infinite alternate; }
1091
+ .is-active .beat-pulses circle { animation: beat-pulse 2s ease-out infinite; }
1092
+ .is-active .beat-pulses circle:nth-child(2) { animation-delay: .4s; }
1093
+ .is-active .beat-pulses circle:nth-child(3) { animation-delay: .8s; }
1094
+ .is-active .beat-pulses circle:nth-child(4) { animation-delay: 1.2s; }
1095
+ .is-active .beat-pulses circle:nth-child(5) { animation-delay: 1.6s; }
1096
+
1097
+ /* Plan */
1098
+ .plan-bars rect { fill: currentColor; opacity: .13; }
1099
+ .plan-bars .plan-climax { fill: var(--red); opacity: .42; }
1100
+ .is-active .plan-bars rect { animation: plan-rise 1.9s var(--ease) infinite alternate; transform-box: fill-box; transform-origin: center bottom; }
1101
+ .is-active .plan-bars rect:nth-child(2) { animation-delay: .12s; }
1102
+ .is-active .plan-bars rect:nth-child(3) { animation-delay: .24s; }
1103
+ .is-active .plan-bars rect:nth-child(4) { animation-delay: .36s; }
1104
+ .is-active .plan-bars rect:nth-child(5) { animation-delay: .48s; }
1105
+ .is-active .plan-bars rect:nth-child(6) { animation-delay: .6s; }
1106
+ .is-active .plan-bars rect:nth-child(7) { animation-delay: .72s; }
1107
+ .is-active .plan-bars rect:nth-child(8) { animation-delay: .84s; }
1108
+ .plan-path { stroke-dasharray: 520; stroke-dashoffset: 520; }
1109
+ .is-active .plan-path { animation: plan-draw 2.2s var(--ease) infinite; }
1110
+ .is-active .plan-cursor { animation: plan-cursor 2.2s var(--ease) infinite; }
1111
+
1112
+ /* Generate */
1113
+ .note { fill: var(--red); transform: translate(560px, 140px); }
1114
+ .note .ka,
1115
+ .export-dots .ka { fill: var(--blue); }
1116
+ .is-active .note { animation: note-travel 3.2s linear infinite; }
1117
+ .is-active .note-2 { animation-delay: -.65s; }
1118
+ .is-active .note-3 { animation-delay: -1.3s; }
1119
+ .is-active .note-4 { animation-delay: -1.95s; }
1120
+ .is-active .note-5 { animation-delay: -2.6s; }
1121
+ .is-active .generate-gate { animation: gate-pulse 1.05s ease-out infinite; transform-box: fill-box; transform-origin: center; }
1122
+
1123
+ /* Export */
1124
+ .export-lines { opacity: .28; stroke-dasharray: 120; stroke-dashoffset: 120; }
1125
+ .is-active .export-sheet { animation: sheet-float 2.4s ease-in-out infinite; }
1126
+ .is-active .export-lines { animation: export-write 2.2s var(--ease) infinite; }
1127
+ .is-active .export-arrow { animation: export-arrow 1.4s ease-in-out infinite; }
1128
+
1129
+ /* Mix */
1130
+ .mix-wave { stroke-dasharray: 35 12; }
1131
+ .wave-top { opacity: .65; }
1132
+ .wave-bottom { color: var(--blue); opacity: .65; }
1133
+ .is-active .wave-top { animation: wave-flow 2s linear infinite; }
1134
+ .is-active .wave-bottom { animation: wave-flow-reverse 2.4s linear infinite; }
1135
+ .is-active .mix-drum { animation: drum-hit 1.2s var(--ease) infinite; }
1136
+ .is-active .mix-spark { animation: spark-pulse 1.2s var(--ease) infinite; }
1137
+
1138
+ /* Complete */
1139
+ .complete-rings { transform: scale(.65); opacity: 0; }
1140
+ .complete-check { stroke-dasharray: 82; stroke-dashoffset: 82; }
1141
+ .confetti { opacity: 0; }
1142
+ .is-active .complete-rings { animation: complete-pop .7s var(--ease) forwards; }
1143
+ .is-active .complete-check { animation: check-draw .45s .35s var(--ease) forwards; }
1144
+ .is-active .confetti { animation: confetti-pop .8s .25s var(--ease) forwards; }
1145
+
1146
+ .result-panel {
1147
+ animation: result-enter .55s var(--ease) both;
1148
+ }
1149
+
1150
+ .audition-card {
1151
+ padding: 18px;
1152
+ color: var(--ink);
1153
+ background: linear-gradient(135deg, var(--red-soft), #f7e8d9);
1154
+ border: 1px solid rgba(217, 71, 62, .18);
1155
+ border-radius: var(--radius-md);
1156
+ }
1157
+
1158
+ .audition-heading {
1159
+ display: flex;
1160
+ align-items: center;
1161
+ gap: 11px;
1162
+ margin-bottom: 13px;
1163
+ }
1164
+
1165
+ .audition-icon {
1166
+ display: grid;
1167
+ width: 38px;
1168
+ height: 38px;
1169
+ place-items: center;
1170
+ color: var(--red);
1171
+ background: rgba(255, 253, 248, .7);
1172
+ border-radius: 50%;
1173
+ }
1174
+
1175
+ .audition-icon svg {
1176
+ width: 27px;
1177
+ }
1178
+
1179
+ .audition-icon circle {
1180
+ fill: none;
1181
+ stroke: currentColor;
1182
+ stroke-width: 1.6;
1183
+ }
1184
+
1185
+ .audition-icon path {
1186
+ fill: currentColor;
1187
+ }
1188
+
1189
+ .audition-heading strong,
1190
+ .audition-heading small {
1191
+ display: block;
1192
+ }
1193
+
1194
+ .audition-heading strong {
1195
+ font-size: 13px;
1196
+ }
1197
+
1198
+ .audition-heading small {
1199
+ color: var(--ink-soft);
1200
+ font-size: 9.5px;
1201
+ }
1202
+
1203
+ .metrics-grid {
1204
+ display: grid;
1205
+ grid-template-columns: repeat(4, 1fr);
1206
+ gap: 8px;
1207
+ margin: 14px 0 28px;
1208
+ }
1209
+
1210
+ .metric {
1211
+ min-width: 0;
1212
+ padding: 13px 12px;
1213
+ background: var(--paper);
1214
+ border: 1px solid var(--line);
1215
+ border-radius: 12px;
1216
+ }
1217
+
1218
+ .metric dt,
1219
+ .metric dd {
1220
+ overflow: hidden;
1221
+ margin: 0;
1222
+ text-overflow: ellipsis;
1223
+ white-space: nowrap;
1224
+ }
1225
+
1226
+ .metric dt {
1227
+ color: var(--ink-faint);
1228
+ font-size: 8.5px;
1229
+ font-weight: 750;
1230
+ letter-spacing: .07em;
1231
+ text-transform: uppercase;
1232
+ }
1233
+
1234
+ .metric dd {
1235
+ margin-top: 4px;
1236
+ color: var(--ink);
1237
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1238
+ font-size: 14px;
1239
+ font-weight: 760;
1240
+ }
1241
+
1242
+ .preview-tabs {
1243
+ display: flex;
1244
+ gap: 20px;
1245
+ border-bottom: 1px solid var(--line);
1246
+ }
1247
+
1248
+ .preview-tab {
1249
+ position: relative;
1250
+ padding: 10px 1px 11px;
1251
+ color: var(--ink-faint);
1252
+ background: transparent;
1253
+ border: 0;
1254
+ font-size: 11px;
1255
+ font-weight: 750;
1256
+ }
1257
+
1258
+ .preview-tab::after {
1259
+ position: absolute;
1260
+ right: 0;
1261
+ bottom: -1px;
1262
+ left: 0;
1263
+ height: 2px;
1264
+ background: var(--red);
1265
+ content: "";
1266
+ transform: scaleX(0);
1267
+ transition: transform .2s var(--ease);
1268
+ }
1269
+
1270
+ .preview-tab.is-active {
1271
+ color: var(--ink);
1272
+ }
1273
+
1274
+ .preview-tab.is-active::after {
1275
+ transform: scaleX(1);
1276
+ }
1277
+
1278
+ .preview-frame {
1279
+ display: grid;
1280
+ min-height: 260px;
1281
+ max-height: 470px;
1282
+ overflow: auto;
1283
+ place-items: center;
1284
+ margin-top: 14px;
1285
+ background: var(--paper);
1286
+ border: 1px solid var(--line);
1287
+ border-radius: var(--radius-md);
1288
+ }
1289
+
1290
+ .preview-frame > div {
1291
+ width: 100%;
1292
+ }
1293
+
1294
+ .preview-frame img {
1295
+ width: 100%;
1296
+ height: auto;
1297
+ }
1298
+
1299
+ .preview-empty {
1300
+ padding: 70px 20px;
1301
+ margin: 0;
1302
+ color: var(--ink-faint);
1303
+ font-size: 11px;
1304
+ text-align: center;
1305
+ }
1306
+
1307
+ .download-group {
1308
+ display: grid;
1309
+ grid-template-columns: 1fr 1fr;
1310
+ gap: 10px;
1311
+ margin-top: 22px;
1312
+ }
1313
+
1314
+ .download-button {
1315
+ display: grid;
1316
+ grid-template-columns: minmax(0, 1fr) 24px;
1317
+ gap: 10px;
1318
+ align-items: center;
1319
+ padding: 14px 15px;
1320
+ color: var(--ink);
1321
+ background: var(--paper);
1322
+ border: 1px solid var(--line);
1323
+ border-radius: 12px;
1324
+ text-decoration: none;
1325
+ transition: border-color .2s, background .2s, transform .2s var(--ease);
1326
+ }
1327
+
1328
+ .download-button:hover {
1329
+ background: var(--surface-solid);
1330
+ border-color: var(--line-strong);
1331
+ transform: translateY(-2px);
1332
+ }
1333
+
1334
+ .download-button strong,
1335
+ .download-button small {
1336
+ display: block;
1337
+ }
1338
+
1339
+ .download-button strong {
1340
+ font-size: 11px;
1341
+ }
1342
+
1343
+ .download-button small {
1344
+ margin-top: 2px;
1345
+ color: var(--ink-faint);
1346
+ font-size: 8.5px;
1347
+ }
1348
+
1349
+ .download-button svg {
1350
+ width: 21px;
1351
+ fill: none;
1352
+ stroke: currentColor;
1353
+ stroke-linecap: round;
1354
+ stroke-linejoin: round;
1355
+ stroke-width: 1.7;
1356
+ }
1357
+
1358
+ .primary-download {
1359
+ color: white;
1360
+ background: var(--ink);
1361
+ border-color: var(--ink);
1362
+ }
1363
+
1364
+ .primary-download:hover {
1365
+ color: white;
1366
+ background: #313238;
1367
+ border-color: #313238;
1368
+ }
1369
+
1370
+ .primary-download small {
1371
+ color: rgba(255, 255, 255, .62);
1372
+ }
1373
+
1374
+ .secondary-button {
1375
+ min-height: 46px;
1376
+ margin-top: 10px;
1377
+ color: var(--ink-soft);
1378
+ background: transparent;
1379
+ border: 1px solid var(--line);
1380
+ font-size: 11px;
1381
+ }
1382
+
1383
+ .secondary-button:hover {
1384
+ color: var(--red);
1385
+ background: var(--red-soft);
1386
+ border-color: rgba(217, 71, 62, .22);
1387
+ }
1388
+
1389
+ footer {
1390
+ width: min(1420px, calc(100% - 48px));
1391
+ min-height: 100px;
1392
+ display: grid;
1393
+ grid-template-columns: 1fr auto 1fr;
1394
+ gap: 30px;
1395
+ align-items: center;
1396
+ margin: 0 auto;
1397
+ color: var(--ink-faint);
1398
+ border-top: 1px solid var(--line-strong);
1399
+ font-size: 10px;
1400
+ }
1401
+
1402
+ footer > span {
1403
+ color: var(--ink);
1404
+ font-size: 13px;
1405
+ font-weight: 760;
1406
+ }
1407
+
1408
+ footer p {
1409
+ margin: 0;
1410
+ }
1411
+
1412
+ footer a {
1413
+ justify-self: end;
1414
+ color: var(--ink-soft);
1415
+ text-decoration: none;
1416
+ }
1417
+
1418
+ footer a:hover {
1419
+ color: var(--red);
1420
+ }
1421
+
1422
+ @keyframes connection-pulse {
1423
+ 50% { opacity: .45; }
1424
+ }
1425
+
1426
+ @keyframes button-arrow {
1427
+ 50% { transform: translateX(4px); }
1428
+ }
1429
+
1430
+ @keyframes canvas-sheen {
1431
+ 0%, 45% { transform: translateX(-120%); }
1432
+ 75%, 100% { transform: translateX(120%); }
1433
+ }
1434
+
1435
+ @keyframes orbit-spin { to { transform: rotate(360deg); } }
1436
+ @keyframes orbit-spin-reverse { to { transform: rotate(-360deg); } }
1437
+ @keyframes core-breathe { 50% { opacity: .55; transform: scale(.72); } }
1438
+ @keyframes dot-breathe { 50% { opacity: .3; transform: scale(.6); } }
1439
+
1440
+ @keyframes draw-line {
1441
+ 0% { stroke-dashoffset: 720; }
1442
+ 55%, 100% { stroke-dashoffset: 0; }
1443
+ }
1444
+
1445
+ @keyframes audio-scan {
1446
+ 0% { transform: translateX(24px); opacity: 0; }
1447
+ 10% { opacity: 1; }
1448
+ 80% { transform: translateX(472px); opacity: 1; }
1449
+ 100% { transform: translateX(472px); opacity: 0; }
1450
+ }
1451
+
1452
+ @keyframes metronome {
1453
+ from { transform: rotate(-28deg); }
1454
+ to { transform: rotate(28deg); }
1455
+ }
1456
+
1457
+ @keyframes beat-pulse {
1458
+ 0%, 18% { opacity: 1; transform: scale(1.6); }
1459
+ 48%, 100% { opacity: .16; transform: scale(.75); }
1460
+ }
1461
+
1462
+ @keyframes plan-rise {
1463
+ 0% { opacity: .12; transform: scaleY(.45); }
1464
+ 100% { opacity: .35; transform: scaleY(1); }
1465
+ }
1466
+
1467
+ @keyframes plan-draw {
1468
+ 0% { stroke-dashoffset: 520; }
1469
+ 75%, 100% { stroke-dashoffset: 0; }
1470
+ }
1471
+
1472
+ @keyframes plan-cursor {
1473
+ 0% { transform: translate(0, 0); opacity: 0; }
1474
+ 8% { opacity: 1; }
1475
+ 12% { transform: translate(0, 0); }
1476
+ 23% { transform: translate(54px, -23px); }
1477
+ 34% { transform: translate(108px, -13px); }
1478
+ 45% { transform: translate(162px, -62px); }
1479
+ 56% { transform: translate(216px, -40px); }
1480
+ 67% { transform: translate(270px, -83px); }
1481
+ 78% { transform: translate(324px, -114px); }
1482
+ 89%, 100% { transform: translate(378px, -49px); opacity: 1; }
1483
+ }
1484
+
1485
+ @keyframes note-travel {
1486
+ from { transform: translate(560px, 140px); }
1487
+ to { transform: translate(25px, 140px); }
1488
+ }
1489
+
1490
+ @keyframes gate-pulse {
1491
+ 0% { opacity: 1; transform: scale(.92); }
1492
+ 70%, 100% { opacity: .25; transform: scale(1.12); }
1493
+ }
1494
+
1495
+ @keyframes sheet-float {
1496
+ 50% { transform: translateY(-7px) rotate(-1deg); }
1497
+ }
1498
+
1499
+ @keyframes export-write {
1500
+ 0% { stroke-dashoffset: 120; }
1501
+ 45%, 100% { stroke-dashoffset: 0; }
1502
+ }
1503
+
1504
+ @keyframes export-arrow {
1505
+ 50% { transform: translateY(8px); }
1506
+ }
1507
+
1508
+ @keyframes wave-flow { to { stroke-dashoffset: -94; } }
1509
+ @keyframes wave-flow-reverse { to { stroke-dashoffset: 94; } }
1510
+ @keyframes drum-hit {
1511
+ 0%, 100% { transform: scale(1); }
1512
+ 12% { transform: scale(.88); }
1513
+ 28% { transform: scale(1.08); }
1514
+ }
1515
+ @keyframes spark-pulse {
1516
+ 0%, 100% { opacity: 0; transform: scale(.6); }
1517
+ 20% { opacity: .8; transform: scale(1.2); }
1518
+ }
1519
+
1520
+ @keyframes complete-pop {
1521
+ to { opacity: 1; transform: scale(1); }
1522
+ }
1523
+ @keyframes check-draw { to { stroke-dashoffset: 0; } }
1524
+ @keyframes confetti-pop {
1525
+ 0% { opacity: 0; transform: scale(.5) rotate(-8deg); }
1526
+ 55%, 100% { opacity: .75; transform: scale(1) rotate(0); }
1527
+ }
1528
+ @keyframes result-enter {
1529
+ from { opacity: 0; transform: translateY(15px); }
1530
+ }
1531
+
1532
+ @media (max-width: 1040px) {
1533
+ .hero {
1534
+ padding-top: 68px;
1535
+ }
1536
+
1537
+ .hero-features {
1538
+ display: flex;
1539
+ }
1540
+
1541
+ .hero-features li {
1542
+ padding-right: 0;
1543
+ }
1544
+
1545
+ .studio-grid {
1546
+ grid-template-columns: minmax(360px, .8fr) minmax(430px, 1.2fr);
1547
+ }
1548
+
1549
+ .metrics-grid {
1550
+ grid-template-columns: repeat(2, 1fr);
1551
+ }
1552
+ }
1553
+
1554
+ @media (max-width: 820px) {
1555
+ .site-header,
1556
+ main,
1557
+ footer {
1558
+ width: min(100% - 30px, 680px);
1559
+ }
1560
+
1561
+ .site-header {
1562
+ height: 72px;
1563
+ }
1564
+
1565
+ .hero {
1566
+ padding: 58px 0 60px;
1567
+ }
1568
+
1569
+ .hero-features {
1570
+ display: flex;
1571
+ }
1572
+
1573
+ .studio-grid {
1574
+ grid-template-columns: 1fr;
1575
+ }
1576
+
1577
+ .setup-panel {
1578
+ position: static;
1579
+ }
1580
+
1581
+ .output-column {
1582
+ grid-row: 2;
1583
+ }
1584
+
1585
+ footer {
1586
+ grid-template-columns: 1fr auto;
1587
+ }
1588
+
1589
+ footer p {
1590
+ display: none;
1591
+ }
1592
+ }
1593
+
1594
+ @media (max-width: 520px) {
1595
+ .site-header,
1596
+ main,
1597
+ footer {
1598
+ width: calc(100% - 22px);
1599
+ }
1600
+
1601
+ .brand-edition {
1602
+ display: none;
1603
+ }
1604
+
1605
+ .hero {
1606
+ padding: 42px 3px 38px;
1607
+ }
1608
+
1609
+ .hero h1 {
1610
+ font-size: clamp(50px, 15vw, 68px);
1611
+ line-height: .92;
1612
+ }
1613
+
1614
+ .hero-lede {
1615
+ margin-top: 20px;
1616
+ font-size: 15px;
1617
+ }
1618
+
1619
+ .hero-features {
1620
+ display: none;
1621
+ }
1622
+
1623
+ .panel {
1624
+ border-radius: 20px;
1625
+ }
1626
+
1627
+ .setup-panel,
1628
+ .process-panel,
1629
+ .result-panel {
1630
+ padding: 22px 18px;
1631
+ }
1632
+
1633
+ .panel-heading {
1634
+ margin-bottom: 25px;
1635
+ }
1636
+
1637
+ .course-selector span {
1638
+ min-height: 52px;
1639
+ font-size: 11px;
1640
+ }
1641
+
1642
+ .two-column-fields,
1643
+ .parameter-grid,
1644
+ .download-group {
1645
+ grid-template-columns: 1fr;
1646
+ }
1647
+
1648
+ .two-column-fields {
1649
+ gap: 18px;
1650
+ }
1651
+
1652
+ .toggle-card {
1653
+ padding: 11px 9px;
1654
+ }
1655
+
1656
+ .stage-canvas {
1657
+ aspect-ratio: 4 / 3;
1658
+ }
1659
+
1660
+ .metrics-grid {
1661
+ gap: 6px;
1662
+ }
1663
+
1664
+ .metric {
1665
+ padding: 11px 9px;
1666
+ }
1667
+
1668
+ .preview-frame {
1669
+ min-height: 190px;
1670
+ }
1671
+ }
1672
+
1673
+ @media (prefers-reduced-motion: reduce) {
1674
+ html {
1675
+ scroll-behavior: auto;
1676
+ }
1677
+
1678
+ *,
1679
+ *::before,
1680
+ *::after {
1681
+ scroll-behavior: auto !important;
1682
+ animation-duration: .001ms !important;
1683
+ animation-iteration-count: 1 !important;
1684
+ transition-duration: .001ms !important;
1685
+ }
1686
+
1687
+ .complete-rings,
1688
+ .complete-check,
1689
+ .confetti {
1690
+ opacity: 1;
1691
+ transform: none;
1692
+ stroke-dashoffset: 0;
1693
+ }
1694
+ }
static/app.js ADDED
@@ -0,0 +1,680 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.3.1/+esm";
2
+
3
+ const ENDPOINT = "/generate_chart";
4
+
5
+ const $ = (selector, root = document) => root.querySelector(selector);
6
+ const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
7
+
8
+ const ui = {
9
+ form: $("#generate-form"),
10
+ fileInput: $("#file-input"),
11
+ dropzone: $("#dropzone"),
12
+ sourceCard: $("#source-card"),
13
+ sourceAudio: $("#source-audio"),
14
+ fileName: $("#file-name"),
15
+ fileMeta: $("#file-meta"),
16
+ removeFile: $("#remove-file"),
17
+ fileError: $("#file-error"),
18
+ level: $("#level"),
19
+ levelOutput: $("#level-output"),
20
+ bpm: $("#bpm"),
21
+ autoPlan: $("#auto-plan"),
22
+ useBeat: $("#use-beat"),
23
+ usePlanner: $("#use-planner"),
24
+ sampling: $("#sampling"),
25
+ samplingParameters: $("#sampling-parameters"),
26
+ temperature: $("#temperature"),
27
+ temperatureOutput: $("#temperature-output"),
28
+ topP: $("#top-p"),
29
+ topPOutput: $("#top-p-output"),
30
+ drumVolume: $("#drum-volume"),
31
+ drumVolumeOutput: $("#drum-volume-output"),
32
+ generateButton: $("#generate-button"),
33
+ connection: $("#connection"),
34
+ connectionLabel: $("#connection-label"),
35
+ processPanel: $("#process-panel"),
36
+ stageCanvas: $("#stage-canvas"),
37
+ statusChip: $("#status-chip"),
38
+ stageEyebrow: $("#stage-eyebrow"),
39
+ stageTitle: $("#stage-title"),
40
+ stageDetail: $("#stage-detail"),
41
+ progressNumber: $("#progress-number"),
42
+ progressTrack: $("#progress-track"),
43
+ progressBar: $("#progress-bar"),
44
+ queueLabel: $("#queue-label"),
45
+ cancelButton: $("#cancel-button"),
46
+ processError: $("#process-error"),
47
+ processErrorMessage: $("#process-error-message"),
48
+ retryButton: $("#retry-button"),
49
+ resultPanel: $("#result-panel"),
50
+ auditionCard: $("#audition-card"),
51
+ resultAudio: $("#result-audio"),
52
+ metricsGrid: $("#metrics-grid"),
53
+ previewBlock: $(".preview-block"),
54
+ chartTab: $("#chart-tab"),
55
+ planTab: $("#plan-tab"),
56
+ chartPreview: $("#chart-preview"),
57
+ planPreview: $("#plan-preview"),
58
+ chartImage: $("#chart-image"),
59
+ planImage: $("#plan-image"),
60
+ chartEmpty: $("#chart-empty"),
61
+ planEmpty: $("#plan-empty"),
62
+ downloadTja: $("#download-tja"),
63
+ downloadAudio: $("#download-audio"),
64
+ newChartButton: $("#new-chart-button"),
65
+ announcer: $("#announcer")
66
+ };
67
+
68
+ const stageDefaults = {
69
+ loading: {
70
+ eyebrow: "PREPARING",
71
+ title: "Preparing",
72
+ detail: "Loading SoftChart."
73
+ },
74
+ audio: {
75
+ eyebrow: "LISTENING",
76
+ title: "Listening",
77
+ detail: "Mapping rhythm, melody, and timbre."
78
+ },
79
+ beat: {
80
+ eyebrow: "ALIGNING",
81
+ title: "Finding the beat",
82
+ detail: "Finding beats and tempo."
83
+ },
84
+ plan: {
85
+ eyebrow: "PLANNING",
86
+ title: "Shaping the arc",
87
+ detail: "Planning density and climaxes."
88
+ },
89
+ generate: {
90
+ eyebrow: "COMPOSING",
91
+ title: "Writing the chart",
92
+ detail: "Writing playable Taiko patterns."
93
+ },
94
+ export: {
95
+ eyebrow: "RENDERING",
96
+ title: "Rendering",
97
+ detail: "Building the TJA and previews."
98
+ },
99
+ mix: {
100
+ eyebrow: "MIXING",
101
+ title: "Mixing",
102
+ detail: "Mixing Taiko with your track."
103
+ },
104
+ complete: {
105
+ eyebrow: "COMPLETE",
106
+ title: "Ready",
107
+ detail: "Play it or download it."
108
+ }
109
+ };
110
+
111
+ const metricLabels = {
112
+ bpm: "BPM",
113
+ notes: "Notes",
114
+ spans: "Drumroll sections",
115
+ timing: "Timing mode",
116
+ grid_rms_ms: "Grid error",
117
+ preview_hits: "Audition hits"
118
+ };
119
+
120
+ let client = null;
121
+ let connectPromise = null;
122
+ let currentFile = null;
123
+ let sourceObjectUrl = "";
124
+ let currentJob = null;
125
+ let currentRun = 0;
126
+ let running = false;
127
+ let receivedTerminalPayload = false;
128
+
129
+ function announce(message) {
130
+ ui.announcer.textContent = "";
131
+ window.setTimeout(() => {
132
+ ui.announcer.textContent = message;
133
+ }, 40);
134
+ }
135
+
136
+ function setConnection(state, label) {
137
+ ui.connection.classList.toggle("is-ready", state === "ready");
138
+ ui.connection.classList.toggle("is-error", state === "error");
139
+ ui.connectionLabel.textContent = label;
140
+ }
141
+
142
+ async function connect() {
143
+ if (client) return client;
144
+ if (connectPromise) return connectPromise;
145
+
146
+ setConnection("connecting", "Connecting");
147
+ connectPromise = Client.connect(new URL(".", window.location.href).href, {
148
+ events: ["status", "data"]
149
+ }).then((connectedClient) => {
150
+ client = connectedClient;
151
+ setConnection("ready", "Service ready");
152
+ return client;
153
+ }).catch((error) => {
154
+ connectPromise = null;
155
+ setConnection("error", "Connection lost");
156
+ throw error;
157
+ });
158
+
159
+ return connectPromise;
160
+ }
161
+
162
+ function formatBytes(bytes) {
163
+ if (!Number.isFinite(bytes) || bytes < 0) return "";
164
+ if (bytes < 1024) return `${bytes} B`;
165
+ const units = ["KB", "MB", "GB"];
166
+ let value = bytes / 1024;
167
+ let unit = units[0];
168
+ for (let index = 1; index < units.length && value >= 1024; index += 1) {
169
+ value /= 1024;
170
+ unit = units[index];
171
+ }
172
+ const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
173
+ return `${value.toFixed(precision)} ${unit}`;
174
+ }
175
+
176
+ function formatDuration(seconds) {
177
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
178
+ const rounded = Math.round(seconds);
179
+ const minutes = Math.floor(rounded / 60);
180
+ const remainder = String(rounded % 60).padStart(2, "0");
181
+ return `${minutes}:${remainder}`;
182
+ }
183
+
184
+ function isSupportedAudio(file) {
185
+ return /\.(mp3|wav|flac|ogg)$/i.test(file.name);
186
+ }
187
+
188
+ function clearFileError() {
189
+ ui.fileError.hidden = true;
190
+ ui.fileError.textContent = "";
191
+ }
192
+
193
+ function showFileError(message) {
194
+ ui.fileError.textContent = message;
195
+ ui.fileError.hidden = false;
196
+ announce(message);
197
+ }
198
+
199
+ function clearFile() {
200
+ if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl);
201
+ sourceObjectUrl = "";
202
+ currentFile = null;
203
+ ui.fileInput.value = "";
204
+ ui.sourceAudio.removeAttribute("src");
205
+ ui.sourceAudio.load();
206
+ ui.sourceCard.hidden = true;
207
+ ui.dropzone.hidden = false;
208
+ clearFileError();
209
+ }
210
+
211
+ function selectFile(file) {
212
+ clearFileError();
213
+ if (!file) return;
214
+ if (!isSupportedAudio(file)) {
215
+ showFileError("Choose an MP3, WAV, FLAC, or OGG audio file.");
216
+ return;
217
+ }
218
+
219
+ if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl);
220
+ currentFile = file;
221
+ sourceObjectUrl = URL.createObjectURL(file);
222
+ ui.fileName.textContent = file.name;
223
+ ui.fileMeta.textContent = formatBytes(file.size);
224
+ ui.sourceAudio.src = sourceObjectUrl;
225
+ ui.dropzone.hidden = true;
226
+ ui.sourceCard.hidden = false;
227
+ ui.sourceAudio.addEventListener("loadedmetadata", () => {
228
+ const size = formatBytes(file.size);
229
+ const duration = formatDuration(ui.sourceAudio.duration);
230
+ ui.fileMeta.textContent = [size, duration].filter(Boolean).join(" · ");
231
+ }, { once: true });
232
+ announce(`Selected ${file.name}`);
233
+ }
234
+
235
+ function setRangeState(input, output, formatter) {
236
+ const min = Number(input.min);
237
+ const max = Number(input.max);
238
+ const value = Number(input.value);
239
+ const progress = ((value - min) / (max - min)) * 100;
240
+ input.style.setProperty("--range-progress", `${progress}%`);
241
+ output.textContent = formatter(value);
242
+ }
243
+
244
+ function updateRanges() {
245
+ setRangeState(ui.level, ui.levelOutput, (value) => `★ ${value.toFixed(0)}`);
246
+ setRangeState(ui.temperature, ui.temperatureOutput, (value) => value.toFixed(2));
247
+ setRangeState(ui.topP, ui.topPOutput, (value) => value.toFixed(2));
248
+ setRangeState(ui.drumVolume, ui.drumVolumeOutput, (value) => `${Math.round(value * 100)}%`);
249
+ }
250
+
251
+ function setSamplingEnabled(enabled) {
252
+ ui.temperature.disabled = !enabled;
253
+ ui.topP.disabled = !enabled;
254
+ ui.samplingParameters.setAttribute("aria-disabled", String(!enabled));
255
+ }
256
+
257
+ function normalizeStage(stage) {
258
+ const value = String(stage || "").trim().toLowerCase();
259
+ if (Object.hasOwn(stageDefaults, value)) return value;
260
+ throw new Error(`Unsupported generation stage: ${value}`);
261
+ }
262
+
263
+ function setStage(stage, data = {}) {
264
+ const normalized = normalizeStage(stage);
265
+ $$(".stage-visual", ui.stageCanvas).forEach((visual) => {
266
+ visual.classList.toggle("is-active", visual.dataset.stage === normalized);
267
+ });
268
+
269
+ const defaults = stageDefaults[normalized];
270
+ ui.stageEyebrow.textContent = defaults.eyebrow;
271
+ ui.stageTitle.textContent = typeof data.title === "string" && data.title.trim()
272
+ ? data.title.trim()
273
+ : defaults.title;
274
+ ui.stageDetail.textContent = typeof data.detail === "string" && data.detail.trim()
275
+ ? data.detail.trim()
276
+ : defaults.detail;
277
+ }
278
+
279
+ function progressPercent(progress) {
280
+ const numeric = Number(progress);
281
+ if (!Number.isFinite(numeric)) return 0;
282
+ const percent = numeric <= 1 ? numeric * 100 : numeric;
283
+ return Math.min(100, Math.max(0, percent));
284
+ }
285
+
286
+ function setProgress(progress) {
287
+ const percent = progressPercent(progress);
288
+ const rounded = Math.round(percent);
289
+ ui.progressBar.style.width = `${percent}%`;
290
+ ui.progressNumber.textContent = `${rounded}%`;
291
+ ui.progressTrack.setAttribute("aria-valuenow", String(rounded));
292
+ }
293
+
294
+ function setStatusChip(state, text) {
295
+ ui.statusChip.className = `status-chip is-${state}`;
296
+ ui.statusChip.textContent = text;
297
+ }
298
+
299
+ function setBusy(isBusy) {
300
+ running = isBusy;
301
+ ui.generateButton.disabled = isBusy;
302
+ ui.removeFile.disabled = isBusy;
303
+ ui.generateButton.classList.toggle("is-running", isBusy);
304
+ ui.stageCanvas.classList.toggle("is-running", isBusy);
305
+ ui.processPanel.setAttribute("aria-busy", String(isBusy));
306
+ $("span", ui.generateButton).textContent = isBusy ? "Generating chart" : "Generate chart";
307
+ ui.cancelButton.hidden = !isBusy;
308
+ }
309
+
310
+ function resetProcess() {
311
+ setBusy(false);
312
+ setStage("loading", {
313
+ title: "Waiting for your music",
314
+ detail: "Upload a song, choose a difficulty, and start creating your chart."
315
+ });
316
+ ui.stageEyebrow.textContent = "STANDBY";
317
+ setProgress(0);
318
+ setStatusChip("idle", "Ready");
319
+ ui.queueLabel.textContent = "Not started";
320
+ ui.processError.hidden = true;
321
+ ui.processErrorMessage.textContent = "";
322
+ }
323
+
324
+ function showProcessError(message) {
325
+ const friendlyMessage = typeof message === "string" && message.trim()
326
+ ? message.trim()
327
+ : "An unexpected error occurred. Please try again shortly.";
328
+ setBusy(false);
329
+ setStatusChip("error", "Error");
330
+ ui.processErrorMessage.textContent = friendlyMessage;
331
+ ui.processError.hidden = false;
332
+ ui.queueLabel.textContent = "Generation incomplete";
333
+ announce(`Generation incomplete: ${friendlyMessage}`);
334
+ }
335
+
336
+ function handleQueueStatus(message) {
337
+ const status = String(message.stage || "").toLowerCase();
338
+ if (status === "pending") {
339
+ const position = Number(message.position);
340
+ const queueSize = Number(message.size);
341
+ const eta = Number(message.eta);
342
+ const parts = [];
343
+ if (Number.isFinite(position)) parts.push(`${Math.max(0, position)} job${position === 1 ? "" : "s"} ahead`);
344
+ if (Number.isFinite(queueSize)) parts.push(`${queueSize} job${queueSize === 1 ? "" : "s"} in queue`);
345
+ if (Number.isFinite(eta) && eta > 0) parts.push(`about ${Math.ceil(eta)} sec`);
346
+ ui.queueLabel.textContent = parts.length ? parts.join(" · ") : "Added to the generation queue";
347
+ setStatusChip("active", "Queued");
348
+ return;
349
+ }
350
+
351
+ if (status === "generating") {
352
+ ui.queueLabel.textContent = "The GPU is processing your song";
353
+ setStatusChip("active", "Generating");
354
+ return;
355
+ }
356
+
357
+ if (status === "error") {
358
+ showProcessError(message.message || "The server could not complete this generation.");
359
+ }
360
+ }
361
+
362
+ function fileDescriptor(value) {
363
+ if (!value) return { url: "", name: "" };
364
+ if (typeof value !== "object") return { url: "", name: "" };
365
+ return {
366
+ url: typeof value.url === "string" ? value.url : "",
367
+ name: typeof value.orig_name === "string" ? value.orig_name : ""
368
+ };
369
+ }
370
+
371
+ function resolvedFile(value) {
372
+ const descriptor = fileDescriptor(value);
373
+ if (!descriptor.url) return descriptor;
374
+ try {
375
+ const url = new URL(descriptor.url, window.location.href);
376
+ if (!["http:", "https:", "blob:"].includes(url.protocol)) return { url: "", name: "" };
377
+ return { url: url.href, name: descriptor.name };
378
+ } catch {
379
+ return { url: "", name: "" };
380
+ }
381
+ }
382
+
383
+ function setImage(image, emptyState, fileValue) {
384
+ const file = resolvedFile(fileValue);
385
+ if (file.url) {
386
+ image.src = file.url;
387
+ image.hidden = false;
388
+ emptyState.hidden = true;
389
+ return true;
390
+ }
391
+ image.removeAttribute("src");
392
+ image.hidden = true;
393
+ emptyState.hidden = false;
394
+ return false;
395
+ }
396
+
397
+ function setDownload(anchor, fileValue, defaultName) {
398
+ const file = resolvedFile(fileValue);
399
+ if (!file.url) {
400
+ anchor.hidden = true;
401
+ anchor.removeAttribute("href");
402
+ return false;
403
+ }
404
+ anchor.href = file.url;
405
+ anchor.download = file.name || defaultName;
406
+ anchor.hidden = false;
407
+ return true;
408
+ }
409
+
410
+ function metricValue(key, value) {
411
+ if (value === null || value === undefined || value === "") return "—";
412
+ if (key === "timing") {
413
+ if (value === "slot-exact") return "Grid-exact";
414
+ if (value === "time-quantized") return "Time-quantized";
415
+ }
416
+ if (key === "grid_rms_ms" && Number.isFinite(Number(value))) return `${Number(value).toFixed(1)} ms`;
417
+ if (key === "bpm" && Number.isFinite(Number(value))) return Number(value).toFixed(1);
418
+ if (typeof value === "boolean") return value ? "Yes" : "No";
419
+ if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(2);
420
+ if (typeof value === "object") return JSON.stringify(value);
421
+ return String(value).replaceAll("_", " ");
422
+ }
423
+
424
+ function renderMetrics(metrics) {
425
+ ui.metricsGrid.replaceChildren();
426
+ if (!metrics || typeof metrics !== "object" || Array.isArray(metrics)) {
427
+ ui.metricsGrid.hidden = true;
428
+ return;
429
+ }
430
+
431
+ const entries = Object.entries(metricLabels).map(([key, label]) => [
432
+ key,
433
+ label,
434
+ metrics[key]
435
+ ]);
436
+ ui.metricsGrid.hidden = entries.length === 0;
437
+ entries.forEach(([key, label, value]) => {
438
+ const wrapper = document.createElement("div");
439
+ const term = document.createElement("dt");
440
+ const description = document.createElement("dd");
441
+ wrapper.className = "metric";
442
+ term.textContent = label;
443
+ description.textContent = metricValue(key, value);
444
+ description.title = description.textContent;
445
+ wrapper.append(term, description);
446
+ ui.metricsGrid.append(wrapper);
447
+ });
448
+ }
449
+
450
+ function activatePreview(name, focus = false) {
451
+ const chartActive = name === "chart";
452
+ ui.chartTab.classList.toggle("is-active", chartActive);
453
+ ui.planTab.classList.toggle("is-active", !chartActive);
454
+ ui.chartTab.setAttribute("aria-selected", String(chartActive));
455
+ ui.planTab.setAttribute("aria-selected", String(!chartActive));
456
+ ui.chartTab.tabIndex = chartActive ? 0 : -1;
457
+ ui.planTab.tabIndex = chartActive ? -1 : 0;
458
+ ui.chartPreview.hidden = !chartActive;
459
+ ui.planPreview.hidden = chartActive;
460
+ if (focus) (chartActive ? ui.chartTab : ui.planTab).focus();
461
+ }
462
+
463
+ function renderResult(payload) {
464
+ const files = payload.files && typeof payload.files === "object" ? payload.files : {};
465
+ const audio = resolvedFile(files.audio);
466
+ if (audio.url) {
467
+ ui.resultAudio.src = audio.url;
468
+ ui.auditionCard.hidden = false;
469
+ } else {
470
+ ui.resultAudio.removeAttribute("src");
471
+ ui.resultAudio.load();
472
+ ui.auditionCard.hidden = true;
473
+ }
474
+
475
+ renderMetrics(payload.metrics);
476
+ const hasChart = setImage(ui.chartImage, ui.chartEmpty, files.chart_image);
477
+ const hasPlan = setImage(ui.planImage, ui.planEmpty, files.plan_image);
478
+ ui.previewBlock.hidden = !hasChart && !hasPlan;
479
+ ui.chartTab.hidden = !hasChart;
480
+ ui.planTab.hidden = !hasPlan;
481
+ activatePreview(hasChart ? "chart" : "plan");
482
+
483
+ setDownload(ui.downloadTja, files.tja, "softchart.tja");
484
+ setDownload(ui.downloadAudio, files.audio, "softchart-taiko-preview.wav");
485
+ ui.resultPanel.hidden = false;
486
+ }
487
+
488
+ function handlePayload(payload) {
489
+ if (!payload || typeof payload !== "object") return;
490
+ const kind = String(payload.kind || "progress").toLowerCase();
491
+
492
+ // Gradio may replay the generator's final yield before its terminal status.
493
+ if (kind === "complete" && receivedTerminalPayload) return;
494
+
495
+ if (kind === "error") {
496
+ receivedTerminalPayload = true;
497
+ showProcessError(payload.detail || payload.title || "The server could not complete this generation.");
498
+ return;
499
+ }
500
+
501
+ setStage(payload.stage, payload);
502
+ setProgress(payload.progress);
503
+ ui.processError.hidden = true;
504
+
505
+ if (kind === "complete") {
506
+ receivedTerminalPayload = true;
507
+ setStage("complete", payload);
508
+ setProgress(100);
509
+ setBusy(false);
510
+ setStatusChip("complete", "Complete");
511
+ ui.queueLabel.textContent = "All outputs are ready";
512
+ renderResult(payload);
513
+ announce("The chart, audition audio, and previews are ready");
514
+ }
515
+ }
516
+
517
+ function requestValues() {
518
+ const selectedCourse = $('input[name="course"]:checked', ui.form);
519
+ const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value);
520
+ return [
521
+ handle_file(currentFile),
522
+ currentFile.name,
523
+ selectedCourse ? selectedCourse.value : "oni",
524
+ Number.parseInt(ui.level.value, 10),
525
+ Number.isFinite(bpm) ? bpm : 0,
526
+ ui.autoPlan.checked,
527
+ ui.useBeat.checked,
528
+ ui.usePlanner.checked,
529
+ ui.sampling.checked,
530
+ Number(ui.temperature.value),
531
+ Number(ui.topP.value),
532
+ Number(ui.drumVolume.value)
533
+ ];
534
+ }
535
+
536
+ async function generate(event) {
537
+ event.preventDefault();
538
+ if (running) return;
539
+ clearFileError();
540
+ if (!currentFile) {
541
+ showFileError("Choose a song before generating a chart.");
542
+ ui.dropzone.focus();
543
+ return;
544
+ }
545
+
546
+ const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value);
547
+ if (!Number.isFinite(bpm) || (bpm !== 0 && (bpm < 30 || bpm > 400))) {
548
+ showFileError("Manual BPM must be between 30 and 400, or left blank for automatic detection.");
549
+ ui.bpm.focus();
550
+ return;
551
+ }
552
+
553
+ const runId = ++currentRun;
554
+ receivedTerminalPayload = false;
555
+ ui.resultPanel.hidden = true;
556
+ ui.processError.hidden = true;
557
+ setBusy(true);
558
+ setStage("loading");
559
+ setProgress(1);
560
+ setStatusChip("active", "Preparing");
561
+ ui.queueLabel.textContent = "Connecting to the generation service";
562
+ ui.processPanel.scrollIntoView({ behavior: "smooth", block: "start" });
563
+ announce("Chart generation submitted");
564
+
565
+ try {
566
+ const app = await connect();
567
+ if (runId !== currentRun) return;
568
+ currentJob = app.submit(ENDPOINT, requestValues());
569
+
570
+ for await (const message of currentJob) {
571
+ if (runId !== currentRun) break;
572
+ if (message.type === "status") {
573
+ handleQueueStatus(message);
574
+ } else if (message.type === "data") {
575
+ const payload = Array.isArray(message.data) ? message.data[0] : message.data;
576
+ handlePayload(payload);
577
+ }
578
+ }
579
+
580
+ if (runId === currentRun && running && !receivedTerminalPayload) {
581
+ showProcessError("The generation connection ended before a complete result arrived. Please try again.");
582
+ }
583
+ } catch (error) {
584
+ if (runId !== currentRun) return;
585
+ const message = error instanceof Error ? error.message : String(error);
586
+ showProcessError(message || "Could not connect to the SoftChart service.");
587
+ setConnection("error", "Connection lost");
588
+ } finally {
589
+ if (runId === currentRun) currentJob = null;
590
+ }
591
+ }
592
+
593
+ async function cancelGeneration() {
594
+ if (!running) return;
595
+ const job = currentJob;
596
+ currentRun += 1;
597
+ currentJob = null;
598
+ if (job && typeof job.cancel === "function") {
599
+ try {
600
+ await job.cancel();
601
+ } catch {
602
+ // The local job is still detached below even if it already started remotely.
603
+ }
604
+ }
605
+ setBusy(false);
606
+ setStatusChip("idle", "Cancelled");
607
+ setStage("loading", {
608
+ title: "Generation cancelled",
609
+ detail: "Adjust the settings whenever you are ready to try again."
610
+ });
611
+ ui.stageEyebrow.textContent = "CANCELLED";
612
+ ui.queueLabel.textContent = "Stopped receiving generation results";
613
+ announce("Generation cancelled");
614
+ }
615
+
616
+ function bindEvents() {
617
+ ui.form.addEventListener("submit", generate);
618
+ ui.fileInput.addEventListener("change", () => selectFile(ui.fileInput.files[0]));
619
+ ui.dropzone.addEventListener("click", () => ui.fileInput.click());
620
+ ui.dropzone.addEventListener("keydown", (event) => {
621
+ if (event.key === "Enter" || event.key === " ") {
622
+ event.preventDefault();
623
+ ui.fileInput.click();
624
+ }
625
+ });
626
+
627
+ ["dragenter", "dragover"].forEach((name) => {
628
+ ui.dropzone.addEventListener(name, (event) => {
629
+ event.preventDefault();
630
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
631
+ ui.dropzone.classList.add("is-dragging");
632
+ });
633
+ });
634
+ ["dragleave", "dragend"].forEach((name) => {
635
+ ui.dropzone.addEventListener(name, () => ui.dropzone.classList.remove("is-dragging"));
636
+ });
637
+ ui.dropzone.addEventListener("drop", (event) => {
638
+ event.preventDefault();
639
+ ui.dropzone.classList.remove("is-dragging");
640
+ selectFile(event.dataTransfer?.files?.[0]);
641
+ });
642
+
643
+ ui.removeFile.addEventListener("click", clearFile);
644
+ [ui.level, ui.temperature, ui.topP, ui.drumVolume].forEach((range) => {
645
+ range.addEventListener("input", updateRanges);
646
+ });
647
+ ui.sampling.addEventListener("change", () => setSamplingEnabled(ui.sampling.checked));
648
+ ui.cancelButton.addEventListener("click", cancelGeneration);
649
+ ui.retryButton.addEventListener("click", () => ui.form.requestSubmit());
650
+ ui.newChartButton.addEventListener("click", () => {
651
+ ui.resultPanel.hidden = true;
652
+ clearFile();
653
+ resetProcess();
654
+ ui.form.scrollIntoView({ behavior: "smooth", block: "start" });
655
+ });
656
+
657
+ ui.chartTab.addEventListener("click", () => activatePreview("chart"));
658
+ ui.planTab.addEventListener("click", () => activatePreview("plan"));
659
+ $$(".preview-tab").forEach((tab) => {
660
+ tab.addEventListener("keydown", (event) => {
661
+ if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
662
+ event.preventDefault();
663
+ const next = tab === ui.chartTab ? "plan" : "chart";
664
+ const target = next === "chart" ? ui.chartTab : ui.planTab;
665
+ if (!target.hidden) activatePreview(next, true);
666
+ });
667
+ });
668
+
669
+ window.addEventListener("beforeunload", () => {
670
+ if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl);
671
+ });
672
+ }
673
+
674
+ bindEvents();
675
+ updateRanges();
676
+ setSamplingEnabled(ui.sampling.checked);
677
+ resetProcess();
678
+ connect().catch(() => {
679
+ ui.queueLabel.textContent = "We will reconnect when you start generation";
680
+ });
static/index.html ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="SoftChart turns music into playable, musical Taiko charts with an instant drum-mix audition.">
7
+ <meta name="theme-color" content="#f6f3ec">
8
+ <title>SoftChart — AI Taiko Chart Studio</title>
9
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='25' fill='%23e14b3f'/%3E%3Ccircle cx='32' cy='32' r='18' fill='%23fff8ed'/%3E%3Ccircle cx='32' cy='32' r='9' fill='%23e14b3f'/%3E%3C/svg%3E">
10
+ <link rel="stylesheet" href="./static/app.css">
11
+ <script type="module" src="./static/app.js"></script>
12
+ </head>
13
+ <body>
14
+ <a class="skip-link" href="#main-content">Skip to main content</a>
15
+
16
+ <header class="site-header">
17
+ <a class="brand" href="./" aria-label="SoftChart home">
18
+ <svg class="brand-mark" viewBox="0 0 48 48" aria-hidden="true">
19
+ <circle cx="24" cy="24" r="20" fill="currentColor" opacity=".12"/>
20
+ <circle cx="24" cy="24" r="14.5" fill="none" stroke="currentColor" stroke-width="2.5"/>
21
+ <circle cx="24" cy="24" r="5.5" fill="currentColor"/>
22
+ <path d="M8.5 24h5M34.5 24h5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
23
+ </svg>
24
+ <span>SoftChart</span>
25
+ <span class="brand-edition">STUDIO</span>
26
+ </a>
27
+ <div class="connection" id="connection" role="status" aria-live="polite">
28
+ <span class="connection-dot" aria-hidden="true"></span>
29
+ <span id="connection-label">Connecting</span>
30
+ </div>
31
+ </header>
32
+
33
+ <main id="main-content">
34
+ <section class="hero" aria-labelledby="hero-title">
35
+ <div class="hero-copy">
36
+ <h1 id="hero-title">Music in.<br><em>Taiko out.</em></h1>
37
+ <p class="hero-lede">Upload a track. Get a playable TJA. Hear it instantly.</p>
38
+ </div>
39
+ <ul class="hero-features" aria-label="Key features">
40
+ <li><strong>Beat-locked</strong></li>
41
+ <li><strong>Structure-aware</strong></li>
42
+ <li><strong>Instant audition</strong></li>
43
+ </ul>
44
+ </section>
45
+
46
+ <section class="studio-grid" aria-label="Taiko chart generation studio">
47
+ <form class="panel setup-panel" id="generate-form">
48
+ <div class="panel-heading">
49
+ <h2>Create</h2>
50
+ </div>
51
+
52
+ <div class="form-section">
53
+ <div class="label-row">
54
+ <label id="upload-label" for="file-input">Music</label>
55
+ <span class="field-hint">MP3, WAV, FLAC, OGG</span>
56
+ </div>
57
+ <input id="file-input" type="file" accept=".mp3,.wav,.flac,.ogg" tabindex="-1" aria-hidden="true">
58
+ <div class="dropzone" id="dropzone" role="button" tabindex="0" aria-labelledby="upload-label upload-help">
59
+ <svg class="upload-icon" viewBox="0 0 56 56" aria-hidden="true">
60
+ <circle cx="28" cy="28" r="27" fill="currentColor" opacity=".08"/>
61
+ <path d="M28 36V18m0 0-7 7m7-7 7 7M17 38.5h22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
62
+ </svg>
63
+ <span class="drop-title">Drop audio</span>
64
+ <span class="drop-subtitle" id="upload-help">or browse</span>
65
+ </div>
66
+
67
+ <div class="source-card" id="source-card" hidden>
68
+ <div class="source-info">
69
+ <span class="file-badge" aria-hidden="true">♪</span>
70
+ <span class="file-copy">
71
+ <strong id="file-name"></strong>
72
+ <span id="file-meta"></span>
73
+ </span>
74
+ <button type="button" class="icon-button" id="remove-file" aria-label="Remove music">
75
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m6 6 8 8m0-8-8 8"/></svg>
76
+ </button>
77
+ </div>
78
+ <audio id="source-audio" controls preload="metadata">Your browser does not support audio playback.</audio>
79
+ </div>
80
+ <p class="field-error" id="file-error" role="alert" hidden></p>
81
+ </div>
82
+
83
+ <div class="form-section split-rule">
84
+ <fieldset class="field-group">
85
+ <legend>Difficulty</legend>
86
+ <div class="course-selector">
87
+ <label><input type="radio" name="course" value="easy"><span><small>E</small>Easy</span></label>
88
+ <label><input type="radio" name="course" value="normal"><span><small>N</small>Normal</span></label>
89
+ <label><input type="radio" name="course" value="hard"><span><small>H</small>Hard</span></label>
90
+ <label><input type="radio" name="course" value="oni" checked><span><small>O</small>Oni</span></label>
91
+ </div>
92
+ </fieldset>
93
+
94
+ <div class="two-column-fields">
95
+ <div class="field-group">
96
+ <div class="label-row">
97
+ <label for="level">Star level</label>
98
+ <output class="value-output" id="level-output" for="level">★ 9</output>
99
+ </div>
100
+ <input class="range" id="level" type="range" min="1" max="10" value="9" step="1">
101
+ </div>
102
+ <div class="field-group">
103
+ <div class="label-row">
104
+ <label for="bpm">BPM</label>
105
+ </div>
106
+ <div class="input-with-unit">
107
+ <input id="bpm" type="number" min="0" max="400" step="0.1" inputmode="decimal" placeholder="AUTO">
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <details class="advanced split-rule" id="advanced-settings">
114
+ <summary>
115
+ <span><strong>Advanced</strong></span>
116
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m6 8 4 4 4-4"/></svg>
117
+ </summary>
118
+ <div class="advanced-body">
119
+ <div class="toggle-list">
120
+ <label class="toggle-card compact">
121
+ <span><strong>Structure</strong></span>
122
+ <input id="auto-plan" type="checkbox" checked>
123
+ <span class="switch" aria-hidden="true"></span>
124
+ </label>
125
+ <label class="toggle-card compact">
126
+ <span><strong>Beat grid</strong></span>
127
+ <input id="use-beat" type="checkbox" checked>
128
+ <span class="switch" aria-hidden="true"></span>
129
+ </label>
130
+ <label class="toggle-card compact">
131
+ <span><strong>AI planner</strong></span>
132
+ <input id="use-planner" type="checkbox" checked>
133
+ <span class="switch" aria-hidden="true"></span>
134
+ </label>
135
+ </div>
136
+ <label class="toggle-card compact">
137
+ <span><strong>Creative sampling</strong></span>
138
+ <input id="sampling" type="checkbox">
139
+ <span class="switch" aria-hidden="true"></span>
140
+ </label>
141
+ <div class="parameter-grid" id="sampling-parameters" aria-disabled="true">
142
+ <div class="field-group">
143
+ <div class="label-row">
144
+ <label for="temperature">Variation</label>
145
+ <output class="value-output" id="temperature-output" for="temperature">0.80</output>
146
+ </div>
147
+ <input class="range" id="temperature" type="range" min="0.2" max="1.2" value="0.8" step="0.05" disabled>
148
+ </div>
149
+ <div class="field-group">
150
+ <div class="label-row">
151
+ <label for="top-p">Candidate range</label>
152
+ <output class="value-output" id="top-p-output" for="top-p">0.90</output>
153
+ </div>
154
+ <input class="range" id="top-p" type="range" min="0.5" max="1" value="0.9" step="0.01" disabled>
155
+ </div>
156
+ </div>
157
+ <div class="field-group drum-volume-field">
158
+ <div class="label-row">
159
+ <label for="drum-volume">Taiko preview volume</label>
160
+ <output class="value-output" id="drum-volume-output" for="drum-volume">80%</output>
161
+ </div>
162
+ <input class="range" id="drum-volume" type="range" min="0" max="1.5" value="0.8" step="0.05">
163
+ </div>
164
+ </div>
165
+ </details>
166
+
167
+ <button class="primary-button" id="generate-button" type="submit">
168
+ <span>Generate chart</span>
169
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-5-5 5 5-5 5"/></svg>
170
+ </button>
171
+ </form>
172
+
173
+ <div class="output-column">
174
+ <section class="panel process-panel" id="process-panel" aria-labelledby="process-title">
175
+ <div class="panel-heading">
176
+ <h2 id="process-title">Progress</h2>
177
+ <span class="status-chip is-idle" id="status-chip">Ready</span>
178
+ </div>
179
+
180
+ <div class="stage-canvas" id="stage-canvas">
181
+ <div class="stage-visual is-active" data-stage="loading" aria-hidden="true">
182
+ <svg viewBox="0 0 520 280">
183
+ <g class="loading-grid" stroke="currentColor" opacity=".07"><path d="M0 56h520M0 112h520M0 168h520M0 224h520M104 0v280M208 0v280M312 0v280M416 0v280"/></g>
184
+ <g transform="translate(260 140)">
185
+ <circle class="loading-orbit orbit-a" r="68" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="10 12" opacity=".25"/>
186
+ <circle class="loading-orbit orbit-b" r="48" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 9" opacity=".4"/>
187
+ <circle r="27" fill="currentColor" opacity=".1"/>
188
+ <circle class="loading-core" r="12" fill="currentColor"/>
189
+ <circle class="loading-dot dot-a" cx="0" cy="-68" r="5" fill="currentColor"/>
190
+ <circle class="loading-dot dot-b" cx="48" cy="0" r="4" fill="currentColor"/>
191
+ </g>
192
+ </svg>
193
+ </div>
194
+
195
+ <div class="stage-visual" data-stage="audio" aria-hidden="true">
196
+ <svg viewBox="0 0 520 280">
197
+ <path class="audio-line-back" d="M24 140C54 140 61 112 84 112s28 68 51 68 31-115 55-115 30 150 53 150 33-104 57-104 30 59 55 59 29-76 52-76 29 46 49 46h40" fill="none" stroke="currentColor" stroke-width="2" opacity=".14"/>
198
+ <path class="audio-line" d="M24 140C54 140 61 112 84 112s28 68 51 68 31-115 55-115 30 150 53 150 33-104 57-104 30 59 55 59 29-76 52-76 29 46 49 46h40" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
199
+ <g class="audio-playhead"><path d="M0 48v184" stroke="currentColor" stroke-width="1.5" opacity=".45"/><circle cy="140" r="7" fill="currentColor"/></g>
200
+ <g fill="currentColor" opacity=".22"><circle cx="84" cy="112" r="4"/><circle cx="190" cy="65" r="4"/><circle cx="300" cy="111" r="4"/><circle cx="407" cy="94" r="4"/></g>
201
+ </svg>
202
+ </div>
203
+
204
+ <div class="stage-visual" data-stage="beat" aria-hidden="true">
205
+ <svg viewBox="0 0 520 280">
206
+ <path d="M72 205h376" stroke="currentColor" stroke-width="2" opacity=".14"/>
207
+ <g class="beat-ticks" stroke="currentColor" opacity=".26"><path d="M100 192v26M180 192v26M260 185v40M340 192v26M420 192v26"/></g>
208
+ <path class="beat-arc" d="M182 199a78 78 0 0 1 156 0" fill="none" stroke="currentColor" stroke-width="3" opacity=".18"/>
209
+ <g class="metronome" transform="translate(260 199)">
210
+ <path d="M0 0 54-118" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
211
+ <circle cx="54" cy="-118" r="10" fill="currentColor"/>
212
+ </g>
213
+ <g class="beat-pulses" fill="currentColor"><circle cx="100" cy="205" r="7"/><circle cx="180" cy="205" r="7"/><circle cx="260" cy="205" r="10"/><circle cx="340" cy="205" r="7"/><circle cx="420" cy="205" r="7"/></g>
214
+ </svg>
215
+ </div>
216
+
217
+ <div class="stage-visual" data-stage="plan" aria-hidden="true">
218
+ <svg viewBox="0 0 520 280">
219
+ <g class="plan-bars">
220
+ <rect x="42" y="166" width="45" height="60" rx="5"/>
221
+ <rect x="96" y="143" width="45" height="83" rx="5"/>
222
+ <rect x="150" y="153" width="45" height="73" rx="5"/>
223
+ <rect x="204" y="105" width="45" height="121" rx="5"/>
224
+ <rect x="258" y="127" width="45" height="99" rx="5"/>
225
+ <rect x="312" y="83" width="45" height="143" rx="5"/>
226
+ <rect x="366" y="53" width="45" height="173" rx="5" class="plan-climax"/>
227
+ <rect x="420" y="118" width="45" height="108" rx="5"/>
228
+ </g>
229
+ <path class="plan-path" d="M64 158 118 135l54 10 54-49 54 22 54-43 54-31 54 65" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
230
+ <circle class="plan-cursor" cx="64" cy="158" r="7" fill="currentColor"/>
231
+ </svg>
232
+ </div>
233
+
234
+ <div class="stage-visual" data-stage="generate" aria-hidden="true">
235
+ <svg viewBox="0 0 520 280">
236
+ <path d="M20 94h480M20 186h480" stroke="currentColor" stroke-width="2" opacity=".13"/>
237
+ <path d="M20 140h480" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 10" opacity=".13"/>
238
+ <g class="note-stream">
239
+ <g class="note note-1"><circle r="20"/><circle r="11" fill="#fff8ed" opacity=".2"/></g>
240
+ <g class="note note-2"><circle r="20" class="ka"/><circle r="11" fill="#fff8ed" opacity=".2"/></g>
241
+ <g class="note note-3"><circle r="27"/><circle r="15" fill="#fff8ed" opacity=".2"/></g>
242
+ <g class="note note-4"><circle r="20" class="ka"/><circle r="11" fill="#fff8ed" opacity=".2"/></g>
243
+ <g class="note note-5"><circle r="20"/><circle r="11" fill="#fff8ed" opacity=".2"/></g>
244
+ </g>
245
+ <g class="generate-gate" transform="translate(93 140)"><circle r="42" fill="none" stroke="currentColor" stroke-width="2" opacity=".22"/><circle r="31" fill="none" stroke="currentColor" stroke-width="2" opacity=".38"/><path d="M0-49v98" stroke="currentColor" stroke-width="3"/></g>
246
+ </svg>
247
+ </div>
248
+
249
+ <div class="stage-visual" data-stage="export" aria-hidden="true">
250
+ <svg viewBox="0 0 520 280">
251
+ <g class="export-sheet" transform="translate(175 34)">
252
+ <path d="M8 0h126l42 42v189H8z" fill="currentColor" opacity=".08"/>
253
+ <path d="M134 0v42h42" fill="none" stroke="currentColor" stroke-width="2" opacity=".35"/>
254
+ <g class="export-lines" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M38 77h108M38 104h78M38 131h108M38 158h91"/></g>
255
+ <g class="export-dots" fill="currentColor"><circle cx="48" cy="194" r="8"/><circle cx="76" cy="194" r="8" class="ka"/><circle cx="104" cy="194" r="8"/><circle cx="132" cy="194" r="8" class="ka"/></g>
256
+ </g>
257
+ <path class="export-arrow" d="M366 91v105m0 0-20-20m20 20 20-20" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
258
+ </svg>
259
+ </div>
260
+
261
+ <div class="stage-visual" data-stage="mix" aria-hidden="true">
262
+ <svg viewBox="0 0 520 280">
263
+ <g class="mix-wave wave-top" fill="none" stroke="currentColor" stroke-width="3"><path d="M35 93c26 0 26-32 52-32s26 64 52 64 26-64 52-64 26 64 52 64 26-64 52-64 26 64 52 64 26-64 52-64 26 32 52 32h46"/></g>
264
+ <g class="mix-wave wave-bottom" fill="none" stroke="currentColor" stroke-width="3"><path d="M35 187c34 0 34-22 68-22s34 44 68 44 34-44 68-44 34 44 68 44 34-44 68-44 34 22 68 22h72"/></g>
265
+ <g class="mix-drum" transform="translate(260 140)"><circle r="48" fill="#fff8ed" stroke="currentColor" stroke-width="4"/><circle r="35" fill="currentColor" opacity=".12"/><circle r="13" fill="currentColor"/></g>
266
+ <g class="mix-spark" fill="currentColor"><circle cx="201" cy="119" r="5"/><circle cx="319" cy="161" r="5"/><circle cx="312" cy="100" r="3"/><circle cx="211" cy="177" r="3"/></g>
267
+ </svg>
268
+ </div>
269
+
270
+ <div class="stage-visual" data-stage="complete" aria-hidden="true">
271
+ <svg viewBox="0 0 520 280">
272
+ <g class="complete-rings" transform="translate(260 140)"><circle r="81" fill="none" stroke="currentColor" stroke-width="2" opacity=".12"/><circle r="64" fill="currentColor" opacity=".1"/><circle r="47" fill="currentColor"/></g>
273
+ <path class="complete-check" d="m235 140 17 17 35-38" fill="none" stroke="#fff8ed" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
274
+ <g class="confetti" fill="currentColor"><rect x="165" y="65" width="7" height="18" rx="3" transform="rotate(-35 168 74)"/><rect x="350" y="70" width="7" height="18" rx="3" transform="rotate(28 353 79)"/><circle cx="177" cy="201" r="6"/><circle cx="350" cy="207" r="5"/><path d="m387 139 12-7-1 14z"/><path d="m126 132 13-5-3 14z"/></g>
275
+ </svg>
276
+ </div>
277
+ </div>
278
+
279
+ <div class="process-copy">
280
+ <div class="process-title-row">
281
+ <div>
282
+ <p class="process-eyebrow" id="stage-eyebrow">STANDBY</p>
283
+ <h3 id="stage-title">Waiting for your music</h3>
284
+ </div>
285
+ <strong class="progress-number" id="progress-number">0%</strong>
286
+ </div>
287
+ <p class="sr-only" id="stage-detail">Upload a track to begin.</p>
288
+ <div class="progress-track" id="progress-track" role="progressbar" aria-label="Chart generation progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
289
+ <span id="progress-bar"></span>
290
+ </div>
291
+ <div class="queue-row">
292
+ <span id="queue-label">Not started</span>
293
+ <button class="text-button" type="button" id="cancel-button" hidden>Cancel generation</button>
294
+ </div>
295
+ </div>
296
+ <div class="process-error" id="process-error" role="alert" hidden>
297
+ <strong>Generation incomplete</strong>
298
+ <span id="process-error-message"></span>
299
+ <button class="text-button" type="button" id="retry-button">Try again</button>
300
+ </div>
301
+ </section>
302
+
303
+ <section class="panel result-panel" id="result-panel" aria-labelledby="result-title" hidden>
304
+ <div class="panel-heading">
305
+ <h2 id="result-title">Ready</h2>
306
+ </div>
307
+
308
+ <div class="audition-card" id="audition-card">
309
+ <div class="audition-heading">
310
+ <span class="audition-icon" aria-hidden="true">
311
+ <svg viewBox="0 0 32 32"><circle cx="16" cy="16" r="12"/><path d="m13 10 9 6-9 6z"/></svg>
312
+ </span>
313
+ <span><strong>Taiko audition</strong></span>
314
+ </div>
315
+ <audio id="result-audio" controls preload="metadata">Your browser does not support audio playback.</audio>
316
+ </div>
317
+
318
+ <dl class="metrics-grid" id="metrics-grid" aria-label="Generation summary"></dl>
319
+
320
+ <div class="preview-block">
321
+ <div class="preview-tabs" role="tablist" aria-label="Preview type">
322
+ <button id="chart-tab" class="preview-tab is-active" type="button" role="tab" aria-selected="true" aria-controls="chart-preview">Chart preview</button>
323
+ <button id="plan-tab" class="preview-tab" type="button" role="tab" aria-selected="false" aria-controls="plan-preview" tabindex="-1">Song structure</button>
324
+ </div>
325
+ <div class="preview-frame">
326
+ <div id="chart-preview" role="tabpanel" aria-labelledby="chart-tab">
327
+ <img id="chart-image" alt="Preview of the generated Taiko chart">
328
+ <p class="preview-empty" id="chart-empty" hidden>No chart preview is available.</p>
329
+ </div>
330
+ <div id="plan-preview" role="tabpanel" aria-labelledby="plan-tab" hidden>
331
+ <img id="plan-image" alt="Song energy and chart section plan">
332
+ <p class="preview-empty" id="plan-empty" hidden>No song structure preview is available.</p>
333
+ </div>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="download-group">
338
+ <a class="download-button primary-download" id="download-tja" href="#" download hidden>
339
+ <span><strong>Download TJA</strong></span>
340
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 20h14"/></svg>
341
+ </a>
342
+ <a class="download-button" id="download-audio" href="#" download hidden>
343
+ <span><strong>Download audio</strong></span>
344
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 20h14"/></svg>
345
+ </a>
346
+ </div>
347
+
348
+ <button class="secondary-button" id="new-chart-button" type="button">Create another chart</button>
349
+ </section>
350
+ </div>
351
+ </section>
352
+ </main>
353
+
354
+ <footer>
355
+ <span>SoftChart</span>
356
+ <a href="https://github.com/JacobLinCool/SoftChart" target="_blank" rel="noreferrer">GitHub <span aria-hidden="true">↗</span></a>
357
+ </footer>
358
+
359
+ <div class="sr-only" id="announcer" aria-live="assertive" aria-atomic="true"></div>
360
+ </body>
361
+ </html>