Togliere le pause da una registrazione vocale si riduce a quattro numeri: un livello sotto il quale l'audio conta come silenzio, quanto deve durare un tratto silenzioso prima di essere rimosso e quanto margine lasciare su ciascun lato dei pezzi che restano. Il Sound Editor dell'app desktop di osFoundry espone proprio questi quattro valori nella finestra Strip Silence, con i default −48 dB, 50 ms, 5 ms e 20 ms. Qui sotto la stessa operazione viene ricostruita in circa 40 righe di numpy, verificata campione per campione su un file esportato dall'app e poi usata per misurare cosa fa davvero ogni numero. Alla fine, lo stesso lavoro con silenceremove di ffmpeg, che si comporta in modo diverso su un dettaglio facile da non notare.

Le registrazioni di test

Due take di test costruite apposta: cinque brevi frasi in sintesi vocale ciascuna (in inglese e in giapponese), separate da 2,4–3,4 secondi di silenzio, con un fruscio d'ambiente generato sotto. 48 kHz, mono, 16 bit.

take durata RMS del fruscio picco del fruscio
Inglese 27,57 s −64,6 dBFS −52,4 dBFS
Giapponese 33,55 s −64,6 dBFS −52,4 dBFS

La distanza tra le ultime due colonne conta più avanti. Un fruscio con una media di −64,6 dBFS ha comunque singoli campioni che arrivano a −52,4.

La regola

Un campione è silenzioso se il suo valore assoluto è pari o inferiore alla soglia. Una sequenza di campioni silenziosi si può rimuovere se dura almeno la lunghezza minima. Tutto quello che sta tra due sequenze rimovibili diventa un clip, allargato dai margini di inizio e di fine:

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 

Questo passaggio si limita a togliere. Chiudere i buchi è un passo a parte, quello che il Sound Editor chiama Space Clips: mettere i clip uno dopo l'altro, lasciando il primo dov'era.

def space_clips(x, clips): return np.concatenate([np.zeros(clips[0][0], x.dtype)] + [x[s:e] for s, e in clips]) 

Il confronto con l'app

Entrambe le take sono passate nel Sound Editor con −48 dB, 500 ms, 100 ms e 300 ms, poi Space Clips, poi Bounce Mix normalizzato a −0,1 dBFS. Il file dell'app è stereo a 24 bit con i due canali identici. Confrontato con il risultato di numpy, normalizzato allo stesso modo:

take bounce dell'app numpy differenza massima
Inglese 660.500 campioni (13,76 s) 660.500 campioni −135,0 dB
Giapponese 815.030 campioni (16,98 s) 815.030 campioni −132,5 dB

Stessa lunghezza al campione e differenze al livello dell'arrotondamento a 24 bit. Basta un semplice confronto campione per campione, senza finestra RMS né smoothing, per riprodurre quello che esporta l'app.

La prima versione della ricostruzione sbagliava esattamente una regione, ed è l'unico punto in cui un'implementazione ingenua sbaglia. Ogni take inizia con 0,47 secondi di silenzio prima della prima parola, meno del minimo di 500 ms. Applicata alla lettera, la regola tiene quel tratto e l'output inizia con 0,47 s di fruscio. Il file dell'app invece inizia con 0,376 s di zero digitale e poi la prima parola: il silenzio che tocca l'inizio o la fine del file viene rimosso qualunque sia la sua durata, e il primo clip mantiene la sua posizione (0,47 s meno il margine di inizio di 100 ms). È la riga keep |= qui sopra.

La lunghezza minima decide la forma

Con i margini di default (5 ms e 20 ms), cambia solo la lunghezza minima:

durata minima clip inglese output inglese clip giapponese output giapponese
20 ms 18 11,40 s 19 14,08 s
50 ms (default) 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

