Encode WAV audio as MP3 in Python with Pydub and FFmpeg
Convert one recording or a batch of WAV files into MP3s with a Python script that reports which files succeeded and returns a failing exit status if any conversion fails. The example below uses Pydub and FFmpeg, leaves your source files alone, and refuses to overwrite existing outputs.
The walkthrough uses Bash on Linux and local mono or stereo WAV recordings. Each output is a 192 kbps MP3 at 44.1 kHz, with the same number of channels as its source. Pydub holds decoded audio in memory, so process files sequentially and use recordings that fit in RAM. This is a local conversion script, not an upload service or a streaming encoder.
Setting up your environment
The example was tested with Python 3.14.7, Pydub 0.25.1, audioop-lts 0.2.2, and FFmpeg 9.0.1 on Linux. The commands use a virtual environment explicitly, so you do not need to activate it.
Installing FFmpeg
Install Python with virtual-environment support, FFmpeg, and ffprobe. On Ubuntu:
sudo apt-get update &&
sudo apt-get install -y python3 python3-venv ffmpeg
Other Linux distributions provide their own packages; the
FFmpeg download page links to distribution packages. Check that
your build includes the libmp3lame encoder:
ffmpeg -version &&
ffprobe -version &&
ffmpeg -hide_banner -encoders
Look for libmp3lame in the encoder list. Installing Pydub does not install FFmpeg.
Installing Pydub
Create a new directory from its parent. If audio-encoding already exists, choose a different name;
the && chain stops setup before it can install into an existing project.
mkdir audio-encoding &&
cd audio-encoding &&
python3 -m venv .venv &&
.venv/bin/python -m pip install 'pydub==0.25.1' 'audioop-lts==0.2.2; python_version >= "3.13"'
Keep this shell in audio-encoding for the remaining commands. Python
removed audioop in 3.13, but Pydub 0.25.1 still
imports it. The conditional dependency installs
audioop-lts on Python 3.13 and newer; Python 3.12
uses its standard-library module. No audio playback package is needed.
Save the converter
Save the complete script below as encode_audio.py. WAV is a container: its samples can be integers
or floating-point values. The script first asks FFmpeg to decode to signed 16-bit PCM, then gives
that known representation to Pydub. This avoids relying on
Pydub 0.25.1’s WAV fast path,
which does not distinguish all extensible WAV sample types. Higher bit depths are reduced to
16 bits for this MP3 workflow; keep the source WAV as your master.
The MP3 is encoded in a temporary directory beside its destination. Only a completed encode is published, using a hard link that fails if the destination already exists. Use a local filesystem that supports hard links. Files from successful conversions remain when another input fails.
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import sys
from tempfile import TemporaryDirectory
from pydub import AudioSegment
from pydub.exceptions import CouldntDecodeError, CouldntEncodeError
def convert_one(source, output_dir, ffmpeg, fade_ms):
if source.suffix.lower() != '.wav' or not source.is_file():
raise ValueError('Expected an existing WAV file')
destination = output_dir / (source.stem + '.mp3')
if os.path.lexists(destination):
raise FileExistsError(f'Output already exists: {destination}')
with TemporaryDirectory(prefix='.encode-', dir=output_dir) as work:
decoded = Path(work) / 'decoded.wav'
encoded = Path(work) / 'encoded.mp3'
subprocess.run(
[ffmpeg, '-nostdin', '-hide_banner', '-v', 'error', '-xerror',
'-f', 'wav', '-i', str(source.resolve(strict=True)),
'-map', '0:a:0', '-c:a', 'pcm_s16le', '-ar', '44100', str(decoded)],
check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
)
audio = AudioSegment.from_wav(decoded)
if audio.channels not in (1, 2) or len(audio) == 0:
raise ValueError('Expected nonempty mono or stereo audio')
if fade_ms > len(audio):
raise ValueError('The fade must not exceed the recording length')
if fade_ms:
audio = audio.fade_in(fade_ms).fade_out(fade_ms)
audio.export(encoded, format='mp3', codec='libmp3lame', bitrate='192k').close()
os.link(encoded, destination)
return destination
def main():
parser = argparse.ArgumentParser(description='Convert WAV recordings to MP3')
parser.add_argument('--out-dir', type=Path, required=True)
parser.add_argument('--fade-ms', type=int, default=0)
parser.add_argument('inputs', type=Path, nargs='+')
args = parser.parse_args()
if args.fade_ms < 0:
parser.error('--fade-ms must be zero or greater')
ffmpeg = shutil.which('ffmpeg')
if ffmpeg is None:
parser.error('ffmpeg is missing from PATH')
AudioSegment.converter = ffmpeg
try:
output_dir = args.out_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
except OSError as error:
parser.exit(2, f'Cannot prepare output directory: {error}\n')
failed = 0
for source in args.inputs:
try:
destination = convert_one(source, output_dir, ffmpeg, args.fade_ms)
except (OSError, ValueError, subprocess.CalledProcessError,
CouldntDecodeError, CouldntEncodeError) as error:
failed += 1
print(f'FAILED {source}: {error}', file=sys.stderr)
else:
print(f'OK {source} -> {destination}')
print(f'{len(args.inputs) - failed} converted, {failed} failed')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())
FFmpeg’s -xerror option makes reported decoding errors fail the
conversion. That catches damaged input that FFmpeg might otherwise partly recover. It is not a
proof that every recording is complete: listen to important outputs and compare their duration
with the expected recording length.
Convert a recording
Use your own input.wav, or create a three-second test tone. The -n flag refuses to overwrite
an existing file:
ffmpeg -nostdin -hide_banner -v error -n -f lavfi \
-i 'sine=frequency=440:sample_rate=44100:duration=3' -c:a pcm_s16le input.wav
Run the converter from audio-encoding:
.venv/bin/python encode_audio.py --out-dir mp3 -- input.wav
It prints an OK line and 1 converted, 0 failed, then exits with status 0. The file is
mp3/input.mp3. Run the command again and it reports an existing-output failure, exits with status
1, and preserves the first MP3’s bytes. Choose another output directory for a new encode.
Batch processing audio files
Create a wav_files directory and put your WAV recordings there, then pass them to the same script:
.venv/bin/python encode_audio.py --out-dir mp3-batch -- wav_files/*.wav
Bash expands this glob for the files directly inside the directory, without recursion. It matches
lowercase .wav names; pass uppercase .WAV files explicitly. If there are no matches, the literal
pattern reaches the script and produces a missing-input failure. Quote individual paths containing
spaces. The -- separator also allows input names beginning with a hyphen.
Each input gets an OK or FAILED line. A mixed batch keeps valid MP3s, continues after a corrupt
file, and exits with status 1. Two inputs such as a/interview.wav and b/interview.wav map to
the same output name: the first successful conversion owns it, and the second is reported as a
failure. This is also why rerunning a completed batch reports failures instead of silently skipping
or replacing files.
| Exit status | Meaning |
|---|---|
0 | Every requested input was converted. |
1 | At least one input failed, including an existing destination. |
2 | Argument or setup error, such as a missing FFmpeg executable or unusable output directory. |
Add fades deliberately
To soften the first and last 100 milliseconds, use a fresh output directory:
.venv/bin/python encode_audio.py --out-dir mp3-faded --fade-ms 100 -- input.wav
Without this option, the script applies no fades, gain, normalization, or dynamic-range compression. A higher MP3 bitrate does not restore information missing from the source. Listen before deciding whether to alter levels; peak normalization followed by a volume boost can clip audio. See the Pydub API for gain and fade operations.
Check the output
Inspect the first MP3 and decode it all the way through:
ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name,sample_rate,channels,bit_rate:format=duration \
-of json mp3/input.mp3 &&
ffmpeg -nostdin -v error -xerror -i mp3/input.mp3 -f null -
For the test tone, expect codec_name to be mp3, sample_rate to be 44100, one channel, and a
duration close to three seconds. MP3 frame padding can make the reported duration slightly longer.
The decode command should finish successfully with no error messages. Metadata and decodability
are useful checks, but neither establishes perceived audio quality.
Troubleshooting common issues
No module named 'audioop'or'pyaudioop': rerun the pinned package installation with.venv/bin/python -m pipinside the project, including the conditionalaudioop-ltsdependency.- Missing FFmpeg or
Unknown encoder 'libmp3lame': check the executable and encoder list from setup. The Python package and the system encoder are separate installations. - Corrupt or missing input: read the
FAILEDline and any preceding FFmpeg diagnostic. A failed decode or encode does not publish a new MP3; a destination that already existed stays untouched. - Hard-link or permission error: use a writable local output directory with hard-link support. The script removes its staging directory on ordinary failures and never replaces a destination to work around a filesystem error.
Keep the original recordings and use a new output directory when changing encoding settings. If conversion needs to move out of your local Python process, explore our audio encoding service.
