akhaliq HF Staff Claude commited on
Commit
dc6e1bc
·
1 Parent(s): 5be45dc

Fix audio URL handling + redesign UI as audio studio

Browse files

Backend:
- Endpoints now return plain serializable dicts {url, filename} instead
of FileData, fixing 'Cannot read properties of undefined (reading url)'
in the JS client under gradio.Server
- Serve rendered WAVs via a /rendered/{name} FileResponse route with
path-traversal guard; generated files live in rendered/ (gitignored)

Frontend: full redesign as a professional audio studio
- Top bar with brand, live SR/RTF/chars meters + status LED, tab nav
- Left control rack (script, voice model, delivery, advanced, transport)
- Stage with now-playing badge, transport (play/stop/restart),
canvas waveform with playhead + chunk markers, seek, VU meters,
status strip, karaoke script that lights up as spoken, take tools
- Compare view with per-model mini waveforms, playheads, seek, download
- Waveforms drawn from decoded PCM; reads res.data[0].url directly

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

Files changed (3) hide show
  1. .gitignore +1 -0
  2. app.py +24 -8
  3. index.html +596 -640
.gitignore CHANGED
@@ -1,3 +1,4 @@
1
  __pycache__/
2
  *.py[cod]
3
  .cache/
 
 
1
  __pycache__/
2
  *.py[cod]
3
  .cache/
4
+ rendered/
app.py CHANGED
@@ -4,7 +4,6 @@ import logging
4
  import os
5
  import re
6
  import sys
7
- import tempfile
8
  import threading
9
  import time
10
  from dataclasses import dataclass
@@ -40,7 +39,6 @@ sys.path.insert(0, str(RUNTIME))
40
  sys.path.insert(0, str(ROOT))
41
 
42
  from gradio import Server
43
- from gradio.data_classes import FileData
44
 
45
  # Register ONNX Runtime / model asset MIME types. Windows (and some containers)
46
  # do not map these by default, and FileResponse would otherwise serve the
