File size: 20,795 Bytes
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc527e7
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a957eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a957eb
 
 
 
 
7e8e229
 
 
 
 
 
 
 
7a957eb
 
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
7a957eb
 
 
 
 
 
fc527e7
7e8e229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6a2c55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d903372
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
"""
dsp.py — Procesado de señal: remuestreo, filtros, detección de voz, silencios y nivel.

Convención: el audio va como float32 con forma (muestras, canales).
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import soxr
from numpy.lib.stride_tricks import sliding_window_view
from scipy import signal
from scipy.ndimage import minimum_filter1d

FRAME_MS = 20
DIGITAL_SILENCE_DB = -140.0


def db(x: float) -> float:
    return 20 * np.log10(x) if x > 0 else -np.inf


def to_mono(x: np.ndarray) -> np.ndarray:
    return x.mean(axis=1, keepdims=True) if x.shape[1] > 1 else x


def resample(x: np.ndarray, sr_in: int, sr_out: int) -> np.ndarray:
    """Remuestreo de muy alta calidad (soxr VHQ, fase lineal, sin desfase)."""
    if sr_in == sr_out:
        return x
    y = soxr.resample(x, sr_in, sr_out, quality="VHQ")
    return np.asarray(y, dtype=np.float32).reshape(-1, x.shape[1])


def highpass(x: np.ndarray, sr: int, fc: float | None) -> np.ndarray:
    """Paso alto Butterworth de fase cero (−24 dB/oct efectivos). Quita retumbes, golpes graves y DC."""
    if not fc:
        return x
    sos = signal.butter(2, fc, btype="highpass", fs=sr, output="sos")
    return signal.sosfiltfilt(sos, x, axis=0).astype(np.float32)


# ── Reducción de ruido a banda completa ──────────────────────────────────────

HF_SPLIT_HZ = 20000.0


def _crossover_lowpass(x: np.ndarray, sr: int) -> np.ndarray:
    """Paso bajo FIR de fase lineal en HF_SPLIT_HZ (transición ±1 kHz); complementario exacto con x − LP(x)."""
    taps = signal.firwin(1023, HF_SPLIT_HZ, width=2000, fs=sr, window=("kaiser", 10.0))
    return signal.oaconvolve(x, taps[:, None], mode="same", axes=0).astype(np.float32)


def _hf_gain_track(x48: np.ndarray, y48: np.ndarray, n_out: int, sr_out: int, floor: float) -> np.ndarray:
    """
    Ganancia temporal para la banda > 20 kHz: cuánto ha bajado la energía de 12–20 kHz con
    DeepFilterNet en cada instante. Así los ultrasonidos se atenúan igual que el ruido audible
    y se conservan donde hay voz (sibilantes, aire).
    """
    hop = 480  # 10 ms a 48 kHz
    sos = signal.butter(4, [12000, 20000], btype="bandpass", fs=48000, output="sos")
    bx = signal.sosfiltfilt(sos, x48.mean(axis=1))
    by = signal.sosfiltfilt(sos, y48.mean(axis=1))
    nfr = len(bx) // hop
    if nfr == 0:
        return np.ones(n_out, dtype=np.float32)
    ex = (bx[: nfr * hop].reshape(nfr, hop) ** 2).mean(axis=1)
    ey = (by[: nfr * hop].reshape(nfr, hop) ** 2).mean(axis=1)
    g = np.sqrt(np.clip(ey / np.maximum(ex, 1e-20), 0, 1))
    g = signal.filtfilt(np.ones(5) / 5, [1.0], g) if nfr > 15 else g  # suaviza ±25 ms
    g = np.clip(g, floor, 1.0)
    t_fr = (np.arange(nfr) + 0.5) * hop / 48000
    t_out = np.arange(n_out) / sr_out
    return np.interp(t_out, t_fr, g).astype(np.float32)


def denoise(x: np.ndarray, sr: int, atten_db: float | None, keep_ultrasonic: bool = True,
            use_gpu: bool = True, progress=None) -> tuple[np.ndarray, dict]:
    """
    Reduce el ruido conservando la frecuencia de muestreo original.
    Si sr > 48 kHz y keep_ultrasonic, la banda > 20 kHz no pasa por el modelo (que llega a 24 kHz):
    se conserva del original con una ganancia que sigue la reducción aplicada a 12–20 kHz.
    """
    from denoise_core import DF_SR, enhance_48k

    x48 = resample(x, sr, DF_SR)
    y48, info = enhance_48k(x48.T, atten_db, use_gpu=use_gpu, progress=progress)
    y48 = y48.T
    y = resample(y48, DF_SR, sr)
    n = x.shape[0]
    y = y[:n] if y.shape[0] >= n else np.pad(y, ((0, n - y.shape[0]), (0, 0)))

    if keep_ultrasonic and sr > DF_SR:
        floor = 10 ** (-atten_db / 20) if atten_db else 0.0
        high = x - _crossover_lowpass(x, sr)
        g = _hf_gain_track(x48, y48, n, sr, floor)
        y = _crossover_lowpass(y, sr) + high * g[:, None]
        info["avisos"].append("Agudos extremos (más de 20 kHz) conservados del original")
    return y.astype(np.float32), info


# ── Detección de voz y silencios ─────────────────────────────────────────────

@dataclass
class VoiceRegion:
    start: int              # primera muestra con voz
    end: int                # última muestra con voz
    noise_db: float         # nivel estimado de ruido de fondo por trama
    start_th_db: float
    frame_db: np.ndarray    # nivel por trama (20 ms)
    frame_len: int
    found: bool


def frame_levels(mono: np.ndarray, sr: int) -> tuple[np.ndarray, int]:
    flen = max(64, int(sr * FRAME_MS / 1000))
    nfr = max(1, int(np.ceil(len(mono) / flen)))
    padded = np.pad(mono.astype(np.float64), (0, nfr * flen - len(mono)))
    p = (padded.reshape(nfr, flen) ** 2).mean(axis=1)
    return 10 * np.log10(np.maximum(p, 1e-30)), flen


def detect_voice(mono: np.ndarray, sr: int, start_above: float = 14.0, end_above: float = 10.0,
                 min_ms: int = 120) -> VoiceRegion:
    """
    Busca el primer y el último tramo con voz sostenida (≥ min_ms por encima del umbral).
    Un clic o un golpe aislado, por cortos, no cuentan como voz.
    El ruido de fondo se estima con el percentil 10 de las tramas no digitales.
    """
    fdb, flen = frame_levels(mono, sr)
    live = fdb[fdb > DIGITAL_SILENCE_DB]
    if live.size == 0:
        return VoiceRegion(0, len(mono) - 1, -np.inf, -np.inf, fdb, flen, False)
    noise = float(np.percentile(live, 10))
    loud = float(np.percentile(live, 95))
    start_th = max(noise + start_above, loud - 45)
    end_th = max(noise + end_above, loud - 50)
    k = max(2, int(round(min_ms / FRAME_MS)))
    if len(fdb) < k:
        return VoiceRegion(0, len(mono) - 1, noise, start_th, fdb, flen, False)
    win_min = sliding_window_view(fdb, k).min(axis=1)
    starts = np.flatnonzero(win_min >= start_th)
    if starts.size == 0:
        return VoiceRegion(0, len(mono) - 1, noise, start_th, fdb, flen, False)
    first = int(starts[0])
    ends = np.flatnonzero(win_min[first:] >= end_th)
    last = first + int(ends[-1]) + k - 1
    start = first * flen
    end = min(len(mono) - 1, (last + 1) * flen - 1)
    return VoiceRegion(start, end, noise, start_th, fdb, flen, True)


def detect_voice_v4(mono: np.ndarray, sr: int) -> VoiceRegion:
    """
    Réplica exacta de detectVoiceRegion() del «Analizador de audio v4» (HTML):
    ruido = media en dB de las tramas de los primeros y últimos 0,4 s; inicio = primeras 6 tramas
    seguidas ≥ ruido + 14 dB; final = últimas 6 tramas seguidas ≥ ruido + 10 dB.
    Así lo que mide y corrige la app coincide con lo que mide esa herramienta.
    """
    flen = max(64, round(sr * FRAME_MS / 1000))
    nfr = max(1, int(np.ceil(len(mono) / flen)))
    x = np.asarray(mono, dtype=np.float64)
    padded = np.pad(x, (0, nfr * flen - len(x)))
    sums = (padded.reshape(nfr, flen) ** 2).sum(axis=1)
    counts = np.full(nfr, flen, dtype=np.float64)
    counts[-1] = len(x) - (nfr - 1) * flen  # la última trama puede ser más corta (como en el HTML)
    with np.errstate(divide="ignore"):
        fdb = 10 * np.log10(sums / counts)
    edge = max(3, int(0.4 * sr / flen))
    vals = np.concatenate([fdb[: min(edge, nfr)], fdb[max(0, nfr - edge) :]])
    vals = vals[np.isfinite(vals)]
    noise = float(vals.mean()) if vals.size else -90.0
    start_th, end_th = noise + 14, noise + 10
    k = max(2, round(120 / FRAME_MS))
    fdb_safe = np.where(np.isfinite(fdb), fdb, -1e9)
    if nfr < k:
        return VoiceRegion(0, len(mono) - 1, noise, start_th, fdb_safe, flen, False)
    win_min = sliding_window_view(fdb_safe, k).min(axis=1)
    starts = np.flatnonzero(win_min >= start_th)
    if starts.size == 0:
        return VoiceRegion(0, len(mono) - 1, noise, start_th, fdb_safe, flen, False)
    first = int(starts[0])
    ends = np.flatnonzero(win_min[first:] >= end_th)
    last = first + int(ends[-1]) + k - 1 if ends.size else nfr - 1
    return VoiceRegion(first * flen, min(len(mono) - 1, (last + 1) * flen - 1), noise, start_th, fdb_safe, flen, True)


def voice_region(mono: np.ndarray, sr: int, method: str | None = None) -> VoiceRegion:
    """Detector de voz según el perfil: «analizador_v4» (compatible con el HTML) o el propio por defecto."""
    return detect_voice_v4(mono, sr) if method == "analizador_v4" else detect_voice(mono, sr)


def find_pauses(mono: np.ndarray, sr: int, region: VoiceRegion, min_s: float = 0.3) -> list[tuple[int, int]]:
    """
    Pausas internas (entre la primera y la última voz) de al menos `min_s`, en muestras [ini, fin).
    Una trama es pausa si queda por debajo del umbral de «fin de voz» (ruido + 10 dB).
    """
    if not region.found:
        return []
    flen, fdb = region.frame_len, region.frame_db
    quiet = fdb < max(region.start_th_db - 4, DIGITAL_SILENCE_DB)
    f0, f1 = region.start // flen, region.end // flen
    pauses, run = [], None
    for f in range(f0, f1 + 1):
        if quiet[f] and run is None:
            run = f
        elif not quiet[f] and run is not None:
            if (f - run) * flen >= min_s * sr:
                pauses.append((run * flen, f * flen))
            run = None
    return pauses


def shorten_pauses(x: np.ndarray, sr: int, region: VoiceRegion, max_s: float) -> tuple[np.ndarray, list[float]]:
    """
    Acorta a `max_s` las pausas internas más largas. Conserva la mitad de `max_s` pegada a cada lado
    (respiraciones y finales de palabra intactos), quita el centro y une con un fundido de 30 ms.
    Devuelve (audio, duraciones originales de las pausas acortadas).
    """
    mono = to_mono(x)[:, 0]
    long_ones = [(a, b) for a, b in find_pauses(mono, sr, region) if (b - a) > max_s * sr + 0.05 * sr]
    if not long_ones:
        return x, []
    half = int(max_s * sr / 2)
    xf = min(int(0.03 * sr), half // 2)
    fade = np.sin(np.linspace(0, np.pi / 2, xf, dtype=np.float32))[:, None] if xf else None
    pieces, pos = [], 0
    for a, b in long_ones:
        cut_a, cut_b = a + half, b - half - xf  # se elimina [cut_a, cut_b); el fundido solapa xf muestras
        left = x[pos:cut_a].copy()
        right_start = cut_b
        if xf:
            # fundido de potencia constante entre el final del tramo izquierdo y el inicio del derecho
            tail = left[-xf:] * fade[::-1]
            head = x[right_start : right_start + xf] * fade
            left[-xf:] = tail + head
            right_start += xf
        pieces.append(left)
        pos = right_start
    pieces.append(x[pos:])
    return np.concatenate(pieces).astype(np.float32), [(b - a) / sr for a, b in long_ones]


def _room_tone(x: np.ndarray, region: VoiceRegion, length: int, sr: int) -> np.ndarray:
    """
    Construye `length` muestras de "tono de sala" a partir del tramo sin voz más silencioso
    de la propia grabación, encadenando trozos con fundidos de potencia constante.
    """
    flen = region.frame_len
    seg_frames = max(5, int(0.25 * sr / flen))  # 250 ms
    fdb = region.frame_db
    if len(fdb) <= seg_frames:
        return np.zeros((length, x.shape[1]), dtype=np.float32)
    # trama inicial del tramo de 250 ms con menos energía (y sin silencio digital)
    win_mean = sliding_window_view(fdb, seg_frames).mean(axis=1)
    win_mean[sliding_window_view(fdb, seg_frames).min(axis=1) <= DIGITAL_SILENCE_DB] = np.inf
    if not np.isfinite(win_mean).any():
        return np.zeros((length, x.shape[1]), dtype=np.float32)
    i = int(np.argmin(win_mean))
    piece = x[i * flen : (i + seg_frames) * flen]
    xf = min(len(piece) // 4, int(0.02 * sr))
    fade_in = np.sin(np.linspace(0, np.pi / 2, xf))[:, None] if xf else None
    out = np.zeros((length + len(piece), x.shape[1]), dtype=np.float32)
    pos, flip = 0, False
    while pos < length:
        p = piece[::-1] if flip else piece  # alternar sentido evita repeticiones evidentes
        if pos and xf:
            out[pos - xf : pos] *= fade_in[::-1]
            p = p.copy()
            p[:xf] *= fade_in
            out[pos - xf : pos - xf + len(p)] += p
            pos += len(p) - xf
        else:
            out[pos : pos + len(p)] = p
            pos += len(p)
        flip = not flip
    return out[:length]


def set_edge_silences(x: np.ndarray, sr: int, region: VoiceRegion, target_s: float,
                      fill: str = "tono_sala", sides=("ini", "fin")) -> tuple[np.ndarray, list[str]]:
    """
    Deja exactamente `target_s` antes de la primera voz («ini») y/o después de la última («fin»).
    Solo toca los lados indicados en `sides`; el resto queda idéntico.
    """
    notes = []
    tgt = int(round(target_s * sr))
    head, tail = region.start, x.shape[0] - 1 - region.end
    core = x[region.start : region.end + 1]
    filler = (lambda n: _room_tone(x, region, n, sr)) if fill == "tono_sala" else \
             (lambda n: np.zeros((n, x.shape[1]), dtype=np.float32))

    def edge(seg_len, side):
        if side not in sides:  # lado que ya cumple: intacto
            return x[: region.start] if side == "ini" else x[region.end + 1 :]
        if seg_len >= tgt:  # recortar (se conserva el tramo pegado a la voz)
            return x[region.start - tgt : region.start] if side == "ini" else x[region.end + 1 : region.end + 1 + tgt]
        extra = filler(tgt - seg_len)
        if side == "ini":
            return np.concatenate([extra, x[: region.start]])
        return np.concatenate([x[region.end + 1 :], extra])

    pre, post = edge(head, "ini"), edge(tail, "fin")
    out = np.concatenate([pre, core, post]).astype(np.float32)
    # fundidos muy cortos en los extremos del fichero (evitan clics al cortar)
    f = min(int(0.005 * sr), len(out) // 2)
    if f:
        ramp = np.linspace(0, 1, f, dtype=np.float32)[:, None]
        if "ini" in sides:
            out[:f] *= ramp
        if "fin" in sides:
            out[-f:] *= ramp[::-1]
    for nombre, antes, side in (("inicial", head, "ini"), ("final", tail, "fin")):
        if side in sides and abs(antes - tgt) > sr * 0.001:
            accion = "recortado" if antes > tgt else ("alargado con el silencio natural de la sala" if fill == "tono_sala" else "alargado con silencio")
            notes.append(f"Silencio {nombre}: {antes / sr:.2f} s → {target_s:.2f} s ({accion})")
    return out, notes


def pause_expander(x: np.ndarray, sr: int, range_db: float = 15.0) -> tuple[np.ndarray, dict]:
    """
    Expansor suave: baja `range_db` el ruido residual en las pausas sin tocar la voz.
    La zona de voz se ensancha 100 ms antes y 200 ms después (no se comen finales de palabra
    ni respiraciones) y la ganancia se suaviza ~100 ms para que no haya «bombeo».
    """
    mono = to_mono(x)[:, 0]
    fdb, flen = frame_levels(mono, sr)
    live = fdb[fdb > DIGITAL_SILENCE_DB]
    if live.size == 0:
        return x, {"pausas_pct": 0.0}
    noise = float(np.percentile(live, 10))
    loud = float(np.percentile(live, 95))
    core = fdb >= max(noise + 10, loud - 50)
    voice = core.copy()
    for s in range(1, int(100 / FRAME_MS) + 1):   # 100 ms antes de cada trama con voz
        voice[:-s] |= core[s:]
    for s in range(1, int(200 / FRAME_MS) + 1):   # 200 ms después
        voice[s:] |= core[:-s]
    g = np.where(voice, 1.0, 10 ** (-range_db / 20))
    k = np.hanning(7)[1:-1]
    g = np.convolve(np.pad(g, 2, mode="edge"), k / k.sum(), mode="valid")
    g = np.maximum(g, np.where(voice, 1.0, 0.0))  # la voz nunca se atenúa
    t_fr = (np.arange(len(g)) + 0.5) * flen
    gs = np.interp(np.arange(x.shape[0]), t_fr, g).astype(np.float32)
    return (x * gs[:, None]).astype(np.float32), {"pausas_pct": float(100 * (~voice).mean())}


# ── Nivel ────────────────────────────────────────────────────────────────────

def peak_limit(x: np.ndarray, sr: int, ceiling_db: float, window_ms: float = 5.0) -> tuple[np.ndarray, dict]:
    """
    Limitador de picos sin sobreoscilaciones (offline, con anticipación):
    ganancia = mínimo deslizante de (techo / |x|) suavizado con una ventana del mismo ancho.
    Garantiza |salida| ≤ techo. Solo actúa alrededor de los picos que superan el techo.
    """
    ceiling = 10 ** (ceiling_db / 20)
    peak = np.abs(x).max(axis=1)
    if peak.max() <= ceiling:
        return x, {"muestras_afectadas": 0, "reduccion_max_db": 0.0}
    w = max(1, int(sr * window_ms / 1000))
    g = np.minimum(1.0, ceiling / np.maximum(peak, 1e-12))
    g = minimum_filter1d(g, size=2 * w + 1, mode="nearest")
    win = np.hanning(2 * w + 3)[1:-1]
    g = signal.oaconvolve(np.pad(g, w, mode="edge"), win / win.sum(), mode="valid")
    y = (x * g[:, None]).astype(np.float32)
    # seguridad numérica: nunca por encima del techo
    over = np.abs(y) > ceiling
    if over.any():
        y[over] = np.sign(y[over]) * ceiling
    return y, {"muestras_afectadas": int((g < 0.999).sum()), "reduccion_max_db": float(-db(g.min()))}


def rms_db(x: np.ndarray) -> float:
    return db(float(np.sqrt(np.mean(np.asarray(x, dtype=np.float64) ** 2)))) if x.size else -np.inf


# ── Ruidos sueltos en las pausas (cláxones, golpes, puertas…) ────────────────

EVENT_ABOVE_DB = 12.0    # un ruido suelto destaca al menos 12 dB sobre el ruido de fondo
EVENT_GUARD_S = 0.25     # no se toca nada a menos de 250 ms de la voz (inicios y finales de palabra)
EVENT_LOUD_DB = 15.0     # y llega a menos de 15 dB del volumen de la voz (respiraciones y ruiditos se quedan)
EVENT_MAX_S = 8.0        # más largo que esto no es un «ruido suelto» (música, obra…): se deja


def remove_events(x: np.ndarray, sr: int, speech_prob: np.ndarray, hop_s: float) -> tuple[np.ndarray, list]:
    """
    Sustituye por tono de sala los ruidos fuertes que caen en las pausas (donde el detector neuronal
    dice que NO hay voz, el nivel sube ≥ 12 dB sobre el fondo y llega cerca del volumen de la voz).
    La voz y sus alrededores no se tocan; las respiraciones y ruiditos suaves, tampoco. Si el detector no
    encuentra voz en todo el audio (risas, llantos…), no se toca nada.
    speech_prob: probabilidad de voz por ventana de `hop_s` segundos. Devuelve (audio, [(inicio_s, dur_s, dB)]).
    """
    mono = to_mono(x)[:, 0]
    hop = hop_s * sr
    n = min(len(speech_prob), int(len(mono) / hop))
    if n < 10:
        return x, []
    idx = (np.arange(n + 1) * hop).astype(int)
    lvl = np.array([rms_db(mono[idx[i] : idx[i + 1]]) for i in range(n)])
    live = lvl[lvl > DIGITAL_SILENCE_DB]
    if live.size < 10:
        return x, []
    floor = float(np.percentile(live, 10))
    p = speech_prob[:n]
    speech = p > 0.4
    if speech.sum() < 10:
        return x, []
    loud = float(np.median(lvl[p > 0.5])) - EVENT_LOUD_DB if (p > 0.5).any() else float(np.median(lvl[speech])) - EVENT_LOUD_DB
    g = max(1, int(round(EVENT_GUARD_S / hop_s)))
    near = np.convolve(speech.astype(int), np.ones(2 * g + 1, dtype=int), mode="same") > 0
    cand = (p < 0.1) & (lvl > floor + EVENT_ABOVE_DB) & ~near
    # ensanchar 2 ventanas (colas del ruido) sin entrar en la zona protegida
    cand = (np.convolve(cand.astype(int), np.ones(5, dtype=int), mode="same") > 0) & ~near
    events, i = [], 0
    while i < n:
        if not cand[i]:
            i += 1
            continue
        j = i
        while j + 1 < n and (cand[j + 1] or (j + 3 < n and cand[j + 2:j + 4].any())):
            j += 1
        if (j + 1 - i) * hop_s <= EVENT_MAX_S and lvl[i : j + 1].max() >= loud:
            events.append((i, j + 1))
        i = j + 1
    if not events:
        return x, []
    region = voice_region(mono, sr)
    y = x.copy()
    xf = int(0.015 * sr)
    out = []
    for a, b in events:
        s0, s1 = idx[a], idx[min(b, n)]
        tone = _room_tone(x, region, s1 - s0, sr)
        w = np.ones(s1 - s0, dtype=np.float32)
        k = min(xf, (s1 - s0) // 2)
        if k:
            ramp = np.sin(np.linspace(0, np.pi / 2, k)) ** 2
            w[:k], w[-k:] = ramp, ramp[::-1]
        y[s0:s1] = x[s0:s1] * (1 - w[:, None]) + tone[: s1 - s0] * w[:, None]
        out.append((s0 / sr, (s1 - s0) / sr, float(lvl[a:b].max() - floor)))
    return y.astype(np.float32), out


def remove_hum(x: np.ndarray, sr: int, harmonics: list[float], q: float = 35.0) -> np.ndarray:
    """Filtros de muesca muy estrechos (Q 35: ~1,5 Hz a 50 Hz) en cada armónico del zumbido, sin desfase."""
    y = x.astype(np.float64)
    for f0 in harmonics:
        if f0 < sr / 2 - 50:
            b, a = signal.iirnotch(f0, q, fs=sr)
            y = signal.filtfilt(b, a, y, axis=0)
    return y.astype(np.float32)