File size: 9,297 Bytes
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a957eb
 
 
 
 
 
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c6ff29
 
 
 
 
 
 
 
 
 
 
 
d903372
 
 
 
 
 
 
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a957eb
 
 
 
 
 
4c6ff29
 
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
7a957eb
 
 
 
 
 
 
 
 
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e63d99
7e8e229
2e63d99
7e8e229
 
 
2e63d99
 
7e8e229
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
"""
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