lyrics-transcriber 0.37.0__py3-none-any.whl → 0.39.0__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 (28) hide show
  1. lyrics_transcriber/correction/handlers/extend_anchor.py +13 -2
  2. lyrics_transcriber/correction/handlers/word_operations.py +8 -2
  3. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js +26696 -0
  4. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +1 -0
  5. lyrics_transcriber/frontend/dist/index.html +1 -1
  6. lyrics_transcriber/frontend/package.json +3 -2
  7. lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -0
  8. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +36 -13
  9. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +41 -1
  10. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +48 -16
  11. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +71 -16
  12. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +11 -7
  13. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +45 -12
  14. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +83 -19
  15. lyrics_transcriber/frontend/src/components/shared/types.ts +3 -0
  16. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +65 -9
  17. lyrics_transcriber/frontend/vite.config.js +4 -0
  18. lyrics_transcriber/frontend/vite.config.ts +4 -0
  19. lyrics_transcriber/lyrics/genius.py +41 -12
  20. lyrics_transcriber/output/cdg.py +1 -0
  21. lyrics_transcriber/output/cdgmaker/composer.py +839 -534
  22. lyrics_transcriber/review/server.py +10 -12
  23. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/METADATA +3 -2
  24. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/RECORD +27 -26
  25. lyrics_transcriber/frontend/dist/assets/index-BNNbsbVN.js +0 -182
  26. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/LICENSE +0 -0
  27. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/WHEEL +0 -0
  28. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/entry_points.txt +0 -0
@@ -8,6 +8,9 @@ import sys
8
8
  import tomllib
9
9
  from typing import NamedTuple, Self, TYPE_CHECKING, cast, Iterable, TypeVar
10
10
  from zipfile import ZipFile
11
+
12
+ import ffmpeg
13
+
11
14
  if TYPE_CHECKING:
12
15
  from _typeshed import FileDescriptorOrPath, StrOrBytesPath
13
16
 
@@ -22,13 +25,30 @@ from .pack import *
22
25
  from .render import *
23
26
  from .utils import *
24
27
 
25
-
26
28
  import logging
29
+
27
30
  logger = logging.getLogger(__name__)
28
31
 
32
+ ASS_REQUIREMENTS = True
33
+ try:
34
+ import ass
35
+ from fontTools import ttLib
36
+
37
+ from datetime import timedelta
38
+ except ImportError:
39
+ ASS_REQUIREMENTS = False
40
+
41
+ MP4_REQUIREMENTS = True
42
+ try:
43
+ import ffmpeg
44
+ except ImportError:
45
+ MP4_REQUIREMENTS = False
46
+
47
+
29
48
  package_dir = Path(__file__).parent
30
49
 
31
- T = TypeVar('T')
50
+ T = TypeVar("T")
51
+
32
52
 
33
53
  def batched(iterable: Iterable[T], n: int) -> Iterable[tuple[T, ...]]:
34
54
  "Batch data into tuples of length n. The last batch may be shorter."
@@ -40,9 +60,10 @@ def batched(iterable: Iterable[T], n: int) -> Iterable[tuple[T, ...]]:
40
60
  return
41
61
  yield batch
42
62
 
63
+
43
64
  def file_relative_to(
44
- filepath: "StrOrBytesPath | Path",
45
- *relative_to: "StrOrBytesPath | Path",
65
+ filepath: "StrOrBytesPath | Path",
66
+ *relative_to: "StrOrBytesPath | Path",
46
67
  ) -> Path:
