userhugginggit commited on
Commit
7904d77
·
verified ·
1 Parent(s): c9e702b

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +152 -1604
index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
6
- <title>Faster Qwen3-TTS</title>
7
  <style>
8
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
 
@@ -55,1727 +55,275 @@ body {
55
  line-height: 1.5;
56
  }
57
 
58
- /* ── App shell ── */
59
- .app {
60
- display: flex;
61
- flex-direction: column;
62
- justify-content: center;
63
- min-height: 100vh;
64
- min-height: 100dvh;
65
- max-width: 680px;
66
- margin: 0 auto;
67
- padding: 10px 16px;
68
- padding-bottom: max(10px, env(safe-area-inset-bottom));
69
- gap: 8px;
70
- }
71
-
72
- /* ── Header ── */
73
- .hdr {
74
- display: flex;
75
- align-items: center;
76
- gap: 8px;
77
- flex-shrink: 0;
78
- }
79
- .hdr h1 { font-size: 15px; font-weight: 600; letter-spacing: -0.3px; }
80
- .badge {
81
- font-size: 9px; font-weight: 700; letter-spacing: 0.8px;
82
- background: var(--accent-bg); color: var(--accent);
83
- border: 1px solid var(--accent-ring);
84
- padding: 1px 6px; border-radius: 100px;
85
- }
86
  .spacer { flex: 1; }
87
- .mpill {
88
- display: flex; align-items: center; gap: 5px;
89
- font-size: 11px; color: var(--dim);
90
- padding: 4px 10px;
91
- background: var(--surface); border: 1px solid var(--border);
92
- border-radius: 20px; cursor: pointer;
93
- transition: border-color 0.15s;
94
- }
95
- .mpill:hover { border-color: var(--border2); }
96
- .mdot {
97
- width: 6px; height: 6px; border-radius: 50%;
98
- background: var(--dimmer); flex-shrink: 0;
99
- transition: background 0.3s;
100
- }
101
  .mdot.loaded { background: var(--green); }
102
  .mdot.loading { background: var(--amber); animation: blink 0.7s infinite; }
103
  .mdot.error { background: var(--red); }
104
- .ibtn {
105
- width: 30px; height: 30px;
106
- display: flex; align-items: center; justify-content: center;
107
- background: transparent;
108
- border: 1px solid var(--border); border-radius: 8px;
109
- color: var(--dim); cursor: pointer;
110
- transition: all 0.15s;
111
- }
112
- .ibtn:hover { border-color: var(--border2); color: var(--text); }
113
- .ibtn svg { width: 15px; height: 15px; }
114
 
115
- /* ── Main area ── */
116
- .main {
117
- display: flex; flex-direction: column; gap: 6px;
118
- }
119
- .text-wrap textarea {
120
- width: 100%; height: 120px;
121
- background: var(--surface); border: 1px solid var(--border);
122
- border-radius: var(--radius);
123
- color: var(--text); font-size: 15px; font-family: inherit;
124
- padding: 10px 12px; resize: none; outline: none;
125
- transition: border-color 0.15s;
126
- }
127
- .text-wrap textarea:focus { border-color: var(--accent-ring); }
128
- .text-wrap textarea::placeholder { color: var(--dimmer); }
129
 
130
- /* ── Mode rows (arcade selector) ── */
131
- .mode-row {
132
- flex-shrink: 0;
133
- padding: 7px 9px; border-radius: var(--radius-sm);
134
- transition: box-shadow 0.2s, opacity 0.2s;
135
- }
136
- .mode-row .label {
137
- font-size: 11px; color: var(--dim);
138
- margin-bottom: 4px;
139
- display: flex; align-items: center; gap: 8px;
140
- transition: color 0.2s;
141
- }
142
- .mode-row .label::after {
143
- content: ''; flex: 1; height: 1px;
144
- background: var(--border);
145
- transition: background 0.2s;
146
- }
147
  .mode-row .row { display: flex; align-items: flex-start; gap: 6px; }
148
-
149
- /* Active (loaded) row — neon arcade glow */
150
- .mode-row.compatible {
151
- box-shadow: 0 0 0 1px var(--accent),
152
- 0 0 12px rgba(139,92,246,0.45),
153
- 0 0 24px rgba(139,92,246,0.15);
154
- }
155
- .mode-row.compatible .label {
156
- color: var(--accent); font-weight: 600; letter-spacing: 0.03em;
157
- }
158
- .mode-row.compatible .label::before {
159
- content: '▶'; color: var(--accent);
160
- font-size: 9px;
161
- animation: blink-cursor 1s step-end infinite;
162
- }
163
- .mode-row.compatible .label::after { background: var(--accent-ring); }
164
- @keyframes blink-cursor { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
165
-
166
- /* Loading row — pulsing glow while model is fetched */
167
- .mode-row.loading {
168
- animation: row-pulse 0.7s ease-in-out infinite alternate;
169
- }
170
- .mode-row.loading .label {
171
- color: var(--accent); font-weight: 600; letter-spacing: 0.03em;
172
- }
173
- .mode-row.loading .label::before {
174
- content: '◌'; color: var(--accent); font-size: 11px;
175
- animation: spin-ring 1s linear infinite;
176
- }
177
- .mode-row.loading .label::after { background: var(--accent-ring); }
178
- @keyframes row-pulse {
179
- from { box-shadow: 0 0 0 1px rgba(139,92,246,0.4), 0 0 6px rgba(139,92,246,0.2); }
180
- to { box-shadow: 0 0 0 1px var(--accent), 0 0 20px rgba(139,92,246,0.55), 0 0 36px rgba(139,92,246,0.2); }
181
- }
182
- @keyframes spin-ring { to { transform: rotate(360deg); } }
183
-
184
- /* Inactive rows — dim + pointer; hover lifts the veil */
185
  .mode-row.incompatible { opacity: 0.42; cursor: pointer; }
186
- /* Block inner elements from capturing clicks/focus so the row onclick fires cleanly */
187
  .mode-row.incompatible > * { pointer-events: none; }
188
- .mode-row.incompatible:hover {
189
- opacity: 0.9;
190
- box-shadow: 0 0 0 1px var(--border2), 0 0 10px rgba(139,92,246,0.2);
191
- }
192
- .mode-row.incompatible:hover .label { color: var(--text2); }
193
- .mode-row.incompatible:hover .switch-tag { display: inline-flex; }
194
 
195
- /* "LOAD ▶" chip shown on hover of incompatible rows */
196
- .switch-tag {
197
- display: none; align-items: center;
198
- font-size: 9px; font-family: monospace; font-weight: 700;
199
- letter-spacing: 0.12em; text-transform: uppercase;
200
- color: var(--accent); padding: 1px 5px;
201
- border: 1px solid var(--accent-ring); border-radius: 3px;
202
- background: var(--accent-bg);
203
- animation: flicker-in 0.35s step-end;
204
- }
205
- @keyframes flicker-in {
206
- 0% { opacity: 0; } 20% { opacity: 1; } 35% { opacity: 0.2; }
207
- 55% { opacity: 1; } 70% { opacity: 0.5; } 100% { opacity: 1; }
208
- }
209
-
210
- /* ── Loader overlay (centered modal) ── */
211
- .lov {
212
- position: fixed; inset: 0;
213
- background: rgba(0,0,0,0.75);
214
- backdrop-filter: blur(2px);
215
- z-index: 110;
216
- display: flex; align-items: center; justify-content: center;
217
- padding: 20px;
218
- opacity: 0; pointer-events: none;
219
- transition: opacity 0.2s;
220
- }
221
  .lov.open { opacity: 1; pointer-events: auto; }
222
- .lpanel {
223
- background: var(--bg);
224
- border: 1px solid var(--border);
225
- border-top: 2px solid var(--accent);
226
- border-radius: 16px;
227
- width: 100%; max-width: 460px;
228
- max-height: 85dvh; overflow-y: auto;
229
- padding: 20px 20px 24px;
230
- transform: scale(0.93) translateY(12px);
231
- transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
232
- box-shadow: 0 0 40px rgba(139,92,246,0.2), 0 20px 60px rgba(0,0,0,0.5);
233
- }
234
  .lov.open .lpanel { transform: scale(1) translateY(0); }
235
- .l-header {
236
- display: flex; align-items: center; margin-bottom: 14px;
237
- }
238
- .l-back {
239
- display: flex; align-items: center; gap: 4px;
240
- font-size: 11px; font-family: monospace; color: var(--dim);
241
- cursor: pointer; border: none; background: none;
242
- padding: 0; margin-right: auto;
243
- transition: color 0.15s;
244
- }
245
- .l-back:hover { color: var(--text2); }
246
- .l-title {
247
- font-family: monospace; font-size: 12px; font-weight: 700;
248
- letter-spacing: 0.22em; color: var(--accent);
249
- text-align: center; flex: 1;
250
- text-shadow: 0 0 8px rgba(139,92,246,0.6);
251
- }
252
- .lcard-grid {
253
- display: grid;
254
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
255
- gap: 8px; margin-bottom: 14px;
256
- }
257
- .lcard {
258
- border: 1px solid var(--border2); border-radius: var(--radius-sm);
259
- padding: 10px 12px; cursor: pointer; transition: all 0.15s;
260
- background: var(--surface);
261
- }
262
- .lcard:hover { border-color: var(--accent-ring); background: var(--accent-bg); }
263
- .lcard.sel {
264
- border-color: var(--accent); background: var(--accent-bg);
265
- box-shadow: 0 0 8px rgba(139,92,246,0.35);
266
- }
267
  .lcard .lc-name { font-size: 13px; font-weight: 600; color: var(--text); }
268
  .lcard .lc-sub { font-size: 11px; color: var(--dim); margin-top: 2px; }
269
- .lcard.sel .lc-name { color: var(--accent); }
270
 
271
- /* Ref audio chip */
272
- .ref-chip {
273
- display: inline-flex; align-items: center; gap: 5px;
274
- padding: 6px 10px;
275
- background: var(--surface); border: 1px dashed var(--border2);
276
- border-radius: var(--radius-sm);
277
- font-size: 12px; color: var(--dim);
278
- cursor: pointer; transition: all 0.15s;
279
- position: relative; overflow: hidden;
280
- max-width: 220px;
281
- }
282
- .ref-chip:hover { border-color: var(--accent); color: var(--accent); }
283
  .ref-chip.has { border-style: solid; color: var(--accent); border-color: var(--accent-ring); }
284
- .ref-chip input[type="file"] {
285
- position: absolute; inset: 0; opacity: 0; cursor: pointer;
286
- }
287
  .ref-chip svg { width: 14px; height: 14px; flex-shrink: 0; }
288
- .ref-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
289
 
290
- /* Mic button */
291
- .mic-btn {
292
- width: 32px; height: 32px;
293
- display: flex; align-items: center; justify-content: center;
294
- background: var(--surface); border: 1px solid var(--border);
295
- border-radius: var(--radius-sm);
296
- color: var(--dim); cursor: pointer; transition: all 0.15s;
297
- flex-shrink: 0;
298
- }
299
- .mic-btn:hover { border-color: var(--accent); color: var(--accent); }
300
- .mic-btn.rec { border-color: var(--red); color: var(--red); animation: blink 1s infinite; }
301
- .mic-btn svg { width: 14px; height: 14px; }
302
-
303
- .preset-row {
304
- display: contents;
305
- }
306
- .preset-row:empty { display: none; }
307
- .preset-btn {
308
- background: var(--surface2);
309
- border: 1px solid var(--border2);
310
- color: var(--text2);
311
- padding: 6px 10px;
312
- border-radius: var(--radius-sm);
313
- font-size: 12px;
314
- cursor: pointer; transition: all 0.15s;
315
- flex-shrink: 0;
316
- }
317
- .preset-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
318
  .preset-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
319
 
320
- .rec-time {
321
- font-size: 11px; color: var(--red);
322
- font-family: 'SF Mono', 'Fira Code', monospace;
323
- min-width: 28px;
324
- }
325
- .rec-status { font-size: 11px; color: var(--dim); }
326
-
327
- /* Recording level meter */
328
- .rec-bar-wrap {
329
- height: 3px; background: var(--border); border-radius: 2px;
330
- margin-top: 4px; display: none;
331
- }
332
- .rec-bar {
333
- height: 100%; width: 0%;
334
- background: linear-gradient(90deg, var(--green), var(--amber), var(--red));
335
- border-radius: 2px; transition: width 0.05s;
336
- }
337
-
338
- /* Recording preview */
339
- .rec-preview {
340
- width: 100%; height: 28px; border-radius: 4px;
341
- margin-top: 4px; display: none;
342
- }
343
- :root:not([data-theme="light"]) .rec-preview {
344
- filter: invert(0.88) hue-rotate(180deg);
345
- }
346
 
347
- /* Small select */
348
- .sm-sel {
349
- padding: 6px 28px 6px 10px;
350
- background: var(--surface); border: 1px solid var(--border);
351
- border-radius: var(--radius-sm);
352
- color: var(--text); font-size: 12px; font-family: inherit;
353
- outline: none; cursor: pointer; appearance: none;
354
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath fill='%2371717a' d='M4 5 0 0h8z'/%3E%3C/svg%3E");
355
- background-repeat: no-repeat; background-position: right 8px center;
356
- }
357
- .sm-sel option { background: var(--surface); }
358
-
359
- /* Voice instruction textarea (auto-grow) */
360
- .voice-in {
361
- flex: 1; padding: 6px 10px;
362
- background: var(--surface); border: 1px solid var(--border);
363
- border-radius: var(--radius-sm);
364
- color: var(--text); font-size: 12px; font-family: inherit;
365
- outline: none; transition: border-color 0.15s;
366
- resize: none; overflow: hidden;
367
- min-height: 32px; line-height: 1.45;
368
- }
369
- .voice-in:focus { border-color: var(--accent-ring); }
370
- .voice-in::placeholder { color: var(--dimmer); }
371
- /* Full-width instruction field below a row (Custom mode) */
372
- .instr-wrap { margin-top: 6px; display: flex; }
373
- .instr-wrap .voice-in { flex: 1; }
374
-
375
- /* ── Clone mode controls ── */
376
- .clone-toggle-row { margin-bottom: 6px; }
377
- .clone-hint {
378
- font-size: 11px; color: var(--dim);
379
- margin-bottom: 5px; line-height: 1.4;
380
- }
381
-
382
- /* ── Play button ── */
383
- .play-btn {
384
- width: 32px; height: 32px;
385
- display: flex; align-items: center; justify-content: center;
386
- background: var(--accent); color: #fff; border: none;
387
- border-radius: var(--radius-sm); cursor: pointer; flex-shrink: 0;
388
- margin-left: auto;
389
- transition: all 0.15s;
390
- }
391
- .play-btn:hover:not(:disabled) { background: var(--accent2); }
392
  .play-btn:disabled { opacity: 0.35; cursor: not-allowed; }
393
- .play-btn svg { width: 14px; height: 14px; margin-left: 1px; }
394
 
395
- /* ── Error / Warning ── */
396
- .msg-bar {
397
- display: none; padding: 8px 12px;
398
- border-radius: var(--radius-sm);
399
- font-size: 12px; flex-shrink: 0;
400
- align-items: center; gap: 8px;
401
- }
402
- .msg-bar.show { display: flex; }
403
- .msg-bar.err {
404
- background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2);
405
- color: #fca5a5;
406
- }
407
- :root[data-theme="light"] .msg-bar.err { color: #dc2626; }
408
- .msg-bar.warn {
409
- background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2);
410
- color: #fcd34d;
411
- }
412
- :root[data-theme="light"] .msg-bar.warn { color: #a16207; }
413
- .msg-bar .sw-btn {
414
- margin-left: auto; padding: 3px 10px;
415
- background: rgba(234,179,8,0.15); border: none; border-radius: 4px;
416
- color: inherit; font-size: 11px; font-weight: 600;
417
- cursor: pointer; white-space: nowrap;
418
- }
419
- .msg-bar .sw-btn:hover { background: rgba(234,179,8,0.3); }
420
-
421
- /* ── Queue indicator ── */
422
- .queue-bar {
423
- display: none; padding: 6px 12px;
424
- border-radius: var(--radius-sm); font-size: 13px; color: var(--text2);
425
- background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2);
426
- }
427
- .queue-bar.show { display: flex; align-items: center; gap: 8px; }
428
-
429
- /* ── Progress ── */
430
- .pbar-wrap {
431
- height: 2px; background: var(--border);
432
- border-radius: 1px; overflow: hidden;
433
- flex-shrink: 0; display: none;
434
- }
435
  .pbar-wrap.show { display: block; }
