DEV Community

Cover image for Streaming FFmpeg Subprocesses: Handling Multi-GB Audio Without Crashing RAM
WgeorgeAssistantIA
WgeorgeAssistantIA

Posted on

Streaming FFmpeg Subprocesses: Handling Multi-GB Audio Without Crashing RAM

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
Enter fullscreen mode Exit fullscreen mode

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))))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • The leftover byte. read(131072) is not guaranteed to return an even number of bytes. If you call np.frombuffer(chunk, dtype="<i2") on an odd-length buffer, NumPy raises ValueError. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Send the stream you don't need to subprocess.DEVNULL.
  2. Make FFmpeg quieter (-v error or -v quiet), so stderr stays tiny.
  3. 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 bytes object 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

  1. 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.
  2. 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 (s16le or f32le).
  3. Read fixed-size chunks and handle partial reads. Carry leftover bytes over to the next read.
  4. 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)