When you automatically remove silences from speech or podcast recordings, you cut hundreds of audio slices and stitch them back together.
If you do this naively by simply concatenating raw audio arrays:
# Naive concatenation: guaranteed audio artifacts
clean_audio = np.concatenate([segment_a, segment_b])
Your output will be riddled with audible clicks, pops, and ticks. Every single edit point sounds like a tiny vinyl scratch.
Here is why naive concatenation fails, what a linear crossfade fixes (and what it doesn't), how an equal-power crossfade works in NumPy, and what we actually ship in VoxCut.
Why Edit Points Pop: Amplitude Discontinuities
Digital audio is a sequence of samples (e.g. 44,100 per second), each one an amplitude between -1.0 and +1.0.
When you cut audio, you almost never cut at an exact zero-crossing (a sample where the amplitude is 0.0):
-
Segment A ends at an amplitude of
+0.42. -
Segment B begins at an amplitude of
-0.31. - Concatenating them creates a jump of
0.73between two consecutive samples (1/44100th of a second apart).
A step like that has energy spread across the whole audible spectrum. The speaker cone is asked to move almost instantly, and you hear it as a click.
Linear Crossfades: The Midpoint Dip
The first instinct is to overlap the two segments for a few milliseconds and apply a linear crossfade:
- Segment A fades out linearly from 1.0 to 0.0
- Segment B fades in linearly from 0.0 to 1.0
# Linear crossfade weights
t = np.linspace(0, 1, fade_samples)
fade_out = 1.0 - t
fade_in = t
This kills the click: there is no longer a step anywhere. But for two uncorrelated signals (two different moments of speech), what adds up is power, not amplitude:
At the exact midpoint of a linear crossfade (t = 0.5):
On long fades between two loud signals, that dip can be heard as a small "breathing" in the volume.
The Theory Fix: Constant-Power (Sine/Cosine) Crossfade
To keep the energy constant across uncorrelated material, use trigonometric curves:
Because , the summed power stays at 1.0 for every sample of the transition.
A NumPy Implementation
Here is a self-contained function that splices two buffers with an equal-power crossfade:
import numpy as np
def equal_power_crossfade(seg_a: np.ndarray, seg_b: np.ndarray, fade_ms: float = 20.0, sample_rate: int = 44100) -> np.ndarray:
"""
Crossfades the end of seg_a into the beginning of seg_b using constant-power curves.
Supports 1D (mono) and 2D (stereo: samples, channels) arrays.
"""
fade_len = int((fade_ms / 1000.0) * sample_rate)
# No room for a fade (or fade disabled): plain splice.
# Note: seg_a[:-0] would return an EMPTY array, hence the fade_len <= 0 check.
if fade_len <= 0 or len(seg_a) < fade_len or len(seg_b) < fade_len:
return np.concatenate([seg_a, seg_b])
# Normalized time vector [0.0, 1.0]
t = np.linspace(0.0, 1.0, fade_len)
# Constant-power weights
w_out = np.cos(0.5 * np.pi * t)
w_in = np.sin(0.5 * np.pi * t)
if seg_a.ndim == 2:
# Broadcast over stereo channels
w_out = w_out[:, np.newaxis]
w_in = w_in[:, np.newaxis]
# Overlapping region
overlap = (seg_a[-fade_len:] * w_out) + (seg_b[:fade_len] * w_in)
# Assemble: A (prefix) + overlap + B (suffix)
return np.concatenate([
seg_a[:-fade_len],
overlap,
seg_b[fade_len:]
])
Swap the two weight lines for w_out = 1.0 - t and w_in = t and you have the linear version.
What VoxCut Actually Ships (and Why It's Linear)
Honest disclosure: VoxCut's desktop export does not use the equal-power curve above. It uses a 40 ms linear crossfade (the equivalent of FFmpeg's acrossfade=c1=tri:c2=tri).
Why that's good enough for silence removal:
- The edges we join are the quiet tails of speech around a removed pause, not two loud musical passages. A -3 dB dip at the midpoint of a 40 ms fade between two near-silent edges is inaudible in practice.
- What matters is removing the step, and a linear ramp does that just as well as a sine curve.
- If any kept segment is shorter than two fade lengths (80 ms), the export falls back to plain cuts rather than fading into a sliver of audio.
If you crossfade music, or long fades between two loud signals, equal-power is the safer default.
The Performance Lesson
Our first version built an FFmpeg filter_complex graph: one atrim per segment plus a chain of N acrossfade filters. On a dense podcast (~870 cuts in 2 hours), that graph became the bottleneck: FFmpeg re-read the input for every segment.
The current version does it in a single pass:
- One FFmpeg process decodes the file to raw
f32lePCM onstdout. - Python reads it in 1-second chunks, skips the silent ranges, and applies the fade ramps in NumPy.
- A second FFmpeg process encodes from
stdin.
On a 30-minute podcast, this was about 50x faster than the filter graph in our benchmark. The old graph is still there as a fallback if the streaming path fails.
Critical Gotcha: The Cumulative Length Shrink
Each overlap crossfade consumes fade_len samples, because the two segments overlap in time.
If your silence detector finds 600 cuts in a 45-minute episode and you apply a 40 ms crossfade at each join:
For an audio-only podcast, nobody cares. But if that audio has to stay in sync with video, you are 24 seconds out of sync by the end. (That's why VoxCut skips the overlap crossfade entirely on video exports.)
The Video-Safe Rule
When timeline sync matters, apply micro-fades at the boundaries without overlapping:
- Fade out the last ~5 ms of segment A to zero.
- Fade in the first ~5 ms of segment B from zero.
- Concatenate without overlapping time indices.
The step disappears (both sides meet at zero), and in speech a 5 ms dip is very hard to notice.
Conclusion
Clicks at edit points are not caused by bad microphones. They come from cutting waveforms that are not at zero.
- Never splice raw arrays at arbitrary indices.
- A short crossfade removes the click; linear is fine for speech edges, equal-power is better for music and long fades.
- Use non-overlapping micro-fades when you must keep the timeline in sync with video.
- If you have hundreds of cuts, avoid giant FFmpeg filter graphs: stream PCM through Python instead.
That's the pipeline behind VoxCut's "smooth transitions" option. If you're building your own silence remover, I'd love to hear how you handle edit points.
Top comments (0)