Most audio tutorials in Python start by loading the whole file:
import librosa
y, sr = librosa.load("podcast.wav", sr=None) # the entire file, decoded, in RAM
That is fine for a 3-minute clip. It is not fine for what people actually send to a silence remover: two-hour podcast recordings, multi-gigabyte WAV exports, screen recordings with an audio track. Decoded to float32, one hour of 48 kHz stereo is about 1.4 GB, and two hours is about 2.8 GB, before Python has made a single copy.
VoxCut, my desktop silence remover, never holds a whole file in memory. It talks to FFmpeg through pipes and lets FFmpeg do the heavy decoding. There are three different patterns in the code, and each one solves a different problem. This post walks through them, then shows what I measured on a 1.38 GB file.
Pattern 1: don't bring the audio into Python at all
The first instinct for silence detection is to stream samples into NumPy and compute the loudness of each window. VoxCut doesn't do that. FFmpeg already has a silencedetect filter, so the app runs it with a null output and only reads the log lines:
cmd = [
"ffmpeg", "-y", "-i", input_file, "-vn",
"-af", f"silencedetect=noise={noise_db}dB:d={min_silence_s}",
"-f", "null", "-",
]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace")
for line in proc.stderr:
m = re.search(r"silence_start:\s*(-?\d+\.?\d*)", line)
if m:
starts.append(max(0.0, float(m.group(1))))
m = re.search(r"silence_end:\s*(-?\d+\.?\d*)", line)
if m:
ends.append(max(0.0, float(m.group(1))))
No sample ever crosses the pipe. Python only receives short text lines: the Duration: header, time= progress lines (which drive the progress bar), and the silence_start / silence_end timestamps. Decoding and level measurement stay in FFmpeg's C code.
If FFmpeg already has a filter that computes what you need, parsing its output is the cheapest stream you will ever write.
Pattern 2: stream raw PCM when you need the samples
The waveform preview does need samples, but not many. VoxCut asks FFmpeg for mono 16-bit PCM at 8 kHz and reads it from stdout in fixed-size chunks:
cmd = [
"ffmpeg", "-v", "quiet", "-i", str(input_file),
"-vn", # ignore video
"-ac", "1", # downmix to mono
"-ar", "8000", # 8 kHz is plenty for a picture of the waveform
"-f", "s16le", # raw signed 16-bit little-endian
"-", # write to stdout
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
leftover = b""
while True:
chunk = proc.stdout.read(131072) # 128 KB at a time
if not chunk:
break
data = leftover + chunk
n = len(data) // 2 # 2 bytes per sample
leftover = data[n * 2:] # keep a dangling odd byte for next time
samples = np.abs(np.frombuffer(data[:n * 2], dtype="<i2").astype(np.float32))
# ...fold samples into ~1,400 peak bins for the preview
Two details matter here:
-
The leftover byte.
read(131072)is not guaranteed to return an even number of bytes. If you callnp.frombuffer(chunk, dtype="<i2")on an odd-length buffer, NumPy raisesValueError. If you silently drop the extra byte, every later sample is misaligned. Carrying it over to the next read is the fix. - Downsampling happens in FFmpeg. Going from 48 kHz stereo to 8 kHz mono divides the data by 12 before it reaches Python.
The same loop also reports progress (bytes read ÷ expected bytes) and checks a cancel flag between chunks. Neither is possible when a single load() call blocks for 40 seconds.
Pattern 3: decode → NumPy → encode, without a temp file
Exporting with crossfades at each cut is the case where Python has to modify the audio. VoxCut runs two FFmpeg processes, one decoding to float32 on stdout and one encoding from stdin, with NumPy in between:
dec_cmd = ["ffmpeg", "-v", "error", "-i", src,
"-map", "0:a:0", "-f", "f32le", "-acodec", "pcm_f32le", "-"]
enc_cmd = ["ffmpeg", "-y", "-v", "error", "-f", "f32le", "-ar", str(sr),
"-ac", str(ch), "-i", "-"] + codec_args + [dst]
dec = subprocess.Popen(dec_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
enc = subprocess.Popen(enc_cmd, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
The loop reads one second of audio at a time. It reads and discards the silent parts, and writes the kept parts to the encoder. It holds back only the last few milliseconds of each segment so they can be blended with the start of the next one. The decoded file never exists on disk, and Python never holds more than about a second of it.
The pipe deadlock that will bite you
All three snippets share one habit: each process has at most one pipe that Python reads. Patterns 2 and 3 send stderr to DEVNULL. Pattern 1 reads stderr and sends stdout to DEVNULL.
This is the version that hangs:
# DON'T: two pipes, but you only ever read one of them
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
while chunk := proc.stdout.read(65536):
process(chunk)
OS pipe buffers are small. If FFmpeg writes enough to stderr (warnings about a damaged file, or the progress line it keeps printing unless you pass -nostats or -v quiet), that buffer fills up. FFmpeg then blocks on its next stderr write and stops producing stdout. Meanwhile your loop is waiting on stdout. Neither side moves, nothing crashes, and no error appears. The app just freezes partway through a long file.
The fixes, from simplest to most flexible:
- Send the stream you don't need to
subprocess.DEVNULL. - Make FFmpeg quieter (
-v erroror-v quiet), so stderr stays tiny. - If you really need both, drain stderr on a separate thread, or use
communicate()when the output is small.
What it costs in practice
I measured this on my dev machine, which is not fast. The test file was a 2-hour, 48 kHz stereo WAV (1.38 GB). I compared VoxCut's waveform loop with the "load everything" approach: decode the whole file to float32 through the same FFmpeg, then hand it to NumPy. That is what any loader returning a full array has to hold. Peak memory is the process's peak working set, read from Windows.
| Approach | Python peak RAM | FFmpeg peak RAM | First data in Python | Total |
|---|---|---|---|---|
| Load the whole file as float32 | 5,275 MB | 20 MB | 44 s | 46 s |
| Stream 8 kHz mono, 128 KB reads | 33 MB | 19 MB | 0.12 s | 6.5 s |
Two things surprised me:
-
The full load peaked at almost twice the size of the decoded audio (5.3 GB for 2.8 GB of float32). Reading an unbounded pipe into one
bytesobject means the buffer grows and gets copied, so for a moment you hold two copies. On an 8 GB laptop that is already swap territory. - The streaming version barely moved Python's memory. The process started at 32 MB with NumPy imported and peaked at 33 MB. The audio itself never accounted for more than a couple of 128 KB chunks.
The streaming run is also faster overall. That is mostly because it asks FFmpeg for 12× less data, not because of the pipe itself. Getting the first data in 0.12 s instead of 44 s is what lets the app show a progress bar from the start.
Takeaways
-
Check whether FFmpeg can compute the answer itself. If a filter exists (
silencedetect,ebur128,astats), parse its output and keep the samples out of Python. -
When you need samples, ask for as few as possible. Downmix and resample in FFmpeg (
-ac,-ar), and pick the sample format you will actually use (s16leorf32le). - Read fixed-size chunks and handle partial reads. Carry leftover bytes over to the next read.
-
Never leave a pipe you don't read. Use
DEVNULL, lower the log level, or drain it on another thread.
These three patterns are what let VoxCut open multi-hour recordings on an ordinary laptop without the waveform preview, the silence scan or the export ever loading the file into RAM.
Top comments (0)