lyrics-transcriber 0.30.1__py3-none-any.whl → 0.32.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. lyrics_transcriber/__init__.py +2 -1
  2. lyrics_transcriber/cli/cli_main.py +33 -12
  3. lyrics_transcriber/core/config.py +35 -0
  4. lyrics_transcriber/core/controller.py +85 -121
  5. lyrics_transcriber/correction/anchor_sequence.py +471 -0
  6. lyrics_transcriber/correction/corrector.py +237 -33
  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 +5 -81
  21. lyrics_transcriber/lyrics/genius.py +5 -2
  22. lyrics_transcriber/lyrics/spotify.py +3 -3
  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 +101 -193
  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/transcribers/audioshake.py +3 -2
  74. lyrics_transcriber/transcribers/base_transcriber.py +5 -42
  75. lyrics_transcriber/transcribers/whisper.py +3 -4
  76. lyrics_transcriber/types.py +454 -0
  77. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/METADATA +14 -3
  78. lyrics_transcriber-0.32.2.dist-info/RECORD +86 -0
  79. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/WHEEL +1 -1
  80. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/entry_points.txt +1 -0
  81. lyrics_transcriber/correction/base_strategy.py +0 -29
  82. lyrics_transcriber/correction/strategy_diff.py +0 -263
  83. lyrics_transcriber-0.30.1.dist-info/RECORD +0 -25
  84. {lyrics_transcriber-0.30.1.dist-info → lyrics_transcriber-0.32.2.dist-info}/LICENSE +0 -0
