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,978 @@
1
+ #!/usr/bin/env python
2
+ # Suppress SyntaxWarnings from third-party dependencies (pydub, syrics)
3
+ # that have invalid escape sequences in regex patterns (not yet fixed for Python 3.12+)
4
+ import warnings
5
+ warnings.filterwarnings("ignore", category=SyntaxWarning, module="pydub")
6
+ warnings.filterwarnings("ignore", category=SyntaxWarning, module="syrics")
7
+
8
+ import argparse
9
+ import logging
10
+ from importlib import metadata
11
+ import tempfile
12
+ import os
13
+ import sys
14
+ import json
15
+ import asyncio
16
+ import time
17
+ import glob
18
+ import pyperclip
19
+ from karaoke_gen import KaraokePrep
20
+ from karaoke_gen.karaoke_finalise import KaraokeFinalise
21
+ from karaoke_gen.audio_fetcher import UserCancelledError
22
+ from karaoke_gen.instrumental_review import (
23
+ AudioAnalyzer,
24
+ WaveformGenerator,
25
+ InstrumentalReviewServer,
26
+ )
27
+ from .cli_args import create_parser, process_style_overrides, is_url, is_file
28
+
29
+
30
+ def _resolve_path_for_cwd(path: str, track_dir: str) -> str:
31
+ """
32
+ Resolve a path that may have been created relative to the original working directory.
33
+
34
+ After os.chdir(track_dir), paths like './TrackDir/stems/file.flac' become invalid.
35
+ This function converts such paths to work from the new current directory.
36
+
37
+ Args:
38
+ path: The path to resolve (may be relative or absolute)
39
+ track_dir: The track directory we've chdir'd into
40
+
41
+ Returns:
42
+ A path that's valid from the current working directory
43
+ """
44
+ if os.path.isabs(path):
45
+ return path
46
+
47
+ # Normalize both paths for comparison
48
+ norm_path = os.path.normpath(path)
49
+ norm_track_dir = os.path.normpath(track_dir)
50
+
51
+ # If path starts with track_dir, strip it to get the relative path from within track_dir
52
+ # e.g., './Four Lanes Male Choir - The White Rose/stems/file.flac' -> 'stems/file.flac'
53
+ if norm_path.startswith(norm_track_dir + os.sep):
54
+ return norm_path[len(norm_track_dir) + 1:]
55
+ elif norm_path.startswith(norm_track_dir):
56
+ return norm_path[len(norm_track_dir):].lstrip(os.sep) or '.'
57
+
58
+ # If path doesn't start with track_dir, it might already be relative to track_dir
59
+ # or it's a path that doesn't need transformation
60
+ return path
61
+
62
+
63
+ def auto_select_instrumental(track: dict, track_dir: str, logger: logging.Logger) -> str:
64
+ """
65
+ Auto-select the best instrumental file when --skip_instrumental_review is used.
66
+
67
+ Selection priority:
68
+ 1. Padded combined instrumental (+BV) - synchronized with vocals + backing vocals
69
+ 2. Non-padded combined instrumental (+BV) - has backing vocals
70
+ 3. Padded clean instrumental - synchronized with vocals
71
+ 4. Non-padded clean instrumental - basic instrumental
72
+
73
+ Args:
74
+ track: The track dictionary from KaraokePrep containing separated audio info
75
+ track_dir: The track output directory (we're already chdir'd into it)
76
+ logger: Logger instance
77
+
78
+ Returns:
79
+ Path to the selected instrumental file
80
+
81
+ Raises:
82
+ FileNotFoundError: If no suitable instrumental file can be found
83
+ """
84
+ separated = track.get("separated_audio", {})
85
+
86
+ # Look for combined instrumentals first (they include backing vocals)
87
+ combined = separated.get("combined_instrumentals", {})
88
+ for model, path in combined.items():
89
+ if path:
90
+ resolved = _resolve_path_for_cwd(path, track_dir)
91
+ # Prefer padded version if it exists
92
+ base, ext = os.path.splitext(resolved)
93
+ padded = f"{base} (Padded){ext}"
94
+ if os.path.exists(padded):
95
+ logger.info(f"Auto-selected padded combined instrumental: {padded}")
96
+ return padded
97
+ if os.path.exists(resolved):
98
+ logger.info(f"Auto-selected combined instrumental: {resolved}")
99
+ return resolved
100
+
101
+ # Fall back to clean instrumental
102
+ clean = separated.get("clean_instrumental", {})
103
+ if clean.get("instrumental"):
104
+ resolved = _resolve_path_for_cwd(clean["instrumental"], track_dir)
105
+ # Prefer padded version if it exists
106
+ base, ext = os.path.splitext(resolved)
107
+ padded = f"{base} (Padded){ext}"
108
+ if os.path.exists(padded):
109
+ logger.info(f"Auto-selected padded clean instrumental: {padded}")
110
+ return padded
111
+ if os.path.exists(resolved):
112
+ logger.info(f"Auto-selected clean instrumental: {resolved}")
113
+ return resolved
114
+
115
+ # If separated_audio doesn't have what we need, search the directory
116
+ # This handles edge cases and custom instrumentals
117
+ logger.info("No instrumental found in separated_audio, searching directory...")
118
+ instrumental_files = glob.glob("*(Instrumental*.flac") + glob.glob("*(Instrumental*.wav")
119
+
120
+ # Sort to prefer padded versions and combined instrumentals
121
+ padded_combined = [f for f in instrumental_files if "(Padded)" in f and "+BV" in f]
122
+ if padded_combined:
123
+ logger.info(f"Auto-selected from directory: {padded_combined[0]}")
124
+ return padded_combined[0]
125
+
126
+ padded_files = [f for f in instrumental_files if "(Padded)" in f]
127
+ if padded_files:
128
+ logger.info(f"Auto-selected from directory: {padded_files[0]}")
129
+ return padded_files[0]
130
+
131
+ combined_files = [f for f in instrumental_files if "+BV" in f]
132
+ if combined_files:
133
+ logger.info(f"Auto-selected from directory: {combined_files[0]}")
134
+ return combined_files[0]
135
+
136
+ if instrumental_files:
137
+ logger.info(f"Auto-selected from directory: {instrumental_files[0]}")
138
+ return instrumental_files[0]
139
+
140
+ raise FileNotFoundError(
141
+ "No instrumental file found. Audio separation may have failed. "
142
+ "Check the stems/ directory for separated audio files."
143
+ )
144
+
145
+
146
+ def run_instrumental_review(track: dict, logger: logging.Logger) -> str | None:
147
+ """
148
+ Run the instrumental review UI to let user select the best instrumental track.
149
+
150
+ This analyzes the backing vocals, generates a waveform, and opens a browser
151
+ with an interactive UI for reviewing and selecting the instrumental.
152
+
153
+ Args:
154
+ track: The track dictionary from KaraokePrep containing separated audio info
155
+ logger: Logger instance
156
+
157
+ Returns:
158
+ Path to the selected instrumental file, or None to use the old numeric selection
159
+ """
160
+ track_dir = track.get("track_output_dir", ".")
161
+ artist = track.get("artist", "")
162
+ title = track.get("title", "")
163
+ base_name = f"{artist} - {title}"
164
+
165
+ # Get separation results
166
+ separated = track.get("separated_audio", {})
167
+ if not separated:
168
+ logger.info("No separated audio found, skipping instrumental review UI")
169
+ return None
170
+
171
+ # Find the backing vocals file
172
+ # Note: Paths in separated_audio may be relative to the original working directory,
173
+ # but we've already chdir'd into track_dir. Use _resolve_path_for_cwd to fix paths.
174
+ backing_vocals_path = None
175
+ backing_vocals_result = separated.get("backing_vocals", {})
176
+ for model, paths in backing_vocals_result.items():
177
+ if paths.get("backing_vocals"):
178
+ backing_vocals_path = _resolve_path_for_cwd(paths["backing_vocals"], track_dir)
179
+ break
180
+
181
+ if not backing_vocals_path or not os.path.exists(backing_vocals_path):
182
+ logger.info("No backing vocals file found, skipping instrumental review UI")
183
+ return None
184
+
185
+ # Find the clean instrumental file
186
+ clean_result = separated.get("clean_instrumental", {})
187
+ raw_clean_path = clean_result.get("instrumental")
188
+ clean_instrumental_path = _resolve_path_for_cwd(raw_clean_path, track_dir) if raw_clean_path else None
189
+
190
+ if not clean_instrumental_path or not os.path.exists(clean_instrumental_path):
191
+ logger.info("No clean instrumental file found, skipping instrumental review UI")
192
+ return None
193
+
194
+ # Find the combined instrumental (with backing vocals) file - these have "(Padded)" suffix if padded
195
+ combined_result = separated.get("combined_instrumentals", {})
196
+ with_backing_path = None
197
+ for model, path in combined_result.items():
198
+ resolved_path = _resolve_path_for_cwd(path, track_dir) if path else None
199
+ if resolved_path and os.path.exists(resolved_path):
200
+ with_backing_path = resolved_path
201
+ break
202
+
203
+ # Find the original audio file (with vocals)
204
+ original_audio_path = None
205
+ raw_original_path = track.get("input_audio_wav")
206
+ if raw_original_path:
207
+ original_audio_path = _resolve_path_for_cwd(raw_original_path, track_dir)
208
+ if not os.path.exists(original_audio_path):
209
+ logger.warning(f"Original audio file not found: {original_audio_path}")
210
+ original_audio_path = None
211
+
212
+ try:
213
+ logger.info("=== Starting Instrumental Review ===")
214
+ logger.info(f"Analyzing backing vocals: {backing_vocals_path}")
215
+
216
+ # Analyze backing vocals
217
+ analyzer = AudioAnalyzer()
218
+ analysis = analyzer.analyze(backing_vocals_path)
219
+
220
+ logger.info(f"Analysis complete:")
221
+ logger.info(f" Has audible content: {analysis.has_audible_content}")
222
+ logger.info(f" Total duration: {analysis.total_duration_seconds:.1f}s")
223
+ logger.info(f" Audible segments: {len(analysis.audible_segments)}")
224
+ logger.info(f" Recommendation: {analysis.recommended_selection.value}")
225
+
226
+ # Generate waveform
227
+ # Note: We're already in track_dir after chdir, so use current directory
228
+ logger.info("Generating waveform visualization...")
229
+ waveform_generator = WaveformGenerator()
230
+ waveform_path = f"{base_name} (Backing Vocals Waveform).png"
231
+ waveform_generator.generate(
232
+ audio_path=backing_vocals_path,
233
+ output_path=waveform_path,
234
+ segments=analysis.audible_segments,
235
+ )
236
+
237
+ # Start the review server
238
+ # Note: We're already in track_dir after chdir, so output_dir is "."
239
+ logger.info("Starting instrumental review UI...")
240
+ server = InstrumentalReviewServer(
241
+ output_dir=".",
242
+ base_name=base_name,
243
+ analysis=analysis,
244
+ waveform_path=waveform_path,
245
+ backing_vocals_path=backing_vocals_path,
246
+ clean_instrumental_path=clean_instrumental_path,
247
+ with_backing_path=with_backing_path,
248
+ original_audio_path=original_audio_path,
249
+ )
250
+
251
+ # Start server and open browser, wait for selection
252
+ server.start_and_open_browser()
253
+
254
+ logger.info("Waiting for instrumental selection in browser...")
255
+ logger.info("(Close the browser tab or press Ctrl+C to cancel)")
256
+
257
+ try:
258
+ # Wait for user selection (blocking)
259
+ server._selection_event.wait()
260
+ selection = server.get_selection()
261
+
262
+ logger.info(f"User selected: {selection}")
263
+
264
+ # Stop the server
265
+ server.stop()
266
+
267
+ # Return the selected instrumental path
268
+ if selection == "clean":
269
+ return clean_instrumental_path
270
+ elif selection == "with_backing":
271
+ return with_backing_path
272
+ elif selection == "custom":
273
+ custom_path = server.get_custom_instrumental_path()
274
+ if custom_path and os.path.exists(custom_path):
275
+ return custom_path
276
+ else:
277
+ logger.warning("Custom instrumental not found, falling back to clean")
278
+ return clean_instrumental_path
279
+ elif selection == "uploaded":
280
+ uploaded_path = server.get_uploaded_instrumental_path()
281
+ if uploaded_path and os.path.exists(uploaded_path):
282
+ return uploaded_path
283
+ else:
284
+ logger.warning("Uploaded instrumental not found, falling back to clean")
285
+ return clean_instrumental_path
286
+ else:
287
+ logger.warning(f"Unknown selection: {selection}, falling back to numeric selection")
288
+ return None
289
+
290
+ except KeyboardInterrupt:
291
+ logger.info("Instrumental review cancelled by user")
292
+ server.stop()
293
+ return None
294
+
295
+ except Exception as e:
296
+ logger.error(f"Error during instrumental review: {e}")
297
+ logger.info("Falling back to numeric selection")
298
+ return None
299
+
300
+
301
+ async def async_main():
302
+ logger = logging.getLogger(__name__)
303
+ # Prevent log propagation to root logger to avoid duplicate logs
304
+ # when external packages (like lyrics_converter) configure root logger handlers
305
+ logger.propagate = False
306
+ log_handler = logging.StreamHandler()
307
+ log_formatter = logging.Formatter(fmt="%(asctime)s.%(msecs)03d - %(levelname)s - %(module)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
308
+ log_handler.setFormatter(log_formatter)
309
+ logger.addHandler(log_handler)
310
+
311
+ # Use shared CLI parser
312
+ parser = create_parser(prog="karaoke-gen")
313
+ args = parser.parse_args()
314
+
315
+ # Set review UI URL environment variable for the lyrics transcriber review server
316
+ # Only set this if the user explicitly wants to use a dev server (e.g., http://localhost:5173)
317
+ # By default, let the ReviewServer use its bundled local frontend (served from lyrics_transcriber/frontend/)
318
+ # This enables local iteration on the frontend without redeploying
319
+ if hasattr(args, 'review_ui_url') and args.review_ui_url:
320
+ # Check if user provided a custom value (not the default hosted URL)
321
+ default_hosted_urls = [
322
+ 'https://gen.nomadkaraoke.com/lyrics',
323
+ 'https://lyrics.nomadkaraoke.com'
324
+ ]
325
+ if args.review_ui_url.rstrip('/') not in [url.rstrip('/') for url in default_hosted_urls]:
326
+ # User explicitly wants a specific URL (e.g., Vite dev server)
327
+ os.environ['LYRICS_REVIEW_UI_URL'] = args.review_ui_url
328
+
329
+ # Process style overrides
330
+ try:
331
+ style_overrides = process_style_overrides(args.style_override, logger)
332
+ except ValueError:
333
+ sys.exit(1)
334
+
335
+ # Handle test email template case first
336
+ if args.test_email_template:
337
+ log_level = getattr(logging, args.log_level.upper())
338
+ logger.setLevel(log_level)
339
+ logger.info("Testing email template functionality...")
340
+ kfinalise = KaraokeFinalise(
341
+ log_formatter=log_formatter,
342
+ log_level=log_level,
343
+ email_template_file=args.email_template_file,
344
+ )
345
+ kfinalise.test_email_template()
346
+ return
347
+
348
+ # Handle edit-lyrics mode
349
+ if args.edit_lyrics:
350
+ log_level = getattr(logging, args.log_level.upper())
351
+ logger.setLevel(log_level)
352
+ logger.info("Running in edit-lyrics mode...")
353
+
354
+ # Get the current directory name to extract artist and title
355
+ current_dir = os.path.basename(os.getcwd())
356
+ logger.info(f"Current directory: {current_dir}")
357
+
358
+ # Extract artist and title from directory name
359
+ # Format could be either "Artist - Title" or "BRAND-XXXX - Artist - Title"
360
+ if " - " not in current_dir:
361
+ logger.error("Current directory name does not contain ' - ' separator. Cannot extract artist and title.")
362
+ sys.exit(1)
363
+ return # Explicit return for testing
364
+
365
+ parts = current_dir.split(" - ")
366
+ if len(parts) == 2:
367
+ artist, title = parts
368
+ elif len(parts) >= 3:
369
+ # Handle brand code format: "BRAND-XXXX - Artist - Title"
370
+ artist = parts[1]
371
+ title = " - ".join(parts[2:])
372
+ else:
373
+ logger.error(f"Could not parse artist and title from directory name: {current_dir}")
374
+ sys.exit(1)
375
+ return # Explicit return for testing
376
+
377
+ logger.info(f"Extracted artist: {artist}, title: {title}")
378
+
379
+ # Initialize KaraokePrep
380
+ kprep_coroutine = KaraokePrep(
381
+ artist=artist,
382
+ title=title,
383
+ input_media=None, # Will be set by backup_existing_outputs
384
+ dry_run=args.dry_run,
385
+ log_formatter=log_formatter,
386
+ log_level=log_level,
387
+ render_bounding_boxes=args.render_bounding_boxes,
388
+ output_dir=".", # We're already in the track directory
389
+ create_track_subfolders=False, # Don't create subfolders, we're already in one
390
+ lossless_output_format=args.lossless_output_format,
391
+ output_png=args.output_png,
392
+ output_jpg=args.output_jpg,
393
+ clean_instrumental_model=args.clean_instrumental_model,
394
+ backing_vocals_models=args.backing_vocals_models,
395
+ other_stems_models=args.other_stems_models,
396
+ model_file_dir=args.model_file_dir,
397
+ skip_separation=True, # Skip separation as we already have the audio files
398
+ lyrics_artist=args.lyrics_artist or artist,
399
+ lyrics_title=args.lyrics_title or title,
400
+ lyrics_file=args.lyrics_file,
401
+ skip_lyrics=False, # We want to process lyrics
402
+ skip_transcription=False, # We want to transcribe
403
+ skip_transcription_review=args.skip_transcription_review,
404
+ subtitle_offset_ms=args.subtitle_offset_ms,
405
+ style_params_json=args.style_params_json,
406
+ style_overrides=style_overrides,
407
+ background_video=args.background_video,
408
+ background_video_darkness=args.background_video_darkness,
409
+ auto_download=getattr(args, 'auto_download', False),
410
+ )
411
+ # No await needed for constructor
412
+ kprep = kprep_coroutine
413
+
414
+ # Backup existing outputs and get the input audio file
415
+ track_output_dir = os.getcwd()
416
+ input_audio_wav = kprep.file_handler.backup_existing_outputs(track_output_dir, artist, title)
417
+ kprep.input_media = input_audio_wav
418
+
419
+ # Run KaraokePrep
420
+ try:
421
+ tracks = await kprep.process()
422
+ except UserCancelledError:
423
+ logger.info("Operation cancelled by user")
424
+ return
425
+ except KeyboardInterrupt:
426
+ logger.info("Operation cancelled by user (Ctrl+C)")
427
+ return
428
+
429
+ # Filter out None tracks (can happen if prep failed for some tracks)
430
+ tracks = [t for t in tracks if t is not None] if tracks else []
431
+
432
+ if not tracks:
433
+ logger.warning("No tracks to process")
434
+ return
435
+
436
+ # Load CDG styles if CDG generation is enabled
437
+ cdg_styles = None
438
+ if args.enable_cdg:
439
+ if not args.style_params_json:
440
+ logger.error("CDG styles JSON file path (--style_params_json) is required when --enable_cdg is used")
441
+ sys.exit(1)
442
+ return # Explicit return for testing
443
+ try:
444
+ with open(args.style_params_json, "r") as f:
445
+ style_params = json.loads(f.read())
446
+ cdg_styles = style_params["cdg"]
447
+ except FileNotFoundError:
448
+ logger.error(f"CDG styles configuration file not found: {args.style_params_json}")
449
+ sys.exit(1)
450
+ return # Explicit return for testing
451
+ except json.JSONDecodeError as e:
452
+ logger.error(f"Invalid JSON in CDG styles configuration file: {e}")
453
+ sys.exit(1)
454
+ return # Explicit return for testing
455
+ except KeyError:
456
+ logger.error(f"'cdg' key not found in style parameters file: {args.style_params_json}")
457
+ sys.exit(1)
458
+ return # Explicit return for testing
459
+
460
+ # Run KaraokeFinalise with keep_brand_code=True and replace_existing=True
461
+ kfinalise = KaraokeFinalise(
462
+ log_formatter=log_formatter,
463
+ log_level=log_level,
464
+ dry_run=args.dry_run,
465
+ instrumental_format=args.instrumental_format,
466
+ enable_cdg=args.enable_cdg,
467
+ enable_txt=args.enable_txt,
468
+ brand_prefix=args.brand_prefix,
469
+ organised_dir=args.organised_dir,
470
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
471
+ public_share_dir=args.public_share_dir,
472
+ youtube_client_secrets_file=args.youtube_client_secrets_file,
473
+ youtube_description_file=args.youtube_description_file,
474
+ rclone_destination=args.rclone_destination,
475
+ discord_webhook_url=args.discord_webhook_url,
476
+ email_template_file=args.email_template_file,
477
+ cdg_styles=cdg_styles,
478
+ keep_brand_code=True, # Always keep brand code in edit mode
479
+ non_interactive=args.yes,
480
+ )
481
+
482
+ try:
483
+ final_track = kfinalise.process(replace_existing=True) # Replace existing YouTube video
484
+ logger.info(f"Successfully completed editing lyrics for: {artist} - {title}")
485
+
486
+ # Display summary of outputs
487
+ logger.info(f"Karaoke lyrics edit complete! Output files:")
488
+ logger.info(f"")
489
+ logger.info(f"Track: {final_track['artist']} - {final_track['title']}")
490
+ logger.info(f"")
491
+ logger.info(f"Working Files:")
492
+ logger.info(f" Video With Vocals: {final_track['video_with_vocals']}")
493
+ logger.info(f" Video With Instrumental: {final_track['video_with_instrumental']}")
494
+ logger.info(f"")
495
+ logger.info(f"Final Videos:")
496
+ logger.info(f" Lossless 4K MP4 (PCM): {final_track['final_video']}")
497
+ logger.info(f" Lossless 4K MKV (FLAC): {final_track['final_video_mkv']}")
498
+ logger.info(f" Lossy 4K MP4 (AAC): {final_track['final_video_lossy']}")
499
+ logger.info(f" Lossy 720p MP4 (AAC): {final_track['final_video_720p']}")
500
+
501
+ if "final_karaoke_cdg_zip" in final_track or "final_karaoke_txt_zip" in final_track:
502
+ logger.info(f"")
503
+ logger.info(f"Karaoke Files:")
504
+
505
+ if "final_karaoke_cdg_zip" in final_track:
506
+ logger.info(f" CDG+MP3 ZIP: {final_track['final_karaoke_cdg_zip']}")
507
+
508
+ if "final_karaoke_txt_zip" in final_track:
509
+ logger.info(f" TXT+MP3 ZIP: {final_track['final_karaoke_txt_zip']}")
510
+
511
+ if final_track["brand_code"]:
512
+ logger.info(f"")
513
+ logger.info(f"Organization:")
514
+ logger.info(f" Brand Code: {final_track['brand_code']}")
515
+ logger.info(f" Directory: {final_track['new_brand_code_dir_path']}")
516
+
517
+ if final_track["youtube_url"] or final_track["brand_code_dir_sharing_link"]:
518
+ logger.info(f"")
519
+ logger.info(f"Sharing:")
520
+
521
+ if final_track["brand_code_dir_sharing_link"]:
522
+ logger.info(f" Folder Link: {final_track['brand_code_dir_sharing_link']}")
523
+ try:
524
+ time.sleep(1) # Brief pause between clipboard operations
525
+ pyperclip.copy(final_track["brand_code_dir_sharing_link"])
526
+ logger.info(f" (Folder link copied to clipboard)")
527
+ except Exception as e:
528
+ logger.warning(f" Failed to copy folder link to clipboard: {str(e)}")
529
+
530
+ if final_track["youtube_url"]:
531
+ logger.info(f" YouTube URL: {final_track['youtube_url']}")
532
+ try:
533
+ pyperclip.copy(final_track["youtube_url"])
534
+ logger.info(f" (YouTube URL copied to clipboard)")
535
+ except Exception as e:
536
+ logger.warning(f" Failed to copy YouTube URL to clipboard: {str(e)}")
537
+
538
+ except Exception as e:
539
+ logger.error(f"Error during finalisation: {str(e)}")
540
+ raise e
541
+
542
+ return
543
+
544
+ # Handle finalise-only mode
545
+ if args.finalise_only:
546
+ log_level = getattr(logging, args.log_level.upper())
547
+ logger.setLevel(log_level)
548
+ logger.info("Running in finalise-only mode...")
549
+
550
+ # Load CDG styles if CDG generation is enabled
551
+ cdg_styles = None
552
+ if args.enable_cdg:
553
+ if not args.style_params_json:
554
+ logger.error("CDG styles JSON file path (--style_params_json) is required when --enable_cdg is used")
555
+ sys.exit(1)
556
+ return # Explicit return for testing
557
+ try:
558
+ with open(args.style_params_json, "r") as f:
559
+ style_params = json.loads(f.read())
560
+ cdg_styles = style_params["cdg"]
561
+ except FileNotFoundError:
562
+ logger.error(f"CDG styles configuration file not found: {args.style_params_json}")
563
+ sys.exit(1)
564
+ return # Explicit return for testing
565
+ except json.JSONDecodeError as e:
566
+ logger.error(f"Invalid JSON in CDG styles configuration file: {e}")
567
+ sys.exit(1)
568
+ return # Explicit return for testing
569
+ except KeyError:
570
+ logger.error(f"'cdg' key not found in style parameters file: {args.style_params_json}")
571
+ sys.exit(1)
572
+ return # Explicit return for testing
573
+
574
+ kfinalise = KaraokeFinalise(
575
+ log_formatter=log_formatter,
576
+ log_level=log_level,
577
+ dry_run=args.dry_run,
578
+ instrumental_format=args.instrumental_format,
579
+ enable_cdg=args.enable_cdg,
580
+ enable_txt=args.enable_txt,
581
+ brand_prefix=args.brand_prefix,
582
+ organised_dir=args.organised_dir,
583
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
584
+ public_share_dir=args.public_share_dir,
585
+ youtube_client_secrets_file=args.youtube_client_secrets_file,
586
+ youtube_description_file=args.youtube_description_file,
587
+ rclone_destination=args.rclone_destination,
588
+ discord_webhook_url=args.discord_webhook_url,
589
+ email_template_file=args.email_template_file,
590
+ cdg_styles=cdg_styles,
591
+ keep_brand_code=getattr(args, 'keep_brand_code', False),
592
+ non_interactive=args.yes,
593
+ )
594
+
595
+ try:
596
+ track = kfinalise.process()
597
+ logger.info(f"Successfully completed finalisation for: {track['artist']} - {track['title']}")
598
+
599
+ # Display summary of outputs
600
+ logger.info(f"Karaoke finalisation complete! Output files:")
601
+ logger.info(f"")
602
+ logger.info(f"Track: {track['artist']} - {track['title']}")
603
+ logger.info(f"")
604
+ logger.info(f"Working Files:")
605
+ logger.info(f" Video With Vocals: {track['video_with_vocals']}")
606
+ logger.info(f" Video With Instrumental: {track['video_with_instrumental']}")
607
+ logger.info(f"")
608
+ logger.info(f"Final Videos:")
609
+ logger.info(f" Lossless 4K MP4 (PCM): {track['final_video']}")
610
+ logger.info(f" Lossless 4K MKV (FLAC): {track['final_video_mkv']}")
611
+ logger.info(f" Lossy 4K MP4 (AAC): {track['final_video_lossy']}")
612
+ logger.info(f" Lossy 720p MP4 (AAC): {track['final_video_720p']}")
613
+
614
+ if "final_karaoke_cdg_zip" in track or "final_karaoke_txt_zip" in track:
615
+ logger.info(f"")
616
+ logger.info(f"Karaoke Files:")
617
+
618
+ if "final_karaoke_cdg_zip" in track:
619
+ logger.info(f" CDG+MP3 ZIP: {track['final_karaoke_cdg_zip']}")
620
+
621
+ if "final_karaoke_txt_zip" in track:
622
+ logger.info(f" TXT+MP3 ZIP: {track['final_karaoke_txt_zip']}")
623
+
624
+ if track["brand_code"]:
625
+ logger.info(f"")
626
+ logger.info(f"Organization:")
627
+ logger.info(f" Brand Code: {track['brand_code']}")
628
+ logger.info(f" Directory: {track['new_brand_code_dir_path']}")
629
+
630
+ if track["youtube_url"] or track["brand_code_dir_sharing_link"]:
631
+ logger.info(f"")
632
+ logger.info(f"Sharing:")
633
+
634
+ if track["brand_code_dir_sharing_link"]:
635
+ logger.info(f" Folder Link: {track['brand_code_dir_sharing_link']}")
636
+ try:
637
+ time.sleep(1) # Brief pause between clipboard operations
638
+ pyperclip.copy(track["brand_code_dir_sharing_link"])
639
+ logger.info(f" (Folder link copied to clipboard)")
640
+ except Exception as e:
641
+ logger.warning(f" Failed to copy folder link to clipboard: {str(e)}")
642
+
643
+ if track["youtube_url"]:
644
+ logger.info(f" YouTube URL: {track['youtube_url']}")
645
+ try:
646
+ pyperclip.copy(track["youtube_url"])
647
+ logger.info(f" (YouTube URL copied to clipboard)")
648
+ except Exception as e:
649
+ logger.warning(f" Failed to copy YouTube URL to clipboard: {str(e)}")
650
+ except Exception as e:
651
+ logger.error(f"An error occurred during finalisation, see stack trace below: {str(e)}")
652
+ raise e
653
+
654
+ return
655
+
656
+ # For prep or full workflow, parse input arguments
657
+ input_media, artist, title, filename_pattern = None, None, None, None
658
+
659
+ if not args.args:
660
+ parser.print_help()
661
+ sys.exit(1)
662
+ return # Explicit return for testing
663
+
664
+ # Allow 3 forms of positional arguments:
665
+ # 1. URL or Media File only (may be single track URL, playlist URL, or local file)
666
+ # 2. Artist and Title only
667
+ # 3. URL, Artist, and Title
668
+ if args.args and (is_url(args.args[0]) or is_file(args.args[0])):
669
+ input_media = args.args[0]
670
+ if len(args.args) > 2:
671
+ artist = args.args[1]
672
+ title = args.args[2]
673
+ elif len(args.args) > 1:
674
+ artist = args.args[1]
675
+ else:
676
+ logger.warning("Input media provided without Artist and Title, both will be guessed from title")
677
+
678
+ elif os.path.isdir(args.args[0]):
679
+ if not args.filename_pattern:
680
+ logger.error("Filename pattern is required when processing a folder.")
681
+ sys.exit(1)
682
+ return # Explicit return for testing
683
+ if len(args.args) <= 1:
684
+ logger.error("Second parameter provided must be Artist name; Artist is required when processing a folder.")
685
+ sys.exit(1)
686
+ return # Explicit return for testing
687
+
688
+ input_media = args.args[0]
689
+ artist = args.args[1]
690
+ filename_pattern = args.filename_pattern
691
+
692
+ elif len(args.args) > 1:
693
+ artist = args.args[0]
694
+ title = args.args[1]
695
+ if getattr(args, 'auto_download', False):
696
+ logger.info(f"No input media provided, flacfetch will automatically search and download: {artist} - {title}")
697
+ else:
698
+ logger.info(f"No input media provided, flacfetch will search for: {artist} - {title} (interactive selection)")
699
+
700
+ else:
701
+ parser.print_help()
702
+ sys.exit(1)
703
+ return # Explicit return for testing
704
+
705
+ log_level = getattr(logging, args.log_level.upper())
706
+ logger.setLevel(log_level)
707
+
708
+ # Set up environment variables for lyrics-only mode
709
+ if args.lyrics_only:
710
+ args.skip_separation = True
711
+ os.environ["KARAOKE_GEN_SKIP_AUDIO_SEPARATION"] = "1"
712
+ os.environ["KARAOKE_GEN_SKIP_TITLE_END_SCREENS"] = "1"
713
+ logger.info("Lyrics-only mode enabled: skipping audio separation and title/end screen generation")
714
+
715
+ # Step 1: Run KaraokePrep
716
+ kprep_coroutine = KaraokePrep(
717
+ input_media=input_media,
718
+ artist=artist,
719
+ title=title,
720
+ filename_pattern=filename_pattern,
721
+ dry_run=args.dry_run,
722
+ log_formatter=log_formatter,
723
+ log_level=log_level,
724
+ render_bounding_boxes=args.render_bounding_boxes,
725
+ output_dir=args.output_dir,
726
+ create_track_subfolders=args.no_track_subfolders,
727
+ lossless_output_format=args.lossless_output_format,
728
+ output_png=args.output_png,
729
+ output_jpg=args.output_jpg,
730
+ clean_instrumental_model=args.clean_instrumental_model,
731
+ backing_vocals_models=args.backing_vocals_models,
732
+ other_stems_models=args.other_stems_models,
733
+ model_file_dir=args.model_file_dir,
734
+ existing_instrumental=args.existing_instrumental,
735
+ skip_separation=args.skip_separation,
736
+ lyrics_artist=args.lyrics_artist,
737
+ lyrics_title=args.lyrics_title,
738
+ lyrics_file=args.lyrics_file,
739
+ skip_lyrics=args.skip_lyrics,
740
+ skip_transcription=args.skip_transcription,
741
+ skip_transcription_review=args.skip_transcription_review,
742
+ subtitle_offset_ms=args.subtitle_offset_ms,
743
+ style_params_json=args.style_params_json,
744
+ style_overrides=style_overrides,
745
+ background_video=args.background_video,
746
+ background_video_darkness=args.background_video_darkness,
747
+ auto_download=getattr(args, 'auto_download', False),
748
+ )
749
+ # No await needed for constructor
750
+ kprep = kprep_coroutine
751
+
752
+ # Create final tracks data structure
753
+ try:
754
+ tracks = await kprep.process()
755
+ except UserCancelledError:
756
+ logger.info("Operation cancelled by user")
757
+ return
758
+ except (KeyboardInterrupt, asyncio.CancelledError):
759
+ logger.info("Operation cancelled by user (Ctrl+C)")
760
+ return
761
+
762
+ # Filter out None tracks (can happen if prep failed for some tracks)
763
+ tracks = [t for t in tracks if t is not None] if tracks else []
764
+
765
+ if not tracks:
766
+ logger.warning("No tracks to process")
767
+ return
768
+
769
+ # If prep-only mode, we're done
770
+ if args.prep_only:
771
+ logger.info("Prep-only mode: skipping finalisation phase")
772
+ return
773
+
774
+ # Step 2: For each track, run KaraokeFinalise
775
+ for track in tracks:
776
+ logger.info(f"Starting finalisation phase for {track['artist']} - {track['title']}...")
777
+
778
+ # Use the track directory that was actually created by KaraokePrep
779
+ track_dir = track["track_output_dir"]
780
+ if not os.path.exists(track_dir):
781
+ logger.error(f"Track directory not found: {track_dir}")
782
+ continue
783
+
784
+ logger.info(f"Changing to directory: {track_dir}")
785
+ os.chdir(track_dir)
786
+
787
+ # Select instrumental file - either via web UI, auto-selection, or custom instrumental
788
+ # This ALWAYS produces a selected file - no silent fallback to legacy code
789
+ selected_instrumental_file = None
790
+ skip_review = getattr(args, 'skip_instrumental_review', False)
791
+
792
+ # Check if a custom instrumental was provided (via --existing_instrumental)
793
+ # In this case, the instrumental is already chosen - skip review entirely
794
+ separated_audio = track.get("separated_audio", {})
795
+ custom_instrumental = separated_audio.get("Custom", {}).get("instrumental")
796
+
797
+ if custom_instrumental:
798
+ # Custom instrumental was provided - use it directly, no review needed
799
+ resolved_path = _resolve_path_for_cwd(custom_instrumental, track_dir)
800
+ if os.path.exists(resolved_path):
801
+ logger.info(f"Using custom instrumental (--existing_instrumental): {resolved_path}")
802
+ selected_instrumental_file = resolved_path
803
+ else:
804
+ logger.error(f"Custom instrumental file not found: {resolved_path}")
805
+ logger.error("The file may have been moved or deleted after preparation.")
806
+ sys.exit(1)
807
+ return # Explicit return for testing
808
+ elif skip_review:
809
+ # Auto-select instrumental when review is skipped (non-interactive mode)
810
+ logger.info("Instrumental review skipped (--skip_instrumental_review), auto-selecting instrumental file...")
811
+ try:
812
+ selected_instrumental_file = auto_select_instrumental(
813
+ track=track,
814
+ track_dir=track_dir,
815
+ logger=logger,
816
+ )
817
+ except FileNotFoundError as e:
818
+ logger.error(f"Failed to auto-select instrumental: {e}")
819
+ logger.error("Check that audio separation completed successfully.")
820
+ sys.exit(1)
821
+ return # Explicit return for testing
822
+ else:
823
+ # Run instrumental review web UI
824
+ selected_instrumental_file = run_instrumental_review(
825
+ track=track,
826
+ logger=logger,
827
+ )
828
+
829
+ # If instrumental review failed/returned None, show error and exit
830
+ # NO SILENT FALLBACK - we want to know if the new flow has issues
831
+ if selected_instrumental_file is None:
832
+ logger.error("")
833
+ logger.error("=" * 70)
834
+ logger.error("INSTRUMENTAL SELECTION FAILED")
835
+ logger.error("=" * 70)
836
+ logger.error("")
837
+ logger.error("The instrumental review UI could not find the required files.")
838
+ logger.error("")
839
+ logger.error("Common causes:")
840
+ logger.error(" - No backing vocals file was found (check stems/ directory)")
841
+ logger.error(" - No clean instrumental was found (audio separation may have failed)")
842
+ logger.error(" - Path resolution failed after directory change")
843
+ logger.error("")
844
+ logger.error("To investigate:")
845
+ logger.error(" - Check the stems/ directory for: *Backing Vocals*.flac and *Instrumental*.flac")
846
+ logger.error(" - Look for separation errors earlier in the log")
847
+ logger.error(" - Verify audio separation completed without errors")
848
+ logger.error("")
849
+ logger.error("Workarounds:")
850
+ logger.error(" - Re-run with --skip_instrumental_review to auto-select an instrumental")
851
+ logger.error(" - Re-run the full pipeline to regenerate stems")
852
+ logger.error("")
853
+ sys.exit(1)
854
+ return # Explicit return for testing
855
+
856
+ logger.info(f"Selected instrumental file: {selected_instrumental_file}")
857
+
858
+ # Get countdown padding info from track (if vocals were padded, instrumental must match)
859
+ countdown_padding_seconds = None
860
+ if track.get("countdown_padding_added", False):
861
+ countdown_padding_seconds = track.get("countdown_padding_seconds", 3.0)
862
+ logger.info(f"Countdown padding detected: {countdown_padding_seconds}s (will be applied to instrumental if needed)")
863
+
864
+ # Load CDG styles if CDG generation is enabled
865
+ cdg_styles = None
866
+ if args.enable_cdg:
867
+ if not args.style_params_json:
868
+ logger.error("CDG styles JSON file path (--style_params_json) is required when --enable_cdg is used")
869
+ sys.exit(1)
870
+ return # Explicit return for testing
871
+ try:
872
+ with open(args.style_params_json, "r") as f:
873
+ style_params = json.loads(f.read())
874
+ cdg_styles = style_params["cdg"]
875
+ except FileNotFoundError:
876
+ logger.error(f"CDG styles configuration file not found: {args.style_params_json}")
877
+ sys.exit(1)
878
+ return # Explicit return for testing
879
+ except json.JSONDecodeError as e:
880
+ logger.error(f"Invalid JSON in CDG styles configuration file: {e}")
881
+ sys.exit(1)
882
+ return # Explicit return for testing
883
+ except KeyError:
884
+ logger.error(f"'cdg' key not found in style parameters file: {args.style_params_json}")
885
+ sys.exit(1)
886
+ return # Explicit return for testing
887
+
888
+ kfinalise = KaraokeFinalise(
889
+ log_formatter=log_formatter,
890
+ log_level=log_level,
891
+ dry_run=args.dry_run,
892
+ instrumental_format=args.instrumental_format,
893
+ enable_cdg=args.enable_cdg,
894
+ enable_txt=args.enable_txt,
895
+ brand_prefix=args.brand_prefix,
896
+ organised_dir=args.organised_dir,
897
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
898
+ public_share_dir=args.public_share_dir,
899
+ youtube_client_secrets_file=args.youtube_client_secrets_file,
900
+ youtube_description_file=args.youtube_description_file,
901
+ rclone_destination=args.rclone_destination,
902
+ discord_webhook_url=args.discord_webhook_url,
903
+ email_template_file=args.email_template_file,
904
+ cdg_styles=cdg_styles,
905
+ keep_brand_code=getattr(args, 'keep_brand_code', False),
906
+ non_interactive=args.yes,
907
+ selected_instrumental_file=selected_instrumental_file,
908
+ countdown_padding_seconds=countdown_padding_seconds,
909
+ )
910
+
911
+ try:
912
+ final_track = kfinalise.process()
913
+ logger.info(f"Successfully completed processing: {final_track['artist']} - {final_track['title']}")
914
+
915
+ # Display summary of outputs
916
+ logger.info(f"Karaoke generation complete! Output files:")
917
+ logger.info(f"")
918
+ logger.info(f"Track: {final_track['artist']} - {final_track['title']}")
919
+ logger.info(f"")
920
+ logger.info(f"Working Files:")
921
+ logger.info(f" Video With Vocals: {final_track['video_with_vocals']}")
922
+ logger.info(f" Video With Instrumental: {final_track['video_with_instrumental']}")
923
+ logger.info(f"")
924
+ logger.info(f"Final Videos:")
925
+ logger.info(f" Lossless 4K MP4 (PCM): {final_track['final_video']}")
926
+ logger.info(f" Lossless 4K MKV (FLAC): {final_track['final_video_mkv']}")
927
+ logger.info(f" Lossy 4K MP4 (AAC): {final_track['final_video_lossy']}")
928
+ logger.info(f" Lossy 720p MP4 (AAC): {final_track['final_video_720p']}")
929
+
930
+ if "final_karaoke_cdg_zip" in final_track or "final_karaoke_txt_zip" in final_track:
931
+ logger.info(f"")
932
+ logger.info(f"Karaoke Files:")
933
+
934
+ if "final_karaoke_cdg_zip" in final_track:
935
+ logger.info(f" CDG+MP3 ZIP: {final_track['final_karaoke_cdg_zip']}")
936
+
937
+ if "final_karaoke_txt_zip" in final_track:
938
+ logger.info(f" TXT+MP3 ZIP: {final_track['final_karaoke_txt_zip']}")
939
+
940
+ if final_track["brand_code"]:
941
+ logger.info(f"")
942
+ logger.info(f"Organization:")
943
+ logger.info(f" Brand Code: {final_track['brand_code']}")
944
+ logger.info(f" Directory: {final_track['new_brand_code_dir_path']}")
945
+
946
+ if final_track["youtube_url"] or final_track["brand_code_dir_sharing_link"]:
947
+ logger.info(f"")
948
+ logger.info(f"Sharing:")
949
+
950
+ if final_track["brand_code_dir_sharing_link"]:
951
+ logger.info(f" Folder Link: {final_track['brand_code_dir_sharing_link']}")
952
+ try:
953
+ time.sleep(1) # Brief pause between clipboard operations
954
+ pyperclip.copy(final_track["brand_code_dir_sharing_link"])
955
+ logger.info(f" (Folder link copied to clipboard)")
956
+ except Exception as e:
957
+ logger.warning(f" Failed to copy folder link to clipboard: {str(e)}")
958
+
959
+ if final_track["youtube_url"]:
960
+ logger.info(f" YouTube URL: {final_track['youtube_url']}")
961
+ try:
962
+ pyperclip.copy(final_track["youtube_url"])
963
+ logger.info(f" (YouTube URL copied to clipboard)")
964
+ except Exception as e:
965
+ logger.warning(f" Failed to copy YouTube URL to clipboard: {str(e)}")
966
+ except Exception as e:
967
+ logger.error(f"An error occurred during finalisation, see stack trace below: {str(e)}")
968
+ raise e
969
+
970
+ return
971
+
972
+
973
+ def main():
974
+ asyncio.run(async_main())
975
+
976
+
977
+ if __name__ == "__main__":
978
+ main()