karaoke-gen 0.71.27__py3-none-any.whl → 0.75.16__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 (39) hide show
  1. karaoke_gen/__init__.py +32 -1
  2. karaoke_gen/audio_fetcher.py +476 -56
  3. karaoke_gen/audio_processor.py +11 -3
  4. karaoke_gen/file_handler.py +192 -0
  5. karaoke_gen/instrumental_review/__init__.py +45 -0
  6. karaoke_gen/instrumental_review/analyzer.py +408 -0
  7. karaoke_gen/instrumental_review/editor.py +322 -0
  8. karaoke_gen/instrumental_review/models.py +171 -0
  9. karaoke_gen/instrumental_review/server.py +475 -0
  10. karaoke_gen/instrumental_review/static/index.html +1506 -0
  11. karaoke_gen/instrumental_review/waveform.py +409 -0
  12. karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
  13. karaoke_gen/karaoke_gen.py +114 -1
  14. karaoke_gen/lyrics_processor.py +81 -4
  15. karaoke_gen/utils/bulk_cli.py +3 -0
  16. karaoke_gen/utils/cli_args.py +9 -2
  17. karaoke_gen/utils/gen_cli.py +379 -2
  18. karaoke_gen/utils/remote_cli.py +1126 -77
  19. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +7 -1
  20. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +38 -26
  21. lyrics_transcriber/correction/anchor_sequence.py +226 -350
  22. lyrics_transcriber/frontend/package.json +1 -1
  23. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  24. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  25. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  26. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  27. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  28. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  29. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  30. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  31. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  32. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
  33. lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
  34. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  35. lyrics_transcriber/review/server.py +5 -5
  36. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  37. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
  38. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
  39. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1506 @@
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
+ const API_BASE = '/api/jobs/local';
602
+
603
+ // HTML escape helper to prevent XSS
604
+ function escapeHtml(str) {
605
+ if (!str) return '';
606
+ const div = document.createElement('div');
607
+ div.textContent = str;
608
+ return div.innerHTML;
609
+ }
610
+
611
+ // Named event handlers for audio (so they can be added once)
612
+ function onAudioPlay() { isPlaying = true; updatePlayButton(); startPlayheadAnimation(); }
613
+ function onAudioPause() { isPlaying = false; updatePlayButton(); stopPlayheadAnimation(); }
614
+ function onAudioEnded() { isPlaying = false; updatePlayButton(); stopPlayheadAnimation(); }
615
+
616
+ // Initialize
617
+ async function init() {
618
+ try {
619
+ const [analysisRes, waveformRes] = await Promise.all([
620
+ fetch(`${API_BASE}/instrumental-analysis`),
621
+ fetch(`${API_BASE}/waveform-data?num_points=1000`)
622
+ ]);
623
+
624
+ if (!analysisRes.ok) throw new Error('Failed to load analysis');
625
+ analysisData = await analysisRes.json();
626
+
627
+ if (waveformRes.ok) {
628
+ waveformData = await waveformRes.json();
629
+ duration = waveformData.duration;
630
+ }
631
+
632
+ // Set initial selection based on recommendation
633
+ selectedOption = analysisData.analysis.recommended_selection === 'clean' ? 'clean' : 'with_backing';
634
+
635
+ // Check if there's already an uploaded instrumental
636
+ if (analysisData.has_uploaded_instrumental) {
637
+ hasUploaded = true;
638
+ }
639
+
640
+ // Check if original audio is available
641
+ if (analysisData.has_original) {
642
+ hasOriginal = true;
643
+ }
644
+
645
+ render();
646
+ setupKeyboardShortcuts();
647
+ } catch (error) {
648
+ showError(error.message);
649
+ }
650
+ }
651
+
652
+ function render() {
653
+ // Pause any existing audio before rebuilding DOM to avoid AbortError
654
+ const existingAudio = document.getElementById('audio-player');
655
+ const wasPlaying = isPlaying;
656
+ if (existingAudio && !existingAudio.paused) {
657
+ existingAudio.pause();
658
+ }
659
+
660
+ const app = document.getElementById('app');
661
+ const segments = analysisData.analysis.audible_segments;
662
+ const hasSegments = segments.length > 0;
663
+
664
+ app.innerHTML = `
665
+ <div class="header">
666
+ <div class="header-left">
667
+ <span class="logo">🎤 Instrumental Review</span>
668
+ <span class="track-info">${escapeHtml(analysisData.artist) || ''} ${analysisData.artist && analysisData.title ? '–' : ''} ${escapeHtml(analysisData.title) || ''}</span>
669
+ </div>
670
+ <div class="header-right">
671
+ ${hasSegments ? `
672
+ <span class="badge">${segments.length} segments</span>
673
+ <span class="badge">${analysisData.analysis.audible_percentage.toFixed(0)}% backing vocals</span>
674
+ ` : ''}
675
+ <span class="badge ${analysisData.analysis.recommended_selection === 'clean' ? 'badge-success' : 'badge-warning'}">
676
+ ${analysisData.analysis.recommended_selection === 'clean' ? '✓ Clean recommended' : '⚠ Review needed'}
677
+ </span>
678
+ </div>
679
+ </div>
680
+
681
+ <div id="error-container"></div>
682
+
683
+ <div class="waveform-player">
684
+ <div class="waveform-toolbar">
685
+ <div class="toolbar-left">
686
+ <button class="btn btn-icon btn-primary" id="play-btn" onclick="togglePlayPause()">
687
+ ${isPlaying ? '⏸' : '▶'}
688
+ </button>
689
+ <span class="time-display">
690
+ <span id="current-time">${formatTime(currentTime)}</span>
691
+ <span style="color: var(--text-muted)"> / ${formatTime(duration)}</span>
692
+ </span>
693
+ </div>
694
+
695
+ <div class="toolbar-center">
696
+ <div class="audio-toggle-group">
697
+ ${hasOriginal ? `
698
+ <button class="audio-toggle ${activeAudio === 'original' ? 'active' : ''}" data-audio-type="original" onclick="setActiveAudio('original')">Original</button>
699
+ ` : ''}
700
+ <button class="audio-toggle ${activeAudio === 'backing' ? 'active' : ''}" data-audio-type="backing" onclick="setActiveAudio('backing')">Backing Vocals Only</button>
701
+ <button class="audio-toggle ${activeAudio === 'clean' ? 'active' : ''}" data-audio-type="clean" onclick="setActiveAudio('clean')">Pure Instrumental</button>
702
+ ${analysisData.audio_urls.with_backing ? `
703
+ <button class="audio-toggle ${activeAudio === 'with_backing' ? 'active' : ''}" data-audio-type="with_backing" onclick="setActiveAudio('with_backing')">Instrumental + Backing</button>
704
+ ` : ''}
705
+ ${hasCustom ? `
706
+ <button class="audio-toggle ${activeAudio === 'custom' ? 'active' : ''}" data-audio-type="custom" onclick="setActiveAudio('custom')">Custom</button>
707
+ ` : ''}
708
+ ${hasUploaded ? `
709
+ <button class="audio-toggle ${activeAudio === 'uploaded' ? 'active' : ''}" data-audio-type="uploaded" onclick="setActiveAudio('uploaded')" title="${escapeHtml(uploadedFilename)}">Uploaded</button>
710
+ ` : ''}
711
+ </div>
712
+ <label class="btn btn-sm btn-secondary upload-btn" title="Upload custom instrumental">
713
+ 📁 Upload
714
+ <input type="file" accept=".flac,.mp3,.wav,.m4a,.ogg" onchange="handleUpload(event)">
715
+ </label>
716
+ </div>
717
+
718
+ <div class="toolbar-right">
719
+ <div class="zoom-controls">
720
+ <button class="zoom-btn ${zoomLevel === 1 ? 'active' : ''}" onclick="setZoom(1)" title="1x zoom">1x</button>
721
+ <button class="zoom-btn ${zoomLevel === 2 ? 'active' : ''}" onclick="setZoom(2)" title="2x zoom">2x</button>
722
+ <button class="zoom-btn ${zoomLevel === 4 ? 'active' : ''}" onclick="setZoom(4)" title="4x zoom">4x</button>
723
+ </div>
724
+ <span style="font-size: 0.7rem; color: var(--text-muted);">
725
+ <span class="kbd">Shift</span>+drag
726
+ </span>
727
+ <span class="kbd">Space</span>
728
+ </div>
729
+ </div>
730
+
731
+ <div class="waveform-container" id="waveform-container">
732
+ <div class="waveform-area" id="waveform-area" style="width: ${zoomLevel * 100}%;">
733
+ <canvas id="waveform-canvas"></canvas>
734
+ <div class="playhead hidden" id="playhead"></div>
735
+ <div class="selection-overlay hidden" id="selection-overlay"></div>
736
+ </div>
737
+ </div>
738
+
739
+ <div class="time-axis">
740
+ <span>0:00</span>
741
+ <span>${formatTime(duration * 0.25)}</span>
742
+ <span>${formatTime(duration * 0.5)}</span>
743
+ <span>${formatTime(duration * 0.75)}</span>
744
+ <span>${formatTime(duration)}</span>
745
+ </div>
746
+ </div>
747
+
748
+ <audio id="audio-player" src="${getAudioUrl()}"></audio>
749
+
750
+ <div class="bottom-section">
751
+ <div class="mute-panel">
752
+ <div class="mute-panel-header">
753
+ <span class="mute-panel-title">Mute Regions ${muteRegions.length > 0 ? `(${muteRegions.length})` : ''}</span>
754
+ ${muteRegions.length > 0 ? `
755
+ <div style="display: flex; gap: 6px;">
756
+ <button class="btn btn-sm btn-secondary" onclick="clearAllRegions()">Clear</button>
757
+ ${!hasCustom ? `<button class="btn btn-sm btn-primary" id="create-custom-btn" onclick="createCustomInstrumental()">Create Custom</button>` : ''}
758
+ </div>
759
+ ` : ''}
760
+ </div>
761
+
762
+ ${muteRegions.length > 0 ? `
763
+ <div class="mute-regions-list">
764
+ ${muteRegions.map((r, i) => `
765
+ <div class="mute-region-tag">
766
+ <span onclick="seekTo(${r.start_seconds}, true)" style="cursor: pointer">${formatTime(r.start_seconds)} – ${formatTime(r.end_seconds)}</span>
767
+ <button onclick="removeRegion(${i})">×</button>
768
+ </div>
769
+ `).join('')}
770
+ </div>
771
+ ` : `
772
+ <div style="color: var(--text-muted); font-size: 0.75rem;">
773
+ ${hasSegments ? 'Click segments below or <kbd class="kbd">Shift</kbd> + drag on waveform' : 'No backing vocals detected – clean instrumental recommended'}
774
+ </div>
775
+ `}
776
+
777
+ ${hasSegments ? `
778
+ <div class="quick-segments">
779
+ ${segments.slice(0, 8).map((seg, i) => `
780
+ <button class="quick-segment-btn" onclick="addSegmentAsRegion(${i})" title="Add to mute regions">
781
+ ${formatTime(seg.start_seconds)} – ${formatTime(seg.end_seconds)}
782
+ </button>
783
+ `).join('')}
784
+ ${segments.length > 8 ? `<span style="font-size: 0.7rem; color: var(--text-muted); padding: 3px;">+${segments.length - 8} more</span>` : ''}
785
+ </div>
786
+ ` : ''}
787
+ </div>
788
+
789
+ <div class="selection-panel">
790
+ <span class="selection-panel-title">Final Selection</span>
791
+ <div class="selection-options">
792
+ <label class="selection-option ${selectedOption === 'clean' ? 'selected' : ''}" onclick="setSelection('clean')">
793
+ <input type="radio" name="selection" value="clean">
794
+ <div class="selection-radio"></div>
795
+ <div class="selection-label">
796
+ <div class="selection-label-title">Clean Instrumental</div>
797
+ <div class="selection-label-desc">No backing vocals</div>
798
+ </div>
799
+ </label>
800
+ <label class="selection-option ${selectedOption === 'with_backing' ? 'selected' : ''}" onclick="setSelection('with_backing')">
801
+ <input type="radio" name="selection" value="with_backing">
802
+ <div class="selection-radio"></div>
803
+ <div class="selection-label">
804
+ <div class="selection-label-title">With Backing Vocals</div>
805
+ <div class="selection-label-desc">All backing vocals included</div>
806
+ </div>
807
+ </label>
808
+ ${hasOriginal ? `
809
+ <label class="selection-option ${selectedOption === 'original' ? 'selected' : ''}" onclick="setSelection('original')">
810
+ <input type="radio" name="selection" value="original">
811
+ <div class="selection-radio"></div>
812
+ <div class="selection-label">
813
+ <div class="selection-label-title">Original Audio</div>
814
+ <div class="selection-label-desc">Full original with lead vocals</div>
815
+ </div>
816
+ </label>
817
+ ` : ''}
818
+ ${hasCustom ? `
819
+ <label class="selection-option ${selectedOption === 'custom' ? 'selected' : ''}" onclick="setSelection('custom')">
820
+ <input type="radio" name="selection" value="custom">
821
+ <div class="selection-radio"></div>
822
+ <div class="selection-label">
823
+ <div class="selection-label-title">Custom</div>
824
+ <div class="selection-label-desc">${muteRegions.length} regions muted</div>
825
+ </div>
826
+ </label>
827
+ ` : ''}
828
+ ${hasUploaded ? `
829
+ <label class="selection-option ${selectedOption === 'uploaded' ? 'selected' : ''}" onclick="setSelection('uploaded')">
830
+ <input type="radio" name="selection" value="uploaded">
831
+ <div class="selection-radio"></div>
832
+ <div class="selection-label">
833
+ <div class="selection-label-title">Uploaded</div>
834
+ <div class="selection-label-desc">${escapeHtml(uploadedFilename)}</div>
835
+ </div>
836
+ </label>
837
+ ` : ''}
838
+ </div>
839
+ <button class="btn btn-primary submit-btn" id="submit-btn" onclick="submitSelection()">
840
+ ✓ Confirm & Continue
841
+ </button>
842
+ </div>
843
+ </div>
844
+ `;
845
+
846
+ // Setup after render
847
+ if (waveformData) {
848
+ resizeCanvas();
849
+ drawWaveform();
850
+ setupWaveformInteraction();
851
+ }
852
+
853
+ // Setup audio state - add listeners when element changes
854
+ const audio = document.getElementById('audio-player');
855
+ if (audio) {
856
+ // Check if this is a new audio element (DOM was rebuilt)
857
+ if (audio !== currentAudioElement) {
858
+ audio.addEventListener('timeupdate', onTimeUpdate);
859
+ audio.addEventListener('play', onAudioPlay);
860
+ audio.addEventListener('pause', onAudioPause);
861
+ audio.addEventListener('ended', onAudioEnded);
862
+ currentAudioElement = audio;
863
+ }
864
+
865
+ // Restore playback position and state after audio is ready
866
+ audio.addEventListener('loadeddata', function onLoaded() {
867
+ audio.currentTime = currentTime;
868
+ if (wasPlaying) {
869
+ audio.play().catch(() => {});
870
+ }
871
+ }, { once: true });
872
+
873
+ // If already loaded (cached), set time directly
874
+ if (audio.readyState >= 2) {
875
+ audio.currentTime = currentTime;
876
+ if (wasPlaying) {
877
+ audio.play().catch(() => {});
878
+ }
879
+ }
880
+ }
881
+ }
882
+
883
+ function resizeCanvas() {
884
+ const canvas = document.getElementById('waveform-canvas');
885
+ const container = document.getElementById('waveform-container');
886
+ const area = document.getElementById('waveform-area');
887
+ if (!canvas || !container || !area) return;
888
+
889
+ // Set canvas width based on container width * zoom level
890
+ canvas.width = container.clientWidth * zoomLevel;
891
+ canvas.height = container.clientHeight;
892
+
893
+ // Update area width to match
894
+ area.style.width = `${zoomLevel * 100}%`;
895
+ }
896
+
897
+ function drawWaveform() {
898
+ const canvas = document.getElementById('waveform-canvas');
899
+ if (!canvas || !waveformData) return;
900
+
901
+ const ctx = canvas.getContext('2d');
902
+ const { amplitudes } = waveformData;
903
+ const width = canvas.width;
904
+ const height = canvas.height;
905
+ const centerY = height / 2;
906
+ const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--waveform-bg').trim();
907
+
908
+ // Clear
909
+ ctx.fillStyle = bgColor;
910
+ ctx.fillRect(0, 0, width, height);
911
+
912
+ // Draw center line
913
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
914
+ ctx.setLineDash([4, 4]);
915
+ ctx.beginPath();
916
+ ctx.moveTo(0, centerY);
917
+ ctx.lineTo(width, centerY);
918
+ ctx.stroke();
919
+ ctx.setLineDash([]);
920
+
921
+ // Draw waveform bars
922
+ const barWidth = width / amplitudes.length;
923
+
924
+ amplitudes.forEach((amp, i) => {
925
+ const x = i * barWidth;
926
+ const barHeight = Math.max(2, amp * height * 0.9);
927
+ const y = centerY - barHeight / 2;
928
+ const time = (i / amplitudes.length) * duration;
929
+
930
+ // Check regions
931
+ const inMuteRegion = muteRegions.some(r => time >= r.start_seconds && time <= r.end_seconds);
932
+ const inAudibleSegment = analysisData.analysis.audible_segments.some(
933
+ s => time >= s.start_seconds && time <= s.end_seconds
934
+ );
935
+
936
+ if (inMuteRegion) {
937
+ // Muted regions blend into background - very subtle
938
+ ctx.fillStyle = 'rgba(13, 17, 23, 0.8)';
939
+ } else if (inAudibleSegment) {
940
+ ctx.fillStyle = '#ec4899'; // pink for detected backing vocals
941
+ } else {
942
+ ctx.fillStyle = '#60a5fa'; // blue for rest
943
+ }
944
+
945
+ ctx.fillRect(x, y, Math.max(1, barWidth - 0.5), barHeight);
946
+ });
947
+ }
948
+
949
+ function setupWaveformInteraction() {
950
+ const canvas = document.getElementById('waveform-canvas');
951
+ const area = document.getElementById('waveform-area');
952
+ if (!canvas || !area) return;
953
+
954
+ canvas.onmousedown = (e) => {
955
+ const rect = canvas.getBoundingClientRect();
956
+ const x = e.clientX - rect.left;
957
+ const time = (x / rect.width) * duration;
958
+
959
+ // Shift+drag to select mute region
960
+ if (e.shiftKey) {
961
+ isDragging = true;
962
+ dragStartTime = time;
963
+ selectionStartX = x;
964
+ updateSelectionOverlay(x, x);
965
+ showSelectionOverlay(true);
966
+ } else {
967
+ // Regular click to seek and play
968
+ seekTo(time);
969
+ }
970
+ };
971
+
972
+ canvas.onmousemove = (e) => {
973
+ if (!isDragging) return;
974
+ const rect = canvas.getBoundingClientRect();
975
+ const x = e.clientX - rect.left;
976
+ updateSelectionOverlay(selectionStartX, x);
977
+ };
978
+
979
+ const endDrag = (e) => {
980
+ if (!isDragging) return;
981
+
982
+ const rect = canvas.getBoundingClientRect();
983
+ const x = e.clientX - rect.left;
984
+ const time = (x / rect.width) * duration;
985
+
986
+ const start = Math.min(dragStartTime, time);
987
+ const end = Math.max(dragStartTime, time);
988
+
989
+ if (end - start > 0.5) {
990
+ addRegion(start, end);
991
+ }
992
+
993
+ isDragging = false;
994
+ showSelectionOverlay(false);
995
+ };
996
+
997
+ canvas.onmouseup = endDrag;
998
+ canvas.onmouseleave = (e) => {
999
+ if (isDragging) endDrag(e);
1000
+ };
1001
+ }
1002
+
1003
+ function updateSelectionOverlay(startX, endX) {
1004
+ const overlay = document.getElementById('selection-overlay');
1005
+ if (!overlay) return;
1006
+
1007
+ const left = Math.min(startX, endX);
1008
+ const width = Math.abs(endX - startX);
1009
+
1010
+ overlay.style.left = `${left}px`;
1011
+ overlay.style.width = `${width}px`;
1012
+ }
1013
+
1014
+ function showSelectionOverlay(show) {
1015
+ const overlay = document.getElementById('selection-overlay');
1016
+ if (overlay) {
1017
+ overlay.classList.toggle('hidden', !show);
1018
+ }
1019
+ }
1020
+
1021
+ function startPlayheadAnimation() {
1022
+ const animate = () => {
1023
+ updatePlayhead();
1024
+ animationFrameId = requestAnimationFrame(animate);
1025
+ };
1026
+ animate();
1027
+ }
1028
+
1029
+ function stopPlayheadAnimation() {
1030
+ if (animationFrameId) {
1031
+ cancelAnimationFrame(animationFrameId);
1032
+ animationFrameId = null;
1033
+ }
1034
+ }
1035
+
1036
+ function updatePlayhead() {
1037
+ const playhead = document.getElementById('playhead');
1038
+ const canvas = document.getElementById('waveform-canvas');
1039
+ const audio = document.getElementById('audio-player');
1040
+
1041
+ if (!playhead || !canvas || !audio) return;
1042
+
1043
+ currentTime = audio.currentTime;
1044
+
1045
+ // Update time display regardless of playhead position validity
1046
+ const timeEl = document.getElementById('current-time');
1047
+ if (timeEl) timeEl.textContent = formatTime(currentTime);
1048
+
1049
+ // Guard against NaN/Infinity when calculating playhead position
1050
+ if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(canvas.width) || canvas.width <= 0) {
1051
+ playhead.style.left = '0px';
1052
+ } else {
1053
+ const x = Math.max(0, Math.min((currentTime / duration) * canvas.width, canvas.width));
1054
+ playhead.style.left = `${x}px`;
1055
+ }
1056
+
1057
+ playhead.classList.remove('hidden');
1058
+ }
1059
+
1060
+ function updatePlayButton() {
1061
+ const btn = document.getElementById('play-btn');
1062
+ if (btn) btn.innerHTML = isPlaying ? '⏸' : '▶';
1063
+ }
1064
+
1065
+ function togglePlayPause() {
1066
+ const audio = document.getElementById('audio-player');
1067
+ if (!audio) return;
1068
+
1069
+ if (isPlaying) {
1070
+ audio.pause();
1071
+ } else {
1072
+ audio.play();
1073
+ }
1074
+ }
1075
+
1076
+ function seekTo(time, autoPlay = true) {
1077
+ const audio = document.getElementById('audio-player');
1078
+ if (audio) {
1079
+ audio.currentTime = time;
1080
+ currentTime = time;
1081
+ updatePlayhead();
1082
+ // Auto-play when seeking via click (if not already playing)
1083
+ if (autoPlay && !isPlaying) {
1084
+ audio.play();
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ function onTimeUpdate(e) {
1090
+ currentTime = e.target.currentTime;
1091
+ }
1092
+
1093
+
1094
+ function setActiveAudio(type) {
1095
+ const audio = document.getElementById('audio-player');
1096
+ if (!audio) return;
1097
+
1098
+ const wasPlaying = !audio.paused;
1099
+ const time = audio.currentTime;
1100
+
1101
+ // Pause before changing source
1102
+ audio.pause();
1103
+
1104
+ activeAudio = type;
1105
+
1106
+ // Update toggle button states using data attributes (robust detection)
1107
+ document.querySelectorAll('.audio-toggle').forEach(btn => {
1108
+ const btnType = btn.dataset.audioType || 'custom';
1109
+ btn.classList.toggle('active', btnType === type);
1110
+ });
1111
+
1112
+ // Change source and restore playback
1113
+ audio.src = getAudioUrl();
1114
+ audio.addEventListener('loadeddata', function onLoaded() {
1115
+ audio.currentTime = time;
1116
+ if (wasPlaying) {
1117
+ audio.play().catch(() => {});
1118
+ }
1119
+ }, { once: true });
1120
+ }
1121
+
1122
+ function getAudioUrl() {
1123
+ const urls = {
1124
+ original: '/api/audio/original',
1125
+ backing: '/api/audio/backing_vocals',
1126
+ clean: '/api/audio/clean_instrumental',
1127
+ with_backing: '/api/audio/with_backing',
1128
+ custom: '/api/audio/custom_instrumental',
1129
+ uploaded: '/api/audio/uploaded_instrumental'
1130
+ };
1131
+ return urls[activeAudio] || urls.backing;
1132
+ }
1133
+
1134
+ function formatTime(seconds) {
1135
+ const mins = Math.floor(seconds / 60);
1136
+ const secs = Math.floor(seconds % 60);
1137
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
1138
+ }
1139
+
1140
+ function addRegion(start, end) {
1141
+ muteRegions.push({ start_seconds: start, end_seconds: end });
1142
+ muteRegions.sort((a, b) => a.start_seconds - b.start_seconds);
1143
+ mergeOverlappingRegions();
1144
+ hasCustom = false; // Invalidate custom when regions change
1145
+
1146
+ // Just redraw waveform instead of full render to preserve scroll position
1147
+ drawWaveform();
1148
+ updateMuteRegionsPanel();
1149
+ }
1150
+
1151
+ function updateMuteRegionsPanel() {
1152
+ // Update only the mute regions panel without full DOM rebuild
1153
+ const panel = document.querySelector('.mute-panel');
1154
+ if (!panel) return;
1155
+
1156
+ const segments = analysisData.analysis.audible_segments;
1157
+ const hasSegments = segments.length > 0;
1158
+
1159
+ // Build mute regions list HTML
1160
+ let regionsListHtml = '';
1161
+ if (muteRegions.length > 0) {
1162
+ regionsListHtml = '<div class="mute-regions-list">' +
1163
+ muteRegions.map((r, i) =>
1164
+ '<div class="mute-region-tag">' +
1165
+ '<span onclick="seekTo(' + r.start_seconds + ', true)" style="cursor: pointer">' +
1166
+ formatTime(r.start_seconds) + ' – ' + formatTime(r.end_seconds) + '</span>' +
1167
+ '<button onclick="removeRegion(' + i + ')">×</button>' +
1168
+ '</div>'
1169
+ ).join('') +
1170
+ '</div>';
1171
+ } else {
1172
+ regionsListHtml = '<div style="color: var(--text-muted); font-size: 0.75rem;">' +
1173
+ (hasSegments ? 'Click segments below or <kbd class="kbd">Shift</kbd> + drag on waveform' : 'No backing vocals detected – clean instrumental recommended') +
1174
+ '</div>';
1175
+ }
1176
+
1177
+ // Build quick segments HTML
1178
+ let quickSegmentsHtml = '';
1179
+ if (hasSegments) {
1180
+ quickSegmentsHtml = '<div class="quick-segments">' +
1181
+ segments.slice(0, 8).map((seg, i) =>
1182
+ '<button class="quick-segment-btn" onclick="addSegmentAsRegion(' + i + ')" title="Add to mute regions">' +
1183
+ formatTime(seg.start_seconds) + ' – ' + formatTime(seg.end_seconds) +
1184
+ '</button>'
1185
+ ).join('') +
1186
+ (segments.length > 8 ? '<span style="font-size: 0.7rem; color: var(--text-muted); padding: 3px;">+' + (segments.length - 8) + ' more</span>' : '') +
1187
+ '</div>';
1188
+ }
1189
+
1190
+ // Build header buttons
1191
+ let headerButtons = '';
1192
+ if (muteRegions.length > 0) {
1193
+ headerButtons = '<div style="display: flex; gap: 6px;">' +
1194
+ '<button class="btn btn-sm btn-secondary" onclick="clearAllRegions()">Clear</button>' +
1195
+ (!hasCustom ? '<button class="btn btn-sm btn-primary" id="create-custom-btn" onclick="createCustomInstrumental()">Create Custom</button>' : '') +
1196
+ '</div>';
1197
+ }
1198
+
1199
+ panel.innerHTML =
1200
+ '<div class="mute-panel-header">' +
1201
+ '<span class="mute-panel-title">Mute Regions ' + (muteRegions.length > 0 ? '(' + muteRegions.length + ')' : '') + '</span>' +
1202
+ headerButtons +
1203
+ '</div>' +
1204
+ regionsListHtml +
1205
+ quickSegmentsHtml;
1206
+ }
1207
+
1208
+ function addSegmentAsRegion(index) {
1209
+ const seg = analysisData.analysis.audible_segments[index];
1210
+ if (seg) {
1211
+ addRegion(seg.start_seconds, seg.end_seconds);
1212
+ }
1213
+ }
1214
+
1215
+ function removeRegion(index) {
1216
+ muteRegions.splice(index, 1);
1217
+ hasCustom = false;
1218
+ drawWaveform();
1219
+ updateMuteRegionsPanel();
1220
+ }
1221
+
1222
+ function clearAllRegions() {
1223
+ muteRegions = [];
1224
+ hasCustom = false;
1225
+ drawWaveform();
1226
+ updateMuteRegionsPanel();
1227
+ }
1228
+
1229
+ function mergeOverlappingRegions() {
1230
+ if (muteRegions.length < 2) return;
1231
+
1232
+ const merged = [muteRegions[0]];
1233
+ for (let i = 1; i < muteRegions.length; i++) {
1234
+ const last = merged[merged.length - 1];
1235
+ const curr = muteRegions[i];
1236
+
1237
+ if (curr.start_seconds <= last.end_seconds + 0.5) {
1238
+ last.end_seconds = Math.max(last.end_seconds, curr.end_seconds);
1239
+ } else {
1240
+ merged.push(curr);
1241
+ }
1242
+ }
1243
+ muteRegions = merged;
1244
+ }
1245
+
1246
+ function setSelection(value) {
1247
+ selectedOption = value;
1248
+ render();
1249
+ }
1250
+
1251
+ function setZoom(level) {
1252
+ const container = document.getElementById('waveform-container');
1253
+ const oldScrollRatio = container ? container.scrollLeft / (container.scrollWidth - container.clientWidth || 1) : 0;
1254
+
1255
+ zoomLevel = level;
1256
+
1257
+ // Update zoom button states directly (avoid full render)
1258
+ document.querySelectorAll('.zoom-btn').forEach(btn => {
1259
+ const btnLevel = parseInt(btn.textContent);
1260
+ btn.classList.toggle('active', btnLevel === level);
1261
+ });
1262
+
1263
+ // Resize canvas and redraw
1264
+ resizeCanvas();
1265
+ drawWaveform();
1266
+
1267
+ // Maintain scroll position proportionally
1268
+ if (container && zoomLevel > 1) {
1269
+ const newMaxScroll = container.scrollWidth - container.clientWidth;
1270
+ container.scrollLeft = oldScrollRatio * newMaxScroll;
1271
+ }
1272
+ }
1273
+
1274
+ async function handleUpload(event) {
1275
+ const file = event.target.files[0];
1276
+ if (!file) return;
1277
+
1278
+ // Show upload progress
1279
+ const overlay = document.createElement('div');
1280
+ overlay.className = 'upload-overlay';
1281
+ overlay.id = 'upload-overlay';
1282
+ document.body.appendChild(overlay);
1283
+
1284
+ const progress = document.createElement('div');
1285
+ progress.className = 'upload-progress';
1286
+ progress.id = 'upload-progress';
1287
+ progress.innerHTML = `
1288
+ <div class="spinner" style="margin: 0 auto 12px;"></div>
1289
+ <div>Uploading ${file.name}...</div>
1290
+ <div style="font-size: 0.8rem; color: var(--text-muted); margin-top: 8px;">Validating duration...</div>
1291
+ `;
1292
+ document.body.appendChild(progress);
1293
+
1294
+ try {
1295
+ const formData = new FormData();
1296
+ formData.append('file', file);
1297
+
1298
+ const response = await fetch(`${API_BASE}/upload-instrumental`, {
1299
+ method: 'POST',
1300
+ body: formData
1301
+ });
1302
+
1303
+ if (!response.ok) {
1304
+ const data = await response.json();
1305
+ throw new Error(data.detail || 'Upload failed');
1306
+ }
1307
+
1308
+ const result = await response.json();
1309
+ hasUploaded = true;
1310
+ uploadedFilename = file.name;
1311
+ activeAudio = 'uploaded';
1312
+ selectedOption = 'uploaded';
1313
+
1314
+ render();
1315
+ showSuccess(`Uploaded ${file.name} (${result.duration_seconds.toFixed(1)}s)`);
1316
+ } catch (error) {
1317
+ showError(error.message);
1318
+ } finally {
1319
+ // Clean up progress UI
1320
+ document.getElementById('upload-overlay')?.remove();
1321
+ document.getElementById('upload-progress')?.remove();
1322
+ // Reset file input so same file can be uploaded again
1323
+ event.target.value = '';
1324
+ }
1325
+ }
1326
+
1327
+ function showSuccess(message) {
1328
+ const existing = document.querySelector('.alert-success-toast');
1329
+ if (existing) existing.remove();
1330
+
1331
+ const el = document.createElement('div');
1332
+ el.className = 'alert-error'; // Reuse error styling but green
1333
+ el.style.background = 'rgba(34, 197, 94, 0.95)';
1334
+ el.textContent = message;
1335
+ document.body.appendChild(el);
1336
+
1337
+ setTimeout(() => el.remove(), 3000);
1338
+ }
1339
+
1340
+ async function createCustomInstrumental() {
1341
+ const btn = document.getElementById('create-custom-btn');
1342
+ const audio = document.getElementById('audio-player');
1343
+ const wasPlaying = isPlaying;
1344
+ const time = currentTime;
1345
+
1346
+ // Pause audio while creating custom instrumental
1347
+ if (audio && !audio.paused) {
1348
+ audio.pause();
1349
+ }
1350
+
1351
+ if (btn) {
1352
+ btn.disabled = true;
1353
+ btn.textContent = 'Creating...';
1354
+ }
1355
+
1356
+ try {
1357
+ const response = await fetch(`${API_BASE}/create-custom-instrumental`, {
1358
+ method: 'POST',
1359
+ headers: { 'Content-Type': 'application/json' },
1360
+ body: JSON.stringify({ mute_regions: muteRegions })
1361
+ });
1362
+
1363
+ if (!response.ok) {
1364
+ const data = await response.json();
1365
+ throw new Error(data.detail || 'Failed to create custom');
1366
+ }
1367
+
1368
+ hasCustom = true;
1369
+ selectedOption = 'custom';
1370
+ activeAudio = 'custom';
1371
+
1372
+ // Render first, then restore playback
1373
+ render();
1374
+
1375
+ // After render, seek to previous position and optionally resume
1376
+ const newAudio = document.getElementById('audio-player');
1377
+ if (newAudio) {
1378
+ newAudio.addEventListener('loadeddata', function onLoaded() {
1379
+ newAudio.removeEventListener('loadeddata', onLoaded);
1380
+ newAudio.currentTime = time;
1381
+ if (wasPlaying) {
1382
+ newAudio.play().catch(() => {});
1383
+ }
1384
+ }, { once: true });
1385
+ }
1386
+ } catch (error) {
1387
+ showError(error.message);
1388
+ if (btn) {
1389
+ btn.disabled = false;
1390
+ btn.textContent = 'Create Custom';
1391
+ }
1392
+ // Resume playback if there was an error
1393
+ if (wasPlaying && audio) {
1394
+ audio.play().catch(() => {});
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ async function submitSelection() {
1400
+ const btn = document.getElementById('submit-btn');
1401
+ if (btn) {
1402
+ btn.disabled = true;
1403
+ btn.textContent = 'Submitting...';
1404
+ }
1405
+
1406
+ try {
1407
+ const response = await fetch(`${API_BASE}/select-instrumental`, {
1408
+ method: 'POST',
1409
+ headers: { 'Content-Type': 'application/json' },
1410
+ body: JSON.stringify({ selection: selectedOption })
1411
+ });
1412
+
1413
+ if (!response.ok) {
1414
+ const data = await response.json();
1415
+ throw new Error(data.detail || 'Failed to submit');
1416
+ }
1417
+
1418
+ const selectionLabels = {
1419
+ clean: 'Clean Instrumental',
1420
+ with_backing: 'With Backing Vocals',
1421
+ custom: 'Custom',
1422
+ uploaded: 'Uploaded Instrumental',
1423
+ original: 'Original Audio'
1424
+ };
1425
+ const selectionLabel = selectionLabels[selectedOption] || selectedOption;
1426
+
1427
+ document.getElementById('app').innerHTML = `
1428
+ <div class="success-screen">
1429
+ <h2>✓ Selection Submitted</h2>
1430
+ <p>You selected: <strong>${escapeHtml(selectionLabel)}</strong></p>
1431
+ <p id="close-msg" style="color: var(--text-muted);">Closing in <span id="countdown">2</span>s...</p>
1432
+ </div>
1433
+ `;
1434
+
1435
+ // Auto-close window after 2 seconds
1436
+ let countdown = 2;
1437
+ const countdownEl = document.getElementById('countdown');
1438
+ const countdownInterval = setInterval(() => {
1439
+ countdown--;
1440
+ if (countdownEl) countdownEl.textContent = countdown;
1441
+ if (countdown <= 0) {
1442
+ clearInterval(countdownInterval);
1443
+ // Try to close the window (works for windows opened by script)
1444
+ window.close();
1445
+ // If window.close() didn't work, update message
1446
+ const msg = document.getElementById('close-msg');
1447
+ if (msg) msg.textContent = 'You can close this window now.';
1448
+ }
1449
+ }, 1000);
1450
+ } catch (error) {
1451
+ showError(error.message);
1452
+ if (btn) {
1453
+ btn.disabled = false;
1454
+ btn.textContent = '✓ Confirm & Continue';
1455
+ }
1456
+ }
1457
+ }
1458
+
1459
+ function setupKeyboardShortcuts() {
1460
+ document.addEventListener('keydown', (e) => {
1461
+ // Ignore if typing in input
1462
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1463
+
1464
+ switch (e.code) {
1465
+ case 'Space':
1466
+ e.preventDefault();
1467
+ togglePlayPause();
1468
+ break;
1469
+ case 'Escape':
1470
+ // Cancel any in-progress drag selection
1471
+ if (isDragging) {
1472
+ isDragging = false;
1473
+ showSelectionOverlay(false);
1474
+ }
1475
+ break;
1476
+ }
1477
+ });
1478
+ }
1479
+
1480
+ function showError(message) {
1481
+ // Remove any existing error
1482
+ const existing = document.querySelector('.alert-error');
1483
+ if (existing) existing.remove();
1484
+
1485
+ const errorEl = document.createElement('div');
1486
+ errorEl.className = 'alert-error';
1487
+ errorEl.textContent = message;
1488
+ document.body.appendChild(errorEl);
1489
+
1490
+ setTimeout(() => errorEl.remove(), 5000);
1491
+ }
1492
+
1493
+ // Handle window resize
1494
+ window.addEventListener('resize', () => {
1495
+ if (waveformData) {
1496
+ resizeCanvas();
1497
+ drawWaveform();
1498
+ }
1499
+ });
1500
+
1501
+ // Start
1502
+ init();
1503
+ </script>
1504
+ </body>
1505
+ </html>
1506
+