Azuredraco commited on
Commit
d903372
·
verified ·
1 Parent(s): c6a2c55

Despliegue desde GitHub

Browse files
Files changed (11) hide show
  1. VERSION +1 -0
  2. app.py +246 -40
  3. dsp.py +10 -0
  4. metrics.py +45 -0
  5. perfiles/asr_dialogo_dos_pistas.yaml +1 -0
  6. perfiles/multiling_es-es_01.yaml +1 -1
  7. pipeline.py +38 -5
  8. profiles.py +7 -0
  9. report.py +20 -2
  10. spectro.py +107 -0
  11. ui_html.py +89 -3
VERSION ADDED
@@ -0,0 +1 @@
 
 
1
+ 2026.10.30-796f5a7
app.py CHANGED
@@ -13,9 +13,13 @@ except ImportError: # versión de escritorio
13
 
14
  import os
15
  import shutil
 
16
  import tempfile
 
17
  import time
 
18
  import zipfile
 
19
  from pathlib import Path
20
 
21
  import gradio as gr
@@ -26,6 +30,7 @@ import metrics
26
  import pipeline
27
  import profiles
28
  import report
 
29
  import ui_html
30
  from audio_io import AUDIO_EXTENSIONS, fmt_duration, read_audio, read_meta
31
  from denoise_core import gpu_available
@@ -47,6 +52,46 @@ DESKTOP_REPO = "Azuredraco/limpieza-de-voz-windows"
47
  DESKTOP_URL = f"https://huggingface.co/{DESKTOP_REPO}/resolve/main/Instalar-Limpieza-de-voz.exe"
48
  DESKTOP_ZIP_URL = f"https://huggingface.co/{DESKTOP_REPO}/resolve/main/Limpieza-de-voz-portable.zip"
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  QUALITY_LABEL = "⭐ Nota de calidad (antes y después)"
51
  QUALITY_INFO = ("Puntúa de 1 a 5 cómo suena cada audio antes y después, como lo valoraría un oyente. "
52
  "Hace que cada audio tarde más: desmárcala solo si tienes prisa.")
@@ -58,6 +103,10 @@ MODE_CHOICES = {
58
  MODE_INFO = ("Solo formato: bits, canales y frecuencia; el sonido no cambia. · Permitido: además volumen, picos y "
59
  "silencios del principio y del final. · Ninja: además quita ruido de fondo y retumbes de forma sutil "
60
  "(pulido automático: solo se queda si suena mejor).")
 
 
 
 
61
  EVENTS_LABEL = "🔇 Quitar ruidos sueltos en las pausas"
62
  EVENTS_INFO = ("Cláxones, golpes, puertas… que suenan mientras nadie habla se cambian por el silencio de la sala; "
63
  "la voz no se toca. En cada audio verás en qué minuto estaba cada uno. Ojo con risas, llantos o "
@@ -87,7 +136,7 @@ def _preset_label(db) -> str:
87
 
88
  def _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono=None, mode=None,
89
  events=False) -> pipeline.Settings:
90
- modo = MODE_CHOICES.get(mode, pipeline.default_mode(prof))
91
  return pipeline.Settings.from_profile(
92
  prof, reduccion_db=PRESETS[preset], paso_alto_hz=float(hp) or None,
93
  conservar_ultrasonidos=ultra, escalar_reduccion=escalate, dnsmos=use_dnsmos, usar_gpu=use_gpu,
@@ -119,6 +168,32 @@ def _profile_info(pid: str):
119
  )
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  # ── Comprobar ───────────────────────────────────────────────────────��────────
123
 
124
  def _prof(pid: str, pause_max: float) -> dict:
@@ -131,12 +206,12 @@ def cb_analyze(files, pid, pause_max, progress=gr.Progress()):
131
  yield ui_html.empty("Primero sube uno o varios audios (paso 2)."), ""
132
  return
133
  prof = _prof(pid, pause_max)
134
- cards, durs = [], []
135
  n_ok = n_fix = n_err = n_pauses = 0
136
  yield ui_html.banner("info", f"⏳ Revisando {len(paths)} audio(s)…"), ""
137
  for i, p in enumerate(paths):
138
  progress(i / len(paths), desc=f"Analizando {Path(p).name}")
139
- a = pipeline.analyze(p, prof)
140
  name = Path(p).name
141
  if a.error:
142
  n_err += 1
@@ -146,11 +221,16 @@ def cb_analyze(files, pid, pause_max, progress=gr.Progress()):
146
  if a.ok:
147
  n_ok += 1
148
  badge = ("ok", "✓ CUMPLE")
 
149
  else:
150
  n_fix += 1
151
  badge = ("fix", "⚡ HAY QUE CORREGIR")
 
 
152
  n_pauses += bool(profiles.long_pauses(a.m, prof))
153
- cards.append(ui_html.card(name, a.meta.describe(), badge, a.rows, m_before=a.m))
 
 
154
  extra = ""
155
  if n_pauses and i == len(paths) - 1:
156
  extra = ui_html.banner("info", f"✂️ Además, {n_pauses} audio(s) tienen pausas de más de {pause_max:.1f} s. "
@@ -158,18 +238,18 @@ def cb_analyze(files, pid, pause_max, progress=gr.Progress()):
158
  if i == len(paths) - 1:
159
  extra += _pairs_html(prof, [p for p in paths])
160
  summary = _summary(prof, len(paths), n_ok, n_fix, n_err, durs, "Por corregir", extra=extra)
161
- yield summary, "".join(reversed(cards))
162
  progress(1.0)
163
 
164
 
165
- def _summary(prof, total, n_ok, n_fix, n_err, durs, fix_label, final=False, extra: str = "") -> str:
166
  target = (prof.get("entregables") or {}).get("objetivo_total_s")
167
  out = ui_html.stats(total, n_ok, n_fix, n_err, sum(d for _, d in durs), target, fix_label)
168
  done = final or (n_ok + n_fix + n_err) == total
169
  reviewing = fix_label == "Por corregir"
170
  if not done:
171
  out = ui_html.banner("info", f"⏳ {'Revisando' if reviewing else 'Corrigiendo'}… "
172
- f"{n_ok + n_fix + n_err} de {total} audio(s) terminados") + out
173
  elif n_err:
174
  out = ui_html.banner("bad", "❌ Algún audio no se ha podido leer o sigue sin cumplir. "
175
  "Mira su tarjeta, aquí debajo, antes de entregar.") + out
@@ -221,6 +301,32 @@ def _file_url(path: str) -> str:
221
  return "/gradio_api/file=" + Path(path).as_posix()
222
 
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mode, mono,
225
  cut_pauses, pause_max, events=False, progress=gr.Progress()):
226
  hidden = gr.update(visible=False)
@@ -231,26 +337,42 @@ def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mod
231
  prof = _prof(pid, pause_max)
232
  settings = _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono, mode, events)
233
  settings.acortar_pausas = bool(cut_pauses)
 
 
234
  yield ui_html.banner("info", f"⏳ Corrigiendo {len(paths)} audio(s)…"), "", hidden, hidden
235
  job = _new_job_dir()
236
  out_dir = job / "corregidos"
237
- cards, durs, items, outputs = [], [], [], []
238
  n_ok = n_fixed = n_err = 0
 
 
 
239
 
240
  for i, p in enumerate(paths):
241
  name = Path(p).name
 
 
242
 
243
- def prog(f, desc, i=i, name=name):
244
- progress((i + f) / len(paths), desc=f"{name} · {desc}")
245
 
246
- progress(i / len(paths), desc=f"{name} · revisando")
247
- pre = pipeline.analyze(p, prof)
248
  if pre.error:
249
  n_err += 1
250
  cards.append(ui_html.card(name, "", ("bad", "✗ ERROR"), [], error=pre.error))
251
  items.append({"nombre": name, "estado_inicial": "ERROR", "estado_final": "ERROR", "notas": [pre.error]})
252
  else:
253
- r = pipeline.correct(p, prof, settings, out_dir, progress=prog, before=pre)
 
 
 
 
 
 
 
 
 
254
  if r.error or r.after is None or r.after.error:
255
  n_err += 1
256
  err = r.error or (r.after.error if r.after else "error desconocido")
@@ -270,13 +392,18 @@ def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mod
270
  else:
271
  n_err += 1
272
  badge = ("bad", "✗ NO SE HA PODIDO DEJAR BIEN")
 
273
  cards.append(ui_html.card(name, r.before.meta.describe() if untouched else
274
  f"{r.before.meta.describe()} → {r.after.meta.describe()}", badge,
275
  r.before.rows, None if untouched else r.after.rows, notes=r.notes,
276
  download=_file_url(r.out_path), m_before=r.before.m,
277
  m_after=None if untouched else r.after.m,
278
  meta_before=r.before.meta, meta_after=r.after.meta,
279
- pause_max=profiles.pause_max(prof)))
 
 
 
 
280
  items.append({
281
  "nombre": Path(r.out_path).name, "duracion_s": r.after.meta.duration,
282
  "estado_inicial": "CUMPLÍA" if r.before.ok else "NO CUMPLÍA",
@@ -284,10 +411,16 @@ def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mod
284
  else "SIGUE SIN CUMPLIR",
285
  "formato": r.before.meta.describe(), "antes": r.before.m, "despues": r.after.m,
286
  "fallos": profiles.failed(r.before.rows), "notas": r.notes,
 
 
 
287
  })
 
288
  files_update = gr.update(value=list(outputs), visible=bool(outputs)) if outputs else hidden
289
- yield (_summary(prof, len(paths), n_ok, n_fixed, n_err, durs, "Corregidos", final=False),
290
- "".join(reversed(cards)), hidden, files_update)
 
 
291
 
292
  # Informe + ZIP
293
  progress(0.99, desc="Preparando la descarga")
@@ -306,7 +439,7 @@ def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mod
306
  z.write(csvf, csvf.name)
307
  yield (_summary(prof, len(paths), n_ok, n_fixed, n_err, durs, "Corregidos", final=True,
308
  extra=_pairs_html(prof, outputs) + ui_html.report_table(items)),
309
- "".join(reversed(cards)),
310
  gr.update(value=str(zip_path), visible=True),
311
  gr.update(value=outputs + [str(xlsx), str(csvf)], visible=True))
312
 
