The PyDevices audio component tier: audioinstruments — 53 classic
synthesizers, electromechanical keyboards and drum machines — and
audioeffects — 45 effect classes, effect racks included. Both are pure
Python built on audiodsp's audio nodes,
and both run unchanged on CPython, MicroPython and CircuitPython.
import audioinstruments, audioeffects
audioeffects.configure(48000)
minimoog = audioinstruments.create("minimoog", sample_rate=48000)
chain = audioeffects.create("TapeDelay", minimoog.output, sample_rate=48000)Hold fx.output, wire it anywhere — a mixer voice, a rack, an audio_out.play(),
a C pump pulling the graph from another thread — and every later knob move is
heard, including Mix from zero:
fx = audioeffects.create("Overdrive", source, 48000)
mixer.voice[0].play(fx.output) # take it once
fx.set_macro(fx.MACRO_LABELS.index("Mix"), 127) # and now you hear it
fx.program_change(3) # and now you hear thatThat used to be untrue and it was quiet about it. About twenty classes rebuild part of their graph when a control crosses a threshold — a Phaser changing its number of stages, a Distortion rebuilding the whole thing — and handed out a new node afterwards, so whoever was holding the old one went on playing the graph the class had finished with, or went silent. Turning a Mix macro up from 0 did nothing at all.
An effect now ends in a wire that gets re-pointed rather than replaced, so the object never changes. Two things follow from it being a wire and not a node: it is not the last node the class built, and at Mix 0 it is a wire onto the source you handed in rather than that source itself. The contract, and the one interpreter where the promise does not hold, are in docs/audio-component-api.md.
An instrument can also play a part on the audio's clock rather than on Python's, so a garbage collection does not land as a late note: docs/sequencing.md.
This repository publishes audioinstruments and audioeffects: the
pydevices-audioinstruments and pydevices-audioeffects distributions on
TestPyPI, and the audioinstruments and audioeffects entries in the MIP
index. PyDevices/audiodsp publishes
the core only — the native nodes and the CircuitPython-compatible synthio
layer these components stand on.
The release chain lives in .github/workflows/ and calls the org's reusable
workflows: prepare-release.yml opens the release PR (VERSION and
CHANGELOG.md), tag-release.yml tags the merged VERSION, and
publish-release-packages.yml runs the gates, builds both packages from the
tag, publishes them, and requests the two MIP entries. The version is a
human's to name, in the release PR; VERSION holds a placeholder until then,
and tag-release.yml refuses to tag anything that is not a release version.
The repository was seeded from audiodsp at v0.1.1 with the components' own
history intact, and the accuracy rewrite has happened here since. audiodsp
still carries its pre-rewrite copies of lib/audioinstruments/ and
lib/audioeffects/; they are retired — nothing ships from them and no fix
belongs in them. Deleting them, like replaying anything from them into this
copy, is Brad's decision, tracked in
#2 along with the
rest of the rewiring that follows the move: the MIP lockfile's repository
keys, micropython-vst3's MPVST_AUDIODSP_LIB, and the org repo database.
lib/audioinstruments/— one module per instrument, plus_support.py(the shared voice/patch/wavetable machinery) andmidi_cc.pylib/audioeffects/— the effect catalogue by family (dynamics,eq,delay,reverb,modulation,drive,pitch) plusrack.pyand_core.pydocs/audio-component-api.md— the runtime contract every component satisfies;docs/audio-components.md— the static metadata manifest;docs/sequencing.md— writing a part that keeps time in framestools/validate_api.py,tools/validate_metadata.py— the two validators that enforce those documentstests/— the CPython suites;tests/parity/— the instrument parity harness and its goldensAUDIODSP_PIN— the exact audiodsp release every gate runs againstVERSION— the version the next tag carries; a placeholder until Brad names the release.github/workflows/— CI (tests.yml,lint.yml) and the release chain
Each package keeps its own pyproject.toml under lib/<package>/, which is
both what makes it a standalone distribution and what the MIP publisher
expects. Leave that layout alone. Each pyproject.toml carries the audiodsp
floor, pydevices-audiodsp>=<release>, the newest audiodsp release; that is
not the pin — AUDIODSP_PIN may name a commit ahead of the floor, and the
gates use the pin.
python3 -m venv .venv
.venv/bin/pip install "pydevices-audiodsp @ git+https://github.com/PyDevices/audiodsp@v0.2.0"
.venv/bin/pip install -e lib/audioinstruments -e lib/audioeffectsThe pin matters. AUDIODSP_PIN names the audiodsp release the gates are read
against, for the same reason audiodsp pins CircuitPython in
CIRCUITPYTHON_ORACLE: with a floating core underneath, a component failure
is unattributable — you cannot tell a rewritten instrument from a moved node
beneath it. A local install from TestPyPI (pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pydevices-audiodsp) is fine for poking around; it is not what a gate result
may be reported against.
CI covers the structural contract, which is everything that can run without a workspace:
python -m unittest discover -s tests -p "test_*.py"
python tools/validate_api.py
python tests/parity/effects_library_smoke.py
python -m flake8Most work does not need that full suite. tools/scoped_tests.py picks the files a change can break:
| Work | Scope |
|---|---|
| One class | tools/scoped_tests.py class:DeEsser (that class's tests plus the contract) |
Kit, __init__, _core, or _component |
tools/scoped_tests.py auto (widens from git diff) |
| Every effect, no instruments | tools/scoped_tests.py effects |
| Shared promises only | tools/scoped_tests.py contract |
| Gate, integration, audit, release | tools/scoped_tests.py full |
--list prints the choice and runs nothing. Instrument tests (test_cpython_instruments, test_cpython_piano_polyphony) run only when lib/audioinstruments/ or AUDIODSP_PIN moved, or when full is named.
The instrument parity gate is workspace-local by design. It renders each component under every interpreter it can find and holds it to a hash captured from the original micropython-vst3 script, read out of that repository's git history at a fixed revision so the oracle cannot drift:
python3 tests/parity/run_instruments_parity.py --verify --batch allIt needs cmods/bin/micropython and a micropython-vst3 checkout as siblings
in the workspace, so CI does not attempt it. Comparison is always within one
interpreter — ulab's vectorized sine and libm's are different functions, so
two interpreters agreeing is an observation, never a gate.
During the accuracy rewrite these goldens are the thing being changed. A failure there is the expected outcome of a deliberate rebuild, not a regression — but it stops being a gate the moment it is re-captured casually. Re-capture is a blessing, and blessings are Brad's.
The API is our contract: 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.
Beneath the components sits a harder guarantee, and it is audiodsp's, not
ours: the CircuitPython-compatible synthio/audiocore/effects-module core
is held bit-exact to CircuitPython itself and does not change release to
release. Where CircuitPython and audiodsp disagree, that is a bug and it is
reported upstream. The components are where the sound evolves; the floor they
stand on does not.
MIT — see LICENSE.