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