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__")