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