karaoke-gen 0.57.0__py3-none-any.whl → 0.71.27__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.
Files changed (268) hide show
  1. karaoke_gen/audio_fetcher.py +461 -0
  2. karaoke_gen/audio_processor.py +407 -30
  3. karaoke_gen/config.py +62 -113
  4. karaoke_gen/file_handler.py +32 -59
  5. karaoke_gen/karaoke_finalise/karaoke_finalise.py +148 -67
  6. karaoke_gen/karaoke_gen.py +270 -61
  7. karaoke_gen/lyrics_processor.py +13 -1
  8. karaoke_gen/metadata.py +78 -73
  9. karaoke_gen/pipeline/__init__.py +87 -0
  10. karaoke_gen/pipeline/base.py +215 -0
  11. karaoke_gen/pipeline/context.py +230 -0
  12. karaoke_gen/pipeline/executors/__init__.py +21 -0
  13. karaoke_gen/pipeline/executors/local.py +159 -0
  14. karaoke_gen/pipeline/executors/remote.py +257 -0
  15. karaoke_gen/pipeline/stages/__init__.py +27 -0
  16. karaoke_gen/pipeline/stages/finalize.py +202 -0
  17. karaoke_gen/pipeline/stages/render.py +165 -0
  18. karaoke_gen/pipeline/stages/screens.py +139 -0
  19. karaoke_gen/pipeline/stages/separation.py +191 -0
  20. karaoke_gen/pipeline/stages/transcription.py +191 -0
  21. karaoke_gen/style_loader.py +531 -0
  22. karaoke_gen/utils/bulk_cli.py +6 -0
  23. karaoke_gen/utils/cli_args.py +424 -0
  24. karaoke_gen/utils/gen_cli.py +26 -261
  25. karaoke_gen/utils/remote_cli.py +1965 -0
  26. karaoke_gen/video_background_processor.py +351 -0
  27. karaoke_gen-0.71.27.dist-info/METADATA +610 -0
  28. karaoke_gen-0.71.27.dist-info/RECORD +275 -0
  29. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.27.dist-info}/WHEEL +1 -1
  30. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.27.dist-info}/entry_points.txt +1 -0
  31. lyrics_transcriber/__init__.py +10 -0
  32. lyrics_transcriber/cli/__init__.py +0 -0
  33. lyrics_transcriber/cli/cli_main.py +285 -0
  34. lyrics_transcriber/core/__init__.py +0 -0
  35. lyrics_transcriber/core/config.py +50 -0
  36. lyrics_transcriber/core/controller.py +520 -0
  37. lyrics_transcriber/correction/__init__.py +0 -0
  38. lyrics_transcriber/correction/agentic/__init__.py +9 -0
  39. lyrics_transcriber/correction/agentic/adapter.py +71 -0
  40. lyrics_transcriber/correction/agentic/agent.py +313 -0
  41. lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
  42. lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
  43. lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
  44. lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
  45. lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
  46. lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
  47. lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
  48. lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
  49. lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
  50. lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
  51. lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
  52. lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
  53. lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
  54. lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
  55. lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
  56. lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
  57. lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
  58. lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
  59. lyrics_transcriber/correction/agentic/models/enums.py +38 -0
  60. lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
  61. lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
  62. lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
  63. lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
  64. lyrics_transcriber/correction/agentic/models/utils.py +19 -0
  65. lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
  66. lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
  67. lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
  68. lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
  69. lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
  70. lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
  71. lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
  72. lyrics_transcriber/correction/agentic/providers/base.py +36 -0
  73. lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
  74. lyrics_transcriber/correction/agentic/providers/config.py +73 -0
  75. lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
  76. lyrics_transcriber/correction/agentic/providers/health.py +28 -0
  77. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
  78. lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
  79. lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
  80. lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
  81. lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
  82. lyrics_transcriber/correction/agentic/router.py +35 -0
  83. lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
  84. lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
  85. lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
  86. lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
  87. lyrics_transcriber/correction/anchor_sequence.py +1043 -0
  88. lyrics_transcriber/correction/corrector.py +760 -0
  89. lyrics_transcriber/correction/feedback/__init__.py +2 -0
  90. lyrics_transcriber/correction/feedback/schemas.py +107 -0
  91. lyrics_transcriber/correction/feedback/store.py +236 -0
  92. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  93. lyrics_transcriber/correction/handlers/base.py +52 -0
  94. lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
  95. lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
  96. lyrics_transcriber/correction/handlers/llm.py +293 -0
  97. lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
  98. lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
  99. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
  100. lyrics_transcriber/correction/handlers/repeat.py +88 -0
  101. lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
  102. lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
  103. lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
  104. lyrics_transcriber/correction/handlers/word_operations.py +187 -0
  105. lyrics_transcriber/correction/operations.py +352 -0
  106. lyrics_transcriber/correction/phrase_analyzer.py +435 -0
  107. lyrics_transcriber/correction/text_utils.py +30 -0
  108. lyrics_transcriber/frontend/.gitignore +23 -0
  109. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
  110. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  111. lyrics_transcriber/frontend/README.md +50 -0
  112. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
  113. lyrics_transcriber/frontend/__init__.py +25 -0
  114. lyrics_transcriber/frontend/eslint.config.js +28 -0
  115. lyrics_transcriber/frontend/index.html +18 -0
  116. lyrics_transcriber/frontend/package.json +42 -0
  117. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  118. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  119. lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
  120. lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
  121. lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
  122. lyrics_transcriber/frontend/public/favicon.ico +0 -0
  123. lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
  124. lyrics_transcriber/frontend/src/App.tsx +212 -0
  125. lyrics_transcriber/frontend/src/api.ts +239 -0
  126. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
  127. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
  128. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
  129. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
  130. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
  131. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
  132. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
  133. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
  134. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
  135. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
  136. lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
  137. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
  138. lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
  139. lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
  140. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  141. lyrics_transcriber/frontend/src/components/Header.tsx +387 -0
  142. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1373 -0
  143. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
  144. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
  145. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
  146. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
  147. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
  148. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +688 -0
  149. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
  150. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  151. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
  152. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
  153. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
  154. lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
  155. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
  156. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
  157. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
  158. lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
  159. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
  160. lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
  161. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  162. lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
  163. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
  164. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  165. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
  166. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
  167. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
  168. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  169. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
  170. lyrics_transcriber/frontend/src/main.tsx +17 -0
  171. lyrics_transcriber/frontend/src/theme.ts +177 -0
  172. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  173. lyrics_transcriber/frontend/src/types.js +2 -0
  174. lyrics_transcriber/frontend/src/types.ts +199 -0
  175. lyrics_transcriber/frontend/src/validation.ts +132 -0
  176. lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
  177. lyrics_transcriber/frontend/tsconfig.app.json +26 -0
  178. lyrics_transcriber/frontend/tsconfig.json +25 -0
  179. lyrics_transcriber/frontend/tsconfig.node.json +23 -0
  180. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
  181. lyrics_transcriber/frontend/update_version.js +11 -0
  182. lyrics_transcriber/frontend/vite.config.d.ts +2 -0
  183. lyrics_transcriber/frontend/vite.config.js +10 -0
  184. lyrics_transcriber/frontend/vite.config.ts +11 -0
  185. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  186. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  187. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  188. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js +42039 -0
  189. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  191. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  192. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  193. lyrics_transcriber/frontend/web_assets/index.html +18 -0
  194. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  195. lyrics_transcriber/frontend/yarn.lock +3752 -0
  196. lyrics_transcriber/lyrics/__init__.py +0 -0
  197. lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
  198. lyrics_transcriber/lyrics/file_provider.py +95 -0
  199. lyrics_transcriber/lyrics/genius.py +384 -0
  200. lyrics_transcriber/lyrics/lrclib.py +231 -0
  201. lyrics_transcriber/lyrics/musixmatch.py +156 -0
  202. lyrics_transcriber/lyrics/spotify.py +290 -0
  203. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  204. lyrics_transcriber/output/__init__.py +0 -0
  205. lyrics_transcriber/output/ass/__init__.py +21 -0
  206. lyrics_transcriber/output/ass/ass.py +2088 -0
  207. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  208. lyrics_transcriber/output/ass/config.py +180 -0
  209. lyrics_transcriber/output/ass/constants.py +23 -0
  210. lyrics_transcriber/output/ass/event.py +94 -0
  211. lyrics_transcriber/output/ass/formatters.py +132 -0
  212. lyrics_transcriber/output/ass/lyrics_line.py +265 -0
  213. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  214. lyrics_transcriber/output/ass/section_detector.py +89 -0
  215. lyrics_transcriber/output/ass/section_screen.py +106 -0
  216. lyrics_transcriber/output/ass/style.py +187 -0
  217. lyrics_transcriber/output/cdg.py +619 -0
  218. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  219. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  220. lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
  221. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  222. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  223. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  224. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  225. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  226. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  227. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  228. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  229. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  230. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  231. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  232. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  233. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  234. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  235. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  236. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  237. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  238. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  239. lyrics_transcriber/output/countdown_processor.py +267 -0
  240. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  241. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  242. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  243. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  244. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  245. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  246. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  247. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  248. lyrics_transcriber/output/generator.py +257 -0
  249. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  250. lyrics_transcriber/output/lyrics_file.py +102 -0
  251. lyrics_transcriber/output/plain_text.py +96 -0
  252. lyrics_transcriber/output/segment_resizer.py +431 -0
  253. lyrics_transcriber/output/subtitles.py +397 -0
  254. lyrics_transcriber/output/video.py +544 -0
  255. lyrics_transcriber/review/__init__.py +0 -0
  256. lyrics_transcriber/review/server.py +676 -0
  257. lyrics_transcriber/storage/__init__.py +0 -0
  258. lyrics_transcriber/storage/dropbox.py +225 -0
  259. lyrics_transcriber/transcribers/__init__.py +0 -0
  260. lyrics_transcriber/transcribers/audioshake.py +290 -0
  261. lyrics_transcriber/transcribers/base_transcriber.py +157 -0
  262. lyrics_transcriber/transcribers/whisper.py +330 -0
  263. lyrics_transcriber/types.py +648 -0
  264. lyrics_transcriber/utils/__init__.py +0 -0
  265. lyrics_transcriber/utils/word_utils.py +27 -0
  266. karaoke_gen-0.57.0.dist-info/METADATA +0 -167
  267. karaoke_gen-0.57.0.dist-info/RECORD +0 -23
  268. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.27.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,2260 @@
