apolinario commited on
Commit
0587958
·
1 Parent(s): d35ed81

Polished UI: sidebar, voice library, history, settings

Browse files
Files changed (1) hide show
  1. index.html +1197 -409
index.html CHANGED
@@ -4,422 +4,1000 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>OpenAudioLabs</title>
 
 
7
  <style>
8
  :root {
9
- --bg: #0a0a0f;
10
- --surface: #14141f;
11
- --surface2: #1e1e2e;
12
- --border: #2a2a3e;
13
- --text: #e0e0e8;
14
- --text-dim: #8888a0;
15
- --accent: #7c5cfc;
16
- --accent-hover: #9070ff;
17
- --danger: #ff4466;
18
- --success: #44cc88;
 
 
 
 
 
 
19
  }
20
 
21
  * { margin: 0; padding: 0; box-sizing: border-box; }
22
 
23
  body {
24
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
25
  background: var(--bg);
26
  color: var(--text);
27
- min-height: 100vh;
 
 
 
 
 
 
 
 
 
28
  }
29
 
30
- header {
 
 
31
  display: flex;
32
  align-items: center;
33
  justify-content: space-between;
34
- padding: 16px 32px;
35
- border-bottom: 1px solid var(--border);
36
  background: var(--surface);
 
 
37
  }
38
 
39
  .logo {
40
- font-size: 20px;
41
  font-weight: 700;
42
- letter-spacing: -0.5px;
 
 
 
43
  }
44
 
45
- .logo span { color: var(--accent); }
46
-
47
- .user-info {
 
 
48
  display: flex;
49
  align-items: center;
50
- gap: 10px;
51
  font-size: 14px;
52
- color: var(--text-dim);
53
  }
54
 
55
- .user-info img {
56
- width: 28px;
57
- height: 28px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  border-radius: 50%;
59
  }
60
 
61
- .btn {
62
- padding: 8px 20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  border: none;
64
- border-radius: 6px;
65
- font-size: 14px;
66
- font-weight: 500;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  cursor: pointer;
68
- transition: all 0.15s;
 
 
69
  }
70
 
71
- .btn-primary {
72
- background: var(--accent);
73
- color: white;
 
 
 
 
 
 
 
 
 
74
  }
75
 
76
- .btn-primary:hover { background: var(--accent-hover); }
 
 
 
77
 
78
- .btn-primary:disabled {
79
- opacity: 0.5;
80
- cursor: not-allowed;
 
 
 
81
  }
82
 
83
- .btn-outline {
84
- background: transparent;
85
- color: var(--text);
86
- border: 1px solid var(--border);
 
 
 
 
 
 
 
 
 
 
87
  }
88
 
89
- .btn-outline:hover { border-color: var(--accent); }
 
90
 
91
- .btn-hf {
92
- background: #ff9d00;
93
- color: #000;
94
- font-weight: 600;
95
- display: inline-flex;
96
  align-items: center;
97
  gap: 8px;
 
 
 
 
 
 
 
 
 
98
  }
99
 
100
- .btn-hf:hover { background: #ffb340; }
101
 
102
- /* Landing */
103
- #landing {
 
 
104
  display: flex;
105
- flex-direction: column;
106
- align-items: center;
107
  justify-content: center;
108
- min-height: 80vh;
109
- text-align: center;
110
- gap: 24px;
111
  }
112
 
113
- #landing h1 {
114
- font-size: 48px;
115
- font-weight: 700;
116
- letter-spacing: -1px;
117
  }
118
 
119
- #landing h1 span { color: var(--accent); }
 
 
120
 
121
- #landing p {
122
- color: var(--text-dim);
123
- font-size: 18px;
124
- max-width: 500px;
125
  }
126
 
127
- /* App */
128
- #app { display: none; }
 
 
 
 
129
 
130
- .main {
131
- max-width: 800px;
132
- margin: 0 auto;
133
- padding: 32px;
134
  }
135
 
136
- .section {
 
 
 
 
 
137
  background: var(--surface);
138
  border: 1px solid var(--border);
139
  border-radius: 12px;
140
- padding: 24px;
141
- margin-bottom: 24px;
 
142
  }
143
 
144
- .section-title {
 
 
 
 
 
 
145
  font-size: 14px;
146
  font-weight: 600;
147
- text-transform: uppercase;
148
- letter-spacing: 1px;
149
- color: var(--text-dim);
150
- margin-bottom: 16px;
151
  }
152
 
153
- /* Upload */
154
- .upload-zone {
155
- border: 2px dashed var(--border);
156
- border-radius: 8px;
157
- padding: 32px;
158
- text-align: center;
159
- cursor: pointer;
160
- transition: all 0.15s;
161
- color: var(--text-dim);
162
  }
163
 
164
- .upload-zone:hover, .upload-zone.dragover {
165
- border-color: var(--accent);
166
- color: var(--text);
 
167
  }
168
 
169
- .upload-zone.has-file {
170
- border-color: var(--success);
171
- border-style: solid;
172
- color: var(--success);
 
 
 
173
  }
174
 
175
- .upload-zone input { display: none; }
176
-
177
- /* Text input */
178
- textarea {
179
  width: 100%;
180
- min-height: 100px;
181
- background: var(--surface2);
182
- border: 1px solid var(--border);
183
- border-radius: 8px;
184
- padding: 12px;
185
  color: var(--text);
186
- font-family: inherit;
187
  font-size: 15px;
 
188
  resize: vertical;
189
  }
190
 
191
- textarea:focus {
192
- outline: none;
193
- border-color: var(--accent);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  }
195
 
196
- .char-count {
197
- text-align: right;
 
 
 
 
 
 
 
 
 
198
  font-size: 12px;
199
- color: var(--text-dim);
200
- margin-top: 4px;
201
  }
202
 
203
- /* Controls row */
204
- .controls {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  display: flex;
206
  gap: 12px;
207
  align-items: center;
208
- margin-top: 16px;
209
  }
210
 
211
- select {
212
- padding: 8px 12px;
213
- background: var(--surface2);
214
- border: 1px solid var(--border);
215
- border-radius: 6px;
216
- color: var(--text);
 
217
  font-size: 14px;
 
 
 
 
 
 
 
 
218
  }
219
 
220
- select:focus {
221
- outline: none;
222
- border-color: var(--accent);
 
 
 
 
 
 
 
 
 
223
  }
224
 
 
 
 
 
 
225
  /* Status */
226
- .status {
227
- margin-top: 16px;
228
- padding: 12px;
229
  border-radius: 8px;
230
- font-size: 14px;
231
  display: none;
 
 
232
  }
233
 