47
68
  """
48
69
  Convert possibly relative filepath to absolute path, relative to any
@@ -99,6 +120,23 @@ def sync_to_cdg(cs: int) -> int:
99
120
  return cs * CDG_FPS // 100
100
121
 
101
122
 
123
+ def cdg_to_sync(fs: int) -> int:
124
+ """
125
+ Convert CDG frame time to sync time to the nearest centisecond.
126
+
127
+ Parameters
128
+ ----------
129
+ fs : int
130
+ Time in CDG frames.
131
+
132
+ Returns
133
+ -------
134
+ int
135
+ Equivalent time in centiseconds (100ths of a second).
136
+ """
137
+ return fs * 100 // CDG_FPS
138
+
139
+
102
140
  @define
103
141
  class SyllableInfo:
104
142
  mask: Image.Image
@@ -160,12 +198,12 @@ class KaraokeComposer:
160
198
  BORDER = 1
161
199
  UNUSED_COLOR = (0, 0, 0)
162
200
 
163
- #region Constructors
201
+ # region Constructors
164
202
  # SECTION Constructors
165
203
  def __init__(
166
- self,
167
- config: Settings,
168
- relative_dir: "StrOrBytesPath | Path" = "",
204
+ self,
205
+ config: Settings,
206
+ relative_dir: "StrOrBytesPath | Path" = "",
169
207
  ):
170
208
  self.config = config
171
209
  self.relative_dir = Path(relative_dir)
@@ -201,15 +239,21 @@ class KaraokeComposer:
201
239
  self.UNUSED_COLOR,
202
240
  ]
203
241
  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
- ))
242
+ self.color_table.extend(
243
+ [
244
+ singer.inactive_fill,
245
+ singer.inactive_stroke,
246
+ singer.active_fill,
247
+ singer.active_stroke,
248
+ ]
249
+ )
250
+ self.color_table = list(
251
+ pad(
252
+ self.color_table,
253
+ 16,
254
+ padvalue=self.UNUSED_COLOR,
255
+ )
256
+ )
213
257
  logger.debug(f"Color table: {self.color_table}")
214
258
 
215
259
  self.max_tile_height = 0
@@ -246,7 +290,7 @@ class KaraokeComposer:
246
290
  )
247
291
  ]
248
292
 
249
- # logger.debug(f"singer {singer}: {syllables}")
293
+ logger.debug(f"singer {singer}: {syllables}")
250
294
  lines.append(syllables)
251
295
  line_singers.append(singer)
252
296
 
@@ -274,19 +318,21 @@ class KaraokeComposer:
274
318
  lyric_lines: list[LineInfo] = []
275
319
  sync_i = 0
276
320
  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
- )):
321
+ for li, (line, singer, line_image, line_mask) in enumerate(
322
+ zip(
323
+ lines,
324
+ line_singers,
325
+ line_images,
326
+ line_masks,
327
+ )
328
+ ):
280
329
  # Center line horizontally
281
330
  x = (CDG_SCREEN_WIDTH - line_image.width) // 2
282
331
  # 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
- )
332
+ y = lyric.row * CDG_TILE_HEIGHT + ((li % lyric.lines_per_page) * lyric.line_tile_height * CDG_TILE_HEIGHT)
287
333
 
288
334
  # Get enough sync points for this line's syllables
289
- line_sync = lyric.sync[sync_i:sync_i + len(line)]
335
+ line_sync = lyric.sync[sync_i : sync_i + len(line)]
290
336
  sync_i += len(line)
291
337
  if line_sync:
292
338
  # The last syllable ends 0.45 seconds after it
@@ -303,11 +349,13 @@ class KaraokeComposer:
303
349
 
304
350
  # Collect this line's syllables
305
351
  syllables: list[SyllableInfo] = []
306
- for si, (mask, syllable, (start, end)) in enumerate(zip(
307
- line_mask,
308
- line,
309
- it.pairwise(line_sync),
310
- )):
352
+ for si, (mask, syllable, (start, end)) in enumerate(
353
+ zip(
354
+ line_mask,
355
+ line,
356
+ it.pairwise(line_sync),
357
+ )
358
+ ):
311
359
  # NOTE Left and right edges here are relative to the
312
360
  # mask. They will be stored relative to the screen.
313
361
  left_edge, right_edge = 0, 0
@@ -315,48 +363,46 @@ class KaraokeComposer:
315
363
  if bbox is not None:
316
364
  left_edge, _, right_edge, _ = bbox
317
365
 
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,
366
+ syllables.append(
367
+ SyllableInfo(
368
+ mask=mask,
369
+ text=syllable,
370
+ start_offset=sync_to_cdg(start),
371
+ end_offset=sync_to_cdg(end),
372
+ left_edge=left_edge + x,
373
+ right_edge=right_edge + x,
374
+ lyric_index=ci,
375
+ line_index=li,
376
+ syllable_index=si,
377
+ )
378
+ )
379
+
380
+ lyric_lines.append(
381
+ LineInfo(
382
+ image=line_image,
383
+ text="".join(line),
384
+ syllables=syllables,
385
+ x=x,
386
+ y=y,
387
+ singer=singer,
325
388
  lyric_index=ci,
326
389
  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
- ))
390
+ )
391
+ )
340
392
 
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
- ))
393
+ self.lyrics.append(
394
+ LyricInfo(
395
+ lines=lyric_lines,
396
+ line_tile_height=tile_height,
397
+ lines_per_page=lyric.lines_per_page,
398
+ lyric_index=ci,
399
+ )
400
+ )
347
401
 
348
402
  # 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
- )
403
+ max_height = max(line.image.height for lyric in self.lyrics for line in lyric.lines)
404
+ line_offset = (self.max_tile_height * CDG_TILE_HEIGHT - max_height) // 2
405
+ logger.debug(f"lines will be vertically offset by {line_offset} pixel(s)")
360
406
  if line_offset:
361
407
  for lyric in self.lyrics:
362
408
  for line in lyric.lines:
@@ -371,8 +417,8 @@ class KaraokeComposer:
371
417
 
372
418
  @classmethod
373
419
  def from_file(
374
- cls,
375
- file: "FileDescriptorOrPath",
420
+ cls,
421
+ file: "FileDescriptorOrPath",
376
422
  ) -> Self:
377
423
  converter = Converter(prefer_attrib_converters=True)
378
424
  relative_dir = Path(file).parent
@@ -384,22 +430,24 @@ class KaraokeComposer:
384
430
 
385
431
  @classmethod
386
432
  def from_string(
387
- cls,
388
- config: str,
389
- relative_dir: "StrOrBytesPath | Path" = "",
433
+ cls,
434
+ config: str,
435
+ relative_dir: "StrOrBytesPath | Path" = "",
390
436
  ) -> Self:
391
437
  converter = Converter(prefer_attrib_converters=True)
392
438
  return cls(
393
439
  converter.structure(tomllib.loads(config), Settings),
394
440
  relative_dir=relative_dir,
395
441
  )
442
+
396
443
  # !SECTION
397
- #endregion
444
+ # endregion
398
445
 
399
- #region Set draw times
446
+ # region Set draw times
400
447
  # SECTION Set draw times
401
448
  # Gap between line draw/erase events = 1/6 second
402
449
  LINE_DRAW_ERASE_GAP = CDG_FPS // 6
450
+
403
451
  # TODO Make more values in these set-draw-times functions into named
404
452
  # constants
405
453
 
@@ -413,11 +461,7 @@ class KaraokeComposer:
413
461
 
414
462
  # The first page is drawn 3 seconds before the first
415
463
  # 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
- ))
464
+ first_syllable = next(iter(syllable_info for line_info in lyric.lines for syllable_info in line_info.syllables))
421
465
  draw_time = first_syllable.start_offset - 900
422
466
  for i in range(lyric.lines_per_page):
423
467
  if i < line_count:
@@ -425,11 +469,7 @@ class KaraokeComposer:
425
469
  draw_time += self.LINE_DRAW_ERASE_GAP
426
470
 
427
471
  # 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
- ):
472
+ for last_wipe, wipe in it.pairwise(syllable_info for line_info in lyric.lines for syllable_info in line_info.syllables):
433
473
  # Skip if not on a line boundary
434
474
  if wipe.line_index <= last_wipe.line_index:
435
475
  continue
@@ -438,21 +478,24 @@ class KaraokeComposer:
438
478
  match self.config.clear_mode:
439
479
  case LyricClearMode.PAGE:
440
480
  self._set_draw_times_page(
441
- last_wipe, wipe,
481
+ last_wipe,
482
+ wipe,
442
483
  lyric=lyric,
443
484
  line_draw=line_draw,
444
485
  line_erase=line_erase,
445
486
  )
446
487
  case LyricClearMode.LINE_EAGER:
447
488
  self._set_draw_times_line_eager(
448
- last_wipe, wipe,
489
+ last_wipe,
490
+ wipe,
449
491
  lyric=lyric,
450
492
  line_draw=line_draw,
451
493
  line_erase=line_erase,
452
494
  )
453
495
  case LyricClearMode.LINE_DELAYED | _:
454
496
  self._set_draw_times_line_delayed(
455
- last_wipe, wipe,
497
+ last_wipe,
498
+ wipe,
456
499
  lyric=lyric,
457
500
  line_draw=line_draw,
458
501
  line_erase=line_erase,
@@ -470,25 +513,23 @@ class KaraokeComposer:
470
513
  line_erase[end_line] = erase_time
471
514
  erase_time += self.LINE_DRAW_ERASE_GAP
472
515
 
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}"
516
+ logger.debug(f"lyric {lyric.lyric_index} draw times: {line_draw!r}")
517
+ logger.debug(f"lyric {lyric.lyric_index} erase times: {line_erase!r}")
518
+ self.lyric_times.append(
519
+ LyricTimes(
520
+ line_draw=line_draw,
521
+ line_erase=line_erase,
522
+ )
478
523
  )
479
- self.lyric_times.append(LyricTimes(
480
- line_draw=line_draw,
481
- line_erase=line_erase,
482
- ))
483
524
  logger.info("draw times set")
484
525
 
485
526
  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],
527
+ self,
528
+ last_wipe: SyllableInfo,
529
+ wipe: SyllableInfo,
530
+ lyric: LyricInfo,
531
+ line_draw: list[int],
532
+ line_erase: list[int],
492
533
  ):
493
534
  line_count = len(lyric.lines)
494
535
  last_page = last_wipe.line_index // lyric.lines_per_page
@@ -516,10 +557,7 @@ class KaraokeComposer:
516
557
 
517
558
  # Warn the user if there's not likely to be enough time
518
559
  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
- )
560
+ logger.warning("not enough bandwidth to clear screen on lyric " f"{wipe.lyric_index} line {wipe.line_index}")
523
561
 
524
562
  # If there's not enough time between the end of the last line
525
563
  # and the start of this line, but there is enough time between
@@ -542,12 +580,12 @@ class KaraokeComposer:
542
580
  page_draw_time += self.LINE_DRAW_ERASE_GAP
543
581
 
544
582
  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],
583
+ self,
584
+ last_wipe: SyllableInfo,
585
+ wipe: SyllableInfo,
586
+ lyric: LyricInfo,
587
+ line_draw: list[int],
588
+ line_erase: list[int],
551
589
  ):
552
590
  line_count = len(lyric.lines)
553
591
  last_page = last_wipe.line_index // lyric.lines_per_page
@@ -614,12 +652,12 @@ class KaraokeComposer:
614
652
  draw_time += self.LINE_DRAW_ERASE_GAP
615
653
 
616
654
  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],
655
+ self,
656
+ last_wipe: SyllableInfo,
657
+ wipe: SyllableInfo,
658
+ lyric: LyricInfo,
659
+ line_draw: list[int],
660
+ line_erase: list[int],
623
661
  ):
624
662
  line_count = len(lyric.lines)
625
663
  last_page = last_wipe.line_index // lyric.lines_per_page
@@ -649,9 +687,7 @@ class KaraokeComposer:
649
687
  )
650
688
  inter_wipe_time = wipe.start_offset - last_wipe_end
651
689
 
652
- last_line_start_offset = lyric.lines[
653
- last_wipe.line_index
654
- ].syllables[0].start_offset
690
+ last_line_start_offset = lyric.lines[last_wipe.line_index].syllables[0].start_offset
655
691
 
656
692
  # The last line will be erased at the earlier of:
657
693
  # - 1/3 seconds after the start of this line
@@ -722,29 +758,26 @@ class KaraokeComposer:
722
758
  if j < line_count:
723
759
  line_draw[j] = draw_time
724
760
  draw_time += self.LINE_DRAW_ERASE_GAP
761
+
725
762
  # !SECTION
726
- #endregion
763
+ # endregion
727
764
 
728
- #region Compose words
765
+ # region Compose words
729
766
  # SECTION Compose words
730
767
  def compose(self):
731
768
  try:
732
769
  # NOTE Logistically, multiple simultaneous lyric sets doesn't
733
770
  # 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
- )
771
+ if self.config.clear_mode == LyricClearMode.PAGE and len(self.lyrics) > 1:
772
+ raise RuntimeError("page mode doesn't support more than one lyric set")
741
773
 
742
774
  logger.debug("loading song file")
743
- song: AudioSegment = AudioSegment.from_file(
744
- file_relative_to(self.config.file, self.relative_dir)
745
- )
775
+ song: AudioSegment = AudioSegment.from_file(file_relative_to(self.config.file, self.relative_dir))
746
776
  logger.info("song file loaded")
747
777
 
778
+ self.lyric_packet_indices: set[int] = set()
779
+ self.instrumental_times: list[int] = []
780
+
748
781
  self.intro_delay = 0
749
782
  # Compose the intro
750
783
  # NOTE This also sets the intro delay for later.
@@ -752,14 +785,16 @@ class KaraokeComposer:
752
785
 
753
786
  lyric_states: list[LyricState] = []
754
787
  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
- ))
788
+ lyric_states.append(
789
+ LyricState(
790
+ line_draw=0,
791
+ line_erase=0,
792
+ syllable_line=0,
793
+ syllable_index=0,
794
+ draw_queue=deque(),
795
+ highlight_queue=deque(),
796
+ )
797
+ )
763
798
 
764
799
  composer_state = ComposerState(
765
800
  instrumental=0,
@@ -772,16 +807,11 @@ class KaraokeComposer:
772
807
  # intro, the screen should not be cleared. The way I'm detecting
773
808
  # this, however, is by (mostly) copy-pasting the code that
774
809
  # 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
- )
810
+ current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
779
811
  should_instrumental = False
780
812
  instrumental = None
781
813
  if composer_state.instrumental < len(self.config.instrumentals):
782
- instrumental = self.config.instrumentals[
783
- composer_state.instrumental
784
- ]
814
+ instrumental = self.config.instrumentals[composer_state.instrumental]
785
815
  instrumental_time = sync_to_cdg(instrumental.sync)
786
816
  # NOTE Normally, this part has code to handle waiting for a
787
817
  # lyric to finish. If there's an instrumental this early,
@@ -791,11 +821,12 @@ class KaraokeComposer:
791
821
  if not should_instrumental:
792
822
  logger.debug("instrumental intro is not present; clearing")
793
823
  # 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}")
824
+ self.writer.queue_packets(
825
+ [
826
+ *memory_preset_repeat(self.BACKGROUND),
827
+ *load_color_table(self.color_table),
828
+ ]
829
+ )
799
830
  if self.config.border is not None:
800
831
  self.writer.queue_packet(border_preset(self.BORDER))
801
832
  else:
@@ -815,9 +846,7 @@ class KaraokeComposer:
815
846
  self.lyric_times,
816
847
  lyric_states,
817
848
  )
818
- ) or (
819
- composer_state.instrumental < len(self.config.instrumentals)
820
- ):
849
+ ) or (composer_state.instrumental < len(self.config.instrumentals)):
821
850
  for lyric, times, state in zip(
822
851
  self.lyrics,
823
852
  self.lyric_times,
@@ -837,15 +866,13 @@ class KaraokeComposer:
837
866
  self.intro_delay * 1000 // CDG_FPS,
838
867
  frame_rate=song.frame_rate,
839
868
  )
840
- padded_song = intro_silence + song
869
+ self.audio = intro_silence + song
841
870
 
842
871
  # NOTE If video padding is not added to the end of the song, the
843
872
  # outro (or next instrumental section) begins immediately after
844
873
  # the end of the last syllable, which would be abrupt.
845
874
  if self.config.clear_mode == LyricClearMode.PAGE:
846
- logger.debug(
847
- "clear mode is page; adding padding before outro"
848
- )
875
+ logger.debug("clear mode is page; adding padding before outro")
849
876
  self.writer.queue_packets([no_instruction()] * 3 * CDG_FPS)
850
877
 
851
878
  # Calculate video padding before outro
@@ -854,16 +881,12 @@ class KaraokeComposer:
854
881
  # - The end of the audio (with the padded intro)
855
882
  # - 8 seconds after the current video time
856
883
  end = max(
857
- int(padded_song.duration_seconds * CDG_FPS),
884
+ int(self.audio.duration_seconds * CDG_FPS),
858
885
  self.writer.packets_queued + OUTRO_DURATION,
859
886
  )
860
887
  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
- )
888
+ padding_before_outro = (end - OUTRO_DURATION) - self.writer.packets_queued
889
+ logger.debug(f"queueing {padding_before_outro} packets before outro")
867
890
  self.writer.queue_packets([no_instruction()] * padding_before_outro)
868
891
 
869
892
  # Compose the outro (and thus, finish the video)
@@ -873,13 +896,10 @@ class KaraokeComposer:
873
896
  # Add audio padding to outro (and thus, finish the audio)
874
897
  logger.debug("padding outro of audio file")
875
898
  outro_silence: AudioSegment = AudioSegment.silent(
876
- (
877
- (self.writer.packets_queued * 1000 // CDG_FPS)
878
- - int(padded_song.duration_seconds * 1000)
879
- ),
899
+ ((self.writer.packets_queued * 1000 // CDG_FPS) - int(self.audio.duration_seconds * 1000)),
880
900
  frame_rate=song.frame_rate,
881
901
  )
882
- padded_song += outro_silence
902
+ self.audio += outro_silence
883
903
 
884
904
  # Write CDG and MP3 data to ZIP file
885
905
  outname = self.config.outname
@@ -889,18 +909,14 @@ class KaraokeComposer:
889
909
  cdg_bytes = BytesIO()
890
910
  logger.debug("writing cdg packets to stream")
891
911
  self.writer.write_packets(cdg_bytes)
892
- logger.debug(
893
- f"writing stream to zipfile as {outname}.cdg"
894
- )
912
+ logger.debug(f"writing stream to zipfile as {outname}.cdg")
895
913
  cdg_bytes.seek(0)
896
914
  zipfile.writestr(f"{outname}.cdg", cdg_bytes.read())
897
915
 
898
916
  mp3_bytes = BytesIO()
899
917
  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
- )
918
+ self.audio.export(mp3_bytes, format="mp3")
919
+ logger.debug(f"writing stream to zipfile as {outname}.mp3")
904
920
  mp3_bytes.seek(0)
905
921
  zipfile.writestr(f"{outname}.mp3", mp3_bytes.read())
906
922
  logger.info(f"karaoke files written to {zipfile_name}")
@@ -909,17 +925,14 @@ class KaraokeComposer:
909
925
  raise
910
926
 
911
927
  def _compose_lyric(
912
- self,
913
- lyric: LyricInfo,
914
- times: LyricTimes,
915
- state: LyricState,
916
- lyric_states: list[LyricState],
917
- composer_state: ComposerState,
928
+ self,
929
+ lyric: LyricInfo,
930
+ times: LyricTimes,
931
+ state: LyricState,
932
+ lyric_states: list[LyricState],
933
+ composer_state: ComposerState,
918
934
  ):
919
- current_time = (
920
- self.writer.packets_queued - self.sync_offset
921
- - self.intro_delay
922
- )
935
+ current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
923
936
 
924
937
  should_draw_this_line = False
925
938
  line_draw_info, line_draw_time = None, None
@@ -936,20 +949,12 @@ class KaraokeComposer:
936
949
  should_erase_this_line = current_time >= line_erase_time
937
950
 
938
951
  # 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
- ):
952
+ if self.config.clear_mode == LyricClearMode.PAGE and should_draw_this_line:
943
953
  composer_state.last_page = composer_state.this_page
944
- composer_state.this_page = (
945
- line_draw_info.line_index // lyric.lines_per_page
946
- )
954
+ composer_state.this_page = line_draw_info.line_index // lyric.lines_per_page
947
955
  # If this line is the start of a new page
948
956
  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
- )
957
+ logger.debug(f"going from page {composer_state.last_page} to " f"page {composer_state.this_page} in page mode")
953
958
  # If we have not just cleared the screen
954
959
  if not composer_state.just_cleared:
955
960
  logger.debug("clearing screen on page transition")
@@ -959,6 +964,12 @@ class KaraokeComposer:
959
964
  ]
960
965
  if self.config.border is not None:
961
966
  page_clear_packets.append(border_preset(self.BORDER))
967
+ self.lyric_packet_indices.update(
968
+ range(
969
+ self.writer.packets_queued,
970
+ self.writer.packets_queued + len(page_clear_packets),
971
+ )
972
+ )
962
973
  self.writer.queue_packets(page_clear_packets)
963
974
  composer_state.just_cleared = True
964
975
  # Update the current frame time
@@ -969,37 +980,37 @@ class KaraokeComposer:
969
980
  # Queue the erasing of this line if necessary
970
981
  if should_erase_this_line:
971
982
  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
- # )
983
+ logger.debug(
984
+ f"t={self.writer.packets_queued}: erasing lyric " f"{line_erase_info.lyric_index} line " f"{line_erase_info.line_index}"
985
+ )
977
986
  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
- ))
987
+ state.draw_queue.extend(
988
+ line_image_to_packets(
989
+ line_erase_info.image,
990
+ xy=(line_erase_info.x, line_erase_info.y),
991
+ background=self.BACKGROUND,
992
+ erase=True,
993
+ )
994
+ )
984
995
  else:
985
996
  logger.debug("line is blank; not erased")
986
997
  state.line_erase += 1
987
998
  # Queue the drawing of this line if necessary
988
999
  if should_draw_this_line:
989
1000
  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
- # )
1001
+ logger.debug(
1002
+ f"t={self.writer.packets_queued}: drawing lyric " f"{line_draw_info.lyric_index} line " f"{line_draw_info.line_index}"
1003
+ )
995
1004
  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
- ))
1005
+ state.draw_queue.extend(
1006
+ line_image_to_packets(
1007
+ line_draw_info.image,
1008
+ xy=(line_draw_info.x, line_draw_info.y),
1009
+ fill=line_draw_info.singer << 2 | 0,
1010
+ stroke=line_draw_info.singer << 2 | 1,
1011
+ background=self.BACKGROUND,
1012
+ )
1013
+ )
1003
1014
  else:
1004
1015
  logger.debug("line is blank; not drawn")
1005
1016
  state.line_draw += 1
@@ -1015,119 +1026,128 @@ class KaraokeComposer:
1015
1026
  should_highlight = False
1016
1027
  syllable_info = None
1017
1028
  if state.syllable_line < len(lyric.lines):
1018
- syllable_info = (
1019
- lyric.lines[state.syllable_line]
1020
- .syllables[state.syllable_index]
1021
- )
1029
+ syllable_info = lyric.lines[state.syllable_line].syllables[state.syllable_index]
1022
1030
  should_highlight = current_time >= syllable_info.start_offset
1023
1031
  # If this syllable should be highlighted now
1024
1032
  if should_highlight:
1025
1033
  assert syllable_info is not None
1026
1034
  if syllable_info.text.strip():
1027
1035
  # 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
- ))
1036
+ state.highlight_queue.extend(
1037
+ self._compose_highlight(
1038
+ lyric=lyric,
1039
+ syllable=syllable_info,
1040
+ current_time=current_time,
1041
+ )
1042
+ )
1033
1043
 
1034
1044
  # Advance to the next syllable
1035
1045
  state.syllable_index += 1
1036
- if state.syllable_index >= len(
1037
- lyric.lines[state.syllable_line].syllables
1038
- ):
1046
+ if state.syllable_index >= len(lyric.lines[state.syllable_line].syllables):
1039
1047
  state.syllable_index = 0
1040
1048
  state.syllable_line += 1
1041
1049
 
1042
1050
  should_instrumental = False
1043
1051
  instrumental = None
1044
1052
  if composer_state.instrumental < len(self.config.instrumentals):
1045
- instrumental = self.config.instrumentals[
1046
- composer_state.instrumental
1047
- ]
1053
+ instrumental = self.config.instrumentals[composer_state.instrumental]
1054
+ # TODO Improve this code for waiting to start instrumentals!
1055
+ # It's a mess!
1048
1056
  instrumental_time = sync_to_cdg(instrumental.sync)
1057
+ # If instrumental time is to be interpreted as waiting for
1058
+ # syllable to end
1049
1059
  if instrumental.wait:
1050
- syllable_iter = iter(
1051
- syll
1052
- for line_info in lyric.lines
1053
- for syll in line_info.syllables
1054
- )
1060
+ syllable_iter = iter(syll for line_info in lyric.lines for syll in line_info.syllables)
1055
1061
  last_syllable = next(syllable_iter)
1056
1062
  # 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
1063
+ while last_syllable is not None and last_syllable.start_offset < instrumental_time:
1064
+ last_syllable = next(syllable_iter, None)
1065
+ # If syllable was not found
1066
+ if last_syllable is None:
1067
+ # Make sure the instrumental won't play
1068
+ # FIXME This happens when the instrumental is
1069
+ # happening after some syllable in another lyric.
1070
+ # What's a better way to handle this?
1071
+ instrumental_time = float("inf")
1072
+ # If syllable was found
1073
+ else:
1074
+ first_syllable = lyric.lines[last_syllable.line_index].syllables[0]
1075
+ # If this line is being actively sung
1076
+ if current_time >= first_syllable.start_offset:
1077
+ # If this is the last syllable in this line
1078
+ if last_syllable.syllable_index == len(lyric.lines[last_syllable.line_index].syllables) - 1:
1079
+ instrumental_time = 0
1080
+ if times.line_erase:
1081
+ # Wait for this line to be erased
1082
+ instrumental_time = times.line_erase[last_syllable.line_index]
1083
+ if not instrumental_time:
1084
+ # Add 1.5 seconds
1085
+ # XXX This is hardcoded.
1086
+ instrumental_time = last_syllable.end_offset + 450
1087
+ else:
1088
+ logger.debug("forcing next instrumental not to " "wait; it does not occur at or before " "the end of this line")
1089
+ instrumental.wait = False
1085
1090
  should_instrumental = current_time >= instrumental_time
1086
-
1087
1091
  # If there should be an instrumental section now
1088
1092
  if should_instrumental:
1089
1093
  assert instrumental is not None
1090
- logger.info("_compose_lyric: Time for an instrumental section")
1094
+ logger.debug("time for an instrumental section")
1091
1095
  if instrumental.wait:
1092
- logger.info("_compose_lyric: This instrumental section waited for the previous line to finish")
1096
+ logger.debug("this instrumental section waited for the previous " "line to finish")
1093
1097
  else:
1094
- logger.info("_compose_lyric: This instrumental did not wait for the previous line to finish")
1098
+ logger.debug("this instrumental did not wait for the previous " "line to finish")
1095
1099
 
1096
- logger.debug("_compose_lyric: Purging all highlight/draw queues")
1100
+ logger.debug("purging all highlight/draw queues")
1097
1101
  for st in lyric_states:
1102
+ # If instrumental has waited for this syllable to end
1098
1103
  if instrumental.wait:
1099
- if st.highlight_queue:
1100
- logger.warning("_compose_lyric: Unexpected items in highlight queue when instrumental waited")
1104
+ # There shouldn't be anything in the highlight queue
1105
+ assert not st.highlight_queue
1106
+ # If there's anything left in the draw queue
1101
1107
  if st.draw_queue:
1108
+ # NOTE If the current lyric state has anything
1109
+ # left in the draw queue, it should be the
1110
+ # erasing of the current line.
1102
1111
  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")
1112
+ assert should_erase_this_line
1113
+ # Queue everything left in the draw queue
1114
+ # immediately
1115
+ self.lyric_packet_indices.update(
1116
+ range(
1117
+ self.writer.packets_queued,
1118
+ self.writer.packets_queued + len(st.draw_queue),
1119
+ )
1120
+ )
1106
1121
  self.writer.queue_packets(st.draw_queue)
1107
1122
 
1123
+ # Purge highlight/draw queues
1108
1124
  st.highlight_queue.clear()
1109
1125
  st.draw_queue.clear()
1110
1126
 
1111
- logger.debug("_compose_lyric: Determining instrumental end time")
1127
+ # The instrumental should end when the next line is drawn by
1128
+ # default
1112
1129
  if line_draw_time is not None:
1113
1130
  instrumental_end = line_draw_time
1114
1131
  else:
1132
+ # NOTE A value of None here means this instrumental will
1133
+ # never end (and once the screen is drawn, it will not
1134
+ # pause), unless there is another instrumental after
1135
+ # this.
1115
1136
  instrumental_end = None
1116
- logger.debug(f"_compose_lyric: instrumental_end={instrumental_end}")
1117
1137
 
1118
1138
  composer_state.instrumental += 1
1119
1139
  next_instrumental = None
1120
1140
  if composer_state.instrumental < len(self.config.instrumentals):
1121
- next_instrumental = self.config.instrumentals[
1122
- composer_state.instrumental
1123
- ]
1124
-
1141
+ next_instrumental = self.config.instrumentals[composer_state.instrumental]
1125
1142
  should_clear = True
1143
+ # If there is a next instrumental
1126
1144
  if next_instrumental is not None:
1127
1145
  next_instrumental_time = sync_to_cdg(next_instrumental.sync)
1128
- logger.debug(f"_compose_lyric: next_instrumental_time={next_instrumental_time}")
1146
+ # If the next instrumental is immediately after this one
1129
1147
  if instrumental_end is None or next_instrumental_time <= instrumental_end:
1148
+ # This instrumental should end there
1130
1149
  instrumental_end = next_instrumental_time
1150
+ # Don't clear the screen afterwards
1131
1151
  should_clear = False
1132
1152
  else:
1133
1153
  if line_draw_time is None:
@@ -1142,16 +1162,20 @@ class KaraokeComposer:
1142
1162
 
1143
1163
  if should_clear:
1144
1164
  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
- ])
1165
+ self.writer.queue_packets(
1166
+ [
1167
+ *memory_preset_repeat(self.BACKGROUND),
1168
+ *load_color_table(self.color_table),
1169
+ ]
1170
+ )
1149
1171
  logger.debug(f"_compose_lyric: Loaded color table: {self.color_table}")
1150
1172
  if self.config.border is not None:
1151
1173
  self.writer.queue_packet(border_preset(self.BORDER))
1152
1174
  composer_state.just_cleared = True
1153
1175
  else:
1154
- logger.debug("_compose_lyric: Not clearing screen after instrumental")
1176
+ logger.debug("not clearing screen after instrumental")
1177
+ # Advance to the next instrumental section
1178
+ instrumental = next_instrumental
1155
1179
  return
1156
1180
 
1157
1181
  composer_state.just_cleared = False
@@ -1164,9 +1188,14 @@ class KaraokeComposer:
1164
1188
  group = state.highlight_queue.popleft()
1165
1189
  highlight_groups.append(list(pad(group, self.max_tile_height)))
1166
1190
  # 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
1191
+ draw_groups: list[list[CDGPacket | None]] = [[None] * self.max_tile_height] * self.config.draw_bandwidth
1192
+
1193
+ self.lyric_packet_indices.update(
1194
+ range(
1195
+ self.writer.packets_queued,
1196
+ self.writer.packets_queued + len(list(it.chain(*highlight_groups, *draw_groups))),
1197
+ )
1198
+ )
1170
1199
 
1171
1200
  # Intersperse the highlight and draw groups and queue the
1172
1201
  # packets
@@ -1181,19 +1210,13 @@ class KaraokeComposer:
1181
1210
  if state.draw_queue:
1182
1211
  self.writer.queue_packet(state.draw_queue.popleft())
1183
1212
  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
- )
1213
+ self.writer.queue_packet(next(iter(st.draw_queue.popleft() for st in lyric_states if st.draw_queue), no_instruction()))
1191
1214
 
1192
1215
  def _compose_highlight(
1193
- self,
1194
- lyric: LyricInfo,
1195
- syllable: SyllableInfo,
1196
- current_time: int,
1216
+ self,
1217
+ lyric: LyricInfo,
1218
+ syllable: SyllableInfo,
1219
+ current_time: int,
1197
1220
  ) -> list[list[CDGPacket]]:
1198
1221
  assert syllable is not None
1199
1222
  line_info = lyric.lines[syllable.line_index]
@@ -1209,37 +1232,34 @@ class KaraokeComposer:
1209
1232
  right_edge = syllable.right_edge
1210
1233
 
1211
1234
  # 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)
1235
+ column_group_length = ((self.config.draw_bandwidth + self.config.highlight_bandwidth) * self.max_tile_height) * len(self.lyrics)
1216
1236
  # Calculate the number of column updates for this highlight
1217
- columns = (
1218
- (end_offset - start_offset) // column_group_length
1219
- ) * self.config.highlight_bandwidth
1237
+ columns = ((end_offset - start_offset) // column_group_length) * self.config.highlight_bandwidth
1220
1238
 
1221
1239
  left_tile = left_edge // CDG_TILE_WIDTH
1222
1240
  right_tile = ceildiv(right_edge, CDG_TILE_WIDTH) - 1
1223
1241
  # The highlight must hit at least the edges of all the tiles
1224
1242
  # along it (not including the one before the left edge or the
1225
1243
  # 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
- ]
1244
+ highlight_progress = [tile_index * CDG_TILE_WIDTH for tile_index in range(left_tile + 1, right_tile + 1)]
1230
1245
  # If there aren't too many tile boundaries for the number of
1231
1246
  # column updates
1232
1247
  if columns - 1 >= len(highlight_progress):
1233
1248
  # Add enough highlight points for all the column updates...
1234
1249
  highlight_progress += sorted(
1235
1250
  # ...which are evenly distributed within the range...
1236
- map(operator.itemgetter(0), distribute(
1237
- range(1, columns), left_edge, right_edge,
1238
- )),
1251
+ map(
1252
+ operator.itemgetter(0),
1253
+ distribute(
1254
+ range(1, columns),
1255
+ left_edge,
1256
+ right_edge,
1257
+ ),
1258
+ ),
1239
1259
  # ...prioritizing highlight points nearest to the middle
1240
1260
  # of a tile
1241
1261
  key=lambda n: abs(n % CDG_TILE_WIDTH - CDG_TILE_WIDTH // 2),
1242
- )[:columns - 1 - len(highlight_progress)]
1262
+ )[: columns - 1 - len(highlight_progress)]
1243
1263
  # NOTE We need the length of this list to be the number of
1244
1264
  # columns minus 1, so that when the left and right edges are
1245
1265
  # included, there will be as many pairs as there are
@@ -1251,47 +1271,52 @@ class KaraokeComposer:
1251
1271
  # updates
1252
1272
  else:
1253
1273
  # Prepare the syllable text representation
1254
- syllable_text = ''.join(
1274
+ syllable_text = "".join(
1255
1275
  f"{{{syll.text}}}" if si == syllable.syllable_index else syll.text
1256
1276
  for si, syll in enumerate(lyric.lines[syllable.line_index].syllables)
1257
1277
  )
1258
-
1278
+
1259
1279
  # Warn the user
1260
1280
  logger.warning(
1261
1281
  "Not enough time to highlight lyric %d line %d syllable %d. "
1262
1282
  "Ideal duration is %d column(s); actual duration is %d column(s). "
1263
1283
  "Syllable text: %s",
1264
- syllable.lyric_index, syllable.line_index, syllable.syllable_index,
1265
- columns, len(highlight_progress) + 1,
1266
- syllable_text
1284
+ syllable.lyric_index,
1285
+ syllable.line_index,
1286
+ syllable.syllable_index,
1287
+ columns,
1288
+ len(highlight_progress) + 1,
1289
+ syllable_text,
1267
1290
  )
1268
1291
 
1269
1292
  # Create the highlight packets
1270
1293
  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
- )
1294
+ line_mask_to_packets(syllable.mask, (x, y), edges) for edges in it.pairwise([left_edge] + highlight_progress + [right_edge])
1275
1295
  ]
1296
+
1276
1297
  # !SECTION
1277
- #endregion
1298
+ # endregion
1278
1299
 
1279
- #region Compose pictures
1300
+ # region Compose pictures
1280
1301
  # SECTION Compose pictures
1281
1302
  def _compose_instrumental(
1282
- self,
1283
- instrumental: SettingsInstrumental,
1284
- end: int | None,
1303
+ self,
1304
+ instrumental: SettingsInstrumental,
1305
+ end: int | None,
1285
1306
  ):
1286
1307
  logger.info(f"Composing instrumental section. End time: {end}")
1287
1308
  try:
1288
- self.writer.queue_packets([
1289
- *memory_preset_repeat(0),
1290
- # TODO Add option for borders in instrumentals
1291
- border_preset(0),
1292
- ])
1309
+ logger.info("composing instrumental section")
1310
+ self.instrumental_times.append(self.writer.packets_queued)
1311
+ self.writer.queue_packets(
1312
+ [
1313
+ *memory_preset_repeat(0),
1314
+ # TODO Add option for borders in instrumentals
1315
+ border_preset(0),
1316
+ ]
1317
+ )
1293
1318
 
1294
- logger.debug("Rendering instrumental text")
1319
+ logger.debug("rendering instrumental text")
1295
1320
  text = instrumental.text.split("\n")
1296
1321
  instrumental_font = ImageFont.truetype(self.config.font, 20)
1297
1322
  text_images = render_lines(
@@ -1299,11 +1324,7 @@ class KaraokeComposer:
1299
1324
  font=instrumental_font,
1300
1325
  # NOTE If the instrumental shouldn't have a stroke, set the
1301
1326
  # stroke width to 0 instead.
1302
- stroke_width=(
1303
- self.config.stroke_width
1304
- if instrumental.stroke is not None
1305
- else 0
1306
- ),
1327
+ stroke_width=(self.config.stroke_width if instrumental.stroke is not None else 0),
1307
1328
  stroke_type=self.config.stroke_type,
1308
1329
  )
1309
1330
  text_width = max(image.width for image in text_images)
@@ -1313,47 +1334,21 @@ class KaraokeComposer:
1313
1334
 
1314
1335
  # Set X position of "text box"
1315
1336
  match instrumental.text_placement:
1316
- case (
1317
- TextPlacement.TOP_LEFT
1318
- | TextPlacement.MIDDLE_LEFT
1319
- | TextPlacement.BOTTOM_LEFT
1320
- ):
1337
+ case TextPlacement.TOP_LEFT | TextPlacement.MIDDLE_LEFT | TextPlacement.BOTTOM_LEFT:
1321
1338
  text_x = CDG_TILE_WIDTH * 2
1322
- case (
1323
- TextPlacement.TOP_MIDDLE
1324
- | TextPlacement.MIDDLE
1325
- | TextPlacement.BOTTOM_MIDDLE
1326
- ):
1339
+ case TextPlacement.TOP_MIDDLE | TextPlacement.MIDDLE | TextPlacement.BOTTOM_MIDDLE:
1327
1340
  text_x = (CDG_SCREEN_WIDTH - text_width) // 2
1328
- case (
1329
- TextPlacement.TOP_RIGHT
1330
- | TextPlacement.MIDDLE_RIGHT
1331
- | TextPlacement.BOTTOM_RIGHT
1332
- ):
1341
+ case TextPlacement.TOP_RIGHT | TextPlacement.MIDDLE_RIGHT | TextPlacement.BOTTOM_RIGHT:
1333
1342
  text_x = CDG_SCREEN_WIDTH - CDG_TILE_WIDTH * 2 - text_width
1334
1343
  # Set Y position of "text box"
1335
1344
  match instrumental.text_placement:
1336
- case (
1337
- TextPlacement.TOP_LEFT
1338
- | TextPlacement.TOP_MIDDLE
1339
- | TextPlacement.TOP_RIGHT
1340
- ):
1345
+ case TextPlacement.TOP_LEFT | TextPlacement.TOP_MIDDLE | TextPlacement.TOP_RIGHT:
1341
1346
  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
1347
+ case TextPlacement.MIDDLE_LEFT | TextPlacement.MIDDLE | TextPlacement.MIDDLE_RIGHT:
1348
+ text_y = ((CDG_SCREEN_HEIGHT - text_height) // 2) // CDG_TILE_HEIGHT * CDG_TILE_HEIGHT
1350
1349
  # Add offset to place text closer to middle of line
1351
1350
  text_y += (line_height - max_height) // 2
1352
- case (
1353
- TextPlacement.BOTTOM_LEFT
1354
- | TextPlacement.BOTTOM_MIDDLE
1355
- | TextPlacement.BOTTOM_RIGHT
1356
- ):
1351
+ case TextPlacement.BOTTOM_LEFT | TextPlacement.BOTTOM_MIDDLE | TextPlacement.BOTTOM_RIGHT:
1357
1352
  text_y = CDG_SCREEN_HEIGHT - CDG_TILE_HEIGHT * 2 - text_height
1358
1353
  # Add offset to place text closer to bottom of line
1359
1354
  text_y += line_height - max_height
@@ -1381,13 +1376,15 @@ class KaraokeComposer:
1381
1376
  (x, y),
1382
1377
  )
1383
1378
  # 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
- ))
1379
+ text_image_packets.extend(
1380
+ line_image_to_packets(
1381
+ image,
1382
+ xy=(x, y),
1383
+ fill=2,
1384
+ stroke=3,
1385
+ background=self.BACKGROUND,
1386
+ )
1387
+ )
1391
1388
  y += instrumental.line_tile_height * CDG_TILE_HEIGHT
1392
1389
 
1393
1390
  if instrumental.image is not None:
@@ -1411,21 +1408,25 @@ class KaraokeComposer:
1411
1408
 
1412
1409
  if instrumental.image is None:
1413
1410
  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
- ))
1411
+ color_table = list(
1412
+ pad(
1413
+ [
1414
+ instrumental.background or self.config.background,
1415
+ self.UNUSED_COLOR,
1416
+ instrumental.fill,
1417
+ instrumental.stroke or self.UNUSED_COLOR,
1418
+ ],
1419
+ 8,
1420
+ padvalue=self.UNUSED_COLOR,
1421
+ )
1422
+ )
1424
1423
  # Set palette and draw text to screen
1425
- self.writer.queue_packets([
1426
- load_color_table_lo(color_table),
1427
- *text_image_packets,
1428
- ])
1424
+ self.writer.queue_packets(
1425
+ [
1426
+ load_color_table_lo(color_table),
1427
+ *text_image_packets,
1428
+ ]
1429
+ )
1429
1430
  logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1430
1431
  else:
1431
1432
  # Queue palette packets
@@ -1433,38 +1434,34 @@ class KaraokeComposer:
1433
1434
  if len(palette) < 8:
1434
1435
  color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1435
1436
  logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1436
- self.writer.queue_packet(load_color_table_lo(
1437
- color_table,
1438
- ))
1437
+ self.writer.queue_packet(
1438
+ load_color_table_lo(
1439
+ color_table,
1440
+ )
1441
+ )
1439
1442
  else:
1440
1443
  color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1441
1444
  logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1442
- self.writer.queue_packets(load_color_table(
1443
- color_table,
1444
- ))
1445
+ self.writer.queue_packets(
1446
+ load_color_table(
1447
+ color_table,
1448
+ )
1449
+ )
1445
1450
 
1446
1451
  logger.debug("drawing instrumental text")
1447
1452
  # Queue text packets
1448
1453
  self.writer.queue_packets(text_image_packets)
1449
1454
 
1450
- logger.debug(
1451
- "rendering instrumental text over background image"
1452
- )
1455
+ logger.debug("rendering instrumental text over background image")
1453
1456
  # HACK To properly draw and layer everything, I need to
1454
1457
  # create a version of the background image that has the text
1455
1458
  # overlaid onto it, and is tile-aligned. This requires some
1456
1459
  # juggling.
1457
1460
  padleft = instrumental.x % CDG_TILE_WIDTH
1458
- padright = -(
1459
- instrumental.x + background_image.width
1460
- ) % CDG_TILE_WIDTH
1461
+ padright = -(instrumental.x + background_image.width) % CDG_TILE_WIDTH
1461
1462
  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
- )
1463
+ padbottom = -(instrumental.y + background_image.height) % CDG_TILE_HEIGHT
1464
+ logger.debug(f"padding L={padleft} R={padright} T={padtop} B={padbottom}")
1468
1465
  # Create axis-aligned background image with proper size and
1469
1466
  # palette
1470
1467
  aligned_background_image = Image.new(
@@ -1490,17 +1487,16 @@ class KaraokeComposer:
1490
1487
  packets = image_to_packets(
1491
1488
  aligned_background_image,
1492
1489
  (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)"
1490
+ background=screen.crop(
1491
+ (
1492
+ instrumental.x - padleft,
1493
+ instrumental.y - padtop,
1494
+ instrumental.x - padleft + aligned_background_image.width,
1495
+ instrumental.y - padtop + aligned_background_image.height,
1496
+ )
1497
+ ),
1503
1498
  )
1499
+ logger.debug("instrumental background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1504
1500
 
1505
1501
  logger.debug("applying instrumental transition")
1506
1502
  # Queue background image packets (and apply transition)
@@ -1508,35 +1504,30 @@ class KaraokeComposer:
1508
1504
  for coord_packets in packets.values():
1509
1505
  self.writer.queue_packets(coord_packets)
1510
1506
  else:
1511
- transition = Image.open(
1512
- package_dir / "transitions" / f"{instrumental.transition}.png"
1513
- )
1507
+ transition = Image.open(package_dir / "transitions" / f"{instrumental.transition}.png")
1514
1508
  for coord in self._gradient_to_tile_positions(transition):
1515
1509
  self.writer.queue_packets(packets.get(coord, []))
1516
1510
 
1517
1511
  if end is None:
1518
- logger.debug("this instrumental will last \"forever\"")
1512
+ logger.debug('this instrumental will last "forever"')
1519
1513
  return
1520
1514
 
1521
1515
  # 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
- )
1516
+ current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
1526
1517
  preparation_time = 3 * CDG_FPS # 3 seconds * 300 frames per second = 900 frames
1527
1518
  end_time = max(current_time, end - preparation_time)
1528
1519
  wait_time = end_time - current_time
1529
-
1520
+
1530
1521
  logger.debug(f"waiting for {wait_time} frame(s) before showing next lyrics")
1531
- self.writer.queue_packets(
1532
- [no_instruction()] * wait_time
1533
- )
1522
+ self.writer.queue_packets([no_instruction()] * wait_time)
1534
1523
 
1535
1524
  # 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
- ])
1525
+ self.writer.queue_packets(
1526
+ [
1527
+ *memory_preset_repeat(self.BACKGROUND),
1528
+ *load_color_table(self.color_table),
1529
+ ]
1530
+ )
1540
1531
  logger.debug(f"loaded color table in compose_instrumental: {self.color_table}")
1541
1532
  if self.config.border is not None:
1542
1533
  self.writer.queue_packet(border_preset(self.BORDER))
@@ -1549,9 +1540,11 @@ class KaraokeComposer:
1549
1540
  def _compose_intro(self):
1550
1541
  # TODO Make it so the intro screen is not hardcoded
1551
1542
  logger.debug("composing intro")
1552
- self.writer.queue_packets([
1553
- *memory_preset_repeat(0),
1554
- ])
1543
+ self.writer.queue_packets(
1544
+ [
1545
+ *memory_preset_repeat(0),
1546
+ ]
1547
+ )
1555
1548
 
1556
1549
  logger.debug("loading intro background image")
1557
1550
  # Load background image
@@ -1560,7 +1553,7 @@ class KaraokeComposer:
1560
1553
  [
1561
1554
  self.config.background, # background
1562
1555
  self.config.border, # border
1563
- self.config.title_color, # title color
1556
+ self.config.title_color, # title color
1564
1557
  self.config.artist_color, # artist color
1565
1558
  ],
1566
1559
  )
@@ -1585,7 +1578,8 @@ class KaraokeComposer:
1585
1578
  font=bigfont,
1586
1579
  ):
1587
1580
  text_image.paste(
1588
- image.point(lambda v: v and 2, "P"), # Use index 2 for title color
1581
+ # Use index 2 for title color
1582
+ image.point(lambda v: v and 2, "P"),
1589
1583
  ((text_image.width - image.width) // 2, y),
1590
1584
  mask=image.point(lambda v: v and 255, "1"),
1591
1585
  )
@@ -1604,7 +1598,8 @@ class KaraokeComposer:
1604
1598
  font=smallfont,
1605
1599
  ):
1606
1600
  text_image.paste(
1607
- image.point(lambda v: v and 3, "P"), # Use index 3 for artist color
1601
+ # Use index 3 for artist color
1602
+ image.point(lambda v: v and 3, "P"),
1608
1603
  ((text_image.width - image.width) // 2, y),
1609
1604
  mask=image.point(lambda v: v and 255, "1"),
1610
1605
  )
@@ -1634,27 +1629,26 @@ class KaraokeComposer:
1634
1629
  if len(palette) < 8:
1635
1630
  color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1636
1631
  logger.debug(f"loaded color table in compose_intro: {color_table}")
1637
- self.writer.queue_packet(load_color_table_lo(
1638
- color_table,
1639
- ))
1632
+ self.writer.queue_packet(
1633
+ load_color_table_lo(
1634
+ color_table,
1635
+ )
1636
+ )
1640
1637
  else:
1641
1638
  color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1642
1639
  logger.debug(f"loaded color table in compose_intro: {color_table}")
1643
- self.writer.queue_packets(load_color_table(
1644
- color_table,
1645
- ))
1640
+ self.writer.queue_packets(
1641
+ load_color_table(
1642
+ color_table,
1643
+ )
1644
+ )
1646
1645
 
1647
1646
  # Render background image to packets
1648
1647
  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
- )
1648
+ logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1653
1649
 
1654
1650
  # Queue background image packets (and apply transition)
1655
- transition = Image.open(
1656
- package_dir / "transitions" / f"{self.config.title_screen_transition}.png"
1657
- )
1651
+ transition = Image.open(package_dir / "transitions" / f"{self.config.title_screen_transition}.png")
1658
1652
  for coord in self._gradient_to_tile_positions(transition):
1659
1653
  self.writer.queue_packets(packets.get(coord, []))
1660
1654
 
@@ -1664,15 +1658,10 @@ class KaraokeComposer:
1664
1658
 
1665
1659
  # Queue the intro screen for 5 seconds
1666
1660
  end_time = INTRO_DURATION
1667
- self.writer.queue_packets(
1668
- [no_instruction()] * (end_time - self.writer.packets_queued)
1669
- )
1661
+ self.writer.queue_packets([no_instruction()] * (end_time - self.writer.packets_queued))
1670
1662
 
1671
1663
  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
1664
+ syllable.start_offset for lyric in self.lyrics for line in lyric.lines for syllable in line.syllables
1676
1665
  )
1677
1666
  logger.debug(f"first syllable starts at {first_syllable_start_offset}")
1678
1667
 
@@ -1681,7 +1670,9 @@ class KaraokeComposer:
1681
1670
  # Otherwise, don't add any silence
1682
1671
  if first_syllable_start_offset < MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE:
1683
1672
  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.")
1673
+ logger.info(
1674
+ f"First syllable within {self.config.intro_duration_seconds + self.config.first_syllable_buffer_seconds} seconds. Adding {self.intro_delay} frames of silence."
1675
+ )
1685
1676
  else:
1686
1677
  self.intro_delay = 0
1687
1678
  logger.info("First syllable after buffer period. No additional silence needed.")
@@ -1689,9 +1680,11 @@ class KaraokeComposer:
1689
1680
  def _compose_outro(self, end: int):
1690
1681
  # TODO Make it so the outro screen is not hardcoded
1691
1682
  logger.debug("composing outro")
1692
- self.writer.queue_packets([
1693
- *memory_preset_repeat(0),
1694
- ])
1683
+ self.writer.queue_packets(
1684
+ [
1685
+ *memory_preset_repeat(0),
1686
+ ]
1687
+ )
1695
1688
 
1696
1689
  logger.debug("loading outro background image")
1697
1690
  # Load background image
@@ -1715,7 +1708,7 @@ class KaraokeComposer:
1715
1708
 
1716
1709
  # Render first line of outro text
1717
1710
  outro_text_line1 = self.config.outro_text_line1.replace("$artist", self.config.artist).replace("$title", self.config.title)
1718
-
1711
+
1719
1712
  for image in render_lines(
1720
1713
  get_wrapped_text(
1721
1714
  outro_text_line1,
@@ -1725,13 +1718,13 @@ class KaraokeComposer:
1725
1718
  font=smallfont,
1726
1719
  ):
1727
1720
  text_image.paste(
1728
- image.point(lambda v: v and 2, "P"), # Use index 2 for line 1 color
1721
+ # Use index 2 for line 1 color
1722
+ image.point(lambda v: v and 2, "P"),
1729
1723
  ((text_image.width - image.width) // 2, y),
1730
1724
  mask=image.point(lambda v: v and 255, "1"),
1731
1725
  )
1732
1726
  y += int(smallfont.size)
1733
1727
 
1734
-
1735
1728
  # Add vertical gap between title and artist using configured value
1736
1729
  y += self.config.outro_line1_line2_gap
1737
1730
 
@@ -1747,7 +1740,8 @@ class KaraokeComposer:
1747
1740
  font=smallfont,
1748
1741
  ):
1749
1742
  text_image.paste(
1750
- image.point(lambda v: v and 3, "P"), # Use index 3 for line 2 color
1743
+ # Use index 3 for line 2 color
1744
+ image.point(lambda v: v and 3, "P"),
1751
1745
  ((text_image.width - image.width) // 2, y),
1752
1746
  mask=image.point(lambda v: v and 255, "1"),
1753
1747
  )
@@ -1770,44 +1764,31 @@ class KaraokeComposer:
1770
1764
  # Queue palette packets
1771
1765
  palette = list(batched(background_image.getpalette(), 3))
1772
1766
  if len(palette) < 8:
1773
- self.writer.queue_packet(load_color_table_lo(
1774
- list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1775
- ))
1767
+ self.writer.queue_packet(load_color_table_lo(list(pad(palette, 8, padvalue=self.UNUSED_COLOR))))
1776
1768
  else:
1777
- self.writer.queue_packets(load_color_table(
1778
- list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1779
- ))
1769
+ self.writer.queue_packets(load_color_table(list(pad(palette, 16, padvalue=self.UNUSED_COLOR))))
1780
1770
 
1781
1771
  # Render background image to packets
1782
1772
  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
- )
1773
+ logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1787
1774
 
1788
1775
  # Queue background image packets (and apply transition)
1789
- transition = Image.open(
1790
- package_dir / "transitions" / f"{self.config.outro_transition}.png"
1791
- )
1776
+ transition = Image.open(package_dir / "transitions" / f"{self.config.outro_transition}.png")
1792
1777
  for coord in self._gradient_to_tile_positions(transition):
1793
1778
  self.writer.queue_packets(packets.get(coord, []))
1794
1779
 
1795
- self.writer.queue_packets(
1796
- [no_instruction()] * (end - self.writer.packets_queued)
1797
- )
1780
+ self.writer.queue_packets([no_instruction()] * (end - self.writer.packets_queued))
1798
1781
 
1799
1782
  def _load_image(
1800
- self,
1801
- image_path: "StrOrBytesPath | Path",
1802
- partial_palette: list[RGBColor] | None = None,
1783
+ self,
1784
+ image_path: "StrOrBytesPath | Path",
1785
+ partial_palette: list[RGBColor] | None = None,
1803
1786
  ):
1804
1787
  if partial_palette is None:
1805
1788
  partial_palette = []
1806
1789
 
1807
1790
  logger.debug("loading image")
1808
- image_rgba = Image.open(
1809
- file_relative_to(image_path, self.relative_dir)
1810
- ).convert("RGBA")
1791
+ image_rgba = Image.open(file_relative_to(image_path, self.relative_dir)).convert("RGBA")
1811
1792
  image = image_rgba.convert("RGB")
1812
1793
 
1813
1794
  # REVIEW How many colors should I allow? Should I make this
@@ -1824,49 +1805,42 @@ class KaraokeComposer:
1824
1805
  dither=Image.Dither.FLOYDSTEINBERG,
1825
1806
  )
1826
1807
  # 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
- ])
1808
+ image.putpalette(
1809
+ [
1810
+ # HACK The RGB values of the colors that show up in CDG
1811
+ # players are repdigits in hexadecimal - 0x00, 0x11, 0x22,
1812
+ # 0x33, etc. This means that we can simply round each value
1813
+ # to the nearest multiple of 0x11 (17 in decimal).
1814
+ 0x11 * round(v / 0x11)
1815
+ for v in image.getpalette()
1816
+ ]
1817
+ )
1835
1818
  image = image.quantize()
1836
1819
  logger.debug(f"image uses {max(image.getdata()) + 1} color(s)")
1837
1820
 
1838
1821
  if partial_palette:
1839
- logger.debug(
1840
- f"prepending {len(partial_palette)} color(s) to palette"
1841
- )
1822
+ logger.debug(f"prepending {len(partial_palette)} color(s) to palette")
1842
1823
  # Add offset to color indices
1843
1824
  image.putdata(image.getdata(), offset=len(partial_palette))
1844
1825
  # Place other colors in palette
1845
- image.putpalette(
1846
- list(it.chain(*partial_palette)) + image.getpalette()
1847
- )
1826
+ image.putpalette(list(it.chain(*partial_palette)) + image.getpalette())
1848
1827
 
1849
- logger.debug(
1850
- f"palette: {list(batched(image.getpalette(), 3))!r}"
1851
- )
1828
+ logger.debug(f"palette: {list(batched(image.getpalette(), 3))!r}")
1852
1829
 
1853
1830
  logger.debug("masking out non-transparent parts of image")
1854
1831
  # Create mask for non-transparent parts of image
1855
1832
  # NOTE We allow alpha values from 128 to 255 (half-transparent
1856
1833
  # to opaque).
1857
1834
  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
- ])
1835
+ mask.putdata([0 if pixel >= 128 else 255 for pixel in image_rgba.getdata(band=3)])
1862
1836
  # Set transparent parts of background to 0
1863
1837
  image.paste(Image.new("P", image.size, 0), mask=mask)
1864
1838
 
1865
1839
  return image
1866
1840
 
1867
1841
  def _gradient_to_tile_positions(
1868
- self,
1869
- image: Image.Image,
1842
+ self,
1843
+ image: Image.Image,
1870
1844
  ) -> list[tuple[int, int]]:
1871
1845
  """
