Spaces:
Running on Zero
Running on Zero
Despliegue desde GitHub
Browse files- app.py +45 -30
- dsp.py +104 -6
- metrics.py +3 -2
- perfiles/analizador_v4.yaml +1 -0
- perfiles/multiling_es-es_01.yaml +15 -8
- pipeline.py +179 -97
- profiles.py +21 -0
app.py
CHANGED
|
@@ -74,11 +74,11 @@ def _preset_label(db) -> str:
|
|
| 74 |
return DEFAULT_PRESET
|
| 75 |
|
| 76 |
|
| 77 |
-
def _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono=None) -> pipeline.Settings:
|
| 78 |
return pipeline.Settings.from_profile(
|
| 79 |
prof, reduccion_db=PRESETS[preset], paso_alto_hz=float(hp) or None,
|
| 80 |
conservar_ultrasonidos=ultra, escalar_reduccion=escalate, dnsmos=use_dnsmos, usar_gpu=use_gpu,
|
| 81 |
-
forzar_mono=mono,
|
| 82 |
)
|
| 83 |
|
| 84 |
|
|
@@ -104,14 +104,18 @@ def _profile_info(pid: str):
|
|
| 104 |
|
| 105 |
# ── Comprobar ────────────────────────────────────────────────────────────────
|
| 106 |
|
| 107 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
paths = _paths(files)
|
| 109 |
if not paths:
|
| 110 |
yield ui_html.empty("Primero sube uno o varios audios (paso 2)."), ""
|
| 111 |
return
|
| 112 |
-
prof =
|
| 113 |
cards, durs = [], []
|
| 114 |
-
n_ok = n_fix = n_err = 0
|
| 115 |
for i, p in enumerate(paths):
|
| 116 |
progress(i / len(paths), desc=f"Analizando {Path(p).name}")
|
| 117 |
a = pipeline.analyze(p, prof)
|
|
@@ -127,8 +131,13 @@ def cb_analyze(files, pid, progress=gr.Progress()):
|
|
| 127 |
else:
|
| 128 |
n_fix += 1
|
| 129 |
badge = ("fix", "⚡ HAY QUE CORREGIR")
|
|
|
|
| 130 |
cards.append(ui_html.card(name, a.meta.describe(), badge, a.rows))
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
progress(1.0)
|
| 133 |
|
| 134 |
|
|
@@ -153,14 +162,15 @@ def _file_url(path: str) -> str:
|
|
| 153 |
|
| 154 |
|
| 155 |
def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, process_all, mono,
|
| 156 |
-
progress=gr.Progress()):
|
| 157 |
hidden = gr.update(visible=False)
|
| 158 |
paths = _paths(files)
|
| 159 |
if not paths:
|
| 160 |
yield ui_html.empty("Primero sube uno o varios audios (paso 2)."), "", hidden, hidden
|
| 161 |
return
|
| 162 |
-
prof =
|
| 163 |
-
settings = _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono)
|
|
|
|
| 164 |
job = _new_job_dir()
|
| 165 |
out_dir = job / "corregidos"
|
| 166 |
cards, durs, items, outputs = [], [], [], []
|
|
@@ -178,18 +188,6 @@ def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, pro
|
|
| 178 |
n_err += 1
|
| 179 |
cards.append(ui_html.card(name, "", ("bad", "✗ ERROR"), [], error=pre.error))
|
| 180 |
items.append({"nombre": name, "estado_inicial": "ERROR", "estado_final": "ERROR", "notas": [pre.error]})
|
| 181 |
-
elif pre.ok and not process_all:
|
| 182 |
-
n_ok += 1
|
| 183 |
-
durs.append((name, pre.meta.duration))
|
| 184 |
-
dst = out_dir / name
|
| 185 |
-
out_dir.mkdir(parents=True, exist_ok=True)
|
| 186 |
-
shutil.copy(p, dst)
|
| 187 |
-
outputs.append(str(dst))
|
| 188 |
-
cards.append(ui_html.card(name, pre.meta.describe(), ("ok", "✓ YA CUMPLE"), pre.rows,
|
| 189 |
-
notes=["Ya cumplía: se incluye tal cual, sin tocarlo."],
|
| 190 |
-
download=_file_url(dst)))
|
| 191 |
-
items.append({"nombre": name, "duracion_s": pre.meta.duration, "estado_inicial": "PASA", "estado_final": "PASA",
|
| 192 |
-
"formato": pre.meta.describe(), "antes": pre.m, "despues": pre.m, "notas": ["Sin cambios"]})
|
| 193 |
else:
|
| 194 |
r = pipeline.correct(p, prof, settings, out_dir, progress=prog, before=pre)
|
| 195 |
if r.error or r.after is None or r.after.error:
|
|
@@ -201,18 +199,24 @@ def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, pro
|
|
| 201 |
ok = r.after.ok
|
| 202 |
durs.append((Path(r.out_path).name, r.after.meta.duration))
|
| 203 |
outputs.append(r.out_path)
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
n_fixed += 1
|
| 206 |
badge = ("ok", "✓ CORREGIDO") if not r.before.ok else ("ok", "✓ PROCESADO")
|
| 207 |
else:
|
| 208 |
n_err += 1
|
| 209 |
badge = ("bad", "✗ NO SE HA PODIDO DEJAR BIEN")
|
| 210 |
-
cards.append(ui_html.card(name,
|
| 211 |
-
r.before.
|
|
|
|
|
|
|
| 212 |
items.append({
|
| 213 |
"nombre": Path(r.out_path).name, "duracion_s": r.after.meta.duration,
|
| 214 |
"estado_inicial": "PASA" if r.before.ok else "CORREGIBLE",
|
| 215 |
-
"estado_final": "CORREGIDO" if ok else "NO CUMPLE",
|
| 216 |
"formato": r.before.meta.describe(), "antes": r.before.m, "despues": r.after.m,
|
| 217 |
"fallos": profiles.failed(r.before.rows), "notas": r.notes,
|
| 218 |
})
|
|
@@ -366,7 +370,7 @@ HELP = """
|
|
| 366 |
- 🔴 **ERROR**: el fichero no se puede abrir. Vuelve a exportarlo desde Audacity.
|
| 367 |
4. Pulsa **✨ Corregir y descargar**. Cuando termine aparece **Descarga**:
|
| 368 |
- El **ZIP** lo contiene todo: los audios corregidos, **con el mismo nombre que tenían**, y un informe en Excel.
|
| 369 |
-
-
|
| 370 |
5. **Entrega los audios del ZIP.**
|
| 371 |
|
| 372 |
Revisar no cambia nada; es solo para mirar. Puedes pasar directamente al paso 4: también revisa antes de corregir.
|
|
@@ -391,6 +395,10 @@ Debajo de cada tarjeta, **«Qué se ha hecho»** resume los arreglos aplicados a
|
|
| 391 |
|
| 392 |
Si marcas **«Poner nota de calidad al corregir»**, cada tarjeta muestra una nota de 1 a 5 **antes** y **después**: es cómo valoraría el audio una persona escuchándolo (por encima de 3,5 suena muy bien). Sirve para quedarte tranquila de que la limpieza **mejora** el sonido y no lo estropea. Como hace que cada audio tarde bastante más (más o menos el triple), viene **desmarcada**: márcala cuando quieras comprobarlo, por ejemplo con el primer audio de un encargo.
|
| 393 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
## Antes de corregir muchos audios, prueba uno
|
| 395 |
|
| 396 |
En la pestaña **🎧 Probar** subes un audio, eliges un trozo y pulsas **▶ Probar este tramo**. Oirás el original y el limpio, uno al lado del otro.
|
|
@@ -407,7 +415,7 @@ Normalmente **no hace falta tocarlas**: cada proyecto ya trae las suyas.
|
|
| 407 |
- **Cuánto ruido quitar**: *Natural* es lo recomendado. *Suave* deja algo de ambiente; *Fuerte* y *Máximo* son para grabaciones con mucho ruido.
|
| 408 |
- **Quitar retumbes y golpes graves**: elimina golpes de vecinos, de la mesa o del pie del micro.
|
| 409 |
- **Convertir a mono**: si no lo marcas, el audio sale con los mismos canales con los que entró.
|
| 410 |
-
- **
|
| 411 |
|
| 412 |
## Si algo no va bien
|
| 413 |
|
|
@@ -446,7 +454,8 @@ with gr.Blocks(title="Limpieza y control de voz", delete_cache=(3600, 86400)) as
|
|
| 446 |
info="Golpes de vecinos, de la mesa, del pie del micro…")
|
| 447 |
mono = gr.Checkbox(value=False, label="Convertir a mono",
|
| 448 |
info="Si no lo marcas, sale con los mismos canales que entró.")
|
| 449 |
-
process_all = gr.Checkbox(value=False, label="
|
|
|
|
| 450 |
escalate = gr.Checkbox(value=True, label="Si sigue habiendo ruido, insistir automáticamente")
|
| 451 |
use_gpu = gr.Checkbox(value=True, label="Usar la tarjeta gráfica si está libre (solo audios largos)",
|
| 452 |
visible=gpu_available())
|
|
@@ -457,6 +466,11 @@ with gr.Blocks(title="Limpieza y control de voz", delete_cache=(3600, 86400)) as
|
|
| 457 |
files = gr.File(label="2 · Sube aquí los audios (puedes arrastrar varios a la vez)",
|
| 458 |
file_count="multiple", file_types=AUDIO_EXTENSIONS)
|
| 459 |
use_dnsmos = gr.Checkbox(value=False, label=QUALITY_LABEL, info=QUALITY_INFO, elem_classes="ad-quality")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
with gr.Row():
|
| 461 |
analyze_btn = gr.Button("🔍 3 · Revisar", variant="secondary")
|
| 462 |
correct_btn = gr.Button("✨ 4 · Corregir y descargar", variant="primary")
|
|
@@ -494,8 +508,9 @@ with gr.Blocks(title="Limpieza y control de voz", delete_cache=(3600, 86400)) as
|
|
| 494 |
|
| 495 |
profile_dd.change(_profile_info, profile_dd, [profile_md, preset, hp, ultra, process_all, mono])
|
| 496 |
app.load(_profile_info, profile_dd, [profile_md, preset, hp, ultra, process_all, mono])
|
| 497 |
-
analyze_btn.click(cb_analyze, [files, profile_dd], [summary, results])
|
| 498 |
-
correct_btn.click(cb_correct, [files, profile_dd, preset, hp, ultra, escalate, use_dnsmos, use_gpu, process_all, mono
|
|
|
|
| 499 |
[summary, results, zip_btn, downloads])
|
| 500 |
p_audio.change(cb_load_preview, p_audio, [p_overview, p_start, p_info])
|
| 501 |
p_btn.click(cb_preview, [p_audio, p_start, p_len, preset, hp, ultra, p_dnsmos, use_gpu],
|
|
|
|
| 74 |
return DEFAULT_PRESET
|
| 75 |
|
| 76 |
|
| 77 |
+
def _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono=None, clean=False) -> pipeline.Settings:
|
| 78 |
return pipeline.Settings.from_profile(
|
| 79 |
prof, reduccion_db=PRESETS[preset], paso_alto_hz=float(hp) or None,
|
| 80 |
conservar_ultrasonidos=ultra, escalar_reduccion=escalate, dnsmos=use_dnsmos, usar_gpu=use_gpu,
|
| 81 |
+
forzar_mono=mono, limpiar_siempre=clean,
|
| 82 |
)
|
| 83 |
|
| 84 |
|
|
|
|
| 104 |
|
| 105 |
# ── Comprobar ────────────────────────────────────────────────────────────────
|
| 106 |
|
| 107 |
+
def _prof(pid: str, pause_max: float) -> dict:
|
| 108 |
+
return {**PROFILES[pid], "_pausa_max_s": float(pause_max)}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def cb_analyze(files, pid, pause_max, progress=gr.Progress()):
|
| 112 |
paths = _paths(files)
|
| 113 |
if not paths:
|
| 114 |
yield ui_html.empty("Primero sube uno o varios audios (paso 2)."), ""
|
| 115 |
return
|
| 116 |
+
prof = _prof(pid, pause_max)
|
| 117 |
cards, durs = [], []
|
| 118 |
+
n_ok = n_fix = n_err = n_pauses = 0
|
| 119 |
for i, p in enumerate(paths):
|
| 120 |
progress(i / len(paths), desc=f"Analizando {Path(p).name}")
|
| 121 |
a = pipeline.analyze(p, prof)
|
|
|
|
| 131 |
else:
|
| 132 |
n_fix += 1
|
| 133 |
badge = ("fix", "⚡ HAY QUE CORREGIR")
|
| 134 |
+
n_pauses += bool(profiles.long_pauses(a.m, prof))
|
| 135 |
cards.append(ui_html.card(name, a.meta.describe(), badge, a.rows))
|
| 136 |
+
summary = _summary(prof, len(paths), n_ok, n_fix, n_err, durs, "Por corregir")
|
| 137 |
+
if n_pauses and i == len(paths) - 1:
|
| 138 |
+
summary = ui_html.banner("info", f"✂️ {n_pauses} audio(s) con pausas de más de {pause_max:.1f} s. "
|
| 139 |
+
"Si quieres acortarlas, marca «Acortar pausas largas» antes de corregir.") + summary
|
| 140 |
+
yield summary, "".join(reversed(cards))
|
| 141 |
progress(1.0)
|
| 142 |
|
| 143 |
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
def cb_correct(files, pid, preset, hp, ultra, escalate, use_dnsmos, use_gpu, process_all, mono,
|
| 165 |
+
cut_pauses, pause_max, progress=gr.Progress()):
|
| 166 |
hidden = gr.update(visible=False)
|
| 167 |
paths = _paths(files)
|
| 168 |
if not paths:
|
| 169 |
yield ui_html.empty("Primero sube uno o varios audios (paso 2)."), "", hidden, hidden
|
| 170 |
return
|
| 171 |
+
prof = _prof(pid, pause_max)
|
| 172 |
+
settings = _settings(prof, preset, hp, ultra, escalate, use_dnsmos, use_gpu, mono, process_all)
|
| 173 |
+
settings.acortar_pausas = bool(cut_pauses)
|
| 174 |
job = _new_job_dir()
|
| 175 |
out_dir = job / "corregidos"
|
| 176 |
cards, durs, items, outputs = [], [], [], []
|
|
|
|
| 188 |
n_err += 1
|
| 189 |
cards.append(ui_html.card(name, "", ("bad", "✗ ERROR"), [], error=pre.error))
|
| 190 |
items.append({"nombre": name, "estado_inicial": "ERROR", "estado_final": "ERROR", "notas": [pre.error]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
else:
|
| 192 |
r = pipeline.correct(p, prof, settings, out_dir, progress=prog, before=pre)
|
| 193 |
if r.error or r.after is None or r.after.error:
|
|
|
|
| 199 |
ok = r.after.ok
|
| 200 |
durs.append((Path(r.out_path).name, r.after.meta.duration))
|
| 201 |
outputs.append(r.out_path)
|
| 202 |
+
untouched = r.after is r.before
|
| 203 |
+
if ok and untouched:
|
| 204 |
+
n_ok += 1
|
| 205 |
+
badge = ("ok", "✓ YA CUMPLE · sin tocar")
|
| 206 |
+
elif ok:
|
| 207 |
n_fixed += 1
|
| 208 |
badge = ("ok", "✓ CORREGIDO") if not r.before.ok else ("ok", "✓ PROCESADO")
|
| 209 |
else:
|
| 210 |
n_err += 1
|
| 211 |
badge = ("bad", "✗ NO SE HA PODIDO DEJAR BIEN")
|
| 212 |
+
cards.append(ui_html.card(name, r.before.meta.describe() if untouched else
|
| 213 |
+
f"{r.before.meta.describe()} → {r.after.meta.describe()}", badge,
|
| 214 |
+
r.before.rows, None if untouched else r.after.rows, notes=r.notes,
|
| 215 |
+
download=_file_url(r.out_path)))
|
| 216 |
items.append({
|
| 217 |
"nombre": Path(r.out_path).name, "duracion_s": r.after.meta.duration,
|
| 218 |
"estado_inicial": "PASA" if r.before.ok else "CORREGIBLE",
|
| 219 |
+
"estado_final": ("PASA" if untouched else "CORREGIDO") if ok else "NO CUMPLE",
|
| 220 |
"formato": r.before.meta.describe(), "antes": r.before.m, "despues": r.after.m,
|
| 221 |
"fallos": profiles.failed(r.before.rows), "notas": r.notes,
|
| 222 |
})
|
|
|
|
| 370 |
- 🔴 **ERROR**: el fichero no se puede abrir. Vuelve a exportarlo desde Audacity.
|
| 371 |
4. Pulsa **✨ Corregir y descargar**. Cuando termine aparece **Descarga**:
|
| 372 |
- El **ZIP** lo contiene todo: los audios corregidos, **con el mismo nombre que tenían**, y un informe en Excel.
|
| 373 |
+
- **Solo se corrige lo que no cumple.** Si un audio solo tiene mal el volumen, solo se ajusta el volumen; si ya cumple todo, se entrega tal cual, sin tocarlo.
|
| 374 |
5. **Entrega los audios del ZIP.**
|
| 375 |
|
| 376 |
Revisar no cambia nada; es solo para mirar. Puedes pasar directamente al paso 4: también revisa antes de corregir.
|
|
|
|
| 395 |
|
| 396 |
Si marcas **«Poner nota de calidad al corregir»**, cada tarjeta muestra una nota de 1 a 5 **antes** y **después**: es cómo valoraría el audio una persona escuchándolo (por encima de 3,5 suena muy bien). Sirve para quedarte tranquila de que la limpieza **mejora** el sonido y no lo estropea. Como hace que cada audio tarde bastante más (más o menos el triple), viene **desmarcada**: márcala cuando quieras comprobarlo, por ejemplo con el primer audio de un encargo.
|
| 397 |
|
| 398 |
+
## Acortar pausas largas (✂️)
|
| 399 |
+
|
| 400 |
+
Al revisar, cada tarjeta indica si el audio tiene **pausas largas** entre frases, por ejemplo «2 · la más larga 4,3 s». Si quieres acortarlas, marca **«Acortar pausas largas»** y elige la **pausa máxima** (2 segundos por defecto) antes de pulsar «Corregir». Solo se acortan las pausas que pasan de esa duración: se quita el trozo del medio y se conservan el principio y el final de la pausa, con un fundido para que el corte no se note. Las pausas normales no se tocan.
|
| 401 |
+
|
| 402 |
## Antes de corregir muchos audios, prueba uno
|
| 403 |
|
| 404 |
En la pestaña **🎧 Probar** subes un audio, eliges un trozo y pulsas **▶ Probar este tramo**. Oirás el original y el limpio, uno al lado del otro.
|
|
|
|
| 415 |
- **Cuánto ruido quitar**: *Natural* es lo recomendado. *Suave* deja algo de ambiente; *Fuerte* y *Máximo* son para grabaciones con mucho ruido.
|
| 416 |
- **Quitar retumbes y golpes graves**: elimina golpes de vecinos, de la mesa o del pie del micro.
|
| 417 |
- **Convertir a mono**: si no lo marcas, el audio sale con los mismos canales con los que entró.
|
| 418 |
+
- **Quitar ruido aunque ya cumpla**: por si quieres pasar el limpiador de ruido aunque el audio ya cumpla.
|
| 419 |
|
| 420 |
## Si algo no va bien
|
| 421 |
|
|
|
|
| 454 |
info="Golpes de vecinos, de la mesa, del pie del micro…")
|
| 455 |
mono = gr.Checkbox(value=False, label="Convertir a mono",
|
| 456 |
info="Si no lo marcas, sale con los mismos canales que entró.")
|
| 457 |
+
process_all = gr.Checkbox(value=False, label="Quitar ruido aunque ya cumpla",
|
| 458 |
+
info="Normalmente no hace falta: la app solo corrige lo que no cumple.")
|
| 459 |
escalate = gr.Checkbox(value=True, label="Si sigue habiendo ruido, insistir automáticamente")
|
| 460 |
use_gpu = gr.Checkbox(value=True, label="Usar la tarjeta gráfica si está libre (solo audios largos)",
|
| 461 |
visible=gpu_available())
|
|
|
|
| 466 |
files = gr.File(label="2 · Sube aquí los audios (puedes arrastrar varios a la vez)",
|
| 467 |
file_count="multiple", file_types=AUDIO_EXTENSIONS)
|
| 468 |
use_dnsmos = gr.Checkbox(value=False, label=QUALITY_LABEL, info=QUALITY_INFO, elem_classes="ad-quality")
|
| 469 |
+
with gr.Row(elem_classes="ad-quality"):
|
| 470 |
+
cut_pauses = gr.Checkbox(value=False, label="✂️ Acortar pausas largas", scale=2,
|
| 471 |
+
info="Recorta las pausas entre frases que pasen de la duración máxima. "
|
| 472 |
+
"Conserva el principio y el final de cada pausa para que suene natural.")
|
| 473 |
+
pause_max = gr.Slider(0.5, 5.0, value=2.0, step=0.1, label="Pausa máxima (segundos)", scale=1)
|
| 474 |
with gr.Row():
|
| 475 |
analyze_btn = gr.Button("🔍 3 · Revisar", variant="secondary")
|
| 476 |
correct_btn = gr.Button("✨ 4 · Corregir y descargar", variant="primary")
|
|
|
|
| 508 |
|
| 509 |
profile_dd.change(_profile_info, profile_dd, [profile_md, preset, hp, ultra, process_all, mono])
|
| 510 |
app.load(_profile_info, profile_dd, [profile_md, preset, hp, ultra, process_all, mono])
|
| 511 |
+
analyze_btn.click(cb_analyze, [files, profile_dd, pause_max], [summary, results])
|
| 512 |
+
correct_btn.click(cb_correct, [files, profile_dd, preset, hp, ultra, escalate, use_dnsmos, use_gpu, process_all, mono,
|
| 513 |
+
cut_pauses, pause_max],
|
| 514 |
[summary, results, zip_btn, downloads])
|
| 515 |
p_audio.change(cb_load_preview, p_audio, [p_overview, p_start, p_info])
|
| 516 |
p_btn.click(cb_preview, [p_audio, p_start, p_len, preset, hp, ultra, p_dnsmos, use_gpu],
|
dsp.py
CHANGED
|
@@ -152,6 +152,97 @@ def detect_voice(mono: np.ndarray, sr: int, start_above: float = 14.0, end_above
|
|
| 152 |
return VoiceRegion(start, end, noise, start_th, fdb, flen, True)
|
| 153 |
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
def _room_tone(x: np.ndarray, region: VoiceRegion, length: int, sr: int) -> np.ndarray:
|
| 156 |
"""
|
| 157 |
Construye `length` muestras de "tono de sala" a partir del tramo sin voz más silencioso
|
|
@@ -189,8 +280,11 @@ def _room_tone(x: np.ndarray, region: VoiceRegion, length: int, sr: int) -> np.n
|
|
| 189 |
|
| 190 |
|
| 191 |
def set_edge_silences(x: np.ndarray, sr: int, region: VoiceRegion, target_s: float,
|
| 192 |
-
fill: str = "tono_sala") -> tuple[np.ndarray, list[str]]:
|
| 193 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 194 |
notes = []
|
| 195 |
tgt = int(round(target_s * sr))
|
| 196 |
head, tail = region.start, x.shape[0] - 1 - region.end
|
|
@@ -199,6 +293,8 @@ def set_edge_silences(x: np.ndarray, sr: int, region: VoiceRegion, target_s: flo
|
|
| 199 |
(lambda n: np.zeros((n, x.shape[1]), dtype=np.float32))
|
| 200 |
|
| 201 |
def edge(seg_len, side):
|
|
|
|
|
|
|
| 202 |
if seg_len >= tgt: # recortar (se conserva el tramo pegado a la voz)
|
| 203 |
return x[region.start - tgt : region.start] if side == "ini" else x[region.end + 1 : region.end + 1 + tgt]
|
| 204 |
extra = filler(tgt - seg_len)
|
|
@@ -212,10 +308,12 @@ def set_edge_silences(x: np.ndarray, sr: int, region: VoiceRegion, target_s: flo
|
|
| 212 |
f = min(int(0.005 * sr), len(out) // 2)
|
| 213 |
if f:
|
| 214 |
ramp = np.linspace(0, 1, f, dtype=np.float32)[:, None]
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
| 219 |
accion = "recortado" if antes > tgt else ("alargado con el silencio natural de la sala" if fill == "tono_sala" else "alargado con silencio")
|
| 220 |
notes.append(f"Silencio {nombre}: {antes / sr:.2f} s → {target_s:.2f} s ({accion})")
|
| 221 |
return out, notes
|
|
|
|
| 152 |
return VoiceRegion(start, end, noise, start_th, fdb, flen, True)
|
| 153 |
|
| 154 |
|
| 155 |
+
def detect_voice_v4(mono: np.ndarray, sr: int) -> VoiceRegion:
|
| 156 |
+
"""
|
| 157 |
+
Réplica exacta de detectVoiceRegion() del «Analizador de audio v4» (HTML):
|
| 158 |
+
ruido = media en dB de las tramas de los primeros y últimos 0,4 s; inicio = primeras 6 tramas
|
| 159 |
+
seguidas ≥ ruido + 14 dB; final = últimas 6 tramas seguidas ≥ ruido + 10 dB.
|
| 160 |
+
Así lo que mide y corrige la app coincide con lo que mide esa herramienta.
|
| 161 |
+
"""
|
| 162 |
+
flen = max(64, round(sr * FRAME_MS / 1000))
|
| 163 |
+
nfr = max(1, int(np.ceil(len(mono) / flen)))
|
| 164 |
+
x = np.asarray(mono, dtype=np.float64)
|
| 165 |
+
padded = np.pad(x, (0, nfr * flen - len(x)))
|
| 166 |
+
sums = (padded.reshape(nfr, flen) ** 2).sum(axis=1)
|
| 167 |
+
counts = np.full(nfr, flen, dtype=np.float64)
|
| 168 |
+
counts[-1] = len(x) - (nfr - 1) * flen # la última trama puede ser más corta (como en el HTML)
|
| 169 |
+
with np.errstate(divide="ignore"):
|
| 170 |
+
fdb = 10 * np.log10(sums / counts)
|
| 171 |
+
edge = max(3, int(0.4 * sr / flen))
|
| 172 |
+
vals = np.concatenate([fdb[: min(edge, nfr)], fdb[max(0, nfr - edge) :]])
|
| 173 |
+
vals = vals[np.isfinite(vals)]
|
| 174 |
+
noise = float(vals.mean()) if vals.size else -90.0
|
| 175 |
+
start_th, end_th = noise + 14, noise + 10
|
| 176 |
+
k = max(2, round(120 / FRAME_MS))
|
| 177 |
+
fdb_safe = np.where(np.isfinite(fdb), fdb, -1e9)
|
| 178 |
+
if nfr < k:
|
| 179 |
+
return VoiceRegion(0, len(mono) - 1, noise, start_th, fdb_safe, flen, False)
|
| 180 |
+
win_min = sliding_window_view(fdb_safe, k).min(axis=1)
|
| 181 |
+
starts = np.flatnonzero(win_min >= start_th)
|
| 182 |
+
if starts.size == 0:
|
| 183 |
+
return VoiceRegion(0, len(mono) - 1, noise, start_th, fdb_safe, flen, False)
|
| 184 |
+
first = int(starts[0])
|
| 185 |
+
ends = np.flatnonzero(win_min[first:] >= end_th)
|
| 186 |
+
last = first + int(ends[-1]) + k - 1 if ends.size else nfr - 1
|
| 187 |
+
return VoiceRegion(first * flen, min(len(mono) - 1, (last + 1) * flen - 1), noise, start_th, fdb_safe, flen, True)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def voice_region(mono: np.ndarray, sr: int, method: str | None = None) -> VoiceRegion:
|
| 191 |
+
"""Detector de voz según el perfil: «analizador_v4» (compatible con el HTML) o el propio por defecto."""
|
| 192 |
+
return detect_voice_v4(mono, sr) if method == "analizador_v4" else detect_voice(mono, sr)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def find_pauses(mono: np.ndarray, sr: int, region: VoiceRegion, min_s: float = 0.3) -> list[tuple[int, int]]:
|
| 196 |
+
"""
|
| 197 |
+
Pausas internas (entre la primera y la última voz) de al menos `min_s`, en muestras [ini, fin).
|
| 198 |
+
Una trama es pausa si queda por debajo del umbral de «fin de voz» (ruido + 10 dB).
|
| 199 |
+
"""
|
| 200 |
+
if not region.found:
|
| 201 |
+
return []
|
| 202 |
+
flen, fdb = region.frame_len, region.frame_db
|
| 203 |
+
quiet = fdb < max(region.start_th_db - 4, DIGITAL_SILENCE_DB)
|
| 204 |
+
f0, f1 = region.start // flen, region.end // flen
|
| 205 |
+
pauses, run = [], None
|
| 206 |
+
for f in range(f0, f1 + 1):
|
| 207 |
+
if quiet[f] and run is None:
|
| 208 |
+
run = f
|
| 209 |
+
elif not quiet[f] and run is not None:
|
| 210 |
+
if (f - run) * flen >= min_s * sr:
|
| 211 |
+
pauses.append((run * flen, f * flen))
|
| 212 |
+
run = None
|
| 213 |
+
return pauses
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def shorten_pauses(x: np.ndarray, sr: int, region: VoiceRegion, max_s: float) -> tuple[np.ndarray, list[float]]:
|
| 217 |
+
"""
|
| 218 |
+
Acorta a `max_s` las pausas internas más largas. Conserva la mitad de `max_s` pegada a cada lado
|
| 219 |
+
(respiraciones y finales de palabra intactos), quita el centro y une con un fundido de 30 ms.
|
| 220 |
+
Devuelve (audio, duraciones originales de las pausas acortadas).
|
| 221 |
+
"""
|
| 222 |
+
mono = to_mono(x)[:, 0]
|
| 223 |
+
long_ones = [(a, b) for a, b in find_pauses(mono, sr, region) if (b - a) > max_s * sr + 0.05 * sr]
|
| 224 |
+
if not long_ones:
|
| 225 |
+
return x, []
|
| 226 |
+
half = int(max_s * sr / 2)
|
| 227 |
+
xf = min(int(0.03 * sr), half // 2)
|
| 228 |
+
fade = np.sin(np.linspace(0, np.pi / 2, xf, dtype=np.float32))[:, None] if xf else None
|
| 229 |
+
pieces, pos = [], 0
|
| 230 |
+
for a, b in long_ones:
|
| 231 |
+
cut_a, cut_b = a + half, b - half - xf # se elimina [cut_a, cut_b); el fundido solapa xf muestras
|
| 232 |
+
left = x[pos:cut_a].copy()
|
| 233 |
+
right_start = cut_b
|
| 234 |
+
if xf:
|
| 235 |
+
# fundido de potencia constante entre el final del tramo izquierdo y el inicio del derecho
|
| 236 |
+
tail = left[-xf:] * fade[::-1]
|
| 237 |
+
head = x[right_start : right_start + xf] * fade
|
| 238 |
+
left[-xf:] = tail + head
|
| 239 |
+
right_start += xf
|
| 240 |
+
pieces.append(left)
|
| 241 |
+
pos = right_start
|
| 242 |
+
pieces.append(x[pos:])
|
| 243 |
+
return np.concatenate(pieces).astype(np.float32), [(b - a) / sr for a, b in long_ones]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
def _room_tone(x: np.ndarray, region: VoiceRegion, length: int, sr: int) -> np.ndarray:
|
| 247 |
"""
|
| 248 |
Construye `length` muestras de "tono de sala" a partir del tramo sin voz más silencioso
|
|
|
|
| 280 |
|
| 281 |
|
| 282 |
def set_edge_silences(x: np.ndarray, sr: int, region: VoiceRegion, target_s: float,
|
| 283 |
+
fill: str = "tono_sala", sides=("ini", "fin")) -> tuple[np.ndarray, list[str]]:
|
| 284 |
+
"""
|
| 285 |
+
Deja exactamente `target_s` antes de la primera voz («ini») y/o después de la última («fin»).
|
| 286 |
+
Solo toca los lados indicados en `sides`; el resto queda idéntico.
|
| 287 |
+
"""
|
| 288 |
notes = []
|
| 289 |
tgt = int(round(target_s * sr))
|
| 290 |
head, tail = region.start, x.shape[0] - 1 - region.end
|
|
|
|
| 293 |
(lambda n: np.zeros((n, x.shape[1]), dtype=np.float32))
|
| 294 |
|
| 295 |
def edge(seg_len, side):
|
| 296 |
+
if side not in sides: # lado que ya cumple: intacto
|
| 297 |
+
return x[: region.start] if side == "ini" else x[region.end + 1 :]
|
| 298 |
if seg_len >= tgt: # recortar (se conserva el tramo pegado a la voz)
|
| 299 |
return x[region.start - tgt : region.start] if side == "ini" else x[region.end + 1 : region.end + 1 + tgt]
|
| 300 |
extra = filler(tgt - seg_len)
|
|
|
|
| 308 |
f = min(int(0.005 * sr), len(out) // 2)
|
| 309 |
if f:
|
| 310 |
ramp = np.linspace(0, 1, f, dtype=np.float32)[:, None]
|
| 311 |
+
if "ini" in sides:
|
| 312 |
+
out[:f] *= ramp
|
| 313 |
+
if "fin" in sides:
|
| 314 |
+
out[-f:] *= ramp[::-1]
|
| 315 |
+
for nombre, antes, side in (("inicial", head, "ini"), ("final", tail, "fin")):
|
| 316 |
+
if side in sides and abs(antes - tgt) > sr * 0.001:
|
| 317 |
accion = "recortado" if antes > tgt else ("alargado con el silencio natural de la sala" if fill == "tono_sala" else "alargado con silencio")
|
| 318 |
notes.append(f"Silencio {nombre}: {antes / sr:.2f} s → {target_s:.2f} s ({accion})")
|
| 319 |
return out, notes
|
metrics.py
CHANGED
|
@@ -39,12 +39,12 @@ def true_peak_db(mono: np.ndarray, sr: int, block_s: float = 30.0) -> float:
|
|
| 39 |
return dsp.db(peak)
|
| 40 |
|
| 41 |
|
| 42 |
-
def measure(data: np.ndarray, sr: int, with_lufs: bool = True) -> dict:
|
| 43 |
"""data: (n, ch) float32. Devuelve un dict de métricas (floats en dB / segundos)."""
|
| 44 |
mono = dsp.to_mono(data)[:, 0]
|
| 45 |
n = len(mono)
|
| 46 |
peak_abs = float(np.abs(data).max()) if n else 0.0
|
| 47 |
-
region = dsp.
|
| 48 |
voice = mono[region.start : region.end + 1] if region.found else mono
|
| 49 |
|
| 50 |
fdb = region.frame_db
|
|
@@ -73,6 +73,7 @@ def measure(data: np.ndarray, sr: int, with_lufs: bool = True) -> dict:
|
|
| 73 |
"silencio_fin_s": (n - 1 - region.end) / sr if region.found else np.nan,
|
| 74 |
"voz_detectada": region.found,
|
| 75 |
"silencio_digital": bool(not live.any()) or bool(noise_frames.size == 0),
|
|
|
|
| 76 |
}
|
| 77 |
if with_lufs:
|
| 78 |
m["lufs"] = lufs(data, sr)
|
|
|
|
| 39 |
return dsp.db(peak)
|
| 40 |
|
| 41 |
|
| 42 |
+
def measure(data: np.ndarray, sr: int, with_lufs: bool = True, detector: str | None = None) -> dict:
|
| 43 |
"""data: (n, ch) float32. Devuelve un dict de métricas (floats en dB / segundos)."""
|
| 44 |
mono = dsp.to_mono(data)[:, 0]
|
| 45 |
n = len(mono)
|
| 46 |
peak_abs = float(np.abs(data).max()) if n else 0.0
|
| 47 |
+
region = dsp.voice_region(mono, sr, detector)
|
| 48 |
voice = mono[region.start : region.end + 1] if region.found else mono
|
| 49 |
|
| 50 |
fdb = region.frame_db
|
|
|
|
| 73 |
"silencio_fin_s": (n - 1 - region.end) / sr if region.found else np.nan,
|
| 74 |
"voz_detectada": region.found,
|
| 75 |
"silencio_digital": bool(not live.any()) or bool(noise_frames.size == 0),
|
| 76 |
+
"pausas_s": [(b - a) / sr for a, b in dsp.find_pauses(mono, sr, region)],
|
| 77 |
}
|
| 78 |
if with_lufs:
|
| 79 |
m["lufs"] = lufs(data, sr)
|
perfiles/analizador_v4.yaml
CHANGED
|
@@ -10,6 +10,7 @@ explicacion: |
|
|
| 10 |
- **Qué hace la corrección:** quita el ruido, pasa a mono, ajusta el volumen de la voz, controla los picos y deja 0,6 s de silencio natural al principio y al final.
|
| 11 |
- **Objetivo de entrega:** 12 h 30 min en total; arriba verás cuánto llevas.
|
| 12 |
orden: 2
|
|
|
|
| 13 |
|
| 14 |
formato:
|
| 15 |
contenedor: WAV
|
|
|
|
| 10 |
- **Qué hace la corrección:** quita el ruido, pasa a mono, ajusta el volumen de la voz, controla los picos y deja 0,6 s de silencio natural al principio y al final.
|
| 11 |
- **Objetivo de entrega:** 12 h 30 min en total; arriba verás cuánto llevas.
|
| 12 |
orden: 2
|
| 13 |
+
deteccion_voz: analizador_v4 # mismo detector de voz que el HTML: lo que mide la app coincide con lo que mide él
|
| 14 |
|
| 15 |
formato:
|
| 16 |
contenedor: WAV
|
perfiles/multiling_es-es_01.yaml
CHANGED
|
@@ -1,28 +1,35 @@
|
|
| 1 |
# Perfil de entrega: MultiLing_es-es_01 (FEMALE, Spanish Spain)
|
| 2 |
# Fuente: especificación del cliente (PDF «MultiLing_es-es_01»), apartado «Audio Requirements».
|
| 3 |
nombre: "MultiLing · voz femenina es-ES (40 min)"
|
| 4 |
-
descripcion: "WAV PCM · 48 kHz ·
|
| 5 |
explicacion: |
|
| 6 |
Encargo **MultiLing es-ES 01** (voz femenina, 40 minutos en total).
|
| 7 |
-
- **Qué pide el cliente
|
| 8 |
-
- **Qué hace la corrección:**
|
| 9 |
- **Ficheros que espera:** nonverbal_laughter, nonverbal_crying y nonverbal_filler words (30 s cada uno), más full recording_part 1, 2 y 3.
|
| 10 |
orden: 1
|
|
|
|
| 11 |
|
| 12 |
formato:
|
| 13 |
contenedor: WAV # «Format: WAV (PCM)»
|
| 14 |
pcm_entero: true
|
| 15 |
-
|
| 16 |
-
frecuencia: 48000 # El PDF dice «48.1 kHz»:
|
| 17 |
-
canales:
|
| 18 |
|
| 19 |
niveles:
|
| 20 |
-
pico_max_dbfs: -
|
|
|
|
| 21 |
|
| 22 |
ruido:
|
| 23 |
snr_min_db: 50 # «Signal-to-Noise Ratio (SNR): 50 dB or higher»
|
|
|
|
| 24 |
|
| 25 |
-
silencios:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
saturacion_max_muestras: 0
|
| 28 |
|
|
|
|
| 1 |
# Perfil de entrega: MultiLing_es-es_01 (FEMALE, Spanish Spain)
|
| 2 |
# Fuente: especificación del cliente (PDF «MultiLing_es-es_01»), apartado «Audio Requirements».
|
| 3 |
nombre: "MultiLing · voz femenina es-ES (40 min)"
|
| 4 |
+
descripcion: "Reglas del analizador v4 + SNR ≥ 50 dB: WAV PCM 24 bits · 48 kHz · mono · pico ≤ −3 · RMS voz −30…−20 · silencios 0,5–0,7 s"
|
| 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:** solo arregla lo que no cumple. Si un audio ya está bien, no se toca.
|
| 9 |
- **Ficheros que espera:** nonverbal_laughter, nonverbal_crying y nonverbal_filler words (30 s cada uno), más full recording_part 1, 2 y 3.
|
| 10 |
orden: 1
|
| 11 |
+
deteccion_voz: analizador_v4 # mismo detector de voz que el HTML v4 (Gemma comprueba con él)
|
| 12 |
|
| 13 |
formato:
|
| 14 |
contenedor: WAV # «Format: WAV (PCM)»
|
| 15 |
pcm_entero: true
|
| 16 |
+
bits: 24 # El PDF admite «24-bit or higher»; el analizador v4 exige exactamente 24.
|
| 17 |
+
frecuencia: 48000 # El PDF dice «48.1 kHz»: errata de 48 kHz (confirmado).
|
| 18 |
+
canales: 1 # Regla del analizador v4.
|
| 19 |
|
| 20 |
niveles:
|
| 21 |
+
pico_max_dbfs: -3.0
|
| 22 |
+
rms_voz: {min: -30.0, max: -20.0, objetivo: -25.0}
|
| 23 |
|
| 24 |
ruido:
|
| 25 |
snr_min_db: 50 # «Signal-to-Noise Ratio (SNR): 50 dB or higher»
|
| 26 |
+
ruido_relativo_max_db: -40.0
|
| 27 |
|
| 28 |
+
silencios:
|
| 29 |
+
min_s: 0.5
|
| 30 |
+
max_s: 0.7
|
| 31 |
+
objetivo_s: 0.6
|
| 32 |
+
relleno: tono_sala
|
| 33 |
|
| 34 |
saturacion_max_muestras: 0
|
| 35 |
|
pipeline.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
pipeline.py — Analizar y corregir ficheros según un perfil de especificación.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
7. Escritura en el formato pedido y nueva medición del fichero escrito (verificación real).
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -27,6 +26,18 @@ from audio_io import AudioMeta, output_format, read_audio, read_meta, write_audi
|
|
| 27 |
|
| 28 |
NOISE_CHECKS = {"Relación señal/ruido (SNR)", "Ruido relativo (inicio/fin)"}
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
@dataclass
|
| 32 |
class Settings:
|
|
@@ -37,6 +48,8 @@ class Settings:
|
|
| 37 |
usar_gpu: bool = True
|
| 38 |
escalar_reduccion: bool = True
|
| 39 |
dnsmos: bool = False
|
|
|
|
|
|
|
| 40 |
|
| 41 |
@classmethod
|
| 42 |
def from_profile(cls, prof: dict, **overrides) -> "Settings":
|
|
@@ -91,7 +104,7 @@ def analyze(path: str, prof: dict, with_dnsmos: bool = False) -> Analysis:
|
|
| 91 |
data, sr, meta = read_audio(path)
|
| 92 |
if data.shape[0] == 0:
|
| 93 |
raise ValueError("El fichero está vacío")
|
| 94 |
-
m = metrics.measure(data, sr)
|
| 95 |
if with_dnsmos:
|
| 96 |
import dnsmos
|
| 97 |
|
|
@@ -101,44 +114,115 @@ def analyze(path: str, prof: dict, with_dnsmos: bool = False) -> Analysis:
|
|
| 101 |
return Analysis(path, None, error=f"{type(e).__name__}: {e}")
|
| 102 |
|
| 103 |
|
| 104 |
-
def
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
for _ in range(4):
|
| 118 |
-
|
| 119 |
-
if not
|
|
|
|
| 120 |
break
|
| 121 |
-
|
| 122 |
-
if abs(
|
|
|
|
| 123 |
break
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
if info["muestras_afectadas"]:
|
| 134 |
-
notes.append(f"Picos suavizados para no pasar de {ceiling + 0.05:.1f} dBFS".replace("-", "−"))
|
| 135 |
-
return y
|
| 136 |
|
| 137 |
|
| 138 |
def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=None,
|
| 139 |
before: Analysis | None = None) -> Correction:
|
| 140 |
"""
|
| 141 |
-
Corrige `path` según `prof`. progress(fracción, texto) opcional.
|
| 142 |
`before`: análisis ya hecho del original (se reutiliza para no medir dos veces).
|
| 143 |
"""
|
| 144 |
if before is None:
|
|
@@ -149,81 +233,79 @@ def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=N
|
|
| 149 |
return res
|
| 150 |
try:
|
| 151 |
data, sr, meta = read_audio(path)
|
| 152 |
-
fmt = prof.get("formato") or {}
|
| 153 |
notes = res.notes
|
| 154 |
-
x = data
|
| 155 |
if settings.dnsmos and "dnsmos" not in before.m:
|
| 156 |
import dnsmos
|
| 157 |
|
| 158 |
before.m["dnsmos"] = dnsmos.score(dsp.to_mono(data)[:, 0], sr)
|
| 159 |
before.rows = profiles.check(meta, before.m, prof)
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
if settings.
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
start = settings.reduccion_db
|
| 177 |
-
if settings.escalar_reduccion and start is not None:
|
| 178 |
need = _needed_reduction(before.m, prof)
|
| 179 |
if need > start:
|
| 180 |
start = start + 12 if need <= start + 12 else None
|
| 181 |
-
|
| 182 |
-
f"{'máxima' if start is None else f'de {start:.0f} dB'}")
|
| 183 |
-
steps = [(start, False)]
|
| 184 |
if settings.escalar_reduccion:
|
| 185 |
if start is not None:
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
sil = prof.get("silencios") or None
|
| 189 |
-
cache: dict = {}
|
| 190 |
-
|
| 191 |
-
for i, (att, expander) in enumerate(steps):
|
| 192 |
-
def prog(f, i=i):
|
| 193 |
-
if progress:
|
| 194 |
-
progress(f, f"Reduciendo ruido ({'sin límite' if att is None else f'{att:.0f} dB'})")
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
step_notes =
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
if
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
notes += step_notes
|
| 218 |
-
break
|
| 219 |
-
notes.append(f"Con la reducción {'máxima' if att is None else f'de {att:.0f} dB'} seguía sin cumplir "
|
| 220 |
-
f"«{', '.join(noise_fail)}»: se ha insistido con más reducción")
|
| 221 |
|
| 222 |
-
container, subtype, ext = output_format(meta, (prof.get("procesado") or {}).get("salida", {}).get("contenedor"),
|
| 223 |
-
(prof.get("procesado") or {}).get("salida", {}).get("subtipo"))
|
| 224 |
-
out_dir.mkdir(parents=True, exist_ok=True)
|
| 225 |
out_path = out_dir / (Path(meta.name).stem + ext)
|
| 226 |
-
write_audio(out_path, y,
|
| 227 |
res.out_path = str(out_path)
|
| 228 |
res.after = analyze(str(out_path), prof, with_dnsmos=settings.dnsmos)
|
| 229 |
if res.after.meta:
|
|
|
|
| 1 |
"""
|
| 2 |
pipeline.py — Analizar y corregir ficheros según un perfil de especificación.
|
| 3 |
|
| 4 |
+
Principio: corrección MÍNIMA. Se mide, se ve qué comprobaciones no cumplen y se aplica SOLO
|
| 5 |
+
la corrección de cada una (si solo falla el volumen, solo se ajusta el volumen). Después se
|
| 6 |
+
vuelve a medir: si una corrección provoca un fallo nuevo (p. ej. al subir el volumen asoma el
|
| 7 |
+
ruido), se añade solo la corrección de ese fallo y se repite. Si un audio cumple, no se toca.
|
| 8 |
+
|
| 9 |
+
Correcciones posibles (se aplican en este orden, solo las necesarias):
|
| 10 |
+
mono → frecuencia → ruido (paso alto + DeepFilterNet, escalonado) → silencios (solo el lado
|
| 11 |
+
que falla) → volumen (ganancia + limitador) o solo picos (limitador) → formato de escritura.
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
|
|
| 26 |
|
| 27 |
NOISE_CHECKS = {"Relación señal/ruido (SNR)", "Ruido relativo (inicio/fin)"}
|
| 28 |
|
| 29 |
+
# Qué corrección arregla cada comprobación del perfil
|
| 30 |
+
FIX_FOR = {
|
| 31 |
+
"Formato": "formato", "Codificación": "formato", "Frecuencia": "frecuencia", "Canales": "mono",
|
| 32 |
+
"Pico máximo": "picos", "Saturación": "picos", "RMS de voz": "volumen",
|
| 33 |
+
"Ruido relativo (inicio/fin)": "ruido", "Relación señal/ruido (SNR)": "ruido",
|
| 34 |
+
"Silencio inicial": "sil_ini", "Silencio final": "sil_fin",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _fixes_for(rows) -> set[str]:
|
| 39 |
+
return {FIX_FOR[r.parametro] for r in rows if r.ok is False and r.parametro in FIX_FOR}
|
| 40 |
+
|
| 41 |
|
| 42 |
@dataclass
|
| 43 |
class Settings:
|
|
|
|
| 48 |
usar_gpu: bool = True
|
| 49 |
escalar_reduccion: bool = True
|
| 50 |
dnsmos: bool = False
|
| 51 |
+
limpiar_siempre: bool = False # quitar ruido aunque el ruido ya cumpla
|
| 52 |
+
acortar_pausas: bool = False # acortar pausas internas más largas que profiles.pause_max()
|
| 53 |
|
| 54 |
@classmethod
|
| 55 |
def from_profile(cls, prof: dict, **overrides) -> "Settings":
|
|
|
|
| 104 |
data, sr, meta = read_audio(path)
|
| 105 |
if data.shape[0] == 0:
|
| 106 |
raise ValueError("El fichero está vacío")
|
| 107 |
+
m = metrics.measure(data, sr, detector=prof.get("deteccion_voz"))
|
| 108 |
if with_dnsmos:
|
| 109 |
import dnsmos
|
| 110 |
|
|
|
|
| 114 |
return Analysis(path, None, error=f"{type(e).__name__}: {e}")
|
| 115 |
|
| 116 |
|
| 117 |
+
def _voice_rms(a: np.ndarray, sr: int, prof: dict) -> float:
|
| 118 |
+
mono = dsp.to_mono(a)[:, 0]
|
| 119 |
+
r = dsp.voice_region(mono, sr, prof.get("deteccion_voz"))
|
| 120 |
+
return dsp.rms_db(mono[r.start : r.end + 1]) if r.found else dsp.rms_db(mono)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _ceiling(prof: dict) -> float:
|
| 124 |
+
peak_max = (prof.get("niveles") or {}).get("pico_max_dbfs")
|
| 125 |
+
return (peak_max if peak_max is not None else -0.1) - 0.05
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _fix_volume(y: np.ndarray, sr: int, prof: dict, notes: list) -> np.ndarray:
|
| 129 |
+
"""Ganancia hasta el RMS de voz objetivo; el limitador solo actúa si algún pico pasa del techo."""
|
| 130 |
+
rv = (prof.get("niveles") or {}).get("rms_voz") or {}
|
| 131 |
+
target = rv.get("objetivo", (rv["min"] + rv["max"]) / 2)
|
| 132 |
+
total, lim = 0.0, None
|
| 133 |
+
for _ in range(4):
|
| 134 |
+
cur = _voice_rms(y, sr, prof)
|
| 135 |
+
if not math.isfinite(cur) or abs(target - cur) < 0.05:
|
| 136 |
+
break
|
| 137 |
+
y = y * np.float32(10 ** ((target - cur) / 20))
|
| 138 |
+
total += target - cur
|
| 139 |
+
y, lim = dsp.peak_limit(y, sr, _ceiling(prof))
|
| 140 |
+
notes.append(f"Volumen de la voz {total:+.1f} dB → {_voice_rms(y, sr, prof):.1f} dBFS (objetivo {target:.1f})".replace("-", "−"))
|
| 141 |
+
if lim and lim["muestras_afectadas"]:
|
| 142 |
+
notes.append(f"Picos suavizados para no pasar del límite ({lim['muestras_afectadas'] / sr * 1000:.0f} ms en total, "
|
| 143 |
+
f"como mucho {lim['reduccion_max_db']:.1f} dB)")
|
| 144 |
+
return y
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _fix_peaks(y: np.ndarray, sr: int, prof: dict, notes: list, clipped: bool) -> np.ndarray:
|
| 148 |
+
"""Solo baja los picos que pasan del techo; el resto del audio no cambia."""
|
| 149 |
+
y, info = dsp.peak_limit(y, sr, _ceiling(prof))
|
| 150 |
+
if info["muestras_afectadas"]:
|
| 151 |
+
notes.append(f"Picos suavizados para no pasar de {_ceiling(prof) + 0.05:.1f} dBFS "
|
| 152 |
+
f"({info['muestras_afectadas'] / sr * 1000:.0f} ms en total, como mucho "
|
| 153 |
+
f"{info['reduccion_max_db']:.1f} dB); el resto del audio no cambia".replace("-", "−"))
|
| 154 |
+
if clipped:
|
| 155 |
+
notes.append("Había muestras saturadas: se han bajado, pero si se oye distorsión conviene regrabar ese trozo")
|
| 156 |
+
return y
|
| 157 |
+
|
| 158 |
|
| 159 |
+
def _apply(data, sr, meta, prof, settings, fixes, att, expander, cache, progress):
|
| 160 |
+
"""Aplica al original solo las correcciones de `fixes`. Devuelve (audio, sr, notas)."""
|
| 161 |
+
fmt = prof.get("formato") or {}
|
| 162 |
+
notes, x = [], data
|
| 163 |
+
if "mono" in fixes and x.shape[1] > 1:
|
| 164 |
+
x = dsp.to_mono(x)
|
| 165 |
+
notes.append(f"{meta.channels} canales → mono")
|
| 166 |
+
if "frecuencia" in fixes and fmt.get("frecuencia") and fmt["frecuencia"] != sr:
|
| 167 |
+
x = dsp.resample(x, sr, fmt["frecuencia"])
|
| 168 |
+
notes.append(f"Frecuencia de muestreo cambiada de {sr} a {fmt['frecuencia']} Hz")
|
| 169 |
+
sr = fmt["frecuencia"]
|
| 170 |
+
if "ruido" in fixes:
|
| 171 |
+
key = (sr, x.shape[1], att)
|
| 172 |
+
if key not in cache:
|
| 173 |
+
def prog(f):
|
| 174 |
+
if progress:
|
| 175 |
+
progress(f, f"Reduciendo ruido ({'sin límite' if att is None else f'{att:.0f} dB'})")
|
| 176 |
|
| 177 |
+
xh = dsp.highpass(x, sr, settings.paso_alto_hz) if settings.paso_alto_hz else x
|
| 178 |
+
cache[key] = dsp.denoise(xh, sr, att, keep_ultrasonic=settings.conservar_ultrasonidos,
|
| 179 |
+
use_gpu=settings.usar_gpu, progress=prog)
|
| 180 |
+
x, info = cache[key]
|
| 181 |
+
if settings.paso_alto_hz:
|
| 182 |
+
notes.append(f"Retumbes y golpes graves quitados (por debajo de {settings.paso_alto_hz:.0f} Hz)")
|
| 183 |
+
notes.append(f"Ruido de fondo reducido ({'al máximo' if att is None else f'hasta {att:.0f} dB'})"
|
| 184 |
+
f" · procesado en {info['dispositivo']}")
|
| 185 |
+
notes += info["avisos"]
|
| 186 |
+
if expander:
|
| 187 |
+
x, ex = dsp.pause_expander(x, sr)
|
| 188 |
+
notes.append(f"Ruido que quedaba en las pausas bajado 15 dB más ({ex['pausas_pct']:.0f} % del audio son pausas)")
|
| 189 |
+
if "pausas" in fixes:
|
| 190 |
+
region = dsp.voice_region(dsp.to_mono(x)[:, 0], sr, prof.get("deteccion_voz"))
|
| 191 |
+
x, cut = dsp.shorten_pauses(x, sr, region, profiles.pause_max(prof))
|
| 192 |
+
if cut:
|
| 193 |
+
notes.append(f"Pausas largas acortadas a {profiles.pause_max(prof):.1f} s: {len(cut)} "
|
| 194 |
+
f"(duraban {', '.join(f'{c:.1f}' for c in cut)} s)")
|
| 195 |
+
sides = tuple(s for s in ("ini", "fin") if f"sil_{s}" in fixes)
|
| 196 |
+
sil = prof.get("silencios") or None
|
| 197 |
+
if sides and sil:
|
| 198 |
+
# Iterativo: el detector (sobre todo el del analizador v4) estima el ruido con los extremos del
|
| 199 |
+
# audio, así que al cambiarlos puede mover un poco dónde «empieza» o «acaba» la voz.
|
| 200 |
+
target = sil.get("objetivo_s", 0.6)
|
| 201 |
+
first = True
|
| 202 |
for _ in range(4):
|
| 203 |
+
region = dsp.voice_region(dsp.to_mono(x)[:, 0], sr, prof.get("deteccion_voz"))
|
| 204 |
+
if not region.found:
|
| 205 |
+
notes.append("No se detecta voz: no se pueden ajustar los silencios")
|
| 206 |
break
|
| 207 |
+
head, tail = region.start / sr, (x.shape[0] - 1 - region.end) / sr
|
| 208 |
+
pending = tuple(sd for sd in sides if abs((head if sd == "ini" else tail) - target) > 0.02)
|
| 209 |
+
if not pending:
|
| 210 |
break
|
| 211 |
+
x, sn = dsp.set_edge_silences(x, sr, region, target, sil.get("relleno", "tono_sala"), pending)
|
| 212 |
+
if first:
|
| 213 |
+
notes += sn
|
| 214 |
+
first = False
|
| 215 |
+
if "volumen" in fixes and (prof.get("niveles") or {}).get("rms_voz"):
|
| 216 |
+
x = _fix_volume(x, sr, prof, notes)
|
| 217 |
+
elif "picos" in fixes:
|
| 218 |
+
x = _fix_peaks(x, sr, prof, notes, clipped=bool((np.abs(data) >= 0.999).any()))
|
| 219 |
+
return x, sr, notes
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
|
| 222 |
def correct(path: str, prof: dict, settings: Settings, out_dir: Path, progress=None,
|
| 223 |
before: Analysis | None = None) -> Correction:
|
| 224 |
"""
|
| 225 |
+
Corrige `path` según `prof` con la corrección mínima. progress(fracción, texto) opcional.
|
| 226 |
`before`: análisis ya hecho del original (se reutiliza para no medir dos veces).
|
| 227 |
"""
|
| 228 |
if before is None:
|
|
|
|
| 233 |
return res
|
| 234 |
try:
|
| 235 |
data, sr, meta = read_audio(path)
|
|
|
|
| 236 |
notes = res.notes
|
|
|
|
| 237 |
if settings.dnsmos and "dnsmos" not in before.m:
|
| 238 |
import dnsmos
|
| 239 |
|
| 240 |
before.m["dnsmos"] = dnsmos.score(dsp.to_mono(data)[:, 0], sr)
|
| 241 |
before.rows = profiles.check(meta, before.m, prof)
|
| 242 |
|
| 243 |
+
fixes = _fixes_for(before.rows)
|
| 244 |
+
if settings.limpiar_siempre:
|
| 245 |
+
fixes.add("ruido")
|
| 246 |
+
if settings.forzar_mono and meta.channels > 1: # pedido expresamente aunque el perfil no lo exija
|
| 247 |
+
fixes.add("mono")
|
| 248 |
+
if settings.acortar_pausas and profiles.long_pauses(before.m, prof):
|
| 249 |
+
fixes.add("pausas")
|
| 250 |
+
failing = profiles.failed(before.rows)
|
| 251 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 252 |
+
|
| 253 |
+
if not fixes: # cumple: no se toca
|
| 254 |
+
out_path = out_dir / Path(path).name
|
| 255 |
+
if Path(path).resolve() != out_path.resolve():
|
| 256 |
+
import shutil
|
| 257 |
+
|
| 258 |
+
shutil.copyfile(path, out_path)
|
| 259 |
+
res.out_path, res.after = str(out_path), before
|
| 260 |
+
notes.append("Cumplía todo: no se ha tocado")
|
| 261 |
+
return res
|
| 262 |
+
|
| 263 |
+
notes.append(f"No cumplía: {', '.join(failing)} → se corrige solo eso" if failing
|
| 264 |
+
else "Cumplía todo; solo se aplica lo que has pedido en las opciones")
|
| 265 |
+
|
| 266 |
+
# Formato de escritura: el original salvo que falle el formato o la codificación.
|
| 267 |
+
salida = (prof.get("procesado") or {}).get("salida") or {}
|
| 268 |
+
if "formato" in fixes:
|
| 269 |
+
container, subtype, ext = output_format(meta, salida.get("contenedor"), salida.get("subtipo"))
|
| 270 |
+
else:
|
| 271 |
+
container, subtype, ext = output_format(meta, None, None)
|
| 272 |
+
|
| 273 |
+
# Escalones de reducción de ruido (solo se usan si hace falta quitar ruido).
|
| 274 |
start = settings.reduccion_db
|
| 275 |
+
if settings.escalar_reduccion and start is not None and "ruido" in fixes:
|
| 276 |
need = _needed_reduction(before.m, prof)
|
| 277 |
if need > start:
|
| 278 |
start = start + 12 if need <= start + 12 else None
|
| 279 |
+
ladder = [(start, False)]
|
|
|
|
|
|
|
| 280 |
if settings.escalar_reduccion:
|
| 281 |
if start is not None:
|
| 282 |
+
ladder += [(lv, False) for lv in (start + 12, None) if lv != start]
|
| 283 |
+
ladder.append((None, True))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
+
cache: dict = {}
|
| 286 |
+
step = 0
|
| 287 |
+
for _ in range(8):
|
| 288 |
+
att, expander = ladder[step]
|
| 289 |
+
y, out_sr, step_notes = _apply(data, sr, meta, prof, settings, fixes, att, expander, cache, progress)
|
| 290 |
+
m = metrics.measure(y, out_sr, with_lufs=False, detector=prof.get("deteccion_voz"))
|
| 291 |
+
rows = profiles.check(AudioMeta(meta.name, container, subtype, out_sr, y.shape[1], y.shape[0]), m, prof)
|
| 292 |
+
new = _fixes_for(rows) - fixes
|
| 293 |
+
noise_left = [r.parametro for r in rows if r.ok is False and r.parametro in NOISE_CHECKS]
|
| 294 |
+
if new:
|
| 295 |
+
notes.append("Al corregir aparecieron fallos nuevos ("
|
| 296 |
+
+ ", ".join(r.parametro for r in rows if r.ok is False and FIX_FOR.get(r.parametro) in new)
|
| 297 |
+
+ "): se corrigen también")
|
| 298 |
+
fixes |= new
|
| 299 |
+
continue
|
| 300 |
+
if noise_left and "ruido" in fixes and step < len(ladder) - 1:
|
| 301 |
+
step += 1
|
| 302 |
+
notes.append(f"Seguía sin cumplir «{', '.join(noise_left)}»: se insiste con más reducción de ruido")
|
| 303 |
+
continue
|
| 304 |
+
break
|
| 305 |
+
notes += step_notes
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
|
|
|
|
|
|
|
|
|
| 307 |
out_path = out_dir / (Path(meta.name).stem + ext)
|
| 308 |
+
write_audio(out_path, y, out_sr, container, subtype)
|
| 309 |
res.out_path = str(out_path)
|
| 310 |
res.after = analyze(str(out_path), prof, with_dnsmos=settings.dnsmos)
|
| 311 |
if res.after.meta:
|
profiles.py
CHANGED
|
@@ -26,6 +26,12 @@ class Check:
|
|
| 26 |
requisito: str
|
| 27 |
ok: bool | None
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def load_profiles(directory: Path = PROFILES_DIR) -> dict[str, dict]:
|
| 31 |
profiles = {}
|
|
@@ -101,6 +107,12 @@ def check(meta: AudioMeta, m: dict, prof: dict) -> list[Check]:
|
|
| 101 |
m["muestras_saturadas"] <= prof["saturacion_max_muestras"]))
|
| 102 |
|
| 103 |
# Informativo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
rows.append(Check("Ruido de fondo", fdb(m["ruido_fondo_dbfs"]), "informativo", None))
|
| 105 |
if not nz.get("snr_min_db"):
|
| 106 |
rows.append(Check("SNR", fdb(m["snr_db"], "dB"), "informativo", None))
|
|
@@ -116,6 +128,15 @@ def check(meta: AudioMeta, m: dict, prof: dict) -> list[Check]:
|
|
| 116 |
return rows
|
| 117 |
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
def passes(rows: list[Check]) -> bool:
|
| 120 |
return all(r.ok is not False for r in rows)
|
| 121 |
|
|
|
|
| 26 |
requisito: str
|
| 27 |
ok: bool | None
|
| 28 |
|
| 29 |
+
def __post_init__(self):
|
| 30 |
+
# Las medidas son floats de numpy y sus comparaciones dan numpy.bool_, que no es True/False:
|
| 31 |
+
# `numpy.False_ is False` es falso. Se normaliza aquí para que «cumple / no cumple» sea fiable.
|
| 32 |
+
if self.ok is not None:
|
| 33 |
+
self.ok = bool(self.ok)
|
| 34 |
+
|
| 35 |
|
| 36 |
def load_profiles(directory: Path = PROFILES_DIR) -> dict[str, dict]:
|
| 37 |
profiles = {}
|
|
|
|
| 107 |
m["muestras_saturadas"] <= prof["saturacion_max_muestras"]))
|
| 108 |
|
| 109 |
# Informativo
|
| 110 |
+
if "pausas_s" in m:
|
| 111 |
+
max_p = pause_max(prof)
|
| 112 |
+
largas = [p for p in m["pausas_s"] if p > max_p + 0.05]
|
| 113 |
+
rows.append(Check(f"Pausas largas (> {max_p:.1f} s)",
|
| 114 |
+
f"{len(largas)} · la más larga {max(largas):.1f} s" if largas else "ninguna",
|
| 115 |
+
"informativo (se pueden acortar)", None))
|
| 116 |
rows.append(Check("Ruido de fondo", fdb(m["ruido_fondo_dbfs"]), "informativo", None))
|
| 117 |
if not nz.get("snr_min_db"):
|
| 118 |
rows.append(Check("SNR", fdb(m["snr_db"], "dB"), "informativo", None))
|
|
|
|
| 128 |
return rows
|
| 129 |
|
| 130 |
|
| 131 |
+
def pause_max(prof: dict) -> float:
|
| 132 |
+
"""Pausa interna máxima para informar y acortar: la elegida en la app o la del perfil (2 s por defecto)."""
|
| 133 |
+
return float(prof.get("_pausa_max_s") or (prof.get("pausas") or {}).get("max_s") or 2.0)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def long_pauses(m: dict, prof: dict) -> list[float]:
|
| 137 |
+
return [p for p in m.get("pausas_s", []) if p > pause_max(prof) + 0.05]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
def passes(rows: list[Check]) -> bool:
|
| 141 |
return all(r.ok is not False for r in rows)
|
| 142 |
|