Entrambe le take hanno cinque frasi. Con i 50 ms di default la take inglese esce in 11 clip e quella giapponese in 14: le brevi pause dentro le frasi (6 tra 50 e 500 ms nella take inglese, 9 in quella giapponese) contano come silenzio e vengono tagliate. L'output è solo mezzo secondo più corto che a 500 ms, ma ognuno di quei tagli toglie un respiro o lo spazio tra due parole, e il risultato suona spezzettato. Da 300 ms in su ogni take dà esattamente cinque clip, uno per frase. La durata totale si muove appena; quello che cambia è dove cadono i tagli.

I margini decidono il ritmo

Con il minimo a 500 ms, i margini:

margine inizio / fine output inglese output giapponese
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 volta chiusi i buchi, la pausa tra due frasi è il margine di fine di una più il margine di inizio della successiva. Con 0/0 le frasi si attaccano l'una all'altra; con 100/300 ogni giunzione è un respiro di 0,4 secondi, più o meno quello che lascia chi legge a ritmo costante. Il margine di inizio ha anche un secondo compito: gli attacchi morbidi delle parole spesso stanno sotto la soglia e, senza margine, la prima consonante della frase sparisce. Il margine di fine è quello da regolare a orecchio.

La soglia deve superare i picchi del rumore, non la sua media

Con 500 ms e margini 100/300, facendo variare la soglia:

soglia clip inglese output inglese clip giapponese output giapponese
−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

Da −36 a −48 dB il risultato è stabile; una soglia più alta taglia solo un po' di più della coda morbida di ogni parola. A −52 dB, appena sopra i picchi del fruscio, alcuni campioni isolati del fruscio superano la linea e spezzano le pause lunghe in pezzi sotto i 500 ms, così parecchie sopravvivono. A −56 dB il fruscio conta come suono ovunque e non viene rimosso nulla. −56 sembra una scelta sicura se si sa soltanto che il rumore di fondo sta intorno a −64 dB RMS. Ma siccome la regola confronta singoli campioni, conta il picco del fruscio, e la soglia ha bisogno di qualche dB di margine sopra di esso.

Lo stesso lavoro con ffmpeg

ffmpeg ha due filtri utili. silencedetect si limita a segnalare e, con la stessa soglia e lo stesso minimo, trova le stesse pause della ricostruzione:

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 prima frase finisce a 2,4118 s e il primo clip della ricostruzione finisce a 2,712 s, cioè lo stesso punto più il margine di fine di 300 ms. Notate cosa manca in quell'output: gli 0,47 s di silenzio all'inizio del file, più corti di d, non vengono segnalati.

silenceremove taglia direttamente, ed è qui che le aspettative non tornano. 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 
impostazioni del filtro output inglese output giapponese
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, margini 0/0 11,86 s 15,08 s

Con stop_silence=0 l'output resta circa 2 secondi più lungo di un taglio senza margini. Passandoci sopra silencedetect si vede perché: ogni pausa rimossa ha lasciato dietro di sé circa 0,52 s. stop_duration è sia la lunghezza minima sia la quantità di silenzio che il filtro lascia passare prima di iniziare a tagliare, quindi chiedere «rimuovi solo le pause sopra i 500 ms» significa anche «lascia 500 ms di ognuna». stop_silence poi si somma a questo invece di sostituirlo, e non c'è nessuna opzione per un margine prima del parlato. Se le pause devono uscire di una lunghezza precisa, silencedetect più una vostra lista di tagli (o la versione numpy qui sopra) dà questo controllo; silenceremove va benissimo quando mezzo secondo di pausa è proprio quello che volevate.

La finestra Strip Silence nel Sound Editor di osFoundry, impostata su −48 dB, 500 ms, 100 ms e 300 ms

Lo script completo, con tutte le prove qui sopra, è di circa 90 righe di Python con numpy e soundfile. Nell'app desktop di osFoundry gli stessi passaggi sono Edit → Strip Silence e Edit → Space Clips… nel Sound Editor, e girano sulla vostra macchina, in linea con il design local-first dell'app. L'app è gratuita dalla pagina di download di osFoundry.