audio-denoise / ui_html.py
Azuredraco's picture
Despliegue desde GitHub
d903372 verified
Raw History Blame Contribute Delete
39.5 kB
"""
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 = """
<script>
(function () {
try {
var t = localStorage.getItem('tema');
var apply = function () {
if (!document.body) return;
if (t === 'dark') document.body.classList.add('dark');
if (t === 'light') document.body.classList.remove('dark');
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply); else apply();
} catch (e) {}
})();
// Reproductor antes/después: cambiar al otro en el mismo punto y saltar a un minuto
window.adSwap = function (id) {
var a = document.getElementById(id + '-a'), b = document.getElementById(id + '-b');
if (!a || !b) return;
var from = (!b.paused || (a.paused && b.currentTime > 0 && b.dataset.last === '1')) ? b : a;
var to = from === a ? b : a;
var t = from.currentTime; from.pause(); a.dataset.last = b.dataset.last = '0';
to.currentTime = t; to.dataset.last = '1'; to.play();
};
window.adSeek = function (id, t) {
var a = document.getElementById(id + '-a'), b = document.getElementById(id + '-b');
var p = (b && !b.paused) ? b : a;
[a, b].forEach(function (x) { if (x) { x.pause(); x.currentTime = Math.max(0, t); } });
if (p) p.play();
};
</script>
"""
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 '<span class="ad-pill info">info</span>'
return '<span class="ad-pill ok">OK</span>' if ok else '<span class="ad-pill bad">Falla</span>'
def empty(msg: str) -> str:
return f'<div class="ad-empty">{escape(msg)}</div>'
# 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("<li><b>❌ No cumple:</b><ul>" + "".join(
f"<li><b>{escape(r.parametro)}</b>: {escape(r.medido)} (se pide {escape(r.requisito)})</li>"
for r in failing) + "</ul></li>")
else:
li.append("<li><b>✅ Cumple todo lo que pide el proyecto.</b></li>")
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"<li><b>{escape(r.parametro)}</b>: {escape(r.medido)} → <b>{escape(a.medido) if a else '—'}</b> "
f"{'✅' if ok else '❌ sigue sin cumplir'}</li>")
li.append("<li><b>🔧 Lo que no cumplía y cómo ha quedado:</b><ul>" + "".join(items) + "</ul></li>")
else:
li.append("<li><b>✅ Ya cumplía todo lo que pide el proyecto.</b></li>")
if fine:
li.append("<li><b>✔️ Ya estaba bien (no se ha tocado):</b> " + ", ".join(escape(r.parametro) for r in fine) + "</li>")
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("<li><b>Frecuencia de muestreo:</b> convertida con la máxima calidad ("
+ escape(next(n for n in notes if n.startswith("Frecuencia de muestreo cambiada"))) + ")</li>")
elif freq.ok:
li.append(f"<li><b>Frecuencia de muestreo:</b> {escape(freq.medido)}, ya era la correcta: "
"<b>no se ha remuestreado</b>.</li>")
for n in notes or []:
if n.startswith("Pulido automático"):
li.append(f"<li><b>✨ {escape(n)}</b></li>")
verdict = quality_verdict((m_before or {}).get("dnsmos"), (m_after or {}).get("dnsmos"))
if verdict:
li.append(f"<li><b>{escape(verdict)}</b></li>")
return f'<div class="ad-summary"><ul>{"".join(li)}</ul></div>'
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"<li><b>{escape(r.parametro)}</b>: {escape(text)}</li>")
if not items:
return ""
return f'<details class="ad-gloss"><summary>❓ ¿Qué significa cada fila?</summary><ul>{"".join(items)}</ul></details>'
# ── 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 = ['<div class="ad-chg-h">📊 Antes y después, medida a medida</div>',
'<table class="ad-table ad-chg"><thead><tr><th>Medida</th><th>Antes</th><th>Después</th><th>Cambio</th>'
'</tr></thead><tbody>']
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'<span class="req-s">se pide {escape(r.requisito)}</span>' 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"<tr class='{'changed' if changed else ''}'><td>{escape(label)}{rq}</td>"
f"<td class='num'>{escape(before)} {pb}</td><td class='num'><strong>{escape(after)}</strong> {pa}</td>"
f"<td class='num d-{cls}'>{escape(change)}</td></tr>"
f"<tr class='mean'><td colspan='4'>{'➜ ' if changed else ''}{escape(meaning)}</td></tr>")
h.append("</tbody></table>")
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 = ['<div class="ad-ab">']
if original:
h.append(f'<div><span>🎧 Original</span><audio id="{pid}-a" src="{escape(original)}" controls preload="none">'
'</audio></div>')
if corrected:
h.append(f'<div><span>✨ Corregido</span><audio id="{pid}-b" src="{escape(corrected)}" controls '
'preload="none"></audio></div>')
if original and corrected:
h.append(f'<div><button type="button" onclick="adSwap(\'{pid}\')">⇄ Cambiar al otro en el mismo punto'
'</button></div>')
h.append("</div>")
if events:
btns = "".join(f'<button type="button" onclick="adSeek(\'{pid}\',{max(0.0, t - 1.5):.2f})">'
f'{fmt_duration(t)}</button>' for t, _, _ in events[:40])
h.append(f'<div class="ad-ev">🔇 Ruidos sueltos quitados (pulsa para escuchar ese momento): {btns}</div>')
return "".join(h)
def verdict(cls: str, text: str, detail: str = "") -> str:
small = f"<small>{escape(detail)}</small>" if detail else ""
return f'<div class="ad-verdict {cls}">{escape(text)}{small}</div>'
def spectro(url: str | None) -> str:
if not url:
return ""
return ('<details class="ad-spec"><summary>🔬 Espectrogramas: antes, después y diferencia</summary>'
f'<img loading="lazy" src="{escape(url)}" alt="Espectrogramas"></details>')
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'<a class="ad-dl" href="{escape(download)}" download="{escape(fname)}">⬇️ Descargar este audio</a>'
h = [f'<div class="ad-card"><div class="ad-head"><div><div class="ad-name">{escape(name)}</div>'
f'<div class="ad-meta">{escape(meta_line)}</div></div>'
f'<div class="ad-actions"><span class="ad-badge {cls}">{escape(text)}</span>{dl}</div></div>']
if error:
h.append(f'<div class="ad-notes">⚠️ {escape(error)}</div></div>')
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('<div class="ad-notes"><strong>Qué se ha hecho, paso a paso</strong><ul>')
h += [f"<li>{escape(n)}</li>" for n in notes]
h.append("</ul></div>")
h.append(spectro(spec_url))
h.append("</div>")
return "".join(h)
h.append('<table class="ad-table"><thead><tr><th>Parámetro</th>')
if rows_after is not None:
h.append('<th>Antes</th><th>Después</th>')
else:
h.append('<th>Medido</th>')
h.append('<th class="req">Requisito</th><th></th></tr></thead><tbody>')
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"<tr{tr_cls}><td>{escape(r.parametro)}</td><td class='num'>{escape(r.medido)}</td>")
if rows_after is not None:
h.append(f"<td class='num'><strong>{escape(a.medido) if a else '—'}</strong></td>")
h.append(f"<td class='req'>{escape(r.requisito)}</td><td>{_pill(state)}</td></tr>")
h.append("</tbody></table>")
if notes:
h.append('<div class="ad-notes"><strong>Qué se ha hecho, paso a paso</strong><ul>')
h += [f"<li>{escape(n)}</li>" for n in notes]
h.append("</ul></div>")
h.append("</div>")
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"<tr><td>{escape(it['nombre'])}</td><td><span class='ad-pill {cls}'>{escape(estado)}</span></td>"
f"<td>{escape(fallos)}</td><td class='num'>{escape(q)}</td></tr>")
return ('<div class="ad-card"><div class="ad-head"><div><div class="ad-name">📋 Informe</div>'
'<div class="ad-meta">Resumen de todos los audios (es lo mismo que el Excel). '
'El detalle de cada uno está en su tarjeta, aquí debajo.</div></div></div>'
'<table class="ad-table"><thead><tr><th>Archivo</th><th>Resultado</th><th>Qué fallaba</th>'
'<th>Calidad</th></tr></thead><tbody>' + "".join(rows) + "</tbody></table></div>")
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'<div class="ad-stat"><span>{escape(k)}</span><strong>{escape(v)}</strong></div>' for k, v in cards)
return f'<div class="ad-stats">{inner}</div>'
def banner(kind: str, text: str) -> str:
return f'<div class="ad-banner {kind}">{escape(text)}</div>'
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": '<span class="ad-pill ok">recibido</span>',
"pendiente": '<span class="ad-pill info">pendiente</span>',
"corto": '<span class="ad-pill fix">más corto</span>'}
h = ['<div class="ad-card"><div class="ad-head"><div><div class="ad-name">Seguimiento del encargo</div>'
'<div class="ad-meta">Solo informativo: lleva la cuenta de los ficheros del encargo por su nombre. '
'Puedes revisar y corregir cualquier otro audio.</div></div></div>'
'<table class="ad-table"><thead><tr><th>Fichero del encargo</th><th>Subido</th><th>Duración</th>'
'<th class="req">Nota</th><th></th></tr></thead><tbody>']
for f in d["ficheros"]:
h.append(f"<tr><td>{escape(f['esperado'])}</td><td>{escape(f['fichero'] or '—')}</td>"
f"<td class='num'>{fmt_duration(f['duracion_s']) if f['duracion_s'] is not None else '—'}</td>"
f"<td class='req'>{escape(f['nota'])}</td><td>{pills[f.get('estado', 'recibido')]}</td></tr>")
h.append("</tbody></table></div>")
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 = ['<div class="ad-card"><div class="ad-head"><div><div class="ad-name">🎧 Parejas de pistas</div>'
'<div class="ad-meta">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).</div></div></div>'
'<table class="ad-table"><thead><tr><th>Pareja</th><th>Duraciones</th><th class="req">Diferencia</th>'
'<th></th></tr></thead><tbody>']
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"<tr><td>{escape(r.a)}<br>{escape(r.b)}</td>"
f"<td class='num'>{fmt_duration(r.dur_a)}<br>{fmt_duration(r.dur_b)}</td>"
f"<td class='req'>{escape(diff)}</td><td>{_pill(ok)}</td></tr>")
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"<tr><td colspan='4'>➜ {' · '.join(escape(n) for n in notes)}</td></tr>")
if alone:
h.append(f"<tr class='informativo'><td colspan='4'>Sin pareja: {escape(', '.join(Path(a).name for a in alone))}"
"</td></tr>")
h.append("</tbody></table></div>")
return "".join(h)