@@ -398,6 +531,46 @@ def cb_preview(path, start, length, preset, hp, ultra, use_dnsmos, use_gpu, prog
398
  return str(orig_p), str(proc_p), png, card
399
 
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  # ── Interfaz ─────────────────────────────────────────────────────────────────
402
 
403
  STEPS = """
@@ -408,6 +581,13 @@ STEPS = """
408
  <div><b>4</b> Pulsa <i>Corregir y descargar</i></div>
409
  </div>
410
  """
 
 
 
 
 
 
 
411
 
412
  DESKTOP_HELP = f"""
413
  ## 💻 Versión para Windows
@@ -426,20 +606,21 @@ LOGIN_HELP = ("**Antes de empezar:** entra con tu cuenta de Hugging Face (botón
426
  "Si no entras también funciona, pero puede ir más despacio.\n\n")
427
 
428
  HELP = """
429
- ## Paso a paso
430
 
431
  {login}1. **Elige el proyecto** en el desplegable de arriba. Debajo verás qué pide ese cliente.
432
  2. **Sube los audios** en la pestaña *✅ Corregir*: arrástralos o pulsa para buscarlos. Puedes subir varios a la vez.
433
- 3. Pulsa **🔍 Revisar**. Cada audio aparece en una tarjeta:
434
- - 🟢 **CUMPLE**: está listo para entregar.
435
- - 🟠 **HAY QUE CORREGIR**: la tabla te dice qué falla, en las filas marcadas con **Falla**.
436
- - 🔴 **ERROR**: el fichero no se puede abrir. Vuelve a exportarlo desde Audacity.
437
- 4. Pulsa **✨ Corregir y descargar**. Cuando termine aparece **Descarga**:
438
- - El **ZIP** lo contiene todo: los audios corregidos, **con el mismo nombre que tenían**, y un informe en Excel.
439
- - Para cumplir los requisitos, **solo se corrige lo que no cumple**: si un audio solo tiene mal el volumen, solo se ajusta el volumen. Cuánto se puede tocar lo eliges con el **nivel de procesado** (más abajo te lo explico).
440
- 5. **Entrega los audios del ZIP.**
441
 
442
- Revisar no cambia nada; es solo para mirar. Puedes pasar directamente al paso 4: también revisa antes de corregir.
 
 
 
 
443
 
444
  ## Cómo se leen las tarjetas
445
 
@@ -457,9 +638,9 @@ Cada fila es una comprobación. Las columnas son **Antes** (tu audio), **Despué
457
 
458
  Debajo de cada tarjeta, **«Qué se ha hecho»** resume los arreglos aplicados a ese audio. El botón **⬇️ Descargar este audio** de cada tarjeta descarga ese audio suelto; el botón grande **⬇️ Descargar todo** baja el ZIP completo cuando terminan todos.
459
 
460
- ## Nivel de procesado (📏 🎚️ 🥷)
461
 
462
- Tú eliges cuánto se toca el audio. Cada proyecto arranca con un nivel, pero puedes cambiarlo antes de pulsar «Corregir».
463
 
464
  - **📏 Solo formato:** bits, canales y frecuencia. **El sonido no cambia.**
465
  - **🎚️ Permitido:** lo anterior y, además, el volumen, los picos y los silencios del principio y del final.
@@ -475,6 +656,10 @@ Para cláxones, golpes, puertas… que suenan **mientras nadie habla**. Un detec
475
  - **No lo uses con risas, llantos o muletillas** («mmm», «eh»): el detector no los considera voz y los quitaría.
476
  - Si el ruido suena **a la vez que la voz**, esto no lo quita (en «Ninja» se atenúa con la limpieza de ruido).
477
 
 
 
 
 
478
  ## Nota de calidad (⭐)
479
 
480
  Viene **marcada**. Cada tarjeta muestra una nota de 1 a 5 **antes** y **después**: cómo valoraría el audio una persona escuchándolo (por encima de 3,5 suena muy bien). Se separa en **voz** (¿suena natural, sin distorsión?) y **fondo** (¿se oye ruido?). Hace que cada audio tarde más; desmárcala si tienes prisa.
@@ -520,7 +705,8 @@ with gr.Blocks(title="Limpieza y control de voz", delete_cache=(3600, 86400)) as
520
  "<p>Quita el ruido de tus grabaciones y comprueba que cumplen lo que pide el cliente.</p></div>")
521
  theme_btn = gr.Button("🌓", elem_id="tema-btn", scale=0, min_width=52, variant="secondary")
522
  theme_btn.click(None, js=ui_html.TOGGLE_JS)
523
- gr.HTML(STEPS)
 
524
  if ON_SPACE:
525
  gr.HTML(f"<div class='ad-desktop'>💻 ¿Lo usas a menudo? <a href='{DESKTOP_URL}'>Descarga la versión para Windows</a>: "
526
  "va en tu ordenador, sin esperas ni límites y sin subir los audios a internet.</div>")
@@ -528,8 +714,12 @@ with gr.Blocks(title="Limpieza y control de voz", delete_cache=(3600, 86400)) as
528
  with gr.Row():
529
  profile_dd = gr.Dropdown(PROFILE_CHOICES, value=DEFAULT_PROFILE, label="1 · Proyecto", scale=3)
530
  profile_md = gr.Markdown(elem_classes="ad-profile")
 
 
 
531
 
532
- with gr.Accordion("⚙️ Opciones avanzadas (normalmente no hace falta tocarlas)", open=False):
 
533
  preset = gr.Radio(list(PRESETS), value=DEFAULT_PRESET, label="Cuánto ruido quitar",
534
  info="Natural deja la voz limpia sin cambiar su timbre.")
535
  with gr.Row():
@@ -546,18 +736,20 @@ with gr.Blocks(title="Limpieza y control de voz", delete_cache=(3600, 86400)) as
546
  with gr.Tab("✅ Corregir"):
547
  files = gr.File(label="2 · Sube aquí los audios (puedes arrastrar varios a la vez)",
548
  file_count="multiple", file_types=AUDIO_EXTENSIONS)
549
- with gr.Row(elem_classes="ad-quality"):
 
 
550
  mode = gr.Radio(list(MODE_CHOICES), value="🥷 Ninja", label="Nivel de procesado", info=MODE_INFO,
551
  scale=2)
552
  use_dnsmos = gr.Checkbox(value=True, label=QUALITY_LABEL, info=QUALITY_INFO)
553
- with gr.Row(elem_classes="ad-quality"):
554
  cut_pauses = gr.Checkbox(value=False, label="✂️ Acortar pausas largas", scale=2,
555
  info="Recorta las pausas entre frases que pasen de la duración máxima. "
556
  "Conserva el principio y el final de cada pausa para que suene natural.")
557
  pause_max = gr.Slider(0.5, 5.0, value=2.0, step=0.1, label="Pausa máxima (segundos)", scale=1)
558
- with gr.Row(elem_classes="ad-quality"):
559
  events = gr.Checkbox(value=False, label=EVENTS_LABEL, info=EVENTS_INFO)
560
- with gr.Row():
561
  analyze_btn = gr.Button("🔍 3 · Revisar", variant="secondary")
562
  correct_btn = gr.Button("✨ 4 · Corregir y descargar", variant="primary")
563
  summary = gr.HTML(ui_html.empty("Aquí aparecerán los resultados."))
@@ -592,13 +784,27 @@ with gr.Blocks(title="Limpieza y control de voz", delete_cache=(3600, 86400)) as
592
  if ON_SPACE:
593
  gr.Markdown(DESKTOP_HELP)
594
 
595
- profile_dd.change(_profile_info, profile_dd, [profile_md, preset, hp, ultra, mode, mono])
596
- app.load(_profile_info, profile_dd, [profile_md, preset, hp, ultra, mode, mono])
597
- files.change(_clear, None, [summary, results, zip_btn, downloads])
 
 
 
 
 
 
 
 
 
 
 
 
598
  analyze_btn.click(cb_analyze, [files, profile_dd, pause_max], [summary, results])
599
  correct_btn.click(cb_correct, [files, profile_dd, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mode, mono,
600
- cut_pauses, pause_max, events],
601
- [summary, results, zip_btn, downloads])
 
 
602
  p_audio.change(cb_load_preview, p_audio, [p_overview, p_start, p_info])
603
  p_btn.click(cb_preview, [p_audio, p_start, p_len, preset, hp, ultra, p_dnsmos, use_gpu],
604
  [p_orig, p_proc, p_spec, p_card])
 
13
 
14
  import os
15
  import shutil
16
+ import subprocess
17
  import tempfile
18
+ import threading
19
  import time
20
+ import urllib.request
21
  import zipfile
22
+ from collections import OrderedDict
23
  from pathlib import Path
24
 
25
  import gradio as gr
 
30
  import pipeline
31
  import profiles
32
  import report
33
+ import spectro
34
  import ui_html
35
  from audio_io import AUDIO_EXTENSIONS, fmt_duration, read_audio, read_meta
36
  from denoise_core import gpu_available
 
52
  DESKTOP_URL = f"https://huggingface.co/{DESKTOP_REPO}/resolve/main/Instalar-Limpieza-de-voz.exe"
53
  DESKTOP_ZIP_URL = f"https://huggingface.co/{DESKTOP_REPO}/resolve/main/Limpieza-de-voz-portable.zip"
54
 
55
+ def _read_version() -> str:
56
+ """Versión: fichero VERSION (lo escriben los despliegues) o, en desarrollo, el commit de git."""
57
+ f = Path(__file__).with_name("VERSION")
58
+ if f.is_file():
59
+ return f.read_text(encoding="utf-8-sig").strip()
60
+ try:
61
+ out = subprocess.run(["git", "log", "-1", "--format=%cd-%h", "--date=format:%Y.%m.%d"],
62
+ cwd=Path(__file__).parent, capture_output=True, text=True, timeout=3)
63
+ return (out.stdout.strip() + "-dev") if out.returncode == 0 and out.stdout.strip() else "dev"
64
+ except Exception:
65
+ return "dev"
66
+
67
+
68
+ VERSION = _read_version()
69
+ VERSION_URL = f"https://huggingface.co/{DESKTOP_REPO}/resolve/main/version.txt"
70
+ _latest = {"v": None}
71
+
72
+
73
+ def _check_update():
74
+ """En la versión de escritorio, mira (en segundo plano) si hay una versión nueva publicada."""
75
+ try:
76
+ req = urllib.request.Request(VERSION_URL, headers={"User-Agent": "audio-denoise"})
77
+ with urllib.request.urlopen(req, timeout=8) as r:
78
+ _latest["v"] = r.read().decode("utf-8").strip() or None
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ if not ON_SPACE and VERSION != "dev" and not VERSION.endswith("-dev"):
84
+ threading.Thread(target=_check_update, daemon=True).start()
85
+
86
+
87
+ def _update_banner() -> str:
88
+ v = _latest["v"]
89
+ if not v or v == VERSION or ON_SPACE:
90
+ return ""
91
+ return (f"<div class='ad-update'>🆕 Hay una versión nueva ({v}). <a href='{DESKTOP_URL}'>Descárgala aquí</a> "
92
+ "e instálala encima: tus ajustes se conservan.</div>")
93
+
94
+
95
  QUALITY_LABEL = "⭐ Nota de calidad (antes y después)"
96
  QUALITY_INFO = ("Puntúa de 1 a 5 cómo suena cada audio antes y después, como lo valoraría un oyente. "
97
  "Hace que cada audio tarde más: desmárcala solo si tienes prisa.")
 
103
  MODE_INFO = ("Solo formato: bits, canales y frecuencia; el sonido no cambia. · Permitido: además volumen, picos y "
104
  "silencios del principio y del final. · Ninja: además quita ruido de fondo y retumbes de forma sutil "
105
  "(pulido automático: solo se queda si suena mejor).")
106
+ AUTO_LABEL = "🤖 AUTOMÁTICO · la app lo hace todo"
107
+ AUTO_INFO = ("Sube los audios y te los devuelve listos: los revisa, arregla solo lo que haga falta (lo mínimo) y te "
108
+ "enseña qué ha cambiado. Si ya cumplen, no se tocan. Desmárcalo para elegir tú cómo procesar.")
109
+ AUTO_MODE = "auto"
110
  EVENTS_LABEL = "🔇 Quitar ruidos sueltos en las pausas"
111
  EVENTS_INFO = ("Cláxones, golpes, puertas… que suenan mientras nadie habla se cambian por el silencio de la sala; "
112
  "la voz no se toca. En cada audio verás en qué minuto estaba cada uno. Ojo con risas, llantos o "
 
136
 
137
  def _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono=None, mode=None,
138
  events=False) -> pipeline.Settings:
139
+ modo = mode if mode == AUTO_MODE else MODE_CHOICES.get(mode, pipeline.default_mode(prof))
140
  return pipeline.Settings.from_profile(
141
  prof, reduccion_db=PRESETS[preset], paso_alto_hz=float(hp) or None,
142
  conservar_ultrasonidos=ultra, escalar_reduccion=escalate, dnsmos=use_dnsmos, usar_gpu=use_gpu,
 
168
  )
169
 
170
 
171
+ # ── Cachés: no repetir el análisis ni la corrección de un audio que no ha cambiado ─────
172
+
173
+ _CACHE: "OrderedDict[tuple, object]" = OrderedDict()
174
+ CACHE_MAX = 64
175
+
176
+
177
+ def _file_key(path: str) -> tuple:
178
+ st = os.stat(path)
179
+ return path, st.st_size, st.st_mtime_ns
180
+
181
+
182
+ def _cached(key: tuple, make):
183
+ if key in _CACHE:
184
+ _CACHE.move_to_end(key)
185
+ return _CACHE[key]
186
+ val = make()
187
+ _CACHE[key] = val
188
+ while len(_CACHE) > CACHE_MAX:
189
+ _CACHE.popitem(last=False)
190
+ return val
191
+
192
+
193
+ def _analyze(path: str, prof: dict, pid: str):
194
+ return _cached(("an", *_file_key(path), pid, prof.get("_pausa_max_s")), lambda: pipeline.analyze(path, prof))
195
+
196
+
197
  # ── Comprobar ───────────────────────────────────────────────────────��────────
198
 
199
  def _prof(pid: str, pause_max: float) -> dict:
 
206
  yield ui_html.empty("Primero sube uno o varios audios (paso 2)."), ""
207
  return
208
  prof = _prof(pid, pause_max)
209
+ cards, durs, all_rows = [], [], []
210
  n_ok = n_fix = n_err = n_pauses = 0
211
  yield ui_html.banner("info", f"⏳ Revisando {len(paths)} audio(s)…"), ""
212
  for i, p in enumerate(paths):
213
  progress(i / len(paths), desc=f"Analizando {Path(p).name}")
214
+ a = _analyze(p, prof, pid)
215
  name = Path(p).name
216
  if a.error:
217
  n_err += 1
 
221
  if a.ok:
222
  n_ok += 1
223
  badge = ("ok", "✓ CUMPLE")
224
+ vd = ui_html.verdict("ok", "✅ Cumple: listo para enviar tal cual")
225
  else:
226
  n_fix += 1
227
  badge = ("fix", "⚡ HAY QUE CORREGIR")
228
+ vd = ui_html.verdict("fix", f"⚠️ No cumple: {', '.join(profiles.failed(a.rows))}",
229
+ "Pulsa «✨ Corregir y descargar» (o activa 🤖 Automático) para arreglarlo.")
230
  n_pauses += bool(profiles.long_pauses(a.m, prof))
231
+ all_rows += a.rows
232
+ cards.append(ui_html.card(name, a.meta.describe(), badge, a.rows, m_before=a.m, verdict_html=vd,
233
+ original=_file_url(p)))
234
  extra = ""
235
  if n_pauses and i == len(paths) - 1:
236
  extra = ui_html.banner("info", f"✂️ Además, {n_pauses} audio(s) tienen pausas de más de {pause_max:.1f} s. "
 
238
  if i == len(paths) - 1:
239
  extra += _pairs_html(prof, [p for p in paths])
240
  summary = _summary(prof, len(paths), n_ok, n_fix, n_err, durs, "Por corregir", extra=extra)
241
+ yield summary, ui_html.glossary_all(all_rows) + "".join(reversed(cards))
242
  progress(1.0)
243
 
244
 
245
+ def _summary(prof, total, n_ok, n_fix, n_err, durs, fix_label, final=False, extra: str = "", left: str = "") -> str:
246
  target = (prof.get("entregables") or {}).get("objetivo_total_s")
247
  out = ui_html.stats(total, n_ok, n_fix, n_err, sum(d for _, d in durs), target, fix_label)
248
  done = final or (n_ok + n_fix + n_err) == total
249
  reviewing = fix_label == "Por corregir"
250
  if not done:
251
  out = ui_html.banner("info", f"⏳ {'Revisando' if reviewing else 'Corrigiendo'}… "
252
+ f"{n_ok + n_fix + n_err} de {total} audio(s) terminados{left}") + out
253
  elif n_err:
254
  out = ui_html.banner("bad", "❌ Algún audio no se ha podido leer o sigue sin cumplir. "
255
  "Mira su tarjeta, aquí debajo, antes de entregar.") + out
 
301
  return "/gradio_api/file=" + Path(path).as_posix()
302
 
303
 
304
+ def _eta(seconds: float) -> str:
305
+ if seconds < 60:
306
+ return "menos de 1 min"
307
+ return f"~{round(seconds / 60)} min"
308
+
309
+
310
+ def _correction_verdict(r, ok: bool, untouched: bool, modo: str) -> str:
311
+ """Veredicto de una línea: ¿se puede mandar? ¿qué se ha cambiado?"""
312
+ stuck = profiles.failed(r.after.rows) if r.after else []
313
+ if not ok:
314
+ fixable = [f for f in stuck if pipeline.FIX_FOR.get(f)]
315
+ detail = ("Con el nivel elegido no se toca; con 🤖 Automático o 🥷 Ninja se intentaría arreglar."
316
+ if fixable and modo in ("formato", "permitido") else
317
+ "Esto no tiene arreglo automático: conviene volver a grabarlo.")
318
+ return ui_html.verdict("bad", f"❌ No queda bien: {', '.join(stuck)}", detail)
319
+ if untouched:
320
+ return ui_html.verdict("ok", "✅ Listo para enviar · ya cumplía, no se ha tocado",
321
+ "El fichero descargado es idéntico a tu original.")
322
+ words = list(dict.fromkeys(pipeline.FIX_WORDS.get(f, f) for f in sorted(r.fixes)))
323
+ lv = pipeline.level_used(r.fixes)
324
+ detail = f"Nivel que ha hecho falta: {pipeline.MODE_LABEL.get(lv, lv)}" if lv else ""
325
+ if r.pulido:
326
+ detail += (" · " if detail else "") + "con pulido automático (suena mejor)"
327
+ return ui_html.verdict("ok", f"✅ Listo para enviar · se ha cambiado: {', '.join(words) or 'formato'}", detail)
328
+
329
+
330
  def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mode, mono,
331
  cut_pauses, pause_max, events=False, progress=gr.Progress()):
332
  hidden = gr.update(visible=False)
 
337
  prof = _prof(pid, pause_max)
338
  settings = _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono, mode, events)
339
  settings.acortar_pausas = bool(cut_pauses)
340
+ skey = repr(settings)
341
+ modo = settings.modo
342
  yield ui_html.banner("info", f"⏳ Corrigiendo {len(paths)} audio(s)…"), "", hidden, hidden
343
  job = _new_job_dir()
344
  out_dir = job / "corregidos"
345
+ cards, durs, items, outputs, all_rows = [], [], [], [], []
346
  n_ok = n_fixed = n_err = 0
347
+ # Tiempo estimado: segundos de proceso por segundo de audio, medido con los audios ya hechos
348
+ lens = [(m.duration if (m := pipeline.quick_meta(p)) else 0.0) for p in paths]
349
+ t0, done_audio = time.time(), 0.0
350
 
351
  for i, p in enumerate(paths):
352
  name = Path(p).name
353
+ rate = (time.time() - t0) / done_audio if done_audio > 0 else None
354
+ left = f" · quedan {_eta(rate * sum(lens[i:]))}" if rate else ""
355
 
356
+ def prog(f, desc, i=i, name=name, left=left):
357
+ progress((i + f) / len(paths), desc=f"{name} · {desc}{left}")
358
 
359
+ progress(i / len(paths), desc=f"{name} · revisando{left}")
360
+ pre = _analyze(p, prof, pid)
361
  if pre.error:
362
  n_err += 1
363
  cards.append(ui_html.card(name, "", ("bad", "✗ ERROR"), [], error=pre.error))
364
  items.append({"nombre": name, "estado_inicial": "ERROR", "estado_final": "ERROR", "notas": [pre.error]})
365
  else:
366
+ key = ("co", *_file_key(p), pid, float(pause_max), skey)
367
+ r = _CACHE.get(key)
368
+ if r is None or not (r.out_path and Path(r.out_path).is_file()):
369
+ r = pipeline.correct(p, prof, settings, out_dir, progress=prog, before=pre)
370
+ r.spec = None
371
+ if not r.error and r.after is not None and r.after is not r.before and lens[i] <= 3600:
372
+ prog(0.97, "dibujando espectrogramas")
373
+ r.spec = spectro.make_png(p, r.out_path, job / f"espectro-{i + 1}.png")
374
+ _CACHE[key] = r
375
+ _cached(key, lambda: r) # recorta la caché si se ha pasado del máximo
376
  if r.error or r.after is None or r.after.error:
377
  n_err += 1
378
  err = r.error or (r.after.error if r.after else "error desconocido")
 
392
  else:
393
  n_err += 1
394
  badge = ("bad", "✗ NO SE HA PODIDO DEJAR BIEN")
395
+ all_rows += r.before.rows
396
  cards.append(ui_html.card(name, r.before.meta.describe() if untouched else
397
  f"{r.before.meta.describe()} → {r.after.meta.describe()}", badge,
398
  r.before.rows, None if untouched else r.after.rows, notes=r.notes,
399
  download=_file_url(r.out_path), m_before=r.before.m,
400
  m_after=None if untouched else r.after.m,
401
  meta_before=r.before.meta, meta_after=r.after.meta,
402
+ pause_max=profiles.pause_max(prof),
403
+ verdict_html=_correction_verdict(r, ok, untouched, modo),
404
+ original=_file_url(p), events=r.eventos,
405
+ spec_url=_file_url(r.spec) if getattr(r, "spec", None) else None))
406
+ lv = pipeline.level_used(r.fixes) if not untouched else None
407
  items.append({
408
  "nombre": Path(r.out_path).name, "duracion_s": r.after.meta.duration,
409
  "estado_inicial": "CUMPLÍA" if r.before.ok else "NO CUMPLÍA",
 
411
  else "SIGUE SIN CUMPLIR",
412
  "formato": r.before.meta.describe(), "antes": r.before.m, "despues": r.after.m,
413
  "fallos": profiles.failed(r.before.rows), "notas": r.notes,
414
+ "nivel": pipeline.MODE_LABEL.get(lv, "sin tocar") if lv else "sin tocar",
415
+ "cambios": list(dict.fromkeys(pipeline.FIX_WORDS.get(f, f) for f in sorted(r.fixes)))
416
+ if not untouched else [],
417
  })
418
+ done_audio += lens[i]
419
  files_update = gr.update(value=list(outputs), visible=bool(outputs)) if outputs else hidden
420
+ rate = (time.time() - t0) / done_audio if done_audio > 0 else None
421
+ left = f" · quedan {_eta(rate * sum(lens[i + 1:]))}" if rate and i + 1 < len(paths) else ""
422
+ yield (_summary(prof, len(paths), n_ok, n_fixed, n_err, durs, "Corregidos", final=False, left=left),
423
+ ui_html.glossary_all(all_rows) + "".join(reversed(cards)), hidden, files_update)
424
 
425
  # Informe + ZIP
426
  progress(0.99, desc="Preparando la descarga")
 
439
  z.write(csvf, csvf.name)
440
  yield (_summary(prof, len(paths), n_ok, n_fixed, n_err, durs, "Corregidos", final=True,
441
  extra=_pairs_html(prof, outputs) + ui_html.report_table(items)),
442
+ ui_html.glossary_all(all_rows) + "".join(reversed(cards)),
443
  gr.update(value=str(zip_path), visible=True),
444
  gr.update(value=outputs + [str(xlsx), str(csvf)], visible=True))
445
 
 
531
  return str(orig_p), str(proc_p), png, card
532
 
533
 
534
+ # ── Flujo automático, ajustes guardados ───────────────────────────────────────
535
+
536
+ def cb_upload(files, auto, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mode, mono, cut_pauses, pause_max,
537
+ events, progress=gr.Progress()):
538
+ """Al subir audios (o cambiar de proyecto): en Automático, todo de una vez; si no, solo la revisión."""
539
+ hidden = gr.update(visible=False)
540
+ if not _paths(files):
541
+ yield ui_html.empty("Aquí aparecerán los resultados."), "", hidden, hidden
542
+ return
543
+ if auto:
544
+ yield from cb_correct(files, pid, preset, hp, ultra, escalate, True, use_gpu, AUTO_MODE, mono, False,
545
+ pause_max, False, progress=progress)
546
+ else:
547
+ for summ, res in cb_analyze(files, pid, pause_max, progress=progress):
548
+ yield summ, res, hidden, hidden
549
+
550
+
551
+ def _auto_view(auto: bool):
552
+ on = bool(auto)
553
+ return (gr.update(visible=not on), gr.update(visible=not on), gr.update(visible=on),
554
+ STEPS_AUTO if on else STEPS)
555
+
556
+
557
+ SETTINGS_DEFAULT = {"perfil": DEFAULT_PROFILE, "auto": True, "dnsmos": True, "pausa": 2.0, "cortar": False,
558
+ "eventos": False}
559
+
560
+
561
+ def _restore(st):
562
+ st = {**SETTINGS_DEFAULT, **(st or {})}
563
+ pid = st["perfil"] if st["perfil"] in PROFILES else DEFAULT_PROFILE
564
+ return (gr.update(value=pid), gr.update(value=bool(st["auto"])), gr.update(value=bool(st["dnsmos"])),
565
+ gr.update(value=float(st["pausa"])), gr.update(value=bool(st["cortar"])),
566
+ gr.update(value=bool(st["eventos"])), *_auto_view(st["auto"]), *_profile_info(pid), _update_banner())
567
+
568
+
569
+ def _save(pid, auto, dnsmos_on, pausa, cortar, eventos):
570
+ return {"perfil": pid, "auto": bool(auto), "dnsmos": bool(dnsmos_on), "pausa": float(pausa),
571
+ "cortar": bool(cortar), "eventos": bool(eventos)}
572
+
573
+
574
  # ── Interfaz ─────────────────────────────────────────────────────────────────
575
 
576
  STEPS = """
 
581
  <div><b>4</b> Pulsa <i>Corregir y descargar</i></div>
582
  </div>
583
  """
584
+ STEPS_AUTO = """
585
+ <div class="ad-steps">
586
+ <div><b>1</b> Elige el proyecto</div>
587
+ <div><b>2</b> Sube los audios</div>
588
+ <div><b>3</b> ¡Listo! Mira el resultado y descarga</div>
589
+ </div>
590
+ """
591
 
592
  DESKTOP_HELP = f"""
593
  ## 💻 Versión para Windows
 
606
  "Si no entras también funciona, pero puede ir más despacio.\n\n")
607
 
608
  HELP = """
609
+ ## Paso a paso (modo 🤖 Automático, el normal)
610
 
611
  {login}1. **Elige el proyecto** en el desplegable de arriba. Debajo verás qué pide ese cliente.
612
  2. **Sube los audios** en la pestaña *✅ Corregir*: arrástralos o pulsa para buscarlos. Puedes subir varios a la vez.
613
+ 3. **Ya está.** La app los revisa y arregla **solo lo que haga falta** (lo mínimo). Cada audio aparece en una tarjeta con una frase arriba:
614
+ - ✅ **Listo para enviar · ya cumplía, no se ha tocado**: el fichero es idéntico a tu original.
615
+ - ✅ **Listo para enviar · se ha cambiado: …**: te dice qué se ha tocado y qué nivel ha hecho falta.
616
+ - ❌ **No queda bien: …**: lo que falla y si tiene arreglo.
617
+ 4. Escúchalo en la propia tarjeta (**🎧 Original** y **✨ Corregido**; el botón **⇄** cambia de uno a otro en el mismo punto) y descárgalo: botón de cada tarjeta o **⬇️ Descargar todo** (ZIP con los audios, **con el mismo nombre que tenían**, y un informe en Excel).
 
 
 
618
 
619
+ Si cambias de proyecto con los audios ya subidos, se vuelven a procesar solos. **🔄 Volver a procesar** repite todo.
620
+
621
+ **¿Quieres decidir tú?** Desmarca **🤖 Automático**: aparecen el nivel de procesado, las demás opciones y los botones **🔍 Revisar** y **✨ Corregir y descargar**. Al subir audios, entonces solo se revisan (no se cambia nada hasta que pulses Corregir).
622
+
623
+ La app recuerda el proyecto y tus ajustes para la próxima vez.
624
 
625
  ## Cómo se leen las tarjetas
626
 
 
638
 
639
  Debajo de cada tarjeta, **«Qué se ha hecho»** resume los arreglos aplicados a ese audio. El botón **⬇️ Descargar este audio** de cada tarjeta descarga ese audio suelto; el botón grande **⬇️ Descargar todo** baja el ZIP completo cuando terminan todos.
640
 
641
+ ## Nivel de procesado (📏 🎚️ 🥷), con Automático desmarcado
642
 
643
+ Tú eliges cuánto se toca el audio. Cada proyecto arranca con un nivel, pero puedes cambiarlo antes de pulsar «Corregir». **🤖 Automático** equivale a usar el nivel más bajo que lo arregle, sin pulido.
644
 
645
  - **📏 Solo formato:** bits, canales y frecuencia. **El sonido no cambia.**
646
  - **🎚️ Permitido:** lo anterior y, además, el volumen, los picos y los silencios del principio y del final.
 
656
  - **No lo uses con risas, llantos o muletillas** («mmm», «eh»): el detector no los considera voz y los quitaría.
657
  - Si el ruido suena **a la vez que la voz**, esto no lo quita (en «Ninja» se atenúa con la limpieza de ruido).
658
 
659
+ ## Espectrogramas (🔬)
660
+
661
+ En cada audio corregido, **«🔬 Espectrogramas»** enseña cómo era el sonido antes, cómo ha quedado y la diferencia (azul = lo que se ha quitado, rojo = lo que se ha añadido). Es para curiosos: no hace falta mirarlo.
662
+
663
  ## Nota de calidad (⭐)
664
 
665
  Viene **marcada**. Cada tarjeta muestra una nota de 1 a 5 **antes** y **después**: cómo valoraría el audio una persona escuchándolo (por encima de 3,5 suena muy bien). Se separa en **voz** (¿suena natural, sin distorsión?) y **fondo** (¿se oye ruido?). Hace que cada audio tarde más; desmárcala si tienes prisa.
 
705
  "<p>Quita el ruido de tus grabaciones y comprueba que cumplen lo que pide el cliente.</p></div>")
706
  theme_btn = gr.Button("🌓", elem_id="tema-btn", scale=0, min_width=52, variant="secondary")
707
  theme_btn.click(None, js=ui_html.TOGGLE_JS)
708
+ update_html = gr.HTML()
709
+ steps = gr.HTML(STEPS_AUTO)
710
  if ON_SPACE:
711
  gr.HTML(f"<div class='ad-desktop'>💻 ¿Lo usas a menudo? <a href='{DESKTOP_URL}'>Descarga la versión para Windows</a>: "
712
  "va en tu ordenador, sin esperas ni límites y sin subir los audios a internet.</div>")
 
714
  with gr.Row():
715
  profile_dd = gr.Dropdown(PROFILE_CHOICES, value=DEFAULT_PROFILE, label="1 · Proyecto", scale=3)
716
  profile_md = gr.Markdown(elem_classes="ad-profile")
717
+ auto = gr.Checkbox(value=True, label=AUTO_LABEL, info=AUTO_INFO, elem_id="auto-toggle")
718
+ settings_state = gr.BrowserState(SETTINGS_DEFAULT, storage_key="limpieza-voz-ajustes",
719
+ secret="limpieza-de-voz-ajustes-v1")
720
 
721
+ with gr.Accordion("⚙️ Opciones avanzadas (normalmente no hace falta tocarlas)", open=False,
722
+ visible=False) as adv:
723
  preset = gr.Radio(list(PRESETS), value=DEFAULT_PRESET, label="Cuánto ruido quitar",
724
  info="Natural deja la voz limpia sin cambiar su timbre.")
725
  with gr.Row():
 
736
  with gr.Tab("✅ Corregir"):
737
  files = gr.File(label="2 · Sube aquí los audios (puedes arrastrar varios a la vez)",
738
  file_count="multiple", file_types=AUDIO_EXTENSIONS)
739
+ rerun_btn = gr.Button("🔄 Volver a procesar", variant="secondary", size="sm")
740
+ with gr.Column(visible=False) as manual:
741
+ with gr.Row(elem_classes="ad-quality"):
742
  mode = gr.Radio(list(MODE_CHOICES), value="🥷 Ninja", label="Nivel de procesado", info=MODE_INFO,
743
  scale=2)
744
  use_dnsmos = gr.Checkbox(value=True, label=QUALITY_LABEL, info=QUALITY_INFO)
745
+ with gr.Row(elem_classes="ad-quality"):
746
  cut_pauses = gr.Checkbox(value=False, label="✂️ Acortar pausas largas", scale=2,
747
  info="Recorta las pausas entre frases que pasen de la duración máxima. "
748
  "Conserva el principio y el final de cada pausa para que suene natural.")
749
  pause_max = gr.Slider(0.5, 5.0, value=2.0, step=0.1, label="Pausa máxima (segundos)", scale=1)
750
+ with gr.Row(elem_classes="ad-quality"):
751
  events = gr.Checkbox(value=False, label=EVENTS_LABEL, info=EVENTS_INFO)
752
+ with gr.Row():
753
  analyze_btn = gr.Button("🔍 3 · Revisar", variant="secondary")
754
  correct_btn = gr.Button("✨ 4 · Corregir y descargar", variant="primary")
755
  summary = gr.HTML(ui_html.empty("Aquí aparecerán los resultados."))
 
784
  if ON_SPACE:
785
  gr.Markdown(DESKTOP_HELP)
786
 
787
+ gr.HTML(f"<div class='ad-version'>Versión {VERSION}</div>")
788
+
789
+ upload_inputs = [files, auto, profile_dd, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mode, mono, cut_pauses,
790
+ pause_max, events]
791
+ run_outputs = [summary, results, zip_btn, downloads]
792
+ profile_dd.change(_profile_info, profile_dd, [profile_md, preset, hp, ultra, mode, mono]).then(
793
+ cb_upload, upload_inputs, run_outputs)
794
+ auto.change(_auto_view, auto, [manual, adv, rerun_btn, steps])
795
+ app.load(_restore, settings_state,
796
+ [profile_dd, auto, use_dnsmos, pause_max, cut_pauses, events, manual, adv, rerun_btn, steps,
797
+ profile_md, preset, hp, ultra, mode, mono, update_html])
798
+ gr.on([profile_dd.change, auto.change, use_dnsmos.change, pause_max.change, cut_pauses.change, events.change],
799
+ _save, [profile_dd, auto, use_dnsmos, pause_max, cut_pauses, events], settings_state)
800
+ files.change(_clear, None, run_outputs).then(cb_upload, upload_inputs, run_outputs)
801
+ rerun_btn.click(cb_upload, upload_inputs, run_outputs)
802
  analyze_btn.click(cb_analyze, [files, profile_dd, pause_max], [summary, results])
803
  correct_btn.click(cb_correct, [files, profile_dd, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mode, mono,
804
+ cut_pauses, pause_max, events], run_outputs)
805
+ if not ON_SPACE:
806
+ upd_timer = gr.Timer(8)
807
+ upd_timer.tick(lambda: (_update_banner(), gr.Timer(active=False)), None, [update_html, upd_timer])
808
  p_audio.change(cb_load_preview, p_audio, [p_overview, p_start, p_info])
809
  p_btn.click(cb_preview, [p_audio, p_start, p_len, preset, hp, ultra, p_dnsmos, use_gpu],
810
  [p_orig, p_proc, p_spec, p_card])
dsp.py CHANGED
@@ -441,3 +441,13 @@ def remove_events(x: np.ndarray, sr: int, speech_prob: np.ndarray, hop_s: float)
441
  y[s0:s1] = x[s0:s1] * (1 - w[:, None]) + tone[: s1 - s0] * w[:, None]
442
  out.append((s0 / sr, (s1 - s0) / sr, float(lvl[a:b].max() - floor)))
443
  return y.astype(np.float32), out
 
 
 
 
 
 
 
 
 
 
 
441
  y[s0:s1] = x[s0:s1] * (1 - w[:, None]) + tone[: s1 - s0] * w[:, None]
442
  out.append((s0 / sr, (s1 - s0) / sr, float(lvl[a:b].max() - floor)))
443
  return y.astype(np.float32), out
444
+
445
+
446
+ def remove_hum(x: np.ndarray, sr: int, harmonics: list[float], q: float = 35.0) -> np.ndarray:
447
+ """Filtros de muesca muy estrechos (Q 35: ~1,5 Hz a 50 Hz) en cada armónico del zumbido, sin desfase."""
448
+ y = x.astype(np.float64)
449
+ for f0 in harmonics:
450
+ if f0 < sr / 2 - 50:
451
+ b, a = signal.iirnotch(f0, q, fs=sr)
452
+ y = signal.filtfilt(b, a, y, axis=0)
453
+ return y.astype(np.float32)
metrics.py CHANGED
@@ -80,6 +80,50 @@ def bandwidth_hz(mono: np.ndarray, sr: int) -> float:
80
  return float(fb[last + 1]) if last > 0 else float(fb[-1])
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def measure(data: np.ndarray, sr: int, with_lufs: bool = True, detector: str | None = None) -> dict:
84
  """data: (n, ch) float32. Devuelve un dict de métricas (floats en dB / segundos)."""
85
  mono = dsp.to_mono(data)[:, 0]
@@ -116,6 +160,7 @@ def measure(data: np.ndarray, sr: int, with_lufs: bool = True, detector: str | N
116
  "silencio_digital": bool(not live.any()) or bool(noise_frames.size == 0),
117
  "pico_voz_muestras": min(32767, int(round(float(np.abs(voice).max()) * 32767))) if voice.size else 0,
118
  "ancho_banda_hz": bandwidth_hz(mono, sr),
 
119
  "pausas_s": [(b - a) / sr for a, b in dsp.find_pauses(mono, sr, region)],
120
  }
121
  if with_lufs:
 
80
  return float(fb[last + 1]) if last > 0 else float(fb[-1])
81
 
82
 
83
+ HUM_HARMONICS = 10 # se miran los 10 primeros armónicos (hasta 500/600 Hz)
84
+ HUM_MIN_DB = 10.0 # un armónico «es zumbido» si destaca ≥ 10 dB sobre las frecuencias vecinas
85
+
86
+
87
+ def hum(mono: np.ndarray, sr: int) -> dict | None:
88
+ """
89
+ Zumbido eléctrico de la red (50 Hz en Europa, 60 Hz en América) y sus armónicos.
90
+ Es un tono fijo: en un espectro de 1 Hz de resolución sobre todo el audio forma picos estrechos,
91
+ mientras que la voz (que cambia de tono continuamente) se reparte. Devuelve
92
+ {'base': 50|60, 'db': prominencia máxima, 'armonicos': [Hz con ≥ 6 dB]} o None.
93
+ """
94
+ from scipy import signal
95
+
96
+ n = len(mono)
97
+ if n < 2 * sr:
98
+ return None
99
+ seg = int(2 * sr)
100
+ if n > 120 * sr:
101
+ starts = np.linspace(0, n - seg, 60).astype(int)
102
+ x = np.concatenate([mono[a : a + seg] for a in starts])
103
+ else:
104
+ x = mono
105
+ f, pxx = signal.welch(x.astype(np.float64), sr, nperseg=seg)
106
+ pdb = 10 * np.log10(np.maximum(pxx, 1e-30))
107
+ df = f[1] - f[0]
108
+ best = None
109
+ for base in (50, 60):
110
+ prom = []
111
+ for k in range(1, HUM_HARMONICS + 1):
112
+ fk = base * k
113
+ i = int(round(fk / df))
114
+ if i + 12 >= len(pdb):
115
+ break
116
+ peak = pdb[max(0, i - 1) : i + 2].max()
117
+ neigh = np.concatenate([pdb[max(0, i - 12) : max(0, i - 3)], pdb[i + 4 : i + 13]])
118
+ prom.append((fk, float(peak - np.median(neigh))))
119
+ strong = [fk for fk, d in prom if d >= 6]
120
+ top = max((d for _, d in prom), default=0.0)
121
+ # hace falta un armónico claro y al menos otro: un solo pico aislado puede ser otra cosa
122
+ if top >= HUM_MIN_DB and len(strong) >= 2 and (best is None or top > best["db"]):
123
+ best = {"base": base, "db": top, "armonicos": strong}
124
+ return best
125
+
126
+
127
  def measure(data: np.ndarray, sr: int, with_lufs: bool = True, detector: str | None = None) -> dict:
128
  """data: (n, ch) float32. Devuelve un dict de métricas (floats en dB / segundos)."""
129
  mono = dsp.to_mono(data)[:, 0]
 
160
  "silencio_digital": bool(not live.any()) or bool(noise_frames.size == 0),
161
  "pico_voz_muestras": min(32767, int(round(float(np.abs(voice).max()) * 32767))) if voice.size else 0,
162
  "ancho_banda_hz": bandwidth_hz(mono, sr),
163
+ "zumbido": hum(mono, sr),
164
  "pausas_s": [(b - a) / sr for a, b in dsp.find_pauses(mono, sr, region)],
165
  }
166
  if with_lufs:
perfiles/asr_dialogo_dos_pistas.yaml CHANGED
@@ -23,6 +23,7 @@ niveles:
23
 
24
  ruido:
25
  ruido_fondo_max_dbfs: -50 # «Background noise: no greater than −50 dB»
 
26
 
27
  silencios: null
28
  saturacion_max_muestras: 0 # «Clipping: not permitted»
 
23
 
24
  ruido:
25
  ruido_fondo_max_dbfs: -50 # «Background noise: no greater than −50 dB»
26
+ zumbido_max_db: 10 # «No … electrical noise»: zumbido de red (50/60 Hz) que destaque ≥ 10 dB
27
 
28
  silencios: null
29
  saturacion_max_muestras: 0 # «Clipping: not permitted»
perfiles/multiling_es-es_01.yaml CHANGED
@@ -5,7 +5,7 @@ descripcion: "Reglas del analizador v4 + SNR ≥ 50 dB: WAV PCM 24 bits · 48 kH
5
  explicacion: |
6
  Encargo **MultiLing es-ES 01** (voz femenina, 40 minutos en total).
7
  - **Qué se comprueba:** lo que pide el cliente (WAV, 48 000 Hz, 24 bits, voz limpia: relación señal/ruido de 50 dB o más) **y las mismas reglas que el analizador v4**: mono, nada por encima de −3 dB, volumen medio de la voz entre −30 y −20 dB, ruido muy bajo y entre 0,5 y 0,7 s de silencio al principio y al final.
8
- - **Qué hace la corrección:** arregla solo lo que no cumple y, con el pulido automático, deja el sonido lo más limpio posible si de verdad mejora.
9
  - **Seguimiento del encargo (informativo):** la app reconoce por su nombre los 6 ficheros del encargo (nonverbal_laughter, nonverbal_crying y nonverbal_filler words, de 30 s cada uno, y full recording_part 1, 2 y 3) y te dice cuáles llevas y cuánto tiempo sumas. Puedes revisar y corregir cualquier otro audio, se llame como se llame.
10
  orden: 1
11
  deteccion_voz: analizador_v4 # mismo detector de voz que el HTML v4 (Gemma comprueba con él)
 
5
  explicacion: |
6
  Encargo **MultiLing es-ES 01** (voz femenina, 40 minutos en total).
7
  - **Qué se comprueba:** lo que pide el cliente (WAV, 48 000 Hz, 24 bits, voz limpia: relación señal/ruido de 50 dB o más) **y las mismas reglas que el analizador v4**: mono, nada por encima de −3 dB, volumen medio de la voz entre −30 y −20 dB, ruido muy bajo y entre 0,5 y 0,7 s de silencio al principio y al final.
8
+ - **Qué hace la corrección:** en 🤖 Automático, arregla solo lo que no cumple. En 🥷 Ninja, además, pule el sonido si de verdad mejora.
9
  - **Seguimiento del encargo (informativo):** la app reconoce por su nombre los 6 ficheros del encargo (nonverbal_laughter, nonverbal_crying y nonverbal_filler words, de 30 s cada uno, y full recording_part 1, 2 y 3) y te dice cuáles llevas y cuánto tiempo sumas. Puedes revisar y corregir cualquier otro audio, se llame como se llame.
10
  orden: 1
11
  deteccion_voz: analizador_v4 # mismo detector de voz que el HTML v4 (Gemma comprueba con él)
pipeline.py CHANGED
@@ -35,7 +35,7 @@ FIX_FOR = {
35
  "Pico máximo": "picos", "Saturación": "picos", "RMS de voz": "volumen",
36
  "Ruido relativo (inicio/fin)": "ruido", "Relación señal/ruido (SNR)": "ruido",
37
  "Silencio inicial": "sil_ini", "Silencio final": "sil_fin",
38
- "Amplitud de la voz": "amplitud", "Ruido de fondo": "ruido",
39
  }
40
 
41
 
@@ -45,8 +45,28 @@ MODES = {
45
  "permitido": {"formato", "mono", "frecuencia", "volumen", "picos", # + niveles y silencios
46
  "amplitud", "sil_ini", "sil_fin"},
47
  "ninja": None, # todo, + pulido sutil
 
48
  }
49
- MODE_LABEL = {"formato": "Solo formato", "permitido": "Permitido", "ninja": "Ninja"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  def _fixes_for(rows, mode: str = "ninja") -> set[str]:
@@ -109,6 +129,9 @@ class Correction:
109
  out_path: str | None = None
110
  notes: list = field(default_factory=list)
111
  error: str | None = None
 
 
 
112
 
113
 
114
  def _needed_reduction(m: dict, prof: dict) -> float:
@@ -211,6 +234,12 @@ def _apply(data, sr, meta, prof, settings, fixes, att, expander, cache, progress
211
  x = dsp.resample(x, sr, fmt["frecuencia"])
212
  notes.append(f"Frecuencia de muestreo cambiada de {sr} a {fmt['frecuencia']} Hz")
213
  sr = fmt["frecuencia"]
 
 
 
 
 
 
214
  if "eventos" in fixes:
215
  if "eventos" not in cache:
216
  import vad
@@ -321,7 +350,7 @@ def _solve(data, sr, meta, prof, settings, fixes, container, subtype, before_m,
321
  notes.append(f"Seguía sin cumplir «{', '.join(noise_left)}»: se insiste con más reducción de ruido")
322
  continue
323
  break
324
- return y, out_sr, rows, notes + step_notes
325
 
326
 
327
  def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=None,
@@ -351,13 +380,16 @@ def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=N
351
 
352
  fixes = _fixes_for(before.rows, settings.modo)
353
  if settings.modo != "ninja":
354
- settings.pulir = False
355
  if settings.forzar_mono and meta.channels > 1: # pedido expresamente aunque el perfil no lo exija
356
  fixes.add("mono")
357
  if settings.acortar_pausas and profiles.long_pauses(before.m, prof):
358
  fixes.add("pausas")
359
  if settings.quitar_eventos:
360
  fixes.add("eventos")
 
 
 
361
  failing = profiles.failed(before.rows)
362
  out_dir.mkdir(parents=True, exist_ok=True)
363
 
@@ -411,7 +443,8 @@ def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=N
411
  "No se ha tocado: lo que falla no se corrige con este nivel de procesado")
412
  return res
413
 
414
- y, out_sr, _, step_notes = chosen
 
415
  notes += step_notes
416
  out_path = out_dir / (Path(meta.name).stem + ext)
417
  write_audio(out_path, y, out_sr, container, subtype)
 
35
  "Pico máximo": "picos", "Saturación": "picos", "RMS de voz": "volumen",
36
  "Ruido relativo (inicio/fin)": "ruido", "Relación señal/ruido (SNR)": "ruido",
37
  "Silencio inicial": "sil_ini", "Silencio final": "sil_fin",
38
+ "Amplitud de la voz": "amplitud", "Ruido de fondo": "ruido", "Zumbido eléctrico": "zumbido",
39
  }
40
 
41
 
 
45
  "permitido": {"formato", "mono", "frecuencia", "volumen", "picos", # + niveles y silencios
46
  "amplitud", "sil_ini", "sil_fin"},
47
  "ninja": None, # todo, + pulido sutil
48
+ "auto": None, # lo mínimo que haga falta, sin pulido
49
  }
50
+ MODE_LABEL = {"formato": "Solo formato", "permitido": "Permitido", "ninja": "Ninja", "auto": "Automático"}
51
+
52
+ # Qué nivel hace falta para cada corrección (para decir «ha hecho falta: Permitido»)
53
+ LEVEL_OF = {"formato": "formato", "mono": "formato", "frecuencia": "formato",
54
+ "volumen": "permitido", "picos": "permitido", "amplitud": "permitido", "sil_ini": "permitido",
55
+ "sil_fin": "permitido", "pausas": "permitido",
56
+ "ruido": "ninja", "eventos": "ninja", "zumbido": "ninja"}
57
+ FIX_WORDS = {"formato": "formato", "mono": "canales (a mono)", "frecuencia": "frecuencia", "volumen": "volumen",
58
+ "picos": "picos", "amplitud": "volumen", "sil_ini": "silencio inicial", "sil_fin": "silencio final",
59
+ "pausas": "pausas largas", "ruido": "ruido de fondo", "eventos": "ruidos sueltos",
60
+ "zumbido": "zumbido eléctrico"}
61
+
62
+
63
+ def level_used(fixes: set[str]) -> str | None:
64
+ """Nivel más alto que han necesitado las correcciones aplicadas (None = no se ha tocado)."""
65
+ levels = {LEVEL_OF.get(f) for f in fixes} - {None}
66
+ for lv in ("ninja", "permitido", "formato"):
67
+ if lv in levels:
68
+ return lv
69
+ return None
70
 
71
 
72
  def _fixes_for(rows, mode: str = "ninja") -> set[str]:
 
129
  out_path: str | None = None
130
  notes: list = field(default_factory=list)
131
  error: str | None = None
132
+ fixes: set = field(default_factory=set) # correcciones aplicadas de verdad
133
+ pulido: bool = False # se ha conservado el pulido automático
134
+ eventos: list = field(default_factory=list) # ruidos sueltos quitados: [(inicio_s, duración_s, dB)]
135
 
136
 
137
  def _needed_reduction(m: dict, prof: dict) -> float:
 
234
  x = dsp.resample(x, sr, fmt["frecuencia"])
235
  notes.append(f"Frecuencia de muestreo cambiada de {sr} a {fmt['frecuencia']} Hz")
236
  sr = fmt["frecuencia"]
237
+ if "zumbido" in fixes:
238
+ zb = metrics.hum(dsp.to_mono(data)[:, 0], meta.samplerate)
239
+ if zb:
240
+ x = dsp.remove_hum(x, sr, zb["armonicos"])
241
+ notes.append(f"Zumbido eléctrico de {zb['base']} Hz quitado (armónicos en "
242
+ f"{', '.join(str(int(f)) for f in zb['armonicos'])} Hz); la voz no cambia")
243
  if "eventos" in fixes:
244
  if "eventos" not in cache:
245
  import vad
 
350
  notes.append(f"Seguía sin cumplir «{', '.join(noise_left)}»: se insiste con más reducción de ruido")
351
  continue
352
  break
353
+ return y, out_sr, rows, notes + step_notes, fixes, (cache.get("eventos") or (None, []))[1]
354
 
355
 
356
  def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=None,
 
380
 
381
  fixes = _fixes_for(before.rows, settings.modo)
382
  if settings.modo != "ninja":
383
+ settings.pulir = False # «auto» es lo mínimo que haga falta: sin pulido
384
  if settings.forzar_mono and meta.channels > 1: # pedido expresamente aunque el perfil no lo exija
385
  fixes.add("mono")
386
  if settings.acortar_pausas and profiles.long_pauses(before.m, prof):
387
  fixes.add("pausas")
388
  if settings.quitar_eventos:
389
  fixes.add("eventos")
390
+ zb = before.m.get("zumbido")
391
+ if settings.modo == "ninja" and zb and zb["db"] >= metrics.HUM_MIN_DB:
392
+ fixes.add("zumbido") # en Ninja el zumbido se quita siempre que se detecte
393
  failing = profiles.failed(before.rows)
394
  out_dir.mkdir(parents=True, exist_ok=True)
395
 
 
443
  "No se ha tocado: lo que falla no se corrige con este nivel de procesado")
444
  return res
445
 
446
+ y, out_sr, _, step_notes, res.fixes, res.eventos = chosen
447
+ res.pulido = chosen is not base
448
  notes += step_notes
449
  out_path = out_dir / (Path(meta.name).stem + ext)
450
  write_audio(out_path, y, out_sr, container, subtype)
profiles.py CHANGED
@@ -103,6 +103,13 @@ def check(meta: AudioMeta, m: dict, prof: dict) -> list[Check]:
103
  rows.append(Check("Espectro (agudos)", "—" if math.isnan(bw) else f"hasta {bw / 1000:.1f} kHz",
104
  f"más allá de {fmt['ancho_banda_min_hz'] / 1000:.0f} kHz",
105
  (not math.isnan(bw)) and bw >= fmt["ancho_banda_min_hz"]))
 
 
 
 
 
 
 
106
  if nz.get("ruido_relativo_max_db") is not None:
107
  rows.append(Check("Ruido relativo (inicio/fin)", fdb(m["ruido_relativo_db"], "dB"),
108
  f"≤ {fdb(nz['ruido_relativo_max_db'], 'dB')} vs voz", m["ruido_relativo_db"] <= nz["ruido_relativo_max_db"]))
 
103
  rows.append(Check("Espectro (agudos)", "—" if math.isnan(bw) else f"hasta {bw / 1000:.1f} kHz",
104
  f"más allá de {fmt['ancho_banda_min_hz'] / 1000:.0f} kHz",
105
  (not math.isnan(bw)) and bw >= fmt["ancho_banda_min_hz"]))
106
+ zb = m.get("zumbido")
107
+ ztxt = f"{zb['base']} Hz · destaca {zb['db']:.0f} dB" if zb else "no"
108
+ if nz.get("zumbido_max_db") is not None:
109
+ rows.append(Check("Zumbido eléctrico", ztxt, f"≤ {nz['zumbido_max_db']:.0f} dB (sin zumbido)",
110
+ zb is None or zb["db"] <= nz["zumbido_max_db"]))
111
+ elif "zumbido" in m:
112
+ rows.append(Check("Zumbido eléctrico", ztxt, "informativo", None))
113
  if nz.get("ruido_relativo_max_db") is not None:
114
  rows.append(Check("Ruido relativo (inicio/fin)", fdb(m["ruido_relativo_db"], "dB"),
115
  f"≤ {fdb(nz['ruido_relativo_max_db'], 'dB')} vs voz", m["ruido_relativo_db"] <= nz["ruido_relativo_max_db"]))
report.py CHANGED
@@ -18,7 +18,9 @@ COLUMNS = [
18
  ("estado_inicial", "Estado inicial"),
19
  ("estado_final", "Estado final"),
20
  ("fallos", "Qué fallaba"),
21
- ("notas", "Qué se ha hecho"),
 
 
22
  ("formato", "Formato original"),
23
  ("pico_antes", "Pico antes (dBFS)"), ("pico_despues", "Pico después (dBFS)"),
24
  ("rms_antes", "RMS voz antes (dBFS)"), ("rms_despues", "RMS voz después (dBFS)"),
@@ -27,6 +29,9 @@ COLUMNS = [
27
  ("sil_ini_antes", "Silencio inicial antes (s)"), ("sil_ini_despues", "Silencio inicial después (s)"),
28
  ("sil_fin_antes", "Silencio final antes (s)"), ("sil_fin_despues", "Silencio final después (s)"),
29
  ("dnsmos_antes", "Nota de calidad antes"), ("dnsmos_despues", "Nota de calidad después"),
 
 
 
30
  ]
31
 
32
 
@@ -36,6 +41,11 @@ def _num(v, nd=2):
36
  return round(float(v), nd)
37
 
38
 
 
 
 
 
 
39
  def build_rows(items: list[dict]) -> list[dict]:
40
  """items: [{'nombre','duracion_s','estado_inicial','estado_final','formato','antes':m,'despues':m,'fallos','notas'}]"""
41
  rows = []
@@ -57,6 +67,14 @@ def build_rows(items: list[dict]) -> list[dict]:
57
  row[f"{key}_despues"] = _num(d.get(mk))
58
  row["dnsmos_antes"] = _num((a.get("dnsmos") or {}).get("OVRL"))
59
  row["dnsmos_despues"] = _num((d.get("dnsmos") or {}).get("OVRL"))
 
 
 
 
 
 
 
 
60
  rows.append(row)
61
  return rows
62
 
@@ -85,7 +103,7 @@ def write_xlsx(path: Path, rows: list[dict], perfil: str, resumen: list[tuple[st
85
  ws.cell(ws.max_row, 1).font = Font(bold=True)
86
  ws.cell(ws.max_row, 2).number_format = "[h]:mm:ss"
87
  ws.cell(ws.max_row, 2).font = Font(bold=True)
88
- widths = {1: 34, 2: 11, 3: 16, 4: 24, 5: 40, 6: 90, 7: 34}
89
  for i in range(1, len(COLUMNS) + 1):
90
  ws.column_dimensions[get_column_letter(i)].width = widths.get(i, 13)
91
  ws.freeze_panes = "B2"
 
18
  ("estado_inicial", "Estado inicial"),
19
  ("estado_final", "Estado final"),
20
  ("fallos", "Qué fallaba"),
21
+ ("nivel", "Nivel de procesado usado"),
22
+ ("cambios", "Qué se ha cambiado"),
23
+ ("notas", "Qué se ha hecho (detalle)"),
24
  ("formato", "Formato original"),
25
  ("pico_antes", "Pico antes (dBFS)"), ("pico_despues", "Pico después (dBFS)"),
26
  ("rms_antes", "RMS voz antes (dBFS)"), ("rms_despues", "RMS voz después (dBFS)"),
 
29
  ("sil_ini_antes", "Silencio inicial antes (s)"), ("sil_ini_despues", "Silencio inicial después (s)"),
30
  ("sil_fin_antes", "Silencio final antes (s)"), ("sil_fin_despues", "Silencio final después (s)"),
31
  ("dnsmos_antes", "Nota de calidad antes"), ("dnsmos_despues", "Nota de calidad después"),
32
+ ("d_pico", "Δ Pico (dB)"), ("d_rms", "Δ RMS voz (dB)"), ("d_ruido", "Δ Ruido de fondo (dB)"),
33
+ ("d_snr", "Δ SNR (dB)"), ("d_dnsmos", "Δ Nota de calidad"),
34
+ ("ruidos_quitados", "Ruidos sueltos quitados"),
35
  ]
36
 
37
 
 
41
  return round(float(v), nd)
42
 
43
 
44
+ def _delta(a, b):
45
+ a, b = _num(a), _num(b)
46
+ return None if a is None or b is None else round(b - a, 2)
47
+
48
+
49
  def build_rows(items: list[dict]) -> list[dict]:
50
  """items: [{'nombre','duracion_s','estado_inicial','estado_final','formato','antes':m,'despues':m,'fallos','notas'}]"""
51
  rows = []
 
67
  row[f"{key}_despues"] = _num(d.get(mk))
68
  row["dnsmos_antes"] = _num((a.get("dnsmos") or {}).get("OVRL"))
69
  row["dnsmos_despues"] = _num((d.get("dnsmos") or {}).get("OVRL"))
70
+ for key, mk in (("d_pico", "pico_dbfs"), ("d_rms", "rms_voz_dbfs"), ("d_ruido", "ruido_fondo_dbfs"),
71
+ ("d_snr", "snr_db")):
72
+ row[key] = _delta(a.get(mk), d.get(mk))
73
+ row["d_dnsmos"] = _delta(row["dnsmos_antes"], row["dnsmos_despues"])
74
+ row["nivel"] = it.get("nivel", "")
75
+ row["cambios"] = ", ".join(it.get("cambios") or [])
76
+ row["ruidos_quitados"] = next((n.split("—", 1)[-1].strip() for n in it.get("notas") or []
77
+ if n.startswith("Ruidos sueltos en las pausas sustituidos")), "")
78
  rows.append(row)
79
  return rows
80
 
 
103
  ws.cell(ws.max_row, 1).font = Font(bold=True)
104
  ws.cell(ws.max_row, 2).number_format = "[h]:mm:ss"
105
  ws.cell(ws.max_row, 2).font = Font(bold=True)
106
+ widths = {1: 34, 2: 11, 3: 16, 4: 24, 5: 40, 6: 14, 7: 40, 8: 90, 9: 34}
107
  for i in range(1, len(COLUMNS) + 1):
108
  ws.column_dimensions[get_column_letter(i)].width = widths.get(i, 13)
109
  ws.freeze_panes = "B2"
spectro.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ spectro.py — Espectrogramas de antes, después y su diferencia (para curiosos).
3
+
4
+ Frecuencia lineal hasta la máxima del fichero, para que se vea bien lo que hay por encima de 20 kHz.
5
+ Para que audios de media hora no cuesten nada, cada columna de la imagen es la media de unas pocas
6
+ FFT repartidas por su tramo (como mucho ~1600 columnas por imagen).
7
+ Diferencia: si los dos audios están alineados (misma frecuencia y misma duración) es «después − antes»
8
+ en cada punto (azul = se ha quitado, rojo = se ha añadido); si no (se han recortado silencios o pausas,
9
+ o se ha cambiado la frecuencia), se compara el espectro medio de los dos.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ import dsp
19
+ from audio_io import read_audio
20
+
21
+ NFFT = 2048
22
+ MAX_COLS = 1600
23
+ SUB = 4
24
+
25
+
26
+ def _spec(mono: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarray, float]:
27
+ """Devuelve (dB [frecuencias × columnas], frecuencias, segundos por columna)."""
28
+ n = len(mono)
29
+ cols = int(min(MAX_COLS, max(1, (n - NFFT) // (NFFT // 2))))
30
+ hop = max(1, (n - NFFT) / cols) if n > NFFT else 1
31
+ win = np.hanning(NFFT).astype(np.float32)
32
+ out = np.empty((NFFT // 2 + 1, cols), dtype=np.float32)
33
+ x = np.pad(mono.astype(np.float32), (0, NFFT))
34
+ for c in range(cols):
35
+ starts = (c * hop + np.linspace(0, max(0.0, hop - NFFT), SUB)).astype(int) if hop > NFFT else [int(c * hop)]
36
+ p = np.mean([np.abs(np.fft.rfft(x[s : s + NFFT] * win)) ** 2 for s in starts], axis=0)
37
+ out[:, c] = 10 * np.log10(np.maximum(p / (NFFT * win.sum() / 2), 1e-20))
38
+ return out, np.fft.rfftfreq(NFFT, 1 / sr), hop / sr
39
+
40
+
41
+ def make_png(before: str, after: str, png: Path) -> str | None:
42
+ try:
43
+ import matplotlib
44
+
45
+ matplotlib.use("Agg")
46
+ import matplotlib.pyplot as plt
47
+
48
+ da, sra, _ = read_audio(before)
49
+ db, srb, _ = read_audio(after)
50
+ ma, mb = dsp.to_mono(da)[:, 0], dsp.to_mono(db)[:, 0]
51
+ sa, fa, ta = _spec(ma, sra)
52
+ sb, fb, tb = _spec(mb, srb)
53
+ aligned = sra == srb and len(ma) == len(mb)
54
+ fig, axes = plt.subplots(3, 1, figsize=(11, 8.4), gridspec_kw={"height_ratios": [1, 1, 1]})
55
+ fig.patch.set_facecolor("#14141f")
56
+ vmax = float(max(np.percentile(sa, 99.9), np.percentile(sb, 99.9)))
57
+ for ax, S, f, t, title in ((axes[0], sa, fa, ta, "Antes"), (axes[1], sb, fb, tb, "Después")):
58
+ im = ax.imshow(S, origin="lower", aspect="auto", cmap="magma", vmin=vmax - 110, vmax=vmax,
59
+ extent=[0, S.shape[1] * t, 0, f[-1] / 1000])
60
+ ax.set_title(title, color="white", fontsize=10, loc="left")
61
+ ax.set_ylabel("kHz", color="#ddd")
62
+ cb = fig.colorbar(im, ax=ax, pad=0.01)
63
+ cb.set_label("dB", color="#ddd")
64
+ cb.ax.tick_params(colors="#ddd", labelsize=8)
65
+ ax = axes[2]
66
+ if aligned:
67
+ d = np.clip(sb - sa, -30, 30)
68
+ im = ax.imshow(d, origin="lower", aspect="auto", cmap="RdBu_r", vmin=-30, vmax=30,
69
+ extent=[0, d.shape[1] * ta, 0, fa[-1] / 1000])
70
+ ax.set_title("Diferencia (después − antes): azul = se ha quitado · rojo = se ha añadido",
71
+ color="white", fontsize=10, loc="left")
72
+ ax.set_ylabel("kHz", color="#ddd")
73
+ cb = fig.colorbar(im, ax=ax, pad=0.01)
74
+ cb.set_label("dB", color="#ddd")
75
+ cb.ax.tick_params(colors="#ddd", labelsize=8)
76
+ ax.set_xlabel("Tiempo (s)", color="#ddd")
77
+ else:
78
+ la = 10 * np.log10(np.mean(10 ** (sa / 10), axis=1))
79
+ lb = 10 * np.log10(np.mean(10 ** (sb / 10), axis=1))
80
+ fmax = min(fa[-1], fb[-1])
81
+ grid = np.linspace(0, fmax, 1000)
82
+ ia, ib = np.interp(grid, fa, la), np.interp(grid, fb, lb)
83
+ ax.plot(grid / 1000, ia, color="#f0a050", lw=1, label="Antes")
84
+ ax.plot(grid / 1000, ib, color="#50c0f0", lw=1, label="Después")
85
+ ax2 = ax.twinx()
86
+ diff = np.convolve(ib - ia, np.ones(15) / 15, mode="same") # suavizado para leer la tendencia
87
+ ax2.plot(grid / 1000, diff, color="#e05080", lw=1.2, alpha=.9, label="Diferencia")
88
+ ax2.axhline(0, color="#888", lw=.6)
89
+ ax2.set_ylabel("Diferencia (dB)", color="#e05080")
90
+ ax2.tick_params(colors="#ddd", labelsize=8)
91
+ ax.set_title("Espectro medio (la duración ha cambiado, así que se compara el promedio)",
92
+ color="white", fontsize=10, loc="left")
93
+ ax.set_xlabel("kHz", color="#ddd")
94
+ ax.set_ylabel("dB", color="#ddd")
95
+ ax.legend(loc="upper right", fontsize=8)
96
+ ax.set_facecolor("#14141f")
97
+ for a in axes:
98
+ a.tick_params(colors="#ddd", labelsize=8)
99
+ axes[0].set_xlabel("")
100
+ axes[1].set_xlabel("Tiempo (s)", color="#ddd")
101
+ fig.tight_layout()
102
+ fig.savefig(png, dpi=100, facecolor=fig.get_facecolor())
103
+ plt.close(fig)
104
+ return str(png)
105
+ except Exception as e: # una imagen nunca debe romper la corrección
106
+ print(f"[ESPECTRO] {e}")
107
+ return None
ui_html.py CHANGED
@@ -93,6 +93,31 @@ CSS = """
93
  .ad-chg .d-same { opacity: .6; }
94
  .ad-chg tr.changed td:first-child { font-weight: 700; }
95
  .ad-chg-h { padding: .5rem .9rem .1rem; font-weight: 700; font-size: .92rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  .ad-empty { opacity: .65; text-align: center; padding: 1.5rem; }
97
  @media (max-width: 640px) {
98
  .ad-table th.req, .ad-table td.req { display: none; }
@@ -120,6 +145,21 @@ HEAD = """
120
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply); else apply();
121
  } catch (e) {}
122
  })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </script>
124
  """
125
 
@@ -434,10 +474,55 @@ def changes_table(meta_b, meta_a, m_b: dict, m_a: dict, rows_b, rows_a, pause_ma
434
  return "".join(h)
435
 
436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  def card(name: str, meta_line: str, badge: tuple[str, str], rows_before, rows_after=None,
438
  notes: list[str] | None = None, error: str | None = None, download: str | None = None,
439
  m_before: dict | None = None, m_after: dict | None = None, meta_before=None, meta_after=None,
440
- pause_max: float | None = None) -> str:
 
441
  cls, text = badge
442
  dl = ""
443
  if download:
@@ -449,6 +534,8 @@ def card(name: str, meta_line: str, badge: tuple[str, str], rows_before, rows_af
449
  if error:
450
  h.append(f'<div class="ad-notes">⚠️ {escape(error)}</div></div>')
451
  return "".join(h)
 
 
452
  if rows_before:
453
  h.append(_summary_box(rows_before, rows_after, notes, m_before, m_after))
454
  if rows_after is not None and m_before and m_after and meta_before is not None and meta_after is not None:
@@ -457,7 +544,7 @@ def card(name: str, meta_line: str, badge: tuple[str, str], rows_before, rows_af
457
  h.append('<div class="ad-notes"><strong>Qué se ha hecho, paso a paso</strong><ul>')
458
  h += [f"<li>{escape(n)}</li>" for n in notes]
459
  h.append("</ul></div>")
460
- h.append(_glossary(rows_before))
461
  h.append("</div>")
462
  return "".join(h)
463
  h.append('<table class="ad-table"><thead><tr><th>Parámetro</th>')
@@ -480,7 +567,6 @@ def card(name: str, meta_line: str, badge: tuple[str, str], rows_before, rows_af
480
  h.append('<div class="ad-notes"><strong>Qué se ha hecho, paso a paso</strong><ul>')
481
  h += [f"<li>{escape(n)}</li>" for n in notes]
482
  h.append("</ul></div>")
483
- h.append(_glossary(rows_before))
484
  h.append("</div>")
485
  return "".join(h)
486
 
 
93
  .ad-chg .d-same { opacity: .6; }
94
  .ad-chg tr.changed td:first-child { font-weight: 700; }
95
  .ad-chg-h { padding: .5rem .9rem .1rem; font-weight: 700; font-size: .92rem; }
96
+ #auto-toggle { border: 2px solid var(--color-accent); border-radius: 1rem; padding: .7rem 1rem !important;
97
+ transition: background .2s, box-shadow .2s; }
98
+ #auto-toggle:has(input:checked) { background: rgba(20, 184, 166, .14); box-shadow: 0 0 0 4px rgba(20, 184, 166, .22); }
99
+ #auto-toggle label > span, #auto-toggle .label-text { font-size: 1.15rem; font-weight: 800; }
100
+ #auto-toggle input[type=checkbox] { width: 1.5rem; height: 1.5rem; }
101
+ #auto-toggle span { color: var(--body-text-color) !important; opacity: 1 !important; }
102
+ .ad-card { overflow-x: auto; }
103
+ .ad-version { text-align: center; font-size: .75rem; opacity: .55; padding: .6rem 0 .2rem; }
104
+ .ad-update { padding: .6rem .9rem; border-radius: .7rem; background: rgba(230, 150, 30, .16); font-weight: 600; }
105
+ .ad-verdict { padding: .65rem .9rem; font-weight: 700; font-size: 1.02rem; border-bottom: 1px solid var(--border-color-primary); }
106
+ .ad-verdict.ok { background: rgba(26,158,85,.13); }
107
+ .ad-verdict.bad { background: rgba(208,69,58,.13); }
108
+ .ad-verdict.fix { background: rgba(230,150,30,.15); }
109
+ .ad-verdict small { display: block; font-weight: 400; font-size: .85rem; opacity: .85; margin-top: .15rem; }
110
+ .ad-ab { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: .4rem .8rem;
111
+ padding: .6rem .9rem; border-bottom: 1px solid var(--border-color-primary); align-items: center; }
112
+ .ad-ab span { font-size: .78rem; font-weight: 700; opacity: .75; display: block; }
113
+ .ad-ab audio { width: 100%; height: 36px; }
114
+ .ad-ab button, .ad-ev button { cursor: pointer; border-radius: .5rem; border: 1px solid var(--border-color-primary);
115
+ background: var(--background-fill-secondary); padding: .3rem .7rem; font-size: .85rem; color: inherit; }
116
+ .ad-ev { padding: .2rem .9rem .6rem; font-size: .85rem; border-bottom: 1px solid var(--border-color-primary); }
117
+ .ad-ev button { margin: .15rem .2rem 0 0; padding: .15rem .5rem; font-variant-numeric: tabular-nums; }
118
+ .ad-spec { padding: .5rem .9rem .8rem; border-top: 1px solid var(--border-color-primary); font-size: .86rem; }
119
+ .ad-spec summary { cursor: pointer; font-weight: 700; }
120
+ .ad-spec img { width: 100%; height: auto; margin-top: .5rem; border-radius: .4rem; }
121
  .ad-empty { opacity: .65; text-align: center; padding: 1.5rem; }
122
  @media (max-width: 640px) {
123
  .ad-table th.req, .ad-table td.req { display: none; }
 
145
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply); else apply();
146
  } catch (e) {}
147
  })();
148
+ // Reproductor antes/después: cambiar al otro en el mismo punto y saltar a un minuto
149
+ window.adSwap = function (id) {
150
+ var a = document.getElementById(id + '-a'), b = document.getElementById(id + '-b');
151
+ if (!a || !b) return;
152
+ var from = (!b.paused || (a.paused && b.currentTime > 0 && b.dataset.last === '1')) ? b : a;
153
+ var to = from === a ? b : a;
154
+ var t = from.currentTime; from.pause(); a.dataset.last = b.dataset.last = '0';
155
+ to.currentTime = t; to.dataset.last = '1'; to.play();
156
+ };
157
+ window.adSeek = function (id, t) {
158
+ var a = document.getElementById(id + '-a'), b = document.getElementById(id + '-b');
159
+ var p = (b && !b.paused) ? b : a;
160
+ [a, b].forEach(function (x) { if (x) { x.pause(); x.currentTime = Math.max(0, t); } });
161
+ if (p) p.play();
162
+ };
163
  </script>
164
  """
165
 
 
474
  return "".join(h)
475
 
476
 
477
+ _uid = [0]
478
+
479
+
480
+ def _player(original: str | None, corrected: str | None, events: list | None) -> str:
481
+ """Reproductor antes/después (y saltos a los ruidos quitados)."""
482
+ if not original and not corrected:
483
+ return ""
484
+ _uid[0] += 1
485
+ pid = f"ab{_uid[0]}"
486
+ h = ['<div class="ad-ab">']
487
+ if original:
488
+ h.append(f'<div><span>🎧 Original</span><audio id="{pid}-a" src="{escape(original)}" controls preload="none">'
489
+ '</audio></div>')
490
+ if corrected:
491
+ h.append(f'<div><span>✨ Corregido</span><audio id="{pid}-b" src="{escape(corrected)}" controls '
492
+ 'preload="none"></audio></div>')
493
+ if original and corrected:
494
+ h.append(f'<div><button type="button" onclick="adSwap(\'{pid}\')">⇄ Cambiar al otro en el mismo punto'
495
+ '</button></div>')
496
+ h.append("</div>")
497
+ if events:
498
+ btns = "".join(f'<button type="button" onclick="adSeek(\'{pid}\',{max(0.0, t - 1.5):.2f})">'
499
+ f'{fmt_duration(t)}</button>' for t, _, _ in events[:40])
500
+ h.append(f'<div class="ad-ev">🔇 Ruidos sueltos quitados (pulsa para escuchar ese momento): {btns}</div>')
501
+ return "".join(h)
502
+
503
+
504
+ def verdict(cls: str, text: str, detail: str = "") -> str:
505
+ small = f"<small>{escape(detail)}</small>" if detail else ""
506
+ return f'<div class="ad-verdict {cls}">{escape(text)}{small}</div>'
507
+
508
+
509
+ def spectro(url: str | None) -> str:
510
+ if not url:
511
+ return ""
512
+ return ('<details class="ad-spec"><summary>🔬 Espectrogramas: antes, después y diferencia</summary>'
513
+ f'<img loading="lazy" src="{escape(url)}" alt="Espectrogramas"></details>')
514
+
515
+
516
+ def glossary_all(rows) -> str:
517
+ """Glosario único para todas las tarjetas."""
518
+ return _glossary(rows).replace("¿Qué significa cada fila?", "¿Qué significa cada medida?")
519
+
520
+
521
  def card(name: str, meta_line: str, badge: tuple[str, str], rows_before, rows_after=None,
522
  notes: list[str] | None = None, error: str | None = None, download: str | None = None,
523
  m_before: dict | None = None, m_after: dict | None = None, meta_before=None, meta_after=None,
524
+ pause_max: float | None = None, verdict_html: str = "", original: str | None = None,
525
+ events: list | None = None, spec_url: str | None = None) -> str:
526
  cls, text = badge
527
  dl = ""
528
  if download:
 
534
  if error:
535
  h.append(f'<div class="ad-notes">⚠️ {escape(error)}</div></div>')
536
  return "".join(h)
537
+ h.append(verdict_html)
538
+ h.append(_player(original, download if rows_after is not None else None, events))
539
  if rows_before:
540
  h.append(_summary_box(rows_before, rows_after, notes, m_before, m_after))
541
  if rows_after is not None and m_before and m_after and meta_before is not None and meta_after is not None:
 
544
  h.append('<div class="ad-notes"><strong>Qué se ha hecho, paso a paso</strong><ul>')
545
  h += [f"<li>{escape(n)}</li>" for n in notes]
546
  h.append("</ul></div>")
547
+ h.append(spectro(spec_url))
548
  h.append("</div>")
549
  return "".join(h)
550
  h.append('<table class="ad-table"><thead><tr><th>Parámetro</th>')
 
567
  h.append('<div class="ad-notes"><strong>Qué se ha hecho, paso a paso</strong><ul>')
568
  h += [f"<li>{escape(n)}</li>" for n in notes]
569
  h.append("</ul></div>")
 
570
  h.append("</div>")
571
  return "".join(h)
572