""" ui_html.py — Tarjetas HTML de resultados (se adaptan a móvil y a modo claro/oscuro). """ from __future__ import annotations from html import escape import math from pathlib import Path from audio_io import SUBTYPE_LABEL, fmt_duration CSS = """ .gradio-container { max-width: 1100px !important; margin: auto; } #cabecera h1 { margin: 0; font-size: clamp(1.4rem, 1.1rem + 1.4vw, 2.1rem); } #cabecera p { margin: .25rem 0 0; opacity: .75; } #cabecera-fila { flex-wrap: nowrap !important; align-items: center; } #cabecera-fila > :first-child { min-width: 0 !important; flex: 1 1 auto; } #cabecera h1 { overflow-wrap: anywhere; } #tema-btn { max-width: 52px; min-width: 52px; font-size: 1.2rem; flex: 0 0 52px; } .ad-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: .6rem; margin: .4rem 0 .8rem; } .ad-stat { padding: .7rem .8rem; border-radius: .7rem; border: 1px solid var(--border-color-primary); background: var(--block-background-fill); } .ad-stat span { display: block; font-size: .78rem; opacity: .7; margin-bottom: .2rem; } .ad-stat strong { font-size: 1.25rem; font-variant-numeric: tabular-nums; } .ad-banner { padding: .8rem 1rem; border-radius: .7rem; font-weight: 700; text-align: center; margin-bottom: .8rem; } .ad-card { border: 1px solid var(--border-color-primary); border-radius: .8rem; margin-bottom: .8rem; overflow: hidden; background: var(--block-background-fill); } .ad-head { display: flex; justify-content: space-between; gap: .6rem; align-items: flex-start; padding: .7rem .9rem; border-bottom: 1px solid var(--border-color-primary); flex-wrap: wrap; } .ad-name { font-weight: 700; word-break: break-all; } .ad-meta { font-size: .82rem; opacity: .75; margin-top: .15rem; } .ad-badge, .ad-pill { display: inline-block; border-radius: 999px; font-weight: 700; white-space: nowrap; } .ad-badge { padding: .3rem .7rem; font-size: .8rem; } .ad-pill { padding: .12rem .5rem; font-size: .74rem; } .ok { background: #e3f1d9; color: #2f5f14; } .bad { background: #f6dceb; color: #8a1f62; } .fix { background: #fbe6d5; color: #8a3a12; } .info { background: var(--background-fill-secondary); color: var(--body-text-color-subdued); } .dark .ok { background: #23361a; color: #9fd67a; } .dark .bad { background: #3d1a2f; color: #f09bd0; } .dark .fix { background: #3c2414; color: #f0a877; } .ad-table { width: 100%; border-collapse: collapse; font-size: .86rem; } .ad-table td, .ad-table th, .ad-name, .ad-meta { overflow-wrap: anywhere; } .ad-card, .ad-stats { max-width: 100%; min-width: 0; } .ad-table th { text-align: left; font-size: .74rem; opacity: .7; font-weight: 700; padding: .4rem .9rem; } .prose .ad-table th, .prose .ad-table td { border: none !important; } .prose .ad-table td { border-top: 1px solid var(--border-color-primary) !important; } .prose .ad-table { margin: 0 !important; border: none !important; } .ad-table td:first-child, .ad-table th:first-child { min-width: 7.5rem; } .ad-table td { padding: .38rem .9rem; border-top: 1px solid var(--border-color-primary); vertical-align: top; } .ad-table td.num { font-variant-numeric: tabular-nums; white-space: nowrap; } .ad-table tr.informativo td { opacity: .75; } .ad-notes { padding: .6rem .9rem .8rem; font-size: .8rem; line-height: 1.55; opacity: .85; border-top: 1px solid var(--border-color-primary); } .ad-notes ul { margin: .2rem 0 0 1.1rem; padding: 0; } .ad-img { min-width: 0 !important; max-width: 100%; } /* Evita que contenidos anchos (onda de audio, imágenes) ensanchen la página en el móvil */ .gradio-container .tabitem, .gradio-container .column, .gradio-container .row > * { min-width: 0 !important; } .gradio-container .audio-container, .gradio-container #waveform, .gradio-container .waveform-container, .gradio-container .component-wrapper { max-width: 100%; overflow: hidden; contain: inline-size; } .ad-img img { max-width: 100% !important; height: auto !important; object-fit: contain; } .ad-steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .5rem; margin: .2rem 0 .4rem; } .ad-steps div { padding: .55rem .7rem; border-radius: .6rem; border: 1px dashed var(--border-color-primary); font-size: .9rem; background: var(--block-background-fill); } .ad-steps b { display: inline-grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 50%; margin-right: .35rem; background: var(--color-accent); color: #fff; font-size: .8rem; } .ad-profile { padding: .2rem .2rem 0; } .ad-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } .ad-dl { display: inline-block; padding: .35rem .8rem; border-radius: .5rem; font-weight: 700; font-size: .85rem; background: var(--button-primary-background-fill); color: var(--button-primary-text-color) !important; text-decoration: none !important; } .ad-dl:hover { filter: brightness(1.08); } #zip-btn { font-size: 1.05rem; } .ad-quality { border: 1px solid var(--border-color-primary); border-radius: .7rem; padding: .2rem .4rem; } .ad-quality { flex-wrap: wrap !important; } .ad-quality > *, .ad-quality > .form > * { min-width: min(280px, 100%) !important; } .ad-quality > .form { flex-wrap: wrap !important; } .ad-desktop { font-size: .88rem; padding: .5rem .8rem; border-radius: .6rem; background: var(--background-fill-secondary); } .ad-summary { padding: .6rem .9rem; border-bottom: 1px solid var(--border-color-primary); font-size: .9rem; line-height: 1.55; background: var(--background-fill-secondary); } .ad-summary ul { margin: 0; padding-left: 1.1rem; } .ad-summary ul ul { margin: .15rem 0 .2rem; } .ad-gloss { padding: .5rem .9rem .8rem; font-size: .84rem; border-top: 1px solid var(--border-color-primary); } .ad-gloss summary { cursor: pointer; font-weight: 700; } .ad-gloss ul { margin: .4rem 0 0 1.1rem; padding: 0; line-height: 1.5; } .ad-chg td { border-top: 1px solid var(--border-color-primary); } .ad-chg tr.mean td { border-top: none; padding-top: 0; font-size: .82rem; opacity: .85; } .ad-chg .req-s { display: block; font-size: .74rem; opacity: .65; font-weight: 400; } .ad-chg .d-good { color: #1a9e55; font-weight: 700; } .ad-chg .d-bad { color: #d0453a; font-weight: 700; } .ad-chg .d-same { opacity: .6; } .ad-chg tr.changed td:first-child { font-weight: 700; } .ad-chg-h { padding: .5rem .9rem .1rem; font-weight: 700; font-size: .92rem; } #auto-toggle { border: 2px solid var(--color-accent); border-radius: 1rem; padding: .7rem 1rem !important; transition: background .2s, box-shadow .2s; } #auto-toggle:has(input:checked) { background: rgba(20, 184, 166, .14); box-shadow: 0 0 0 4px rgba(20, 184, 166, .22); } #auto-toggle label > span, #auto-toggle .label-text { font-size: 1.15rem; font-weight: 800; } #auto-toggle input[type=checkbox] { width: 1.5rem; height: 1.5rem; } #auto-toggle span { color: var(--body-text-color) !important; opacity: 1 !important; } .ad-card { overflow-x: auto; } .ad-version { text-align: center; font-size: .75rem; opacity: .55; padding: .6rem 0 .2rem; } .ad-update { padding: .6rem .9rem; border-radius: .7rem; background: rgba(230, 150, 30, .16); font-weight: 600; } .ad-verdict { padding: .65rem .9rem; font-weight: 700; font-size: 1.02rem; border-bottom: 1px solid var(--border-color-primary); } .ad-verdict.ok { background: rgba(26,158,85,.13); } .ad-verdict.bad { background: rgba(208,69,58,.13); } .ad-verdict.fix { background: rgba(230,150,30,.15); } .ad-verdict small { display: block; font-weight: 400; font-size: .85rem; opacity: .85; margin-top: .15rem; } .ad-ab { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: .4rem .8rem; padding: .6rem .9rem; border-bottom: 1px solid var(--border-color-primary); align-items: center; } .ad-ab span { font-size: .78rem; font-weight: 700; opacity: .75; display: block; } .ad-ab audio { width: 100%; height: 36px; } .ad-ab button, .ad-ev button { cursor: pointer; border-radius: .5rem; border: 1px solid var(--border-color-primary); background: var(--background-fill-secondary); padding: .3rem .7rem; font-size: .85rem; color: inherit; } .ad-ev { padding: .2rem .9rem .6rem; font-size: .85rem; border-bottom: 1px solid var(--border-color-primary); } .ad-ev button { margin: .15rem .2rem 0 0; padding: .15rem .5rem; font-variant-numeric: tabular-nums; } .ad-spec { padding: .5rem .9rem .8rem; border-top: 1px solid var(--border-color-primary); font-size: .86rem; } .ad-spec summary { cursor: pointer; font-weight: 700; } .ad-spec img { width: 100%; height: auto; margin-top: .5rem; border-radius: .4rem; } .ad-empty { opacity: .65; text-align: center; padding: 1.5rem; } @media (max-width: 640px) { .ad-table th.req, .ad-table td.req { display: none; } .ad-table { table-layout: fixed; } .ad-table th, .ad-table td { padding: .35rem .4rem; } .ad-table td:first-child, .ad-table th:first-child { min-width: 0; width: 36%; } .ad-table td:last-child, .ad-table th:last-child { width: 3.4rem; } .ad-table td.num { white-space: normal; } .ad-head, .ad-notes { padding-left: .6rem; padding-right: .6rem; } .ad-chg td:last-child, .ad-chg th:last-child { width: auto; } .ad-chg td:first-child, .ad-chg th:first-child { width: 30%; } } """ HEAD = """ """ TOGGLE_JS = """ () => { const dark = document.body.classList.toggle('dark'); try { localStorage.setItem('tema', dark ? 'dark' : 'light'); } catch (e) {} } """ def _pill(ok) -> str: if ok is None: return 'info' return 'OK' if ok else 'Falla' def empty(msg: str) -> str: return f'
{escape(msg)}
' # Explicación de cada fila, en lenguaje llano (se muestra en «¿Qué significa cada fila?») EXPLAIN = [ ("Formato", "El tipo de fichero (WAV, FLAC…). Los clientes suelen pedir WAV."), ("Codificación", "Cómo se guardan los datos del sonido. «PCM entero 24 bits» es el estándar profesional; " "Audacity a veces exporta 32 bits, que es igual de bueno pero no es lo que piden."), ("Frecuencia", "Cuántas veces por segundo se mide el sonido. 48 000 Hz es lo habitual en vídeo y doblaje. " "Si ya es la correcta no se toca; si no, se convierte con la máxima calidad."), ("Canales", "Mono = un canal (un micro). Estéreo = dos. Para voz se pide mono."), ("Pico máximo", "El momento más fuerte del audio. Si se acerca a 0 dB puede saturar. Se pide que no pase de un " "límite (p. ej. −3 dB); la app baja solo esos picos, el resto del audio no cambia."), ("RMS de voz", "El volumen medio de la voz. Más cerca de 0 = más fuerte. Se pide que esté dentro de un margen."), ("Ruido relativo", "Cuánto ruido hay en los silencios del principio y del final comparado con la voz. " "Cuanto más negativo, más limpio (−50 dB es mejor que −40 dB)."), ("Relación señal/ruido", "SNR: cuánto más fuerte es la voz que el ruido de fondo. Cuanto MÁS alta, mejor. " "Si sube después de corregir, es que el fondo ha quedado más limpio."), ("SNR", "Cuánto más fuerte es la voz que el ruido de fondo. Cuanto MÁS alta, mejor."), ("Silencio inicial", "Lo que dura el silencio antes de la primera palabra."), ("Silencio final", "Lo que dura el silencio después de la última palabra."), ("Saturación", "Trozos que «revientan» (distorsión). Tiene que ser 0."), ("Pausas largas", "Silencios entre frases más largos que el máximo elegido. Solo informa; se acortan si marcas " "«Acortar pausas largas»."), ("Ruido de fondo", "El nivel del ruido de la sala. Cuanto más negativo, más silencioso (−90 dB es casi nada)."), ("Sonoridad integrada", "Cómo de fuerte se percibe el audio en conjunto (LUFS). Solo informa."), ("Pico real", "Como el pico máximo, pero contando los picos que aparecen entre muestras al reproducir. Solo informa."), ("Amplitud de la voz", "La muestra más alta de la voz, en la escala de 16 bits (0 a 32 767). Más de 5000 equivale " "a que los picos de la voz pasen de unos −16 dB: que no se grabe demasiado bajo."), ("Espectro", "Hasta qué frecuencia llega el sonido grabado. Un móvil grabando a 48 kHz de verdad llega casi a " "24 kHz; si se corta antes (16, 20 o 22 kHz), el audio viene filtrado, comprimido o convertido."), ("Calidad percibida", "Nota de 1 a 5 de cómo sonaría a un oyente. Diferencias de menos de 0,1 no se notan al oído: " "son el margen de la propia medida. En risas, llantos o muletillas es solo orientativa, porque " "el medidor está pensado para voz hablada."), ] def _explain(param: str) -> str | None: for key, text in EXPLAIN: if param.startswith(key): return text return None def quality_verdict(before: dict | None, after: dict | None) -> str | None: """Interpreta la diferencia de nota DNSMOS para que no asuste un cambio inapreciable.""" if not before or not after: return None d = after["OVRL"] - before["OVRL"] txt = f"{before['OVRL']:.2f} → {after['OVRL']:.2f}" if d >= 0.1: return f"⭐ Calidad percibida: {txt} · suena mejor" if d > -0.1: return f"⭐ Calidad percibida: {txt} · igual que el original (una diferencia de {abs(d):.2f} no se nota al oído)" return (f"⭐ Calidad percibida: {txt} · algo más baja. Suele pasar al recortar silencios o cambiar el volumen, " "porque el medidor compara trozos distintos. Escúchalo: si suena bien, está bien.") def _summary_box(rows_before, rows_after, notes, m_before, m_after) -> str: li = [] by_after = {r.parametro: r for r in (rows_after or [])} failing = [r for r in rows_before if r.ok is False] fine = [r for r in rows_before if r.ok is True] if rows_after is None: # revisión if failing: li.append("
  • ❌ No cumple:
  • ") else: li.append("
  • ✅ Cumple todo lo que pide el proyecto.
  • ") else: # corrección if failing: items = [] for r in failing: a = by_after.get(r.parametro) ok = a is not None and a.ok is not False items.append(f"
  • {escape(r.parametro)}: {escape(r.medido)} → {escape(a.medido) if a else '—'} " f"{'✅' if ok else '❌ sigue sin cumplir'}
  • ") li.append("
  • 🔧 Lo que no cumplía y cómo ha quedado:
  • ") else: li.append("
  • ✅ Ya cumplía todo lo que pide el proyecto.
  • ") if fine: li.append("
  • ✔️ Ya estaba bien (no se ha tocado): " + ", ".join(escape(r.parametro) for r in fine) + "
  • ") freq = next((r for r in rows_before if r.parametro == "Frecuencia"), None) if freq is not None: if any(n.startswith("Frecuencia de muestreo cambiada") for n in notes or []): li.append("
  • Frecuencia de muestreo: convertida con la máxima calidad (" + escape(next(n for n in notes if n.startswith("Frecuencia de muestreo cambiada"))) + ")
  • ") elif freq.ok: li.append(f"
  • Frecuencia de muestreo: {escape(freq.medido)}, ya era la correcta: " "no se ha remuestreado.
  • ") for n in notes or []: if n.startswith("Pulido automático"): li.append(f"
  • ✨ {escape(n)}
  • ") verdict = quality_verdict((m_before or {}).get("dnsmos"), (m_after or {}).get("dnsmos")) if verdict: li.append(f"
  • {escape(verdict)}
  • ") return f'
    ' def _glossary(rows) -> str: seen, items = set(), [] for r in rows: text = _explain(r.parametro) if text and text not in seen: seen.add(text) items.append(f"
  • {escape(r.parametro)}: {escape(text)}
  • ") if not items: return "" return f'
    ❓ ¿Qué significa cada fila?
    ' # ── Tabla de cambios: antes → después, cuánto y qué significa ──────────────── def _fin(v) -> bool: return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) def _n(v, unit: str, nd: int = 1) -> str: if v is None or (isinstance(v, float) and math.isnan(v)): return "—" if isinstance(v, float) and math.isinf(v): return f"{'−' if v < 0 else ''}∞ {unit}".strip() return f"{v:.{nd}f} {unit}".replace("-", "−").strip() def _delta(d: float, unit: str, nd: int = 1) -> str: return f"{d:+.{nd}f} {unit}".replace("-", "−").strip() def _lvl(d, mb, ma, unit, nd, eps, better, means): """Fila numérica genérica. better: +1 si más alto es mejor, −1 si más bajo, 0 neutro. means(d) -> texto explicativo cuando cambia.""" if not (_fin(mb) and _fin(ma)): same = (mb == ma) or (not _fin(mb) and not _fin(ma)) return ("=", "same", "Sin cambios." if same else means(None)) d = ma - mb if abs(d) < eps: return ("=", "same", "Sin cambios.") cls = "same" if better == 0 else ("good" if d * better > 0 else "bad") return (_delta(d, unit, nd), cls, means(d)) def _quality_mean(d, what): if d is None: return "Nota no disponible." if abs(d) < 0.1: return f"Diferencia de {abs(d):.2f}: no se nota al oído (es el margen de la propia medida)." return f"{what} {'mejor' if d > 0 else 'peor'} ({abs(d):.2f} puntos sobre 5)." def changes_table(meta_b, meta_a, m_b: dict, m_a: dict, rows_b, rows_a, pause_max: float | None = None) -> str: """Una fila por medida: valor antes, valor después, cuánto ha cambiado y qué significa en lenguaje llano.""" req = {r.parametro: r for r in rows_b} okb = {r.parametro: r.ok for r in rows_b} oka = {r.parametro: r.ok for r in (rows_a or [])} d_rms = (m_a.get("rms_voz_dbfs", 0) - m_b.get("rms_voz_dbfs", 0)) if _fin(m_a.get("rms_voz_dbfs")) and _fin(m_b.get("rms_voz_dbfs")) else 0.0 lines = [] def add(label, before, after, change, cls, meaning, param=None): lines.append((label, before, after, change, cls, meaning, param)) # Formato if meta_b.samplerate != meta_a.samplerate: add("Frecuencia", f"{meta_b.samplerate} Hz", f"{meta_a.samplerate} Hz", "convertida", "good", f"Convertido de {meta_b.samplerate} a {meta_a.samplerate} Hz con el método de máxima calidad (sin pérdida audible).", "Frecuencia") else: add("Frecuencia", f"{meta_b.samplerate} Hz", f"{meta_a.samplerate} Hz", "=", "same", "Ya era la correcta: NO se ha remuestreado.", "Frecuencia") ch = lambda c: "mono" if c == 1 else ("estéreo" if c == 2 else f"{c} canales") if meta_b.channels != meta_a.channels: add("Canales", ch(meta_b.channels), ch(meta_a.channels), "→ mono" if meta_a.channels == 1 else "cambiado", "good", "Los canales se han mezclado en uno solo, como pide el proyecto.", "Canales") else: add("Canales", ch(meta_b.channels), ch(meta_a.channels), "=", "same", "Sin cambios.", "Canales") sb, sa = SUBTYPE_LABEL.get(meta_b.subtype, meta_b.subtype), SUBTYPE_LABEL.get(meta_a.subtype, meta_a.subtype) add("Codificación", sb, sa, "=" if sb == sa else "cambiada", "same" if sb == sa else "good", "Sin cambios." if sb == sa else f"Guardado como {sa}, que es lo que se pide.", "Codificación") dd = meta_a.duration - meta_b.duration add("Duración", fmt_duration(meta_b.duration), fmt_duration(meta_a.duration), "=" if abs(dd) < 0.05 else _delta(dd, "s", 2), "same", "Sin cambios." if abs(dd) < 0.05 else (f"{abs(dd):.2f} s más corto: se han recortado silencios o pausas; la voz está entera." if dd < 0 else f"{dd:.2f} s más largo: se ha añadido silencio al principio o al final (la voz no cambia).")) # Niveles add("Pico máximo", _n(m_b.get("pico_dbfs"), "dBFS"), _n(m_a.get("pico_dbfs"), "dBFS"), *_lvl(None, m_b.get("pico_dbfs"), m_a.get("pico_dbfs"), "dB", 1, 0.1, 0, lambda d: (f"Los picos han bajado {abs(d):.1f} dB: más margen antes de saturar." if d < 0 else f"Los picos suben {d:.1f} dB porque se ha subido el volumen; siguen dentro del límite." if okb.get("Pico máximo") is not None else f"Los picos suben {d:.1f} dB al subir el volumen.")), "Pico máximo") add("Volumen de la voz (RMS)", _n(m_b.get("rms_voz_dbfs"), "dBFS"), _n(m_a.get("rms_voz_dbfs"), "dBFS"), *_lvl(None, m_b.get("rms_voz_dbfs"), m_a.get("rms_voz_dbfs"), "dB", 1, 0.2, 0, lambda d: (f"Normalizado: la voz suena {abs(d):.1f} dB {'más fuerte' if d > 0 else 'más suave'} " f"(≈ {'+' if d > 0 else '−'}{abs(100 * (10 ** (d / 20) - 1)):.0f} % de amplitud). " "Todo el audio sube o baja igual: no cambia el timbre.")), "RMS de voz") if _fin(m_b.get("lufs")) or _fin(m_a.get("lufs")): add("Sonoridad (LUFS)", _n(m_b.get("lufs"), "LUFS"), _n(m_a.get("lufs"), "LUFS"), *_lvl(None, m_b.get("lufs"), m_a.get("lufs"), "LU", 1, 0.2, 0, lambda d: f"Se percibe {abs(d):.1f} LU {'más fuerte' if d > 0 else 'más bajo'} en conjunto.")) add("Pico real", _n(m_b.get("pico_real_dbtp"), "dBTP"), _n(m_a.get("pico_real_dbtp"), "dBTP"), *_lvl(None, m_b.get("pico_real_dbtp"), m_a.get("pico_real_dbtp"), "dB", 1, 0.1, 0, lambda d: f"Pico al reproducir {'más bajo' if d < 0 else 'más alto'} ({abs(d):.1f} dB). Solo informa.")) # Ruido def noise_mean(d, what): if d is None: return "Ahora es silencio digital absoluto." if not _fin(m_a.get("ruido_fondo_dbfs")) else "Cambiado." if abs(d_rms) >= 0.2 and abs(d - d_rms) < 1.0: return (f"Cambia {abs(d):.1f} dB igual que la voz (por el ajuste de volumen): " "la limpieza real es la misma.") real = d - d_rms return (f"{what} {abs(real):.1f} dB más {'limpio' if real < 0 else 'ruidoso'} en proporción a la voz." + (" Se ha filtrado ruido." if real < -1 else "")) add("Ruido de fondo", _n(m_b.get("ruido_fondo_dbfs"), "dBFS"), _n(m_a.get("ruido_fondo_dbfs"), "dBFS"), *_lvl(None, m_b.get("ruido_fondo_dbfs"), m_a.get("ruido_fondo_dbfs"), "dB", 1, 0.3, -1, lambda d: noise_mean(d, "Fondo")), "Ruido de fondo") add("Ruido relativo (inicio/fin)", _n(m_b.get("ruido_relativo_db"), "dB"), _n(m_a.get("ruido_relativo_db"), "dB"), *_lvl(None, m_b.get("ruido_relativo_db"), m_a.get("ruido_relativo_db"), "dB", 1, 0.3, -1, lambda d: f"Los silencios del principio y del final están {abs(d):.1f} dB " f"{'más limpios' if d < 0 else 'menos limpios'} comparados con la voz." + (" (Cuanto más negativo, mejor.)" if d < 0 else "")), "Ruido relativo") add("Señal/ruido (SNR)", _n(m_b.get("snr_db"), "dB"), _n(m_a.get("snr_db"), "dB"), *_lvl(None, m_b.get("snr_db"), m_a.get("snr_db"), "dB", 1, 0.3, +1, lambda d: "La voz ahora está sobre silencio digital: no queda ruido medible." if d is None else (f"La voz destaca {abs(d):.1f} dB más sobre el fondo: suena más limpia (más alto = mejor)." if d > 0 else f"La voz destaca {abs(d):.1f} dB menos sobre el fondo (más alto = mejor).")), "Relación señal/ruido" if "Relación señal/ruido (SNR)" in req else "SNR") # Silencios y pausas for key, label, param in (("silencio_ini_s", "Silencio inicial", "Silencio inicial"), ("silencio_fin_s", "Silencio final", "Silencio final")): add(label, _n(m_b.get(key), "s", 2), _n(m_a.get(key), "s", 2), *_lvl(None, m_b.get(key), m_a.get(key), "s", 2, 0.01, 0, lambda d: f"{'Recortado' if d < 0 else 'Alargado'} {abs(d):.2f} s para quedar dentro del margen pedido."), param) if pause_max is not None and "pausas_s" in m_b: lb = [p for p in m_b.get("pausas_s", []) if p > pause_max + 0.05] la = [p for p in m_a.get("pausas_s", []) if p > pause_max + 0.05] fb = f"{len(lb)}" + (f" (máx. {max(lb):.1f} s)" if lb else "") fa = f"{len(la)}" + (f" (máx. {max(la):.1f} s)" if la else "") add(f"Pausas > {pause_max:.1f} s", fb, fa, "=" if len(lb) == len(la) else f"{len(la) - len(lb):+d}".replace("-", "−"), "same" if len(lb) == len(la) else ("good" if len(la) < len(lb) else "bad"), "Sin cambios." if len(lb) == len(la) else f"Se han acortado {len(lb) - len(la)} pausas largas.", "Pausas largas") if "muestras_saturadas" in m_b: sb_, sa_ = m_b["muestras_saturadas"], m_a.get("muestras_saturadas", 0) add("Saturación", f"{sb_} muestras", f"{sa_} muestras", "=" if sb_ == sa_ else f"{sa_ - sb_:+d}".replace("-", "−"), "same" if sb_ == sa_ else ("good" if sa_ < sb_ else "bad"), "Sin cambios." if sb_ == sa_ else "Muestras que «revientan» eliminadas." if sa_ < sb_ else "Hay más saturación.", "Saturación") if "ancho_banda_hz" in m_b and _fin(m_b["ancho_banda_hz"]) and _fin(m_a.get("ancho_banda_hz")): bb, ba = m_b["ancho_banda_hz"], m_a["ancho_banda_hz"] d = ba - bb add("Espectro (agudos)", f"hasta {bb / 1000:.1f} kHz", f"hasta {ba / 1000:.1f} kHz", "=" if abs(d) < 300 else _delta(d / 1000, "kHz"), "same" if abs(d) < 300 else ("good" if d > 0 else "bad"), "Sin cambios: los agudos se conservan." if abs(d) < 300 else "Los agudos llegan más arriba." if d > 0 else "Se han perdido agudos (filtro o cambio de frecuencia).", "Espectro") if "pico_voz_muestras" in m_b and "pico_voz_muestras" in m_a: vb, va = m_b["pico_voz_muestras"], m_a["pico_voz_muestras"] add("Amplitud de la voz", f"{vb}", f"{va}", "=" if abs(va - vb) < 50 else f"{va - vb:+d}".replace("-", "−"), "same", "Sin cambios." if abs(va - vb) < 50 else "Cambia con el volumen.", "Amplitud de la voz") # Calidad percibida qb, qa = m_b.get("dnsmos") or {}, m_a.get("dnsmos") or {} if qb and qa: for k, label, what in (("OVRL", "Calidad global (1–5)", "Suena"), ("SIG", "Calidad de la voz (1–5)", "La voz suena"), ("BAK", "Limpieza del fondo (1–5)", "El fondo suena")): d = qa[k] - qb[k] add(label, f"{qb[k]:.2f}", f"{qa[k]:.2f}", _delta(d, "", 2), "same" if abs(d) < 0.1 else ("good" if d > 0 else "bad"), _quality_mean(d, what)) h = ['
    📊 Antes y después, medida a medida
    ', '' ''] for label, before, after, change, cls, meaning, param in lines: r = req.get(param) if param else None if r is None and param: r = next((x for x in rows_b if x.parametro.startswith(param)), None) rq = f'se pide {escape(r.requisito)}' if r is not None and r.ok is not None else "" pb = _pill(okb.get(r.parametro)) if r is not None and r.ok is not None else "" pa = _pill(oka.get(r.parametro)) if r is not None and r.ok is not None and r.parametro in oka else "" changed = cls != "same" or change not in ("=",) h.append(f"" f"" f"" f"") h.append("
    MedidaAntesDespuésCambio
    {escape(label)}{rq}{escape(before)} {pb}{escape(after)} {pa}{escape(change)}
    {'➜ ' if changed else ''}{escape(meaning)}
    ") return "".join(h) _uid = [0] def _player(original: str | None, corrected: str | None, events: list | None) -> str: """Reproductor antes/después (y saltos a los ruidos quitados).""" if not original and not corrected: return "" _uid[0] += 1 pid = f"ab{_uid[0]}" h = ['
    '] if original: h.append(f'
    🎧 Original
    ') if corrected: h.append(f'
    ✨ Corregido
    ') if original and corrected: h.append(f'
    ') h.append("
    ") if events: btns = "".join(f'' for t, _, _ in events[:40]) h.append(f'
    🔇 Ruidos sueltos quitados (pulsa para escuchar ese momento): {btns}
    ') return "".join(h) def verdict(cls: str, text: str, detail: str = "") -> str: small = f"{escape(detail)}" if detail else "" return f'
    {escape(text)}{small}
    ' def spectro(url: str | None) -> str: if not url: return "" return ('
    🔬 Espectrogramas: antes, después y diferencia' f'Espectrogramas
    ') def glossary_all(rows) -> str: """Glosario único para todas las tarjetas.""" return _glossary(rows).replace("¿Qué significa cada fila?", "¿Qué significa cada medida?") def card(name: str, meta_line: str, badge: tuple[str, str], rows_before, rows_after=None, notes: list[str] | None = None, error: str | None = None, download: str | None = None, m_before: dict | None = None, m_after: dict | None = None, meta_before=None, meta_after=None, pause_max: float | None = None, verdict_html: str = "", original: str | None = None, events: list | None = None, spec_url: str | None = None) -> str: cls, text = badge dl = "" if download: fname = download.rsplit("/", 1)[-1] dl = f'⬇️ Descargar este audio' h = [f'
    {escape(name)}
    ' f'
    {escape(meta_line)}
    ' f'
    {escape(text)}{dl}
    '] if error: h.append(f'
    ⚠️ {escape(error)}
    ') return "".join(h) h.append(verdict_html) h.append(_player(original, download if rows_after is not None else None, events)) if rows_before: h.append(_summary_box(rows_before, rows_after, notes, m_before, m_after)) if rows_after is not None and m_before and m_after and meta_before is not None and meta_after is not None: h.append(changes_table(meta_before, meta_after, m_before, m_after, rows_before, rows_after, pause_max)) if notes: h.append('
    Qué se ha hecho, paso a paso
    ") h.append(spectro(spec_url)) h.append("") return "".join(h) h.append('') if rows_after is not None: h.append('') else: h.append('') h.append('') after = {r.parametro: r for r in (rows_after or [])} for r in rows_before: a = after.get(r.parametro) state = a.ok if a is not None else r.ok tr_cls = ' class="informativo"' if state is None else "" h.append(f"") if rows_after is not None: h.append(f"") h.append(f"") h.append("
    ParámetroAntesDespuésMedidoRequisito
    {escape(r.parametro)}{escape(r.medido)}{escape(a.medido) if a else '—'}{escape(r.requisito)}{_pill(state)}
    ") if notes: h.append('
    Qué se ha hecho, paso a paso
    ") h.append("") return "".join(h) def report_table(items: list[dict]) -> str: """Informe resumen en pantalla (lo mismo que el Excel, en corto).""" if not items: return "" rows = [] for it in items: q = quality_verdict((it.get("antes") or {}).get("dnsmos"), (it.get("despues") or {}).get("dnsmos")) q = q.split("·")[0].replace("⭐ Calidad percibida:", "").strip() if q else "—" fallos = ", ".join(it.get("fallos") or []) or "nada" estado = it.get("estado_final", "") cls = "ok" if estado.startswith(("CORREGIDO", "YA CUMPL", "PASA")) else "bad" rows.append(f"{escape(it['nombre'])}{escape(estado)}" f"{escape(fallos)}{escape(q)}") return ('
    📋 Informe
    ' '
    Resumen de todos los audios (es lo mismo que el Excel). ' 'El detalle de cada uno está en su tarjeta, aquí debajo.
    ' '' '' + "".join(rows) + "
    ArchivoResultadoQué fallabaCalidad
    ") def stats(total: int, ok: int, fixable: int, errors: int, duration_s: float, target_s: float | None, fixed_label: str = "Corregibles") -> str: cards = [("Archivos", str(total)), ("Cumplen", str(ok)), (fixed_label, str(fixable)), ("Errores", str(errors)), ("Duración total", fmt_duration(duration_s))] if target_s: cards += [("Objetivo", fmt_duration(target_s)), ("Avance", f"{100 * duration_s / target_s:.1f} %")] inner = "".join(f'
    {escape(k)}{escape(v)}
    ' for k, v in cards) return f'
    {inner}
    ' def banner(kind: str, text: str) -> str: return f'
    {escape(text)}
    ' def deliverables_table(d: dict | None) -> str: """Seguimiento informativo del encargo: no bloquea nada ni impide procesar otros audios.""" if not d or not d.get("ficheros"): return "" pills = {"recibido": 'recibido', "pendiente": 'pendiente', "corto": 'más corto'} h = ['
    Seguimiento del encargo
    ' '
    Solo informativo: lleva la cuenta de los ficheros del encargo por su nombre. ' 'Puedes revisar y corregir cualquier otro audio.
    ' '' ''] for f in d["ficheros"]: h.append(f"" f"" f"") h.append("
    Fichero del encargoSubidoDuraciónNota
    {escape(f['esperado'])}{escape(f['fichero'] or '—')}{fmt_duration(f['duracion_s']) if f['duracion_s'] is not None else '—'}{escape(f['nota'])}{pills[f.get('estado', 'recibido')]}
    ") return "".join(h) def pairs_table(results, alone: list[str]) -> str: """Parejas de pistas (diálogo a dos pistas): misma duración y sin voz cruzada.""" if not results and not alone: return "" h = ['
    🎧 Parejas de pistas
    ' '
    Las dos pistas de una conversación deben durar exactamente lo mismo y llevar solo ' 'la voz de quien graba. Se emparejan por el nombre (todo igual menos la última parte).
    ' '' ''] for r in results: ok = r.same_length and not r.crosstalk diff = "idénticas" if r.same_length else f"{r.diff_ms:.0f} ms" h.append(f"" f"" f"") notes = ([] if r.same_length else [f"No duran lo mismo ({r.diff_ms:.0f} ms de diferencia): hay que recortarlas " "para que empiecen y acaben a la vez."]) + r.crosstalk if notes: h.append(f"") if alone: h.append(f"") h.append("
    ParejaDuracionesDiferencia
    {escape(r.a)}
    {escape(r.b)}
    {fmt_duration(r.dur_a)}
    {fmt_duration(r.dur_b)}
    {escape(diff)}{_pill(ok)}
    ➜ {' · '.join(escape(n) for n in notes)}
    Sin pareja: {escape(', '.join(Path(a).name for a in alone))}" "
    ") return "".join(h)