← Whispering

fix: Add pydub fallback for MP3 and other audio formats

d57d02b32810b61f266ad6bfd8a43f3cbf45b2c1 · Claude · Jan 26, 2026 07:18

soundfile/libsndfile doesn't support MP3 files natively. Added pydub
as a fallback for MP3, M4A, AAC, and other formats that soundfile
can't handle. Both load_audio_file and get_audio_duration now use
pydub when soundfile fails.

+105 −38 · 1 file changed

  • src/core_parts/audio_devices.py +105 −38
diff --git a/src/core_parts/audio_devices.py b/src/core_parts/audio_devices.py
index d7587a5..ba363df 100644
--- a/src/core_parts/audio_devices.py
+++ b/src/core_parts/audio_devices.py
@@ -176,9 +176,21 @@ def get_audio_duration(file_path: str) -> float:
     if not os.path.exists(file_path):
         raise FileNotFoundError(f"Audio file not found: {file_path}")
 
+    ext = os.path.splitext(file_path)[1].lower()
+
+    # Try soundfile first for formats it supports
+    if ext in ['.wav', '.flac', '.ogg']:
+        try:
+            info = sf.info(file_path)
+            return info.duration
+        except Exception:
+            pass  # Fall through to pydub
+
+    # Use pydub for MP3, M4A, and other formats
     try:
-        info = sf.info(file_path)
-        return info.duration
+        from pydub import AudioSegment
+        audio = AudioSegment.from_file(file_path)
+        return len(audio) / 1000.0  # Duration in seconds
     except Exception as e:
         raise ValueError(f"Failed to get audio duration: {str(e)}")
 
@@ -187,7 +199,8 @@ def load_audio_file(file_path: str, start_time: float = 0.0, end_time: float = N
     """
     Load an audio file and return audio data ready for Whisper.
 
-    Supports: WAV, MP3, FLAC, OGG, M4A, and other formats supported by soundfile/libsndfile.
+    Supports: WAV, MP3, FLAC, OGG, M4A, and other formats.
+    Uses soundfile for WAV/FLAC/OGG, falls back to pydub for MP3/M4A/etc.
 
     Args:
         file_path: Path to the audio file
@@ -204,47 +217,101 @@ def load_audio_file(file_path: str, start_time: float = 0.0, end_time: float = N
     if not os.path.exists(file_path):
         raise FileNotFoundError(f"Audio file not found: {file_path}")
 
+    ext = os.path.splitext(file_path)[1].lower()
+
+    # Try soundfile first for formats it supports well (WAV, FLAC, OGG)
+    if ext in ['.wav', '.flac', '.ogg']:
+        try:
+            return _load_with_soundfile(file_path, start_time, end_time)
+        except Exception:
+            pass  # Fall through to pydub
+
+    # Use pydub for MP3, M4A, and other formats (or as fallback)
     try:
-        # Get file info first
-        info = sf.info(file_path)
-        file_sample_rate = info.samplerate
-        total_duration = info.duration
-
-        # Calculate frame positions
-        start_frame = int(start_time * file_sample_rate)
-        if end_time is not None:
-            end_frame = int(end_time * file_sample_rate)
-            frames_to_read = end_frame - start_frame
-        else:
-            frames_to_read = -1  # Read to end
-
-        # Read audio file using soundfile with time range
-        audio_data, sample_rate = sf.read(
-            file_path,
-            dtype='float32',
-            start=start_frame,
-            frames=frames_to_read if frames_to_read > 0 else None
-        )
-
-        # Get number of channels
-        if len(audio_data.shape) == 1:
-            channels = 1
-        else:
-            channels = audio_data.shape[1]
+        return _load_with_pydub(file_path, start_time, end_time)
+    except Exception as e:
+        raise ValueError(f"Failed to load audio file: {str(e)}")
 
-        # Calculate actual duration of loaded segment
-        duration = len(audio_data) / sample_rate
 
-        # Convert to int16
-        audio_int16 = (audio_data * 32768.0).clip(-32768, 32767).astype(np.int16)
+def _load_with_soundfile(file_path: str, start_time: float, end_time: float) -> tuple:
+    """Load audio using soundfile (for WAV, FLAC, OGG)."""
+    # Get file info first
+    info = sf.info(file_path)
+    file_sample_rate = info.samplerate
+    total_duration = info.duration
 
-        # Convert to mono 16kHz for Whisper
-        mono_16k = resample_to_mono_16k(audio_int16, sample_rate, channels)
+    # Calculate frame positions
+    start_frame = int(start_time * file_sample_rate)
+    if end_time is not None:
+        end_frame = int(end_time * file_sample_rate)
+        frames_to_read = end_frame - start_frame
+    else:
+        frames_to_read = -1  # Read to end
+
+    # Read audio file using soundfile with time range
+    audio_data, sample_rate = sf.read(
+        file_path,
+        dtype='float32',
+        start=start_frame,
+        frames=frames_to_read if frames_to_read > 0 else None
+    )
+
+    # Get number of channels
+    if len(audio_data.shape) == 1:
+        channels = 1
+    else:
+        channels = audio_data.shape[1]
 
-        return mono_16k, TARGET_SAMPLE_RATE, duration
+    # Calculate actual duration of loaded segment
+    duration = len(audio_data) / sample_rate
 
-    except Exception as e:
-        raise ValueError(f"Failed to load audio file: {str(e)}")
+    # Convert to int16
+    audio_int16 = (audio_data * 32768.0).clip(-32768, 32767).astype(np.int16)
+
+    # Convert to mono 16kHz for Whisper
+    mono_16k = resample_to_mono_16k(audio_int16, sample_rate, channels)
+
+    return mono_16k, TARGET_SAMPLE_RATE, duration
+
+
+def _load_with_pydub(file_path: str, start_time: float, end_time: float) -> tuple:
+    """Load audio using pydub (for MP3, M4A, AAC, etc.)."""
+    from pydub import AudioSegment
+
+    # Load audio file
+    audio = AudioSegment.from_file(file_path)
+
+    # Apply time range (pydub uses milliseconds)
+    start_ms = int(start_time * 1000)
+    if end_time is not None:
+        end_ms = int(end_time * 1000)
+        audio = audio[start_ms:end_ms]
+    else:
+        audio = audio[start_ms:]
+
+    # Get properties
+    sample_rate = audio.frame_rate
+    channels = audio.channels
+    duration = len(audio) / 1000.0  # Duration in seconds
+
+    # Convert to numpy array
+    samples = np.array(audio.get_array_of_samples())
+
+    # Handle stereo
+    if channels == 2:
+        samples = samples.reshape((-1, 2))
+
+    # Convert to int16 if needed (pydub usually returns int16 already)
+    if samples.dtype != np.int16:
+        if samples.dtype == np.float32 or samples.dtype == np.float64:
+            samples = (samples * 32768.0).clip(-32768, 32767).astype(np.int16)
+        else:
+            samples = samples.astype(np.int16)
+
+    # Convert to mono 16kHz for Whisper
+    mono_16k = resample_to_mono_16k(samples, sample_rate, channels)
+
+    return mono_16k, TARGET_SAMPLE_RATE, duration
 
 
 def get_audio_files_from_directory(directory_path: str, recursive: bool = False) -> list: