lyrics-transcriber 0.37.0__py3-none-any.whl → 0.40.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 (29) 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 +106 -29
  21. lyrics_transcriber/output/cdgmaker/composer.py +822 -528
  22. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  23. lyrics_transcriber/review/server.py +10 -12
  24. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/METADATA +3 -2
  25. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/RECORD +28 -26
  26. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/entry_points.txt +1 -0
  27. lyrics_transcriber/frontend/dist/assets/index-BNNbsbVN.js +0 -182
  28. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/LICENSE +0 -0
  29. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/WHEEL +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,83 +1026,76 @@ 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
1100
  logger.debug("_compose_lyric: Purging all highlight/draw queues")
1097
1101
  for st in lyric_states:
@@ -1105,29 +1109,34 @@ class KaraokeComposer:
1105
1109
  logger.warning("_compose_lyric: Unexpected items in draw queue for non-current state")
1106
1110
  self.writer.queue_packets(st.draw_queue)
1107
1111
 
1112
+ # Purge highlight/draw queues
1108
1113
  st.highlight_queue.clear()
1109
1114
  st.draw_queue.clear()
1110
1115
 
1111
- logger.debug("_compose_lyric: Determining instrumental end time")
1116
+ # The instrumental should end when the next line is drawn by
1117
+ # default
1112
1118
  if line_draw_time is not None:
1113
1119
  instrumental_end = line_draw_time
1114
1120
  else:
1121
+ # NOTE A value of None here means this instrumental will
1122
+ # never end (and once the screen is drawn, it will not
1123
+ # pause), unless there is another instrumental after
1124
+ # this.
1115
1125
  instrumental_end = None
1116
- logger.debug(f"_compose_lyric: instrumental_end={instrumental_end}")
1117
1126
 
1118
1127
  composer_state.instrumental += 1
1119
1128
  next_instrumental = None
1120
1129
  if composer_state.instrumental < len(self.config.instrumentals):
1121
- next_instrumental = self.config.instrumentals[
1122
- composer_state.instrumental
1123
- ]
1124
-
1130
+ next_instrumental = self.config.instrumentals[composer_state.instrumental]
1125
1131
  should_clear = True
1132
+ # If there is a next instrumental
1126
1133
  if next_instrumental is not None:
1127
1134
  next_instrumental_time = sync_to_cdg(next_instrumental.sync)
1128
- logger.debug(f"_compose_lyric: next_instrumental_time={next_instrumental_time}")
1135
+ # If the next instrumental is immediately after this one
1129
1136
  if instrumental_end is None or next_instrumental_time <= instrumental_end:
1137
+ # This instrumental should end there
1130
1138
  instrumental_end = next_instrumental_time
1139
+ # Don't clear the screen afterwards
1131
1140
  should_clear = False
1132
1141
  else:
1133
1142
  if line_draw_time is None:
@@ -1142,16 +1151,20 @@ class KaraokeComposer:
1142
1151
 
1143
1152
  if should_clear:
1144
1153
  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
- ])
1154
+ self.writer.queue_packets(
1155
+ [
1156
+ *memory_preset_repeat(self.BACKGROUND),
1157
+ *load_color_table(self.color_table),
1158
+ ]
1159
+ )
1149
1160
  logger.debug(f"_compose_lyric: Loaded color table: {self.color_table}")
1150
1161
  if self.config.border is not None:
1151
1162
  self.writer.queue_packet(border_preset(self.BORDER))
1152
1163
  composer_state.just_cleared = True
1153
1164
  else:
1154
- logger.debug("_compose_lyric: Not clearing screen after instrumental")
1165
+ logger.debug("not clearing screen after instrumental")
1166
+ # Advance to the next instrumental section
1167
+ instrumental = next_instrumental
1155
1168
  return
1156
1169
 
1157
1170
  composer_state.just_cleared = False
@@ -1164,9 +1177,14 @@ class KaraokeComposer:
1164
1177
  group = state.highlight_queue.popleft()
1165
1178
  highlight_groups.append(list(pad(group, self.max_tile_height)))
1166
1179
  # 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
1180
+ draw_groups: list[list[CDGPacket | None]] = [[None] * self.max_tile_height] * self.config.draw_bandwidth
1181
+
1182
+ self.lyric_packet_indices.update(
1183
+ range(
1184
+ self.writer.packets_queued,
1185
+ self.writer.packets_queued + len(list(it.chain(*highlight_groups, *draw_groups))),
1186
+ )
1187
+ )
1170
1188
 
1171
1189
  # Intersperse the highlight and draw groups and queue the
1172
1190
  # packets
@@ -1181,19 +1199,13 @@ class KaraokeComposer:
1181
1199
  if state.draw_queue:
1182
1200
  self.writer.queue_packet(state.draw_queue.popleft())
1183
1201
  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
- )
1202
+ self.writer.queue_packet(next(iter(st.draw_queue.popleft() for st in lyric_states if st.draw_queue), no_instruction()))
1191
1203
 
1192
1204
  def _compose_highlight(
1193
- self,
1194
- lyric: LyricInfo,
1195
- syllable: SyllableInfo,
1196
- current_time: int,
1205
+ self,
1206
+ lyric: LyricInfo,
1207
+ syllable: SyllableInfo,
1208
+ current_time: int,
1197
1209
  ) -> list[list[CDGPacket]]:
1198
1210
  assert syllable is not None
1199
1211
  line_info = lyric.lines[syllable.line_index]
@@ -1209,37 +1221,34 @@ class KaraokeComposer:
1209
1221
  right_edge = syllable.right_edge
1210
1222
 
1211
1223
  # 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)
1224
+ column_group_length = ((self.config.draw_bandwidth + self.config.highlight_bandwidth) * self.max_tile_height) * len(self.lyrics)
1216
1225
  # Calculate the number of column updates for this highlight
