karaoke-gen 0.71.27__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 (39) 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/file_handler.py +192 -0
  5. karaoke_gen/instrumental_review/__init__.py +45 -0
  6. karaoke_gen/instrumental_review/analyzer.py +408 -0
  7. karaoke_gen/instrumental_review/editor.py +322 -0
  8. karaoke_gen/instrumental_review/models.py +171 -0
  9. karaoke_gen/instrumental_review/server.py +475 -0
  10. karaoke_gen/instrumental_review/static/index.html +1506 -0
  11. karaoke_gen/instrumental_review/waveform.py +409 -0
  12. karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
  13. karaoke_gen/karaoke_gen.py +114 -1
  14. karaoke_gen/lyrics_processor.py +81 -4
  15. karaoke_gen/utils/bulk_cli.py +3 -0
  16. karaoke_gen/utils/cli_args.py +9 -2
  17. karaoke_gen/utils/gen_cli.py +379 -2
  18. karaoke_gen/utils/remote_cli.py +1126 -77
  19. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +7 -1
  20. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +38 -26
  21. lyrics_transcriber/correction/anchor_sequence.py +226 -350
  22. lyrics_transcriber/frontend/package.json +1 -1
  23. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  24. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  25. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  26. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  27. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  28. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  29. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  30. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  31. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  32. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
  33. lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
  34. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  35. lyrics_transcriber/review/server.py +5 -5
  36. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  37. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
  38. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
  39. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,9 @@ from .utils import sanitize_filename
11
11
 
12
12
  # Placeholder class or functions for lyrics processing
13
13
  class LyricsProcessor:
14
+ # Standard countdown padding duration used by LyricsTranscriber
15
+ COUNTDOWN_PADDING_SECONDS = 3.0
16
+
14
17
  def __init__(
15
18
  self, logger, style_params_json, lyrics_file, skip_transcription, skip_transcription_review, render_video, subtitle_offset_ms
16
19
  ):
@@ -22,6 +25,60 @@ class LyricsProcessor:
22
25
  self.render_video = render_video
23
26
  self.subtitle_offset_ms = subtitle_offset_ms
24
27
 
28
+ def _detect_countdown_padding_from_lrc(self, lrc_filepath):
29
+ """
30
+ Detect if countdown padding was applied by checking the first lyric timestamp in the LRC file.
31
+
32
+ LRC format timestamps look like: [mm:ss.xx] or [mm:ss.xxx]
33
+ If the first lyric timestamp is >= 3.0 seconds, countdown padding was likely applied.
34
+
35
+ Args:
36
+ lrc_filepath: Path to the LRC file
37
+
38
+ Returns:
39
+ Tuple of (countdown_padding_added: bool, countdown_padding_seconds: float)
40
+ """
41
+ try:
42
+ with open(lrc_filepath, 'r', encoding='utf-8') as f:
43
+ content = f.read()
44
+
45
+ # Find all timestamp patterns in the LRC file
46
+ # LRC timestamps: [mm:ss.xx] or [mm:ss.xxx]
47
+ timestamp_pattern = r'\[(\d{1,2}):(\d{2})\.(\d{2,3})\]'
48
+ matches = re.findall(timestamp_pattern, content)
49
+
50
+ if not matches:
51
+ self.logger.debug("No timestamps found in LRC file")
52
+ return (False, 0.0)
53
+
54
+ # Find the first non-metadata timestamp (metadata like [ar:Artist] doesn't have decimal)
55
+ # We already filtered for decimal timestamps in our pattern
56
+ first_timestamp = matches[0]
57
+ minutes = int(first_timestamp[0])
58
+ seconds = int(first_timestamp[1])
59
+ # Handle both .xx and .xxx formats
60
+ centiseconds = first_timestamp[2]
61
+ if len(centiseconds) == 2:
62
+ milliseconds = int(centiseconds) * 10
63
+ else:
64
+ milliseconds = int(centiseconds)
65
+
66
+ first_lyric_time = minutes * 60 + seconds + milliseconds / 1000.0
67
+
68
+ self.logger.debug(f"First lyric timestamp in LRC: {first_lyric_time:.3f}s")
69
+
70
+ # If first lyric is at or after 3 seconds, countdown padding was applied
71
+ # Use a small buffer (2.5s) to account for songs that naturally start a bit late
72
+ if first_lyric_time >= 2.5:
73
+ self.logger.info(f"Detected countdown padding from LRC: first lyric at {first_lyric_time:.2f}s")
74
+ return (True, self.COUNTDOWN_PADDING_SECONDS)
75
+
76
+ return (False, 0.0)
77
+
78
+ except Exception as e:
79
+ self.logger.warning(f"Failed to detect countdown padding from LRC file: {e}")
80
+ return (False, 0.0)
81
+
25
82
  def find_best_split_point(self, line):
