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
@@ -10,16 +10,22 @@ Similar pattern to LyricsTranscriber's ReviewServer.
10
10
 
11
11
  import logging
12
12
  import os
13
+ from pathlib import Path
14
+ import socket
13
15
  import threading
14
16
  import webbrowser
15
17
  from typing import List, Optional
16
18
 
17
- from fastapi import FastAPI, HTTPException
19
+ from fastapi import FastAPI, HTTPException, UploadFile, File
18
20
  from fastapi.middleware.cors import CORSMiddleware
19
21
  from fastapi.responses import FileResponse, HTMLResponse
20
22
  from pydantic import BaseModel
23
+ import shutil
24
+ import tempfile
21
25
  import uvicorn
22
26
 
27
+ from pydub import AudioSegment
28
+
23
29
  from karaoke_gen.instrumental_review import (
24
30
  AnalysisResult,
25
31
  AudioAnalyzer,
@@ -63,6 +69,7 @@ class InstrumentalReviewServer:
63
69
  backing_vocals_path: str,
64
70
  clean_instrumental_path: str,
65
71
  with_backing_path: Optional[str] = None,
72
+ original_audio_path: Optional[str] = None,
66
73
  ):
67
74
  """
68
75
  Initialize the review server.
@@ -75,6 +82,7 @@ class InstrumentalReviewServer:
75
82
  backing_vocals_path: Path to the backing vocals audio file
76
83
  clean_instrumental_path: Path to the clean instrumental audio file
77
84
  with_backing_path: Path to the instrumental with backing vocals
85
+ original_audio_path: Path to the original audio file (with vocals)
78
86
  """
79
87
  self.output_dir = output_dir
80
88
  self.base_name = base_name
@@ -83,7 +91,9 @@ class InstrumentalReviewServer:
83
91
  self.backing_vocals_path = backing_vocals_path
84
92
  self.clean_instrumental_path = clean_instrumental_path
85
93
  self.with_backing_path = with_backing_path
94
+ self.original_audio_path = original_audio_path
86
95
  self.custom_instrumental_path: Optional[str] = None
96
+ self.uploaded_instrumental_path: Optional[str] = None
87
97
  self.selection: Optional[str] = None
88
98
 
89
99
  self._app: Optional[FastAPI] = None
@@ -148,9 +158,13 @@ class InstrumentalReviewServer:
148
158
  "backing_vocals": "/api/audio/backing_vocals" if self.backing_vocals_path else None,
149
159
  "with_backing": "/api/audio/with_backing" if self.with_backing_path else None,
150
160
  "custom_instrumental": "/api/audio/custom_instrumental" if self.custom_instrumental_path else None,
161
+ "uploaded_instrumental": "/api/audio/uploaded_instrumental" if self.uploaded_instrumental_path else None,
162
+ "original": "/api/audio/original" if self.original_audio_path else None,
151
163
  },
152
164
  "waveform_url": "/api/waveform" if self.waveform_path else None,
153
165
  "has_custom_instrumental": self.custom_instrumental_path is not None,
166
+ "has_uploaded_instrumental": self.uploaded_instrumental_path is not None,
167
+ "has_original": self.original_audio_path is not None,
154
168
  }
155
169
 
156
170
  @app.get("/api/jobs/local/waveform-data")
@@ -182,6 +196,8 @@ class InstrumentalReviewServer:
182
196
  "backing_vocals": self.backing_vocals_path,
183
197
  "with_backing": self.with_backing_path,
184
198
  "custom_instrumental": self.custom_instrumental_path,
199
+ "uploaded_instrumental": self.uploaded_instrumental_path,
200
+ "original": self.original_audio_path,
185
201
  }
186
202
 
187
203
  audio_path = path_map.get(stem_type)
@@ -249,10 +265,72 @@ class InstrumentalReviewServer:
249
265
  logger.exception(f"Error creating custom instrumental: {e}")
250
266
  raise HTTPException(status_code=500, detail=str(e)) from e
251
267
 
268
+ @app.post("/api/jobs/local/upload-instrumental")
269
+ async def upload_instrumental(file: UploadFile = File(...)):
270
+ """Upload a custom instrumental audio file."""
271
+ # Validate file type
272
+ allowed_extensions = {".flac", ".mp3", ".wav", ".m4a", ".ogg"}
273
+ ext = os.path.splitext(file.filename or "")[1].lower()
274
+ if ext not in allowed_extensions:
275
+ raise HTTPException(
276
+ status_code=400,
277
+ detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
278
+ )
279
+
280
+ tmp_path = None
281
+ file_moved = False
282
+ try:
283
+ # Save to temp file first to validate
284
+ with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
285
+ shutil.copyfileobj(file.file, tmp)
286
+ tmp_path = tmp.name
287
+
288
+ # Load and check duration
289
+ uploaded_audio = AudioSegment.from_file(tmp_path)
290
+ uploaded_duration = len(uploaded_audio) / 1000.0 # ms to seconds
291
+
292
+ expected_duration = self.analysis.total_duration_seconds
293
+ duration_diff = abs(uploaded_duration - expected_duration)
294
+
295
+ if duration_diff > 0.5:
296
+ raise HTTPException(
297
+ status_code=400,
298
+ detail=f"Duration mismatch: uploaded file is {uploaded_duration:.2f}s, "
299
+ f"expected {expected_duration:.2f}s (±0.5s allowed)"
300
+ )
301
+
302
+ # Move to final location
303
+ output_path = os.path.join(
304
+ self.output_dir,
305
+ f"{self.base_name} (Instrumental Uploaded){ext}"
306
+ )
307
+ shutil.move(tmp_path, output_path)
308
+ file_moved = True
309
+ self.uploaded_instrumental_path = output_path
310
+
311
+ return {
312
+ "status": "success",
313
+ "uploaded_instrumental_url": "/api/audio/uploaded_instrumental",
314
+ "duration_seconds": uploaded_duration,
315
+ "filename": file.filename,
316
+ }
317
+ except HTTPException:
318
+ raise
319
+ except Exception as e:
320
+ logger.exception(f"Error uploading instrumental: {e}")
321
+ raise HTTPException(status_code=500, detail=str(e)) from e
322
+ finally:
323
+ # Clean up temp file if it wasn't moved
324
+ if tmp_path and not file_moved and os.path.exists(tmp_path):
325
+ try:
326
+ os.unlink(tmp_path)
327
+ except OSError:
328
+ pass # Best effort cleanup
329
+
252
330
  @app.post("/api/jobs/local/select-instrumental")
253
331
  async def select_instrumental(request: SelectionRequest):
254
332
  """Submit instrumental selection."""
255
- if request.selection not in ("clean", "with_backing", "custom"):
333
+ if request.selection not in ("clean", "with_backing", "custom", "uploaded", "original"):
256
334
  raise HTTPException(status_code=400, detail=f"Invalid selection: {request.selection}")
257
335
 
258
336
  self.selection = request.selection
@@ -260,883 +338,95 @@ class InstrumentalReviewServer:
260
338
 
261
339
  return {"status": "success", "selection": request.selection}
262
340
 
341
+ @staticmethod
342
+ def _get_static_dir() -> Path:
343
+ """Get the path to the static assets directory."""
344
+ return Path(__file__).parent / "static"
345
+
263
346
  def _get_frontend_html(self) -> str:
264
- """Return the complete frontend HTML with all features."""
265
- return '''<!DOCTYPE html>
266
- <html lang="en">
267
- <head>
268
- <meta charset="UTF-8">
269
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
270
- <title>Instrumental Review</title>
271
- <style>
272
- :root {
273
- --bg: #0a0a0a;
274
- --card: #18181b;
275
- --card-border: #27272a;
276
- --text: #fafafa;
277
- --text-muted: #a1a1aa;
278
- --primary: #3b82f6;
279
- --primary-hover: #2563eb;
280
- --secondary: #27272a;
281
- --secondary-hover: #3f3f46;
282
- --success: #22c55e;
283
- --warning: #eab308;
284
- --danger: #ef4444;
285
- --badge-bg: #27272a;
286
- }
287
-
288
- * { box-sizing: border-box; margin: 0; padding: 0; }
289
-
290
- body {
291
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
292
- background: var(--bg);
293
- color: var(--text);
294
- line-height: 1.6;
295
- min-height: 100vh;
296
- }
297
-
298
- .container {
299
- max-width: 1200px;
300
- margin: 0 auto;
301
- padding: 2rem;
302
- }
303
-
304
- header {
305
- background: var(--card);
306
- border-bottom: 1px solid var(--card-border);
307
- padding: 1rem 2rem;
308
- margin-bottom: 2rem;
309
- }
310
-
311
- header h1 {
312
- font-size: 1.25rem;
313
- font-weight: 600;
314
- }
315
-
316
- .page-title {
317
- font-size: 1.875rem;
318
- font-weight: 700;
319
- margin-bottom: 0.5rem;
320
- }
321
-
322
- .page-subtitle {
323
- color: var(--text-muted);
324
- margin-bottom: 2rem;
325
- }
326
-
327
- .card {
328
- background: var(--card);
329
- border: 1px solid var(--card-border);
330
- border-radius: 0.5rem;
331
- margin-bottom: 1.5rem;
332
- }
333
-
334
- .card-header {
335
- padding: 1.25rem 1.5rem;
336
- border-bottom: 1px solid var(--card-border);
337
- }
338
-
339
- .card-title {
340
- font-size: 1.125rem;
341
- font-weight: 600;
342
- display: flex;
343
- align-items: center;
344
- gap: 0.5rem;
345
- }
346
-
347
- .card-description {
348
- color: var(--text-muted);
349
- font-size: 0.875rem;
350
- margin-top: 0.25rem;
351
- }
352
-
353
- .card-content {
354
- padding: 1.5rem;
355
- }
356
-
357
- .badge {
358
- display: inline-flex;
359
- align-items: center;
360
- padding: 0.25rem 0.75rem;
361
- border-radius: 9999px;
362
- font-size: 0.75rem;
363
- font-weight: 500;
364
- background: var(--badge-bg);
365
- border: 1px solid var(--card-border);
366
- }
367
-
368
- .badge-success { background: rgba(34, 197, 94, 0.2); color: var(--success); border-color: var(--success); }
369
- .badge-warning { background: rgba(234, 179, 8, 0.2); color: var(--warning); border-color: var(--warning); }
370
-
371
- .flex { display: flex; }
372
- .flex-wrap { flex-wrap: wrap; }
373
- .gap-2 { gap: 0.5rem; }
374
- .gap-4 { gap: 1rem; }
375
- .items-center { align-items: center; }
376
- .justify-between { justify-content: space-between; }
377
-
378
- .grid { display: grid; }
379
- .grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
380
- @media (max-width: 1024px) { .grid-cols-2 { grid-template-columns: 1fr; } }
381
-
382
- .btn {
383
- display: inline-flex;
384
- align-items: center;
385
- justify-content: center;
386
- padding: 0.5rem 1rem;
387
- border-radius: 0.375rem;
388
- font-size: 0.875rem;
389
- font-weight: 500;
390
- cursor: pointer;
391
- border: none;
392
- transition: background 0.2s;
393
- }
394
-
395
- .btn-primary {
396
- background: var(--primary);
397
- color: white;
398
- }
399
- .btn-primary:hover { background: var(--primary-hover); }
400
- .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
401
-
402
- .btn-secondary {
403
- background: var(--secondary);
404
- color: var(--text);
405
- }
406
- .btn-secondary:hover { background: var(--secondary-hover); }
407
-
408
- .btn-outline {
409
- background: transparent;
410
- border: 1px solid var(--card-border);
411
- color: var(--text);
412
- }
413
- .btn-outline:hover { background: var(--secondary); }
414
- .btn-outline.active { background: var(--primary); border-color: var(--primary); }
415
-
416
- .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
417
- .btn-lg { padding: 0.75rem 1.5rem; font-size: 1rem; }
418
-
419
- .btn-danger { background: var(--danger); color: white; }
420
- .btn-success { background: var(--success); color: white; }
421
-
422
- /* Waveform */
423
- #waveform-container {
424
- position: relative;
425
- background: #1a1a2e;
426
- border-radius: 0.5rem;
427
- overflow: hidden;
428
- }
429
-
430
- #waveform-canvas {
431
- display: block;
432
- width: 100%;
433
- cursor: crosshair;
434
- }
435
-
436
- .time-axis {
437
- display: flex;
438
- justify-content: space-between;
439
- padding: 0.5rem 1rem;
440
- font-size: 0.75rem;
441
- color: var(--text-muted);
442
- background: rgba(0,0,0,0.3);
443
- }
444
-
445
- /* Audio Player */
446
- .audio-player {
447
- background: var(--secondary);
448
- padding: 1rem;
449
- border-radius: 0.5rem;
450
- }
451
-
452
- .audio-player audio {
453
- width: 100%;
454
- }
455
-
456
- .audio-label {
457
- font-size: 0.875rem;
458
- color: var(--text-muted);
459
- margin-bottom: 0.5rem;
460
- }
461
-
462
- /* Region List */
463
- .region-list {
464
- max-height: 200px;
465
- overflow-y: auto;
466
- }
467
-
468
- .region-item {
469
- display: flex;
470
- align-items: center;
471
- justify-content: space-between;
472
- padding: 0.75rem;
473
- background: var(--secondary);
474
- border-radius: 0.375rem;
475
- margin-bottom: 0.5rem;
476
- }
477
-
478
- .region-item:last-child { margin-bottom: 0; }
479
-
480
- /* Radio Group */
481
- .radio-group { display: flex; flex-direction: column; gap: 1rem; }
482
-
483
- .radio-item {
484
- display: flex;
485
- align-items: flex-start;
486
- gap: 0.75rem;
487
- padding: 1rem;
488
- background: var(--secondary);
489
- border-radius: 0.5rem;
490
- cursor: pointer;
491
- border: 2px solid transparent;
492
- transition: border-color 0.2s;
493
- }
494
-
495
- .radio-item:hover { border-color: var(--card-border); }
496
- .radio-item.selected { border-color: var(--primary); }
497
-
498
- .radio-item input { margin-top: 0.25rem; }
499
- .radio-item-content { flex: 1; }
500
- .radio-item-title { font-weight: 500; }
501
- .radio-item-desc { font-size: 0.875rem; color: var(--text-muted); }
502
-
503
- /* Alert */
504
- .alert {
505
- padding: 1rem;
506
- border-radius: 0.5rem;
507
- margin-bottom: 1.5rem;
508
- }
509
-
510
- .alert-error {
511
- background: rgba(239, 68, 68, 0.2);
512
- border: 1px solid var(--danger);
513
- color: var(--danger);
514
- }
515
-
516
- .alert-success {
517
- background: rgba(34, 197, 94, 0.2);
518
- border: 1px solid var(--success);
519
- color: var(--success);
520
- }
521
-
522
- /* Loading */
523
- .loading {
524
- display: flex;
525
- align-items: center;
526
- justify-content: center;
527
- padding: 4rem;
528
- }
529
-
530
- .spinner {
531
- width: 2rem;
532
- height: 2rem;
533
- border: 3px solid var(--card-border);
534
- border-top-color: var(--primary);
535
- border-radius: 50%;
536
- animation: spin 1s linear infinite;
537
- }
538
-
539
- @keyframes spin { to { transform: rotate(360deg); } }
540
-
541
- /* Segment markers */
542
- .segment-marker {
543
- position: absolute;
544
- background: rgba(234, 179, 8, 0.3);
545
- border-left: 1px solid var(--warning);
546
- border-right: 1px solid var(--warning);
547
- pointer-events: none;
548
- }
549
-
550
- .mute-region {
551
- position: absolute;
552
- background: rgba(239, 68, 68, 0.4);
553
- border: 1px dashed var(--danger);
554
- pointer-events: none;
555
- }
556
-
557
- .playhead {
558
- position: absolute;
559
- width: 2px;
560
- background: var(--primary);
561
- pointer-events: none;
562
- }
563
-
564
- .hidden { display: none !important; }
565
- </style>
566
- </head>
567
- <body>
568
- <header>
569
- <h1>🎤 Karaoke Generator</h1>
570
- </header>
347
+ """Return the frontend HTML by reading from the static file."""
348
+ static_file = self._get_static_dir() / "index.html"
349
+ if static_file.exists():
350
+ return static_file.read_text(encoding="utf-8")
351
+ else:
352
+ # Fallback error message if file is missing
353
+ return """<!DOCTYPE html>
354
+ <html>
355
+ <head><title>Error</title></head>
356
+ <body style="background:#1a1a1a;color:#fff;font-family:sans-serif;padding:2rem;">
357
+ <h1>Frontend assets not found</h1>
358
+ <p>The static/index.html file is missing from the instrumental_review module.</p>
359
+ </body>
360
+ </html>"""
571
361
 
572
- <main class="container" id="main-content">
573
- <div class="loading" id="loading">
574
- <div class="spinner"></div>
575
- </div>
576
- </main>
577
-
578
- <script>
579
- // State
580
- let analysisData = null;
581
- let waveformData = null;
582
- let muteRegions = [];
583
- let currentTime = 0;
584
- let isSelectionMode = false;
585
- let selectionStart = null;
586
- let activeAudio = 'backing';
587
- let selectedOption = 'clean';
588
- let hasCustom = false;
589
-
590
- const API_BASE = '/api/jobs/local';
591
-
592
- // Initialize
593
- async function init() {
594
- try {
595
- // Fetch analysis
596
- const analysisRes = await fetch(`${API_BASE}/instrumental-analysis`);
597
- if (!analysisRes.ok) throw new Error('Failed to load analysis');
598
- analysisData = await analysisRes.json();
599
-
600
- // Fetch waveform data
601
- const waveformRes = await fetch(`${API_BASE}/waveform-data?num_points=800`);
602
- if (waveformRes.ok) {
603
- waveformData = await waveformRes.json();
604
- }
605
-
606
- // Set initial selection based on recommendation
607
- selectedOption = analysisData.analysis.recommended_selection === 'clean' ? 'clean' : 'with_backing';
608
-
609
- render();
610
- } catch (error) {
611
- showError(error.message);
612
- }
613
- }
614
-
615
- function render() {
616
- const main = document.getElementById('main-content');
617
- main.innerHTML = `
618
- <h1 class="page-title">Select Instrumental Track</h1>
619
- <p class="page-subtitle">${analysisData.artist || ''} ${analysisData.artist && analysisData.title ? '-' : ''} ${analysisData.title || ''}</p>
620
-
621
- <div id="error-container"></div>
622
-
623
- <!-- Analysis Summary -->
624
- <div class="card">
625
- <div class="card-header">
626
- <div class="card-title">
627
- <span>🎵</span> Backing Vocals Analysis
628
- </div>
629
- <div class="card-description">Automated analysis of the backing vocals stem</div>
630
- </div>
631
- <div class="card-content">
632
- <div class="flex flex-wrap gap-4 items-center">
633
- <div class="flex items-center gap-2">
634
- ${analysisData.analysis.has_audible_content
635
- ? '<span style="color: var(--warning)">🔊</span> Backing vocals detected'
636
- : '<span style="color: var(--success)">🔇</span> No backing vocals detected'}
637
- </div>
638
- ${analysisData.analysis.has_audible_content ? `
639
- <span class="badge">${analysisData.analysis.audible_segments.length} segments</span>
640
- <span class="badge">${analysisData.analysis.audible_percentage.toFixed(1)}% of track</span>
641
- <span class="badge">${analysisData.analysis.total_audible_duration_seconds.toFixed(1)}s total</span>
642
- ` : ''}
643
- <span class="badge ${analysisData.analysis.recommended_selection === 'clean' ? 'badge-success' : 'badge-warning'}">
644
- Recommended: ${analysisData.analysis.recommended_selection === 'clean' ? 'Clean Instrumental' : 'Review Needed'}
645
- </span>
646
- </div>
647
- </div>
648
- </div>
649
-
650
- <!-- Waveform -->
651
- ${waveformData ? `
652
- <div class="card">
653
- <div class="card-header">
654
- <div class="card-title">Backing Vocals Waveform</div>
655
- <div class="card-description">
656
- Click to seek • ${isSelectionMode ? 'Click and drag to select mute region' : 'Click "Add Mute Region" to start selecting'}
657
- </div>
658
- </div>
659
- <div class="card-content">
660
- <div id="waveform-container">
661
- <canvas id="waveform-canvas" width="800" height="150"></canvas>
662
- <div id="segments-overlay"></div>
663
- <div id="mute-overlay"></div>
664
- <div id="playhead" class="playhead hidden"></div>
665
- </div>
666
- <div class="time-axis">
667
- <span>0:00</span>
668
- <span>${formatTime(waveformData.duration / 4)}</span>
669
- <span>${formatTime(waveformData.duration / 2)}</span>
670
- <span>${formatTime(waveformData.duration * 3 / 4)}</span>
671
- <span>${formatTime(waveformData.duration)}</span>
672
- </div>
673
- </div>
674
- </div>
675
- ` : ''}
676
-
677
- <!-- Audio & Regions Grid -->
678
- <div class="grid grid-cols-2 gap-4">
679
- <!-- Audio Player -->
680
- <div class="card">
681
- <div class="card-header">
682
- <div class="card-title">Audio Preview</div>
683
- <div class="card-description">Listen to different instrumental options</div>
684
- </div>
685
- <div class="card-content">
686
- <div class="flex flex-wrap gap-2" style="margin-bottom: 1rem;">
687
- <button class="btn btn-sm ${activeAudio === 'backing' ? 'btn-primary' : 'btn-outline'}" onclick="setActiveAudio('backing')">Backing Vocals</button>
688
- <button class="btn btn-sm ${activeAudio === 'clean' ? 'btn-primary' : 'btn-outline'}" onclick="setActiveAudio('clean')">Clean Instrumental</button>
689
- ${analysisData.audio_urls.with_backing ? `
690
- <button class="btn btn-sm ${activeAudio === 'with_backing' ? 'btn-primary' : 'btn-outline'}" onclick="setActiveAudio('with_backing')">With Backing</button>
691
- ` : ''}
692
- ${hasCustom ? `
693
- <button class="btn btn-sm ${activeAudio === 'custom' ? 'btn-primary' : 'btn-outline'}" onclick="setActiveAudio('custom')">Custom</button>
694
- ` : ''}
695
- </div>
696
- <div class="audio-player">
697
- <div class="audio-label">${getAudioLabel()}</div>
698
- <audio id="audio-player" controls src="${getAudioUrl()}" ontimeupdate="onTimeUpdate(this)"></audio>
699
- </div>
700
- </div>
701
- </div>
702
-
703
- <!-- Region Selector -->
704
- <div class="card">
705
- <div class="card-header">
706
- <div class="card-title">Mute Regions</div>
707
- <div class="card-description">Select sections of backing vocals to mute</div>
708
- </div>
709
- <div class="card-content">
710
- <div class="flex gap-2" style="margin-bottom: 1rem;">
711
- <button class="btn btn-sm ${isSelectionMode ? 'btn-primary' : 'btn-outline'}" onclick="toggleSelectionMode()">
712
- ${isSelectionMode ? '✓ Selecting...' : '+ Add Mute Region'}
713
- </button>
714
- ${muteRegions.length > 0 ? `
715
- <button class="btn btn-sm btn-outline" onclick="clearAllRegions()">Clear All</button>
716
- ` : ''}
717
- </div>
718
-
719
- ${muteRegions.length > 0 ? `
720
- <div class="region-list">
721
- ${muteRegions.map((r, i) => `
722
- <div class="region-item">
723
- <span>${formatTime(r.start_seconds)} - ${formatTime(r.end_seconds)} (${(r.end_seconds - r.start_seconds).toFixed(1)}s)</span>
724
- <div class="flex gap-2">
725
- <button class="btn btn-sm btn-outline" onclick="seekTo(${r.start_seconds})">▶</button>
726
- <button class="btn btn-sm btn-danger" onclick="removeRegion(${i})">×</button>
727
- </div>
728
- </div>
729
- `).join('')}
730
- </div>
731
- ` : `
732
- <p style="color: var(--text-muted); font-size: 0.875rem;">
733
- No mute regions selected. Click "Add Mute Region" then click and drag on the waveform to select sections to mute.
734
- </p>
735
- `}
736
-
737
- ${analysisData.analysis.audible_segments.length > 0 ? `
738
- <div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--card-border);">
739
- <div style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 0.5rem;">Quick mute detected segments:</div>
740
- <div class="flex flex-wrap gap-2">
741
- ${analysisData.analysis.audible_segments.slice(0, 5).map((seg, i) => `
742
- <button class="btn btn-sm btn-outline" onclick="addSegmentAsRegion(${i})">
743
- ${formatTime(seg.start_seconds)} - ${formatTime(seg.end_seconds)}
744
- </button>
745
- `).join('')}
746
- ${analysisData.analysis.audible_segments.length > 5 ? `<span class="badge">+${analysisData.analysis.audible_segments.length - 5} more</span>` : ''}
747
- </div>
748
- </div>
749
- ` : ''}
750
- </div>
751
- </div>
752
- </div>
753
-
754
- <!-- Create Custom Button -->
755
- ${muteRegions.length > 0 && !hasCustom ? `
756
- <div class="card">
757
- <div class="card-content">
758
- <div class="flex items-center justify-between">
759
- <div>
760
- <p style="font-weight: 500;">Ready to create custom instrumental</p>
761
- <p style="font-size: 0.875rem; color: var(--text-muted);">${muteRegions.length} region${muteRegions.length > 1 ? 's' : ''} will be muted</p>
762
- </div>
763
- <button class="btn btn-primary" id="create-custom-btn" onclick="createCustomInstrumental()">
764
- Create Custom Instrumental
765
- </button>
766
- </div>
767
- </div>
768
- </div>
769
- ` : ''}
770
-
771
- <!-- Selection Options -->
772
- <div class="card">
773
- <div class="card-header">
774
- <div class="card-title">Final Selection</div>
775
- <div class="card-description">Choose which instrumental to use for your karaoke video</div>
776
- </div>
777
- <div class="card-content">
778
- <div class="radio-group">
779
- <label class="radio-item ${selectedOption === 'clean' ? 'selected' : ''}" onclick="setSelection('clean')">
780
- <input type="radio" name="selection" value="clean" ${selectedOption === 'clean' ? 'checked' : ''}>
781
- <div class="radio-item-content">
782
- <div class="radio-item-title">Clean Instrumental</div>
783
- <div class="radio-item-desc">Use the instrumental with no backing vocals at all</div>
784
- </div>
785
- ${analysisData.analysis.recommended_selection === 'clean' ? '<span class="badge badge-success">✓ Recommended</span>' : ''}
786
- </label>
787
-
788
- <label class="radio-item ${selectedOption === 'with_backing' ? 'selected' : ''}" onclick="setSelection('with_backing')">
789
- <input type="radio" name="selection" value="with_backing" ${selectedOption === 'with_backing' ? 'checked' : ''}>
790
- <div class="radio-item-content">
791
- <div class="radio-item-title">Instrumental with Backing Vocals</div>
792
- <div class="radio-item-desc">Use the instrumental with all backing vocals included</div>
793
- </div>
794
- </label>
795
-
796
- ${hasCustom ? `
797
- <label class="radio-item ${selectedOption === 'custom' ? 'selected' : ''}" onclick="setSelection('custom')">
798
- <input type="radio" name="selection" value="custom" ${selectedOption === 'custom' ? 'checked' : ''}>
799
- <div class="radio-item-content">
800
- <div class="radio-item-title">Custom Instrumental</div>
801
- <div class="radio-item-desc">Use your custom instrumental with ${muteRegions.length} muted region${muteRegions.length > 1 ? 's' : ''}</div>
802
- </div>
803
- <span class="badge">Custom</span>
804
- </label>
805
- ` : ''}
806
- </div>
807
- </div>
808
- </div>
809
-
810
- <!-- Submit Button -->
811
- <button class="btn btn-primary btn-lg" id="submit-btn" onclick="submitSelection()" style="width: 100%; max-width: 400px; margin-top: 1rem;">
812
- ✓ Confirm Selection & Continue
813
- </button>
814
- `;
815
-
816
- // Initialize waveform after render
817
- if (waveformData) {
818
- setTimeout(drawWaveform, 0);
819
- setupWaveformInteraction();
820
- }
821
- }
822
-
823
- function drawWaveform() {
824
- const canvas = document.getElementById('waveform-canvas');
825
- if (!canvas || !waveformData) return;
826
-
827
- const ctx = canvas.getContext('2d');
828
- const { amplitudes, duration } = waveformData;
829
- const width = canvas.width;
830
- const height = canvas.height;
831
- const centerY = height / 2;
832
-
833
- // Clear canvas
834
- ctx.fillStyle = '#1a1a2e';
835
- ctx.fillRect(0, 0, width, height);
836
-
837
- // Draw threshold line
838
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
839
- ctx.setLineDash([5, 5]);
840
- ctx.beginPath();
841
- ctx.moveTo(0, centerY);
842
- ctx.lineTo(width, centerY);
843
- ctx.stroke();
844
- ctx.setLineDash([]);
845
-
846
- // Draw waveform
847
- const barWidth = width / amplitudes.length;
848
-
849
- amplitudes.forEach((amp, i) => {
850
- const x = i * barWidth;
851
- const barHeight = Math.max(2, amp * height);
852
- const y = centerY - barHeight / 2;
853
-
854
- // Check if this point is in a muted region
855
- const time = (i / amplitudes.length) * duration;
856
- const inMuteRegion = muteRegions.some(r => time >= r.start_seconds && time <= r.end_seconds);
857
-
858
- // Check if in audible segment
859
- const inAudibleSegment = analysisData.analysis.audible_segments.some(
860
- s => time >= s.start_seconds && time <= s.end_seconds
861
- );
862
-
863
- if (inMuteRegion) {
864
- ctx.fillStyle = '#ef4444';
865
- } else if (inAudibleSegment) {
866
- ctx.fillStyle = '#ec4899';
867
- } else {
868
- ctx.fillStyle = '#60a5fa';
869
- }
870
-
871
- ctx.fillRect(x, y, Math.max(1, barWidth - 1), barHeight);
872
- });
873
-
874
- // Draw playhead
875
- const playhead = document.getElementById('playhead');
876
- if (playhead && currentTime > 0) {
877
- const x = (currentTime / duration) * width;
878
- playhead.style.left = `${x}px`;
879
- playhead.style.height = `${height}px`;
880
- playhead.classList.remove('hidden');
881
- }
882
- }
883
-
884
- function setupWaveformInteraction() {
885
- const canvas = document.getElementById('waveform-canvas');
886
- if (!canvas) return;
887
-
888
- let isDragging = false;
889
- let dragStart = 0;
890
-
891
- canvas.onmousedown = (e) => {
892
- const rect = canvas.getBoundingClientRect();
893
- const x = e.clientX - rect.left;
894
- const time = (x / rect.width) * waveformData.duration;
895
-
896
- if (isSelectionMode) {
897
- isDragging = true;
898
- dragStart = time;
899
- selectionStart = time;
900
- } else {
901
- seekTo(time);
902
- }
903
- };
904
-
905
- canvas.onmousemove = (e) => {
906
- if (!isDragging || !isSelectionMode) return;
907
- // Visual feedback could be added here
908
- };
909
-
910
- canvas.onmouseup = (e) => {
911
- if (!isDragging || !isSelectionMode) return;
912
-
913
- const rect = canvas.getBoundingClientRect();
914
- const x = e.clientX - rect.left;
915
- const time = (x / rect.width) * waveformData.duration;
916
-
917
- const start = Math.min(dragStart, time);
918
- const end = Math.max(dragStart, time);
919
-
920
- if (end - start > 0.1) {
921
- addRegion(start, end);
922
- }
923
-
924
- isDragging = false;
925
- isSelectionMode = false;
926
- render();
927
- };
928
-
929
- canvas.onmouseleave = () => {
930
- if (isDragging) {
931
- isDragging = false;
932
- }
933
- };
934
- }
935
-
936
- function formatTime(seconds) {
937
- const mins = Math.floor(seconds / 60);
938
- const secs = Math.floor(seconds % 60);
939
- return `${mins}:${secs.toString().padStart(2, '0')}`;
940
- }
941
-
942
- function getAudioUrl() {
943
- const urls = {
944
- backing: '/api/audio/backing_vocals',
945
- clean: '/api/audio/clean_instrumental',
946
- with_backing: '/api/audio/with_backing',
947
- custom: '/api/audio/custom_instrumental'
948
- };
949
- return urls[activeAudio] || urls.backing;
950
- }
951
-
952
- function getAudioLabel() {
953
- const labels = {
954
- backing: 'Backing Vocals Only',
955
- clean: 'Clean Instrumental',
956
- with_backing: 'Instrumental + Backing Vocals',
957
- custom: 'Custom Instrumental'
958
- };
959
- return labels[activeAudio] || 'Audio';
960
- }
961
-
962
- function setActiveAudio(type) {
963
- activeAudio = type;
964
- render();
965
- }
966
-
967
- function onTimeUpdate(audio) {
968
- currentTime = audio.currentTime;
969
- if (waveformData) {
970
- const playhead = document.getElementById('playhead');
971
- const canvas = document.getElementById('waveform-canvas');
972
- if (playhead && canvas) {
973
- const x = (currentTime / waveformData.duration) * canvas.width;
974
- playhead.style.left = `${x}px`;
975
- playhead.style.height = `${canvas.height}px`;
976
- playhead.classList.remove('hidden');
977
- }
978
- }
979
- }
980
-
981
- function seekTo(time) {
982
- const audio = document.getElementById('audio-player');
983
- if (audio) {
984
- audio.currentTime = time;
985
- audio.play();
986
- }
987
- }
988
-
989
- function toggleSelectionMode() {
990
- isSelectionMode = !isSelectionMode;
991
- render();
992
- }
993
-
994
- function addRegion(start, end) {
995
- muteRegions.push({ start_seconds: start, end_seconds: end });
996
- muteRegions.sort((a, b) => a.start_seconds - b.start_seconds);
997
- // Merge overlapping
998
- mergeOverlappingRegions();
999
- }
1000
-
1001
- function addSegmentAsRegion(index) {
1002
- const seg = analysisData.analysis.audible_segments[index];
1003
- if (seg) {
1004
- addRegion(seg.start_seconds, seg.end_seconds);
1005
- render();
1006
- }
1007
- }
1008
-
1009
- function removeRegion(index) {
1010
- muteRegions.splice(index, 1);
1011
- render();
1012
- }
1013
-
1014
- function clearAllRegions() {
1015
- muteRegions = [];
1016
- hasCustom = false;
1017
- render();
1018
- }
1019
-
1020
- function mergeOverlappingRegions() {
1021
- if (muteRegions.length < 2) return;
1022
-
1023
- const merged = [muteRegions[0]];
1024
- for (let i = 1; i < muteRegions.length; i++) {
1025
- const last = merged[merged.length - 1];
1026
- const curr = muteRegions[i];
1027
-
1028
- if (curr.start_seconds <= last.end_seconds) {
1029
- last.end_seconds = Math.max(last.end_seconds, curr.end_seconds);
1030
- } else {
1031
- merged.push(curr);
1032
- }
1033
- }
1034
- muteRegions = merged;
1035
- }
1036
-
1037
- function setSelection(value) {
1038
- selectedOption = value;
1039
- render();
1040
- }
362
+ @staticmethod
363
+ def _is_port_available(host: str, port: int) -> bool:
364
+ """Check if a port is available for binding."""
365
+ try:
366
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
367
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
368
+ sock.bind((host, port))
369
+ return True
370
+ except OSError:
371
+ return False
372
+
373
+ @staticmethod
374
+ def _find_available_port(host: str, preferred_port: int, max_attempts: int = 100) -> int:
375
+ """
376
+ Find an available port, starting with the preferred port.
1041
377
 
1042
- async function createCustomInstrumental() {
1043
- const btn = document.getElementById('create-custom-btn');
1044
- if (btn) {
1045
- btn.disabled = true;
1046
- btn.textContent = 'Creating...';
1047
- }
378
+ Args:
379
+ host: Host to bind to
380
+ preferred_port: The preferred port to try first
381
+ max_attempts: Maximum number of ports to try
1048
382
 
1049
- try {
1050
- const response = await fetch(`${API_BASE}/create-custom-instrumental`, {
1051
- method: 'POST',
1052
- headers: { 'Content-Type': 'application/json' },
1053
- body: JSON.stringify({ mute_regions: muteRegions })
1054
- });
1055
-
1056
- if (!response.ok) {
1057
- const data = await response.json();
1058
- throw new Error(data.detail || 'Failed to create custom instrumental');
1059
- }
1060
-
1061
- hasCustom = true;
1062
- selectedOption = 'custom';
1063
- activeAudio = 'custom';
1064
- render();
1065
- } catch (error) {
1066
- showError(error.message);
1067
- if (btn) {
1068
- btn.disabled = false;
1069
- btn.textContent = 'Create Custom Instrumental';
1070
- }
1071
- }
1072
- }
1073
-
1074
- async function submitSelection() {
1075
- const btn = document.getElementById('submit-btn');
1076
- if (btn) {
1077
- btn.disabled = true;
1078
- btn.textContent = 'Submitting...';
1079
- }
383
+ Returns:
384
+ An available port number
1080
385
 
1081
- try {
1082
- const response = await fetch(`${API_BASE}/select-instrumental`, {
1083
- method: 'POST',
1084
- headers: { 'Content-Type': 'application/json' },
1085
- body: JSON.stringify({ selection: selectedOption })
1086
- });
1087
-
1088
- if (!response.ok) {
1089
- const data = await response.json();
1090
- throw new Error(data.detail || 'Failed to submit selection');
1091
- }
1092
-
1093
- // Show success message
1094
- document.getElementById('main-content').innerHTML = `
1095
- <div class="alert alert-success" style="max-width: 600px; margin: 4rem auto; text-align: center;">
1096
- <h2 style="margin-bottom: 1rem;">✓ Selection Submitted!</h2>
1097
- <p>You selected: <strong>${selectedOption === 'clean' ? 'Clean Instrumental' : selectedOption === 'with_backing' ? 'Instrumental with Backing Vocals' : 'Custom Instrumental'}</strong></p>
1098
- <p style="margin-top: 1rem; color: var(--text-muted);">You can close this window now.</p>
1099
- </div>
1100
- `;
1101
- } catch (error) {
1102
- showError(error.message);
1103
- if (btn) {
1104
- btn.disabled = false;
1105
- btn.textContent = '✓ Confirm Selection & Continue';
1106
- }
1107
- }
1108
- }
1109
-
1110
- function showError(message) {
1111
- const container = document.getElementById('error-container');
1112
- if (container) {
1113
- container.innerHTML = `<div class="alert alert-error">${message}</div>`;
1114
- }
1115
- }
1116
-
1117
- // Start
1118
- init();
1119
- </script>
1120
- </body>
1121
- </html>'''
386
+ Raises:
387
+ RuntimeError: If no available port could be found
388
+ """
389
+ # Try the preferred port first
390
+ if InstrumentalReviewServer._is_port_available(host, preferred_port):
391
+ return preferred_port
392
+
393
+ # Try subsequent ports
394
+ for offset in range(1, max_attempts):
395
+ port = preferred_port + offset
396
+ if port > 65535:
397
+ break
398
+ if InstrumentalReviewServer._is_port_available(host, port):
399
+ return port
400
+
401
+ # Last resort: let the OS assign a port
402
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
403
+ sock.bind((host, 0))
404
+ return sock.getsockname()[1]
1122
405
 
1123
406
  def start_and_open_browser(self, port: int = 8765) -> str:
1124
407
  """
1125
408
  Start server, open browser, and block until selection is submitted.
1126
409
 
1127
410
  Args:
1128
- port: Port to run the server on
411
+ port: Preferred port to run the server on. If unavailable, will
412
+ automatically find an available port.
1129
413
 
1130
414
  Returns:
1131
415
  The user's selection ("clean", "with_backing", or "custom")
1132
416
  """
1133
417
  self._app = self._create_app()
1134
418
 
419
+ # Find an available port (handles concurrent CLI instances)
420
+ host = "127.0.0.1"
421
+ actual_port = self._find_available_port(host, port)
422
+ if actual_port != port:
423
+ logger.info(f"Port {port} in use, using port {actual_port} instead")
424
+
1135
425
  # Run uvicorn in a separate thread
1136
426
  config = uvicorn.Config(
1137
427
  self._app,
1138
- host="127.0.0.1",
1139
- port=port,
428
+ host=host,
429
+ port=actual_port,
1140
430
  log_level="warning",
1141
431
  )
1142
432
  server = uvicorn.Server(config)
@@ -1151,7 +441,7 @@ class InstrumentalReviewServer:
1151
441
  import time
1152
442
  time.sleep(0.5)
1153
443
 
1154
- url = f"http://localhost:{port}/"
444
+ url = f"http://localhost:{actual_port}/"
1155
445
  logger.info(f"Instrumental review server started at {url}")
1156
446
 
1157
447
  # Open browser
@@ -1179,3 +469,7 @@ class InstrumentalReviewServer:
1179
469
  def get_custom_instrumental_path(self) -> Optional[str]:
1180
470
  """Get the path to the custom instrumental if one was created."""
1181
471
  return self.custom_instrumental_path
472
+
473
+ def get_uploaded_instrumental_path(self) -> Optional[str]:
474
+ """Get the path to the uploaded instrumental if one was uploaded."""
475
+ return self.uploaded_instrumental_path