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.
- karaoke_gen/__init__.py +32 -1
- karaoke_gen/audio_fetcher.py +476 -56
- karaoke_gen/audio_processor.py +11 -3
- karaoke_gen/file_handler.py +192 -0
- karaoke_gen/instrumental_review/__init__.py +45 -0
- karaoke_gen/instrumental_review/analyzer.py +408 -0
- karaoke_gen/instrumental_review/editor.py +322 -0
- karaoke_gen/instrumental_review/models.py +171 -0
- karaoke_gen/instrumental_review/server.py +475 -0
- karaoke_gen/instrumental_review/static/index.html +1506 -0
- karaoke_gen/instrumental_review/waveform.py +409 -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 +9 -2
- karaoke_gen/utils/gen_cli.py +379 -2
- karaoke_gen/utils/remote_cli.py +1126 -77
- {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +7 -1
- {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +38 -26
- lyrics_transcriber/correction/anchor_sequence.py +226 -350
- 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.27.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/lyrics_processor.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
karaoke_gen/utils/bulk_cli.py
CHANGED
|
@@ -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):
|
karaoke_gen/utils/cli_args.py
CHANGED
|
@@ -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
|
|
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",
|
karaoke_gen/utils/gen_cli.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|