""" 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)