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
karaoke_gen/utils/gen_cli.py
CHANGED
|
@@ -18,6 +18,7 @@ import glob
|
|
|
18
18
|
import pyperclip
|
|
19
19
|
from karaoke_gen import KaraokePrep
|
|
20
20
|
from karaoke_gen.karaoke_finalise import KaraokeFinalise
|
|
21
|
+
from karaoke_gen.audio_fetcher import UserCancelledError
|
|
21
22
|
from karaoke_gen.instrumental_review import (
|
|
22
23
|
AudioAnalyzer,
|
|
23
24
|
WaveformGenerator,
|
|
@@ -59,6 +60,89 @@ def _resolve_path_for_cwd(path: str, track_dir: str) -> str:
|
|
|
59
60
|
return path
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
def auto_select_instrumental(track: dict, track_dir: str, logger: logging.Logger) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Auto-select the best instrumental file when --skip_instrumental_review is used.
|
|
66
|
+
|
|
67
|
+
Selection priority:
|
|
68
|
+
1. Padded combined instrumental (+BV) - synchronized with vocals + backing vocals
|
|
69
|
+
2. Non-padded combined instrumental (+BV) - has backing vocals
|
|
70
|
+
3. Padded clean instrumental - synchronized with vocals
|
|
71
|
+
4. Non-padded clean instrumental - basic instrumental
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
track: The track dictionary from KaraokePrep containing separated audio info
|
|
75
|
+
track_dir: The track output directory (we're already chdir'd into it)
|
|
76
|
+
logger: Logger instance
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Path to the selected instrumental file
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
FileNotFoundError: If no suitable instrumental file can be found
|
|
83
|
+
"""
|
|
84
|
+
separated = track.get("separated_audio", {})
|
|
85
|
+
|
|
86
|
+
# Look for combined instrumentals first (they include backing vocals)
|
|
87
|
+
combined = separated.get("combined_instrumentals", {})
|
|
88
|
+
for model, path in combined.items():
|
|
89
|
+
if path:
|
|
90
|
+
resolved = _resolve_path_for_cwd(path, track_dir)
|
|
91
|
+
# Prefer padded version if it exists
|
|
92
|
+
base, ext = os.path.splitext(resolved)
|
|
93
|
+
padded = f"{base} (Padded){ext}"
|
|
94
|
+
if os.path.exists(padded):
|
|
95
|
+
logger.info(f"Auto-selected padded combined instrumental: {padded}")
|
|
96
|
+
return padded
|
|
97
|
+
if os.path.exists(resolved):
|
|
98
|
+
logger.info(f"Auto-selected combined instrumental: {resolved}")
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
# Fall back to clean instrumental
|
|
102
|
+
clean = separated.get("clean_instrumental", {})
|
|
103
|
+
if clean.get("instrumental"):
|
|
104
|
+
resolved = _resolve_path_for_cwd(clean["instrumental"], track_dir)
|
|
105
|
+
# Prefer padded version if it exists
|
|
106
|
+
base, ext = os.path.splitext(resolved)
|
|
107
|
+
padded = f"{base} (Padded){ext}"
|
|
108
|
+
if os.path.exists(padded):
|
|
109
|
+
logger.info(f"Auto-selected padded clean instrumental: {padded}")
|
|
110
|
+
return padded
|
|
111
|
+
if os.path.exists(resolved):
|
|
112
|
+
logger.info(f"Auto-selected clean instrumental: {resolved}")
|
|
113
|
+
return resolved
|
|
114
|
+
|
|
115
|
+
# If separated_audio doesn't have what we need, search the directory
|
|
116
|
+
# This handles edge cases and custom instrumentals
|
|
117
|
+
logger.info("No instrumental found in separated_audio, searching directory...")
|
|
118
|
+
instrumental_files = glob.glob("*(Instrumental*.flac") + glob.glob("*(Instrumental*.wav")
|
|
119
|
+
|
|
120
|
+
# Sort to prefer padded versions and combined instrumentals
|
|
121
|
+
padded_combined = [f for f in instrumental_files if "(Padded)" in f and "+BV" in f]
|
|
122
|
+
if padded_combined:
|
|
123
|
+
logger.info(f"Auto-selected from directory: {padded_combined[0]}")
|
|
124
|
+
return padded_combined[0]
|
|
125
|
+
|
|
126
|
+
padded_files = [f for f in instrumental_files if "(Padded)" in f]
|
|
127
|
+
if padded_files:
|
|
128
|
+
logger.info(f"Auto-selected from directory: {padded_files[0]}")
|
|
129
|
+
return padded_files[0]
|
|
130
|
+
|
|
131
|
+
combined_files = [f for f in instrumental_files if "+BV" in f]
|
|
132
|
+
if combined_files:
|
|
133
|
+
logger.info(f"Auto-selected from directory: {combined_files[0]}")
|
|
134
|
+
return combined_files[0]
|
|
135
|
+
|
|
136
|
+
if instrumental_files:
|
|
137
|
+
logger.info(f"Auto-selected from directory: {instrumental_files[0]}")
|
|
138
|
+
return instrumental_files[0]
|
|
139
|
+
|
|
140
|
+
raise FileNotFoundError(
|
|
141
|
+
"No instrumental file found. Audio separation may have failed. "
|
|
142
|
+
"Check the stems/ directory for separated audio files."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
62
146
|
def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
|
|
63
147
|
"""
|
|
64
148
|
Run the instrumental review UI to let user select the best instrumental track.
|
|
@@ -116,6 +200,15 @@ def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
|
|
|
116
200
|
with_backing_path = resolved_path
|
|
117
201
|
break
|
|
118
202
|
|
|
203
|
+
# Find the original audio file (with vocals)
|
|
204
|
+
original_audio_path = None
|
|
205
|
+
raw_original_path = track.get("input_audio_wav")
|
|
206
|
+
if raw_original_path:
|
|
207
|
+
original_audio_path = _resolve_path_for_cwd(raw_original_path, track_dir)
|
|
208
|
+
if not os.path.exists(original_audio_path):
|
|
209
|
+
logger.warning(f"Original audio file not found: {original_audio_path}")
|
|
210
|
+
original_audio_path = None
|
|
211
|
+
|
|
119
212
|
try:
|
|
120
213
|
logger.info("=== Starting Instrumental Review ===")
|
|
121
214
|
logger.info(f"Analyzing backing vocals: {backing_vocals_path}")
|
|
@@ -138,7 +231,7 @@ def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
|
|
|
138
231
|
waveform_generator.generate(
|
|
139
232
|
audio_path=backing_vocals_path,
|
|
140
233
|
output_path=waveform_path,
|
|
141
|
-
|
|
234
|
+
segments=analysis.audible_segments,
|
|
142
235
|
)
|
|
143
236
|
|
|
144
237
|
# Start the review server
|
|
@@ -152,6 +245,7 @@ def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
|
|
|
152
245
|
backing_vocals_path=backing_vocals_path,
|
|
153
246
|
clean_instrumental_path=clean_instrumental_path,
|
|
154
247
|
with_backing_path=with_backing_path,
|
|
248
|
+
original_audio_path=original_audio_path,
|
|
155
249
|
)
|
|
156
250
|
|
|
157
251
|
# Start server and open browser, wait for selection
|
|
@@ -182,6 +276,13 @@ def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
|
|
|
182
276
|
else:
|
|
183
277
|
logger.warning("Custom instrumental not found, falling back to clean")
|
|
184
278
|
return clean_instrumental_path
|
|
279
|
+
elif selection == "uploaded":
|
|
280
|
+
uploaded_path = server.get_uploaded_instrumental_path()
|
|
281
|
+
if uploaded_path and os.path.exists(uploaded_path):
|
|
282
|
+
return uploaded_path
|
|
283
|
+
else:
|
|
284
|
+
logger.warning("Uploaded instrumental not found, falling back to clean")
|
|
285
|
+
return clean_instrumental_path
|
|
185
286
|
else:
|
|
186
287
|
logger.warning(f"Unknown selection: {selection}, falling back to numeric selection")
|
|
187
288
|
return None
|
|
@@ -199,6 +300,9 @@ def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
|
|
|
199
300
|
|
|
200
301
|
async def async_main():
|
|
201
302
|
logger = logging.getLogger(__name__)
|
|
303
|
+
# Prevent log propagation to root logger to avoid duplicate logs
|
|
304
|
+
# when external packages (like lyrics_converter) configure root logger handlers
|
|
305
|
+
logger.propagate = False
|
|
202
306
|
log_handler = logging.StreamHandler()
|
|
203
307
|
log_formatter = logging.Formatter(fmt="%(asctime)s.%(msecs)03d - %(levelname)s - %(module)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
204
308
|
log_handler.setFormatter(log_formatter)
|
|
@@ -208,6 +312,11 @@ async def async_main():
|
|
|
208
312
|
parser = create_parser(prog="karaoke-gen")
|
|
209
313
|
args = parser.parse_args()
|
|
210
314
|
|
|
315
|
+
# Set review UI URL environment variable for the lyrics transcriber review server
|
|
316
|
+
# This allows development against a local frontend dev server (e.g., http://localhost:5173)
|
|
317
|
+
if hasattr(args, 'review_ui_url') and args.review_ui_url:
|
|
318
|
+
os.environ['LYRICS_REVIEW_UI_URL'] = args.review_ui_url
|
|
319
|
+
|
|
211
320
|
# Process style overrides
|
|
212
321
|
try:
|
|
213
322
|
style_overrides = process_style_overrides(args.style_override, logger)
|
|
@@ -299,7 +408,21 @@ async def async_main():
|
|
|
299
408
|
kprep.input_media = input_audio_wav
|
|
300
409
|
|
|
301
410
|
# Run KaraokePrep
|
|
302
|
-
|
|
411
|
+
try:
|
|
412
|
+
tracks = await kprep.process()
|
|
413
|
+
except UserCancelledError:
|
|
414
|
+
logger.info("Operation cancelled by user")
|
|
415
|
+
return
|
|
416
|
+
except KeyboardInterrupt:
|
|
417
|
+
logger.info("Operation cancelled by user (Ctrl+C)")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
# Filter out None tracks (can happen if prep failed for some tracks)
|
|
421
|
+
tracks = [t for t in tracks if t is not None] if tracks else []
|
|
422
|
+
|
|
423
|
+
if not tracks:
|
|
424
|
+
logger.warning("No tracks to process")
|
|
425
|
+
return
|
|
303
426
|
|
|
304
427
|
# Load CDG styles if CDG generation is enabled
|
|
305
428
|
cdg_styles = None
|
|
@@ -618,7 +741,21 @@ async def async_main():
|
|
|
618
741
|
kprep = kprep_coroutine
|
|
619
742
|
|
|
620
743
|
# Create final tracks data structure
|
|
621
|
-
|
|
744
|
+
try:
|
|
745
|
+
tracks = await kprep.process()
|
|
746
|
+
except UserCancelledError:
|
|
747
|
+
logger.info("Operation cancelled by user")
|
|
748
|
+
return
|
|
749
|
+
except KeyboardInterrupt:
|
|
750
|
+
logger.info("Operation cancelled by user (Ctrl+C)")
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
# Filter out None tracks (can happen if prep failed for some tracks)
|
|
754
|
+
tracks = [t for t in tracks if t is not None] if tracks else []
|
|
755
|
+
|
|
756
|
+
if not tracks:
|
|
757
|
+
logger.warning("No tracks to process")
|
|
758
|
+
return
|
|
622
759
|
|
|
623
760
|
# If prep-only mode, we're done
|
|
624
761
|
if args.prep_only:
|
|
@@ -638,13 +775,66 @@ async def async_main():
|
|
|
638
775
|
logger.info(f"Changing to directory: {track_dir}")
|
|
639
776
|
os.chdir(track_dir)
|
|
640
777
|
|
|
641
|
-
#
|
|
778
|
+
# Select instrumental file - either via web UI or auto-selection
|
|
779
|
+
# This ALWAYS produces a selected file - no silent fallback to legacy code
|
|
642
780
|
selected_instrumental_file = None
|
|
643
|
-
|
|
781
|
+
skip_review = getattr(args, 'skip_instrumental_review', False)
|
|
782
|
+
|
|
783
|
+
if skip_review:
|
|
784
|
+
# Auto-select instrumental when review is skipped (non-interactive mode)
|
|
785
|
+
logger.info("Instrumental review skipped (--skip_instrumental_review), auto-selecting instrumental file...")
|
|
786
|
+
try:
|
|
787
|
+
selected_instrumental_file = auto_select_instrumental(
|
|
788
|
+
track=track,
|
|
789
|
+
track_dir=track_dir,
|
|
790
|
+
logger=logger,
|
|
791
|
+
)
|
|
792
|
+
except FileNotFoundError as e:
|
|
793
|
+
logger.error(f"Failed to auto-select instrumental: {e}")
|
|
794
|
+
logger.error("Check that audio separation completed successfully.")
|
|
795
|
+
sys.exit(1)
|
|
796
|
+
return # Explicit return for testing
|
|
797
|
+
else:
|
|
798
|
+
# Run instrumental review web UI
|
|
644
799
|
selected_instrumental_file = run_instrumental_review(
|
|
645
800
|
track=track,
|
|
646
801
|
logger=logger,
|
|
647
802
|
)
|
|
803
|
+
|
|
804
|
+
# If instrumental review failed/returned None, show error and exit
|
|
805
|
+
# NO SILENT FALLBACK - we want to know if the new flow has issues
|
|
806
|
+
if selected_instrumental_file is None:
|
|
807
|
+
logger.error("")
|
|
808
|
+
logger.error("=" * 70)
|
|
809
|
+
logger.error("INSTRUMENTAL SELECTION FAILED")
|
|
810
|
+
logger.error("=" * 70)
|
|
811
|
+
logger.error("")
|
|
812
|
+
logger.error("The instrumental review UI could not find the required files.")
|
|
813
|
+
logger.error("")
|
|
814
|
+
logger.error("Common causes:")
|
|
815
|
+
logger.error(" - No backing vocals file was found (check stems/ directory)")
|
|
816
|
+
logger.error(" - No clean instrumental was found (audio separation may have failed)")
|
|
817
|
+
logger.error(" - Path resolution failed after directory change")
|
|
818
|
+
logger.error("")
|
|
819
|
+
logger.error("To investigate:")
|
|
820
|
+
logger.error(" - Check the stems/ directory for: *Backing Vocals*.flac and *Instrumental*.flac")
|
|
821
|
+
logger.error(" - Look for separation errors earlier in the log")
|
|
822
|
+
logger.error(" - Verify audio separation completed without errors")
|
|
823
|
+
logger.error("")
|
|
824
|
+
logger.error("Workarounds:")
|
|
825
|
+
logger.error(" - Re-run with --skip_instrumental_review to auto-select an instrumental")
|
|
826
|
+
logger.error(" - Re-run the full pipeline to regenerate stems")
|
|
827
|
+
logger.error("")
|
|
828
|
+
sys.exit(1)
|
|
829
|
+
return # Explicit return for testing
|
|
830
|
+
|
|
831
|
+
logger.info(f"Selected instrumental file: {selected_instrumental_file}")
|
|
832
|
+
|
|
833
|
+
# Get countdown padding info from track (if vocals were padded, instrumental must match)
|
|
834
|
+
countdown_padding_seconds = None
|
|
835
|
+
if track.get("countdown_padding_added", False):
|
|
836
|
+
countdown_padding_seconds = track.get("countdown_padding_seconds", 3.0)
|
|
837
|
+
logger.info(f"Countdown padding detected: {countdown_padding_seconds}s (will be applied to instrumental if needed)")
|
|
648
838
|
|
|
649
839
|
# Load CDG styles if CDG generation is enabled
|
|
650
840
|
cdg_styles = None
|
|
@@ -690,6 +880,7 @@ async def async_main():
|
|
|
690
880
|
keep_brand_code=getattr(args, 'keep_brand_code', False),
|
|
691
881
|
non_interactive=args.yes,
|
|
692
882
|
selected_instrumental_file=selected_instrumental_file,
|
|
883
|
+
countdown_padding_seconds=countdown_padding_seconds,
|
|
693
884
|
)
|
|
694
885
|
|
|
695
886
|
try:
|