Loop audio in Python with Pydub: repeats and crossfades
Use hard repetition when you need exact copies of a clip, or overlap its end and beginning with a crossfade to soften an abrupt join. This walkthrough creates a finite WAV file with Pydub, reports failures through its exit status, and refuses to replace an existing output. A crossfade can reduce a click; it does not guarantee a musically seamless loop.
Prerequisites
The commands use Bash on Linux, Python with virtual-environment support, FFmpeg, and ffprobe. The walkthrough is tested with Python 3.12.13 and 3.14.7, Pydub 0.25.1, and FFmpeg 7.1.5 and 9.0.1. Install Python and the native tools through your distribution; the FFmpeg download page links to packages. Check the executables:
python3 --version && ffmpeg -version && ffprobe -version
Use uncompressed, little-endian signed 16-bit PCM WAV input with one or two channels. The script preserves its sample rate and channels, and rejects other sample encodings instead of interpreting their bytes as 16-bit integers. It builds the complete result in memory, so start with short clips.
Essential tools setup
Open a Bash shell in a new empty working directory. Install the pinned packages in a fresh virtual
environment; the first check refuses to reuse an existing .venv:
test ! -e .venv && test ! -L .venv &&
python3 -m venv .venv &&
.venv/bin/python -m pip install 'pydub==0.25.1' 'audioop-lts==0.2.2; python_version >= "3.13"'
Python removed audioop in 3.13. Pydub 0.25.1 uses
it, so the conditional dependency supplies audioop-lts
on newer Python versions. These commands use the virtual environment directly; activation is
unnecessary. If installation fails, the shell stays in your working directory; choose a fresh
directory before retrying setup.
Basic looping with Pydub
Save this complete program as loop_audio.py. The standard-library
wave reader validates the PCM container, and the
script checks that every declared frame is present before constructing a Pydub segment. It rejects
empty files, nonpositive repeat counts, negative crossfades, and crossfades as long as the clip.
The output is staged beside its destination and published with a hard link. Use a writable local filesystem that supports hard links, and an existing output directory. An ordinary failure removes the staging directory; an existing destination is preserved.
import argparse
import os
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import wave
from pydub import AudioSegment
def read_pcm(source):
with wave.open(str(source), 'rb') as recording:
channels = recording.getnchannels()
rate = recording.getframerate()
frames = recording.getnframes()
if (recording.getcomptype() != 'NONE'
or recording.getsampwidth() != 2 or channels not in (1, 2)):
raise ValueError('Expected mono or stereo signed 16-bit PCM WAV')
data = recording.readframes(frames)
if frames == 0 or len(data) != frames * channels * 2:
raise ValueError('Empty or truncated PCM data')
return AudioSegment(data=data, sample_width=2, frame_rate=rate, channels=channels)
def main():
parser = argparse.ArgumentParser(description='Repeat a PCM WAV clip')
parser.add_argument('--repeats', type=int, default=3)
parser.add_argument('--crossfade-ms', type=int, default=0)
parser.add_argument('input', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
if args.repeats < 1 or args.crossfade_ms < 0:
parser.error('--repeats must be positive; --crossfade-ms must be nonnegative')
try:
if args.output.suffix.lower() != '.wav':
raise ValueError('The output must have a .wav extension')
if os.path.lexists(args.output):
raise FileExistsError(f'Output already exists: {args.output}')
audio = read_pcm(args.input)
if args.crossfade_ms and args.crossfade_ms >= len(audio):
raise ValueError('The crossfade must be shorter than the clip')
if args.crossfade_ms == 0:
combined = audio * args.repeats
else:
combined = audio
for _ in range(args.repeats - 1):
combined = combined.append(audio, crossfade=args.crossfade_ms)
with TemporaryDirectory(prefix='.loop-', dir=args.output.parent) as work:
staged = Path(work) / 'loop.wav'
combined.export(staged, format='wav').close()
os.link(staged, args.output)
except (OSError, ValueError, wave.Error, EOFError) as error:
print(f'FAILED {args.input}: {error}', file=sys.stderr)
return 1
seconds = combined.frame_count() / combined.frame_rate
print(f'Wrote {args.output}: {seconds:.6f} seconds, {combined.channels} channel(s)')
return 0
if __name__ == '__main__':
sys.exit(main())
Use your own compatible input.wav, or generate a one-second tone with intentionally mismatched
endpoints. The existence checks return a failing status if the fixture already exists; FFmpeg’s
-n flag also refuses to overwrite it:
test ! -e input.wav && test ! -L input.wav &&
ffmpeg -nostdin -hide_banner -v error -n -f lavfi \
-i 'aevalsrc=0.25*sin(2*PI*440.5*t+PI/4):s=48000:d=1' -c:a pcm_s16le input.wav
Repeat it five times:
.venv/bin/python loop_audio.py --repeats 5 -- input.wav repeated.wav
Expect Wrote repeated.wav: 5.000000 seconds, 1 channel(s) and status 0. Hard repetition keeps
every input frame, including silence, but also repeats any discontinuity between the last and
first samples. Run the command again and it returns status 1, preserving repeated.wav. Invalid
arguments return status 2. Input/output errors, including missing input, detected truncation, or
unsupported sample encodings, return status 1. Checking the declared frames cannot establish
whether a recording was complete before it was saved or whether valid sample values were altered.
Crossfade the internal joins
Choose a fresh output filename and a 25 ms overlap:
.venv/bin/python loop_audio.py --repeats 5 --crossfade-ms 25 -- input.wav crossfaded.wav
Expect Wrote crossfaded.wav: 4.900000 seconds, 1 channel(s). Pydub’s
append operation overlaps the tail of
the accumulated audio with the head of the next copy. With N copies of duration D and a
crossfade of duration F, the result is approximately N × D − (N − 1) × F. Five one-second
copies have four joins, so they lose 100 ms in total. Pydub slices in milliseconds; clips or sample
rates that do not align to that grid can introduce sample rounding. Hard repetition uses the
original frames without that slicing.
Check the saved WAV files
Inspect and fully decode both literal outputs:
ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name,sample_rate,channels:format=duration -of json repeated.wav &&
ffmpeg -nostdin -v error -xerror -i repeated.wav -f null - &&
ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name,sample_rate,channels:format=duration -of json crossfaded.wav &&
ffmpeg -nostdin -v error -xerror -i crossfaded.wav -f null -
For the generated tone, both files should report pcm_s16le, 48000 Hz, and one channel. Their
durations are 5 seconds and 4.9 seconds, respectively. The decode commands should finish with no
errors. These checks establish format, timing, and decodability; listen in an audio player to judge
whether the joins suit your recording.
Choose a useful loop boundary
A zero crossing can reduce an amplitude jump, but two zero crossings can have different slopes, phases, or channel behavior. Beat detection suggests rhythmic edit points; it does not establish that the notes, ambience, or reverb join naturally. Silent input remains silent after either operation, and a crossfade can blur a transient or alter the rhythm by shortening each repeat.
The script fades only the internal joins. It does not join the saved file’s end back to its beginning, so replaying the whole output indefinitely can still click. Audition both the internal joins and that outer boundary in your intended player. For a game or background-music loop, select a musically compatible region first and check both stereo channels before choosing the overlap.
Repeat longer files with FFmpeg
Pydub holds the input and growing result in memory. Splitting a fully loaded segment into chunks does not remove that initial allocation. If you only need complete-file repetition, FFmpeg can process it without constructing the entire repeated PCM buffer in Python:
test ! -e repeated-ffmpeg.wav && test ! -L repeated-ffmpeg.wav &&
ffmpeg -nostdin -hide_banner -v error -xerror -n -stream_loop 4 \
-i input.wav -map 0:a:0 -c:a pcm_s16le repeated-ffmpeg.wav
The -stream_loop input option counts additional playbacks:
4 gives five copies including the first. This command creates a five-second WAV from the tone,
preserves its sample rate and channel count, and adds no crossfade. Run these commands sequentially.
The existence checks make a rerun fail before invoking FFmpeg; -n alone can report an overwrite
refusal with status 0 on FFmpeg 9.0.1. -xerror makes reported processing errors fail the command.
A failure after writing starts can leave a partial new file; remove that failed output before
retrying. Successful decoding does not prove that a recording was complete before it reached your
script.
Keep PCM WAV as the editing output and judge the joins before encoding for delivery. The Pydub API documents further fades and gain adjustments if your chosen region needs them.
