diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 4835266d..b0781e0e 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -107,7 +107,7 @@ jobs: # The DSP nodes' real gate: every interpreter must render them # identically, which needs two interpreters, which is why it lives here # and not in test-cpython.yml. There is no stored digest to match - the - # comparison IS the gate (docs/correctness-standard.md) - and verify_dsp + # comparison IS the gate (docs/how-it-works/correctness-standard.md) - and verify_dsp # refuses a run with fewer than two interpreters rather than passing one # that cannot fail. # diff --git a/.github/workflows/test-cpython.yml b/.github/workflows/test-cpython.yml index d1d47820..a6a55531 100644 --- a/.github/workflows/test-cpython.yml +++ b/.github/workflows/test-cpython.yml @@ -99,7 +99,7 @@ jobs: # The DSP nodes' real gate is cross-interpreter agreement and it needs # two interpreters, so it lives in clean-build.yml where a MicroPython # binary is built. A one-interpreter run cannot fail and verify_dsp now - # refuses one (docs/correctness-standard.md). + # refuses one (docs/how-it-works/correctness-standard.md). # # What this matrix can say is worth having and is not a comparison: every # probe RUNS, on four operating systems and five Pythons. That catches an @@ -132,7 +132,7 @@ jobs: # runner. This job is currently the ONLY thing that builds an ARM wheel: # the org release matrix (reusable-build-native-and-wasm-wheels.yml, at # publishing-v8) runs ubuntu-latest, windows-latest and macos-latest, so - # releases still ship no aarch64 wheel. See docs/building-wheels.md for + # releases still ship no aarch64 wheel. See docs/how-it-works/wheels-and-platforms.md for # what closing that gap costs. Proving the build here first means the org # change, when it comes, is a matrix line rather than a discovery. linux-arm-wheels: diff --git a/AGENTS.md b/AGENTS.md index c74ab1b3..cddb6435 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ for source compatibility; only this repo's own name differs. offline rendering — numpy, desktop-only, never frozen) and `lib/audiodsp_util/` (`float32`, the round trip that makes a setting derived in Python the same number on a board as on a desktop — see - [docs/correctness-standard.md](docs/correctness-standard.md)). Both ship + [docs/how-it-works/correctness-standard.md](docs/how-it-works/correctness-standard.md)). Both ship inside the `omnipython-audiodsp` wheel. The instrument and effect libraries that used to sit beside it — `audioinstruments` (55 `synthio` instruments) and `audioeffects` (45 effect classes, racks included) — live in @@ -49,10 +49,10 @@ for source compatibility; only this repo's own name differs. here rather than the other way round. - `docs/porting-plan.md` — the full phased porting history, architecture, and target layout -- `docs/upstream-sync.md` — when and how to resync the CircuitPython-derived +- `docs/how-it-works/upstream-sync.md` — when and how to resync the CircuitPython-derived modules with upstream, what the last resync took, and the list of every deliberate difference (the pump, `deinit`, the recorded deviations) -- `docs/upstream-diff.md` — the reasons and measurements behind each +- `docs/how-it-works/upstream-diff.md` — the reasons and measurements behind each deviation from upstream CircuitPython (verbatim-kept quirks vs. genuine port bugs found and fixed) - `tests/parity/` — oracle-diff scripts, run unchanged against both this @@ -63,7 +63,7 @@ for source compatibility; only this repo's own name differs. `audiovorbis` wraps, BSD-3-Clause, vendored (see its `ORIGIN.txt`). Never listed in a build on its own: `src/shared/audiodsp_vorbis.c` includes it into one translation unit with every symbol renamed. See - [docs/vorbis.md](docs/vorbis.md). + [docs/reference/vorbis.md](docs/reference/vorbis.md). ## Sibling dependencies (cloned, not vendored) @@ -82,9 +82,9 @@ of this repository in the parent workspace, same pattern as - Oracle diffing is the core discipline: every parity script under `tests/parity/` runs unchanged against this port and `bin/circuitpython`, rendering PCM and diffing byte-for-byte (or documenting the exact, - bounded exception in `docs/upstream-diff.md`). + bounded exception in `docs/how-it-works/upstream-diff.md`). - The nine modules that are **ours** have no oracle, by decision - ([docs/correctness-standard.md](docs/correctness-standard.md), 2026-09-09). + ([docs/how-it-works/correctness-standard.md](docs/how-it-works/correctness-standard.md), 2026-09-09). Upstream CircuitPython has no counterpart to them, so they are held to two things: every target renders them identically, and their numeric traits hold. `.venv/bin/python tests/parity/verify_dsp.py --micropython ../micropython/ports/unix/build-pydevices/micropython` @@ -198,10 +198,10 @@ parity golden is measured against. The rule, for any agent working here: every CP-shared gate and re-pins the binary's hash with the reason written down. **Every pin move is a resync**: take every audio fix and every addition (a new property, argument or class) upstream made since the old - pin, following `docs/upstream-sync.md`, and update its record of what was + pin, following `docs/how-it-works/upstream-sync.md`, and update its record of what was taken and what differs on purpose. The oracle is built at the same version and the same ceiling this port ships — an oracle at a different configuration cannot answer the only question - worth asking. See `docs/correctness-standard.md` and + worth asking. See `docs/how-it-works/correctness-standard.md` and `tests/test_voice_ceiling_consistency.py`. - **Extending CircuitPython is fine and is the designed path**: new modules live in this repo under `src/circuitpython_spike/` and build, with @@ -211,7 +211,7 @@ parity golden is measured against. The rule, for any agent working here: because the oracle is built with it, so a behavioral change would leak *into* the oracle. - Fixes to bugs that also exist upstream go in **this repo's targets only** - (MicroPython/CPython/`src/shared/`), recorded in `docs/upstream-diff.md` + (MicroPython/CPython/`src/shared/`), recorded in `docs/how-it-works/upstream-diff.md` — never into the CP tree. Approved deviations from the oracle are enumerated there; **ask before adding one**. - Quick self-check after any CP-adjacent work: `circuitpython.mk` compiles diff --git a/CIRCUITPYTHON_ORACLE b/CIRCUITPYTHON_ORACLE index 785fe659..04424c40 100644 --- a/CIRCUITPYTHON_ORACLE +++ b/CIRCUITPYTHON_ORACLE @@ -3,8 +3,8 @@ # 2026-10-22, from 10.3.0 on 2026-10-08 with audiodsp#201, from 10.2.1 to # 10.3.0 on 2026-09-09). # Moving it is a re-port, not a bump: every move is a resync with upstream -# (docs/upstream-sync.md; the one before this was audiodsp#220). See also -# AGENTS.md "The CircuitPython oracle" and docs/upstream-diff.md. The 64-voice oracle binary built from +# (docs/how-it-works/upstream-sync.md; the one before this was audiodsp#220). See also +# AGENTS.md "The CircuitPython oracle" and docs/how-it-works/upstream-diff.md. The 64-voice oracle binary built from # it lives at bin/circuitpython-oracle-11.0.0-alpha.2 (build_interpreters.sh # cp-oracle); tests/test_voice_ceiling_consistency.py pins its sha256. 11.0.0-alpha.2 d53b2277cfad71589028e5da77c984c045db990d diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..148d5e61 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing + +Bugs, and things you need that don't work yet, go to +[issues](https://github.com/PyDevices/audiodsp/issues). Planned work is in +[ROADMAP.md](ROADMAP.md). A documentation correction, a focused CPython +fixture or a well-bounded module change is a good first pull request. + +[How it works](docs/how-it-works/index.md) maps the repository: what each +directory holds, how one graph runs on three targets, and where the +boundaries are. + +## Build from source + +For CPython, build and install the wheel from a checkout: + +```sh +python -m pip install build numpy +python -m build +python -m pip install dist/*.whl +``` + +For MicroPython and CircuitPython, build the way +[Install](docs/get-started/install.md) describes, with your checkout in place +of a clone. The `mp3` decoder that `audiomp3` wraps is fetched, not +vendored: + +```sh +scripts/fetch_deps.sh +``` + +It puts the decoder under `.deps/` at the commit [DEPENDENCIES.lock](DEPENDENCIES.lock) +pins. + +## Test + +CI runs the CPython fixture tests and the parity gates whose goldens live in +this repository: + +```sh +python -m unittest discover -s tests -p "test_*.py" +python tests/parity/verify_acceptance.py +python tests/parity/verify_effects.py +python tests/parity/verify_streaming.py +python tests/parity/verify_biquad.py +python tests/parity/verify_mixdown_knee.py +python -m flake8 +``` + +Run the tests for the module you changed rather than the whole suite: the +full run is long. The deeper parity checks need built interpreters and the +pinned CircuitPython oracle beside this checkout, so CI doesn't run them and +you don't need them for a first contribution. +[The correctness standard](docs/how-it-works/correctness-standard.md) says +what each module is held to. + +## Rules that keep the port honest + +- **Don't modify the CircuitPython oracle.** The tag in `CIRCUITPYTHON_ORACLE` + is what every parity golden is measured against. Edit CircuitPython's own + sources for it and parity quietly means something else. Moving the pin is a + resync with upstream, done deliberately: see + [Keeping in step with upstream](docs/how-it-works/upstream-sync.md). +- **Extend CircuitPython, don't change it.** New modules live in `src/` and + build into CircuitPython as a user C module through `circuitpython.mk`. They + never shadow or rewrite one of CircuitPython's. +- **Don't re-capture a parity golden casually.** A fix that is right can still + change one; say what independent evidence shows it is right. +- **Don't move the dependency pins** (`DEPENDENCIES.lock`, `CIRCUITPYTHON_ORACLE`) + without saying why. +- **A fix for a bug that also exists upstream** goes in this repository's + targets only, recorded in + [upstream-diff](docs/how-it-works/upstream-diff.md); open an issue before + adding a new deviation from CircuitPython. The fixes already sent upstream + are in [upstream reports](docs/upstream-reports/README.md). + +## Build and release wheels + +`pydevices-audiodsp` is a native CPython distribution. `python -m build` makes +a local source archive and wheel. + +A GitHub Release `vX.Y.Z`, matching the `VERSION` file (which is also where +`_audiodsp.__version__` comes from), starts the organization's reusable +publishing workflow. The pin is the `@publishing-vN` ref in +`.github/workflows/publish-release-packages.yml`, with +`build-kind: native-and-wasm`. It builds and validates exactly the number of +wheels `expected-wheel-count` names in that file. If the count changes, change +it in the same pull request. +[Wheels and platforms](docs/how-it-works/wheels-and-platforms.md) lists what +that is and what it leaves out. + +Final releases then go to production PyPI too, after a maintainer approves the +`pypi` environment. That upload is the `pypi` job in this repository's own +`publish-release-packages.yml`, not in the reusable workflow, because PyPI's +Trusted Publishing only matches the workflow that runs the upload +([why](https://github.com/PyDevices/.github/blob/main/docs/publishing-automation.md#production-pypi-opt-in-protected-trusted-publishing)). + +After TestPyPI publication, run `python scripts/test_testpypi_install.py` on +fresh Linux and Windows hosts. It takes an optional version and defaults to +the one in `VERSION`; it builds a bare environment and one with the `[render]` +extra. + +## License + +audiodsp is MIT licensed; see [LICENSE](LICENSE). + +The attribution that goes with it lives in [NOTICE](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. 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 don't 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. +`scripts/fetch_deps.sh` fetches it, carried unmodified per upstream's own +terms; it isn't part of this repository. See +[upstream-diff](docs/how-it-works/upstream-diff.md). + +The Tremor Vorbis decoder that `audiovorbis` wraps is in this repository, in +[third_party/tremor](third_party/tremor), under Xiph.Org's BSD-3-Clause +license ([COPYING](third_party/tremor/COPYING)), which travels with the +wheel. diff --git a/README.md b/README.md index ce573eb7..8729de9d 100644 --- a/README.md +++ b/README.md @@ -1,919 +1,47 @@ -# audiodsp - -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. -It adds modules CircuitPython doesn't have, among them `audiovorbis` for Ogg -Vorbis files. -Import names stay `audiocore`/`synthio`/etc., matching CircuitPython for -source compatibility. - -`audiodsp` was named `audioif` until 2026-09-21; that name now belongs to the -audio hardware layer. The modules you import did not change. The helper -package is `audiodsp_util` now, and the distribution on PyPI is -`pydevices-audiodsp`. - -New here? See the [newcomer's guide](docs/newcomers.md) for the runtime map, -shared-core architecture, and contributor boundaries. - -## Installation - -**MicroPython 1.29 or later** takes this repository with one line in the -manifest your build already uses. Clone it anywhere, fetch its two pinned -native dependencies, and include its [manifest.py](manifest.py): - -```sh -git clone https://github.com/PyDevices/audiodsp ~/build/audiodsp -~/build/audiodsp/scripts/fetch_deps.sh -``` - -```python -include("/home/you/build/audiodsp/manifest.py") -``` - -On unix the manifest to edit is `ports/unix/variants/standard/manifest.py`; on -esp32 and rp2 it is usually `ports//boards/manifest.py`, unless your -board brings its own. Then build the way you always do. The include brings in -the audiodsp C modules (named with `c_module()`); it freezes no Python and -brings no `ulab`, which audiodsp doesn't use: a build that wants `ulab` names -it itself. `fetch_deps.sh` puts the `mp3` decoder under `.deps/`, pinned by -[DEPENDENCIES.lock](DEPENDENCIES.lock); the mp3 glue looks there first and -falls back to a sibling checkout beside this repo. Tested on the unix port -against MicroPython v1.29.0, where `synthio`, `audiomp3` and `audiopump` all -import. - -If you would rather not edit the MicroPython tree, write a manifest of your -own and pass it as `FROZEN_MANIFEST=`. That replaces the port's default, so -include the default too (`include("$(PORT_DIR)/variants/standard/manifest.py")` -on unix, `include("$(PORT_DIR)/boards/manifest.py")` on esp32) or you lose -`asyncio` and the port's other frozen modules. - -To build without `mp3`, skip the fetch and set `AUDIODSP_OPTIONAL_DEPS=1` on -the **build** step — `micropython.mk`/`micropython.cmake` read it, not -`fetch_deps.sh`, and on CMake ports it is an *environment* variable, not a -`-D` cache entry. - -**Older than 1.29?** Manifests there have no `c_module()`, so do not include -this one; use `USER_C_MODULES`. On esp32 and rp2 point it at this checkout; -on a Make port point it at the directory that *contains* the checkout, which -builds every module in that directory. - -MicroPython's -[manifest reference](https://docs.micropython.org/en/v1.29.0/reference/manifest.html) -explains `include()` and `c_module()`, and -[micropython-pydevices](https://github.com/PyDevices/micropython-pydevices) -keeps ready-made manifests and boards for several PyDevices modules at once. -[docs/porting-plan.md](docs/porting-plan.md) covers the architecture, module -tiers and testing strategy. - -**CPython 3.11+** installs from PyPI: - -```sh -python -m pip install pydevices-audiodsp -``` - -This gets you `audiocore`, `synthio`, `audiomixer`, `audiofilters`, -`audiodelays`, `audiofreeverb`, `audiospeed`, `audiodynamics`, `audioroute`, -`audiomath`, `audioecho`, `audioshaper`, `audioladder`, `audiooscillator`, -`audioconvolve`, `audiobiquad`, `audioverb`, `audiomodal`, `audiosampler`, `audiometer`, -`audiopump`, `audiovorbis`, `audiograph`, and the `audiorender` package. -`audiomp3` remains firmware-only. The distribution declares no *required* -runtime dependencies and does not itself publish an `audiodsp` import; its -version is the `VERSION` file, which is also what `_audiodsp.__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: - -```sh -python -m pip install "pydevices-audiodsp[render]" -``` - -The instrument and effect libraries — `audioinstruments` (55 synthesizers, -keyboards and drum machines) and `audioeffects` (45 effect classes, racks -included) — are not part of this distribution. They live in the -[audiocomponents](https://github.com/PyDevices/audiocomponents) repository -as their own packages, each depending on `pydevices-audiodsp`; see that -repository for how to install them. - -### Threads, and free-threaded Python - -You can render on as many threads as you like, as long as **no node is used -from two threads at once**. Give each thread its own synthesizer, effects -and mixer, and each renders exactly the bytes it would render alone, LFOs -included: synthio's block clock belongs to the render in progress on each -thread, not to the process. - -On a free-threaded build (3.15t has wheels), the native extension declares -that it doesn't need the GIL, so those threads run in parallel and importing -audiodsp leaves the GIL off. Every call into a native node holds a lock on -that node, so reconfiguring a node while another thread renders it won't -crash the interpreter. What comes out of that is still undefined, though, -because it breaks the rule above. [tests/test_cpython_free_threading.py](tests/test_cpython_free_threading.py) is -the stress test that holds both, sixteen instruments on sixteen threads. - -## What's here - -Two pure-Python modules sit beside the CircuitPython-compatible core: - -- **`lib/audiodsp_util/`** — `float32(value)` and `float32_bits(value)`, - `struct` and nothing else. A Python float is the interpreter's own width: - a double here, a **single** on every board. So a setting written in Python - — `node.mix = 0.35` — is two different numbers, and the node renders - different bytes on a board than on a desktop before its kernel is reached. - Put a derived setting through `float32` and both targets hold the number - the board would have held. See - [docs/correctness-standard.md](docs/correctness-standard.md). -- **`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](https://github.com/PyDevices/audiocomponents) repository, -together with the audio component contract they implement: the metadata -manifest -([docs/audio-components.md](https://github.com/PyDevices/audiocomponents/blob/main/docs/audio-components.md)) -and the runtime API -([docs/audio-component-api.md](https://github.com/PyDevices/audiocomponents/blob/main/docs/audio-component-api.md)). -`audiorender` drives any component that speaks that API and does not need -those packages installed. - -## Additions beyond CircuitPython - -These 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; and `cast_into`, which turns a float -block into the int16 a `RawSample` plays without allocating), `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, and a -sample-and-hold at an exact rational ratio), -`audioladder` (a transistor ladder filter — four one-pole stages round a -feedback loop with an odd saturator inside it), `audiooscillator` (a -band-limited oscillator whose pitch, FM, pulse width and hard sync are audio -streams, read sample by sample), `audioconvolve` (apply a -measured or synthesized impulse response, by partitioned FFT), `audioverb` -(a reverberation tank whose line lengths and output taps come from Python), -`audiomodal` (a bank of resonators, which is what a struck object is), -`audiosampler` (recorded notes played at any pitch, looped, under an -envelope), `audioroute.MidSide` (scale the difference between a stereo pair's channels) -and `audiobiquad` (below) have no ancestor anywhere and are audiodsp's own. -Every one of them builds into CircuitPython 11 too, as a user C module from -this repository's `micropython.mk` (`make BOARD=... USER_C_MODULES=/path/to/audiodsp` -in a CircuitPython port), and plays through CircuitPython's own `audiobusio`, -`audioio` and `audiopwmio`; there, `audiocore`, `synthio`, `audiomixer` and the -effects modules stay CircuitPython's. `audiometer` (band levels for a spectrum -meter, below) is ours as well, and is the one CircuitPython doesn't get: its -sources are a board's pump and usbif's sound card, and CircuitPython has -neither. `audiovorbis` (below) plays Ogg Vorbis files, which CircuitPython -has no module for. And `audiopump.Ring` (below) lets CircuitPython play audio -your own code computes. - -### `audiopump.Ring` on CircuitPython — audio your code computes - -CircuitPython's audio outputs play samples, and a sample is filled from C, so -until now there was no way to play a stream your Python code or a graph of -nodes renders as it goes. A `Ring` is a sample you write into. The output -pulls it in C, block by block, while your loop keeps it topped up: - -```python -import audiobusio, board -from array import array -from audiopump import Ring - -ring = Ring(sample_rate=22050, channel_count=1, frames=256, capacity=16) -block = array("h", bytes(512)) # 256 mono frames -while ring.space() >= len(block) * 2: # fill it before play() - ring.write(render(block)) -out = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WCLK, board.I2S_DOUT) -out.play(ring) -while True: - while ring.space() >= len(block) * 2: - ring.write(render(block)) -``` - -It plays through `audiobusio.I2SOut`, `audioio.AudioOut`, -`audiopwmio.PWMAudioOut` and an `audiomixer` voice like any other sample. -When your code falls behind, the output plays a block of silence rather than -stopping, and `ring.stats()[3]` counts those underruns; a write that doesn't -fit is short and returns the bytes it took. `capacity` is how many blocks the -ring holds, which is also its latency. - -Size the ring for what your output takes at once, and fill it before -`play()`. On ESP32 boards `I2SOut` takes about 3,000 frames the moment it -starts and about 1,000 at a time after that, so a ring smaller than about -4,000 frames underruns at the start however fast you write. 256-frame blocks -with `capacity=16` (186 ms at 22,050 Hz) play a tone written from Python for -30 s on an ESP32-S3 with no underruns. - -It is the same `audiopump.Ring` MicroPython's pump pulls, with the same -methods (`write`, `space`, `level`, `stats`, `clear`, `deinit`), so one -producer runs on both. On CircuitPython the module carries only `Ring`: the -board's output is the pump. - -### `audiovorbis.VorbisDecoder` — Ogg Vorbis files - -Shaped like `audiomp3.MP3Decoder`, so it plays wherever an MP3 does: through -`audiocore.get_buffer()`, a `Mixer` voice, or a board's audio output. - -```python -import audiovorbis -song = audiovorbis.VorbisDecoder("song.ogg") # or an open binary file -mixer.voice[0].play(song, loop=True) -``` - -It takes the same arguments (a file or a path, and an optional buffer) and -has the same `file`, `open()`, `rms_level`, `samples_decoded`, `deinit()` -and `with` as `MP3Decoder`. Mono and stereo files play; `sample_rate` and -`channel_count` come from the file. Like an MP3, it reads its file while it -plays, so `audiopump` refuses it: feed the pump from the interpreter instead. - -The decoder is Xiph's Tremor, integer-only, carried in -[third_party/tremor](third_party/tremor) under its own BSD-3-Clause licence. -It decodes to the same samples on every build, and within two steps of -ffmpeg's float decoder. Other C modules in the same firmware can decode a -stream through it too. [docs/vorbis.md](docs/vorbis.md) has the C interface, -what it needs of memory on a board, and how it's tested. - -### `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 hold a constant 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. It is also the one to reach for below about -100 Hz, where the integer coefficients run out of resolution — -[which filter, and why](docs/upstream-diff.md#which-filter-to-reach-for). - -```python -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 | - -When a `Biquad`'s `frequency`, `Q`, `gain_db` or `mode` changes while it plays, -it glides to the new filter over about 20 ms instead of jumping, so a preset -change that drops the corner from 20 kHz to 20 Hz doesn't thump. A block input -glides the same way, so an LFO on `frequency` arrives about a block late. -`mix` still moves at once, and so does a filter that hasn't played yet. - -`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](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](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. Shifting up, the climb fades out at the top of the band -rather than folding back past Nyquist. See -[docs/upstream-diff.md](docs/upstream-diff.md) for what each was asked for -and what it measures. - -If you want the delay in frames rather than milliseconds, give `delay_frames` -instead of `delay_ms`: the line is read exactly that many frames back. At -44.1 and 22.05 kHz some whole frames have no `delay_ms` that lands on them, -so a lossless repeat at those rates needs `delay_frames`. - -`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)` moves everything but its first four arguments -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. A curve that reaches the rails has a ceiling of its -own: the decimator can ring past them on a hard edge, so keep -`post_gain * max(abs(curve))` at or below `audioshaper.CLIP_HEADROOM` -(0.74) and put the rest of the wanted level on a mixer voice after this -node. Why each of those is the shape it is, and what the -oversampling measures: -[docs/upstream-diff.md](docs/upstream-diff.md), "`audioshaper`: audiodsp's own, -and the two things a fixed curve cannot be". - -`audioshaper.SampleHold(source, num=…, den=…)` is the same module's other -node: a zero-order hold in which `num` source frames carry `den` new values, -so 26 040 Hz at 48 kHz is 400/217 and is **exact** — one frame in, one frame -out, at the source's own rate, channels and bit depth, with an integer -accumulator that cannot drift however long the render runs. `num == den` is a -wire byte for byte, `latency` is 0 (what a hold displaces is an event landing -on a frame it drops, up to `ceil(num/den) − 1` frames, which is the effect -and the caller's to report), and `set(num, den)` moves the ratio mid-stream. -It exists because the palette's sample-and-hold was a pair of -`audiospeed.SpeedChanger` nodes whose 16.16 rates cannot be made reciprocal: -measured on that hold, the pair ran 0.9999947 of the rate it asked for and -flanged a static setting by 10.74 dB over twelve seconds. The rate form was -not added to `SpeedChanger` because that module is CircuitPython's and an -argument added here would not exist on a board — -[docs/upstream-diff.md](docs/upstream-diff.md), "`audioshaper.SampleHold`: -lo-fi's other half". - -`audioladder` is 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: - -```python -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 - cutoff_octaves=10.0, # octaves for a full-scale cutoff input - buffer_size=None, # bytes a block, as on the effects; 256 frames -) -ladder.play(source) -ladder.set(cutoff_hz=1200.0) # any option, mid-stream, once a block -ladder.modulate(cutoff=lfo) # the cutoff read from a stream, every sample -ladder.clear() # empty the integrators; stops a sustained tone -``` - -`set()` moves the cutoff once a block, which is plenty for an envelope or a -knob. Sweep it with an audio-rate signal that way and you hear the staircase, -a buzz at the block rate. The `cutoff` input reads the cutoff every sample -instead, as `cutoff_hz * 2 ** (v * cutoff_octaves)` for an input at `v` of -full scale (first channel only), so filter FM sounds like filter FM. An input -of zero, or none at all, is the filter exactly as `cutoff_hz` sets it. - -`audiooscillator` is the oscillator synthio does not have. synthio's moves its -pitch once per block and reads a wavetable with no band limiting; this one -reads its pitch, linear FM, pulse width and hard sync from audio streams every -sample, so one oscillator can sync to another or be frequency-modulated by it: - -```python -import audiooscillator as ao - -lead = ao.Oscillator( - sample_rate=48000, # and channel_count=1 or 2 - frequency=220.0, # Hz, before the pitch input - waveform=ao.SAW, # any sum of SAW, PULSE, TRIANGLE and SINE - waveform_right=ao.PULSE, # the right channel's, from the same phase - pulse_width=0.5, # 0.05..0.95 - level=0.5, # output for a waveform's full swing - pitch_octaves=10.0, # octaves for a full-scale pitch input - fm_depth=300.0, # Hz for a full-scale FM input, linear - pwm_depth=0.5, # pulse width for a full-scale PWM input - buffer_size=None, # bytes a block; 256 frames -) -lead.modulate(sync=ao.Oscillator(frequency=110.0, waveform=ao.PULSE)) -lead.set(frequency=330.0) # any option, mid-stream -lead.clear() # phase back to zero -``` - -Steps are band-limited with PolyBLEP and the triangle's corners with -PolyBLAMP. The output trails the phase by one sample, which is what lets a -sync edge, which nothing can see coming, still correct the sample before it. - -See [docs/upstream-diff.md](docs/upstream-diff.md) for why its feedback loop -is solved rather than delayed, and what that was measured to be worth. - -### `audiomodal.Bank` - -A bank of resonators, which is what a struck object is. Hit a drum head, a -marimba bar, a bell or a wine glass and it rings as a sum of decaying -sinusoids at frequencies that are not harmonics of anything; hand `Bank` that -list and a short noise burst for the stick, and it is that object. +![The gain of a 1 kHz low-pass Biquad from 50 Hz to 20 kHz, measured by playing sines through it](docs/images/biquad-response.png) -```python -kick = audiomodal.Bank(modes=9, sample_rate=48000, channel_count=1) -kick.set_modes(((58.0, 0.55, 1.00), (92.4, 0.24, 0.38), - (123.9, 0.15, 0.24), (2200.0, 0.010, 0.30))) -kick.play(stick) # a short noise burst -audio_out.play(kick) -``` - -Construction fixes the allocation: `sample_rate`, `channel_count` (1 or 2) and -`modes` (1 to 64). `set_mode(index, frequency, decay, gain)` places one mode -and `set_modes(table)` places the whole list; `decay` is a **60 dB time in -seconds**, not a Q, and `gain` is the peak of that mode's impulse response, so -a modal table read out of a paper goes in as published. `set()` moves `mix` -and `gain` mid-stream without stopping anything ringing, `clear()` stops -everything at once, and `ringing` says whether any mode still holds energy. - -Two behaviours worth knowing before you use it. A bare `Bank()` is **silence**, -not a passthrough — the default `mix` is fully wet and an unconfigured bank has -no modes — so reach for `mix=0.0` if you want the untouched signal. And the -tail keeps ringing after the source stops, as `audioecho.FeedbackDelay`'s, -`audioverb.Tank`'s and `audioconvolve.Convolver`'s do, because a drum whose -decay ended the instant the stick left would be the one thing this node exists -not to be. - -See [docs/upstream-diff.md](docs/upstream-diff.md) for why this is a node -rather than a chain of `audiobiquad.Biquad`, measured rather than argued. - -### `audiograph.Graph` - -A whole patch rendered by one C call per block. You build a graph of nodes -over the kernels above (the oscillator, the ladder, the biquad, the feedback -delay, the all-pass, the sample-and-hold) and a few small ones a voice needs -(an ADSR, an LFO of five shapes, noise, a mixer with ramped gains, a VCA, a -glide), wire them through the graph's own block buffers, -put each voice in a group, and call `run()` once a block; nothing in Python -runs between the nodes. That is what lets a six-voice polysynth play in real -time on an ESP32-P4. The same graph renders the same samples on CPython, -MicroPython and CircuitPython, so a patch you build on the desktop sounds -there as it will on the board: -[docs/audiograph.md](docs/audiograph.md) has the nodes, the wire and the -limits. - -### `audiosampler.Sampler` - -Recorded notes, played at any pitch. `audiomixer.Mixer` plays sources at -their own rate and `synthio` plays single-cycle waveforms; neither can take a -recorded note, play it a fourth up, loop a region of it while the key is held -and let the rest of it out when the key lifts. `Sampler` does that, which is -what a SoundFont instrument and every drum machine need. - -```python -sampler = audiosampler.Sampler(voice_count=8, sample_rate=48000) -audio_out.play(sampler) -sampler.voice[0].play(piano_c4, rate=44100, pitch=2 ** (5 / 12), - loop_start=11000, loop_end=19000, - loop_mode=audiosampler.LOOP_UNTIL_RELEASE, - attack=0.005, release=0.3) -... -sampler.voice[0].release() -``` - -A `Sampler` is an audio source like a `Mixer`: play it into an output, a -mixer voice or an effect. Notes start on its voices. A buffer is mono signed -16-bit — an `array('h')`, a `memoryview`, anything with a buffer — and `rate` -is the buffer's own sample rate (the sampler's when left out), so the step per -output frame is `pitch * rate / sample_rate` and a 44.1 kHz recording plays at -the right speed through a 48 kHz output without being told anything else. -The buffer is held for as long as the voice sounds and let go when it stops. - -The loop modes are SoundFont's `sampleModes` under their own numbers, so a -reader writes the file's value straight in: `LOOP_NONE` (0) plays to the end -and stops, `LOOP_FOREVER` (1) loops `[loop_start, loop_end)` until -`release()` or `stop()`, and `LOOP_UNTIL_RELEASE` (3) loops while the key is -held, then plays on past the loop to the end of the buffer. - -Each voice has an amplitude DAHDSR — `delay`, `attack`, `hold`, `decay` and -`release` in seconds, `sustain` a linear level — run per sample in C, so a -two-millisecond attack is a slope and not a step. `delay` postpones the start -of the sound itself, which is what a flam wants. `level`, `pan` and `pitch` -are live properties: a change lands at the next block, the first two as a -ramp across it so a fader move is not a click, and the third is how a pitch -bend is played. `release()` enters the release stage from wherever the -envelope is; `stop()` is silence from the next block; `playing` says whether -the voice still sounds. - -Every voice is mixed in one C call per block, summed in float and quantised -once, through a 4-point Hermite interpolator — so a loop at pitch 1.0 is a -wire. Measured on a desktop: 24 looping stereo voices at 48 kHz in about -25 µs of the 5333 µs block; on an ESP32-P4 at 400 MHz, about 37 µs a voice. -See [docs/upstream-diff.md](docs/upstream-diff.md) for why the position is -fixed point and how the pitch is verified by measurement. - -`SampleBuffer(size)` is memory for the samples, outside the collected heap: -`size` zeroed bytes, with a buffer, so you can read into it and play views of -it. A bank of samples in bytearrays makes every `gc.collect()` read all of -them looking for pointers, which on a board with megabytes of samples is -seconds; a `SampleBuffer`'s bytes are never read. A view of one doesn't keep -it alive, so hold the buffer itself for as long as a voice plays from it. -`deinit()`, or the buffer being collected, frees the bytes. On a board whose -audio driver says where (PSRAM on the ESP32 ports) they come from there; on -CPython it is a bytearray. - -### `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. - -```python -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. When the source ends, -the tail rings out on silence to that zero and the node rests, so a source -played into it afterwards meets an empty plate; `audioecho.FeedbackDelay` and -`audioconvolve.Convolver` do the same. See -[docs/upstream-diff.md](docs/upstream-diff.md) for the measurement, the cost -and the memory the default table needs. - -### `audioroute.Port` — the wire a consumer holds - -A zero-copy pass-through you can re-point while it is playing. Its -`get_buffer` hands back its source's own pointer, length and result; its -`play(source)` swaps that source atomically against a pull in flight. Hold a -port, wire it into a mixer voice or a rack, and change what is behind it as -often as you like — the object the consumer took never changes. - -```python -import audioroute - -port = audioroute.Port(overdrive) -mixer.voice[0].play(port) # take it once - -port.play(bitcrusher) # heard on the next block; nothing rewires -port.source # what it is playing right now -``` - -That is the problem it was built for. About twenty effect classes rebuild -part of their graph when a knob crosses a threshold and hand out a new node -afterwards — and a consumer holding the old one goes on playing the graph the -class has finished with, or goes silent. A port turns that into a re-point. -`play()` does the protocol lookup, the deinit check and the format match -first, on the calling thread where raising is free, then takes the pump lock -for three stores, so a pull in flight sees the whole old source or the whole -new one and nothing in between. - -It costs about **20 ns a block on x86-64** and **1.27 µs a block on an -ESP32-P4**, both measured as the slope of 0, 25, 50 and 100 stacked ports over -the same 2000-block probe; against a 256-frame block at 48 kHz that is four -ten-thousandths of one per cent on the desktop and about two hundredths of one -per cent on the board. - -The board figure replaces an estimate of 700 ns that was extrapolated from -four effect classes timed on both machines, and the estimate was low by 1.8x. -The real ratio here is **64x**, not the 32–35x those classes gave: a port's -work is a protocol lookup and three stores with almost no arithmetic, so it -does not scale the way a DSP node does. That is an argument against -extrapolating a per-class cost at all ([#113](https://github.com/PyDevices/audiodsp/issues/113)). - -`Port` has no `stop()`, deliberately — a port always has a source. A port -pulled while it is already inside itself, which is what a component wrapping -its own output builds, publishes a loop fault and hands back an error in -17 us instead of recursing until the stack is gone. - -### `audiometer.Meter` — band levels for a spectrum meter - -N log-spaced bands between two frequencies, plus peak and RMS, one byte each -in half-dB steps (0 is -100 dB or quieter, 200 a full-scale sine). - -```python -meter = audiometer.Meter(32, low_hz=35, high_hz=20000) -meter.feed(pcm, 48000, 2) # interleaved s16, from Python -seq, levels, peak, rms = meter.levels() -``` - -On a board it can listen without Python touching a sample. -`meter.attach(tap, 48000)` reads an `audiopump.Tap` in C each time you call -`levels()`, so any pump stream can be metered, whatever it came from: a file, -I2S, or a USB sound card playing through the pump. `seq` counts analyses, 60 a second of audio, so a -reader can tell fresh levels from a stopped stream. - -Two FFTs, because one can't be both quick and sharp at the bottom of a log -scale: 1024 points for the bands above 400 Hz, and 512 points on a copy -low-passed and decimated by 16 for those below. A band's level is the -spectrum integrated over the band, so pink noise reads flat to within about -1.5 dB across 35 Hz to 20 kHz. The default floor is 35 Hz because below that -real music barely moves a bar. The window, the filter, the band edges and -the dB rounding avoid libm, so a desktop and a board read the same bytes for -the same samples; `tests/parity/meter_probe.py` checks that. - -## `audiopump` — the audio pull, off the interpreter thread - -`audiopump` runs the same pull a player runs, in C, on a thread the -interpreter is not on: a task pinned to the other core on esp32, a native -thread on unix and Windows, and the calling thread on a port that has no -threads at all. A graph pulled that way keeps an exact clock while a screen -redraws, a USB stack runs and Python does whatever it likes. - -The thread and the output it writes to come from a platform driver, which -this repository doesn't carry; the end of this section says where it comes -from. The example below is for a board whose driver has opened its output. On -a build without one, such as unix built from this repository alone, -`sink=True` raises `ValueError: no i2s sink open`, and the `service()` form -under **Which builds carry it** is the one to use. - -```python -import audiopump, audiomixer, synthio - -synth = synthio.Synthesizer(sample_rate=48000, channel_count=2) -mixer = audiomixer.Mixer(voice_count=1, sample_rate=48000, - channel_count=2, buffer_size=2048) -mixer.voice[0].play(synth) +# audiodsp -status = bytearray(audiopump.STATUS_BYTES) -audiopump.spawn(mixer, 0x7FFFFFFF, status, sink=True) +audiodsp is the part of OmniPython that makes and shapes sound: CircuitPython's audio modules (`audiocore`, `synthio`, `audiomixer` and the effects) with the same import names on MicroPython-compatible boards, CircuitPython-compatible boards and CPython, plus filters, a reverb, a sampler, a Vorbis decoder and an audio pump that CircuitPython doesn't have. -synth.press(60) # while it plays; no parking, no ceremony -mixer.voice[0].level = 0.6 -audiopump.shutdown() # and a soft reset does this for you +```bash +pip install pydevices-audiodsp ``` -`spawn(sample, blocks, status, …)` starts it; `pull()` runs the same loop on -the calling thread, so `micropython.heap_lock()` around it is a real gate on -"the pull does not allocate"; `service()` advances it where there is no -thread; `retarget(sample, loop=…)` points it at a different tail; -`shutdown()` stops it. `status` is a `bytearray` of counters — blocks, bytes, -a digest of everything pulled, the worst block, what the sink clocked, the -error and the fault — read back with `struct.unpack`. Everything that can -refuse happens on the calling thread. The pull itself never raises, because -there is no interpreter on that thread to raise on, so it publishes a fault -code and stops. - -`retarget`'s `loop=` travels **with** the swap rather than being stored when -you call it: the old tail is pulled until the next block boundary, so setting -the flag early ends the pump one block before the new tail ever runs. Leave it -out and the flag stays as `spawn()` set it, which is what every caller before -the argument existed wanted. Get it wrong the other way and a looping client -left alone on a live pump stops at the end of its lap. - -### `fade=` — changing what plays without a click - -Swapping one graph for another mid-waveform is a seam you can hear, and a -change that holds the pump for longer than its ring lets the speaker run dry, -which cuts the sound off and clicks again when it comes back. Both calls that -change what plays can fade instead: - -```python -rack = build_the_next_patch() # off the lock, as long as it takes -audiopump.retarget(rack.output, fade=True) # old fades out, new fades in -old_rack.deinit() # safe: True means it is let go - -audiopump.park(fade=True) # hold the graph while you rewire it -rewire() # however long this takes, the speaker is fed -audiopump.unpark() +```bash +git clone https://github.com/PyDevices/audiodsp ~/build/audiodsp && ~/build/audiodsp/scripts/fetch_deps.sh ``` -`retarget(tail, fade=True)` sends the current tail's next block out faded to -silence, swaps, and fades the new tail's first block in: one block each way, -5.3 ms at 256 frames and 48 kHz. A freshly built graph's first block is often -its slowest, and with the fade that lands in the gap rather than in a cut. It -returns True once the pump has stopped pulling the old tail, so you can -release it; False if it stopped waiting (the pump is parked, asleep on a full -ring, or `timeout_us`, 100 ms by default, ran out). The swap still happens on -the next block, and the old tail is kept until then. - -`park(fade=True)` is a park with a deadline. The pump pulls one block ahead -and keeps it. If you `unpark()` before the speaker's queue is down to that -block, it goes out untouched and nothing is audible. If the queue gets there -first, the block goes out faded, the pump feeds the speaker silence for as -long as the park lasts, and the first block after `unpark()` fades in. It -needs a pump on a thread of its own and a ring, or a sink whose driver counts -what it clocked; where nothing can say how full the queue is, it fades at once. - -`fades()` says what happened since `spawn()`: `(fades, clean holds, silence -bytes)`. The status block's digest is still the graph's own audio; only what -goes to the speaker, the ring and the tap is faded. Without `fade=`, both calls -behave exactly as before. - -### `backpressure()` — whether a full ring makes the pump wait +The first is for a desktop. The second is for a MicroPython firmware build: add `include("/home/you/build/audiodsp/manifest.py")` to your port's manifest, and [Install](docs/get-started/install.md) has the rest. ```python ->>> audiopump.backpressure() -True -``` - -True where a full output ring makes the pump **wait** for room instead of -dropping the block; False where a free-running pump would lose audio. It is -the question a driver asks before it decides whether to park the pump between -ticks, and it is True in two different ways: on a threaded port whose driver -fills in `park_spin`, where a wake ends the wait, and in service mode, where -the loop hands the thread back on a full ring and the caller's next -`service()` is the wake. It is False only on a threaded build with no -`park_spin` — a driver that is not finished, which now says so rather than -quietly losing blocks. +import array, math, audiobiquad, audiocore -It is worth asking because parking costs the caller. On the **desktop unix -build**, ten seconds of a synth through an Overdrive and a TapeDelay with an -app doing 3.5 ms of work a tick: the interpreter spends **52 ms per 10 s on -audio against 862 ms on the old interpreter-thread path**, where a parked pump -cost 923 ms. With no app work at all the two are level (758 ms against 713). -A parked pump used to spin 94 % of one core and now sleeps at 1 %. A WAV -plays back byte-identically ten times out of ten with all eight cores of that -box in a busy loop, overflow count 0 by construction; with the drop put back, -0 of 10. +RATE = 48000 -Three kinds of source feed it and they are all the same kind of thing to it: -a graph; `audiopump.Ring`, an audiosample node Python writes PCM into, so a -pushed stream can sit behind a Mixer with effects on it like anything else; -and `audiopump.Events`, a queue of frame-stamped presses, plays and levels -the pump applies at block boundaries, which is what puts a sequenced bar on -the audio's clock instead of the interpreter's. `audiopump.Tap` reads what is -going out, for a meter or a scope, without being in the path. +def gain_db(freq): + """Play a sine through a 1 kHz low-pass and report how loud it comes out.""" + sine = array.array("h", (int(16000 * math.sin(2 * math.pi * freq * i / RATE)) + for i in range(12000))) + src = audiocore.RawSample(sine, sample_rate=RATE, channel_count=1) + lp = audiobiquad.Biquad(mode=audiobiquad.LOW_PASS, frequency=1000, Q=0.707, + sample_rate=RATE, channel_count=1) + lp.play(src) + peaks = [max(map(abs, memoryview(audiocore.get_buffer(lp)[1]).cast("h"))) + for _ in range(40)] + return 20 * math.log10(max(peaks[20:]) / 16000) # skip the settling -**Which builds carry it.** Every MicroPython port: unix, Windows, -WebAssembly and esp32. **The CPython wheel does too**, in the shape a port -with no thread has: `driver()` is `'none'`, `threaded()` is False, `spawn()` -returns −2 and adopts the graph, and nothing is pulled until you call -`service()`, which fills the ring you passed to `spawn()` and returns when it -is full. The names, the status block, `Ring`, `Events` and `Tap` are the same, -and so are the bytes: drained from the ring, a graph is byte-identical to the -same graph on the MicroPython unix build. - -```python -status = bytearray(audiopump.STATUS_BYTES) -ring = bytearray(16384) -audiopump.spawn(mixer, 0x7FFFFFFF, status, ring=ring) -buf = bytearray(4096) -while playing: # from your app's timer, say every 20 ms - audiopump.service() - n = audiopump.drain(buf) - sink.write(memoryview(buf)[:n]) +for f in (100, 1000, 4000, 8000): + print(f"{f:5d} Hz {gain_db(f):6.1f} dB") ``` -`audiodev` does this for you: on CPython its players drive the pump from -their own tick. What CPython does not have is `pull()` under a heap lock, -because it has no heap lock, and audiocore's pull-depth cap: a graph that -leads back into itself is caught at Python's recursion limit instead, and -reported as the same fault. **CircuitPython carries only `Ring`**: its playback -layer already pulls natively from its own audio thread, and a second pull loop -would be a second owner of the same graph, so there a board's audio output -pulls the `Ring` itself ([above](#audiopumpring-on-circuitpython--audio-your-code-computes)). It still takes the lock and the -hook table, on default hooks, which cost a load and a branch. - -The hardware — the channel a board writes into, and a live microphone as a -source — is not here. It arrives as a separate module, `_audioif`, from a -platform driver, and `audiopump.driver()` tells you which one bound: - -```python ->>> audiopump.driver() -'esp32' # or 'pthread', 'win32', 'none' +```text + 100 Hz -0.0 dB + 1000 Hz -3.0 dB + 4000 Hz -24.6 dB + 8000 Hz -38.4 dB ``` -`'none'` is a real answer rather than a failure: the default hook table is -all NULLs, which means one thread and no hardware, and that is exactly how -the WebAssembly build runs. Writing a driver for a platform that has none yet -is [docs/pump-ports.md](docs/pump-ports.md). - -## Status - -**MicroPython:** all module tiers ported and oracle-diffed byte-for-byte -against CircuitPython's own unix build; 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](docs/porting-plan.md) for the full phased history -and [docs/upstream-sync.md](docs/upstream-sync.md) for every deliberate -difference from upstream CircuitPython and how the port is kept in step with -it. - -**CPython:** the public surface and wheel plumbing are present, and the -committed synthesis, mixer, MIDI, streaming, and effects fixtures match -CircuitPython 11.0.0-alpha.2 PCM byte-for-byte — built at the same voice ceiling this -port ships, which is the only comparison worth making. Import/API smoke success -is not used as a substitute for those comparisons. - -`mp3` (the Adafruit_MP3/Helix decoder `audiomp3` depends on, -RPSL/RCSL-licensed — not MIT, carried unmodified per upstream's own terms) is -fetched by [scripts/fetch_deps.sh](scripts/fetch_deps.sh) at the commit -[DEPENDENCIES.lock](DEPENDENCIES.lock) pins, 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`](https://github.com/todbot/CircuitPython_SynthTools) running -unmodified, with rendered PCM diffed against CircuitPython's own unix -coverage build (which already contains the entire DSP stack — the parity -oracle for this whole port). - -What's planned next is in [ROADMAP.md](ROADMAP.md). - -## 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 for the instruments and effects built on it (the -[audiocomponents](https://github.com/PyDevices/audiocomponents) packages). -They 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 audiodsp 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 -audiodsp 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 - -audiodsp is MIT licensed — see [LICENSE](LICENSE). - -The attribution that goes with it lives in [NOTICE](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; -`scripts/fetch_deps.sh` fetches it; it is not part of this repository. See -[docs/upstream-diff.md](docs/upstream-diff.md). +[Docs](https://github.com/PyDevices/audiodsp/blob/main/docs/index.md) · [Examples](https://pydevices.github.io/pydevices-examples/gallery/) · [Roadmap](ROADMAP.md) -The Tremor Vorbis decoder that `audiovorbis` wraps is in this repository, in -[third_party/tremor](third_party/tremor), under Xiph.Org's BSD-3-Clause -licence ([COPYING](third_party/tremor/COPYING)), which travels with the -wheel. +OmniPython is made by [PyDevices](https://pydevices.com). diff --git a/ROADMAP.md b/ROADMAP.md index fa6953a7..db51b919 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,7 @@ audio pump holding up under live use such as a stompbox or a stage rig. - An RP2 pump driver: the pump on the second core with an I2S (PIO) sink, so RP2 boards get the same off-thread audio as ESP32. See - [docs/pump-ports.md](docs/pump-ports.md) for how a port plugs in. + [docs/how-it-works/pump-ports.md](docs/how-it-works/pump-ports.md) for how a port plugs in. - `audiograph`: `set()` at a frame inside a block, so a note lands on its own sample rather than at the top of the next block. - An LC3 codec module beside `audiomp3`, frame by frame: encode PCM to one diff --git a/docs/building-wheels.md b/docs/building-wheels.md deleted file mode 100644 index f2853e60..00000000 --- a/docs/building-wheels.md +++ /dev/null @@ -1,165 +0,0 @@ -# Building and publishing wheels - -`pydevices-audiodsp` is a native CPython distribution. Build a local source -archive and wheel with: - -```sh -python -m build -``` - -A GitHub Release `vX.Y.Z` — matching the `VERSION` file, which is also where -`_audiodsp.__version__` comes from — invokes the organization's reusable -publishing workflow (the pin is the `@publishing-vN` ref in -`.github/workflows/publish-release-packages.yml`) with -`build-kind: native-and-wasm`. It builds and validates exactly **24** wheels: - -- CPython 3.11–3.15, plus the free-threaded 3.15t, on manylinux_2_28 x86_64, - Windows AMD64, and macosx_arm64 — 18. The 3.15t wheel loads with the GIL - turned back on: `_audiodsp` doesn't yet declare that it runs safely - without it. -- CPython 3.13–3.14, Android API 21 arm64_v8a/x86_64 — 4. -- CPython 3.13–3.14, Pyodide wasm32 — 2. - -Final releases then go to production PyPI too, after the maintainer approves the `pypi` -environment. That upload is the `pypi` job in this repository's own -`publish-release-packages.yml`, not in the reusable workflow, because PyPI's -Trusted Publishing only matches the workflow that runs the upload -([why](https://github.com/PyDevices/.github/blob/main/docs/publishing-automation.md#production-pypi-opt-in-protected-trusted-publishing)). - -(This paragraph said `publishing-v6` and 16 wheels until 2026-09-02, having -gone stale when macOS landed. The count that matters is -`expected-wheel-count` in `publish-release-packages.yml`; if this prose and -that value ever disagree again, **the workflow is right**.) - -## macOS: enabled, CI-proven - -macOS is enabled at the org's **CI-proven** tier ("PyDevices ships -everything for macOS that CI can prove" — see the organization -`platform-support-tiers.md`): the `test-cpython.yml` workflow builds, -installs, and runs the full unit/contract/parity suite and -`tools/validate_api.py` on `macos-latest`, and its `macos-wheels` job -runs the same cibuildwheel the release path uses — compiling the native -extension for CPython 3.11–3.15 and 3.15t and running the import smoke test in -each wheel — on every push, publishing nothing. Hardware audio output -on a real Mac remains **community-verified**: no Mac is on our bench, -and a field report is what promotes that claim. - -**Arch choice: `macosx_arm64` only.** The runner is Apple Silicon; an -x86_64 (or universal2) wheel cross-compiled there could not run its -test-command, and an untested wheel is a claim, not a capability. An -Intel-Mac field report is what would add x86_64. - -**The release-time gap is closed** (corrected 2026-09-02; this section -described it as open long after it shut). `macos-latest` joined the org -desktop matrix in **publishing-v7**, this repository is pinned at -**`@publishing-v12`** (the production PyPI upload runs here, not in the -reusable, since 2026-09-26), and `expected-wheel-count` is already **21** — the -five `macosx_arm64` wheels ship. Hardware audio output on a real Mac is -still community-verified; that part has not changed. - -## Linux aarch64: enabled, CI-proven; not yet shipped - -**Decided 2026-09-02: add Linux ARM.** aarch64 is the Raspberry Pi -and every 64-bit SBC — real users on a tier the organization's -`platform-support-tiers.md` already claims desktop-Linux coverage for, -while shipping no ARM wheel. That gap between claim and artifact is the -cause; "it would be nice" is not. - -`[tool.cibuildwheel.linux]` uses **`archs = ["auto64"]`** — the runner's -own 64-bit architecture — with `manylinux_2_28` images for both x86_64 and -aarch64. Naming `["x86_64", "aarch64"]` explicitly would have been a -release-breaking mistake: the x86_64 release runner would then try to build -aarch64 under QEMU, which no workflow configures. `auto64` resolves to -x86_64 on an x86_64 runner and aarch64 on an ARM one, so one config is -correct everywhere and an ARM release cell needs no config change at all. - -`test-cpython.yml` proves it two ways on **native ARM runners** -(`ubuntu-24.04-arm`) rather than under QEMU: - -- the full matrix — build, install, unit/contract tests, `validate_api`, - and all four parity verifiers — runs on aarch64 for CPython 3.11–3.14; -- a `linux-arm-wheels` job runs the same cibuildwheel the release path - uses, compiling the extension and executing each wheel's import smoke - test. Nothing is published. - -**Native, not emulated, deliberately.** Under QEMU each wheel's -test-command runs on a faithful *simulation* of the target. The bugs -worth catching here are the ones that differ by architecture, which is -exactly the class an emulator reproduces only by accident. An untested -wheel is a claim; a wheel tested on emulated hardware is a weaker claim -than it looks. - -**What the ARM lane found on its first run, and what closed it.** aarch64 -reproduces audiodsp's effects output byte for byte, since 2026-09-17. It did -not at first, and the gap is the part worth keeping. Three of the four -parity gates (`verify_acceptance`, `verify_streaming`, `verify_biquad`) -were identical from the start; `verify_effects` was not — **6 of 744 -numeric fields differed, largest absolute deviation 1 in every case**, -confined to `multitap` and `pitchshift`, the two effects doing delay-line -interpolation. That is where a compiler's fused multiply-add changes the -rounding, and AArch64 fuses by baseline where x86-64 does not without -`-mfma`. So the six blocks were accepted as an aarch64 baseline of its own, -with the evidence written beside it. - -[#79](https://github.com/PyDevices/audiodsp/issues/79) forbade contraction -in every `src/shared/` file that computes in float, and the six blocks -closed. Eight `ubuntu-24.04-arm` cells — Python 3.11 through 3.14 in two CI -runs — now hash identically to the x86_64 reference, **0 of 882 numeric -fields differing**, checksums included rather than sums alone. aarch64's -baseline is therefore *removed* from -`tests/parity/golden/effects_component.json` rather than updated: it is -held to the oracle hash itself now, the same one x86_64 is held to, and -`cpython_stdout_sha256_reproduced_by` records that the agreement was -measured so a future mismatch reads as a regression. - -The rule this settled (2026-09-02) stands whatever any one -architecture does: **bit-identical audio is required within one CPU -architecture, not across them.** No tolerance was introduced anywhere. The -gate stays exact everywhere, and an architecture that genuinely differs is -held to its own recorded baseline, accepted deliberately with its evidence -written beside it. That is both a faithful reading of the rule and a -stricter regression detector than a threshold. - -**Release-time gap, precisely — this one IS open.** The organization -desktop matrix (`reusable-build-native-and-wasm-wheels.yml`) runs `ubuntu-latest`, `windows-latest` and -`macos-latest`. There is no ARM cell, so **a release still ships no -aarch64 wheel** and `expected-wheel-count` stays 18. Closing it is three -coupled steps, all outside this repository: - -1. add an ARM runner to that matrix (the macOS comment there describes - the same shape: repos that skip the arch build a no-op cell); -2. cut the new `publishing-vN` tag; -3. move this repository's pin and raise `expected-wheel-count` 18 → 22 - in the same change. - -Steps 2 and 3 are release-machinery version decisions and belong to the maintainer. -The work here means that when they happen, the ARM lane is a matrix line -rather than a discovery. - -## The remaining exclusions - -Each with its cause rather than by silence: - -- **musllinux** — **decided 2026-09-02: stay skipped.** It doubles - the Linux matrix for the Alpine family, and the test that admitted - aarch64 is what excludes it: we can name aarch64's users (every Pi and - SBC, on a tier we already claim) and cannot name musllinux's. Revisit - if an actual Alpine user asks. Remains in `pyproject.toml`'s - cibuildwheel `skip` list. -- **win32** (32-bit Windows) — every Windows target here is 64-bit: the - wheel matrix builds AMD64 only and the validation hosts are 64-bit; - nothing in this repository has ever been built or tested 32-bit. Also in - the cibuildwheel `skip` list. -- **Linux ARM** — no longer an exclusion; see the aarch64 section above. - The old note here said ARM would need QEMU on x86_64 runners. GitHub now - offers native `ubuntu-24.04-arm` runners to public repositories, so the - premise that made it expensive is gone. -- **MicroPython `.mpy`** — the native modules are firmware: MicroPython and - CircuitPython build them from `src/` as a usermod, so there is nothing - for MIP to carry (recorded in `publish-release-packages.yml`). The - pure-Python tier publishes to MIP as source; compiling it to `.mpy` - would pin it to one bytecode version per MicroPython release, and no - decision to take that on has been made. - -After TestPyPI publication, run -`python scripts/test_testpypi_install.py` on fresh Linux and Windows hosts. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..a40bfa04 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,3 @@ +# Changelog + +Every release's changes are listed in [CHANGELOG.md](../CHANGELOG.md), newest first. What is planned next is in [ROADMAP.md](../ROADMAP.md). diff --git a/docs/get-started/first-program.md b/docs/get-started/first-program.md new file mode 100644 index 00000000..8eb8f6d9 --- /dev/null +++ b/docs/get-started/first-program.md @@ -0,0 +1,79 @@ +# Your first program + +This program measures a filter by playing sines through it. It needs +[CPython and the wheel](install.md#cpython-311-or-later) and nothing else. + +```python +import array, math, audiobiquad, audiocore + +RATE = 48000 + +def gain_db(freq): + """Play a sine through a 1 kHz low-pass and report how loud it comes out.""" + sine = array.array("h", (int(16000 * math.sin(2 * math.pi * freq * i / RATE)) + for i in range(12000))) + src = audiocore.RawSample(sine, sample_rate=RATE, channel_count=1) + lp = audiobiquad.Biquad(mode=audiobiquad.LOW_PASS, frequency=1000, Q=0.707, + sample_rate=RATE, channel_count=1) + lp.play(src) + peaks = [max(map(abs, memoryview(audiocore.get_buffer(lp)[1]).cast("h"))) + for _ in range(40)] + return 20 * math.log10(max(peaks[20:]) / 16000) # skip the settling + +for f in (100, 1000, 4000, 8000): + print(f"{f:5d} Hz {gain_db(f):6.1f} dB") +``` + +Saved as `first.py` and run with `python first.py`, it prints: + +```text + 100 Hz -0.0 dB + 1000 Hz -3.0 dB + 4000 Hz -24.6 dB + 8000 Hz -38.4 dB +``` + +A 1 kHz low-pass lets 100 Hz through untouched, is 3 dB down at the corner, +and cuts hard above it. Here is the same measurement at 70 frequencies: + +![Gain of a 1 kHz low-pass Biquad, from 50 Hz to 20 kHz](../images/biquad-response.png) + +## What it does + +`gain_db` builds a quarter second of sine as an `array` of 16-bit samples and wraps +it in an `audiocore.RawSample`. It builds an `audiobiquad.Biquad`, a +low-pass at 1,000 Hz, and `play()`s the sine into it. A filter is a source: +anything that can be played into something else is an audio sample. + +`audiocore.get_buffer(lp)` asks the filter for its next block, the way a +mixer or an output does. It returns a status and a `memoryview` of +16-bit samples. The program pulls 40 blocks, keeps each block's peak, and +ignores the first 20 while the filter settles. The ratio of that peak to the +input's 16,000 is the gain in decibels. + +That pull is the whole design: a source hands out blocks when whatever sits +downstream asks for them, so nodes chain without Python touching every +sample. [How it works](../how-it-works/index.md) has the rest. + +## Render a synthesizer + +The same call works on a synthesizer: + +```python +import audiocore +import synthio + +synth = synthio.Synthesizer(sample_rate=8_000) +synth.press(69) +status, block = audiocore.get_buffer(synth) +``` + +That renders a block from a source; it doesn't select a speaker. A host can +pull the graph for offline rendering, give it to a mixer or a pump, or +attach it to a platform-specific audio output. + +## Next + +- [Play computed audio](../guides/play-computed-audio.md) with `audiopump.Ring`. +- [Filter a sound](../guides/filter-a-sound.md) with more of `audiobiquad`. +- [The modules](../reference/modules.md), one by one. diff --git a/docs/get-started/install.md b/docs/get-started/install.md new file mode 100644 index 00000000..bddda5f2 --- /dev/null +++ b/docs/get-started/install.md @@ -0,0 +1,109 @@ +# Install + +Pick the channel that matches where your code runs: a desktop with CPython, +a MicroPython build, or a CircuitPython build. Once it's installed, +[your first program](first-program.md) renders a block of audio. + +## CPython 3.11 or later + +**CPython 3.11+** installs from PyPI: + +```sh + +The distribution on PyPI is `pydevices-audiodsp`. + +``` + +This gets you `audiocore`, `synthio`, `audiomixer`, `audiofilters`, +`audiodelays`, `audiofreeverb`, `audiospeed`, `audiodynamics`, `audioroute`, +`audiomath`, `audioecho`, `audioshaper`, `audioladder`, `audiooscillator`, +`audioconvolve`, `audiobiquad`, `audioverb`, `audiomodal`, `audiosampler`, `audiometer`, +`audiopump`, `audiovorbis`, `audiograph`, and the `audiorender` package. +`audiomp3` remains firmware-only. The distribution declares no *required* +runtime dependencies and does not itself publish an `audiodsp` import; its +version is the `VERSION` file, which is also what `_audiodsp.__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: + +```sh +python -m pip install "pydevices-audiodsp[render]" +``` + + +The instrument and effect libraries, `audioinstruments` and `audioeffects`, +are not part of this distribution. They live in the +[audiocomponents](https://github.com/PyDevices/audiocomponents) repository +as their own packages, each depending on `pydevices-audiodsp`; see that +repository for how to install them. + +To render on several threads, see [Render on several threads](../guides/use-threads.md). + +## MicroPython 1.29 or later + +**MicroPython 1.29 or later** takes this repository with one line in the +manifest your build already uses. Clone it anywhere, fetch its two pinned +native dependencies, and include its [manifest.py](../../manifest.py): + +```sh +git clone https://github.com/PyDevices/audiodsp ~/build/audiodsp +~/build/audiodsp/scripts/fetch_deps.sh +``` + +```python +include("/home/you/build/audiodsp/manifest.py") +``` + +On unix the manifest to edit is `ports/unix/variants/standard/manifest.py`; on +esp32 and rp2 it is usually `ports//boards/manifest.py`, unless your +board brings its own. Then build the way you always do. The include brings in +the audiodsp C modules (named with `c_module()`); it freezes no Python and +brings no `ulab`, which audiodsp doesn't use: a build that wants `ulab` names +it itself. `fetch_deps.sh` puts the `mp3` decoder under `.deps/`, pinned by +[DEPENDENCIES.lock](../../DEPENDENCIES.lock); the mp3 glue looks there first and +falls back to a sibling checkout beside this repo. On a unix build, `synthio`, `audiomp3` +and `audiopump` all import. + + +If you would rather not edit the MicroPython tree, write a manifest of your +own and pass it as `FROZEN_MANIFEST=`. That replaces the port's default, so +include the default too (`include("$(PORT_DIR)/variants/standard/manifest.py")` +on unix, `include("$(PORT_DIR)/boards/manifest.py")` on esp32) or you lose +`asyncio` and the port's other frozen modules. + +To build without `mp3`, skip the fetch and set `AUDIODSP_OPTIONAL_DEPS=1` on +the **build** step — `micropython.mk`/`micropython.cmake` read it, not +`fetch_deps.sh`, and on CMake ports it is an *environment* variable, not a +`-D` cache entry. + + +**Older than 1.29?** Manifests there have no `c_module()`, so do not include +this one; use `USER_C_MODULES`. On esp32 and rp2 point it at this checkout; +on a Make port point it at the directory that *contains* the checkout, which +builds every module in that directory. + +MicroPython's +[manifest reference](https://docs.micropython.org/en/v1.29.0/reference/manifest.html) +explains `include()` and `c_module()`, and +[micropython-pydevices](https://github.com/PyDevices/micropython-pydevices) +keeps ready-made manifests and boards for several PyDevices modules at once. + +## CircuitPython 11 + +Every module that is audiodsp's own builds into CircuitPython 11 as a user C +module from this repository's `micropython.mk`: + +```sh +make BOARD=... USER_C_MODULES=/path/to/audiodsp +``` + +Run that in a CircuitPython port. Your sounds play through CircuitPython's +own `audiobusio`, `audioio` and `audiopwmio`; `audiocore`, `synthio`, +`audiomixer` and the effects modules stay CircuitPython's. Two modules differ +there: `audiopump` carries only `Ring` (see +[Play computed audio](../guides/play-computed-audio.md)), and `audiometer` +isn't built, because its sources are a board's pump and a USB sound card, and +CircuitPython has neither. diff --git a/docs/guides/change-without-a-click.md b/docs/guides/change-without-a-click.md new file mode 100644 index 00000000..6f5a13eb --- /dev/null +++ b/docs/guides/change-without-a-click.md @@ -0,0 +1,39 @@ +# Change what plays without a click + + +Swapping one graph for another mid-waveform is a seam you can hear, and a +change that holds the pump for longer than its ring lets the speaker run dry, +which cuts the sound off and clicks again when it comes back. Both calls that +change what plays can fade instead: + +```python +rack = build_the_next_patch() # off the lock, as long as it takes +audiopump.retarget(rack.output, fade=True) # old fades out, new fades in +old_rack.deinit() # safe: True means it is let go + +audiopump.park(fade=True) # hold the graph while you rewire it +rewire() # however long this takes, the speaker is fed +audiopump.unpark() +``` + +`retarget(tail, fade=True)` sends the current tail's next block out faded to +silence, swaps, and fades the new tail's first block in: one block each way, +5.3 ms at 256 frames and 48 kHz. A freshly built graph's first block is often +its slowest, and with the fade that lands in the gap rather than in a cut. It +returns True once the pump has stopped pulling the old tail, so you can +release it; False if it stopped waiting (the pump is parked, asleep on a full +ring, or `timeout_us`, 100 ms by default, ran out). The swap still happens on +the next block, and the old tail is kept until then. + +`park(fade=True)` is a park with a deadline. The pump pulls one block ahead +and keeps it. If you `unpark()` before the speaker's queue is down to that +block, it goes out untouched and nothing is audible. If the queue gets there +first, the block goes out faded, the pump feeds the speaker silence for as +long as the park lasts, and the first block after `unpark()` fades in. It +needs a pump on a thread of its own and a ring, or a sink whose driver counts +what it clocked; where nothing can say how full the queue is, it fades at once. + +`fades()` says what happened since `spawn()`: `(fades, clean holds, silence +bytes)`. The status block's digest is still the graph's own audio; only what +goes to the speaker, the ring and the tap is faded. Without `fade=`, both calls +behave exactly as before. diff --git a/docs/guides/decode-vorbis.md b/docs/guides/decode-vorbis.md new file mode 100644 index 00000000..d45e0820 --- /dev/null +++ b/docs/guides/decode-vorbis.md @@ -0,0 +1,25 @@ +# Decode Ogg Vorbis + + +Shaped like `audiomp3.MP3Decoder`, so it plays wherever an MP3 does: through +`audiocore.get_buffer()`, a `Mixer` voice, or a board's audio output. + +```python +import audiovorbis +song = audiovorbis.VorbisDecoder("song.ogg") # or an open binary file +mixer.voice[0].play(song, loop=True) +``` + +It takes the same arguments (a file or a path, and an optional buffer) and +has the same `file`, `open()`, `rms_level`, `samples_decoded`, `deinit()` +and `with` as `MP3Decoder`. Mono and stereo files play; `sample_rate` and +`channel_count` come from the file. Like an MP3, it reads its file while it +plays, so `audiopump` refuses it: feed the pump from the interpreter instead. + +The decoder is Xiph's Tremor, integer-only, carried in +[third_party/tremor](../../third_party/tremor) under its own BSD-3-Clause licence. +It decodes to the same samples on every build, and within two steps of +ffmpeg's float decoder. Other C modules in the same firmware can decode a +stream through it too. [The C interface page](../reference/vorbis.md) has the C interface, +what it needs of memory on a board, and how it's tested. + diff --git a/docs/guides/filter-a-sound.md b/docs/guides/filter-a-sound.md new file mode 100644 index 00000000..f36e1496 --- /dev/null +++ b/docs/guides/filter-a-sound.md @@ -0,0 +1,27 @@ +# Filter a sound + + +`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 hold a constant 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. It is also the one to reach for below about +100 Hz, where the integer coefficients run out of resolution — +[which filter, and why](../how-it-works/upstream-diff.md#which-filter-to-reach-for). + +```python +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) +``` + +Every constructor argument, what can change mid-stream, and the limits are in the [filters and effects reference](../reference/filters-and-effects.md#audiobiquad). diff --git a/docs/guides/meter-levels.md b/docs/guides/meter-levels.md new file mode 100644 index 00000000..f5ca583f --- /dev/null +++ b/docs/guides/meter-levels.md @@ -0,0 +1,27 @@ +# Meter levels + + +N log-spaced bands between two frequencies, plus peak and RMS, one byte each +in half-dB steps (0 is -100 dB or quieter, 200 a full-scale sine). + +```python +meter = audiometer.Meter(32, low_hz=35, high_hz=20000) +meter.feed(pcm, 48000, 2) # interleaved s16, from Python +seq, levels, peak, rms = meter.levels() +``` + +On a board it can listen without Python touching a sample. +`meter.attach(tap, 48000)` reads an `audiopump.Tap` in C each time you call +`levels()`, so any pump stream can be metered, whatever it came from: a file, +I2S, or a USB sound card playing through the pump. `seq` counts analyses, 60 a second of audio, so a +reader can tell fresh levels from a stopped stream. + +Two FFTs, because one can't be both quick and sharp at the bottom of a log +scale: 1024 points for the bands above 400 Hz, and 512 points on a copy +low-passed and decimated by 16 for those below. A band's level is the +spectrum integrated over the band, so pink noise reads flat to within 3 dB +of the median band across 35 Hz to 20 kHz (the bound its test holds). The default floor is 35 Hz because below that +real music barely moves a bar. The window, the filter, the band edges and +the dB rounding avoid libm, so a desktop and a board read the same bytes for +the same samples; `tests/parity/meter_probe.py` checks that. + diff --git a/docs/guides/play-computed-audio.md b/docs/guides/play-computed-audio.md new file mode 100644 index 00000000..f5ba945b --- /dev/null +++ b/docs/guides/play-computed-audio.md @@ -0,0 +1,41 @@ +# Play computed audio + + +CircuitPython's audio outputs play samples, and a sample is filled from C, so +CircuitPython has no built-in way to play a stream your Python code or a graph of +nodes renders as it goes. A `Ring` is a sample you write into. The output +pulls it in C, block by block, while your loop keeps it topped up: + +```python +import audiobusio, board +from array import array +from audiopump import Ring + +ring = Ring(sample_rate=22050, channel_count=1, frames=256, capacity=16) +block = array("h", bytes(512)) # 256 mono frames +while ring.space() >= len(block) * 2: # fill it before play() + ring.write(render(block)) +out = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WCLK, board.I2S_DOUT) +out.play(ring) +while True: + while ring.space() >= len(block) * 2: + ring.write(render(block)) +``` + +It plays through `audiobusio.I2SOut`, `audioio.AudioOut`, +`audiopwmio.PWMAudioOut` and an `audiomixer` voice like any other sample. +When your code falls behind, the output plays a block of silence rather than +stopping, and `ring.stats()[3]` counts those underruns; a write that doesn't +fit is short and returns the bytes it took. `capacity` is how many blocks the +ring holds, which is also its latency. + +Size the ring for what your output takes at once, and fill it before +`play()`. An output that takes a large first gulp underruns at the start +however fast you write, so leave room beyond one block. Blocks of 256 frames +with `capacity=16` hold 186 ms at 22,050 Hz. + +It is the same `audiopump.Ring` MicroPython's pump pulls, with the same +methods (`write`, `space`, `level`, `stats`, `clear`, `deinit`), so one +producer runs on both. On CircuitPython the module carries only `Ring`: the +board's output is the pump. + diff --git a/docs/guides/play-recorded-notes.md b/docs/guides/play-recorded-notes.md new file mode 100644 index 00000000..986e4b02 --- /dev/null +++ b/docs/guides/play-recorded-notes.md @@ -0,0 +1,58 @@ +# Play recorded notes + + +Recorded notes, played at any pitch. `audiomixer.Mixer` plays sources at +their own rate and `synthio` plays single-cycle waveforms; neither can take a +recorded note, play it a fourth up, loop a region of it while the key is held +and let the rest of it out when the key lifts. `Sampler` does that, which is +what a SoundFont instrument and every drum machine need. + +```python +sampler = audiosampler.Sampler(voice_count=8, sample_rate=48000) +audio_out.play(sampler) +sampler.voice[0].play(piano_c4, rate=44100, pitch=2 ** (5 / 12), + loop_start=11000, loop_end=19000, + loop_mode=audiosampler.LOOP_UNTIL_RELEASE, + attack=0.005, release=0.3) +... +sampler.voice[0].release() +``` + +A `Sampler` is an audio source like a `Mixer`: play it into an output, a +mixer voice or an effect. Notes start on its voices. A buffer is mono signed +16-bit — an `array('h')`, a `memoryview`, anything with a buffer — and `rate` +is the buffer's own sample rate (the sampler's when left out), so the step per +output frame is `pitch * rate / sample_rate` and a 44.1 kHz recording plays at +the right speed through a 48 kHz output without being told anything else. +The buffer is held for as long as the voice sounds and let go when it stops. + +The loop modes are SoundFont's `sampleModes` under their own numbers, so a +reader writes the file's value straight in: `LOOP_NONE` (0) plays to the end +and stops, `LOOP_FOREVER` (1) loops `[loop_start, loop_end)` until +`release()` or `stop()`, and `LOOP_UNTIL_RELEASE` (3) loops while the key is +held, then plays on past the loop to the end of the buffer. + +Each voice has an amplitude DAHDSR — `delay`, `attack`, `hold`, `decay` and +`release` in seconds, `sustain` a linear level — run per sample in C, so a +two-millisecond attack is a slope and not a step. `delay` postpones the start +of the sound itself, which is what a flam wants. `level`, `pan` and `pitch` +are live properties: a change lands at the next block, the first two as a +ramp across it so a fader move is not a click, and the third is how a pitch +bend is played. `release()` enters the release stage from wherever the +envelope is; `stop()` is silence from the next block; `playing` says whether +the voice still sounds. + +Every voice is mixed in one C call per block, summed in float and quantised +once, through a 4-point Hermite interpolator, so a loop at pitch 1.0 is a +wire. For why the position is fixed point and how the pitch is verified by +measurement, see [`upstream-diff`](../how-it-works/upstream-diff.md). + +`SampleBuffer(size)` is memory for the samples, outside the collected heap: +`size` zeroed bytes, with a buffer, so you can read into it and play views of +it. A bank of samples in bytearrays makes every `gc.collect()` read all of +them looking for pointers, which on a board with megabytes of samples is +seconds; a `SampleBuffer`'s bytes are never read. A view of one doesn't keep +it alive, so hold the buffer itself for as long as a voice plays from it. +`deinit()`, or the buffer being collected, frees the bytes. On a board whose +audio driver says where (PSRAM on the ESP32 ports) they come from there; on +CPython it is a bytearray. diff --git a/docs/guides/run-the-audio-pump.md b/docs/guides/run-the-audio-pump.md new file mode 100644 index 00000000..6c35e023 --- /dev/null +++ b/docs/guides/run-the-audio-pump.md @@ -0,0 +1,43 @@ +# Run audio off the interpreter thread + +`audiopump` runs the same pull a player runs, in C, on a thread the +interpreter is not on: a task pinned to the other core on esp32, a native +thread on unix and Windows, and the calling thread on a port that has no +threads at all. A graph pulled that way keeps an exact clock while a screen +redraws, a USB stack runs and Python does whatever it likes. + +The thread and the output it writes to come from a platform driver, which +this repository doesn't carry; the end of this section says where it comes +from. The example below is for a board whose driver has opened its output. On +a build without one, such as unix built from this repository alone, +`sink=True` raises `ValueError: no i2s sink open`, and the `service()` form +under **Which builds carry it** is the one to use. + +```python +import audiopump, audiomixer, synthio + +synth = synthio.Synthesizer(sample_rate=48000, channel_count=2) +mixer = audiomixer.Mixer(voice_count=1, sample_rate=48000, + channel_count=2, buffer_size=2048) +mixer.voice[0].play(synth) + +status = bytearray(audiopump.STATUS_BYTES) +audiopump.spawn(mixer, 0x7FFFFFFF, status, sink=True) + +synth.press(60) # while it plays; no parking, no ceremony +mixer.voice[0].level = 0.6 +audiopump.shutdown() # and a soft reset does this for you +``` + +`spawn(sample, blocks, status, …)` starts it; `pull()` runs the same loop on +the calling thread, so `micropython.heap_lock()` around it is a real gate on +"the pull does not allocate"; `service()` advances it where there is no +thread; `retarget(sample, loop=…)` points it at a different tail; +`shutdown()` stops it. `status` is a `bytearray` of counters — blocks, bytes, +a digest of everything pulled, the worst block, what the sink clocked, the +error and the fault — read back with `struct.unpack`. Everything that can +refuse happens on the calling thread. The pull itself never raises, because +there is no interpreter on that thread to raise on, so it publishes a fault +code and stops. + +What `status` holds, which builds carry the pump, and what each source can be are in the [`audiopump` reference](../reference/audiopump.md). To change what plays while the pump runs, see [Change what plays without a click](change-without-a-click.md). diff --git a/docs/guides/swap-what-plays.md b/docs/guides/swap-what-plays.md new file mode 100644 index 00000000..ceb5d1de --- /dev/null +++ b/docs/guides/swap-what-plays.md @@ -0,0 +1,26 @@ +# Change what plays without rewiring + + +A zero-copy pass-through you can re-point while it is playing. Its +`get_buffer` hands back its source's own pointer, length and result; its +`play(source)` swaps that source atomically against a pull in flight. Hold a +port, wire it into a mixer voice or a rack, and change what is behind it as +often as you like — the object the consumer took never changes. + +```python +import audioroute + +port = audioroute.Port(overdrive) +mixer.voice[0].play(port) # take it once + +port.play(bitcrusher) # heard on the next block; nothing rewires +port.source # what it is playing right now +``` + + +`Port` has no `stop()`, deliberately — a port always has a source. A port +pulled while it is already inside itself, which is what a component wrapping +its own output builds, publishes a loop fault and hands back an error +instead of recursing until the stack is gone. + +Why it exists, and what its pull costs: [Additions beyond CircuitPython](../how-it-works/additions.md#why-audiorouteport-exists). diff --git a/docs/guides/use-threads.md b/docs/guides/use-threads.md new file mode 100644 index 00000000..d94ac573 --- /dev/null +++ b/docs/guides/use-threads.md @@ -0,0 +1,15 @@ +# Render on several threads + +You can render on as many threads as you like, as long as **no node is used +from two threads at once**. Give each thread its own synthesizer, effects +and mixer, and each renders exactly the bytes it would render alone, LFOs +included: synthio's block clock belongs to the render in progress on each +thread, not to the process. + +On a free-threaded build (3.15t has wheels), the native extension declares +that it doesn't need the GIL, so those threads run in parallel and importing +audiodsp leaves the GIL off. Every call into a native node holds a lock on +that node, so reconfiguring a node while another thread renders it won't +crash the interpreter. What comes out of that is still undefined, though, +because it breaks the rule above. [tests/test_cpython_free_threading.py](../../tests/test_cpython_free_threading.py) is +the stress test that holds both, sixteen instruments on sixteen threads. diff --git a/docs/how-it-works/additions.md b/docs/how-it-works/additions.md new file mode 100644 index 00000000..834bac35 --- /dev/null +++ b/docs/how-it-works/additions.md @@ -0,0 +1,69 @@ +# Additions beyond CircuitPython + +audiodsp has two kinds of modules. The CircuitPython-compatible core is held +to CircuitPython's own output; [the correctness standard](correctness-standard.md) +says how. The additions are audiodsp's, and each has a reason to exist beside +what CircuitPython already carries. [The modules](../reference/modules.md) +lists them with their documentation. + +## Where they came from + +`audiodynamics` (compression, limiting, expansion, gating, transient shaping) +and `audioroute.Splitter` (fan one stream out to parallel branches) come from +[mpvst](https://github.com/PyDevices/mpvst)'s audio engine, which had them and CircuitPython does not. + +These have no ancestor anywhere and are audiodsp's own: + +- `audiomath`: multiply one stream by another (ring and amplitude + modulation), divide one down in frequency (the analog octave divider), and + `cast_into`, which turns a float block into the int16 a `RawSample` plays + without allocating. +- `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, and a sample-and-hold at an exact rational ratio. +- `audioladder`: a transistor ladder filter, four one-pole stages round a + feedback loop with an odd saturator inside it. +- `audiooscillator`: a band-limited oscillator whose pitch, FM, pulse width + and hard sync are audio streams, read sample by sample. +- `audioconvolve`: apply a measured or synthesized impulse response, by + partitioned FFT. +- `audioverb`: a reverberation tank whose line lengths and output taps come + from Python. +- `audiomodal`: a bank of resonators, which is what a struck object is. +- `audiosampler`: recorded notes played at any pitch, looped, under an + envelope. +- `audioroute.MidSide`: scale the difference between a stereo pair's channels. +- `audiobiquad`: the biquad and all-pass filters with float state. +- `audiograph`: a whole patch rendered by one C call per block. + +Every one of them builds into CircuitPython 11 too, as a user C module; see +[Install](../get-started/install.md#circuitpython-11). + +Three more are for cases CircuitPython doesn't cover. `audiometer` (band +levels for a spectrum meter) is the one CircuitPython doesn't get: its +sources are a board's pump and a USB sound card, and CircuitPython has +neither. `audiovorbis` plays Ogg Vorbis files, which CircuitPython has no +module for. And `audiopump.Ring` lets CircuitPython play audio your own code +computes. + +## Why `audioroute.Port` exists + +Many effect classes rebuild part of their graph when a knob crosses a +threshold and hand out a new node afterwards. A consumer holding the old one +goes on playing the graph the class has finished with, or goes silent. A port +turns that into a re-point. + +`play()` does the protocol lookup, the deinit check and the format match +first, on the calling thread where raising is free, then takes the pump lock +for three stores, so a pull in flight sees the whole old source or the whole +new one and nothing in between. How to use it: +[Change what plays without rewiring](../guides/swap-what-plays.md). + +## Where each reason is written down + +[upstream-diff](upstream-diff.md) holds the measurement behind each module +and each difference from CircuitPython: why `audiobiquad` exists beside +`audiofilters.Filter`, what the dynamics options cost, why `audioshaper` +oversamples, why `audiomodal` is a node rather than a chain of biquads, how +the sampler's pitch is verified, and the memory the reverb tank needs. diff --git a/docs/audiograph.md b/docs/how-it-works/audiograph.md similarity index 100% rename from docs/audiograph.md rename to docs/how-it-works/audiograph.md diff --git a/docs/correctness-standard.md b/docs/how-it-works/correctness-standard.md similarity index 78% rename from docs/correctness-standard.md rename to docs/how-it-works/correctness-standard.md index ee3b857d..3b8f9b54 100644 --- a/docs/correctness-standard.md +++ b/docs/how-it-works/correctness-standard.md @@ -1,9 +1,7 @@ # What audiodsp is held to -Decided on 2026-09-09, replacing every earlier arrangement. One page, -because the previous answer was spread across a golden file, an oracle binary, -a build script, three deviation sections and a rule about a binary nobody was -allowed to rebuild — and nobody could state it in a sentence. +This is the one page that says what each part of audiodsp is held to: a rule +you can state in a sentence, and the gates that enforce it. ## The rule @@ -35,9 +33,9 @@ all of them observed in this repository rather than imagined: tried. Every stored fixture in `flanger_probe.py` matched to the byte throughout. Only comparing the two targets against each other found it. * **A reference can outlive the thing it referenced.** The vstaudio oracle - pinned `audiodynamics` and `audioroute` to micropython-vst3's + pinned `audiodynamics` and `audioroute` to mpvst's `vstaudio_dsp.c` at revision `ac87f13`. That file was deleted from - micropython-vst3 in `6ea60d3` — "Drop the effects library and the DSP that + mpvst in `6ea60d3` — "Drop the effects library and the DSP that moved to audiodsp" — and the plug-in links audiodsp now. The relationship reversed: vstaudio is a consumer of this package, not a grader of it. @@ -64,7 +62,7 @@ Nine of the seventeen are ours, and they are most of the effects palette — whi is why "match CircuitPython" cannot be the only rule, and why the second half of this page is not an afterthought. -Two of ours came from micropython-vst3's engine (`audiodynamics`, `audioroute`) +Two of ours came from mpvst's engine (`audiodynamics`, `audioroute`) and the rest have no ancestor anywhere. Neither fact changes anything: what matters is that no independent implementation exists to compare against today. @@ -95,74 +93,17 @@ A trait without a planted fault is not a check; it is a hope. See `tests/parity/deinit_surface_probe.py` for the shape — its `--fault` mode is run by CI first, and CI fails if the fault *passes*. -## What this retires - -Agreed 2026-09-09; the removals follow this page rather than precede it, so -until each line below is struck the old machinery is still present. This list -is the checklist, not a report. - - -* ~~**The vstaudio oracle**~~ — **done.** `tests/parity/vstaudio_oracle/`, - `tests/parity/build_vstaudio_oracle.sh` and the pinned `VSTAUDIO_REV` are - deleted, along with `golden/dsp_nodes.json`. `verify_dsp.py` is now the - comparison itself: it runs every probe on every interpreter given and requires - the outputs to be byte-identical, with **no stored digest**, and it **refuses - a run with fewer than two interpreters** rather than passing one that cannot - fail. A probe that cannot be covered yet is `PENDING` against a named issue - - visible and counted, neither silently skipped nor standing red. Proved able to - fail: reverting the flanger's 64-bit widening and rebuilding gives - `FAIL flanger_probe.py cpython and micropython differ at output byte 1160`. - - The gate moved workflow with its meaning. It needs two interpreters, so it - runs in `clean-build.yml`, which builds one. `test-cpython.yml` and the - release job keep what a single interpreter *can* say - that every probe runs - - across four operating systems and five Pythons, which catches an import - error or an arithmetic assumption that only holds on x86_64. -* ~~**`cmods/bin/circuitpython` as an untouchable artefact**~~ — **done** - 2026-09-09. The oracle is now built at the version and the ceiling this port - ships (CircuitPython 10.3.0, `CIRCUITPY_SYNTHIO_MAX_CHANNELS=64`) and is - rebuilt deliberately whenever either moves, re-pinned in the same change with - the reason written down. `cmods/build_cp.sh` passes `CP_CFLAGS_EXTRA` through - for the ceiling override, which must be `-U` then `-D` because `-Werror` makes - a conflicting redefinition an error. `tests/test_voice_ceiling_consistency.py` - still compares the binary's bytes against a pin — that check is about noticing - an *undeclared* rebuild and is not retired. The oracle moved to - `cmods/bin/circuitpython-oracle-10.3.0` on 2026-09-17 (audiodsp#89): the old - path is what `build_interpreters.sh`'s `cp-unix` target installs, so a - routine interpreter refresh replaced the oracle with a 14-voice build twice. - The opt-in `cp-oracle` target builds and installs the oracle now, and - nothing else writes that path. - - The rebuild earned itself immediately: it found two behaviour changes - CircuitPython 10.3.0 made to `synthio` and `audiomixer` that this port had not - taken — the panning polarity flip and the zero-crossing loudness gate. Both - were quiet arithmetic edits rather than new nodes, so the port already carried - 10.3.0's new *files* and looked current. Nothing that grades us against our own - past could have seen them. -* ~~**The 14 → 64 voice-ceiling deviation**~~ — **done** 2026-09-09, and gone - rather than documented. Both sides are built at 64, - `SYNTHIO_MIX_DOWN_SCALE` is 129 on both, and `verify_mixdown_knee` is - byte-identical to CircuitPython above the knee as well as below. -* ~~**Stored digests as a reference of record**~~ — **done** for the DSP nodes: - `golden/dsp_nodes.json` is deleted and `verify_dsp` compares interpreters - against each other. The CP-shared gates (`verify_acceptance`, - `verify_effects`, `verify_streaming`, `verify_biquad`, `verify_mixdown_knee`) - still carry stored files, and those are a different case: they hold this port - to *CircuitPython's* answer, which is the rule, not to our own past. Probes - stay — they are how a - render is made reproducible and comparable. What goes is treating the stored - value as the thing that must be matched, rather than as a convenience for - running the comparison. - -`docs/upstream-diff.md` does **not** retire. Deliberate departures from -CircuitPython in nodes CircuitPython has are exactly what it is for, and under -this rule each one is a decision that must be written there or it is a bug. +## Where deviations are written down + +[upstream-diff](upstream-diff.md) records every deliberate departure from +CircuitPython in a node CircuitPython has. Under this rule each one is a +decision that must be written there, or it is a bug. ## What a divergence means now * **CP-shared node, we differ from CircuitPython.** Either a bug of ours, or a bug of theirs worth an upstream report (`docs/upstream-reports/`), or a - deliberate departure that goes in `docs/upstream-diff.md`. Three outcomes, and + deliberate departure that goes in `docs/how-it-works/upstream-diff.md`. Three outcomes, and choosing between them is the work. What it is never is "re-record ours". * **Our own node, the three targets disagree.** Ours, always. One of the three compilations is wrong, or the kernel relies on something undefined. @@ -193,7 +134,7 @@ baseline, and CI's ARM lane had carried an accepted baseline of its own since 2026-09-02 for six `multitap` and `pitchshift` blocks one byte from x86_64. Adding the pragma closed exactly those six, which is where the mechanism stops being inferred and starts being measured -([docs/building-wheels.md](building-wheels.md)). Proving the board half still +([docs/wheels-and-platforms.md](wheels-and-platforms.md)). Proving the board half still needs board digests. See audiodsp#79 and audiodsp#55. ## The other way: a number derived in Python diff --git a/docs/newcomers.md b/docs/how-it-works/index.md similarity index 60% rename from docs/newcomers.md rename to docs/how-it-works/index.md index c3f829f9..cd72e4bf 100644 --- a/docs/newcomers.md +++ b/docs/how-it-works/index.md @@ -1,4 +1,4 @@ -# Newcomer's guide to audiodsp +# How audiodsp works `audiodsp` brings CircuitPython's audio-programming surface to MicroPython and CPython. It supplies familiar top-level modules—`audiocore`, `synthio`, @@ -13,34 +13,13 @@ high-level effects belong in the separate ## Start by choosing a runtime -For CPython 3.11 or newer, install the published wheel from TestPyPI: - -```bash -python -m pip install --index-url https://test.pypi.org/simple/ \ - pydevices-audiodsp -``` - -The installed modules are top-level imports, just as they are on a board: - -```python -import audiocore -import synthio - -synth = synthio.Synthesizer(sample_rate=8_000) -synth.press(69) -status, block = audiocore.get_buffer(synth) -``` - -That example renders a block from a source; it does not select a speaker. A -host can pull the graph for offline rendering, give it to a mixer or pump, or -attach it to a platform-specific audio output. `audiomp3` is firmware-only. - -For MicroPython, audiodsp is compiled into firmware through -[`manifest.py`](../manifest.py), with the pinned `mp3` dependency retrieved by -`scripts/fetch_deps.sh`. audiodsp brings no `ulab`; a build that wants it -names it. The root [README](../README.md#installation) -has the supported CMake and Make build paths. It explains when a custom -manifest must include the port's own frozen modules as well. +On CPython the wheel gives you the modules as top-level imports, just as +they are on a board. On MicroPython, audiodsp is compiled into firmware +through [`manifest.py`](../../manifest.py). [Install](../get-started/install.md) +has the commands for each, and [Your first program](../get-started/first-program.md) +renders a block of audio. A host can pull the graph for offline rendering, +give it to a mixer or pump, or attach it to a platform-specific audio output. +`audiomp3` is firmware-only. ## The mental model @@ -77,8 +56,8 @@ For a new pump port, [the pump-port guide](pump-ports.md) is authoritative. | `lib/audiodsp_util/` | Cross-runtime `float32()` helpers for settings derived in Python. | | `lib/audiorender/` | Desktop-only, NumPy-based offline composition renderer. | | `micropython.mk`, `micropython.cmake` | User-module build glue for Make and CMake MicroPython ports. | -| `tests/` | CPython fixtures, in-repository parity gates, and workspace-level parity tools. | -| `docs/` | Detailed architecture, correctness, porting, wheel, and pump-port documentation. | +| `tests/` | CPython fixtures, in-repository parity gates, and parity tools that need sibling checkouts. | +| `docs/` | Guides, reference, and these architecture pages. | ## One graph, three target shapes @@ -95,13 +74,8 @@ edit. audiodsp-owned modules build into CircuitPython as a user C module CircuitPython-adjacent work. `audiorender` deliberately stays outside firmware: it holds a full composition -in memory and requires NumPy. Install its optional extra on CPython when you -need it: - -```bash -python -m pip install --index-url https://test.pypi.org/simple/ \ - --extra-index-url https://pypi.org/simple/ "pydevices-audiodsp[render]" -``` +in memory and requires NumPy. [Install](../get-started/install.md#cpython-311-or-later) +shows its optional extra. ## Important boundaries @@ -120,20 +94,47 @@ python -m pip install --index-url https://test.pypi.org/simple/ \ ## Testing and contributing Most deep parity checks need the sibling repositories checked out beside -this one, built interpreters, and the fixed CircuitPython oracle. They are intentionally not a -standalone contributor prerequisite. The repository's CI covers CPython +this one, built interpreters, and the fixed CircuitPython oracle. They are +not a standalone contributor prerequisite. The repository's CI covers CPython fixture tests and committed in-repository parity gates; `python -m flake8` is -the local lint gate. +the local lint gate. [CONTRIBUTING.md](../../CONTRIBUTING.md) has the rules: +don't modify the CircuitPython oracle, don't casually update parity goldens, +and don't move the dependency pins. -Before changing source, read [AGENTS.md](../AGENTS.md). In particular, do not -modify the CircuitPython oracle, casually update parity goldens, or move the -dependency pins. Start with a documentation correction, a focused CPython -fixture, or a well-bounded module change; then consult -[the porting plan](porting-plan.md) and -[the upstream-sync record](upstream-sync.md) for the module's intended -behavior and known deliberate differences. +Start with a documentation correction, a focused CPython fixture, or a +well-bounded module change; then read [the upstream-sync record](upstream-sync.md) +for the module's intended behavior and known deliberate differences. For a first tour of the implementation, follow one simple source through `audiocore`'s sample interface, a `synthio.Synthesizer`, and `audiomixer` or `audiopump`. That path explains the common contract behind the larger effect catalogue better than reading the module directories alphabetically. + +## Names + +audiodsp was named `audioif` earlier; that name now belongs to the audio +hardware layer. The modules you import did not change. The helper package is +`audiodsp_util`, and the distribution on PyPI is `pydevices-audiodsp`. + +## How it is checked + +The committed synthesis, mixer, MIDI, streaming and effects fixtures match +CircuitPython 11.0.0-alpha.2 PCM byte-for-byte, built at the same voice +ceiling this port ships (the pin is in `CIRCUITPYTHON_ORACLE`). Import and API +smoke success is not used as a substitute for those comparisons. The +playback-facing pull protocol (`audiocore.get_buffer` and `reset_buffer`) is +consumed by `pydevices`' `lib/audiodev` package (`AudioOut` in +`sample_out.py`), which pumps any audiosample into any of `audiodev`'s +push-PCM transports. + +## Pages in this section + +- [Additions beyond CircuitPython](additions.md): why each added module exists. +- [Sound stability](sound-stability.md): what is stable across releases, and what isn't. +- [The correctness standard](correctness-standard.md): how each module is held to a reference. +- [Keeping in step with upstream](upstream-sync.md): the resync procedure, and every deliberate difference. +- [Differences from upstream, with measurements](upstream-diff.md): the reasons behind each one. +- [Upstream reports](../upstream-reports/README.md): the fixes sent to CircuitPython. +- [The graph runner](audiograph.md): how `audiograph.Graph` renders a patch. +- [Writing a pump port](pump-ports.md): how a platform plugs in a clock, a thread and a sink. +- [Wheels and platforms](wheels-and-platforms.md): what the release builds, and what it skips. diff --git a/docs/pump-ports.md b/docs/how-it-works/pump-ports.md similarity index 97% rename from docs/pump-ports.md rename to docs/how-it-works/pump-ports.md index c94ec206..2d827137 100644 --- a/docs/pump-ports.md +++ b/docs/how-it-works/pump-ports.md @@ -6,7 +6,7 @@ platform, what a mutex is, where the clock comes from and where the audio goes. You supply those four, and nothing else. Write one C file. Define `audiodsp_port_driver()` from -[`../src/shared/audiodsp_port.h`](../src/shared/audiodsp_port.h), return an +[`../../src/shared/audiodsp_port.h`](../../src/shared/audiodsp_port.h), return an `audiodsp_port_ops_t` filled in with whatever your platform has, and put the table in the **same translation unit** as your MicroPython module registration — that last part is not style, and [How it binds](#how-it-binds-and-the-trap-it-dodges) @@ -46,7 +46,7 @@ the interpreter on a board, so an interpreter holding a swap has to be lifted to finish it. The contract these two serve is in -[`../src/shared/audiodsp_pump_lock.h`](../src/shared/audiodsp_pump_lock.h) and it +[`../../src/shared/audiodsp_pump_lock.h`](../../src/shared/audiodsp_pump_lock.h) and it is small: the pump holds the lock for one block pull, the control path holds it around its final swap only, and nothing is ever held across an allocation or across anything that can raise. Recursive because a node's pull @@ -185,7 +185,7 @@ enforced rather than promised: python3 tools/check_portable.py ``` -[`../tools/check_portable.py`](../tools/check_portable.py) fails on +[`../../tools/check_portable.py`](../../tools/check_portable.py) fails on `freertos/`, `esp_*`, `driver/`, `sdkconfig`, `pthread.h`, `windows.h`, `unistd.h`, `sys/time.h`, and on the two dozen call names those headers bring — `clock_gettime`, `nanosleep` and the rest. Comments and string literals are diff --git a/docs/how-it-works/sound-stability.md b/docs/how-it-works/sound-stability.md new file mode 100644 index 00000000..4569f1d4 --- /dev/null +++ b/docs/how-it-works/sound-stability.md @@ -0,0 +1,20 @@ +# 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 for the instruments and effects built on it (the +[audiocomponents](https://github.com/PyDevices/audiocomponents) packages). +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 audiodsp 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 +audiodsp 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. diff --git a/docs/upstream-diff.md b/docs/how-it-works/upstream-diff.md similarity index 98% rename from docs/upstream-diff.md rename to docs/how-it-works/upstream-diff.md index d281ef20..027fc528 100644 --- a/docs/upstream-diff.md +++ b/docs/how-it-works/upstream-diff.md @@ -24,7 +24,7 @@ A voice's position is a 64-bit frame index, 32 bits of integer and 32 of fraction, stepped by `pitch * rate / sample_rate` in the same format. Two reasons, neither taste. A `float` accumulator has 24 bits of mantissa, so a loop a thousand frames long played for a minute accumulates the missing bits -into audible tuning; and `docs/correctness-standard.md` holds every own node +into audible tuning; and `docs/how-it-works/correctness-standard.md` holds every own node to three interpreters rendering it identically, and integer arithmetic is the one kind that three architectures cannot disagree about. Only the fraction becomes a float, for the interpolator. The step is computed once per `play()` @@ -87,7 +87,7 @@ can keep up with. ### How it is verified `tests/parity/sampler_probe.py` in `verify_dsp`'s set, for the three-target -agreement `docs/correctness-standard.md` asks of an own node: an integer +agreement `docs/how-it-works/correctness-standard.md` asks of an own node: an integer triangle wave through binary-fraction pitches and whole-sample envelope times, with the blocks at which a one-shot ends, a released mode-3 voice reaches its end and a release fades to silence printed as integers beside the @@ -549,9 +549,9 @@ a MicroPython build that predates these two fixes shows up: any such binary renders CircuitPython's bytes here to the byte, because the port was faithful. Found by the effects program's `Bitcrusher` (audiocomponents#71) and reported -in [upstream-reports/speedchanger-rate-rounding.md](upstream-reports/speedchanger-rate-rounding.md). +in [upstream-reports/speedchanger-rate-rounding.md](../upstream-reports/speedchanger-rate-rounding.md). The PR is prepared and unfiled: -[upstream-reports/prs/speedchanger-rate-rounding/](upstream-reports/prs/speedchanger-rate-rounding/PR.md). +[upstream-reports/prs/speedchanger-rate-rounding/](../upstream-reports/prs/speedchanger-rate-rounding/PR.md). ## `audiospeed`: the phase accumulator crosses a source buffer, upstream's restarts (audiodsp#91) @@ -598,9 +598,9 @@ frames cannot be indexed at all. That limit is upstream's and this change neither widens nor narrows it. Found by the effects program's `Bitcrusher` (audiocomponents#71) and reported -in [upstream-reports/speedchanger-phase-carry.md](upstream-reports/speedchanger-phase-carry.md). +in [upstream-reports/speedchanger-phase-carry.md](../upstream-reports/speedchanger-phase-carry.md). The PR is prepared and unfiled: -[upstream-reports/prs/speedchanger-phase-carry/](upstream-reports/prs/speedchanger-phase-carry/PR.md). +[upstream-reports/prs/speedchanger-phase-carry/](../upstream-reports/prs/speedchanger-phase-carry/PR.md). ## `audiodelays.Echo`: a delay line shorter than one buffer stays inside its allocation @@ -648,7 +648,7 @@ the signature of an overflow that only bites at the extremes. `verify_dsp` carries a stated skip for `flanger_probe.py` on circuitpython naming that issue, so the three-way is not red on a defect that is not ours. The PR is prepared and unfiled: -[upstream-reports/prs/flanger-int32-overflow/](upstream-reports/prs/flanger-int32-overflow/PR.md), +[upstream-reports/prs/flanger-int32-overflow/](../upstream-reports/prs/flanger-int32-overflow/PR.md), where the overflow is re-measured on a stock build of the 10.3.0 tag — 1717 of 2048 samples of a rails render move when the multiply is widened, and none of an ordinary one. @@ -659,12 +659,12 @@ a language without fixed-width arithmetic cannot surface this class of defect, so it is not evidence about it either way. Running log of every place this port's behavior deliberately differs from -CircuitPython's, or needed a workspace-side fix that isn't a plain port. +CircuitPython's, or needed a local fix that isn't a plain port. Goal: keep this list short. Python-level API and behavior match CP unless noted here. Six of the entries below are upstream bugs rather than divergences, written up -in [upstream-reports/](upstream-reports/). **Five have been filed with +in [upstream-reports/](../upstream-reports/). **Five have been filed with CircuitPython and four are merged** (see that directory's README for the issue and PR numbers): peaking-eq-sign, dds-oscillator-off-by-one, distortion-soft-clip-union and biquad-reset are fixed upstream; @@ -720,7 +720,7 @@ CircuitPython's `audiocore.WaveFile` reads its file via raw FatFS calls and its constructor hard-requires `mp_obj_is_type(arg, &mp_type_vfs_fat_fileio)`. This only works when the mounted filesystem is FatFS — confirmed by testing directly: even CircuitPython's own unix `coverage` build (`bin/circuitpython` -in this workspace, used as the parity oracle everywhere else) raises +in this project, used as the parity oracle everywhere else) raises `TypeError: file must be a file opened in byte mode` for `WaveFile(path)` *and* `WaveFile(open(path, "rb"))`, because the unix port's `open()` returns a POSIX-backed file object, not a `vfs_fat_fileio` one. WaveFile is @@ -815,7 +815,7 @@ notes: - **`audiospeed.SpeedChanger`** has no oracle in the canonical `bin/circuitpython`: `CIRCUITPY_AUDIOSPEED` is gated per-port (only - `ports/raspberrypi/mpconfigport.mk` sets it), and this workspace's unix + `ports/raspberrypi/mpconfigport.mk` sets it), and this project's unix coverage variant (`circuitpython/ports/unix/variants/coverage/mpconfigvariant.mk`) hand-lists its `SRC_C` files rather than using the `CIRCUITPY_AUDIOSPEED` Make-variable cascade, so simply passing the flag on the command line @@ -825,7 +825,7 @@ notes: and rebuilding into a scratch `ports/unix/build-coverage-audiospeed/` directory (never touching the canonical `build-coverage/`), diffed byte-for-byte, then reverted the variant-file edit and deleted the - scratch build -- the parent workspace's circuitpython checkout is back to + scratch build -- the build tree's circuitpython checkout is back to its pre-existing local patch state, unmodified beyond that one-off test. Also needed the same `0.001`/`1000.0` -> `0`/`1000` literal-truncation fix as this port's `rate_to_fp` for `mp_arg_validate_obj_float_range`'s int-typed min/max @@ -896,8 +896,8 @@ runner diffs stdout. concurrent `Note`s across one `Synthesizer`, not per-key) defaulting to 2 -- correctly mirroring CP's own conservative default (`py/circuitpy_mpconfig.mk`). But this port's unix/windows build never -overrode it, while this workspace's CircuitPython oracle -(the parent workspace's circuitpython checkout, unix `coverage` variant, +overrode it, while this project's CircuitPython oracle +(the build tree's circuitpython checkout, unix `coverage` variant, `bin/circuitpython`) is itself built with `-DCIRCUITPY_SYNTHIO_MAX_CHANNELS=14` (`ports/unix/variants/coverage/mpconfigvariant.mk`). At 2, anything past the first two concurrently-alive `Note`s (e.g. two held notes on a @@ -943,7 +943,7 @@ state) is identical after the `CIRCUITPY_SYNTHIO_MAX_CHANNELS` fix. ### The ceiling deviation is RETIRED (audiodsp#31, closed 2026-09-09) There is no longer a ceiling deviation. The comparison build takes the ceiling -this port ships, which is what `docs/correctness-standard.md` requires of any +this port ships, which is what `docs/how-it-works/correctness-standard.md` requires of any node CircuitPython also has: compare with CircuitPython *at the same compile-time configuration*. @@ -977,7 +977,7 @@ provenance of each re-pin. ## Tier 5 audiomp3: license CircuitPython's `lib/mp3` (upstream `adafruit/Adafruit_MP3`, cloned as a -parent workspace sibling pinned to the same commit, +build tree sibling pinned to the same commit, `aac02afd9f24d2ee930f650156654ab9211a306a`) is the Helix fixed-point MP3 decoder, originally developed by RealNetworks in 2003. Every core decoder source file (`bitstream.c` through `statname.h`, @@ -999,7 +999,7 @@ CircuitPython itself (MIT overall) carries this dependency: unmodified, under its own original headers, no relicensing attempted, no separate top-level LICENSE entry for it either (confirmed -- CircuitPython's own repo has no `lib/mp3`-specific license documentation beyond the per-file -headers). This port does the same: the parent workspace's `mp3` sibling is +headers). This port does the same: the build tree's `mp3` sibling is vendored verbatim (one local patch, see below, kept as narrow as possible and documented inline), its RPSL/RCSL headers untouched, and this section is the disclosure. @@ -1010,11 +1010,11 @@ test time from the cloned sibling, not vendored content. ## Tier 5 audiomp3: three Windows-only local fixes, no unix impact -All three were found only when building/running this workspace's Windows +All three were found only when building/running this project's Windows MicroPython target (`mp-windows`/mingw-w64) -- CircuitPython has no Windows port, so none was ever reachable upstream: -- **the parent workspace's `mp3/src/assembly.h`** picks an MSVC-only inline-`__asm{}` code +- **the build tree's `mp3/src/assembly.h`** picks an MSVC-only inline-`__asm{}` code path (plus an MSVC-only `#pragma warning`) whenever `_WIN32` is defined and `_WIN32_WCE` isn't. mingw-w64 GCC also defines `_WIN32` (confirmed: `echo | x86_64-w64-mingw32-gcc -dM -E - | grep _WIN32` -> `#define _WIN32 @@ -1072,7 +1072,7 @@ port, so none was ever reachable upstream: Upstream's Make glue picks between two different `MPDEC_ALLOCATOR(x)`/ `MPDEC_FREE(x)` wirings for `lib/mp3/src/buffers.c` depending on build -variant: the unix `coverage` variant (this workspace's oracle, +variant: the unix `coverage` variant (this project's oracle, `bin/circuitpython`) wires straight to `malloc(x)`/`free(x)`; every other CP port wires through `mp3_alloc(x)`/`mp3_free(x)` (`shared-module/audiomp3/__init__.c`, `MP_WEAK`, defaulting to @@ -1085,13 +1085,13 @@ build-configuration choice invisible to output, not a parity-relevant deviation. `mp3_free` itself also isn't a literal port of CP's version -- see the previous section and the `mp3_alloc.c` header comment for why it uses `gc_free` rather than a direct `m_free(ptr)` call (mainline's `m_free` -needs an explicit size on this workspace's unix port, which `mp3_free`'s +needs an explicit size on this project's unix port, which `mp3_free`'s callers never have in scope). ## Tier 5 audiomp3 verified against the oracle `MP3Decoder` oracle-diffed byte-for-byte against `bin/circuitpython` using -the parent workspace's `mp3/examples/test.mp3` (Adafruit's own bundled fixture, ID3v2.3, +the build tree's `mp3/examples/test.mp3` (Adafruit's own bundled fixture, ID3v2.3, MPEG1 Layer III, 40 kbps CBR, 44.1 kHz stereo, ~12s): full-track decode via `reset_buffer`/`get_buffer` (checksum + byte count identical), `rms_level` and `samples_decoded` at multiple points, construction from a filename @@ -1115,13 +1115,13 @@ up `audiodsp` automatically once built; it just hadn't been rebuilt since the usermod landed) surfaced a batch of portability bugs, none reachable on unix or windows before now: -- **the parent workspace's `mp3/src/mp3dec.h`** picks its fixed-point/asm path from a closed +- **the build tree's `mp3/src/mp3dec.h`** picks its fixed-point/asm path from a closed list of `(__GNUC__, arch)` combinations, `#error`ing on anything else -- with an explicit `MP3DEC_GENERIC` escape hatch for exactly this case. wasm32 matches none of the listed architectures. Fixed in `micropython.mk`: `-DMP3DEC_GENERIC` added, but only when building for the `webassembly` port specifically (detected the same way - the parent workspace's `wasmbridge/micropython.mk` detects it: `$(findstring + the build tree's `wasmbridge/micropython.mk` detects it: `$(findstring /ports/webassembly,$(abspath $(CURDIR)))`) -- unix and windows both already match a named `__GNUC__`/arch branch and must keep using it, not silently fall back to the generic path. @@ -1155,7 +1155,7 @@ none reachable on unix or windows before now: libc does not. Also needed an explicit `(mp_float_t)` cast on `common_hal_audiomp3_mp3file_get_rms_level(self)`'s `float` return value before `mp_obj_new_float()` (which takes `mp_float_t`, `double` on every - build in this workspace) -- the implicit widening is fine under GCC's + build in this project) -- the implicit widening is fine under GCC's default warning set but trips `-Wdouble-promotion -Werror` under clang. Full DSP parity suite (all of tiers 0-5, plus `synthtools_acceptance.py`) @@ -1232,7 +1232,7 @@ everywhere), so it remains kept verbatim. Wiring tier 5 into `micropython.cmake` (deferred at phase 8e, closed at phase 10 -- see docs/porting-plan.md) surfaced two separate issues, one -upstream (Adafruit_MP3/Helix, vendored as the parent workspace's `mp3`) and one in mainline +upstream (Adafruit_MP3/Helix, vendored as the build tree's `mp3`) and one in mainline MicroPython's own CMake glue (`py/mkrules.cmake`, `py/py.cmake`). **`mp3dec.h`'s closed `(__GNUC__, arch)` platform list has no Xtensa @@ -1245,7 +1245,7 @@ microcontrollers -- Xtensa (esp32/esp32s2/esp32s3, not just the RISC-V esp32 variants) was never covered either, matching wasm32 for the same reason: it's just not `-D`efined into the pinned Helix decoder's platform list. So `-DMP3DEC_GENERIC` is needed for every esp32 target this -workspace builds, Xtensa and RISC-V alike; only rp2 (RP2040/RP2350, ARM +project builds, Xtensa and RISC-V alike; only rp2 (RP2040/RP2350, ARM Cortex-M, `__GNUC__ && __ARMEL__`) matches natively. Gated in `audiodsp/micropython.cmake` on ESP-IDF's own `CONFIG_IDF_TARGET_ARCH_RISCV`/`CONFIG_IDF_TARGET_ARCH_XTENSA` sdkconfig @@ -1375,7 +1375,7 @@ Every other module here is CircuitPython's, moved. These two are not: they come from micropython-vst3's `vstaudio` usermod (`usermods/vstaudio/vstaudio_dsp.c`), where its effects library's compressors, limiters, gates, de-essers and parallel branches were built. CircuitPython has no equivalent and never had -one, so there is no oracle in the parent workspace's circuitpython checkout to diff against and nothing +one, so there is no oracle in the build tree's circuitpython checkout to diff against and nothing in this section is an upstream deviation. What it records instead is where the port differs from *its* original. @@ -2176,7 +2176,7 @@ the flat-EQ passthrough, and the Nyquist refusal. > applies to **`audiobiquad`, which is ours, and to nothing CircuitPython has.** > `synthio.Biquad` — and so `audiofilters.Filter` and a `Note.filter` chain — > runs `audiodsp_biquad_cp_*`, CircuitPython's own Q15 arithmetic, on all three -> targets, because `docs/correctness-standard.md` holds a node CircuitPython +> targets, because `docs/how-it-works/correctness-standard.md` holds a node CircuitPython > also has to CircuitPython's bytes. > > Why the scope had to be decided at all: it had been applied on **one target @@ -2254,8 +2254,6 @@ talked out of. The same applies to `audiobiquad.AllPass` against `audiofilters.Phaser`, whose feedback is clamped to `0.1..0.9` so a true null is not reachable. -The README's `audiobiquad` section links here rather than repeating any of it. - > Two smaller entries are **not** in this deviation's scope and stay applied > everywhere, because upstream has already merged them: PEAKING_EQ's `b2` sign > (CircuitPython `main` 8fabdbbfb1) and `synthio_biquad_filter_reset()` clearing @@ -2423,8 +2421,8 @@ workaround moved them up out of the worst of it by accident. any of their probes. - Stock CircuitPython does not get this. `apply_cp_patches.sh` only *adds* modules to a CP tree; `synthio` and `audiofilters` there are upstream's, so - a CP board still cannot filter below a few hundred hertz. See the effects - README, "A note on filters off a stock CircuitPython board". + a CP board still cannot filter below a few hundred hertz. See the audioeffects + documentation in audiocomponents. ## `audiofilters.Phaser` holds a DC constant forever (audiodsp#36, kept verbatim) @@ -2837,7 +2835,7 @@ claves+cowbell circuit: | claves | 53 ms | 207 ms — inherited the cowbell's tail | 53 ms | | cowbell | 203 ms | 55 ms — cut to the claves' tail | 203 ms | -The same code under the parent workspace's built `micropython` was already correct (57.3 / 204.3), +The same code under the build tree's built `micropython` was already correct (57.3 / 204.3), which is what identified the target rather than the instrument as the fault. **Fix.** `_audiodsp.EnvelopeState` gains `set_definition(...)`, which replaces the @@ -2888,7 +2886,7 @@ sequence never holds more than a four-note chord. note's slot, exactly as the oracle does, and still refuses when every channel is held. Verified on all three runtimes with one script: all released → press taken, all held → press refused and every held note intact, identical on the -CPython target, and the parent workspace's built `micropython` and `circuitpython`. +CPython target, and the build tree's built `micropython` and `circuitpython`. Regression coverage: `tests/test_cpython_released_reclaim.py`. @@ -2932,7 +2930,7 @@ the result; it now divides in float. Measured on two unix binaries built either side of the edit: of 12801 fractional MIDI values, 5082 (39.7%) differ, by at most 7 ulps of `float` — **0.0009 cents**, about a millionth of a semitone. 54 of the 128 integer MIDI notes are among them (A440 is -not). Nothing in this workspace has ever captured a single-precision +not). Nothing in this project has ever captured a single-precision render as a golden, so nothing goes red; the change is recorded here because it is a real, if inaudible, difference in board output. @@ -4038,7 +4036,7 @@ What found it is worth recording too. The trait that reads the **output** reaching zero passed the entire time, because the output had rounded to zero long before. Only the trait that reads the **state** could see it, and an own node has no oracle that would have said anything at all. That is -`docs/correctness-standard.md`'s argument for traits, arriving on its own. +`docs/how-it-works/correctness-standard.md`'s argument for traits, arriving on its own. ### Muting a mode is not stopping it @@ -4105,7 +4103,7 @@ there with eight drums sounding, which is 31% and 3.2x real time. ### How it is verified `tests/parity/modal_probe.py` in `verify_dsp`'s set, for the three-target -agreement `docs/correctness-standard.md` asks of an own node, and it prints +agreement `docs/how-it-works/correctness-standard.md` asks of an own node, and it prints invariants as well as PCM — the block at which the tail reaches exact zero, whether the bank is still sounding after its source has gone, and the peak of a mode whose Q a filter would refuse. `tests/test_cpython_audiomodal.py` diff --git a/docs/upstream-sync.md b/docs/how-it-works/upstream-sync.md similarity index 99% rename from docs/upstream-sync.md rename to docs/how-it-works/upstream-sync.md index d8056186..27a81e4b 100644 --- a/docs/upstream-sync.md +++ b/docs/how-it-works/upstream-sync.md @@ -229,7 +229,7 @@ reason, and a resync should not take them. - **audiocore**: `get_buffer()` returns a byte view, and the oracle is patched to match - ([why](upstream-diff.md#audiocoreget_buffer-returns-a-byte-view-circuitpython-patch)). + ([why](upstream-diff.md), under "`audiocore.get_buffer` returns a byte view"). A host reset keeps the frames a node has already taken ([why](upstream-diff.md#a-host-reset-keeps-the-frames-a-node-has-already-taken-audiodsp181)). `WaveFile` opens a path as well as a file. diff --git a/docs/how-it-works/wheels-and-platforms.md b/docs/how-it-works/wheels-and-platforms.md new file mode 100644 index 00000000..70fb5c5c --- /dev/null +++ b/docs/how-it-works/wheels-and-platforms.md @@ -0,0 +1,89 @@ +# Wheels and platforms + +What a release builds, which platforms are proven by CI and which aren't, +and why. The steps to build and release are in +[CONTRIBUTING.md](../../CONTRIBUTING.md#build-and-release-wheels). + +## What a release builds + +A release builds and validates exactly **24** wheels +(`expected-wheel-count` in `.github/workflows/publish-release-packages.yml` +is the number that counts; if this page and that value disagree, the workflow +is right): + +- CPython 3.11 to 3.15, plus the free-threaded 3.15t, on manylinux_2_28 + x86_64, Windows AMD64 and macosx_arm64: 18. +- CPython 3.13 to 3.14, Android API 21 arm64_v8a and x86_64: 4. +- CPython 3.13 to 3.14, Pyodide wasm32: 2. + +## macOS: CI-proven + +macOS is enabled at the CI-proven tier: ship everything for macOS that CI +can prove. `test-cpython.yml` builds, installs and runs the unit, contract +and parity suite and `tools/validate_api.py` on `macos-latest`, and its +`macos-wheels` job runs the same cibuildwheel a release uses, compiling the +native extension for CPython 3.11 to 3.15 and 3.15t and running an import +smoke test in each wheel, on every push. It publishes nothing. Hardware audio +output on a real Mac is community-verified: a field report is what would +promote that claim. + +The wheel is **`macosx_arm64` only**. The runner is Apple Silicon, and an +x86_64 or universal2 wheel cross-compiled there couldn't run its test +command. An untested wheel is a claim, not a capability, so an Intel Mac +field report is what would add x86_64. + +## Linux aarch64: CI-proven, not shipped + +`[tool.cibuildwheel.linux]` uses `archs = ["auto64"]`, the runner's own +64-bit architecture, with `manylinux_2_28` images for both x86_64 and +aarch64. Naming `["x86_64", "aarch64"]` explicitly would make the x86_64 +release runner try to build aarch64 under QEMU, which no workflow configures. +`auto64` resolves to x86_64 on an x86_64 runner and aarch64 on an ARM one, so +one config is correct everywhere. + +`test-cpython.yml` proves aarch64 on native ARM runners (`ubuntu-24.04-arm`), +not under QEMU, because the bugs worth catching are the ones that differ by +architecture, and an emulator reproduces those only by accident: + +- the full matrix (build, install, unit and contract tests, `validate_api` + and the parity verifiers) runs on aarch64 for CPython 3.11 to 3.14; +- a `linux-arm-wheels` job runs the same cibuildwheel a release uses, + compiling the extension and running each wheel's import smoke test. + Nothing is published. + +A release still ships no aarch64 wheel. The organization's desktop wheel +matrix has no ARM cell, so shipping one means adding an ARM runner to that +matrix, cutting a new publishing tag, and moving this repository's pin and +`expected-wheel-count` together. + +## The same bytes within one architecture + +Bit-identical audio is required **within one CPU architecture, not across +them**. No tolerance is introduced anywhere: the gate stays exact, and an +architecture that genuinely differs is held to its own recorded baseline, +accepted deliberately with its evidence written beside it. That is a stricter +regression detector than a threshold. + +aarch64 is the example. At first, `verify_effects` differed on `multitap` and +`pitchshift`, the two effects that interpolate along a delay line: a +compiler's fused multiply-add changes the rounding, and AArch64 fuses by +baseline where x86-64 doesn't without `-mfma`. Forbidding contraction in +every `src/shared/` file that computes in float closed it (issue +[#79](https://github.com/PyDevices/audiodsp/issues/79)), so aarch64's own +baseline was removed from `tests/parity/golden/effects_component.json`: it is +held to the oracle hash itself, the same one x86_64 is held to. + +## What isn't built + +- **musllinux** stays skipped: it doubles the Linux matrix for the Alpine + family. aarch64 is built because its users can be named (every Pi and 64-bit + SBC); Alpine's can't. It remains in `pyproject.toml`'s cibuildwheel `skip` + list. +- **win32** (32-bit Windows): every Windows target here is 64-bit, and + nothing in this repository has been built or tested 32-bit. Also in the + `skip` list. +- **MicroPython `.mpy`**: the native modules are firmware. MicroPython and + CircuitPython build them from `src/` as a user module, so there is nothing + for mip to carry. The pure-Python tier publishes to mip as source, because + compiling it to `.mpy` would pin it to one bytecode version per MicroPython + release. diff --git a/docs/images/biquad-response.png b/docs/images/biquad-response.png new file mode 100644 index 00000000..588f9ba9 Binary files /dev/null and b/docs/images/biquad-response.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..b6cd301a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,51 @@ +# audiodsp documentation + +audiodsp is the OmniPython audio engine: CircuitPython's audio modules +(`audiocore`, `synthio`, `audiomixer` and the effects) on MicroPython and +CPython with the same import names, plus filters, a reverb, a sampler, a +Vorbis decoder, a level meter and an audio pump that CircuitPython doesn't +have. Start with install, then your first program. + +## Get started + +- [Install](get-started/install.md): CPython wheel, MicroPython manifest, CircuitPython user module. +- [Your first program](get-started/first-program.md): measure a filter, with its output. + +## Guides + +- [Play computed audio](guides/play-computed-audio.md) with `audiopump.Ring`. +- [Run audio off the interpreter thread](guides/run-the-audio-pump.md) with `audiopump`. +- [Change what plays without a click](guides/change-without-a-click.md) with `fade=`. +- [Change what plays without rewiring](guides/swap-what-plays.md) with `audioroute.Port`. +- [Decode Ogg Vorbis](guides/decode-vorbis.md). +- [Filter a sound](guides/filter-a-sound.md) with `audiobiquad`. +- [Play recorded notes](guides/play-recorded-notes.md) with `audiosampler`. +- [Meter levels](guides/meter-levels.md) for a spectrum display. +- [Render on several threads](guides/use-threads.md), including free-threaded Python. + +## Reference + +- [The modules](reference/modules.md): what ships, and where each is documented. +- [Filters and effects](reference/filters-and-effects.md): `audiobiquad`, `audiodynamics`, `audioecho`, `audioshaper`, `audioladder`, `audioverb`. +- [Sources and synthesis](reference/sources-and-synthesis.md): `audiooscillator`, `audiomodal`, `audiograph`. +- [audiopump](reference/audiopump.md): status, builds, sources. +- [Vorbis](reference/vorbis.md): the C interface, memory and tests. + +## How it works + +- [How audiodsp works](how-it-works/index.md): the pull protocol, the three targets, the repository map. +- [Additions beyond CircuitPython](how-it-works/additions.md) +- [Sound stability](how-it-works/sound-stability.md) +- [The correctness standard](how-it-works/correctness-standard.md) +- [Keeping in step with upstream](how-it-works/upstream-sync.md) +- [Differences from upstream, with measurements](how-it-works/upstream-diff.md) +- [Upstream reports](upstream-reports/README.md): the fixes sent to CircuitPython. +- [The graph runner](how-it-works/audiograph.md) +- [Writing a pump port](how-it-works/pump-ports.md) +- [Wheels and platforms](how-it-works/wheels-and-platforms.md) + +## Changelog + +- [Changelog](changelog.md): what changed in each release. + +[Roadmap](../ROADMAP.md) · [Contributing](../CONTRIBUTING.md) diff --git a/docs/porting-plan.md b/docs/porting-plan.md index afb4f9ab..fe904216 100644 --- a/docs/porting-plan.md +++ b/docs/porting-plan.md @@ -133,7 +133,7 @@ and their source is micropython-vst3 rather than CircuitPython: any of them, so this repository builds them into CircuitPython as a user C module (`circuitpython.mk`) — the only direction in this repo where CircuitPython is the recipient. See - `docs/upstream-diff.md` for what changed in the moves and what was kept. + `docs/how-it-works/upstream-diff.md` for what changed in the moves and what was kept. - **tier 8** is pure Python under `lib/`. `audioinstruments` — 55 classic synthesizers, keyboards and drum machines written entirely in `synthio`, no samples — and `audioeffects` — 45 effect classes built on the tiers @@ -249,11 +249,11 @@ consumer there is no aggregator, so `micropython.cmake` carries the same tests against `bin/circuitpython` (`RawSample`: full property/deinit/ context-manager/PCM diff, byte-for-byte match; `WaveFile`: PCM verified against Python's own `wave` module instead, since CP's own unix build - can't open a WaveFile at all — see docs/upstream-diff.md). Found and + can't open a WaveFile at all — see docs/how-it-works/upstream-diff.md). Found and fixed a real gap along the way: mainline has no code path that invokes a declarative property on a *native* type (CP patches this into its own core `py/runtime.c`); every ported type needs an explicit - `attr, cp_compat_attr` slot instead — see docs/upstream-diff.md, + `attr, cp_compat_attr` slot instead — see docs/how-it-works/upstream-diff.md, "Property invocation needs an explicit attr slot." This applies to every remaining tier, not just audiocore. 4. **Tier 2 `synthio`** *(done)*. Full engine ported in dependency order @@ -264,7 +264,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same `Synthesizer` note press → attack → decay → sustain → release → PCM render cycle, plus a `MidiTrack` melody, both bit-exact against upstream. `synthio.from_file()` got the same portable-stream adaptation as - `WaveFile` (see docs/upstream-diff.md); everything else is a mechanical, + `WaveFile` (see docs/how-it-works/upstream-diff.md); everything else is a mechanical, oracle-verified port. One new compat piece needed: `cp_compat/namedtuple` (CP's `py/objnamedtuple.h` extension for building a const namedtuple type at compile time, used for `Envelope`) — same "verify against @@ -278,7 +278,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same 8-bit unsigned sample formats, single- and multi-voice mixdown (verbatim-copy vs. add-mixed paths), looping, and `level`/`panning` driven by a `synthio.LFO`/`synthio.Math` block input. Two deviations, - both mechanical (see docs/upstream-diff.md): the `#if CIRCUITPY_SYNTHIO` + both mechanical (see docs/how-it-works/upstream-diff.md): the `#if CIRCUITPY_SYNTHIO` guard around block-input level/panning is unconditional here (this port always has synthio); ARM CMSIS DSP intrinsics dropped for the portable C fallback unconditionally (numerically identical, avoids a CMSIS @@ -297,7 +297,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same in the canonical unix coverage build); every deviation and verbatim-kept upstream quirk (a union type-pun, a `memset` fill-value truncation, an inconsistent `single_channel_output` check, two custom `__exit__` - overrides) is catalogued in docs/upstream-diff.md. Full regression clean + overrides) is catalogued in docs/how-it-works/upstream-diff.md. Full regression clean (`build_interpreters.sh`, LVGL smoke test, all module imports) after every change. 7. **`synthtools` end-to-end (unix)** *(done)*. **Primary acceptance @@ -318,7 +318,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same detail, including the isolation methodology and the deterministic-PRNG test-harness technique needed for a byte-exact diff (`synthtools` reseeds oscillator phase via `random.randint()` by design), is in - docs/upstream-diff.md. Full regression clean (tier 0-4 parity suite, + docs/how-it-works/upstream-diff.md. Full regression clean (tier 0-4 parity suite, LVGL smoke test) after the fix. 8. **Tier 6 outputs** *(8a-8e done; superseded design)*. The original plan here (a native SDL2-callback `AudioOut`) was replaced after actually @@ -367,7 +367,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same `NotImplementedError` from on `_WIN32` for a real file object, so `MP3Decoder` reading an `open()`ed file was completely broken on `micropython.exe`. Fixed with an `#ifdef _WIN32` fallback in - `src/audiomp3/MP3Decoder.c` (see docs/upstream-diff.md, "Tier 5 + `src/audiomp3/MP3Decoder.c` (see docs/how-it-works/upstream-diff.md, "Tier 5 audiomp3: three Windows-only local fixes"). A mechanical `win_audio` `AudioOut` smoke (construct, play a `synthio.Note`, service, close, no hardware errors) also passed under `micropython.exe`; true by-ear @@ -378,7 +378,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same platform-detection gap, two merged-header duplicate-typedef errors, a missing `M_PI`, dead-code/`-Wunused-function`, a missing ``/an implicit float promotion in `MP3Decoder.c` -- see - docs/upstream-diff.md, "Phase 8d: WASM build fixes"). Ran the full DSP + docs/how-it-works/upstream-diff.md, "Phase 8d: WASM build fixes"). Ran the full DSP parity suite (tiers 0-5 + `synthtools_acceptance.py`) against the rebuilt interpreter via a headless Node driver (`loadMicroPython()`/`runPythonAsync`, no wasm-equivalent CLI exists) @@ -394,7 +394,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same could never have caught. Fixed properly (`.u_bool`, not `.u_obj`); now produces byte-identical output across all three of this port's own targets (unix/windows/wasm) and a deliberate, documented divergence - from the x86-64 CP oracle for that one field -- see docs/upstream-diff.md, + from the x86-64 CP oracle for that one field -- see docs/how-it-works/upstream-diff.md, "Distortion soft_clip: verbatim-kept union type-pun turned out to be architecture-dependent." Full regression clean on unix/windows/wasm after the fix (parity suite, LVGL smoke test on unix). @@ -453,7 +453,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same 9. **Tier 5 `audiomp3`** *(done)* — ported out of order (before phase 8, which is blocked on mpftp/hardware coordination), since nothing else depends on it and it needs no hardware. License checked first (see - docs/upstream-diff.md, "Tier 5 audiomp3: license"): the Helix decoder + docs/how-it-works/upstream-diff.md, "Tier 5 audiomp3: license"): the Helix decoder core is RPSL 1.0/RCSL 1.0, not MIT, and this port carries it unmodified under those terms rather than relicensing it -- same as CircuitPython itself. Cloned `mp3` (upstream `adafruit/Adafruit_MP3`) as a parent diff --git a/docs/reference/audiopump.md b/docs/reference/audiopump.md new file mode 100644 index 00000000..c6fcecf5 --- /dev/null +++ b/docs/reference/audiopump.md @@ -0,0 +1,83 @@ +# audiopump + +What `spawn()` takes, what `status` holds, which builds carry the pump and +what can feed it. To start one, see +[Run audio off the interpreter thread](../guides/run-the-audio-pump.md). + +## `retarget` and `loop=` + +`retarget`'s `loop=` travels **with** the swap rather than being stored when +you call it: the old tail is pulled until the next block boundary, so setting +the flag early ends the pump one block before the new tail ever runs. Leave it +out and the flag stays as `spawn()` set it, which is what every caller before +the argument existed wanted. Get it wrong the other way and a looping client +left alone on a live pump stops at the end of its lap. + +## `backpressure()` + +```python +>>> audiopump.backpressure() +True +``` + +True where a full output ring makes the pump **wait** for room instead of +dropping the block; False where a free-running pump would lose audio. It is +the question a driver asks before it decides whether to park the pump between +ticks, and it is True in two different ways: on a threaded port whose driver +fills in `park_spin`, where a wake ends the wait, and in service mode, where +the loop hands the thread back on a full ring and the caller's next +`service()` is the wake. It is False only on a threaded build with no +`park_spin` — a driver that is not finished, which now says so rather than +quietly losing blocks. + +Three kinds of source feed it and they are all the same kind of thing to it: +a graph; `audiopump.Ring`, an audiosample node Python writes PCM into, so a +pushed stream can sit behind a Mixer with effects on it like anything else; +and `audiopump.Events`, a queue of frame-stamped presses, plays and levels +the pump applies at block boundaries, which is what puts a sequenced bar on +the audio's clock instead of the interpreter's. `audiopump.Tap` reads what is +going out, for a meter or a scope, without being in the path. + +**Which builds carry it.** Every MicroPython port: unix, Windows, +WebAssembly and esp32. **The CPython wheel does too**, in the shape a port +with no thread has: `driver()` is `'none'`, `threaded()` is False, `spawn()` +returns −2 and adopts the graph, and nothing is pulled until you call +`service()`, which fills the ring you passed to `spawn()` and returns when it +is full. The names, the status block, `Ring`, `Events` and `Tap` are the same, +and so are the bytes: drained from the ring, a graph is byte-identical to the +same graph on the MicroPython unix build. + +```python +status = bytearray(audiopump.STATUS_BYTES) +ring = bytearray(16384) +audiopump.spawn(mixer, 0x7FFFFFFF, status, ring=ring) +buf = bytearray(4096) +while playing: # from your app's timer, say every 20 ms + audiopump.service() + n = audiopump.drain(buf) + sink.write(memoryview(buf)[:n]) +``` + +`audiodev` does this for you: on CPython its players drive the pump from +their own tick. What CPython does not have is `pull()` under a heap lock, +because it has no heap lock, and audiocore's pull-depth cap: a graph that +leads back into itself is caught at Python's recursion limit instead, and +reported as the same fault. **CircuitPython carries only `Ring`**: its playback +layer already pulls natively from its own audio thread, and a second pull loop +would be a second owner of the same graph, so there a board's audio output +pulls the `Ring` itself X. It still takes the lock and the +hook table, on default hooks, which cost a load and a branch. + +The hardware — the channel a board writes into, and a live microphone as a +source — is not here. It arrives as a separate module, `_audioif`, from a +platform driver, and `audiopump.driver()` tells you which one bound: + +```python +>>> audiopump.driver() +'esp32' # or 'pthread', 'win32', 'none' +``` + +`'none'` is a real answer rather than a failure: the default hook table is +all NULLs, which means one thread and no hardware, and that is exactly how +the WebAssembly build runs. Writing a driver for a platform that has none yet +is [the pump-port guide](../how-it-works/pump-ports.md). diff --git a/docs/reference/filters-and-effects.md b/docs/reference/filters-and-effects.md new file mode 100644 index 00000000..cf9a9f7a --- /dev/null +++ b/docs/reference/filters-and-effects.md @@ -0,0 +1,205 @@ +# Filters and effects + +What the added filters and effects take and return. For a task-shaped +introduction to the biquad, see [Filter a sound](../guides/filter-a-sound.md). +The reasons these exist beside CircuitPython's own modules are in +[Additions beyond CircuitPython](../how-it-works/additions.md). + +## `audiobiquad` + + +| | `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 | + +When a `Biquad`'s `frequency`, `Q`, `gain_db` or `mode` changes while it plays, +it glides to the new filter over about 20 ms instead of jumping, so a preset +change that drops the corner from 20 kHz to 20 Hz doesn't thump. A block input +glides the same way, so an LFO on `frequency` arrives about a block late. +`mix` still moves at once, and so does a filter that hasn't played yet. + +`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 +[upstream-diff](../how-it-works/upstream-diff.md) for the measurements. + +## `audiodynamics` + +`audiodynamics.Dynamics` carries options beyond what the engine it came +from had, 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: +[upstream-diff](../how-it-works/upstream-diff.md). + +## `audioecho` + +`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. Shifting up, the climb fades out at the top of the band +rather than folding back past Nyquist. See +[upstream-diff](../how-it-works/upstream-diff.md) for what each was asked for +and what it measures. + +If you want the delay in frames rather than milliseconds, give `delay_frames` +instead of `delay_ms`: the line is read exactly that many frames back. At +44.1 and 22.05 kHz some whole frames have no `delay_ms` that lands on them, +so a lossless repeat at those rates needs `delay_frames`. +## `audioroute.MidSide` + +`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` + +`audioshaper.Waveshaper(sample_rate=…, curve=…, oversample=4, +channel_count=2, **options)` moves everything but its first four arguments +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. A curve that reaches the rails has a ceiling of its +own: the decimator can ring past them on a hard edge, so keep +`post_gain * max(abs(curve))` at or below `audioshaper.CLIP_HEADROOM` +(0.74) and put the rest of the wanted level on a mixer voice after this +node. Why each of those is the shape it is, and what the +oversampling measures: +[upstream-diff](../how-it-works/upstream-diff.md), "`audioshaper`: audiodsp's own, +and the two things a fixed curve cannot be". + +`audioshaper.SampleHold(source, num=…, den=…)` is the same module's other +node: a zero-order hold in which `num` source frames carry `den` new values, +so 26 040 Hz at 48 kHz is 400/217 and is **exact** — one frame in, one frame +out, at the source's own rate, channels and bit depth, with an integer +accumulator that cannot drift however long the render runs. `num == den` is a +wire byte for byte, `latency` is 0 (what a hold displaces is an event landing +on a frame it drops, up to `ceil(num/den) − 1` frames, which is the effect +and the caller's to report), and `set(num, den)` moves the ratio mid-stream. +It exists because the palette's sample-and-hold was a pair of +`audiospeed.SpeedChanger` nodes whose 16.16 rates cannot be made reciprocal: +measured on that hold, the pair ran 0.9999947 of the rate it asked for and +flanged a static setting by 10.74 dB over twelve seconds. The rate form was +not added to `SpeedChanger` because that module is CircuitPython's and an +argument added here would not exist on a board — +[upstream-diff](../how-it-works/upstream-diff.md), "`audioshaper.SampleHold`: +lo-fi's other half". +## `audioladder` + +`audioladder` is 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: + +```python +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 + cutoff_octaves=10.0, # octaves for a full-scale cutoff input + buffer_size=None, # bytes a block, as on the effects; 256 frames +) +ladder.play(source) +ladder.set(cutoff_hz=1200.0) # any option, mid-stream, once a block +ladder.modulate(cutoff=lfo) # the cutoff read from a stream, every sample +ladder.clear() # empty the integrators; stops a sustained tone +``` + +`set()` moves the cutoff once a block, which is plenty for an envelope or a +knob. Sweep it with an audio-rate signal that way and you hear the staircase, +a buzz at the block rate. The `cutoff` input reads the cutoff every sample +instead, as `cutoff_hz * 2 ** (v * cutoff_octaves)` for an input at `v` of +full scale (first channel only), so filter FM sounds like filter FM. An input +of zero, or none at all, is the filter exactly as `cutoff_hz` sets it. +## `audioverb` + +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. + +```python +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. When the source ends, +the tail rings out on silence to that zero and the node rests, so a source +played into it afterwards meets an empty plate; `audioecho.FeedbackDelay` and +`audioconvolve.Convolver` do the same. See +[upstream-diff](../how-it-works/upstream-diff.md) for the measurement, the cost +and the memory the default table needs. diff --git a/docs/reference/modules.md b/docs/reference/modules.md new file mode 100644 index 00000000..1b74e3cb --- /dev/null +++ b/docs/reference/modules.md @@ -0,0 +1,59 @@ +# The modules + +audiodsp is CircuitPython's audio system for MicroPython and CPython: +`audiocore`, `audiomixer`, `synthio` (including `MidiTrack`), the effects +modules (`audiofilters`, `audiodelays`, `audiofreeverb`, `audiospeed`), +`audiomp3`, and CircuitPython's `play()`, `stop()`, `pause()` and `resume()` +output semantics. Import names stay `audiocore`, `synthio` and the rest, +matching CircuitPython, so source runs unchanged. + +It adds modules CircuitPython doesn't have. Each has a page here or a guide: + +| Module | What it does | Where | +|---|---|---| +| `audiobiquad` | Biquad and all-pass filters whose tails reach exact zero | [guide](../guides/filter-a-sound.md), [reference](filters-and-effects.md#audiobiquad) | +| `audiodynamics` | Compression, limiting, expansion, gating, transient shaping | [reference](filters-and-effects.md#audiodynamics) | +| `audioecho` | A delay with a filter, a soft-clip and a cross-feed in its feedback loop | [reference](filters-and-effects.md#audioecho) | +| `audioroute` | `Splitter` fans a stream out; `MidSide` scales stereo width; `Port` is a re-pointable wire | [MidSide](filters-and-effects.md#audioroutemidside), [Port guide](../guides/swap-what-plays.md) | +| `audioshaper` | A waveshaper whose curve is data, and an exact-ratio sample-and-hold | [reference](filters-and-effects.md#audioshaper) | +| `audioladder` | A transistor ladder filter | [reference](filters-and-effects.md#audioladder) | +| `audioverb` | A reverberation tank whose line lengths and taps come from Python | [reference](filters-and-effects.md#audioverb) | +| `audiomath` | Multiply one stream by another, divide one down in frequency, and `cast_into` | [reasons](../how-it-works/additions.md) | +| `audioconvolve` | Apply an impulse response by partitioned FFT | [reasons](../how-it-works/additions.md) | +| `audiooscillator` | A band-limited oscillator read sample by sample | [reference](sources-and-synthesis.md#audiooscillator) | +| `audiomodal` | A bank of resonators | [reference](sources-and-synthesis.md#audiomodal) | +| `audiograph` | A whole patch rendered by one C call per block | [reference](sources-and-synthesis.md#audiograph), [how it works](../how-it-works/audiograph.md) | +| `audiosampler` | Recorded notes played at any pitch, looped, under an envelope | [guide](../guides/play-recorded-notes.md) | +| `audiometer` | Band levels for a spectrum meter | [guide](../guides/meter-levels.md) | +| `audiovorbis` | Ogg Vorbis files | [guide](../guides/decode-vorbis.md), [C interface](vorbis.md) | +| `audiopump` | The audio pull, off the interpreter thread | [guide](../guides/run-the-audio-pump.md), [reference](audiopump.md) | + +## Beside the core + +Two pure-Python modules sit beside the CircuitPython-compatible core: + +- **`lib/audiodsp_util/`** — `float32(value)` and `float32_bits(value)`, + `struct` and nothing else. A Python float is the interpreter's own width: + a double here, a **single** on every board. So a setting written in Python + — `node.mix = 0.35` — is two different numbers, and the node renders + different bytes on a board than on a desktop before its kernel is reached. + Put a derived setting through `float32` and both targets hold the number + the board would have held. See + [docs/correctness-standard.md](../how-it-works/correctness-standard.md). +- **`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](https://github.com/PyDevices/audiocomponents) repository, +together with the audio component contract they implement: the metadata +manifest +([docs/audio-components.md](https://github.com/PyDevices/audiocomponents/blob/main/docs/audio-components.md)) +and the runtime API +([docs/audio-component-api.md](https://github.com/PyDevices/audiocomponents/blob/main/docs/audio-component-api.md)). +`audiorender` drives any component that speaks that API and does not need +those packages installed. diff --git a/docs/reference/sources-and-synthesis.md b/docs/reference/sources-and-synthesis.md new file mode 100644 index 00000000..b0166a4b --- /dev/null +++ b/docs/reference/sources-and-synthesis.md @@ -0,0 +1,89 @@ +# Sources and synthesis + +What the added sound sources take and return. `audiosampler` is in +[Play recorded notes](../guides/play-recorded-notes.md); the reasons behind +each module are in [Additions beyond CircuitPython](../how-it-works/additions.md). + +## `audiooscillator` + +`audiooscillator` is the oscillator synthio does not have. synthio's moves its +pitch once per block and reads a wavetable with no band limiting; this one +reads its pitch, linear FM, pulse width and hard sync from audio streams every +sample, so one oscillator can sync to another or be frequency-modulated by it: + +```python +import audiooscillator as ao + +lead = ao.Oscillator( + sample_rate=48000, # and channel_count=1 or 2 + frequency=220.0, # Hz, before the pitch input + waveform=ao.SAW, # any sum of SAW, PULSE, TRIANGLE and SINE + waveform_right=ao.PULSE, # the right channel's, from the same phase + pulse_width=0.5, # 0.05..0.95 + level=0.5, # output for a waveform's full swing + pitch_octaves=10.0, # octaves for a full-scale pitch input + fm_depth=300.0, # Hz for a full-scale FM input, linear + pwm_depth=0.5, # pulse width for a full-scale PWM input + buffer_size=None, # bytes a block; 256 frames +) +lead.modulate(sync=ao.Oscillator(frequency=110.0, waveform=ao.PULSE)) +lead.set(frequency=330.0) # any option, mid-stream +lead.clear() # phase back to zero +``` + +Steps are band-limited with PolyBLEP and the triangle's corners with +PolyBLAMP. The output trails the phase by one sample, which is what lets a +sync edge, which nothing can see coming, still correct the sample before it. + +See [upstream-diff](../how-it-works/upstream-diff.md) for why its feedback loop +is solved rather than delayed, and what that was measured to be worth. + +## `audiomodal` + + +A bank of resonators, which is what a struck object is. Hit a drum head, a +marimba bar, a bell or a wine glass and it rings as a sum of decaying +sinusoids at frequencies that are not harmonics of anything; hand `Bank` that +list and a short noise burst for the stick, and it is that object. + +```python +kick = audiomodal.Bank(modes=9, sample_rate=48000, channel_count=1) +kick.set_modes(((58.0, 0.55, 1.00), (92.4, 0.24, 0.38), + (123.9, 0.15, 0.24), (2200.0, 0.010, 0.30))) +kick.play(stick) # a short noise burst +audio_out.play(kick) +``` + +Construction fixes the allocation: `sample_rate`, `channel_count` (1 or 2) and +`modes` (1 to 64). `set_mode(index, frequency, decay, gain)` places one mode +and `set_modes(table)` places the whole list; `decay` is a **60 dB time in +seconds**, not a Q, and `gain` is the peak of that mode's impulse response, so +a modal table read out of a paper goes in as published. `set()` moves `mix` +and `gain` mid-stream without stopping anything ringing, `clear()` stops +everything at once, and `ringing` says whether any mode still holds energy. + +Two behaviours worth knowing before you use it. A bare `Bank()` is **silence**, +not a passthrough — the default `mix` is fully wet and an unconfigured bank has +no modes — so reach for `mix=0.0` if you want the untouched signal. And the +tail keeps ringing after the source stops, as `audioecho.FeedbackDelay`'s, +`audioverb.Tank`'s and `audioconvolve.Convolver`'s do, because a drum whose +decay ended the instant the stick left would be the one thing this node exists +not to be. + +See [upstream-diff](../how-it-works/upstream-diff.md) for why this is a node +rather than a chain of `audiobiquad.Biquad`, measured rather than argued. +## `audiograph` + + +A whole patch rendered by one C call per block. You build a graph of nodes +over the kernels above (the oscillator, the ladder, the biquad, the feedback +delay, the all-pass, the sample-and-hold) and a few small ones a voice needs +(an ADSR, an LFO of five shapes, noise, a mixer with ramped gains, a VCA, a +glide), wire them through the graph's own block buffers, +put each voice in a group, and call `run()` once a block; nothing in Python +runs between the nodes. That is what lets a six-voice polysynth play in real +time on an ESP32-P4. The same graph renders the same samples on CPython, +MicroPython and CircuitPython, so a patch you build on the desktop sounds +there as it will on the board: +[the audiograph page](../how-it-works/audiograph.md) has the nodes, the wire and the +limits. diff --git a/docs/vorbis.md b/docs/reference/vorbis.md similarity index 90% rename from docs/vorbis.md rename to docs/reference/vorbis.md index 1cac73f0..1bccfd73 100644 --- a/docs/vorbis.md +++ b/docs/reference/vorbis.md @@ -51,20 +51,20 @@ link that doesn't. ## The decoder, and decoding from C The decoding is Xiph's Tremor, the lowmem branch: integer-only, BSD-3-Clause, -carried in [third_party/tremor](../third_party/tremor) with a few small changes -with its [ORIGIN.txt](../third_party/tremor/ORIGIN.txt). It is compiled as one -translation unit, [src/shared/audiodsp_vorbis.c](../src/shared/audiodsp_vorbis.c), +carried in [third_party/tremor](../../third_party/tremor) with a few small changes +with its [ORIGIN.txt](../../third_party/tremor/ORIGIN.txt). It is compiled as one +translation unit, [src/shared/audiodsp_vorbis.c](../../src/shared/audiodsp_vorbis.c), which: - renames every symbol Tremor defines to `audiodsp_tremor_*` - ([audiodsp_tremor_names.h](../src/shared/audiodsp_tremor_names.h)), so a + ([audiodsp_tremor_names.h](../../src/shared/audiodsp_tremor_names.h)), so a firmware can link another copy of Tremor beside it; - keeps Tremor's warnings to Tremor's code, so a port that builds with `-Werror` still checks ours; - routes Tremor's allocations through the bound driver (below). Any C module in the same firmware can decode through it with -[src/shared/audiodsp_vorbis.h](../src/shared/audiodsp_vorbis.h): give it a +[src/shared/audiodsp_vorbis.h](../../src/shared/audiodsp_vorbis.h): give it a read callback (and seek and tell, for a stream that can), then pull interleaved int16 frames. It never touches an interpreter, so it can run on a thread of its own; one decoder belongs to one thread at a time. @@ -85,7 +85,7 @@ audiodsp_vorbis_close(v); Nothing comes from the MicroPython or CPython heap. Tremor allocates through the bound driver's memory hooks (`mem_alloc`, `mem_realloc` and `mem_free` in -[audiodsp_port.h](../src/shared/audiodsp_port.h)), and through the C +[audiodsp_port.h](../../src/shared/audiodsp_port.h)), and through the C library's `malloc` when there are none. A driver for a board with PSRAM puts the decoder there: it is tens of kilobytes in many small blocks that no DMA touches, and internal RAM is what Wi-Fi and DMA can't do without. Because it @@ -132,13 +132,13 @@ C library that doesn't, so leave it the room above. ## Tests -- [tests/parity/vorbis_probe.py](../tests/parity/vorbis_probe.py) runs on +- [tests/parity/vorbis_probe.py](../../tests/parity/vorbis_probe.py) runs on MicroPython, CircuitPython and CPython from two small fixtures: the attributes, a rewound lap against the first, a `Mixer` voice playing the file unchanged and looping it, a file cut off in its headers and in its audio, things that aren't Vorbis, `deinit()` twice, and the pump's refusal. `--fault` skips the rewind and must fail. -- [tests/parity/verify_vorbis.py](../tests/parity/verify_vorbis.py) makes +- [tests/parity/verify_vorbis.py](../../tests/parity/verify_vorbis.py) makes files with ffmpeg (mono and stereo, 44.1 and 48 kHz, 4 s and 3 minutes, from the lowest libvorbis quality to the highest), decodes them with ffmpeg, and compares every sample. Tremor and ffmpeg land on 16 bits differently: @@ -146,6 +146,6 @@ C library that doesn't, so leave it the room above. are one step apart (RMS 0.71); none has been more than 2 apart, and the gate is 3. Every interpreter must decode the same bytes. `--fault swap` (left and right exchanged) and `--fault drop` (a block lost) must fail. -- [tests/parity/vorbis_symbols_check.py](../tests/parity/vorbis_symbols_check.py) +- [tests/parity/vorbis_symbols_check.py](../../tests/parity/vorbis_symbols_check.py) compiles the decoder alone and fails on any global symbol outside `audiodsp_`; `--fault` plants one. diff --git a/docs/upstream-reports/prs/flanger-int32-overflow/PR.md b/docs/upstream-reports/prs/flanger-int32-overflow/PR.md index 0bcafac3..c6e2a6c3 100644 --- a/docs/upstream-reports/prs/flanger-int32-overflow/PR.md +++ b/docs/upstream-reports/prs/flanger-int32-overflow/PR.md @@ -2,7 +2,7 @@ Branch: `fix-flanger-int32-overflow` (one commit, the patch beside this document). From audiodsp#76 and the `audiodelays.Flanger` section of -[upstream-diff.md](../../../upstream-diff.md). Verified against the `10.3.0` tag +[upstream-diff.md](../../../how-it-works/upstream-diff.md). Verified against the `10.3.0` tag (`d897c15f`) and the `main` tip (`268a168d`, 2026-09-17) — `shared-module/audiodelays/Flanger.c` is byte-identical between the two. diff --git a/lib/audiodsp_util/__init__.py b/lib/audiodsp_util/__init__.py index 9ff93ac8..b1f0c01a 100644 --- a/lib/audiodsp_util/__init__.py +++ b/lib/audiodsp_util/__init__.py @@ -20,7 +20,7 @@ and no amount of care inside the kernel can fix it: the number was already two different numbers before it arrived. -`docs/correctness-standard.md` holds our nodes to every target rendering +`docs/how-it-works/correctness-standard.md` holds our nodes to every target rendering them identically, so a setting derived in Python arithmetic passes through :func:`float32` before it reaches a node. On a single-precision target that call is the identity. On a double one it rounds to the value the board would diff --git a/micropython.cmake b/micropython.cmake index 2a731622..8ee90070 100644 --- a/micropython.cmake +++ b/micropython.cmake @@ -241,7 +241,7 @@ endif() # translation unit by src/shared/audiodsp_vorbis.c, which renames its symbols # and silences its warnings for its own code only, so IDF's -Werror=all sees # nothing. Its memory comes through the driver's hooks (PSRAM on a board with -# it); see docs/vorbis.md. +# it); see docs/reference/vorbis.md. target_sources(usermod_mpaudio INTERFACE ${MPAUDIO_SRC_DIR}/shared/audiodsp_vorbis.c ${MPAUDIO_SRC_DIR}/audiovorbis/VorbisDecoder.c diff --git a/micropython.mk b/micropython.mk index 0673faba..a09605d5 100644 --- a/micropython.mk +++ b/micropython.mk @@ -151,7 +151,7 @@ SRC_USERMOD_C += \ # so this file matched that until 2026-09-03, when the ceiling moved to 64 # (#31). The oracle stays at 14 and is not rebuilt, so material that crosses # 14 channels is non-parity by construction and declared as such in -# docs/upstream-diff.md. Everything under 14 channels still byte-matches: the +# docs/how-it-works/upstream-diff.md. Everything under 14 channels still byte-matches: the # mix-down limiter only reshapes samples past +/-28000, and all four parity # gates are byte-identical at 14, 36 and 56 (measured). # @@ -235,7 +235,7 @@ SRC_USERMOD_C += \ # and audiosampler plays a recorded buffer at a pitch through an # interpolator under an envelope, which a Mixer voice cannot (it pulls a # source at the source's own rate) and synthio cannot (a waveform there is -# one cycle). See docs/upstream-diff.md. +# one cycle). See docs/how-it-works/upstream-diff.md. SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/audiodynamics/Dynamics.c \ $(MPAUDIO_SRC_DIR)/audiodynamics/module.c @@ -285,7 +285,7 @@ SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/audiosampler/SamplerVoice.c \ $(MPAUDIO_SRC_DIR)/audiosampler/module.c # audiograph: the graph runner. One C call renders a whole patch per block -# from the kernels above. MicroPython only (docs/audiograph.md). +# from the kernels above. MicroPython only (docs/how-it-works/audiograph.md). SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/shared/audiodsp_graph.c \ $(MPAUDIO_SRC_DIR)/audiograph/Graph.c \ @@ -300,13 +300,13 @@ SRC_USERMOD_C += \ # fixed-point MP3 decoder (RealNetworks, 2003), under RPSL 1.0/RCSL 1.0 # per-file headers, NOT MIT; CircuitPython itself carries it unmodified # under those terms rather than relicensing it, and so does this port -- -# see docs/upstream-diff.md, "Tier 5 audiomp3: license" for the full check. +# see docs/how-it-works/upstream-diff.md, "Tier 5 audiomp3: license" for the full check. # One local patch was needed on top of the pinned commit # (mp3/src/assembly.h in that sibling clone): its MSVC-only inline-asm branch # guards on `defined _WIN32`, which mingw-w64 GCC (this workspace's Windows # MicroPython target) also defines, so it now also excludes __GNUC__ and # falls through to the portable C path instead -- CircuitPython itself never -# hits this, since it has no Windows port. See docs/upstream-diff.md for the +# hits this, since it has no Windows port. See docs/how-it-works/upstream-diff.md for the # full patch rationale. # mp3dec.h picks its platform-specific fixed-point/asm path by matching a # closed list of (__GNUC__ + arch) combinations, with an explicit @@ -381,7 +381,7 @@ endif # one translation unit by src/shared/audiodsp_vorbis.c, which renames its # symbols and silences its warnings for its own code only. No qstrs in that # file, so it skips the qstr scan. Unlike audiomp3 there is nothing to fetch: -# the decoder is in this repository. docs/vorbis.md has what it needs. +# the decoder is in this repository. docs/reference/vorbis.md has what it needs. SRC_USERMOD_LIB_C += $(MPAUDIO_SRC_DIR)/shared/audiodsp_vorbis.c SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/audiovorbis/VorbisDecoder.c \ diff --git a/patches/adafruit_mp3/README.md b/patches/adafruit_mp3/README.md index 8b94ce57..cbdd44d8 100644 --- a/patches/adafruit_mp3/README.md +++ b/patches/adafruit_mp3/README.md @@ -10,4 +10,4 @@ declared in `DEPENDENCIES.lock`. (the Windows MicroPython target) also defines; the patch additionally excludes `__GNUC__` so mingw falls through to the portable C path. CircuitPython never hits this (no Windows port). Full rationale: - `docs/upstream-diff.md`, "Tier 5 audiomp3". + `docs/how-it-works/upstream-diff.md`, "Tier 5 audiomp3". diff --git a/pyproject.toml b/pyproject.toml index e9c87622..1f7a8636 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ packages = ["audiorender", "audiodsp_util"] # beside the wrappers because it is the one pure-Python thing in this # repository that has to run on a *board*: a class deriving a setting in # Python rounds it differently there, and audiodsp_util.float32 is how it -# stops (docs/correctness-standard.md, audiodsp#80, audiocomponents#75). +# stops (docs/how-it-works/correctness-standard.md, audiodsp#80, audiocomponents#75). # The CPython target is one directory: src/cpython holds the extension source, # the extension built in place, and the thirteen modules that wrap it. They are diff --git a/scripts/test_testpypi_install.py b/scripts/test_testpypi_install.py index c024bc5c..cf5a2b75 100755 --- a/scripts/test_testpypi_install.py +++ b/scripts/test_testpypi_install.py @@ -3,7 +3,7 @@ scripts/test_testpypi_install.py [VERSION] [--skip-render] -`docs/building-wheels.md` names this as a post-release step to run on fresh +`docs/how-it-works/wheels-and-platforms.md` names this as a post-release step to run on fresh Linux and Windows hosts. Two things used to stop it being one (audiodsp#72): the version was frozen into the source as `0.0.1`, so it smoked a 2026-08-25 build whichever release it was run for, and its embedded snippet used diff --git a/setup.py b/setup.py index cbf7162a..aa6605b8 100644 --- a/setup.py +++ b/setup.py @@ -72,7 +72,7 @@ "src/shared/audiodsp_meter.c", "src/shared/audiodsp_graph.c", # Xiph's Tremor, included into this one file from - # third_party/tremor; docs/vorbis.md. + # third_party/tremor; docs/reference/vorbis.md. "src/shared/audiodsp_vorbis.c", ], include_dirs=["src"], diff --git a/src/audiobiquad/AllPass.h b/src/audiobiquad/AllPass.h index e23f807e..7a51bf0d 100644 --- a/src/audiobiquad/AllPass.h +++ b/src/audiobiquad/AllPass.h @@ -5,7 +5,7 @@ // the same topology in int16 with a feedback clamped to 0.1..0.9 // (audiofilters/Phaser.c:211), so neither the exact-zero tail nor the // feedback-free null a script phaser makes is reachable on it. See -// shared/audiodsp_filter_f32.h and docs/upstream-diff.md. +// shared/audiodsp_filter_f32.h and docs/how-it-works/upstream-diff.md. // // SPDX-License-Identifier: MIT diff --git a/src/audiobiquad/Biquad.h b/src/audiobiquad/Biquad.h index 9b3ab357..99551ca3 100644 --- a/src/audiobiquad/Biquad.h +++ b/src/audiobiquad/Biquad.h @@ -4,7 +4,7 @@ // Not a CircuitPython port and not from vstaudio: audiodsp adds this one. // `audiofilters.Filter` over `synthio.Biquad` already filters, and its // recursion is integer with fixed points it can park on -- see -// shared/audiodsp_filter_f32.h for the measurement, and docs/upstream-diff.md +// shared/audiodsp_filter_f32.h for the measurement, and docs/how-it-works/upstream-diff.md // for where this module sits among the rest. A new module rather than an // argument on `audiofilters`, because an argument added to this port's copy // of a CircuitPython module would not exist on a stock board. diff --git a/src/audiocore/WaveFile.c b/src/audiocore/WaveFile.c index acbc2f5f..ba02d33b 100644 --- a/src/audiocore/WaveFile.c +++ b/src/audiocore/WaveFile.c @@ -3,7 +3,7 @@ // https://github.com/adafruit/circuitpython, MIT). Since CircuitPython 11 // (cf227dc184) upstream reads through MicroPython's generic stream protocol, // as this port always did, so the file I/O below is upstream's own. What -// differs is listed in docs/upstream-sync.md. Docstrings (`//|` lines) +// differs is listed in docs/how-it-works/upstream-sync.md. Docstrings (`//|` lines) // dropped. // // SPDX-FileCopyrightText: Copyright (c) 2018 Scott Shawcroft diff --git a/src/audiodelays/Chorus.c b/src/audiodelays/Chorus.c index 5a560ebc..8f4ad117 100644 --- a/src/audiodelays/Chorus.c +++ b/src/audiodelays/Chorus.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiodelays/ // Chorus.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, see docs/upstream-diff.md). `attr, cp_compat_attr` added for +// equivalent, see docs/how-it-works/upstream-diff.md). `attr, cp_compat_attr` added for // delay_ms/voices/mix/playing. The custom `__exit__` (calling // common_hal_audiodelays_chorus_deinit directly instead of the generic // default___exit___obj method-dispatch helper) is kept verbatim -- it's diff --git a/src/audiodelays/Echo.c b/src/audiodelays/Echo.c index 2af97085..099afa34 100644 --- a/src/audiodelays/Echo.c +++ b/src/audiodelays/Echo.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiodelays/ // Echo.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, see docs/upstream-diff.md). `attr, cp_compat_attr` added for +// equivalent, see docs/how-it-works/upstream-diff.md). `attr, cp_compat_attr` added for // delay_ms/decay/mix/freq_shift/playing. Kept verbatim: set_freq_shift // truncates delay_ms to a uint32_t before passing it to // recalculate_delay(mp_float_t) -- an upstream precision-losing round trip, @@ -142,7 +142,7 @@ void recalculate_delay(audiodelays_echo_obj_t *self, mp_float_t f_delay_ms) { } // Clamped last: raised to the audio buffer, a short line was longer // than its allocation, and the memset below and the line itself ran - // past it (upstream's order; see docs/upstream-diff.md). + // past it (upstream's order; see docs/how-it-works/upstream-diff.md). if (new_echo_buffer_len > max_echo_buffer_len) { new_echo_buffer_len = max_echo_buffer_len; } @@ -275,7 +275,7 @@ audioio_get_buffer_result_t audiodelays_echo_get_buffer(audiodelays_echo_obj_t * // A source that ends with nothing in hand is let go, looped // or not, and the line rings out on silence below, as the // CPython twin does. Upstream loops it again, gets nothing - // again, and never leaves this loop (see docs/upstream-diff.md). + // again, and never leaves this loop (see docs/how-it-works/upstream-diff.md). if (self->sample_buffer_length == 0 && !self->more_data) { self->sample = NULL; } @@ -306,7 +306,7 @@ audioio_get_buffer_result_t audiodelays_echo_get_buffer(audiodelays_echo_obj_t * // A line shortened under the write head starts again at its top. // Upstream writes one more sample past the new end first, where // nothing reads it until the delay grows back and it plays out of - // the silence then (see docs/upstream-diff.md). + // the silence then (see docs/how-it-works/upstream-diff.md). if (!self->freq_shift) { if (self->echo_buffer_left_pos >= echo_buf_len) { self->echo_buffer_left_pos = 0; diff --git a/src/audiodelays/Flanger.c b/src/audiodelays/Flanger.c index eff20e38..4119b67c 100644 --- a/src/audiodelays/Flanger.c +++ b/src/audiodelays/Flanger.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiodelays/ // Flanger.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, see docs/upstream-diff.md). `attr, cp_compat_attr` added for +// equivalent, see docs/how-it-works/upstream-diff.md). `attr, cp_compat_attr` added for // min_delay_ms/rate/depth/feedback/mix/invert/lfo_value/playing. The custom // `__exit__` (calling common_hal_audiodelays_flanger_deinit directly // instead of the generic default___exit___obj method-dispatch helper) is @@ -231,7 +231,7 @@ audioio_get_buffer_result_t audiodelays_flanger_get_buffer(audiodelays_flanger_o // looping source that comes back empty straight after its reset has // nothing at all, so the node lets go of it, as the CPython twin does. // Upstream CircuitPython goes round again with n == 0 in both cases and - // never leaves the loop (docs/upstream-diff.md). + // never leaves the loop (docs/how-it-works/upstream-diff.md). bool starved = false; while (length != 0) { diff --git a/src/audiodelays/GranularPitchShift.c b/src/audiodelays/GranularPitchShift.c index d746cf6c..02fcefd7 100644 --- a/src/audiodelays/GranularPitchShift.c +++ b/src/audiodelays/GranularPitchShift.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiodelays/ // GranularPitchShift.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, see docs/upstream-diff.md). `attr, cp_compat_attr` added for +// equivalent, see docs/how-it-works/upstream-diff.md). `attr, cp_compat_attr` added for // semitones/mix/spread/playing. The custom `__exit__` is kept verbatim, same // as Chorus (see that file's comment). The signed-16 kernel follows the // CPython twin's arithmetic where the two differ (audiodsp#74). @@ -165,7 +165,7 @@ void audiodelays_granular_pitch_shift_reset_buffer( audiodsp_granular_pitch_shift_reset_state(&self->granular); // The jitter's sequence starts again too, as a new node's does. Upstream // carries it on, so a reset node drew grain offsets no new node draws - // (docs/upstream-diff.md). + // (docs/how-it-works/upstream-diff.md). self->granular.rng_state = AUDIODSP_GRANULAR_RNG_SEED; self->granular.read_rate = read_rate; } @@ -223,7 +223,7 @@ audioio_get_buffer_result_t audiodelays_granular_pitch_shift_get_buffer( // looping source that comes back empty straight after its reset has // nothing at all, so the node lets go of it, as the CPython twin does. // Upstream CircuitPython goes round again with n == 0 in both cases and - // never leaves the loop (docs/upstream-diff.md). + // never leaves the loop (docs/how-it-works/upstream-diff.md). bool starved = false; while (length != 0) { diff --git a/src/audiodelays/MultiTapDelay.c b/src/audiodelays/MultiTapDelay.c index ce455a52..17a485c2 100644 --- a/src/audiodelays/MultiTapDelay.c +++ b/src/audiodelays/MultiTapDelay.c @@ -313,7 +313,7 @@ void audiodelays_multi_tap_delay_reset_buffer(audiodelays_multi_tap_delay_obj_t // Both write heads back at the top, where construction puts them. // Upstream leaves them where the pulls took them, so a Time shortened // after a reset kept a different stretch of the line than on a fresh - // node (a recorded departure, docs/upstream-diff.md). + // node (a recorded departure, docs/how-it-works/upstream-diff.md). self->delay_buffer_pos = 0; self->delay_buffer_right_pos = 0; } @@ -385,7 +385,7 @@ audioio_get_buffer_result_t audiodelays_multi_tap_delay_get_buffer(audiodelays_m // straight after its reset. Upstream CircuitPython goes round again with // n == 0 and never leaves the loop; here the rest of the block is // rendered from silence and the source is asked again next pull (a - // recorded deviation, docs/upstream-diff.md). + // recorded deviation, docs/how-it-works/upstream-diff.md). bool starved = false; while (length != 0) { @@ -412,7 +412,7 @@ audioio_get_buffer_result_t audiodelays_multi_tap_delay_get_buffer(audiodelays_m const bool silent = self->sample == NULL || self->sample_buffer_length == 0; // Which lane the stretch starts on. Every stretch used to start on // the left; one after an odd-length source buffer starts on the right - // (a recorded deviation, docs/upstream-diff.md). + // (a recorded deviation, docs/how-it-works/upstream-diff.md). const uint8_t lane = (uint8_t)((block_samples - length) % self->base.channel_count); uint32_t n; diff --git a/src/audiodelays/PitchShift.c b/src/audiodelays/PitchShift.c index 5e0330c5..54b04233 100644 --- a/src/audiodelays/PitchShift.c +++ b/src/audiodelays/PitchShift.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiodelays/ // PitchShift.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, see docs/upstream-diff.md). `attr, cp_compat_attr` added for +// equivalent, see docs/how-it-works/upstream-diff.md). `attr, cp_compat_attr` added for // semitones/mix/freeze/playing. The custom `__exit__` is kept verbatim, same as // Chorus (see that file's comment). The unsigned-16-bit silence fill and the // window's lower bound follow upstream's 6dddbda87 (11.0.0-alpha.1), which @@ -158,7 +158,7 @@ void audiodelays_pitch_shift_reset_buffer(audiodelays_pitch_shift_obj_t *self, // so the read position's distance from the write position -- the splice // phase, which moves every frame the shift is not 0 -- carried across the // reset and the node never rendered what a new one does again - // (docs/upstream-diff.md). + // (docs/how-it-works/upstream-diff.md). self->window_index = 0; self->overlap_index = 0; self->read_index = 0; @@ -228,7 +228,7 @@ audioio_get_buffer_result_t audiodelays_pitch_shift_get_buffer(audiodelays_pitch // looping source that comes back empty straight after its reset has // nothing at all, so the node lets go of it, as the CPython twin does. // Upstream CircuitPython goes round again with n == 0 in both cases and - // never leaves the loop (docs/upstream-diff.md). + // never leaves the loop (docs/how-it-works/upstream-diff.md). bool starved = false; while (length != 0) { @@ -299,7 +299,7 @@ audioio_get_buffer_result_t audiodelays_pitch_shift_get_buffer(audiodelays_pitch self->splice_align, self->splice_fading, self->splice_rising}; // The splice may move by up to a 50th of a second: a whole - // period down to G1 (docs/upstream-diff.md). + // period down to G1 (docs/how-it-works/upstream-diff.md). audiodsp_pitchshift_process_s16(word_buffer, sample_src, n, window_buffer, window_size, overlap_buffer, overlap_size, self->base.channel_count, self->read_rate, mix, diff --git a/src/audiodelays/PitchShift.h b/src/audiodelays/PitchShift.h index a3e15b04..e12c735b 100644 --- a/src/audiodelays/PitchShift.h +++ b/src/audiodelays/PitchShift.h @@ -48,7 +48,7 @@ typedef struct { uint32_t read_rate; // words << PITCH_READ_SHIFT // The splice's move and whether a crossfade is running (the shared - // kernel's positions; docs/upstream-diff.md). + // kernel's positions; docs/how-it-works/upstream-diff.md). uint32_t splice_align; bool splice_fading; bool splice_rising; diff --git a/src/audiodynamics/Dynamics.h b/src/audiodynamics/Dynamics.h index 02f2f7d7..71142741 100644 --- a/src/audiodynamics/Dynamics.h +++ b/src/audiodynamics/Dynamics.h @@ -4,7 +4,7 @@ // Not a CircuitPython port: this module comes from micropython-vst3's // `vstaudio` usermod, which is where the effects library's compressors, // limiters, gates and transient shapers have always lived. See -// docs/upstream-diff.md. +// docs/how-it-works/upstream-diff.md. // // SPDX-License-Identifier: MIT diff --git a/src/audiofilters/Distortion.c b/src/audiofilters/Distortion.c index 140394ee..450e5298 100644 --- a/src/audiofilters/Distortion.c +++ b/src/audiofilters/Distortion.c @@ -1,12 +1,12 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiofilters/ // Distortion.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviations: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, see docs/upstream-diff.md); `attr, cp_compat_attr` added to +// equivalent, see docs/how-it-works/upstream-diff.md); `attr, cp_compat_attr` added to // the type registration for its BlockInput/mode/soft_clip/playing // properties. // // Reversed from an earlier "kept verbatim" decision (see -// docs/upstream-diff.md, "Distortion soft_clip: verbatim-kept union +// docs/how-it-works/upstream-diff.md, "Distortion soft_clip: verbatim-kept union // type-pun turned out to be architecture-dependent"): the make_new call // site used to pass `args[ARG_soft_clip].u_obj` (an mp_arg_val_t union // member) into a `bool soft_clip` parameter instead of `.u_bool` -- an diff --git a/src/audiofilters/Filter.c b/src/audiofilters/Filter.c index c4fdb268..e0a92d2f 100644 --- a/src/audiofilters/Filter.c +++ b/src/audiofilters/Filter.c @@ -1,9 +1,9 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiofilters/ // Filter.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, same fix as every other tier -- see docs/upstream-diff.md). +// equivalent, same fix as every other tier -- see docs/how-it-works/upstream-diff.md). // Type registration adds `attr, cp_compat_attr` for filter/mix/playing -// (see docs/upstream-diff.md, "Property invocation needs an explicit attr +// (see docs/how-it-works/upstream-diff.md, "Property invocation needs an explicit attr // slot"). // // SPDX-FileCopyrightText: Copyright (c) 2024 Cooper Dalrymple diff --git a/src/audiofilters/Phaser.c b/src/audiofilters/Phaser.c index 223928bc..6d1b715d 100644 --- a/src/audiofilters/Phaser.c +++ b/src/audiofilters/Phaser.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiofilters/ // Phaser.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, see docs/upstream-diff.md). `attr, cp_compat_attr` added for +// equivalent, see docs/how-it-works/upstream-diff.md). `attr, cp_compat_attr` added for // frequency/feedback/mix/stages/playing. // // SPDX-FileCopyrightText: Copyright (c) 2025 Cooper Dalrymple diff --git a/src/audiofreeverb/Freeverb.c b/src/audiofreeverb/Freeverb.c index dfe77fb6..ec303eb3 100644 --- a/src/audiofreeverb/Freeverb.c +++ b/src/audiofreeverb/Freeverb.c @@ -6,7 +6,7 @@ // prints "freeverb" even though the class is `audiofreeverb.Freeverb" -- // a genuine upstream quirk, kept for parity, not a transcription error). // Type registration adds `attr, cp_compat_attr` for the roomsize/damp/mix/ -// playing properties (see docs/upstream-diff.md, "Property invocation +// playing properties (see docs/how-it-works/upstream-diff.md, "Property invocation // needs an explicit attr slot"). // // Based on FreeVerb - https://github.com/sinshu/freeverb/tree/main diff --git a/src/audiofreeverb/Freeverb.h b/src/audiofreeverb/Freeverb.h index 10d3126c..8b6cc982 100644 --- a/src/audiofreeverb/Freeverb.h +++ b/src/audiofreeverb/Freeverb.h @@ -4,7 +4,7 @@ // why this port doesn't keep the shared-bindings/shared-module split. // roomsize/damp/mix are synthio_block_slot_t exactly as upstream (this // module was never guarded behind CIRCUITPY_SYNTHIO upstream, unlike -// audiomixer -- see docs/upstream-diff.md). +// audiomixer -- see docs/how-it-works/upstream-diff.md). // // SPDX-FileCopyrightText: Copyright (c) 2025 Mark Komus // SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices diff --git a/src/audiomath/Multiply.h b/src/audiomath/Multiply.h index b1ab8ce6..578d52b0 100644 --- a/src/audiomath/Multiply.h +++ b/src/audiomath/Multiply.h @@ -3,7 +3,7 @@ // // Not a CircuitPython port and not from vstaudio: audiodsp adds this one. See // shared/audiodsp_multiply.h for why nothing already in the palette can do it, -// and docs/upstream-diff.md for where it sits among the rest. +// and docs/how-it-works/upstream-diff.md for where it sits among the rest. // // SPDX-License-Identifier: MIT diff --git a/src/audiomath/SubOctave.h b/src/audiomath/SubOctave.h index 48af848b..969db283 100644 --- a/src/audiomath/SubOctave.h +++ b/src/audiomath/SubOctave.h @@ -3,7 +3,7 @@ // // Not a CircuitPython port and not from vstaudio: audiodsp adds this one. See // shared/audiodsp_suboctave.h for why nothing already in the palette can do it, -// and docs/upstream-diff.md for where it sits among the rest. +// and docs/how-it-works/upstream-diff.md for where it sits among the rest. // // SPDX-License-Identifier: MIT diff --git a/src/audiomixer/Mixer.c b/src/audiomixer/Mixer.c index 1c000e19..56179585 100644 --- a/src/audiomixer/Mixer.c +++ b/src/audiomixer/Mixer.c @@ -3,7 +3,7 @@ // https://github.com/adafruit/circuitpython, MIT), merged into one file // (this port doesn't keep CP's shared-bindings/shared-module split). // -// Deviations from upstream, both documented in docs/upstream-diff.md: +// Deviations from upstream, both documented in docs/how-it-works/upstream-diff.md: // - mix_down_one_voice's `#if CIRCUITPY_SYNTHIO` branch is unconditional // here (this port always has synthio, tier 2); the plain-uint16 level/ // panning fallback is dropped. @@ -228,7 +228,7 @@ static MP_DEFINE_CONST_DICT(audiomixer_mixer_locals_dict, audiomixer_mixer_local // --- mixdown engine (shared-module) ------------------------------------- // Deviation from upstream, which stops every voice here -- see -// docs/upstream-diff.md, "Resetting a Mixer silenced it". Anything that pulls +// docs/how-it-works/upstream-diff.md, "Resetting a Mixer silenced it". Anything that pulls // from a Mixer resets it first (every effect's play() does), so upstream's // version makes a Mixer feeding an effect render silence forever. // diff --git a/src/audiomixer/MixerVoice.c b/src/audiomixer/MixerVoice.c index 8a103046..0c8ac106 100644 --- a/src/audiomixer/MixerVoice.c +++ b/src/audiomixer/MixerVoice.c @@ -145,7 +145,7 @@ void common_hal_audiomixer_mixervoice_stop(audiomixer_mixervoice_obj_t *self) { // Rewind to the start of whatever is playing, keeping it playing. New here: // upstream has no equivalent, because its Mixer.reset_buffer stops the voices -// instead. See docs/upstream-diff.md. +// instead. See docs/how-it-works/upstream-diff.md. void common_hal_audiomixer_mixervoice_reset(audiomixer_mixervoice_obj_t *self) { if (self->sample == NULL) { return; diff --git a/src/audiomp3/MP3Decoder.c b/src/audiomp3/MP3Decoder.c index b8428f49..533aabcc 100644 --- a/src/audiomp3/MP3Decoder.c +++ b/src/audiomp3/MP3Decoder.c @@ -9,10 +9,10 @@ // -> `cp_compat/background_callback.h` (a stub, not a port -- see that // header). `lib/mp3/src/{mp3common,coder}.h` -> the vendored `mp3/src` // clone in the parent workspace (see docs/porting-plan.md "Tier 5 audiomp3" and -// docs/upstream-diff.md for the license and the one local patch it needed). +// docs/how-it-works/upstream-diff.md for the license and the one local patch it needed). // `MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS` keeps upstream's flag; this port adds // `attr, cp_compat_attr` for property invocation on a native type, same as -// every other tier -- see docs/upstream-diff.md, "Property invocation needs +// every other tier -- see docs/how-it-works/upstream-diff.md, "Property invocation needs // an explicit attr slot." // // Deviation from upstream: `mp_obj_malloc_with_finaliser` here is mainline's @@ -70,7 +70,7 @@ #define DO_DEBUG (0) // Deviation from upstream, this port's own windows target only (see -// docs/upstream-diff.md, "Tier 5 audiomp3: MP_STREAM_POLL is unavailable on +// docs/how-it-works/upstream-diff.md, "Tier 5 audiomp3: MP_STREAM_POLL is unavailable on // win32 for real files"): mainline's own extmod/vfs_posix_file.c raises // NotImplementedError from MP_STREAM_POLL on _WIN32 for regular POSIX-style // file objects (not sockets) rather than returning a value -- so this @@ -363,7 +363,7 @@ void common_hal_audiomp3_mp3file_construct(audiomp3_mp3file_obj_t *self, } else { self->inbuf.size = DEFAULT_INPUT_BUFFER_SIZE; // Deviation from upstream: m_malloc_without_collect -> m_malloc, same - // established fix as every other tier (see docs/upstream-diff.md) -- + // established fix as every other tier (see docs/how-it-works/upstream-diff.md) -- // mainline has no non-collecting allocator. self->inbuf.buf = m_malloc(DEFAULT_INPUT_BUFFER_SIZE); if (self->inbuf.buf == NULL) { diff --git a/src/audiomp3/mp3_alloc.c b/src/audiomp3/mp3_alloc.c index ae67b86a..7286804c 100644 --- a/src/audiomp3/mp3_alloc.c +++ b/src/audiomp3/mp3_alloc.c @@ -3,7 +3,7 @@ // `__init__.c` to `mp3_alloc.c` for this port's flat layout. This is the // default (non-coverage) allocator CP itself uses for the Helix decoder's // internal buffers: GC-heap `m_malloc_maybe`, same as every other buffer -// this port allocates (see docs/upstream-diff.md's +// this port allocates (see docs/how-it-works/upstream-diff.md's // `m_malloc_without_collect` -> `m_malloc` fix note for the established // convention) rather than the unix coverage variant's raw // malloc()/free() shortcut -- that shortcut has no effect on rendered PCM diff --git a/src/audioroute/MidSide.h b/src/audioroute/MidSide.h index 804fba8f..dd0f3661 100644 --- a/src/audioroute/MidSide.h +++ b/src/audioroute/MidSide.h @@ -4,7 +4,7 @@ // In `audioroute` rather than a module of its own because it is routing, not // arithmetic on streams: it changes which signal reaches which channel, the // same job `Splitter` does across branches. See shared/audiodsp_midside.h for -// why the palette could not already do it, and docs/upstream-diff.md for +// why the palette could not already do it, and docs/how-it-works/upstream-diff.md for // where it sits among the rest. // // SPDX-License-Identifier: MIT diff --git a/src/audioroute/Splitter.h b/src/audioroute/Splitter.h index 4b580996..50873d26 100644 --- a/src/audioroute/Splitter.h +++ b/src/audioroute/Splitter.h @@ -4,7 +4,7 @@ // Not a CircuitPython port: this module comes from micropython-vst3's // `vstaudio` usermod, where the effects library's parallel branches (exciters, // Haas wideners, multiband splits) have always been built. See -// docs/upstream-diff.md. +// docs/how-it-works/upstream-diff.md. // // SPDX-License-Identifier: MIT diff --git a/src/audiospeed/Resampler.c b/src/audiospeed/Resampler.c index 27f2cf43..07f701e6 100644 --- a/src/audiospeed/Resampler.c +++ b/src/audiospeed/Resampler.c @@ -20,7 +20,7 @@ void audiospeed_resampler_set_sample_rate(audiospeed_resampler_obj_t *self, if (self->speed.source != MP_OBJ_NULL && sample_rate != 0) { // Rounded, not truncated, for the same reason `rate_to_fp` rounds: // 48000/44100 is 71331.9 in Q16 and upstream's cast hands back - // 71331. docs/upstream-diff.md, audiodsp#92. + // 71331. docs/how-it-works/upstream-diff.md, audiodsp#92. self->speed.rate_fp = (uint32_t)( (mp_float_t)self->speed.base.sample_rate / sample_rate * (1 << SPEED_SHIFT) + (mp_float_t)0.5); diff --git a/src/audiospeed/SpeedChanger.c b/src/audiospeed/SpeedChanger.c index 1a991a92..abdda793 100644 --- a/src/audiospeed/SpeedChanger.c +++ b/src/audiospeed/SpeedChanger.c @@ -1,9 +1,9 @@ // Ported from CircuitPython's shared-bindings+shared-module/audiospeed/ // SpeedChanger.{h,c} (upstream repo: https://github.com/adafruit/circuitpython, // MIT). Deviation: m_malloc_without_collect -> m_malloc (no mainline -// equivalent, same fix as every other tier -- see docs/upstream-diff.md). +// equivalent, same fix as every other tier -- see docs/how-it-works/upstream-diff.md). // Type registration adds `attr, cp_compat_attr` for the rate property (see -// docs/upstream-diff.md, "Property invocation needs an explicit attr slot"). +// docs/how-it-works/upstream-diff.md, "Property invocation needs an explicit attr slot"). // // SPDX-FileCopyrightText: Copyright (c) 2026 Tod Kurt // SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices @@ -32,7 +32,7 @@ static uint32_t rate_to_fp(mp_obj_t rate_obj) { // Round to the nearest Q16 step. Upstream truncates, so a float landing a // hair under its neighbour loses a whole LSB instead of arriving at it: // 1/1.0000000000000004 becomes 65535/65536 rather than unity. A deliberate - // departure -- see docs/upstream-diff.md (audiodsp#92). + // departure -- see docs/how-it-works/upstream-diff.md (audiodsp#92). return (uint32_t)(rate * (1 << SPEED_SHIFT) + (mp_float_t)0.5); } @@ -112,7 +112,7 @@ static bool fetch_source_buffer(audiospeed_speedchanger_obj_t *self) { // this buffer consumed is the frame count of the buffer *before* it, not // everything the phase was holding -- upstream throws away the remainder, // so a hold restarts its staircase at every source buffer. audiodsp#91, - // docs/upstream-diff.md. + // docs/how-it-works/upstream-diff.md. uint32_t consumed = self->src_sample_count << SPEED_SHIFT; self->phase = self->phase >= consumed ? self->phase - consumed : 0; self->src_buffer = buf; diff --git a/src/audiovorbis/VorbisDecoder.c b/src/audiovorbis/VorbisDecoder.c index f158fee4..c405a241 100644 --- a/src/audiovorbis/VorbisDecoder.c +++ b/src/audiovorbis/VorbisDecoder.c @@ -49,7 +49,7 @@ // The C stack a call into Tremor may take below the caller, with room to // spare: about 1 KB of fixed frames on RISC-V, up to 2 KB of scratch a packet -// sizes on the stack, and the stream's own read underneath (docs/vorbis.md). +// sizes on the stack, and the stream's own read underneath (docs/reference/vorbis.md). // Tremor doesn't check its depth, so this checks first: a Python program deep // enough to run the stack out gets an error instead of a crashed board. #define AUDIOVORBIS_STACK_NEEDED (6 * 1024) diff --git a/src/circuitpython_spike/shared-module/audiovorbis/VorbisDecoder.c b/src/circuitpython_spike/shared-module/audiovorbis/VorbisDecoder.c index a81cd019..b59df44c 100644 --- a/src/circuitpython_spike/shared-module/audiovorbis/VorbisDecoder.c +++ b/src/circuitpython_spike/shared-module/audiovorbis/VorbisDecoder.c @@ -30,7 +30,7 @@ // The C stack a call into Tremor may take below the caller, with room to // spare: about 1 KB of fixed frames on RISC-V, up to 2 KB of scratch a packet -// sizes on the stack, and the stream's own read underneath (docs/vorbis.md). +// sizes on the stack, and the stream's own read underneath (docs/reference/vorbis.md). // Tremor doesn't check its depth, so this checks first: a Python program deep // enough to run the stack out gets an error instead of a crashed board. #define AUDIOVORBIS_STACK_NEEDED (6 * 1024) diff --git a/src/cp_compat/background_callback.h b/src/cp_compat/background_callback.h index 53488eaa..153ec4d0 100644 --- a/src/cp_compat/background_callback.h +++ b/src/cp_compat/background_callback.h @@ -1,7 +1,7 @@ // Compat shim for audiodsp, standing in for CircuitPython's // supervisor/background_callback.h (upstream repo: // https://github.com/adafruit/circuitpython, MIT) -- see -// docs/upstream-diff.md, "audiomp3: background_callback is synchronous +// docs/how-it-works/upstream-diff.md, "audiomp3: background_callback is synchronous // here" for why this is a stub rather than a port. // // CircuitPython's own unix `coverage` build variant -- our parity oracle -- diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 6693b1f5..fdc6de02 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -589,7 +589,7 @@ static PyObject *envelope_state_reattack(audiodsp_envelope_state_object_t *self, // a note's envelope on every render block (synthio_synth_get_note_envelope, // called from the render loop), so reassigning `note.envelope` takes effect // immediately there. This target caches the definition inside the state object -// at press time, so it needs this to stay faithful. See docs/upstream-diff.md. +// at press time, so it needs this to stay faithful. See docs/how-it-works/upstream-diff.md. static PyObject *envelope_state_set_definition( audiodsp_envelope_state_object_t *self, PyObject *args, PyObject *kwargs) { unsigned int sample_rate; @@ -657,7 +657,7 @@ static PyType_Spec envelope_state_spec = { // One filter state per channel. A stereo stream filtered through a single // state makes each channel's filter memory the other channel's history; see -// docs/upstream-diff.md. CircuitPython caps channel_count at 2. +// docs/how-it-works/upstream-diff.md. CircuitPython caps channel_count at 2. #define AUDIODSP_BIQUAD_MAX_CHANNELS 2 typedef struct { @@ -4134,7 +4134,7 @@ static PyType_Slot graph_state_slots[] = { {Py_tp_getset, graph_state_getset}, {Py_tp_doc, (void *)PyDoc_STR("Graph(*, sample_rate=48000, frames=128, " "channel_count=2, buffers=64, nodes=64): a whole patch rendered by " - "one C call per block. See docs/audiograph.md.")}, + "one C call per block. See docs/how-it-works/audiograph.md.")}, {0, NULL}, }; diff --git a/src/cpython/audiodelays.py b/src/cpython/audiodelays.py index c58fa88a..18c8abf4 100644 --- a/src/cpython/audiodelays.py +++ b/src/cpython/audiodelays.py @@ -218,7 +218,7 @@ def _reset_state(self): self._delay_buffer[:] = array("h", [0]) * len(self._delay_buffer) # The write head goes back to the top, where a fresh node's is: a # Time shortened later keeps the stretch of the line a fresh node - # would (a recorded departure, docs/upstream-diff.md). + # would (a recorded departure, docs/how-it-works/upstream-diff.md). self._position = 0 def _delay_samples(self): @@ -294,7 +294,7 @@ def _process(self, data): class PitchShift(_Effect): # A source with nothing to give ends the pull's stretch rather than - # spinning the loop (a recorded deviation, docs/upstream-diff.md). + # spinning the loop (a recorded deviation, docs/how-it-works/upstream-diff.md). _stop_on_empty_pull = True def __init__(self, *, semitones=0, mix=1, window=1024, overlap=128, buffer_size=512, sample_rate=8000, bits_per_sample=16, samples_signed=True, channel_count=1): @@ -341,7 +341,7 @@ def _process(self, data): _advance_blocks(self.sample_rate, len(data) // (self.channel_count * 2)) read_rate = int(2.0 ** (_value(self.semitones) / 12.0) * 256) - # The splice moves by up to a 50th of a second (docs/upstream-diff.md). + # The splice moves by up to a 50th of a second (docs/how-it-works/upstream-diff.md). (result, self._window_index, self._overlap_index, self._read_index, *splice) = _audiodsp.pitchshift_s16( data, self._window_buffer, self._overlap_buffer, @@ -357,7 +357,7 @@ def _process(self, data): class Flanger(_Effect): # A source with nothing to give ends the pull's stretch rather than - # spinning the loop (a recorded deviation, docs/upstream-diff.md). + # spinning the loop (a recorded deviation, docs/how-it-works/upstream-diff.md). _stop_on_empty_pull = True def __init__(self, *, max_delay_ms=10, min_delay_ms=None, rate=None, depth=None, feedback=None, mix=None, invert=False, buffer_size=512, sample_rate=8000, bits_per_sample=16, samples_signed=True, channel_count=1): diff --git a/src/cpython/audiodynamics.py b/src/cpython/audiodynamics.py index fe244d08..9a893386 100644 --- a/src/cpython/audiodynamics.py +++ b/src/cpython/audiodynamics.py @@ -27,7 +27,7 @@ Twenty-one further options came with the effects program, every one of them default-off and every one of them a fixed trait of a named circuit that -tuning the shipped knobs cannot reach. See docs/upstream-diff.md for the +tuning the shipped knobs cannot reach. See docs/how-it-works/upstream-diff.md for the measurements; briefly: * ``detector="rms"`` with ``rms_ms`` averages a mean square instead of diff --git a/src/cpython/audiofilters.py b/src/cpython/audiofilters.py index 3335eb40..0bec41d8 100644 --- a/src/cpython/audiofilters.py +++ b/src/cpython/audiofilters.py @@ -37,7 +37,7 @@ class _Effect(_AudioSample): _process_during_pull = True # Upstream CircuitPython's effects go round their pull loop for ever when # a source says it has more and hands back 0 bytes. MultiTapDelay stops - # (a recorded deviation, docs/upstream-diff.md); the rest match upstream. + # (a recorded deviation, docs/how-it-works/upstream-diff.md); the rest match upstream. _stop_on_empty_pull = False _lane = 0 diff --git a/src/cpython/audiograph.py b/src/cpython/audiograph.py index c3971158..3aa9b7b1 100644 --- a/src/cpython/audiograph.py +++ b/src/cpython/audiograph.py @@ -19,7 +19,7 @@ g.set(k, 0, ag.UNIT) # key down sounding = g.run(pcm, 0) # one block into pcm -``docs/audiograph.md`` has the nodes, the wire, the groups and the limits. +``docs/how-it-works/audiograph.md`` has the nodes, the wire, the groups and the limits. """ import _audiodsp diff --git a/src/cpython/audiomixer.py b/src/cpython/audiomixer.py index 501cf689..576966d1 100644 --- a/src/cpython/audiomixer.py +++ b/src/cpython/audiomixer.py @@ -194,7 +194,7 @@ def stop_voice(self, voice=0): self._voice_at(voice).stop() def _reset_buffer(self, single_channel_output=False, audio_channel=0): # Deviation from upstream, which stops every voice here -- see - # docs/upstream-diff.md, "Resetting a Mixer silenced it". A host reset + # docs/how-it-works/upstream-diff.md, "Resetting a Mixer silenced it". A host reset # keeps every voice as it is, its source and the frames it has taken, # the rule every node follows on a reset (audiodsp#181, #178). self._check() diff --git a/src/cpython/audioshaper.py b/src/cpython/audioshaper.py index 9f352ef3..4abd746f 100644 --- a/src/cpython/audioshaper.py +++ b/src/cpython/audioshaper.py @@ -50,7 +50,7 @@ then clips. Keep `post_gain * max(abs(curve))` at or below `CLIP_HEADROOM` (about 0.74 of full scale) for a curve that reaches the rails, and put the rest of the wanted level on a mixer voice after this node rather than on -this knob. Measured table: `docs/upstream-diff.md`, "`audioshaper`" +this knob. Measured table: `docs/how-it-works/upstream-diff.md`, "`audioshaper`" (audiodsp#99). `hysteresis` is off by default and is the one thing a table cannot do: give @@ -113,7 +113,7 @@ #: Above roughly this fraction of full scale, `post_gain` on a curve that #: reaches the rails re-clips the decimator's own overshoot at the base #: rate rather than anything the oversampling can still fix -- see the -#: docstring above and docs/upstream-diff.md's `audioshaper` section for +#: docstring above and docs/how-it-works/upstream-diff.md's `audioshaper` section for #: the measured table (audiodsp#99). Documentation only, the same as #: `GROUP_DELAY_SAMPLES` above it: neither the MicroPython usermod's module #: globals (`src/audioshaper/module.c`) nor the CircuitPython spike's diff --git a/src/cpython/audiospeed.py b/src/cpython/audiospeed.py index f3a904e1..02e2360b 100644 --- a/src/cpython/audiospeed.py +++ b/src/cpython/audiospeed.py @@ -23,7 +23,7 @@ def rate(self, value): value = float(value) if not 0 <= value <= 1000: raise ValueError("rate must be from 0 to 1000") # Rounded, not truncated: upstream's cast loses a whole Q16 LSB for a - # float a hair under its neighbour. docs/upstream-diff.md, audiodsp#92. + # float a hair under its neighbour. docs/how-it-works/upstream-diff.md, audiodsp#92. self._rate_fp = int(value * 65536 + 0.5) & 0xffffffff def _release(self): @@ -49,7 +49,7 @@ def _fetch(self): return False # Carry the accumulator across the boundary, rather than zeroing it: # what this buffer consumed is the frame count of the one before it. - # audiodsp#91, docs/upstream-diff.md. + # audiodsp#91, docs/how-it-works/upstream-diff.md. consumed = (len(self._source_data or b"") // self._frame_size()) << 16 self._phase = self._phase - consumed if self._phase >= consumed else 0 self._source_data = raw diff --git a/src/cpython/synthio.py b/src/cpython/synthio.py index d8b8fe5e..591c32d0 100644 --- a/src/cpython/synthio.py +++ b/src/cpython/synthio.py @@ -442,7 +442,7 @@ def press(self, notes): # the slot came back 758 render blocks later. Driving rhodes # through the parity sequence, 117 of its 122 refusals happened # with NO key held at all - the engine was clogged by its own - # decaying voices, not full. See docs/upstream-diff.md. + # decaying voices, not full. See docs/how-it-works/upstream-diff.md. victim = None quietest = None for other in self._notes: diff --git a/src/shared/audiodsp_biquad.c b/src/shared/audiodsp_biquad.c index 36a3add9..a088c3a1 100644 --- a/src/shared/audiodsp_biquad.c +++ b/src/shared/audiodsp_biquad.c @@ -17,7 +17,7 @@ // and it costs low frequencies, badly and silently: `b0` for a low-pass is // (1 - cos W0)/2, which at 100 Hz / 48 kHz is 4.3e-5, or 1.4 in Q15 - so it // rounds to 1 and the filter is no longer the one that was asked for. A -// LOW_PASS at 100 Hz used to return silence. See docs/upstream-diff.md. +// LOW_PASS at 100 Hz used to return silence. See docs/how-it-works/upstream-diff.md. // // Two things are widened here, for two different reasons. The coefficients get // as many fractional bits as they individually have room for (below), because @@ -89,7 +89,7 @@ void audiodsp_biquad_configure_w0(audiodsp_biquad_coefficients_t *coefficients, } } else if (mode == 4) { // b2 is `1 - alpha * A`, not `+`. Upstream CircuitPython has the plus - // (shared-module/synthio/Biquad.c); see docs/upstream-diff.md. The sign + // (shared-module/synthio/Biquad.c); see docs/how-it-works/upstream-diff.md. The sign // is what makes numerator and denominator sum alike at DC and Nyquist, // which is the whole point of a peaking filter: unity everywhere except // the band. With it flipped the numerator gains 2*alpha*A at DC while diff --git a/src/shared/audiodsp_biquad.h b/src/shared/audiodsp_biquad.h index 853e71ec..1aac493c 100644 --- a/src/shared/audiodsp_biquad.h +++ b/src/shared/audiodsp_biquad.h @@ -45,7 +45,7 @@ void audiodsp_biquad_process(const audiodsp_biquad_coefficients_t *coefficients, // root for the shelves, Q15 coefficients rounded with `round(ldexp(v, 15))`, // and an int32_t accumulator saturated to int16 each step. Its known costs are // upstream's and are not corrected here -- that is the point of it. They are -// measured in docs/upstream-diff.md, "The biquads were Q15, so they could not +// measured in docs/how-it-works/upstream-diff.md, "The biquads were Q15, so they could not // go low". #define AUDIODSP_BIQUAD_CP_SHIFT 15 diff --git a/src/shared/audiodsp_filter_f32.c b/src/shared/audiodsp_filter_f32.c index b17b8eae..35e2f890 100644 --- a/src/shared/audiodsp_filter_f32.c +++ b/src/shared/audiodsp_filter_f32.c @@ -205,7 +205,7 @@ void audiodsp_biquad_f32_config_finish(audiodsp_biquad_f32_config_t *config) { } } else if (config->mode == AUDIODSP_BIQUAD_F32_PEAKING_EQ) { // `1 - alpha * A`, the sign audiodsp_biquad.c:87 corrected upstream to - // (docs/upstream-diff.md, "synthio.Biquad's PEAKING_EQ"): with the + // (docs/how-it-works/upstream-diff.md, "synthio.Biquad's PEAKING_EQ"): with the // plus, numerator and denominator stop summing alike at DC and a // +6 dB bell at 1 kHz comes out about +21 dB down there. b0 = 1 + alpha * A; diff --git a/src/shared/audiodsp_fp_contract.h b/src/shared/audiodsp_fp_contract.h index 8f1a2349..20a7b22f 100644 --- a/src/shared/audiodsp_fp_contract.h +++ b/src/shared/audiodsp_fp_contract.h @@ -16,7 +16,7 @@ // // That was the whole of the P4-vs-S3 split on `audiodynamics`' transient attack // path (audiodsp#66, 29 against 30 sites there), which this file generalises to -// the rest of the kernel (audiodsp#79). `docs/correctness-standard.md` holds our +// the rest of the kernel (audiodsp#79). `docs/how-it-works/correctness-standard.md` holds our // nodes to every target rendering them identically; contraction is one of the // few ways the same C can fail that without anybody writing a bug. // diff --git a/src/shared/audiodsp_multitap.h b/src/shared/audiodsp_multitap.h index f1919dd7..32bb4f38 100644 --- a/src/shared/audiodsp_multitap.h +++ b/src/shared/audiodsp_multitap.h @@ -10,7 +10,7 @@ // same block ended part-way through a frame. Upstream CircuitPython starts // every run on the left lane, so a stereo source that hands an odd number of // samples swaps the lanes from there on; this port carries the lane instead -// (a recorded deviation, docs/upstream-diff.md). +// (a recorded deviation, docs/how-it-works/upstream-diff.md). uint32_t audiodsp_multitap_process_s16(int16_t *output, const int16_t *input, size_t sample_count, int16_t *delay_buffer, uint32_t position, uint32_t delay_samples_per_channel, diff --git a/src/shared/audiodsp_pitchshift.c b/src/shared/audiodsp_pitchshift.c index 081e5259..b593ddee 100644 --- a/src/shared/audiodsp_pitchshift.c +++ b/src/shared/audiodsp_pitchshift.c @@ -223,7 +223,7 @@ void audiodsp_pitchshift_process_s16(int16_t *output, const int16_t *input, // window there still holds its oldest frame; the frame that // follows the newest is the overlap's oldest. Upstream plays the // window's, a click on every lap whose read lands exactly on the - // write position (docs/upstream-diff.md). + // write position (docs/how-it-works/upstream-diff.md). word = overlap[positions->overlap_index + overlap_plane]; } else { word = window[read + window_plane]; diff --git a/src/shared/audiodsp_pitchshift.h b/src/shared/audiodsp_pitchshift.h index b37573e1..a88347de 100644 --- a/src/shared/audiodsp_pitchshift.h +++ b/src/shared/audiodsp_pitchshift.h @@ -11,7 +11,7 @@ typedef struct { uint32_t window_index; uint32_t overlap_index; uint32_t read_index; - // The splice (docs/upstream-diff.md): while a crossfade runs, how many + // The splice (docs/how-it-works/upstream-diff.md): while a crossfade runs, how many // frames the incoming side is moved to land in phase with the outgoing // one, and whether a crossfade is running. Both 0 where a new node // starts, and after a reset. diff --git a/src/shared/audiodsp_sampler.h b/src/shared/audiodsp_sampler.h index f4d3ba02..4aa291e1 100644 --- a/src/shared/audiodsp_sampler.h +++ b/src/shared/audiodsp_sampler.h @@ -24,7 +24,7 @@ // about 30 dB down; a 4-point Hermite puts it about 60 dB down for four taps // and two more multiplies a sample. Measured on the desktop, 24 looping // voices at 48 kHz stereo cost 26 us of a 5333 us block with the cubic -// (docs/upstream-diff.md), so there was no budget case for the cheaper one +// (docs/how-it-works/upstream-diff.md), so there was no budget case for the cheaper one // and the better one is the only one. The polynomial is the x-form 3rd-order // Hermite, the one every sampler since the 1990s has used, chosen because // it passes through the samples exactly: an integer position reads the diff --git a/src/shared/audiodsp_vorbis.h b/src/shared/audiodsp_vorbis.h index 8e9a949a..d23d0bc7 100644 --- a/src/shared/audiodsp_vorbis.h +++ b/src/shared/audiodsp_vorbis.h @@ -30,7 +30,7 @@ // (shared/audiodsp_port.h), which put it in PSRAM on a board that has it, // and through the C library's malloc everywhere else. Nothing comes from a // MicroPython or CPython heap. Header parsing and decoding also take stack: see -// docs/vorbis.md for what a stream needs of both. +// docs/reference/vorbis.md for what a stream needs of both. #pragma once diff --git a/src/synthio/Biquad.c b/src/synthio/Biquad.c index cbd06944..51a293d3 100644 --- a/src/synthio/Biquad.c +++ b/src/synthio/Biquad.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings/synthio/Biquad.c and // shared-module/synthio/Biquad.c (upstream repo: // https://github.com/adafruit/circuitpython, MIT), merged into one file. -// Docstrings dropped. See docs/upstream-diff.md: every type using +// Docstrings dropped. See docs/how-it-works/upstream-diff.md: every type using // MP_PROPERTY_GETTER/GETSET needs `attr, cp_compat_attr` wired in. // // SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries diff --git a/src/synthio/LFO.c b/src/synthio/LFO.c index 3b26f4d2..006afef6 100644 --- a/src/synthio/LFO.c +++ b/src/synthio/LFO.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings/synthio/LFO.c and // shared-module/synthio/LFO.c (upstream repo: // https://github.com/adafruit/circuitpython, MIT), merged into one file. -// Docstrings dropped. See docs/upstream-diff.md: every type using +// Docstrings dropped. See docs/how-it-works/upstream-diff.md: every type using // MP_PROPERTY_GETTER/GETSET needs `attr, cp_compat_attr` wired in. // // SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries diff --git a/src/synthio/Math.c b/src/synthio/Math.c index f216cc19..0e8a7b4c 100644 --- a/src/synthio/Math.c +++ b/src/synthio/Math.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings/synthio/Math.c and // shared-module/synthio/Math.c (upstream repo: // https://github.com/adafruit/circuitpython, MIT), merged into one file. -// Docstrings dropped. See docs/upstream-diff.md: every type using +// Docstrings dropped. See docs/how-it-works/upstream-diff.md: every type using // MP_PROPERTY_GETTER/GETSET needs `attr, cp_compat_attr` wired in. // // SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries diff --git a/src/synthio/MidiTrack.c b/src/synthio/MidiTrack.c index 2fa03028..13633bdc 100644 --- a/src/synthio/MidiTrack.c +++ b/src/synthio/MidiTrack.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings/synthio/MidiTrack.c and // shared-module/synthio/MidiTrack.c (upstream repo: // https://github.com/adafruit/circuitpython, MIT), merged into one file. -// Docstrings dropped. See docs/upstream-diff.md: every type using +// Docstrings dropped. See docs/how-it-works/upstream-diff.md: every type using // MP_PROPERTY_GETTER/GETSET needs `attr, cp_compat_attr` wired in. This // file's own byte-buffer-based construction (`MidiTrack(buffer, tempo)`) // reads no file -- `synthio.from_file()` (synthio/__init__.c) does. diff --git a/src/synthio/Note.c b/src/synthio/Note.c index 928e4a32..9b3f347a 100644 --- a/src/synthio/Note.c +++ b/src/synthio/Note.c @@ -1,7 +1,7 @@ // Ported from CircuitPython's shared-bindings/synthio/Note.c and // shared-module/synthio/Note.c (upstream repo: // https://github.com/adafruit/circuitpython, MIT), merged into one file. -// Docstrings dropped. See docs/upstream-diff.md: every type using +// Docstrings dropped. See docs/how-it-works/upstream-diff.md: every type using // MP_PROPERTY_GETTER/GETSET needs `attr, cp_compat_attr` wired in. // // SPDX-FileCopyrightText: Copyright (c) 2023 Jeff Epler for Adafruit Industries diff --git a/src/synthio/Note.h b/src/synthio/Note.h index 5e48760d..13cf510f 100644 --- a/src/synthio/Note.h +++ b/src/synthio/Note.h @@ -53,7 +53,7 @@ extern const mp_obj_type_t synthio_note_type; // synthio_bend_mode_e synthio_bend_mode_t;` here -- upstream's own // shared-module/synthio/Note.h repeats a typedef synthio/__init__.h // already fully defines (same "two files never in one translation unit -// upstream" story as synthio_synth_t; see docs/upstream-diff.md). This +// upstream" story as synthio_synth_t; see docs/how-it-works/upstream-diff.md). This // file already includes `synthio/__init__.h` above, so the type is // already complete here; the second typedef added nothing and failed // under emscripten's strict `-std=c99 -Werror` (phase 8d). diff --git a/src/synthio/Synthesizer.c b/src/synthio/Synthesizer.c index 189df9ec..baa828e7 100644 --- a/src/synthio/Synthesizer.c +++ b/src/synthio/Synthesizer.c @@ -4,7 +4,7 @@ // Docstrings dropped. `MP_OBJ_NEW_TUPLE(...)` (CP's variadic tuple-literal // macro, py/objtuple.h) inlined at its one call site instead of ported -- // avoids relying on a GCC statement-expression extension for a single use. -// See docs/upstream-diff.md: every type using MP_PROPERTY_GETTER/GETSET +// See docs/how-it-works/upstream-diff.md: every type using MP_PROPERTY_GETTER/GETSET // needs `attr, cp_compat_attr` wired in. // // SPDX-FileCopyrightText: Copyright (c) 2021 Artyom Skrobov diff --git a/src/synthio/__init__.c b/src/synthio/__init__.c index c96e09f8..8e4c1066 100644 --- a/src/synthio/__init__.c +++ b/src/synthio/__init__.c @@ -2,7 +2,7 @@ // shared-bindings/synthio/__init__.c (upstream repo: // https://github.com/adafruit/circuitpython, MIT), merged into one file. // Docstrings dropped. `m_malloc_without_collect` -> `m_malloc` (mainline -// has no "won't be GC-scanned" allocation variant; see docs/upstream-diff.md +// has no "won't be GC-scanned" allocation variant; see docs/how-it-works/upstream-diff.md // tier-1 notes on the same substitution in audiocore's WaveFile). // `synthio.from_file()` is upstream's stream-protocol body (CircuitPython // 11), and also opens a path. `lfo_tick()` is ported unconditionally (CP diff --git a/src/synthio/__init__.h b/src/synthio/__init__.h index 7aab64f6..43d564d3 100644 --- a/src/synthio/__init__.h +++ b/src/synthio/__init__.h @@ -66,7 +66,7 @@ // question worth asking. This comment said the oracle stays at 14 and that // material crossing the ceiling is non-parity by construction; both stopped // being true when the oracle moved, and the deviation that went with them is -// retired in docs/upstream-diff.md ("The ceiling deviation is RETIRED", +// retired in docs/how-it-works/upstream-diff.md ("The ceiling deviation is RETIRED", // audiodsp#31). // // tests/parity/verify_mixdown_knee.py is still the only committed gate whose @@ -112,7 +112,7 @@ typedef enum synthio_bend_mode_e { extern const mp_obj_type_t synthio_note_state_type; extern const cp_enum_obj_t bend_mode_VIBRATO_obj; extern const mp_obj_type_t synthio_bend_mode_type; -// Deviation from upstream (see docs/upstream-diff.md, "synthio_synth_t: +// Deviation from upstream (see docs/how-it-works/upstream-diff.md, "synthio_synth_t: // one typedef, not two"): CP's shared-bindings/__init__.h and // shared-module/__init__.h each separately declare `typedef ... // synthio_synth_t` once, but since separate translation units never see diff --git a/tests/parity/biquad_component_probe.py b/tests/parity/biquad_component_probe.py index d1ad91f4..902731f2 100644 --- a/tests/parity/biquad_component_probe.py +++ b/tests/parity/biquad_component_probe.py @@ -4,12 +4,12 @@ the peaking-EQ sign error in `audiodsp_biquad.c` survived: no fixture reached mode 4. This probe walks all seven modes in mono and stereo, and then all seven again across the band, so the three deliberate deviations recorded in -docs/upstream-diff.md ("Peaking EQ computed b2 with the wrong sign", "A stereo +docs/how-it-works/upstream-diff.md ("Peaking EQ computed b2 with the wrong sign", "A stereo Filter shared one biquad state" and "The biquads are Q15, so they cannot go low") are each pinned by something that fails when they regress. Running this against `bin/circuitpython` is expected to differ. That is the -point of it; see docs/upstream-diff.md for the direction of each difference. +point of it; see docs/how-it-works/upstream-diff.md for the direction of each difference. **The material is integer arithmetic, deliberately.** The square wave used to be `(2*pi*hz*frame/RATE) % 2*pi < pi`, and a Python float is the interpreter's @@ -127,7 +127,7 @@ def source(channel_count): #: coefficients used to collapse - a low-pass there returned silence - and #: 3600 Hz is past pi/2 in W0, where the old sine/cosine fit was extrapolating #: and a high-pass passed its own stopband. Neither end was pinned by anything -#: before the biquads were widened; see docs/upstream-diff.md. +#: before the biquads were widened; see docs/how-it-works/upstream-diff.md. EDGE_CENTERS = (60, 200, 1200, 3600) for name, mode in MODES: diff --git a/tests/parity/cp11_files_probe.py b/tests/parity/cp11_files_probe.py index 12854043..8324ca43 100644 --- a/tests/parity/cp11_files_probe.py +++ b/tests/parity/cp11_files_probe.py @@ -121,7 +121,7 @@ def refused(tag, action): decoder = audiomp3.MP3Decoder(handle) # Only how each lap ends is compared, not its bytes: this port clears # the decoder's state on a rewind and 11.0.0-alpha.1 does not yet - # (docs/upstream-sync.md), so the second laps differ by design. + # (docs/how-it-works/upstream-sync.md), so the second laps differ by design. for lap in (1, 2): buffers = 0 while True: diff --git a/tests/parity/dynamics_probe.py b/tests/parity/dynamics_probe.py index a9c7f43d..071ec62d 100644 --- a/tests/parity/dynamics_probe.py +++ b/tests/parity/dynamics_probe.py @@ -4,7 +4,7 @@ Every interpreter renders this and the outputs must be identical, so any difference in the printed stats is a difference in the DSP - a width, a shift, -a compiler's choice. See docs/correctness-standard.md. +a compiler's choice. See docs/how-it-works/correctness-standard.md. **A legacy worth knowing about.** This file uses only the argument forms micropython-vst3's `vstaudio_dsp.c` accepted, because it was written to be run diff --git a/tests/parity/mixer_reset_probe.py b/tests/parity/mixer_reset_probe.py index 4cf412d0..5b718386 100644 --- a/tests/parity/mixer_reset_probe.py +++ b/tests/parity/mixer_reset_probe.py @@ -14,7 +14,7 @@ Each case pulls a Mixer for one block, resets it, and pulls three more, beside the same Mixer never reset. The blocks must match, and they are printed so the interpreters are held to the same bytes. CircuitPython's own Mixer stops every -voice on a reset (docs/upstream-diff.md, "Resetting a Mixer silenced it"), so +voice on a reset (docs/how-it-works/upstream-diff.md, "Resetting a Mixer silenced it"), so `verify_dsp.py` doesn't run this there. """ diff --git a/tests/parity/mixer_short_loop_probe.py b/tests/parity/mixer_short_loop_probe.py index fb4a18d3..a4db0029 100644 --- a/tests/parity/mixer_short_loop_probe.py +++ b/tests/parity/mixer_short_loop_probe.py @@ -23,7 +23,7 @@ **CircuitPython hangs on case 1** (10.3.0 and 11.0.0-alpha.1 both) and is not in the comparison for that reason; `audiomixer` is a stock CP module, this port fixes its own two targets, -and the deviation is written up in docs/upstream-diff.md. So this file is not +and the deviation is written up in docs/how-it-works/upstream-diff.md. So this file is not in `verify_dsp.py`'s PROBES -- adding it there would hang the three-way on the oracle rather than report anything. """ diff --git a/tests/parity/multitap_edges_probe.py b/tests/parity/multitap_edges_probe.py index 5a6e4079..7108acdf 100644 --- a/tests/parity/multitap_edges_probe.py +++ b/tests/parity/multitap_edges_probe.py @@ -13,7 +13,7 @@ of the source instead and rings out what its line holds, as its CPython twin always did. -All are recorded deviations from the oracle (docs/upstream-diff.md), so the +All are recorded deviations from the oracle (docs/how-it-works/upstream-diff.md), so the probe runs on MicroPython and CPython and is skipped on CircuitPython, whose MultiTapDelay and Echo are upstream's. """ diff --git a/tests/parity/pitchshift_freeze_probe.py b/tests/parity/pitchshift_freeze_probe.py index 93f190b8..05763a5c 100644 --- a/tests/parity/pitchshift_freeze_probe.py +++ b/tests/parity/pitchshift_freeze_probe.py @@ -122,7 +122,7 @@ def node_for(channels, **options): # A reset of the node's buffer lets go of the freeze. What the node renders # after the reset is pitch_reset_probe.py's to check: a reset here also puts # the read and write positions back where a new node starts, which upstream -# does not (docs/upstream-diff.md), so those bytes are not held to +# does not (docs/how-it-works/upstream-diff.md), so those bytes are not held to # CircuitPython's. node = node_for(1, semitones=7, mix=1.0, window=1024, overlap=128) emit("reset_before", node, 1) diff --git a/tests/parity/route_probe.py b/tests/parity/route_probe.py index 8d7484b7..00862595 100644 --- a/tests/parity/route_probe.py +++ b/tests/parity/route_probe.py @@ -4,7 +4,7 @@ Only the positional `Splitter(source, taps)` form is used here; the keyword form belongs in the unit tests. The oracle invocation this file used to -carry is gone with the oracle itself - see docs/correctness-standard.md. +carry is gone with the oracle itself - see docs/how-it-works/correctness-standard.md. The source is a double-buffered RawSample, which hands out 300 frames at a time - enough to walk the ring past its 8192-frame wrap, and short enough that diff --git a/tests/parity/speedchanger_hold_probe.py b/tests/parity/speedchanger_hold_probe.py index c2b44fda..eb02e019 100644 --- a/tests/parity/speedchanger_hold_probe.py +++ b/tests/parity/speedchanger_hold_probe.py @@ -20,7 +20,7 @@ what the node actually holds. All three are far enough from a half-step to round the same way under a 32-bit and a 64-bit `mp_float_t`. -Both departures this probe sees are recorded in `docs/upstream-diff.md` +Both departures this probe sees are recorded in `docs/how-it-works/upstream-diff.md` (audiodsp#91, audiodsp#92). """ diff --git a/tests/parity/synthtools_acceptance.py b/tests/parity/synthtools_acceptance.py index b7fa1521..759a44b7 100644 --- a/tests/parity/synthtools_acceptance.py +++ b/tests/parity/synthtools_acceptance.py @@ -26,7 +26,7 @@ # AttributeError), so a whole substitute module is pre-seeded into # sys.modules before synthtools imports it: a fixed-sequence LCG, identical # on both interpreters. This is a property of THIS test harness only, not a -# port deviation -- see docs/upstream-diff.md. +# port deviation -- see docs/how-it-works/upstream-diff.md. class _DeterministicRandom: def __init__(self, seed): self._state = seed diff --git a/tests/parity/verify_acceptance.py b/tests/parity/verify_acceptance.py index d7873a7f..2de3a190 100644 --- a/tests/parity/verify_acceptance.py +++ b/tests/parity/verify_acceptance.py @@ -6,7 +6,7 @@ three deliberate deviations (the peaking-EQ sign, per-channel filter state, and the widened coefficients this file's `circuitpython_stdout` predates), and this script drives a resonant low-pass sweeping down from 300 Hz at 22050 Hz - -the exact case those deviations exist to fix. See docs/upstream-diff.md. +the exact case those deviations exist to fix. See docs/how-it-works/upstream-diff.md. So the fixture now carries both: `stdout` is what this port produces and is what gets enforced, and `circuitpython_stdout` is the oracle's own answer, kept diff --git a/tests/parity/verify_biquad.py b/tests/parity/verify_biquad.py index 567e23b2..720e5736 100644 --- a/tests/parity/verify_biquad.py +++ b/tests/parity/verify_biquad.py @@ -3,7 +3,7 @@ Unlike the other verifies here, the golden is captured from *this* port and not from CircuitPython: three deliberate deviations live in this path. See -docs/upstream-diff.md. Pass --capture to re-record after an intended change. +docs/how-it-works/upstream-diff.md. Pass --capture to re-record after an intended change. """ import hashlib diff --git a/tests/parity/verify_dsp.py b/tests/parity/verify_dsp.py index c203d79c..d90c3061 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -3,7 +3,7 @@ verify_dsp.py --micropython PATH [--circuitpython PATH] -`docs/correctness-standard.md` is what this implements. There is no stored +`docs/how-it-works/correctness-standard.md` is what this implements. There is no stored digest and no oracle: the probes are run on every interpreter given, and the gate is that their output is byte-identical. A disagreement is the finding. @@ -153,7 +153,7 @@ "are this port's extension and which CircuitPython " "refuses. Everything else in it matches 11.0.0-alpha.1 " "byte for byte, PEAKING_EQ and the filter reset " - "included. See docs/upstream-diff.md"}, + "included. See docs/how-it-works/upstream-diff.md"}, None), # audiomixer is CircuitPython's, so like biquad_component_probe.py it is # held to the interpreters agreeing rather than to a capture. The twin @@ -184,7 +184,7 @@ ("mixer_reset_probe.py", "audiomixer", {"circuitpython": "CircuitPython's own Mixer stops every voice on a " "reset, which this port deliberately does not - " - "docs/upstream-diff.md, \"Resetting a Mixer silenced " + "docs/how-it-works/upstream-diff.md, \"Resetting a Mixer silenced " "it\""}, None), ("tank_probe.py", "audioverb", {}, None), @@ -199,14 +199,14 @@ {"circuitpython": "upstream's own Flanger overflows int32 in its wet " "interpolation on full-scale material and ours does " "not - audiodsp#76, a deliberate departure recorded in " - "docs/upstream-diff.md"}, + "docs/how-it-works/upstream-diff.md"}, None), ("granular_pitch_shift_probe.py", "audiodelays", {}, None), ("resampler_probe.py", "audiospeed", {}, None), # Two departures from 10.3.0, both of them upstream bugs this port # declines to reproduce: the Q16 rate truncates there and rounds here # (audiodsp#92), and the phase accumulator is zeroed at every source buffer - # there and carried here (audiodsp#91). See docs/upstream-diff.md. + # there and carried here (audiodsp#91). See docs/how-it-works/upstream-diff.md. # # `resampler_probe.py` above cannot see either one -- it asks only for 2.0, # 1.0 and 0.5, which are exact in Q16 and divide a buffer exactly -- which @@ -221,7 +221,7 @@ "restarts its phase at every source buffer, and ours " "does neither - audiodsp#92 and audiodsp#91, both " "deliberate departures recorded in " - "docs/upstream-diff.md"}, + "docs/how-it-works/upstream-diff.md"}, None), ("echo_filter_probe.py", "audiodelays", {}, None), # A delay line shorter than one audio buffer. CircuitPython 10.3.0 raised @@ -240,7 +240,7 @@ "write head, where upstream writes one sample past " "the new end and ours starts the line again at its " "top: 2 samples differ, a deliberate departure " - "recorded in docs/upstream-diff.md"}, + "recorded in docs/how-it-works/upstream-diff.md"}, None), # A stereo source handing an odd number of samples, and a looped source # that comes back empty (audiodsp#177). Upstream swaps the lanes on the @@ -252,7 +252,7 @@ "a looped source that comes back empty; ours do none " "of these - audiodsp#177 and the Echo entry, " "deliberate departures recorded in " - "docs/upstream-diff.md"}, + "docs/how-it-works/upstream-diff.md"}, None), # A reset puts the write head back at the top, so a shorter Time after # it plays as on a fresh node (audiocomponents' MultiTapDelay, 7d). @@ -261,7 +261,7 @@ "across reset_buffer, so a Time shortened after a " "reset keeps another stretch of the line; ours puts " "them at the top, a deliberate departure recorded " - "in docs/upstream-diff.md"}, + "in docs/how-it-works/upstream-diff.md"}, None), # wow_hz and loop_semitones taken to 0 park at their next zero crossing # instead of freezing mid-swing (audiodsp#177). @@ -282,7 +282,7 @@ {"circuitpython": "upstream's Echo and MultiTapDelay write one sample " "past a line shortened under the write head, and ours " "do not - a deliberate departure recorded in " - "docs/upstream-diff.md"}, + "docs/how-it-works/upstream-diff.md"}, None), ("delay_time_probe.py", "audioecho", {}, None), ("feedback_delay_frames_probe.py", "audioecho", {}, None), @@ -311,7 +311,7 @@ # frozen loop here plays the overlap's frame where its read lands on the # write position, where upstream plays the window's oldest frame (a # click a lap), and the blocks after `freeze` goes back to False splice - # live audio, which this port moves into phase (docs/upstream-diff.md), + # live audio, which this port moves into phase (docs/how-it-works/upstream-diff.md), # so CircuitPython sits this one out. ("pitchshift_freeze_probe.py", "audiodelays", {"circuitpython": "upstream's PitchShift splices wherever the window " @@ -320,7 +320,7 @@ "the window's oldest frame, a click, wherever the " "read lands on the write position, where ours plays " "the overlap's - deliberate departures recorded in " - "docs/upstream-diff.md"}, + "docs/how-it-works/upstream-diff.md"}, None), # A reset puts PitchShift's read and write positions and # GranularPitchShift's jitter sequence back where a new node starts, and @@ -330,7 +330,7 @@ "positions across a reset and its GranularPitchShift " "keeps its jitter sequence, and ours start again - " "deliberate departures recorded in " - "docs/upstream-diff.md"}, + "docs/how-it-works/upstream-diff.md"}, None), # A looping source with nothing to give no longer spins the pitch # shifters' or Flanger's pull loop: the node lets go of it and the block @@ -339,7 +339,7 @@ {"circuitpython": "upstream's PitchShift, GranularPitchShift and Flanger " "go round their pull loop for ever on a looping source that " "comes back empty, and ours return - a deliberate " - "departure recorded in docs/upstream-diff.md"}, + "departure recorded in docs/how-it-works/upstream-diff.md"}, None), ) @@ -352,7 +352,7 @@ def run_probe(argv_prefix, probe, module): # is only here for anything a probe loads out of the tree -- `lib/` is on # it because `audiodsp_util` lives there, and a probe that derives a setting # in Python has to round it the way a board would before handing it over - # (docs/correctness-standard.md, audiodsp#80). + # (docs/how-it-works/correctness-standard.md, audiodsp#80). environment["MICROPYPATH"] = "%s:%s" % (ROOT, ROOT / "lib") # The same directory for the CPython leg, ahead of whatever is installed. # It is pure Python with no DSP in it, and putting it here is what lets diff --git a/tests/parity/verify_mixdown_knee.py b/tests/parity/verify_mixdown_knee.py index 22c74c0d..f46139ba 100644 --- a/tests/parity/verify_mixdown_knee.py +++ b/tests/parity/verify_mixdown_knee.py @@ -35,7 +35,7 @@ knee and oracle-identical. The above-knee half of this gate is therefore NOT oracle-enforced, and nothing here should be described as if it were. That divergence is the ceiling and nothing else, and it is written -down: docs/upstream-diff.md, "The ceiling later moved 14 -> 64, which +down: docs/how-it-works/upstream-diff.md, "The ceiling later moved 14 -> 64, which gives up that alignment above the knee". Check (b), the anti-launder tripwire, and the only check here that diff --git a/tests/test_audiodsp_util.py b/tests/test_audiodsp_util.py index c938c25e..c5f1b989 100644 --- a/tests/test_audiodsp_util.py +++ b/tests/test_audiodsp_util.py @@ -4,7 +4,7 @@ double here and on a desktop MicroPython, single on every board audiodsp ships on and on a MicroPython built with `MICROPY_FLOAT_IMPL_FLOAT`. `float32` is the round trip that makes the two agree, and `float32_bits` is the exact way to -print one. `docs/correctness-standard.md` carries the rule; audiodsp#80 is where +print one. `docs/how-it-works/correctness-standard.md` carries the rule; audiodsp#80 is where six parity probes were found disagreeing with CPython over nothing else. | ID | Trait | Bar | diff --git a/tests/test_cpython_audiobiquad.py b/tests/test_cpython_audiobiquad.py index ebb3eb44..201e5047 100644 --- a/tests/test_cpython_audiobiquad.py +++ b/tests/test_cpython_audiobiquad.py @@ -20,7 +20,7 @@ ## The traits, with their bars -`docs/correctness-standard.md` is what this file implements for `audiobiquad`: +`docs/how-it-works/correctness-standard.md` is what this file implements for `audiobiquad`: the module is ours, upstream CircuitPython has no counterpart, so it is held to three-target agreement (`tests/parity/filter_f32_probe.py`, run by `verify_dsp`) plus the numeric traits below. Each has an ID so an issue or an evidence pack @@ -591,7 +591,7 @@ def settled_words(node, blocks=16, skip=10): class UniversalTraitTest(unittest.TestCase): """The traits every node of ours carries, on this one. - B8, B9 and B10 are the shape `docs/correctness-standard.md` asks of every + B8, B9 and B10 are the shape `docs/how-it-works/correctness-standard.md` asks of every audiodsp-own module. They are here first because `audiobiquad` is what ten of the Phase 2 `audioeffects` classes are built on, so a defect here is a defect in all of them. diff --git a/tests/test_cpython_audioconvolve.py b/tests/test_cpython_audioconvolve.py index c7df1877..1a5c448b 100644 --- a/tests/test_cpython_audioconvolve.py +++ b/tests/test_cpython_audioconvolve.py @@ -4,7 +4,7 @@ targets. What is left for here is what a render cannot say, and the numeric traits with their bars. -`docs/correctness-standard.md` is what this file implements for +`docs/how-it-works/correctness-standard.md` is what this file implements for `audioconvolve`: the module is ours - upstream CircuitPython has no counterpart - so it is held to three-target agreement plus the traits below, never to a previous version of its own output. @@ -28,7 +28,7 @@ | C10 | Each side of a synthesized stereo room is the unit-energy room of its own noise: the left is the mono room of the same seed, the right the mono room of the right side's seed; the two sides balance, together they are the level of a mono room, and they stay two different noises | exact; 0.01 dB; correlation under 0.5 | **C1 and C2 are this module's form of the identity trait** that -`docs/correctness-standard.md` asks of every own node: an exact answer *through* +`docs/how-it-works/correctness-standard.md` asks of every own node: an exact answer *through* the DSP rather than around it, evaluated at full scale, because that is where an arithmetic overflow shows and a range check does not. diff --git a/tests/test_cpython_audiodynamics.py b/tests/test_cpython_audiodynamics.py index 875d8a26..c71ef44b 100644 --- a/tests/test_cpython_audiodynamics.py +++ b/tests/test_cpython_audiodynamics.py @@ -6,7 +6,7 @@ the errors, the source behaviours only a Python-defined audiosample can produce, and the numeric traits with their bars. -`docs/correctness-standard.md` is what this file implements for +`docs/how-it-works/correctness-standard.md` is what this file implements for `audiodynamics`: the module is ours - upstream CircuitPython has no counterpart - so it is held to three-target agreement plus the traits below, never to a previous version of its own output. diff --git a/tests/test_cpython_audioecho.py b/tests/test_cpython_audioecho.py index 4d41ac07..861960a3 100644 --- a/tests/test_cpython_audioecho.py +++ b/tests/test_cpython_audioecho.py @@ -4,7 +4,7 @@ pin what this module renders across the three targets. What is left for here is what a render cannot say, and the numeric traits with their bars. -`docs/correctness-standard.md` is what this file implements for `audioecho`: +`docs/how-it-works/correctness-standard.md` is what this file implements for `audioecho`: the module is ours - upstream CircuitPython has no counterpart - so it is held to three-target agreement plus the traits below, never to a previous version of its own output. @@ -43,7 +43,7 @@ value at least one LSB smaller than the last. **E1 is this module's form of the identity trait** that -`docs/correctness-standard.md` asks of every own node - an exact answer through +`docs/how-it-works/correctness-standard.md` asks of every own node - an exact answer through the DSP, at full scale, because that is where an arithmetic width error shows and a range check does not. diff --git a/tests/test_cpython_audiomodal.py b/tests/test_cpython_audiomodal.py index d9f1c185..c8038fb9 100644 --- a/tests/test_cpython_audiomodal.py +++ b/tests/test_cpython_audiomodal.py @@ -4,7 +4,7 @@ targets. What is left for here is what a render cannot say, and the numeric traits with their bars. -`docs/correctness-standard.md` is what this file implements for `audiomodal`: +`docs/how-it-works/correctness-standard.md` is what this file implements for `audiomodal`: the module is ours - upstream CircuitPython has no counterpart - so it is held to three-target agreement plus the traits below, never to a previous version of its own output. @@ -28,7 +28,7 @@ | M13 | Narrowing the loop to the live modes changes no sample | exact, 0 LSB | **M2 and M6 are this module's form of the identity trait** that -`docs/correctness-standard.md` asks of every own node: an exact answer +`docs/how-it-works/correctness-standard.md` asks of every own node: an exact answer *through* the DSP rather than around it, evaluated at full scale, because that is where an arithmetic overflow shows and a range check does not. @@ -66,7 +66,7 @@ ## The planted faults -`docs/correctness-standard.md`: "a trait without a planted fault is not a +`docs/how-it-works/correctness-standard.md`: "a trait without a planted fault is not a check; it is a hope." Each fault below is a one-line change to `src/shared/audiodsp_modal.c` that a reader can make by hand, with what it did to the traits when it was made. They are recorded rather than automated diff --git a/tests/test_cpython_audioroute.py b/tests/test_cpython_audioroute.py index d7858348..f82a67ec 100644 --- a/tests/test_cpython_audioroute.py +++ b/tests/test_cpython_audioroute.py @@ -5,7 +5,7 @@ what a render cannot say: the argument forms, the errors, the source behaviours only a Python-defined audiosample can produce, and the numeric traits. -`docs/correctness-standard.md` is what this file implements for `audioroute`: +`docs/how-it-works/correctness-standard.md` is what this file implements for `audioroute`: the module is ours - upstream CircuitPython has no counterpart - so it is held to three-target agreement plus the traits below, never to a previous version of its own output. @@ -32,7 +32,7 @@ | R16 | `Splitter.clear()` drops what nobody read, and reads nothing | exact, frame for frame | R7 is this module's form of the identity trait -(`docs/correctness-standard.md`): an exact answer through the DSP rather than a +(`docs/how-it-works/correctness-standard.md`): an exact answer through the DSP rather than a bypass, which is where an arithmetic overflow shows and a range check does not. R12 is audiodsp#87, and it is a trait rather than a render case because no diff --git a/tests/test_cpython_audiosampler.py b/tests/test_cpython_audiosampler.py index edeef276..88352413 100644 --- a/tests/test_cpython_audiosampler.py +++ b/tests/test_cpython_audiosampler.py @@ -4,7 +4,7 @@ three targets. What is left for here is what a render cannot say, and the numeric traits with their bars. -`docs/correctness-standard.md` is what this file implements for +`docs/how-it-works/correctness-standard.md` is what this file implements for `audiosampler`: the module is ours - upstream CircuitPython has no counterpart - so it is held to three-target agreement plus the traits below, never to a previous version of its own output. @@ -37,7 +37,7 @@ | S22 | A steal on the last voice of a table of 128 fades the old note out, as S18 | exact | | S23 | 128 looping stereo voices render inside the 5333 us block | measured | -S3 is this module's form of the identity trait `docs/correctness-standard.md` +S3 is this module's form of the identity trait `docs/how-it-works/correctness-standard.md` asks of every own node: an exact answer through the interpolator rather than around it. The Hermite polynomial passes through its samples, so an integer position reads the buffer's own value, and a loop at pitch 1.0 whose length @@ -50,7 +50,7 @@ ## The planted fault -`docs/correctness-standard.md`: "a trait without a planted fault is not a +`docs/how-it-works/correctness-standard.md`: "a trait without a planted fault is not a check; it is a hope." The fault below is a one-line change to `src/shared/audiodsp_sampler.c` that a reader can make by hand, with what it did when it was made. It is recorded rather than automated because the file diff --git a/tests/test_cpython_audioshaper.py b/tests/test_cpython_audioshaper.py index 52ed06fe..3fdc8933 100644 --- a/tests/test_cpython_audioshaper.py +++ b/tests/test_cpython_audioshaper.py @@ -352,7 +352,7 @@ class HeadroomTest(unittest.TestCase): 101 cycles of 1010 Hz, so the fundamental and every harmonic fall on their own bin with no window and no rounding. - Measured (`docs/upstream-diff.md`'s `audioshaper` section carries the + Measured (`docs/how-it-works/upstream-diff.md`'s `audioshaper` section carries the full table): flat at -54.34 dB through `post_gain` 0.80, -41.24 at 0.90, -36.44 at 1.00 -- a 13.1 dB fall by 0.90 that the bars below ask 10 of, for margin. The control is oversample x1: no half-band, no diff --git a/tests/test_cpython_multitap_empty_source.py b/tests/test_cpython_multitap_empty_source.py index d0a9b5a7..8d9e4097 100644 --- a/tests/test_cpython_multitap_empty_source.py +++ b/tests/test_cpython_multitap_empty_source.py @@ -3,7 +3,7 @@ Upstream CircuitPython's pull loop goes round for ever on such a source: it asks again, gets 0 bytes, advances by 0 and asks again. This port renders the rest of the block from silence, keeps the source, and asks it again on the -next pull (audiodsp#177, a recorded deviation in docs/upstream-diff.md). No +next pull (audiodsp#177, a recorded deviation in docs/how-it-works/upstream-diff.md). No native source in this repository hands an empty buffer with more to come, so the case is held on the CPython twin, where a source can be written in Python; the native node's loop is the same shape and `multitap_edges_probe.py` holds diff --git a/tests/test_cpython_multitap_reset.py b/tests/test_cpython_multitap_reset.py index 9fa04ede..d3fad459 100644 --- a/tests/test_cpython_multitap_reset.py +++ b/tests/test_cpython_multitap_reset.py @@ -10,7 +10,7 @@ puts both lanes' heads at the top, as construction does. CircuitPython builds upstream's own MultiTapDelay, which keeps the head; the -departure is recorded in docs/upstream-diff.md. +departure is recorded in docs/how-it-works/upstream-diff.md. """ import unittest diff --git a/tests/test_cpython_pitch_empty_source.py b/tests/test_cpython_pitch_empty_source.py index b1805293..8c3bb444 100644 --- a/tests/test_cpython_pitch_empty_source.py +++ b/tests/test_cpython_pitch_empty_source.py @@ -3,7 +3,7 @@ `PitchShift`, `GranularPitchShift` and `Flanger` render the rest of the block from silence, keep the source and ask it again on the next pull, as -`MultiTapDelay` does (docs/upstream-diff.md). Upstream CircuitPython's pull +`MultiTapDelay` does (docs/how-it-works/upstream-diff.md). Upstream CircuitPython's pull loop goes round for ever on such a source. No native source in this repository hands an empty buffer with more to come, so the case is held on the CPython twin, where a source can be written in Python; diff --git a/tests/test_cpython_pitch_splice.py b/tests/test_cpython_pitch_splice.py index d14ffc36..d8f890fe 100644 --- a/tests/test_cpython_pitch_splice.py +++ b/tests/test_cpython_pitch_splice.py @@ -9,7 +9,7 @@ what comes out is the note's neighbours a splice rate either side, and almost nothing at the shifted pitch. Here the incoming side is moved, by up to a 50th of a second, to where it matches the outgoing one -(docs/upstream-diff.md). This file holds that on the CPython twin, which +(docs/how-it-works/upstream-diff.md). This file holds that on the CPython twin, which runs the same kernel as the native node; `verify_dsp.py` holds the native builds to the same bytes. diff --git a/tests/test_cpython_speedchanger.py b/tests/test_cpython_speedchanger.py index 1c3326d2..e7c9bd29 100644 --- a/tests/test_cpython_speedchanger.py +++ b/tests/test_cpython_speedchanger.py @@ -1,7 +1,7 @@ """`audiospeed.SpeedChanger`: the Q16 rate, and the phase across a boundary. Two deliberate departures from CircuitPython 10.3.0, both of them upstream -bugs this port declines to reproduce. `docs/upstream-diff.md` carries the +bugs this port declines to reproduce. `docs/how-it-works/upstream-diff.md` carries the measurements and `docs/upstream-reports/` the reports. The parity gate cannot see either one. `resampler_probe.py` only ever asks @@ -102,7 +102,7 @@ class TheRateRoundsToTheNearestStep(unittest.TestCase): """audiodsp#92. Upstream's `rate_to_fp` is `(uint32_t)(rate * (1 << 16))`, a truncation, so a float landing a hair under its Q16 neighbour loses a whole LSB rather than arriving at it. Every number below was measured on - this twin before the fix and is in `docs/upstream-diff.md`.""" + this twin before the fix and is in `docs/how-it-works/upstream-diff.md`.""" def test_a_rate_a_hair_under_unity_arrives_at_unity(self): """Truncating gave 65535/65536 = 0.9999847412109375.""" @@ -160,7 +160,7 @@ class ThePhaseCrossesASourceBuffer(unittest.TestCase): """audiodsp#91. Upstream zeroes the accumulator every time it takes a new buffer from the source, so a hold restarts its staircase 187 times a second at 48 kHz and drifts. Every "before" number here was measured on - this twin and is in `docs/upstream-diff.md`.""" + this twin and is in `docs/how-it-works/upstream-diff.md`.""" def rendered_hold(self, values, down, frames, block=BLOCK): node = audiospeed.SpeedChanger( diff --git a/tests/test_voice_ceiling_consistency.py b/tests/test_voice_ceiling_consistency.py index 4ab2e41e..f7bea867 100644 --- a/tests/test_voice_ceiling_consistency.py +++ b/tests/test_voice_ceiling_consistency.py @@ -49,7 +49,7 @@ #: ("built at 14 and must never be rebuilt") is retired: an oracle at a #: different configuration cannot answer the only question worth asking, which #: is whether we render CircuitPython's bytes in the same situation. See -#: docs/correctness-standard.md. The hole this closes is unchanged: a +#: docs/how-it-works/correctness-standard.md. The hole this closes is unchanged: a #: CFLAGS_EXTRA rebuild of the oracle needs no edit to any tracked file, #: leaves `git -C circuitpython status` clean, and overwrites the #: gitignored binary in place. Every existing check would pass on a silently