karaoke-gen 0.57.0__py3-none-any.whl → 0.71.23__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.
- karaoke_gen/audio_fetcher.py +461 -0
- karaoke_gen/audio_processor.py +407 -30
- karaoke_gen/config.py +62 -113
- karaoke_gen/file_handler.py +32 -59
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +148 -67
- karaoke_gen/karaoke_gen.py +270 -61
- karaoke_gen/lyrics_processor.py +13 -1
- karaoke_gen/metadata.py +78 -73
- karaoke_gen/pipeline/__init__.py +87 -0
- karaoke_gen/pipeline/base.py +215 -0
- karaoke_gen/pipeline/context.py +230 -0
- karaoke_gen/pipeline/executors/__init__.py +21 -0
- karaoke_gen/pipeline/executors/local.py +159 -0
- karaoke_gen/pipeline/executors/remote.py +257 -0
- karaoke_gen/pipeline/stages/__init__.py +27 -0
- karaoke_gen/pipeline/stages/finalize.py +202 -0
- karaoke_gen/pipeline/stages/render.py +165 -0
- karaoke_gen/pipeline/stages/screens.py +139 -0
- karaoke_gen/pipeline/stages/separation.py +191 -0
- karaoke_gen/pipeline/stages/transcription.py +191 -0
- karaoke_gen/style_loader.py +531 -0
- karaoke_gen/utils/bulk_cli.py +6 -0
- karaoke_gen/utils/cli_args.py +424 -0
- karaoke_gen/utils/gen_cli.py +26 -261
- karaoke_gen/utils/remote_cli.py +1815 -0
- karaoke_gen/video_background_processor.py +351 -0
- karaoke_gen-0.71.23.dist-info/METADATA +610 -0
- karaoke_gen-0.71.23.dist-info/RECORD +275 -0
- {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info}/WHEEL +1 -1
- {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info}/entry_points.txt +1 -0
- lyrics_transcriber/__init__.py +10 -0
- lyrics_transcriber/cli/__init__.py +0 -0
- lyrics_transcriber/cli/cli_main.py +285 -0
- lyrics_transcriber/core/__init__.py +0 -0
- lyrics_transcriber/core/config.py +50 -0
- lyrics_transcriber/core/controller.py +520 -0
- lyrics_transcriber/correction/__init__.py +0 -0
- lyrics_transcriber/correction/agentic/__init__.py +9 -0
- lyrics_transcriber/correction/agentic/adapter.py +71 -0
- lyrics_transcriber/correction/agentic/agent.py +313 -0
- lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
- lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
- lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
- lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
- lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
- lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
- lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
- lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
- lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
- lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
- lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
- lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
- lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
- lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
- lyrics_transcriber/correction/agentic/models/enums.py +38 -0
- lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
- lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
- lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
- lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
- lyrics_transcriber/correction/agentic/models/utils.py +19 -0
- lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
- lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
- lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
- lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
- lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
- lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
- lyrics_transcriber/correction/agentic/providers/base.py +36 -0
- lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
- lyrics_transcriber/correction/agentic/providers/config.py +73 -0
- lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
- lyrics_transcriber/correction/agentic/providers/health.py +28 -0
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
- lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
- lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
- lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
- lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
- lyrics_transcriber/correction/agentic/router.py +35 -0
- lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
- lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
- lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
- lyrics_transcriber/correction/anchor_sequence.py +1043 -0
- lyrics_transcriber/correction/corrector.py +760 -0
- lyrics_transcriber/correction/feedback/__init__.py +2 -0
- lyrics_transcriber/correction/feedback/schemas.py +107 -0
- lyrics_transcriber/correction/feedback/store.py +236 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +52 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
- lyrics_transcriber/correction/handlers/llm.py +293 -0
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
- lyrics_transcriber/correction/handlers/repeat.py +88 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
- lyrics_transcriber/correction/handlers/word_operations.py +187 -0
- lyrics_transcriber/correction/operations.py +352 -0
- lyrics_transcriber/correction/phrase_analyzer.py +435 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/frontend/.gitignore +23 -0
- lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
- lyrics_transcriber/frontend/.yarnrc.yml +3 -0
- lyrics_transcriber/frontend/README.md +50 -0
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
- lyrics_transcriber/frontend/__init__.py +25 -0
- lyrics_transcriber/frontend/eslint.config.js +28 -0
- lyrics_transcriber/frontend/index.html +18 -0
- lyrics_transcriber/frontend/package.json +42 -0
- lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/public/favicon.ico +0 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/src/App.tsx +212 -0
- lyrics_transcriber/frontend/src/api.ts +239 -0
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
- lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
- lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
- lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +387 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1373 -0
- lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
- lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +688 -0
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
- lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
- lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
- lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
- lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
- lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
- lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
- lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
- lyrics_transcriber/frontend/src/main.tsx +17 -0
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
- lyrics_transcriber/frontend/src/types.js +2 -0
- lyrics_transcriber/frontend/src/types.ts +199 -0
- lyrics_transcriber/frontend/src/validation.ts +132 -0
- lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
- lyrics_transcriber/frontend/tsconfig.app.json +26 -0
- lyrics_transcriber/frontend/tsconfig.json +25 -0
- lyrics_transcriber/frontend/tsconfig.node.json +23 -0
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
- lyrics_transcriber/frontend/update_version.js +11 -0
- lyrics_transcriber/frontend/vite.config.d.ts +2 -0
- lyrics_transcriber/frontend/vite.config.js +10 -0
- lyrics_transcriber/frontend/vite.config.ts +11 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js +42039 -0
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
- lyrics_transcriber/frontend/web_assets/index.html +18 -0
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/yarn.lock +3752 -0
- lyrics_transcriber/lyrics/__init__.py +0 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
- lyrics_transcriber/lyrics/file_provider.py +95 -0
- lyrics_transcriber/lyrics/genius.py +384 -0
- lyrics_transcriber/lyrics/lrclib.py +231 -0
- lyrics_transcriber/lyrics/musixmatch.py +156 -0
- lyrics_transcriber/lyrics/spotify.py +290 -0
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/__init__.py +0 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/ass/ass.py +2088 -0
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +180 -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 +265 -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 +619 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +2260 -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/countdown_processor.py +267 -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 +257 -0
- lyrics_transcriber/output/lrc_to_cdg.py +61 -0
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +96 -0
- lyrics_transcriber/output/segment_resizer.py +431 -0
- lyrics_transcriber/output/subtitles.py +397 -0
- lyrics_transcriber/output/video.py +544 -0
- lyrics_transcriber/review/__init__.py +0 -0
- lyrics_transcriber/review/server.py +676 -0
- lyrics_transcriber/storage/__init__.py +0 -0
- lyrics_transcriber/storage/dropbox.py +225 -0
- lyrics_transcriber/transcribers/__init__.py +0 -0
- lyrics_transcriber/transcribers/audioshake.py +290 -0
- lyrics_transcriber/transcribers/base_transcriber.py +157 -0
- lyrics_transcriber/transcribers/whisper.py +330 -0
- lyrics_transcriber/types.py +648 -0
- lyrics_transcriber/utils/__init__.py +0 -0
- lyrics_transcriber/utils/word_utils.py +27 -0
- karaoke_gen-0.57.0.dist-info/METADATA +0 -167
- karaoke_gen-0.57.0.dist-info/RECORD +0 -23
- {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,2260 @@
|
|
|
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
|
+
|
|
12
|
+
import ffmpeg
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from _typeshed import FileDescriptorOrPath, StrOrBytesPath
|
|
16
|
+
|
|
17
|
+
from attrs import define
|
|
18
|
+
from cattrs import Converter
|
|
19
|
+
from PIL import Image, ImageFont
|
|
20
|
+
from pydub import AudioSegment
|
|
21
|
+
|
|
22
|
+
from .cdg import *
|
|
23
|
+
from .config import *
|
|
24
|
+
from .pack import *
|
|
25
|
+
from .render import *
|
|
26
|
+
from .utils import *
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
|
|
30
|
+
ASS_REQUIREMENTS = True
|
|
31
|
+
try:
|
|
32
|
+
import ass
|
|
33
|
+
from fontTools import ttLib
|
|
34
|
+
|
|
35
|
+
from datetime import timedelta
|
|
36
|
+
except ImportError:
|
|
37
|
+
ASS_REQUIREMENTS = False
|
|
38
|
+
|
|
39
|
+
MP4_REQUIREMENTS = True
|
|
40
|
+
try:
|
|
41
|
+
import ffmpeg
|
|
42
|
+
except ImportError:
|
|
43
|
+
MP4_REQUIREMENTS = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
package_dir = Path(__file__).parent
|
|
47
|
+
|
|
48
|
+
T = TypeVar("T")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def batched(iterable: Iterable[T], n: int) -> Iterable[tuple[T, ...]]:
|
|
52
|
+
"Batch data into tuples of length n. The last batch may be shorter."
|
|
53
|
+
# batched('ABCDEFG', 3) --> ABC DEF G
|
|
54
|
+
itobj = iter(iterable)
|
|
55
|
+
while True:
|
|
56
|
+
batch = tuple(it.islice(itobj, n))
|
|
57
|
+
if not batch:
|
|
58
|
+
return
|
|
59
|
+
yield batch
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def file_relative_to(
|
|
63
|
+
filepath: "StrOrBytesPath | Path",
|
|
64
|
+
*relative_to: "StrOrBytesPath | Path",
|
|
65
|
+
) -> Path:
|
|
66
|
+
"""
|
|
67
|
+
Convert possibly relative filepath to absolute path, relative to any
|
|
68
|
+
of the paths in `relative_to`, or to the parent directory of this
|
|
69
|
+
very Python file itself.
|
|
70
|
+
|
|
71
|
+
If the filepath is already absolute, it is returned unchanged.
|
|
72
|
+
Otherwise, the first absolute filepath found to exist as a file will
|
|
73
|
+
be returned.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
filepath : path-like
|
|
78
|
+
Filepath.
|
|
79
|
+
*relative_to
|
|
80
|
+
The filepath will be given as relative to these paths.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
`pathlib.Path`
|
|
85
|
+
Absolute path relative to given directories, if any exist as
|
|
86
|
+
files.
|
|
87
|
+
"""
|
|
88
|
+
filepath = Path(filepath)
|
|
89
|
+
if filepath.is_absolute():
|
|
90
|
+
return filepath
|
|
91
|
+
|
|
92
|
+
# If all else fails, check filepath relative to this file
|
|
93
|
+
relative_to += (Path(__file__).parent,)
|
|
94
|
+
for rel in relative_to:
|
|
95
|
+
outpath = Path(rel) / filepath
|
|
96
|
+
if outpath.is_file():
|
|
97
|
+
return outpath
|
|
98
|
+
|
|
99
|
+
# Add more detailed error information
|
|
100
|
+
searched_paths = [str(Path(rel) / filepath) for rel in relative_to]
|
|
101
|
+
raise FileNotFoundError(f"File not found: {filepath}. Searched in: {', '.join(searched_paths)}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sync_to_cdg(cs: int) -> int:
|
|
105
|
+
"""
|
|
106
|
+
Convert sync time to CDG frame time to the nearest frame.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
cs : int
|
|
111
|
+
Time in centiseconds (100ths of a second).
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
int
|
|
116
|
+
Equivalent time in CDG frames.
|
|
117
|
+
"""
|
|
118
|
+
return cs * CDG_FPS // 100
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cdg_to_sync(fs: int) -> int:
|
|
122
|
+
"""
|
|
123
|
+
Convert CDG frame time to sync time to the nearest centisecond.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
fs : int
|
|
128
|
+
Time in CDG frames.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
int
|
|
133
|
+
Equivalent time in centiseconds (100ths of a second).
|
|
134
|
+
"""
|
|
135
|
+
return fs * 100 // CDG_FPS
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@define
|
|
139
|
+
class SyllableInfo:
|
|
140
|
+
mask: Image.Image
|
|
141
|
+
text: str
|
|
142
|
+
start_offset: int
|
|
143
|
+
end_offset: int
|
|
144
|
+
left_edge: int
|
|
145
|
+
right_edge: int
|
|
146
|
+
lyric_index: int
|
|
147
|
+
line_index: int
|
|
148
|
+
syllable_index: int
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@define
|
|
152
|
+
class LineInfo:
|
|
153
|
+
image: Image.Image
|
|
154
|
+
text: str
|
|
155
|
+
syllables: list[SyllableInfo]
|
|
156
|
+
x: int
|
|
157
|
+
y: int
|
|
158
|
+
singer: int
|
|
159
|
+
lyric_index: int
|
|
160
|
+
line_index: int
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class LyricInfo(NamedTuple):
|
|
164
|
+
lines: list[LineInfo]
|
|
165
|
+
line_tile_height: int
|
|
166
|
+
lines_per_page: int
|
|
167
|
+
lyric_index: int
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@define
|
|
171
|
+
class LyricTimes:
|
|
172
|
+
line_draw: list[int]
|
|
173
|
+
line_erase: list[int]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@define
|
|
177
|
+
class LyricState:
|
|
178
|
+
line_draw: int
|
|
179
|
+
line_erase: int
|
|
180
|
+
syllable_line: int
|
|
181
|
+
syllable_index: int
|
|
182
|
+
draw_queue: deque[CDGPacket]
|
|
183
|
+
highlight_queue: deque[list[CDGPacket]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@define
|
|
187
|
+
class ComposerState:
|
|
188
|
+
instrumental: int
|
|
189
|
+
this_page: int
|
|
190
|
+
last_page: int
|
|
191
|
+
just_cleared: bool
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class KaraokeComposer:
|
|
195
|
+
BACKGROUND = 0
|
|
196
|
+
BORDER = 1
|
|
197
|
+
UNUSED_COLOR = (0, 0, 0)
|
|
198
|
+
|
|
199
|
+
# region Constructors
|
|
200
|
+
# SECTION Constructors
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
config: Settings,
|
|
204
|
+
relative_dir: "StrOrBytesPath | Path" = "",
|
|
205
|
+
logger=None,
|
|
206
|
+
):
|
|
207
|
+
self.config = config
|
|
208
|
+
self.relative_dir = Path(relative_dir)
|
|
209
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
210
|
+
|
|
211
|
+
self.logger.debug("loading config settings")
|
|
212
|
+
|
|
213
|
+
font_path = self.config.font
|
|
214
|
+
self.logger.debug(f"font_path: {font_path}")
|
|
215
|
+
try:
|
|
216
|
+
# First, use the font path directly from the config
|
|
217
|
+
if not Path(font_path).is_file():
|
|
218
|
+
# Try to find the font relative to the config file
|
|
219
|
+
font_path = Path(self.relative_dir) / font_path
|
|
220
|
+
if not font_path.is_file():
|
|
221
|
+
# If not found, try to find it in the package fonts directory
|
|
222
|
+
font_path = package_dir / "fonts" / Path(self.config.font).name
|
|
223
|
+
if not font_path.is_file():
|
|
224
|
+
raise FileNotFoundError(f"Font file not found: {self.config.font}")
|
|
225
|
+
self.font = ImageFont.truetype(str(font_path), self.config.font_size)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
self.logger.error(f"Error loading font: {e}")
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
# Set color table for lyrics sections
|
|
231
|
+
# NOTE At the moment, this only allows for up to 3 singers, with
|
|
232
|
+
# distinct color indices for active/inactive fills/strokes.
|
|
233
|
+
# REVIEW Could this be smarter? Perhaps if some colors are
|
|
234
|
+
# reused/omitted, color indices could be organized in a
|
|
235
|
+
# different way that allows for more singers at a time.
|
|
236
|
+
self.color_table = [
|
|
237
|
+
self.config.background,
|
|
238
|
+
self.config.border or self.UNUSED_COLOR,
|
|
239
|
+
self.UNUSED_COLOR,
|
|
240
|
+
self.UNUSED_COLOR,
|
|
241
|
+
]
|
|
242
|
+
for singer in self.config.singers:
|
|
243
|
+
self.color_table.extend(
|
|
244
|
+
[
|
|
245
|
+
singer.inactive_fill,
|
|
246
|
+
singer.inactive_stroke,
|
|
247
|
+
singer.active_fill,
|
|
248
|
+
singer.active_stroke,
|
|
249
|
+
]
|
|
250
|
+
)
|
|
251
|
+
self.color_table = list(
|
|
252
|
+
pad(
|
|
253
|
+
self.color_table,
|
|
254
|
+
16,
|
|
255
|
+
padvalue=self.UNUSED_COLOR,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
self.logger.debug(f"Color table: {self.color_table}")
|
|
259
|
+
|
|
260
|
+
self.max_tile_height = 0
|
|
261
|
+
self.lyrics: list[LyricInfo] = []
|
|
262
|
+
# Process lyric sets
|
|
263
|
+
for ci, lyric in enumerate(self.config.lyrics):
|
|
264
|
+
self.logger.debug(f"processing config lyric {ci}")
|
|
265
|
+
lines: list[list[str]] = []
|
|
266
|
+
line_singers: list[int] = []
|
|
267
|
+
for textline in re.split(r"\n+", lyric.text):
|
|
268
|
+
textline: str
|
|
269
|
+
|
|
270
|
+
# Assign singer
|
|
271
|
+
if "|" in textline:
|
|
272
|
+
singer, textline = textline.split("|")
|
|
273
|
+
singer = int(singer)
|
|
274
|
+
else:
|
|
275
|
+
singer = lyric.singer
|
|
276
|
+
|
|
277
|
+
textline = textline.strip()
|
|
278
|
+
# Tildes signify empty lines
|
|
279
|
+
if textline == "~":
|
|
280
|
+
syllables = []
|
|
281
|
+
else:
|
|
282
|
+
syllables = [
|
|
283
|
+
# Replace underscores in syllables with spaces
|
|
284
|
+
syllable.replace("_", " ")
|
|
285
|
+
for syllable in it.chain.from_iterable(
|
|
286
|
+
# Split syllables at slashes
|
|
287
|
+
cast(str, word).split("/")
|
|
288
|
+
# Split words after one space and possibly
|
|
289
|
+
# before other spaces
|
|
290
|
+
for word in re.split(r"(?<= )(?<! ) *", textline)
|
|
291
|
+
)
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
self.logger.debug(f"singer {singer}: {syllables}")
|
|
295
|
+
lines.append(syllables)
|
|
296
|
+
line_singers.append(singer)
|
|
297
|
+
|
|
298
|
+
self.logger.debug(f"rendering line images and masks for lyric {ci}")
|
|
299
|
+
line_images, line_masks = render_lines_and_masks(
|
|
300
|
+
lines,
|
|
301
|
+
font=self.font,
|
|
302
|
+
stroke_width=self.config.stroke_width,
|
|
303
|
+
stroke_type=self.config.stroke_type,
|
|
304
|
+
logger=self.logger,
|
|
305
|
+
)
|
|
306
|
+
max_height = 0
|
|
307
|
+
for li, image in enumerate(line_images):
|
|
308
|
+
if image.width > CDG_VISIBLE_WIDTH:
|
|
309
|
+
self.logger.warning(
|
|
310
|
+
f"line {li} too wide\n"
|
|
311
|
+
f"max width is {CDG_VISIBLE_WIDTH} pixel(s); "
|
|
312
|
+
f"actual width is {image.width} pixel(s)\n"
|
|
313
|
+
f"\t{''.join(lines[li])}"
|
|
314
|
+
)
|
|
315
|
+
max_height = max(max_height, image.height)
|
|
316
|
+
|
|
317
|
+
tile_height = ceildiv(max_height, CDG_TILE_HEIGHT)
|
|
318
|
+
self.max_tile_height = max(self.max_tile_height, tile_height)
|
|
319
|
+
|
|
320
|
+
lyric_lines: list[LineInfo] = []
|
|
321
|
+
sync_i = 0
|
|
322
|
+
self.logger.debug(f"setting sync points for lyric {ci}")
|
|
323
|
+
for li, (line, singer, line_image, line_mask) in enumerate(
|
|
324
|
+
zip(
|
|
325
|
+
lines,
|
|
326
|
+
line_singers,
|
|
327
|
+
line_images,
|
|
328
|
+
line_masks,
|
|
329
|
+
)
|
|
330
|
+
):
|
|
331
|
+
# Center line horizontally
|
|
332
|
+
x = (CDG_SCREEN_WIDTH - line_image.width) // 2
|
|
333
|
+
# Place line on correct row
|
|
334
|
+
y = lyric.row * CDG_TILE_HEIGHT + ((li % lyric.lines_per_page) * lyric.line_tile_height * CDG_TILE_HEIGHT)
|
|
335
|
+
|
|
336
|
+
# Get enough sync points for this line's syllables
|
|
337
|
+
line_sync = lyric.sync[sync_i : sync_i + len(line)]
|
|
338
|
+
sync_i += len(line)
|
|
339
|
+
if line_sync:
|
|
340
|
+
# The last syllable ends 0.45 seconds after it
|
|
341
|
+
# starts...
|
|
342
|
+
next_sync_point = line_sync[-1] + 45
|
|
343
|
+
if sync_i < len(lyric.sync):
|
|
344
|
+
# ...or when the first syllable of the next line
|
|
345
|
+
# starts, whichever comes first
|
|
346
|
+
next_sync_point = min(
|
|
347
|
+
next_sync_point,
|
|
348
|
+
lyric.sync[sync_i],
|
|
349
|
+
)
|
|
350
|
+
line_sync.append(next_sync_point)
|
|
351
|
+
|
|
352
|
+
# Collect this line's syllables
|
|
353
|
+
syllables: list[SyllableInfo] = []
|
|
354
|
+
for si, (mask, syllable, (start, end)) in enumerate(
|
|
355
|
+
zip(
|
|
356
|
+
line_mask,
|
|
357
|
+
line,
|
|
358
|
+
it.pairwise(line_sync),
|
|
359
|
+
)
|
|
360
|
+
):
|
|
361
|
+
# NOTE Left and right edges here are relative to the
|
|
362
|
+
# mask. They will be stored relative to the screen.
|
|
363
|
+
left_edge, right_edge = 0, 0
|
|
364
|
+
bbox = mask.getbbox()
|
|
365
|
+
if bbox is not None:
|
|
366
|
+
left_edge, _, right_edge, _ = bbox
|
|
367
|
+
|
|
368
|
+
syllables.append(
|
|
369
|
+
SyllableInfo(
|
|
370
|
+
mask=mask,
|
|
371
|
+
text=syllable,
|
|
372
|
+
start_offset=sync_to_cdg(start),
|
|
373
|
+
end_offset=sync_to_cdg(end),
|
|
374
|
+
left_edge=left_edge + x,
|
|
375
|
+
right_edge=right_edge + x,
|
|
376
|
+
lyric_index=ci,
|
|
377
|
+
line_index=li,
|
|
378
|
+
syllable_index=si,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
lyric_lines.append(
|
|
383
|
+
LineInfo(
|
|
384
|
+
image=line_image,
|
|
385
|
+
text="".join(line),
|
|
386
|
+
syllables=syllables,
|
|
387
|
+
x=x,
|
|
388
|
+
y=y,
|
|
389
|
+
singer=singer,
|
|
390
|
+
lyric_index=ci,
|
|
391
|
+
line_index=li,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
self.lyrics.append(
|
|
396
|
+
LyricInfo(
|
|
397
|
+
lines=lyric_lines,
|
|
398
|
+
line_tile_height=tile_height,
|
|
399
|
+
lines_per_page=lyric.lines_per_page,
|
|
400
|
+
lyric_index=ci,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Add vertical offset to lines to vertically center them
|
|
405
|
+
max_height = max(line.image.height for lyric in self.lyrics for line in lyric.lines)
|
|
406
|
+
line_offset = (self.max_tile_height * CDG_TILE_HEIGHT - max_height) // 2
|
|
407
|
+
self.logger.debug(f"lines will be vertically offset by {line_offset} pixel(s)")
|
|
408
|
+
if line_offset:
|
|
409
|
+
for lyric in self.lyrics:
|
|
410
|
+
for line in lyric.lines:
|
|
411
|
+
line.y += line_offset
|
|
412
|
+
|
|
413
|
+
self.sync_offset = sync_to_cdg(self.config.sync_offset)
|
|
414
|
+
|
|
415
|
+
self.writer = CDGWriter()
|
|
416
|
+
self.logger.info("config settings loaded")
|
|
417
|
+
|
|
418
|
+
self._set_draw_times()
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def from_file(
|
|
422
|
+
cls,
|
|
423
|
+
file: "FileDescriptorOrPath",
|
|
424
|
+
logger=None,
|
|
425
|
+
) -> Self:
|
|
426
|
+
converter = Converter(prefer_attrib_converters=True)
|
|
427
|
+
relative_dir = Path(file).parent
|
|
428
|
+
with open(file, "rb") as stream:
|
|
429
|
+
return cls(
|
|
430
|
+
converter.structure(tomllib.load(stream), Settings),
|
|
431
|
+
relative_dir=relative_dir,
|
|
432
|
+
logger=logger,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
@classmethod
|
|
436
|
+
def from_string(
|
|
437
|
+
cls,
|
|
438
|
+
config: str,
|
|
439
|
+
relative_dir: "StrOrBytesPath | Path" = "",
|
|
440
|
+
) -> Self:
|
|
441
|
+
converter = Converter(prefer_attrib_converters=True)
|
|
442
|
+
return cls(
|
|
443
|
+
converter.structure(tomllib.loads(config), Settings),
|
|
444
|
+
relative_dir=relative_dir,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# !SECTION
|
|
448
|
+
# endregion
|
|
449
|
+
|
|
450
|
+
# region Set draw times
|
|
451
|
+
# SECTION Set draw times
|
|
452
|
+
# Gap between line draw/erase events = 1/6 second
|
|
453
|
+
LINE_DRAW_ERASE_GAP = CDG_FPS // 6
|
|
454
|
+
|
|
455
|
+
# TODO Make more values in these set-draw-times functions into named
|
|
456
|
+
# constants
|
|
457
|
+
|
|
458
|
+
def _set_draw_times(self):
|
|
459
|
+
self.lyric_times: list[LyricTimes] = []
|
|
460
|
+
for lyric in self.lyrics:
|
|
461
|
+
self.logger.debug(f"setting draw times for lyric {lyric.lyric_index}")
|
|
462
|
+
line_count = len(lyric.lines)
|
|
463
|
+
line_draw: list[int] = [0] * line_count
|
|
464
|
+
line_erase: list[int] = [0] * line_count
|
|
465
|
+
|
|
466
|
+
# The first page is drawn 3 seconds before the first
|
|
467
|
+
# syllable
|
|
468
|
+
first_syllable = next(iter(syllable_info for line_info in lyric.lines for syllable_info in line_info.syllables))
|
|
469
|
+
draw_time = first_syllable.start_offset - 900
|
|
470
|
+
for i in range(lyric.lines_per_page):
|
|
471
|
+
if i < line_count:
|
|
472
|
+
line_draw[i] = draw_time
|
|
473
|
+
draw_time += self.LINE_DRAW_ERASE_GAP
|
|
474
|
+
|
|
475
|
+
# For each pair of syllables
|
|
476
|
+
for last_wipe, wipe in it.pairwise(syllable_info for line_info in lyric.lines for syllable_info in line_info.syllables):
|
|
477
|
+
# Skip if not on a line boundary
|
|
478
|
+
if wipe.line_index <= last_wipe.line_index:
|
|
479
|
+
continue
|
|
480
|
+
|
|
481
|
+
# Set draw times for lines
|
|
482
|
+
match self.config.clear_mode:
|
|
483
|
+
case LyricClearMode.PAGE:
|
|
484
|
+
self._set_draw_times_page(
|
|
485
|
+
last_wipe,
|
|
486
|
+
wipe,
|
|
487
|
+
lyric=lyric,
|
|
488
|
+
line_draw=line_draw,
|
|
489
|
+
line_erase=line_erase,
|
|
490
|
+
)
|
|
491
|
+
case LyricClearMode.LINE_EAGER:
|
|
492
|
+
self._set_draw_times_line_eager(
|
|
493
|
+
last_wipe,
|
|
494
|
+
wipe,
|
|
495
|
+
lyric=lyric,
|
|
496
|
+
line_draw=line_draw,
|
|
497
|
+
line_erase=line_erase,
|
|
498
|
+
)
|
|
499
|
+
case LyricClearMode.LINE_DELAYED | _:
|
|
500
|
+
self._set_draw_times_line_delayed(
|
|
501
|
+
last_wipe,
|
|
502
|
+
wipe,
|
|
503
|
+
lyric=lyric,
|
|
504
|
+
line_draw=line_draw,
|
|
505
|
+
line_erase=line_erase,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# If clearing page by page
|
|
509
|
+
if self.config.clear_mode == LyricClearMode.PAGE:
|
|
510
|
+
# Don't actually erase any lines
|
|
511
|
+
line_erase = []
|
|
512
|
+
# If we're not clearing page by page
|
|
513
|
+
else:
|
|
514
|
+
end_line = wipe.line_index
|
|
515
|
+
# Calculate the erase time of the last highlighted line
|
|
516
|
+
erase_time = wipe.end_offset + 600
|
|
517
|
+
line_erase[end_line] = erase_time
|
|
518
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
519
|
+
|
|
520
|
+
self.logger.debug(f"lyric {lyric.lyric_index} draw times: {line_draw!r}")
|
|
521
|
+
self.logger.debug(f"lyric {lyric.lyric_index} erase times: {line_erase!r}")
|
|
522
|
+
self.lyric_times.append(
|
|
523
|
+
LyricTimes(
|
|
524
|
+
line_draw=line_draw,
|
|
525
|
+
line_erase=line_erase,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
self.logger.info("draw times set")
|
|
529
|
+
|
|
530
|
+
def _set_draw_times_page(
|
|
531
|
+
self,
|
|
532
|
+
last_wipe: SyllableInfo,
|
|
533
|
+
wipe: SyllableInfo,
|
|
534
|
+
lyric: LyricInfo,
|
|
535
|
+
line_draw: list[int],
|
|
536
|
+
line_erase: list[int],
|
|
537
|
+
):
|
|
538
|
+
line_count = len(lyric.lines)
|
|
539
|
+
last_page = last_wipe.line_index // lyric.lines_per_page
|
|
540
|
+
this_page = wipe.line_index // lyric.lines_per_page
|
|
541
|
+
|
|
542
|
+
# Skip if not on a page boundary
|
|
543
|
+
if this_page <= last_page:
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
# This page starts at the later of:
|
|
547
|
+
# - a few frames after the end of the last line
|
|
548
|
+
# - 3 seconds before this line
|
|
549
|
+
page_draw_time = max(
|
|
550
|
+
last_wipe.end_offset + 12,
|
|
551
|
+
wipe.start_offset - 900,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Calculate the available time between the start of this line
|
|
555
|
+
# and the desired page draw time
|
|
556
|
+
available_time = wipe.start_offset - page_draw_time
|
|
557
|
+
# Calculate the absolute minimum time from the last line to this
|
|
558
|
+
# line
|
|
559
|
+
# NOTE This is a sensible minimum, but not guaranteed.
|
|
560
|
+
minimum_time = wipe.start_offset - last_wipe.start_offset - 24
|
|
561
|
+
|
|
562
|
+
# Warn the user if there's not likely to be enough time
|
|
563
|
+
if minimum_time < 32:
|
|
564
|
+
self.logger.warning("not enough bandwidth to clear screen on lyric " f"{wipe.lyric_index} line {wipe.line_index}")
|
|
565
|
+
|
|
566
|
+
# If there's not enough time between the end of the last line
|
|
567
|
+
# and the start of this line, but there is enough time between
|
|
568
|
+
# the start of the last line and the start of this page
|
|
569
|
+
if available_time < 32:
|
|
570
|
+
# Shorten the last wipe's duration to make room
|
|
571
|
+
new_duration = wipe.start_offset - last_wipe.start_offset - 150
|
|
572
|
+
if new_duration > 0:
|
|
573
|
+
last_wipe.end_offset = last_wipe.start_offset + new_duration
|
|
574
|
+
page_draw_time = last_wipe.end_offset + 12
|
|
575
|
+
else:
|
|
576
|
+
last_wipe.end_offset = last_wipe.start_offset
|
|
577
|
+
page_draw_time = last_wipe.end_offset + 32
|
|
578
|
+
|
|
579
|
+
# Set the draw times for lines on this page
|
|
580
|
+
start_line = this_page * lyric.lines_per_page
|
|
581
|
+
for i in range(start_line, start_line + lyric.lines_per_page):
|
|
582
|
+
if i < line_count:
|
|
583
|
+
line_draw[i] = page_draw_time
|
|
584
|
+
page_draw_time += self.LINE_DRAW_ERASE_GAP
|
|
585
|
+
|
|
586
|
+
def _set_draw_times_line_eager(
|
|
587
|
+
self,
|
|
588
|
+
last_wipe: SyllableInfo,
|
|
589
|
+
wipe: SyllableInfo,
|
|
590
|
+
lyric: LyricInfo,
|
|
591
|
+
line_draw: list[int],
|
|
592
|
+
line_erase: list[int],
|
|
593
|
+
):
|
|
594
|
+
line_count = len(lyric.lines)
|
|
595
|
+
last_page = last_wipe.line_index // lyric.lines_per_page
|
|
596
|
+
this_page = wipe.line_index // lyric.lines_per_page
|
|
597
|
+
|
|
598
|
+
# The last line should be erased near the start of this line
|
|
599
|
+
erase_time = wipe.start_offset
|
|
600
|
+
|
|
601
|
+
# If we're not on the next page
|
|
602
|
+
if last_page >= this_page:
|
|
603
|
+
# The last line is erased 1/3 seconds after the start of
|
|
604
|
+
# this line
|
|
605
|
+
erase_time += 100
|
|
606
|
+
|
|
607
|
+
# Set draw and erase times for the last line
|
|
608
|
+
for i in range(last_wipe.line_index, wipe.line_index):
|
|
609
|
+
if i < line_count:
|
|
610
|
+
line_erase[i] = erase_time
|
|
611
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
612
|
+
j = i + lyric.lines_per_page
|
|
613
|
+
if j < line_count:
|
|
614
|
+
line_draw[j] = erase_time
|
|
615
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
616
|
+
return
|
|
617
|
+
# If we're here, we're on the next page
|
|
618
|
+
|
|
619
|
+
last_wipe_end = last_wipe.end_offset
|
|
620
|
+
inter_wipe_time = wipe.start_offset - last_wipe_end
|
|
621
|
+
|
|
622
|
+
# The last line is erased at the earlier of:
|
|
623
|
+
# - halfway between the pages
|
|
624
|
+
# - 1.5 seconds after the last line
|
|
625
|
+
erase_time = min(
|
|
626
|
+
last_wipe_end + inter_wipe_time // 2,
|
|
627
|
+
last_wipe_end + 450,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
# If time between pages is less than 8 seconds
|
|
631
|
+
if inter_wipe_time < 2400:
|
|
632
|
+
# Set draw and erase times for the last line
|
|
633
|
+
for i in range(last_wipe.line_index, wipe.line_index):
|
|
634
|
+
if i < line_count:
|
|
635
|
+
line_erase[i] = erase_time
|
|
636
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
637
|
+
j = i + lyric.lines_per_page
|
|
638
|
+
if j < line_count:
|
|
639
|
+
line_draw[j] = erase_time
|
|
640
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
641
|
+
# If time between pages is 8 seconds or longer
|
|
642
|
+
else:
|
|
643
|
+
# Set erase time for the last line
|
|
644
|
+
for i in range(last_wipe.line_index, wipe.line_index):
|
|
645
|
+
if i < line_count:
|
|
646
|
+
line_erase[i] = erase_time
|
|
647
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
648
|
+
|
|
649
|
+
# The new page will be drawn 3 seconds before the start of
|
|
650
|
+
# this line
|
|
651
|
+
draw_time = wipe.start_offset - 900
|
|
652
|
+
start_line = wipe.line_index
|
|
653
|
+
for i in range(start_line, start_line + lyric.lines_per_page):
|
|
654
|
+
if i < line_count:
|
|
655
|
+
line_draw[i] = draw_time
|
|
656
|
+
draw_time += self.LINE_DRAW_ERASE_GAP
|
|
657
|
+
|
|
658
|
+
def _set_draw_times_line_delayed(
|
|
659
|
+
self,
|
|
660
|
+
last_wipe: SyllableInfo,
|
|
661
|
+
wipe: SyllableInfo,
|
|
662
|
+
lyric: LyricInfo,
|
|
663
|
+
line_draw: list[int],
|
|
664
|
+
line_erase: list[int],
|
|
665
|
+
):
|
|
666
|
+
line_count = len(lyric.lines)
|
|
667
|
+
last_page = last_wipe.line_index // lyric.lines_per_page
|
|
668
|
+
this_page = wipe.line_index // lyric.lines_per_page
|
|
669
|
+
|
|
670
|
+
# If we're on the same page
|
|
671
|
+
if last_page == this_page:
|
|
672
|
+
# The last line will be erased at the earlier of:
|
|
673
|
+
# - 1/3 seconds after the start of this line
|
|
674
|
+
# - 1.5 seconds after the end of the last line
|
|
675
|
+
erase_time = min(
|
|
676
|
+
wipe.start_offset + 100,
|
|
677
|
+
last_wipe.end_offset + 450,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# Set erase time for the last line
|
|
681
|
+
for i in range(last_wipe.line_index, wipe.line_index):
|
|
682
|
+
if i < line_count:
|
|
683
|
+
line_erase[i] = erase_time
|
|
684
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
685
|
+
return
|
|
686
|
+
# If we're here, we're on the next page
|
|
687
|
+
|
|
688
|
+
last_wipe_end = max(
|
|
689
|
+
last_wipe.end_offset,
|
|
690
|
+
last_wipe.start_offset + 100,
|
|
691
|
+
)
|
|
692
|
+
inter_wipe_time = wipe.start_offset - last_wipe_end
|
|
693
|
+
|
|
694
|
+
last_line_start_offset = lyric.lines[last_wipe.line_index].syllables[0].start_offset
|
|
695
|
+
|
|
696
|
+
# The last line will be erased at the earlier of:
|
|
697
|
+
# - 1/3 seconds after the start of this line
|
|
698
|
+
# - 1.5 seconds after the end of the last line
|
|
699
|
+
# - 1/3 of the way between the pages
|
|
700
|
+
erase_time = min(
|
|
701
|
+
wipe.start_offset + 100,
|
|
702
|
+
last_wipe_end + 450,
|
|
703
|
+
last_wipe_end + inter_wipe_time // 3,
|
|
704
|
+
)
|
|
705
|
+
# This line will be drawn at the latest of:
|
|
706
|
+
# - 1/3 seconds after the start of the last line
|
|
707
|
+
# - 3 seconds before the start of this line
|
|
708
|
+
# - 1/3 of the way between the pages
|
|
709
|
+
draw_time = max(
|
|
710
|
+
last_line_start_offset + 100,
|
|
711
|
+
wipe.start_offset - 900,
|
|
712
|
+
last_wipe_end + inter_wipe_time // 3,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# If time between pages is 4 seconds or more, clear current page
|
|
716
|
+
# lines before drawing new page lines
|
|
717
|
+
if inter_wipe_time >= 1200:
|
|
718
|
+
# Set erase times for lines on previous page
|
|
719
|
+
for i in range(last_wipe.line_index, wipe.line_index):
|
|
720
|
+
if i < line_count:
|
|
721
|
+
line_erase[i] = erase_time
|
|
722
|
+
erase_time += self.LINE_DRAW_ERASE_GAP
|
|
723
|
+
|
|
724
|
+
draw_time = max(draw_time, erase_time)
|
|
725
|
+
start_line = last_page * lyric.lines_per_page
|
|
726
|
+
# Set draw times for lines on this page
|
|
727
|
+
for i in range(start_line, start_line + lyric.lines_per_page):
|
|
728
|
+
j = i + lyric.lines_per_page
|
|
729
|
+
if j < line_count:
|
|
730
|
+
line_draw[j] = draw_time
|
|
731
|
+
draw_time += self.LINE_DRAW_ERASE_GAP
|
|
732
|
+
return
|
|
733
|
+
# If time between pages is less than 4 seconds, draw new page
|
|
734
|
+
# lines before clearing current page lines
|
|
735
|
+
|
|
736
|
+
# The first lines on the next page should be drawn 1/2 seconds
|
|
737
|
+
# after the start of the last line
|
|
738
|
+
draw_time = last_line_start_offset + 150
|
|
739
|
+
|
|
740
|
+
# Set draw time for all lines on the next page before this line
|
|
741
|
+
start_line = last_page * lyric.lines_per_page
|
|
742
|
+
for i in range(start_line, last_wipe.line_index):
|
|
743
|
+
j = i + lyric.lines_per_page
|
|
744
|
+
if j < line_count:
|
|
745
|
+
line_draw[j] = draw_time
|
|
746
|
+
draw_time += self.LINE_DRAW_ERASE_GAP
|
|
747
|
+
|
|
748
|
+
# The last lines on the next page should be drawn at least 1/3
|
|
749
|
+
# of the way between the pages
|
|
750
|
+
draw_time = max(
|
|
751
|
+
draw_time,
|
|
752
|
+
last_wipe_end + inter_wipe_time // 3,
|
|
753
|
+
)
|
|
754
|
+
# Set erase times for the rest of the lines on the previous page
|
|
755
|
+
for i in range(last_wipe.line_index, wipe.line_index):
|
|
756
|
+
if i < line_count:
|
|
757
|
+
line_erase[i] = draw_time
|
|
758
|
+
draw_time += self.LINE_DRAW_ERASE_GAP
|
|
759
|
+
# Set draw times for the rest of the lines on this page
|
|
760
|
+
for i in range(last_wipe.line_index, wipe.line_index):
|
|
761
|
+
j = i + lyric.lines_per_page
|
|
762
|
+
if j < line_count:
|
|
763
|
+
line_draw[j] = draw_time
|
|
764
|
+
draw_time += self.LINE_DRAW_ERASE_GAP
|
|
765
|
+
|
|
766
|
+
# !SECTION
|
|
767
|
+
# endregion
|
|
768
|
+
|
|
769
|
+
# region Compose words
|
|
770
|
+
# SECTION Compose words
|
|
771
|
+
def compose(self):
|
|
772
|
+
try:
|
|
773
|
+
# NOTE Logistically, multiple simultaneous lyric sets doesn't
|
|
774
|
+
# make sense if the lyrics are being cleared by page.
|
|
775
|
+
if self.config.clear_mode == LyricClearMode.PAGE and len(self.lyrics) > 1:
|
|
776
|
+
raise RuntimeError("page mode doesn't support more than one lyric set")
|
|
777
|
+
|
|
778
|
+
self.logger.debug("loading song file")
|
|
779
|
+
song: AudioSegment = AudioSegment.from_file(file_relative_to(self.config.file, self.relative_dir))
|
|
780
|
+
self.logger.info("song file loaded")
|
|
781
|
+
|
|
782
|
+
self.lyric_packet_indices: set[int] = set()
|
|
783
|
+
self.instrumental_times: list[int] = []
|
|
784
|
+
|
|
785
|
+
self.intro_delay = 0
|
|
786
|
+
# Compose the intro
|
|
787
|
+
# NOTE This also sets the intro delay for later.
|
|
788
|
+
self._compose_intro()
|
|
789
|
+
|
|
790
|
+
lyric_states: list[LyricState] = []
|
|
791
|
+
for lyric in self.lyrics:
|
|
792
|
+
lyric_states.append(
|
|
793
|
+
LyricState(
|
|
794
|
+
line_draw=0,
|
|
795
|
+
line_erase=0,
|
|
796
|
+
syllable_line=0,
|
|
797
|
+
syllable_index=0,
|
|
798
|
+
draw_queue=deque(),
|
|
799
|
+
highlight_queue=deque(),
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
composer_state = ComposerState(
|
|
804
|
+
instrumental=0,
|
|
805
|
+
this_page=0,
|
|
806
|
+
last_page=0,
|
|
807
|
+
just_cleared=False,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# XXX If there is an instrumental section immediately after the
|
|
811
|
+
# intro, the screen should not be cleared. The way I'm detecting
|
|
812
|
+
# this, however, is by (mostly) copy-pasting the code that
|
|
813
|
+
# checks for instrumental sections. I shouldn't do it this way.
|
|
814
|
+
current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
|
|
815
|
+
should_instrumental = False
|
|
816
|
+
instrumental = None
|
|
817
|
+
if composer_state.instrumental < len(self.config.instrumentals):
|
|
818
|
+
instrumental = self.config.instrumentals[composer_state.instrumental]
|
|
819
|
+
instrumental_time = sync_to_cdg(instrumental.sync)
|
|
820
|
+
# NOTE Normally, this part has code to handle waiting for a
|
|
821
|
+
# lyric to finish. If there's an instrumental this early,
|
|
822
|
+
# however, there shouldn't be any lyrics to finish.
|
|
823
|
+
should_instrumental = current_time >= instrumental_time
|
|
824
|
+
# If there should not be an instrumental section now
|
|
825
|
+
if not should_instrumental:
|
|
826
|
+
self.logger.debug("instrumental intro is not present; clearing")
|
|
827
|
+
# Clear the screen
|
|
828
|
+
self.writer.queue_packets(
|
|
829
|
+
[
|
|
830
|
+
*memory_preset_repeat(self.BACKGROUND),
|
|
831
|
+
*load_color_table(self.color_table),
|
|
832
|
+
]
|
|
833
|
+
)
|
|
834
|
+
if self.config.border is not None:
|
|
835
|
+
self.writer.queue_packet(border_preset(self.BORDER))
|
|
836
|
+
else:
|
|
837
|
+
self.logger.debug("instrumental intro is present; not clearing")
|
|
838
|
+
|
|
839
|
+
# While there are lines to draw/erase, or syllables to
|
|
840
|
+
# highlight, or events in the highlight/draw queues, or
|
|
841
|
+
# instrumental sections to process
|
|
842
|
+
while any(
|
|
843
|
+
state.line_draw < len(times.line_draw)
|
|
844
|
+
or state.line_erase < len(times.line_erase)
|
|
845
|
+
or state.syllable_line < len(lyric.lines)
|
|
846
|
+
or state.draw_queue
|
|
847
|
+
or state.highlight_queue
|
|
848
|
+
for lyric, times, state in zip(
|
|
849
|
+
self.lyrics,
|
|
850
|
+
self.lyric_times,
|
|
851
|
+
lyric_states,
|
|
852
|
+
)
|
|
853
|
+
) or (composer_state.instrumental < len(self.config.instrumentals)):
|
|
854
|
+
for lyric, times, state in zip(
|
|
855
|
+
self.lyrics,
|
|
856
|
+
self.lyric_times,
|
|
857
|
+
lyric_states,
|
|
858
|
+
):
|
|
859
|
+
self._compose_lyric(
|
|
860
|
+
lyric=lyric,
|
|
861
|
+
times=times,
|
|
862
|
+
state=state,
|
|
863
|
+
lyric_states=lyric_states,
|
|
864
|
+
composer_state=composer_state,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
# Add audio padding to intro
|
|
868
|
+
self.logger.debug("padding intro of audio file")
|
|
869
|
+
intro_silence: AudioSegment = AudioSegment.silent(
|
|
870
|
+
self.intro_delay * 1000 // CDG_FPS,
|
|
871
|
+
frame_rate=song.frame_rate,
|
|
872
|
+
)
|
|
873
|
+
self.audio = intro_silence + song
|
|
874
|
+
|
|
875
|
+
# NOTE If video padding is not added to the end of the song, the
|
|
876
|
+
# outro (or next instrumental section) begins immediately after
|
|
877
|
+
# the end of the last syllable, which would be abrupt.
|
|
878
|
+
if self.config.clear_mode == LyricClearMode.PAGE:
|
|
879
|
+
self.logger.debug("clear mode is page; adding padding before outro")
|
|
880
|
+
self.writer.queue_packets([no_instruction()] * 3 * CDG_FPS)
|
|
881
|
+
|
|
882
|
+
# Calculate video padding before outro
|
|
883
|
+
OUTRO_DURATION = 2400
|
|
884
|
+
# This karaoke file ends at the later of:
|
|
885
|
+
# - The end of the audio (with the padded intro)
|
|
886
|
+
# - 8 seconds after the current video time
|
|
887
|
+
end = max(
|
|
888
|
+
int(self.audio.duration_seconds * CDG_FPS),
|
|
889
|
+
self.writer.packets_queued + OUTRO_DURATION,
|
|
890
|
+
)
|
|
891
|
+
self.logger.debug(f"song should be {end} frame(s) long")
|
|
892
|
+
padding_before_outro = (end - OUTRO_DURATION) - self.writer.packets_queued
|
|
893
|
+
self.logger.debug(f"queueing {padding_before_outro} packets before outro")
|
|
894
|
+
self.writer.queue_packets([no_instruction()] * padding_before_outro)
|
|
895
|
+
|
|
896
|
+
# Compose the outro (and thus, finish the video)
|
|
897
|
+
self._compose_outro(end)
|
|
898
|
+
self.logger.info("karaoke file composed")
|
|
899
|
+
|
|
900
|
+
# Add audio padding to outro (and thus, finish the audio)
|
|
901
|
+
self.logger.debug("padding outro of audio file")
|
|
902
|
+
outro_silence: AudioSegment = AudioSegment.silent(
|
|
903
|
+
((self.writer.packets_queued * 1000 // CDG_FPS) - int(self.audio.duration_seconds * 1000)),
|
|
904
|
+
frame_rate=song.frame_rate,
|
|
905
|
+
)
|
|
906
|
+
self.audio += outro_silence
|
|
907
|
+
|
|
908
|
+
# Write CDG and MP3 data to ZIP file
|
|
909
|
+
outname = self.config.outname
|
|
910
|
+
zipfile_name = self.relative_dir / Path(f"{outname}.zip")
|
|
911
|
+
self.logger.debug(f"creating {zipfile_name}")
|
|
912
|
+
with ZipFile(zipfile_name, "w") as zipfile:
|
|
913
|
+
cdg_bytes = BytesIO()
|
|
914
|
+
self.logger.debug("writing cdg packets to stream")
|
|
915
|
+
self.writer.write_packets(cdg_bytes)
|
|
916
|
+
self.logger.debug(f"writing stream to zipfile as {outname}.cdg")
|
|
917
|
+
cdg_bytes.seek(0)
|
|
918
|
+
zipfile.writestr(f"{outname}.cdg", cdg_bytes.read())
|
|
919
|
+
|
|
920
|
+
mp3_bytes = BytesIO()
|
|
921
|
+
self.logger.debug("writing mp3 data to stream")
|
|
922
|
+
self.audio.export(mp3_bytes, format="mp3")
|
|
923
|
+
self.logger.debug(f"writing stream to zipfile as {outname}.mp3")
|
|
924
|
+
mp3_bytes.seek(0)
|
|
925
|
+
zipfile.writestr(f"{outname}.mp3", mp3_bytes.read())
|
|
926
|
+
self.logger.info(f"karaoke files written to {zipfile_name}")
|
|
927
|
+
except Exception as e:
|
|
928
|
+
self.logger.error(f"Error in compose: {str(e)}", exc_info=True)
|
|
929
|
+
raise
|
|
930
|
+
|
|
931
|
+
def _compose_lyric(
|
|
932
|
+
self,
|
|
933
|
+
lyric: LyricInfo,
|
|
934
|
+
times: LyricTimes,
|
|
935
|
+
state: LyricState,
|
|
936
|
+
lyric_states: list[LyricState],
|
|
937
|
+
composer_state: ComposerState,
|
|
938
|
+
):
|
|
939
|
+
current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
|
|
940
|
+
|
|
941
|
+
should_draw_this_line = False
|
|
942
|
+
line_draw_info, line_draw_time = None, None
|
|
943
|
+
if state.line_draw < len(times.line_draw):
|
|
944
|
+
line_draw_info = lyric.lines[state.line_draw]
|
|
945
|
+
line_draw_time = times.line_draw[state.line_draw]
|
|
946
|
+
should_draw_this_line = current_time >= line_draw_time
|
|
947
|
+
|
|
948
|
+
should_erase_this_line = False
|
|
949
|
+
line_erase_info, line_erase_time = None, None
|
|
950
|
+
if state.line_erase < len(times.line_erase):
|
|
951
|
+
line_erase_info = lyric.lines[state.line_erase]
|
|
952
|
+
line_erase_time = times.line_erase[state.line_erase]
|
|
953
|
+
should_erase_this_line = current_time >= line_erase_time
|
|
954
|
+
|
|
955
|
+
# If we're clearing lyrics by page and drawing a new line
|
|
956
|
+
if self.config.clear_mode == LyricClearMode.PAGE and should_draw_this_line:
|
|
957
|
+
composer_state.last_page = composer_state.this_page
|
|
958
|
+
composer_state.this_page = line_draw_info.line_index // lyric.lines_per_page
|
|
959
|
+
# If this line is the start of a new page
|
|
960
|
+
if composer_state.this_page > composer_state.last_page:
|
|
961
|
+
self.logger.debug(f"going from page {composer_state.last_page} to " f"page {composer_state.this_page} in page mode")
|
|
962
|
+
# If we have not just cleared the screen
|
|
963
|
+
if not composer_state.just_cleared:
|
|
964
|
+
self.logger.debug("clearing screen on page transition")
|
|
965
|
+
# Clear the last page
|
|
966
|
+
page_clear_packets = [
|
|
967
|
+
*memory_preset_repeat(self.BACKGROUND),
|
|
968
|
+
]
|
|
969
|
+
if self.config.border is not None:
|
|
970
|
+
page_clear_packets.append(border_preset(self.BORDER))
|
|
971
|
+
self.lyric_packet_indices.update(
|
|
972
|
+
range(
|
|
973
|
+
self.writer.packets_queued,
|
|
974
|
+
self.writer.packets_queued + len(page_clear_packets),
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
self.writer.queue_packets(page_clear_packets)
|
|
978
|
+
composer_state.just_cleared = True
|
|
979
|
+
# Update the current frame time
|
|
980
|
+
current_time += len(page_clear_packets)
|
|
981
|
+
else:
|
|
982
|
+
self.logger.debug("not clearing screen on page transition")
|
|
983
|
+
|
|
984
|
+
# Queue the erasing of this line if necessary
|
|
985
|
+
if should_erase_this_line:
|
|
986
|
+
assert line_erase_info is not None
|
|
987
|
+
self.logger.debug(
|
|
988
|
+
f"t={self.writer.packets_queued}: erasing lyric " f"{line_erase_info.lyric_index} line " f"{line_erase_info.line_index}"
|
|
989
|
+
)
|
|
990
|
+
if line_erase_info.text.strip():
|
|
991
|
+
state.draw_queue.extend(
|
|
992
|
+
line_image_to_packets(
|
|
993
|
+
line_erase_info.image,
|
|
994
|
+
xy=(line_erase_info.x, line_erase_info.y),
|
|
995
|
+
background=self.BACKGROUND,
|
|
996
|
+
erase=True,
|
|
997
|
+
)
|
|
998
|
+
)
|
|
999
|
+
else:
|
|
1000
|
+
self.logger.debug("line is blank; not erased")
|
|
1001
|
+
state.line_erase += 1
|
|
1002
|
+
# Queue the drawing of this line if necessary
|
|
1003
|
+
if should_draw_this_line:
|
|
1004
|
+
assert line_draw_info is not None
|
|
1005
|
+
self.logger.debug(
|
|
1006
|
+
f"t={self.writer.packets_queued}: drawing lyric " f"{line_draw_info.lyric_index} line " f"{line_draw_info.line_index}"
|
|
1007
|
+
)
|
|
1008
|
+
if line_draw_info.text.strip():
|
|
1009
|
+
state.draw_queue.extend(
|
|
1010
|
+
line_image_to_packets(
|
|
1011
|
+
line_draw_info.image,
|
|
1012
|
+
xy=(line_draw_info.x, line_draw_info.y),
|
|
1013
|
+
fill=line_draw_info.singer << 2 | 0,
|
|
1014
|
+
stroke=line_draw_info.singer << 2 | 1,
|
|
1015
|
+
background=self.BACKGROUND,
|
|
1016
|
+
)
|
|
1017
|
+
)
|
|
1018
|
+
else:
|
|
1019
|
+
self.logger.debug("line is blank; not drawn")
|
|
1020
|
+
state.line_draw += 1
|
|
1021
|
+
|
|
1022
|
+
# NOTE If this line has no syllables, we must advance the
|
|
1023
|
+
# syllable line index until we reach a line that has syllables.
|
|
1024
|
+
while state.syllable_line < len(lyric.lines):
|
|
1025
|
+
if lyric.lines[state.syllable_line].syllables:
|
|
1026
|
+
break
|
|
1027
|
+
state.syllable_index = 0
|
|
1028
|
+
state.syllable_line += 1
|
|
1029
|
+
|
|
1030
|
+
should_highlight = False
|
|
1031
|
+
syllable_info = None
|
|
1032
|
+
if state.syllable_line < len(lyric.lines):
|
|
1033
|
+
syllable_info = lyric.lines[state.syllable_line].syllables[state.syllable_index]
|
|
1034
|
+
should_highlight = current_time >= syllable_info.start_offset
|
|
1035
|
+
# If this syllable should be highlighted now
|
|
1036
|
+
if should_highlight:
|
|
1037
|
+
assert syllable_info is not None
|
|
1038
|
+
if syllable_info.text.strip():
|
|
1039
|
+
# Add the highlight packets to the highlight queue
|
|
1040
|
+
state.highlight_queue.extend(
|
|
1041
|
+
self._compose_highlight(
|
|
1042
|
+
lyric=lyric,
|
|
1043
|
+
syllable=syllable_info,
|
|
1044
|
+
current_time=current_time,
|
|
1045
|
+
)
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
# Advance to the next syllable
|
|
1049
|
+
state.syllable_index += 1
|
|
1050
|
+
if state.syllable_index >= len(lyric.lines[state.syllable_line].syllables):
|
|
1051
|
+
state.syllable_index = 0
|
|
1052
|
+
state.syllable_line += 1
|
|
1053
|
+
|
|
1054
|
+
should_instrumental = False
|
|
1055
|
+
instrumental = None
|
|
1056
|
+
if composer_state.instrumental < len(self.config.instrumentals):
|
|
1057
|
+
instrumental = self.config.instrumentals[composer_state.instrumental]
|
|
1058
|
+
# TODO Improve this code for waiting to start instrumentals!
|
|
1059
|
+
# It's a mess!
|
|
1060
|
+
instrumental_time = sync_to_cdg(instrumental.sync)
|
|
1061
|
+
# If instrumental time is to be interpreted as waiting for
|
|
1062
|
+
# syllable to end
|
|
1063
|
+
if instrumental.wait:
|
|
1064
|
+
syllable_iter = iter(syll for line_info in lyric.lines for syll in line_info.syllables)
|
|
1065
|
+
last_syllable = next(syllable_iter)
|
|
1066
|
+
# Find first syllable on or after the instrumental time
|
|
1067
|
+
while last_syllable is not None and last_syllable.start_offset < instrumental_time:
|
|
1068
|
+
last_syllable = next(syllable_iter, None)
|
|
1069
|
+
# If syllable was not found
|
|
1070
|
+
if last_syllable is None:
|
|
1071
|
+
# Make sure the instrumental won't play
|
|
1072
|
+
# FIXME This happens when the instrumental is
|
|
1073
|
+
# happening after some syllable in another lyric.
|
|
1074
|
+
# What's a better way to handle this?
|
|
1075
|
+
instrumental_time = float("inf")
|
|
1076
|
+
# If syllable was found
|
|
1077
|
+
else:
|
|
1078
|
+
first_syllable = lyric.lines[last_syllable.line_index].syllables[0]
|
|
1079
|
+
# If this line is being actively sung
|
|
1080
|
+
if current_time >= first_syllable.start_offset:
|
|
1081
|
+
# If this is the last syllable in this line
|
|
1082
|
+
if last_syllable.syllable_index == len(lyric.lines[last_syllable.line_index].syllables) - 1:
|
|
1083
|
+
instrumental_time = 0
|
|
1084
|
+
if times.line_erase:
|
|
1085
|
+
# Wait for this line to be erased
|
|
1086
|
+
instrumental_time = times.line_erase[last_syllable.line_index]
|
|
1087
|
+
if not instrumental_time:
|
|
1088
|
+
# Add 1.5 seconds
|
|
1089
|
+
# XXX This is hardcoded.
|
|
1090
|
+
instrumental_time = last_syllable.end_offset + 450
|
|
1091
|
+
else:
|
|
1092
|
+
self.logger.debug("forcing next instrumental not to " "wait; it does not occur at or before " "the end of this line")
|
|
1093
|
+
instrumental.wait = False
|
|
1094
|
+
should_instrumental = current_time >= instrumental_time
|
|
1095
|
+
# If there should be an instrumental section now
|
|
1096
|
+
if should_instrumental:
|
|
1097
|
+
assert instrumental is not None
|
|
1098
|
+
self.logger.debug("time for an instrumental section")
|
|
1099
|
+
if instrumental.wait:
|
|
1100
|
+
self.logger.debug("this instrumental section waited for the previous " "line to finish")
|
|
1101
|
+
else:
|
|
1102
|
+
self.logger.debug("this instrumental did not wait for the previous " "line to finish")
|
|
1103
|
+
|
|
1104
|
+
self.logger.debug("_compose_lyric: Purging all highlight/draw queues")
|
|
1105
|
+
for st in lyric_states:
|
|
1106
|
+
if instrumental.wait:
|
|
1107
|
+
if st.highlight_queue:
|
|
1108
|
+
self.logger.warning("_compose_lyric: Unexpected items in highlight queue when instrumental waited")
|
|
1109
|
+
if st.draw_queue:
|
|
1110
|
+
if st == state:
|
|
1111
|
+
self.logger.debug("_compose_lyric: Queueing remaining draw packets for current state")
|
|
1112
|
+
else:
|
|
1113
|
+
self.logger.warning("_compose_lyric: Unexpected items in draw queue for non-current state")
|
|
1114
|
+
self.writer.queue_packets(st.draw_queue)
|
|
1115
|
+
|
|
1116
|
+
# Purge highlight/draw queues
|
|
1117
|
+
st.highlight_queue.clear()
|
|
1118
|
+
st.draw_queue.clear()
|
|
1119
|
+
|
|
1120
|
+
# The instrumental should end when the next line is drawn by
|
|
1121
|
+
# default
|
|
1122
|
+
if line_draw_time is not None:
|
|
1123
|
+
instrumental_end = line_draw_time
|
|
1124
|
+
else:
|
|
1125
|
+
# NOTE A value of None here means this instrumental will
|
|
1126
|
+
# never end (and once the screen is drawn, it will not
|
|
1127
|
+
# pause), unless there is another instrumental after
|
|
1128
|
+
# this.
|
|
1129
|
+
instrumental_end = None
|
|
1130
|
+
|
|
1131
|
+
composer_state.instrumental += 1
|
|
1132
|
+
next_instrumental = None
|
|
1133
|
+
if composer_state.instrumental < len(self.config.instrumentals):
|
|
1134
|
+
next_instrumental = self.config.instrumentals[composer_state.instrumental]
|
|
1135
|
+
should_clear = True
|
|
1136
|
+
# If there is a next instrumental
|
|
1137
|
+
if next_instrumental is not None:
|
|
1138
|
+
next_instrumental_time = sync_to_cdg(next_instrumental.sync)
|
|
1139
|
+
# If the next instrumental is immediately after this one
|
|
1140
|
+
if instrumental_end is None or next_instrumental_time <= instrumental_end:
|
|
1141
|
+
# This instrumental should end there
|
|
1142
|
+
instrumental_end = next_instrumental_time
|
|
1143
|
+
# Don't clear the screen afterwards
|
|
1144
|
+
should_clear = False
|
|
1145
|
+
else:
|
|
1146
|
+
if line_draw_time is None:
|
|
1147
|
+
should_clear = False
|
|
1148
|
+
|
|
1149
|
+
self.logger.info(f"_compose_lyric: Composing instrumental. End time: {instrumental_end}, Should clear: {should_clear}")
|
|
1150
|
+
try:
|
|
1151
|
+
self._compose_instrumental(instrumental, instrumental_end)
|
|
1152
|
+
except Exception as e:
|
|
1153
|
+
self.logger.error(f"Error in _compose_instrumental: {str(e)}", exc_info=True)
|
|
1154
|
+
raise
|
|
1155
|
+
|
|
1156
|
+
if should_clear:
|
|
1157
|
+
self.logger.debug("_compose_lyric: Clearing screen after instrumental")
|
|
1158
|
+
self.writer.queue_packets(
|
|
1159
|
+
[
|
|
1160
|
+
*memory_preset_repeat(self.BACKGROUND),
|
|
1161
|
+
*load_color_table(self.color_table),
|
|
1162
|
+
]
|
|
1163
|
+
)
|
|
1164
|
+
self.logger.debug(f"_compose_lyric: Loaded color table: {self.color_table}")
|
|
1165
|
+
if self.config.border is not None:
|
|
1166
|
+
self.writer.queue_packet(border_preset(self.BORDER))
|
|
1167
|
+
composer_state.just_cleared = True
|
|
1168
|
+
else:
|
|
1169
|
+
self.logger.debug("not clearing screen after instrumental")
|
|
1170
|
+
# Advance to the next instrumental section
|
|
1171
|
+
instrumental = next_instrumental
|
|
1172
|
+
return
|
|
1173
|
+
|
|
1174
|
+
composer_state.just_cleared = False
|
|
1175
|
+
# Create groups of packets for highlights and draws, with None
|
|
1176
|
+
# as a placeholder value for non-highlight packets
|
|
1177
|
+
highlight_groups: list[list[CDGPacket | None]] = []
|
|
1178
|
+
for _ in range(self.config.highlight_bandwidth):
|
|
1179
|
+
group = []
|
|
1180
|
+
if state.highlight_queue:
|
|
1181
|
+
group = state.highlight_queue.popleft()
|
|
1182
|
+
highlight_groups.append(list(pad(group, self.max_tile_height)))
|
|
1183
|
+
# NOTE This means the draw groups will only contain None.
|
|
1184
|
+
draw_groups: list[list[CDGPacket | None]] = [[None] * self.max_tile_height] * self.config.draw_bandwidth
|
|
1185
|
+
|
|
1186
|
+
self.lyric_packet_indices.update(
|
|
1187
|
+
range(
|
|
1188
|
+
self.writer.packets_queued,
|
|
1189
|
+
self.writer.packets_queued + len(list(it.chain(*highlight_groups, *draw_groups))),
|
|
1190
|
+
)
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
# Intersperse the highlight and draw groups and queue the
|
|
1194
|
+
# packets
|
|
1195
|
+
for group in intersperse(highlight_groups, draw_groups):
|
|
1196
|
+
for item in group:
|
|
1197
|
+
if item is not None:
|
|
1198
|
+
self.writer.queue_packet(item)
|
|
1199
|
+
continue
|
|
1200
|
+
|
|
1201
|
+
# If a group item is None, try getting packets from the
|
|
1202
|
+
# draw queue
|
|
1203
|
+
if state.draw_queue:
|
|
1204
|
+
self.writer.queue_packet(state.draw_queue.popleft())
|
|
1205
|
+
continue
|
|
1206
|
+
self.writer.queue_packet(next(iter(st.draw_queue.popleft() for st in lyric_states if st.draw_queue), no_instruction()))
|
|
1207
|
+
|
|
1208
|
+
def _compose_highlight(
|
|
1209
|
+
self,
|
|
1210
|
+
lyric: LyricInfo,
|
|
1211
|
+
syllable: SyllableInfo,
|
|
1212
|
+
current_time: int,
|
|
1213
|
+
) -> list[list[CDGPacket]]:
|
|
1214
|
+
assert syllable is not None
|
|
1215
|
+
line_info = lyric.lines[syllable.line_index]
|
|
1216
|
+
x = line_info.x
|
|
1217
|
+
y = line_info.y
|
|
1218
|
+
|
|
1219
|
+
# NOTE Using the current time instead of the ideal start offset
|
|
1220
|
+
# accounts for any lost frames from previous events that took
|
|
1221
|
+
# too long.
|
|
1222
|
+
start_offset = current_time
|
|
1223
|
+
end_offset = syllable.end_offset
|
|
1224
|
+
left_edge = syllable.left_edge
|
|
1225
|
+
right_edge = syllable.right_edge
|
|
1226
|
+
|
|
1227
|
+
# Calculate the length of each column group in frames
|
|
1228
|
+
column_group_length = ((self.config.draw_bandwidth + self.config.highlight_bandwidth) * self.max_tile_height) * len(self.lyrics)
|
|
1229
|
+
# Calculate the number of column updates for this highlight
|
|
1230
|
+
columns = ((end_offset - start_offset) // column_group_length) * self.config.highlight_bandwidth
|
|
1231
|
+
|
|
1232
|
+
left_tile = left_edge // CDG_TILE_WIDTH
|
|
1233
|
+
right_tile = ceildiv(right_edge, CDG_TILE_WIDTH) - 1
|
|
1234
|
+
# The highlight must hit at least the edges of all the tiles
|
|
1235
|
+
# along it (not including the one before the left edge or the
|
|
1236
|
+
# one after the right edge)
|
|
1237
|
+
highlight_progress = [tile_index * CDG_TILE_WIDTH for tile_index in range(left_tile + 1, right_tile + 1)]
|
|
1238
|
+
# If there aren't too many tile boundaries for the number of
|
|
1239
|
+
# column updates
|
|
1240
|
+
if columns - 1 >= len(highlight_progress):
|
|
1241
|
+
# Add enough highlight points for all the column updates...
|
|
1242
|
+
highlight_progress += sorted(
|
|
1243
|
+
# ...which are evenly distributed within the range...
|
|
1244
|
+
map(
|
|
1245
|
+
operator.itemgetter(0),
|
|
1246
|
+
distribute(
|
|
1247
|
+
range(1, columns),
|
|
1248
|
+
left_edge,
|
|
1249
|
+
right_edge,
|
|
1250
|
+
),
|
|
1251
|
+
),
|
|
1252
|
+
# ...prioritizing highlight points nearest to the middle
|
|
1253
|
+
# of a tile
|
|
1254
|
+
key=lambda n: abs(n % CDG_TILE_WIDTH - CDG_TILE_WIDTH // 2),
|
|
1255
|
+
)[: columns - 1 - len(highlight_progress)]
|
|
1256
|
+
# NOTE We need the length of this list to be the number of
|
|
1257
|
+
# columns minus 1, so that when the left and right edges are
|
|
1258
|
+
# included, there will be as many pairs as there are
|
|
1259
|
+
# columns.
|
|
1260
|
+
|
|
1261
|
+
# Round and sort the highlight points
|
|
1262
|
+
highlight_progress = sorted(map(round, highlight_progress))
|
|
1263
|
+
# If there are too many tile boundaries for the number of column
|
|
1264
|
+
# updates
|
|
1265
|
+
else:
|
|
1266
|
+
# Prepare the syllable text representation
|
|
1267
|
+
syllable_text = "".join(
|
|
1268
|
+
f"{{{syll.text}}}" if si == syllable.syllable_index else syll.text
|
|
1269
|
+
for si, syll in enumerate(lyric.lines[syllable.line_index].syllables)
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
# Warn the user
|
|
1273
|
+
self.logger.warning(
|
|
1274
|
+
"Not enough time to highlight lyric %d line %d syllable %d. "
|
|
1275
|
+
"Ideal duration is %d column(s); actual duration is %d column(s). "
|
|
1276
|
+
"Syllable text: %s",
|
|
1277
|
+
syllable.lyric_index,
|
|
1278
|
+
syllable.line_index,
|
|
1279
|
+
syllable.syllable_index,
|
|
1280
|
+
columns,
|
|
1281
|
+
len(highlight_progress) + 1,
|
|
1282
|
+
syllable_text,
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
# Create the highlight packets
|
|
1286
|
+
return [
|
|
1287
|
+
line_mask_to_packets(syllable.mask, (x, y), edges) for edges in it.pairwise([left_edge] + highlight_progress + [right_edge])
|
|
1288
|
+
]
|
|
1289
|
+
|
|
1290
|
+
# !SECTION
|
|
1291
|
+
# endregion
|
|
1292
|
+
|
|
1293
|
+
# region Compose pictures
|
|
1294
|
+
# SECTION Compose pictures
|
|
1295
|
+
def _compose_instrumental(
|
|
1296
|
+
self,
|
|
1297
|
+
instrumental: SettingsInstrumental,
|
|
1298
|
+
end: int | None,
|
|
1299
|
+
):
|
|
1300
|
+
self.logger.info(f"Composing instrumental section. End time: {end}")
|
|
1301
|
+
try:
|
|
1302
|
+
self.logger.info("composing instrumental section")
|
|
1303
|
+
self.instrumental_times.append(self.writer.packets_queued)
|
|
1304
|
+
self.writer.queue_packets(
|
|
1305
|
+
[
|
|
1306
|
+
*memory_preset_repeat(0),
|
|
1307
|
+
# TODO Add option for borders in instrumentals
|
|
1308
|
+
border_preset(0),
|
|
1309
|
+
]
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
self.logger.debug("rendering instrumental text")
|
|
1313
|
+
text = instrumental.text.split("\n")
|
|
1314
|
+
instrumental_font = ImageFont.truetype(self.config.font, 20)
|
|
1315
|
+
text_images = render_lines(
|
|
1316
|
+
text,
|
|
1317
|
+
font=instrumental_font,
|
|
1318
|
+
# NOTE If the instrumental shouldn't have a stroke, set the
|
|
1319
|
+
# stroke width to 0 instead.
|
|
1320
|
+
stroke_width=(self.config.stroke_width if instrumental.stroke is not None else 0),
|
|
1321
|
+
stroke_type=self.config.stroke_type,
|
|
1322
|
+
)
|
|
1323
|
+
text_width = max(image.width for image in text_images)
|
|
1324
|
+
line_height = instrumental.line_tile_height * CDG_TILE_HEIGHT
|
|
1325
|
+
text_height = line_height * len(text)
|
|
1326
|
+
max_height = max(image.height for image in text_images)
|
|
1327
|
+
|
|
1328
|
+
# Set X position of "text box"
|
|
1329
|
+
match instrumental.text_placement:
|
|
1330
|
+
case TextPlacement.TOP_LEFT | TextPlacement.MIDDLE_LEFT | TextPlacement.BOTTOM_LEFT:
|
|
1331
|
+
text_x = CDG_TILE_WIDTH * 2
|
|
1332
|
+
case TextPlacement.TOP_MIDDLE | TextPlacement.MIDDLE | TextPlacement.BOTTOM_MIDDLE:
|
|
1333
|
+
text_x = (CDG_SCREEN_WIDTH - text_width) // 2
|
|
1334
|
+
case TextPlacement.TOP_RIGHT | TextPlacement.MIDDLE_RIGHT | TextPlacement.BOTTOM_RIGHT:
|
|
1335
|
+
text_x = CDG_SCREEN_WIDTH - CDG_TILE_WIDTH * 2 - text_width
|
|
1336
|
+
# Set Y position of "text box"
|
|
1337
|
+
match instrumental.text_placement:
|
|
1338
|
+
case TextPlacement.TOP_LEFT | TextPlacement.TOP_MIDDLE | TextPlacement.TOP_RIGHT:
|
|
1339
|
+
text_y = CDG_TILE_HEIGHT * 2
|
|
1340
|
+
case TextPlacement.MIDDLE_LEFT | TextPlacement.MIDDLE | TextPlacement.MIDDLE_RIGHT:
|
|
1341
|
+
text_y = ((CDG_SCREEN_HEIGHT - text_height) // 2) // CDG_TILE_HEIGHT * CDG_TILE_HEIGHT
|
|
1342
|
+
# Add offset to place text closer to middle of line
|
|
1343
|
+
text_y += (line_height - max_height) // 2
|
|
1344
|
+
case TextPlacement.BOTTOM_LEFT | TextPlacement.BOTTOM_MIDDLE | TextPlacement.BOTTOM_RIGHT:
|
|
1345
|
+
text_y = CDG_SCREEN_HEIGHT - CDG_TILE_HEIGHT * 2 - text_height
|
|
1346
|
+
# Add offset to place text closer to bottom of line
|
|
1347
|
+
text_y += line_height - max_height
|
|
1348
|
+
|
|
1349
|
+
# Create "screen" image for drawing text
|
|
1350
|
+
screen = Image.new("P", (CDG_SCREEN_WIDTH, CDG_SCREEN_HEIGHT), 0)
|
|
1351
|
+
# Create list of packets to draw text
|
|
1352
|
+
text_image_packets: list[CDGPacket] = []
|
|
1353
|
+
y = text_y
|
|
1354
|
+
for image in text_images:
|
|
1355
|
+
# Set alignment of text
|
|
1356
|
+
match instrumental.text_align:
|
|
1357
|
+
case TextAlign.LEFT:
|
|
1358
|
+
x = text_x
|
|
1359
|
+
case TextAlign.CENTER:
|
|
1360
|
+
x = text_x + (text_width - image.width) // 2
|
|
1361
|
+
case TextAlign.RIGHT:
|
|
1362
|
+
x = text_x + text_width - image.width
|
|
1363
|
+
# Draw text onto simulated screen
|
|
1364
|
+
screen.paste(
|
|
1365
|
+
image.point(
|
|
1366
|
+
lambda v: v and (2 if v == RENDERED_FILL else 3),
|
|
1367
|
+
"P",
|
|
1368
|
+
),
|
|
1369
|
+
(x, y),
|
|
1370
|
+
)
|
|
1371
|
+
# Render text into packets
|
|
1372
|
+
text_image_packets.extend(
|
|
1373
|
+
line_image_to_packets(
|
|
1374
|
+
image,
|
|
1375
|
+
xy=(x, y),
|
|
1376
|
+
fill=2,
|
|
1377
|
+
stroke=3,
|
|
1378
|
+
background=self.BACKGROUND,
|
|
1379
|
+
)
|
|
1380
|
+
)
|
|
1381
|
+
y += instrumental.line_tile_height * CDG_TILE_HEIGHT
|
|
1382
|
+
|
|
1383
|
+
if instrumental.image is not None:
|
|
1384
|
+
self.logger.debug("creating instrumental background image")
|
|
1385
|
+
try:
|
|
1386
|
+
# Load background image
|
|
1387
|
+
background_image = self._load_image(
|
|
1388
|
+
instrumental.image,
|
|
1389
|
+
[
|
|
1390
|
+
instrumental.background or self.config.background,
|
|
1391
|
+
self.UNUSED_COLOR,
|
|
1392
|
+
instrumental.fill,
|
|
1393
|
+
instrumental.stroke or self.UNUSED_COLOR,
|
|
1394
|
+
],
|
|
1395
|
+
)
|
|
1396
|
+
except FileNotFoundError as e:
|
|
1397
|
+
self.logger.error(f"Failed to load instrumental image: {e}")
|
|
1398
|
+
# Fallback to simple screen if image can't be loaded
|
|
1399
|
+
instrumental.image = None
|
|
1400
|
+
self.logger.warning("Falling back to simple screen for instrumental")
|
|
1401
|
+
|
|
1402
|
+
if instrumental.image is None:
|
|
1403
|
+
self.logger.debug("no instrumental image; drawing simple screen")
|
|
1404
|
+
color_table = list(
|
|
1405
|
+
pad(
|
|
1406
|
+
[
|
|
1407
|
+
instrumental.background or self.config.background,
|
|
1408
|
+
self.UNUSED_COLOR,
|
|
1409
|
+
instrumental.fill,
|
|
1410
|
+
instrumental.stroke or self.UNUSED_COLOR,
|
|
1411
|
+
],
|
|
1412
|
+
8,
|
|
1413
|
+
padvalue=self.UNUSED_COLOR,
|
|
1414
|
+
)
|
|
1415
|
+
)
|
|
1416
|
+
# Set palette and draw text to screen
|
|
1417
|
+
self.writer.queue_packets(
|
|
1418
|
+
[
|
|
1419
|
+
load_color_table_lo(color_table),
|
|
1420
|
+
*text_image_packets,
|
|
1421
|
+
]
|
|
1422
|
+
)
|
|
1423
|
+
self.logger.debug(f"loaded color table in compose_instrumental: {color_table}")
|
|
1424
|
+
else:
|
|
1425
|
+
# Queue palette packets
|
|
1426
|
+
palette = list(batched(background_image.getpalette(), 3))
|
|
1427
|
+
if len(palette) < 8:
|
|
1428
|
+
color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
|
|
1429
|
+
self.logger.debug(f"loaded color table in compose_instrumental: {color_table}")
|
|
1430
|
+
self.writer.queue_packet(
|
|
1431
|
+
load_color_table_lo(
|
|
1432
|
+
color_table,
|
|
1433
|
+
)
|
|
1434
|
+
)
|
|
1435
|
+
else:
|
|
1436
|
+
color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
|
|
1437
|
+
self.logger.debug(f"loaded color table in compose_instrumental: {color_table}")
|
|
1438
|
+
self.writer.queue_packets(
|
|
1439
|
+
load_color_table(
|
|
1440
|
+
color_table,
|
|
1441
|
+
)
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
self.logger.debug("drawing instrumental text")
|
|
1445
|
+
# Queue text packets
|
|
1446
|
+
self.writer.queue_packets(text_image_packets)
|
|
1447
|
+
|
|
1448
|
+
self.logger.debug("rendering instrumental text over background image")
|
|
1449
|
+
# HACK To properly draw and layer everything, I need to
|
|
1450
|
+
# create a version of the background image that has the text
|
|
1451
|
+
# overlaid onto it, and is tile-aligned. This requires some
|
|
1452
|
+
# juggling.
|
|
1453
|
+
padleft = instrumental.x % CDG_TILE_WIDTH
|
|
1454
|
+
padright = -(instrumental.x + background_image.width) % CDG_TILE_WIDTH
|
|
1455
|
+
padtop = instrumental.y % CDG_TILE_HEIGHT
|
|
1456
|
+
padbottom = -(instrumental.y + background_image.height) % CDG_TILE_HEIGHT
|
|
1457
|
+
self.logger.debug(f"padding L={padleft} R={padright} T={padtop} B={padbottom}")
|
|
1458
|
+
# Create axis-aligned background image with proper size and
|
|
1459
|
+
# palette
|
|
1460
|
+
aligned_background_image = Image.new(
|
|
1461
|
+
"P",
|
|
1462
|
+
(
|
|
1463
|
+
background_image.width + padleft + padright,
|
|
1464
|
+
background_image.height + padtop + padbottom,
|
|
1465
|
+
),
|
|
1466
|
+
0,
|
|
1467
|
+
)
|
|
1468
|
+
aligned_background_image.putpalette(background_image.getpalette())
|
|
1469
|
+
# Paste background image onto axis-aligned image
|
|
1470
|
+
aligned_background_image.paste(background_image, (padleft, padtop))
|
|
1471
|
+
# Paste existing screen text onto axis-aligned image
|
|
1472
|
+
aligned_background_image.paste(
|
|
1473
|
+
screen,
|
|
1474
|
+
(padleft - instrumental.x, padtop - instrumental.y),
|
|
1475
|
+
# NOTE This masks out the 0 pixels.
|
|
1476
|
+
mask=screen.point(lambda v: v and 255, mode="1"),
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
# Render background image to packets
|
|
1480
|
+
packets = image_to_packets(
|
|
1481
|
+
aligned_background_image,
|
|
1482
|
+
(instrumental.x - padleft, instrumental.y - padtop),
|
|
1483
|
+
background=screen.crop(
|
|
1484
|
+
(
|
|
1485
|
+
instrumental.x - padleft,
|
|
1486
|
+
instrumental.y - padtop,
|
|
1487
|
+
instrumental.x - padleft + aligned_background_image.width,
|
|
1488
|
+
instrumental.y - padtop + aligned_background_image.height,
|
|
1489
|
+
)
|
|
1490
|
+
),
|
|
1491
|
+
)
|
|
1492
|
+
self.logger.debug("instrumental background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
|
|
1493
|
+
|
|
1494
|
+
self.logger.debug("applying instrumental transition")
|
|
1495
|
+
# Queue background image packets (and apply transition)
|
|
1496
|
+
if instrumental.transition is None:
|
|
1497
|
+
for coord_packets in packets.values():
|
|
1498
|
+
self.writer.queue_packets(coord_packets)
|
|
1499
|
+
else:
|
|
1500
|
+
transition = Image.open(package_dir / "transitions" / f"{instrumental.transition}.png")
|
|
1501
|
+
for coord in self._gradient_to_tile_positions(transition):
|
|
1502
|
+
self.writer.queue_packets(packets.get(coord, []))
|
|
1503
|
+
|
|
1504
|
+
if end is None:
|
|
1505
|
+
self.logger.debug('this instrumental will last "forever"')
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1508
|
+
# Wait until 3 seconds before the next line should be drawn
|
|
1509
|
+
current_time = self.writer.packets_queued - self.sync_offset - self.intro_delay
|
|
1510
|
+
preparation_time = 3 * CDG_FPS # 3 seconds * 300 frames per second = 900 frames
|
|
1511
|
+
end_time = max(current_time, end - preparation_time)
|
|
1512
|
+
wait_time = end_time - current_time
|
|
1513
|
+
|
|
1514
|
+
self.logger.debug(f"waiting for {wait_time} frame(s) before showing next lyrics")
|
|
1515
|
+
self.writer.queue_packets([no_instruction()] * wait_time)
|
|
1516
|
+
|
|
1517
|
+
# Clear the screen for the next lyrics
|
|
1518
|
+
self.writer.queue_packets(
|
|
1519
|
+
[
|
|
1520
|
+
*memory_preset_repeat(self.BACKGROUND),
|
|
1521
|
+
*load_color_table(self.color_table),
|
|
1522
|
+
]
|
|
1523
|
+
)
|
|
1524
|
+
self.logger.debug(f"loaded color table in compose_instrumental: {self.color_table}")
|
|
1525
|
+
if self.config.border is not None:
|
|
1526
|
+
self.writer.queue_packet(border_preset(self.BORDER))
|
|
1527
|
+
|
|
1528
|
+
self.logger.debug("instrumental section ended")
|
|
1529
|
+
except Exception as e:
|
|
1530
|
+
self.logger.error(f"Error in _compose_instrumental: {str(e)}", exc_info=True)
|
|
1531
|
+
raise
|
|
1532
|
+
|
|
1533
|
+
def _compose_intro(self):
|
|
1534
|
+
# TODO Make it so the intro screen is not hardcoded
|
|
1535
|
+
self.logger.debug("composing intro")
|
|
1536
|
+
self.writer.queue_packets(
|
|
1537
|
+
[
|
|
1538
|
+
*memory_preset_repeat(0),
|
|
1539
|
+
]
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
self.logger.debug("loading intro background image")
|
|
1543
|
+
# Load background image
|
|
1544
|
+
background_image = self._load_image(
|
|
1545
|
+
self.config.title_screen_background,
|
|
1546
|
+
[
|
|
1547
|
+
self.config.background, # background
|
|
1548
|
+
self.config.border, # border
|
|
1549
|
+
self.config.title_color, # title color
|
|
1550
|
+
self.config.artist_color, # artist color
|
|
1551
|
+
],
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
smallfont = ImageFont.truetype(self.config.font, 25)
|
|
1555
|
+
bigfont_size = 30
|
|
1556
|
+
MAX_HEIGHT = 200
|
|
1557
|
+
# Try rendering the title and artist to an image
|
|
1558
|
+
while True:
|
|
1559
|
+
self.logger.debug(f"trying song title at size {bigfont_size}")
|
|
1560
|
+
text_image = Image.new("P", (CDG_VISIBLE_WIDTH, MAX_HEIGHT * 2), 0)
|
|
1561
|
+
y = 0
|
|
1562
|
+
|
|
1563
|
+
if self.config.title_top_padding:
|
|
1564
|
+
self.logger.info(f"title top padding set to {self.config.title_top_padding} in config, setting as initial y position")
|
|
1565
|
+
y = self.config.title_top_padding
|
|
1566
|
+
self.logger.info(f"Initial y position with padding: {y}")
|
|
1567
|
+
else:
|
|
1568
|
+
self.logger.info("no title top padding configured; starting with y = 0")
|
|
1569
|
+
self.logger.info(f"Initial y position without padding: {y}")
|
|
1570
|
+
|
|
1571
|
+
bigfont = ImageFont.truetype(self.config.font, bigfont_size)
|
|
1572
|
+
|
|
1573
|
+
# Draw song title
|
|
1574
|
+
title_start_y = y
|
|
1575
|
+
self.logger.info(f"Starting to draw title at y={y}")
|
|
1576
|
+
for image in render_lines(
|
|
1577
|
+
get_wrapped_text(
|
|
1578
|
+
self.config.title,
|
|
1579
|
+
font=bigfont,
|
|
1580
|
+
width=text_image.width,
|
|
1581
|
+
).split("\n"),
|
|
1582
|
+
font=bigfont,
|
|
1583
|
+
):
|
|
1584
|
+
text_image.paste(
|
|
1585
|
+
# Use index 2 for title color
|
|
1586
|
+
image.point(lambda v: v and 2, "P"),
|
|
1587
|
+
((text_image.width - image.width) // 2, y),
|
|
1588
|
+
mask=image.point(lambda v: v and 255, "1"),
|
|
1589
|
+
)
|
|
1590
|
+
y += int(bigfont.size)
|
|
1591
|
+
title_end_y = y
|
|
1592
|
+
self.logger.info(f"Finished drawing title at y={y}, title height={title_end_y - title_start_y}")
|
|
1593
|
+
|
|
1594
|
+
# Add vertical gap between title and artist using configured value
|
|
1595
|
+
y += self.config.title_artist_gap
|
|
1596
|
+
self.logger.info(f"After adding title_artist_gap of {self.config.title_artist_gap}, y is now {y}")
|
|
1597
|
+
|
|
1598
|
+
# Draw song artist
|
|
1599
|
+
artist_start_y = y
|
|
1600
|
+
self.logger.info(f"Starting to draw artist at y={y}")
|
|
1601
|
+
for image in render_lines(
|
|
1602
|
+
get_wrapped_text(
|
|
1603
|
+
self.config.artist,
|
|
1604
|
+
font=smallfont,
|
|
1605
|
+
width=text_image.width,
|
|
1606
|
+
).split("\n"),
|
|
1607
|
+
font=smallfont,
|
|
1608
|
+
):
|
|
1609
|
+
text_image.paste(
|
|
1610
|
+
# Use index 3 for artist color
|
|
1611
|
+
image.point(lambda v: v and 3, "P"),
|
|
1612
|
+
((text_image.width - image.width) // 2, y),
|
|
1613
|
+
mask=image.point(lambda v: v and 255, "1"),
|
|
1614
|
+
)
|
|
1615
|
+
y += int(smallfont.size)
|
|
1616
|
+
artist_end_y = y
|
|
1617
|
+
self.logger.info(f"Finished drawing artist at y={y}, artist height={artist_end_y - artist_start_y}")
|
|
1618
|
+
self.logger.info(f"Total content height before cropping: {artist_end_y - title_start_y}")
|
|
1619
|
+
|
|
1620
|
+
# Break out of loop only if text box ends up small enough
|
|
1621
|
+
bbox = text_image.getbbox()
|
|
1622
|
+
self.logger.info(f"Original bounding box from getbbox(): {bbox}")
|
|
1623
|
+
if bbox is None:
|
|
1624
|
+
# If there's no content, still create a minimal bbox
|
|
1625
|
+
bbox = (0, 0, text_image.width, 1)
|
|
1626
|
+
self.logger.info("No content found, created minimal bbox")
|
|
1627
|
+
|
|
1628
|
+
# We'll crop to just the content area, without padding
|
|
1629
|
+
original_height = text_image.height
|
|
1630
|
+
text_image = text_image.crop(bbox)
|
|
1631
|
+
self.logger.info(f"After cropping: text_image dimensions={text_image.width}x{text_image.height}, height difference={original_height - text_image.height}")
|
|
1632
|
+
|
|
1633
|
+
if text_image.height <= MAX_HEIGHT:
|
|
1634
|
+
self.logger.debug("height just right")
|
|
1635
|
+
break
|
|
1636
|
+
# If text box is not small enough, reduce font size of title
|
|
1637
|
+
self.logger.debug("height too big; reducing font size")
|
|
1638
|
+
bigfont_size -= 2
|
|
1639
|
+
|
|
1640
|
+
# Calculate position - center horizontally, but add padding to vertical position
|
|
1641
|
+
center_x = (CDG_SCREEN_WIDTH - text_image.width) // 2
|
|
1642
|
+
|
|
1643
|
+
# Standard centered position
|
|
1644
|
+
standard_center_y = (CDG_SCREEN_HEIGHT - text_image.height) // 2
|
|
1645
|
+
|
|
1646
|
+
# Add the title_top_padding to shift the entire content downward
|
|
1647
|
+
padding_offset = self.config.title_top_padding if self.config.title_top_padding else 0
|
|
1648
|
+
final_y = standard_center_y + padding_offset
|
|
1649
|
+
|
|
1650
|
+
self.logger.info(f"Pasting text image ({text_image.width}x{text_image.height}) onto background")
|
|
1651
|
+
self.logger.info(f"Standard centered position would be y={standard_center_y}")
|
|
1652
|
+
self.logger.info(f"With padding offset of {padding_offset}, final position is y={final_y}")
|
|
1653
|
+
|
|
1654
|
+
background_image.paste(
|
|
1655
|
+
text_image,
|
|
1656
|
+
(
|
|
1657
|
+
center_x,
|
|
1658
|
+
final_y,
|
|
1659
|
+
),
|
|
1660
|
+
mask=text_image.point(lambda v: v and 255, "1"),
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
# Queue palette packets
|
|
1664
|
+
palette = list(batched(background_image.getpalette(), 3))
|
|
1665
|
+
if len(palette) < 8:
|
|
1666
|
+
color_table = list(pad(palette, 8, padvalue=self.UNUSED_COLOR))
|
|
1667
|
+
self.logger.debug(f"loaded color table in compose_intro: {color_table}")
|
|
1668
|
+
self.writer.queue_packet(
|
|
1669
|
+
load_color_table_lo(
|
|
1670
|
+
color_table,
|
|
1671
|
+
)
|
|
1672
|
+
)
|
|
1673
|
+
else:
|
|
1674
|
+
color_table = list(pad(palette, 16, padvalue=self.UNUSED_COLOR))
|
|
1675
|
+
self.logger.debug(f"loaded color table in compose_intro: {color_table}")
|
|
1676
|
+
self.writer.queue_packets(
|
|
1677
|
+
load_color_table(
|
|
1678
|
+
color_table,
|
|
1679
|
+
)
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
# Render background image to packets
|
|
1683
|
+
packets = image_to_packets(background_image, (0, 0))
|
|
1684
|
+
self.logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
|
|
1685
|
+
|
|
1686
|
+
# Queue background image packets (and apply transition)
|
|
1687
|
+
transition = Image.open(package_dir / "transitions" / f"{self.config.title_screen_transition}.png")
|
|
1688
|
+
for coord in self._gradient_to_tile_positions(transition):
|
|
1689
|
+
self.writer.queue_packets(packets.get(coord, []))
|
|
1690
|
+
|
|
1691
|
+
# Replace hardcoded values with configured ones
|
|
1692
|
+
INTRO_DURATION = int(self.config.intro_duration_seconds * CDG_FPS)
|
|
1693
|
+
FIRST_SYLLABLE_BUFFER = int(self.config.first_syllable_buffer_seconds * CDG_FPS)
|
|
1694
|
+
|
|
1695
|
+
# Queue the intro screen for 5 seconds
|
|
1696
|
+
end_time = INTRO_DURATION
|
|
1697
|
+
self.writer.queue_packets([no_instruction()] * (end_time - self.writer.packets_queued))
|
|
1698
|
+
|
|
1699
|
+
first_syllable_start_offset = min(
|
|
1700
|
+
syllable.start_offset for lyric in self.lyrics for line in lyric.lines for syllable in line.syllables
|
|
1701
|
+
)
|
|
1702
|
+
self.logger.debug(f"first syllable starts at {first_syllable_start_offset}")
|
|
1703
|
+
|
|
1704
|
+
MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE = INTRO_DURATION + FIRST_SYLLABLE_BUFFER
|
|
1705
|
+
# If the first syllable is within buffer+intro time, add silence
|
|
1706
|
+
# Otherwise, don't add any silence
|
|
1707
|
+
if first_syllable_start_offset < MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE:
|
|
1708
|
+
self.intro_delay = MINIMUM_FIRST_SYLLABLE_TIME_FOR_NO_SILENCE
|
|
1709
|
+
self.logger.info(
|
|
1710
|
+
f"First syllable within {self.config.intro_duration_seconds + self.config.first_syllable_buffer_seconds} seconds. Adding {self.intro_delay} frames of silence."
|
|
1711
|
+
)
|
|
1712
|
+
else:
|
|
1713
|
+
self.intro_delay = 0
|
|
1714
|
+
self.logger.info("First syllable after buffer period. No additional silence needed.")
|
|
1715
|
+
|
|
1716
|
+
def _compose_outro(self, end: int):
|
|
1717
|
+
# TODO Make it so the outro screen is not hardcoded
|
|
1718
|
+
self.logger.debug("composing outro")
|
|
1719
|
+
self.writer.queue_packets(
|
|
1720
|
+
[
|
|
1721
|
+
*memory_preset_repeat(0),
|
|
1722
|
+
]
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
self.logger.debug("loading outro background image")
|
|
1726
|
+
# Load background image
|
|
1727
|
+
background_image = self._load_image(
|
|
1728
|
+
self.config.outro_background,
|
|
1729
|
+
[
|
|
1730
|
+
self.config.background, # background
|
|
1731
|
+
self.config.border, # border
|
|
1732
|
+
self.config.outro_line1_color,
|
|
1733
|
+
self.config.outro_line2_color,
|
|
1734
|
+
],
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
smallfont = ImageFont.truetype(self.config.font, 25)
|
|
1738
|
+
MAX_HEIGHT = 200
|
|
1739
|
+
|
|
1740
|
+
# Render text to an image
|
|
1741
|
+
self.logger.debug(f"rendering outro text")
|
|
1742
|
+
text_image = Image.new("P", (CDG_VISIBLE_WIDTH, MAX_HEIGHT * 2), 0)
|
|
1743
|
+
y = 0
|
|
1744
|
+
|
|
1745
|
+
# Render first line of outro text
|
|
1746
|
+
outro_text_line1 = self.config.outro_text_line1.replace("$artist", self.config.artist).replace("$title", self.config.title)
|
|
1747
|
+
|
|
1748
|
+
for image in render_lines(
|
|
1749
|
+
get_wrapped_text(
|
|
1750
|
+
outro_text_line1,
|
|
1751
|
+
font=smallfont,
|
|
1752
|
+
width=text_image.width,
|
|
1753
|
+
).split("\n"),
|
|
1754
|
+
font=smallfont,
|
|
1755
|
+
):
|
|
1756
|
+
text_image.paste(
|
|
1757
|
+
# Use index 2 for line 1 color
|
|
1758
|
+
image.point(lambda v: v and 2, "P"),
|
|
1759
|
+
((text_image.width - image.width) // 2, y),
|
|
1760
|
+
mask=image.point(lambda v: v and 255, "1"),
|
|
1761
|
+
)
|
|
1762
|
+
y += int(smallfont.size)
|
|
1763
|
+
|
|
1764
|
+
# Add vertical gap between title and artist using configured value
|
|
1765
|
+
y += self.config.outro_line1_line2_gap
|
|
1766
|
+
|
|
1767
|
+
# Render second line of outro text
|
|
1768
|
+
outro_text_line2 = self.config.outro_text_line2.replace("$artist", self.config.artist).replace("$title", self.config.title)
|
|
1769
|
+
|
|
1770
|
+
for image in render_lines(
|
|
1771
|
+
get_wrapped_text(
|
|
1772
|
+
outro_text_line2,
|
|
1773
|
+
font=smallfont,
|
|
1774
|
+
width=text_image.width,
|
|
1775
|
+
).split("\n"),
|
|
1776
|
+
font=smallfont,
|
|
1777
|
+
):
|
|
1778
|
+
text_image.paste(
|
|
1779
|
+
# Use index 3 for line 2 color
|
|
1780
|
+
image.point(lambda v: v and 3, "P"),
|
|
1781
|
+
((text_image.width - image.width) // 2, y),
|
|
1782
|
+
mask=image.point(lambda v: v and 255, "1"),
|
|
1783
|
+
)
|
|
1784
|
+
y += int(smallfont.size)
|
|
1785
|
+
|
|
1786
|
+
# Break out of loop only if text box ends up small enough
|
|
1787
|
+
text_image = text_image.crop(text_image.getbbox())
|
|
1788
|
+
assert text_image.height <= MAX_HEIGHT
|
|
1789
|
+
|
|
1790
|
+
# Draw text onto image
|
|
1791
|
+
background_image.paste(
|
|
1792
|
+
text_image,
|
|
1793
|
+
(
|
|
1794
|
+
(CDG_SCREEN_WIDTH - text_image.width) // 2,
|
|
1795
|
+
(CDG_SCREEN_HEIGHT - text_image.height) // 2,
|
|
1796
|
+
),
|
|
1797
|
+
mask=text_image.point(lambda v: v and 255, "1"),
|
|
1798
|
+
)
|
|
1799
|
+
|
|
1800
|
+
# Queue palette packets
|
|
1801
|
+
palette = list(batched(background_image.getpalette(), 3))
|
|
1802
|
+
if len(palette) < 8:
|
|
1803
|
+
self.writer.queue_packet(load_color_table_lo(list(pad(palette, 8, padvalue=self.UNUSED_COLOR))))
|
|
1804
|
+
else:
|
|
1805
|
+
self.writer.queue_packets(load_color_table(list(pad(palette, 16, padvalue=self.UNUSED_COLOR))))
|
|
1806
|
+
|
|
1807
|
+
# Render background image to packets
|
|
1808
|
+
packets = image_to_packets(background_image, (0, 0))
|
|
1809
|
+
self.logger.debug("intro background image packed in " f"{len(list(it.chain(*packets.values())))} packet(s)")
|
|
1810
|
+
|
|
1811
|
+
# Queue background image packets (and apply transition)
|
|
1812
|
+
transition = Image.open(package_dir / "transitions" / f"{self.config.outro_transition}.png")
|
|
1813
|
+
for coord in self._gradient_to_tile_positions(transition):
|
|
1814
|
+
self.writer.queue_packets(packets.get(coord, []))
|
|
1815
|
+
|
|
1816
|
+
self.writer.queue_packets([no_instruction()] * (end - self.writer.packets_queued))
|
|
1817
|
+
|
|
1818
|
+
def _load_image(
|
|
1819
|
+
self,
|
|
1820
|
+
image_path: "StrOrBytesPath | Path",
|
|
1821
|
+
partial_palette: list[RGBColor] | None = None,
|
|
1822
|
+
):
|
|
1823
|
+
if partial_palette is None:
|
|
1824
|
+
partial_palette = []
|
|
1825
|
+
|
|
1826
|
+
self.logger.debug("loading image")
|
|
1827
|
+
image_rgba = Image.open(file_relative_to(image_path, self.relative_dir)).convert("RGBA")
|
|
1828
|
+
image = image_rgba.convert("RGB")
|
|
1829
|
+
|
|
1830
|
+
# REVIEW How many colors should I allow? Should I make this
|
|
1831
|
+
# configurable?
|
|
1832
|
+
COLORS = 16 - len(partial_palette)
|
|
1833
|
+
self.logger.debug(f"quantizing to {COLORS} color(s)")
|
|
1834
|
+
# Reduce colors with quantization and dithering
|
|
1835
|
+
image = image.quantize(
|
|
1836
|
+
colors=COLORS,
|
|
1837
|
+
palette=image.quantize(
|
|
1838
|
+
colors=COLORS,
|
|
1839
|
+
method=Image.Quantize.MAXCOVERAGE,
|
|
1840
|
+
),
|
|
1841
|
+
dither=Image.Dither.FLOYDSTEINBERG,
|
|
1842
|
+
)
|
|
1843
|
+
# Further reduce colors to conform to 12-bit RGB palette
|
|
1844
|
+
image.putpalette(
|
|
1845
|
+
[
|
|
1846
|
+
# HACK The RGB values of the colors that show up in CDG
|
|
1847
|
+
# players are repdigits in hexadecimal - 0x00, 0x11, 0x22,
|
|
1848
|
+
# 0x33, etc. This means that we can simply round each value
|
|
1849
|
+
# to the nearest multiple of 0x11 (17 in decimal).
|
|
1850
|
+
0x11 * round(v / 0x11)
|
|
1851
|
+
for v in image.getpalette()
|
|
1852
|
+
]
|
|
1853
|
+
)
|
|
1854
|
+
image = image.quantize()
|
|
1855
|
+
self.logger.debug(f"image uses {max(image.getdata()) + 1} color(s)")
|
|
1856
|
+
|
|
1857
|
+
if partial_palette:
|
|
1858
|
+
self.logger.debug(f"prepending {len(partial_palette)} color(s) to palette")
|
|
1859
|
+
# Add offset to color indices
|
|
1860
|
+
image.putdata(image.getdata(), offset=len(partial_palette))
|
|
1861
|
+
# Place other colors in palette
|
|
1862
|
+
image.putpalette(list(it.chain(*partial_palette)) + image.getpalette())
|
|
1863
|
+
|
|
1864
|
+
self.logger.debug(f"palette: {list(batched(image.getpalette(), 3))!r}")
|
|
1865
|
+
|
|
1866
|
+
self.logger.debug("masking out non-transparent parts of image")
|
|
1867
|
+
# Create mask for non-transparent parts of image
|
|
1868
|
+
# NOTE We allow alpha values from 128 to 255 (half-transparent
|
|
1869
|
+
# to opaque).
|
|
1870
|
+
mask = Image.new("1", image_rgba.size, 0)
|
|
1871
|
+
mask.putdata([0 if pixel >= 128 else 255 for pixel in image_rgba.getdata(band=3)])
|
|
1872
|
+
# Set transparent parts of background to 0
|
|
1873
|
+
image.paste(Image.new("P", image.size, 0), mask=mask)
|
|
1874
|
+
|
|
1875
|
+
return image
|
|
1876
|
+
|
|
1877
|
+
def _gradient_to_tile_positions(
|
|
1878
|
+
self,
|
|
1879
|
+
image: Image.Image,
|
|
1880
|
+
) -> list[tuple[int, int]]:
|
|
1881
|
+
"""
|
|
1882
|
+
Convert an image of a gradient to an ordering of tile positions.
|
|
1883
|
+
|
|
1884
|
+
The closer a section of the image is to white, the earlier it
|
|
1885
|
+
will appear. The closer a section of the image is to black, the
|
|
1886
|
+
later it will appear. The image is converted to `L` mode before
|
|
1887
|
+
processing.
|
|
1888
|
+
|
|
1889
|
+
Parameters
|
|
1890
|
+
----------
|
|
1891
|
+
image : `PIL.Image.Image`
|
|
1892
|
+
Image to convert.
|
|
1893
|
+
|
|
1894
|
+
Returns
|
|
1895
|
+
-------
|
|
1896
|
+
list of tuple of (int, int)
|
|
1897
|
+
Tile positions in order.
|
|
1898
|
+
"""
|
|
1899
|
+
image = image.convert("L")
|
|
1900
|
+
intensities: dict[tuple[int, int], int] = {}
|
|
1901
|
+
for tile_y, tile_x in it.product(
|
|
1902
|
+
range(CDG_SCREEN_HEIGHT // CDG_TILE_HEIGHT),
|
|
1903
|
+
range(CDG_SCREEN_WIDTH // CDG_TILE_WIDTH),
|
|
1904
|
+
):
|
|
1905
|
+
# NOTE The intensity is negative so that, when it's sorted,
|
|
1906
|
+
# it will be sorted from highest intensity to lowest. This
|
|
1907
|
+
# is not done with reverse=True to preserve the sort's
|
|
1908
|
+
# stability.
|
|
1909
|
+
intensities[(tile_y, tile_x)] = -sum(
|
|
1910
|
+
image.getpixel(
|
|
1911
|
+
(
|
|
1912
|
+
tile_x * CDG_TILE_WIDTH + x,
|
|
1913
|
+
tile_y * CDG_TILE_HEIGHT + y,
|
|
1914
|
+
)
|
|
1915
|
+
)
|
|
1916
|
+
for x in range(CDG_TILE_WIDTH)
|
|
1917
|
+
for y in range(CDG_TILE_HEIGHT)
|
|
1918
|
+
)
|
|
1919
|
+
return sorted(intensities, key=intensities.get)
|
|
1920
|
+
|
|
1921
|
+
# !SECTION
|
|
1922
|
+
# endregion
|
|
1923
|
+
|
|
1924
|
+
# region Create MP4
|
|
1925
|
+
# SECTION Create MP4
|
|
1926
|
+
def create_ass(self):
|
|
1927
|
+
if not ASS_REQUIREMENTS:
|
|
1928
|
+
raise RuntimeError("could not import requirements for creating ASS")
|
|
1929
|
+
|
|
1930
|
+
# Create ASS subtitle object
|
|
1931
|
+
# (ASS = Advanced Sub Station. Get your mind out of the gutter.)
|
|
1932
|
+
self.logger.debug("creating ASS subtitle object")
|
|
1933
|
+
assdoc = ass.Document()
|
|
1934
|
+
assdoc.fields.update(
|
|
1935
|
+
Title="",
|
|
1936
|
+
WrapStyle=2,
|
|
1937
|
+
ScaledBorderAndShadow="yes",
|
|
1938
|
+
Collisions="normal",
|
|
1939
|
+
PlayResX=CDG_SCREEN_WIDTH,
|
|
1940
|
+
PlayResY=CDG_SCREEN_HEIGHT,
|
|
1941
|
+
)
|
|
1942
|
+
|
|
1943
|
+
# Load lyric font using fontTools
|
|
1944
|
+
# NOTE We do this because we need some of the font's metadata.
|
|
1945
|
+
self.logger.debug("loading metadata from font")
|
|
1946
|
+
font = ttLib.TTFont(self.font.path)
|
|
1947
|
+
|
|
1948
|
+
# NOTE The ASS Style lines need the "fontname as used by
|
|
1949
|
+
# Windows". The best name for this purpose is name 4, which
|
|
1950
|
+
# Apple calls the "full name of the font". (Oh yeah, and Apple
|
|
1951
|
+
# developed TrueType, the font format used here. Who knew?)
|
|
1952
|
+
fontname = font["name"].getDebugName(4)
|
|
1953
|
+
|
|
1954
|
+
# NOTE PIL interprets a font's size as its "nominal size", or
|
|
1955
|
+
# "em height". The ASS format interprets a font's size as its
|
|
1956
|
+
# "actual size" - the area enclosing its highest and lowest
|
|
1957
|
+
# points.
|
|
1958
|
+
# Relative values for these sizes can be found/calculated from
|
|
1959
|
+
# the font's headers, and the ratio between them is used to
|
|
1960
|
+
# scale the lyric font size from nominal to actual.
|
|
1961
|
+
nominal_size = cast(int, font["head"].unitsPerEm)
|
|
1962
|
+
ascent = cast(int, font["hhea"].ascent)
|
|
1963
|
+
descent = cast(int, font["hhea"].descent)
|
|
1964
|
+
actual_size = ascent - descent
|
|
1965
|
+
fontsize = self.config.font_size * actual_size / nominal_size
|
|
1966
|
+
# HACK If I position each line at its proper Y position, it
|
|
1967
|
+
# looks shifted down slightly. This should correct it, I think.
|
|
1968
|
+
y_offset = self.config.font_size * (descent / 2) / nominal_size
|
|
1969
|
+
|
|
1970
|
+
# Create a style for each singer
|
|
1971
|
+
for i, singer in enumerate(self.config.singers, 1):
|
|
1972
|
+
self.logger.debug(f"creating ASS style for singer {i}")
|
|
1973
|
+
assdoc.styles.append(
|
|
1974
|
+
ass.Style(
|
|
1975
|
+
name=f"Singer{i}",
|
|
1976
|
+
fontname=fontname,
|
|
1977
|
+
fontsize=fontsize,
|
|
1978
|
+
primary_color=ass.line.Color(*singer.active_fill),
|
|
1979
|
+
secondary_color=ass.line.Color(*singer.inactive_fill),
|
|
1980
|
+
outline_color=ass.line.Color(*singer.inactive_stroke),
|
|
1981
|
+
back_color=ass.line.Color(*singer.active_stroke),
|
|
1982
|
+
border_style=1, # outline + drop shadow
|
|
1983
|
+
outline=self.config.stroke_width,
|
|
1984
|
+
shadow=0,
|
|
1985
|
+
alignment=8, # alignment point is at top middle
|
|
1986
|
+
margin_l=0,
|
|
1987
|
+
margin_r=0,
|
|
1988
|
+
margin_v=0,
|
|
1989
|
+
)
|
|
1990
|
+
)
|
|
1991
|
+
|
|
1992
|
+
offset = cdg_to_sync(self.intro_delay + self.sync_offset)
|
|
1993
|
+
instrumental = 0
|
|
1994
|
+
# Create events for each line sung in each lyric set
|
|
1995
|
+
for ci, (lyric, times) in enumerate(
|
|
1996
|
+
zip(
|
|
1997
|
+
self.lyrics,
|
|
1998
|
+
self.lyric_times,
|
|
1999
|
+
)
|
|
2000
|
+
):
|
|
2001
|
+
for li, line in enumerate(lyric.lines):
|
|
2002
|
+
# Skip line if it has no syllables
|
|
2003
|
+
if not line.syllables:
|
|
2004
|
+
continue
|
|
2005
|
+
self.logger.debug(f"creating event for lyric {ci} line {li}")
|
|
2006
|
+
|
|
2007
|
+
# Get intended draw time of line
|
|
2008
|
+
line_draw_time = cdg_to_sync(times.line_draw[li]) + offset
|
|
2009
|
+
# XXX This is hardcoded, so as to not have the line's
|
|
2010
|
+
# appearance clash with the intro.
|
|
2011
|
+
line_draw_time = max(line_draw_time, 800)
|
|
2012
|
+
|
|
2013
|
+
# The upcoming instrumental section should be the first
|
|
2014
|
+
# one after this line is drawn
|
|
2015
|
+
while instrumental < len(self.instrumental_times) and (
|
|
2016
|
+
cdg_to_sync(self.instrumental_times[instrumental]) <= line_draw_time
|
|
2017
|
+
):
|
|
2018
|
+
instrumental += 1
|
|
2019
|
+
|
|
2020
|
+
# Get intended erase time of line, if possible
|
|
2021
|
+
if times.line_erase:
|
|
2022
|
+
line_erase_time = cdg_to_sync(times.line_erase[li]) + offset
|
|
2023
|
+
# If there are no erase times saved, then lyrics are
|
|
2024
|
+
# being cleared by page instead of being erased
|
|
2025
|
+
else:
|
|
2026
|
+
# Get first non-empty line of next page
|
|
2027
|
+
next_page_li = (li // lyric.lines_per_page + 1) * lyric.lines_per_page
|
|
2028
|
+
while next_page_li < len(lyric.lines):
|
|
2029
|
+
if lyric.lines[next_page_li].syllables:
|
|
2030
|
+
break
|
|
2031
|
+
next_page_li += 1
|
|
2032
|
+
|
|
2033
|
+
# If there is a next page
|
|
2034
|
+
if next_page_li < len(lyric.lines):
|
|
2035
|
+
# Erase the current line when the next page is
|
|
2036
|
+
# drawn
|
|
2037
|
+
line_erase_time = cdg_to_sync(times.line_draw[next_page_li]) + offset
|
|
2038
|
+
# If there is no next page
|
|
2039
|
+
else:
|
|
2040
|
+
# Erase the current line after the last syllable
|
|
2041
|
+
# of this line is highlighted
|
|
2042
|
+
# XXX This is hardcoded.
|
|
2043
|
+
line_erase_time = cdg_to_sync(line.syllables[-1].end_offset) + offset + 200
|
|
2044
|
+
|
|
2045
|
+
if instrumental < len(self.instrumental_times):
|
|
2046
|
+
# The current line should be erased before the
|
|
2047
|
+
# upcoming instrumental section
|
|
2048
|
+
line_erase_time = min(
|
|
2049
|
+
line_erase_time,
|
|
2050
|
+
cdg_to_sync(self.instrumental_times[instrumental]),
|
|
2051
|
+
)
|
|
2052
|
+
|
|
2053
|
+
text = ""
|
|
2054
|
+
# Text is horizontally centered, and at the line's Y
|
|
2055
|
+
x = CDG_SCREEN_WIDTH // 2
|
|
2056
|
+
y = line.y + y_offset
|
|
2057
|
+
text += f"{{\\pos({x},{y})}}"
|
|
2058
|
+
# Text should fade in and out with the intended
|
|
2059
|
+
# draw/erase timing
|
|
2060
|
+
# NOTE This is in milliseconds for some reason, whereas
|
|
2061
|
+
# every other timing value is in centiseconds.
|
|
2062
|
+
fade = cdg_to_sync(self.LINE_DRAW_ERASE_GAP) * 10
|
|
2063
|
+
text += f"{{\\fad({fade},{fade})}}"
|
|
2064
|
+
# There should be a pause before the text is highlighted
|
|
2065
|
+
line_start_offset = cdg_to_sync(line.syllables[0].start_offset) + offset
|
|
2066
|
+
text += f"{{\\k{line_start_offset - line_draw_time}}}"
|
|
2067
|
+
# Each syllable should be filled in for the specified
|
|
2068
|
+
# duration
|
|
2069
|
+
for syll in line.syllables:
|
|
2070
|
+
length = cdg_to_sync(syll.end_offset - syll.start_offset)
|
|
2071
|
+
text += f"{{\\kf{length}}}{syll.text}"
|
|
2072
|
+
|
|
2073
|
+
# Create a dialogue event for this line
|
|
2074
|
+
assdoc.events.append(
|
|
2075
|
+
ass.Dialogue(
|
|
2076
|
+
layer=ci,
|
|
2077
|
+
# NOTE The line draw and erase times are in
|
|
2078
|
+
# centiseconds, so we need to multiply by 10 for
|
|
2079
|
+
# milliseconds.
|
|
2080
|
+
start=timedelta(milliseconds=line_draw_time * 10),
|
|
2081
|
+
end=timedelta(milliseconds=line_erase_time * 10),
|
|
2082
|
+
style=f"Singer{line.singer}",
|
|
2083
|
+
effect="karaoke",
|
|
2084
|
+
text=text,
|
|
2085
|
+
)
|
|
2086
|
+
)
|
|
2087
|
+
|
|
2088
|
+
outname = self.config.outname
|
|
2089
|
+
assfile_name = self.relative_dir / Path(f"{outname}.ass")
|
|
2090
|
+
self.logger.debug(f"dumping ASS object to {assfile_name}")
|
|
2091
|
+
# HACK If I don't specify "utf-8-sig" as the encoding, the
|
|
2092
|
+
# python-ass module gives me a warning telling me to. This adds
|
|
2093
|
+
# a "byte order mark" to the ASS file (seemingly unnecessarily).
|
|
2094
|
+
with open(assfile_name, "w", encoding="utf-8-sig") as assfile:
|
|
2095
|
+
assdoc.dump_file(assfile)
|
|
2096
|
+
self.logger.info(f"ASS object dumped to {assfile_name}")
|
|
2097
|
+
|
|
2098
|
+
def create_mp4(self, height: int = 720, fps: int = 30):
|
|
2099
|
+
if not MP4_REQUIREMENTS:
|
|
2100
|
+
raise RuntimeError("could not import requirements for creating MP4")
|
|
2101
|
+
|
|
2102
|
+
outname = self.config.outname
|
|
2103
|
+
|
|
2104
|
+
# Create a "background plate" for the video
|
|
2105
|
+
# NOTE The "background plate" will simply be the CDG file we've
|
|
2106
|
+
# composed, but without the lyrics. We create this by replacing
|
|
2107
|
+
# all lyric-drawing packets with no-instruction packets.
|
|
2108
|
+
platecdg_name = self.relative_dir / Path(f"{outname}.plate.cdg")
|
|
2109
|
+
self.logger.debug(f"writing plate CDG to {platecdg_name}")
|
|
2110
|
+
with open(platecdg_name, "wb") as platecdg:
|
|
2111
|
+
self.logger.debug("writing plate")
|
|
2112
|
+
for i, packet in enumerate(self.writer.packets):
|
|
2113
|
+
packet_to_write = packet
|
|
2114
|
+
if i in self.lyric_packet_indices:
|
|
2115
|
+
packet_to_write = no_instruction()
|
|
2116
|
+
self.writer.write_packet(platecdg, packet_to_write)
|
|
2117
|
+
self.logger.info(f"plate CDG written to {platecdg_name}")
|
|
2118
|
+
|
|
2119
|
+
# Create an MP3 file for the audio
|
|
2120
|
+
platemp3_name = self.relative_dir / Path(f"{outname}.plate.mp3")
|
|
2121
|
+
self.logger.debug(f"writing plate MP3 to {platemp3_name}")
|
|
2122
|
+
self.audio.export(platemp3_name, format="mp3")
|
|
2123
|
+
self.logger.info(f"plate MP3 written to {platemp3_name}")
|
|
2124
|
+
|
|
2125
|
+
# Create a subtitle file for the HQ lyrics
|
|
2126
|
+
self.create_ass()
|
|
2127
|
+
assfile_name = self.relative_dir / Path(f"{outname}.ass")
|
|
2128
|
+
|
|
2129
|
+
self.logger.debug("building ffmpeg command for encoding MP4")
|
|
2130
|
+
video = (
|
|
2131
|
+
ffmpeg.input(platecdg_name).video
|
|
2132
|
+
# Pad the end of the video by a few seconds
|
|
2133
|
+
# HACK This ensures the last video frame isn't some CDG
|
|
2134
|
+
# frame before the last one. This padding will also be cut
|
|
2135
|
+
# later.
|
|
2136
|
+
.filter_("tpad", stop_mode="clone", stop_duration=5)
|
|
2137
|
+
# Set framerate
|
|
2138
|
+
.filter_("fps", fps=fps)
|
|
2139
|
+
# Scale video to resolution
|
|
2140
|
+
.filter_(
|
|
2141
|
+
"scale",
|
|
2142
|
+
# HACK The libx264 codec requires the video dimensions
|
|
2143
|
+
# to be divisible by 2. Here, the width is not only
|
|
2144
|
+
# automatically calculated from the plate's aspect
|
|
2145
|
+
# ratio, but truncated down to a multiple of 2.
|
|
2146
|
+
w="trunc(oh*a/2)*2",
|
|
2147
|
+
h=height // 2 * 2,
|
|
2148
|
+
flags="neighbor",
|
|
2149
|
+
)
|
|
2150
|
+
# Burn in subtitles
|
|
2151
|
+
.filter_("ass", filename=assfile_name)
|
|
2152
|
+
)
|
|
2153
|
+
audio = ffmpeg.input(platemp3_name)
|
|
2154
|
+
|
|
2155
|
+
mp4_name = self.relative_dir / Path(f"{outname}.mp4")
|
|
2156
|
+
mp4 = ffmpeg.output(
|
|
2157
|
+
video,
|
|
2158
|
+
audio,
|
|
2159
|
+
filename=mp4_name,
|
|
2160
|
+
hide_banner=None,
|
|
2161
|
+
loglevel="error",
|
|
2162
|
+
stats=None,
|
|
2163
|
+
# Video should use the H.264 codec, at a decent quality
|
|
2164
|
+
vcodec="libx264",
|
|
2165
|
+
pix_fmt="yuv420p",
|
|
2166
|
+
crf=22,
|
|
2167
|
+
preset="veryfast",
|
|
2168
|
+
# Truncate to the length of the shortest input
|
|
2169
|
+
# HACK This effectively removes the video padding that was
|
|
2170
|
+
# added earlier, because the audio is shorter than the
|
|
2171
|
+
# padded video.
|
|
2172
|
+
shortest=None,
|
|
2173
|
+
).overwrite_output()
|
|
2174
|
+
self.logger.debug(f"ffmpeg command: {mp4.compile()}")
|
|
2175
|
+
mp4.run()
|
|
2176
|
+
|
|
2177
|
+
self.logger.debug("deleting plate CDG")
|
|
2178
|
+
platecdg_name.unlink()
|
|
2179
|
+
self.logger.info("plate CDG deleted")
|
|
2180
|
+
|
|
2181
|
+
self.logger.debug("deleting plate MP3")
|
|
2182
|
+
platemp3_name.unlink()
|
|
2183
|
+
self.logger.info("plate MP3 deleted")
|
|
2184
|
+
|
|
2185
|
+
# !SECTION
|
|
2186
|
+
# endregion
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
def main():
|
|
2190
|
+
from argparse import ArgumentParser, RawDescriptionHelpFormatter
|
|
2191
|
+
import sys
|
|
2192
|
+
|
|
2193
|
+
parser = ArgumentParser(
|
|
2194
|
+
prog="py -m cdgmaker",
|
|
2195
|
+
description="Create custom CDG files for karaoke.",
|
|
2196
|
+
epilog=("For a description of the config format, visit " "https://github.com/WinslowJosiah/cdgmaker"),
|
|
2197
|
+
formatter_class=RawDescriptionHelpFormatter,
|
|
2198
|
+
)
|
|
2199
|
+
parser.add_argument(
|
|
2200
|
+
"config",
|
|
2201
|
+
help=".toml config file to create CDG files with",
|
|
2202
|
+
metavar="FILE",
|
|
2203
|
+
type=str,
|
|
2204
|
+
)
|
|
2205
|
+
parser.add_argument(
|
|
2206
|
+
"-v",
|
|
2207
|
+
"--verbose",
|
|
2208
|
+
help="make logs more verbose (-v, -vv, etc.)",
|
|
2209
|
+
action="count",
|
|
2210
|
+
default=0,
|
|
2211
|
+
)
|
|
2212
|
+
parser.add_argument(
|
|
2213
|
+
"-r",
|
|
2214
|
+
"--render",
|
|
2215
|
+
help="render MP4 video of created CDG file",
|
|
2216
|
+
action="store_true",
|
|
2217
|
+
)
|
|
2218
|
+
|
|
2219
|
+
# If there aren't any arguments to parse
|
|
2220
|
+
if len(sys.argv) < 2:
|
|
2221
|
+
# Print help message and exit with error
|
|
2222
|
+
parser.print_help()
|
|
2223
|
+
sys.exit(1)
|
|
2224
|
+
|
|
2225
|
+
# Overwrite the error handler to also print a help message
|
|
2226
|
+
# HACK: This is what's known in the biz as a "monkey-patch". Don't
|
|
2227
|
+
# worry if it doesn't make sense to you; it makes sense to argparse,
|
|
2228
|
+
# and that's all that matters.
|
|
2229
|
+
def custom_error_handler(_self: ArgumentParser):
|
|
2230
|
+
|
|
2231
|
+
def wrapper(message: str):
|
|
2232
|
+
sys.stderr.write(f"{_self.prog}: error: {message}\n")
|
|
2233
|
+
_self.print_help()
|
|
2234
|
+
sys.exit(2)
|
|
2235
|
+
|
|
2236
|
+
return wrapper
|
|
2237
|
+
|
|
2238
|
+
parser.error = custom_error_handler(parser)
|
|
2239
|
+
|
|
2240
|
+
# Parse command line arguments
|
|
2241
|
+
args = parser.parse_args()
|
|
2242
|
+
|
|
2243
|
+
# Set logging level based on verbosity
|
|
2244
|
+
log_level = logging.ERROR
|
|
2245
|
+
if not args.verbose:
|
|
2246
|
+
log_level = logging.WARNING
|
|
2247
|
+
elif args.verbose == 1:
|
|
2248
|
+
log_level = logging.INFO
|
|
2249
|
+
elif args.verbose >= 2:
|
|
2250
|
+
log_level = logging.DEBUG
|
|
2251
|
+
logging.basicConfig(level=log_level)
|
|
2252
|
+
|
|
2253
|
+
kc = KaraokeComposer.from_file(args.config)
|
|
2254
|
+
kc.compose()
|
|
2255
|
+
if args.render:
|
|
2256
|
+
kc.create_mp4(height=1080, fps=60)
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
if __name__ == "__main__":
|
|
2260
|
+
main()
|