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

Add Voices page, usage tracker, smart quota error CTAs

Browse files
Files changed (1) hide show
  1. index.html +425 -623
index.html CHANGED
@@ -36,7 +36,6 @@ body {
36
  overflow: hidden;
37
  }
38
 
39
- /* ── Layout ── */
40
  .app-layout {
41
  display: grid;
42
  grid-template-columns: 260px 1fr;
@@ -44,7 +43,6 @@ body {
44
  height: 100vh;
45
  }
46
 
47
- /* ── Header ── */
48
  .header {
49
  grid-column: 1 / -1;
50
  display: flex;
@@ -100,7 +98,6 @@ body {
100
  border-radius: 50%;
101
  }
102
 
103
- /* ── Sidebar ── */
104
  .sidebar {
105
  background: var(--surface);
106
  border-right: 1px solid var(--border);
@@ -143,7 +140,7 @@ body {
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;
@@ -173,58 +170,62 @@ body {
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;
@@ -232,345 +233,216 @@ body {
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
 
@@ -579,65 +451,30 @@ body {
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; }
@@ -645,96 +482,39 @@ pre {
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; }
@@ -743,72 +523,40 @@ pre {
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>
@@ -817,10 +565,8 @@ pre {
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>
@@ -834,43 +580,47 @@ pre {
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>
@@ -881,7 +631,7 @@ pre {
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>
@@ -939,7 +689,7 @@ pre {
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>
@@ -947,7 +697,7 @@ pre {
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>
@@ -957,6 +707,15 @@ pre {
957
  </div>
958
  </div>
959
 
 
 
 
 
 
 
 
 
 
960
  <!-- History Page -->
961
  <div class="page" id="page-history">
962
  <div class="page-header">
@@ -964,10 +723,7 @@ pre {
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
 
@@ -975,15 +731,13 @@ pre {
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 \
@@ -998,13 +752,11 @@ SESSION=$(<span class="f">uuidgen</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'",
1004
  "meta": {"_type": "gradio.FileData"},
1005
  "orig_name": "voice.mp3"},
1006
- "", "", 32, 2.0, true, 1.0, 0, true, true
1007
- ],
1008
  "fn_index": 0,
1009
  "session_hash": "'$SESSION'"
1010
  }'</span>
@@ -1014,16 +766,14 @@ SESSION=$(<span class="f">uuidgen</span>)
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
@@ -1032,32 +782,26 @@ form.<span class="f">append</span>(<span class="s">"files"</span>, audioFile);
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>,
@@ -1103,14 +847,19 @@ import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://esm.sh/@hug
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) => {
@@ -1123,67 +872,95 @@ function openDB() {
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");
@@ -1197,16 +974,79 @@ function updateVoiceSelector() {
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;
@@ -1218,13 +1058,9 @@ window.closeModal = function() {
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) {
@@ -1239,18 +1075,14 @@ function handleModalFile(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,
@@ -1260,7 +1092,8 @@ window.saveVoice = async function() {
1260
  await putVoice(voice);
1261
  selectedVoice = voice;
1262
  closeModal();
1263
- renderVoices();
 
1264
  updateVoiceSelector();
1265
  updateGenerateBtn();
1266
  };
@@ -1271,22 +1104,31 @@ async function initOAuth() {
1271
  const result = await oauthHandleRedirectIfPresent();
1272
  if (result) {
1273
  accessToken = result.accessToken;
1274
- showApp(result.userInfo);
 
 
 
 
 
 
 
 
1275
  }
1276
  }
1277
 
1278
  window.login = async function() {
1279
- const url = await oauthLoginUrl({ scopes: "openid profile read-billing" });
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 ──
@@ -1296,6 +1138,8 @@ window.showPage = function(page, el) {
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) {
@@ -1305,14 +1149,13 @@ window.showApiTab = function(tab, el) {
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
  }
@@ -1345,7 +1188,6 @@ window.generate = async function() {
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);
@@ -1367,16 +1209,12 @@ window.generate = async function() {
1367
  ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
1368
  },
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
1377
  })
1378
  });
1379
-
1380
  if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
1381
 
1382
  const streamRes = await fetch(
@@ -1411,21 +1249,25 @@ window.generate = async function() {
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
  }
1426
  }
1427
  } catch (err) {
1428
- showStatus("error", err.message);
1429
  } finally {
1430
  btn.disabled = false;
1431
  btn.classList.remove("loading");
@@ -1433,34 +1275,16 @@ window.generate = async function() {
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;
1462
- const res = await fetch(resultAudioUrl);
1463
- const blob = await res.blob();
1464
  const a = document.createElement("a");
1465
  a.href = URL.createObjectURL(blob);
1466
  a.download = "openaudio_output.wav";
@@ -1478,44 +1302,22 @@ function addToHistory(text, voiceName, duration, url) {
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 ──
 
36
  overflow: hidden;
37
  }
38
 
 
39
  .app-layout {
40
  display: grid;
41
  grid-template-columns: 260px 1fr;
 
43
  height: 100vh;
44
  }
45
 
 
46
  .header {
47
  grid-column: 1 / -1;
48
  display: flex;
 
98
  border-radius: 50%;
99
  }
100
 
 
101
  .sidebar {
102
  background: var(--surface);
103
  border-right: 1px solid var(--border);
 
140
  .nav-item.active { background: var(--accent-subtle); color: var(--accent-hover); }
141
  .nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
142
 
143
+ .sidebar-voices {
144
  flex: 1;
145
  overflow-y: auto;
146
  padding: 0 12px 12px;
 
170
  justify-content: center;
171
  font-size: 16px;
172
  flex-shrink: 0;
173
+ color: white;
174
+ font-weight: 600;
175
  }
176
 
177
+ .voice-meta { flex: 1; min-width: 0; }
178
+ .voice-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
179
+ .voice-detail { font-size: 11px; color: var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  .voice-card .delete-voice {
182
+ opacity: 0; padding: 4px; border: none; background: none;
183
+ color: var(--text-muted); cursor: pointer; border-radius: 4px; transition: all 0.1s;
 
 
 
 
 
 
184
  }
 
185
  .voice-card:hover .delete-voice { opacity: 1; }
186
  .voice-card .delete-voice:hover { color: var(--danger); background: rgba(239,68,68,0.1); }
187
 
188
+ /* ── Usage tracker ── */
189
+ .usage-tracker {
190
+ padding: 12px;
191
+ margin: 0 12px 12px;
192
+ background: var(--surface2);
193
+ border: 1px solid var(--border);
194
+ border-radius: 10px;
195
+ }
196
+
197
+ .usage-header {
198
  display: flex;
199
  align-items: center;
200
+ justify-content: space-between;
201
+ margin-bottom: 8px;
202
+ }
203
+
204
+ .usage-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); }
205
+ .usage-value { font-size: 11px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
206
+
207
+ .usage-bar {
208
  width: 100%;
209
+ height: 4px;
210
+ background: var(--surface3);
211
+ border-radius: 2px;
212
+ overflow: hidden;
213
+ margin-bottom: 6px;
214
+ }
215
+
216
+ .usage-fill {
217
+ height: 100%;
218
+ background: var(--accent);
219
+ border-radius: 2px;
220
+ transition: width 0.5s ease;
221
  }
222
 
223
+ .usage-fill.warning { background: var(--warning); }
224
+ .usage-fill.danger { background: var(--danger); }
225
 
226
+ .usage-sub { font-size: 10px; color: var(--text-muted); }
227
+
228
+ /* ── Content ── */
229
  .content {
230
  overflow-y: auto;
231
  padding: 32px;
 
233
  justify-content: center;
234
  }
235
 
236
+ .content-inner { width: 100%; max-width: 720px; }
 
 
 
237
 
 
238
  .page { display: none; }
239
  .page.active { display: block; }
240
 
241
+ .page-header { margin-bottom: 28px; }
242
+ .page-title { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 6px; }
243
+ .page-desc { font-size: 14px; color: var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
+ /* Voice selector */
246
  .voice-selector {
247
+ display: flex; align-items: center; gap: 12px;
248
+ padding: 14px 16px; background: var(--surface); border: 1px solid var(--border);
249
+ border-radius: 12px; margin-bottom: 16px; cursor: pointer; transition: border-color 0.15s;
 
 
 
 
 
 
 
250
  }
 
251
  .voice-selector:hover { border-color: var(--border-hover); }
 
252
  .voice-selector .voice-avatar { width: 40px; height: 40px; }
 
253
  .voice-selector-info { flex: 1; }
254
+ .voice-selector-name { font-size: 14px; font-weight: 600; }
255
+ .voice-selector-hint { font-size: 12px; color: var(--text-muted); }
256
+ .voice-selector-change { font-size: 12px; color: var(--accent); font-weight: 500; }
257
 
258
+ /* Editor */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  .editor {
260
+ background: var(--surface); border: 1px solid var(--border);
261
+ border-radius: 12px; overflow: hidden; margin-bottom: 16px;
 
 
 
262
  }
 
263
  .editor textarea {
264
+ width: 100%; min-height: 180px; background: transparent; border: none;
265
+ padding: 20px; color: var(--text); font-family: 'Inter', sans-serif;
266
+ font-size: 15px; line-height: 1.6; resize: vertical;
 
 
 
 
 
 
 
267
  }
 
268
  .editor textarea:focus { outline: none; }
 
269
  .editor textarea::placeholder { color: var(--text-muted); }
270
 
271
  .editor-footer {
272
+ display: flex; align-items: center; justify-content: space-between;
273
+ padding: 10px 20px; border-top: 1px solid var(--border); background: var(--surface2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  }
275
+ .editor-left { display: flex; align-items: center; gap: 12px; }
276
+ .editor-stat { font-size: 12px; color: var(--text-muted); }
277
 
278
  .lang-select {
279
+ padding: 4px 8px; background: var(--surface3); border: 1px solid var(--border);
280
+ border-radius: 6px; color: var(--text-secondary); font-size: 12px; font-family: 'Inter', sans-serif;
 
 
 
 
 
281
  }
 
282
  .lang-select:focus { outline: none; border-color: var(--accent); }
283
 
284
+ /* Settings */
285
+ .settings-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
286
+ .setting-group { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
287
+ .setting-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 8px; }
288
+ .setting-value { display: flex; align-items: center; gap: 10px; }
289
+ .setting-value input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; }
290
+ .setting-num { font-size: 13px; color: var(--text-secondary); min-width: 30px; text-align: right; font-variant-numeric: tabular-nums; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
+ /* Generate */
293
+ .generate-bar { display: flex; gap: 12px; align-items: center; }
 
 
 
 
294
 
295
  .btn-generate {
296
+ flex: 1; padding: 14px 24px;
 
297
  background: linear-gradient(135deg, var(--accent), #7c3aed);
298
+ color: white; border: none; border-radius: 10px;
299
+ font-size: 14px; font-weight: 600; font-family: 'Inter', sans-serif;
300
+ cursor: pointer; transition: all 0.2s;
301
+ display: flex; align-items: center; justify-content: center; gap: 8px;
 
 
 
 
 
 
 
 
302
  }
 
303
  .btn-generate:hover { filter: brightness(1.1); transform: translateY(-1px); }
304
  .btn-generate:active { transform: translateY(0); }
305
  .btn-generate:disabled { opacity: 0.4; cursor: not-allowed; transform: none; filter: none; }
306
+ .btn-generate .spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; display: none; }
 
 
 
 
 
 
 
 
 
 
307
  .btn-generate.loading .spinner { display: block; }
308
  .btn-generate.loading .btn-text { display: none; }
 
309
  @keyframes spin { to { transform: rotate(360deg); } }
310
 
311
  /* Status */
312
  .status-bar {
313
+ margin-top: 12px; padding: 10px 14px; border-radius: 8px;
314
+ font-size: 13px; display: none; gap: 8px; flex-direction: column;
 
 
 
 
 
315
  }
 
316
  .status-bar.visible { display: flex; }
317
  .status-bar.info { background: rgba(139,92,246,0.08); color: var(--accent-hover); border: 1px solid rgba(139,92,246,0.15); }
318
  .status-bar.success { background: rgba(34,197,94,0.08); color: var(--success); border: 1px solid rgba(34,197,94,0.15); }
319
  .status-bar.error { background: rgba(239,68,68,0.08); color: var(--danger); border: 1px solid rgba(239,68,68,0.15); }
320
 
321
+ .status-text { line-height: 1.4; }
322
+
323
+ .status-cta {
324
+ display: inline-flex; align-items: center; gap: 6px;
325
+ padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
326
+ font-family: 'Inter', sans-serif; cursor: pointer; border: none;
327
+ transition: all 0.15s; margin-top: 4px; text-decoration: none; width: fit-content;
 
328
  }
329
+ .status-cta.upgrade { background: #ff9d00; color: #000; }
330
+ .status-cta.upgrade:hover { background: #ffb340; }
331
+ .status-cta.credits { background: var(--accent); color: white; }
332
+ .status-cta.credits:hover { background: var(--accent-hover); }
333
 
334
+ .progress-track { width: 100%; height: 3px; background: var(--surface3); border-radius: 2px; margin-top: 6px; overflow: hidden; display: none; }
335
  .progress-track.visible { display: block; }
336
+ .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), #ec4899); border-radius: 2px; transition: width 0.3s; width: 0%; }
337
 
338
+ /* Player */
339
+ .player-card { margin-top: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; display: none; }
340
+ .player-card.visible { display: block; }
341
+ .player-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
342
+
343
+ .play-btn {
344
+ width: 40px; height: 40px; border-radius: 50%; background: var(--accent);
345
+ border: none; color: white; cursor: pointer;
346
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s;
347
  }
348
+ .play-btn:hover { background: var(--accent-hover); transform: scale(1.05); }
349
+ .play-btn svg { width: 18px; height: 18px; }
350
 
351
+ .player-info { flex: 1; }
352
+ .player-title { font-size: 14px; font-weight: 500; }
353
+ .player-meta { font-size: 12px; color: var(--text-muted); }
354
+
355
+ .icon-btn {
356
+ width: 32px; height: 32px; border-radius: 6px; border: 1px solid var(--border);
357
+ background: transparent; color: var(--text-secondary); cursor: pointer;
358
+ display: flex; align-items: center; justify-content: center; transition: all 0.1s;
359
  }
360
+ .icon-btn:hover { background: var(--surface2); color: var(--text); border-color: var(--border-hover); }
361
 
362
+ .player-audio { width: 100%; height: 32px; }
363
 
364
+ /* ── Voices Page ── */
365
+ .voices-grid {
366
+ display: grid;
367
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
368
  gap: 12px;
 
369
  }
370
 
371
+ .voice-tile {
372
+ background: var(--surface);
373
+ border: 1px solid var(--border);
374
+ border-radius: 12px;
375
+ padding: 20px;
 
 
 
 
 
 
 
376
  transition: all 0.15s;
377
+ cursor: default;
378
+ position: relative;
379
  }
380
 
381
+ .voice-tile:hover { border-color: var(--border-hover); }
382
 
383
+ .voice-tile-avatar {
384
+ width: 48px; height: 48px; border-radius: 12px;
385
+ display: flex; align-items: center; justify-content: center;
386
+ font-size: 22px; font-weight: 700; color: white; margin-bottom: 12px;
387
+ }
388
 
389
+ .voice-tile-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
390
+ .voice-tile-file { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
 
391
 
392
+ .voice-tile-actions { display: flex; gap: 6px; }
393
+
394
+ .voice-tile-btn {
395
+ padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 500;
396
+ font-family: 'Inter', sans-serif; cursor: pointer; border: none; transition: all 0.1s;
397
  }
398
 
399
+ .voice-tile-btn.use { background: var(--accent); color: white; }
400
+ .voice-tile-btn.use:hover { background: var(--accent-hover); }
401
+ .voice-tile-btn.del { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
402
+ .voice-tile-btn.del:hover { color: var(--danger); border-color: var(--danger); }
403
+
404
+ .voice-tile-date {
405
+ position: absolute; top: 12px; right: 12px;
406
+ font-size: 10px; color: var(--text-muted);
407
+ }
408
+
409
+ .add-voice-tile {
410
  background: transparent;
411
+ border: 2px dashed var(--border);
412
+ border-radius: 12px;
413
+ padding: 20px;
414
  display: flex;
415
+ flex-direction: column;
416
  align-items: center;
417
  justify-content: center;
418
+ gap: 8px;
419
+ cursor: pointer;
420
+ color: var(--text-muted);
421
+ font-size: 13px;
422
+ transition: all 0.15s;
423
+ min-height: 160px;
424
  }
425
 
426
+ .add-voice-tile:hover { border-color: var(--accent); color: var(--accent-hover); }
427
+ .add-voice-tile svg { opacity: 0.5; }
 
428
 
429
+ /* ── History ── */
430
  .history-list { display: flex; flex-direction: column; gap: 8px; }
431
 
432
  .history-item {
433
+ display: flex; align-items: center; gap: 14px;
434
+ padding: 14px 16px; background: var(--surface); border: 1px solid var(--border);
435
+ border-radius: 10px; transition: border-color 0.1s;
 
 
 
 
 
436
  }
 
437
  .history-item:hover { border-color: var(--border-hover); }
438
 
439
  .history-play {
440
+ width: 36px; height: 36px; border-radius: 50%;
441
+ background: var(--surface3); border: 1px solid var(--border);
442
+ color: var(--text-secondary); cursor: pointer;
443
+ display: flex; align-items: center; justify-content: center;
444
+ flex-shrink: 0; transition: all 0.1s;
 
 
 
 
 
 
 
445
  }
 
446
  .history-play:hover { background: var(--accent); color: white; border-color: var(--accent); }
447
  .history-play svg { width: 14px; height: 14px; }
448
 
 
451
  .history-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
452
 
453
  .history-download {
454
+ padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);
455
+ background: transparent; color: var(--text-secondary); font-size: 12px;
456
+ cursor: pointer; font-family: 'Inter', sans-serif; transition: all 0.1s;
 
 
 
 
 
 
457
  }
 
458
  .history-download:hover { background: var(--surface2); color: var(--text); }
459
 
460
+ .empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 14px; }
 
 
 
 
 
 
461
  .empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
462
 
463
+ /* ── API ── */
464
+ .api-tabs { display: flex; gap: 2px; background: var(--surface2); border-radius: 8px; padding: 3px; margin-bottom: 16px; }
 
 
 
 
 
 
 
 
465
  .api-tab {
466
+ padding: 7px 16px; border-radius: 6px; font-size: 13px; cursor: pointer;
467
+ color: var(--text-muted); background: transparent; border: none;
468
+ font-family: 'Inter', sans-serif; font-weight: 500; transition: all 0.1s;
 
 
 
 
 
 
 
469
  }
 
470
  .api-tab.active { background: var(--surface3); color: var(--text); }
471
  .api-tab:hover { color: var(--text-secondary); }
472
 
473
  pre {
474
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
475
+ padding: 20px; overflow-x: auto; font-size: 13px; line-height: 1.6;
 
 
 
 
 
476
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
477
  }
 
478
  .cm { color: var(--text-muted); }
479
  .s { color: #86efac; }
480
  .k { color: #c4b5fd; }
 
482
 
483
  /* ── Modal ── */
484
  .modal-overlay {
485
+ position: fixed; inset: 0; background: rgba(0,0,0,0.6);
486
+ backdrop-filter: blur(4px); display: none; align-items: center;
487
+ justify-content: center; z-index: 100;
 
 
 
 
 
488
  }
 
489
  .modal-overlay.visible { display: flex; }
490
 
491
  .modal {
492
+ background: var(--surface); border: 1px solid var(--border);
493
+ border-radius: 16px; padding: 28px; width: 440px; max-width: 90vw;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  }
495
+ .modal-title { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
496
+ .modal-desc { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; }
497
  .modal-field { margin-bottom: 16px; }
498
+ .modal-field label { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
 
 
 
 
 
 
 
 
499
  .modal-field input[type="text"] {
500
+ width: 100%; padding: 10px 12px; background: var(--surface2);
501
+ border: 1px solid var(--border); border-radius: 8px; color: var(--text);
502
+ font-family: 'Inter', sans-serif; font-size: 14px;
 
 
 
 
 
503
  }
 
504
  .modal-field input:focus { outline: none; border-color: var(--accent); }
505
 
506
  .upload-area {
507
+ border: 2px dashed var(--border); border-radius: 10px; padding: 28px;
508
+ text-align: center; cursor: pointer; transition: all 0.15s;
509
+ color: var(--text-muted); font-size: 13px;
 
 
 
 
 
510
  }
 
511
  .upload-area:hover { border-color: var(--accent); color: var(--text-secondary); }
512
  .upload-area.has-file { border-color: var(--success); border-style: solid; color: var(--success); }
513
  .upload-area input { display: none; }
514
  .upload-area-sub { font-size: 11px; margin-top: 4px; opacity: 0.7; }
515
 
516
+ .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
517
+ .btn-modal { padding: 9px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; font-family: 'Inter', sans-serif; cursor: pointer; transition: all 0.1s; border: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  .btn-cancel { background: var(--surface2); color: var(--text-secondary); border: 1px solid var(--border); }
519
  .btn-cancel:hover { background: var(--surface3); color: var(--text); }
520
  .btn-save { background: var(--accent); color: white; }
 
523
 
524
  /* ── Landing ── */
525
  #landing {
526
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
527
+ min-height: 100vh; text-align: center; gap: 20px;
 
 
 
 
 
528
  background: radial-gradient(ellipse at 50% 0%, var(--accent-glow) 0%, transparent 60%);
529
  }
 
530
  #landing h1 {
531
+ font-size: 52px; font-weight: 700; letter-spacing: -1.5px;
 
 
532
  background: linear-gradient(135deg, var(--text) 0%, var(--text-secondary) 100%);
533
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
 
 
 
 
 
 
 
 
534
  }
535
+ #landing .subtitle { color: var(--text-muted); font-size: 17px; max-width: 420px; line-height: 1.5; }
536
 
537
  .btn-hf {
538
+ padding: 13px 32px; background: #ff9d00; color: #000; border: none; border-radius: 10px;
539
+ font-size: 15px; font-weight: 600; font-family: 'Inter', sans-serif; cursor: pointer;
540
+ display: inline-flex; align-items: center; gap: 8px; transition: all 0.2s;
 
 
 
 
 
 
 
 
 
 
541
  }
 
542
  .btn-hf:hover { background: #ffb340; transform: translateY(-1px); }
543
+ .landing-note { font-size: 12px; color: var(--text-muted); }
544
 
 
 
 
 
 
 
545
  ::-webkit-scrollbar { width: 6px; }
546
  ::-webkit-scrollbar-track { background: transparent; }
547
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
548
  ::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
549
 
 
550
  @media (max-width: 768px) {
551
  .app-layout { grid-template-columns: 1fr; }
552
  .sidebar { display: none; }
553
  #landing h1 { font-size: 36px; }
554
+ .voices-grid { grid-template-columns: 1fr; }
555
  }
556
  </style>
557
  </head>
558
  <body>
559
 
 
560
  <div id="landing">
561
  <div class="logo-icon" style="width:56px;height:56px;font-size:28px;border-radius:14px">🎙</div>
562
  <h1>OpenAudioLabs</h1>
 
565
  <p class="landing-note">Uses your HF ZeroGPU quota &middot; Free tier included</p>
566
  </div>
567
 
 
568
  <div id="app" class="app-layout" style="display:none">
569
 
 
570
  <div class="header">
571
  <div class="logo">
572
  <div class="logo-icon">🎙</div>
 
580
  </div>
581
  </div>
582
 
 
583
  <div class="sidebar">
584
  <div class="sidebar-section">
585
  <div class="sidebar-label">Create</div>
586
+ <button class="nav-item active" data-page="synthesis" onclick="showPage('synthesis', this)">
587
  <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>
588
  Speech Synthesis
589
  </button>
590
+ <button class="nav-item" data-page="voices" onclick="showPage('voices', this)">
591
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
592
+ Voices
593
+ </button>
594
+ <button class="nav-item" data-page="history" onclick="showPage('history', this)">
595
  <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>
596
  History
597
  </button>
598
+ <button class="nav-item" data-page="api" onclick="showPage('api', this)">
599
  <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>
600
  API
601
  </button>
602
  </div>
603
 
604
  <div class="sidebar-section">
605
+ <div class="sidebar-label">Quick Select</div>
 
 
 
606
  </div>
607
+ <div class="sidebar-voices" id="voice-list"></div>
608
+
609
+ <!-- Usage Tracker -->
610
+ <div class="usage-tracker" id="usage-tracker">
611
+ <div class="usage-header">
612
+ <span class="usage-label">Session Usage</span>
613
+ <span class="usage-value" id="usage-value">0s used</span>
614
+ </div>
615
+ <div class="usage-bar"><div class="usage-fill" id="usage-fill" style="width:0%"></div></div>
616
+ <div class="usage-sub" id="usage-sub">Estimated from generation durations</div>
617
  </div>
618
  </div>
619
 
 
620
  <div class="content">
621
  <div class="content-inner">
622
 
623
+ <!-- Synthesis Page -->
624
  <div class="page active" id="page-synthesis">
625
  <div class="page-header">
626
  <div class="page-title">Speech Synthesis</div>
 
631
  <div class="voice-avatar" id="sel-voice-avatar" style="background:var(--surface3)">🎤</div>
632
  <div class="voice-selector-info">
633
  <div class="voice-selector-name" id="sel-voice-name">No voice selected</div>
634
+ <div class="voice-selector-hint" id="sel-voice-hint">Click to add a voice</div>
635
  </div>
636
  <div class="voice-selector-change">Change</div>
637
  </div>
 
689
 
690
  <div class="player-card" id="result">
691
  <div class="player-top">
692
+ <button class="play-btn" onclick="togglePlay()">
693
  <svg id="play-icon" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
694
  <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>
695
  </button>
 
697
  <div class="player-title" id="player-title">Generated audio</div>
698
  <div class="player-meta" id="player-meta"></div>
699
  </div>
700
+ <div style="display:flex;gap:6px">
701
  <button class="icon-btn" onclick="downloadAudio()" title="Download">
702
  <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>
703
  </button>
 
707
  </div>
708
  </div>
709
 
710
+ <!-- Voices Page -->
711
+ <div class="page" id="page-voices">
712
+ <div class="page-header">
713
+ <div class="page-title">Voice Library</div>
714
+ <div class="page-desc">Create and manage your cloned voices. Voices are stored in your browser.</div>
715
+ </div>
716
+ <div class="voices-grid" id="voices-grid"></div>
717
+ </div>
718
+
719
  <!-- History Page -->
720
  <div class="page" id="page-history">
721
  <div class="page-header">
 
723
  <div class="page-desc">Your recent generations.</div>
724
  </div>
725
  <div id="history-container">
726
+ <div class="empty-state"><div class="empty-state-icon">🕐</div><div>No generations yet</div></div>
 
 
 
727
  </div>
728
  </div>
729
 
 
731
  <div class="page" id="page-api">
732
  <div class="page-header">
733
  <div class="page-title">API</div>
734
+ <div class="page-desc">Use your HF token to call OmniVoice directly. File objects need <code style="background:var(--surface2);padding:2px 6px;border-radius:4px;font-size:12px">meta: {"_type": "gradio.FileData"}</code>.</div>
735
  </div>
 
736
  <div class="api-tabs">
737
  <button class="api-tab active" onclick="showApiTab('curl', this)">curl</button>
738
  <button class="api-tab" onclick="showApiTab('javascript', this)">JavaScript</button>
739
  <button class="api-tab" onclick="showApiTab('python', this)">Python</button>
740
  </div>
 
741
  <div id="api-curl">
742
  <pre><code><span class="cm"># 1. Upload reference audio</span>
743
  UPLOAD_PATH=$(<span class="f">curl</span> -s -X POST \
 
752
  -H <span class="s">"Content-Type: application/json"</span> \
753
  -H <span class="s">"Authorization: Bearer $HF_TOKEN"</span> \
754
  -d <span class="s">'{
755
+ "data": ["Your text here", "English",
 
756
  {"path": "'$UPLOAD_PATH'",
757
  "meta": {"_type": "gradio.FileData"},
758
  "orig_name": "voice.mp3"},
759
+ "", "", 32, 2.0, true, 1.0, 0, true, true],
 
760
  "fn_index": 0,
761
  "session_hash": "'$SESSION'"
762
  }'</span>
 
766
  <span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api/queue/data?session_hash=$SESSION"</span> \
767
  -H <span class="s">"Authorization: Bearer $HF_TOKEN"</span></code></pre>
768
  </div>
 
769
  <div id="api-javascript" style="display:none">
770
  <pre><code><span class="k">const</span> BASE = <span class="s">"https://k2-fsa-omnivoice.hf.space/gradio_api"</span>;
 
771
  <span class="k">const</span> SESSION = <span class="f">crypto.randomUUID</span>();
772
 
773
+ <span class="cm">// 1. Upload</span>
774
  <span class="k">const</span> form = <span class="k">new</span> <span class="f">FormData</span>();
775
  form.<span class="f">append</span>(<span class="s">"files"</span>, audioFile);
776
+ <span class="k">const</span> [path] = <span class="k">await</span> <span class="f">fetch</span>(<span class="s">`${BASE}/upload`</span>, {
777
  method: <span class="s">"POST"</span>,
778
  headers: { Authorization: <span class="s">`Bearer ${TOKEN}`</span> },
779
  body: form
 
782
  <span class="cm">// 2. Join queue</span>
783
  <span class="k">await</span> <span class="f">fetch</span>(<span class="s">`${BASE}/queue/join`</span>, {
784
  method: <span class="s">"POST"</span>,
785
+ headers: { <span class="s">"Content-Type"</span>: <span class="s">"application/json"</span>,
786
+ Authorization: <span class="s">`Bearer ${TOKEN}`</span> },
 
 
787
  body: <span class="f">JSON.stringify</span>({
788
+ data: [<span class="s">"Text"</span>, <span class="s">"English"</span>,
789
+ { path, meta: { _type: <span class="s">"gradio.FileData"</span> },
 
790
  orig_name: <span class="s">"voice.mp3"</span> },
791
  <span class="s">""</span>, <span class="s">""</span>, 32, 2.0, true, 1.0, 0, true, true],
792
+ fn_index: 0, session_hash: SESSION })
 
793
  });
794
 
795
+ <span class="cm">// 3. Stream SSE</span>
796
+ <span class="k">const</span> r = <span class="k">await</span> <span class="f">fetch</span>(
797
  <span class="s">`${BASE}/queue/data?session_hash=${SESSION}`</span>,
798
  { headers: { Authorization: <span class="s">`Bearer ${TOKEN}`</span> } }
799
  );</code></pre>
800
  </div>
 
801
  <div id="api-python" style="display:none">
802
  <pre><code><span class="k">from</span> gradio_client <span class="k">import</span> Client, handle_file
803
 
804
  client = <span class="f">Client</span>(<span class="s">"k2-fsa/OmniVoice"</span>)
 
805
  result = client.<span class="f">predict</span>(
806
  text=<span class="s">"Your text here"</span>,
807
  lang=<span class="s">"English"</span>,
 
847
 
848
  const OMNIVOICE_BASE = "https://k2-fsa-omnivoice.hf.space/gradio_api";
849
  const COLORS = ["#7c3aed","#ec4899","#f59e0b","#22c55e","#3b82f6","#ef4444","#06b6d4","#8b5cf6"];
850
+ const QUOTA_FREE = 240;
851
+ const QUOTA_PRO = 1500;
852
 
853
  let accessToken = null;
854
+ let userInfo = null;
855
+ let isPro = false;
856
  let selectedVoice = null;
857
  let resultAudioUrl = null;
858
  let modalFileData = null;
859
  let history = JSON.parse(localStorage.getItem("oal_history") || "[]");
860
+ let sessionUsage = parseFloat(sessionStorage.getItem("oal_usage") || "0");
861
 
862
+ // ── IndexedDB ──
863
 
864
  function openDB() {
865
  return new Promise((resolve, reject) => {
 
872
 
873
  async function getVoices() {
874
  const db = await openDB();
875
+ return new Promise((res, rej) => {
876
+ const r = db.transaction("voices","readonly").objectStore("voices").getAll();
877
+ r.onsuccess = () => res(r.result);
878
+ r.onerror = () => rej(r.error);
 
 
879
  });
880
  }
881
 
882
+ async function putVoice(v) {
883
  const db = await openDB();
884
+ return new Promise((res, rej) => {
885
+ const tx = db.transaction("voices","readwrite");
886
+ tx.objectStore("voices").put(v);
887
+ tx.oncomplete = () => res();
888
+ tx.onerror = () => rej(tx.error);
889
  });
890
  }
891
 
892
  async function deleteVoiceDB(id) {
893
  const db = await openDB();
894
+ return new Promise((res, rej) => {
895
+ const tx = db.transaction("voices","readwrite");
896
  tx.objectStore("voices").delete(id);
897
+ tx.oncomplete = () => res();
898
+ tx.onerror = () => rej(tx.error);
899
  });
900
  }
901
 
902
+ // ── Render sidebar voices ──
903
+
904
+ async function renderSidebarVoices() {
905
  const voices = await getVoices();
906
+ document.getElementById("voice-list").innerHTML = voices.map(v => `
 
907
  <div class="voice-card ${selectedVoice?.id === v.id ? 'selected' : ''}" onclick="selectVoice('${v.id}')">
908
  <div class="voice-avatar" style="background:${v.color}">${v.name[0].toUpperCase()}</div>
909
  <div class="voice-meta">
910
  <div class="voice-name">${v.name}</div>
911
  <div class="voice-detail">${v.fileName}</div>
912
  </div>
 
 
 
913
  </div>
914
  `).join("");
915
  }
916
 
917
+ // ── Render Voices page grid ──
918
+
919
+ async function renderVoicesPage() {
920
+ const voices = await getVoices();
921
+ const grid = document.getElementById("voices-grid");
922
+ grid.innerHTML = voices.map(v => {
923
+ const date = new Date(v.createdAt).toLocaleDateString();
924
+ return `<div class="voice-tile">
925
+ <div class="voice-tile-date">${date}</div>
926
+ <div class="voice-tile-avatar" style="background:${v.color}">${v.name[0].toUpperCase()}</div>
927
+ <div class="voice-tile-name">${v.name}</div>
928
+ <div class="voice-tile-file">${v.fileName}</div>
929
+ <div class="voice-tile-actions">
930
+ <button class="voice-tile-btn use" onclick="selectVoiceAndGo('${v.id}')">Use Voice</button>
931
+ <button class="voice-tile-btn del" onclick="deleteVoiceFromPage('${v.id}')">Remove</button>
932
+ </div>
933
+ </div>`;
934
+ }).join("") + `
935
+ <div class="add-voice-tile" onclick="openAddVoice()">
936
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
937
+ Add New Voice
938
+ </div>`;
939
+ }
940
+
941
  window.selectVoice = async function(id) {
942
  const voices = await getVoices();
943
  selectedVoice = voices.find(v => v.id === id);
944
+ renderSidebarVoices();
945
  updateVoiceSelector();
946
  updateGenerateBtn();
947
  };
948
 
949
+ window.selectVoiceAndGo = async function(id) {
950
+ await window.selectVoice(id);
951
+ document.querySelector('[data-page="synthesis"]').click();
952
+ };
953
+
954
  window.deleteVoice = async function(id) {
955
  await deleteVoiceDB(id);
956
  if (selectedVoice?.id === id) { selectedVoice = null; updateVoiceSelector(); }
957
+ renderSidebarVoices();
958
+ renderVoicesPage();
959
  updateGenerateBtn();
960
  };
961
 
962
+ window.deleteVoiceFromPage = window.deleteVoice;
963
+
964
  function updateVoiceSelector() {
965
  const avatar = document.getElementById("sel-voice-avatar");
966
  const name = document.getElementById("sel-voice-name");
 
974
  avatar.textContent = "🎤";
975
  avatar.style.background = "var(--surface3)";
976
  name.textContent = "No voice selected";
977
+ hint.textContent = "Click to add a voice";
978
+ }
979
+ }
980
+
981
+ // ── Usage Tracker ──
982
+
983
+ function updateUsageTracker(addSeconds) {
984
+ if (addSeconds) {
985
+ sessionUsage += addSeconds;
986
+ sessionStorage.setItem("oal_usage", sessionUsage.toString());
987
+ }
988
+ const quota = isPro ? QUOTA_PRO : QUOTA_FREE;
989
+ const pct = Math.min((sessionUsage / quota) * 100, 100);
990
+ const fill = document.getElementById("usage-fill");
991
+ fill.style.width = `${pct}%`;
992
+ fill.className = "usage-fill" + (pct > 80 ? " danger" : pct > 50 ? " warning" : "");
993
+
994
+ const mins = Math.floor(sessionUsage / 60);
995
+ const secs = Math.round(sessionUsage % 60);
996
+ document.getElementById("usage-value").textContent = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
997
+
998
+ const quotaMins = Math.round(quota / 60);
999
+ const tier = isPro ? "Pro" : "Free";
1000
+ document.getElementById("usage-sub").textContent = `~${Math.round(sessionUsage)}s of ${quotaMins}m ${tier} daily quota (est.)`;
1001
+ }
1002
+
1003
+ // ── Smart Error CTAs ──
1004
+
1005
+ function showStatusWithCTA(errorMsg) {
1006
+ const el = document.getElementById("status");
1007
+ const isQuotaError = errorMsg.includes("GPU quota");
1008
+ const isFreeQuota = errorMsg.includes("free GPU quota");
1009
+
1010
+ if (isQuotaError) {
1011
+ const tryAgainMatch = errorMsg.match(/Try again in ([\d:]+)/);
1012
+ const waitTime = tryAgainMatch ? tryAgainMatch[1] : null;
1013
+
1014
+ let html = `<div class="status-text">${errorMsg}</div>`;
1015
+
1016
+ if (isFreeQuota) {
1017
+ html += `<a class="status-cta upgrade" href="https://huggingface.co/subscribe/pro" target="_blank">
1018
+ 🤗 Upgrade to HF Pro &mdash; 25 min/day GPU quota
1019
+ </a>`;
1020
+ } else {
1021
+ html += `<a class="status-cta credits" href="https://huggingface.co/settings/billing" target="_blank">
1022
+ Buy additional GPU credits &mdash; $1 per 10 min
1023
+ </a>`;
1024
+ }
1025
+
1026
+ if (waitTime && waitTime !== "0:00:00") {
1027
+ html += `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">Or wait ${waitTime} for your quota to reset</div>`;
1028
+ }
1029
+
1030
+ el.className = "status-bar visible error";
1031
+ el.innerHTML = html;
1032
+ } else {
1033
+ el.className = "status-bar visible error";
1034
+ el.innerHTML = `<div class="status-text">${errorMsg}</div>`;
1035
  }
1036
  }
1037
 
1038
+ function showStatus(type, msg) {
1039
+ const el = document.getElementById("status");
1040
+ el.className = `status-bar visible ${type}`;
1041
+ el.innerHTML = `<div class="status-text">${msg}</div>`;
1042
+ }
1043
+
1044
  // ── Modal ──
1045
 
1046
  window.openAddVoice = function() {
1047
  document.getElementById("add-voice-modal").classList.add("visible");
1048
  document.getElementById("voice-name-input").value = "";
1049
+ document.getElementById("modal-upload-label").innerHTML = 'Drop audio file here or click to browse<div class="upload-area-sub">MP3, WAV, FLAC &middot; 5-30 seconds recommended</div>';
1050
  document.getElementById("modal-upload").classList.remove("has-file");
1051
  document.getElementById("save-voice-btn").disabled = true;
1052
  modalFileData = null;
 
1058
 
1059
  const modalUpload = document.getElementById("modal-upload");
1060
  const modalFileInput = document.getElementById("modal-file-input");
 
1061
  modalUpload.addEventListener("click", () => modalFileInput.click());
1062
+ modalUpload.addEventListener("dragover", e => e.preventDefault());
1063
+ modalUpload.addEventListener("drop", e => { e.preventDefault(); if (e.dataTransfer.files.length) handleModalFile(e.dataTransfer.files[0]); });
 
 
 
1064
  modalFileInput.addEventListener("change", () => { if (modalFileInput.files.length) handleModalFile(modalFileInput.files[0]); });
1065
 
1066
  function handleModalFile(file) {
 
1075
  }
1076
 
1077
  document.getElementById("voice-name-input").addEventListener("input", checkModalSave);
 
1078
  function checkModalSave() {
1079
+ document.getElementById("save-voice-btn").disabled = !(document.getElementById("voice-name-input").value.trim() && modalFileData);
 
 
1080
  }
1081
 
1082
  window.saveVoice = async function() {
 
1083
  const voice = {
1084
  id: crypto.randomUUID(),
1085
+ name: document.getElementById("voice-name-input").value.trim(),
1086
  fileName: modalFileData.name,
1087
  audioData: modalFileData.data,
1088
  audioType: modalFileData.type,
 
1092
  await putVoice(voice);
1093
  selectedVoice = voice;
1094
  closeModal();
1095
+ renderSidebarVoices();
1096
+ renderVoicesPage();
1097
  updateVoiceSelector();
1098
  updateGenerateBtn();
1099
  };
 
1104
  const result = await oauthHandleRedirectIfPresent();
1105
  if (result) {
1106
  accessToken = result.accessToken;
1107
+ userInfo = result.userInfo;
1108
+ // Check Pro status
1109
+ try {
1110
+ const whoami = await fetch("https://huggingface.co/api/whoami-v2", {
1111
+ headers: { Authorization: `Bearer ${accessToken}` }
1112
+ }).then(r => r.json());
1113
+ isPro = !!whoami.isPro;
1114
+ } catch { isPro = false; }
1115
+ showApp();
1116
  }
1117
  }
1118
 
1119
  window.login = async function() {
1120
+ window.location.href = await oauthLoginUrl({ scopes: "openid profile read-billing" });
 
1121
  };
1122
 
1123
+ function showApp() {
1124
  document.getElementById("landing").style.display = "none";
1125
  document.getElementById("app").style.display = "grid";
1126
  document.getElementById("user-avatar").src = userInfo.avatarUrl;
1127
  document.getElementById("user-name").textContent = userInfo.name;
1128
+ renderSidebarVoices();
1129
+ renderVoicesPage();
1130
  renderHistory();
1131
+ updateUsageTracker();
1132
  }
1133
 
1134
  // ── Pages ──
 
1138
  document.getElementById(`page-${page}`).classList.add("active");
1139
  document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
1140
  el.classList.add("active");
1141
+ if (page === "voices") renderVoicesPage();
1142
+ if (page === "history") renderHistory();
1143
  };
1144
 
1145
  window.showApiTab = function(tab, el) {
 
1149
  el.classList.add("active");
1150
  };
1151
 
1152
+ // ── Text ──
1153
 
1154
  const textInput = document.getElementById("text-input");
1155
  textInput.addEventListener("input", () => {
1156
  document.getElementById("char-count").textContent = textInput.value.length;
1157
  updateGenerateBtn();
1158
  });
 
1159
  function updateGenerateBtn() {
1160
  document.getElementById("generate-btn").disabled = !(selectedVoice && textInput.value.trim());
1161
  }
 
1188
  progressFill.style.width = "0%";
1189
 
1190
  try {
 
1191
  const blob = new Blob([selectedVoice.audioData], { type: selectedVoice.audioType || "audio/mpeg" });
1192
  const form = new FormData();
1193
  form.append("files", blob, selectedVoice.fileName);
 
1209
  ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
1210
  },
1211
  body: JSON.stringify({
1212
+ data: [text, lang,
 
1213
  { path: uploadPath, meta: { _type: "gradio.FileData" }, orig_name: selectedVoice.fileName },
1214
+ "", "", steps, 2.0, true, speed, 0, true, true],
1215
+ fn_index: 0, session_hash: sessionHash
 
 
1216
  })
1217
  });
 
1218
  if (!joinRes.ok) throw new Error(`Queue join failed: ${joinRes.status}`);
1219
 
1220
  const streamRes = await fetch(
 
1249
  } else if (data.msg === "process_completed") {
1250
  progressFill.style.width = "100%";
1251
  if (data.success) {
1252
+ const dur = data.output.duration || 0;
1253
  resultAudioUrl = data.output.data[0].url;
1254
  document.getElementById("audio-player").src = resultAudioUrl;
1255
  document.getElementById("player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
1256
+ document.getElementById("player-meta").textContent = `${selectedVoice.name} · ${dur.toFixed(1)}s · ${text.length} chars`;
1257
  result.classList.add("visible");
1258
+ showStatus("success", `Generated in ${dur.toFixed(1)}s`);
1259
+ addToHistory(text, selectedVoice.name, dur, resultAudioUrl);
1260
+ // Estimate GPU usage (~50% of wall time based on our measurements)
1261
+ updateUsageTracker(dur * 0.5);
1262
  } else {
1263
+ const errMsg = data.output?.error || "Generation failed";
1264
+ showStatusWithCTA(errMsg);
1265
  }
1266
  }
1267
  }
1268
  }
1269
  } catch (err) {
1270
+ showStatusWithCTA(err.message);
1271
  } finally {
1272
  btn.disabled = false;
1273
  btn.classList.remove("loading");
 
1275
  }
1276
  };
1277
 
 
 
 
 
 
 
1278
  // ── Player ──
1279
 
1280
  const audioEl = document.getElementById("audio-player");
1281
+ window.togglePlay = function() { audioEl.paused ? audioEl.play() : audioEl.pause(); };
1282
+ audioEl.addEventListener("play", () => { document.getElementById("play-icon").style.display = "none"; document.getElementById("pause-icon").style.display = "block"; });
1283
+ audioEl.addEventListener("pause", () => { document.getElementById("play-icon").style.display = "block"; document.getElementById("pause-icon").style.display = "none"; });
 
 
 
 
 
 
 
 
 
 
 
1284
 
1285
  window.downloadAudio = async function() {
1286
  if (!resultAudioUrl) return;
1287
+ const blob = await fetch(resultAudioUrl).then(r => r.blob());
 
1288
  const a = document.createElement("a");
1289
  a.href = URL.createObjectURL(blob);
1290
  a.download = "openaudio_output.wav";
 
1302
  }
1303
 
1304
  function renderHistory() {
1305
+ const c = document.getElementById("history-container");
1306
+ if (!history.length) { c.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🕐</div><div>No generations yet</div></div>'; return; }
1307
+ c.innerHTML = '<div class="history-list">' + history.map((h, i) => {
1308
+ const t = new Date(h.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
 
 
 
 
1309
  return `<div class="history-item">
1310
+ <button class="history-play" onclick="playHistory(${i})"><svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
1311
+ <div class="history-info"><div class="history-text">${h.text}</div><div class="history-sub">${h.voiceName} · ${h.duration?.toFixed(1)}s · ${t}</div></div>
 
 
 
 
 
1312
  <button class="history-download" onclick="downloadHistoryItem(${i})">Download</button>
1313
  </div>`;
1314
  }).join("") + '</div>';
1315
  }
1316
 
1317
+ window.playHistory = function(i) { const h = history[i]; if (h.url) { audioEl.src = h.url; audioEl.play(); } };
 
 
 
 
1318
  window.downloadHistoryItem = async function(i) {
1319
+ const h = history[i]; if (!h.url) return;
1320
+ try { const b = await fetch(h.url).then(r=>r.blob()); const a=document.createElement("a"); a.href=URL.createObjectURL(b); a.download=`openaudio_${i}.wav`; a.click(); URL.revokeObjectURL(a.href); } catch {}
 
 
 
 
 
 
 
 
 
1321
  };
1322
 
1323
  // ── Init ──