Download backend/app/features/audio_preprocessor.py from vipan-kumar/Audio_Deepfake_Detection: direct link, hf CLI and curl.
- Browser
- Download file 7.97 kB
-
https://huggingface.co/spaces/vipan-kumar/Audio_Deepfake_Detection/resolve/main/backend/app/features/audio_preprocessor.py
- Command line
-
hf download hf://spaces/vipan-kumar/Audio_Deepfake_Detection/backend/app/features/audio_preprocessor.py
-
curl -L -o audio_preprocessor.py https://huggingface.co/spaces/vipan-kumar/Audio_Deepfake_Detection/resolve/main/backend/app/features/audio_preprocessor.py
7.97 kB
| """Audio loading, resampling, and normalisation utilities. | |
| Decode chain (in order): | |
| 1. ``soundfile`` (libsndfile) β fast path for WAV / FLAC / AIFF | |
| 2. ``ffmpeg`` via imageio-ffmpeg β handles MP3 / OGG / WebM / M4A / etc. | |
| The ffmpeg fallback is critical on Windows because libsndfile ships without | |
| MP3/OGG/WebM codecs and torchaudio's default backend uses libsndfile too. | |
| Browser MediaRecorder produces WebM/Opus, so without this fallback the | |
| microphone-recording path is completely broken. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import shutil | |
| import subprocess | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Optional, Tuple, Union | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| from app.logging_setup import get_logger | |
| logger = get_logger(__name__) | |
| # --------------------------------------------------------------------------- # | |
| # ffmpeg discovery β prefer the bundled binary, fall back to system PATH. | |
| # --------------------------------------------------------------------------- # | |
| _FFMPEG_EXE: Optional[str] = None | |
| def _resolve_ffmpeg() -> Optional[str]: | |
| global _FFMPEG_EXE | |
| if _FFMPEG_EXE is not None: | |
| return _FFMPEG_EXE | |
| # Bundled ffmpeg first β works without any system install. | |
| try: | |
| import imageio_ffmpeg | |
| exe = imageio_ffmpeg.get_ffmpeg_exe() | |
| if exe and Path(exe).exists(): | |
| _FFMPEG_EXE = exe | |
| logger.info("Using bundled ffmpeg: %s", exe) | |
| return exe | |
| except Exception as exc: # noqa: BLE001 | |
| logger.debug("imageio_ffmpeg unavailable: %s", exc) | |
| # System ffmpeg as last resort. | |
| sys_exe = shutil.which("ffmpeg") | |
| if sys_exe: | |
| _FFMPEG_EXE = sys_exe | |
| logger.info("Using system ffmpeg: %s", sys_exe) | |
| return sys_exe | |
| logger.warning( | |
| "No ffmpeg available β only PCM WAV / FLAC / AIFF will decode. " | |
| "Install imageio-ffmpeg or ffmpeg to enable MP3 / OGG / WebM / M4A." | |
| ) | |
| return None | |
| # --------------------------------------------------------------------------- # | |
| # Public dataclass | |
| # --------------------------------------------------------------------------- # | |
| class LoadedAudio: | |
| waveform: torch.Tensor # [1, T] float32 in ~[-1, 1] | |
| sample_rate: int # always == target_sr after preprocessing | |
| original_sample_rate: int | |
| original_channels: int | |
| duration_seconds: float | |
| file_format: str | |
| file_size_bytes: int | |
| decoder: str # 'soundfile' | 'ffmpeg' | 'unknown' | |
| # --------------------------------------------------------------------------- # | |
| # Decoders | |
| # --------------------------------------------------------------------------- # | |
| def _decode_with_soundfile(data: Union[bytes, str, Path]) -> Tuple[np.ndarray, int, str]: | |
| """Try the fast soundfile path. Raises if the format isn't supported.""" | |
| if isinstance(data, (bytes, bytearray)): | |
| bio = io.BytesIO(data) | |
| wav, sr = sf.read(bio, dtype="float32", always_2d=True) | |
| return wav, sr, "wav" | |
| path = Path(data) | |
| suffix = path.suffix.lstrip(".").lower() or "unknown" | |
| wav, sr = sf.read(str(path), dtype="float32", always_2d=True) | |
| return wav, sr, suffix | |
| def _decode_with_ffmpeg( | |
| data: Union[bytes, str, Path], | |
| target_sr: int = 16000, | |
| ) -> Tuple[np.ndarray, int, str]: | |
| """Decode anything ffmpeg understands β mono float32 PCM at target_sr.""" | |
| ffmpeg = _resolve_ffmpeg() | |
| if ffmpeg is None: | |
| raise RuntimeError( | |
| "Audio format not supported by libsndfile and no ffmpeg available. " | |
| "Install imageio-ffmpeg (pip install imageio-ffmpeg) or ffmpeg." | |
| ) | |
| is_bytes = isinstance(data, (bytes, bytearray)) | |
| fmt_hint = "compressed" | |
| if not is_bytes: | |
| fmt_hint = Path(data).suffix.lstrip(".").lower() or "compressed" | |
| cmd = [ | |
| ffmpeg, | |
| "-hide_banner", | |
| "-loglevel", "error", | |
| "-nostdin", | |
| "-i", "pipe:0" if is_bytes else str(data), | |
| "-vn", # discard any video stream | |
| "-ac", "1", # mono | |
| "-ar", str(target_sr), # resample | |
| "-f", "wav", | |
| "-acodec", "pcm_s16le", | |
| "pipe:1", | |
| ] | |
| try: | |
| proc = subprocess.run( | |
| cmd, | |
| input=bytes(data) if is_bytes else None, | |
| capture_output=True, | |
| check=False, | |
| timeout=60, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| raise RuntimeError("ffmpeg decode timed out after 60s") from exc | |
| if proc.returncode != 0: | |
| msg = proc.stderr.decode(errors="replace").strip() or "unknown ffmpeg error" | |
| raise RuntimeError(f"ffmpeg decode failed: {msg}") | |
| bio = io.BytesIO(proc.stdout) | |
| wav, sr = sf.read(bio, dtype="float32", always_2d=True) | |
| return wav, sr, fmt_hint | |
| def _read_bytes( | |
| data: Union[bytes, str, Path], | |
| target_sr: int = 16000, | |
| ) -> Tuple[np.ndarray, int, str, str]: | |
| """Returns (waveform_np[T,C], sr, format_hint, decoder_used).""" | |
| # 1) Fast path: soundfile | |
| try: | |
| wav, sr, fmt = _decode_with_soundfile(data) | |
| return wav, sr, fmt, "soundfile" | |
| except Exception as sf_exc: | |
| logger.debug("soundfile decode failed: %s; trying ffmpeg.", sf_exc) | |
| # 2) Fallback: ffmpeg β wav β soundfile | |
| wav, sr, fmt = _decode_with_ffmpeg(data, target_sr=target_sr) | |
| return wav, sr, fmt, "ffmpeg" | |
| # --------------------------------------------------------------------------- # | |
| # Public API | |
| # --------------------------------------------------------------------------- # | |
| def preprocess_audio( | |
| source: Union[bytes, str, Path], | |
| target_sr: int = 16000, | |
| max_seconds: float = 30.0, | |
| ) -> LoadedAudio: | |
| """Load β decode β resample β mono β peak-normalize β clip to ``max_seconds``. | |
| Returns a LoadedAudio with a [1, T] float32 tensor. Raises RuntimeError | |
| with a clear message on decode failure. | |
| """ | |
| wav_np, sr, fmt, decoder = _read_bytes(source, target_sr=target_sr) | |
| file_size = ( | |
| len(source) | |
| if isinstance(source, (bytes, bytearray)) | |
| else Path(source).stat().st_size | |
| ) | |
| orig_channels = int(wav_np.shape[1]) if wav_np.ndim == 2 else 1 | |
| # numpy β torch [C, T] | |
| if wav_np.ndim == 1: | |
| tensor = torch.from_numpy(wav_np).unsqueeze(0) | |
| else: | |
| tensor = torch.from_numpy(wav_np.T) | |
| tensor = tensor.float() | |
| # Stereo β mono | |
| if tensor.shape[0] > 1: | |
| tensor = tensor.mean(dim=0, keepdim=True) | |
| # Resample if needed (ffmpeg path is already at target_sr, but be safe). | |
| if sr != target_sr: | |
| import torchaudio # lazy import; not all paths need it | |
| tensor = torchaudio.functional.resample(tensor, sr, target_sr) | |
| # Trim to max length | |
| max_samples = int(max_seconds * target_sr) | |
| if tensor.shape[1] > max_samples: | |
| tensor = tensor[:, :max_samples] | |
| # Peak-normalise (avoid div-by-zero on silence) | |
| peak = tensor.abs().max() | |
| if peak > 1e-6: | |
| tensor = tensor / (peak + 1e-8) | |
| duration = float(tensor.shape[1]) / target_sr | |
| return LoadedAudio( | |
| waveform=tensor.contiguous(), | |
| sample_rate=target_sr, | |
| original_sample_rate=sr, | |
| original_channels=orig_channels, | |
| duration_seconds=duration, | |
| file_format=fmt, | |
| file_size_bytes=int(file_size), | |
| decoder=decoder, | |
| ) | |
| def waveform_summary(waveform: torch.Tensor, max_points: int = 512) -> list[float]: | |
| """Down-sample the waveform to ``max_points`` envelope values for the UI.""" | |
| x = waveform.squeeze().detach().cpu().numpy().astype(np.float32) | |
| if x.size == 0: | |
| return [] | |
| if x.size <= max_points: | |
| return x.tolist() | |
| bucket_size = x.size // max_points | |
| trimmed = x[: bucket_size * max_points].reshape(max_points, bucket_size) | |
| env = np.abs(trimmed).max(axis=1) | |
| return env.tolist() | |