Skip to content

Repository files navigation

audioif

CircuitPython's audio system for MicroPython and CPython — audiocore, audiomixer, synthio (including MidiTrack), the effects modules (audiofilters, audiodelays, audiofreeverb, audiospeed), audiomp3, and CircuitPython play()/stop()/pause()/resume() output semantics. Import names stay audiocore/synthio/etc., matching CircuitPython for source compatibility.

Installation

MicroPython consumes this repository as USER_C_MODULES, and it is fully standalone for both build flavors — no other repository is required. ./scripts/fetch_deps.sh puts the two pinned native dependencies (ulab, mp3) under .deps/, and both micropython.mk and micropython.cmake look there first, falling back to a sibling checkout beside this repo and otherwise failing the build with an error naming both paths.

For a CMake port (esp32, rp2), point USER_C_MODULES straight at this checkout:

git clone https://github.com/PyDevices/audioif ~/build/audioif
cd ~/build/audioif && ./scripts/fetch_deps.sh
idf.py build -DUSER_C_MODULES=~/build/audioif

For a Make port (unix, windows, webassembly), MicroPython's own build glob looks one level down, so point USER_C_MODULES at this checkout's parent directory instead:

git clone https://github.com/PyDevices/audioif ~/build/audioif
cd ~/build/audioif && ./scripts/fetch_deps.sh
git clone https://github.com/micropython/micropython ~/build/micropython
cd ~/build/micropython/mpy-cross && make
cd ~/build/micropython/ports/unix && make submodules
make USER_C_MODULES=~/build

The two dependencies are ulab (so synthtools's import ulab.numpy works) and mp3 (the audiomp3 tier's decoder), pinned by DEPENDENCIES.lock. To build without them, skip the fetch and set AUDIOIF_OPTIONAL_DEPS=1 on the build step instead — the variable is read by micropython.mk/micropython.cmake, not by fetch_deps.sh, and on CMake ports it is read as an environment variable, not as a -D cache entry:

make USER_C_MODULES=~/build AUDIOIF_OPTIONAL_DEPS=1              # Make ports
AUDIOIF_OPTIONAL_DEPS=1 idf.py build -DUSER_C_MODULES=~/build/audioif   # CMake

That builds every module except audiomp3 with no clone beyond this repository; synthtools' import ulab.numpy then fails at runtime, which is the trade the flag buys. See docs/porting-plan.md for the architecture, module tiers, phased plan, and testing strategy.

CPython 3.10+ installs from TestPyPI:

python -m pip install --index-url https://test.pypi.org/simple/ pydevices-audioif

This gets you audiocore, synthio, audiomixer, audiofilters, audiodelays, audiofreeverb, audiospeed, audiodynamics, audioroute, audiomath, audioecho, audioshaper, audioladder, audioconvolve, audiobiquad, audioverb, and the audiorender package. audiomp3 remains firmware-only. The distribution declares no required runtime dependencies and does not itself publish an audioif import; its version is the VERSION file, which is also what _audioif.__version__ reports.

audiorender is the exception, and it is opt-in: it is numpy throughout, so numpy comes with the render extra rather than with the wheel, keeping the core dependency-free for boards and wasm. TestPyPI carries no usable numpy, so the extra needs PyPI as a second index:

python -m pip install --index-url https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple/ "pydevices-audioif[render]"

The instrument and effect libraries — audioinstruments (53 synthesizers, keyboards and drum machines) and audioeffects (46 effect classes, racks included) — are not part of this distribution. They live in the audiocomponents repository as their own packages, each depending on pydevices-audioif; see that repository for how to install them.

What's here

One pure-Python tier sits on top of the CircuitPython-compatible core:

  • lib/audiorender/ — renders a whole composition offline: tracks, tempo map, notes and automation in, a mixed stereo master and a level report out. This is the one part of the repository written for a desktop rather than a board — numpy throughout, a whole song in memory — so it ships in the wheel and is never frozen into firmware. It has its own README.

The instrument and effect libraries that used to sit beside it — audioinstruments and audioeffects — now live in the audiocomponents repository, together with the audio component contract they implement: the metadata manifest (docs/audio-components.md) and the runtime API (docs/audio-component-api.md). audiorender drives any component that speaks that API and does not need those packages installed.

Additions beyond CircuitPython

Ten things here are not CircuitPython's. audiodynamics (compression, limiting, expansion, gating, transient shaping) and audioroute.Splitter (fan one stream out to parallel branches) come from micropython-vst3's audio engine, which had them and CircuitPython does not. audiomath (multiply one stream by another — ring and amplitude modulation; and divide one down in frequency — the analog octave divider), audioecho (a delay with a filter, a soft-clip and a cross-feed inside its feedback loop), audioshaper (a waveshaper whose curve is data, applied above the sample rate), audioladder (a transistor ladder filter — four one-pole stages round a feedback loop with an odd saturator inside it), audioconvolve (apply a measured or synthesized impulse response, by partitioned FFT), audioverb (a reverberation tank whose line lengths and output taps come from Python), audioroute.MidSide (scale the difference between a stereo pair's channels) and audiobiquad (below) have no ancestor anywhere and are audioif's own. apply_cp_patches.sh adds every one of them to a CircuitPython tree too.

audiobiquad — filters whose tails reach exact zero

audiofilters.Filter (over synthio.Biquad) and audiofilters.Phaser are ported CircuitPython and their recursions are integer. Both have states that reproduce themselves: fed silence after a burst, they can hold a constant of one to a few LSB for ever. audiobiquad is the same two filters with float state and a flush of anything below 1e-20, so a tail decays to zero and stays there — and its all-pass feedback is not clamped to 0.1..0.9, so zero is zero and a feedback-free phaser's notches are true nulls.

import audiobiquad, synthio

eq = audiobiquad.Biquad(mode=audiobiquad.PEAKING_EQ, frequency=3200,
                        Q=1.2, gain_db=-4.0, sample_rate=48000)
eq.play(source)

sweep = synthio.LFO(rate=0.4, scale=400.0, offset=900.0)
phase = audiobiquad.AllPass(stages=4, frequency=sweep, feedback=0.0,
                            mix=0.5, sample_rate=48000)
phase.play(eq)
Biquad AllPass
constructor mode, frequency, Q, gain_db, mix, sample_rate, channel_count stages, frequency, feedback, mix, sample_rate, channel_count
block inputs frequency, Q, gain_db, mix frequency, feedback, mix
fixed at construction sample_rate, channel_count those, plus stages (it sizes the state)
methods play, stop, clear play, stop, clear
read-only playing, coefficients playing, stages, coefficient
latency 0 samples 0 samples

mode is one of LOW_PASS, HIGH_PASS, BAND_PASS, NOTCH, PEAKING_EQ, LOW_SHELF, HIGH_SHELF — numbered exactly as synthio.FilterMode numbers them, so either can be handed to either. mix crossfades: 0 is a wire, 1 is the filtered signal alone; for an all-pass cascade 0.5 is the equal sum a script phaser makes, and where its notches are deepest. AllPass's frequency really is the frequency at which one stage's phase passes −90°, so nothing has to pre-warp it. Both nodes hand out 256 frames at a time, sit in an audiosample chain like any other effect, and never report themselves finished — a starved chain gets silence. Q is bounded to 0.05..60 and feedback to ±0.99: a pole on the unit circle is a filter that never stops ringing, which is the one thing this module exists to avoid. See docs/upstream-diff.md for the measurements.

audiodynamics.Dynamics carries twenty-one options beyond what the engine gave it, added for the effects program and every one of them default-off: an RMS detector, a feedback detector topology and an external key input with Key Listen, a 4x true-peak reconstruction, a two-pole key band with both corners settable, a settable expander/gate depth, a four-stage gate envelope with hold and hysteresis, a relative-threshold gain computer, a program-dependent attack, and the transient shaper's four detector time constants, a second envelope pair and a peak-hold on the slow one. What each is for, what it was measured doing, and what it cost: docs/upstream-diff.md.

audioecho.FeedbackDelay takes four further options, each off at its default. wow_shape swaps the built-in modulation sine for one period of your own — int16 Q15, a power-of-two count from 2 to 4096 — because a bucket brigade's delay is its line length over its clock, so a triangle on the clock is a reciprocal on the delay and no sine is that. delay_slew walks the read head to a new delay_ms at a constant rate (delay-seconds per second) instead of jumping to it, which is both an Echoplex's varispeed and the reason a delay-time knob can be turned mid-take without a click. wow_am_depth puts the same oscillator on the wet gain, dipping only. loop_semitones pitch-shifts the line read inside the loop, so every repeat rises again — a shimmer, which chaining audiodelays.PitchShift after a delay is not. See docs/upstream-diff.md for what each was asked for and what it measures.

audioroute.MidSide(source, width=1.0, sample_rate=48000, channel_count=2) takes a stereo pair apart into its mono sum and the difference between its channels, scales the difference, and puts the pair back together. width=0 collapses to mono, 1 passes through, 2 doubles the sides; values outside 0..2 clamp to those rails. play(sample) sets the source, set(width=...) moves the width mid-stream. There is no state and no latency — and at width=1 the output bytes are the input bytes, exactly, for every int16 pair, so the node costs nothing to leave in a chain that is not using it. A mono source passes through: there is no difference to scale. It exists for the drive classes as much as for stereo width: a nonlinearity applied to a stereo pair intermodulates its channels, so a saturator that wants to keep its image drives the mid and leaves the side alone.

audioshaper.Waveshaper(sample_rate=…, curve=…, oversample=4, channel_count=2, **options) is the newest of them, and everything but the first four moves in set(). curve is int16 Q15, at least two points, spanning −1..+1 of input: compute it once on a desktop and ship it as data, never rebuild it on a board, whose float is single-precision where the desktop's is double. oversample is 1, 2, 4 or 8 — the shaping happens there, between a matched pair of polyphase all-pass half-bands, because a nonlinearity at the base rate folds the harmonics it makes above Nyquist straight back onto the signal. pre_gain is the drive knob (gain into one normalised curve, never a curve rebuilt per knob move), bias moves the operating point, post_gain follows the curve, and mix is a straight 0..1 crossfade. hysteresis is off by default; above zero it gives the curve a memory, so a slow signal in and out traces two paths and encloses an area, with hysteresis_width its half-width as a fraction of full scale at the input and hysteresis_bias splitting that between the rising and falling branches. audioshaper.GROUP_DELAY_SAMPLES carries the measured group delay per factor — 0, 2.2, 3.3 and 3.9 base samples — for a component that has to report its latency. Why each of those is the shape it is, and what the oversampling measures: docs/upstream-diff.md, "audioshaper: audioif's own, and the two things a fixed curve cannot be".

audioladder is the newest, and the one whose reason for existing is least obvious next to a module CircuitPython already has. audiofilters.Filter is a better resonant low-pass than this: a cascade of biquads tracks the analytic response to a fraction of a decibel. It is also linear, so it never sustains a tone of its own, has no harmonics to speak of, and sounds exactly the same however hard you hit it — and those three are what a ladder is. The whole surface:

import audioladder

ladder = audioladder.Ladder(
    sample_rate=48000,      # and channel_count=1 or 2
    cutoff_hz=800.0,        # where the filter turns over
    resonance=3.6,          # 0..4.2; at 4 it sustains a tone at the cutoff
    drive=2.5,              # linear gain into the loop, 1 being unity
    poles=4,                # tap stage 1..4: 6, 12, 18 or 24 dB an octave
    passband_comp=0.0,      # 0..1, how much of the passband to give back
    oversample=2,           # 1 or 2; 2 folds back fewer of its harmonics
    mix=1.0,                # 0 a wire, 1 the filter
)
ladder.play(source)
ladder.set(cutoff_hz=1200.0)   # any option, mid-stream, once a block
ladder.clear()                 # empty the integrators; stops a sustained tone

See docs/upstream-diff.md for why its feedback loop is solved rather than delayed, and what that was measured to be worth.

audioverb.Tank

Dattorro's plate reverberator, with the network handed in rather than compiled in — which is the difference from audiofreeverb.Freeverb, whose comb and all-pass lengths are constants in a CircuitPython-ported kernel.

plate = audioverb.Tank(
    sample_rate=48000, channel_count=2, max_predelay_ms=200,
    decay=0.7, diffusion=0.75, bandwidth_hz=9000, damping_hz=4500,
    mod_rate_hz=1.0, mod_depth_ms=0.27, mix=0.35)
plate.play(source)
audio_out.play(plate)

Construction fixes the allocation and the topology: sample_rate, channel_count (1 or 2), max_predelay_ms, delays and taps. delays is twelve line lengths in frames — the four input diffusers, then each tank half's modulated all-pass, delay, all-pass and delay. taps is four values per tap (channel, line index, offset in frames, gain), at most 32. Both default to Dattorro's published table, scaled from 29761 Hz to sample_rate.

set() moves the rest, mid-stream and without emptying the lines: decay, diffusion, damping_hz, bandwidth_hz, low_cut_hz, predelay_ms, mod_depth_ms, mod_rate_hz, drive, width, tone_db, mix. Every filter is out of the path at zero, so a bare Tank() is that network with nothing added to it; mix follows audiodelays.Echo's 0..2 convention, dry at unity until 1. clear() empties every line and every filter. Latency is zero dry-to-wet, and the tail reaches exact zero rather than sitting at one LSB — every line write is a magnitude truncation, which is the standard cure for a recirculating fixed-point network's limit cycles. See docs/upstream-diff.md for the measurement, the cost and the memory the default table needs.

Status

MicroPython: all module tiers ported and oracle-diffed byte-for-byte against bin/circuitpython on unix; DSP parity re-verified on windows and wasm; built and measured on two real mcu targets, ESP32-P4 (hardware-confirmed by ear) and RP2040 (build-only). See docs/porting-plan.md for the full phased history and docs/upstream-diff.md for every deliberate deviation from upstream CircuitPython.

CPython: the public surface and wheel plumbing are present, and the committed synthesis, mixer, MIDI, streaming, and effects fixtures match CircuitPython 10.2.1 PCM byte-for-byte. Import/API smoke success is not used as a substitute for those oracle comparisons.

ulab and mp3 (the vendored Adafruit_MP3/Helix decoder audiomp3 depends on, RPSL/RCSL-licensed — not MIT, carried unmodified per upstream's own terms) are consumed as cloned sibling dependencies in the parent workspace, same pattern as pygraphics/displayif, not vendored into this repo.

The playback-facing pull protocol (audiocore.get_buffer/reset_buffer) is consumed by pydevices' lib/audiodev package (AudioOut in sample_out.py), which pumps any audiosample into any of audiodev's existing push-PCM transports (sdl2/win/wasm/i2s/emulated) — see that package's own docs for the playback-side contract this repo's protocol implementation is built to satisfy.

Acceptance target: todbot's synthtools running unmodified, with rendered PCM diffed against bin/circuitpython's own unix coverage build (which already contains the entire DSP stack — the parity oracle for this whole port).

Sound stability

The API is our contract with you: class names, signatures, metadata, and macro surfaces stay stable and change only deliberately. The sound is not part of that contract. These components sound great, but they are not all as accurate as they could be, and implementations will keep being refined as the library matures — so a component may render audibly differently from one release to the next. If a composition depends on the exact sound of a release, pin that release rather than tracking the latest; the code of every release stays available for exactly this reason.

Beneath the components sits a harder guarantee: the audioif core — the CircuitPython-compatible synthio/audiocore/effects-module layer — is held bit-exact to CircuitPython itself, verified by parity gates, and that never changes release to release. Where we find CircuitPython and audioif disagree, we treat it as a bug and report it upstream. The components are where the sound evolves; the floor they stand on does not.

License

audioif is MIT licensed — see LICENSE.

The attribution that goes with it lives in NOTICE: substantial portions of this code are ported from CircuitPython under its own MIT license, CircuitPython is a trademark of Adafruit Industries and this project is not affiliated with or endorsed by them, and the vendored test corpus under tests/vendor/ carries its own licenses. The two are separate files on purpose — GitHub and PyPI only detect the license when LICENSE holds the MIT text and nothing else, so please do not fold NOTICE back into it. NOTICE is in setuptools' default license-files glob, so it ships in the wheel and sdist alongside LICENSE.

The Helix MP3 decoder that audiomp3 wraps is RPSL/RCSL-licensed, not MIT; it is a cloned sibling dependency, not part of this repository. See docs/upstream-diff.md.

About

CircuitPython's audio system (audiocore, synthio, audiomixer, effects, audiomp3), ported to MicroPython.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages