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.
- 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 +106 -29
- lyrics_transcriber/output/cdgmaker/composer.py +822 -528
- lyrics_transcriber/output/lrc_to_cdg.py +61 -0
- lyrics_transcriber/review/server.py +10 -12
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/METADATA +3 -2
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/RECORD +28 -26
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/entry_points.txt +1 -0
- lyrics_transcriber/frontend/dist/assets/index-BNNbsbVN.js +0 -182
- {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/LICENSE +0 -0
- {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(
|
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,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(
|
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
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
|
-
|
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
|
-
|
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
|
-
|
1147
|
-
|
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("
|
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
|
-
|
1169
|
-
|
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
|
-
|
1194
|
-
|
1195
|
-
|
1196
|
-
|
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(
|
1237
|
-
|
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 =
|
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,
|
1265
|
-
|
1266
|
-
|
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
|
-
|
1283
|
-
|
1284
|
-
|
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
|
-
|
1289
|
-
|
1290
|
-
|
1291
|
-
|
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("
|
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
|
-
|
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(
|
1385
|
-
|
1386
|
-
|
1387
|
-
|
1388
|
-
|
1389
|
-
|
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(
|
1415
|
-
|
1416
|
-
|
1417
|
-
|
1418
|
-
|
1419
|
-
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
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
|
-
|
1427
|
-
|
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(
|
1437
|
-
|
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(
|
1443
|
-
|
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
|
-
|
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
|
-
|
1495
|
-
|
1496
|
-
|
1497
|
-
|
1498
|
-
|
1499
|
-
|
1500
|
-
|
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(
|
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
|
-
|
1538
|
-
|
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
|
-
|
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,
|
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
|
-
|
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
|
-
|
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(
|
1638
|
-
|
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(
|
1644
|
-
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
1801
|
-
|
1802
|
-
|
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
|
-
|
1829
|
-
|
1830
|
-
|
1831
|
-
|
1832
|
-
|
1833
|
-
|
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
|
-
|
1869
|
-
|
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
|
-
|
1902
|
-
|
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
|
-
|
1912
|
-
|
1913
|
-
|
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
|
-
|
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()
|