lyrics-transcriber 0.41.0__py3-none-any.whl → 0.43.0__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 (78) hide show
  1. lyrics_transcriber/core/controller.py +30 -52
  2. lyrics_transcriber/correction/anchor_sequence.py +325 -150
  3. lyrics_transcriber/correction/corrector.py +224 -107
  4. lyrics_transcriber/correction/handlers/base.py +28 -10
  5. lyrics_transcriber/correction/handlers/extend_anchor.py +47 -24
  6. lyrics_transcriber/correction/handlers/levenshtein.py +75 -33
  7. lyrics_transcriber/correction/handlers/llm.py +290 -0
  8. lyrics_transcriber/correction/handlers/no_space_punct_match.py +81 -36
  9. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +46 -26
  10. lyrics_transcriber/correction/handlers/repeat.py +28 -11
  11. lyrics_transcriber/correction/handlers/sound_alike.py +68 -32
  12. lyrics_transcriber/correction/handlers/syllables_match.py +80 -30
  13. lyrics_transcriber/correction/handlers/word_count_match.py +36 -19
  14. lyrics_transcriber/correction/handlers/word_operations.py +68 -22
  15. lyrics_transcriber/correction/text_utils.py +3 -7
  16. lyrics_transcriber/frontend/.yarn/install-state.gz +0 -0
  17. lyrics_transcriber/frontend/.yarn/releases/yarn-4.6.0.cjs +934 -0
  18. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  19. lyrics_transcriber/frontend/dist/assets/{index-DKnNJHRK.js → index-D0Gr3Ep7.js} +16509 -9038
  20. lyrics_transcriber/frontend/dist/assets/index-D0Gr3Ep7.js.map +1 -0
  21. lyrics_transcriber/frontend/dist/index.html +1 -1
  22. lyrics_transcriber/frontend/package.json +6 -2
  23. lyrics_transcriber/frontend/src/App.tsx +18 -2
  24. lyrics_transcriber/frontend/src/api.ts +103 -6
  25. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +14 -6
  26. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +86 -59
  27. lyrics_transcriber/frontend/src/components/EditModal.tsx +281 -63
  28. lyrics_transcriber/frontend/src/components/FileUpload.tsx +2 -2
  29. lyrics_transcriber/frontend/src/components/Header.tsx +249 -0
  30. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +320 -266
  31. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +120 -0
  32. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +174 -52
  33. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +158 -114
  34. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +59 -78
  35. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +39 -16
  36. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +4 -10
  37. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +134 -68
  38. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +1 -1
  39. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +85 -115
  40. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  41. lyrics_transcriber/frontend/src/components/shared/types.ts +15 -7
  42. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +67 -0
  43. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  44. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +7 -7
  45. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +121 -0
  46. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  47. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  48. lyrics_transcriber/frontend/src/types.js +2 -0
  49. lyrics_transcriber/frontend/src/types.ts +70 -49
  50. lyrics_transcriber/frontend/src/validation.ts +132 -0
  51. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  52. lyrics_transcriber/frontend/yarn.lock +3752 -0
  53. lyrics_transcriber/lyrics/base_lyrics_provider.py +75 -12
  54. lyrics_transcriber/lyrics/file_provider.py +6 -5
  55. lyrics_transcriber/lyrics/genius.py +5 -2
  56. lyrics_transcriber/lyrics/spotify.py +58 -21
  57. lyrics_transcriber/output/ass/config.py +16 -5
  58. lyrics_transcriber/output/cdg.py +1 -1
  59. lyrics_transcriber/output/generator.py +22 -8
  60. lyrics_transcriber/output/plain_text.py +15 -10
  61. lyrics_transcriber/output/segment_resizer.py +16 -3
  62. lyrics_transcriber/output/subtitles.py +27 -1
  63. lyrics_transcriber/output/video.py +107 -1
  64. lyrics_transcriber/review/__init__.py +0 -1
  65. lyrics_transcriber/review/server.py +337 -164
  66. lyrics_transcriber/transcribers/audioshake.py +3 -0
  67. lyrics_transcriber/transcribers/base_transcriber.py +11 -3
  68. lyrics_transcriber/transcribers/whisper.py +11 -1
  69. lyrics_transcriber/types.py +151 -105
  70. lyrics_transcriber/utils/word_utils.py +27 -0
  71. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/METADATA +3 -1
  72. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/RECORD +75 -61
  73. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/WHEEL +1 -1
  74. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +0 -1
  75. lyrics_transcriber/frontend/package-lock.json +0 -4260
  76. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +0 -202
  77. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/LICENSE +0 -0
  78. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/entry_points.txt +0 -0
@@ -1,18 +1,30 @@
1
- from typing import List, Optional, Tuple, Union
1
+ from typing import List, Optional, Tuple, Union, Dict, Any
2
2
  import logging
3
3
  from pathlib import Path
4
+ from copy import deepcopy
4
5
 
6
+ from lyrics_transcriber.correction.handlers.levenshtein import LevenshteinHandler
7
+ from lyrics_transcriber.correction.handlers.llm import LLMHandler
5
8
  from lyrics_transcriber.correction.handlers.no_space_punct_match import NoSpacePunctuationMatchHandler
6
9
  from lyrics_transcriber.correction.handlers.relaxed_word_count_match import RelaxedWordCountMatchHandler
10
+ from lyrics_transcriber.correction.handlers.repeat import RepeatCorrectionHandler
11
+ from lyrics_transcriber.correction.handlers.sound_alike import SoundAlikeHandler
7
12
  from lyrics_transcriber.correction.handlers.syllables_match import SyllablesMatchHandler
8
- from lyrics_transcriber.types import GapSequence, LyricsData, TranscriptionResult, CorrectionResult, LyricsSegment, WordCorrection, Word
13
+ from lyrics_transcriber.correction.handlers.word_count_match import WordCountMatchHandler
14
+ from lyrics_transcriber.types import (
15
+ CorrectionStep,
16
+ GapSequence,
17
+ LyricsData,
18
+ TranscriptionResult,
19
+ CorrectionResult,
20
+ LyricsSegment,
21
+ WordCorrection,
22
+ Word,
23
+ )
9
24
  from lyrics_transcriber.correction.anchor_sequence import AnchorSequenceFinder
10
25
  from lyrics_transcriber.correction.handlers.base import GapCorrectionHandler
11
- from lyrics_transcriber.correction.handlers.word_count_match import WordCountMatchHandler
12
26
  from lyrics_transcriber.correction.handlers.extend_anchor import ExtendAnchorHandler
13
- from lyrics_transcriber.correction.handlers.sound_alike import SoundAlikeHandler
14
- from lyrics_transcriber.correction.handlers.levenshtein import LevenshteinHandler
15
- from lyrics_transcriber.correction.handlers.repeat import RepeatCorrectionHandler
27
+ from lyrics_transcriber.utils.word_utils import WordUtils
16
28
 
17
29
 
18
30
  class LyricsCorrector:
@@ -24,25 +36,54 @@ class LyricsCorrector:
24
36
  self,
25
37
  cache_dir: Union[str, Path],
26
38
  handlers: Optional[List[GapCorrectionHandler]] = None,
39
+ enabled_handlers: Optional[List[str]] = None,
27
40
  anchor_finder: Optional[AnchorSequenceFinder] = None,
28
41
  logger: Optional[logging.Logger] = None,
29
42
  ):
30
43
  self.logger = logger or logging.getLogger(__name__)
31
44
  self._anchor_finder = anchor_finder
32
- self._cache_dir = cache_dir
33
-
34
- # Default handlers in order of preference
35
- self.handlers = handlers or [
36
- # WordCountMatchHandler(logger=self.logger),
37
- # RelaxedWordCountMatchHandler(logger=self.logger),
38
- # NoSpacePunctuationMatchHandler(logger=self.logger),
39
- # SyllablesMatchHandler(logger=self.logger),
40
- ExtendAnchorHandler(logger=self.logger),
41
- # RepeatCorrectionHandler(logger=self.logger),
42
- # SoundAlikeHandler(logger=self.logger),
43
- # LevenshteinHandler(logger=self.logger),
45
+ self._cache_dir = Path(cache_dir)
46
+
47
+ # Define default enabled handlers - excluding LLM, Repeat, SoundAlike, and Levenshtein
48
+ DEFAULT_ENABLED_HANDLERS = [
49
+ "ExtendAnchorHandler",
50
+ "WordCountMatchHandler",
51
+ "SyllablesMatchHandler",
52
+ "RelaxedWordCountMatchHandler",
53
+ "NoSpacePunctuationMatchHandler",
44
54
  ]
45
55
 
56
+ # Create all handlers but respect enabled_handlers if provided
57
+ all_handlers = [
58
+ ("ExtendAnchorHandler", ExtendAnchorHandler(logger=self.logger)),
59
+ ("WordCountMatchHandler", WordCountMatchHandler(logger=self.logger)),
60
+ ("SyllablesMatchHandler", SyllablesMatchHandler(logger=self.logger)),
61
+ ("RelaxedWordCountMatchHandler", RelaxedWordCountMatchHandler(logger=self.logger)),
62
+ ("NoSpacePunctuationMatchHandler", NoSpacePunctuationMatchHandler(logger=self.logger)),
63
+ ("LLMHandler", LLMHandler(logger=self.logger, cache_dir=self._cache_dir)),
64
+ ("RepeatCorrectionHandler", RepeatCorrectionHandler(logger=self.logger)),
65
+ ("SoundAlikeHandler", SoundAlikeHandler(logger=self.logger)),
66
+ ("LevenshteinHandler", LevenshteinHandler(logger=self.logger)),
67
+ ]
68
+
69
+ # Store all handler information
70
+ self.all_handlers = [
71
+ {
72
+ "id": handler_id,
73
+ "name": handler_id,
74
+ "description": handler.__class__.__doc__ or "",
75
+ "enabled": handler_id in (enabled_handlers if enabled_handlers is not None else DEFAULT_ENABLED_HANDLERS),
76
+ }
77
+ for handler_id, handler in all_handlers
78
+ ]
79
+
80
+ if handlers:
81
+ self.handlers = handlers
82
+ else:
83
+ # Use provided enabled_handlers if available, otherwise use defaults
84
+ handler_filter = enabled_handlers if enabled_handlers is not None else DEFAULT_ENABLED_HANDLERS
85
+ self.handlers = [h[1] for h in all_handlers if h[0] in handler_filter]
86
+
46
87
  @property
47
88
  def anchor_finder(self) -> AnchorSequenceFinder:
48
89
  """Lazy load the anchor finder instance, initializing it if not already set."""
@@ -50,39 +91,52 @@ class LyricsCorrector:
50
91
  self._anchor_finder = AnchorSequenceFinder(cache_dir=self._cache_dir, logger=self.logger)
51
92
  return self._anchor_finder
52
93
 
53
- def run(self, transcription_results: List[TranscriptionResult], lyrics_results: List[LyricsData]) -> CorrectionResult:
94
+ def run(
95
+ self,
96
+ transcription_results: List[TranscriptionResult],
97
+ lyrics_results: Dict[str, LyricsData],
98
+ metadata: Optional[Dict[str, Any]] = None,
99
+ ) -> CorrectionResult:
54
100
  """Execute the correction process."""
55
101
  if not transcription_results:
56
102
  self.logger.error("No transcription results available")
57
103
  raise ValueError("No primary transcription data available")
58
104
 
105
+ # Store reference lyrics for use in word map
106
+ self.reference_lyrics = lyrics_results
107
+
59
108
  # Get primary transcription
60
109
  primary_transcription = sorted(transcription_results, key=lambda x: x.priority)[0].result
61
110
  transcribed_text = " ".join(" ".join(w.text for w in segment.words) for segment in primary_transcription.segments)
62
- reference_texts = {lyrics.source: lyrics.lyrics for lyrics in lyrics_results}
63
111
 
64
112
  # Find anchor sequences and gaps
65
113
  self.logger.debug("Finding anchor sequences and gaps")
66
- anchor_sequences = self.anchor_finder.find_anchors(transcribed_text, reference_texts)
67
- gap_sequences = self.anchor_finder.find_gaps(transcribed_text, anchor_sequences, reference_texts)
114
+ anchor_sequences = self.anchor_finder.find_anchors(transcribed_text, lyrics_results, primary_transcription)
115
+ gap_sequences = self.anchor_finder.find_gaps(transcribed_text, anchor_sequences, lyrics_results, primary_transcription)
116
+
117
+ # Store anchor sequences for use in correction handlers
118
+ self._anchor_sequences = anchor_sequences
68
119
 
69
- # Process corrections
70
- corrections, corrected_segments = self._process_corrections(primary_transcription.segments, gap_sequences)
120
+ # Process corrections with metadata
121
+ corrections, corrected_segments, correction_steps, word_id_map, segment_id_map = self._process_corrections(
122
+ primary_transcription.segments, gap_sequences, metadata=metadata
123
+ )
71
124
 
72
125
  # Calculate correction ratio
73
126
  total_words = sum(len(segment.words) for segment in corrected_segments)
74
127
  corrections_made = len(corrections)
75
128
  correction_ratio = 1 - (corrections_made / total_words if total_words > 0 else 0)
76
129
 
130
+ # Get the currently enabled handler IDs using full class names
131
+ enabled_handlers = [handler.__class__.__name__ for handler in self.handlers]
132
+
77
133
  return CorrectionResult(
78
134
  original_segments=primary_transcription.segments,
79
135
  corrected_segments=corrected_segments,
80
- corrected_text="\n".join(segment.text for segment in corrected_segments) + "\n",
81
136
  corrections=corrections,
82
137
  corrections_made=corrections_made,
83
138
  confidence=correction_ratio,
84
- transcribed_text=transcribed_text,
85
- reference_texts=reference_texts,
139
+ reference_lyrics=lyrics_results,
86
140
  anchor_sequences=anchor_sequences,
87
141
  resized_segments=[],
88
142
  gap_sequences=gap_sequences,
@@ -91,7 +145,12 @@ class LyricsCorrector:
91
145
  "gap_sequences_count": len(gap_sequences),
92
146
  "total_words": total_words,
93
147
  "correction_ratio": correction_ratio,
148
+ "available_handlers": self.all_handlers,
149
+ "enabled_handlers": enabled_handlers,
94
150
  },
151
+ correction_steps=correction_steps,
152
+ word_id_map=word_id_map,
153
+ segment_id_map=segment_id_map,
95
154
  )