1217
- columns = (
1218
- (end_offset - start_offset) // column_group_length
1219
- ) * self.config.highlight_bandwidth
1226
+ columns = ((end_offset - start_offset) // column_group_length) * self.config.highlight_bandwidth
1220
1227
 
1221
1228
  left_tile = left_edge // CDG_TILE_WIDTH
1222
1229
  right_tile = ceildiv(right_edge, CDG_TILE_WIDTH) - 1
1223
1230
  # The highlight must hit at least the edges of all the tiles
1224
1231
  # along it (not including the one before the left edge or the
1225
1232
  # 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
- ]
1233
+ highlight_progress = [tile_index * CDG_TILE_WIDTH for tile_index in range(left_tile + 1, right_tile + 1)]
1230
1234
  # If there aren't too many tile boundaries for the number of
1231
1235
  # column updates
1232
1236
  if columns - 1 >= len(highlight_progress):
1233
1237
  # Add enough highlight points for all the column updates...
1234
1238
  highlight_progress += sorted(
1235
1239
  # ...which are evenly distributed within the range...
1236
- map(operator.itemgetter(0), distribute(
1237
- range(1, columns), left_edge, right_edge,
1238
- )),
1240
+ map(
1241
+ operator.itemgetter(0),
1242
+ distribute(
1243
+ range(1, columns),
1244
+ left_edge,
1245
+ right_edge,
1246
+ ),
1247
+ ),
1239
1248
  # ...prioritizing highlight points nearest to the middle
1240
1249
  # of a tile
1241
1250
  key=lambda n: abs(n % CDG_TILE_WIDTH - CDG_TILE_WIDTH // 2),
1242
- )[:columns - 1 - len(highlight_progress)]
1251
+ )[: columns - 1 - len(highlight_progress)]
1243
1252
  # NOTE We need the length of this list to be the number of
1244
1253
  # columns minus 1, so that when the left and right edges are
1245
1254
  # included, there will be as many pairs as there are
@@ -1251,47 +1260,52 @@ class KaraokeComposer:
1251
1260
  # updates
1252
1261
  else:
1253
1262
  # Prepare the syllable text representation
1254
- syllable_text = ''.join(
1263
+ syllable_text = "".join(
1255
1264
  f"{{{syll.text}}}" if si == syllable.syllable_index else syll.text
1256
1265
  for si, syll in enumerate(lyric.lines[syllable.line_index].syllables)
1257
1266
  )
1258
-
1267
+
1259
1268
  # Warn the user
1260
1269
  logger.warning(
1261
1270
  "Not enough time to highlight lyric %d line %d syllable %d. "
1262
1271
  "Ideal duration is %d column(s); actual duration is %d column(s). "
1263
1272
  "Syllable text: %s",
1264
- syllable.lyric_index, syllable.line_index, syllable.syllable_index,
1265
- columns, len(highlight_progress) + 1,
1266
- syllable_text
1273
+ syllable.lyric_index,
1274
+ syllable.line_index,
1275
+ syllable.syllable_index,
1276
+ columns,
1277
+ len(highlight_progress) + 1,
1278
+ syllable_text,
1267
1279
  )
1268
1280
 
1269
1281
  # Create the highlight packets
1270
1282
  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
- )
1283
+ line_mask_to_packets(syllable.mask, (x, y), edges) for edges in it.pairwise([left_edge] + highlight_progress + [right_edge])
1275
1284
  ]
1285
+
1276
1286
  # !SECTION
1277
- #endregion
1287
+ # endregion
1278
1288
 
1279
- #region Compose pictures
1289
+ # region Compose pictures
1280
1290
  # SECTION Compose pictures
1281
1291
  def _compose_instrumental(
1282
- self,
1283
- instrumental: SettingsInstrumental,
1284
- end: int | None,
1292
+ self,
1293
+ instrumental: SettingsInstrumental,
1294
+ end: int | None,
1285
1295
  ):
1286
1296
  logger.info(f"Composing instrumental section. End time: {end}")
1287
1297
  try:
1288
- self.writer.queue_packets([
1289
- *memory_preset_repeat(0),
1290
- # TODO Add option for borders in instrumentals
1291
- border_preset(0),
1292
- ])
1298
+ logger.info("composing instrumental section")
1299
+ self.instrumental_times.append(self.writer.packets_queued)
1300
+ self.writer.queue_packets(
1301
+ [
1302
+ *memory_preset_repeat(0),
1303
+ # TODO Add option for borders in instrumentals
1304
+ border_preset(0),
1305
+ ]
1306
+ )
1293
1307
 
1294
- logger.debug("Rendering instrumental text")
1308
+ logger.debug("rendering instrumental text")
1295
1309
  text = instrumental.text.split("\n")
1296
1310
  instrumental_font = ImageFont.truetype(self.config.font, 20)
1297
1311
  text_images = render_lines(
@@ -1299,11 +1313,7 @@ class KaraokeComposer:
1299
1313
  font=instrumental_font,
1300
1314
  # NOTE If the instrumental shouldn't have a stroke, set the
1301
1315
  # stroke width to 0 instead.
1302
- stroke_width=(
1303
- self.config.stroke_width
1304
- if instrumental.stroke is not None
1305
- else 0
1306
- ),
1316
+ stroke_width=(self.config.stroke_width if instrumental.stroke is not None else 0),
1307
1317
  stroke_type=self.config.stroke_type,
1308
1318
  )
1309
1319
  text_width = max(image.width for image in text_images)
@@ -1313,47 +1323,21 @@ class KaraokeComposer:
1313
1323
 
1314
1324
  # Set X position of "text box"
1315
1325
  match instrumental.text_placement:
1316
- case (
1317
- TextPlacement.TOP_LEFT
1318
- | TextPlacement.MIDDLE_LEFT
1319
- | TextPlacement.BOTTOM_LEFT
1320
- ):
1326
+ case TextPlacement.TOP_LEFT | TextPlacement.MIDDLE_LEFT | TextPlacement.BOTTOM_LEFT:
1321
1327
  text_x = CDG_TILE_WIDTH * 2
1322
- case (
1323
- TextPlacement.TOP_MIDDLE
1324
- | TextPlacement.MIDDLE
1325
- | TextPlacement.BOTTOM_MIDDLE
1326
- ):
1328
+ case TextPlacement.TOP_MIDDLE | TextPlacement.MIDDLE | TextPlacement.BOTTOM_MIDDLE:
1327
1329
  text_x = (CDG_SCREEN_WIDTH - text_width) // 2
1328
- case (
1329
- TextPlacement.TOP_RIGHT
1330
- | TextPlacement.MIDDLE_RIGHT
1331
- | TextPlacement.BOTTOM_RIGHT
1332
- ):
1330
+ case TextPlacement.TOP_RIGHT | TextPlacement.MIDDLE_RIGHT | TextPlacement.BOTTOM_RIGHT:
1333
1331
  text_x = CDG_SCREEN_WIDTH - CDG_TILE_WIDTH * 2 - text_width
1334
1332
  # Set Y position of "text box"
1335
1333
  match instrumental.text_placement:
1336
- case (
1337
- TextPlacement.TOP_LEFT
1338
- | TextPlacement.TOP_MIDDLE
1339
- | TextPlacement.TOP_RIGHT
1340
- ):
1334
+ case TextPlacement.TOP_LEFT | TextPlacement.TOP_MIDDLE | TextPlacement.TOP_RIGHT:
1341
1335
  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
1336
+ case TextPlacement.MIDDLE_LEFT | TextPlacement.MIDDLE | TextPlacement.MIDDLE_RIGHT:
1337
+ text_y = ((CDG_SCREEN_HEIGHT - text_height) // 2) // CDG_TILE_HEIGHT * CDG_TILE_HEIGHT
1350
1338
  # Add offset to place text closer to middle of line
1351
1339
  text_y += (line_height - max_height) // 2
1352
- case (
1353
- TextPlacement.BOTTOM_LEFT
1354
- | TextPlacement.BOTTOM_MIDDLE
1355
- | TextPlacement.BOTTOM_RIGHT
1356
- ):
1340
+ case TextPlacement.BOTTOM_LEFT | TextPlacement.BOTTOM_MIDDLE | TextPlacement.BOTTOM_RIGHT:
1357
1341
  text_y = CDG_SCREEN_HEIGHT - CDG_TILE_HEIGHT * 2 - text_height
1358
1342
  # Add offset to place text closer to bottom of line
1359
1343
  text_y += line_height - max_height
@@ -1381,13 +1365,15 @@ class KaraokeComposer:
1381
1365
  (x, y),
1382
1366
  )
1383
1367
  # 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
- ))
1368
+ text_image_packets.extend(
1369
+ line_image_to_packets(
1370
+ image,
1371
+ xy=(x, y),
1372
+ fill=2,
1373
+ stroke=3,
1374
+ background=self.BACKGROUND,
1375
+ )
1376
+ )
1391
1377
  y += instrumental.line_tile_height * CDG_TILE_HEIGHT
1392
1378
 
1393
1379
  if instrumental.image is not None:
@@ -1411,21 +1397,25 @@ class KaraokeComposer:
1411
1397
 
1412
1398
  if instrumental.image is None:
1413
1399
  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
- ))
1400
+ color_table = list(
1401
+ pad(
1402
+ [
1403
+ instrumental.background or self.config.background,
1404
+ self.UNUSED_COLOR,
1405
+ instrumental.fill,
1406
+ instrumental.stroke or self.UNUSED_COLOR,
1407
+ ],
1408
+ 8,
1409
+ padvalue=self.UNUSED_COLOR,
1410
+ )
1411
+ )
1424
1412
  # Set palette and draw text to screen
1425
- self.writer.queue_packets([
1426
- load_color_table_lo(color_table),
1427
- *text_image_packets,
1428
- ])
1413
+ self.writer.queue_packets(
1414
+ [
1415
+ load_color_table_lo(color_table),
1416
+ *text_image_packets,
1417
+ ]
1418
+ )
1429
1419
  logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1430
1420
  else:
1431
1421
  # Queue palette packets
@@ -1433,38 +1423,34 @@ class KaraokeComposer:
1433
1423
  if len(palette) < 8:
1434
1424
  color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1435
1425
  logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1436
- self.writer.queue_packet(load_color_table_lo(
1437
- color_table,
1438
- ))
1426
+ self.writer.queue_packet(
1427
+ load_color_table_lo(
1428
+ color_table,
1429
+ )
1430
+ )
1439
1431
  else:
1440
1432
  color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1441
1433
  logger.debug(f"loaded color table in compose_instrumental: {color_table}")
1442
- self.writer.queue_packets(load_color_table(
1443
- color_table,
1444
- ))
1434
+ self.writer.queue_packets(
1435
+ load_color_table(
1436
+ color_table,
1437
+ )
1438
+ )
1445
1439
 
1446
1440
  logger.debug("drawing instrumental text")
1447
1441
  # Queue text packets
1448
1442
  self.writer.queue_packets(text_image_packets)
1449
1443
 
1450
- logger.debug(
1451
- "rendering instrumental text over background image"
1452
- )
1444
+ logger.debug("rendering instrumental text over background image")
1453
1445
  # HACK To properly draw and layer everything, I need to
1454
1446
  # create a version of the background image that has the text
1455
1447
  # overlaid onto it, and is tile-aligned. This requires some
1456
1448
  # juggling.
1457
1449
  padleft = instrumental.x % CDG_TILE_WIDTH
1458
- padright = -(
1459
- instrumental.x + background_image.width
1460
- ) % CDG_TILE_WIDTH
1450
+ padright = -(instrumental.x + background_image.width) % CDG_TILE_WIDTH
1461
1451
  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
- )
1452
+ padbottom = -(instrumental.y + background_image.height) % CDG_TILE_HEIGHT
1453
+ logger.debug(f"padding L={padleft} R={padright} T={padtop} B={padbottom}")
1468
1454
  # Create axis-aligned background image with proper size and
1469
1455
  # palette