436
- .pbar {
437
- height: 100%; background: var(--accent);
438
- border-radius: 1px; transition: width 0.4s; width: 0%;
439
- }
440
- .pbar.spin {
441
- width: 25% !important;
442
- animation: sweep 1.4s ease-in-out infinite;
443
- }
444
 
445
- /* ── Results ── */
446
- .results {
447
- flex-shrink: 0; display: none;
448
- flex-direction: column; gap: 6px;
449
- }
450
  .results.show { display: flex; }
451
  .player-row { display: flex; align-items: center; gap: 6px; }
452
  .player-row audio { flex: 1; height: 32px; border-radius: 4px; }
453
- :root:not([data-theme="light"]) .player-row audio { filter: invert(0.88) hue-rotate(180deg); }
454
- .dl-btn {
455
- width: 28px; height: 28px;
456
- display: flex; align-items: center; justify-content: center;
457
- background: transparent; border: 1px solid var(--border);
458
- border-radius: var(--radius-sm);
459
- color: var(--dim); cursor: pointer; font-size: 13px;
460
- transition: all 0.15s; flex-shrink: 0;
461
- }
462
- .dl-btn:hover { border-color: var(--accent); color: var(--accent); }
463
-
464
- .metrics {
465
- display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
466
- padding: 5px 10px;
467
- background: var(--surface); border: 1px solid var(--border);
468
- border-radius: var(--radius-sm);
469
- font-size: 11px;
470
- }
471
- .met { display: flex; align-items: baseline; gap: 3px; }
472
- .met-k {
473
- color: var(--dim); text-transform: uppercase;
474
- letter-spacing: 0.5px; font-size: 10px;
475
- }
476
- .met-v {
477
- font-family: 'SF Mono', 'Fira Code', monospace;
478
- font-weight: 600; font-size: 12px;
479
- }
480
- .wave {
481
- display: flex; align-items: center; gap: 2px; height: 12px;
482
- margin-left: auto;
483
- }
484
- .wave.off { display: none; }
485
- .wave i {
486
- width: 2px; border-radius: 1px; background: var(--accent);
487
- animation: wavebar 0.7s ease-in-out infinite;
488
- display: block;
489
- }
490
- .wave i:nth-child(1) { height: 3px; animation-delay: 0s; }
491
- .wave i:nth-child(2) { height: 7px; animation-delay: 0.1s; }
492
- .wave i:nth-child(3) { height: 11px; animation-delay: 0.2s; }
493
- .wave i:nth-child(4) { height: 7px; animation-delay: 0.3s; }
494
- .wave i:nth-child(5) { height: 3px; animation-delay: 0.4s; }
495
 
496
- /* ── Settings overlay ── */
497
- .sov {
498
- position: fixed; inset: 0;
499
- background: rgba(0,0,0,0.5);
500
- z-index: 100;
501
- display: flex; align-items: flex-end; justify-content: center;
502
- opacity: 0; pointer-events: none;
503
- transition: opacity 0.2s;
504
- }
505
  .sov.open { opacity: 1; pointer-events: auto; }
506
- .spanel {
507
- background: var(--bg);
508
- border: 1px solid var(--border);
509
- border-radius: 16px 16px 0 0;
510
- width: 100%; max-width: 680px;
511
- max-height: 75dvh;
512
- overflow-y: auto;
513
- padding: 16px 20px 24px;
514
- transform: translateY(100%);
515
- transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
516
- }
517
  .sov.open .spanel { transform: translateY(0); }
518
- .s-handle {
519
- width: 32px; height: 4px;
520
- background: var(--border2); border-radius: 2px;
521
- margin: 0 auto 14px;
522
- }
523
- .s-head {
524
- display: flex; align-items: center;
525
- margin-bottom: 16px;
526
- }
527
- .s-head span { font-size: 14px; font-weight: 600; }
528
- .s-head .spacer { flex: 1; }
529
- .s-section {
530
- font-size: 10px; font-weight: 600; letter-spacing: 0.8px;
531
- text-transform: uppercase; color: var(--dim);
532
- margin: 16px 0 8px; padding-top: 12px;
533
- border-top: 1px solid var(--border);
534
- }
535
- .s-section:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
536
- .s-row {
537
- display: flex; align-items: center; gap: 8px;
538
- margin-bottom: 10px;
539
- }
540
- .s-row label {
541
- font-size: 12px; color: var(--text2);
542
- min-width: 90px; flex-shrink: 0;
543
- }
544
- .s-row select, .s-row input[type="text"], .s-row textarea {
545
- flex: 1; padding: 7px 10px;
546
- background: var(--surface); border: 1px solid var(--border);
547
- border-radius: var(--radius-sm);
548
- color: var(--text); font-size: 13px; font-family: inherit;
549
- outline: none;
550
- }
551
- .s-row select {
552
- appearance: none; padding-right: 28px;
553
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath fill='%2371717a' d='M4 5 0 0h8z'/%3E%3C/svg%3E");
554
- background-repeat: no-repeat; background-position: right 10px center;
555
- cursor: pointer;
556
- }
557
- .s-row select option { background: var(--surface); }
558
- .s-row textarea { resize: vertical; min-height: 40px; max-height: 80px; }
559
-
560
- /* Settings hint text */
561
- .s-hint {
562
- font-size: 11px; color: var(--dimmer);
563
- margin: -6px 0 8px 98px;
564
- }
565
-
566
- /* Toggle */
567
- .toggle {
568
- display: flex; background: var(--surface);
569
- border: 1px solid var(--border); border-radius: var(--radius-sm);
570
- padding: 2px; flex: 1;
571
- }
572
- .tog {
573
- flex: 1; padding: 4px 8px; font-size: 11px; font-weight: 500;
574
- text-align: center; cursor: pointer; border-radius: 4px;
575
- color: var(--dim); transition: all 0.15s; user-select: none;
576
- }
577
- .tog.on {
578
- background: var(--surface2); color: var(--text);
579
- box-shadow: var(--shadow);
580
- }
581
-
582
- /* Slider */
583
- .s-slider {
584
- flex: 1; display: flex; align-items: center; gap: 8px;
585
- }
586
- .s-slider input[type="range"] {
587
- -webkit-appearance: none; flex: 1;
588
- height: 3px; background: var(--border2); border-radius: 2px;
589
- outline: none; cursor: pointer;
590
- }
591
- .s-slider input[type="range"]::-webkit-slider-thumb {
592
- -webkit-appearance: none; width: 13px; height: 13px;
593
- border-radius: 50%; background: var(--accent); cursor: pointer;
594
- }
595
- .s-slider .sv {
596
- font-size: 11px; color: var(--dim);
597
- font-family: 'SF Mono', 'Fira Code', monospace;
598
- min-width: 30px; text-align: right;
599
- }
600
- .s-btn {
601
- padding: 6px 14px; font-size: 12px; font-weight: 600;
602
- background: var(--accent); color: #fff; border: none;
603
- border-radius: var(--radius-sm); cursor: pointer;
604
- font-family: inherit; transition: background 0.15s;
605
- }
606
- .s-btn:hover { background: var(--accent2); }
607
- .s-btn:disabled { opacity: 0.4; cursor: not-allowed; }
608
-
609
- /* ── Responsive ── */
610
- @media (min-width: 640px) {
611
- .sov { align-items: center; }
612
- .spanel { border-radius: 16px; max-height: 70vh; }
613
- .s-grid3 {
614
- display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
615
- }
616
- .s-grid3 .s-row { flex-direction: column; align-items: stretch; margin-bottom: 0; }
617
- .s-grid3 .s-row label { min-width: unset; margin-bottom: 4px; }
618
- }
619
- @media (max-height: 520px) {
620
- .app { padding: 6px 12px; gap: 6px; }
621
- .hdr h1 { font-size: 13px; }
622
- .mode-row .label { font-size: 10px; margin-bottom: 2px; }
623
- .play-btn { width: 28px; height: 28px; }
624
- .play-btn svg { width: 12px; height: 12px; }
625
- .player-row audio { height: 28px; }
626
- }
627
-
628
- /* ── Animations ── */
629
- @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} }
630
- @keyframes sweep { 0%{transform:translateX(-200%)} 100%{transform:translateX(500%)} }
631
- @keyframes wavebar { 0%,100%{transform:scaleY(0.3)} 50%{transform:scaleY(1)} }
632
  </style>
