markury commited on
Commit
fa8dc8b
·
1 Parent(s): b5a56ee

add example and new viz

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.ogg filter=lfs diff=lfs merge=lfs -text
37
+ *.json filter=lfs diff=lfs merge=lfs -text
app.py CHANGED
@@ -1,6 +1,9 @@
1
  """midmid3: Guitar Hero Chart Generator Demo"""
2
 
 
3
  import os
 
 
4
  import gradio as gr
5
 
6
  # ZeroGPU: import spaces if available (no-op locally)
@@ -13,6 +16,31 @@ except ImportError:
13
  from pipeline import generate_chart
14
  from visualizer import build_visualizer_html
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  PLACEHOLDER_HTML = """
17
  <div style="font-family: system-ui, sans-serif; background: #111; border-radius: 12px;
18
  padding: 60px 20px; text-align: center; color: #666; max-width: 900px; margin: 0 auto;">
@@ -61,7 +89,7 @@ with gr.Blocks(
61
  )
62
 
63
  with gr.Row():
64
- with gr.Column(scale=1):
65
  audio_input = gr.Audio(
66
  label="Upload audio",
67
  type="filepath",
@@ -77,10 +105,11 @@ with gr.Blocks(
77
  genre_input = gr.Textbox(label="Genre", placeholder="rock", value="rock")
78
 
79
  generate_btn = gr.Button("Generate Chart", variant="primary", elem_id="generate-btn")
 
 
80
 
81
- with gr.Column(scale=2):
82
  viz_output = gr.HTML(value=PLACEHOLDER_HTML, label="Chart Preview")
83
- zip_output = gr.File(label="Download song package (.zip)")
84
 
85
  generate_btn.click(
86
  fn=_generate_wrapper,
@@ -88,6 +117,12 @@ with gr.Blocks(
88
  outputs=[viz_output, zip_output],
89
  )
90
 
 
 
 
 
 
 
91
  gr.Markdown(
92
  "---\n"
93
  "*Powered by [midmid3](https://huggingface.co/markury/midmid3-19m-0326) — "
@@ -105,8 +140,5 @@ with gr.Blocks(
105
  if __name__ == "__main__":
106
  demo.launch(
107
  theme=gr.themes.Base(primary_hue="purple", neutral_hue="gray"),
108
- css="""
109
- .gradio-container { max-width: 960px !important; }
110
- #generate-btn { min-height: 48px; font-size: 16px; }
111
- """,
112
  )
 
1
  """midmid3: Guitar Hero Chart Generator Demo"""
2
 
3
+ import json
4
  import os
5
+ from pathlib import Path
6
+
7
  import gradio as gr
8
 
9
  # ZeroGPU: import spaces if available (no-op locally)
 
16
  from pipeline import generate_chart
17
  from visualizer import build_visualizer_html
18
 
19
+ EXAMPLES_DIR = Path(__file__).parent / "examples"
20
+
21
+
22
+ def _load_example():
23
+ """Load pre-computed demo - no GPU needed."""
24
+ chart_path = EXAMPLES_DIR / "hot_mess.json"
25
+ zip_path = EXAMPLES_DIR / "Hot Mess - Friday Pilots Club.zip"
26
+ audio_path = EXAMPLES_DIR / "hot_mess_input.ogg"
27
+
28
+ with open(chart_path) as f:
29
+ chart_json = json.load(f)
30
+
31
+ viz_html = build_visualizer_html(chart_json)
32
+ return (
33
+ str(audio_path), # audio_input
34
+ "Hot Mess", # title
35
+ "Friday Pilots Club", # artist
36
+ "", # album
37
+ "2022", # year
38
+ "rock", # genre
39
+ viz_html, # viz_output
40
+ str(zip_path), # zip_output
41
+ )
42
+
43
+
44
  PLACEHOLDER_HTML = """
45
  <div style="font-family: system-ui, sans-serif; background: #111; border-radius: 12px;
46
  padding: 60px 20px; text-align: center; color: #666; max-width: 900px; margin: 0 auto;">
 
89
  )
90
 
91
  with gr.Row():
92
+ with gr.Column(scale=1, min_width=280):
93
  audio_input = gr.Audio(
94
  label="Upload audio",
95
  type="filepath",
 
105
  genre_input = gr.Textbox(label="Genre", placeholder="rock", value="rock")
106
 
107
  generate_btn = gr.Button("Generate Chart", variant="primary", elem_id="generate-btn")
108
+ example_btn = gr.Button("Load Example", variant="secondary", size="sm")
109
+ zip_output = gr.File(label="Download song package (.zip)")
110
 
111
+ with gr.Column(scale=3):
112
  viz_output = gr.HTML(value=PLACEHOLDER_HTML, label="Chart Preview")
 
113
 
114
  generate_btn.click(
115
  fn=_generate_wrapper,
 
117
  outputs=[viz_output, zip_output],
118
  )
119
 
120
+ example_btn.click(
121
+ fn=_load_example,
122
+ inputs=[],
123
+ outputs=[audio_input, title_input, artist_input, album_input, year_input, genre_input, viz_output, zip_output],
124
+ )
125
+
126
  gr.Markdown(
127
  "---\n"
128
  "*Powered by [midmid3](https://huggingface.co/markury/midmid3-19m-0326) — "
 
140
  if __name__ == "__main__":
141
  demo.launch(
142
  theme=gr.themes.Base(primary_hue="purple", neutral_hue="gray"),
143
+ css="#generate-btn { min-height: 48px; font-size: 16px; }",
 
 
 
144
  )
examples/Hot Mess - Friday Pilots Club.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0a29a48c187fbb6ebbe9621ff9705f7471bc47eff1c54eb4705fa4e9bc4eea80
3
+ size 2623981
examples/hot_mess.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:13e70f6bea0b482f7e0645f4dae0160024c66b0d2c167099c517256dbec55242
3
+ size 3614477
examples/hot_mess_input.ogg ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2b2d6ff38f42882a56b6db92ec383e96133c9a1078257faa864ba9a38cf8d575
3
+ size 2624840
static/visualizer.js ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * midmid chart visualizer – Guitar Hero-style vertical track
3
+ *
4
+ * Standalone module — same rendering logic used in the Gradio Space.
5
+ * Edit this file, Vite HMR picks it up instantly.
6
+ *
7
+ * To export back to the Space: the buildVisualizerHTML() in visualizer.py
8
+ * inlines this into an iframe. Keep the DOM structure and DATA contract
9
+ * the same so it stays compatible.
10
+ *
11
+ * DATA contract (chart JSON):
12
+ * resolution: number (ticks per quarter, usually 192)
13
+ * bpm: number
14
+ * tempo_events: [{tick, bpm}, ...]
15
+ * time_signatures: [{tick, num, den}, ...]
16
+ * sections: [{tick, label}, ...]
17
+ * beats: [{tick, downbeat}, ...]
18
+ * notes: { expert: [{tick, frets: number[], sustain, hopo}, ...], hard: [...], ... }
19
+ * audio_b64: string (base64-encoded OGG)
20
+ * audio_format: string
21
+ */
22
+
23
+ // ─── Colors & constants ──────────────────────────────────────────
24
+ const FRET_COLORS = ['#22c55e', '#ef4444', '#eab308', '#3b82f6', '#f97316'];
25
+ const FRET_GLOW = ['#4ade80', '#f87171', '#facc15', '#60a5fa', '#fb923c'];
26
+ const LANE_COUNT = 5;
27
+
28
+ // ─── Track geometry tunables ─────────────────────────────────────
29
+ const VISIBLE_SEC = 4.5; // seconds of future notes visible on track
30
+ const CANVAS_ASPECT = 0.72; // height = width * aspect (responsive)
31
+ const CANVAS_MIN_H = 400;
32
+ const CANVAS_MAX_H = 700;
33
+ const STRIKE_Y_FRAC = 0.88; // strikeline Y (fraction from top)
34
+ const TOP_Y_FRAC = 0.04; // top of visible track
35
+ const BOTTOM_W_FRAC = 0.52; // track width at strikeline
36
+ const TOP_W_FRAC = 0.14; // track width at far end
37
+ const NOTE_LANE_RATIO = 0.42; // note rx as fraction of lane width (~84% diameter)
38
+ const NOTE_SQUISH = 0.40; // constant ellipse squish (width:height ratio)
39
+
40
+ // ─── State ───────────────────────────────────────────────────────
41
+ let DATA = null, audio = null, canvas, ctx;
42
+ let W, H;
43
+ let currentDiff = 'expert';
44
+ let playing = false;
45
+ let noteCache = [], beatCache = [], sectionCache = [];
46
+ let tempoMap = [], RES = 192, totalDuration = 0;
47
+
48
+ // Computed on resize
49
+ let strikeY, topY, centerX, bottomW, topW, zFar, sFar, noteRX;
50
+
51
+ // ─── Timing ──────────────────────────────────────────────────────
52
+
53
+ function tickToSec(tick) {
54
+ let sec = 0, prevTick = 0, bpm = tempoMap[0].bpm;
55
+ for (let i = 1; i < tempoMap.length; i++) {
56
+ if (tempoMap[i].tick > tick) break;
57
+ sec += (tempoMap[i].tick - prevTick) / RES * 60 / bpm;
58
+ prevTick = tempoMap[i].tick;
59
+ bpm = tempoMap[i].bpm;
60
+ }
61
+ return sec + (tick - prevTick) / RES * 60 / bpm;
62
+ }
63
+
64
+ // ─── Caching ─────────────────────────────────────────────────────
65
+
66
+ function buildNoteCache(diff) {
67
+ return (DATA.notes[diff] || []).map(n => ({
68
+ sec: tickToSec(n.tick),
69
+ frets: n.frets,
70
+ sustainSec: n.sustain > 0 ? tickToSec(n.tick + n.sustain) - tickToSec(n.tick) : 0,
71
+ hopo: n.hopo,
72
+ }));
73
+ }
74
+
75
+ function rebuildCaches() {
76
+ tempoMap = DATA.tempo_events.map(e => ({ tick: e.tick, bpm: e.bpm }));
77
+ RES = DATA.resolution;
78
+ noteCache = buildNoteCache(currentDiff);
79
+
80
+ // 3-level beat subdivisions (Moonscraper style):
81
+ // level 0 = measure line, 1 = beat line, 2 = sub-beat (eighth note)
82
+ const rawBeats = DATA.beats.map(b => ({ sec: tickToSec(b.tick), downbeat: b.downbeat }));
83
+ beatCache = [];
84
+ for (let i = 0; i < rawBeats.length; i++) {
85
+ beatCache.push({ sec: rawBeats[i].sec, level: rawBeats[i].downbeat ? 0 : 1 });
86
+ if (i < rawBeats.length - 1) {
87
+ beatCache.push({ sec: (rawBeats[i].sec + rawBeats[i + 1].sec) / 2, level: 2 });
88
+ }
89
+ }
90
+
91
+ sectionCache = DATA.sections.map(s => ({ sec: tickToSec(s.tick), label: s.label }));
92
+ }
93
+
94
+ // ─── Duration & format ───────────────────────────────────────────
95
+
96
+ function getDuration() {
97
+ if (totalDuration && isFinite(totalDuration)) return totalDuration;
98
+ const all = Object.values(DATA.notes).flat().map(n => tickToSec(n.tick + (n.sustain || 0)));
99
+ return all.length ? Math.max(...all) + 5 : 120;
100
+ }
101
+
102
+ function fmt(s) {
103
+ const m = Math.floor(s / 60), sc = Math.floor(s % 60);
104
+ return m + ':' + (sc < 10 ? '0' : '') + sc;
105
+ }
106
+
107
+ // ─── Resize ──────────────────────────────────────────────────────
108
+
109
+ function resize() {
110
+ const container = document.getElementById('midmid-viz');
111
+ W = container.clientWidth;
112
+ H = Math.round(Math.max(CANVAS_MIN_H, Math.min(CANVAS_MAX_H, W * CANVAS_ASPECT)));
113
+ canvas.width = W * devicePixelRatio;
114
+ canvas.height = H * devicePixelRatio;
115
+ canvas.style.height = H + 'px';
116
+ ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
117
+
118
+ centerX = W / 2;
119
+ strikeY = H * STRIKE_Y_FRAC;
120
+ topY = H * TOP_Y_FRAC;
121
+ bottomW = W * BOTTOM_W_FRAC;
122
+ topW = W * TOP_W_FRAC;
123
+ // Perspective derived from the taper: the width ratio IS the depth ratio
124
+ zFar = BOTTOM_W_FRAC / TOP_W_FRAC; // ≈ 3.71
125
+ sFar = 1 / zFar; // ≈ 0.269
126
+ // Note size proportional to lane width (Moonscraper: ~1:1 sprite in 1-unit lane)
127
+ noteRX = (bottomW / LANE_COUNT) * NOTE_LANE_RATIO;
128
+ }
129
+
130
+ // ─── True perspective projection ─────────────────────────────────
131
+ // One 1/z calculation drives everything: Y position, track width,
132
+ // note size, and lane spacing — so beat-line gaps look correct.
133
+
134
+ const clamp01 = v => Math.max(0, Math.min(1, v));
135
+
136
+ /** Project a time-offset into screen space.
137
+ * Returns { y, w, s } where s is the perspective scale factor (1 at
138
+ * strikeline, sFar at the far end). Width, note size, etc. all scale by s. */
139
+ function project(secAhead) {
140
+ const t = clamp01(secAhead / VISIBLE_SEC); // 0 → 1 in world space
141
+ const z = 1 + t * (zFar - 1); // linear depth
142
+ const s = 1 / z; // perspective scale
143
+ const y = strikeY - (1 - s) / (1 - sFar) * (strikeY - topY);
144
+ const w = bottomW * s;
145
+ return { y, w, s };
146
+ }
147
+
148
+ /** Screen position for a lane at a given time offset from strikeline */
149
+ function getPoint(lane, secAhead) {
150
+ const { y, w, s } = project(Math.max(0, secAhead));
151
+ const lw = w / LANE_COUNT;
152
+ const x = centerX - w / 2 + (lane + 0.5) * lw;
153
+ return { x, y, scale: s };
154
+ }
155
+
156
+ // ─── Color helpers ───────────────────────────────────────────────
157
+
158
+ function hexRgb(hex) {
159
+ return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
160
+ }
161
+ function rgba(r, g, b, a) { return `rgba(${r},${g},${b},${a})`; }
162
+ function colorAlpha(hex, a) { const [r, g, b] = hexRgb(hex); return rgba(r, g, b, a); }
163
+
164
+ // ─── Drawing: track surface ──────────────────────────────────────
165
+
166
+ function drawTrackSurface() {
167
+ const bL = centerX - bottomW / 2, bR = centerX + bottomW / 2;
168
+ const tL = centerX - topW / 2, tR = centerX + topW / 2;
169
+
170
+ const grad = ctx.createLinearGradient(0, topY, 0, strikeY);
171
+ grad.addColorStop(0, '#0d0d0d');
172
+ grad.addColorStop(0.6, '#141414');
173
+ grad.addColorStop(1, '#1a1a1a');
174
+
175
+ ctx.beginPath();
176
+ ctx.moveTo(bL, strikeY);
177
+ ctx.lineTo(tL, topY);
178
+ ctx.lineTo(tR, topY);
179
+ ctx.lineTo(bR, strikeY);
180
+ ctx.closePath();
181
+ ctx.fillStyle = grad;
182
+ ctx.fill();
183
+
184
+ // Edge rails
185
+ ctx.strokeStyle = '#2a2a2a';
186
+ ctx.lineWidth = 2;
187
+ ctx.beginPath(); ctx.moveTo(bL, strikeY); ctx.lineTo(tL, topY); ctx.stroke();
188
+ ctx.beginPath(); ctx.moveTo(bR, strikeY); ctx.lineTo(tR, topY); ctx.stroke();
189
+ }
190
+
191
+ function drawLaneLines() {
192
+ for (let i = 1; i < LANE_COUNT; i++) {
193
+ const frac = i / LANE_COUNT;
194
+ const bx = centerX - bottomW / 2 + frac * bottomW;
195
+ const tx = centerX - topW / 2 + frac * topW;
196
+ ctx.beginPath();
197
+ ctx.moveTo(bx, strikeY);
198
+ ctx.lineTo(tx, topY);
199
+ ctx.strokeStyle = '#1f1f1f';
200
+ ctx.lineWidth = 1;
201
+ ctx.stroke();
202
+ }
203
+ }
204
+
205
+ function drawBeatLines(t) {
206
+ // 3-level line styles matching Moonscraper:
207
+ // 0 = measure (bold), 1 = beat (medium), 2 = sub-beat (faint)
208
+ const styles = [
209
+ { color: 'rgba(255,255,255,0.25)', width: 2 },
210
+ { color: 'rgba(255,255,255,0.10)', width: 1 },
211
+ { color: 'rgba(255,255,255,0.04)', width: 0.5 },
212
+ ];
213
+ for (const beat of beatCache) {
214
+ const ahead = beat.sec - t;
215
+ if (ahead < 0 || ahead > VISIBLE_SEC) continue;
216
+ const { y, w } = project(ahead);
217
+ const st = styles[beat.level];
218
+ ctx.beginPath();
219
+ ctx.moveTo(centerX - w / 2, y);
220
+ ctx.lineTo(centerX + w / 2, y);
221
+ ctx.strokeStyle = st.color;
222
+ ctx.lineWidth = st.width;
223
+ ctx.stroke();
224
+ }
225
+ }
226
+
227
+ function drawSectionMarkers(t) {
228
+ for (const sec of sectionCache) {
229
+ const ahead = sec.sec - t;
230
+ if (ahead < 0 || ahead > VISIBLE_SEC) continue;
231
+ const { y, w, s } = project(ahead);
232
+ const right = centerX + w / 2;
233
+
234
+ if (s > 0.25) {
235
+ ctx.fillStyle = colorAlpha('#7c3aed', 0.4 + 0.5 * s);
236
+ ctx.font = `${Math.max(9, Math.round(11 * s))}px system-ui`;
237
+ ctx.textAlign = 'left';
238
+ ctx.fillText(sec.label, right + 8, y + 4);
239
+ }
240
+ }
241
+ }
242
+
243
+ // ─── Drawing: strikeline & fret buttons ──────────────────────────
244
+
245
+ function drawStrikeline() {
246
+ const left = centerX - bottomW / 2, right = centerX + bottomW / 2;
247
+
248
+ // Glow band
249
+ const grad = ctx.createLinearGradient(0, strikeY - 14, 0, strikeY + 14);
250
+ grad.addColorStop(0, 'rgba(255,255,255,0)');
251
+ grad.addColorStop(0.5, 'rgba(255,255,255,0.08)');
252
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
253
+ ctx.fillStyle = grad;
254
+ ctx.fillRect(left, strikeY - 14, right - left, 28);
255
+
256
+ // Line
257
+ ctx.beginPath();
258
+ ctx.moveTo(left, strikeY);
259
+ ctx.lineTo(right, strikeY);
260
+ ctx.strokeStyle = 'rgba(255,255,255,0.55)';
261
+ ctx.lineWidth = 2;
262
+ ctx.stroke();
263
+ }
264
+
265
+ function drawFretButtons(fretRise) {
266
+ for (let i = 0; i < LANE_COUNT; i++) {
267
+ const pt = getPoint(i, 0);
268
+ const cx = pt.x, cy = strikeY;
269
+ const rx = noteRX * 1.1;
270
+ const ry = rx * NOTE_SQUISH;
271
+ const color = FRET_COLORS[i];
272
+ const rise = fretRise[i]; // 0 = idle, 1 = fully raised
273
+ const active = rise > 0.05;
274
+
275
+ // Soft pulse glow (scales with rise)
276
+ if (active) {
277
+ const gr = rx * 1.6;
278
+ const pulse = ctx.createRadialGradient(cx, cy, rx * 0.5, cx, cy, gr);
279
+ pulse.addColorStop(0, colorAlpha(color, 0.25 * rise));
280
+ pulse.addColorStop(1, colorAlpha(color, 0));
281
+ ctx.beginPath();
282
+ ctx.ellipse(cx, cy, gr, gr * NOTE_SQUISH, 0, 0, Math.PI * 2);
283
+ ctx.fillStyle = pulse;
284
+ ctx.fill();
285
+ }
286
+
287
+ // Base body
288
+ ctx.beginPath();
289
+ ctx.ellipse(cx, cy, rx * 1.08, ry * 1.08, 0, 0, Math.PI * 2);
290
+ ctx.fillStyle = '#1a1a1a';
291
+ ctx.fill();
292
+
293
+ // Coloured outer ring (brightens with rise)
294
+ ctx.beginPath();
295
+ ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
296
+ ctx.strokeStyle = active ? color : colorAlpha(color, 0.7);
297
+ ctx.lineWidth = 3 + rise;
298
+ ctx.stroke();
299
+
300
+ // Smooth rise offset (shorter travel = reaches full height quicker visually)
301
+ const riseH = ry * 0.38 * rise;
302
+ const rY = cy - riseH;
303
+
304
+ // Dark cylinder wall (visible proportional to rise)
305
+ if (active) {
306
+ ctx.beginPath();
307
+ ctx.ellipse(cx, cy, rx * 0.9, ry * 0.9, 0, 0, Math.PI * 2);
308
+ ctx.fillStyle = '#222';
309
+ ctx.fill();
310
+ }
311
+
312
+ // Silver ring (rises smoothly) — radial gradient for metallic look
313
+ ctx.beginPath();
314
+ ctx.ellipse(cx, rY, rx * 0.9, ry * 0.9, 0, 0, Math.PI * 2);
315
+ const silver = ctx.createRadialGradient(
316
+ cx - rx * 0.2, rY - ry * 0.15, rx * 0.05,
317
+ cx, rY, rx * 0.9
318
+ );
319
+ silver.addColorStop(0, '#d8d8d8');
320
+ silver.addColorStop(0.3, '#b0b0b0');
321
+ silver.addColorStop(0.7, '#808080');
322
+ silver.addColorStop(1, '#606060');
323
+ ctx.fillStyle = silver;
324
+ ctx.fill();
325
+
326
+ // Dark gap inside silver ring
327
+ ctx.beginPath();
328
+ ctx.ellipse(cx, rY, rx * 0.75, ry * 0.75, 0, 0, Math.PI * 2);
329
+ ctx.fillStyle = '#111';
330
+ ctx.fill();
331
+
332
+ // Center: dark when idle, glowing lane colour when raised (fades with rise)
333
+ const [cr, cg, cb] = hexRgb(color);
334
+ if (rise > 0.05) {
335
+ const glow = ctx.createRadialGradient(cx, rY, 0, cx, rY, rx * 0.62);
336
+ glow.addColorStop(0, rgba(
337
+ Math.round(10 + (Math.min(255, cr + 80) - 10) * rise),
338
+ Math.round(10 + (Math.min(255, cg + 80) - 10) * rise),
339
+ Math.round(10 + (Math.min(255, cb + 80) - 10) * rise), 1));
340
+ glow.addColorStop(0.7, rgba(
341
+ Math.round(10 + cr * rise), Math.round(10 + cg * rise), Math.round(10 + cb * rise), 1));
342
+ glow.addColorStop(1, rgba(
343
+ Math.round(10 + Math.max(0, cr - 20) * rise),
344
+ Math.round(10 + Math.max(0, cg - 20) * rise),
345
+ Math.round(10 + Math.max(0, cb - 20) * rise), 1));
346
+ ctx.beginPath();
347
+ ctx.ellipse(cx, rY, rx * 0.65, ry * 0.65, 0, 0, Math.PI * 2);
348
+ ctx.fillStyle = glow;
349
+ ctx.fill();
350
+ } else {
351
+ ctx.beginPath();
352
+ ctx.ellipse(cx, rY, rx * 0.65, ry * 0.65, 0, 0, Math.PI * 2);
353
+ ctx.fillStyle = '#0a0a0a';
354
+ ctx.fill();
355
+ }
356
+ }
357
+ }
358
+
359
+ // ─── Drawing: note puck (3D layered) ─────────────────────────────
360
+ // Matches the real GH note structure visible in reference screenshots:
361
+ // black base → dark coloured side → dark ring gap → bright top face → solid white cap
362
+
363
+ function drawNotePuck(cx, cy, scale, color, isHopo) {
364
+ const rx = noteRX * scale;
365
+ const ry = rx * NOTE_SQUISH;
366
+ if (rx < 3) return;
367
+
368
+ const [cr, cg, cb] = hexRgb(color);
369
+ const pH = ry * 0.6; // visible side-band height
370
+
371
+ // 1 ── Shadow on track
372
+ ctx.beginPath();
373
+ ctx.ellipse(cx, cy + pH + ry * 0.12, rx * 1.04, ry * 0.45, 0, 0, Math.PI * 2);
374
+ ctx.fillStyle = 'rgba(0,0,0,0.4)';
375
+ ctx.fill();
376
+
377
+ // 2 ── Black base rim
378
+ ctx.beginPath();
379
+ ctx.ellipse(cx, cy + pH, rx * 1.01, ry, 0, 0, Math.PI * 2);
380
+ ctx.fillStyle = '#080808';
381
+ ctx.fill();
382
+
383
+ // 3 ── Side band (white/silver rim — the visible puck edge below the colour)
384
+ ctx.beginPath();
385
+ ctx.ellipse(cx, cy + pH * 0.45, rx, ry, 0, 0, Math.PI * 2);
386
+ ctx.fillStyle = '#c8c8c8';
387
+ ctx.fill();
388
+
389
+ // 4 ── Dark separation ring — drawn full-size, then top face covers most
390
+ // of it, leaving a visible dark border (the groove between top & side)
391
+ ctx.beginPath();
392
+ ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
393
+ ctx.fillStyle = '#0e0e0e';
394
+ ctx.fill();
395
+
396
+ // 5 ── Top face — inset slightly so the dark ring shows as a hard border
397
+ ctx.beginPath();
398
+ ctx.ellipse(cx, cy - ry * 0.04, rx * 0.92, ry * 0.85, 0, 0, Math.PI * 2);
399
+ const topGrad = ctx.createLinearGradient(cx - rx, cy - ry, cx + rx * 0.3, cy + ry * 0.5);
400
+ topGrad.addColorStop(0, rgba(Math.min(255, cr + 40), Math.min(255, cg + 40), Math.min(255, cb + 40), 1));
401
+ topGrad.addColorStop(0.5, rgba(cr, cg, cb, 1));
402
+ topGrad.addColorStop(1, rgba(Math.max(0, cr - 15), Math.max(0, cg - 15), Math.max(0, cb - 15), 1));
403
+ ctx.fillStyle = topGrad;
404
+ ctx.fill();
405
+
406
+ // 6 ── White cap — shifted toward top of face (perspective: looking down at puck)
407
+ // Dark ring around cap eats into the "forehead", leaving big coloured "chin"
408
+ const capY = cy - ry * 0.32;
409
+
410
+ if (!isHopo) {
411
+ // Dark ring around cap — large enough to merge with outer dark ring at
412
+ // the top, so zero colour is visible above the cap (no "forehead")
413
+ ctx.beginPath();
414
+ ctx.ellipse(cx, capY, rx * 0.55, ry * 0.58, 0, 0, Math.PI * 2);
415
+ ctx.fillStyle = '#0e0e0e';
416
+ ctx.fill();
417
+
418
+ // Cap base (grey edge — gives the cap its own visible thickness)
419
+ ctx.beginPath();
420
+ ctx.ellipse(cx, capY, rx * 0.46, ry * 0.42, 0, 0, Math.PI * 2);
421
+ ctx.fillStyle = 'rgba(175,175,175,0.95)';
422
+ ctx.fill();
423
+
424
+ // Cap top (bright white)
425
+ ctx.beginPath();
426
+ ctx.ellipse(cx, capY - ry * 0.06, rx * 0.39, ry * 0.33, 0, 0, Math.PI * 2);
427
+ ctx.fillStyle = 'rgba(238,238,238,0.97)';
428
+ ctx.fill();
429
+
430
+ // Cap highlight
431
+ ctx.beginPath();
432
+ ctx.ellipse(cx, capY - ry * 0.12, rx * 0.24, ry * 0.19, 0, 0, Math.PI * 2);
433
+ ctx.fillStyle = '#fff';
434
+ ctx.fill();
435
+ } else {
436
+ // HOPO: dark open center, same position
437
+ ctx.beginPath();
438
+ ctx.ellipse(cx, capY, rx * 0.55, ry * 0.58, 0, 0, Math.PI * 2);
439
+ ctx.fillStyle = '#0e0e0e';
440
+ ctx.fill();
441
+ ctx.beginPath();
442
+ ctx.ellipse(cx, capY, rx * 0.32, ry * 0.28, 0, 0, Math.PI * 2);
443
+ ctx.fillStyle = '#080808';
444
+ ctx.fill();
445
+ }
446
+ }
447
+
448
+ // ─── Drawing: sustain tails ──────────────────────────────────────
449
+
450
+ function drawSustainTail(fret, startSec, endSec, t, color, isPlaying) {
451
+ const clipStart = Math.max(startSec - t, 0);
452
+ const clipEnd = Math.min(endSec - t, VISIBLE_SEC);
453
+ if (clipStart >= clipEnd) return;
454
+
455
+ const steps = Math.max(8, Math.ceil((clipEnd - clipStart) * 8));
456
+ const [cr, cg, cb] = hexRgb(color);
457
+
458
+ // When actively playing, the "consumed" portion glows like a lightsaber
459
+ const playing = isPlaying && startSec <= t;
460
+
461
+ // Cache sampled points along the sustain
462
+ const pts = [];
463
+ for (let i = 0; i <= steps; i++) {
464
+ pts.push(getPoint(fret, clipStart + (clipEnd - clipStart) * (i / steps)));
465
+ }
466
+
467
+ // Outer glow — use shadowBlur for soft falloff instead of a wide hard shape
468
+ ctx.save();
469
+ if (playing) {
470
+ ctx.shadowColor = rgba(cr, cg, cb, 0.8);
471
+ ctx.shadowBlur = noteRX * 0.5;
472
+ }
473
+
474
+ // Main sustain strip
475
+ ctx.beginPath();
476
+ for (let i = 0; i <= steps; i++) {
477
+ const hw = Math.max(1, noteRX * pts[i].scale * (playing ? 0.16 : 0.12));
478
+ if (i === 0) ctx.moveTo(pts[i].x - hw, pts[i].y);
479
+ else ctx.lineTo(pts[i].x - hw, pts[i].y);
480
+ }
481
+ for (let i = steps; i >= 0; i--) {
482
+ ctx.lineTo(pts[i].x + Math.max(1, noteRX * pts[i].scale * (playing ? 0.16 : 0.12)), pts[i].y);
483
+ }
484
+ ctx.closePath();
485
+ ctx.fillStyle = playing ? rgba(Math.min(255,cr+60), Math.min(255,cg+60), Math.min(255,cb+60), 0.9)
486
+ : rgba(cr, cg, cb, 0.45);
487
+ ctx.fill();
488
+ ctx.shadowBlur = 0;
489
+ ctx.restore();
490
+
491
+ // White-hot center when playing (lightsaber core)
492
+ if (playing) {
493
+ ctx.beginPath();
494
+ for (let i = 0; i <= steps; i++) {
495
+ const hw = Math.max(0.3, noteRX * pts[i].scale * 0.05);
496
+ if (i === 0) ctx.moveTo(pts[i].x - hw, pts[i].y);
497
+ else ctx.lineTo(pts[i].x - hw, pts[i].y);
498
+ }
499
+ for (let i = steps; i >= 0; i--) {
500
+ ctx.lineTo(pts[i].x + Math.max(0.3, noteRX * pts[i].scale * 0.05), pts[i].y);
501
+ }
502
+ ctx.closePath();
503
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
504
+ ctx.fill();
505
+ }
506
+ }
507
+
508
+ // ─── Fade overlay at vanishing end ───────────────────────────────
509
+
510
+ function drawFadeOverlay() {
511
+ const h = (strikeY - topY) * 0.18;
512
+ const grad = ctx.createLinearGradient(0, topY - 5, 0, topY + h);
513
+ grad.addColorStop(0, '#0a0a0a');
514
+ grad.addColorStop(1, 'rgba(10,10,10,0)');
515
+ ctx.fillStyle = grad;
516
+ ctx.fillRect(0, 0, W, topY + h);
517
+ }
518
+
519
+ // ─── Main draw loop ──────────────────────────────────────────────
520
+
521
+ function draw() {
522
+ const t = audio ? audio.currentTime || 0 : 0;
523
+ const dur = getDuration();
524
+
525
+ // Update UI controls
526
+ const seekFill = document.getElementById('viz-seekfill');
527
+ const timeDiv = document.getElementById('viz-time');
528
+ const secDiv = document.getElementById('viz-sections');
529
+ if (seekFill) seekFill.style.width = (t / dur * 100) + '%';
530
+ if (timeDiv) timeDiv.textContent = fmt(t) + ' / ' + fmt(dur);
531
+ let curSec = '';
532
+ for (let i = sectionCache.length - 1; i >= 0; i--) {
533
+ if (sectionCache[i].sec <= t) { curSec = sectionCache[i].label; break; }
534
+ }
535
+ if (secDiv) secDiv.textContent = curSec;
536
+
537
+ // ── Clear ──
538
+ ctx.fillStyle = '#0a0a0a';
539
+ ctx.fillRect(0, 0, W, H);
540
+
541
+ // ── Track structure ──
542
+ drawTrackSurface();
543
+ drawLaneLines();
544
+ drawBeatLines(t);
545
+ drawSectionMarkers(t);
546
+
547
+ // ── Collect visible notes (future only — past notes vanish) ──
548
+ const viewEnd = t + VISIBLE_SEC;
549
+ const visible = [];
550
+ for (const note of noteCache) {
551
+ if (note.sec > viewEnd) break;
552
+ if (note.sec + Math.max(note.sustainSec, 0) < t) continue;
553
+ visible.push(note);
554
+ }
555
+
556
+ // ── Sustain tails (back-to-front: furthest first) ──
557
+ for (let i = visible.length - 1; i >= 0; i--) {
558
+ const note = visible[i];
559
+ if (note.sustainSec <= 0) continue;
560
+ for (const fret of note.frets) {
561
+ if (fret > 4) continue;
562
+ const playing = note.sec <= t && note.sec + note.sustainSec > t;
563
+ drawSustainTail(fret, note.sec, note.sec + note.sustainSec, t, FRET_COLORS[fret], playing);
564
+ }
565
+ }
566
+
567
+ // ── Per-fret rise animation (0 = idle, 1 = fully raised) ──
568
+ // Also track which frets are actively sustaining (for glow)
569
+ const fretRise = [0, 0, 0, 0, 0];
570
+ const fretSustaining = [false, false, false, false, false];
571
+ const RISE_BEFORE = 0.10; // start early so cylinder is up before note touches fret
572
+ const RISE_HOLD = 0.04; // hold at full rise after note passes before falling
573
+ const RISE_AFTER = 0.08; // fall back with gravity ease
574
+ for (const note of noteCache) {
575
+ const ahead = note.sec - t;
576
+ const noteEnd = note.sec + note.sustainSec;
577
+ const endAhead = noteEnd - t;
578
+ if (ahead > RISE_BEFORE + 0.5) break;
579
+ if (endAhead < -RISE_HOLD - RISE_AFTER - 0.5 && ahead < -RISE_HOLD - RISE_AFTER - 0.5) continue;
580
+ let rise = 0;
581
+ if (note.sustainSec > 0 && ahead <= 0 && endAhead > 0) {
582
+ // Sustain actively playing — fully raised
583
+ rise = 1;
584
+ for (const fret of note.frets) {
585
+ if (fret <= 4) fretSustaining[fret] = true;
586
+ }
587
+ } else if (ahead > 0 && ahead < RISE_BEFORE) {
588
+ // Approaching — rise up
589
+ rise = 1 - ahead / RISE_BEFORE;
590
+ } else {
591
+ // Falling back — use the END of the note (or sustain) as reference
592
+ const fallRef = note.sustainSec > 0 ? endAhead : ahead;
593
+ if (fallRef <= 0 && fallRef > -RISE_HOLD) {
594
+ // Hold at peak briefly
595
+ rise = 1;
596
+ } else if (fallRef <= -RISE_HOLD && fallRef > -RISE_HOLD - RISE_AFTER) {
597
+ // Then fall with gravity
598
+ const f = 1 + (fallRef + RISE_HOLD) / RISE_AFTER;
599
+ rise = f * f;
600
+ }
601
+ }
602
+ for (const fret of note.frets) {
603
+ if (fret <= 4) fretRise[fret] = Math.max(fretRise[fret], rise);
604
+ }
605
+ }
606
+
607
+ // ── Notes (back-to-front: furthest first) ──
608
+ for (let i = visible.length - 1; i >= 0; i--) {
609
+ const note = visible[i];
610
+ const ahead = note.sec - t;
611
+ if (ahead < 0) continue; // already played — vanish
612
+
613
+ for (const fret of note.frets) {
614
+ if (fret > 4) continue;
615
+ const pt = getPoint(fret, ahead);
616
+
617
+ // Glow when approaching strikeline
618
+ if (ahead < 0.2) {
619
+ const intensity = 1 - ahead / 0.2;
620
+ const gr = noteRX * pt.scale * 2.5;
621
+ const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, gr);
622
+ glow.addColorStop(0, colorAlpha(FRET_GLOW[fret], 0.35 * intensity));
623
+ glow.addColorStop(1, colorAlpha(FRET_GLOW[fret], 0));
624
+ ctx.fillStyle = glow;
625
+ ctx.beginPath();
626
+ ctx.arc(pt.x, pt.y, gr, 0, Math.PI * 2);
627
+ ctx.fill();
628
+ }
629
+ drawNotePuck(pt.x, pt.y, pt.scale, FRET_COLORS[fret], note.hopo);
630
+ }
631
+ }
632
+
633
+ // ── Overlays ──
634
+ drawFadeOverlay();
635
+ drawStrikeline();
636
+ drawFretButtons(fretRise);
637
+
638
+ // ── HUD text ──
639
+ ctx.fillStyle = '#555';
640
+ ctx.font = '11px system-ui';
641
+ ctx.textAlign = 'right';
642
+ ctx.fillText(`${noteCache.length} notes (${currentDiff})`, W - 16, 20);
643
+ ctx.textAlign = 'left';
644
+
645
+ requestAnimationFrame(draw);
646
+ }
647
+
648
+ // ─── UI scaffolding ──────────────────────────────────────────────
649
+
650
+ function buildUI(container) {
651
+ container.style.background = '#0a0a0a';
652
+ container.style.borderRadius = '12px';
653
+ container.style.overflow = 'hidden';
654
+
655
+ container.innerHTML = `
656
+ <div style="display:flex; align-items:center; gap:12px; padding:10px 16px; background:#111; border-bottom:1px solid #222;">
657
+ <button id="viz-play" style="background:none; border:none; color:#fff; font-size:22px; cursor:pointer; padding:4px 8px;" title="Play/Pause">&#9654;</button>
658
+ <div id="viz-time" style="color:#aaa; font-size:13px; min-width:80px;">0:00 / 0:00</div>
659
+ <div style="flex:1; position:relative; height:6px; background:#222; border-radius:3px; cursor:pointer;" id="viz-seekbar">
660
+ <div id="viz-seekfill" style="height:100%; background:#7c3aed; border-radius:3px; width:0%; pointer-events:none;"></div>
661
+ </div>
662
+ <select id="viz-diff" style="background:#1a1a1a; color:#fff; border:1px solid #333; border-radius:4px; padding:2px 6px; font-size:13px;">
663
+ <option value="expert">Expert</option>
664
+ <option value="hard">Hard</option>
665
+ <option value="medium">Medium</option>
666
+ <option value="easy">Easy</option>
667
+ </select>
668
+ </div>
669
+ <canvas id="viz-canvas" style="width:100%; display:block;"></canvas>
670
+ <div id="viz-sections" style="padding:6px 16px 10px; background:#111; border-top:1px solid #222; color:#666; font-size:11px; min-height:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
671
+ `;
672
+
673
+ canvas = document.getElementById('viz-canvas');
674
+ ctx = canvas.getContext('2d');
675
+
676
+ document.getElementById('viz-play').addEventListener('click', () => {
677
+ if (!audio) return;
678
+ if (playing) {
679
+ audio.pause(); playing = false;
680
+ document.getElementById('viz-play').textContent = '\u25B6';
681
+ } else {
682
+ audio.play(); playing = true;
683
+ document.getElementById('viz-play').textContent = '\u23F8';
684
+ }
685
+ });
686
+
687
+ document.getElementById('viz-seekbar').addEventListener('click', e => {
688
+ if (!audio) return;
689
+ const rect = e.currentTarget.getBoundingClientRect();
690
+ audio.currentTime = ((e.clientX - rect.left) / rect.width) * getDuration();
691
+ });
692
+
693
+ document.getElementById('viz-diff').addEventListener('change', e => {
694
+ currentDiff = e.target.value;
695
+ noteCache = buildNoteCache(currentDiff);
696
+ });
697
+
698
+ resize();
699
+ window.addEventListener('resize', resize);
700
+ }
701
+
702
+ // ─── Init ────────────────────────────────────────────────────────
703
+
704
+ async function init() {
705
+ const container = document.getElementById('midmid-viz');
706
+ if (!container) { console.error('No #midmid-viz element'); return; }
707
+
708
+ // Space injects window.CHART_DATA; viz-dev fetches from file
709
+ if (window.CHART_DATA) {
710
+ DATA = window.CHART_DATA;
711
+ } else {
712
+ const resp = await fetch('/demo-data.json');
713
+ if (!resp.ok) {
714
+ container.innerHTML = '<div style="padding:40px; text-align:center; color:#f66;">No demo-data.json found. Run: bun run extract</div>';
715
+ return;
716
+ }
717
+ DATA = await resp.json();
718
+ }
719
+
720
+ buildUI(container);
721
+ rebuildCaches();
722
+
723
+ if (DATA.audio_b64) {
724
+ audio = new Audio();
725
+ audio.src = 'data:audio/' + DATA.audio_format + ';base64,' + DATA.audio_b64;
726
+ audio.preload = 'auto';
727
+ audio.addEventListener('loadedmetadata', () => { totalDuration = audio.duration; });
728
+ audio.addEventListener('ended', () => {
729
+ playing = false;
730
+ document.getElementById('viz-play').textContent = '\u25B6';
731
+ });
732
+ }
733
+
734
+ requestAnimationFrame(draw);
735
+ }
736
+
737
+ init();
738
+
739
+ // Vite HMR
740
+ if (typeof import.meta !== 'undefined' && import.meta.hot) {
741
+ import.meta.hot.accept(() => {});
742
+ import.meta.hot.dispose(() => { window.removeEventListener('resize', resize); });
743
+ }
visualizer.py CHANGED
@@ -1,287 +1,43 @@
1
  """Build the HTML/JS/CSS for the chart visualizer.
2
 
3
- Returns an iframe with a base64 data URL so that scripts actually execute
4
- (Gradio's gr.HTML sets content via innerHTML which doesn't run <script> tags).
5
  """
6
 
7
- import base64
8
  import json
 
 
 
9
 
10
 
11
  def build_visualizer_html(chart_json: dict) -> str:
12
- """Return an iframe whose srcdoc contains the full visualizer."""
13
  data_json = json.dumps(chart_json, separators=(",", ":"))
14
- full_html = TEMPLATE.replace("__CHART_DATA__", data_json)
15
- b64 = base64.b64encode(full_html.encode("utf-8")).decode("ascii")
 
 
16
  return (
17
- f'<iframe src="data:text/html;base64,{b64}" '
18
- f'style="width:100%; height:460px; border:none; border-radius:12px;" '
19
- f'allow="autoplay" sandbox="allow-scripts allow-same-origin"></iframe>'
20
  )
21
 
22
 
23
- TEMPLATE = """<!DOCTYPE html>
24
  <html>
25
  <head>
26
  <meta charset="utf-8">
27
  <style>
28
  * { margin: 0; padding: 0; box-sizing: border-box; }
29
- body { font-family: system-ui, -apple-system, sans-serif; background: #111; overflow: hidden; }
30
  </style>
31
  </head>
32
  <body>
33
-
34
- <div id="midmid-viz" style="background: #111; overflow: hidden;">
35
-
36
- <!-- Controls bar -->
37
- <div style="display:flex; align-items:center; gap:12px; padding:10px 16px; background:#1a1a1a; border-bottom:1px solid #333;">
38
- <button id="viz-play" style="background:none; border:none; color:#fff; font-size:22px; cursor:pointer; padding:4px 8px;" title="Play/Pause">&#9654;</button>
39
- <div id="viz-time" style="color:#aaa; font-size:13px; min-width:80px;">0:00 / 0:00</div>
40
- <div style="flex:1; position:relative; height:6px; background:#333; border-radius:3px; cursor:pointer;" id="viz-seekbar">
41
- <div id="viz-seekfill" style="height:100%; background:#7c3aed; border-radius:3px; width:0%; pointer-events:none;"></div>
42
- </div>
43
- <select id="viz-diff" style="background:#222; color:#fff; border:1px solid #444; border-radius:4px; padding:2px 6px; font-size:13px;">
44
- <option value="expert">Expert</option>
45
- <option value="hard">Hard</option>
46
- <option value="medium">Medium</option>
47
- <option value="easy">Easy</option>
48
- </select>
49
- </div>
50
-
51
- <!-- Canvas -->
52
- <canvas id="viz-canvas" style="width:100%; display:block;"></canvas>
53
-
54
- <!-- Section labels row -->
55
- <div id="viz-sections" style="padding:6px 16px 10px; background:#1a1a1a; border-top:1px solid #333; color:#888; font-size:11px; min-height:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
56
-
57
- </div>
58
-
59
- <script>
60
- (function() {
61
- const DATA = __CHART_DATA__;
62
-
63
- const FRET_COLORS = ['#22c55e','#ef4444','#eab308','#3b82f6','#f97316'];
64
- const FRET_GLOW = ['#4ade80','#f87171','#facc15','#60a5fa','#fb923c'];
65
- const LANE_COUNT = 5;
66
- const NOTE_RADIUS = 14;
67
- const CANVAS_PAD_LEFT = 80;
68
- const CANVAS_PAD_RIGHT = 20;
69
- const RES = DATA.resolution;
70
-
71
- const tempoMap = DATA.tempo_events.map(e => ({tick: e.tick, bpm: e.bpm}));
72
-
73
- function tickToSec(tick) {
74
- let sec = 0, prevTick = 0, bpm = tempoMap[0].bpm;
75
- for (let i = 1; i < tempoMap.length; i++) {
76
- if (tempoMap[i].tick > tick) break;
77
- sec += (tempoMap[i].tick - prevTick) / RES * 60.0 / bpm;
78
- prevTick = tempoMap[i].tick;
79
- bpm = tempoMap[i].bpm;
80
- }
81
- sec += (tick - prevTick) / RES * 60.0 / bpm;
82
- return sec;
83
- }
84
-
85
- // --- Audio ---
86
- const audio = new Audio();
87
- audio.src = 'data:audio/' + DATA.audio_format + ';base64,' + DATA.audio_b64;
88
- audio.preload = 'auto';
89
-
90
- // --- Canvas ---
91
- const canvas = document.getElementById('viz-canvas');
92
- const ctx = canvas.getContext('2d');
93
- let W, H, pxPerSec;
94
- const VISIBLE_SEC = 8;
95
-
96
- function resize() {
97
- const container = document.getElementById('midmid-viz');
98
- W = container.clientWidth;
99
- H = 360;
100
- canvas.width = W * devicePixelRatio;
101
- canvas.height = H * devicePixelRatio;
102
- canvas.style.height = H + 'px';
103
- ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
104
- pxPerSec = (W - CANVAS_PAD_LEFT - CANVAS_PAD_RIGHT) / VISIBLE_SEC;
105
- }
106
- resize();
107
- window.addEventListener('resize', resize);
108
-
109
- // --- State ---
110
- let currentDiff = 'expert';
111
- let playing = false;
112
-
113
- function buildNoteCache(diff) {
114
- return (DATA.notes[diff] || []).map(n => ({
115
- sec: tickToSec(n.tick),
116
- frets: n.frets,
117
- sustainSec: n.sustain > 0 ? tickToSec(n.tick + n.sustain) - tickToSec(n.tick) : 0,
118
- hopo: n.hopo,
119
- }));
120
- }
121
-
122
- let noteCache = buildNoteCache(currentDiff);
123
-
124
- const beatCache = DATA.beats.map(b => ({
125
- sec: tickToSec(b.tick),
126
- downbeat: b.downbeat,
127
- }));
128
-
129
- const sectionCache = DATA.sections.map(s => ({
130
- sec: tickToSec(s.tick),
131
- label: s.label,
132
- }));
133
-
134
- let totalDuration = 0;
135
- audio.addEventListener('loadedmetadata', () => { totalDuration = audio.duration; });
136
- const allNoteSecs = Object.values(DATA.notes).flat().map(n => tickToSec(n.tick + (n.sustain || 0)));
137
- const estimatedDuration = allNoteSecs.length ? Math.max(...allNoteSecs) + 5 : 120;
138
- function getDuration() { return (totalDuration && isFinite(totalDuration)) ? totalDuration : estimatedDuration; }
139
-
140
- // --- Controls ---
141
- const playBtn = document.getElementById('viz-play');
142
- const timeDiv = document.getElementById('viz-time');
143
- const seekBar = document.getElementById('viz-seekbar');
144
- const seekFill = document.getElementById('viz-seekfill');
145
- const diffSelect = document.getElementById('viz-diff');
146
- const sectionsDiv = document.getElementById('viz-sections');
147
-
148
- playBtn.addEventListener('click', () => {
149
- if (playing) {
150
- audio.pause(); playing = false;
151
- playBtn.textContent = '\u25B6';
152
- } else {
153
- audio.play(); playing = true;
154
- playBtn.textContent = '\u23F8';
155
- }
156
- });
157
-
158
- seekBar.addEventListener('click', (e) => {
159
- const rect = seekBar.getBoundingClientRect();
160
- audio.currentTime = ((e.clientX - rect.left) / rect.width) * getDuration();
161
- });
162
-
163
- diffSelect.addEventListener('change', () => {
164
- currentDiff = diffSelect.value;
165
- noteCache = buildNoteCache(currentDiff);
166
- });
167
-
168
- audio.addEventListener('ended', () => { playing = false; playBtn.textContent = '\u25B6'; });
169
-
170
- function fmt(s) {
171
- const m = Math.floor(s / 60), sc = Math.floor(s % 60);
172
- return m + ':' + (sc < 10 ? '0' : '') + sc;
173
- }
174
-
175
- // --- Render loop ---
176
- function draw() {
177
- const t = audio.currentTime || 0;
178
- const dur = getDuration();
179
-
180
- seekFill.style.width = (t / dur * 100) + '%';
181
- timeDiv.textContent = fmt(t) + ' / ' + fmt(dur);
182
-
183
- let curSec = '';
184
- for (let i = sectionCache.length - 1; i >= 0; i--) {
185
- if (sectionCache[i].sec <= t) { curSec = sectionCache[i].label; break; }
186
- }
187
- sectionsDiv.textContent = curSec;
188
-
189
- ctx.fillStyle = '#111';
190
- ctx.fillRect(0, 0, W, H);
191
-
192
- const playheadX = CANVAS_PAD_LEFT + 40;
193
- const secToX = (sec) => playheadX + (sec - t) * pxPerSec;
194
- const viewStart = t - 1, viewEnd = t + VISIBLE_SEC + 1;
195
- const laneTop = 20, laneBottom = H - 20;
196
- const laneHeight = laneBottom - laneTop;
197
- const laneH = laneHeight / LANE_COUNT;
198
-
199
- // Lane separators
200
- for (let i = 0; i <= LANE_COUNT; i++) {
201
- const y = laneTop + (laneHeight / LANE_COUNT) * i;
202
- ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 1;
203
- ctx.beginPath(); ctx.moveTo(CANVAS_PAD_LEFT - 10, y); ctx.lineTo(W - CANVAS_PAD_RIGHT, y); ctx.stroke();
204
- }
205
-
206
- // Beat lines
207
- for (const beat of beatCache) {
208
- if (beat.sec < viewStart || beat.sec > viewEnd) continue;
209
- const x = secToX(beat.sec);
210
- ctx.strokeStyle = beat.downbeat ? '#444' : '#222';
211
- ctx.lineWidth = beat.downbeat ? 1.5 : 0.5;
212
- ctx.beginPath(); ctx.moveTo(x, laneTop); ctx.lineTo(x, laneBottom); ctx.stroke();
213
- }
214
-
215
- // Section boundaries
216
- for (const sec of sectionCache) {
217
- if (sec.sec < viewStart || sec.sec > viewEnd) continue;
218
- const x = secToX(sec.sec);
219
- ctx.strokeStyle = '#7c3aed55'; ctx.lineWidth = 2;
220
- ctx.beginPath(); ctx.moveTo(x, laneTop); ctx.lineTo(x, laneBottom); ctx.stroke();
221
- ctx.fillStyle = '#7c3aed'; ctx.font = '10px system-ui';
222
- ctx.fillText(sec.label, x + 4, laneTop - 4);
223
- }
224
-
225
- // Playhead
226
- ctx.strokeStyle = '#fff'; ctx.lineWidth = 2;
227
- ctx.beginPath(); ctx.moveTo(playheadX, laneTop - 2); ctx.lineTo(playheadX, laneBottom + 2); ctx.stroke();
228
-
229
- // Notes
230
- for (const note of noteCache) {
231
- if (note.sec + note.sustainSec < viewStart || note.sec > viewEnd) continue;
232
- const x = secToX(note.sec);
233
-
234
- for (const fret of note.frets) {
235
- if (fret > 4) continue;
236
- const laneY = laneTop + fret * laneH + laneH / 2;
237
- const color = FRET_COLORS[fret];
238
- const glow = FRET_GLOW[fret];
239
-
240
- // Sustain tail
241
- if (note.sustainSec > 0) {
242
- const endX = secToX(note.sec + note.sustainSec);
243
- ctx.fillStyle = color + '55'; ctx.fillRect(x, laneY - 4, endX - x, 8);
244
- ctx.fillStyle = color + '99'; ctx.fillRect(x, laneY - 2, endX - x, 4);
245
- }
246
-
247
- // Note
248
- const isPast = note.sec < t;
249
- ctx.beginPath(); ctx.arc(x, laneY, NOTE_RADIUS - 2, 0, Math.PI * 2);
250
- if (isPast) {
251
- ctx.fillStyle = color + '44'; ctx.fill();
252
- ctx.strokeStyle = color + '66'; ctx.lineWidth = 1.5; ctx.stroke();
253
- } else {
254
- if (note.sec - t < 0.3) { ctx.shadowColor = glow; ctx.shadowBlur = 12; }
255
- ctx.fillStyle = color; ctx.fill();
256
- ctx.shadowBlur = 0;
257
- ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
258
- if (note.hopo) {
259
- ctx.beginPath(); ctx.arc(x, laneY, NOTE_RADIUS - 6, 0, Math.PI * 2);
260
- ctx.fillStyle = '#111'; ctx.fill();
261
- }
262
- }
263
- }
264
- }
265
-
266
- // Fret labels
267
- const fretAbbrev = ['G', 'R', 'Y', 'B', 'O'];
268
- ctx.font = 'bold 13px system-ui';
269
- for (let i = 0; i < LANE_COUNT; i++) {
270
- const y = laneTop + i * laneH + laneH / 2;
271
- ctx.fillStyle = FRET_COLORS[i]; ctx.textAlign = 'right';
272
- ctx.fillText(fretAbbrev[i], CANVAS_PAD_LEFT - 20, y + 5);
273
- }
274
- ctx.textAlign = 'left';
275
-
276
- // Note count
277
- ctx.fillStyle = '#666'; ctx.font = '11px system-ui';
278
- ctx.fillText(noteCache.length + ' notes (' + currentDiff + ')', W - CANVAS_PAD_RIGHT - 140, laneTop - 4);
279
-
280
- requestAnimationFrame(draw);
281
- }
282
-
283
- requestAnimationFrame(draw);
284
- })();
285
  </script>
286
  </body>
287
  </html>"""
 
1
  """Build the HTML/JS/CSS for the chart visualizer.
2
 
3
+ Reads static/visualizer.js and injects chart data via window.CHART_DATA.
4
+ Returns an iframe with srcdoc for Chrome/Arc compatibility.
5
  """
6
 
7
+ import html
8
  import json
9
+ from pathlib import Path
10
+
11
+ _JS_PATH = Path(__file__).parent / "static" / "visualizer.js"
12
 
13
 
14
  def build_visualizer_html(chart_json: dict) -> str:
 
15
  data_json = json.dumps(chart_json, separators=(",", ":"))
16
+ js_code = _JS_PATH.read_text(encoding="utf-8")
17
+
18
+ full_html = _TEMPLATE.replace("__JS_CODE__", js_code).replace("__CHART_DATA__", data_json)
19
+ escaped = html.escape(full_html, quote=True)
20
  return (
21
+ f'<iframe srcdoc="{escaped}" '
22
+ f'style="width:100%; height:75vw; max-height:780px; min-height:480px; border:none; border-radius:12px;" '
23
+ f'allow="autoplay" sandbox="allow-scripts"></iframe>'
24
  )
25
 
26
 
27
+ _TEMPLATE = """<!DOCTYPE html>
28
  <html>
29
  <head>
30
  <meta charset="utf-8">
31
  <style>
32
  * { margin: 0; padding: 0; box-sizing: border-box; }
33
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0a0a0a; overflow: hidden; }
34
  </style>
35
  </head>
36
  <body>
37
+ <div id="midmid-viz"></div>
38
+ <script>window.CHART_DATA = __CHART_DATA__;</script>
39
+ <script type="module">
40
+ __JS_CODE__
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </script>
42
  </body>
43
  </html>"""