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.
- karaoke_gen/__init__.py +32 -1
- karaoke_gen/audio_fetcher.py +1220 -67
- karaoke_gen/audio_processor.py +15 -3
- karaoke_gen/instrumental_review/server.py +154 -860
- karaoke_gen/instrumental_review/static/index.html +1529 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +87 -2
- karaoke_gen/karaoke_gen.py +131 -14
- karaoke_gen/lyrics_processor.py +172 -4
- karaoke_gen/utils/bulk_cli.py +3 -0
- karaoke_gen/utils/cli_args.py +7 -4
- karaoke_gen/utils/gen_cli.py +221 -5
- karaoke_gen/utils/remote_cli.py +786 -43
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/METADATA +109 -4
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/RECORD +37 -31
- lyrics_transcriber/core/controller.py +76 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/App.tsx +6 -4
- lyrics_transcriber/frontend/src/api.ts +25 -10
- 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-BECn1o8Q.js} +1802 -553
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- lyrics_transcriber/output/countdown_processor.py +39 -0
- lyrics_transcriber/review/server.py +5 -5
- lyrics_transcriber/transcribers/audioshake.py +96 -7
- lyrics_transcriber/types.py +14 -12
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.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,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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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:
|