Portable audio
August 25, 2026 · View on GitHub
The audio surface is split by direction: boards expose audio_out and/or
audio_in.
Playback: the audiosample contract
audio_out returns an audiodev.sample_out.AudioOut: play(sample, loop=False), stop(), pause(), resume(), playing. sample is anything
satisfying CircuitPython's audiosample pull protocol — a
synthio.Synthesizer, an audiomixer.Mixer, an audiocore.RawSample /
WaveFile, or any effect chained on top of one (audiofilters,
audiodelays, audiofreeverb, audiospeed), all provided by the
audioif usermod (import synthio, import audiomixer, ...).
This is the same object shape CircuitPython code already expects from
audiobusio.I2SOut/audioio.AudioOut — a synth built for a CircuitPython
tutorial runs unchanged against this port's AudioOut.
On CircuitPython boards (board_configs/cp/) the contract is satisfied
natively: there is no audio_out role or AudioOut wrapper at all — CP
apps construct audiobusio.I2SOut(...)/audioio.AudioOut(...) directly (see
docs/board-peripherals.md: CP boards have no board_peripherals.py). Same
protocol shape, zero adapter, because CircuitPython's own output devices
already speak it.
On MicroPython, AudioOut is a pure-Python pump wrapping a push-based
transport (below): it pulls PCM from the sample via
audiocore.get_buffer()/reset_buffer() on a lookahead schedule and pushes
it into the transport's write(). This is the only sound architecture here,
not a stopgap: every real backend's bottom layer is a push+queued transport
(SDL_QueueAudio, WASAPI, the wasm bridge, machine.I2S.write), and an
audio-thread callback into Python is unsafe even in C once it would need to
pull a synth's block graph, which allocates on the GC heap (documented in
sdl2_audio.py). So playback flows only while something calls
AudioOut.service() — the app tick, an asyncio pump, or attach(app) —
same requirement every queued transport already has. No resampling: a
sample's own sample_rate is not renegotiated against the transport; a
mismatch plays at the wrong pitch rather than raising, exactly like real DAC
hardware and exactly like CircuitPython's own AudioOut/I2SOut.
Low-level raw PCM streaming is still available as an escape hatch: every
AudioOut.transport is (or behaves like) a PCMOutput — write, drain,
awrite, adrain, volume/mute, codec. audiodev.auto.audio_out() returns
the bare transport for callers that want to push raw bytes without going
through the sample-pull layer at all.
Capture and tones: unchanged
audio_in returns a PCMInput (readinto/areadinto); tone/buzzer roles
return a ToneOutput (play(frequency, ...), frequency-based, not
sample-based — unrelated to AudioOut.play(sample) above). Both report an
AudioFormat plus a capabilities set; volume, gain, and mute use a
normalized 0–100 scale and select hardware controls when a codec provides
them, otherwise audiodev scales PCM in software.
Queued hosts also implement service(), queued_size(), is_active(), and
clear() on the device (no-ops on MCU).
Codec-specific features remain reachable through device.codec. Shared
half-duplex hardware uses AudioSession; opening the opposite direction while
one direction owns the session raises OSError.
Host backends (transports under AudioOut, or usable raw)
audiodev.sdl2_audio is the reference playback and real-microphone backend for
MicroPython. It uses queued SDL audio and provides the same sync and
async contract as hardware devices.
audiodev.win_audio is the Windows WASAPI backend (uwin32), selected ahead
of sdl2_audio on Windows when available.
audiodev.wasm_audio is the direct MicroPython WebAssembly backend. The
compiled bridge owns Web Audio and permission state, while Python sees only
neutral PCM buffers and queue operations. It supports mono/stereo 8-, 16-, and
32-bit signed or unsigned samples in either byte order at caller-selected
rates. A host page must explicitly enable audio or microphone access first.
audiodev.i2s_audio adapts machine.I2S for MCU boards; boards construct it
directly with board-specific pins/codec wiring, then wrap it in AudioOut.
Desktop board_peripherals may use audiodev.auto for host probe (now
MicroPython/CircuitPython-only — see below). Fixed host boards import a
concrete backend. audiodev itself does not import displaydev.
CPython (pygame_audio, web_audio): raw PCM only, no AudioOut
audiodev.pygame_audio (CPython + pygame-ce, via SDL_QueueAudio on
pygame's bundled SDL) and audiodev.web_audio (PyScript/Pyodide, via
AudioContext/getUserMedia) still exist and still work as raw
PCMOutput/PCMInput transports — pgdisplay and psdisplay use them
directly for audio_out/audio_in. Neither can back an AudioOut: both run
an interpreter that cannot load the audioif usermod (a
MicroPython C extension), so there is no audiocore.get_buffer for the pump
to pull from. audiodev.auto.select_backend() no longer offers either —
CPython sample playback is deferred until a CPython build of the usermod
exists (tracked for the eventual pdaudio spin-off), not abandoned.
Emulated devices (CI / no hardware)
audiodev.emulated_audio implements WAV files, a waveform generator, in-memory
loopback, and a discard sink. It is never auto-selected:
from audiodev import AudioFormat
from audiodev.emulated_audio import audio_in, audio_out
fmt = AudioFormat(24000, 1, 16)
out = audio_out(fmt, path="/sd/prompt.wav")
out.write(pcm_bytes)
out.close()
mic = audio_in(path="/sd/prompt.wav")
WavPCMOutput doubles as the deterministic golden-file target for testing
the whole AudioOut chain — synthio/Mixer rendered through AudioOut over
emulated_audio.audio_out(path=...), hash-compared, no audio hardware
needed. See tests/test_audiodev.py.
ESP32-P4 status
The Waveshare ESP32-P4 configuration uses one half-duplex session for its I2S
peripheral and ES8311. Playback exposes hardware DAC volume/mute and controls
the speaker amplifier through AudioOut; capture exposes hardware ADC gain.
Register, stream, session, and GPIO behavior is covered by host simulations.