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.
- lyrics_transcriber/correction/handlers/extend_anchor.py +13 -2
- lyrics_transcriber/correction/handlers/word_operations.py +8 -2
- lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js +26696 -0
- lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +1 -0
- lyrics_transcriber/frontend/dist/index.html +1 -1
- lyrics_transcriber/frontend/package.json +3 -2
- lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +36 -13
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +41 -1
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +48 -16
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +71 -16
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +45 -12
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +83 -19
- lyrics_transcriber/frontend/src/components/shared/types.ts +3 -0
- lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +65 -9
- lyrics_transcriber/frontend/vite.config.js +4 -0
- lyrics_transcriber/frontend/vite.config.ts +4 -0
- lyrics_transcriber/lyrics/genius.py +41 -12
- lyrics_transcriber/output/cdg.py +1 -0
- lyrics_transcriber/output/cdgmaker/composer.py +839 -534
- lyrics_transcriber/review/server.py +10 -12
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/METADATA +3 -2
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/RECORD +27 -26
- lyrics_transcriber/frontend/dist/assets/index-BNNbsbVN.js +0 -182
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/LICENSE +0 -0
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.39.0.dist-info}/WHEEL +0 -0
- {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(
|
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
|
-
|
45
|
-
|
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
|
-
|
167
|
-
|
168
|
-
|
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
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
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
|
-
|
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(
|
278
|
-
|
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(
|
307
|
-
|
308
|
-
|
309
|
-
|
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(
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
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
|
-
|
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(
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
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
|
-
|
351
|
-
|
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
|
-
|
375
|
-
|
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
|
-
|
388
|
-
|
389
|
-
|
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,
|
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,
|
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,
|
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
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
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
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
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
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
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
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
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
|
-
|
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(
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
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
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
913
|
-
|
914
|
-
|
915
|
-
|
916
|
-
|
917
|
-
|
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
|
-
|
973
|
-
|
974
|
-
|
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(
|
979
|
-
|
980
|
-
|
981
|
-
|
982
|
-
|
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
|
-
|
991
|
-
|
992
|
-
|
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(
|
997
|
-
|
998
|
-
|
999
|
-
|
1000
|
-
|
1001
|
-
|
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(
|
1029
|
-
|
1030
|
-
|
1031
|
-
|
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
|
-
|
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
|
-
|
1060
|
-
|
1061
|
-
|
1062
|
-
|
1063
|
-
|
1064
|
-
#
|
1065
|
-
|
1066
|
-
|
1067
|
-
|
1068
|
-
|
1069
|
-
|
1070
|
-
|
1071
|
-
|
1072
|
-
|
1073
|
-
|
1074
|
-
|
1075
|
-
|
1076
|
-
|
1077
|
-
|
1078
|
-
|
1079
|
-
|
1080
|
-
|
1081
|
-
|
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.
|
1094
|
+
logger.debug("time for an instrumental section")
|
1091
1095
|
if instrumental.wait:
|
1092
|
-
logger.
|
1096
|
+
logger.debug("this instrumental section waited for the previous " "line to finish")
|
1093
1097
|
else:
|
1094
|
-
logger.
|
1098
|
+
logger.debug("this instrumental did not wait for the previous " "line to finish")
|
1095
1099
|
|
1096
|
-
logger.debug("
|
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
|
-
|
1100
|
-
|
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
|
-
|
1104
|
-
|
1105
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
1147
|
-
|
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("
|
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
|
-
|
1169
|
-
|
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
|
-
|
1194
|
-
|
1195
|
-
|
1196
|
-
|
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(
|
1237
|
-
|
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 =
|
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,
|
1265
|
-
|
1266
|
-
|
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
|
-
|
1283
|
-
|
1284
|
-
|
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
|
-
|
1289
|
-
|
1290
|
-
|
1291
|
-
|
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("
|
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
|
-
|
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(
|
1385
|
-
|
1386
|
-
|
1387
|
-
|
1388
|
-
|
1389
|
-
|
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(
|
1415
|
-
|
1416
|
-
|
1417
|
-
|
1418
|
-
|
1419
|
-
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
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
|
-
|
1427
|
-
|
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(
|
1437
|
-
|
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(
|
1443
|
-
|
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
|
-
|
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
|
-
|
1495
|
-
|
1496
|
-
|
1497
|
-
|
1498
|
-
|
1499
|
-
|
1500
|
-
|
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(
|
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
|
-
|
1538
|
-
|
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
|
-
|
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,
|
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
|
-
|
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
|
-
|
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(
|
1638
|
-
|
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(
|
1644
|
-
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
1801
|
-
|
1802
|
-
|
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
|
-
|
1829
|
-
|
1830
|
-
|
1831
|
-
|
1832
|
-
|
1833
|
-
|
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
|
-
|
1869
|
-
|
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
|
-
|
1902
|
-
|
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
|
-
|
1912
|
-
|
1913
|
-
|
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
|
-
|
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()
|