633
  </head>
634
  <body>
635
  <div class="app">
636
 
637
- <!-- Header -->
638
  <header class="hdr">
639
- <h1>faster-qwen3-tts</h1>
640
- <span class="badge">CUDA GRAPHS</span>
641
  <span class="spacer"></span>
642
- <button class="mpill" onclick="openSettings()" title="Settings">
643
  <span class="mdot" id="mdot"></span>
644
  <span id="mtext">not loaded</span>
645
  </button>
646
- <button class="ibtn" onclick="openSettings()" title="Settings">
647
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
648
  </button>
649
- <button class="ibtn" id="themeBtn" onclick="toggleTheme()" title="Toggle theme">
650
- <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
651
- </button>
652
  </header>
653
 
654
- <!-- Main content -->
655
  <div class="main">
656
  <div class="text-wrap">
657
- <textarea id="textIn" placeholder="Enter text to synthesize...">Against the odds, the wild lobster has found a new vessel for its voice. And with it, the possibility to realise its full potential.</textarea>
658
  </div>
659
 
660
- <!-- Clone -->
661
  <div class="mode-row" id="rowClone" onclick="onModeRowClick('voice_clone')">
662
- <div class="label">Clone — match a voice from a reference clip<span class="switch-tag">LOAD ▶</span></div>
663
- <!-- Simple / Advanced toggle -->
664
- <div class="clone-toggle-row">
665
- <div class="toggle">
666
- <div class="tog" data-x="1" onclick="setXvec(true)">Simple</div>
667
- <div class="tog on" data-x="0" onclick="setXvec(false)">Advanced</div>
668
- </div>
669
- </div>
670
- <!-- Reference audio + action buttons -->
671
  <div class="row">
672
  <label class="ref-chip" id="refChip">
673
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
674
- <span id="refLabel">Reference audio</span>
675
  <input type="file" id="refInput" accept=".wav,.mp3,.flac,.ogg" onchange="pickRef(this)">
676
  </label>
677
- <button class="mic-btn" id="micBtn" onclick="toggleRec()" title="Record from microphone">
678
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
679
- </button>
680
- <span class="rec-time" id="recTime"></span>
681
- <span class="rec-status" id="recStatus"></span>
682
  <div class="preset-row" id="presetRow"></div>
683
- <button class="play-btn" onclick="generate('voice_clone')" title="Generate">
684
- <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>
685
- </button>
686
- </div>
687
- <!-- Advanced: transcript -->
688
- <div id="advTranscript">
689
- <div class="clone-hint">Advanced cloning uses the transcript of the reference clip for higher accuracy. Upload or record to auto-transcribe.</div>
690
- <div class="instr-wrap">
691
- <textarea id="refTextIn" class="voice-in" rows="2"
692
- placeholder="Upload or record audio to auto-transcribe…"
693
- oninput="autoGrow(this)"></textarea>
694
- </div>
695
  </div>
696
- <!-- Level meter (shown during recording) -->
697
- <div class="rec-bar-wrap" id="recBarWrap"><div class="rec-bar" id="recBar"></div></div>
698
- <!-- Preview of last recording -->
699
- <audio id="recPreview" class="rec-preview" controls></audio>
700
  </div>
701
 
702
- <!-- Custom (built-in speakers) -->
703
  <div class="mode-row" id="rowChar" onclick="onModeRowClick('custom')">
704
- <div class="label">Custom — use a built-in speaker<span class="switch-tag">LOAD ▶</span></div>
705
  <div class="row">
706
- <select class="sm-sel" id="speakerId" style="flex:1">
707
- <option value="">Select speaker...</option>
708
- </select>
709
- <button class="play-btn" onclick="generate('custom')" title="Generate">
710
- <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>
711
- </button>
712
- </div>
713
- <div class="instr-wrap">
714
- <textarea id="customInstr" class="voice-in" rows="1"
715
- placeholder="Voice style instructions (optional)…"
716
- oninput="autoGrow(this)"></textarea>
717
  </div>
718
  </div>
719
 
720
- <!-- Voice Design -->
721
- <div class="mode-row" id="rowCustom" onclick="onModeRowClick('voice_design')">
722
- <div class="label">Voice Design — describe any voice<span class="switch-tag">LOAD ▶</span></div>
723
- <div class="row">
724
- <textarea class="voice-in" id="voiceInstr" rows="1"
725
- placeholder="e.g. warm, calm female narrator…"
726
- oninput="autoGrow(this)">A warm, calm narrator with a clear and engaging delivery.</textarea>
727
- <button class="play-btn" onclick="generate('voice_design')" title="Generate">
728
- <svg viewBox="0 0 24 24" fill="currentColor"><polygon points="6,3 20,12 6,21"/></svg>
729
- </button>
730
- </div>
731
- </div>
732
- <!-- Messages -->
733
- <div class="msg-bar" id="msgBar"></div>
734
- <div class="queue-bar" id="queueBar"></div>
735
-
736
- <!-- Progress -->
737
  <div class="pbar-wrap" id="pbarWrap"><div class="pbar" id="pbar"></div></div>
738
 
739
- <!-- Results -->
740
  <div class="results" id="results">
741
- <div class="player-row">
742
- <audio id="player" controls></audio>
743
- <button class="dl-btn" onclick="dlAudio()" title="Download WAV">&darr;</button>
744
- </div>
745
- <div class="metrics">
746
- <div class="met"><span class="met-k">TTFA</span><span class="met-v" id="mTTFA">&mdash;</span></div>
747
- <div class="met"><span class="met-k">Client</span><span class="met-v" id="mClient">&mdash;</span></div>
748
- <div class="met" id="mCloneWrap" style="display:none"><span class="met-k">Clone</span><span class="met-v" id="mClone">&mdash;</span></div>
749
- <div class="met"><span class="met-k">RTF</span><span class="met-v" id="mRTF">&mdash;</span></div>
750
- <div class="met"><span class="met-k">Dur</span><span class="met-v" id="mDur">&mdash;</span></div>
751
- <div class="met"><span class="met-k">Buf</span><span class="met-v" id="mBuf">&mdash;</span></div>
752
- <div class="wave" id="waveInd"><i></i><i></i><i></i><i></i><i></i></div>
753
- </div>
754
  </div>
755
-
756
  </div>
 
757
 
758
- </div><!-- /.app -->
759
-
760
- <!-- Settings bottom sheet -->
761
  <div class="sov" id="settingsOv" onclick="closeSettings(event)">
762
  <div class="spanel" onclick="event.stopPropagation()">
763
- <div class="s-handle"></div>
764
- <div class="s-head">
765
- <span>Settings</span>
766
- <span class="spacer"></span>
767
- <button class="ibtn" onclick="closeSettings()" title="Close">
768
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
769
- </button>
770
- </div>
771
-
772
- <div class="s-section">Model</div>
773
- <div class="s-row">
774
- <label>Model</label>
775
- <select id="modelSel"></select>
776
- <button class="s-btn" id="loadBtn" onclick="loadModel()">Load</button>
777
- </div>
778
-
779
- <div class="s-section">Generation</div>
780
  <div class="s-row">
781
- <label>Language</label>
782
  <select id="langSel">
783
- <option>English</option><option>Chinese</option><option>French</option>
784
- <option>German</option><option>Spanish</option><option>Auto</option>
 
 
 
785
  </select>
786
  </div>
787
- <div class="s-row">
788
- <label>Mode</label>
789
- <div class="toggle">
790
- <div class="tog on" data-m="stream" onclick="setMode('stream')">Streaming</div>
791
- <div class="tog" data-m="non" onclick="setMode('non')">Non-streaming</div>
792
- </div>
793
- </div>
794
- <div class="s-row" id="chunkRow">
795
- <label>Chunk size</label>
796
- <div class="s-slider">
797
- <input type="range" id="chunkSl" min="1" max="24" value="8"
798
- oninput="$('chunkV').textContent=this.value">
799
- <span class="sv" id="chunkV">8</span>
800
- </div>
801
- </div>
802
-
803
- <div class="s-section">Sampling</div>
804
- <div class="s-grid3">
805
- <div class="s-row">
806
- <label>Temperature</label>
807
- <div class="s-slider">
808
- <input type="range" id="tempSl" min="0.1" max="2.0" step="0.05" value="0.9"
809
- oninput="$('tempV').textContent=this.value">
810
- <span class="sv" id="tempV">0.9</span>
811
- </div>
812
- </div>
813
- <div class="s-row">
814
- <label>Top-K</label>
815
- <div class="s-slider">
816
- <input type="range" id="topkSl" min="1" max="100" value="50"
817
- oninput="$('topkV').textContent=this.value">
818
- <span class="sv" id="topkV">50</span>
819
- </div>
820
- </div>
821
- <div class="s-row">
822
- <label>Rep. penalty</label>
823
- <div class="s-slider">
824
- <input type="range" id="repSl" min="1.0" max="1.5" step="0.01" value="1.05"
825
- oninput="$('repV').textContent=this.value">
826
- <span class="sv" id="repV">1.05</span>
827
- </div>
828
- </div>
829
- </div>
830
-
831
- <div class="s-section">Recording</div>
832
- <div class="s-row">
833
- <label>Microphone</label>
834
- <select id="micSel"></select>
835
- </div>
836
- </div>
837
- </div>
838
-
839
- <!-- ── Model Loader Overlay ── -->
840
- <div class="lov" id="loaderOv" onclick="closeLoader(event)">
841
- <div class="lpanel" onclick="event.stopPropagation()">
842
- <div class="l-header">
843
- <button class="l-back" onclick="$('loaderOv').classList.remove('open')">◀ BACK</button>
844
- <div class="l-title">⬡ <span id="loaderModeLabel"></span> ⬡</div>
845
- </div>
846
- <div class="lcard-grid" id="loaderGrid"></div>
847
- <button class="btn-primary" style="width:100%" onclick="loadFromSheet()">LOAD ▶</button>
848
  </div>
849
  </div>
850
 
851
  <script>
852
- // ── Shorthand ─────────────────────────────────────────────────────────────────
853
  const $ = id => document.getElementById(id);
