Removing the pauses from a voice recording comes down to four numbers: a level below which audio counts as silent, how long a quiet stretch must last before it is removed, and how much to keep on either side of each piece that survives. The Sound Editor in the osFoundry desktop app exposes exactly these four in its Strip Silence dialog, with defaults of −48 dB, 50 ms, 5 ms and 20 ms. Below, the same operation is rebuilt in about 40 lines of numpy, checked sample for sample against a file the app exported, and then used to measure what each number actually does. At the end, the same job with ffmpeg's silenceremove, which behaves differently in a way that is easy to miss.
The test recordings
Two test takes, built for this: five short text-to-speech sentences each (English and Japanese), separated by 2.4 to 3.4 seconds of quiet, with generated room hiss underneath. 48 kHz, mono, 16-bit.
| take | length | hiss RMS | hiss peak |
|---|---|---|---|
| English | 27.57 s | −64.6 dBFS | −52.4 dBFS |
| Japanese | 33.55 s | −64.6 dBFS | −52.4 dBFS |
The gap between those last two columns matters later. Hiss that averages −64.6 dBFS still has individual samples reaching −52.4.
The rule
A sample is quiet if its absolute value is at or below the threshold. A run of quiet samples is strippable if it lasts at least the minimum length. Everything between strippable runs becomes a clip, widened by the start and end pads:
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
Stripping only removes. Closing the gaps is a separate step, the one the Sound Editor calls Space Clips: put the clips end to start, leaving the first one where it was.
def space_clips(x, clips):
return np.concatenate([np.zeros(clips[0][0], x.dtype)] + [x[s:e] for s, e in clips])
Checking it against the app
Both takes went through the Sound Editor with −48 dB, 500 ms, 100 ms and 300 ms, then Space Clips, then Bounce Mix normalized to −0.1 dBFS. The app's file is 24-bit stereo with identical channels. Comparing it with the numpy result, normalized the same way:
| take | app bounce | numpy | largest difference |
|---|---|---|---|
| English | 660,500 samples (13.76 s) | 660,500 samples | −135.0 dB |
| Japanese | 815,030 samples (16.98 s) | 815,030 samples | −132.5 dB |
Same length to the sample, and differences at the level of 24-bit rounding. A plain per-sample comparison, no RMS window and no smoothing, is enough to reproduce what the app exports.
The first version of the rebuild was off by exactly one region, and it is the one place where a naive implementation goes wrong. Each take starts with 0.47 seconds of quiet before the first word, shorter than the 500 ms minimum. Applied literally, the rule keeps it, and the output starts with 0.47 s of hiss. The app's file instead starts with 0.376 s of digital zero, then the first word: quiet touching the start or end of the file is stripped whatever its length, and the first clip keeps its position (0.47 s minus the 100 ms start pad). That is the keep |= line above.
Minimum length decides the shape
With the default pads (5 ms and 20 ms), only the minimum length changes:
| min strip duration | English clips | English output | Japanese clips | Japanese output |
|---|---|---|---|---|
| 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 |
Both takes have five sentences. At the default 50 ms the English take comes back as 11 clips and the Japanese as 14: the short gaps inside sentences (6 of them between 50 and 500 ms in the English take, 9 in the Japanese) count as silence and are cut out. The output is only half a second shorter than at 500 ms, but every one of those cuts removes a breath or the space between two words, and the result sounds clipped. From 300 ms up, each take is exactly five clips, one per sentence. The total length barely moves; what changes is where the cuts are.
Pads decide the rhythm
With the minimum at 500 ms, the pads:
| start / end pad | English output | Japanese output |
|---|---|---|
| 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 |
After the gaps are closed, the pause between two sentences is the end pad of one plus the start pad of the next. At 0/0 the sentences butt against each other; at 100/300 every join is a 0.4-second breath, which is roughly what a speaker leaves when reading at a steady pace. The start pad does a second job: soft word onsets are often below the threshold, and without it the first consonant of a sentence is gone. The end pad is the one to tune by ear.
The threshold has to clear the noise peaks, not its average
With 500 ms and 100/300 pads, sweeping the threshold:
| threshold | English clips | English output | Japanese clips | Japanese output |
|---|---|---|---|---|
| −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 |
From −36 to −48 dB the result is stable; a higher threshold only trims a little more of each word's quiet tail. At −52 dB, just above the hiss peaks, occasional hiss samples cross the line and break the long pauses into pieces shorter than 500 ms, so several of them survive. At −56 dB the hiss counts as sound everywhere and nothing is stripped at all. −56 looks like a safe choice if you only know the noise floor is around −64 dB RMS. Because the rule compares single samples, what matters is the hiss's peak, and the threshold needs a few dB of room above it.
The same job with ffmpeg
ffmpeg has two relevant filters. silencedetect only reports, and with the same threshold and minimum it finds the same pauses as the rebuild:
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
# ...
The first sentence ends at 2.4118 s, and the rebuild's first clip ends at 2.712 s, the same point plus the 300 ms end pad. Note what is missing from that output: the 0.47 s of quiet at the start of the file, shorter than d, is not reported.
silenceremove cuts directly, and this is where expectations go wrong. With 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
| filter settings | English output | Japanese output |
|---|---|---|
| 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, pads 0/0 | 11.86 s | 15.08 s |
With stop_silence=0 the output is still about 2 seconds longer than a zero-pad strip. Running silencedetect on it shows why: each removed pause has left about 0.52 s behind. stop_duration is both the minimum length and the amount of silence the filter lets through before it starts cutting, so asking for "only remove pauses over 500 ms" also means "leave 500 ms of each one". stop_silence then adds to that rather than replacing it, and there is no option for padding before speech. If the pauses should come out at a chosen length, silencedetect plus your own cut list (or the numpy version above) gives that control; silenceremove is fine when a half-second pause is what you wanted anyway.
The full script with every sweep above is about 90 lines of Python with numpy and soundfile. In the osFoundry desktop app, the same steps are Edit → Strip Silence and Edit → Space Clips… in the Sound Editor, running on your own machine in line with the app's local-first design. The app is free from the osFoundry download page.

Top comments (0)