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
@@ -1,33 +1,18 @@
1
1
  from dataclasses import dataclass
2
2
  import os
3
3
  import logging
4
- from typing import Dict, Any, List, Optional, Tuple
5
- import subprocess
6
- from datetime import timedelta
4
+ from typing import List, Optional
5
+ import json
7
6
 
8
- from lyrics_transcriber.lyrics.base_lyrics_provider import LyricsData
9
- from .subtitles import create_styled_subtitles, LyricsScreen, LyricsLine, LyricSegment
10
- from ..correction.corrector import CorrectionResult
11
-
12
-
13
- @dataclass
14
- class OutputGeneratorConfig:
15
- """Configuration for output generation."""
16
-
17
- output_dir: str
18
- cache_dir: str
19
- video_resolution: str = "360p"
20
- video_background_image: Optional[str] = None
21
- video_background_color: str = "black"
22
-
23
- def __post_init__(self):
24
- """Validate configuration after initialization."""
25
- if not self.output_dir:
26
- raise ValueError("output_dir must be provided")
27
- if not self.cache_dir:
28
- raise ValueError("cache_dir must be provided")
29
- if self.video_background_image and not os.path.isfile(self.video_background_image):
30
- raise FileNotFoundError(f"Video background image not found: {self.video_background_image}")
7
+ from lyrics_transcriber.types import LyricsData
8
+ from lyrics_transcriber.correction.corrector import CorrectionResult
9
+ from lyrics_transcriber.output.plain_text import PlainTextGenerator
10
+ from lyrics_transcriber.output.lyrics_file import LyricsFileGenerator
11
+ from lyrics_transcriber.output.subtitles import SubtitlesGenerator
12
+ from lyrics_transcriber.output.video import VideoGenerator
13
+ from lyrics_transcriber.output.segment_resizer import SegmentResizer
14
+ from lyrics_transcriber.output.cdg import CDGGenerator
15
+ from lyrics_transcriber.core.config import OutputConfig
31
16
 
32
17
 
33
18
  @dataclass
@@ -37,6 +22,12 @@ class OutputPaths:
37
22
  lrc: Optional[str] = None
38
23
  ass: Optional[str] = None
39
24
  video: Optional[str] = None
25
+ original_txt: Optional[str] = None
26
+ corrected_txt: Optional[str] = None
27
+ corrections_json: Optional[str] = None
28
+ cdg: Optional[str] = None
29
+ mp3: Optional[str] = None
30
+ cdg_zip: Optional[str] = None
40
31
 
41
32
 
42
33
  class OutputGenerator:
@@ -44,33 +35,72 @@ class OutputGenerator:
44
35
 
45
36
  def __init__(
46
37
  self,
47
- config: OutputGeneratorConfig,
38
+ config: OutputConfig,
48
39
  logger: Optional[logging.Logger] = None,
49
40
  ):
50
41
  """
51
42
  Initialize OutputGenerator with configuration.
52
43
 
53
44
  Args:
54
- config: OutputGeneratorConfig instance with required paths
45
+ config: OutputConfig instance with required paths and settings
55
46
  logger: Optional logger instance
56
47
  """
57
48
  self.config = config
58
49
  self.logger = logger or logging.getLogger(__name__)
59
50
 
60
- # Log the configured directories
61
- self.logger.debug(f"Initialized OutputGenerator with output_dir: {self.config.output_dir}")
62
- self.logger.debug(f"Using cache_dir: {self.config.cache_dir}")
51
+ self.logger.debug(f"Initializing OutputGenerator with config: {self.config}")
63
52
 
64
53
  # Set video resolution parameters
65
54
  self.video_resolution_num, self.font_size, self.line_height = self._get_video_params(self.config.video_resolution)
66
55
 
56
+ self.segment_resizer = SegmentResizer(max_line_length=self.config.max_line_length, logger=self.logger)
57
+
58
+ # Initialize generators
59
+ self.plain_text = PlainTextGenerator(self.config.output_dir, self.logger)
60
+ self.lyrics_file = LyricsFileGenerator(self.config.output_dir, self.logger)
61
+
62
+ if self.config.render_video or self.config.generate_cdg:
63
+ # Load output styles from JSON
64
+ try:
65
+ with open(self.config.output_styles_json, "r") as f:
66
+ self.config.styles = json.load(f)
67
+ self.logger.debug(f"Loaded output styles from: {self.config.output_styles_json}")
68
+ except Exception as e:
69
+ raise ValueError(f"Failed to load output styles file: {str(e)}")
70
+
71
+ if self.config.generate_cdg:
72
+ self.cdg = CDGGenerator(self.config.output_dir, self.logger)
73
+
74
+ if self.config.render_video:
75
+ self.subtitle = SubtitlesGenerator(
76
+ output_dir=self.config.output_dir,
77
+ video_resolution=self.video_resolution_num,
78
+ font_size=self.font_size,
79
+ line_height=self.line_height,
80
+ styles=self.config.styles,
81
+ logger=self.logger,
82
+ )
83
+
84
+ self.video = VideoGenerator(
85
+ output_dir=self.config.output_dir,
86
+ cache_dir=self.config.cache_dir,
87
+ video_resolution=self.video_resolution_num,
88
+ styles=self.config.styles,
89
+ logger=self.logger,
90
+ )
91
+
92
+ # Log the configured directories
93
+ self.logger.debug(f"Initialized OutputGenerator with output_dir: {self.config.output_dir}")
94
+ self.logger.debug(f"Using cache_dir: {self.config.cache_dir}")
95
+
67
96
  def generate_outputs(
68
97
  self,
69
98
  transcription_corrected: CorrectionResult,
70
99
  lyrics_results: List[LyricsData],
71
100
  output_prefix: str,
72
101
  audio_filepath: str,
73
- render_video: bool = False,
102
+ artist: Optional[str] = None,
103
+ title: Optional[str] = None,
74
104
  ) -> OutputPaths:
75
105
  """Generate all requested output formats."""
76
106
  outputs = OutputPaths()
@@ -78,122 +108,50 @@ class OutputGenerator:
78
108
  try:
79
109
  # Generate plain lyrics files for each provider
80
110
  for lyrics_data in lyrics_results:
81
- provider_name = lyrics_data.metadata.source.title()
82
- self.write_plain_lyrics(lyrics_data, f"{output_prefix} (Lyrics {provider_name})")
111
+ self.plain_text.write_lyrics(lyrics_data, output_prefix)
83
112
 
84
- if transcription_corrected:
85
- # Write corrected lyrics as plain text
86
- self.write_plain_lyrics_from_correction(transcription_corrected, f"{output_prefix} (Lyrics Corrected)")
113
+ # Write original (uncorrected) transcription
114
+ outputs.original_txt = self.plain_text.write_original_transcription(transcription_corrected, output_prefix)
87
115
 
88
- # Generate LRC
89
- outputs.lrc = self.generate_lrc(transcription_corrected, output_prefix)
116
+ # Resize corrected segments to ensure none are longer than max_line_length
117
+ resized_segments = self.segment_resizer.resize_segments(transcription_corrected.corrected_segments)
118
+ transcription_corrected.resized_segments = resized_segments
119
+ outputs.corrections_json = self.write_corrections_data(transcription_corrected, output_prefix)
90
120
 
91
- # Generate ASS
92
- outputs.ass = self.generate_ass(transcription_corrected, output_prefix)
121
+ # Write corrected lyrics as plain text
122
+ outputs.corrected_txt = self.plain_text.write_corrected_lyrics(resized_segments, output_prefix)
93
123
 
94
- # Generate video if requested
95
- if render_video:
96
- outputs.video = self.generate_video(outputs.ass, audio_filepath, output_prefix)
124
+ # Generate LRC using LyricsFileGenerator
125
+ outputs.lrc = self.lyrics_file.generate_lrc(resized_segments, output_prefix)
97
126
 
98
- except Exception as e:
99
- self.logger.error(f"Error generating outputs: {str(e)}")
100
- raise
127
+ # Generate CDG file if requested
128
+ if self.config.generate_cdg:
129
+ outputs.cdg, outputs.mp3, outputs.cdg_zip = self.cdg.generate_cdg(
130
+ segments=resized_segments,
131
+ audio_file=audio_filepath,
132
+ title=title or output_prefix,
133
+ artist=artist or "",
134
+ cdg_styles=self.config.styles["cdg"],
135
+ )
101
136
 
