audio-denoise / profiles.py
Azuredraco's picture
Despliegue desde GitHub
d903372 verified
Raw History Blame Contribute Delete
9.3 kB
"""
profiles.py — Perfiles de especificación (perfiles/*.yaml) y comprobaciones.
Cada comprobación devuelve una fila: parámetro, valor medido, requisito y resultado
(True = cumple, False = no cumple, None = solo informativo).
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from pathlib import Path
import yaml
from audio_io import SUBTYPE_LABEL, AudioMeta, fmt_duration
PROFILES_DIR = Path(__file__).parent / "perfiles"
@dataclass
class Check:
parametro: str
medido: str
requisito: str
ok: bool | None
def __post_init__(self):
# Las medidas son floats de numpy y sus comparaciones dan numpy.bool_, que no es True/False:
# `numpy.False_ is False` es falso. Se normaliza aquí para que «cumple / no cumple» sea fiable.
if self.ok is not None:
self.ok = bool(self.ok)
def load_profiles(directory: Path = PROFILES_DIR) -> dict[str, dict]:
profiles = {}
for path in sorted(directory.glob("*.yaml")):
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
data["_id"] = path.stem
profiles[path.stem] = data
return dict(sorted(profiles.items(), key=lambda kv: (kv[1].get("orden", 99), kv[1].get("nombre", kv[0]))))
def _sec(section) -> dict:
return section if isinstance(section, dict) else {}
def fdb(v: float, unit: str = "dBFS") -> str:
if v is None or (isinstance(v, float) and math.isnan(v)):
return "—"
if math.isinf(v):
return f"{'−' if v < 0 else ''}∞ {unit}"
return f"{v:.1f} {unit}".replace("-", "−")
def fsec(v: float) -> str:
return "—" if v is None or math.isnan(v) else f"{v:.2f} s"
def check(meta: AudioMeta, m: dict, prof: dict) -> list[Check]:
fmt, lvl, nz, sil = _sec(prof.get("formato")), _sec(prof.get("niveles")), _sec(prof.get("ruido")), _sec(prof.get("silencios"))
rows: list[Check] = []
if fmt.get("contenedor"):
rows.append(Check("Formato", meta.container, fmt["contenedor"], meta.container == fmt["contenedor"].upper()))
if fmt.get("pcm_entero") or fmt.get("bits") or fmt.get("bits_min"):
bits = meta.bits or 0
ok = True
req = []
if fmt.get("pcm_entero"):
ok &= meta.is_integer_pcm
req.append("PCM entero")
if fmt.get("bits"):
ok &= bits == fmt["bits"]
req.append(f"{fmt['bits']} bits")
elif fmt.get("bits_min"):
ok &= bits >= fmt["bits_min"]
req.append(f"≥ {fmt['bits_min']} bits")
rows.append(Check("Codificación", SUBTYPE_LABEL.get(meta.subtype, meta.subtype), " · ".join(req), ok))
if fmt.get("frecuencia"):
rows.append(Check("Frecuencia", f"{meta.samplerate} Hz", f"{fmt['frecuencia']} Hz", meta.samplerate == fmt["frecuencia"]))
if fmt.get("canales"):
rows.append(Check("Canales", str(meta.channels), "mono (1)" if fmt["canales"] == 1 else str(fmt["canales"]),
meta.channels == fmt["canales"]))
if lvl.get("pico_max_dbfs") is not None:
rows.append(Check("Pico máximo", fdb(m["pico_dbfs"]), f"≤ {fdb(lvl['pico_max_dbfs'])}", m["pico_dbfs"] <= lvl["pico_max_dbfs"] + 1e-6))
rv = _sec(lvl.get("rms_voz"))
if rv:
rows.append(Check("RMS de voz", fdb(m["rms_voz_dbfs"]), f"{fdb(rv['min'])} a {fdb(rv['max'])}",
rv["min"] <= m["rms_voz_dbfs"] <= rv["max"]))
if lvl.get("amplitud_voz_min") is not None:
rows.append(Check("Amplitud de la voz", f"{m['pico_voz_muestras']} (de 32767)",
f"> {lvl['amplitud_voz_min']} ({fdb(20 * math.log10(lvl['amplitud_voz_min'] / 32767))})",
m["pico_voz_muestras"] > lvl["amplitud_voz_min"]))
if nz.get("ruido_fondo_max_dbfs") is not None:
rows.append(Check("Ruido de fondo", fdb(m["ruido_fondo_dbfs"]), f"≤ {fdb(nz['ruido_fondo_max_dbfs'])}",
m["ruido_fondo_dbfs"] <= nz["ruido_fondo_max_dbfs"]))
if fmt.get("ancho_banda_min_hz") and "ancho_banda_hz" in m:
bw = m["ancho_banda_hz"]
rows.append(Check("Espectro (agudos)", "—" if math.isnan(bw) else f"hasta {bw / 1000:.1f} kHz",
f"más allá de {fmt['ancho_banda_min_hz'] / 1000:.0f} kHz",
(not math.isnan(bw)) and bw >= fmt["ancho_banda_min_hz"]))
zb = m.get("zumbido")
ztxt = f"{zb['base']} Hz · destaca {zb['db']:.0f} dB" if zb else "no"
if nz.get("zumbido_max_db") is not None:
rows.append(Check("Zumbido eléctrico", ztxt, f"≤ {nz['zumbido_max_db']:.0f} dB (sin zumbido)",
zb is None or zb["db"] <= nz["zumbido_max_db"]))
elif "zumbido" in m:
rows.append(Check("Zumbido eléctrico", ztxt, "informativo", None))
if nz.get("ruido_relativo_max_db") is not None:
rows.append(Check("Ruido relativo (inicio/fin)", fdb(m["ruido_relativo_db"], "dB"),
f"≤ {fdb(nz['ruido_relativo_max_db'], 'dB')} vs voz", m["ruido_relativo_db"] <= nz["ruido_relativo_max_db"]))
if nz.get("snr_min_db") is not None:
rows.append(Check("Relación señal/ruido (SNR)", fdb(m["snr_db"], "dB"), f"≥ {nz['snr_min_db']:.0f} dB",
m["snr_db"] >= nz["snr_min_db"]))
if sil:
for key, label in (("silencio_ini_s", "Silencio inicial"), ("silencio_fin_s", "Silencio final")):
v = m[key]
rows.append(Check(label, fsec(v), f"{sil['min_s']:.2f} a {sil['max_s']:.2f} s",
(not math.isnan(v)) and sil["min_s"] - 1e-3 <= v <= sil["max_s"] + 1e-3))
if prof.get("saturacion_max_muestras") is not None:
rows.append(Check("Saturación", f"{m['muestras_saturadas']} muestras", f"≤ {prof['saturacion_max_muestras']}",
m["muestras_saturadas"] <= prof["saturacion_max_muestras"]))
# Informativo
if "pausas_s" in m:
max_p = pause_max(prof)
largas = [p for p in m["pausas_s"] if p > max_p + 0.05]
rows.append(Check(f"Pausas largas (> {max_p:.1f} s)",
f"{len(largas)} · la más larga {max(largas):.1f} s" if largas else "ninguna",
"informativo (se pueden acortar)", None))
if nz.get("ruido_fondo_max_dbfs") is None:
rows.append(Check("Ruido de fondo", fdb(m["ruido_fondo_dbfs"]), "informativo", None))
if not nz.get("snr_min_db"):
rows.append(Check("SNR", fdb(m["snr_db"], "dB"), "informativo", None))
if "lufs" in m:
rows.append(Check("Sonoridad integrada", fdb(m["lufs"], "LUFS"), "informativo", None))
rows.append(Check("Pico real", fdb(m["pico_real_dbtp"], "dBTP"), "informativo", None))
if "dnsmos" in m and m["dnsmos"]:
d = m["dnsmos"]
rows.append(Check("Calidad percibida (DNSMOS)", f"{d['OVRL']:.2f} / 5",
f"voz {d['SIG']:.2f} · fondo {d['BAK']:.2f}", None))
if not m.get("voz_detectada", True):
rows.append(Check("Detección de voz", "no se detecta voz", "—", False))
return rows
def pause_max(prof: dict) -> float:
"""Pausa interna máxima para informar y acortar: la elegida en la app o la del perfil (2 s por defecto)."""
return float(prof.get("_pausa_max_s") or (prof.get("pausas") or {}).get("max_s") or 2.0)
def long_pauses(m: dict, prof: dict) -> list[float]:
return [p for p in m.get("pausas_s", []) if p > pause_max(prof) + 0.05]
def passes(rows: list[Check]) -> bool:
return all(r.ok is not False for r in rows)
def failed(rows: list[Check]) -> list[str]:
return [r.parametro for r in rows if r.ok is False]
# ── Entregables ──────────────────────────────────────────────────────────────
def _norm(name: str) -> str:
stem = Path(name).stem.lower()
return re.sub(r"[\s_\-]+", " ", stem).strip()
def deliverables(prof: dict, files: list[tuple[str, float]]) -> dict | None:
"""files: [(nombre, duración_s)]. Devuelve resumen de entregables o None si el perfil no los define."""
ent = _sec(prof.get("entregables"))
if not ent:
return None
total = sum(d for _, d in files)
out = {"total_s": total, "objetivo_s": ent.get("objetivo_total_s"), "ficheros": []}
by_name = {_norm(n): (n, d) for n, d in files}
for spec in ent.get("ficheros") or []:
found = by_name.get(_norm(spec["nombre"]))
row = {"esperado": spec["nombre"], "fichero": found[0] if found else None,
"duracion_s": found[1] if found else None, "ok": None, "nota": "", "estado": "recibido"}
if not found:
row["ok"], row["nota"], row["estado"] = False, "aún no subido", "pendiente"
elif spec.get("duracion_min_s"):
row["ok"] = found[1] >= spec["duracion_min_s"] - 0.05
row["nota"] = f"mín. {fmt_duration(spec['duracion_min_s'])}"
if not row["ok"]:
row["estado"] = "corto"
elif spec.get("duracion_aprox_s"):
row["nota"] = f"≈ {fmt_duration(spec['duracion_aprox_s'])}"
out["ficheros"].append(row)
return out