lyrics-transcriber 0.30.0__py3-none-any.whl → 0.32.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. lyrics_transcriber/__init__.py +2 -1
  2. lyrics_transcriber/cli/{main.py → cli_main.py} +47 -14
  3. lyrics_transcriber/core/config.py +35 -0
  4. lyrics_transcriber/core/controller.py +164 -166
  5. lyrics_transcriber/correction/anchor_sequence.py +471 -0
  6. lyrics_transcriber/correction/corrector.py +256 -0
  7. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  8. lyrics_transcriber/correction/handlers/base.py +30 -0
  9. lyrics_transcriber/correction/handlers/extend_anchor.py +91 -0
  10. lyrics_transcriber/correction/handlers/levenshtein.py +147 -0
  11. lyrics_transcriber/correction/handlers/no_space_punct_match.py +98 -0
  12. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +55 -0
  13. lyrics_transcriber/correction/handlers/repeat.py +71 -0
  14. lyrics_transcriber/correction/handlers/sound_alike.py +223 -0
  15. lyrics_transcriber/correction/handlers/syllables_match.py +182 -0
  16. lyrics_transcriber/correction/handlers/word_count_match.py +54 -0
  17. lyrics_transcriber/correction/handlers/word_operations.py +135 -0
  18. lyrics_transcriber/correction/phrase_analyzer.py +426 -0
  19. lyrics_transcriber/correction/text_utils.py +30 -0
  20. lyrics_transcriber/lyrics/base_lyrics_provider.py +125 -0
  21. lyrics_transcriber/lyrics/genius.py +73 -0
  22. lyrics_transcriber/lyrics/spotify.py +82 -0
  23. lyrics_transcriber/output/ass/__init__.py +21 -0
  24. lyrics_transcriber/output/{ass.py → ass/ass.py} +150 -690
  25. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  26. lyrics_transcriber/output/ass/config.py +37 -0
  27. lyrics_transcriber/output/ass/constants.py +23 -0
  28. lyrics_transcriber/output/ass/event.py +94 -0
  29. lyrics_transcriber/output/ass/formatters.py +132 -0
  30. lyrics_transcriber/output/ass/lyrics_line.py +219 -0
  31. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  32. lyrics_transcriber/output/ass/section_detector.py +89 -0
  33. lyrics_transcriber/output/ass/section_screen.py +106 -0
  34. lyrics_transcriber/output/ass/style.py +187 -0
  35. lyrics_transcriber/output/cdg.py +503 -0
  36. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  37. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  38. lyrics_transcriber/output/cdgmaker/composer.py +1919 -0
  39. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  40. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  41. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  42. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  43. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  44. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  45. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  46. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  47. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  48. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  49. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  50. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  51. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  52. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  53. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  54. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  55. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  56. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  57. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  58. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  59. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  60. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  61. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  62. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  63. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  64. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  65. lyrics_transcriber/output/generator.py +140 -171
  66. lyrics_transcriber/output/lyrics_file.py +102 -0
  67. lyrics_transcriber/output/plain_text.py +91 -0
  68. lyrics_transcriber/output/segment_resizer.py +416 -0
  69. lyrics_transcriber/output/subtitles.py +328 -302
  70. lyrics_transcriber/output/video.py +219 -0
  71. lyrics_transcriber/review/__init__.py +1 -0
  72. lyrics_transcriber/review/server.py +138 -0
  73. lyrics_transcriber/storage/dropbox.py +110 -134
  74. lyrics_transcriber/transcribers/audioshake.py +171 -105
  75. lyrics_transcriber/transcribers/base_transcriber.py +149 -0
  76. lyrics_transcriber/transcribers/whisper.py +267 -133
  77. lyrics_transcriber/types.py +454 -0
  78. {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/METADATA +14 -3
  79. lyrics_transcriber-0.32.1.dist-info/RECORD +86 -0
  80. {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/WHEEL +1 -1
  81. lyrics_transcriber-0.32.1.dist-info/entry_points.txt +4 -0
  82. lyrics_transcriber/core/corrector.py +0 -56
  83. lyrics_transcriber/core/fetcher.py +0 -143
  84. lyrics_transcriber/storage/tokens.py +0 -116
  85. lyrics_transcriber/transcribers/base.py +0 -31
  86. lyrics_transcriber-0.30.0.dist-info/RECORD +0 -22
  87. lyrics_transcriber-0.30.0.dist-info/entry_points.txt +0 -3
  88. {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,219 @@
1
+ import logging
2
+ import os
3
+ import json
4
+ import subprocess
5
+ from typing import List, Optional, Tuple
6
+
7
+
8
+ class VideoGenerator:
9
+ """Handles generation of video files with lyrics overlay."""
10
+
11
+ def __init__(
12
+ self,
13
+ output_dir: str,
14
+ cache_dir: str,
15
+ video_resolution: Tuple[int, int],
16
+ styles: dict,
17
+ logger: Optional[logging.Logger] = None,
18
+ ):
19
+ """Initialize VideoGenerator.
20
+
21
+ Args:
22
+ output_dir: Directory where output files will be written
23
+ cache_dir: Directory for temporary files
24
+ video_resolution: Tuple of (width, height) for video resolution
25
+ styles: Dictionary of output video & CDG styling configuration
26
+ logger: Optional logger instance
27
+ """
28
+ if not all(x > 0 for x in video_resolution):
29
+ raise ValueError("Video resolution dimensions must be greater than 0")
30
+
31
+ self.output_dir = output_dir
32
+ self.cache_dir = cache_dir
33
+ self.video_resolution = video_resolution
34
+ self.logger = logger or logging.getLogger(__name__)
35
+
36
+ # Get background settings from styles, with defaults
37
+ karaoke_styles = styles.get("karaoke", {})
38
+ self.background_image = karaoke_styles.get("background_image")
39
+ self.background_color = karaoke_styles.get("background_color", "black")
40
+
41
+ # Validate background image if specified
42
+ if self.background_image and not os.path.isfile(self.background_image):
43
+ raise FileNotFoundError(f"Video background image not found: {self.background_image}")
44
+
45
+ def generate_video(self, ass_path: str, audio_path: str, output_prefix: str) -> str:
46
+ """Generate MP4 video with lyrics overlay.
47
+
48
+ Args:
49
+ ass_path: Path to ASS subtitles file
50
+ audio_path: Path to audio file
51
+ output_prefix: Prefix for output filename
52
+
53
+ Returns:
54
+ Path to generated video file
55
+ """
56
+ self.logger.info("Generating video with lyrics overlay")
57
+ output_path = self._get_output_path(f"{output_prefix} (With Vocals)", "mkv")
58
+
59
+ # Check input files exist before running FFmpeg
60
+ if not os.path.isfile(ass_path):
61
+ raise FileNotFoundError(f"Subtitles file not found: {ass_path}")
62
+ if not os.path.isfile(audio_path):
63
+ raise FileNotFoundError(f"Audio file not found: {audio_path}")
64
+
65
+ try:
66
+ # Create a temporary copy of the ASS file with a safe filename
67
+ temp_ass_path = os.path.join(self.cache_dir, "temp_subtitles.ass")
68
+ import shutil
69
+
70
+ shutil.copy2(ass_path, temp_ass_path)
71
+ self.logger.debug(f"Created temporary ASS file: {temp_ass_path}")
72
+
73
+ cmd = self._build_ffmpeg_command(temp_ass_path, audio_path, output_path)
74
+ self._run_ffmpeg_command(cmd)
75
+ self.logger.info(f"Video generated: {output_path}")
76
+
77
+ # Clean up temporary file
78
+ os.remove(temp_ass_path)
79
+ return output_path
80
+
81
+ except Exception as e:
82
+ self.logger.error(f"Failed to generate video: {str(e)}")
83
+ # Clean up temporary file in case of error
84
+ if "temp_ass_path" in locals():
85
+ try:
86
+ os.remove(temp_ass_path)
87
+ except:
88
+ pass
89
+ raise
90
+
91
+ def _get_output_path(self, output_prefix: str, extension: str) -> str:
92
+ """Generate full output path for a file."""
93
+ return os.path.join(self.output_dir, f"{output_prefix}.{extension}")
94
+
95
+ def _resize_background_image(self, input_path: str) -> str:
96
+ """Resize background image to match target resolution and save to temp file."""
97
+ target_width, target_height = self.video_resolution
98
+
99
+ # Get current image dimensions using ffprobe
100
+ try:
101
+ probe_cmd = [
102
+ "ffprobe",
103
+ "-v",
104
+ "error",
105
+ "-select_streams",
106
+ "v:0",
107
+ "-show_entries",
108
+ "stream=width,height",
109
+ "-of",
110
+ "json",
111
+ input_path,
112
+ ]
113
+ probe_output = subprocess.check_output(probe_cmd, universal_newlines=True)
114
+ probe_data = json.loads(probe_output)
115
+ current_width = probe_data["streams"][0]["width"]
116
+ current_height = probe_data["streams"][0]["height"]
117
+
118
+ # If dimensions already match, return original path
119
+ if current_width == target_width and current_height == target_height:
120
+ self.logger.debug("Background image already at target resolution")
121
+ return input_path
122
+
123
+ except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
124
+ self.logger.warning(f"Failed to get image dimensions: {e}")
125
+ # Continue with resize attempt if probe fails
126
+
127
+ temp_path = os.path.join(self.cache_dir, "resized_background.png")
128
+ cmd = [
129
+ "ffmpeg",
130
+ "-y",
131
+ "-i",
132
+ input_path,
133
+ "-vf",
134
+ f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
135
+ f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2",
136
+ temp_path,
137
+ ]
138
+
139
+ try:
140
+ subprocess.check_output(cmd, stderr=subprocess.STDOUT, universal_newlines=True)
141
+ return temp_path
142
+ except subprocess.CalledProcessError as e:
143
+ self.logger.error(f"Failed to resize background image: {e.output}")
144
+ raise
145
+
146
+ def _build_ffmpeg_command(self, ass_path: str, audio_path: str, output_path: str) -> List[str]:
147
+ """Build FFmpeg command for video generation with optimized settings."""
148
+ width, height = self.video_resolution
149
+
150
+ # fmt: off
151
+ cmd = [
152
+ "ffmpeg",
153
+ "-hide_banner",
154
+ "-loglevel", "error",
155
+ "-r", "30", # Set frame rate to 30 fps
156
+ ]
157
+
158
+ # Input source (background)
159
+ if self.background_image:
160
+ # Resize background image first
161
+ resized_bg = self._resize_background_image(self.background_image)
162
+ self.logger.debug(f"Using resized background image: {resized_bg}")
163
+ cmd.extend([
164
+ "-loop", "1", # Loop the image
165
+ "-i", resized_bg,
166
+ ])
167
+ else:
168
+ self.logger.debug(
169
+ f"Using solid {self.background_color} background "
170
+ f"with resolution: {width}x{height}"
171
+ )
172
+ cmd.extend([
173
+ "-f", "lavfi",
174
+ "-i", f"color=c={self.background_color}:s={width}x{height}:r=30"
175
+ ])
176
+
177
+ # Add audio input and subtitle overlay
178
+ cmd.extend([
179
+ "-i", audio_path,
180
+ "-c:a", "flac", # Re-encode audio as FLAC
181
+ "-vf", f"ass={ass_path}", # Add subtitles
182
+ "-c:v", self._get_video_codec(),
183
+ # Video quality settings
184
+ "-preset", "slow", # Better compression efficiency
185
+ "-b:v", "5000k", # Base video bitrate
186
+ "-minrate", "5000k", # Minimum bitrate
187
+ "-maxrate", "20000k", # Maximum bitrate
188
+ "-bufsize", "10000k", # Buffer size (2x base rate)
189
+ "-shortest", # End encoding after shortest stream
190
+ "-y", # Overwrite output without asking
191
+ ])
192
+ # fmt: on
193
+
194
+ # Add output path
195
+ cmd.append(output_path)
196
+
197
+ return cmd
198
+
199
+ def _get_video_codec(self) -> str:
200
+ """Determine the best available video codec."""
201
+ try:
202
+ ffmpeg_codes = subprocess.getoutput("ffmpeg -codecs")
203
+ if "h264_videotoolbox" in ffmpeg_codes:
204
+ self.logger.info("Using hardware accelerated h264_videotoolbox")
205
+ return "h264_videotoolbox"
206
+ except Exception as e:
207
+ self.logger.warning(f"Error checking for hardware acceleration: {e}")
208
+
209
+ return "libx264"
210
+
211
+ def _run_ffmpeg_command(self, cmd: List[str]) -> None:
212
+ """Execute FFmpeg command with output handling."""
213
+ self.logger.debug(f"Running FFmpeg command: {' '.join(cmd)}")
214
+ try:
215
+ output = subprocess.check_output(cmd, universal_newlines=True, stderr=subprocess.STDOUT)
216
+ # self.logger.debug(f"FFmpeg output: {output}")
217
+ except subprocess.CalledProcessError as e:
218
+ self.logger.error(f"FFmpeg error: {e.output}")
219
+ raise
@@ -0,0 +1 @@
1
+ from .server import start_review_server, complete_review
@@ -0,0 +1,138 @@
1
+ import logging
2
+ from fastapi import FastAPI, Body
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from typing import Optional, Dict, Any
5
+ from ..types import CorrectionResult
6
+ import time
7
+ import subprocess
8
+ import os
9
+ import atexit
10
+ import urllib.parse
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ app = FastAPI()
15
+
16
+ # Configure CORS for development
17
+ app.add_middleware(
18
+ CORSMiddleware,
19
+ allow_origins=["http://localhost:5173"], # Vite's default dev server port
20
+ allow_credentials=True,
21
+ allow_methods=["*"],
22
+ allow_headers=["*"],
23
+ )
24
+
25
+ # Global state for the review process
26
+ current_review: Optional[CorrectionResult] = None
27
+ review_completed = False
28
+ vite_process: Optional[subprocess.Popen] = None
29
+
30
+
31
+ def start_vite_server():
32
+ """Start the Vite development server."""
33
+ global vite_process
34
+
35
+ # Get the path to the lyrics-analyzer directory relative to this file
36
+ current_dir = os.path.dirname(os.path.abspath(__file__))
37
+ vite_dir = os.path.abspath(os.path.join(current_dir, "../../lyrics-analyzer"))
38
+
39
+ logger.info(f"Starting Vite dev server in {vite_dir}")
40
+
41
+ # Start the Vite dev server
42
+ vite_process = subprocess.Popen(["npm", "run", "dev"], cwd=vite_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
43
+
44
+ # Register cleanup function to kill Vite server on exit
45
+ atexit.register(lambda: vite_process.terminate() if vite_process else None)
46
+
47
+ # Wait a bit for the server to start
48
+ time.sleep(2) # Adjust this if needed
49
+
50
+ return vite_process
51
+
52
+
53
+ @app.get("/api/correction-data")
54
+ async def get_correction_data():
55
+ """Get the current correction data for review."""
56
+ if current_review is None:
57
+ return {"error": "No review in progress"}
58
+ return current_review.to_dict()
59
+
60
+
61
+ @app.post("/api/complete")
62
+ async def complete_review(updated_data: Dict[str, Any] = Body(...)):
63
+ """
64
+ Mark the review as complete and update the correction data.
65
+
66
+ Args:
67
+ updated_data: The complete correction result data with any modifications
68
+ """
69
+ global review_completed, current_review
70
+
71
+ logger.info("Received updated correction data")
72
+
73
+ try:
74
+ # Update the current review with modified data
75
+ # We use from_dict to ensure the data is properly structured
76
+ current_review = CorrectionResult.from_dict(updated_data)
77
+ logger.info(f"Successfully updated correction data with {len(current_review.corrections)} corrections")
78
+
79
+ review_completed = True
80
+ return {"status": "success"}
81
+ except Exception as e:
82
+ logger.error(f"Failed to update correction data: {str(e)}")
83
+ return {"status": "error", "message": str(e)}
84
+
85
+
86
+ def start_review_server(correction_result: CorrectionResult) -> CorrectionResult:
87
+ """
88
+ Start the review server and wait for completion.
89
+
90
+ Args:
91
+ correction_result: The correction result to review
92
+
93
+ Returns:
94
+ The potentially modified correction result after review
95
+ """
96
+ import uvicorn
97
+ import webbrowser
98
+ from threading import Thread
99
+
100
+ global current_review, review_completed
101
+ current_review = correction_result
102
+ review_completed = False
103
+
104
+ logger.info("Starting review server...")
105
+
106
+ # Start Vite dev server
107
+ vite_proc = start_vite_server()
108
+ logger.info("Vite dev server started")
109
+
110
+ # Start FastAPI server in a separate thread
111
+ server_thread = Thread(target=uvicorn.run, args=(app,), kwargs={"host": "127.0.0.1", "port": 8000, "log_level": "info"}, daemon=True)
112
+ server_thread.start()
113
+ logger.info("Server thread started")
114
+
115
+ # Open browser
116
+ base_api_url = "http://localhost:8000/api"
117
+ encoded_api_url = urllib.parse.quote(base_api_url, safe="")
118
+ webbrowser.open(f"http://localhost:5173?baseApiUrl={encoded_api_url}")
119
+ logger.info("Opened browser for review")
120
+
121
+ # Wait for review to complete
122
+ start_time = time.time()
123
+ while not review_completed:
124
+ time.sleep(0.1)
125
+ # if time.time() - start_time > 600: # 10 minute timeout
126
+ # logger.error("Review timed out after 10 minutes")
127
+ # raise TimeoutError("Review did not complete within the expected time frame.")
128
+
129
+ # Clean up Vite server
130
+ if vite_proc:
131
+ vite_proc.terminate()
132
+ try:
133
+ vite_proc.wait(timeout=5)
134
+ except subprocess.TimeoutExpired:
135
+ vite_proc.kill()
136
+
137
+ logger.info("Review completed, returning results")
138
+ return current_review