Quitar las pausas de una grabación de voz se reduce a cuatro números: un nivel por debajo del cual el audio cuenta como silencio, cuánto tiene que durar un tramo silencioso para eliminarlo y cuánto margen se deja a cada lado de los fragmentos que se conservan. El Sound Editor de la aplicación de escritorio de osFoundry muestra exactamente esos cuatro en su diálogo Strip Silence, con valores por defecto de −48 dB, 50 ms, 5 ms y 20 ms. Aquí se reconstruye la misma operación en unas 40 líneas de numpy, se comprueba muestra a muestra contra un archivo exportado por la aplicación y después se usa para medir qué hace realmente cada número. Al final, el mismo trabajo con silenceremove de ffmpeg, que se comporta de otra forma en un detalle fácil de pasar por alto.
Las grabaciones de prueba
Dos tomas de prueba hechas para esto: cinco frases cortas de texto a voz cada una (en inglés y en japonés), separadas por entre 2,4 y 3,4 segundos de silencio, con un siseo de sala generado por debajo. 48 kHz, mono, 16 bits.
| toma | duración | RMS del siseo | pico del siseo |
|---|---|---|---|
| Inglés | 27,57 s | −64,6 dBFS | −52,4 dBFS |
| Japonés | 33,55 s | −64,6 dBFS | −52,4 dBFS |
La diferencia entre las dos últimas columnas importa más adelante. Un siseo con una media de −64,6 dBFS sigue teniendo muestras sueltas que llegan a −52,4.
La regla
Una muestra es silenciosa si su valor absoluto está en el umbral o por debajo. Una racha de muestras silenciosas se puede eliminar si dura al menos la longitud mínima. Todo lo que queda entre rachas eliminables se convierte en un clip, ampliado con los márgenes de inicio y de fin:
import numpy as np
def silent_runs(x, sr, threshold_db, min_ms):
quiet = np.abs(x) <= 10 ** (threshold_db / 20)
edges = np.flatnonzero(np.diff(np.concatenate(([0], quiet.view(np.int8), [0]))))
runs = edges.reshape(-1, 2) # [start, end) of every quiet run
keep = (runs[:, 1] - runs[:, 0]) >= min_ms * sr / 1000
keep |= (runs[:, 0] == 0) | (runs[:, 1] == len(x)) # file edges: always strippable
return runs[keep]
def strip_silence(x, sr, threshold_db=-48, min_ms=50, pad_start_ms=5, pad_end_ms=20):
runs = silent_runs(x, sr, threshold_db, min_ms)
bounds = np.concatenate(([0], runs.ravel(), [len(x)])).reshape(-1, 2)
ps, pe = int(pad_start_ms * sr / 1000), int(pad_end_ms * sr / 1000)
clips = []
for s, e in bounds:
if e <= s:
continue
s, e = max(0, s - ps), min(len(x), e + pe)
if clips and s <= clips[-1][1]: # pads made two clips touch: merge
clips[-1][1] = e
else:
clips.append([s, e])
return clips
Eliminar el silencio solo quita. Cerrar los huecos es un paso aparte, el que el Sound Editor llama Space Clips: poner los clips uno detrás de otro, dejando el primero donde estaba.
def space_clips(x, clips):
return np.concatenate([np.zeros(clips[0][0], x.dtype)] + [x[s:e] for s, e in clips])
Comprobación contra la aplicación
Las dos tomas pasaron por el Sound Editor con −48 dB, 500 ms, 100 ms y 300 ms, luego Space Clips y luego Bounce Mix normalizado a −0,1 dBFS. El archivo de la aplicación es estéreo de 24 bits con los dos canales idénticos. Comparado con el resultado de numpy, normalizado igual:
| toma | bounce de la aplicación | numpy | mayor diferencia |
|---|---|---|---|
| Inglés | 660.500 muestras (13,76 s) | 660.500 muestras | −135,0 dB |
| Japonés | 815.030 muestras (16,98 s) | 815.030 muestras | −132,5 dB |
La misma longitud al nivel de muestra, y diferencias del orden del redondeo de 24 bits. Basta con una comparación simple muestra a muestra, sin ventana RMS ni suavizado, para reproducir lo que exporta la aplicación.
La primera versión de la reconstrucción fallaba exactamente en una región, y es el único sitio donde una implementación ingenua se equivoca. Cada toma empieza con 0,47 segundos de silencio antes de la primera palabra, menos que el mínimo de 500 ms. Si se aplica la regla al pie de la letra, ese tramo se conserva y la salida empieza con 0,47 s de siseo. El archivo de la aplicación, en cambio, empieza con 0,376 s de cero digital y luego la primera palabra: el silencio que toca el principio o el final del archivo se elimina sea cual sea su duración, y el primer clip mantiene su posición (0,47 s menos el margen de inicio de 100 ms). Eso es la línea keep |= de arriba.
La longitud mínima decide la forma
Con los márgenes por defecto (5 ms y 20 ms), solo cambia la longitud mínima:
| duración mínima | clips en inglés | salida en inglés | clips en japonés | salida en japonés |
|---|---|---|---|---|
| 20 ms | 18 | 11,40 s | 19 | 14,08 s |
| 50 ms (por defecto) | 11 | 11,46 s | 14 | 14,15 s |
| 100 ms | 7 | 11,63 s | 11 | 14,30 s |
| 200 ms | 6 | 11,73 s | 6 | 15,00 s |
| 300 ms | 5 | 11,98 s | 5 | 15,20 s |
| 500 ms | 5 | 11,98 s | 5 | 15,20 s |
Las dos tomas tienen cinco frases. Con los 50 ms por defecto, la toma en inglés sale en 11 clips y la japonesa en 14: los huecos cortos dentro de las frases (6 de entre 50 y 500 ms en la toma en inglés, 9 en la japonesa) cuentan como silencio y se recortan. La salida es solo medio segundo más corta que con 500 ms, pero cada uno de esos cortes se lleva una respiración o el espacio entre dos palabras, y el resultado suena entrecortado. A partir de 300 ms, cada toma da exactamente cinco clips, uno por frase. La duración total apenas cambia; lo que cambia es dónde caen los cortes.
Los márgenes deciden el ritmo
Con el mínimo en 500 ms, los márgenes:
| margen inicio / fin | salida en inglés | salida en japonés |
|---|---|---|
| 0 / 0 ms | 11,86 s | 15,08 s |
| 5 / 20 ms | 11,98 s | 15,20 s |
| 50 / 100 ms | 12,56 s | 15,78 s |
| 100 / 300 ms | 13,76 s | 16,98 s |
| 200 / 500 ms | 15,16 s | 18,38 s |
Una vez cerrados los huecos, la pausa entre dos frases es el margen de fin de una más el margen de inicio de la siguiente. Con 0/0 las frases quedan pegadas; con 100/300 cada unión es una respiración de 0,4 segundos, más o menos lo que deja alguien que lee a ritmo constante. El margen de inicio tiene una segunda función: los ataques suaves de las palabras a menudo quedan por debajo del umbral y, sin él, la primera consonante de la frase desaparece. El margen de fin es el que conviene ajustar de oído.
El umbral tiene que superar los picos del ruido, no su media
Con 500 ms y márgenes de 100/300, variando el umbral:
| umbral | clips en inglés | salida en inglés | clips en japonés | salida en japonés |
|---|---|---|---|---|
| −36 dB | 5 | 13,29 s | 5 | 16,79 s |
| −40 dB | 5 | 13,34 s | 5 | 16,87 s |
| −44 dB | 5 | 13,40 s | 5 | 16,94 s |
| −48 dB | 5 | 13,76 s | 5 | 16,98 s |
| −52 dB | 10 | 17,48 s | 13 | 22,78 s |
| −56 dB | 1 | 27,57 s | 1 | 33,55 s |
Entre −36 y −48 dB el resultado es estable; un umbral más alto solo recorta un poco más de la cola suave de cada palabra. Con −52 dB, justo por encima de los picos del siseo, algunas muestras sueltas del siseo cruzan la línea y parten las pausas largas en trozos de menos de 500 ms, así que varias sobreviven. Con −56 dB el siseo cuenta como sonido en todas partes y no se elimina nada. −56 parece una opción segura si solo sabes que el ruido de fondo está en torno a −64 dB RMS. Como la regla compara muestras individuales, lo que importa es el pico del siseo, y el umbral necesita unos cuantos dB de margen por encima.
El mismo trabajo con ffmpeg
ffmpeg tiene dos filtros relevantes. silencedetect solo informa y, con el mismo umbral y el mismo mínimo, encuentra las mismas pausas que la reconstrucción:
ffmpeg -i en-take.wav -af silencedetect=noise=-48dB:d=0.5 -f null - 2>&1 | grep silence_
# silence_start: 2.411833
# silence_end: 6.070167 | silence_duration: 3.658333
# ...
La primera frase termina en 2,4118 s y el primer clip de la reconstrucción termina en 2,712 s, el mismo punto más el margen de fin de 300 ms. Fíjate en lo que falta en esa salida: los 0,47 s de silencio al principio del archivo, más cortos que d, no aparecen.
silenceremove corta directamente, y aquí es donde fallan las expectativas. Con ffmpeg 7.1:
ffmpeg -i en-take.wav -af "silenceremove=start_periods=1:start_threshold=-48dB:\
stop_periods=-1:stop_threshold=-48dB:stop_duration=0.5:stop_silence=0:detection=peak" out.wav
| ajustes del filtro | salida en inglés | salida en japonés |
|---|---|---|
| detection=peak, stop_silence=0 | 13,98 s | 17,20 s |
| detection=peak, stop_silence=0.3 | 15,48 s | 18,70 s |
| detection=rms, stop_silence=0 | 13,50 s | 17,03 s |
| numpy, 500 ms, márgenes 0/0 | 11,86 s | 15,08 s |
Con stop_silence=0, la salida sigue siendo unos 2 segundos más larga que un recorte sin márgenes. Pasar silencedetect sobre ella muestra por qué: cada pausa eliminada ha dejado unos 0,52 s detrás. stop_duration es a la vez la longitud mínima y la cantidad de silencio que el filtro deja pasar antes de empezar a cortar, así que pedir «quita solo las pausas de más de 500 ms» también significa «deja 500 ms de cada una». stop_silence se suma a eso en lugar de sustituirlo, y no hay opción para un margen antes del habla. Si quieres que las pausas queden con una longitud concreta, silencedetect más tu propia lista de cortes (o la versión de numpy de arriba) te da ese control; silenceremove va bien cuando medio segundo de pausa es justo lo que querías.
El script completo, con todos los barridos de arriba, son unas 90 líneas de Python con numpy y soundfile. En la aplicación de escritorio de osFoundry, los mismos pasos son Edit → Strip Silence y Edit → Space Clips… en el Sound Editor, y se ejecutan en tu propio equipo, de acuerdo con el diseño local-first de la aplicación. La aplicación es gratuita desde la página de descarga de osFoundry.

Top comments (0)