102
- return outputs
137
+ # Generate video if requested
138
+ if self.config.render_video:
139
+ # Generate ASS subtitles
140
+ outputs.ass = self.subtitle.generate_ass(resized_segments, output_prefix, audio_filepath)
141
+ outputs.video = self.video.generate_video(outputs.ass, audio_filepath, output_prefix)
103
142
 
104
- def _get_output_path(self, output_prefix: str, extension: str) -> str:
105
- """Generate full output path for a file."""
106
- return os.path.join(self.config.output_dir or self.config.cache_dir, f"{output_prefix}.{extension}")
107
-
108
- def generate_lrc(self, transcription_data: CorrectionResult, output_prefix: str) -> str:
109
- """Generate LRC format lyrics file."""
110
- self.logger.info("Generating LRC format lyrics")
111
- output_path = self._get_output_path(output_prefix, "lrc")
112
-
113
- try:
114
- self._write_lrc_file(output_path, transcription_data.segments)
115
- self.logger.info(f"LRC file generated: {output_path}")
116
- return output_path
143
+ return outputs
117
144
 
118
145
  except Exception as e:
119
- self.logger.error(f"Failed to generate LRC file: {str(e)}")
146
+ self.logger.error(f"Failed to generate outputs: {str(e)}")
120
147
  raise
121
148
 
122
- def _write_lrc_file(self, output_path: str, segments: list) -> None:
123
- """Write LRC file content."""
124
- with open(output_path, "w", encoding="utf-8") as f:
125
- for segment in segments:
126
- start_time = self._format_lrc_timestamp(segment.start_time)
127
- line = f"[{start_time}]{segment.text}\n"
128
- f.write(line)
129
-
130
- def generate_ass(self, transcription_data: CorrectionResult, output_prefix: str) -> str:
131
- """Generate ASS format subtitles file."""
132
- self.logger.info("Generating ASS format subtitles")
133
- output_path = self._get_output_path(output_prefix, "ass")
134
-
135
- try:
136
- self._write_ass_file(output_path, transcription_data.segments)
137
- self.logger.info(f"ASS file generated: {output_path}")
138
- return output_path
139
-
140
- except Exception as e:
141
- self.logger.error(f"Failed to generate ASS file: {str(e)}")
142
- raise
143
-
144
- def _write_ass_file(self, output_path: str, segments: list) -> None:
145
- """Write ASS file content."""
146
- with open(output_path, "w", encoding="utf-8") as f:
147
- f.write(self._get_ass_header())
148
- for segment in segments:
149
- # Change from ts/end_ts to start_time/end_time
150
- start_time = self._format_ass_timestamp(segment.start_time)
151
- end_time = self._format_ass_timestamp(segment.end_time)
152
- line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{segment.text}\n"
153
- f.write(line)
154
-
155
- def generate_video(self, ass_path: str, audio_path: str, output_prefix: str) -> str:
156
- """Generate MP4 video with lyrics overlay."""
157
- self.logger.info("Generating video with lyrics overlay")
158
- output_path = self._get_output_path(output_prefix, "mp4")
159
-
160
- try:
161
- cmd = self._build_ffmpeg_command(ass_path, audio_path, output_path)
162
- self._run_ffmpeg_command(cmd)
163
- self.logger.info(f"Video generated: {output_path}")
164
- return output_path
165
-
166
- except Exception as e:
167
- self.logger.error(f"Failed to generate video: {str(e)}")
168
- raise
169
-
170
- def _build_ffmpeg_command(self, ass_path: str, audio_path: str, output_path: str) -> list:
171
- """Build FFmpeg command for video generation."""
172
- width, height = self.video_resolution_num
173
- cmd = ["ffmpeg", "-y"]
174
-
175
- # Input source (background)
176
- if self.config.video_background_image:
177
- cmd.extend(["-i", self.config.video_background_image])
178
- else:
179
- cmd.extend(["-f", "lavfi", "-i", f"color=c={self.config.video_background_color}:s={width}x{height}"])
180
-
181
- # Add audio and subtitle inputs
182
- cmd.extend(["-i", audio_path, "-vf", f"ass={ass_path}", "-c:v", "libx264", "-c:a", "aac", "-shortest", output_path])
183
-
184
- return cmd
185
-
186
- def _run_ffmpeg_command(self, cmd: list) -> None:
187
- """Execute FFmpeg command."""
188
- self.logger.debug(f"Running FFmpeg command: {' '.join(cmd)}")
189
- try:
190
- subprocess.run(cmd, check=True)
191
- except subprocess.CalledProcessError as e:
192
- self.logger.error(f"FFmpeg error: {str(e)}")
193
- raise
149
+ def _get_output_path(self, output_prefix: str, extension: str) -> str:
150
+ """Generate full output path for a file."""
151
+ return os.path.join(self.config.output_dir or self.config.cache_dir, f"{output_prefix}.{extension}")
194
152
 
