1- """Automatic lyrics fetching for audio files using syncedlyrics ."""
1+ """Automatic lyrics fetching with syncedlyrics and Whisper fallback ."""
22
33from __future__ import annotations
44
1414
1515logger = 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
1888def 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 )
0 commit comments