karaoke-gen 0.75.53__py3-none-any.whl → 0.81.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 (50) hide show
  1. karaoke_gen/audio_fetcher.py +218 -0
  2. karaoke_gen/instrumental_review/static/index.html +179 -16
  3. karaoke_gen/karaoke_gen.py +191 -25
  4. karaoke_gen/lyrics_processor.py +39 -31
  5. karaoke_gen/utils/__init__.py +26 -0
  6. karaoke_gen/utils/cli_args.py +9 -1
  7. karaoke_gen/utils/gen_cli.py +1 -1
  8. karaoke_gen/utils/remote_cli.py +33 -6
  9. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/METADATA +80 -4
  10. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/RECORD +50 -43
  11. lyrics_transcriber/core/config.py +8 -0
  12. lyrics_transcriber/core/controller.py +43 -1
  13. lyrics_transcriber/correction/agentic/providers/config.py +6 -0
  14. lyrics_transcriber/correction/agentic/providers/model_factory.py +24 -1
  15. lyrics_transcriber/correction/agentic/router.py +17 -13
  16. lyrics_transcriber/frontend/.gitignore +1 -0
  17. lyrics_transcriber/frontend/e2e/agentic-corrections.spec.ts +207 -0
  18. lyrics_transcriber/frontend/e2e/fixtures/agentic-correction-data.json +226 -0
  19. lyrics_transcriber/frontend/index.html +5 -1
  20. lyrics_transcriber/frontend/package-lock.json +4553 -0
  21. lyrics_transcriber/frontend/package.json +7 -1
  22. lyrics_transcriber/frontend/playwright.config.ts +69 -0
  23. lyrics_transcriber/frontend/public/nomad-karaoke-logo.svg +5 -0
  24. lyrics_transcriber/frontend/src/App.tsx +88 -59
  25. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +55 -21
  26. lyrics_transcriber/frontend/src/components/AppHeader.tsx +65 -0
  27. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +39 -35
  28. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +9 -9
  29. lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -1
  30. lyrics_transcriber/frontend/src/components/EditWordList.tsx +1 -1
  31. lyrics_transcriber/frontend/src/components/Header.tsx +96 -3
  32. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +120 -3
  33. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +22 -21
  34. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  35. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +12 -2
  36. lyrics_transcriber/frontend/src/components/WordDivider.tsx +3 -3
  37. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +122 -35
  38. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +2 -2
  39. lyrics_transcriber/frontend/src/components/shared/constants.ts +15 -5
  40. lyrics_transcriber/frontend/src/components/shared/types.ts +6 -0
  41. lyrics_transcriber/frontend/src/main.tsx +1 -7
  42. lyrics_transcriber/frontend/src/theme.ts +337 -135
  43. lyrics_transcriber/frontend/vite.config.ts +5 -0
  44. lyrics_transcriber/frontend/yarn.lock +1005 -1046
  45. lyrics_transcriber/output/generator.py +50 -3
  46. lyrics_transcriber/review/server.py +1 -1
  47. lyrics_transcriber/transcribers/local_whisper.py +260 -0
  48. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/WHEEL +0 -0
  49. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/entry_points.txt +0 -0
  50. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/licenses/LICENSE +0 -0
@@ -52,7 +52,7 @@ class OutputGenerator:
52
52
 
53
53
  self.logger.info(f"Initializing OutputGenerator with config: {self.config}")
54
54
 
55
- # Load output styles from JSON if provided
55
+ # Load output styles from JSON if provided, otherwise use defaults
56
56
  if self.config.output_styles_json and os.path.exists(self.config.output_styles_json):
57
57
  try:
58
58
  with open(self.config.output_styles_json, "r") as f:
@@ -67,9 +67,10 @@ class OutputGenerator:
67
67
  self.logger.warning(f"Failed to load output styles file: {str(e)}")
68
68
  self.config.styles = {}
69
69
  else:
70
- # No styles file provided or doesn't exist
70
+ # No styles file provided or doesn't exist - use defaults
71
71
  if self.config.render_video or self.config.generate_cdg:
72
- raise ValueError(f"Output styles file required for video/CDG generation but not found: {self.config.output_styles_json}")
72
+ self.logger.info("No output styles file provided, using default karaoke styles")
73
+ self.config.styles = self._get_default_styles()
73
74
  else:
74
75
  self.config.styles = {}
75
76
 
@@ -242,6 +243,52 @@ class OutputGenerator:
242
243
 
243
244
  return resolution_dims, font_size, line_height
244
245
 
246
+ def _get_default_styles(self) -> dict:
247
+ """Get default styles for video/CDG generation when no styles file is provided."""
248
+ return {
249
+ "karaoke": {
250
+ # Video background
251
+ "background_color": "#000000",
252
+ "background_image": None,
253
+ # Font settings
254
+ "font": "Arial",
255
+ "font_path": "", # Must be string, not None (for ASS generator)
256
+ "ass_name": "Default",
257
+ # Colors in "R, G, B, A" format (required by ASS)
258
+ "primary_color": "112, 112, 247, 255",
259
+ "secondary_color": "255, 255, 255, 255",
260
+ "outline_color": "26, 58, 235, 255",
261
+ "back_color": "0, 0, 0, 0",
262
+ # Boolean style options
263
+ "bold": False,
264
+ "italic": False,
265
+ "underline": False,
266
+ "strike_out": False,
267
+ # Numeric style options (all required for ASS)
268
+ "scale_x": 100,
269
+ "scale_y": 100,
270
+ "spacing": 0,
271
+ "angle": 0.0,
272
+ "border_style": 1,
273
+ "outline": 1,
274
+ "shadow": 0,
275
+ "margin_l": 0,
276
+ "margin_r": 0,
277
+ "margin_v": 0,
278
+ "encoding": 0,
279
+ # Layout settings
280
+ "max_line_length": 40,
281
+ "top_padding": 200,
282
+ "font_size": 100,
283
+ },
284
+ "cdg": {
285
+ "font_path": None,
286
+ "instrumental_background": None,
287
+ "title_screen_background": None,
288
+ "outro_background": None,
289
+ },
290
+ }
291
+
245
292
  def write_corrections_data(self, correction_result: CorrectionResult, output_prefix: str) -> str:
246
293
  """Write corrections data to JSON file."""
247
294
  self.logger.info("Writing corrections data JSON")
@@ -110,7 +110,7 @@ class ReviewServer:
110
110
  allowed_origins = (
111
111
  [f"http://localhost:{port}" for port in range(3000, 5174)]
112
112
  + [f"http://127.0.0.1:{port}" for port in range(3000, 5174)]
113
- + ["https://lyrics.nomadkaraoke.com"]
113
+ + ["https://gen.nomadkaraoke.com"]
114
114
  )
115
115
 
116
116
  # Also allow custom review UI URL if set
@@ -0,0 +1,260 @@
1
+ """Local Whisper transcription service using whisper-timestamped for word-level timestamps."""
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ import logging
6
+ from typing import Optional, Dict, Any, Union
7
+ from pathlib import Path
8
+
9
+ from lyrics_transcriber.types import TranscriptionData, LyricsSegment, Word
10
+ from lyrics_transcriber.transcribers.base_transcriber import BaseTranscriber, TranscriptionError
11
+ from lyrics_transcriber.utils.word_utils import WordUtils
12
+
13
+
14
+ @dataclass
15
+ class LocalWhisperConfig:
16
+ """Configuration for local Whisper transcription service."""
17
+
18
+ model_size: str = "medium" # tiny, base, small, medium, large, large-v2, large-v3
19
+ device: Optional[str] = None # None for auto-detect, or "cpu", "cuda", "mps"
20
+ cache_dir: Optional[str] = None # Directory for model downloads (~/.cache/whisper by default)
21
+ language: Optional[str] = None # Language code for transcription, None for auto-detect
22
+ compute_type: str = "auto" # float16, float32, int8, auto
23
+
24
+
25
+ class LocalWhisperTranscriber(BaseTranscriber):
26
+ """
27
+ Transcription service using local Whisper inference via whisper-timestamped.
28
+
29
+ This transcriber runs Whisper models locally on your machine, supporting
30
+ CPU, CUDA GPU, and Apple Silicon MPS acceleration. It uses the
31
+ whisper-timestamped library to get accurate word-level timestamps.
32
+
33
+ Requirements:
34
+ pip install karaoke-gen[local-whisper]
35
+
36
+ Configuration:
37
+ Set environment variables to customize behavior:
38
+ - WHISPER_MODEL_SIZE: Model size (tiny, base, small, medium, large)
39
+ - WHISPER_DEVICE: Device to use (cpu, cuda, mps, or auto)
40
+ - WHISPER_CACHE_DIR: Directory for model downloads
41
+ - WHISPER_LANGUAGE: Language code (en, es, fr, etc.) or auto-detect
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ cache_dir: Union[str, Path],
47
+ config: Optional[LocalWhisperConfig] = None,
48
+ logger: Optional[logging.Logger] = None,
49
+ ):
50
+ """
51
+ Initialize local Whisper transcriber.
52
+
53
+ Args:
54
+ cache_dir: Directory for caching transcription results
55
+ config: Configuration options for the transcriber
56
+ logger: Logger instance to use
57
+ """
58
+ super().__init__(cache_dir=cache_dir, logger=logger)
59
+
60
+ # Initialize configuration from env vars or defaults
61
+ self.config = config or LocalWhisperConfig(
62
+ model_size=os.getenv("WHISPER_MODEL_SIZE", "medium"),
63
+ device=os.getenv("WHISPER_DEVICE"), # None for auto-detect
64
+ cache_dir=os.getenv("WHISPER_CACHE_DIR"),
65
+ language=os.getenv("WHISPER_LANGUAGE"), # None for auto-detect
66
+ )
67
+
68
+ # Lazy-loaded model instance (loaded on first use)
69
+ self._model = None
70
+ self._whisper_module = None
71
+
72
+ self.logger.debug(
73
+ f"LocalWhisperTranscriber initialized with model_size={self.config.model_size}, "
74
+ f"device={self.config.device or 'auto'}, language={self.config.language or 'auto-detect'}"
75
+ )
76
+
77
+ def get_name(self) -> str:
78
+ """Return the name of this transcription service."""
79
+ return "LocalWhisper"
80
+
81
+ def _check_dependencies(self) -> None:
82
+ """Check that required dependencies are installed."""
83
+ try:
84
+ import whisper_timestamped # noqa: F401
85
+ except ImportError:
86
+ raise TranscriptionError(
87
+ "whisper-timestamped is not installed. "
88
+ "Install it with: pip install karaoke-gen[local-whisper] "
89
+ "or: pip install whisper-timestamped"
90
+ )
91
+
92
+ def _get_device(self) -> str:
93
+ """Determine the best device to use for inference."""
94
+ if self.config.device:
95
+ return self.config.device
96
+
97
+ # Auto-detect best available device
98
+ try:
99
+ import torch
100
+
101
+ if torch.cuda.is_available():
102
+ self.logger.info("Using CUDA GPU for Whisper inference")
103
+ return "cuda"
104
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
105
+ self.logger.info("Using Apple Silicon MPS for Whisper inference")
106
+ return "cpu" # whisper-timestamped works better with CPU on MPS
107
+ else:
108
+ self.logger.info("Using CPU for Whisper inference (no GPU detected)")
109
+ return "cpu"
110
+ except ImportError:
111
+ self.logger.warning("PyTorch not available, defaulting to CPU")
112
+ return "cpu"
113
+
114
+ def _load_model(self):
115
+ """Load the Whisper model (lazy loading on first use)."""
116
+ if self._model is not None:
117
+ return self._model
118
+
119
+ self._check_dependencies()
120
+ import whisper_timestamped as whisper
121
+
122
+ self._whisper_module = whisper
123
+
124
+ device = self._get_device()
125
+ self.logger.info(f"Loading Whisper model '{self.config.model_size}' on device '{device}'...")
126
+
127
+ try:
128
+ # Load model with optional custom cache directory
129
+ download_root = self.config.cache_dir
130
+ self._model = whisper.load_model(
131
+ self.config.model_size,
132
+ device=device,
133
+ download_root=download_root,
134
+ )
135
+ self.logger.info(f"Whisper model '{self.config.model_size}' loaded successfully")
136
+ return self._model
137
+ except RuntimeError as e:
138
+ if "out of memory" in str(e).lower() or "CUDA" in str(e):
139
+ raise TranscriptionError(
140
+ f"GPU out of memory loading model '{self.config.model_size}'. "
141
+ "Try using a smaller model (set WHISPER_MODEL_SIZE=small or tiny) "
142
+ "or force CPU mode (set WHISPER_DEVICE=cpu)"
143
+ ) from e
144
+ raise TranscriptionError(f"Failed to load Whisper model: {e}") from e
145
+ except Exception as e:
146
+ raise TranscriptionError(f"Failed to load Whisper model: {e}") from e
147
+
148
+ def _perform_transcription(self, audio_filepath: str) -> Dict[str, Any]:
149
+ """
150
+ Perform local Whisper transcription with word-level timestamps.
151
+
152
+ Args:
153
+ audio_filepath: Path to the audio file to transcribe
154
+
155
+ Returns:
156
+ Raw transcription result dictionary
157
+ """
158
+ self.logger.info(f"Starting local Whisper transcription for {audio_filepath}")
159
+
160
+ # Load model if not already loaded
161
+ model = self._load_model()
162
+
163
+ try:
164
+ # Perform transcription with word-level timestamps
165
+ transcribe_kwargs = {
166
+ "verbose": False,
167
+ }
168
+
169
+ # Add language if specified
170
+ if self.config.language:
171
+ transcribe_kwargs["language"] = self.config.language
172
+
173
+ self.logger.debug(f"Transcribing with options: {transcribe_kwargs}")
174
+ result = self._whisper_module.transcribe_timestamped(
175
+ model,
176
+ audio_filepath,
177
+ **transcribe_kwargs,
178
+ )
179
+
180
+ self.logger.info("Local Whisper transcription completed successfully")
181
+ return result
182
+
183
+ except RuntimeError as e:
184
+ if "out of memory" in str(e).lower():
185
+ raise TranscriptionError(
186
+ f"GPU out of memory during transcription. "
187
+ "Try using a smaller model (WHISPER_MODEL_SIZE=small) "
188
+ "or force CPU mode (WHISPER_DEVICE=cpu)"
189
+ ) from e
190
+ raise TranscriptionError(f"Transcription failed: {e}") from e
191
+ except Exception as e:
192
+ raise TranscriptionError(f"Transcription failed: {e}") from e
193
+
194
+ def _convert_result_format(self, raw_data: Dict[str, Any]) -> TranscriptionData:
195
+ """
196
+ Convert whisper-timestamped output to standard TranscriptionData format.
197
+
198
+ The whisper-timestamped library returns results in this format:
199
+ {
200
+ "text": "Full transcription text",
201
+ "segments": [
202
+ {
203
+ "id": 0,
204
+ "text": "Segment text",
205
+ "start": 0.0,
206
+ "end": 2.5,
207
+ "words": [
208
+ {"text": "word", "start": 0.0, "end": 0.5, "confidence": 0.95},
209
+ ...
210
+ ]
211
+ },
212
+ ...
213
+ ],
214
+ "language": "en"
215
+ }
216
+
217
+ Args:
218
+ raw_data: Raw output from whisper_timestamped.transcribe_timestamped()
219
+
220
+ Returns:
221
+ TranscriptionData with segments, words, and metadata
222
+ """
223
+ segments = []
224
+ all_words = []
225
+
226
+ for seg in raw_data.get("segments", []):
227
+ segment_words = []
228
+
229
+ for word_data in seg.get("words", []):
230
+ word = Word(
231
+ id=WordUtils.generate_id(),
232
+ text=word_data.get("text", "").strip(),
233
+ start_time=word_data.get("start", 0.0),
234
+ end_time=word_data.get("end", 0.0),
235
+ confidence=word_data.get("confidence"),
236
+ )
237
+ segment_words.append(word)
238
+ all_words.append(word)
239
+
240
+ # Create segment with its words
241
+ segment = LyricsSegment(
242
+ id=WordUtils.generate_id(),
243
+ text=seg.get("text", "").strip(),
244
+ words=segment_words,
245
+ start_time=seg.get("start", 0.0),
246
+ end_time=seg.get("end", 0.0),
247
+ )
248
+ segments.append(segment)
249
+
250
+ return TranscriptionData(
251
+ segments=segments,
252
+ words=all_words,
253
+ text=raw_data.get("text", "").strip(),
254
+ source=self.get_name(),
255
+ metadata={
256
+ "model_size": self.config.model_size,
257
+ "detected_language": raw_data.get("language", "unknown"),
258
+ "device": self._get_device(),
259
+ },
260
+ )