lyrics-transcriber 0.30.0__py3-none-any.whl → 0.32.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lyrics_transcriber/__init__.py +2 -1
- lyrics_transcriber/cli/{main.py → cli_main.py} +47 -14
- lyrics_transcriber/core/config.py +35 -0
- lyrics_transcriber/core/controller.py +164 -166
- lyrics_transcriber/correction/anchor_sequence.py +471 -0
- lyrics_transcriber/correction/corrector.py +256 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +30 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +91 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +147 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +98 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +55 -0
- lyrics_transcriber/correction/handlers/repeat.py +71 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +223 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +182 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +54 -0
- lyrics_transcriber/correction/handlers/word_operations.py +135 -0
- lyrics_transcriber/correction/phrase_analyzer.py +426 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +125 -0
- lyrics_transcriber/lyrics/genius.py +73 -0
- lyrics_transcriber/lyrics/spotify.py +82 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/{ass.py → ass/ass.py} +150 -690
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +37 -0
- lyrics_transcriber/output/ass/constants.py +23 -0
- lyrics_transcriber/output/ass/event.py +94 -0
- lyrics_transcriber/output/ass/formatters.py +132 -0
- lyrics_transcriber/output/ass/lyrics_line.py +219 -0
- lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
- lyrics_transcriber/output/ass/section_detector.py +89 -0
- lyrics_transcriber/output/ass/section_screen.py +106 -0
- lyrics_transcriber/output/ass/style.py +187 -0
- lyrics_transcriber/output/cdg.py +503 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +1919 -0
- lyrics_transcriber/output/cdgmaker/config.py +151 -0
- lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
- lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
- lyrics_transcriber/output/cdgmaker/pack.py +507 -0
- lyrics_transcriber/output/cdgmaker/render.py +346 -0
- lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
- lyrics_transcriber/output/cdgmaker/utils.py +132 -0
- lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
- lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
- lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/arial.ttf +0 -0
- lyrics_transcriber/output/fonts/georgia.ttf +0 -0
- lyrics_transcriber/output/fonts/verdana.ttf +0 -0
- lyrics_transcriber/output/generator.py +140 -171
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +91 -0
- lyrics_transcriber/output/segment_resizer.py +416 -0
- lyrics_transcriber/output/subtitles.py +328 -302
- lyrics_transcriber/output/video.py +219 -0
- lyrics_transcriber/review/__init__.py +1 -0
- lyrics_transcriber/review/server.py +138 -0
- lyrics_transcriber/storage/dropbox.py +110 -134
- lyrics_transcriber/transcribers/audioshake.py +171 -105
- lyrics_transcriber/transcribers/base_transcriber.py +149 -0
- lyrics_transcriber/transcribers/whisper.py +267 -133
- lyrics_transcriber/types.py +454 -0
- {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/METADATA +14 -3
- lyrics_transcriber-0.32.1.dist-info/RECORD +86 -0
- {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/WHEEL +1 -1
- lyrics_transcriber-0.32.1.dist-info/entry_points.txt +4 -0
- lyrics_transcriber/core/corrector.py +0 -56
- lyrics_transcriber/core/fetcher.py +0 -143
- lyrics_transcriber/storage/tokens.py +0 -116
- lyrics_transcriber/transcribers/base.py +0 -31
- lyrics_transcriber-0.30.0.dist-info/RECORD +0 -22
- lyrics_transcriber-0.30.0.dist-info/entry_points.txt +0 -3
- {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,252 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import List, Optional, Tuple
|
3
|
+
import logging
|
4
|
+
from datetime import timedelta
|
5
|
+
|
6
|
+
from lyrics_transcriber.output.ass.style import Style
|
7
|
+
from lyrics_transcriber.output.ass.event import Event
|
8
|
+
from lyrics_transcriber.output.ass.lyrics_line import LyricsLine
|
9
|
+
from lyrics_transcriber.output.ass.config import ScreenConfig, LineTimingInfo, LineState
|
10
|
+
|
11
|
+
|
12
|
+
class PositionStrategy:
|
13
|
+
"""Handles calculation of vertical positions for lines."""
|
14
|
+
|
15
|
+
def __init__(self, video_size: Tuple[int, int], config: ScreenConfig):
|
16
|
+
self.video_size = video_size
|
17
|
+
self.config = config
|
18
|
+
|
19
|
+
def calculate_line_positions(self) -> List[int]:
|
20
|
+
"""Calculate vertical positions for all possible lines."""
|
21
|
+
return PositionCalculator.calculate_line_positions(self.config)
|
22
|
+
|
23
|
+
|
24
|
+
class PositionCalculator:
|
25
|
+
"""Shared position calculation logic."""
|
26
|
+
|
27
|
+
@staticmethod
|
28
|
+
def calculate_first_line_position(config: ScreenConfig) -> int:
|
29
|
+
"""Calculate vertical position of first line."""
|
30
|
+
total_height = config.max_visible_lines * config.line_height
|
31
|
+
return config.top_padding + (config.video_height - total_height - config.top_padding) // 4
|
32
|
+
|
33
|
+
@staticmethod
|
34
|
+
def calculate_line_positions(config: ScreenConfig) -> List[int]:
|
35
|
+
"""Calculate vertical positions for all possible lines."""
|
36
|
+
first_pos = PositionCalculator.calculate_first_line_position(config)
|
37
|
+
return [first_pos + (i * config.line_height) for i in range(config.max_visible_lines)]
|
38
|
+
|
39
|
+
@staticmethod
|
40
|
+
def position_to_line_index(y_position: int, config: ScreenConfig) -> int:
|
41
|
+
"""Convert y-position to 0-based line index."""
|
42
|
+
first_pos = PositionCalculator.calculate_first_line_position(config)
|
43
|
+
return (y_position - first_pos) // config.line_height
|
44
|
+
|
45
|
+
@staticmethod
|
46
|
+
def line_index_to_position(index: int, config: ScreenConfig) -> int:
|
47
|
+
"""Convert 0-based line index to y-position."""
|
48
|
+
first_pos = PositionCalculator.calculate_first_line_position(config)
|
49
|
+
return first_pos + (index * config.line_height)
|
50
|
+
|
51
|
+
|
52
|
+
class TimingStrategy:
|
53
|
+
"""Handles calculation of line timings during screen transitions."""
|
54
|
+
|
55
|
+
def __init__(self, config: ScreenConfig, logger: Optional[logging.Logger] = None):
|
56
|
+
self.config = config
|
57
|
+
self.logger = logger or logging.getLogger(__name__)
|
58
|
+
|
59
|
+
def calculate_line_timings(
|
60
|
+
self,
|
61
|
+
current_lines: List[LyricsLine],
|
62
|
+
previous_active_lines: Optional[List[Tuple[float, int, str]]] = None,
|
63
|
+
previous_instrumental_end: Optional[float] = None,
|
64
|
+
) -> List[LineTimingInfo]:
|
65
|
+
"""Calculate timing information for each line in the screen."""
|
66
|
+
previous_active_lines = previous_active_lines or []
|
67
|
+
|
68
|
+
# If no previous lines, determine appropriate start time
|
69
|
+
if not previous_active_lines:
|
70
|
+
if previous_instrumental_end is not None:
|
71
|
+
# For post-instrumental, start right after the instrumental
|
72
|
+
return self._calculate_simultaneous_timings(current_lines, fade_in_start=previous_instrumental_end)
|
73
|
+
else:
|
74
|
+
# For first screen, start at 0
|
75
|
+
return self._calculate_simultaneous_timings(current_lines, fade_in_start=0.0)
|
76
|
+
|
77
|
+
# Create a map of position -> clear time from previous lines
|
78
|
+
position_clear_times = {}
|
79
|
+
for end_time, y_position, text in previous_active_lines:
|
80
|
+
# Clear time is now just when the fade out ends
|
81
|
+
clear_time = end_time + (self.config.fade_out_ms / 1000)
|
82
|
+
position_clear_times[y_position] = clear_time
|
83
|
+
|
84
|
+
# Add buffer time for the position above the active line
|
85
|
+
# Use the end time without fade for the line above to give more reading time
|
86
|
+
line_index = PositionCalculator.position_to_line_index(y_position, self.config)
|
87
|
+
# fmt: off
|
88
|
+
if line_index > 0: # If not the top line
|
89
|
+
position_above = PositionCalculator.line_index_to_position(line_index - 1, self.config)
|
90
|
+
position_clear_times[position_above] = max(
|
91
|
+
position_clear_times.get(position_above, 0),
|
92
|
+
end_time # Use end time without fade for position above
|
93
|
+
)
|
94
|
+
# fmt: on
|
95
|
+
|
96
|
+
self.logger.debug(f" Position {line_index + 1} occupied by '{text}' until {clear_time:.2f}s")
|
97
|
+
|
98
|
+
# Calculate timings for each line
|
99
|
+
timings = []
|
100
|
+
positions = PositionCalculator.calculate_line_positions(self.config)
|
101
|
+
for i, (line, position) in enumerate(zip(current_lines, positions)):
|
102
|
+
# Fade in as soon as the position is available
|
103
|
+
fade_in_time = position_clear_times.get(position, 0)
|
104
|
+
|
105
|
+
# Calculate remaining timing information
|
106
|
+
end_time = line.segment.end_time + self.config.post_roll_time
|
107
|
+
fade_out_time = end_time + (self.config.fade_out_ms / 1000)
|
108
|
+
# Clear time is now just the fade out time
|
109
|
+
clear_time = fade_out_time
|
110
|
+
|
111
|
+
# fmt: off
|
112
|
+
timing = LineTimingInfo(
|
113
|
+
fade_in_time=fade_in_time,
|
114
|
+
end_time=end_time,
|
115
|
+
fade_out_time=fade_out_time,
|
116
|
+
clear_time=clear_time
|
117
|
+
)
|
118
|
+
# fmt: on
|
119
|
+
timings.append(timing)
|
120
|
+
|
121
|
+
line_index = PositionCalculator.position_to_line_index(position, self.config)
|
122
|
+
self.logger.debug(
|
123
|
+
f" Line {line_index + 1}: '{line.segment.text}' "
|
124
|
+
f"fades in at {fade_in_time:.2f}s "
|
125
|
+
f"(position available at {position_clear_times.get(position, 0):.2f}s)"
|
126
|
+
)
|
127
|
+
|
128
|
+
return timings
|
129
|
+
|
130
|
+
def _calculate_simultaneous_timings(self, lines: List[LyricsLine], fade_in_start: float) -> List[LineTimingInfo]:
|
131
|
+
"""Calculate timings for screens where all lines appear together."""
|
132
|
+
return [
|
133
|
+
LineTimingInfo(
|
134
|
+
fade_in_time=fade_in_start,
|
135
|
+
end_time=line.segment.end_time + self.config.post_roll_time,
|
136
|
+
fade_out_time=line.segment.end_time + self.config.post_roll_time + (self.config.fade_out_ms / 1000),
|
137
|
+
clear_time=line.segment.end_time + self.config.post_roll_time + (self.config.fade_out_ms / 1000),
|
138
|
+
)
|
139
|
+
for line in lines
|
140
|
+
]
|
141
|
+
|
142
|
+
|
143
|
+
@dataclass
|
144
|
+
class LyricsScreen:
|
145
|
+
"""Represents a screen of lyrics (multiple lines)."""
|
146
|
+
|
147
|
+
video_size: Tuple[int, int]
|
148
|
+
line_height: int
|
149
|
+
lines: List[LyricsLine] = None # Make lines optional
|
150
|
+
logger: Optional[logging.Logger] = None
|
151
|
+
post_instrumental: bool = False
|
152
|
+
config: Optional[ScreenConfig] = None
|
153
|
+
|
154
|
+
def __post_init__(self):
|
155
|
+
if self.logger is None:
|
156
|
+
self.logger = logging.getLogger(__name__)
|
157
|
+
if self.config is None:
|
158
|
+
self.config = ScreenConfig(line_height=self.line_height)
|
159
|
+
else:
|
160
|
+
# Ensure line_height is consistent
|
161
|
+
self.config.line_height = self.line_height
|
162
|
+
|
163
|
+
# Initialize empty lines list if None
|
164
|
+
if self.lines is None:
|
165
|
+
self.lines = []
|
166
|
+
|
167
|
+
# Update video height in config
|
168
|
+
self.config.video_width = self.video_size[0]
|
169
|
+
self.config.video_height = self.video_size[1]
|
170
|
+
|
171
|
+
# Initialize strategies
|
172
|
+
self.position_strategy = PositionStrategy(self.video_size, self.config)
|
173
|
+
self.timing_strategy = TimingStrategy(self.config, self.logger)
|
174
|
+
|
175
|
+
def as_ass_events(
|
176
|
+
self,
|
177
|
+
style: Style,
|
178
|
+
previous_active_lines: Optional[List[Tuple[float, int, str]]] = None,
|
179
|
+
previous_instrumental_end: Optional[float] = None,
|
180
|
+
) -> Tuple[List[Event], List[Tuple[float, int, str]]]:
|
181
|
+
"""Convert screen to ASS events. Returns (events, active_lines)."""
|
182
|
+
events = []
|
183
|
+
active_lines = []
|
184
|
+
previous_active_lines = previous_active_lines or []
|
185
|
+
|
186
|
+
# Find the latest end time from previous lines
|
187
|
+
previous_end_time = None
|
188
|
+
if previous_active_lines:
|
189
|
+
previous_end_time = max(end_time for end_time, _, _ in previous_active_lines)
|
190
|
+
|
191
|
+
# Log active lines from previous screen
|
192
|
+
if previous_active_lines:
|
193
|
+
self.logger.debug(" Active lines from previous screen:")
|
194
|
+
for end, pos, text in previous_active_lines:
|
195
|
+
# Convert y-position back to line index (0-based)
|
196
|
+
line_index = PositionCalculator.position_to_line_index(pos, self.config)
|
197
|
+
clear_time = end + (self.config.fade_out_ms / 1000)
|
198
|
+
self.logger.debug(
|
199
|
+
f" Line {line_index + 1}: '{text}' "
|
200
|
+
f"(ends {end:.2f}s, fade out {end + (self.config.fade_out_ms / 1000):.2f}s, clear {clear_time:.2f}s)"
|
201
|
+
)
|
202
|
+
|
203
|
+
# Calculate positions and timings
|
204
|
+
positions = self.position_strategy.calculate_line_positions()
|
205
|
+
# fmt: off
|
206
|
+
timings = self.timing_strategy.calculate_line_timings(
|
207
|
+
current_lines=self.lines,
|
208
|
+
previous_active_lines=previous_active_lines,
|
209
|
+
previous_instrumental_end=previous_instrumental_end
|
210
|
+
)
|
211
|
+
# fmt: on
|
212
|
+
|
213
|
+
# Create line states and events
|
214
|
+
for i, (line, timing) in enumerate(zip(self.lines, timings)):
|
215
|
+
y_position = positions[i]
|
216
|
+
|
217
|
+
# Create line state
|
218
|
+
line_state = LineState(text=line.segment.text, timing=timing, y_position=y_position)
|
219
|
+
|
220
|
+
# Create ASS events with previous end time info
|
221
|
+
# fmt: off
|
222
|
+
line_events = line.create_ass_events(
|
223
|
+
state=line_state,
|
224
|
+
style=style,
|
225
|
+
config=self.config,
|
226
|
+
previous_end_time=previous_end_time
|
227
|
+
)
|
228
|
+
# fmt: on
|
229
|
+
events.extend(line_events)
|
230
|
+
|
231
|
+
# Track this line's end time for the next screen
|
232
|
+
previous_end_time = timing.end_time
|
233
|
+
active_lines.append((timing.end_time, y_position, line.segment.text))
|
234
|
+
|
235
|
+
# Log line placement with index
|
236
|
+
self.logger.debug(f" Line {i + 1}: '{line.segment.text}'")
|
237
|
+
|
238
|
+
return events, active_lines
|
239
|
+
|
240
|
+
@property
|
241
|
+
def start_ts(self) -> timedelta:
|
242
|
+
"""Get screen start timestamp."""
|
243
|
+
return timedelta(seconds=min(line.segment.start_time for line in self.lines))
|
244
|
+
|
245
|
+
@property
|
246
|
+
def end_ts(self) -> timedelta:
|
247
|
+
"""Get screen end timestamp."""
|
248
|
+
latest_ts = max(line.segment.end_time for line in self.lines)
|
249
|
+
return timedelta(seconds=latest_ts + self.config.post_roll_time)
|
250
|
+
|
251
|
+
def __str__(self):
|
252
|
+
return "\n".join([f"{self.start_ts} - {self.end_ts}:", *[f"\t{line}" for line in self.lines]])
|
@@ -0,0 +1,89 @@
|
|
1
|
+
from typing import List, Optional, Tuple
|
2
|
+
import logging
|
3
|
+
|
4
|
+
from lyrics_transcriber.types import LyricsSegment
|
5
|
+
from lyrics_transcriber.output.ass import LyricsScreen, SectionScreen
|
6
|
+
|
7
|
+
|
8
|
+
class SectionDetector:
|
9
|
+
"""Detects and creates section screens between lyrics."""
|
10
|
+
|
11
|
+
def __init__(self, gap_threshold: float = 10.0, logger: Optional[logging.Logger] = None):
|
12
|
+
self.gap_threshold = gap_threshold
|
13
|
+
self.logger = logger or logging.getLogger(__name__)
|
14
|
+
self.intro_padding = 0.0 # No padding for intro
|
15
|
+
self.outro_padding = 5.0 # End 5s before song ends
|
16
|
+
self.instrumental_start_padding = 1.0 # Start 1s after previous segment
|
17
|
+
self.instrumental_end_padding = 5.0 # End 5s before next segment
|
18
|
+
|
19
|
+
def process_segments(
|
20
|
+
self, segments: List[LyricsSegment], video_size: Tuple[int, int], line_height: int, song_duration: float
|
21
|
+
) -> List[LyricsScreen]:
|
22
|
+
"""Process segments and insert section screens where appropriate.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
segments: List of lyric segments
|
26
|
+
video_size: Tuple of (width, height) for video resolution
|
27
|
+
line_height: Height of each line in pixels
|
28
|
+
song_duration: Total duration of the song in seconds
|
29
|
+
"""
|
30
|
+
if not segments:
|
31
|
+
return []
|
32
|
+
|
33
|
+
screens: List[LyricsScreen] = []
|
34
|
+
|
35
|
+
# Check for intro
|
36
|
+
if segments[0].start_time >= self.gap_threshold:
|
37
|
+
self.logger.debug(f"Detected intro section: 0.0 - {segments[0].start_time:.2f}s")
|
38
|
+
screens.append(
|
39
|
+
SectionScreen(
|
40
|
+
section_type="INTRO",
|
41
|
+
start_time=0.0,
|
42
|
+
end_time=segments[0].start_time - self.intro_padding,
|
43
|
+
video_size=video_size,
|
44
|
+
line_height=line_height,
|
45
|
+
logger=self.logger,
|
46
|
+
)
|
47
|
+
)
|
48
|
+
|
49
|
+
# Check for instrumental sections between segments
|
50
|
+
for i in range(len(segments) - 1):
|
51
|
+
gap = segments[i + 1].start_time - segments[i].end_time
|
52
|
+
if gap >= self.gap_threshold:
|
53
|
+
instrumental_start = segments[i].end_time + self.instrumental_start_padding
|
54
|
+
instrumental_end = segments[i + 1].start_time - self.instrumental_end_padding
|
55
|
+
|
56
|
+
# Only create section if there's meaningful duration after padding
|
57
|
+
if instrumental_end > instrumental_start:
|
58
|
+
self.logger.debug(f"Detected instrumental section: {instrumental_start:.2f} - {instrumental_end:.2f}s")
|
59
|
+
screens.append(
|
60
|
+
SectionScreen(
|
61
|
+
section_type="INSTRUMENTAL",
|
62
|
+
start_time=instrumental_start,
|
63
|
+
end_time=instrumental_end,
|
64
|
+
video_size=video_size,
|
65
|
+
line_height=line_height,
|
66
|
+
logger=self.logger,
|
67
|
+
)
|
68
|
+
)
|
69
|
+
|
70
|
+
# Check for outro
|
71
|
+
if segments: # Only add outro if there are segments
|
72
|
+
last_segment = segments[-1]
|
73
|
+
outro_duration = song_duration - last_segment.end_time
|
74
|
+
if outro_duration >= self.gap_threshold:
|
75
|
+
outro_start = last_segment.end_time + self.instrumental_start_padding
|
76
|
+
outro_end = song_duration - self.outro_padding # End 5s before song ends
|
77
|
+
self.logger.debug(f"Detected outro section: {outro_start:.2f}s - {outro_end:.2f}s")
|
78
|
+
screens.append(
|
79
|
+
SectionScreen(
|
80
|
+
section_type="OUTRO",
|
81
|
+
start_time=outro_start,
|
82
|
+
end_time=outro_end,
|
83
|
+
video_size=video_size,
|
84
|
+
line_height=line_height,
|
85
|
+
logger=self.logger,
|
86
|
+
)
|
87
|
+
)
|
88
|
+
|
89
|
+
return screens
|
@@ -0,0 +1,106 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import List, Optional, Literal, Tuple
|
3
|
+
import logging
|
4
|
+
from datetime import timedelta
|
5
|
+
|
6
|
+
from lyrics_transcriber.output.ass.style import Style
|
7
|
+
from lyrics_transcriber.output.ass.event import Event
|
8
|
+
from lyrics_transcriber.output.ass.lyrics_screen import ScreenConfig
|
9
|
+
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class SectionScreen:
|
13
|
+
"""Special screen for instrumental sections."""
|
14
|
+
|
15
|
+
section_type: Literal["INTRO", "OUTRO", "INSTRUMENTAL"]
|
16
|
+
start_time: float
|
17
|
+
end_time: float
|
18
|
+
video_size: Tuple[int, int]
|
19
|
+
line_height: int
|
20
|
+
logger: Optional[logging.Logger] = None
|
21
|
+
config: Optional[ScreenConfig] = None
|
22
|
+
|
23
|
+
def __post_init__(self):
|
24
|
+
self._initialize_logger_and_config()
|
25
|
+
self._calculate_duration()
|
26
|
+
self._adjust_timing()
|
27
|
+
self._create_text()
|
28
|
+
|
29
|
+
def _initialize_logger_and_config(self):
|
30
|
+
"""Initialize logger and config with defaults if not provided."""
|
31
|
+
if self.logger is None:
|
32
|
+
self.logger = logging.getLogger(__name__)
|
33
|
+
if self.config is None:
|
34
|
+
self.config = ScreenConfig(line_height=self.line_height)
|
35
|
+
|
36
|
+
self.config.video_width = self.video_size[0]
|
37
|
+
self.config.video_height = self.video_size[1]
|
38
|
+
|
39
|
+
def _calculate_duration(self):
|
40
|
+
"""Calculate duration before any timing adjustments."""
|
41
|
+
self.original_duration = round(self.end_time - self.start_time)
|
42
|
+
|
43
|
+
def _adjust_timing(self):
|
44
|
+
"""Apply timing adjustments based on section type."""
|
45
|
+
if self.section_type == "INTRO":
|
46
|
+
self.start_time = 1.0 # Start after 1 second
|
47
|
+
self.end_time = self.end_time - 5.0 # End 5 seconds before next section
|
48
|
+
|
49
|
+
def _create_text(self):
|
50
|
+
"""Create the section text with duration."""
|
51
|
+
self.text = f"♪ {self.section_type} ({self.original_duration} seconds) ♪"
|
52
|
+
|
53
|
+
def _calculate_start_time(self, previous_active_lines: Optional[List[Tuple[float, int, str]]] = None) -> float:
|
54
|
+
"""Calculate start time accounting for previous lines."""
|
55
|
+
start_time = self.start_time
|
56
|
+
if previous_active_lines:
|
57
|
+
latest_end = max(end + (self.config.fade_out_ms / 1000) for end, _, _ in previous_active_lines)
|
58
|
+
start_time = max(start_time, latest_end)
|
59
|
+
return start_time
|
60
|
+
|
61
|
+
def _calculate_vertical_position(self) -> int:
|
62
|
+
"""Calculate vertical position for centered text."""
|
63
|
+
return (self.video_size[1] - self.line_height) // 2
|
64
|
+
|
65
|
+
def _create_event(self, style: Style, start_time: float) -> Event:
|
66
|
+
"""Create an ASS event with proper formatting."""
|
67
|
+
event = Event()
|
68
|
+
event.type = "Dialogue"
|
69
|
+
event.Layer = 0
|
70
|
+
event.Style = style
|
71
|
+
event.Start = start_time
|
72
|
+
event.End = self.end_time
|
73
|
+
event.MarginV = self._calculate_vertical_position()
|
74
|
+
|
75
|
+
# Add karaoke timing for the entire duration
|
76
|
+
duration = int((self.end_time - start_time) * 100) # Convert to centiseconds
|
77
|
+
event.Text = f"{{\\fad({self.config.fade_in_ms},{self.config.fade_out_ms})}}" f"{{\\an8}}{{\\K{duration}}}{self.text}"
|
78
|
+
return event
|
79
|
+
|
80
|
+
def as_ass_events(
|
81
|
+
self,
|
82
|
+
style: Style,
|
83
|
+
previous_active_lines: Optional[List[Tuple[float, int, str]]] = None,
|
84
|
+
previous_instrumental_end: Optional[float] = None,
|
85
|
+
) -> Tuple[List[Event], List[Tuple[float, int, str]]]:
|
86
|
+
"""Create ASS events for section markers with karaoke highlighting."""
|
87
|
+
self.logger.debug(f"Creating section marker event for {self.section_type}")
|
88
|
+
|
89
|
+
start_time = self._calculate_start_time(previous_active_lines)
|
90
|
+
event = self._create_event(style, start_time)
|
91
|
+
|
92
|
+
self.logger.debug(f"Created section event: {event.Text} ({event.Start}s - {event.End}s)")
|
93
|
+
return [event], [] # No active lines to track for sections
|
94
|
+
|
95
|
+
@property
|
96
|
+
def start_ts(self) -> timedelta:
|
97
|
+
"""Get start timestamp."""
|
98
|
+
return timedelta(seconds=self.start_time)
|
99
|
+
|
100
|
+
@property
|
101
|
+
def end_ts(self) -> timedelta:
|
102
|
+
"""Get end timestamp."""
|
103
|
+
return timedelta(seconds=self.end_time)
|
104
|
+
|
105
|
+
def __str__(self):
|
106
|
+
return f"{self.section_type} {self.start_ts} - {self.end_ts}"
|
@@ -0,0 +1,187 @@
|
|
1
|
+
from lyrics_transcriber.output.ass.formatters import Formatters
|
2
|
+
from lyrics_transcriber.output.ass.constants import ALIGN_BOTTOM_CENTER
|
3
|
+
|
4
|
+
|
5
|
+
class Style:
|
6
|
+
aliases = {}
|
7
|
+
formatters = None
|
8
|
+
order = [
|
9
|
+
"Name",
|
10
|
+
"Fontname",
|
11
|
+
"Fontpath",
|
12
|
+
"Fontsize",
|
13
|
+
"PrimaryColour",
|
14
|
+
"SecondaryColour",
|
15
|
+
"OutlineColour",
|
16
|
+
"BackColour",
|
17
|
+
"Bold",
|
18
|
+
"Italic",
|
19
|
+
"Underline",
|
20
|
+
"StrikeOut",
|
21
|
+
"ScaleX",
|
22
|
+
"ScaleY",
|
23
|
+
"Spacing",
|
24
|
+
"Angle",
|
25
|
+
"BorderStyle",
|
26
|
+
"Outline",
|
27
|
+
"Shadow",
|
28
|
+
"Alignment",
|
29
|
+
"MarginL",
|
30
|
+
"MarginR",
|
31
|
+
"MarginV",
|
32
|
+
"Encoding",
|
33
|
+
]
|
34
|
+
|
35
|
+
# Constructor
|
36
|
+
def __init__(self):
|
37
|
+
self.type = None
|
38
|
+
self.fake = False
|
39
|
+
|
40
|
+
self.Name = ""
|
41
|
+
self.Fontname = ""
|
42
|
+
self.Fontpath = ""
|
43
|
+
self.Fontsize = 1.0
|
44
|
+
self.PrimaryColour = (255, 255, 255, 255)
|
45
|
+
self.SecondaryColour = (255, 255, 255, 255)
|
46
|
+
self.OutlineColour = (255, 255, 255, 255)
|
47
|
+
self.BackColour = (255, 255, 255, 255)
|
48
|
+
self.Bold = False
|
49
|
+
self.Italic = False
|
50
|
+
self.Underline = False
|
51
|
+
self.StrikeOut = False
|
52
|
+
self.ScaleX = 100
|
53
|
+
self.ScaleY = 100
|
54
|
+
self.Spacing = 0
|
55
|
+
self.Angle = 0.0
|
56
|
+
self.BorderStyle = 1
|
57
|
+
self.Outline = 0
|
58
|
+
self.Shadow = 0
|
59
|
+
self.Alignment = ALIGN_BOTTOM_CENTER
|
60
|
+
self.MarginL = 0
|
61
|
+
self.MarginR = 0
|
62
|
+
self.MarginV = 0
|
63
|
+
self.Encoding = 0
|
64
|
+
|
65
|
+
def set(self, attribute_name, value, *args):
|
66
|
+
if hasattr(self, attribute_name):
|
67
|
+
if not attribute_name[0].isupper():
|
68
|
+
return
|
69
|
+
elif attribute_name in self.aliases:
|
70
|
+
attribute_name = self.aliases[attribute_name]
|
71
|
+
else:
|
72
|
+
return
|
73
|
+
|
74
|
+
setattr(self, attribute_name, self.formatters[attribute_name][0](value, *args))
|
75
|
+
|
76
|
+
def get(self, attribute_name, *args):
|
77
|
+
if hasattr(self, attribute_name):
|
78
|
+
if not attribute_name[0].isupper():
|
79
|
+
return None
|
80
|
+
elif attribute_name in self.aliases:
|
81
|
+
attribute_name = self.aliases[attribute_name]
|
82
|
+
else:
|
83
|
+
return None
|
84
|
+
|
85
|
+
return self.formatters[attribute_name][1](getattr(self, attribute_name), *args)
|
86
|
+
|
87
|
+
def copy(self, other=None):
|
88
|
+
if other is None:
|
89
|
+
# Creating a new style
|
90
|
+
other = self.__class__()
|
91
|
+
target = other
|
92
|
+
source = self
|
93
|
+
else:
|
94
|
+
# Copying into existing style
|
95
|
+
target = other # This was the issue - we had target and source swapped
|
96
|
+
source = self
|
97
|
+
|
98
|
+
# Copy all attributes
|
99
|
+
target.type = source.type
|
100
|
+
target.fake = source.fake # Also need to copy the fake flag
|
101
|
+
|
102
|
+
target.Name = source.Name
|
103
|
+
target.Fontname = source.Fontname
|
104
|
+
target.Fontpath = source.Fontpath
|
105
|
+
target.Fontsize = source.Fontsize
|
106
|
+
target.PrimaryColour = source.PrimaryColour
|
107
|
+
target.SecondaryColour = source.SecondaryColour
|
108
|
+
target.OutlineColour = source.OutlineColour
|
109
|
+
target.BackColour = source.BackColour
|
110
|
+
target.Bold = source.Bold
|
111
|
+
target.Italic = source.Italic
|
112
|
+
target.Underline = source.Underline
|
113
|
+
target.StrikeOut = source.StrikeOut
|
114
|
+
target.ScaleX = source.ScaleX
|
115
|
+
target.ScaleY = source.ScaleY
|
116
|
+
target.Spacing = source.Spacing
|
117
|
+
target.Angle = source.Angle
|
118
|
+
target.BorderStyle = source.BorderStyle
|
119
|
+
target.Outline = source.Outline
|
120
|
+
target.Shadow = source.Shadow
|
121
|
+
target.Alignment = source.Alignment
|
122
|
+
target.MarginL = source.MarginL
|
123
|
+
target.MarginR = source.MarginR
|
124
|
+
target.MarginV = source.MarginV
|
125
|
+
target.Encoding = source.Encoding
|
126
|
+
|
127
|
+
return target
|
128
|
+
|
129
|
+
def equals(self, other, names_can_differ=False):
|
130
|
+
return (
|
131
|
+
self.type == other.type
|
132
|
+
and not self.fake
|
133
|
+
and not other.fake
|
134
|
+
and not other.fake
|
135
|
+
and (names_can_differ or self.Name == other.Name)
|
136
|
+
and self.Fontname == other.Fontname
|
137
|
+
and self.Fontpath == other.Fontpath
|
138
|
+
and self.Fontsize == other.Fontsize
|
139
|
+
and self.PrimaryColour == other.PrimaryColour
|
140
|
+
and self.SecondaryColour == other.SecondaryColour
|
141
|
+
and self.OutlineColour == other.OutlineColour
|
142
|
+
and self.BackColour == other.BackColour
|
143
|
+
and self.Bold == other.Bold
|
144
|
+
and self.Italic == other.Italic
|
145
|
+
and self.Underline == other.Underline
|
146
|
+
and self.StrikeOut == other.StrikeOut
|
147
|
+
and self.ScaleX == other.ScaleX
|
148
|
+
and self.ScaleY == other.ScaleY
|
149
|
+
and self.Spacing == other.Spacing
|
150
|
+
and self.Angle == other.Angle
|
151
|
+
and self.BorderStyle == other.BorderStyle
|
152
|
+
and self.Outline == other.Outline
|
153
|
+
and self.Shadow == other.Shadow
|
154
|
+
and self.Alignment == other.Alignment
|
155
|
+
and self.MarginL == other.MarginL
|
156
|
+
and self.MarginR == other.MarginR
|
157
|
+
and self.MarginV == other.MarginV
|
158
|
+
and self.Encoding == other.Encoding
|
159
|
+
)
|
160
|
+
|
161
|
+
|
162
|
+
Style.formatters = {
|
163
|
+
"Name": (Formatters.same, Formatters.same),
|
164
|
+
"Fontname": (Formatters.same, Formatters.same),
|
165
|
+
"Fontpath": (Formatters.same, Formatters.same),
|
166
|
+
"Fontsize": (Formatters.str_to_number, Formatters.number_to_str),
|
167
|
+
"PrimaryColour": (Formatters.str_to_color, Formatters.color_to_str),
|
168
|
+
"SecondaryColour": (Formatters.str_to_color, Formatters.color_to_str),
|
169
|
+
"OutlineColour": (Formatters.str_to_color, Formatters.color_to_str),
|
170
|
+
"BackColour": (Formatters.str_to_color, Formatters.color_to_str),
|
171
|
+
"Bold": (Formatters.str_to_n1bool, Formatters.n1bool_to_str),
|
172
|
+
"Italic": (Formatters.str_to_n1bool, Formatters.n1bool_to_str),
|
173
|
+
"Underline": (Formatters.str_to_n1bool, Formatters.n1bool_to_str),
|
174
|
+
"StrikeOut": (Formatters.str_to_n1bool, Formatters.n1bool_to_str),
|
175
|
+
"ScaleX": (Formatters.str_to_integer, Formatters.integer_to_str),
|
176
|
+
"ScaleY": (Formatters.str_to_integer, Formatters.integer_to_str),
|
177
|
+
"Spacing": (Formatters.str_to_integer, Formatters.integer_to_str),
|
178
|
+
"Angle": (Formatters.str_to_number, Formatters.number_to_str),
|
179
|
+
"BorderStyle": (Formatters.str_to_integer, Formatters.integer_to_str),
|
180
|
+
"Outline": (Formatters.str_to_integer, Formatters.integer_to_str),
|
181
|
+
"Shadow": (Formatters.str_to_integer, Formatters.integer_to_str),
|
182
|
+
"Alignment": (Formatters.str_to_integer, Formatters.integer_to_str),
|
183
|
+
"MarginL": (Formatters.str_to_integer, Formatters.integer_to_str),
|
184
|
+
"MarginR": (Formatters.str_to_integer, Formatters.integer_to_str),
|
185
|
+
"MarginV": (Formatters.str_to_integer, Formatters.integer_to_str),
|
186
|
+
"Encoding": (Formatters.str_to_integer, Formatters.integer_to_str),
|
187
|
+
}
|