audio-denoise / metrics.py
Azuredraco's picture
Despliegue desde GitHub
d903372 verified
Raw History Blame Contribute Delete
9.05 kB
"""
metrics.py — Mediciones técnicas de un audio (las que piden las especificaciones de entrega).
Definiciones (todas en dBFS sobre el canal mezclado a mono):
- Pico: máximo absoluto de muestra. Pico real (dBTP): con sobremuestreo ×4.
- RMS de voz (bloque): RMS entre la primera y la última voz detectada (como el analizador v4).
- RMS de voz activa: RMS solo de las tramas con voz (excluye pausas).
- Ruido relativo: max(RMS de los primeros 0,4 s, RMS de los últimos 0,4 s) − RMS de voz (bloque).
- Ruido de fondo: potencia media de las tramas sin voz (excluye silencio digital).
- SNR: voz activa − ruido de fondo.
- Silencio inicial/final: tiempo antes de la primera voz / después de la última.
- Amplitud de voz: muestra más alta dentro de la zona con voz, en unidades de 16 bits (0–32767).
- Ancho de banda: frecuencia más alta con contenido real (no corte de filtro ni silencio digital).
"""
from __future__ import annotations
import numpy as np
import soxr
import dsp
NOISE_EDGE_SEC = 0.4
def _power_db(frames_db: np.ndarray) -> float:
if frames_db.size == 0:
return -np.inf
return float(10 * np.log10(np.mean(10 ** (frames_db / 10))))
def true_peak_db(mono: np.ndarray, sr: int, block_s: float = 30.0) -> float:
"""Pico real (sobremuestreo ×4), por bloques para no disparar la memoria en ficheros largos."""
blk, pad, peak = int(block_s * sr), 256, 0.0
for i in range(0, len(mono), blk):
a, b = max(0, i - pad), min(len(mono), i + blk + pad)
up = soxr.resample(mono[a:b].astype(np.float32), sr, sr * 4, quality="HQ")
inner = up[(i - a) * 4 : (i - a + min(blk, len(mono) - i)) * 4]
if inner.size:
peak = max(peak, float(np.abs(inner).max()))
return dsp.db(peak)
SPECTRUM_SECONDS = 120 # para el ancho de banda basta una muestra repartida por todo el audio
CLIFF_DB = 20 # caída que delata un filtro: ≥ 20 dB entre el kHz de antes y el de después
def bandwidth_hz(mono: np.ndarray, sr: int) -> float:
"""
Frecuencia hasta la que llega el sonido. Un móvil que graba a 48 kHz «de verdad» tiene ruido de micro y
de sala hasta casi 24 kHz (el filtro del propio conversor actúa por encima de ~22,5 kHz); si el audio
viene de 44,1 kHz, de un códec (AAC, Opus…) o de un filtro, el espectro cae en picado antes.
Se busca el primer «acantilado»: una caída de ≥ 20 dB entre el kHz anterior y el siguiente.
Sin acantilado, el sonido llega hasta arriba (se devuelve la frecuencia máxima evaluable).
"""
from scipy import signal
n = len(mono)
if n < sr // 10:
return float("nan")
seg = int(sr)
if n > int(SPECTRUM_SECONDS * sr):
starts = np.linspace(0, n - seg, int(SPECTRUM_SECONDS)).astype(int)
x = np.concatenate([mono[a : a + seg] for a in starts])
else:
x = mono
f, pxx = signal.welch(x.astype(np.float64), sr, nperseg=min(len(x), 4096))
df = f[1] - f[0]
step = max(1, int(round(100 / df)))
nb = len(pxx) // step
band = 10 * np.log10(np.maximum(pxx[: nb * step].reshape(nb, step).mean(axis=1), 1e-30))
fb = f[: nb * step].reshape(nb, step).mean(axis=1)
k = 10 # 10 bandas de 100 Hz = 1 kHz
last = nb - k - 2
for i in range(int(3000 / (df * step)), max(last, 0)):
before = np.median(band[i - k : i])
after = np.median(band[i + 2 : i + 2 + k])
if before - after >= CLIFF_DB:
return float(fb[i])
return float(fb[last + 1]) if last > 0 else float(fb[-1])
HUM_HARMONICS = 10 # se miran los 10 primeros armónicos (hasta 500/600 Hz)
HUM_MIN_DB = 10.0 # un armónico «es zumbido» si destaca ≥ 10 dB sobre las frecuencias vecinas
def hum(mono: np.ndarray, sr: int) -> dict | None:
"""
Zumbido eléctrico de la red (50 Hz en Europa, 60 Hz en América) y sus armónicos.
Es un tono fijo: en un espectro de 1 Hz de resolución sobre todo el audio forma picos estrechos,
mientras que la voz (que cambia de tono continuamente) se reparte. Devuelve
{'base': 50|60, 'db': prominencia máxima, 'armonicos': [Hz con ≥ 6 dB]} o None.
"""
from scipy import signal
n = len(mono)
if n < 2 * sr:
return None
seg = int(2 * sr)
if n > 120 * sr:
starts = np.linspace(0, n - seg, 60).astype(int)
x = np.concatenate([mono[a : a + seg] for a in starts])
else:
x = mono
f, pxx = signal.welch(x.astype(np.float64), sr, nperseg=seg)
pdb = 10 * np.log10(np.maximum(pxx, 1e-30))
df = f[1] - f[0]
best = None
for base in (50, 60):
prom = []
for k in range(1, HUM_HARMONICS + 1):
fk = base * k
i = int(round(fk / df))
if i + 12 >= len(pdb):
break
peak = pdb[max(0, i - 1) : i + 2].max()
neigh = np.concatenate([pdb[max(0, i - 12) : max(0, i - 3)], pdb[i + 4 : i + 13]])
prom.append((fk, float(peak - np.median(neigh))))
strong = [fk for fk, d in prom if d >= 6]
top = max((d for _, d in prom), default=0.0)
# hace falta un armónico claro y al menos otro: un solo pico aislado puede ser otra cosa
if top >= HUM_MIN_DB and len(strong) >= 2 and (best is None or top > best["db"]):
best = {"base": base, "db": top, "armonicos": strong}
return best
def measure(data: np.ndarray, sr: int, with_lufs: bool = True, detector: str | None = None) -> dict:
"""data: (n, ch) float32. Devuelve un dict de métricas (floats en dB / segundos)."""
mono = dsp.to_mono(data)[:, 0]
n = len(mono)
peak_abs = float(np.abs(data).max()) if n else 0.0
region = dsp.voice_region(mono, sr, detector)
voice = mono[region.start : region.end + 1] if region.found else mono
fdb = region.frame_db
live = fdb > dsp.DIGITAL_SILENCE_DB
voiced = fdb >= region.start_th_db
noise_frames = fdb[live & (fdb < region.noise_db + 6)] if np.isfinite(region.noise_db) else np.array([])
voice_active_db = _power_db(fdb[voiced]) if voiced.any() else -np.inf
noise_floor_db = _power_db(noise_frames)
w = int(NOISE_EDGE_SEC * sr)
edge_noise = max(dsp.rms_db(mono[: min(w, n)]), dsp.rms_db(mono[max(0, n - w) :]))
voice_rms = dsp.rms_db(voice)
m = {
"duracion_s": n / sr if sr else 0.0,
"pico_dbfs": dsp.db(peak_abs),
"pico_real_dbtp": true_peak_db(mono, sr) if n else -np.inf,
"muestras_saturadas": int((np.abs(data) >= 0.999).any(axis=1).sum()),
"dc_offset": float(np.mean(mono)) if n else 0.0,
"rms_voz_dbfs": voice_rms,
"rms_voz_activa_dbfs": voice_active_db,
"ruido_fondo_dbfs": noise_floor_db,
"ruido_relativo_db": edge_noise - voice_rms,
"snr_db": voice_active_db - noise_floor_db,
"silencio_ini_s": region.start / sr if region.found else np.nan,
"silencio_fin_s": (n - 1 - region.end) / sr if region.found else np.nan,
"voz_detectada": region.found,
"silencio_digital": bool(not live.any()) or bool(noise_frames.size == 0),
"pico_voz_muestras": min(32767, int(round(float(np.abs(voice).max()) * 32767))) if voice.size else 0,
"ancho_banda_hz": bandwidth_hz(mono, sr),
"zumbido": hum(mono, sr),
"pausas_s": [(b - a) / sr for a, b in dsp.find_pauses(mono, sr, region)],
}
if with_lufs:
m["lufs"] = lufs(data, sr)
return m
def lufs(data: np.ndarray, sr: int) -> float:
"""Sonoridad integrada ITU-R BS.1770-4 (ponderación K, bloques de 400 ms, puertas −70 y −10)."""
if data.shape[0] < int(0.4 * sr):
return np.nan
# Filtros K (fórmulas de BS.1770 válidas para cualquier frecuencia de muestreo)
from scipy import signal
f0, g, q = 1681.974450955533, 3.999843853973347, 0.7071752369554196
k = np.tan(np.pi * f0 / sr)
vh = 10 ** (g / 20)
vb = vh ** 0.4996667741545416
a0 = 1 + k / q + k * k
shelf = [(vh + vb * k / q + k * k) / a0, 2 * (k * k - vh) / a0, (vh - vb * k / q + k * k) / a0,
1, 2 * (k * k - 1) / a0, (1 - k / q + k * k) / a0]
f0, q = 38.13547087602444, 0.5003270373238773
k = np.tan(np.pi * f0 / sr)
a0 = 1 + k / q + k * k
hp = [1, -2, 1, 1, 2 * (k * k - 1) / a0, (1 - k / q + k * k) / a0]
sos = np.array([shelf, hp])
blk, hop = int(0.4 * sr), int(0.1 * sr)
power = None
for c in range(data.shape[1]):
z = signal.sosfilt(sos, data[:, c]).astype(np.float32) ** 2
cs = np.concatenate([[0.0], np.cumsum(z, dtype=np.float64)])
starts = np.arange(0, len(z) - blk + 1, hop)
ms = (cs[starts + blk] - cs[starts]) / blk
power = ms if power is None else power + ms
lk = -0.691 + 10 * np.log10(np.maximum(power, 1e-20))
gated = power[lk > -70]
if gated.size == 0:
return -np.inf
rel = -0.691 + 10 * np.log10(gated.mean()) - 10
gated = power[(lk > -70) & (lk > rel)]
return float(-0.691 + 10 * np.log10(gated.mean())) if gated.size else -np.inf