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.
Files changed (32) hide show
  1. karaoke_gen/__init__.py +32 -1
  2. karaoke_gen/audio_fetcher.py +476 -56
  3. karaoke_gen/audio_processor.py +11 -3
  4. karaoke_gen/instrumental_review/server.py +154 -860
  5. karaoke_gen/instrumental_review/static/index.html +1506 -0
  6. karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
  7. karaoke_gen/karaoke_gen.py +114 -1
  8. karaoke_gen/lyrics_processor.py +81 -4
  9. karaoke_gen/utils/bulk_cli.py +3 -0
  10. karaoke_gen/utils/cli_args.py +4 -2
  11. karaoke_gen/utils/gen_cli.py +196 -5
  12. karaoke_gen/utils/remote_cli.py +523 -34
  13. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +4 -1
  14. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +31 -25
  15. lyrics_transcriber/frontend/package.json +1 -1
  16. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  17. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  18. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  19. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  20. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  21. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  22. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  23. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  24. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  25. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
  26. lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
  27. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  28. lyrics_transcriber/review/server.py +5 -5
  29. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  30. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
  31. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
  32. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
@@ -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
- audible_segments=analysis.audible_segments,
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
- tracks = await kprep.process()
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
- tracks = await kprep.process()
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
- # Run instrumental review UI if not skipped
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
- if not getattr(args, 'skip_instrumental_review', False):
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: