Skip to content

Commit 4963c4e

Browse files
authored
feat: add Whisper local transcription fallback for lyrics (#5)
When syncedlyrics providers find no lyrics, fall back to local Whisper transcription to generate timestamped .lrc files from the audio itself. - Lazy-loads Whisper model on first use (configurable: tiny/base/small/medium/large) - Converts Whisper segments to LRC format with timestamps - New config options: whisper_fallback (default: true), whisper_model (default: base) - 13 lyrics tests (4 new for Whisper fallback) - Bump to v0.4.0
1 parent 8f2ae90 commit 4963c4e

7 files changed

Lines changed: 647 additions & 270 deletions

File tree

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.13-slim
1+
FROM python:3.14-slim
22

33
# Install FFmpeg for transcoding
44
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "audio-transcode-watcher"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "Watch a source folder and automatically transcode audio files to multiple formats"
99
readme = "README.md"
1010
license = "GPL-3.0-or-later"
@@ -28,6 +28,7 @@ dependencies = [
2828
"pyyaml>=6.0",
2929
"mutagen>=1.47.0",
3030
"syncedlyrics>=1.0.0",
31+
"openai-whisper>=20240930",
3132
]
3233

3334
[project.optional-dependencies]

‎src/audio_transcode_watcher/config.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ class Config:
9494
stability_timeout: float = 60.0
9595
min_stable_seconds: float = 1.0
9696
fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics
97+
whisper_fallback: bool = True # Use Whisper local transcription as fallback
98+
whisper_model: str = "base" # Whisper model size: tiny, base, small, medium, large
9799

98100
def __post_init__(self) -> None:
99101
"""Validate configuration after initialization."""
@@ -140,6 +142,8 @@ def from_dict(cls, data: dict[str, Any]) -> Config:
140142
stability_timeout=settings.get("stability_timeout", 60.0),
141143
min_stable_seconds=settings.get("min_stable_seconds", 1.0),
142144
fetch_lyrics=settings.get("fetch_lyrics", True),
145+
whisper_fallback=settings.get("whisper_fallback", True),
146+
whisper_model=settings.get("whisper_model", "base"),
143147
)
144148

145149
@classmethod

‎src/audio_transcode_watcher/lyrics.py‎

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Automatic lyrics fetching for audio files using syncedlyrics."""
1+
"""Automatic lyrics fetching with syncedlyrics and Whisper fallback."""
22

33
from __future__ import annotations
44

@@ -14,6 +14,76 @@
1414

1515
logger = logging.getLogger(__name__)
1616

17+
# Lazy-loaded Whisper model (heavyweight, only load once when needed)
18+
_whisper_model = None
19+
_whisper_load_failed = False
20+
21+
22+
def _get_whisper_model(model_name: str = "base"):
23+
"""Lazy-load and cache the Whisper model."""
24+
global _whisper_model, _whisper_load_failed
25+
if _whisper_load_failed:
26+
return None
27+
if _whisper_model is not None:
28+
return _whisper_model
29+
try:
30+
import whisper
31+
32+
logger.info("Loading Whisper model '%s' (first use, may take a moment)...", model_name)
33+
_whisper_model = whisper.load_model(model_name)
34+
logger.info("Whisper model '%s' loaded", model_name)
35+
return _whisper_model
36+
except Exception:
37+
logger.warning("Failed to load Whisper model", exc_info=True)
38+
_whisper_load_failed = True
39+
return None
40+
41+
42+
def _segments_to_lrc(segments: list[dict]) -> str:
43+
"""Convert Whisper transcript segments to LRC format."""
44+
lines = []
45+
for seg in segments:
46+
start = seg.get("start", 0.0)
47+
text = seg.get("text", "").strip()
48+
if not text:
49+
continue
50+
mins = int(start // 60)
51+
secs = start % 60
52+
lines.append(f"[{mins:02d}:{secs:05.2f}] {text}")
53+
return "\n".join(lines)
54+
55+
56+
def _transcribe_with_whisper(filepath: str, model_name: str = "base") -> str | None:
57+
"""
58+
Transcribe audio to synced lyrics using Whisper.
59+
60+
Args:
61+
filepath: Path to the audio file.
62+
model_name: Whisper model size (tiny, base, small, medium, large).
63+
64+
Returns:
65+
LRC-formatted string, or None on failure.
66+
"""
67+
model = _get_whisper_model(model_name)
68+
if model is None:
69+
return None
70+
71+
try:
72+
logger.info("Transcribing with Whisper: %s", Path(filepath).name)
73+
result = model.transcribe(filepath, verbose=False)
74+
segments = result.get("segments", [])
75+
if not segments:
76+
logger.info("Whisper produced no segments for: %s", Path(filepath).name)
77+
return None
78+
lrc = _segments_to_lrc(segments)
79+
logger.info(
80+
"Whisper transcribed %d segments for: %s", len(segments), Path(filepath).name
81+
)
82+
return lrc
83+
except Exception:
84+
logger.warning("Whisper transcription failed for: %s", filepath, exc_info=True)
85+
return None
86+
1787

1888
def extract_metadata(filepath: str) -> tuple[str, str] | None:
1989
"""
@@ -53,15 +123,23 @@ def extract_metadata(filepath: str) -> tuple[str, str] | None:
53123
return None
54124

55125

56-
def fetch_lyrics_for_file(filepath: str) -> str | None:
126+
def fetch_lyrics_for_file(
127+
filepath: str,
128+
whisper_fallback: bool = True,
129+
whisper_model: str = "base",
130+
) -> str | None:
57131
"""
58132
Fetch synced lyrics (.lrc) for an audio file if not already present.
59133
60-
Checks if an .lrc sidecar already exists. If not, extracts metadata
61-
and queries syncedlyrics providers (Musixmatch, LRCLIB, NetEase, etc.).
134+
Strategy:
135+
1. Check if .lrc sidecar already exists -> skip
136+
2. Try syncedlyrics providers (Musixmatch, LRCLIB, NetEase)
137+
3. If nothing found and whisper_fallback enabled, transcribe locally
62138
63139
Args:
64140
filepath: Path to the audio file.
141+
whisper_fallback: Use Whisper local transcription as fallback.
142+
whisper_model: Whisper model size (tiny, base, small, medium, large).
65143
66144
Returns:
67145
Path to the written .lrc file, or None if lyrics were not found
@@ -74,28 +152,41 @@ def fetch_lyrics_for_file(filepath: str) -> str | None:
74152
if os.path.isfile(lrc_path):
75153
return None
76154

155+
lrc_content: str | None = None
156+
157+
# Step 1: Try syncedlyrics
77158
meta = extract_metadata(filepath)
78-
if meta is None:
159+
if meta is not None:
160+
artist, title = meta
161+
query = f"{artist} {title}"
162+
try:
163+
lrc_content = syncedlyrics.search(query)
164+
except Exception:
165+
logger.warning("syncedlyrics search failed for: %s", query, exc_info=True)
166+
167+
if lrc_content:
168+
return _write_lrc(lrc_path, lrc_content, f"{artist} - {title}", "syncedlyrics")
169+
170+
logger.info("No lyrics found via syncedlyrics for: %s - %s", artist, title)
171+
else:
79172
logger.debug("Cannot extract metadata for lyrics: %s", filepath)
80-
return None
81173

82-
artist, title = meta
83-
query = f"{artist} {title}"
174+
# Step 2: Whisper fallback
175+
if whisper_fallback:
176+
lrc_content = _transcribe_with_whisper(filepath, whisper_model)
177+
if lrc_content:
178+
label = f"{meta[0]} - {meta[1]}" if meta else Path(filepath).stem
179+
return _write_lrc(lrc_path, lrc_content, label, "whisper")
84180

85-
try:
86-
lrc_content = syncedlyrics.search(query)
87-
except Exception:
88-
logger.warning("syncedlyrics search failed for: %s", query, exc_info=True)
89-
return None
181+
return None
90182

91-
if not lrc_content:
92-
logger.info("No lyrics found for: %s - %s", artist, title)
93-
return None
94183

184+
def _write_lrc(lrc_path: str, content: str, label: str, source: str) -> str | None:
185+
"""Write LRC content to disk."""
95186
try:
96187
with open(lrc_path, "w", encoding="utf-8") as f:
97-
f.write(lrc_content)
98-
logger.info("♫ lyrics saved: %s - %s → %s", artist, title, lrc_path)
188+
f.write(content)
189+
logger.info("♫ lyrics saved (%s): %s → %s", source, label, lrc_path)
99190
return lrc_path
100191
except Exception:
101192
logger.error("Failed to write lyrics file: %s", lrc_path, exc_info=True)

‎src/audio_transcode_watcher/sync.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ def process_source_file(
122122
# Auto-fetch lyrics if enabled and no .lrc sidecar exists
123123
if config.fetch_lyrics:
124124
try:
125-
fetch_lyrics_for_file(source_path)
125+
fetch_lyrics_for_file(
126+
source_path,
127+
whisper_fallback=config.whisper_fallback,
128+
whisper_model=config.whisper_model,
129+
)
126130
except Exception:
127131
logger.debug("Lyrics fetch failed for %s", source_path, exc_info=True)
128132
sync_sidecars(source_path, config)

‎tests/test_lyrics.py‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import pytest
99

10-
from audio_transcode_watcher.lyrics import extract_metadata, fetch_lyrics_for_file
10+
from audio_transcode_watcher.lyrics import (
11+
_segments_to_lrc,
12+
extract_metadata,
13+
fetch_lyrics_for_file,
14+
)
1115

1216

1317
class TestExtractMetadata:
@@ -57,6 +61,28 @@ def test_falls_back_to_filename_on_empty_tags(self, mock_mutagen, tmp_path):
5761
assert result == ("AC DC", "Thunderstruck")
5862

5963

64+
class TestSegmentsToLrc:
65+
"""Tests for _segments_to_lrc()."""
66+
67+
def test_converts_segments(self):
68+
segments = [
69+
{"start": 0.0, "text": "Hello world"},
70+
{"start": 65.5, "text": "Second line"},
71+
]
72+
result = _segments_to_lrc(segments)
73+
assert "[00:00.00] Hello world" in result
74+
assert "[01:05.50] Second line" in result
75+
76+
def test_skips_empty_text(self):
77+
segments = [
78+
{"start": 0.0, "text": " "},
79+
{"start": 5.0, "text": "Real line"},
80+
]
81+
result = _segments_to_lrc(segments)
82+
assert "Real line" in result
83+
assert result.count("[") == 1
84+
85+
6086
class TestFetchLyricsForFile:
6187
"""Tests for fetch_lyrics_for_file()."""
6288

@@ -78,28 +104,60 @@ def test_fetches_and_writes_lyrics(self, mock_meta, mock_syncedlyrics, tmp_path)
78104
mock_meta.return_value = ("Queen", "Radio Gaga")
79105
mock_syncedlyrics.search.return_value = "[00:01.00] All we hear is\n[00:03.00] Radio gaga"
80106

81-
result = fetch_lyrics_for_file(str(audio))
107+
result = fetch_lyrics_for_file(str(audio), whisper_fallback=False)
82108
assert result is not None
83109
assert result.endswith(".lrc")
84110
assert os.path.isfile(result)
85111
content = open(result).read()
86112
assert "Radio gaga" in content
87113

114+
@patch("audio_transcode_watcher.lyrics._transcribe_with_whisper")
115+
@patch("audio_transcode_watcher.lyrics.syncedlyrics")
116+
@patch("audio_transcode_watcher.lyrics.extract_metadata")
117+
def test_falls_back_to_whisper(self, mock_meta, mock_syncedlyrics, mock_whisper, tmp_path):
118+
"""Fall back to Whisper when syncedlyrics returns nothing."""
119+
audio = tmp_path / "Niche Band - Rare Song.flac"
120+
audio.touch()
121+
mock_meta.return_value = ("Niche Band", "Rare Song")
122+
mock_syncedlyrics.search.return_value = None
123+
mock_whisper.return_value = "[00:00.00] Transcribed by whisper"
124+
125+
result = fetch_lyrics_for_file(str(audio), whisper_fallback=True)
126+
assert result is not None
127+
assert os.path.isfile(result)
128+
content = open(result).read()
129+
assert "Transcribed by whisper" in content
130+
mock_whisper.assert_called_once()
131+
132+
@patch("audio_transcode_watcher.lyrics._transcribe_with_whisper")
133+
@patch("audio_transcode_watcher.lyrics.syncedlyrics")
134+
@patch("audio_transcode_watcher.lyrics.extract_metadata")
135+
def test_whisper_disabled(self, mock_meta, mock_syncedlyrics, mock_whisper, tmp_path):
136+
"""Don't use Whisper when disabled."""
137+
audio = tmp_path / "Artist - Song.flac"
138+
audio.touch()
139+
mock_meta.return_value = ("Artist", "Song")
140+
mock_syncedlyrics.search.return_value = None
141+
142+
result = fetch_lyrics_for_file(str(audio), whisper_fallback=False)
143+
assert result is None
144+
mock_whisper.assert_not_called()
145+
88146
@patch("audio_transcode_watcher.lyrics.syncedlyrics")
89147
@patch("audio_transcode_watcher.lyrics.extract_metadata")
90148
def test_returns_none_when_no_lyrics(self, mock_meta, mock_syncedlyrics, tmp_path):
91-
"""Return None when syncedlyrics finds nothing."""
149+
"""Return None when nothing is found."""
92150
audio = tmp_path / "Obscure Band - Niche Song.flac"
93151
audio.touch()
94152
mock_meta.return_value = ("Obscure Band", "Niche Song")
95153
mock_syncedlyrics.search.return_value = None
96154

97-
result = fetch_lyrics_for_file(str(audio))
155+
result = fetch_lyrics_for_file(str(audio), whisper_fallback=False)
98156
assert result is None
99157

100158
def test_returns_none_when_no_metadata(self, tmp_path):
101159
"""Return None when metadata can't be extracted."""
102160
audio = tmp_path / "noinfo.flac"
103161
audio.touch()
104-
result = fetch_lyrics_for_file(str(audio))
162+
result = fetch_lyrics_for_file(str(audio), whisper_fallback=False)
105163
assert result is None

0 commit comments

Comments
 (0)