audio-denoise / audio_io.py
Azuredraco's picture
Despliegue desde GitHub
7e8e229 verified
Raw History Blame Contribute Delete
5.37 kB
"""
audio_io.py — Lectura y escritura de audio sin sorpresas.
- Lee WAV/FLAC/AIFF/OGG/MP3 con soundfile; si falla (M4A, AAC…), usa ffmpeg.
- Devuelve siempre float32 con forma (muestras, canales).
- Escribe en el formato pedido; al bajar a 16 bits aplica dither TPDF.
"""
from __future__ import annotations
import io
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import soundfile as sf
AUDIO_EXTENSIONS = [".wav", ".flac", ".aif", ".aiff", ".ogg", ".opus", ".mp3", ".m4a", ".aac", ".wma"]
# Subtipos que se pueden conservar tal cual al escribir el resultado.
_LOSSLESS = {
"WAV": {"PCM_16", "PCM_24", "PCM_32", "FLOAT", "DOUBLE"},
"FLAC": {"PCM_16", "PCM_24"},
"AIFF": {"PCM_16", "PCM_24", "PCM_32", "FLOAT"},
}
_EXT = {"WAV": ".wav", "FLAC": ".flac", "AIFF": ".aiff"}
SUBTYPE_LABEL = {
"PCM_16": "PCM entero 16 bits",
"PCM_24": "PCM entero 24 bits",
"PCM_32": "PCM entero 32 bits",
"FLOAT": "coma flotante 32 bits",
"DOUBLE": "coma flotante 64 bits",
}
SUBTYPE_BITS = {"PCM_U8": 8, "PCM_S8": 8, "PCM_16": 16, "PCM_24": 24, "PCM_32": 32, "FLOAT": 32, "DOUBLE": 64}
@dataclass
class AudioMeta:
name: str
container: str # WAV, FLAC, MP3…
subtype: str # PCM_24, FLOAT, MPEG_LAYER_III…
samplerate: int
channels: int
frames: int
@property
def duration(self) -> float:
return self.frames / self.samplerate if self.samplerate else 0.0
@property
def bits(self) -> int | None:
return SUBTYPE_BITS.get(self.subtype)
@property
def is_integer_pcm(self) -> bool:
return self.subtype.startswith("PCM_")
def describe(self) -> str:
sub = SUBTYPE_LABEL.get(self.subtype, self.subtype)
ch = "mono" if self.channels == 1 else f"{self.channels} canales"
return f"{self.container} · {sub} · {self.samplerate} Hz · {ch} · {fmt_duration(self.duration)}"
def fmt_duration(sec: float) -> str:
sec = max(0.0, sec)
h, rem = divmod(int(round(sec)), 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def _read_ffmpeg(path: str) -> tuple[np.ndarray, int]:
if not shutil.which("ffmpeg"):
raise RuntimeError("Formato no soportado y ffmpeg no está instalado.")
proc = subprocess.run(
["ffmpeg", "-v", "error", "-i", path, "-vn", "-f", "wav", "-acodec", "pcm_f32le", "-"],
capture_output=True, check=False,
)
if proc.returncode != 0 or not proc.stdout:
raise RuntimeError(f"No se pudo leer el audio: {proc.stderr.decode(errors='ignore')[:300]}")
data, sr = sf.read(io.BytesIO(proc.stdout), dtype="float32", always_2d=True)
return data, sr
def read_audio(path: str | Path, start: float | None = None, stop: float | None = None):
"""Devuelve (datos float32 (n, ch), sr, AudioMeta). start/stop en segundos (opcional)."""
path = str(path)
name = Path(path).name
try:
info = sf.info(path)
meta = AudioMeta(name, info.format, info.subtype, info.samplerate, info.channels, info.frames)
a = int(start * info.samplerate) if start else 0
b = int(stop * info.samplerate) if stop else None
data, sr = sf.read(path, start=a, stop=b, dtype="float32", always_2d=True)
except Exception:
data, sr = _read_ffmpeg(path)
ext = Path(path).suffix.lstrip(".").upper() or "?"
meta = AudioMeta(name, ext, "COMPRIMIDO", sr, data.shape[1], data.shape[0])
if start or stop:
a = int((start or 0) * sr)
b = int(stop * sr) if stop else None
data = data[a:b]
return data, sr, meta
def read_meta(path: str | Path) -> AudioMeta:
try:
info = sf.info(str(path))
return AudioMeta(Path(path).name, info.format, info.subtype, info.samplerate, info.channels, info.frames)
except Exception:
_, _, meta = read_audio(path)
return meta
def output_format(meta: AudioMeta, container: str | None, subtype: str | None) -> tuple[str, str, str]:
"""
Decide (contenedor, subtipo, extensión) de salida.
container/subtype None = "como el original"; si el original es comprimido → WAV 24 bits.
"""
if container is None:
container = meta.container if meta.container in _LOSSLESS else "WAV"
container = container.upper()
if subtype is None:
subtype = meta.subtype if meta.subtype in _LOSSLESS.get(container, set()) else "PCM_24"
if subtype not in _LOSSLESS[container]:
subtype = "PCM_24"
return container, subtype, _EXT[container]
def write_audio(path: str | Path, data: np.ndarray, sr: int, container: str = "WAV", subtype: str = "PCM_24",
seed: int = 0) -> None:
"""Escribe (n, ch) float. En formatos enteros recorta a [-1, 1) y en 16 bits aplica dither TPDF."""
data = np.asarray(data, dtype=np.float64)
if subtype.startswith("PCM_"):
if subtype in ("PCM_16", "PCM_U8", "PCM_S8"):
lsb = 1.0 / (2 ** (SUBTYPE_BITS[subtype] - 1))
rng = np.random.default_rng(seed)
data = data + (rng.random(data.shape) - rng.random(data.shape)) * lsb
bits = SUBTYPE_BITS[subtype]
data = np.clip(data, -1.0, 1.0 - 1.0 / 2 ** (bits - 1))
sf.write(str(path), data, sr, format=container, subtype=subtype)