karaoke-gen 0.71.42__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.
- karaoke_gen/__init__.py +32 -1
- karaoke_gen/audio_fetcher.py +476 -56
- karaoke_gen/audio_processor.py +11 -3
- karaoke_gen/instrumental_review/server.py +154 -860
- karaoke_gen/instrumental_review/static/index.html +1506 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
- karaoke_gen/karaoke_gen.py +114 -1
- karaoke_gen/lyrics_processor.py +81 -4
- karaoke_gen/utils/bulk_cli.py +3 -0
- karaoke_gen/utils/cli_args.py +4 -2
- karaoke_gen/utils/gen_cli.py +196 -5
- karaoke_gen/utils/remote_cli.py +523 -34
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +4 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +31 -25
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
- lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- lyrics_transcriber/review/server.py +5 -5
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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:
|
|
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=
|
|
1139
|
-
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:{
|
|
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
|