fix: Add pydub fallback for MP3 and other audio formats
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
@@ -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: