akhaliq HF Staff Claude commited on
Commit
27faf0f
·
1 Parent(s): 7b05c4f

Replace waveform with highlighted lyrics, condense controls, drop channel strip

Browse files

- Viewport output is now the LYRICS preview, not a waveform: 28pt
centered text with the current word glowing amber + scaled up,
past words fading to 55% opacity, upcoming words at 35%. Auto-
scrolls to keep the current word in view.
- Click anywhere in the lyrics viewport to seek (vertical position
maps to time, like a waveform scrub).
- Removed the channel strip (fader/meter/pan/mute). The audio graph
and metering were tied to those controls, so the visual meter is
gone with them — playback still works.
- Combined the bottom 3 panels (Source+Script, Mix+Render, Channel)
into ONE "Console" panel with a 2-column body: Source/Script on
the left, Parameters/Library/Render on the right.
- Render button is now smaller and inline at the bottom-right of the
Console panel, with the toast next to it.

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (1) hide show
  1. index.html +200 -564
index.html CHANGED
@@ -6,14 +6,13 @@
6
  <title>GEPARD STUDIO</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
10
  <style>
11
  :root {
12
  --bg: #0a0c11;
13
  --bg-elev: #13161d;
14
  --bg-elev-2: #1a1e27;
15
  --bg-input: #080a0e;
16
- --bg-meter: #05070a;
17
  --bg-viewport: #000000;
18
  --line: #20242e;
19
  --line-strong: #2b303c;
@@ -25,8 +24,9 @@
25
  --orange: #f97316;
26
  --red: #dc2626;
27
  --green: #22c55e;
28
- --hl-bg: rgba(245, 165, 36, 0.22);
29
- --hl-text: #f5a524;
 
30
  --radius-sm: 3px;
31
  --radius: 5px;
32
  --radius-lg: 8px;
@@ -44,7 +44,7 @@
44
  body { display: flex; flex-direction: column; min-height: 100vh; }
45
  .mono, .num { font-family: "JetBrains Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; }
46
 
47
- /* ===== Top bar (project header) ===== */
48
  .topbar {
49
  display: flex; align-items: center; justify-content: space-between;
50
  height: 48px; padding: 0 16px;
@@ -66,9 +66,8 @@
66
  text-transform: uppercase; letter-spacing: 0.1em;
67
  padding-left: 12px; border-left: 1px solid var(--line);
68
  }
69
- .topbar-right { display: flex; align-items: center; gap: 14px; }
70
 
71
- /* ===== Main viewport (audio hero) ===== */
72
  .viewport {
73
  margin: 14px;
74
  background: var(--bg-viewport);
@@ -100,23 +99,23 @@
100
  background: var(--bg-elev-2);
101
  }
102
 
103
- /* Timecode display (video-studio style) */
104
  .timecode {
105
  display: flex; align-items: center; gap: 14px;
106
- padding: 14px 16px 6px;
107
  background: var(--bg-viewport);
108
  }
109
  .timecode .tc {
110
  font-family: "JetBrains Mono", ui-monospace, monospace;
111
  font-variant-numeric: tabular-nums;
112
- font-size: 38px;
113
  font-weight: 500;
114
  color: var(--text);
115
  letter-spacing: 0.02em;
116
  line-height: 1;
117
  }
118
  .timecode .tc .sep { color: var(--text-faint); margin: 0 1px; }
119
- .timecode .tc .frames { color: var(--text-dim); font-size: 24px; }
120
  .timecode .tc-label {
121
  font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
122
  color: var(--text-faint); margin-bottom: 4px;
@@ -133,102 +132,62 @@
133
  .timecode .meta .k { color: var(--text-faint); margin-right: 6px; }
134
  .timecode .meta .v { color: var(--text); }
135
 
136
- /* Big waveform */
137
- .waveform-wrap {
138
- height: 220px;
139
  background: var(--bg-viewport);
140
  position: relative;
141
- overflow: hidden;
142
- cursor: pointer;
143
  border-top: 1px solid var(--line);
 
 
 
144
  }
145
- canvas.wave { width: 100%; height: 100%; display: block; }
146
- .wave-empty {
147
- position: absolute; inset: 0;
148
- display: flex; align-items: center; justify-content: center;
149
  color: var(--text-faint);
150
- font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase;
151
- }
152
- .wave-progress {
153
- position: absolute; top: 0; left: 0; bottom: 0; width: 0;
154
- background: rgba(245, 165, 36, 0.06);
155
- border-right: 2px solid var(--amber);
156
- pointer-events: none;
157
- }
158
- .playhead {
159
- position: absolute; top: 0; bottom: 0; left: 0; width: 1px;
160
- background: var(--amber);
161
- pointer-events: none;
162
- box-shadow: 0 0 6px var(--amber);
163
- }
164
- .playhead::before {
165
- content: ""; position: absolute; top: -4px; left: -5px;
166
- width: 11px; height: 11px;
167
- background: var(--amber);
168
- clip-path: polygon(50% 100%, 0 0, 100% 0);
169
  }
170
-
171
- /* Timeline strip (below viewport) */
172
- .timeline {
173
- background: var(--bg-viewport);
174
- border-top: 1px solid var(--line);
175
- padding: 8px 16px 12px;
 
 
176
  }
177
- .ruler {
178
- height: 18px;
179
- position: relative;
180
- border-bottom: 1px solid var(--line);
181
- margin-bottom: 6px;
 
 
182
  }
183
- .ruler .tick {
184
- position: absolute; top: 0; bottom: 0;
185
- border-left: 1px solid var(--line-strong);
186
- color: var(--text-faint);
187
- font-family: "JetBrains Mono", monospace;
188
- font-size: 9px;
189
- padding-left: 3px;
190
- white-space: nowrap;
191
  }
192
- .ruler .tick.major { border-left-color: var(--text-faint); }
193
- .track-strip {
194
- height: 36px;
195
- background: var(--bg-elev-2);
196
- border: 1px solid var(--line);
197
- border-radius: var(--radius-sm);
198
- position: relative;
199
- overflow: hidden;
200
- }
201
- .track-strip .clip {
202
- position: absolute; top: 4px; bottom: 4px; left: 0;
203
- background: linear-gradient(180deg, rgba(245,165,36,0.18), rgba(245,165,36,0.08));
204
- border: 1px solid rgba(245,165,36,0.4);
205
- border-radius: 3px;
206
- width: 0;
207
  }
208
- .track-strip .clip-label {
209
- position: absolute; top: 50%; left: 8px;
210
- transform: translateY(-50%);
211
- font-family: "JetBrains Mono", monospace;
212
- font-size: 10px; color: var(--amber);
213
- font-weight: 600;
214
- letter-spacing: 0.04em;
215
- pointer-events: none;
216
  }
217
- .track-strip .playhead-mini {
218
- position: absolute; top: 0; bottom: 0; left: 0; width: 1px;
219
  background: var(--amber);
220
- pointer-events: none;
221
- }
222
- .track-label {
223
- position: absolute; top: 50%; left: 8px;
224
- transform: translateY(-50%);
225
- font-size: 10px; font-weight: 700;
226
- color: var(--text-dim);
227
- text-transform: uppercase; letter-spacing: 0.12em;
228
- pointer-events: none;
229
  }
230
 
231
- /* Transport bar */
232
  .transport {
233
  display: flex; align-items: center; gap: 14px;
234
  padding: 14px 16px;
@@ -247,9 +206,7 @@
247
  }
248
  .transport-btns button:hover:not(:disabled) { color: var(--text); border-color: var(--line-strong); }
249
  .transport-btns button:disabled { color: var(--text-faint); cursor: not-allowed; }
250
- .transport-btns .skip {
251
- width: 32px; height: 32px; font-size: 12px;
252
- }
253
  .transport-btns .play {
254
  width: 48px; height: 48px; font-size: 18px;
255
  background: var(--amber); color: #1a1300;
@@ -270,91 +227,46 @@
270
  .transport .info .k { color: var(--text-faint); margin-right: 6px; }
271
  .transport .info .v { color: var(--text); }
272
 
273
- /* ===== Bottom controls (3-column workspace) ===== */
274
- .workspace {
275
- flex: 1;
276
- max-width: 1320px;
277
- margin: 0 auto;
278
- width: 100%;
279
- padding: 0 14px 14px;
280
- display: grid;
281
- grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr) 180px;
282
- gap: 12px;
283
- align-items: stretch;
284
- }
285
- @media (max-width: 1080px) {
286
- .workspace { grid-template-columns: 1fr 1fr; }
287
- .channel-strip { grid-column: 1 / -1; height: 200px; }
288
- }
289
- @media (max-width: 680px) {
290
- .workspace { grid-template-columns: 1fr; }
291
- }
292
-
293
- .panel {
294
  background: var(--bg-elev);
295
  border: 1px solid var(--line);
296
  border-radius: var(--radius-lg);
297
  overflow: hidden;
298
- display: flex; flex-direction: column;
299
  }
300
- .panel-head {
301
  display: flex; align-items: center; justify-content: space-between;
302
  padding: 8px 12px;
303
  border-bottom: 1px solid var(--line);
304
  background: var(--bg-elev-2);
305
  }
306
- .panel-head .title {
307
  font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-dim);
308
  }
309
- .panel-body { padding: 12px; flex: 1; }
 
 
 
 
 
 
 
 
310
 
311
- .section { margin-bottom: 14px; }
312
- .section:last-child { margin-bottom: 0; }
313
- .section-label {
314
  font-size: 9px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--text-faint);
315
- margin-bottom: 6px;
316
  }
317
-
 
318
  select, textarea, input[type="text"] {
319
  width: 100%; background: var(--bg-input); border: 1px solid var(--line); border-radius: var(--radius);
320
  color: var(--text); font: inherit; padding: 8px 10px; outline: none; transition: border-color 0.15s;
321
  }
322
  select:focus, textarea:focus { border-color: var(--amber); }
323
- textarea { resize: vertical; min-height: 96px; line-height: 1.5; }
324
-
325
- /* Script editor with word highlight */
326
- .script-editor {
327
- background: var(--bg-input);
328
- border: 1px solid var(--line);
329
- border-radius: var(--radius);
330
- padding: 10px 12px;
331
- min-height: 110px;
332
- line-height: 1.65;
333
- font-size: 14px;
334
- color: var(--text);
335
- outline: none;
336
- transition: border-color 0.15s;
337
- white-space: pre-wrap;
338
- word-wrap: break-word;
339
- }
340
- .script-editor:focus { border-color: var(--amber); }
341
- .script-editor.playing { background: #0e1014; }
342
- .word {
343
- display: inline;
344
- padding: 1px 2px;
345
- margin: 0 -2px;
346
- border-radius: 3px;
347
- color: var(--text);
348
- transition: background-color 0.08s, color 0.08s;
349
- }
350
- .word.past { color: var(--text-faint); }
351
- .word.current {
352
- background: var(--hl-bg);
353
- color: var(--hl-text);
354
- font-weight: 600;
355
- }
356
 
357
- /* Segmented control */
358
  .seg {
359
  display: grid; grid-template-columns: 1fr 1fr;
360
  background: var(--bg-input); border: 1px solid var(--line); border-radius: var(--radius); padding: 3px;
@@ -367,7 +279,6 @@
367
  .seg button.active { background: var(--bg-elev-2); color: var(--text); box-shadow: 0 0 0 1px var(--line-strong); }
368
  .seg button:not(.active):hover { color: var(--text); }
369
 
370
- /* File drop */
371
  .file-drop {
372
  border: 1px dashed var(--line-strong); border-radius: var(--radius); padding: 12px;
373
  text-align: center; color: var(--text-dim); background: var(--bg-input); cursor: pointer; transition: all 0.15s;
@@ -377,9 +288,26 @@
377
  .file-drop .lbl { font-size: 11px; }
378
  .file-name { margin-top: 5px; font-family: "JetBrains Mono", monospace; font-size: 10px; color: var(--amber); }
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  /* Sliders */
381
  .slider {
382
- display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; padding: 5px 0;
383
  }
384
  .slider .name { font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: 0.1em; }
385
  .slider .val {
@@ -399,93 +327,30 @@
399
  input[type="range"]::-moz-range-track { height: 3px; background: var(--bg-input); border: 1px solid var(--line); border-radius: 2px; }
400
  input[type="range"]::-moz-range-thumb { width: 11px; height: 11px; border: 0; border-radius: 2px; background: var(--amber); cursor: pointer; }
401
 
402
- /* Channel strip */
403
- .channel-strip {
404
- background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius-lg);
405
- overflow: hidden; display: flex; flex-direction: column;
406
- }
407
- .strip-body {
408
- padding: 14px 10px 12px;
409
- display: grid;
410
- grid-template-columns: 1fr 22px 1fr;
411
- gap: 8px;
412
- align-items: end;
413
- flex: 1;
414
- }
415
- .strip-col { display: flex; flex-direction: column; align-items: center; gap: 10px; min-height: 0; }
416
- .strip-btn {
417
- width: 100%; padding: 5px 0; border: 1px solid var(--line); border-radius: var(--radius-sm);
418
- background: var(--bg-elev-2); color: var(--text-dim); font-size: 9px; font-weight: 700;
419
- letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer; transition: all 0.12s;
420
- }
421
- .strip-btn:hover { border-color: var(--line-strong); color: var(--text); }
422
- .strip-btn.active { background: var(--red); color: #fff; border-color: transparent; }
423
- .fader-rail {
424
- flex: 1; width: 30px; min-height: 100px;
425
- position: relative; background: var(--bg-input);
426
- border: 1px solid var(--line); border-radius: 999px; cursor: pointer; touch-action: none;
427
- }
428
- .fader-rail::before {
429
- content: ""; position: absolute; left: 50%; top: 8px; bottom: 8px; width: 2px;
430
- background: var(--line); transform: translateX(-50%);
431
- }
432
- .fader-cap {
433
- position: absolute; left: 50%; width: 38px; height: 16px;
434
- background: linear-gradient(180deg, #2a2f3a, #1a1e27);
435
- border: 1px solid var(--line-strong); border-radius: 3px;
436
- transform: translate(-50%, -50%);
437
- box-shadow: 0 2px 6px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06);
438
- cursor: grab;
439
- }
440
- .fader-cap:active { cursor: grabbing; }
441
- .fader-cap::after {
442
- content: ""; position: absolute; left: 50%; top: 50%; width: 26px; height: 2px;
443
- background: var(--amber); transform: translate(-50%, -50%); border-radius: 1px;
444
- }
445
- .fader-value { font-family: "JetBrains Mono", monospace; font-size: 10px; color: var(--text-dim); text-align: center; margin-top: 6px; }
446
- .meter-rail {
447
- flex: 1; width: 22px; min-height: 100px;
448
- position: relative; background: var(--bg-meter);
449
- border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden;
450
- }
451
- .meter-led {
452
- position: absolute; left: 2px; right: 2px; height: 3px;
453
- background: var(--line-strong); border-radius: 1px;
454
- }
455
- .meter-led.on { background: var(--green); }
456
- .meter-led.on.warn { background: var(--amber); }
457
- .meter-led.on.clip { background: var(--red); box-shadow: 0 0 4px var(--red); }
458
- .knob-ring {
459
- width: 44px; height: 44px; border-radius: 50%;
460
- background: var(--bg-input); border: 1px solid var(--line);
461
- position: relative; cursor: pointer; touch-action: none;
462
- }
463
- .knob-ring::before {
464
- content: ""; position: absolute; inset: 0; border-radius: 50%;
465
- background: conic-gradient(var(--amber) 0deg, var(--amber) var(--knob-deg, 0deg), transparent var(--knob-deg, 0deg));
466
- mask: radial-gradient(transparent 56%, black 60%); -webkit-mask: radial-gradient(transparent 56%, black 60%);
467
- }
468
- .knob-cap {
469
- position: absolute; inset: 5px; border-radius: 50%;
470
- background: linear-gradient(180deg, #2a2f3a, #1a1e27);
471
- border: 1px solid var(--line-strong);
472
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
473
- }
474
- .knob-cap::after {
475
- content: ""; position: absolute; top: 5px; left: 50%; width: 2px; height: 8px;
476
- background: var(--amber); transform: translateX(-50%); border-radius: 1px;
477
  }
478
- .knob-label { font-size: 9px; color: var(--text-faint); letter-spacing: 0.14em; text-transform: uppercase; margin-top: 4px; }
479
- .knob-value { font-family: "JetBrains Mono", monospace; font-size: 10px; color: var(--text-dim); margin-top: 2px; }
 
480
 
481
- /* Generate */
 
 
 
482
  .generate-btn {
483
- width: 100%; appearance: none; border: 0; padding: 12px; border-radius: var(--radius);
484
  background: linear-gradient(90deg, var(--amber) 0%, var(--orange) 60%, var(--red) 100%);
485
  color: #fff; font: inherit; font-weight: 700; font-size: 12px;
486
  letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer;
487
  box-shadow: 0 0 0 1px rgba(0,0,0,0.3), 0 6px 18px rgba(249,115,22,0.2);
488
- transition: filter 0.12s, transform 0.05s; display: flex; align-items: center; justify-content: center; gap: 10px;
 
 
489
  }
490
  .generate-btn:hover:not(:disabled) { filter: brightness(1.06); }
491
  .generate-btn:active:not(:disabled) { transform: translateY(1px); }
@@ -493,23 +358,11 @@
493
  .generate-btn .spinner { width: 12px; height: 12px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; }
494
  @keyframes spin { to { transform: rotate(360deg); } }
495
 
496
- /* Toast */
497
  .toast {
498
  margin-top: 10px; padding: 9px 12px; border-radius: var(--radius);
499
  font-size: 12px; background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5;
500
  }
501
  .toast.hidden { display: none; }
502
-
503
- /* Examples */
504
- .examples { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
505
- .example-chip {
506
- appearance: none; display: inline-flex; align-items: center; gap: 6px;
507
- padding: 5px 9px; background: var(--bg-elev-2); border: 1px solid var(--line); border-radius: 999px;
508
- color: var(--text-dim); font: inherit; font-size: 11px; cursor: pointer; transition: all 0.12s;
509
- }
510
- .example-chip:hover { border-color: var(--amber); color: var(--text); }
511
- .example-chip .tag { font-family: "JetBrains Mono", monospace; color: var(--amber); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
512
- .example-chip .txt { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
513
  </style>
514
  </head>
515
  <body>
@@ -523,15 +376,14 @@
523
  <span class="meta">v1.0 · Text-to-Speech</span>
524
  </div>
525
  </div>
526
- <div class="topbar-right"></div>
527
  </div>
528
 
529
- <!-- ===== Viewport (audio hero) ===== -->
530
  <section class="viewport">
531
  <div class="viewport-head">
532
  <div class="left">
533
  <span class="rec-dot" id="rec-dot"></span>
534
- <span class="label">Program · Audio</span>
535
  </div>
536
  <div class="right">
537
  <span class="stage" id="stage-pill">Idle</span>
@@ -546,111 +398,78 @@
546
  <div class="spacer"></div>
547
  <div class="meta">
548
  <div><span class="k">Duration</span><span class="v" id="dur-time">0:00</span></div>
549
- <div><span class="k">Sample Rate</span><span class="v" id="sr-meta">—</span></div>
550
- <div><span class="k">Channels</span><span class="v" id="ch-meta">—</span></div>
551
  </div>
552
  </div>
553
 
554
- <div class="waveform-wrap" id="wave-wrap">
555
- <div class="wave-empty" id="wave-empty">No signal · awaiting input</div>
556
- <canvas class="wave" id="wave"></canvas>
557
- <div class="wave-progress" id="wave-progress"></div>
558
- <div class="playhead" id="playhead"></div>
559
- </div>
560
-
561
- <div class="timeline">
562
- <div class="ruler" id="ruler"></div>
563
- <div class="track-strip" id="track-strip">
564
- <div class="clip" id="clip"><div class="clip-label" id="clip-label"></div></div>
565
- <div class="playhead-mini" id="playhead-mini"></div>
566
- </div>
567
  </div>
568
 
569
  <div class="transport">
570
  <div class="transport-btns">
571
- <button class="skip" id="stop-btn" type="button" title="Return to start">⏮</button>
572
  <button class="play" id="play-btn" type="button" title="Play / Pause" disabled>▶</button>
573
  </div>
574
  <div class="spacer"></div>
575
  <div class="info">
576
- <div><span class="k">Voice</span><span class="v" id="voice-info">—</span></div>
577
- <div><span class="k">Codec</span><span class="v">NeMo Nano · 22.05kHz</span></div>
578
  </div>
579
  </div>
580
  </section>
581
 
582
- <!-- ===== Workspace (controls) ===== -->
583
- <div class="workspace">
 
 
 
 
584
 
585
- <!-- Track -->
586
- <section class="panel">
587
- <div class="panel-head"><div class="title">Source · Script</div></div>
588
- <div class="panel-body">
589
- <div class="section">
590
- <div class="section-label">Source</div>
591
  <div class="seg" id="mode-seg">
592
  <button id="mode-preset" class="active" type="button">Preset</button>
593
  <button id="mode-clone" type="button">Clone</button>
594
  </div>
595
  </div>
596
- <div class="section" id="speaker-section">
597
- <div class="section-label">Voice</div>
598
  <select id="speaker"></select>
599
  </div>
600
- <div class="section hidden" id="ref-section">
601
- <div class="section-label">Reference · ≤ 60s</div>
602
  <div class="file-drop" id="drop">
603
  <div class="ico">🎙️</div>
604
- <div class="lbl">Drop clip · click to upload</div>
605
  <div class="file-name" id="file-name"></div>
606
  </div>
607
  <input type="file" id="file-input" accept="audio/*" hidden />
608
  </div>
609
- <div class="section">
610
- <div class="section-label">Script</div>
611
  <div class="script-editor" id="script" contenteditable="true" spellcheck="false">Type what the cheetah should say…</div>
612
  </div>
613
  </div>
614
- </section>
615
 
616
- <!-- Mix + transport -->
617
- <section class="panel">
618
- <div class="panel-head"><div class="title">Mix · Render</div></div>
619
- <div class="panel-body">
620
- <div class="section">
621
- <div class="section-label">Parameters</div>
622
- <div id="sliders"></div>
623
- </div>
624
- <div class="section">
625
- <div class="section-label">Library · Examples</div>
626
- <div class="examples" id="examples"></div>
627
- </div>
628
- <button class="generate-btn" id="generate-btn" type="button"><span class="lbl">▶ Render</span></button>
629
- <div class="toast hidden" id="toast"></div>
630
- </div>
631
- </section>
632
 
633
- <!-- Channel strip -->
634
- <section class="channel-strip">
635
- <div class="panel-head"><div class="title">Channel</div></div>
636
- <div class="strip-body">
637
- <div class="strip-col">
638
- <button class="strip-btn" id="mute-btn" type="button">Mute</button>
639
- <div class="knob-ring" id="pan-ring" style="--knob-deg:0deg"><div class="knob-cap" id="pan-cap"></div></div>
640
- <div class="knob-label">Pan</div>
641
- <div class="knob-value mono" id="pan-val">C</div>
642
- </div>
643
- <div class="strip-col">
644
- <div class="meter-rail" id="meter-rail"></div>
645
- </div>
646
- <div class="strip-col">
647
- <div class="fader-rail" id="fader-rail"><div class="fader-cap" id="fader-cap"></div></div>
648
- <div class="fader-value mono" id="fader-val">0.0 dB</div>
649
  </div>
650
  </div>
651
- </section>
652
 
653
- </div>
 
654
 
655
  <script type="module">
656
  import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
@@ -659,13 +478,10 @@ const $ = (id) => document.getElementById(id);
659
  const els = {
660
  recDot: $("rec-dot"), stagePill: $("stage-pill"),
661
  tcH: $("tc-h"), tcM: $("tc-m"), tcS: $("tc-s"), tcF: $("tc-f"),
662
- durTime: $("dur-time"), srMeta: $("sr-meta"), chMeta: $("ch-meta"),
663
- voiceInfo: $("voice-info"),
664
- waveWrap: $("wave-wrap"), wave: $("wave"), waveEmpty: $("wave-empty"),
665
- waveProgress: $("wave-progress"), playhead: $("playhead"),
666
- ruler: $("ruler"),
667
- trackStrip: $("track-strip"), clip: $("clip"), clipLabel: $("clip-label"),
668
- playheadMini: $("playhead-mini"),
669
  playBtn: $("play-btn"), stopBtn: $("stop-btn"),
670
  modePreset: $("mode-preset"), modeClone: $("mode-clone"),
671
  speaker: $("speaker"), speakerSection: $("speaker-section"), refSection: $("ref-section"),
@@ -673,33 +489,18 @@ const els = {
673
  script: $("script"),
674
  sliders: $("sliders"),
675
  generateBtn: $("generate-btn"), toast: $("toast"), examples: $("examples"),
676
- faderRail: $("fader-rail"), faderCap: $("fader-cap"), faderVal: $("fader-val"),
677
- meterRail: $("meter-rail"),
678
- panRing: $("pan-ring"), panCap: $("pan-cap"), panVal: $("pan-val"),
679
- muteBtn: $("mute-btn"),
680
  };
681
 
682
  let cfg = null;
683
  let client = null;
684
  let mode = "preset";
685
  let refFile = null;
686
- let audioUrl = null;
687
  let audioEl = new Audio();
688
  let currentSubmission = null;
689
- let startedAt = 0;
690
  let trackDuration = 0;
691
 
692
- let audioCtx = null;
693
- let sourceNode = null;
694
- let gainNode = null;
695
- let panNode = null;
696
- let analyser = null;
697
- let meterRAF = null;
698
- let faderValueDb = 0;
699
- let panValue = 0;
700
- let isMuted = false;
701
-
702
  let wordSpans = [];
 
703
  let hlRAF = null;
704
 
705
  const sliderSpec = [
@@ -710,7 +511,6 @@ const sliderSpec = [
710
  { key: "repetition_window", label: "Rep. Window", fmt: v => String(Math.round(v)), step: 1 },
711
  ];
712
 
713
- const dbToGain = (db) => Math.pow(10, db / 20);
714
  const setBusy = (busy) => {
715
  els.generateBtn.disabled = busy;
716
  els.generateBtn.innerHTML = busy
@@ -722,7 +522,6 @@ const clearError = () => { els.toast.classList.add("hidden"); els.toast.textCont
722
  const fmtTime = (s) => { if (!isFinite(s)) return "0:00"; const m = Math.floor(s / 60); const r = Math.floor(s % 60); return `${m}:${String(r).padStart(2, "0")}`; };
723
  const pad = (n, w = 2) => String(Math.floor(n)).padStart(w, "0");
724
 
725
- // Timecode formatter: H:MM:SS:FF (24fps fake frames based on time progress)
726
  const updateTimecode = (current, total) => {
727
  const fps = 24;
728
  const h = Math.floor(current / 3600);
@@ -734,28 +533,7 @@ const updateTimecode = (current, total) => {
734
  els.tcS.textContent = pad(s);
735
  els.tcF.textContent = pad(frames);
736
  const pct = total ? (current / total) * 100 : 0;
737
- const clipW = total > 0 ? Math.max(2, Math.min(100, pct)) : 0;
738
- els.clip.style.width = `${clipW}%`;
739
- els.playheadMini.style.left = `${clipW}%`;
740
- els.playhead.style.left = `${pct}%`;
741
- els.waveProgress.style.width = `${pct}%`;
742
- };
743
-
744
- const buildRuler = (durationSec) => {
745
- els.ruler.innerHTML = "";
746
- if (!durationSec || !isFinite(durationSec)) return;
747
- const ticks = [];
748
- const step = durationSec > 60 ? 10 : durationSec > 20 ? 5 : durationSec > 10 ? 2 : 1;
749
- for (let t = 0; t <= durationSec; t += step) ticks.push(t);
750
- if (ticks[ticks.length - 1] < durationSec) ticks.push(Math.ceil(durationSec));
751
- ticks.forEach(t => {
752
- const tick = document.createElement("div");
753
- const pct = (t / durationSec) * 100;
754
- tick.className = "tick" + (t === 0 || t === Math.round(durationSec) ? " major" : "");
755
- tick.style.left = `${pct}%`;
756
- tick.textContent = `${pad(Math.floor(t/60))}:${pad(t%60)}`;
757
- els.ruler.appendChild(tick);
758
- });
759
  };
760
 
761
  // ---------- Mode / file ----------
@@ -792,60 +570,32 @@ const buildSliders = () => {
792
  };
793
  const sliderValue = (key) => Number($(`sl-${key}`).value);
794
 
795
- // ---------- Script editor ----------
796
- const tokenizeScript = () => {
797
- const sel = window.getSelection();
798
- let caretOffset = 0;
799
- if (sel && sel.rangeCount && els.script.contains(sel.anchorNode)) {
800
- const range = sel.getRangeAt(0);
801
- const pre = range.cloneRange();
802
- pre.selectNodeContents(els.script);
803
- pre.setEnd(range.endContainer, range.endOffset);
804
- caretOffset = pre.toString().length;
805
- }
806
- const text = els.script.innerText;
807
- els.script.innerHTML = "";
808
  wordSpans = [];
809
  const tokens = text.split(/(\s+)/);
810
  for (const tok of tokens) {
811
  if (/^\s+$/.test(tok)) {
812
- els.script.appendChild(document.createTextNode(tok));
813
  } else if (tok.length) {
814
  const span = document.createElement("span");
815
  span.className = "word";
816
  span.textContent = tok;
817
- els.script.appendChild(span);
818
  wordSpans.push(span);
819
  }
820
  }
821
- if (els.script.contains(document.activeElement)) {
822
- const walker = document.createTreeWalker(els.script, NodeFilter.SHOW_TEXT);
823
- let pos = 0, node;
824
- while ((node = walker.nextNode())) {
825
- const next = pos + node.nodeValue.length;
826
- if (next >= caretOffset) {
827
- const range = document.createRange();
828
- range.setStart(node, caretOffset - pos);
829
- range.collapse(true);
830
- sel.removeAllRanges();
831
- sel.addRange(range);
832
- break;
833
- }
834
- pos = next;
835
- }
836
- }
837
  };
838
- els.script.addEventListener("input", tokenizeScript);
839
- els.script.addEventListener("focus", () => {
840
- if (els.script.innerText.trim() === "Type what the cheetah should say…") {
841
- els.script.innerHTML = "";
842
- tokenizeScript();
843
- }
844
- });
845
- const scriptText = () => els.script.innerText.trim();
846
  const clearHighlight = () => {
847
  wordSpans.forEach(s => s.classList.remove("current", "past"));
848
- els.script.classList.remove("playing");
849
  };
850
  const setCurrentWord = (idx) => {
851
  for (let i = 0; i < wordSpans.length; i++) {
@@ -858,13 +608,16 @@ const setCurrentWord = (idx) => {
858
  const cur = wordSpans[idx];
859
  cur.classList.add("current");
860
  cur.classList.remove("past");
861
- const r = cur.getBoundingClientRect();
862
- const sr = els.script.getBoundingClientRect();
863
- if (r.bottom > sr.bottom - 20) cur.scrollIntoView({ block: "end" });
 
 
 
864
  }
865
  };
866
  const highlightLoop = () => {
867
- if (!audioEl || !audioEl.duration) {
868
  hlRAF = requestAnimationFrame(highlightLoop);
869
  return;
870
  }
@@ -879,155 +632,32 @@ const highlightLoop = () => {
879
  hlRAF = requestAnimationFrame(highlightLoop);
880
  };
881
 
882
- // ---------- Channel strip ----------
883
- const buildMeter = () => {
884
- const ledCount = 28;
885
- const step = 100 / ledCount;
886
- els.meterRail.innerHTML = "";
887
- for (let i = 0; i < ledCount; i++) {
888
- const led = document.createElement("div");
889
- led.className = "meter-led";
890
- led.style.top = `${i * step}%`;
891
- led.style.height = `${Math.max(2, step * 0.7)}%`;
892
- led.dataset.level = 1 - (i / ledCount);
893
- els.meterRail.appendChild(led);
894
  }
895
- };
896
- const updateFaderUI = () => {
897
- const pct = (1 - (faderValueDb - (-60)) / (12 - (-60))) * 100;
898
- els.faderCap.style.top = `${Math.max(4, Math.min(96, pct))}%`;
899
- els.faderVal.textContent = faderValueDb <= -58 ? "-∞ dB" : `${faderValueDb >= 0 ? "+" : ""}${faderValueDb.toFixed(1)} dB`;
900
- if (gainNode) gainNode.gain.value = isMuted ? 0 : dbToGain(faderValueDb);
901
- };
902
- const updatePanUI = () => {
903
- const deg = (panValue + 1) * 180;
904
- els.panRing.style.setProperty("--knob-deg", `${deg}deg`);
905
- els.panCap.style.transform = `rotate(${deg}deg)`;
906
- els.panVal.textContent = panValue === 0 ? "C" : `${panValue > 0 ? "R" : "L"}${Math.round(Math.abs(panValue) * 100)}`;
907
- if (panNode) panNode.pan.value = panValue;
908
- };
909
- let draggingFader = false;
910
- els.faderRail.addEventListener("pointerdown", (e) => { draggingFader = true; els.faderRail.setPointerCapture(e.pointerId); moveFader(e); });
911
- els.faderRail.addEventListener("pointermove", (e) => { if (draggingFader) moveFader(e); });
912
- els.faderRail.addEventListener("pointerup", () => draggingFader = false);
913
- const moveFader = (e) => {
914
- const rect = els.faderRail.getBoundingClientRect();
915
- const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
916
- faderValueDb = 12 - y * (12 - (-60));
917
- updateFaderUI();
918
- };
919
- let draggingPan = false;
920
- els.panRing.addEventListener("pointerdown", (e) => { draggingPan = true; els.panRing.setPointerCapture(e.pointerId); movePan(e); });
921
- els.panRing.addEventListener("pointermove", (e) => { if (draggingPan) movePan(e); });
922
- els.panRing.addEventListener("pointerup", () => draggingPan = false);
923
- const movePan = (e) => {
924
- const rect = els.panRing.getBoundingClientRect();
925
- const cx = rect.left + rect.width / 2;
926
- const cy = rect.top + rect.height / 2;
927
- const angle = Math.atan2(e.clientY - cy, e.clientX - cx);
928
- let deg = (angle * 180 / Math.PI) + 90;
929
- if (deg < 0) deg += 360;
930
- panValue = Math.max(-1, Math.min(1, (deg / 180) - 1));
931
- updatePanUI();
932
- };
933
- els.muteBtn.addEventListener("click", () => { isMuted = !isMuted; els.muteBtn.classList.toggle("active", isMuted); updateFaderUI(); });
934
-
935
- // ---------- Audio graph ----------
936
- const initAudioGraph = () => {
937
- if (audioCtx) return;
938
- const AC = window.AudioContext || window.webkitAudioContext;
939
- audioCtx = new AC();
940
- gainNode = audioCtx.createGain();
941
- panNode = audioCtx.createStereoPanner();
942
- analyser = audioCtx.createAnalyser();
943
- analyser.fftSize = 256;
944
- analyser.smoothingTimeConstant = 0.8;
945
- gainNode.connect(panNode);
946
- panNode.connect(analyser);
947
- analyser.connect(audioCtx.destination);
948
- updateFaderUI();
949
- updatePanUI();
950
- meterLoop();
951
- highlightLoop();
952
- };
953
- const attachAudio = () => {
954
- if (!audioCtx) initAudioGraph();
955
- if (sourceNode) sourceNode.disconnect();
956
- sourceNode = audioCtx.createMediaElementSource(audioEl);
957
- sourceNode.connect(gainNode);
958
- };
959
- const meterLoop = () => {
960
- if (!analyser) return;
961
- const data = new Uint8Array(analyser.frequencyBinCount);
962
- analyser.getByteFrequencyData(data);
963
- const time = new Uint8Array(analyser.fftSize);
964
- analyser.getByteTimeDomainData(time);
965
- let peak = 0, tpeak = 0;
966
- for (let i = 0; i < data.length; i++) peak = Math.max(peak, data[i] / 255);
967
- for (let i = 0; i < time.length; i++) { const v = Math.abs((time[i] - 128) / 128); if (v > tpeak) tpeak = v; }
968
- const level = Math.max(peak * 0.7, tpeak * 0.9);
969
- els.meterRail.querySelectorAll(".meter-led").forEach(led => {
970
- const thr = parseFloat(led.dataset.level);
971
- const on = level >= thr;
972
- led.classList.toggle("on", on);
973
- led.classList.toggle("warn", on && thr > 0.5);
974
- led.classList.toggle("clip", on && thr > 0.85);
975
- });
976
- meterRAF = requestAnimationFrame(meterLoop);
977
  };
978
 
979
  // ---------- Player ----------
980
- const drawWaveform = async (audioUrl) => {
981
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
982
- const buf = await fetch(audioUrl).then(r => r.arrayBuffer());
983
- const audio = await ctx.decodeAudioData(buf);
984
- ctx.close();
985
- els.srMeta.textContent = `${audio.sampleRate} Hz`;
986
- els.chMeta.textContent = audio.numberOfChannels === 1 ? "Mono" : "Stereo";
987
- const canvas = els.wave;
988
- const dpr = window.devicePixelRatio || 1;
989
- const rect = els.waveWrap.getBoundingClientRect();
990
- canvas.width = rect.width * dpr; canvas.height = rect.height * dpr;
991
- const c = canvas.getContext("2d"); c.scale(dpr, dpr);
992
- const data0 = audio.getChannelData(0);
993
- const data1 = audio.numberOfChannels > 1 ? audio.getChannelData(1) : null;
994
- const samples = Math.floor(rect.width);
995
- const block = Math.max(1, Math.floor(data0.length / samples));
996
- const mid = rect.height / 2;
997
- c.clearRect(0, 0, rect.width, rect.height);
998
- c.strokeStyle = "rgba(255,255,255,0.04)"; c.beginPath(); c.moveTo(0, mid); c.lineTo(rect.width, mid); c.stroke();
999
- for (let i = 0; i < samples; i++) {
1000
- let s0 = 0, s1 = 0;
1001
- const start = i * block;
1002
- for (let j = 0; j < block; j++) { s0 += data0[start+j]*data0[start+j]; if (data1) s1 += data1[start+j]*data1[start+j]; }
1003
- const rms0 = Math.sqrt(s0 / block), rms1 = data1 ? Math.sqrt(s1 / block) : rms0;
1004
- const rms = Math.max(rms0, rms1);
1005
- const h = Math.max(1, rms * rect.height * 0.92);
1006
- c.fillStyle = "rgba(245, 165, 36, 0.75)";
1007
- c.fillRect(i, mid - h / 2, 1, h);
1008
- }
1009
- };
1010
- const loadAudio = (url) => {
1011
- if (audioUrl) URL.revokeObjectURL(audioUrl);
1012
- audioUrl = url;
1013
  audioEl = new Audio(url);
1014
  audioEl.preload = "auto";
1015
  audioEl.addEventListener("loadedmetadata", () => {
1016
  trackDuration = audioEl.duration;
1017
  els.durTime.textContent = fmtTime(trackDuration);
1018
- els.waveEmpty.style.display = "none";
1019
  els.playBtn.disabled = false;
1020
  els.stopBtn.disabled = false;
1021
  els.stagePill.textContent = "Ready";
1022
  els.recDot.classList.remove("live");
1023
- attachAudio();
1024
- drawWaveform(url);
1025
- buildRuler(trackDuration);
1026
  clearHighlight();
1027
- tokenizeScript();
1028
- els.script.classList.add("playing");
1029
- els.clipLabel.textContent = `Clip · ${fmtTime(trackDuration)}`;
1030
- const voiceLabel = els.speaker.options[els.speaker.selectedIndex]?.textContent || "—";
1031
  els.voiceInfo.textContent = voiceLabel;
1032
  });
1033
  audioEl.addEventListener("timeupdate", () => {
@@ -1051,17 +681,23 @@ const loadAudio = (url) => {
1051
  });
1052
  };
1053
  els.playBtn.addEventListener("click", () => {
1054
- if (!audioCtx) initAudioGraph();
1055
- if (audioCtx.state === "suspended") audioCtx.resume();
1056
  if (audioEl.paused) audioEl.play();
1057
  else audioEl.pause();
1058
  });
1059
- els.stopBtn.addEventListener("click", () => { audioEl.pause(); audioEl.currentTime = 0; els.playBtn.textContent = "▶"; clearHighlight(); });
1060
- els.waveWrap.addEventListener("click", (e) => {
 
 
 
 
 
1061
  if (!audioEl.duration) return;
1062
- const rect = els.waveWrap.getBoundingClientRect();
1063
- const t = ((e.clientX - rect.left) / rect.width) * audioEl.duration;
1064
- audioEl.currentTime = Math.max(0, Math.min(audioEl.duration, t));
 
 
 
1065
  });
1066
 
1067
  // ---------- Examples ----------
@@ -1070,12 +706,11 @@ const renderExamples = (items) => {
1070
  items.forEach(ex => {
1071
  const chip = document.createElement("button");
1072
  chip.type = "button"; chip.className = "example-chip";
1073
- chip.innerHTML = `<span class="tag">${ex.label}</span><span class="txt">${ex.text.slice(0,60)}${ex.text.length > 60 ? "…" : ""}</span>`;
1074
  chip.addEventListener("click", () => {
1075
  els.script.innerText = ex.text;
1076
  setMode("preset");
1077
  els.speaker.value = ex.speaker;
1078
- tokenizeScript();
1079
  });
1080
  els.examples.appendChild(chip);
1081
  });
@@ -1102,7 +737,6 @@ els.generateBtn.addEventListener("click", async () => {
1102
  setBusy(true);
1103
  els.stagePill.textContent = "Rendering";
1104
  els.recDot.classList.add("live");
1105
- startedAt = Date.now();
1106
  try {
1107
  currentSubmission = await client.submit("/synthesize", args);
1108
  for await (const event of currentSubmission) {
@@ -1110,7 +744,9 @@ els.generateBtn.addEventListener("click", async () => {
1110
  const data = event.data?.[0];
1111
  if (data?.url) {
1112
  const resp = await fetch(data.url); const blob = await resp.blob();
1113
- loadAudio(URL.createObjectURL(blob));
 
 
1114
  }
1115
  }
1116
  }
@@ -1128,10 +764,10 @@ els.generateBtn.addEventListener("click", async () => {
1128
  cfg = await fetch("/config.json").then(r => r.json());
1129
  cfg.speakers.forEach(s => { const o = document.createElement("option"); o.value = s.key; o.textContent = s.label; els.speaker.appendChild(o); });
1130
  buildSliders();
1131
- buildMeter();
1132
- tokenizeScript();
1133
  renderExamples(cfg.examples);
1134
  client = await Client.connect(window.location.origin);
 
 
1135
  })();
1136
  </script>
1137
  </body>
 
6
  <title>GEPARD STUDIO</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
10
  <style>
11
  :root {
12
  --bg: #0a0c11;
13
  --bg-elev: #13161d;
14
  --bg-elev-2: #1a1e27;
15
  --bg-input: #080a0e;
 
16
  --bg-viewport: #000000;
17
  --line: #20242e;
18
  --line-strong: #2b303c;
 
24
  --orange: #f97316;
25
  --red: #dc2626;
26
  --green: #22c55e;
27
+ --hl-bg: rgba(245, 165, 36, 0.28);
28
+ --hl-text: #fbbf24;
29
+ --hl-glow: 0 0 20px rgba(245, 165, 36, 0.25);
30
  --radius-sm: 3px;
31
  --radius: 5px;
32
  --radius-lg: 8px;
 
44
  body { display: flex; flex-direction: column; min-height: 100vh; }
45
  .mono, .num { font-family: "JetBrains Mono", ui-monospace, monospace; font-variant-numeric: tabular-nums; }
46
 
47
+ /* ===== Top bar ===== */
48
  .topbar {
49
  display: flex; align-items: center; justify-content: space-between;
50
  height: 48px; padding: 0 16px;
 
66
  text-transform: uppercase; letter-spacing: 0.1em;
67
  padding-left: 12px; border-left: 1px solid var(--line);
68
  }
 
69
 
70
+ /* ===== Viewport ===== */
71
  .viewport {
72
  margin: 14px;
73
  background: var(--bg-viewport);
 
99
  background: var(--bg-elev-2);
100
  }
101
 
102
+ /* Timecode */
103
  .timecode {
104
  display: flex; align-items: center; gap: 14px;
105
+ padding: 14px 16px 8px;
106
  background: var(--bg-viewport);
107
  }
108
  .timecode .tc {
109
  font-family: "JetBrains Mono", ui-monospace, monospace;
110
  font-variant-numeric: tabular-nums;
111
+ font-size: 36px;
112
  font-weight: 500;
113
  color: var(--text);
114
  letter-spacing: 0.02em;
115
  line-height: 1;
116
  }
117
  .timecode .tc .sep { color: var(--text-faint); margin: 0 1px; }
118
+ .timecode .tc .frames { color: var(--text-dim); font-size: 22px; }
119
  .timecode .tc-label {
120
  font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
121
  color: var(--text-faint); margin-bottom: 4px;
 
132
  .timecode .meta .k { color: var(--text-faint); margin-right: 6px; }
133
  .timecode .meta .v { color: var(--text); }
134
 
135
+ /* ===== The Lyrics Preview (replaces waveform) ===== */
136
+ .lyrics-preview {
137
+ min-height: 220px;
138
  background: var(--bg-viewport);
139
  position: relative;
140
+ overflow-y: auto;
 
141
  border-top: 1px solid var(--line);
142
+ padding: 32px 40px;
143
+ cursor: pointer;
144
+ scroll-behavior: smooth;
145
  }
146
+ .lyrics-preview .empty {
 
 
 
147
  color: var(--text-faint);
148
+ font-size: 14px;
149
+ letter-spacing: 0.12em;
150
+ text-transform: uppercase;
151
+ text-align: center;
152
+ margin-top: 80px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
+ .lyrics-text {
155
+ font-size: 28px;
156
+ font-weight: 500;
157
+ line-height: 1.55;
158
+ color: var(--text);
159
+ max-width: 900px;
160
+ margin: 0 auto;
161
+ text-align: center;
162
  }
163
+ .lyrics-text .word {
164
+ display: inline-block;
165
+ padding: 2px 4px;
166
+ margin: 0 1px;
167
+ border-radius: 6px;
168
+ color: rgba(231, 233, 240, 0.35);
169
+ transition: color 0.15s, background-color 0.15s, transform 0.15s;
170
  }
171
+ .lyrics-text .word.past {
172
+ color: rgba(231, 233, 240, 0.55);
 
 
 
 
 
 
173
  }
174
+ .lyrics-text .word.current {
175
+ color: var(--hl-text);
176
+ background: var(--hl-bg);
177
+ box-shadow: var(--hl-glow);
178
+ transform: scale(1.05);
 
 
 
 
 
 
 
 
 
 
179
  }
180
+ .lyrics-progress-bar {
181
+ position: absolute; top: 0; left: 0; right: 0; height: 2px;
182
+ background: var(--bg-elev);
 
 
 
 
 
183
  }
184
+ .lyrics-progress-bar .fill {
185
+ height: 100%; width: 0;
186
  background: var(--amber);
187
+ transition: width 0.05s linear;
 
 
 
 
 
 
 
 
188
  }
189
 
190
+ /* Transport */
191
  .transport {
192
  display: flex; align-items: center; gap: 14px;
193
  padding: 14px 16px;
 
206
  }
207
  .transport-btns button:hover:not(:disabled) { color: var(--text); border-color: var(--line-strong); }
208
  .transport-btns button:disabled { color: var(--text-faint); cursor: not-allowed; }
209
+ .transport-btns .skip { width: 32px; height: 32px; font-size: 12px; }
 
 
210
  .transport-btns .play {
211
  width: 48px; height: 48px; font-size: 18px;
212
  background: var(--amber); color: #1a1300;
 
227
  .transport .info .k { color: var(--text-faint); margin-right: 6px; }
228
  .transport .info .v { color: var(--text); }
229
 
230
+ /* ===== Combined control panel ===== */
231
+ .controls {
232
+ margin: 0 14px 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  background: var(--bg-elev);
234
  border: 1px solid var(--line);
235
  border-radius: var(--radius-lg);
236
  overflow: hidden;
 
237
  }
238
+ .controls-head {
239
  display: flex; align-items: center; justify-content: space-between;
240
  padding: 8px 12px;
241
  border-bottom: 1px solid var(--line);
242
  background: var(--bg-elev-2);
243
  }
244
+ .controls-head .title {
245
  font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-dim);
246
  }
247
+ .controls-body {
248
+ padding: 14px;
249
+ display: grid;
250
+ grid-template-columns: 1fr 1fr;
251
+ gap: 20px;
252
+ }
253
+ @media (max-width: 900px) {
254
+ .controls-body { grid-template-columns: 1fr; }
255
+ }
256
 
257
+ .col-label {
 
 
258
  font-size: 9px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--text-faint);
259
+ margin-bottom: 8px;
260
  }
261
+ .field { margin-bottom: 12px; }
262
+ .field:last-child { margin-bottom: 0; }
263
  select, textarea, input[type="text"] {
264
  width: 100%; background: var(--bg-input); border: 1px solid var(--line); border-radius: var(--radius);
265
  color: var(--text); font: inherit; padding: 8px 10px; outline: none; transition: border-color 0.15s;
266
  }
267
  select:focus, textarea:focus { border-color: var(--amber); }
268
+ textarea { resize: vertical; min-height: 80px; line-height: 1.5; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
 
270
  .seg {
271
  display: grid; grid-template-columns: 1fr 1fr;
272
  background: var(--bg-input); border: 1px solid var(--line); border-radius: var(--radius); padding: 3px;
 
279
  .seg button.active { background: var(--bg-elev-2); color: var(--text); box-shadow: 0 0 0 1px var(--line-strong); }
280
  .seg button:not(.active):hover { color: var(--text); }
281
 
 
282
  .file-drop {
283
  border: 1px dashed var(--line-strong); border-radius: var(--radius); padding: 12px;
284
  text-align: center; color: var(--text-dim); background: var(--bg-input); cursor: pointer; transition: all 0.15s;
 
288
  .file-drop .lbl { font-size: 11px; }
289
  .file-name { margin-top: 5px; font-family: "JetBrains Mono", monospace; font-size: 10px; color: var(--amber); }
290
 
291
+ /* Script editor (compact, for typing) */
292
+ .script-editor {
293
+ background: var(--bg-input);
294
+ border: 1px solid var(--line);
295
+ border-radius: var(--radius);
296
+ padding: 10px 12px;
297
+ min-height: 80px;
298
+ line-height: 1.5;
299
+ font-size: 13px;
300
+ color: var(--text);
301
+ outline: none;
302
+ transition: border-color 0.15s;
303
+ white-space: pre-wrap;
304
+ word-wrap: break-word;
305
+ }
306
+ .script-editor:focus { border-color: var(--amber); }
307
+
308
  /* Sliders */
309
  .slider {
310
+ display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; padding: 4px 0;
311
  }
312
  .slider .name { font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: 0.1em; }
313
  .slider .val {
 
327
  input[type="range"]::-moz-range-track { height: 3px; background: var(--bg-input); border: 1px solid var(--line); border-radius: 2px; }
328
  input[type="range"]::-moz-range-thumb { width: 11px; height: 11px; border: 0; border-radius: 2px; background: var(--amber); cursor: pointer; }
329
 
330
+ /* Examples + render */
331
+ .examples { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
332
+ .example-chip {
333
+ appearance: none; display: inline-flex; align-items: center; gap: 6px;
334
+ padding: 5px 9px; background: var(--bg-elev-2); border: 1px solid var(--line); border-radius: 999px;
335
+ color: var(--text-dim); font: inherit; font-size: 11px; cursor: pointer; transition: all 0.12s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  }
337
+ .example-chip:hover { border-color: var(--amber); color: var(--text); }
338
+ .example-chip .tag { font-family: "JetBrains Mono", monospace; color: var(--amber); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
339
+ .example-chip .txt { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
340
 
341
+ .render-row {
342
+ margin-top: 14px;
343
+ display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px;
344
+ }
345
  .generate-btn {
346
+ appearance: none; border: 0; padding: 12px 24px; border-radius: var(--radius);
347
  background: linear-gradient(90deg, var(--amber) 0%, var(--orange) 60%, var(--red) 100%);
348
  color: #fff; font: inherit; font-weight: 700; font-size: 12px;
349
  letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer;
350
  box-shadow: 0 0 0 1px rgba(0,0,0,0.3), 0 6px 18px rgba(249,115,22,0.2);
351
+ transition: filter 0.12s, transform 0.05s;
352
+ display: flex; align-items: center; justify-content: center; gap: 10px;
353
+ min-width: 180px;
354
  }
355
  .generate-btn:hover:not(:disabled) { filter: brightness(1.06); }
356
  .generate-btn:active:not(:disabled) { transform: translateY(1px); }
 
358
  .generate-btn .spinner { width: 12px; height: 12px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; }
359
  @keyframes spin { to { transform: rotate(360deg); } }
360
 
 
361
  .toast {
362
  margin-top: 10px; padding: 9px 12px; border-radius: var(--radius);
363
  font-size: 12px; background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5;
364
  }
365
  .toast.hidden { display: none; }
 
 
 
 
 
 
 
 
 
 
 
366
  </style>
367
  </head>
368
  <body>
 
376
  <span class="meta">v1.0 · Text-to-Speech</span>
377
  </div>
378
  </div>
 
379
  </div>
380
 
381
+ <!-- ===== Viewport ===== -->
382
  <section class="viewport">
383
  <div class="viewport-head">
384
  <div class="left">
385
  <span class="rec-dot" id="rec-dot"></span>
386
+ <span class="label">Program · Lyrics</span>
387
  </div>
388
  <div class="right">
389
  <span class="stage" id="stage-pill">Idle</span>
 
398
  <div class="spacer"></div>
399
  <div class="meta">
400
  <div><span class="k">Duration</span><span class="v" id="dur-time">0:00</span></div>
401
+ <div><span class="k">Voice</span><span class="v" id="voice-info">—</span></div>
402
+ <div><span class="k">Codec</span><span class="v">NeMo Nano · 22.05kHz</span></div>
403
  </div>
404
  </div>
405
 
406
+ <div class="lyrics-preview" id="lyrics-preview">
407
+ <div class="lyrics-progress-bar"><div class="fill" id="progress-fill"></div></div>
408
+ <div class="empty" id="lyrics-empty">No signal · awaiting input</div>
409
+ <div class="lyrics-text" id="lyrics-text" style="display:none"></div>
 
 
 
 
 
 
 
 
 
410
  </div>
411
 
412
  <div class="transport">
413
  <div class="transport-btns">
414
+ <button class="skip" id="stop-btn" type="button" title="Return to start" disabled>⏮</button>
415
  <button class="play" id="play-btn" type="button" title="Play / Pause" disabled>▶</button>
416
  </div>
417
  <div class="spacer"></div>
418
  <div class="info">
419
+ <div><span class="k">Engine</span><span class="v">Gepard · 32 FSQ heads</span></div>
 
420
  </div>
421
  </div>
422
  </section>
423
 
424
+ <!-- ===== Combined control panel ===== -->
425
+ <section class="controls">
426
+ <div class="controls-head">
427
+ <div class="title">Console</div>
428
+ </div>
429
+ <div class="controls-body">
430
 
431
+ <!-- Left column: Source + Script -->
432
+ <div>
433
+ <div class="col-label">Source</div>
434
+ <div class="field">
 
 
435
  <div class="seg" id="mode-seg">
436
  <button id="mode-preset" class="active" type="button">Preset</button>
437
  <button id="mode-clone" type="button">Clone</button>
438
  </div>
439
  </div>
440
+ <div class="field" id="speaker-section">
 
441
  <select id="speaker"></select>
442
  </div>
443
+ <div class="field hidden" id="ref-section">
 
444
  <div class="file-drop" id="drop">
445
  <div class="ico">🎙️</div>
446
+ <div class="lbl">Drop reference clip · click to upload</div>
447
  <div class="file-name" id="file-name"></div>
448
  </div>
449
  <input type="file" id="file-input" accept="audio/*" hidden />
450
  </div>
451
+ <div class="col-label" style="margin-top: 16px">Script</div>
452
+ <div class="field">
453
  <div class="script-editor" id="script" contenteditable="true" spellcheck="false">Type what the cheetah should say…</div>
454
  </div>
455
  </div>
 
456
 
457
+ <!-- Right column: Mix + Examples + Render -->
458
+ <div>
459
+ <div class="col-label">Mix · Parameters</div>
460
+ <div class="field" id="sliders"></div>
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
+ <div class="col-label" style="margin-top: 16px">Library · Examples</div>
463
+ <div class="examples" id="examples"></div>
464
+
465
+ <div class="render-row">
466
+ <div class="toast hidden" id="toast"></div>
467
+ <button class="generate-btn" id="generate-btn" type="button"><span class="lbl">▶ Render</span></button>
 
 
 
 
 
 
 
 
 
 
468
  </div>
469
  </div>
 
470
 
471
+ </div>
472
+ </section>
473
 
474
  <script type="module">
475
  import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
 
478
  const els = {
479
  recDot: $("rec-dot"), stagePill: $("stage-pill"),
480
  tcH: $("tc-h"), tcM: $("tc-m"), tcS: $("tc-s"), tcF: $("tc-f"),
481
+ durTime: $("dur-time"), voiceInfo: $("voice-info"),
482
+ lyricsPreview: $("lyrics-preview"),
483
+ lyricsEmpty: $("lyrics-empty"), lyricsText: $("lyrics-text"),
484
+ progressFill: $("progress-fill"),
 
 
 
485
  playBtn: $("play-btn"), stopBtn: $("stop-btn"),
486
  modePreset: $("mode-preset"), modeClone: $("mode-clone"),
487
  speaker: $("speaker"), speakerSection: $("speaker-section"), refSection: $("ref-section"),
 
489
  script: $("script"),
490
  sliders: $("sliders"),
491
  generateBtn: $("generate-btn"), toast: $("toast"), examples: $("examples"),
 
 
 
 
492
  };
493
 
494
  let cfg = null;
495
  let client = null;
496
  let mode = "preset";
497
  let refFile = null;
 
498
  let audioEl = new Audio();
499
  let currentSubmission = null;
 
500
  let trackDuration = 0;
501
 
 
 
 
 
 
 
 
 
 
 
502
  let wordSpans = [];
503
+ let wordScript = ""; // the actual text that's currently in the lyrics viewport
504
  let hlRAF = null;
505
 
506
  const sliderSpec = [
 
511
  { key: "repetition_window", label: "Rep. Window", fmt: v => String(Math.round(v)), step: 1 },
512
  ];
513
 
 
514
  const setBusy = (busy) => {
515
  els.generateBtn.disabled = busy;
516
  els.generateBtn.innerHTML = busy
 
522
  const fmtTime = (s) => { if (!isFinite(s)) return "0:00"; const m = Math.floor(s / 60); const r = Math.floor(s % 60); return `${m}:${String(r).padStart(2, "0")}`; };
523
  const pad = (n, w = 2) => String(Math.floor(n)).padStart(w, "0");
524
 
 
525
  const updateTimecode = (current, total) => {
526
  const fps = 24;
527
  const h = Math.floor(current / 3600);
 
533
  els.tcS.textContent = pad(s);
534
  els.tcF.textContent = pad(frames);
535
  const pct = total ? (current / total) * 100 : 0;
536
+ els.progressFill.style.width = `${pct}%`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  };
538
 
539
  // ---------- Mode / file ----------
 
570
  };
571
  const sliderValue = (key) => Number($(`sl-${key}`).value);
572
 
573
+ // ---------- Lyrics preview ----------
574
+ // Renders the script as a series of word spans so we can highlight the
575
+ // current word as the audio plays. Called when audio loads (with the
576
+ // synthesized text) — the small script editor below stays editable but
577
+ // is NOT what gets highlighted in the viewport.
578
+ const renderLyrics = (text) => {
579
+ wordScript = text;
580
+ els.lyricsEmpty.style.display = "none";
581
+ els.lyricsText.style.display = "";
582
+ els.lyricsText.innerHTML = "";
 
 
 
583
  wordSpans = [];
584
  const tokens = text.split(/(\s+)/);
585
  for (const tok of tokens) {
586
  if (/^\s+$/.test(tok)) {
587
+ els.lyricsText.appendChild(document.createTextNode(tok));
588
  } else if (tok.length) {
589
  const span = document.createElement("span");
590
  span.className = "word";
591
  span.textContent = tok;
592
+ els.lyricsText.appendChild(span);
593
  wordSpans.push(span);
594
  }
595
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  };
 
 
 
 
 
 
 
 
597
  const clearHighlight = () => {
598
  wordSpans.forEach(s => s.classList.remove("current", "past"));
 
599
  };
600
  const setCurrentWord = (idx) => {
601
  for (let i = 0; i < wordSpans.length; i++) {
 
608
  const cur = wordSpans[idx];
609
  cur.classList.add("current");
610
  cur.classList.remove("past");
611
+ // Scroll into view inside the preview
612
+ const pr = els.lyricsPreview.getBoundingClientRect();
613
+ const cr = cur.getBoundingClientRect();
614
+ if (cr.bottom > pr.bottom - 40 || cr.top < pr.top + 40) {
615
+ cur.scrollIntoView({ block: "center", behavior: "smooth" });
616
+ }
617
  }
618
  };
619
  const highlightLoop = () => {
620
+ if (!audioEl || !audioEl.duration || wordSpans.length === 0) {
621
  hlRAF = requestAnimationFrame(highlightLoop);
622
  return;
623
  }
 
632
  hlRAF = requestAnimationFrame(highlightLoop);
633
  };
634
 
635
+ // ---------- Script editor (small, editable input below) ----------
636
+ els.script.addEventListener("focus", () => {
637
+ if (els.script.innerText.trim() === "Type what the cheetah should say…") {
638
+ els.script.innerHTML = "";
 
 
 
 
 
 
 
 
639
  }
640
+ });
641
+ const scriptText = () => {
642
+ const t = els.script.innerText.trim();
643
+ if (t === "Type what the cheetah should say…") return "";
644
+ return t;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
  };
646
 
647
  // ---------- Player ----------
648
+ const loadAudio = (url, script) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  audioEl = new Audio(url);
650
  audioEl.preload = "auto";
651
  audioEl.addEventListener("loadedmetadata", () => {
652
  trackDuration = audioEl.duration;
653
  els.durTime.textContent = fmtTime(trackDuration);
 
654
  els.playBtn.disabled = false;
655
  els.stopBtn.disabled = false;
656
  els.stagePill.textContent = "Ready";
657
  els.recDot.classList.remove("live");
658
+ renderLyrics(script);
 
 
659
  clearHighlight();
660
+ const voiceLabel = els.speaker.options[els.speaker.selectedIndex]?.textContent || "Cloned";
 
 
 
661
  els.voiceInfo.textContent = voiceLabel;
662
  });
663
  audioEl.addEventListener("timeupdate", () => {
 
681
  });
682
  };
683
  els.playBtn.addEventListener("click", () => {
 
 
684
  if (audioEl.paused) audioEl.play();
685
  else audioEl.pause();
686
  });
687
+ els.stopBtn.addEventListener("click", () => {
688
+ audioEl.pause();
689
+ audioEl.currentTime = 0;
690
+ els.playBtn.textContent = "▶";
691
+ clearHighlight();
692
+ });
693
+ els.lyricsPreview.addEventListener("click", (e) => {
694
  if (!audioEl.duration) return;
695
+ // Click anywhere in the lyrics jumps to the proportional time
696
+ const r = els.lyricsPreview.getBoundingClientRect();
697
+ // Use vertical position to seek? Actually the progress bar is at the top.
698
+ // For simplicity, click position relative to top controls seek:
699
+ const y = (e.clientY - r.top) / r.height;
700
+ audioEl.currentTime = Math.max(0, Math.min(audioEl.duration, y * audioEl.duration));
701
  });
702
 
703
  // ---------- Examples ----------
 
706
  items.forEach(ex => {
707
  const chip = document.createElement("button");
708
  chip.type = "button"; chip.className = "example-chip";
709
+ chip.innerHTML = `<span class="tag">${ex.label}</span><span class="txt">${ex.text.slice(0,50)}${ex.text.length > 50 ? "…" : ""}</span>`;
710
  chip.addEventListener("click", () => {
711
  els.script.innerText = ex.text;
712
  setMode("preset");
713
  els.speaker.value = ex.speaker;
 
714
  });
715
  els.examples.appendChild(chip);
716
  });
 
737
  setBusy(true);
738
  els.stagePill.textContent = "Rendering";
739
  els.recDot.classList.add("live");
 
740
  try {
741
  currentSubmission = await client.submit("/synthesize", args);
742
  for await (const event of currentSubmission) {
 
744
  const data = event.data?.[0];
745
  if (data?.url) {
746
  const resp = await fetch(data.url); const blob = await resp.blob();
747
+ loadAudio(URL.createObjectURL(blob), text);
748
+ // Auto-play once loaded
749
+ audioEl.addEventListener("loadedmetadata", () => audioEl.play(), { once: true });
750
  }
751
  }
752
  }
 
764
  cfg = await fetch("/config.json").then(r => r.json());
765
  cfg.speakers.forEach(s => { const o = document.createElement("option"); o.value = s.key; o.textContent = s.label; els.speaker.appendChild(o); });
766
  buildSliders();
 
 
767
  renderExamples(cfg.examples);
768
  client = await Client.connect(window.location.origin);
769
+ // start the highlight RAF loop regardless (it no-ops without audio)
770
+ highlightLoop();
771
  })();
772
  </script>
773
  </body>