1
+ from collections import deque
2
+ from io import BytesIO
3
+ import itertools as it
4
+ import operator
5
+ from pathlib import Path
6
+ import re
7
+ import sys
8
+ import tomllib
9
+ from typing import NamedTuple, Self, TYPE_CHECKING, cast, Iterable, TypeVar
10
+ from zipfile import ZipFile
11
+
12
+ import ffmpeg
13
+
14
+ if TYPE_CHECKING:
15
+ from _typeshed import FileDescriptorOrPath, StrOrBytesPath
16
+
17
+ from attrs import define
18
+ from cattrs import Converter
19
+ from PIL import Image, ImageFont
20
+ from pydub import AudioSegment
21
+
22
+ from .cdg import *
23
+ from .config import *
24
+ from .pack import *
25
+ from .render import *
26
+ from .utils import *
27
+
28
+ import logging
29
+
30
+ ASS_REQUIREMENTS = True
31
+ try:
32
+ import ass
33
+ from fontTools import ttLib
34
+
35
+ from datetime import timedelta
36
+ except ImportError:
37
+ ASS_REQUIREMENTS = False
38
+
39
+ MP4_REQUIREMENTS = True
40
+ try:
41
+ import ffmpeg
42
+ except ImportError:
43
+ MP4_REQUIREMENTS = False
44
+
45
+
46
+ package_dir = Path(__file__).parent
47
+
48
+ T = TypeVar("T")
49
+
50
+
51
+ def batched(iterable: Iterable[T], n: int) -> Iterable[tuple[T, ...]]:
52
+ "Batch data into tuples of length n. The last batch may be shorter."
53
+ # batched('ABCDEFG', 3) --> ABC DEF G
54
+ itobj = iter(iterable)
55
+ while True:
56
+ batch = tuple(it.islice(itobj, n))
57
+ if not batch:
58
+ return
59
+ yield batch
60
+
61
+
62
+ def file_relative_to(
63
+ filepath: "StrOrBytesPath | Path",
64
+ *relative_to: "StrOrBytesPath | Path",
65
+ ) -> Path:
66
+ """
67
+ Convert possibly relative filepath to absolute path, relative to any
68
+ of the paths in `relative_to`, or to the parent directory of this
69
+ very Python file itself.
70
+
71
+ If the filepath is already absolute, it is returned unchanged.
72
+ Otherwise, the first absolute filepath found to exist as a file will
73
+ be returned.
74
+
75
+ Parameters
76
+ ----------
77
+ filepath : path-like
78
+ Filepath.
79
+ *relative_to
80
+ The filepath will be given as relative to these paths.
81
+
82
+ Returns
83
+ -------
84
+ `pathlib.Path`
85
+ Absolute path relative to given directories, if any exist as
86
+ files.
87
+ """
88
+ filepath = Path(filepath)
89
+ if filepath.is_absolute():
90
+ return filepath
91
+
92
+ # If all else fails, check filepath relative to this file
93
+ relative_to += (Path(__file__).parent,)
94
+ for rel in relative_to:
95
+ outpath = Path(rel) / filepath
96
+ if outpath.is_file():
97
+ return outpath
98
+
99
+ # Add more detailed error information
100
+ searched_paths = [str(Path(rel) / filepath) for rel in relative_to]
101
+ raise FileNotFoundError(f"File not found: {filepath}. Searched in: {', '.join(searched_paths)}")
102
+
103
+
104
+ def sync_to_cdg(cs: int) -> int:
105
+ """
106
+ Convert sync time to CDG frame time to the nearest frame.
107
+
108
+ Parameters
109
+ ----------
110
+ cs : int
111
+ Time in centiseconds (100ths of a second).
112
+
113
+ Returns
114
+ -------
115
+ int
116
+ Equivalent time in CDG frames.
117
+ """
118
+ return cs * CDG_FPS // 100
119
+
120
+
121
+ def cdg_to_sync(fs: int) -> int:
122
+ """
123
+ Convert CDG frame time to sync time to the nearest centisecond.
124
+
125
+ Parameters
126
+ ----------
127
+ fs : int
128
+ Time in CDG frames.
129
+
130
+ Returns
131
+ -------
132
+ int
133
+ Equivalent time in centiseconds (100ths of a second).
134
+ """
135
+ return fs * 100 // CDG_FPS
136
+
137
+
138
+ @define
139
+ class SyllableInfo:
140
+ mask: Image.Image
141
+ text: str
142
+ start_offset: int
143
+ end_offset: int
144
+ left_edge: int
145
+ right_edge: int
146
+ lyric_index: int
147
+ line_index: int
148
+ syllable_index: int
149
+
150
+
151
+ @define
152
+ class LineInfo:
153
+ image: Image.Image
154
+ text: str
155
+ syllables: list[SyllableInfo]
156
+ x: int
157
+ y: int
158
+ singer: int
159
+ lyric_index: int
160
+ line_index: int
161
+
162
+
163
+ class LyricInfo(NamedTuple):
164
+ lines: list[LineInfo]
165
+ line_tile_height: int
166
+ lines_per_page: int
167
+ lyric_index: int
168
+
169
+
170
+ @define
171
+ class LyricTimes:
172
+ line_draw: list[int]
173
+ line_erase: list[int]
174
+
175
+
176
+ @define
177
+ class LyricState:
178
+ line_draw: int
179
+ line_erase: int
180
+ syllable_line: int
181
+ syllable_index: int
182
+ draw_queue: deque[CDGPacket]
183
+ highlight_queue: deque[list[CDGPacket]]
184
+
185
+
186
+ @define
187
+ class ComposerState:
188
+ instrumental: int
189
+ this_page: int
190
+ last_page: int
191
+ just_cleared: bool
192
+
193
+
194
+ class KaraokeComposer:
195
+ BACKGROUND = 0
196
+ BORDER = 1
197
+ UNUSED_COLOR = (0, 0, 0)
198
+
199
+ # region Constructors
200
+ # SECTION Constructors
201
+ def __init__(
202
+ self,
203
+ config: Settings,
204
+ relative_dir: "StrOrBytesPath | Path" = "",
205
+ logger=None,
206
+ ):
207
+ self.config = config
208
+ self.relative_dir = Path(relative_dir)
209
+ self.logger = logger or logging.getLogger(__name__)
210
+
211
+ self.logger.debug("loading config settings")
212
+
213
+ font_path = self.config.font
214
+ self.logger.debug(f"font_path: {font_path}")
215
+ try:
216
+ # First, use the font path directly from the config
217
+ if not Path(font_path).is_file():
218
+ # Try to find the font relative to the config file
219
+ font_path = Path(self.relative_dir) / font_path
220
+ if not font_path.is_file():
221
+ # If not found, try to find it in the package fonts directory
222
+ font_path = package_dir / "fonts" / Path(self.config.font).name
223
+ if not font_path.is_file():
224
+ raise FileNotFoundError(f"Font file not found: {self.config.font}")
225
+ self.font = ImageFont.truetype(str(font_path), self.config.font_size)
226
+ except Exception as e:
227
+ self.logger.error(f"Error loading font: {e}")
228
+ raise
229
+
230
+ # Set color table for lyrics sections
231
+ # NOTE At the moment, this only allows for up to 3 singers, with
232
+ # distinct color indices for active/inactive fills/strokes.
233
+ # REVIEW Could this be smarter? Perhaps if some colors are
234
+ # reused/omitted, color indices could be organized in a
235
+ # different way that allows for more singers at a time.
236
+ self.color_table = [
237
+ self.config.background,
238
+ self.config.border or self.UNUSED_COLOR,
239
+ self.UNUSED_COLOR,
240
+ self.UNUSED_COLOR,
241
+ ]
242
+ for singer in self.config.singers:
243
+ self.color_table.extend(
244
+ [
245
+ singer.inactive_fill,
246
+ singer.inactive_stroke,
247
+ singer.active_fill,
248
+ singer.active_stroke,
249
+ ]
250
+ )
251
+ self.color_table = list(
252
+ pad(
253
+ self.color_table,
254
+ 16,
255
+ padvalue=self.UNUSED_COLOR,
256
+ )
257
+ )
258
+ self.logger.debug(f"Color table: {self.color_table}")
259
+
260
+ self.max_tile_height = 0
261
+ self.lyrics: list[LyricInfo] = []
262
+ # Process lyric sets
263
+ for ci, lyric in enumerate(self.config.lyrics):
264
+ self.logger.debug(f"processing config lyric {ci}")
265
+ lines: list[list[str]] = []
266
+ line_singers: list[int] = []
267
+ for textline in re.split(r"\n+", lyric.text):
268
+ textline: str
269
+
270
+ # Assign singer
271
+ if "|" in textline:
272
+ singer, textline = textline.split("|")
273
+ singer = int(singer)
274
+ else:
275
+ singer = lyric.singer
276
+
277
+ textline = textline.strip()
278
+ # Tildes signify empty lines
279
+ if textline == "~":
280
+ syllables = []
281
+ else:
282
+ syllables = [
283
+ # Replace underscores in syllables with spaces
284
+ syllable.replace("_", " ")
285
+ for syllable in it.chain.from_iterable(
286
+ # Split syllables at slashes
287
+ cast(str, word).split("/")
288
+ # Split words after one space and possibly
289
+ # before other spaces
290
+ for word in re.split(r"(?<= )(?<! ) *", textline)
291
+ )
292
+ ]
293
+
294
+ self.logger.debug(f"singer {singer}: {syllables}")
295
+ lines.append(syllables)
296
+ line_singers.append(singer)
297
+
298
+ self.logger.debug(f"rendering line images and masks for lyric {ci}")
299
+ line_images, line_masks = render_lines_and_masks(
300
+ lines,
301
+ font=self.font,
302
+ stroke_width=self.config.stroke_width,
303
+ stroke_type=self.config.stroke_type,
304
+ logger=self.logger,
305
+ )
306
+ max_height = 0
307
+ for li, image in enumerate(line_images):
308
+ if image.width > CDG_VISIBLE_WIDTH:
309
+ self.logger.warning(
310
+ f"line {li} too wide\n"
311
+ f"max width is {CDG_VISIBLE_WIDTH} pixel(s); "
312
+ f"actual width is {image.width} pixel(s)\n"
313
+ f"\t{''.join(lines[li])}"
314
+ )
315
+ max_height = max(max_height, image.height)
316
+
317
+ tile_height = ceildiv(max_height, CDG_TILE_HEIGHT)
318
+ self.max_tile_height = max(self.max_tile_height, tile_height)
319
+
320
+ lyric_lines: list[LineInfo] = []
321
+ sync_i = 0
322
+ self.logger.debug(f"setting sync points for lyric {ci}")
323
+ for li, (line, singer, line_image, line_mask) in enumerate(
324
+ zip(
325
+ lines,
326
+ line_singers,
327
+ line_images,
328
+ line_masks,
329
+ )
330
+ ):
331
+ # Center line horizontally
332
+ x = (CDG_SCREEN_WIDTH - line_image.width) // 2
333
+ # Place line on correct row
334
+ y = lyric.row * CDG_TILE_HEIGHT + ((li % lyric.lines_per_page) * lyric.line_tile_height * CDG_TILE_HEIGHT)
335
+
336
+ # Get enough sync points for this line's syllables
337
+ line_sync = lyric.sync[sync_i : sync_i + len(line)]
338
+ sync_i += len(line)
339
+ if line_sync:
340
+ # The last syllable ends 0.45 seconds after it
341
+ # starts...
342
+ next_sync_point = line_sync[-1] + 45
343
+ if sync_i < len(lyric.sync):
344
+ # ...or when the first syllable of the next line
345
+ # starts, whichever comes first
346
+ next_sync_point = min(
347
+ next_sync_point,
348
+ lyric.sync[sync_i],
349
+ )
350
+ line_sync.append(next_sync_point)
351
+
352
+ # Collect this line's syllables
353
+ syllables: list[SyllableInfo] = []
354
+ for si, (mask, syllable, (start, end)) in enumerate(
355
+ zip(
356
+ line_mask,
357
+ line,
358
+ it.pairwise(line_sync),
359
+ )
360
+ ):
361
+ # NOTE Left and right edges here are relative to the
362
+ # mask. They will be stored relative to the screen.
363
+ left_edge, right_edge = 0, 0
364
+ bbox = mask.getbbox()
365
+ if bbox is not None:
366
+ left_edge, _, right_edge, _ = bbox
367
+
368
+ syllables.append(
369
+ SyllableInfo(
370
+ mask=mask,
371
+ text=syllable,
372
+ start_offset=sync_to_cdg(start),
373
+ end_offset=sync_to_cdg(end),
374
+ left_edge=left_edge + x,
375
+ right_edge=right_edge + x,
376
+ lyric_index=ci,
377
+ line_index=li,
378
+ syllable_index=si,
379
+ )
380
+ )
381
+
382
+ lyric_lines.append(
383
+ LineInfo(
384
+ image=line_image,
385
+ text="".join(line),
386
+ syllables=syllables,
387
+ x=x,
388
+ y=y,
389
+ singer=singer,
390
+ lyric_index=ci,
391
+ line_index=li,
392
+ )
393
+ )
394
+
395
+ self.lyrics.append(
396
+ LyricInfo(
397
+ lines=lyric_lines,
398
+ line_tile_height=tile_height,
399
+ lines_per_page=lyric.lines_per_page,
400
+ lyric_index=ci,
401
+ )
402
+ )
403
+
404
+ # Add vertical offset to lines to vertically center them
405
+ max_height = max(line.image.height for lyric in self.lyrics for line in lyric.lines)
406
+ line_offset = (self.max_tile_height * CDG_TILE_HEIGHT - max_height) // 2
407
+ self.logger.debug(f"lines will be vertically offset by {line_offset} pixel(s)")
408
+ if line_offset:
409
+ for lyric in self.lyrics:
410
+ for line in lyric.lines:
411
+ line.y += line_offset
412
+
413
+ self.sync_offset = sync_to_cdg(self.config.sync_offset)
414
+
415
+ self.writer = CDGWriter()
416
+ self.logger.info("config settings loaded")
417
+
418
+ self._set_draw_times()
419
+
420
+ @classmethod
421
+ def from_file(
422
+ cls,
423
+ file: "FileDescriptorOrPath",
424
+ logger=None,
425
+ ) -> Self:
426
+ converter = Converter(prefer_attrib_converters=True)
427
+ relative_dir = Path(file).parent
428
+ with open(file, "rb") as stream:
429
+ return cls(
430
+ converter.structure(tomllib.load(stream), Settings),
431
+ relative_dir=relative_dir,
432
+ logger=logger,
433
+ )
434
+
435
+ @classmethod
436
+ def from_string(
437
+ cls,
438
+ config: str,
439
+ relative_dir: "StrOrBytesPath | Path" = "",
440
+ ) -> Self:
441
+ converter = Converter(prefer_attrib_converters=True)
442
+ return cls(
443
+ converter.structure(tomllib.loads(config), Settings),
444
+ relative_dir=relative_dir,
445
+ )
446
+
447
+ # !SECTION
448
+ # endregion
449
+
450
+ # region Set draw times
451
+ # SECTION Set draw times
452
+ # Gap between line draw/erase events = 1/6 second
453
+ LINE_DRAW_ERASE_GAP = CDG_FPS // 6
454
+
455
+ # TODO Make more values in these set-draw-times functions into named
456
+ # constants
457
+
458
+ def _set_draw_times(self):
459
+ self.lyric_times: list[LyricTimes] = []
460
+ for lyric in self.lyrics:
461
+ self.logger.debug(f"setting draw times for lyric {lyric.lyric_index}")
462
+ line_count = len(lyric.lines)
463
+ line_draw: list[int] = [0] * line_count
464
+ line_erase: list[int] = [0] * line_count
465
+
466
+ # The first page is drawn 3 seconds before the first
467
+ # syllable
468
+ first_syllable = next(iter(syllable_info for line_info in lyric.lines for syllable_info in line_info.syllables))
469
+ draw_time = first_syllable.start_offset - 900
470
+ for i in range(lyric.lines_per_page):
471
+ if i < line_count:
472
+ line_draw[i] = draw_time
473
+ draw_time += self.LINE_DRAW_ERASE_GAP
474
+
475
+ # For each pair of syllables
476
+ for last_wipe, wipe in it.pairwise(syllable_info for line_info in lyric.lines for syllable_info in line_info.syllables):
477
+ # Skip if not on a line boundary
478
+ if wipe.line_index <= last_wipe.line_index:
479
+ continue
480
+
481
+ # Set draw times for lines
482
+ match self.config.clear_mode:
483
+ case LyricClearMode.PAGE:
484
+ self._set_draw_times_page(
485
+ last_wipe,
486
+ wipe,
487
+ lyric=lyric,
488
+ line_draw=line_draw,
489
+ line_erase=line_erase,
490
+ )
491
+ case LyricClearMode.LINE_EAGER:
492
+ self._set_draw_times_line_eager(
493
+ last_wipe,
494
+ wipe,
495
+ lyric=lyric,
496
+ line_draw=line_draw,
497
+ line_erase=line_erase,
498
+ )
499
+ case LyricClearMode.LINE_DELAYED | _:
500
+ self._set_draw_times_line_delayed(
501
+ last_wipe,
502
+ wipe,
503
+ lyric=lyric,
504
+ line_draw=line_draw,
505
+ line_erase=line_erase,
506
+ )
507
+
508
+ # If clearing page by page
509
+ if self.config.clear_mode == LyricClearMode.PAGE:
510
+ # Don't actually erase any lines
511
+ line_erase = []
512
+ # If we're not clearing page by page
513
+ else:
514
+ end_line = wipe.line_index
515
+ # Calculate the erase time of the last highlighted line
516
+ erase_time = wipe.end_offset + 600
517
+ line_erase[end_line] = erase_time
518
+ erase_time += self.LINE_DRAW_ERASE_GAP
519
+
520
+ self.logger.debug(f"lyric {lyric.lyric_index} draw times: {line_draw!r}")
521
+ self.logger.debug(f"lyric {lyric.lyric_index} erase times: {line_erase!r}")
522
+ self.lyric_times.append(
523
+ LyricTimes(
524
+ line_draw=line_draw,
525
+ line_erase=line_erase,
526
+ )
527
+ )
528
+ self.logger.info("draw times set")
529
+
530
+ def _set_draw_times_page(
531
+ self,
532
+ last_wipe: SyllableInfo,
533
+ wipe: SyllableInfo,
534
+ lyric: LyricInfo,
535
+ line_draw: list[int],
536
+ line_erase: list[int],
537
+ ):
538
+ line_count = len(lyric.lines)
539
+ last_page = last_wipe.line_index // lyric.lines_per_page
540
+ this_page = wipe.line_index // lyric.lines_per_page
541
+
542
+ # Skip if not on a page boundary
543
+ if this_page <= last_page:
544
+ return
545
+
546
+ # This page starts at the later of:
547
+ # - a few frames after the end of the last line
548
+ # - 3 seconds before this line
549
+ page_draw_time = max(
550
+ last_wipe.end_offset + 12,
551
+ wipe.start_offset - 900,
552
+ )
553
+
554
+ # Calculate the available time between the start of this line
555
+ # and the desired page draw time
556
+ available_time = wipe.start_offset - page_draw_time
557
+ # Calculate the absolute minimum time from the last line to this
558
+ # line
559
+ # NOTE This is a sensible minimum, but not guaranteed.
560
+ minimum_time = wipe.start_offset - last_wipe.start_offset - 24
561
+
562
+ # Warn the user if there's not likely to be enough time
563
+ if minimum_time < 32:
564
+ self.logger.warning("not enough bandwidth to clear screen on lyric " f"{wipe.lyric_index} line {wipe.line_index}")
565
+
566
+ # If there's not enough time between the end of the last line
567
+ # and the start of this line, but there is enough time between
568
+ # the start of the last line and the start of this page
569
+ if available_time < 32:
570
+ # Shorten the last wipe's duration to make room
571
+ new_duration = wipe.start_offset - last_wipe.start_offset - 150
572
+ if new_duration > 0:
573
+ last_wipe.end_offset = last_wipe.start_offset + new_duration
574
+ page_draw_time = last_wipe.end_offset + 12
575
+ else:
576
+ last_wipe.end_offset = last_wipe.start_offset
577
+ page_draw_time = last_wipe.end_offset + 32
578
+
579
+ # Set the draw times for lines on this page
580
+ start_line = this_page * lyric.lines_per_page
581
+ for i in range(start_line, start_line + lyric.lines_per_page):
582
+ if i < line_count:
583
+ line_draw[i] = page_draw_time
584
+ page_draw_time += self.LINE_DRAW_ERASE_GAP
585
+
586
+ def _set_draw_times_line_eager(
587
+ self,
588
+ last_wipe: SyllableInfo,
589
+ wipe: SyllableInfo,
590
+ lyric: LyricInfo,
591
+ line_draw: list[int],
592
+ line_erase: list[int],
593
+ ):
594
+ line_count = len(lyric.lines)
595
+ last_page = last_wipe.line_index // lyric.lines_per_page
596
+ this_page = wipe.line_index // lyric.lines_per_page
597
+
598
+ # The last line should be erased near the start of this line
599
+ erase_time = wipe.start_offset
600
+
601
+ # If we're not on the next page
602
+ if last_page >= this_page:
603
+ # The last line is erased 1/3 seconds after the start of
604
+ # this line
605
+ erase_time += 100
606
+
607
+ # Set draw and erase times for the last line
608
+ for i in range(last_wipe.line_index, wipe.line_index):
609
+ if i < line_count:
610
+ line_erase[i] = erase_time
611
+ erase_time += self.LINE_DRAW_ERASE_GAP
612
+ j = i + lyric.lines_per_page
613
+ if j < line_count:
614
+ line_draw[j] = erase_time
615
+ erase_time += self.LINE_DRAW_ERASE_GAP
616
+ return
617
+ # If we're here, we're on the next page
618
+
619
+ last_wipe_end = last_wipe.end_offset
620
+ inter_wipe_time = wipe.start_offset - last_wipe_end
621
+
622
+ # The last line is erased at the earlier of:
623
+ # - halfway between the pages
624
+ # - 1.5 seconds after the last line
625
+ erase_time = min(
626
+ last_wipe_end + inter_wipe_time // 2,
627
+ last_wipe_end + 450,
628
+ )
629
+
630
+ # If time between pages is less than 8 seconds
631
+ if inter_wipe_time < 2400:
632
+ # Set draw and erase times for the last line
633
+ for i in range(last_wipe.line_index, wipe.line_index):
634
+ if i < line_count:
635
+ line_erase[i] = erase_time
636
+ erase_time += self.LINE_DRAW_ERASE_GAP
637
+ j = i + lyric.lines_per_page
638
+ if j < line_count:
639
+ line_draw[j] = erase_time
640
+ erase_time += self.LINE_DRAW_ERASE_GAP
641
+ # If time between pages is 8 seconds or longer
642
+ else:
643
+ # Set erase time for the last line
644
+ for i in range(last_wipe.line_index, wipe.line_index):
645
+ if i < line_count:
646
+ line_erase[i] = erase_time
647
+ erase_time += self.LINE_DRAW_ERASE_GAP
648
+
649
+ # The new page will be drawn 3 seconds before the start of
650
+ # this line
651
+ draw_time = wipe.start_offset - 900
652
+ start_line = wipe.line_index
653
+ for i in range(start_line, start_line + lyric.lines_per_page):
654
+ if i < line_count:
655
+ line_draw[i] = draw_time
656
+ draw_time += self.LINE_DRAW_ERASE_GAP
657
+
658
+ def _set_draw_times_line_delayed(
659
+ self,
660
+ last_wipe: SyllableInfo,
661
+ wipe: SyllableInfo,
662
+ lyric: LyricInfo,
663
+ line_draw: list[int],
664
+ line_erase: list[int],
665
+ ):
666
+ line_count = len(lyric.lines)
667
+ last_page = last_wipe.line_index // lyric.lines_per_page
668
+ this_page = wipe.line_index // lyric.lines_per_page
669
+
670
+ # If we're on the same page
671
+ if last_page == this_page:
672
+ # The last line will be erased at the earlier of:
673
+ # - 1/3 seconds after the start of this line
674
+ # - 1.5 seconds after the end of the last line
675
+ erase_time = min(
676
+ wipe.start_offset + 100,
677
+ last_wipe.end_offset + 450,
678
+ )
679
+
680
+ # Set erase time for the last line
681
+ for i in range(last_wipe.line_index, wipe.line_index):
682
+ if i < line_count:
683
+ line_erase[i] = erase_time
684
+ erase_time += self.LINE_DRAW_ERASE_GAP
685
+ return
686
+ # If we're here, we're on the next page
687
+
688
+ last_wipe_end = max(
689
+ last_wipe.end_offset,
690
+ last_wipe.start_offset + 100,
691
+ )
692
+ inter_wipe_time = wipe.start_offset - last_wipe_end
693
+
694
+ last_line_start_offset = lyric.lines[last_wipe.line_index].syllables[0].start_offset
695
+
696
+ # The last line will be erased at the earlier of:
697
+ # - 1/3 seconds after the start of this line
698
+ # - 1.5 seconds after the end of the last line
699
+ # - 1/3 of the way between the pages
700
+ erase_time = min(
701
+ wipe.start_offset + 100,
702
+ last_wipe_end + 450,
703
+ last_wipe_end + inter_wipe_time // 3,
704
+ )
705
+ # This line will be drawn at the latest of:
706
+ # - 1/3 seconds after the start of the last line
707
+ # - 3 seconds before the start of this line
708
+ # - 1/3 of the way between the pages
709
+ draw_time = max(
710
+ last_line_start_offset + 100,
711
+ wipe.start_offset - 900,
712
+ last_wipe_end + inter_wipe_time // 3,
713
+ )
714
+
715
+ # If time between pages is 4 seconds or more, clear current page
716
+ # lines before drawing new page lines
717
+ if inter_wipe_time >= 1200:
718
+ # Set erase times for lines on previous page
719
+ for i in range(last_wipe.line_index, wipe.line_index):
720
+ if i < line_count:
721
+ line_erase[i] = erase_time
722
+ erase_time += self.LINE_DRAW_ERASE_GAP
723
+
724
+ draw_time = max(draw_time, erase_time)
725
+ start_line = last_page * lyric.lines_per_page
726
+ # Set draw times for lines on this page
727
+ for i in range(start_line, start_line + lyric.lines_per_page):
728
+ j = i + lyric.lines_per_page
729
+ if j < line_count:
730
+ line_draw[j] = draw_time
731
+ draw_time += self.LINE_DRAW_ERASE_GAP
732
+ return
733
+ # If time between pages is less than 4 seconds, draw new page
734
+ # lines before clearing current page lines
735
+
736
+ # The first lines on the next page should be drawn 1/2 seconds
737
+ # after the start of the last line
738
+ draw_time = last_line_start_offset + 150
739
+
740
+ # Set draw time for all lines on the next page before this line
741
+ start_line = last_page * lyric.lines_per_page
742
+ for i in range(start_line, last_wipe.line_index):
743
+ j = i + lyric.lines_per_page
744
+ if j < line_count:
745
+ line_draw[j] = draw_time
746
+ draw_time += self.LINE_DRAW_ERASE_GAP
747
+
748
+ # The last lines on the next page should be drawn at least 1/3
749
+ # of the way between the pages
750
+ draw_time = max(
751
+ draw_time,
752
+ last_wipe_end + inter_wipe_time // 3,
753
+ )
754
+ # Set erase times for the rest of the lines on the previous page
755
+ for i in range(last_wipe.line_index, wipe.line_index):
756
+ if i < line_count:
757
+ line_erase[i] = draw_time
758
+ draw_time += self.LINE_DRAW_ERASE_GAP
759
+ # Set draw times for the rest of the lines on this page
760
+ for i in range(last_wipe.line_index, wipe.line_index):
761
+ j = i + lyric.lines_per_page
762
+ if j < line_count:
763
+ line_draw[j] = draw_time
764
+ draw_time += self.LINE_DRAW_ERASE_GAP
765
+
766
+ # !SECTION
767
+ # endregion
768
+
769
+ # region Compose words
770
+ # SECTION Compose words
771
+ def compose(self):
772
+ try:
773
+ # NOTE Logistically, multiple simultaneous lyric sets doesn't
774
+ # make sense if the lyrics are being cleared by page.
775
+ if self.config.clear_mode == LyricClearMode.PAGE and len(self.lyrics) > 1:
776
+ raise RuntimeError("page mode doesn't support more than one lyric set")
777
+
778
+ self.logger.debug("loading song file")
779
+ song: AudioSegment = AudioSegment.from_file(file_relative_to(self.config.file, self.relative_dir))
780
+ self.logger.info("song file loaded")
781
+
782
+ self.lyric_packet_indices: set[int] = set()
783
+ self.instrumental_times: list[int] = []
784
+
785
+ self.intro_delay = 0
786
+ # Compose the intro
787
+ # NOTE This also sets the intro delay for later.
788
+ self._compose_intro()
789
+
790
+ lyric_states: list[LyricState] = []
791
+ for lyric in self.lyrics:
792
+ lyric_states.append(
793
+ LyricState(
794
+ line_draw=0,
795
+ line_erase=0,
796
+ syllable_line=0,
797
+ syllable_index=0,
798
+ draw_queue=deque(),
799
+ highlight_queue=deque(),
800
+ )
801
+ )
802
+
803
+ composer_state = ComposerState(
804
+ instrumental=0,
805
+ this_page=0,
806
+ last_page=0,
807
+ just_cleared=False,
808
+ )
809
+
810
+ # XXX If there is an instrumental section immediately after the
811
+ # intro, the screen should not be cleared. The way I'm detecting
812
+ # this, however, is by (mostly) copy-pasting the code that
813
+ # checks for instrumental sections. I shouldn't do it this way.
814
+ current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
815
+ should_instrumental = False
816
+ instrumental = None
817
+ if composer_state.instrumental < len(self.config.instrumentals):
818
+ instrumental = self.config.instrumentals[composer_state.instrumental]
819
+ instrumental_time = sync_to_cdg(instrumental.sync)
820
+ # NOTE Normally, this part has code to handle waiting for a
821
+ # lyric to finish. If there's an instrumental this early,
822
+ # however, there shouldn't be any lyrics to finish.
823
+ should_instrumental = current_time >= instrumental_time
824
+ # If there should not be an instrumental section now
825
+ if not should_instrumental:
826
+ self.logger.debug("instrumental intro is not present; clearing")
827
+ # Clear the screen
828
+ self.writer.queue_packets(
829
+ [
830
+ *memory_preset_repeat(self.BACKGROUND),
831
+ *load_color_table(self.color_table),
832
+ ]
833
+ )
834
+ if self.config.border is not None:
835
+ self.writer.queue_packet(border_preset(self.BORDER))
836
+ else:
837
+ self.logger.debug("instrumental intro is present; not clearing")
838
+
839
+ # While there are lines to draw/erase, or syllables to
840
+ # highlight, or events in the highlight/draw queues, or
841
+ # instrumental sections to process
842
+ while any(
843
+ state.line_draw < len(times.line_draw)
844
+ or state.line_erase < len(times.line_erase)
845
+ or state.syllable_line < len(lyric.lines)
846
+ or state.draw_queue
847
+ or state.highlight_queue
848
+ for lyric, times, state in zip(
849
+ self.lyrics,
850
+ self.lyric_times,
851
+ lyric_states,
852
+ )
853
+ ) or (composer_state.instrumental < len(self.config.instrumentals)):
854
+ for lyric, times, state in zip(
855
+ self.lyrics,
856
+ self.lyric_times,
857
+ lyric_states,
858
+ ):
859
+ self._compose_lyric(
860
+ lyric=lyric,
861
+ times=times,
862
+ state=state,
863
+ lyric_states=lyric_states,
864
+ composer_state=composer_state,
865
+ )
866
+
867
+ # Add audio padding to intro
868
+ self.logger.debug("padding intro of audio file")
869
+ intro_silence: AudioSegment = AudioSegment.silent(
870
+ self.intro_delay * 1000 // CDG_FPS,
871
+ frame_rate=song.frame_rate,
872
+ )
873
+ self.audio = intro_silence + song
874
+
875
+ # NOTE If video padding is not added to the end of the song, the
876
+ # outro (or next instrumental section) begins immediately after
877
+ # the end of the last syllable, which would be abrupt.
878
+ if self.config.clear_mode == LyricClearMode.PAGE:
879
+ self.logger.debug("clear mode is page; adding padding before outro")
880
+ self.writer.queue_packets([no_instruction()] * 3 * CDG_FPS)
881
+
882
+ # Calculate video padding before outro
883
+ OUTRO_DURATION = 2400
884
+ # This karaoke file ends at the later of:
885
+ # - The end of the audio (with the padded intro)
886
+ # - 8 seconds after the current video time
887
+ end = max(
888
+ int(self.audio.duration_seconds * CDG_FPS),
889
+ self.writer.packets_queued + OUTRO_DURATION,
890
+ )
891
+ self.logger.debug(f"song should be {end} frame(s) long")
892
+ padding_before_outro = (end - OUTRO_DURATION) - self.writer.packets_queued
893
+ self.logger.debug(f"queueing {padding_before_outro} packets before outro")
894
+ self.writer.queue_packets([no_instruction()] * padding_before_outro)
895
+
896
+ # Compose the outro (and thus, finish the video)
897
+ self._compose_outro(end)
898
+ self.logger.info("karaoke file composed")
899
+
900
+ # Add audio padding to outro (and thus, finish the audio)
901
+ self.logger.debug("padding outro of audio file")
902
+ outro_silence: AudioSegment = AudioSegment.silent(
903
+ ((self.writer.packets_queued * 1000 // CDG_FPS) - int(self.audio.duration_seconds * 1000)),
904
+ frame_rate=song.frame_rate,
905
+ )
906
+ self.audio += outro_silence
907
+
908
+ # Write CDG and MP3 data to ZIP file
909
+ outname = self.config.outname
910
+ zipfile_name = self.relative_dir / Path(f"{outname}.zip")
911
+ self.logger.debug(f"creating {zipfile_name}")
912
+ with ZipFile(zipfile_name, "w") as zipfile:
913
+ cdg_bytes = BytesIO()
914
+ self.logger.debug("writing cdg packets to stream")
915
+ self.writer.write_packets(cdg_bytes)
916
+ self.logger.debug(f"writing stream to zipfile as {outname}.cdg")
917
+ cdg_bytes.seek(0)
918
+ zipfile.writestr(f"{outname}.cdg", cdg_bytes.read())
919
+
920
+ mp3_bytes = BytesIO()
921
+ self.logger.debug("writing mp3 data to stream")
922
+ self.audio.export(mp3_bytes, format="mp3")
923
+ self.logger.debug(f"writing stream to zipfile as {outname}.mp3")
924
+ mp3_bytes.seek(0)
925
+ zipfile.writestr(f"{outname}.mp3", mp3_bytes.read())
926
+ self.logger.info(f"karaoke files written to {zipfile_name}")
927
+ except Exception as e:
928
+ self.logger.error(f"Error in compose: {str(e)}", exc_info=True)
929
+ raise
930
+
931
+ def _compose_lyric(
932
+ self,
933
+ lyric: LyricInfo,
934
+ times: LyricTimes,
935
+ state: LyricState,
936
+ lyric_states: list[LyricState],
937
+ composer_state: ComposerState,
938
+ ):
939
+ current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
940
+
941
+ should_draw_this_line = False
942
+ line_draw_info, line_draw_time = None, None
943
+ if state.line_draw < len(times.line_draw):
944
+ line_draw_info = lyric.lines[state.line_draw]
945
+ line_draw_time = times.line_draw[state.line_draw]
946
+ should_draw_this_line = current_time >= line_draw_time
947
+
948
+ should_erase_this_line = False
949
+ line_erase_info, line_erase_time = None, None
950
+ if state.line_erase < len(times.line_erase):
951
+ line_erase_info = lyric.lines[state.line_erase]
952
+ line_erase_time = times.line_erase[state.line_erase]
953
+ should_erase_this_line = current_time >= line_erase_time
954
+
955
+ # If we're clearing lyrics by page and drawing a new line
956
+ if self.config.clear_mode == LyricClearMode.PAGE and should_draw_this_line:
957
+ composer_state.last_page = composer_state.this_page
958
+ composer_state.this_page = line_draw_info.line_index // lyric.lines_per_page
959
+ # If this line is the start of a new page
960
+ if composer_state.this_page > composer_state.last_page:
961
+ self.logger.debug(f"going from page {composer_state.last_page} to " f"page {composer_state.this_page} in page mode")
962
+ # If we have not just cleared the screen
963
+ if not composer_state.just_cleared:
964
+ self.logger.debug("clearing screen on page transition")
965
+ # Clear the last page
966
+ page_clear_packets = [
967
+ *memory_preset_repeat(self.BACKGROUND),
968
+ ]
969
+ if self.config.border is not None:
970
+ page_clear_packets.append(border_preset(self.BORDER))
971
+ self.lyric_packet_indices.update(
972
+ range(
973
+ self.writer.packets_queued,
974
+ self.writer.packets_queued + len(page_clear_packets),
975
+ )
976
+ )
977
+ self.writer.queue_packets(page_clear_packets)
978
+ composer_state.just_cleared = True
979
+ # Update the current frame time
980
+ current_time += len(page_clear_packets)
981
+ else:
982
+ self.logger.debug("not clearing screen on page transition")
983
+
984
+ # Queue the erasing of this line if necessary
985
+ if should_erase_this_line:
986
+ assert line_erase_info is not None
987
+ self.logger.debug(
988
+ f"t={self.writer.packets_queued}: erasing lyric " f"{line_erase_info.lyric_index} line " f"{line_erase_info.line_index}"
989
+ )
990
+ if line_erase_info.text.strip():
991
+ state.draw_queue.extend(
992
+ line_image_to_packets(
993
+ line_erase_info.image,
994
+ xy=(line_erase_info.x, line_erase_info.y),
995
+ background=self.BACKGROUND,
996
+ erase=True,
997
+ )
998
+ )
999
+ else:
1000
+ self.logger.debug("line is blank; not erased")
1001
+ state.line_erase += 1
1002
+ # Queue the drawing of this line if necessary
1003
+ if should_draw_this_line:
1004
+ assert line_draw_info is not None
1005
+ self.logger.debug(
1006
+ f"t={self.writer.packets_queued}: drawing lyric " f"{line_draw_info.lyric_index} line " f"{line_draw_info.line_index}"
1007
+ )
1008
+ if line_draw_info.text.strip():
1009
+ state.draw_queue.extend(
1010
+ line_image_to_packets(
1011
+ line_draw_info.image,
1012
+ xy=(line_draw_info.x, line_draw_info.y),
1013
+ fill=line_draw_info.singer << 2 | 0,
1014
+ stroke=line_draw_info.singer << 2 | 1,
1015
+ background=self.BACKGROUND,
1016
+ )
1017
+ )
1018
+ else:
1019
+ self.logger.debug("line is blank; not drawn")
1020
+ state.line_draw += 1
1021
+
1022
+ # NOTE If this line has no syllables, we must advance the
1023
+ # syllable line index until we reach a line that has syllables.
1024
+ while state.syllable_line < len(lyric.lines):
1025
+ if lyric.lines[state.syllable_line].syllables:
1026
+ break
1027
+ state.syllable_index = 0
1028
+ state.syllable_line += 1
1029
+
1030
+ should_highlight = False
1031
+ syllable_info = None
1032
+ if state.syllable_line < len(lyric.lines):
1033
+ syllable_info = lyric.lines[state.syllable_line].syllables[state.syllable_index]
1034
+ should_highlight = current_time >= syllable_info.start_offset
1035
+ # If this syllable should be highlighted now
1036
+ if should_highlight:
1037
+ assert syllable_info is not None
1038
+ if syllable_info.text.strip():
1039
+ # Add the highlight packets to the highlight queue
1040
+ state.highlight_queue.extend(
1041
+ self._compose_highlight(
1042
+ lyric=lyric,
1043
+ syllable=syllable_info,
1044
+ current_time=current_time,
1045
+ )
1046
+ )
1047
+
1048
+ # Advance to the next syllable
1049
+ state.syllable_index += 1
1050
+ if state.syllable_index >= len(lyric.lines[state.syllable_line].syllables):
1051
+ state.syllable_index = 0
1052
+ state.syllable_line += 1
1053
+
1054
+ should_instrumental = False
1055
+ instrumental = None
1056
+ if composer_state.instrumental < len(self.config.instrumentals):
1057
+ instrumental = self.config.instrumentals[composer_state.instrumental]
1058
+ # TODO Improve this code for waiting to start instrumentals!
1059
+ # It's a mess!
1060
+ instrumental_time = sync_to_cdg(instrumental.sync)
1061
+ # If instrumental time is to be interpreted as waiting for
1062
+ # syllable to end
1063
+ if instrumental.wait:
1064
+ syllable_iter = iter(syll for line_info in lyric.lines for syll in line_info.syllables)
1065
+ last_syllable = next(syllable_iter)
1066
+ # Find first syllable on or after the instrumental time
1067
+ while last_syllable is not None and last_syllable.start_offset < instrumental_time:
1068
+ last_syllable = next(syllable_iter, None)
1069
+ # If syllable was not found
1070
+ if last_syllable is None:
1071
+ # Make sure the instrumental won't play
1072
+ # FIXME This happens when the instrumental is
1073
+ # happening after some syllable in another lyric.
1074
+ # What's a better way to handle this?
1075
+ instrumental_time = float("inf")
1076
+ # If syllable was found
1077
+ else:
1078
+ first_syllable = lyric.lines[last_syllable.line_index].syllables[0]
1079
+ # If this line is being actively sung
1080
+ if current_time >= first_syllable.start_offset:
1081
+ # If this is the last syllable in this line
1082
+ if last_syllable.syllable_index == len(lyric.lines[last_syllable.line_index].syllables) - 1:
1083
+ instrumental_time = 0
1084
+ if times.line_erase:
1085
+ # Wait for this line to be erased
1086
+ instrumental_time = times.line_erase[last_syllable.line_index]
1087
+ if not instrumental_time:
1088
+ # Add 1.5 seconds
1089
+ # XXX This is hardcoded.
1090
+ instrumental_time = last_syllable.end_offset + 450
1091
+ else:
1092
+ self.logger.debug("forcing next instrumental not to " "wait; it does not occur at or before " "the end of this line")
1093
+ instrumental.wait = False
1094
+ should_instrumental = current_time >= instrumental_time
1095
+ # If there should be an instrumental section now
1096
+ if should_instrumental:
1097
+ assert instrumental is not None
1098
+ self.logger.debug("time for an instrumental section")
1099
+ if instrumental.wait:
1100
+ self.logger.debug("this instrumental section waited for the previous " "line to finish")
1101
+ else:
1102
+ self.logger.debug("this instrumental did not wait for the previous " "line to finish")
1103
+
1104
+ self.logger.debug("_compose_lyric: Purging all highlight/draw queues")
1105
+ for st in lyric_states:
1106
+ if instrumental.wait:
1107
+ if st.highlight_queue:
1108
+ self.logger.warning("_compose_lyric: Unexpected items in highlight queue when instrumental waited")
1109
+ if st.draw_queue:
1110
+ if st == state:
1111
+ self.logger.debug("_compose_lyric: Queueing remaining draw packets for current state")
1112
+ else:
1113
+ self.logger.warning("_compose_lyric: Unexpected items in draw queue for non-current state")
1114
+ self.writer.queue_packets(st.draw_queue)
1115
+
1116
+ # Purge highlight/draw queues
1117
+ st.highlight_queue.clear()
1118
+ st.draw_queue.clear()
1119
+
1120
+ # The instrumental should end when the next line is drawn by
1121
+ # default
1122
+ if line_draw_time is not None:
1123
+ instrumental_end = line_draw_time
1124
+ else:
1125
+ # NOTE A value of None here means this instrumental will
1126
+ # never end (and once the screen is drawn, it will not
1127
+ # pause), unless there is another instrumental after
1128
+ # this.
1129
+ instrumental_end = None
1130
+
1131
+ composer_state.instrumental += 1
1132
+ next_instrumental = None
1133
+ if composer_state.instrumental < len(self.config.instrumentals):
1134
+ next_instrumental = self.config.instrumentals[composer_state.instrumental]
1135
+ should_clear = True
1136
+ # If there is a next instrumental
1137
+ if next_instrumental is not None:
1138
+ next_instrumental_time = sync_to_cdg(next_instrumental.sync)
1139
+ # If the next instrumental is immediately after this one
1140
+ if instrumental_end is None or next_instrumental_time <= instrumental_end:
1141
+ # This instrumental should end there
1142
+ instrumental_end = next_instrumental_time
1143
+ # Don't clear the screen afterwards
1144
+ should_clear = False
1145
+ else:
1146
+ if line_draw_time is None:
1147
+ should_clear = False
1148
+
1149
+ self.logger.info(f"_compose_lyric: Composing instrumental. End time: {instrumental_end}, Should clear: {should_clear}")
1150
+ try:
1151
+ self._compose_instrumental(instrumental, instrumental_end)
1152
+ except Exception as e:
1153
+ self.logger.error(f"Error in _compose_instrumental: {str(e)}", exc_info=True)
1154
+ raise
1155
+
1156
+ if should_clear:
1157
+ self.logger.debug("_compose_lyric: Clearing screen after instrumental")
1158
+ self.writer.queue_packets(
1159
+ [
1160
+ *memory_preset_repeat(self.BACKGROUND),
1161
+ *load_color_table(self.color_table),
1162
+ ]
1163
+ )
1164
+ self.logger.debug(f"_compose_lyric: Loaded color table: {self.color_table}")
1165
+ if self.config.border is not None:
1166
+ self.writer.queue_packet(border_preset(self.BORDER))
1167
+ composer_state.just_cleared = True
1168
+ else:
1169
+ self.logger.debug("not clearing screen after instrumental")
1170
+ # Advance to the next instrumental section
1171
+ instrumental = next_instrumental
1172
+ return
1173
+
1174
+ composer_state.just_cleared = False
1175
+ # Create groups of packets for highlights and draws, with None
1176
+ # as a placeholder value for non-highlight packets
1177
+ highlight_groups: list[list[CDGPacket | None]] = []
1178
+ for _ in range(self.config.highlight_bandwidth):
1179
+ group = []
1180
+ if state.highlight_queue:
1181
+ group = state.highlight_queue.popleft()
1182
+ highlight_groups.append(list(pad(group, self.max_tile_height)))
1183
+ # NOTE This means the draw groups will only contain None.
1184
+ draw_groups: list[list[CDGPacket | None]] = [[None] * self.max_tile_height] * self.config.draw_bandwidth
1185
+
1186
+ self.lyric_packet_indices.update(
1187
+ range(
1188
+ self.writer.packets_queued,
1189
+ self.writer.packets_queued + len(list(it.chain(*highlight_groups, *draw_groups))),
1190
+ )
1191
+ )
1192
+
1193
+ # Intersperse the highlight and draw groups and queue the
1194
+ # packets
1195
+ for group in intersperse(highlight_groups, draw_groups):
1196
+ for item in group:
1197
+ if item is not None:
1198
+ self.writer.queue_packet(item)
1199
+ continue
1200
+
1201
+ # If a group item is None, try getting packets from the
1202
+ # draw queue
1203
+ if state.draw_queue:
1204
+ self.writer.queue_packet(state.draw_queue.popleft())
1205
+ continue
1206
+ self.writer.queue_packet(next(iter(st.draw_queue.popleft() for st in lyric_states if st.draw_queue), no_instruction()))
1207
+
1208
+ def _compose_highlight(
1209
+ self,
1210
+ lyric: LyricInfo,
1211
+ syllable: SyllableInfo,
1212
+ current_time: int,
1213
+ ) -> list[list[CDGPacket]]:
1214
+ assert syllable is not None
1215
+ line_info = lyric.lines[syllable.line_index]
1216
+ x = line_info.x
1217
+ y = line_info.y
1218
+
1219
+ # NOTE Using the current time instead of the ideal start offset
1220
+ # accounts for any lost frames from previous events that took
1221
+ # too long.
1222
+ start_offset = current_time
1223
+ end_offset = syllable.end_offset
1224
+ left_edge = syllable.left_edge
1225
+ right_edge = syllable.right_edge
1226
+
1227
+ # Calculate the length of each column group in frames
1228
+ column_group_length = ((self.config.draw_bandwidth + self.config.highlight_bandwidth) * self.max_tile_height) * len(self.lyrics)
1229
+ # Calculate the number of column updates for this highlight
1230
+ columns = ((end_offset - start_offset) // column_group_length) * self.config.highlight_bandwidth
1231
+
1232
+ left_tile = left_edge // CDG_TILE_WIDTH
1233
+ right_tile = ceildiv(right_edge, CDG_TILE_WIDTH) - 1
1234
+ # The highlight must hit at least the edges of all the tiles
1235
+ # along it (not including the one before the left edge or the
1236
+ # one after the right edge)
1237
+ highlight_progress = [tile_index * CDG_TILE_WIDTH for tile_index in range(left_tile + 1, right_tile + 1)]
1238
+ # If there aren't too many tile boundaries for the number of
1239
+ # column updates
1240
+ if columns - 1 >= len(highlight_progress):
1241
+ # Add enough highlight points for all the column updates...
1242
+ highlight_progress += sorted(
1243
+ # ...which are evenly distributed within the range...
1244
+ map(
1245
+ operator.itemgetter(0),
1246
+ distribute(
1247
+ range(1, columns),
1248
+ left_edge,
1249
+ right_edge,
1250
+ ),
1251
+ ),
1252
+ # ...prioritizing highlight points nearest to the middle
1253
+ # of a tile
1254
+ key=lambda n: abs(n % CDG_TILE_WIDTH - CDG_TILE_WIDTH // 2),
1255
+ )[: columns - 1 - len(highlight_progress)]
1256
+ # NOTE We need the length of this list to be the number of
1257
+ # columns minus 1, so that when the left and right edges are
1258
+ # included, there will be as many pairs as there are
1259
+ # columns.
1260
+
1261
+ # Round and sort the highlight points
1262
+ highlight_progress = sorted(map(round, highlight_progress))
1263
+ # If there are too many tile boundaries for the number of column
1264
+ # updates
1265
+ else:
1266
+ # Prepare the syllable text representation
1267
+ syllable_text = "".join(
1268
+ f"{{{syll.text}}}" if si == syllable.syllable_index else syll.text
1269
+ for si, syll in enumerate(lyric.lines[syllable.line_index].syllables)
1270
+ )
1271
+
1272
+ # Warn the user
1273
+ self.logger.warning(
1274
+ "Not enough time to highlight lyric %d line %d syllable %d. "
1275
+ "Ideal duration is %d column(s); actual duration is %d column(s). "
1276
+ "Syllable text: %s",
1277
+ syllable.lyric_index,
1278
+ syllable.line_index,
1279
+ syllable.syllable_index,
1280
+ columns,
1281
+ len(highlight_progress) + 1,
1282
+ syllable_text,
1283
+ )
1284
+
1285
+ # Create the highlight packets
1286
+ return [
1287
+ line_mask_to_packets(syllable.mask, (x, y), edges) for edges in it.pairwise([left_edge] + highlight_progress + [right_edge])
1288
+ ]
1289
+
1290
+ # !SECTION
1291
+ # endregion
1292
+
1293
+ # region Compose pictures
1294
+ # SECTION Compose pictures
1295
+ def _compose_instrumental(
1296
+ self,
1297
+ instrumental: SettingsInstrumental,
1298
+ end: int | None,
1299
+ ):
1300
+ self.logger.info(f"Composing instrumental section. End time: {end}")
1301
+ try:
1302
+ self.logger.info("composing instrumental section")
1303
+ self.instrumental_times.append(self.writer.packets_queued)
1304
+ self.writer.queue_packets(
1305
+ [
1306
+ *memory_preset_repeat(0),
1307
+ # TODO Add option for borders in instrumentals
1308
+ border_preset(0),
1309
+ ]
1310
+ )
1311
+
1312
+ self.logger.debug("rendering instrumental text")
1313
+ text = instrumental.text.split("\n")
1314
+ instrumental_font = ImageFont.truetype(self.config.font, 20)
1315
+ text_images = render_lines(
1316
+ text,
1317
+ font=instrumental_font,
1318
+ # NOTE If the instrumental shouldn't have a stroke, set the
1319
+ # stroke width to 0 instead.
1320
+ stroke_width=(self.config.stroke_width if instrumental.stroke is not None else 0),
1321
+ stroke_type=self.config.stroke_type,
1322
+ )
1323
+ text_width = max(image.width for image in text_images)
1324
+ line_height = instrumental.line_tile_height * CDG_TILE_HEIGHT
1325
+ text_height = line_height * len(text)
1326
+ max_height = max(image.height for image in text_images)
1327
+
1328
+ # Set X position of "text box"
1329
+ match instrumental.text_placement:
1330
+ case TextPlacement.TOP_LEFT | TextPlacement.MIDDLE_LEFT | TextPlacement.BOTTOM_LEFT:
1331
+ text_x = CDG_TILE_WIDTH * 2
1332
+ case TextPlacement.TOP_MIDDLE | TextPlacement.MIDDLE | TextPlacement.BOTTOM_MIDDLE:
1333
+ text_x = (CDG_SCREEN_WIDTH - text_width) // 2
1334
+ case TextPlacement.TOP_RIGHT | TextPlacement.MIDDLE_RIGHT | TextPlacement.BOTTOM_RIGHT:
1335
+ text_x = CDG_SCREEN_WIDTH - CDG_TILE_WIDTH * 2 - text_width
1336
+ # Set Y position of "text box"
1337
+ match instrumental.text_placement:
1338
+ case TextPlacement.TOP_LEFT | TextPlacement.TOP_MIDDLE | TextPlacement.TOP_RIGHT:
1339
+ text_y = CDG_TILE_HEIGHT * 2
1340
+ case TextPlacement.MIDDLE_LEFT | TextPlacement.MIDDLE | TextPlacement.MIDDLE_RIGHT:
1341
+ text_y = ((CDG_SCREEN_HEIGHT - text_height) // 2) // CDG_TILE_HEIGHT * CDG_TILE_HEIGHT
1342
+ # Add offset to place text closer to middle of line
1343
+ text_y += (line_height - max_height) // 2
1344
+ case TextPlacement.BOTTOM_LEFT | TextPlacement.BOTTOM_MIDDLE | TextPlacement.BOTTOM_RIGHT:
1345
+ text_y = CDG_SCREEN_HEIGHT - CDG_TILE_HEIGHT * 2 - text_height
1346
+ # Add offset to place text closer to bottom of line
1347
+ text_y += line_height - max_height
1348
+
1349
+ # Create "screen" image for drawing text
1350
+ screen = Image.new("P", (CDG_SCREEN_WIDTH, CDG_SCREEN_HEIGHT), 0)
1351
+ # Create list of packets to draw text
1352
+ text_image_packets: list[CDGPacket] = []
1353
+ y = text_y
1354
+ for image in text_images:
1355
+ # Set alignment of text
1356
+ match instrumental.text_align:
1357
+ case TextAlign.LEFT:
1358
+ x = text_x
1359
+ case TextAlign.CENTER:
1360
+ x = text_x + (text_width - image.width) // 2
1361
+ case TextAlign.RIGHT:
1362
+ x = text_x + text_width - image.width
1363
+ # Draw text onto simulated screen
1364
+ screen.paste(
1365
+ image.point(
1366
+ lambda v: v and (2 if v == RENDERED_FILL else 3),
1367
+ "P",
1368
+ ),
1369
+ (x, y),
1370
+ )
1371
+ # Render text into packets
1372
+ text_image_packets.extend(
1373
+ line_image_to_packets(
1374
+ image,
1375
+ xy=(x, y),
1376
+ fill=2,
1377
+ stroke=3,
1378
+ background=self.BACKGROUND,
1379
+ )
1380
+ )
1381
+ y += instrumental.line_tile_height * CDG_TILE_HEIGHT
1382
+
1383
+ if instrumental.image is not None:
1384
+ self.logger.debug("creating instrumental background image")
1385
+ try:
1386
+ # Load background image
1387
+ background_image = self._load_image(
1388
+ instrumental.image,
1389
+ [
1390
+ instrumental.background or self.config.background,
1391
+ self.UNUSED_COLOR,
1392
+ instrumental.fill,
1393
+ instrumental.stroke or self.UNUSED_COLOR,
1394
+ ],
1395
+ )
1396
+ except FileNotFoundError as e:
1397
+ self.logger.error(f"Failed to load instrumental image: {e}")
1398
+ # Fallback to simple screen if image can't be loaded
1399
+ instrumental.image = None
1400
+ self.logger.warning("Falling back to simple screen for instrumental")
1401
+
1402
+ if instrumental.image is None:
1403
+ self.logger.debug("no instrumental image; drawing simple screen")
1404
+ color_table = list(
1405
+ pad(
1406
+ [
1407
+ instrumental.background or self.config.background,
1408
+ self.UNUSED_COLOR,
1409
+ instrumental.fill,
1410
+ instrumental.stroke or self.UNUSED_COLOR,
1411
+ ],
1412
+ 8,
1413
+ padvalue=self.UNUSED_COLOR,
1414
+ )
1415
+ )
1416
+ # Set palette and draw text to screen
1417
+ self.writer.queue_packets(
1418
+ [
1419
+ load_color_table_lo(color_table),
1420
+ *text_image_packets,
1421
+ ]
1422
+ )
1423
+ self.logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1424
+ else:
1425
+ # Queue palette packets
1426
+ palette = list(batched(background_image.getpalette(), 3))
1427
+ if len(palette) < 8:
1428
+ color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1429
+ self.logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1430
+ self.writer.queue_packet(
1431
+ load_color_table_lo(
1432
+ color_table,
1433
+ )
1434
+ )
1435
+ else:
1436
+ color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1437
+ self.logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1438
+ self.writer.queue_packets(
1439
+ load_color_table(
1440
+ color_table,
1441
+ )
1442
+ )
1443
+
1444
+ self.logger.debug("drawing instrumental text")
1445
+ # Queue text packets
1446
+ self.writer.queue_packets(text_image_packets)
1447
+
1448
+ self.logger.debug("rendering instrumental text over background image")
1449
+ # HACK To properly draw and layer everything, I need to
1450
+ # create a version of the background image that has the text
1451
+ # overlaid onto it, and is tile-aligned. This requires some
1452
+ # juggling.
1453
+ padleft = instrumental.x % CDG_TILE_WIDTH
1454
+ padright = -(instrumental.x + background_image.width) % CDG_TILE_WIDTH
1455
+ padtop = instrumental.y % CDG_TILE_HEIGHT
1456
+ padbottom = -(instrumental.y + background_image.height) % CDG_TILE_HEIGHT
1457
+ self.logger.debug(f"padding L={padleft} R={padright} T={padtop} B={padbottom}")
1458
+ # Create axis-aligned background image with proper size and
1459
+ # palette
1460
+ aligned_background_image = Image.new(
1461
+ "P",
1462
+ (
1463
+ background_image.width + padleft + padright,
1464
+ background_image.height + padtop + padbottom,
1465
+ ),
1466
+ 0,
1467
+ )
1468
+ aligned_background_image.putpalette(background_image.getpalette())
1469
+ # Paste background image onto axis-aligned image
1470
+ aligned_background_image.paste(background_image, (padleft, padtop))
1471
+ # Paste existing screen text onto axis-aligned image
1472
+ aligned_background_image.paste(
1473
+ screen,
1474
+ (padleft - instrumental.x, padtop - instrumental.y),
1475
+ # NOTE This masks out the 0 pixels.
1476
+ mask=screen.point(lambda v: v and 255, mode="1"),
1477
+ )
1478
+
1479
+ # Render background image to packets
1480
+ packets = image_to_packets(
1481
+ aligned_background_image,
1482
+ (instrumental.x - padleft, instrumental.y - padtop),
1483
+ background=screen.crop(
1484
+ (
1485
+ instrumental.x - padleft,
1486
+ instrumental.y - padtop,
1487
+ instrumental.x - padleft + aligned_background_image.width,
1488
+ instrumental.y - padtop + aligned_background_image.height,
1489
+ )
1490
+ ),
1491
+ )
1492
+ self.logger.debug("instrumental background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1493
+
1494
+ self.logger.debug("applying instrumental transition")
1495
+ # Queue background image packets (and apply transition)
1496
+ if instrumental.transition is None:
1497
+ for coord_packets in packets.values():
1498
+ self.writer.queue_packets(coord_packets)
1499
+ else:
1500
+ transition = Image.open(package_dir / "transitions" / f"{instrumental.transition}.png")
1501
+ for coord in self._gradient_to_tile_positions(transition):
1502
+ self.writer.queue_packets(packets.get(coord, []))
1503
+
1504
+ if end is None:
1505
+ self.logger.debug('this instrumental will last "forever"')
1506
+ return
1507
+
1508
+ # Wait until 3 seconds before the next line should be drawn
1509
+ current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
1510
+ preparation_time = 3 * CDG_FPS # 3 seconds * 300 frames per second = 900 frames
1511
+ end_time = max(current_time, end - preparation_time)
1512
+ wait_time = end_time - current_time
1513
+
1514
+ self.logger.debug(f"waiting for {wait_time} frame(s) before showing next lyrics")
1515
+ self.writer.queue_packets([no_instruction()] * wait_time)
1516
+
1517
+ # Clear the screen for the next lyrics
1518
+ self.writer.queue_packets(
1519
+ [
1520
+ *memory_preset_repeat(self.BACKGROUND),
1521
+ *load_color_table(self.color_table),
1522
+ ]
1523
+ )
1524
+ self.logger.debug(f"loaded color table in compose_instrumental: {self.color_table}")
1525
+ if self.config.border is not None:
1526
+ self.writer.queue_packet(border_preset(self.BORDER))
1527
+
1528
+ self.logger.debug("instrumental section ended")
1529
+ except Exception as e:
1530
+ self.logger.error(f"Error in _compose_instrumental: {str(e)}", exc_info=True)
1531
+ raise
1532
+
1533
+ def _compose_intro(self):
1534
+ # TODO Make it so the intro screen is not hardcoded
1535
+ self.logger.debug("composing intro")
1536
+ self.writer.queue_packets(
1537
+ [
1538
+ *memory_preset_repeat(0),
1539
+ ]
1540
+ )
1541
+
1542
+ self.logger.debug("loading intro background image")
1543
+ # Load background image
1544
+ background_image = self._load_image(
1545
+ self.config.title_screen_background,
1546
+ [
1547
+ self.config.background, # background
1548
+ self.config.border, # border
1549
+ self.config.title_color, # title color
1550
+ self.config.artist_color, # artist color
1551
+ ],
1552
+ )
1553
+
1554
+ smallfont = ImageFont.truetype(self.config.font, 25)
1555
+ bigfont_size = 30
1556
+ MAX_HEIGHT = 200
1557
+ # Try rendering the title and artist to an image
1558
+ while True:
1559
+ self.logger.debug(f"trying song title at size {bigfont_size}")
1560
+ text_image = Image.new("P", (CDG_VISIBLE_WIDTH, MAX_HEIGHT * 2), 0)
1561
+ y = 0
1562
+
1563
+ if self.config.title_top_padding:
1564
+ self.logger.info(f"title top padding set to {self.config.title_top_padding} in config, setting as initial y position")
1565
+ y = self.config.title_top_padding
1566
+ self.logger.info(f"Initial y position with padding: {y}")
1567
+ else:
1568
+ self.logger.info("no title top padding configured; starting with y = 0")
1569
+ self.logger.info(f"Initial y position without padding: {y}")
1570
+
1571
+ bigfont = ImageFont.truetype(self.config.font, bigfont_size)
1572
+
1573
+ # Draw song title
1574
+ title_start_y = y
1575
+ self.logger.info(f"Starting to draw title at y={y}")
1576
+ for image in render_lines(
1577
+ get_wrapped_text(
1578
+ self.config.title,
1579
+ font=bigfont,
1580
+ width=text_image.width,
1581
+ ).split("\n"),
1582
+ font=bigfont,
1583
+ ):
1584
+ text_image.paste(
1585
+ # Use index 2 for title color
1586
+ image.point(lambda v: v and 2, "P"),
1587
+ ((text_image.width - image.width) // 2, y),
1588
+ mask=image.point(lambda v: v and 255, "1"),
1589
+ )
1590
+ y += int(bigfont.size)
1591
+ title_end_y = y
1592
+ self.logger.info(f"Finished drawing title at y={y}, title height={title_end_y - title_start_y}")
1593
+
1594
+ # Add vertical gap between title and artist using configured value
1595
+ y += self.config.title_artist_gap
1596
+ self.logger.info(f"After adding title_artist_gap of {self.config.title_artist_gap}, y is now {y}")
1597
+
1598
+ # Draw song artist
1599
+ artist_start_y = y
1600
+ self.logger.info(f"Starting to draw artist at y={y}")
1601
+ for image in render_lines(
1602
+ get_wrapped_text(
1603
+ self.config.artist,
1604
+ font=smallfont,
1605
+ width=text_image.width,
1606
+ ).split("\n"),
1607
+ font=smallfont,
1608
+ ):
1609
+ text_image.paste(
1610
+ # Use index 3 for artist color
1611
+ image.point(lambda v: v and 3, "P"),
1612
+ ((text_image.width - image.width) // 2, y),
1613
+ mask=image.point(lambda v: v and 255, "1"),
1614
+ )
1615
+ y += int(smallfont.size)
1616
+ artist_end_y = y
1617
+ self.logger.info(f"Finished drawing artist at y={y}, artist height={artist_end_y - artist_start_y}")
1618
+ self.logger.info(f"Total content height before cropping: {artist_end_y - title_start_y}")
1619
+
1620
+ # Break out of loop only if text box ends up small enough
1621
+ bbox = text_image.getbbox()
1622
+ self.logger.info(f"Original bounding box from getbbox(): {bbox}")
1623
+ if bbox is None:
1624
+ # If there's no content, still create a minimal bbox
1625
+ bbox = (0, 0, text_image.width, 1)
1626
+ self.logger.info("No content found, created minimal bbox")
1627
+
1628
+ # We'll crop to just the content area, without padding
1629
+ original_height = text_image.height
1630
+ text_image = text_image.crop(bbox)
1631
+ self.logger.info(f"After cropping: text_image dimensions={text_image.width}x{text_image.height}, height difference={original_height - text_image.height}")
1632
+
1633
+ if text_image.height <= MAX_HEIGHT:
1634
+ self.logger.debug("height just right")
1635
+ break
1636
+ # If text box is not small enough, reduce font size of title
1637
+ self.logger.debug("height too big; reducing font size")
1638
+ bigfont_size -= 2
1639
+
1640
+ # Calculate position - center horizontally, but add padding to vertical position
1641
+ center_x = (CDG_SCREEN_WIDTH - text_image.width) // 2
1642
+
1643
+ # Standard centered position
1644
+ standard_center_y = (CDG_SCREEN_HEIGHT - text_image.height) // 2
1645
+
1646
+ # Add the title_top_padding to shift the entire content downward
1647
+ padding_offset = self.config.title_top_padding if self.config.title_top_padding else 0
1648
+ final_y = standard_center_y + padding_offset
1649
+
1650
+ self.logger.info(f"Pasting text image ({text_image.width}x{text_image.height}) onto background")
1651
+ self.logger.info(f"Standard centered position would be y={standard_center_y}")
1652
+ self.logger.info(f"With padding offset of {padding_offset}, final position is y={final_y}")
1653
+
1654
+ background_image.paste(
1655
+ text_image,
1656
+ (
1657
+ center_x,
1658
+ final_y,
1659
+ ),
1660
+ mask=text_image.point(lambda v: v and 255, "1"),
1661
+ )
1662
+
1663
+ # Queue palette packets
1664
+ palette = list(batched(background_image.getpalette(), 3))
1665
+ if len(palette) < 8:
1666
+ color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1667
+ self.logger.debug(f"loaded color table in compose_intro: {color_table}")
1668
+ self.writer.queue_packet(
1669
+ load_color_table_lo(
1670
+ color_table,
1671
+ )
1672
+ )
1673
+ else:
1674
+ color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1675
+ self.logger.debug(f"loaded color table in compose_intro: {color_table}")
1676
+ self.writer.queue_packets(
1677
+ load_color_table(
1678
+ color_table,
1679
+ )
1680
+ )
1681
+
1682
+ # Render background image to packets
1683
+ packets = image_to_packets(background_image, (0, 0))
1684
+ self.logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1685
+
1686
+ # Queue background image packets (and apply transition)
1687
+ transition = Image.open(package_dir / "transitions" / f"{self.config.title_screen_transition}.png")
1688
+ for coord in self._gradient_to_tile_positions(transition):
1689
+ self.writer.queue_packets(packets.get(coord, []))
1690
+
1691
+ # Replace hardcoded values with configured ones
1692
+ INTRO_DURATION = int(self.config.intro_duration_seconds * CDG_FPS)
1693
+ FIRST_SYLLABLE_BUFFER = int(self.config.first_syllable_buffer_seconds * CDG_FPS)
1694
+
1695
+ # Queue the intro screen for 5 seconds
1696
+ end_time = INTRO_DURATION
1697
+ self.writer.queue_packets([no_instruction()] * (end_time - self.writer.packets_queued))
1698
+
1699
+ first_syllable_start_offset = min(
1700
+ syllable.start_offset for lyric in self.lyrics for line in lyric.lines for syllable in line.syllables
1701
+ )
1702
+ self.logger.debug(f"first syllable starts at {first_syllable_start_offset}")
1703
+
1704
+ MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE = INTRO_DURATION + FIRST_SYLLABLE_BUFFER
1705
+ # If the first syllable is within buffer+intro time, add silence
1706
+ # Otherwise, don't add any silence
1707
+ if first_syllable_start_offset < MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE:
1708
+ self.intro_delay = MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE
1709
+ self.logger.info(
1710
+ f"First syllable within {self.config.intro_duration_seconds + self.config.first_syllable_buffer_seconds} seconds. Adding {self.intro_delay} frames of silence."
1711
+ )
1712
+ else:
1713
+ self.intro_delay = 0
1714
+ self.logger.info("First syllable after buffer period. No additional silence needed.")
1715
+
1716
+ def _compose_outro(self, end: int):
1717
+ # TODO Make it so the outro screen is not hardcoded
1718
+ self.logger.debug("composing outro")
1719
+ self.writer.queue_packets(
1720
+ [
1721
+ *memory_preset_repeat(0),
1722
+ ]
1723
+ )
1724
+
1725
+ self.logger.debug("loading outro background image")
1726
+ # Load background image
1727
+ background_image = self._load_image(
1728
+ self.config.outro_background,
1729
+ [
1730
+ self.config.background, # background
1731
+ self.config.border, # border
1732
+ self.config.outro_line1_color,
1733
+ self.config.outro_line2_color,
1734
+ ],
1735
+ )
1736
+
1737
+ smallfont = ImageFont.truetype(self.config.font, 25)
1738
+ MAX_HEIGHT = 200
1739
+
1740
+ # Render text to an image
1741
+ self.logger.debug(f"rendering outro text")
1742
+ text_image = Image.new("P", (CDG_VISIBLE_WIDTH, MAX_HEIGHT * 2), 0)
1743
+ y = 0
1744
+
1745
+ # Render first line of outro text
1746
+ outro_text_line1 = self.config.outro_text_line1.replace("$artist", self.config.artist).replace("$title", self.config.title)
1747
+
1748
+ for image in render_lines(
1749
+ get_wrapped_text(
1750
+ outro_text_line1,
1751
+ font=smallfont,
1752
+ width=text_image.width,
1753
+ ).split("\n"),
1754
+ font=smallfont,
1755
+ ):
1756
+ text_image.paste(
1757
+ # Use index 2 for line 1 color
1758
+ image.point(lambda v: v and 2, "P"),
1759
+ ((text_image.width - image.width) // 2, y),
1760
+ mask=image.point(lambda v: v and 255, "1"),
1761
+ )
1762
+ y += int(smallfont.size)
1763
+
1764
+ # Add vertical gap between title and artist using configured value
1765
+ y += self.config.outro_line1_line2_gap
1766
+
1767
+ # Render second line of outro text
1768
+ outro_text_line2 = self.config.outro_text_line2.replace("$artist", self.config.artist).replace("$title", self.config.title)
1769
+
1770
+ for image in render_lines(
1771
+ get_wrapped_text(
1772
+ outro_text_line2,
1773
+ font=smallfont,
1774
+ width=text_image.width,
1775
+ ).split("\n"),
1776
+ font=smallfont,
1777
+ ):
1778
+ text_image.paste(
1779
+ # Use index 3 for line 2 color
1780
+ image.point(lambda v: v and 3, "P"),
1781
+ ((text_image.width - image.width) // 2, y),
1782
+ mask=image.point(lambda v: v and 255, "1"),
1783
+ )
1784
+ y += int(smallfont.size)
1785
+
1786
+ # Break out of loop only if text box ends up small enough
1787
+ text_image = text_image.crop(text_image.getbbox())
1788
+ assert text_image.height <= MAX_HEIGHT
1789
+
1790
+ # Draw text onto image
1791
+ background_image.paste(
1792
+ text_image,
1793
+ (
1794
+ (CDG_SCREEN_WIDTH - text_image.width) // 2,
1795
+ (CDG_SCREEN_HEIGHT - text_image.height) // 2,
1796
+ ),
1797
+ mask=text_image.point(lambda v: v and 255, "1"),
1798
+ )
1799
+
1800
+ # Queue palette packets
1801
+ palette = list(batched(background_image.getpalette(), 3))
1802
+ if len(palette) < 8:
1803
+ self.writer.queue_packet(load_color_table_lo(list(pad(palette, 8, padvalue=self.UNUSED_COLOR))))
1804
+ else:
1805
+ self.writer.queue_packets(load_color_table(list(pad(palette, 16, padvalue=self.UNUSED_COLOR))))
1806
+
1807
+ # Render background image to packets
1808
+ packets = image_to_packets(background_image, (0, 0))
1809
+ self.logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1810
+
1811
+ # Queue background image packets (and apply transition)
1812
+ transition = Image.open(package_dir / "transitions" / f"{self.config.outro_transition}.png")
1813
+ for coord in self._gradient_to_tile_positions(transition):
1814
+ self.writer.queue_packets(packets.get(coord, []))
1815
+
1816
+ self.writer.queue_packets([no_instruction()] * (end - self.writer.packets_queued))
1817
+
1818
+ def _load_image(
1819
+ self,
1820
+ image_path: "StrOrBytesPath | Path",
1821
+ partial_palette: list[RGBColor] | None = None,
1822
+ ):
1823
+ if partial_palette is None:
1824
+ partial_palette = []
1825
+
1826
+ self.logger.debug("loading image")
1827
+ image_rgba = Image.open(file_relative_to(image_path, self.relative_dir)).convert("RGBA")
1828
+ image = image_rgba.convert("RGB")
1829
+
1830
+ # REVIEW How many colors should I allow? Should I make this
1831
+ # configurable?
1832
+ COLORS = 16 - len(partial_palette)
1833
+ self.logger.debug(f"quantizing to {COLORS} color(s)")
1834
+ # Reduce colors with quantization and dithering
1835
+ image = image.quantize(
1836
+ colors=COLORS,
1837
+ palette=image.quantize(
1838
+ colors=COLORS,
1839
+ method=Image.Quantize.MAXCOVERAGE,
1840
+ ),
1841
+ dither=Image.Dither.FLOYDSTEINBERG,
1842
+ )
1843
+ # Further reduce colors to conform to 12-bit RGB palette
1844
+ image.putpalette(
1845
+ [
1846
+ # HACK The RGB values of the colors that show up in CDG
1847
+ # players are repdigits in hexadecimal - 0x00, 0x11, 0x22,
1848
+ # 0x33, etc. This means that we can simply round each value
1849
+ # to the nearest multiple of 0x11 (17 in decimal).
1850
+ 0x11 * round(v / 0x11)
1851
+ for v in image.getpalette()
1852
+ ]
1853
+ )
1854
+ image = image.quantize()
1855
+ self.logger.debug(f"image uses {max(image.getdata()) + 1} color(s)")
1856
+
1857
+ if partial_palette:
1858
+ self.logger.debug(f"prepending {len(partial_palette)} color(s) to palette")
1859
+ # Add offset to color indices
1860
+ image.putdata(image.getdata(), offset=len(partial_palette))
1861
+ # Place other colors in palette
1862
+ image.putpalette(list(it.chain(*partial_palette)) + image.getpalette())
1863
+
1864
+ self.logger.debug(f"palette: {list(batched(image.getpalette(), 3))!r}")
1865
+
1866
+ self.logger.debug("masking out non-transparent parts of image")
1867
+ # Create mask for non-transparent parts of image
1868
+ # NOTE We allow alpha values from 128 to 255 (half-transparent
1869
+ # to opaque).
1870
+ mask = Image.new("1", image_rgba.size, 0)
1871
+ mask.putdata([0 if pixel >= 128 else 255 for pixel in image_rgba.getdata(band=3)])
1872
+ # Set transparent parts of background to 0
1873
+ image.paste(Image.new("P", image.size, 0), mask=mask)
1874
+
1875
+ return image
1876
+
1877
+ def _gradient_to_tile_positions(
1878
+ self,
1879
+ image: Image.Image,
1880
+ ) -> list[tuple[int, int]]:
1881
+ """
1882
+ Convert an image of a gradient to an ordering of tile positions.
1883
+
1884
+ The closer a section of the image is to white, the earlier it
1885
+ will appear. The closer a section of the image is to black, the
1886
+ later it will appear. The image is converted to `L` mode before
1887
+ processing.
1888
+
1889
+ Parameters
1890
+ ----------
1891
+ image : `PIL.Image.Image`
1892
+ Image to convert.
1893
+
1894
+ Returns
1895
+ -------
1896
+ list of tuple of (int, int)
1897
+ Tile positions in order.
1898
+ """
1899
+ image = image.convert("L")
1900
+ intensities: dict[tuple[int, int], int] = {}
1901
+ for tile_y, tile_x in it.product(
1902
+ range(CDG_SCREEN_HEIGHT // CDG_TILE_HEIGHT),
1903
+ range(CDG_SCREEN_WIDTH // CDG_TILE_WIDTH),
1904
+ ):
1905
+ # NOTE The intensity is negative so that, when it's sorted,
1906
+ # it will be sorted from highest intensity to lowest. This
1907
+ # is not done with reverse=True to preserve the sort's
1908
+ # stability.
1909
+ intensities[(tile_y, tile_x)] = -sum(
1910
+ image.getpixel(
1911
+ (
1912
+ tile_x * CDG_TILE_WIDTH + x,
1913
+ tile_y * CDG_TILE_HEIGHT + y,
1914
+ )
1915
+ )
1916
+ for x in range(CDG_TILE_WIDTH)
1917
+ for y in range(CDG_TILE_HEIGHT)
1918
+ )
1919
+ return sorted(intensities, key=intensities.get)
1920
+
1921
+ # !SECTION
1922
+ # endregion
1923
+
1924
+ # region Create MP4
1925
+ # SECTION Create MP4
1926
+ def create_ass(self):
1927
+ if not ASS_REQUIREMENTS:
1928
+ raise RuntimeError("could not import requirements for creating ASS")
1929
+
1930
+ # Create ASS subtitle object
1931
+ # (ASS = Advanced Sub Station. Get your mind out of the gutter.)
1932
+ self.logger.debug("creating ASS subtitle object")
1933
+ assdoc = ass.Document()
1934
+ assdoc.fields.update(
1935
+ Title="",
1936
+ WrapStyle=2,
1937
+ ScaledBorderAndShadow="yes",
1938
+ Collisions="normal",
1939
+ PlayResX=CDG_SCREEN_WIDTH,
1940
+ PlayResY=CDG_SCREEN_HEIGHT,
1941
+ )
1942
+
1943
+ # Load lyric font using fontTools
1944
+ # NOTE We do this because we need some of the font's metadata.
1945
+ self.logger.debug("loading metadata from font")
1946
+ font = ttLib.TTFont(self.font.path)
1947
+
1948
+ # NOTE The ASS Style lines need the "fontname as used by
1949
+ # Windows". The best name for this purpose is name 4, which
1950
+ # Apple calls the "full name of the font". (Oh yeah, and Apple
1951
+ # developed TrueType, the font format used here. Who knew?)
1952
+ fontname = font["name"].getDebugName(4)
1953
+
1954
+ # NOTE PIL interprets a font's size as its "nominal size", or
1955
+ # "em height". The ASS format interprets a font's size as its
1956
+ # "actual size" - the area enclosing its highest and lowest
1957
+ # points.
1958
+ # Relative values for these sizes can be found/calculated from
1959
+ # the font's headers, and the ratio between them is used to
1960
+ # scale the lyric font size from nominal to actual.
1961
+ nominal_size = cast(int, font["head"].unitsPerEm)
1962
+ ascent = cast(int, font["hhea"].ascent)
1963
+ descent = cast(int, font["hhea"].descent)
1964
+ actual_size = ascent - descent
1965
+ fontsize = self.config.font_size * actual_size / nominal_size
1966
+ # HACK If I position each line at its proper Y position, it
1967
+ # looks shifted down slightly. This should correct it, I think.
1968
+ y_offset = self.config.font_size * (descent / 2) / nominal_size
1969
+
1970
+ # Create a style for each singer
1971
+ for i, singer in enumerate(self.config.singers, 1):
1972
+ self.logger.debug(f"creating ASS style for singer {i}")
1973
+ assdoc.styles.append(
1974
+ ass.Style(
1975
+ name=f"Singer{i}",
1976
+ fontname=fontname,
1977
+ fontsize=fontsize,
1978
+ primary_color=ass.line.Color(*singer.active_fill),
1979
+ secondary_color=ass.line.Color(*singer.inactive_fill),
1980
+ outline_color=ass.line.Color(*singer.inactive_stroke),
1981
+ back_color=ass.line.Color(*singer.active_stroke),
1982
+ border_style=1, # outline + drop shadow
1983
+ outline=self.config.stroke_width,
1984
+ shadow=0,
1985
+ alignment=8, # alignment point is at top middle
1986
+ margin_l=0,
1987
+ margin_r=0,
1988
+ margin_v=0,
1989
+ )
1990
+ )
1991
+
1992
+ offset = cdg_to_sync(self.intro_delay + self.sync_offset)
1993
+ instrumental = 0
1994
+ # Create events for each line sung in each lyric set
1995
+ for ci, (lyric, times) in enumerate(
1996
+ zip(
1997
+ self.lyrics,
1998
+ self.lyric_times,
1999
+ )
2000
+ ):
2001
+ for li, line in enumerate(lyric.lines):
2002
+ # Skip line if it has no syllables
2003
+ if not line.syllables:
2004
+ continue
2005
+ self.logger.debug(f"creating event for lyric {ci} line {li}")
2006
+
2007
+ # Get intended draw time of line
2008
+ line_draw_time = cdg_to_sync(times.line_draw[li]) + offset
2009
+ # XXX This is hardcoded, so as to not have the line's
2010
+ # appearance clash with the intro.
2011
+ line_draw_time = max(line_draw_time, 800)
2012
+
2013
+ # The upcoming instrumental section should be the first
2014
+ # one after this line is drawn
2015
+ while instrumental < len(self.instrumental_times) and (
2016
+ cdg_to_sync(self.instrumental_times[instrumental]) <= line_draw_time
2017
+ ):
2018
+ instrumental += 1
2019
+
2020
+ # Get intended erase time of line, if possible
2021
+ if times.line_erase:
2022
+ line_erase_time = cdg_to_sync(times.line_erase[li]) + offset
2023
+ # If there are no erase times saved, then lyrics are
2024
+ # being cleared by page instead of being erased
2025
+ else:
2026
+ # Get first non-empty line of next page
2027
+ next_page_li = (li // lyric.lines_per_page + 1) * lyric.lines_per_page
2028
+ while next_page_li < len(lyric.lines):
2029
+ if lyric.lines[next_page_li].syllables:
2030
+ break
2031
+ next_page_li += 1
2032
+
2033
+ # If there is a next page
2034
+ if next_page_li < len(lyric.lines):
2035
+ # Erase the current line when the next page is
2036
+ # drawn
2037
+ line_erase_time = cdg_to_sync(times.line_draw[next_page_li]) + offset
2038
+ # If there is no next page
2039
+ else:
2040
+ # Erase the current line after the last syllable
2041
+ # of this line is highlighted
2042
+ # XXX This is hardcoded.
2043
+ line_erase_time = cdg_to_sync(line.syllables[-1].end_offset) + offset + 200
2044
+
2045
+ if instrumental < len(self.instrumental_times):
2046
+ # The current line should be erased before the
2047
+ # upcoming instrumental section
2048
+ line_erase_time = min(
2049
+ line_erase_time,
2050
+ cdg_to_sync(self.instrumental_times[instrumental]),
2051
+ )
2052
+
2053
+ text = ""
2054
+ # Text is horizontally centered, and at the line's Y
2055
+ x = CDG_SCREEN_WIDTH // 2
2056
+ y = line.y + y_offset
2057
+ text += f"{{\\pos({x},{y})}}"
2058
+ # Text should fade in and out with the intended
2059
+ # draw/erase timing
2060
+ # NOTE This is in milliseconds for some reason, whereas
2061
+ # every other timing value is in centiseconds.
2062
+ fade = cdg_to_sync(self.LINE_DRAW_ERASE_GAP) * 10
2063
+ text += f"{{\\fad({fade},{fade})}}"
2064
+ # There should be a pause before the text is highlighted
2065
+ line_start_offset = cdg_to_sync(line.syllables[0].start_offset) + offset
2066
+ text += f"{{\\k{line_start_offset - line_draw_time}}}"
2067
+ # Each syllable should be filled in for the specified
2068
+ # duration
2069
+ for syll in line.syllables:
2070
+ length = cdg_to_sync(syll.end_offset - syll.start_offset)
2071
+ text += f"{{\\kf{length}}}{syll.text}"
2072
+
2073
+ # Create a dialogue event for this line
2074
+ assdoc.events.append(
2075
+ ass.Dialogue(
2076
+ layer=ci,
2077
+ # NOTE The line draw and erase times are in
2078
+ # centiseconds, so we need to multiply by 10 for
2079
+ # milliseconds.
2080
+ start=timedelta(milliseconds=line_draw_time * 10),
2081
+ end=timedelta(milliseconds=line_erase_time * 10),
2082
+ style=f"Singer{line.singer}",
2083
+ effect="karaoke",
2084
+ text=text,
2085
+ )
2086
+ )
2087
+
2088
+ outname = self.config.outname
2089
+ assfile_name = self.relative_dir / Path(f"{outname}.ass")
2090
+ self.logger.debug(f"dumping ASS object to {assfile_name}")
2091
+ # HACK If I don't specify "utf-8-sig" as the encoding, the
2092
+ # python-ass module gives me a warning telling me to. This adds
2093
+ # a "byte order mark" to the ASS file (seemingly unnecessarily).
2094
+ with open(assfile_name, "w", encoding="utf-8-sig") as assfile:
2095
+ assdoc.dump_file(assfile)
2096
+ self.logger.info(f"ASS object dumped to {assfile_name}")
2097
+
2098
+ def create_mp4(self, height: int = 720, fps: int = 30):
2099
+ if not MP4_REQUIREMENTS:
2100
+ raise RuntimeError("could not import requirements for creating MP4")
2101
+
2102
+ outname = self.config.outname
2103
+
2104
+ # Create a "background plate" for the video
2105
+ # NOTE The "background plate" will simply be the CDG file we've
2106
+ # composed, but without the lyrics. We create this by replacing
2107
+ # all lyric-drawing packets with no-instruction packets.
2108
+ platecdg_name = self.relative_dir / Path(f"{outname}.plate.cdg")
2109
+ self.logger.debug(f"writing plate CDG to {platecdg_name}")
2110
+ with open(platecdg_name, "wb") as platecdg:
2111
+ self.logger.debug("writing plate")
2112
+ for i, packet in enumerate(self.writer.packets):
2113
+ packet_to_write = packet
2114
+ if i in self.lyric_packet_indices:
2115
+ packet_to_write = no_instruction()
2116
+ self.writer.write_packet(platecdg, packet_to_write)
2117
+ self.logger.info(f"plate CDG written to {platecdg_name}")
2118
+
2119
+ # Create an MP3 file for the audio
2120
+ platemp3_name = self.relative_dir / Path(f"{outname}.plate.mp3")
2121
+ self.logger.debug(f"writing plate MP3 to {platemp3_name}")
2122
+ self.audio.export(platemp3_name, format="mp3")
2123
+ self.logger.info(f"plate MP3 written to {platemp3_name}")
2124
+
2125
+ # Create a subtitle file for the HQ lyrics
2126
+ self.create_ass()
2127
+ assfile_name = self.relative_dir / Path(f"{outname}.ass")
2128
+
2129
+ self.logger.debug("building ffmpeg command for encoding MP4")
2130
+ video = (
2131
+ ffmpeg.input(platecdg_name).video
2132
+ # Pad the end of the video by a few seconds
2133
+ # HACK This ensures the last video frame isn't some CDG
2134
+ # frame before the last one. This padding will also be cut
2135
+ # later.
2136
+ .filter_("tpad", stop_mode="clone", stop_duration=5)
2137
+ # Set framerate
2138
+ .filter_("fps", fps=fps)
2139
+ # Scale video to resolution
2140
+ .filter_(
2141
+ "scale",
2142
+ # HACK The libx264 codec requires the video dimensions
2143
+ # to be divisible by 2. Here, the width is not only
2144
+ # automatically calculated from the plate's aspect
2145
+ # ratio, but truncated down to a multiple of 2.
2146
+ w="trunc(oh*a/2)*2",
2147
+ h=height // 2 * 2,
2148
+ flags="neighbor",
2149
+ )
2150
+ # Burn in subtitles
2151
+ .filter_("ass", filename=assfile_name)
2152
+ )
2153
+ audio = ffmpeg.input(platemp3_name)
2154
+
2155
+ mp4_name = self.relative_dir / Path(f"{outname}.mp4")
2156
+ mp4 = ffmpeg.output(
2157
+ video,
2158
+ audio,
2159
+ filename=mp4_name,
2160
+ hide_banner=None,
2161
+ loglevel="error",
2162
+ stats=None,
2163
+ # Video should use the H.264 codec, at a decent quality
2164
+ vcodec="libx264",
2165
+ pix_fmt="yuv420p",
2166
+ crf=22,
2167
+ preset="veryfast",
2168
+ # Truncate to the length of the shortest input
2169
+ # HACK This effectively removes the video padding that was
2170
+ # added earlier, because the audio is shorter than the
2171
+ # padded video.
2172
+ shortest=None,
2173
+ ).overwrite_output()
2174
+ self.logger.debug(f"ffmpeg command: {mp4.compile()}")
2175
+ mp4.run()
2176
+
2177
+ self.logger.debug("deleting plate CDG")
2178
+ platecdg_name.unlink()
2179
+ self.logger.info("plate CDG deleted")
2180
+
2181
+ self.logger.debug("deleting plate MP3")
2182
+ platemp3_name.unlink()
2183
+ self.logger.info("plate MP3 deleted")
2184
+
2185
+ # !SECTION
2186
+ # endregion
2187
+
2188
+
2189
+ def main():
2190
+ from argparse import ArgumentParser, RawDescriptionHelpFormatter
2191
+ import sys
2192
+
2193
+ parser = ArgumentParser(
2194
+ prog="py -m cdgmaker",
2195
+ description="Create custom CDG files for karaoke.",
2196
+ epilog=("For a description of the config format, visit " "https://github.com/WinslowJosiah/cdgmaker"),
2197
+ formatter_class=RawDescriptionHelpFormatter,
2198
+ )
2199
+ parser.add_argument(
2200
+ "config",
2201
+ help=".toml config file to create CDG files with",
2202
+ metavar="FILE",
2203
+ type=str,
2204
+ )
2205
+ parser.add_argument(
2206
+ "-v",
2207
+ "--verbose",
2208
+ help="make logs more verbose (-v, -vv, etc.)",
2209
+ action="count",
2210
+ default=0,
2211
+ )
2212
+ parser.add_argument(
2213
+ "-r",
2214
+ "--render",
2215
+ help="render MP4 video of created CDG file",
2216
+ action="store_true",
2217
+ )
2218
+
2219
+ # If there aren't any arguments to parse
2220
+ if len(sys.argv) < 2:
2221
+ # Print help message and exit with error
2222
+ parser.print_help()
2223
+ sys.exit(1)
2224
+
2225
+ # Overwrite the error handler to also print a help message
2226
+ # HACK: This is what's known in the biz as a "monkey-patch". Don't
2227
+ # worry if it doesn't make sense to you; it makes sense to argparse,
2228
+ # and that's all that matters.
2229
+ def custom_error_handler(_self: ArgumentParser):
2230
+
2231
+ def wrapper(message: str):
2232
+ sys.stderr.write(f"{_self.prog}: error: {message}\n")
2233
+ _self.print_help()
2234
+ sys.exit(2)
2235
+
2236
+ return wrapper
2237
+
2238
+ parser.error = custom_error_handler(parser)
2239
+
2240
+ # Parse command line arguments
2241
+ args = parser.parse_args()
2242
+
2243
+ # Set logging level based on verbosity
2244
+ log_level = logging.ERROR
2245
+ if not args.verbose:
2246
+ log_level = logging.WARNING
2247
+ elif args.verbose == 1:
2248
+ log_level = logging.INFO
2249
+ elif args.verbose >= 2:
2250
+ log_level = logging.DEBUG
2251
+ logging.basicConfig(level=log_level)
2252
+
2253
+ kc = KaraokeComposer.from_file(args.config)
2254
+ kc.compose()
2255
+ if args.render:
2256
+ kc.create_mp4(height=1080, fps=60)
2257
+
2258
+
2259
+ if __name__ == "__main__":
2260
+ main()