195
153
  def _get_video_params(self, resolution: str) -> tuple:
196
- """Get video parameters based on resolution setting."""
154
+ """Get video parameters: (width, height), font_size, line_height based on video resolution config."""
197
155
  match resolution:
198
156
  case "4k":
199
157
  return (3840, 2160), 250, 250
@@ -202,70 +160,20 @@ class OutputGenerator:
202
160
  case "720p":
203
161
  return (1280, 720), 100, 100
204
162
  case "360p":
205
- return (640, 360), 50, 50
163
+ return (640, 360), 40, 50
206
164
  case _:
207
165
  raise ValueError("Invalid video_resolution value. Must be one of: 4k, 1080p, 720p, 360p")
208
166
 
209
- def _format_lrc_timestamp(self, seconds: float) -> str:
210
- """Format timestamp for LRC format."""
211
- time = timedelta(seconds=seconds)
212
- minutes = int(time.total_seconds() / 60)
213
- seconds = time.total_seconds() % 60
214
- return f"{minutes:02d}:{seconds:05.2f}"
215
-
216
- def _format_ass_timestamp(self, seconds: float) -> str:
217
- """Format timestamp for ASS format."""
218
- time = timedelta(seconds=seconds)
219
- hours = int(time.total_seconds() / 3600)
220
- minutes = int((time.total_seconds() % 3600) / 60)
221
- seconds = time.total_seconds() % 60
222
- centiseconds = int((seconds % 1) * 100)
223
- seconds = int(seconds)
224
- return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
225
-
226
- def _get_ass_header(self) -> str:
227
- """Get ASS format header with style definitions."""
228
- width, height = self.video_resolution_num
229
- return f"""[Script Info]
230
- ScriptType: v4.00+
231
- PlayResX: {width}
232
- PlayResY: {height}
233
- WrapStyle: 0
234
-
235
- [V4+ Styles]
236
- Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
237
- Style: Default,Arial,{self.font_size},&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
238
-
239
- [Events]
240
- Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
241
- """
242
-
243
- def write_plain_lyrics(self, lyrics_data: LyricsData, output_prefix: str) -> str:
244
- """Write plain text lyrics file."""
245
- self.logger.info("Writing plain lyrics file")
246
- output_path = self._get_output_path(output_prefix, "txt")
167
+ def write_corrections_data(self, correction_result: CorrectionResult, output_prefix: str) -> str:
168
+ """Write corrections data to JSON file."""
169
+ self.logger.info("Writing corrections data JSON")
170
+ output_path = self._get_output_path(f"{output_prefix} (Lyrics Corrections)", "json")
247
171
 
248
172
  try:
249
173
  with open(output_path, "w", encoding="utf-8") as f:
250
- f.write(lyrics_data.lyrics)
251
- self.logger.info(f"Plain lyrics file generated: {output_path}")
174
+ json.dump(correction_result.to_dict(), f, indent=2, ensure_ascii=False)
175
+ self.logger.info(f"Corrections data JSON generated: {output_path}")
252
176
  return output_path
253
-
254
- except Exception as e:
255
- self.logger.error(f"Failed to write plain lyrics file: {str(e)}")
256
- raise
257
-
258
- def write_plain_lyrics_from_correction(self, correction_result: CorrectionResult, output_prefix: str) -> str:
259
- """Write corrected lyrics as plain text file."""
260
- self.logger.info("Writing corrected lyrics file")
261
- output_path = self._get_output_path(output_prefix, "txt")
262
-
263
- try:
264
- with open(output_path, "w", encoding="utf-8") as f:
265
- f.write(correction_result.text)
266
- self.logger.info(f"Corrected lyrics file generated: {output_path}")
267
- return output_path
268
-
269
177
  except Exception as e:
