The finished recorder: record, save and play audio in Python
A command-line Python voice recorder. It finds your microphone, records for a few seconds, writes a real 16-bit mono WAV file, reads that file back to prove the save worked, and plays it through your speakers. Here is the whole thing running:
$ python recorder.py --seconds 3Recording 3.0 seconds...Recording finished.frames captured: 132300peak amplitude: 15 out of 32767Saved output.wavchannels: 1sample width: 2 bytesframe rate: 44100 Hzframes: 132300duration: 3.00 secondsround-trip identical: TruePlaying back...Playback finished.By the end you will be able to record audio from a microphone in Python, save it as a WAV file that any player can open, and prove the file contains exactly the samples you captured.
This is a beginner tutorial. It assumes you can run python and pip, and nothing else about
audio. Budget about twenty minutes.
Verified against Python 3.13.9, sounddevice==0.5.6, numpy==2.5.3 and pyaudio==0.2.14 on Windows 11 on 2026-09-19. Every command and every block of output on this page was captured from a real run on that machine, including live microphone capture and live playback.
Seven blocks were not executed, because they need a different operating system: the four Linux and macOS install commands, the gcc and clang build-failure transcripts, and the ALSA warning output. Those come from the sources in the references rather than from a run here.
Prerequisites: Python 3.13 and one pip install
You need Python 3.10 or newer. This tutorial was verified on 3.13.9. Check yours:
python --versionThen install the two packages this tutorial uses:
pip install sounddevice==0.5.6 numpy==2.5.3That is the complete dependency list. The third piece, wave, is already in the standard
library, so there is nothing to install for it.
Do not run pip install wave. There is an unrelated package called Wave on PyPI that has
nothing to do with audio, and installing it will not give you the wave module you want. The
wave you need ships with Python.
Linux needs one system package
One piece of vocabulary first, because it explains most Python audio install pain and it comes up again further down. A wheel is a prebuilt package that pip downloads and unpacks. When no wheel exists for your system, pip falls back to compiling the package on your machine, which needs a C compiler and the right header files.
The sounddevice wheels for Windows and macOS bundle PortAudio, so pip install sounddevice
is genuinely all you need there. The Linux wheel does not, so install PortAudio from your
package manager:
sudo apt install libportaudio2 # Debian, Ubuntusudo dnf install portaudio # Fedorasudo pacman -S portaudio # ArchCheck the install worked before going further
Run this now, before step 1:
python -c "import sounddevice, numpy; print(sounddevice.__version__, numpy.__version__)"You should see exactly this:
0.5.6 2.5.3Two version numbers means your environment is ready. An error instead means nothing after this point will work, so read Python audio errors and fixes before you continue. That section is indexed by the exact error text, so search it for your own string rather than reading it through.
How audio recording works in Python
Four ideas carry this entire tutorial. Read them once now and the code will not surprise you.
Sound is a stream of numbers. A microphone measures air pressure thousands of times per second. Each measurement is one sample, and it is just an integer.
Sample rate is how often you measure. At 44100 Hz your microphone is measured 44,100 times per second. Three seconds of audio is therefore 132,300 samples. That number will show up in your terminal later, and it is worth recognising when it does.
Bit depth is how precisely you measure. 16-bit audio stores each sample as a number between
-32768 and 32767. That is what int16 means, and it is why loudness gets reported later as a
fraction of 32767. Two bytes per sample.
Channels is how many microphones. Mono is one channel. Stereo is two. A single microphone records mono, so everything here uses one channel.
One more word, because the code and the WAV headers use it constantly. A frame is one
sample from every channel at the same instant. In stereo a frame holds two samples; in mono it
holds one, so for this entire tutorial frame and sample mean the same thing. When you see
frames: 132300 in your terminal, read it as 132,300 samples.
A WAV file is those raw samples with a small header in front of them. The header records the four facts above so that a player knows how to interpret the bytes. It is 44 bytes long, which is why the file you save shortly will be 44 bytes larger than the audio inside it.
Your code never talks to the microphone directly. It calls sounddevice, which calls
PortAudio, a C library that knows how to talk to WASAPI on Windows, CoreAudio on macOS and
ALSA on Linux. That indirection is why the same five lines of Python work on all three.
Step 1: Find the microphone Python will use
First make a folder to work in. Every command on this page assumes you are inside it, and the recorder writes its WAV files into whatever directory you run it from:
mkdir audio-recordercd audio-recorderEvery later step records from the default input device. If that device is missing, or is not the one you expected, everything downstream either fails or records silence. The error you get then points at the recording call rather than at the real cause, which is why it is worth ten seconds now to confirm what Python can actually see.
Create a file called find_microphone.py:
"""Step 1: find the microphone Python will record from."""import sounddevice as sddevice = sd.query_devices(kind="input")print("Default input device")print(" name: ", device["name"])print(" index: ", device["index"])print(" max channels:", device["max_input_channels"])print(" sample rate: ", int(device["default_samplerate"]))Run it:
python find_microphone.pyThe device name will be your hardware, not mine. The shape is the same:
Default input device name: Microphone (Sennheiser Profile) index: 1 max channels: 1 sample rate: 44100sounddevice started PortAudio, asked the operating system for its default recording device,
and printed what it found. A microphone exists. You also know how many channels it supports
and which sample rate it prefers, and both of those numbers matter later.
An error here instead means your machine has no usable input device, which is common inside
WSL, Docker containers and CI runners. See
OSError: [Errno -9996] Invalid input device
below to confirm that is what happened.
If that is your situation, you can still do most of this tutorial. Read the next five steps
rather than running them, because every one of them calls sd.rec() and every one will fail on
your machine. The code and the explanations are still worth reading. Step 7 then builds a
--tone flag that generates a sine wave instead of recording, so you can create, save and read
back a real WAV file with no microphone at all. Playback at the end of that run still needs
working speakers, so expect it to stop there if your machine has no audio output either. Skip
ahead to step 7 now if you would rather see something work
first.
Step 2: Record three seconds into memory
Recording and saving are two separate problems. Mixing them makes both harder to debug, so this step does only the first half: get sound from the microphone into a variable and confirm it arrived. Disk comes later.
Create recorder.py:
"""A small voice recorder: record, save and play a WAV file."""import sounddevice as sdSAMPLE_RATE = 44100CHANNELS = 1DURATION = 3frame_count = int(DURATION * SAMPLE_RATE)print(f"Recording {DURATION} seconds...")audio = sd.rec(frame_count, samplerate=SAMPLE_RATE, channels=CHANNELS, dtype="int16")sd.wait()print("Recording finished.")print("array shape:", audio.shape)print("array dtype:", audio.dtype)sd.wait() is not optional. Leaving it out is the most common bug in code that uses this
library, because sd.rec() returns immediately, while recording is still in progress, and
hands you an array that is still being filled in. Skip the wait and you save an array that is only
partly filled. The upstream documentation is blunt about it: the returned data is only valid once
recording has stopped.
Run it, and talk, or tap the desk, for the three seconds it is recording:
python recorder.pyExpected output:
Recording 3 seconds...Recording finished.array shape: (132300, 1)array dtype: int16You now have a NumPy array of 132,300 rows and 1 column. That is three seconds at 44100 Hz in one channel, exactly as predicted in the mental model above, and each value is a 16-bit integer. Nothing has touched the disk yet. The audio disappears when the script ends.
Step 3: Check whether you actually captured sound
Why this step. A microphone that is muted, unplugged, or blocked by an operating system permission does not usually raise an error. It returns the right number of samples, all of them zero. You get a WAV file of the correct length and size that plays back as nothing at all. On macOS this is the normal symptom of a denied microphone permission, and it confuses people for hours because nothing anywhere reports a failure.
One number catches it. Replace recorder.py with this. It is your step 2 file with NumPy
imported and a loudness check at the end, and with the two array shape and array dtype
lines dropped now that they have done their job:
"""A small voice recorder: record, save and play a WAV file."""import numpy as npimport sounddevice as sdSAMPLE_RATE = 44100CHANNELS = 1DURATION = 3frame_count = int(DURATION * SAMPLE_RATE)print(f"Recording {DURATION} seconds...")audio = sd.rec(frame_count, samplerate=SAMPLE_RATE, channels=CHANNELS, dtype="int16")sd.wait()print("Recording finished.")peak = int(np.abs(audio).max())print(f"peak amplitude: {peak} out of 32767")if peak == 0: print("WARNING: every sample is zero. The microphone captured silence.")Run it again:
python recorder.pyYour number will differ, because it depends on how loud the room was:
Recording 3 seconds...Recording finished.peak amplitude: 15 out of 32767np.abs(audio).max() found the loudest single sample in the recording and compared it to the
largest value 16-bit audio can hold. Read the result like this:
| Peak | What it means | What to do |
|---|---|---|
Exactly 0 | Nothing reached the microphone at all | See the recording is silent, but nothing errored |
Under about 100 | A silent room, or the wrong input device | Speak directly into the mic and re-run; check step 1 named the device you expected |
Roughly 1000 to 30000 | Normal speech | Nothing |
32767 | Clipping: the signal is too loud and the peaks are cut off | Move back from the microphone, or lower the input gain |
The reading of 15 above is a quiet room with nobody speaking. It is not a failure, but it is
not a useful recording either. Speak while it records and the number climbs by three orders of
magnitude.
Step 4: Save the recording as a WAV file
The array in memory is just numbers, and nothing else on your machine can read it. A WAV file wraps those same numbers in a header describing the sample rate, channel count and bit depth, and that header is what turns them into a file your music player, your browser, or a speech-to-text API can open.
The standard library's wave module does this, and needs exactly four calls. Replace
recorder.py with this. Everything from step 3 stays, including the silence warning; what is
new is the wave import, the SAMPLE_WIDTH and FILENAME constants, and the save block at
the bottom:
"""A small voice recorder: record, save and play a WAV file."""import waveimport numpy as npimport sounddevice as sdSAMPLE_RATE = 44100CHANNELS = 1SAMPLE_WIDTH = 2DURATION = 3FILENAME = "output.wav"frame_count = int(DURATION * SAMPLE_RATE)print(f"Recording {DURATION} seconds...")audio = sd.rec(frame_count, samplerate=SAMPLE_RATE, channels=CHANNELS, dtype="int16")sd.wait()print("Recording finished.")peak = int(np.abs(audio).max())print(f"peak amplitude: {peak} out of 32767")if peak == 0: print("WARNING: every sample is zero. The microphone captured silence.")with wave.open(FILENAME, "wb") as wav: wav.setnchannels(CHANNELS) wav.setsampwidth(SAMPLE_WIDTH) wav.setframerate(SAMPLE_RATE) wav.writeframes(audio.tobytes())print(f"Saved {FILENAME}")SAMPLE_WIDTH = 2 is bytes per sample, not bits. This trips people up constantly: 16-bit audio
is setsampwidth(2). Getting it wrong does not raise an error, which is why step 5 exists.
audio.tobytes() is the bridge between the two halves. NumPy holds the samples as an array,
and wave writes raw bytes. tobytes() flattens one into the other with no conversion,
because the array is already int16 and that is exactly what the WAV header now claims it is.
Run the script again:
python recorder.pyExpected output:
Recording 3 seconds...Recording finished.peak amplitude: 5 out of 32767Saved output.wavYour peak will be a different number again, for the same reason as in step 3.
There is now an output.wav next to your script that any audio player can open. Check its
size, in a way that works the same on all three operating systems:
python -c "import os; print(os.path.getsize('output.wav'), 'bytes')"264644 bytes264,644 bytes. The audio itself is 132,300 samples at 2 bytes each, which is 264,600 bytes, and the remaining 44 bytes are the WAV header described in the mental model. The arithmetic works out exactly. Being able to check it is worth more than it looks, because a file of the wrong size tells you which half of the pipeline went wrong.
Step 5: Read the WAV back and check its header
Why this step. wave does almost no validation. If you tell it your 16-bit audio is 8-bit,
it believes you, writes the file without complaint, and produces something that plays at the
wrong speed and sounds like static. No exception. No warning. The only way to catch this class
of bug is to read the file back and compare it with what you meant to write.
Append this to the bottom of recorder.py. Do not delete anything this time - unlike
steps 3 and 4, this block is a fragment, not a whole file:
with wave.open(FILENAME, "rb") as wav: frames = wav.getnframes() rate = wav.getframerate() print(f"channels: {wav.getnchannels()}") print(f"sample width: {wav.getsampwidth()} bytes") print(f"frame rate: {rate} Hz") print(f"frames: {frames}") print(f"duration: {frames / rate:.2f} seconds") raw = wav.readframes(frames)reloaded = np.frombuffer(raw, dtype=np.int16).reshape(-1, CHANNELS)print(f"round-trip identical: {np.array_equal(audio, reloaded)}")Run it:
python recorder.pyThe script still prints everything from the earlier steps first. These are the six new lines at the end:
channels: 1sample width: 2 bytesframe rate: 44100 Hzframes: 132300duration: 3.00 secondsround-trip identical: Truenp.frombuffer is tobytes() run backwards: it turns raw bytes back into int16 numbers.
.reshape(-1, CHANNELS) puts them back into one column, and the -1 tells NumPy to work out
the number of rows by itself.
What just happened. round-trip identical: True is the checkpoint that matters. It says
every sample you recorded came back off disk unchanged, which means the header is correct, the
byte conversion was lossless, and the file is genuinely playable by anything else.
To see why this check earns its place, break it on purpose. Make a separate file called
show_broken.py so your working recorder stays intact:
"""Write a WAV with a deliberately wrong sample width, then read it back."""import waveimport sounddevice as sdaudio = sd.rec(132300, samplerate=44100, channels=1, dtype="int16")sd.wait()with wave.open("broken.wav", "wb") as wav: wav.setnchannels(1) wav.setsampwidth(1) # wrong on purpose: 1 byte, not 2 wav.setframerate(44100) wav.writeframes(audio.tobytes())with wave.open("broken.wav", "rb") as wav: frames = wav.getnframes() print(f"sample width: {wav.getsampwidth()} bytes") print(f"frames: {frames}") print(f"duration: {frames / wav.getframerate():.2f} seconds")Run it:
python show_broken.pyIt reports this:
sample width: 1 bytesframes: 264600duration: 6.00 secondsNo error was raised at any point, and the file is a structurally valid WAV. But the header claims 8-bit samples, so the same bytes are counted as twice as many frames, the duration doubles, and every pair of bytes that was one sample is now read as two. It plays at half speed and sounds like buzzing. The header lied, and only the read-back caught it.
Step 6: Play the WAV file back
Everything so far has been numbers agreeing with other numbers. Numbers can agree and still be wrong in a way you would never notice on screen. Playback is the check your ears do.
Append this to the bottom of recorder.py as well, below the read-back block:
print("Playing back...")sd.play(reloaded, samplerate=SAMPLE_RATE)sd.wait()print("Playback finished.")sd.wait() appears again, and for the same reason as in step 2. sd.play() returns
immediately while audio is still playing. Without the wait, the script ends, the process exits,
and playback is cut off part way through, usually after a fraction of a second. People assume
playback is broken when in fact it worked and was killed.
Run it one more time:
python recorder.pyEverything from the earlier steps prints first again. These are the two new lines at the end, with your recording audible between them:
Playing back...Playback finished.sd.play() handed the array to PortAudio, which sent it to your default output device. Note
which array it played: reloaded, read back from the file, rather than audio from memory.
That is deliberate. Hearing the file prove itself is stronger than hearing the array you
already trusted.
Step 7: Turn it into a command-line tool
Right now the recording length and the filename are constants you have to edit the file to change. That is fine while learning and annoying immediately afterwards. Splitting the script into functions also gives you pieces you can import into something else, which is usually the reason people learn this in the first place.
One more thing is worth having: a --tone flag that generates a sine wave instead of
recording. If your machine has no working microphone, which is normal in WSL, Docker and CI,
that flag lets you exercise the save, read-back and playback path anyway.
If you skipped straight here from step 1 because you have no microphone, create recorder.py
now with the code below and read on; everything it needs is in this one file.
Nothing below is new behaviour except --tone and the command-line flags. The record,
describe, save, load and play functions are the code you have already written, moved
into boxes with names. Replace recorder.py with its final form:
"""Record a few seconds of audio, save it as a WAV file, and play it back."""import argparseimport waveimport numpy as npimport sounddevice as sdSAMPLE_RATE = 44100CHANNELS = 1SAMPLE_WIDTH = 2DTYPE = "int16"def record(seconds): """Capture `seconds` of audio from the default input device.""" frames = int(seconds * SAMPLE_RATE) print(f"Recording {seconds} seconds...") audio = sd.rec(frames, samplerate=SAMPLE_RATE, channels=CHANNELS, dtype=DTYPE) sd.wait() print("Recording finished.") return audiodef test_tone(seconds, hz=440): """Build a sine wave, for machines with no working microphone.""" frames = int(seconds * SAMPLE_RATE) t = np.linspace(0, seconds, frames, endpoint=False) samples = np.sin(2 * np.pi * hz * t) * 0.3 * 32767 return samples.astype(np.int16).reshape(-1, CHANNELS)def describe(audio): """Report how loud the capture was, so silence is caught early.""" peak = int(np.abs(audio).max()) print(f"frames captured: {len(audio)}") print(f"peak amplitude: {peak} out of 32767") if peak == 0: print("WARNING: every sample is zero - nothing reached the mic.") return peakdef save(audio, filename): """Write a 16-bit mono WAV file.""" with wave.open(filename, "wb") as wav: wav.setnchannels(CHANNELS) wav.setsampwidth(SAMPLE_WIDTH) wav.setframerate(SAMPLE_RATE) wav.writeframes(audio.tobytes()) print(f"Saved {filename}")def load(filename): """Read a WAV file back and print the header it declares.""" with wave.open(filename, "rb") as wav: frames = wav.getnframes() rate = wav.getframerate() print(f"channels: {wav.getnchannels()}") print(f"sample width: {wav.getsampwidth()} bytes") print(f"frame rate: {rate} Hz") print(f"frames: {frames}") print(f"duration: {frames / rate:.2f} seconds") raw = wav.readframes(frames) return np.frombuffer(raw, dtype=np.int16).reshape(-1, CHANNELS)def play(audio): """Send audio to the default output device and wait for it to finish.""" print("Playing back...") sd.play(audio, samplerate=SAMPLE_RATE) sd.wait() print("Playback finished.")def main(): parser = argparse.ArgumentParser( description="Record audio, save it as a WAV file, and play it back.") parser.add_argument("--seconds", type=float, default=3.0, help="how long to record (default: 3)") parser.add_argument("--output", default="output.wav", help="file to write (default: output.wav)") parser.add_argument("--tone", action="store_true", help="use a test tone instead of the microphone") args = parser.parse_args() if args.tone: audio = test_tone(args.seconds) else: audio = record(args.seconds) describe(audio) save(audio, args.output) reloaded = load(args.output) print(f"round-trip identical: {np.array_equal(audio, reloaded)}") play(reloaded)if __name__ == "__main__": main()test_tone is the only function here you have not seen. np.linspace lays out the instants
at which to measure, np.sin(2 * np.pi * hz * t) gives a 440 Hz wave swinging between -1 and
1, and * 0.3 * 32767 scales it to 30 percent of full volume so it is audible without being
painful. That is where the tone's peak of 9830 comes from: 0.3 times 32767. The final
.reshape(-1, CHANNELS) gives it the same one-column shape sd.rec() returns, so the rest of
the pipeline cannot tell the two apart.
Run it:
python recorder.py --seconds 3Expected output:
Recording 3.0 seconds...Recording finished.frames captured: 132300peak amplitude: 15 out of 32767Saved output.wavchannels: 1sample width: 2 bytesframe rate: 44100 Hzframes: 132300duration: 3.00 secondsround-trip identical: TruePlaying back...Playback finished.Now try it with no microphone involved at all:
python recorder.py --tone --seconds 2 --output tone.wavframes captured: 88200peak amplitude: 9830 out of 32767Saved tone.wavchannels: 1sample width: 2 bytesframe rate: 44100 Hzframes: 88200duration: 2.00 secondsround-trip identical: TruePlaying back...Playback finished.You have a working tool. --tone exercises the file half of the pipeline without any hardware
at all, which is exactly what you want when you are trying to work out whether a problem is
your code or your microphone.
How to record audio with PyAudio instead
PyAudio is the library most older Python audio tutorials use, including the earlier version of this one. It still works, and you will meet it in other people's code, so it is worth being able to read. It is not what this tutorial recommends starting with, for reasons that are mostly about installation.
PyAudio ships wheels for Windows only, for CPython 3.8 through 3.13. There is no macOS
wheel and no Linux wheel, and there never has been. On those two platforms pip install pyaudio always compiles from source, which means you need the PortAudio development headers
installed first:
sudo apt install portaudio19-dev python3-all-dev # Debian, Ubuntusudo dnf install portaudio-devel # Fedorabrew install portaudio # macOSThere is also no PyAudio wheel for Python 3.14 on any platform, including Windows. Since 3.14
is now the default download on python.org, a beginner installing Python today and running
pip install pyaudio gets a source build on every operating system.
On Debian and Ubuntu you can skip the build entirely with the distribution package:
sudo apt install python3-pyaudioThis section needs a package the rest of the tutorial does not. Nothing above or below depends on it, so skip this section entirely if you only came for a working recorder. To run the two scripts here, install it now:
pip install pyaudio==0.2.14On Windows that is a prebuilt wheel and takes a couple of seconds. On macOS and Linux it compiles, so install the headers above first or it will fail.
Put the following in a new file called recorder_pyaudio.py, beside recorder.py. It replaces
nothing. It is the same recorder, and it records, saves and plays without any third library, by
using PyAudio's own output stream:
"""The same recorder written with PyAudio instead of sounddevice."""import waveimport pyaudioCHUNK = 1024FORMAT = pyaudio.paInt16CHANNELS = 1RATE = 44100SECONDS = 3FILENAME = "pyaudio_output.wav"p = pyaudio.PyAudio()stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK)print(f"Recording {SECONDS} seconds...")chunks = []for _ in range(int(RATE / CHUNK * SECONDS)): chunks.append(stream.read(CHUNK, exception_on_overflow=False))print("Recording finished.")stream.stop_stream()stream.close()audio_bytes = b"".join(chunks)print(f"chunks: {len(chunks)} bytes: {len(audio_bytes)}")with wave.open(FILENAME, "wb") as wav: wav.setnchannels(CHANNELS) wav.setsampwidth(p.get_sample_size(FORMAT)) wav.setframerate(RATE) wav.writeframes(audio_bytes)print(f"Saved {FILENAME}")with wave.open(FILENAME, "rb") as wav: out = p.open(format=p.get_format_from_width(wav.getsampwidth()), channels=wav.getnchannels(), rate=wav.getframerate(), output=True) print("Playing back...") data = wav.readframes(CHUNK) while data: out.write(data) data = wav.readframes(CHUNK) out.stop_stream() out.close()print("Playback finished.")p.terminate()Run it:
python recorder_pyaudio.pyExpected output:
Recording 3 seconds...Recording finished.chunks: 129 bytes: 264192Saved pyaudio_output.wavPlaying back...Playback finished.Three differences are worth noticing, because they are the whole reason this tutorial leads
with sounddevice.
PyAudio reads in chunks, so you rarely get the duration you asked for. int(44100 / 1024 * 3) is 129, and 129 chunks of 1024 frames is 132,096 frames, which is 2.995 seconds rather than
3. The sd.rec() version captured exactly 132,300. The difference is inaudible, and it is
still the kind of thing that is easier not to have to explain.
p.terminate() must come last. It shuts PortAudio down. Opening another stream on the same
PyAudio object afterwards fails with OSError: [Errno -9996] Invalid input device (no default output device), on a machine whose microphone is working perfectly. Recording inside a
function that calls terminate() and then trying to play back is a genuinely common way to hit
this.
PyAudio crashes where sounddevice raises. Asking for a device index that does not exist
segfaults the interpreter, reproducibly, with no traceback and nothing to catch:
Put this in a scratch file called crash_demo.py:
"""Ask PyAudio for a device index that does not exist."""import pyaudiopyaudio.PyAudio().open(format=pyaudio.paInt16, channels=1, rate=44100, input=True, input_device_index=999)python crash_demo.pyIn a POSIX shell that reports:
Segmentation faultWindows shells report the same crash differently, because there is no signal name to print.
PowerShell prints nothing at all and sets $LASTEXITCODE to -1073741819, which is
0xC0000005, the access-violation code. Either way the process dies and no except block
anywhere can help you.
The same mistake in sounddevice gives you a normal Python exception naming the device:
sounddevice.PortAudioError: Error querying device 999For a beginner, that difference matters more than any API detail.
Python audio errors and fixes
Every string below is what actually gets printed. Search for yours.
ModuleNotFoundError: No module named 'sounddevice'
The package is not installed in the interpreter you are running. This is almost always a virtual environment mismatch rather than a failed install. Check which Python you are using:
python -c "import sys; print(sys.executable)"pip --versionIf those point at different installations, use python -m pip install sounddevice==0.5.6 so
that pip and Python are guaranteed to be the same one.
The PyAudio section throws the identical error with a different package name:
ModuleNotFoundError: No module named 'pyaudio'. Same cause, same fix - swap the name in the
command above. If you never ran the pip install pyaudio==0.2.14 from that section, that is
your answer.
OSError: PortAudio library not found
Linux only, and it means the wheel installed but the C library it needs is missing, because the Linux wheel does not bundle PortAudio. Install it:
sudo apt install libportaudio2fatal error: portaudio.h: No such file or directory
You are building PyAudio from source without the PortAudio development headers. This is the gcc wording, which is what you get on Linux:
src/pyaudio/device_api.c:9:10: fatal error: portaudio.h: No such file or directory #include "portaudio.h" ^~~~~~~~~~~~~compilation terminated.error: command 'x86_64-linux-gnu-gcc' failed with exit status 1Fix it by installing the headers first, then retry the install: sudo apt install portaudio19-dev python3-all-dev on Debian and Ubuntu, sudo dnf install portaudio-devel on
Fedora.
fatal error: 'portaudio.h' file not found
The same failure on macOS, where clang words it differently. A reader searching the gcc string will never find a page carrying only this one, so both are here:
src/pyaudio/device_api.c:9:10: fatal error: 'portaudio.h' file not found#include "portaudio.h" ^~~~~~~~~~~~~1 error generated.error: command '/usr/bin/clang' failed with exit code 1Fix it with brew install portaudio, then retry the install.
ERROR: Could not build wheels for pyaudio
The summary line that follows either error above. Same cause, same fix.
OSError: [Errno -9996] Invalid input device
PortAudio cannot use the device it was given. Three different causes produce this one string:
- There is genuinely no input device. Normal in WSL, Docker and CI runners. Confirm with
python -m sounddevice, which prints the device list with no script at all. An empty or output-only list is your answer. Use the--toneflag from step 7 to carry on without a microphone. - You passed a device index that does not exist. Re-run step 1 to get the real index.
p.terminate()was called earlier. PortAudio is shut down, so the nextopen()fails even though the hardware is fine. Moveterminate()to the end.
Note that PyAudio really does print Invalid input device (no default output device) in some
versions, saying output when it means input. That mismatch is in PyAudio, not a typo here.
OSError: [Errno -9981] Input overflowed
Your program did not read from the stream fast enough, and PortAudio dropped samples. It is a
PyAudio-path error; sd.rec() does not expose it. The usual patch is to stop it raising:
data = stream.read(CHUNK, exception_on_overflow=False)Be aware of what that does. It does not stop samples being dropped, it stops you being told.
The symptom becomes a WAV file shorter than the wall-clock time you recorded for. If that
matters, raise CHUNK to 2048 or 4096 instead, which gives your loop more slack.
Invalid number of channels [PaErrorCode -9998]
The message from sd.rec() is one long line. This is its tail, which is the part worth
searching for:
Invalid number of channels [PaErrorCode -9998]It is preceded on the same line by sounddevice.PortAudioError: Error opening InputStream: .
You asked for more channels than the device has. Step 1 printed max channels for your device,
and most microphones are mono, so CHANNELS = 1.
Worth knowing: PyAudio reports the identical PortAudio failure as OSError: [Errno -9998] Invalid number of channels. Same underlying error, two different strings, so searching one
will not find pages about the other.
wave.Error: unknown format: 65534
You are reading a WAV in WAVE_FORMAT_EXTENSIBLE form, which Windows capture paths produce
routinely, on a Python older than 3.12. Support for it was added in Python 3.12. Upgrade
Python, which is the reason this tutorial pins 3.13.
wave.Error: sample width not specified
You called writeframes() before setsampwidth(). All three of setnchannels, setsampwidth
and setframerate must be set before the first write.
A wall of ALSA lib pcm.c ... Unknown PCM messages on Linux
ALSA lib pcm_dsnoop.c:606:(snd_pcm_dsnoop_open) unable to open slaveALSA lib pcm.c:2266:(snd_pcm_open_noupdate) Unknown PCM cards.pcm.rearALSA lib pcm.c:2266:(snd_pcm_open_noupdate) Unknown PCM cards.pcm.center_lfeThis is not an error and your recording is fine. The same wall of messages turns up when
running a local LLM voice bot on a Raspberry Pi,
where it is equally harmless. ALSA is looking for optional virtual
devices your sound card does not have, and writing complaints straight to stderr from C, which
is why Python's logging cannot suppress them. The line numbers differ between ALSA versions,
so yours may not match exactly. sounddevice silences this during initialisation, and PyAudio
passes it straight through.
The recording is silent, but nothing errored
On macOS this is the standard symptom of a denied microphone permission. A denied process is handed silence rather than an error, so you get a correct-length, correct-size, completely empty WAV file.
Grant access under System Settings, Privacy and Security, Microphone. The important detail:
the permission belongs to your terminal application, not to Python. Look for Terminal,
iTerm or Visual Studio Code in that list, not python. You are only asked once, and dismissing
the prompt counts as a refusal.
The peak amplitude check from step 3 catches this immediately, which is the reason it is in the tutorial.
ERROR: Could not build wheels for simpleaudio
Older audio tutorials, including the previous version of this one, used simpleaudio for
playback. Do not.
Its last release was November 2019, its newest wheel is for CPython 3.8, and its repository no
longer accepts new issues. Because no wheel exists for any current interpreter, pip install simpleaudio always compiles a C extension from source. On a machine that happens to have a
working C toolchain, that succeeds and you may never notice. On a machine without one it fails,
and which of the two you get is not something a tutorial can predict for you:
- Windows without the Microsoft C++ Build Tools stops at
error: Microsoft Visual C++ 14.0 or greater is required. - Linux without the ALSA development headers stops at a missing
alsa/asoundlib.h.
You can see the underlying problem without needing a compiler either way. Ask pip for a wheel and refuse a source build:
pip install --only-binary=:all: simpleaudioThe last line that comes back is:
ERROR: No matching distribution found for simpleaudioFurther up the same output, pip says (from versions: none), and that is the whole story.
There is nothing prebuilt for your Python, so every install is a gamble on your machine's
toolchain.
sd.play() from step 6 replaces it, and PyAudio's own output stream replaces it if you are on
the PyAudio path. Neither needs an extra dependency.
A note on sample rate errors
You will find plenty of pages about OSError: [Errno -9997] Invalid sample rate. It is real,
but it is platform-dependent rather than universal. On Windows, shared-mode WASAPI resamples
for you, and a nonsense rate like 7777 Hz is accepted silently on a device whose default is
44100. On Linux with ALSA the same request usually fails outright. If you are writing code that
has to run on both, do not rely on an unsupported rate raising an error. Read the device's
default_samplerate from step 1 and use that.
How the Python audio pipeline fits together
This is the path a single sample takes, from air to file and back:
flowchart TD
MIC[Microphone] --> PA[PortAudio C library]
PA --> SD[sounddevice sd.rec]
SD --> ARR[NumPy int16 array]
ARR --> TB[tobytes]
TB --> WAV[wave writeframes]
WAV --> FILE[(output.wav)]
FILE --> RD[wave readframes]
RD --> FB[frombuffer]
FB --> ARR2[NumPy int16 array]
ARR2 --> PLAY[sounddevice sd.play]
PLAY --> PA2[PortAudio C library]
PA2 --> SPK[Speakers]
style MIC fill:#4A90E2,color:#FFFFFF
style SPK fill:#4A90E2,color:#FFFFFF
style PA fill:#95A5A6,color:#FFFFFF
style PA2 fill:#95A5A6,color:#FFFFFF
style SD fill:#7B68EE,color:#FFFFFF
style PLAY fill:#7B68EE,color:#FFFFFF
style ARR fill:#98D8C8,color:#2C2C2A
style ARR2 fill:#98D8C8,color:#2C2C2A
style TB fill:#FFD93D,color:#2C2C2A
style FB fill:#FFD93D,color:#2C2C2A
style WAV fill:#6BCF7F,color:#2C2C2A
style RD fill:#6BCF7F,color:#2C2C2A
style FILE fill:#C2185B,color:#FFFFFF
Read left to right, the diagram makes two things obvious that the code does not. The pipeline
is symmetrical: every step on the way to disk has a mirror on the way back, and
round-trip identical: True from step 5 is the assertion that the two halves agree. And
PortAudio sits at both ends, which is why one missing system library breaks recording and
playback together rather than one at a time.
The complete Python audio recorder script
Your working directory after following along:
audio-recorder/├── find_microphone.py # step 1, the device probe├── recorder.py # steps 2 to 7, the finished tool├── show_broken.py # step 5, the wrong-sample-width demo├── recorder_pyaudio.py # the PyAudio alternative├── crash_demo.py # the PyAudio segfault demo├── output.wav # produced by a microphone run├── tone.wav # produced by --tone├── broken.wav # produced by show_broken.py└── pyaudio_output.wav # produced by recorder_pyaudio.pyIf you also ran the virtual environment recipe below, there is a .venv/ folder alongside
those.
The complete recorder.py is in step 7 above, in full and ready to copy. find_microphone.py
is in step 1, show_broken.py is in step 5, and recorder_pyaudio.py and crash_demo.py are
in the PyAudio section. Nothing on this page is left as an exercise.
To reproduce the environment from scratch:
python -m venv .venvsource .venv/bin/activatepip install sounddevice==0.5.6 numpy==2.5.3python recorder.py --seconds 3The activate line differs by shell. Use .venv\Scripts\activate in Windows cmd or
PowerShell, and source .venv/Scripts/activate in Git Bash on Windows. If python is not
found on Linux, try python3, which is the default name on distributions that do not install
the python-is-python3 package.
Where to go next
Record until the speaker stops talking, instead of for a fixed time. You already compute
peak amplitude in describe(). Move that calculation into a loop over sd.InputStream, count
consecutive blocks below a threshold, and stop after about two seconds of them. The threshold
table in step 3 gives you sensible starting values.
Send the WAV to a speech-to-text API. That is the starting point for
a full audio bot built on Whisper and RAG,
which consumes this exact WAV format. The file you are producing, 16-bit mono PCM, is the
format most of them ask for, though many prefer 16000 Hz to 44100 Hz. Change SAMPLE_RATE to
16000 and nothing else: three seconds drops from 264,644 bytes to 96,044, about 64 percent
smaller, and speech stays perfectly intelligible because almost nothing useful in a human voice
lives above 8 kHz.
Write to disk as you record, rather than buffering in memory. Three seconds is 264 KB, and
thirty minutes is about 150 MB sitting in RAM. Open the wave file first, then use
sd.InputStream with a callback that calls writeframes() on each block as it arrives, so
memory stays flat regardless of length.
References
- Geier, M. python-sounddevice documentation, version 0.5.6. https://python-sounddevice.readthedocs.io/en/0.5.6/
- Geier, M. python-sounddevice: Installation. https://python-sounddevice.readthedocs.io/en/0.5.6/installation.html
- Python Software Foundation. wave: Read and write WAV files. https://docs.python.org/3/library/wave.html
- Python Software Foundation. What's New In Python 3.12. https://docs.python.org/3/whatsnew/3.12.html
- Python Software Foundation. Download Python. https://www.python.org/downloads/
- Pham, H. PyAudio Documentation, v0.2.14. https://people.csail.mit.edu/hubert/pyaudio/docs/
- PyAudio on PyPI, wheel list and release dates. https://pypi.org/project/PyAudio/
- PortAudio. https://www.portaudio.com/
- PortAudio contributors.
src/common/pa_front.c,Pa_GetErrorText(). https://github.com/PortAudio/portaudio/blob/master/src/common/pa_front.c - NumPy developers. numpy.ndarray.tobytes. https://numpy.org/doc/stable/reference/generated/numpy.ndarray.tobytes.html
- simpleaudio on PyPI, last release 2019-11-29. https://pypi.org/project/simpleaudio/
- Microsoft. WAVEFORMATEXTENSIBLE structure. https://learn.microsoft.com/en-us/windows/win32/api/mmreg/ns-mmreg-waveformatextensible
Related Articles
More Articles
- How to Rerank Retrieval Results with a Cross-Encoder
- BM25 vs Dense Retrieval: Measure It on Your Own Corpus
- Build a Kill Switch for a LangGraph Agent
- How SynthID Works: Build a Watermark in Python



