karaoke-gen 0.71.42__py3-none-any.whl → 0.75.53__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. karaoke_gen/__init__.py +32 -1
  2. karaoke_gen/audio_fetcher.py +1220 -67
  3. karaoke_gen/audio_processor.py +15 -3
  4. karaoke_gen/instrumental_review/server.py +154 -860
  5. karaoke_gen/instrumental_review/static/index.html +1529 -0
  6. karaoke_gen/karaoke_finalise/karaoke_finalise.py +87 -2
  7. karaoke_gen/karaoke_gen.py +131 -14
  8. karaoke_gen/lyrics_processor.py +172 -4
  9. karaoke_gen/utils/bulk_cli.py +3 -0
  10. karaoke_gen/utils/cli_args.py +7 -4
  11. karaoke_gen/utils/gen_cli.py +221 -5
  12. karaoke_gen/utils/remote_cli.py +786 -43
  13. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/METADATA +109 -4
  14. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/RECORD +37 -31
  15. lyrics_transcriber/core/controller.py +76 -2
  16. lyrics_transcriber/frontend/package.json +1 -1
  17. lyrics_transcriber/frontend/src/App.tsx +6 -4
  18. lyrics_transcriber/frontend/src/api.ts +25 -10
  19. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  20. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  21. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  22. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  23. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  24. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  25. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  26. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  27. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  28. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-BECn1o8Q.js} +1802 -553
  29. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
  30. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  31. lyrics_transcriber/output/countdown_processor.py +39 -0
  32. lyrics_transcriber/review/server.py +5 -5
  33. lyrics_transcriber/transcribers/audioshake.py +96 -7
  34. lyrics_transcriber/types.py +14 -12
  35. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  36. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/WHEEL +0 -0
  37. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/entry_points.txt +0 -0
  38. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1529 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Instrumental Review</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f0f0f;