270
- self.logger.error(f"Failed to write corrected lyrics file: {str(e)}")
178
+ self.logger.error(f"Failed to write corrections data JSON: {str(e)}")
271
179
  raise
@@ -0,0 +1,102 @@
1
+ import logging
2
+ import os
3
+ from typing import List, Optional
4
+
5
+ from lyrics_transcriber.types import LyricsSegment, Word
6
+
7
+
8
+ class LyricsFileGenerator:
9
+ """Handles generation of lyrics files in various formats (LRC, etc)."""
10
+
11
+ def __init__(self, output_dir: str, logger: Optional[logging.Logger] = None):
12
+ """Initialize LyricsFileGenerator.
13
+
14
+ Args:
15
+ output_dir: Directory where output files will be written
16
+ logger: Optional logger instance
17
+ """
18
+ self.output_dir = output_dir
19
+ self.logger = logger or logging.getLogger(__name__)
20
+
21
+ def _get_output_path(self, output_prefix: str, extension: str) -> str:
22
+ """Generate full output path for a file."""
23
+ return os.path.join(self.output_dir, f"{output_prefix}.{extension}")
24
+
25
+ def generate_lrc(self, segments: List[LyricsSegment], output_prefix: str) -> str:
26
+ """Generate LRC format lyrics file.
27
+
28
+ Args:
29
+ segments: List of LyricsSegment objects containing word timing data
30
+ output_prefix: Prefix for output filename
31
+
32
+ Returns:
33
+ Path to generated LRC file
34
+ """
35
+ self.logger.info("Generating LRC format lyrics")
36
+ output_path = self._get_output_path(f"{output_prefix} (Karaoke)", "lrc")
37
+
38
+ try:
39
+ self._write_lrc_file(output_path, segments)
40
+ self.logger.info(f"LRC file generated: {output_path}")
41
+ return output_path
42
+
43
+ except Exception as e:
44
+ self.logger.error(f"Failed to generate LRC file: {str(e)}")
45
+ raise
46
+
47
+ def _write_lrc_file(self, output_path: str, segments: List[LyricsSegment]) -> None:
48
+ """Write LRC file content with MidiCo-compatible word-level timestamps.
49
+
50
+ Args:
51
+ output_path: Path to write the LRC file
52
+ segments: List of LyricsSegment objects containing word timing data
53
+ """
54
+ with open(output_path, "w", encoding="utf-8") as f:
55
+ # Write MidiCo header
56
+ f.write("[re:MidiCo]\n")
57
+
58
+ for segment in segments:
59
+ for i, word in enumerate(segment.words):
60
+ start_time = self._format_lrc_timestamp(word.start_time)
61
+
62
+ # Add space after all words except last in segment
63
+ text = word.text
64
+ if i != len(segment.words) - 1:
65
+ text += " "
66
+
67
+ # Add "/" prefix for first word in segment
68
+ prefix = "/" if i == 0 else ""
69
+
70
+ # Write MidiCo formatted line
71
+ f.write(f"[{start_time}]1:{prefix}{text}\n")
72
+
73
+ def _format_lrc_timestamp(self, seconds: float) -> str:
74
+ """Format timestamp for MidiCo LRC format (MM:SS.mmm).
75
+
76
+ Args:
77
+ seconds: Time in seconds
78
+
79
+ Returns:
80
+ Formatted timestamp string in MM:SS.mmm format
81
+ """
82
+ minutes = int(seconds // 60)
83
+ remaining_seconds = seconds % 60
84
+
85
+ # Convert to milliseconds and round to nearest integer
86
+ total_milliseconds = round(remaining_seconds * 1000)
87
+
88
+ # Extract seconds and milliseconds
89
+ seconds_part = total_milliseconds // 1000
90
+ milliseconds = total_milliseconds % 1000
91
+
92
+ # Handle rollover
93
+ if seconds_part == 60:
94
+ seconds_part = 0
95
+ minutes += 1
96
+
97
+ return f"{minutes:02d}:{seconds_part:02d}.{milliseconds:03d}"
98
+
99
+ # Future methods for other lyrics file formats can be added here
100
+ # def generate_txt(self, segments: List[LyricsSegment], output_prefix: str) -> str:
101
+ # """Generate Power Karaoke TXT format lyrics file."""
102
+ # pass
@@ -0,0 +1,91 @@
1
+ import logging
2
+ import os
3
+ from typing import List, Optional
4
+
5
+ from lyrics_transcriber.types import LyricsData, LyricsSegment
6
+ from lyrics_transcriber.correction.corrector import CorrectionResult
7
+
8
+ class PlainTextGenerator:
9
+ """Handles generation of plain text output files for lyrics and transcriptions."""
10
+
11
+ def __init__(self, output_dir: str, logger: Optional[logging.Logger] = None):
12
+ """Initialize PlainTextGenerator.
13
+
14
+ Args:
15
+ output_dir: Directory where output files will be written
16
+ logger: Optional logger instance
17
+ """
18
+ self.output_dir = output_dir
19
+ self.logger = logger or logging.getLogger(__name__)
20
+
21
+ def _get_output_path(self, output_prefix: str, extension: str) -> str:
22
+ """Generate full output path for a file."""
23
+ return os.path.join(self.output_dir, f"{output_prefix}.{extension}")
24
+
25
+ def write_lyrics(self, lyrics_data: LyricsData, output_prefix: str) -> str:
26
+ """Write plain text lyrics file from provider data.
27
+
28
+ Args:
29
+ lyrics_data: LyricsData from a lyrics provider
30
+ output_prefix: Prefix for output filename
31
+
32
+ Returns:
33
+ Path to generated file
34
+ """
35
+ self.logger.info("Writing plain lyrics file")
36
+ provider_name = lyrics_data.metadata.source.title()
37
+ output_path = self._get_output_path(f"{output_prefix} (Lyrics {provider_name})", "txt")
38
+
39
+ try:
40
+ with open(output_path, "w", encoding="utf-8") as f:
41
+ f.write(lyrics_data.lyrics)
42
+ self.logger.info(f"Plain lyrics file generated: {output_path}")
43
+ return output_path
44
+ except Exception as e:
45
+ self.logger.error(f"Failed to write plain lyrics file: {str(e)}")
46
+ raise
47
+
48
+ def write_corrected_lyrics(self, segments: List[LyricsSegment], output_prefix: str) -> str:
49
+ """Write corrected lyrics as plain text file.
50
+
51
+ Args:
52
+ segments: List of corrected LyricsSegment objects
53
+ output_prefix: Prefix for output filename
54
+
55
+ Returns:
56
+ Path to generated file
57
+ """
58
+ self.logger.info("Writing corrected lyrics file")
59
+ output_path = self._get_output_path(f"{output_prefix} (Lyrics Corrected)", "txt")
60
+
61
+ try:
62
+ with open(output_path, "w", encoding="utf-8") as f:
63
+ for segment in segments:
64
+ f.write(f"{segment.text}\n")
65
+ self.logger.info(f"Corrected lyrics file generated: {output_path}")
66
+ return output_path
67
+ except Exception as e:
68
+ self.logger.error(f"Failed to write corrected lyrics file: {str(e)}")
69
+ raise
70
+
71
+ def write_original_transcription(self, correction_result: CorrectionResult, output_prefix: str) -> str:
72
+ """Write original (uncorrected) transcription as plain text.
73
+
74
+ Args:
75
+ correction_result: CorrectionResult containing original transcription
76
+ output_prefix: Prefix for output filename
77
+
78
+ Returns:
79
+ Path to generated file
80
+ """
81
+ self.logger.info("Writing original transcription file")
82
+ output_path = self._get_output_path(f"{output_prefix} (Lyrics Uncorrected)", "txt")
83
+
84
+ try:
85
+ with open(output_path, "w", encoding="utf-8") as f:
86
+ f.write(correction_result.transcribed_text)
87
+ self.logger.info(f"Original transcription file generated: {output_path}")
88
+ return output_path
89
+ except Exception as e:
90
+ self.logger.error(f"Failed to write original transcription file: {str(e)}")
91
+ raise