1470
1456
  aligned_background_image = Image.new(
@@ -1490,17 +1476,16 @@ class KaraokeComposer:
1490
1476
  packets = image_to_packets(
1491
1477
  aligned_background_image,
1492
1478
  (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)"
1479
+ background=screen.crop(
1480
+ (
1481
+ instrumental.x - padleft,
1482
+ instrumental.y - padtop,
1483
+ instrumental.x - padleft + aligned_background_image.width,
1484
+ instrumental.y - padtop + aligned_background_image.height,
1485
+ )
1486
+ ),
1503
1487
  )
1488
+ logger.debug("instrumental background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1504
1489
 
1505
1490
  logger.debug("applying instrumental transition")
1506
1491
  # Queue background image packets (and apply transition)
@@ -1508,35 +1493,30 @@ class KaraokeComposer:
1508
1493
  for coord_packets in packets.values():
1509
1494
  self.writer.queue_packets(coord_packets)
1510
1495
  else:
1511
- transition = Image.open(
1512
- package_dir / "transitions" / f"{instrumental.transition}.png"
1513
- )
1496
+ transition = Image.open(package_dir / "transitions" / f"{instrumental.transition}.png")
1514
1497
  for coord in self._gradient_to_tile_positions(transition):
1515
1498
  self.writer.queue_packets(packets.get(coord, []))
1516
1499
 
1517
1500
  if end is None:
1518
- logger.debug("this instrumental will last \"forever\"")
1501
+ logger.debug('this instrumental will last "forever"')
1519
1502
  return
1520
1503
 
1521
1504
  # 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
- )
1505
+ current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
1526
1506
  preparation_time = 3 * CDG_FPS # 3 seconds * 300 frames per second = 900 frames
1527
1507
  end_time = max(current_time, end - preparation_time)
1528
1508
  wait_time = end_time - current_time
1529
-
1509
+
1530
1510
  logger.debug(f"waiting for {wait_time} frame(s) before showing next lyrics")
1531
- self.writer.queue_packets(
1532
- [no_instruction()] * wait_time
1533
- )
1511
+ self.writer.queue_packets([no_instruction()] * wait_time)
1534
1512
 
1535
1513
  # 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
- ])
1514
+ self.writer.queue_packets(
1515
+ [
1516
+ *memory_preset_repeat(self.BACKGROUND),
1517
+ *load_color_table(self.color_table),
1518
+ ]
1519
+ )
1540
1520
  logger.debug(f"loaded color table in compose_instrumental: {self.color_table}")
1541
1521
  if self.config.border is not None:
1542
1522
  self.writer.queue_packet(border_preset(self.BORDER))
@@ -1549,9 +1529,11 @@ class KaraokeComposer:
1549
1529
  def _compose_intro(self):
1550
1530
  # TODO Make it so the intro screen is not hardcoded
1551
1531
  logger.debug("composing intro")
1552
- self.writer.queue_packets([
1553
- *memory_preset_repeat(0),
1554
- ])
1532
+ self.writer.queue_packets(
1533
+ [
1534
+ *memory_preset_repeat(0),
1535
+ ]
1536
+ )
1555
1537
 
1556
1538
  logger.debug("loading intro background image")
1557
1539
  # Load background image
@@ -1560,7 +1542,7 @@ class KaraokeComposer:
1560
1542
  [
1561
1543
  self.config.background, # background
1562
1544
  self.config.border, # border
1563
- self.config.title_color, # title color
1545
+ self.config.title_color, # title color
1564
1546
  self.config.artist_color, # artist color
1565
1547
  ],
1566
1548
  )
@@ -1585,7 +1567,8 @@ class KaraokeComposer:
1585
1567
  font=bigfont,
1586
1568
  ):
1587
1569
  text_image.paste(
1588
- image.point(lambda v: v and 2, "P"), # Use index 2 for title color
1570
+ # Use index 2 for title color
1571
+ image.point(lambda v: v and 2, "P"),
1589
1572
  ((text_image.width - image.width) // 2, y),
1590
1573
  mask=image.point(lambda v: v and 255, "1"),
1591
1574
  )
@@ -1604,7 +1587,8 @@ class KaraokeComposer:
1604
1587
  font=smallfont,
1605
1588
  ):
1606
1589
  text_image.paste(
1607
- image.point(lambda v: v and 3, "P"), # Use index 3 for artist color
1590
+ # Use index 3 for artist color
1591
+ image.point(lambda v: v and 3, "P"),
1608
1592
  ((text_image.width - image.width) // 2, y),
1609
1593
  mask=image.point(lambda v: v and 255, "1"),
1610
1594
  )
@@ -1634,27 +1618,26 @@ class KaraokeComposer:
1634
1618
  if len(palette) < 8:
1635
1619
  color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1636
1620
  logger.debug(f"loaded color table in compose_intro: {color_table}")
1637
- self.writer.queue_packet(load_color_table_lo(
1638
- color_table,
1639
- ))
1621
+ self.writer.queue_packet(
1622
+ load_color_table_lo(
1623
+ color_table,
1624
+ )
1625
+ )
1640
1626
  else:
1641
1627
  color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1642
1628
  logger.debug(f"loaded color table in compose_intro: {color_table}")
1643
- self.writer.queue_packets(load_color_table(
1644
- color_table,
1645
- ))
1629
+ self.writer.queue_packets(
1630
+ load_color_table(
1631
+ color_table,
1632
+ )
1633
+ )
1646
1634
 
1647
1635
  # Render background image to packets
1648
1636
  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
- )
1637
+ logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1653
1638
 
1654
1639
  # Queue background image packets (and apply transition)
1655
- transition = Image.open(
1656
- package_dir / "transitions" / f"{self.config.title_screen_transition}.png"
1657
- )
1640
+ transition = Image.open(package_dir / "transitions" / f"{self.config.title_screen_transition}.png")
1658
1641
  for coord in self._gradient_to_tile_positions(transition):
1659
1642
  self.writer.queue_packets(packets.get(coord, []))
1660
1643
 
@@ -1664,15 +1647,10 @@ class KaraokeComposer:
1664
1647
 
1665
1648
  # Queue the intro screen for 5 seconds
1666
1649
  end_time = INTRO_DURATION
1667
- self.writer.queue_packets(
1668
- [no_instruction()] * (end_time - self.writer.packets_queued)
1669
- )
1650
+ self.writer.queue_packets([no_instruction()] * (end_time - self.writer.packets_queued))
1670
1651
 
1671
1652
  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
1653
+ syllable.start_offset for lyric in self.lyrics for line in lyric.lines for syllable in line.syllables
1676
1654
  )
1677
1655
  logger.debug(f"first syllable starts at {first_syllable_start_offset}")
1678
1656
 
@@ -1681,7 +1659,9 @@ class KaraokeComposer:
1681
1659
  # Otherwise, don't add any silence
1682
1660
  if first_syllable_start_offset < MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE:
1683
1661
  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.")
1662
+ logger.info(
1663
+ f"First syllable within {self.config.intro_duration_seconds + self.config.first_syllable_buffer_seconds} seconds. Adding {self.intro_delay} frames of silence."
1664
+ )
1685
1665
  else:
1686
1666
  self.intro_delay = 0
1687
1667
  logger.info("First syllable after buffer period. No additional silence needed.")
@@ -1689,9 +1669,11 @@ class KaraokeComposer:
1689
1669
  def _compose_outro(self, end: int):
1690
1670
  # TODO Make it so the outro screen is not hardcoded
1691
1671
  logger.debug("composing outro")
1692
- self.writer.queue_packets([
1693
- *memory_preset_repeat(0),
1694
- ])
1672
+ self.writer.queue_packets(
1673
+ [
1674
+ *memory_preset_repeat(0),
1675
+ ]
1676
+ )
1695
1677
 
1696
1678
  logger.debug("loading outro background image")
1697
1679
  # Load background image
@@ -1715,7 +1697,7 @@ class KaraokeComposer:
1715
1697
 
1716
1698
  # Render first line of outro text
1717
1699
  outro_text_line1 = self.config.outro_text_line1.replace("$artist", self.config.artist).replace("$title", self.config.title)
1718
-
1700
+
1719
1701
  for image in render_lines(
1720
1702
  get_wrapped_text(
1721
1703
  outro_text_line1,
@@ -1725,13 +1707,13 @@ class KaraokeComposer:
1725
1707
  font=smallfont,
1726
1708
  ):
1727
1709
  text_image.paste(
1728
- image.point(lambda v: v and 2, "P"), # Use index 2 for line 1 color
1710
+ # Use index 2 for line 1 color
1711
+ image.point(lambda v: v and 2, "P"),
1729
1712
  ((text_image.width - image.width) // 2, y),
1730
1713
  mask=image.point(lambda v: v and 255, "1"),
1731
1714
  )
1732
1715
  y += int(smallfont.size)
1733
1716
 
1734
-
1735
1717
  # Add vertical gap between title and artist using configured value
1736
1718
  y += self.config.outro_line1_line2_gap
1737
1719
 
@@ -1747,7 +1729,8 @@ class KaraokeComposer:
1747
1729
  font=smallfont,
1748
1730
  ):
1749
1731
  text_image.paste(
1750
- image.point(lambda v: v and 3, "P"), # Use index 3 for line 2 color
1732
+ # Use index 3 for line 2 color
1733
+ image.point(lambda v: v and 3, "P"),
1751
1734
  ((text_image.width - image.width) // 2, y),
1752
1735
  mask=image.point(lambda v: v and 255, "1"),
1753
1736
  )
@@ -1770,44 +1753,31 @@ class KaraokeComposer:
1770
1753
  # Queue palette packets
1771
1754
  palette = list(batched(background_image.getpalette(), 3))
1772
1755
  if len(palette) < 8:
1773
- self.writer.queue_packet(load_color_table_lo(
1774
- list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
1775
- ))
1756
+ self.writer.queue_packet(load_color_table_lo(list(pad(palette, 8, padvalue=self.UNUSED_COLOR))))
1776
1757
  else:
1777
- self.writer.queue_packets(load_color_table(
1778
- list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
1779
- ))
1758
+ self.writer.queue_packets(load_color_table(list(pad(palette, 16, padvalue=self.UNUSED_COLOR))))
1780
1759
 
1781
1760
  # Render background image to packets
1782
1761
  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
- )
1762
+ logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
1787
1763
 
1788
1764
  # Queue background image packets (and apply transition)
1789
- transition = Image.open(
1790
- package_dir / "transitions" / f"{self.config.outro_transition}.png"
1791
- )
1765
+ transition = Image.open(package_dir / "transitions" / f"{self.config.outro_transition}.png")
1792
1766
  for coord in self._gradient_to_tile_positions(transition):
1793
1767
  self.writer.queue_packets(packets.get(coord, []))
1794
1768
 
1795
- self.writer.queue_packets(
1796
- [no_instruction()] * (end - self.writer.packets_queued)
1797
- )
1769
+ self.writer.queue_packets([no_instruction()] * (end - self.writer.packets_queued))
1798
1770
 
1799
1771
  def _load_image(
1800
- self,
1801
- image_path: "StrOrBytesPath | Path",
1802
- partial_palette: list[RGBColor] | None = None,
1772
+ self,
1773
+ image_path: "StrOrBytesPath | Path",
1774
+ partial_palette: list[RGBColor] | None = None,
1803
1775
  ):
1804
1776
  if partial_palette is None:
1805
1777
  partial_palette = []
1806
1778
 
1807
1779
  logger.debug("loading image")
1808
- image_rgba = Image.open(
1809
- file_relative_to(image_path, self.relative_dir)
1810
- ).convert("RGBA")
1780
+ image_rgba = Image.open(file_relative_to(image_path, self.relative_dir)).convert("RGBA")
1811
1781
  image = image_rgba.convert("RGB")
1812
1782
 
1813
1783
  # REVIEW How many colors should I allow? Should I make this
@@ -1824,49 +1794,42 @@ class KaraokeComposer:
1824
1794
  dither=Image.Dither.FLOYDSTEINBERG,
1825
1795
  )
1826
1796
  # 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
- ])
1797
+ image.putpalette(
1798
+ [
1799
+ # HACK The RGB values of the colors that show up in CDG
1800
+ # players are repdigits in hexadecimal - 0x00, 0x11, 0x22,
1801
+ # 0x33, etc. This means that we can simply round each value
1802
+ # to the nearest multiple of 0x11 (17 in decimal).
1803
+ 0x11 * round(v / 0x11)
1804
+ for v in image.getpalette()
1805
+ ]
1806
+ )
1835
1807
  image = image.quantize()