1872
1846
  Convert an image of a gradient to an ordering of tile positions.
@@ -1897,23 +1871,354 @@ class KaraokeComposer:
1897
1871
  # is not done with reverse=True to preserve the sort's
1898
1872
  # stability.
1899
1873
  intensities[(tile_y, tile_x)] = -sum(
1900
- image.getpixel((
1901
- tile_x * CDG_TILE_WIDTH + x,
1902
- tile_y * CDG_TILE_HEIGHT + y,
1903
- ))
1874
+ image.getpixel(
1875
+ (
1876
+ tile_x * CDG_TILE_WIDTH + x,
1877
+ tile_y * CDG_TILE_HEIGHT + y,
1878
+ )
1879
+ )
1904
1880
  for x in range(CDG_TILE_WIDTH)
1905
1881
  for y in range(CDG_TILE_HEIGHT)
1906
1882
  )
1907
1883
  return sorted(intensities, key=intensities.get)
1884
+
1908
1885
  # !SECTION
1909
- #endregion
1886
+ # endregion
1887
+
1888
+ # region Create MP4
1889
+ # SECTION Create MP4
1890
+ def create_ass(self):
1891
+ if not ASS_REQUIREMENTS:
1892
+ raise RuntimeError("could not import requirements for creating ASS")
1893
+
1894
+ # Create ASS subtitle object
1895
+ # (ASS = Advanced Sub Station. Get your mind out of the gutter.)
1896
+ logger.debug("creating ASS subtitle object")
1897
+ assdoc = ass.Document()
1898
+ assdoc.fields.update(
1899
+ Title="",
1900
+ WrapStyle=2,
1901
+ ScaledBorderAndShadow="yes",
1902
+ Collisions="normal",
1903
+ PlayResX=CDG_SCREEN_WIDTH,
1904
+ PlayResY=CDG_SCREEN_HEIGHT,
1905
+ )
1910
1906
 