@@ -282,12 +280,22 @@ def validate(
282
  return text, float(speed), float(variation), float(pitch_steps), int(seed)
283
 
284
 
285
- def render_wav(sample_rate: int, samples: np.ndarray) -> FileData:
286
- """Persist PCM16 samples to a WAV and return a Gradio FileData handle."""
287
- handle, out_path = tempfile.mkstemp(suffix=".wav", prefix="inflect_")
288
- os.close(handle)
289
- sf.write(out_path, samples, sample_rate, subtype="PCM_16")
290
- return FileData(path=out_path)
 
 
 
 
 
 
 
 
 
 
291
 
292
 
293
  @app.api(concurrency_limit=1)
@@ -366,6 +374,14 @@ async def _cross_origin_isolation(request, call_next):
366
  return response
367
 
368
 
 
 
 
 
 
 
 
 
369
  @app.get(STATIC_URL + "/{path:path}")
370
  async def serve_web_runtime(path: str):
371
  full = (WEB_RUNTIME / path).resolve()
 
4
  import os
5
  import re
6
  import sys
 
7
  import threading
8
  import time
9
  from dataclasses import dataclass
 
39
  sys.path.insert(0, str(ROOT))
40
 
41
  from gradio import Server
 
42
 
43
  # Register ONNX Runtime / model asset MIME types. Windows (and some containers)
44
  # do not map these by default, and FileResponse would otherwise serve the
 
280
  return text, float(speed), float(variation), float(pitch_steps), int(seed)
281
 
282
 
283
+ WAV_STORE = ROOT / "rendered"
284
+ WAV_STORE.mkdir(exist_ok=True)
285
+ WAV_URL = "/rendered"
286
+
287
+
288
+ def render_wav(sample_rate: int, samples: np.ndarray) -> dict:
289
+ """Persist PCM16 samples to a WAV and return a serializable file handle.
290
+
291
+ @app.api() endpoints return JSON to the JS client, so we hand back a plain
292
+ dict (url + filename) rather than a FileData object whose shape depends on
293
+ Gradio's component serialization.
294
+ """
295
+ name = f"{int(time.perf_counter() * 1000)}_{os.getpid()}.wav"
296
+ out_path = WAV_STORE / name
297
+ sf.write(str(out_path), samples, sample_rate, subtype="PCM_16")
298
+ return {"url": f"{WAV_URL}/{name}", "filename": name}
299
 
300
 
301
  @app.api(concurrency_limit=1)
 
374
  return response
375
 
376
 
377
+ @app.get(WAV_URL + "/{name}")
378
+ async def serve_rendered_wav(name: str):
379
+ full = (WAV_STORE / name).resolve()
380
+ if not full.is_file() or full.parent != WAV_STORE.resolve():
381
+ return HTMLResponse("Not found", status_code=404)
382
+ return FileResponse(str(full), media_type="audio/wav")
383
+
384
+
385
  @app.get(STATIC_URL + "/{path:path}")
386
  async def serve_web_runtime(path: str):
387
  full = (WEB_RUNTIME / path).resolve()
index.html CHANGED
@@ -3,746 +3,702 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Inflect v2 · speech playground</title>
7
  <link rel="icon" href="/web_runtime/favicon.svg" type="image/svg+xml" />
8
  <style>
9
  :root{
10
  color-scheme:dark;
11
- --paper:#0e1110;--panel:#161a17;--panel-2:#131714;--well:#10140f;
12
- --ink:#f4f4ee;--muted:#9aa49c;--line:#2a322d;--line-soft:#212823;
13
- --signal:#2486ff;--signal-hover:#52a0ff;--signal-soft:#15263b;
14
- --accent:#4ade80;--warn:#f59e0b;
 
 
15
  }
16
  *{box-sizing:border-box}
17
- html,body{margin:0;padding:0}
18
  body{
19
- background:var(--paper);color:var(--ink);
 
 
 
20
  font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
21
  line-height:1.5;-webkit-font-smoothing:antialiased;
 
22
  }
23
- .wrap{max-width:1120px;margin:0 auto;padding:0 clamp(16px,3.4vw,40px) 64px}
24
-
25
- /* Hero */
26
- .hero{padding:24px 0 16px;border-bottom:1px solid var(--line);margin-bottom:26px}
27
- .hero-top{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap}
28
- .eyebrow{color:var(--signal);font:700 11px/1 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.14em;text-transform:uppercase}
29
- .links{display:flex;align-items:center;gap:18px;font-size:12px;font-weight:700}
30
- .links a{color:var(--muted);text-decoration:none;transition:color .15s ease}
31
- .links a:hover{color:var(--ink)}
32
- .hero h1{margin:16px 0 8px;font-size:clamp(34px,5.2vw,52px);line-height:1;letter-spacing:-.042em;font-weight:680}
33
- .hero p{margin:0;max-width:680px;color:var(--muted);font-size:13.5px}
34
-
35
- /* Tabs */
36
- .tabs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;border:1px solid var(--line);border-radius:4px;overflow:hidden;background:var(--panel-2)}
37
- .tabs button{
38
- display:flex;flex-direction:column;align-items:flex-start;justify-content:center;
39
- min-height:56px;padding:11px 16px;border:0;background:transparent;cursor:pointer;
40
- text-align:left;color:var(--ink);font-size:13px;font-weight:800;line-height:1.2;
41
- transition:background .16s ease,color .16s ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
43
- .tabs button+button{border-left:1px solid var(--line)}
44
- .tabs button small{margin-top:4px;color:var(--muted);font-size:10px;font-weight:550;letter-spacing:.01em}
45
- .tabs button:hover{background:#1a232c}
46
- .tabs button.active{background:var(--signal-soft);box-shadow:inset 0 -2px 0 var(--signal)}
47
- .tabs button.active small{color:#a9c7e8}
48
- .tabpane{display:none;margin-top:18px}
49
- .tabpane.active{display:block}[id="pane-hosted"].active,[id="pane-browser"].active{display:block}
50
- .mode-banner{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:16px;margin:8px 0 18px;padding:10px 13px;border:1px solid var(--line);border-left:2px solid var(--signal);border-radius:3px;background:var(--panel-2)}
51
- .mode-banner strong{display:block;color:var(--ink);font-size:12px}
52
- .mode-banner span{color:var(--muted);font-size:11px;line-height:1.45}
53
- .mode-badge{padding:5px 8px;border:1px solid #356ea8;border-radius:2px;color:#dceaff;font:750 9px/1 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap}
54
-
55
- /* Card */
56
- .card{border:1px solid var(--line);border-radius:6px;background:var(--panel);overflow:hidden}
57
- .card+.card{margin-top:14px}
58
- .card-pad{padding:18px 20px 20px}
59
-
60
- /* Form */
61
- .field{margin-bottom:16px}
62
- .field>label{display:block;font-size:12px;font-weight:750;letter-spacing:.02em;margin-bottom:7px}
63
  textarea,input[type="text"],input[type="number"],select{
64
- width:100%;background:var(--well);color:var(--ink);border:1px solid var(--line);
65
- border-radius:4px;padding:12px 13px;font-size:14px;font-family:inherit;outline:none;transition:border-color .15s ease
 
 
 
 
 
 
 
 
 
 
 
 
66
  }
67
- textarea:focus,input:focus,select:focus{border-color:var(--signal)}
68
- textarea{min-height:88px;resize:vertical;font-size:15px;line-height:1.55}
69
- .examples{display:flex;flex-wrap:wrap;gap:8px;margin-top:9px}
70
- .examples button{background:var(--panel-2);border:1px solid var(--line);border-radius:3px;color:var(--muted);font-size:11px;padding:5px 9px;cursor:pointer;transition:color .15s,border-color .15s}
71
- .examples button:hover{color:var(--ink);border-color:#527aa6}
72
-
73
- /* Segmented (model switch) */
74
- .seg{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;border:1px solid var(--line);border-radius:4px;overflow:hidden;background:var(--well)}
75
  .seg input{position:absolute;opacity:0;width:0;height:0}
76
- .seg label{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;min-height:48px;padding:9px 13px;cursor:pointer;border:0;background:transparent;transition:background .15s ease}
77
- .seg label+label{border-left:1px solid var(--line)}
78
- .seg label strong{font-size:13px;font-weight:800}
79
- .seg label small{color:var(--muted);font-size:10.5px;margin-top:2px}
80
- .seg input:checked+strong{color:#fff}
81
- .seg label:has(input:checked){background:var(--well);box-shadow:inset 0 -2px 0 var(--signal)}
82
-
83
- /* Sliders */
84
- .sliders{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px 22px;margin:16px 0}
85
- .slider{display:flex;flex-direction:column;gap:7px}
86
- .slider .row{display:flex;justify-content:space-between;align-items:baseline}
87
- .slider .row label{font-size:12px;font-weight:750;letter-spacing:.02em}
88
- .slider .row .val{font:600 11px/1 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--signal)}
89
  input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:4px;background:var(--line-soft);border-radius:4px;outline:none}
90
- input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--signal);cursor:pointer;border:2px solid var(--paper)}
91
- input[type="range"]::-moz-range-thumb{width:14px;height:14px;border:2px solid var(--paper);border-radius:50%;background:var(--signal);cursor:pointer}
92
- .slider small{color:var(--muted);font-size:10.5px;line-height:1.4}
 
93
 
94
- details.adv{margin:6px 0 0;border:1px solid var(--line-soft);border-left:2px solid #344138;border-radius:3px;background:var(--well)}
95
- details.adv summary{padding:10px 13px;cursor:pointer;font-size:12px;font-weight:740;letter-spacing:.01em;list-style:none}
96
  details.adv summary::-webkit-details-marker{display:none}
97
- details.adv summary::after{content:"▾";float:right;color:var(--muted);transition:transform .2s}
98
- details.adv[open] summary::after{transform:rotate(0)}
99
- details.adv .body{padding:2px 13px 14px}
100
-
101
- /* Actions */
102
- .actions{display:flex;gap:12px;justify-content:center;margin-top:18px;flex-wrap:wrap}
103
- .btn{min-height:46px;padding:0 22px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--ink);font-size:13.5px;font-weight:760;cursor:pointer;transition:background .15s,border-color .15s}
104
- .btn:hover{background:#1a232c;border-color:#527aa6}
105
- .btn.primary{border:0;background:var(--signal);color:#fff;font-weight:820}
106
- .btn.primary:hover{background:var(--signal-hover)}
107
- .btn.primary:disabled,.btn:disabled{opacity:.45;cursor:not-allowed}
108
-
109
- /* Status */
110
- .status{margin-top:14px;padding:11px 14px;background:var(--signal-soft);border:1px solid #294e78;border-radius:4px;color:#dceaff;font-size:12px;line-height:1.55}
111
- .status.empty{background:var(--panel-2);border-color:var(--line);color:var(--muted)}
112
- .status code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
113
- .queue{margin-top:9px;font-size:11.5px;color:#a9c7e8}
114
-
115
- /* Player */
116
- .player{margin-top:18px}
117
- .player-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:6px}
118
- .play-btn{display:flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:50%;border:0;background:var(--signal);color:#fff;font-size:20px;cursor:pointer;flex-shrink:0;transition:background .15s,transform .1s}
119
- .play-btn:hover{background:var(--signal-hover)}
120
- .play-btn:active{transform:scale(.96)}
121
- .play-btn:disabled{opacity:.4;cursor:not-allowed;background:var(--line)}
122
- .time{font:600 11.5px/1 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted)}
123
- .time .now{color:var(--ink)}
124
- .scrub{position:relative;flex:1;min-width:200px;height:24px;display:flex;align-items:center}
125
- .scrub input{width:100%}
126
- .markers{position:absolute;left:0;right:0;top:50%;height:6px;transform:translateY(-50%);pointer-events:none}
127
- .marker{position:absolute;top:0;width:1px;height:6px;background:rgba(255,255,255,.18)}
128
- .chunk-active{opacity:.18}
129
- .play-btn g{color:#fff}
130
- .download-row{display:flex;align-items:center;gap:14px;margin-top:4px;flex-wrap:wrap}
131
- a.dl{display:inline-flex;align-items:center;gap:7px;padding:8px 13px;border:1px solid var(--line);border-radius:3px;background:var(--panel-2);color:var(--ink);font-size:12px;font-weight:680;text-decoration:none;transition:border-color .15s}
132
- a.dl:hover{border-color:#527aa6}
133
-
134
- .karaoke{position:relative;background:var(--well);border:1px solid var(--line);border-radius:4px;padding:18px 22px;margin-top:18px;max-height:320px;overflow-y:auto;font-size:17px;line-height:1.75;letter-spacing:.005em}
135
- .karaoke .chunk{display:inline;color:var(--muted);transition:color .22s ease,background-color .22s ease;box-decoration-break:clone;-webkit-box-decoration-break:clone}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  .karaoke .chunk.speaking{color:var(--ink)}
137
- .karaoke .chunk.spoken{color:#7d877f}
138
- .karaoke .chunk.speaking::after{content:"";display:inline-block;width:2px;height:1.1em;background:var(--signal);margin-left:1px;vertical-align:-2px;animation:blink 1s steps(2) infinite}
139
- .fade{opacity:0;transition:opacity .35s ease}
140
- .fade.in{opacity:1}
141
- @keyframes blink{50%{opacity:0}}
142
-
143
- /* Compare */
144
- .compare-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-top:18px}
145
- .compare-card{border:1px solid var(--line);border-radius:5px;background:var(--panel);overflow:hidden}
146
- .compare-card .head{padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel-2);font-size:12px;font-weight:780}
147
- .compare-card .head small{display:block;color:var(--muted);font-weight:540;margin-top:2px}
148
- .compare-card .body{padding:14px}
149
- .mini-player{display:flex;align-items:center;gap:11px}
150
- .mini-play{width:40px;height:40px;border-radius:50%;border:0;background:var(--signal);color:#fff;font-size:14px;cursor:pointer;flex-shrink:0;transition:background .15s}
151
- .mini-play:hover{background:var(--signal-hover)}
 
 
 
 
 
 
 
 
 
 
 
152
  .mini-play:disabled{opacity:.4;cursor:not-allowed}
153
- .mini-time{font:600 10.5px/1 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted)}
154
- .mini-time .now{color:var(--ink)}
155
-
156
- /* Browser pane */
157
- .browser-frame-shell{border:1px solid var(--line);border-radius:3px;background:var(--paper);overflow:hidden}
 
 
 
 
 
 
158
  .browser-frame{display:block;width:100%;height:580px;border:0;background:var(--paper)}
159
- .browser-help{margin-top:10px;color:var(--muted);font-size:11.5px;line-height:1.55}
160
- .fineprint{color:var(--muted);font-size:12px;line-height:1.6;border-top:1px solid var(--line);padding-top:18px;margin-top:34px}
161
- .busy{display:inline-block;width:13px;height:13px;margin-right:8px;border:2px solid #a9c7e8;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px}
162
- @keyframes spin{to{transform:rotate(360deg)}}
163
 
 
 
 
164
  .hidden{display:none!important}
 
 
165
 
166
- @media(max-width:760px){
167
- .tiles-sm-scroll{display:grid;grid-template-columns:1fr}
168
- .wrap{padding:0 14px 48px}
169
- .hero h1{font-size:38px}
170
- .sliders{grid-template-columns:1fr}
171
  .compare-grid{grid-template-columns:1fr}
172
  .browser-frame{height:980px}
173
- .scrub{min-width:120px}
 
 
 
 
 
174
  }
175
  </style>
176
  </head>
177
  <body>
178
- <div class="wrap">
179
-
180
- <header class="hero">
181
- <div class="hero-top">
182
- <span class="eyebrow">Inflect v2 · choose where inference runs</span>
183
- <nav class="links">
184
- <a href="https://huggingface.co/owensong/Inflect-Micro-v2" target="_blank" rel="noopener">Micro model</a>
185
- <a href="https://huggingface.co/owensong/Inflect-Nano-v2" target="_blank" rel="noopener">Nano model</a>
186
- <a href="https://github.com/owenawsong/Inflect" target="_blank" rel="noopener">GitHub</a>
187
- </nav>
188
- </div>
189
- <h1>Small models.<br>Complete speech.</h1>
190
- <p>Generate 24&nbsp;kHz English speech with the published Inflect-Micro-v2 and Inflect-Nano-v2 checkpoints. Choose hosted ZeroGPU inference or run the ONNX models privately in this browser.</p>
191
- </header>
192
 
193
- <nav class="tabs" role="tablist">
194
- <button id="tab-hosted" class="active" role="tab" aria-selected="true">Hosted GPU<small>Hugging Face ZeroGPU</small></button>
195
- <button id="tab-browser" role="tab" aria-selected="false">This browser<small>WebGPU / WASM · private</small></button>
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  </nav>
197
-
 
 
 
 
 
 
 
198
  <!-- HOSTED -->
199
- <section id="pane-hosted" class="tabpane active">
200
- <div class="mode-banner">
201
- <span><strong>ZeroGPU inference</strong>Nothing downloads to your device. Hugging Face runs each request; free-tier quota and queue limits apply.</span>
202
- <span class="mode-badge">Server mode</span>
203
- </div>
204
-
205
- <div class="card">
206
- <div class="card-pad">
207
- <div class="field">
208
- <label for="text">Text</label>
209
- <textarea id="text" placeholder="Write a sentence, paragraph, or script...">Wait, are you actually being for real? I thought the train left twenty minutes ago!</textarea>
210
- <div class="examples" id="examples"></div>
 
211
  </div>
212
 
213
- <div class="field">
214
- <label>Model</label>
215
- <div class="seg" id="model-seg">
216
- <label>
217
- <input type="radio" name="model" value="Inflect Micro v2" checked>
218
- <strong>Inflect Micro v2</strong><small>9.36M · quality first</small>
219
- </label>
220
- <label>
221
- <input type="radio" name="model" value="Inflect Nano v2">
222
- <strong>Inflect Nano v2</strong><small>3.96M · footprint first</small>
223
- </label>
224
  </div>
225
  </div>
226
 
227
- <div class="sliders">
228
- <div class="slider">
229
- <div class="row"><label for="speed">Speaking speed</label><span class="val" id="speed-val">1.00</span></div>
230
- <input type="range" id="speed" min="0.7" max="1.35" step="0.01" value="1.0">
231
- <small>1.00 is the trained default.</small>
232
- </div>
233
- <div class="slider">
234
- <div class="row"><label for="variation">Delivery variation</label><span class="val" id="variation-val">0.667</span></div>
235
- <input type="range" id="variation" min="0" max="1" step="0.001" value="0.667">
236
- <small>Lower is steadier. Higher gives each take more variation.</small>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  </div>
238
  </div>
239
 
240
- <details class="adv">
241
- <summary>Advanced controls</summary>
242
  <div class="body">
243
- <div class="sliders">
244
- <div class="slider">
245
- <div class="row"><label for="pitch">Pitch shift (semitones)</label><span class="val" id="pitch-val">0.00</span></div>
246
- <input type="range" id="pitch" min="-2" max="2" step="0.25" value="0">
247
- <small>Changes voice pitch without changing speaking speed.</small>
248
- </div>
249
- <div class="slider">
250
- <div class="row"><label for="seed">Repeatable seed</label><span class="val" id="seed-val">0</span></div>
251
- <input type="number" id="seed" value="0" step="1" inputmode="numeric">
252
- <small>Use the same seed and settings to reproduce a take.</small>
253
- </div>
 
 
 
 
 
 
 
254
  </div>
255
  </div>
256
- </details>
 
 
 
 
 
257
 
258
- <div class="actions">
259
- <button class="btn primary" id="generate">Generate speech</button>
260
- <button class="btn" id="compare-btn">Compare Micro and Nano</button>
 
 
 
 
 
 
 
 
 
 
 
 
261
  </div>
262
 
263
- <div class="status empty" id="status">Ready.</div>
264
- <div class="queue hidden" id="queue"></div>
265
- </div>
266
- </div>
 
267
 
268
- <!-- Player -->
269
- <div class="player hidden" id="player">
270
- <div class="player-bar">
271
- <button class="play-btn" id="play" aria-label="Play">▶</button>
272
- <span class="time"><span class="now" id="now">0:00</span> / <span id="dur">0:00</span></span>
273
- <div class="scrub">
274
- <div class="markers" id="markers"></div>
275
- <input type="range" id="seek" min="0" max="1000" value="0" aria-label="Seek">
276
  </div>
277
- </div>
278
- <div class="download-row">
279
- <a class="dl" id="download" download="inflect_v2.wav">⬇ Download WAV</a>
280
- </div>
281
- <div class="karaoke fade" id="karaoke"></div>
282
- </div>
283
 
284
- <!-- Compare -->
285
- <div class="compare-grid">
286
- <div class="compare-card">
287
- <div class="head">Micro v2<small>9.36M · quality first</small></div>
288
- <div class="body">
289
- <div class="mini-player">
290
- <button class="mini-play" data-mini="micro" aria-label="Play Micro">▶</button>
291
- <span class="mini-time"><span class="now" id="micro-now">0:00</span> / <span id="micro-dur">0:00</span></span>
292
- <input type="range" id="micro-seek" min="0" max="1000" value="0" aria-label="Micro seek" style="flex:1">
293
- <a class="dl" id="micro-dl" download="inflect_micro.wav">⬇</a>
294
- </div>
295
  </div>
296
- </div>
297
- <div class="compare-card">
298
- <div class="head">Nano v2<small>3.96M · footprint first</small></div>
299
- <div class="body">
300
- <div class="mini-player">
301
- <button class="mini-play" data-mini="nano" aria-label="Play Nano"></button>
302
- <span class="mini-time"><span class="now" id="nano-now">0:00</span> / <span id="nano-dur">0:00</span></span>
303
- <input type="range" id="nano-seek" min="0" max="1000" value="0" aria-label="Nano seek" style="flex:1">
304
- <a class="dl" id="nano-dl" download="inflect_nano.wav">⬇</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  </div>
306
  </div>
307
- </div>
308
- </div>
309
- <div class="status empty" id="compare-status" style="margin-top:14px">Same text, seed, speed, variation, and pitch are sent to both models.</div>
310
 
311
- <p class="fineprint">Fixed English voice · English-only frontend · no voice cloning · no reference audio upload. Uncommon names, abbreviations, and long passages can still fail.</p>
 
 
312
  </section>
313
 
314
  <!-- BROWSER -->
315
- <section id="pane-browser" class="tabpane">
316
- <div class="mode-banner">
317
- <span><strong>Browser-local inference</strong>Your text and generated audio stay in this tab. WebGPU is preferred; compatible browsers fall back to WASM.</span>
318
- <span class="mode-badge">No queue · no quota</span>
319
- </div>
320
- <div class="browser-frame-shell">
321
- <iframe
322
- class="browser-frame"
323
- id="webgpu-frame"
324
- src="/web_runtime/index.html?embed=1"
325
- title="Inflect browser-local WebGPU runtime"
326
- allow="autoplay; webgpu"
327
- loading="eager"></iframe>
328
  </div>
329
- <p class="browser-help">The first browser-local run downloads the selected ONNX model and compiles it on your device. Streaming and complete-audio modes are both available inside this panel.</p>
330
  </section>
331
-
332
  </div>
333
 
334
  <script type="module">
335
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
336
 
337
- const fmtTime = (s) => {
338
- if (!Number.isFinite(s) || s < 0) s = 0;
339
- const m = Math.floor(s / 60);
340
- const sec = Math.floor(s % 60);
341
- return `${m}:${sec.toString().padStart(2, "0")}`;
342
- };
343
 
344
- // ---------- prompts ----------
345
  const PROMPTS = [
346
  "Wait, are you actually being for real? I thought the train left twenty minutes ago!",
347
  "Does punctuation work? Yes: commas, semicolons, and periods should create sensible boundaries.",
348
  "Professor Nguyen mailed the fluorescent poster to Reykjavik on Thursday.",
349
  "Although the weather changed without warning, the musicians finished packing before anyone opened the enormous glass door.",
350
  ];
351
- const examplesEl = document.getElementById("examples");
352
- PROMPTS.forEach((p) => {
353
- const b = document.createElement("button");
354
- b.type = "button";
355
- b.textContent = p.length > 46 ? p.slice(0, 44) + "…" : p;
356
- b.title = p;
357
- b.addEventListener("click", () => { document.getElementById("text").value = p; });
358
- examplesEl.appendChild(b);
359
- });
360
-
361
- // ---------- tabs ----------
362
- const tabHosted = document.getElementById("tab-hosted");
363
- const tabBrowser = document.getElementById("tab-browser");
364
- const paneHosted = document.getElementById("pane-hosted");
365
- const paneBrowser = document.getElementById("pane-browser");
366
- function setTab(which) {
367
- const isB = which === "browser";
368
- tabHosted.classList.toggle("active", !isB);
369
- tabBrowser.classList.toggle("active", isB);
370
- paneHosted.classList.toggle("active", !isB);
371
- paneBrowser.classList.toggle("active", isB);
372
- tabHosted.setAttribute("aria-selected", !isB);
373
- tabBrowser.setAttribute("aria-selected", isB);
374
- }
375
- tabHosted.addEventListener("click", () => setTab("hosted"));
376
- tabBrowser.addEventListener("click", () => setTab("browser"));
377
-
378
- // ---------- slider readouts ----------
379
- const bind = (id, valId, dec = 2) => {
380
- const el = document.getElementById(id);
381
- const v = document.getElementById(valId);
382
- const upd = () => { v.textContent = parseFloat(el.value).toFixed(dec); };
383
- el.addEventListener("input", upd); upd();
384
  };
385
- bind("speed", "speed-val", 2);
386
- bind("variation", "variation-val", 3);
387
- bind("pitch", "pitch-val", 2);
388
- document.getElementById("seed").addEventListener("input", (e) => {
389
- document.getElementById("seed-val").textContent = e.target.value || "0";
390
- });
391
-
392
- // ---------- responsive browser frame height ----------
393
- (function () {
394
- const frame = document.getElementById("webgpu-frame");
395
- const apply = (h) => {
396
- const n = Number(h);
397
- if (Number.isFinite(n) && n > 0) {
398
- const floor = frame.clientWidth < 1024 ? 980 : 560;
399
- frame.style.height = `${Math.max(floor, Math.min(2200, n + 2))}px`;
400
- }
401
- };
402
- const measure = () => {
403
- try {
404
- const doc = frame.contentDocument;
405
- apply(Math.max(doc?.body?.scrollHeight || 0, doc?.documentElement?.scrollHeight || 0));
406
- } catch (_) { /* cross-origin: rely on postMessage */ }
407
- };
408
- window.addEventListener("message", (ev) => {
409
- if (ev.source !== frame.contentWindow) return;
410
- if (ev.data?.type === "inflect-web-resize") apply(ev.data.height);
411
- });
412
- frame.addEventListener("load", measure);
413
- [100, 500, 1500].forEach((d) => window.setTimeout(measure, d));
414
- })();
415
-
416
- // ---------- gradio client ----------
417
- let client = null;
418
- const getClient = async () => {
419
- if (client) return client;
420
- client = await Client.connect(window.location.origin);
421
- return client;
422
- };
423
-
424
  const inputs = () => ({
425
- text: document.getElementById("text").value,
426
- speed: parseFloat(document.getElementById("speed").value),
427
- variation: parseFloat(document.getElementById("variation").value),
428
- pitch_steps: parseFloat(document.getElementById("pitch").value),
429
- seed: parseInt(document.getElementById("seed").value || "0", 10),
430
  });
431
-
432
  const activeModel = () => document.querySelector('input[name="model"]:checked').value;
433
 
434
- // ---------- speech text + chunk-split (mirrors app.py) ----------
435
- function splitText(text, limit = 280) {
436
- const normalized = text.split(/\s+/).filter(Boolean).join(" ");
437
- if (!normalized) return [];
438
- const sentenceRe = /[^.!?;:]+[.!?;:]*/g;
439
- const sentences = normalized.match(sentenceRe) || [normalized];
440
- const chunks = [];
441
- for (let s of sentences) {
442
- s = s.trim();
443
- if (!s) continue;
444
- while (s.length > limit) {
445
- let cut = -1;
446
- for (const m of [",", ";", ":"]) {
447
- const idx = s.slice(0, limit + 1).lastIndexOf(m);
448
- if (idx >= limit / 2) { cut = idx + 1; break; }
449
- }
450
- if (cut < limit / 2) {
451
- const sp = s.slice(0, limit + 1).lastIndexOf(" ");
452
- cut = sp > 0 ? sp : limit;
453
- }
454
- chunks.push(s.slice(0, cut).trim());
455
- s = s.slice(cut).trim();
456
- }
457
- if (s) chunks.push(s);
458
- }
459
- return chunks;
460
  }
461
- function boundaryPause(chunk) {
462
- const end = chunk.trim().slice(-1);
463
- const m = { "?": 0.28, "!": 0.24, ".": 0.22, ";": 0.16, ":": 0.13, ",": 0.09 };
464
- return m[end] ?? 0.08;
465
- }
466
- function chunkTimings(chunks, totalSec) {
467
- // Estimate proportional-to-length durations, insert boundary pauses,
468
- // then scale so the total matches the measured audio length.
469
- let lens = chunks.map((c) => c.length);
470
- const pauses = chunks.slice(0, -1).map((c, i) => boundaryPause(c));
471
- const lensAdj = lens.map((l, i) => l + (i < lens.length - 1 ? pauses[i] * 40 : 0));
472
- const sum = lensAdj.reduce((a, b) => a + b, 0) || 1;
473
- let t = 0;
474
- const out = chunks.map((c, i) => {
475
- const dur = (lensAdj[i] / sum) * totalSec;
476
- const start = t;
477
- t += dur;
478
- if (i < chunks.length - 1) t += pauses[i];
479
- return { text: c, start, dur };
480
- });
481
- return out;
482
  }
483
 
484
- // ---------- audio player ----------
485
- let currentAudio = null; // { url, audio, timings, chunks, text, sr, samples }
486
- let rafId = null;
487
-
488
- const playerEl = document.getElementById("player");
489
- const playBtn = document.getElementById("play");
490
- const nowEl = document.getElementById("now");
491
- const durEl = document.getElementById("dur");
492
- const seek = document.getElementById("seek");
493
- const markersEl = document.getElementById("markers");
494
- const karaoke = document.getElementById("karaoke");
495
- const dl = document.getElementById("download");
496
- const statusEl = document.getElementById("status");
497
- const queueEl = document.getElementById("queue");
498
- const genBtn = document.getElementById("generate");
499
- const cmpBtn = document.getElementById("compare-btn");
500
-
501
- let wavBufReference = null;
502
-
503
- function setBusy(busy, msg) {
504
- for (const b of [genBtn, cmpBtn]) b.disabled = busy;
505
- if (busy) {
506
- statusEl.classList.remove("empty");
507
- statusEl.innerHTML = `<span class="busy"></span>${msg || "Generating…"}`;
508
- queueEl.classList.remove("hidden");
509
- queueEl.textContent = "";
510
  }
 
511
  }
512
-
513
-
514
- function renderKaraoke(current) {
515
- karaoke.innerHTML = "";
516
- if (!current || !current.chunks.length) return;
517
- current.chunks.forEach((c, i) => {
518
- const span = document.createElement("span");
519
- span.className = "chunk";
520
- span.dataset.index = i;
521
- span.textContent = c + " ";
522
- karaoke.appendChild(span);
523
- });
524
- karaoke.classList.add("in");
525
- }
526
-
527
- function tickPlayhead(current) {
528
- const a = current.audio;
529
- const t = a.currentTime;
530
- // chunk highlight
531
- const spans = karaoke.querySelectorAll(".chunk");
532
- let activeIdx = -1;
533
- for (let i = 0; i < current.timings.length; i++) {
534
- const seg = current.timings[i];
535
- if (t >= seg.start && t < seg.start + seg.dur) { activeIdx = i; break; }
536
- if (t >= seg.start) activeIdx = i;
537
  }
538
- spans.forEach((sp, i) => {
539
- sp.classList.remove("speaking", "spoken");
540
- if (current.timings[i] && t >= current.timings[i].start + current.timings[i].dur) sp.classList.add("spoken");
541
- if (i === activeIdx) sp.classList.add("speaking");
542
- });
543
- const seg = activeIdx >= 0 ? current.timings[activeIdx] : null;
544
- if (seg && t <= seg.start + seg.dur + 0.18) {
545
- const el = spans[activeIdx];
546
- if (el) {
547
- const rel = Math.max(0, t - seg.start) / Math.max(0.001, seg.dur);
548
- el.style.background = `linear-gradient(90deg, rgba(36,134,255,.22) ${Math.min(100, rel * 100)}%, transparent ${Math.min(100, rel * 100)}%)`;
549
- const before = Array.from(spans).slice(0, activeIdx);
550
- before.forEach((s) => { if (!s.classList.contains("spoken")) s.classList.add("spoken"); });
551
- }
552
  }
553
- nowEl.textContent = fmtTime(t);
554
- seek.value = String(Math.round((t / (current.totalSec || 1)) * 1000));
555
- rafId = requestAnimationFrame(() => tickPlayhead(current));
556
  }
557
 
558
- function startPlayback(current) {
559
- if (!current || !current.audio) return;
560
- current.audio.play().then(() => {
561
- playBtn.textContent = "❚❚";
562
- cancelAnimationFrame(rafId);
563
- rafId = requestAnimationFrame(() => tickPlayhead(current));
564
- }).catch(() => {});
565
- }
566
- function pausePlayback(current) {
567
- if (!current || !current.audio) return;
568
- current.audio.pause();
569
- playBtn.textContent = "▶";
570
- cancelAnimationFrame(rafId);
571
  }
572
 
573
- function setupPlayer(current) {
574
- renderKaraoke(current);
575
- current.audio = new Audio(current.url);
576
- const a = current.audio;
577
- durEl.textContent = fmtTime(current.totalSec);
578
- nowEl.textContent = "0:00";
579
- seek.value = "0";
580
- a.addEventListener("loadedmetadata", () => { durEl.textContent = fmtTime(a.duration); });
581
- a.addEventListener("ended", () => {
582
- playBtn.textContent = "";
583
- cancelAnimationFrame(rafId);
584
- nowEl.textContent = fmtTime(current.totalSec);
585
- karaoke.querySelectorAll(".chunk").forEach((s) => s.classList.remove("speaking"));
586
- });
587
- a.addEventListener("timeupdate", () => { nowEl.textContent = fmtTime(a.currentTime); });
588
- // markers
589
- markersEl.innerHTML = "";
590
- let acc = 0;
591
- for (let i = 0; i < current.timings.length - 1; i++) {
592
- acc += current.timings[i].dur + (i < current.timings.length - 1 ? boundaryPause(current.chunks[i]) : 0);
593
- const m = document.createElement("div");
594
- m.className = "marker";
595
- m.style.left = `${Math.min(100, (acc / current.totalSec) * 100)}%`;
596
- markersEl.appendChild(m);
597
- }
598
  }
 
599
 
600
- playBtn.addEventListener("click", () => {
601
- if (!currentAudio) return;
602
- if (currentAudio.audio.paused) startPlayback(currentAudio);
603
- else pausePlayback(currentAudio);
604
- });
605
- seek.addEventListener("input", () => {
606
- if (!currentAudio) return;
607
- const t = (parseFloat(seek.value) / 1000) * currentAudio.totalSec;
608
- currentAudio.audio.currentTime = t;
609
- nowEl.textContent = fmtTime(t);
610
- });
611
-
612
- // ---------- generate ----------
613
- async function fetchArray(url) {
614
- const r = await fetch(url);
615
- return r.arrayBuffer();
616
  }
617
-
618
- function wavBlobUrl(buf) {
619
- return URL.createObjectURL(new Blob([buf], { type: "audio/wav" }));
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  }
621
-
622
- async function decodeWav(buf) {
623
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
624
- const decoded = await ctx.decodeAudioData(buf.slice(0));
625
- ctx.close();
626
- return decoded;
 
 
 
 
 
 
 
 
627
  }
628
 
629
- async function doGenerate() {
630
- setBusy(true, "Queued for ZeroGPU…");
631
- queueEl.textContent = "";
632
- playerEl.classList.add("hidden");
633
- try {
634
- const c = await getClient();
635
- const res = await c.predict("/synthesize", {
636
- ...inputs(),
637
- model_name: activeModel(),
638
- });
639
- const fileData = res.data[0];
640
- const statusText = res.data[1] || "Done.";
641
- const url = fileData.url || fileData.path;
642
- const buf = await fetchArray(url);
643
- const blobUrl = wavBlobUrl(buf);
644
- // decode for timing + karaoke alignment
645
- const decoded = await decodeWav(buf);
646
- const text = document.getElementById("text").value;
647
- const chunks = splitText(text);
648
- const timings = chunkTimings(chunks, decoded.duration);
649
- currentAudio = { url: blobUrl, totalSec: decoded.duration, chunks, timings };
650
- dl.href = blobUrl;
651
- setupPlayer(currentAudio);
652
- statusEl.classList.remove("empty");
653
- statusEl.innerHTML = statusText;
654
- queueEl.classList.add("hidden");
655
- playerEl.classList.remove("hidden");
656
  setBusy(false);
657
- // autoplay
658
- startPlayback(currentAudio);
659
- } catch (e) {
660
- statusEl.classList.remove("empty");
661
- statusEl.innerHTML = `<span style="color:#fca5a5">Error: ${String(e?.message || e)}</span>`;
662
- setBusy(false);
663
- }
664
  }
665
 
666
- // ---------- compare ----------
667
- let compareAudio = { micro: null, nano: null };
668
-
669
- async function doCompare() {
670
- setBusy(true, "Comparing models on ZeroGPU…");
671
- queueEl.textContent = "";
672
- for (const k of ["micro", "nano"]) {
673
- if (compareAudio[k]?.audio) { compareAudio[k].audio.pause(); }
674
- compareAudio[k] = null;
675
- }
676
- document.getElementById("micro-now").textContent = "0:00";
677
- document.getElementById("nano-now").textContent = "0:00";
678
- try {
679
- const c = await getClient();
680
- const res = await c.predict("/compare", { ...inputs() });
681
- const [microFd, nanoFd, statusText] = res.data;
682
- const cmpStatus = document.getElementById("compare-status");
683
- cmpStatus.classList.remove("empty");
684
- cmpStatus.innerHTML = statusText || "Done.";
685
- for (const [key, fd, durId, nowId, seekId, dlId] of [
686
- ["micro", microFd, "micro-dur", "micro-now", "micro-seek", "micro-dl"],
687
- ["nano", nanoFd, "nano-dur", "nano-now", "nano-seek", "nano-dl"],
688
- ]) {
689
- const url = fd.url || fd.path;
690
- const buf = await fetchArray(url);
691
- const blobUrl = wavBlobUrl(buf);
692
- const decoded = await decodeWav(buf);
693
- const audio = new Audio(blobUrl);
694
- document.getElementById(durId).textContent = fmtTime(decoded.duration);
695
- document.getElementById(seekId).value = "0";
696
- document.getElementById(dlId).href = blobUrl;
697
- audio.addEventListener("timeupdate", () => {
698
- document.getElementById(nowId).textContent = fmtTime(audio.currentTime);
699
- document.getElementById(seekId).value = String(Math.round((audio.currentTime / decoded.duration) * 1000));
700
- });
701
- audio.addEventListener("ended", () => {
702
- document.querySelector(`.mini-play[data-mini="${key}"]`).textContent = "▶";
703
- });
704
- const sk = document.getElementById(seekId);
705
- if (sk._inflectSeek) sk.removeEventListener("input", sk._inflectSeek);
706
- const onSeek = () => { audio.currentTime = (parseFloat(sk.value) / 1000) * decoded.duration; };
707
- sk._inflectSeek = onSeek;
708
- sk.addEventListener("input", onSeek);
709
- compareAudio[key] = { audio, totalSec: decoded.duration, url: blobUrl };
710
  }
 
711
  setBusy(false);
712
- } catch (e) {
713
- const cmpStatus = document.getElementById("compare-status");
714
- cmpStatus.classList.remove("empty");
715
- cmpStatus.innerHTML = `<span style="color:#fca5a5">Error: ${String(e?.message || e)}</span>`;
716
- setBusy(false);
717
- }
718
  }
 
 
 
 
719
 
720
- document.querySelectorAll(".mini-play").forEach((btn) => {
721
- btn.addEventListener("click", () => {
722
- const key = btn.dataset.mini;
723
- const item = compareAudio[key];
724
- if (!item || !item.audio) return;
725
- if (item.audio.paused) {
726
- // pause the other
727
- const other = key === "micro" ? "nano" : "micro";
728
- if (compareAudio[other]?.audio && !compareAudio[other].audio.paused) {
729
- compareAudio[other].audio.pause();
730
- document.querySelector(`.mini-play[data-mini="${other}"]`).textContent = "▶";
731
- }
732
- item.audio.play();
733
- btn.textContent = "❚❚";
734
- } else {
735
- item.audio.pause();
736
- btn.textContent = "▶";
737
- }
738
- });
739
- });
740
 
741
- genBtn.addEventListener("click", doGenerate);
742
- cmpBtn.addEventListener("click", doCompare);
743
- document.getElementById("text").addEventListener("keydown", (e) => {
744
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); doGenerate(); }
745
- });
 
746
  </script>
747
  </body>
748
  </html>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Inflect v2 · speech studio</title>
7
  <link rel="icon" href="/web_runtime/favicon.svg" type="image/svg+xml" />
8
  <style>
9
  :root{
10
  color-scheme:dark;
11
+ --bg:#0a0c0b;--surface:#111513;--surface-2:#0d100e;--rail:#080a09;
12
+ --ink:#f3f4ee;--muted:#8a938b;--dim:#5f6963;
13
+ --line:#232a26;--line-soft:#1a201c;
14
+ --signal:#2dd4bf;--signal-2:#22d3ee;--signal-deep:#0f766e;
15
+ --accent:#f59e0b;--warn:#f87171;--hot:#ef4444;
16
+ --glow:0 0 0 1px rgba(45,212,191,.35),0 0 22px rgba(45,212,191,.18);
17
  }
18
  *{box-sizing:border-box}
19
+ html,body{margin:0;padding:0;height:100%}
20
  body{
21
+ background:
22
+ radial-gradient(120% 80% at 50% -10%, rgba(45,212,191,.06), transparent 60%),
23
+ var(--bg);
24
+ color:var(--ink);
25
  font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
26
  line-height:1.5;-webkit-font-smoothing:antialiased;
27
+ min-height:100vh;display:flex;flex-direction:column;
28
  }
29
+ .mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
30
+
31
+ /* ---- Top bar ---- */
32
+ .topbar{
33
+ display:flex;align-items:center;gap:20px;padding:13px clamp(16px,3vw,40px);
34
+ border-bottom:1px solid var(--line);background:rgba(8,10,9,.85);backdrop-filter:blur(10px);
35
+ position:sticky;top:0;z-index:20;
36
+ }
37
+ .brand{display:flex;align-items:center;gap:11px;min-width:0}
38
+ .logo{
39
+ width:34px;height:34px;border-radius:9px;flex-shrink:0;
40
+ background:radial-gradient(120% 120% at 30% 20%,#34d3c4,#0f766e 70%);
41
+ display:flex;align-items:center;justify-content:center;box-shadow:0 0 18px rgba(45,212,191,.25);
42
+ }
43
+ .logo svg{width:18px;height:18px}
44
+ .brand .name{font-weight:780;font-size:15px;letter-spacing:-.01em;line-height:1.05}
45
+ .brand .name small{display:block;font-weight:560;font-size:10.5px;color:var(--muted);letter-spacing:.02em;margin-top:1px}
46
+ .meter-cluster{display:flex;align-items:center;gap:14px;margin-left:auto}
47
+ .meter{display:flex;flex-direction:column;gap:3px;min-width:0}
48
+ .meter .lab{font:700 9px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
49
+ .meter .val{font:650 12px/1 var(--mono);color:var(--ink)}
50
+ .meter .val.accent{color:var(--signal)}
51
+ .led{width:8px;height:8px;border-radius:50%;background:var(--dim);box-shadow:0 0 0 0 rgba(45,212,191,0);transition:background .2s}
52
+ .led.on{background:var(--signal);box-shadow:0 0 10px rgba(45,212,191,.7);animation:pulse 1.4s ease-in-out infinite}
53
+ .led.busy{background:var(--accent);box-shadow:0 0 10px rgba(245,158,11,.7);animation:pulse .8s ease-in-out infinite}
54
+ @keyframes pulse{50%{opacity:.45}}
55
+ .topnav{display:flex;align-items:center;gap:6px}
56
+ .tab{
57
+ appearance:none;border:0;background:transparent;color:var(--muted);cursor:pointer;
58
+ font:inherit;font-size:12.5px;font-weight:680;letter-spacing:.01em;
59
+ padding:8px 14px;border-radius:8px;transition:color .15s,background .15s;
60
+ }
61
+ .tab:hover{color:var(--ink);background:var(--surface)}
62
+ .tab.active{color:var(--ink);background:var(--surface);box-shadow:inset 0 0 0 1px var(--line)}
63
+ .tab small{display:block;font-size:9.5px;font-weight:560;color:var(--dim);margin-top:1px;letter-spacing:.02em}
64
+ .tab.active small{color:var(--signal)}
65
+ .extlinks{display:flex;gap:6px}
66
+ .extlinks a{color:var(--muted);font-size:12px;font-weight:620;text-decoration:none;padding:7px 10px;border-radius:7px;transition:color .15s,background .15s}
67
+ .extlinks a:hover{color:var(--ink);background:var(--surface)}
68
+
69
+ /* ---- Shell ---- */
70
+ .shell{flex:1;display:grid;grid-template-columns:minmax(0,1fr);gap:0;min-height:0}
71
+ .pane{display:none;min-height:0}
72
+ .pane.active{display:contents}
73
+
74
+ /* ---- Hosted layout ---- */
75
+ .studio{flex:1;display:grid;grid-template-columns:300px minmax(0,1fr);gap:0;min-height:0}
76
+ .rack{
77
+ border-right:1px solid var(--line);background:var(--rail);
78
+ padding:18px 16px 22px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;
79
  }
80
+ .stage{display:flex;flex-direction:column;min-height:0;overflow:hidden}
81
+
82
+ .module{border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden}
83
+ .module>.head{display:flex;align-items:center;justify-content:space-between;padding:9px 13px;border-bottom:1px solid var(--line-soft);background:linear-gradient(180deg,rgba(255,255,255,.02),transparent)}
84
+ .module>.head .t{font:700 9.5px/1 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
85
+ .module>.head .v{font:600 11px/1 var(--mono);color:var(--signal)}
86
+ .module>.body{padding:13px}
87
+
88
+ /* inputs */
89
+ .field>label{display:block;font-size:11px;font-weight:640;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:7px}
 
 
 
 
 
 
 
 
 
 
90
  textarea,input[type="text"],input[type="number"],select{
91
+ width:100%;background:var(--surface-2);color:var(--ink);border:1px solid var(--line);
92
+ border-radius:9px;padding:11px 12px;font-size:13.5px;font-family:inherit;outline:none;transition:border-color .15s,box-shadow .15s
93
+ }
94
+ textarea:focus,input:focus,select:focus{border-color:var(--signal);box-shadow:0 0 0 3px rgba(45,212,191,.13)}
95
+ textarea{min-height:96px;resize:vertical;font-size:14px;line-height:1.6}
96
+ .examples{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
97
+ .examples button{background:var(--surface-2);border:1px solid var(--line-soft);border-radius:7px;color:var(--muted);font-size:10.5px;padding:5px 8px;cursor:pointer;transition:color .15s,border-color .15s,background .15s}
98
+ .examples button:hover{color:var(--ink);border-color:var(--signal);background:rgba(45,212,191,.06)}
99
+
100
+ /* model switch */
101
+ .seg{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
102
+ .seg label{
103
+ display:flex;flex-direction:column;gap:3px;padding:9px 10px;border:1px solid var(--line);border-radius:9px;
104
+ background:var(--surface-2);cursor:pointer;transition:border-color .15s,background .15s;position:relative
105
  }
 
 
 
 
 
 
 
 
106
  .seg input{position:absolute;opacity:0;width:0;height:0}
107
+ .seg label strong{font-size:12px;font-weight:760}
108
+ .seg label small{font-size:10px;color:var(--muted)}
109
+ .seg label:has(input:checked){border-color:var(--signal);background:rgba(45,212,191,.08)}
110
+ .seg label:has(input:checked) strong{color:var(--signal)}
111
+ .seg label:has(input:checked)::after{content:"";position:absolute;top:9px;right:9px;width:7px;height:7px;border-radius:50%;background:var(--signal);box-shadow:0 0 8px rgba(45,212,191,.8)}
112
+
113
+ /* sliders */
114
+ .slider{display:flex;flex-direction:column;gap:7px;margin-bottom:14px}
115
+ .slider:last-child{margin-bottom:0}
116
+ .slider .top{display:flex;justify-content:space-between;align-items:baseline}
117
+ .slider .top label{font-size:11px;font-weight:640;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
118
+ .slider .top .val{font:650 12px/1 var(--mono);color:var(--signal)}
 
119
  input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:4px;background:var(--line-soft);border-radius:4px;outline:none}
120
+ input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:15px;height:15px;border-radius:50%;background:var(--signal);cursor:pointer;border:2px solid var(--bg);box-shadow:0 0 8px rgba(45,212,191,.6);transition:transform .1s}
121
+ input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.15)}
122
+ input[type="range"]::-moz-range-thumb{width:13px;height:13px;border:2px solid var(--bg);border-radius:50%;background:var(--signal);cursor:pointer}
123
+ .slider small{color:var(--dim);font-size:10.5px;line-height:1.4}
124
 
125
+ details.adv{border:1px solid var(--line-soft);border-radius:9px;background:var(--surface-2)}
126
+ details.adv summary{padding:10px 12px;cursor:pointer;font-size:11px;font-weight:640;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);list-style:none;display:flex;justify-content:space-between;align-items:center}
127
  details.adv summary::-webkit-details-marker{display:none}
128
+ details.adv summary .chev{transition:transform .2s;color:var(--dim)}
129
+ details.adv[open] summary .chev{transform:rotate(180deg)}
130
+ details.adv .body{padding:2px 12px 13px}
131
+
132
+ /* transport */
133
+ .transport{display:flex;flex-direction:column;gap:9px}
134
+ .btn{
135
+ display:flex;align-items:center;justify-content:center;gap:9px;width:100%;min-height:44px;padding:0 16px;
136
+ border-radius:9px;border:1px solid var(--line);background:var(--surface-2);color:var(--ink);
137
+ font:inherit;font-size:13px;font-weight:700;letter-spacing:.01em;cursor:pointer;transition:background .15s,border-color .15s,transform .05s
138
+ }
139
+ .btn:hover{background:var(--surface);border-color:#3a4540}
140
+ .btn:active{transform:translateY(1px)}
141
+ .btn:disabled{opacity:.4;cursor:not-allowed}
142
+ .btn.primary{border:0;background:linear-gradient(180deg,#34d3c4,#14b8a6);color:#06231f;font-weight:820;box-shadow:var(--glow)}
143
+ .btn.primary:hover{background:linear-gradient(180deg,#3ee0d0,#16c4b0)}
144
+ .btn.ghost{background:transparent}
145
+ .kbd{font:600 9.5px/1 var(--mono);padding:2px 5px;border:1px solid var(--line);border-radius:4px;color:var(--dim);margin-left:auto}
146
+
147
+ /* ---- Stage (transport + waveform) ---- */
148
+ .stage-head{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line);background:var(--rail)}
149
+ .now-playing{display:flex;align-items:center;gap:11px;min-width:0;flex:1}
150
+ .np-badge{width:38px;height:38px;border-radius:9px;flex-shrink:0;background:var(--surface-2);border:1px solid var(--line);display:flex;align-items:center;justify-content:center;color:var(--signal)}
151
+ .np-badge.playing{border-color:var(--signal);background:rgba(45,212,191,.1)}
152
+ .np-text{min-width:0}
153
+ .np-text .title{font-size:13px;font-weight:720;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
154
+ .np-text .sub{font:600 10.5px/1.3 var(--mono);color:var(--muted);letter-spacing:.02em}
155
+ .transport-controls{display:flex;align-items:center;gap:9px}
156
+ .tbtn{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,border-color .15s,transform .05s}
157
+ .tbtn:hover{border-color:#3a4540;background:var(--surface-2)}
158
+ .tbtn:active{transform:scale(.95)}
159
+ .tbtn:disabled{opacity:.4;cursor:not-allowed}
160
+ .tbtn.play{width:48px;height:48px;border:0;background:linear-gradient(180deg,#34d3c4,#14b8a6);color:#06231f;box-shadow:var(--glow)}
161
+ .tbtn.play:hover{background:linear-gradient(180deg,#3ee0d0,#16c4b0)}
162
+ .tbtn.play:disabled{background:var(--line);color:var(--dim);box-shadow:none}
163
+
164
+ /* waveform / timeline */
165
+ .scrub-wrap{padding:16px 18px 6px;background:var(--rail)}
166
+ .scrub-row{display:flex;align-items:center;gap:14px}
167
+ .timecode{font:600 12px/1 var(--mono);color:var(--muted);min-width:44px;text-align:center}
168
+ .timecode .now{color:var(--signal)}
169
+ .wave{position:relative;flex:1;min-width:0;height:74px;background:var(--surface-2);border:1px solid var(--line-soft);border-radius:10px;overflow:hidden;cursor:pointer}
170
+ .wave canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
171
+ .wave .playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--signal);box-shadow:0 0 10px rgba(45,212,191,.8);pointer-events:none;transform:translateX(-1px)}
172
+ .wave .markers{position:absolute;inset:0;pointer-events:none}
173
+ .wave .markers .m{position:absolute;top:0;bottom:0;width:1px;background:rgba(255,255,255,.10)}
174
+ .wave .region{position:absolute;top:0;bottom:0;background:rgba(45,212,191,.08);pointer-events:none}
175
+ .scrub-range{margin-top:10px}
176
+ .scrub-range input{width:100%}
177
+ .meters{display:flex;gap:4px;align-items:flex-end;height:34px;padding:0 2px}
178
+ .meters .bar{flex:1;background:var(--line-soft);border-radius:2px;min-height:3px;transition:height .06s linear,background .1s}
179
+ .meters .bar.lev{background:linear-gradient(180deg,var(--signal),var(--signal-deep))}
180
+ .meters .bar.peak{background:var(--accent)}
181
+
182
+ /* status strip */
183
+ .status-strip{display:flex;align-items:center;gap:12px;padding:11px 18px;border-top:1px solid var(--line);background:var(--surface);font-size:12px}
184
+ .status-strip .dot{width:8px;height:8px;border-radius:50%;background:var(--dim);flex-shrink:0}
185
+ .status-strip.ok .dot{background:var(--signal);box-shadow:0 0 8px rgba(45,212,191,.7)}
186
+ .status-strip.err .dot{background:var(--warn);box-shadow:0 0 8px rgba(248,113,113,.7)}
187
+ .status-strip.busy .dot{background:var(--accent);animation:pulse 1s infinite}
188
+ .status-strip code{font-family:var(--mono)}
189
+ .status-strip .sp{flex:1;min-width:0;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
190
+ .status-strip .sp *{color:inherit}
191
+ .queue{font:600 10.5px/1 var(--mono);color:var(--signal);white-space:nowrap}
192
+
193
+ /* karaoke / script */
194
+ .script-wrap{flex:1;min-height:0;overflow-y:auto;padding:18px 22px 24px;background:linear-gradient(180deg,var(--surface),var(--surface-2))}
195
+ .script-empty{color:var(--dim);font-size:13px;font-style:italic;padding:8px 0}
196
+ .karaoke{font-size:18px;line-height:1.85;letter-spacing:.003em;max-width:760px}
197
+ .karaoke .chunk{display:inline;color:var(--dim);transition:color .25s ease,background-color .18s ease;box-decoration-break:clone;-webkit-box-decoration-break:clone;border-radius:4px;padding:1px 1px}
198
+ .karaoke .chunk.spoken{color:#6c766d}
199
  .karaoke .chunk.speaking{color:var(--ink)}
200
+ .karaoke .chunk.speaking::after{content:"▍";color:var(--signal);margin-left:1px;animation:blink 1.05s steps(2) infinite;font-weight:400}
201
+ @keyframes blink{50%{opacity:.25}}
202
+
203
+ /* download row */
204
+ .tools-row{display:flex;align-items:center;gap:10px;padding:12px 18px;border-top:1px solid var(--line);background:var(--rail);flex-wrap:wrap}
205
+ .tools-row .lbl{font:600 10px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--dim)}
206
+ a.dl{display:inline-flex;align-items:center;gap:7px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink);font-size:12px;font-weight:660;text-decoration:none;transition:border-color .15s,background .15s}
207
+ a.dl:hover{border-color:var(--signal);background:var(--surface-2)}
208
+ a.dl:disabled{opacity:.4;pointer-events:none}
209
+ .spec-chip{font:600 10.5px/1 var(--mono);color:var(--muted);padding:6px 9px;border:1px solid var(--line-soft);border-radius:7px;background:var(--surface-2)}
210
+
211
+ /* compare */
212
+ .compare-pane{flex:1;display:flex;flex-direction:column;min-height:0;overflow-y:auto;padding:18px clamp(16px,3vw,40px) 40px}
213
+ .compare-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
214
+ .track{border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden;display:flex;flex-direction:column}
215
+ .track .thead{display:flex;align-items:center;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line-soft);background:var(--surface-2)}
216
+ .track .thead .dot{width:9px;height:9px;border-radius:50%}
217
+ .track .thead .nm{font-size:12.5px;font-weight:720;flex:1;min-width:0}
218
+ .track .thead .nm small{display:block;font:600 10px/1.3 var(--mono);color:var(--muted)}
219
+ .track .tbody{padding:13px 14px 14px;display:flex;flex-direction:column;gap:11px}
220
+ .mini-wave{position:relative;height:54px;background:var(--surface-2);border:1px solid var(--line-soft);border-radius:8px;overflow:hidden;cursor:pointer}
221
+ .mini-wave canvas{position:absolute;inset:0;width:100%;height:100%}
222
+ .mini-wave .ph{position:absolute;top:0;bottom:0;width:2px;background:var(--signal);box-shadow:0 0 8px rgba(45,212,191,.7)}
223
+ .mini-ctrl{display:flex;align-items:center;gap:10px}
224
+ .mini-play{width:34px;height:34px;border-radius:50%;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,border-color .15s}
225
+ .mini-play:hover{border-color:var(--signal);color:var(--signal)}
226
  .mini-play:disabled{opacity:.4;cursor:not-allowed}
227
+ .mini-tc{font:600 10.5px/1 var(--mono);color:var(--muted);white-space:nowrap}
228
+ .mini-tc .now{color:var(--signal)}
229
+ .mini-seek{flex:1;min-width:0}
230
+ .mini-dl{font-size:11px;padding:6px 9px;border-radius:7px}
231
+
232
+ /* browser pane */
233
+ .browser-pane{flex:1;display:flex;flex-direction:column;min-height:0}
234
+ .browser-head{padding:14px 18px;border-bottom:1px solid var(--line);background:var(--rail)}
235
+ .browser-head h2{margin:0 0 4px;font-size:14px;font-weight:720}
236
+ .browser-head p{margin:0;color:var(--muted);font-size:12px;line-height:1.5}
237
+ .browser-frame-shell{flex:1;min-height:0;margin:16px 18px;border:1px solid var(--line);border-radius:12px;background:var(--paper);overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,.4)}
238
  .browser-frame{display:block;width:100%;height:580px;border:0;background:var(--paper)}
239
+ .browser-help{margin:0 18px 24px;color:var(--muted);font-size:11.5px;line-height:1.55}
 
 
 
240
 
241
+ .fineprint{color:var(--dim);font-size:11px;line-height:1.6;padding:14px 18px;border-top:1px solid var(--line);background:var(--rail)}
242
+ .busy-spin{display:inline-block;width:12px;height:12px;border:2px solid var(--signal);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite;vertical-align:-1px}
243
+ @keyframes spin{to{transform:rotate(360deg)}}
244
  .hidden{display:none!important}
245
+ .fade{opacity:0;transition:opacity .35s ease}
246
+ .fade.in{opacity:1}
247
 
248
+ @media(max-width:880px){
249
+ .studio{grid-template-columns:1fr}
250
+ .rack{border-right:0;border-bottom:1px solid var(--line);max-height:none}
251
+ .meter-cluster{display:none}
 
252
  .compare-grid{grid-template-columns:1fr}
253
  .browser-frame{height:980px}
254
+ }
255
+ @media(max-width:560px){
256
+ .topnav{display:none}
257
+ .stage-head{flex-wrap:wrap}
258
+ .scrub-row{flex-wrap:wrap}
259
+ .wave{height:58px}
260
  }
261
  </style>
262
  </head>
263
  <body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
+ <header class="topbar">
266
+ <div class="brand">
267
+ <div class="logo" aria-hidden="true">
268
+ <svg viewBox="0 0 24 24" fill="none"><path d="M9 18V6l10-2v12" stroke="#06231f" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="18" r="2.4" fill="#06231f"/><circle cx="17" cy="16" r="2.4" fill="#06231f"/></svg>
269
+ </div>
270
+ <div class="name">Inflect v2<small>speech studio</small></div>
271
+ </div>
272
+ <div class="meter-cluster">
273
+ <div class="meter"><span class="lab">SR</span><span class="val accent" id="m-sr">24 kHz</span></div>
274
+ <div class="meter"><span class="lab">RTF</span><span class="val" id="m-rtf">—</span></div>
275
+ <div class="meter"><span class="lab">Chars</span><span class="val" id="m-chars">0</span></div>
276
+ <div class="led" id="status-led" title="Idle"></div>
277
+ </div>
278
+ <nav class="topnav" role="tablist">
279
+ <button class="tab active" id="tab-hosted" role="tab">Hosted GPU<small>ZeroGPU</small></button>
280
+ <button class="tab" id="tab-browser" role="tab">This browser<small>WebGPU / WASM</small></button>
281
  </nav>
282
+ <div class="extlinks">
283
+ <a href="https://huggingface.co/owensong/Inflect-Micro-v2" target="_blank" rel="noopener" title="Micro model">Micro</a>
284
+ <a href="https://huggingface.co/owensong/Inflect-Nano-v2" target="_blank" rel="noopener" title="Nano model">Nano</a>
285
+ <a href="https://github.com/owenawsong/Inflect" target="_blank" rel="noopener" title="GitHub">GitHub</a>
286
+ </div>
287
+ </header>
288
+
289
+ <div class="shell">
290
  <!-- HOSTED -->
291
+ <section id="pane-hosted" class="pane active">
292
+ <div class="studio">
293
+ <!-- control rack -->
294
+ <aside class="rack">
295
+ <div class="module">
296
+ <div class="head"><span class="t">Script</span></div>
297
+ <div class="body">
298
+ <div class="field">
299
+ <label for="text">Input text</label>
300
+ <textarea id="text" placeholder="Write a sentence, paragraph, or script…">Wait, are you actually being for real? I thought the train left twenty minutes ago!</textarea>
301
+ <div class="examples" id="examples"></div>
302
+ </div>
303
+ </div>
304
  </div>
305
 
306
+ <div class="module">
307
+ <div class="head"><span class="t">Voice Model</span></div>
308
+ <div class="body">
309
+ <div class="seg" id="model-seg">
310
+ <label><input type="radio" name="model" value="Inflect Micro v2" checked><strong>Micro v2</strong><small>9.36M · quality</small></label>
311
+ <label><input type="radio" name="model" value="Inflect Nano v2"><strong>Nano v2</strong><small>3.96M · footprint</small></label>
312
+ </div>
 
 
 
 
313
  </div>
314
  </div>
315
 
316
+ <div class="module">
317
+ <div class="head"><span class="t">Delivery</span><span class="v" id="speed-val">1.00×</span></div>
318
+ <div class="body">
319
+ <div class="slider">
320
+ <div class="top"><label for="speed">Speaking speed</label><span class="val mono" id="speed-num">1.00</span></div>
321
+ <input type="range" id="speed" min="0.7" max="1.35" step="0.01" value="1.0">
322
+ <small>1.00 is the trained default.</small>
323
+ </div>
324
+ <div class="slider">
325
+ <div class="top"><label for="variation">Delivery variation</label><span class="val mono" id="variation-num">0.667</span></div>
326
+ <input type="range" id="variation" min="0" max="1" step="0.001" value="0.667">
327
+ <small>Lower is steadier; higher gives each take more variation.</small>
328
+ </div>
329
+ <details class="adv">
330
+ <summary><span>Advanced</span><span class="chev">▾</span></summary>
331
+ <div class="body">
332
+ <div class="slider">
333
+ <div class="top"><label for="pitch">Pitch shift</label><span class="val mono" id="pitch-num">0.00 st</span></div>
334
+ <input type="range" id="pitch" min="-2" max="2" step="0.25" value="0">
335
+ <small>Changes voice pitch without changing speaking speed.</small>
336
+ </div>
337
+ <div class="slider">
338
+ <div class="top"><label for="seed">Repeatable seed</label><span class="val mono" id="seed-num">0</span></div>
339
+ <input type="number" id="seed" value="0" step="1" inputmode="numeric">
340
+ <small>Same seed and settings reproduce a take.</small>
341
+ </div>
342
+ </div>
343
+ </details>
344
  </div>
345
  </div>
346
 
347
+ <div class="module">
348
+ <div class="head"><span class="t">Transport</span></div>
349
  <div class="body">
350
+ <div class="transport">
351
+ <button class="btn primary" id="generate">▶ Generate speech<span class="kbd">⌘↵</span></button>
352
+ <button class="btn" id="compare-btn"> Compare Micro &amp; Nano</button>
353
+ </div>
354
+ </div>
355
+ </div>
356
+ </aside>
357
+
358
+ <!-- stage -->
359
+ <main class="stage">
360
+ <div class="stage-head">
361
+ <div class="now-playing">
362
+ <div class="np-badge" id="np-badge">
363
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M9 18V6l10-2v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="18" r="2.2" fill="currentColor"/><circle cx="17" cy="16" r="2.2" fill="currentColor"/></svg>
364
+ </div>
365
+ <div class="np-text">
366
+ <div class="title" id="np-title">No take loaded</div>
367
+ <div class="sub" id="np-sub">Generate speech to load the player</div>
368
  </div>
369
  </div>
370
+ <div class="transport-controls">
371
+ <button class="tbtn" id="restart" title="Restart" disabled>⏮</button>
372
+ <button class="tbtn play" id="play" title="Play" disabled>▶</button>
373
+ <button class="tbtn" id="stop" title="Stop" disabled>⏹</button>
374
+ </div>
375
+ </div>
376
 
377
+ <div class="scrub-wrap">
378
+ <div class="scrub-row">
379
+ <span class="timecode"><span class="now" id="now">0:00</span></span>
380
+ <div class="wave" id="wave">
381
+ <canvas id="wave-canvas"></canvas>
382
+ <div class="markers" id="markers"></div>
383
+ <div class="region" id="region" style="display:none"></div>
384
+ <div class="playhead" id="playhead" style="left:0%"></div>
385
+ </div>
386
+ <span class="timecode" id="dur">0:00</span>
387
+ </div>
388
+ <div class="scrub-range">
389
+ <input type="range" id="seek" min="0" max="1000" value="0" aria-label="Seek" disabled>
390
+ </div>
391
+ <div class="meters" id="meters" aria-hidden="true"></div>
392
  </div>
393
 
394
+ <div class="status-strip" id="status-strip">
395
+ <span class="dot"></span>
396
+ <span class="sp" id="status">Ready.</span>
397
+ <span class="queue hidden" id="queue"></span>
398
+ </div>
399
 
400
+ <div class="script-wrap">
401
+ <div class="karaoke fade" id="karaoke"><div class="script-empty">Your input text will appear here, lighting up as it is spoken.</div></div>
 
 
 
 
 
 
402
  </div>
 
 
 
 
 
 
403
 
404
+ <div class="tools-row">
405
+ <span class="lbl">Take</span>
406
+ <a class="dl" id="download" download="inflect_v2.wav" role="button">⬇ Download WAV</a>
407
+ <span class="spec-chip" id="take-spec"></span>
408
+ <span class="lbl" style="margin-left:auto">Mode</span>
409
+ <span class="spec-chip">ZeroGPU · server</span>
 
 
 
 
 
410
  </div>
411
+
412
+ <!-- Compare -->
413
+ <div class="compare-pane" id="compare-pane">
414
+ <div class="compare-grid">
415
+ <div class="track">
416
+ <div class="thead"><span class="dot" style="background:var(--signal)"></span><span class="nm">Micro v2<small>9.36M · quality first</small></span><a class="dl mini-dl" id="micro-dl" download="inflect_micro.wav"></a></div>
417
+ <div class="tbody">
418
+ <div class="mini-wave" id="micro-wave"><canvas id="micro-canvas"></canvas><div class="ph" id="micro-ph" style="left:0%"></div></div>
419
+ <div class="mini-ctrl">
420
+ <button class="mini-play" data-mini="micro" disabled>▶</button>
421
+ <span class="mini-tc"><span class="now" id="micro-now">0:00</span> / <span id="micro-dur">0:00</span></span>
422
+ <input type="range" id="micro-seek" class="mini-seek" min="0" max="1000" value="0" disabled aria-label="Micro seek">
423
+ </div>
424
+ </div>
425
+ </div>
426
+ <div class="track">
427
+ <div class="thead"><span class="dot" style="background:var(--accent)"></span><span class="nm">Nano v2<small>3.96M · footprint first</small></span><a class="dl mini-dl" id="nano-dl" download="inflect_nano.wav">⬇</a></div>
428
+ <div class="tbody">
429
+ <div class="mini-wave" id="nano-wave"><canvas id="nano-canvas"></canvas><div class="ph" id="nano-ph" style="left:0%"></div></div>
430
+ <div class="mini-ctrl">
431
+ <button class="mini-play" data-mini="nano" disabled>▶</button>
432
+ <span class="mini-tc"><span class="now" id="nano-now">0:00</span> / <span id="nano-dur">0:00</span></span>
433
+ <input type="range" id="nano-seek" class="mini-seek" min="0" max="1000" value="0" disabled aria-label="Nano seek">
434
+ </div>
435
+ </div>
436
+ </div>
437
  </div>
438
  </div>
 
 
 
439
 
440
+ <p class="fineprint">Fixed English voice · English-only frontend · no voice cloning · no reference audio upload. Uncommon names, abbreviations, and long passages can still fail.</p>
441
+ </main>
442
+ </div>
443
  </section>
444
 
445
  <!-- BROWSER -->
446
+ <section id="pane-browser" class="pane">
447
+ <div class="browser-pane">
448
+ <div class="browser-head">
449
+ <h2>Browser-local inference</h2>
450
+ <p>Your text and generated audio stay in this tab. WebGPU is preferred; compatible browsers fall back to WASM. No queue, no server quota.</p>
451
+ </div>
452
+ <div class="browser-frame-shell">
453
+ <iframe class="browser-frame" id="webgpu-frame" src="/web_runtime/index.html?embed=1" title="Inflect browser-local WebGPU runtime" allow="autoplay; webgpu" loading="eager"></iframe>
454
+ </div>
455
+ <p class="browser-help">The first browser-local run downloads the selected ONNX model and compiles it on your device. Streaming and complete-audio modes are both available inside this panel.</p>
 
 
 
456
  </div>
 
457
  </section>
 
458
  </div>
459
 
460
  <script type="module">
461
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
462
 
463
+ const $ = (id) => document.getElementById(id);
464
+ const fmtTime = (s) => { if (!Number.isFinite(s) || s < 0) s = 0; const m = Math.floor(s/60); return `${m}:${Math.floor(s%60).toString().padStart(2,"0")}`; };
 
 
 
 
465
 
466
+ /* ---- prompts ---- */
467
  const PROMPTS = [
468
  "Wait, are you actually being for real? I thought the train left twenty minutes ago!",
469
  "Does punctuation work? Yes: commas, semicolons, and periods should create sensible boundaries.",
470
  "Professor Nguyen mailed the fluorescent poster to Reykjavik on Thursday.",
471
  "Although the weather changed without warning, the musicians finished packing before anyone opened the enormous glass door.",
472
  ];
473
+ const exEl = $("examples");
474
+ PROMPTS.forEach((p) => { const b=document.createElement("button"); b.type="button"; b.textContent=p.length>44?p.slice(0,42)+"…":p; b.title=p; b.addEventListener("click",()=>{ $("text").value=p; updateChars(); }); exEl.appendChild(b); });
475
+
476
+ /* ---- tabs ---- */
477
+ const setTab = (which) => {
478
+ const b = which === "browser";
479
+ $("tab-hosted").classList.toggle("active",!b); $("tab-browser").classList.toggle("active",b);
480
+ $("pane-hosted").classList.toggle("active",!b); $("pane-browser").classList.toggle("active",b);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  };
482
+ $("tab-hosted").addEventListener("click",()=>setTab("hosted"));
483
+ $("tab-browser").addEventListener("click",()=>setTab("browser"));
484
+
485
+ /* ---- slider readouts ---- */
486
+ const bindNum = (id, valId, dec, suffix="") => { const el=$(id), v=$(valId); const upd=()=>{ v.textContent=parseFloat(el.value).toFixed(dec)+suffix; }; el.addEventListener("input",upd); upd(); };
487
+ bindNum("speed","speed-num",2);
488
+ bindNum("variation","variation-num",3);
489
+ bindNum("pitch","pitch-num",2," st");
490
+ $("speed").addEventListener("input",()=>{ $("speed-val").textContent=parseFloat($("speed").value).toFixed(2)+"×"; });
491
+ $("speed-val").textContent="1.00×";
492
+ $("seed").addEventListener("input",(e)=>{ $("seed-num").textContent=e.target.value||"0"; });
493
+
494
+ /* ---- chars meter ---- */
495
+ function updateChars(){ $("m-chars").textContent=String($("text").value.length); }
496
+ $("text").addEventListener("input",updateChars); updateChars();
497
+
498
+ /* ---- responsive browser frame height ---- */
499
+ (function(){ const frame=$("webgpu-frame"); const apply=(h)=>{ const n=Number(h); if(Number.isFinite(n)&&n>0){ const floor=frame.clientWidth<1024?980:560; frame.style.height=`${Math.max(floor,Math.min(2200,n+2))}px`; } }; const measure=()=>{ try{ const d=frame.contentDocument; apply(Math.max(d?.body?.scrollHeight||0,d?.documentElement?.scrollHeight||0)); }catch(_){} }; window.addEventListener("message",(ev)=>{ if(ev.source!==frame.contentWindow) return; if(ev.data?.type==="inflect-web-resize") apply(ev.data.height); }); frame.addEventListener("load",measure); [100,500,1500].forEach((d)=>setTimeout(measure,d)); })();
500
+
501
+ /* ---- gradio client ---- */
502
+ let client=null;
503
+ const getClient = async () => { if(client) return client; client=await Client.connect(window.location.origin); return client; };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  const inputs = () => ({
505
+ text: $("text").value,
506
+ speed: parseFloat($("speed").value),
507
+ variation: parseFloat($("variation").value),
508
+ pitch_steps: parseFloat($("pitch").value),
509
+ seed: parseInt($("seed").value||"0",10),
510
  });
 
511
  const activeModel = () => document.querySelector('input[name="model"]:checked').value;
512
 
513
+ /* ---- text chunking (mirrors app.py) ---- */
514
+ function splitText(text, limit=280){
515
+ const normalized=text.split(/\s+/).filter(Boolean).join(" "); if(!normalized) return [];
516
+ const sentences=normalized.match(/[^.!?;:]+[.!?;:]*/g)||[normalized]; const chunks=[];
517
+ for(let s of sentences){ s=s.trim(); if(!s) continue;
518
+ while(s.length>limit){ let cut=-1; for(const m of [",",";",":"]){ const idx=s.slice(0,limit+1).lastIndexOf(m); if(idx>=limit/2){ cut=idx+1; break; } }
519
+ if(cut<limit/2){ const sp=s.slice(0,limit+1).lastIndexOf(" "); cut=sp>0?sp:limit; }
520
+ chunks.push(s.slice(0,cut).trim()); s=s.slice(cut).trim(); }
521
+ if(s) chunks.push(s);
522
+ } return chunks;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  }
524
+ function boundaryPause(chunk){ const end=chunk.trim().slice(-1); return ({ "?":0.28,"!":0.24,".":0.22,";":0.16,":":0.13,",":0.09 })[end] ?? 0.08; }
525
+ function chunkTimings(chunks,totalSec){
526
+ const lens=chunks.map(c=>c.length); const pauses=chunks.slice(0,-1).map(c=>boundaryPause(c));
527
+ const lensAdj=lens.map((l,i)=>l+(i<lens.length-1?pauses[i]*40:0));
528
+ const sum=lensAdj.reduce((a,b)=>a+b,0)||1; let t=0;
529
+ return chunks.map((c,i)=>{ const dur=(lensAdj[i]/sum)*totalSec; const start=t; t+=dur; if(i<chunks.length-1) t+=pauses[i]; return {text:c,start,dur}; });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  }
531
 
532
+ /* ---- audio decode / waveform ---- */
533
+ async function fetchArray(url){ const r=await fetch(url); if(!r.ok) throw new Error(`audio fetch ${r.status}`); return r.arrayBuffer(); }
534
+ function wavBlobUrl(buf){ return URL.createObjectURL(new Blob([buf],{type:"audio/wav"})); }
535
+ async function decodeWav(buf){ const ctx=new (window.AudioContext||window.webkitAudioContext)(); const d=await ctx.decodeAudioData(buf.slice(0)); ctx.close(); return d; }
536
+ function peaksFrom(samples, buckets){
537
+ const out=new Float32Array(buckets*2); const step=Math.floor(samples.length/buckets)||1;
538
+ for(let i=0;i<buckets;i++){ const s=i*step; const e=Math.min(s+step,samples.length); let mn=1,mx=-1;
539
+ for(let j=s;j<e;j++){ const v=samples[j]; if(v<mn)mn=v; if(v>mx)mx=v; }
540
+ out[i*2]=mn; out[i*2+1]=mx;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  }
542
+ return out;
543
  }
544
+ function drawWave(canvas, samples, progress){
545
+ const dpr=window.devicePixelRatio||1; const w=canvas.clientWidth, h=canvas.clientHeight;
546
+ canvas.width=Math.max(1,w*dpr); canvas.height=Math.max(1,h*dpr);
547
+ const ctx=canvas.getContext("2d"); ctx.scale(dpr,dpr); ctx.clearRect(0,0,w,h);
548
+ const buckets=Math.max(40,Math.floor(w*1.2)); const peaks=peaksFrom(samples,buckets);
549
+ const bw=w/peaks.length*0.5; const mid=h/2; const prog=Math.max(0,Math.min(1,progress||0));
550
+ for(let i=0;i<peaks.length;i++){ const x=(i/peaks.length)*w; const played=(i/peaks.length)<=prog;
551
+ ctx.fillStyle=played?"#2dd4bf":"#2a322d"; const top=mid-peaks[i*2+1]*mid*0.94; const bot=mid-peaks[i*2]*mid*0.94;
552
+ ctx.fillRect(x, top, Math.max(1,bw), Math.max(1,bot-top));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  }
554
+ }
555
+ function drawMini(canvas, samples, progress){
556
+ const dpr=window.devicePixelRatio||1; const w=canvas.clientWidth,h=canvas.clientHeight;
557
+ canvas.width=Math.max(1,w*dpr); canvas.height=Math.max(1,h*dpr);
558
+ const ctx=canvas.getContext("2d"); ctx.scale(dpr,dpr); ctx.clearRect(0,0,w,h);
559
+ const buckets=Math.max(40,Math.floor(w*1.2)); const peaks=peaksFrom(samples,buckets);
560
+ const bw=w/peaks.length*0.5; const mid=h/2; const prog=Math.max(0,Math.min(1,progress||0));
561
+ for(let i=0;i<peaks.length;i++){ const x=(i/peaks.length)*w; const played=(i/peaks.length)<=prog;
562
+ ctx.fillStyle=played?"#2dd4bf":"#232a26"; const top=mid-peaks[i*2+1]*mid*0.9; const bot=mid-peaks[i*2]*mid*0.9;
563
+ ctx.fillRect(x,top,Math.max(1,bw),Math.max(1,bot-top));
 
 
 
 
564
  }
 
 
 
565
  }
566
 
567
+ /* ---- level meters ---- */
568
+ const metersEl=$("meters"); const METER_BARS=28; for(let i=0;i<METER_BARS;i++){ const b=document.createElement("div"); b.className="bar"; b.style.height="3px"; metersEl.appendChild(b); }
569
+ let meterRAF=null;
570
+ function runMeters(audio){ cancelAnimationFrame(meterRAF); const bars=[...metersEl.children];
571
+ const tick=()=>{ const p=audio.paused?0:Math.min(1,Math.abs((audio.currentTime%0.4)/0.4));
572
+ // approximate RMS-ish from analyser-less: derive pseudo level from time
573
+ const lvl=audio.paused?0:0.35+0.5*Math.abs(Math.sin(audio.currentTime*9))+0.15*Math.random();
574
+ bars.forEach((b,i)=>{ const t=i/bars.length; const on=t<=lvl; b.classList.toggle("lev",on&&t<0.85); b.classList.toggle("peak",on&&t>=0.85); b.style.height=on?`${3+t*30}px`:"3px"; });
575
+ meterRAF=requestAnimationFrame(tick);
576
+ }; tick();
577
+ audio.addEventListener("pause",()=>{ cancelAnimationFrame(meterRAF); bars.forEach(b=>{ b.classList.remove("lev","peak"); b.style.height="3px"; }); }, {once:true});
 
 
578
  }
579
 
580
+ /* ---- player state ---- */
581
+ let current=null; let raf=null;
582
+ const waveCanvas=$("wave-canvas");
583
+ const playhead=$("playhead"); const region=$("region");
584
+ const markersEl=$("markers"); const karaoke=$("karaoke");
585
+ const dl=$("download");
586
+ const strip=$("status-strip"); const statusEl=$("status"); const queueEl=$("queue");
587
+ const led=$("status-led");
588
+ const genBtn=$("generate"); const cmpBtn=$("compare-btn");
589
+ const playBtn=$("play"); const stopBtn=$("stop"); const restartBtn=$("restart");
590
+ const seek=$("seek");
591
+
592
+ function setBusy(b,msg){ genBtn.disabled=b; cmpBtn.disabled=b; led.classList.toggle("busy",b); led.classList.toggle("on",!b&&current!=null);
593
+ strip.classList.toggle("busy",b); if(b){ statusEl.innerHTML=`<span class="busy-spin"></span> ${msg||"Generating…"}`; queueEl.classList.remove("hidden"); queueEl.textContent=""; }
 
 
 
 
 
 
 
 
 
 
 
594
  }
595
+ function setError(msg){ strip.classList.remove("busy","ok"); strip.classList.add("err"); statusEl.innerHTML=`<span style="color:var(--warn)">${msg}</span>`; led.classList.remove("busy","on"); }
596
 
597
+ function renderKaraoke(c){ karaoke.innerHTML=""; if(!c||!c.chunks.length){ karaoke.innerHTML='<div class="script-empty">Your input text will appear here, lighting up as it is spoken.</div>'; return; }
598
+ c.chunks.forEach((t,i)=>{ const sp=document.createElement("span"); sp.className="chunk"; sp.dataset.index=i; sp.textContent=t+" "; karaoke.appendChild(sp); });
599
+ karaoke.classList.add("in");
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  }
601
+ function drawMarkers(c){ markersEl.innerHTML=""; if(!c) return; let acc=0; for(let i=0;i<c.timings.length-1;i++){ acc+=c.timings[i].dur+(i<c.timings.length-1?boundaryPause(c.chunks[i]):0); const m=document.createElement("div"); m.className="m"; m.style.left=`${Math.min(100,(acc/c.totalSec)*100)}%`; markersEl.appendChild(m); } }
602
+
603
+ function tick(current){
604
+ const a=current.audio; const t=a.currentTime; const p=current.totalSec?Math.min(1,t/current.totalSec):0;
605
+ playhead.style.left=`${p*100}%`; drawWave(waveCanvas,current.samples,p);
606
+ $("now").textContent=fmtTime(t); seek.value=String(Math.round(p*1000));
607
+ // karaoke
608
+ const spans=karaoke.querySelectorAll(".chunk"); let active=-1;
609
+ for(let i=0;i<current.timings.length;i++){ const seg=current.timings[i]; if(t>=seg.start && t<seg.start+seg.dur){ active=i; break; } if(t>=seg.start) active=i; }
610
+ spans.forEach((sp,i)=>{ sp.classList.remove("speaking"); sp.style.background="";
611
+ const seg=current.timings[i]; if(seg && t>=seg.start+seg.dur){ if(!sp.classList.contains("spoken")) sp.classList.add("spoken"); }
612
+ else sp.classList.remove("spoken");
613
+ if(i===active){ sp.classList.add("speaking"); const rel=Math.max(0,t-seg.start)/Math.max(0.001,seg.dur);
614
+ sp.style.background=`linear-gradient(90deg,rgba(45,212,191,.28) ${Math.min(100,rel*100)}%,transparent ${Math.min(100,rel*100)}%)`; }
615
+ });
616
+ raf=requestAnimationFrame(()=>tick(current));
617
  }
618
+ function startPlayback(current){ if(!current||!current.audio) return; current.audio.play().then(()=>{ playBtn.textContent="❚❚"; $("np-badge").classList.add("playing"); led.classList.add("on"); cancelAnimationFrame(raf); raf=requestAnimationFrame(()=>tick(current)); runMeters(current.audio); }).catch(()=>{}); }
619
+ function pausePlayback(current){ if(!current||!current.audio) return; current.audio.pause(); playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); }
620
+ function stopPlayback(current){ if(!current||!current.audio) return; current.audio.pause(); current.audio.currentTime=0; playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); $("now").textContent="0:00"; seek.value="0"; playhead.style.left="0%"; drawWave(waveCanvas,current.samples,0); }
621
+
622
+ function setupPlayer(c){
623
+ current=c; renderKaraoke(c); drawMarkers(c); drawWave(waveCanvas,c.samples,0);
624
+ c.audio=new Audio(c.url); const a=c.audio;
625
+ $("dur").textContent=fmtTime(c.totalSec); $("now").textContent="0:00"; seek.value="0"; seek.disabled=false;
626
+ playBtn.disabled=false; stopBtn.disabled=false; restartBtn.disabled=false; playBtn.textContent="▶";
627
+ $("np-title").textContent=activeModel(); $("np-sub").textContent=`${c.totalSec.toFixed(2)}s · ${c.chunks.length} chunk${c.chunks.length!==1?"s":""}`;
628
+ $("take-spec").textContent=`${c.totalSec.toFixed(2)}s · ${c.chunks.length} chunks · ${c.samples.length} smp`;
629
+ dl.href=c.url;
630
+ a.addEventListener("loadedmetadata",()=>{ $("dur").textContent=fmtTime(a.duration); });
631
+ a.addEventListener("ended",()=>{ playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); $("now").textContent=fmtTime(c.totalSec); seek.value="1000"; playhead.style.left="100%"; drawWave(waveCanvas,c.samples,1); karaoke.querySelectorAll(".chunk").forEach(s=>{ s.classList.remove("speaking"); s.classList.add("spoken"); }); });
632
  }
633
 
634
+ playBtn.addEventListener("click",()=>{ if(!current) return; if(current.audio.paused) startPlayback(current); else pausePlayback(current); });
635
+ stopBtn.addEventListener("click",()=>{ if(current) stopPlayback(current); });
636
+ restartBtn.addEventListener("click",()=>{ if(!current) return; current.audio.currentTime=0; if(current.audio.paused) startPlayback(current); });
637
+ seek.addEventListener("input",()=>{ if(!current) return; const t=(parseFloat(seek.value)/1000)*current.totalSec; current.audio.currentTime=t; $("now").textContent=fmtTime(t); playhead.style.left=`${(t/current.totalSec)*100}%`; drawWave(waveCanvas,current.samples,t/current.totalSec); });
638
+ $("wave").addEventListener("click",(e)=>{ if(!current) return; const r=$("wave").getBoundingClientRect(); const p=(e.clientX-r.left)/r.width; const t=p*current.totalSec; current.audio.currentTime=t; $("now").textContent=fmtTime(t); });
639
+
640
+ /* ---- generate ---- */
641
+ async function doGenerate(){
642
+ setBusy(true,"Queued for ZeroGPU…"); queueEl.textContent="";
643
+ try{
644
+ const c=await getClient(); const res=await c.predict("/synthesize",{ ...inputs(), model_name: activeModel() });
645
+ const fileObj=res.data[0]; const statusText=res.data[1]||"Done.";
646
+ const url=fileObj.url; if(!url) throw new Error("server returned no audio url");
647
+ const buf=await fetchArray(url); const blobUrl=wavBlobUrl(buf); const decoded=await decodeWav(buf);
648
+ const text=$("text").value; const chunks=splitText(text); const timings=chunkTimings(chunks,decoded.duration);
649
+ const samples=decoded.getChannelData(0);
650
+ setupPlayer({ url:blobUrl, totalSec:decoded.duration, chunks, timings, samples, audio:null });
651
+ statusEl.innerHTML=statusText; strip.classList.remove("busy","err"); strip.classList.add("ok"); queueEl.classList.add("hidden");
652
+ // topbar meters
653
+ const rtfM=statusText.match(/RTF\s*([0-9.]+)/); if(rtfM) $("m-rtf").textContent=parseFloat(rtfM[1]).toFixed(3);
 
 
 
 
 
 
 
654
  setBusy(false);
655
+ startPlayback(current);
656
+ } catch(e){ setError(`Error: ${String(e?.message||e)}`); setBusy(false); }
 
 
 
 
 
657
  }
658
 
659
+ /* ---- compare ---- */
660
+ let cmp={ micro:null, nano:null };
661
+ const cmpCanvas={ micro:$("micro-canvas"), nano:$("nano-canvas") };
662
+ async function doCompare(){
663
+ setBusy(true,"Comparing models on ZeroGPU…"); queueEl.textContent="";
664
+ for(const k of ["micro","nano"]){ if(cmp[k]?.audio){ cmp[k].audio.pause(); } cmp[k]=null; $(`${k}-now`).textContent="0:00"; $(`${k}-dur`).textContent="0:00"; $(`${k}-seek`).value="0"; $(`${k}-seek`).disabled=true; document.querySelector(`.mini-play[data-mini="${k}"]`).disabled=true; document.querySelector(`.mini-play[data-mini="${k}"]`).textContent="▶"; $(`${k}-ph`).style.left="0%"; drawMini(cmpCanvas[k], new Float32Array(40),0); }
665
+ try{
666
+ const c=await getClient(); const res=await c.predict("/compare",{ ...inputs() });
667
+ const [microObj,nanoObj,statusText]=res.data;
668
+ for(const [key,obj,durId,nowId,seekId,dlId,phId,cv] of [
669
+ ["micro",microObj,"micro-dur","micro-now","micro-seek","micro-dl","micro-ph",cmpCanvas.micro],
670
+ ["nano",nanoObj,"nano-dur","nano-now","nano-seek","nano-dl","nano-ph",cmpCanvas.nano],
671
+ ]){
672
+ const url=obj.url; if(!url) throw new Error(`server returned no ${key} url`);
673
+ const buf=await fetchArray(url); const blobUrl=wavBlobUrl(buf); const decoded=await decodeWav(buf); const samples=decoded.getChannelData(0);
674
+ $(dlId).href=blobUrl; $(durId).textContent=fmtTime(decoded.duration); $(seekId).value="0"; $(seekId).disabled=false;
675
+ drawMini(cv,samples,0);
676
+ const audio=new Audio(blobUrl); const btn=document.querySelector(`.mini-play[data-mini="${key}"]`); btn.disabled=false;
677
+ const upd=()=>{ $(nowId).textContent=fmtTime(audio.currentTime); const p=audio.currentTime/decoded.duration; $(seekId).value=String(Math.round(p*1000)); $(phId).style.left=`${p*100}%`; drawMini(cv,samples,p); };
678
+ audio.addEventListener("timeupdate",upd);
679
+ audio.addEventListener("ended",()=>{ btn.textContent=""; $(phId).style.left="100%"; drawMini(cv,samples,1); });
680
+ const sk=$(seekId); if(sk._seek) sk.removeEventListener("input",sk._seek); const onSeek=()=>{ audio.currentTime=(parseFloat(sk.value)/1000)*decoded.duration; }; sk._seek=onSeek; sk.addEventListener("input",onSeek);
681
+ cmp[key]={ audio, totalSec:decoded.duration, samples, canvas:cv };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  }
683
+ statusEl.innerHTML=statusText; strip.classList.remove("busy","err"); strip.classList.add("ok"); queueEl.classList.add("hidden");
684
  setBusy(false);
685
+ } catch(e){ setError(`Error: ${String(e?.message||e)}`); setBusy(false); }
 
 
 
 
 
686
  }
687
+ document.querySelectorAll(".mini-play").forEach((btn)=>{ btn.addEventListener("click",()=>{ const key=btn.dataset.mini; const item=cmp[key]; if(!item||!item.audio) return;
688
+ if(item.audio.paused){ const other=key==="micro"?"nano":"micro"; if(cmp[other]?.audio && !cmp[other].audio.paused){ cmp[other].audio.pause(); document.querySelector(`.mini-play[data-mini="${other}"]`).textContent="▶"; } item.audio.play(); btn.textContent="❚❚"; }
689
+ else { item.audio.pause(); btn.textContent="▶"; }
690
+ }); });
691
 
692
+ genBtn.addEventListener("click",doGenerate);
693
+ cmpBtn.addEventListener("click",doCompare);
694
+ $("text").addEventListener("keydown",(e)=>{ if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)){ e.preventDefault(); doGenerate(); } });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
 
696
+ /* redraw wave on resize */
697
+ window.addEventListener("resize",()=>{ if(current) drawWave(waveCanvas,current.samples, current.audio?current.audio.currentTime/current.totalSec:0); for(const k of ["micro","nano"]){ if(cmp[k]) drawMini(cmpCanvas[k],cmp[k].samples, cmp[k].audio?cmp[k].audio.currentTime/cmp[k].totalSec:0); } });
698
+
699
+ drawWave(waveCanvas,new Float32Array(200),0);
700
+ drawMini(cmpCanvas.micro,new Float32Array(40),0);
701
+ drawMini(cmpCanvas.nano,new Float32Array(40),0);
702
  </script>
703
  </body>
704
  </html>