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.

Files changed (287) hide show
  1. karaoke_gen/__init__.py +38 -0
  2. karaoke_gen/audio_fetcher.py +1614 -0
  3. karaoke_gen/audio_processor.py +790 -0
  4. karaoke_gen/config.py +83 -0
  5. karaoke_gen/file_handler.py +387 -0
  6. karaoke_gen/instrumental_review/__init__.py +45 -0
  7. karaoke_gen/instrumental_review/analyzer.py +408 -0
  8. karaoke_gen/instrumental_review/editor.py +322 -0
  9. karaoke_gen/instrumental_review/models.py +171 -0
  10. karaoke_gen/instrumental_review/server.py +475 -0
  11. karaoke_gen/instrumental_review/static/index.html +1529 -0
  12. karaoke_gen/instrumental_review/waveform.py +409 -0
  13. karaoke_gen/karaoke_finalise/__init__.py +1 -0
  14. karaoke_gen/karaoke_finalise/karaoke_finalise.py +1833 -0
  15. karaoke_gen/karaoke_gen.py +1026 -0
  16. karaoke_gen/lyrics_processor.py +474 -0
  17. karaoke_gen/metadata.py +160 -0
  18. karaoke_gen/pipeline/__init__.py +87 -0
  19. karaoke_gen/pipeline/base.py +215 -0
  20. karaoke_gen/pipeline/context.py +230 -0
  21. karaoke_gen/pipeline/executors/__init__.py +21 -0
  22. karaoke_gen/pipeline/executors/local.py +159 -0
  23. karaoke_gen/pipeline/executors/remote.py +257 -0
  24. karaoke_gen/pipeline/stages/__init__.py +27 -0
  25. karaoke_gen/pipeline/stages/finalize.py +202 -0
  26. karaoke_gen/pipeline/stages/render.py +165 -0
  27. karaoke_gen/pipeline/stages/screens.py +139 -0
  28. karaoke_gen/pipeline/stages/separation.py +191 -0
  29. karaoke_gen/pipeline/stages/transcription.py +191 -0
  30. karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
  31. karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
  32. karaoke_gen/resources/Oswald-Bold.ttf +0 -0
  33. karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
  34. karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
  35. karaoke_gen/style_loader.py +531 -0
  36. karaoke_gen/utils/__init__.py +18 -0
  37. karaoke_gen/utils/bulk_cli.py +492 -0
  38. karaoke_gen/utils/cli_args.py +432 -0
  39. karaoke_gen/utils/gen_cli.py +978 -0
  40. karaoke_gen/utils/remote_cli.py +3268 -0
  41. karaoke_gen/video_background_processor.py +351 -0
  42. karaoke_gen/video_generator.py +424 -0
  43. karaoke_gen-0.75.54.dist-info/METADATA +718 -0
  44. karaoke_gen-0.75.54.dist-info/RECORD +287 -0
  45. karaoke_gen-0.75.54.dist-info/WHEEL +4 -0
  46. karaoke_gen-0.75.54.dist-info/entry_points.txt +5 -0
  47. karaoke_gen-0.75.54.dist-info/licenses/LICENSE +21 -0
  48. lyrics_transcriber/__init__.py +10 -0
  49. lyrics_transcriber/cli/__init__.py +0 -0
  50. lyrics_transcriber/cli/cli_main.py +285 -0
  51. lyrics_transcriber/core/__init__.py +0 -0
  52. lyrics_transcriber/core/config.py +50 -0
  53. lyrics_transcriber/core/controller.py +594 -0
  54. lyrics_transcriber/correction/__init__.py +0 -0
  55. lyrics_transcriber/correction/agentic/__init__.py +9 -0
  56. lyrics_transcriber/correction/agentic/adapter.py +71 -0
  57. lyrics_transcriber/correction/agentic/agent.py +313 -0
  58. lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
  59. lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
  60. lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
  61. lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
  62. lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
  63. lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
  64. lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
  65. lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
  66. lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
  67. lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
  68. lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
  69. lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
  70. lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
  71. lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
  72. lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
  73. lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
  74. lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
  75. lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
  76. lyrics_transcriber/correction/agentic/models/enums.py +38 -0
  77. lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
  78. lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
  79. lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
  80. lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
  81. lyrics_transcriber/correction/agentic/models/utils.py +19 -0
  82. lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
  83. lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
  84. lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
  85. lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
  86. lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
  87. lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
  88. lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
  89. lyrics_transcriber/correction/agentic/providers/base.py +36 -0
  90. lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
  91. lyrics_transcriber/correction/agentic/providers/config.py +73 -0
  92. lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
  93. lyrics_transcriber/correction/agentic/providers/health.py +28 -0
  94. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
  95. lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
  96. lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
  97. lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
  98. lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
  99. lyrics_transcriber/correction/agentic/router.py +35 -0
  100. lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
  101. lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
  102. lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
  103. lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
  104. lyrics_transcriber/correction/anchor_sequence.py +919 -0
  105. lyrics_transcriber/correction/corrector.py +760 -0
  106. lyrics_transcriber/correction/feedback/__init__.py +2 -0
  107. lyrics_transcriber/correction/feedback/schemas.py +107 -0
  108. lyrics_transcriber/correction/feedback/store.py +236 -0
  109. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  110. lyrics_transcriber/correction/handlers/base.py +52 -0
  111. lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
  112. lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
  113. lyrics_transcriber/correction/handlers/llm.py +293 -0
  114. lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
  115. lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
  116. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
  117. lyrics_transcriber/correction/handlers/repeat.py +88 -0
  118. lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
  119. lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
  120. lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
  121. lyrics_transcriber/correction/handlers/word_operations.py +187 -0
  122. lyrics_transcriber/correction/operations.py +352 -0
  123. lyrics_transcriber/correction/phrase_analyzer.py +435 -0
  124. lyrics_transcriber/correction/text_utils.py +30 -0
  125. lyrics_transcriber/frontend/.gitignore +23 -0
  126. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
  127. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  128. lyrics_transcriber/frontend/README.md +50 -0
  129. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
  130. lyrics_transcriber/frontend/__init__.py +25 -0
  131. lyrics_transcriber/frontend/eslint.config.js +28 -0
  132. lyrics_transcriber/frontend/index.html +18 -0
  133. lyrics_transcriber/frontend/package.json +42 -0
  134. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  135. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  136. lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
  137. lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
  138. lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
  139. lyrics_transcriber/frontend/public/favicon.ico +0 -0
  140. lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
  141. lyrics_transcriber/frontend/src/App.tsx +214 -0
  142. lyrics_transcriber/frontend/src/api.ts +254 -0
  143. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
  144. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
  145. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
  146. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
  147. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
  148. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
  149. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
  150. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
  151. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
  152. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
  153. lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
  154. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
  155. lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
  156. lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
  157. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  158. lyrics_transcriber/frontend/src/components/Header.tsx +413 -0
  159. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1387 -0
  160. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  161. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  162. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  163. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  164. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
  165. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  166. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
  167. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
  168. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
  170. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +336 -0
  171. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
  172. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  173. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
  174. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
  175. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
  176. lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
  177. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
  178. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
  179. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
  180. lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
  181. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
  182. lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
  183. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  184. lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
  185. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
  186. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  187. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
  188. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
  189. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
  190. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  191. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
  192. lyrics_transcriber/frontend/src/main.tsx +17 -0
  193. lyrics_transcriber/frontend/src/theme.ts +177 -0
  194. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  195. lyrics_transcriber/frontend/src/types.js +2 -0
  196. lyrics_transcriber/frontend/src/types.ts +199 -0
  197. lyrics_transcriber/frontend/src/validation.ts +132 -0
  198. lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
  199. lyrics_transcriber/frontend/tsconfig.app.json +26 -0
  200. lyrics_transcriber/frontend/tsconfig.json +25 -0
  201. lyrics_transcriber/frontend/tsconfig.node.json +23 -0
  202. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
  203. lyrics_transcriber/frontend/update_version.js +11 -0
  204. lyrics_transcriber/frontend/vite.config.d.ts +2 -0
  205. lyrics_transcriber/frontend/vite.config.js +10 -0
  206. lyrics_transcriber/frontend/vite.config.ts +11 -0
  207. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  208. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  209. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  210. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js +43288 -0
  211. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
  212. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  213. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  214. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  215. lyrics_transcriber/frontend/web_assets/index.html +18 -0
  216. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  217. lyrics_transcriber/frontend/yarn.lock +3752 -0
  218. lyrics_transcriber/lyrics/__init__.py +0 -0
  219. lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
  220. lyrics_transcriber/lyrics/file_provider.py +95 -0
  221. lyrics_transcriber/lyrics/genius.py +384 -0
  222. lyrics_transcriber/lyrics/lrclib.py +231 -0
  223. lyrics_transcriber/lyrics/musixmatch.py +156 -0
  224. lyrics_transcriber/lyrics/spotify.py +290 -0
  225. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  226. lyrics_transcriber/output/__init__.py +0 -0
  227. lyrics_transcriber/output/ass/__init__.py +21 -0
  228. lyrics_transcriber/output/ass/ass.py +2088 -0
  229. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  230. lyrics_transcriber/output/ass/config.py +180 -0
  231. lyrics_transcriber/output/ass/constants.py +23 -0
  232. lyrics_transcriber/output/ass/event.py +94 -0
  233. lyrics_transcriber/output/ass/formatters.py +132 -0
  234. lyrics_transcriber/output/ass/lyrics_line.py +265 -0
  235. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  236. lyrics_transcriber/output/ass/section_detector.py +89 -0
  237. lyrics_transcriber/output/ass/section_screen.py +106 -0
  238. lyrics_transcriber/output/ass/style.py +187 -0
  239. lyrics_transcriber/output/cdg.py +619 -0
  240. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  241. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  242. lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
  243. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  244. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  245. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  246. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  247. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  248. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  249. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  250. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  251. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  252. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  253. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  254. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  255. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  256. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  257. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  258. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  259. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  260. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  261. lyrics_transcriber/output/countdown_processor.py +306 -0
  262. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  263. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  264. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  265. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  266. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  267. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  268. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  269. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  270. lyrics_transcriber/output/generator.py +257 -0
  271. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  272. lyrics_transcriber/output/lyrics_file.py +102 -0
  273. lyrics_transcriber/output/plain_text.py +96 -0
  274. lyrics_transcriber/output/segment_resizer.py +431 -0
  275. lyrics_transcriber/output/subtitles.py +397 -0
  276. lyrics_transcriber/output/video.py +544 -0
  277. lyrics_transcriber/review/__init__.py +0 -0
  278. lyrics_transcriber/review/server.py +676 -0
  279. lyrics_transcriber/storage/__init__.py +0 -0
  280. lyrics_transcriber/storage/dropbox.py +225 -0
  281. lyrics_transcriber/transcribers/__init__.py +0 -0
  282. lyrics_transcriber/transcribers/audioshake.py +379 -0
  283. lyrics_transcriber/transcribers/base_transcriber.py +157 -0
  284. lyrics_transcriber/transcribers/whisper.py +330 -0
  285. lyrics_transcriber/types.py +650 -0
  286. lyrics_transcriber/utils/__init__.py +0 -0
  287. 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
+ )