1911
- def main():
1912
- # TODO Make the logging level configurable from the command line
1913
- logging.basicConfig(level=logging.DEBUG)
1907
+ # Load lyric font using fontTools
1908
+ # NOTE We do this because we need some of the font's metadata.
1909
+ logger.debug("loading metadata from font")
1910
+ font = ttLib.TTFont(self.font.path)
1911
+
1912
+ # NOTE The ASS Style lines need the "fontname as used by
1913
+ # Windows". The best name for this purpose is name 4, which
1914
+ # Apple calls the "full name of the font". (Oh yeah, and Apple
1915
+ # developed TrueType, the font format used here. Who knew?)
1916
+ fontname = font["name"].getDebugName(4)
1917
+
1918
+ # NOTE PIL interprets a font's size as its "nominal size", or
1919
+ # "em height". The ASS format interprets a font's size as its
1920
+ # "actual size" - the area enclosing its highest and lowest
1921
+ # points.
1922
+ # Relative values for these sizes can be found/calculated from
1923
+ # the font's headers, and the ratio between them is used to
1924
+ # scale the lyric font size from nominal to actual.
1925
+ nominal_size = cast(int, font["head"].unitsPerEm)
1926
+ ascent = cast(int, font["hhea"].ascent)
1927
+ descent = cast(int, font["hhea"].descent)
1928
+ actual_size = ascent - descent
1929
+ fontsize = self.config.font_size * actual_size / nominal_size
1930
+ # HACK If I position each line at its proper Y position, it
1931
+ # looks shifted down slightly. This should correct it, I think.
1932
+ y_offset = self.config.font_size * (descent / 2) / nominal_size
1933
+
1934
+ # Create a style for each singer
1935
+ for i, singer in enumerate(self.config.singers, 1):
1936
+ logger.debug(f"creating ASS style for singer {i}")
1937
+ assdoc.styles.append(
1938
+ ass.Style(
1939
+ name=f"Singer{i}",
1940
+ fontname=fontname,
1941
+ fontsize=fontsize,
1942
+ primary_color=ass.line.Color(*singer.active_fill),
1943
+ secondary_color=ass.line.Color(*singer.inactive_fill),
1944
+ outline_color=ass.line.Color(*singer.inactive_stroke),
1945
+ back_color=ass.line.Color(*singer.active_stroke),
1946
+ border_style=1, # outline + drop shadow
1947
+ outline=self.config.stroke_width,
1948
+ shadow=0,
1949
+ alignment=8, # alignment point is at top middle
1950
+ margin_l=0,
1951
+ margin_r=0,
1952
+ margin_v=0,
1953
+ )
1954
+ )
1955
+
1956
+ offset = cdg_to_sync(self.intro_delay + self.sync_offset)
1957
+ instrumental = 0
1958
+ # Create events for each line sung in each lyric set
1959
+ for ci, (lyric, times) in enumerate(
1960
+ zip(
1961
+ self.lyrics,
1962
+ self.lyric_times,
1963
+ )
1964
+ ):
1965
+ for li, line in enumerate(lyric.lines):
1966
+ # Skip line if it has no syllables
1967
+ if not line.syllables:
1968
+ continue
1969
+ logger.debug(f"creating event for lyric {ci} line {li}")
1970
+
1971
+ # Get intended draw time of line
1972
+ line_draw_time = cdg_to_sync(times.line_draw[li]) + offset
1973
+ # XXX This is hardcoded, so as to not have the line's
1974
+ # appearance clash with the intro.
1975
+ line_draw_time = max(line_draw_time, 800)
1976
+
1977
+ # The upcoming instrumental section should be the first
1978
+ # one after this line is drawn
1979
+ while instrumental < len(self.instrumental_times) and (
1980
+ cdg_to_sync(self.instrumental_times[instrumental]) <= line_draw_time
1981
+ ):
1982
+ instrumental += 1
1983
+
1984
+ # Get intended erase time of line, if possible
1985
+ if times.line_erase:
1986
+ line_erase_time = cdg_to_sync(times.line_erase[li]) + offset
1987
+ # If there are no erase times saved, then lyrics are
1988
+ # being cleared by page instead of being erased
1989
+ else:
1990
+ # Get first non-empty line of next page
1991
+ next_page_li = (li // lyric.lines_per_page + 1) * lyric.lines_per_page
1992
+ while next_page_li < len(lyric.lines):
1993
+ if lyric.lines[next_page_li].syllables:
1994
+ break
1995
+ next_page_li += 1
1996
+
1997
+ # If there is a next page
1998
+ if next_page_li < len(lyric.lines):
1999
+ # Erase the current line when the next page is
2000
+ # drawn
2001
+ line_erase_time = cdg_to_sync(times.line_draw[next_page_li]) + offset
2002
+ # If there is no next page
2003
+ else:
2004
+ # Erase the current line after the last syllable
2005
+ # of this line is highlighted
2006
+ # XXX This is hardcoded.
2007
+ line_erase_time = cdg_to_sync(line.syllables[-1].end_offset) + offset + 200
2008
+
2009
+ if instrumental < len(self.instrumental_times):
2010
+ # The current line should be erased before the
2011
+ # upcoming instrumental section
2012
+ line_erase_time = min(
2013
+ line_erase_time,
2014
+ cdg_to_sync(self.instrumental_times[instrumental]),
2015
+ )
2016
+
2017
+ text = ""
2018
+ # Text is horizontally centered, and at the line's Y
2019
+ x = CDG_SCREEN_WIDTH // 2
2020
+ y = line.y + y_offset
2021
+ text += f"{{\\pos({x},{y})}}"
2022
+ # Text should fade in and out with the intended
2023
+ # draw/erase timing
2024
+ # NOTE This is in milliseconds for some reason, whereas
2025
+ # every other timing value is in centiseconds.
2026
+ fade = cdg_to_sync(self.LINE_DRAW_ERASE_GAP) * 10
2027
+ text += f"{{\\fad({fade},{fade})}}"
2028
+ # There should be a pause before the text is highlighted
2029
+ line_start_offset = cdg_to_sync(line.syllables[0].start_offset) + offset
2030
+ text += f"{{\\k{line_start_offset - line_draw_time}}}"
2031
+ # Each syllable should be filled in for the specified
2032
+ # duration
2033
+ for syll in line.syllables:
2034
+ length = cdg_to_sync(syll.end_offset - syll.start_offset)
2035
+ text += f"{{\\kf{length}}}{syll.text}"
2036
+
2037
+ # Create a dialogue event for this line
2038
+ assdoc.events.append(
2039
+ ass.Dialogue(
2040
+ layer=ci,
2041
+ # NOTE The line draw and erase times are in
2042
+ # centiseconds, so we need to multiply by 10 for
2043
+ # milliseconds.
2044
+ start=timedelta(milliseconds=line_draw_time * 10),
2045
+ end=timedelta(milliseconds=line_erase_time * 10),
2046
+ style=f"Singer{line.singer}",
2047
+ effect="karaoke",
2048
+ text=text,
2049
+ )
2050
+ )
2051
+
2052
+ outname = self.config.outname
2053
+ assfile_name = self.relative_dir / Path(f"{outname}.ass")
2054
+ logger.debug(f"dumping ASS object to {assfile_name}")
2055
+ # HACK If I don't specify "utf-8-sig" as the encoding, the
2056
+ # python-ass module gives me a warning telling me to. This adds
2057
+ # a "byte order mark" to the ASS file (seemingly unnecessarily).
2058
+ with open(assfile_name, "w", encoding="utf-8-sig") as assfile:
2059
+ assdoc.dump_file(assfile)
2060
+ logger.info(f"ASS object dumped to {assfile_name}")
2061
+
2062
+ def create_mp4(self, height: int = 720, fps: int = 30):
2063
+ if not MP4_REQUIREMENTS:
2064
+ raise RuntimeError("could not import requirements for creating MP4")
2065
+
2066
+ outname = self.config.outname
2067
+
2068
+ # Create a "background plate" for the video
2069
+ # NOTE The "background plate" will simply be the CDG file we've
2070
+ # composed, but without the lyrics. We create this by replacing
2071
+ # all lyric-drawing packets with no-instruction packets.
2072
+ platecdg_name = self.relative_dir / Path(f"{outname}.plate.cdg")
2073
+ logger.debug(f"writing plate CDG to {platecdg_name}")
2074
+ with open(platecdg_name, "wb") as platecdg:
2075
+ logger.debug("writing plate")
2076
+ for i, packet in enumerate(self.writer.packets):
2077
+ packet_to_write = packet
2078
+ if i in self.lyric_packet_indices:
2079
+ packet_to_write = no_instruction()
2080
+ self.writer.write_packet(platecdg, packet_to_write)
2081
+ logger.info(f"plate CDG written to {platecdg_name}")
2082
+
2083
+ # Create an MP3 file for the audio
2084
+ platemp3_name = self.relative_dir / Path(f"{outname}.plate.mp3")
2085
+ logger.debug(f"writing plate MP3 to {platemp3_name}")
2086
+ self.audio.export(platemp3_name, format="mp3")
2087
+ logger.info(f"plate MP3 written to {platemp3_name}")
2088
+
2089
+ # Create a subtitle file for the HQ lyrics
2090
+ self.create_ass()
2091
+ assfile_name = self.relative_dir / Path(f"{outname}.ass")
2092
+
2093
+ logger.debug("building ffmpeg command for encoding MP4")
2094
+ video = (
2095
+ ffmpeg.input(platecdg_name).video
2096
+ # Pad the end of the video by a few seconds
2097
+ # HACK This ensures the last video frame isn't some CDG
2098
+ # frame before the last one. This padding will also be cut
2099
+ # later.
2100
+ .filter_("tpad", stop_mode="clone", stop_duration=5)
2101
+ # Set framerate
2102
+ .filter_("fps", fps=fps)
2103
+ # Scale video to resolution
2104
+ .filter_(
2105
+ "scale",
2106
+ # HACK The libx264 codec requires the video dimensions
2107
+ # to be divisible by 2. Here, the width is not only
2108
+ # automatically calculated from the plate's aspect
2109
+ # ratio, but truncated down to a multiple of 2.
2110
+ w="trunc(oh*a/2)*2",
2111
+ h=height // 2 * 2,
2112
+ flags="neighbor",
2113
+ )
2114
+ # Burn in subtitles
2115
+ .filter_("ass", filename=assfile_name)
2116
+ )
2117
+ audio = ffmpeg.input(platemp3_name)
2118
+
2119
+ mp4_name = self.relative_dir / Path(f"{outname}.mp4")
2120
+ mp4 = ffmpeg.output(
2121
+ video,
2122
+ audio,
2123
+ filename=mp4_name,
2124
+ hide_banner=None,
2125
+ loglevel="error",
2126
+ stats=None,
2127
+ # Video should use the H.264 codec, at a decent quality
2128
+ vcodec="libx264",
2129
+ pix_fmt="yuv420p",
2130
+ crf=22,
2131
+ preset="veryfast",
2132
+ # Truncate to the length of the shortest input
2133
+ # HACK This effectively removes the video padding that was
2134
+ # added earlier, because the audio is shorter than the
2135
+ # padded video.
2136
+ shortest=None,
2137
+ ).overwrite_output()
2138
+ logger.debug(f"ffmpeg command: {mp4.compile()}")
2139
+ mp4.run()
2140
+
2141
+ logger.debug("deleting plate CDG")
2142
+ platecdg_name.unlink()
2143
+ logger.info("plate CDG deleted")
2144
+
2145
+ logger.debug("deleting plate MP3")
2146
+ platemp3_name.unlink()
2147
+ logger.info("plate MP3 deleted")
2148
+
2149
+ # !SECTION
2150
+ # endregion
1914
2151
 
1915
- kc = KaraokeComposer.from_file(sys.argv[1])
2152
+
2153
+ def main():
2154
+ from argparse import ArgumentParser, RawDescriptionHelpFormatter
2155
+ import sys
2156
+
2157
+ parser = ArgumentParser(
2158
+ prog="py -m cdgmaker",
2159
+ description="Create custom CDG files for karaoke.",
2160
+ epilog=("For a description of the config format, visit " "https://github.com/WinslowJosiah/cdgmaker"),
2161
+ formatter_class=RawDescriptionHelpFormatter,
2162
+ )
2163
+ parser.add_argument(
2164
+ "config",
2165
+ help=".toml config file to create CDG files with",
2166
+ metavar="FILE",
2167
+ type=str,
2168
+ )
2169
+ parser.add_argument(
2170
+ "-v",
2171
+ "--verbose",
2172
+ help="make logs more verbose (-v, -vv, etc.)",
2173
+ action="count",
2174
+ default=0,
2175
+ )
2176
+ parser.add_argument(
2177
+ "-r",
2178
+ "--render",
2179
+ help="render MP4 video of created CDG file",
2180
+ action="store_true",
2181
+ )
2182
+
2183
+ # If there aren't any arguments to parse
2184
+ if len(sys.argv) < 2:
2185
+ # Print help message and exit with error
2186
+ parser.print_help()
2187
+ sys.exit(1)
2188
+
2189
+ # Overwrite the error handler to also print a help message
2190
+ # HACK: This is what's known in the biz as a "monkey-patch". Don't
2191
+ # worry if it doesn't make sense to you; it makes sense to argparse,
2192
+ # and that's all that matters.
2193
+ def custom_error_handler(_self: ArgumentParser):
2194
+
2195
+ def wrapper(message: str):
2196
+ sys.stderr.write(f"{_self.prog}: error: {message}\n")
2197
+ _self.print_help()
2198
+ sys.exit(2)
2199
+
2200
+ return wrapper
2201
+
2202
+ parser.error = custom_error_handler(parser)
2203
+
2204
+ # Parse command line arguments
2205
+ args = parser.parse_args()
2206
+
2207
+ # Set logging level based on verbosity
2208
+ log_level = logging.ERROR
2209
+ if not args.verbose:
2210
+ log_level = logging.WARNING
2211
+ elif args.verbose == 1:
2212
+ log_level = logging.INFO
2213
+ elif args.verbose >= 2:
2214
+ log_level = logging.DEBUG
2215
+ logging.basicConfig(level=log_level)
2216
+
2217
+ kc = KaraokeComposer.from_file(args.config)
1916
2218
  kc.compose()
2219
+ if args.render:
2220
+ kc.create_mp4(height=1080, fps=60)
2221
+
1917
2222
 
1918
2223
  if __name__ == "__main__":
1919
2224
  main()