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
@@ -264,6 +264,36 @@ class AudioFetcher(ABC):
264
264
  """
265
265
  pass
266
266
 
267
+ @abstractmethod
268
+ def download_from_url(
269
+ self,
270
+ url: str,
271
+ output_dir: str,
272
+ output_filename: Optional[str] = None,
273
+ artist: Optional[str] = None,
274
+ title: Optional[str] = None,
275
+ ) -> AudioFetchResult:
276
+ """
277
+ Download audio directly from a URL (e.g., YouTube URL).
278
+
279
+ This bypasses the search step and downloads directly from the provided URL.
280
+ Useful when the user provides a specific YouTube URL rather than artist/title.
281
+
282
+ Args:
283
+ url: The URL to download from (e.g., YouTube video URL)
284
+ output_dir: Directory to save the downloaded file
285
+ output_filename: Optional filename (without extension)
286
+ artist: Optional artist name for metadata
287
+ title: Optional title for metadata
288
+
289
+ Returns:
290
+ AudioFetchResult with the downloaded file path
291
+
292
+ Raises:
293
+ DownloadError: If download fails
294
+ """
295
+ pass
296
+
267
297
 
268
298
  class FlacFetchAudioFetcher(AudioFetcher):
269
299
  """
@@ -645,6 +675,94 @@ class FlacFetchAudioFetcher(AudioFetcher):
645
675
  except Exception as e:
646
676
  raise DownloadError(f"Failed to download {artist} - {title}: {e}") from e
647
677
 
678
+ def download_from_url(
679
+ self,
680
+ url: str,
681
+ output_dir: str,
682
+ output_filename: Optional[str] = None,
683
+ artist: Optional[str] = None,
684
+ title: Optional[str] = None,
685
+ ) -> AudioFetchResult:
686
+ """
687
+ Download audio directly from a URL (e.g., YouTube URL).
688
+
689
+ Uses flacfetch's download_by_id() method which supports direct YouTube downloads.
690
+
691
+ Args:
692
+ url: The URL to download from (e.g., YouTube video URL)
693
+ output_dir: Directory to save the downloaded file
694
+ output_filename: Optional filename (without extension)
695
+ artist: Optional artist name for metadata
696
+ title: Optional title for metadata
697
+
698
+ Returns:
699
+ AudioFetchResult with the downloaded file path
700
+
701
+ Raises:
702
+ DownloadError: If download fails
703
+ """
704
+ import re
705
+
706
+ manager = self._get_manager()
707
+
708
+ # Ensure output directory exists
709
+ os.makedirs(output_dir, exist_ok=True)
710
+
711
+ # Detect source type from URL
712
+ source_name = "YouTube" # Default to YouTube for now
713
+ source_id = None
714
+
715
+ # Extract YouTube video ID from URL
716
+ youtube_patterns = [
717
+ r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
718
+ r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
719
+ r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
720
+ ]
721
+ for pattern in youtube_patterns:
722
+ match = re.search(pattern, url)
723
+ if match:
724
+ source_id = match.group(1)
725
+ break
726
+
727
+ if not source_id:
728
+ # For other URLs, use the full URL as the source_id
729
+ source_id = url
730
+
731
+ # Generate filename if not provided
732
+ if output_filename is None:
733
+ if artist and title:
734
+ output_filename = f"{artist} - {title}"
735
+ else:
736
+ output_filename = source_id
737
+
738
+ self.logger.info(f"Downloading from URL: {url}")
739
+
740
+ try:
741
+ filepath = manager.download_by_id(
742
+ source_name=source_name,
743
+ source_id=source_id,
744
+ output_path=output_dir,
745
+ output_filename=output_filename,
746
+ download_url=url, # Pass full URL for direct download
747
+ )
748
+
749
+ if not filepath:
750
+ raise DownloadError(f"Download returned no file path for URL: {url}")
751
+
752
+ self.logger.info(f"Downloaded to: {filepath}")
753
+
754
+ return AudioFetchResult(
755
+ filepath=filepath,
756
+ artist=artist or "",
757
+ title=title or "",
758
+ provider=source_name,
759
+ duration=None, # Could extract from yt-dlp info if needed
760
+ quality=None,
761
+ )
762
+
763
+ except Exception as e:
764
+ raise DownloadError(f"Failed to download from URL {url}: {e}") from e
765
+
648
766
  def _interruptible_search(self, manager, query) -> list:
649
767
  """
650
768
  Run search in a way that can be interrupted by Ctrl+C.
@@ -1548,6 +1666,106 @@ class RemoteFlacFetchAudioFetcher(AudioFetcher):
1548
1666
  print("\nCancelled")
1549
1667
  raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
1550
1668
 
1669
+ def download_from_url(
1670
+ self,
1671
+ url: str,
1672
+ output_dir: str,
1673
+ output_filename: Optional[str] = None,
1674
+ artist: Optional[str] = None,
1675
+ title: Optional[str] = None,
1676
+ ) -> AudioFetchResult:
1677
+ """
1678
+ Download audio directly from a URL (e.g., YouTube URL).
1679
+
1680
+ For YouTube URLs, this uses local flacfetch since YouTube downloads
1681
+ don't require the remote flacfetch infrastructure (no torrents).
1682
+
1683
+ Args:
1684
+ url: The URL to download from (e.g., YouTube video URL)
1685
+ output_dir: Directory to save the downloaded file
1686
+ output_filename: Optional filename (without extension)
1687
+ artist: Optional artist name for metadata
1688
+ title: Optional title for metadata
1689
+
1690
+ Returns:
1691
+ AudioFetchResult with the downloaded file path
1692
+
1693
+ Raises:
1694
+ DownloadError: If download fails
1695
+ """
1696
+ import re
1697
+
1698
+ self.logger.info(f"[RemoteFlacFetcher] Downloading from URL: {url}")
1699
+ self.logger.info("[RemoteFlacFetcher] Using local flacfetch for YouTube download (no remote API needed)")
1700
+
1701
+ try:
1702
+ # Use local flacfetch for YouTube downloads - no need for remote API
1703
+ # This avoids needing yt-dlp directly in karaoke-gen
1704
+ from flacfetch.core.manager import FetchManager
1705
+ from flacfetch.providers.youtube import YoutubeProvider
1706
+ from flacfetch.downloaders.youtube import YoutubeDownloader
1707
+
1708
+ # Create a minimal local manager for YouTube downloads
1709
+ manager = FetchManager()
1710
+ manager.add_provider(YoutubeProvider())
1711
+ manager.register_downloader("YouTube", YoutubeDownloader())
1712
+
1713
+ # Ensure output directory exists
1714
+ os.makedirs(output_dir, exist_ok=True)
1715
+
1716
+ # Extract video ID from URL
1717
+ source_id = None
1718
+ youtube_patterns = [
1719
+ r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
1720
+ r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
1721
+ r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
1722
+ ]
1723
+ for pattern in youtube_patterns:
1724
+ match = re.search(pattern, url)
1725
+ if match:
1726
+ source_id = match.group(1)
1727
+ break
1728
+
1729
+ if not source_id:
1730
+ source_id = url
1731
+
1732
+ # Generate filename if not provided
1733
+ if output_filename is None:
1734
+ if artist and title:
1735
+ output_filename = f"{artist} - {title}"
1736
+ else:
1737
+ output_filename = source_id
1738
+
1739
+ # Use flacfetch's download_by_id for direct URL download
1740
+ filepath = manager.download_by_id(
1741
+ source_name="YouTube",
1742
+ source_id=source_id,
1743
+ output_path=output_dir,
1744
+ output_filename=output_filename,
1745
+ download_url=url,
1746
+ )
1747
+
1748
+ if not filepath:
1749
+ raise DownloadError(f"Download returned no file path for URL: {url}")
1750
+
1751
+ self.logger.info(f"[RemoteFlacFetcher] Downloaded to: {filepath}")
1752
+
1753
+ return AudioFetchResult(
1754
+ filepath=filepath,
1755
+ artist=artist or "",
1756
+ title=title or "",
1757
+ provider="YouTube",
1758
+ duration=None,
1759
+ quality=None,
1760
+ )
1761
+
1762
+ except ImportError as e:
1763
+ raise DownloadError(
1764
+ f"flacfetch is required for URL downloads but import failed: {e}"
1765
+ ) from e
1766
+ except Exception as e:
1767
+ raise DownloadError(f"Failed to download from URL {url}: {e}") from e
1768
+
1551
1769
 
1552
1770
  # Alias for shorter name
1553
1771
  RemoteFlacFetcher = RemoteFlacFetchAudioFetcher
@@ -60,8 +60,16 @@
60
60
  .logo {
61
61
  font-size: 1.25rem;
62
62
  font-weight: 600;
63
+ display: inline-flex;
64
+ align-items: center;
65
+ gap: 8px;
63
66
  }
64
-
67
+
68
+ .logo-img {
69
+ height: 40px;
70
+ width: auto;
71
+ }
72
+
65
73
  .track-info {
66
74
  font-size: 0.9rem;
67
75
  color: var(--text-muted);
@@ -568,6 +576,143 @@
568
576
  font-size: 1.5rem;
569
577
  color: var(--success);
570
578
  }
579
+
580
+ /* Mobile responsiveness */
581
+ @media (max-width: 768px) {
582
+ .app {
583
+ padding: 12px;
584
+ gap: 8px;
585
+ height: auto;
586
+ min-height: 100vh;
587
+ overflow-y: auto;
588
+ }
589
+
590
+ body {
591
+ overflow: auto;
592
+ }
593
+
594
+ .header {
595
+ flex-direction: column;
596
+ align-items: flex-start;
597
+ gap: 8px;
598
+ }
599
+
600
+ .header-left {
601
+ width: 100%;
602
+ }
603
+
604
+ .header-right {
605
+ width: 100%;
606
+ justify-content: flex-start;
607
+ flex-wrap: wrap;
608
+ }
609
+
610
+ .logo {
611
+ font-size: 1rem;
612
+ }
613
+
614
+ .logo-img {
615
+ height: 32px;
616
+ }
617
+
618
+ .waveform-player {
619
+ flex: none;
620
+ min-height: 200px;
621
+ }
622
+
623
+ .waveform-toolbar {
624
+ flex-wrap: wrap;
625
+ padding: 8px 12px;
626
+ gap: 8px;
627
+ }
628
+
629
+ .toolbar-left,
630
+ .toolbar-center,
631
+ .toolbar-right {
632
+ flex-wrap: wrap;
633
+ }
634
+
635
+ .audio-toggle-group {
636
+ order: 10;
637
+ width: 100%;
638
+ justify-content: center;
639
+ }
640
+
641
+ .bottom-section {
642
+ flex-direction: column;
643
+ }
644
+
645
+ .mute-panel {
646
+ max-height: none;
647
+ }
648
+
649
+ .selection-panel {
650
+ width: 100%;
651
+ }
652
+
653
+ .selection-option {
654
+ padding: 12px;
655
+ }
656
+
657
+ .btn {
658
+ min-height: 44px;
659
+ padding: 8px 12px;
660
+ }
661
+
662
+ .btn-icon {
663
+ width: 44px;
664
+ height: 44px;
665
+ }
666
+
667
+ .audio-toggle {
668
+ padding: 8px 12px;
669
+ min-height: 40px;
670
+ }
671
+
672
+ .zoom-btn {
673
+ width: 40px;
674
+ height: 40px;
675
+ }
676
+
677
+ .time-display {
678
+ font-size: 0.9rem;
679
+ }
680
+ }
681
+
682
+ @media (max-width: 480px) {
683
+ .app {
684
+ padding: 8px;
685
+ }
686
+
687
+ .header-left {
688
+ flex-wrap: wrap;
689
+ }
690
+
691
+ .track-info {
692
+ width: 100%;
693
+ margin-top: 4px;
694
+ }
695
+
696
+ .waveform-toolbar {
697
+ padding: 6px 8px;
698
+ }
699
+
700
+ .toolbar-center {
701
+ width: 100%;
702
+ justify-content: center;
703
+ order: -1;
704
+ }
705
+
706
+ .toolbar-left {
707
+ order: 1;
708
+ }
709
+
710
+ .toolbar-right {
711
+ order: 2;
712
+ width: 100%;
713
+ justify-content: space-between;
714
+ }
715
+ }
571
716
  </style>
572
717
  </head>
573
718
  <body>
@@ -641,7 +786,8 @@
641
786
 
642
787
  if (waveformRes.ok) {
643
788
  waveformData = await waveformRes.json();
644
- duration = waveformData.duration;
789
+ // API returns duration_seconds, not duration
790
+ duration = waveformData.duration_seconds || 0;
645
791
  }
646
792
 
647
793
  // Set initial selection based on recommendation
@@ -679,7 +825,7 @@
679
825
  app.innerHTML = `
680
826
  <div class="header">
681
827
  <div class="header-left">
682
- <span class="logo">🎤 Instrumental Review</span>
828
+ <span class="logo"><img src="https://gen.nomadkaraoke.com/nomad-karaoke-logo.svg" alt="Nomad Karaoke" class="logo-img" onerror="this.style.display='none'"> Instrumental Review</span>
683
829
  <span class="track-info">${escapeHtml(analysisData.artist) || ''} ${analysisData.artist && analysisData.title ? '–' : ''} ${escapeHtml(analysisData.title) || ''}</span>
684
830
  </div>
685
831
  <div class="header-right">
@@ -969,8 +1115,14 @@
969
1115
  canvas.onmousedown = (e) => {
970
1116
  const rect = canvas.getBoundingClientRect();
971
1117
  const x = e.clientX - rect.left;
1118
+
1119
+ // Guard against invalid duration
1120
+ if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(rect.width) || rect.width <= 0) {
1121
+ return;
1122
+ }
1123
+
972
1124
  const time = (x / rect.width) * duration;
973
-
1125
+
974
1126
  // Shift+drag to select mute region
975
1127
  if (e.shiftKey) {
976
1128
  isDragging = true;
@@ -993,18 +1145,26 @@
993
1145
 
994
1146
  const endDrag = (e) => {
995
1147
  if (!isDragging) return;
996
-
1148
+
997
1149
  const rect = canvas.getBoundingClientRect();
998
1150
  const x = e.clientX - rect.left;
1151
+
1152
+ // Guard against invalid duration
1153
+ if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(rect.width) || rect.width <= 0) {
1154
+ isDragging = false;
1155
+ showSelectionOverlay(false);
1156
+ return;
1157
+ }
1158
+
999
1159
  const time = (x / rect.width) * duration;
1000
-
1160
+
1001
1161
  const start = Math.min(dragStartTime, time);
1002
1162
  const end = Math.max(dragStartTime, time);
1003
-
1163
+
1004
1164
  if (end - start > 0.5) {
1005
1165
  addRegion(start, end);
1006
1166
  }
1007
-
1167
+
1008
1168
  isDragging = false;
1009
1169
  showSelectionOverlay(false);
1010
1170
  };
@@ -1090,14 +1250,15 @@
1090
1250
 
1091
1251
  function seekTo(time, autoPlay = true) {
1092
1252
  const audio = document.getElementById('audio-player');
1093
- if (audio) {
1094
- audio.currentTime = time;
1095
- currentTime = time;
1096
- updatePlayhead();
1097
- // Auto-play when seeking via click (if not already playing)
1098
- if (autoPlay && !isPlaying) {
1099
- audio.play();
1100
- }
1253
+ // Guard against non-finite time values (NaN, Infinity)
1254
+ if (!audio || !Number.isFinite(time)) return;
1255
+
1256
+ audio.currentTime = time;
1257
+ currentTime = time;
1258
+ updatePlayhead();
1259
+ // Auto-play when seeking via click (if not already playing)
1260
+ if (autoPlay && !isPlaying) {
1261
+ audio.play();
1101
1262
  }
1102
1263
  }
1103
1264
 
@@ -1155,6 +1316,8 @@
1155
1316
  }
1156
1317
 
1157
1318
  function formatTime(seconds) {
1319
+ // Guard against NaN/Infinity
1320
+ if (!Number.isFinite(seconds)) return '0:00';
1158
1321
  const mins = Math.floor(seconds / 60);
1159
1322
  const secs = Math.floor(seconds % 60);
1160
1323
  return `${mins}:${secs.toString().padStart(2, '0')}`;