234
- .status.visible { display: block; }
235
- .status.info { background: #1a1a3e; color: #8888ff; }
236
- .status.success { background: #1a3e2a; color: var(--success); }
237
- .status.error { background: #3e1a1a; color: var(--danger); }
238
 
239
- /* Audio result */
240
- .result {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  margin-top: 16px;
 
 
 
 
242
  display: none;
243
  }
244
 
245
- .result.visible { display: block; }
246
 
247
- .result audio {
248
- width: 100%;
 
 
249
  margin-bottom: 12px;
250
  }
251
 
252
- .result-actions {
 
 
 
 
 
 
 
253
  display: flex;
254
- gap: 8px;
 
 
 
255
  }
256
 
257
- /* API Docs */
258
- .tabs {
 
 
 
 
 
 
 
259
  display: flex;
260
- gap: 4px;
261
- margin-bottom: 16px;
262
  }
263
 
264
- .tab {
265
- padding: 6px 14px;
 
266
  border-radius: 6px;
267
- font-size: 13px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  cursor: pointer;
269
- color: var(--text-dim);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  background: transparent;
271
- border: 1px solid transparent;
272
- transition: all 0.15s;
 
 
 
273
  }
274
 
275
- .tab.active {
276
- color: var(--text);
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  background: var(--surface2);
278
- border-color: var(--border);
 
 
279
  }
280
 
281
- .tab:hover { color: var(--text); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  pre {
284
- background: var(--bg);
285
  border: 1px solid var(--border);
286
- border-radius: 8px;
287
- padding: 16px;
288
  overflow-x: auto;
289
  font-size: 13px;
290
- line-height: 1.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  }
292
 
293
- code { color: #c0c0d8; }
294
- .code-comment { color: #6a6a8a; }
295
- .code-string { color: #88cc88; }
296
- .code-key { color: #cc88cc; }
297
 
298
- /* Progress bar */
299
- .progress-bar {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  width: 100%;
301
- height: 4px;
302
  background: var(--surface2);
303
- border-radius: 2px;
304
- margin-top: 8px;
305
- overflow: hidden;
306
- display: none;
 
307
  }
308
 
309
- .progress-bar.visible { display: block; }
310
 
311
- .progress-fill {
312
- height: 100%;
313
- background: var(--accent);
314
- border-radius: 2px;
315
- transition: width 0.3s;
316
- width: 0%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  }
318
 
319
- @media (max-width: 600px) {
320
- header { padding: 12px 16px; }
321
- .main { padding: 16px; }
322
- #landing h1 { font-size: 32px; }
323
- .controls { flex-direction: column; align-items: stretch; }
 
 
 
 
 
 
324
  }
325
  </style>
326
  </head>
327
  <body>
328
 
329
- <header>
330
- <div class="logo">Open<span>Audio</span>Labs</div>
331
- <div id="header-right">
332
- <button class="btn btn-hf" id="login-btn-header" style="display:none" onclick="login()">
333
- 🤗 Sign in with Hugging Face
334
- </button>
335
- <div class="user-info" id="user-info" style="display:none">
336
- <img id="user-avatar" src="" alt="">
337
- <span id="user-name"></span>
338
- <button class="btn btn-outline" onclick="logout()" style="font-size:12px;padding:4px 10px">Sign out</button>
339
- </div>
340
- </div>
341
- </header>
342
-
343
  <div id="landing">
344
- <h1>Open<span>Audio</span>Labs</h1>
345
- <p>Voice cloning powered by open-source models on Hugging Face. Sign in with your HF account to get started.</p>
346
- <button class="btn btn-hf" onclick="login()" style="font-size:16px;padding:12px 28px">
347
- 🤗 Sign in with Hugging Face
348
- </button>
349
- <p style="font-size:13px;color:var(--text-dim)">Uses your HF ZeroGPU quota. Free tier included with every account.</p>
350
  </div>
351
 
352
- <div id="app">
353
- <div class="main">
354
 
355
- <div class="section">
356
- <div class="section-title">Reference Voice</div>
357
- <div class="upload-zone" id="upload-zone">
358
- <div id="upload-label">Drop an audio file here or click to upload<br><span style="font-size:12px">MP3, WAV, FLAC supported</span></div>
359
- <input type="file" id="file-input" accept="audio/*">
 
 
 
 
 
360
  </div>
361
  </div>
 
362
 
363
- <div class="section">
364
- <div class="section-title">Text to Synthesize</div>
365
- <textarea id="text-input" placeholder="Type the text you want to synthesize..."></textarea>
366
- <div class="char-count"><span id="char-count">0</span> characters</div>
367
-
368
- <div class="controls">
369
- <select id="lang-select">
370
- <option value="Auto">Auto-detect</option>
371
- <option value="English">English</option>
372
- <option value="Chinese">Chinese</option>
373
- <option value="French">French</option>
374
- <option value="German">German</option>
375
- <option value="Spanish">Spanish</option>
376
- <option value="Japanese">Japanese</option>
377
- <option value="Korean">Korean</option>
378
- <option value="Portuguese">Portuguese</option>
379
- <option value="Italian">Italian</option>
380
- <option value="Dutch">Dutch</option>
381
- <option value="Russian">Russian</option>
382
- <option value="Arabic">Arabic</option>
383
- <option value="Hindi">Hindi</option>
384
- </select>
385
- <button class="btn btn-primary" id="generate-btn" onclick="generate()" disabled>Generate</button>
386
- </div>
 
 
 
 
 
 
 
387
 
388
- <div class="status" id="status"></div>
389
- <div class="progress-bar" id="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
 
390
 
391
- <div class="result" id="result">
392
- <audio id="audio-player" controls></audio>
393
- <div class="result-actions">
394
- <button class="btn btn-outline" onclick="downloadAudio()">Download WAV</button>
 
 
 
 
 
 
 
 
 
 
395
  </div>
396
- </div>
397
- </div>
398
 
399
- <div class="section">
400
- <div class="section-title">API Usage</div>
401
- <p style="color:var(--text-dim);font-size:14px;margin-bottom:16px">Use your HF token to call the OmniVoice API directly. The key trick: file objects need <code>meta: {"_type": "gradio.FileData"}</code>.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
- <div class="tabs">
404
- <div class="tab active" onclick="showTab('curl')">curl</div>
405
- <div class="tab" onclick="showTab('javascript')">JavaScript</div>
406
- <div class="tab" onclick="showTab('python')">Python</div>
 
 
 
 
 
 
 
 
407
  </div>
408
 
409
- <div id="tab-curl">
410
- <pre><code><span class="code-comment"># 1. Upload reference audio</span>
411
- UPLOAD_PATH=$(curl -s -X POST \
412
- <span class="code-string">"https://k2-fsa-omnivoice.hf.space/gradio_api/upload"</span> \
413
- -H <span class="code-string">"Authorization: Bearer $HF_TOKEN"</span> \
414
- -F <span class="code-string">"files=@voice.mp3"</span> | jq -r <span class="code-string">'.[0]'</span>)
415
-
416
- <span class="code-comment"># 2. Generate (queue/join + queue/data)</span>
417
- SESSION=$(uuidgen)
418
- curl -s -X POST \
419
- <span class="code-string">"https://k2-fsa-omnivoice.hf.space/gradio_api/queue/join"</span> \
420
- -H <span class="code-string">"Content-Type: application/json"</span> \
421
- -H <span class="code-string">"Authorization: Bearer $HF_TOKEN"</span> \
422
- -d <span class="code-string">'{
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  "data": [
424
  "Your text here", "English",
425
  {"path": "'$UPLOAD_PATH'",
@@ -431,73 +1009,92 @@ curl -s -X POST \
431
  "session_hash": "'$SESSION'"
432
  }'</span>
433
 
434
- <span class="code-comment"># 3. Stream result</span>
435
- curl -N <span class="code-string">"https://k2-fsa-omnivoice.hf.space/gradio_api/queue/data?session_hash=$SESSION"</span> \
436
- -H <span class="code-string">"Authorization: Bearer $HF_TOKEN"</span></code></pre>
437
- </div>
 
438
 
439
- <div id="tab-javascript" style="display:none">
440
- <pre><code><span class="code-key">const</span> BASE = <span class="code-string">"https://k2-fsa-omnivoice.hf.space/gradio_api"</span>;
441
- <span class="code-key">const</span> TOKEN = <span class="code-string">"hf_..."</span>;
442
- <span class="code-key">const</span> SESSION = crypto.randomUUID();
443
-
444
- <span class="code-comment">// 1. Upload reference audio</span>
445
- <span class="code-key">const</span> form = <span class="code-key">new</span> FormData();
446
- form.append(<span class="code-string">"files"</span>, audioFile);
447
- <span class="code-key">const</span> [uploadPath] = <span class="code-key">await</span> fetch(`${BASE}/upload`, {
448
- method: <span class="code-string">"POST"</span>,
449
- headers: { Authorization: <span class="code-string">`Bearer ${TOKEN}`</span> },
450
  body: form
451
- }).then(r => r.json());
452
 
453
- <span class="code-comment">// 2. Join queue</span>
454
- <span class="code-key">await</span> fetch(`${BASE}/queue/join`, {
455
- method: <span class="code-string">"POST"</span>,
456
  headers: {
457
- <span class="code-string">"Content-Type"</span>: <span class="code-string">"application/json"</span>,
458
- Authorization: <span class="code-string">`Bearer ${TOKEN}`</span>
459
  },
460
- body: JSON.stringify({
461
- data: [
462
- <span class="code-string">"Your text"</span>, <span class="code-string">"English"</span>,
463
  { path: uploadPath,
464
- meta: { _type: <span class="code-string">"gradio.FileData"</span> },
465
- orig_name: <span class="code-string">"voice.mp3"</span> },
466
- <span class="code-string">""</span>, <span class="code-string">""</span>, 32, 2.0, true, 1.0, 0, true, true
467
- ],
468
- fn_index: 0,
469
- session_hash: SESSION
470
  })
471
  });
472
 
473
- <span class="code-comment">// 3. Stream results</span>
474
- <span class="code-key">const</span> res = <span class="code-key">await</span> fetch(
475
- `${BASE}/queue/data?session_hash=${SESSION}`,
476
- { headers: { Authorization: <span class="code-string">`Bearer ${TOKEN}`</span> } }
477
- );
478
- <span class="code-comment">// Parse SSE stream for process_completed message</span></code></pre>
479
- </div>
480
 
481
- <div id="tab-python" style="display:none">
482
- <pre><code><span class="code-key">from</span> gradio_client <span class="code-key">import</span> Client, handle_file
483
 
484
- client = Client(<span class="code-string">"k2-fsa/OmniVoice"</span>)
485
 
486
- result = client.predict(
487
- text=<span class="code-string">"Your text here"</span>,
488
- lang=<span class="code-string">"English"</span>,
489
- ref_aud=handle_file(<span class="code-string">"voice.mp3"</span>),
490
- ref_text=<span class="code-string">""</span>,
491
- instruct=<span class="code-string">""</span>,
492
- ns=32, gs=2.0, dn=True,
493
- sp=1.0, du=0, pp=True, po=True,
494
- api_name=<span class="code-string">"/_clone_fn"</span>
495
  )
496
-
497
- <span class="code-key">print</span>(result) <span class="code-comment"># (audio_path, status)</span></code></pre>
498
  </div>
 
499
  </div>
 
 
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  </div>
502
  </div>
503
 
@@ -505,13 +1102,170 @@ result = client.predict(
505
  import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://esm.sh/@huggingface/hub@0.20.0";
506
 
507
  const OMNIVOICE_BASE = "https://k2-fsa-omnivoice.hf.space/gradio_api";
 
508
 
509
  let accessToken = null;
510
- let uploadedFilePath = null;
511
- let uploadedFileName = null;
512
  let resultAudioUrl = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
 
514
- // --- OAuth ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
516
  async function initOAuth() {
517
  const result = await oauthHandleRedirectIfPresent();
@@ -526,99 +1280,86 @@ window.login = async function() {
526
  window.location.href = url;
527
  };
528
 
529
- window.logout = function() {
530
- accessToken = null;
531
- document.getElementById("app").style.display = "none";
532
- document.getElementById("landing").style.display = "flex";
533
- document.getElementById("user-info").style.display = "none";
534
- document.getElementById("login-btn-header").style.display = "none";
535
- };
536
-
537
  function showApp(userInfo) {
538
  document.getElementById("landing").style.display = "none";
539
- document.getElementById("app").style.display = "block";
540
- document.getElementById("login-btn-header").style.display = "none";
541
- document.getElementById("user-info").style.display = "flex";
542
  document.getElementById("user-avatar").src = userInfo.avatarUrl;
543
  document.getElementById("user-name").textContent = userInfo.name;
 
 
544
  }
545
 
546
- // --- File Upload ---
547
-
548
- const uploadZone = document.getElementById("upload-zone");
549
- const fileInput = document.getElementById("file-input");
550
- const uploadLabel = document.getElementById("upload-label");
551
-
552
- uploadZone.addEventListener("click", () => fileInput.click());
553
- uploadZone.addEventListener("dragover", e => { e.preventDefault(); uploadZone.classList.add("dragover"); });
554
- uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
555
- uploadZone.addEventListener("drop", e => {
556
- e.preventDefault();
557
- uploadZone.classList.remove("dragover");
558
- if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
559
- });
560
- fileInput.addEventListener("change", () => { if (fileInput.files.length) handleFile(fileInput.files[0]); });
561
-
562
- async function handleFile(file) {
563
- uploadLabel.textContent = `Uploading ${file.name}...`;
564
- uploadedFileName = file.name;
565
 
566
- const form = new FormData();
567
- form.append("files", file);
 
 
 
 
568
 
569
- try {
570
- const res = await fetch(`${OMNIVOICE_BASE}/upload`, {
571
- method: "POST",
572
- headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
573
- body: form
574
- });
575
- const paths = await res.json();
576
- uploadedFilePath = paths[0];
577
- uploadZone.classList.add("has-file");
578
- uploadLabel.textContent = `${file.name}`;
579
- updateGenerateBtn();
580
- } catch (err) {
581
- uploadLabel.textContent = `Upload failed: ${err.message}`;
582
- uploadZone.classList.remove("has-file");
583
- }
584
- }
585
 
586
- // --- Text Input ---
587
 
588
  const textInput = document.getElementById("text-input");
589
- const charCount = document.getElementById("char-count");
590
-
591
  textInput.addEventListener("input", () => {
592
- charCount.textContent = textInput.value.length;
593
  updateGenerateBtn();
594
  });
595
 
596
  function updateGenerateBtn() {
597
- document.getElementById("generate-btn").disabled = !(uploadedFilePath && textInput.value.trim());
598
  }
599
 
600
- // --- Generate ---
 
 
 
 
 
 
 
601
 
602
  window.generate = async function() {
 
603
  const text = textInput.value.trim();
604
  const lang = document.getElementById("lang-select").value;
 
 
605
  const btn = document.getElementById("generate-btn");
606
- const status = document.getElementById("status");
607
  const progressBar = document.getElementById("progress-bar");
608
  const progressFill = document.getElementById("progress-fill");
609
  const result = document.getElementById("result");
610
 
611
  btn.disabled = true;
612
- btn.textContent = "Generating...";
613
  result.classList.remove("visible");
614
- showStatus("info", "Joining queue...");
615
  progressBar.classList.add("visible");
616
  progressFill.style.width = "0%";
617
 
618
- const sessionHash = crypto.randomUUID();
619
-
620
  try {
621
- // Join queue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  const joinRes = await fetch(`${OMNIVOICE_BASE}/queue/join`, {
623
  method: "POST",
624
  headers: {
@@ -628,8 +1369,8 @@ window.generate = async function() {
628
  body: JSON.stringify({
629
  data: [
630
  text, lang,
631
- { path: uploadedFilePath, meta: { _type: "gradio.FileData" }, orig_name: uploadedFileName },
632
- "", "", 32, 2.0, true, 1.0, 0, true, true
633
  ],
634
  fn_index: 0,
635
  session_hash: sessionHash
@@ -638,7 +1379,6 @@ window.generate = async function() {
638
 
639
  if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
640
 
641
- // Stream results
642
  const streamRes = await fetch(
643
  `${OMNIVOICE_BASE}/queue/data?session_hash=${sessionHash}`,
644
  { headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} }
@@ -651,7 +1391,6 @@ window.generate = async function() {
651
  while (true) {
652
  const { done, value } = await reader.read();
653
  if (done) break;
654
-
655
  buffer += decoder.decode(value, { stream: true });
656
  const lines = buffer.split("\n\n");
657
  buffer = lines.pop();
@@ -660,32 +1399,27 @@ window.generate = async function() {
660
  if (!line.startsWith("data: ")) continue;
661
  const data = JSON.parse(line.slice(6));
662
 
663
- if (data.msg === "estimation") {
664
- showStatus("info", `In queue (position ${data.rank})...`);
665
- } else if (data.msg === "process_starts") {
666
- showStatus("info", "Processing...");
667
- progressFill.style.width = "10%";
668
- } else if (data.msg === "log") {
669
- showStatus("info", data.log);
670
- } else if (data.msg === "progress" && data.progress_data?.length) {
671
  const p = data.progress_data[0];
672
  if (p.length) {
673
- const pct = Math.round((p.index / p.length) * 90) + 10;
674
- progressFill.style.width = `${pct}%`;
675
- const desc = p.desc || `Step ${p.index}/${p.length}`;
676
- showStatus("info", desc);
677
  }
678
  } else if (data.msg === "process_completed") {
679
  progressFill.style.width = "100%";
680
  if (data.success) {
681
- const audioData = data.output.data[0];
682
- resultAudioUrl = audioData.url;
683
  document.getElementById("audio-player").src = resultAudioUrl;
 
 
684
  result.classList.add("visible");
685
- showStatus("success", `Done in ${data.output.duration?.toFixed(1)}s`);
 
686
  } else {
687
- const errMsg = data.output?.error || "Generation failed";
688
- showStatus("error", errMsg);
689
  }
690
  }
691
  }
@@ -694,21 +1428,34 @@ window.generate = async function() {
694
  showStatus("error", err.message);
695
  } finally {
696
  btn.disabled = false;
697
- btn.textContent = "Generate";
698
- setTimeout(() => {
699
- progressBar.classList.remove("visible");
700
- updateGenerateBtn();
701
- }, 2000);
702
  }
703
  };
704
 
705
  function showStatus(type, msg) {
706
  const el = document.getElementById("status");
707
- el.className = `status visible ${type}`;
708
  el.textContent = msg;
709
  }
710
 
711
- // --- Download ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
 
713
  window.downloadAudio = async function() {
714
  if (!resultAudioUrl) return;
@@ -721,16 +1468,57 @@ window.downloadAudio = async function() {
721
  URL.revokeObjectURL(a.href);
722
  };
723
 
724
- // --- Tabs ---
 
 
 
 
 
 
 
725
 
726
- window.showTab = function(tab) {
727
- document.querySelectorAll('[id^="tab-"]').forEach(el => el.style.display = "none");
728
- document.getElementById(`tab-${tab}`).style.display = "block";
729
- document.querySelectorAll(".tab").forEach(el => el.classList.remove("active"));
730
- event.target.classList.add("active");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  };
732
 
733
- // --- Init ---
734
  initOAuth();
735
  </script>
736
  </body>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>OpenAudioLabs</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
  <style>
10
  :root {
11
+ --bg: #09090b;
12
+ --surface: #111113;
13
+ --surface2: #18181b;
14
+ --surface3: #1f1f23;
15
+ --border: #27272a;
16
+ --border-hover: #3f3f46;
17
+ --text: #fafafa;
18
+ --text-secondary: #a1a1aa;
19
+ --text-muted: #71717a;
20
+ --accent: #8b5cf6;
21
+ --accent-hover: #a78bfa;
22
+ --accent-subtle: rgba(139,92,246,0.1);
23
+ --accent-glow: rgba(139,92,246,0.15);
24
+ --danger: #ef4444;
25
+ --success: #22c55e;
26
+ --warning: #f59e0b;
27
  }
28
 
29
  * { margin: 0; padding: 0; box-sizing: border-box; }
30
 
31
  body {
32
+ font-family: 'Inter', -apple-system, sans-serif;
33
  background: var(--bg);
34
  color: var(--text);
35
+ height: 100vh;
36
+ overflow: hidden;
37
+ }
38
+
39
+ /* ── Layout ── */
40
+ .app-layout {
41
+ display: grid;
42
+ grid-template-columns: 260px 1fr;
43
+ grid-template-rows: 56px 1fr;
44
+ height: 100vh;
45
  }
46
 
47
+ /* ── Header ── */
48
+ .header {
49
+ grid-column: 1 / -1;
50
  display: flex;
51
  align-items: center;
52
  justify-content: space-between;
53
+ padding: 0 20px;
 
54
  background: var(--surface);
55
+ border-bottom: 1px solid var(--border);
56
+ z-index: 10;
57
  }
58
 
59
  .logo {
60
+ font-size: 17px;
61
  font-weight: 700;
62
+ letter-spacing: -0.3px;
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 8px;
66
  }
67
 
68
+ .logo-icon {
69
+ width: 28px;
70
+ height: 28px;
71
+ background: linear-gradient(135deg, var(--accent), #ec4899);
72
+ border-radius: 7px;
73
  display: flex;
74
  align-items: center;
75
+ justify-content: center;
76
  font-size: 14px;
 
77
  }
78
 
79
+ .header-right {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 12px;
83
+ }
84
+
85
+ .user-pill {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 8px;
89
+ padding: 4px 12px 4px 4px;
90
+ background: var(--surface2);
91
+ border: 1px solid var(--border);
92
+ border-radius: 20px;
93
+ font-size: 13px;
94
+ color: var(--text-secondary);
95
+ }
96
+
97
+ .user-pill img {
98
+ width: 24px;
99
+ height: 24px;
100
  border-radius: 50%;
101
  }
102
 
103
+ /* ── Sidebar ── */
104
+ .sidebar {
105
+ background: var(--surface);
106
+ border-right: 1px solid var(--border);
107
+ display: flex;
108
+ flex-direction: column;
109
+ overflow: hidden;
110
+ }
111
+
112
+ .sidebar-section {
113
+ padding: 16px 12px 8px;
114
+ }
115
+
116
+ .sidebar-label {
117
+ font-size: 11px;
118
+ font-weight: 600;
119
+ text-transform: uppercase;
120
+ letter-spacing: 0.8px;
121
+ color: var(--text-muted);
122
+ padding: 0 8px;
123
+ margin-bottom: 8px;
124
+ }
125
+
126
+ .nav-item {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 10px;
130
+ padding: 8px 12px;
131
+ border-radius: 8px;
132
+ font-size: 13px;
133
+ color: var(--text-secondary);
134
+ cursor: pointer;
135
+ transition: all 0.1s;
136
  border: none;
137
+ background: none;
138
+ width: 100%;
139
+ text-align: left;
140
+ }
141
+
142
+ .nav-item:hover { background: var(--surface2); color: var(--text); }
143
+ .nav-item.active { background: var(--accent-subtle); color: var(--accent-hover); }
144
+ .nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
145
+
146
+ .voice-list {
147
+ flex: 1;
148
+ overflow-y: auto;
149
+ padding: 0 12px 12px;
150
+ }
151
+
152
+ .voice-card {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 10px;
156
+ padding: 10px;
157
+ border-radius: 8px;
158
  cursor: pointer;
159
+ transition: all 0.1s;
160
+ margin-bottom: 2px;
161
+ border: 1px solid transparent;
162
  }
163
 
164
+ .voice-card:hover { background: var(--surface2); }
165
+ .voice-card.selected { background: var(--accent-subtle); border-color: var(--accent); }
166
+
167
+ .voice-avatar {
168
+ width: 36px;
169
+ height: 36px;
170
+ border-radius: 8px;
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: center;
174
+ font-size: 16px;
175
+ flex-shrink: 0;
176
  }
177
 
178
+ .voice-meta {
179
+ flex: 1;
180
+ min-width: 0;
181
+ }
182
 
183
+ .voice-name {
184
+ font-size: 13px;
185
+ font-weight: 500;
186
+ white-space: nowrap;
187
+ overflow: hidden;
188
+ text-overflow: ellipsis;
189
  }
190
 
191
+ .voice-detail {
192
+ font-size: 11px;
193
+ color: var(--text-muted);
194
+ }
195
+
196
+ .voice-card .delete-voice {
197
+ opacity: 0;
198
+ padding: 4px;
199
+ border: none;
200
+ background: none;
201
+ color: var(--text-muted);
202
+ cursor: pointer;
203
+ border-radius: 4px;
204
+ transition: all 0.1s;
205
  }
206
 
207
+ .voice-card:hover .delete-voice { opacity: 1; }
208
+ .voice-card .delete-voice:hover { color: var(--danger); background: rgba(239,68,68,0.1); }
209
 
210
+ .add-voice-btn {
211
+ display: flex;
 
 
 
212
  align-items: center;
213
  gap: 8px;
214
+ padding: 10px;
215
+ border-radius: 8px;
216
+ font-size: 13px;
217
+ color: var(--text-muted);
218
+ cursor: pointer;
219
+ border: 1px dashed var(--border);
220
+ background: none;
221
+ width: 100%;
222
+ transition: all 0.15s;
223
  }
224
 
225
+ .add-voice-btn:hover { border-color: var(--accent); color: var(--accent-hover); }
226
 
227
+ /* ── Main Content ── */
228
+ .content {
229
+ overflow-y: auto;
230
+ padding: 32px;
231
  display: flex;
 
 
232
  justify-content: center;
 
 
 
233
  }
234
 
235
+ .content-inner {
236
+ width: 100%;
237
+ max-width: 720px;
 
238
  }
239
 
240
+ /* ── Page: Speech Synthesis ── */
241
+ .page { display: none; }
242
+ .page.active { display: block; }
243
 
244
+ .page-header {
245
+ margin-bottom: 28px;
 
 
246
  }
247
 
248
+ .page-title {
249
+ font-size: 22px;
250
+ font-weight: 700;
251
+ letter-spacing: -0.5px;
252
+ margin-bottom: 6px;
253
+ }
254
 
255
+ .page-desc {
256
+ font-size: 14px;
257
+ color: var(--text-muted);
 
258
  }
259
 
260
+ /* Voice selector inline */
261
+ .voice-selector {
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 12px;
265
+ padding: 14px 16px;
266
  background: var(--surface);
267
  border: 1px solid var(--border);
268
  border-radius: 12px;
269
+ margin-bottom: 16px;
270
+ cursor: pointer;
271
+ transition: border-color 0.15s;
272
  }
273
 
274
+ .voice-selector:hover { border-color: var(--border-hover); }
275
+
276
+ .voice-selector .voice-avatar { width: 40px; height: 40px; }
277
+
278
+ .voice-selector-info { flex: 1; }
279
+
280
+ .voice-selector-name {
281
  font-size: 14px;
282
  font-weight: 600;
 
 
 
 
283
  }
284
 
285
+ .voice-selector-hint {
286
+ font-size: 12px;
287
+ color: var(--text-muted);
 
 
 
 
 
 
288
  }
289
 
290
+ .voice-selector-change {
291
+ font-size: 12px;
292
+ color: var(--accent);
293
+ font-weight: 500;
294
  }
295
 
296
+ /* Text editor area */
297
+ .editor {
298
+ background: var(--surface);
299
+ border: 1px solid var(--border);
300
+ border-radius: 12px;
301
+ overflow: hidden;
302
+ margin-bottom: 16px;
303
  }
304
 
305
+ .editor textarea {
 
 
 
306
  width: 100%;
307
+ min-height: 180px;
308
+ background: transparent;
309
+ border: none;
310
+ padding: 20px;
 
311
  color: var(--text);
312
+ font-family: 'Inter', sans-serif;
313
  font-size: 15px;
314
+ line-height: 1.6;
315
  resize: vertical;
316
  }
317
 
318
+ .editor textarea:focus { outline: none; }
319
+
320
+ .editor textarea::placeholder { color: var(--text-muted); }
321
+
322
+ .editor-footer {
323
+ display: flex;
324
+ align-items: center;
325
+ justify-content: space-between;
326
+ padding: 10px 20px;
327
+ border-top: 1px solid var(--border);
328
+ background: var(--surface2);
329
+ }
330
+
331
+ .editor-left {
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 12px;
335
  }
336
 
337
+ .editor-stat {
338
+ font-size: 12px;
339
+ color: var(--text-muted);
340
+ }
341
+
342
+ .lang-select {
343
+ padding: 4px 8px;
344
+ background: var(--surface3);
345
+ border: 1px solid var(--border);
346
+ border-radius: 6px;
347
+ color: var(--text-secondary);
348
  font-size: 12px;
349
+ font-family: 'Inter', sans-serif;
 
350
  }
351
 
352
+ .lang-select:focus { outline: none; border-color: var(--accent); }
353
+
354
+ /* Settings panel */
355
+ .settings-row {
356
+ display: grid;
357
+ grid-template-columns: 1fr 1fr;
358
+ gap: 12px;
359
+ margin-bottom: 16px;
360
+ }
361
+
362
+ .setting-group {
363
+ background: var(--surface);
364
+ border: 1px solid var(--border);
365
+ border-radius: 10px;
366
+ padding: 14px 16px;
367
+ }
368
+
369
+ .setting-label {
370
+ font-size: 11px;
371
+ font-weight: 600;
372
+ text-transform: uppercase;
373
+ letter-spacing: 0.5px;
374
+ color: var(--text-muted);
375
+ margin-bottom: 8px;
376
+ }
377
+
378
+ .setting-value {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 10px;
382
+ }
383
+
384
+ .setting-value input[type="range"] {
385
+ flex: 1;
386
+ accent-color: var(--accent);
387
+ height: 4px;
388
+ }
389
+
390
+ .setting-num {
391
+ font-size: 13px;
392
+ color: var(--text-secondary);
393
+ min-width: 30px;
394
+ text-align: right;
395
+ font-variant-numeric: tabular-nums;
396
+ }
397
+
398
+ /* Generate button */
399
+ .generate-bar {
400
  display: flex;
401
  gap: 12px;
402
  align-items: center;
 
403
  }
404
 
405
+ .btn-generate {
406
+ flex: 1;
407
+ padding: 14px 24px;
408
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
409
+ color: white;
410
+ border: none;
411
+ border-radius: 10px;
412
  font-size: 14px;
413
+ font-weight: 600;
414
+ font-family: 'Inter', sans-serif;
415
+ cursor: pointer;
416
+ transition: all 0.2s;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: center;
420
+ gap: 8px;
421
  }
422
 
423
+ .btn-generate:hover { filter: brightness(1.1); transform: translateY(-1px); }
424
+ .btn-generate:active { transform: translateY(0); }
425
+ .btn-generate:disabled { opacity: 0.4; cursor: not-allowed; transform: none; filter: none; }
426
+
427
+ .btn-generate .spinner {
428
+ width: 16px;
429
+ height: 16px;
430
+ border: 2px solid rgba(255,255,255,0.3);
431
+ border-top-color: white;
432
+ border-radius: 50%;
433
+ animation: spin 0.6s linear infinite;
434
+ display: none;
435
  }
436
 
437
+ .btn-generate.loading .spinner { display: block; }
438
+ .btn-generate.loading .btn-text { display: none; }
439
+
440
+ @keyframes spin { to { transform: rotate(360deg); } }
441
+
442
  /* Status */
443
+ .status-bar {
444
+ margin-top: 12px;
445
+ padding: 10px 14px;
446
  border-radius: 8px;
447
+ font-size: 13px;
448
  display: none;
449
+ align-items: center;
450
+ gap: 8px;
451
  }
452
 
453
+ .status-bar.visible { display: flex; }
454
+ .status-bar.info { background: rgba(139,92,246,0.08); color: var(--accent-hover); border: 1px solid rgba(139,92,246,0.15); }
455
+ .status-bar.success { background: rgba(34,197,94,0.08); color: var(--success); border: 1px solid rgba(34,197,94,0.15); }
456
+ .status-bar.error { background: rgba(239,68,68,0.08); color: var(--danger); border: 1px solid rgba(239,68,68,0.15); }
457
 
458
+ .progress-track {
459
+ width: 100%;
460
+ height: 3px;
461
+ background: var(--surface3);
462
+ border-radius: 2px;
463
+ margin-top: 6px;
464
+ overflow: hidden;
465
+ display: none;
466
+ }
467
+
468
+ .progress-track.visible { display: block; }
469
+
470
+ .progress-fill {
471
+ height: 100%;
472
+ background: linear-gradient(90deg, var(--accent), #ec4899);
473
+ border-radius: 2px;
474
+ transition: width 0.3s;
475
+ width: 0%;
476
+ }
477
+
478
+ /* Result player */
479
+ .player-card {
480
  margin-top: 16px;
481
+ background: var(--surface);
482
+ border: 1px solid var(--border);
483
+ border-radius: 12px;
484
+ padding: 16px 20px;
485
  display: none;
486
  }
487
 
488
+ .player-card.visible { display: block; }
489
 
490
+ .player-top {
491
+ display: flex;
492
+ align-items: center;
493
+ gap: 12px;
494
  margin-bottom: 12px;
495
  }
496
 
497
+ .play-btn {
498
+ width: 40px;
499
+ height: 40px;
500
+ border-radius: 50%;
501
+ background: var(--accent);
502
+ border: none;
503
+ color: white;
504
+ cursor: pointer;
505
  display: flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ flex-shrink: 0;
509
+ transition: all 0.15s;
510
  }
511
 
512
+ .play-btn:hover { background: var(--accent-hover); transform: scale(1.05); }
513
+
514
+ .play-btn svg { width: 18px; height: 18px; }
515
+
516
+ .player-info { flex: 1; }
517
+ .player-title { font-size: 14px; font-weight: 500; }
518
+ .player-meta { font-size: 12px; color: var(--text-muted); }
519
+
520
+ .player-actions {
521
  display: flex;
522
+ gap: 6px;
 
523
  }
524
 
525
+ .icon-btn {
526
+ width: 32px;
527
+ height: 32px;
528
  border-radius: 6px;
529
+ border: 1px solid var(--border);
530
+ background: transparent;
531
+ color: var(--text-secondary);
532
+ cursor: pointer;
533
+ display: flex;
534
+ align-items: center;
535
+ justify-content: center;
536
+ transition: all 0.1s;
537
+ }
538
+
539
+ .icon-btn:hover { background: var(--surface2); color: var(--text); border-color: var(--border-hover); }
540
+
541
+ .player-audio { width: 100%; height: 32px; }
542
+
543
+ /* ── Page: History ── */
544
+ .history-list { display: flex; flex-direction: column; gap: 8px; }
545
+
546
+ .history-item {
547
+ display: flex;
548
+ align-items: center;
549
+ gap: 14px;
550
+ padding: 14px 16px;
551
+ background: var(--surface);
552
+ border: 1px solid var(--border);
553
+ border-radius: 10px;
554
+ transition: border-color 0.1s;
555
+ }
556
+
557
+ .history-item:hover { border-color: var(--border-hover); }
558
+
559
+ .history-play {
560
+ width: 36px;
561
+ height: 36px;
562
+ border-radius: 50%;
563
+ background: var(--surface3);
564
+ border: 1px solid var(--border);
565
+ color: var(--text-secondary);
566
  cursor: pointer;
567
+ display: flex;
568
+ align-items: center;
569
+ justify-content: center;
570
+ flex-shrink: 0;
571
+ transition: all 0.1s;
572
+ }
573
+
574
+ .history-play:hover { background: var(--accent); color: white; border-color: var(--accent); }
575
+ .history-play svg { width: 14px; height: 14px; }
576
+
577
+ .history-info { flex: 1; min-width: 0; }
578
+ .history-text { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
579
+ .history-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
580
+
581
+ .history-download {
582
+ padding: 6px 12px;
583
+ border-radius: 6px;
584
+ border: 1px solid var(--border);
585
  background: transparent;
586
+ color: var(--text-secondary);
587
+ font-size: 12px;
588
+ cursor: pointer;
589
+ font-family: 'Inter', sans-serif;
590
+ transition: all 0.1s;
591
  }
592
 
593
+ .history-download:hover { background: var(--surface2); color: var(--text); }
594
+
595
+ .empty-state {
596
+ text-align: center;
597
+ padding: 60px 20px;
598
+ color: var(--text-muted);
599
+ font-size: 14px;
600
+ }
601
+
602
+ .empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
603
+
604
+ /* ── Page: API ── */
605
+ .api-tabs {
606
+ display: flex;
607
+ gap: 2px;
608
  background: var(--surface2);
609
+ border-radius: 8px;
610
+ padding: 3px;
611
+ margin-bottom: 16px;
612
  }
613
 
614
+ .api-tab {
615
+ padding: 7px 16px;
616
+ border-radius: 6px;
617
+ font-size: 13px;
618
+ cursor: pointer;
619
+ color: var(--text-muted);
620
+ background: transparent;
621
+ border: none;
622
+ font-family: 'Inter', sans-serif;
623
+ font-weight: 500;
624
+ transition: all 0.1s;
625
+ }
626
+
627
+ .api-tab.active { background: var(--surface3); color: var(--text); }
628
+ .api-tab:hover { color: var(--text-secondary); }
629
 
630
  pre {
631
+ background: var(--surface);
632
  border: 1px solid var(--border);
633
+ border-radius: 10px;
634
+ padding: 20px;
635
  overflow-x: auto;
636
  font-size: 13px;
637
+ line-height: 1.6;
638
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
639
+ }
640
+
641
+ .cm { color: var(--text-muted); }
642
+ .s { color: #86efac; }
643
+ .k { color: #c4b5fd; }
644
+ .f { color: #93c5fd; }
645
+
646
+ /* ── Modal ── */
647
+ .modal-overlay {
648
+ position: fixed;
649
+ inset: 0;
650
+ background: rgba(0,0,0,0.6);
651
+ backdrop-filter: blur(4px);
652
+ display: none;
653
+ align-items: center;
654
+ justify-content: center;
655
+ z-index: 100;
656
  }
657
 
658
+ .modal-overlay.visible { display: flex; }
 
 
 
659
 
660
+ .modal {
661
+ background: var(--surface);
662
+ border: 1px solid var(--border);
663
+ border-radius: 16px;
664
+ padding: 28px;
665
+ width: 440px;
666
+ max-width: 90vw;
667
+ }
668
+
669
+ .modal-title {
670
+ font-size: 17px;
671
+ font-weight: 600;
672
+ margin-bottom: 6px;
673
+ }
674
+
675
+ .modal-desc {
676
+ font-size: 13px;
677
+ color: var(--text-muted);
678
+ margin-bottom: 20px;
679
+ }
680
+
681
+ .modal-field { margin-bottom: 16px; }
682
+
683
+ .modal-field label {
684
+ display: block;
685
+ font-size: 12px;
686
+ font-weight: 500;
687
+ color: var(--text-secondary);
688
+ margin-bottom: 6px;
689
+ }
690
+
691
+ .modal-field input[type="text"] {
692
  width: 100%;
693
+ padding: 10px 12px;
694
  background: var(--surface2);
695
+ border: 1px solid var(--border);
696
+ border-radius: 8px;
697
+ color: var(--text);
698
+ font-family: 'Inter', sans-serif;
699
+ font-size: 14px;
700
  }
701
 
702
+ .modal-field input:focus { outline: none; border-color: var(--accent); }
703
 
704
+ .upload-area {
705
+ border: 2px dashed var(--border);
706
+ border-radius: 10px;
707
+ padding: 28px;
708
+ text-align: center;
709
+ cursor: pointer;
710
+ transition: all 0.15s;
711
+ color: var(--text-muted);
712
+ font-size: 13px;
713
+ }
714
+
715
+ .upload-area:hover { border-color: var(--accent); color: var(--text-secondary); }
716
+ .upload-area.has-file { border-color: var(--success); border-style: solid; color: var(--success); }
717
+ .upload-area input { display: none; }
718
+ .upload-area-sub { font-size: 11px; margin-top: 4px; opacity: 0.7; }
719
+
720
+ .modal-actions {
721
+ display: flex;
722
+ gap: 8px;
723
+ justify-content: flex-end;
724
+ margin-top: 20px;
725
+ }
726
+
727
+ .btn-modal {
728
+ padding: 9px 20px;
729
+ border-radius: 8px;
730
+ font-size: 13px;
731
+ font-weight: 500;
732
+ font-family: 'Inter', sans-serif;
733
+ cursor: pointer;
734
+ transition: all 0.1s;
735
+ border: none;
736
+ }
737
+
738
+ .btn-cancel { background: var(--surface2); color: var(--text-secondary); border: 1px solid var(--border); }
739
+ .btn-cancel:hover { background: var(--surface3); color: var(--text); }
740
+ .btn-save { background: var(--accent); color: white; }
741
+ .btn-save:hover { background: var(--accent-hover); }
742
+ .btn-save:disabled { opacity: 0.4; cursor: not-allowed; }
743
+
744
+ /* ── Landing ── */
745
+ #landing {
746
+ display: flex;
747
+ flex-direction: column;
748
+ align-items: center;
749
+ justify-content: center;
750
+ min-height: 100vh;
751
+ text-align: center;
752
+ gap: 20px;
753
+ background: radial-gradient(ellipse at 50% 0%, var(--accent-glow) 0%, transparent 60%);
754
+ }
755
+
756
+ #landing h1 {
757
+ font-size: 52px;
758
+ font-weight: 700;
759
+ letter-spacing: -1.5px;
760
+ background: linear-gradient(135deg, var(--text) 0%, var(--text-secondary) 100%);
761
+ -webkit-background-clip: text;
762
+ -webkit-text-fill-color: transparent;
763
+ }
764
+
765
+ #landing .subtitle {
766
+ color: var(--text-muted);
767
+ font-size: 17px;
768
+ max-width: 420px;
769
+ line-height: 1.5;
770
+ }
771
+
772
+ .btn-hf {
773
+ padding: 13px 32px;
774
+ background: #ff9d00;
775
+ color: #000;
776
+ border: none;
777
+ border-radius: 10px;
778
+ font-size: 15px;
779
+ font-weight: 600;
780
+ font-family: 'Inter', sans-serif;
781
+ cursor: pointer;
782
+ display: inline-flex;
783
+ align-items: center;
784
+ gap: 8px;
785
+ transition: all 0.2s;
786
+ }
787
+
788
+ .btn-hf:hover { background: #ffb340; transform: translateY(-1px); }
789
+
790
+ .landing-note {
791
+ font-size: 12px;
792
+ color: var(--text-muted);
793
  }
794
 
795
+ /* ── Scrollbar ── */
796
+ ::-webkit-scrollbar { width: 6px; }
797
+ ::-webkit-scrollbar-track { background: transparent; }
798
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
799
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
800
+
801
+ /* ── Responsive ── */
802
+ @media (max-width: 768px) {
803
+ .app-layout { grid-template-columns: 1fr; }
804
+ .sidebar { display: none; }
805
+ #landing h1 { font-size: 36px; }
806
  }
807
  </style>
808
  </head>
809
  <body>
810
 
811
+ <!-- ── Landing ── -->
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  <div id="landing">
813
+ <div class="logo-icon" style="width:56px;height:56px;font-size:28px;border-radius:14px">🎙</div>
814
+ <h1>OpenAudioLabs</h1>
815
+ <p class="subtitle">Voice cloning powered by open-source models. Sign in with your Hugging Face account to start generating.</p>
816
+ <button class="btn-hf" onclick="login()">🤗 Sign in with Hugging Face</button>
817
+ <p class="landing-note">Uses your HF ZeroGPU quota &middot; Free tier included</p>
 
818
  </div>
819
 
820
+ <!-- ── App Shell ── -->
821
+ <div id="app" class="app-layout" style="display:none">
822
 
823
+ <!-- Header -->
824
+ <div class="header">
825
+ <div class="logo">
826
+ <div class="logo-icon">🎙</div>
827
+ OpenAudioLabs
828
+ </div>
829
+ <div class="header-right">
830
+ <div class="user-pill" id="user-pill">
831
+ <img id="user-avatar" src="" alt="">
832
+ <span id="user-name"></span>
833
  </div>
834
  </div>
835
+ </div>
836
 
837
+ <!-- Sidebar -->
838
+ <div class="sidebar">
839
+ <div class="sidebar-section">
840
+ <div class="sidebar-label">Create</div>
841
+ <button class="nav-item active" onclick="showPage('synthesis', this)">
842
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
843
+ Speech Synthesis
844
+ </button>
845
+ <button class="nav-item" onclick="showPage('history', this)">
846
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
847
+ History
848
+ </button>
849
+ <button class="nav-item" onclick="showPage('api', this)">
850
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
851
+ API
852
+ </button>
853
+ </div>
854
+
855
+ <div class="sidebar-section">
856
+ <div class="sidebar-label">Voice Library</div>
857
+ </div>
858
+ <div class="voice-list" id="voice-list">
859
+ <!-- populated by JS -->
860
+ </div>
861
+ <div style="padding:0 12px 12px">
862
+ <button class="add-voice-btn" onclick="openAddVoice()">
863
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
864
+ Add Voice
865
+ </button>
866
+ </div>
867
+ </div>
868
 
869
+ <!-- Content -->
870
+ <div class="content">
871
+ <div class="content-inner">
872
 
873
+ <!-- Speech Synthesis Page -->
874
+ <div class="page active" id="page-synthesis">
875
+ <div class="page-header">
876
+ <div class="page-title">Speech Synthesis</div>
877
+ <div class="page-desc">Select a voice and type your text to generate speech.</div>
878
+ </div>
879
+
880
+ <div class="voice-selector" id="voice-selector" onclick="openAddVoice()">
881
+ <div class="voice-avatar" id="sel-voice-avatar" style="background:var(--surface3)">🎤</div>
882
+ <div class="voice-selector-info">
883
+ <div class="voice-selector-name" id="sel-voice-name">No voice selected</div>
884
+ <div class="voice-selector-hint" id="sel-voice-hint">Click to add a voice from your library</div>
885
+ </div>
886
+ <div class="voice-selector-change">Change</div>
887
  </div>
 
 
888
 
889
+ <div class="editor">
890
+ <textarea id="text-input" placeholder="Start typing your text here..."></textarea>
891
+ <div class="editor-footer">
892
+ <div class="editor-left">
893
+ <span class="editor-stat"><span id="char-count">0</span> chars</span>
894
+ <select class="lang-select" id="lang-select">
895
+ <option value="Auto">Auto</option>
896
+ <option value="English">English</option>
897
+ <option value="Chinese">Chinese</option>
898
+ <option value="French">French</option>
899
+ <option value="German">German</option>
900
+ <option value="Spanish">Spanish</option>
901
+ <option value="Japanese">Japanese</option>
902
+ <option value="Korean">Korean</option>
903
+ <option value="Portuguese">Portuguese</option>
904
+ <option value="Italian">Italian</option>
905
+ <option value="Russian">Russian</option>
906
+ <option value="Arabic">Arabic</option>
907
+ <option value="Hindi">Hindi</option>
908
+ </select>
909
+ </div>
910
+ </div>
911
+ </div>
912
+
913
+ <div class="settings-row">
914
+ <div class="setting-group">
915
+ <div class="setting-label">Inference Steps</div>
916
+ <div class="setting-value">
917
+ <input type="range" id="setting-steps" min="4" max="64" value="32">
918
+ <span class="setting-num" id="steps-val">32</span>
919
+ </div>
920
+ </div>
921
+ <div class="setting-group">
922
+ <div class="setting-label">Speed</div>
923
+ <div class="setting-value">
924
+ <input type="range" id="setting-speed" min="50" max="150" value="100">
925
+ <span class="setting-num" id="speed-val">1.0x</span>
926
+ </div>
927
+ </div>
928
+ </div>
929
+
930
+ <div class="generate-bar">
931
+ <button class="btn-generate" id="generate-btn" onclick="generate()" disabled>
932
+ <span class="btn-text">Generate Speech</span>
933
+ <div class="spinner"></div>
934
+ </button>
935
+ </div>
936
+
937
+ <div class="status-bar" id="status"></div>
938
+ <div class="progress-track" id="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
939
+
940
+ <div class="player-card" id="result">
941
+ <div class="player-top">
942
+ <button class="play-btn" id="play-btn" onclick="togglePlay()">
943
+ <svg id="play-icon" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
944
+ <svg id="pause-icon" viewBox="0 0 24 24" fill="currentColor" style="display:none"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
945
+ </button>
946
+ <div class="player-info">
947
+ <div class="player-title" id="player-title">Generated audio</div>
948
+ <div class="player-meta" id="player-meta"></div>
949
+ </div>
950
+ <div class="player-actions">
951
+ <button class="icon-btn" onclick="downloadAudio()" title="Download">
952
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
953
+ </button>
954
+ </div>
955
+ </div>
956
+ <audio id="audio-player" class="player-audio"></audio>
957
+ </div>
958
+ </div>
959
 
960
+ <!-- History Page -->
961
+ <div class="page" id="page-history">
962
+ <div class="page-header">
963
+ <div class="page-title">History</div>
964
+ <div class="page-desc">Your recent generations.</div>
965
+ </div>
966
+ <div id="history-container">
967
+ <div class="empty-state">
968
+ <div class="empty-state-icon">🕐</div>
969
+ <div>No generations yet. Create your first one!</div>
970
+ </div>
971
+ </div>
972
  </div>
973
 
974
+ <!-- API Page -->
975
+ <div class="page" id="page-api">
976
+ <div class="page-header">
977
+ <div class="page-title">API</div>
978
+ <div class="page-desc">Use your HF token to call the OmniVoice API directly. File objects need <code style="background:var(--surface2);padding:2px 6px;border-radius:4px;font-size:12px">meta: {"_type": "gradio.FileData"}</code>.</div>
979
+ </div>
980
+
981
+ <div class="api-tabs">
982
+ <button class="api-tab active" onclick="showApiTab('curl', this)">curl</button>
983
+ <button class="api-tab" onclick="showApiTab('javascript', this)">JavaScript</button>
984
+ <button class="api-tab" onclick="showApiTab('python', this)">Python</button>
985
+ </div>
986
+
987
+ <div id="api-curl">
988
+ <pre><code><span class="cm"># 1. Upload reference audio</span>
989
+ UPLOAD_PATH=$(<span class="f">curl</span> -s -X POST \
990
+ <span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api/upload"</span> \
991
+ -H <span class="s">"Authorization: Bearer $HF_TOKEN"</span> \
992
+ -F <span class="s">"files=@voice.mp3"</span> | jq -r <span class="s">'.[0]'</span>)
993
+
994
+ <span class="cm"># 2. Join queue</span>
995
+ SESSION=$(<span class="f">uuidgen</span>)
996
+ <span class="f">curl</span> -s -X POST \
997
+ <span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api/queue/join"</span> \
998
+ -H <span class="s">"Content-Type: application/json"</span> \
999
+ -H <span class="s">"Authorization: Bearer $HF_TOKEN"</span> \
1000
+ -d <span class="s">'{
1001
  "data": [
1002
  "Your text here", "English",
1003
  {"path": "'$UPLOAD_PATH'",
 
1009
  "session_hash": "'$SESSION'"
1010
  }'</span>
1011
 
1012
+ <span class="cm"># 3. Stream result</span>
1013
+ <span class="f">curl</span> -N \
1014
+ <span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api/queue/data?session_hash=$SESSION"</span> \
1015
+ -H <span class="s">"Authorization: Bearer $HF_TOKEN"</span></code></pre>
1016
+ </div>
1017
 
1018
+ <div id="api-javascript" style="display:none">
1019
+ <pre><code><span class="k">const</span> BASE = <span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api"</span>;
1020
+ <span class="k">const</span> TOKEN = <span class="s">"hf_..."</span>;
1021
+ <span class="k">const</span> SESSION = <span class="f">crypto.randomUUID</span>();
1022
+
1023
+ <span class="cm">// 1. Upload reference audio</span>
1024
+ <span class="k">const</span> form = <span class="k">new</span> <span class="f">FormData</span>();
1025
+ form.<span class="f">append</span>(<span class="s">"files"</span>, audioFile);
1026
+ <span class="k">const</span> [uploadPath] = <span class="k">await</span> <span class="f">fetch</span>(<span class="s">`${BASE}/upload`</span>, {
1027
+ method: <span class="s">"POST"</span>,
1028
+ headers: { Authorization: <span class="s">`Bearer ${TOKEN}`</span> },
1029
  body: form
1030
+ }).<span class="f">then</span>(r => r.<span class="f">json</span>());
1031
 
1032
+ <span class="cm">// 2. Join queue</span>
1033
+ <span class="k">await</span> <span class="f">fetch</span>(<span class="s">`${BASE}/queue/join`</span>, {
1034
+ method: <span class="s">"POST"</span>,
1035
  headers: {
1036
+ <span class="s">"Content-Type"</span>: <span class="s">"application/json"</span>,
1037
+ Authorization: <span class="s">`Bearer ${TOKEN}`</span>
1038
  },
1039
+ body: <span class="f">JSON.stringify</span>({
1040
+ data: [<span class="s">"Your text"</span>, <span class="s">"English"</span>,
 
1041
  { path: uploadPath,
1042
+ meta: { _type: <span class="s">"gradio.FileData"</span> },
1043
+ orig_name: <span class="s">"voice.mp3"</span> },
1044
+ <span class="s">""</span>, <span class="s">""</span>, 32, 2.0, true, 1.0, 0, true, true],
1045
+ fn_index: 0, session_hash: SESSION
 
 
1046
  })
1047
  });
1048
 
1049
+ <span class="cm">// 3. Stream results via SSE</span>
1050
+ <span class="k">const</span> res = <span class="k">await</span> <span class="f">fetch</span>(
1051
+ <span class="s">`${BASE}/queue/data?session_hash=${SESSION}`</span>,
1052
+ { headers: { Authorization: <span class="s">`Bearer ${TOKEN}`</span> } }
1053
+ );</code></pre>
1054
+ </div>
 
1055
 
1056
+ <div id="api-python" style="display:none">
1057
+ <pre><code><span class="k">from</span> gradio_client <span class="k">import</span> Client, handle_file
1058
 
1059
+ client = <span class="f">Client</span>(<span class="s">"k2-fsa/OmniVoice"</span>)
1060
 
1061
+ result = client.<span class="f">predict</span>(
1062
+ text=<span class="s">"Your text here"</span>,
1063
+ lang=<span class="s">"English"</span>,
1064
+ ref_aud=<span class="f">handle_file</span>(<span class="s">"voice.mp3"</span>),
1065
+ ref_text=<span class="s">""</span>, instruct=<span class="s">""</span>,
1066
+ ns=<span class="s">32</span>, gs=<span class="s">2.0</span>, dn=<span class="k">True</span>,
1067
+ sp=<span class="s">1.0</span>, du=<span class="s">0</span>, pp=<span class="k">True</span>, po=<span class="k">True</span>,
1068
+ api_name=<span class="s">"/_clone_fn"</span>
 
1069
  )
1070
+ <span class="f">print</span>(result)</code></pre>
1071
+ </div>
1072
  </div>
1073
+
1074
  </div>
1075
+ </div>
1076
+ </div>
1077
 
1078
+ <!-- Add Voice Modal -->
1079
+ <div class="modal-overlay" id="add-voice-modal">
1080
+ <div class="modal">
1081
+ <div class="modal-title">Add Voice</div>
1082
+ <div class="modal-desc">Upload a reference audio clip to clone a voice. Short clips (5-30s) work best.</div>
1083
+ <div class="modal-field">
1084
+ <label>Voice Name</label>
1085
+ <input type="text" id="voice-name-input" placeholder="e.g. Joshua, Sarah...">
1086
+ </div>
1087
+ <div class="modal-field">
1088
+ <label>Reference Audio</label>
1089
+ <div class="upload-area" id="modal-upload">
1090
+ <div id="modal-upload-label">Drop audio file here or click to browse<div class="upload-area-sub">MP3, WAV, FLAC &middot; 5-30 seconds recommended</div></div>
1091
+ <input type="file" id="modal-file-input" accept="audio/*">
1092
+ </div>
1093
+ </div>
1094
+ <div class="modal-actions">
1095
+ <button class="btn-modal btn-cancel" onclick="closeModal()">Cancel</button>
1096
+ <button class="btn-modal btn-save" id="save-voice-btn" onclick="saveVoice()" disabled>Save Voice</button>
1097
+ </div>
1098
  </div>
1099
  </div>
1100
 
 
1102
  import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://esm.sh/@huggingface/hub@0.20.0";
1103
 
1104
  const OMNIVOICE_BASE = "https://k2-fsa-omnivoice.hf.space/gradio_api";
1105
+ const COLORS = ["#7c3aed","#ec4899","#f59e0b","#22c55e","#3b82f6","#ef4444","#06b6d4","#8b5cf6"];
1106
 
1107
  let accessToken = null;
1108
+ let selectedVoice = null;
 
1109
  let resultAudioUrl = null;
1110
+ let modalFileData = null;
1111
+ let history = JSON.parse(localStorage.getItem("oal_history") || "[]");
1112
+
1113
+ // ── Voices (IndexedDB) ──
1114
+
1115
+ function openDB() {
1116
+ return new Promise((resolve, reject) => {
1117
+ const req = indexedDB.open("OpenAudioLabs", 1);
1118
+ req.onupgradeneeded = () => req.result.createObjectStore("voices", { keyPath: "id" });
1119
+ req.onsuccess = () => resolve(req.result);
1120
+ req.onerror = () => reject(req.error);
1121
+ });
1122
+ }
1123
+
1124
+ async function getVoices() {
1125
+ const db = await openDB();
1126
+ return new Promise((resolve, reject) => {
1127
+ const tx = db.transaction("voices", "readonly");
1128
+ const store = tx.objectStore("voices");
1129
+ const req = store.getAll();
1130
+ req.onsuccess = () => resolve(req.result);
1131
+ req.onerror = () => reject(req.error);
1132
+ });
1133
+ }
1134
 
1135
+ async function putVoice(voice) {
1136
+ const db = await openDB();
1137
+ return new Promise((resolve, reject) => {
1138
+ const tx = db.transaction("voices", "readwrite");
1139
+ tx.objectStore("voices").put(voice);
1140
+ tx.oncomplete = () => resolve();
1141
+ tx.onerror = () => reject(tx.error);
1142
+ });
1143
+ }
1144
+
1145
+ async function deleteVoiceDB(id) {
1146
+ const db = await openDB();
1147
+ return new Promise((resolve, reject) => {
1148
+ const tx = db.transaction("voices", "readwrite");
1149
+ tx.objectStore("voices").delete(id);
1150
+ tx.oncomplete = () => resolve();
1151
+ tx.onerror = () => reject(tx.error);
1152
+ });
1153
+ }
1154
+
1155
+ async function renderVoices() {
1156
+ const voices = await getVoices();
1157
+ const list = document.getElementById("voice-list");
1158
+ list.innerHTML = voices.map(v => `
1159
+ <div class="voice-card ${selectedVoice?.id === v.id ? 'selected' : ''}" onclick="selectVoice('${v.id}')">
1160
+ <div class="voice-avatar" style="background:${v.color}">${v.name[0].toUpperCase()}</div>
1161
+ <div class="voice-meta">
1162
+ <div class="voice-name">${v.name}</div>
1163
+ <div class="voice-detail">${v.fileName}</div>
1164
+ </div>
1165
+ <button class="delete-voice" onclick="event.stopPropagation();deleteVoice('${v.id}')" title="Remove">
1166
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1167
+ </button>
1168
+ </div>
1169
+ `).join("");
1170
+ }
1171
+
1172
+ window.selectVoice = async function(id) {
1173
+ const voices = await getVoices();
1174
+ selectedVoice = voices.find(v => v.id === id);
1175
+ renderVoices();
1176
+ updateVoiceSelector();
1177
+ updateGenerateBtn();
1178
+ };
1179
+
1180
+ window.deleteVoice = async function(id) {
1181
+ await deleteVoiceDB(id);
1182
+ if (selectedVoice?.id === id) { selectedVoice = null; updateVoiceSelector(); }
1183
+ renderVoices();
1184
+ updateGenerateBtn();
1185
+ };
1186
+
1187
+ function updateVoiceSelector() {
1188
+ const avatar = document.getElementById("sel-voice-avatar");
1189
+ const name = document.getElementById("sel-voice-name");
1190
+ const hint = document.getElementById("sel-voice-hint");
1191
+ if (selectedVoice) {
1192
+ avatar.textContent = selectedVoice.name[0].toUpperCase();
1193
+ avatar.style.background = selectedVoice.color;
1194
+ name.textContent = selectedVoice.name;
1195
+ hint.textContent = selectedVoice.fileName;
1196
+ } else {
1197
+ avatar.textContent = "🎤";
1198
+ avatar.style.background = "var(--surface3)";
1199
+ name.textContent = "No voice selected";
1200
+ hint.textContent = "Click to add a voice from your library";
1201
+ }
1202
+ }
1203
+
1204
+ // ── Modal ──
1205
+
1206
+ window.openAddVoice = function() {
1207
+ document.getElementById("add-voice-modal").classList.add("visible");
1208
+ document.getElementById("voice-name-input").value = "";
1209
+ document.getElementById("modal-upload-label").textContent = "Drop audio file here or click to browse";
1210
+ document.getElementById("modal-upload").classList.remove("has-file");
1211
+ document.getElementById("save-voice-btn").disabled = true;
1212
+ modalFileData = null;
1213
+ };
1214
+
1215
+ window.closeModal = function() {
1216
+ document.getElementById("add-voice-modal").classList.remove("visible");
1217
+ };
1218
+
1219
+ const modalUpload = document.getElementById("modal-upload");
1220
+ const modalFileInput = document.getElementById("modal-file-input");
1221
+
1222
+ modalUpload.addEventListener("click", () => modalFileInput.click());
1223
+ modalUpload.addEventListener("dragover", e => { e.preventDefault(); });
1224
+ modalUpload.addEventListener("drop", e => {
1225
+ e.preventDefault();
1226
+ if (e.dataTransfer.files.length) handleModalFile(e.dataTransfer.files[0]);
1227
+ });
1228
+ modalFileInput.addEventListener("change", () => { if (modalFileInput.files.length) handleModalFile(modalFileInput.files[0]); });
1229
+
1230
+ function handleModalFile(file) {
1231
+ const reader = new FileReader();
1232
+ reader.onload = () => {
1233
+ modalFileData = { name: file.name, data: reader.result, type: file.type };
1234
+ document.getElementById("modal-upload-label").textContent = file.name;
1235
+ modalUpload.classList.add("has-file");
1236
+ checkModalSave();
1237
+ };
1238
+ reader.readAsArrayBuffer(file);
1239
+ }
1240
+
1241
+ document.getElementById("voice-name-input").addEventListener("input", checkModalSave);
1242
+
1243
+ function checkModalSave() {
1244
+ document.getElementById("save-voice-btn").disabled = !(
1245
+ document.getElementById("voice-name-input").value.trim() && modalFileData
1246
+ );
1247
+ }
1248
+
1249
+ window.saveVoice = async function() {
1250
+ const name = document.getElementById("voice-name-input").value.trim();
1251
+ const voice = {
1252
+ id: crypto.randomUUID(),
1253
+ name,
1254
+ fileName: modalFileData.name,
1255
+ audioData: modalFileData.data,
1256
+ audioType: modalFileData.type,
1257
+ color: COLORS[Math.floor(Math.random() * COLORS.length)],
1258
+ createdAt: Date.now()
1259
+ };
1260
+ await putVoice(voice);
1261
+ selectedVoice = voice;
1262
+ closeModal();
1263
+ renderVoices();
1264
+ updateVoiceSelector();
1265
+ updateGenerateBtn();
1266
+ };
1267
+
1268
+ // ── OAuth ──
1269
 
1270
  async function initOAuth() {
1271
  const result = await oauthHandleRedirectIfPresent();
 
1280
  window.location.href = url;
1281
  };
1282
 
 
 
 
 
 
 
 
 
1283
  function showApp(userInfo) {
1284
  document.getElementById("landing").style.display = "none";
1285
+ document.getElementById("app").style.display = "grid";
 
 
1286
  document.getElementById("user-avatar").src = userInfo.avatarUrl;
1287
  document.getElementById("user-name").textContent = userInfo.name;
1288
+ renderVoices();
1289
+ renderHistory();
1290
  }
1291
 
1292
+ // ── Pages ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1293
 
1294
+ window.showPage = function(page, el) {
1295
+ document.querySelectorAll(".page").forEach(p => p.classList.remove("active"));
1296
+ document.getElementById(`page-${page}`).classList.add("active");
1297
+ document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
1298
+ el.classList.add("active");
1299
+ };
1300
 
1301
+ window.showApiTab = function(tab, el) {
1302
+ document.querySelectorAll('[id^="api-"]').forEach(t => t.style.display = "none");
1303
+ document.getElementById(`api-${tab}`).style.display = "block";
1304
+ document.querySelectorAll(".api-tab").forEach(t => t.classList.remove("active"));
1305
+ el.classList.add("active");
1306
+ };
 
 
 
 
 
 
 
 
 
 
1307
 
1308
+ // ── Text Input ──
1309
 
1310
  const textInput = document.getElementById("text-input");
 
 
1311
  textInput.addEventListener("input", () => {
1312
+ document.getElementById("char-count").textContent = textInput.value.length;
1313
  updateGenerateBtn();
1314
  });
1315
 
1316
  function updateGenerateBtn() {
1317
+ document.getElementById("generate-btn").disabled = !(selectedVoice && textInput.value.trim());
1318
  }
1319
 
1320
+ // ── Settings ──
1321
+
1322
+ const stepsSlider = document.getElementById("setting-steps");
1323
+ const speedSlider = document.getElementById("setting-speed");
1324
+ stepsSlider.addEventListener("input", () => document.getElementById("steps-val").textContent = stepsSlider.value);
1325
+ speedSlider.addEventListener("input", () => document.getElementById("speed-val").textContent = (speedSlider.value / 100).toFixed(1) + "x");
1326
+
1327
+ // ── Generate ──
1328
 
1329
  window.generate = async function() {
1330
+ if (!selectedVoice) return;
1331
  const text = textInput.value.trim();
1332
  const lang = document.getElementById("lang-select").value;
1333
+ const steps = parseInt(stepsSlider.value);
1334
+ const speed = speedSlider.value / 100;
1335
  const btn = document.getElementById("generate-btn");
 
1336
  const progressBar = document.getElementById("progress-bar");
1337
  const progressFill = document.getElementById("progress-fill");
1338
  const result = document.getElementById("result");
1339
 
1340
  btn.disabled = true;
1341
+ btn.classList.add("loading");
1342
  result.classList.remove("visible");
1343
+ showStatus("info", "Uploading voice...");
1344
  progressBar.classList.add("visible");
1345
  progressFill.style.width = "0%";
1346
 
 
 
1347
  try {
1348
+ // Upload voice from IndexedDB
1349
+ const blob = new Blob([selectedVoice.audioData], { type: selectedVoice.audioType || "audio/mpeg" });
1350
+ const form = new FormData();
1351
+ form.append("files", blob, selectedVoice.fileName);
1352
+
1353
+ const uploadRes = await fetch(`${OMNIVOICE_BASE}/upload`, {
1354
+ method: "POST",
1355
+ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
1356
+ body: form
1357
+ });
1358
+ const [uploadPath] = await uploadRes.json();
1359
+
1360
+ showStatus("info", "Joining queue...");
1361
+ const sessionHash = crypto.randomUUID();
1362
+
1363
  const joinRes = await fetch(`${OMNIVOICE_BASE}/queue/join`, {
1364
  method: "POST",
1365
  headers: {
 
1369
  body: JSON.stringify({
1370
  data: [
1371
  text, lang,
1372
+ { path: uploadPath, meta: { _type: "gradio.FileData" }, orig_name: selectedVoice.fileName },
1373
+ "", "", steps, 2.0, true, speed, 0, true, true
1374
  ],
1375
  fn_index: 0,
1376
  session_hash: sessionHash
 
1379
 
1380
  if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
1381
 
 
1382
  const streamRes = await fetch(
1383
  `${OMNIVOICE_BASE}/queue/data?session_hash=${sessionHash}`,
1384
  { headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} }
 
1391
  while (true) {
1392
  const { done, value } = await reader.read();
1393
  if (done) break;
 
1394
  buffer += decoder.decode(value, { stream: true });
1395
  const lines = buffer.split("\n\n");
1396
  buffer = lines.pop();
 
1399
  if (!line.startsWith("data: ")) continue;
1400
  const data = JSON.parse(line.slice(6));
1401
 
1402
+ if (data.msg === "estimation") showStatus("info", `In queue (position ${data.rank})...`);
1403
+ else if (data.msg === "process_starts") { showStatus("info", "Processing..."); progressFill.style.width = "10%"; }
1404
+ else if (data.msg === "log") showStatus("info", data.log);
1405
+ else if (data.msg === "progress" && data.progress_data?.length) {
 
 
 
 
1406
  const p = data.progress_data[0];
1407
  if (p.length) {
1408
+ progressFill.style.width = `${Math.round((p.index / p.length) * 90) + 10}%`;
1409
+ showStatus("info", p.desc || `Step ${p.index}/${p.length}`);
 
 
1410
  }
1411
  } else if (data.msg === "process_completed") {
1412
  progressFill.style.width = "100%";
1413
  if (data.success) {
1414
+ resultAudioUrl = data.output.data[0].url;
 
1415
  document.getElementById("audio-player").src = resultAudioUrl;
1416
+ document.getElementById("player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
1417
+ document.getElementById("player-meta").textContent = `${selectedVoice.name} · ${data.output.duration?.toFixed(1)}s · ${text.length} chars`;
1418
  result.classList.add("visible");
1419
+ showStatus("success", `Generated in ${data.output.duration?.toFixed(1)}s`);
1420
+ addToHistory(text, selectedVoice.name, data.output.duration, resultAudioUrl);
1421
  } else {
1422
+ showStatus("error", data.output?.error || "Generation failed");
 
1423
  }
1424
  }
1425
  }
 
1428
  showStatus("error", err.message);
1429
  } finally {
1430
  btn.disabled = false;
1431
+ btn.classList.remove("loading");
1432
+ setTimeout(() => { progressBar.classList.remove("visible"); updateGenerateBtn(); }, 2000);
 
 
 
1433
  }
1434
  };
1435
 
1436
  function showStatus(type, msg) {
1437
  const el = document.getElementById("status");
1438
+ el.className = `status-bar visible ${type}`;
1439
  el.textContent = msg;
1440
  }
1441
 
1442
+ // ── Player ──
1443
+
1444
+ const audioEl = document.getElementById("audio-player");
1445
+
1446
+ window.togglePlay = function() {
1447
+ if (audioEl.paused) { audioEl.play(); } else { audioEl.pause(); }
1448
+ };
1449
+
1450
+ audioEl.addEventListener("play", () => {
1451
+ document.getElementById("play-icon").style.display = "none";
1452
+ document.getElementById("pause-icon").style.display = "block";
1453
+ });
1454
+
1455
+ audioEl.addEventListener("pause", () => {
1456
+ document.getElementById("play-icon").style.display = "block";
1457
+ document.getElementById("pause-icon").style.display = "none";
1458
+ });
1459
 
1460
  window.downloadAudio = async function() {
1461
  if (!resultAudioUrl) return;
 
1468
  URL.revokeObjectURL(a.href);
1469
  };
1470
 
1471
+ // ── History ──
1472
+
1473
+ function addToHistory(text, voiceName, duration, url) {
1474
+ history.unshift({ text, voiceName, duration, url, timestamp: Date.now() });
1475
+ if (history.length > 50) history.pop();
1476
+ localStorage.setItem("oal_history", JSON.stringify(history));
1477
+ renderHistory();
1478
+ }
1479
 
1480
+ function renderHistory() {
1481
+ const container = document.getElementById("history-container");
1482
+ if (!history.length) {
1483
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🕐</div><div>No generations yet</div></div>';
1484
+ return;
1485
+ }
1486
+ container.innerHTML = '<div class="history-list">' + history.map((h, i) => {
1487
+ const date = new Date(h.timestamp);
1488
+ const timeStr = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1489
+ return `<div class="history-item">
1490
+ <button class="history-play" onclick="playHistory(${i})">
1491
+ <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
1492
+ </button>
1493
+ <div class="history-info">
1494
+ <div class="history-text">${h.text}</div>
1495
+ <div class="history-sub">${h.voiceName} · ${h.duration?.toFixed(1)}s · ${timeStr}</div>
1496
+ </div>
1497
+ <button class="history-download" onclick="downloadHistoryItem(${i})">Download</button>
1498
+ </div>`;
1499
+ }).join("") + '</div>';
1500
+ }
1501
+
1502
+ window.playHistory = function(i) {
1503
+ const h = history[i];
1504
+ if (h.url) { audioEl.src = h.url; audioEl.play(); }
1505
+ };
1506
+
1507
+ window.downloadHistoryItem = async function(i) {
1508
+ const h = history[i];
1509
+ if (!h.url) return;
1510
+ try {
1511
+ const res = await fetch(h.url);
1512
+ const blob = await res.blob();
1513
+ const a = document.createElement("a");
1514
+ a.href = URL.createObjectURL(blob);
1515
+ a.download = `openaudio_${i}.wav`;
1516
+ a.click();
1517
+ URL.revokeObjectURL(a.href);
1518
+ } catch { /* URL may have expired */ }
1519
  };
1520
 
1521
+ // ── Init ──
1522
  initOAuth();
1523
  </script>
1524
  </body>