karaoke-gen 0.50.0__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.
Potentially problematic release.
This version of karaoke-gen might be problematic. Click here for more details.
- karaoke_gen-0.50.0.dist-info/LICENSE +21 -0
- karaoke_gen-0.50.0.dist-info/METADATA +140 -0
- karaoke_gen-0.50.0.dist-info/RECORD +23 -0
- karaoke_gen-0.50.0.dist-info/WHEEL +4 -0
- karaoke_gen-0.50.0.dist-info/entry_points.txt +4 -0
- karaoke_prep/__init__.py +1 -0
- karaoke_prep/audio_processor.py +396 -0
- karaoke_prep/config.py +134 -0
- karaoke_prep/file_handler.py +186 -0
- karaoke_prep/karaoke_finalise/__init__.py +1 -0
- karaoke_prep/karaoke_finalise/karaoke_finalise.py +1163 -0
- karaoke_prep/karaoke_prep.py +687 -0
- karaoke_prep/lyrics_processor.py +225 -0
- karaoke_prep/metadata.py +105 -0
- karaoke_prep/resources/AvenirNext-Bold.ttf +0 -0
- karaoke_prep/resources/Montserrat-Bold.ttf +0 -0
- karaoke_prep/resources/Oswald-Bold.ttf +0 -0
- karaoke_prep/resources/Oswald-SemiBold.ttf +0 -0
- karaoke_prep/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- karaoke_prep/utils/__init__.py +18 -0
- karaoke_prep/utils/bulk_cli.py +483 -0
- karaoke_prep/utils/gen_cli.py +873 -0
- karaoke_prep/video_generator.py +424 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import re
|
|
4
|
+
import glob
|
|
5
|
+
import logging
|
|
6
|
+
import tempfile
|
|
7
|
+
import shutil
|
|
8
|
+
import asyncio
|
|
9
|
+
import signal
|
|
10
|
+
import time
|
|
11
|
+
import fcntl
|
|
12
|
+
import errno
|
|
13
|
+
import psutil
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
import importlib.resources as pkg_resources
|
|
16
|
+
import json
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
from .config import (
|
|
19
|
+
load_style_params,
|
|
20
|
+
setup_title_format,
|
|
21
|
+
setup_end_format,
|
|
22
|
+
get_video_durations,
|
|
23
|
+
get_existing_images,
|
|
24
|
+
setup_ffmpeg_command,
|
|
25
|
+
)
|
|
26
|
+
from .metadata import extract_info_for_online_media, parse_track_metadata
|
|
27
|
+
from .file_handler import FileHandler
|
|
28
|
+
from .audio_processor import AudioProcessor
|
|
29
|
+
from .lyrics_processor import LyricsProcessor
|
|
30
|
+
from .video_generator import VideoGenerator
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class KaraokePrep:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
# Basic inputs
|
|
37
|
+
input_media=None,
|
|
38
|
+
artist=None,
|
|
39
|
+
title=None,
|
|
40
|
+
filename_pattern=None,
|
|
41
|
+
# Logging & Debugging
|
|
42
|
+
dry_run=False,
|
|
43
|
+
logger=None,
|
|
44
|
+
log_level=logging.DEBUG,
|
|
45
|
+
log_formatter=None,
|
|
46
|
+
render_bounding_boxes=False,
|
|
47
|
+
# Input/Output Configuration
|
|
48
|
+
output_dir=".",
|
|
49
|
+
create_track_subfolders=False,
|
|
50
|
+
lossless_output_format="FLAC",
|
|
51
|
+
output_png=True,
|
|
52
|
+
output_jpg=True,
|
|
53
|
+
# Audio Processing Configuration
|
|
54
|
+
clean_instrumental_model="model_bs_roformer_ep_317_sdr_12.9755.ckpt",
|
|
55
|
+
backing_vocals_models=["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"],
|
|
56
|
+
other_stems_models=["htdemucs_6s.yaml"],
|
|
57
|
+
model_file_dir=os.path.join(tempfile.gettempdir(), "audio-separator-models"),
|
|
58
|
+
existing_instrumental=None,
|
|
59
|
+
# Lyrics Configuration
|
|
60
|
+
lyrics_artist=None,
|
|
61
|
+
lyrics_title=None,
|
|
62
|
+
lyrics_file=None,
|
|
63
|
+
skip_lyrics=False,
|
|
64
|
+
skip_transcription=False,
|
|
65
|
+
skip_transcription_review=False,
|
|
66
|
+
render_video=True,
|
|
67
|
+
subtitle_offset_ms=0,
|
|
68
|
+
# Style Configuration
|
|
69
|
+
style_params_json=None,
|
|
70
|
+
# Add the new parameter
|
|
71
|
+
skip_separation=False,
|
|
72
|
+
):
|
|
73
|
+
self.log_level = log_level
|
|
74
|
+
self.log_formatter = log_formatter
|
|
75
|
+
|
|
76
|
+
if logger is None:
|
|
77
|
+
self.logger = logging.getLogger(__name__)
|
|
78
|
+
self.logger.setLevel(log_level)
|
|
79
|
+
|
|
80
|
+
self.log_handler = logging.StreamHandler()
|
|
81
|
+
|
|
82
|
+
if self.log_formatter is None:
|
|
83
|
+
self.log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s - %(message)s")
|
|
84
|
+
|
|
85
|
+
self.log_handler.setFormatter(self.log_formatter)
|
|
86
|
+
self.logger.addHandler(self.log_handler)
|
|
87
|
+
else:
|
|
88
|
+
self.logger = logger
|
|
89
|
+
|
|
90
|
+
self.logger.debug(f"KaraokePrep instantiating with input_media: {input_media} artist: {artist} title: {title}")
|
|
91
|
+
|
|
92
|
+
self.dry_run = dry_run
|
|
93
|
+
self.extractor = None # Will be set later based on source (Original or yt-dlp extractor)
|
|
94
|
+
self.media_id = None # Will be set by parse_track_metadata if applicable
|
|
95
|
+
self.url = None # Will be set by parse_track_metadata if applicable
|
|
96
|
+
self.input_media = input_media
|
|
97
|
+
self.artist = artist
|
|
98
|
+
self.title = title
|
|
99
|
+
self.filename_pattern = filename_pattern
|
|
100
|
+
|
|
101
|
+
# Input/Output - Keep these as they might be needed for logic outside handlers or passed to multiple handlers
|
|
102
|
+
self.output_dir = output_dir
|
|
103
|
+
self.lossless_output_format = lossless_output_format.lower()
|
|
104
|
+
self.create_track_subfolders = create_track_subfolders
|
|
105
|
+
self.output_png = output_png
|
|
106
|
+
self.output_jpg = output_jpg
|
|
107
|
+
|
|
108
|
+
# Lyrics Config - Keep needed ones
|
|
109
|
+
self.lyrics_artist = lyrics_artist
|
|
110
|
+
self.lyrics_title = lyrics_title
|
|
111
|
+
self.lyrics_file = lyrics_file # Passed to LyricsProcessor
|
|
112
|
+
self.skip_lyrics = skip_lyrics # Used in prep_single_track logic
|
|
113
|
+
self.skip_transcription = skip_transcription # Passed to LyricsProcessor
|
|
114
|
+
self.skip_transcription_review = skip_transcription_review # Passed to LyricsProcessor
|
|
115
|
+
self.render_video = render_video # Passed to LyricsProcessor
|
|
116
|
+
self.subtitle_offset_ms = subtitle_offset_ms # Passed to LyricsProcessor
|
|
117
|
+
|
|
118
|
+
# Audio Config - Keep needed ones
|
|
119
|
+
self.existing_instrumental = existing_instrumental # Used in prep_single_track logic
|
|
120
|
+
self.skip_separation = skip_separation # Used in prep_single_track logic
|
|
121
|
+
self.model_file_dir = model_file_dir # Passed to AudioProcessor
|
|
122
|
+
|
|
123
|
+
# Style Config - Keep needed ones
|
|
124
|
+
self.render_bounding_boxes = render_bounding_boxes # Passed to VideoGenerator
|
|
125
|
+
self.style_params_json = style_params_json # Passed to LyricsProcessor
|
|
126
|
+
|
|
127
|
+
# Load style parameters using the config module
|
|
128
|
+
self.style_params = load_style_params(self.style_params_json, self.logger)
|
|
129
|
+
|
|
130
|
+
# Set up title and end formats using the config module
|
|
131
|
+
self.title_format = setup_title_format(self.style_params)
|
|
132
|
+
self.end_format = setup_end_format(self.style_params)
|
|
133
|
+
|
|
134
|
+
# Get video durations and existing images using the config module
|
|
135
|
+
self.intro_video_duration, self.end_video_duration = get_video_durations(self.style_params)
|
|
136
|
+
self.existing_title_image, self.existing_end_image = get_existing_images(self.style_params)
|
|
137
|
+
|
|
138
|
+
# Set up ffmpeg command using the config module
|
|
139
|
+
self.ffmpeg_base_command = setup_ffmpeg_command(self.log_level)
|
|
140
|
+
|
|
141
|
+
# Instantiate Handlers
|
|
142
|
+
self.file_handler = FileHandler(
|
|
143
|
+
logger=self.logger,
|
|
144
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
145
|
+
create_track_subfolders=self.create_track_subfolders,
|
|
146
|
+
dry_run=self.dry_run,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
self.audio_processor = AudioProcessor(
|
|
150
|
+
logger=self.logger,
|
|
151
|
+
log_level=self.log_level,
|
|
152
|
+
log_formatter=self.log_formatter,
|
|
153
|
+
model_file_dir=self.model_file_dir,
|
|
154
|
+
lossless_output_format=self.lossless_output_format,
|
|
155
|
+
clean_instrumental_model=clean_instrumental_model, # Passed directly from args
|
|
156
|
+
backing_vocals_models=backing_vocals_models, # Passed directly from args
|
|
157
|
+
other_stems_models=other_stems_models, # Passed directly from args
|
|
158
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self.lyrics_processor = LyricsProcessor(
|
|
162
|
+
logger=self.logger,
|
|
163
|
+
style_params_json=self.style_params_json,
|
|
164
|
+
lyrics_file=self.lyrics_file,
|
|
165
|
+
skip_transcription=self.skip_transcription,
|
|
166
|
+
skip_transcription_review=self.skip_transcription_review,
|
|
167
|
+
render_video=self.render_video,
|
|
168
|
+
subtitle_offset_ms=self.subtitle_offset_ms,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.video_generator = VideoGenerator(
|
|
172
|
+
logger=self.logger,
|
|
173
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
174
|
+
render_bounding_boxes=self.render_bounding_boxes,
|
|
175
|
+
output_png=self.output_png,
|
|
176
|
+
output_jpg=self.output_jpg,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
self.logger.debug(f"Initialized title_format with extra_text: {self.title_format['extra_text']}")
|
|
180
|
+
self.logger.debug(f"Initialized title_format with extra_text_region: {self.title_format['extra_text_region']}")
|
|
181
|
+
|
|
182
|
+
self.logger.debug(f"Initialized end_format with extra_text: {self.end_format['extra_text']}")
|
|
183
|
+
self.logger.debug(f"Initialized end_format with extra_text_region: {self.end_format['extra_text_region']}")
|
|
184
|
+
|
|
185
|
+
self.extracted_info = None # Will be populated by extract_info_for_online_media if needed
|
|
186
|
+
self.persistent_artist = None # Used for playlists
|
|
187
|
+
|
|
188
|
+
self.logger.debug(f"KaraokePrep lossless_output_format: {self.lossless_output_format}")
|
|
189
|
+
|
|
190
|
+
# Use FileHandler method to check/create output dir
|
|
191
|
+
if not os.path.exists(self.output_dir):
|
|
192
|
+
self.logger.debug(f"Overall output dir {self.output_dir} did not exist, creating")
|
|
193
|
+
os.makedirs(self.output_dir)
|
|
194
|
+
else:
|
|
195
|
+
self.logger.debug(f"Overall output dir {self.output_dir} already exists")
|
|
196
|
+
|
|
197
|
+
# Compatibility methods for tests - these call the new functions in metadata.py
|
|
198
|
+
def extract_info_for_online_media(self, input_url=None, input_artist=None, input_title=None):
|
|
199
|
+
"""Compatibility method that calls the function in metadata.py"""
|
|
200
|
+
self.extracted_info = extract_info_for_online_media(input_url, input_artist, input_title, self.logger)
|
|
201
|
+
return self.extracted_info
|
|
202
|
+
|
|
203
|
+
def parse_single_track_metadata(self, input_artist, input_title):
|
|
204
|
+
"""Compatibility method that calls the function in metadata.py"""
|
|
205
|
+
metadata_result = parse_track_metadata(self.extracted_info, input_artist, input_title, self.persistent_artist, self.logger)
|
|
206
|
+
self.url = metadata_result["url"]
|
|
207
|
+
self.extractor = metadata_result["extractor"]
|
|
208
|
+
self.media_id = metadata_result["media_id"]
|
|
209
|
+
self.artist = metadata_result["artist"]
|
|
210
|
+
self.title = metadata_result["title"]
|
|
211
|
+
|
|
212
|
+
async def prep_single_track(self):
|
|
213
|
+
# Add signal handler at the start
|
|
214
|
+
loop = asyncio.get_running_loop()
|
|
215
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
216
|
+
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self.shutdown(s)))
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
self.logger.info(f"Preparing single track: {self.artist} - {self.title}")
|
|
220
|
+
|
|
221
|
+
# Determine extractor early based on input type
|
|
222
|
+
# Assume self.extractor, self.url, self.media_id etc. are set by process() before calling this
|
|
223
|
+
if self.input_media and os.path.isfile(self.input_media):
|
|
224
|
+
if not self.extractor: # If extractor wasn't somehow set before (e.g., direct call)
|
|
225
|
+
self.extractor = "Original"
|
|
226
|
+
elif self.url: # If it's a URL (set by process)
|
|
227
|
+
if not self.extractor: # Should have been set by parse_track_metadata in process()
|
|
228
|
+
self.logger.warning("Extractor not set before prep_single_track for URL, attempting fallback logic.")
|
|
229
|
+
# Fallback logic (less ideal, relies on potentially missing info)
|
|
230
|
+
if self.extracted_info and self.extracted_info.get('extractor'):
|
|
231
|
+
self.extractor = self.extracted_info['extractor']
|
|
232
|
+
elif self.media_id: # Try to guess based on ID format
|
|
233
|
+
# Basic youtube id check
|
|
234
|
+
if re.match(r'^[a-zA-Z0-9_-]{11}$', self.media_id):
|
|
235
|
+
self.extractor = "youtube"
|
|
236
|
+
else:
|
|
237
|
+
self.extractor = "UnknownSource" # Fallback if ID doesn't look like youtube
|
|
238
|
+
else:
|
|
239
|
+
self.extractor = "UnknownSource" # Final fallback
|
|
240
|
+
self.logger.info(f"Fallback extractor set to: {self.extractor}")
|
|
241
|
+
elif self.input_media: # Not a file, not a URL -> maybe a direct URL string?
|
|
242
|
+
self.logger.warning(f"Input media '{self.input_media}' is not a file and self.url was not set. Attempting to treat as URL.")
|
|
243
|
+
# This path requires calling extract/parse again, less efficient
|
|
244
|
+
try:
|
|
245
|
+
extracted = extract_info_for_online_media(self.input_media, self.artist, self.title, self.logger)
|
|
246
|
+
if extracted:
|
|
247
|
+
metadata_result = parse_track_metadata(
|
|
248
|
+
extracted, self.artist, self.title, self.persistent_artist, self.logger
|
|
249
|
+
)
|
|
250
|
+
self.url = metadata_result["url"]
|
|
251
|
+
self.extractor = metadata_result["extractor"]
|
|
252
|
+
self.media_id = metadata_result["media_id"]
|
|
253
|
+
self.artist = metadata_result["artist"]
|
|
254
|
+
self.title = metadata_result["title"]
|
|
255
|
+
self.logger.info(f"Successfully extracted metadata within prep_single_track for {self.input_media}")
|
|
256
|
+
else:
|
|
257
|
+
self.logger.error(f"Could not extract info for {self.input_media} within prep_single_track.")
|
|
258
|
+
self.extractor = "ErrorExtracting"
|
|
259
|
+
return None # Cannot proceed without metadata
|
|
260
|
+
except Exception as meta_exc:
|
|
261
|
+
self.logger.error(f"Error during metadata extraction/parsing within prep_single_track: {meta_exc}")
|
|
262
|
+
self.extractor = "ErrorParsing"
|
|
263
|
+
return None # Cannot proceed
|
|
264
|
+
else:
|
|
265
|
+
# If it's neither file nor URL, and input_media is None, check for existing files
|
|
266
|
+
# This path is mainly for the case where files exist from previous run
|
|
267
|
+
# We still need artist/title for filename generation
|
|
268
|
+
if not self.artist or not self.title:
|
|
269
|
+
self.logger.error("Cannot determine output path without artist/title when input_media is None and not a URL.")
|
|
270
|
+
return None
|
|
271
|
+
self.logger.info("Input media is None, assuming check for existing files based on artist/title.")
|
|
272
|
+
# We need a nominal extractor for filename matching if files exist
|
|
273
|
+
# Let's default to 'UnknownExisting' or try to infer if possible later
|
|
274
|
+
if not self.extractor:
|
|
275
|
+
self.extractor = "UnknownExisting"
|
|
276
|
+
|
|
277
|
+
if not self.extractor:
|
|
278
|
+
self.logger.error("Could not determine extractor for the track.")
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# Now self.extractor should be set correctly for path generation etc.
|
|
282
|
+
|
|
283
|
+
self.logger.info(f"Preparing output path for track: {self.title} by {self.artist} (Extractor: {self.extractor})")
|
|
284
|
+
if self.dry_run:
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
# Delegate to FileHandler
|
|
288
|
+
track_output_dir, artist_title = self.file_handler.setup_output_paths(self.output_dir, self.artist, self.title)
|
|
289
|
+
|
|
290
|
+
processed_track = {
|
|
291
|
+
"track_output_dir": track_output_dir,
|
|
292
|
+
"artist": self.artist,
|
|
293
|
+
"title": self.title,
|
|
294
|
+
"extractor": self.extractor,
|
|
295
|
+
"extracted_info": self.extracted_info,
|
|
296
|
+
"lyrics": None,
|
|
297
|
+
"processed_lyrics": None,
|
|
298
|
+
"separated_audio": {},
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
processed_track["input_media"] = None
|
|
302
|
+
processed_track["input_still_image"] = None
|
|
303
|
+
processed_track["input_audio_wav"] = None
|
|
304
|
+
|
|
305
|
+
if self.input_media and os.path.isfile(self.input_media):
|
|
306
|
+
# --- Local File Input Handling ---
|
|
307
|
+
input_wav_filename_pattern = os.path.join(track_output_dir, f"{artist_title} ({self.extractor}*).wav")
|
|
308
|
+
input_wav_glob = glob.glob(input_wav_filename_pattern)
|
|
309
|
+
|
|
310
|
+
if input_wav_glob:
|
|
311
|
+
processed_track["input_audio_wav"] = input_wav_glob[0]
|
|
312
|
+
self.logger.info(f"Input media WAV file already exists, skipping conversion: {processed_track['input_audio_wav']}")
|
|
313
|
+
else:
|
|
314
|
+
output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({self.extractor})")
|
|
315
|
+
|
|
316
|
+
self.logger.info(f"Copying input media from {self.input_media} to new directory...")
|
|
317
|
+
# Delegate to FileHandler
|
|
318
|
+
processed_track["input_media"] = self.file_handler.copy_input_media(self.input_media, output_filename_no_extension)
|
|
319
|
+
|
|
320
|
+
self.logger.info("Converting input media to WAV for audio processing...")
|
|
321
|
+
# Delegate to FileHandler
|
|
322
|
+
processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(processed_track["input_media"], output_filename_no_extension)
|
|
323
|
+
|
|
324
|
+
else:
|
|
325
|
+
# --- URL or Existing Files Handling ---
|
|
326
|
+
# Construct patterns using the determined extractor
|
|
327
|
+
base_pattern = os.path.join(track_output_dir, f"{artist_title} ({self.extractor}*)")
|
|
328
|
+
input_media_glob = glob.glob(f"{base_pattern}.*webm") + glob.glob(f"{base_pattern}.*mp4") # Add other common formats if needed
|
|
329
|
+
input_png_glob = glob.glob(f"{base_pattern}.png")
|
|
330
|
+
input_wav_glob = glob.glob(f"{base_pattern}.wav")
|
|
331
|
+
|
|
332
|
+
if input_media_glob and input_png_glob and input_wav_glob:
|
|
333
|
+
# Existing files found
|
|
334
|
+
processed_track["input_media"] = input_media_glob[0]
|
|
335
|
+
processed_track["input_still_image"] = input_png_glob[0]
|
|
336
|
+
processed_track["input_audio_wav"] = input_wav_glob[0]
|
|
337
|
+
self.logger.info(f"Found existing media files matching extractor '{self.extractor}', skipping download/conversion.")
|
|
338
|
+
# Ensure self.extractor reflects the found files if it was a fallback
|
|
339
|
+
# Extract the actual extractor string from the filename if needed, though it should match
|
|
340
|
+
|
|
341
|
+
elif self.url: # URL provided and files not found, proceed with download
|
|
342
|
+
# Use media_id if available for better uniqueness
|
|
343
|
+
filename_suffix = f"{self.extractor} {self.media_id}" if self.media_id else self.extractor
|
|
344
|
+
output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({filename_suffix})")
|
|
345
|
+
|
|
346
|
+
self.logger.info(f"Downloading input media from {self.url}...")
|
|
347
|
+
# Delegate to FileHandler
|
|
348
|
+
processed_track["input_media"] = self.file_handler.download_video(self.url, output_filename_no_extension)
|
|
349
|
+
|
|
350
|
+
self.logger.info("Extracting still image from downloaded media (if input is video)...")
|
|
351
|
+
# Delegate to FileHandler
|
|
352
|
+
processed_track["input_still_image"] = self.file_handler.extract_still_image_from_video(
|
|
353
|
+
processed_track["input_media"], output_filename_no_extension
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
self.logger.info("Converting downloaded video to WAV for audio processing...")
|
|
357
|
+
# Delegate to FileHandler
|
|
358
|
+
processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(
|
|
359
|
+
processed_track["input_media"], output_filename_no_extension
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
# This case means input_media was None, not a URL, and no existing files found
|
|
363
|
+
self.logger.error(f"Cannot proceed: No input file, no URL, and no existing files found for {artist_title}.")
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
if self.skip_lyrics:
|
|
367
|
+
self.logger.info("Skipping lyrics fetch as requested.")
|
|
368
|
+
processed_track["lyrics"] = None
|
|
369
|
+
processed_track["processed_lyrics"] = None
|
|
370
|
+
else:
|
|
371
|
+
lyrics_artist = self.lyrics_artist or self.artist
|
|
372
|
+
lyrics_title = self.lyrics_title or self.title
|
|
373
|
+
|
|
374
|
+
# Create futures for both operations
|
|
375
|
+
transcription_future = None
|
|
376
|
+
separation_future = None
|
|
377
|
+
|
|
378
|
+
self.logger.info("=== Starting Parallel Processing ===")
|
|
379
|
+
|
|
380
|
+
if not self.skip_lyrics:
|
|
381
|
+
self.logger.info("Creating transcription future...")
|
|
382
|
+
# Run transcription in a separate thread
|
|
383
|
+
transcription_future = asyncio.create_task(
|
|
384
|
+
asyncio.to_thread(
|
|
385
|
+
# Delegate to LyricsProcessor
|
|
386
|
+
self.lyrics_processor.transcribe_lyrics, processed_track["input_audio_wav"], lyrics_artist, lyrics_title, track_output_dir
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
self.logger.info(f"Transcription future created, type: {type(transcription_future)}")
|
|
390
|
+
|
|
391
|
+
# Default to a placeholder future if separation won't run
|
|
392
|
+
separation_future = asyncio.sleep(0)
|
|
393
|
+
|
|
394
|
+
# Only create real separation future if not skipping AND no existing instrumental provided
|
|
395
|
+
if not self.skip_separation and not self.existing_instrumental:
|
|
396
|
+
self.logger.info("Creating separation future (not skipping and no existing instrumental)...")
|
|
397
|
+
# Run separation in a separate thread
|
|
398
|
+
separation_future = asyncio.create_task(
|
|
399
|
+
asyncio.to_thread(
|
|
400
|
+
# Delegate to AudioProcessor
|
|
401
|
+
self.audio_processor.process_audio_separation,
|
|
402
|
+
audio_file=processed_track["input_audio_wav"],
|
|
403
|
+
artist_title=artist_title,
|
|
404
|
+
track_output_dir=track_output_dir,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
self.logger.info(f"Separation future created, type: {type(separation_future)}")
|
|
408
|
+
elif self.existing_instrumental:
|
|
409
|
+
self.logger.info(f"Skipping separation future creation because existing instrumental was provided: {self.existing_instrumental}")
|
|
410
|
+
elif self.skip_separation: # Check this condition explicitly for clarity
|
|
411
|
+
self.logger.info("Skipping separation future creation because skip_separation is True.")
|
|
412
|
+
|
|
413
|
+
self.logger.info("About to await both operations with asyncio.gather...")
|
|
414
|
+
# Wait for both operations to complete
|
|
415
|
+
try:
|
|
416
|
+
results = await asyncio.gather(
|
|
417
|
+
transcription_future if transcription_future else asyncio.sleep(0), # Use placeholder if None
|
|
418
|
+
separation_future, # Already defaults to placeholder if not created
|
|
419
|
+
return_exceptions=True,
|
|
420
|
+
)
|
|
421
|
+
except asyncio.CancelledError:
|
|
422
|
+
self.logger.info("Received cancellation request, cleaning up...")
|
|
423
|
+
# Cancel any running futures
|
|
424
|
+
if transcription_future and not transcription_future.done():
|
|
425
|
+
transcription_future.cancel()
|
|
426
|
+
if separation_future and not separation_future.done() and not isinstance(separation_future, asyncio.Task): # Check if it's a real task
|
|
427
|
+
# Don't try to cancel the asyncio.sleep(0) placeholder
|
|
428
|
+
separation_future.cancel()
|
|
429
|
+
|
|
430
|
+
# Wait for futures to complete cancellation
|
|
431
|
+
await asyncio.gather(
|
|
432
|
+
transcription_future if transcription_future else asyncio.sleep(0),
|
|
433
|
+
separation_future if separation_future else asyncio.sleep(0), # Use placeholder if None/Placeholder
|
|
434
|
+
return_exceptions=True,
|
|
435
|
+
)
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
# Handle transcription results
|
|
439
|
+
if transcription_future:
|
|
440
|
+
self.logger.info("Processing transcription results...")
|
|
441
|
+
try:
|
|
442
|
+
# Index 0 corresponds to transcription_future in gather
|
|
443
|
+
transcriber_outputs = results[0]
|
|
444
|
+
# Check if the result is an exception or the actual output
|
|
445
|
+
if isinstance(transcriber_outputs, Exception):
|
|
446
|
+
self.logger.error(f"Error during lyrics transcription: {transcriber_outputs}")
|
|
447
|
+
# Optionally log traceback: self.logger.exception("Transcription error:")
|
|
448
|
+
raise transcriber_outputs # Re-raise the exception
|
|
449
|
+
elif transcriber_outputs is not None and not isinstance(transcriber_outputs, asyncio.futures.Future): # Ensure it's not the placeholder future
|
|
450
|
+
self.logger.info(f"Successfully received transcription outputs: {type(transcriber_outputs)}")
|
|
451
|
+
# Ensure transcriber_outputs is a dictionary before calling .get()
|
|
452
|
+
if isinstance(transcriber_outputs, dict):
|
|
453
|
+
self.lyrics = transcriber_outputs.get("corrected_lyrics_text")
|
|
454
|
+
processed_track["lyrics"] = transcriber_outputs.get("corrected_lyrics_text_filepath")
|
|
455
|
+
else:
|
|
456
|
+
self.logger.warning(f"Unexpected type for transcriber_outputs: {type(transcriber_outputs)}, value: {transcriber_outputs}")
|
|
457
|
+
else:
|
|
458
|
+
self.logger.info("Transcription task did not return results (possibly skipped or placeholder).")
|
|
459
|
+
except Exception as e:
|
|
460
|
+
self.logger.error(f"Error processing transcription results: {e}")
|
|
461
|
+
self.logger.exception("Full traceback:")
|
|
462
|
+
raise # Re-raise the exception
|
|
463
|
+
|
|
464
|
+
# Handle separation results only if a real future was created and ran
|
|
465
|
+
# Check if separation_future was the placeholder or a real task
|
|
466
|
+
# The result index in `results` depends on whether transcription_future existed
|
|
467
|
+
separation_result_index = 1 if transcription_future else 0
|
|
468
|
+
if separation_future is not None and not isinstance(separation_future, asyncio.Task) and len(results) > separation_result_index:
|
|
469
|
+
self.logger.info("Processing separation results...")
|
|
470
|
+
try:
|
|
471
|
+
separation_results = results[separation_result_index]
|
|
472
|
+
# Check if the result is an exception or the actual output
|
|
473
|
+
if isinstance(separation_results, Exception):
|
|
474
|
+
self.logger.error(f"Error during audio separation: {separation_results}")
|
|
475
|
+
# Optionally log traceback: self.logger.exception("Separation error:")
|
|
476
|
+
# Decide if you want to raise here or just log
|
|
477
|
+
elif separation_results is not None and not isinstance(separation_results, asyncio.futures.Future): # Ensure it's not the placeholder future
|
|
478
|
+
self.logger.info(f"Successfully received separation results: {type(separation_results)}")
|
|
479
|
+
if isinstance(separation_results, dict):
|
|
480
|
+
processed_track["separated_audio"] = separation_results
|
|
481
|
+
else:
|
|
482
|
+
self.logger.warning(f"Unexpected type for separation_results: {type(separation_results)}, value: {separation_results}")
|
|
483
|
+
else:
|
|
484
|
+
self.logger.info("Separation task did not return results (possibly skipped or placeholder).")
|
|
485
|
+
except Exception as e:
|
|
486
|
+
self.logger.error(f"Error processing separation results: {e}")
|
|
487
|
+
self.logger.exception("Full traceback:")
|
|
488
|
+
# Decide if you want to raise here or just log
|
|
489
|
+
elif not self.skip_separation and not self.existing_instrumental:
|
|
490
|
+
# This case means separation was supposed to run but didn't return results properly
|
|
491
|
+
self.logger.warning("Separation task was expected but did not yield results or resulted in an error captured earlier.")
|
|
492
|
+
else:
|
|
493
|
+
# This case means separation was intentionally skipped
|
|
494
|
+
self.logger.info("Skipping processing of separation results as separation was not run.")
|
|
495
|
+
|
|
496
|
+
self.logger.info("=== Parallel Processing Complete ===")
|
|
497
|
+
|
|
498
|
+
output_image_filepath_noext = os.path.join(track_output_dir, f"{artist_title} (Title)")
|
|
499
|
+
processed_track["title_image_png"] = f"{output_image_filepath_noext}.png"
|
|
500
|
+
processed_track["title_image_jpg"] = f"{output_image_filepath_noext}.jpg"
|
|
501
|
+
processed_track["title_video"] = os.path.join(track_output_dir, f"{artist_title} (Title).mov")
|
|
502
|
+
|
|
503
|
+
# Use FileHandler._file_exists
|
|
504
|
+
if not self.file_handler._file_exists(processed_track["title_video"]) and not os.environ.get("KARAOKE_PREP_SKIP_TITLE_END_SCREENS"):
|
|
505
|
+
self.logger.info(f"Creating title video...")
|
|
506
|
+
# Delegate to VideoGenerator
|
|
507
|
+
self.video_generator.create_title_video(
|
|
508
|
+
artist=self.artist,
|
|
509
|
+
title=self.title,
|
|
510
|
+
format=self.title_format,
|
|
511
|
+
output_image_filepath_noext=output_image_filepath_noext,
|
|
512
|
+
output_video_filepath=processed_track["title_video"],
|
|
513
|
+
existing_title_image=self.existing_title_image,
|
|
514
|
+
intro_video_duration=self.intro_video_duration,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
output_image_filepath_noext = os.path.join(track_output_dir, f"{artist_title} (End)")
|
|
518
|
+
processed_track["end_image_png"] = f"{output_image_filepath_noext}.png"
|
|
519
|
+
processed_track["end_image_jpg"] = f"{output_image_filepath_noext}.jpg"
|
|
520
|
+
processed_track["end_video"] = os.path.join(track_output_dir, f"{artist_title} (End).mov")
|
|
521
|
+
|
|
522
|
+
# Use FileHandler._file_exists
|
|
523
|
+
if not self.file_handler._file_exists(processed_track["end_video"]) and not os.environ.get("KARAOKE_PREP_SKIP_TITLE_END_SCREENS"):
|
|
524
|
+
self.logger.info(f"Creating end screen video...")
|
|
525
|
+
# Delegate to VideoGenerator
|
|
526
|
+
self.video_generator.create_end_video(
|
|
527
|
+
artist=self.artist,
|
|
528
|
+
title=self.title,
|
|
529
|
+
format=self.end_format,
|
|
530
|
+
output_image_filepath_noext=output_image_filepath_noext,
|
|
531
|
+
output_video_filepath=processed_track["end_video"],
|
|
532
|
+
existing_end_image=self.existing_end_image,
|
|
533
|
+
end_video_duration=self.end_video_duration,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
if self.skip_separation:
|
|
537
|
+
self.logger.info("Skipping audio separation as requested.")
|
|
538
|
+
processed_track["separated_audio"] = {
|
|
539
|
+
"clean_instrumental": {},
|
|
540
|
+
"backing_vocals": {},
|
|
541
|
+
"other_stems": {},
|
|
542
|
+
"combined_instrumentals": {},
|
|
543
|
+
}
|
|
544
|
+
elif self.existing_instrumental:
|
|
545
|
+
self.logger.info(f"Using existing instrumental file: {self.existing_instrumental}")
|
|
546
|
+
existing_instrumental_extension = os.path.splitext(self.existing_instrumental)[1]
|
|
547
|
+
|
|
548
|
+
instrumental_path = os.path.join(track_output_dir, f"{artist_title} (Instrumental Custom){existing_instrumental_extension}")
|
|
549
|
+
|
|
550
|
+
# Use FileHandler._file_exists
|
|
551
|
+
if not self.file_handler._file_exists(instrumental_path):
|
|
552
|
+
shutil.copy2(self.existing_instrumental, instrumental_path)
|
|
553
|
+
|
|
554
|
+
processed_track["separated_audio"]["Custom"] = {
|
|
555
|
+
"instrumental": instrumental_path,
|
|
556
|
+
"vocals": None,
|
|
557
|
+
}
|
|
558
|
+
else:
|
|
559
|
+
# Only run separation if not skipped
|
|
560
|
+
if not self.skip_separation:
|
|
561
|
+
self.logger.info(f"Separating audio for track: {self.title} by {self.artist}")
|
|
562
|
+
# Delegate to AudioProcessor (called directly, not in thread here)
|
|
563
|
+
separation_results = self.audio_processor.process_audio_separation(
|
|
564
|
+
audio_file=processed_track["input_audio_wav"], artist_title=artist_title, track_output_dir=track_output_dir
|
|
565
|
+
)
|
|
566
|
+
processed_track["separated_audio"] = separation_results
|
|
567
|
+
# We don't need an else here, if skip_separation is true, separated_audio remains the default empty dict
|
|
568
|
+
|
|
569
|
+
self.logger.info("Script finished, audio downloaded, lyrics fetched and audio separated!")
|
|
570
|
+
|
|
571
|
+
return processed_track
|
|
572
|
+
|
|
573
|
+
except Exception as e:
|
|
574
|
+
self.logger.error(f"Error in prep_single_track: {e}")
|
|
575
|
+
raise
|
|
576
|
+
finally:
|
|
577
|
+
# Remove signal handlers
|
|
578
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
579
|
+
loop.remove_signal_handler(sig)
|
|
580
|
+
|
|
581
|
+
async def shutdown(self, signal):
|
|
582
|
+
"""Handle shutdown signals gracefully."""
|
|
583
|
+
self.logger.info(f"Received exit signal {signal.name}...")
|
|
584
|
+
|
|
585
|
+
# Get all running tasks
|
|
586
|
+
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
587
|
+
|
|
588
|
+
if tasks:
|
|
589
|
+
self.logger.info(f"Cancelling {len(tasks)} outstanding tasks")
|
|
590
|
+
# Cancel all running tasks
|
|
591
|
+
for task in tasks:
|
|
592
|
+
task.cancel()
|
|
593
|
+
|
|
594
|
+
self.logger.info("Received cancellation request, cleaning up...")
|
|
595
|
+
|
|
596
|
+
# Wait for all tasks to complete with cancellation
|
|
597
|
+
try:
|
|
598
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
599
|
+
except asyncio.CancelledError:
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
# Force exit after cleanup
|
|
603
|
+
self.logger.info("Cleanup complete, exiting...")
|
|
604
|
+
sys.exit(0) # Add this line to force exit
|
|
605
|
+
|
|
606
|
+
async def process_playlist(self):
|
|
607
|
+
if self.artist is None or self.title is None:
|
|
608
|
+
raise Exception("Error: Artist and Title are required for processing a local file.")
|
|
609
|
+
|
|
610
|
+
if "entries" in self.extracted_info:
|
|
611
|
+
track_results = []
|
|
612
|
+
self.logger.info(f"Found {len(self.extracted_info['entries'])} entries in playlist, processing each invididually...")
|
|
613
|
+
for entry in self.extracted_info["entries"]:
|
|
614
|
+
self.extracted_info = entry
|
|
615
|
+
self.logger.info(f"Processing playlist entry with title: {self.extracted_info['title']}")
|
|
616
|
+
if not self.dry_run:
|
|
617
|
+
track_results.append(await self.prep_single_track())
|
|
618
|
+
self.artist = self.persistent_artist
|
|
619
|
+
self.title = None
|
|
620
|
+
return track_results
|
|
621
|
+
else:
|
|
622
|
+
raise Exception(f"Failed to find 'entries' in playlist, cannot process")
|
|
623
|
+
|
|
624
|
+
async def process_folder(self):
|
|
625
|
+
if self.filename_pattern is None or self.artist is None:
|
|
626
|
+
raise Exception("Error: Filename pattern and artist are required for processing a folder.")
|
|
627
|
+
|
|
628
|
+
folder_path = self.input_media
|
|
629
|
+
output_folder_path = os.path.join(os.getcwd(), os.path.basename(folder_path))
|
|
630
|
+
|
|
631
|
+
if not os.path.exists(output_folder_path):
|
|
632
|
+
if not self.dry_run:
|
|
633
|
+
self.logger.info(f"DRY RUN: Would create output folder: {output_folder_path}")
|
|
634
|
+
os.makedirs(output_folder_path)
|
|
635
|
+
else:
|
|
636
|
+
self.logger.info(f"Output folder already exists: {output_folder_path}")
|
|
637
|
+
|
|
638
|
+
pattern = re.compile(self.filename_pattern)
|
|
639
|
+
tracks = []
|
|
640
|
+
|
|
641
|
+
for filename in sorted(os.listdir(folder_path)):
|
|
642
|
+
match = pattern.match(filename)
|
|
643
|
+
if match:
|
|
644
|
+
title = match.group("title")
|
|
645
|
+
file_path = os.path.join(folder_path, filename)
|
|
646
|
+
self.input_media = file_path
|
|
647
|
+
self.title = title
|
|
648
|
+
|
|
649
|
+
track_index = match.group("index") if "index" in match.groupdict() else None
|
|
650
|
+
|
|
651
|
+
self.logger.info(f"Processing track: {track_index} with title: {title} from file: {filename}")
|
|
652
|
+
|
|
653
|
+
track_output_dir = os.path.join(output_folder_path, f"{track_index} - {self.artist} - {title}")
|
|
654
|
+
|
|
655
|
+
if not self.dry_run:
|
|
656
|
+
track = await self.prep_single_track()
|
|
657
|
+
tracks.append(track)
|
|
658
|
+
|
|
659
|
+
# Move the track folder to the output folder
|
|
660
|
+
track_folder = track["track_output_dir"]
|
|
661
|
+
shutil.move(track_folder, track_output_dir)
|
|
662
|
+
else:
|
|
663
|
+
self.logger.info(f"DRY RUN: Would move track folder to: {os.path.basename(track_output_dir)}")
|
|
664
|
+
|
|
665
|
+
return tracks
|
|
666
|
+
|
|
667
|
+
async def process(self):
|
|
668
|
+
if self.input_media is not None and os.path.isdir(self.input_media):
|
|
669
|
+
self.logger.info(f"Input media {self.input_media} is a local folder, processing each file individually...")
|
|
670
|
+
return await self.process_folder()
|
|
671
|
+
elif self.input_media is not None and os.path.isfile(self.input_media):
|
|
672
|
+
self.logger.info(f"Input media {self.input_media} is a local file, youtube logic will be skipped")
|
|
673
|
+
return [await self.prep_single_track()]
|
|
674
|
+
else:
|
|
675
|
+
self.url = self.input_media
|
|
676
|
+
# Use the imported extract_info_for_online_media function
|
|
677
|
+
self.extracted_info = extract_info_for_online_media(
|
|
678
|
+
input_url=self.url, input_artist=self.artist, input_title=self.title, logger=self.logger
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
if self.extracted_info and "playlist_count" in self.extracted_info:
|
|
682
|
+
self.persistent_artist = self.artist
|
|
683
|
+
self.logger.info(f"Input URL is a playlist, beginning batch operation with persistent artist: {self.persistent_artist}")
|
|
684
|
+
return await self.process_playlist()
|
|
685
|
+
else:
|
|
686
|
+
self.logger.info(f"Input URL is not a playlist, processing single track")
|
|
687
|
+
return [await self.prep_single_track()]
|