26
83
  """
27
84
  Find the best split point in a line based on the specified criteria.
@@ -138,23 +195,43 @@ class LyricsProcessor:
138
195
  lyrics_video_path = os.path.join(lyrics_dir, f"{sanitized_artist} - {sanitized_title} (With Vocals).mkv")
139
196
  lyrics_lrc_path = os.path.join(lyrics_dir, f"{sanitized_artist} - {sanitized_title} (Karaoke).lrc")
140
197
 
141
- # If files exist in parent directory, return early
198
+ # If files exist in parent directory, return early (but detect countdown padding first)
142
199
  if os.path.exists(parent_video_path) and os.path.exists(parent_lrc_path):
143
- self.logger.info(f"Found existing video and LRC files in parent directory, skipping transcription")
200
+ self.logger.info("Found existing video and LRC files in parent directory, skipping transcription")
201
+
202
+ # Detect countdown padding from existing LRC file
203
+ countdown_padding_added, countdown_padding_seconds = self._detect_countdown_padding_from_lrc(parent_lrc_path)
204
+
205
+ if countdown_padding_added:
206
+ self.logger.info(f"Existing files have countdown padding: {countdown_padding_seconds}s")
207
+
144
208
  return {
145
209
  "lrc_filepath": parent_lrc_path,
146
210
  "ass_filepath": parent_video_path,
211
+ "countdown_padding_added": countdown_padding_added,
212
+ "countdown_padding_seconds": countdown_padding_seconds,
213
+ "padded_audio_filepath": None, # Original padded audio may not exist
147
214
  }
148
215
 
149
- # If files exist in lyrics directory, copy to parent and return
216
+ # If files exist in lyrics directory, copy to parent and return (but detect countdown padding first)
150
217
  if os.path.exists(lyrics_video_path) and os.path.exists(lyrics_lrc_path):
151
- self.logger.info(f"Found existing video and LRC files in lyrics directory, copying to parent")
218
+ self.logger.info("Found existing video and LRC files in lyrics directory, copying to parent")
152
219
  os.makedirs(track_output_dir, exist_ok=True)
153
220
  shutil.copy2(lyrics_video_path, parent_video_path)
154
221
  shutil.copy2(lyrics_lrc_path, parent_lrc_path)
222
+
223
+ # Detect countdown padding from existing LRC file
224
+ countdown_padding_added, countdown_padding_seconds = self._detect_countdown_padding_from_lrc(parent_lrc_path)
225
+
226
+ if countdown_padding_added:
227
+ self.logger.info(f"Existing files have countdown padding: {countdown_padding_seconds}s")
228
+
155
229
  return {
156
230
  "lrc_filepath": parent_lrc_path,
157
231
  "ass_filepath": parent_video_path,
232
+ "countdown_padding_added": countdown_padding_added,
233
+ "countdown_padding_seconds": countdown_padding_seconds,
234
+ "padded_audio_filepath": None, # Original padded audio may not exist
158
235
  }
159
236
 
160
237
  # Create lyrics directory if it doesn't exist
@@ -19,6 +19,9 @@ from karaoke_gen.karaoke_finalise import KaraokeFinalise
19
19
  # Global logger
20
20
  logger = logging.getLogger(__name__)
21
21
  logger.setLevel(logging.INFO) # Set initial log level
22
+ # Prevent log propagation to root logger to avoid duplicate logs
23
+ # when external packages (like lyrics_converter) configure root logger handlers
24
+ logger.propagate = False
22
25
 
23
26
 
24
27
  async def process_track_prep(row, args, logger, log_formatter):
@@ -208,6 +208,11 @@ def create_parser(prog: str = "karaoke-gen") -> argparse.ArgumentParser:
208
208
  default="flac",
209
209
  help="Optional: format / file extension for instrumental track to use for remux (default: %(default)s). Example: --instrumental_format=mp3",
210
210
  )
211
+ audio_group.add_argument(
212
+ "--skip_instrumental_review",
213
+ action="store_true",
214
+ help="Optional: Skip the interactive instrumental review UI and use the old numeric selection. Example: --skip_instrumental_review",
215
+ )
211
216
 
212
217
  # Lyrics Configuration
213
218
  lyrics_group = parser.add_argument_group("Lyrics Configuration")
@@ -347,8 +352,10 @@ def create_parser(prog: str = "karaoke-gen") -> argparse.ArgumentParser:
347
352
  )
348
353
  remote_group.add_argument(
349
354
  "--review-ui-url",
350
- default=os.environ.get('REVIEW_UI_URL', 'https://lyrics.nomadkaraoke.com'),
351
- help="Lyrics review UI URL (default: https://lyrics.nomadkaraoke.com)",
355
+ default=os.environ.get('REVIEW_UI_URL', os.environ.get('LYRICS_REVIEW_UI_URL', 'https://lyrics.nomadkaraoke.com')),
356
+ help="Lyrics review UI URL. Default: 'https://lyrics.nomadkaraoke.com'. "
357
+ "Use 'http://localhost:5173' for Vite dev server during development. "
358
+ "(env: REVIEW_UI_URL or LYRICS_REVIEW_UI_URL)",
352
359
  )
353
360
  remote_group.add_argument(
354
361
  "--poll-interval",
@@ -14,14 +14,295 @@ import sys
14
14
  import json
15
15
  import asyncio
16
16
  import time
17
+ import glob
17
18
  import pyperclip
18
19
  from karaoke_gen import KaraokePrep
19
20
  from karaoke_gen.karaoke_finalise import KaraokeFinalise
21
+ from karaoke_gen.audio_fetcher import UserCancelledError
22
+ from karaoke_gen.instrumental_review import (
23
+ AudioAnalyzer,
24
+ WaveformGenerator,
25
+ InstrumentalReviewServer,
26
+ )
20
27
  from .cli_args import create_parser, process_style_overrides, is_url, is_file
21
28
 
22
29
 
30
+ def _resolve_path_for_cwd(path: str, track_dir: str) -> str:
31
+ """
32
+ Resolve a path that may have been created relative to the original working directory.
33
+
34
+ After os.chdir(track_dir), paths like './TrackDir/stems/file.flac' become invalid.
35
+ This function converts such paths to work from the new current directory.
36
+
37
+ Args:
38
+ path: The path to resolve (may be relative or absolute)
39
+ track_dir: The track directory we've chdir'd into
40
+
41
+ Returns:
42
+ A path that's valid from the current working directory
43
+ """
44
+ if os.path.isabs(path):
45
+ return path
46
+
47
+ # Normalize both paths for comparison
48
+ norm_path = os.path.normpath(path)
49
+ norm_track_dir = os.path.normpath(track_dir)
50
+
51
+ # If path starts with track_dir, strip it to get the relative path from within track_dir
52
+ # e.g., './Four Lanes Male Choir - The White Rose/stems/file.flac' -> 'stems/file.flac'
53
+ if norm_path.startswith(norm_track_dir + os.sep):
54
+ return norm_path[len(norm_track_dir) + 1:]
55
+ elif norm_path.startswith(norm_track_dir):
56
+ return norm_path[len(norm_track_dir):].lstrip(os.sep) or '.'
57
+
58
+ # If path doesn't start with track_dir, it might already be relative to track_dir
59
+ # or it's a path that doesn't need transformation
60
+ return path
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
+
146
+ def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
147
+ """
148
+ Run the instrumental review UI to let user select the best instrumental track.
149
+
150
+ This analyzes the backing vocals, generates a waveform, and opens a browser
151
+ with an interactive UI for reviewing and selecting the instrumental.
152
+
153
+ Args:
154
+ track: The track dictionary from KaraokePrep containing separated audio info
155
+ logger: Logger instance
156
+
157
+ Returns:
158
+ Path to the selected instrumental file, or None to use the old numeric selection
159
+ """
160
+ track_dir = track.get("track_output_dir", ".")
161
+ artist = track.get("artist", "")
162
+ title = track.get("title", "")
163
+ base_name = f"{artist} - {title}"
164
+
165
+ # Get separation results
166
+ separated = track.get("separated_audio", {})
167
+ if not separated:
168
+ logger.info("No separated audio found, skipping instrumental review UI")
169
+ return None
170
+
171
+ # Find the backing vocals file
172
+ # Note: Paths in separated_audio may be relative to the original working directory,
173
+ # but we've already chdir'd into track_dir. Use _resolve_path_for_cwd to fix paths.
174
+ backing_vocals_path = None
175
+ backing_vocals_result = separated.get("backing_vocals", {})
176
+ for model, paths in backing_vocals_result.items():
177
+ if paths.get("backing_vocals"):
178
+ backing_vocals_path = _resolve_path_for_cwd(paths["backing_vocals"], track_dir)
179
+ break
180
+
181
+ if not backing_vocals_path or not os.path.exists(backing_vocals_path):
182
+ logger.info("No backing vocals file found, skipping instrumental review UI")
183
+ return None
184
+
185
+ # Find the clean instrumental file
186
+ clean_result = separated.get("clean_instrumental", {})
187
+ raw_clean_path = clean_result.get("instrumental")
188
+ clean_instrumental_path = _resolve_path_for_cwd(raw_clean_path, track_dir) if raw_clean_path else None
189
+
190
+ if not clean_instrumental_path or not os.path.exists(clean_instrumental_path):
191
+ logger.info("No clean instrumental file found, skipping instrumental review UI")
192
+ return None
193
+
194
+ # Find the combined instrumental (with backing vocals) file - these have "(Padded)" suffix if padded
195
+ combined_result = separated.get("combined_instrumentals", {})
196
+ with_backing_path = None
197
+ for model, path in combined_result.items():
198
+ resolved_path = _resolve_path_for_cwd(path, track_dir) if path else None
199
+ if resolved_path and os.path.exists(resolved_path):
200
+ with_backing_path = resolved_path
201
+ break
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
+
212
+ try:
213
+ logger.info("=== Starting Instrumental Review ===")
214
+ logger.info(f"Analyzing backing vocals: {backing_vocals_path}")
215
+
216
+ # Analyze backing vocals
217
+ analyzer = AudioAnalyzer()
218
+ analysis = analyzer.analyze(backing_vocals_path)
219
+
220
+ logger.info(f"Analysis complete:")
221
+ logger.info(f" Has audible content: {analysis.has_audible_content}")
222
+ logger.info(f" Total duration: {analysis.total_duration_seconds:.1f}s")
223
+ logger.info(f" Audible segments: {len(analysis.audible_segments)}")
224
+ logger.info(f" Recommendation: {analysis.recommended_selection.value}")
225
+
226
+ # Generate waveform
227
+ # Note: We're already in track_dir after chdir, so use current directory
228
+ logger.info("Generating waveform visualization...")
229
+ waveform_generator = WaveformGenerator()
230
+ waveform_path = f"{base_name} (Backing Vocals Waveform).png"
231
+ waveform_generator.generate(
232
+ audio_path=backing_vocals_path,
233
+ output_path=waveform_path,
234
+ segments=analysis.audible_segments,
235
+ )
236
+
237
+ # Start the review server
238
+ # Note: We're already in track_dir after chdir, so output_dir is "."
239
+ logger.info("Starting instrumental review UI...")
240
+ server = InstrumentalReviewServer(
241
+ output_dir=".",
242
+ base_name=base_name,
243
+ analysis=analysis,
244
+ waveform_path=waveform_path,
245
+ backing_vocals_path=backing_vocals_path,
246
+ clean_instrumental_path=clean_instrumental_path,
247
+ with_backing_path=with_backing_path,
248
+ original_audio_path=original_audio_path,
249
+ )
250
+
251
+ # Start server and open browser, wait for selection
252
+ server.start_and_open_browser()
253
+
254
+ logger.info("Waiting for instrumental selection in browser...")
255
+ logger.info("(Close the browser tab or press Ctrl+C to cancel)")
256
+
257
+ try:
258
+ # Wait for user selection (blocking)
259
+ server._selection_event.wait()
260
+ selection = server.get_selection()
261
+
262
+ logger.info(f"User selected: {selection}")
263
+
264
+ # Stop the server
265
+ server.stop()
266
+
267
+ # Return the selected instrumental path
268
+ if selection == "clean":
269
+ return clean_instrumental_path
270
+ elif selection == "with_backing":
271
+ return with_backing_path
272
+ elif selection == "custom":
273
+ custom_path = server.get_custom_instrumental_path()
274
+ if custom_path and os.path.exists(custom_path):
275
+ return custom_path
276
+ else:
277
+ logger.warning("Custom instrumental not found, falling back to clean")
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
286
+ else:
287
+ logger.warning(f"Unknown selection: {selection}, falling back to numeric selection")
288
+ return None
289
+
290
+ except KeyboardInterrupt:
291
+ logger.info("Instrumental review cancelled by user")
292
+ server.stop()
293
+ return None
294
+
295
+ except Exception as e:
296
+ logger.error(f"Error during instrumental review: {e}")
297
+ logger.info("Falling back to numeric selection")
298
+ return None
299
+
300
+
23
301
  async def async_main():
24
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
25
306
  log_handler = logging.StreamHandler()
26
307
  log_formatter = logging.Formatter(fmt="%(asctime)s.%(msecs)03d - %(levelname)s - %(module)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
27
308
  log_handler.setFormatter(log_formatter)
@@ -31,6 +312,11 @@ async def async_main():
31
312
  parser = create_parser(prog="karaoke-gen")
32
313
  args = parser.parse_args()
33
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
+
34
320
  # Process style overrides
35
321
  try:
36
322
  style_overrides = process_style_overrides(args.style_override, logger)
@@ -122,7 +408,21 @@ async def async_main():
122
408
  kprep.input_media = input_audio_wav
123
409
 
124
410
  # Run KaraokePrep
125
- 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
126
426
 
127
427
  # Load CDG styles if CDG generation is enabled
128
428
  cdg_styles = None
@@ -441,7 +741,21 @@ async def async_main():
441
741
  kprep = kprep_coroutine
442
742
 
443
743
  # Create final tracks data structure
444
- 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
445
759
 
446
760
  # If prep-only mode, we're done
447
761
  if args.prep_only:
@@ -461,6 +775,67 @@ async def async_main():
461
775
  logger.info(f"Changing to directory: {track_dir}")
462
776
  os.chdir(track_dir)
463
777
 
778
+ # Select instrumental file - either via web UI or auto-selection
779
+ # This ALWAYS produces a selected file - no silent fallback to legacy code
780
+ selected_instrumental_file = None
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
799
+ selected_instrumental_file = run_instrumental_review(
800
+ track=track,
801
+ logger=logger,
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)")
838
+
464
839
  # Load CDG styles if CDG generation is enabled
465
840
  cdg_styles = None
466
841
  if args.enable_cdg:
@@ -504,6 +879,8 @@ async def async_main():
504
879
  cdg_styles=cdg_styles,
505
880
  keep_brand_code=getattr(args, 'keep_brand_code', False),
506
881
  non_interactive=args.yes,
882
+ selected_instrumental_file=selected_instrumental_file,
883
+ countdown_padding_seconds=countdown_padding_seconds,
507
884
  )
508
885
 
509
886
  try: