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.
- karaoke_gen/audio_fetcher.py +218 -0
- karaoke_gen/instrumental_review/static/index.html +179 -16
- karaoke_gen/karaoke_gen.py +191 -25
- karaoke_gen/lyrics_processor.py +39 -31
- karaoke_gen/utils/__init__.py +26 -0
- karaoke_gen/utils/cli_args.py +9 -1
- karaoke_gen/utils/gen_cli.py +1 -1
- karaoke_gen/utils/remote_cli.py +33 -6
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/METADATA +80 -4
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/RECORD +50 -43
- lyrics_transcriber/core/config.py +8 -0
- lyrics_transcriber/core/controller.py +43 -1
- lyrics_transcriber/correction/agentic/providers/config.py +6 -0
- lyrics_transcriber/correction/agentic/providers/model_factory.py +24 -1
- lyrics_transcriber/correction/agentic/router.py +17 -13
- lyrics_transcriber/frontend/.gitignore +1 -0
- lyrics_transcriber/frontend/e2e/agentic-corrections.spec.ts +207 -0
- lyrics_transcriber/frontend/e2e/fixtures/agentic-correction-data.json +226 -0
- lyrics_transcriber/frontend/index.html +5 -1
- lyrics_transcriber/frontend/package-lock.json +4553 -0
- lyrics_transcriber/frontend/package.json +7 -1
- lyrics_transcriber/frontend/playwright.config.ts +69 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/frontend/src/App.tsx +88 -59
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +55 -21
- lyrics_transcriber/frontend/src/components/AppHeader.tsx +65 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +39 -35
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +9 -9
- lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +1 -1
- lyrics_transcriber/frontend/src/components/Header.tsx +96 -3
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +120 -3
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +22 -21
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +12 -2
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +3 -3
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +122 -35
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +2 -2
- lyrics_transcriber/frontend/src/components/shared/constants.ts +15 -5
- lyrics_transcriber/frontend/src/components/shared/types.ts +6 -0
- lyrics_transcriber/frontend/src/main.tsx +1 -7
- lyrics_transcriber/frontend/src/theme.ts +337 -135
- lyrics_transcriber/frontend/vite.config.ts +5 -0
- lyrics_transcriber/frontend/yarn.lock +1005 -1046
- lyrics_transcriber/output/generator.py +50 -3
- lyrics_transcriber/review/server.py +1 -1
- lyrics_transcriber/transcribers/local_whisper.py +260 -0
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.81.1.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/audio_fetcher.py
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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')}`;
|