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
@@ -1,9 +1,33 @@
1
+ from dataclasses import dataclass
1
2
  import os
2
3
  import logging
3
- from typing import Dict, Any, Optional
4
- import subprocess
5
- from datetime import timedelta
6
- from .subtitles import create_styled_subtitles, LyricsScreen, LyricsLine, LyricSegment
4
+ from typing import List, Optional
5
+ import json
6
+
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
16
+
17
+
18
+ @dataclass
19
+ class OutputPaths:
20
+ """Holds paths for generated output files."""
21
+
22
+ lrc: Optional[str] = None
23
+ ass: Optional[str] = None
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
7
31
 
8
32
 
9
33
  class OutputGenerator:
@@ -11,158 +35,123 @@ class OutputGenerator:
11
35
 
12
36
  def __init__(
13
37
  self,
38
+ config: OutputConfig,
14
39
  logger: Optional[logging.Logger] = None,
15
- output_dir: Optional[str] = None,
16
- cache_dir: str = "/tmp/lyrics-transcriber-cache/",
17
- video_resolution: str = "360p",
18
- video_background_image: Optional[str] = None,
19
- video_background_color: str = "black",
20
40
  ):
21
- self.logger = logger or logging.getLogger(__name__)
22
- self.output_dir = output_dir
23
- self.cache_dir = cache_dir
24
-
25
- # Video settings
26
- self.video_resolution = video_resolution
27
- self.video_background_image = video_background_image
28
- self.video_background_color = video_background_color
29
-
30
- # Set video resolution parameters
31
- self.video_resolution_num, self.font_size, self.line_height = self._get_video_params(video_resolution)
32
-
33
- # Validate video background if provided
34
- if self.video_background_image and not os.path.isfile(self.video_background_image):
35
- raise FileNotFoundError(f"Video background image not found: {self.video_background_image}")
36
-
37
- def generate_outputs(
38
- self, transcription_data: Dict[str, Any], output_prefix: str, audio_filepath: str, render_video: bool = False
39
- ) -> Dict[str, str]:
40
41
  """
41
- Generate all requested output formats.
42
+ Initialize OutputGenerator with configuration.
42
43
 
43
44
  Args:
44
- transcription_data: Dictionary containing transcription segments with timing
45
- output_prefix: Prefix for output filenames
46
- audio_filepath: Path to the source audio file
47
- render_video: Whether to generate video output
48
-
49
- Returns:
50
- Dictionary of output paths for each format
45
+ config: OutputConfig instance with required paths and settings
46
+ logger: Optional logger instance
51
47
  """
52
- outputs = {}
53
-
54
- try:
55
- # Generate LRC
56
- lrc_path = self.generate_lrc(transcription_data, output_prefix)
57
- outputs["lrc"] = lrc_path
58
-
59
- # Generate ASS
60
- ass_path = self.generate_ass(transcription_data, output_prefix)
61
- outputs["ass"] = ass_path
62
-
63
- # Generate video if requested
64
- if render_video:
65
- video_path = self.generate_video(ass_path, audio_filepath, output_prefix)
66
- outputs["video"] = video_path
67
-
68
- except Exception as e:
69
- self.logger.error(f"Error generating outputs: {str(e)}")
70
- raise
71
-
72
- return outputs
73
-
74
- def generate_lrc(self, transcription_data: Dict[str, Any], output_prefix: str) -> str:
75
- """Generate LRC format lyrics file."""
76
- self.logger.info("Generating LRC format lyrics")
77
-
78
- output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.lrc")
79
-
80
- try:
81
- with open(output_path, "w", encoding="utf-8") as f:
82
- for segment in transcription_data["segments"]:
83
- start_time = self._format_lrc_timestamp(segment["start"])
84
- line = f"[{start_time}]{segment['text']}\n"
85
- f.write(line)
86
-
87
- self.logger.info(f"LRC file generated: {output_path}")
88
- return output_path
48
+ self.config = config
49
+ self.logger = logger or logging.getLogger(__name__)
89
50
 
90
- except Exception as e:
91
- self.logger.error(f"Failed to generate LRC file: {str(e)}")
92
- raise
51
+ self.logger.debug(f"Initializing OutputGenerator with config: {self.config}")
93
52
 
94
- def generate_ass(self, transcription_data: Dict[str, Any], output_prefix: str) -> str:
95
- """Generate ASS format subtitles file."""
96
- self.logger.info("Generating ASS format subtitles")
53
+ # Set video resolution parameters
54
+ self.video_resolution_num, self.font_size, self.line_height = self._get_video_params(self.config.video_resolution)
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}")
97
95
 
98
- output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.ass")
96
+ def generate_outputs(
97
+ self,
98
+ transcription_corrected: CorrectionResult,
99
+ lyrics_results: List[LyricsData],
100
+ output_prefix: str,
101
+ audio_filepath: str,
102
+ artist: Optional[str] = None,
103
+ title: Optional[str] = None,
104
+ ) -> OutputPaths:
105
+ """Generate all requested output formats."""
106
+ outputs = OutputPaths()
99
107
 
100
108
  try:
101
- with open(output_path, "w", encoding="utf-8") as f:
102
- # Write ASS header
103
- f.write(self._get_ass_header())
109
+ # Generate plain lyrics files for each provider
110
+ for lyrics_data in lyrics_results:
111
+ self.plain_text.write_lyrics(lyrics_data, output_prefix)
112
+
113
+ # Write original (uncorrected) transcription
114
+ outputs.original_txt = self.plain_text.write_original_transcription(transcription_corrected, output_prefix)
115
+
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)
120
+
121
+ # Write corrected lyrics as plain text
122
+ outputs.corrected_txt = self.plain_text.write_corrected_lyrics(resized_segments, output_prefix)
123
+
124
+ # Generate LRC using LyricsFileGenerator
125
+ outputs.lrc = self.lyrics_file.generate_lrc(resized_segments, output_prefix)
126
+
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
+ )
104
136
 
105
- # Write events
106
- for segment in transcription_data["segments"]:
107
- start_time = self._format_ass_timestamp(segment["start"])
108
- end_time = self._format_ass_timestamp(segment["end"])
109
- line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{segment['text']}\n"
110
- f.write(line)
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)
111
142
 
112
- self.logger.info(f"ASS file generated: {output_path}")
113
- return output_path
143
+ return outputs
114
144
 
115
145
  except Exception as e:
116
- self.logger.error(f"Failed to generate ASS file: {str(e)}")
146
+ self.logger.error(f"Failed to generate outputs: {str(e)}")
117
147
  raise
118
148
 
119
- def generate_video(self, ass_path: str, audio_path: str, output_prefix: str) -> str:
120
- """Generate MP4 video with lyrics overlay."""
121
- self.logger.info("Generating video with lyrics overlay")
122
-
123
- output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.mp4")
124
- width, height = self.video_resolution_num
125
-
126
- try:
127
- # Prepare FFmpeg command
128
- cmd = [
129
- "ffmpeg",
130
- "-y",
131
- "-f",
132
- "lavfi",
133
- "-i",
134
- f"color=c={self.video_background_color}:s={width}x{height}",
135
- "-i",
136
- audio_path,
137
- "-vf",
138
- f"ass={ass_path}",
139
- "-c:v",
140
- "libx264",
141
- "-c:a",
142
- "aac",
143
- "-shortest",
144
- output_path,
145
- ]
146
-
147
- # If background image provided, use it instead of solid color
148
- if self.video_background_image:
149
- cmd[3:6] = ["-i", self.video_background_image]
150
-
151
- self.logger.debug(f"Running FFmpeg command: {' '.join(cmd)}")
152
- subprocess.run(cmd, check=True)
153
-
154
- self.logger.info(f"Video generated: {output_path}")
155
- return output_path
156
-
157
- except subprocess.CalledProcessError as e:
158
- self.logger.error(f"FFmpeg error: {str(e)}")
159
- raise
160
- except Exception as e:
161
- self.logger.error(f"Failed to generate video: {str(e)}")
162
- 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}")
163
152
 
164
153
  def _get_video_params(self, resolution: str) -> tuple:
165
- """Get video parameters based on resolution setting."""
154
+ """Get video parameters: (width, height), font_size, line_height based on video resolution config."""
166
155
  match resolution:
167
156
  case "4k":
168
157
  return (3840, 2160), 250, 250
@@ -171,40 +160,20 @@ class OutputGenerator:
171
160
  case "720p":
172
161
  return (1280, 720), 100, 100
173
162
  case "360p":
174
- return (640, 360), 50, 50
163
+ return (640, 360), 40, 50
175
164
  case _:
176
165
  raise ValueError("Invalid video_resolution value. Must be one of: 4k, 1080p, 720p, 360p")
177
166
 
178
- def _format_lrc_timestamp(self, seconds: float) -> str:
179
- """Format timestamp for LRC format."""
180
- time = timedelta(seconds=seconds)
181
- minutes = int(time.total_seconds() / 60)
182
- seconds = time.total_seconds() % 60
183
- return f"{minutes:02d}:{seconds:05.2f}"
184
-
185
- def _format_ass_timestamp(self, seconds: float) -> str:
186
- """Format timestamp for ASS format."""
187
- time = timedelta(seconds=seconds)
188
- hours = int(time.total_seconds() / 3600)
189
- minutes = int((time.total_seconds() % 3600) / 60)
190
- seconds = time.total_seconds() % 60
191
- centiseconds = int((seconds % 1) * 100)
192
- seconds = int(seconds)
193
- return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
194
-
195
- def _get_ass_header(self) -> str:
196
- """Get ASS format header with style definitions."""
197
- width, height = self.video_resolution_num
198
- return f"""[Script Info]
199
- ScriptType: v4.00+
200
- PlayResX: {width}
201
- PlayResY: {height}
202
- WrapStyle: 0
203
-
204
- [V4+ Styles]
205
- Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
206
- 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
207
-
208
- [Events]
209
- Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
210
- """
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")
171
+
172
+ try:
173
+ with open(output_path, "w", encoding="utf-8") as f:
174
+ json.dump(correction_result.to_dict(), f, indent=2, ensure_ascii=False)
175
+ self.logger.info(f"Corrections data JSON generated: {output_path}")
176
+ return output_path
177
+ except Exception as e:
178
+ self.logger.error(f"Failed to write corrections data JSON: {str(e)}")
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