854
- function autoGrow(el) {
855
- el.style.height = 'auto';
856
- el.style.height = el.scrollHeight + 'px';
857
- }
858
-
859
- function setPlayBtns(disabled) {
860
- document.querySelectorAll('.play-btn').forEach(b => b.disabled = disabled);
861
- }
862
-
863
- // ── State ─────────────────────────────────────────────────────────────────────
864
- let genMode = 'stream';
865
- let xvecOnly = false;
866
- let busy = false;
867
- let refFile = null;
868
- let presetRefId = null;
869
- let presetRefs = [];
870
- let loadedModel = null;
871
- let loadingModel = null;
872
- let dlBlob = null;
873
-
874
- const defaultSpeakers = [
875
- { id: 'Vivian', desc: 'Chinese — Bright young female' },
876
- { id: 'Serena', desc: 'Chinese — Warm gentle female' },
877
- { id: 'Uncle_Fu', desc: 'Chinese — Seasoned low male' },
878
- { id: 'Dylan', desc: 'Chinese — Youthful Beijing male' },
879
- { id: 'Eric', desc: 'Chinese (Sichuan) — Lively male' },
880
- { id: 'Ryan', desc: 'English — Dynamic male' },
881
- { id: 'Aiden', desc: 'English — Sunny American male' },
882
- { id: 'Ono_Anna', desc: 'Japanese — Playful female' },
883
- { id: 'Sohee', desc: 'Korean — Warm female' },
884
  ];
885
 
886
- // Web Audio
887
- let actx = null, sproc = null, pcmQueue = [], rawPcmParts = [], rawPcmSr = 24000;
888
- let chunkQ = Promise.resolve();
889
- let clientT0 = 0, firstChunkAt = null, firstAudioAt = null, lastBufS = 0, firstServerWall = null;
890
-
891
- // Recording
892
- let recStream = null, recCtx = null, recSource = null, recProc = null;
893
- let recGain = null, recAnalyser = null, recMeterRaf = null, recMeterBuf = null;
894
- let recChunks = [], recActive = false, recDeviceId = null;
895
- let recPeak = 0, recHasSignal = false, recSignalTs = null, recSignalCount = 0;
896
- const REC_SR = 24000;
897
-
898
- // ── Init ──────────────────────────────────────────────────────────────────────
899
  window.addEventListener('load', async () => {
900
- initTheme();
901
- autoGrow($('voiceInstr'));
902
  await fetchStatus();
903
- if (!loadedModel && availableModels.length > 0) {
904
- // Prefer CustomVoice as default (richer demo), fall back to first available
905
- const preferred = availableModels.find(m => m.includes('CustomVoice'))
906
- || availableModels[0];
907
- $('modelSel').value = preferred;
908
  loadModel();
909
  }
910
- loadMics();
911
- populateSpeakers([]);
912
- const warm = () => { warmAudio(); window.removeEventListener('pointerdown', warm); };
913
- window.addEventListener('pointerdown', warm, { passive: true });
914
  });
915
 
