Spaces:
Running on Zero
Running on Zero
Download ui_html.py from Azuredraco/audio-denoise: direct link, hf CLI and curl.
- Browser
- Download file 39.5 kB
-
https://huggingface.co/spaces/Azuredraco/audio-denoise/resolve/main/ui_html.py
- Command line
-
hf download hf://spaces/Azuredraco/audio-denoise/ui_html.py
-
curl -L -o ui_html.py https://huggingface.co/spaces/Azuredraco/audio-denoise/resolve/main/ui_html.py
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) | |