karaoke-gen 0.75.54__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/__init__.py +38 -0
- karaoke_gen/audio_fetcher.py +1614 -0
- karaoke_gen/audio_processor.py +790 -0
- karaoke_gen/config.py +83 -0
- karaoke_gen/file_handler.py +387 -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 +1529 -0
- karaoke_gen/instrumental_review/waveform.py +409 -0
- karaoke_gen/karaoke_finalise/__init__.py +1 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +1833 -0
- karaoke_gen/karaoke_gen.py +1026 -0
- karaoke_gen/lyrics_processor.py +474 -0
- karaoke_gen/metadata.py +160 -0
- karaoke_gen/pipeline/__init__.py +87 -0
- karaoke_gen/pipeline/base.py +215 -0
- karaoke_gen/pipeline/context.py +230 -0
- karaoke_gen/pipeline/executors/__init__.py +21 -0
- karaoke_gen/pipeline/executors/local.py +159 -0
- karaoke_gen/pipeline/executors/remote.py +257 -0
- karaoke_gen/pipeline/stages/__init__.py +27 -0
- karaoke_gen/pipeline/stages/finalize.py +202 -0
- karaoke_gen/pipeline/stages/render.py +165 -0
- karaoke_gen/pipeline/stages/screens.py +139 -0
- karaoke_gen/pipeline/stages/separation.py +191 -0
- karaoke_gen/pipeline/stages/transcription.py +191 -0
- karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
- karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
- karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- karaoke_gen/style_loader.py +531 -0
- karaoke_gen/utils/__init__.py +18 -0
- karaoke_gen/utils/bulk_cli.py +492 -0
- karaoke_gen/utils/cli_args.py +432 -0
- karaoke_gen/utils/gen_cli.py +978 -0
- karaoke_gen/utils/remote_cli.py +3268 -0
- karaoke_gen/video_background_processor.py +351 -0
- karaoke_gen/video_generator.py +424 -0
- karaoke_gen-0.75.54.dist-info/METADATA +718 -0
- karaoke_gen-0.75.54.dist-info/RECORD +287 -0
- karaoke_gen-0.75.54.dist-info/WHEEL +4 -0
- karaoke_gen-0.75.54.dist-info/entry_points.txt +5 -0
- karaoke_gen-0.75.54.dist-info/licenses/LICENSE +21 -0
- lyrics_transcriber/__init__.py +10 -0
- lyrics_transcriber/cli/__init__.py +0 -0
- lyrics_transcriber/cli/cli_main.py +285 -0
- lyrics_transcriber/core/__init__.py +0 -0
- lyrics_transcriber/core/config.py +50 -0
- lyrics_transcriber/core/controller.py +594 -0
- lyrics_transcriber/correction/__init__.py +0 -0
- lyrics_transcriber/correction/agentic/__init__.py +9 -0
- lyrics_transcriber/correction/agentic/adapter.py +71 -0
- lyrics_transcriber/correction/agentic/agent.py +313 -0
- lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
- lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
- lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
- lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
- lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
- lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
- lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
- lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
- lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
- lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
- lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
- lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
- lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
- lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
- lyrics_transcriber/correction/agentic/models/enums.py +38 -0
- lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
- lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
- lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
- lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
- lyrics_transcriber/correction/agentic/models/utils.py +19 -0
- lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
- lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
- lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
- lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
- lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
- lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
- lyrics_transcriber/correction/agentic/providers/base.py +36 -0
- lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
- lyrics_transcriber/correction/agentic/providers/config.py +73 -0
- lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
- lyrics_transcriber/correction/agentic/providers/health.py +28 -0
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
- lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
- lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
- lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
- lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
- lyrics_transcriber/correction/agentic/router.py +35 -0
- lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
- lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
- lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
- lyrics_transcriber/correction/anchor_sequence.py +919 -0
- lyrics_transcriber/correction/corrector.py +760 -0
- lyrics_transcriber/correction/feedback/__init__.py +2 -0
- lyrics_transcriber/correction/feedback/schemas.py +107 -0
- lyrics_transcriber/correction/feedback/store.py +236 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +52 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
- lyrics_transcriber/correction/handlers/llm.py +293 -0
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
- lyrics_transcriber/correction/handlers/repeat.py +88 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
- lyrics_transcriber/correction/handlers/word_operations.py +187 -0
- lyrics_transcriber/correction/operations.py +352 -0
- lyrics_transcriber/correction/phrase_analyzer.py +435 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/frontend/.gitignore +23 -0
- lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
- lyrics_transcriber/frontend/.yarnrc.yml +3 -0
- lyrics_transcriber/frontend/README.md +50 -0
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
- lyrics_transcriber/frontend/__init__.py +25 -0
- lyrics_transcriber/frontend/eslint.config.js +28 -0
- lyrics_transcriber/frontend/index.html +18 -0
- lyrics_transcriber/frontend/package.json +42 -0
- lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/public/favicon.ico +0 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/src/App.tsx +214 -0
- lyrics_transcriber/frontend/src/api.ts +254 -0
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
- lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
- lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
- lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +413 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1387 -0
- 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/MetricsDashboard.tsx +51 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
- lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +336 -0
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
- lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
- lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
- lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
- lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
- lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
- lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
- lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
- lyrics_transcriber/frontend/src/main.tsx +17 -0
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
- lyrics_transcriber/frontend/src/types.js +2 -0
- lyrics_transcriber/frontend/src/types.ts +199 -0
- lyrics_transcriber/frontend/src/validation.ts +132 -0
- lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
- lyrics_transcriber/frontend/tsconfig.app.json +26 -0
- lyrics_transcriber/frontend/tsconfig.json +25 -0
- lyrics_transcriber/frontend/tsconfig.node.json +23 -0
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
- lyrics_transcriber/frontend/update_version.js +11 -0
- lyrics_transcriber/frontend/vite.config.d.ts +2 -0
- lyrics_transcriber/frontend/vite.config.js +10 -0
- lyrics_transcriber/frontend/vite.config.ts +11 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js +43288 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
- lyrics_transcriber/frontend/web_assets/index.html +18 -0
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/yarn.lock +3752 -0
- lyrics_transcriber/lyrics/__init__.py +0 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
- lyrics_transcriber/lyrics/file_provider.py +95 -0
- lyrics_transcriber/lyrics/genius.py +384 -0
- lyrics_transcriber/lyrics/lrclib.py +231 -0
- lyrics_transcriber/lyrics/musixmatch.py +156 -0
- lyrics_transcriber/lyrics/spotify.py +290 -0
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/__init__.py +0 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/ass/ass.py +2088 -0
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +180 -0
- lyrics_transcriber/output/ass/constants.py +23 -0
- lyrics_transcriber/output/ass/event.py +94 -0
- lyrics_transcriber/output/ass/formatters.py +132 -0
- lyrics_transcriber/output/ass/lyrics_line.py +265 -0
- lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
- lyrics_transcriber/output/ass/section_detector.py +89 -0
- lyrics_transcriber/output/ass/section_screen.py +106 -0
- lyrics_transcriber/output/ass/style.py +187 -0
- lyrics_transcriber/output/cdg.py +619 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
- lyrics_transcriber/output/cdgmaker/config.py +151 -0
- lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
- lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
- lyrics_transcriber/output/cdgmaker/pack.py +507 -0
- lyrics_transcriber/output/cdgmaker/render.py +346 -0
- lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
- lyrics_transcriber/output/cdgmaker/utils.py +132 -0
- lyrics_transcriber/output/countdown_processor.py +306 -0
- lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
- lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
- lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/arial.ttf +0 -0
- lyrics_transcriber/output/fonts/georgia.ttf +0 -0
- lyrics_transcriber/output/fonts/verdana.ttf +0 -0
- lyrics_transcriber/output/generator.py +257 -0
- lyrics_transcriber/output/lrc_to_cdg.py +61 -0
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +96 -0
- lyrics_transcriber/output/segment_resizer.py +431 -0
- lyrics_transcriber/output/subtitles.py +397 -0
- lyrics_transcriber/output/video.py +544 -0
- lyrics_transcriber/review/__init__.py +0 -0
- lyrics_transcriber/review/server.py +676 -0
- lyrics_transcriber/storage/__init__.py +0 -0
- lyrics_transcriber/storage/dropbox.py +225 -0
- lyrics_transcriber/transcribers/__init__.py +0 -0
- lyrics_transcriber/transcribers/audioshake.py +379 -0
- lyrics_transcriber/transcribers/base_transcriber.py +157 -0
- lyrics_transcriber/transcribers/whisper.py +330 -0
- lyrics_transcriber/types.py +650 -0
- lyrics_transcriber/utils/__init__.py +0 -0
- lyrics_transcriber/utils/word_utils.py +27 -0
|
@@ -0,0 +1,1026 @@
|
|
|
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
|
+
from .video_background_processor import VideoBackgroundProcessor
|
|
32
|
+
from .audio_fetcher import create_audio_fetcher, AudioFetcherError, NoResultsError, UserCancelledError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class KaraokePrep:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
# Basic inputs
|
|
39
|
+
input_media=None,
|
|
40
|
+
artist=None,
|
|
41
|
+
title=None,
|
|
42
|
+
filename_pattern=None,
|
|
43
|
+
# Logging & Debugging
|
|
44
|
+
dry_run=False,
|
|
45
|
+
logger=None,
|
|
46
|
+
log_level=logging.DEBUG,
|
|
47
|
+
log_formatter=None,
|
|
48
|
+
render_bounding_boxes=False,
|
|
49
|
+
# Input/Output Configuration
|
|
50
|
+
output_dir=".",
|
|
51
|
+
create_track_subfolders=False,
|
|
52
|
+
lossless_output_format="FLAC",
|
|
53
|
+
output_png=True,
|
|
54
|
+
output_jpg=True,
|
|
55
|
+
# Audio Processing Configuration
|
|
56
|
+
clean_instrumental_model="model_bs_roformer_ep_317_sdr_12.9755.ckpt",
|
|
57
|
+
backing_vocals_models=["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"],
|
|
58
|
+
other_stems_models=["htdemucs_6s.yaml"],
|
|
59
|
+
model_file_dir=os.path.join(tempfile.gettempdir(), "audio-separator-models"),
|
|
60
|
+
existing_instrumental=None,
|
|
61
|
+
# Lyrics Configuration
|
|
62
|
+
lyrics_artist=None,
|
|
63
|
+
lyrics_title=None,
|
|
64
|
+
lyrics_file=None,
|
|
65
|
+
skip_lyrics=False,
|
|
66
|
+
skip_transcription=False,
|
|
67
|
+
skip_transcription_review=False,
|
|
68
|
+
render_video=True,
|
|
69
|
+
subtitle_offset_ms=0,
|
|
70
|
+
# Style Configuration
|
|
71
|
+
style_params_json=None,
|
|
72
|
+
style_overrides=None,
|
|
73
|
+
# Add the new parameter
|
|
74
|
+
skip_separation=False,
|
|
75
|
+
# Video Background Configuration
|
|
76
|
+
background_video=None,
|
|
77
|
+
background_video_darkness=50,
|
|
78
|
+
# Audio Fetcher Configuration
|
|
79
|
+
auto_download=False,
|
|
80
|
+
):
|
|
81
|
+
self.log_level = log_level
|
|
82
|
+
self.log_formatter = log_formatter
|
|
83
|
+
|
|
84
|
+
if logger is None:
|
|
85
|
+
self.logger = logging.getLogger(__name__)
|
|
86
|
+
self.logger.setLevel(log_level)
|
|
87
|
+
# Prevent log propagation to root logger to avoid duplicate logs
|
|
88
|
+
# when external packages (like lyrics_converter) configure root logger handlers
|
|
89
|
+
self.logger.propagate = False
|
|
90
|
+
|
|
91
|
+
self.log_handler = logging.StreamHandler()
|
|
92
|
+
|
|
93
|
+
if self.log_formatter is None:
|
|
94
|
+
self.log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s - %(message)s")
|
|
95
|
+
|
|
96
|
+
self.log_handler.setFormatter(self.log_formatter)
|
|
97
|
+
self.logger.addHandler(self.log_handler)
|
|
98
|
+
else:
|
|
99
|
+
self.logger = logger
|
|
100
|
+
|
|
101
|
+
self.logger.debug(f"KaraokePrep instantiating with input_media: {input_media} artist: {artist} title: {title}")
|
|
102
|
+
|
|
103
|
+
self.dry_run = dry_run
|
|
104
|
+
self.extractor = None # Will be set later based on source (Original or yt-dlp extractor)
|
|
105
|
+
self.media_id = None # Will be set by parse_track_metadata if applicable
|
|
106
|
+
self.url = None # Will be set by parse_track_metadata if applicable
|
|
107
|
+
self.input_media = input_media
|
|
108
|
+
self.artist = artist
|
|
109
|
+
self.title = title
|
|
110
|
+
self.filename_pattern = filename_pattern
|
|
111
|
+
|
|
112
|
+
# Input/Output - Keep these as they might be needed for logic outside handlers or passed to multiple handlers
|
|
113
|
+
self.output_dir = output_dir
|
|
114
|
+
self.lossless_output_format = lossless_output_format.lower()
|
|
115
|
+
self.create_track_subfolders = create_track_subfolders
|
|
116
|
+
self.output_png = output_png
|
|
117
|
+
self.output_jpg = output_jpg
|
|
118
|
+
|
|
119
|
+
# Lyrics Config - Keep needed ones
|
|
120
|
+
self.lyrics_artist = lyrics_artist
|
|
121
|
+
self.lyrics_title = lyrics_title
|
|
122
|
+
self.lyrics_file = lyrics_file # Passed to LyricsProcessor
|
|
123
|
+
self.skip_lyrics = skip_lyrics # Used in prep_single_track logic
|
|
124
|
+
self.skip_transcription = skip_transcription # Passed to LyricsProcessor
|
|
125
|
+
self.skip_transcription_review = skip_transcription_review # Passed to LyricsProcessor
|
|
126
|
+
self.render_video = render_video # Passed to LyricsProcessor
|
|
127
|
+
self.subtitle_offset_ms = subtitle_offset_ms # Passed to LyricsProcessor
|
|
128
|
+
|
|
129
|
+
# Audio Config - Keep needed ones
|
|
130
|
+
self.existing_instrumental = existing_instrumental # Used in prep_single_track logic
|
|
131
|
+
self.skip_separation = skip_separation # Used in prep_single_track logic
|
|
132
|
+
self.model_file_dir = model_file_dir # Passed to AudioProcessor
|
|
133
|
+
|
|
134
|
+
# Style Config - Keep needed ones
|
|
135
|
+
self.render_bounding_boxes = render_bounding_boxes # Passed to VideoGenerator
|
|
136
|
+
self.style_params_json = style_params_json
|
|
137
|
+
self.style_overrides = style_overrides
|
|
138
|
+
self.temp_style_file = None
|
|
139
|
+
|
|
140
|
+
# Video Background Config
|
|
141
|
+
self.background_video = background_video
|
|
142
|
+
self.background_video_darkness = background_video_darkness
|
|
143
|
+
|
|
144
|
+
# Audio Fetcher Config (replaces yt-dlp)
|
|
145
|
+
self.auto_download = auto_download # If True, automatically select best audio source
|
|
146
|
+
|
|
147
|
+
# Initialize audio fetcher for searching and downloading audio when no input file is provided
|
|
148
|
+
self.audio_fetcher = create_audio_fetcher(logger=self.logger)
|
|
149
|
+
|
|
150
|
+
# Load style parameters using the config module
|
|
151
|
+
self.style_params = load_style_params(self.style_params_json, self.style_overrides, self.logger)
|
|
152
|
+
|
|
153
|
+
# If overrides were applied, write to a temp file and update the path
|
|
154
|
+
if self.style_overrides:
|
|
155
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".json") as temp_file:
|
|
156
|
+
json.dump(self.style_params, temp_file, indent=2)
|
|
157
|
+
self.temp_style_file = temp_file.name
|
|
158
|
+
self.style_params_json = self.temp_style_file
|
|
159
|
+
self.logger.info(f"Style overrides applied. Using temporary style file: {self.temp_style_file}")
|
|
160
|
+
|
|
161
|
+
# Set up title and end formats using the config module
|
|
162
|
+
self.title_format = setup_title_format(self.style_params)
|
|
163
|
+
self.end_format = setup_end_format(self.style_params)
|
|
164
|
+
|
|
165
|
+
# Get video durations and existing images using the config module
|
|
166
|
+
self.intro_video_duration, self.end_video_duration = get_video_durations(self.style_params)
|
|
167
|
+
self.existing_title_image, self.existing_end_image = get_existing_images(self.style_params)
|
|
168
|
+
|
|
169
|
+
# Set up ffmpeg command using the config module
|
|
170
|
+
self.ffmpeg_base_command = setup_ffmpeg_command(self.log_level)
|
|
171
|
+
|
|
172
|
+
# Instantiate Handlers
|
|
173
|
+
self.file_handler = FileHandler(
|
|
174
|
+
logger=self.logger,
|
|
175
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
176
|
+
create_track_subfolders=self.create_track_subfolders,
|
|
177
|
+
dry_run=self.dry_run,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
self.audio_processor = AudioProcessor(
|
|
181
|
+
logger=self.logger,
|
|
182
|
+
log_level=self.log_level,
|
|
183
|
+
log_formatter=self.log_formatter,
|
|
184
|
+
model_file_dir=self.model_file_dir,
|
|
185
|
+
lossless_output_format=self.lossless_output_format,
|
|
186
|
+
clean_instrumental_model=clean_instrumental_model, # Passed directly from args
|
|
187
|
+
backing_vocals_models=backing_vocals_models, # Passed directly from args
|
|
188
|
+
other_stems_models=other_stems_models, # Passed directly from args
|
|
189
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
self.lyrics_processor = LyricsProcessor(
|
|
193
|
+
logger=self.logger,
|
|
194
|
+
style_params_json=self.style_params_json,
|
|
195
|
+
lyrics_file=self.lyrics_file,
|
|
196
|
+
skip_transcription=self.skip_transcription,
|
|
197
|
+
skip_transcription_review=self.skip_transcription_review,
|
|
198
|
+
render_video=self.render_video,
|
|
199
|
+
subtitle_offset_ms=self.subtitle_offset_ms,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
self.video_generator = VideoGenerator(
|
|
203
|
+
logger=self.logger,
|
|
204
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
205
|
+
render_bounding_boxes=self.render_bounding_boxes,
|
|
206
|
+
output_png=self.output_png,
|
|
207
|
+
output_jpg=self.output_jpg,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Instantiate VideoBackgroundProcessor if background_video is provided
|
|
211
|
+
if self.background_video:
|
|
212
|
+
self.logger.info(f"Video background enabled: {self.background_video}")
|
|
213
|
+
self.video_background_processor = VideoBackgroundProcessor(
|
|
214
|
+
logger=self.logger,
|
|
215
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
self.video_background_processor = None
|
|
219
|
+
|
|
220
|
+
self.logger.debug(f"Initialized title_format with extra_text: {self.title_format['extra_text']}")
|
|
221
|
+
self.logger.debug(f"Initialized title_format with extra_text_region: {self.title_format['extra_text_region']}")
|
|
222
|
+
|
|
223
|
+
self.logger.debug(f"Initialized end_format with extra_text: {self.end_format['extra_text']}")
|
|
224
|
+
self.logger.debug(f"Initialized end_format with extra_text_region: {self.end_format['extra_text_region']}")
|
|
225
|
+
|
|
226
|
+
self.extracted_info = None # Will be populated by extract_info_for_online_media if needed
|
|
227
|
+
self.persistent_artist = None # Used for playlists
|
|
228
|
+
|
|
229
|
+
self.logger.debug(f"KaraokePrep lossless_output_format: {self.lossless_output_format}")
|
|
230
|
+
|
|
231
|
+
# Use FileHandler method to check/create output dir
|
|
232
|
+
if not os.path.exists(self.output_dir):
|
|
233
|
+
self.logger.debug(f"Overall output dir {self.output_dir} did not exist, creating")
|
|
234
|
+
os.makedirs(self.output_dir)
|
|
235
|
+
else:
|
|
236
|
+
self.logger.debug(f"Overall output dir {self.output_dir} already exists")
|
|
237
|
+
|
|
238
|
+
def __del__(self):
|
|
239
|
+
# Cleanup the temporary style file if it was created
|
|
240
|
+
if self.temp_style_file and os.path.exists(self.temp_style_file):
|
|
241
|
+
try:
|
|
242
|
+
os.remove(self.temp_style_file)
|
|
243
|
+
self.logger.debug(f"Removed temporary style file: {self.temp_style_file}")
|
|
244
|
+
except OSError as e:
|
|
245
|
+
self.logger.warning(f"Error removing temporary style file {self.temp_style_file}: {e}")
|
|
246
|
+
|
|
247
|
+
# Compatibility methods for tests - these call the new functions in metadata.py
|
|
248
|
+
def extract_info_for_online_media(self, input_url=None, input_artist=None, input_title=None):
|
|
249
|
+
"""Compatibility method that calls the function in metadata.py"""
|
|
250
|
+
self.extracted_info = extract_info_for_online_media(input_url, input_artist, input_title, self.logger)
|
|
251
|
+
return self.extracted_info
|
|
252
|
+
|
|
253
|
+
def parse_single_track_metadata(self, input_artist, input_title):
|
|
254
|
+
"""Compatibility method that calls the function in metadata.py"""
|
|
255
|
+
metadata_result = parse_track_metadata(self.extracted_info, input_artist, input_title, self.persistent_artist, self.logger)
|
|
256
|
+
self.url = metadata_result["url"]
|
|
257
|
+
self.extractor = metadata_result["extractor"]
|
|
258
|
+
self.media_id = metadata_result["media_id"]
|
|
259
|
+
self.artist = metadata_result["artist"]
|
|
260
|
+
self.title = metadata_result["title"]
|
|
261
|
+
|
|
262
|
+
def _scan_directory_for_instrumentals(self, track_output_dir, artist_title):
|
|
263
|
+
"""
|
|
264
|
+
Scan the directory for existing instrumental files and build a separated_audio structure.
|
|
265
|
+
|
|
266
|
+
This is used when transcription was skipped (existing files found) but we need to
|
|
267
|
+
pad instrumentals due to countdown padding.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
track_output_dir: The track output directory to scan
|
|
271
|
+
artist_title: The "{artist} - {title}" string for matching files
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Dictionary with separated_audio structure containing found instrumental paths
|
|
275
|
+
"""
|
|
276
|
+
self.logger.info(f"Scanning directory for existing instrumentals: {track_output_dir}")
|
|
277
|
+
|
|
278
|
+
separated_audio = {
|
|
279
|
+
"clean_instrumental": {},
|
|
280
|
+
"backing_vocals": {},
|
|
281
|
+
"other_stems": {},
|
|
282
|
+
"combined_instrumentals": {},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# Search patterns for instrumental files
|
|
286
|
+
# Files are named like: "{artist} - {title} (Instrumental {model}).flac"
|
|
287
|
+
# Or with backing vocals: "{artist} - {title} (Instrumental +BV {model}).flac"
|
|
288
|
+
|
|
289
|
+
# Look for files in the track output directory
|
|
290
|
+
search_dir = track_output_dir
|
|
291
|
+
|
|
292
|
+
# Find all instrumental files (not padded ones - we want the originals)
|
|
293
|
+
instrumental_pattern = os.path.join(search_dir, f"{artist_title} (Instrumental*.flac")
|
|
294
|
+
instrumental_files = glob.glob(instrumental_pattern)
|
|
295
|
+
|
|
296
|
+
# Also check for wav files
|
|
297
|
+
instrumental_pattern_wav = os.path.join(search_dir, f"{artist_title} (Instrumental*.wav")
|
|
298
|
+
instrumental_files.extend(glob.glob(instrumental_pattern_wav))
|
|
299
|
+
|
|
300
|
+
self.logger.debug(f"Found {len(instrumental_files)} instrumental files")
|
|
301
|
+
|
|
302
|
+
for filepath in instrumental_files:
|
|
303
|
+
filename = os.path.basename(filepath)
|
|
304
|
+
|
|
305
|
+
# Skip already padded files
|
|
306
|
+
if "(Padded)" in filename:
|
|
307
|
+
self.logger.debug(f"Skipping already padded file: {filename}")
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
# Determine if it's a combined instrumental (+BV) or clean instrumental
|
|
311
|
+
if "+BV" in filename or "+bv" in filename.lower():
|
|
312
|
+
# Combined instrumental with backing vocals
|
|
313
|
+
# Extract model name from filename
|
|
314
|
+
# Pattern: "(Instrumental +BV {model}).flac"
|
|
315
|
+
model_match = re.search(r'\(Instrumental \+BV ([^)]+)\)', filename)
|
|
316
|
+
if model_match:
|
|
317
|
+
model_name = model_match.group(1).strip()
|
|
318
|
+
separated_audio["combined_instrumentals"][model_name] = filepath
|
|
319
|
+
self.logger.info(f"Found combined instrumental: {filename}")
|
|
320
|
+
else:
|
|
321
|
+
# Clean instrumental (no backing vocals)
|
|
322
|
+
# Pattern: "(Instrumental {model}).flac"
|
|
323
|
+
model_match = re.search(r'\(Instrumental ([^)]+)\)', filename)
|
|
324
|
+
if model_match:
|
|
325
|
+
# Use as clean instrumental if we don't have one yet
|
|
326
|
+
if not separated_audio["clean_instrumental"].get("instrumental"):
|
|
327
|
+
separated_audio["clean_instrumental"]["instrumental"] = filepath
|
|
328
|
+
self.logger.info(f"Found clean instrumental: {filename}")
|
|
329
|
+
else:
|
|
330
|
+
# Additional clean instrumentals go to combined_instrumentals for padding
|
|
331
|
+
model_name = model_match.group(1).strip()
|
|
332
|
+
separated_audio["combined_instrumentals"][model_name] = filepath
|
|
333
|
+
self.logger.info(f"Found additional instrumental: {filename}")
|
|
334
|
+
|
|
335
|
+
# Also look for backing vocals files
|
|
336
|
+
backing_vocals_pattern = os.path.join(search_dir, f"{artist_title} (Backing Vocals*.flac")
|
|
337
|
+
backing_vocals_files = glob.glob(backing_vocals_pattern)
|
|
338
|
+
backing_vocals_pattern_wav = os.path.join(search_dir, f"{artist_title} (Backing Vocals*.wav")
|
|
339
|
+
backing_vocals_files.extend(glob.glob(backing_vocals_pattern_wav))
|
|
340
|
+
|
|
341
|
+
for filepath in backing_vocals_files:
|
|
342
|
+
filename = os.path.basename(filepath)
|
|
343
|
+
model_match = re.search(r'\(Backing Vocals ([^)]+)\)', filename)
|
|
344
|
+
if model_match:
|
|
345
|
+
model_name = model_match.group(1).strip()
|
|
346
|
+
if model_name not in separated_audio["backing_vocals"]:
|
|
347
|
+
separated_audio["backing_vocals"][model_name] = {"backing_vocals": filepath}
|
|
348
|
+
self.logger.info(f"Found backing vocals: {filename}")
|
|
349
|
+
|
|
350
|
+
# Log summary
|
|
351
|
+
clean_count = 1 if separated_audio["clean_instrumental"].get("instrumental") else 0
|
|
352
|
+
combined_count = len(separated_audio["combined_instrumentals"])
|
|
353
|
+
self.logger.info(f"Directory scan complete: {clean_count} clean instrumental, {combined_count} combined instrumentals")
|
|
354
|
+
|
|
355
|
+
return separated_audio
|
|
356
|
+
|
|
357
|
+
async def prep_single_track(self):
|
|
358
|
+
# Add signal handler at the start
|
|
359
|
+
loop = asyncio.get_running_loop()
|
|
360
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
361
|
+
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self.shutdown(s)))
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
self.logger.info(f"Preparing single track: {self.artist} - {self.title}")
|
|
365
|
+
|
|
366
|
+
# Determine extractor early based on input type
|
|
367
|
+
# Assume self.extractor, self.url, self.media_id etc. are set by process() before calling this
|
|
368
|
+
if self.input_media and os.path.isfile(self.input_media):
|
|
369
|
+
if not self.extractor: # If extractor wasn't somehow set before (e.g., direct call)
|
|
370
|
+
self.extractor = "Original"
|
|
371
|
+
elif self.url: # If it's a URL (set by process)
|
|
372
|
+
if not self.extractor: # Should have been set by parse_track_metadata in process()
|
|
373
|
+
self.logger.warning("Extractor not set before prep_single_track for URL, attempting fallback logic.")
|
|
374
|
+
# Fallback logic (less ideal, relies on potentially missing info)
|
|
375
|
+
if self.extracted_info and self.extracted_info.get('extractor'):
|
|
376
|
+
self.extractor = self.extracted_info['extractor']
|
|
377
|
+
elif self.media_id: # Try to guess based on ID format
|
|
378
|
+
# Basic youtube id check
|
|
379
|
+
if re.match(r'^[a-zA-Z0-9_-]{11}$', self.media_id):
|
|
380
|
+
self.extractor = "youtube"
|
|
381
|
+
else:
|
|
382
|
+
self.extractor = "UnknownSource" # Fallback if ID doesn't look like youtube
|
|
383
|
+
else:
|
|
384
|
+
self.extractor = "UnknownSource" # Final fallback
|
|
385
|
+
self.logger.info(f"Fallback extractor set to: {self.extractor}")
|
|
386
|
+
elif self.input_media: # Not a file, not a URL -> maybe a direct URL string?
|
|
387
|
+
self.logger.warning(f"Input media '{self.input_media}' is not a file and self.url was not set. Attempting to treat as URL.")
|
|
388
|
+
# This path requires calling extract/parse again, less efficient
|
|
389
|
+
try:
|
|
390
|
+
extracted = extract_info_for_online_media(self.input_media, self.artist, self.title, self.logger, self.cookies_str)
|
|
391
|
+
if extracted:
|
|
392
|
+
metadata_result = parse_track_metadata(
|
|
393
|
+
extracted, self.artist, self.title, self.persistent_artist, self.logger
|
|
394
|
+
)
|
|
395
|
+
self.url = metadata_result["url"]
|
|
396
|
+
self.extractor = metadata_result["extractor"]
|
|
397
|
+
self.media_id = metadata_result["media_id"]
|
|
398
|
+
self.artist = metadata_result["artist"]
|
|
399
|
+
self.title = metadata_result["title"]
|
|
400
|
+
self.logger.info(f"Successfully extracted metadata within prep_single_track for {self.input_media}")
|
|
401
|
+
else:
|
|
402
|
+
self.logger.error(f"Could not extract info for {self.input_media} within prep_single_track.")
|
|
403
|
+
self.extractor = "ErrorExtracting"
|
|
404
|
+
return None # Cannot proceed without metadata
|
|
405
|
+
except Exception as meta_exc:
|
|
406
|
+
self.logger.error(f"Error during metadata extraction/parsing within prep_single_track: {meta_exc}")
|
|
407
|
+
self.extractor = "ErrorParsing"
|
|
408
|
+
return None # Cannot proceed
|
|
409
|
+
else:
|
|
410
|
+
# If it's neither file nor URL, and input_media is None, check for existing files
|
|
411
|
+
# This path is mainly for the case where files exist from previous run
|
|
412
|
+
# We still need artist/title for filename generation
|
|
413
|
+
if not self.artist or not self.title:
|
|
414
|
+
self.logger.error("Cannot determine output path without artist/title when input_media is None and not a URL.")
|
|
415
|
+
return None
|
|
416
|
+
self.logger.info("Input media is None, assuming check for existing files based on artist/title.")
|
|
417
|
+
# We need a nominal extractor for filename matching if files exist
|
|
418
|
+
# Let's default to 'UnknownExisting' or try to infer if possible later
|
|
419
|
+
if not self.extractor:
|
|
420
|
+
self.extractor = "UnknownExisting"
|
|
421
|
+
|
|
422
|
+
if not self.extractor:
|
|
423
|
+
self.logger.error("Could not determine extractor for the track.")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
# Now self.extractor should be set correctly for path generation etc.
|
|
427
|
+
|
|
428
|
+
self.logger.info(f"Preparing output path for track: {self.title} by {self.artist} (Extractor: {self.extractor})")
|
|
429
|
+
if self.dry_run:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
# Delegate to FileHandler
|
|
433
|
+
track_output_dir, artist_title = self.file_handler.setup_output_paths(self.output_dir, self.artist, self.title)
|
|
434
|
+
|
|
435
|
+
processed_track = {
|
|
436
|
+
"track_output_dir": track_output_dir,
|
|
437
|
+
"artist": self.artist,
|
|
438
|
+
"title": self.title,
|
|
439
|
+
"extractor": self.extractor,
|
|
440
|
+
"extracted_info": self.extracted_info,
|
|
441
|
+
"lyrics": None,
|
|
442
|
+
"processed_lyrics": None,
|
|
443
|
+
"separated_audio": {},
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
processed_track["input_media"] = None
|
|
447
|
+
processed_track["input_still_image"] = None
|
|
448
|
+
processed_track["input_audio_wav"] = None
|
|
449
|
+
|
|
450
|
+
if self.input_media and os.path.isfile(self.input_media):
|
|
451
|
+
# --- Local File Input Handling ---
|
|
452
|
+
input_wav_filename_pattern = os.path.join(track_output_dir, f"{artist_title} ({self.extractor}*).wav")
|
|
453
|
+
input_wav_glob = glob.glob(input_wav_filename_pattern)
|
|
454
|
+
|
|
455
|
+
if input_wav_glob:
|
|
456
|
+
processed_track["input_audio_wav"] = input_wav_glob[0]
|
|
457
|
+
self.logger.info(f"Input media WAV file already exists, skipping conversion: {processed_track['input_audio_wav']}")
|
|
458
|
+
else:
|
|
459
|
+
output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({self.extractor})")
|
|
460
|
+
|
|
461
|
+
self.logger.info(f"Copying input media from {self.input_media} to new directory...")
|
|
462
|
+
# Delegate to FileHandler
|
|
463
|
+
processed_track["input_media"] = self.file_handler.copy_input_media(self.input_media, output_filename_no_extension)
|
|
464
|
+
|
|
465
|
+
self.logger.info("Converting input media to WAV for audio processing...")
|
|
466
|
+
# Delegate to FileHandler
|
|
467
|
+
processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(processed_track["input_media"], output_filename_no_extension)
|
|
468
|
+
|
|
469
|
+
else:
|
|
470
|
+
# --- AudioFetcher or Existing Files Handling ---
|
|
471
|
+
# Construct patterns using the determined extractor
|
|
472
|
+
base_pattern = os.path.join(track_output_dir, f"{artist_title} ({self.extractor}*)")
|
|
473
|
+
input_media_glob = glob.glob(f"{base_pattern}.*flac") + glob.glob(f"{base_pattern}.*mp3") + glob.glob(f"{base_pattern}.*wav") + glob.glob(f"{base_pattern}.*webm") + glob.glob(f"{base_pattern}.*mp4")
|
|
474
|
+
input_png_glob = glob.glob(f"{base_pattern}.png")
|
|
475
|
+
input_wav_glob = glob.glob(f"{base_pattern}.wav")
|
|
476
|
+
|
|
477
|
+
if input_media_glob and input_wav_glob:
|
|
478
|
+
# Existing files found
|
|
479
|
+
processed_track["input_media"] = input_media_glob[0]
|
|
480
|
+
processed_track["input_still_image"] = input_png_glob[0] if input_png_glob else None
|
|
481
|
+
processed_track["input_audio_wav"] = input_wav_glob[0]
|
|
482
|
+
self.logger.info(f"Found existing media files matching extractor '{self.extractor}', skipping download/conversion.")
|
|
483
|
+
|
|
484
|
+
elif getattr(self, '_use_audio_fetcher', False):
|
|
485
|
+
# Use flacfetch to search and download audio
|
|
486
|
+
self.logger.info(f"Using flacfetch to search and download: {self.artist} - {self.title}")
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
# Search and download audio using the AudioFetcher
|
|
490
|
+
fetch_result = self.audio_fetcher.search_and_download(
|
|
491
|
+
artist=self.artist,
|
|
492
|
+
title=self.title,
|
|
493
|
+
output_dir=track_output_dir,
|
|
494
|
+
output_filename=f"{artist_title} (flacfetch)",
|
|
495
|
+
auto_select=self.auto_download,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Update extractor to reflect the actual provider used
|
|
499
|
+
self.extractor = f"flacfetch-{fetch_result.provider}"
|
|
500
|
+
|
|
501
|
+
# Set up the output paths
|
|
502
|
+
output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({self.extractor})")
|
|
503
|
+
|
|
504
|
+
# Copy/move the downloaded file to the expected location
|
|
505
|
+
processed_track["input_media"] = self.file_handler.download_audio_from_fetcher_result(
|
|
506
|
+
fetch_result.filepath, output_filename_no_extension
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
self.logger.info(f"Audio downloaded from {fetch_result.provider}: {processed_track['input_media']}")
|
|
510
|
+
|
|
511
|
+
# Convert to WAV for audio processing
|
|
512
|
+
self.logger.info("Converting downloaded audio to WAV for processing...")
|
|
513
|
+
processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(
|
|
514
|
+
processed_track["input_media"], output_filename_no_extension
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# No still image for audio-only downloads
|
|
518
|
+
processed_track["input_still_image"] = None
|
|
519
|
+
|
|
520
|
+
except UserCancelledError:
|
|
521
|
+
# User cancelled - propagate up to CLI for graceful exit
|
|
522
|
+
raise
|
|
523
|
+
except NoResultsError as e:
|
|
524
|
+
self.logger.error(f"No audio found: {e}")
|
|
525
|
+
return None
|
|
526
|
+
except AudioFetcherError as e:
|
|
527
|
+
self.logger.error(f"Failed to fetch audio: {e}")
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
else:
|
|
531
|
+
# This case means input_media was None, no audio fetcher flag, and no existing files found
|
|
532
|
+
self.logger.error(f"Cannot proceed: No input file and no existing files found for {artist_title}.")
|
|
533
|
+
self.logger.error("Please provide a local audio file or use artist+title to search for audio.")
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
if self.skip_lyrics:
|
|
537
|
+
self.logger.info("Skipping lyrics fetch as requested.")
|
|
538
|
+
processed_track["lyrics"] = None
|
|
539
|
+
processed_track["processed_lyrics"] = None
|
|
540
|
+
# No countdown padding when lyrics are skipped
|
|
541
|
+
processed_track["countdown_padding_added"] = False
|
|
542
|
+
processed_track["countdown_padding_seconds"] = 0.0
|
|
543
|
+
processed_track["padded_vocals_audio"] = None
|
|
544
|
+
else:
|
|
545
|
+
lyrics_artist = self.lyrics_artist or self.artist
|
|
546
|
+
lyrics_title = self.lyrics_title or self.title
|
|
547
|
+
|
|
548
|
+
# Create futures for both operations
|
|
549
|
+
transcription_future = None
|
|
550
|
+
separation_future = None
|
|
551
|
+
|
|
552
|
+
self.logger.info("=== Starting Parallel Processing ===")
|
|
553
|
+
|
|
554
|
+
if not self.skip_lyrics:
|
|
555
|
+
self.logger.info("Creating transcription future...")
|
|
556
|
+
# Run transcription in a separate thread
|
|
557
|
+
transcription_future = asyncio.create_task(
|
|
558
|
+
asyncio.to_thread(
|
|
559
|
+
# Delegate to LyricsProcessor - pass original artist/title for filenames, lyrics_artist/lyrics_title for processing
|
|
560
|
+
self.lyrics_processor.transcribe_lyrics,
|
|
561
|
+
processed_track["input_audio_wav"],
|
|
562
|
+
self.artist, # Original artist for filename generation
|
|
563
|
+
self.title, # Original title for filename generation
|
|
564
|
+
track_output_dir,
|
|
565
|
+
lyrics_artist, # Lyrics artist for processing
|
|
566
|
+
lyrics_title # Lyrics title for processing
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
self.logger.info(f"Transcription future created, type: {type(transcription_future)}")
|
|
570
|
+
|
|
571
|
+
# Default to a placeholder task if separation won't run
|
|
572
|
+
separation_future = asyncio.create_task(asyncio.sleep(0))
|
|
573
|
+
|
|
574
|
+
# Only create real separation future if not skipping AND no existing instrumental provided
|
|
575
|
+
if not self.skip_separation and not self.existing_instrumental:
|
|
576
|
+
self.logger.info("Creating separation future (not skipping and no existing instrumental)...")
|
|
577
|
+
# Run separation in a separate thread
|
|
578
|
+
separation_future = asyncio.create_task(
|
|
579
|
+
asyncio.to_thread(
|
|
580
|
+
# Delegate to AudioProcessor
|
|
581
|
+
self.audio_processor.process_audio_separation,
|
|
582
|
+
audio_file=processed_track["input_audio_wav"],
|
|
583
|
+
artist_title=artist_title,
|
|
584
|
+
track_output_dir=track_output_dir,
|
|
585
|
+
)
|
|
586
|
+
)
|
|
587
|
+
self.logger.info(f"Separation future created, type: {type(separation_future)}")
|
|
588
|
+
elif self.existing_instrumental:
|
|
589
|
+
self.logger.info(f"Skipping separation future creation because existing instrumental was provided: {self.existing_instrumental}")
|
|
590
|
+
elif self.skip_separation: # Check this condition explicitly for clarity
|
|
591
|
+
self.logger.info("Skipping separation future creation because skip_separation is True.")
|
|
592
|
+
|
|
593
|
+
self.logger.info("About to await both operations with asyncio.gather...")
|
|
594
|
+
# Wait for both operations to complete
|
|
595
|
+
try:
|
|
596
|
+
results = await asyncio.gather(
|
|
597
|
+
transcription_future if transcription_future else asyncio.sleep(0), # Use placeholder if None
|
|
598
|
+
separation_future, # Already defaults to placeholder if not created
|
|
599
|
+
return_exceptions=True,
|
|
600
|
+
)
|
|
601
|
+
except asyncio.CancelledError:
|
|
602
|
+
self.logger.info("Received cancellation request, cleaning up...")
|
|
603
|
+
# Cancel any running futures
|
|
604
|
+
if transcription_future and not transcription_future.done():
|
|
605
|
+
transcription_future.cancel()
|
|
606
|
+
if separation_future and not separation_future.done() and not isinstance(separation_future, asyncio.Task): # Check if it's a real task
|
|
607
|
+
# Don't try to cancel the asyncio.sleep(0) placeholder
|
|
608
|
+
separation_future.cancel()
|
|
609
|
+
|
|
610
|
+
# Wait for futures to complete cancellation
|
|
611
|
+
await asyncio.gather(
|
|
612
|
+
transcription_future if transcription_future else asyncio.sleep(0),
|
|
613
|
+
separation_future if separation_future else asyncio.sleep(0), # Use placeholder if None/Placeholder
|
|
614
|
+
return_exceptions=True,
|
|
615
|
+
)
|
|
616
|
+
raise
|
|
617
|
+
|
|
618
|
+
# Handle transcription results
|
|
619
|
+
if transcription_future:
|
|
620
|
+
self.logger.info("Processing transcription results...")
|
|
621
|
+
try:
|
|
622
|
+
# Index 0 corresponds to transcription_future in gather
|
|
623
|
+
transcriber_outputs = results[0]
|
|
624
|
+
# Check if the result is an exception or the actual output
|
|
625
|
+
if isinstance(transcriber_outputs, Exception):
|
|
626
|
+
self.logger.error(f"Error during lyrics transcription: {transcriber_outputs}")
|
|
627
|
+
# Optionally log traceback: self.logger.exception("Transcription error:")
|
|
628
|
+
raise transcriber_outputs # Re-raise the exception
|
|
629
|
+
elif transcriber_outputs is not None and not isinstance(transcriber_outputs, asyncio.futures.Future): # Ensure it's not the placeholder future
|
|
630
|
+
self.logger.info(f"Successfully received transcription outputs: {type(transcriber_outputs)}")
|
|
631
|
+
# Ensure transcriber_outputs is a dictionary before calling .get()
|
|
632
|
+
if isinstance(transcriber_outputs, dict):
|
|
633
|
+
self.lyrics = transcriber_outputs.get("corrected_lyrics_text")
|
|
634
|
+
processed_track["lyrics"] = transcriber_outputs.get("corrected_lyrics_text_filepath")
|
|
635
|
+
|
|
636
|
+
# Capture countdown padding information
|
|
637
|
+
processed_track["countdown_padding_added"] = transcriber_outputs.get("countdown_padding_added", False)
|
|
638
|
+
processed_track["countdown_padding_seconds"] = transcriber_outputs.get("countdown_padding_seconds", 0.0)
|
|
639
|
+
processed_track["padded_vocals_audio"] = transcriber_outputs.get("padded_audio_filepath")
|
|
640
|
+
|
|
641
|
+
# Store ASS filepath for video background processing
|
|
642
|
+
processed_track["ass_filepath"] = transcriber_outputs.get("ass_filepath")
|
|
643
|
+
|
|
644
|
+
if processed_track["countdown_padding_added"]:
|
|
645
|
+
self.logger.info(
|
|
646
|
+
f"=== COUNTDOWN PADDING DETECTED ==="
|
|
647
|
+
)
|
|
648
|
+
self.logger.info(
|
|
649
|
+
f"Vocals have been padded with {processed_track['countdown_padding_seconds']}s of silence. "
|
|
650
|
+
f"Instrumental tracks will be padded after separation to maintain synchronization."
|
|
651
|
+
)
|
|
652
|
+
else:
|
|
653
|
+
self.logger.warning(f"Unexpected type for transcriber_outputs: {type(transcriber_outputs)}, value: {transcriber_outputs}")
|
|
654
|
+
else:
|
|
655
|
+
self.logger.info("Transcription task did not return results (possibly skipped or placeholder).")
|
|
656
|
+
except Exception as e:
|
|
657
|
+
self.logger.error(f"Error processing transcription results: {e}")
|
|
658
|
+
self.logger.exception("Full traceback:")
|
|
659
|
+
raise # Re-raise the exception
|
|
660
|
+
|
|
661
|
+
# Handle separation results only if a real future was created and ran
|
|
662
|
+
# Check if separation_future was the placeholder or a real task
|
|
663
|
+
# The result index in `results` depends on whether transcription_future existed
|
|
664
|
+
separation_result_index = 1 if transcription_future else 0
|
|
665
|
+
if separation_future is not None and isinstance(separation_future, asyncio.Task) and len(results) > separation_result_index:
|
|
666
|
+
self.logger.info("Processing separation results...")
|
|
667
|
+
try:
|
|
668
|
+
separation_results = results[separation_result_index]
|
|
669
|
+
# Check if the result is an exception or the actual output
|
|
670
|
+
if isinstance(separation_results, Exception):
|
|
671
|
+
self.logger.error(f"Error during audio separation: {separation_results}")
|
|
672
|
+
# Optionally log traceback: self.logger.exception("Separation error:")
|
|
673
|
+
# Decide if you want to raise here or just log
|
|
674
|
+
elif separation_results is not None and not isinstance(separation_results, asyncio.futures.Future): # Ensure it's not the placeholder future
|
|
675
|
+
self.logger.info(f"Successfully received separation results: {type(separation_results)}")
|
|
676
|
+
if isinstance(separation_results, dict):
|
|
677
|
+
processed_track["separated_audio"] = separation_results
|
|
678
|
+
else:
|
|
679
|
+
self.logger.warning(f"Unexpected type for separation_results: {type(separation_results)}, value: {separation_results}")
|
|
680
|
+
else:
|
|
681
|
+
self.logger.info("Separation task did not return results (possibly skipped or placeholder).")
|
|
682
|
+
except Exception as e:
|
|
683
|
+
self.logger.error(f"Error processing separation results: {e}")
|
|
684
|
+
self.logger.exception("Full traceback:")
|
|
685
|
+
# Decide if you want to raise here or just log
|
|
686
|
+
elif not self.skip_separation and not self.existing_instrumental:
|
|
687
|
+
# This case means separation was supposed to run but didn't return results properly
|
|
688
|
+
self.logger.warning("Separation task was expected but did not yield results or resulted in an error captured earlier.")
|
|
689
|
+
else:
|
|
690
|
+
# This case means separation was intentionally skipped
|
|
691
|
+
self.logger.info("Skipping processing of separation results as separation was not run.")
|
|
692
|
+
|
|
693
|
+
self.logger.info("=== Parallel Processing Complete ===")
|
|
694
|
+
|
|
695
|
+
# Apply video background if requested and lyrics were processed
|
|
696
|
+
if self.video_background_processor and processed_track.get("lyrics"):
|
|
697
|
+
self.logger.info("=== Processing Video Background ===")
|
|
698
|
+
|
|
699
|
+
# Find the With Vocals video file
|
|
700
|
+
with_vocals_video = os.path.join(track_output_dir, f"{artist_title} (With Vocals).mkv")
|
|
701
|
+
|
|
702
|
+
# Get ASS file from transcriber outputs if available
|
|
703
|
+
ass_file = processed_track.get("ass_filepath")
|
|
704
|
+
|
|
705
|
+
# If not in processed_track, try to find it in common locations
|
|
706
|
+
if not ass_file or not os.path.exists(ass_file):
|
|
707
|
+
self.logger.info("ASS filepath not found in transcriber outputs, searching for it...")
|
|
708
|
+
from .utils import sanitize_filename
|
|
709
|
+
sanitized_artist = sanitize_filename(self.artist)
|
|
710
|
+
sanitized_title = sanitize_filename(self.title)
|
|
711
|
+
lyrics_dir = os.path.join(track_output_dir, "lyrics")
|
|
712
|
+
|
|
713
|
+
possible_ass_files = [
|
|
714
|
+
os.path.join(lyrics_dir, f"{sanitized_artist} - {sanitized_title}.ass"),
|
|
715
|
+
os.path.join(track_output_dir, f"{sanitized_artist} - {sanitized_title}.ass"),
|
|
716
|
+
os.path.join(lyrics_dir, f"{artist_title}.ass"),
|
|
717
|
+
os.path.join(track_output_dir, f"{artist_title}.ass"),
|
|
718
|
+
os.path.join(track_output_dir, f"{artist_title} (Karaoke).ass"),
|
|
719
|
+
os.path.join(lyrics_dir, f"{artist_title} (Karaoke).ass"),
|
|
720
|
+
]
|
|
721
|
+
|
|
722
|
+
for possible_file in possible_ass_files:
|
|
723
|
+
if os.path.exists(possible_file):
|
|
724
|
+
ass_file = possible_file
|
|
725
|
+
self.logger.info(f"Found ASS subtitle file: {ass_file}")
|
|
726
|
+
break
|
|
727
|
+
|
|
728
|
+
if os.path.exists(with_vocals_video) and ass_file and os.path.exists(ass_file):
|
|
729
|
+
self.logger.info(f"Found With Vocals video, will replace with video background: {with_vocals_video}")
|
|
730
|
+
self.logger.info(f"Using ASS subtitle file: {ass_file}")
|
|
731
|
+
|
|
732
|
+
# Get audio duration
|
|
733
|
+
audio_duration = self.video_background_processor.get_audio_duration(processed_track["input_audio_wav"])
|
|
734
|
+
|
|
735
|
+
# Check if we need to use the padded audio instead
|
|
736
|
+
if processed_track.get("countdown_padding_added") and processed_track.get("padded_vocals_audio"):
|
|
737
|
+
self.logger.info(f"Using padded vocals audio for video background processing")
|
|
738
|
+
audio_for_video = processed_track["padded_vocals_audio"]
|
|
739
|
+
else:
|
|
740
|
+
audio_for_video = processed_track["input_audio_wav"]
|
|
741
|
+
|
|
742
|
+
# Process video background
|
|
743
|
+
try:
|
|
744
|
+
self.video_background_processor.process_video_background(
|
|
745
|
+
video_path=self.background_video,
|
|
746
|
+
audio_path=audio_for_video,
|
|
747
|
+
ass_subtitles_path=ass_file,
|
|
748
|
+
output_path=with_vocals_video,
|
|
749
|
+
darkness_percent=self.background_video_darkness,
|
|
750
|
+
audio_duration=audio_duration,
|
|
751
|
+
)
|
|
752
|
+
self.logger.info(f"✓ Video background applied, With Vocals video updated: {with_vocals_video}")
|
|
753
|
+
except Exception as e:
|
|
754
|
+
self.logger.error(f"Failed to apply video background: {e}")
|
|
755
|
+
self.logger.exception("Full traceback:")
|
|
756
|
+
# Continue with original video if background processing fails
|
|
757
|
+
else:
|
|
758
|
+
if not os.path.exists(with_vocals_video):
|
|
759
|
+
self.logger.warning(f"With Vocals video not found at {with_vocals_video}, skipping video background processing")
|
|
760
|
+
elif not ass_file or not os.path.exists(ass_file):
|
|
761
|
+
self.logger.warning("Could not find ASS subtitle file, skipping video background processing")
|
|
762
|
+
if 'possible_ass_files' in locals():
|
|
763
|
+
self.logger.warning("Searched locations:")
|
|
764
|
+
for possible_file in possible_ass_files:
|
|
765
|
+
self.logger.warning(f" - {possible_file}")
|
|
766
|
+
|
|
767
|
+
output_image_filepath_noext = os.path.join(track_output_dir, f"{artist_title} (Title)")
|
|
768
|
+
processed_track["title_image_png"] = f"{output_image_filepath_noext}.png"
|
|
769
|
+
processed_track["title_image_jpg"] = f"{output_image_filepath_noext}.jpg"
|
|
770
|
+
processed_track["title_video"] = os.path.join(track_output_dir, f"{artist_title} (Title).mov")
|
|
771
|
+
|
|
772
|
+
# Use FileHandler._file_exists
|
|
773
|
+
if not self.file_handler._file_exists(processed_track["title_video"]) and not os.environ.get("KARAOKE_GEN_SKIP_TITLE_END_SCREENS"):
|
|
774
|
+
self.logger.info(f"Creating title video...")
|
|
775
|
+
# Delegate to VideoGenerator
|
|
776
|
+
self.video_generator.create_title_video(
|
|
777
|
+
artist=self.artist,
|
|
778
|
+
title=self.title,
|
|
779
|
+
format=self.title_format,
|
|
780
|
+
output_image_filepath_noext=output_image_filepath_noext,
|
|
781
|
+
output_video_filepath=processed_track["title_video"],
|
|
782
|
+
existing_title_image=self.existing_title_image,
|
|
783
|
+
intro_video_duration=self.intro_video_duration,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
output_image_filepath_noext = os.path.join(track_output_dir, f"{artist_title} (End)")
|
|
787
|
+
processed_track["end_image_png"] = f"{output_image_filepath_noext}.png"
|
|
788
|
+
processed_track["end_image_jpg"] = f"{output_image_filepath_noext}.jpg"
|
|
789
|
+
processed_track["end_video"] = os.path.join(track_output_dir, f"{artist_title} (End).mov")
|
|
790
|
+
|
|
791
|
+
# Use FileHandler._file_exists
|
|
792
|
+
if not self.file_handler._file_exists(processed_track["end_video"]) and not os.environ.get("KARAOKE_GEN_SKIP_TITLE_END_SCREENS"):
|
|
793
|
+
self.logger.info(f"Creating end screen video...")
|
|
794
|
+
# Delegate to VideoGenerator
|
|
795
|
+
self.video_generator.create_end_video(
|
|
796
|
+
artist=self.artist,
|
|
797
|
+
title=self.title,
|
|
798
|
+
format=self.end_format,
|
|
799
|
+
output_image_filepath_noext=output_image_filepath_noext,
|
|
800
|
+
output_video_filepath=processed_track["end_video"],
|
|
801
|
+
existing_end_image=self.existing_end_image,
|
|
802
|
+
end_video_duration=self.end_video_duration,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
if self.skip_separation:
|
|
806
|
+
self.logger.info("Skipping audio separation as requested.")
|
|
807
|
+
processed_track["separated_audio"] = {
|
|
808
|
+
"clean_instrumental": {},
|
|
809
|
+
"backing_vocals": {},
|
|
810
|
+
"other_stems": {},
|
|
811
|
+
"combined_instrumentals": {},
|
|
812
|
+
}
|
|
813
|
+
elif self.existing_instrumental:
|
|
814
|
+
self.logger.info(f"Using existing instrumental file: {self.existing_instrumental}")
|
|
815
|
+
existing_instrumental_extension = os.path.splitext(self.existing_instrumental)[1]
|
|
816
|
+
|
|
817
|
+
instrumental_path = os.path.join(track_output_dir, f"{artist_title} (Instrumental Custom){existing_instrumental_extension}")
|
|
818
|
+
|
|
819
|
+
# Use FileHandler._file_exists
|
|
820
|
+
if not self.file_handler._file_exists(instrumental_path):
|
|
821
|
+
shutil.copy2(self.existing_instrumental, instrumental_path)
|
|
822
|
+
|
|
823
|
+
processed_track["separated_audio"]["Custom"] = {
|
|
824
|
+
"instrumental": instrumental_path,
|
|
825
|
+
"vocals": None,
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
# If countdown padding was added to vocals, pad the custom instrumental too
|
|
829
|
+
if processed_track.get("countdown_padding_added", False):
|
|
830
|
+
padding_seconds = processed_track["countdown_padding_seconds"]
|
|
831
|
+
self.logger.info(
|
|
832
|
+
f"Countdown padding detected - applying {padding_seconds}s padding to custom instrumental"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
base, ext = os.path.splitext(instrumental_path)
|
|
836
|
+
padded_instrumental_path = f"{base} (Padded){ext}"
|
|
837
|
+
|
|
838
|
+
if not self.file_handler._file_exists(padded_instrumental_path):
|
|
839
|
+
self.audio_processor.pad_audio_file(instrumental_path, padded_instrumental_path, padding_seconds)
|
|
840
|
+
|
|
841
|
+
# Update the path to use the padded version
|
|
842
|
+
processed_track["separated_audio"]["Custom"]["instrumental"] = padded_instrumental_path
|
|
843
|
+
self.logger.info(f"✓ Custom instrumental has been padded and synchronized with vocals")
|
|
844
|
+
elif "separated_audio" not in processed_track or not processed_track["separated_audio"]:
|
|
845
|
+
# Only run separation if it wasn't already done in parallel processing
|
|
846
|
+
self.logger.info(f"Separation was not completed in parallel processing, running separation for track: {self.title} by {self.artist}")
|
|
847
|
+
# Delegate to AudioProcessor (called directly, not in thread here)
|
|
848
|
+
separation_results = self.audio_processor.process_audio_separation(
|
|
849
|
+
audio_file=processed_track["input_audio_wav"], artist_title=artist_title, track_output_dir=track_output_dir
|
|
850
|
+
)
|
|
851
|
+
processed_track["separated_audio"] = separation_results
|
|
852
|
+
else:
|
|
853
|
+
self.logger.info("Audio separation was already completed in parallel processing, skipping duplicate separation.")
|
|
854
|
+
|
|
855
|
+
# Apply countdown padding to instrumental files if needed
|
|
856
|
+
if processed_track.get("countdown_padding_added", False):
|
|
857
|
+
padding_seconds = processed_track["countdown_padding_seconds"]
|
|
858
|
+
self.logger.info(
|
|
859
|
+
f"=== APPLYING COUNTDOWN PADDING TO INSTRUMENTALS ==="
|
|
860
|
+
)
|
|
861
|
+
self.logger.info(
|
|
862
|
+
f"Applying {padding_seconds}s padding to all instrumental files to sync with vocal countdown"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# If separated_audio is empty (e.g., transcription was skipped but existing files have countdown),
|
|
866
|
+
# scan the directory for existing instrumental files
|
|
867
|
+
# Note: also check for Custom instrumental (provided via --existing_instrumental)
|
|
868
|
+
has_instrumentals = (
|
|
869
|
+
processed_track["separated_audio"].get("clean_instrumental", {}).get("instrumental") or
|
|
870
|
+
processed_track["separated_audio"].get("combined_instrumentals") or
|
|
871
|
+
processed_track["separated_audio"].get("Custom", {}).get("instrumental")
|
|
872
|
+
)
|
|
873
|
+
if not has_instrumentals:
|
|
874
|
+
self.logger.info("No instrumentals in separated_audio, scanning directory for existing files...")
|
|
875
|
+
# Preserve existing Custom key if present before overwriting
|
|
876
|
+
custom_backup = processed_track["separated_audio"].get("Custom")
|
|
877
|
+
processed_track["separated_audio"] = self._scan_directory_for_instrumentals(
|
|
878
|
+
track_output_dir, artist_title
|
|
879
|
+
)
|
|
880
|
+
if custom_backup:
|
|
881
|
+
processed_track["separated_audio"]["Custom"] = custom_backup
|
|
882
|
+
|
|
883
|
+
# Apply padding using AudioProcessor
|
|
884
|
+
padded_separation_result = self.audio_processor.apply_countdown_padding_to_instrumentals(
|
|
885
|
+
separation_result=processed_track["separated_audio"],
|
|
886
|
+
padding_seconds=padding_seconds,
|
|
887
|
+
artist_title=artist_title,
|
|
888
|
+
track_output_dir=track_output_dir,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# Update processed_track with padded file paths
|
|
892
|
+
processed_track["separated_audio"] = padded_separation_result
|
|
893
|
+
|
|
894
|
+
self.logger.info(
|
|
895
|
+
f"✓ All instrumental files have been padded and are now synchronized with vocals"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
self.logger.info("Script finished, audio downloaded, lyrics fetched and audio separated!")
|
|
899
|
+
|
|
900
|
+
return processed_track
|
|
901
|
+
|
|
902
|
+
except Exception as e:
|
|
903
|
+
self.logger.error(f"Error in prep_single_track: {e}")
|
|
904
|
+
raise
|
|
905
|
+
finally:
|
|
906
|
+
# Remove signal handlers
|
|
907
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
908
|
+
loop.remove_signal_handler(sig)
|
|
909
|
+
|
|
910
|
+
async def shutdown(self, signal_received):
|
|
911
|
+
"""Handle shutdown signals gracefully."""
|
|
912
|
+
self.logger.info(f"Received exit signal {signal_received.name}...")
|
|
913
|
+
|
|
914
|
+
# Get all running tasks except the current shutdown task
|
|
915
|
+
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
916
|
+
|
|
917
|
+
if tasks:
|
|
918
|
+
self.logger.info(f"Cancelling {len(tasks)} outstanding tasks")
|
|
919
|
+
# Cancel all running tasks
|
|
920
|
+
for task in tasks:
|
|
921
|
+
task.cancel()
|
|
922
|
+
|
|
923
|
+
# Wait for all tasks to complete with cancellation
|
|
924
|
+
# Use return_exceptions=True to gather all results without raising
|
|
925
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
926
|
+
|
|
927
|
+
self.logger.info("Cleanup complete")
|
|
928
|
+
|
|
929
|
+
# Raise KeyboardInterrupt to propagate the cancellation up the call stack
|
|
930
|
+
# This allows the main event loop to exit cleanly
|
|
931
|
+
raise KeyboardInterrupt()
|
|
932
|
+
|
|
933
|
+
async def process_playlist(self):
|
|
934
|
+
if self.artist is None or self.title is None:
|
|
935
|
+
raise Exception("Error: Artist and Title are required for processing a local file.")
|
|
936
|
+
|
|
937
|
+
if "entries" in self.extracted_info:
|
|
938
|
+
track_results = []
|
|
939
|
+
self.logger.info(f"Found {len(self.extracted_info['entries'])} entries in playlist, processing each invididually...")
|
|
940
|
+
for entry in self.extracted_info["entries"]:
|
|
941
|
+
self.extracted_info = entry
|
|
942
|
+
self.logger.info(f"Processing playlist entry with title: {self.extracted_info['title']}")
|
|
943
|
+
if not self.dry_run:
|
|
944
|
+
track_results.append(await self.prep_single_track())
|
|
945
|
+
self.artist = self.persistent_artist
|
|
946
|
+
self.title = None
|
|
947
|
+
return track_results
|
|
948
|
+
else:
|
|
949
|
+
raise Exception(f"Failed to find 'entries' in playlist, cannot process")
|
|
950
|
+
|
|
951
|
+
async def process_folder(self):
|
|
952
|
+
if self.filename_pattern is None or self.artist is None:
|
|
953
|
+
raise Exception("Error: Filename pattern and artist are required for processing a folder.")
|
|
954
|
+
|
|
955
|
+
folder_path = self.input_media
|
|
956
|
+
output_folder_path = os.path.join(os.getcwd(), os.path.basename(folder_path))
|
|
957
|
+
|
|
958
|
+
if not os.path.exists(output_folder_path):
|
|
959
|
+
if not self.dry_run:
|
|
960
|
+
self.logger.info(f"DRY RUN: Would create output folder: {output_folder_path}")
|
|
961
|
+
os.makedirs(output_folder_path)
|
|
962
|
+
else:
|
|
963
|
+
self.logger.info(f"Output folder already exists: {output_folder_path}")
|
|
964
|
+
|
|
965
|
+
pattern = re.compile(self.filename_pattern)
|
|
966
|
+
tracks = []
|
|
967
|
+
|
|
968
|
+
for filename in sorted(os.listdir(folder_path)):
|
|
969
|
+
match = pattern.match(filename)
|
|
970
|
+
if match:
|
|
971
|
+
title = match.group("title")
|
|
972
|
+
file_path = os.path.join(folder_path, filename)
|
|
973
|
+
self.input_media = file_path
|
|
974
|
+
self.title = title
|
|
975
|
+
|
|
976
|
+
track_index = match.group("index") if "index" in match.groupdict() else None
|
|
977
|
+
|
|
978
|
+
self.logger.info(f"Processing track: {track_index} with title: {title} from file: {filename}")
|
|
979
|
+
|
|
980
|
+
track_output_dir = os.path.join(output_folder_path, f"{track_index} - {self.artist} - {title}")
|
|
981
|
+
|
|
982
|
+
if not self.dry_run:
|
|
983
|
+
track = await self.prep_single_track()
|
|
984
|
+
tracks.append(track)
|
|
985
|
+
|
|
986
|
+
# Move the track folder to the output folder
|
|
987
|
+
track_folder = track["track_output_dir"]
|
|
988
|
+
shutil.move(track_folder, track_output_dir)
|
|
989
|
+
else:
|
|
990
|
+
self.logger.info(f"DRY RUN: Would move track folder to: {os.path.basename(track_output_dir)}")
|
|
991
|
+
|
|
992
|
+
return tracks
|
|
993
|
+
|
|
994
|
+
async def process(self):
|
|
995
|
+
if self.input_media is not None and os.path.isdir(self.input_media):
|
|
996
|
+
self.logger.info(f"Input media {self.input_media} is a local folder, processing each file individually...")
|
|
997
|
+
return await self.process_folder()
|
|
998
|
+
elif self.input_media is not None and os.path.isfile(self.input_media):
|
|
999
|
+
self.logger.info(f"Input media {self.input_media} is a local file, audio download will be skipped")
|
|
1000
|
+
return [await self.prep_single_track()]
|
|
1001
|
+
elif self.artist and self.title:
|
|
1002
|
+
# No input file provided - use flacfetch to search and download audio
|
|
1003
|
+
self.logger.info(f"No input file provided, using flacfetch to search for: {self.artist} - {self.title}")
|
|
1004
|
+
|
|
1005
|
+
# Set up the extracted_info for metadata consistency
|
|
1006
|
+
self.extracted_info = {
|
|
1007
|
+
"title": f"{self.artist} - {self.title}",
|
|
1008
|
+
"artist": self.artist,
|
|
1009
|
+
"track_title": self.title,
|
|
1010
|
+
"extractor_key": "flacfetch",
|
|
1011
|
+
"id": f"flacfetch_{self.artist}_{self.title}".replace(" ", "_"),
|
|
1012
|
+
"url": None,
|
|
1013
|
+
"source": "flacfetch",
|
|
1014
|
+
}
|
|
1015
|
+
self.extractor = "flacfetch"
|
|
1016
|
+
self.url = None # URL will be determined by flacfetch
|
|
1017
|
+
|
|
1018
|
+
# Mark that we need to use audio fetcher for download
|
|
1019
|
+
self._use_audio_fetcher = True
|
|
1020
|
+
|
|
1021
|
+
return [await self.prep_single_track()]
|
|
1022
|
+
else:
|
|
1023
|
+
raise ValueError(
|
|
1024
|
+
"Either a local file path or both artist and title must be provided. "
|
|
1025
|
+
"URL-based input has been replaced with flacfetch audio fetching."
|
|
1026
|
+
)
|