916
- // ── Theme ─────────────────────────────────────────────────────────────────────
917
- function initTheme() {
918
- const t = localStorage.getItem('theme') ||
919
- (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
920
- applyTheme(t);
921
- }
922
- function toggleTheme() {
923
- applyTheme((document.documentElement.dataset.theme || 'dark') === 'dark' ? 'light' : 'dark');
924
- }
925
- function applyTheme(t) {
926
- document.documentElement.dataset.theme = t;
927
- localStorage.setItem('theme', t);
928
- $('themeIcon').innerHTML = t === 'dark'
929
- ? '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>'
930
- : '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>';
931
- }
932
-
933
- // ── Settings ──────────────────────────────────────────────────────────────────
934
  function openSettings() { $('settingsOv').classList.add('open'); }
935
- function closeSettings(e) {
936
- // Close only when clicking the backdrop or via button (no event)
937
- if (e && e.target !== $('settingsOv')) return;
938
- $('settingsOv').classList.remove('open');
939
- }
940
- function setMode(m) {
941
- genMode = m;
942
- document.querySelectorAll('.tog[data-m]').forEach(el =>
943
- el.classList.toggle('on', el.dataset.m === m));
944
- $('chunkRow').style.display = m === 'stream' ? '' : 'none';
945
- }
946
- function setXvec(v) {
947
- xvecOnly = v;
948
- document.querySelectorAll('.tog[data-x]').forEach(el =>
949
- el.classList.toggle('on', el.dataset.x === (v ? '1' : '0')));
950
- $('advTranscript').style.display = v ? 'none' : '';
951
- }
952
-
953
- function renderPresetRefs(list) {
954
- presetRefs = list || [];
955
- const row = $('presetRow');
956
- if (!row) return;
957
- row.innerHTML = '';
958
- presetRefs.forEach(p => {
959
- const b = document.createElement('button');
960
- b.type = 'button';
961
- b.className = 'preset-btn';
962
- b.textContent = p.label || p.id;
963
- b.dataset.id = p.id;
964
- b.onclick = () => selectPresetRef(p.id);
965
- row.appendChild(b);
966
- });
967
- updatePresetButtons();
968
- if (presetRefs.length > 0 && !presetRefId) selectPresetRef(presetRefs[0].id);
969
- }
970
 
971
- function updatePresetButtons() {
972
- const row = $('presetRow');
973
- if (!row) return;
974
- row.querySelectorAll('.preset-btn').forEach(btn => {
975
- if (btn.dataset.id === presetRefId) btn.classList.add('active');
976
- else btn.classList.remove('active');
977
- });
978
- }
979
 
980
- function clearPresetSelection() {
981
- presetRefId = null;
982
- updatePresetButtons();
983
- }
984
-
985
- async function selectPresetRef(id) {
986
- try {
987
- const d = await fetch(`/preset_ref/${id}`).then(r => r.json());
988
- const bytes = Uint8Array.from(atob(d.audio_b64), c => c.charCodeAt(0));
989
- const blob = new Blob([bytes], { type: 'audio/wav' });
990
- const file = new File([blob], d.filename || `${id}.wav`, { type: 'audio/wav' });
991
-
992
- presetRefId = id;
993
- refFile = null; // avoid uploading when preset is selected
994
- setXvec(false); // presets have verified transcripts — use ICL mode
995
- $('refLabel').textContent = d.label || d.filename || 'Reference audio';
996
- $('refChip').classList.add('has');
997
- const prev = $('recPreview');
998
- if (prev.src) URL.revokeObjectURL(prev.src);
999
- prev.src = URL.createObjectURL(file);
1000
- prev.style.display = 'block';
1001
- $('refTextIn').value = d.ref_text || '';
1002
- autoGrow($('refTextIn'));
1003
- updatePresetButtons();
1004
- } catch {
1005
- showMsg('err', 'Failed to load preset audio.');
1006
- }
1007
- }
1008
-
1009
- // ── Auto-transcription ─────────────────────────────────────────────────────────
1010
- async function transcribeRef() {
1011
- if (!refFile) return;
1012
- const ta = $('refTextIn');
1013
- ta.disabled = true;
1014
- ta.value = '';
1015
- ta.placeholder = 'Transcribing…';
1016
- autoGrow(ta);
1017
- try {
1018
- const fd = new FormData();
1019
- fd.append('audio', refFile);
1020
- const res = await fetch('/transcribe', { method: 'POST', body: fd });
1021
- if (!res.ok) return; // transcription unavailable — leave field empty
1022
- const d = await res.json();
1023
- ta.value = d.text || '';
1024
- autoGrow(ta);
1025
- } catch {
1026
- // network error — leave field empty, user can type manually
1027
- } finally {
1028
- ta.disabled = false;
1029
- if (!ta.value) ta.placeholder = 'Type the full transcript here…';
1030
- }
1031
- }
1032
-
1033
- // ── Model ─────────────────────────────────────────────────────────────────────
1034
  async function fetchStatus() {
1035
  try {
1036
  const d = await fetch('/status').then(r => r.json());
1037
- applyAvailableModels(d.available_models || []);
1038
- if (d.loaded && d.model) {
1039
- // Only trust loadedModel when the server confirms it's actually loaded
1040
- loadedModel = d.model;
1041
- $('modelSel').value = d.model;
1042
- }
1043
- populateSpeakers(d.speakers || []);
1044
- renderPresetRefs(d.preset_refs || []);
1045
- setPill(d.loaded ? 'loaded' : 'off', d.loaded ? 'ready' : 'not loaded');
1046
- } catch { setPill('off', 'offline'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1047
  }
1048
 
1049
  async function loadModel() {
1050
- const btn = $('loadBtn');
1051
- btn.disabled = true; btn.textContent = 'Loading...';
1052
- loadingModel = $('modelSel').value;
1053
- loadedModel = null;
1054
- setPlayBtns(true);
1055
- setPill('loading', 'loading...');
1056
  try {
1057
- const fd = new FormData();
1058
- fd.append('model_id', loadingModel);
1059
- const d = await fetch('/load', { method: 'POST', body: fd }).then(r => r.json());
1060
- if (d.status === 'loaded' || d.status === 'already_loaded') {
1061
- loadedModel = loadingModel;
1062
- loadingModel = null;
1063
- setPill('loaded', 'ready');
1064
- fetchStatus();
1065
- } else {
1066
- loadingModel = null;
1067
- setPill('error', 'failed');
1068
- }
1069
- } catch {
1070
- loadingModel = null;
1071
- setPill('error', 'error');
1072
- }
1073
- btn.disabled = false; btn.textContent = 'Load';
1074
- setPlayBtns(false);
1075
- }
1076
-
1077
- function setPill(state, label) {
1078
- $('mdot').className = 'mdot ' + state;
1079
- $('mtext').textContent = label;
1080
- updateModeRows();
1081
  }
1082
 
1083
  function updateModeRows() {
1084
- function modelToRow(m) {
1085
- if (!m) return null;
1086
- if (m.includes('VoiceDesign')) return 'rowCustom';
1087
- if (m.includes('CustomVoice')) return 'rowChar';
1088
- return 'rowClone';
1089
- }
1090
- const activeRow = modelToRow(loadingModel ?? loadedModel);
1091
- const isLoading = !!loadingModel;
1092
- for (const id of ['rowClone', 'rowChar', 'rowCustom']) {
1093
- const el = $(id);
1094
- el.classList.toggle('compatible', id === activeRow && !isLoading);
1095
- el.classList.toggle('loading', id === activeRow && isLoading);
1096
- el.classList.toggle('incompatible', activeRow !== null && id !== activeRow);
1097
- }
1098
- }
1099
-
1100
- // ── Arcade mode loader ─────────────────────────────────────────────────────────
1101
- const ALL_MODE_MODELS = {
1102
- voice_clone: [
1103
- { id: 'Qwen/Qwen3-TTS-12Hz-0.6B-Base', name: '0.6B Base', sub: 'Fastest · RTF ~4×' },
1104
- { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-Base', name: '1.7B Base', sub: 'Higher quality · RTF ~3.5×' },
1105
- ],
1106
- custom: [
1107
- { id: 'Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice', name: '0.6B CustomVoice', sub: 'Fastest' },
1108
- { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice', name: '1.7B CustomVoice', sub: 'Higher quality' },
1109
- ],
1110
- voice_design: [
1111
- { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign', name: '1.7B VoiceDesign', sub: 'Describe any voice' },
1112
- ],
1113
- };
1114
- // Filtered at runtime by available_models from /status
1115
- let MODE_MODELS = ALL_MODE_MODELS;
1116
- const MODE_LABELS = { voice_clone: 'Clone', custom: 'Custom', voice_design: 'Voice Design' };
1117
- let loaderMode = null;
1118
- let loaderSelectedModel = null;
1119
- let availableModels = [];
1120
-
1121
- function applyAvailableModels(available) {
1122
- availableModels = available || [];
1123
- const avail = new Set(availableModels);
1124
-
1125
- // Rebuild model selector options
1126
- const sel = $('modelSel');
1127
- const prev = sel.value;
1128
- sel.innerHTML = '';
1129
- for (const [mode, models] of Object.entries(ALL_MODE_MODELS)) {
1130
- for (const m of models) {
1131
- if (!avail.has(m.id)) continue;
1132
- const opt = document.createElement('option');
1133
- opt.value = m.id;
1134
- opt.textContent = m.name;
1135
- sel.appendChild(opt);
1136
- }
1137
- }
1138
- // Restore selection if still available, else pick first
1139
- if (avail.has(prev)) sel.value = prev;
1140
- else if (sel.options.length > 0) sel.value = sel.options[0].value;
1141
-
1142
- // Filter MODE_MODELS to available only
1143
- MODE_MODELS = {};
1144
- for (const [mode, models] of Object.entries(ALL_MODE_MODELS)) {
1145
- MODE_MODELS[mode] = models.filter(m => avail.has(m.id));
1146
- }
1147
-
1148
- // Hide mode rows that have no available models; show those that do
1149
- const modeRows = { voice_clone: 'rowClone', custom: 'rowChar', voice_design: 'rowCustom' };
1150
- for (const [mode, rowId] of Object.entries(modeRows)) {
1151
- $(rowId).style.display = MODE_MODELS[mode].length > 0 ? '' : 'none';
1152
- }
1153
  }
1154
 
1155
  function onModeRowClick(mode) {
1156
- const rowMap = { voice_clone: 'rowClone', custom: 'rowChar', voice_design: 'rowCustom' };
1157
- if (!$(rowMap[mode]).classList.contains('incompatible')) return;
1158
- openModelLoader(mode);
1159
- }
1160
-
1161
- function openModelLoader(mode) {
1162
- loaderMode = mode;
1163
- const models = MODE_MODELS[mode];
1164
- loaderSelectedModel = models[0].id;
1165
-
1166
- const grid = $('loaderGrid');
1167
- grid.innerHTML = '';
1168
- models.forEach(m => {
1169
- const card = document.createElement('div');
1170
- card.className = 'lcard' + (m.id === loaderSelectedModel ? ' sel' : '');
1171
- card.dataset.id = m.id;
1172
- card.innerHTML = `<div class="lc-name">${m.name}</div><div class="lc-sub">${m.sub}</div>`;
1173
- card.onclick = () => {
1174
- loaderSelectedModel = m.id;
1175
- grid.querySelectorAll('.lcard').forEach(c => c.classList.toggle('sel', c.dataset.id === m.id));
1176
- };
1177
- grid.appendChild(card);
1178
- });
1179
-
1180
- $('loaderModeLabel').textContent = MODE_LABELS[mode] || mode;
1181
- $('loaderOv').classList.add('open');
1182
- }
1183
-
1184
- function closeLoader(e) {
1185
- if (e && e.target !== $('loaderOv')) return;
1186
- $('loaderOv').classList.remove('open');
1187
- }
1188
-
1189
- async function loadFromSheet() {
1190
- if (!loaderSelectedModel) return;
1191
- $('loaderOv').classList.remove('open');
1192
- $('modelSel').value = loaderSelectedModel;
1193
- await loadModel();
1194
- }
1195
-
1196
- // ── Speakers ──────────────────────────────────────────────────────────────────
1197
- function populateSpeakers(list) {
1198
- const sel = $('speakerId');
1199
- const prev = sel.value;
1200
- sel.innerHTML = '<option value="" disabled hidden>Select speaker...</option>';
1201
- let ids = Array.isArray(list) ? list : Object.keys(list || {});
1202
- const defaultMap = new Map(defaultSpeakers.map(s => [s.id.toLowerCase(), s]));
1203
- if (!ids.length) ids = defaultSpeakers.map(s => s.id);
1204
-
1205
- // Normalize to canonical capitalization from defaultSpeakers where known
1206
- ids = ids.map(id => defaultMap.get(String(id).toLowerCase())?.id ?? id);
1207
-
1208
- // Keep previous selection even if backend list doesn't include it (case-insensitive)
1209
- if (prev && !ids.some(id => id.toLowerCase() === prev.toLowerCase())) ids = [prev, ...ids];
1210
-
1211
- // Order: English first (based on known descriptions), then the rest
1212
- ids.sort((a, b) => {
1213
- const aInfo = defaultMap.get(String(a).toLowerCase());
1214
- const bInfo = defaultMap.get(String(b).toLowerCase());
1215
- const aEn = aInfo && aInfo.desc.includes('English') ? 0 : 1;
1216
- const bEn = bInfo && bInfo.desc.includes('English') ? 0 : 1;
1217
- if (aEn !== bEn) return aEn - bEn;
1218
- return String(a).localeCompare(String(b));
1219
- });
1220
-
1221
- ids.forEach(id => {
1222
- const o = document.createElement('option');
1223
- o.value = id; // keep backend ID for requests
1224
- const info = defaultMap.get(String(id).toLowerCase());
1225
- const label = info ? `${info.id} — ${info.desc}` : id;
1226
- o.textContent = label;
1227
- sel.appendChild(o);
1228
- });
1229
-
1230
- if (prev) {
1231
- sel.value = prev;
1232
- } else {
1233
- const defaultId = 'Aiden';
1234
- sel.value = ids.find(id => id.toLowerCase() === defaultId.toLowerCase()) ?? ids[0] ?? '';
1235
- }
1236
- }
1237
-
1238
- // ── Mic devices ───────────────────────────────────────────────────────────────
1239
- async function loadMics() {
1240
- if (!navigator.mediaDevices?.enumerateDevices) return;
1241
- try {
1242
- const devs = await navigator.mediaDevices.enumerateDevices();
1243
- const inputs = devs.filter(d => d.kind === 'audioinput');
1244
- const sel = $('micSel');
1245
- const prevSelection = recDeviceId || sel.value;
1246
- sel.innerHTML = '';
1247
- let defaultId = null;
1248
- inputs.forEach((d, i) => {
1249
- const o = document.createElement('option');
1250
- o.value = d.deviceId;
1251
- o.textContent = d.label || `Microphone ${i + 1}`;
1252
- sel.appendChild(o);
1253
- const lbl = (d.label || '').toLowerCase();
1254
- if (!defaultId && (d.deviceId === 'default' || lbl.includes('default'))) {
1255
- defaultId = d.deviceId;
1256
- }
1257
- });
1258
- sel.onchange = () => { recDeviceId = sel.value; };
1259
- if (prevSelection && inputs.some(d => d.deviceId === prevSelection)) {
1260
- recDeviceId = prevSelection;
1261
- sel.value = prevSelection;
1262
- } else if (defaultId) {
1263
- recDeviceId = defaultId;
1264
- sel.value = defaultId;
1265
- } else if (inputs.length) {
1266
- recDeviceId = inputs[0].deviceId;
1267
- sel.value = inputs[0].deviceId;
1268
- }
1269
- } catch {}
1270
  }
1271
 
1272
- // ── Reference audio ───────────────────────────────────────────────────────────
1273
- function pickRef(input) { const f = input.files[0]; if (f) setRef(f); }
1274
- function setRef(f) {
1275
- clearPresetSelection();
1276
- refFile = f;
1277
- $('refLabel').textContent = f.name;
1278
- $('refChip').classList.add('has');
1279
- const prev = $('recPreview');
1280
- if (prev.src) URL.revokeObjectURL(prev.src);
1281
- prev.src = URL.createObjectURL(f);
1282
- prev.style.display = 'block';
1283
- transcribeRef();
1284
  }
1285
 
1286
- // ── Recording ─────────────────────────────────────────────────────────────────
1287
- function toggleRec() { if (recActive) stopRec(); else startRec(); }
1288
-
1289
- async function startRec() {
1290
- if (!navigator.mediaDevices?.getUserMedia) {
1291
- showMsg('err', 'Recording not supported. Use HTTPS or localhost.');
1292
- return;
1293
- }
1294
- // Secure-context guard — 0.0.0.0 will silently fail without this
1295
- if (!window.isSecureContext) {
1296
- const { hostname, port } = window.location;
1297
- if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
1298
- showMsg('err', `Recording requires HTTPS or localhost. Try http://localhost:${port}/`);
1299
- return;
1300
- }
1301
- }
1302
- hideMsg();
1303
- recChunks = []; recPeak = 0;
1304
- recHasSignal = false; recSignalTs = null; recSignalCount = 0;
1305
- recActive = true;
1306
- $('micBtn').classList.add('rec');
1307
- $('recStatus').textContent = 'Starting mic…';
1308
- $('recBarWrap').style.display = 'block';
1309
- $('recPreview').style.display = 'none';
1310
-
1311
- try {
1312
- const constraints = {
1313
- audio: { channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false }
1314
- };
1315
- if (recDeviceId) constraints.audio.deviceId = { exact: recDeviceId };
1316
- recStream = await navigator.mediaDevices.getUserMedia(constraints);
1317
- await loadMics(); // refresh labels now that permission is granted
1318
-
1319
- recCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
1320
- if (recCtx.state === 'suspended') await recCtx.resume();
1321
-
1322
- recSource = recCtx.createMediaStreamSource(recStream);
1323
- recAnalyser = recCtx.createAnalyser();
1324
- recAnalyser.fftSize = 2048;
1325
- recGain = recCtx.createGain();
1326
- recGain.gain.value = 0.0001; // near-silent, keeps graph alive
1327
-
1328
- recProc = recCtx.createScriptProcessor(4096, 1, 1);
1329
- recProc.onaudioprocess = e => {
1330
- if (!recActive) return;
1331
- const inp = e.inputBuffer.getChannelData(0);
1332
- const copy = new Float32Array(inp.length);
1333
- for (let i = 0; i < inp.length; i++) {
1334
- copy[i] = inp[i];
1335
- const a = Math.abs(inp[i]);
1336
- if (a > recPeak) recPeak = a;
1337
- }
1338
- recChunks.push(copy);
1339
- };
1340
-
1341
- recSource.connect(recAnalyser);
1342
- recAnalyser.connect(recProc);
1343
- recProc.connect(recGain);
1344
- recGain.connect(recCtx.destination);
1345
-
1346
- recMeterBuf = new Uint8Array(recAnalyser.fftSize);
1347
-
1348
- const tick = () => {
1349
- if (!recActive) return;
1350
- recAnalyser.getByteTimeDomainData(recMeterBuf);
1351
- let peak = 0;
1352
- for (let i = 0; i < recMeterBuf.length; i++) {
1353
- const v = Math.abs(recMeterBuf[i] - 128);
1354
- if (v > peak) peak = v;
1355
- }
1356
- const norm = Math.min(1, peak / 64);
1357
- $('recBar').style.width = Math.round(norm * 100) + '%';
1358
-
1359
- if (norm > 0.02) recSignalCount++;
1360
- else recSignalCount = 0;
1361
- if (!recHasSignal && recSignalCount >= 4) {
1362
- recHasSignal = true;
1363
- recSignalTs = performance.now();
1364
- }
1365
-
1366
- $('recStatus').textContent = recHasSignal ? 'Recording…' : 'Waiting for signal…';
1367
- if (recHasSignal) {
1368
- const s = Math.floor((performance.now() - recSignalTs) / 1000);
1369
- $('recTime').textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
1370
- }
1371
- recMeterRaf = requestAnimationFrame(tick);
1372
- };
1373
- recMeterRaf = requestAnimationFrame(tick);
1374
-
1375
- } catch (e) {
1376
- showMsg('err', 'Mic failed: ' + e.message);
1377
- cleanupRec();
1378
- }
1379
- }
1380
-
1381
- async function stopRec() {
1382
- recActive = false;
1383
- if (recMeterRaf) { cancelAnimationFrame(recMeterRaf); recMeterRaf = null; }
1384
- $('micBtn').classList.remove('rec');
1385
- $('recStatus').textContent = '';
1386
- $('recTime').textContent = '';
1387
- $('recBar').style.width = '0%';
1388
- $('recBarWrap').style.display = 'none';
1389
-
1390
- if (!recChunks.length) {
1391
- showMsg('err', 'No audio captured.');
1392
- cleanupRec(); return;
1393
- }
1394
-
1395
- try {
1396
- const total = recChunks.reduce((s, c) => s + c.length, 0);
1397
- const buf = recCtx.createBuffer(1, total, recCtx.sampleRate);
1398
- const ch = buf.getChannelData(0);
1399
- let off = 0;
1400
- for (const c of recChunks) { ch.set(c, off); off += c.length; }
1401
-
1402
- // Show preview immediately from raw buffer — no resampling needed for playback
1403
- const rawWav = bufToWav(buf);
1404
- const prev = $('recPreview');
1405
- if (prev.src) URL.revokeObjectURL(prev.src);
1406
- prev.src = URL.createObjectURL(rawWav);
1407
- prev.style.display = 'block';
1408
-
1409
- // Resample to 24kHz for server use (codec's native rate; transcription server handles 16kHz resampling)
1410
- let out = buf;
1411
- if (buf.sampleRate !== REC_SR) {
1412
- const len = Math.ceil(buf.duration * REC_SR);
1413
- const offCtx = new OfflineAudioContext(1, len, REC_SR);
1414
- const src = offCtx.createBufferSource();
1415
- src.buffer = buf; src.connect(offCtx.destination); src.start(0);
1416
- out = await offCtx.startRendering();
1417
- }
1418
- const wav = bufToWav(out);
1419
- clearPresetSelection();
1420
- refFile = new File([wav], 'recorded.wav', { type: 'audio/wav' });
1421
- $('refLabel').textContent = 'recorded.wav';
1422
- $('refChip').classList.add('has');
1423
- transcribeRef();
1424
- } catch (e) {
1425
- showMsg('err', 'Recording failed: ' + e.message);
1426
- }
1427
- cleanupRec();
1428
- }
1429
-
1430
- function cleanupRec() {
1431
- if (recStream) recStream.getTracks().forEach(t => t.stop());
1432
- recStream = null; recActive = false;
1433
- if (recMeterRaf) { cancelAnimationFrame(recMeterRaf); recMeterRaf = null; }
1434
- recMeterBuf = null;
1435
- if (recAnalyser) recAnalyser.disconnect();
1436
- if (recProc) recProc.disconnect();
1437
- if (recGain) recGain.disconnect();
1438
- if (recSource) recSource.disconnect();
1439
- if (recCtx) recCtx.close().catch(() => {});
1440
- recAnalyser = null; recProc = null; recGain = null; recSource = null; recCtx = null;
1441
- $('micBtn').classList.remove('rec');
1442
- }
1443
-
1444
- function bufToWav(buffer) {
1445
- const pcm = buffer.getChannelData(0);
1446
- const len = pcm.length, sr = buffer.sampleRate;
1447
- const ab = new ArrayBuffer(44 + len * 2);
1448
- const v = new DataView(ab);
1449
- const ws = (o, s) => [...s].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
1450
- ws(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true);
1451
- ws(8, 'WAVE'); ws(12, 'fmt ');
1452
- v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
1453
- v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true);
1454
- v.setUint16(32, 2, true); v.setUint16(34, 16, true);
1455
- ws(36, 'data'); v.setUint32(40, len * 2, true);
1456
- let off = 44;
1457
- for (let i = 0; i < len; i++, off += 2) {
1458
- const s = Math.max(-1, Math.min(1, pcm[i]));
1459
- v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
1460
- }
1461
- return new Blob([ab], { type: 'audio/wav' });
1462
- }
1463
-
1464
- // ── Web Audio: gapless streaming via ScriptProcessor ─────────────────────────
1465
- async function warmAudio() {
1466
- if (actx) return;
1467
- try { await initAudio(24000); } catch {}
1468
- }
1469
-
1470
- async function initAudio(sr) {
1471
- rawPcmSr = sr || 24000;
1472
- pcmQueue = []; rawPcmParts = [];
1473
- chunkQ = Promise.resolve(); dlBlob = null;
1474
- firstChunkAt = null; firstAudioAt = null; lastBufS = 0;
1475
-
1476
- if (actx) {
1477
- if (actx.state === 'suspended') await actx.resume();
1478
- return;
1479
- }
1480
- actx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: rawPcmSr });
1481
- sproc = actx.createScriptProcessor(256, 0, 1);
1482
- sproc.onaudioprocess = e => {
1483
- const out = e.outputBuffer.getChannelData(0);
1484
- let i = 0, wrote = false;
1485
- while (i < out.length) {
1486
- if (!pcmQueue.length) { out.fill(0, i); break; }
1487
- const seg = pcmQueue[0];
1488
- const take = Math.min(out.length - i, seg.data.length - seg.pos);
1489
- out.set(seg.data.subarray(seg.pos, seg.pos + take), i);
1490
- seg.pos += take; i += take; wrote = true;
1491
- if (seg.pos >= seg.data.length) pcmQueue.shift();
1492
- }
1493
- if (wrote && firstAudioAt == null) {
1494
- firstAudioAt = performance.now();
1495
- pushClientMetrics();
1496
- }
1497
- };
1498
- if (actx.state === 'suspended') await actx.resume();
1499
- sproc.connect(actx.destination);
1500
- }
1501
-
1502
- function parseWav(bytes) {
1503
- const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1504
- const sr = v.getUint32(24, true);
1505
- let off = 12;
1506
- while (off + 8 <= bytes.length) {
1507
- const id = String.fromCharCode(bytes[off], bytes[off+1], bytes[off+2], bytes[off+3]);
1508
- const size = v.getUint32(off + 4, true);
1509
- if (id === 'data') {
1510
- const raw = bytes.slice(off + 8, off + 8 + size);
1511
- const i16 = new Int16Array(raw.buffer, raw.byteOffset, raw.byteLength / 2);
1512
- const f32 = new Float32Array(i16.length);
1513
- for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768.0;
1514
- return { pcm: f32, rawPcm: raw, sr };
1515
- }
1516
- off += 8 + size;
1517
- }
1518
- return null;
1519
- }
1520
-
1521
- function enqueueChunk(b64) {
1522
- chunkQ = chunkQ.then(() => {
1523
- if (!actx) return;
1524
- const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
1525
- const p = parseWav(bytes);
1526
- if (!p) return;
1527
- rawPcmParts.push(p.rawPcm);
1528
- pcmQueue.push({ data: p.pcm, pos: 0 });
1529
- if (firstChunkAt == null) firstChunkAt = performance.now();
1530
- lastBufS = pcmQueue.reduce((s, seg) => s + (seg.data.length - seg.pos), 0) / rawPcmSr;
1531
- pushClientMetrics();
1532
- });
1533
- }
1534
-
1535
- function buildFinalWav() {
1536
- if (!rawPcmParts.length) return null;
1537
- const totalPcm = rawPcmParts.reduce((s, p) => s + p.length, 0);
1538
- const ab = new ArrayBuffer(44 + totalPcm);
1539
- const v = new DataView(ab);
1540
- const ws = (o, s) => [...s].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
1541
- ws(0, 'RIFF'); v.setUint32(4, 36 + totalPcm, true);
1542
- ws(8, 'WAVE'); ws(12, 'fmt ');
1543
- v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
1544
- v.setUint32(24, rawPcmSr, true); v.setUint32(28, rawPcmSr * 2, true);
1545
- v.setUint16(32, 2, true); v.setUint16(34, 16, true);
1546
- ws(36, 'data'); v.setUint32(40, totalPcm, true);
1547
- const out = new Uint8Array(ab, 44);
1548
- let offset = 0;
1549
- for (const part of rawPcmParts) { out.set(part, offset); offset += part.length; }
1550
- return new Blob([ab], { type: 'audio/wav' });
1551
  }
1552
 
1553
- // ── Generate ──────────────────────────────────────────────────────────────────
1554
  async function generate(mode) {
1555
- if (busy) return;
1556
-
1557
- const isVD = loadedModel?.includes('VoiceDesign');
1558
- const isCV = loadedModel?.includes('CustomVoice');
1559
- if (mode === 'voice_design' && loadedModel && !isVD) {
1560
- showMsg('warn', 'Voice design requires the 1.7B-VoiceDesign model.',
1561
- 'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign', mode); return;
1562
- }
1563
- if (mode === 'custom' && loadedModel && !isCV) {
1564
- showMsg('warn', 'Custom speaker requires a CustomVoice model.',
1565
- 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice', mode); return;
1566
- }
1567
- if (mode === 'voice_clone' && loadedModel && (isVD || isCV)) {
1568
- showMsg('warn', 'Voice clone needs a Base model.',
1569
- 'Qwen/Qwen3-TTS-12Hz-0.6B-Base', mode); return;
1570
- }
1571
- if (mode === 'voice_clone' && !refFile && !presetRefId) {
1572
- showMsg('err', 'Add reference audio first.');
1573
- return;
1574
- }
1575
- if (mode === 'custom' && !$('speakerId').value) { showMsg('err', 'Select a speaker first.'); return; }
1576
- hideMsg();
1577
- $('queueBar').className = 'queue-bar';
1578
-
1579
- busy = true;
1580
- setPlayBtns(true);
1581
-
1582
- $('results').classList.add('show');
1583
- $('player').parentElement.style.display = 'none';
1584
- $('waveInd').classList.remove('off');
1585
- $('pbarWrap').classList.add('show');
1586
- const pb = $('pbar');
1587
- pb.style.width = ''; pb.classList.add('spin');
1588
- resetMetrics();
1589
-
1590
  const fd = new FormData();
1591
- fd.append('text', $('textIn').value);
1592
- fd.append('mode', mode);
1593
- fd.append('language', $('langSel').value);
1594
- fd.append('temperature', $('tempSl').value);
1595
- fd.append('top_k', $('topkSl').value);
1596
- fd.append('repetition_penalty', $('repSl').value);
1597
-
1598
  if (mode === 'voice_clone') {
1599
- if (presetRefId) {
1600
- fd.append('ref_preset', presetRefId);
1601
- } else if (refFile) {
1602
- fd.append('ref_audio', refFile);
1603
- }
1604
- fd.append('xvec_only', xvecOnly ? 'true' : 'false');
1605
- if (!xvecOnly) fd.append('ref_text', $('refTextIn').value);
1606
  } else if (mode === 'custom') {
1607
- fd.append('speaker', $('speakerId').value); // value is already the bare ID
1608
- fd.append('instruct', $('customInstr').value);
1609
- } else {
1610
- fd.append('instruct', $('voiceInstr').value);
1611
  }
1612
 
1613
  try {
1614
- if (genMode === 'stream') {
1615
- fd.append('chunk_size', $('chunkSl').value);
1616
- await runStream(fd);
1617
- } else {
1618
- await runNonStream(fd);
1619
- }
1620
- } catch (e) {
1621
- showMsg('err', 'Generation failed: ' + e.message);
1622
- pb.classList.remove('spin'); pb.style.width = '0%';
1623
- }
1624
-
1625
- busy = false;
1626
- setPlayBtns(false);
1627
- }
1628
-
1629
- async function runStream(fd) {
1630
- clientT0 = performance.now();
1631
- firstChunkAt = null; firstAudioAt = null; lastBufS = 0; firstServerWall = null;
1632
-
1633
- const res = await fetch('/generate/stream', { method: 'POST', body: fd });
1634
- if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Request failed'); }
1635
-
1636
- const reader = res.body.getReader();
1637
- const dec = new TextDecoder();
1638
- let buf = '', audioInited = false;
1639
-
1640
- while (true) {
1641
- const { done, value } = await reader.read();
1642
- if (done) break;
1643
- buf += dec.decode(value, { stream: true });
1644
- const lines = buf.split('\n');
1645
- buf = lines.pop();
1646
-
1647
- for (const line of lines) {
1648
- if (!line.startsWith('data: ')) continue;
1649
- const d = JSON.parse(line.slice(6));
1650
-
1651
- if (d.type === 'queued') {
1652
- const bar = $('queueBar');
1653
- bar.textContent = d.position === 1
1654
- ? 'Waiting... (1 request ahead of you)'
1655
- : `Waiting... (${d.position} requests ahead of you)`;
1656
- bar.classList.add('show');
1657
-
1658
- } else if (d.type === 'chunk') {
1659
- $('queueBar').className = 'queue-bar';
1660
- if (!audioInited) { await initAudio(d.sample_rate); audioInited = true; }
1661
- // Capture server wall time on first chunk
1662
- if (firstChunkAt == null && d.elapsed_ms != null) firstServerWall = d.elapsed_ms;
1663
- pushMetrics(d.ttfa_ms, d.rtf, d.total_audio_s, d.voice_clone_ms);
1664
- enqueueChunk(d.audio_b64);
1665
-
1666
- } else if (d.type === 'done') {
1667
- $('queueBar').className = 'queue-bar';
1668
- pushMetrics(d.ttfa_ms, d.rtf, d.total_audio_s, d.voice_clone_ms);
1669
- await chunkQ;
1670
- setDone();
1671
- const blob = buildFinalWav();
1672
- if (blob) setPlayer(blob);
1673
-
1674
- } else if (d.type === 'error') {
1675
- $('queueBar').className = 'queue-bar';
1676
- throw new Error(d.message);
1677
- }
1678
- }
1679
- }
1680
- }
1681
-
1682
- async function runNonStream(fd) {
1683
- $('waveInd').classList.add('off');
1684
- const res = await fetch('/generate', { method: 'POST', body: fd });
1685
- if (!res.ok) { const e = await res.json(); throw new Error(e.detail || 'Request failed'); }
1686
- const d = await res.json();
1687
- const m = d.metrics;
1688
- pushMetrics(m.total_ms, m.rtf, m.audio_duration_s, m.voice_clone_ms);
1689
- setDone();
1690
- const bytes = Uint8Array.from(atob(d.audio_b64), c => c.charCodeAt(0));
1691
- const blob = new Blob([bytes], { type: 'audio/wav' });
1692
- setPlayer(blob);
1693
- $('player').play().catch(() => {}); // gracefully handle autoplay block
1694
- }
1695
-
1696
- // ── Metrics ───────────────────────────────────────────────────────────────────
1697
- function resetMetrics() {
1698
- ['mTTFA', 'mClient', 'mRTF', 'mDur', 'mBuf', 'mClone'].forEach(id => $(id).innerHTML = '&mdash;');
1699
- $('mCloneWrap').style.display = 'none';
1700
- }
1701
- function pushMetrics(ttfa, rtf, dur, cloneMs) {
1702
- if (ttfa != null) $('mTTFA').textContent = Math.round(ttfa) + 'ms';
1703
- if (rtf != null) $('mRTF').textContent = rtf.toFixed(2) + 'x';
1704
- if (dur != null) $('mDur').textContent = dur.toFixed(1) + 's';
1705
- if (cloneMs != null) {
1706
- $('mClone').textContent = Math.round(cloneMs) + 'ms';
1707
- $('mCloneWrap').style.display = '';
1708
- }
1709
- }
1710
- function pushClientMetrics() {
1711
- // Client TTFA: time from request to first audio heard
1712
- if (firstAudioAt != null)
1713
- $('mClient').textContent = Math.round(firstAudioAt - clientT0) + 'ms';
1714
- else if (firstChunkAt != null)
1715
- $('mClient').textContent = Math.round(firstChunkAt - clientT0) + 'ms';
1716
- // Buffer: audio queued ahead of playback
1717
- if (lastBufS != null)
1718
- $('mBuf').textContent = lastBufS.toFixed(2) + 's';
1719
- }
1720
- function setDone() {
1721
- $('waveInd').classList.add('off');
1722
- const pb = $('pbar');
1723
- pb.classList.remove('spin'); pb.style.width = '100%';
1724
- }
1725
- function setPlayer(blob) {
1726
- dlBlob = blob;
1727
- $('player').src = URL.createObjectURL(blob);
1728
- $('player').parentElement.style.display = '';
1729
- }
1730
-
1731
- // ── Messages ──────────────────────────────────────────────────────────────────
1732
- let pendingMode = null;
1733
- function showMsg(type, text, switchModel, mode) {
1734
- const el = $('msgBar');
1735
- el.className = 'msg-bar show ' + type;
1736
- if (switchModel) {
1737
- pendingMode = mode;
1738
- el.innerHTML = '<span>' + text + '</span>' +
1739
- '<button class="sw-btn" onclick="switchAndGen(\'' + switchModel + '\')">Load &amp; generate</button>';
1740
- } else {
1741
- el.textContent = text;
1742
- }
1743
- }
1744
- function hideMsg() { $('msgBar').className = 'msg-bar'; }
1745
-
1746
- async function switchAndGen(target) {
1747
- if (busy) return;
1748
- const mode = pendingMode;
1749
- busy = true; hideMsg(); setPlayBtns(true);
1750
- $('modelSel').value = target;
1751
- try { await loadModel(); } finally { busy = false; }
1752
- setPlayBtns(false);
1753
- generate(mode);
1754
- }
1755
-
1756
- // ── Download ──────────────────────────────────────────────────────────────────
1757
- function dlAudio() {
1758
- if (!dlBlob) return;
1759
- const a = document.createElement('a');
1760
- a.href = URL.createObjectURL(dlBlob);
1761
- a.download = 'generated_speech.wav';
1762
- a.click();
1763
- URL.revokeObjectURL(a.href);
1764
  }
1765
-
1766
- // ── Keyboard shortcuts ────────────────────────────────────────────────────────
1767
- document.addEventListener('keydown', e => {
1768
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1769
- e.preventDefault();
1770
- // Pick best mode based on what's filled in
1771
- const mode = refFile ? 'voice_clone' : $('speakerId').value ? 'custom' : 'voice_design';
1772
- generate(mode);
1773
- }
1774
- if (e.key === 'Escape') {
1775
- if ($('loaderOv').classList.contains('open')) $('loaderOv').classList.remove('open');
1776
- else if ($('settingsOv').classList.contains('open')) closeSettings();
1777
- }
1778
- });
1779
  </script>
1780
  </body>
1781
- </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
6
+ <title>Qwen3-TTS</title>
7
  <style>
8
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
 
 
55
  line-height: 1.5;
56
  }
57
 
58
+ .app { display: flex; flex-direction: column; justify-content: center; min-height: 100vh; max-width: 680px; margin: 0 auto; padding: 10px 16px; gap: 8px; }
59
+ .hdr { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
60
+ .hdr h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; color: var(--accent); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  .spacer { flex: 1; }
62
+ .mpill { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--dim); padding: 4px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 20px; cursor: pointer; }
63
+ .mdot { width: 6px; height: 6px; border-radius: 50%; background: var(--dimmer); flex-shrink: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
64
  .mdot.loaded { background: var(--green); }
65
  .mdot.loading { background: var(--amber); animation: blink 0.7s infinite; }
66
  .mdot.error { background: var(--red); }
67
+ .ibtn { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--border); border-radius: 8px; color: var(--dim); cursor: pointer; }
 
 
 
 
 
 
 
 
 
68
 
69
+ .main { display: flex; flex-direction: column; gap: 6px; }
70
+ .text-wrap textarea { width: 100%; height: 120px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 15px; padding: 10px 12px; resize: none; outline: none; }
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
+ .mode-row { flex-shrink: 0; padding: 7px 9px; border-radius: var(--radius-sm); transition: box-shadow 0.2s, opacity 0.2s; }
73
+ .mode-row .label { font-size: 11px; color: var(--dim); margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
74
+ .mode-row .label::after { content: ''; flex: 1; height: 1px; background: var(--border); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  .mode-row .row { display: flex; align-items: flex-start; gap: 6px; }
76
+ .mode-row.compatible { box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(139,92,246,0.45); }
77
+ .mode-row.compatible .label { color: var(--accent); font-weight: 600; }
78
+ .mode-row.loading { animation: row-pulse 0.7s ease-in-out infinite alternate; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  .mode-row.incompatible { opacity: 0.42; cursor: pointer; }
 
80
  .mode-row.incompatible > * { pointer-events: none; }
81
+ .mode-row.incompatible:hover { opacity: 0.9; box-shadow: 0 0 0 1px var(--border2); }
 
 
 
 
 
82
 
83
+ .lov { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(2px); z-index: 110; display: flex; align-items: center; justify-content: center; padding: 20px; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  .lov.open { opacity: 1; pointer-events: auto; }
85
+ .lpanel { background: var(--bg); border: 1px solid var(--border); border-top: 2px solid var(--accent); border-radius: 16px; width: 100%; max-width: 460px; padding: 20px; transform: scale(0.93) translateY(12px); transition: transform 0.25s; }
 
 
 
 
 
 
 
 
 
 
 
86
  .lov.open .lpanel { transform: scale(1) translateY(0); }
87
+ .l-header { display: flex; align-items: center; margin-bottom: 14px; }
88
+ .l-back { display: flex; align-items: center; font-size: 11px; color: var(--dim); cursor: pointer; border: none; background: none; margin-right: auto; }
89
+ .lcard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; margin-bottom: 14px; }
90
+ .lcard { border: 1px solid var(--border2); border-radius: var(--radius-sm); padding: 10px 12px; cursor: pointer; background: var(--surface); }
91
+ .lcard.sel { border-color: var(--accent); background: var(--accent-bg); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  .lcard .lc-name { font-size: 13px; font-weight: 600; color: var(--text); }
93
  .lcard .lc-sub { font-size: 11px; color: var(--dim); margin-top: 2px; }
 
94
 
95
+ .ref-chip { display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; background: var(--surface); border: 1px dashed var(--border2); border-radius: var(--radius-sm); font-size: 12px; color: var(--dim); cursor: pointer; position: relative; overflow: hidden; max-width: 220px; }
 
 
 
 
 
 
 
 
 
 
 
96
  .ref-chip.has { border-style: solid; color: var(--accent); border-color: var(--accent-ring); }
97
+ .ref-chip input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
 
 
98
  .ref-chip svg { width: 14px; height: 14px; flex-shrink: 0; }
 
99
 
100
+ .preset-row { display: contents; }
101
+ .preset-btn { background: var(--surface2); border: 1px solid var(--border2); color: var(--text2); padding: 6px 10px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  .preset-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
103
 
104
+ .sm-sel { padding: 6px 28px 6px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); font-size: 12px; appearance: none; }
105
+ .voice-in { flex: 1; padding: 6px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); font-size: 12px; resize: none; overflow: hidden; min-height: 32px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
+ .play-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); cursor: pointer; margin-left: auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  .play-btn:disabled { opacity: 0.35; cursor: not-allowed; }
 
109
 
110
+ .pbar-wrap { height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  .pbar-wrap.show { display: block; }
112
+ .pbar { height: 100%; background: var(--accent); border-radius: 1px; width: 0%; }
113
+ .pbar.spin { width: 25% !important; animation: sweep 1.4s ease-in-out infinite; }
 
 
 
 
 
 
114
 
115
+ .results { display: none; flex-direction: column; gap: 6px; }
 
 
 
 
116
  .results.show { display: flex; }
117
  .player-row { display: flex; align-items: center; gap: 6px; }
118
  .player-row audio { flex: 1; height: 32px; border-radius: 4px; }
119
+ .metrics { display: flex; align-items: center; gap: 10px; padding: 5px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 11px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
+ .sov { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; display: flex; align-items: flex-end; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
 
 
 
 
 
 
 
 
122
  .sov.open { opacity: 1; pointer-events: auto; }
123
+ .spanel { background: var(--bg); border: 1px solid var(--border); border-radius: 16px 16px 0 0; width: 100%; max-width: 680px; padding: 16px 20px 24px; transform: translateY(100%); transition: transform 0.3s; }
 
 
 
 
 
 
 
 
 
 
124
  .sov.open .spanel { transform: translateY(0); }
125
+ .s-head { display: flex; align-items: center; margin-bottom: 16px; }
126
+ .s-section { font-size: 10px; font-weight: 600; color: var(--dim); margin: 16px 0 8px; border-top: 1px solid var(--border); padding-top: 12px; }
127
+ .s-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
128
+ .s-row label { font-size: 12px; color: var(--text2); min-width: 90px; }
129
+ .s-row select { flex: 1; padding: 7px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); }
130
+ .s-btn { padding: 6px 14px; font-size: 12px; font-weight: 600; background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); cursor: pointer; }
131
+
132
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} }
133
+ @keyframes sweep { 0%{transform:translateX(-200%)} 100%{transform:translateX(500%)} }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  </style>
135
  </head>
136
  <body>
137
  <div class="app">
138
 
 
139
  <header class="hdr">
140
+ <h1>Qwen3-TTS</h1>
 
141
  <span class="spacer"></span>
142
+ <button class="mpill" onclick="openSettings()">
143
  <span class="mdot" id="mdot"></span>
144
  <span id="mtext">not loaded</span>
145
  </button>
146
+ <button class="ibtn" onclick="openSettings()">
147
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
148
  </button>
 
 
 
149
  </header>
150
 
 
151
  <div class="main">
152
  <div class="text-wrap">
153
+ <textarea id="textIn" placeholder="Escribe el texto aquí...">Contra todo pronóstico, este modelo por fin pudo hablar en CPU, demostrando todo su potencial.</textarea>
154
  </div>
155
 
156
+ <!-- Clonar Voz -->
157
  <div class="mode-row" id="rowClone" onclick="onModeRowClick('voice_clone')">
158
+ <div class="label">Clonación — imita una voz de referencia</div>
 
 
 
 
 
 
 
 
159
  <div class="row">
160
  <label class="ref-chip" id="refChip">
161
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
162
+ <span id="refLabel">Subir audio...</span>
163
  <input type="file" id="refInput" accept=".wav,.mp3,.flac,.ogg" onchange="pickRef(this)">
164
  </label>
 
 
 
 
 
165
  <div class="preset-row" id="presetRow"></div>
166
+ <button class="play-btn" onclick="generate('voice_clone')" title="Generate">▶</button>
 
 
 
 
 
 
 
 
 
 
 
167
  </div>
 
 
 
 
168
  </div>
169
 
170
+ <!-- Voz Personalizada (Custom) -->
171
  <div class="mode-row" id="rowChar" onclick="onModeRowClick('custom')">
172
+ <div class="label">Voces Integradas — usa un locutor del sistema</div>
173
  <div class="row">
174
+ <select class="sm-sel" id="speakerId" style="flex:1"></select>
175
+ <button class="play-btn" onclick="generate('custom')" title="Generate">▶</button>
 
 
 
 
 
 
 
 
 
176
  </div>
177
  </div>
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  <div class="pbar-wrap" id="pbarWrap"><div class="pbar" id="pbar"></div></div>
180
 
 
181
  <div class="results" id="results">
182
+ <div class="player-row"><audio id="player" controls></audio></div>
 
 
 
 
 
 
 
 
 
 
 
 
183
  </div>
 
184
  </div>
185
+ </div>
186
 
187
+ <!-- Settings -->
 
 
188
  <div class="sov" id="settingsOv" onclick="closeSettings(event)">
189
  <div class="spanel" onclick="event.stopPropagation()">
190
+ <div class="s-head"><span>Configuración</span><span class="spacer"></span><button class="ibtn" onclick="closeSettings()">X</button></div>
191
+ <div class="s-section">Modelo</div>
192
+ <div class="s-row"><select id="modelSel"></select><button class="s-btn" id="loadBtn" onclick="loadModel()">Cargar</button></div>
193
+ <div class="s-section">Idioma</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  <div class="s-row">
 
195
  <select id="langSel">
196
+ <option selected>Spanish</option>
197
+ <option>English</option>
198
+ <option>Chinese</option>
199
+ <option>French</option>
200
+ <option>German</option>
201
  </select>
202
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  </div>
204
  </div>
205
 
206
  <script>
 
207
  const $ = id => document.getElementById(id);
208
+ let loadedModel = null, loadingModel = null, refFile = null, presetRefId = null, presetRefs = [];
209
+ const defaultSpeakers =[
210
+ { id: 'Serena', desc: 'Femenina — Cálida y amable (Defecto)' },
211
+ { id: 'Vivian', desc: 'Femenina — Joven y brillante' },
212
+ { id: 'Sohee', desc: 'Femenina — Estilo Coreano' },
213
+ { id: 'Ono_Anna', desc: 'Femenina — Estilo Japonés' },
214
+ { id: 'Dylan', desc: 'Masculina — Joven' },
215
+ { id: 'Ryan', desc: 'Masculina — Dinámica (English)' },
216
+ { id: 'Aiden', desc: 'Masculina — Americana (English)' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  ];
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  window.addEventListener('load', async () => {
 
 
220
  await fetchStatus();
221
+ if (!loadedModel && $('modelSel').options.length > 0) {
222
+ $('modelSel').value = $('modelSel').options[0].value;
 
 
 
223
  loadModel();
224
  }
 
 
 
 
225
  });
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  function openSettings() { $('settingsOv').classList.add('open'); }
228
+ function closeSettings(e) { if (!e || e.target === $('settingsOv')) $('settingsOv').classList.remove('open'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
+ function setPlayBtns(disabled) { document.querySelectorAll('.play-btn').forEach(b => b.disabled = disabled); }
231
+ function setPill(state, label) { $('mdot').className = 'mdot ' + state; $('mtext').textContent = label; }
 
 
 
 
 
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  async function fetchStatus() {
234
  try {
235
  const d = await fetch('/status').then(r => r.json());
236
+ const sel = $('modelSel'); sel.innerHTML = '';
237
+ (d.available_models ||[]).forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); });
238
+ if (d.loaded && d.model) { loadedModel = d.model; sel.value = d.model; }
239
+
240
+ // Poblamos los hablantes (Speakers)
241
+ const spkSel = $('speakerId'); spkSel.innerHTML = '';
242
+ let ids = d.speakers || defaultSpeakers.map(s => s.id);
243
+ ids.forEach(id => {
244
+ const info = defaultSpeakers.find(s => s.id.toLowerCase() === id.toLowerCase());
245
+ const o = document.createElement('option'); o.value = id;
246
+ o.textContent = info ? info.desc : id;
247
+ spkSel.appendChild(o);
248
+ });
249
+ // Serena por defecto (Femenina)
250
+ if(ids.includes('Serena')) spkSel.value = 'Serena';
251
+
252
+ // Poblamos presets
253
+ presetRefs = d.preset_refs ||[];
254
+ const row = $('presetRow'); row.innerHTML = '';
255
+ presetRefs.forEach(p => {
256
+ const b = document.createElement('button'); b.className = 'preset-btn'; b.textContent = p.label;
257
+ b.onclick = () => selectPresetRef(p.id); row.appendChild(b);
258
+ });
259
+ if (presetRefs.length > 0 && !presetRefId) selectPresetRef(presetRefs[0].id);
260
+
261
+ setPill(d.loaded ? 'loaded' : 'off', d.loaded ? 'Listo' : 'No cargado');
262
+ updateModeRows();
263
+ } catch { setPill('error', 'Desconectado'); }
264
  }
265
 
266
  async function loadModel() {
267
+ $('loadBtn').disabled = true; loadingModel = $('modelSel').value; loadedModel = null; setPill('loading', 'Cargando...');
 
 
 
 
 
268
  try {
269
+ const fd = new FormData(); fd.append('model_id', loadingModel);
270
+ await fetch('/load', { method: 'POST', body: fd });
271
+ loadedModel = loadingModel; loadingModel = null; setPill('loaded', 'Listo'); fetchStatus();
272
+ } catch { setPill('error', 'Fallo al cargar'); }
273
+ $('loadBtn').disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  }
275
 
276
  function updateModeRows() {
277
+ const isCustom = (loadingModel || loadedModel || '').includes('CustomVoice');
278
+ $('rowChar').classList.toggle('compatible', isCustom);
279
+ $('rowChar').classList.toggle('incompatible', !isCustom);
280
+ $('rowClone').classList.toggle('compatible', !isCustom);
281
+ $('rowClone').classList.toggle('incompatible', isCustom);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
283
 
284
  function onModeRowClick(mode) {
285
+ const isCustom = mode === 'custom';
286
+ const target = isCustom ? 'Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice' : 'Qwen/Qwen3-TTS-12Hz-0.6B-Base';
287
+ if ((loadedModel || '').includes(isCustom ? 'CustomVoice' : 'Base')) return;
288
+ $('modelSel').value = target; loadModel();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
 
291
+ function pickRef(input) {
292
+ if (!input.files[0]) return;
293
+ refFile = input.files[0]; presetRefId = null;
294
+ $('refLabel').textContent = refFile.name; $('refChip').classList.add('has');
295
+ document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
 
 
 
 
 
 
 
296
  }
297
 
298
+ async function selectPresetRef(id) {
299
+ presetRefId = id; refFile = null;
300
+ const p = presetRefs.find(x => x.id === id);
301
+ $('refLabel').textContent = p ? p.label : id; $('refChip').classList.add('has');
302
+ document.querySelectorAll('.preset-btn').forEach(b => { b.classList.toggle('active', b.textContent === (p ? p.label : id)); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  }
304
 
 
305
  async function generate(mode) {
306
+ if (!loadedModel) return alert("Carga el modelo primero en Configuración.");
307
+ setPlayBtns(true); $('results').classList.add('show'); $('pbarWrap').classList.add('show'); $('pbar').classList.add('spin');
308
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  const fd = new FormData();
310
+ fd.append('text', $('textIn').value); fd.append('mode', mode); fd.append('language', $('langSel').value);
 
 
 
 
 
 
311
  if (mode === 'voice_clone') {
312
+ if (presetRefId) fd.append('ref_preset', presetRefId);
313
+ else if (refFile) fd.append('ref_audio', refFile);
 
 
 
 
 
314
  } else if (mode === 'custom') {
315
+ fd.append('speaker', $('speakerId').value);
 
 
 
316
  }
317
 
318
  try {
319
+ const res = await fetch('/generate', { method: 'POST', body: fd });
320
+ const d = await res.json();
321
+ const bytes = Uint8Array.from(atob(d.audio_b64), c => c.charCodeAt(0));
322
+ $('player').src = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }));
323
+ } catch(e) { alert("Error: " + e.message); }
324
+
325
+ setPlayBtns(false); $('pbar').classList.remove('spin'); $('pbar').style.width = '100%';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  </script>
328
  </body>
329
+ </html>