""" denoise_core.py — Motor de reducción de ruido (DeepFilterNet3) con ZeroGPU y respaldo en CPU. - El modelo trabaja a 48 kHz. Quien llama se encarga de remuestrear (ver dsp.py). - El audio se procesa por bloques con contexto y fundido cruzado, para que cada llamada a la GPU sea corta y la memoria no dependa de la duración del fichero. - Si la GPU no está disponible (cuota agotada, cola, error), se usa la CPU. """ from __future__ import annotations import copy import sys import time import types import warnings try: import spaces # en ZeroGPU debe importarse antes que torch except ImportError: # versión de escritorio: sin ZeroGPU spaces = None # Shim de compatibilidad: deepfilternet 0.5.6 importa torchaudio.backend.common (solo para df.io, # que no usamos). Versiones recientes de torchaudio lo han eliminado: si falta, se crea uno mínimo. try: with warnings.catch_warnings(): warnings.simplefilter("ignore") import torchaudio.backend.common # noqa: F401 except ImportError: from dataclasses import dataclass @dataclass class AudioMetaData: sample_rate: int num_frames: int num_channels: int bits_per_sample: int encoding: str _common = types.ModuleType("torchaudio.backend.common") _common.AudioMetaData = AudioMetaData _backend = types.ModuleType("torchaudio.backend") _backend.common = _common sys.modules["torchaudio.backend"] = _backend sys.modules["torchaudio.backend.common"] = _common import numpy as np import torch from torch.nn import functional as F with warnings.catch_warnings(): warnings.simplefilter("ignore") # avisos de deprecación de torchaudio dentro de deepfilternet from df.enhance import df_features, init_df from df.model import ModelParams from df.utils import as_complex from models import dfn3_dir DF_SR = 48000 CHUNK_SEC = 60.0 # bloque útil por inferencia CONTEXT_SEC = 5.0 # contexto extra a cada lado para que el modelo "se asiente" XFADE_SEC = 0.05 # fundido entre bloques GROUP_SEC = 300.0 # audio máximo por llamada a la GPU GPU_MIN_SEC = 90.0 # por debajo, la espera de ZeroGPU (cola, asignación) cuesta más que hacerlo en CPU GPU_COOLDOWN_SEC = 900.0 # tras un fallo de GPU (p. ej. cuota) no se reintenta en este tiempo ON_ZEROGPU = bool(spaces is not None and spaces.config.Config.zero_gpu) _gpu_blocked_until = 0.0 def _force_df_device(device: str) -> None: """DeepFilterNet decide el dispositivo con df.utils.get_device(); lo fijamos nosotros.""" dev = torch.device(device) for name, mod in list(sys.modules.items()): if (name == "df" or name.startswith("df.")) and hasattr(mod, "get_device"): mod.get_device = lambda dev=dev: dev def _disable_df_git() -> None: """DeepFilterNet consulta git al iniciar su log; sin git instalado (Windows) eso rompe el arranque.""" import df.logger import df.utils for mod in (df.utils, df.logger): mod.get_commit_hash = lambda: None mod.get_branch_name = lambda: None print("[MODELO] Cargando DeepFilterNet3…") _disable_df_git() _force_df_device("cpu") _model_cpu, _df_state, _ = init_df(model_base_dir=str(dfn3_dir()), log_level="WARNING", log_file=None) _model_cpu.eval() _nb_df = getattr(_model_cpu, "nb_df", getattr(_model_cpu, "df_bins", ModelParams().nb_df)) assert _df_state.sr() == DF_SR # En ZeroGPU el modelo se coloca en "cuda" a nivel de módulo (emulado fuera de @spaces.GPU). _model_gpu = None if ON_ZEROGPU or torch.cuda.is_available(): try: _model_gpu = copy.deepcopy(_model_cpu).to("cuda").eval() except Exception as e: # sin CUDA real ni emulada: solo CPU print(f"[MODELO] GPU no disponible: {e}") print(f"[MODELO] Listo · ZeroGPU={ON_ZEROGPU} · GPU={'sí' if _model_gpu is not None else 'no'}") @torch.no_grad() def _enhance_block(model, audio: np.ndarray, atten_db: float | None, device: str) -> np.ndarray: """Equivalente a df.enhance.enhance() pero con dispositivo explícito. audio: (C, T) float32 a 48 kHz.""" x = torch.from_numpy(np.ascontiguousarray(audio, dtype=np.float32)) orig_len = x.shape[-1] n_fft, hop = _df_state.fft_size(), _df_state.hop_size() x = F.pad(x, (0, n_fft)) # compensa el retardo del STFT spec, erb_feat, spec_feat = df_features(x, _df_state, _nb_df, device=device) enhanced = as_complex(model(spec.clone(), erb_feat, spec_feat)[0].cpu().squeeze(1)) if atten_db is not None and atten_db > 0: lim = 10 ** (-atten_db / 20) enhanced = as_complex(spec.squeeze(1).cpu()) * lim + enhanced * (1 - lim) out = _df_state.synthesis(enhanced.numpy()) d = n_fft - hop return np.asarray(out[:, d : orig_len + d], dtype=np.float32) def _plan_blocks(n: int) -> list[tuple[int, int, int, int]]: """Bloques (ini_ctx, fin_ctx, ini_util, fin_util) en muestras.""" chunk, ctx = int(CHUNK_SEC * DF_SR), int(CONTEXT_SEC * DF_SR) blocks, s = [], 0 while s < n: e = min(n, s + chunk) if n - e < chunk // 4: # no dejar un último bloque diminuto e = n blocks.append((max(0, s - ctx), min(n, e + ctx), s, e)) s = e return blocks def _run_blocks(model, pieces: list[np.ndarray], atten_db, device) -> list[np.ndarray]: return [_enhance_block(model, p, atten_db, device) for p in pieces] def _gpu_duration(pieces, atten_db) -> int: seconds = sum(p.shape[-1] for p in pieces) / DF_SR return int(min(120, 15 + seconds * 0.15)) def _gpu_decorator(fn): return spaces.GPU(duration=_gpu_duration)(fn) if spaces is not None else fn @_gpu_decorator def _run_blocks_gpu(pieces: list[np.ndarray], atten_db) -> list[np.ndarray]: return _run_blocks(_model_gpu, pieces, atten_db, "cuda") def enhance_48k(audio: np.ndarray, atten_db: float | None, use_gpu: bool = True, progress=None): """ Reduce el ruido de `audio` (C, T) float32 a 48 kHz. atten_db: reducción máxima en dB (None = sin límite). Devuelve (audio_procesado, info) donde info indica dispositivo y avisos. """ n = audio.shape[-1] blocks = _plan_blocks(n) xf = int(XFADE_SEC * DF_SR) out = np.zeros_like(audio, dtype=np.float32) weight = np.zeros(n, dtype=np.float32) # Agrupa bloques para que cada llamada a la GPU sea corta. per_group = max(1, int(GROUP_SEC // CHUNK_SEC)) groups = [blocks[i : i + per_group] for i in range(0, len(blocks), per_group)] global _gpu_blocked_until gpu_ok = (use_gpu and _model_gpu is not None and time.time() >= _gpu_blocked_until and (not ON_ZEROGPU or n / DF_SR >= GPU_MIN_SEC)) devices, notes = set(), [] for gi, group in enumerate(groups): pieces = [audio[:, a:b] for a, b, _, _ in group] results = None if gpu_ok: try: results = _run_blocks_gpu(pieces, atten_db) devices.add("GPU") except Exception as e: gpu_ok = False # si falla una vez (p. ej. cuota), el resto va por CPU _gpu_blocked_until = time.time() + GPU_COOLDOWN_SEC print(f"[GPU] No disponible, se sigue en CPU: {str(e).splitlines()[0][:200]}") notes.append("La tarjeta gráfica no estaba libre: se ha hecho con el procesador (mismo resultado)") if results is None: results = _run_blocks(_model_cpu, pieces, atten_db, "cpu") devices.add("CPU") for (a, b, s, e), y in zip(group, results): lo, hi = max(0, s - xf), min(n, e + xf) w = np.ones(hi - lo, dtype=np.float32) if s > 0: # rampa de entrada centrada en s w[: 2 * xf] = np.linspace(0, 1, 2 * xf, dtype=np.float32)[: min(2 * xf, hi - lo)] if e < n: # rampa de salida centrada en e w[-2 * xf :] = np.minimum(w[-2 * xf :], np.linspace(1, 0, 2 * xf, dtype=np.float32)) out[:, lo:hi] += y[:, lo - a : hi - a] * w weight[lo:hi] += w if progress is not None: progress((gi + 1) / len(groups)) out /= np.maximum(weight, 1e-6) return out, {"dispositivo": "+".join(sorted(devices)) or "—", "avisos": notes} def gpu_available() -> bool: return _model_gpu is not None