1836
1808
  logger.debug(f"image uses {max(image.getdata()) + 1} color(s)")
1837
1809
 
1838
1810
  if partial_palette:
1839
- logger.debug(
1840
- f"prepending {len(partial_palette)} color(s) to palette"
1841
- )
1811
+ logger.debug(f"prepending {len(partial_palette)} color(s) to palette")
1842
1812
  # Add offset to color indices
1843
1813
  image.putdata(image.getdata(), offset=len(partial_palette))
1844
1814
  # Place other colors in palette
1845
- image.putpalette(
1846
- list(it.chain(*partial_palette)) + image.getpalette()
1847
- )
1815
+ image.putpalette(list(it.chain(*partial_palette)) + image.getpalette())
1848
1816
 
1849
- logger.debug(
1850
- f"palette: {list(batched(image.getpalette(), 3))!r}"
1851
- )
1817
+ logger.debug(f"palette: {list(batched(image.getpalette(), 3))!r}")
1852
1818
 
1853
1819
  logger.debug("masking out non-transparent parts of image")
1854
1820
  # Create mask for non-transparent parts of image
1855
1821
  # NOTE We allow alpha values from 128 to 255 (half-transparent
1856
1822
  # to opaque).
1857
1823
  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
- ])
1824
+ mask.putdata([0 if pixel >= 128 else 255 for pixel in image_rgba.getdata(band=3)])
1862
1825
  # Set transparent parts of background to 0
1863
1826
  image.paste(Image.new("P", image.size, 0), mask=mask)
1864
1827
 
1865
1828
  return image
1866
1829
 
1867
1830
  def _gradient_to_tile_positions(
1868
- self,
1869
- image: Image.Image,
1831
+ self,
1832
+ image: Image.Image,
1870
1833
  ) -> list[tuple[int, int]]:
1871
1834
  """
1872
1835
  Convert an image of a gradient to an ordering of tile positions.
@@ -1897,23 +1860,354 @@ class KaraokeComposer:
1897
1860
  # is not done with reverse=True to preserve the sort's
1898
1861
  # stability.
1899
1862
  intensities[(tile_y, tile_x)] = -sum(
1900
- image.getpixel((
1901
- tile_x * CDG_TILE_WIDTH + x,
1902
- tile_y * CDG_TILE_HEIGHT + y,
1903
- ))
1863
+ image.getpixel(
1864
+ (
1865
+ tile_x * CDG_TILE_WIDTH + x,
1866
+ tile_y * CDG_TILE_HEIGHT + y,
1867
+ )
1868
+ )
1904
1869
  for x in range(CDG_TILE_WIDTH)
1905
1870
  for y in range(CDG_TILE_HEIGHT)
1906
1871
  )
1907
1872
  return sorted(intensities, key=intensities.get)
1873
+
1908
1874
  # !SECTION
1909
- #endregion
1875
+ # endregion
1876
+
1877
+ # region Create MP4
1878
+ # SECTION Create MP4
1879
+ def create_ass(self):
1880
+ if not ASS_REQUIREMENTS:
1881
+ raise RuntimeError("could not import requirements for creating ASS")
1882
+
1883
+ # Create ASS subtitle object
1884
+ # (ASS = Advanced Sub Station. Get your mind out of the gutter.)
1885
+ logger.debug("creating ASS subtitle object")
1886
+ assdoc = ass.Document()
1887
+ assdoc.fields.update(
1888
+ Title="",
1889
+ WrapStyle=2,
1890
+ ScaledBorderAndShadow="yes",
1891
+ Collisions="normal",
1892
+ PlayResX=CDG_SCREEN_WIDTH,
1893
+ PlayResY=CDG_SCREEN_HEIGHT,
1894
+ )
1910
1895
 
1911
- def main():
1912
- # TODO Make the logging level configurable from the command line
1913
- logging.basicConfig(level=logging.DEBUG)
1896
+ # Load lyric font using fontTools
1897
+ # NOTE We do this because we need some of the font's metadata.
1898
+ logger.debug("loading metadata from font")
1899
+ font = ttLib.TTFont(self.font.path)
1900
+
1901
+ # NOTE The ASS Style lines need the "fontname as used by
1902
+ # Windows". The best name for this purpose is name 4, which
1903
+ # Apple calls the "full name of the font". (Oh yeah, and Apple
1904
+ # developed TrueType, the font format used here. Who knew?)
1905
+ fontname = font["name"].getDebugName(4)
1906
+
1907
+ # NOTE PIL interprets a font's size as its "nominal size", or
1908
+ # "em height". The ASS format interprets a font's size as its
1909
+ # "actual size" - the area enclosing its highest and lowest
1910
+ # points.
1911
+ # Relative values for these sizes can be found/calculated from
1912
+ # the font's headers, and the ratio between them is used to
1913
+ # scale the lyric font size from nominal to actual.
1914
+ nominal_size = cast(int, font["head"].unitsPerEm)
1915
+ ascent = cast(int, font["hhea"].ascent)
1916
+ descent = cast(int, font["hhea"].descent)
1917
+ actual_size = ascent - descent
1918
+ fontsize = self.config.font_size * actual_size / nominal_size
1919
+ # HACK If I position each line at its proper Y position, it
1920
+ # looks shifted down slightly. This should correct it, I think.
1921
+ y_offset = self.config.font_size * (descent / 2) / nominal_size
1922
+
1923
+ # Create a style for each singer
1924
+ for i, singer in enumerate(self.config.singers, 1):
1925
+ logger.debug(f"creating ASS style for singer {i}")
1926
+ assdoc.styles.append(
1927
+ ass.Style(
1928
+ name=f"Singer{i}",
1929
+ fontname=fontname,
1930
+ fontsize=fontsize,
1931
+ primary_color=ass.line.Color(*singer.active_fill),
1932
+ secondary_color=ass.line.Color(*singer.inactive_fill),
1933
+ outline_color=ass.line.Color(*singer.inactive_stroke),
1934
+ back_color=ass.line.Color(*singer.active_stroke),
1935
+ border_style=1, # outline + drop shadow
1936
+ outline=self.config.stroke_width,
1937
+ shadow=0,
1938
+ alignment=8, # alignment point is at top middle
1939
+ margin_l=0,
1940
+ margin_r=0,
1941
+ margin_v=0,
1942
+ )
1943
+ )
1914
1944
 
1915
- kc = KaraokeComposer.from_file(sys.argv[1])
1945
+ offset = cdg_to_sync(self.intro_delay + self.sync_offset)
1946
+ instrumental = 0
1947
+ # Create events for each line sung in each lyric set
1948
+ for ci, (lyric, times) in enumerate(
1949
+ zip(
1950
+ self.lyrics,
1951
+ self.lyric_times,
1952
+ )
1953
+ ):
1954
+ for li, line in enumerate(lyric.lines):
1955
+ # Skip line if it has no syllables
1956
+ if not line.syllables:
1957
+ continue
1958
+ logger.debug(f"creating event for lyric {ci} line {li}")
1959
+
1960
+ # Get intended draw time of line
1961
+ line_draw_time = cdg_to_sync(times.line_draw[li]) + offset
1962
+ # XXX This is hardcoded, so as to not have the line's
1963
+ # appearance clash with the intro.
1964
+ line_draw_time = max(line_draw_time, 800)
1965
+
1966
+ # The upcoming instrumental section should be the first
1967
+ # one after this line is drawn
1968
+ while instrumental < len(self.instrumental_times) and (
1969
+ cdg_to_sync(self.instrumental_times[instrumental]) <= line_draw_time
1970
+ ):
1971
+ instrumental += 1
1972
+
1973
+ # Get intended erase time of line, if possible
1974
+ if times.line_erase:
1975
+ line_erase_time = cdg_to_sync(times.line_erase[li]) + offset
1976
+ # If there are no erase times saved, then lyrics are
1977
+ # being cleared by page instead of being erased
1978
+ else:
1979
+ # Get first non-empty line of next page
1980
+ next_page_li = (li // lyric.lines_per_page + 1) * lyric.lines_per_page
1981
+ while next_page_li < len(lyric.lines):
1982
+ if lyric.lines[next_page_li].syllables:
1983
+ break
1984
+ next_page_li += 1
1985
+
1986
+ # If there is a next page
1987
+ if next_page_li < len(lyric.lines):
1988
+ # Erase the current line when the next page is
1989
+ # drawn
1990
+ line_erase_time = cdg_to_sync(times.line_draw[next_page_li]) + offset
1991
+ # If there is no next page
1992
+ else:
1993
+ # Erase the current line after the last syllable
1994
+ # of this line is highlighted
1995
+ # XXX This is hardcoded.
1996
+ line_erase_time = cdg_to_sync(line.syllables[-1].end_offset) + offset + 200
1997
+
1998
+ if instrumental < len(self.instrumental_times):
1999
+ # The current line should be erased before the
2000
+ # upcoming instrumental section
2001
+ line_erase_time = min(
2002
+ line_erase_time,
2003
+ cdg_to_sync(self.instrumental_times[instrumental]),
2004
+ )
2005
+
2006
+ text = ""
2007
+ # Text is horizontally centered, and at the line's Y
2008
+ x = CDG_SCREEN_WIDTH // 2
2009
+ y = line.y + y_offset
2010
+ text += f"{{\\pos({x},{y})}}"
2011
+ # Text should fade in and out with the intended
2012
+ # draw/erase timing
2013
+ # NOTE This is in milliseconds for some reason, whereas
2014
+ # every other timing value is in centiseconds.
2015
+ fade = cdg_to_sync(self.LINE_DRAW_ERASE_GAP) * 10
2016
+ text += f"{{\\fad({fade},{fade})}}"
2017
+ # There should be a pause before the text is highlighted
2018
+ line_start_offset = cdg_to_sync(line.syllables[0].start_offset) + offset
2019
+ text += f"{{\\k{line_start_offset - line_draw_time}}}"
2020
+ # Each syllable should be filled in for the specified
2021
+ # duration
2022
+ for syll in line.syllables:
2023
+ length = cdg_to_sync(syll.end_offset - syll.start_offset)
2024
+ text += f"{{\\kf{length}}}{syll.text}"
2025
+
2026
+ # Create a dialogue event for this line
2027
+ assdoc.events.append(
2028
+ ass.Dialogue(
2029
+ layer=ci,
2030
+ # NOTE The line draw and erase times are in
2031
+ # centiseconds, so we need to multiply by 10 for
2032
+ # milliseconds.
2033
+ start=timedelta(milliseconds=line_draw_time * 10),
2034
+ end=timedelta(milliseconds=line_erase_time * 10),
2035
+ style=f"Singer{line.singer}",
2036
+ effect="karaoke",
2037
+ text=text,
2038
+ )
2039
+ )
2040
+
2041
+ outname = self.config.outname
2042
+ assfile_name = self.relative_dir / Path(f"{outname}.ass")
2043
+ logger.debug(f"dumping ASS object to {assfile_name}")
2044
+ # HACK If I don't specify "utf-8-sig" as the encoding, the
2045
+ # python-ass module gives me a warning telling me to. This adds
2046
+ # a "byte order mark" to the ASS file (seemingly unnecessarily).
2047
+ with open(assfile_name, "w", encoding="utf-8-sig") as assfile:
2048
+ assdoc.dump_file(assfile)
2049
+ logger.info(f"ASS object dumped to {assfile_name}")
2050
+
2051
+ def create_mp4(self, height: int = 720, fps: int = 30):
2052
+ if not MP4_REQUIREMENTS:
2053
+ raise RuntimeError("could not import requirements for creating MP4")
2054
+
2055
+ outname = self.config.outname
2056
+
2057
+ # Create a "background plate" for the video
2058
+ # NOTE The "background plate" will simply be the CDG file we've
2059
+ # composed, but without the lyrics. We create this by replacing
2060
+ # all lyric-drawing packets with no-instruction packets.
2061
+ platecdg_name = self.relative_dir / Path(f"{outname}.plate.cdg")
2062
+ logger.debug(f"writing plate CDG to {platecdg_name}")
2063
+ with open(platecdg_name, "wb") as platecdg:
2064
+ logger.debug("writing plate")
2065
+ for i, packet in enumerate(self.writer.packets):
2066
+ packet_to_write = packet
2067
+ if i in self.lyric_packet_indices:
2068
+ packet_to_write = no_instruction()
2069
+ self.writer.write_packet(platecdg, packet_to_write)
2070
+ logger.info(f"plate CDG written to {platecdg_name}")
2071
+
2072
+ # Create an MP3 file for the audio
2073
+ platemp3_name = self.relative_dir / Path(f"{outname}.plate.mp3")
2074
+ logger.debug(f"writing plate MP3 to {platemp3_name}")
2075
+ self.audio.export(platemp3_name, format="mp3")
2076
+ logger.info(f"plate MP3 written to {platemp3_name}")
2077
+
2078
+ # Create a subtitle file for the HQ lyrics
2079
+ self.create_ass()
2080
+ assfile_name = self.relative_dir / Path(f"{outname}.ass")
2081
+
2082
+ logger.debug("building ffmpeg command for encoding MP4")
2083
+ video = (
2084
+ ffmpeg.input(platecdg_name).video
2085
+ # Pad the end of the video by a few seconds
2086
+ # HACK This ensures the last video frame isn't some CDG
2087
+ # frame before the last one. This padding will also be cut
2088
+ # later.
2089
+ .filter_("tpad", stop_mode="clone", stop_duration=5)
2090
+ # Set framerate
2091
+ .filter_("fps", fps=fps)
2092
+ # Scale video to resolution
2093
+ .filter_(
2094
+ "scale",
2095
+ # HACK The libx264 codec requires the video dimensions
2096
+ # to be divisible by 2. Here, the width is not only
2097
+ # automatically calculated from the plate's aspect
2098
+ # ratio, but truncated down to a multiple of 2.
2099
+ w="trunc(oh*a/2)*2",
2100
+ h=height // 2 * 2,
2101
+ flags="neighbor",
2102
+ )
2103
+ # Burn in subtitles
2104
+ .filter_("ass", filename=assfile_name)
2105
+ )
2106
+ audio = ffmpeg.input(platemp3_name)
2107
+
2108
+ mp4_name = self.relative_dir / Path(f"{outname}.mp4")
2109
+ mp4 = ffmpeg.output(
2110
+ video,
2111
+ audio,
2112
+ filename=mp4_name,
2113
+ hide_banner=None,
2114
+ loglevel="error",
2115
+ stats=None,
2116
+ # Video should use the H.264 codec, at a decent quality
2117
+ vcodec="libx264",
2118
+ pix_fmt="yuv420p",
2119
+ crf=22,
2120
+ preset="veryfast",
2121
+ # Truncate to the length of the shortest input
2122
+ # HACK This effectively removes the video padding that was
2123
+ # added earlier, because the audio is shorter than the
2124
+ # padded video.
2125
+ shortest=None,
2126
+ ).overwrite_output()
2127
+ logger.debug(f"ffmpeg command: {mp4.compile()}")
2128
+ mp4.run()
2129
+
2130
+ logger.debug("deleting plate CDG")
2131
+ platecdg_name.unlink()
2132
+ logger.info("plate CDG deleted")
2133
+
2134
+ logger.debug("deleting plate MP3")
2135
+ platemp3_name.unlink()
2136
+ logger.info("plate MP3 deleted")
2137
+
2138
+ # !SECTION
2139
+ # endregion
2140
+
2141
+
2142
+ def main():
2143
+ from argparse import ArgumentParser, RawDescriptionHelpFormatter
2144
+ import sys
2145
+
2146
+ parser = ArgumentParser(
2147
+ prog="py -m cdgmaker",
2148
+ description="Create custom CDG files for karaoke.",
2149
+ epilog=("For a description of the config format, visit " "https://github.com/WinslowJosiah/cdgmaker"),
2150
+ formatter_class=RawDescriptionHelpFormatter,
2151
+ )
2152
+ parser.add_argument(
2153
+ "config",
2154
+ help=".toml config file to create CDG files with",
2155
+ metavar="FILE",
2156
+ type=str,
2157
+ )
2158
+ parser.add_argument(
2159
+ "-v",
2160
+ "--verbose",
2161
+ help="make logs more verbose (-v, -vv, etc.)",
2162
+ action="count",
2163
+ default=0,
2164
+ )
2165
+ parser.add_argument(
2166
+ "-r",
2167
+ "--render",
2168
+ help="render MP4 video of created CDG file",
2169
+ action="store_true",
2170
+ )
2171
+
2172
+ # If there aren't any arguments to parse
2173
+ if len(sys.argv) < 2:
2174
+ # Print help message and exit with error
2175
+ parser.print_help()
2176
+ sys.exit(1)
2177
+
2178
+ # Overwrite the error handler to also print a help message
2179
+ # HACK: This is what's known in the biz as a "monkey-patch". Don't
2180
+ # worry if it doesn't make sense to you; it makes sense to argparse,
2181
+ # and that's all that matters.
2182
+ def custom_error_handler(_self: ArgumentParser):
2183
+
2184
+ def wrapper(message: str):
2185
+ sys.stderr.write(f"{_self.prog}: error: {message}\n")
2186
+ _self.print_help()
2187
+ sys.exit(2)
2188
+
2189
+ return wrapper
2190
+
2191
+ parser.error = custom_error_handler(parser)
2192
+
2193
+ # Parse command line arguments
2194
+ args = parser.parse_args()
2195
+
2196
+ # Set logging level based on verbosity
2197
+ log_level = logging.ERROR
2198
+ if not args.verbose:
2199
+ log_level = logging.WARNING
2200
+ elif args.verbose == 1:
2201
+ log_level = logging.INFO
2202
+ elif args.verbose >= 2:
2203
+ log_level = logging.DEBUG
2204
+ logging.basicConfig(level=log_level)
2205
+
2206
+ kc = KaraokeComposer.from_file(args.config)
1916
2207
  kc.compose()
2208
+ if args.render:
2209
+ kc.create_mp4(height=1080, fps=60)
2210
+
1917
2211
 
1918
2212
  if __name__ == "__main__":
1919
2213
  main()