lyrics-transcriber 0.30.1__py3-none-any.whl → 0.32.2__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 (84) hide show
  1. lyrics_transcriber/__init__.py +2 -1
  2. lyrics_transcriber/cli/cli_main.py +33 -12
  3. lyrics_transcriber/core/config.py +35 -0
  4. lyrics_transcriber/core/controller.py +85 -121
  5. lyrics_transcriber/correction/anchor_sequence.py +471 -0
  6. lyrics_transcriber/correction/corrector.py +237 -33
  7. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  8. lyrics_transcriber/correction/handlers/base.py +30 -0
  9. lyrics_transcriber/correction/handlers/extend_anchor.py +91 -0
  10. lyrics_transcriber/correction/handlers/levenshtein.py +147 -0
  11. lyrics_transcriber/correction/handlers/no_space_punct_match.py +98 -0
  12. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +55 -0
  13. lyrics_transcriber/correction/handlers/repeat.py +71 -0
  14. lyrics_transcriber/correction/handlers/sound_alike.py +223 -0
  15. lyrics_transcriber/correction/handlers/syllables_match.py +182 -0
  16. lyrics_transcriber/correction/handlers/word_count_match.py +54 -0
  17. lyrics_transcriber/correction/handlers/word_operations.py +135 -0
  18. lyrics_transcriber/correction/phrase_analyzer.py +426 -0
  19. lyrics_transcriber/correction/text_utils.py +30 -0
  20. lyrics_transcriber/lyrics/base_lyrics_provider.py +5 -81
  21. lyrics_transcriber/lyrics/genius.py +5 -2
  22. lyrics_transcriber/lyrics/spotify.py +3 -3
  23. lyrics_transcriber/output/ass/__init__.py +21 -0
  24. lyrics_transcriber/output/{ass.py → ass/ass.py} +150 -690
  25. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  26. lyrics_transcriber/output/ass/config.py +37 -0
  27. lyrics_transcriber/output/ass/constants.py +23 -0
  28. lyrics_transcriber/output/ass/event.py +94 -0
  29. lyrics_transcriber/output/ass/formatters.py +132 -0
  30. lyrics_transcriber/output/ass/lyrics_line.py +219 -0
  31. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  32. lyrics_transcriber/output/ass/section_detector.py +89 -0
  33. lyrics_transcriber/output/ass/section_screen.py +106 -0
  34. lyrics_transcriber/output/ass/style.py +187 -0
  35. lyrics_transcriber/output/cdg.py +503 -0
  36. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  37. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  38. lyrics_transcriber/output/cdgmaker/composer.py +1919 -0
  39. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  40. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  41. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  42. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  43. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  44. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  45. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  46. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  47. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  48. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  49. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  50. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  51. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  52. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  53. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  54. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  55. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  56. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  57. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  58. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  59. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  60. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  61. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  62. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  63. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  64. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  65. lyrics_transcriber/output/generator.py +101 -193
  66. lyrics_transcriber/output/lyrics_file.py +102 -0
  67. lyrics_transcriber/output/plain_text.py +91 -0
  68. lyrics_transcriber/output/segment_resizer.py +416 -0
  69. lyrics_transcriber/output/subtitles.py +328 -302
  70. lyrics_transcriber/output/video.py +219 -0
  71. lyrics_transcriber/review/__init__.py +1 -0
  72. lyrics_transcriber/review/server.py +138 -0
  73. lyrics_transcriber/transcribers/audioshake.py +3 -2
  74. lyrics_transcriber/transcribers/base_transcriber.py +5 -42
  75. lyrics_transcriber/transcribers/whisper.py +3 -4
  76. lyrics_transcriber/types.py +454 -0
  77. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/METADATA +14 -3
  78. lyrics_transcriber-0.32.2.dist-info/RECORD +86 -0
  79. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/WHEEL +1 -1
  80. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/entry_points.txt +1 -0
  81. lyrics_transcriber/correction/base_strategy.py +0 -29
  82. lyrics_transcriber/correction/strategy_diff.py +0 -263
  83. lyrics_transcriber-0.30.1.dist-info/RECORD +0 -25
  84. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/LICENSE +0 -0
