Azuredraco commited on
Commit
7a957eb
·
verified ·
1 Parent(s): fc527e7

Despliegue desde GitHub

Browse files
Files changed (7) hide show
  1. app.py +45 -30
  2. dsp.py +104 -6
  3. metrics.py +3 -2
  4. perfiles/analizador_v4.yaml +1 -0
  5. perfiles/multiling_es-es_01.yaml +15 -8
  6. pipeline.py +179 -97
  7. 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 cb_analyze(files, pid, progress=gr.Progress()):
 
 
 
 
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 = PROFILES[pid]
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
- yield _summary(prof, len(paths), n_ok, n_fix, n_err, durs, "Por corregir"), "".join(reversed(cards))
 
 
 
 
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 = PROFILES[pid]
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
- if ok:
 
 
 
 
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, f"{r.before.meta.describe()} → {r.after.meta.describe()}", badge,
211
- r.before.rows, r.after.rows, notes=r.notes, download=_file_url(r.out_path)))
 
 
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
- - Los audios que ya cumplían se incluyen tal cual, sin tocarlos.
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
- - **Limpiar también los audios que ya cumplen**: por si quieres pasarlos todos por el limpiador.
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="Limpiar también los audios que ya cumplen")
 
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
- """Deja exactamente `target_s` antes de la primera voz y después de la última."""
 
 
 
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
- out[:f] *= ramp
216
- out[-f:] *= ramp[::-1]
217
- for nombre, antes in (("inicial", head), ("final", tail)):
218
- if abs(antes - tgt) > sr * 0.001:
 
 
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.detect_voice(mono, sr)
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 · ≥ 24 bits · SNR ≥ 50 dB · sin ruido de fondo"
5
  explicacion: |
6
  Encargo **MultiLing es-ES 01** (voz femenina, 40 minutos en total).
7
- - **Qué pide el cliente:** WAV, 48 000 Hz, 24 bits, voz limpia y sin ruido de fondo.
8
- - **Qué hace la corrección:** quita el ruido y deja el formato correcto. No recorta el principio ni el final ni cambia el volumen (solo suaviza algún pico que pudiera saturar), porque el cliente quiere la grabación entera, sin editar.
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
- bits_min: 24 # «Bit depth: 24-bit or higher»
16
- frecuencia: 48000 # El PDF dice «48.1 kHz»: se asume errata de 48 kHz (estándar). Cámbialo si el cliente confirma otra cosa.
17
- canales: null # El PDF no lo exige: se conservan los canales de la entrada (casilla «Convertir a mono» en la app).
18
 
19
  niveles:
20
- pico_max_dbfs: -1.0 # No lo exige el PDF; margen de seguridad para no saturar.
 
21
 
22
  ruido:
23
  snr_min_db: 50 # «Signal-to-Noise Ratio (SNR): 50 dB or higher»
 
24
 
25
- silencios: null # El PDF pide audio sin cortar: no se tocan inicio ni final.
 
 
 
 
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
- Cadena de corrección (en este orden):
5
- 1. Canales: se conservan los de la entrada salvo que se pida mono.
6
- 2. Paso alto: quita retumbes, golpes graves y offset de continua.
7
- 3. Frecuencia de muestreo: remuestreo de alta calidad si hace falta.
8
- 4. Reducción de ruido (DeepFilterNet3). Si el resultado no cumple el ruido exigido,
9
- se repite con más reducción (escalonado) antes de darse por vencido.
10
- 5. Silencios inicial/final: recorte o relleno con tono de sala.
11
- 6. Nivel: ganancia hasta el RMS objetivo y limitador de picos transparente.
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 _level(y: np.ndarray, sr: int, prof: dict, notes: list) -> np.ndarray:
105
- lvl = prof.get("niveles") or {}
106
- peak_max = lvl.get("pico_max_dbfs")
107
- ceiling = (peak_max if peak_max is not None else -0.1) - 0.05
108
- rv = lvl.get("rms_voz") or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
- def voice_rms(a):
111
- r = dsp.detect_voice(dsp.to_mono(a)[:, 0], sr)
112
- return dsp.rms_db(dsp.to_mono(a)[r.start : r.end + 1, 0]) if r.found else dsp.rms_db(a)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
- if rv:
115
- target = rv.get("objetivo", (rv["min"] + rv["max"]) / 2)
116
- total_gain, lim_info = 0.0, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  for _ in range(4):
118
- cur = voice_rms(y)
119
- if not math.isfinite(cur):
 
120
  break
121
- gain = target - cur
122
- if abs(gain) < 0.05:
 
123
  break
124
- y = y * np.float32(10 ** (gain / 20))
125
- total_gain += gain
126
- y, lim_info = dsp.peak_limit(y, sr, ceiling)
127
- notes.append(f"Volumen de la voz ajustado {total_gain:+.1f} dB → {voice_rms(y):.1f} dBFS (objetivo {target:.1f})".replace("-", "−"))
128
- if lim_info and lim_info["muestras_afectadas"]:
129
- notes.append(f"Picos suavizados para no pasar del límite ({lim_info['muestras_afectadas'] / sr * 1000:.0f} ms en total, "
130
- f"como mucho {lim_info['reduccion_max_db']:.1f} dB)")
131
- else:
132
- y, info = dsp.peak_limit(y, sr, ceiling)
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
- if settings.forzar_mono and x.shape[1] > 1:
162
- x = dsp.to_mono(x)
163
- notes.append(f"{meta.channels} canales → mono")
164
- if settings.paso_alto_hz:
165
- x = dsp.highpass(x, sr, settings.paso_alto_hz)
166
- notes.append(f"Retumbes y golpes graves quitados (por debajo de {settings.paso_alto_hz:.0f} Hz)")
167
- target_sr = fmt.get("frecuencia") or sr
168
- if target_sr != sr:
169
- x = dsp.resample(x, sr, target_sr)
170
- notes.append(f"Frecuencia de muestreo cambiada de {sr} a {target_sr} Hz")
171
- sr = target_sr
172
-
173
- # Escalado si no se cumple el ruido: más reducción y, como último recurso, expansor en pausas.
174
- # Si el original ya tiene mucho más ruido del permitido, se empieza directamente por el escalón
175
- # necesario (evita pasar el modelo varias veces).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- notes.append("El audio tenía bastante ruido: se ha empezado con una reducción "
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
- steps += [(lv, False) for lv in (start + 12, None) if lv != start]
187
- steps.append((None, True))
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
- if att not in cache:
197
- cache[att] = dsp.denoise(x, sr, att, keep_ultrasonic=settings.conservar_ultrasonidos,
198
- use_gpu=settings.usar_gpu, progress=prog)
199
- y, info = cache[att]
200
- step_notes = [f"Ruido de fondo reducido ({'al máximo' if att is None else f'hasta {att:.0f} dB'})"
201
- f" · procesado en {info['dispositivo']}"] + info["avisos"]
202
- if expander:
203
- y, ex = dsp.pause_expander(y, sr)
204
- step_notes.append(f"Ruido que quedaba en las pausas bajado 15 dB más ({ex['pausas_pct']:.0f} % del audio son pausas)")
205
- if sil:
206
- region = dsp.detect_voice(dsp.to_mono(y)[:, 0], sr)
207
- if region.found:
208
- y, sn = dsp.set_edge_silences(y, sr, region, sil.get("objetivo_s", 0.6), sil.get("relleno", "tono_sala"))
209
- step_notes += sn
210
- else:
211
- step_notes.append("No se detecta voz: no se ajustan los silencios")
212
- y = _level(y, sr, prof, step_notes)
213
- m = metrics.measure(y, sr, with_lufs=False)
214
- rows = profiles.check(AudioMeta(meta.name, "WAV", "FLOAT", sr, y.shape[1], y.shape[0]), m, prof)
215
- noise_fail = [r.parametro for r in rows if r.ok is False and r.parametro in NOISE_CHECKS]
216
- if not noise_fail or i == len(steps) - 1:
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, sr, container, subtype)
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