From 81538bfe7f708d2fa799baaab526e92ac8c48dd4 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:47:19 -0600 Subject: [PATCH 1/3] stubs: type stubs for the twelve modules that lacked one --- ROADMAP.md | 3 - src/audiobiquad/audiobiquad.pyi | 234 +++++++++ src/audiodelays/audiodelays.pyi | 540 +++++++++++++++++++++ src/audiodynamics/audiodynamics.pyi | 280 +++++++++++ src/audioecho/audioecho.pyi | 185 +++++++ src/audiofilters/audiofilters.pyi | 250 ++++++++++ src/audiofreeverb/audiofreeverb.pyi | 102 ++++ src/audiograph/audiograph.pyi | 722 ++++++++++++++++++++++++++++ src/audioladder/audioladder.pyi | 173 +++++++ src/audiomixer/audiomixer.pyi | 153 ++++++ src/audioshaper/audioshaper.pyi | 228 +++++++++ src/audiospeed/audiospeed.pyi | 91 ++++ src/audioverb/audioverb.pyi | 191 ++++++++ tests/test_stubs_match_modules.py | 5 +- 14 files changed, 3153 insertions(+), 4 deletions(-) create mode 100644 src/audiobiquad/audiobiquad.pyi create mode 100644 src/audiodelays/audiodelays.pyi create mode 100644 src/audiodynamics/audiodynamics.pyi create mode 100644 src/audioecho/audioecho.pyi create mode 100644 src/audiofilters/audiofilters.pyi create mode 100644 src/audiofreeverb/audiofreeverb.pyi create mode 100644 src/audiograph/audiograph.pyi create mode 100644 src/audioladder/audioladder.pyi create mode 100644 src/audiomixer/audiomixer.pyi create mode 100644 src/audioshaper/audioshaper.pyi create mode 100644 src/audiospeed/audiospeed.pyi create mode 100644 src/audioverb/audioverb.pyi diff --git a/ROADMAP.md b/ROADMAP.md index b742186b..6a1e9609 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,9 +18,6 @@ audio pump holding up under live use such as a stompbox or a stage rig. ## Housekeeping -- `.pyi` stubs for the modules that lack one (`audiobiquad`, `audiodelays`, - `audiodynamics`, `audioecho`, `audiofilters`, `audiofreeverb`, `audiograph`, - `audioladder`, `audiomixer`, `audioshaper`, `audiospeed`, `audioverb`). - The CircuitPython glue's folder takes a lasting name (`src/circuitpython_spike/` becomes `src/circuitpython/`). - `audiocore`, `audiomp3`, `audiopump` and `synthio` report a `__revision__` diff --git a/src/audiobiquad/audiobiquad.pyi b/src/audiobiquad/audiobiquad.pyi new file mode 100644 index 00000000..13885b94 --- /dev/null +++ b/src/audiobiquad/audiobiquad.pyi @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Biquad and all-pass filters whose tails reach exact zero. + +`Biquad` is one filter section in any of seven modes, and `AllPass` is a +cascade of first-order all-pass stages, which is what a phaser is made of. +Both keep their state in floating point, so a ringing tail decays to exactly +zero instead of sitting at one least-significant bit. `Q` is bounded to +0.05 to 60 and `feedback` to plus or minus 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. + +This module is named apart from CircuitPython's `audiofilters` on purpose. It +is not an extension of `audiofilters.Filter` or `audiofilters.Phaser` but a +second set of kernels, so the ported modules stay the same as the ones on a +stock board. For a task-shaped introduction, see "Filter a sound". + +Both nodes hand out 256 frames at a time (or fewer, with ``buffer_size``), sit +in an audio chain like any other effect, add no latency, and never report +themselves finished: a chain with nothing left to read gets silence. + +A level moved on a playing node walks to its new value in a straight line +over 20 ms instead of stepping, so turning ``mix`` in one call does not click. +A node that has not played yet, or has just been reset, takes its levels at +once. +""" + +from typing import Protocol, Self, Union + +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + +LOW_PASS: int +"""A `Biquad` mode: pass what is below ``frequency``. This is 0.""" +HIGH_PASS: int +"""A `Biquad` mode: pass what is above ``frequency``. This is 1.""" +BAND_PASS: int +"""A `Biquad` mode: pass a band around ``frequency``. This is 2.""" +NOTCH: int +"""A `Biquad` mode: remove a band around ``frequency``. This is 3.""" +PEAKING_EQ: int +"""A `Biquad` mode: boost or cut a band around ``frequency`` by ``gain_db``. +This is 4.""" +LOW_SHELF: int +"""A `Biquad` mode: boost or cut everything below ``frequency`` by +``gain_db``. This is 5.""" +HIGH_SHELF: int +"""A `Biquad` mode: boost or cut everything above ``frequency`` by +``gain_db``. This is 6.""" +MODES: tuple[int, int, int, int, int, int, int] +"""The seven mode numbers, `LOW_PASS` to `HIGH_SHELF`, in order. They are +numbered exactly as ``synthio.FilterMode`` numbers its modes, so either can be +handed to either.""" +FRAMES: int +"""Frames in one block when ``buffer_size`` is not given: 256.""" +MAX_STAGES: int +"""The most stages an `AllPass` can have: 16.""" + +# A number, or a synthio.LFO or synthio.Math read once a block. +_BlockInput = Union[float, object, None] + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class Biquad: + """One filter section. + + Every argument is a keyword. ``frequency``, ``Q``, ``gain_db`` and ``mix`` + are block inputs: a number, or a ``synthio.LFO`` or ``synthio.Math`` that + is read once a block, so an LFO on ``frequency`` arrives about a block + late. + + When ``frequency``, ``Q``, ``gain_db`` or ``mode`` changes while the + filter 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 does + not thump. A filter that has not played yet takes its new settings at once. + + Args: + mode: One of `LOW_PASS`, `HIGH_PASS`, `BAND_PASS`, `NOTCH`, + `PEAKING_EQ`, `LOW_SHELF` and `HIGH_SHELF`. The default is + `LOW_PASS`. + frequency: The corner or centre in Hz. The default is 1000. + Q: The resonance, bounded to 0.05 to 60. The default, None, is 0.7071 + (Butterworth), and reads back as that number. + gain_db: The boost or cut in dB, for `PEAKING_EQ`, `LOW_SHELF` and + `HIGH_SHELF`. The default is 0. + mix: A crossfade: 0 is a wire, 1 is the filtered signal alone. The + default is 1. + sample_rate: Frames per second. The default is 48000. + channel_count: 1 or 2. The default is 2. + buffer_size: Bytes in each block, a whole number of frames from one to + 256. The default, None, is 256 frames. + + Raises: + ValueError: ``mode`` is not one of the seven, ``channel_count`` is not + 1 or 2, ``sample_rate`` is below 1, or ``buffer_size`` is not whole + frames from one to 256. + """ + + def __init__( + self, + *, + mode: int = 0, + frequency: _BlockInput = 1000, + Q: _BlockInput = None, + gain_db: _BlockInput = 0, + mix: _BlockInput = 1, + sample_rate: int = 48000, + channel_count: int = 2, + buffer_size: int | None = None, + ) -> None: ... + mode: int + """The filter's mode. Setting it raises `ValueError` for a number that is + not one of the seven.""" + frequency: _BlockInput + """The corner or centre in Hz.""" + Q: _BlockInput + """The resonance, bounded to 0.05 to 60.""" + gain_db: _BlockInput + """The boost or cut in dB.""" + mix: _BlockInput + """The crossfade from the dry signal (0) to the filtered one (1).""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """Whether a source is set with `play`.""" + @property + def coefficients(self) -> tuple[float, float, float, float, float]: + """The filter's ``(b0, b1, b2, a1, a2)``, normalised, at the settings + in force. Reading it does not advance an LFO on a block input.""" + def play(self, source: _AudioSample, /) -> None: + """Set the stream to filter, replacing the last one.""" + def stop(self) -> None: + """Forget the source. The node then hands out silence.""" + def clear(self) -> None: + """Empty the filter's memory, which stops a ringing tail.""" + def deinit(self) -> None: + """Release the node. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class AllPass: + """A cascade of first-order all-pass stages. + + ``frequency`` really is the frequency at which one stage's phase passes + minus 90 degrees, so nothing has to be pre-warped. With ``mix`` at 0.5 the + cascade sums with the dry signal into the notches a script phaser makes, + and that is where they are deepest. + + Every argument is a keyword. ``frequency``, ``feedback`` and ``mix`` are + block inputs, as for `Biquad`. ``stages`` sizes the node's state, so it can + only be given here. + + Args: + stages: How many stages, 1 to `MAX_STAGES`. The default is 4. + frequency: The frequency in Hz at which one stage reaches minus 90 + degrees. The default is 1000. + feedback: Output fed back to the input, bounded to plus or minus 0.99. + The default is 0. + mix: A crossfade: 0 is a wire, 1 is the all-pass cascade alone. The + default is 1. + sample_rate: Frames per second. The default is 48000. + channel_count: 1 or 2. The default is 2. + buffer_size: Bytes in each block, as for `Biquad`. + + Raises: + ValueError: ``stages`` is out of range, ``channel_count`` is not 1 or + 2, ``sample_rate`` is below 1, or ``buffer_size`` is not whole + frames from one to 256. + """ + + def __init__( + self, + *, + stages: int = 4, + frequency: _BlockInput = 1000, + feedback: _BlockInput = 0, + mix: _BlockInput = 1, + sample_rate: int = 48000, + channel_count: int = 2, + buffer_size: int | None = None, + ) -> None: ... + frequency: _BlockInput + """The frequency in Hz at which one stage's phase passes minus 90 degrees.""" + feedback: _BlockInput + """Output fed back to the input, bounded to plus or minus 0.99.""" + mix: _BlockInput + """The crossfade from the dry signal (0) to the cascade (1).""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """Whether a source is set with `play`.""" + @property + def stages(self) -> int: + """How many stages the cascade has.""" + @property + def coefficient(self) -> float: + """The coefficient of each stage at the settings in force. Reading it + does not advance an LFO on a block input.""" + def play(self, source: _AudioSample, /) -> None: + """Set the stream to filter, replacing the last one.""" + def stop(self) -> None: + """Forget the source. The node then hands out silence.""" + def clear(self) -> None: + """Empty the filter's memory, which stops a ringing tail.""" + def deinit(self) -> None: + """Release the node. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audiodelays/audiodelays.pyi b/src/audiodelays/audiodelays.pyi new file mode 100644 index 00000000..fcdae26c --- /dev/null +++ b/src/audiodelays/audiodelays.pyi @@ -0,0 +1,540 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Delay-based effects: echo, chorus, flanger, pitch shifters and a multi-tap delay. + +These are CircuitPython's `audiodelays` effects, with the same names and +arguments, so code written for a CircuitPython board runs unchanged. Each one +is an audio source that reads another audio source: give it a source with +`play`, hand the effect to an output or a mixer, and it processes the audio +a block at a time. + +The effects take a ``buffer_size`` in bytes for each block, the sample format +(``sample_rate``, ``bits_per_sample``, ``samples_signed``, ``channel_count``) +the source has to match, and then their own options. Every argument is a +keyword. Options that can change while the effect plays are block inputs: a +number, or a ``synthio.LFO`` or ``synthio.Math`` read once a block. A level +moved on a playing node walks to its new value over 20 ms instead of +stepping, so turning a knob does not click. + +`audioecho.FeedbackDelay` is a separate delay with a filter, a soft-clip and a +cross-feed in its feedback loop; reach for it when `Echo` is not enough. +""" + +from typing import Iterable, Protocol, Self, Union + +# A number, or a synthio.LFO or synthio.Math read once a block. +_BlockInput = Union[float, object, None] + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class Echo: + """A single-tap echo with feedback. + + The delay line repeats the source, each repeat ``decay`` times as loud as the + one before. ``filter`` is applied to the signal inside the feedback loop, + so each repeat is filtered again. With ``freq_shift`` on, a change to + ``delay_ms`` while playing shifts the pitch of the repeats, as moving a tape + head does; with it off the repeats do not shift. + + Args: + max_delay_ms: The longest delay the effect can be set to, 1 to 4000. + It sizes the delay line, so it can only be given here. The default + is 500. + delay_ms: The delay in milliseconds, up to ``max_delay_ms``. The + default is 250. + decay: How much of each repeat feeds back, 0.0 to 1.0. The default is + 0.7. + filter: A filter, or a sequence of them, applied inside the feedback + loop, or None for none. + mix: The balance of the effect, 0.0 for the dry signal only, 1.0 for + the echo only. The default is 0.25. + freq_shift: Whether a change to ``delay_ms`` shifts the pitch of the + repeats. The default is True. + buffer_size: How many bytes the effect hands out in each block. The + default is 512. + sample_rate: Frames per second, 1 or more. The default is 8000. + bits_per_sample: 8 or 16. The default is 16. + samples_signed: Whether samples are signed. The default is True. + channel_count: 1 or 2. The default is 1. + + Raises: + ValueError: ``max_delay_ms`` is not 1 to 4000. + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is below + 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + max_delay_ms: int = 500, + delay_ms: _BlockInput = ..., + decay: _BlockInput = ..., + filter: object | None = None, + mix: _BlockInput = ..., + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + freq_shift: bool = True, + ) -> None: ... + delay_ms: _BlockInput + """The delay in milliseconds.""" + decay: _BlockInput + """How much of each repeat feeds back, 0.0 to 1.0.""" + filter: object | None + """The filter, or sequence of filters, in the feedback loop.""" + mix: _BlockInput + """The balance from the dry signal (0.0) to the echo (1.0).""" + freq_shift: bool + """Whether a change to ``delay_ms`` shifts the repeats' pitch.""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: 8 or 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """Whether a source is playing.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> Self: + """Start reading a source. + + Args: + sample: The audio to process. Its sample format has to match the + effect's. + loop: Whether to start the source again when it ends. + + Returns: + The effect itself. + """ + def stop(self) -> None: + """Stop reading the source. The tail is not played out.""" + def deinit(self) -> None: + """Release the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class Chorus: + """A chorus: the source mixed with slightly detuned, delayed copies of itself. + + ``voices`` sets how many copies, and may be fractional, so a chorus can + fade from one voice to two. + + Args: + max_delay_ms: The longest delay the effect can be set to, 1 to 4000. + It sizes the delay line, so it can only be given here. The default + is 50. + delay_ms: The centre delay of the copies in milliseconds. The default + is 50. + voices: How many copies are mixed in. The default is 1.0. + mix: The balance from the dry signal (0.0) to the copies only (1.0). + The default is 0.5. + buffer_size: How many bytes the effect hands out in each block. The + default is 512. + sample_rate: Frames per second, 1 or more. The default is 8000. + bits_per_sample: 8 or 16. The default is 16. + samples_signed: Whether samples are signed. The default is True. + channel_count: 1 or 2. The default is 1. + + Raises: + ValueError: ``max_delay_ms`` is not 1 to 4000. + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is below + 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + max_delay_ms: int = 50, + delay_ms: _BlockInput = ..., + voices: _BlockInput = ..., + mix: _BlockInput = ..., + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + delay_ms: _BlockInput + """The centre delay of the copies in milliseconds.""" + voices: _BlockInput + """How many copies are mixed in.""" + mix: _BlockInput + """The balance from the dry signal (0.0) to the copies (1.0).""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: 8 or 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """Whether a source is playing.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> Self: + """Start reading a source. + + Args: + sample: The audio to process. Its sample format has to match the + effect's. + loop: Whether to start the source again when it ends. + + Returns: + The effect itself. + """ + def stop(self) -> None: + """Stop reading the source. The tail is not played out.""" + def deinit(self) -> None: + """Release the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class Flanger: + """A flanger: a short, swept delay mixed with the source, with feedback. + + A low-frequency oscillator sweeps the delay from ``min_delay_ms`` up + towards ``max_delay_ms``, by as much of the span as ``depth`` says. + + Args: + max_delay_ms: The longest delay the sweep can reach, 1 to 100. It + sizes the delay line, so it can only be given here. The default + is 10. + min_delay_ms: The shortest delay of the sweep in milliseconds. The + default is 1.0. + rate: How fast the sweep runs, in Hz. The default is 0.5. + depth: How much of the span between the shortest and longest delay the + sweep covers. The default is 0.5. + feedback: How much of the delayed signal feeds back. The default is + 0.5. + mix: The balance from the dry signal (0.0) to the delayed one (1.0). + The default is 0.5. + invert: Whether the delayed signal is inverted, which moves the notches + from the harmonics to between them. The default is False. + buffer_size: How many bytes the effect hands out in each block. The + default is 512. + sample_rate: Frames per second, 1 or more. The default is 8000. + bits_per_sample: 8 or 16. The default is 16. + samples_signed: Whether samples are signed. The default is True. + channel_count: 1 or 2. The default is 1. + + Raises: + ValueError: ``max_delay_ms`` is not 1 to 100. + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is below + 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + max_delay_ms: int = 10, + min_delay_ms: _BlockInput = ..., + rate: _BlockInput = ..., + depth: _BlockInput = ..., + feedback: _BlockInput = ..., + mix: _BlockInput = ..., + invert: bool = False, + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + min_delay_ms: _BlockInput + """The shortest delay of the sweep in milliseconds.""" + rate: _BlockInput + """How fast the sweep runs, in Hz.""" + depth: _BlockInput + """How much of the delay span the sweep covers.""" + feedback: _BlockInput + """How much of the delayed signal feeds back.""" + mix: _BlockInput + """The balance from the dry signal (0.0) to the delayed one (1.0).""" + invert: bool + """Whether the delayed signal is inverted.""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: 8 or 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def lfo_value(self) -> float: + """Where the sweep is now, as the oscillator's triangle from 0.0 to 1.0.""" + @property + def playing(self) -> bool: + """Whether a source is playing.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> Self: + """Start reading a source. + + Args: + sample: The audio to process. Its sample format has to match the + effect's. + loop: Whether to start the source again when it ends. + + Returns: + The effect itself. + """ + def stop(self) -> None: + """Stop reading the source. The tail is not played out.""" + def deinit(self) -> None: + """Release the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class GranularPitchShift: + """A pitch shifter that plays overlapping grains of the source. + + Grains are short slices of the recent input, replayed at a different + speed. It keeps the tempo and shifts the pitch. + + Args: + semitones: How far to shift the pitch, in semitones. Negative is + down. The default is 0. + mix: The balance from the dry signal (0.0) to the shifted one (1.0). + The default is 1. + grain_size: How many frames in a grain, 2 or more. The default is + 1024. + density: How many grains play at once, 1 to 8. The default is 2. + spread: How much each grain's start is randomised, 0.0 to 1.0. The + default is 0. + buffer_size: How many bytes the effect hands out in each block. The + default is 512. + sample_rate: Frames per second, 1 or more. The default is 8000. + bits_per_sample: 8 or 16. The default is 16. + samples_signed: Whether samples are signed. The default is True. + channel_count: 1 or 2. The default is 1. + + Raises: + ValueError: ``grain_size`` is below 2 or ``density`` is not 1 to 8. + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is below + 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + semitones: _BlockInput = 0, + mix: _BlockInput = 1, + grain_size: int = 1024, + density: int = 2, + spread: _BlockInput = 0, + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + semitones: _BlockInput + """How far to shift the pitch, in semitones.""" + mix: _BlockInput + """The balance from the dry signal (0.0) to the shifted one (1.0).""" + spread: _BlockInput + """How much each grain's start is randomised.""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: 8 or 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """Whether a source is playing.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> Self: + """Start reading a source. + + Args: + sample: The audio to process. Its sample format has to match the + effect's. + loop: Whether to start the source again when it ends. + + Returns: + The effect itself. + """ + def stop(self) -> None: + """Stop reading the source. The tail is not played out.""" + def deinit(self) -> None: + """Release the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class PitchShift: + """A pitch shifter that reads the source through an overlapping window. + + Where `GranularPitchShift` plays many grains, this plays one window at a + time, which costs less. ``freeze`` holds the window so a note sustains. + + Args: + semitones: How far to shift the pitch, in semitones. Negative is + down. The default is 0. + mix: The balance from the dry signal (0.0) to the shifted one (1.0). + The default is 1. + window: The size of the window in bytes, at least one frame. The + default is 1024. + overlap: How many bytes the windows overlap by. The default is 128. + buffer_size: How many bytes the effect hands out in each block. The + default is 512. + sample_rate: Frames per second, 1 or more. The default is 8000. + bits_per_sample: 8 or 16. The default is 16. + samples_signed: Whether samples are signed. The default is True. + channel_count: 1 or 2. The default is 1. + + Raises: + ValueError: ``window`` is shorter than one frame. + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is below + 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + semitones: _BlockInput = 0, + mix: _BlockInput = 1, + window: int = 1024, + overlap: int = 128, + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + semitones: _BlockInput + """How far to shift the pitch, in semitones.""" + mix: _BlockInput + """The balance from the dry signal (0.0) to the shifted one (1.0).""" + freeze: bool + """Whether the window is held, so the sound sustains.""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: 8 or 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """Whether a source is playing.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> Self: + """Start reading a source. + + Args: + sample: The audio to process. Its sample format has to match the + effect's. + loop: Whether to start the source again when it ends. + + Returns: + The effect itself. + """ + def stop(self) -> None: + """Stop reading the source. The tail is not played out.""" + def deinit(self) -> None: + """Release the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class MultiTapDelay: + """A delay with several taps, each at its own position and level. + + A tap is read from the delay line at a fraction of ``delay_ms``, and its + level scales what is heard there. + + Args: + max_delay_ms: The longest delay the effect can be set to, 1 to 4000. + It sizes the delay line, so it can only be given here. The default + is 500. + delay_ms: The delay in milliseconds, up to ``max_delay_ms``. The + default is 250. + decay: How much of the output feeds back, 0.0 to 1.0. The default is + 0.7. + mix: The balance from the dry signal (0.0) to the taps (1.0). The + default is 0.25. + taps: The taps: a sequence of positions, or of ``(position, level)`` + pairs, each from 0.0 to 1.0, where a position is a fraction of + ``delay_ms``. A bare position has a level of 1. None, the default, + is no taps. + buffer_size: How many bytes the effect hands out in each block. The + default is 512. + sample_rate: Frames per second, 1 or more. The default is 8000. + bits_per_sample: 8 or 16. The default is 16. + samples_signed: Whether samples are signed. The default is True. + channel_count: 1 or 2. The default is 1. + + Raises: + ValueError: ``max_delay_ms`` is not 1 to 4000, ``taps`` is empty, or a + pair is not two values. + TypeError: ``taps`` is not iterable. + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is below + 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + max_delay_ms: int = 500, + delay_ms: _BlockInput = 250, + decay: _BlockInput = ..., + mix: _BlockInput = ..., + taps: Iterable[float | tuple[float, float]] | None = None, + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + delay_ms: _BlockInput + """The delay in milliseconds.""" + decay: _BlockInput + """How much of the output feeds back, 0.0 to 1.0.""" + mix: _BlockInput + """The balance from the dry signal (0.0) to the taps (1.0).""" + taps: Iterable[float | tuple[float, float]] | None + """The taps. Reading gives ``(position, level)`` pairs, or None for no taps; any iterable of positions or pairs can be set.""" + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: 8 or 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """Whether a source is playing.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> Self: + """Start reading a source. + + Args: + sample: The audio to process. Its sample format has to match the + effect's. + loop: Whether to start the source again when it ends. + + Returns: + The effect itself. + """ + def stop(self) -> None: + """Stop reading the source. The tail is not played out.""" + def deinit(self) -> None: + """Release the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audiodynamics/audiodynamics.pyi b/src/audiodynamics/audiodynamics.pyi new file mode 100644 index 00000000..6a45a42d --- /dev/null +++ b/src/audiodynamics/audiodynamics.pyi @@ -0,0 +1,280 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Compression, limiting, expansion, gating and transient shaping. + +`Dynamics` is one node that does the job of a compressor, a limiter, an +expander, a gate or a transient shaper, chosen by its mode. Hand it an audio +source with `play`, and the result is another audio source. Its detector can +run on the signal itself or on a separate key signal given to `key`, which is +how a sidechain compressor is made. + +Beyond the usual settings it has a number of options, every one of them off by +default, so a node that sets none of them does what a plain compressor does: +an RMS detector, a feedback detector 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 and 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 hold on the slow one. + +A level moved on a playing node walks to its new value over 20 ms instead of +stepping, so turning ``makeup_db`` in one call does not click. Every option +changes together or not at all, so a bad keyword leaves the node as it was. + +The node takes and gives 16-bit audio in blocks of 256 frames, with 1 or 2 +channels, and never reports itself finished: a chain with nothing left to +read gets silence. +""" + +from typing import Protocol, Self + +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + +DYN_COMPRESS: int +"""A mode: turn down what is above the threshold, by ``ratio``. This is 0.""" +DYN_LIMIT: int +"""A mode: stop the level at the threshold. This is 1.""" +DYN_EXPAND: int +"""A mode: turn down what is below the threshold, by ``ratio``. This is 2.""" +DYN_GATE: int +"""A mode: shut off what is below the threshold. This is 3.""" +DYN_TRANSIENT: int +"""A mode: reshape the start and the body of notes, by ``attack_gain_db`` and +``sustain_gain_db``. This is 4.""" + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class Dynamics: + """A compressor, limiter, expander, gate or transient shaper. + + The mode is the one positional argument; everything else is a keyword. + + Args: + mode: One of `DYN_COMPRESS`, `DYN_LIMIT`, `DYN_EXPAND`, `DYN_GATE` + and `DYN_TRANSIENT`. The default is `DYN_COMPRESS`. + sample_rate: Frames per second. The default is 48000. + channel_count: 1 or 2. The default is 2. + threshold_db: The level in dB where the effect starts, for + every mode. The default is -24. + ratio: The compression or expansion ratio, 1 or more (lower + values are 1). The default is 4. + knee_db: The width of the soft knee around the threshold, in + dB, 0 or more. The default is 6. + makeup_db: Gain added after the effect, in dB. A change walks + to its new value over 20 ms. The default is 0. + attack_ms: How fast the detector follows a rise, in + milliseconds. The default is 10. + release_ms: How fast the detector follows a fall, in + milliseconds. The default is 120. + attack_gain_db: `DYN_TRANSIENT` only: the gain in dB for the + start of a note. The default is 0. + sustain_gain_db: `DYN_TRANSIENT` only: the gain in dB for the + body of a note. The default is 0. + sidechain_hz: A high-pass corner in Hz on the signal the + detector hears, so low notes do not pump the gain. 0 or + less is off, the default. + lookahead_ms: How far ahead of the output the detector looks, + in milliseconds, 0 to 50. It adds that much latency. The + default is 0. + true_peak: How the detector finds peaks between samples. 0 or + False reads the samples; 1 or True estimates the peak with + a half-band filter; 2 or more reconstructs it at four + times the rate. The default is 0. + transient_fast_attack_ms: `DYN_TRANSIENT`: the fast envelope's + attack time, in milliseconds. The default is 1. + transient_fast_release_ms: `DYN_TRANSIENT`: the fast + envelope's release time, in milliseconds. The default is + 50. + transient_slow_attack_ms: `DYN_TRANSIENT`: the slow envelope's + attack time, in milliseconds. The default is 25. + transient_slow_release_ms: `DYN_TRANSIENT`: the slow + envelope's release time, in milliseconds. The default is + 300. + detector: What the detector measures: ``'peak'`` or ``'rms'``, + or 0 or 1 for the same. The default is ``'peak'``. + rms_ms: The averaging window of the RMS detector, in + milliseconds. The default is 10. + feedback_detector: Have the detector read the previous output + frame instead of the input, the feedback arrangement of + classic hardware compressors. The default is off. + sidechain_lp_hz: A low-pass corner in Hz on the signal the + detector hears, which with ``sidechain_hz`` makes a band. + 0 or less is off, the default. + sidechain_poles: How steep the key band's filters are: 1 pole + (6 dB an octave) or 2 (12). The default is 1. + key_listen: Put the detector's signal on the output instead of + the audio, as a gate's Key Listen switch does. The default + is off. + depth_db: The floor `DYN_EXPAND` and `DYN_GATE` pull the level + down to, in dB, zero or below. A positive value, the + default, leaves it unset: -60 dB for the expander and -80 + dB for the gate. + hold_ms: `DYN_GATE`: how long the gate holds open after the + level falls below the threshold, in milliseconds, which + makes the gate's envelope four-stage. 0 is no hold, the + default. + hysteresis_db: `DYN_GATE`: the gap in dB between the level + that opens the gate and the one that closes it. The + default is 0. + relative_threshold: Drive the gain computer by the key band's + level minus the full-band level, so a fixed spectral + balance gets the same reduction wherever it sits in the + level range. The default is off. + program_attack: Scale the attack by how far the level + overshoots the envelope, so a bigger overshoot is caught + faster. The default is off. + transient_dual: `DYN_TRANSIENT`: apply the attack and sustain + gains together, the sustain section reading its own, + slower pair of envelopes. The default is off. + sustain_fast_attack_ms: `DYN_TRANSIENT`, with + ``transient_dual``: the sustain section's fast attack, in + milliseconds. The default is 1. + sustain_fast_release_ms: `DYN_TRANSIENT`, with + ``transient_dual``: the sustain section's fast release, in + milliseconds. The default is 200. + sustain_slow_attack_ms: `DYN_TRANSIENT`, with + ``transient_dual``: the sustain section's slow attack, in + milliseconds. The default is 25. + sustain_slow_release_ms: `DYN_TRANSIENT`, with + ``transient_dual``: the sustain section's slow release, in + milliseconds. The default is 1200. + slow_hold_ms: `DYN_TRANSIENT`: how long the slow envelope + holds its peak before it releases, in milliseconds. 0 is + no hold, the default. + gain_smooth_ms: Smooth the computed gain with a one-pole + filter of this time, in milliseconds. 0 or less is off, + the default. + feedback_gain_corrected: With ``feedback_detector`` in + `DYN_COMPRESS`, make the loop land on the ratio you asked + for. Off, the loop reduces its own input and settles near + 2:1 whatever the ratio. The default is off. + + Raises: + ValueError: ``channel_count`` is not 1 or 2, or ``detector`` is not + ``'peak'`` or ``'rms'``. + TypeError: An unknown option was given. + """ + + def __init__( + self, + mode: int = 0, + *, + sample_rate: int = 48000, + channel_count: int = 2, + threshold_db: float = ..., + ratio: float = ..., + knee_db: float = ..., + makeup_db: float = ..., + attack_ms: float = ..., + release_ms: float = ..., + attack_gain_db: float = ..., + sustain_gain_db: float = ..., + sidechain_hz: float = ..., + lookahead_ms: float = ..., + true_peak: float = ..., + transient_fast_attack_ms: float = ..., + transient_fast_release_ms: float = ..., + transient_slow_attack_ms: float = ..., + transient_slow_release_ms: float = ..., + detector: str | float = ..., + rms_ms: float = ..., + feedback_detector: bool = ..., + sidechain_lp_hz: float = ..., + sidechain_poles: float = ..., + key_listen: bool = ..., + depth_db: float = ..., + hold_ms: float = ..., + hysteresis_db: float = ..., + relative_threshold: bool = ..., + program_attack: bool = ..., + transient_dual: bool = ..., + sustain_fast_attack_ms: float = ..., + sustain_fast_release_ms: float = ..., + sustain_slow_attack_ms: float = ..., + sustain_slow_release_ms: float = ..., + slow_hold_ms: float = ..., + gain_smooth_ms: float = ..., + feedback_gain_corrected: bool = ..., + ) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + def play(self, source: _AudioSample, /) -> None: + """Set the stream to process, replacing the last one.""" + def key(self, source: _AudioSample | None, /) -> None: + """Set the key signal the detector listens to instead of the stream + itself, or None to go back to the stream. The key has to match the + stream's channel count.""" + def set( + self, + *, + sample_rate: int = ..., + channel_count: int = ..., + threshold_db: float = ..., + ratio: float = ..., + knee_db: float = ..., + makeup_db: float = ..., + attack_ms: float = ..., + release_ms: float = ..., + attack_gain_db: float = ..., + sustain_gain_db: float = ..., + sidechain_hz: float = ..., + lookahead_ms: float = ..., + true_peak: float = ..., + transient_fast_attack_ms: float = ..., + transient_fast_release_ms: float = ..., + transient_slow_attack_ms: float = ..., + transient_slow_release_ms: float = ..., + detector: str | float = ..., + rms_ms: float = ..., + feedback_detector: bool = ..., + sidechain_lp_hz: float = ..., + sidechain_poles: float = ..., + key_listen: bool = ..., + depth_db: float = ..., + hold_ms: float = ..., + hysteresis_db: float = ..., + relative_threshold: bool = ..., + program_attack: bool = ..., + transient_dual: bool = ..., + sustain_fast_attack_ms: float = ..., + sustain_fast_release_ms: float = ..., + sustain_slow_attack_ms: float = ..., + sustain_slow_release_ms: float = ..., + slow_hold_ms: float = ..., + gain_smooth_ms: float = ..., + feedback_gain_corrected: bool = ..., + ) -> None: + """Change options while the node plays. + + Takes the same options as the constructor. All of them change together + or not at all, and a change to ``lookahead_ms`` or ``channel_count`` + allocates what it needs before anything changes. + + Raises: + ValueError: As for the constructor. + TypeError: An unknown option was given. + """ + def gain_reduction_db(self) -> float: + """How much the node is turning the level down now, in dB, for a meter.""" + def deinit(self) -> None: + """Release the node. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audioecho/audioecho.pyi b/src/audioecho/audioecho.pyi new file mode 100644 index 00000000..04bc470a --- /dev/null +++ b/src/audioecho/audioecho.pyi @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A delay with a filter, a soft-clip and a cross-feed in its feedback loop. + +`FeedbackDelay` is a delay line with the things a tape or bucket-brigade echo +has in its loop: a low-pass that darkens each repeat, a high-pass that thins +it, a soft-clip, a cross-feed that ping-pongs the repeats between channels, +and a slow wobble in the delay time ("wow") applied per sample, so it bends +pitch the way tape does instead of stepping once a block. It is named apart +from `audiodelays` on purpose: that module is CircuitPython's own, and the +additions here would not exist on a stock board. + +Four options go further, each off by default. ``wow_shape`` swaps the built-in +modulation sine for one period of your own, 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 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. + +The node is one 256-frame block at a time, 16-bit audio, 1 or 2 channels. A +level moved on a playing node, such as ``mix``, walks to its new value over +20 ms instead of stepping. Latency from dry to wet is the delay itself. When +the source ends the tail rings out on silence until it reaches exactly zero, +and the node rests, so a source played into it afterwards meets an empty +line; `audiomodal.Bank`, `audioverb.Tank` and `audioconvolve.Convolver` do +the same. +""" + +from typing import Protocol, Self + +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class FeedbackDelay: + """A delay with a processed feedback loop. + + Every argument is a keyword. ``sample_rate``, ``max_delay_ms`` and + ``channel_count`` size the delay line and the audio, so they can only be + given here; every other option can be changed with `set`. A keyword that + is not one of these raises `TypeError`. + + Args: + sample_rate: Frames per second. The default is 48000. + max_delay_ms: The longest delay in milliseconds, above 0. It sizes the + line. The default is 250. + channel_count: 1 or 2. The default is 2. + delay_ms: The delay in milliseconds, clamped to the line. The default + is half of ``max_delay_ms``. Give this or ``delay_frames``, not + both. + delay_frames: The delay in frames, 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 this. + feedback: How much of each repeat feeds back, 0.0 to 0.99 (clamped). + The default is 0.4. + mix: The wet level, 0.0 to 2.0, clamped. The dry stays at unity until + 1.0, as for `audiodelays.Echo`, so 0.3 is the dry plus 30 percent + wet and 2.0 is the wet alone. The default is 0.3. + damping_hz: A low-pass in the loop at this corner, in Hz, that darkens + each repeat. 0 takes the filter out, the default. + cut_hz: A high-pass in the loop at this corner, in Hz, that thins each + repeat. 0 takes the filter out, the default. + wow_hz: The rate of the delay-time wobble, in Hz. 0 is none, the + default. + wow_depth_ms: How far the wobble moves the delay, 0 to 1000 + milliseconds. A change ramps over ``wow_depth_ramp_ms``. The + default is 0. + wow_depth_ramp_ms: How long a change of ``wow_depth_ms`` takes to land, + 0 to 60000 milliseconds. The default is 20. + wow_shape: One period of the wobble as int16 Q15 samples, a + power-of-two count from 2 to 4096, or None for the built-in sine. + The default is None. + wow_am_depth: How far the wobble also dips the wet gain, 0.0 to 1.0. + It never boosts. The default is 0. + cross_feed: How much of each repeat goes to the other channel, 0.0 to + 1.0. The default is 0. + loop_drive: The soft-clip in the loop, 0.0 to 1.0. The default is 0. + input_pan: Where the input goes: -1.0 sends both channels into the left + line, 1.0 into the right, 0.0 leaves each on its own. Hard over + sends the average of the two channels. With ``cross_feed`` it makes + a ping-pong. The default is 0. + delay_slew: How fast the read head walks to a new ``delay_ms``, in + seconds of delay per second, 0 to 64. 0 jumps, the default. + loop_semitones: How far each pass through the loop shifts the pitch, + -24 to 24 semitones, clamped. 0 is off, the default. + loop_window_ms: The crossfade window of the loop's pitch shifter, 1 to + 250 milliseconds, clamped. The default is 25. + + Raises: + ValueError: ``max_delay_ms`` is not above 0, ``channel_count`` is not + 1 or 2, or ``wow_shape`` is not a power-of-two count from 2 to + 4096 int16 samples. + TypeError: An unknown option was given, or both ``delay_ms`` and + ``delay_frames``. + """ + + def __init__( + self, + *, + sample_rate: int = 48000, + max_delay_ms: float = 250, + channel_count: int = 2, + delay_ms: float = ..., + delay_frames: float = ..., + feedback: float = 0.4, + mix: float = 0.3, + damping_hz: float = 0.0, + cut_hz: float = 0.0, + wow_hz: float = 0.0, + wow_depth_ms: float = 0.0, + wow_depth_ramp_ms: float = 20.0, + wow_shape: bytes | bytearray | memoryview | None = None, + wow_am_depth: float = 0.0, + cross_feed: float = 0.0, + loop_drive: float = 0.0, + input_pan: float = 0.0, + delay_slew: float = 0.0, + loop_semitones: float = 0.0, + loop_window_ms: float = 25.0, + ) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + def play(self, source: _AudioSample, /) -> None: + """Set the stream to delay, replacing the last one.""" + def set( + self, + *, + delay_ms: float = ..., + delay_frames: float = ..., + feedback: float = ..., + mix: float = ..., + damping_hz: float = ..., + cut_hz: float = ..., + wow_hz: float = ..., + wow_depth_ms: float = ..., + wow_depth_ramp_ms: float = ..., + wow_shape: bytes | bytearray | memoryview | None = ..., + wow_am_depth: float = ..., + cross_feed: float = ..., + loop_drive: float = ..., + input_pan: float = ..., + delay_slew: float = ..., + loop_semitones: float = ..., + loop_window_ms: float = ..., + ) -> None: + """Change options while the node plays, with the meanings and ranges + of the constructor's. All of them change together or not at all, so a + bad keyword leaves the node as it was. + + Raises: + ValueError: ``wow_shape`` is not a power-of-two count from 2 to + 4096 int16 samples. + TypeError: An unknown option was given, or both ``delay_ms`` and + ``delay_frames``. ``sample_rate``, ``max_delay_ms`` and + ``channel_count`` are ignored here, not applied. + """ + def clear(self) -> None: + """Empty the line and every filter, which stops a ringing tail.""" + def deinit(self) -> None: + """Release the node. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audiofilters/audiofilters.pyi b/src/audiofilters/audiofilters.pyi new file mode 100644 index 00000000..72872bd3 --- /dev/null +++ b/src/audiofilters/audiofilters.pyi @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""CircuitPython's filter effects: a biquad chain, a phaser and a distortion. + +Each class is an audio source that reads another source, changes it and hands +the result on, so it sits in a chain like any other effect: +``mixer.play(effect)`` after ``effect.play(source)``. The module is +CircuitPython's own and takes the same arguments on every port, so a program +written for a board runs here unchanged. The biquad filter of +`audiobiquad`, which carries more options and whose tail reaches exact zero, +is a separate module. + +A setting that takes a `synthio.BlockInput` can be a number, or a +`synthio.LFO` or `synthio.Math` that is read once a block. +""" + +from typing import Protocol, Self, Union + +_BlockInput = Union[float, object, None] +"""A number, or a `synthio.LFO` or `synthio.Math` read once a block.""" +_Biquad = object +"""A `synthio.Biquad`.""" + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class DistortionMode: + """The way `Distortion` shapes the signal.""" + + CLIP: DistortionMode + """Hard clipping: anything past full scale is cut off.""" + LOFI: DistortionMode + """Bit reduction, which makes a gritty low-resolution sound.""" + OVERDRIVE: DistortionMode + """A rounded saturation that squeezes the peaks.""" + WAVESHAPE: DistortionMode + """A smooth curve that folds the peaks back.""" + +class Filter: + """A chain of biquad filters. + + Args: + filter: A `synthio.Biquad`, or a sequence of them that run one after + another, or None for a wire. The same object is returned from + the `filter` property. + mix: How much of the filtered signal goes out: 0 is the dry signal + and 1 is the filtered one. A `synthio.BlockInput`. + buffer_size: The size in bytes of each block the filter hands out. + sample_rate: The rate of the audio in Hz. It must match the source. + bits_per_sample: 8 or 16. It must match the source. + samples_signed: Whether the samples are signed. + channel_count: 1 or 2. It must match the source. + + Raises: + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is less + than 1, or ``bits_per_sample`` is not 8 or 16. + TypeError: ``filter`` holds something that is not a `synthio.Biquad`. + """ + + def __init__( + self, + *, + filter: _Biquad | tuple[_Biquad, ...] | list[_Biquad] | None = None, + mix: _BlockInput = 1.0, + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + def deinit(self) -> None: + """Free the filter. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """True while a source is playing through the filter.""" + filter: _Biquad | tuple[_Biquad, ...] | list[_Biquad] | None + """The biquad or biquads applied to the signal.""" + mix: _BlockInput + """How much of the filtered signal goes out, 0 to 1.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> None: + """Read from a source and filter it. + + Args: + sample: The audio source to filter. + loop: Start the source over when it ends. + """ + def stop(self) -> None: + """Stop reading the source.""" + +class Phaser: + """A phaser: a cascade of all-pass stages mixed with the dry signal. + + Args: + frequency: The corner frequency of the all-pass stages in Hz. + A `synthio.BlockInput`. + feedback: How much of the output goes back into the input, 0 to 1. + None leaves it off. A `synthio.BlockInput`. + mix: How much of the processed signal goes out, 0 to 1. A + `synthio.BlockInput`. + stages: How many all-pass stages. More stages make more notches. + buffer_size: The size in bytes of each block the effect hands out. + sample_rate: The rate of the audio in Hz. It must match the source. + bits_per_sample: 8 or 16. It must match the source. + samples_signed: Whether the samples are signed. + channel_count: 1 or 2. It must match the source. + + Raises: + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is less + than 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + frequency: _BlockInput = 1000.0, + feedback: _BlockInput = None, + mix: _BlockInput = 1.0, + stages: int = 6, + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + def deinit(self) -> None: + """Free the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """True while a source is playing through the effect.""" + frequency: _BlockInput + """The corner frequency of the all-pass stages in Hz.""" + feedback: _BlockInput + """How much of the output goes back into the input.""" + mix: _BlockInput + """How much of the processed signal goes out, 0 to 1.""" + stages: int + """How many all-pass stages.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> None: + """Read from a source and process it. + + Args: + sample: The audio source. + loop: Start the source over when it ends. + """ + def stop(self) -> None: + """Stop reading the source.""" + +class Distortion: + """A distortion with four modes. + + Args: + drive: How hard the signal is pushed, 0 to 1. A `synthio.BlockInput`. + pre_gain: Gain in dB before the distortion. A `synthio.BlockInput`. + post_gain: Gain in dB after the distortion. A `synthio.BlockInput`. + mode: A `DistortionMode`. The default is `DistortionMode.CLIP`. + soft_clip: Round off what the mode leaves past full scale instead of + wrapping it. + mix: How much of the distorted signal goes out, 0 to 1. A + `synthio.BlockInput`. + buffer_size: The size in bytes of each block the effect hands out. + sample_rate: The rate of the audio in Hz. It must match the source. + bits_per_sample: 8 or 16. It must match the source. + samples_signed: Whether the samples are signed. + channel_count: 1 or 2. It must match the source. + + Raises: + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is less + than 1, or ``bits_per_sample`` is not 8 or 16. + """ + + def __init__( + self, + *, + drive: _BlockInput = 0.0, + pre_gain: _BlockInput = 0.0, + post_gain: _BlockInput = 0.0, + mode: DistortionMode = ..., + soft_clip: bool = False, + mix: _BlockInput = 1.0, + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + def deinit(self) -> None: + """Free the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """True while a source is playing through the effect.""" + drive: _BlockInput + """How hard the signal is pushed, 0 to 1.""" + pre_gain: _BlockInput + """Gain in dB before the distortion.""" + post_gain: _BlockInput + """Gain in dB after the distortion.""" + mode: DistortionMode + """The `DistortionMode` in use.""" + soft_clip: bool + """Whether the output is softly clipped.""" + mix: _BlockInput + """How much of the distorted signal goes out, 0 to 1.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> None: + """Read from a source and distort it. + + Args: + sample: The audio source. + loop: Start the source over when it ends. + """ + def stop(self) -> None: + """Stop reading the source.""" diff --git a/src/audiofreeverb/audiofreeverb.pyi b/src/audiofreeverb/audiofreeverb.pyi new file mode 100644 index 00000000..825e436d --- /dev/null +++ b/src/audiofreeverb/audiofreeverb.pyi @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""CircuitPython's Freeverb reverb. + +`Freeverb` is an audio source that reads another source and adds a room +around it, with eight comb filters and four all-pass filters per channel. Its +line lengths are fixed in the code. To choose your own network, see +`audioverb.Tank`. The class is CircuitPython's own and takes the same +arguments on every port. +""" + +from typing import Protocol, Self, Union + +_BlockInput = Union[float, object, None] +"""A number, or a `synthio.LFO` or `synthio.Math` read once a block.""" +_Biquad = object +"""A `synthio.Biquad`.""" + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class Freeverb: + """A reverb. + + Args: + roomsize: The size of the room, 0 to 1. The default is 0.5. A + `synthio.BlockInput`. + damp: How quickly the high frequencies die away, 0 to 1. The default + is 0.5. A `synthio.BlockInput`. + pre_filter: A `synthio.Biquad`, or a sequence of them, run on the + signal before the reverb. None for none. + post_filter: The same, run after the reverb. + mix: How much of the reverberated signal goes out, 0 to 1. The default + is 0.5. A `synthio.BlockInput`. + buffer_size: The size in bytes of each block the effect hands out. + sample_rate: The rate of the audio in Hz. It must match the source. + bits_per_sample: Must be 16. + samples_signed: Must be True. + channel_count: 1 or 2. It must match the source. + + Raises: + ValueError: ``channel_count`` is not 1 or 2, ``sample_rate`` is less + than 1, ``bits_per_sample`` is not 16 or ``samples_signed`` is + False. + MemoryError: The comb and all-pass lines do not fit. + """ + + def __init__( + self, + *, + roomsize: _BlockInput = ..., + damp: _BlockInput = ..., + pre_filter: _Biquad | tuple[_Biquad, ...] | list[_Biquad] | None = None, + post_filter: _Biquad | tuple[_Biquad, ...] | list[_Biquad] | None = None, + mix: _BlockInput = ..., + buffer_size: int = 512, + sample_rate: int = 8000, + bits_per_sample: int = 16, + samples_signed: bool = True, + channel_count: int = 1, + ) -> None: ... + def deinit(self) -> None: + """Free the effect. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + @property + def playing(self) -> bool: + """True while a source is playing through the effect.""" + roomsize: _BlockInput + """The size of the room, 0 to 1.""" + damp: _BlockInput + """How quickly the high frequencies die away, 0 to 1.""" + pre_filter: _Biquad | tuple[_Biquad, ...] | list[_Biquad] | None + """The biquad or biquads run before the reverb.""" + post_filter: _Biquad | tuple[_Biquad, ...] | list[_Biquad] | None + """The biquad or biquads run after the reverb.""" + mix: _BlockInput + """How much of the reverberated signal goes out, 0 to 1.""" + def play(self, sample: _AudioSample, *, loop: bool = False) -> None: + """Read from a source and add reverb to it. + + Args: + sample: The audio source. + loop: Start the source over when it ends. + """ + def stop(self) -> None: + """Stop reading the source.""" diff --git a/src/audiograph/audiograph.pyi b/src/audiograph/audiograph.pyi new file mode 100644 index 00000000..f7ec817d --- /dev/null +++ b/src/audiograph/audiograph.pyi @@ -0,0 +1,722 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A whole patch rendered by one C call per block. + +`Graph` renders a patch in one C call per block. You build it once, from nodes +that call the kernels audiodsp already has (the oscillator, the ladder, the +float biquad, the feedback delay, the all-pass cascade, the sample-and-hold) +and a few small ones a voice needs (an envelope, an LFO, noise, a mixer, a +VCA, a glide, a clipping two-pole filter, an overdrive and more), then call +`Graph.run` once a block. Nothing in Python runs between the nodes, which is +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 +``` + +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, +belongs to a group and holds one kernel's settings behind up to sixteen float +parameters. Nodes run in the order you added them, so add a node after the +nodes it reads. Every node is mono; a stereo patch has two buffers where it +needs them. A block is 256 frames at most, a node has at most eight inputs +and a group is 0 to 31 or `ALWAYS`. A graph refuses a node with nothing to +write (every kind but `OUT` writes a buffer), an input or output id past its +buffers, and a node past its table. + +The buffers carry one wire: `UNIT` counts is 1.0, the +10 V of a modular rack, +so audio at 0.5 is a quarter of full scale. + +An input left None is open: an oscillator without a pitch input plays +`OSC_FREQUENCY`, a ladder without a cutoff input sits at `LADDER_CUTOFF_HZ`. +The kernel nodes take their parameters under the same numbers as the modules +that wrap them (`audiooscillator`, `audioladder`, `audiobiquad`, `audioecho`, +and `audiobiquad.AllPass` for `PHASER`), re-exported here so a caller needs +no other module. + +Every node kind and every parameter index is a constant below; a kind's +parameters carry its name as a prefix. +""" + +from typing import Self, Sequence + +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + +# Node kinds, the first argument of Graph.add + +CONST: int +"""A node kind with no inputs: writes the value of parameter 0, in counts, every sample.""" +OSC: int +"""A band-limited oscillator, `audiooscillator`'s. Inputs: pitch, fm, pwm, sync.""" +LADDER: int +"""A transistor ladder filter, `audioladder`'s, with the cutoff input read every sample. Inputs: audio, cutoff.""" +BIQUAD: int +"""A float biquad, `audiobiquad.Biquad`'s, which walks to new coefficients. Input: audio.""" +FBDELAY: int +"""A feedback delay, `audioecho.FeedbackDelay`'s. Built with ``arg`` the line's length in milliseconds. `Graph.table` sets its wow shape. Input: audio.""" +ENV: int +"""An ADSR envelope from 0 to `UNIT`; each stage is a one-pole approach, the attack aimed past 1.0 so it arrives with a slope. Input: gate.""" +LFO: int +"""A low-frequency oscillator of one of five shapes, swinging plus and minus its amplitude. No inputs.""" +NOISE: int +"""White noise. Parameter 0 is the amplitude in counts. No inputs.""" +MIX: int +"""A mixer of up to eight inputs. Parameters 0 to 7 are a gain per input, and `MIX_OFFSET` adds an offset. A gain that moved ramps across the block.""" +VCA: int +"""A voltage-controlled amplifier: audio times the chosen control input over `UNIT`, times the gain. Inputs: audio, cv_a, cv_b.""" +OUT: int +"""Writes the run's output buffer: interleaved stereo, or the mean when the graph is mono. Parameter 0 is a scale. Inputs: left, right. It writes no buffer, so its output is None.""" +PHASER: int +"""`audiobiquad.AllPass` on one channel: first-order all-pass stages, one feedback around the chain, and a dry and wet mix. Built with ``arg`` the number of stages (0 means 4). Input: audio.""" +LAG: int +"""Glides a note's pitch CV to each new note, with the bend (pitch minus note) on top. Inputs: note, pitch, legato.""" +SAMPLE_HOLD: int +"""Holds its signal at each rising edge of the clock, or `SH_RATE` times a second with the clock open. Inputs: signal, clock.""" +TWOPOLE: int +"""A 12 dB per octave filter with its nonlinearity inside the feedback loop. Inputs: audio, cutoff CV.""" +OVERDRIVE: int +"""`audiofilters.Distortion`'s soft-clipped overdrive followed by a high shelf at 1.5 kHz. CircuitPython's own `audiograph` lacks it; check with ``hasattr``. Input: audio.""" +TANH: int +"""``TANH_LEVEL * tanh(TANH_DRIVE * x)`` with ``x`` in units of `UNIT`. The same bytes on every interpreter. Input: audio.""" +PAN: int +"""A stereo mixer of up to eight inputs with a balance on each: the left side goes to the node's output and the right to the buffer given as ``arg`` when the node is added. Parameters 0 to 7 are a gain per input, and `PAN_PAN` plus ``i`` is input ``i``'s pan.""" +WAVEFOLD: int +"""A West Coast wavefolder: three sine folds crossfaded with the input by the fold. Inputs: audio, fold CV.""" +LPG: int +"""A vactrol low-pass gate. Inputs: audio, control, strike.""" +MODAL: int +"""Eight tuned resonators struck or rung by the input: bells, bars and drum heads, `audiomodal.Bank`'s kernel. Inputs: excitation, strike.""" +STRUCK: int +"""A hammer or pluck exciter into a 24-mode struck bar bank, for tine, reed, vibraphone and clavinet voices. `Graph.table` replaces its modes. Inputs: strike, velocity.""" +PICKUP: int +"""An electric piano pickup's asymmetric nonlinearity with DC removal. Input: audio.""" +TREMOLO: int +"""A mono-to-stereo auto-pan or amplitude tremolo. Built with ``arg`` the right side's buffer, like `PAN`. Inputs: audio, rate CV, depth CV.""" +DECAY: int +"""A one-shot exponential envelope, a burst of re-strikes, or the two-stage, soft-attack and rise shapes a drum needs. `Graph.table` sets the strikes. Inputs: trigger, velocity.""" +PINK: int +"""Pink noise: Paul Kellet's pinking filter on a white input. Input: white.""" +CRUSH: int +"""Rounds the audio to a number of bits. Input: audio.""" +ACID: int +"""A diode ladder filter with an accent circuit, as a TB-303 style bass line needs. Inputs: audio, env, accent.""" +SPRING: int +"""A two-tank spring reverb over `audiofreeverb`, mixed on both sides. Built with ``arg`` the right side's buffer. Input: audio.""" +ADSR2: int +"""An `ENV` contour that starts at `ADSR2_INITIAL` and peaks at `ADSR2_ATTACK_LEVEL`. Input: gate.""" +GLISS: int +"""`LAG`'s linear glide stepped onto a semitone grid once a block. Inputs: note, pitch, legato.""" +FOLLOW: int +"""An envelope follower, one level a block, peak or RMS. Input: audio.""" +WAVETABLE: int +"""A drawbar organ voice from one stored cycle. Inputs: pitch, gate and an extra signal added before the contacts. `Graph.table` holds the cycle; ``arg`` is the most samples it may hold.""" +ROTARY: int +"""A rotary speaker, stereo like `PAN`: ``arg`` is the right side's buffer. Input: audio.""" + +# Groups and the wire + +ALWAYS: int +"""A group that always runs, whatever mask `Graph.run` is given. Pass it as a node's ``group``.""" +UNIT: int +"""The wire's 1.0 in counts: 16384. A buffer holds 16-bit samples, so audio at 0.5 is a quarter of full scale.""" + +# ENV, ADSR2 and DECAY + +ENV_ATTACK: int +"""`ENV` parameter: the attack time in seconds.""" +ENV_DECAY: int +"""`ENV` parameter: the decay time in seconds.""" +ENV_SUSTAIN: int +"""`ENV` parameter: the sustain level, 0.0 to 1.0.""" +ENV_RELEASE: int +"""`ENV` parameter: the release time in seconds.""" +ADSR2_ATTACK: int +"""`ADSR2` parameter: the attack time in seconds.""" +ADSR2_DECAY: int +"""`ADSR2` parameter: the decay time in seconds.""" +ADSR2_SUSTAIN: int +"""`ADSR2` parameter: the sustain level, 0.0 to 1.0.""" +ADSR2_RELEASE: int +"""`ADSR2` parameter: the release time in seconds.""" +ADSR2_INITIAL: int +"""`ADSR2` parameter: the level the contour starts from.""" +ADSR2_ATTACK_LEVEL: int +"""`ADSR2` parameter: the level the attack peaks at.""" +DECAY_DECAY: int +"""`DECAY` parameter: seconds for the envelope to fall by 40 dB.""" +DECAY_RESTRIKE: int +"""`DECAY` parameter: with a strike table, seconds to fall by 40 dB between spikes.""" +DECAY_ATTACK: int +"""`DECAY` parameter: seconds of linear ramp from 0 up to the velocity before the fall begins.""" +DECAY_FAST: int +"""`DECAY` parameter: seconds to fall by 40 dB for the first `DECAY_FAST_SEC` seconds, then `DECAY_DECAY` for the rest.""" +DECAY_FAST_SEC: int +"""`DECAY` parameter: how long the fast fall lasts, in seconds.""" +DECAY_RISE: int +"""`DECAY` parameter: seconds to fall by 40 dB of a second envelope that is subtracted from the first.""" +DECAY_STRIKES: int +"""The most strikes `Graph.table` takes for a `DECAY` node: 16.""" + +# LFO and VCA + +LFO_RATE: int +"""`LFO` parameter: the rate in Hz.""" +LFO_AMPLITUDE: int +"""`LFO` parameter: the swing in counts, plus and minus.""" +LFO_SHAPE: int +"""`LFO` parameter: one of the ``LFO_*`` shapes. Every shape comes from one phase, so LFOs of different shapes at one rate stay in step.""" +LFO_TRIANGLE: int +"""An `LFO_SHAPE` value: a triangle whose bottom is at the start of the cycle.""" +LFO_SINE: int +"""An `LFO_SHAPE` value: a sine.""" +LFO_SAW: int +"""An `LFO_SHAPE` value: a falling sawtooth.""" +LFO_SQUARE: int +"""An `LFO_SHAPE` value: plus the amplitude for the first half of the cycle, minus for the second.""" +LFO_RANDOM: int +"""An `LFO_SHAPE` value: a new level, uniform in plus and minus the amplitude, at the start of each cycle, held until the next.""" +MIX_OFFSET: int +"""`MIX` parameter: an offset in counts added to the sum.""" +VCA_GAIN: int +"""`VCA` parameter: a gain applied after the control input.""" +VCA_SELECT: int +"""`VCA` parameter: which control input to use: ``cv_a`` below 0.5, else ``cv_b``.""" +VCA_CURVE: int +"""`VCA` parameter: `VCA_LINEAR` or `VCA_EXPONENTIAL`.""" +VCA_LINEAR: int +"""A `VCA_CURVE` value: the gain follows the control input.""" +VCA_EXPONENTIAL: int +"""A `VCA_CURVE` value: 60 dB over the control input, unity at `UNIT`.""" + +# LAG, GLISS and SAMPLE_HOLD + +LAG_TIME: int +"""`LAG` parameter: the glide time in seconds.""" +LAG_MODE: int +"""`LAG` parameter: `LAG_OFF`, `LAG_ON` (the default) or `LAG_AUTO`.""" +LAG_SHAPE: int +"""`LAG` parameter: `LAG_LINEAR` (the default) or `LAG_ONE_POLE`.""" +LAG_OFF: int +"""A `LAG_MODE` value: no glide.""" +LAG_ON: int +"""A `LAG_MODE` value: always glide.""" +LAG_AUTO: int +"""A `LAG_MODE` value: glide only when the legato input is above 0.5 at the start of the block.""" +LAG_LINEAR: int +"""A `LAG_SHAPE` value: a straight line that takes `LAG_TIME` seconds wherever it starts from.""" +LAG_ONE_POLE: int +"""A `LAG_SHAPE` value: a one-pole approach. It ignores the mode.""" +GLISS_TIME: int +"""`GLISS` parameter: the glide time in seconds.""" +GLISS_MODE: int +"""`GLISS` parameter: `LAG_OFF`, `LAG_ON` or `LAG_AUTO`.""" +GLISS_STEP: int +"""`GLISS` parameter: the grid the pitch is stepped onto, where 0.1 is a semitone at one volt per octave.""" +SH_LAG: int +"""`SAMPLE_HOLD` parameter: a per-sample one-pole coefficient from 0 to 1 that slews the output toward the held value. 0 does not slew.""" +SH_RATE: int +"""`SAMPLE_HOLD` parameter: with the clock input open, how many times a second to refresh.""" + +# Filters and shapers + +TWOPOLE_TYPE: int +"""`TWOPOLE` parameter: one of the ``TWOPOLE_*`` filter types.""" +TWOPOLE_CUTOFF_HZ: int +"""`TWOPOLE` parameter: the cutoff in Hz, before the cutoff CV.""" +TWOPOLE_DEPTH: int +"""`TWOPOLE` parameter: how far the cutoff CV moves the cutoff; 1 is an octave per 1638.4 counts, 0 ignores the input.""" +TWOPOLE_RESONANCE: int +"""`TWOPOLE` parameter: the Sallen-Key's peak or the CMOS filter's resonance, 0.0 to 1.0.""" +TWOPOLE_DRIVE: int +"""`TWOPOLE` parameter: a gain applied to the input before the filter.""" +TWOPOLE_SK_LOWPASS: int +"""A `TWOPOLE_TYPE` value: a Sallen-Key low-pass whose peak feedback runs through a diode clipper.""" +TWOPOLE_SK_HIGHPASS: int +"""A `TWOPOLE_TYPE` value: a Sallen-Key high-pass.""" +TWOPOLE_CMOS_LOWPASS: int +"""A `TWOPOLE_TYPE` value: a CMOS state-variable low-pass, with a clipping inverter in every stage.""" +TWOPOLE_CMOS_BANDPASS: int +"""A `TWOPOLE_TYPE` value: the CMOS filter as a band-pass.""" +TWOPOLE_CMOS_HIGHPASS: int +"""A `TWOPOLE_TYPE` value: the CMOS filter as a high-pass.""" +OVERDRIVE_DRIVE: int +"""`OVERDRIVE` parameter: the pre-gain, 0.0 to 1.0, up to 20 dB.""" +OVERDRIVE_TONE: int +"""`OVERDRIVE` parameter: the shelf's gain, 0.0 to 1.0, from minus 12 dB through flat at 0.5 to plus 12 dB.""" +OVERDRIVE_LEVEL: int +"""`OVERDRIVE` parameter: the linear gain after the clipper; 1 is unity.""" +TANH_DRIVE: int +"""`TANH` parameter: a gain before the tanh; 1 is unity.""" +TANH_LEVEL: int +"""`TANH` parameter: a gain after the tanh; 1 is unity.""" +PAN_PAN: int +"""`PAN` parameter: the index of the first pan; input ``i``'s pan is ``PAN_PAN + i``, from -1 (left) to 1 (right).""" +WAVEFOLD_FOLD: int +"""`WAVEFOLD` parameter: how far the signal is folded, 0.0 to 1.0. A fold CV adds to it.""" +WAVEFOLD_SYMMETRY: int +"""`WAVEFOLD` parameter: an offset before the folds, -1.0 to 1.0.""" +LPG_MODE: int +"""`LPG` parameter: `LPG_VCF`, `LPG_VCA` or `LPG_BOTH`.""" +LPG_DECAY: int +"""`LPG` parameter: seconds for the vactrol to fall by a factor of e.""" +LPG_MANUAL: int +"""`LPG` parameter: a manual opening, 0.0 to 1.0, added to the control and strike inputs.""" +LPG_LEVEL: int +"""`LPG` parameter: a gain applied last.""" +LPG_VCF: int +"""An `LPG_MODE` value: low-pass the input at a cutoff the vactrol moves, 30 Hz closed to 12 kHz open.""" +LPG_VCA: int +"""An `LPG_MODE` value: scale the input by the vactrol.""" +LPG_BOTH: int +"""An `LPG_MODE` value, and the default: filter and scale, so the highs die before the lows.""" +PICKUP_MODE: int +"""`PICKUP` parameter: `PICKUP_ELECTROMAGNETIC` or `PICKUP_ELECTROSTATIC`.""" +PICKUP_DRIVE: int +"""`PICKUP` parameter: a gain applied to the input.""" +PICKUP_OFFSET: int +"""`PICKUP` parameter: a shift added before the nonlinearity; above 0 it grows second-harmonic bark.""" +PICKUP_LEVEL: int +"""`PICKUP` parameter: a gain applied last.""" +PICKUP_ELECTROMAGNETIC: int +"""A `PICKUP_MODE` value, and the default: a Rhodes-style coil.""" +PICKUP_ELECTROSTATIC: int +"""A `PICKUP_MODE` value: a Wurlitzer-style reed plate.""" +CRUSH_BITS: int +"""`CRUSH` parameter: the bit depth, 1 to 15.""" +ACID_CUTOFF_HZ: int +"""`ACID` parameter: the cutoff in Hz.""" +ACID_RESONANCE: int +"""`ACID` parameter: the resonance, 0.0 to 1.0.""" +ACID_ENVMOD: int +"""`ACID` parameter: how far the envelope input moves the cutoff, 0.0 to 1.0.""" +PINK_LEVEL: int +"""`PINK` parameter: the output level.""" + +# Modal, struck and spring + +MODAL_FREQUENCY: int +"""`MODAL` parameter: the base frequency in Hz.""" +MODAL_DECAY: int +"""`MODAL` parameter: the ring time in seconds.""" +MODAL_BRIGHTNESS: int +"""`MODAL` parameter: how much the upper modes sound, 0.0 to 1.0.""" +MODAL_INPUT: int +"""`MODAL` parameter: a gain on the excitation before the bank.""" +MODAL_MIX: int +"""`MODAL` parameter: from the excitation alone (0.0) to the bank alone (1.0).""" +MODAL_RATIO: int +"""`MODAL` parameter: the index of the first mode ratio; mode ``m`` sits at the frequency times ``MODAL_RATIO + m``, for ``m`` 0 to 7. A ratio of 0 turns its mode off.""" +MODAL_MODES: int +"""The number of modes of a `MODAL` node: 8.""" +STRUCK_FREQUENCY: int +"""`STRUCK` parameter: the note's frequency in Hz.""" +STRUCK_HARDNESS: int +"""`STRUCK` parameter: 0.0 to 1.0; harder shortens the strike's pulse while keeping its area.""" +STRUCK_LEVEL: int +"""`STRUCK` parameter: the strike's level.""" +STRUCK_SHAPE: int +"""`STRUCK` parameter: `STRUCK_HAMMER` or `STRUCK_PLUCK`.""" +STRUCK_HAMMER: int +"""A `STRUCK_SHAPE` value: a half-sine pulse.""" +STRUCK_PLUCK: int +"""A `STRUCK_SHAPE` value: a rising ramp.""" +STRUCK_MODES: int +"""The most modes `Graph.table` takes for a `STRUCK` node: 24.""" +SPRING_MIX: int +"""`SPRING` parameter: dry to wet, 0.0 to 1.0.""" +SPRING_DECAY: int +"""`SPRING` parameter: the tail's length, 0.0 to 1.0.""" +SPRING_DAMP: int +"""`SPRING` parameter: how fast the highs die, 0.0 to 1.0.""" + +# Tremolo, follower, wavetable and rotary + +TREMOLO_RATE: int +"""`TREMOLO` parameter: the rate in Hz, at least 0.01.""" +TREMOLO_DEPTH: int +"""`TREMOLO` parameter: the depth, 0.0 to 1.0. A change glides across the next block.""" +TREMOLO_MODE: int +"""`TREMOLO` parameter: `TREMOLO_PAN` or `TREMOLO_AMPLITUDE`.""" +TREMOLO_PAN: int +"""A `TREMOLO_MODE` value, and the default: stereo auto-pan.""" +TREMOLO_AMPLITUDE: int +"""A `TREMOLO_MODE` value: the same amplitude tremolo on both sides.""" +FOLLOW_ATTACK: int +"""`FOLLOW` parameter: the attack time in seconds.""" +FOLLOW_RELEASE: int +"""`FOLLOW` parameter: the release time in seconds.""" +FOLLOW_MODE: int +"""`FOLLOW` parameter: `FOLLOW_PEAK` or `FOLLOW_RMS`.""" +FOLLOW_PEAK: int +"""A `FOLLOW_MODE` value: follow the peak.""" +FOLLOW_RMS: int +"""A `FOLLOW_MODE` value: follow the RMS level.""" +WAVETABLE_FREQUENCY: int +"""`WAVETABLE` parameter: the frequency in Hz.""" +ROTARY_SPEED: int +"""`ROTARY` parameter: `ROTARY_STOP`, `ROTARY_SLOW` or `ROTARY_FAST`.""" +ROTARY_DRIVE: int +"""`ROTARY` parameter: the drive, 0 to 10.""" +ROTARY_VOLUME: int +"""`ROTARY` parameter: the volume, 0 to 10.""" +ROTARY_STOP: int +"""A `ROTARY_SPEED` value: the rotors stopped.""" +ROTARY_SLOW: int +"""A `ROTARY_SPEED` value: the slow speed.""" +ROTARY_FAST: int +"""A `ROTARY_SPEED` value: the fast speed.""" + +# The kernels' option numbers, as the modules that wrap them number them + +OSC_FREQUENCY: int +"""`OSC` parameter: the frequency in Hz, before the pitch input.""" +OSC_WAVEFORM: int +"""`OSC` parameter: 1 for a sawtooth, 2 pulse, 4 triangle, 8 sine; a sum plays those added.""" +OSC_PULSE_WIDTH: int +"""`OSC` parameter: the pulse width, 0.05 to 0.95.""" +OSC_LEVEL: int +"""`OSC` parameter: the output level.""" +OSC_PITCH_OCTAVES: int +"""`OSC` parameter: octaves for a full-scale pitch input.""" +OSC_FM_DEPTH: int +"""`OSC` parameter: Hz for a full-scale FM input.""" +OSC_PWM_DEPTH: int +"""`OSC` parameter: pulse width for a full-scale PWM input.""" +LADDER_CUTOFF_HZ: int +"""`LADDER` parameter: where the filter turns over, in Hz.""" +LADDER_RESONANCE: int +"""`LADDER` parameter: 0 to 4.2; at 4 it sustains a tone at the cutoff.""" +LADDER_DRIVE: int +"""`LADDER` parameter: the linear gain into the loop; 1 is unity.""" +LADDER_POLES: int +"""`LADDER` parameter: the tap, 1 to 4 stages, 6 to 24 dB an octave.""" +LADDER_PASSBAND_COMP: int +"""`LADDER` parameter: 0.0 to 1.0, how much of the passband to give back.""" +LADDER_OVERSAMPLE: int +"""`LADDER` parameter: 1 or 2.""" +LADDER_MIX: int +"""`LADDER` parameter: 0.0 is a wire, 1.0 is the filter.""" +LADDER_CUTOFF_OCTAVES: int +"""`LADDER` parameter: octaves for a full-scale cutoff input.""" +BIQUAD_MODE: int +"""`BIQUAD` parameter: a mode numbered as `audiobiquad` numbers them.""" +BIQUAD_FREQUENCY: int +"""`BIQUAD` parameter: the corner or centre frequency in Hz.""" +BIQUAD_Q: int +"""`BIQUAD` parameter: the quality factor, 0.05 to 60.""" +BIQUAD_GAIN_DB: int +"""`BIQUAD` parameter: the gain in decibels, for the peaking and shelf modes.""" +BIQUAD_MIX: int +"""`BIQUAD` parameter: 0.0 is a wire, 1.0 is the filtered signal alone.""" +BIQUAD_RAMP: int +"""`BIQUAD` parameter: milliseconds the filter takes to walk to new coefficients, 0 to 1000; the default is 20. The walk is the power of two frames nearest the request, and 0 is one frame. Set it low when a value moves every block.""" +DELAY_MS: int +"""`FBDELAY` parameter: the delay in milliseconds.""" +DELAY_FEEDBACK: int +"""`FBDELAY` parameter: the feedback.""" +DELAY_MIX: int +"""`FBDELAY` parameter: the wet mix.""" +DELAY_DAMPING_HZ: int +"""`FBDELAY` parameter: the damping filter's corner in Hz.""" +DELAY_WOW_HZ: int +"""`FBDELAY` parameter: the wow modulation's rate in Hz.""" +DELAY_WOW_DEPTH_MS: int +"""`FBDELAY` parameter: the wow modulation's depth in milliseconds.""" +PHASER_FREQUENCY: int +"""`PHASER` parameter: the frequency in Hz at which one stage's phase passes minus 90 degrees.""" +PHASER_FEEDBACK: int +"""`PHASER` parameter: the feedback around the chain, -0.99 to 0.99.""" +PHASER_MIX: int +"""`PHASER` parameter: dry to wet, 0.0 to 1.0.""" + +class Graph: + """A patch of nodes that renders a block at a time. + + Every argument is a keyword. + + A `Graph` is also an audio source: hand it to an output, a mixer or the + pump and each pull renders one block, and it never ends. + + `set`, `reset` and `enable` do not touch the running graph. Each goes into + the queue, and the renderer applies everything waiting at the top of the + next block it renders, so a change never lands halfway through a block. + Between `hold` and `commit` nothing is released, so a batch lands on one + block together. When the queue fills, the control side waits for the pump + to finish its block and applies what is waiting itself, so nothing is + dropped; a held batch that overflowed lands across two blocks. + + While the pump is pulling a graph, the calls that change its shape + (`buffer`, `add`, `table` and `write`) and a `run` of your own raise + `RuntimeError`. Build the graph before you hand it over. `read` and + `peak` still work, but can catch a buffer the pump is rendering into. + + Args: + sample_rate: The patch's rate in Hz, 1 or more. The default is 48000. + frames: Frames in a block, 1 to 256. The default is 128. + channel_count: Channels of the output, 1 or 2. + buffers: How many block buffers the graph owns, 1 to 4096. The + default is 64. + nodes: How many nodes the table holds, 1 to 4096. The default is 64. + queue: How many changes the control queue holds, 1 to 65536. The + default, 0, is the larger of 256 and four per node. + + Raises: + ValueError: An argument is out of range. + """ + + def __init__( + self, + *, + sample_rate: int = 48000, + frames: int = 128, + channel_count: int = 2, + buffers: int = 64, + nodes: int = 64, + queue: int = 0, + ) -> None: ... + sample_rate: int + """Frames per second, as given to the constructor.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + def buffer(self) -> int: + """Hand out a fresh block buffer. + + Returns: + The buffer's id, to pass to `add`, `read`, `write` and `peak`. + + Raises: + ValueError: No buffers are left. + RuntimeError: The pump is rendering this graph. + """ + def add( + self, + kind: int, + group: int, + inputs: Sequence[int | None], + output: int | None, + arg: float = 0.0, + /, + ) -> int: + """Append a node. + + Nodes run in the order you add them, so add a node after the nodes it + reads. + + Args: + kind: A node kind, such as `OSC`. + group: 0 to 31, or `ALWAYS`. `run` skips a node whose group bit is + not set in its ``enable`` mask, and a node skipped after it ran + zeroes its output once. + inputs: Buffer ids the node reads, up to eight. None is an open + input. + output: The buffer id the node writes. Every kind but `OUT` needs + one. + arg: A setting fixed at construction. What it means depends on the + kind: the line's length for `FBDELAY`, the stages for + `PHASER`, the right side's buffer for `PAN`, `TREMOLO`, + `SPRING` and `ROTARY`. + + Returns: + The node's index. + + Raises: + ValueError: The kind is unknown, the group is out of range, there + are too many inputs, a buffer id is out of range, the node has + no output, or the graph is full. + RuntimeError: The pump is rendering this graph. + """ + def set(self, node: int, index: int, value: float, /) -> None: + """Store a parameter. The change is queued and lands at the top of the + next block. A parameter that has not changed costs nothing per block. + + Args: + node: The node's index. + index: The parameter, such as `OSC_WAVEFORM`, or a number for a + kind that numbers its parameters from 0. + value: The new value. + + Raises: + ValueError: The node or parameter does not exist. + """ + def get(self, node: int, index: int, /) -> float: + """The value `set` last stored, whether or not a block has heard it. + + Raises: + ValueError: The node or parameter does not exist. + """ + def table(self, node: int, table: object, /) -> None: + """Give a node a table. + + What the table is depends on the node. For `FBDELAY` it is the wow + shape, an ``array('h')`` the graph keeps a reference to and does not + copy. For `STRUCK` it is up to `STRUCK_MODES` ``(ratio, decay, gain)`` + triples, or a flat float array of three floats a mode. For `DECAY` it + is up to `DECAY_STRIKES` ``(delay in seconds, level)`` pairs, or a flat + float array of two floats a strike. For `WAVETABLE` it is one cycle, + an ``array('h')`` of 2 samples up to the node's ``arg``. A copied table + may change while the pump plays; a borrowed one may not. + + For `STRUCK`, set `STRUCK_FREQUENCY` first and then call this: it + retunes the bank at the frequency you last set, so the block that + plays the note only copies the result. + + Raises: + TypeError: The table is not of the type the node takes. + ValueError: The node takes no table, or the table is too long. + RuntimeError: The pump is rendering this graph (not for tables the + node copies). + """ + def reset(self, node: int, /) -> None: + """Clear a node's running state. Queued, like `set`. + + Raises: + ValueError: The node does not exist. + """ + def carry(self, node: int, src_graph: Graph, src_node: int, /) -> None: + """Take over a node's running state from a node of another graph, or + of this one: its voice, envelope, oscillator phase, filter memory and + delay line continue where ``src_node`` left them. Build the changed + patch beside the running one, carry the nodes both have, then + `swap` it in. Do it between two blocks. + + Args: + node: The node of this graph that receives the state. + src_graph: The graph the state comes from. + src_node: The node in ``src_graph``. + + Raises: + TypeError: ``src_graph`` is not a `Graph`. + ValueError: The nodes are of different kinds, were built with + different ``arg`` values, hold state of different sizes, or + belong to graphs at different sample rates. A node it refuses + keeps the fresh state it was built with. + RuntimeError: The pump is rendering this graph; park it first. + """ + def swap(self, other: Graph, /) -> None: + """Trade patches with ``other`` on a block boundary: nodes, buffers, + wiring and queue. This graph keeps its identity and its frame count, + so what holds it (the pump, an output, a mixer) need not know, and + renders ``other``'s patch from the next block on. ``other`` is left + holding the old patch, ready to drop. Node ids are then the ones you + got from building ``other``. + + Raises: + TypeError: ``other`` is not a `Graph`. + ValueError: The graphs differ in sample rate, block size or + channel count. + """ + def enable(self, mask: int, /) -> None: + """Set the groups a pull renders, as a bit mask. Queued like `set`, so + a note's gate and its group's enable land in the same block.""" + def hold(self) -> None: + """Stop releasing queued changes until `commit`, so a batch (a patch + change, a chord) lands on one block together.""" + def commit(self) -> None: + """Release what was queued since `hold`.""" + def sounding(self) -> tuple[int, int, int, int]: + """What the renderer last published. + + Returns: + ``(sounding, frame, applied, pushed)``: the mask of groups still + sounding after the last block, the frames rendered so far, and the + number of changes applied and queued. A change counted in + ``pushed`` and not yet in ``applied`` has not been heard. Read + ``sounding`` and ``frame`` together and you know which block the + mask belongs to. + """ + def stats(self) -> dict[str, int]: + """The queue's counts. + + Returns: + A dict with ``capacity``, ``pushed``, ``applied``, ``overflows`` + (times the queue filled), ``splits`` (held batches that landed + across two blocks), ``high_water`` (the most changes waiting at + once) and ``frame``. Size the queue for your biggest batch and + ``overflows`` and ``splits`` stay 0. + """ + def run( + self, + out: bytearray | memoryview | None = None, + block: int = 0, + enable: int = 0xFFFFFFFF, + /, + ) -> int: + """Render one block, draining the queue first. + + Runs every node whose group bit is set in ``enable`` and every + `ALWAYS` node. + + Args: + out: Where the `OUT` node writes: interleaved signed 16-bit + samples, ``frames * channel_count`` of them a block. None + renders without keeping the output. + block: Which block of ``out`` to fill, counting from 0, so one + buffer holds several blocks. + enable: The groups to run, a bit mask. The default runs all. + + Returns: + The mask of groups whose envelopes are still sounding. When a + voice's amplitude envelope goes idle, the mask tells you to park + its group. + + Raises: + ValueError: ``out`` is too small for that block. + RuntimeError: The pump is rendering this graph. + """ + def peak(self, buffer: int, /) -> int: + """The peak of a buffer's block, as a count. + + Raises: + ValueError: The buffer id does not exist. + """ + def read(self, buffer: int, dst: bytearray | memoryview, /) -> None: + """Copy a buffer's block out into ``dst``, which must hold exactly one + block of ``frames`` signed 16-bit samples. + + Raises: + ValueError: The buffer id does not exist, or ``dst`` is the wrong + size. + """ + def write(self, buffer: int, src: bytes | bytearray | memoryview, /) -> None: + """Fill a buffer's block from ``src``, exactly one block of ``frames`` + signed 16-bit samples, so Python can feed a buffer from something the + graph has no node for. + + Raises: + ValueError: The buffer id does not exist, or ``src`` is the wrong + size. + RuntimeError: The pump is rendering this graph. + """ + def deinit(self) -> None: + """Free the graph. A graph released while the pump is pulling it stops + cleanly.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audioladder/audioladder.pyi b/src/audioladder/audioladder.pyi new file mode 100644 index 00000000..0329b730 --- /dev/null +++ b/src/audioladder/audioladder.pyi @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A transistor ladder filter. + +`Ladder` is a low-pass filter that behaves like the circuit. `audiofilters.Filter` +is a better *resonant low-pass* than this one: 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 the +same however hard you hit it. A ladder does those three things, and that is +what this module is for. + +:: + + 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 to 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 to 4: 6, 12, 18 or 24 dB an octave + passband_comp=0.0, # 0 to 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 + ) + 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 +""" + +from typing import Protocol, Self + +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class Ladder: + """A four-pole transistor ladder low-pass filter, played like any audio + sample. + + It hands out 256 frames at a time, or fewer with ``buffer_size``, and + renders 16-bit audio. Every option is a keyword. ``sample_rate``, + ``channel_count`` and ``buffer_size`` can only be given here; the rest can + be given here or to `set`. + + Args: + sample_rate: Frames per second, 1 or more. The default is 48000. + channel_count: 1 or 2. The default is 2. + buffer_size: Bytes in each block, a whole number of frames from one + frame to 256. None, the default, is 256 frames. + cutoff_hz: Where the filter turns over, in Hz. The default is 1000. + It is held between 1 Hz and just under half the sample rate. + resonance: Feedback around the loop, 0.0 to 4.2, clamped. The default + is 0. At 4 the loop sustains a tone at the cutoff; a little past + it is where a ladder is usually played. + drive: A linear gain into the loop, 0.0 to 64.0, clamped, where 1 is + unity. The default is 1. + poles: Which stage the output is tapped from, 1 to 4, which is 6, + 12, 18 or 24 dB an octave above the cutoff. The default is 4. + The feedback comes from the fourth stage at every tap. + passband_comp: How much of the passband the feedback takes away to + give back, 0.0 to 1.0, clamped. At a resonance of 4 the whole + band below the cutoff is 14 dB down; 1 gives all of it back. + The default is 0, which leaves the filter as the circuit behaves. + oversample: 1 or 2. At 2, the default, the filter runs at twice the + rate, so fewer of the harmonics it makes fold back above Nyquist. + mix: A crossfade, 0.0 to 1.0, clamped. 0 is a wire and 1 is the + filter. This is not the 0 to 2 scale of `audiodelays.Echo`. The + default is 1. + cutoff_octaves: How many octaves a full-scale `modulate` input moves + the cutoff, of either sign. The default is 10. + cutoff: A stream to read the cutoff from, as for `modulate`. + + Raises: + ValueError: ``sample_rate`` is less than 1, ``channel_count`` is not + 1 or 2, or ``buffer_size`` is not whole frames up to 256. + TypeError: An unknown option was given. + """ + + def __init__( + self, + *, + sample_rate: int = 48000, + channel_count: int = 2, + buffer_size: int | None = None, + cutoff_hz: float = 1000.0, + resonance: float = 0.0, + drive: float = 1.0, + poles: int = 4, + passband_comp: float = 0.0, + oversample: int = 2, + mix: float = 1.0, + cutoff_octaves: float = 10.0, + cutoff: _AudioSample | None = None, + ) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + def play(self, source: _AudioSample, /) -> None: + """Set the stream to filter, replacing the last one.""" + def set( + self, + *, + cutoff_hz: float = ..., + resonance: float = ..., + drive: float = ..., + poles: int = ..., + passband_comp: float = ..., + oversample: int = ..., + mix: float = ..., + cutoff_octaves: float = ..., + ) -> None: + """Change options while the filter plays. + + The cutoff moves once a block, which is plenty for an envelope or a + knob. Swept by an audio-rate signal that way, it is heard as a + staircase, a buzz at the block rate; use `modulate` for that. All the + options given change together or not at all. + + Args: + cutoff_hz: As for the constructor. + resonance: As for the constructor. + drive: As for the constructor. + poles: As for the constructor. + passband_comp: As for the constructor. + oversample: As for the constructor. + mix: As for the constructor. + cutoff_octaves: As for the constructor. + + Raises: + TypeError: An unknown option was given. + """ + def modulate(self, *, cutoff: _AudioSample | None) -> None: + """Read the cutoff from a stream, every sample. + + For an input at ``v`` of full scale the cutoff is + ``cutoff_hz * 2 ** (v * cutoff_octaves)``, so filter FM sounds like + filter FM. Only the first channel of a stereo input is read. An input + of zero, or none at all, leaves the filter exactly as ``cutoff_hz`` + sets it. + + Args: + cutoff: The stream, mono or stereo, or None to disconnect it. + + Raises: + TypeError: An input other than ``cutoff`` was named. + ValueError: The stream is not mono or stereo. + """ + def clear(self) -> None: + """Empty the integrators. This stops a tone the loop is sustaining.""" + def deinit(self) -> None: + """Release the filter and let go of its source. Every later use + raises `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audiomixer/audiomixer.pyi b/src/audiomixer/audiomixer.pyi new file mode 100644 index 00000000..5061765b --- /dev/null +++ b/src/audiomixer/audiomixer.pyi @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Mix several audio sources into one stream. + +`Mixer` is an audio source with a fixed number of voices. Each voice plays +one source at that source's own rate, with its own level and panning, and the +mixer adds the voices up into one stream of the format you gave it. Hand the +mixer to an output, then `Mixer.play` sources into its voices. It follows +CircuitPython's `audiomixer`, so code written for a board runs unchanged, +and adds `Mixer.level_ramp`. + +Nothing in the module makes a voice on its own: you reach one through +`Mixer.voice`. +""" + +from typing import Protocol, Self + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class _MixerVoice: + """One voice of a `Mixer`. + + Get voices from `Mixer.voice`; the module has no name for the type and + you cannot make one on its own. + """ + + @property + def playing(self) -> bool: + """Whether the voice is playing a source. A source that has ended, or + a voice that was stopped, is not playing.""" + level: float | object + """How loud the voice is: 0.0 is silent, 1.0 is as recorded. A number, or + a `synthio.LFO` or `synthio.Math` that moves it. A new voice starts at 1.0. + + With `Mixer.level_ramp` at 0 a change lands at the voice's next zero + crossing, so it cannot step a waveform mid-cycle. With a ramp set it walks + to the new level over that many frames.""" + panning: float | object + """Where the voice sits between the channels of a stereo mixer: -1.0 is + all left, 1.0 is all right, 0.0 is the centre and the default. A number, + or a `synthio.LFO` or `synthio.Math`. A mono mixer ignores it. A change + follows the rules of `level`.""" + loop: bool + """Whether the voice starts its source again when it ends.""" + def play(self, sample: _AudioSample, /, *, loop: bool = False) -> None: + """Play a source on this voice, from its start, replacing what the + voice was playing. + + Args: + sample: An audio source. Its channel count, bit depth and + signedness must match the mixer's. + loop: Start the source again whenever it ends. + + Raises: + ValueError: The source's format does not match the mixer's. + """ + def stop(self, *, voice: int = 0) -> None: + """Stop the voice at once. ``voice`` is accepted and ignored, as in + CircuitPython.""" + def end(self) -> None: + """Let the source play to its end and then stop, by turning looping + off.""" + +class Mixer: + """An audio source that mixes several others. + + Every argument is a keyword. The format you give is the format of the + stream the mixer produces, and every source played into it must have the + same channel count, bit depth and signedness; it may have any sample rate, + and plays at its own. + + Args: + voice_count: How many voices, 1 to 255. The default is 2. + buffer_size: Bytes in each of the mixer's two block buffers. The + default is 1024. + channel_count: 1 or 2. + bits_per_sample: 8 or 16. + samples_signed: Whether samples are signed. The default is True. + sample_rate: The mixer's rate in Hz, 1 or more. The default is 8000. + + Raises: + ValueError: An argument is out of range. + """ + + def __init__( + self, + *, + voice_count: int = 2, + buffer_size: int = 1024, + channel_count: int = 2, + bits_per_sample: int = 16, + samples_signed: bool = True, + sample_rate: int = 8000, + ) -> None: ... + @property + def playing(self) -> bool: + """Whether any voice is playing.""" + @property + def voice(self) -> tuple[_MixerVoice, ...]: + """The voices, as a tuple of ``voice_count`` objects, so + ``mixer.voice[0].level = 0.5`` sets the first one's level.""" + level_ramp: int + """Frames a voice's level or pan move takes, 0 to 16777216 (2 ** 24). At + 0, the default, a move behaves as it does in CircuitPython. Above 0, a + move walks from the old value to the new one in a straight line over that + many frames, so turning a level in one call does not click. Set it to the + number of frames in 20 ms (960 at 48 kHz) for the usual crossfade. + + A voice's first level lands at once; the walk starts with the next move. + A move made during a walk starts a new walk from where the level stands. + A level driven by a `synthio.LFO` starts a walk each time it is read, so + it follows the LFO a walk behind.""" + sample_rate: int + """The mixer's rate in Hz. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: 8 or 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + def play(self, sample: _AudioSample, /, *, voice: int = 0, loop: bool = False) -> Self: + """Play a source on one voice, from its start. + + Args: + sample: An audio source in the mixer's format. + voice: The voice, 0 up to ``voice_count - 1``. + loop: Start the source again whenever it ends. + + Returns: + The mixer. + + Raises: + ValueError: ``voice`` is out of range, or the source's format + does not match the mixer's. + """ + def stop_voice(self, voice: int = 0) -> None: + """Stop one voice at once. + + Raises: + ValueError: ``voice`` is out of range. + """ + def deinit(self) -> None: + """Free the mixer. Later calls other than `deinit` raise + `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audioshaper/audioshaper.pyi b/src/audioshaper/audioshaper.pyi new file mode 100644 index 00000000..c494ed37 --- /dev/null +++ b/src/audioshaper/audioshaper.pyi @@ -0,0 +1,228 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A waveshaper whose curve is data, and an exact-ratio sample-and-hold. + +`Waveshaper` pushes audio through a transfer curve you supply, between a pair +of oversampling stages, because a nonlinearity at the base rate folds the +harmonics it makes above Nyquist straight back onto the signal. `SampleHold` +is the other half of a lo-fi effect: the waveshaper quantises the value, the +hold quantises the time. +""" + +from typing import Protocol, Self + +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class Waveshaper: + """A waveshaper with oversampling, played like any audio sample. + + The shaping happens at ``oversample`` times the sample rate, between a + matched pair of polyphase all-pass half-bands. It hands out 256 frames at + a time and renders 16-bit audio. Every option is a keyword. ``curve``, + ``sample_rate``, ``channel_count`` and ``oversample`` are fixed when the + node is made, except that `set` can replace the curve. The rest can be + given here or to `set`. + + ``curve`` is signed 16-bit samples (Q15) of at least two points, + spanning -1 to +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. The node copies it. + + A curve that reaches the rails has a ceiling of its own, because the + decimator can ring past the rails on a hard edge. Keep + ``post_gain * max(abs(curve))`` at or below about 0.74 and put the rest + of the level you want on a mixer voice after this node. + + The ``mix`` and ``post_gain`` of a playing node walk to a new value in a + straight line over 20 ms instead of stepping, so turning one in a single + call does not click. A node that has not played yet, or has just been + cleared, takes them at once. + + Args: + curve: The transfer curve: a buffer of signed 16-bit values, at + least two points. Required. + sample_rate: Frames per second. The default is 48000. + channel_count: 1 or 2. The default is 2. + oversample: 1, 2, 4 or 8. The default is 4. The oversampling adds a + group delay of about 0, 2.2, 3.3 and 3.9 base-rate samples for + those factors, for a component that has to report its latency. + pre_gain: The drive knob: a gain into the normalised curve, -1000 to + 1000, clamped. The default is 1. + bias: Moves the operating point, -1.0 to 1.0, clamped. The default + is 0. + post_gain: A gain after the curve, -1000 to 1000, clamped. The + default is 1. + mix: A crossfade, 0.0 to 1.0, clamped. 0 is the dry signal and 1 the + shaped one. The default is 1. + hysteresis: How much memory the curve has, 0.0 to 1.0, clamped. + At 0, the default, there is none. Above 0 a slow signal in and + out traces two paths and encloses an area. + hysteresis_width: The half-width of that loop as a fraction of full + scale at the input, 0.0 to 0.5, clamped. The default is 0.02. + hysteresis_bias: How the width is split between the rising and + falling branches, -1.0 to 1.0, clamped. The default is 0. + + Raises: + ValueError: ``curve`` is missing, shorter than two points or not a + whole number of 16-bit values; ``channel_count`` is not 1 or 2; + or ``oversample`` is not 1, 2, 4 or 8. + TypeError: An unknown option was given. + MemoryError: The curve does not fit. + """ + + def __init__( + self, + *, + curve: bytes | bytearray | memoryview, + sample_rate: int = 48000, + channel_count: int = 2, + oversample: int = 4, + pre_gain: float = 1.0, + bias: float = 0.0, + post_gain: float = 1.0, + mix: float = 1.0, + hysteresis: float = 0.0, + hysteresis_width: float = 0.02, + hysteresis_bias: float = 0.0, + ) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + def play(self, source: _AudioSample, /) -> None: + """Set the stream to shape, replacing the last one.""" + def set( + self, + *, + curve: bytes | bytearray | memoryview = ..., + pre_gain: float = ..., + bias: float = ..., + post_gain: float = ..., + mix: float = ..., + hysteresis: float = ..., + hysteresis_width: float = ..., + hysteresis_bias: float = ..., + ) -> None: + """Change options while the node plays. All the options given change + together or not at all. ``sample_rate``, ``channel_count`` and + ``oversample`` are accepted and ignored, because they are fixed when + the node is made. + + Args: + curve: A new transfer curve, as for the constructor. + pre_gain: As for the constructor. + bias: As for the constructor. + post_gain: As for the constructor. + mix: As for the constructor. + hysteresis: As for the constructor. + hysteresis_width: As for the constructor. + hysteresis_bias: As for the constructor. + + Raises: + ValueError: ``curve`` is shorter than two points or not a whole + number of 16-bit values. + TypeError: An unknown option was given. + """ + def clear(self) -> None: + """Empty the oversampling stages and put the hysteresis memory back + to the start.""" + def recentre(self) -> bool: + """Settle the node on its operating point after silence. + + Once every channel's input has been exactly zero for a whole block, + this puts the hysteresis memory where a silent input would hold it, + and moves the decimator's memories to match, so the output steps to + the curve's answer at the operating point at once instead of ringing + down from the old position. Under signal it does nothing. + + Returns: + True if it moved anything, False under signal. + """ + def deinit(self) -> None: + """Release the node and let go of its source. Every later use + raises `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class SampleHold: + """A zero-order hold in which ``num`` source frames carry ``den`` new + values. + + So 26040 Hz at 48 kHz is 400 over 217, and it 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. When + ``num`` equals ``den`` the node is a wire, byte for byte. Its `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 for the caller to + report. + + It reads mono or stereo audio of 8 or 16 bits. + + Args: + source: The audio to hold. + num: Source frames in the ratio, 1 or more. + den: New values in the ratio, 1 or more and no more than ``num``, + because a hold cannot invent frames. ``num`` can be up to 2**30. + + Raises: + ValueError: The ratio is not whole numbers with ``den <= num``, or + the source is not 1 or 2 channels of 8 or 16 bits. + TypeError: ``source`` is not an audio source. + """ + + def __init__(self, source: _AudioSample, num: int = 1, den: int = 1) -> None: ... + sample_rate: int + """Frames per second, the source's to begin with. Writable, to 1 or + more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits, the source's.""" + @property + def channel_count(self) -> int: + """Number of channels, the source's.""" + @property + def num(self) -> int: + """Source frames in the ratio.""" + @property + def den(self) -> int: + """New values in the ratio.""" + @property + def latency(self) -> int: + """Frames of delay the node adds: always 0.""" + def play(self, source: _AudioSample, /) -> None: + """Hold a new source, and start the hold over. + + Raises: + ValueError: The source's bit depth or channel count is not the + one this node was made with. + """ + def set(self, num: int, den: int) -> None: + """Move the ratio while the node plays, as for the constructor. + + Raises: + ValueError: The ratio is not whole numbers with ``den <= num``. + """ + def clear(self) -> None: + """Start the hold over.""" + def deinit(self) -> None: + """Release the node and let go of its source. Every later use + raises `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audiospeed/audiospeed.pyi b/src/audiospeed/audiospeed.pyi new file mode 100644 index 00000000..19cc3b19 --- /dev/null +++ b/src/audiospeed/audiospeed.pyi @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""CircuitPython's speed and sample-rate changers. + +`SpeedChanger` plays a source faster or slower, which also moves its pitch. +`Resampler` plays a source at the rate its destination asks for. Both read +every frame by position, with no interpolation. The module is CircuitPython's +own and takes the same arguments on every port. +""" + +from typing import Protocol, Self + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +class SpeedChanger: + """Plays a source at a changed speed. + + The output has the source's rate, channel count and bit depth. At a + ``rate`` of 2.0 the source plays twice as fast, an octave higher; at 0.5 + it plays half as fast, an octave lower. The node reports done when its + source does. + + Args: + source: The audio source to read. + rate: The speed relative to the source, from 0 to 1000. The default, + None, is 1.0. + + Raises: + TypeError: ``source`` is not an audio source. + ValueError: ``rate`` is outside 0 to 1000. + """ + + def __init__(self, source: _AudioSample, rate: float | None = None) -> None: ... + rate: float + """The speed relative to the source, from 0 to 1000. It is held in + 16.16 fixed point, rounded to the nearest step, so what you read back can + differ from what you wrote in the sixth decimal place.""" + sample_rate: int + """Frames per second, the source's. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits, the source's.""" + @property + def channel_count(self) -> int: + """Number of channels, the source's.""" + def deinit(self) -> None: + """Release the node and let go of its source. Every later use + raises `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... + +class Resampler: + """Plays a source at the sample rate the destination asks for. + + Set `sample_rate` to the destination's rate and the node reads the source + at the ratio of the source's rate to that. Playing 16 kHz material into + an 8 kHz destination consumes two source frames for each frame out. Until + a rate is set the ratio is 1.0. + + Args: + source: The audio source to read. + + Raises: + TypeError: ``source`` is not an audio source. + """ + + def __init__(self, source: _AudioSample) -> None: ... + @property + def rate(self) -> float: + """The source frames consumed for each frame out. It is read-only: it + is whatever the destination's `sample_rate` asked for.""" + sample_rate: int + """The rate the destination wants, in Hz. Set it to change `rate`.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits, the source's.""" + @property + def channel_count(self) -> int: + """Number of channels, the source's.""" + def deinit(self) -> None: + """Release the node and let go of its source. Every later use + raises `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/src/audioverb/audioverb.pyi b/src/audioverb/audioverb.pyi new file mode 100644 index 00000000..af5f83b7 --- /dev/null +++ b/src/audioverb/audioverb.pyi @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A plate reverberator whose network you hand in. + +`Tank` is Dattorro's plate reverberator, with the network passed in rather +than compiled in. That is the difference from `audiofreeverb.Freeverb`, whose +comb and all-pass lengths are constants. + +:: + + 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) +""" + +from typing import Protocol, Self, Sequence + +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + +class _AudioSample(Protocol): + @property + def sample_rate(self) -> int: ... + @property + def bits_per_sample(self) -> int: ... + @property + def channel_count(self) -> int: ... + +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. + Every filter is out of the path at zero, so a ``Tank()`` given nothing + else is that network with nothing added to it. + + There is no latency from dry to wet. The tail reaches exact zero rather + than sitting at one least significant bit, because every line write is a + magnitude truncation, which is the standard cure for the limit cycles of + a recirculating fixed-point network. 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. + + ``delays`` is twelve line lengths in frames: the four input diffusers, + then each tank half's modulated all-pass, delay, all-pass and delay. Each + is at least 4. ``taps`` is four values per tap: the output channel (0 or + 1), the line (0 to 11), the offset in frames within that line and the + gain, with at most 32 taps. Both default to Dattorro's published table, + scaled from 29761 Hz to ``sample_rate``. + + Every argument is a keyword. + + Args: + sample_rate: Frames per second, 1 or more. The default is 48000. + channel_count: 1 or 2. The default is 2. + max_predelay_ms: The longest pre-delay `predelay_ms` can ask for, in + milliseconds, 0 or more. It sets how much memory the pre-delay + line takes. The default is 200. + delays: The twelve line lengths, in frames. + taps: The output taps, a flat sequence of four numbers a tap. + decay: How much of the tail comes round again, 0.0 to 0.999, + clamped. The default is 0.5. + diffusion: The all-pass coefficient of the diffusers, 0.0 to 0.9, + clamped. The default is 0.75. + damping_hz: A low-pass in the tank's loop, in Hz. 0, the default, + is out of the path. + bandwidth_hz: A low-pass on the input, in Hz. 0, the default, is out + of the path. + low_cut_hz: A high-pass on the input, in Hz. 0, the default, is out + of the path. + predelay_ms: Delay before the reverb, in milliseconds, held between + 0 and ``max_predelay_ms``. The default is 0. + mod_depth_ms: How far the modulated all-passes move, in + milliseconds, 0 to 100, clamped. The default is 0. + mod_rate_hz: How fast they move, 0 to 100 Hz, clamped. The default + is 0. + drive: A saturation on the input, 0.0 to 1.0, clamped. The default + is 0. + width: The stereo width of the wet signal, 0.0 to 2.0, clamped. The + default is 1. + tone_db: A tilt of the wet signal, bright to dark, -24 to 24 dB, + clamped: half the change up at the top and the same down at the + bottom, pivoting at 1 kHz. A balance control, not a trim. The + default is 0. + mix: How much of the reverb is added, 0.0 to 2.0, clamped. It + follows `audiodelays.Echo`: the dry signal stays at unity until + 1, and from there it fades out. The default is 0.3. + + Raises: + ValueError: ``sample_rate`` is less than 1, ``channel_count`` is not + 1 or 2, ``max_predelay_ms`` is negative, ``delays`` is not + twelve lengths of at least 4 frames that fit, or ``taps`` is not + whole taps naming a channel, a line and an offset that exist. + TypeError: An unknown option was given. + MemoryError: The lines do not fit. + """ + + def __init__( + self, + *, + sample_rate: int = 48000, + channel_count: int = 2, + max_predelay_ms: float = 200.0, + delays: Sequence[int] | None = None, + taps: Sequence[float] | None = None, + decay: float = 0.5, + diffusion: float = 0.75, + damping_hz: float = 0.0, + bandwidth_hz: float = 0.0, + low_cut_hz: float = 0.0, + predelay_ms: float = 0.0, + mod_depth_ms: float = 0.0, + mod_rate_hz: float = 0.0, + drive: float = 0.0, + width: float = 1.0, + tone_db: float = 0.0, + mix: float = 0.3, + ) -> None: ... + sample_rate: int + """Frames per second. Writable, to 1 or more.""" + @property + def bits_per_sample(self) -> int: + """Size of one sample in bits: always 16.""" + @property + def channel_count(self) -> int: + """Number of channels: 1 or 2.""" + def play(self, source: _AudioSample, /) -> None: + """Set the stream to reverberate, replacing the last one.""" + def set( + self, + *, + delays: Sequence[int] = ..., + taps: Sequence[float] = ..., + decay: float = ..., + diffusion: float = ..., + damping_hz: float = ..., + bandwidth_hz: float = ..., + low_cut_hz: float = ..., + predelay_ms: float = ..., + mod_depth_ms: float = ..., + mod_rate_hz: float = ..., + drive: float = ..., + width: float = ..., + tone_db: float = ..., + mix: float = ..., + ) -> None: + """Change options while the tank plays, without emptying the lines. + + Giving ``delays`` or ``taps`` cuts a new network, which does empty + every line and filter. All the options given change together or not + at all. + + Args: + delays: As for the constructor. + taps: As for the constructor. + decay: As for the constructor. + diffusion: As for the constructor. + damping_hz: As for the constructor. + bandwidth_hz: As for the constructor. + low_cut_hz: As for the constructor. + predelay_ms: As for the constructor. + mod_depth_ms: As for the constructor. + mod_rate_hz: As for the constructor. + drive: As for the constructor. + width: As for the constructor. + tone_db: As for the constructor. + mix: As for the constructor. + + Raises: + TypeError: An unknown option was given, or one of + ``sample_rate``, ``channel_count`` or ``max_predelay_ms``, + which are fixed when the node is made. + ValueError: ``delays`` or ``taps`` are not valid, as for the + constructor. + """ + def clear(self) -> None: + """Empty every line and every filter.""" + def deinit(self) -> None: + """Release the node and let go of its source. Every later use + raises `ValueError`.""" + def __enter__(self) -> Self: ... + def __exit__(self, *args: object) -> None: ... diff --git a/tests/test_stubs_match_modules.py b/tests/test_stubs_match_modules.py index ba136c0b..f6e342fa 100644 --- a/tests/test_stubs_match_modules.py +++ b/tests/test_stubs_match_modules.py @@ -37,11 +37,14 @@ ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "src" -# The modules that have a stub. +# The modules that have a stub: all of them. MODULES = ( "audiocore", "audiofeatures", "audiomath", "audiometer", "audiomp3", "audiovorbis", "audiooscillator", "audiopump", "audioroute", "audiosampler", "audiomodal", "audioconvolve", "synthio", + "audiobiquad", "audiodelays", "audiodynamics", "audioecho", + "audiofilters", "audiofreeverb", "audiograph", "audioladder", + "audiomixer", "audioshaper", "audiospeed", "audioverb", ) KEPT_DUNDERS = ("__enter__", "__exit__") From dab3efb6048027ef5bc12c832534f3570b26153a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:55:46 -0600 Subject: [PATCH 2/3] docs: generate the module reference from the type stubs --- .github/workflows/docs.yml | 39 +++ .gitignore | 3 + .readthedocs.yaml | 14 ++ docs/changelog.md | 2 +- docs/conf.py | 55 +++++ docs/guides/decode-vorbis.md | 2 +- docs/how-it-works/correctness-standard.md | 4 +- docs/how-it-works/index.md | 16 +- docs/how-it-works/upstream-diff.md | 10 +- docs/how-it-works/upstream-sync.md | 6 +- docs/how-it-works/wheels-and-platforms.md | 4 +- docs/index.md | 65 ++++- docs/porting-plan.md | 14 +- docs/reference/analysis-and-utilities.md | 23 ++ docs/reference/audiopump.md | 6 + docs/reference/filters-and-effects.md | 222 +++++------------- docs/reference/mixing-and-routing.md | 19 ++ docs/reference/modules.md | 4 +- docs/reference/sources-and-synthesis.md | 96 +++----- docs/reference/vorbis.md | 8 +- docs/requirements.txt | 4 + docs/upstream-reports/README.md | 2 +- docs/upstream-reports/biquad-band-edges.md | 14 +- docs/upstream-reports/biquad-reset.md | 8 +- .../dds-oscillator-off-by-one.md | 6 +- .../distortion-overdrive-drive.md | 4 +- .../distortion-soft-clip-union.md | 6 +- docs/upstream-reports/peaking-eq-sign.md | 8 +- .../speedchanger-phase-carry.md | 8 +- .../speedchanger-rate-rounding.md | 8 +- src/audiograph/audiograph.pyi | 40 ++-- src/audioroute/audioroute.pyi | 7 +- 32 files changed, 400 insertions(+), 327 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .readthedocs.yaml create mode 100644 docs/conf.py create mode 100644 docs/reference/analysis-and-utilities.md create mode 100644 docs/reference/mixing-and-routing.md create mode 100644 docs/requirements.txt diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..176b2752 --- /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 8feb6033..c6ee4800 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 00000000..6a17e5fc --- /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 a40bfa04..881f42f5 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 00000000..aca07942 --- /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 d45e0820..68c05c52 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 f148cc7a..6b8cfb11 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 81890c5a..757a9920 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 fdd59621..e3e3a4b3 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/`/ @@ -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 4d51f74d..97889c44 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 63a38679..5e9989d7 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 42fa4e88..118c1f16 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 b7980efb..bbf21596 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 00000000..0c106534 --- /dev/null +++ b/docs/reference/analysis-and-utilities.md @@ -0,0 +1,23 @@ +# 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` + +```{eval-rst} +.. automodule:: audiometer +``` + +## `audiofeatures` + +```{eval-rst} +.. automodule:: audiofeatures +``` + +## `audiomath` + +```{eval-rst} +.. automodule:: audiomath +``` diff --git a/docs/reference/audiopump.md b/docs/reference/audiopump.md index df832efb..c281d32f 100644 --- a/docs/reference/audiopump.md +++ b/docs/reference/audiopump.md @@ -108,3 +108,9 @@ 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 + +```{eval-rst} +.. automodule:: audiopump +``` diff --git a/docs/reference/filters-and-effects.md b/docs/reference/filters-and-effects.md index dad27318..eff7f7cf 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,72 @@ 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. +```{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). +```{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` +```{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". +```{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 +```{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) +```{eval-rst} +.. automodule:: audioverb +``` + +## `audioroute.MidSide` + +```{eval-rst} +.. autoclass:: audioroute.MidSide +``` + +## `audiofilters` + +```{eval-rst} +.. automodule:: audiofilters +``` + +## `audiodelays` + +```{eval-rst} +.. automodule:: audiodelays +``` + +## `audiofreeverb` + +```{eval-rst} +.. automodule:: audiofreeverb +``` + +## `audiospeed` + +```{eval-rst} +.. automodule:: audiospeed ``` -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. +## `audioconvolve` + +```{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 00000000..ca75f733 --- /dev/null +++ b/docs/reference/mixing-and-routing.md @@ -0,0 +1,19 @@ +# 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` + +```{eval-rst} +.. automodule:: audiomixer +``` + +## `audioroute` + +```{eval-rst} +.. automodule:: audioroute + :exclude-members: MidSide +``` diff --git a/docs/reference/modules.md b/docs/reference/modules.md index ce27f7d4..1129e7c7 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 b0166a4b..8182c149 100644 --- a/docs/reference/sources-and-synthesis.md +++ b/docs/reference/sources-and-synthesis.md @@ -1,89 +1,47 @@ # 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` -`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: +```{eval-rst} +.. automodule:: audiocore +``` -```python -import audiooscillator as ao +## `audiomp3` -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:: audiomp3 ``` -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. +## `synthio` -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. +```{eval-rst} +.. automodule:: synthio +``` -## `audiomodal` +## `audiooscillator` +```{eval-rst} +.. automodule:: audiooscillator +``` -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. +## `audiomodal` -```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) +```{eval-rst} +.. automodule:: audiomodal ``` -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. +## `audiosampler` -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. +```{eval-rst} +.. automodule:: audiosampler +``` -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. +```{eval-rst} +.. automodule:: audiograph +``` diff --git a/docs/reference/vorbis.md b/docs/reference/vorbis.md index 1bccfd73..a699ad73 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,9 @@ 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 + +```{eval-rst} +.. automodule:: audiovorbis +``` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..1cdafe01 --- /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 0ab52f2c..e710a0d3 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 a4661cf0..9006ce61 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 dfe52df6..496b1f6b 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 00fba528..e5930d66 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 ac88d2ae..62457310 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 76eab384..5628d0fe 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 04a9d225..574b37ef 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 4dd0df3e..32a1ed49 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 458072f2..20006bf2 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 f7ec817d..641478a2 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 37e5ea3f..54af0786 100644 --- a/src/audioroute/audioroute.pyi +++ b/src/audioroute/audioroute.pyi @@ -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`. From 11ff918a7249a12b1a4f8892ff4d9667adc76f02 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:32:21 -0600 Subject: [PATCH 3/3] docs: say where each generated section comes from; correct the reverb, shaper and mixer-voice text --- docs/how-it-works/upstream-diff.md | 2 +- docs/reference/analysis-and-utilities.md | 6 ++++++ docs/reference/audiopump.md | 2 ++ docs/reference/filters-and-effects.md | 24 ++++++++++++++++++++++++ docs/reference/mixing-and-routing.md | 12 ++++++++++++ docs/reference/sources-and-synthesis.md | 14 ++++++++++++++ docs/reference/vorbis.md | 2 ++ src/audioroute/audioroute.pyi | 2 +- src/audioverb/audioverb.pyi | 8 +++++--- 9 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/how-it-works/upstream-diff.md b/docs/how-it-works/upstream-diff.md index e3e3a4b3..3f4edeae 100644 --- a/docs/how-it-works/upstream-diff.md +++ b/docs/how-it-works/upstream-diff.md @@ -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. diff --git a/docs/reference/analysis-and-utilities.md b/docs/reference/analysis-and-utilities.md index 0c106534..6c79fcf5 100644 --- a/docs/reference/analysis-and-utilities.md +++ b/docs/reference/analysis-and-utilities.md @@ -6,18 +6,24 @@ 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 c281d32f..f183b74c 100644 --- a/docs/reference/audiopump.md +++ b/docs/reference/audiopump.md @@ -111,6 +111,8 @@ 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 eff7f7cf..06d97217 100644 --- a/docs/reference/filters-and-effects.md +++ b/docs/reference/filters-and-effects.md @@ -24,72 +24,96 @@ What the two kinds of node take, side by side: | read-only | `playing`, `coefficients` | `playing`, `stages`, `coefficient` | | latency | 0 samples | 0 samples | +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` +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` +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 ``` ## `audioshaper` +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` +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 ``` ## `audioverb` +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 ``` ## `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 index ca75f733..bcc8f52e 100644 --- a/docs/reference/mixing-and-routing.md +++ b/docs/reference/mixing-and-routing.md @@ -7,12 +7,24 @@ out, re-points a wire and scales stereo width. To change what plays without rewi ## `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/sources-and-synthesis.md b/docs/reference/sources-and-synthesis.md index 8182c149..b6b785e7 100644 --- a/docs/reference/sources-and-synthesis.md +++ b/docs/reference/sources-and-synthesis.md @@ -6,42 +6,56 @@ each module are in [Additions beyond CircuitPython](../how-it-works/additions.md ## `audiocore` +Generated from [`audiocore.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiocore/audiocore.pyi); the file reads on its own. + ```{eval-rst} .. automodule:: audiocore ``` ## `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 ``` ## `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. + ```{eval-rst} .. automodule:: audiomodal ``` ## `audiosampler` +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 ``` ## `audiograph` +Generated from [`audiograph.pyi`](https://github.com/PyDevices/audiodsp/blob/main/src/audiograph/audiograph.pyi); the file reads on its own. + ```{eval-rst} .. automodule:: audiograph ``` diff --git a/docs/reference/vorbis.md b/docs/reference/vorbis.md index a699ad73..a4142d21 100644 --- a/docs/reference/vorbis.md +++ b/docs/reference/vorbis.md @@ -152,6 +152,8 @@ C library that doesn't, so leave it the room above. ## 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/src/audioroute/audioroute.pyi b/src/audioroute/audioroute.pyi index 54af0786..65887e00 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 diff --git a/src/audioverb/audioverb.pyi b/src/audioverb/audioverb.pyi index af5f83b7..9e597c46 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.