10
+ --card: #1a1a1a;
11
+ --card-border: #2a2a2a;
12
+ --waveform-bg: #0d1117;
13
+ --text: #e5e5e5;
14
+ --text-muted: #888;
15
+ --primary: #3b82f6;
16
+ --primary-hover: #2563eb;
17
+ --secondary: #252525;
18
+ --secondary-hover: #333;
19
+ --success: #22c55e;
20
+ --warning: #f59e0b;
21
+ --danger: #ef4444;
22
+ --pink: #ec4899;
23
+ --blue: #60a5fa;
24
+ }
25
+
26
+ * { box-sizing: border-box; margin: 0; padding: 0; }
27
+
28
+ body {
29
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
30
+ background: var(--bg);
31
+ color: var(--text);
32
+ line-height: 1.5;
33
+ height: 100vh;
34
+ overflow: hidden;
35
+ }
36
+
37
+ .app {
38
+ display: flex;
39
+ flex-direction: column;
40
+ height: 100vh;
41
+ padding: 16px 24px;
42
+ gap: 12px;
43
+ }
44
+
45
+ /* Header - compact */
46
+ .header {
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: space-between;
50
+ gap: 16px;
51
+ flex-shrink: 0;
52
+ }
53
+
54
+ .header-left {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 12px;
58
+ }
59
+
60
+ .logo {
61
+ font-size: 1.25rem;
62
+ font-weight: 600;
63
+ }
64
+
65
+ .track-info {
66
+ font-size: 0.9rem;
67
+ color: var(--text-muted);
68
+ }
69
+
70
+ .header-right {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 8px;
74
+ }
75
+
76
+ .badge {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ padding: 4px 10px;
80
+ border-radius: 12px;
81
+ font-size: 0.7rem;
82
+ font-weight: 500;
83
+ background: var(--secondary);
84
+ color: var(--text-muted);
85
+ }
86
+
87
+ .badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
88
+ .badge-success { background: rgba(34, 197, 94, 0.15); color: var(--success); }
89
+
90
+ /* Main waveform player */
91
+ .waveform-player {
92
+ background: var(--card);
93
+ border: 1px solid var(--card-border);
94
+ border-radius: 12px;
95
+ overflow: hidden;
96
+ flex: 1;
97
+ display: flex;
98
+ flex-direction: column;
99
+ min-height: 0;
100
+ }
101
+
102
+ .waveform-toolbar {
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: space-between;
106
+ padding: 10px 16px;
107
+ background: var(--secondary);
108
+ border-bottom: 1px solid var(--card-border);
109
+ gap: 12px;
110
+ flex-shrink: 0;
111
+ }
112
+
113
+ .toolbar-left {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 8px;
117
+ }
118
+
119
+ .toolbar-center {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 6px;
123
+ }
124
+
125
+ .toolbar-right {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 12px;
129
+ }
130
+
131
+ .time-display {
132
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
133
+ font-size: 0.85rem;
134
+ color: var(--text);
135
+ min-width: 90px;
136
+ }
137
+
138
+ .btn {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ padding: 6px 12px;
143
+ border-radius: 6px;
144
+ font-size: 0.8rem;
145
+ font-weight: 500;
146
+ cursor: pointer;
147
+ border: none;
148
+ transition: all 0.15s;
149
+ gap: 6px;
150
+ }
151
+
152
+ .btn-icon {
153
+ width: 32px;
154
+ height: 32px;
155
+ padding: 0;
156
+ border-radius: 50%;
157
+ font-size: 1rem;
158
+ }
159
+
160
+ .btn-primary { background: var(--primary); color: white; }
161
+ .btn-primary:hover { background: var(--primary-hover); }
162
+ .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
163
+
164
+ .btn-secondary { background: var(--secondary); color: var(--text); border: 1px solid var(--card-border); }
165
+ .btn-secondary:hover { background: var(--secondary-hover); }
166
+ .btn-secondary.active { background: var(--primary); border-color: var(--primary); }
167
+
168
+ .btn-ghost { background: transparent; color: var(--text-muted); }
169
+ .btn-ghost:hover { background: var(--secondary); color: var(--text); }
170
+ .btn-ghost.active { background: var(--primary); color: white; }
171
+
172
+ .btn-sm { padding: 4px 8px; font-size: 0.75rem; }
173
+
174
+ .btn-danger { background: var(--danger); color: white; }
175
+ .btn-success { background: var(--success); color: white; }
176
+
177
+ .audio-toggle-group {
178
+ display: flex;
179
+ background: var(--bg);
180
+ border-radius: 6px;
181
+ padding: 2px;
182
+ }
183
+
184
+ .audio-toggle {
185
+ padding: 5px 10px;
186
+ border-radius: 4px;
187
+ font-size: 0.75rem;
188
+ font-weight: 500;
189
+ color: var(--text-muted);
190
+ background: transparent;
191
+ border: none;
192
+ cursor: pointer;
193
+ transition: all 0.15s;
194
+ }
195
+
196
+ .audio-toggle:hover { color: var(--text); }
197
+ .audio-toggle.active { background: var(--primary); color: white; }
198
+
199
+ /* Waveform canvas area */
200
+ .waveform-container {
201
+ flex: 1;
202
+ position: relative;
203
+ overflow-x: auto;
204
+ overflow-y: hidden;
205
+ background: var(--waveform-bg);
206
+ min-height: 120px;
207
+ }
208
+
209
+ .waveform-area {
210
+ position: relative;
211
+ height: 100%;
212
+ min-width: 100%;
213
+ }
214
+
215
+ #waveform-canvas {
216
+ display: block;
217
+ height: 100%;
218
+ cursor: pointer;
219
+ }
220
+
221
+ /* Zoom controls */
222
+ .zoom-controls {
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 4px;
226
+ background: var(--bg);
227
+ border-radius: 6px;
228
+ padding: 2px;
229
+ }
230
+
231
+ .zoom-btn {
232
+ width: 28px;
233
+ height: 28px;
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: center;
237
+ background: transparent;
238
+ border: none;
239
+ color: var(--text-muted);
240
+ cursor: pointer;
241
+ border-radius: 4px;
242
+ font-size: 0.9rem;
243
+ }
244
+
245
+ .zoom-btn:hover { background: var(--secondary); color: var(--text); }
246
+ .zoom-btn.active { background: var(--primary); color: white; }
247
+
248
+ .zoom-label {
249
+ font-size: 0.7rem;
250
+ color: var(--text-muted);
251
+ min-width: 28px;
252
+ text-align: center;
253
+ }
254
+
255
+ /* Upload button */
256
+ .upload-btn {
257
+ position: relative;
258
+ overflow: hidden;
259
+ }
260
+
261
+ .upload-btn input[type="file"] {
262
+ position: absolute;
263
+ top: 0;
264
+ left: 0;
265
+ width: 100%;
266
+ height: 100%;
267
+ opacity: 0;
268
+ cursor: pointer;
269
+ }
270
+
271
+ .upload-progress {
272
+ position: fixed;
273
+ top: 50%;
274
+ left: 50%;
275
+ transform: translate(-50%, -50%);
276
+ background: var(--card);
277
+ border: 1px solid var(--card-border);
278
+ border-radius: 12px;
279
+ padding: 24px 32px;
280
+ z-index: 1000;
281
+ text-align: center;
282
+ }
283
+
284
+ .upload-overlay {
285
+ position: fixed;
286
+ top: 0;
287
+ left: 0;
288
+ width: 100%;
289
+ height: 100%;
290
+ background: rgba(0,0,0,0.6);
291
+ z-index: 999;
292
+ }
293
+
294
+ .playhead {
295
+ position: absolute;
296
+ top: 0;
297
+ width: 2px;
298
+ height: 100%;
299
+ background: var(--primary);
300
+ pointer-events: none;
301
+ z-index: 10;
302
+ box-shadow: 0 0 8px var(--primary);
303
+ }
304
+
305
+ .playhead::after {
306
+ content: '';
307
+ position: absolute;
308
+ top: 0;
309
+ left: -4px;
310
+ width: 10px;
311
+ height: 10px;
312
+ background: var(--primary);
313
+ border-radius: 50%;
314
+ }
315
+
316
+ .selection-overlay {
317
+ position: absolute;
318
+ top: 0;
319
+ height: 100%;
320
+ background: rgba(59, 130, 246, 0.3);
321
+ border: 1px dashed var(--primary);
322
+ pointer-events: none;
323
+ z-index: 5;
324
+ }
325
+
326
+ .time-axis {
327
+ display: flex;
328
+ justify-content: space-between;
329
+ padding: 4px 12px;
330
+ font-size: 0.7rem;
331
+ color: var(--text-muted);
332
+ background: rgba(0,0,0,0.4);
333
+ flex-shrink: 0;
334
+ }
335
+
336
+ /* Hidden audio element */
337
+ #audio-player { display: none; }
338
+
339
+ /* Bottom section */
340
+ .bottom-section {
341
+ display: flex;
342
+ gap: 12px;
343
+ flex-shrink: 0;
344
+ }
345
+
346
+ /* Mute regions panel */
347
+ .mute-panel {
348
+ flex: 1;
349
+ background: var(--card);
350
+ border: 1px solid var(--card-border);
351
+ border-radius: 10px;
352
+ padding: 12px;
353
+ display: flex;
354
+ flex-direction: column;
355
+ gap: 8px;
356
+ max-height: 140px;
357
+ }
358
+
359
+ .mute-panel-header {
360
+ display: flex;
361
+ align-items: center;
362
+ justify-content: space-between;
363
+ }
364
+
365
+ .mute-panel-title {
366
+ font-size: 0.85rem;
367
+ font-weight: 600;
368
+ }
369
+
370
+ .mute-regions-list {
371
+ display: flex;
372
+ flex-wrap: wrap;
373
+ gap: 6px;
374
+ overflow-y: auto;
375
+ flex: 1;
376
+ }
377
+
378
+ .mute-region-tag {
379
+ display: inline-flex;
380
+ align-items: center;
381
+ gap: 4px;
382
+ padding: 4px 8px;
383
+ background: var(--secondary);
384
+ border-radius: 4px;
385
+ font-size: 0.75rem;
386
+ }
387
+
388
+ .mute-region-tag button {
389
+ background: none;
390
+ border: none;
391
+ color: var(--text-muted);
392
+ cursor: pointer;
393
+ padding: 0;
394
+ font-size: 0.8rem;
395
+ line-height: 1;
396
+ }
397
+
398
+ .mute-region-tag button:hover { color: var(--danger); }
399
+
400
+ .quick-segments {
401
+ display: flex;
402
+ flex-wrap: wrap;
403
+ gap: 4px;
404
+ }
405
+
406
+ .quick-segment-btn {
407
+ padding: 3px 6px;
408
+ background: var(--bg);
409
+ border: 1px solid var(--card-border);
410
+ border-radius: 4px;
411
+ font-size: 0.7rem;
412
+ color: var(--text-muted);
413
+ cursor: pointer;
414
+ }
415
+
416
+ .quick-segment-btn:hover {
417
+ border-color: var(--pink);
418
+ color: var(--pink);
419
+ }
420
+
421
+ /* Selection panel */
422
+ .selection-panel {
423
+ width: 340px;
424
+ background: var(--card);
425
+ border: 1px solid var(--card-border);
426
+ border-radius: 10px;
427
+ padding: 12px;
428
+ display: flex;
429
+ flex-direction: column;
430
+ gap: 8px;
431
+ }
432
+
433
+ .selection-panel-title {
434
+ font-size: 0.85rem;
435
+ font-weight: 600;
436
+ }
437
+
438
+ .selection-options {
439
+ display: flex;
440
+ flex-direction: column;
441
+ gap: 6px;
442
+ }
443
+
444
+ .selection-option {
445
+ display: flex;
446
+ align-items: center;
447
+ gap: 10px;
448
+ padding: 10px 12px;
449
+ background: var(--secondary);
450
+ border-radius: 8px;
451
+ cursor: pointer;
452
+ border: 2px solid transparent;
453
+ transition: border-color 0.15s;
454
+ }
455
+
456
+ .selection-option:hover { border-color: var(--card-border); }
457
+ .selection-option.selected { border-color: var(--primary); background: rgba(59, 130, 246, 0.1); }
458
+
459
+ .selection-option input { display: none; }
460
+
461
+ .selection-radio {
462
+ width: 16px;
463
+ height: 16px;
464
+ border: 2px solid var(--text-muted);
465
+ border-radius: 50%;
466
+ display: flex;
467
+ align-items: center;
468
+ justify-content: center;
469
+ flex-shrink: 0;
470
+ }
471
+
472
+ .selection-option.selected .selection-radio {
473
+ border-color: var(--primary);
474
+ }
475
+
476
+ .selection-option.selected .selection-radio::after {
477
+ content: '';
478
+ width: 8px;
479
+ height: 8px;
480
+ background: var(--primary);
481
+ border-radius: 50%;
482
+ }
483
+
484
+ .selection-label {
485
+ flex: 1;
486
+ font-size: 0.8rem;
487
+ }
488
+
489
+ .selection-label-title { font-weight: 500; }
490
+ .selection-label-desc { font-size: 0.7rem; color: var(--text-muted); }
491
+
492
+ .submit-btn {
493
+ margin-top: auto;
494
+ width: 100%;
495
+ padding: 10px;
496
+ font-size: 0.85rem;
497
+ }
498
+
499
+ /* Keyboard hints */
500
+ .kbd {
501
+ display: inline-block;
502
+ padding: 2px 6px;
503
+ background: var(--bg);
504
+ border: 1px solid var(--card-border);
505
+ border-radius: 4px;
506
+ font-family: 'SF Mono', Monaco, monospace;
507
+ font-size: 0.65rem;
508
+ color: var(--text-muted);
509
+ }
510
+
511
+ /* Alert */
512
+ .alert {
513
+ padding: 1rem;
514
+ border-radius: 8px;
515
+ text-align: center;
516
+ }
517
+
518
+ .alert-success {
519
+ background: rgba(34, 197, 94, 0.15);
520
+ border: 1px solid var(--success);
521
+ color: var(--success);
522
+ }
523
+
524
+ .alert-error {
525
+ position: fixed;
526
+ top: 16px;
527
+ left: 50%;
528
+ transform: translateX(-50%);
529
+ background: rgba(239, 68, 68, 0.95);
530
+ color: white;
531
+ padding: 10px 20px;
532
+ border-radius: 8px;
533
+ z-index: 1000;
534
+ }
535
+
536
+ /* Loading */
537
+ .loading {
538
+ display: flex;
539
+ align-items: center;
540
+ justify-content: center;
541
+ height: 100%;
542
+ }
543
+
544
+ .spinner {
545
+ width: 32px;
546
+ height: 32px;
547
+ border: 3px solid var(--card-border);
548
+ border-top-color: var(--primary);
549
+ border-radius: 50%;
550
+ animation: spin 1s linear infinite;
551
+ }
552
+
553
+ @keyframes spin { to { transform: rotate(360deg); } }
554
+
555
+ .hidden { display: none !important; }
556
+
557
+ /* Success screen */
558
+ .success-screen {
559
+ display: flex;
560
+ flex-direction: column;
561
+ align-items: center;
562
+ justify-content: center;
563
+ height: 100%;
564
+ gap: 16px;
565
+ }
566
+
567
+ .success-screen h2 {
568
+ font-size: 1.5rem;
569
+ color: var(--success);
570
+ }
571
+ </style>
572
+ </head>
573
+ <body>
574
+ <div class="app" id="app">
575
+ <div class="loading">
576
+ <div class="spinner"></div>
577
+ </div>
578
+ </div>
579
+
580
+ <script>
581
+ // State
582
+ let analysisData = null;
583
+ let waveformData = null;
584
+ let muteRegions = [];
585
+ let currentTime = 0;
586
+ let duration = 0;
587
+ let isPlaying = false;
588
+ let isDragging = false;
589
+ let dragStartTime = 0;
590
+ let selectionStartX = 0;
591
+ let activeAudio = 'backing';
592
+ let selectedOption = 'with_backing';
593
+ let hasCustom = false;
594
+ let hasUploaded = false;
595
+ let uploadedFilename = '';
596
+ let hasOriginal = false;
597
+ let zoomLevel = 1;
598
+ let animationFrameId = null;
599
+ let currentAudioElement = null; // Track audio element reference for listener management
600
+
601
+ // Parse URL parameters for cloud mode
602
+ const urlParams = new URLSearchParams(window.location.search);
603
+ const encodedBaseApiUrl = urlParams.get('baseApiUrl');
604
+ const instrumentalToken = urlParams.get('instrumentalToken');
605
+
606
+ // Determine API base URL - cloud mode uses provided URL, local mode uses default
607
+ const API_BASE = encodedBaseApiUrl
608
+ ? decodeURIComponent(encodedBaseApiUrl)
609
+ : '/api/jobs/local';
610
+
611
+ // Helper to add token to URL if available
612
+ function addTokenToUrl(url) {
613
+ if (!instrumentalToken) return url;
614
+ const separator = url.includes('?') ? '&' : '?';
615
+ return `${url}${separator}instrumental_token=${encodeURIComponent(instrumentalToken)}`;
616
+ }
617
+
618
+ // HTML escape helper to prevent XSS
619
+ function escapeHtml(str) {
620
+ if (!str) return '';
621
+ const div = document.createElement('div');
622
+ div.textContent = str;
623
+ return div.innerHTML;
624
+ }
625
+
626
+ // Named event handlers for audio (so they can be added once)
627
+ function onAudioPlay() { isPlaying = true; updatePlayButton(); startPlayheadAnimation(); }
628
+ function onAudioPause() { isPlaying = false; updatePlayButton(); stopPlayheadAnimation(); }
629
+ function onAudioEnded() { isPlaying = false; updatePlayButton(); stopPlayheadAnimation(); }
630
+
631
+ // Initialize
632
+ async function init() {
633
+ try {
634
+ const [analysisRes, waveformRes] = await Promise.all([
635
+ fetch(addTokenToUrl(`${API_BASE}/instrumental-analysis`)),
636
+ fetch(addTokenToUrl(`${API_BASE}/waveform-data?num_points=1000`))
637
+ ]);
638
+
639
+ if (!analysisRes.ok) throw new Error('Failed to load analysis');
640
+ analysisData = await analysisRes.json();
641
+
642
+ if (waveformRes.ok) {
643
+ waveformData = await waveformRes.json();
644
+ duration = waveformData.duration;
645
+ }
646
+
647
+ // Set initial selection based on recommendation
648
+ selectedOption = analysisData.analysis.recommended_selection === 'clean' ? 'clean' : 'with_backing';
649
+
650
+ // Check if there's already an uploaded instrumental
651
+ if (analysisData.has_uploaded_instrumental) {
652
+ hasUploaded = true;
653
+ }
654
+
655
+ // Check if original audio is available
656
+ if (analysisData.has_original) {
657
+ hasOriginal = true;
658
+ }
659
+
660
+ render();
661
+ setupKeyboardShortcuts();
662
+ } catch (error) {
663
+ showError(error.message);
664
+ }
665
+ }
666
+
667
+ function render() {
668
+ // Pause any existing audio before rebuilding DOM to avoid AbortError
669
+ const existingAudio = document.getElementById('audio-player');
670
+ const wasPlaying = isPlaying;
671
+ if (existingAudio && !existingAudio.paused) {
672
+ existingAudio.pause();
673
+ }
674
+
675
+ const app = document.getElementById('app');
676
+ const segments = analysisData.analysis.audible_segments;
677
+ const hasSegments = segments.length > 0;
678
+
679
+ app.innerHTML = `
680
+ <div class="header">
681
+ <div class="header-left">
682
+ <span class="logo">🎤 Instrumental Review</span>
683
+ <span class="track-info">${escapeHtml(analysisData.artist) || ''} ${analysisData.artist && analysisData.title ? '–' : ''} ${escapeHtml(analysisData.title) || ''}</span>
684
+ </div>
685
+ <div class="header-right">
686
+ ${hasSegments ? `
687
+ <span class="badge">${segments.length} segments</span>
688
+ <span class="badge">${analysisData.analysis.audible_percentage.toFixed(0)}% backing vocals</span>
689
+ ` : ''}
690
+ <span class="badge ${analysisData.analysis.recommended_selection === 'clean' ? 'badge-success' : 'badge-warning'}">
691
+ ${analysisData.analysis.recommended_selection === 'clean' ? '✓ Clean recommended' : '⚠ Review needed'}
692
+ </span>
693
+ </div>
694
+ </div>
695
+
696
+ <div id="error-container"></div>
697
+
698
+ <div class="waveform-player">
699
+ <div class="waveform-toolbar">
700
+ <div class="toolbar-left">
701
+ <button class="btn btn-icon btn-primary" id="play-btn" onclick="togglePlayPause()">
702
+ ${isPlaying ? '⏸' : '▶'}
703
+ </button>
704
+ <span class="time-display">
705
+ <span id="current-time">${formatTime(currentTime)}</span>
706
+ <span style="color: var(--text-muted)"> / ${formatTime(duration)}</span>
707
+ </span>
708
+ </div>
709
+
710
+ <div class="toolbar-center">
711
+ <div class="audio-toggle-group">
712
+ ${hasOriginal ? `
713
+ <button class="audio-toggle ${activeAudio === 'original' ? 'active' : ''}" data-audio-type="original" onclick="setActiveAudio('original')">Original</button>
714
+ ` : ''}
715
+ <button class="audio-toggle ${activeAudio === 'backing' ? 'active' : ''}" data-audio-type="backing" onclick="setActiveAudio('backing')">Backing Vocals Only</button>
716
+ <button class="audio-toggle ${activeAudio === 'clean' ? 'active' : ''}" data-audio-type="clean" onclick="setActiveAudio('clean')">Pure Instrumental</button>
717
+ ${analysisData.audio_urls.with_backing ? `
718
+ <button class="audio-toggle ${activeAudio === 'with_backing' ? 'active' : ''}" data-audio-type="with_backing" onclick="setActiveAudio('with_backing')">Instrumental + Backing</button>
719
+ ` : ''}
720
+ ${hasCustom ? `
721
+ <button class="audio-toggle ${activeAudio === 'custom' ? 'active' : ''}" data-audio-type="custom" onclick="setActiveAudio('custom')">Custom</button>
722
+ ` : ''}
723
+ ${hasUploaded ? `
724
+ <button class="audio-toggle ${activeAudio === 'uploaded' ? 'active' : ''}" data-audio-type="uploaded" onclick="setActiveAudio('uploaded')" title="${escapeHtml(uploadedFilename)}">Uploaded</button>
725
+ ` : ''}
726
+ </div>
727
+ <label class="btn btn-sm btn-secondary upload-btn" title="Upload custom instrumental">
728
+ 📁 Upload
729
+ <input type="file" accept=".flac,.mp3,.wav,.m4a,.ogg" onchange="handleUpload(event)">
730
+ </label>
731
+ </div>
732
+
733
+ <div class="toolbar-right">
734
+ <div class="zoom-controls">
735
+ <button class="zoom-btn ${zoomLevel === 1 ? 'active' : ''}" onclick="setZoom(1)" title="1x zoom">1x</button>
736
+ <button class="zoom-btn ${zoomLevel === 2 ? 'active' : ''}" onclick="setZoom(2)" title="2x zoom">2x</button>
737
+ <button class="zoom-btn ${zoomLevel === 4 ? 'active' : ''}" onclick="setZoom(4)" title="4x zoom">4x</button>
738
+ </div>
739
+ <span style="font-size: 0.7rem; color: var(--text-muted);">
740
+ <span class="kbd">Shift</span>+drag
741
+ </span>
742
+ <span class="kbd">Space</span>
743
+ </div>
744
+ </div>
745
+
746
+ <div class="waveform-container" id="waveform-container">
747
+ <div class="waveform-area" id="waveform-area" style="width: ${zoomLevel * 100}%;">
748
+ <canvas id="waveform-canvas"></canvas>
749
+ <div class="playhead hidden" id="playhead"></div>
750
+ <div class="selection-overlay hidden" id="selection-overlay"></div>
751
+ </div>
752
+ </div>
753
+
754
+ <div class="time-axis">
755
+ <span>0:00</span>
756
+ <span>${formatTime(duration * 0.25)}</span>
757
+ <span>${formatTime(duration * 0.5)}</span>
758
+ <span>${formatTime(duration * 0.75)}</span>
759
+ <span>${formatTime(duration)}</span>
760
+ </div>
761
+ </div>
762
+
763
+ <audio id="audio-player" src="${getAudioUrl()}"></audio>
764
+
765
+ <div class="bottom-section">
766
+ <div class="mute-panel">
767
+ <div class="mute-panel-header">
768
+ <span class="mute-panel-title">Mute Regions ${muteRegions.length > 0 ? `(${muteRegions.length})` : ''}</span>
769
+ ${muteRegions.length > 0 ? `
770
+ <div style="display: flex; gap: 6px;">
771
+ <button class="btn btn-sm btn-secondary" onclick="clearAllRegions()">Clear</button>
772
+ ${!hasCustom ? `<button class="btn btn-sm btn-primary" id="create-custom-btn" onclick="createCustomInstrumental()">Create Custom</button>` : ''}
773
+ </div>
774
+ ` : ''}
775
+ </div>
776
+
777
+ ${muteRegions.length > 0 ? `
778
+ <div class="mute-regions-list">
779
+ ${muteRegions.map((r, i) => `
780
+ <div class="mute-region-tag">
781
+ <span onclick="seekTo(${r.start_seconds}, true)" style="cursor: pointer">${formatTime(r.start_seconds)} – ${formatTime(r.end_seconds)}</span>
782
+ <button onclick="removeRegion(${i})">×</button>
783
+ </div>
784
+ `).join('')}
785
+ </div>
786
+ ` : `
787
+ <div style="color: var(--text-muted); font-size: 0.75rem;">
788
+ ${hasSegments ? 'Click segments below or <kbd class="kbd">Shift</kbd> + drag on waveform' : 'No backing vocals detected – clean instrumental recommended'}
789
+ </div>
790
+ `}
791
+
792
+ ${hasSegments ? `
793
+ <div class="quick-segments">
794
+ ${segments.slice(0, 8).map((seg, i) => `
795
+ <button class="quick-segment-btn" onclick="addSegmentAsRegion(${i})" title="Add to mute regions">
796
+ ${formatTime(seg.start_seconds)} – ${formatTime(seg.end_seconds)}
797
+ </button>
798
+ `).join('')}
799
+ ${segments.length > 8 ? `<span style="font-size: 0.7rem; color: var(--text-muted); padding: 3px;">+${segments.length - 8} more</span>` : ''}
800
+ </div>
801
+ ` : ''}
802
+ </div>
803
+
804
+ <div class="selection-panel">
805
+ <span class="selection-panel-title">Final Selection</span>
806
+ <div class="selection-options">
807
+ <label class="selection-option ${selectedOption === 'clean' ? 'selected' : ''}" onclick="setSelection('clean')">
808
+ <input type="radio" name="selection" value="clean">
809
+ <div class="selection-radio"></div>
810
+ <div class="selection-label">
811
+ <div class="selection-label-title">Clean Instrumental</div>
812
+ <div class="selection-label-desc">No backing vocals</div>
813
+ </div>
814
+ </label>
815
+ <label class="selection-option ${selectedOption === 'with_backing' ? 'selected' : ''}" onclick="setSelection('with_backing')">
816
+ <input type="radio" name="selection" value="with_backing">
817
+ <div class="selection-radio"></div>
818
+ <div class="selection-label">
819
+ <div class="selection-label-title">With Backing Vocals</div>
820
+ <div class="selection-label-desc">All backing vocals included</div>
821
+ </div>
822
+ </label>
823
+ ${hasOriginal ? `
824
+ <label class="selection-option ${selectedOption === 'original' ? 'selected' : ''}" onclick="setSelection('original')">
825
+ <input type="radio" name="selection" value="original">
826
+ <div class="selection-radio"></div>
827
+ <div class="selection-label">
828
+ <div class="selection-label-title">Original Audio</div>
829
+ <div class="selection-label-desc">Full original with lead vocals</div>
830
+ </div>
831
+ </label>
832
+ ` : ''}
833
+ ${hasCustom ? `
834
+ <label class="selection-option ${selectedOption === 'custom' ? 'selected' : ''}" onclick="setSelection('custom')">
835
+ <input type="radio" name="selection" value="custom">
836
+ <div class="selection-radio"></div>
837
+ <div class="selection-label">
838
+ <div class="selection-label-title">Custom</div>
839
+ <div class="selection-label-desc">${muteRegions.length} regions muted</div>
840
+ </div>
841
+ </label>
842
+ ` : ''}
843
+ ${hasUploaded ? `
844
+ <label class="selection-option ${selectedOption === 'uploaded' ? 'selected' : ''}" onclick="setSelection('uploaded')">
845
+ <input type="radio" name="selection" value="uploaded">
846
+ <div class="selection-radio"></div>
847
+ <div class="selection-label">
848
+ <div class="selection-label-title">Uploaded</div>
849
+ <div class="selection-label-desc">${escapeHtml(uploadedFilename)}</div>
850
+ </div>
851
+ </label>
852
+ ` : ''}
853
+ </div>
854
+ <button class="btn btn-primary submit-btn" id="submit-btn" onclick="submitSelection()">
855
+ ✓ Confirm & Continue
856
+ </button>
857
+ </div>
858
+ </div>
859
+ `;
860
+
861
+ // Setup after render
862
+ if (waveformData) {
863
+ resizeCanvas();
864
+ drawWaveform();
865
+ setupWaveformInteraction();
866
+ }
867
+
868
+ // Setup audio state - add listeners when element changes
869
+ const audio = document.getElementById('audio-player');
870
+ if (audio) {
871
+ // Check if this is a new audio element (DOM was rebuilt)
872
+ if (audio !== currentAudioElement) {
873
+ audio.addEventListener('timeupdate', onTimeUpdate);
874
+ audio.addEventListener('play', onAudioPlay);
875
+ audio.addEventListener('pause', onAudioPause);
876
+ audio.addEventListener('ended', onAudioEnded);
877
+ currentAudioElement = audio;
878
+ }
879
+
880
+ // Restore playback position and state after audio is ready
881
+ audio.addEventListener('loadeddata', function onLoaded() {
882
+ audio.currentTime = currentTime;
883
+ if (wasPlaying) {
884
+ audio.play().catch(() => {});
885
+ }
886
+ }, { once: true });
887
+
888
+ // If already loaded (cached), set time directly
889
+ if (audio.readyState >= 2) {
890
+ audio.currentTime = currentTime;
891
+ if (wasPlaying) {
892
+ audio.play().catch(() => {});
893
+ }
894
+ }
895
+ }
896
+ }
897
+
898
+ function resizeCanvas() {
899
+ const canvas = document.getElementById('waveform-canvas');
900
+ const container = document.getElementById('waveform-container');
901
+ const area = document.getElementById('waveform-area');
902
+ if (!canvas || !container || !area) return;
903
+
904
+ // Set canvas width based on container width * zoom level
905
+ canvas.width = container.clientWidth * zoomLevel;
906
+ canvas.height = container.clientHeight;
907
+
908
+ // Update area width to match
909
+ area.style.width = `${zoomLevel * 100}%`;
910
+ }
911
+
912
+ function drawWaveform() {
913
+ const canvas = document.getElementById('waveform-canvas');
914
+ if (!canvas || !waveformData) return;
915
+
916
+ const ctx = canvas.getContext('2d');
917
+ const { amplitudes } = waveformData;
918
+ const width = canvas.width;
919
+ const height = canvas.height;
920
+ const centerY = height / 2;
921
+ const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--waveform-bg').trim();
922
+
923
+ // Clear
924
+ ctx.fillStyle = bgColor;
925
+ ctx.fillRect(0, 0, width, height);
926
+
927
+ // Draw center line
928
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
929
+ ctx.setLineDash([4, 4]);
930
+ ctx.beginPath();
931
+ ctx.moveTo(0, centerY);
932
+ ctx.lineTo(width, centerY);
933
+ ctx.stroke();
934
+ ctx.setLineDash([]);
935
+
936
+ // Draw waveform bars
937
+ const barWidth = width / amplitudes.length;
938
+
939
+ amplitudes.forEach((amp, i) => {
940
+ const x = i * barWidth;
941
+ const barHeight = Math.max(2, amp * height * 0.9);
942
+ const y = centerY - barHeight / 2;
943
+ const time = (i / amplitudes.length) * duration;
944
+
945
+ // Check regions
946
+ const inMuteRegion = muteRegions.some(r => time >= r.start_seconds && time <= r.end_seconds);
947
+ const inAudibleSegment = analysisData.analysis.audible_segments.some(
948
+ s => time >= s.start_seconds && time <= s.end_seconds
949
+ );
950
+
951
+ if (inMuteRegion) {
952
+ // Muted regions blend into background - very subtle
953
+ ctx.fillStyle = 'rgba(13, 17, 23, 0.8)';
954
+ } else if (inAudibleSegment) {
955
+ ctx.fillStyle = '#ec4899'; // pink for detected backing vocals
956
+ } else {
957
+ ctx.fillStyle = '#60a5fa'; // blue for rest
958
+ }
959
+
960
+ ctx.fillRect(x, y, Math.max(1, barWidth - 0.5), barHeight);
961
+ });
962
+ }
963
+
964
+ function setupWaveformInteraction() {
965
+ const canvas = document.getElementById('waveform-canvas');
966
+ const area = document.getElementById('waveform-area');
967
+ if (!canvas || !area) return;
968
+
969
+ canvas.onmousedown = (e) => {
970
+ const rect = canvas.getBoundingClientRect();
971
+ const x = e.clientX - rect.left;
972
+ const time = (x / rect.width) * duration;
973
+
974
+ // Shift+drag to select mute region
975
+ if (e.shiftKey) {
976
+ isDragging = true;
977
+ dragStartTime = time;
978
+ selectionStartX = x;
979
+ updateSelectionOverlay(x, x);
980
+ showSelectionOverlay(true);
981
+ } else {
982
+ // Regular click to seek and play
983
+ seekTo(time);
984
+ }
985
+ };
986
+
987
+ canvas.onmousemove = (e) => {
988
+ if (!isDragging) return;
989
+ const rect = canvas.getBoundingClientRect();
990
+ const x = e.clientX - rect.left;
991
+ updateSelectionOverlay(selectionStartX, x);
992
+ };
993
+
994
+ const endDrag = (e) => {
995
+ if (!isDragging) return;
996
+
997
+ const rect = canvas.getBoundingClientRect();
998
+ const x = e.clientX - rect.left;
999
+ const time = (x / rect.width) * duration;
1000
+
1001
+ const start = Math.min(dragStartTime, time);
1002
+ const end = Math.max(dragStartTime, time);
1003
+
1004
+ if (end - start > 0.5) {
1005
+ addRegion(start, end);
1006
+ }
1007
+
1008
+ isDragging = false;
1009
+ showSelectionOverlay(false);
1010
+ };
1011
+
1012
+ canvas.onmouseup = endDrag;
1013
+ canvas.onmouseleave = (e) => {
1014
+ if (isDragging) endDrag(e);
1015
+ };
1016
+ }
1017
+
1018
+ function updateSelectionOverlay(startX, endX) {
1019
+ const overlay = document.getElementById('selection-overlay');
1020
+ if (!overlay) return;
1021
+
1022
+ const left = Math.min(startX, endX);
1023
+ const width = Math.abs(endX - startX);
1024
+
1025
+ overlay.style.left = `${left}px`;
1026
+ overlay.style.width = `${width}px`;
1027
+ }
1028
+
1029
+ function showSelectionOverlay(show) {
1030
+ const overlay = document.getElementById('selection-overlay');
1031
+ if (overlay) {
1032
+ overlay.classList.toggle('hidden', !show);
1033
+ }
1034
+ }
1035
+
1036
+ function startPlayheadAnimation() {
1037
+ const animate = () => {
1038
+ updatePlayhead();
1039
+ animationFrameId = requestAnimationFrame(animate);
1040
+ };
1041
+ animate();
1042
+ }
1043
+
1044
+ function stopPlayheadAnimation() {
1045
+ if (animationFrameId) {
1046
+ cancelAnimationFrame(animationFrameId);
1047
+ animationFrameId = null;
1048
+ }
1049
+ }
1050
+
1051
+ function updatePlayhead() {
1052
+ const playhead = document.getElementById('playhead');
1053
+ const canvas = document.getElementById('waveform-canvas');
1054
+ const audio = document.getElementById('audio-player');
1055
+
1056
+ if (!playhead || !canvas || !audio) return;
1057
+
1058
+ currentTime = audio.currentTime;
1059
+
1060
+ // Update time display regardless of playhead position validity
1061
+ const timeEl = document.getElementById('current-time');
1062
+ if (timeEl) timeEl.textContent = formatTime(currentTime);
1063
+
1064
+ // Guard against NaN/Infinity when calculating playhead position
1065
+ if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(canvas.width) || canvas.width <= 0) {
1066
+ playhead.style.left = '0px';
1067
+ } else {
1068
+ const x = Math.max(0, Math.min((currentTime / duration) * canvas.width, canvas.width));
1069
+ playhead.style.left = `${x}px`;
1070
+ }
1071
+
1072
+ playhead.classList.remove('hidden');
1073
+ }
1074
+
1075
+ function updatePlayButton() {
1076
+ const btn = document.getElementById('play-btn');
1077
+ if (btn) btn.innerHTML = isPlaying ? '⏸' : '▶';
1078
+ }
1079
+
1080
+ function togglePlayPause() {
1081
+ const audio = document.getElementById('audio-player');
1082
+ if (!audio) return;
1083
+
1084
+ if (isPlaying) {
1085
+ audio.pause();
1086
+ } else {
1087
+ audio.play();
1088
+ }
1089
+ }
1090
+
1091
+ function seekTo(time, autoPlay = true) {
1092
+ const audio = document.getElementById('audio-player');
1093
+ if (audio) {
1094
+ audio.currentTime = time;
1095
+ currentTime = time;
1096
+ updatePlayhead();
1097
+ // Auto-play when seeking via click (if not already playing)
1098
+ if (autoPlay && !isPlaying) {
1099
+ audio.play();
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ function onTimeUpdate(e) {
1105
+ currentTime = e.target.currentTime;
1106
+ }
1107
+
1108
+
1109
+ function setActiveAudio(type) {
1110
+ const audio = document.getElementById('audio-player');
1111
+ if (!audio) return;
1112
+
1113
+ const wasPlaying = !audio.paused;
1114
+ const time = audio.currentTime;
1115
+
1116
+ // Pause before changing source
1117
+ audio.pause();
1118
+
1119
+ activeAudio = type;
1120
+
1121
+ // Update toggle button states using data attributes (robust detection)
1122
+ document.querySelectorAll('.audio-toggle').forEach(btn => {
1123
+ const btnType = btn.dataset.audioType || 'custom';
1124
+ btn.classList.toggle('active', btnType === type);
1125
+ });
1126
+
1127
+ // Change source and restore playback
1128
+ audio.src = getAudioUrl();
1129
+ audio.addEventListener('loadeddata', function onLoaded() {
1130
+ audio.currentTime = time;
1131
+ if (wasPlaying) {
1132
+ audio.play().catch(() => {});
1133
+ }
1134
+ }, { once: true });
1135
+ }
1136
+
1137
+ function getAudioUrl() {
1138
+ const stemTypes = {
1139
+ original: 'original',
1140
+ backing: 'backing_vocals',
1141
+ clean: 'clean_instrumental',
1142
+ with_backing: 'with_backing',
1143
+ custom: 'custom_instrumental',
1144
+ uploaded: 'uploaded_instrumental'
1145
+ };
1146
+ const stemType = stemTypes[activeAudio] || stemTypes.backing;
1147
+
1148
+ // Cloud mode uses /audio-stream/{stem_type}, local mode uses /api/audio/{stem_type}
1149
+ const isCloudMode = !!encodedBaseApiUrl;
1150
+ const url = isCloudMode
1151
+ ? `${API_BASE}/audio-stream/${stemType}`
1152
+ : `/api/audio/${stemType}`;
1153
+
1154
+ return addTokenToUrl(url);
1155
+ }
1156
+
1157
+ function formatTime(seconds) {
1158
+ const mins = Math.floor(seconds / 60);
1159
+ const secs = Math.floor(seconds % 60);
1160
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
1161
+ }
1162
+
1163
+ function addRegion(start, end) {
1164
+ muteRegions.push({ start_seconds: start, end_seconds: end });
1165
+ muteRegions.sort((a, b) => a.start_seconds - b.start_seconds);
1166
+ mergeOverlappingRegions();
1167
+ hasCustom = false; // Invalidate custom when regions change
1168
+
1169
+ // Just redraw waveform instead of full render to preserve scroll position
1170
+ drawWaveform();
1171
+ updateMuteRegionsPanel();
1172
+ }
1173
+
1174
+ function updateMuteRegionsPanel() {
1175
+ // Update only the mute regions panel without full DOM rebuild
1176
+ const panel = document.querySelector('.mute-panel');
1177
+ if (!panel) return;
1178
+
1179
+ const segments = analysisData.analysis.audible_segments;
1180
+ const hasSegments = segments.length > 0;
1181
+
1182
+ // Build mute regions list HTML
1183
+ let regionsListHtml = '';
1184
+ if (muteRegions.length > 0) {
1185
+ regionsListHtml = '<div class="mute-regions-list">' +
1186
+ muteRegions.map((r, i) =>
1187
+ '<div class="mute-region-tag">' +
1188
+ '<span onclick="seekTo(' + r.start_seconds + ', true)" style="cursor: pointer">' +
1189
+ formatTime(r.start_seconds) + ' – ' + formatTime(r.end_seconds) + '</span>' +
1190
+ '<button onclick="removeRegion(' + i + ')">×</button>' +
1191
+ '</div>'
1192
+ ).join('') +
1193
+ '</div>';
1194
+ } else {
1195
+ regionsListHtml = '<div style="color: var(--text-muted); font-size: 0.75rem;">' +
1196
+ (hasSegments ? 'Click segments below or <kbd class="kbd">Shift</kbd> + drag on waveform' : 'No backing vocals detected – clean instrumental recommended') +
1197
+ '</div>';
1198
+ }
1199
+
1200
+ // Build quick segments HTML
1201
+ let quickSegmentsHtml = '';
1202
+ if (hasSegments) {
1203
+ quickSegmentsHtml = '<div class="quick-segments">' +
1204
+ segments.slice(0, 8).map((seg, i) =>
1205
+ '<button class="quick-segment-btn" onclick="addSegmentAsRegion(' + i + ')" title="Add to mute regions">' +
1206
+ formatTime(seg.start_seconds) + ' – ' + formatTime(seg.end_seconds) +
1207
+ '</button>'
1208
+ ).join('') +
1209
+ (segments.length > 8 ? '<span style="font-size: 0.7rem; color: var(--text-muted); padding: 3px;">+' + (segments.length - 8) + ' more</span>' : '') +
1210
+ '</div>';
1211
+ }
1212
+
1213
+ // Build header buttons
1214
+ let headerButtons = '';
1215
+ if (muteRegions.length > 0) {
1216
+ headerButtons = '<div style="display: flex; gap: 6px;">' +
1217
+ '<button class="btn btn-sm btn-secondary" onclick="clearAllRegions()">Clear</button>' +
1218
+ (!hasCustom ? '<button class="btn btn-sm btn-primary" id="create-custom-btn" onclick="createCustomInstrumental()">Create Custom</button>' : '') +
1219
+ '</div>';
1220
+ }
1221
+
1222
+ panel.innerHTML =
1223
+ '<div class="mute-panel-header">' +
1224
+ '<span class="mute-panel-title">Mute Regions ' + (muteRegions.length > 0 ? '(' + muteRegions.length + ')' : '') + '</span>' +
1225
+ headerButtons +
1226
+ '</div>' +
1227
+ regionsListHtml +
1228
+ quickSegmentsHtml;
1229
+ }
1230
+
1231
+ function addSegmentAsRegion(index) {
1232
+ const seg = analysisData.analysis.audible_segments[index];
1233
+ if (seg) {
1234
+ addRegion(seg.start_seconds, seg.end_seconds);
1235
+ }
1236
+ }
1237
+
1238
+ function removeRegion(index) {
1239
+ muteRegions.splice(index, 1);
1240
+ hasCustom = false;
1241
+ drawWaveform();
1242
+ updateMuteRegionsPanel();
1243
+ }
1244
+
1245
+ function clearAllRegions() {
1246
+ muteRegions = [];
1247
+ hasCustom = false;
1248
+ drawWaveform();
1249
+ updateMuteRegionsPanel();
1250
+ }
1251
+
1252
+ function mergeOverlappingRegions() {
1253
+ if (muteRegions.length < 2) return;
1254
+
1255
+ const merged = [muteRegions[0]];
1256
+ for (let i = 1; i < muteRegions.length; i++) {
1257
+ const last = merged[merged.length - 1];
1258
+ const curr = muteRegions[i];
1259
+
1260
+ if (curr.start_seconds <= last.end_seconds + 0.5) {
1261
+ last.end_seconds = Math.max(last.end_seconds, curr.end_seconds);
1262
+ } else {
1263
+ merged.push(curr);
1264
+ }
1265
+ }
1266
+ muteRegions = merged;
1267
+ }
1268
+
1269
+ function setSelection(value) {
1270
+ selectedOption = value;
1271
+ render();
1272
+ }
1273
+
1274
+ function setZoom(level) {
1275
+ const container = document.getElementById('waveform-container');
1276
+ const oldScrollRatio = container ? container.scrollLeft / (container.scrollWidth - container.clientWidth || 1) : 0;
1277
+
1278
+ zoomLevel = level;
1279
+
1280
+ // Update zoom button states directly (avoid full render)
1281
+ document.querySelectorAll('.zoom-btn').forEach(btn => {
1282
+ const btnLevel = parseInt(btn.textContent);
1283
+ btn.classList.toggle('active', btnLevel === level);
1284
+ });
1285
+
1286
+ // Resize canvas and redraw
1287
+ resizeCanvas();
1288
+ drawWaveform();
1289
+
1290
+ // Maintain scroll position proportionally
1291
+ if (container && zoomLevel > 1) {
1292
+ const newMaxScroll = container.scrollWidth - container.clientWidth;
1293
+ container.scrollLeft = oldScrollRatio * newMaxScroll;
1294
+ }
1295
+ }
1296
+
1297
+ async function handleUpload(event) {
1298
+ const file = event.target.files[0];
1299
+ if (!file) return;
1300
+
1301
+ // Show upload progress
1302
+ const overlay = document.createElement('div');
1303
+ overlay.className = 'upload-overlay';
1304
+ overlay.id = 'upload-overlay';
1305
+ document.body.appendChild(overlay);
1306
+
1307
+ const progress = document.createElement('div');
1308
+ progress.className = 'upload-progress';
1309
+ progress.id = 'upload-progress';
1310
+ progress.innerHTML = `
1311
+ <div class="spinner" style="margin: 0 auto 12px;"></div>
1312
+ <div>Uploading ${file.name}...</div>
1313
+ <div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 8px;">Validating duration...</div>
1314
+ `;
1315
+ document.body.appendChild(progress);
1316
+
1317
+ try {
1318
+ const formData = new FormData();
1319
+ formData.append('file', file);
1320
+
1321
+ const response = await fetch(addTokenToUrl(`${API_BASE}/upload-instrumental`), {
1322
+ method: 'POST',
1323
+ body: formData
1324
+ });
1325
+
1326
+ if (!response.ok) {
1327
+ const data = await response.json();
1328
+ throw new Error(data.detail || 'Upload failed');
1329
+ }
1330
+
1331
+ const result = await response.json();
1332
+ hasUploaded = true;
1333
+ uploadedFilename = file.name;
1334
+ activeAudio = 'uploaded';
1335
+ selectedOption = 'uploaded';
1336
+
1337
+ render();
1338
+ showSuccess(`Uploaded ${file.name} (${result.duration_seconds.toFixed(1)}s)`);
1339
+ } catch (error) {
1340
+ showError(error.message);
1341
+ } finally {
1342
+ // Clean up progress UI
1343
+ document.getElementById('upload-overlay')?.remove();
1344
+ document.getElementById('upload-progress')?.remove();
1345
+ // Reset file input so same file can be uploaded again
1346
+ event.target.value = '';
1347
+ }
1348
+ }
1349
+
1350
+ function showSuccess(message) {
1351
+ const existing = document.querySelector('.alert-success-toast');
1352
+ if (existing) existing.remove();
1353
+
1354
+ const el = document.createElement('div');
1355
+ el.className = 'alert-error'; // Reuse error styling but green
1356
+ el.style.background = 'rgba(34, 197, 94, 0.95)';
1357
+ el.textContent = message;
1358
+ document.body.appendChild(el);
1359
+
1360
+ setTimeout(() => el.remove(), 3000);
1361
+ }
1362
+
1363
+ async function createCustomInstrumental() {
1364
+ const btn = document.getElementById('create-custom-btn');
1365
+ const audio = document.getElementById('audio-player');
1366
+ const wasPlaying = isPlaying;
1367
+ const time = currentTime;
1368
+
1369
+ // Pause audio while creating custom instrumental
1370
+ if (audio && !audio.paused) {
1371
+ audio.pause();
1372
+ }
1373
+
1374
+ if (btn) {
1375
+ btn.disabled = true;
1376
+ btn.textContent = 'Creating...';
1377
+ }
1378
+
1379
+ try {
1380
+ const response = await fetch(addTokenToUrl(`${API_BASE}/create-custom-instrumental`), {
1381
+ method: 'POST',
1382
+ headers: { 'Content-Type': 'application/json' },
1383
+ body: JSON.stringify({ mute_regions: muteRegions })
1384
+ });
1385
+
1386
+ if (!response.ok) {
1387
+ const data = await response.json();
1388
+ throw new Error(data.detail || 'Failed to create custom');
1389
+ }
1390
+
1391
+ hasCustom = true;
1392
+ selectedOption = 'custom';
1393
+ activeAudio = 'custom';
1394
+
1395
+ // Render first, then restore playback
1396
+ render();
1397
+
1398
+ // After render, seek to previous position and optionally resume
1399
+ const newAudio = document.getElementById('audio-player');
1400
+ if (newAudio) {
1401
+ newAudio.addEventListener('loadeddata', function onLoaded() {
1402
+ newAudio.removeEventListener('loadeddata', onLoaded);
1403
+ newAudio.currentTime = time;
1404
+ if (wasPlaying) {
1405
+ newAudio.play().catch(() => {});
1406
+ }
1407
+ }, { once: true });
1408
+ }
1409
+ } catch (error) {
1410
+ showError(error.message);
1411
+ if (btn) {
1412
+ btn.disabled = false;
1413
+ btn.textContent = 'Create Custom';
1414
+ }
1415
+ // Resume playback if there was an error
1416
+ if (wasPlaying && audio) {
1417
+ audio.play().catch(() => {});
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ async function submitSelection() {
1423
+ const btn = document.getElementById('submit-btn');
1424
+ if (btn) {
1425
+ btn.disabled = true;
1426
+ btn.textContent = 'Submitting...';
1427
+ }
1428
+
1429
+ try {
1430
+ const response = await fetch(addTokenToUrl(`${API_BASE}/select-instrumental`), {
1431
+ method: 'POST',
1432
+ headers: { 'Content-Type': 'application/json' },
1433
+ body: JSON.stringify({ selection: selectedOption })
1434
+ });
1435
+
1436
+ if (!response.ok) {
1437
+ const data = await response.json();
1438
+ throw new Error(data.detail || 'Failed to submit');
1439
+ }
1440
+
1441
+ const selectionLabels = {
1442
+ clean: 'Clean Instrumental',
1443
+ with_backing: 'With Backing Vocals',
1444
+ custom: 'Custom',
1445
+ uploaded: 'Uploaded Instrumental',
1446
+ original: 'Original Audio'
1447
+ };
1448
+ const selectionLabel = selectionLabels[selectedOption] || selectedOption;
1449
+
1450
+ document.getElementById('app').innerHTML = `
1451
+ <div class="success-screen">
1452
+ <h2>✓ Selection Submitted</h2>
1453
+ <p>You selected: <strong>${escapeHtml(selectionLabel)}</strong></p>
1454
+ <p id="close-msg" style="color: var(--text-muted);">Closing in <span id="countdown">2</span>s...</p>
1455
+ </div>
1456
+ `;
1457
+
1458
+ // Auto-close window after 2 seconds
1459
+ let countdown = 2;
1460
+ const countdownEl = document.getElementById('countdown');
1461
+ const countdownInterval = setInterval(() => {
1462
+ countdown--;
1463
+ if (countdownEl) countdownEl.textContent = countdown;
1464
+ if (countdown <= 0) {
1465
+ clearInterval(countdownInterval);
1466
+ // Try to close the window (works for windows opened by script)
1467
+ window.close();
1468
+ // If window.close() didn't work, update message
1469
+ const msg = document.getElementById('close-msg');
1470
+ if (msg) msg.textContent = 'You can close this window now.';
1471
+ }
1472
+ }, 1000);
1473
+ } catch (error) {
1474
+ showError(error.message);
1475
+ if (btn) {
1476
+ btn.disabled = false;
1477
+ btn.textContent = '✓ Confirm & Continue';
1478
+ }
1479
+ }
1480
+ }
1481
+
1482
+ function setupKeyboardShortcuts() {
1483
+ document.addEventListener('keydown', (e) => {
1484
+ // Ignore if typing in input
1485
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1486
+
1487
+ switch (e.code) {
1488
+ case 'Space':
1489
+ e.preventDefault();
1490
+ togglePlayPause();
1491
+ break;
1492
+ case 'Escape':
1493
+ // Cancel any in-progress drag selection
1494
+ if (isDragging) {
1495
+ isDragging = false;
1496
+ showSelectionOverlay(false);
1497
+ }
1498
+ break;
1499
+ }
1500
+ });
1501
+ }
1502
+
1503
+ function showError(message) {
1504
+ // Remove any existing error
1505
+ const existing = document.querySelector('.alert-error');
1506
+ if (existing) existing.remove();
1507
+
1508
+ const errorEl = document.createElement('div');
1509
+ errorEl.className = 'alert-error';
1510
+ errorEl.textContent = message;
1511
+ document.body.appendChild(errorEl);
1512
+
1513
+ setTimeout(() => errorEl.remove(), 5000);
1514
+ }
1515
+
1516
+ // Handle window resize
1517
+ window.addEventListener('resize', () => {
1518
+ if (waveformData) {
1519
+ resizeCanvas();
1520
+ drawWaveform();
1521
+ }
1522
+ });
1523
+
1524
+ // Start
1525
+ init();
1526
+ </script>
1527
+ </body>
1528
+ </html>
1529
+