audio-denoise / pipeline.py
Azuredraco's picture
Despliegue desde GitHub
d903372 verified
Raw History Blame Contribute Delete
22.7 kB
"""
pipeline.py — Analizar y corregir ficheros según un perfil de especificación.
Principio: corrección MÍNIMA. Se mide, se ve qué comprobaciones no cumplen y se aplica SOLO
la corrección de cada una (si solo falla el volumen, solo se ajusta el volumen). Después se
vuelve a medir: si una corrección provoca un fallo nuevo (p. ej. al subir el volumen asoma el
ruido), se añade solo la corrección de ese fallo y se repite. Si un audio cumple, no se toca.
Pulido automático (opcional, activado por defecto en la app): además se prueba a quitar ruido de
fondo y retumbes; solo se conserva si la nota de calidad (DNSMOS) sube y la voz no empeora.
Correcciones posibles (se aplican en este orden, solo las necesarias):
mono → frecuencia → ruido (paso alto + DeepFilterNet, escalonado) → silencios (solo el lado
que falla) → volumen (ganancia + limitador) o solo picos (limitador) → formato de escritura.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import dsp
import metrics
import profiles
from audio_io import AudioMeta, output_format, read_audio, read_meta, write_audio
NOISE_CHECKS = {"Relación señal/ruido (SNR)", "Ruido relativo (inicio/fin)", "Ruido de fondo"}
# Qué corrección arregla cada comprobación del perfil
FIX_FOR = {
"Formato": "formato", "Codificación": "formato", "Frecuencia": "frecuencia", "Canales": "mono",
"Pico máximo": "picos", "Saturación": "picos", "RMS de voz": "volumen",
"Ruido relativo (inicio/fin)": "ruido", "Relación señal/ruido (SNR)": "ruido",
"Silencio inicial": "sil_ini", "Silencio final": "sil_fin",
"Amplitud de la voz": "amplitud", "Ruido de fondo": "ruido", "Zumbido eléctrico": "zumbido",
}
# Niveles de procesado: qué correcciones puede usar cada uno (lo elige el usuario en la app)
MODES = {
"formato": {"formato", "mono", "frecuencia"}, # el sonido no cambia
"permitido": {"formato", "mono", "frecuencia", "volumen", "picos", # + niveles y silencios
"amplitud", "sil_ini", "sil_fin"},
"ninja": None, # todo, + pulido sutil
"auto": None, # lo mínimo que haga falta, sin pulido
}
MODE_LABEL = {"formato": "Solo formato", "permitido": "Permitido", "ninja": "Ninja", "auto": "Automático"}
# Qué nivel hace falta para cada corrección (para decir «ha hecho falta: Permitido»)
LEVEL_OF = {"formato": "formato", "mono": "formato", "frecuencia": "formato",
"volumen": "permitido", "picos": "permitido", "amplitud": "permitido", "sil_ini": "permitido",
"sil_fin": "permitido", "pausas": "permitido",
"ruido": "ninja", "eventos": "ninja", "zumbido": "ninja"}
FIX_WORDS = {"formato": "formato", "mono": "canales (a mono)", "frecuencia": "frecuencia", "volumen": "volumen",
"picos": "picos", "amplitud": "volumen", "sil_ini": "silencio inicial", "sil_fin": "silencio final",
"pausas": "pausas largas", "ruido": "ruido de fondo", "eventos": "ruidos sueltos",
"zumbido": "zumbido eléctrico"}
def level_used(fixes: set[str]) -> str | None:
"""Nivel más alto que han necesitado las correcciones aplicadas (None = no se ha tocado)."""
levels = {LEVEL_OF.get(f) for f in fixes} - {None}
for lv in ("ninja", "permitido", "formato"):
if lv in levels:
return lv
return None
def _fixes_for(rows, mode: str = "ninja") -> set[str]:
fixes = {FIX_FOR[r.parametro] for r in rows if r.ok is False and r.parametro in FIX_FOR}
allowed = MODES.get(mode)
return fixes if allowed is None else fixes & allowed
def default_mode(prof: dict) -> str:
return (prof.get("procesado") or {}).get("modo_defecto") or "ninja"
@dataclass
class Settings:
reduccion_db: float | None = 24.0
paso_alto_hz: float | None = 70.0
conservar_ultrasonidos: bool = True
forzar_mono: bool = False
usar_gpu: bool = True
escalar_reduccion: bool = True
dnsmos: bool = True # mostrar la nota de calidad antes/después
pulir: bool = True # pulido automático (solo se conserva si mejora la nota)
acortar_pausas: bool = False # acortar pausas internas más largas que profiles.pause_max()
modo: str = "ninja" # formato | permitido | ninja (ver MODES)
quitar_eventos: bool = False # quitar ruidos sueltos en las pausas (cláxones, golpes…)
@classmethod
def from_profile(cls, prof: dict, **overrides) -> "Settings":
p = prof.get("procesado") or {}
s = cls(
reduccion_db=p.get("reduccion_db", 24.0),
paso_alto_hz=p.get("paso_alto_hz", 70.0),
conservar_ultrasonidos=p.get("conservar_ultrasonidos", True),
forzar_mono=(prof.get("formato") or {}).get("canales") == 1,
modo=default_mode(prof),
)
for k, v in overrides.items():
if v is not None or k in ("reduccion_db", "paso_alto_hz"):
setattr(s, k, v)
return s
@dataclass
class Analysis:
path: str
meta: AudioMeta | None
m: dict = field(default_factory=dict)
rows: list = field(default_factory=list)
error: str | None = None
@property
def ok(self) -> bool:
return self.error is None and profiles.passes(self.rows)
@dataclass
class Correction:
before: Analysis
after: Analysis | None = None
out_path: str | None = None
notes: list = field(default_factory=list)
error: str | None = None
fixes: set = field(default_factory=set) # correcciones aplicadas de verdad
pulido: bool = False # se ha conservado el pulido automático
eventos: list = field(default_factory=list) # ruidos sueltos quitados: [(inicio_s, duración_s, dB)]
def _needed_reduction(m: dict, prof: dict) -> float:
"""dB de reducción que haría falta, con margen, para cumplir los requisitos de ruido del perfil."""
nz = prof.get("ruido") or {}
need = 0.0
if nz.get("snr_min_db") is not None and math.isfinite(m.get("snr_db", math.inf)):
need = max(need, nz["snr_min_db"] - m["snr_db"] + 6)
if nz.get("ruido_relativo_max_db") is not None and math.isfinite(m.get("ruido_relativo_db", -math.inf)):
need = max(need, m["ruido_relativo_db"] - nz["ruido_relativo_max_db"] + 6)
if nz.get("ruido_fondo_max_dbfs") is not None and math.isfinite(m.get("ruido_fondo_dbfs", -math.inf)):
need = max(need, m["ruido_fondo_dbfs"] - nz["ruido_fondo_max_dbfs"] + 6)
return need
def analyze(path: str, prof: dict, with_dnsmos: bool = False) -> Analysis:
try:
data, sr, meta = read_audio(path)
if data.shape[0] == 0:
raise ValueError("El fichero está vacío")
m = metrics.measure(data, sr, detector=prof.get("deteccion_voz"))
if with_dnsmos:
import dnsmos
m["dnsmos"] = dnsmos.score(dsp.to_mono(data)[:, 0], sr)
return Analysis(path, meta, m, profiles.check(meta, m, prof))
except Exception as e:
return Analysis(path, None, error=f"{type(e).__name__}: {e}")
def _voice_rms(a: np.ndarray, sr: int, prof: dict) -> float:
mono = dsp.to_mono(a)[:, 0]
r = dsp.voice_region(mono, sr, prof.get("deteccion_voz"))
return dsp.rms_db(mono[r.start : r.end + 1]) if r.found else dsp.rms_db(mono)
def _ceiling(prof: dict) -> float:
peak_max = (prof.get("niveles") or {}).get("pico_max_dbfs")
return (peak_max if peak_max is not None else -0.1) - 0.05
def _fix_volume(y: np.ndarray, sr: int, prof: dict, notes: list) -> np.ndarray:
"""Ganancia hasta el RMS de voz objetivo; el limitador solo actúa si algún pico pasa del techo."""
rv = (prof.get("niveles") or {}).get("rms_voz") or {}
target = rv.get("objetivo", (rv["min"] + rv["max"]) / 2)
total, lim = 0.0, None
for _ in range(4):
cur = _voice_rms(y, sr, prof)
if not math.isfinite(cur) or abs(target - cur) < 0.05:
break
y = y * np.float32(10 ** ((target - cur) / 20))
total += target - cur
y, lim = dsp.peak_limit(y, sr, _ceiling(prof))
notes.append(f"Volumen de la voz {total:+.1f} dB → {_voice_rms(y, sr, prof):.1f} dBFS (objetivo {target:.1f})".replace("-", "−"))
if lim and lim["muestras_afectadas"]:
notes.append(f"Picos suavizados para no pasar del límite ({lim['muestras_afectadas'] / sr * 1000:.0f} ms en total, "
f"como mucho {lim['reduccion_max_db']:.1f} dB)")
return y
def _fix_peaks(y: np.ndarray, sr: int, prof: dict, notes: list, clipped: bool) -> np.ndarray:
"""Solo baja los picos que pasan del techo; el resto del audio no cambia."""
y, info = dsp.peak_limit(y, sr, _ceiling(prof))
if info["muestras_afectadas"]:
notes.append(f"Picos suavizados para no pasar de {_ceiling(prof) + 0.05:.1f} dBFS "
f"({info['muestras_afectadas'] / sr * 1000:.0f} ms en total, como mucho "
f"{info['reduccion_max_db']:.1f} dB); el resto del audio no cambia".replace("-", "−"))
if clipped:
notes.append("Había muestras saturadas: se han bajado, pero si se oye distorsión conviene regrabar ese trozo")
return y
def _fix_amplitude(y: np.ndarray, sr: int, prof: dict, notes: list) -> np.ndarray:
"""Ganancia para que el pico de la voz quede holgado sobre el mínimo (1,6× el mínimo, ≈ +4 dB);
el limitador solo actúa si algún pico pasa del techo."""
amin = (prof.get("niveles") or {}).get("amplitud_voz_min")
mono = dsp.to_mono(y)[:, 0]
r = dsp.voice_region(mono, sr, prof.get("deteccion_voz"))
peak = float(np.abs(mono[r.start : r.end + 1] if r.found else mono).max())
if not amin or peak <= 0:
return y
target = min(1.6 * amin / 32767, 10 ** (_ceiling(prof) / 20))
g = 20 * math.log10(target / peak)
y, lim = dsp.peak_limit(y * np.float32(10 ** (g / 20)), sr, _ceiling(prof))
notes.append(f"Volumen subido {g:+.1f} dB para que la voz pase de {amin} (ahora llega a ≈ {target * 32767:.0f})"
.replace("-", "−"))
if lim and lim["muestras_afectadas"]:
notes.append(f"Picos suavizados para no pasar del límite ({lim['muestras_afectadas'] / sr * 1000:.0f} ms en total)")
return y
def _apply(data, sr, meta, prof, settings, fixes, att, expander, cache, progress):
"""Aplica al original solo las correcciones de `fixes`. Devuelve (audio, sr, notas)."""
fmt = prof.get("formato") or {}
notes, x = [], data
if "mono" in fixes and x.shape[1] > 1:
x = dsp.to_mono(x)
notes.append(f"{meta.channels} canales → mono")
if "frecuencia" in fixes and fmt.get("frecuencia") and fmt["frecuencia"] != sr:
x = dsp.resample(x, sr, fmt["frecuencia"])
notes.append(f"Frecuencia de muestreo cambiada de {sr} a {fmt['frecuencia']} Hz")
sr = fmt["frecuencia"]
if "zumbido" in fixes:
zb = metrics.hum(dsp.to_mono(data)[:, 0], meta.samplerate)
if zb:
x = dsp.remove_hum(x, sr, zb["armonicos"])
notes.append(f"Zumbido eléctrico de {zb['base']} Hz quitado (armónicos en "
f"{', '.join(str(int(f)) for f in zb['armonicos'])} Hz); la voz no cambia")
if "eventos" in fixes:
if "eventos" not in cache:
import vad
if progress:
progress(0.1, "Buscando ruidos sueltos en las pausas")
cache["eventos"] = dsp.remove_events(x, sr, vad.speech_prob(dsp.to_mono(x)[:, 0], sr), vad.HOP_S)
x, ev = cache["eventos"]
if ev:
from audio_io import fmt_duration
lista = ", ".join(f"{fmt_duration(t)} ({d:.1f} s)" for t, d, _ in ev[:12]) + (" …" if len(ev) > 12 else "")
notes.append(f"Ruidos sueltos en las pausas sustituidos por el silencio de la sala: {len(ev)} — {lista}")
else:
notes.append("Ruidos sueltos en las pausas: no se ha encontrado ninguno")
if "ruido" in fixes:
key = (sr, x.shape[1], att, "eventos" in fixes)
if key not in cache:
def prog(f):
if progress:
progress(f, f"Reduciendo ruido ({'sin límite' if att is None else f'{att:.0f} dB'})")
xh = dsp.highpass(x, sr, settings.paso_alto_hz) if settings.paso_alto_hz else x
cache[key] = dsp.denoise(xh, sr, att, keep_ultrasonic=settings.conservar_ultrasonidos,
use_gpu=settings.usar_gpu, progress=prog)
x, info = cache[key]
if settings.paso_alto_hz:
notes.append(f"Retumbes y golpes graves quitados (por debajo de {settings.paso_alto_hz:.0f} Hz)")
notes.append(f"Ruido de fondo reducido ({'al máximo' if att is None else f'hasta {att:.0f} dB'})"
f" · procesado en {info['dispositivo']}")
notes += info["avisos"]
if expander:
x, ex = dsp.pause_expander(x, sr)
notes.append(f"Ruido que quedaba en las pausas bajado 15 dB más ({ex['pausas_pct']:.0f} % del audio son pausas)")
if "pausas" in fixes:
region = dsp.voice_region(dsp.to_mono(x)[:, 0], sr, prof.get("deteccion_voz"))
x, cut = dsp.shorten_pauses(x, sr, region, profiles.pause_max(prof))
if cut:
notes.append(f"Pausas largas acortadas a {profiles.pause_max(prof):.1f} s: {len(cut)} "
f"(duraban {', '.join(f'{c:.1f}' for c in cut)} s)")
sides = tuple(s for s in ("ini", "fin") if f"sil_{s}" in fixes)
sil = prof.get("silencios") or None
if sides and sil:
# Iterativo: el detector (sobre todo el del analizador v4) estima el ruido con los extremos del
# audio, así que al cambiarlos puede mover un poco dónde «empieza» o «acaba» la voz.
target = sil.get("objetivo_s", 0.6)
first = True
for _ in range(4):
region = dsp.voice_region(dsp.to_mono(x)[:, 0], sr, prof.get("deteccion_voz"))
if not region.found:
notes.append("No se detecta voz: no se pueden ajustar los silencios")
break
head, tail = region.start / sr, (x.shape[0] - 1 - region.end) / sr
pending = tuple(sd for sd in sides if abs((head if sd == "ini" else tail) - target) > 0.02)
if not pending:
break
x, sn = dsp.set_edge_silences(x, sr, region, target, sil.get("relleno", "tono_sala"), pending)
if first:
notes += sn
first = False
if "volumen" in fixes and (prof.get("niveles") or {}).get("rms_voz"):
x = _fix_volume(x, sr, prof, notes)
elif "amplitud" in fixes:
x = _fix_amplitude(x, sr, prof, notes)
elif "picos" in fixes:
x = _fix_peaks(x, sr, prof, notes, clipped=bool((np.abs(data) >= 0.999).any()))
return x, sr, notes
POLISH_MIN_GAIN = 0.05 # el pulido se queda solo si la nota global sube al menos esto…
POLISH_MAX_SIG_LOSS = 0.05 # …y la nota de la voz no baja más que esto
def _solve(data, sr, meta, prof, settings, fixes, container, subtype, before_m, progress):
"""
Aplica `fixes`, vuelve a medir y añade solo lo que haga falta si aparecen fallos nuevos
(o sube la reducción de ruido si el ruido sigue sin cumplir). Devuelve (audio, sr, filas, notas).
"""
fixes = set(fixes)
notes: list[str] = []
start = settings.reduccion_db
if settings.escalar_reduccion and start is not None and "ruido" in fixes:
need = _needed_reduction(before_m, prof)
if need > start:
start = start + 12 if need <= start + 12 else None
ladder = [(start, False)]
if settings.escalar_reduccion:
if start is not None:
ladder += [(lv, False) for lv in (start + 12, None) if lv != start]
ladder.append((None, True))
cache: dict = {}
step = 0
for _ in range(8):
att, expander = ladder[step]
y, out_sr, step_notes = _apply(data, sr, meta, prof, settings, fixes, att, expander, cache, progress)
m = metrics.measure(y, out_sr, with_lufs=False, detector=prof.get("deteccion_voz"))
rows = profiles.check(AudioMeta(meta.name, container, subtype, out_sr, y.shape[1], y.shape[0]), m, prof)
new = _fixes_for(rows, settings.modo) - fixes
noise_left = [r.parametro for r in rows if r.ok is False and r.parametro in NOISE_CHECKS]
if new:
notes.append("Al corregir aparecieron fallos nuevos ("
+ ", ".join(r.parametro for r in rows if r.ok is False and FIX_FOR.get(r.parametro) in new)
+ "): se corrigen también")
fixes |= new
continue
if noise_left and "ruido" in fixes and step < len(ladder) - 1:
step += 1
notes.append(f"Seguía sin cumplir «{', '.join(noise_left)}»: se insiste con más reducción de ruido")
continue
break
return y, out_sr, rows, notes + step_notes, fixes, (cache.get("eventos") or (None, []))[1]
def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=None,
before: Analysis | None = None) -> Correction:
"""
Corrige `path` según `prof`:
1. Cumplimiento: corrección mínima (solo lo que no cumple).
2. Pulido automático (opcional): quitar ruido de fondo y retumbes con criterio de estudio. Solo se
conserva si la nota de calidad (DNSMOS) confirma que suena mejor y no empeora la voz.
`before`: análisis ya hecho del original (se reutiliza para no medir dos veces).
"""
if before is None:
before = analyze(path, prof)
res = Correction(before)
if before.error:
res.error = before.error
return res
try:
import dnsmos
data, sr, meta = read_audio(path)
notes = res.notes
if (settings.dnsmos or settings.pulir) and "dnsmos" not in before.m:
before.m["dnsmos"] = dnsmos.score(dsp.to_mono(data)[:, 0], sr)
if settings.dnsmos:
before.rows = profiles.check(meta, before.m, prof)
fixes = _fixes_for(before.rows, settings.modo)
if settings.modo != "ninja":
settings.pulir = False # «auto» es lo mínimo que haga falta: sin pulido
if settings.forzar_mono and meta.channels > 1: # pedido expresamente aunque el perfil no lo exija
fixes.add("mono")
if settings.acortar_pausas and profiles.long_pauses(before.m, prof):
fixes.add("pausas")
if settings.quitar_eventos:
fixes.add("eventos")
zb = before.m.get("zumbido")
if settings.modo == "ninja" and zb and zb["db"] >= metrics.HUM_MIN_DB:
fixes.add("zumbido") # en Ninja el zumbido se quita siempre que se detecte
failing = profiles.failed(before.rows)
out_dir.mkdir(parents=True, exist_ok=True)
# Formato de escritura: el original salvo que falle el formato o la codificación.
salida = (prof.get("procesado") or {}).get("salida") or {}
if "formato" in fixes:
container, subtype, ext = output_format(meta, salida.get("contenedor"), salida.get("subtipo"))
else:
container, subtype, ext = output_format(meta, None, None)
if failing:
stuck = [r.parametro for r in before.rows if r.ok is False and FIX_FOR.get(r.parametro) not in fixes]
fixable = [f for f in failing if f not in stuck]
if fixable:
notes.append(f"No cumplía: {', '.join(fixable)} → se corrige solo eso")
if stuck:
why = [f for f in stuck if FIX_FOR.get(f)]
notes.append(f"Nivel «{MODE_LABEL.get(settings.modo, settings.modo)}»: no se toca {', '.join(stuck)}"
+ (" (se corregiría subiendo el nivel de procesado)" if why else " (no tiene corrección automática)"))
# 1) Cumplimiento
base = _solve(data, sr, meta, prof, settings, fixes, container, subtype, before.m, progress) if fixes else None
# 2) Pulido automático, solo si mejora de verdad
chosen = base
if settings.pulir and "ruido" not in fixes:
if progress:
progress(0.5, "Pulido automático")
cand = _solve(data, sr, meta, prof, settings, fixes | {"ruido"}, container, subtype, before.m, progress)
ref = dsp.to_mono(base[0])[:, 0] if base else dsp.to_mono(data)[:, 0]
ref_sr = base[1] if base else sr
s_ref = dnsmos.score(ref, ref_sr) if base else before.m.get("dnsmos")
s_new = dnsmos.score(dsp.to_mono(cand[0])[:, 0], cand[1])
ref_ok = profiles.passes(base[2]) if base else profiles.passes(before.rows)
if s_ref and s_new and (profiles.passes(cand[2]) or not ref_ok) \
and s_new["OVRL"] >= s_ref["OVRL"] + POLISH_MIN_GAIN and s_new["SIG"] >= s_ref["SIG"] - POLISH_MAX_SIG_LOSS:
chosen = cand
notes.append(f"Pulido automático aplicado: suena mejor (nota {s_ref['OVRL']:.2f} → {s_new['OVRL']:.2f})")
elif s_ref and s_new:
notes.append(f"Pulido automático probado y descartado: no mejoraba el sonido "
f"(nota {s_ref['OVRL']:.2f} → {s_new['OVRL']:.2f})")
if chosen is None: # cumple y el pulido no aporta: no se toca
out_path = out_dir / Path(path).name
if Path(path).resolve() != out_path.resolve():
import shutil
shutil.copyfile(path, out_path)
res.out_path, res.after = str(out_path), before
notes.append("Cumplía todo: no se ha tocado" if before.ok else
"No se ha tocado: lo que falla no se corrige con este nivel de procesado")
return res
y, out_sr, _, step_notes, res.fixes, res.eventos = chosen
res.pulido = chosen is not base
notes += step_notes
out_path = out_dir / (Path(meta.name).stem + ext)
write_audio(out_path, y, out_sr, container, subtype)
res.out_path = str(out_path)
res.after = analyze(str(out_path), prof, with_dnsmos=settings.dnsmos)
if res.after.meta:
notes.append(f"Guardado como {res.after.meta.describe()}")
except Exception as e:
res.error = f"{type(e).__name__}: {e}"
return res
def quick_meta(path: str) -> AudioMeta | None:
try:
return read_meta(path)
except Exception:
return None