'
# 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("
")
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:
" + "".join(items) + "
")
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'
{"".join(li)}
'
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?
{"".join(items)}
'
# ── 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
',
'
Medida
Antes
Después
Cambio
'
'
']
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"
{escape(label)}{rq}
"
f"
{escape(before)} {pb}
{escape(after)} {pa}
"
f"
{escape(change)}
"
f"
{'➜ ' if changed else ''}{escape(meaning)}
")
h.append("
")
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'')
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('
')
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"
{escape(r.parametro)}
{escape(r.medido)}
")
if rows_after is not None:
h.append(f"
{escape(a.medido) if a else '—'}
")
h.append(f"
{escape(r.requisito)}
{_pill(state)}
")
h.append("
")
if notes:
h.append('
Qué se ha hecho, paso a paso
')
h += [f"
{escape(n)}
" for n in notes]
h.append("
")
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.
'
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.
'
'
Fichero del encargo
Subido
Duración
'
'
Nota
']
for f in d["ficheros"]:
h.append(f"
{escape(f['esperado'])}
{escape(f['fichero'] or '—')}
"
f"
{fmt_duration(f['duracion_s']) if f['duracion_s'] is not None else '—'}
"
f"
{escape(f['nota'])}
{pills[f.get('estado', 'recibido')]}
")
h.append("
")
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).
'
'
Pareja
Duraciones
Diferencia
'
'
']
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"
{escape(r.a)} {escape(r.b)}
"
f"
{fmt_duration(r.dur_a)} {fmt_duration(r.dur_b)}
"
f"
{escape(diff)}
{_pill(ok)}
")
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"
➜ {' · '.join(escape(n) for n in notes)}
")
if alone:
h.append(f"
Sin pareja: {escape(', '.join(Path(a).name for a in alone))}"
"