@@ -0,0 +1,416 @@
1
+ import logging
2
+ import re
3
+ from typing import List, Optional, Tuple
4
+
5
+ from lyrics_transcriber.types import LyricsSegment, Word
6
+
7
+
8
+ class SegmentResizer:
9
+ """Handles resizing of lyrics segments to ensure proper line lengths and natural breaks.
10
+
11
+ This class processes lyrics segments and splits them into smaller segments when they exceed
12
+ a maximum line length. It attempts to split at natural break points like sentence endings,
13
+ commas, or conjunctions to maintain readability.
14
+
15
+ Example:
16
+ resizer = SegmentResizer(max_line_length=36)
17
+ segments = [
18
+ LyricsSegment(
19
+ text="This is a very long sentence that needs to be split into multiple lines for better readability",
20
+ words=[...], # List of Word objects with timing information
21
+ start_time=0.0,
22
+ end_time=5.0
23
+ )
24
+ ]
25
+ resized = resizer.resize_segments(segments)
26
+ # Results in:
27
+ # [
28
+ # LyricsSegment(text="This is a very long sentence", ...),
29
+ # LyricsSegment(text="that needs to be split", ...),
30
+ # LyricsSegment(text="into multiple lines", ...),
31
+ # LyricsSegment(text="for better readability", ...)
32
+ # ]
33
+ """
34
+
35
+ def __init__(self, max_line_length: int = 36, logger: Optional[logging.Logger] = None):
36
+ """Initialize the SegmentResizer.
37
+
38
+ Args:
39
+ max_line_length: Maximum allowed length for a single line of text
40
+ logger: Optional logger for debugging information
41
+ """
42
+ self.max_line_length = max_line_length
43
+ self.logger = logger or logging.getLogger(__name__)
44
+
45
+ def resize_segments(self, segments: List[LyricsSegment]) -> List[LyricsSegment]:
46
+ """Main entry point for resizing segments.
47
+
48
+ Takes a list of potentially long segments and splits them into smaller ones
49
+ while preserving word timing information.
50
+
51
+ Example:
52
+ Input segment: "Hello world, this is a test. And here's another sentence."
53
+ Output segments: [
54
+ "Hello world, this is a test.",
55
+ "And here's another sentence."
56
+ ]
57
+
58
+ Args:
59
+ segments: List of LyricsSegment objects to process
60
+
61
+ Returns:
62
+ List of resized LyricsSegment objects
63
+ """
64
+ self._log_input_segments(segments)
65
+ resized_segments: List[LyricsSegment] = []
66
+
67
+ for segment_idx, segment in enumerate(segments):
68
+ cleaned_segment = self._create_cleaned_segment(segment)
69
+
70
+ # Only split if the segment is longer than max_line_length
71
+ if len(cleaned_segment.text) <= self.max_line_length:
72
+ resized_segments.append(cleaned_segment)
73
+ continue
74
+
75
+ # Process oversized segments
76
+ resized_segments.extend(self._split_oversized_segment(segment_idx, segment))
77
+
78
+ self._log_output_segments(resized_segments)
79
+ return resized_segments
80
+
81
+ def _clean_text(self, text: str) -> str:
82
+ """Clean text by removing newlines and extra whitespace.
83
+
84
+ Example:
85
+ Input: "Hello\n World \n!"
86
+ Output: "Hello World !"
87
+
88
+ Args:
89
+ text: String to clean
90
+
91
+ Returns:
92
+ Cleaned string with normalized whitespace
93
+ """
94
+ return " ".join(text.replace("\n", " ").split())
95
+
96
+ def _create_cleaned_segment(self, segment: LyricsSegment) -> LyricsSegment:
97
+ """Create a new segment with cleaned text while preserving timing info.
98
+
99
+ Example:
100
+ Input: LyricsSegment(text="Hello\n World\n", words=[...])
101
+ Output: LyricsSegment(text="Hello World", words=[...])
102
+ """
103
+ cleaned_text = self._clean_text(segment.text)
104
+ return LyricsSegment(text=cleaned_text, words=segment.words, start_time=segment.start_time, end_time=segment.end_time)
105
+
106
+ def _create_cleaned_word(self, word: Word) -> Word:
107
+ """Create a new word with cleaned text."""
108
+ cleaned_text = self._clean_text(word.text)
109
+ return Word(
110
+ text=cleaned_text,
111
+ start_time=word.start_time,
112
+ end_time=word.end_time,
113
+ confidence=word.confidence if hasattr(word, "confidence") else None,
114
+ )
115
+
116
+ def _split_oversized_segment(self, segment_idx: int, segment: LyricsSegment) -> List[LyricsSegment]:
117
+ """Split an oversized segment into multiple segments at natural break points.
118
+
119
+ Example:
120
+ Input: "This is a long sentence. Here's another one."
121
+ Output: [
122
+ LyricsSegment(text="This is a long sentence.", ...),
123
+ LyricsSegment(text="Here's another one.", ...)
124
+ ]
125
+ """
126
+ self.logger.info(f"Processing oversized segment {segment_idx}: '{segment.text}'")
127
+ segment_text = self._clean_text(segment.text)
128
+ split_lines = self._process_segment_text(segment_text)
129
+ self.logger.debug(f"Split into {len(split_lines)} lines: {split_lines}")
130
+
131
+ return self._create_segments_from_lines(segment_text, split_lines, segment.words)
132
+
133
+ def _create_segments_from_lines(self, segment_text: str, split_lines: List[str], words: List[Word]) -> List[LyricsSegment]:
134
+ """Create segments from split lines while preserving word timing.
135
+
136
+ Matches words to their corresponding lines based on text position and
137
+ creates new segments with the correct timing information.
138
+
139
+ Example:
140
+ segment_text: "Hello world, how are you"
141
+ split_lines: ["Hello world,", "how are you"]
142
+ words: [Word("Hello", 0.0, 1.0), Word("world", 1.0, 2.0), ...]
143
+
144
+ Returns segments with words properly assigned to each line.
145
+ """
146
+ segments: List[LyricsSegment] = []
147
+ words_to_process = words.copy()
148
+ current_pos = 0
149
+
150
+ for line in split_lines:
151
+ line_words = []
152
+ line_text = line.strip()
153
+ remaining_line = line_text
154
+
155
+ # Keep processing words until we've found all words for this line
156
+ while words_to_process and remaining_line:
157
+ word = words_to_process[0]
158
+ word_clean = self._clean_text(word.text)
159
+
160
+ # Check if the cleaned word appears in the remaining line text
161
+ if word_clean in remaining_line:
162
+ word_pos = remaining_line.find(word_clean)
163
+ if word_pos != -1:
164
+ line_words.append(words_to_process.pop(0))
165
+ # Remove the word and any following spaces from remaining line
166
+ remaining_line = remaining_line[word_pos + len(word_clean):].strip()
167
+ continue
168
+
169
+ # If we can't find the word in the remaining line, we're done with this line
170
+ break
171
+
172
+ if line_words:
173
+ segments.append(self._create_segment_from_words(line, line_words))
174
+ current_pos += len(line) + 1 # +1 for the space between lines
175
+
176
+ # If we have any remaining words, create a final segment with them
177
+ if words_to_process:
178
+ remaining_text = " ".join(self._clean_text(w.text) for w in words_to_process)
179
+ segments.append(self._create_segment_from_words(remaining_text, words_to_process))
180
+
181
+ return segments
182
+
183
+ def _create_line_segment(
184
+ self, line_idx: int, line: str, segment_text: str, available_words: List[Word], current_pos: int
185
+ ) -> Optional[LyricsSegment]:
186
+ """Create a single segment from a line of text."""
187
+ line_pos = segment_text.find(line, current_pos)
188
+ if line_pos == -1:
189
+ self.logger.error(f"Failed to find line '{line}' in segment text '{segment_text}' " f"starting from position {current_pos}")
190
+ return None
191
+
192
+ line_words = self._find_words_for_line(line, line_pos, len(line), segment_text, available_words, current_pos)
193
+
194
+ if line_words:
195
+ return self._create_segment_from_words(line, line_words)
196
+ else:
197
+ self.logger.warning(f"No words found for line '{line}'")
198
+ return None
199
+
200
+ def _find_words_for_line(
201
+ self, line: str, line_pos: int, line_length: int, segment_text: str, available_words: List[Word], current_pos: int
202
+ ) -> List[Word]:
203
+ """Find words that belong to a specific line."""
204
+ line_words = []
205
+ line_text = line.strip()
206
+ remaining_text = line_text
207
+
208
+ for word in available_words:
209
+ # Skip if word isn't in remaining text
210
+ if word.text not in remaining_text:
211
+ continue
212
+
213
+ # Find position of word in line
214
+ word_pos = remaining_text.find(word.text)
215
+ if word_pos != -1:
216
+ line_words.append(word)
217
+ # Remove processed text up to and including this word
218
+ remaining_text = remaining_text[word_pos + len(word.text) :].strip()
219
+
220
+ if not remaining_text: # All words found
221
+ break
222
+
223
+ return line_words
224
+
225
+ def _create_segment_from_words(self, line: str, words: List[Word]) -> LyricsSegment:
226
+ """Create a new segment from a list of words."""
227
+ cleaned_text = self._clean_text(line)
228
+ return LyricsSegment(text=cleaned_text, words=words, start_time=words[0].start_time, end_time=words[-1].end_time)
229
+
230
+ def _process_segment_text(self, text: str) -> List[str]:
231
+ """Process segment text to determine optimal split points."""
232
+ self.logger.debug(f"Processing segment text: '{text}'")
233
+ processed_lines: List[str] = []
234
+ remaining_text = text.strip()
235
+
236
+ while remaining_text:
237
+ self.logger.debug(f"Remaining text to process: '{remaining_text}'")
238
+
239
+ # If remaining text is within limit, add it and we're done
240
+ if len(remaining_text) <= self.max_line_length:
241
+ processed_lines.append(remaining_text)
242
+ break
243
+
244
+ # Find best split point
245
+ split_point = self._find_best_split_point(remaining_text)
246
+ first_part = remaining_text[:split_point].strip()
247
+ second_part = remaining_text[split_point:].strip()
248
+
249
+ # Only split if:
250
+ # 1. We found a valid split point
251
+ # 2. First part isn't too long
252
+ # 3. Both parts are non-empty
253
+ if split_point < len(remaining_text) and len(first_part) <= self.max_line_length and first_part and second_part:
254
+
255
+ processed_lines.append(first_part)
256
+ remaining_text = second_part
257
+ else:
258
+ # If we can't find a good split, keep the whole text
259
+ processed_lines.append(remaining_text)
260
+ break
261
+
262
+ return processed_lines
263
+
264
+ def _find_best_split_point(self, line: str) -> int:
265
+ """Find the best split point that creates natural, well-balanced segments."""
266
+ self.logger.debug(f"Finding best split point for line: '{line}' (length: {len(line)})")
267
+
268
+ # If line is within max length, don't split
269
+ if len(line) <= self.max_line_length:
270
+ return len(line)
271
+
272
+ break_points = self._find_break_points(line)
273
+ best_point = None
274
+ best_score = float("-inf")
275
+
276
+ # Try each break point and score it
277
+ for priority, points in enumerate(break_points):
278
+ for point in sorted(points): # Sort points to prefer earlier ones in same priority
279
+ if point <= 0 or point >= len(line):
280
+ continue
281
+
282
+ first_part = line[:point].strip()
283
+
284
+ # Skip if first part is too long
285
+ if len(first_part) > self.max_line_length:
286
+ continue
287
+
288
+ # Score this break point
289
+ score = self._score_break_point(line, point, priority)
290
+ if score > best_score:
291
+ best_score = score
292
+ best_point = point
293
+
294
+ # If no good break points found, fall back to last space before max_length
295
+ if best_point is None:
296
+ last_space = line.rfind(" ", 0, self.max_line_length)
297
+ if last_space != -1:
298
+ return last_space
299
+
300
+ return best_point if best_point is not None else self.max_line_length
301
+
302
+ def _score_break_point(self, line: str, point: int, priority: int) -> float:
303
+ """Score a potential break point based on multiple factors.
304
+
305
+ Factors considered:
306
+ 1. Priority of the break point type (sentence > clause > comma, etc.)
307
+ 2. Balance of segment lengths
308
+ 3. Proximity to target length
309
+
310
+ Example:
311
+ line: "This is a sentence. And more text."
312
+ point: 18 (after "sentence.")
313
+ priority: 0 (sentence break)
314
+
315
+ Returns a score where higher is better. Score components:
316
+ - Base score (100-20*priority): 100 for priority 0
317
+ - Length ratio bonus (0-10): Based on segment balance
318
+ - Target length bonus (0-5): Based on proximity to ideal length
319
+ """
320
+ first_segment = line[:point].strip()
321
+ second_segment = line[point:].strip()
322
+
323
+ # Base score starts with priority
324
+ score = 100 - (priority * 20) # Priorities 0-4 give scores 100,80,60,40,20
325
+
326
+ # Length ratio bonus
327
+ length_ratio = min(len(first_segment), len(second_segment)) / max(len(first_segment), len(second_segment))
328
+ score += length_ratio * 10
329
+
330
+ # Target length bonus
331
+ target_length = self.max_line_length * 0.7
332
+ first_length_score = 1 - abs(len(first_segment) - target_length) / self.max_line_length
333
+ score += first_length_score * 5
334
+
335
+ return score
336
+
337
+ def _find_break_points(self, line: str) -> List[List[int]]:
338
+ """Find potential break points in order of preference.
339
+
340
+ Returns a list of lists, where each inner list contains break points
341
+ of the same priority. Break points are indices where text should be split.
342
+
343
+ Priority order:
344
+ 1. Sentence endings (., !, ?)
345
+ 2. Major clause breaks (;, -)
346
+ 3. Comma breaks
347
+ 4. Coordinating conjunctions (and, but, or)
348
+ 5. Prepositions/articles (in, at, the, a)
349
+
350
+ Example:
351
+ Input: "Hello, world. This is a test"
352
+ Output: [
353
+ [12], # sentence break after "world."
354
+ [], # no semicolons or dashes
355
+ [5], # comma after "Hello,"
356
+ [], # no conjunctions
357
+ [15] # preposition "is"
358
+ ]
359
+ """
360
+ break_points = []
361
+
362
+ # Priority 1: Sentence endings
363
+ sentence_breaks = []
364
+ for punct in [".", "!", "?"]:
365
+ for match in re.finditer(rf"\{punct}\s+", line):
366
+ sentence_breaks.append(match.start() + 1)
367
+ break_points.append(sentence_breaks)
368
+
369
+ # Priority 2: Major clause breaks (semicolons, dashes)
370
+ major_breaks = []
371
+ for punct in [";", " - "]:
372
+ for match in re.finditer(re.escape(punct), line):
373
+ major_breaks.append(match.start()) # Position before the punctuation
374
+ break_points.append(major_breaks)
375
+
376
+ # Priority 3: Comma breaks, typically marking natural pauses
377
+ comma_breaks = []
378
+ for match in re.finditer(r",\s+", line):
379
+ comma_breaks.append(match.start() + 1) # Position after the comma
380
+ break_points.append(comma_breaks)
381
+
382
+ # Priority 4: Coordinating conjunctions with surrounding spaces
383
+ conjunction_breaks = []
384
+ for conj in [" and ", " but ", " or "]:
385
+ for match in re.finditer(re.escape(conj), line):
386
+ conjunction_breaks.append(match.start()) # Position before the conjunction
387
+ break_points.append(conjunction_breaks)
388
+
389
+ # Priority 5: Prepositions or articles with surrounding spaces (last resort)
390
+ minor_breaks = []
391
+ for prep in [" in ", " at ", " the ", " a "]:
392
+ for match in re.finditer(re.escape(prep), line):
393
+ minor_breaks.append(match.start()) # Position before the preposition
394
+ break_points.append(minor_breaks)
395
+
396
+ return break_points
397
+
398
+ def _log_input_segments(self, segments: List[LyricsSegment]) -> None:
399
+ """Log input segment information."""
400
+ self.logger.info(f"Starting segment resize. Input: {len(segments)} segments")
401
+ for idx, segment in enumerate(segments):
402
+ self.logger.debug(
403
+ f"Input segment {idx}: text='{segment.text}', "
404
+ f"words={len(segment.words)} words, "
405
+ f"time={segment.start_time:.2f}-{segment.end_time:.2f}"
406
+ )
407
+
408
+ def _log_output_segments(self, segments: List[LyricsSegment]) -> None:
409
+ """Log output segment information."""
410
+ self.logger.info(f"Finished resizing. Output: {len(segments)} segments")
411
+ for idx, segment in enumerate(segments):
412
+ self.logger.debug(
413
+ f"Output segment {idx}: text='{segment.text}', "
414
+ f"words={len(segment.words)} words, "
415
+ f"time={segment.start_time:.2f}-{segment.end_time:.2f}"
416
+ )