diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..176b275 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,39 @@ +# The docs site as Read the Docs builds it: from the repository root, with +# docs/requirements.txt, and every warning a failure. The module reference is +# written from the type stubs, so a stub that does not render fails here. +name: Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'src/**/*.pyi' + - 'VERSION' + - '.readthedocs.yaml' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'docs/**' + - 'src/**/*.pyi' + - 'VERSION' + - '.readthedocs.yaml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + docs: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + - name: Install docs requirements + run: pip install -r docs/requirements.txt + - name: Build docs + run: sphinx-build -W --keep-going -b html docs _site diff --git a/.gitignore b/.gitignore index 8feb603..c6ee480 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ dist/ *.o *.a .deps/ + +# Built documentation +docs/_build/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..6a17e5f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,14 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +sphinx: + configuration: docs/conf.py + fail_on_warning: true + +python: + install: + - requirements: docs/requirements.txt diff --git a/docs/changelog.md b/docs/changelog.md index a40bfa0..881f42f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +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). +Every release's changes are listed in [CHANGELOG.md](https://github.com/PyDevices/audiodsp/blob/main/CHANGELOG.md), newest first. What is planned next is in [ROADMAP.md](https://github.com/PyDevices/audiodsp/blob/main/ROADMAP.md). diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..aca0794 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""Sphinx configuration for the audiodsp documentation. + +Everything shared by the PyDevices documentation projects (theme, top bar, +Markdown and docstring setup) comes from pydevices-docs. + +The module reference is written from the type stubs, ``src//.pyi``, +which list every name each native module exports. The build copies each stub +into ``_build`` as an importable module, so autodoc reads the stubs and never +imports the compiled modules. +""" + +import re +import sys +from pathlib import Path + +from pydevices_docs import configure + +globals().update(configure( + "audiodsp", __file__, + repo="PyDevices/audiodsp", edit_path="docs", + version_file="../VERSION", +)) + +_DOCS = Path(__file__).resolve().parent +_STUBS = _DOCS.parent / "src" +_STUB_ROOT = _DOCS / "_build" / "stubs" + + +def _importable(text): + """A stub may name a class before it is defined, which a stub allows and + Python before 3.14 does not, so defer the annotations.""" + return re.sub(r"^((?:from|import) )", r"from __future__ import annotations\n\1", + text, count=1, flags=re.M) + + +def _install_stubs(app): + """Make every module's stub importable under the module's own name.""" + _STUB_ROOT.mkdir(parents=True, exist_ok=True) + if str(_STUB_ROOT) not in sys.path: + sys.path.insert(0, str(_STUB_ROOT)) + for stub in sorted(_STUBS.glob("*/*.pyi")): + target = _STUB_ROOT / (stub.stem + ".py") + text = _importable(stub.read_text(encoding="utf-8")) + if not target.exists() or target.read_text(encoding="utf-8") != text: + target.write_text(text, encoding="utf-8") + sys.modules.pop(stub.stem, None) + + +def setup(app): + app.connect("builder-inited", _install_stubs) + return {"version": "1", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/guides/decode-vorbis.md b/docs/guides/decode-vorbis.md index d45e082..68c05c5 100644 --- a/docs/guides/decode-vorbis.md +++ b/docs/guides/decode-vorbis.md @@ -17,7 +17,7 @@ and `with` as `MP3Decoder`. Mono and stereo files play; `sample_rate` and 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. +[third_party/tremor](https://github.com/PyDevices/audiodsp/tree/main/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, diff --git a/docs/how-it-works/correctness-standard.md b/docs/how-it-works/correctness-standard.md index f148cc7..6b8cfb1 100644 --- a/docs/how-it-works/correctness-standard.md +++ b/docs/how-it-works/correctness-standard.md @@ -70,7 +70,7 @@ matters is that no independent implementation exists to compare against today. **1. Three-target agreement, with nothing stored.** The CPython extension, desktop MicroPython, and the patched CircuitPython build must render a probe -identically. All three compile the same kernel under [`src/shared/`](../../src/shared/), so this is +identically. All three compile the same kernel under [`src/shared/`](https://github.com/PyDevices/audiodsp/tree/main/src/shared), so this is a real check on the three compilations and on anything that is architecture-dependent, undefined, or width-sensitive — the class of defect that found the flanger overflow. It cannot be laundered, because there is no file to @@ -102,7 +102,7 @@ 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/`](../upstream-reports/)), or a + bug of theirs worth an upstream report ([`docs/upstream-reports/`](https://github.com/PyDevices/audiodsp/tree/main/docs/upstream-reports)), or a deliberate departure that goes in [`docs/how-it-works/upstream-diff.md`](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 diff --git a/docs/how-it-works/index.md b/docs/how-it-works/index.md index 81890c5..757a992 100644 --- a/docs/how-it-works/index.md +++ b/docs/how-it-works/index.md @@ -47,14 +47,14 @@ For a new pump port, [the pump-port guide](pump-ports.md) is authoritative. | Path | Purpose | |---|---| -| [`src/audiocore/`](../../src/audiocore/), [`src/synthio/`](../../src/synthio/), [`src/audiomixer/`](../../src/audiomixer/) | Core CircuitPython-compatible source, synthesis, and mixing modules. | +| [`src/audiocore/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiocore), [`src/synthio/`](https://github.com/PyDevices/audiodsp/tree/main/src/synthio), [`src/audiomixer/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiomixer) | Core CircuitPython-compatible source, synthesis, and mixing modules. | | `src/audio*/` | Effects, DSP, routing, and audio utility modules compiled into native targets. | -| [`src/audiopump/`](../../src/audiopump/) | Portable pump loop, rings, events, taps, and lifecycle guards. | -| [`src/shared/`](../../src/shared/) | DSP and sample-protocol C shared by the MicroPython and CPython targets. | -| [`src/cp_compat/`](../../src/cp_compat/) | Small CircuitPython compatibility shims needed by the MicroPython port. | -| [`src/cpython/`](../../src/cpython/) | `_audiodsp` extension plus top-level Python wrappers for CPython. | -| [`lib/audiodsp_util/`](../../lib/audiodsp_util/) | Cross-runtime `float32()` helpers for settings derived in Python. | -| [`lib/audiorender/`](../../lib/audiorender/) | Desktop-only, NumPy-based offline composition renderer. | +| [`src/audiopump/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiopump) | Portable pump loop, rings, events, taps, and lifecycle guards. | +| [`src/shared/`](https://github.com/PyDevices/audiodsp/tree/main/src/shared) | DSP and sample-protocol C shared by the MicroPython and CPython targets. | +| [`src/cp_compat/`](https://github.com/PyDevices/audiodsp/tree/main/src/cp_compat) | Small CircuitPython compatibility shims needed by the MicroPython port. | +| [`src/cpython/`](https://github.com/PyDevices/audiodsp/tree/main/src/cpython) | `_audiodsp` extension plus top-level Python wrappers for CPython. | +| [`lib/audiodsp_util/`](https://github.com/PyDevices/audiodsp/tree/main/lib/audiodsp_util) | Cross-runtime `float32()` helpers for settings derived in Python. | +| [`lib/audiorender/`](https://github.com/PyDevices/audiodsp/tree/main/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 parity tools that need sibling checkouts. | | `docs/` | Guides, reference, and these architecture pages. | @@ -97,7 +97,7 @@ Most deep parity checks need the sibling repositories checked out beside 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. [CONTRIBUTING.md](../../CONTRIBUTING.md) has the rules: +the local lint gate. [CONTRIBUTING.md](https://github.com/PyDevices/audiodsp/blob/main/CONTRIBUTING.md) has the rules: don't modify the CircuitPython oracle, don't casually update parity goldens, and don't move the dependency pins. diff --git a/docs/how-it-works/upstream-diff.md b/docs/how-it-works/upstream-diff.md index fdd5962..3f4edea 100644 --- a/docs/how-it-works/upstream-diff.md +++ b/docs/how-it-works/upstream-diff.md @@ -747,7 +747,7 @@ 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/](https://github.com/PyDevices/audiodsp/tree/main/docs/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; @@ -958,7 +958,7 @@ MicroPython, unrelated to any ported module). ## Phase 7: `synthtools` end-to-end acceptance test -[`tests/vendor/synthtools`](../../tests/vendor/synthtools/) is todbot's +[`tests/vendor/synthtools`](https://github.com/PyDevices/audiodsp/tree/main/tests/vendor/synthtools) is todbot's [`CircuitPython_SynthTools`](https://github.com/todbot/CircuitPython_SynthTools) (MIT, commit recorded in [`tests/vendor/SYNTHTOOLS_COMMIT.txt`](../../tests/vendor/SYNTHTOOLS_COMMIT.txt)), vendored unmodified -- the `synthtools/` package only, not `examples/`/`tests/`/ @@ -3555,7 +3555,7 @@ group delay 0 samples at x1, 2.2 (46 us) at x2, 3.3 (69 us) at x4, 3.9 (80 us) at x8, with the magnitude response flat to within 0.001 dB from 200 Hz to 18 kHz. It is small -- a linear-phase FIR half-band steep enough for this transition would be several times that -- but it is not zero and it is not an -integer, so `audioshaper.GROUP_DELAY_SAMPLES` carries the table and a +integer, so `audioshaper.GROUP_DELAY_SAMPLES` (a constant of the CPython build only) carries the table and a component reporting `latency_samples` reports the entry for the factor it built with. @@ -3606,7 +3606,7 @@ Phase 0 sketch carried would have been a byte of state nothing reads. `audiomixer.Mixer` does sample by sample with a saturating signed add (`src/audiomixer/Mixer.c:216-230`). No surviving claimant needs a stream here, and keeping the kernel free of `mp_obj` is what lets one golden hash - cover every interpreter -- the arithmetic all lives in [`src/shared/`](../../src/shared/), so + cover every interpreter -- the arithmetic all lives in [`src/shared/`](https://github.com/PyDevices/audiodsp/tree/main/src/shared), so two interpreters disagreeing would itself be the finding. - **There is no `pre_filter_hz`.** Vision section 6 says the input filter is the class's to compose, and `audiofilters.Filter` with a `synthio.Biquad` @@ -4274,7 +4274,7 @@ the routes `play()` cannot see. **Upstream still spins**, which is why [`tests/parity/mixer_short_loop_probe.py`](../../tests/parity/mixer_short_loop_probe.py) is not in `verify_dsp.py`'s `PROBES`: the oracle would hang the three-way rather than report a difference. `audiomixer` is a stock CircuitPython module and not part of -[`src/circuitpython_spike/`](../../src/circuitpython_spike/), so per this repository's rule the fix lands on the +[`src/circuitpython_spike/`](https://github.com/PyDevices/audiodsp/tree/main/src/circuitpython_spike), so per this repository's rule the fix lands on the MicroPython and CPython targets only and the oracle is left alone. ### Refused rather than padded @@ -4540,7 +4540,7 @@ public surface, and is written down here rather than assumed away. does not need one: its playback layer already pulls the graph natively, from its own audio thread, so a second pull loop would be a second owner of the same nodes. The module is therefore **absent from a CircuitPython build** — -`copy_manifest.txt` names none of [`src/audiopump/`](../../src/audiopump/) — and absent from the +`copy_manifest.txt` names none of [`src/audiopump/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiopump) — and absent from the **CPython wheel**, which excludes it rather than stubbing it, because it is a MicroPython C module through and through (`MP_REGISTER_MODULE`, `mp_obj_t`, a VM root pointer array) and a desktop Python program has threads of its own. diff --git a/docs/how-it-works/upstream-sync.md b/docs/how-it-works/upstream-sync.md index 4d51f74..97889c4 100644 --- a/docs/how-it-works/upstream-sync.md +++ b/docs/how-it-works/upstream-sync.md @@ -50,7 +50,7 @@ to turn up. 3. For each commit, find the same code here. A file under `src//` holds upstream's `shared-module` and `shared-bindings` files merged, with a `// --- from ...` line where each begins. Arithmetic that both bindings - run lives in [`src/shared/`](../../src/shared/), and the CPython target's copy of the module + run lives in [`src/shared/`](https://github.com/PyDevices/audiodsp/tree/main/src/shared), and the CPython target's copy of the module is `src/cpython/.py` beside `_audiodsp.c`. A fix usually lands in two or three of those places. 4. Take each fix in upstream's own words where the code is still upstream's, @@ -59,7 +59,7 @@ to turn up. all, in both the native module and the CPython target. Leave out only what the list of deliberate differences below says this port does otherwise, and say which in the pull request. -5. Add a probe under [`tests/parity/`](../../tests/parity/) whose output changes with each fix or +5. Add a probe under [`tests/parity/`](https://github.com/PyDevices/audiodsp/tree/main/tests/parity) whose output changes with each fix or addition, and add it to `verify_dsp.py`. A probe CPython can't run (no `audiomp3`, say) can't be compared there, because CI has only CPython and MicroPython; have it check itself against the oracle's answers and run it in @@ -211,7 +211,7 @@ a resync should take upstream's version. Each entry links to its reasons. `shared-bindings` files for that class merged into one, docstrings (`//|`) dropped. The per-sample loops of the effects live in `src/shared/` so the CPython target runs the same C. -- **The CPython target** ([`src/cpython/`](../../src/cpython/)) is a second implementation, held to +- **The CPython target** ([`src/cpython/`](https://github.com/PyDevices/audiodsp/tree/main/src/cpython)) is a second implementation, held to the native bytes by `verify_dsp.py` rather than by sharing upstream's code. - **`attr, cp_compat_attr`** on every type, because mainline MicroPython needs it to call a property diff --git a/docs/how-it-works/wheels-and-platforms.md b/docs/how-it-works/wheels-and-platforms.md index 63a3867..5e9989d 100644 --- a/docs/how-it-works/wheels-and-platforms.md +++ b/docs/how-it-works/wheels-and-platforms.md @@ -2,7 +2,7 @@ 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). +[CONTRIBUTING.md](https://github.com/PyDevices/audiodsp/blob/main/CONTRIBUTING.md#build-and-release-wheels). ## What a release builds @@ -71,7 +71,7 @@ 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/`](../../src/shared/) file that computes in float closed it (issue +every [`src/shared/`](https://github.com/PyDevices/audiodsp/tree/main/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`](../../tests/parity/golden/effects_component.json): it is held to the oracle hash itself, the same one x86_64 is held to. diff --git a/docs/index.md b/docs/index.md index 42fa4e8..118c1f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,10 +27,12 @@ have. Start with install, then your first program. ## 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`. +- [Sources and synthesis](reference/sources-and-synthesis.md): `audiocore`, `audiomp3`, `synthio`, `audiooscillator`, `audiomodal`, `audiosampler`, `audiograph`. +- [Filters and effects](reference/filters-and-effects.md): `audiobiquad`, `audiodynamics`, `audioecho`, `audioshaper`, `audioladder`, `audioverb`, `audiofilters`, `audiodelays`, `audiofreeverb`, `audiospeed`, `audioconvolve`. +- [Mixing and routing](reference/mixing-and-routing.md): `audiomixer`, `audioroute`. +- [Analysis and utilities](reference/analysis-and-utilities.md): `audiometer`, `audiofeatures`, `audiomath`. - [audiopump](reference/audiopump.md): status, builds, sources. -- [Vorbis](reference/vorbis.md): the C interface, memory and tests. +- [Vorbis](reference/vorbis.md): `audiovorbis`, the C interface, memory and tests. ## How it works @@ -50,4 +52,59 @@ have. Start with install, then your first program. - [Changelog](changelog.md): what changed in each release. -[Roadmap](../ROADMAP.md) · [Contributing](../CONTRIBUTING.md) +[Roadmap](https://github.com/PyDevices/audiodsp/blob/main/ROADMAP.md) · [Contributing](https://github.com/PyDevices/audiodsp/blob/main/CONTRIBUTING.md) + +```{toctree} +:hidden: + +changelog +get-started/first-program +get-started/install +guides/change-without-a-click +guides/decode-vorbis +guides/filter-a-sound +guides/meter-levels +guides/play-computed-audio +guides/play-recorded-notes +guides/run-the-audio-pump +guides/speech-features +guides/swap-what-plays +guides/use-threads +how-it-works/additions +how-it-works/audiograph +how-it-works/correctness-standard +how-it-works/drift-resampler +how-it-works/index +how-it-works/parity-gates +how-it-works/pump-ports +how-it-works/sound-stability +how-it-works/upstream-diff +how-it-works/upstream-sync +how-it-works/wheels-and-platforms +porting-plan +reference/analysis-and-utilities +reference/audiopump +reference/filters-and-effects +reference/mixing-and-routing +reference/modules +reference/sources-and-synthesis +reference/vorbis +upstream-reports/README +upstream-reports/biquad-band-edges +upstream-reports/biquad-reset +upstream-reports/dds-oscillator-off-by-one +upstream-reports/distortion-overdrive-drive +upstream-reports/distortion-soft-clip-union +upstream-reports/peaking-eq-sign +upstream-reports/prs/biquad-reset/PR +upstream-reports/prs/dds-oscillator-off-by-one/PR +upstream-reports/prs/distortion-soft-clip-union/PR +upstream-reports/prs/flanger-int32-overflow/PR +upstream-reports/prs/peaking-eq-sign/PR +upstream-reports/prs/speedchanger-phase-carry/PR +upstream-reports/prs/speedchanger-rate-rounding/PR +upstream-reports/prs/synthio-note-repress-zero-level/PR +upstream-reports/prs/synthio-note-repress-zero-level/review-reply-relic-se +upstream-reports/speedchanger-phase-carry +upstream-reports/speedchanger-rate-rounding +``` diff --git a/docs/porting-plan.md b/docs/porting-plan.md index b7980ef..bbf2159 100644 --- a/docs/porting-plan.md +++ b/docs/porting-plan.md @@ -141,7 +141,7 @@ and their source is micropython-vst3 rather than CircuitPython: VST host, and have since moved once more, to the [audiocomponents](https://github.com/PyDevices/audiocomponents) repository, which develops and publishes them as their own distributions. - What remains here is [`lib/audiorender/`](../lib/audiorender/), the tier above those two: a + What remains here is [`lib/audiorender/`](https://github.com/PyDevices/audiodsp/tree/main/lib/audiorender), the tier above those two: a composition — tracks, a tempo map, notes, automation, sections — rendered offline to a mixed master and the level report a render is judged by. It is the one part of this repository written for a desktop rather than a @@ -272,7 +272,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same clean (`build_interpreters.sh`, LVGL smoke test, all 8 module stubs) after every change. 5. **Tier 3 `audiomixer`** *(done)*. `Mixer` + `MixerVoice` ported - ([`src/audiomixer/`](../src/audiomixer/)), oracle-diffed against `bin/circuitpython` 10.2.1 + ([`src/audiomixer/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiomixer)), oracle-diffed against `bin/circuitpython` 10.2.1 byte-for-byte across every branch of the mixdown engine: mono→mono, mono source upmixed into a stereo mixer with panning, 16-bit signed and 8-bit unsigned sample formats, single- and multi-voice mixdown @@ -287,8 +287,8 @@ consumer there is no aggregator, so `micropython.cmake` carries the same test, all module imports) after every change. 6. **Tier 4 effects** *(done)*. All nine types across four modules ported - ([`src/audiospeed/`](../src/audiospeed/), [`src/audiofreeverb/`](../src/audiofreeverb/), [`src/audiofilters/`](../src/audiofilters/), - [`src/audiodelays/`](../src/audiodelays/)): `SpeedChanger`; `Freeverb`; `Filter`, `Distortion`, + ([`src/audiospeed/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiospeed), [`src/audiofreeverb/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiofreeverb), [`src/audiofilters/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiofilters), + [`src/audiodelays/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiodelays)): `SpeedChanger`; `Freeverb`; `Filter`, `Distortion`, `Phaser`; `Chorus`, `Echo`, `MultiTapDelay`, `PitchShift`. All oracle-diffed byte-for-byte across mono/stereo, 8/16-bit signed/ unsigned, looping, block-input-driven (`synthio.LFO`) parameters, @@ -302,7 +302,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same every change. 7. **`synthtools` end-to-end (unix)** *(done)*. **Primary acceptance milestone.** Vendored todbot's `CircuitPython_SynthTools` unmodified - ([`tests/vendor/synthtools`](../tests/vendor/synthtools/), MIT); a new acceptance script + ([`tests/vendor/synthtools`](https://github.com/PyDevices/audiodsp/tree/main/tests/vendor/synthtools), MIT); a new acceptance script ([`tests/parity/synthtools_acceptance.py`](../tests/parity/synthtools_acceptance.py)) drives its `SubtractiveSynth` and `BasslineSynth` engines through a real `Synthesizer` + `Mixer` + `EffectsChain` (tier 4's `Filter`/`Distortion`/`Echo`), oracle-diffed @@ -459,7 +459,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same itself. Cloned `mp3` (upstream `adafruit/Adafruit_MP3`) as a parent workspace sibling dependency pinned to the exact commit its `circuitpython/lib/mp3` vendors, same treatment as `ulab`. - `MP3Decoder` ported to [`src/audiomp3/`](../src/audiomp3/) (merged shared-bindings + + `MP3Decoder` ported to [`src/audiomp3/`](https://github.com/PyDevices/audiodsp/tree/main/src/audiomp3) (merged shared-bindings + shared-module, matching every other tier); needed a `background_callback` compat stub (`cp_compat/background_callback.h`) -- CircuitPython's own unix coverage build (this port's oracle) already collapses that whole @@ -582,7 +582,7 @@ consumer there is no aggregator, so `micropython.cmake` carries the same ## Testing strategy (recap) - **Oracle diffing is the core:** every DSP tier lands with scripts under - [`tests/parity/`](../tests/parity/) that run unchanged on `bin/circuitpython` and the MP + [`tests/parity/`](https://github.com/PyDevices/audiodsp/tree/main/tests/parity) that run unchanged on `bin/circuitpython` and the MP build, rendering PCM to stdout/file; the runner asserts byte-equality (or bounded error where float-width differences make byte-exactness impossible — record any such case in `upstream-diff.md`). diff --git a/docs/reference/analysis-and-utilities.md b/docs/reference/analysis-and-utilities.md new file mode 100644 index 0000000..6c79fcf --- /dev/null +++ b/docs/reference/analysis-and-utilities.md @@ -0,0 +1,29 @@ +# Analysis and utilities + +What the modules that measure audio or do arithmetic on it take and return. +The guides cover the two analysers: [Meter levels](../guides/meter-levels.md) +and [Speech features](../guides/speech-features.md). + +## `audiometer` + +Generated from [`audiometer.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiometer/audiometer.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiometer +``` + +## `audiofeatures` + +Generated from [`audiofeatures.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiofeatures/audiofeatures.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiofeatures +``` + +## `audiomath` + +Generated from [`audiomath.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiomath/audiomath.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiomath +``` diff --git a/docs/reference/audiopump.md b/docs/reference/audiopump.md index df832ef..f183b74 100644 --- a/docs/reference/audiopump.md +++ b/docs/reference/audiopump.md @@ -108,3 +108,11 @@ platform driver, and `audiopump.driver()` tells you which one bound: 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). + +## Names and signatures + +Generated from [`audiopump.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiopump/audiopump.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiopump +``` diff --git a/docs/reference/filters-and-effects.md b/docs/reference/filters-and-effects.md index dad2731..06d9721 100644 --- a/docs/reference/filters-and-effects.md +++ b/docs/reference/filters-and-effects.md @@ -13,6 +13,7 @@ hasn't played yet, or has just been reset, takes its levels at once. ## `audiobiquad` +What the two kinds of node take, side by side: | | `Biquad` | `AllPass` | |---|---|---| @@ -23,189 +24,96 @@ hasn't played yet, or has just been reset, takes its levels at once. | 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. -A filter that hasn't played yet takes its new settings at once. - -`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. +Generated from [`audiobiquad.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiobiquad/audiobiquad.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiobiquad +``` ## `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). +Generated from [`audiodynamics.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiodynamics/audiodynamics.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiodynamics +``` ## `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` +Generated from [`audioecho.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audioecho/audioecho.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audioecho +``` -`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". +Generated from [`audioshaper.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audioshaper/audioshaper.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audioshaper +``` + ## `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 +Generated from [`audioladder.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audioladder/audioladder.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audioladder ``` -`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) +Generated from [`audioverb.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audioverb/audioverb.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audioverb +``` + +## `audioroute.MidSide` + +Generated from [`audioroute.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audioroute/audioroute.pyi); the file reads on its own. + +```{eval-rst} +.. autoclass:: audioroute.MidSide +``` + +## `audiofilters` + +Generated from [`audiofilters.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiofilters/audiofilters.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiofilters +``` + +## `audiodelays` + +Generated from [`audiodelays.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiodelays/audiodelays.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiodelays +``` + +## `audiofreeverb` + +Generated from [`audiofreeverb.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiofreeverb/audiofreeverb.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiofreeverb ``` -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. +## `audiospeed` + +Generated from [`audiospeed.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiospeed/audiospeed.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiospeed +``` + +## `audioconvolve` + +Generated from [`audioconvolve.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audioconvolve/audioconvolve.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audioconvolve +``` diff --git a/docs/reference/mixing-and-routing.md b/docs/reference/mixing-and-routing.md new file mode 100644 index 0000000..bcc8f52 --- /dev/null +++ b/docs/reference/mixing-and-routing.md @@ -0,0 +1,31 @@ +# Mixing and routing + +What the modules that combine, split and steer streams take and return. +`audiomixer.Mixer` plays several sources at once; `audioroute` fans a stream +out, re-points a wire and scales stereo width. To change what plays without rewiring, see +[Change what plays without rewiring](../guides/swap-what-plays.md). + +## `audiomixer` + +Generated from [`audiomixer.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiomixer/audiomixer.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiomixer +``` + +### Mixer voices + +`Mixer.voice` returns these. The module has no public name for the type, so it is listed here under the private name the stub gives it. + +```{eval-rst} +.. autoclass:: audiomixer._MixerVoice +``` + +## `audioroute` + +Generated from [`audioroute.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audioroute/audioroute.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audioroute + :exclude-members: MidSide +``` diff --git a/docs/reference/modules.md b/docs/reference/modules.md index ce27f7d..1129e7c 100644 --- a/docs/reference/modules.md +++ b/docs/reference/modules.md @@ -33,7 +33,7 @@ It adds modules CircuitPython doesn't have. Each has a page here or a guide: Two pure-Python modules sit beside the CircuitPython-compatible core: -- **[`lib/audiodsp_util/`](../../lib/audiodsp_util/)** — `float32(value)` and `float32_bits(value)`, +- **[`lib/audiodsp_util/`](https://github.com/PyDevices/audiodsp/tree/main/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 @@ -41,7 +41,7 @@ Two pure-Python modules sit beside the CircuitPython-compatible core: 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/`](../../lib/audiorender/)** — renders a whole composition offline: tracks, +- **[`lib/audiorender/`](https://github.com/PyDevices/audiodsp/tree/main/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 diff --git a/docs/reference/sources-and-synthesis.md b/docs/reference/sources-and-synthesis.md index b0166a4..b6b785e 100644 --- a/docs/reference/sources-and-synthesis.md +++ b/docs/reference/sources-and-synthesis.md @@ -1,89 +1,61 @@ # 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 +What the added sound sources take and return. `audiosampler` also has a +[guide](../guides/play-recorded-notes.md), and `audiovorbis` has [its own page](vorbis.md); the reasons behind each module are in [Additions beyond CircuitPython](../how-it-works/additions.md). -## `audiooscillator` +## `audiocore` + +Generated from [`audiocore.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiocore/audiocore.pyi); the file reads on its own. -`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 +```{eval-rst} +.. automodule:: audiocore ``` -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. +## `audiomp3` + +Generated from [`audiomp3.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiomp3/audiomp3.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiomp3 +``` -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. +## `synthio` + +Generated from [`synthio.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/synthio/synthio.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: synthio +``` + +## `audiooscillator` + +Generated from [`audiooscillator.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiooscillator/audiooscillator.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiooscillator +``` ## `audiomodal` +Generated from [`audiomodal.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiomodal/audiomodal.pyi); the file reads on its own. -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. +```{eval-rst} +.. automodule:: audiomodal +``` + +## `audiosampler` -```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) +Generated from [`audiosampler.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiosampler/audiosampler.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiosampler ``` -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` +Generated from [`audiograph.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiograph/audiograph.pyi); the file reads on its own. -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. +```{eval-rst} +.. automodule:: audiograph +``` diff --git a/docs/reference/vorbis.md b/docs/reference/vorbis.md index 1bccfd7..a4142d2 100644 --- a/docs/reference/vorbis.md +++ b/docs/reference/vorbis.md @@ -51,7 +51,7 @@ 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 +carried in [third_party/tremor](https://github.com/PyDevices/audiodsp/tree/main/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: @@ -149,3 +149,11 @@ C library that doesn't, so leave it the room above. - [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. + +## Names and signatures + +Generated from [`audiovorbis.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiovorbis/audiovorbis.pyi); the file reads on its own. + +```{eval-rst} +.. automodule:: audiovorbis +``` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..1cdafe0 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +# Local preview: use the checkout of .github next to this repository, +# pip install -e ../.github/docs-config +# Read the Docs and CI install the tagged release: +pydevices-docs @ git+https://github.com/PyDevices/.github@docs-v0.1.0#subdirectory=docs-config diff --git a/docs/upstream-reports/README.md b/docs/upstream-reports/README.md index 0ab52f2..e710a0d 100644 --- a/docs/upstream-reports/README.md +++ b/docs/upstream-reports/README.md @@ -1,7 +1,7 @@ # Drafts for adafruit/circuitpython Three contributions are **prepared and unfiled**, waiting on the maintainer. Each has a -directory under [prs/](prs/) holding a patch that applies to the `10.3.0` tag +directory under [prs/](https://github.com/PyDevices/audiodsp/tree/main/docs/upstream-reports/prs) holding a patch that applies to the `10.3.0` tag and to the `main` tip (`268a168d`, 2026-09-17 — the files involved are byte-identical between the two), a paste-ready title and body, the repro scripts, and the publish commands. diff --git a/docs/upstream-reports/biquad-band-edges.md b/docs/upstream-reports/biquad-band-edges.md index a4661cf..9006ce6 100644 --- a/docs/upstream-reports/biquad-band-edges.md +++ b/docs/upstream-reports/biquad-band-edges.md @@ -29,7 +29,7 @@ Verified present on `main` 2026-08-27 (`BIQUAD_SHIFT` is still 15, --- -### `synthio.Biquad` is inaccurate below ~300 Hz and above ~16 kHz +## `synthio.Biquad` is inaccurate below ~300 Hz and above ~16 kHz A biquad asked for a corner near either end of the audio band is not the filter that was asked for, and it fails silently — a `LOW_PASS` at 50 Hz @@ -39,7 +39,7 @@ nothing. There are **two independent causes** and neither can be fixed alone, which is why they are in one report. -#### Cause 1: Q15 coefficients in an int32 accumulator +### Cause 1: Q15 coefficients in an int32 accumulator `shared-module/synthio/Biquad.h`: @@ -88,7 +88,7 @@ back into it, so half an LSB of rounding is not half an LSB by the time it comes round again. This is what takes a 50 Hz low-pass from "wrong" to "silent", and what makes a 30 Hz high-pass *gain* rather than merely mis-sit. -#### Cause 2: `fast_sincos()` fits one polynomial to two functions +### Cause 2: `fast_sincos()` fits one polynomial to two functions ```c #define FOUR_OVER_PI (4 / M_PI) @@ -134,7 +134,7 @@ precision by default (`py/circuitpy_mpconfig.h`, there than in the double-precision figures above — float epsilon alone costs about 3.5 % at 100 Hz before the polynomial's error is counted. -#### What it measures +### What it measures One biquad, 48 kHz, Q 0.707 unless stated, on a build of `main`. A corner should read −3.01 dB and a passband 0.00: @@ -190,7 +190,7 @@ for exact replication and for instrument-grade fidelity below 100 Hz. It is worth reporting as a limitation with a fix and a cost, which is what this document is — not as an alarm. -#### Why both causes have to go together +### Why both causes have to go together Turning each cause off in the closed form, at 48 kHz: @@ -217,7 +217,7 @@ the build gives silence, and −4.71 at 30 Hz where the build gives +9.03. The difference is the whole-sample feedback state described above, which a transfer function cannot express.) -#### A fix, and what it costs +### A fix, and what it costs This is what we did in a downstream port of these files. Offering it as a starting point rather than a patch to take, because the cost below is real @@ -263,7 +263,7 @@ Step 3 on its own is nearly free on every core, and it is the entire fix above 16 kHz — so it is worth considering separately even if steps 1 and 2 are not wanted. -#### Repro +### Repro ```python # What a biquad does at the ends of the audio band. diff --git a/docs/upstream-reports/biquad-reset.md b/docs/upstream-reports/biquad-reset.md index dfe52df..496b1f6 100644 --- a/docs/upstream-reports/biquad-reset.md +++ b/docs/upstream-reports/biquad-reset.md @@ -16,7 +16,7 @@ practice as well as in diff. --- -### `synthio_biquad_filter_reset()` only clears half the filter state +## `synthio_biquad_filter_reset()` only clears half the filter state `shared-module/synthio/Biquad.h`: @@ -58,7 +58,7 @@ start something quiet, and the first few milliseconds carry a burst of the old sound. On a `synthio.Note`, a re-pressed note briefly inherits the last one's filter tail. -#### Fix +### Fix ```diff void synthio_biquad_filter_reset(biquad_filter_state *st) { @@ -67,7 +67,7 @@ filter tail. } ``` -#### Measured +### Measured 800 Hz low-pass, fed a 200 Hz tone at 30000 for four blocks, then `reset_buffer`, then pure silence. Peak of the first block of "silence": @@ -81,7 +81,7 @@ First eight samples before the fix: `28082, 28041, 27761, 26946, 25968, 24859, 23648, 22362` — a clean exponential decay, which is the filter ringing down from a state that should have been zeroed. -#### Repro +### Repro ```python # audiofilters.Filter.reset_buffer() should leave no audio behind. diff --git a/docs/upstream-reports/dds-oscillator-off-by-one.md b/docs/upstream-reports/dds-oscillator-off-by-one.md index 00fba52..e5930d6 100644 --- a/docs/upstream-reports/dds-oscillator-off-by-one.md +++ b/docs/upstream-reports/dds-oscillator-off-by-one.md @@ -20,7 +20,7 @@ Verified present on `main` 2026-08-27 (four sites in --- -### `synthio` oscillator wraps one sample late and reads past the end of the waveform +## `synthio` oscillator wraps one sample late and reads past the end of the waveform `shared-module/synthio/__init__.c`, in `synthio_synth_synthesize()`: @@ -60,7 +60,7 @@ one drum-machine sequence rendered differently under `PYTHONMALLOC=default`, `malloc` and `debug`, and changed again when unrelated objects were allocated first. -#### Fix +### Fix ```diff for (uint16_t i = 0; i < dur; i++) { @@ -93,7 +93,7 @@ That reduces into `[0, lim)` and *then* adds `offset`, so with a non-zero the loop on the very next sample. Reducing into `[offset, lim)` directly would be the matching change. -#### Repro +### Repro Deterministic, and stays inside the buffer, by making the loop end short of the buffer end: the loop is 256 silent samples and sample 256 is a marker the diff --git a/docs/upstream-reports/distortion-overdrive-drive.md b/docs/upstream-reports/distortion-overdrive-drive.md index ac88d2a..6245731 100644 --- a/docs/upstream-reports/distortion-overdrive-drive.md +++ b/docs/upstream-reports/distortion-overdrive-drive.md @@ -18,7 +18,7 @@ Verified present on `main` 2026-08-27. --- -### `audiofilters.Distortion`: `drive` has no effect in OVERDRIVE mode +## `audiofilters.Distortion`: `drive` has no effect in OVERDRIVE mode `shared-module/audiofilters/Distortion.c`: @@ -58,7 +58,7 @@ We took the first option downstream (as pre-gain, with the level put back so the historical default stays bit-identical), but a docs fix would have saved us the measurement, and either is fine from out here. -#### Repro +### Repro ```python import array, math diff --git a/docs/upstream-reports/distortion-soft-clip-union.md b/docs/upstream-reports/distortion-soft-clip-union.md index 76eab38..5628d0f 100644 --- a/docs/upstream-reports/distortion-soft-clip-union.md +++ b/docs/upstream-reports/distortion-soft-clip-union.md @@ -23,7 +23,7 @@ Verified present on `main` 2026-08-27. --- -### `audiofilters.Distortion(soft_clip=False)` enables soft clipping +## `audiofilters.Distortion(soft_clip=False)` enables soft clipping `shared-bindings/audiofilters/Distortion.c`. The argument is declared as a bool: @@ -85,7 +85,7 @@ in one script on one binary I get `True` and in another, on the same binary, I get `False`. It also varies by target — this is how we originally found it, building for wasm32 where even the *default* read as `True`. -#### Fix +### Fix ```diff mode, @@ -94,7 +94,7 @@ building for wasm32 where even the *default* read as `True`. args[ARG_mix].u_obj, ``` -#### Repro +### Repro ```python # soft_clip is declared MP_ARG_BOOL and read back through .u_obj, so what you diff --git a/docs/upstream-reports/peaking-eq-sign.md b/docs/upstream-reports/peaking-eq-sign.md index 04a9d22..574b37e 100644 --- a/docs/upstream-reports/peaking-eq-sign.md +++ b/docs/upstream-reports/peaking-eq-sign.md @@ -14,7 +14,7 @@ the repro also runs on a board with the `get_buffer` loop replaced by an --- -### `synthio.Biquad` PEAKING_EQ has the wrong sign on `b2` +## `synthio.Biquad` PEAKING_EQ has the wrong sign on `b2` `shared-module/synthio/Biquad.c`, in `common_hal_synthio_biquad_tick()`: @@ -60,7 +60,7 @@ the one mode a synthesis library rarely reaches for. It reaches `synthio.Note.filter` as well as `audiofilters.Filter` — the coefficients are shared. -#### Fix +### Fix ```diff case SYNTHIO_PEAKING_EQ: @@ -74,7 +74,7 @@ coefficients are shared. break; ``` -#### Measured +### Measured On a build of `main`, a +6 dB bell at 1 kHz / Q 1 / 48 kHz: @@ -88,7 +88,7 @@ and a −6 dB cut reads −6.00 dB at centre after the fix. The +21.39 agrees wi the closed form `1 + alpha*A/(1 - cos W0)` = +21.40 dB to within the Q15 coefficient rounding. -#### Repro +### Repro ```python # A peaking bell should be unity at DC and at Nyquist, and hit its own gain diff --git a/docs/upstream-reports/speedchanger-phase-carry.md b/docs/upstream-reports/speedchanger-phase-carry.md index 4dd0df3..32a1ed4 100644 --- a/docs/upstream-reports/speedchanger-phase-carry.md +++ b/docs/upstream-reports/speedchanger-phase-carry.md @@ -18,7 +18,7 @@ output stops depending on the source's block size at all. --- -### `audiospeed_fetch_source_buffer` resets the phase, so a `SpeedChanger` drifts and only renders correctly when the rate divides the buffer length +## `audiospeed_fetch_source_buffer` resets the phase, so a `SpeedChanger` drifts and only renders correctly when the rate divides the buffer length `shared-module/audiospeed/__init__.c`: @@ -54,7 +54,7 @@ rate divides the buffer length exactly. At `rate` 1.8433 with a 256-frame upstream, 0.2 of a frame is dropped every 139 output frames; at `rate` 6 with the same upstream, four whole frames are dropped every 43. -#### What it sounds like +### What it sounds like A `SpeedChanger` pair used as a sample-and-hold — decimate at N, restore at 1/N, which is how a lo-fi rate reducer is built out of this module — is the clearest @@ -82,7 +82,7 @@ miss: at N = 4 over 256-frame buffers, and at the 2.0 / 1.0 / 0.5 a `Resampler` is usually bound to, the accumulator lands on the boundary every time and the output is correct. -#### Fix +### Fix Subtract what the previous buffer held instead of zeroing, and let the caller ask the accumulator where it is rather than assuming frame 0: @@ -145,7 +145,7 @@ the guard is worth having either way. `audiospeed_reset_buffer` should keep zeroing the phase: it resets the source as well, so the stream genuinely restarts there. -#### Repro +### Repro ```python # What a SpeedChanger renders should not depend on the block size of the node diff --git a/docs/upstream-reports/speedchanger-rate-rounding.md b/docs/upstream-reports/speedchanger-rate-rounding.md index 458072f..20006bf 100644 --- a/docs/upstream-reports/speedchanger-rate-rounding.md +++ b/docs/upstream-reports/speedchanger-rate-rounding.md @@ -17,7 +17,7 @@ was captured. --- -### `audiospeed_rate_to_fp` truncates to 16.16, so a rate just under a step loses a whole LSB +## `audiospeed_rate_to_fp` truncates to 16.16, so a rate just under a step loses a whole LSB `shared-module/audiospeed/__init__.c`: @@ -51,7 +51,7 @@ static void calculate_rate(audiospeed_base_t *self, uint32_t sample_rate) { so a `Resampler` binding 48000 into 44100 stores 71331 where 71331.918… wants 71332. -#### Why it is worth a character +### Why it is worth a character A caller that computes a rate rather than typing one is the common case — `fs / some_hz`, an interval as `2 ** (semitones / 12)`, or the reciprocal of a @@ -67,7 +67,7 @@ away from what went in rather than 0. At 48 kHz and 22.05 kHz the same knob happens to land exactly on 1.0, so the same code is an identity there — which is what makes it look like a sample-rate bug rather than a rounding one. -#### Fix +### Fix ```diff uint32_t audiospeed_rate_to_fp(mp_obj_t rate_obj) { @@ -88,7 +88,7 @@ and, in `Resampler.c`: needs no sign test. The range check is unaffected: 1000.0 rounds to 65536000, which is what it truncated to as well. -#### Repro +### Repro ```python # audiospeed.SpeedChanger.rate should come back as close to what was asked diff --git a/src/audiograph/audiograph.pyi b/src/audiograph/audiograph.pyi index f7ec817..641478a 100644 --- a/src/audiograph/audiograph.pyi +++ b/src/audiograph/audiograph.pyi @@ -13,26 +13,26 @@ what lets a six-voice polysynth play in real time on an ESP32-P4. It is the same C on CPython, MicroPython and CircuitPython, so a patch built and listened to on the desktop renders the same samples on the board. -```python -import audiograph as ag - -g = ag.Graph(sample_rate=48000, frames=128, channel_count=2) -pitch, gate, env, saw, vcf, out = (g.buffer() for _ in range(6)) - -g.add(ag.CONST, 0, (), pitch) # the key, in octaves from C4 -key = g.add(ag.CONST, 0, (), gate) -g.add(ag.ENV, 0, (gate,), env) -osc = g.add(ag.OSC, 0, (pitch, None, None, None), saw) -g.set(osc, ag.OSC_WAVEFORM, 1) # a sawtooth -lad = g.add(ag.LADDER, 0, (saw, env), vcf) # the envelope sweeps the cutoff -g.set(lad, ag.LADDER_CUTOFF_HZ, 600.0) -g.add(ag.VCA, 0, (vcf, env, None), out) -g.add(ag.OUT, ag.ALWAYS, (out, out), None) - -pcm = bytearray(128 * 2 * 2) -g.set(key, 0, ag.UNIT) # key down -sounding = g.run(pcm, 0) # one block into pcm -``` +Example:: + + import audiograph as ag + + g = ag.Graph(sample_rate=48000, frames=128, channel_count=2) + pitch, gate, env, saw, vcf, out = (g.buffer() for _ in range(6)) + + g.add(ag.CONST, 0, (), pitch) # the key, in octaves from C4 + key = g.add(ag.CONST, 0, (), gate) + g.add(ag.ENV, 0, (gate,), env) + osc = g.add(ag.OSC, 0, (pitch, None, None, None), saw) + g.set(osc, ag.OSC_WAVEFORM, 1) # a sawtooth + lad = g.add(ag.LADDER, 0, (saw, env), vcf) # the envelope sweeps the cutoff + g.set(lad, ag.LADDER_CUTOFF_HZ, 600.0) + g.add(ag.VCA, 0, (vcf, env, None), out) + g.add(ag.OUT, ag.ALWAYS, (out, out), None) + + pcm = bytearray(128 * 2 * 2) + g.set(key, 0, ag.UNIT) # key down + sounding = g.run(pcm, 0) # one block into pcm A graph owns ``buffers`` blocks of ``frames`` signed 16-bit samples and a table of up to ``nodes`` nodes. Every node reads some buffers, writes one, diff --git a/src/audioroute/audioroute.pyi b/src/audioroute/audioroute.pyi index 37e5ea3..65887e0 100644 --- a/src/audioroute/audioroute.pyi +++ b/src/audioroute/audioroute.pyi @@ -9,7 +9,7 @@ that each play the same audio, so a stream can feed parallel branches. is a wire whose source can be changed while it plays. All of them are audio samples (a `Splitter` itself is not; its taps are), so -they play wherever a `audiocore.RawSample` does. +they play wherever an `audiocore.RawSample` does. """ from typing import Protocol, Self @@ -88,7 +88,12 @@ class MidSide: At a width of 1.0 the output is the input exactly, so the node can stay in a chain that does not use it. A mono node copies its input, since a mono - signal has no difference to scale. + signal has no difference to scale. The node keeps no state and adds no + latency. + + It also suits the drive classes. 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. Args: source: The stream to process, or None to set it later with `play`. diff --git a/src/audioverb/audioverb.pyi b/src/audioverb/audioverb.pyi index af5f83b..9e597c4 100644 --- a/src/audioverb/audioverb.pyi +++ b/src/audioverb/audioverb.pyi @@ -36,9 +36,11 @@ class _AudioSample(Protocol): class Tank: """A plate reverberator, played like any audio sample. - Construction fixes the allocation and the topology: ``sample_rate``, - ``channel_count``, ``max_predelay_ms``, ``delays`` and ``taps``. `set` - moves the rest, while the node plays and without emptying the lines. + Construction fixes the allocation: ``sample_rate``, ``channel_count`` and + ``max_predelay_ms``. It also takes the network, ``delays`` and ``taps``. + `set` moves every other option while the node plays and without emptying + the lines; giving it ``delays`` or ``taps`` cuts a new network, which + does empty them. Every filter is out of the path at zero, so a ``Tank()`` given nothing else is that network with nothing added to it.