96
155
 
97
156
  def _preserve_formatting(self, original: str, new_word: str) -> str:
@@ -102,8 +161,8 @@ class LyricsCorrector:
102
161
  return leading_space + new_word.strip() + trailing_space
103
162
 
104
163
  def _process_corrections(
105
- self, segments: List[LyricsSegment], gap_sequences: List[GapSequence]
106
- ) -> Tuple[List[WordCorrection], List[LyricsSegment]]:
164
+ self, segments: List[LyricsSegment], gap_sequences: List[GapSequence], metadata: Optional[Dict[str, Any]] = None
165
+ ) -> Tuple[List[WordCorrection], List[LyricsSegment], List[CorrectionStep], Dict[str, str], Dict[str, str]]:
107
166
  """Process corrections using handlers.
108
167
 
109
168
  The correction flow works as follows:
@@ -121,84 +180,109 @@ class LyricsCorrector:
121
180
  b) Applying those corrections to the original text (segment-centric)
122
181
  """
123
182
  self.logger.info(f"Starting correction process with {len(gap_sequences)} gaps")
124
-
125
- # First pass: Process all gaps
126
- all_corrections = self._process_gaps(gap_sequences)
127
-
128
- # Second pass: Apply corrections to segments
129
- corrected_segments = self._apply_corrections_to_segments(segments, all_corrections)
130
-
131
- self.logger.info(f"Correction process complete. Made {len(all_corrections)} corrections")
132
- return all_corrections, corrected_segments
133
-
134
- def _process_gaps(self, gap_sequences: List[GapSequence]) -> List[WordCorrection]:
135
- """Process each gap using available handlers until all words are corrected or no handlers remain."""
183
+ correction_steps = []
136
184
  all_corrections = []
137
- # return all_corrections
138
-
139
- for gap in gap_sequences:
140
- self.logger.debug(f"Processing gap: {gap.text}")
141
- high_confidence_positions = set() # Track positions that have high confidence corrections
142
- corrected_positions = set() # Track all corrected positions regardless of confidence
143
-
144
- # Try each handler until gap is fully corrected
185
+ word_id_map = {}
186
+ segment_id_map = {}
187
+
188
+ # Create word map for handlers - include both transcribed and reference words
189
+ word_map = {w.id: w for s in segments for w in s.words} # Transcribed words
190
+
191
+ # Add reference words from all sources
192
+ for source, lyrics_data in self.reference_lyrics.items():
193
+ for segment in lyrics_data.segments:
194
+ for word in segment.words:
195
+ if word.id not in word_map: # Don't overwrite transcribed words
196
+ word_map[word.id] = word
197
+
198
+ # Base handler data that all handlers need
199
+ base_handler_data = {
200
+ "word_map": word_map,
201
+ "anchor_sequences": self._anchor_sequences,
202
+ "audio_file_hash": metadata.get("audio_file_hash") if metadata else None,
203
+ }
204
+
205
+ for i, gap in enumerate(gap_sequences, 1):
206
+ self.logger.info(f"Processing gap {i}/{len(gap_sequences)} at position {gap.transcription_position}")
207
+
208
+ # Get the actual words for logging
209
+ gap_words = [word_map[word_id] for word_id in gap.transcribed_word_ids]
210
+ self.logger.debug(f"Gap text: '{' '.join(w.text for w in gap_words)}'")
211
+
212
+ # Try each handler in order
145
213
  for handler in self.handlers:
146
- # Skip if all words have high confidence corrections
147
- uncorrected_positions = set(range(gap.transcription_position, gap.transcription_position + gap.length))
148
- uncorrected_positions -= corrected_positions # Skip any corrected positions
214
+ handler_name = handler.__class__.__name__
215
+ can_handle, handler_data = handler.can_handle(gap, base_handler_data)
149
216
 
150
- if not uncorrected_positions:
151
- self.logger.debug("All words have been corrected, skipping remaining handlers")
152
- break
217
+ if can_handle:
218
+ # Merge base handler data with specific handler data
219
+ handler_data = {**base_handler_data, **(handler_data or {})}
153
220
 
154
- self.logger.debug(f"Trying handler {handler.__class__.__name__}")
221
+ corrections = handler.handle(gap, handler_data)
222
+ if corrections:
223
+ self.logger.info(f"Handler {handler_name} made {len(corrections)} corrections")
224
+ # Track affected IDs
225
+ affected_word_ids = [w.id for w in self._get_affected_words(gap, segments)]
226
+ affected_segment_ids = [s.id for s in self._get_affected_segments(gap, segments)]
155
227
 
156
- # Pass previous corrections to RepeatCorrectionHandler
157
- if isinstance(handler, RepeatCorrectionHandler):
158
- handler.set_previous_corrections(all_corrections)
228
+ # Apply corrections and get updated segments
229
+ updated_segments = self._apply_corrections_to_segments(self._get_affected_segments(gap, segments), corrections)
159
230
 
160
- can_handle, handler_data = handler.can_handle(gap)
161
- if can_handle:
162
- self.logger.debug(f"{handler.__class__.__name__} can handle gap")
163
- # Only pass handler_data if it's not empty
164
- corrections = handler.handle(gap, handler_data if handler_data else None)
165
- if corrections:
166
- # Add corrections to gap and track corrected positions
231
+ # Update ID maps
232
+ for correction in corrections:
233
+ if correction.word_id and correction.corrected_word_id:
234
+ word_id_map[correction.word_id] = correction.corrected_word_id
235
+
236
+ # Map segment IDs
237
+ for old_seg, new_seg in zip(self._get_affected_segments(gap, segments), updated_segments):
238
+ segment_id_map[old_seg.id] = new_seg.id
239
+
240
+ # Create correction step
241
+ step = CorrectionStep(
242
+ handler_name=handler_name,
243
+ affected_word_ids=affected_word_ids,
244
+ affected_segment_ids=affected_segment_ids,
245
+ corrections=corrections,
246
+ segments_before=self._get_affected_segments(gap, segments),
247
+ segments_after=updated_segments,
248
+ created_word_ids=[w.id for w in self._get_new_words(updated_segments, affected_word_ids)],
249
+ deleted_word_ids=[id for id in affected_word_ids if not self._word_exists(id, updated_segments)],
250
+ )
251
+ correction_steps.append(step)
252
+ all_corrections.extend(corrections)
253
+
254
+ # Log correction details
167
255
  for correction in corrections:
