mobile friendly UI with gradio server

#3
by akhaliq HF Staff - opened
Files changed (4) hide show
  1. .gitignore +1 -0
  2. README.md +1 -1
  3. app.py +312 -525
  4. index.html +842 -0
.gitignore CHANGED
@@ -1,3 +1,4 @@
1
  __pycache__/
2
  *.py[cod]
3
  .cache/
 
 
1
  __pycache__/
2
  *.py[cod]
3
  .cache/
4
+ rendered/
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🔊
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.11.0
8
  python_version: 3.12
9
  app_file: app.py
10
  pinned: false
 
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.20.0
8
  python_version: 3.12
9
  app_file: app.py
10
  pinned: false
app.py CHANGED
@@ -1,9 +1,8 @@
1
- from __future__ import annotations
2
-
3
  import logging
4
- import mimetypes
5
  import re
6
- import sys
 
7
  import threading
8
  import time
9
  from dataclasses import dataclass
@@ -12,118 +11,130 @@ from pathlib import Path
12
 
13
  import gradio as gr
14
  import numpy as np
 
15
  import torch
16
  from audiotsm import wsola
17
  from audiotsm.io.array import ArrayReader, ArrayWriter
 
18
  from scipy.signal import resample_poly
19
-
20
- try:
21
- import spaces
22
- except ImportError: # Local smoke tests do not require the ZeroGPU shim.
23
- class _Spaces:
24
- @staticmethod
25
- def GPU(*args, **kwargs):
26
- def decorate(function):
27
- return function
28
- return decorate
29
- spaces = _Spaces()
30
-
31
-
32
  ROOT = Path(__file__).resolve().parent
33
  RUNTIME = ROOT / "runtime"
34
  WEB_RUNTIME = ROOT / "web_runtime"
 
35
  sys.path.insert(0, str(RUNTIME))
36
  sys.path.insert(0, str(ROOT))
37
 
38
- # Windows does not register .mjs by default. Gradio's static-file response
39
- # would otherwise serve ONNX Runtime's module as text/plain.
 
 
 
 
 
40
  mimetypes.add_type("text/javascript", ".mjs")
41
- gr.set_static_paths(paths=[WEB_RUNTIME])
42
-
43
- import commons
44
- import utils
45
- from inflect_vits_frontend import run_vits_frontend
46
- from models import SynthesizerTrn
47
- from text import cleaned_text_to_sequence
48
- from text.symbols import symbols
49
-
50
-
51
- @dataclass(frozen=True)
52
- class ModelSpec:
53
- label: str
54
- short_label: str
55
- params: str
56
- checkpoint: Path
57
- config: Path
58
- revision: str
59
-
60
-
61
- SPECS = {
62
- "Inflect Micro v2": ModelSpec(
63
- label="Inflect Micro v2",
64
- short_label="Micro",
65
- params="9.36M",
66
- checkpoint=ROOT / "models" / "micro" / "model.pth",
67
- config=ROOT / "models" / "micro" / "config.json",
68
- revision="3eede065",
69
- ),
70
- "Inflect Nano v2": ModelSpec(
71
- label="Inflect Nano v2",
72
- short_label="Nano",
73
- params="3.96M",
74
- checkpoint=ROOT / "models" / "nano" / "model.pth",
75
- config=ROOT / "models" / "nano" / "config.json",
76
- revision="bfca4684",
77
- ),
78
- }
79
-
80
-
81
- def split_text(text: str, limit: int = 280) -> list[str]:
82
- normalized = " ".join(text.split())
83
- sentences = [
84
- part.strip()
85
- for part in re.split(r"(?<=[.!?;:])\s+", normalized)
86
- if part.strip()
87
- ]
88
- chunks: list[str] = []
89
- for sentence in sentences or [normalized]:
90
- while len(sentence) > limit:
91
- search = sentence[: limit + 1]
92
- punctuation = max(search.rfind(mark) for mark in (",", ";", ":"))
93
- split_at = (
94
- punctuation + 1
95
- if punctuation >= limit // 2
96
- else sentence.rfind(" ", 0, limit + 1)
97
- )
98
- if split_at < limit // 2:
99
- split_at = limit
100
- chunks.append(sentence[:split_at].strip())
101
- sentence = sentence[split_at:].strip()
102
- if sentence:
103
- chunks.append(sentence)
104
- return chunks
105
-
106
-
107
- def boundary_pause_seconds(chunk: str) -> float:
108
- ending = chunk.rstrip()[-1:] if chunk.strip() else ""
109
- return {
110
- "?": 0.28,
111
- "!": 0.24,
112
- ".": 0.22,
113
- ";": 0.16,
114
- ":": 0.13,
115
- ",": 0.09,
116
- }.get(ending, 0.08)
117
-
118
-
 
 
 
 
119
  def edge_fade(waveform: np.ndarray, sample_rate: int, milliseconds: float = 5.0) -> np.ndarray:
120
- frames = min(round(sample_rate * milliseconds / 1000.0), waveform.size // 2)
121
- if frames <= 0:
122
- return waveform
123
- output = waveform.copy()
124
- ramp = np.linspace(0.0, 1.0, frames, endpoint=True, dtype=np.float32)
125
- output[:frames] *= ramp
126
- output[-frames:] *= ramp[::-1]
127
  return output
128
 
129
 
@@ -173,429 +184,205 @@ def pitch_shift_speech(
173
 
174
 
175
  class Engine:
176
- def __init__(self, spec: ModelSpec) -> None:
177
- if not spec.config.is_file():
178
- raise FileNotFoundError(f"Missing release configuration for {spec.label}")
179
- if not spec.checkpoint.is_file():
180
- raise FileNotFoundError(f"Missing release weights for {spec.label}")
181
- self.spec = spec
182
- self.hps = utils.get_hparams_from_file(str(spec.config))
183
- self.model = SynthesizerTrn(
184
- len(symbols),
185
- self.hps.data.filter_length // 2 + 1,
186
- self.hps.train.segment_size // self.hps.data.hop_length,
187
- **self.hps.model,
188
- ).eval()
189
- root_logger = logging.getLogger()
190
- previous_level = root_logger.level
191
- try:
192
- root_logger.setLevel(logging.WARNING)
193
- utils.load_checkpoint(str(spec.checkpoint), self.model, None)
194
- finally:
195
- root_logger.setLevel(previous_level)
196
- self.device = torch.device("cpu")
197
- self.sample_rate = int(self.hps.data.sampling_rate)
198
- self.lock = threading.Lock()
199
-
200
- def tokens(self, text: str) -> tuple[torch.Tensor, torch.Tensor]:
201
- phonemes = run_vits_frontend(text).phoneme_text
202
- sequence = cleaned_text_to_sequence(phonemes)
203
- if self.hps.data.add_blank:
204
- sequence = commons.intersperse(sequence, 0)
205
- if not sequence:
206
- raise ValueError("The phoneme frontend produced no speakable tokens.")
207
- tokens = torch.LongTensor(sequence).to(self.device).unsqueeze(0)
208
- lengths = torch.LongTensor([tokens.size(1)]).to(self.device)
209
- return tokens, lengths
210
-
211
- @torch.inference_mode()
212
- def synthesize(
213
- self,
214
- text: str,
215
- speed: float,
216
- variation: float,
217
- pitch_steps: float,
218
- seed: int,
219
- ) -> tuple[int, np.ndarray]:
220
- chunks = split_text(text)
221
- waveforms: list[np.ndarray] = []
222
- with self.lock:
223
- self.device = torch.device("cuda")
224
- self.model.to(self.device)
225
- try:
226
- for index, chunk in enumerate(chunks):
227
- if index:
228
- waveforms.append(
229
- np.zeros(
230
- round(self.sample_rate * boundary_pause_seconds(chunks[index - 1])),
231
- dtype=np.float32,
232
- )
233
- )
234
- tokens, lengths = self.tokens(chunk)
235
- torch.manual_seed(seed + index)
236
- torch.cuda.manual_seed_all(seed + index)
237
- waveform = self.model.infer(
238
- tokens,
239
- lengths,
240
- noise_scale=variation,
241
- noise_scale_w=0.8,
242
- length_scale=1.0 / speed,
243
- max_len=4000,
244
- )[0][0, 0].float().cpu().numpy()
245
- waveforms.append(edge_fade(waveform, self.sample_rate))
246
- finally:
247
- self.model.to("cpu")
248
- self.device = torch.device("cpu")
249
- torch.cuda.empty_cache()
250
  audio = np.concatenate(waveforms)
251
  if abs(pitch_steps) >= 0.01:
252
  audio = pitch_shift_speech(audio, pitch_steps)
253
  return self.sample_rate, pcm16(audio)
254
-
255
-
256
- ENGINES = {label: Engine(spec) for label, spec in SPECS.items()}
257
-
258
-
259
- def validate(
260
- text: str,
261
- speed: float,
262
- variation: float,
263
- pitch_steps: float,
264
- seed: int,
265
- ) -> tuple[str, float, float, float, int]:
266
- text = " ".join((text or "").split())
267
- if not text:
268
- raise gr.Error("Enter something for Inflect to say.")
269
- return text, float(speed), float(variation), float(pitch_steps), int(seed)
270
-
271
-
272
- @spaces.GPU(duration=120)
273
- def synthesize_one(
274
- text: str,
275
- model_name: str,
276
- speed: float,
277
- variation: float,
278
- pitch_steps: float,
279
- seed: int,
280
- ):
281
- text, speed, variation, pitch_steps, seed = validate(
282
- text, speed, variation, pitch_steps, seed
283
- )
284
- engine = ENGINES[model_name]
285
- started = time.perf_counter()
286
- audio = engine.synthesize(text, speed, variation, pitch_steps, seed)
287
- seconds = len(audio[1]) / audio[0]
288
- wall = time.perf_counter() - started
289
- rtf = wall / seconds
290
- chunks = len(split_text(text))
291
- return audio, (
292
- f"**{model_name}** · fixed English voice \n"
293
- f"`{engine.spec.params}` parameters · `{seconds:.2f}s` audio · "
294
- f"`{wall:.2f}s` generation · `RTF {rtf:.3f}` · "
295
- f"`{len(text)}` characters · `{chunks}` text chunk{'s' if chunks != 1 else ''} · "
296
- f"pitch `{pitch_steps:+.2f} st` · "
297
- f"seed `{seed}` · weights `{engine.spec.revision}`"
298
- )
299
-
300
-
301
- @spaces.GPU(duration=120)
302
- def synthesize_both(
303
- text: str,
304
- speed: float,
305
- variation: float,
306
- pitch_steps: float,
307
- seed: int,
308
- ):
309
- text, speed, variation, pitch_steps, seed = validate(
310
- text, speed, variation, pitch_steps, seed
311
- )
312
- started = time.perf_counter()
313
- micro = ENGINES["Inflect Micro v2"].synthesize(
314
- text, speed, variation, pitch_steps, seed
315
- )
316
- micro_wall = time.perf_counter() - started
317
- started = time.perf_counter()
318
- nano = ENGINES["Inflect Nano v2"].synthesize(
319
- text, speed, variation, pitch_steps, seed
320
- )
321
- nano_wall = time.perf_counter() - started
322
- micro_seconds = len(micro[1]) / micro[0]
323
- nano_seconds = len(nano[1]) / nano[0]
324
- return micro, nano, (
325
- f"Same text and seed `{seed}` · "
326
- f"Micro `{micro_wall:.2f}s / {micro_seconds:.2f}s audio` · "
327
- f"Nano `{nano_wall:.2f}s / {nano_seconds:.2f}s audio`"
328
- )
329
-
330
-
331
- CSS = """
332
- :root{color-scheme:dark;--paper:#111412;--panel:#181c19;--panel-2:#141815;--ink:#f3f4ef;--muted:#9ea8a0;--line:#303732;--signal:#2486ff;--signal-hover:#52a0ff;--signal-soft:#15263b}
333
- html,body,.gradio-container,.dark{background:var(--paper)!important;color:var(--ink)!important;color-scheme:dark!important}
334
- body,.gradio-container,.gradio-container button,.gradio-container input,.gradio-container textarea{font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif!important}
335
- .gradio-container{width:min(100%,1640px)!important;max-width:none!important;margin:auto!important;padding:0 clamp(14px,2.2vw,34px) 54px!important;box-sizing:border-box!important}
336
- .gradio-container>.main{padding:0!important}
337
- .gradio-container *{box-sizing:border-box}
338
- .hero-wrap,.hero-wrap.block,.hero-wrap>.wrap{padding:0!important;border:0!important;box-shadow:none!important;background:transparent!important}
339
- .hero{padding:18px 2px 13px;border-bottom:1px solid var(--line);margin-bottom:2px}
340
- .hero-top{display:flex;align-items:center;justify-content:space-between;gap:20px}
341
- .eyebrow{color:var(--signal);font:700 11px/1 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.13em;text-transform:uppercase}
342
- .links{display:flex;align-items:center;gap:18px;font-size:12px;font-weight:700}
343
- .links a{color:var(--muted)!important;text-decoration:none}.links a:hover{color:var(--ink)!important}
344
- .hero h1{max-width:1040px;margin:15px 0 8px;color:var(--ink);font:650 clamp(40px,4.5vw,56px)/.96 Inter,ui-sans-serif,sans-serif;letter-spacing:-.055em}
345
- .hero>p{max-width:1000px;margin:0;color:var(--muted)!important;font-size:13px;line-height:1.48}
346
- .model-facts{display:flex;align-items:stretch;margin-top:13px;border-top:1px solid var(--line)}
347
- .model-fact{flex:1;padding:8px 24px 0 0;color:var(--muted)!important;font-size:11px;line-height:1.3}
348
- .model-fact+.model-fact{padding-left:20px;border-left:1px solid var(--line)}
349
- .model-fact strong{display:block;margin-bottom:2px;color:var(--ink);font-size:14px;line-height:1.2}
350
- .runtime-tabs{margin:14px 10px 0!important}
351
- .runtime-tabs>.tab-container,.runtime-tabs .tab-container{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr))!important;gap:0!important;height:auto!important;overflow:hidden!important;border:1px solid var(--line)!important;border-radius:2px!important;background:var(--panel-2)!important}
352
- .runtime-tabs>.tab-container button,.runtime-tabs .tab-container button{display:flex!important;min-height:54px!important;padding:9px 14px!important;flex-direction:column!important;align-items:flex-start!important;justify-content:center!important;border:0!important;border-radius:0!important;background:transparent!important;color:var(--ink)!important;font-size:13px!important;font-weight:800!important;line-height:1.2!important;letter-spacing:.005em!important;transition:background .16s ease,color .16s ease!important}
353
- .runtime-tabs>.tab-container button+button,.runtime-tabs .tab-container button+button{border-left:1px solid var(--line)!important}
354
- .runtime-tabs>.tab-container button::after,.runtime-tabs .tab-container button::after{margin-top:3px;color:var(--muted);font-size:10px;font-weight:550;letter-spacing:.01em}
355
- .runtime-tabs>.tab-container button:nth-child(1)::after,.runtime-tabs .tab-container button:nth-child(1)::after{content:"Hugging Face ZeroGPU"}
356
- .runtime-tabs>.tab-container button:nth-child(2)::after,.runtime-tabs .tab-container button:nth-child(2)::after{content:"WebGPU / WASM · private"}
357
- .runtime-tabs>.tab-container button:hover,.runtime-tabs .tab-container button:hover{background:#1b242d!important;color:var(--ink)!important}
358
- .runtime-tabs>.tab-container button.selected,.runtime-tabs .tab-container button.selected{background:var(--signal-soft)!important;color:#fff!important;box-shadow:inset 0 -2px 0 var(--signal)!important}
359
- .runtime-tabs>.tab-container button.selected::after,.runtime-tabs .tab-container button.selected::after{color:#a9c7e8!important}
360
- .runtime-tabs>.tabitem,.runtime-tabs .tabitem{padding-top:0!important;border:0!important;background:transparent!important}
361
- .mode-banner{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:16px;margin:10px 0;padding:9px 11px;border:1px solid var(--line);border-left:2px solid var(--signal);border-radius:2px;background:var(--panel-2)}
362
- .mode-banner strong{display:block;margin-bottom:2px;color:var(--ink)!important;font-size:12px}
363
- .mode-banner span{color:var(--muted)!important;font-size:11px;line-height:1.45}
364
- .mode-badge{padding:5px 7px;border:1px solid #356ea8;border-radius:2px;color:#dceaff!important;font:750 9px/1 ui-monospace,SFMono-Regular,Consolas,monospace!important;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap}
365
- .browser-frame-shell{margin:0;padding:0;border:1px solid var(--line);border-radius:2px;background:var(--paper);overflow:hidden}
366
- .browser-frame{display:block;width:100%;height:580px;border:0;background:var(--paper);transition:height .18s ease}
367
- .browser-help{margin:9px 2px 0!important;color:var(--muted)!important;font-size:11px!important;line-height:1.5!important}
368
- .workbench{padding:0 0 4px!important;border:0!important;background:transparent!important}
369
- .workbench.block,.workbench>.wrap{border:0!important;box-shadow:none!important;background:transparent!important}
370
- .control-row{align-items:stretch!important;gap:18px!important;margin-top:6px!important;padding:12px 14px 11px!important;border:1px solid var(--line)!important;border-radius:3px!important;background:var(--panel-2)!important}
371
- .control-row>div{min-width:0!important}
372
- .control-row .form{height:100%!important;border:0!important;background:transparent!important}
373
- .control-row label>span:first-child{font-size:12px!important;font-weight:750!important;letter-spacing:.015em!important}
374
- .model-switch .wrap{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr))!important;gap:0!important;padding:0!important;overflow:hidden!important;border:1px solid var(--line)!important;border-radius:2px!important;background:#101411!important}
375
- .model-switch .wrap label{min-height:46px!important;margin:0!important;padding:10px 12px!important;border:0!important;border-radius:0!important;background:transparent!important}
376
- .model-switch .wrap label+label{border-left:1px solid var(--line)!important}
377
- .model-switch .wrap label:has(input:checked){background:var(--signal-soft)!important;box-shadow:inset 0 -2px 0 var(--signal)!important}
378
- .model-switch .wrap label span{font-size:12px!important;font-weight:750!important}
379
- .action-row{justify-content:center!important;gap:12px!important;margin:12px auto 0!important;max-width:840px!important}
380
- .action-row button{min-height:48px!important}
381
- .comparison{margin-top:22px!important;padding-top:20px!important;border-top:1px solid var(--line)!important}
382
- .comparison-title{margin-bottom:9px!important}
383
- .gradio-container .block,.gradio-container .form{border-radius:3px!important;background:var(--panel-2)!important;border-color:var(--line)!important;color:var(--ink)!important}
384
- .gradio-container .prose,.gradio-container .markdown-body,.gradio-container label,.gradio-container span,.gradio-container p,.gradio-container h1,.gradio-container h2,.gradio-container h3{color:var(--ink)!important}
385
- .gradio-container label{background:transparent!important}
386
- .gradio-container .wrap{background:var(--panel-2)!important}
387
- .gradio-container select{min-height:48px!important;background:#101411!important;color:var(--ink)!important;border-color:var(--line)!important;text-align:left!important}
388
- .gradio-container input,.gradio-container textarea{background:#101411!important;color:var(--ink)!important;border-color:var(--line)!important}
389
- .prompt-input textarea{font-size:16px!important;line-height:1.55!important;min-height:104px!important;padding:15px!important}
390
- .gradio-container button:not(.primary){background:var(--panel-2)!important;color:var(--ink)!important;border-color:var(--line)!important}
391
- .gradio-container button:not(.primary):hover{border-color:#527aa6!important;background:#1b242d!important}
392
- button.primary{min-height:46px!important;border:0!important;border-radius:1px!important;background:var(--signal)!important;color:#fff!important;font-weight:800!important}
393
- button.primary *{color:#fff!important}button.primary:hover{background:var(--signal-hover)!important}
394
- .advanced{margin:10px 0 3px!important;background:#131814!important;border-left:2px solid #344138!important}
395
- .advanced>button{font-size:12px!important;font-weight:750!important;letter-spacing:.01em!important}
396
- .output-audio{margin-top:8px!important}
397
- .outputmeta{padding:11px 13px!important;background:var(--signal-soft)!important;border:1px solid #294e78!important}
398
- .outputmeta *{color:#dceaff!important}
399
- .helper-copy{margin:8px 2px 4px!important;color:var(--muted)!important;font-size:12px!important}
400
- .fineprint{color:var(--muted)!important;font-size:12px;line-height:1.55;border-top:1px solid var(--line);padding-top:16px;margin-top:22px}
401
- footer{display:none!important}
402
- @media(max-width:760px){
403
- html,body{overflow-x:hidden!important}.gradio-container{width:100%!important;min-width:0!important;padding:0 14px 38px!important}
404
- .hero{padding-top:22px}.hero-top{align-items:flex-start;flex-direction:column;gap:12px}.links{gap:14px;font-size:11px}
405
- .hero h1{margin-top:18px;font-size:42px}.model-facts{display:grid;grid-template-columns:1fr 1fr}.model-fact{padding:11px 10px 9px 0}.model-fact+.model-fact{padding-left:10px}.model-fact:nth-child(3){padding-left:0;border-left:0;border-top:1px solid var(--line)}.model-fact:nth-child(4){border-top:1px solid var(--line)}
406
- .runtime-tabs{margin-left:0!important;margin-right:0!important}.runtime-tabs>.tab-container button,.runtime-tabs .tab-container button{min-height:54px!important;padding-inline:10px!important}.runtime-tabs>.tab-container button::after,.runtime-tabs .tab-container button::after{font-size:9px}.mode-banner{grid-template-columns:1fr}.mode-badge{justify-self:start}.browser-frame{height:980px}
407
- .workbench{min-width:0!important;padding-top:18px!important}.control-row,.action-row,.comparison .row{flex-direction:column!important;min-width:0!important}
408
- .control-row>*,.action-row>*,.comparison .row>*{width:100%!important;min-width:0!important}.gradio-container .wrap,.gradio-container .form,.gradio-container .block{min-width:0!important}
409
- }
410
- """
411
-
412
-
413
- PROMPTS = [
414
- "Wait, are you actually being for real? I thought the train left twenty minutes ago!",
415
- "Does punctuation work? Yes: commas, semicolons, and periods should create sensible boundaries.",
416
- "Professor Nguyen mailed the fluorescent poster to Reykjavik on Thursday.",
417
- "Although the weather changed without warning, the musicians finished packing before anyone opened the enormous glass door.",
418
- ]
419
-
420
- with gr.Blocks(title="Inflect v2 · local speech playground") as demo:
421
- gr.HTML("""
422
- <header class="hero">
423
- <div class="hero-top">
424
- <span class="eyebrow">Inflect v2 · choose where inference runs</span>
425
- <nav class="links">
426
- <a href="https://huggingface.co/owensong/Inflect-Micro-v2">Micro model</a>
427
- <a href="https://huggingface.co/owensong/Inflect-Nano-v2">Nano model</a>
428
- <a href="https://github.com/owenawsong/Inflect">GitHub</a>
429
- </nav>
430
- </div>
431
- <h1>Small models.<br>Complete speech.</h1>
432
- <p>Generate 24 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>
433
- <div class="model-facts">
434
- <div class="model-fact"><strong>9.36M · Micro</strong>quality-first model</div>
435
- <div class="model-fact"><strong>3.96M · Nano</strong>footprint-first model</div>
436
- <div class="model-fact"><strong>24 kHz WAV</strong>complete waveform output</div>
437
- <div class="model-fact"><strong>Local stack</strong>text normalization through audio</div>
438
- </div>
439
- </header>
440
- """, elem_classes="hero-wrap")
441
- with gr.Tabs(elem_classes="runtime-tabs"):
442
- with gr.Tab("Hosted GPU", id="zerogpu"):
443
- gr.HTML("""
444
- <div class="mode-banner">
445
- <span><strong>ZeroGPU inference</strong>Nothing downloads to your device. Hugging Face runs each request; free-tier quota and queue limits apply.</span>
446
- <span class="mode-badge">Server mode</span>
447
- </div>
448
- """)
449
- with gr.Column(elem_classes="workbench"):
450
- text = gr.Textbox(
451
- value=PROMPTS[0],
452
- lines=4,
453
- max_lines=14,
454
- label="Text",
455
- placeholder="Write a sentence, paragraph, or script...",
456
- elem_classes="prompt-input",
457
- )
458
- with gr.Row(equal_height=True, elem_classes="control-row"):
459
- model = gr.Radio(
460
- choices=list(SPECS),
461
- value="Inflect Micro v2",
462
- label="Model",
463
- info="Micro prioritizes quality; Nano prioritizes footprint.",
464
- scale=4,
465
- elem_classes="model-switch",
466
- )
467
- speed = gr.Slider(
468
- 0.7,
469
- 1.35,
470
- value=1.0,
471
- step=0.01,
472
- label="Speaking speed",
473
- info="1.00 is the trained default.",
474
- scale=4,
475
- )
476
- variation = gr.Slider(
477
- 0.0,
478
- 1.0,
479
- value=0.667,
480
- step=0.001,
481
- label="Delivery variation",
482
- info="Lower is steadier. Higher gives each take more variation.",
483
- scale=5,
484
- )
485
- with gr.Accordion(
486
- "Advanced controls",
487
- open=False,
488
- elem_classes="advanced",
489
- ):
490
- with gr.Row():
491
- pitch = gr.Slider(
492
- -2.0,
493
- 2.0,
494
- value=0.0,
495
- step=0.25,
496
- label="Pitch shift (semitones)",
497
- info="Changes the finished voice pitch without changing speaking speed.",
498
- )
499
- seed = gr.Number(
500
- value=0,
501
- precision=0,
502
- label="Repeatable seed",
503
- info="Use the same seed and settings to reproduce a take.",
504
- )
505
- with gr.Row(elem_classes="action-row"):
506
- generate = gr.Button("Generate selected model", variant="primary")
507
- compare = gr.Button("Compare Micro and Nano")
508
- audio = gr.Audio(label="Generated 24 kHz WAV", elem_classes="output-audio")
509
- status = gr.Markdown("Ready.", elem_classes="outputmeta")
510
- gr.Markdown(
511
- "The first request can include ZeroGPU queue and model-transfer time. "
512
- "Use the audio player's menu to download the final WAV.",
513
- elem_classes="helper-copy",
514
- )
515
- gr.Examples(
516
- [[prompt] for prompt in PROMPTS],
517
- inputs=text,
518
- label="Try a prepared prompt",
519
- )
520
- with gr.Column(elem_classes="comparison"):
521
- gr.Markdown(
522
- "### Direct model comparison\n"
523
- "The same text, seed, speed, variation, and pitch are sent to both models.",
524
- elem_classes="comparison-title",
525
- )
526
- with gr.Row():
527
- micro_audio = gr.Audio(label="Micro v2 · 9.36M · quality first")
528
- nano_audio = gr.Audio(label="Nano v2 · 3.96M · footprint first")
529
- compare_status = gr.Markdown("", elem_classes="outputmeta")
530
- gr.HTML("""<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>""")
531
- with gr.Tab("This browser", id="webgpu"):
532
- gr.HTML("""
533
- <div class="mode-banner">
534
- <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>
535
- <span class="mode-badge">No queue · no quota</span>
536
- </div>
537
- <div class="browser-frame-shell">
538
- <iframe
539
- class="browser-frame"
540
- src="/gradio_api/file=web_runtime/index.html?embed=1"
541
- title="Inflect browser-local WebGPU runtime"
542
- allow="autoplay; webgpu"
543
- loading="eager"
544
- ></iframe>
545
- </div>
546
- <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>
547
- """, js_on_load="""
548
- const frame = element.querySelector('.browser-frame');
549
- if (frame) {
550
- if (element.__inflectResizeHandler) {
551
- window.removeEventListener('message', element.__inflectResizeHandler);
552
- }
553
- if (element.__inflectLoadHandler) {
554
- frame.removeEventListener('load', element.__inflectLoadHandler);
555
- }
556
- const applyHeight = (height) => {
557
- const measured = Number(height);
558
- if (Number.isFinite(measured) && measured > 0) {
559
- const responsiveFloor = frame.clientWidth < 1024 ? 980 : 560;
560
- frame.style.height = `${Math.max(responsiveFloor, Math.min(2200, measured + 2))}px`;
561
- }
562
- };
563
- const measureFrame = () => {
564
- try {
565
- const doc = frame.contentDocument;
566
- applyHeight(Math.max(
567
- doc?.body?.scrollHeight || 0,
568
- doc?.documentElement?.scrollHeight || 0,
569
- ));
570
- } catch (_) {
571
- // The embedded runtime also reports its height by postMessage.
572
- }
573
- };
574
- const resizeHandler = (event) => {
575
- if (
576
- event.origin !== window.location.origin ||
577
- event.source !== frame.contentWindow ||
578
- event.data?.type !== 'inflect-web-resize'
579
- ) {
580
- return;
581
- }
582
- applyHeight(event.data.height);
583
- };
584
- element.__inflectResizeHandler = resizeHandler;
585
- element.__inflectLoadHandler = measureFrame;
586
- window.addEventListener('message', resizeHandler);
587
- frame.addEventListener('load', measureFrame);
588
- [100, 500, 1500].forEach((delay) => window.setTimeout(measureFrame, delay));
589
- }
590
- """)
591
- generate.click(synthesize_one, [text, model, speed, variation, pitch, seed], [audio, status], concurrency_limit=1, api_name="synthesize")
592
- text.submit(synthesize_one, [text, model, speed, variation, pitch, seed], [audio, status], concurrency_limit=1)
593
- compare.click(synthesize_both, [text, speed, variation, pitch, seed], [micro_audio, nano_audio, compare_status], concurrency_limit=1, api_name="compare")
594
-
595
-
596
- if __name__ == "__main__":
597
- demo.queue(default_concurrency_limit=1).launch(
598
- theme=gr.themes.Base(),
599
- css=CSS,
600
- allowed_paths=[str(WEB_RUNTIME)],
601
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
+ import os
3
  import re
4
+ import sys
5
+ import tempfile
6
  import threading
7
  import time
8
  from dataclasses import dataclass
 
11
 
12
  import gradio as gr
13
  import numpy as np
14
+ import soundfile as sf
15
  import torch
16
  from audiotsm import wsola
17
  from audiotsm.io.array import ArrayReader, ArrayWriter
18
+ from fastapi.responses import HTMLResponse, FileResponse
19
  from scipy.signal import resample_poly
20
+
21
+ try:
22
+ import spaces
23
+ except ImportError: # Local smoke tests do not require the ZeroGPU shim.
24
+ class _Spaces:
25
+ @staticmethod
26
+ def GPU(*args, **kwargs):
27
+ def decorate(function):
28
+ return function
29
+ return decorate
30
+ spaces = _Spaces()
31
+
32
+
33
  ROOT = Path(__file__).resolve().parent
34
  RUNTIME = ROOT / "runtime"
35
  WEB_RUNTIME = ROOT / "web_runtime"
36
+ STATIC_URL = "/web_runtime"
37
  sys.path.insert(0, str(RUNTIME))
38
  sys.path.insert(0, str(ROOT))
39
 
40
+ from gradio import Server
41
+ from gradio.data_classes import FileData
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
45
+ # browser runtime's modules with a wrong content type.
46
+ import mimetypes
47
  mimetypes.add_type("text/javascript", ".mjs")
48
+ mimetypes.add_type("application/wasm", ".wasm")
49
+ mimetypes.add_type("application/octet-stream", ".onnx")
50
+ mimetypes.add_type("application/json", ".config.json")
51
+
52
+ app = Server()
53
+
54
+ import commons
55
+ import utils
56
+ from inflect_vits_frontend import run_vits_frontend
57
+ from models import SynthesizerTrn
58
+ from text import cleaned_text_to_sequence
59
+ from text.symbols import symbols
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ModelSpec:
64
+ label: str
65
+ short_label: str
66
+ params: str
67
+ checkpoint: Path
68
+ config: Path
69
+ revision: str
70
+
71
+
72
+ SPECS = {
73
+ "Inflect Micro v2": ModelSpec(
74
+ label="Inflect Micro v2",
75
+ short_label="Micro",
76
+ params="9.36M",
77
+ checkpoint=ROOT / "models" / "micro" / "model.pth",
78
+ config=ROOT / "models" / "micro" / "config.json",
79
+ revision="3eede065",
80
+ ),
81
+ "Inflect Nano v2": ModelSpec(
82
+ label="Inflect Nano v2",
83
+ short_label="Nano",
84
+ params="3.96M",
85
+ checkpoint=ROOT / "models" / "nano" / "model.pth",
86
+ config=ROOT / "models" / "nano" / "config.json",
87
+ revision="bfca4684",
88
+ ),
89
+ }
90
+
91
+
92
+ def split_text(text: str, limit: int = 280) -> list[str]:
93
+ normalized = " ".join(text.split())
94
+ sentences = [
95
+ part.strip()
96
+ for part in re.split(r"(?<=[.!?;:])\s+", normalized)
97
+ if part.strip()
98
+ ]
99
+ chunks: list[str] = []
100
+ for sentence in sentences or [normalized]:
101
+ while len(sentence) > limit:
102
+ search = sentence[: limit + 1]
103
+ punctuation = max(search.rfind(mark) for mark in (",", ";", ":"))
104
+ split_at = (
105
+ punctuation + 1
106
+ if punctuation >= limit // 2
107
+ else sentence.rfind(" ", 0, limit + 1)
108
+ )
109
+ if split_at < limit // 2:
110
+ split_at = limit
111
+ chunks.append(sentence[:split_at].strip())
112
+ sentence = sentence[split_at:].strip()
113
+ if sentence:
114
+ chunks.append(sentence)
115
+ return chunks
116
+
117
+
118
+ def boundary_pause_seconds(chunk: str) -> float:
119
+ ending = chunk.rstrip()[-1:] if chunk.strip() else ""
120
+ return {
121
+ "?": 0.28,
122
+ "!": 0.24,
123
+ ".": 0.22,
124
+ ";": 0.16,
125
+ ":": 0.13,
126
+ ",": 0.09,
127
+ }.get(ending, 0.08)
128
+
129
+
130
  def edge_fade(waveform: np.ndarray, sample_rate: int, milliseconds: float = 5.0) -> np.ndarray:
131
+ frames = min(round(sample_rate * milliseconds / 1000.0), waveform.size // 2)
132
+ if frames <= 0:
133
+ return waveform
134
+ output = waveform.copy()
135
+ ramp = np.linspace(0.0, 1.0, frames, endpoint=True, dtype=np.float32)
136
+ output[:frames] *= ramp
137
+ output[-frames:] *= ramp[::-1]
138
  return output
139
 
140
 
 
184
 
185
 
186
  class Engine:
187
+ def __init__(self, spec: ModelSpec) -> None:
188
+ if not spec.config.is_file():
189
+ raise FileNotFoundError(f"Missing release configuration for {spec.label}")
190
+ if not spec.checkpoint.is_file():
191
+ raise FileNotFoundError(f"Missing release weights for {spec.label}")
192
+ self.spec = spec
193
+ self.hps = utils.get_hparams_from_file(str(spec.config))
194
+ self.model = SynthesizerTrn(
195
+ len(symbols),
196
+ self.hps.data.filter_length // 2 + 1,
197
+ self.hps.train.segment_size // self.hps.data.hop_length,
198
+ **self.hps.model,
199
+ ).eval()
200
+ root_logger = logging.getLogger()
201
+ previous_level = root_logger.level
202
+ try:
203
+ root_logger.setLevel(logging.WARNING)
204
+ utils.load_checkpoint(str(spec.checkpoint), self.model, None)
205
+ finally:
206
+ root_logger.setLevel(previous_level)
207
+ self.device = torch.device("cpu")
208
+ self.sample_rate = int(self.hps.data.sampling_rate)
209
+ self.lock = threading.Lock()
210
+
211
+ def tokens(self, text: str) -> tuple[torch.Tensor, torch.Tensor]:
212
+ phonemes = run_vits_frontend(text).phoneme_text
213
+ sequence = cleaned_text_to_sequence(phonemes)
214
+ if self.hps.data.add_blank:
215
+ sequence = commons.intersperse(sequence, 0)
216
+ if not sequence:
217
+ raise ValueError("The phoneme frontend produced no speakable tokens.")
218
+ tokens = torch.LongTensor(sequence).to(self.device).unsqueeze(0)
219
+ lengths = torch.LongTensor([tokens.size(1)]).to(self.device)
220
+ return tokens, lengths
221
+
222
+ @torch.inference_mode()
223
+ def synthesize(
224
+ self,
225
+ text: str,
226
+ speed: float,
227
+ variation: float,
228
+ pitch_steps: float,
229
+ seed: int,
230
+ ) -> tuple[int, np.ndarray]:
231
+ chunks = split_text(text)
232
+ waveforms: list[np.ndarray] = []
233
+ with self.lock:
234
+ self.device = torch.device("cuda")
235
+ self.model.to(self.device)
236
+ try:
237
+ for index, chunk in enumerate(chunks):
238
+ if index:
239
+ waveforms.append(
240
+ np.zeros(
241
+ round(self.sample_rate * boundary_pause_seconds(chunks[index - 1])),
242
+ dtype=np.float32,
243
+ )
244
+ )
245
+ tokens, lengths = self.tokens(chunk)
246
+ torch.manual_seed(seed + index)
247
+ torch.cuda.manual_seed_all(seed + index)
248
+ waveform = self.model.infer(
249
+ tokens,
250
+ lengths,
251
+ noise_scale=variation,
252
+ noise_scale_w=0.8,
253
+ length_scale=1.0 / speed,
254
+ max_len=4000,
255
+ )[0][0, 0].float().cpu().numpy()
256
+ waveforms.append(edge_fade(waveform, self.sample_rate))
257
+ finally:
258
+ self.model.to("cpu")
259
+ self.device = torch.device("cpu")
260
+ torch.cuda.empty_cache()
261
  audio = np.concatenate(waveforms)
262
  if abs(pitch_steps) >= 0.01:
263
  audio = pitch_shift_speech(audio, pitch_steps)
264
  return self.sample_rate, pcm16(audio)
265
+
266
+
267
+ ENGINES = {label: Engine(spec) for label, spec in SPECS.items()}
268
+
269
+
270
+ def validate(
271
+ text: str,
272
+ speed: float,
273
+ variation: float,
274
+ pitch_steps: float,
275
+ seed: int,
276
+ ) -> tuple[str, float, float, float, int]:
277
+ text = " ".join((text or "").split())
278
+ if not text:
279
+ raise gr.Error("Enter something for Inflect to say.")
280
+ return text, float(speed), float(variation), float(pitch_steps), int(seed)
281
+
282
+
283
+ def render_wav(sample_rate: int, samples: np.ndarray) -> FileData:
284
+ """Persist PCM16 samples to a WAV and return a Gradio FileData handle.
285
+
286
+ The return-type annotation is what tells gradio.Server which output
287
+ component to serialize, so the JS client receives a {url, ...} blob
288
+ rather than the function's return value being dropped.
289
+ """
290
+ handle, out_path = tempfile.mkstemp(suffix=".wav", prefix="inflect_")
291
+ os.close(handle)
292
+ sf.write(out_path, samples, sample_rate, subtype="PCM_16")
293
+ return FileData(path=out_path)
294
+
295
+
296
+ @app.api(concurrency_limit=1)
297
+ @spaces.GPU(duration=120)
298
+ def synthesize(
299
+ text: str,
300
+ model_name: str,
301
+ speed: float,
302
+ variation: float,
303
+ pitch_steps: float,
304
+ seed: int,
305
+ ) -> tuple[FileData, str]:
306
+ text, speed, variation, pitch_steps, seed = validate(
307
+ text, speed, variation, pitch_steps, seed
308
+ )
309
+ engine = ENGINES[model_name]
310
+ started = time.perf_counter()
311
+ sample_rate, samples = engine.synthesize(text, speed, variation, pitch_steps, seed)
312
+ seconds = len(samples) / sample_rate
313
+ wall = time.perf_counter() - started
314
+ rtf = wall / seconds
315
+ chunks = len(split_text(text))
316
+ status = (
317
+ f"**{model_name}** · fixed English voice \n"
318
+ f"`{engine.spec.params}` parameters · `{seconds:.2f}s` audio · "
319
+ f"`{wall:.2f}s` generation · `RTF {rtf:.3f}` · "
320
+ f"`{len(text)}` characters · `{chunks}` chunk{'s' if chunks != 1 else ''} · "
321
+ f"pitch `{pitch_steps:+.2f} st` · "
322
+ f"seed `{seed}` · weights `{engine.spec.revision}`"
323
+ )
324
+ return render_wav(sample_rate, samples), status
325
+
326
+
327
+ @app.api(concurrency_limit=1)
328
+ @spaces.GPU(duration=120)
329
+ def compare(
330
+ text: str,
331
+ speed: float,
332
+ variation: float,
333
+ pitch_steps: float,
334
+ seed: int,
335
+ ) -> tuple[FileData, FileData, str]:
336
+ text, speed, variation, pitch_steps, seed = validate(
337
+ text, speed, variation, pitch_steps, seed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  )
339
+ started = time.perf_counter()
340
+ micro_sr, micro_pcm = ENGINES["Inflect Micro v2"].synthesize(
341
+ text, speed, variation, pitch_steps, seed
342
+ )
343
+ micro_wall = time.perf_counter() - started
344
+ started = time.perf_counter()
345
+ nano_sr, nano_pcm = ENGINES["Inflect Nano v2"].synthesize(
346
+ text, speed, variation, pitch_steps, seed
347
+ )
348
+ nano_wall = time.perf_counter() - started
349
+ micro_seconds = len(micro_pcm) / micro_sr
350
+ nano_seconds = len(nano_pcm) / nano_sr
351
+ status = (
352
+ f"Same text and seed `{seed}` · "
353
+ f"Micro `{micro_wall:.2f}s / {micro_seconds:.2f}s audio` · "
354
+ f"Nano `{nano_wall:.2f}s / {nano_seconds:.2f}s audio`"
355
+ )
356
+ return render_wav(micro_sr, micro_pcm), render_wav(nano_sr, nano_pcm), status
357
+
358
+
359
+ @app.middleware("http")
360
+ async def _cross_origin_isolation(request, call_next):
361
+ """Enable crossOriginIsolated so the browser ONNX runtime can use WebGPU.
362
+
363
+ COEP `credentialless` (rather than `require-corp`) keeps the Gradio JS
364
+ client module from the CDN loadable while still enabling shared memory.
365
+ """
366
+ response = await call_next(request)
367
+ response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
368
+ response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
369
+ return response
370
+
371
+
372
+ @app.get(STATIC_URL + "/{path:path}")
373
+ async def serve_web_runtime(path: str):
374
+ full = (WEB_RUNTIME / path).resolve()
375
+ if not full.is_file():
376
+ return HTMLResponse("Not found", status_code=404)
377
+ return FileResponse(str(full))
378
+
379
+
380
+ @app.get("/")
381
+ async def homepage():
382
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
383
+ with open(html_path, "r", encoding="utf-8") as f:
384
+ return HTMLResponse(f.read())
385
+
386
+
387
+ if __name__ == "__main__":
388
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,842 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Inflect v2 · 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:#000000;--bg-2:#08090a;
12
+ --surface:#16181a;--surface-2:#1c1e20;--inset:#101213;
13
+ --well:#0c0d0e;
14
+ --ink:#f5f5f7;--muted:#98989d;--dim:#6c6c70;
15
+ --line:#2a2c2e;--line-soft:#232527;--line-faint:#1d1f21;
16
+ --accent:#0a84ff;--accent-press:#3a9bff;--accent-glow:rgba(10,132,255,.4);
17
+ --teal:#30d4c2;--amber:#ff9f0a;--green:#30d158;--warn:#ff453a;
18
+ --shadow:0 1px 3px rgba(0,0,0,.4),0 8px 28px rgba(0,0,0,.5);
19
+ --shadow-lg:0 2px 8px rgba(0,0,0,.45),0 24px 60px rgba(0,0,0,.6);
20
+ --ring:0 0 0 4px rgba(10,132,255,.22);
21
+ }
22
+ *{box-sizing:border-box}
23
+ html,body{margin:0;padding:0}
24
+ body{
25
+ background:
26
+ radial-gradient(90% 60% at 50% -8%,rgba(10,132,255,.08),transparent 60%),
27
+ var(--bg);
28
+ color:var(--ink);
29
+ font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display",Inter,ui-sans-serif,"Segoe UI",sans-serif;
30
+ line-height:1.5;-webkit-font-smoothing:antialiased;letter-spacing:-.003em;
31
+ min-height:100vh;display:flex;flex-direction:column;
32
+ }
33
+ .mono{font-family:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace}
34
+
35
+ /* ---- top bar ---- */
36
+ .topbar{
37
+ display:flex;align-items:center;gap:16px;padding:13px clamp(16px,4vw,40px);
38
+ background:rgba(0,0,0,.62);backdrop-filter:saturate(180%) blur(20px);
39
+ border-bottom:1px solid var(--line-soft);position:sticky;top:0;z-index:20;
40
+ }
41
+ .brand{display:flex;align-items:center;gap:11px}
42
+ .logo{width:32px;height:32px;border-radius:9px;flex-shrink:0;background:linear-gradient(140deg,#2bd4c4,#0a7a6e);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(43,212,196,.35)}
43
+ .logo svg{width:17px;height:17px}
44
+ .brand .name{font-weight:600;font-size:15px;letter-spacing:-.01em;line-height:1.05}
45
+ .brand .name small{display:block;font-weight:400;font-size:11px;color:var(--muted);margin-top:1px}
46
+ .spacer{flex:1}
47
+ .meters{display:flex;align-items:center;gap:16px}
48
+ .meter{display:flex;flex-direction:column;gap:2px;align-items:flex-end}
49
+ .meter .lab{font:600 9px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
50
+ .meter .val{font:600 12px/1 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums}
51
+ .meter .val.accent{color:var(--teal)}
52
+ .led{width:9px;height:9px;border-radius:50%;background:var(--dim);transition:background .2s,box-shadow .2s}
53
+ .led.on{background:var(--teal);box-shadow:0 0 10px rgba(48,212,194,.8)}
54
+ .led.busy{background:var(--accent);box-shadow:0 0 10px var(--accent-glow);animation:pulse 1s infinite}
55
+ @keyframes pulse{50%{opacity:.4}}
56
+ .segctrl{display:inline-flex;background:rgba(120,120,128,.18);border-radius:10px;padding:2px;gap:2px}
57
+ .segctrl button{appearance:none;border:0;background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:13px;font-weight:550;padding:6px 16px;border-radius:8px;transition:color .18s,background .18s,box-shadow .18s}
58
+ .segctrl button.active{background:var(--surface-2);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.4),0 0 0 1px var(--line)}
59
+ .link{color:var(--muted);font-size:13px;font-weight:450;text-decoration:none;padding:7px 9px;border-radius:8px;transition:color .15s,background .15s}
60
+ .link:hover{color:var(--ink);background:rgba(255,255,255,.06)}
61
+
62
+ /* ---- shell ---- */
63
+ .shell{flex:1;display:flex;flex-direction:column;min-height:0}
64
+ .pane{display:none;flex:1;min-height:0;flex-direction:column}
65
+ .pane.active{display:flex}
66
+ .stage-scroll{flex:1;overflow-y:auto}
67
+ .page{max-width:820px;margin:0 auto;padding:28px clamp(16px,4vw,40px) 80px;width:100%}
68
+
69
+ /* ---- cards ---- */
70
+ .card{background:var(--surface);border:1px solid var(--line-soft);border-radius:18px;box-shadow:var(--shadow);overflow:hidden}
71
+ .card+.card{margin-top:16px}
72
+ .card-pad{padding:20px 22px}
73
+ .card-head{display:flex;align-items:center;gap:11px;padding:15px 20px;border-bottom:1px solid var(--line-soft);background:linear-gradient(180deg,rgba(255,255,255,.02),transparent)}
74
+ .card-head .tag{font:600 10px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
75
+ .card-head .ic{width:26px;height:26px;border-radius:7px;background:var(--inset);border:1px solid var(--line);display:flex;align-items:center;justify-content:center;color:var(--teal)}
76
+ .card-head .grow{flex:1}
77
+
78
+ .label{font-size:13px;font-weight:600;color:var(--ink);margin-bottom:9px;display:block;letter-spacing:-.01em}
79
+ textarea,input[type="text"],input[type="number"]{
80
+ width:100%;background:var(--well);color:var(--ink);border:1px solid var(--line);border-radius:12px;
81
+ padding:14px 15px;font-size:16px;font-family:inherit;outline:none;transition:border-color .15s,box-shadow .15s;line-height:1.5;
82
+ }
83
+ textarea::placeholder,input::placeholder{color:var(--dim)}
84
+ textarea:focus,input:focus{border-color:var(--accent);box-shadow:var(--ring)}
85
+ textarea{min-height:108px;resize:vertical}
86
+ .examples{display:flex;flex-wrap:wrap;gap:8px;margin-top:11px}
87
+ .examples button{background:var(--inset);border:1px solid var(--line-soft);border-radius:100px;color:var(--muted);font-size:12px;padding:6px 13px;cursor:pointer;transition:all .15s;font-family:inherit}
88
+ .examples button:hover{color:var(--teal);border-color:var(--teal);background:rgba(48,212,194,.08)}
89
+
90
+ /* segmented (model) */
91
+ .segmented{display:flex;background:var(--inset);border:1px solid var(--line-soft);border-radius:12px;padding:3px;gap:3px}
92
+ .segmented label{flex:1;position:relative}
93
+ .segmented input{position:absolute;opacity:0;width:0;height:0}
94
+ .segmented label span{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:10px 10px;border-radius:9px;cursor:pointer;transition:background .15s,box-shadow .15s;text-align:center}
95
+ .segmented label strong{font-size:13px;font-weight:550;color:var(--muted);transition:color .15s}
96
+ .segmented label small{font-size:11px;color:var(--dim);margin-top:2px;font-weight:400;transition:color .15s}
97
+ .segmented label input:checked+span{background:var(--surface-2);box-shadow:0 0 0 1px var(--line),0 1px 3px rgba(0,0,0,.4)}
98
+ .segmented label input:checked+span strong{color:var(--ink)}
99
+ .segmented label input:checked+span small{color:var(--muted)}
100
+
101
+ /* grouped rows */
102
+ .group{background:var(--inset);border:1px solid var(--line-soft);border-radius:12px;overflow:hidden}
103
+ .row{padding:14px 16px;display:flex;flex-direction:column;gap:10px}
104
+ .row+.row{border-top:1px solid var(--line-faint)}
105
+ .row .top{display:flex;justify-content:space-between;align-items:center}
106
+ .row .top label{font-size:15px;font-weight:500;color:var(--ink);margin:0}
107
+ .row .top .val{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;font-weight:500}
108
+ input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:4px;background:var(--line);border-radius:4px;outline:none}
109
+ input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:26px;height:26px;border-radius:50%;background:var(--surface-2);cursor:pointer;border:0;box-shadow:0 0 0 1px var(--line),0 2px 8px rgba(0,0,0,.5);transition:transform .1s}
110
+ input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.08)}
111
+ input[type="range"]::-webkit-slider-thumb:active{transform:scale(1.12);box-shadow:0 0 0 1px var(--accent),var(--ring)}
112
+ input[type="range"]::-moz-range-thumb{width:22px;height:22px;border:0;border-radius:50%;background:var(--surface-2);cursor:pointer;box-shadow:0 0 0 1px var(--line),0 2px 8px rgba(0,0,0,.5)}
113
+ .row small{font-size:12px;color:var(--muted);margin-top:-2px;line-height:1.4}
114
+
115
+ /* advanced disclosure */
116
+ details.adv{margin-top:16px;border:1px solid var(--line-soft);border-radius:12px;background:var(--surface-2);overflow:hidden}
117
+ details.adv summary{list-style:none;padding:14px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;font-size:15px;font-weight:500;color:var(--ink)}
118
+ details.adv summary::-webkit-details-marker{display:none}
119
+ details.adv summary .chev{margin-left:auto;color:var(--muted);transition:transform .22s ease;font-size:13px}
120
+ details.adv[open] summary .chev{transform:rotate(90deg)}
121
+ details.adv summary .dot-i{width:7px;height:7px;border-radius:50%;background:var(--dim)}
122
+ details.adv .body{padding:0 16px 16px;display:grid;grid-template-columns:1fr;gap:0}
123
+ details.adv .body .group{border:1px solid var(--line-soft)}
124
+
125
+ /* actions */
126
+ .actions{display:flex;gap:12px;margin-top:18px;align-items:stretch}
127
+ .btn{display:flex;align-items:center;justify-content:center;gap:9px;border-radius:14px;cursor:pointer;font:inherit;font-size:16px;font-weight:560;border:0;transition:background .15s,transform .05s,box-shadow .15s}
128
+ .btn:active{transform:scale(.985)}
129
+ .btn:disabled{opacity:.4;cursor:not-allowed}
130
+ .btn.primary{flex:1;min-height:52px;background:var(--accent);color:#fff;box-shadow:0 1px 2px rgba(10,132,255,.4),0 6px 20px rgba(10,132,255,.3)}
131
+ .btn.primary:hover{background:var(--accent-press)}
132
+ .btn.secondary{background:var(--surface-2);color:var(--ink);min-height:52px;padding:0 20px;border:1px solid var(--line)}
133
+ .btn.secondary:hover{background:#26292b}
134
+ .kbd{font-size:11px;font-weight:500;opacity:.75;padding:2px 6px;border-radius:5px;background:rgba(255,255,255,.16)}
135
+
136
+ /* status */
137
+ .status{margin-top:14px;font-size:13px;color:var(--muted);line-height:1.55;min-height:1.2em;display:flex;align-items:center;gap:9px}
138
+ .status .dot{width:8px;height:8px;border-radius:50%;background:var(--dim);flex-shrink:0;transition:background .2s,box-shadow .2s}
139
+ .status.ok .dot{background:var(--green);box-shadow:0 0 0 3px rgba(48,209,88,.2)}
140
+ .status.busy .dot{background:var(--accent);animation:pulse 1s infinite}
141
+ .status.err .dot{background:var(--warn);box-shadow:0 0 0 3px rgba(255,69,58,.2)}
142
+ .status .txt{min-width:0}
143
+ .status .txt b{color:var(--ink);font-weight:600}
144
+ .status .txt code{font-family:var(--mono);font-size:12px;background:var(--inset);padding:1px 5px;border-radius:5px;color:var(--muted)}
145
+ .queue{font-size:12px;color:var(--accent);white-space:nowrap}
146
+ .busy-spin{display:inline-block;width:13px;height:13px;border:2.5px solid var(--accent);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
147
+ @keyframes spin{to{transform:rotate(360deg)}}
148
+
149
+ /* ---- player ---- */
150
+ .player{display:none}
151
+ .player.show{display:block;animation:rise .42s cubic-bezier(.22,1,.36,1)}
152
+ @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
153
+ .np{display:flex;align-items:center;gap:13px;margin-bottom:16px}
154
+ .np-badge{width:44px;height:44px;border-radius:11px;flex-shrink:0;background:linear-gradient(140deg,#34d8c8,#0a7a6e);display:flex;align-items:center;justify-content:center;color:#fff;box-shadow:0 3px 12px rgba(14,143,130,.45);transition:box-shadow .25s}
155
+ .np-badge.playing{box-shadow:0 3px 12px rgba(14,143,130,.6),0 0 0 5px rgba(48,212,196,.18)}
156
+ .np-text{min-width:0}
157
+ .np-text .title{font-size:16px;font-weight:600;letter-spacing:-.01em}
158
+ .np-text .sub{font-size:12.5px;color:var(--muted);font-variant-numeric:tabular-nums}
159
+
160
+ .scrub-row{display:flex;align-items:center;gap:13px;margin-bottom:12px}
161
+ .tc{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:38px;text-align:center;font-weight:500}
162
+ .tc.now{color:var(--accent)}
163
+ .wave{position:relative;flex:1;min-width:0;height:72px;background:var(--well);border:1px solid var(--line-faint);border-radius:10px;overflow:hidden;cursor:pointer}
164
+ .wave canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
165
+ .wave .ph{position:absolute;top:0;bottom:0;width:2px;background:var(--accent);box-shadow:0 0 10px var(--accent-glow);pointer-events:none;transform:translateX(-1px);border-radius:2px}
166
+ .wave .center{position:absolute;left:0;right:0;top:50%;height:1px;background:var(--line-faint);pointer-events:none}
167
+ .seek{width:100%}
168
+
169
+ .tctrl{display:flex;align-items:center;gap:11px;margin-bottom:6px}
170
+ .tbtn{width:30px;height:30px;border-radius:50%;border:0;background:transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:color .15s,background .15s;font-size:14px}
171
+ .tbtn:hover{color:var(--ink);background:var(--inset)}
172
+ .tbtn:disabled{opacity:.35;cursor:not-allowed}
173
+ .tbtn.play{width:50px;height:50px;background:var(--accent);color:#fff;font-size:17px;box-shadow:0 2px 10px var(--accent-glow)}
174
+ .tbtn.play:hover{background:var(--accent-press)}
175
+ .tbtn.play:disabled{background:var(--line);color:var(--dim);box-shadow:none}
176
+
177
+ /* meters */
178
+ .meterbars{display:flex;gap:4px;align-items:flex-end;height:30px;margin-top:6px;padding:0 2px}
179
+ .meterbars .bar{flex:1;background:var(--line);border-radius:2px;min-height:3px;transition:height .06s linear,background .1s}
180
+ .meterbars .bar.lev{background:linear-gradient(180deg,var(--teal),#0a7a6e)}
181
+ .meterbars .bar.peak{background:var(--amber)}
182
+
183
+ /* karaoke */
184
+ .script{margin-top:16px;padding:16px 4px 4px;font-size:19px;line-height:1.85;letter-spacing:.001em;color:var(--muted);max-width:680px}
185
+ .script .empty{font-size:14px;color:var(--dim)}
186
+ .karaoke .chunk{display:inline;color:#5c5d61;transition:color .25s ease,background-color .18s ease;border-radius:3px;padding:0 1px}
187
+ .karaoke .chunk.spoken{color:#8a8b90}
188
+ .karaoke .chunk.speaking{color:var(--ink)}
189
+ .karaoke .chunk.speaking::after{content:"";display:inline-block;width:3px;height:1.1em;background:var(--accent);margin-left:2px;vertical-align:-3px;border-radius:1px;animation:blink 1.05s steps(2) infinite}
190
+ @keyframes blink{50%{opacity:.25}}
191
+
192
+ /* tools */
193
+ .tools{display:flex;align-items:center;gap:14px;margin-top:14px;flex-wrap:wrap}
194
+ a.dl{display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:100px;background:var(--surface-2);color:var(--ink);font-size:13px;font-weight:500;text-decoration:none;border:1px solid var(--line);transition:all .15s}
195
+ a.dl:hover{background:rgba(10,132,255,.12);color:var(--accent);border-color:var(--accent)}
196
+ .chip{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
197
+
198
+ /* compare */
199
+ .cmp-wrap{display:none}
200
+ .cmp-wrap.show{display:block;animation:rise .42s cubic-bezier(.22,1,.36,1)}
201
+ .cmp-title{font-size:13px;font-weight:600;color:var(--muted);margin:30px 0 12px;display:flex;align-items:center;gap:9px}
202
+ .cmp-title .line{flex:1;height:1px;background:var(--line-soft)}
203
+ .compare-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
204
+ .track{background:var(--surface);border:1px solid var(--line-soft);border-radius:16px;box-shadow:var(--shadow);overflow:hidden}
205
+ .track .th{padding:14px 16px 0;display:flex;align-items:center;gap:9px}
206
+ .track .th .dot{width:9px;height:9px;border-radius:50%}
207
+ .track .th .nm{font-size:14px;font-weight:600}
208
+ .track .th .nm small{display:block;font-size:11px;color:var(--muted);font-weight:400;margin-top:1px}
209
+ .track .tb{padding:10px 16px 16px}
210
+ .mini-wave{position:relative;height:50px;background:var(--well);border:1px solid var(--line-faint);border-radius:8px;overflow:hidden;cursor:pointer;margin-bottom:10px}
211
+ .mini-wave canvas{position:absolute;inset:0;width:100%;height:100%}
212
+ .mini-wave .ph{position:absolute;top:0;bottom:0;width:2px;background:var(--accent);pointer-events:none;transform:translateX(-1px)}
213
+ .mini-ctrl{display:flex;align-items:center;gap:10px}
214
+ .mini-play{width:34px;height:34px;border-radius:50%;border:0;background:var(--accent);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .1s;font-size:12px;box-shadow:0 2px 8px var(--accent-glow)}
215
+ .mini-play:hover{background:var(--accent-press)}
216
+ .mini-play:active{transform:scale(.94)}
217
+ .mini-play:disabled{background:var(--line);box-shadow:none;cursor:not-allowed}
218
+ .mini-tc{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
219
+ .mini-tc .now{color:var(--accent)}
220
+ .mini-seek{flex:1;min-width:0}
221
+ .mini-dl{font-size:12px;padding:6px 11px;border-radius:100px}
222
+
223
+ /* browser pane */
224
+ .browser-pane{flex:1;display:flex;flex-direction:column;min-height:0}
225
+ .browser-head{padding:20px clamp(16px,4vw,40px) 0;max-width:820px;margin:0 auto;width:100%}
226
+ .browser-head h2{margin:0 0 5px;font-size:17px;font-weight:600}
227
+ .browser-head p{margin:0;color:var(--muted);font-size:13px;max-width:640px;line-height:1.5}
228
+ .browser-frame-shell{flex:1;min-height:0;margin:16px clamp(16px,4vw,40px);border:1px solid var(--line-soft);border-radius:18px;background:#000;overflow:hidden;box-shadow:var(--shadow-lg)}
229
+ .browser-frame{display:block;width:100%;height:580px;border:0;background:#000}
230
+ .browser-help{margin:0 clamp(16px,4vw,40px) 28px;color:var(--muted);font-size:12px;line-height:1.55;max-width:820px;margin-left:auto;margin-right:auto}
231
+
232
+ .fineprint{margin-top:30px;font-size:11.5px;color:var(--dim);line-height:1.6;text-align:center;max-width:560px;margin-left:auto;margin-right:auto}
233
+ .hidden{display:none!important}
234
+
235
+ @media(max-width:680px){
236
+ .page{padding:18px 14px 56px}
237
+ .meters{display:none}
238
+ .compare-grid{grid-template-columns:1fr}
239
+ details.adv .body{grid-template-columns:1fr}
240
+ .browser-frame{height:1000px}
241
+ .actions{flex-direction:column}
242
+ .btn.secondary{padding:0 20px;min-height:48px}
243
+ .card-pad{padding:18px 16px}
244
+ .script{font-size:17px;line-height:1.8}
245
+ .tctrl{flex-wrap:wrap}
246
+ a.dl{margin-left:0}
247
+ .np-badge{width:40px;height:40px}
248
+ }
249
+ @media(max-width:520px){
250
+ .topbar{padding:11px 14px;gap:10px}
251
+ .brand .name small{display:none}
252
+ .link:not(.always){display:none}
253
+ .segctrl button{padding:6px 12px;font-size:12px}
254
+ .scrub-row{flex-wrap:nowrap;gap:9px}
255
+ .wave{height:56px}
256
+ .tc{min-width:34px;font-size:11px}
257
+ .tbtn{width:28px;height:28px}
258
+ .tbtn.play{width:46px;height:46px}
259
+ .mini-seek{display:none}
260
+ .mini-tc{font-size:11px}
261
+ .meterbars{height:24px}
262
+ }
263
+ @media(max-width:380px){
264
+ .brand .name{font-size:14px}
265
+ .logo{width:28px;height:28px}
266
+ .segctrl button{padding:6px 10px}
267
+ }
268
+ </style>
269
+ </head>
270
+ <body>
271
+
272
+ <header class="topbar">
273
+ <div class="brand">
274
+ <div class="logo" aria-hidden="true">
275
+ <svg viewBox="0 0 24 24" fill="none"><path d="M9 18V6l10-2v12" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="18" r="2.4" fill="#fff"/><circle cx="17" cy="16" r="2.4" fill="#fff"/></svg>
276
+ </div>
277
+ <div class="name">Inflect v2<small>speech studio</small></div>
278
+ </div>
279
+ <div class="spacer"></div>
280
+ <div class="meters">
281
+ <div class="meter"><span class="lab">Rate</span><span class="val" id="m-sr">24 kHz</span></div>
282
+ <div class="meter"><span class="lab">RTF</span><span class="val accent" id="m-rtf">—</span></div>
283
+ <div class="led" id="status-led" title="Idle"></div>
284
+ </div>
285
+ <div class="segctrl" role="tablist">
286
+ <button class="active" id="tab-hosted" role="tab">Hosted</button>
287
+ <button id="tab-browser" role="tab">Browser</button>
288
+ </div>
289
+ <a class="link always" href="https://github.com/owenawsong/Inflect" target="_blank" rel="noopener">GitHub</a>
290
+ </header>
291
+
292
+ <div class="shell">
293
+ <!-- HOSTED -->
294
+ <section id="pane-hosted" class="pane active">
295
+ <div class="stage-scroll">
296
+ <div class="page">
297
+
298
+ <div class="card">
299
+ <div class="card-head"><span class="ic"><svg width="15" height="15" 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" fill="currentColor"/><circle cx="17" cy="16" r="2" fill="currentColor"/></svg></span><span class="tag">Script</span></div>
300
+ <div class="card-pad">
301
+ <label class="label" for="text">What should Inflect say?</label>
302
+ <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>
303
+ <div class="examples" id="examples"></div>
304
+ </div>
305
+ </div>
306
+
307
+ <div class="card">
308
+ <div class="card-head"><span class="ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M7 10c2-1.5 8-1.5 10 0M8 13c1.6-1 6.4-1 8 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span><span class="tag">Voice &amp; Delivery</span></div>
309
+ <div class="card-pad">
310
+ <label class="label">Voice model</label>
311
+ <div class="segmented" id="model-seg">
312
+ <label><input type="radio" name="model" value="Inflect Micro v2" checked><span><strong>Micro v2</strong><small>Higher quality · 9.36M</small></span></label>
313
+ <label><input type="radio" name="model" value="Inflect Nano v2"><span><strong>Nano v2</strong><small>Lighter · 3.96M</small></span></label>
314
+ </div>
315
+ <details class="adv">
316
+ <summary><span class="dot-i"></span><span>Advanced</span><span class="chev">▸</span></summary>
317
+ <div class="body">
318
+ <div class="group">
319
+ <div class="row">
320
+ <div class="top"><label for="speed">Speaking speed</label><span class="val" id="speed-val">1.00×</span></div>
321
+ <input type="range" id="speed" min="0.7" max="1.35" step="0.01" value="1.0">
322
+ </div>
323
+ <div class="row">
324
+ <div class="top"><label for="variation">Delivery variation</label><span class="val" id="variation-val">Steady</span></div>
325
+ <input type="range" id="variation" min="0" max="1" step="0.001" value="0.667">
326
+ <small>Lower is steadier; higher gives each take more character.</small>
327
+ </div>
328
+ <div class="row">
329
+ <div class="top"><label for="pitch">Pitch shift</label><span class="val" id="pitch-val">0 st</span></div>
330
+ <input type="range" id="pitch" min="-2" max="2" step="0.25" value="0">
331
+ </div>
332
+ <div class="row">
333
+ <div class="top"><label for="seed">Repeatable seed</label><span class="val" id="seed-val">0</span></div>
334
+ <input type="number" id="seed" value="0" step="1" inputmode="numeric">
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </details>
339
+ <div class="actions">
340
+ <button class="btn primary" id="generate">Generate<span class="kbd">⌘↵</span></button>
341
+ <button class="btn secondary" id="compare-btn">Compare models</button>
342
+ </div>
343
+ <div class="status" id="status"><span class="dot"></span><span class="txt" id="status-txt">Ready.</span><span class="queue hidden" id="queue"></span></div>
344
+ </div>
345
+ </div>
346
+
347
+ <!-- player -->
348
+ <div class="card player" id="player">
349
+ <div class="card-head"><span class="ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M4 12h3l3-7 4 14 3-7h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="tag">Playback</span><div class="grow"></div><span class="chip" id="take-spec">—</span></div>
350
+ <div class="card-pad">
351
+ <div class="np">
352
+ <div class="np-badge" id="np-badge"><svg width="19" height="19" 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></div>
353
+ <div class="np-text"><div class="title" id="np-title">Inflect Micro v2</div><div class="sub" id="np-sub">—</div></div>
354
+ </div>
355
+ <div class="scrub-row">
356
+ <span class="tc now" id="now">0:00</span>
357
+ <div class="wave" id="wave"><canvas id="wave-canvas"></canvas><div class="center"></div><div class="ph" id="playhead" style="left:0%"></div></div>
358
+ <span class="tc" id="dur">0:00</span>
359
+ </div>
360
+ <input type="range" class="seek" id="seek" min="0" max="1000" value="0" disabled aria-label="Seek">
361
+ <div class="tctrl">
362
+ <button class="tbtn play" id="play" title="Play" disabled>▶</button>
363
+ <button class="tbtn" id="restart" title="Restart" disabled>⏮</button>
364
+ <button class="tbtn" id="stop" title="Stop" disabled>⏹</button>
365
+ <div style="flex:1"></div>
366
+ <a class="dl" id="download" download="inflect_v2.wav">Download</a>
367
+ </div>
368
+ <div class="meterbars" id="meters" aria-hidden="true"></div>
369
+ <div class="script"><div class="karaoke fade" id="karaoke"><div class="empty">Your text will appear here, highlighting as it's spoken.</div></div></div>
370
+ </div>
371
+ </div>
372
+
373
+ <!-- compare (shown after Compare models is used) -->
374
+ <div class="cmp-wrap" id="cmp-wrap">
375
+ <div class="cmp-title"><span>Model comparison</span><span class="line"></span><span class="chip" id="cmp-chip">same text · same seed</span></div>
376
+ <div class="compare-grid">
377
+ <div class="track">
378
+ <div class="th"><span class="dot" style="background:#30d4c2"></span><span class="nm">Micro v2<small>9.36M · quality first</small></span><div style="flex:1"></div><a class="dl mini-dl" id="micro-dl" download="inflect_micro.wav">WAV</a></div>
379
+ <div class="tb">
380
+ <div class="mini-wave" id="micro-wave"><canvas id="micro-canvas"></canvas><div class="ph" id="micro-ph" style="left:0%"></div></div>
381
+ <div class="mini-ctrl">
382
+ <button class="mini-play" data-mini="micro" disabled>▶</button>
383
+ <span class="mini-tc"><span class="now" id="micro-now">0:00</span> / <span id="micro-dur">0:00</span></span>
384
+ <input type="range" id="micro-seek" class="mini-seek" min="0" max="1000" value="0" disabled aria-label="Micro seek">
385
+ </div>
386
+ </div>
387
+ </div>
388
+ <div class="track">
389
+ <div class="th"><span class="dot" style="background:#ff9f0a"></span><span class="nm">Nano v2<small>3.96M · footprint first</small></span><div style="flex:1"></div><a class="dl mini-dl" id="nano-dl" download="inflect_nano.wav">WAV</a></div>
390
+ <div class="tb">
391
+ <div class="mini-wave" id="nano-wave"><canvas id="nano-canvas"></canvas><div class="ph" id="nano-ph" style="left:0%"></div></div>
392
+ <div class="mini-ctrl">
393
+ <button class="mini-play" data-mini="nano" disabled>▶</button>
394
+ <span class="mini-tc"><span class="now" id="nano-now">0:00</span> / <span id="nano-dur">0:00</span></span>
395
+ <input type="range" id="nano-seek" class="mini-seek" min="0" max="1000" value="0" disabled aria-label="Nano seek">
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ </div>
401
+
402
+ <p class="fineprint">Fixed English voice · English-only · no voice cloning or reference audio. Uncommon names and long passages can still stumble.</p>
403
+ </div>
404
+ </div>
405
+ </section>
406
+
407
+ <!-- BROWSER -->
408
+ <section id="pane-browser" class="pane">
409
+ <div class="browser-pane">
410
+ <div class="browser-head">
411
+ <h2>Run it in your browser</h2>
412
+ <p>Your text and audio stay on this device. WebGPU is preferred; compatible browsers fall back to WASM. No queue, no server quota.</p>
413
+ </div>
414
+ <div class="browser-frame-shell">
415
+ <iframe class="browser-frame" id="webgpu-frame" src="/web_runtime/index.html?embed=1" title="Inflect browser-local runtime" allow="autoplay; webgpu" loading="eager"></iframe>
416
+ </div>
417
+ <p class="browser-help">The first run downloads the selected ONNX model and compiles it on your device. Streaming and complete-audio modes are both available inside this panel.</p>
418
+ </div>
419
+ </section>
420
+ </div>
421
+
422
+ <script type="module">
423
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
424
+
425
+ const $ = (id) => document.getElementById(id);
426
+ 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")}`; };
427
+ const variationLabel = (v) => v < 0.2 ? "Very steady" : v < 0.45 ? "Steady" : v < 0.75 ? "Natural" : v < 0.92 ? "Expressive" : "Very expressive";
428
+
429
+ /* prompts */
430
+ const PROMPTS = [
431
+ "Wait, are you actually being for real? I thought the train left twenty minutes ago!",
432
+ "Does punctuation work? Yes: commas, semicolons, and periods should create sensible boundaries.",
433
+ "Professor Nguyen mailed the fluorescent poster to Reykjavik on Thursday.",
434
+ "Although the weather changed without warning, the musicians finished packing before anyone opened the enormous glass door.",
435
+ ];
436
+ const exEl = $("examples");
437
+ PROMPTS.forEach((p) => { const b=document.createElement("button"); b.type="button"; b.textContent=p.length>42?p.slice(0,40)+"…":p; b.title=p; b.addEventListener("click",()=>{ $("text").value=p; }); exEl.appendChild(b); });
438
+
439
+ /* tabs */
440
+ const setTab = (which) => { const b = which === "browser";
441
+ $("tab-hosted").classList.toggle("active",!b); $("tab-browser").classList.toggle("active",b);
442
+ $("pane-hosted").classList.toggle("active",!b); $("pane-browser").classList.toggle("active",b);
443
+ };
444
+ $("tab-hosted").addEventListener("click",()=>setTab("hosted"));
445
+ $("tab-browser").addEventListener("click",()=>setTab("browser"));
446
+
447
+ /* readouts */
448
+ $("speed").addEventListener("input",()=>{ $("speed-val").textContent=parseFloat($("speed").value).toFixed(2)+"×"; });
449
+ $("speed-val").textContent="1.00×";
450
+ $("variation").addEventListener("input",()=>{ $("variation-val").textContent=variationLabel(parseFloat($("variation").value)); });
451
+ $("variation-val").textContent=variationLabel(parseFloat($("variation").value));
452
+ $("pitch").addEventListener("input",()=>{ $("pitch-val").textContent=parseFloat($("pitch").value).toFixed(2)+" st"; });
453
+ $("pitch-val").textContent="0.00 st";
454
+ $("seed").addEventListener("input",(e)=>{ $("seed-val").textContent=e.target.value||"0"; });
455
+
456
+ /* ---- theme the embedded browser runtime ---- */
457
+ (function(){
458
+ const frame = $("webgpu-frame");
459
+ const THEME = `
460
+ :root{color-scheme:dark}
461
+ html,body{background:#07080a !important;color:#f5f5f7 !important}
462
+ body{background:#07080a !important;color:#f5f5f7 !important;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Inter,ui-sans-serif,sans-serif !important}
463
+ #root{background:#07080a !important;color:#f5f5f7 !important}
464
+ .surface-panel, .bg-white{background:#16181a !important;border-color:#2a2c2e !important}
465
+ .bg-slate-50, .bg-slate-50\\/80, .embedded-sidebar{background:#0e0f10 !important}
466
+ .bg-slate-100{background:#1c1e20 !important}
467
+ .border-slate-200, .border-slate-300{border-color:#2a2c2e !important}
468
+ .border-blue-600{border-color:#0a84ff !important}
469
+ .text-slate-950, .text-slate-900{color:#f5f5f7 !important}
470
+ .text-slate-700{color:#d1d1d6 !important}
471
+ .text-slate-600{color:#98989d !important}
472
+ .text-slate-500{color:#86868b !important}
473
+ .text-slate-400{color:#6c6c70 !important}
474
+ .hover\\:text-blue-600:hover{color:#4d9bff !important}
475
+ .text-blue-600{color:#4d9bff !important}
476
+ .speech-input{background:#0c0d0e !important;border-color:#2a2c2e !important;color:#f5f5f7 !important}
477
+ .speech-input:focus{border-color:#0a84ff !important;box-shadow:0 0 0 4px rgba(10,132,255,.22) !important}
478
+ .speech-input::placeholder{color:#6c6c70 !important}
479
+ textarea, input[type="text"]{background:#0c0d0e !important;color:#f5f5f7 !important;border-color:#2a2c2e !important}
480
+ .model-option-selected{background:rgba(10,132,255,.16) !important;border-color:#0a84ff !important;color:#f5f5f7 !important}
481
+ .segmented-selected{background:#0a84ff !important}
482
+ .h-full.bg-blue-500{background:#0a84ff !important}
483
+ input[type="range"]::-webkit-slider-thumb{background:#1c1e20 !important}
484
+ input[type="range"]{background:#2a2c2e !important}
485
+ :root{
486
+ --dt-bg:#0c0d0e; --dt-surface-selected:#1c2126; --dt-border:#2a2c2e;
487
+ --dt-text:#e5e5ea; --dt-text-muted:#86868b; --dt-accent:#0a84ff;
488
+ --dt-live:#30d158; --dt-paused:#ff9f0a; --dt-tree-hover:#1c1e20;
489
+ --dt-json-string:#30d158; --dt-json-number:#64d2ff; --dt-json-boolean:#ff9f0a;
490
+ --dt-json-null:#86868b; --dt-json-key:#64d2ff; --dt-json-tag:#5ac8fa;
491
+ --dt-json-preview:#86868b; --dt-json-arrow:#86868b; --dt-diff-changed:#64d2ff;
492
+ }
493
+ [style*="#f9fafb"]{background:#0c0d0e !important}
494
+ [style*="#ffffff"]{background:#16181a !important}
495
+ [style*="#f3f4f6"]{background:#0e0f10 !important;border-color:#2a2c2e !important}
496
+ [style*="#e5e7eb"]{border-color:#2a2c2e !important}
497
+ [style*="#111827"]{color:#f5f5f7 !important}
498
+ [style*="#6b7280"]{color:#98989d !important}
499
+ [style*="#dc2626"]{color:#ff453a !important}
500
+ ::-webkit-scrollbar-thumb{background:#2a2c2e !important}
501
+
502
+ /* ADVANCED sections hidden by default; shown when body.studio-adv-open */
503
+ body:not(.studio-adv-open) .voice-controls{display:none !important}
504
+ body:not(.studio-adv-open) .playback-setting{display:none !important}
505
+ body:not(.studio-adv-open) .session-status{display:none !important}
506
+ body:not(.studio-adv-open) .embedded-logs{display:none !important}
507
+ /* Compute backend has no stable class — hide by JS tagging via .studio-adv-section */
508
+ .studio-adv-section{display:none !important}
509
+ body.studio-adv-open .studio-adv-section{display:flex !important}
510
+
511
+ /* compact model picker */
512
+ .model-picker{gap:8px !important}
513
+ .model-options-grid{grid-template-columns:1fr 1fr !important;gap:8px !important}
514
+ .model-option{padding:9px 11px !important;border-radius:10px !important}
515
+ .model-option .text-sm{font-size:13px !important}
516
+ .model-option .text-xs{font-size:11px !important}
517
+ .model-option>div:nth-child(3){display:none !important}
518
+ .model-option>div.mt-3{display:none !important}
519
+ .model-option .h-2.w-2{height:7px !important;width:7px !important}
520
+
521
+ /* kill empty space: stop compose/sidebar from stretching */
522
+ .embedded-compose, .embedded-sidebar{align-self:start !important;flex:0 0 auto !important}
523
+ .embedded-panel{align-items:start !important}
524
+ .embedded-grid{align-items:start !important;gap:0 !important}
525
+ .embedded-compose{gap:14px !important;padding:18px !important}
526
+ .embedded-sidebar{padding:18px !important;gap:14px !important}
527
+ /* hide empty bottom blocks */
528
+ .embedded-compose>div.border-t.border-slate-200.pt-6:empty,
529
+ .embedded-compose>div.border-t.border-slate-200.pt-6:not(:has(*)),
530
+ .embedded-compose>.flex.w-full:empty,
531
+ .embedded-compose>.flex.w-full:not(:has(*)){display:none !important}
532
+ .embedded-compose>.flex.w-full:not(:has(a:not(.hidden)):not(button)){display:none !important}
533
+ .embedded-compose>div:last-child:not(.flex):empty{display:none !important}
534
+ .section-label{font-size:11px !important;letter-spacing:.1em !important;text-transform:uppercase;color:#86868b !important}
535
+ .embedded-compose>div:first-child>h2,
536
+ .embedded-compose .text-lg{font-size:15px !important;margin-top:2px !important}
537
+ .embedded-compose .font-mono.text-\\[11px\\]{display:none !important}
538
+ .embedded-compose .flex.items-end.justify-between{margin-bottom:2px !important}
539
+
540
+ /* Advanced toggle (injected) */
541
+ .studio-adv-toggle{
542
+ appearance:none;border:1px solid #2a2c2e;background:#1c1e20;color:#f5f5f7;
543
+ border-radius:12px;padding:13px 15px;width:100%;display:flex;align-items:center;gap:10px;
544
+ font:inherit;font-size:14px;font-weight:550;cursor:pointer;margin-top:14px;
545
+ }
546
+ .studio-adv-toggle:hover{background:#26292b;border-color:#3a3d40}
547
+ .studio-adv-toggle .dot{width:7px;height:7px;border-radius:50%;background:#6c6c70;flex-shrink:0}
548
+ .studio-adv-toggle .chev{margin-left:auto;color:#98989d;transition:transform .22s ease}
549
+ body.studio-adv-open .studio-adv-toggle .chev{transform:rotate(90deg)}
550
+
551
+ @media(max-width:680px){
552
+ .dt-inspector-pane, .dt-panel, .dt-panel-wide{display:none !important}
553
+ .p-5{padding:1rem !important} .p-4{padding:0.85rem !important}
554
+ .speech-input{min-height:140px !important;font-size:16px !important}
555
+ .embedded-compose{padding:14px !important;gap:12px !important}
556
+ .embedded-sidebar{padding:14px !important;gap:12px !important}
557
+ .studio-adv-toggle{margin-top:12px;padding:12px 13px;font-size:13px}
558
+ }
559
+ `;
560
+ const tag = () => {
561
+ let doc;
562
+ try { doc = frame.contentDocument; } catch(_) { return false; }
563
+ if (!doc || !doc.getElementById("root")) return false;
564
+ if (doc.getElementById("inflect-studio-theme")) return true;
565
+ const s = doc.createElement("style");
566
+ s.id = "inflect-studio-theme";
567
+ s.textContent = THEME;
568
+ (doc.head || doc.documentElement).appendChild(s);
569
+ if (doc.documentElement){ doc.documentElement.style.background="#07080a"; doc.documentElement.style.color="#f5f5f7"; }
570
+ if (doc.body){ doc.body.style.background="#07080a"; doc.body.style.color="#f5f5f7"; }
571
+ return true;
572
+ };
573
+ const tagAdv = () => {
574
+ let doc;
575
+ try { doc = frame.contentDocument; } catch(_) { return; }
576
+ if (!doc) return;
577
+ // tag advanced sections by their label text (Compute backend, Voice controls, Runtime, etc.)
578
+ doc.querySelectorAll(".section-label").forEach((lab) => {
579
+ const t = (lab.textContent || "").trim();
580
+ if (["Compute backend","Runtime","Playback mode","Voice controls","Logs","Activity","Details"].includes(t)) {
581
+ let n = lab.parentElement;
582
+ if (n) {
583
+ n.classList.add("studio-adv-section");
584
+ if (n.parentElement && /flex|grid/.test(n.parentElement.className)) n.parentElement.classList.add("studio-adv-section");
585
+ }
586
+ }
587
+ });
588
+ // also tag the Auto/WebGPU/WASM grid (no label relationship) by being a sibling of Compute backend label
589
+ doc.querySelectorAll(".grid.grid-cols-3").forEach((g) => {
590
+ const prev = g.previousElementSibling;
591
+ if (prev && (prev.textContent || "").trim() === "Compute backend") {
592
+ g.classList.add("studio-adv-section");
593
+ const w = g.closest(".flex.flex-col"); if (w) w.classList.add("studio-adv-section");
594
+ }
595
+ });
596
+ // inject the Advanced toggle if not present
597
+ if (!doc.getElementById("studio-adv-toggle")) {
598
+ const sb = doc.querySelector(".embedded-sidebar") || doc.querySelector(".embedded-compose");
599
+ if (sb) {
600
+ const b = doc.createElement("button");
601
+ b.id = "studio-adv-toggle"; b.type = "button"; b.className = "studio-adv-toggle";
602
+ b.innerHTML = '<span class="dot"></span><span>Advanced</span><span class="chev">▸</span>';
603
+ b.addEventListener("click", () => {
604
+ const open = doc.body.classList.toggle("studio-adv-open");
605
+ b.setAttribute("aria-expanded", String(open));
606
+ try { frame.contentWindow.postMessage({type:"inflect-web-resize",height:Math.max(doc.body.scrollHeight,doc.documentElement.scrollHeight)}, "*"); } catch (_) {}
607
+ });
608
+ sb.appendChild(b);
609
+ }
610
+ }
611
+ };
612
+ const tick = () => {
613
+ if (!tag()) return; // iframe not ready yet
614
+ tagAdv();
615
+ // re-tag on DOM mutations (foldkit re-renders)
616
+ let doc;
617
+ try { doc = frame.contentDocument; } catch(_) { return; }
618
+ if (!doc._studioMO) {
619
+ try {
620
+ const mo = new (doc.defaultView.MutationObserver || MutationObserver)(() => tagAdv());
621
+ mo.observe(doc.getElementById("root"), { childList: true, subtree: true });
622
+ doc._studioMO = mo;
623
+ } catch (_) {}
624
+ }
625
+ };
626
+ frame.addEventListener("load", tick);
627
+ [60, 200, 600, 1500, 3000, 5000, 8000].forEach((d) => window.setTimeout(tick, d));
628
+ })();
629
+
630
+ /* responsive browser frame height */
631
+ (function(){ const frame=$("webgpu-frame"); const apply=(h)=>{ const n=Number(h); if(Number.isFinite(n)&&n>0){ const floor=frame.clientWidth<1024?1020:560; frame.style.height=`${Math.max(floor,Math.min(2400,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); [120,500,1000,1800,3000].forEach((d)=>setTimeout(measure,d)); })();
632
+
633
+ /* client */
634
+ let client=null;
635
+ const getClient = async () => { if(client) return client; client=await Client.connect(window.location.origin); return client; };
636
+ const inputs = () => ({
637
+ text: $("text").value,
638
+ speed: parseFloat($("speed").value),
639
+ variation: parseFloat($("variation").value),
640
+ pitch_steps: parseFloat($("pitch").value),
641
+ seed: parseInt($("seed").value||"0",10),
642
+ });
643
+ const activeModel = () => document.querySelector('input[name="model"]:checked').value;
644
+
645
+ /* chunking (mirrors app.py) */
646
+ function splitText(text, limit=280){
647
+ const normalized=text.split(/\s+/).filter(Boolean).join(" "); if(!normalized) return [];
648
+ const sentences=normalized.match(/[^.!?;:]+[.!?;:]*/g)||[normalized]; const chunks=[];
649
+ for(let s of sentences){ s=s.trim(); if(!s) continue;
650
+ 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; } }
651
+ if(cut<limit/2){ const sp=s.slice(0,limit+1).lastIndexOf(" "); cut=sp>0?sp:limit; }
652
+ chunks.push(s.slice(0,cut).trim()); s=s.slice(cut).trim(); }
653
+ if(s) chunks.push(s);
654
+ } return chunks;
655
+ }
656
+ 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; }
657
+ function chunkTimings(chunks,totalSec){
658
+ const lens=chunks.map(c=>c.length); const pauses=chunks.slice(0,-1).map(c=>boundaryPause(c));
659
+ const lensAdj=lens.map((l,i)=>l+(i<lens.length-1?pauses[i]*40:0));
660
+ const sum=lensAdj.reduce((a,b)=>a+b,0)||1; let t=0;
661
+ 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}; });
662
+ }
663
+
664
+ /* decode + waveform */
665
+ async function fetchArray(url){ const r=await fetch(url); if(!r.ok) throw new Error(`audio fetch ${r.status}`); return r.arrayBuffer(); }
666
+ function wavBlobUrl(buf){ return URL.createObjectURL(new Blob([buf],{type:"audio/wav"})); }
667
+ async function decodeWav(buf){ const ctx=new (window.AudioContext||window.webkitAudioContext)(); const d=await ctx.decodeAudioData(buf.slice(0)); ctx.close(); return d; }
668
+ function peaksFrom(samples, buckets){
669
+ const out=new Float32Array(buckets*2); const step=Math.floor(samples.length/buckets)||1;
670
+ for(let i=0;i<buckets;i++){ const s=i*step; const e=Math.min(s+step,samples.length); let mn=1,mx=-1;
671
+ for(let j=s;j<e;j++){ const v=samples[j]; if(v<mn)mn=v; if(v>mx)mx=v; }
672
+ out[i*2]=mn; out[i*2+1]=mx; }
673
+ return out;
674
+ }
675
+ function drawWave(canvas, samples, progress, playedColor, baseColor){
676
+ const dpr=window.devicePixelRatio||1; const w=canvas.clientWidth, h=canvas.clientHeight;
677
+ canvas.width=Math.max(1,w*dpr); canvas.height=Math.max(1,h*dpr);
678
+ const ctx=canvas.getContext("2d"); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,w,h);
679
+ const buckets=Math.max(40,Math.floor(w*1.4)); const peaks=peaksFrom(samples,buckets);
680
+ const bw=Math.max(1,w/peaks.length*0.55); const mid=h/2; const prog=Math.max(0,Math.min(1,progress||0));
681
+ for(let i=0;i<peaks.length;i++){ const x=(i/peaks.length)*w; const played=(i/peaks.length)<=prog;
682
+ ctx.fillStyle=played?playedColor:baseColor; const top=mid-peaks[i*2+1]*mid*0.92; const bot=mid-peaks[i*2]*mid*0.92;
683
+ ctx.fillRect(x, top, bw, Math.max(1,bot-top));
684
+ }
685
+ }
686
+ const drawMain=(c,s,p)=>drawWave(c,s,p,"#0a84ff","#2a2c2e");
687
+ const drawMiniC=(c,s,p)=>drawWave(c,s,p,"#0a84ff","#2a2c2e");
688
+
689
+ /* player state */
690
+ let current=null; let raf=null;
691
+ const waveCanvas=$("wave-canvas"); const playhead=$("playhead"); const karaoke=$("karaoke");
692
+ const dl=$("download");
693
+ const statusEl=$("status"); const statusTxt=$("status-txt"); const queueEl=$("queue"); const led=$("status-led");
694
+ const genBtn=$("generate"); const cmpBtn=$("compare-btn");
695
+ const playBtn=$("play"); const stopBtn=$("stop"); const restartBtn=$("restart"); const seek=$("seek"); const player=$("player");
696
+
697
+ /* meters */
698
+ const metersEl=$("meters"); const METER_BARS=24; for(let i=0;i<METER_BARS;i++){ const b=document.createElement("div"); b.className="bar"; b.style.height="3px"; metersEl.appendChild(b); }
699
+ let meterRAF=null;
700
+ function runMeters(audio){ cancelAnimationFrame(meterRAF); const bars=[...metersEl.children];
701
+ const tick=()=>{ const lvl=audio.paused?0:0.3+0.45*Math.abs(Math.sin(audio.currentTime*8))+0.2*Math.random();
702
+ 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*26}px`:"3px"; });
703
+ meterRAF=requestAnimationFrame(tick);
704
+ }; tick();
705
+ const off=()=>{ cancelAnimationFrame(meterRAF); bars.forEach(b=>{ b.classList.remove("lev","peak"); b.style.height="3px"; }); };
706
+ audio.addEventListener("pause",off,{once:true}); audio.addEventListener("ended",off,{once:true});
707
+ }
708
+
709
+ function renderMarkdown(md){ let s=(md||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
710
+ s=s.replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>").replace(/`([^`]+)`/g,"<code>$1</code>"); return s.replace(/\n/g,"<br>"); }
711
+ function setBusy(b,msg){ genBtn.disabled=b; cmpBtn.disabled=b; led.classList.toggle("busy",b); led.classList.toggle("on",!b&&current!=null);
712
+ statusEl.classList.toggle("busy",b); statusEl.classList.remove("ok","err");
713
+ if(b){ statusTxt.innerHTML=`<span class="busy-spin"></span> ${msg||"Generating…"}`; queueEl.classList.remove("hidden"); queueEl.textContent=""; } }
714
+ function setStatus(md,kind){ statusEl.classList.remove("busy","ok","err"); if(kind) statusEl.classList.add(kind); statusTxt.innerHTML=renderMarkdown(md); }
715
+ function setError(msg){ statusEl.classList.remove("busy","ok"); statusEl.classList.add("err"); statusTxt.innerHTML=`<span style="color:var(--warn)">${msg}</span>`; led.classList.remove("busy","on"); }
716
+
717
+ function renderKaraoke(c){ karaoke.innerHTML=""; if(!c||!c.chunks.length){ karaoke.innerHTML='<div class="empty">Your text will appear here, highlighting as it\'s spoken.</div>'; return; }
718
+ c.chunks.forEach((t,i)=>{ const sp=document.createElement("span"); sp.className="chunk"; sp.dataset.index=i; sp.textContent=t+" "; karaoke.appendChild(sp); }); karaoke.classList.add("in"); }
719
+
720
+ function tick(current){
721
+ const a=current.audio; const t=a.currentTime; const p=current.totalSec?Math.min(1,t/current.totalSec):0;
722
+ playhead.style.left=`${p*100}%`; drawMain(waveCanvas,current.samples,p);
723
+ $("now").textContent=fmtTime(t); seek.value=String(Math.round(p*1000));
724
+ const spans=karaoke.querySelectorAll(".chunk"); let active=-1;
725
+ 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; }
726
+ spans.forEach((sp,i)=>{ sp.classList.remove("speaking"); sp.style.background="";
727
+ const seg=current.timings[i]; const done=seg && t>=seg.start+seg.dur;
728
+ sp.classList.toggle("spoken",!!done); if(i===active){ sp.classList.add("speaking"); const rel=Math.max(0,t-seg.start)/Math.max(0.001,seg.dur);
729
+ sp.style.background=`linear-gradient(90deg,rgba(10,132,255,.18) ${Math.min(100,rel*100)}%,transparent ${Math.min(100,rel*100)}%)`; }
730
+ });
731
+ raf=requestAnimationFrame(()=>tick(current));
732
+ }
733
+ function startPlayback(c){ if(!c||!c.audio) return; c.audio.play().then(()=>{ playBtn.textContent="❚❚"; $("np-badge").classList.add("playing"); led.classList.add("on"); cancelAnimationFrame(raf); raf=requestAnimationFrame(()=>tick(c)); runMeters(c.audio); }).catch(()=>{}); }
734
+ function pausePlayback(c){ if(!c||!c.audio) return; c.audio.pause(); playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); }
735
+ function stopPlayback(c){ if(!c||!c.audio) return; c.audio.pause(); c.audio.currentTime=0; playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); $("now").textContent="0:00"; seek.value="0"; playhead.style.left="0%"; drawMain(waveCanvas,c.samples,0); }
736
+
737
+ function setupPlayer(c){
738
+ current=c; renderKaraoke(c); drawMain(waveCanvas,c.samples,0);
739
+ c.audio=new Audio(c.url); const a=c.audio;
740
+ $("dur").textContent=fmtTime(c.totalSec); $("now").textContent="0:00"; seek.value="0"; seek.disabled=false;
741
+ playBtn.disabled=false; stopBtn.disabled=false; restartBtn.disabled=false; playBtn.textContent="▶";
742
+ $("np-title").textContent=activeModel(); $("np-sub").textContent=`${c.totalSec.toFixed(2)}s · ${c.chunks.length} segment${c.chunks.length!==1?"s":""}`;
743
+ $("take-spec").textContent=`${c.totalSec.toFixed(2)}s · ${c.chunks.length} seg · ${c.samples.length} smp`;
744
+ dl.href=c.url; player.classList.add("show"); led.classList.add("on");
745
+ a.addEventListener("loadedmetadata",()=>{ $("dur").textContent=fmtTime(a.duration); });
746
+ a.addEventListener("ended",()=>{ playBtn.textContent="▶"; $("np-badge").classList.remove("playing"); cancelAnimationFrame(raf); $("now").textContent=fmtTime(c.totalSec); seek.value="1000"; playhead.style.left="100%"; drawMain(waveCanvas,c.samples,1); karaoke.querySelectorAll(".chunk").forEach(s=>{ s.classList.remove("speaking"); s.classList.add("spoken"); }); });
747
+ }
748
+ playBtn.addEventListener("click",()=>{ if(!current) return; if(current.audio.paused) startPlayback(current); else pausePlayback(current); });
749
+ stopBtn.addEventListener("click",()=>{ if(current) stopPlayback(current); });
750
+ restartBtn.addEventListener("click",()=>{ if(!current) return; current.audio.currentTime=0; if(current.audio.paused) startPlayback(current); });
751
+ 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}%`; drawMain(waveCanvas,current.samples,t/current.totalSec); });
752
+ $("wave").addEventListener("click",(e)=>{ if(!current) return; const r=$("wave").getBoundingClientRect(); const t=((e.clientX-r.left)/r.width)*current.totalSec; current.audio.currentTime=t; $("now").textContent=fmtTime(t); });
753
+
754
+ /* generate */
755
+ /* Auto-retry transient ZeroGPU failures (hardware ECC / queue blips) up to 2x. */
756
+ const TRANSIENT = /CUDA|ECC|GPU|queue|timeout|connection|fetch|network|503|502|500/i;
757
+ async function withRetry(fn, label){
758
+ let lastErr;
759
+ for (let attempt = 0; attempt < 3; attempt++) {
760
+ try { return await fn(); }
761
+ catch (e) {
762
+ lastErr = e;
763
+ const msg = String(e?.message || e);
764
+ const transient = TRANSIENT.test(msg);
765
+ if (!transient || attempt === 2) throw e;
766
+ const wait = 1200 * (attempt + 1);
767
+ statusTxt.innerHTML = `<span class="busy-spin"></span> ${label} hit a transient GPU error — retrying in ${Math.round(wait/1000)}s…`;
768
+ led.classList.add("busy");
769
+ await new Promise((r) => setTimeout(r, wait));
770
+ }
771
+ }
772
+ throw lastErr;
773
+ }
774
+
775
+ async function doGenerate(){
776
+ setBusy(true,"Generating on ZeroGPU…"); queueEl.textContent="";
777
+ $("cmp-wrap").classList.remove("show");
778
+ try{
779
+ await withRetry(async () => {
780
+ const c=await getClient(); const res=await c.predict("/synthesize",{ ...inputs(), model_name: activeModel() });
781
+ const fileObj=res.data[0]; const statusText=res.data[1]||"Done.";
782
+ const url=(fileObj && (fileObj.url || fileObj.path)) || (typeof fileObj==="string"?fileObj:""); if(!url) throw new Error("server returned no audio url");
783
+ const buf=await fetchArray(url); const blobUrl=wavBlobUrl(buf); const decoded=await decodeWav(buf);
784
+ const text=$("text").value; const chunks=splitText(text); const timings=chunkTimings(chunks,decoded.duration);
785
+ setupPlayer({ url:blobUrl, totalSec:decoded.duration, chunks, timings, samples:decoded.getChannelData(0), audio:null });
786
+ setStatus(statusText,"ok"); queueEl.classList.add("hidden");
787
+ const rtfM=statusText.match(/RTF\s*([0-9.]+)/); if(rtfM) $("m-rtf").textContent=parseFloat(rtfM[1]).toFixed(3);
788
+ });
789
+ setBusy(false); startPlayback(current);
790
+ } catch(e){ setError(`Error: ${String(e?.message||e)}`); setBusy(false); }
791
+ }
792
+
793
+ /* compare */
794
+ let cmp={ micro:null, nano:null };
795
+ const cmpCanvas={ micro:$("micro-canvas"), nano:$("nano-canvas") };
796
+ async function doCompare(){
797
+ setBusy(true,"Comparing on ZeroGPU…"); queueEl.textContent="";
798
+ $("cmp-wrap").classList.add("show");
799
+ for(const k of ["micro","nano"]){ if(cmp[k]?.audio) cmp[k].audio.pause(); cmp[k]=null;
800
+ $(`${k}-now`).textContent="0:00"; $(`${k}-dur`).textContent="0:00"; $(`${k}-seek`).value="0"; $(`${k}-seek`).disabled=true;
801
+ const btn=document.querySelector(`.mini-play[data-mini="${k}"]`); btn.disabled=true; btn.textContent="▶"; $(`${k}-ph`).style.left="0%";
802
+ drawMiniC(cmpCanvas[k], new Float32Array(40),0); }
803
+ try{
804
+ const [microObj,nanoObj,statusText] = await withRetry(async () => {
805
+ const c=await getClient(); const res=await c.predict("/compare",{ ...inputs() });
806
+ return res.data;
807
+ }, "Compare");
808
+ for(const [key,obj,durId,nowId,seekId,dlId,phId,cv] of [
809
+ ["micro",microObj,"micro-dur","micro-now","micro-seek","micro-dl","micro-ph",cmpCanvas.micro],
810
+ ["nano",nanoObj,"nano-dur","nano-now","nano-seek","nano-dl","nano-ph",cmpCanvas.nano],
811
+ ]){
812
+ const url=(obj && (obj.url || obj.path)) || (typeof obj==="string"?obj:""); if(!url) throw new Error(`server returned no ${key} url`);
813
+ const buf=await fetchArray(url); const blobUrl=wavBlobUrl(buf); const decoded=await decodeWav(buf); const samples=decoded.getChannelData(0);
814
+ $(dlId).href=blobUrl; $(durId).textContent=fmtTime(decoded.duration); $(seekId).value="0"; $(seekId).disabled=false; drawMiniC(cv,samples,0);
815
+ const audio=new Audio(blobUrl); const btn=document.querySelector(`.mini-play[data-mini="${key}"]`); btn.disabled=false;
816
+ 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}%`; drawMiniC(cv,samples,p); };
817
+ audio.addEventListener("timeupdate",upd);
818
+ audio.addEventListener("ended",()=>{ btn.textContent="▶"; $(phId).style.left="100%"; drawMiniC(cv,samples,1); });
819
+ 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);
820
+ cmp[key]={ audio, totalSec:decoded.duration, samples, canvas:cv };
821
+ }
822
+ setStatus(statusText,"ok"); queueEl.classList.add("hidden"); setBusy(false);
823
+ } catch(e){ setError(`Error: ${String(e?.message||e)}`); setBusy(false); }
824
+ }
825
+ document.querySelectorAll(".mini-play").forEach((btn)=>{ btn.addEventListener("click",()=>{ const key=btn.dataset.mini; const item=cmp[key]; if(!item||!item.audio) return;
826
+ 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="❚❚"; }
827
+ else { item.audio.pause(); btn.textContent="▶"; }
828
+ }); });
829
+ [ $("micro-wave"), $("nano-wave") ].forEach((el,i)=>{ const key=i?"nano":"micro"; el.addEventListener("click",(e)=>{ const item=cmp[key]; if(!item) return; const r=el.getBoundingClientRect(); item.audio.currentTime=((e.clientX-r.left)/r.width)*item.totalSec; }); });
830
+
831
+ genBtn.addEventListener("click",doGenerate);
832
+ cmpBtn.addEventListener("click",doCompare);
833
+ $("text").addEventListener("keydown",(e)=>{ if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)){ e.preventDefault(); doGenerate(); } });
834
+
835
+ window.addEventListener("resize",()=>{ if(current) drawMain(waveCanvas,current.samples, current.audio?current.audio.currentTime/current.totalSec:0); for(const k of ["micro","nano"]){ if(cmp[k]) drawMiniC(cmpCanvas[k],cmp[k].samples, cmp[k].audio?cmp[k].audio.currentTime/cmp[k].totalSec:0); } });
836
+
837
+ drawMain(waveCanvas,new Float32Array(200),0);
838
+ drawMiniC(cmpCanvas.micro,new Float32Array(40),0);
839
+ drawMiniC(cmpCanvas.nano,new Float32Array(40),0);
840
+ </script>
841
+ </body>
842
+ </html>