@@ -0,0 +1,1919 @@
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
+ if TYPE_CHECKING:
12
+ from _typeshed import FileDescriptorOrPath, StrOrBytesPath
13
+
14
+ from attrs import define
15
+ from cattrs import Converter
16
+ from PIL import Image, ImageFont
17
+ from pydub import AudioSegment
18
+
19
+ from .cdg import *
20
+ from .config import *
21
+ from .pack import *
22
+ from .render import *
23
+ from .utils import *
24
+
25
+
26
+ import logging
27
+ logger = logging.getLogger(__name__)
28
+
29
+ package_dir = Path(__file__).parent
30
+
31
+ T = TypeVar('T')
32
+
33
+ def batched(iterable: Iterable[T], n: int) -> Iterable[tuple[T, ...]]:
34
+ "Batch data into tuples of length n. The last batch may be shorter."
35
+ # batched('ABCDEFG', 3) --> ABC DEF G
36
+ itobj = iter(iterable)
37
+ while True:
38
+ batch = tuple(it.islice(itobj, n))
39
+ if not batch:
40
+ return
41
+ yield batch
42
+
43
+ def file_relative_to(
44
+ filepath: "StrOrBytesPath | Path",
45
+ *relative_to: "StrOrBytesPath | Path",
46
+ ) -> Path:
47
+ """
48
+ Convert possibly relative filepath to absolute path, relative to any
49
+ of the paths in `relative_to`, or to the parent directory of this
50
+ very Python file itself.
51
+
52
+ If the filepath is already absolute, it is returned unchanged.
53
+ Otherwise, the first absolute filepath found to exist as a file will
54
+ be returned.
55
+
56
+ Parameters
57
+ ----------
58
+ filepath : path-like
59
+ Filepath.
60
+ *relative_to
61
+ The filepath will be given as relative to these paths.
62
+
63
+ Returns
64
+ -------
65
+ `pathlib.Path`
66
+ Absolute path relative to given directories, if any exist as
67
+ files.
68
+ """
69
+ filepath = Path(filepath)
70
+ if filepath.is_absolute():
71
+ return filepath
72
+
73
+ # If all else fails, check filepath relative to this file
74
+ relative_to += (Path(__file__).parent,)
75
+ for rel in relative_to:
76
+ outpath = Path(rel) / filepath
77
+ if outpath.is_file():
78
+ return outpath
79
+
80
+ # Add more detailed error information
81
+ searched_paths = [str(Path(rel) / filepath) for rel in relative_to]
82
+ raise FileNotFoundError(f"File not found: {filepath}. Searched in: {', '.join(searched_paths)}")
83
+
84
+
85
+ def sync_to_cdg(cs: int) -> int:
86
+ """
87
+ Convert sync time to CDG frame time to the nearest frame.
88
+
89
+ Parameters
90
+ ----------
91
+ cs : int
92
+ Time in centiseconds (100ths of a second).
93
+
94
+ Returns
95
+ -------
96
+ int
97
+ Equivalent time in CDG frames.
98
+ """
99
+ return cs * CDG_FPS // 100
100
+
101
+
102
+ @define
103
+ class SyllableInfo:
104
+ mask: Image.Image
105
+ text: str
106
+ start_offset: int
107
+ end_offset: int
108
+ left_edge: int
109
+ right_edge: int
110
+ lyric_index: int
111
+ line_index: int
112
+ syllable_index: int
113
+
114
+
115
+ @define
116
+ class LineInfo:
117
+ image: Image.Image
118
+ text: str
119
+ syllables: list[SyllableInfo]
120
+ x: int
121
+ y: int
122
+ singer: int
123
+ lyric_index: int
124
+ line_index: int
125
+
126
+
127
+ class LyricInfo(NamedTuple):
128
+ lines: list[LineInfo]
129
+ line_tile_height: int
130
+ lines_per_page: int
131
+ lyric_index: int
132
+
133
+
134
+ @define
135
+ class LyricTimes:
136
+ line_draw: list[int]
137
+ line_erase: list[int]
138
+
139
+
140
+ @define
141
+ class LyricState:
142
+ line_draw: int
143
+ line_erase: int
144
+ syllable_line: int
145
+ syllable_index: int
146
+ draw_queue: deque[CDGPacket]
147
+ highlight_queue: deque[list[CDGPacket]]
148
+
149
+
150
+ @define
151
+ class ComposerState:
152
+ instrumental: int
153
+ this_page: int
154
+ last_page: int
155
+ just_cleared: bool
156
+
157
+
158
+ class KaraokeComposer:
159
+ BACKGROUND = 0
160
+ BORDER = 1
161
+ UNUSED_COLOR = (0, 0, 0)
162
+
163
+ #region Constructors
164
+ # SECTION Constructors
165
+ def __init__(
166
+ self,
167
+ config: Settings,
168
+ relative_dir: "StrOrBytesPath | Path" = "",
169
+ ):
170
+ self.config = config
171
+ self.relative_dir = Path(relative_dir)
172
+ logger.debug("loading config settings")
173
+
174
+ font_path = self.config.font
175
+ logger.debug(f"font_path: {font_path}")
176
+ try:
177
+ # First, use the font path directly from the config
178
+ if not Path(font_path).is_file():
179
+ # Try to find the font relative to the config file
180
+ font_path = Path(self.relative_dir) / font_path
181
+ if not font_path.is_file():
182
+ # If not found, try to find it in the package fonts directory
183
+ font_path = package_dir / "fonts" / Path(self.config.font).name
184
+ if not font_path.is_file():
185
+ raise FileNotFoundError(f"Font file not found: {self.config.font}")
186
+ self.font = ImageFont.truetype(str(font_path), self.config.font_size)
187
+ except Exception as e:
188
+ logger.error(f"Error loading font: {e}")
189
+ raise
190
+
191
+ # Set color table for lyrics sections
192
+ # NOTE At the moment, this only allows for up to 3 singers, with
193
+ # distinct color indices for active/inactive fills/strokes.
194
+ # REVIEW Could this be smarter? Perhaps if some colors are
195
+ # reused/omitted, color indices could be organized in a
196
+ # different way that allows for more singers at a time.
197
+ self.color_table = [
198
+ self.config.background,
199
+ self.config.border or self.UNUSED_COLOR,
200
+ self.UNUSED_COLOR,
201
+ self.UNUSED_COLOR,
202
+ ]
203
+ for singer in self.config.singers:
204
+ self.color_table.extend([
205
+ singer.inactive_fill,
206
+ singer.inactive_stroke,
207
+ singer.active_fill,
208
+ singer.active_stroke,
209
+ ])
210
+ self.color_table = list(pad(
211
+ self.color_table, 16, padvalue=self.UNUSED_COLOR,
212
+ ))
213
+ logger.debug(f"Color table: {self.color_table}")
214
+
215
+ self.max_tile_height = 0
216
+ self.lyrics: list[LyricInfo] = []
217
+ # Process lyric sets
218
+ for ci, lyric in enumerate(self.config.lyrics):
219
+ logger.debug(f"processing config lyric {ci}")
220
+ lines: list[list[str]] = []
221
+ line_singers: list[int] = []
222
+ for textline in re.split(r"\n+", lyric.text):
223
+ textline: str
224
+
225
+ # Assign singer
226
+ if "|" in textline:
227
+ singer, textline = textline.split("|")
228
+ singer = int(singer)
229
+ else:
230
+ singer = lyric.singer
231
+
232
+ textline = textline.strip()
233
+ # Tildes signify empty lines
234
+ if textline == "~":
235
+ syllables = []
236
+ else:
237
+ syllables = [
238
+ # Replace underscores in syllables with spaces
239
+ syllable.replace("_", " ")
240
+ for syllable in it.chain.from_iterable(
241
+ # Split syllables at slashes
242
+ cast(str, word).split("/")
243
+ # Split words after one space and possibly
244
+ # before other spaces
245
+ for word in re.split(r"(?<= )(?<! ) *", textline)
246
+ )
247
+ ]
248
+
249
+ # logger.debug(f"singer {singer}: {syllables}")
250
+ lines.append(syllables)
251
+ line_singers.append(singer)
252
+
253
+ logger.debug(f"rendering line images and masks for lyric {ci}")
254
+ line_images, line_masks = render_lines_and_masks(
255
+ lines,
256
+ font=self.font,
257
+ stroke_width=self.config.stroke_width,
258
+ stroke_type=self.config.stroke_type,
259
+ )
260
+ max_height = 0
261
+ for li, image in enumerate(line_images):
262
+ if image.width > CDG_VISIBLE_WIDTH:
263
+ logger.warning(
264
+ f"line {li} too wide\n"
265
+ f"max width is {CDG_VISIBLE_WIDTH} pixel(s); "
266
+ f"actual width is {image.width} pixel(s)\n"
267
+ f"\t{''.join(lines[li])}"
268
+ )
269
+ max_height = max(max_height, image.height)
270
+
271
+ tile_height = ceildiv(max_height, CDG_TILE_HEIGHT)
272
+ self.max_tile_height = max(self.max_tile_height, tile_height)
273
+
274
+ lyric_lines: list[LineInfo] = []
275
+ sync_i = 0
276
+ logger.debug(f"setting sync points for lyric {ci}")
277
+ for li, (line, singer, line_image, line_mask) in enumerate(zip(
278
+ lines, line_singers, line_images, line_masks,
279
+ )):
280
+ # Center line horizontally
281
+ x = (CDG_SCREEN_WIDTH - line_image.width) // 2
282
+ # Place line on correct row
283
+ y = lyric.row * CDG_TILE_HEIGHT + (
284
+ (li % lyric.lines_per_page)
285
+ * lyric.line_tile_height * CDG_TILE_HEIGHT
286
+ )
287
+
288
+ # Get enough sync points for this line's syllables
289
+ line_sync = lyric.sync[sync_i:sync_i + len(line)]
290
+ sync_i += len(line)
291
+ if line_sync:
292
+ # The last syllable ends 0.45 seconds after it
293
+ # starts...
294
+ next_sync_point = line_sync[-1] + 45
295
+ if sync_i < len(lyric.sync):
296
+ # ...or when the first syllable of the next line
297
+ # starts, whichever comes first
298
+ next_sync_point = min(
299
+ next_sync_point,
300
+ lyric.sync[sync_i],
301
+ )
302
+ line_sync.append(next_sync_point)
303
+
304
+ # Collect this line's syllables
305
+ syllables: list[SyllableInfo] = []
306
+ for si, (mask, syllable, (start, end)) in enumerate(zip(
307
+ line_mask,
308
+ line,
309
+ it.pairwise(line_sync),
310
+ )):
311
+ # NOTE Left and right edges here are relative to the
312
+ # mask. They will be stored relative to the screen.
313
+ left_edge, right_edge = 0, 0
314
+ bbox = mask.getbbox()
315
+ if bbox is not None:
316
+ left_edge, _, right_edge, _ = bbox
317
+
318
+ syllables.append(SyllableInfo(
319
+ mask=mask,
320
+ text=syllable,
321
+ start_offset=sync_to_cdg(start),
322
+ end_offset=sync_to_cdg(end),
323
+ left_edge=left_edge + x,
324
+ right_edge=right_edge + x,
325
+ lyric_index=ci,
326
+ line_index=li,
327
+ syllable_index=si,
328
+ ))
329
+
330
+ lyric_lines.append(LineInfo(
331
+ image=line_image,
332
+ text="".join(line),
333
+ syllables=syllables,
334
+ x=x,
335
+ y=y,
336
+ singer=singer,
337
+ lyric_index=ci,
338
+ line_index=li,
339
+ ))
340
+
341
+ self.lyrics.append(LyricInfo(
342
+ lines=lyric_lines,
343
+ line_tile_height=tile_height,
344
+ lines_per_page=lyric.lines_per_page,
345
+ lyric_index=ci,
346
+ ))
347
+
348
+ # Add vertical offset to lines to vertically center them
349
+ max_height = max(
350
+ line.image.height
351
+ for lyric in self.lyrics
352
+ for line in lyric.lines
353
+ )
354
+ line_offset = (
355
+ self.max_tile_height * CDG_TILE_HEIGHT - max_height
356
+ ) // 2
357
+ logger.debug(
358
+ f"lines will be vertically offset by {line_offset} pixel(s)"
359
+ )
360
+ if line_offset:
361
+ for lyric in self.lyrics:
362
+ for line in lyric.lines:
363
+ line.y += line_offset
364
+
365
+ self.sync_offset = sync_to_cdg(self.config.sync_offset)
366
+
367
+ self.writer = CDGWriter()
368
+ logger.info("config settings loaded")
369
+
370
+ self._set_draw_times()
371
+
372
+ @classmethod
373
+ def from_file(
374
+ cls,
375
+ file: "FileDescriptorOrPath",
376
+ ) -> Self:
377
+ converter = Converter(prefer_attrib_converters=True)
378
+ relative_dir = Path(file).parent
379
+ with open(file, "rb") as stream:
380
+ return cls(
381
+ converter.structure(tomllib.load(stream), Settings),
382
+ relative_dir=relative_dir,
383
+ )
384
+
385
+ @classmethod
386
+ def from_string(
387
+ cls,
388
+ config: str,
389
+ relative_dir: "StrOrBytesPath | Path" = "",
390
+ ) -> Self:
391
+ converter = Converter(prefer_attrib_converters=True)
392
+ return cls(
393
+ converter.structure(tomllib.loads(config), Settings),
394
+ relative_dir=relative_dir,
395
+ )
396
+ # !SECTION
397
+ #endregion
398
+
399
+ #region Set draw times
400
+ # SECTION Set draw times
401
+ # Gap between line draw/erase events = 1/6 second
402
+ LINE_DRAW_ERASE_GAP = CDG_FPS // 6
403
+ # TODO Make more values in these set-draw-times functions into named
404
+ # constants
405
+
406
+ def _set_draw_times(self):
407
+ self.lyric_times: list[LyricTimes] = []
408
+ for lyric in self.lyrics:
409
+ logger.debug(f"setting draw times for lyric {lyric.lyric_index}")
410
+ line_count = len(lyric.lines)
411
+ line_draw: list[int] = [0] * line_count
412
+ line_erase: list[int] = [0] * line_count
413
+
414
+ # The first page is drawn 3 seconds before the first
415
+ # syllable
416
+ first_syllable = next(iter(
417
+ syllable_info
418
+ for line_info in lyric.lines
419
+ for syllable_info in line_info.syllables
420
+ ))
421
+ draw_time = first_syllable.start_offset - 900
422
+ for i in range(lyric.lines_per_page):
423
+ if i < line_count:
424
+ line_draw[i] = draw_time
425
+ draw_time += self.LINE_DRAW_ERASE_GAP
426
+
427
+ # For each pair of syllables
428
+ for last_wipe, wipe in it.pairwise(
429
+ syllable_info
430
+ for line_info in lyric.lines
431
+ for syllable_info in line_info.syllables
432
+ ):
433
+ # Skip if not on a line boundary
434
+ if wipe.line_index <= last_wipe.line_index:
435
+ continue
436
+
437
+ # Set draw times for lines
438
+ match self.config.clear_mode:
439
+ case LyricClearMode.PAGE:
440
+ self._set_draw_times_page(
441
+ last_wipe, wipe,
442
+ lyric=lyric,
443
+ line_draw=line_draw,
444
+ line_erase=line_erase,
445
+ )
446
+ case LyricClearMode.LINE_EAGER:
447
+ self._set_draw_times_line_eager(
448
+ last_wipe, wipe,
449
+ lyric=lyric,
450
+ line_draw=line_draw,
451
+ line_erase=line_erase,
452
+ )
453
+ case LyricClearMode.LINE_DELAYED | _:
454
+ self._set_draw_times_line_delayed(
455
+ last_wipe, wipe,
456
+ lyric=lyric,
457
+ line_draw=line_draw,
458
+ line_erase=line_erase,
459
+ )
460
+
461
+ # If clearing page by page
462
+ if self.config.clear_mode == LyricClearMode.PAGE:
463
+ # Don't actually erase any lines
464
+ line_erase = []
465
+ # If we're not clearing page by page
466
+ else:
467
+ end_line = wipe.line_index
468
+ # Calculate the erase time of the last highlighted line
469
+ erase_time = wipe.end_offset + 600
470
+ line_erase[end_line] = erase_time
471
+ erase_time += self.LINE_DRAW_ERASE_GAP
472
+
473
+ logger.debug(
474
+ f"lyric {lyric.lyric_index} draw times: {line_draw!r}"
475
+ )
476
+ logger.debug(
477
+ f"lyric {lyric.lyric_index} erase times: {line_erase!r}"
478
+ )
479
+ self.lyric_times.append(LyricTimes(
480
+ line_draw=line_draw,
481
+ line_erase=line_erase,
482
+ ))
483
+ logger.info("draw times set")
484
+
485
+ def _set_draw_times_page(
486
+ self,
487
+ last_wipe: SyllableInfo,
488
+ wipe: SyllableInfo,
489
+ lyric: LyricInfo,
490
+ line_draw: list[int],
491
+ line_erase: list[int],
492
+ ):
493
+ line_count = len(lyric.lines)
494
+ last_page = last_wipe.line_index // lyric.lines_per_page
495
+ this_page = wipe.line_index // lyric.lines_per_page
496
+
497
+ # Skip if not on a page boundary
498
+ if this_page <= last_page:
499
+ return
500
+
501
+ # This page starts at the later of:
502
+ # - a few frames after the end of the last line
503
+ # - 3 seconds before this line
504
+ page_draw_time = max(
505
+ last_wipe.end_offset + 12,
506
+ wipe.start_offset - 900,
507
+ )
508
+
509
+ # Calculate the available time between the start of this line
510
+ # and the desired page draw time
511
+ available_time = wipe.start_offset - page_draw_time
512
+ # Calculate the absolute minimum time from the last line to this
513
+ # line
514
+ # NOTE This is a sensible minimum, but not guaranteed.
515
+ minimum_time = wipe.start_offset - last_wipe.start_offset - 24
516
+
517
+ # Warn the user if there's not likely to be enough time
518
+ if minimum_time < 32:
519
+ logger.warning(
520
+ "not enough bandwidth to clear screen on lyric "
521
+ f"{wipe.lyric_index} line {wipe.line_index}"
522
+ )
523
+
524
+ # If there's not enough time between the end of the last line
525
+ # and the start of this line, but there is enough time between
526
+ # the start of the last line and the start of this page
527
+ if available_time < 32:
528
+ # Shorten the last wipe's duration to make room
529
+ new_duration = wipe.start_offset - last_wipe.start_offset - 150
530
+ if new_duration > 0:
531
+ last_wipe.end_offset = last_wipe.start_offset + new_duration
532
+ page_draw_time = last_wipe.end_offset + 12
533
+ else:
534
+ last_wipe.end_offset = last_wipe.start_offset
535
+ page_draw_time = last_wipe.end_offset + 32
536
+
537
+ # Set the draw times for lines on this page
538
+ start_line = this_page * lyric.lines_per_page
539
+ for i in range(start_line, start_line + lyric.lines_per_page):
540
+ if i < line_count:
541
+ line_draw[i] = page_draw_time
542
+ page_draw_time += self.LINE_DRAW_ERASE_GAP
543
+
544
+ def _set_draw_times_line_eager(
545
+ self,
546
+ last_wipe: SyllableInfo,
547
+ wipe: SyllableInfo,
548
+ lyric: LyricInfo,
549
+ line_draw: list[int],
550
+ line_erase: list[int],
551
+ ):
552
+ line_count = len(lyric.lines)
553
+ last_page = last_wipe.line_index // lyric.lines_per_page
554
+ this_page = wipe.line_index // lyric.lines_per_page
555
+
556
+ # The last line should be erased near the start of this line
557
+ erase_time = wipe.start_offset
558
+
559
+ # If we're not on the next page
560
+ if last_page >= this_page:
561
+ # The last line is erased 1/3 seconds after the start of
562
+ # this line
563
+ erase_time += 100
564
+
565
+ # Set draw and erase times for the last line
566
+ for i in range(last_wipe.line_index, wipe.line_index):
567
+ if i < line_count:
568
+ line_erase[i] = erase_time
569
+ erase_time += self.LINE_DRAW_ERASE_GAP
570
+ j = i + lyric.lines_per_page
571
+ if j < line_count:
572
+ line_draw[j] = erase_time
573
+ erase_time += self.LINE_DRAW_ERASE_GAP
574
+ return
575
+ # If we're here, we're on the next page
576
+
577
+ last_wipe_end = last_wipe.end_offset
578
+ inter_wipe_time = wipe.start_offset - last_wipe_end
579
+
580
+ # The last line is erased at the earlier of:
581
+ # - halfway between the pages
582
+ # - 1.5 seconds after the last line
583
+ erase_time = min(
584
+ last_wipe_end + inter_wipe_time // 2,
585
+ last_wipe_end + 450,
586
+ )
587
+
588
+ # If time between pages is less than 8 seconds
589
+ if inter_wipe_time < 2400:
590
+ # Set draw and erase times for the last line
591
+ for i in range(last_wipe.line_index, wipe.line_index):
592
+ if i < line_count:
593
+ line_erase[i] = erase_time
594
+ erase_time += self.LINE_DRAW_ERASE_GAP
595
+ j = i + lyric.lines_per_page
596
+ if j < line_count:
597
+ line_draw[j] = erase_time
598
+ erase_time += self.LINE_DRAW_ERASE_GAP
599
+ # If time between pages is 8 seconds or longer
600
+ else:
601
+ # Set erase time for the last line
602
+ for i in range(last_wipe.line_index, wipe.line_index):
603
+ if i < line_count:
604
+ line_erase[i] = erase_time
605
+ erase_time += self.LINE_DRAW_ERASE_GAP
606
+
607
+ # The new page will be drawn 3 seconds before the start of
608
+ # this line
609
+ draw_time = wipe.start_offset - 900
610
+ start_line = wipe.line_index
611
+ for i in range(start_line, start_line + lyric.lines_per_page):
612
+ if i < line_count:
613
+ line_draw[i] = draw_time
614
+ draw_time += self.LINE_DRAW_ERASE_GAP
615
+
616
+ def _set_draw_times_line_delayed(
617
+ self,
618
+ last_wipe: SyllableInfo,
619
+ wipe: SyllableInfo,
620
+ lyric: LyricInfo,
621
+ line_draw: list[int],
622
+ line_erase: list[int],
623
+ ):
624
+ line_count = len(lyric.lines)
625
+ last_page = last_wipe.line_index // lyric.lines_per_page
626
+ this_page = wipe.line_index // lyric.lines_per_page
627
+
628
+ # If we're on the same page
629
+ if last_page == this_page:
630
+ # The last line will be erased at the earlier of:
631
+ # - 1/3 seconds after the start of this line
632
+ # - 1.5 seconds after the end of the last line
633
+ erase_time = min(
634
+ wipe.start_offset + 100,
635
+ last_wipe.end_offset + 450,
636
+ )
637
+
638
+ # Set erase time for the last line
639
+ for i in range(last_wipe.line_index, wipe.line_index):
640
+ if i < line_count:
641
+ line_erase[i] = erase_time
642
+ erase_time += self.LINE_DRAW_ERASE_GAP
643
+ return
644
+ # If we're here, we're on the next page
645
+
646
+ last_wipe_end = max(
647
+ last_wipe.end_offset,
648
+ last_wipe.start_offset + 100,
649
+ )
650
+ inter_wipe_time = wipe.start_offset - last_wipe_end
651
+
652
+ last_line_start_offset = lyric.lines[
653
+ last_wipe.line_index
654
+ ].syllables[0].start_offset
655
+
656
+ # The last line will be erased at the earlier of:
657
+ # - 1/3 seconds after the start of this line
658
+ # - 1.5 seconds after the end of the last line
659
+ # - 1/3 of the way between the pages
660
+ erase_time = min(
661
+ wipe.start_offset + 100,
662
+ last_wipe_end + 450,
663
+ last_wipe_end + inter_wipe_time // 3,
664
+ )
665
+ # This line will be drawn at the latest of:
666
+ # - 1/3 seconds after the start of the last line
667
+ # - 3 seconds before the start of this line
668
+ # - 1/3 of the way between the pages
669
+ draw_time = max(
670
+ last_line_start_offset + 100,
671
+ wipe.start_offset - 900,
672
+ last_wipe_end + inter_wipe_time // 3,
673
+ )
674
+
675
+ # If time between pages is 4 seconds or more, clear current page
676
+ # lines before drawing new page lines
677
+ if inter_wipe_time >= 1200:
678
+ # Set erase times for lines on previous page
679
+ for i in range(last_wipe.line_index, wipe.line_index):
680
+ if i < line_count:
681
+ line_erase[i] = erase_time
682
+ erase_time += self.LINE_DRAW_ERASE_GAP
683
+
684
+ draw_time = max(draw_time, erase_time)
685
+ start_line = last_page * lyric.lines_per_page
686
+ # Set draw times for lines on this page
687
+ for i in range(start_line, start_line + lyric.lines_per_page):
688
+ j = i + lyric.lines_per_page
689
+ if j < line_count:
690
+ line_draw[j] = draw_time
691
+ draw_time += self.LINE_DRAW_ERASE_GAP
692
+ return
693
+ # If time between pages is less than 4 seconds, draw new page
694
+ # lines before clearing current page lines
695
+
696
+ # The first lines on the next page should be drawn 1/2 seconds
697
+ # after the start of the last line
698
+ draw_time = last_line_start_offset + 150
699
+
700
+ # Set draw time for all lines on the next page before this line
701
+ start_line = last_page * lyric.lines_per_page
702
+ for i in range(start_line, last_wipe.line_index):
703
+ j = i + lyric.lines_per_page
704
+ if j < line_count:
705
+ line_draw[j] = draw_time
706
+ draw_time += self.LINE_DRAW_ERASE_GAP
707
+
708
+ # The last lines on the next page should be drawn at least 1/3
709
+ # of the way between the pages
710
+ draw_time = max(
711
+ draw_time,
712
+ last_wipe_end + inter_wipe_time // 3,
713
+ )
714
+ # Set erase times for the rest of the lines on the previous page
715
+ for i in range(last_wipe.line_index, wipe.line_index):
716
+ if i < line_count:
717
+ line_erase[i] = draw_time
718
+ draw_time += self.LINE_DRAW_ERASE_GAP
719
+ # Set draw times for the rest of the lines on this page
720
+ for i in range(last_wipe.line_index, wipe.line_index):
721
+ j = i + lyric.lines_per_page
722
+ if j < line_count:
723
+ line_draw[j] = draw_time
724
+ draw_time += self.LINE_DRAW_ERASE_GAP
725
+ # !SECTION
726
+ #endregion
727
+
728
+ #region Compose words
729
+ # SECTION Compose words
730
+ def compose(self):
731
+ try:
732
+ # NOTE Logistically, multiple simultaneous lyric sets doesn't
733
+ # make sense if the lyrics are being cleared by page.
734
+ if (
735
+ self.config.clear_mode == LyricClearMode.PAGE
736
+ and len(self.lyrics) > 1
737
+ ):
738
+ raise RuntimeError(
739
+ "page mode doesn't support more than one lyric set"
740
+ )
741
+
742
+ logger.debug("loading song file")
743
+ song: AudioSegment = AudioSegment.from_file(
744
+ file_relative_to(self.config.file, self.relative_dir)
745
+ )
746
+ logger.info("song file loaded")
747
+
748
+ self.intro_delay = 0
749
+ # Compose the intro
750
+ # NOTE This also sets the intro delay for later.
751
+ self._compose_intro()
752
+
753
+ lyric_states: list[LyricState] = []
754
+ for lyric in self.lyrics:
755
+ lyric_states.append(LyricState(
756
+ line_draw=0,
757
+ line_erase=0,
758
+ syllable_line=0,
759
+ syllable_index=0,
760
+ draw_queue=deque(),
761
+ highlight_queue=deque(),
762
+ ))
763
+
764
+ composer_state = ComposerState(
765
+ instrumental=0,
766
+ this_page=0,
767
+ last_page=0,
768
+ just_cleared=False,
769
+ )
770
+
771
+ # XXX If there is an instrumental section immediately after the
772
+ # intro, the screen should not be cleared. The way I'm detecting
773
+ # this, however, is by (mostly) copy-pasting the code that
774
+ # checks for instrumental sections. I shouldn't do it this way.
775
+ current_time = (
776
+ self.writer.packets_queued - self.sync_offset
777
+ - self.intro_delay
778
+ )
779
+ should_instrumental = False
780
+ instrumental = None
781
+ if composer_state.instrumental < len(self.config.instrumentals):
782
+ instrumental = self.config.instrumentals[
783
+ composer_state.instrumental
784
+ ]
785
+ instrumental_time = sync_to_cdg(instrumental.sync)
786
+ # NOTE Normally, this part has code to handle waiting for a
787
+ # lyric to finish. If there's an instrumental this early,
788
+ # however, there shouldn't be any lyrics to finish.
789
+ should_instrumental = current_time >= instrumental_time
790
+ # If there should not be an instrumental section now
791
+ if not should_instrumental:
792
+ logger.debug("instrumental intro is not present; clearing")
793
+ # Clear the screen
794
+ self.writer.queue_packets([
795
+ *memory_preset_repeat(self.BACKGROUND),
796
+ *load_color_table(self.color_table),
797
+ ])
798
+ logger.debug(f"loaded color table in compose: {self.color_table}")
799
+ if self.config.border is not None:
800
+ self.writer.queue_packet(border_preset(self.BORDER))
801
+ else:
802
+ logger.debug("instrumental intro is present; not clearing")
803
+
804
+ # While there are lines to draw/erase, or syllables to
805
+ # highlight, or events in the highlight/draw queues, or
806
+ # instrumental sections to process
807
+ while any(
808
+ state.line_draw < len(times.line_draw)
809
+ or state.line_erase < len(times.line_erase)
810
+ or state.syllable_line < len(lyric.lines)
811
+ or state.draw_queue
812
+ or state.highlight_queue
813
+ for lyric, times, state in zip(
814
+ self.lyrics,
815
+ self.lyric_times,
816
+ lyric_states,
817
+ )
818
+ ) or (
819
+ composer_state.instrumental < len(self.config.instrumentals)
820
+ ):
821
+ for lyric, times, state in zip(
822
+ self.lyrics,
823
+ self.lyric_times,
824
+ lyric_states,
825
+ ):
826
+ self._compose_lyric(
827
+ lyric=lyric,
828
+ times=times,
829
+ state=state,
830
+ lyric_states=lyric_states,
831
+ composer_state=composer_state,
832
+ )
833
+
834
+ # Add audio padding to intro
835
+ logger.debug("padding intro of audio file")
836
+ intro_silence: AudioSegment = AudioSegment.silent(
837
+ self.intro_delay * 1000 // CDG_FPS,
838
+ frame_rate=song.frame_rate,
839
+ )
840
+ padded_song = intro_silence + song
841
+
842
+ # NOTE If video padding is not added to the end of the song, the
843
+ # outro (or next instrumental section) begins immediately after
844
+ # the end of the last syllable, which would be abrupt.
845
+ if self.config.clear_mode == LyricClearMode.PAGE:
846
+ logger.debug(
847
+ "clear mode is page; adding padding before outro"
848
+ )
849
+ self.writer.queue_packets([no_instruction()] * 3 * CDG_FPS)
850
+
851
+ # Calculate video padding before outro
852
+ OUTRO_DURATION = 2400
853
+ # This karaoke file ends at the later of:
854
+ # - The end of the audio (with the padded intro)
855
+ # - 8 seconds after the current video time
856
+ end = max(
857
+ int(padded_song.duration_seconds * CDG_FPS),
858
+ self.writer.packets_queued + OUTRO_DURATION,
859
+ )
860
+ logger.debug(f"song should be {end} frame(s) long")
861
+ padding_before_outro = (
862
+ (end - OUTRO_DURATION) - self.writer.packets_queued
863
+ )
864
+ logger.debug(
865
+ f"queueing {padding_before_outro} packets before outro"
866
+ )
867
+ self.writer.queue_packets([no_instruction()] * padding_before_outro)
868
+
869
+ # Compose the outro (and thus, finish the video)
870
+ self._compose_outro(end)
871
+ logger.info("karaoke file composed")
872
+
873
+ # Add audio padding to outro (and thus, finish the audio)
874
+ logger.debug("padding outro of audio file")
875
+ outro_silence: AudioSegment = AudioSegment.silent(
876
+ (
877
+ (self.writer.packets_queued * 1000 // CDG_FPS)
878
+ - int(padded_song.duration_seconds * 1000)
879
+ ),
880
+ frame_rate=song.frame_rate,
881
+ )
882
+ padded_song += outro_silence
883
+
884
+ # Write CDG and MP3 data to ZIP file
885
+ outname = self.config.outname
886
+ zipfile_name = self.relative_dir / Path(f"{outname}.zip")
887
+ logger.debug(f"creating {zipfile_name}")
888
+ with ZipFile(zipfile_name, "w") as zipfile:
889
+ cdg_bytes = BytesIO()
890
+ logger.debug("writing cdg packets to stream")
891
+ self.writer.write_packets(cdg_bytes)
892
+ logger.debug(
893
+ f"writing stream to zipfile as {outname}.cdg"
894
+ )
895
+ cdg_bytes.seek(0)
896
+ zipfile.writestr(f"{outname}.cdg", cdg_bytes.read())
897
+
898
+ mp3_bytes = BytesIO()
899
+ logger.debug("writing mp3 data to stream")
900
+ padded_song.export(mp3_bytes, format="mp3")
901
+ logger.debug(
902
+ f"writing stream to zipfile as {outname}.mp3"
903
+ )
904
+ mp3_bytes.seek(0)
905
+ zipfile.writestr(f"{outname}.mp3", mp3_bytes.read())
906
+ logger.info(f"karaoke files written to {zipfile_name}")
907
+ except Exception as e:
908
+ logger.error(f"Error in compose: {str(e)}", exc_info=True)
909
+ raise
910
+
911
+ def _compose_lyric(
912
+ self,
913
+ lyric: LyricInfo,
914
+ times: LyricTimes,
915
+ state: LyricState,
916
+ lyric_states: list[LyricState],
917
+ composer_state: ComposerState,
918
+ ):
919
+ current_time = (
920
+ self.writer.packets_queued - self.sync_offset
921
+ - self.intro_delay
922
+ )
923
+
924
+ should_draw_this_line = False
925
+ line_draw_info, line_draw_time = None, None
926
+ if state.line_draw < len(times.line_draw):
927
+ line_draw_info = lyric.lines[state.line_draw]
928
+ line_draw_time = times.line_draw[state.line_draw]
929
+ should_draw_this_line = current_time >= line_draw_time
930
+
931
+ should_erase_this_line = False
932
+ line_erase_info, line_erase_time = None, None
933
+ if state.line_erase < len(times.line_erase):
934
+ line_erase_info = lyric.lines[state.line_erase]
935
+ line_erase_time = times.line_erase[state.line_erase]
936
+ should_erase_this_line = current_time >= line_erase_time
937
+
938
+ # If we're clearing lyrics by page and drawing a new line
939
+ if (
940
+ self.config.clear_mode == LyricClearMode.PAGE
941
+ and should_draw_this_line
942
+ ):
943
+ composer_state.last_page = composer_state.this_page
944
+ composer_state.this_page = (
945
+ line_draw_info.line_index // lyric.lines_per_page
946
+ )
947
+ # If this line is the start of a new page
948
+ if composer_state.this_page > composer_state.last_page:
949
+ logger.debug(
950
+ f"going from page {composer_state.last_page} to "
951
+ f"page {composer_state.this_page} in page mode"
952
+ )
953
+ # If we have not just cleared the screen
954
+ if not composer_state.just_cleared:
955
+ logger.debug("clearing screen on page transition")
956
+ # Clear the last page
957
+ page_clear_packets = [
958
+ *memory_preset_repeat(self.BACKGROUND),
959
+ ]
960
+ if self.config.border is not None:
961
+ page_clear_packets.append(border_preset(self.BORDER))
962
+ self.writer.queue_packets(page_clear_packets)
963
+ composer_state.just_cleared = True
964
+ # Update the current frame time
965
+ current_time += len(page_clear_packets)
966
+ else:
967
+ logger.debug("not clearing screen on page transition")
968
+
969
+ # Queue the erasing of this line if necessary
970
+ if should_erase_this_line:
971
+ assert line_erase_info is not None
972
+ # logger.debug(
973
+ # f"t={self.writer.packets_queued}: erasing lyric "
974
+ # f"{line_erase_info.lyric_index} line "
975
+ # f"{line_erase_info.line_index}"
976
+ # )
977
+ if line_erase_info.text.strip():
978
+ state.draw_queue.extend(line_image_to_packets(
979
+ line_erase_info.image,
980
+ xy=(line_erase_info.x, line_erase_info.y),
981
+ background=self.BACKGROUND,
982
+ erase=True,
983
+ ))
984
+ else:
985
+ logger.debug("line is blank; not erased")
986
+ state.line_erase += 1
987
+ # Queue the drawing of this line if necessary
988
+ if should_draw_this_line:
989
+ assert line_draw_info is not None
990
+ # logger.debug(
991
+ # f"t={self.writer.packets_queued}: drawing lyric "
992
+ # f"{line_draw_info.lyric_index} line "
993
+ # f"{line_draw_info.line_index}"
994
+ # )
995
+ if line_draw_info.text.strip():
996
+ state.draw_queue.extend(line_image_to_packets(
997
+ line_draw_info.image,
998
+ xy=(line_draw_info.x, line_draw_info.y),
999
+ fill=line_draw_info.singer << 2 | 0,
1000
+ stroke=line_draw_info.singer << 2 | 1,
1001
+ background=self.BACKGROUND,
1002
+ ))
1003
+ else:
1004
+ logger.debug("line is blank; not drawn")
1005
+ state.line_draw += 1
1006
+
1007
+ # NOTE If this line has no syllables, we must advance the
1008
+ # syllable line index until we reach a line that has syllables.
1009
+ while state.syllable_line < len(lyric.lines):
1010
+ if lyric.lines[state.syllable_line].syllables:
1011
+ break
1012
+ state.syllable_index = 0
1013
+ state.syllable_line += 1
1014
+
1015
+ should_highlight = False
1016
+ syllable_info = None
1017
+ if state.syllable_line < len(lyric.lines):
1018
+ syllable_info = (
1019
+ lyric.lines[state.syllable_line]
1020
+ .syllables[state.syllable_index]
1021
+ )
1022
+ should_highlight = current_time >= syllable_info.start_offset
1023
+ # If this syllable should be highlighted now
1024
+ if should_highlight:
1025
+ assert syllable_info is not None
1026
+ if syllable_info.text.strip():
1027
+ # Add the highlight packets to the highlight queue
1028
+ state.highlight_queue.extend(self._compose_highlight(
1029
+ lyric=lyric,
1030
+ syllable=syllable_info,
1031
+ current_time=current_time,
1032
+ ))
1033
+
1034
+ # Advance to the next syllable
1035
+ state.syllable_index += 1
1036
+ if state.syllable_index >= len(
1037
+ lyric.lines[state.syllable_line].syllables
1038
+ ):
1039
+ state.syllable_index = 0
1040
+ state.syllable_line += 1
1041
+
1042
+ should_instrumental = False
1043
+ instrumental = None
1044
+ if composer_state.instrumental < len(self.config.instrumentals):
1045
+ instrumental = self.config.instrumentals[
1046
+ composer_state.instrumental
1047
+ ]
1048
+ instrumental_time = sync_to_cdg(instrumental.sync)
1049
+ if instrumental.wait:
1050
+ syllable_iter = iter(
1051
+ syll
1052
+ for line_info in lyric.lines
1053
+ for syll in line_info.syllables
1054
+ )
1055
+ last_syllable = next(syllable_iter)
1056
+ # Find first syllable on or after the instrumental time
1057
+ while last_syllable.start_offset < instrumental_time:
1058
+ last_syllable = next(syllable_iter)
1059
+ first_syllable = lyric.lines[
1060
+ last_syllable.line_index
1061
+ ].syllables[0]
1062
+ # If this line is being actively sung
1063
+ if current_time >= first_syllable.start_offset:
1064
+ # If this is the last syllable in this line
1065
+ if last_syllable.syllable_index == len(
1066
+ lyric.lines[last_syllable.line_index].syllables
1067
+ ) - 1:
1068
+ instrumental_time = 0
1069
+ if times.line_erase:
1070
+ # Wait for this line to be erased
1071
+ instrumental_time = times.line_erase[
1072
+ last_syllable.line_index
1073
+ ]
1074
+ if not instrumental_time:
1075
+ # Add 1.5 seconds
1076
+ # XXX This is hardcoded.
1077
+ instrumental_time = last_syllable.end_offset + 450
1078
+ else:
1079
+ logger.debug(
1080
+ "forcing next instrumental not to wait; it "
1081
+ "does not occur at or before the end of this "
1082
+ "line"
1083
+ )
1084
+ instrumental.wait = False
1085
+ should_instrumental = current_time >= instrumental_time
1086
+
1087
+ # If there should be an instrumental section now
1088
+ if should_instrumental:
1089
+ assert instrumental is not None
1090
+ logger.info("_compose_lyric: Time for an instrumental section")
1091
+ if instrumental.wait:
1092
+ logger.info("_compose_lyric: This instrumental section waited for the previous line to finish")
1093
+ else:
1094
+ logger.info("_compose_lyric: This instrumental did not wait for the previous line to finish")
1095
+
1096
+ logger.debug("_compose_lyric: Purging all highlight/draw queues")
1097
+ for st in lyric_states:
1098
+ if instrumental.wait:
1099
+ if st.highlight_queue:
1100
+ logger.warning("_compose_lyric: Unexpected items in highlight queue when instrumental waited")
1101
+ if st.draw_queue:
1102
+ if st == state:
1103
+ logger.debug("_compose_lyric: Queueing remaining draw packets for current state")
1104
+ else:
1105
+ logger.warning("_compose_lyric: Unexpected items in draw queue for non-current state")
1106
+ self.writer.queue_packets(st.draw_queue)
1107
+
1108
+ st.highlight_queue.clear()
1109
+ st.draw_queue.clear()
1110
+
1111
+ logger.debug("_compose_lyric: Determining instrumental end time")
1112
+ if line_draw_time is not None:
1113
+ instrumental_end = line_draw_time
1114
+ else:
1115
+ instrumental_end = None
1116
+ logger.debug(f"_compose_lyric: instrumental_end={instrumental_end}")
1117
+
1118
+ composer_state.instrumental += 1
1119
+ next_instrumental = None
1120
+ if composer_state.instrumental < len(self.config.instrumentals):
1121
+ next_instrumental = self.config.instrumentals[
1122
+ composer_state.instrumental
1123
+ ]
1124
+
1125
+ should_clear = True
1126
+ if next_instrumental is not None:
1127
+ next_instrumental_time = sync_to_cdg(next_instrumental.sync)
1128
+ logger.debug(f"_compose_lyric: next_instrumental_time={next_instrumental_time}")
1129
+ if instrumental_end is None or next_instrumental_time <= instrumental_end:
1130
+ instrumental_end = next_instrumental_time
1131
+ should_clear = False
1132
+ else:
1133
+ if line_draw_time is None:
1134
+ should_clear = False
1135
+
1136
+ logger.info(f"_compose_lyric: Composing instrumental. End time: {instrumental_end}, Should clear: {should_clear}")
1137
+ try:
1138
+ self._compose_instrumental(instrumental, instrumental_end)
1139
+ except Exception as e:
1140
+ logger.error(f"Error in _compose_instrumental: {str(e)}", exc_info=True)
1141
+ raise
1142
+
1143
+ if should_clear:
1144
+ logger.debug("_compose_lyric: Clearing screen after instrumental")
1145
+ self.writer.queue_packets([
1146
+ *memory_preset_repeat(self.BACKGROUND),
1147
+ *load_color_table(self.color_table),
1148
+ ])
1149
+ logger.debug(f"_compose_lyric: Loaded color table: {self.color_table}")
1150
+ if self.config.border is not None:
1151
+ self.writer.queue_packet(border_preset(self.BORDER))
1152
+ composer_state.just_cleared = True
1153
+ else:
1154
+ logger.debug("_compose_lyric: Not clearing screen after instrumental")
1155
+ return
1156
+
1157
+ composer_state.just_cleared = False
1158
+ # Create groups of packets for highlights and draws, with None
1159
+ # as a placeholder value for non-highlight packets
1160
+ highlight_groups: list[list[CDGPacket | None]] = []
1161
+ for _ in range(self.config.highlight_bandwidth):
1162
+ group = []
1163
+ if state.highlight_queue:
1164
+ group = state.highlight_queue.popleft()
1165
+ highlight_groups.append(list(pad(group, self.max_tile_height)))
1166
+ # NOTE This means the draw groups will only contain None.
1167
+ draw_groups: list[list[CDGPacket | None]] = [
1168
+ [None] * self.max_tile_height
1169
+ ] * self.config.draw_bandwidth
1170
+
1171
+ # Intersperse the highlight and draw groups and queue the
1172
+ # packets
1173
+ for group in intersperse(highlight_groups, draw_groups):
1174
+ for item in group:
1175
+ if item is not None:
1176
+ self.writer.queue_packet(item)
1177
+ continue
1178
+
1179
+ # If a group item is None, try getting packets from the
1180
+ # draw queue
1181
+ if state.draw_queue:
1182
+ self.writer.queue_packet(state.draw_queue.popleft())
1183
+ continue
1184
+ self.writer.queue_packet(
1185
+ next(iter(
1186
+ st.draw_queue.popleft()
1187
+ for st in lyric_states
1188
+ if st.draw_queue
1189
+ ), no_instruction())
1190
+ )
1191
+
1192
+ def _compose_highlight(
1193
+ self,
1194
+ lyric: LyricInfo,
1195
+ syllable: SyllableInfo,
1196
+ current_time: int,
1197
+ ) -> list[list[CDGPacket]]:
1198
+ assert syllable is not None
1199
+ line_info = lyric.lines[syllable.line_index]
1200
+ x = line_info.x
1201
+ y = line_info.y
1202
+
1203
+ # NOTE Using the current time instead of the ideal start offset
1204
+ # accounts for any lost frames from previous events that took
1205
+ # too long.
1206
+ start_offset = current_time
1207
+ end_offset = syllable.end_offset
1208
+ left_edge = syllable.left_edge
1209
+ right_edge = syllable.right_edge
1210
+
1211
+ # Calculate the length of each column group in frames
1212
+ column_group_length = (
1213
+ (self.config.draw_bandwidth + self.config.highlight_bandwidth)
1214
+ * self.max_tile_height
1215
+ ) * len(self.lyrics)
1216
+ # Calculate the number of column updates for this highlight
1217
+ columns = (
1218
+ (end_offset - start_offset) // column_group_length
1219
+ ) * self.config.highlight_bandwidth
1220
+
1221
+ left_tile = left_edge // CDG_TILE_WIDTH
1222
+ right_tile = ceildiv(right_edge, CDG_TILE_WIDTH) - 1
1223
+ # The highlight must hit at least the edges of all the tiles
1224
+ # along it (not including the one before the left edge or the
1225
+ # one after the right edge)
1226
+ highlight_progress = [
1227
+ tile_index * CDG_TILE_WIDTH
1228
+ for tile_index in range(left_tile + 1, right_tile + 1)
1229
+ ]
1230
+ # If there aren't too many tile boundaries for the number of
1231
+ # column updates
1232
+ if columns - 1 >= len(highlight_progress):
1233
+ # Add enough highlight points for all the column updates...
1234
+ highlight_progress += sorted(
1235
+ # ...which are evenly distributed within the range...
1236
+ map(operator.itemgetter(0), distribute(
1237
+ range(1, columns), left_edge, right_edge,
1238
+ )),
1239
+ # ...prioritizing highlight points nearest to the middle
1240
+ # of a tile
1241
+ key=lambda n: abs(n % CDG_TILE_WIDTH - CDG_TILE_WIDTH // 2),
1242
+ )[:columns - 1 - len(highlight_progress)]
1243
+ # NOTE We need the length of this list to be the number of
1244
+ # columns minus 1, so that when the left and right edges are
1245
+ # included, there will be as many pairs as there are
1246
+ # columns.
1247
+
1248
+ # Round and sort the highlight points
1249
+ highlight_progress = sorted(map(round, highlight_progress))
1250
+ # If there are too many tile boundaries for the number of column
1251
+ # updates
1252
+ else:
1253
+ # Prepare the syllable text representation
1254
+ syllable_text = ''.join(
1255
+ f"{{{syll.text}}}" if si == syllable.syllable_index else syll.text
1256
+ for si, syll in enumerate(lyric.lines[syllable.line_index].syllables)
1257
+ )
1258
+
1259
+ # Warn the user
1260
+ logger.warning(
1261
+ "Not enough time to highlight lyric %d line %d syllable %d. "
1262
+ "Ideal duration is %d column(s); actual duration is %d column(s). "
1263
+ "Syllable text: %s",
1264
+ syllable.lyric_index, syllable.line_index, syllable.syllable_index,
1265
+ columns, len(highlight_progress) + 1,
1266
+ syllable_text
1267
+ )
1268
+
1269
+ # Create the highlight packets
1270
+ return [
1271
+ line_mask_to_packets(syllable.mask, (x, y), edges)
1272
+ for edges in it.pairwise(
1273
+ [left_edge] + highlight_progress + [right_edge]
1274
+ )
1275
+ ]
1276
+ # !SECTION
1277
+ #endregion
1278
+
1279
+ #region Compose pictures
1280
+ # SECTION Compose pictures
1281
+ def _compose_instrumental(
1282
+ self,
1283
+ instrumental: SettingsInstrumental,
1284
+ end: int | None,
1285
+ ):
1286
+ logger.info(f"Composing instrumental section. End time: {end}")
1287
+ try:
1288
+ self.writer.queue_packets([
1289
+ *memory_preset_repeat(0),
1290
+ # TODO Add option for borders in instrumentals
1291
+ border_preset(0),
1292
+ ])
1293
+
1294
+ logger.debug("Rendering instrumental text")
1295
+ text = instrumental.text.split("\n")
1296
+ instrumental_font = ImageFont.truetype(self.config.font, 20)
1297
+ text_images = render_lines(
1298
+ text,
1299
+ font=instrumental_font,
1300
+ # NOTE If the instrumental shouldn't have a stroke, set the
1301
+ # stroke width to 0 instead.
1302
+ stroke_width=(
1303
+ self.config.stroke_width
1304
+ if instrumental.stroke is not None
1305
+ else 0
1306
+ ),
1307
+ stroke_type=self.config.stroke_type,
1308
+ )
1309
+ text_width = max(image.width for image in text_images)
1310
+ line_height = instrumental.line_tile_height * CDG_TILE_HEIGHT
1311
+ text_height = line_height * len(text)
1312
+ max_height = max(image.height for image in text_images)
1313
+
1314
+ # Set X position of "text box"
1315
+ match instrumental.text_placement:
1316
+ case (
1317
+ TextPlacement.TOP_LEFT
1318
+ | TextPlacement.MIDDLE_LEFT
1319
+ | TextPlacement.BOTTOM_LEFT
1320
+ ):
1321
+ text_x = CDG_TILE_WIDTH * 2
1322
+ case (
1323
+ TextPlacement.TOP_MIDDLE
1324
+ | TextPlacement.MIDDLE
1325
+ | TextPlacement.BOTTOM_MIDDLE
1326
+ ):
1327
+ text_x = (CDG_SCREEN_WIDTH - text_width) // 2
1328
+ case (
1329
+ TextPlacement.TOP_RIGHT
1330
+ | TextPlacement.MIDDLE_RIGHT
1331
+ | TextPlacement.BOTTOM_RIGHT
1332
+ ):
1333
+ text_x = CDG_SCREEN_WIDTH - CDG_TILE_WIDTH * 2 - text_width
1334
+ # Set Y position of "text box"
1335
+ match instrumental.text_placement:
1336
+ case (
1337
+ TextPlacement.TOP_LEFT
1338
+ | TextPlacement.TOP_MIDDLE
1339
+ | TextPlacement.TOP_RIGHT
1340
+ ):
1341
+ text_y = CDG_TILE_HEIGHT * 2
1342
+ case (
1343
+ TextPlacement.MIDDLE_LEFT
1344
+ | TextPlacement.MIDDLE
1345
+ | TextPlacement.MIDDLE_RIGHT
1346
+ ):
1347
+ text_y = (
1348
+ (CDG_SCREEN_HEIGHT - text_height) // 2
1349
+ ) // CDG_TILE_HEIGHT * CDG_TILE_HEIGHT
1350
+ # Add offset to place text closer to middle of line
1351
+ text_y += (line_height - max_height) // 2
1352
+ case (
1353
+ TextPlacement.BOTTOM_LEFT
1354
+ | TextPlacement.BOTTOM_MIDDLE
1355
+ | TextPlacement.BOTTOM_RIGHT
1356
+ ):
1357
+ text_y = CDG_SCREEN_HEIGHT - CDG_TILE_HEIGHT * 2 - text_height
1358
+ # Add offset to place text closer to bottom of line
1359
+ text_y += line_height - max_height
1360
+
1361
+ # Create "screen" image for drawing text
1362
+ screen = Image.new("P", (CDG_SCREEN_WIDTH, CDG_SCREEN_HEIGHT), 0)
1363
+ # Create list of packets to draw text
1364
+ text_image_packets: list[CDGPacket] = []
1365
+ y = text_y
1366
+ for image in text_images:
1367
+ # Set alignment of text
1368
+ match instrumental.text_align:
1369
+ case TextAlign.LEFT:
1370
+ x = text_x
1371
+ case TextAlign.CENTER:
1372
+ x = text_x + (text_width - image.width) // 2
1373
+ case TextAlign.RIGHT:
1374
+ x = text_x + text_width - image.width
1375
+ # Draw text onto simulated screen
1376
+ screen.paste(
1377
+ image.point(
1378
+ lambda v: v and (2 if v == RENDERED_FILL else 3),
1379
+ "P",
1380
+ ),
1381
+ (x, y),
1382
+ )
1383
+ # Render text into packets
1384
+ text_image_packets.extend(line_image_to_packets(
1385
+ image,
1386
+ xy=(x, y),
1387
+ fill=2,
1388
+ stroke=3,
1389
+ background=self.BACKGROUND,
1390
+ ))
1391
+ y += instrumental.line_tile_height * CDG_TILE_HEIGHT
1392
+
1393
+ if instrumental.image is not None:
1394
+ logger.debug("creating instrumental background image")
1395
+ try:
1396
+ # Load background image
1397
+ background_image = self._load_image(
1398
+ instrumental.image,
1399
+ [
1400
+ instrumental.background or self.config.background,
1401
+ self.UNUSED_COLOR,
1402
+ instrumental.fill,
1403
+ instrumental.stroke or self.UNUSED_COLOR,
1404
+ ],
1405
+ )
1406
+ except FileNotFoundError as e:
1407
+ logger.error(f"Failed to load instrumental image: {e}")
1408
+ # Fallback to simple screen if image can't be loaded
1409
+ instrumental.image = None
1410
+ logger.warning("Falling back to simple screen for instrumental")
1411
+
1412
+ if instrumental.image is None:
1413
+ logger.debug("no instrumental image; drawing simple screen")
1414
+ color_table = list(pad(
1415
+ [
1416
+ instrumental.background or self.config.background,
1417
+ self.UNUSED_COLOR,
1418
+ instrumental.fill,
1419
+ instrumental.stroke or self.UNUSED_COLOR,
1420
+ ],
1421
+ 8,
1422
+ padvalue=self.UNUSED_COLOR,
1423
+ ))
1424
+ # Set palette and draw text to screen
1425
+ self.writer.queue_packets([
1426
+ load_color_table_lo(color_table),
1427
+ *text_image_packets,
1428
+ ])
1429
+ logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1430
+ else:
1431
+ # Queue palette packets
1432
+ palette = list(batched(background_image.getpalette(), 3))
1433
+ if len(palette) < 8:
1434
+ color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1435
+ logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1436
+ self.writer.queue_packet(load_color_table_lo(
1437
+ color_table,
1438
+ ))
1439
+ else:
1440
+ color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1441
+ logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1442
+ self.writer.queue_packets(load_color_table(
1443
+ color_table,
1444
+ ))
1445
+
1446
+ logger.debug("drawing instrumental text")
1447
+ # Queue text packets
1448
+ self.writer.queue_packets(text_image_packets)
1449
+
1450
+ logger.debug(
1451
+ "rendering instrumental text over background image"
1452
+ )
1453
+ # HACK To properly draw and layer everything, I need to
1454
+ # create a version of the background image that has the text
1455
+ # overlaid onto it, and is tile-aligned. This requires some
1456
+ # juggling.
1457
+ padleft = instrumental.x % CDG_TILE_WIDTH
1458
+ padright = -(
1459
+ instrumental.x + background_image.width
1460
+ ) % CDG_TILE_WIDTH
1461
+ padtop = instrumental.y % CDG_TILE_HEIGHT
1462
+ padbottom = -(
1463
+ instrumental.y + background_image.height
1464
+ ) % CDG_TILE_HEIGHT
1465
+ logger.debug(
1466
+ f"padding L={padleft} R={padright} T={padtop} B={padbottom}"
1467
+ )
1468
+ # Create axis-aligned background image with proper size and
1469
+ # palette
1470
+ aligned_background_image = Image.new(
1471
+ "P",
1472
+ (
1473
+ background_image.width + padleft + padright,
1474
+ background_image.height + padtop + padbottom,
1475
+ ),
1476
+ 0,
1477
+ )
1478
+ aligned_background_image.putpalette(background_image.getpalette())
1479
+ # Paste background image onto axis-aligned image
1480
+ aligned_background_image.paste(background_image, (padleft, padtop))
1481
+ # Paste existing screen text onto axis-aligned image
1482
+ aligned_background_image.paste(
1483
+ screen,
1484
+ (padleft - instrumental.x, padtop - instrumental.y),
1485
+ # NOTE This masks out the 0 pixels.
1486
+ mask=screen.point(lambda v: v and 255, mode="1"),
1487
+ )
1488
+
1489
+ # Render background image to packets
1490
+ packets = image_to_packets(
1491
+ aligned_background_image,
1492
+ (instrumental.x - padleft, instrumental.y - padtop),
1493
+ background=screen.crop((
1494
+ instrumental.x - padleft,
1495
+ instrumental.y - padtop,
1496
+ instrumental.x - padleft + aligned_background_image.width,
1497
+ instrumental.y - padtop + aligned_background_image.height,
1498
+ )),
1499
+ )
1500
+ logger.debug(
1501
+ "instrumental background image packed in "
1502
+ f"{len(list(it.chain(*packets.values())))} packet(s)"
1503
+ )
1504
+
1505
+ logger.debug("applying instrumental transition")
1506
+ # Queue background image packets (and apply transition)
1507
+ if instrumental.transition is None:
1508
+ for coord_packets in packets.values():
1509
+ self.writer.queue_packets(coord_packets)
1510
+ else:
1511
+ transition = Image.open(
1512
+ package_dir / "transitions" / f"{instrumental.transition}.png"
1513
+ )
1514
+ for coord in self._gradient_to_tile_positions(transition):
1515
+ self.writer.queue_packets(packets.get(coord, []))
1516
+
1517
+ if end is None:
1518
+ logger.debug("this instrumental will last \"forever\"")
1519
+ return
1520
+
1521
+ # Wait until 3 seconds before the next line should be drawn
1522
+ current_time = (
1523
+ self.writer.packets_queued - self.sync_offset
1524
+ - self.intro_delay
1525
+ )
1526
+ preparation_time = 3 * CDG_FPS # 3 seconds * 300 frames per second = 900 frames
1527
+ end_time = max(current_time, end - preparation_time)
1528
+ wait_time = end_time - current_time
1529
+
1530
+ logger.debug(f"waiting for {wait_time} frame(s) before showing next lyrics")
1531
+ self.writer.queue_packets(
1532
+ [no_instruction()] * wait_time
1533
+ )
1534
+
1535
+ # Clear the screen for the next lyrics
1536
+ self.writer.queue_packets([
1537
+ *memory_preset_repeat(self.BACKGROUND),
1538
+ *load_color_table(self.color_table),
1539
+ ])
1540
+ logger.debug(f"loaded color table in compose_instrumental: {self.color_table}")
1541
+ if self.config.border is not None:
1542
+ self.writer.queue_packet(border_preset(self.BORDER))
1543
+
1544
+ logger.debug("instrumental section ended")
1545
+ except Exception as e:
1546
+ logger.error(f"Error in _compose_instrumental: {str(e)}", exc_info=True)
1547
+ raise
1548
+
1549
+ def _compose_intro(self):
1550
+ # TODO Make it so the intro screen is not hardcoded
1551
+ logger.debug("composing intro")
1552
+ self.writer.queue_packets([
1553
+ *memory_preset_repeat(0),
1554
+ ])
1555
+
1556
+ logger.debug("loading intro background image")
1557
+ # Load background image
1558
+ background_image = self._load_image(
1559
+ self.config.title_screen_background,
1560
+ [
1561
+ self.config.background, # background
1562
+ self.config.border, # border
1563
+ self.config.title_color, # title color
1564
+ self.config.artist_color, # artist color
1565
+ ],
1566
+ )
1567
+
1568
+ smallfont = ImageFont.truetype(self.config.font, 25)
1569
+ bigfont_size = 30
1570
+ MAX_HEIGHT = 200
1571
+ # Try rendering the title and artist to an image
1572
+ while True:
1573
+ logger.debug(f"trying song title at size {bigfont_size}")
1574
+ text_image = Image.new("P", (CDG_VISIBLE_WIDTH, MAX_HEIGHT * 2), 0)
1575
+ y = 0
1576
+ bigfont = ImageFont.truetype(self.config.font, bigfont_size)
1577
+
1578
+ # Draw song title
1579
+ for image in render_lines(
1580
+ get_wrapped_text(
1581
+ self.config.title,
1582
+ font=bigfont,
1583
+ width=text_image.width,
1584
+ ).split("\n"),
1585
+ font=bigfont,
1586
+ ):
1587
+ text_image.paste(
1588
+ image.point(lambda v: v and 2, "P"), # Use index 2 for title color
1589
+ ((text_image.width - image.width) // 2, y),
1590
+ mask=image.point(lambda v: v and 255, "1"),
1591
+ )
1592
+ y += int(bigfont.size)
1593
+
1594
+ # Add vertical gap between title and artist using configured value
1595
+ y += self.config.title_artist_gap
1596
+
1597
+ # Draw song artist
1598
+ for image in render_lines(
1599
+ get_wrapped_text(
1600
+ self.config.artist,
1601
+ font=smallfont,
1602
+ width=text_image.width,
1603
+ ).split("\n"),
1604
+ font=smallfont,
1605
+ ):
1606
+ text_image.paste(
1607
+ image.point(lambda v: v and 3, "P"), # Use index 3 for artist color
1608
+ ((text_image.width - image.width) // 2, y),
1609
+ mask=image.point(lambda v: v and 255, "1"),
1610
+ )
1611
+ y += int(smallfont.size)
1612
+
1613
+ # Break out of loop only if text box ends up small enough
1614
+ text_image = text_image.crop(text_image.getbbox())
1615
+ if text_image.height <= MAX_HEIGHT:
1616
+ logger.debug("height just right")
1617
+ break
1618
+ # If text box is not small enough, reduce font size of title
1619
+ logger.debug("height too big; reducing font size")
1620
+ bigfont_size -= 2
1621
+
1622
+ # Draw text onto image
1623
+ background_image.paste(
1624
+ text_image,
1625
+ (
1626
+ (CDG_SCREEN_WIDTH - text_image.width) // 2,
1627
+ (CDG_SCREEN_HEIGHT - text_image.height) // 2,
1628
+ ),
1629
+ mask=text_image.point(lambda v: v and 255, "1"),
1630
+ )
1631
+
1632
+ # Queue palette packets
1633
+ palette = list(batched(background_image.getpalette(), 3))
1634
+ if len(palette) < 8:
1635
+ color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1636
+ logger.debug(f"loaded color table in compose_intro: {color_table}")
1637
+ self.writer.queue_packet(load_color_table_lo(
1638
+ color_table,
1639
+ ))
1640
+ else:
1641
+ color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1642
+ logger.debug(f"loaded color table in compose_intro: {color_table}")
1643
+ self.writer.queue_packets(load_color_table(
1644
+ color_table,
1645
+ ))
1646
+
1647
+ # Render background image to packets
1648
+ packets = image_to_packets(background_image, (0, 0))
1649
+ logger.debug(
1650
+ "intro background image packed in "
1651
+ f"{len(list(it.chain(*packets.values())))} packet(s)"
1652
+ )
1653
+
1654
+ # Queue background image packets (and apply transition)
1655
+ transition = Image.open(
1656
+ package_dir / "transitions" / f"{self.config.title_screen_transition}.png"
1657
+ )
1658
+ for coord in self._gradient_to_tile_positions(transition):
1659
+ self.writer.queue_packets(packets.get(coord, []))
1660
+
1661
+ # Replace hardcoded values with configured ones
1662
+ INTRO_DURATION = int(self.config.intro_duration_seconds * CDG_FPS)
1663
+ FIRST_SYLLABLE_BUFFER = int(self.config.first_syllable_buffer_seconds * CDG_FPS)
1664
+
1665
+ # Queue the intro screen for 5 seconds
1666
+ end_time = INTRO_DURATION
1667
+ self.writer.queue_packets(
1668
+ [no_instruction()] * (end_time - self.writer.packets_queued)
1669
+ )
1670
+
1671
+ first_syllable_start_offset = min(
1672
+ syllable.start_offset
1673
+ for lyric in self.lyrics
1674
+ for line in lyric.lines
1675
+ for syllable in line.syllables
1676
+ )
1677
+ logger.debug(f"first syllable starts at {first_syllable_start_offset}")
1678
+
1679
+ MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE = INTRO_DURATION + FIRST_SYLLABLE_BUFFER
1680
+ # If the first syllable is within buffer+intro time, add silence
1681
+ # Otherwise, don't add any silence
1682
+ if first_syllable_start_offset < MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE:
1683
+ self.intro_delay = MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE
1684
+ logger.info(f"First syllable within {self.config.intro_duration_seconds + self.config.first_syllable_buffer_seconds} seconds. Adding {self.intro_delay} frames of silence.")
1685
+ else:
1686
+ self.intro_delay = 0
1687
+ logger.info("First syllable after buffer period. No additional silence needed.")
1688
+
1689
+ def _compose_outro(self, end: int):
1690
+ # TODO Make it so the outro screen is not hardcoded
1691
+ logger.debug("composing outro")
1692
+ self.writer.queue_packets([
1693
+ *memory_preset_repeat(0),
1694
+ ])
1695
+
1696
+ logger.debug("loading outro background image")
1697
+ # Load background image
1698
+ background_image = self._load_image(
1699
+ self.config.outro_background,
1700
+ [
1701
+ self.config.background, # background
1702
+ self.config.border, # border
1703
+ self.config.outro_line1_color,
1704
+ self.config.outro_line2_color,
1705
+ ],
1706
+ )
1707
+
1708
+ smallfont = ImageFont.truetype(self.config.font, 25)
1709
+ MAX_HEIGHT = 200
1710
+
1711
+ # Render text to an image
1712
+ logger.debug(f"rendering outro text")
1713
+ text_image = Image.new("P", (CDG_VISIBLE_WIDTH, MAX_HEIGHT * 2), 0)
1714
+ y = 0
1715
+
1716
+ # Render first line of outro text
1717
+ outro_text_line1 = self.config.outro_text_line1.replace("$artist", self.config.artist).replace("$title", self.config.title)
1718
+
1719
+ for image in render_lines(
1720
+ get_wrapped_text(
1721
+ outro_text_line1,
1722
+ font=smallfont,
1723
+ width=text_image.width,
1724
+ ).split("\n"),
1725
+ font=smallfont,
1726
+ ):
1727
+ text_image.paste(
1728
+ image.point(lambda v: v and 2, "P"), # Use index 2 for line 1 color
1729
+ ((text_image.width - image.width) // 2, y),
1730
+ mask=image.point(lambda v: v and 255, "1"),
1731
+ )
1732
+ y += int(smallfont.size)
1733
+
1734
+
1735
+ # Add vertical gap between title and artist using configured value
1736
+ y += self.config.outro_line1_line2_gap
1737
+
1738
+ # Render second line of outro text
1739
+ outro_text_line2 = self.config.outro_text_line2.replace("$artist", self.config.artist).replace("$title", self.config.title)
1740
+
1741
+ for image in render_lines(
1742
+ get_wrapped_text(
1743
+ outro_text_line2,
1744
+ font=smallfont,
1745
+ width=text_image.width,
1746
+ ).split("\n"),
1747
+ font=smallfont,
1748
+ ):
1749
+ text_image.paste(
1750
+ image.point(lambda v: v and 3, "P"), # Use index 3 for line 2 color
1751
+ ((text_image.width - image.width) // 2, y),
1752
+ mask=image.point(lambda v: v and 255, "1"),
1753
+ )
1754
+ y += int(smallfont.size)
1755
+
1756
+ # Break out of loop only if text box ends up small enough
1757
+ text_image = text_image.crop(text_image.getbbox())
1758
+ assert text_image.height <= MAX_HEIGHT
1759
+
1760
+ # Draw text onto image
1761
+ background_image.paste(
1762
+ text_image,
1763
+ (
1764
+ (CDG_SCREEN_WIDTH - text_image.width) // 2,
1765
+ (CDG_SCREEN_HEIGHT - text_image.height) // 2,
1766
+ ),
1767
+ mask=text_image.point(lambda v: v and 255, "1"),
1768
+ )
1769
+
1770
+ # Queue palette packets
1771
+ palette = list(batched(background_image.getpalette(), 3))
1772
+ if len(palette) < 8:
1773
+ self.writer.queue_packet(load_color_table_lo(
1774
+ list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1775
+ ))
1776
+ else:
1777
+ self.writer.queue_packets(load_color_table(
1778
+ list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1779
+ ))
1780
+
1781
+ # Render background image to packets
1782
+ packets = image_to_packets(background_image, (0, 0))
1783
+ logger.debug(
1784
+ "intro background image packed in "
1785
+ f"{len(list(it.chain(*packets.values())))} packet(s)"
1786
+ )
1787
+
1788
+ # Queue background image packets (and apply transition)
1789
+ transition = Image.open(
1790
+ package_dir / "transitions" / f"{self.config.outro_transition}.png"
1791
+ )
1792
+ for coord in self._gradient_to_tile_positions(transition):
1793
+ self.writer.queue_packets(packets.get(coord, []))
1794
+
1795
+ self.writer.queue_packets(
1796
+ [no_instruction()] * (end - self.writer.packets_queued)
1797
+ )
1798
+
1799
+ def _load_image(
1800
+ self,
1801
+ image_path: "StrOrBytesPath | Path",
1802
+ partial_palette: list[RGBColor] | None = None,
1803
+ ):
1804
+ if partial_palette is None:
1805
+ partial_palette = []
1806
+
1807
+ logger.debug("loading image")
1808
+ image_rgba = Image.open(
1809
+ file_relative_to(image_path, self.relative_dir)
1810
+ ).convert("RGBA")
1811
+ image = image_rgba.convert("RGB")
1812
+
1813
+ # REVIEW How many colors should I allow? Should I make this
1814
+ # configurable?
1815
+ COLORS = 16 - len(partial_palette)
1816
+ logger.debug(f"quantizing to {COLORS} color(s)")
1817
+ # Reduce colors with quantization and dithering
1818
+ image = image.quantize(
1819
+ colors=COLORS,
1820
+ palette=image.quantize(
1821
+ colors=COLORS,
1822
+ method=Image.Quantize.MAXCOVERAGE,
1823
+ ),
1824
+ dither=Image.Dither.FLOYDSTEINBERG,
1825
+ )
1826
+ # Further reduce colors to conform to 12-bit RGB palette
1827
+ image.putpalette([
1828
+ # HACK The RGB values of the colors that show up in CDG
1829
+ # players are repdigits in hexadecimal - 0x00, 0x11, 0x22,
1830
+ # 0x33, etc. This means that we can simply round each value
1831
+ # to the nearest multiple of 0x11 (17 in decimal).
1832
+ 0x11 * round(v / 0x11)
1833
+ for v in image.getpalette()
1834
+ ])
1835
+ image = image.quantize()
1836
+ logger.debug(f"image uses {max(image.getdata()) + 1} color(s)")
1837
+
1838
+ if partial_palette:
1839
+ logger.debug(
1840
+ f"prepending {len(partial_palette)} color(s) to palette"
1841
+ )
1842
+ # Add offset to color indices
1843
+ image.putdata(image.getdata(), offset=len(partial_palette))
1844
+ # Place other colors in palette
1845
+ image.putpalette(
1846
+ list(it.chain(*partial_palette)) + image.getpalette()
1847
+ )
1848
+
1849
+ logger.debug(
1850
+ f"palette: {list(batched(image.getpalette(), 3))!r}"
1851
+ )
1852
+
1853
+ logger.debug("masking out non-transparent parts of image")
1854
+ # Create mask for non-transparent parts of image
1855
+ # NOTE We allow alpha values from 128 to 255 (half-transparent
1856
+ # to opaque).
1857
+ mask = Image.new("1", image_rgba.size, 0)
1858
+ mask.putdata([
1859
+ 0 if pixel >= 128 else 255
1860
+ for pixel in image_rgba.getdata(band=3)
1861
+ ])
1862
+ # Set transparent parts of background to 0
1863
+ image.paste(Image.new("P", image.size, 0), mask=mask)
1864
+
1865
+ return image
1866
+
1867
+ def _gradient_to_tile_positions(
1868
+ self,
1869
+ image: Image.Image,
1870
+ ) -> list[tuple[int, int]]:
1871
+ """
1872
+ Convert an image of a gradient to an ordering of tile positions.
1873
+
1874
+ The closer a section of the image is to white, the earlier it
1875
+ will appear. The closer a section of the image is to black, the
1876
+ later it will appear. The image is converted to `L` mode before
1877
+ processing.
1878
+
1879
+ Parameters
1880
+ ----------
1881
+ image : `PIL.Image.Image`
1882
+ Image to convert.
1883
+
1884
+ Returns
1885
+ -------
1886
+ list of tuple of (int, int)
1887
+ Tile positions in order.
1888
+ """
1889
+ image = image.convert("L")
1890
+ intensities: dict[tuple[int, int], int] = {}
1891
+ for tile_y, tile_x in it.product(
1892
+ range(CDG_SCREEN_HEIGHT // CDG_TILE_HEIGHT),
1893
+ range(CDG_SCREEN_WIDTH // CDG_TILE_WIDTH),
1894
+ ):
1895
+ # NOTE The intensity is negative so that, when it's sorted,
1896
+ # it will be sorted from highest intensity to lowest. This
1897
+ # is not done with reverse=True to preserve the sort's
1898
+ # stability.
1899
+ intensities[(tile_y, tile_x)] = -sum(
1900
+ image.getpixel((
1901
+ tile_x * CDG_TILE_WIDTH + x,
1902
+ tile_y * CDG_TILE_HEIGHT + y,
1903
+ ))
1904
+ for x in range(CDG_TILE_WIDTH)
1905
+ for y in range(CDG_TILE_HEIGHT)
1906
+ )
1907
+ return sorted(intensities, key=intensities.get)
1908
+ # !SECTION
1909
+ #endregion
1910
+
1911
+ def main():
1912
+ # TODO Make the logging level configurable from the command line
1913
+ logging.basicConfig(level=logging.DEBUG)
1914
+
1915
+ kc = KaraokeComposer.from_file(sys.argv[1])
1916
+ kc.compose()
1917
+
1918
+ if __name__ == "__main__":
1919
+ main()