diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 371e762..cd5411f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -14,6 +14,9 @@ on:
branches: [main]
paths-ignore: ['docs/**', 'README.md', 'ROADMAP.md', 'CHANGELOG.md', 'AGENTS.md', 'LICENSE']
pull_request:
+ # `labeled` too: adding the `lifecycle: all classes` label re-runs the
+ # pull request with the whole lifecycle matrix (see the test step).
+ types: [opened, synchronize, reopened, labeled]
paths-ignore: ['docs/**', 'README.md', 'ROADMAP.md', 'CHANGELOG.md', 'AGENTS.md', 'LICENSE']
# The full matrix on demand, for a branch that should see every Python
# before it merges rather than after.
@@ -47,8 +50,30 @@ jobs:
- os: macos-latest
python: "3.13"
runs-on: ${{ matrix.os }}
+ env:
+ # The lifecycle matrices (quick mode). All 45 classes take about 4.4
+ # CPU hours, over an hour on this runner, and the 13 audiodsp nodes
+ # under them half an hour more on one worker, so a pull request runs
+ # only what its change can move: the class or node whose own code
+ # changed, and when a shared file changed (the base class, the
+ # matrix, the pin) a fixed sample of each, nine classes in about 13
+ # minutes and four nodes in about five (SAMPLE in
+ # tests/test_lifecycle_matrix.py, NODE_SAMPLE in
+ # tests/test_node_matrix.py). A push to main runs every class and
+ # every node, and so does a manual run (workflow_dispatch) or a pull
+ # request labelled `lifecycle: all classes`.
+ LIFECYCLE_CHANGED_FROM: "${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'lifecycle: all classes') && format('origin/{0}', github.base_ref) || '' }}"
steps:
+ # A pull request fetches its history, so the matrices can ask git what
+ # the change touches against origin/ (LIFECYCLE_CHANGED_FROM).
+ # Written `!= ... && 1 || 0` because `== ... && 0 || 1` is always 1:
+ # 0 is false in an expression, so the `||` took over and every pull
+ # request checked out at depth 1. With no base to diff from, the
+ # matrix then fell back to all 45 classes, and a pull request ran
+ # 1 h 43 min. A base git cannot diff from is now an error instead.
- uses: actions/checkout@v7
+ with:
+ fetch-depth: ${{ github.event_name != 'pull_request' && 1 || 0 }}
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
@@ -109,6 +134,17 @@ jobs:
# a test. The tests would generate them too; this makes it explicit.
- run: python tools/effect_probes/make_probes.py --ensure
+ # What the two matrices will run, in the log where a reader can check
+ # it, and a scope git cannot work out fails here by name.
+ - name: Show the lifecycle scope
+ shell: bash
+ run: >-
+ python -c "from tests.test_lifecycle_matrix import classes;
+ from tests.test_node_matrix import names;
+ print('base:', '${LIFECYCLE_CHANGED_FROM:-none, all}');
+ print('classes:', ' '.join(classes()) or 'none');
+ print('nodes:', ' '.join(names()) or 'none')"
+
# The same tests `python -m unittest discover -s tests -p "test_*.py"`
# finds, spread across every core the runner has: serially the suite
# takes over an hour. A test that writes a file writes it into its own
diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN
index d337e91..852b598 100644
--- a/AUDIODSP_PIN
+++ b/AUDIODSP_PIN
@@ -345,5 +345,57 @@
# holds its line still at mix 0 (#226); audiovorbis (#238, #239); and an
# fmod-free angle reduction for the RP2040 (#225).
#
+# 2026-10-13: v0.6.6rc1 -> f7a86c9, the effects program's Phase 7 node batch
+# (step 7b): the candidate for audiodsp's next pre-release, v0.6.6rc3, and
+# the final pin of the phase. Named by commit until the tag is cut; the tag
+# names the same source. What moves beneath the components, by `git log
+# v0.6.6rc1..f7a86c9 -- src`: audiodelays.Flanger lets go of a looped source
+# that comes back empty and gives a stalled one silence, instead of spinning
+# the pull; audioecho.FeedbackDelay's upward loop_semitones writes its line
+# through a band limit at 0.7 of the frequency the next pass would carry to
+# Nyquist, so a climb fades out at the top instead of folding back; and both
+# shifters (the loop's two taps and audiodelays.PitchShift's splice) move a
+# splice's incoming side into phase with the outgoing one, so a held note
+# plays its shifted pitch, and a chord its notes, instead of cancelling.
+# Unshifted and downward FeedbackDelay renders, silence and a lone click
+# through either shifter, and a frozen PitchShift window render the bytes they
+# did.
+#
+# Moved to v0.6.6rc3 (a9d36e6) on 2026-10-13: the release of the commit
+# above (audiodsp#245, #247), the same node tree as f7a86c9 outside VERSION
+# and CHANGELOG.md, so CI can install it from GitHub.
+#
+#
+# Moved to v0.6.6rc4 (8d5c282) on 2026-10-14: MultiTapDelay's reset puts the
+# write heads back where a fresh node has them, and audioconvolve.Convolver
+# gains wet_gain (default 1.0, byte-identical there), which
+# ConvolutionReverb uses to trim its four darkest rooms (audiodsp#248).
+#
+# Moved to v0.6.6rc5 (b278d32) on 2026-10-14: audioconvolve.Convolver gains
+# room_peak_gain and room_peak_gain_for, the reading ConvolutionReverb's wet
+# trim is computed from. Every render is byte-identical to rc4's.
+#
+# Moved to v0.6.6rc6 (9399602) on 2026-10-14: audioecho.FeedbackDelay gains
+# wow_depth_ramp_ms, which Vibrato's Engage rides so its Rise ramps on every
+# interpreter, and audioshaper.Waveshaper gains recentre(), which
+# Saturation calls so a gain move in silence with Hysteresis on plays
+# nothing. Every render is byte-identical to rc5's at the defaults of both.
+#
+# Moved to v0.6.6rc8 (f83ddc6) on 2026-10-14, by way of rc7: the CPython
+# extension no longer crashes in garbage collection while a RawSample still
+# exports a buffer; audiodynamics.Dynamics writes a mono block at its own
+# stride, so short source buffers leave no stale frames; audioroute.Splitter
+# gains clear(), which six classes call to drop what the ring held from
+# before a bypass when Mix comes back up; and audioecho.FeedbackDelay takes
+# a new wow_shape by dipping the depth to zero and swapping there, which
+# Vibrato hands its clock law to. Every other render is byte-identical to
+# rc6's.
+#
+# Moved to v0.6.6rc9 (96abd34) on 2026-10-15: audiobiquad.Biquad walks a
+# coefficient move over 20 ms instead of keeping its state through the jump,
+# so LowPass, BandPass and Notch no longer thump at full scale when
+# Frequency jumps to the bottom (audiocomponents#162). Static renders are
+# byte-identical to rc8's; only renders across a coefficient move change.
+#
# -- ref is what CI installs (git+https://...@)
-v0.6.6rc1 30cbbf619751ab6ff7c40d99336907af0a081fa6
+v0.6.6rc9 96abd345344eb356f1af5de24e320bfcf778cb8d
diff --git a/CHANGELOG.md b/CHANGELOG.md
index db62a61..99ce2ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,27 @@ there, and are recorded in its changelog.
### Added
+- **`Limiter` patch 6, *Delivery Ceiling - lean*:** Delivery Ceiling for an
+ ESP32-S3, where Catch The Peaks, Delivery Ceiling and Loud (True Peak)
+ cannot keep up. It keeps the lookahead and release, turns True Peak off
+ and sits 3 dB lower (-3.97 dB), so a worst-phase tone driven into it
+ still reads -0.96 dB TP. The docstring now says which patches an S3
+ cannot play.
+- **`MultibandCompressor`'s docstring says to build it with `bands=2` on an
+ ESP32-S3**, where no three-band patch keeps up (107 % of a block; two
+ bands is 71 %). No patch can do this instead: every macro position builds
+ the same graph.
+- **`Flanger` (rebuilt, Phase 7):** a thin class on CircuitPython's own
+ `audiodelays.Flanger`, so it now runs on a stock CircuitPython board.
+ Eight macros (Rate, Range, Manual, Color, Mix, Polarity, Sync, Division)
+ and six patches; `create("Flanger")` serves it from
+ `audioeffects/flanger.py`. Both old flangers are gone:
+ `modulation.Flanger` (and `modulation.py` with it) and the parked Phase 3
+ rebuild under `rebuilt/`. The old `depth_ms=` and `feedback=` keywords
+ give way to the macros; `rate=` and `mix=` keep their names, and Mix 1 is
+ now the default. The long ring and the bucket-brigade sweep of the parked
+ rebuild do not carry: the node stops Color at 0.95 (about 0.4 s to -60 dB
+ at 3 ms) and sweeps a triangle in milliseconds.
- **`ShimmerHall` (rebuilt, Phase 6; adopted 2026-10-13):** a pitch shifter around a hall, in
two characters. Bloom sends the shifted copy into the hall once, Bloom
after you play, on `audiodelays.PitchShift`; Rise puts the shift inside
@@ -296,6 +317,13 @@ there, and are recorded in its changelog.
### Changed
+- **`_core.py` is gone, and with it the old classes the rebuilds replaced.**
+ `delay.py`, `reverb.py`, `pitch.py` and `rack.py` held only the
+ pre-contract classes, which no public name had served since the Phase 5
+ and Phase 6 adoptions; they are deleted. The public classes, `ALL` and
+ `create()` are unchanged. `configure()`, `sample_rate()` and
+ `channel_count()` stay, but only record a format: every effect takes its
+ format from its source, as it already did.
- **`ShimmerHall` and `AirSpace` keep the classes they were ported
against.** With the Phase 5 `TapeDelay` and `Reverb` and the Phase 6
`Octaver` adopted, the two old racks (`rack.ShimmerHall` and
@@ -364,6 +392,19 @@ there, and are recorded in its changelog.
### Fixed
+- `Bitcrusher`, `Compressor`, `Distortion`, `Exciter`, `Fuzz` and `Saturation`: bringing Mix back up from 0 no longer plays audio from before the bypass. With a source whose buffers aren't 256 frames (a decoder, a file), the first node of the graph kept the rest of a buffer, and on the way back played up to 20 ms of what was sounding when you bypassed, at full level, and then sat that far late. Distortion and germanium Fuzz drop it now; the four Splitter-headed classes and cascade Fuzz need the audiodsp that carries `Splitter.clear()`.
+- `Tremolo`: touching a knob, or a host re-sending the same value, no longer throws the throb back to the top of its cycle. The LFO takes the new shape and rate in place, so a slow sweep of any knob moves the pulse smoothly instead of stuttering.
+- `Fuzz`: with Bias off centre, `tail_samples` is None. The output settles near zero rather than on it there, so the 24 576 it reported was a bound the class never met. The docstring now says what stays: a few LSB of wander, or at Bias -1 a DC constant of up to 177 LSB.
+- `Bitcrusher`: the dither tail is 9 728 samples, where 8 192 fell short at maximum Dither, and `tail_samples` is None where the dither alone is louder than its gate (maximum Dither at 12 bits, and any deeper crush with Dither up), because there the dither never stops.
+- `Phaser`: with Sync on, a bar is the host's time signature, numerator x 4 / denominator quarter notes (3/4 is three, 6/8 is three), where it read the denominator alone as the beats in a bar; and `transport_changed()` follows a time-signature change as well as a tempo change. In 4/4 nothing moves.
+- `CabinetSim`: `set_macro()` with plus infinity sets the top stop, as 128 does, where it raised `OverflowError`. Every component now gives the one answer the API names: a macro value off 0-127 is clamped, NaN to the bottom stop.
+- `ConvolutionReverb` states its input ceiling, -15.5 dBFS for a synthesized room anywhere on the knobs with the dry mixed in, and -13.5 dBFS for the room alone. The room is normalised to unit energy, so a dark or short one gathers that energy low, and it now plays through a wet trim read off the room itself: the convolver reports how much the room brings a low square back (`room_peak_gain`, audiodsp), and the trim is none where that is 1 dB or more under 12 dB, then a soft knee, then just enough to hold it to 12 dB, through the convolver's `wet_gain`. One step of Decay or Damping moves the level by 0.5 dB at most, where the trims four patches used to carry jumped 3.5 to 6.5 dB one step off the patch. Full Room and Wet Only play 1.0 dB lower at 48 kHz, Late Room 0.7 dB and Bright Room 0.4 dB; Short Room, Dark Room, Soft Onset and Tight Room play 4.8, 7.5, 4.0 and 3.1 dB lower, where the old patch trims had them 3.5 to 6.5 dB down. The default room used to rail on a -12 dBFS square at 163 Hz, which the kit now plays. Before this the stated ceiling was -21 dBFS. Needs the audiodsp that carries `room_peak_gain`.
+- `Distortion`: `reset()`, then a patch across the Character boundary (6 to 9), then `reset()` again no longer raises "deinitialized" on any interpreter.
+- `set_macro()` with an integer too large for a float (`10**400`) sets the stop on its side, where it raised `OverflowError` on every class.
+- On a CircuitPython board build, which has no `audiocore.get_buffer`, `MultiTapDelay` no longer drops 256 frames of your source at a reset or a patch change, `Fuzz` and `Overdrive` charge their output pole again (no bang or thump after a patch change, a Mix move or a reset), and a class that re-arms lazily after `reset()` (Bitcrusher, Compressor, Distortion, DynamicEQ, Exciter, MultibandCompressor, Overdrive) resets as it does on a desktop, two resets in a row included. Each pull these classes make on their own graph now goes through a scratch mixer voice where `get_buffer` is missing, so a board renders what a desktop does, byte for byte.
+- `Phaser` and `Flanger` with Sync on keep the last good tempo and time signature when the host hands one they cannot use: a tempo of None, NaN, infinity or 0, a missing field, or a numerator or denominator outside 1 to 128. Such a transport used to raise, stand the sweep still, or play a NaN tempo as 1 bpm.
+- `Saturation`: `reset()` no longer plays one block of the audio from before it, and after the lean patch `latency_samples` reads the onset again (1 at tube x4, 0 on the lean patch itself) instead of the group delay.
+- `DigitalDelay` and `SlapbackDelay`: an option you leave out plays patch 0's step, so a fresh instance is patch 0 exactly and a reset sounds and reports its tail as a fresh instance does. At the defaults Time is 352.8 ms on `DigitalDelay` (it was 350) and 134.4 ms on `SlapbackDelay` (it was 135), and the other controls move by under a step; a value you pass plays as passed.
- A Time brought back after a shorter one no longer plays a stray sample from before the move in MultiTapDelay and the delays built on `audiodelays.Echo` (on CPython and MicroPython; CircuitPython's own `audiodelays` still plays it); PitchShifter, Harmonizer and Octaver start again from scratch on a reset and no longer hang on a source with nothing to give; and a host reset that reaches a Mixer inside an effect keeps the audio its voices hold. All of it is audiodsp's, at the pre-release `AUDIODSP_PIN` now names (v0.6.6rc1).
- The delays, Reverb and ConvolutionReverb now ring out when their source ends, then rest at exact zero, instead of freezing the tail until the source came back; a one-shot source plays once instead of feeding them for ever. A source that comes up short part-way through a block leaves a whole block with the rest silent, and a host `reset_buffer` on ConvolutionReverb keeps the frames it holds. All of it is audiodsp's, at the commit `AUDIODSP_PIN` now names (audiocomponents#127).
@@ -408,6 +449,9 @@ version number. The release carries repository tooling and docs only.
### Fixed
+- `Tremolo`: touching a knob, or a host re-sending the same value, no longer throws the throb back to the top of its cycle. The LFO takes the new shape and rate in place, so a slow sweep of any knob moves the pulse smoothly instead of stuttering.
+- `Fuzz`: with Bias off centre, `tail_samples` is None. The output settles near zero rather than on it there, so the 24 576 it reported was a bound the class never met. The docstring now says what stays: a few LSB of wander, or at Bias -1 a DC constant of up to 177 LSB.
+- `Bitcrusher`: the dither tail is 9 728 samples, where 8 192 fell short at maximum Dither, and `tail_samples` is None where the dither alone is louder than its gate (maximum Dither at 12 bits, and any deeper crush with Dither up), because there the dither never stops.
- **`pydevices-audioeffects` installs and imports.** The 0.3.0 wheel left out
`audioeffects.rebuilt`, so the published package failed on import; 0.3.0 of
`audioinstruments` was unaffected. The wheel now ships the subpackage, and a
@@ -939,6 +983,13 @@ version number. The release carries repository tooling and docs only.
### Changed
+- **`_core.py` is gone, and with it the old classes the rebuilds replaced.**
+ `delay.py`, `reverb.py`, `pitch.py` and `rack.py` held only the
+ pre-contract classes, which no public name had served since the Phase 5
+ and Phase 6 adoptions; they are deleted. The public classes, `ALL` and
+ `create()` are unchanged. `configure()`, `sample_rate()` and
+ `channel_count()` stay, but only record a format: every effect takes its
+ format from its source, as it already did.
- **The core is `audiodsp` now.** The repository these packages are built on
was renamed on 2026-09-21, and everything here that named it follows: the
dependency is `pydevices-audiodsp>=0.5.1` (its first published release
@@ -970,6 +1021,9 @@ version number. The release carries repository tooling and docs only.
### Fixed
+- `Tremolo`: touching a knob, or a host re-sending the same value, no longer throws the throb back to the top of its cycle. The LFO takes the new shape and rate in place, so a slow sweep of any knob moves the pulse smoothly instead of stuttering.
+- `Fuzz`: with Bias off centre, `tail_samples` is None. The output settles near zero rather than on it there, so the 24 576 it reported was a bound the class never met. The docstring now says what stays: a few LSB of wander, or at Bias -1 a DC constant of up to 177 LSB.
+- `Bitcrusher`: the dither tail is 9 728 samples, where 8 192 fell short at maximum Dither, and `tail_samples` is None where the dither alone is louder than its gate (maximum Dither at 12 bits, and any deeper crush with Dither up), because there the dither never stops.
- **A bipolar macro's centre is MIDI 64 now, and it is exact** (#87). The
0-127 grid has an even number of steps and so no middle of its own —
64/127 is 0.50394 — so until now no patch could ask for the centre of a
@@ -1115,6 +1169,13 @@ version number. The release carries repository tooling and docs only.
### Changed
+- **`_core.py` is gone, and with it the old classes the rebuilds replaced.**
+ `delay.py`, `reverb.py`, `pitch.py` and `rack.py` held only the
+ pre-contract classes, which no public name had served since the Phase 5
+ and Phase 6 adoptions; they are deleted. The public classes, `ALL` and
+ `create()` are unchanged. `configure()`, `sample_rate()` and
+ `channel_count()` stay, but only record a format: every effect takes its
+ format from its source, as it already did.
- **`RingMod` (Phase 3, M4 claim dropped).** M4 is **disconfirmed**,
not a shipped claim: the same-kind plant is inert at constructor
Frequency 220 (**0.0022 dB**) and moves **8.6 dB** only at 2 Hz.
@@ -1882,6 +1943,9 @@ version number. The release carries repository tooling and docs only.
### Fixed
+- `Tremolo`: touching a knob, or a host re-sending the same value, no longer throws the throb back to the top of its cycle. The LFO takes the new shape and rate in place, so a slow sweep of any knob moves the pulse smoothly instead of stuttering.
+- `Fuzz`: with Bias off centre, `tail_samples` is None. The output settles near zero rather than on it there, so the 24 576 it reported was a bound the class never met. The docstring now says what stays: a few LSB of wander, or at Bias -1 a DC constant of up to 177 LSB.
+- `Bitcrusher`: the dither tail is 9 728 samples, where 8 192 fell short at maximum Dither, and `tail_samples` is None where the dither alone is louder than its gate (maximum Dither at 12 bits, and any deeper crush with Dither up), because there the dither never stops.
- `tests/parity/effects_library_smoke.py` walked one instance through all of
a class's patches on a **4096-frame** probe and pulled eight output blocks
per patch, so a class whose output block is the contract's own 256 frames
@@ -1912,6 +1976,13 @@ the first release from this repository is 0.2.0.
### Changed
+- **`_core.py` is gone, and with it the old classes the rebuilds replaced.**
+ `delay.py`, `reverb.py`, `pitch.py` and `rack.py` held only the
+ pre-contract classes, which no public name had served since the Phase 5
+ and Phase 6 adoptions; they are deleted. The public classes, `ALL` and
+ `create()` are unchanged. `configure()`, `sample_rate()` and
+ `channel_count()` stay, but only record a format: every effect takes its
+ format from its source, as it already did.
- Phase 1 of the accuracy program: the ten drum machines — `cr78`, `dmx`,
`drumtraks`, `linndrum`, `simmons_sdsv`, `sp1200`, `tr606`, `tr707`,
`tr808`, `tr909` — rebuilt as fixed circuits against named references, each
diff --git a/README.md b/README.md
index b36a061..d4f6e8d 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,6 @@ repository map, public component flow, and contributor boundary.
```python
import audioinstruments, audioeffects
-audioeffects.configure(48000)
minimoog = audioinstruments.create("minimoog", sample_rate=48000)
chain = audioeffects.create("TapeDelay", minimoog.output, sample_rate=48000)
```
@@ -78,9 +77,9 @@ What's planned next is in [ROADMAP.md](ROADMAP.md).
- `lib/audioinstruments/` — one module per instrument, plus `_support.py`
(the shared voice/patch/wavetable machinery) and `midi_cc.py`
-- `lib/audioeffects/` — the effect catalogue by family (`dynamics`, `eq`,
- `delay`, `reverb`, `modulation`, `drive`, `pitch`) plus `rack.py` and
- `_core.py`
+- `lib/audioeffects/` — one module per effect, plus `_component.py` (the
+ base every effect is built on) and `rebuilt/` (the delay, reverb, pitch
+ and rack classes, which live there for now)
- `docs/audio-component-api.md` — the runtime contract every component
satisfies; `docs/audio-components.md` — the static metadata manifest;
`docs/sequencing.md` — writing a part that keeps time in frames
@@ -134,7 +133,7 @@ Most work does not need that full suite. `tools/scoped_tests.py` picks the files
| Work | Scope |
|---|---|
| One class | `tools/scoped_tests.py class:DeEsser` (that class's tests plus the contract) |
-| Kit, `__init__`, `_core`, or `_component` | `tools/scoped_tests.py auto` (widens from `git diff`) |
+| Kit, `__init__`, or `_component` | `tools/scoped_tests.py auto` (widens from `git diff`) |
| Every effect, no instruments | `tools/scoped_tests.py effects` |
| Shared promises only | `tools/scoped_tests.py contract` |
| Gate, integration, audit, release | `tools/scoped_tests.py full` |
diff --git a/ROADMAP.md b/ROADMAP.md
index f661de9..221ee1b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -10,8 +10,6 @@ the real thing.
StereoWidener, Rack, ShimmerHall and AirSpace. The README gains a latency
table for every class, each rack and a five-effect chain.
- A release that carries the rebuilt effects, with a changelog.
-- Flanger rebuilt after the Electric Mistress. Until it measures up on every
- target, the package keeps serving today's `modulation.Flanger`.
- The two placeholder classes in `audioeffects/rebuilt/` (`ExampleStock`,
`ExampleAudioif`) go once shipped classes cover both portability tiers.
diff --git a/docs/audio-component-api.md b/docs/audio-component-api.md
index 256152f..7dde3be 100644
--- a/docs/audio-component-api.md
+++ b/docs/audio-component-api.md
@@ -6,7 +6,7 @@ separately in `audio-components.md`.
The contract is structural. A provider does not have to inherit from an
`audiodsp` helper class, but it must expose the required attributes and methods.
-The helper classes in `audioinstruments._support` and `audioeffects._core` are
+The helper classes in `audioinstruments._support` and `audioeffects._component` are
implementation conveniences, not a requirement imposed on consumers.
## Construction
@@ -274,6 +274,11 @@ Writing a part against this, and the two traps that cost a run: the
value may be fractional even though patch declarations use integer MIDI
values. An invalid macro index raises `IndexError`.
+A macro value off the 0-127 range is clamped, never refused: below 0, minus
+infinity and NaN set the macro's bottom stop, and above 127 and plus infinity
+its top stop. `get_macro()` then reads back 0 or 127, and the component plays
+exactly what `set_macro(index, 0)` or `set_macro(index, 127)` plays.
+
Construction applies patch `0` automatically. A fresh component and a fresh
component followed by `program_change(0)` have the same observable state.
`program_change(index)` applies a known patch and safely ignores an unknown
@@ -335,10 +340,11 @@ control. An effect rack has exactly the effect shape even when its internal
graph contains serial, parallel, or mixed effects. Racks may contain and be
used by other racks.
-**Shipped status:** rack providers ship in `lib/audioeffects/rack.py`.
+**Shipped status:** rack providers ship in `lib/audioeffects/rebuilt/`
+(`rack.py`, `shimmerhall.py`, `airspace.py`).
`Rack` is the generic mechanism — a serial chain of the package's own
effects described by portable literals
-(`create("Rack", source, rate, chain=(("Reverb", {"preset": "hall"}),))`) —
+(`create("Rack", source, rate, chain=(("Reverb", {"character": "hall"}),))`) —
and `ShimmerHall` and `AirSpace` are fixed-topology racks with a macro
surface over their children, ported from micropython-vst3's shared
soundtrack racks. A rack is one more `audioeffects` provider: it is
diff --git a/docs/audio-components.md b/docs/audio-components.md
index c96a678..9e98175 100644
--- a/docs/audio-components.md
+++ b/docs/audio-components.md
@@ -206,7 +206,8 @@ the declarations to build a UI, initialize values, or offer program changes;
the metadata remains mandatory even when that consumer chooses not to use
those features.
-**Shipped status:** rack providers ship in `lib/audioeffects/rack.py` —
+**Shipped status:** rack providers ship in `lib/audioeffects/rebuilt/`
+(`rack.py`, `shimmerhall.py`, `airspace.py`) —
`Rack` (the generic serial-chain mechanism, built from a portable `chain`
literal), `ShimmerHall`, and `AirSpace` (fixed topologies with a macro
surface, ported from micropython-vst3's shared soundtrack racks). All
diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md
index 15565da..231b175 100644
--- a/lib/audioeffects/README.md
+++ b/lib/audioeffects/README.md
@@ -1,7 +1,9 @@
# audioeffects
-Forty-five effect classes built out of audiodsp's audio nodes, for any host
-that can pull an audiosample:
+Forty-five effects for any host that can pull an audiosample: compressors and
+EQs, reverbs and delays, modulation, drive and amp simulation, pitch
+shifters, and three ready-made racks. Pick one by name, hand it your audio,
+and play what comes out.
```python
import audioeffects
@@ -15,38 +17,190 @@ hall = audioeffects.create("Reverb", tape.output, 48000,
audio_out.play(hall.output)
```
-The package factory is the host-facing construction boundary: it receives the
-source and sample rate explicitly. Direct class construction remains
-supported for local code after a call to `configure()`.
-
-Every class takes its audio source as the first argument - a synthesizer, an
-`audioinstruments` instrument's `output`, a host input, or another effect's
-`.output` - and exposes its chain tail as `.output`. The underlying nodes are
-kept as attributes (`.node`, `.mixer`, `.cutoff`, ...) so applications can
-bind parameters straight to them; the classes with a natural swept control
-also expose `set_*` helpers (`TapeDelay.set_time`, ...). A class that
-has been rebuilt on the component contract drops those helpers for its macro
-surface: `LadderFilter` is the first, and its cutoff is macro 0.
-
-Every public effect class explicitly declares `NAME`, `MACRO_LABELS`,
-`MACRO_MODES`, and `PATCHES`. `VENDOR` is declared once at module scope for
-all effects in that source file. The complete provider rules are in
-`docs/audio-components.md`.
-
-For host integration, every public class also has the explicit factory
-`EffectClass.create(source, sample_rate, **options)`, and the package helper
-`audioeffects.create(name, source, sample_rate, **options)`. Direct class
-construction remains supported for local code after a call to `configure()`.
-
-Five nodes make the deeper processors possible: `audiodynamics.Dynamics` (an
-envelope-follower gain computer with sidechain filtering, lookahead and
-true-peak detection), `audioroute.Splitter` (fans one stream out to parallel
-branches that a Mixer then sums), `audiomath.Multiply` (one stream times
-another, which is ring modulation), `audioecho.FeedbackDelay` (a delay with
-a filter, a soft-clip and a cross-feed inside its loop) and
-`audioconvolve.Convolver` (an impulse response applied by partitioned FFT).
-None of the five is CircuitPython's; a stock board does not have them.
+`audioeffects.ALL` lists every name `create()` accepts. `create()` takes the
+audio source and the sample rate explicitly; a source can be a synthesizer,
+an `audioinstruments` instrument's `output`, a host input, or another
+effect's `.output`. Every effect hands back its chain tail as `.output`, keeps
+its underlying nodes as attributes (`.node`, `.mixer`, `.cutoff`, ...) so you
+can bind parameters straight to them, and follows the common component
+surface in `docs/audio-component-api.md`: `set_macro()`, `program_change()`,
+`latency_samples`, `tail_samples` and the rest. Every class declares `NAME`,
+`MACRO_LABELS`, `MACRO_MODES` and `PATCHES`; the provider rules are in
+`docs/audio-components.md`. Constructing a class directly also works, and
+reads the format off the source it is handed.
+
+Nearly everything here needs the audiodsp tier of nodes
+(`audiodynamics`, `audiobiquad`, `audioecho`, `audioverb`, `audioconvolve`,
+`audiomath`, `audioshaper`, `audioladder` and friends), which a stock
+CircuitPython board does not have.
+
+## What is in it
+
+Each class below is a name you give to `audioeffects.create()`. The module
+docstring of any of them lists its controls and limits.
+
+### Dynamics
+
+- `Compressor` evens out loud and quiet with four characters: a fast FET, a slow optical, a VCA and a vari-mu.
+- `Limiter` stops the signal at a ceiling, with optional lookahead and true-peak detection.
+- `Expander` makes quiet passages quieter by a ratio, down to a floor you set.
+- `NoiseGate` shuts the signal off when it falls under a threshold, with hold and a key filter.
+- `DeEsser` turns down sibilance without dulling the rest of a voice.
+- `TransientShaper` gives you more attack or more sustain from one pair of knobs, with no threshold.
+- `MultibandCompressor` compresses two or three bands on their own and sums them back flat.
+
+### EQ and filters
+
+- `ParametricEQ` is a shelving and bell EQ with three bells and a Q law you choose.
+- `GraphicEQ` is a ten-band octave graphic equalizer.
+- `LowPass` cuts the highs above a corner, with resonance and a 12 or 24 dB/oct slope.
+- `HighPass` cuts the lows below a corner, with the same resonance and slope choices.
+- `BandPass` keeps one band and drops the rest.
+- `Notch` removes one band, or a hum and its harmonics.
+- `LadderFilter` is a four-pole Moog-style low-pass that can sing at high resonance.
+- `CombFilter` is a tuned feedback comb that puts a pitch on whatever goes through it.
+- `DynamicEQ` is a bell that only appears when its band gets loud.
+
+### Reverb
+
+- `Reverb` is a plate, room, chamber or hall, built from a fixed network of delay lines.
+- `ConvolutionReverb` puts your signal in a synthesized room, or in the room your own impulse was recorded in.
+
+### Delay
+
+- `DigitalDelay` is a clean delay whose Time knob bends the repeats instead of clicking.
+- `SlapbackDelay` is the single short tape repeat behind a vintage rockabilly voice.
+- `TapeDelay` is a tape echo with a motor or a sliding head, wow and flutter.
+- `AnalogDelay` is a bucket-brigade echo whose repeats darken as Time grows.
+- `PingPongDelay` bounces the repeats between the two speakers.
+- `MultiTapDelay` reads one recording with several heads for rhythmic patterns.
+
+### Modulation
+
+- `Chorus` thickens a part with one analog-style swept voice.
+- `Flanger` sweeps a comb of notches through the sound.
+- `Phaser` sweeps an all-pass cascade for a softer, swirling version of the same idea.
+- `Tremolo` pulses the level, in bias-vary or optical style. Default is bias, Depth 0.5, Rate 5 Hz — not the optical standout and not L1's Depth 1. Wet peak at the default is −1.341 dB vs a 16000-LSB tone, not the ~6 dB `synthio` `>>16` ceiling; Depth 0 is still a wire. At 22.05 kHz the default's L1 bar is measured at the constructor, not assumed. Optical L1/L2 are disconfirmed; O1–O4 unmeasured at the constructor. L1 holds on sine at every rate, and misses by 0.16 dB on SQUARE material at 44.1 kHz only (−79.844 against −80; 48 kHz holds at −82.01).
+- `AutoPan` moves the sound left and right on an LFO.
+- `Vibrato` wobbles the pitch.
+- `RingMod` multiplies your signal by a carrier for bell-like and metallic tones.
+
+### Drive
+
+- `Overdrive` is a diode-limited, Tube Screamer style push.
+- `Distortion` is hard clipping after a shaped gain stage.
+- `Fuzz` is a germanium fuzz box, or a cascaded Big Muff style one.
+- `Saturation` is the warmth of a tube stage, a tape machine or transformer iron.
+- `Bitcrusher` lowers the word length and the sample rate for a lo-fi crunch.
+- `Exciter` adds high harmonics to the top of the sound.
+- `CabinetSim` runs the signal through a designed 4x12 or 1x12 cabinet.
+
+### Pitch and stereo
+
+- `PitchShifter` plays your part again a fixed interval up or down.
+- `Harmonizer` blends up to three fixed intervals under the dry for instant harmony.
+- `Octaver` adds an octave or two below the note you play.
+- `StereoWidener` widens a stereo part with a Haas delay or Mid/Side scaling.
+
+### Racks
+
+- `Rack` chains the other effects in the order you name, and hands back a single effect.
+- `ShimmerHall` is a pitch-shifted bloom around a long hall.
+- `AirSpace` is a swept filter into a wobbling tape echo into a chamber.
+
+## Chaining effects with a rack
+
+A rack is one component with the shape of a single effect, however many
+effects sit inside it. `Rack` takes a `chain` of effect names, or
+`(name, options)` pairs, in the order the signal passes through them:
+```python
+rack = audioeffects.create("Rack", source, 48000, chain=(
+ ("Compressor", {"threshold_db": -24.0, "ratio": 3.0}),
+ ("TapeDelay", {"time_ms": 340.0, "mix": 0.25}),
+ ("Reverb", {"character": "hall", "mix": 0.3}),
+))
+```
+
+The chain is plain data, so a host can store a rack as a literal. Racks nest:
+an entry may itself be `("Rack", {"chain": (...)})`. A rack owns its children
+and borrows the source. It reports its whole graph's latency (the children
+summed) and tail (`None` as soon as any child's is unbounded), and
+`reset()` clears every child's history. `ShimmerHall` and `AirSpace` are
+fixed racks whose macros move their children's controls.
+
+## Latency
+
+`latency_samples` is how late the output arrives behind the input. The table
+gives it in milliseconds at 48 kHz, read from each class's own report, at the
+default patch, at the worst of its shipped patches, and at the widest setting
+any one macro can reach. Zero means the effect adds no delay to the dry path.
+A delay's repeats, a reverb's tail and a compressor's release are not
+latency; `tail_samples` reports those.
+
+| Class | Default patch | Worst shipped patch | Widest setting of any knob |
+|---|---|---|---|
+| `Compressor` | 0 | 0 | 0 |
+| `Limiter` | 0 | 2.98 | 10 |
+| `Expander` | 0 | 0 | 0 |
+| `NoiseGate` | 0 | 0 | 0 |
+| `DeEsser` | 0 | 0 | 0 |
+| `TransientShaper` | 0 | 0 | 0 |
+| `MultibandCompressor` | 0 | 0 | 0 |
+| `ParametricEQ` | 0 | 0 | 0 |
+| `GraphicEQ` | 0 | 0 | 0 |
+| `LowPass` | 0 | 0 | 0 |
+| `HighPass` | 0 | 0 | 0 |
+| `BandPass` | 0 | 0 | 0 |
+| `Notch` | 0 | 0 | 0 |
+| `LadderFilter` | 0 | 0 | 0 |
+| `CombFilter` | 0 | 0 | 0 |
+| `DynamicEQ` | 0 | 0 | 0 |
+| `Reverb` | 0 | 0 | 0 |
+| `ConvolutionReverb` | 5.33 | 5.33 | 5.33 |
+| `DigitalDelay` | 0 | 0 | 0 |
+| `SlapbackDelay` | 0 | 0 | 0 |
+| `TapeDelay` | 0 | 0 | 0 |
+| `AnalogDelay` | 0 | 0 | 0 |
+| `PingPongDelay` | 0 | 0 | 0 |
+| `MultiTapDelay` | 0 | 0 | 0 |
+| `Chorus` | 0 | 0 | 0 |
+| `Flanger` | 0 | 0 | 0 |
+| `Phaser` | 0 | 0 | 0 |
+| `Tremolo` | 0 | 0 | 0 |
+| `AutoPan` | 0 | 0 | 0 |
+| `Vibrato` | 4 | 10 | 12 |
+| `RingMod` | 0 | 0 | 0 |
+| `Overdrive` | 0 | 0 | 0 |
+| `Distortion` | 0.04 | 0.04 | 0.04 |
+| `Fuzz` | 0.08 | 0.08 | 0.08 |
+| `Saturation` | 0.02 | 0.02 | 0.02 |
+| `Bitcrusher` | 0.02 | 0.17 | 0.98 |
+| `Exciter` | 0 | 0 | 0 |
+| `CabinetSim` | 0 | 0 | 0 |
+| `PitchShifter` | 0 | 0 | 0 |
+| `Harmonizer` | 0 | 0 | 0 |
+| `Octaver` | 0 | 0 | 0 |
+| `StereoWidener` | 0 | 0 | 0 |
+| `Rack` | 0 | 0 | 0 |
+| `ShimmerHall` | 0 | 0 | 0 |
+| `AirSpace` | 0 | 0 | 0 |
+
+Racks report the sum of their children. A `Rack` built from the five-effect
+chain `NoiseGate`, `Compressor`, `Overdrive`, `Chorus`, `Reverb` (the usual
+order on a guitar or a voice: clean up, level, colour, move, place) reports
+**0 ms** at defaults, and **0 ms** with each effect on its worst shipped
+patch. Latency adds up along a chain, so the effects to watch are the few
+with a number: `ConvolutionReverb` (5.33 ms while an impulse is loaded),
+`Limiter` (up to 10 ms of lookahead, 2.98 ms on its true-peak patch),
+`Vibrato` (its mean delay, 4 ms by default), and the drive effects that
+oversample, which add a few samples.
+
+Two things the figure does not say. `PitchShifter`, `Harmonizer` and
+`Octaver` leave the dry untouched, so they report zero, yet the shifted
+voice they add trails the dry by the shifter's window. `StereoWidener`'s Haas
+delay puts the right channel behind the left on purpose, and that is not
+counted either.
## Sound stability
@@ -68,215 +222,6 @@ audiodsp disagree, we treat it as a bug and report it upstream. The
components are where the sound evolves; the floor they stand on does
not.
-## Catalogue
-
-### Dynamic range - one file per class
-
-The seven Dynamics classes of the effects program's Phase 2, each rebuilt from
-scratch against a frozen trait table and an evidence pack. They live as
-`compressor.py`, `limiter.py`, `expander.py`, `noisegate.py`, `deesser.py`,
-`transientshaper.py` and `multibandcompressor.py` beside this README. Every
-one of them is **audiodsp** tier - none runs on a stock CircuitPython board -
-and every one adds **zero samples of latency** at its defaults.
-
-**Cost** is the class's own share of one 256-frame stereo block at 48 kHz,
-measured on an ESP32-P4 at 360 MHz and an ESP32-S3 at 240 MHz on 2026-09-07.
-It is the **marginal** figure - the same run's control subtracted,
-so the harness floor is not charged to the class - taken at construction
-defaults with no patch applied. Twelve of the sixteen are over the budget
-their dossier set, and the board run names the reason: those budgets were
-instruction-count arithmetic that cannot see the per-block Python of a node
-graph. Every one of the sixteen runs in real time on both boards.
-
-**Home.** Phase 2's sixteen have come home. `rebuilt/` keeps the substitution
-machinery and the two Example fixtures for the phases still to come;
-`rebuilt.ADOPTED` no longer lists these names.
-
-| Class | Tier | Macros | Latency | Cost | Standout |
-|---|---|---|---|---|---|
-| `Compressor` | audiodsp (`audiodynamics`, `audioroute`) | 14 | 0 samples | working patch 13 is 22.0 % P4 / 40.9 % S3, **over** its 19 % / 36 % palette budget; patch 14 `Compressor - lean` is **13.6 % / 24.2 %** and fits | UREI 1176 (FET), Teletronix LA-2A (Optical), dbx 160 (VCA/RMS) and Fairchild 670 (Vari-Mu) - four characters that differ in *law*, not in three numbers: `optical`, the default, has no working time knobs at all. **F2 is demonstrated at Threshold −8 dB and below.** **Bounded:** not clean on bass with a fast release - 3.89 % THD at 50 Hz and 10 dB of gain reduction with Release at its fastest, and three of its four 1176 patches over the 0.5 % bar at 50 Hz beside the all-buttons-in one, with patch 0's own defaults reading 0.83 % there; the true-RMS detector is level-honest only above about 65 Hz at the shipped 10 ms window |
-| `Limiter` | audiodsp (`audiodynamics`) | 6 | 0 samples at the default and at *Loud*; up to **480** at 10 ms of Lookahead, 143 at *Loud (True Peak)* | **13.8 % P4 / 27.6 % S3** at working *Loud* (`rebuilt:Limiter@2`), inside the redefined **15 % / 29 %** palette pair (2 × bare `Dynamics` + glue 0.0 ms); rt **4.90 / 2.65**. **Redefined 2026-09-07** (vision §7.2): the old ITU working graph cannot run on the S3. Patch 5 *Loud (True Peak)* is that old graph — P4 rt 1.64 (fits 61 %), S3 **rt 0.80, unavailable**. True Peak on both stages costs the +options row twice (1.611 / 3.461 ms each) | none - a two-stage **sample-peak** brickwall (Hämäläinen catch stage so lookahead does not overshoot). The ITU-R BS.1770-5 4× true-peak detector is optional and **P4-priced**: leave it off on an S3. **What the default surrenders:** with True Peak off (the default), a worst-phase tone at a quarter of the sample rate leaves a −6 dBFS ceiling **3.01 dB high** in the reconstructed waveform at 48000, 44100 and 22050 Hz. **True Peak on, Lookahead 0** is a different setting: it still holds the sample ceiling only — a full-scale ramp then escapes a −6 dBFS ceiling by **+2.22 dB TP** |
-| `Expander` | audiodsp (`audiodynamics`) | 9 | 0 samples | 8.4 % P4 / 16.3 % S3 of a block, **over** its 6 % / 12 % budget; patch 6 `Expander - lean` does **not** close G6 — the only cost lever is worth 5–8 % where the budget needs 26–29 % | Drawmer DS201 with RaneNote 155 - downward, `ratio` dB out per dB in below threshold to a `Depth` floor, true-RMS detector, two-ended key band, external key. The `ratio` law is the **RMS** detector's: at the Detector's peak position the slope falls 7.5 % short at ratio 8 and 29 % at patch 5's settings, which is what that patch is for. And the detector reads the key **band**, so a sine and a square of equal RMS match only while the band is wide and the ratio moderate - 0.55 dB apart at ratio 4, 0.74 dB at ratio 2 with the band closed to 3 kHz. No Hold: that is `NoiseGate`'s, and the reason is measured |
-| `NoiseGate` | audiodsp (`audiodynamics`, `audioroute`, `audiomath`) | 8 | 0 samples | 4.5 % P4 / 8.4 % S3 of a block, inside its 6 % / 12 % budget | Drawmer DS201 dual noise gate - the four-stage attack/hold/decay machine the ratio law does not have, with the key filters always in circuit |
-| `DeEsser` | audiodsp (`audiobiquad`, `audiodynamics`, `audioroute`) | 7 | 0 samples | 27.5 % P4 / 50.8 % S3 of a block, **over** its 8 % / 15 % budget, and no patch changes it - the cost is the graph, and seven macros do not change the graph | dbx 902 De-Esser - the detector is high-passed at `Frequency`, so only sibilance ducks the signal. Three bounds a player meets: the same setting works from -6 to -40 dBFS and stops being level-independent at -55; `Range` is an asymptote rather than a clamp, and the deepest reduction any setting reaches is about 11.5 dB; the 925 dB/sec release is the default `Release`'s, and the knob dials it from 1643 dB/sec to 20 |
-| `TransientShaper` | audiodsp (`audiodynamics`) | 5 | 0 samples | 14.9 % P4 / 31.3 % S3 of a block, **over** its 5 % / 10 % budget, and no `" - lean"` patch can fit: every patch costs 102–106 % of patch 0 and a node doing nothing at all still costs 75 %. The figures above were taken at defaults, where this class is a wire, so they are an *idle* cost; patch 1 (Snap) is the working one | SPL Transient Designer (RackPack 2715), Differential Envelope Technology - more stick **and** less room from one instance, with no threshold and no ratio. Level-independent over −20 to −40 dBFS at any Attack, and −6 to −40 dBFS below Attack +6, where the render stops clipping; **not** across −6 to −60 dBFS as first claimed. Sustain reaches its −24 dB end on a 10 dB/s decay and stops 8 dB short on a 3 dB/s one |
-| `MultibandCompressor` | audiodsp (`audiobiquad`, `audiodynamics`, `audioroute`) | 14 | 0 samples | 44.3 % P4 / 83.6 % S3 of a block, **over** its 25 % / 45 % budget. The S3 row leaves 8 % of the block for everything else on the chip, and **no patch is cheaper**: all 243 macro positions and shipped patches walked, the graph is the same 18 nodes and 72 pulls at every one - the one exception is `Mix` 0, where `output` is the class's input node and nothing it owns is pulled at all, and no shipped patch sits there. `bands=2` is the only lever - 12 nodes, 48 pulls, 75-79 % of the three-band desktop cost - and it is a constructor option, not a patch | none - RaneNote 155 Fig. 7 over a Linkwitz-Riley alignment (RaneNote 160): three bands split, compressed and summed back to +0.001/−0.119 dB of a wire; `Mix` at 0 is a true bypass, with no mixer in the path: a voice at level 1.0 scales by 32768/32767 (audiodsp#95). **A band does not compress evenly across its own passband**: out to its −6 dB corners the reduction tilts 1.82 / 1.31 / 1.42 dB (low / mid / high) against a 0.5 dB bar and lands 0.09 / 0.73 / 0.36 dB short of the depth dialled — the band's own skirt against a fixed threshold, disconfirmed with the arithmetic in the docstring |
-
-### Frequency and EQ - one file per class
-
-The nine EQ and filter classes of Phase 2, on the same terms, each in its
-own file (`parametriceq.py`, `graphiceq.py`, `lowpass.py`, `highpass.py`,
-`bandpass.py`, `notch.py`, `ladderfilter.py`, `combfilter.py`,
-`dynamiceq.py`): all **audiodsp**
-tier (`audiobiquad`, `audioladder` or `audioecho` - the float nodes, whose
-tails reach exact zero where the ported integer kernel parks on DC), all
-**zero latency** at every setting, none of them available on a stock
-CircuitPython board.
-
-Cost is read the same way as for the Dynamics table above: the
-marginal share of one block on each board at construction defaults.
-
-| Class | Tier | Macros | Latency | Cost | Standout |
-|---|---|---|---|---|---|
-| `ParametricEQ` | audiodsp (`audiobiquad`) | 16 | 0 samples | 15.2 % P4 / 25.3 % S3 of a block, inside its 26 % / 43 % budget; at defaults this class passes the probe through unchanged, so it is an *idle* cost | Pultec EQP-1A bottom and resonant top with three API 550A proportional-Q bells between: boost and cut the bass at once and you get the record trick, not silence; a bell's cut is the exact mirror of its boost **while the signal has headroom for the boost** - every section writes int16, so at -6 dBFS a +16 dB bell delivers +8.4 dB and the mirror is 8.05 dB out. Measured per trait: the mirror holds to -16 dBFS, the Bandwidth pot's peak-gain lift to -14, the bells' proportional-Q ratio to -10, and the Pultec low pair at every level tried (evidence pack section 1d) |
-| `GraphicEQ` | audiodsp (`audiobiquad`) | 14 | 0 samples | 22.7 % P4 / 38.6 % S3 of a block, inside its 38 % / 64 % budget; at defaults this class passes the probe through unchanged, so it is an *idle* cost | MXR M-108 Ten Band - ten octave bands from 31.25 Hz whose bells **get wider as you back off**, 1.8 octaves at +3 dB and 0.71 at +12, so three sliders at +6 dB make a +8.78 dB hump. A band at its detent is a wire byte for byte. The 16 kHz **shelf** needs headroom: full lift to a source peaking about 13 000 LSB (-8.1 dBFS), +8.78 dB at 14 000, and `Gain` +9 dB against `Volume` -9 dB takes it to +7.40 dB, and a patch's own `Volume` trim comes off it as well - add the trim back and the lift is +11.98 to +12.08 dB at all six |
-| `DynamicEQ` | audiodsp (`audiobiquad`, `audiodynamics`, `audioroute`) | 8 | 0 samples | 14.5 % P4 / 26.0 % S3 of a block at patch 0 against the palette-derived 17 % / 29 % (listed remainder; `Splitter` taps=3 is a named gap). T2/T5 miss above ~5 ms attack; T4/T6 out-of-band miss below Q 2 | none - the exactly complementary split: one bell that does nothing until the sound *in that band* crosses a threshold, and a measured **wire** when it is idle |
-| `LowPass` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget, and no `" - lean"` patch is possible: every section runs whatever its `mix` is, so no macro position is cheaper than any other | none - the two-pole analog prototype `H(s) = 1/(s² + 2Rs + 1)`: one knob slides the curve, one decides how loud the corner stands, −3 dB at Resonance 0.707 and +24 dB at 16. **Exact except at the bottom of the Frequency knob**: at f₀ 20 Hz with Q 16 at 24 dB/oct the corner stands 0.44 dB low and the curve stops being one shape below 25 Hz (float32 coefficients near z = 1), and "rings for Q periods" is a 12 dB/oct statement below about 0.17·F_s - at 24 dB/oct the ring runs 1.36× the Resonance number at Q 2 up to 1.71× at Q 16. The roll-off, −12.03 dB/oct on the warped axis with unity DC, holds everywhere |
-| `HighPass` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.7 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget, and no patch on this surface is cheaper - patch 0 is already one live section and two wires | none - RBJ's two-pole low-cut, with an exact transmission zero at DC: a held offset decays to zero rather than to the 71 LSB the ported node parks on at a 10 Hz corner. Two bounds, measured: the two numbers on the panel are exact only above about 40 Hz at 48 kHz - at a 10 Hz corner the peak is 0.6 dB shy of `Resonance` (Q 8, 24 dB/oct) and the curve is 0.21 dB off its own shape, both the node's `float` coefficients rather than the cut - and at 24 dB/oct the corner **rings 1.85x longer** than the knob says, which is the price of reading the same gain at both slopes. The cut, the 12/24 dB/oct skirt and the exact zero at DC hold everywhere |
-| `BandPass` | audiodsp (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audiodsp#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB |
-| `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator |
-| `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit |
-| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. Feedback is handed as set; at a stall-window centre (the 0.95 stop is one) the bound counts one landing lap, since audiodsp v0.6.3rc1 lands a stalled loop low-pass (#157). Tone off is exactly 0 whatever came before, and Tone back in from silence plays nothing (the v0.6.2 node froze the low-pass and played 15 070 LSB at 48 kHz). The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series |
-### Time and space - all eight adopted, served from `rebuilt/`
-
-Phase 5's eight classes are adopted: `DigitalDelay` and `SlapbackDelay` on
-2026-09-28, the other six on 2026-09-29. `audioeffects.create()` serves the
-rebuilds, from `lib/audioeffects/rebuilt/`, until they come home. All eight
-are **audiodsp** tier, and none runs on a stock CircuitPython board. Cost is
-the class's own share of one 256-frame stereo block at 48 kHz, measured on
-both boards at every shipped patch. For the first two the S3 figures are the
-same-conditions reading (the class measured beside the palette row it was
-priced from); the other six were measured at audiodsp v0.6.3, where every
-cell met the bar (at most 80 % of a block) and ran in real time on the P4,
-and on the S3 every cell did but `MultiTapDelay`'s patch 4. Their digests
-equal the desktop's, or a desktop MicroPython's built with single-precision
-floats (a setting worked out in Python float, audiocomponents#75), except
-`Reverb`'s patch 7 (below).
-
-| Class | Tier | Macros | Latency | Cost | Standout |
-|---|---|---|---|---|---|
-| `DigitalDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 at most 7.0 %** (0.372 ms, patch 5, rt 5.49) **/ S3 about 14.6 %** at patch 5 (about 0.78 ms corrected; 11.8 % as the tool reads it; rt 3.11), against palette **9 % / 15 %** (FeedbackDelay +options); The S3 figure was accepted on 2026-09-28. No lean patch. RAM 155 584 B at the default 800 ms line (48 kHz); `max_time_ms` spends less | Boss DD-2 (1983), light touch: its control law and dry/wet discipline, Mix 0 a wire, Time bends the repeats in pitch at a rate Glide sets instead of clicking, every static Time on a whole frame. Repeat Tone and Repeat Cut put the pedal's 7 kHz and 40 Hz corners in the loop as knobs that default out; patch 5 is the pedal's own corners. `tail_samples` is finite with Repeat Cut out; Feedback is handed as set, and with Repeat Tone in the bound counts one landing lap at a stall-window centre (audiodsp v0.6.3rc1, #157). Both out stops are exactly 0, and either filter back in after silence plays nothing (at v0.6.2 Tone's played 26 443 LSB and Cut's 20 858). The node lands 18 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 18 at 22.05 kHz (patch 4 at 44.1 kHz: a click's first repeat reads 19 922 and 78), none at 48 kHz. **Digests:** six of seven patches identical on the P4, the S3 and the desktop; patch 5 identical board to board, and different from the desktop through single-precision Python floats in two derived settings (audiocomponents#75): `cut_hz` 0.035 % high (39.44766 Hz against 39.43366) and `damping_hz` one float32 step. **What the default surrenders:** no band limit and no compander, no HOLD, and Glide 0's instant knob clicks. |
-| `SlapbackDelay` | audiodsp (`audioecho`) | 6 | 0 samples | **P4 at most 7.4 %** (0.396 ms corrected; 6.4 % as the tool reads it; rt 5.82) **/ S3 14.6-14.7 %** at the default and patches 0-4 and **15.2 % at patch 5** (0.813 ms corrected, the one patch with Tone in; 11.6 % as the tool reads it; rt 3.08), against palette **9 % / 15 %** (FeedbackDelay +options); Patch 5 was accepted on 2026-09-28. No lean patch. RAM about 50 KB (a fixed 251 ms line) | Sam Phillips' two-Ampex-350 slap at Sun Studio: one mono repeat at 135 ms, handed as a whole frame at every rate, with Saturation, Tone and Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire; Time walks rather than clicks. Tone out is byte-identical to no filter whatever came before, and a Wow move glides over 20 ms (both since audiodsp v0.6.3rc1). The node lands 21 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 20 at 22.05 kHz, none at 48 kHz and no shipped patch. **Digests:** all seven patches identical on the P4 and the S3, and different from the desktop through single-precision Python floats in a derived setting (audiocomponents#75): `wow_depth_ms` 2.1 x 10^-5 to 6.8 x 10^-5 ms high, under 0.0001 cent (patch 4's Feedback also one float32 step). **What the default surrenders:** Tone out, so the repeat is as bright as the dry, and Wow at 1 cent makes a 15 kHz repeat breathe down to -5.11 dB at 48 kHz. |
-| `TapeDelay` | audiodsp (`audioecho`) | 11 | 0 samples | **P4 13.4-14.1 %** (0.714-0.751 ms, rt 3.85-3.95) **/ S3 10.0-12.8 %** (0.531-0.683 ms, rt 2.51-2.70) at every shipped patch, audiodsp v0.6.3; the lean patch 8 is the cheapest on both. RAM 250 064 B | The RE-201's motor (`"varispeed"`, the default: a Time move bends the pitch for exactly the new time) and the EP-3's sliding head (`"sliding-head"`: the pitch bends only while the head moves, at a rate Glide sets) as characters. Each repeat darkens by a playback loss law that follows Spacing and the tape speed; Wow and Flutter drive one wow table; Record Level is the loop's squash. Mix 0 is a wire. |
-| `AnalogDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 7.0-7.5 %** (0.374-0.399 ms, rt 5.19-5.31) **/ S3 14.0-14.5 %** (0.746-0.774 ms, rt 3.06-3.12), audiodsp v0.6.3. RAM 118 896 B | A bucket brigade: the Boss DM-2's one 4096-stage line (`"single-line"`) and the Deluxe Memory Man's two in series (`"double-line"`). Time is the line's clock, so the repeats darken as Time grows and a Time move bends their pitch instead of clicking; every Time lands on a whole frame. No sample-and-hold, no compander, so short Times are brighter than either pedal. |
-| `PingPongDelay` | audiodsp (`audioecho`) | 9 | 0 samples | **P4 11.9-12.2 %** (0.636-0.649 ms, rt 4.20-4.22) **/ S3 16.4-16.6 %** (0.872-0.887 ms, rt 2.87-2.90), audiodsp v0.6.3; every digest equals the desktop's. RAM 194 112 B | Repeats that alternate between the speakers, on one node whose cross-feed and input pan Spread moves from two plain delays to the full bounce. The dry is each channel's own signal and the mono sum is an ordinary feedback delay exactly; Repeat Tone and Repeat Cut put a low-pass and a high-pass in the loop, defaulting out. |
-| `MultiTapDelay` | audiodsp (`audioecho`, `audiodelays`) | 9 | 0 samples | **P4 36.0-72.5 %** (1.922-3.865 ms, rt 1.19-2.09) **/ S3 57.8-78.1 %** at patches 0-3, 5 and 6 (rt 1.01-1.34), audiodsp v0.6.3. **Patch 4 misses on the S3**: 5.567 ms, 104.4 % of a block, rt 0.80, so it needs a P4-class board, and patch 3 may; the docstring says so. RAM 661 376 B | The RE-201's multi-head modes, with the Echorec as a second reference: heads on a grid of whole multiples of one base time, Pattern the RE-202's twelve head sets, laps round a loop low-pass that darkens once per lap. Repeat Tone's out stop swaps to a lighter graph (patch 1, the one to stack on an S3). Mix 0 is a wire. |
-| `Reverb` | audiodsp (`audioverb`) | 13 | 0 samples | **P4 19.0-23.4 %** (1.012-1.249 ms, rt 2.84-3.23) **/ S3 33.7-36.3 %** (1.799-1.935 ms, rt 1.84-1.93), audiodsp v0.6.3. RAM 49 120-149 904 B by patch | Dattorro's plate network on one `audioverb.Tank`, cut four ways by Character: an EMT 140 plate, dense at once, and a room, a chamber and a hall that build. Decay is T60 at 500 Hz within 12 % from 2 s to 10 s at the reference patches; Mix 0 is a byte-exact wire while the tank rings. The spring character is parked. **Digests:** patch 7's differs from the single-precision desktop build by nine one-LSB samples of 65536 (audiodsp#183). |
-| `ConvolutionReverb` | audiodsp (`audioconvolve`) | 6 | 256 frames with an impulse loaded, 0 without | **P4 41.6-42.2 %** (2.217-2.249 ms, rt 1.85-1.88) **/ S3 71.2-73.1 %** (3.796-3.900 ms, rt 1.07-1.09) at the default 0.08 s, audiodsp v0.6.3. RAM 142 544 B | A short room synthesized from Decay, Damping, Predelay, Diffusion and one of 64 Room seeds, or your own impulse (int16 frames or a 16-bit WAV) with only Mix live. `seconds` is the allocation, from 0.06 s to the node's 512-partition ceiling. |
-
-The old classes (`Reverb`'s `preset`, `TapeDelay`'s `wow`, `AnalogDelay`'s
-`age`, `MultiTapDelay`'s `(position, level)` taps) stay in `delay.py` and
-`reverb.py` until the rebuilds come home, and `ShimmerHall` and `AirSpace`
-still build on them (see [Effect racks](#effect-racks---rackpy)). A delay's `max_time_ms` sizes its line and
-cannot change afterwards; at 48 kHz a second of stereo line is 192 KB, so
-ask for what will be used.
-
-The two reverbs are not the same kind of thing. `Reverb` is `audioverb`: a
-fixed network of delay lines whose cost does not depend on how long the room
-rings, and that sounds like a plausible room. `ConvolutionReverb` is
-`audioconvolve`: it applies an actual impulse response, so it sounds like a
-*particular* room - and one second of stereo impulse is about 1.5 MB and
-~150 MFLOPS, which is a desktop or an offline render. Reach for `Reverb`
-first. The microcontroller-scale use of convolution is a **short** impulse:
-`CabinetSim` is 1024 taps and about 3 MFLOPS.
-
-`ConvolutionReverb`'s `seconds` is an allocation, not a setting - it fixes how
-long an impulse that instance can ever hold, because the storage is carved
-once and the audio path may already be pulling. Its Decay macro then works
-within that allocation, which is why it is a proportion rather than a time.
-Both convolution classes trail their input by `audioconvolve.FRAMES` (5.3 ms
-at 48 kHz) once an impulse is loaded, and by nothing at all before one is.
-
-### Modulation - six at home, one still in `rebuilt/`
-
-Phase 3's THROUGH classes (`AutoPan`, `Chorus`, `Phaser`, `Tremolo`,
-`Vibrato`) and `RingMod` have come home as one file per effect beside this
-README. `Flanger` stays parked under `rebuilt/`; the package still serves
-`modulation.Flanger`. `modulation.py` cannot be deleted while Flanger
-remains. Every one of them is **audiodsp** tier - none runs on a stock
-CircuitPython board.
-
-**Cost** is the class's own share of one 256-frame stereo block at 48 kHz
-where the board stage has run, else the dossier palette budget. A
-`" - lean"` patch is named only where one exists.
-
-| Class | Tier | Macros | Latency | Cost | Standout |
-|---|---|---|---|---|---|
-| `Chorus` | audiodsp (`audioecho`) | 5 | 0 samples; wet Delay 3–20 ms is the effect, not lookahead | **8.1 % P4 / 9.8 % S3** at patch 0 (ROW marg 0.431 / 0.524 ms), inside palette **P4 ≤ 9 % / S3 ≤ 15 %**; no lean patch | Electro-Harmonix Small Clone - one BBD voice, clock-law triangle, Mix 0 a wire. **What the default surrenders:** the within-half pitch-offset ratio is 1.98, not (d_max/d_min)² = 3.60 (T2). Tone 12 kHz is 2.4 dB down at 10 kHz, not ≥10 dB (T3). The constructor Tone 3 kHz still meets T3 |
-| `Flanger` | audiodsp (`audioecho`; `audioroute` only when Through Zero is on) | 12 | 0 samples at defaults; Through Zero off by default, **10 ms / 480 samples at Range max / 48 kHz** when on | **0.672 ms/block P4 (rt 7.94) / 1.081 ms S3 (rt 4.93)** at patch 0, inside palette **9 % / 15 %** (FeedbackDelay +options; no extra looping RawSample); no lean patch | Electro-Harmonix Electric Mistress - BBD swept comb, Filter Matrix, Mix 0–2. **What the default gives up:** Color 0.55 / Matrix off is about −22 dB and 0.1 s, not the Color-0 −37 dB null or the Color-max ring; Color 0→0.9 holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s measured at audiodsp v0.6.2; restated 2026-09-28 from "the 2 s ring", which the node's old rounding floor partly held up); the default does not, and a click is not that bar. |
-| `Phaser` | audiodsp (`audiobiquad`, `audioshaper`) | 10 | 0 samples at every setting | Palette **15 % P4 / 24 % S3** (AllPass-6 0.248 / 0.423 + Waveshaper ×1 0.223 / 0.410 + extra synthio 0.317 / 0.408 + glue 0.0). The old **9 % / 16 %** bar omitted the LFO: the palette could not price a `synthio` source. Quoted ROW **8.5 % / 15.6 %**. Patch 8 `Phaser - lean` (Drive 0) is AllPass alone → **5/8 %**. | MXR Phase 90 — four first-order all-pass stages, JFET as the variable resistor. **What the default surrenders:** Drive 0.3 is on at construction. Feedback's inter-notch peak then misses the 5 dB bar (600–1800 Hz rise −0.633 dB at 48 kHz) and the notch floor is not monotone (0.5→0.7 −1.204 dB). Both hold at Drive 0. The floor's 0.5→0.7 step also deepens at 22.05 kHz with Drive 0 (−1.286 dB). |
-| `Tremolo` | audiodsp (`audiomath`) | 9 | 0 samples; Lag is table shape, not a delay | Palette **7 % P4 / 9 % S3** (Multiply 0.028 / 0.047 + extra synthio 0.317 / 0.408 + glue 0.0). 1024-point board ROW **2.4 % / 4.9 %** (P4 marg 0.128 / ctrl 0.346, rt 11.23; S3 marg 0.265 / ctrl 0.532, rt 6.69). Prior 256-point ROW **2.3 / 4.9** is a shorter waveform. No lean patch | Fender Princeton 6G2 bias-vary and AB763 optical. **What the default surrenders:** Default is bias, Depth 0.5, Rate 5 Hz — not the optical standout and not L1's Depth 1. Wet peak at the default is −1.341 dB vs a 16000-LSB tone, not the ~6 dB `synthio` `>>16` ceiling; Depth 0 is still a wire. At 22.05 kHz the default's L1 bar is measured at the constructor, not assumed. Optical L1/L2 are disconfirmed; O1–O4 unmeasured at the constructor. L1 holds on sine at every rate, and misses by 0.16 dB on SQUARE material at 44.1 kHz only (−79.844 against −80; 48 kHz holds at −82.01). |
-| `Vibrato` | audiodsp (`audioecho`) | 8 | **mean Delay**; default **192 samples / 4.0 ms at 48 kHz** (wet alone); constructor and `program_change(0)` stay on that whole-sample bin | Palette **9 % P4 / 15 % S3** (`FeedbackDelay` +options, Mixer optioned off at Level 0 dB). Prior Mixer-in-path ROW **9.5 / 15.3 %** is stale. Board ROW of the Delay-only graph **unmeasured**. No lean patch | BBD clock-law vibrato (VB-2). Level 0 dB leaves the Mixer out of the pull. **What the default surrenders:** T1's any-2 kHz-window no-dry clause (Tone −3 dB at 17 kHz). At 22.05 kHz a 2 kHz window can tilt past 1 dB. The 17 kHz −3 dB band is at 48 kHz with a whole-sample Delay. T4 and T7 magnitude are Published defaults only. T7 quadrature is disconfirmed |
-| `AutoPan` | audiodsp (`audiomath`) | 7 | 0 samples | Palette **6 % P4 / 10 % S3** (`Multiply` 0.028 / 0.047 + extra synthio 1500 0.263 / 0.472 + glue 0.0). The old **1 % / 1 %** bar counted Multiply only: the palette could not price a `synthio` source. Quoted ROW **2.5 % / 4.5 %**. No lean patch | none (grade *design*). **What the default surrenders:** Rate starts at 2 Hz, not a slow 0.05 Hz wander — a 256-frame hold at 0.05 Hz reads under −80 dB, so A3's plant cannot fire on the old floor. The oscillator is two `synthio.Note`s, which top out at half scale (P12): hard-over is −6 dB vs the source. A mono source is a wire. |
-| `RingMod` | audiodsp (`audiomath`) | 12 | 0 samples | Palette **7 % / 9 %** at Clean Ring (Multiply 0.028 / 0.047 + extra synthio 0.317 / 0.408 + glue 0.0). The old **3 % / 5 %** bar used a 256-frame RawSample stand-in: the palette could not price a `synthio` source. The **18 % / 52 %** bar counted a 1964-frame looping RawSample. Quoted ROW **4.4 % / 4.8 %**. Demonstrated traits are **M1** and **M3** only. | Bode four-diode ring (Moog 6401). **What the default surrenders:** Default is the multiplier at Frequency 220 Hz, Depth 1, Mix 1 — not the switching standout and not W1's Shape 1. At that default, 3·f₂+f₁ sits at **−92.2 dB** (1 s rect, 48 kHz), not in W1's −9.5±3 dB window, so W1 is disconfirmed at the constructor default. Wet RMS is **−9.03 dB** vs the program (M2 disconfirmed): Q15 product −3.01 dB plus the `synthio` voice sum. Squelch, Threshold and Release do not move the audio (M5 disconfirmed). Shape is a byte no-op until Character is flipped. Switching products at Character 1 Shape 1 are not a demonstrated row. Carrier Low's 5 Hz HPF is **not a demonstrated claim** (M4 disconfirmed): a same-kind plant moves **0.0022 dB** at Frequency 220 and **8.6 dB** only at 2 Hz. |
-
-### Drive - four at home, three adopted and on their way
-
-Phase 4's first four (`Overdrive`, `Distortion`, `Bitcrusher`, `CabinetSim`)
-have come home as one file per effect beside this README. `Saturation`,
-`Fuzz` and `Exciter` were **adopted on 2026-09-18** under the G6 ruling -
-the cost gate is a real-time ceiling, 80 % of one stereo block on both
-chips, and all three are inside it at every shipped patch - so
-`audioeffects.create()` serves the rebuilds now, from
-`lib/audioeffects/rebuilt/`, until they come home too. Every one of the
-seven is **audiodsp** tier - none runs on a stock CircuitPython board.
-
-**Cost** is the dossier's palette budget of a 256-frame stereo block at
-48 kHz, re-derived after the 2026-09-17 fix round for the graphs each class
-now has - five of the seven lost nodes, so five of these numbers came down.
-Every row now carries a measured board ROW beside its budget; where a row
-quotes a number for a graph the boards have not seen since, it says so. A
-`" - lean"` patch is named only where one exists; constructor `oversample=`
-is not a patch.
-
-| Class | Tier | Macros | Latency | Cost | Standout |
-|---|---|---|---|---|---|
-| `Overdrive` | audiodsp (`audioshaper`, `audiobiquad`, `audioroute`) | 7 | **0 samples** at every macro setting; the dry tap is a copy. `oversample` puts group delay on the clip branch only - 0.069 ms at x4, 0.088 ms at x8 - and it is not on the macro surface. `tail_samples` **4096** over a worst measured ring of **2692 frames** (48 kHz, patch 6, 0 dBFS burst), **level-free**: it was 512 over 222 frames until the third fix round put a 30 Hz output pole behind the shaper | Palette **32 % P4 / 55 % S3** (MidSide 0.028 / 0.018 + Splitter-2 0.165 / 0.395 + **4** x Biquad 0.400 / 0.676 + Waveshaper x4 1.019 / 1.803 + 2 x Mixer 0.072 / 0.036 + glue 0.0 = 1.684 / 2.928 ms). The fourth Biquad is the **30 Hz output pole** the third fix round put behind the shaper; it was 30 % / 52 % with three. The old **36 % / 63 %** priced the graph before the fix round, which took out two nodes. Board ROW of the fixed graph **34.1 % P4 / 64.7 % S3**, measured 2026-09-18 on both boards at audiodsp `e3b95e7`, inside the budget re-derived from that day's palette (**34 % / 66 %**); rt 2.41 / 1.28; the pre-fix graph read 35.3 % / 68.5 %. Lean position `oversample=2` **21 % / 37 %**, and it surrenders T7 | Ibanez TS808 - dry plus a diode-limited high-passed copy, then a movable low-pass. `oversample` is derived from the rate: **x8 at every rate below 48 kHz, x4 at 48 kHz and above**, and every rate under 24 kHz leaves the internal rate under the 192 kHz target because x8 is where the node stops. 48 kHz keeps x4 because x8 there costs +0.799 ms a block on the P4 and **+1.408 ms on the S3**, which projects the S3 to a real-time factor of about 1.00. The clip table fills Q15 ([#77](https://github.com/PyDevices/audiocomponents/issues/77)) - it held diode volts and peaked at 35 % of int16 - and the even-harmonic floor at the default drops from -97.6 to **-106.7 dBc**. **What the default surrenders:** Drive is never clean, but at Drive *minimum* it is 8.305 % THD at -14 dBFS, under the standout's 10-40 % band. **Mix rides the clipped voltage, not a crossfade to bypass**: Mix 0 is the wire, and one step above it the tone stack is in circuit on the dry note too, so stepping off zero is a tone change. **At Mix 1, Level MIDI 0 is silence, not a wire.** The 100 Hz-cleaner-than-1 kHz claim is a **sine at Body 720 Hz**: it reverses at the bottom of the Body travel, at Drive max, on a square wave, **on a sawtooth at all three rates** (86.2 % against 70.5 % at 48 kHz), and on shipped patches 2 and 4. T2 names h2 **and h4**, and patch 6 is h4 **-31.8 dB** beside its h2 -25.8. **T5's law - THD rises with Drive - is a claim at Symmetry centred**: off centre it turns over, peaking at Drive MIDI 64 and falling above it (Symmetry 0: 8.601 / 19.875 / 24.900 / 20.291 / 17.660 % over Drive 0 / 32 / 64 / 96 / 127, all three rates). **Silence in is silence out at every shipped patch** since the third fix round gave the class the output capacitor a pedal has - patch 6 stood +4461 LSB on the output for ever without it - and the pole costs 3.4 dB of alias floor at the default at 48 kHz and one block of input on a live macro move that shifts the offset. **Input ceiling -1 dBFS at the shipped patches, -6 dBFS anywhere on the surface**; patch 7 reaches the rail at 0 dBFS / 400 Hz (4 samples of 9600) and Drive max with Level max and Tone open at -3 dBFS (570 of 9600). T3's level law is **not monotone outside its stated -14 -> -6 pair**: THD rises -20 -> -14 dBFS, turns back up at -1 dBFS at 22.05 kHz, and rises with level at 100 Hz. **T7's alias floor was restated 2026-09-17 under vision §7.2 and is read on the wet branch**, with the dry copy muted - the dry note flatters every floor by 5.6-6.6 dB, and this class's dry voice is on the circuit mixer, which `mute_dry` searched past until the reach moved into the kit ([#68](https://github.com/PyDevices/audiocomponents/issues/68), [#81](https://github.com/PyDevices/audiocomponents/issues/81)). **Four** bars, 1010 Hz at **-6 dBFS** except the fourth: **-70 dB at the constructor default** (-77.9 / -78.7 / -75.6 at 48 / 44.1 / 22.05 kHz), **-50 dB at every shipped patch as shipped** (worst patch 3, -56.1 / -71.4 / -54.4), **-40 dB anywhere on the macro surface** (worst -47.6 / -61.0 / -46.6, at Drive max with Body at the bottom of its travel and Tone open) and **-40 dB at that corner at every input level up to full scale** (worst -42.0 / -52.2 / **-41.3**, all three **at 0 dBFS**, which is **1.3 dB of margin** and the thinnest this class has anywhere). A fifth, T7e, says what the oversampling is worth: **at least 20 dB** of wet floor against the same class at the base rate, at every surface position where Drive is above its floor and Body below its top (47 of 52 gradable cells at 48 and 44.1 kHz, all 52 at 22.05; worst carried cell +20.5 dB). The old single -60 dB bar is **kept at 44.1 kHz**, where the shipped x8 build meets it everywhere (worst surface cell -61.0 dB), and replaced at the other two: at 48 kHz x4 reads -47.6 and x8 would read -63.9 but projects the S3 to rt 1.00 - **a projection with no board behind it** - and at 22.05 kHz x8 is the node's maximum (`AUDIODSP_SHAPER_MAX_OVERSAMPLE`, [audiodsp#104](https://github.com/PyDevices/audiodsp/issues/104)). The **3700 Hz diagnostic does not hold**, and the -37.7 dB the pack published for it was a **mixed** reading: wet it is **-33.9 dB** at 48 kHz, -32.1 at the worst corner, and **5000 Hz is worse and was named nowhere** - -27.8 held at 48 kHz and **-24.1 at 22.05 kHz**. End-to-end gain is not the op-amp's 10-15 / 90-130 plateau, and the tone stack is not the TS wiper network |
-| `Distortion` | audiodsp (`audioshaper`, `audiobiquad`, `audioroute`) | 10 | **2 samples** at Mix > 0 - the ×4 half-band, read as the click's integer onset, the same at 48 / 44.1 / 22.05 kHz, mono and stereo, on both characters; **0** at Mix 0. Every one of the eleven shipped patches reads it: the four scoop patches were unmeasurable while Asymmetry was a bias, and are not now. `oversample` default 4 is the latency-adding option and it is on. The sub-sample click is 15.2 / 14.3 / 8.7 samples: minimum-phase group delay, not latency | Palette **35 % P4 / 63 % S3** at patch 0 Mix 1 ×4 (1.849 / 3.323 ms, glue 0.0), re-derived 2026-09-17 for a graph three nodes smaller; the old **43 / 78** priced the graph the board ran. Board ROW **36.9 % P4 / 71.8 % S3**, measured 2026-09-18, inside the budget re-derived from that day's palette (**38 % / 73 %**), and **rt 1.16-1.18 on the S3 at all eleven patches** - the old graph read 49.2 % / 91.8 % with **rt 0.94 on the S3**, and the desktop marginal has since fallen 21.8 % (1.743 → 1.363 ms). Constructor `oversample=2` is **26 % / 47 %**, not a named lean patch | ProCo Rat (`filter`) with Boss DS-1 (`scoop`) - a hard clip to ground after a gain stage. Mix 0 is the wire; Distortion 0 is clean only in `filter`, and `scoop` starts with its 35 dB booster in circuit. **What the default surrenders:** F1's 11-18 dB bass-to-treble tilt is a *low Distortion* claim - at the shipped Distortion 64 the tilt is **+1.1 dB**. F5's 5 kHz clause is **false at 22.05 kHz** (default 1 k→5 k fall −13.7 dB against −11.7 ±1.5) because `_fo_lp` parks its second pole at 0.45·Fs. **A1 is a −20 dBFS number and its row carries the span**: −60 dB at 1010 Hz over **−22 to −16 dBFS**, graded at both ends and the middle (−62.4 / −63.3 / −60.6 at −22, −62.4 / −60.6 / −60.7 at −19, −68.4 / −67.0 / −65.1 at −16). Outside it the bar does not hold - **−55.7 dB at −12 dBFS and −46.5 dB at −6** - and it is a 1010 Hz claim: **7010 Hz reads −42.2 / −34.3 / −30.1** at −20 dBFS. "It is not aliasing" describes ×4: the ladder at −20 dBFS / 48 kHz is ×1 −36.1, ×2 −56.4, ×4 −61.7, ×8 −62.6. It holds up to Distortion 80 and is **redefined above that** (vision §7.2): at maximum Distortion it is −58.5 dB at 1010 Hz and −48.1 dB at 3700 Hz, which is the ×4 `Waveshaper`'s own decimation floor - the bare node reads −52.2 dB at 3700 Hz. On `scoop` with the booster up it is **−40.4 dB at patch 6**, −39.0 at the worst scoop cell. **Asymmetry is a second table, not a bias** (third fix round): `_CURVE_DS1_ASYM` is the DS-1's one-diode-against-two clipping section, both tables answer zero with zero, and silence in is silence out at every shipped patch - the bias model sat at up to **−6.8 dBFS of DC** and started a −20 dBFS note on −0.28 of full scale. It costs 1.8-2.3 dB of level on the four scoop patches. **Volume and Mix stop above a knee and `wet_ceiling()` says where**: 1.0 at the shipped Ceiling MIDI 42 (the whole travel works), 0.6317 at Ceiling MIDI 108, where Volume MIDI 84 to 127 is one number. **The Filter macro works over its whole travel on `scoop` now** - it was one section from MIDI 8 up and ran backwards below it. Scoop traits are not the shipped character |
-| `Fuzz` | audiodsp (`audioshaper`, `audiobiquad`, `audioroute`) | 7 | Mix 1 at oversample 8 reports **4** (3.9 samples / 0.083 ms at 48 kHz); oversample 4 reports **3** (0.069 ms), oversample 2 and patch 8 report **2** (0.046 ms), oversample 1 and Mix 0 report **0**. **`cascade` ships at x2, so it reports 2 where `germanium` reports 4**; `LATENCY_SAMPLES` is the germanium constant and the instance property is the one to read | Palette **42 % P4 / 75 % S3** at patch 0 germanium x8, re-derived 2026-09-18 at the rows both boards took that morning (3 x Biquad 0.324 / 0.612 + Waveshaper x8 1.933 / 3.368 + glue 0.0 = 2.257 / 3.980 ms). Patch 8 **`Fuzz - lean`** shapes at x2 through a second shaper the tilt stage plays instead - **17 % / 31 %** (0.882 / 1.629 ms) - and that patch, not the constructor, is the S3's germanium position. Board ROW, second pass 2026-09-18 on the 256-frame graph, **44.4 % P4 / 76.8 % S3** at patch 0, **18.5 % / 32.8 %** at patch 8 and **44.6 % / 77.7 %** at its costliest shipped patch - still **over the palette sum by 2.1 / 2.2 points**, which under the G6 ruling of that day (the cost gate is a real-time ceiling, 80 % of a block, and the palette sum is a planning estimate) is a note and not a park: this class is **adopted** (audiocomponents#74). **Alone on an ESP32-S3 this uses 77 % of a block at its default germanium patch, 78 % at its costliest; use patch 8 `Fuzz - lean` (33 %) to stack.** **That palette miss is not located**: the census confirms the node count, the shaper's own settings price the same as the palette row (0.0691 against 0.0718 ms/block on desktop MicroPython, order-balanced), germanium builds no Mixer and no Splitter and hands back a 256-frame block already - and the miss is **0.122 ms on the P4 against 0.116 on the S3**, the same absolute number on two chips that differ by 1.8x, which is not the shape of CPU work. rt 1.81 / 1.05 on germanium. **`cascade` ships at `oversample=2` since the fifth fix round**: at x8 the board read **89.7 % / 161.0 %, rt 1.00 on the P4 and 0.56 on the S3** - it does not run on a T-Embed S3 - while `create()` defaulted to 8 for both characters and only a docstring said otherwise. At x2 it is **40.7 % / 74.3 %, rt 1.97 / 1.09** against a palette sum of 2 x Splitter-2 + 6 x Biquad + 2 x Waveshaper x2 + Mixer = 2.228 / 4.169 ms (**42 % / 78 %**): inside on both boards. x4 is 3.250 / 5.971 ms, **61 % / 112 %**, so it does not run on an S3 either. **What x2 gives up is 2.9 dB at 1010 Hz and 5.3 at 3700 of alias floor at the default Fuzz, and 5.6 / 5.6 at maximum Fuzz** - cascade's floor spans only 2.5 / 5.6 dB from x2 to x8 because the second clipper folds what the first already put above the output Nyquist (x8 buys it 12.3 / 12.7 dB over x1, against germanium's 20.9 / 18.7). **`cascade` is outside A4's span at every factor** - A4 is a germanium row and at x8 cascade reads -29.8 / -26.0 against bars of 35 / 28 dB. `oversample=` still takes 1, 2, 4 or 8 | Fuzz Face (`germanium`) and Ram's Head 1973 Big Muff (`cascade`). Mix 0 is the wire; on `germanium` Mix is the shaper's own blend. **What the default surrenders:** Tone is inert on germanium. G2's 0.5 dB-per-step THD staircase is not met (the last steps are +0.17 / +0.04 / +0.008 dB). Cascade C2's 25 dB THD-vs-Sustain bar is not met at -20 dBFS - both ends of Sustain are already hard-clipped. Gating is the Bias macro's starved end, not a moving bias. **G1's peak clause is bounded to Tilt 0**: at Tilt +6 dB the 9.02 dB peak split reads 0.000 dB while h2 is still -19.58 dBc, and G1 holds at **patch 0** and misses at patches **1, 4, 5, 6 and 7** - every patch at Fuzz 48 dB or above. **A4's alias floor was redefined 2026-09-17 under vision §7.2**: the old 60 dB bar at 3700 Hz fails at every position of the macro, so the claim is now >= 50 dB at 1010 Hz and >= 35 dB at 3700 Hz at the default (measured -55.5 / -38.4), x8 at least 20 dB better than x1, worst case at maximum Fuzz ≥ 35 / ≥ 25 dB. **The worst cell is the joint one**, not the one-macro-at-a-time sweep the pack published: Fuzz 127 with Tilt 127 reads **−35.407 dB at 44.1 kHz** against a published −38.154, so the margin is **0.4 dB**. **The tail is 24 576 samples** since the third fix round — 16 384 was a 1 kHz number and a 40 Hz burst rings 18 750 frames at patch 5. **The output pole is charged before the first block**, so patch 2's starved bias emits 11 LSB into digital silence where it banged **21 971** (−3.5 dBFS); what is left is a stated residual of 16 LSB, bounded and tested. **Nothing reaches the rail between 100 Hz and 3700 Hz** (worst 30 185) — below 100 Hz it does: at Fuzz maximum a 30 Hz sine pins 10 samples of 6 400 at 0 dBFS, and the worst shipped patch below 100 Hz has a 0.16 dB margin. **G1 is level-free over −46 … −20 dBFS**, walked at both ends and the middle. **`cascade` has its output coupling capacitor** since the fourth fix round ([#89](https://github.com/PyDevices/audiocomponents/issues/89), ruling (n)). Without it the low arm of the tone stack is a LOW_PASS, which passes DC, and Bias off centre held **-16 383 to +16 382 LSB (-6.0 dBFS) on the output for ever** at eight of the nine stops of a front-panel macro. Walked over 108 cells - 3 rates x 2 characters x 9 Bias stops, built at the value and moved to it live - the settled mean is now at most **7.1 LSB** and the peak at most **13**, both on germanium; cascade's worst is 6 LSB of each, inside the stated 16 LSB residual. **Two rows moved with it, and C1 did not.** **C3's Bias clause is restated**: Bias off centre gates the LEVEL (three probe tones average -15.5 dBFS at centre, -26.8 at +-0.25, -43.9 at +-0.5, -56.2 at +-0.85, -67.8 at +-1) and the **scoop survives the whole travel** (+3.93 ... +7.63 dB); the old "three probe tones come out at one level, scoop 0.002 dB" was the offset, not the tone stack, and patch 2's miss reads 2.561 dB against the 3 dB bar where the offset made it 1.1. **C4's span narrows to Fuzz 18 ... 32 dB and is disconfirmed above it, which includes the cascade constructor's own 36 dB and eight of the nine shipped patches** - above the span the clipper has filled the notch in and the Tone 0.75 curve is a monotone slope across the whole 150-2000 Hz grid; the old claim to 36 dB rested on a **0.041 dB** dip between two grid points an octave apart. C1 is unmoved (h2 -79.059 -> -79.379, peak growth 0.012 -> 0.013 dB) |
-| `Saturation` | audiodsp (`audioshaper`, `audiobiquad`, `audioroute`, `audioecho`) | 8 | Mix 1 at oversample 8 reports **4** (3.9 samples / 0.083 ms at 48 kHz); patch 8 and oversample 4 report **3** (0.069 ms); oversample 2 reports **2** (0.046 ms); oversample 1 and **Mix 0** report **0**. `console` adds **2 samples** | Palette **31 % P4 / 58 % S3** at patch 0 (tube, x4), re-derived 2026-09-18 from the rows both boards took that morning: Splitter-2 0.207/0.368 + Waveshaper x4 1.069/1.918 + **3**xBiquad 0.324/0.612 + Mixer 0.050/0.175 + glue 0.0 = 1.650 / 3.073 ms. **Three sections and not four** - `_after` **is** `_couple`, so the board's attribute census counted one node under two names. Patch 8 **`Saturation - lean`** shapes one factor down - **21 % / 41 %** (1.139 / 2.172 ms). Board ROW of the 128-frame graph was **34.3 % / 62.2 %** at patch 0 and **24.7 % / 46.4 %** at patch 8 - over by 3.4 / 4.6 and 3.3 / 5.7 points. The 256-frame graph it ships now, measured on both boards in the second pass of 2026-09-18, reads **29.4 % / 55.4 %** at patch 0, **20.8 % / 39.6 %** at patch 8 and **29.9 % / 56.4 %** at its costliest shipped patch: inside the palette sum, and inside the G6 real-time ceiling of 80 % of a block at all nine patches, so this class is **adopted** (audiocomponents#70). **Alone on an ESP32-S3 this uses 55 % of a block at its default patch, 56 % at its costliest; use patch 8 `Saturation - lean` (40 %) to stack.** **The miss the 128-frame graph had is not the node count:** every node the class drops leaves the sum by exactly its palette row, so trimming does not move it. It is the **per-block glue** every budget here prices at 0.0 - the class handed back a **128-frame** block (`Mixer._render_size` is `buffer_size // 2 // 4 * 4` bytes), so everything behind it was pulled twice per 256-frame block, and one Python-level pull is worth **0.178 ms P4 / 0.109 ms S3** by the board's own rows (`Splitter+3 - Splitter+tap` against `Mixer@3active - Mixer@2active`) - 3.3 and 2.0 points, the whole P4 miss. It hands back the palette's own 256-frame block now and **no byte moved** (`ffb5e07e33e2732d` / `58444f96ff950621` at either length, three interpreters), and the second board pass measured that graph: the miss is gone. rt 1.35-1.73 on the S3, 2.41-3.10 on the P4. `tape` adds a fourth biquad (33 % / 61 %) and `console` replaces one with two bare `FeedbackDelay` sections, which have no Phase 4 row at all; neither is this bar. `hysteresis` has no palette row | 12AX7 (`tube`), Langevin tape (`tape`), transformer iron (`console`). **Mix 0 is the wire, and it is the borrowed source itself** - no mixer in the path, because a voice at level 1.0 scales by 32768/32767 and lifts every sample at or above 32736 by an LSB (audiodsp#95). **What the default surrenders:** **Drive stops at +15 dB, not +24** - that is where the alias floor still holds 60 dB down at 1010 and 3700 Hz on the shipped x8 (-72.9 / -64.2 dB; +18 dB reads -63.0 / -50.9). It costs the top of the range, 21.7 % THD at the ceiling against 34.9 % at +24, and the plate has long since pinned by then. Speed is inert on tube and the Hysteresis macro is off; **TP3 is not a graded trait** (dossier §8 Q1). Drive compensation keeps small-signal level from jumping with the knob, which also means a rendered peak never sits on the plate ceiling - so **TU2 is measured without it**. Switching into or out of patch 8 re-routes the pull chain and steps at the block boundary like the patch change it is (3357 LSB against 1281 for an ordinary one): a setup choice, not a knob to ride. **Output delivers what it says since the third fix round**: the excess the node gave up at `NODE_CEILING` used to go onto the wet mixer voice as `level > 1.0`, which clamps, so Output +12 delivered **+7.866 dB**; a 10 Hz `HIGH_SHELF` carries it now and +3/+6/+9/+12 all land within **0.25 dB**, rendered, on both builds. **The tube table no longer inverts** — it did, so Mix was a notch rather than a blend (−33.580 dBFS at Mix 0.75 against −23.125 at Mix 1) — and every unit this models has an even number of gain stages. **The plate coupling pole is charged before the first block**, so patches 4 and 6 emit **1 LSB** into digital silence where they banged 8 489 and 7 044; the stated residual is 2 LSB. **`latency_samples` is the measured integer onset**, per character and factor — tube 1 / tape 2 / console 4 at ×4 — and all 18 build × rate cells read within the kit's 1-sample bar; the sub-sample group delay (3.912 / 4.753 / 6.418) is the graph's property and not what this declares. **A4 is a −6 to 0 dBFS row and TU2 is a 0 dBFS row**, and both say so; TU2 also holds **Tilt** fixed (Tilt −6 reads plate 0.3380, 8.4× the bar). TU1's guard is graded against **17 readable positions of 51**, not the 49 the row published. **Insertion loss at the default is 8.1 / 13.4 / 33.5 dB** (tube / tape / console) — Mix 1 is not unity — and the tail declaration is **3.4×** the worst measured, not 4.8×: `console` at 40 Hz rings 19 317 frames |
-| `Bitcrusher` | audiodsp (`audioshaper`, `audiobiquad`, `audioroute`; every node is audiodsp's own since 2026-09-17 - `audiospeed` is gone) | 6 | **The hold's displacement.** `latency_samples` reports `ceil(num/den) - 1` off `audioshaper.SampleHold`'s own reduced pair: **1** sample at the default at 48 and 44.1 kHz, **0** at 22.05 kHz where Rate clamps to the running rate, and **0** at Mix 0. `SampleHold.latency` is 0 at every ratio. Band Limit is an IIR and is not in the reported path. `tail_samples` is 0 with Band Limit off, **640** with it on, measured at the knob's lowest corner | Palette **8 % P4 / 15 % S3 priced** at patch 0 Mix 1 x1 (Splitter 2 taps 0.165 / 0.395 + Waveshaper x1 0.223 / 0.410 + Mixer 0.036 / 0.018 = 0.424 / 0.823 ms) plus one `audioshaper.SampleHold`, **priced on the boards 2026-09-18 at 0.050 / 0.089 ms**, against the 0.039 / 0.230 the `SpeedChanger` pair it replaced cost. Board ROW **13.3 % P4 / 25.5 % S3** at patch 0, rt 4.74 / 2.54, against a budget re-derived from that day's palette over the nodes the class actually builds - three Mixer stages, not one, and the shaper at its own 8193-point curve (0.241 / 0.464, only 0.018 / 0.033 more than the 1024-point row) - of **13 % / 28 %**. Desktop whole-class marginal **0.025 ms/block** (rt 17.83). Band Limit is not in the sum - the knob re-sources the hold, so the filtered path is not pulled; turning it on adds **0.200 / 0.338 ms** (two sections, not four), 4 and 6 points. Board ROW **unmeasured**. No lean patch | SP-1200's two numbers as knobs: 12-bit linear hold. Mix 0 is the wire. **What the default surrenders** (12 bits at 26.04 kHz, dither off, band limit off): the **hold**, not the quantizer, is what you hear - on a sine through patch 0 the residual is a thousand times the quantizer's. **The quantizer is soft, and at 12 bits soft by half**: the `Waveshaper` interpolates its table, so every riser is 8 input codes wide and the error power is `(D - 8)/D` of a hard converter's - 0.50 at 12 bits, 0.75 at 11, 0.97 at 8 - and the same riser rounds up 4 codes early, so the mean error is **+0.25 LSB at 12 bits** (a rounding asymmetry: f(0) is 0 and silence in is silence out). The hold's **images stand at their own level** and that is the Rate knob working: a 1 kHz image under a 7 kHz tone held at 8 kHz reads -0.22 dB against the input, and Band Limit puts **27.5 dB** under it; above the hold's own Nyquist the image spreads and reads 4.6 to 6.4 dB down, which is **disconfirmed** against the row's +-3 dB. The run alphabet is **99.98 %** 1s and 2s at 12 bits and falls below 99 % under 8 bits (99.41 / 97.59 / 90.52 / 66.76 at 8 / 6 / 4 / 2), so patches 2 and 6 are outside that clause. **An event can be dropped, and not only a single-sample one** — 45 % of one-sample offsets vanish at 48 kHz, 40 % at 44.1, 0 % at 22.05, and at shipped patch 6 **60 of 120 FOUR-sample offsets vanish at 48 kHz** (55 at 44.1). **The dither is gated on the input** since the third fix round: patch 5 put ±16 counts out into digital silence to the last frame, and silence in is silence out at every patch now, at the cost of a **8 192-sample tail** wherever the dither is on. **`latency_samples` carries the band limit's own delay** — patch 3 measured 2 to 8 samples over 120 offsets against a declared 6 and declares **8** now. **T1, T2 and T6 hold where the probe spans at least 16 steps of the depth in force** (−48 dBFS at 12 bits, −24 at 8) **and sweeps the codes**: a sine's mean wanders with the phase at every level (+0.28648 LSB at −55 dBFS against a +0.25 law), which is the precondition the rebuild dropped. T4's one excluded stop is 48 kHz / Rate MIDI 80, **1.495 dB** out, where the 5th image lands 1.25 Hz from the tone. **At 22.05 kHz the default does not reduce the rate at all** and the top four of the Rate knob's seventeen stops are the same identity; `hold_rate_hz` says what you got. **Dither is two coprime loops, not noise** - 1021 and 1024 frames, so it repeats every 21.8 s rather than 42.7 ms, with autocorrelation 0.47 / 0.43 at each half's own lag. A mono source is the same graph, not a downmix |
-| `Exciter` | audiodsp (`audioshaper`, `audiobiquad`, `audioroute`, `audiodynamics`) | 5 | **0 samples** at every macro setting; the dry tap is a copy. `oversample` delays the wet branch only - x4 is 3.3 samples / 0.069 ms at 48 kHz, x8 is 3.9 / 0.083 ms - and it is not on the macro surface. `tail_samples` is **4096** since the third fix round — the 30 Hz output coupling pole rings, worst measured **3 085 frames** across three rates and eight builds; it was 512. There is no delay line | Palette **29 % P4 / 54 % S3** on `classic` at patch 0, re-derived 2026-09-18 at the rows both boards took that morning (Splitter-2 0.207 / 0.368 + **2 x** Biquad 0.216 / 0.408 + Waveshaper x4 1.069 / 1.918 + Mixer 0.050 / 0.175 = 1.542 / 2.869 ms). Two Biquads and not three: `_chain` is an **alias** - `_hp` on `classic`, `_dyn` on `transient` - never a node of its own. `transient` adds `Dynamics@transient` 0.495 / 0.985: 2.037 / 3.854 ms, **39 % / 73 %** (`transient_fast_release_ms` is a config field, not an option that costs). Board ROW of the 128-frame graph was **32.3 % P4 / 57.8 % S3** on `classic` and **42.5 % / 81.6 %** on `transient`. The 256-frame graph it ships now, measured on both boards in the second pass of 2026-09-18, reads **28.9 % / 54.3 %** on `classic` and **39.3 % / 77.0 %** on `transient`, worst shipped patch **39.4 % / 77.5 %** - still over the palette sum by 1.1 / 4.7 points, which under the G6 ruling of that day (the cost gate is a real-time ceiling, 80 % of a block, and the palette sum is a planning estimate) is a note and not a park: this class is **adopted** (audiocomponents#72). **Alone on an ESP32-S3 this uses 54 % of a block on `classic` and 77 % on `transient`, 78 % at its costliest patch; there is no lean patch, so run it alone or with something cheap.** **The `Dynamics` row is NOT priced at the wrong settings**, which is what the board suspected: this class's own +6 / -12 dB gains measure **1.034x and 1.062x** the palette's 0 dB build on desktop MicroPython, order-balanced - a few per cent, not the 27 % the S3 is missing. What the S3's five extra points are is the `Dynamics` costing the class 1.234 ms against a 0.985 row (0.537 against 0.495 on the P4), and the suspect - **unproven** - is that the class handed back a **128-frame** block while a `Dynamics` renders in 256-frame blocks by construction (`audiodsp_dynamics.h:56`). It hands back the palette's own 256-frame block now and **no byte moved**, and the second board pass measured that graph: the 128-frame block was worth 3.2 / 4.6 points of it and the rest is still unexplained. rt 2.06 / **1.07** on `transient`, the thinnest margin of the seven, and measured with the probe source in the graph and nothing else - an app's source is not free. The old **33 % / 59 %** charged every build for the Dynamics wire; `classic` no longer pays it. **No lean position**: `oversample=2` was documented as one and is red at **12 of 48** T7 readings, worst **-45.1 dB**, and at the old drive on shipped patch 2 as well. **x8** would hold T7 at the old drive and does not fit the S3 (3.804 ms `classic`, rt 1.04; 4.557 `transient`, rt 0.91). Board ROW **unmeasured** | Aphex Aural Exciter - dry plus a one-sided high-passed 1N914 branch. Mix 0 is the wire. **What the default surrenders** (Tune 4 kHz, Harmonics 0.25, Mix 0.3, `classic`, Output 0 dB): **no makeup gain** - Output is -24…0 dB and the dry path is already unity. **At 22.05 kHz the corner tops out at 3150 Hz** (rate/7) whatever Tune reads back. **At 22.05 kHz T7 is disconfirmed**: "the whole 22.05 kHz column is green (worst −64.4 dB)" was read at the Tune grid's own stops, and walked at every eighth stop with Harmonics at maximum **6 of 12 readable stops are over the −60 dB bar** — worst **−41.661 at Tune MIDI 56** (1656 Hz). The row is claimed at 48 and 44.1 kHz. **T7 is also claimed only up to the stated input ceiling**: at 48 kHz with Tune and Harmonics at maximum it reads −63.461 at −6 dBFS and −63.475 at −3, and −59.856 at −1 and −59.058 at 0. **The class states an input ceiling of −3 dBFS** (`INPUT_CEILING_DBFS`) because it only attenuates and had no headroom control at all: the first railed sample anywhere is at −2 dBFS. **T1 is a -40 dBFS row and says so**: it holds Harmonics 0 and -40 dBFS fixed, where the clipper is within 0.05 dB of linear, and it is inside its 10.8-13.8 dB bar at 27 of 27 cells at -40, -30 and -20 dBFS at all three rates. Up the level axis it leaves the bar at **32 of 72 readings in 9 of 9 rows**, every one at -12 dBFS or hotter, worst **-9.635 dB** (48 kHz, Harmonics 0.25, 0 dBFS). **The harmonic reader has its control** ([#72](https://github.com/PyDevices/audiocomponents/issues/72) item 6): an identity curve - a build that makes no h2 at all - reads no h2 over T2's own 240-cell grid, worst **-64.822 dB** against the shipped class's **-15.430** at the same cells, so the four macro-surface fault walks stand at **250 / 276 / 273 / 273 RED and 0 HEALTHY**. **There is a coupling capacitor behind the diode now** — the rectified offset used to walk out at −122 to −1601 LSB on the shipped patches and −4310 LSB at Harmonics 1.0 / Mix max / −1 dBFS, where a silence test never saw it; the same cells read −1.8 and −0.1 LSB. **What the x4 is worth against x1** is a clause now (`ALIAS_OVERSAMPLE_COST_DB`, at least 15 dB wherever Tune is at or above 1900 Hz, measured +19.1 to +46.2 dB there and −2.2 to +7.1 below it), because under the absolute bar alone `BaseRate` was healthy at 21 of 276 positions — all of them the bottom of the Tune macro. And a tone whose harmonics reach past the output Nyquist folds where no oversampling factor reaches - 1010 Hz at full drive puts its 11th harmonic at 11110 Hz against a Nyquist of 11025 and reads **-48.7 dB at 10940.8 Hz**, a 3010 Hz tone puts its 4th there (-51.9) and a 8662.5 Hz tone its second (-54.9), where at 48 and 44.1 kHz the same sweep is green to rate/8. **On material with step edges the wet exceeds the dry** - at Mix maximum a 1 kHz square puts the wet at **164.7 %** of the dry, because the high-pass differentiates the edge and the reverse side of the 1N914 curve is a wire. **Harmonics is 0…+14 dB of drive across the diode, not 0…+24** - the top ten decibels bought **1.76 dB** of h2 at -6 dBFS and cost **6.7 dB** of alias, so the knob's 0…1 span is unchanged and what it commands is not. **The class adds content BELOW the corner**: the high-pass is upstream of the diode, so two tones both above it (5000 + 5500 Hz at -6 dBFS) put their **500 Hz difference tone at -17.7 dB** on the wet branch and **-31.7 dB** on the mixed output at the shipped Mix 0.3, and on noise the wet carries 12.3 dB less below the corner than the dry, not none - a low rumble that follows the music's intervals rather than its pitches, louder with Harmonics and with Mix. **T7 is a `classic` claim**: on `transient` the Dynamics stage's gain ripple folds at base rate, ahead of the shaper, 12 of 36 grid readings above the bar, worst **-52.3 dB**, the same at x2, x4 and x8 where `classic` reads -90.6. **On `transient` T4 is read after the onset**: 59.6-61.9 % of the dry on a steady sine at Mix maximum once the render's own start has passed, 124-135 % over a window that still contains it, and the boost is spent within ~25 ms. **T6 is a `transient` row and is disconfirmed** (17.6 dB of emphasis at -30 dBFS against 6.7 at -6, an 11.0 dB spread against a 3 dB bar); **T5's shape holds on `transient`** (12.75 dB at 5 kHz / -18 dBFS) and is false at the shipped `classic` default, which is the control: 0.00 dB |
-| `CabinetSim` | audiodsp (`audiobiquad`) | 8 | **0 samples** at the synthetic default. Constructor `impulse` is off by default; a user IR costs the convolver partition **256 samples / 5.333 ms at 48 kHz**, and it is the only latency-adding option. Room-scale IRs do not fit the boards (48000 taps: 18.0 / 31.1 ms). `tail_samples` is **6144**, measured burst-to-exact-zero over the whole shipped surface: 1850 samples at the default, 4421 at patch 1, **5565** at the worst corner (combo, Low Cut 55, Body +6, a 0 dBFS burst at the bell's own 115 Hz). The longest ring is the combo's bell, not the lowest Low Cut | Palette **15 % P4 / 26 % S3** at patch 0, eight live sections, Mix 1 (0.800 / 1.352 ms). **Board ROW 17.2 % / 31.1 %, re-measured 2026-09-18** at audiodsp `e3b95e7` (17.1 % / 27.7 % on 2026-09-17 at the older pin), against the same eight-section arithmetic re-priced on those boards, **17 % / 31 %**. rt 4.07 / 2.27. The gap is the palette's, not the graph's: its `audiobiquad.Biquad` row prices ONE node whose upstream is the harness, and the eighth node of a serial chain costs more than the first (0.1138 / 0.1848 ms per section here). There is no Splitter, no Mixer, no second source and no per-block Python in this graph. No lean patch, no extra source-pull | Vintage 30 on-baffle in a sealed close-miked box (`4x12 stack`); G12M-class open back (`1x12 combo`). Mix 0 is the wire. Default is a designed cascade, not a third-party impulse. **What the class gives up:** **this cabinet clips above −13 dBFS, and at the constructor default above −6 dBFS**, with stated no-railed-sample ceilings of **−14 and −6** — the −11 and −5 published before were read through a `macro_surface()` that moves **one macro at a time** and never swept Low Cut 55 with Body +6, which is shipped patch 6's own settings; read in combination the analytic peak there is **+13.148 dB** against a published +10.91. Every one of the eight sections hands the next an int16 and audiodsp's converter saturates at the rail, and the designed response is a boost (+10.9 dB at the corner the old sweep found - combo, Body +6, 118 Hz - and +5.3 dB at the default). There is no output trim. Shipped patch 6 is the worst cell (150 Hz: 8.28 % THD at -9 dBFS, 27.69 % at -3); shipped patch 1 at 115 Hz reads 2.01 % at -9 and 31.11 % at 0; the default at 2112 Hz clips above -5. Below the ceiling it is linear and no alias row is owed; above it one is - the non-harmonic floor at 3700 Hz goes from -90 dB at -20 dBFS to **-42.0 dB at 0 dBFS** (patch 0, 48 kHz) and **-25.7 dB** at 22.05 kHz. A 1 kHz guard cannot see any of it, because 1 kHz is the one frequency the class does not boost. **It is not the same cabinet at 22.05 kHz**: 48 and 44.1 kHz agree within 0.3 dB, but at 22.05 kHz 5 kHz sits **+4.34 dB** higher re 1 kHz and 7 kHz 4.37 dB lower, the worst cell over the seven patches is **4.95 dB** (patch 3 at 8 kHz), and T5a's upper -3 dB point moves 4551 -> 5050 Hz - the bilinear warping of the two top shelves, one design reinterpreted at each rate. **The macro surface is the 128 MIDI positions** - `set_macro` snaps a float, because only those are proved to hand a board and a desktop the same filter. **Every Tier 2 trait is a Mix 1 claim** - Mix blends every section at once, so T1 holds down to Mix MIDI 112 (14.6 dB at MIDI 96, under its 15 dB bar) and T4 down to Mix MIDI 48 (-8.6 dB at MIDI 32, over its -12); between those and Mix 0 neither is claimed. **T2 is not claimed above Top 5.6 kHz at every Air, nor above 6.3 kHz at any** - the shelves separate as Top rises (3.8 dB at 5.6 kHz, 10.3 at 6.3, 14.0 at 6.6, 17.4 at the 7 kHz stop) and T1 goes with it at 14.6 dB at the stop; **T2 is not applicable at 22.05 kHz**. **T3 is not claimed below the default Bite**: at Bite 0 the two break-up sections are switched off entirely, and shipped patch 2 ships Bite +1 and misses T3's 3 dB bar. **T5a's 115-230 Hz plateau is disconfirmed** - the three high-pass poles put 115 Hz **4.02 dB** under the low-frequency maximum - and its **60 Hz clause holds while its 200 Hz-1 kHz clause holds at Body 0 and nowhere else** (Body −6 reads −1.31, a rise; Body +6 reads +7.48, over the 6 dB the trait allows); the lower −3 dB point lands at **121 Hz**. **T4 is a 40.00 Hz row and the pack read 41.0156 Hz** — the tone snapped to the render's bin grid, worth **1.28 dB** on a six-pole rolloff: at an exact 40.00 Hz the default reads **−42.443 / −42.441 / −42.483 dB** against a published −41.16 / −41.96 / −42.01, and patch 6 **−19.009** against −17.94. **`tail_samples` carries a user impulse now** — it returned the eight-section constant whatever `impulse=` held, and an 8 192-tap IR measures 8 447 samples of tail while a 48 000-tap room measures 48 255 against a declared 6 144. **Three Tier 2 readings go red above the ceiling** (T3 at 0 dBFS, T5a's 200 Hz clause at +1.21, T5b's level clause at −6 dBFS with Low Cut 55 and Body +6) and each row takes its level span. **T5b is a claim about the character**, read at identical macros, not about the patch pair. Every hertz and decibel is quantised onto a coarse grid, and the macro position onto the MIDI integers, so an ESP32's single-precision Python and a desktop's double produce the same float32 (audiocomponents#75) |
-
-The three saturation characters are different curves, not one curve with
-presets. `tube` is a 12AX7 common-cathode stage; `tape` is odd Langevin
-saturation plus a playback-loss curve that slides with Speed; `console` is
-transformer iron saturating from the bottom up. Mix 0 is the wire on all
-three. Drive compensation keeps small-signal level from jumping with the
-knob.
-
-### Pitch and stereo - `pitch.py`
-| Class | Notes |
-|---|---|
-| `PitchShifter` | time-independent shift |
-| `Harmonizer` | dry + up to three fixed intervals |
-| `Octaver` | one or two octaves either way; only the branches asked for are built |
-| `StereoWidener` | Haas: short-delayed copy panned wide against the dry |
-
-### Effect racks - `rack.py`
-| Class | Notes |
-|---|---|
-| `Rack` | a serial chain of this package's effects, described by a portable `chain` literal; owns its children, borrows the source |
-| `ShimmerHall` | dry + octave-up through a tape echo into a long hall; **patches** |
-| `AirSpace` | swept tone filter into a wobbling tape delay into a hall; **patches** |
-
-A rack is one component with exactly the effect shape, however many effects
-its internal graph holds - which is why racks live here rather than in a
-package of their own. `Rack` is the reusable mechanism, ported from the way
-micropython-vst3's soundtrack builds its custom racks:
-
-```python
-rack = audioeffects.create("Rack", source, 48000, chain=(
- ("Compressor", {"threshold_db": -24.0, "ratio": 3.0}),
- ("TapeDelay", {"time_ms": 340.0, "mix": 0.25}),
- ("Reverb", {"character": "hall", "mix": 0.3}),
-))
-```
-
-The chain entries are `NAME` strings or `(NAME, options)` pairs - portable
-literals, so a host can carry a rack definition as data. Racks nest: an
-entry may itself be `("Rack", {"chain": (...)})`, and a rack's `.output`
-feeds anything an effect's can. A rack reports its complete graph's
-latency (children summed) and tail (`None` as soon as any child's is
-unbounded), and its `reset()` clears every child's DSP history without
-reapplying the children's own patches over the options the rack built
-them with. `ShimmerHall` and `AirSpace` are the two racks the vst3
-soundtrack shares between pieces, ported whole: fixed topologies whose
-macros move their children's controls. They name the classes they were
-ported against, `delay.TapeDelay` and `reverb.Reverb`, directly: a chain
-entry may be a class in place of a `NAME`. Moving them onto the rebuilds
-adopted on 2026-09-29 would re-voice them, and is not part of the adoption.
## Deliberately absent
@@ -285,16 +230,17 @@ adopted on 2026-09-29 would re-voice them, and is not part of the adoption.
shift by an interval you name; nothing here works out what interval to ask
for.
- **Sample-rate reduction as a stock Distortion mode** - CircuitPython's
- LOFI masks low bits and nothing else. The rebuilt `Bitcrusher` holds both
+ LOFI masks low bits and nothing else. `Bitcrusher` holds both
word length and hold rate on `audioshaper`, and it is what the package
- serves: it came home to `bitcrusher.py` on 2026-09-18.
+ serves.
- LFO-driven parameters update at the block rate (~187 Hz at 48 kHz),
plenty for sweep rates but not audio-rate modulation. `RingMod` is the
exception and does not use an LFO at all - see below.
+
## A note on how low a filter can go
-Anywhere in the band on `audiobiquad`, which is what the rebuilt filters and
+Anywhere in the band on `audiobiquad`, which is what the filters and
EQs here are built on. On `synthio.Biquad` - and so `audiofilters.Filter` and
a `Note.filter` chain - the low end is CircuitPython's, on every target, and
it is worth knowing why.
@@ -316,7 +262,7 @@ fractional bits as each filter has room for, the recursion accumulates in
64 bits and keeps its feedback below the sample grid, and the trigonometry
is a proper series. Measured against the closed-form response, every mode
lands within **0.03 dB from 50 Hz to 22 kHz**. Ten octave bands all read
-+6.01 dB or better on a +6 dB request, read on `ParametricEQ`. The rebuilt
++6.01 dB or better on a +6 dB request, read on `ParametricEQ`. The
`MultibandCompressor` sums to 0.12 dB from 30 Hz to 20 kHz on
`audiobiquad`'s float sections. `Saturation` builds its shelves there too,
and falls back to `synthio.Biquad` only where `audiobiquad` is missing.
@@ -385,10 +331,9 @@ are worth knowing about:
its unity-outside-the-band property: a +6 dB bell at 1 kHz / Q 1 is
about **+21 dB at DC**, worse the lower the center. Still present
upstream. It no longer reaches the catalogue's bells: `ParametricEQ`,
- `GraphicEQ` and `DynamicEQ` are all rebuilt onto `audiobiquad`, which
+ `GraphicEQ` and `DynamicEQ` are all built on `audiobiquad`, which
computes the sign correctly in its own float kernel and never went
- through this. The old family `eq.py:ParametricEQ` used the ported
- kernel; that module is gone.
+ through this.
- Biquad coefficients are Q15 and the recursion accumulates in 32 bits, so
nothing below roughly 300 Hz is the filter it was asked to be, and one
polynomial covers sine and cosine only as far as π/2 - 12 kHz at 48 kHz -
@@ -406,9 +351,8 @@ the measurements and the one-line coefficient fix.
`audioeffects.ALL` to the contract - exports and metadata, the factory,
building and rendering, chaining, the patch surface - under CPython. What
each class *sounds* like is measured beside it, one module per family:
-`test_cpython_effects_dynamics_eq.py`, `..._modulation.py`, `..._time.py`,
-`..._pitch.py` and `..._racks.py` - plus one module per rebuilt class, which
-is what a family module's phase leaves behind - with
+`test_cpython_effects_dynamics_eq.py` and `..._modulation.py`, plus one
+module per class that has its own, with
`test_cpython_convolve_node.py` for the audioconvolve node underneath the
convolvers. `tests/parity/effects_library_smoke.py` is the coarse half of
all that in portable Python, so MicroPython and patched CircuitPython can
diff --git a/lib/audioeffects/__init__.py b/lib/audioeffects/__init__.py
index ede9190..09b04d4 100644
--- a/lib/audioeffects/__init__.py
+++ b/lib/audioeffects/__init__.py
@@ -10,16 +10,39 @@
audio_out.play(comp.output)
The package factory receives the source and sample rate explicitly. Direct
-class construction remains supported for local code after `configure()`.
+class construction is supported for local code too, and reads the format
+off the source it is handed.
Some classes also carry patches - named settings on the 0-127 MIDI grid, the
way an instrument does. See README.md, "A note on patches".
"""
from . import _component
-from . import _core
from . import rebuilt as _rebuilt
-from ._core import channel_count, configure, sample_rate
+
+
+#: What `configure()` last set. No class reads it: every effect takes its
+#: format from the source it is built around. It stays so the
+#: `configure(48000)` idiom in existing code keeps working.
+_FORMAT = [48000, 2]
+
+
+def configure(rate, channel_count=2):
+ """Record a default format. Kept for code that calls it; an effect's
+ format always comes from its source, so this changes no effect."""
+ _FORMAT[0] = int(rate)
+ _FORMAT[1] = int(channel_count)
+
+
+def sample_rate():
+ """The rate `configure()` last recorded (48000 until it is called)."""
+ return _FORMAT[0]
+
+
+def channel_count():
+ """The channel count `configure()` last recorded (2 until it is called)."""
+ return _FORMAT[1]
+
from .compressor import Compressor
from .limiter import Limiter
@@ -37,16 +60,23 @@ class construction remains supported for local code after `configure()`.
from .ladderfilter import LadderFilter
from .combfilter import CombFilter
from .dynamiceq import DynamicEQ
-from .reverb import Reverb, ConvolutionReverb
-from .delay import (DigitalDelay, SlapbackDelay, TapeDelay, AnalogDelay,
- PingPongDelay, MultiTapDelay)
+# Phase 5's and Phase 6's classes are served from `rebuilt/` until they
+# come home as one file each.
+from .rebuilt.reverb import Reverb
+from .rebuilt.convolutionreverb import ConvolutionReverb
+from .rebuilt.digitaldelay import DigitalDelay
+from .rebuilt.slapbackdelay import SlapbackDelay
+from .rebuilt.tapedelay import TapeDelay
+from .rebuilt.analogdelay import AnalogDelay
+from .rebuilt.pingpongdelay import PingPongDelay
+from .rebuilt.multitapdelay import MultiTapDelay
from .chorus import Chorus
from .phaser import Phaser
from .tremolo import Tremolo
from .autopan import AutoPan
from .vibrato import Vibrato
from .ringmod import RingMod
-from .modulation import Flanger
+from .flanger import Flanger
from .overdrive import Overdrive
from .distortion import Distortion
from .bitcrusher import Bitcrusher
@@ -54,8 +84,13 @@ class construction remains supported for local code after `configure()`.
from .fuzz import Fuzz
from .saturation import Saturation
from .exciter import Exciter
-from .pitch import PitchShifter, Harmonizer, Octaver, StereoWidener
-from .rack import Rack, ShimmerHall, AirSpace
+from .rebuilt.pitchshifter import PitchShifter
+from .rebuilt.harmonizer import Harmonizer
+from .rebuilt.octaver import Octaver
+from .rebuilt.stereowidener import StereoWidener
+from .rebuilt.rack import Rack
+from .rebuilt.shimmerhall import ShimmerHall
+from .rebuilt.airspace import AirSpace
#: The package's whole surface, grouped as the modules above are. Every
@@ -89,15 +124,12 @@ class construction remains supported for local code after `configure()`.
def _is_provider(candidate):
"""Whether `candidate` is one of this package's public effect classes.
- Two bases, on purpose: `_core.Effect` for the families this program has
- not rebuilt yet, `_component.Component` for the ones it has. The test is
- structural rather than a base-class list a consumer must know about --
- the contract is structural too (`docs/audio-component-api.md`).
+ Every class the package ships is a `_component.Component` subclass that
+ carries a `NAME`; the base itself is not a provider.
"""
return (isinstance(candidate, type)
- and candidate is not _core.Effect
and candidate is not _component.Component
- and issubclass(candidate, (_core.Effect, _component.Component))
+ and issubclass(candidate, _component.Component)
and getattr(candidate, "NAME", None) is not None)
@@ -116,11 +148,13 @@ def _adopt(namespace, names):
Chorus, Phaser, Tremolo, Vibrato, RingMod) and all seven of Phase 4's
(Overdrive, Distortion, Fuzz, Saturation, Bitcrusher, Exciter,
CabinetSim) have come home as one file per effect beside this module, so
- they are imported above rather than substituted. The machinery stays for
- Flanger and for the phases still to come: `rebuilt.load()` answers
- `None` for a parked name (or a name with no file), and
- `rebuilt.module_class()` is how a tool or a class's own tests reach a
- rebuild that has not come home yet.
+ they are imported above rather than substituted, and Phase 5's and
+ Phase 6's are imported above straight from `rebuilt/`, where they live
+ until they come home. Every name is therefore already the class this
+ would substitute; the walk stays so a rebuild can still land as one new
+ file: `rebuilt.load()` answers `None` for a parked name (or a name with
+ no file), and `rebuilt.module_class()` is how a tool or a class's own
+ tests reach a rebuild that has not come home yet.
"""
for exported in names:
current = namespace.get(exported)
diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py
index 4f61784..e60d159 100644
--- a/lib/audioeffects/_component.py
+++ b/lib/audioeffects/_component.py
@@ -1,11 +1,10 @@
"""The construction shape every rebuilt effect class is built on.
-`_core.py` is the pre-contract shape: a module-level `configure()` holds the
-sample rate, and `Effect.__new__` sniffs the format off whatever it is handed.
-The effects roadmap (§3, "What the contract freezes") frees that, and this
-module is the replacement. It lands beside `_core.py` rather than over it:
-the families not yet rebuilt keep importing `_core`, untouched, until Phase 6
-retires it with the last of them.
+It replaced `_core.py`, the pre-contract shape, in which a module-level
+`configure()` held the sample rate and `Effect.__new__` sniffed the format
+off whatever it was handed. The effects roadmap (§3, "What the contract
+freezes") freed that; every family was rebuilt on this module, and
+`_core.py` was retired in Phase 7.
What is different here, and why:
@@ -18,7 +17,7 @@
earlier, which is the failure `configure()` invited.
* **A class enumerates the nodes it builds**, with `self._own(node)`, and
- `reset()` and `deinit()` walk that list. `_core.reset()` touches the output
+ `reset()` and `deinit()` walk that list. `_core.reset()` touched the output
node only, so what a chain's reset actually clears depends on which node
happened to be last. Every node clears something different:
`audiofilters.Filter` and `Phaser` clear their own delay buffers and
@@ -249,8 +248,8 @@ def _set_output(effect, node):
#: port itself is `deinit`'s job and not this setter's, because the order
#: matters there and a plain attribute store is no place to say so.
#:
-#: Both bases carry it -- `_core.Effect` imports this very object -- so the
-#: pre-contract classes get the same fix without being rebuilt first.
+#: The pre-contract `_core.Effect` base carried this same object until it
+#: was retired, so its classes had the fix too.
output_port = property(_get_output, _set_output)
@@ -309,6 +308,51 @@ def static_transport():
return (False, 0.0, 120.0, 4, 4)
+#: The tempo and the quarter notes in a bar a synced class follows until a
+#: transport it can read says otherwise: `static_transport`'s 120 bpm, 4/4.
+GOOD_BEAT = (120.0, 4.0)
+
+#: The largest numerator or denominator of a time signature `synced_beat`
+#: follows. A bar of 128/1 is 512 quarter notes; past it a synced sweep
+#: would stand still.
+METER_LIMIT = 128.0
+
+
+def _field(state, index):
+ """`state[index]` as a finite float, or None for a field that is missing
+ or is not a finite number."""
+ try:
+ value = float(state[index])
+ except (TypeError, ValueError, OverflowError, IndexError):
+ return None
+ if value != value or value in (float("inf"), float("-inf")):
+ return None
+ return value
+
+
+def synced_beat(state, last=GOOD_BEAT):
+ """(bpm, quarter notes in one bar) read off a host transport tuple
+ `(playing, position, bpm, numerator, denominator)`: numerator x 4 /
+ denominator quarters, so 4/4 is 4, 3/4 is 3 and 6/8 is 3.
+
+ What a host gets wrong keeps the last good reading, `last`, rather than
+ raising or standing the sweep still: a tempo that is missing, None, NaN,
+ infinite, or 0 or below keeps `last`'s tempo, and a signature with a
+ field missing, or a numerator or denominator that is not a number from
+ 1 to `METER_LIMIT`, keeps `last`'s bar. A tempo below 1 bpm is 1."""
+ bpm, quarters = last
+ tempo = _field(state, 2)
+ if tempo is not None and tempo > 0.0:
+ bpm = max(1.0, tempo)
+ numerator = _field(state, 3)
+ denominator = _field(state, 4)
+ if numerator is not None and denominator is not None \
+ and 1.0 <= numerator <= METER_LIMIT \
+ and 1.0 <= denominator <= METER_LIMIT:
+ quarters = numerator * 4.0 / denominator
+ return bpm, quarters
+
+
def biquad_ring(node):
"""Frames an `audiobiquad.Biquad` rings for after its input stops, from
any state, as an upper bound, read off the coefficients in force.
@@ -394,6 +438,16 @@ def macro_position(span, value):
return min(1.0, max(0.0, position))
+def clamped_midi(value):
+ """`value` on the 0-127 MIDI scale as a float, clamped to it: NaN is 0,
+ and an int too large for a float (10**400) is the stop on its side."""
+ try:
+ value = float(value)
+ except OverflowError:
+ return 127.0 if value > 0 else 0.0
+ return min(127.0, max(0.0, value))
+
+
def position_of_midi(mode, midi):
"""The 0-127 MIDI grid -> a macro's 0..1 position, by the macro's mode.
@@ -417,7 +471,7 @@ def position_of_midi(mode, midi):
continuous at 64 and takes floats, so a host with finer resolution than
7 bits still lands where its knob says.
"""
- midi = min(127.0, max(0.0, float(midi)))
+ midi = clamped_midi(midi)
if mode != "BIPOLAR":
return midi / 127.0
if midi >= 64.0:
@@ -447,6 +501,38 @@ def macro_of(span, value, mode="UNIPOLAR"):
return int(round(midi_of_position(mode, macro_position(span, value))))
+#: Frames in one block of `_lazy_silence` (an `audioroute.MidSide` with no
+#: source hands out this many per pull).
+_SILENCE_FRAMES = 256
+
+
+def _drain(pull, mixer):
+ """Pull `mixer` until each voice on the lazy silence has handed up the
+ whole silent block it primed and holds nothing: a mixer that pulls
+ fewer frames than the block (128 at two channels on some classes) left
+ the rest to play first, and the output started half a block late."""
+ total = 0
+ for _ in range(64):
+ state, data = pull(mixer)
+ frames = len(data) // (2 * mixer.channel_count) if data else 0
+ if not frames:
+ return
+ total += frames
+ if total % _SILENCE_FRAMES == 0:
+ return
+
+
+def release(node):
+ """Let go of a node no graph points at any more (None is fine): its
+ `release_all()` where it has one, then its `deinit()`."""
+ if node is None:
+ return
+ for name in ("release_all", "deinit"):
+ call = getattr(node, name, None)
+ if call is not None:
+ call()
+
+
# --------------------------------------------------------------------------
# Level gates
@@ -487,6 +573,42 @@ def open_level_gates(node, voices, silence):
return True
+def let_go_of_source(head, source, consumers=()):
+ """Drop the source frames `head` has taken and not handed on, without
+ reading or resetting `source`. Returns whether it could.
+
+ For the node at the head of a graph a bypass stopped pulling, called
+ as the graph rejoins. While Mix is 0 the host reads the source itself,
+ so whatever the head still held from before is old audio: played on
+ the way back it is up to 20 ms of what was sounding when the bypass
+ began, and the stream runs late by as much. A host reset does not drop
+ those frames, by design (the source was not reset), so the reset walk
+ `_rejoin` runs leaves them.
+
+ A Splitter holds them in its ring, and `Splitter.clear()` lets them go
+ (audiodsp 0.6.6rc7); before that node there is no way to, and this
+ returns False. A node with `play()` (a Biquad, a MidSide) drops what it
+ held when it is handed its source again, and reads nothing from it.
+
+ `consumers` are `(node, tap)` pairs, each a node reading one of the
+ Splitter's taps, handed its tap again so it drops what it held too. A
+ tap hands out at most 256 frames, and less at the end of a source
+ buffer, so the first node behind it carries part of a chunk across
+ blocks: old audio as well, once the ring is cleared. Nodes further
+ down read whole blocks and hold nothing over.
+ """
+ if hasattr(head, "tap"):
+ clear = getattr(head, "clear", None)
+ if clear is None:
+ return False
+ clear()
+ for node, tap in consumers:
+ node.play(tap)
+ return True
+ head.play(source)
+ return True
+
+
# --------------------------------------------------------------------------
# Metadata
@@ -746,10 +868,21 @@ def __init__(self, source, *options, **keywords):
#: (`_KEPT`) keeps it through a knob applied while this is set.
self._seeding = False
+ # A `LAZY_BUILD` class's constructor arms lazily, as a reset does
+ # (`_play_voice`): a voice primes a block of silence, not the
+ # source's first block, so a Mix 0 set before the first pull hands
+ # out the source from frame 0 rather than from frame 256. The
+ # silence is handed up by `get_buffer`, or where it is compiled out
+ # by `_pull_node`.
+ pull = getattr(audiocore, "get_buffer", None)
+ self._lazy_pending = []
+ self._arming_lazily = bool(cls.LAZY_BUILD and PORT is not None)
try:
self._build(*options, **keywords)
finally:
self._constructing = False
+ self._arming_lazily = False
+ self._hand_up_lazy(pull)
if self._output is None:
raise TypeError("%s._build() set no output" % cls.NAME)
@@ -899,17 +1032,30 @@ def _hz(self, frequency):
return 1.0
return frequency
- def _init_macros(self, values, patch=None):
+ def _init_macros(self, values, patch=None, defaults=None):
"""Seed the knobs from `_build`'s own arguments, in the macros' own
units, and apply a patch over the top if one was asked for. This is
deliberately the only route into `_apply_macro`, so the constructor
- and a later `set_macro` cannot drift apart."""
+ and a later `set_macro` cannot drift apart.
+
+ `defaults`, when given, holds one flag per value: True where the
+ caller did not choose it (the argument was left out). That value is
+ seeded on patch 0's step for its macro rather than at the value
+ itself, so a fresh instance is patch 0 exactly, as the contract
+ says, and a reset lands where a fresh instance starts. A value the
+ caller passed is seeded where it is."""
cls = type(self)
if len(values) != len(cls.MACRO_LABELS):
raise TypeError("%s seeded %d macros for %d labels"
% (cls.NAME, len(values), len(cls.MACRO_LABELS)))
self._macros = [macro_position(span, value)
for span, value in zip(cls._MACRO_RANGES, values)]
+ if defaults is not None:
+ steps = cls.PATCHES[0][1]
+ for index in range(len(values)):
+ if defaults[index]:
+ self._macros[index] = position_of_midi(
+ self._mode(index), steps[index])
for index in range(len(self._macros)):
self._apply_macro(index, self._macros[index])
self._patch_index = 0
@@ -1001,7 +1147,7 @@ def set_macro(self, index, value, channel=0, note_id=-1,
self._macro_index(index)
self._macros[index] = position_of_midi(self._mode(index), value)
self._patch_index = None
- self._apply_macro(index, self._macros[index])
+ self._lazily(self._apply_macro, index, self._macros[index])
def get_macro(self, index):
self._check_live()
@@ -1032,9 +1178,10 @@ def program_change(self, index, channel=0, note_id=-1,
patch = type(self).PATCHES.get(index)
if patch is None:
return
- self._apply_positions([position_of_midi(self._mode(macro), value)
- for macro, value in enumerate(patch[1])],
- index)
+ self._lazily(self._apply_positions,
+ [position_of_midi(self._mode(macro), value)
+ for macro, value in enumerate(patch[1])],
+ index)
def _apply_positions(self, positions, patch_index):
"""Set the macros to `positions` (0..1, one per macro from index 0),
@@ -1083,7 +1230,7 @@ def _restore(self, record):
setattr(self, name, value)
self._seeding = True
try:
- self._apply_positions(list(positions), patch_index)
+ self._lazily(self._apply_positions, list(positions), patch_index)
finally:
self._seeding = False
@@ -1131,6 +1278,16 @@ def transport_changed(self):
self._followed_bpm = bpm
self._apply_macro(index, self._macros[index])
+ def _synced_beat(self):
+ """`synced_beat` of the transport as it reads now, against the last
+ good reading this instance took (`GOOD_BEAT` before the first),
+ which it then keeps."""
+ transport = self._transport
+ state = transport() if callable(transport) else transport
+ beat = synced_beat(state, getattr(self, "_good_beat", GOOD_BEAT))
+ self._good_beat = beat
+ return beat
+
def poly_pressure(self, pitch, value, channel=0, note_id=-1,
sample_position=0):
self._check_live()
@@ -1160,6 +1317,262 @@ def reset(self):
self._restore(self._reset_to)
self._stranded = False
+ #: Set inside `_reset_rearmed`, and inside the constructor for a class
+ #: with `LAZY_BUILD`: `_play_voice` hands a voice a stand-in.
+ _arming_lazily = False
+ #: Whether the constructor arms the voices the way a reset does: on
+ #: silence, so building takes nothing from the source. A class that
+ #: primes at construction and then routes around its graph at Mix 0
+ #: wants it; without it a Mix 0 set before the first pull hands out a
+ #: source that has already given its first block to a voice.
+ LAZY_BUILD = False
+
+ def _play_voice(self, voice, source, loop=False):
+ """`voice.play(source)`, except while `_reset_rearmed` (or a
+ `LAZY_BUILD` constructor) arms the graph: then the voice is played
+ on a Port standing on silence, so priming it takes nothing from the
+ source, and the Port is pointed
+ at `source` once the voice has given its silent block up. The next
+ pull then fetches from `source` itself, which is what a fresh
+ build's voice does after the block it primed has played."""
+ if not self._arming_lazily or PORT is None:
+ getattr(self, "_lazy_on", {}).pop(id(voice), None)
+ voice.play(source, loop=loop)
+ return
+ ports = getattr(self, "_lazy_ports", None)
+ if ports is None:
+ ports = self._lazy_ports = {}
+ port = ports.get(id(voice))
+ # A graph built again since (Distortion's Character) released the
+ # Port with the voice it stood in for; and a new voice may have the
+ # old one's id.
+ if port is not None and not _named(port, self._nodes):
+ port = None
+ if port is None:
+ port = self._own(PORT(source), reset=False)
+ ports[id(voice)] = port
+ port.play(self._lazy_silence())
+ voice.play(port, loop=loop)
+ self._lazy_pending.append((voice, port, source))
+ on = getattr(self, "_lazy_on", None)
+ if on is None:
+ on = self._lazy_on = {}
+ on[id(voice)] = port
+
+ def _lazily(self, call, *args):
+ """`call(*args)` with the voices it arms armed lazily, for a
+ `LAZY_BUILD` class, and the silence they primed handed up after.
+
+ A control re-arms as a build does: a rejoin off Mix 0, a patch that
+ builds the graph again, a reset a class does itself. Each primes
+ its voices, and a voice primed off the source takes a block the
+ bypass then never hands out when Mix 0 follows before a pull. So
+ here, as in the constructor, they prime silence and are pointed at
+ their sources once it is handed up. Inside a build or a reset that
+ already arms lazily, it is the plain call."""
+ if (self._arming_lazily or self._constructing or PORT is None
+ or not type(self).LAZY_BUILD):
+ return call(*args)
+ self._arming_lazily = True
+ try:
+ call(*args)
+ finally:
+ self._arming_lazily = False
+ self._hand_up_lazy(getattr(audiocore, "get_buffer", None))
+
+ def _repoint_voice(self, voice, source):
+ """Hand a voice that is playing a new source without priming it:
+ the Port it stands on is pointed at `source`, and the next pull
+ fetches from it. `source` is not reset: a stage the graph has been
+ pulling all along carries on, where a reset jumped its state. A
+ voice that is not on a Port is played (`_play_voice`). A route
+ change re-primed one voice and left the other where it was: the
+ two then ran a block apart for good, and the bypass that followed
+ handed out a source a block short."""
+ for at in range(len(self._lazy_pending)):
+ if self._lazy_pending[at][0] is voice:
+ port = self._lazy_pending[at][1]
+ self._lazy_pending[at] = (voice, port, source)
+ return
+ port = getattr(self, "_lazy_on", {}).get(id(voice))
+ if port is None or not _named(port, self._nodes):
+ self._play_voice(voice, source)
+ return
+ port.play(source)
+
+ def _lazy_silence(self):
+ """A source that hands one block of zeros per pull and never ends:
+ a width-1 `audioroute.MidSide` with nothing to play (MultiTapDelay's
+ `_hush`). A two-frame sample would end, and a voice that saw it end
+ would stop."""
+ silence = getattr(self, "_lazy_zero", None)
+ # Only while this graph still owns it: a graph built again since (a
+ # patch across Distortion's Character) released it with the rest,
+ # and a second reset that reached for it raised "deinitialized".
+ if silence is not None and not _named(silence, self._nodes):
+ silence = None
+ if silence is None:
+ import audioroute
+ silence = self._own(audioroute.MidSide(
+ width=1.0, sample_rate=self._sample_rate,
+ channel_count=self._channel_count), reset=False)
+ self._lazy_zero = silence
+ return silence
+
+ def _pull_node(self, node):
+ """Pull one block of `node` through the graph from the control
+ side, and return what `audiocore.get_buffer` returns, or None where
+ the build has none (CircuitPython's default board builds compile it
+ out). There a scratch one-voice Mixer primes from a width-1
+ `audioroute.MidSide` standing on `node`: the voice's `play()` resets
+ the MidSide, which forwards nothing upstream, and takes one block,
+ so `node` is pulled once (for blocks of 256 frames or more) and its
+ state moves exactly as a `get_buffer` would move it. The block
+ itself can't be read back, and the voice is stopped again, so a
+ later pull of the scratch Mixer reaches nothing. Returns False
+ where neither way exists (no `audioroute`)."""
+ pull = getattr(audiocore, "get_buffer", None)
+ if pull is not None:
+ return pull(node)
+ if PORT is None:
+ return False
+ scratch = getattr(self, "_puller", None)
+ if scratch is not None and not _named(scratch[0], self._nodes):
+ scratch = None
+ if scratch is not None and node is scratch[0]:
+ return None
+ if scratch is None:
+ import audiomixer
+ import audioroute
+ mixer = self._own(audiomixer.Mixer(
+ voice_count=1, **self._pcm(512 * self._channel_count)),
+ reset=False)
+ shim = self._own(audioroute.MidSide(
+ width=1.0, sample_rate=self._sample_rate,
+ channel_count=self._channel_count), reset=False)
+ scratch = self._puller = (mixer, shim)
+ mixer, shim = scratch
+ shim.play(node)
+ mixer.voice[0].play(shim)
+ mixer.voice[0].stop()
+ return None
+
+ def _reset_rearmed(self, rearm, primed=(), building=False):
+ """`reset()` for a class whose mixer voices each hold a block they
+ primed ahead, and which arms them once, the way `_build` does.
+
+ The base walk clears the nodes behind a voice but not the block the
+ voice already took through them, so the first block after a reset
+ was the old chain's and the next one a cleared chain's: a step of a
+ few to a few hundred LSB where a fresh instance has none. Here every
+ voice of every mixer this class owns is stopped, the `primed` flags
+ are dropped, patch 0 goes in with the graph unarmed (`_ready` off,
+ so no macro primes a voice part-way through the patch), and then
+ `rearm()` arms the graph as coming back from a bypass does: the
+ class's own refresh, called with the graph marked stranded so its
+ `_rejoin` branch runs. With `building`, the patch goes in with
+ `_constructing` set, for a class that builds part of its graph again
+ on a macro and must not connect it part-way through the patch.
+
+ **The arming is lazy: a reset takes nothing from the source.** Each
+ voice the class plays through `_play_voice` primes a block of
+ silence instead; each mixer is pulled once, which hands those up;
+ and then the voices' stand-ins are pointed at their real sources,
+ so the next pull fetches as a fresh build's next pull does. Where
+ `audiocore.get_buffer` is compiled out, `_pull_node` hands the
+ silence up, so a board resets as a desktop does. Saturation keeps its
+ own `reset()`.
+ """
+ self._check_live()
+ self._clear_nodes()
+ for node in self._nodes:
+ voices = getattr(node, "voice", None)
+ if voices is None:
+ continue
+ for voice in voices:
+ voice.stop()
+ for name in primed:
+ setattr(self, name, False)
+ ready = getattr(self, "_ready", None)
+ self._ready = False
+ self._stranded = False
+ # `building`: the patch goes in as a constructor's `patch=` does,
+ # for a class that builds part of its graph again on a macro.
+ self._constructing = bool(building)
+ try:
+ if self._reset_to is None:
+ self.program_change(0)
+ else:
+ self._restore(self._reset_to)
+ finally:
+ self._ready = ready
+ self._constructing = False
+ # Stranded, so the refresh's `_rejoin` arms the graph (and clears
+ # it again, which changes nothing). A patch 0 at Mix 0 routes
+ # around instead; the Mix move off 0 arms it then.
+ self._stranded = True
+ self._lazy_pending = []
+ # Lazily wherever the primed silence can be handed up before the
+ # next pull: by `get_buffer`, or where it is compiled out by
+ # `_pull_node` (a snapshot of the nodes, since `_pull_node` may own
+ # its scratch mixer).
+ pull = getattr(audiocore, "get_buffer", None)
+ self._arming_lazily = PORT is not None
+ try:
+ rearm()
+ finally:
+ self._arming_lazily = False
+ # Settings that route around the graph leave it as `_build` leaves
+ # a graph it routes around: cleared and not played since, so not
+ # stranded. A later Mix move then arms it as a fresh instance's
+ # does, rather than clearing it again through `_rejoin`.
+ self._stranded = False
+ pending = self._lazy_pending
+ self._lazy_pending = []
+ if pending:
+ # The graph as it stands now: a patch that builds part of it
+ # again has new mixers.
+ for node in list(self._nodes):
+ if getattr(node, "voice", None) is None:
+ continue
+ if pull is not None:
+ _drain(pull, node)
+ else:
+ self._pull_node(node)
+ for _voice, port, source in pending:
+ port.play(source)
+
+ def _hand_up_lazy(self, pull):
+ """The constructor's half of the lazy arming: pull each mixer
+ once, so the voices `_play_voice` armed on silence hand that
+ silence up, then point their stand-ins at the real sources, so the
+ first pull fetches as an eagerly primed build's would. `pull` is
+ `get_buffer`, or None where it is compiled out and `_pull_node` does
+ it."""
+ pending = self._lazy_pending
+ self._lazy_pending = []
+ if not pending:
+ return
+ # The graph as it stands now (a snapshot: `_pull_node` may own its
+ # scratch mixer): a patch that builds part of it again has new
+ # mixers, and released the old voices' stand-ins. Only a mixer with
+ # a voice on the silence: mid-stream, pulling one that plays its
+ # real sources would take a block of them.
+ for node in list(self._nodes):
+ voices = getattr(node, "voice", None)
+ if voices is None:
+ continue
+ if not any(voice is armed for armed, _p, _s in pending
+ for voice in voices):
+ continue
+ if pull is not None:
+ _drain(pull, node)
+ else:
+ self._pull_node(node)
+ for _voice, port, source in pending:
+ if _named(port, self._nodes):
+ port.play(source)
+
def deinit(self):
"""Release every node this class built, tail first. Idempotent, and
it never deinitialises the borrowed source."""
@@ -1212,7 +1625,7 @@ def _midi_value(value, name):
def _bounded_midi(value):
- return min(127.0, max(0.0, float(value)))
+ return clamped_midi(value)
def _pitch_bend(value):
diff --git a/lib/audioeffects/_core.py b/lib/audioeffects/_core.py
deleted file mode 100644
index 87d067a..0000000
--- a/lib/audioeffects/_core.py
+++ /dev/null
@@ -1,550 +0,0 @@
-"""Shared plumbing for the effects library.
-
-Every effect class takes its audio source as the first argument - a
-synthesizer, an instrument's ``output``, the host's input, or a previous
-effect's ``output`` - builds its chain immediately, and exposes the chain
-tail as ``.output``:
-
- import audioeffects
-
- comp = audioeffects.create("Compressor", source, 48000,
- threshold_db=-20, ratio=3)
- verb = audioeffects.create("Reverb", comp.output, 48000,
- character="hall", mix=0.3)
- audio_out.play(verb.output)
-
-The underlying audiodsp nodes are kept as attributes so applications can bind
-parameters straight to them. Direct class construction remains supported for
-local code after `configure()`.
-
-Some classes also carry patches - named settings on the 0-127 MIDI grid, the
-way an instrument does. See `Effect` below.
-"""
-
-import math
-
-#: The stable output port, shared with the rebuilt base rather than written
-#: twice: the identity defect is the same defect on both, and five classes
-#: still live here (`Delay`, `Modulation`, `Pitch`, `Rack`, `Reverb` and
-#: their subclasses). `_component` imports nothing from this module, so the
-#: dependency runs one way only.
-from ._component import PORT as _PORT
-from ._component import output_port as _output_port
-from ._component import port_target as _port_target
-
-#: Every node this library builds is created at this rate. It is module state
-#: rather than a constructor argument because a process only ever has one
-#: sample rate, and threading it through 39 classes would be noise. Call
-#: ``configure()`` before building anything.
-SAMPLE_RATE = 48000
-CHANNEL_COUNT = 2
-
-
-def configure(rate, channel_count=2):
- """Set the format every effect built after this call will run at."""
- global SAMPLE_RATE, CHANNEL_COUNT
- SAMPLE_RATE = int(rate)
- CHANNEL_COUNT = int(channel_count)
-
-
-def sample_rate():
- """The rate `configure()` last set. Read this rather than importing
- SAMPLE_RATE, which would be a copy taken at import time."""
- return SAMPLE_RATE
-
-
-def channel_count():
- """Return the channel count selected by :func:`configure`."""
- return CHANNEL_COUNT
-
-
-def static_transport():
- """Default transport for components that do not need a clock."""
- return (False, 0.0, 120.0, 4, 4)
-
-
-#: How many taps one `audioroute.Splitter` fans out to. The limit lives in
-#: the C (`AUDIODSP_SPLITTER_MAX_TAPS`, which raises "taps must be 1..4"),
-#: and the native modules do not export it, so classes that build parallel
-#: branches count against this mirror rather than a literal 4 apiece.
-SPLITTER_TAPS = 4
-
-
-def pcm(buffer_size=2048):
- """The keyword bundle every audiodsp node wants."""
- return {
- "sample_rate": SAMPLE_RATE,
- "channel_count": CHANNEL_COUNT,
- "bits_per_sample": 16,
- "samples_signed": True,
- "buffer_size": buffer_size,
- }
-
-
-def db_to_gain(db):
- return 10.0 ** (db / 20.0)
-
-
-def db_to_amplitude(db):
- """Biquad peaking/shelf A parameter."""
- return 10.0 ** (db / 40.0)
-
-
-def logmap(value, lo, hi):
- """0..1 -> lo..hi, logarithmic; the natural mapping for frequencies."""
- return lo * ((hi / lo) ** value)
-
-
-def macro_value(span, position):
- """A macro's 0..1 position -> the value it stands for. ``span`` is
- ``(low, high)``, or ``(low, high, "log")`` for the ones that should sweep
- by ratio rather than by difference - anything in hertz or seconds."""
- low, high = span[0], span[1]
- if len(span) > 2:
- return logmap(position, low, high)
- return low + (high - low) * position
-
-
-def macro_position(span, value):
- """The inverse of `macro_value`, unquantized. Used to seed a knob from a
- constructor argument, so the exact number a caller asked for stays on the
- audio path rather than being rounded onto the 7-bit grid first."""
- low, high = span[0], span[1]
- if len(span) > 2:
- position = math.log(value / low) / math.log(high / low)
- else:
- position = (value - low) / (high - low)
- return min(1.0, max(0.0, position))
-
-
-def macro_of(span, value):
- """`value` as the nearest integer on the 0-127 MIDI grid. Patch authoring
- and the tests that hold patch 0 to the constructor's defaults need this;
- nothing on the audio path calls it."""
- return int(round(macro_position(span, value) * 127))
-
-
-class Effect:
- """Base: subclasses set self.output to their chain tail.
-
- ``create(source, sample_rate, **options)`` is the stable construction
- boundary for hosts. Direct class calls remain a convenient local API and
- use the package-wide rate selected by :func:`configure`.
-
- A public subclass carries a **patch surface**, in the shape the instrument
- tier uses: `MACRO_LABELS` names the knobs, `MACRO_MODES` describes each
- control's public behavior, `PATCHES` holds settings on the 0-127 MIDI
- grid, and `_apply_macro` does the work. `_MACRO_RANGES` is private
- implementation data for converting those values into engineering units.
- The provider validator requires every public subclass to declare the
- public fields explicitly, including empty values for a macro-less effect.
-
- A patchable class calls `_init_macros()` at the end of its `__init__` with
- its own arguments, in the macros' own units. That is deliberately the only
- route into `_apply_macro`, so the constructor and a later `set_macro` can
- never drift apart.
- """
-
- CAPABILITIES = ()
- LATENCY_SAMPLES = 0
- TAIL_SAMPLES = None
-
- #: Every `self._output = node` re-points one stable port. See
- #: `_component.output_port`.
- _output = _output_port
-
- def __new__(cls, *args, **kwargs):
- effect = super().__new__(cls)
- source = args[0] if args else kwargs.get("source")
- # Direct class construction is a supported local API. Historically
- # those constructors read the package-wide format, so keep that path
- # coherent with the source as well as the canonical factory path.
- source_rate = getattr(source, "sample_rate", None)
- source_channels = getattr(source, "channel_count", None)
- if source_rate is not None and source_channels is not None:
- configure(source_rate, source_channels)
- effect._source = source
- effect._port = None
- effect._output = None
- effect._sample_rate = (SAMPLE_RATE if source_rate is None
- else source_rate)
- effect._channel_count = (CHANNEL_COUNT if source_channels is None
- else source_channels)
- effect._transport = static_transport
- effect._macros = []
- effect._patch_index = 0
- effect._deinited = False
- effect._factory_options = None
- return effect
-
- #: Knob names, one per macro index - the same thing an instrument module's
- #: MACRO_LABELS means.
- MACRO_LABELS = ()
-
- #: One of UNIPOLAR, BIPOLAR, or TOGGLE for every macro index.
- #:
- #: This pre-contract base still crosses the 0-127 grid with the plain
- #: linear law, which has no centre (64/127 = 0.50394) and so cannot put
- #: a bipolar macro at 0 from a patch - the defect audiocomponents#87
- #: fixes in `_component.position_of_midi`. No class left on this base
- #: declares BIPOLAR, and `test_audio_component_api` holds that: a family
- #: that needs one is rebuilt onto `_component` first rather than having
- #: the detent copied here for Phase 6 to delete again.
- MACRO_MODES = {}
-
- #: Private engineering spans; never part of the provider metadata.
- _MACRO_RANGES = ()
-
- #: {index: (name, (values on the 0-127 grid,))}. Patch 0 is the
- #: constructor's own defaults rendered onto that grid - close to a fresh
- #: instance, not identical to one, because the grid is 128 steps wide and
- #: the defaults are not obliged to land on it. `test_patch_zero_is_the_
- #: constructor_defaults` holds every patchable class to that.
- PATCHES = {}
-
- @classmethod
- def create(cls, source, sample_rate, transport=None, **options):
- """Construct ``cls`` for ``sample_rate`` around ``source``.
-
- Effect implementations historically took ``source`` in their
- constructor and used the package-wide ``configure()`` setting. Keep
- that implementation shape, but make the host-facing rate explicit at
- this one boundary.
-
- **`patch` is handled here rather than by each constructor**, which
- is what makes `create(..., patch=N)` work on every class on this
- base instead of ten of them raising TypeError
- (audiocomponents#84). Ten classes never declared the parameter -
- `DigitalDelay`, `Flanger`, `Harmonizer`, `MultiTapDelay`,
- `Octaver`, `PitchShifter`, `Rack`, `Reverb`, `SlapbackDelay`,
- `StereoWidener` - and a host writing the obvious thing got an
- exception rather than a patch.
-
- Applying it *after* construction rather than from inside the
- constructor is free **on this base and only on this base**: a class
- here calls `_init_macros()` at the end of its `__init__` with
- nothing primed afterwards, so the two moments are the same moment.
- Measured over every (class, patch) pair the library ships: all 25
- pairs on this base are byte-identical either way, and **74 of the
- pairs on `_component.Component` are not**, which is why that base
- keeps applying the patch from inside `_build` and this change stops
- here.
- """
- patch = options.pop("patch", None)
- source_rate = getattr(source, "sample_rate", None)
- source_channels = getattr(source, "channel_count", None)
- if source_rate is None or source_channels is None:
- raise TypeError("effect source must expose sample_rate and "
- "channel_count")
- if int(source_rate) != int(sample_rate):
- raise ValueError("effect source sample_rate does not match "
- "requested sample_rate")
- if int(source_channels) not in (1, 2):
- raise ValueError("effect source channel_count must be 1 or 2")
- configure(sample_rate, source_channels)
- effect = cls(source, **options)
- if patch is not None:
- # Back in the record of how this instance was made, so
- # `_factory_options` still describes it.
- options = dict(options, patch=patch)
- effect._finish_component(source, sample_rate, transport, options)
- if effect._output is None:
- raise TypeError("%s.create() returned no output"
- % cls.__name__)
- if patch is not None:
- effect.program_change(patch)
- return effect
-
- def _finish_component(self, source, sample_rate, transport, options):
- self._check_live()
- self._source = source
- self._sample_rate = int(sample_rate)
- self._channel_count = int(source.channel_count)
- self._transport = static_transport if transport is None else transport
- self._factory_options = dict(options)
- output_rate = getattr(self._output, "sample_rate", self._sample_rate)
- output_channels = getattr(self._output, "channel_count",
- self._channel_count)
- if int(output_rate) != self._sample_rate:
- raise ValueError("effect output sample_rate does not match source")
- if int(output_channels) != self._channel_count:
- raise ValueError("effect output channel_count does not match source")
-
- def _check_live(self):
- if self._deinited:
- raise RuntimeError("effect has been deinitialized")
-
- @property
- def output(self):
- self._check_live()
- return self._output
-
- @property
- def sample_rate(self):
- self._check_live()
- return self._sample_rate
-
- @property
- def channel_count(self):
- self._check_live()
- return self._channel_count
-
- @property
- def latency_samples(self):
- self._check_live()
- return _nonnegative_int(getattr(type(self), "LATENCY_SAMPLES", 0),
- "latency_samples")
-
- @property
- def tail_samples(self):
- self._check_live()
- return _tail_value(getattr(type(self), "TAIL_SAMPLES", None))
-
- @property
- def capabilities(self):
- self._check_live()
- capabilities = tuple(getattr(type(self), "CAPABILITIES", ()))
- if any(not isinstance(capability, str) or
- any(ord(character) >= 128 for character in capability)
- for capability in capabilities):
- raise ValueError("effect capabilities must be ASCII strings")
- return capabilities
-
- @property
- def patch_index(self):
- self._check_live()
- return self._patch_index
-
- @property
- def transport(self):
- self._check_live()
- return self._transport
-
- def _init_macros(self, values, patch=None):
- """Seed the knobs from the constructor's arguments and, if one was
- asked for, apply a patch over the top."""
- self._macros = [macro_position(span, value)
- for span, value in zip(self._MACRO_RANGES, values)]
- for index, position in enumerate(self._macros):
- self._apply_macro(index, position)
- self._patch_index = 0
- if patch is not None:
- self.program_change(patch)
-
- def set_macro(self, index, value, channel=0, note_id=-1,
- sample_position=0):
- """Set macro `index` from the 0-127 MIDI scale. Floats are accepted so
- a host with finer resolution need not quantize."""
- del channel, note_id
- _sample_position(sample_position)
- self._check_live()
- if isinstance(index, bool) or not isinstance(index, int):
- raise IndexError("macro index must be an integer")
- if not 0 <= index < len(self.MACRO_LABELS):
- raise IndexError("%s has %d macros; no index %d"
- % (type(self).__name__, len(self.MACRO_LABELS),
- index))
- self._macros[index] = min(1.0, max(0.0, float(value) / 127.0))
- self._patch_index = None
- self._apply_macro(index, self._macros[index])
-
- def transport_changed(self):
- """The host's transport may have changed. A class on this base does
- not sync to tempo, so there is nothing to follow."""
- self._check_live()
-
- def program_change(self, index, channel=0, note_id=-1,
- sample_position=0):
- """Apply patch `index`. An index this class does not have is ignored,
- as it is on an instrument: a program change is a wire message, and the
- wire carries indices nobody has to have implemented."""
- del channel, note_id
- _sample_position(sample_position)
- self._check_live()
- if isinstance(index, bool) or not isinstance(index, int):
- raise ValueError("program index must be a non-negative integer")
- if index < 0:
- raise ValueError("program index must be non-negative")
- patch = self.PATCHES.get(index)
- if patch is None:
- return
- self._apply_positions([min(1.0, max(0.0, float(value) / 127.0))
- for value in patch[1]], index)
-
- def _apply_positions(self, positions, patch_index):
- """Set the macros to `positions` (0..1), apply each, and record
- `patch_index`: `program_change`'s body, which a rack also calls to
- put a child back as it built it (`_component.Component`'s
- counterpart says why)."""
- for macro in range(len(positions)):
- self._macros[macro] = positions[macro]
- self._apply_macro(macro, positions[macro])
- self._patch_index = patch_index
-
- def macro(self, index):
- """What macro `index` currently stands for, in its own units."""
- self._check_live()
- return macro_value(self._MACRO_RANGES[index], self._macros[index])
-
- def get_macro(self, index):
- self._check_live()
- if isinstance(index, bool) or not isinstance(index, int):
- raise IndexError("macro index must be an integer")
- if not 0 <= index < len(self.MACRO_LABELS):
- raise IndexError("%s has %d macros; no index %d"
- % (type(self).__name__, len(self.MACRO_LABELS),
- index))
- return self._macros[index] * 127.0
-
- def pitch_bend(self, value, channel=0, sample_position=0):
- self._check_live()
- _pitch_bend(value)
- _channel(channel)
- _sample_position(sample_position)
-
- def control_change(self, controller, value, channel=0, sample_position=0):
- self._check_live()
- _midi_value(controller, "controller")
- _bounded_midi(value)
- _channel(channel)
- _sample_position(sample_position)
-
- def channel_pressure(self, value, channel=0, sample_position=0):
- self._check_live()
- _bounded_midi(value)
- _channel(channel)
- _sample_position(sample_position)
-
- def poly_pressure(self, pitch, value, channel=0, note_id=-1,
- sample_position=0):
- self._check_live()
- _midi_value(pitch, "pitch")
- _bounded_midi(value)
- _channel(channel)
- _note_id(note_id)
- _sample_position(sample_position)
-
- def reset(self):
- self._check_live()
- # Through the port to the node behind it, because the question this
- # asks is "is my output the borrowed source" and the port's answer is
- # always no -- it is a wire, never the source. Resetting the port
- # would forward the rewind straight into the borrowed source, which
- # this line exists to prevent.
- output = self._output
- node = _port_target(output) if output is not None else None
- if node is not None and node is not self._source:
- try:
- import audiocore
- audiocore.reset_buffer(output)
- except (AttributeError, RuntimeError):
- pass
- self.program_change(0)
-
- def deinit(self):
- if self._deinited:
- return
- output = self._output
- node = _port_target(output) if output is not None else None
- # The wire first, then the node behind it: a pump holding the port
- # stops at the port rather than inside a node that has just been
- # freed. `port_target` is what `output` used to be, so the borrowed
- # source is still never released here.
- if _PORT is not None and isinstance(output, _PORT):
- output.deinit()
- if node is not None and node is not self._source:
- deinit = getattr(node, "deinit", None)
- if deinit is not None:
- deinit()
- self._output = None
- self._deinited = True
-
- def _apply_macro(self, index, position):
- """Push macro `index`, at its 0..1 `position`, into the nodes. Read
- `self._macros[other]` for a setting that depends on two knobs."""
- raise NotImplementedError(
- "%s declares macros but does not apply them"
- % (type(self).__name__,))
-
-
-def _nonnegative_int(value, name):
- if isinstance(value, bool) or not isinstance(value, int):
- raise ValueError("%s must be a non-negative integer" % name)
- if value < 0:
- raise ValueError("%s must be non-negative" % name)
- return value
-
-
-def _tail_value(value):
- if value is None:
- return None
- return _nonnegative_int(value, "tail_samples")
-
-
-def _midi_value(value, name):
- if isinstance(value, bool) or not isinstance(value, int):
- raise ValueError("%s must be an integer from 0 through 127" % name)
- if not 0 <= value <= 127:
- raise ValueError("%s must be from 0 through 127" % name)
- return value
-
-
-def _bounded_midi(value):
- value = float(value)
- return min(127.0, max(0.0, value))
-
-
-def _pitch_bend(value):
- if isinstance(value, bool) or not isinstance(value, int):
- raise ValueError("pitch bend must be an integer from 0 through 16383")
- if not 0 <= value <= 16383:
- raise ValueError("pitch bend must be from 0 through 16383")
- return value
-
-
-def _channel(value):
- if isinstance(value, bool) or not isinstance(value, int):
- raise ValueError("channel must be an integer from 0 through 15")
- if not 0 <= value <= 15:
- raise ValueError("channel must be from 0 through 15")
- return value
-
-
-def _note_id(value):
- if isinstance(value, bool) or not isinstance(value, int):
- raise ValueError("note_id must be -1 or a non-negative integer")
- if value < -1:
- raise ValueError("note_id must be -1 or non-negative")
- return value
-
-
-def _sample_position(value):
- if isinstance(value, bool) or not isinstance(value, int):
- raise ValueError("sample_position must be a non-negative integer")
- if value < 0:
- raise ValueError("sample_position must be non-negative")
- return value
-
-
-# This library used to halve every frequency it handed a Biquad, and to
-# build bells out of notch and band-pass sections. Both were workarounds
-# for engine bugs that no longer exist: a stereo audiofilters.Filter now
-# keeps one biquad state per channel (so a filter asked for f is centered
-# at f, not 2f), and PEAKING_EQ computes b2 with the sign the RBJ cookbook
-# calls for. See docs/upstream-diff.md. Frequencies here are now just
-# frequencies. Note that a stock CircuitPython board still has both bugs -
-# see this package's README.
-
-
-def check_hz(frequency):
- """A biquad's center must sit below Nyquist. Above it the coefficient
- math folds over and the filter is noise rather than a filter, silently.
- Halving every frequency used to keep the library clear of the edge by
- accident; now that it doesn't, say so out loud instead."""
- frequency = float(frequency)
- limit = SAMPLE_RATE * 0.5
- if not 0.0 < frequency < limit:
- raise ValueError(
- "filter frequency %g Hz is outside 0..%g Hz (Nyquist at the "
- "configured %d Hz rate)" % (frequency, limit, SAMPLE_RATE))
- return frequency
diff --git a/lib/audioeffects/autopan.py b/lib/audioeffects/autopan.py
index c45a04e..991cd34 100644
--- a/lib/audioeffects/autopan.py
+++ b/lib/audioeffects/autopan.py
@@ -169,6 +169,19 @@ def _release():
self._init_macros((rate, depth, shape, law_db, centre, phase, sync),
patch)
+ def reset(self):
+ """Clear every node, restore patch 0 and start the LFO again: the
+ notes on the modulating `synthio.Synthesizer` played on through the
+ walk, a block ahead, so patch 0 now builds a fresh one, as a fresh
+ instance does."""
+ self._check_live()
+ old = self._synth
+ self._synth = None
+ self._note_l = None
+ self._note_r = None
+ _component.Component.reset(self)
+ _component.release(old)
+
def _apply_macro(self, index, position):
del position
if self._node is None:
diff --git a/lib/audioeffects/bitcrusher.py b/lib/audioeffects/bitcrusher.py
index d12d8d3..427e4f0 100644
--- a/lib/audioeffects/bitcrusher.py
+++ b/lib/audioeffects/bitcrusher.py
@@ -86,10 +86,13 @@
level: the input going away is the only thing that closes it. Silence in is
silence out at **every shipped patch, peak 0**. It costs a tail — the gate
holds 20 ms and releases over 50 ms, so the dither fades to its last
-nonzero code **7 248 frames** after a burst at 48 kHz — and
-`DITHER_TAIL_SAMPLES` declares 8 192 for it. `tail_samples` is 0 with Band
-Limit and dither off, **640** with the band limit on, and **8 192**
-wherever the dither is.
+nonzero code **7 248 frames** after a burst at 48 kHz, 9 030 at maximum
+Dither — and `DITHER_TAIL_SAMPLES` declares 9 728 for it. `tail_samples` is
+0 with Band Limit and dither off, **640** with the band limit on, and
+**9 728** wherever the dither is, up to a dither of ±32 counts. Past that
+the dither alone holds the gate open, it plays on silence for good, and
+`tail_samples` is None: maximum Dither at 12 bits is the edge, and any
+deeper crush with Dither up is past it.
**What the default surrenders.** Patch 0 is 12 bits at 26.04 kHz, dither
off, band limit off, Output 0 dB.
@@ -214,6 +217,9 @@
DITHER_GATE_DB = -60.0
DITHER_GATE_HOLD_MS = 20.0
DITHER_GATE_RELEASE_MS = 50.0
+#: The dither peak, in 16-bit counts, at which the dither alone reaches the
+#: gate's threshold and holds it open.
+DITHER_GATE_PEAK = 32768.0 * 10.0 ** (DITHER_GATE_DB / 20.0)
#: The band limit is a 4th-order Butterworth -- two sections at the pole Qs
#: the polynomial actually has, not four sections at 0.7071 apiece. Four
@@ -1331,6 +1337,9 @@ def _rect_table(frames, channels, seed):
class Bitcrusher(_component.Component):
"""12-bit linear hold: Bits and Rate, informed by the SP-1200."""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'Bitcrusher'
DISPLAY_NAME = 'Bitcrusher'
CATEGORIES = ('Distortion',)
@@ -1565,6 +1574,16 @@ def _refresh(self):
self._output_gain = _component.db_to_gain(output_db)
self._prime_if_wet()
+ def reset(self):
+ """Clear every node and restore patch 0, then arm the graph as
+ `_build` does, lazily, so a reset is a fresh instance and takes
+ nothing from the source."""
+ # The hold is played again as `_build` plays it, so its accumulator
+ # starts where a fresh one's does.
+ self._hold_source = None
+ self._reset_rearmed(self._prime_if_wet,
+ ("_dither_primed", "_blend_primed"))
+
def _prime_if_wet(self):
mix = getattr(self, "_mix", 1.0)
if mix <= 0.0:
@@ -1574,6 +1593,14 @@ def _prime_if_wet(self):
self._output = self._blend
return
if self._rejoin():
+ # The Splitter drops the source frames it took before the
+ # bypass, which the host has since read past, and the node
+ # reading the wet tap what it held of a tap's chunk: the band
+ # limit's first section, or the hold with Band Limit off.
+ head = self._sections[0] if self._hold_source is not \
+ self._wet_in else self._hold
+ _component.let_go_of_source(self._split, self._source,
+ ((head, self._wet_in),))
# Back off the bypass: every node is cleared - the hold's last
# value, the band limit's memory, the dither gate - and the
# voices, which still hold the blocks they had queued, are
@@ -1611,9 +1638,11 @@ def _prime_if_wet(self):
# and an open gate plays the dither out of silence until it
# closes. Closed is what silence would have left it.
self._clear_nodes(only=(self._dither_gate,))
- self._dither_mix.voice[0].play(hold)
- self._dither_mix.voice[1].play(self._dither_a, loop=True)
- self._dither_mix.voice[2].play(self._dither_b, loop=True)
+ self._play_voice(self._dither_mix.voice[0], hold)
+ self._play_voice(self._dither_mix.voice[1], self._dither_a,
+ loop=True)
+ self._play_voice(self._dither_mix.voice[2], self._dither_b,
+ loop=True)
self._dither_gate.play(self._dither_mix)
self._dither_primed = True
self._shaper.play(self._dither_gate)
@@ -1621,8 +1650,8 @@ def _prime_if_wet(self):
self._dither_primed = False
self._shaper.play(hold)
if not self._blend_primed:
- self._blend.voice[0].play(self._dry)
- self._blend.voice[1].play(self._shaper)
+ self._play_voice(self._blend.voice[0], self._dry)
+ self._play_voice(self._blend.voice[1], self._shaper)
self._blend_primed = True
self._output = self._blend
self._set_levels()
@@ -1718,22 +1747,35 @@ def latency_samples(self):
#: (p)5). It is a group delay, so it is the same at both.
BAND_LATENCY_SAMPLES = 2
- #: What the dither gate rings for after the input stops, third fix
- #: round. The gate holds 20 ms and releases over 50 ms, and the +-16
- #: count dither fades to its last nonzero 16-bit code **7 248 frames**
- #: after the burst at 48 kHz (6 485 at 44.1, 3 257 at 22.05), with or
- #: without the band limit. 8 192 covers the worst with 13 % over it.
- #: Zero dither is still a zero tail: the gate is only in the chain
- #: where the dither is.
- DITHER_TAIL_SAMPLES = 8192
+ #: What the dither gate rings for after the input stops. The gate holds
+ #: 20 ms and releases over 50 ms, and the longer the dither, the later
+ #: its last nonzero 16-bit code: 7 248 frames at +-16 counts, **9 030**
+ #: at +-32 (12 bits, Dither at maximum, mono, 48 kHz, with the Dither
+ #: move inside the burst), counted the way the contract counts. 8 192
+ #: was the +-16 number. 9 728 covers +-32 with 7 % over it, with or
+ #: without the band limit, at every rate. Zero dither is still a zero
+ #: tail: the gate is only in the chain where the dither is.
+ DITHER_TAIL_SAMPLES = 9728
@property
def tail_samples(self):
+ """0, the band limit's 640, or the dither gate's 9 728, and None
+ where the dither is louder than the gate's threshold.
+
+ The gate's threshold is a level, `DITHER_GATE_DB`, and the dither's
+ peak is Dither's LSB at the depth in force. Past +-32 counts (12
+ bits at maximum Dither, 11 bits at 1 LSB, and every deeper crush
+ above that) the dither alone holds the gate open, so it plays on
+ silence for good and no bound is true."""
self._check_live()
tail = 0
if self._macros[3] >= 0.5:
tail = type(self).BAND_TAIL_SAMPLES
- if getattr(self, "_dither_lsb", 0.0) > 0.0:
+ dither_lsb = getattr(self, "_dither_lsb", 0.0)
+ if dither_lsb > 0.0:
+ step = 1 << (16 - max(2, min(MAX_BITS, self._bits)))
+ if dither_lsb * step > DITHER_GATE_PEAK:
+ return None
dithered = type(self).DITHER_TAIL_SAMPLES
tail = dithered if dithered > tail else tail
return tail
diff --git a/lib/audioeffects/cabinetsim.py b/lib/audioeffects/cabinetsim.py
index add1412..0f26092 100644
--- a/lib/audioeffects/cabinetsim.py
+++ b/lib/audioeffects/cabinetsim.py
@@ -553,11 +553,14 @@ class a finer position is not safe to honour, because only those
`get_macro` reads back the snapped number, not the one sent.
"""
try:
- value = float(value)
+ value = _component.clamped_midi(value)
except (TypeError, ValueError):
pass
else:
- value = float(int(value + 0.5)) if value >= 0.0 else 0.0
+ # The clamp first, so +inf is the top stop and never reaches
+ # int(); NaN fails `>= 0` and is the bottom stop, as in the base.
+ value = (float(int(min(value, 127.0) + 0.5)) if value >= 0.0
+ else 0.0)
return _component.Component.set_macro(self, index, value,
channel=channel,
note_id=note_id,
diff --git a/lib/audioeffects/compressor.py b/lib/audioeffects/compressor.py
index e9ec578..93077a0 100644
--- a/lib/audioeffects/compressor.py
+++ b/lib/audioeffects/compressor.py
@@ -181,6 +181,9 @@
class Compressor(_component.Component):
"""Four compressor characters on two `audiodynamics.Dynamics` nodes."""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'Compressor'
DISPLAY_NAME = 'Compressor'
CATEGORIES = ('Dynamics',)
@@ -292,6 +295,7 @@ def _build(self, character="optical", patch=None, **overrides):
# well as the current one.
split = self._own(audioroute.Splitter(self._source, taps=2),
reset=False)
+ self._split = split
self._wet_tap = self._own(split.tap(0), reset=False)
self._dry_tap = self._own(split.tap(1), reset=False)
self._fast = self._own(audiodynamics.Dynamics(
@@ -337,6 +341,12 @@ def _build(self, character="optical", patch=None, **overrides):
self._ready = True
self._refresh_output()
+ def reset(self):
+ """Clear every node and restore patch 0, then arm the graph as
+ `_build` does, so a reset is a fresh instance (no block primed
+ through the chain before it was cleared plays after it)."""
+ self._reset_rearmed(self._refresh_output, ("_primed",))
+
def _prime(self):
"""Open the mixer's level gates, then hand its voices their sources.
@@ -349,8 +359,11 @@ def _prime(self):
"""
_component.open_level_gates(self._mixer, [self._wet, self._dry],
self._silence)
- self._wet.play(self._fast if self._lean() else self._slow)
- self._dry.play(self._dry_tap)
+ # The stage `_route` last chose: a macro moved since leaves the
+ # patch index unset but the lean route (and its makeup) in place.
+ self._play_voice(self._wet,
+ self._fast if self._routed_lean else self._slow)
+ self._play_voice(self._dry, self._dry_tap)
self._primed = True
def _refresh_output(self):
@@ -381,8 +394,12 @@ def _refresh_output(self):
# primed afresh (audiocomponents#113). The two Dynamics hold a gain
# and no audio, but a voice's `play()` resets what it plays, so the
# stage it reads restarts its gain either way: the class comes back
- # as it was built, envelopes and all.
+ # as it was built, envelopes and all. And the Splitter drops the
+ # source frames it took before the bypass, which the host has since
+ # read past (`_component.let_go_of_source`).
if self._rejoin():
+ _component.let_go_of_source(self._split, self._source,
+ ((self._fast, self._wet_tap),))
self._primed = False
if not self._primed:
self._prime()
@@ -504,12 +521,12 @@ def _route(self):
return
if lean:
if self._primed:
- self._wet.play(self._fast)
+ self._repoint_voice(self._wet, self._fast)
self._fast.set(makeup_db=self.macro(12))
self._slow.set(makeup_db=0.0)
else:
if self._primed:
- self._wet.play(self._slow)
+ self._repoint_voice(self._wet, self._slow)
self._fast.set(makeup_db=0.0)
self._slow.set(makeup_db=self.macro(12))
self._routed_lean = lean
diff --git a/lib/audioeffects/deesser.py b/lib/audioeffects/deesser.py
index 7f623af..87501b9 100644
--- a/lib/audioeffects/deesser.py
+++ b/lib/audioeffects/deesser.py
@@ -142,12 +142,6 @@
#: thing in both paths.
_DETECTOR_CORNER = 1.0 / 1.5537739740300374
-#: What a reset has to push through a Splitter's ring. The ring is 8192
-#: frames (`audiodsp/src/shared/audiodsp_splitter.h:20`) and one take is at
-#: most 256 (`audiodsp_splitter.c:64-70`), so this many pulls a tap empties
-#: any backlog it can be holding.
-_FLUSH_PULLS = 8192 // 256 + 1
-
#: Detector averaging. The manual gives none ("dbx patented RMS level
#: detectors" and no time constant), so this is a design choice, and it was
#: measured rather than picked: the RMS filter sits *before* the attack
@@ -164,6 +158,9 @@
class DeEsser(_component.Component):
"""De-essing by comparing the sibilant band with the programme."""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'DeEsser'
DISPLAY_NAME = 'De-Esser'
CATEGORIES = ('Dynamics',)
@@ -217,8 +214,18 @@ class DeEsser(_component.Component):
_ATTACK = 5
_LISTEN = 6
- def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
- hf_only=False, release_ms=3.5, attack_ms=2.0, listen=False,
+ #: What each option left out stands for: patch 0's step, rounded
+ #: (Frequency 2500 Hz is grid 63, 2507 Hz). An option left out plays
+ #: the step itself, so a fresh instance is patch 0 exactly and a reset
+ #: lands where it starts; a value you pass plays as passed.
+ _OPTION_DEFAULTS = {"frequency": 2500.0, "range_db": 12.0,
+ "sensitivity_db": 30.0, "hf_only": False,
+ "release_ms": 3.5, "attack_ms": 2.0, "listen": False}
+ _OPTION_NAMES = ("frequency", "range_db", "sensitivity_db", "hf_only",
+ "release_ms", "attack_ms", "listen")
+
+ def _build(self, frequency=None, range_db=None, sensitivity_db=None,
+ hf_only=None, release_ms=None, attack_ms=None, listen=None,
patch=None):
"""Build the graph the dossier's section 4 draws.
@@ -229,6 +236,13 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
sets.
"""
rate, channels = self._sample_rate, self._channel_count
+ raw = (frequency, range_db, sensitivity_db, hf_only, release_ms,
+ attack_ms, listen)
+ seeded = tuple(value is None for value in raw)
+ (frequency, range_db, sensitivity_db, hf_only, release_ms, attack_ms,
+ listen) = (self._OPTION_DEFAULTS[self._OPTION_NAMES[index]]
+ if seeded[index] else raw[index]
+ for index in range(len(raw)))
# The block adapter. `audioroute.MidSide` at width 1 is the exact
# identity (its own docstring says so, and it is measured in the
@@ -237,9 +251,11 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
# 8192-frame ring - and its `play()` does not reset its source,
# where `audiofilters.Filter.play()` does. The reset flush below
# depends on that.
- adapter = audioroute.MidSide(width=1.0, sample_rate=rate,
- channel_count=channels)
- adapter.play(self._source)
+ adapter = getattr(self, "_kept_adapter", None)
+ if adapter is None:
+ adapter = audioroute.MidSide(width=1.0, sample_rate=rate,
+ channel_count=channels)
+ adapter.play(self._source)
top = audioroute.Splitter(adapter, taps=4)
raw = audioroute.Splitter(top.tap(3), taps=2)
@@ -330,7 +346,7 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
# What each mixer voice plays, in the order a reset has to re-prime
# them: `pre` before `out`, because `out`'s voice 3 pulls through
# `pre`. `MixerVoice.play()` is the only clear a mixer has that
- # works on all three interpreters - see `_reset_chain`.
+ # works on all three interpreters.
self._voices = ((pre, 0, raw.tap(0)), (pre, 1, band.tap(0)),
(out, 0, raw.tap(1)), (out, 1, lows[1]),
(out, 2, band.tap(1)), (out, 3, duck))
@@ -341,28 +357,17 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
sample_rate=rate, channel_count=channels)
# `deinit()` walks this list backwards, tail first, which is what
- # the order below is for. `reset()` cannot use the same walk, and
- # the reason is worth stating rather than discovering: a
- # `Splitter`'s ring cannot be cleared - the type exposes `tap()` and
- # nothing else on every target, and `SplitterTap.reset_buffer` is
- # "deliberately nothing" - so the rings have to be pushed empty
- # against a silent source, and `audiomixer.Mixer._reset_buffer`
- # refetches a chunk into every voice, so both mixers have to be
- # cleared *while* that silent source is still on. One ordered
- # routine can express that and a per-node walk cannot, so the tail
- # carries `_reset_chain` and every other node declines its own
- # reset. Nothing is skipped: `_reset_chain` names all seven
- # resettable nodes explicitly, and the list below is what the class
- # is held to.
- self._own(tail, reset=self._reset_chain)
+ # the order below is for. `reset()` does not walk it: it builds the
+ # graph again (see there), so every node declines a reset walk.
+ self._own(tail, reset=False)
self._own(out, reset=False)
self._own(pre, reset=False)
self._own(duck, reset=False)
for node in (highs[1], highs[0], lows[1], lows[0]):
self._own(node, reset=False)
for splitter in (band, raw, top):
- # No `reset_buffer` and no `clear`, so `_reset_chain()` is what
- # clears these. `deinit()` it does have, since audiodsp#58, and
+ # No `reset_buffer` and no `clear`; `reset()` builds them
+ # again. `deinit()` it does have, since audiodsp#58, and
# asking for one an older audiodsp lacks is skipped by `getattr`.
self._own(splitter, reset=False)
self._own(adapter, reset=False)
@@ -370,8 +375,8 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
# too, and they belong on the list for the reason the paragraph
# above gives: `deinit()` walks it, and a node missing from it is a
# node nothing releases. Both decline their own reset - a tap's
- # `reset_buffer` is deliberately nothing and `_reset_chain()` empties
- # the rings by pulling them, and the filler holds no state to clear.
+ # `reset_buffer` is deliberately nothing and `reset()` builds them
+ # again, and the filler holds no state to clear.
# `Splitter.tap(n)` returns the same object each call, so these are
# the same eight taps `self._voices` plays.
for tap in self._taps:
@@ -383,7 +388,7 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
self._ready = False
self._init_macros((frequency, range_db, sensitivity_db,
1.0 if hf_only else 0.0, release_ms, attack_ms,
- 1.0 if listen else 0.0), patch)
+ 1.0 if listen else 0.0), patch, defaults=seeded)
self._prime()
self._ready = True
self._refresh_output()
@@ -431,7 +436,7 @@ def _prime(self):
self._primed = False
return
for mixer, index, sample in self._voices:
- mixer.voice[index].play(sample)
+ self._play_voice(mixer.voice[index], sample)
self._primed = True
def _refresh_output(self):
@@ -464,46 +469,54 @@ def _refresh_output(self):
# -- reset ---------------------------------------------------------
- def _reset_chain(self):
- """Clear every node this class built, in the one order that works.
-
- Measured on the way to this routine, and the reason it is not a
- per-node walk: clearing every node but the rings still replayed
- **19999 LSB** of stale audio and did not reach an all-zero block
- until frame 512; clearing the rings first but restoring the source
- before the mixers still left **3024 LSB**, because
- `audiomixer.Mixer._reset_buffer` refetches a chunk into every voice
- and that fetch pulled live audio through.
-
- So: the silent sample goes on first, every tap is pulled until its
- backlog is gone, every clearable node is cleared while the silence
- is still there - the mixers' refetch then fetches silence - and the
- borrowed source goes back on last. The source itself is never reset
- and never rewound; `audioroute.MidSide.play()` sets its source and
- drops its own pending bytes, and does nothing else.
+ def reset(self):
+ """Come back as a fresh instance at patch 0: the graph is built
+ again and the old one released.
+
+ A reset that cleared this graph in place never came back fresh.
+ A `Splitter`'s ring cannot be cleared - the type exposes `tap()`
+ and nothing else, and `SplitterTap.reset_buffer` is "deliberately
+ nothing" - so the old reset pushed eight taps empty with
+ `audiocore.get_buffer` (compiled out of CircuitPython's board
+ builds, where it raised), and with the rings empty it still
+ differed from a fresh instance from the first frame of material
+ on. A graph built again is a fresh instance, by construction, on
+ every interpreter. The output port is re-pointed at the new tail
+ before the old graph is released, so a pump holding the port never
+ reads a released node. The borrowed source is not reset or
+ rewound; it plays on from where it stands, as a fresh instance
+ handed it would.
"""
- self._adapter.play(self._silence)
- for tap in self._taps:
- for _ in range(_FLUSH_PULLS):
- audiocore.get_buffer(tap)
- for node in (self._lows[0], self._lows[1], self._highs[0],
- self._highs[1], self._duck):
- audiocore.reset_buffer(node)
- # The mixers are re-primed rather than reset. `reset_buffer` on a
- # Mixer stops every voice for good on CircuitPython (see `_build`),
- # and `MixerVoice.play()` is what does the same job on all three: it
- # resets the voice's own source and refills the voice, which is
- # exactly a rewind. It runs while the silent sample is still on the
- # adapter, so what the voices refill with is silence.
- # At Range 0 the voices have never left `self._silence`, so there is
- # nothing stale in them and re-playing would only prime a graph the
- # bypass does not use.
- if self._primed:
- for mixer, index, sample in self._voices:
- mixer.voice[index].play(sample)
- self._adapter.play(self._source)
- audiocore.reset_buffer(self._adapter)
- audiocore.reset_buffer(self._tail)
+ self._check_live()
+ # The adapter is kept, not built again: it may hold the rest of a
+ # source buffer it has already taken, and a new one would drop
+ # those frames - up to a whole buffer of the source, gone, at every
+ # reset that lands part-way through one.
+ self._kept_adapter = self._adapter
+ old = [(node, release) for node, release
+ in zip(self._nodes, self._deinits)
+ if node is not self._adapter]
+ self._nodes = []
+ self._resets = []
+ self._deinits = []
+ self._constructing = True
+ try:
+ if self._reset_to is None:
+ self._build(patch=0)
+ else:
+ self._build()
+ self._restore(self._reset_to)
+ finally:
+ self._constructing = False
+ self._kept_adapter = None
+ self._stranded = False
+ for node, release in reversed(old):
+ if release is False:
+ continue
+ if release is True:
+ release = getattr(node, "deinit", None)
+ if release is not None:
+ release()
# -- macros --------------------------------------------------------
diff --git a/lib/audioeffects/delay.py b/lib/audioeffects/delay.py
deleted file mode 100644
index 5ad6d43..0000000
--- a/lib/audioeffects/delay.py
+++ /dev/null
@@ -1,303 +0,0 @@
-"""Delays, of two kinds.
-
-`DigitalDelay`, `SlapbackDelay` and `MultiTapDelay` are clean: they run on
-`audiodelays`, whose feedback path is the echo times a decay and nothing
-else, which is exactly what a clean repeat is.
-
-`TapeDelay`, `AnalogDelay` and `PingPongDelay` are not clean, and cannot be
-built that way. All three are named after something that happens *inside* the
-feedback loop - a filter that takes a little more off each pass, a soft-clip
-that rounds it, a modulation that bends it, a cross-feed that sends it to the
-other speaker - so they run on `audioecho.FeedbackDelay`, which puts those
-there. See audiodsp's docs/upstream-diff.md.
-
-The three coloured ones carry patches; see this package's README.
-"""
-
-VENDOR = "PyDevices"
-
-import audiodelays
-import audioecho
-
-from . import _core
-
-
-class DigitalDelay(_core.Effect):
-
- NAME = 'DigitalDelay'
- DISPLAY_NAME = 'Digital Delay'
- CATEGORIES = ('Delay',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
- def __init__(self, source, time_ms=350.0, feedback=0.4, mix=0.3):
- self.node = audiodelays.Echo(
- max_delay_ms=int(time_ms) + 100, delay_ms=time_ms,
- decay=feedback, mix=mix, freq_shift=False, **_core.pcm())
- self.node.play(source)
- self._output = self.node
-
- def set_time(self, time_ms):
- self.node.delay_ms = time_ms
-
- def set_mix(self, mix):
- self.node.mix = mix
-
-
-class SlapbackDelay(DigitalDelay):
- """One short, single repeat - the rockabilly vocal trick."""
-
- NAME = 'SlapbackDelay'
- DISPLAY_NAME = 'Slapback Delay'
- CATEGORIES = ('Delay',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
-
- def __init__(self, source, time_ms=95.0, mix=0.4):
- DigitalDelay.__init__(self, source, time_ms=time_ms, feedback=0.0,
- mix=mix)
-
-
-class _LoopDelay(_core.Effect):
- """Shared plumbing for the three built on `audioecho.FeedbackDelay`.
-
- `max_time_ms` sizes the line and cannot change afterwards - it is what was
- allocated, and at 48 kHz a second of stereo line is 192 KB, so it is worth
- asking for only what will be used.
-
- `mix` runs 0..2 with the dry at unity until 1, which is `audiodelays.Echo`'s
- convention rather than a crossfade: the six delay classes have to mean one
- thing by it, and three of them are Echo's. The Time macro's range is fixed across
- every instance so a patch means the same milliseconds everywhere; on an
- instance with a short line the engine clamps, which is the honest failure.
- """
-
- #: Line headroom over the requested time, for the wow to move within and
- #: for the Time macro to have somewhere to go.
- HEADROOM = 2.0
-
- def _build(self, source, time_ms, max_time_ms, **options):
- line_ms = max_time_ms
- if line_ms is None:
- line_ms = max(float(time_ms) * self.HEADROOM, 400.0)
- self.node = audioecho.FeedbackDelay(
- sample_rate=_core.sample_rate(),
- channel_count=_core.channel_count(), max_delay_ms=line_ms,
- **options)
- self.node.play(source)
- self.max_time_ms = line_ms
- self._output = self.node
-
- def set_time(self, time_ms):
- """Retune the delay, in milliseconds."""
- self.set_macro(0, _core.macro_of(self._MACRO_RANGES[0], time_ms))
-
- def set_mix(self, mix):
- self.set_macro(2, _core.macro_of(self._MACRO_RANGES[2], mix))
-
- def clear(self):
- """Empty the line, so the next thing through is not played over the
- last thing that was."""
- self.node.clear()
-
-
-class TapeDelay(_LoopDelay):
- """A tape machine's echo: each pass loses top and bottom and softens, and
- the transport's wow bends the pitch of the repeats as they go.
-
- The filtering is *in* the loop, which is the whole difference. This class
- used to low-pass the delay's output - the wet and the dry together, once -
- which darkened the first repeat as much as the tenth and, at a low mix,
- quietly took the top off the dry signal as well. Repeats now darken
- progressively and the dry path is untouched.
- """
-
- NAME = 'TapeDelay'
- DISPLAY_NAME = 'Tape Delay'
- CATEGORIES = ('Delay',)
- VERSION = '0.0.1'
-
- MACRO_LABELS = ("Time", "Feedback", "Mix", "Wow", "Tone", "Drive")
- MACRO_MODES = {
- 0: "UNIPOLAR",
- 1: "UNIPOLAR",
- 2: "UNIPOLAR",
- 3: "UNIPOLAR",
- 4: "UNIPOLAR",
- 5: "UNIPOLAR",
- }
- _MACRO_RANGES = ((20.0, 1000.0, "log"), (0.0, 0.95), (0.0, 2.0),
- (0.0, 1.0), (800.0, 16000.0, "log"), (0.0, 1.0))
- PATCHES = {
- 0: ("Tape Echo", (90, 60, 22, 38, 66, 38)),
- 1: ("Slapback Tape", (55, 13, 19, 19, 79, 42)),
- 2: ("Space Echo", (97, 74, 25, 42, 60, 55)),
- # Long, hot and dark: the repeats pile up faster than they decay and
- # the loop's soft-clip is what stops it running away.
- 3: ("Dub Siren", (108, 107, 32, 60, 47, 72)),
- 4: ("Worn Cassette", (86, 67, 25, 108, 37, 85)),
- 5: ("Clean Repeats", (93, 53, 19, 6, 103, 6)),
- }
-
- #: The wobble's rate. Fixed rather than exposed: a tape transport's wow is
- #: slow by construction, and `Wow` is how far it swings.
- WOW_HZ = 0.7
-
- def __init__(self, source, time_ms=320.0, feedback=0.45, mix=0.35,
- wow=0.3, tone_hz=3800.0, drive=0.3, max_time_ms=None,
- patch=None):
- self._build(source, time_ms, max_time_ms, wow_hz=self.WOW_HZ,
- cut_hz=90.0)
- self._init_macros((time_ms, feedback, mix, wow, tone_hz, drive),
- patch)
-
- def _apply_macro(self, index, position):
- if index == 0:
- self.node.set(delay_ms=self.macro(0))
- elif index == 1:
- self.node.set(feedback=self.macro(1))
- elif index == 2:
- self.node.set(mix=self.macro(2))
- elif index == 3:
- self.node.set(wow_depth_ms=2.5 * self.macro(3))
- elif index == 4:
- self.node.set(damping_hz=self.macro(4))
- else:
- self.node.set(loop_drive=self.macro(5))
-
-
-class AnalogDelay(_LoopDelay):
- """A bucket-brigade delay: band-limited at both ends by construction,
- darker with every pass, and drifting slightly because its clock does.
-
- `age` is one knob over the lot of it - a fresh unit against one whose
- capacitors have been in a pedal since 1978. It moves the loop's low-pass
- down, its high-pass up, and the drift and softening up with them.
- """
-
- NAME = 'AnalogDelay'
- DISPLAY_NAME = 'Analog Delay'
- CATEGORIES = ('Delay',)
- VERSION = '0.0.1'
-
- MACRO_LABELS = ("Time", "Feedback", "Mix", "Age", "Drive")
- MACRO_MODES = {
- 0: "UNIPOLAR",
- 1: "UNIPOLAR",
- 2: "UNIPOLAR",
- 3: "UNIPOLAR",
- 4: "UNIPOLAR",
- }
- _MACRO_RANGES = ((20.0, 1000.0, "log"), (0.0, 0.95), (0.0, 2.0),
- (0.0, 1.0), (0.0, 1.0))
- PATCHES = {
- 0: ("Aged BBD", (88, 53, 19, 64, 44)),
- 1: ("Short BBD", (65, 47, 18, 51, 38)),
- 2: ("Memory Man", (99, 74, 22, 79, 57)),
- 3: ("Chorus Echo", (81, 51, 25, 38, 44)),
- 4: ("Dying Battery", (96, 83, 29, 127, 89)),
- }
-
- WOW_HZ = 1.3
-
- def __init__(self, source, time_ms=300.0, feedback=0.4, mix=0.3,
- age=0.5, drive=0.35, max_time_ms=None, patch=None):
- self._build(source, time_ms, max_time_ms, wow_hz=self.WOW_HZ)
- self._init_macros((time_ms, feedback, mix, age, drive), patch)
-
- def _apply_macro(self, index, position):
- if index == 0:
- self.node.set(delay_ms=self.macro(0))
- elif index == 1:
- self.node.set(feedback=self.macro(1))
- elif index == 2:
- self.node.set(mix=self.macro(2))
- elif index == 4:
- self.node.set(loop_drive=self.macro(4))
- else:
- # One knob, four settings. A bucket-brigade line loses its top
- # first and its bottom soon after, and an old one drifts: the
- # three move together or the knob is not describing a thing.
- age = self.macro(3)
- self.node.set(damping_hz=_core.logmap(1.0 - age, 1100.0, 4200.0),
- cut_hz=_core.logmap(age, 60.0, 280.0),
- wow_depth_ms=0.15 + 1.6 * age)
-
-
-class PingPongDelay(_LoopDelay):
- """Repeats bouncing between the speakers: each channel's output feeds the
- *other* channel's line, so one repeat is on the left, the next on the
- right, and they alternate all the way down.
-
- This used to be two independent delays panned hard apart, one at twice the
- other's time. That gives repeats on both sides, which is not the same
- thing - nothing crossed over, so the pattern never alternated and the
- right side simply ran at half speed.
- """
-
- NAME = 'PingPongDelay'
- DISPLAY_NAME = 'Ping-Pong Delay'
- CATEGORIES = ('Delay',)
- VERSION = '0.0.1'
-
- MACRO_LABELS = ("Time", "Feedback", "Mix", "Spread", "Tone")
- MACRO_MODES = {
- 0: "UNIPOLAR",
- 1: "UNIPOLAR",
- 2: "UNIPOLAR",
- 3: "UNIPOLAR",
- 4: "UNIPOLAR",
- }
- _MACRO_RANGES = ((20.0, 1000.0, "log"), (0.0, 0.95), (0.0, 2.0),
- (0.0, 1.0), (800.0, 16000.0, "log"))
- PATCHES = {
- 0: ("Wide Bounce", (86, 47, 22, 127, 85)),
- 1: ("Wide Eighths", (95, 60, 25, 127, 95)),
- 2: ("Narrow Taps", (68, 40, 19, 64, 103)),
- 3: ("Dark Bounce", (104, 74, 29, 127, 50)),
- }
-
- def __init__(self, source, time_ms=280.0, feedback=0.35, mix=0.35,
- spread=1.0, tone_hz=6000.0, max_time_ms=None, patch=None):
- self._build(source, time_ms, max_time_ms)
- self._init_macros((time_ms, feedback, mix, spread, tone_hz), patch)
-
- def _apply_macro(self, index, position):
- if index == 0:
- self.node.set(delay_ms=self.macro(0))
- elif index == 1:
- self.node.set(feedback=self.macro(1))
- elif index == 2:
- self.node.set(mix=self.macro(2))
- elif index == 4:
- self.node.set(damping_hz=self.macro(4))
- else:
- # Spread does two things at once because half of it is useless
- # alone: crossing the feedback over without steering the input to
- # one side gives repeats that alternate but start on both.
- spread = self.macro(3)
- self.node.set(cross_feed=spread, input_pan=-spread)
-
-
-class MultiTapDelay(_core.Effect):
- """taps: (position 0..1 of time_ms, level) pairs."""
-
- NAME = 'MultiTapDelay'
- DISPLAY_NAME = 'Multi-Tap Delay'
- CATEGORIES = ('Delay',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
-
- def __init__(self, source, time_ms=500.0,
- taps=((0.25, 0.8), (0.5, 0.6), (0.75, 0.4), (1.0, 0.3)),
- mix=0.4):
- self.node = audiodelays.MultiTapDelay(
- max_delay_ms=int(time_ms) + 100, delay_ms=time_ms,
- decay=0.0, mix=mix, taps=tuple(taps), **_core.pcm())
- self.node.play(source)
- self._output = self.node
diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py
index 75c2019..3c64d02 100644
--- a/lib/audioeffects/distortion.py
+++ b/lib/audioeffects/distortion.py
@@ -465,6 +465,13 @@ def _fo_lp(frequency, rate):
return f0, q
+def _rbj_a1(f0, q, rate):
+ """An RBJ section's normalised a1, -2 cos(w0) / (1 + alpha): above 0
+ its poles are on the negative real side, and near 2 they sit at -1."""
+ w0 = 2.0 * math.pi * f0 / rate
+ return -2.0 * math.cos(w0) / (1.0 + math.sin(w0) / (2.0 * q))
+
+
def _fo_hp(frequency, rate):
"""Parked-pole first-order high-pass: `_fo_lp` read the other way up.
@@ -486,6 +493,9 @@ class Distortion(_component.Component):
Boss DS-1. Mix 0 is the wire. Distortion 0 is clean only in `filter`.
"""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'Distortion'
DISPLAY_NAME = 'Distortion'
CATEGORIES = ('Drive',)
@@ -590,6 +600,8 @@ def _build(self, distortion=0.5, filt=0.5, tone=0.0, volume=100.0 / 127.0,
self._wet_latency = CLICK_DELAY_SAMPLES[self._oversample]
self._latency = self._wet_latency
self._scoop = False
+ #: Set by `_rebuild` outside the constructor, read by `reset()`.
+ self._rebuilt = False
self._rat_curve = array("h", _CURVE_RAT_DIODE)
self._ds1_curve = array("h", _CURVE_DS1_DIODE)
self._ds1_asym = array("h", _CURVE_DS1_ASYM)
@@ -665,7 +677,7 @@ def _connect(self):
_component.open_level_gates(
mixer, list(mixer.voice), self._silence)
for voice, source in self._wiring:
- voice.play(source)
+ self._play_voice(voice, source)
def _charge_output_cap(self):
"""Settle `_out_hp` on the bias before the first block is asked for.
@@ -678,8 +690,14 @@ def _charge_output_cap(self):
at its own silence buffer and the chain downstream of it is pulled
until the pole has settled, which happens before anything reads the
Splitter, so no tap is starved and the real source is not touched.
+
+ On a build with no `audiocore.get_buffer` - CircuitPython's default
+ board build compiles it out - nothing can pull the pole, so this
+ does nothing. The scoop tables answer zero with zero, so there is
+ no offset to settle and nothing renders differently.
"""
- if self._out_hp is None or not self._macros:
+ pull = getattr(audiocore, "get_buffer", None)
+ if pull is None or self._out_hp is None or not self._macros:
return
# Ten time constants of the pole, in whole blocks. The silence
# buffer is one block long, so it is re-played each time round.
@@ -687,7 +705,7 @@ def _charge_output_cap(self):
/ (2.0 * math.pi * OUT_HP_HZ * 256.0)) + 1
for _ in range(blocks):
self._shaper.play(self._silence)
- if not audiocore.get_buffer(self._out_hp)[1]:
+ if not pull(self._out_hp)[1]:
break
self._shaper.play(self._booster)
@@ -705,8 +723,10 @@ def _install_graph(self, scoop):
width=1.0, sample_rate=self._sample_rate,
channel_count=self._channel_count), reset=False)
adapter.play(self._source)
+ self._head = adapter
mix_split = self._own(
audioroute.Splitter(adapter, taps=2), reset=False)
+ self._head_split = mix_split
self._dry_tap = self._own(mix_split.tap(0), reset=False)
wet_in = self._own(mix_split.tap(1), reset=False)
@@ -824,6 +844,7 @@ def _rebuild(self, scoop):
# method. Now the new graph is standing and the port is pointed at it
# before a single old node is told to let go, so nothing is ever
# pulled after it is released.
+ self._rebuilt = True
old_nodes = self._nodes
self._nodes = []
self._resets = []
@@ -859,6 +880,26 @@ def _release(self, nodes):
if release is not None:
release()
+ def reset(self):
+ """Clear every node and restore patch 0, then arm the graph as
+ `_build` does, so a reset is a fresh instance (no block primed
+ through the chain before it was cleared plays after it)."""
+ self._rebuilt = False
+ self._reset_rearmed(self._rearm, (), building=True)
+
+ def _rearm(self):
+ """After a reset's walk. A patch 0 that crossed the Character
+ boundary built a new graph while the patch went in, unconnected as
+ `_build` leaves it, so it is connected now, at patch 0's levels, as
+ `_build` connects it. Otherwise the graph is re-armed from its
+ bypass branch."""
+ if self._rebuilt:
+ self._stranded = False
+ if self.macro(MIX) > 0.0:
+ self._connect()
+ return
+ self._push()
+
def _push(self):
if not self._macros:
return
@@ -876,6 +917,12 @@ def _push(self):
# at the levels just pushed, every voice re-played - before the
# port is pointed at it (audiocomponents#113).
rejoin = self._rejoin()
+ if rejoin:
+ # The adapter drops the source frames it took before the bypass,
+ # which the host has since read past, and the Splitter behind it
+ # what it held of them.
+ _component.let_go_of_source(self._head, self._source)
+ _component.let_go_of_source(self._head_split, self._head)
# The dry leg is not delayed and the wet leg is, so the class's own
# onset is the DRY one at every Mix that lets any dry through: a
# click at Mix 64 arrives at sample 0, not at sample 2. Reporting
@@ -997,8 +1044,24 @@ def _push_filter(self):
p = self.macro(FILT)
fc = 1.0 / (2.0 * math.pi * (FILTER_R0 + p * FILTER_POT) * FILTER_C)
f0, q = _fo_lp(self._hz(fc), self._sample_rate)
- self._filt.frequency = f0
- self._filt.Q = q
+ filt = self._filt
+ before = _rbj_a1(filt.frequency, filt.Q, self._sample_rate)
+ filt.frequency = f0
+ filt.Q = q
+ # The Filter pot's open end puts this section's poles near -1 at
+ # 44.1 and 48 kHz (Filter 0's corner is above Nyquist, so both
+ # sit at the top of the band), where they ring at Nyquist. Swung
+ # there from a darker corner - Filter 64 to 0, `Clean Edge` from
+ # patch 0 - the state the old section left is in the wrong
+ # coordinates for the new one, and the ring went rail to rail
+ # every sample for a dozen frames: a full-scale crack even at
+ # -40 dBFS in. Starting the section from rest there costs one
+ # step of the size of the material instead. The bound: a landing
+ # with a1 above 1 (Filter 0 to 1 at 48 kHz, 0 to 2 at 44.1 kHz),
+ # from a1 at least 0.5 lower; a sweep there moves it less a step.
+ after = _rbj_a1(f0, q, self._sample_rate)
+ if after > 1.0 and after - before > 0.5:
+ filt.clear()
def _blend_curve(self, asymmetry):
"""Mix the two DS-1 tables at `asymmetry`, in place, in integers.
diff --git a/lib/audioeffects/dynamiceq.py b/lib/audioeffects/dynamiceq.py
index bc1f33e..80dd4da 100644
--- a/lib/audioeffects/dynamiceq.py
+++ b/lib/audioeffects/dynamiceq.py
@@ -147,6 +147,9 @@ class DynamicEQ(_component.Component):
for the gain cell, `audioroute` for the fan-out.
"""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'DynamicEQ'
DISPLAY_NAME = 'Dynamic EQ'
CATEGORIES = ('EQ',)
@@ -400,9 +403,9 @@ def _play_voices(self):
and self._macros[self._LISTEN] < 0.5:
self._primed = False
return
- self._mixer.play(self._taps[0], voice=0, loop=True)
- self._mixer.play(self._notch, voice=1, loop=True)
- self._mixer.play(self._cell, voice=2, loop=True)
+ self._play_voice(self._mixer.voice[0], self._taps[0], loop=True)
+ self._play_voice(self._mixer.voice[1], self._notch, loop=True)
+ self._play_voice(self._mixer.voice[2], self._cell, loop=True)
self._primed = True
def _refresh_output(self):
@@ -446,6 +449,19 @@ def _refresh_output(self):
self._play_voices()
self._output = self._tail
+ def reset(self):
+ """Clear every node and restore patch 0, then hand the voices their
+ sources at patch 0's levels, as `_build` does. The walk used to
+ re-play them at the outgoing patch's levels, before patch 0 went
+ in, so the levels moved after the gates had opened and landed late,
+ a few LSB (up to 70 here) off a fresh instance for the first
+ blocks."""
+ self._resetting = True
+ try:
+ self._reset_rearmed(self._refresh_output, ("_primed",))
+ finally:
+ self._resetting = False
+
def _reset_mixer(self):
"""Clear the Mixer, then hand its voices back their sources.
@@ -459,6 +475,12 @@ def _reset_mixer(self):
Mixer is owned first and so walked last, that chunk comes through
nodes this reset has already cleared.
"""
+ if getattr(self, "_resetting", False):
+ # `reset()` stops the voices and plays them once the patch is in
+ # (`_reset_rearmed`). A host reset of CircuitPython's stock
+ # Mixer here reached the voices' sources and cost the reset a
+ # block of the source, as it did Saturation's.
+ return
audiocore.reset_buffer(self._mixer)
self._play_voices()
diff --git a/lib/audioeffects/exciter.py b/lib/audioeffects/exciter.py
index e1803e9..ab74ea6 100644
--- a/lib/audioeffects/exciter.py
+++ b/lib/audioeffects/exciter.py
@@ -481,6 +481,9 @@
class Exciter(_component.Component):
"""Dry plus a one-sided high-passed harmonic branch (Aphex structure)."""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'Exciter'
DISPLAY_NAME = 'Exciter'
CATEGORIES = ('Distortion',)
@@ -607,6 +610,12 @@ def _build(self, tune=TUNE_DEFAULT, harmonics=HARMONICS_DEFAULT,
self._ready = True
self._prime_if_wet()
+ def reset(self):
+ """Clear every node and restore patch 0, then arm the graph as
+ `_build` does, so a reset is a fresh instance (no block primed
+ through the chain before it was cleared plays after it)."""
+ self._reset_rearmed(self._prime_if_wet, ("_primed",))
+
def _value(self, index):
return _component.macro_value(self._MACRO_RANGES[index],
self._macros[index])
@@ -690,6 +699,10 @@ def _prime_if_wet(self):
self._output = self._blend
return
if self._rejoin():
+ # The Splitter drops the source frames it took before the
+ # bypass, which the host has since read past.
+ _component.let_go_of_source(self._splitter, self._source,
+ ((self._hp, self._wet_in),))
# Back off the bypass: the nodes are cleared, and the voices
# still hold the blocks they had queued when the bypass began.
# Re-armed as `_build` arms them - levels, gates, voices - and
@@ -702,8 +715,8 @@ def _prime_if_wet(self):
self._primed = False
if not self._primed:
self._dc.play(self._shaper)
- self._blend.voice[0].play(self._dry)
- self._blend.voice[1].play(self._dc)
+ self._play_voice(self._blend.voice[0], self._dry)
+ self._play_voice(self._blend.voice[1], self._dc)
self._primed = True
self._output = self._blend
self._blend.voice[0].level = out
diff --git a/lib/audioeffects/flanger.py b/lib/audioeffects/flanger.py
new file mode 100644
index 0000000..e4bb79d
--- /dev/null
+++ b/lib/audioeffects/flanger.py
@@ -0,0 +1,360 @@
+"""`Flanger` - a swept comb on CircuitPython's own `audiodelays.Flanger`.
+
+The player's text is the class docstring; the `CLAIMS` table in the class's
+test file ties its claim sentences to tests. How the node behaves is told
+where the class drives it, in `Flanger._build`.
+"""
+
+VENDOR = "PyDevices"
+
+import math
+
+import audiodelays
+
+from . import _component
+
+
+RATE_I, RANGE_I, MANUAL_I, COLOR_I, MIX_I, POLARITY_I, SYNC_I, DIVISION_I = \
+ range(8)
+
+#: The line, fixed: nothing re-allocates after construction.
+MAX_DELAY_MS = 10
+
+#: Frames a pull hands out: the palette's block.
+BLOCK = 256
+
+#: Sync's divisions in bars, Phaser's own (`phaser.py:DIVISION_BARS`).
+DIVISION_BARS = (4.0, 2.0, 1.0, 0.5, 1.0 / 3.0, 0.25, 0.125)
+
+POLARITIES = ("positive", "negative")
+
+#: 20 log10(32768), rounded up: a peak this far under full scale is under
+#: one LSB.
+SILENCE_DB = 90.31
+
+#: How far a knob may sit from the step its constructor value seeded and
+#: still count as not moved (a host echoing `get_macro` back).
+ECHO_TOLERANCE = 1e-6
+
+_RANGES = (
+ (0.05, 10.0, "log"), # 0 Rate, Hz
+ (0.0, 1.0), # 1 Range: Manual to 10 ms
+ (0.25, 5.0, "log"), # 2 Manual, ms
+ (0.0, 0.95), # 3 Color; the node stops at 0.95
+ (0.0, 1.0), # 4 Mix
+ (0.0, 1.0), # 5 Polarity (TOGGLE)
+ (0.0, 1.0), # 6 Sync (TOGGLE)
+ (0.0, 6.0), # 7 Division index into DIVISION_BARS
+)
+
+
+def _patch(rate, depth, manual, color, mix, negative, sync, bars):
+ """A patch's MIDI steps from the card's engineering values."""
+ division = DIVISION_BARS.index(bars)
+ values = (rate, depth, manual, color, mix, 1.0 if negative else 0.0,
+ 1.0 if sync else 0.0, float(division))
+ return tuple(_component.macro_of(span, value)
+ for span, value in zip(_RANGES, values))
+
+
+#: Patch 0, "Slow jet", on the grid. Construction is patch 0 (the
+#: contract), so each constructor default is the value of patch 0's step.
+PATCH_0 = _patch(0.25, 0.6, 1.0, 0.7, 1.0, False, False, 1.0)
+
+#: Each constructor keyword's default, which NaN falls back to: Rate about
+#: 0.25 Hz, Range about 0.6, Manual about 1 ms, Color about 0.7, Mix 1.
+DEFAULTS = tuple(_component.macro_value(_RANGES[i], PATCH_0[i] / 127.0)
+ for i in range(5))
+RATE, RANGE, MANUAL_MS, COLOR, MIX = DEFAULTS
+
+
+def _choice(choices, position):
+ last = len(choices) - 1
+ return choices[min(last, max(0, int(round(position * last))))]
+
+
+def _option(value, default, span):
+ """A constructor value: NaN is the default, past a stop is that stop."""
+ value = float(value)
+ if value != value:
+ value = default
+ return min(span[1], max(span[0], value))
+
+
+def tail_frames(color, top_ms, rate):
+ """Frames until the ring is exact silence, for Color `color` and a
+ sweep whose top is `top_ms`, at `rate` (the card's section 5).
+
+ Interpolation never exceeds its two samples and the feedback truncates
+ toward zero, so the line's peak falls by at least g each D + 1 frames,
+ and is zero once it would fall under one LSB."""
+ line = int(rate // 100)
+ longest = min(line, int(math.ceil(top_ms * rate / 1000.0)))
+ g = int(color * 32767) / 32767.0
+ if g <= 0.0:
+ return longest + 3
+ trips = int(math.ceil(SILENCE_DB / (-20.0 * math.log10(g))))
+ return (trips + 2) * (longest + 1) + 2
+
+
+class Flanger(_component.Component):
+ """A flanger: your signal plus a copy of itself a few milliseconds
+ late, the delay swept up and down by a triangle, so the comb of notches
+ the sum makes moves through the spectrum.
+
+ **The controls.** Rate runs the sweep from 0.05 to 10 Hz.
+ Manual, from 0.25 to 5 ms, is the sweep's floor, and Range is how far
+ it climbs from there towards 10 ms: at Range 0 the comb holds still at
+ Manual.
+ Color, from 0 to 0.95, feeds the copy back into the line and raises the
+ comb's peaks into a ring.
+ Mix is the copy's level against a dry that never moves: 1 is equal
+ weight and the deepest notches, 0 is a wire.
+ Polarity minus turns the comb over and puts a notch at DC.
+ With Sync on, one sweep lasts Division bars (4 bars down to an eighth)
+ at the host's tempo and meter, and Rate is ignored.
+ Constructor values play exactly until their knob first moves or a patch
+ loads.
+
+ **What you hear.** The copy is your signal exactly Manual late, added
+ at Mix.
+ At Color 0 and Mix 1 a tone on a notch comes out at least 40 dB down.
+ Color raises the peaks by up to 26.4 dB over the input and fills the
+ notches to 6 dB under it.
+ The sweep is a triangle in milliseconds that starts at Manual and
+ reaches its top once a sweep.
+ The ring lasts about 0.4 s to -60 dB at 3 ms and full Color, and
+ about 0.7 s at 5 ms; it grows as Range sweeps the delay longer, and it
+ never oscillates on its own.
+
+ **Limits.** It is linear only to about -28 dBFS on a resonant frequency
+ at full Color, and -7.5 dBFS at Color 0: above that the output is
+ squashed and gritty, not louder.
+ Turning Mix up makes the output louder, by 6 dB on a comb peak at Mix 1:
+ there is no wet-only setting.
+ There is no tone control, no through-zero and no stereo spread: both
+ channels sweep together.
+ Sync sets the sweep's period, not its phase: the sweep starts at Manual
+ when the class is built or reset, not on a bar line, and a synced sweep
+ faster than 20 Hz runs at 20 Hz.
+ A control that jumps makes the output step: move it in small steps from
+ the host if you need it smooth.
+ At 22.05 kHz the sweep's top is 9.977 ms, half a frame under 10 ms.
+ Mix's first step above 0 is still a wire; the copy comes in at the
+ second step, about 36 dB down.
+ Keep the input under -6 dBFS.
+
+ **Wire, latency, tail.** At Mix 0 the output is the input, byte for
+ byte.
+ There is no latency.
+ `tail_samples` is the longest the ring can last at the controls in
+ force, while the source keeps playing, silence included.
+ If the source ends, the output stops dead; `reset()` clears what the
+ line held and returns to patch 0.
+ On a CircuitPython board, stop the flanger from the host if your source
+ can run dry: CircuitPython's own flanger waits on a source that comes
+ back empty.
+ """
+
+ NAME = 'Flanger'
+ DISPLAY_NAME = 'Flanger'
+ CATEGORIES = ('Modulation',)
+ VERSION = '0.1.0'
+
+ #: Upstream's own node: it runs on a stock CircuitPython board.
+ TIER = _component.STOCK
+ REQUIRES = ()
+
+ CAPABILITIES = ("tempo_sync",)
+ _SYNC_MACRO = SYNC_I
+ LATENCY_SAMPLES = 0
+ #: The worst case at 48 kHz: full Color and the sweep at the line's top
+ #: (D = 480 frames). `tail_samples` is the bound at the controls in
+ #: force.
+ TAIL_SAMPLES = 98607
+
+ #: On stock CircuitPython the wet's interpolation overflows int32 where
+ #: two neighbouring line samples are more than 32768 apart at a
+ #: fractional delay (audiodsp#76, widened in audiodsp's copy). At Color
+ #: 0 the line is the input, so -6 dBFS keeps it safe there.
+ INPUT_CEILING_DBFS = -6.0
+
+ #: The constructor's exact values, which play until their knob moves.
+ _KEPT = ("_exact",)
+
+ MACRO_LABELS = ("Rate", "Range", "Manual", "Color", "Mix", "Polarity",
+ "Sync", "Division")
+ MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR",
+ 3: "UNIPOLAR", 4: "UNIPOLAR", 5: "TOGGLE", 6: "TOGGLE",
+ 7: "UNIPOLAR"}
+ _MACRO_RANGES = _RANGES
+
+ PATCHES = {
+ 0: ("Slow jet", PATCH_0),
+ 1: ("Fast metallic",
+ _patch(4.0, 0.15, 0.5, 0.85, 1.0, False, False, 1.0)),
+ 2: ("Held comb", _patch(0.25, 0.0, 2.0, 0.6, 1.0, False, False, 1.0)),
+ 3: ("Negative jet",
+ _patch(0.2, 0.6, 1.0, 0.7, 1.0, True, False, 1.0)),
+ 4: ("Gentle", _patch(0.5, 0.3, 2.0, 0.0, 0.5, False, False, 1.0)),
+ 5: ("Synced one bar",
+ _patch(0.25, 0.6, 1.0, 0.6, 1.0, False, True, 1.0)),
+ }
+
+ def _build(self, rate=RATE, depth=RANGE, manual_ms=MANUAL_MS,
+ color=COLOR, mix=MIX, polarity="positive", sync=False, division=1.0,
+ patch=None):
+ if polarity not in POLARITIES:
+ raise ValueError("polarity must be %r or %r, not %r"
+ % (POLARITIES[0], POLARITIES[1], polarity))
+ asked = (rate, depth, manual_ms, color, mix)
+ exact = tuple(_option(asked[i], DEFAULTS[i], _RANGES[i])
+ for i in range(5))
+ #: The constructor's values, exactly, None for a knob that has
+ #: moved since. A patch drops them all.
+ self._exact = exact
+ bars = float(division)
+ index = 2
+ if bars == bars:
+ for k in range(len(DIVISION_BARS)):
+ if (abs(DIVISION_BARS[k] - bars)
+ < abs(DIVISION_BARS[index] - bars)):
+ index = k
+ self._ready = False
+
+ # The whole graph is the node. Each frame its line is read at
+ # Manual + Range * (10 ms - Manual) * tri(n), a 0-1-0 triangle whose
+ # phase starts at 0 on construction and on a reset
+ # (src/audiodelays/Flanger.c:94, `:172-183`) and advances by
+ # rate / sample_rate a frame (`:280-281`); the line writes
+ # x + wet * feedback and the output is x +- wet * mix through a soft
+ # knee. Every control is read once a pull. A reset, the base's walk
+ # over `_own`, clears the line, the write positions and the phase.
+ self._node = self._own(audiodelays.Flanger(
+ max_delay_ms=MAX_DELAY_MS, min_delay_ms=exact[MANUAL_I],
+ rate=exact[RATE_I], depth=exact[RANGE_I],
+ feedback=exact[COLOR_I], mix=exact[MIX_I], invert=False,
+ sample_rate=self._sample_rate,
+ channel_count=self._channel_count, bits_per_sample=16,
+ samples_signed=True,
+ buffer_size=BLOCK * 2 * self._channel_count))
+ self._node.play(self._source)
+ self._output = self._node
+
+ # Each knob seeds the step nearest its constructor value, so a
+ # fresh instance reads whole MIDI steps; the exact value plays.
+ values = exact + (1.0 if polarity == POLARITIES[1] else 0.0,
+ 1.0 if sync else 0.0, float(index))
+ self._seeds = [
+ _component.position_of_midi(
+ self._mode(i), _component.macro_of(_RANGES[i], values[i]))
+ for i in range(8)]
+ self._seeding = True
+ try:
+ self._init_macros(values)
+ for i in range(8):
+ self._macros[i] = self._seeds[i]
+ finally:
+ self._seeding = False
+ if patch is not None:
+ self.program_change(patch)
+ self._ready = True
+ self._refresh()
+
+ # -- the maps ------------------------------------------------------
+
+ def _value(self, index):
+ exact = self._exact[index] if index < 5 else None
+ if exact is not None:
+ return exact
+ return _component.macro_value(_RANGES[index], self._macros[index])
+
+ def _negative(self):
+ return self._macros[POLARITY_I] >= 0.5
+
+ def _rate_hz(self):
+ """The sweep's rate: Rate, or with Sync on, one sweep per Division
+ bars at the host's tempo, a bar numerator x 4 / denominator quarter
+ notes of its time signature (Phaser's rule)."""
+ if self._macros[SYNC_I] < 0.5:
+ return self._value(RATE_I)
+ tempo, quarters = self._synced_beat()
+ bars = _choice(DIVISION_BARS, self._macros[DIVISION_I])
+ return tempo / (60.0 * quarters * bars)
+
+ def _top_ms(self):
+ manual = self._value(MANUAL_I)
+ return manual + self._value(RANGE_I) * (MAX_DELAY_MS - manual)
+
+ # -- applying ------------------------------------------------------
+
+ def _apply_macro(self, index, position):
+ if (index < 5 and self._exact[index] is not None
+ and not self._seeding
+ and abs(position - self._seeds[index]) > ECHO_TOLERANCE):
+ exact = list(self._exact)
+ exact[index] = None
+ self._exact = tuple(exact)
+ if self._ready:
+ self._refresh()
+
+ def _apply_positions(self, positions, patch_index):
+ """A patch (or a rack's restore) applied whole, then the node set
+ once. A patch drops the constructor's values; a restore keeps the
+ ones it put back (`_KEPT`)."""
+ ready = self._ready
+ self._ready = False
+ try:
+ _component.Component._apply_positions(self, positions,
+ patch_index)
+ finally:
+ self._ready = ready
+ if not self._seeding:
+ self._exact = (None,) * 5
+ if ready:
+ self._refresh()
+
+ def _refresh(self):
+ node = self._node
+ color = self._value(COLOR_I)
+ negative = self._negative()
+ node.min_delay_ms = self._value(MANUAL_I)
+ node.depth = self._value(RANGE_I)
+ node.rate = self._rate_hz()
+ node.feedback = -color if negative else color
+ node.invert = negative
+ node.mix = self._value(MIX_I)
+
+ def transport_changed(self):
+ """With Sync on, follow the host's tempo and its meter: a move of
+ either sets the sweep's rate again. The base follows the tempo
+ alone, so a 4/4 to 3/4 change at one tempo would keep the old
+ rate."""
+ self._check_live()
+ index = type(self)._SYNC_MACRO
+ if index is None or self._macros[index] < 0.5:
+ return
+ # What the host got wrong (a tempo of None or NaN, a field missing,
+ # a signature of 4/0 or 10**400/4) keeps the last good reading, so
+ # it is no change and the sweep plays on as it was.
+ followed = self._synced_beat()
+ if followed == self._followed_bpm:
+ return
+ self._followed_bpm = followed
+ # The base's own path: the Sync switch applied where it stands.
+ self._apply_macro(index, self._macros[index])
+
+ def macro(self, index):
+ """What macro `index` currently stands for, in its own units; for
+ the first five, the value that plays."""
+ self._check_live()
+ if isinstance(index, int) and 0 <= index < 5:
+ return self._value(index)
+ return _component.Component.macro(self, index)
+
+ @property
+ def tail_samples(self):
+ """The bound at the controls in force; see the class docstring."""
+ self._check_live()
+ return tail_frames(self._value(COLOR_I), self._top_ms(),
+ self._sample_rate)
diff --git a/lib/audioeffects/fuzz.py b/lib/audioeffects/fuzz.py
index 1ebc574..f323062 100644
--- a/lib/audioeffects/fuzz.py
+++ b/lib/audioeffects/fuzz.py
@@ -195,11 +195,16 @@
at patch 5**, and 100 Hz is over it at patches 1, 5 and 7 (audit 3 (p)1).
The declaration covers the worst of 40, 100, 220 and 1000 Hz at those four
settings at all three rates, and it is a −6 dBFS reading - the tail is
-shorter at every quieter input. **With Bias off centre it never
-reaches digital zero** - a wander of at most 16 LSB (−66.2 dBFS) stays,
-because ±1 LSB at the input of a 36 dB gain stage is ±63 LSB at the
-steepest part of the curve. Everything above 16 LSB is gone within **9 441**
-frames (patch 2, 40 Hz). Patch 2 ships Bias −0.85 and has it.
+shorter at every quieter input. **With Bias off centre there is no
+bound, and `tail_samples` is None** (Mix 0 aside, which is the dry alone).
+The shaper answers silence with a constant and the coupling poles behind it
+settle near zero rather than on it: on `germanium` the output was still
+nonzero 6 s after the input at every Bias stop but the three either side of
+centre, and on `cascade` it settled at some stops and not at others. What
+stays is small: a wander of about ±11 LSB at patch 2's Bias −0.85 and ±3 at
+Bias +1, and at Bias −1 a constant that depends on what was played - 177 LSB
+(−45.3 dBFS) after a −6 dBFS DC step, 44 after full-scale noise - which is
+DC and not heard. Patch 2 ships Bias −0.85 and has it.
**The output pole is charged before the first block.** S1's third
capacitor is the output one and its section is behind the shaper, but it
@@ -226,8 +231,8 @@
and 0.000 at 482 Hz and above**. Walked over 108 cells (3 rates × 2 characters × 9 Bias
stops × built-at-the-value and moved-to-it-live), the **settled mean is at
most 7.1 LSB and the peak at most 13**, both on `germanium`; `cascade`'s
-worst is 6 LSB of each, inside the 16 LSB residual this class already
-states. What it takes with it is C3's Bias clause and C4's Fuzz span,
+worst is 6 LSB of each. That walk played tones; after a DC step at Bias
+−1 the constant left is 177 LSB (the tail paragraph above). What it takes with it is C3's Bias clause and C4's Fuzz span,
both restated above; **C1 does not move** (h2 −79.059 → −79.379, peak
growth 0.012 → 0.013 dB).
@@ -608,10 +613,9 @@ class Fuzz(_component.Component):
#: 16 627 frames at the constructor default, **18 750 at patch 5**, and
#: 100 Hz is over it at patches 1, 5 and 7 (audit 3 (p)1). This covers
#: the worst of 40, 100, 220 and 1000 Hz at the default and at patches
- #: 1, 5 and 7, at all three rates, after a -6 dBFS burst. What it does
- #: **not** cover is said in the docstring: with Bias off centre the
- #: class never reaches digital zero, and that wander is bounded at
- #: 16 LSB.
+ #: 1, 5 and 7, at all three rates, after a -6 dBFS burst. It holds with
+ #: Bias centred; off centre the output settles near zero rather than on
+ #: it, and `tail_samples` is None there (see the docstring).
TAIL_SAMPLES = 24576
MACRO_LABELS = ("Fuzz", "Level", "Tone", "Bias", "Load", "Mix",
@@ -708,6 +712,8 @@ def _build(self, fuzz_db=36.0, level=1.0, tone=0.5, bias=0.0,
mode=audiobiquad.HIGH_PASS, frequency=self._hz(HP_A_HZ),
Q=HP_A_Q, sample_rate=rate, channel_count=channels))
hp_a.play(self._source)
+ self._head = hp_a
+ self._head_consumers = ()
hp_b = self._own(audiobiquad.Biquad(
mode=audiobiquad.HIGH_PASS, frequency=self._hz(HP_B_HZ),
Q=HP_B_Q, sample_rate=rate, channel_count=channels))
@@ -747,12 +753,14 @@ def _build(self, fuzz_db=36.0, level=1.0, tone=0.5, bias=0.0,
# reach the sum *past* a tone stack the wet legs go through.
self._split = self._own(
audioroute.Splitter(source=self._source, taps=2), reset=False)
+ self._head = self._split
self._dry = self._own(self._split.tap(0), reset=False)
wet_in = self._own(self._split.tap(1), reset=False)
hp_in = self._own(audiobiquad.Biquad(
mode=audiobiquad.HIGH_PASS, frequency=self._hz(14.0),
Q=BUTTER_Q, sample_rate=rate, channel_count=channels))
hp_in.play(wet_in)
+ self._head_consumers = ((hp_in, wet_in),)
self._hps.append(hp_in)
shaper1 = self._own(audioshaper.Waveshaper(
sample_rate=rate, channel_count=channels,
@@ -843,7 +851,7 @@ def _build(self, fuzz_db=36.0, level=1.0, tone=0.5, bias=0.0,
self._route()
self._refresh()
- def _charge_output(self, rewire=False):
+ def _charge_output(self, rewire=False, whole=False):
"""Settle the output pole on the offset before the first block.
Bias is an offset into an asymmetric curve, so the shaper answers a
@@ -875,22 +883,28 @@ def _charge_output(self, rewire=False):
place.
On a build with no `audiocore.get_buffer` - CircuitPython's default
- board build compiles it out - this does nothing and the bang is
- back.
+ board build compiles it out - the pole is pulled the same way from a
+ scratch mixer voice (`_pull_node`), which can't read the blocks back,
+ so it runs all `CHARGE_BLOCKS` and the pole lands where the early stop
+ leaves it, to the byte.
"""
- pull = getattr(audiocore, "get_buffer", None)
- if pull is None or self._charge_tap is None:
+ if self._charge_tap is None:
return False
self._charged_bias = self._value(3)
for index in range(CHARGE_BLOCKS):
for node, _source in self._charge_wiring:
node.play(self._quiet)
- _state, data = pull(self._charge_tap)
+ got = self._pull_node(self._charge_tap)
+ if got is False:
+ break
+ if got is None:
+ continue
+ _state, data = got
if not data:
break
# The first block back is what the pole was holding before the
# shapers were re-pointed, so it says nothing about this offset.
- if index and not any(array("h", bytes(data))):
+ if index and not whole and not any(array("h", bytes(data))):
break
if rewire:
for node, source in self._charge_wiring:
@@ -989,6 +1003,11 @@ def _refresh_output(self):
self._latency = 0
else:
if self._rejoin(keep=(self._tone_mix,)):
+ # The head (the first coupling cap, or cascade's Splitter)
+ # drops the source frames it took before the bypass, which
+ # the host has since read past.
+ _component.let_go_of_source(self._head, self._source,
+ self._head_consumers)
self._rearm()
self._output = self._tilt
self._latency = self._wet_delay
@@ -1033,6 +1052,13 @@ def _route(self):
return
self._hps[1].play(self._lean if lean else self._shapers[0])
self._routed_lean = lean
+ # The pole now reads a shaper that has stood idle: cleared and
+ # charged on the offset, as a live Bias move charges it, or the
+ # move to or from the lean patch thumps out of silence.
+ # All `CHARGE_BLOCKS`, as a build without `get_buffer` charges:
+ # the early stop left 1 LSB that surfaced 120 frames on.
+ if self._ready and not self._constructing:
+ self._charge_output(rewire=True, whole=True)
self._wet_delay = _group_delay_samples(
self._lean_oversample if lean else self._oversample)
@@ -1040,3 +1066,21 @@ def _route(self):
def latency_samples(self):
self._check_live()
return int(self._latency)
+
+ @property
+ def tail_samples(self):
+ """`TAIL_SAMPLES` with Bias centred or Mix at 0, and None anywhere
+ else.
+
+ Off centre the shaper answers silence with a constant, the coupling
+ poles behind it settle into a wander of a few LSB rather than onto
+ zero, and nothing bounds when (or whether) it stops: on `germanium`
+ it was still there 6 s after the input at every Bias stop but the
+ three either side of centre, and on `cascade` it settled at some
+ stops and not at others. A bound there would be a guess, and the
+ contract's None is the honest answer. Mix 0 is the dry alone, and
+ the wander is not in it."""
+ self._check_live()
+ if self._value(3) != 0.0 and self._value(5) > 0.0:
+ return None
+ return type(self).TAIL_SAMPLES
diff --git a/lib/audioeffects/limiter.py b/lib/audioeffects/limiter.py
index 18f6ae3..a65f08b 100644
--- a/lib/audioeffects/limiter.py
+++ b/lib/audioeffects/limiter.py
@@ -156,6 +156,14 @@ class Limiter(_component.Component):
samples of the Lookahead macro are the catch stage's, so True Peak still
adds no latency of its own; every shipped patch that turns it on asks
for lookahead too, and those patches are P4 only.
+
+ **On an ESP32-S3, Catch The Peaks, Delivery Ceiling and Loud (True
+ Peak) can't keep up and the sound breaks up; play Delivery Ceiling -
+ lean instead.** It keeps Delivery Ceiling's lookahead and release,
+ turns True Peak off, and sits 3 dB lower (-3.97 dB), which is the most a
+ peak between samples was measured to rise over a sample ceiling: a
+ worst-phase tone driven 6 dB into it reads **-0.96 dB TP**, where
+ Delivery Ceiling reads -0.77.
"""
NAME = 'Limiter'
@@ -198,6 +206,7 @@ class Limiter(_component.Component):
3: ("Soft Ceiling", (116, 0, 0, 78, 0, 95)),
4: ("Delivery Ceiling", (122, 0, 25, 72, 127, 0)),
5: ("Loud (True Peak)", (125, 64, 38, 38, 127, 0)),
+ 6: ("Delivery Ceiling - lean", (106, 0, 25, 72, 0, 0)),
}
#: `DYN_LIMIT` has no knee term in its gain computer at all
diff --git a/lib/audioeffects/modulation.py b/lib/audioeffects/modulation.py
deleted file mode 100644
index 26dee65..0000000
--- a/lib/audioeffects/modulation.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Modulation effects still served from this family module: `Flanger`.
-
-Phase 3's THROUGH classes and `RingMod` live one file per effect beside
-this package. LFOs tick at the engine's block rate (about 187 Hz at 48 kHz),
-which is ample for musical sweep rates. `Flanger` stays here until CircuitPython
-10.3.0's own node lands in a later follow-up.
-"""
-
-VENDOR = "PyDevices"
-
-import audiodelays
-import synthio
-
-from . import _core
-
-
-class Flanger(_core.Effect):
- """A very short modulated delay with feedback and doppler - the real
- swept comb, jet engine included."""
-
- NAME = 'Flanger'
- DISPLAY_NAME = 'Flanger'
- CATEGORIES = ('Modulation',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
-
- def __init__(self, source, rate=0.25, depth_ms=2.5, feedback=0.6,
- mix=0.5):
- self.motion = synthio.LFO(rate=rate, scale=depth_ms,
- offset=depth_ms + 1.0)
- self.node = audiodelays.Echo(
- max_delay_ms=int(depth_ms * 2 + 20), delay_ms=self.motion,
- decay=feedback, mix=mix, freq_shift=True, **_core.pcm())
- self.node.play(source)
- self._output = self.node
diff --git a/lib/audioeffects/multibandcompressor.py b/lib/audioeffects/multibandcompressor.py
index ffb7d5b..ee25386 100644
--- a/lib/audioeffects/multibandcompressor.py
+++ b/lib/audioeffects/multibandcompressor.py
@@ -200,13 +200,17 @@ class MultibandCompressor(_component.Component):
**Latency 0 at every setting**, and no option here can add any. The
crossover is IIR; the detectors do not look ahead.
- **Cost.** This is the most expensive class in the Dynamics family, and
- it is over its budget on both boards: measured 2026-09-07 at
- construction defaults, **44.3 % of a 256-frame stereo block on an
- ESP32-P4 and 83.6 % on an ESP32-S3**, against a dossier budget of 25 %
- and 45 %. It runs in real time on both - the S3 figure is 4.910 ms of a
- 5.333 ms block - and it leaves 8 % of that block for everything else on
- the chip. At three bands it owns **18 nodes**, eight of them biquads,
+ **On an ESP32-S3, build it with `bands=2`.** At three bands no patch
+ keeps up there and the sound breaks up; at two bands it does, with the
+ split at Crossover Low and the Mid macros idle. No patch can stand in
+ for this: every macro position builds the same graph.
+
+ **Cost.** This is the most expensive class in the Dynamics family.
+ Measured in October 2026, its costliest patch (Loudness; all five sit
+ within 0.2 ms of each other) takes **54.0 % of a 256-frame stereo block
+ on an ESP32-P4 and 107.0 % on an ESP32-S3**, so the S3 cannot run it in
+ real time; built with `bands=2` it is **34.2 % and 71.1 %**. At three
+ bands it owns **18 nodes**, eight of them biquads,
and pulls 72 of them per eight blocks; `bands=2` is the cheaper build
at **12 nodes and 48 pulls** - four biquads, two detectors, three mixer
voices - and on the desktop it measured between 75 % and 79 % of the
@@ -221,6 +225,9 @@ class MultibandCompressor(_component.Component):
Only the constructor can make a *working* instance cheaper.
"""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'MultibandCompressor'
DISPLAY_NAME = 'Multiband Compressor'
CATEGORIES = ('Dynamics',)
@@ -472,12 +479,25 @@ def _play_voices(self):
if self._mix <= 0.0:
self._primed = False
return
- self._mixer.play(self._taps[0], voice=0, loop=True)
+ self._play_voice(self._mixer.voice[0], self._taps[0], loop=True)
for band in range(self._bands):
- self._mixer.play(self._detectors[band], voice=band + 1,
- loop=True)
+ self._play_voice(self._mixer.voice[band + 1],
+ self._detectors[band], loop=True)
self._primed = True
+ def reset(self):
+ """Clear every node and restore patch 0, then hand the voices their
+ sources at patch 0's levels, as `_build` does. The walk used to
+ re-play them at the outgoing patch's levels, before patch 0 went
+ in, so the levels moved after the gates had opened and landed late,
+ a few LSB (up to 70 here) off a fresh instance for the first
+ blocks."""
+ self._resetting = True
+ try:
+ self._reset_rearmed(self._refresh_output, ("_primed",))
+ finally:
+ self._resetting = False
+
def _reset_mixer(self):
"""Clear the Mixer, then hand its voices back their sources.
@@ -495,6 +515,12 @@ def _reset_mixer(self):
walked last, that chunk comes through nodes this reset has already
cleared.
"""
+ if getattr(self, "_resetting", False):
+ # `reset()` stops the voices and plays them once the patch is in
+ # (`_reset_rearmed`). A host reset of CircuitPython's stock
+ # Mixer here reached the voices' sources and cost the reset a
+ # block of the source, as it did Saturation's.
+ return
audiocore.reset_buffer(self._mixer)
self._play_voices()
diff --git a/lib/audioeffects/overdrive.py b/lib/audioeffects/overdrive.py
index fa7b9d8..8ed95e8 100644
--- a/lib/audioeffects/overdrive.py
+++ b/lib/audioeffects/overdrive.py
@@ -56,10 +56,9 @@
floor at the constructor default at 48 kHz** (-81.291 -> -77.939, 1.8 dB
at 44.1, 0.3 dB at 22.05), and a live macro move that changes the offset
re-charges the pole, which costs **one block of input** (5.3 ms at
-48 kHz). On a build with no `audiocore.get_buffer` - CircuitPython's
-default board build compiles it out - the charge is skipped and the
-class's first block after a change is a step of up to 4461 LSB that
-decays to zero in 62 ms.
+48 kHz). A CircuitPython board build, which has no
+`audiocore.get_buffer`, charges it too, and renders byte for byte what a
+desktop does.
**Latency: zero samples** at every macro setting. The dry tap is a copy.
`oversample` puts group delay on the *clip branch only*: 3.3 samples at
@@ -486,6 +485,9 @@ def _one_pole(freq, park):
class Overdrive(_component.Component):
"""Dry plus a diode-limited high-pass; Tube Screamer structure."""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'Overdrive'
DISPLAY_NAME = 'Overdrive'
CATEGORIES = ('Distortion',)
@@ -693,12 +695,11 @@ def _settle_dc(self):
It costs nothing at Symmetry 0, where there is no offset to
settle. On a build with no `audiocore.get_buffer` - CircuitPython's
- default board build compiles it out - this does nothing and the
- thump is back; the class docstring says so.
+ default board build compiles it out - the pole is pulled the same
+ way from a scratch mixer voice (`_pull_node`), which can't read the
+ blocks back, so it runs all `DC_SETTLE_BLOCKS` and the pole lands
+ where the early stop leaves it, to the byte.
"""
- pull = getattr(audiocore, "get_buffer", None)
- if pull is None:
- return False
if not (self._clip_bias or self._dc_charged):
return False
self._dc_charged = bool(self._clip_bias)
@@ -709,7 +710,12 @@ def _settle_dc(self):
# oversampler, which costs a few samples of ramp at the
# top of each block and nothing at all to the charge.
self._shaper.play(self._quiet)
- _state, data = pull(self._dc)
+ got = self._pull_node(self._dc)
+ if got is False:
+ break
+ if got is None:
+ continue
+ _state, data = got
if not data:
break
# The first block back is the one the pole was holding
@@ -784,6 +790,19 @@ def _refresh(self):
if getattr(self, "_ready", False):
self._settle_dc()
+ def reset(self):
+ """Clear every node and restore patch 0, then arm the graph as
+ `_build` does, so a reset is a fresh instance (no block primed
+ through the chain before it was cleared plays after it)."""
+ # The nodes' taken frames are old audio only if Mix 0 left the
+ # graph un-pulled before the reset; otherwise they are the next
+ # frames, and `_rearm` keeps them.
+ self._stale = self._stranded
+ try:
+ self._reset_rearmed(self._prime_if_wet, ("_primed",))
+ finally:
+ self._stale = True
+
def _prime_if_wet(self):
mix = self._value(3)
level = self._value(2)
@@ -797,9 +816,9 @@ def _prime_if_wet(self):
self._rearm(level)
return
if not self._primed:
- self._circuit.voice[0].play(self._dry)
- self._circuit.voice[1].play(self._dc)
- self._out.voice[0].play(self._lp)
+ self._play_voice(self._circuit.voice[0], self._dry)
+ self._play_voice(self._circuit.voice[1], self._dc)
+ self._play_voice(self._out.voice[0], self._lp)
self._primed = True
self._output = self._out
self._circuit.voice[0].level = 1.0
@@ -823,12 +842,16 @@ def _rearm(self, level):
# taken, which is right mid-stream and wrong here: what the nodes
# took before Mix went to 0 is old audio now. Playing each one
# from its own source again drops it.
- self._adapter.play(self._source)
- self._hp.play(self._tap1)
- self._c4.play(self._hp)
- self._shaper.play(self._c4)
- self._dc.play(self._shaper)
- self._lp.play(self._circuit)
+ # A reset of a graph that was playing keeps them: they are the
+ # frames that come next, and dropping them ran the class ahead of
+ # the source for good.
+ if getattr(self, "_stale", True):
+ self._adapter.play(self._source)
+ self._hp.play(self._tap1)
+ self._c4.play(self._hp)
+ self._shaper.play(self._c4)
+ self._dc.play(self._shaper)
+ self._lp.play(self._circuit)
self._circuit.voice[0].level = 1.0
self._circuit.voice[1].level = 1.0
self._out.voice[0].level = level
@@ -839,9 +862,9 @@ def _rearm(self, level):
self._silence)
_component.open_level_gates(self._out, [self._out.voice[0]],
self._silence)
- self._circuit.voice[0].play(self._dry)
- self._circuit.voice[1].play(self._dc)
- self._out.voice[0].play(self._lp)
+ self._play_voice(self._circuit.voice[0], self._dry)
+ self._play_voice(self._circuit.voice[1], self._dc)
+ self._play_voice(self._out.voice[0], self._lp)
self._primed = True
if self._clip_bias:
self._shaper.set(post_gain=self._post_gain)
diff --git a/lib/audioeffects/parametriceq.py b/lib/audioeffects/parametriceq.py
index debcae0..98a2613 100644
--- a/lib/audioeffects/parametriceq.py
+++ b/lib/audioeffects/parametriceq.py
@@ -317,7 +317,12 @@ def section(mode):
(low_hz, low_boost, low_atten,
bell1_hz, bell1_db, bell2_hz, bell2_db, bell3_hz, bell3_db,
bandwidth, high_hz, high_boost, atten_hz, high_atten,
- q_law, output_db), patch)
+ q_law, output_db))
+ #: Where the constructor's own values left the knobs, before any
+ #: patch: what every section was last handed on a fresh instance.
+ self._built_positions = list(self._macros)
+ if patch is not None:
+ self.program_change(patch)
# -- the control laws ----------------------------------------------
@@ -383,6 +388,26 @@ def _refresh_output(self):
self._set(self._output_trim, OUTPUT_CORNER_HZ, SHELF_Q,
self._value(15))
+ def reset(self):
+ """Clear every section and restore patch 0, from the settings a
+ fresh instance's sections hold: built, then the constructor's
+ values, then patch 0. A section patch 0 leaves flat is only taken
+ out (`mix` 0), so it kept the outgoing patch's corner, Q and gain,
+ and a later move that brought it in moved from those instead of
+ from a fresh section's."""
+ self._check_live()
+ for node in self._nodes:
+ node.mix = 0.0
+ node.frequency = 1000.0
+ node.Q = SHELF_Q
+ node.gain_db = 0.0
+ # As `_init_macros` seeds them: every position first, then each
+ # applied, so no section is handed a mix of two settings.
+ self._macros = list(self._built_positions)
+ for index in range(len(self._macros)):
+ self._apply_macro(index, self._macros[index])
+ _component.Component.reset(self)
+
def _apply_macro(self, index, position):
if index in (0, 1, 2):
self._refresh_low()
diff --git a/lib/audioeffects/phaser.py b/lib/audioeffects/phaser.py
index ad40827..efec3fb 100644
--- a/lib/audioeffects/phaser.py
+++ b/lib/audioeffects/phaser.py
@@ -39,14 +39,19 @@
extra synthio 256-point **0.317 / 0.408** + glue **0.0** →
**0.788 / 1.241 ms → P4 ≤ 15 %, S3 ≤ 24 %**. The old **9 % / 16 %** bar
omitted the LFO: the palette could not price a `synthio` source. Lean
-patch 8 is AllPass alone → **5 / 8 %**.
+patch 8 is AllPass alone → **5 / 8 %**. Both figures predate the input
+guard (an `audiofilters.Filter` with no filter, a block copy), which keeps
+a Drive or Mix move from replaying the source: on the desktop it adds about
+1 µs to a 19 µs block. The boards are not re-taken with it yet.
**Latency: zero samples at every setting.** Drive is `oversample=1` (group
delay 0). No lookahead, partition or window is offered, so there is no
latency-adding option to default off and none to name in milliseconds.
`capabilities = ("tempo_sync",)`: Sync on reads `self._transport()` and
-locks Rate to Division in bars.
+locks Rate to Division in bars of the host's time signature (numerator x 4
+/ denominator quarter notes), and `transport_changed()` follows a move of
+the tempo or the time signature.
This class lives in `phaser.py` and is what the package serves.
"""
@@ -68,6 +73,7 @@
except ImportError: # pragma: no cover - a stock board
audioshaper = None
+import audiofilters
import synthio
@@ -327,11 +333,31 @@ def _build(self, rate=0.4, depth=0.7, centre=400.0, feedback=0.0,
# it fills int16, and this puts the 2/3 back. The wet leg's level is
# exactly where it was - measured, the peak over the dry moves by
# 0.000000 dB and the wet RMS by 0.00002 (audiocomponents#77).
+ # The guard plays the borrowed source once, here, and nothing plays
+ # the source again. A Drive or Mix move that takes the shaper in or
+ # out of the pull re-points the cascade between the guard and the
+ # shaper, and a `play()` drops the frames its node had taken and
+ # not yet used: on the source itself that rewound a whole
+ # RawSample to its start, and on a stream of 100-frame buffers
+ # dropped part of one. The guard hands out whole 256-frame blocks,
+ # which the shaper and the cascade each use up in a pull, so a
+ # re-point between them drops nothing. `reset=False`: its reset
+ # would drop the source frames it holds, and it holds nothing else.
+ # `audiofilters.Filter` with no filter is a copy, the cheapest node
+ # that hands out its own block size (DynamicEQ's guard).
+ self._guard = self._own(audiofilters.Filter(
+ filter=None, mix=1, buffer_size=256 * channels * 2,
+ sample_rate=rate_hz, channel_count=channels,
+ bits_per_sample=16, samples_signed=True), reset=False)
+ self._guard.play(self._source, loop=False)
self._shaper = self._own(audioshaper.Waveshaper(
sample_rate=rate_hz, channel_count=channels,
curve=CUBIC_CURVE, oversample=1,
mix=0.0, pre_gain=1.0, post_gain=CURVE_SCALE))
- self._shaper.play(self._source)
+ self._shaper.play(self._guard)
+ #: What the cascade plays: the shaper, or the guard while Drive or
+ #: Mix is at 0.
+ self._head = self._shaper
# Built-in triangle: a custom waveform table is not a palette
# audio node, but serving one still costs. Shape 0.5 (default)
@@ -373,7 +399,7 @@ def _install_cascade(self, stages):
stages=int(stages), frequency=self._held_hz, feedback=0.0,
mix=0.5, sample_rate=self._sample_rate,
channel_count=self._channel_count)
- node.play(self._shaper)
+ node.play(self._head)
if self._cascade is None:
self._own(node)
self._cascade = node
@@ -401,6 +427,17 @@ def _value(self, index):
return _component.macro_value(self._MACRO_RANGES[index],
self._macros[index])
+ def reset(self):
+ """Clear every node, restore patch 0 and start the sweep again.
+
+ The sweep is a `synthio.LFO` the walk does not reach, so it ran on
+ through a reset from wherever it was; patch 0 now builds a fresh
+ one, which starts where a fresh instance's does, as Vibrato's does
+ on its clear."""
+ self._check_live()
+ self._lfo = None
+ _component.Component.reset(self)
+
def _apply_macro(self, index, position):
if index == 5:
stages = _choice(STAGES_CHOICES, position)
@@ -419,15 +456,19 @@ def _apply_macro(self, index, position):
def _refresh_drive(self):
# Mix 0 is a wire. Drive 0 drops the shaper from the pull so the
- # lean patch is AllPass alone (palette 0.248 / 0.423 ms).
+ # lean patch is AllPass (and the guard) alone. The cascade is
+ # re-pointed only when that route changes: a move that keeps it
+ # (or a reset) plays nothing again.
if self._value(4) <= 0.0 or self._value(7) <= 0.0:
self._shaper.set(mix=0.0, pre_gain=1.0)
- self._cascade.play(self._source)
- return
- drive = self._value(7)
- self._shaper.set(mix=drive, pre_gain=1.0 + 8.0 * drive)
- self._shaper.play(self._source)
- self._cascade.play(self._shaper)
+ head = self._guard
+ else:
+ drive = self._value(7)
+ self._shaper.set(mix=drive, pre_gain=1.0 + 8.0 * drive)
+ head = self._shaper
+ if head is not self._head:
+ self._head = head
+ self._cascade.play(head)
def _refresh_mix_feedback(self):
self._cascade.feedback = self._value(3)
@@ -437,14 +478,30 @@ def _refresh_mix_feedback(self):
def _lfo_rate_hz(self):
rate = self._value(0)
if self._value(8) >= 0.5:
- playing, _pos, bpm, _beats, beats_per_bar = self._transport()
- del playing
+ tempo, quarters = self._synced_beat()
bars = _choice(DIVISION_BARS, self._macros[9])
- beats = max(1.0, float(beats_per_bar)) * bars
- tempo = max(1.0, float(bpm))
- rate = tempo / (60.0 * beats)
+ rate = tempo / (60.0 * quarters * bars)
return rate
+ def transport_changed(self):
+ """With Sync on, follow the host's tempo and its time signature: a
+ move of either sets the sweep's rate again. The base follows the
+ tempo alone, so a 4/4 to 3/4 change at one tempo would keep the old
+ rate. A NaN read twice is no change, as the base has it for a
+ tempo."""
+ self._check_live()
+ index = type(self)._SYNC_MACRO
+ if index is None or self._macros[index] < 0.5:
+ return
+ # What the host got wrong (a tempo of None or NaN, a field missing,
+ # a signature of 4/0 or 10**400/4) keeps the last good reading, so
+ # it is no change and the sweep plays on as it was.
+ followed = self._synced_beat()
+ if followed == self._followed_bpm:
+ return
+ self._followed_bpm = followed
+ self._apply_macro(index, self._macros[index])
+
def _fb_span(self):
centre_notch = self._hz(self._value(2))
mid = centre_notch / TAN_22_5
diff --git a/lib/audioeffects/pitch.py b/lib/audioeffects/pitch.py
deleted file mode 100644
index 7cd80f1..0000000
--- a/lib/audioeffects/pitch.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""Pitch and stereo-field manipulation.
-
-Pitch correction (Auto-Tune-style) is deliberately absent: it needs
-pitch detection, which the DSP palette does not provide.
-"""
-
-VENDOR = "PyDevices"
-
-import audiodelays
-import audiomixer
-import audioroute
-
-from . import _core
-
-
-class PitchShifter(_core.Effect):
-
- NAME = 'PitchShifter'
- DISPLAY_NAME = 'Pitch Shifter'
- CATEGORIES = ('Pitch Shift',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
- def __init__(self, source, semitones=0.0, mix=1.0):
- self.node = audiodelays.PitchShift(
- semitones=semitones, mix=mix, window=2048, **_core.pcm())
- self.node.play(source)
- self._output = self.node
-
- def set_semitones(self, semitones):
- self.node.semitones = semitones
-
-
-class Harmonizer(_core.Effect):
- """Dry plus up to three fixed-interval shifted copies."""
-
- NAME = 'Harmonizer'
- DISPLAY_NAME = 'Harmonizer'
- CATEGORIES = ('Pitch Shift',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
-
- def __init__(self, source, intervals=(4.0, 7.0), level=0.5):
- intervals = tuple(intervals)[:_core.SPLITTER_TAPS - 1]
- split = audioroute.Splitter(source, taps=len(intervals) + 1)
- self.shifters = []
- self.mixer = audiomixer.Mixer(voice_count=len(intervals) + 1,
- **_core.pcm(1024))
- self.mixer.voice[0].play(split.tap(0))
- self.mixer.voice[0].level = 1.0
- for index, semitones in enumerate(intervals):
- shifter = audiodelays.PitchShift(
- semitones=semitones, mix=1.0, window=2048, **_core.pcm())
- shifter.play(split.tap(index + 1))
- self.shifters.append(shifter)
- self.mixer.voice[index + 1].play(shifter)
- self.mixer.voice[index + 1].level = level
- self.splitter = split
- self._output = self.mixer
-
-
-class Octaver(_core.Effect):
- """The dry signal plus octaves above and below it, one or two of each.
-
- Each level is how loud that octave sits under the dry; a level of zero
- builds no branch at all, so the common one-octave-down setting costs
- one shifter rather than four. A Splitter fans out to at most four taps,
- which leaves room for the dry plus any three octaves - ask for all four
- and it says so rather than dropping one quietly.
- """
-
- NAME = 'Octaver'
- DISPLAY_NAME = 'Octaver'
- CATEGORIES = ('Pitch Shift',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
-
- #: (attribute, semitones, window) per octave, in the order they mix.
- #: Shorter windows track faster, which matters more the further up the
- #: shift goes and not at all going down.
- OCTAVES = (("down", -12.0, 2048), ("down2", -24.0, 2048),
- ("up", 12.0, 1024), ("up2", 24.0, 512))
-
- def __init__(self, source, down=0.5, up=0.0, down2=0.0, up2=0.0):
- levels = {"down": down, "up": up, "down2": down2, "up2": up2}
- wanted = [octave for octave in self.OCTAVES if levels[octave[0]] > 0.0]
- if len(wanted) > _core.SPLITTER_TAPS - 1:
- raise ValueError(
- "an Octaver can carry %d octaves beside the dry signal, and "
- "%d were asked for"
- % (_core.SPLITTER_TAPS - 1, len(wanted)))
- for name, _, _ in self.OCTAVES:
- setattr(self, name, None)
- split = audioroute.Splitter(source, taps=len(wanted) + 1)
- self.mixer = audiomixer.Mixer(voice_count=len(wanted) + 1,
- **_core.pcm(1024))
- self.mixer.voice[0].play(split.tap(0))
- self.mixer.voice[0].level = 1.0
- for index, (name, semitones, window) in enumerate(wanted):
- shifter = audiodelays.PitchShift(
- semitones=semitones, mix=1.0, window=window, **_core.pcm())
- shifter.play(split.tap(index + 1))
- setattr(self, name, shifter)
- self.mixer.voice[index + 1].play(shifter)
- self.mixer.voice[index + 1].level = levels[name]
- self.splitter = split
- self._output = self.mixer
-
-
-class StereoWidener(_core.Effect):
- """Haas-effect width: the dry center plus a short-delayed copy pushed
- to one side and its source-panned opposite."""
-
- NAME = 'StereoWidener'
- DISPLAY_NAME = 'Stereo Widener'
- CATEGORIES = ('Spatial',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
-
- def __init__(self, source, delay_ms=14.0, width=0.7):
- split = audioroute.Splitter(source, taps=2)
- self.side = audiodelays.Echo(
- max_delay_ms=int(delay_ms) + 20, delay_ms=delay_ms, decay=0.0,
- mix=1.0, freq_shift=False, **_core.pcm())
- self.side.play(split.tap(1))
- self.mixer = audiomixer.Mixer(voice_count=2, **_core.pcm(1024))
- self.mixer.voice[0].play(split.tap(0))
- self.mixer.voice[0].level = 1.0
- self.mixer.voice[0].panning = -0.3 * width
- self.mixer.voice[1].play(self.side)
- self.mixer.voice[1].level = 0.8
- self.mixer.voice[1].panning = 0.9 * width
- self.splitter = split
- self._output = self.mixer
diff --git a/lib/audioeffects/rack.py b/lib/audioeffects/rack.py
deleted file mode 100644
index fce6c78..0000000
--- a/lib/audioeffects/rack.py
+++ /dev/null
@@ -1,271 +0,0 @@
-"""Effect racks: one component whose internal graph is several effects.
-
-A rack has exactly the effect shape - audio source in, `.output` out, MIDI
-for control only - so it lives here as one more effect kind rather than in
-a package of its own. `Rack` is the mechanism, ported from the way
-micropython-vst3's soundtrack builds its custom racks: a serial chain of
-this package's own classes, described by portable literals so a host can
-construct one without holding any live objects:
-
- rack = audioeffects.create("Rack", source, 48000, chain=(
- ("Compressor", {"threshold_db": -24.0, "ratio": 3.0}),
- ("TapeDelay", {"time_ms": 340.0, "mix": 0.25}),
- ("Reverb", {"character": "hall", "mix": 0.3}),
- ))
- audio_out.play(rack.output)
-
-`ShimmerHall` and `AirSpace` are the two racks the soundtrack shares
-between pieces (its `fx_shimmer.py` and `fx_space.py`), ported whole:
-fixed topologies with a macro surface over the children, so they carry
-patches the way any patchable effect here does. Racks may contain and be
-used by other racks: a `chain` entry may itself be a `("Rack", {...})`.
-
-Those two racks name the classes they were ported against, `delay.py`'s
-`TapeDelay`, `pitch.py`'s `Octaver` and `reverb.py`'s `Reverb`, directly
-rather than by NAME: the rebuilt classes the package serves under those
-names (since 2026-09-29 for the delay and reverb, 2026-10-13 for the
-octaver) take different settings (no `preset`, no `wow`, no `down` or `up`)
-and sound different, and moving a rack onto them is a re-voicing of the
-rack, not a part of the adoption.
-"""
-
-VENDOR = "PyDevices"
-
-from . import _core
-from ._component import macro_position as _macro_position
-from ._component import midi_of_position as _midi_of_position
-from ._component import port_target as _port_target
-from .delay import TapeDelay as _OldTapeDelay
-from .pitch import Octaver as _OldOctaver
-from .reverb import Reverb as _OldReverb
-
-
-def _child(name, tail, options):
- """One chain entry built around `tail` at the configured rate. `name`
- is an effect NAME, or, for the soundtrack racks below, a class."""
- if not isinstance(name, str):
- return name.create(tail, _core.sample_rate(), **options)
- import audioeffects
- return audioeffects.create(name, tail, _core.sample_rate(), **options)
-
-
-class Rack(_core.Effect):
- """A serial chain of this package's effects, as one component.
-
- `chain` is a tuple of entries, each either an effect `NAME` or a
- `(NAME, options)` pair whose options are the keyword arguments that
- effect's constructor takes. The entries are built in order, each fed
- the previous one's `.output`; the live children are kept on
- `self.effects` in that order. An empty chain is a wire: the rack's
- output is its source, the same pattern as a flat `GraphicEQ`.
-
- The rack owns its children and `deinit()` releases them; the outer
- source stays borrowed, as the component contract requires. Latency and
- tail are those of the complete graph: latencies add, and the tail is
- the children's tails summed, or `None` as soon as any child's tail is
- unbounded.
- """
-
- NAME = 'Rack'
- DISPLAY_NAME = 'Effect Rack'
- CATEGORIES = ('Effect Rack',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
-
- def __init__(self, source, chain=()):
- self.effects = []
- tail = source
- for entry in chain:
- if isinstance(entry, str):
- name, options = entry, {}
- else:
- try:
- name, options = entry
- except (TypeError, ValueError):
- raise ValueError(
- "a chain entry is a NAME or a (NAME, options) "
- "pair, not %r" % (entry,))
- options = dict(options)
- child = _child(name, tail, options)
- self.effects.append(child)
- tail = child.output
- # A RACK DOES NOT GET A PORT OF ITS OWN, and that is a decision, not
- # an omission. Its output never changes after construction -- nothing
- # in this file re-points it -- and the last child's output is already
- # a stable port, so a second one would buy nothing and cost a call per
- # block. It would also break two things the contract says out loud and
- # `tests/test_cpython_effects_racks.py` asserts: a rack's output IS
- # its last child's output, and an empty rack IS a wire (`rack.output
- # is src`). So the port is set directly here, going around the
- # property that would wrap it.
- self._port = tail
-
- @property
- def latency_samples(self):
- self._check_live()
- return sum(child.latency_samples for child in self.effects)
-
- @property
- def tail_samples(self):
- self._check_live()
- total = 0
- for child in self.effects:
- tail = child.tail_samples
- if tail is None:
- return None
- total += tail
- return total
-
- def transport_changed(self):
- """Pass the host's word on to every child, in chain order."""
- self._check_live()
- for child in self.effects:
- child.transport_changed()
-
- def reset(self):
- """Clear the whole graph's DSP history, then restore patch 0.
-
- Each child's history is cleared directly rather than through
- `child.reset()`, because a child's own reset would also reapply
- that child's patch 0 over the options this rack built it with.
- """
- self._check_live()
- for child in self.effects:
- output = child.output
- # Ask the port what it is playing, not what it is. A child at
- # Mix 0 has its port pointed straight at the borrowed source --
- # the previous child's output, or the rack's own -- and a reset
- # forwarded through the port would rewind the whole chain
- # upstream of it. Before the port, `output is child._source` was
- # the same test.
- if _port_target(output) is not child._source:
- try:
- import audiocore
- audiocore.reset_buffer(output)
- except (AttributeError, RuntimeError):
- pass
- self.program_change(0)
-
- def deinit(self):
- if self._deinited:
- return
- # No port of its own to release -- `self._output` is the last child's
- # port, or, for an empty rack, the borrowed source. Releasing either
- # from here would be releasing something this rack does not own.
- for child in reversed(self.effects):
- child.deinit()
- self._output = None
- self._deinited = True
-
-
-class ShimmerHall(Rack):
- """The classic shimmer: the signal plus a copy of itself an octave up,
- smeared through a tape echo and a long hall.
-
- The octave-up is the whole trick. A reverb on its own gets bigger; a
- reverb fed something an octave above what went in keeps growing upward,
- and that rising quality is what "shimmer" actually names. The tape echo
- in between is what stops it arriving all at once - each repeat is a
- little darker than the last, so the octave blooms in and decays rather
- than sitting there.
-
- Shimmer is how loud the octave sits under the dry, Echo the tape send,
- Space the hall, Tone how bright the repeats stay.
- """
-
- NAME = 'ShimmerHall'
- DISPLAY_NAME = 'Shimmer Hall'
- CATEGORIES = ('Effect Rack', 'Reverb')
- VERSION = '0.0.1'
-
- MACRO_LABELS = ("Shimmer", "Echo", "Space", "Tone")
- MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR",
- 3: "UNIPOLAR"}
- _MACRO_RANGES = ((0.0, 1.0), (0.0, 0.8), (0.0, 0.7),
- (800.0, 16000.0, "log"))
- PATCHES = {0: ("Shimmer Hall", (70, 57, 76, 79))}
-
- def __init__(self, source, shimmer=0.55, echo=0.36, space=0.42,
- tone_hz=5160.0, patch=None):
- # The octave branch is built at full level whatever `shimmer` asks
- # for, because the topology is fixed by construction: the Shimmer
- # macro moves the branch's mixer level, and a branch that was
- # skipped for starting at zero could never come back.
- Rack.__init__(self, source, chain=(
- (_OldOctaver, {"down": 0.0, "up": 1.0}),
- (_OldTapeDelay, {"time_ms": 420.0, "feedback": 0.5,
- "wow": 0.3, "drive": 0.2}),
- (_OldReverb, {"preset": "hall"}),
- ))
- self.octave, self.tape, self.hall = self.effects
- self._init_macros((shimmer, echo, space, tone_hz), patch)
-
- def _apply_macro(self, index, position):
- if index == 0:
- # voice[0] is the dry; voice[1] is the octave, which is the
- # only branch this Octaver was asked to build.
- self.octave.mixer.voice[1].level = self.macro(0)
- elif index == 1:
- self.tape.set_mix(self.macro(1))
- elif index == 2:
- self.hall.set_mix(self.macro(2))
- else:
- # The tape's own Tone macro, on the same 800-16000 Hz log span.
- self.tape.set_macro(4, position * 127.0)
-
-
-class AirSpace(Rack):
- """A send-style space: a swept tone filter into a wobbling tape delay
- into a hall. Space is the reverb mix, Echo the tape send, Tone the
- filter."""
-
- NAME = 'AirSpace'
- DISPLAY_NAME = 'Air Space'
- CATEGORIES = ('Effect Rack', 'Reverb')
- VERSION = '0.0.1'
-
- MACRO_LABELS = ("Space", "Echo", "Tone")
- MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR"}
- _MACRO_RANGES = ((0.0, 0.6), (0.0, 0.6), (500.0, 12000.0, "log"))
- PATCHES = {0: ("Air Space", (64, 53, 85))}
-
- def __init__(self, source, space=0.3, echo=0.25, frequency=4200.0,
- patch=None):
- Rack.__init__(self, source, chain=(
- ("LowPass", {"frequency": 4200.0}),
- (_OldTapeDelay, {"time_ms": 375.0, "feedback": 0.4}),
- (_OldReverb, {"preset": "hall"}),
- ))
- self.tone, self.tape, self.hall = self.effects
- self._init_macros((space, echo, frequency), patch)
-
- def _apply_macro(self, index, position):
- if index == 0:
- self.hall.set_mix(self.macro(0))
- elif index == 1:
- self.tape.set_mix(self.macro(1))
- else:
- # `LowPass`'s own Frequency macro, on its own span. The rebuilt
- # class has no `set_frequency`: its dossier's section 7 names
- # that method as off-contract - it raised above Nyquist instead
- # of clamping - and hertz reach a component through its macro
- # grid, which is what the contract has. `set_macro` takes a
- # float, so nothing is quantized on the way through.
- #
- # Which class `self.tone` is depends on `rebuilt.ADOPTED`: while
- # `LowPass` is parked (gate audit section 4.1) the old class in
- # `eq.py` stands, and it has no macro grid at all - hertz reach
- # it through `set_frequency`. The rack works with whichever
- # class the library serves rather than assuming the rebuild has
- # been adopted.
- labels = type(self.tone).MACRO_LABELS
- if "Frequency" in labels:
- index = labels.index("Frequency")
- span = type(self.tone)._MACRO_RANGES[index]
- self.tone.set_macro(index, _midi_of_position(
- type(self.tone).MACRO_MODES.get(index, "UNIPOLAR"),
- _macro_position(span, self.macro(2))))
- else:
- self.tone.set_frequency(self.macro(2))
diff --git a/lib/audioeffects/rebuilt/__init__.py b/lib/audioeffects/rebuilt/__init__.py
index ad2e4c0..48d9b1e 100644
--- a/lib/audioeffects/rebuilt/__init__.py
+++ b/lib/audioeffects/rebuilt/__init__.py
@@ -1,6 +1,5 @@
"""One module per rebuilt effect class, named after the class's `NAME` in
-lower case: `rebuilt/flanger.py` holds parked `Flanger` until that program
-comes home.
+lower case: `rebuilt/octaver.py` holds `Octaver` until it comes home.
**Nothing in this file lists the classes.** A rebuild adds exactly one file
and edits nothing else -- not this module, not the family module it
@@ -21,7 +20,7 @@
`ringmod.py`) and all seven of Phase 4's (`overdrive.py`, `distortion.py`,
`fuzz.py`, `saturation.py`, `bitcrusher.py`, `exciter.py`,
`cabinetsim.py`). This directory keeps the substitution machinery, the two
-`Example` fixtures and the parked Phase 3 rebuild (`flanger.py`). `ADOPTED`
+`Example` fixtures and the later phases' classes. `ADOPTED`
below no longer
names the classes that have come home -- there is nothing left here for it
to arbitrate for them -- and it will name later phases' classes until those
@@ -89,7 +88,8 @@ def _present():
#: shipped patch, and all three are inside it). `drive.py` is deleted, and
#: there is nothing left here to arbitrate for any of them.
#:
-#: What stays parked under this directory: Phase 3's `Flanger`.
+#: Nothing is parked under this directory now: Phase 3's parked `Flanger`
+#: was deleted in Phase 7, and `Flanger` is served from `flanger.py`.
#:
#: Phase 5's first two, adopted 2026-09-28 on the rulings of that date:
#: `DigitalDelay` and `SlapbackDelay`. Both are THROUGH on the desktop half
@@ -117,8 +117,8 @@ def _present():
#:
#: Phase 6's seven, adopted 2026-10-13: `StereoWidener`, `Rack`, `Octaver`,
#: `PitchShifter`, `Harmonizer`, `AirSpace` and `ShimmerHall`. They stay
-#: under this directory, and `pitch.py` and `rack.py` keep the old classes,
-#: until they come home.
+#: under this directory until they come home; the old classes they replaced
+#: were deleted with `_core.py`.
ADOPTED = (
"DigitalDelay",
"SlapbackDelay",
diff --git a/lib/audioeffects/rebuilt/airspace.py b/lib/audioeffects/rebuilt/airspace.py
index 6bd5d23..0183b0f 100644
--- a/lib/audioeffects/rebuilt/airspace.py
+++ b/lib/audioeffects/rebuilt/airspace.py
@@ -289,6 +289,8 @@ def _build(self, time_ms=111.0, feedback=0.375, glide_ms=4000.0,
#: shorter Time walks the head down, so the old one holds until the
#: tape is next emptied.
self._reach = 1
+ #: True from a tape clear to the next Time handed (`_clear_tape`).
+ self._landing = False
# The borrowed source's only reader: a width-1 MidSide passes it
# byte for byte in the palette's blocks, and Mix 0 hands it out, so
@@ -389,6 +391,9 @@ def set_macro(self, index, value, channel=0, note_id=-1,
self._macro_index(index)
flags = list(self._snap)
try:
+ # Clamped first: an int past float's range (10**400) is the top
+ # stop, and `%` on it would raise.
+ value = _component.clamped_midi(value)
position = _component.position_of_midi("UNIPOLAR", value)
if abs(position - self._macros[index]) > STEP_TOLERANCE:
fraction = value % 1.0
@@ -467,8 +472,12 @@ def _apply_macro(self, index, position):
if frames < 1:
frames = 1
self._frames = frames
- if frames > self._reach:
+ if frames > self._reach or self._landing:
+ # A tape just emptied lands its head on this Time, so the
+ # head reaches no further than it (a reset from a longer
+ # patch used to keep the longer Time in the tail).
self._reach = frames
+ self._landing = False
self._hand_tape(delay_frames=frames)
elif index == FEEDBACK_I:
self._hand_tape(feedback=value)
@@ -565,6 +574,7 @@ def _clear_tape(self):
Time it is handed instead of gliding there."""
self._fd.clear()
self._reach = self._frames
+ self._landing = True
def _reset_chamber(self):
"""The chamber's own reset lands on its one patch; the rack's three
diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py
index 5780aaf..60b3d03 100644
--- a/lib/audioeffects/rebuilt/convolutionreverb.py
+++ b/lib/audioeffects/rebuilt/convolutionreverb.py
@@ -7,6 +7,8 @@
VENDOR = "PyDevices"
+import math
+
from . import _component
try:
@@ -47,6 +49,48 @@
#: Room seeds: 1 + round(position * 63), 64 rooms.
ROOMS = 64
+#: The wet trim, from the room itself. The room is normalised to unit energy,
+#: so a dark or short one gathers that energy low and brings a low input back
+#: louder than it went in. The node reads how much as it synthesizes
+#: (`Convolver.room_peak_gain`: the step response's peak and the
+#: steady-state peak of a full-scale square from 40 to 160 Hz, the worse
+#: side), and the trim holds that, the room alone, to `TRIM_PEAK_DB`: a
+#: -12 dBFS input then peaks at full scale. Where the room reads 1 dB or
+#: more under it the trim is exactly 1.0; above `TRIM_PEAK_DB + TRIM_KNEE_DB`
+#: it takes the excess off; between, a quadratic knee joins the two with no
+#: corner, and is never above what holds. The dry is not trimmed, so with it
+#: mixed in the sum can go higher (`INPUT_CEILING_DBFS`).
+TRIM_PEAK_DB = 12.0
+TRIM_KNEE_DB = 1.0
+#: Damping out is a different room from its step below, brighter by a
+#: one-pole: it reads about 3.15 dB lower (3.04 to 3.31 on average at 22.05 to
+#: 48 kHz, 2.05 to 3.82 over 512 rooms). Read that much higher, out is trimmed
+#: as the step below it is, so the top step moves the level by no more than
+#: that spread, where it jumped 2.4 dB, and never trims out less than it
+#: needs.
+OUT_LIFT_DB = 3.15
+_OUT_LIFT = 10.0 ** (OUT_LIFT_DB / 20.0)
+_DB20 = 20.0 / math.log(10.0)
+
+
+def room_trim(peak_gain):
+ """The wet gain for a room whose `room_peak_gain` is `peak_gain`: 1.0
+ up to the knee, then down by the excess over `TRIM_PEAK_DB`, through a
+ quadratic soft knee `TRIM_KNEE_DB` either side of it. Never above 1.0.
+ Rounded to 1/4096, so it is the same number in single and double
+ precision."""
+ if not peak_gain > 0.0:
+ return 1.0
+ excess = _DB20 * math.log(peak_gain) - TRIM_PEAK_DB
+ if excess <= -TRIM_KNEE_DB:
+ return 1.0
+ if excess < TRIM_KNEE_DB:
+ db = -(excess + TRIM_KNEE_DB) ** 2 / (4.0 * TRIM_KNEE_DB)
+ else:
+ db = -excess
+ return int(10.0 ** (db / 20.0) * 4096.0 + 0.5) / 4096.0
+
+
DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I, MIX_I = range(6)
SYNTHESIS_MACROS = (DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I)
@@ -174,8 +218,15 @@ class ConvolutionReverb(_component.Component):
With Damping in, the 64 Rooms fall 60 dB within 2 % of the Decay time on
average.
A single Room with Damping in can take more than 15 % longer.
- At Damping's 500 Hz stop, low material comes back louder than it went
- in.
+ A synthesized room plays through a level trim read off the room itself:
+ none where the room brings a low square back 1 dB or more under 12 dB
+ louder, and otherwise just enough to hold it to 12 dB, so the defaults
+ play about 1 dB down at 48 kHz.
+ One step of Decay or Damping moves the room's level by 0.5 dB at most.
+ Anywhere on the knobs, a synthesized room alone is clean up to
+ -13.5 dBFS in, and with the dry mixed in up to -15.5 dBFS, its input
+ ceiling: the trim holds a low square's steady state at -12 dBFS, its
+ onset rises about 1.3 dB past that, and the dry is not trimmed.
Each side of a stereo room is normalised on its own, so the room sits in
the middle.
@@ -236,6 +287,17 @@ class ConvolutionReverb(_component.Component):
#: holds (`latency_samples` below): 0 on an empty impulse.
LATENCY_SAMPLES = PARTITION
TAIL_SAMPLES = None
+ #: Where no synthesized setting reaches the rail, after `room_trim`, with
+ #: the dry in. Measured on the kit's ceiling material (with its 163 Hz
+ #: square) over 1600 settings drawn at random, every Room and both sides,
+ #: at 48 and 22.05 kHz: the worst, Decay 83, Damping 40, Predelay 0,
+ #: Diffusion 127, Room seed 17 at 48 kHz, is clean to -13.3 dBFS with the
+ #: room alone and to -15.0 dBFS at Mix 1, where the dry adds to it; this
+ #: rounds the second down. The trim holds `room_peak_gain`, a low square's
+ #: steady state, at 12 dB; a square's onset rises about 1.3 dB past that.
+ #: A loaded impulse brings its own level and `ir_gain_db` and is not
+ #: covered.
+ INPUT_CEILING_DBFS = -15.5
MACRO_LABELS = ("Decay", "Damping", "Predelay", "Diffusion", "Room",
"Mix")
@@ -278,6 +340,8 @@ def _build(self, decay=1.0, damping_hz=6000.0, predelay=0.0,
self._deferred = True
#: The synthesis tuple the node holds, or None.
self._loaded = None
+ #: The wet gain the node holds (`room_trim`).
+ self._trim = 1.0
self._measured = impulse is not None
rate = self._sample_rate
if self._measured:
@@ -463,6 +527,13 @@ def _refresh(self):
if not self._measured:
room = self._synthesis()
if room != self._loaded:
+ # The new room's reading, taken before it is built, and its
+ # trim set before the synthesis, so the block in flight fades
+ # from the old room at its level to the new at its own.
+ trim = self._room_trim(room)
+ if trim != self._trim:
+ self._node.set(wet_gain=trim)
+ self._trim = trim
self._node.synthesize(decay=room[0], damping_hz=room[1],
predelay_ms=room[2],
diffusion_ms=room[3], seed=room[4])
@@ -471,6 +542,17 @@ def _refresh(self):
# halving is a power of two, so it adds no rounding of its own.
self._node.set(mix=self._value(MIX_I) * 0.5)
+ def _room_trim(self, room):
+ """The wet gain for the room `room` (a `_synthesis()` tuple):
+ `room_trim` of the `room_peak_gain` the node reads for it, lifted by
+ `OUT_LIFT_DB` with Damping out, wherever Mix is."""
+ peak = self._node.room_peak_gain_for(
+ decay=room[0], damping_hz=room[1], predelay_ms=room[2],
+ diffusion_ms=room[3], seed=room[4])
+ if room[1] == 0.0:
+ peak *= _OUT_LIFT
+ return room_trim(peak)
+
def _apply_positions(self, positions, patch_index):
"""Apply a patch (or a rack's restore) whole, then synthesize once. In measured
mode only its Mix is audible; the synthesis positions are stored
diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py
index e1a4fff..6bbba45 100644
--- a/lib/audioeffects/rebuilt/digitaldelay.py
+++ b/lib/audioeffects/rebuilt/digitaldelay.py
@@ -125,6 +125,10 @@
TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, SYNC_I, DIVISION_I, TONE_I, CUT_I = \
range(8)
+#: `_build`'s defaults in the macros' units: patch 0's steps, rounded. An
+#: option left out plays the step itself.
+DEFAULTS = (350.0, 0.35, 0.3, 4000.0, 0.0, 6.0, 16000.0, 20.0)
+
def nominal_cut_hz(corner_hz, sample_rate):
"""`cut_hz` whose one-pole high-pass -3 dB is `corner_hz` at
@@ -354,9 +358,27 @@ class DigitalDelay(_component.Component):
5: ("Band Limited Repeats", (102, 77, 19, 89, 0, 51, 92, 29)),
}
- def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0,
- sync=False, division=6, tone_hz=16000.0, cut_hz=20.0,
+ #: The number each option left out stands for (patch 0's step,
+ #: rounded), for a reader of the signature.
+ _OPTION_DEFAULTS = {"time_ms": 350.0, "feedback": 0.35, "mix": 0.3,
+ "glide_ms": 4000.0, "division": 6,
+ "tone_hz": 16000.0, "cut_hz": 20.0}
+
+ def _build(self, time_ms=None, feedback=None, mix=None, glide_ms=None,
+ sync=None, division=None, tone_hz=None, cut_hz=None,
max_time_ms=TIME_MAX_MS, patch=None):
+ # An option left out plays patch 0's step, so a fresh instance is
+ # patch 0 exactly and a reset lands where it starts
+ # (`_init_macros`): Time 350 ms is grid 102, 352.8 ms, and the two
+ # used to play side by side. `DEFAULTS` is each step rounded. A
+ # value you pass plays as passed.
+ raw = (time_ms, feedback, mix, glide_ms, sync, division, tone_hz,
+ cut_hz)
+ seeded = tuple(value is None for value in raw)
+ time_ms, feedback, mix, glide_ms, sync, division, tone_hz, cut_hz = (
+ DEFAULTS[index] if seeded[index] else raw[index]
+ for index in range(len(raw)))
+ sync = bool(sync)
max_time_ms = float(max_time_ms)
# `not <=` catches NaN, which would otherwise pass both clamps and
# size the line from nothing.
@@ -403,7 +425,8 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0,
# 0, a negative and NaN are the jump; a NaN slew would neither
# walk nor jump, and the Time knob would do nothing.
glide_ms = 0.0
- self._glide_exact = glide_ms
+ # A Glide left out plays its grid step, so it is not kept exact.
+ self._glide_exact = None if seeded[GLIDE_I] else glide_ms
self._seeding = True
self._delay = audioecho.FeedbackDelay(
sample_rate=self._sample_rate,
@@ -428,8 +451,9 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0,
self._init_macros((time_ms, feedback, mix,
max(GLIDE_MIN_MS, glide_ms),
1.0 if sync else 0.0, float(division), tone_hz,
- cut_hz))
- if self._glide_exact > 0.0 and self._macros[GLIDE_I] < GLIDE_FLOOR:
+ cut_hz), defaults=seeded)
+ if self._glide_exact is not None and self._glide_exact > 0.0 \
+ and self._macros[GLIDE_I] < GLIDE_FLOOR:
self._macros[GLIDE_I] = GLIDE_FLOOR
self._seeding = False
if patch is not None:
diff --git a/lib/audioeffects/rebuilt/flanger.py b/lib/audioeffects/rebuilt/flanger.py
deleted file mode 100644
index 444b4ef..0000000
--- a/lib/audioeffects/rebuilt/flanger.py
+++ /dev/null
@@ -1,393 +0,0 @@
-"""`Flanger` - a BBD swept comb informed by the Electric Mistress.
-
-Rebuilt from scratch against a trait table frozen on 2026-09-08 before
-this file existed. The old class in `modulation.py` is not consulted
-except for eight known defects, and it stays the class the library serves
-until the auditor adopts this one.
-
-**What it sounds like.** One interpolated delay line mixes with the dry
-path at equal weight, so the comb's notches are true nulls. A triangle
-on the *clock* becomes a hyperbola in milliseconds - that is the bucket
-brigade law, not a triangle LFO on the delay. Color is regeneration:
-peaks rise and the floor fills. Filter Matrix stops the LFO and leaves
-Manual, Color and Mix live. Through Zero is off unless you turn it on,
-and then the dry path is late by the sweep's maximum at Range max
-(**10 ms / 480 samples at 48 kHz**).
-
-**What the default gives up.** At the constructor default (Color 0.55,
-Filter Matrix off) the comb floor is about −22 dB, not the Color-0
-−37 dB null, and the −60 dB tail is about 0.1 s, not Color max's ring.
-Color 0→0.9 peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on noise
-(held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to
-−60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one
-(1.91 s and 2.13 s, 20 ms RMS windows after a 50 ms burst at −6 dBFS,
-48 kHz, audiodsp v0.6.2); the default does not, and a click is not that
-bar. *Restated 2026-09-28:* this read "the 2 s ring on a 200–440 Hz
-burst" until audiodsp v0.6.2, where the node stopped holding a few LSB
-going round for ever (audiodsp#154) and the ring at 440 Hz fell from
-2.13 s to 1.91 s. The sound is unchanged; the claim now says what the
-node does.
-
-**Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`).
-`audioroute` is only built when Through Zero is on. On a stock
-CircuitPython board this module imports and construction raises
-`ImportError`.
-
-**Cost.** Reference patch 0 is one `audioecho.FeedbackDelay` with a
-256-point `wow_shape` (inside the palette +options row; this class does
-not add a looping `RawSample` carrier). Palette +options 0.434 / 0.752 ms
-plus glue 0.0 ms: **P4 ≤ 9 %, S3 ≤ 15 %** of a 5.333 ms stereo block.
-Measured at patch 0: 0.681 ms/block on the P4 (rt 7.84) and 1.073 ms on
-the S3 (rt 4.97). Lean patch: no.
-
-**Latency is zero at defaults.** Through Zero is the only latency-adding
-option; it defaults off.
-
-`capabilities = ("tempo_sync",)`: this class always reads
-`self._transport()`.
-"""
-
-VENDOR = "PyDevices"
-
-from array import array
-import math
-
-try:
- import audioecho
-except ImportError: # pragma: no cover - a stock board
- audioecho = None
-try:
- import audioroute
-except ImportError:
- audioroute = None
-try:
- import audiomixer
-except ImportError:
- audiomixer = None
-
-from .. import _component
-
-
-#: Line length. F5's 10 ms top plus Through Zero's dry copy of the same,
-#: plus the node's two-frame clamp.
-MAX_DELAY_MS = 20.0
-SHORT_MS = 2.5
-LONG_MS = 10.0
-BBD_N = 1024
-SHAPE_LEN = 256
-# One cut cannot do Color-0 exact-zero and Color-max ≥2 s together:
-# 20 Hz yields Color-max audio t60 1.63 s; 1 Hz yields 2.02 s at 440 Hz
-# but a 200 Hz burst was 1.96 s (indep3). Color-max cut is 0.4 Hz so
-# the 2 s bar has margin across those bursts. Color 0 stays 20 Hz.
-# Same one-pole, not a second node. (Those figures are from before
-# audiodsp v0.6.2. There, at 0.4 Hz, the ring reads 1.91 s at 440 Hz and
-# 2.13 s at 200 Hz, and F8 is restated to those values, 2026-09-28.)
-CUT_HZ = 20.0
-CUT_HZ_COLOR_MAX = 0.4
-# Color 0.9 as raw feedback misses ≥15 dB at 44.1 / 22.05 kHz on some
-# noise seeds (14.32–14.83 dB). A 0.03 lift at the 0.9 stop (0.90→0.93)
-# is the same regeneration law, hotter by a trim — not a second node.
-COLOR_EXPAND_AT_09 = 0.03
-# Color 0 still has the 20 Hz pole: last non-zero 2356 frames / 48 kHz
-# (0.049 s) after a burst. Declared tail at Color 0 must cover that.
-COLOR0_SETTLE_S = 0.08
-DELAY_SLEW = 1.0
-MIX_UNITY_SNAP = 0.04
-DIVISIONS = (4.0, 2.0, 1.0, 0.5, 1.0 / 3.0, 0.25, 0.125)
-TONE_MAX_HZ = 16000.0
-
-
-def _hyperbola_table(duty, length=SHAPE_LEN, ratio=4.0):
- """One period of delay wow in Q15: a linear clock ramp of `duty` is a
- hyperbola in milliseconds, mapped onto [-1, 1]."""
- duty = min(0.75, max(0.25, float(duty)))
- samples = []
- centre = (1.0 + ratio) / 2.0
- depth = (ratio - 1.0) / 2.0
- for index in range(length):
- phase = index / float(length)
- if phase < duty:
- clock = phase / duty
- else:
- clock = 1.0 - (phase - duty) / (1.0 - duty)
- delay = 1.0 / (1.0 / ratio + clock * (1.0 - 1.0 / ratio))
- wow = (delay - centre) / depth
- samples.append(int(max(-32767, min(32767, round(wow * 32767.0)))))
- return array("h", samples)
-
-
-def _loop_cut_hz(color):
- """20 Hz at Color 0 (Tier 1 exact zero); 0.4 Hz at Color 0.99 (F8)."""
- travel = min(1.0, max(0.0, float(color) / 0.99))
- return CUT_HZ + travel * (CUT_HZ_COLOR_MAX - CUT_HZ)
-
-
-def _color_to_feedback(color, ceiling=0.99, law="linear"):
- """Panel Color → node feedback. 0 stays 0; 0.9 is lifted so F3's
- +15 dB four-stop rise holds at 48 / 44.1 / 22.05 kHz."""
- value = min(ceiling, max(0.0, float(color)))
- if law == "squared":
- value = value * value
- if value > 0.0:
- value = min(ceiling, value + COLOR_EXPAND_AT_09 * (value / 0.9))
- return value
-
-
-def _linear_ms_table(duty, length=SHAPE_LEN):
- """F1's null: a triangle in milliseconds, not in clock."""
- duty = min(0.75, max(0.25, float(duty)))
- samples = []
- for index in range(length):
- phase = index / float(length)
- if phase < duty:
- wow = 2.0 * (phase / duty) - 1.0
- else:
- wow = 1.0 - 2.0 * (phase - duty) / (1.0 - duty)
- samples.append(int(max(-32767, min(32767, round(wow * 32767.0)))))
- return array("h", samples)
-
-
-class Flanger(_component.Component):
- """Electric Mistress-informed flanger: one `FeedbackDelay`, audiodsp
- tier, zero latency until Through Zero (10 ms at Range max / 48 kHz) is
- turned on.
-
- What the default gives up. At the constructor default (Color 0.55,
- Filter Matrix off) the comb floor is about −22 dB, not the Color-0
- −37 dB null, and the −60 dB tail is about 0.1 s, not Color max's
- ring. Color 0→0.9 peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on
- noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings
- to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a
- 200 Hz one (restated 2026-09-28 at audiodsp v0.6.2); the default does
- not, and a click is not that bar.
- """
-
- NAME = 'Flanger'
- DISPLAY_NAME = 'Flanger'
- CATEGORIES = ('Modulation',)
- VERSION = '0.0.2'
-
- TIER = _component.AUDIODSP
- REQUIRES = ("audioecho", "audioroute")
-
- CAPABILITIES = ("tempo_sync",)
- LATENCY_SAMPLES = 0
- TAIL_SAMPLES = None
-
- MACRO_LABELS = (
- "Rate", "Range", "Color", "Manual", "Filter Matrix", "Mix",
- "Shape", "Tone", "Spread", "Sync", "Division", "Through Zero",
- )
- MACRO_MODES = {
- 0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", 3: "UNIPOLAR",
- 4: "TOGGLE", 5: "UNIPOLAR", 6: "UNIPOLAR", 7: "UNIPOLAR",
- 8: "UNIPOLAR", 9: "TOGGLE", 10: "UNIPOLAR", 11: "TOGGLE",
- }
- _MACRO_RANGES = (
- (0.02, 8.0, "log"), # 0 Rate, Hz
- (0.0, 1.0), # 1 Range
- (0.0, 0.99), # 2 Color
- (0.4, 12.0, "log"), # 3 Manual, ms
- (0.0, 1.0), # 4 Filter Matrix
- (0.0, 2.0), # 5 Mix; 1.0 equal weight, 2.0 wet only
- (0.25, 0.75), # 6 Shape, ramp duty
- (2000.0, 16000.0, "log"), # 7 Tone, Hz
- (0.0, 1.0), # 8 Spread
- (0.0, 1.0), # 9 Sync
- (0.0, 6.0), # 10 Division index
- (0.0, 1.0), # 11 Through Zero
- )
-
- #: Values are `macro_of` of the engineering settings in the comment.
- PATCHES = {
- 0: ("Slow jet", (54, 108, 71, 75, 0, 64, 64, 127, 0, 0, 42, 0)),
- 1: ("Fast metallic", (98, 89, 90, 60, 0, 64, 64, 85, 0, 0, 42, 0)),
- 2: ("Held comb", (54, 64, 71, 75, 127, 64, 64, 127, 0, 0, 42, 0)),
- 3: ("Wide sweep with resonance",
- (68, 127, 115, 75, 0, 64, 64, 67, 0, 0, 42, 0)),
- 4: ("Shallow and short", (83, 19, 19, 34, 0, 64, 64, 127, 0, 0, 42, 0)),
- 5: ("Deep and slow, no resonance",
- (29, 127, 0, 94, 0, 64, 64, 127, 0, 0, 42, 0)),
- 6: ("Synced one bar", (54, 108, 71, 75, 0, 64, 64, 127, 0, 127, 42, 0)),
- 7: ("Through zero, dry delayed",
- (54, 108, 38, 75, 0, 64, 64, 127, 0, 0, 42, 127)),
- }
-
- #: F1's law. Planted faults flip this without a macro that can.
- DELAY_LAW = "reciprocal"
- COLOR_LAW = "linear"
- DAMPING_HONOURED = True
- MATRIX_HONOURED = True
- RANGE_SCALE = 1.0
- COLOR_CEILING = 0.99
- DELAY_SLEW = DELAY_SLEW
-
- def _build(self, rate_hz=0.25, range_=0.85, color=0.55, manual_ms=3.0,
- filter_matrix=0.0, mix=1.0, shape=0.5, tone_hz=TONE_MAX_HZ,
- spread=0.0, sync=0.0, division=2.0, through_zero=0.0,
- patch=None):
- """`through_zero` defaults off (0 ms). On, Range max is 10 ms
- (480 samples at 48 kHz)."""
- self._latency = 0
- self._shape_table = _hyperbola_table(shape)
- self._wet = self._own(audioecho.FeedbackDelay(
- sample_rate=self._sample_rate,
- channel_count=self._channel_count,
- max_delay_ms=MAX_DELAY_MS,
- delay_ms=manual_ms, feedback=0.0, mix=0.0,
- cut_hz=CUT_HZ, delay_slew=type(self).DELAY_SLEW,
- wow_shape=self._shape_table),
- reset=lambda: self._wet.clear())
- self._wet.play(self._source)
-
- # Dry delay + mixer exist only for Through Zero. Attaching the mixer
- # to `_wet` at construction makes a pull of `_wet` come back at half
- # scale (the mixer and the live output sharing one node). So they
- # stay dark until the toggle is actually on.
- self._dry = None
- self._mixer = None
- self._output = self._wet
- self._tz = False
-
- self._init_macros(
- (rate_hz, range_, color, manual_ms, filter_matrix, mix, shape,
- tone_hz, spread, sync, division, through_zero), patch)
-
- @property
- def latency_samples(self):
- self._check_live()
- return self._latency
-
- @property
- def tail_samples(self):
- self._check_live()
- mix = self._value(5)
- if mix <= MIX_UNITY_SNAP:
- return 0
- delay_ms = self._held_ms()
- color = _color_to_feedback(
- self._value(2), type(self).COLOR_CEILING, type(self).COLOR_LAW)
- frames = int(delay_ms * self._sample_rate / 1000.0) + 2
- if color <= 0.0:
- return max(frames, int(COLOR0_SETTLE_S * self._sample_rate))
- tau = delay_ms / 1000.0
- t60 = tau * 60.0 / (-20.0 * math.log10(max(color, 1e-6)))
- return int(t60 * self._sample_rate) + frames
-
- def _value(self, index):
- return _component.macro_value(self._MACRO_RANGES[index],
- self._macros[index])
-
- def _transport_tuple(self):
- raw = self._transport
- if callable(raw):
- raw = raw()
- return raw
-
- def _lfo_hz(self):
- """Always reads the transport so `tempo_sync` is honest."""
- playing, beat, bpm, num, den = self._transport_tuple()[:5]
- del playing, beat, den
- if self._value(9) >= 0.5:
- index = int(round(self._value(10)))
- index = min(len(DIVISIONS) - 1, max(0, index))
- bar_s = (60.0 / max(1.0, float(bpm))) * max(1.0, float(num))
- period = DIVISIONS[index] * bar_s
- if period <= 0.0:
- return self._value(0)
- return 1.0 / period
- return self._value(0)
-
- def _span_ms(self):
- depth = min(1.0, max(0.0, self._value(1))) * type(self).RANGE_SCALE
- t_long = SHORT_MS + (LONG_MS - SHORT_MS) * max(depth, 1e-6)
- t_short = t_long / (1.0 + 3.0 * depth) if depth > 0.0 else t_long
- return t_short, t_long
-
- def _held_ms(self):
- if (self._value(4) >= 0.5) and type(self).MATRIX_HONOURED:
- return self._value(3)
- t_short, t_long = self._span_ms()
- return 0.5 * (t_short + t_long)
-
- def _bake_shape(self):
- duty = self._value(6)
- if type(self).DELAY_LAW == "linear":
- self._shape_table = _linear_ms_table(duty)
- else:
- self._shape_table = _hyperbola_table(duty)
-
- def _ensure_tz_graph(self):
- if self._dry is not None:
- return
- split = self._own(audioroute.Splitter(self._source, taps=2),
- reset=False, deinit=False)
- self._dry = self._own(audioecho.FeedbackDelay(
- sample_rate=self._sample_rate,
- channel_count=self._channel_count,
- max_delay_ms=MAX_DELAY_MS,
- delay_ms=LONG_MS, feedback=0.0, mix=2.0, delay_slew=0.0),
- reset=lambda: self._dry.clear())
- self._dry.play(split.tap(0))
- self._wet.play(split.tap(1))
- self._mixer = self._own(
- audiomixer.Mixer(voice_count=2, **self._pcm(1024)), reset=False)
- self._mixer.voice[0].play(self._dry)
- self._mixer.voice[1].play(self._wet)
-
- def _route_tz(self, on):
- on = bool(on)
- t_short, t_long = self._span_ms()
- del t_short
- if on:
- self._ensure_tz_graph()
- self._latency = int(round(t_long * self._sample_rate / 1000.0))
- self._dry.set(delay_ms=t_long, mix=2.0, feedback=0.0)
- self._output = self._mixer
- else:
- self._latency = 0
- self._output = self._wet
- self._tz = on
-
- def _refresh(self):
- self._bake_shape()
- t_short, t_long = self._span_ms()
- matrix = (self._value(4) >= 0.5) and type(self).MATRIX_HONOURED
- mix = self._value(5)
- if abs(mix - 1.0) <= MIX_UNITY_SNAP:
- mix = 1.0
- color = _color_to_feedback(
- self._value(2), type(self).COLOR_CEILING, type(self).COLOR_LAW)
- tone = self._hz(self._value(7))
- held = self._value(3) if matrix else 0.5 * (t_short + t_long)
- depth = 0.0 if matrix else 0.5 * (t_long - t_short)
- clock_hz = BBD_N / (2.0 * max(held, 0.1) * 0.001)
- damping = max(1.0, clock_hz / 3.0)
- if tone < TONE_MAX_HZ * 0.98:
- damping = min(damping, tone)
- wow_hz = 0.0 if matrix else self._lfo_hz()
- tz = self._value(11) >= 0.5
- wet_mix = 2.0 if tz else mix
- self._wet.set(
- delay_ms=held,
- wow_depth_ms=depth,
- wow_hz=wow_hz,
- wow_shape=self._shape_table,
- feedback=color,
- mix=wet_mix,
- damping_hz=damping,
- cut_hz=_loop_cut_hz(color),
- cross_feed=self._value(8),
- delay_slew=type(self).DELAY_SLEW)
- self._route_tz(tz)
- if tz and self._mixer is not None:
- dry_gain = 0.0 if mix >= 2.0 - MIX_UNITY_SNAP else (
- 1.0 if mix <= 1.0 else 2.0 - mix)
- wet_gain = 0.0 if mix <= MIX_UNITY_SNAP else (
- mix if mix <= 1.0 else 1.0)
- self._mixer.voice[0].level = dry_gain
- self._mixer.voice[1].level = wet_gain
-
- def _apply_macro(self, index, position):
- del index, position
- self._refresh()
diff --git a/lib/audioeffects/rebuilt/harmonizer.py b/lib/audioeffects/rebuilt/harmonizer.py
index 55cdd2d..a1c1c48 100644
--- a/lib/audioeffects/rebuilt/harmonizer.py
+++ b/lib/audioeffects/rebuilt/harmonizer.py
@@ -122,7 +122,10 @@ class Harmonizer(_component.Component):
Detune is a steady offset, not a chorus: a voice holds its pitch.
Each voice glitches as it splices, more often the further it is
shifted and the shorter the window: at the default window a fifth up
- splices every 43 ms, at any sample rate.
+ splices every 43 ms, at any sample rate, where the first channel is
+ silent.
+ Where it is not, each splice moves by up to a fiftieth of a second to
+ land in phase, so the splices come sooner.
A voice at exact unison never splices: it is a fixed short delay, and
it combs with the dry.
A voice you turn back on starts from silence, not with a scrap of what
diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py
index 1a1bfce..bb30854 100644
--- a/lib/audioeffects/rebuilt/multitapdelay.py
+++ b/lib/audioeffects/rebuilt/multitapdelay.py
@@ -691,31 +691,22 @@ def _route(self):
the source, and a second or third `_resync` before the next pull
leaves exactly what the first did.
- **Every wiring is quiet** where `audiocore.get_buffer` exists
- (`_wire`): the Splitter reads `_hush` while the voices prime, and
- one pull of the Mixer hands the primed zeros out, so the graph is
- left as it is between two pulls mid-stream and nothing is taken
- from the source. Up to re-audit fix round 1 a wiring outside
- `reset()` primed the dry voice with the source's first block, and
- a `_resync` before the first pull then dropped that block's heads
- (tap 1 held it, not yet heard) and, in mono on a native build,
- refilled the tap's own buffer the voice was pointing into, so the
- dry's first 256 samples played as zeros. Inside `reset()` the
- wiring is quiet on every build, because a reset must take nothing
- from the source.
-
- Where `get_buffer` is left out, a wiring outside `reset()` primes
- the source's first block into the dry voice, and nothing here can
- pull it out. The first pull plays it on time. A Mix-0 route before
- that pull hands out the adapter, which has already given that block
- away, so the run plays the source one block early without it; the
- voice keeps the block, and plays it at the return (or the reset)
- with tap 1's copy going into the lines, since `_resync` cannot swap
- it, so its heads follow it. A wiring inside `reset()` opens the
- output with the one silent block it primed, and a reset or a return
- from Mix 0 lets tap 1's pending block into the lines, its heads
- late by any Mix-0 run between. The module docstring says all of
- this in a host's terms.
+ **Every wiring is quiet** (`_wire`): the Splitter reads `_hush`
+ while the voices prime, and one pull of the Mixer hands the primed
+ zeros out, so the graph is left as it is between two pulls
+ mid-stream and nothing is taken from the source. Up to re-audit fix
+ round 1 a wiring outside `reset()` primed the dry voice with the
+ source's first block, and a `_resync` before the first pull then
+ dropped that block's heads (tap 1 held it, not yet heard) and, in
+ mono on a native build, refilled the tap's own buffer the voice was
+ pointing into, so the dry's first 256 samples played as zeros.
+
+ Where `audiocore.get_buffer` is left out (CircuitPython's default
+ board builds), every pull here goes through `_pull_node`: a scratch
+ mixer voice primes one block through a width-1 MidSide standing on
+ the node, which moves the node exactly as `get_buffer` would. So
+ the wiring is quiet and `_resync` swaps tap 1's block on every
+ build, and a board renders what a desktop does, byte for byte.
"""
if not self._ready:
return
@@ -764,11 +755,9 @@ def _bypass(self):
return self._through
def _quiet_wiring(self):
- """Whether `_wire` primes the voices from `_hush`: wherever
- `audiocore.get_buffer` can hand the primed zeros out, and always
- inside `reset()`, which must take nothing from the source."""
- return self._resetting or \
- getattr(audiocore, "get_buffer", None) is not None
+ """Whether `_wire` primes the voices from `_hush`: always, since
+ `_pull_node` hands the primed zeros out on every build."""
+ return True
def _wire(self, quiet=False):
"""Play every node once, the first time Mix is above 0 (`_route`).
@@ -782,7 +771,6 @@ def _wire(self, quiet=False):
block would point into a mono `SplitterTap`'s own buffer, which
`_resync` refills, and tap 1 would hold audio whose dry had not
played yet, which `_resync` drops."""
- pull = getattr(audiocore, "get_buffer", None)
self._fd.play(self._tap1)
self._feed.play(self._hush)
self._tapnode.play(self._feed, loop=False)
@@ -796,8 +784,7 @@ def _wire(self, quiet=False):
self._plugged = self._lean
if quiet:
self._in.play(self._adapter)
- if pull is not None:
- pull(self._mixer)
+ self._pull_node(self._mixer)
def _resync(self):
"""Empty both lines and swap the block tap 1 holds for the tap node
@@ -815,16 +802,14 @@ def _resync(self):
holding a block, so right after construction this is that state
too: the dry tap's pull refills no buffer a voice still points
into, and what tap 1 drops was never audio."""
- pull = getattr(audiocore, "get_buffer", None)
self._fd.clear()
audiocore.reset_buffer(self._tapnode)
- if pull is not None:
- self._in.play(self._hush)
- try:
- pull(self._tap1)
- pull(self._dry)
- finally:
- self._in.play(self._adapter)
+ self._in.play(self._hush)
+ try:
+ self._pull_node(self._tap1)
+ self._pull_node(self._dry)
+ finally:
+ self._in.play(self._adapter)
self._feed.play(self._target(self._lean))
self._plugged = self._lean
diff --git a/lib/audioeffects/rebuilt/octaver.py b/lib/audioeffects/rebuilt/octaver.py
index c378ea1..bc2898c 100644
--- a/lib/audioeffects/rebuilt/octaver.py
+++ b/lib/audioeffects/rebuilt/octaver.py
@@ -529,6 +529,13 @@ def _arm(self):
# does, and a move before the first pull lands at once.
if self._allpass is not None:
audiocore.reset_buffer(self._allpass)
+ # The same for the clock's and the Tone legs' biquads, which also
+ # heard only zeros. Since audiodsp v0.6.6rc9 a biquad that has run a
+ # block walks a corner move over 20 ms (#162); cleared, it takes a
+ # Tone or Tracking set before the first pull as it stands, as a
+ # fresh build does (audiocomponents#164).
+ for node in self._clock + [self._tone1, self._tone1b, self._tone2]:
+ audiocore.reset_buffer(node)
def _pull(self):
"""Pull one block from the mixer and drop it.
diff --git a/lib/audioeffects/rebuilt/pitchshifter.py b/lib/audioeffects/rebuilt/pitchshifter.py
index 44a26af..3478e0b 100644
--- a/lib/audioeffects/rebuilt/pitchshifter.py
+++ b/lib/audioeffects/rebuilt/pitchshifter.py
@@ -137,9 +137,9 @@ class PitchShifter(_component.Component):
sample, with the shifted voice added on top, so a hot input can reach
the rail.
Feedback stacks level too, far past the input: at Feedback 127, Delay 0
- and Mix 2 a noise burst peaking at -10.3 dBFS reaches the rail at unison
- or an octave up, and comes within 4 dB of it at an octave down or a
- fourth or fifth either way.
+ and Mix 2 a noise burst peaking at -10.3 dBFS reaches the rail at
+ unison, and comes within 6 dB of it at an octave or a fourth or fifth
+ either way.
**Mono, latency, tail.** A one-channel source gets a one-channel
shifter with the same window.
diff --git a/lib/audioeffects/rebuilt/rack.py b/lib/audioeffects/rebuilt/rack.py
index a3fb892..25cc721 100644
--- a/lib/audioeffects/rebuilt/rack.py
+++ b/lib/audioeffects/rebuilt/rack.py
@@ -208,7 +208,7 @@ def deinit(self):
def _record(child):
"""What puts `child` back as it stands: its `_record()`, or, for a
- child on the older `_core` base, its positions and patch index."""
+ child on another base, its positions and patch index."""
if isinstance(child, _component.Component):
return child._record()
return (list(child._macros), child._patch_index)
diff --git a/lib/audioeffects/rebuilt/shimmerhall.py b/lib/audioeffects/rebuilt/shimmerhall.py
index e858b5c..b9c89b9 100644
--- a/lib/audioeffects/rebuilt/shimmerhall.py
+++ b/lib/audioeffects/rebuilt/shimmerhall.py
@@ -50,12 +50,13 @@
old level.
Interval is a fixed shift in semitones, with no key awareness. Both
-shifters splice, so the shifted copy has a slight flutter. In Rise, on some
-held notes the shimmer lands beside the octave rather than on it: a held G3
-at +12 is one. With Damping high and Rise near the top, or at the widest
-intervals, the highest octaves fold back down as tones outside the series;
-keep Damping lower for a clean climb. A mono source gets a mono rack. It
-needs a board with PSRAM.
+shifters splice, so the shifted copy has a slight flutter. In Rise, a held
+note's shimmer lands on the octave, a held G3 at +12 included. With Damping
+high and Rise near the top, or at the widest intervals, the climb fades out
+at the top of the band at 48 kHz; at 22.05 kHz a tone outside the series
+can still fold back, 25 to 40 dB under the first pass. Keep Damping lower
+for a clean climb. A mono source gets a mono rack. It needs a board with
+PSRAM.
When your source ends, the tail rings out as it would on silence. On a
CircuitPython board, stop the rack from the host if your source can run
@@ -283,7 +284,8 @@ class ShimmerHall(_component.Component):
1: ("Short Stagger", (0, 76, 85, 38, 96, 77, 38, 102, 25)),
2: ("Fifth Halo", (0, 102, 85, 103, 64, 108, 89, 84, 38)),
# Damping step 86 (about 5.2 kHz), where the card had 102 (8 kHz):
- # at 8 kHz the sixth pass folded back past -30 dB at 22.05 kHz.
+ # at 8 kHz the sixth pass folded back past -30 dB at 22.05 kHz,
+ # before the loop shifter's band limit (audiodsp v0.6.6).
3: ("Slow Rise", (127, 89, 85, 103, 96, 108, 89, 86, 38)),
4: ("Rise Damped", (127, 102, 113, 87, 96, 94, 64, 51, 32)),
5: ("Falling Octaves", (127, 89, 85, 93, 0, 102, 64, 76, 32)),
diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py
index c479ef8..ee55009 100644
--- a/lib/audioeffects/rebuilt/slapbackdelay.py
+++ b/lib/audioeffects/rebuilt/slapbackdelay.py
@@ -59,7 +59,8 @@
TIME_I, LEVEL_I, SATURATION_I, TONE_I, WOW_I, REPEATS_I = range(6)
-#: The constructor defaults, which NaN falls back to.
+#: The constructor defaults, which NaN falls back to: patch 0's steps,
+#: rounded. An option left out plays the step itself.
DEFAULTS = (135.0, 0.35, 0.15, TONE_MAX_HZ, 1.0, 0.0)
@@ -102,6 +103,15 @@ def _option(value, default):
return value
+def _unset(value):
+ """True for an option left out (None) or NaN, which takes patch 0's
+ step."""
+ if value is None:
+ return True
+ value = float(value)
+ return value != value
+
+
def _between(value, low, high):
if value < low:
return low
@@ -116,7 +126,7 @@ class SlapbackDelay(_component.Component):
Your dry signal passes untouched, and one copy of it comes back a moment
later, a little quieter: the two-machine tape echo on Sam Phillips' Sun
sides.
- By default the repeat comes 135 ms after the dry, once.
+ By default the repeat comes 134.4 ms after the dry, once.
**The controls.** Time is the gap between the dry and the repeat. Level
is the console's return. Saturation is how hard the return drove the
@@ -210,8 +220,23 @@ class SlapbackDelay(_component.Component):
5: ("Dark Slap", (84, 22, 19, 51, 36, 0)),
}
- def _build(self, time_ms=135.0, level=0.35, saturation=0.15,
- tone_hz=TONE_MAX_HZ, wow_cents=1.0, repeats=0.0, patch=None):
+ #: The number each option left out stands for (patch 0's step,
+ #: rounded), for a reader of the signature.
+ _OPTION_DEFAULTS = dict(zip(("time_ms", "level", "saturation",
+ "tone_hz", "wow_cents", "repeats"),
+ DEFAULTS))
+
+ def _build(self, time_ms=None, level=None, saturation=None,
+ tone_hz=None, wow_cents=None, repeats=None, patch=None):
+ # An option left out (or NaN) plays patch 0's step, so a fresh
+ # instance is patch 0 exactly and a reset lands where it starts
+ # (`_init_macros`); `DEFAULTS` is that step rounded to a readable
+ # number. A value you pass plays as passed.
+ raw = (time_ms, level, saturation, tone_hz, wow_cents, repeats)
+ seeded = tuple(_unset(value) for value in raw)
+ time_ms, level, saturation, tone_hz, wow_cents, repeats = (
+ DEFAULTS[index] if seeded[index] else raw[index]
+ for index in range(len(raw)))
self._frames = 1
#: The longest delay, in whole frames, the read head may still sit
#: at. The walk starts from wherever the head is and the class cannot
@@ -267,10 +292,12 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15,
self._own(self._delay, reset=self._clear)
self._delay.play(self._source)
self._output = self._delay
- self._time_exact = time_ms
+ # A Time left out is patch 0's step, grid 84 (134.4 ms); only a
+ # Time you pass is kept exact.
+ self._time_exact = None if seeded[TIME_I] else time_ms
self._deferred = True
try:
- self._init_macros(values)
+ self._init_macros(values, defaults=seeded)
finally:
self._deferred = False
self._seeding = False
diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py
index 6e2aa62..ce8706d 100644
--- a/lib/audioeffects/rebuilt/tapedelay.py
+++ b/lib/audioeffects/rebuilt/tapedelay.py
@@ -29,9 +29,10 @@
off the ideal bend, because the node walks its read head in single
precision.
-**The tape.** The wobble is periodic, not random. A Wow or Flutter move that
-changes only how deep the wobble is glides in, and one that changes their
-balance steps, so set the balance before you play. The darkening follows
+**The tape.** The wobble is periodic, not random. A Wow or Flutter move
+glides in, whether it changes how deep the wobble is or the balance between
+them: a new balance takes the wobble out and brings it back in the new
+shape. The darkening follows
the tape's loss law only up to a band top that rises with the tape's speed.
Record Level has no memory: tape hysteresis is not modelled. The RE-201's
Bass and Treble are not here.
diff --git a/lib/audioeffects/reverb.py b/lib/audioeffects/reverb.py
deleted file mode 100644
index f63dc1b..0000000
--- a/lib/audioeffects/reverb.py
+++ /dev/null
@@ -1,187 +0,0 @@
-"""Reverb: an algorithmic one over the engine's Freeverb, and a convolution
-one over `audioconvolve`.
-
-Reach for `Reverb` first. Freeverb is a fixed handful of delay lines and it
-costs the same on a Cortex-M0 as on a workstation. `ConvolutionReverb` applies
-a real impulse response, which is the only way to get a *particular* room
-rather than a plausible one -- and it costs, in both directions, roughly two
-orders of magnitude more. See its docstring before using it on a board.
-"""
-
-VENDOR = "PyDevices"
-
-import audiodelays
-import audiofreeverb
-
-from . import _core
-
-try:
- import audioconvolve
-except ImportError: # a stock CircuitPython board, or an old audiodsp
- audioconvolve = None
-
-_PRESETS = {
- # roomsize damp
- "room": (0.55, 0.6),
- "chamber": (0.72, 0.5),
- "hall": (0.88, 0.35),
- "plate": (0.80, 0.15),
- "spring": (0.62, 0.25),
-}
-
-
-class Reverb(_core.Effect):
-
- NAME = 'Reverb'
- DISPLAY_NAME = 'Reverb'
- CATEGORIES = ('Reverb',)
- VERSION = '0.0.1'
- MACRO_LABELS = ()
- MACRO_MODES = {}
- PATCHES = {0: ("Default", ())}
- def __init__(self, source, preset="hall", mix=0.3):
- roomsize, damp = _PRESETS[preset]
- chain_source = source
- if preset == "spring":
- # the boingy pre-flutter of a spring tank
- self.flutter = audiodelays.Echo(
- max_delay_ms=60, delay_ms=33, decay=0.45, mix=0.5,
- freq_shift=True, **_core.pcm())
- self.flutter.play(source)
- chain_source = self.flutter
- self.node = audiofreeverb.Freeverb(
- roomsize=roomsize, damp=damp, mix=mix, **_core.pcm())
- self.node.play(chain_source)
- self._output = self.node
-
- def set_mix(self, mix):
- self.node.mix = mix
-
-
-class ConvolutionReverb(_core.Effect):
- """A reverb built by convolving with an impulse response.
-
- With `impulse` given -- int16 frames, interleaved if `impulse_channels`
- is 2 -- this is the room that impulse was measured in. With no impulse it
- synthesizes decaying noise, which is a serviceable hall and, more to the
- point, is what makes the class usable with nothing to load::
-
- verb = ConvolutionReverb(source, seconds=1.2, stereo=True)
- verb.set_macro(0, 100) # Decay, MIDI scale
- verb.set_macro(4, 40) # Mix
-
- **`seconds` is an allocation, not a setting.** It fixes how long an
- impulse this instance can ever hold, because the storage is carved once
- and the audio path may already be pulling. The Decay macro then works
- *within* that allocation, which is why it is a proportion (0.05..1.0)
- rather than a time: on a fixed length, size is a fraction. `.seconds`
- reports the allocation and `.decay_seconds` what the knob currently means.
-
- **What it costs.** Every partition is 256 taps, and both the arithmetic
- and the memory grow linearly with the count. One second at 48 kHz is 188
- partitions: about 1.2 MB, and on the order of 150 MFLOPS to run in real
- time. That is a desktop or a render, not a microcontroller. The MCU-scale
- use of convolution on this palette is a *short* impulse -- see
- `cabinetsim.CabinetSim`'s `impulse=` option, which convolves a cabinet
- IR a partition at a time. Its own default is a designed biquad cascade
- and convolves nothing at all.
-
- The output trails the input by `audioconvolve.FRAMES` (5.3 ms at 48 kHz);
- see the audioconvolve docstring for why that is inherent here.
- """
-
- NAME = 'ConvolutionReverb'
- DISPLAY_NAME = 'Convolution Reverb'
- CATEGORIES = ('Reverb',)
- VERSION = '0.0.1'
-
- MACRO_LABELS = ("Decay", "Damping", "Predelay", "Diffusion", "Mix")
- MACRO_MODES = {
- 0: "UNIPOLAR",
- 1: "UNIPOLAR",
- 2: "UNIPOLAR",
- 3: "UNIPOLAR",
- 4: "UNIPOLAR",
- }
- _MACRO_RANGES = (
- (0.05, 1.0), # fraction of `seconds`
- (500.0, 16000.0, "log"),
- (0.0, 120.0),
- (0.0, 60.0),
- (0.0, 1.0),
- )
- PATCHES = {
- 0: ("Natural Room", (127, 91, 0, 0, 38)),
- 1: ("Small Room", (26, 76, 4, 6, 45)),
- 2: ("Concert Hall", (127, 96, 30, 25, 51)),
- 3: ("Dark Chamber", (79, 51, 13, 19, 45)),
- 4: ("Bright Plate", (98, 118, 0, 4, 57)),
- 5: ("Cathedral", (127, 83, 51, 38, 64)),
- 6: ("Gated Slap", (18, 108, 21, 0, 76)),
- }
-
- def __init__(self, source, seconds=1.0, stereo=True, decay=1.0,
- damping_hz=6000.0, predelay_ms=0.0, diffusion_ms=0.0,
- seed=1, mix=0.3, impulse=None, impulse_channels=1,
- gain=1.0, patch=None):
- if audioconvolve is None:
- raise ImportError(
- "ConvolutionReverb needs the audioconvolve module, which a "
- "stock CircuitPython board does not have")
- rate = _core.sample_rate()
- self.seconds = float(seconds)
- self._seed = int(seed)
- self._taps = max(int(self.seconds * rate), audioconvolve.FRAMES)
- self.node = audioconvolve.Convolver(
- max_taps=self._taps, ir_channels=2 if stereo else 1,
- sample_rate=rate, channel_count=_core.channel_count())
- self.node.play(source)
- self._output = self.node
-
- # A loaded impulse is the room; the macros that would shape a
- # synthetic one stop applying, and saying so is better than letting a
- # Decay knob look connected while it does nothing.
- self.measured = impulse is not None
- if self.measured:
- self.node.load(impulse, impulse_channels, gain)
-
- # One synthesis, not one per knob: _init_macros walks every macro in
- # turn, and rebuilding a second of impulse five times over would be
- # the most expensive thing this constructor does.
- self._ready = False
- self._init_macros(
- (decay, damping_hz, predelay_ms, diffusion_ms, mix), patch)
- self._ready = True
- self._rebuild()
-
- @property
- def decay_seconds(self):
- """What the Decay knob currently stands for, in seconds."""
- return self.macro(0) * self.seconds
-
- def set_mix(self, mix):
- self.node.set(mix=mix)
-
- def _apply_positions(self, positions, patch_index):
- # Suppress the per-macro rebuild and do one at the end. A patch moves
- # every knob, and four of the five reshape the impulse.
- self._ready = False
- try:
- _core.Effect._apply_positions(self, positions, patch_index)
- finally:
- self._ready = True
- self._rebuild()
-
- def _rebuild(self):
- if not self._ready or self.measured:
- return
- self.node.synthesize(
- decay=self.decay_seconds, damping_hz=self.macro(1),
- predelay_ms=self.macro(2), diffusion_ms=self.macro(3),
- seed=self._seed)
-
- def _apply_macro(self, index, position):
- if index == 4:
- self.node.set(mix=_core.macro_value(self._MACRO_RANGES[4], position))
- else:
- self._rebuild()
diff --git a/lib/audioeffects/ringmod.py b/lib/audioeffects/ringmod.py
index a190d0a..c9bf0ef 100644
--- a/lib/audioeffects/ringmod.py
+++ b/lib/audioeffects/ringmod.py
@@ -270,6 +270,19 @@ def _value(self, index):
return _component.macro_value(self._MACRO_RANGES[index],
self._macros[index])
+ def reset(self):
+ """Clear every node, restore patch 0 and start the carrier again:
+ its notes on the modulating `synthio.Synthesizer` played on through
+ the walk, a block ahead, so patch 0 now builds a fresh one, as a
+ fresh instance does."""
+ self._check_live()
+ old = self._synth
+ self._synth = None
+ self._note_l = None
+ self._note_r = None
+ _component.Component.reset(self)
+ _component.release(old)
+
def _apply_macro(self, index, position):
del position
self._refresh()
diff --git a/lib/audioeffects/saturation.py b/lib/audioeffects/saturation.py
index 8c0a5f8..3f27331 100644
--- a/lib/audioeffects/saturation.py
+++ b/lib/audioeffects/saturation.py
@@ -18,17 +18,28 @@
**Controls moved and moved back play nothing out of silence**
(audiocomponents#113). Mix back up from 0 comes back as the class was
-built - cleared, the pole charged on the bias, the voices re-armed - where
-it used to play what it held, up to 7 927 LSB. Off centre the pole holds
-`curve(bias) * post_gain`, so Bias back to the centre now drains it (it bled
-out as a 29 058 LSB thump), and Drive, Output, Headroom and Hysteresis
-re-charge it as Bias does. Hysteresis back in starts its play operator at
-the centre instead of where it froze (314 LSB). Off centre each charge
-leaves at most 1 LSB, as the constructor's does. And a Bias re-charge no
-longer pulls a block off the dry tap: that put the dry leg 256 frames ahead
-of the wet one after every `program_change`, a comb at patch 7 (Mix 44),
-and left a block of old audio in the Splitter for the next Mix move off 0
-to play (13 071 LSB).
+built - cleared, the voices re-armed - where it used to play what it held,
+up to 7 927 LSB. **Bias is folded into the table**, which answers silence
+with exactly zero at every Bias, so the plate pole never holds an offset:
+off centre (patches 4 and 6) silence in is silence out from the first
+frame, and a Bias, Drive, Output, Headroom or Hysteresis move leaves the
+pole nothing to bleed out. That holds where `audiocore.get_buffer` is
+compiled out (CircuitPython's board builds) as well, because nothing is
+charged by pulling. Hysteresis back in starts its play operator at the
+centre instead of where it froze (314 LSB). And a Bias move pulls no block
+off the dry tap: that once put the dry leg 256 frames ahead of the wet one
+after every `program_change`, a comb at patch 7 (Mix 44), and left a block
+of old audio in the Splitter for the next Mix move off 0 to play
+(13 071 LSB).
+
+**Bias sounds over its whole travel.** On `tube` its ends move the
+operating point **±1.25 V** of grid (`TUBE_BIAS_REACH`), towards cutoff one
+way and grid conduction the other, and stop short of both: at −6 dBFS the
+ends read 5.6 and 7.0 dB under Bias 0 and hold there. Bias used to reach
+±10 V, and past about a quarter of its travel either way the stage was cut
+off or pinned, so the sound died away within a tenth of a second of the
+move (Bias −0.4 rendered digital silence). `tape` and `console` keep the
+whole span, whose ends read 6.8 and 6.9 dB down.
**What the default surrenders.** Mix 1, Drive 0 dB, Headroom 0, Bias 0,
Tilt 0, Hysteresis 0, Speed 15 ips (inert on tube).
@@ -49,9 +60,9 @@
quiet the ratio reads badly whatever the shaper did, one dB for one:
Output −12 / −18 / −24 read **−69.007 / −63.233 / −57.301**, and
`character="console"` reads **−46.094** at Drive 0 because its 5 Hz flux
-pair takes 25 dB out before the jack. Bias past ±0.25 walks the stage to
-cutoff or into grid conduction and the floor goes with it (**−47.491** at
-+0.25, −27.136 at +1); the shipped patches use ±0.1, which reads −64.160.
+pair takes 25 dB out before the jack. Bias holds the bar over its whole
+travel: its ends read **−71.770 / −72.135** at 3700 Hz and Drive 0, and
+−71.521 / −63.075 at maximum Drive.
Over the whole Drive × Headroom plane at 48 kHz the worst cell is
**−63.863** (3700 Hz, maximum Drive, Headroom −3) and every cell clears.
**At 44.1 kHz the 3700 Hz probe cannot reach the bar at any factor**
@@ -160,7 +171,9 @@
note lives, 0.02 dB down at 100 Hz — and it carries the excess. Rendered,
+3 / +6 / +9 / +12 dB of Output now deliver within **0.25 dB** of what
they say, on `tube` and on `console`. It is 0 dB at 63 of the 75 shipped
-positions, whose bytes do not move.
+positions, whose bytes do not move. **Output adds up to +12 dB, so a
+full-scale input can clip at the rails**: with every macro at its top, a
+0 dBFS sine or noise does.
**The tube table does not invert any more.** A triode inverts, and a table
that carried the inversion put the wet leg 180° out of phase with the dry
@@ -172,15 +185,15 @@
`saturation_curve.tube_points()` and no harmonic magnitude moved with it.
Mix now walks −15.011 → −16.515 → −18.301 → −20.469 → −23.125.
-**The plate coupling pole is charged before the first block.** `Bias` is
-an offset into an asymmetric curve, so the shaper answers silence with a
-constant and the pole behind it blocked the constant but not the step:
-shipped patches 4 and 6 put **8 489 and 7 044 LSB** out of digital
-silence. They put **1 LSB** now. A live Bias move is not re-charged — the
-wet leg comes off a `Splitter` — and settles through the pole instead;
-Bias +0.30 still settles to a **−5 LSB** floor and Bias +1.0 to −11, which
-is the class's stated residual and is what `±1 LSB at the input of this
-much gain` looks like at the steepest part of the curve.
+**Silence answers silence at every Bias.** `Bias` is an offset into an
+asymmetric curve. As the node's own offset it made the shaper answer
+silence with a constant, and the pole behind it passed the step that
+started it: shipped patches 4 and 6 put **8 489 and 7 044 LSB** out of
+digital silence, and charging the pole by pulling it left 1 LSB. The class
+now builds the table the shapers read: the curve at `(1 + |Bias|) * u +
+Bias`, less its value at `u = 0`, so silence reads exactly zero and the
+curve's rails sit where they did. It is built in integers on a grid of
+1/256 of a table step, so a board builds the table a desktop builds.
**Tail.** `TAIL_SAMPLES = 65536` (1.365 s at 48 kHz), measured: a 200 ms
0 dBFS burst's residual reaches 0 LSB **3282 frames** after the burst on
@@ -495,13 +508,33 @@ def _f32(value):
#: frames against 0.0672 at 256**, 8.7 % of what the sub-graph costs.
MIXER_BUFFER_BYTES = 1024
-#: How many blocks `_charge_coupling` pulls, and how long the zero sample
-#: it pulls through is. The plate coupling pole is 6.8 Hz, so its charge is
-#: about 0.23 s; 32 blocks of 512 frames is 16 384 and covers it at every
-#: rate. The shaper's native `play()` takes no `loop`, so the sample is a
-#: block long and is re-played each pull.
-CHARGE_BLOCKS = 32
+#: The zero sample the shapers are re-pointed through on a rewire. The
+#: shaper's native `play()` takes no `loop`; one block is all a re-point
+#: needs.
CHARGE_FRAMES = 512
+#: Bias is folded into the table in steps of 1/256 of one table step: a
+#: grid a board's single precision and a desktop's double land on alike
+#: (audiocomponents#75), and 0.00001 of the curve's input span.
+BIAS_STEPS = 256
+#: How far Bias's ends move `tube`'s operating point, in the table's own
+#: input units (x = +-1 is +-10 V of grid): **+-1.25 V**. The 12AX7 in this
+#: netlist is cut off below about -2.5 V and pinned by grid conduction
+#: above about +1.5 V, and Bias used to be the table's whole span, +-10 V,
+#: so past about a quarter of its travel either way the stage had no slope
+#: left: at -6 dBFS Bias -0.4 rendered digital silence and +1 was 37 dB
+#: down (sweep finding 4). At +-1.25 V the ends read 5.6 and 7.0 dB under
+#: Bias 0 and the operating point is still well off centre. A power of two,
+#: so shipped patches 4 and 6 (now Bias -0.75 and +0.762, MIDI 16 and 112,
+#: eight times the 58 and 70 they were) land on the table offset they
+#: always had and their bytes do not move on `tube`. `tape` and `console`
+#: are odd Langevin curves with slope to the table's ends and keep the
+#: whole span: their ends read 6.8 and 6.9 dB under Bias 0. Patches 4 and 6
+#: built on either of those do move, to the eightfold Bias they now name.
+TUBE_BIAS_REACH = 0.125
+#: The macros whose move changes what the shaper answers silence with while
+#: Hysteresis holds the play operator off centre: Drive, Output, Headroom,
+#: Bias and Hysteresis itself (`_let_go_in_silence`).
+HELD_BY_THE_PLAY_OPERATOR = (0, 1, 3, 4, 7)
TILT_HZ = 1000.0
PLATE_HZ = 20.0
# 20 Hz, not the 5 Hz this was while it sat at the grid. The node's kernel
@@ -650,6 +683,9 @@ def shipped_oversample(sample_rate):
class Saturation(_component.Component):
"""A 12AX7 stage, a tape machine, or transformer iron."""
+ #: Construction arms the voices on silence (`Component._play_voice`),
+ #: so a Mix 0 set before the first pull hands out the source whole.
+ LAZY_BUILD = True
NAME = 'Saturation'
DISPLAY_NAME = 'Saturation'
CATEGORIES = ('Distortion',)
@@ -660,7 +696,8 @@ class Saturation(_component.Component):
CAPABILITIES = ()
#: The shipped x4 group delay, 3.3 samples. The instance property is the
- #: one to read: Mix 0 is 0, the lean patch's x2 is 2, `console` adds 2.
+ #: one to read: Mix 0 is 0, and it is the onset
+ #: `CLICK_ONSET_SAMPLES` measures at the running character and factor.
LATENCY_SAMPLES = 3
#: Measured, not inherited. The longest memory in the graph is the 20 Hz
#: plate capacitor - 5 Hz on `console`, which is the slow one. A 200 ms
@@ -688,11 +725,11 @@ class Saturation(_component.Component):
3: ("Tape at seven and a half",
_patch(6.0, 0.0, 1.0, 0.0, 0.0, 7.5, 0.0, 0.0)),
4: ("Under-biased and bright",
- _patch(6.0, 0.0, 1.0, 0.0, -0.1, 15.0, 3.0, 0.0)),
+ _patch(6.0, 0.0, 1.0, 0.0, -0.75, 15.0, 3.0, 0.0)),
5: ("Iron, low end working",
_patch(0.0, 0.0, 1.0, -6.0, 0.0, 15.0, 0.0, 0.0)),
6: ("Offset, even harmonics",
- _patch(6.0, 0.0, 1.0, 0.0, 0.1, 15.0, 0.0, 0.0)),
+ _patch(6.0, 0.0, 1.0, 0.0, 0.762, 15.0, 0.0, 0.0)),
7: ("Mix back",
_patch(6.0, 0.0, 0.35, 0.0, 0.0, 15.0, 0.0, 0.0)),
LEAN_PATCH: ("Saturation - lean",
@@ -722,17 +759,14 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0,
if character == "console" else 0.0)
self._routed_lean = False
self._ready = False
+ #: True inside `reset()`, whose walk must not re-play the voices.
+ self._resetting = False
self._attached = False
self._bias = 0.0
- #: Whether the plate pole was last charged on an offset, so a Bias
- #: move back to the centre drains it rather than leaving it to
- #: bleed out as a thump (audiocomponents#113).
- self._charged = False
#: The Hysteresis the shapers were last handed; see `_apply_macro`.
self._hysteresis = 0.0
- #: The shapers' `post_gain`, and what it was at the last charge.
+ #: The shapers' `post_gain`, as the node is handed it.
self._post = 1.0
- self._charged_post = 1.0
self._wet_head = 1.0
self._group_delay = _group_delay_samples(oversample,
self._extra_delay)
@@ -752,6 +786,11 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0,
blob = IRON_CURVE
curve_h = _q15_array(blob)
self._curve = curve_h
+ #: What the shapers read: the curve with Bias folded in and the
+ #: answer to silence taken out (`_fold_bias`).
+ self._table = array("h", curve_h)
+ self._span = 1.0
+ self._table_gain = 1.0
head = wet_in
self._couple = None
@@ -770,14 +809,11 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0,
self._shaper = self._own(audioshaper.Waveshaper(
sample_rate=rate, channel_count=channels,
- curve=curve_h, oversample=oversample,
+ curve=self._table, oversample=oversample,
pre_gain=1.0, post_gain=1.0, mix=1.0, bias=0.0,
hysteresis=0.0))
- # Wired to the zero sample until `_charge_coupling` has run: the
- # plate's coupling pole is behind the shaper and starts cold, so a
- # Bias off centre banged its whole offset out of the first block -
- # 8 489 LSB at shipped patch 4 (audit 3 (p)6, ruling (n)).
- # Re-pointing it at `head` afterwards is the one `play()` the
+ # Wired to the zero sample until the end of construction.
+ # Re-pointing it at `head` then is the one `play()` the
# caller was always paying for; doing it the other way round reads
# the split's wet tap twice and leaves the dry one a block behind.
self._quiet = self._own(audiocore.RawSample(
@@ -794,7 +830,7 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0,
if self._lean_oversample < oversample:
self._lean = self._own(audioshaper.Waveshaper(
sample_rate=rate, channel_count=channels,
- curve=curve_h, oversample=self._lean_oversample,
+ curve=self._table, oversample=self._lean_oversample,
pre_gain=1.0, post_gain=1.0, mix=1.0, bias=0.0,
hysteresis=0.0))
self._lean.play(self._quiet)
@@ -866,7 +902,6 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0,
(drive_db, output_db, mix, headroom_db, bias, speed_ips,
tilt_db, hysteresis),
patch)
- self._charge_coupling()
for shaper in self._shapers():
shaper.play(self._shaper_head)
_component.open_level_gates(
@@ -876,69 +911,98 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0,
self._refresh_output()
def _charge_coupling(self, rewire=False):
- """Settle the plate coupling pole on the bias before block one.
-
- `Bias` is an offset into an asymmetric curve, so the shaper answers
- a silent input with a constant, and the pole behind it blocks the
- constant but not the step that starts it: shipped patches 4 and 6
- put **8 489 and 7 044 LSB** out of digital silence and settled to
- +-1 LSB over about 0.2 s (audit 3 (p)6). This pulls the offset
- through the pole with the shapers fed the class's own zero sample -
- **the split is not pulled and no source frame is consumed** - and
- stops as soon as a whole block comes back zero.
-
- A **live** Bias move - a `program_change` onto silence, most of
- all - re-charges with `rewire=True`. Without it a change onto patch
- 4 banged 8 489 LSB out of nothing.
-
- **The re-point costs no block, so nothing is pulled to level the
- split.** This used to pull one block off the dry tap after the
- re-point, on the belief that `Waveshaper.play()` takes one from the
- wet tap. It does not - measured on CPython, desktop MicroPython and
- CircuitPython, a `play()` onto a source takes nothing from it (only
- `audiofilters.Filter.play` and `MixerVoice.play` do) - so the pull
- put the dry leg a block **ahead** of the wet one at every
- `program_change` and every Bias move, and left the wet tap a block
- behind in the Splitter's ring. The dry leg's impulse came out 256
- frames early at patch 4, 512 after one more Bias move; at shipped
- patch 7 (Mix 44), where the dry leg is heard, that is a comb; and
- the block left behind in the ring is old audio that the next Mix
- move off 0 played out of silence, 13 071 LSB (audiocomponents#113).
-
- A Bias move **back to the centre** drains the pole the same way:
- the shaper then answers silence with zero, and the charge the pole
- holds from the old offset would otherwise bleed out of silence as a
- thump - 29 058 LSB after Bias went to its bottom stop and back
- (audiocomponents#113).
-
- On a build with no `audiocore.get_buffer` this does nothing and the
- bang is back.
+ """Re-point the shapers through the zero sample, on a rewire.
+
+ There is no plate pole to charge any more. `Bias` used to be the
+ node's own offset into an asymmetric curve, so the shaper answered
+ silence with a constant and the pole behind it passed the step that
+ started it: 8 489 and 7 044 LSB out of digital silence at shipped
+ patches 4 and 6 (audit 3 (p)6). The class pulled the offset through
+ the pole before the first block, which left up to 1 LSB, needed
+ `audiocore.get_buffer` (compiled out of CircuitPython's board
+ builds, where the bang came back), and had to run again at every
+ move of Bias, Drive, Output, Headroom and Hysteresis
+ (audiocomponents#113). Bias is now folded into the table, which
+ answers silence with exactly zero at every Bias (`_fold_bias`), so
+ nothing waits behind the shaper to be charged.
+
+ The re-point is kept as it was: a `play()` takes nothing from its
+ source, so it costs the split no block.
"""
- pull = getattr(audiocore, "get_buffer", None)
- if pull is None or self._after is None:
- return False
- if not self._bias and not self._charged:
- if rewire:
- for shaper in self._shapers():
- shaper.play(self._quiet)
- shaper.play(self._shaper_head)
- return False
- self._charged = bool(self._bias)
- self._charged_post = self._post
- for index in range(CHARGE_BLOCKS):
+ if rewire and self._after is not None:
for shaper in self._shapers():
shaper.play(self._quiet)
- _state, data = pull(self._makeup_shelf)
- if not data:
- break
- # The first block back is what the pole held before the
- # shapers were re-pointed, so it says nothing about this bias.
- if index and not any(array("h", bytes(data))):
- break
- if rewire:
- for shaper in self._shapers():
shaper.play(self._shaper_head)
- return True
+ return False
+
+ def _fold_bias(self):
+ """Fill `self._table` with the curve as a Bias of `self._bias`
+ reads it, less what that answers silence with.
+
+ The node's input `u` (after `pre_gain`) reads the curve at
+ `span * u + bias`, `span = 1 + |bias|`, so the table still covers
+ everything the node's own bias reached: past its ends the curve's
+ rails, as before. The point `u = 0` is the old operating point and
+ reads exactly zero, so silence in is silence out and the plate pole
+ never holds an offset. `pre_gain` is divided by `span` to match.
+ Integers throughout, on a 1/`BIAS_STEPS` grid, so a board and a
+ desktop build the same table. A table that would pass the rails
+ once the offset is taken out is scaled to fit, and `post_gain`
+ carries the scale back (`self._table_gain`).
+ """
+ curve = self._curve
+ table = self._table
+ last = len(curve) - 1
+ mid = last // 2
+ shift = int(round(self._bias * mid * BIAS_STEPS))
+ widen = shift if shift >= 0 else -shift
+ unit = mid * BIAS_STEPS
+ step = unit + widen
+ base = unit * mid + shift * mid
+
+ def at(index):
+ pos = base + (index - mid) * step
+ if pos <= 0:
+ return curve[0]
+ whole = pos // unit
+ if whole >= last:
+ return curve[last]
+ part = pos - whole * unit
+ low = curve[whole]
+ return low + ((curve[whole + 1] - low) * part + unit // 2) // unit
+
+ if shift == 0:
+ # The centre: the curve itself, which reads zero at zero.
+ for index in range(last + 1):
+ table[index] = curve[index]
+ self._table_gain = 1.0
+ self._span = 1.0
+ for shaper in self._shapers():
+ shaper.set(curve=table)
+ return
+ rest = at(mid)
+ values = [at(index) - rest for index in range(last + 1)]
+ peak = 0
+ for value in values:
+ if value > peak:
+ peak = value
+ elif -value > peak:
+ peak = -value
+ if peak > 32767:
+ for index in range(last + 1):
+ value = values[index]
+ if value >= 0:
+ table[index] = (value * 65534 + peak) // (2 * peak)
+ else:
+ table[index] = -((-value * 65534 + peak) // (2 * peak))
+ self._table_gain = peak / 32767.0
+ else:
+ for index in range(last + 1):
+ table[index] = values[index]
+ self._table_gain = 1.0
+ self._span = step / unit
+ for shaper in self._shapers():
+ shaper.set(curve=table)
def _attach(self):
"""Hand the mixer its real sources, once and not before they are
@@ -955,19 +1019,66 @@ def _attach(self):
if self._attached:
return
self._attached = True
- self._mix.voice[0].play(self._dry)
- self._mix.voice[1].play(self._makeup_shelf)
+ self._play_voice(self._mix.voice[0], self._dry)
+ self._play_voice(self._mix.voice[1], self._makeup_shelf)
def _reset_mixers(self):
+ if self._resetting:
+ # Inside `reset()` the voices only let go: it arms them again
+ # once the chain behind them is clear. A host reset of the
+ # Mixer here reached the voices' sources on CircuitPython's
+ # stock `audiomixer` and cost the reset a block of the source.
+ for voice in self._mix.voice:
+ voice.stop()
+ return
audiocore.reset_buffer(self._mix)
if self._attached:
self._mix.voice[0].play(self._dry)
self._mix.voice[1].play(self._makeup_shelf)
+ def reset(self):
+ """Clear every node and come back as `_build` leaves a fresh
+ instance at patch 0.
+
+ The walk clears the mixer first (it is registered last), and its
+ voices used to be re-played there: a play primes a voice with a
+ block of its source, so the wet voice took one from a chain the
+ walk had not cleared yet and the first block after the reset was
+ the old audio, 1504 LSB at the defaults. Here the voices are
+ stopped, the patch goes in with the graph unarmed, and the graph is
+ armed the way `_build` arms it: the shapers re-pointed, the level
+ gates opened, then the voices played.
+ """
+ self._check_live()
+ self._resetting = True
+ try:
+ self._clear_nodes()
+ finally:
+ self._resetting = False
+ self._stranded = False
+ self._ready = False
+ try:
+ if self._reset_to is None:
+ self.program_change(0)
+ else:
+ self._restore(self._reset_to)
+ finally:
+ self._ready = True
+ # Armed lazily, as `_build` arms: a Mix 0 set before the next pull
+ # hands out the source from where the reset found it.
+ self._lazily(self._rearm_after_reset)
+ # Armed just now: a Mix 0 it lands on routes around a graph that
+ # has not played since it was cleared, as `_build`'s does.
+ self._stranded = False
+
def _value(self, index):
return _component.macro_value(self._MACRO_RANGES[index],
self._macros[index])
+ def _rearm_after_reset(self):
+ self._rearm()
+ self._refresh_output()
+
def _pre0(self):
if self._character == "tube":
return TUBE_PRE0
@@ -991,24 +1102,49 @@ def _shapers(self):
def _apply_macro(self, index, position):
value = _component.macro_value(self._MACRO_RANGES[index], position)
+ if (index in HELD_BY_THE_PLAY_OPERATOR and self._hysteresis > 0.0
+ and self._ready and not self._constructing):
+ self._let_go_in_silence()
+ self._apply_one(index, value)
+
+ def _let_go_in_silence(self):
+ """Let go of what the play operator holds, if the input is silent.
+
+ With Hysteresis on, the operator keeps the position the material
+ left it at when the input stops, so the shaper answers silence with
+ the curve there, times `post_gain`, and the plate pole (the flux
+ high-pass on `console`) has taken that constant out. A Drive,
+ Output, Headroom, Bias or Hysteresis move changes the constant, and
+ the pole passed the step: up to 1763 LSB out of silence at Drive's
+ top. So before such a move the shaper re-centres its operator, which
+ it does only once its input has been silent a whole block, and then
+ answers silence with exactly zero; the pole is cleared with it and
+ has nothing to let go of. Under signal neither moves, and the loop
+ plays on as it was.
+ """
+ routed = self._lean if self._routed_lean else self._shaper
+ recentre = getattr(routed, "recentre", None)
+ if recentre is None:
+ return
+ if recentre():
+ for shaper in self._shapers():
+ if shaper is not routed:
+ shaper.recentre()
+ self._clear_nodes(only=(self._after,))
+
+ def _apply_one(self, index, value):
if index in (0, 1, 3):
+ # The folded table answers silence with zero, so `post_gain`
+ # scales no offset and a gain move leaves the pole nothing.
self._push_gains()
- # Drive, Output and Headroom move `post_gain`, and off centre
- # the offset the plate pole holds is `curve(bias) * post_gain`,
- # so they move it as much as Bias does: Headroom at patch 4 and
- # back banged 4 885 LSB out of silence. Re-charged the way a
- # Bias move is (audiocomponents#113). At the centre there is no
- # offset and nothing runs.
- if self._ready and (self._bias or self._charged) \
- and self._post != self._charged_post:
- self._charge_coupling(rewire=True)
elif index == 2:
self._push_mix()
self._refresh_output()
elif index == 4:
+ if self._character == "tube":
+ value = value * TUBE_BIAS_REACH
self._bias = value
- for shaper in self._shapers():
- shaper.set(bias=value)
+ self._fold_bias()
self._push_gains()
if self._ready:
self._charge_coupling(rewire=True)
@@ -1027,7 +1163,6 @@ def _apply_macro(self, index, position):
elif index == 7:
back_in = (value > 0.0 and self._hysteresis <= 0.0
and not self._constructing)
- moved = value != self._hysteresis
self._hysteresis = value
for shaper in self._shapers():
shaper.set(hysteresis=value)
@@ -1038,24 +1173,15 @@ def _apply_macro(self, index, position):
# first sample back steps from there - an offset out of
# silence, 314 LSB through the plate pole. Cleared, it
# starts at the centre, which is where silence would have
- # left it (audiocomponents#113). Clearing a shaper clears
- # its oversampler too, and off centre the offset then has to
- # climb back through the half-band - a dip the charged pole
- # passes as a thump, 7 560 LSB at patch 4 - so off centre
- # the pole is re-charged behind it, as a Bias move does,
- # with the operator already engaged so the charge settles
- # where it will sit.
+ # left it (audiocomponents#113). With Bias folded into the
+ # table the centre is zero at every Bias, so there is no
+ # offset to climb back to and nothing to re-charge.
self._clear_nodes(only=self._shapers())
- if moved and self._ready and (self._bias or self._charged):
- # Off centre the operator rests a half-width below the bias,
- # so any Hysteresis move - off included - moves the offset
- # the pole holds: 14 LSB out of silence at patch 4 after
- # Hysteresis went off and came back.
- self._charge_coupling(rewire=True)
def _curve_at(self, x):
- """The table at `x`, interpolated the way the node interpolates."""
- table = self._curve
+ """The table the shapers read at `x`, interpolated the way the node
+ interpolates."""
+ table = self._table
last = len(table) - 1
if x <= -1.0:
return float(table[0])
@@ -1078,9 +1204,8 @@ def _reach(self, pre):
window's two ends. Two interpolated lookups per gain write, not a
sweep, which is what a board can afford in `_apply_macro`.
"""
- bias = self._bias
- lo = self._curve_at(bias - pre)
- hi = self._curve_at(bias + pre)
+ lo = self._curve_at(-pre)
+ hi = self._curve_at(pre)
if lo < 0.0:
lo = -lo
if hi < 0.0:
@@ -1114,8 +1239,10 @@ def _push_gains(self):
gain = drive / head
if gain > ceiling:
gain = ceiling
- pre = self._pre0() * gain
- post = self._makeup() * output / gain
+ # The node's own gains: the folded table reads `span` of the
+ # curve's input per unit and holds it at `1 / table_gain`.
+ pre = self._pre0() * gain / self._span
+ post = self._makeup() * output / gain * self._table_gain
ask = post * self._reach(pre)
if ask > NODE_CEILING:
move = NODE_CEILING / ask
@@ -1164,6 +1291,9 @@ def _refresh_output(self):
self._latency = 0
else:
if self._rejoin(keep=(self._mix,)):
+ # The Splitter drops the source frames it took before the
+ # bypass, which the host has since read past.
+ _component.let_go_of_source(self._split, self._source)
self._rearm()
self._attach()
self._output = self._mix
@@ -1177,12 +1307,11 @@ def _rearm(self):
oversamplers - and each voice the block it had queued; `_rejoin` has
cleared the nodes. The mixer is left out of that walk on purpose:
its registered reset re-plays the voices there and then, through a
- pole not yet charged. So the order is `_build`'s: the pole charged
- on the bias with the shapers on the zero sample, the shapers pointed
- back at the chain, the gates opened at the levels, and the voices
+ pole whose input has not been re-pointed. So the order is
+ `_build`'s: the shapers re-pointed through the zero sample and back
+ at the chain, the gates opened at the levels, and the voices
re-played (audiocomponents#113).
"""
- self._charged = False
self._charge_coupling(rewire=True)
_component.open_level_gates(
self._mix, [self._mix.voice[0], self._mix.voice[1]],
@@ -1203,9 +1332,22 @@ def _route(self):
return
shaper = self._lean if lean else self._shaper
factor = self._lean_oversample if lean else self._oversample
+ # The incoming shaper has stood idle since it was last routed and
+ # still holds what it was shaping then, which the move plays out of
+ # silence (a thump, 10 000 LSB and more). It is cleared only when
+ # the outgoing one has seen a whole block of exact silence, which
+ # its `recentre()` reports: under signal a clear is a step of its
+ # own (2 084 LSB on two patch moves while a note plays). The idle
+ # shaper's own `recentre()` cannot tell: it never saw the silence.
+ outgoing = self._shaper if lean else self._lean
+ if outgoing.recentre():
+ audiocore.reset_buffer(shaper)
self._after.play(shaper)
self._routed_lean = lean
- self._wet_delay = _group_delay_samples(factor, self._extra_delay)
+ # The onset, as `_build` declares it: the group delay here made a
+ # visit to the lean patch leave `latency_samples` reading the
+ # centroid (3 at tube x4) where a fresh instance reads 1.
+ self._wet_delay = _click_onset_samples(self._character, factor)
self._refresh_output()
@property
diff --git a/lib/audioeffects/tremolo.py b/lib/audioeffects/tremolo.py
index 86fad2a..10d7253 100644
--- a/lib/audioeffects/tremolo.py
+++ b/lib/audioeffects/tremolo.py
@@ -292,6 +292,8 @@ def _build(self, character=0.0, rate=5.0, depth=0.5, shape=0.5,
lag_ms=35.0, bias=0.5, speed_link=1.0, stereo_phase=0.0,
sync=0.0, patch=None):
self._mod_sample = None
+ self._notes = None
+ self._applied = None
self._synth = synthio.Synthesizer(
sample_rate=self._sample_rate,
channel_count=self._channel_count)
@@ -331,6 +333,24 @@ def _value(self, index):
return _component.macro_value(self._MACRO_RANGES[index],
self._macros[index])
+ def reset(self):
+ """Clear every node, restore patch 0 and start the LFO again.
+
+ The LFO is a note on a `synthio.Synthesizer`, which the walk does
+ not reach: it played on, holding a block it had rendered before the
+ reset, and the note it was asked for next started on top of the old
+ one's release. A fresh synthesizer, which patch 0 then presses, is
+ the LFO a fresh instance starts with."""
+ self._check_live()
+ old = self._synth
+ self._notes = None
+ self._applied = None
+ self._synth = synthio.Synthesizer(
+ sample_rate=self._sample_rate,
+ channel_count=self._channel_count)
+ _component.Component.reset(self)
+ _component.release(old)
+
def _apply_macro(self, index, position):
del position
self._refresh()
@@ -356,6 +376,15 @@ def _refresh(self):
if bpm > 0.0:
rate = snap_rate(rate, bpm)
+ # A knob that lands where it already was (a host re-sending its
+ # automation, a reset onto the same patch) changes nothing; the
+ # LFO runs on rather than being baked and pressed again.
+ applied = (character, rate, depth, shape, lag_ms, idle, linked,
+ phase_deg)
+ if applied == self._applied:
+ return
+ self._applied = applied
+
if depth <= DEPTH_WIRE:
self._mul.set(mix=0.0)
return
@@ -422,6 +451,7 @@ def _law_pcm(self, frames, channels, table_fs, rate, depth, shape,
frames, channels, depth, shape, idle, phase_r)
def _modulate_raw(self, table, fs, channels):
+ self._notes = None
release = getattr(self._synth, "release_all", None)
if release is not None:
release()
@@ -435,23 +465,39 @@ def _modulate_raw(self, table, fs, channels):
self._mul.modulate(sample)
def _modulate_synth(self, table, played, channels):
+ """Hand the LFO its new period. The notes already playing take the
+ new waveform and frequency in place, as `AutoPan` does, so a knob
+ moves the throb from where it is in its cycle; only the first
+ period (and the first after a reset or a raw table) presses notes."""
if self._mod_sample is not None:
deinit = getattr(self._mod_sample, "deinit", None)
if deinit is not None:
deinit()
self._mod_sample = None
left = self._channel_of(table, channels, 0)
+ right = None if channels == 1 else self._channel_of(
+ table, channels, 1)
+ notes = self._notes
+ if notes is not None:
+ notes[0].waveform = left
+ notes[0].frequency = played
+ if right is not None:
+ notes[1].waveform = right
+ notes[1].frequency = played
+ return
release = getattr(self._synth, "release_all", None)
if release is not None:
release()
- if channels == 1:
- self._synth.press(synthio.Note(frequency=played, waveform=left))
+ if right is None:
+ notes = (synthio.Note(frequency=played, waveform=left),)
+ self._synth.press(notes[0])
else:
- right = self._channel_of(table, channels, 1)
- self._synth.press((
+ notes = (
synthio.Note(frequency=played, waveform=left, panning=-1.0),
synthio.Note(frequency=played, waveform=right, panning=1.0),
- ))
+ )
+ self._synth.press(notes)
+ self._notes = notes
self._mul.modulate(self._synth)
@staticmethod
diff --git a/lib/audioeffects/vibrato.py b/lib/audioeffects/vibrato.py
index 42edd4d..265e9d2 100644
--- a/lib/audioeffects/vibrato.py
+++ b/lib/audioeffects/vibrato.py
@@ -9,7 +9,12 @@
the delayed signal, pitch-wobbled because a sine-family LFO moves the
*clock* of the line, not an additive delay. Rate is 2–15 Hz. Depth is peak
cents, held against Rate by solving the clock index m. Rise (150 ms–5 s)
-ramps that depth when Engage turns on; the audio path is never switched.
+ramps that depth in when Engage turns on, and out when it turns off, in a
+straight line whose 10–90 % is Rise, on every interpreter: the ramp is the
+delay node's own (`wow_depth_ramp_ms`), so nothing in Python runs while it
+travels. Any other knob that moves the depth glides there over 20 ms, which
+also finishes an Engage ramp still under way. The audio path is never
+switched.
Delay is the mean line, default 4 ms, and that number is
`latency_samples`. Tone and Body are the wet reconstruction low-pass and
high-pass (−3 dB at 17 kHz and 40 Hz at the published defaults). Level is
@@ -74,13 +79,9 @@
#: One period of the clock law, power of two, borrowed by the node.
SHAPE_LEN = 256
-#: FeedbackDelay's native block. Rise steps once per pull of this size.
+#: FeedbackDelay's native block, which the Mixer's buffer matches.
BLOCK_FRAMES = 256
-#: CPython Mixer's get_buffer was 128 frames; T5 stamps that size.
-#: The MCU pulls the delay whole and does not use this split.
-SPLIT_FRAMES = 128
-
#: Depth / m below this is off: wow_depth_ms = 0, delay = mean.
DEPTH_OFF = 1e-4
@@ -93,6 +94,9 @@
#: 200 ms — so T5's ifreq clause is Station C's 5 s extreme, not this law.
RISE_10_90 = 0.8
+#: How long a depth move other than Engage glides: the node's default.
+DEPTH_GLIDE_MS = 20.0
+
_RANGES = (
(2.0, 15.0, "log"), # 0 Rate, Hz
(0.0, 60.0), # 1 Depth, peak cents
@@ -237,87 +241,6 @@ def _patch(rate, depth_cents, rise, engage, delay_ms, tone_hz, body_hz,
)
-class _RampTap:
- """Re-blocking source in front of the delay. Advances the Engage
- envelope once per 256-frame native block, then forwards at most that
- many frames so the delay cannot stash a whole RawSample and starve the
- ramp.
- """
-
- def __init__(self, source, owner):
- self._source = source
- self._owner = owner
- self.sample_rate = source.sample_rate
- self.channel_count = source.channel_count
- self.bits_per_sample = 16
- self.samples_signed = True
- self._pending = b""
-
- def _reset_buffer(self, single_channel_output=False, audio_channel=0):
- self._pending = b""
- self._owner._snap_envelope()
- source = self._source
- if source is not None:
- audiocore.reset_buffer(source, single_channel_output,
- audio_channel)
-
- def _get_buffer(self, single_channel_output=False, audio_channel=0):
- self._owner._on_block()
- need = BLOCK_FRAMES * self.channel_count * 2
- while len(self._pending) < need:
- if self._source is None:
- break
- result, data = audiocore.get_buffer(self._source,
- single_channel_output,
- audio_channel)
- chunk = bytes(data)
- if not chunk:
- break
- self._pending += chunk
- if result == 0:
- break
- take = self._pending[:need]
- self._pending = self._pending[len(take):]
- more = 1 if take else 0
- return more, memoryview(take)
-
-
-class _SplitOut:
- """128-frame view of the delay so CPython T5 stamps match Mixer-era
- pulls. Not a palette node; boards pull the delay directly.
- """
-
- def __init__(self, delay, owner):
- self._delay = delay
- self._owner = owner
- self.sample_rate = delay.sample_rate
- self.channel_count = delay.channel_count
- self.bits_per_sample = 16
- self.samples_signed = True
- self._pending = b""
-
- def _reset_buffer(self, single_channel_output=False, audio_channel=0):
- self._pending = b""
- audiocore.reset_buffer(self._delay, single_channel_output,
- audio_channel)
-
- def _get_buffer(self, single_channel_output=False, audio_channel=0):
- need = SPLIT_FRAMES * self.channel_count * 2
- while len(self._pending) < need:
- result, data = audiocore.get_buffer(self._delay,
- single_channel_output,
- audio_channel)
- chunk = bytes(data)
- if not chunk:
- break
- self._pending += chunk
- if result == 0:
- break
- take = self._pending[:need]
- self._pending = self._pending[len(take):]
- return (1 if take else 0), memoryview(take)
-
-
class Vibrato(_component.Component):
"""Boss VB-2–informed vibrato: a sine on the BBD clock, wet alone.
audiodsp tier; latency is the mean delay (4 ms at the default).
@@ -384,8 +307,14 @@ def _build(self, rate=5.0, depth=30.0, rise=1.0, engage=True,
self._latency = int(round(float(delay_ms) * self._sample_rate
/ 1000.0))
self._tail = self._latency
- self._envelope = 1.0 if engage else 0.0
self._wow_full_ms = 0.0
+ #: The mean `delay_ms` last written, so a move can glide from it.
+ self._delay_written = None
+ #: The clock index the shape last handed to the node was built for.
+ self._m_written = 0.0
+ #: Whether the depth last written was the engaged one, so an Engage
+ #: move is told apart from a move of any other knob.
+ self._engaged_written = None
self._delay = self._own(audioecho.FeedbackDelay(
sample_rate=self._sample_rate,
channel_count=self._channel_count,
@@ -398,23 +327,18 @@ def _build(self, rate=5.0, depth=30.0, rise=1.0, engage=True,
wow_hz=0.0,
wow_depth_ms=0.0,
wow_shape=self._shape))
- self._tap = _RampTap(self._source, self)
- try:
- self._delay.play(self._tap)
- except TypeError:
- # Native MicroPython/CircuitPython FeedbackDelay will not play a
- # Python source (`protocol_audiosample`). The Engage envelope
- # then snaps on reset and on the Engage macro; T5 is CPython.
- self._tap = None
- self._delay.play(self._source)
+ # The Engage envelope is the node's own depth ramp
+ # (`wow_depth_ramp_ms`), so it runs on every interpreter and needs
+ # nothing from Python while the output is pulled.
+ self._delay.play(self._source)
orig_reset = getattr(self._delay, "_reset_buffer", None)
if orig_reset is not None:
def _reset_delay(single_channel_output=False, audio_channel=0,
orig=orig_reset):
orig(single_channel_output, audio_channel)
- src = self._tap if self._tap is not None else self._source
- if src is not None:
- audiocore.reset_buffer(src, single_channel_output,
+ if self._source is not None:
+ audiocore.reset_buffer(self._source,
+ single_channel_output,
audio_channel)
self._delay._reset_buffer = _reset_delay
bytes_per_block = BLOCK_FRAMES * self._channel_count * 2
@@ -426,9 +350,7 @@ def _reset_delay(single_channel_output=False, audio_channel=0,
buffer_size=bytes_per_block),
reset=self._reset_mixer)
# Default Level is 0 dB: pull the delay. Mixer is optioned.
- # CPython keeps a 128-frame split so T5's 128/fs stamp stays honest.
- self._split = _SplitOut(self._delay, self) if self._tap is not None else None
- self._output = self._split if self._split is not None else self._delay
+ self._output = self._delay
self._mixer_armed = False
orig_mix_reset = getattr(self._mixer, "_reset_buffer", None)
orig_mix_get = getattr(self._mixer, "_get_buffer", None)
@@ -458,34 +380,47 @@ def _level_is_unity(self):
def _route_output(self):
if self._level_is_unity():
- if self._split is not None:
- self._output = self._split
- else:
- self._output = self._delay
+ self._output = self._delay
return
if not self._mixer_armed:
self._arm_mixer_voice()
self._output = self._mixer
def _arm_mixer_voice(self):
+ """Arm the Level mixer's voice on the delay, the lazy way, as a
+ reset arms a mixer: the voice primes a block of silence, the mixer
+ hands it up, and the voice's stand-in is then pointed at the delay,
+ so the next pull fetches from where the delay was.
+
+ A native voice's `play()` on the delay primed a block of the music
+ there and then, and the output ran 256 frames ahead of it for good:
+ 5 ms skipped the first time Level left 0 dB, on a board only.
+ CPython's voice was armed by hand without a pull, and started at
+ level 0 until the music crossed zero, which the silence here opens.
+ Both interpreters now arm the same way and render the same bytes.
+ """
voice = self._mixer.voice[0]
- if hasattr(voice, "_remaining"):
- # CPython Mixer.play() prefetches a delay block and advances the
- # probe. render_effect then reset_buffer()s the mixer; the
- # block adapter does not rewind, so an impulse probe goes silent.
- # Arm the voice without pulling.
- voice._sample = self._delay
- voice._loop = False
- voice._remaining = b""
- voice._source_more = True
- else:
- voice.play(self._delay)
self._mixer_armed = True
+ if _component.PORT is None:
+ if hasattr(voice, "_remaining"):
+ voice._sample = self._delay
+ voice._loop = False
+ voice._remaining = b""
+ voice._source_more = True
+ else:
+ voice.play(self._delay)
+ return
+ self._lazy_pending = []
+ self._arming_lazily = True
+ try:
+ self._play_voice(voice, self._delay)
+ finally:
+ self._arming_lazily = False
+ self._hand_up_lazy(getattr(audiocore, "get_buffer", None))
def _reset_mixer(self):
audiocore.reset_buffer(self._mixer)
self._mixer_armed = False
- self._snap_envelope()
def _mean_delay_ms(self):
"""Whole-sample mean delay. A 7-bit Delay within two samples of
@@ -509,29 +444,25 @@ def _value(self, index):
self._macros[index])
def _engaged(self):
- if len(self._macros) < 4:
- return self._envelope >= 0.5
return self._macros[3] >= 0.5
- def _snap_envelope(self):
- self._envelope = 1.0 if self._engaged() else 0.0
- self._write_wow()
-
- def _on_block(self):
- target = 1.0 if self._engaged() else 0.0
- rise = self._value(2)
- span = rise / RISE_10_90
- dt = BLOCK_FRAMES / float(self._sample_rate)
- if span <= dt:
- self._envelope = target
- elif self._envelope < target:
- self._envelope = min(target, self._envelope + dt / span)
- elif self._envelope > target:
- self._envelope = max(target, self._envelope - dt / span)
- self._write_wow()
+ def _rise_ramp_ms(self):
+ """The whole of the Engage envelope in ms: Rise is its 10-90 %, so
+ the straight line takes Rise / 0.8."""
+ return self._value(2) / RISE_10_90 * 1000.0
def _write_wow(self):
- self._delay.set(wow_depth_ms=self._wow_full_ms * self._envelope)
+ """Write the depth Engage asks for. An Engage move travels there over
+ the envelope; any other move (Depth, Rate, Delay) over the node's
+ 20 ms, which also finishes an envelope still under way."""
+ engaged = self._engaged()
+ ramp_ms = DEPTH_GLIDE_MS
+ if (self._engaged_written is not None
+ and engaged != self._engaged_written):
+ ramp_ms = self._rise_ramp_ms()
+ self._engaged_written = engaged
+ self._delay.set(wow_depth_ramp_ms=ramp_ms,
+ wow_depth_ms=self._wow_full_ms if engaged else 0.0)
def _apply_macro(self, index, position):
del position
@@ -547,7 +478,25 @@ def _refresh(self):
m = m_for_cents(mean_ms, rate, cents)
delay_ms, wow_depth_ms = delay_law(mean_ms, m)
self._wow_full_ms = wow_depth_ms
- self._shape = clock_shape(m)
+ # A move of Rate, Depth or Delay moves the mean line and the clock
+ # law at once, while the depth glides over 20 ms. Written as they
+ # were, the read head jumped by the whole change on the next frame,
+ # a tick each time one was turned. So the mean line walks there
+ # over the same 20 ms (the node's `delay_slew`, delay-seconds per
+ # second, set from the size of this move), and a new clock law goes
+ # to the node, which ramps the depth to zero over the same 20 ms,
+ # changes the table there and ramps back (audioecho's wow_shape
+ # swap). Only a law that moved is handed over, so a Tone, Body,
+ # Level or Engage move does not dip the swing. Depth 0 keeps the
+ # last law while the depth glides out under it, as Engage off does.
+ if m >= DEPTH_OFF and m != self._m_written:
+ self._shape = clock_shape(m)
+ self._m_written = m
+ last = self._delay_written
+ if last is not None and last != delay_ms:
+ self._delay.set(
+ delay_slew=abs(delay_ms - last) / DEPTH_GLIDE_MS)
+ self._delay_written = delay_ms
damping = nominal_damping_hz(tone, self._sample_rate)
self._delay.set(
delay_ms=delay_ms,
@@ -568,7 +517,14 @@ def _refresh(self):
# adds the frames it takes to fall from full scale to under half an
# LSB (audiocomponents#114: Body alone rang 2 430 frames past the
# line at 20 Hz, 48 kHz).
+ # And a move keeps the read head travelling for up to two glides
+ # after it: the mean line's 20 ms, or a new law's dip to zero depth
+ # and back. What the wet filters hold from that travel rings out
+ # behind it, so the count starts where the travel ends.
+ glide = int(math.ceil(2.0 * DEPTH_GLIDE_MS * self._sample_rate
+ / 1000.0))
self._tail = (int(math.ceil(d_max * self._sample_rate / 1000.0)) + 64
+ + glide
+ one_pole_ring(damping, self._sample_rate)
+ one_pole_ring(body, self._sample_rate))
diff --git a/tests/conftest.py b/tests/conftest.py
index 9ceac03..af6afc7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,14 +5,16 @@
never which tests run.
The slowest files go first. The lifecycle matrix runs the whole matrix on
-CPython in its setUpClass, and a few reverb and tape delay classes take
+CPython in its setUpClass, the node matrix runs its nodes one after another
+in its own (NodeFlanger alone takes minutes), and a few reverb and tape delay classes take
minutes each; started last, any of them would finish long after every other
worker had gone idle. With `--dist loadscope` each class stays on one worker,
so a setUpClass runs once.
"""
#: Files whose tests start first, slowest first.
-SLOW_FIRST = ("test_lifecycle_matrix.py", "test_cpython_effects_reverb.py",
+SLOW_FIRST = ("test_lifecycle_matrix.py", "test_node_matrix.py",
+ "test_cpython_effects_reverb.py",
"test_cpython_effects_tapedelay.py")
diff --git a/tests/parity/effects_library_smoke.py b/tests/parity/effects_library_smoke.py
index 4285ff3..e6655f4 100644
--- a/tests/parity/effects_library_smoke.py
+++ b/tests/parity/effects_library_smoke.py
@@ -19,7 +19,7 @@ class the gate audit has not adopted is not in the catalogue, so walking the
sample rate, and every patch is selected the way a host selects one, with
`program_change()`. Nothing here calls the module-level `configure()` or
passes a `patch=` constructor keyword, so this file keeps working through the
-Phase 1 construction change that replaces
+Phase 1 construction change that replaced
`_core`'s module state and its `__new__` format sniffing.
Exit status is non-zero if any class fails to build, disagrees with its
diff --git a/tests/support/KIT.md b/tests/support/KIT.md
index b72b1ca..416a4a2 100644
--- a/tests/support/KIT.md
+++ b/tests/support/KIT.md
@@ -1,16 +1,16 @@
# The effects kit
-Seven mechanical checks that run on one effect class with no model in the
+Eight mechanical checks that run on one effect class with no model in the
loop: every verdict is arithmetic on rendered bytes, or on the source. They
sit beside the [lifecycle matrix](LIFECYCLE.md) and catch the kinds of
defect that kept turning up class after class: a tail that outlasts
`tail_samples`, a planted fault a knob can reach, a docstring promise no
test holds, a control that does not read back what it was set to, an input
level that wraps, a citation that no longer resolves, a refused call that
-leaves a trace, and a class that only behaves where `audiocore.get_buffer`
-exists.
+leaves a trace, a class that only behaves where `audiocore.get_buffer`
+exists, and a reset that does not come back as a fresh instance.
-To run all seven on one class, from the repo root:
+To run all eight on one class, from the repo root:
python tests/support/kit.py DigitalDelay
python tests/support/kit.py DigitalDelay --only tail,ceiling --quick
@@ -28,10 +28,11 @@ It prints one line per cell, `name|check|key|V:verdict[|h:digest]`, a
why), `RED(...)` with what was measured, or `decl`. `h:` is a CRC-32 of the
cell's render, so two interpreters' lines agree only when their bytes do.
-The four render checks (tail, plants, args, ceiling) are numpy-free and
-print the same lines on CPython, MicroPython and CircuitPython. The claims,
-paperwork and nogb checks read source, unittest and git, or patch modules,
-and run on CPython only; a native run prints `SKIP` for them.
+The five render checks (tail, plants, args, ceiling, fresh) are numpy-free
+and print the same lines on CPython, MicroPython and CircuitPython. nogb
+runs on all three too. The claims and paperwork checks read source,
+unittest and git, and run on CPython only; a native run prints `SKIP` for
+them.
A class that does nothing at all must not read as a pass: the tail and
args checks print `na(silent)` for a render that was zero throughout, and
@@ -105,17 +106,22 @@ hands it:
- `rt-m`: `set_macro(i, get_macro(i))` mid-stream changes no byte, at
the defaults and every patch.
- `stops-m`: 0, 64 and 127 are accepted and read back within 1e-4.
-- `bad-m`: NaN, +-inf, -1 and 128. The contract
- ([audio-component-api.md](../../docs/audio-component-api.md)) does not
- say whether an out-of-range macro value raises or clamps, so either
- passes: a raise is ValueError or TypeError and moves no macro (`r`); a
- clamp reads back a finite 0..127 (0 below, 127 above) and renders
- exactly what `set_macro(i, readback)` renders (`c`).
+- `bad-m`: NaN, +-inf, -1, 128 and +-10**400 (an int past float's
+ range), held to the contract's one answer
+ ([audio-component-api.md](../../docs/audio-component-api.md#macro-and-patch-state)):
+ a value off the range is clamped, never refused. NaN, -inf, -1 and
+ -10**400 read back 0, +inf, 128 and 10**400 read back 127, and each
+ renders exactly what
+ `set_macro(i, readback)` renders (`c`). A raise of any kind is red.
- `patch-p`: `program_change(k)` sets `patch_index` and reads back the
- patch's values, and `program_change(0)` reads back what a fresh instance
- read (the contract: a fresh component and one given patch 0 are the
- same).
-- `ctor-