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.
Files changed (88) hide show
  1. lyrics_transcriber/__init__.py +2 -1
  2. lyrics_transcriber/cli/{main.py → cli_main.py} +47 -14
  3. lyrics_transcriber/core/config.py +35 -0
  4. lyrics_transcriber/core/controller.py +164 -166
  5. lyrics_transcriber/correction/anchor_sequence.py +471 -0
  6. lyrics_transcriber/correction/corrector.py +256 -0
  7. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  8. lyrics_transcriber/correction/handlers/base.py +30 -0
  9. lyrics_transcriber/correction/handlers/extend_anchor.py +91 -0
  10. lyrics_transcriber/correction/handlers/levenshtein.py +147 -0
  11. lyrics_transcriber/correction/handlers/no_space_punct_match.py +98 -0
  12. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +55 -0
  13. lyrics_transcriber/correction/handlers/repeat.py +71 -0
  14. lyrics_transcriber/correction/handlers/sound_alike.py +223 -0
  15. lyrics_transcriber/correction/handlers/syllables_match.py +182 -0
  16. lyrics_transcriber/correction/handlers/word_count_match.py +54 -0
  17. lyrics_transcriber/correction/handlers/word_operations.py +135 -0
  18. lyrics_transcriber/correction/phrase_analyzer.py +426 -0
  19. lyrics_transcriber/correction/text_utils.py +30 -0
  20. lyrics_transcriber/lyrics/base_lyrics_provider.py +125 -0
  21. lyrics_transcriber/lyrics/genius.py +73 -0
  22. lyrics_transcriber/lyrics/spotify.py +82 -0
  23. lyrics_transcriber/output/ass/__init__.py +21 -0
  24. lyrics_transcriber/output/{ass.py → ass/ass.py} +150 -690
  25. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  26. lyrics_transcriber/output/ass/config.py +37 -0
  27. lyrics_transcriber/output/ass/constants.py +23 -0
  28. lyrics_transcriber/output/ass/event.py +94 -0
  29. lyrics_transcriber/output/ass/formatters.py +132 -0
  30. lyrics_transcriber/output/ass/lyrics_line.py +219 -0
  31. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  32. lyrics_transcriber/output/ass/section_detector.py +89 -0
  33. lyrics_transcriber/output/ass/section_screen.py +106 -0
  34. lyrics_transcriber/output/ass/style.py +187 -0
  35. lyrics_transcriber/output/cdg.py +503 -0
  36. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  37. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  38. lyrics_transcriber/output/cdgmaker/composer.py +1919 -0
  39. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  40. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  41. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  42. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  43. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  44. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  45. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  46. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  47. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  48. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  49. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  50. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  51. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  52. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  53. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  54. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  55. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  56. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  57. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  58. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  59. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  60. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  61. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  62. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  63. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  64. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  65. lyrics_transcriber/output/generator.py +140 -171
  66. lyrics_transcriber/output/lyrics_file.py +102 -0
  67. lyrics_transcriber/output/plain_text.py +91 -0
  68. lyrics_transcriber/output/segment_resizer.py +416 -0
  69. lyrics_transcriber/output/subtitles.py +328 -302
  70. lyrics_transcriber/output/video.py +219 -0
  71. lyrics_transcriber/review/__init__.py +1 -0
  72. lyrics_transcriber/review/server.py +138 -0
  73. lyrics_transcriber/storage/dropbox.py +110 -134
  74. lyrics_transcriber/transcribers/audioshake.py +171 -105
  75. lyrics_transcriber/transcribers/base_transcriber.py +149 -0
  76. lyrics_transcriber/transcribers/whisper.py +267 -133
  77. lyrics_transcriber/types.py +454 -0
  78. {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/METADATA +14 -3
  79. lyrics_transcriber-0.32.1.dist-info/RECORD +86 -0
  80. {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/WHEEL +1 -1
  81. lyrics_transcriber-0.32.1.dist-info/entry_points.txt +4 -0
  82. lyrics_transcriber/core/corrector.py +0 -56
  83. lyrics_transcriber/core/fetcher.py +0 -143
  84. lyrics_transcriber/storage/tokens.py +0 -116
  85. lyrics_transcriber/transcribers/base.py +0 -31
  86. lyrics_transcriber-0.30.0.dist-info/RECORD +0 -22
  87. lyrics_transcriber-0.30.0.dist-info/entry_points.txt +0 -3
  88. {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ class ScreenConfig:
5
+ """Configuration for screen timing and layout."""
6
+
7
+ def __init__(self, line_height: int = 50, max_visible_lines: int = 4, top_padding: int = None, video_width: int = 640, video_height: int = 360):
8
+ # Screen layout
9
+ self.max_visible_lines = max_visible_lines
10
+ self.line_height = line_height
11
+ self.top_padding = top_padding if top_padding is not None else line_height
12
+ self.video_height = video_height
13
+ self.video_width = video_width
14
+ # Timing configuration
15
+ self.screen_gap_threshold = 5.0
16
+ self.post_roll_time = 1.0
17
+ self.fade_in_ms = 200
18
+ self.fade_out_ms = 300
19
+
20
+
21
+ @dataclass
22
+ class LineTimingInfo:
23
+ """Timing information for a single line."""
24
+
25
+ fade_in_time: float
26
+ end_time: float
27
+ fade_out_time: float
28
+ clear_time: float
29
+
30
+
31
+ @dataclass
32
+ class LineState:
33
+ """Complete state for a single line."""
34
+
35
+ text: str
36
+ timing: LineTimingInfo
37
+ y_position: int
@@ -0,0 +1,23 @@
1
+ # Alignment constants
2
+ ALIGN_BOTTOM_LEFT = 1
3
+ ALIGN_BOTTOM_CENTER = 2
4
+ ALIGN_BOTTOM_RIGHT = 3
5
+ ALIGN_MIDDLE_LEFT = 4
6
+ ALIGN_MIDDLE_CENTER = 5
7
+ ALIGN_MIDDLE_RIGHT = 6
8
+ ALIGN_TOP_LEFT = 7
9
+ ALIGN_TOP_CENTER = 8
10
+ ALIGN_TOP_RIGHT = 9
11
+
12
+ # Legacy alignment mapping
13
+ LEGACY_ALIGNMENT_TO_REGULAR = {
14
+ "1": ALIGN_BOTTOM_LEFT,
15
+ "2": ALIGN_BOTTOM_CENTER,
16
+ "3": ALIGN_BOTTOM_RIGHT,
17
+ "5": ALIGN_TOP_LEFT,
18
+ "6": ALIGN_TOP_CENTER,
19
+ "7": ALIGN_TOP_RIGHT,
20
+ "9": ALIGN_MIDDLE_LEFT,
21
+ "10": ALIGN_MIDDLE_CENTER,
22
+ "11": ALIGN_MIDDLE_RIGHT,
23
+ }
@@ -0,0 +1,94 @@
1
+ class Event:
2
+ aliases = {}
3
+ formatters = None
4
+ order = [
5
+ "Layer",
6
+ "Start",
7
+ "End",
8
+ "Style",
9
+ "Name",
10
+ "MarginL",
11
+ "MarginR",
12
+ "MarginV",
13
+ "Effect",
14
+ "Text",
15
+ ]
16
+
17
+ # Constructor
18
+ def __init__(self):
19
+ self.type = None
20
+
21
+ self.Layer = 0
22
+ self.Start = 0.0
23
+ self.End = 0.0
24
+ self.Style = None
25
+ self.Name = ""
26
+ self.MarginL = 0
27
+ self.MarginR = 0
28
+ self.MarginV = 0
29
+ self.Effect = ""
30
+ self.Text = ""
31
+
32
+ def set(self, attribute_name, value, *args):
33
+ if hasattr(self, attribute_name) and attribute_name[0].isupper():
34
+ setattr(
35
+ self,
36
+ attribute_name,
37
+ self.formatters[attribute_name][0](value, *args),
38
+ )
39
+
40
+ def get(self, attribute_name, *args):
41
+ if hasattr(self, attribute_name) and attribute_name[0].isupper():
42
+ return self.formatters[attribute_name][1](getattr(self, attribute_name), *args)
43
+ return None
44
+
45
+ def copy(self, other=None):
46
+ if other is None:
47
+ other = self.__class__()
48
+ target = other
49
+ source = self
50
+ else:
51
+ target = other
52
+ source = self
53
+
54
+ # Copy all attributes
55
+ target.type = source.type
56
+ target.Layer = source.Layer
57
+ target.Start = source.Start
58
+ target.End = source.End
59
+ target.Style = source.Style
60
+ target.Name = source.Name
61
+ target.MarginL = source.MarginL
62
+ target.MarginR = source.MarginR
63
+ target.MarginV = source.MarginV
64
+ target.Effect = source.Effect
65
+ target.Text = source.Text
66
+
67
+ return target
68
+
69
+ def equals(self, other):
70
+ return (
71
+ self.type == other.type
72
+ and self.Layer == other.Layer
73
+ and self.Start == other.Start
74
+ and self.End == other.End
75
+ and self.Style is other.Style
76
+ and self.Name == other.Name
77
+ and self.MarginL == other.MarginL
78
+ and self.MarginR == other.MarginR
79
+ and self.MarginV == other.MarginV
80
+ and self.Effect == other.Effect
81
+ and self.Text == other.Text
82
+ )
83
+
84
+ def same_style(self, other):
85
+ return (
86
+ self.type == other.type
87
+ and self.Layer == other.Layer
88
+ and self.Style is other.Style
89
+ and self.Name == other.Name
90
+ and self.MarginL == other.MarginL
91
+ and self.MarginR == other.MarginR
92
+ and self.MarginV == other.MarginV
93
+ and self.Effect == other.Effect
94
+ )
@@ -0,0 +1,132 @@
1
+ import re
2
+
3
+
4
+ class Formatters:
5
+ __re_color_format = re.compile(r"&H([0-9a-fA-F]{8}|[0-9a-fA-F]{6})", re.U)
6
+ __re_tag_number = re.compile(r"^\s*([\+\-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))", re.U)
7
+
8
+ @classmethod
9
+ def same(cls, val, *args):
10
+ return val
11
+
12
+ @classmethod
13
+ def color_to_str(cls, val, *args):
14
+ return "&H{0:02X}{1:02X}{2:02X}{3:02X}".format(255 - val[3], val[2], val[1], val[0])
15
+
16
+ @classmethod
17
+ def str_to_color(cls, val, *args):
18
+ match = cls.__re_color_format.search(val)
19
+ if match:
20
+ hex_val = "{0:>08s}".format(match.group(1))
21
+ return (
22
+ int(hex_val[6:8], 16), # Red
23
+ int(hex_val[4:6], 16), # Green
24
+ int(hex_val[2:4], 16), # Blue
25
+ 255 - int(hex_val[0:2], 16), # Alpha
26
+ )
27
+ # Return white (255, 255, 255, 255) for invalid input
28
+ return (255, 255, 255, 255)
29
+
30
+ @classmethod
31
+ def n1bool_to_str(cls, val, *args):
32
+ if val:
33
+ return "-1"
34
+ return "0"
35
+
36
+ @classmethod
37
+ def str_to_n1bool(cls, val, *args):
38
+ try:
39
+ val = int(val, 10)
40
+ except ValueError:
41
+ return False
42
+ return val != 0
43
+
44
+ @classmethod
45
+ def integer_to_str(cls, val, *args):
46
+ return str(int(val))
47
+
48
+ @classmethod
49
+ def str_to_integer(cls, val, *args):
50
+ try:
51
+ return int(val, 10)
52
+ except ValueError:
53
+ return 0
54
+
55
+ @classmethod
56
+ def number_to_str(cls, val, *args):
57
+ if int(val) == val:
58
+ return str(int(val))
59
+ # No decimal
60
+ return str(val)
61
+
62
+ @classmethod
63
+ def str_to_number(cls, val, *args):
64
+ try:
65
+ return float(val)
66
+ except ValueError:
67
+ return 0.0
68
+
69
+ @classmethod
70
+ def timecode_to_str_generic(
71
+ cls,
72
+ timecode,
73
+ decimal_length=2,
74
+ seconds_length=2,
75
+ minutes_length=2,
76
+ hours_length=1,
77
+ ):
78
+ if decimal_length > 0:
79
+ total_length = seconds_length + decimal_length + 1
80
+ else:
81
+ total_length = seconds_length
82
+
83
+ tc_parts = [
84
+ "{{0:0{0:d}d}}".format(hours_length).format(int(timecode // 3600)),
85
+ "{{0:0{0:d}d}}".format(minutes_length).format(int((timecode // 60) % 60)),
86
+ "{{0:0{0:d}.{1:d}f}}".format(total_length, decimal_length).format(timecode % 60),
87
+ ]
88
+ return ":".join(tc_parts)
89
+
90
+ @classmethod
91
+ def timecode_to_str(cls, val, *args):
92
+ return cls.timecode_to_str_generic(val, 2)
93
+
94
+ @classmethod
95
+ def str_to_timecode(cls, val, *args):
96
+ time = 0.0
97
+ mult = 1
98
+
99
+ for t in reversed(val.split(":")):
100
+ time += float(t) * mult
101
+ mult *= 60
102
+
103
+ return time
104
+
105
+ @classmethod
106
+ def style_to_str(cls, val, *args):
107
+ if val is None:
108
+ return ""
109
+ return val.Name
110
+
111
+ @classmethod
112
+ def str_to_style(cls, val, style_map, style_constructor, *args):
113
+ if val in style_map:
114
+ return style_map[val]
115
+
116
+ # Create fake
117
+ style = style_constructor()
118
+ style.fake = True
119
+ style.Name = val
120
+
121
+ # Add to map (will not be included in global style list, but allows for duplicate "fake" styles to reference the same object)
122
+ style_map[style.Name] = style
123
+
124
+ # Return the new style
125
+ return style
126
+
127
+ @classmethod
128
+ def tag_argument_to_number(cls, arg, default_value=None):
129
+ match = cls.__re_tag_number.match(arg)
130
+ if match is None:
131
+ return default_value
132
+ return float(match.group(1))
@@ -0,0 +1,219 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, Tuple, List
3
+ import logging
4
+ from datetime import timedelta
5
+ from PIL import Image, ImageDraw, ImageFont
6
+ import os
7
+
8
+ from lyrics_transcriber.types import LyricsSegment
9
+ from lyrics_transcriber.output.ass.event import Event
10
+ from lyrics_transcriber.output.ass.style import Style
11
+ from lyrics_transcriber.output.ass.config import LineState, ScreenConfig
12
+
13
+
14
+ @dataclass
15
+ class LyricsLine:
16
+ """Represents a single line of lyrics with timing and karaoke information."""
17
+
18
+ segment: LyricsSegment
19
+ screen_config: ScreenConfig
20
+ logger: Optional[logging.Logger] = None
21
+ previous_end_time: Optional[float] = None
22
+
23
+ def __post_init__(self):
24
+ """Ensure logger is initialized"""
25
+ if self.logger is None:
26
+ self.logger = logging.getLogger(__name__)
27
+
28
+ def _get_font(self, style: Style) -> ImageFont.FreeTypeFont:
29
+ """Get the font for text measurements."""
30
+ # ASS renders fonts about 70% of their actual size
31
+ ASS_FONT_SCALE = 0.70
32
+
33
+ # Scale down the font size to match ASS rendering
34
+ adjusted_size = int(style.Fontsize * ASS_FONT_SCALE)
35
+ self.logger.debug(f"Adjusting font size from {style.Fontsize} to {adjusted_size} to match ASS rendering")
36
+
37
+ try:
38
+ # Use the Fontpath property from Style class
39
+ if style.Fontpath and os.path.exists(style.Fontpath):
40
+ return ImageFont.truetype(style.Fontpath, size=adjusted_size)
41
+ self.logger.warning(f"Could not load font {style.Fontpath}, using default")
42
+ return ImageFont.load_default()
43
+ except (OSError, AttributeError) as e:
44
+ self.logger.warning(f"Font error ({e}), using default")
45
+ return ImageFont.load_default()
46
+
47
+ def _get_text_dimensions(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
48
+ """Get the pixel dimensions of rendered text."""
49
+ # Create an image the same size as the video frame
50
+ img = Image.new("RGB", (self.screen_config.video_width, self.screen_config.video_height), color="black")
51
+ draw = ImageDraw.Draw(img)
52
+
53
+ # Get the bounding box
54
+ bbox = draw.textbbox((0, 0), text, font=font)
55
+ width = bbox[2] - bbox[0]
56
+ height = bbox[3] - bbox[1]
57
+
58
+ self.logger.debug(f"Text dimensions for '{text}': width={width}px, height={height}px")
59
+ self.logger.debug(f"Video dimensions: {self.screen_config.video_width}x{self.screen_config.video_height}")
60
+ return width, height
61
+
62
+ # fmt: off
63
+ def _create_lead_in_text(self, state: LineState) -> Tuple[str, bool]:
64
+ """Create lead-in indicator text if needed.
65
+
66
+ Returns:
67
+ Tuple of (text, has_lead_in)
68
+ """
69
+ has_lead_in = (self.previous_end_time is None or
70
+ self.segment.start_time - self.previous_end_time >= 5.0)
71
+
72
+ if not has_lead_in:
73
+ return "", False
74
+
75
+ # Add a hyphen with karaoke timing for the last 2 seconds before the line
76
+ lead_in_start = max(state.timing.fade_in_time, self.segment.start_time - 2.0)
77
+ gap_before_highlight = int((lead_in_start - state.timing.fade_in_time) * 100)
78
+ highlight_duration = int((self.segment.start_time - lead_in_start) * 100)
79
+
80
+ text = ""
81
+ # Add initial gap if needed
82
+ if gap_before_highlight > 0:
83
+ text += f"{{\\k{gap_before_highlight}}}"
84
+ # Add the hyphen with highlight
85
+ text += f"{{\\kf{highlight_duration}}}→ "
86
+
87
+ return text, True
88
+
89
+ def _create_lead_in_event(self, state: LineState, style: Style, video_width: int, config: ScreenConfig) -> Optional[Event]:
90
+ """Create a separate event for the lead-in indicator if needed."""
91
+ if not (self.previous_end_time is None or
92
+ self.segment.start_time - self.previous_end_time >= 5.0):
93
+ return None
94
+
95
+ self.logger.debug(f"Creating lead-in indicator for line: '{self.segment.text}'")
96
+
97
+ # Calculate all timing points
98
+ line_start = self.segment.start_time
99
+ appear_time = line_start - 3.0 # Start 3 seconds before line
100
+ fade_in_end = appear_time + 0.8 # 800ms fade in
101
+ fade_out_start = line_start - 0.3 # Start fade 300ms before reaching final position
102
+ fade_out_end = line_start + 0.2 # Complete fade 200ms after line starts (500ms total fade)
103
+
104
+ self.logger.debug(f"Timing calculations:")
105
+ self.logger.debug(f" Line starts at: {line_start:.2f}s")
106
+ self.logger.debug(f" Rectangle appears at: {appear_time:.2f}s")
107
+ self.logger.debug(f" Fade in completes at: {fade_in_end:.2f}s")
108
+ self.logger.debug(f" Fade out starts at: {fade_out_start:.2f}s")
109
+ self.logger.debug(f" Rectangle reaches final position at: {line_start:.2f}s")
110
+ self.logger.debug(f" Rectangle fully faded out at: {fade_out_end:.2f}s")
111
+
112
+ # Calculate dimensions and positions
113
+ font = self._get_font(style)
114
+ main_text = self.segment.text
115
+ main_width, main_height = self._get_text_dimensions(main_text, font)
116
+ rect_width = int(self.screen_config.video_width * 0.035) # 3.5% of video width
117
+ rect_height = int(self.screen_config.video_height * 0.04) # 4% of video height
118
+ text_left = self.screen_config.video_width//2 - main_width//2
119
+
120
+ self.logger.debug(f"Position calculations:")
121
+ self.logger.debug(f" Video dimensions: {self.screen_config.video_width}x{self.screen_config.video_height}")
122
+ self.logger.debug(f" Main text width: {main_width}px")
123
+ self.logger.debug(f" Main text height: {main_height}px")
124
+ self.logger.debug(f" Rectangle dimensions: {rect_width}x{rect_height}px")
125
+ self.logger.debug(f" Text left edge: {text_left}px")
126
+ self.logger.debug(f" Vertical position: {state.y_position}px")
127
+
128
+ # Create main indicator event
129
+ main_event = Event()
130
+ main_event.type = "Dialogue"
131
+ main_event.Layer = 0
132
+ main_event.Style = style
133
+ main_event.Start = appear_time
134
+ main_event.End = fade_out_end
135
+
136
+ # Calculate movement duration in milliseconds
137
+ move_duration = int((line_start - appear_time) * 1000)
138
+
139
+ # Create indicator rectangle aligned to bottom
140
+ main_text = (
141
+ f"{{\\an8}}" # center-bottom alignment
142
+ f"{{\\move(0,{state.y_position + main_height},{text_left},{state.y_position + main_height},0,{move_duration})}}" # Move until line start
143
+ f"{{\\c&HF77070&}}" # Same color as karaoke highlight
144
+ f"{{\\alpha&H4D&}}" # 70% opacity (FF=0%, 00=100%)
145
+ f"{{\\fad(800,500)}}" # 800ms fade in, 500ms fade out
146
+ f"{{\\p1}}m {-rect_width} {-rect_height} l 0 {-rect_height} 0 0 {-rect_width} 0{{\\p0}}" # Draw up from bottom
147
+ )
148
+ main_event.Text = main_text
149
+
150
+ return [main_event]
151
+
152
+ def create_ass_events(
153
+ self,
154
+ state: LineState,
155
+ style: Style,
156
+ config: ScreenConfig,
157
+ previous_end_time: Optional[float] = None
158
+ ) -> List[Event]:
159
+ """Create ASS events for this line. Returns [main_event] or [lead_in_event, main_event]."""
160
+ self.previous_end_time = previous_end_time
161
+ events = []
162
+
163
+ # Create lead-in event if needed
164
+ lead_in_event = self._create_lead_in_event(state, style, config.video_width, config)
165
+ if lead_in_event:
166
+ events.extend(lead_in_event)
167
+
168
+ # Create main lyrics event
169
+ main_event = Event()
170
+ main_event.type = "Dialogue"
171
+ main_event.Layer = 0
172
+ main_event.Style = style
173
+ main_event.Start = state.timing.fade_in_time
174
+ main_event.End = state.timing.end_time
175
+
176
+ # Use absolute positioning
177
+ x_pos = config.video_width // 2 # Center horizontally
178
+
179
+ # Main lyrics text with positioning and fade
180
+ text = (
181
+ f"{{\\an8}}{{\\pos({x_pos},{state.y_position})}}"
182
+ f"{{\\fad({config.fade_in_ms},{config.fade_out_ms})}}"
183
+ )
184
+
185
+ # Add the main lyrics text with karaoke timing
186
+ text += self._create_ass_text(timedelta(seconds=state.timing.fade_in_time))
187
+
188
+ main_event.Text = text
189
+ events.append(main_event)
190
+
191
+ return events
192
+
193
+ def _create_ass_text(self, start_ts: timedelta) -> str:
194
+ """Create the ASS text with karaoke timing tags."""
195
+ # Initial delay before first word
196
+ first_word_time = self.segment.start_time
197
+
198
+ # Add initial delay for regular lines
199
+ start_time = max(0, (first_word_time - start_ts.total_seconds()) * 100)
200
+ text = r"{\k" + str(int(round(start_time))) + r"}"
201
+
202
+ prev_end_time = first_word_time
203
+
204
+ for word in self.segment.words:
205
+ # Add gap between words if needed
206
+ gap = word.start_time - prev_end_time
207
+ if gap > 0.1: # Only add gap if significant
208
+ text += r"{\k" + str(int(round(gap * 100))) + r"}"
209
+
210
+ # Add the word with its duration
211
+ duration = int(round((word.end_time - word.start_time) * 100))
212
+ text += r"{\kf" + str(duration) + r"}" + word.text + " "
213
+
214
+ prev_end_time = word.end_time # Track the actual end time of the word
215
+
216
+ return text.rstrip()
217
+
218
+ def __str__(self):
219
+ return f"{{{self.segment.text}}}"