168
- # Skip if this position was already corrected
169
- if correction.original_position in corrected_positions:
170
- continue
171
-
172
- gap.add_correction(correction)
173
- corrected_positions.add(correction.original_position)
174
- # Track positions with high confidence corrections (>= 0.9)
175
- if correction.confidence >= 0.9:
176
- high_confidence_positions.add(correction.original_position)
177
-
178
- # Filter out corrections for already corrected positions
179
- new_corrections = [c for c in corrections if c.original_position in corrected_positions]
180
- if new_corrections:
181
- self.logger.debug(
182
- f"{handler.__class__.__name__} made {len(new_corrections)} corrections: "
183
- f"{[f'{c.original_word}->{c.corrected_word}' for c in new_corrections]}"
256
+ self.logger.info(
257
+ f"Made correction: '{correction.original_word}' -> '{correction.corrected_word}' "
258
+ f"(confidence: {correction.confidence:.2f}, reason: {correction.reason})"
184
259
  )
185
- all_corrections.extend(new_corrections)
260
+ break # Stop trying other handlers once we've made corrections
261
+ else:
262
+ self.logger.debug(f"Handler {handler_name} found no corrections needed")
263
+ else:
264
+ self.logger.debug(f"Handler {handler_name} cannot handle gap")
186
265
 
187
- # Log remaining uncorrected words
188
- if not gap.is_fully_corrected:
189
- uncorrected = [word for pos, word in gap.uncorrected_words if pos not in corrected_positions]
190
- if uncorrected:
191
- self.logger.debug(f"Uncorrected words remaining: {', '.join(uncorrected)}")
266
+ # Create final result with correction history
267
+ corrected_segments = self._apply_all_corrections(segments, all_corrections)
268
+ self.logger.info(f"Correction process completed with {len(all_corrections)} total corrections")
269
+ return all_corrections, corrected_segments, correction_steps, word_id_map, segment_id_map
192
270
 
193
- if not gap.corrections:
194
- self.logger.warning("No handler could handle the gap")
271
+ def _get_new_words(self, segments: List[LyricsSegment], original_word_ids: List[str]) -> List[Word]:
272
+ """Find words that were created during correction."""
273
+ return [w for s in segments for w in s.words if w.id not in original_word_ids]
195
274
 
196
- return all_corrections
275
+ def _word_exists(self, word_id: str, segments: List[LyricsSegment]) -> bool:
276
+ """Check if a word ID still exists in the segments."""
277
+ return any(w.id == word_id for s in segments for w in s.words)
197
278
 
198
279
  def _apply_corrections_to_segments(self, segments: List[LyricsSegment], corrections: List[WordCorrection]) -> List[LyricsSegment]:
199
280
  """Apply corrections to create new segments."""
200
- correction_map = {}
281
+ # Create word ID map for quick lookup
282
+ word_map = {w.id: w for s in segments for w in s.words}
283
+
201
284
  # Group corrections by original_position to handle splits
285
+ correction_map = {}
202
286
  for c in corrections:
203
287
  if c.original_position not in correction_map:
204
288
  correction_map[c.original_position] = []
@@ -207,7 +291,7 @@ class LyricsCorrector:
207
291
  corrected_segments = []
208
292
  current_word_idx = 0
209
293
 
210
- for segment_idx, segment in enumerate(segments):
294
+ for segment in segments:
211
295
  corrected_words = []
212
296
  for word in segment.words:
213
297
  if current_word_idx in correction_map:
@@ -226,28 +310,30 @@ class LyricsCorrector:
226
310
 
227
311
  # Update corrected_position as we create new words
228
312
  correction.corrected_position = len(corrected_words)
229
- corrected_words.append(
230
- Word(
231
- text=self._preserve_formatting(correction.original_word, correction.corrected_word),
232
- start_time=start_time,
233
- end_time=end_time,
234
- confidence=correction.confidence,
235
- )
313
+ new_word = Word(
314
+ id=correction.corrected_word_id or WordUtils.generate_id(),
315
+ text=self._preserve_formatting(correction.original_word, correction.corrected_word),
316
+ start_time=start_time,
317
+ end_time=end_time,
318
+ confidence=correction.confidence,
319
+ created_during_correction=True,
236
320
  )
321
+ corrected_words.append(new_word)
237
322
  else:
238
323
  # Handle single word replacement
239
324
  correction = word_corrections[0]
240
325
  if not correction.is_deletion:
241
326
  # Update corrected_position
242
327
  correction.corrected_position = len(corrected_words)
243
- corrected_words.append(
244
- Word(
245
- text=self._preserve_formatting(correction.original_word, correction.corrected_word),
246
- start_time=word.start_time,
247
- end_time=word.end_time,
248
- confidence=correction.confidence,
249
- )
328
+ new_word = Word(
329
+ id=correction.corrected_word_id or WordUtils.generate_id(),
330
+ text=self._preserve_formatting(correction.original_word, correction.corrected_word),
331
+ start_time=word.start_time,
332
+ end_time=word.end_time,
333
+ confidence=correction.confidence,
334
+ created_during_correction=True,
250
335
  )
336
+ corrected_words.append(new_word)
251
337
  else:
252
338
  corrected_words.append(word)
253
339
  current_word_idx += 1
@@ -255,6 +341,7 @@ class LyricsCorrector:
255
341
  if corrected_words:
256
342
  corrected_segments.append(
257
343
  LyricsSegment(
344
+ id=segment.id, # Preserve original segment ID
258
345
  text=" ".join(w.text for w in corrected_words),
259
346
  words=corrected_words,
260
347
  start_time=segment.start_time,
@@ -263,3 +350,33 @@ class LyricsCorrector:
263
350
  )
264
351
 
265
352
  return corrected_segments
353
+
354
+ def _get_affected_segments(self, gap: GapSequence, segments: List[LyricsSegment]) -> List[LyricsSegment]:
355
+ """Get segments that contain words from the gap sequence."""
356
+ affected_segments = []
357
+ gap_word_ids = set(gap.transcribed_word_ids)
358
+
359
+ for segment in segments:
360
+ # Check if any words in this segment are part of the gap
361
+ if any(w.id in gap_word_ids for w in segment.words):
362
+ affected_segments.append(segment)
363
+ elif affected_segments: # We've passed the gap
364
+ break
365
+
366
+ return affected_segments
367
+
368
+ def _get_affected_words(self, gap: GapSequence, segments: List[LyricsSegment]) -> List[Word]:
369
+ """Get words that are part of the gap sequence."""
370
+ # Create a map of word IDs to Word objects for quick lookup
371
+ word_map = {w.id: w for s in segments for w in s.words}
372
+
373
+ # Get the actual Word objects using the IDs
374
+ return [word_map[word_id] for word_id in gap.transcribed_word_ids]
375
+
376
+ def _apply_all_corrections(self, segments: List[LyricsSegment], corrections: List[WordCorrection]) -> List[LyricsSegment]:
377
+ """Apply all corrections to create final corrected segments."""
378
+ # Make a deep copy to avoid modifying original segments
379
+ working_segments = deepcopy(segments)
380
+
381
+ # Apply corrections in order
382
+ return self._apply_corrections_to_segments(working_segments, corrections)
@@ -12,23 +12,41 @@ class GapCorrectionHandler(ABC):
12
12
  self.logger = logger or logging.getLogger(__name__)
13
13
 
14
14
  @abstractmethod
15
- def can_handle(self, gap: GapSequence) -> Tuple[bool, Dict[str, Any]]:
16
- """Determine if this handler can process the given gap.
17
-
15
+ def can_handle(self, gap: GapSequence, data: Optional[Dict[str, Any]] = None) -> Tuple[bool, Dict[str, Any]]:
16
+ """Check if this handler can process the given gap.
17
+
18
+ Args:
19
+ gap: The gap sequence to check
20
+ data: Optional dictionary containing additional data like word_map
21
+
18
22
  Returns:
19
- Tuple containing:
20
- - bool: Whether this handler can process the gap
21
- - dict: Data computed during can_handle that will be needed by handle().
22
- Empty dict if no data needs to be passed.
23
+ Tuple of (can_handle, handler_data)
23
24
  """
24
25
  pass
25
26
 
26
27
  @abstractmethod
27
28
  def handle(self, gap: GapSequence, data: Optional[Dict[str, Any]] = None) -> List[WordCorrection]:
28
- """Process a gap and return any corrections.
29
-
29
+ """Process the gap and return any corrections.
30
+
30
31
  Args:
31
32
  gap: The gap sequence to process
32
- data: Optional data dictionary returned by can_handle()
33
+ data: Optional dictionary containing additional data like word_map
34
+
35
+ Returns:
36
+ List of corrections to apply
33
37
  """
34
38
  pass
39
+
40
+ def _validate_data(self, data: Optional[Dict[str, Any]]) -> bool:
41
+ """Validate that required data is present.
42
+
43
+ Args:
44
+ data: The data dictionary to validate
45
+
46
+ Returns:
47
+ True if data is valid, False otherwise
48
+ """
49
+ if not data or "word_map" not in data:
50
+ self.logger.error("No word_map provided in data")
51
+ return False
52
+ return True
@@ -1,7 +1,7 @@
1
1
  from typing import List, Optional, Tuple, Dict, Any
2
2
  import logging
3
3
 
4
- from lyrics_transcriber.types import GapSequence, WordCorrection
4
+ from lyrics_transcriber.types import GapSequence, WordCorrection, Word
5
5
  from lyrics_transcriber.correction.handlers.base import GapCorrectionHandler
6
6
  from lyrics_transcriber.correction.handlers.word_operations import WordOperations
7
7
 
@@ -40,26 +40,31 @@ class ExtendAnchorHandler(GapCorrectionHandler):
40
40
  """
41
41
 
42
42
  def __init__(self, logger: Optional[logging.Logger] = None):
43
- super().__init__(logger)
44
43
  self.logger = logger or logging.getLogger(__name__)
45
44
 
46
- def can_handle(self, gap: GapSequence) -> Tuple[bool, Dict[str, Any]]:
47
- # Must have reference words
48
- if not gap.reference_words:
49
- self.logger.debug("No reference words available.")
45
+ def can_handle(self, gap: GapSequence, data: Optional[Dict[str, Any]] = None) -> Tuple[bool, Dict[str, Any]]:
46
+ """Check if this gap can be handled by extending anchor sequences."""
47
+ # Check if we have anchor sequences
48
+ if not data or "anchor_sequences" not in data:
49
+ self.logger.debug("No anchor sequences available")
50
50
  return False, {}
51
51
 
52
- # Gap must have words
53
- if not gap.words:
54
- self.logger.debug("No words in the gap to process.")
52
+ # Must have reference word IDs
53
+ if not gap.reference_word_ids:
54
+ self.logger.debug("No reference word IDs available.")
55
55
  return False, {}
56
56
 
57
- # At least one word must match between gap and any reference source
57
+ # Gap must have word IDs
58
+ if not gap.transcribed_word_ids:
59
+ self.logger.debug("No word IDs in the gap to process.")
60
+ return False, {}
61
+
62
+ # At least one word ID must match between gap and any reference source
58
63
  # in the same position
59
64
  has_match = any(
60
- i < len(ref_words) and gap.words[i].lower() == ref_words[i].lower()
61
- for ref_words in gap.reference_words.values()
62
- for i in range(min(len(gap.words), len(ref_words)))
65
+ i < len(ref_word_ids) and gap.transcribed_word_ids[i] == ref_word_ids[i]
66
+ for ref_word_ids in gap.reference_word_ids.values()
67
+ for i in range(min(len(gap.transcribed_word_ids), len(ref_word_ids)))
63
68
  )
64
69
 
65
70
  self.logger.debug(f"Can handle gap: {has_match}")
@@ -68,16 +73,32 @@ class ExtendAnchorHandler(GapCorrectionHandler):
68
73
  def handle(self, gap: GapSequence, data: Optional[Dict[str, Any]] = None) -> List[WordCorrection]:
69
74
  corrections = []
70
75
 
76
+ # Get word lookup map from data
77
+ word_map = data.get("word_map", {})
78
+ if not word_map:
79
+ self.logger.error("No word_map provided in data")
80
+ return []
81
+
71
82
  # Process each word in the gap that has a corresponding reference position
72
- for i, word in enumerate(gap.words):
83
+ for i, word_id in enumerate(gap.transcribed_word_ids):
84
+ # Get the actual word object
85
+ if word_id not in word_map:
86
+ self.logger.error(f"Word ID {word_id} not found in word_map")
87
+ continue
88
+ word = word_map[word_id]
89
+
73
90
  # Find reference sources that have a matching word at this position
74
91
  matching_sources = [
75
- source for source, ref_words in gap.reference_words.items() if i < len(ref_words) and word.lower() == ref_words[i].lower()
92
+ source for source, ref_word_ids in gap.reference_word_ids.items() if i < len(ref_word_ids) and word_id == ref_word_ids[i]
76
93
  ]
77
94
 
95
+ if not matching_sources:
96
+ self.logger.debug(f"Skipping word '{word.text}' at position {i} - no matching references")
97
+ continue
98
+
78
99
  if matching_sources:
79
100
  # Word matches reference(s) at this position - validate it
80
- confidence = len(matching_sources) / len(gap.reference_words)
101
+ confidence = len(matching_sources) / len(gap.reference_word_ids)
81
102
  sources = ", ".join(matching_sources)
82
103
 
83
104
  # Get base reference positions
@@ -88,24 +109,26 @@ class ExtendAnchorHandler(GapCorrectionHandler):
88
109
  for source in matching_sources:
89
110
  if source in base_reference_positions:
90
111
  # Find this word's position in the reference text
91
- ref_words = gap.reference_words[source]
92
- for ref_idx, ref_word in enumerate(ref_words):
93
- if ref_word.lower() == word.lower():
112
+ ref_word_ids = gap.reference_word_ids[source]
113
+ for ref_idx, ref_word_id in enumerate(ref_word_ids):
114
+ if ref_word_id == word_id:
94
115
  reference_positions[source] = base_reference_positions[source] + ref_idx
95
116
  break
96
117
 
97
118
  corrections.append(
98
119
  WordOperations.create_word_replacement_correction(
99
- original_word=word,
100
- corrected_word=word, # Same word, just validating
120
+ original_word=word.text,
121
+ corrected_word=word.text,
101
122
  original_position=gap.transcription_position + i,
102
123
  source=sources,
103
124
  confidence=confidence,
104
- reason="ExtendAnchorHandler: Matched reference source(s)",
125
+ reason="Matched reference source(s)",
105
126
  reference_positions=reference_positions,
127
+ handler="ExtendAnchorHandler",
128
+ original_word_id=word_id,
129
+ corrected_word_id=word_id,
106
130
  )
107
131
  )
108
- self.logger.debug(f"Validated word '{word}' with confidence {confidence} from sources: {sources}")
109
- # No else clause - non-matching words are left unchanged
132
+ self.logger.debug(f"Validated word '{word.text}' with confidence {confidence} from sources: {sources}")
110
133
 
111
134
  return corrections