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
@@ -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,20 @@ 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
+ # Only set this if the user explicitly wants to use a dev server (e.g., http://localhost:5173)
317
+ # By default, let the ReviewServer use its bundled local frontend (served from lyrics_transcriber/frontend/)
318
+ # This enables local iteration on the frontend without redeploying
319
+ if hasattr(args, 'review_ui_url') and args.review_ui_url:
320
+ # Check if user provided a custom value (not the default hosted URL)
321
+ default_hosted_urls = [
322
+ 'https://gen.nomadkaraoke.com/lyrics',
323
+ 'https://lyrics.nomadkaraoke.com'
324
+ ]
325
+ if args.review_ui_url.rstrip('/') not in [url.rstrip('/') for url in default_hosted_urls]:
326
+ # User explicitly wants a specific URL (e.g., Vite dev server)
327
+ os.environ['LYRICS_REVIEW_UI_URL'] = args.review_ui_url
328
+
211
329
  # Process style overrides
212
330
  try:
213
331
  style_overrides = process_style_overrides(args.style_override, logger)
@@ -299,7 +417,21 @@ async def async_main():
299
417
  kprep.input_media = input_audio_wav
300
418
 
301
419
  # Run KaraokePrep
302
- tracks = await kprep.process()
420
+ try:
421
+ tracks = await kprep.process()
422
+ except UserCancelledError:
423
+ logger.info("Operation cancelled by user")
424
+ return
425
+ except KeyboardInterrupt:
426
+ logger.info("Operation cancelled by user (Ctrl+C)")
427
+ return
428
+
429
+ # Filter out None tracks (can happen if prep failed for some tracks)
430
+ tracks = [t for t in tracks if t is not None] if tracks else []
431
+
432
+ if not tracks:
433
+ logger.warning("No tracks to process")
434
+ return
303
435
 
304
436
  # Load CDG styles if CDG generation is enabled
305
437
  cdg_styles = None
@@ -618,7 +750,21 @@ async def async_main():
618
750
  kprep = kprep_coroutine
619
751
 
620
752
  # Create final tracks data structure
621
- tracks = await kprep.process()
753
+ try:
754
+ tracks = await kprep.process()
755
+ except UserCancelledError:
756
+ logger.info("Operation cancelled by user")
757
+ return
758
+ except (KeyboardInterrupt, asyncio.CancelledError):
759
+ logger.info("Operation cancelled by user (Ctrl+C)")
760
+ return
761
+
762
+ # Filter out None tracks (can happen if prep failed for some tracks)
763
+ tracks = [t for t in tracks if t is not None] if tracks else []
764
+
765
+ if not tracks:
766
+ logger.warning("No tracks to process")
767
+ return
622
768
 
623
769
  # If prep-only mode, we're done
624
770
  if args.prep_only:
@@ -638,13 +784,82 @@ async def async_main():
638
784
  logger.info(f"Changing to directory: {track_dir}")
639
785
  os.chdir(track_dir)
640
786
 
641
- # Run instrumental review UI if not skipped
787
+ # Select instrumental file - either via web UI, auto-selection, or custom instrumental
788
+ # This ALWAYS produces a selected file - no silent fallback to legacy code
642
789
  selected_instrumental_file = None
643
- if not getattr(args, 'skip_instrumental_review', False):
790
+ skip_review = getattr(args, 'skip_instrumental_review', False)
791
+
792
+ # Check if a custom instrumental was provided (via --existing_instrumental)
793
+ # In this case, the instrumental is already chosen - skip review entirely
794
+ separated_audio = track.get("separated_audio", {})
795
+ custom_instrumental = separated_audio.get("Custom", {}).get("instrumental")
796
+
797
+ if custom_instrumental:
798
+ # Custom instrumental was provided - use it directly, no review needed
799
+ resolved_path = _resolve_path_for_cwd(custom_instrumental, track_dir)
800
+ if os.path.exists(resolved_path):
801
+ logger.info(f"Using custom instrumental (--existing_instrumental): {resolved_path}")
802
+ selected_instrumental_file = resolved_path
803
+ else:
804
+ logger.error(f"Custom instrumental file not found: {resolved_path}")
805
+ logger.error("The file may have been moved or deleted after preparation.")
806
+ sys.exit(1)
807
+ return # Explicit return for testing
808
+ elif skip_review:
809
+ # Auto-select instrumental when review is skipped (non-interactive mode)
810
+ logger.info("Instrumental review skipped (--skip_instrumental_review), auto-selecting instrumental file...")
811
+ try:
812
+ selected_instrumental_file = auto_select_instrumental(
813
+ track=track,
814
+ track_dir=track_dir,
815
+ logger=logger,
816
+ )
817
+ except FileNotFoundError as e:
818
+ logger.error(f"Failed to auto-select instrumental: {e}")
819
+ logger.error("Check that audio separation completed successfully.")
820
+ sys.exit(1)
821
+ return # Explicit return for testing
822
+ else:
823
+ # Run instrumental review web UI
644
824
  selected_instrumental_file = run_instrumental_review(
645
825
  track=track,
646
826
  logger=logger,
647
827
  )
828
+
829
+ # If instrumental review failed/returned None, show error and exit
830
+ # NO SILENT FALLBACK - we want to know if the new flow has issues
831
+ if selected_instrumental_file is None:
832
+ logger.error("")
833
+ logger.error("=" * 70)
834
+ logger.error("INSTRUMENTAL SELECTION FAILED")
835
+ logger.error("=" * 70)
836
+ logger.error("")
837
+ logger.error("The instrumental review UI could not find the required files.")
838
+ logger.error("")
839
+ logger.error("Common causes:")
840
+ logger.error(" - No backing vocals file was found (check stems/ directory)")
841
+ logger.error(" - No clean instrumental was found (audio separation may have failed)")
842
+ logger.error(" - Path resolution failed after directory change")
843
+ logger.error("")
844
+ logger.error("To investigate:")
845
+ logger.error(" - Check the stems/ directory for: *Backing Vocals*.flac and *Instrumental*.flac")
846
+ logger.error(" - Look for separation errors earlier in the log")
847
+ logger.error(" - Verify audio separation completed without errors")
848
+ logger.error("")
849
+ logger.error("Workarounds:")
850
+ logger.error(" - Re-run with --skip_instrumental_review to auto-select an instrumental")
851
+ logger.error(" - Re-run the full pipeline to regenerate stems")
852
+ logger.error("")
853
+ sys.exit(1)
854
+ return # Explicit return for testing
855
+
856
+ logger.info(f"Selected instrumental file: {selected_instrumental_file}")
857
+
858
+ # Get countdown padding info from track (if vocals were padded, instrumental must match)
859
+ countdown_padding_seconds = None
860
+ if track.get("countdown_padding_added", False):
861
+ countdown_padding_seconds = track.get("countdown_padding_seconds", 3.0)
862
+ logger.info(f"Countdown padding detected: {countdown_padding_seconds}s (will be applied to instrumental if needed)")
648
863
 
649
864
  # Load CDG styles if CDG generation is enabled
650
865
  cdg_styles = None
@@ -690,6 +905,7 @@ async def async_main():
690
905
  keep_brand_code=getattr(args, 'keep_brand_code', False),
691
906
  non_interactive=args.yes,
692
907
  selected_instrumental_file=selected_instrumental_file,
908
+ countdown_padding_seconds=countdown_padding_seconds,
693
909
  )
694
910
 
695
911
  try: