diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index b9bf764..d337e91 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -321,5 +321,29 @@ # plants that #211 made inert; and the lifecycle matrix's E8-dry event, # retired because a native source that runs dry has ended. # +# 2026-10-12: eb2d20d -> v0.6.6rc1 (30cbbf6), a pre-release of the next +# patch, for the effects program's Phase 6; TestPyPI only, so CI's +# `git+...@v0.6.6rc1` install is the one that reaches it. The pyproject +# floors stay at the last final release: the pin is not the floor. What +# moved beneath the components, by `git log eb2d20d..v0.6.6rc1 -- src`: +# audiodelays.Echo and MultiTapDelay start a line shortened under the write +# head at its top, so a Time brought back after a shorter one plays nothing +# from before, and the CPython Echo clears past a shorter line as the native +# one does (audiodsp#241); Echo lets go of a looped source that comes back +# empty instead of hanging the pull (#242); PitchShift and +# GranularPitchShift start again from a new node's state on a reset (#240) +# and return from a pull when the source has nothing to give (#243), and +# PitchShift gains upstream's freeze (#222); Convolver's load() keeps the +# audio in flight and crossfades the block being played, as synthesize() +# already did (#237); a Mixer's host reset keeps every voice where it was +# (#229); a set() on FeedbackDelay, Tank, Dynamics, Waveshaper, Ladder and +# Bank is staged off the lock and lands whole (#233); an audiopump swap +# that outlasts the ring fades out and back in (#234); Tank's filters and +# modulation brought back from 0 start from what is playing (#227); a mono +# SplitterTap's buffer is the tap's own on CPython too (#228); FeedbackDelay +# takes its wow and pitch coast rate from what played, and the CPython Echo +# holds its line still at mix 0 (#226); audiovorbis (#238, #239); and an +# fmod-free angle reduction for the RP2040 (#225). +# # -- ref is what CI installs (git+https://...@) -eb2d20daa7f3be2965f8f641b3fdf2d9a55aca9c eb2d20daa7f3be2965f8f641b3fdf2d9a55aca9c +v0.6.6rc1 30cbbf619751ab6ff7c40d99336907af0a081fa6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 437eacd..db62a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,78 @@ there, and are recorded in its changelog. ### Added +- **`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 + an `audioecho.FeedbackDelay` loop (`loop_semitones`), so each pass comes + back shifted again until Damping darkens it away. Nine macros (Character, + Shimmer, Rise, Bloom, Interval, Decay, Size, Damping, Mix) and seven + patches replace the old Shimmer / Echo / Space / Tone surface and its + constructor arguments. The defaults, patch 0 "Octave Rise", are Rise at + Rise 0: one octave, once, with the hall starting at once, which an + ESP32-S3 keeps up with where the Bloom patches (Octave Bloom, Short + Stagger, Fifth Halo) do not. The hall is the rebuilt `Reverb`. The dry passes + untouched up to Mix 1, Mix 0 is a byte-exact wire, and the input ceiling + is -24 dBFS. It is what `audioeffects.ShimmerHall` and `create()` now + serve. It lives in `lib/audioeffects/rebuilt/shimmerhall.py`, beside the + old class in `rack.py`, until it is promoted. +- **`AirSpace` (rebuilt, Phase 6; adopted 2026-10-13):** a tape echo sent + into a chamber, with the dry untouched beside it, in + `audioeffects/rebuilt/airspace.py`. Eleven macros (Time, Feedback, Glide, + Drive, Loop Top, Loop Bass, Top Cut, Low Cut, Decay, Damping, Mix) and + seven patches replace the old rack's Space, Echo and Tone; the loop + darkens each repeat, Top Cut darkens the send once, Feedback stretches the + chamber's tail, and a Time move bends the repeats' pitch. The chamber is + the rebuilt `Reverb`, and `reset()` renders exactly as a fresh instance. + It is what `audioeffects.AirSpace` and `create()` now serve; the old + `rack.AirSpace` stays beside it until the rebuild is promoted. +- **`Harmonizer` (rebuilt, Phase 6; adopted 2026-10-13):** three pitch shifters at fixed + intervals under the dry, after three Eventide H910s played from one + keyboard. Each voice is its own `audiodelays.PitchShift` on its own + splitter tap and lands on the nearest step the shifter can play; a + voice at its Level's bottom step is off and its shifter is not pulled. + Ten controls (Pitch, Voice 1 to 3, Level 1 to 3, Detune, Spread, Mix), + seven patches including a lean two-voice one, and a `window_ms` option. + It drops the old class's `intervals=` and `level=` options, and is what + `audioeffects.Harmonizer` and `create()` now serve. +- **`PitchShifter` (rebuilt, Phase 6; adopted 2026-10-13):** a fixed-interval splice shifter + in the manner of the first harmonizers, on a Pitch, Fine, Mix, Delay and + Feedback surface. Pitch moves in whole semitones an octave either way and + Fine detunes by up to 50 cents; the dry passes untouched up to Mix 1; + Delay holds the shifted voice back up to 112.5 ms; and with Feedback up + every repeat is shifted again, so a fifth climbs a fifth at a time. + `window_ms` (15 to 60, 30 by default) sets the splice window at + construction. The old `semitones=` option is gone: Pitch and Fine + replace it. It is what `audioeffects.PitchShifter` and `create()` now + serve. +- **`StereoWidener` (rebuilt, Phase 6; adopted 2026-10-13):** two characters on a Width, Delay + and Character surface. Haas delays the right channel 1 to 40 ms behind the + left, crossfading the right toward its late copy up to Width 1 and turning + the left down above it; Mid/Side scales the difference between the + channels and leaves the sum alone. A mono source passes untouched, and a + Delay move glides instead of clicking. The old `delay_ms=`/`width=` + options keep their names with these meanings. It is what + `audioeffects.StereoWidener` and `create()` now serve. +- **`Rack` (rebuilt, Phase 6; adopted 2026-10-13):** the serial chain on the contract's base, in + `rebuilt/rack.py` beside the old class, which stays until the rebuild is + promoted. It is what `audioeffects.Rack` and `create()` now serve. `chain=` keeps its meaning and also takes a bare effect class. It + renders exactly the same pedals patched by hand; `latency_samples` and + `tail_samples` are live sums; `reset()` clears every pedal and puts each + back to the settings the rack built it with; `deinit()` releases every + pedal, and a chain that fails part-way releases what it built; each pedal + gets the rack's transport and `transport_changed()` reaches nested racks; + `capabilities` is the union of the pedals'. `effects` is a tuple. Patches + and a rack's restore now share one private path in the base, + `_apply_positions`, which the classes that did work once per patch moved + onto. +- **`Octaver` (rebuilt, Phase 6; adopted 2026-10-13):** the analog octave-down, after the + Boss OC-2. The octave is your own note with every other cycle turned + over, clocked from a low-passed copy and multiplied into an all-passed + one, so it keeps your attack and decay and tracks from E2 to A4 at the + default. Seven controls: Direct, Octave 1, Octave 2, Tracking, Tone 1, + Tone 2 and Sensitivity, and six patches. It drops the shifter-based + class's octave up and its `down=`, `up=`, `down2=` and `up2=` options, + and is what `audioeffects.Octaver` and `create()` now serve. - MultiTapDelay: the docstring says which patches need a P4-class board (patch 4 does, patch 3 may), from the boards' figures at audiodsp v0.6.3 - **`DigitalDelay` (rebuilt, Phase 5; adopted 2026-09-28):** a clean interpolated line with the Boss DD-2's control law on one @@ -225,11 +297,12 @@ there, and are recorded in its changelog. ### Changed - **`ShimmerHall` and `AirSpace` keep the classes they were ported - against.** With the Phase 5 `TapeDelay` and `Reverb` adopted, the two - racks name the old `delay.TapeDelay` and `reverb.Reverb` directly (a - `Rack` chain entry may now be a class in place of a `NAME`), so they - build and sound as before; moving them onto the rebuilds would re-voice - them. A chain that names `"Reverb"` gets the rebuild, which takes + against.** With the Phase 5 `TapeDelay` and `Reverb` and the Phase 6 + `Octaver` adopted, the two old racks (`rack.ShimmerHall` and + `rack.AirSpace`) name the old `delay.TapeDelay`, `reverb.Reverb` and + `pitch.Octaver` directly (a `Rack` chain entry may now be a class in + place of a `NAME`), so they build and sound as before; moving them onto + the rebuilds would re-voice them. A chain that names `"Reverb"` gets the rebuild, which takes `character=` where the old class took `preset=`. - **The audiodsp pin moves to v0.6.3rc3.** The release carries the convolution node's two fixes (audiodsp#165: the block in flight kept @@ -291,6 +364,8 @@ there, and are recorded in its changelog. ### Fixed +- 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). - **Ten effects no longer replay old audio when a control brings a bypassed diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py index 09bd800..4f61784 100644 --- a/lib/audioeffects/_component.py +++ b/lib/audioeffects/_component.py @@ -740,6 +740,11 @@ def __init__(self, source, *options, **keywords): #: `Saturation` and `Overdrive` each grew a private flag for the #: same hazard; this is the one every class can read. self._constructing = True + #: True while a class seeds its knobs from its constructor's + #: arguments (the class sets it in `_build`) or `_restore` puts them + #: back: a class that keeps an exact constructor value beside a knob + #: (`_KEPT`) keeps it through a knob applied while this is set. + self._seeding = False try: self._build(*options, **keywords) @@ -1027,11 +1032,60 @@ def program_change(self, index, channel=0, note_id=-1, patch = type(self).PATCHES.get(index) if patch is None: return - for macro in range(len(patch[1])): - self._macros[macro] = position_of_midi(self._mode(macro), - patch[1][macro]) - self._apply_macro(macro, self._macros[macro]) - self._patch_index = index + 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), + apply each, and record `patch_index`. + + This is the body of `program_change`, and the one path a patch and a + restore share: a rack puts a child back as it built it by handing + this the positions and patch index it recorded, which the public + `set_macro` cannot do for a class whose grid is coarser than its + positions or whose macros are not all live. A class that does work + once per patch (a refresh, a re-route) does it here, not in + `program_change`, so both paths get it. + """ + for macro in range(len(positions)): + self._macros[macro] = positions[macro] + self._apply_macro(macro, positions[macro]) + self._patch_index = patch_index + + #: Attributes beyond the macros and the patch index that a class's + #: constructor leaves and a put-back must restore with them: an exact + #: constructor value kept beside its knob (`_glide_exact`). A class that + #: keeps one names it here; `_record` and `_restore` carry it. + _KEPT = () + + #: What `reset()` reloads in place of patch 0: `None`, or a `_record()` + #: a parent hands in for the length of one reset, so a child it built + #: comes back as built in one step, never passing through patch 0. + _reset_to = None + + def _record(self): + """This instance's settings as they stand, for `_restore`: the + positions, the patch index and the `_KEPT` values.""" + return (list(self._macros), self._patch_index, + tuple(getattr(self, name) for name in type(self)._KEPT)) + + def _restore(self, record): + """Put back what `_record` returned, through `_apply_positions`. + + The kept values go back first and the positions are applied with + `_seeding` set, the flag a class's constructor holds while it seeds + the knobs: a class that drops an exact value when its knob moves + keeps it while seeding, so the values and the knobs land together. + """ + positions, patch_index, kept = record + for name, value in zip(type(self)._KEPT, kept): + setattr(self, name, value) + self._seeding = True + try: + self._apply_positions(list(positions), patch_index) + finally: + self._seeding = False def pitch_bend(self, value, channel=0, sample_position=0): self._check_live() @@ -1094,7 +1148,17 @@ def reset(self): # Everything is clear now, so nothing is stranded: patch 0 coming # back off a bypass is the reset's own re-arm, not a second clear. self._stranded = False - self.program_change(0) + if self._reset_to is None: + self.program_change(0) + else: + # A parent's reset: straight back to the settings it built this + # with. Loading patch 0 on the way could re-arm a graph those + # settings route around, and pull the source to do it. Those + # settings may route around the graph, but it has not played + # since it was cleared, so it is no more stranded than a graph + # `_build` routes around. + self._restore(self._reset_to) + self._stranded = False def deinit(self): """Release every node this class built, tail first. Idempotent, and diff --git a/lib/audioeffects/_core.py b/lib/audioeffects/_core.py index 46b3fea..87d067a 100644 --- a/lib/audioeffects/_core.py +++ b/lib/audioeffects/_core.py @@ -368,10 +368,18 @@ def program_change(self, index, channel=0, note_id=-1, patch = self.PATCHES.get(index) if patch is None: return - for macro, value in enumerate(patch[1]): - self._macros[macro] = min(1.0, max(0.0, float(value) / 127.0)) - self._apply_macro(macro, self._macros[macro]) - self._patch_index = index + 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.""" diff --git a/lib/audioeffects/compressor.py b/lib/audioeffects/compressor.py index 58cb305..e9ec578 100644 --- a/lib/audioeffects/compressor.py +++ b/lib/audioeffects/compressor.py @@ -486,9 +486,8 @@ def _push_emphasis(self): else: self._fast.set(sidechain_hz=self._hz(self.macro(11) * emphasis)) - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - super().program_change(index, channel, note_id, sample_position) + def _apply_positions(self, positions, patch_index): + _component.Component._apply_positions(self, positions, patch_index) self._route() def _lean(self): diff --git a/lib/audioeffects/fuzz.py b/lib/audioeffects/fuzz.py index caa63d2..1ebc574 100644 --- a/lib/audioeffects/fuzz.py +++ b/lib/audioeffects/fuzz.py @@ -1016,10 +1016,8 @@ def _rearm(self): self._tone_mix.voice[1].play(self._tone_hp) self._tone_mix.voice[2].play(self._dry) - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - _component.Component.program_change(self, index, channel, note_id, - sample_position) + def _apply_positions(self, positions, patch_index): + _component.Component._apply_positions(self, positions, patch_index) self._route() self._refresh_output() diff --git a/lib/audioeffects/graphiceq.py b/lib/audioeffects/graphiceq.py index f4fe4b4..a7fe9ab 100644 --- a/lib/audioeffects/graphiceq.py +++ b/lib/audioeffects/graphiceq.py @@ -411,9 +411,9 @@ def _level(self, node, index): node.gain_db = gain_db node.mix = 1.0 - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Jump to a patch, and start the new curve from a clean bank. + def _apply_positions(self, positions, patch_index): + """Jump to a patch (or a rack's restore), and start the new curve + from a clean bank. The difference between this and `set_macro` is deliberate, and it is the difference between a jump and a sweep. A slider being pushed @@ -424,16 +424,9 @@ def program_change(self, index, channel=0, note_id=-1, peaks at **32767 and stays above its own steady level for 306 ms** without this, and at **5826 for 8 ms** with it. """ - if type(self).PATCHES.get(index) is None: - return _component.Component.program_change( - self, index, channel=channel, note_id=note_id, - sample_position=sample_position) - result = _component.Component.program_change( - self, index, channel=channel, note_id=note_id, - sample_position=sample_position) + _component.Component._apply_positions(self, positions, patch_index) for node in self._nodes: node.clear() - return result @property def tail_samples(self): diff --git a/lib/audioeffects/rack.py b/lib/audioeffects/rack.py index 9730114..fce6c78 100644 --- a/lib/audioeffects/rack.py +++ b/lib/audioeffects/rack.py @@ -21,11 +21,12 @@ 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` and `reverb.py`'s `Reverb`, directly rather than by NAME: the -rebuilt classes the package serves under those names since their adoption -on 2026-09-29 take different settings (no `preset`, no `wow`) and sound -different, and moving a rack onto them is a re-voicing of the rack, not a -part of the adoption. +`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" @@ -35,6 +36,7 @@ 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 @@ -192,7 +194,7 @@ def __init__(self, source, shimmer=0.55, echo=0.36, space=0.42, # 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=( - ("Octaver", {"down": 0.0, "up": 1.0}), + (_OldOctaver, {"down": 0.0, "up": 1.0}), (_OldTapeDelay, {"time_ms": 420.0, "feedback": 0.5, "wow": 0.3, "drive": 0.2}), (_OldReverb, {"preset": "hall"}), diff --git a/lib/audioeffects/rebuilt/__init__.py b/lib/audioeffects/rebuilt/__init__.py index d3e7616..ad2e4c0 100644 --- a/lib/audioeffects/rebuilt/__init__.py +++ b/lib/audioeffects/rebuilt/__init__.py @@ -114,6 +114,11 @@ def _present(): #: (audiocomponents#75); Reverb's patch 7 differs from that build by nine #: one-LSB samples of 65536 (audiodsp#183). They stay under this directory #: with the first two, for the same reason. +#: +#: 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. ADOPTED = ( "DigitalDelay", "SlapbackDelay", @@ -123,6 +128,13 @@ def _present(): "MultiTapDelay", "Reverb", "ConvolutionReverb", + "StereoWidener", + "Rack", + "Octaver", + "PitchShifter", + "Harmonizer", + "AirSpace", + "ShimmerHall", ) diff --git a/lib/audioeffects/rebuilt/airspace.py b/lib/audioeffects/rebuilt/airspace.py new file mode 100644 index 0000000..6bd5d23 --- /dev/null +++ b/lib/audioeffects/rebuilt/airspace.py @@ -0,0 +1,605 @@ +"""`AirSpace` - a tape echo sent into a chamber, with the dry beside it. + +You send your signal to a tape echo whose repeats feed an echo chamber, and +your dry signal comes back exactly as it went in: the filters, the drive and +the tape act only on the send. + +Time (20 to 500 ms) is the gap between repeats and Feedback (0 to 0.85) how +much of each repeat goes round again. Loop Top (1 to 16 kHz) and Loop Bass +(20 Hz to 1 kHz) filter the loop, so each repeat is darker than the last, +and Drive (0 to 1) saturates it. Top Cut (1 to 16 kHz) darkens everything +sent to the chamber, once. Low Cut (20 to 500 Hz), Decay (0.3 to 10 s) and +Damping (500 Hz to 16 kHz) set the chamber. Mix (0 to 2) is +`audiodelays.Echo`'s: the dry at unity up to 1, the chamber alone at 2. + +More Feedback makes the chamber's tail longer: at Time 500 ms, Feedback 0.85 +at least triples it. At the defaults you hear the repeats as a longer +chamber, not as a rhythm of echoes. + +Move Time while it plays and the repeats bend in pitch, the way tape does: +down while it lengthens, up while it shortens. Glide (1 to 10 s) is how long +a move across the whole Time range takes, so it sets how far they bend: 12 % +at the default of 4 s. + +Keep your input at or under -17 dBFS: at Mix 1 the dry and the chamber are +both at unity and add, so a hotter input can reach the rail. That holds on +every patch and with any one macro at either end. Open two or more of +Feedback, Decay and Time together and the chamber builds further, by up to +25 dB: keep your input at or under -26 dBFS there. Latency is zero. `reset()` empties the tape and the chamber and restores patch 0, whose +Time of 111 ms lands at once instead of gliding. + +The patches: Chamber Send (the defaults), Short Send, Long Send, Dark Loop, +Bright Chamber, Driven Tape, Plain Chamber. + +**Limits** + +A control that jumps makes the output step: move it in small steps from the +host if you need it smooth. Time is the exception: it glides. + +A patch that changes Time glides there too, and the repeats bend while it +travels. + +At the defaults the repeats add only a little length to the tail; the +stretch comes with Feedback above about 0.6 or a long Time. + +At a short Time with high Feedback the repeats are heard as a flutter, not a +longer chamber. + +There is always at least 20 ms between the dry and the chamber. + +The loop's filters are gentle, 6 dB per octave, and the Top Cut's is +steeper: there is no loop bell, no presence EQ and no wow. Drive's level is +not compensated. The chamber's size is fixed, there is no tempo sync, and a mono source gets a +mono rack. + +At Mix 0 the output is your input, byte for byte, and the tail that was +ringing when Mix went to 0 is gone when it comes back. + +When your source ends, the tail rings out as it would on silence. +""" + +VENDOR = "PyDevices" + +from array import array + +import audiocore + +from .. import _component + +try: + from .digitaldelay import laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import laps_to_zero, whole_frames +try: + from .tapedelay import tone_excess +except ImportError: # pragma: no cover - after it lands + from ..tapedelay import tone_excess +# The module, not the class: a Component class at this module's top level +# would be listed as a second provider here (`rebuilt.known()`). +try: + from . import reverb as _reverb +except ImportError: # pragma: no cover - after it lands + from .. import reverb as _reverb + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None +try: + import audiobiquad +except ImportError: # pragma: no cover - a stock board + audiobiquad = None +try: + import audioroute +except ImportError: # pragma: no cover - a stock board + audioroute = None +try: + import audiomixer +except ImportError: # pragma: no cover - a stock board + audiomixer = None + + +#: Frames per pull: the palette's block. +BLOCK = 256 + +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 500.0 + +#: The tape's line: Time's top plus headroom for the node's two-frame clamp +#: (`audiodsp_feedback_delay.c:156-159`). +LINE_MS = 505.0 + +#: How far a move across the whole Time range runs, in ms: Glide is the +#: time it takes, so `delay_slew` is this over Glide. +FULL_RANGE_MS = TIME_MAX_MS - TIME_MIN_MS + +FEEDBACK_MAX = 0.85 + +(TIME_I, FEEDBACK_I, GLIDE_I, DRIVE_I, LOOP_TOP_I, LOOP_BASS_I, TOP_CUT_I, + LOW_CUT_I, DECAY_I, DAMPING_I, MIX_I) = range(11) + +#: The constructor defaults, in macro order; NaN falls back to these. +DEFAULTS = (111.0, 0.375, 4000.0, 0.2, 6000.0, 100.0, 5000.0, 100.0, 1.8, + 4000.0, 0.35) + +#: Each default's step on the 0-127 grid (`_component.macro_of`): patch 0. +#: While a macro that arrived as a whole step (the seed, a patch, a whole +#: `set_macro`) sits on its default's step, the default itself plays, so a +#: fresh instance, patch 0 and a host echoing `get_macro` all play exactly +#: 111 ms, not the step's 112.08. A constructor value that is not the +#: default is not a step and plays as given, even where it lands on one. +DEFAULT_STEPS = (68, 56, 76, 25, 82, 52, 74, 64, 65, 76, 22) + +#: How close a 0..1 position must come to a default's step to play the +#: default: far under one grid step (1/127), far over a float's rounding. +STEP_TOLERANCE = 1e-6 + +#: The chamber's indices in `Reverb`'s macro table that this rack drives. +REVERB_DECAY_I = 1 +REVERB_DAMPING_I = 5 +REVERB_LOW_CUT_I = 7 + +#: The chamber at this rack's defaults on `Reverb`'s grid: Character 85 +#: (chamber), Decay 65, Size 64 (1.004), Predelay 0 (the tape is the +#: predelay), Diffusion 99 (0.70), Damping 76, Bandwidth 127 (Top Cut is the +#: band), Low Cut 64, Mod Depth 19 (0.30 ms), Mod Rate 71 (0.89 Hz), Width +#: 102 (0.80), Tone 64 (0 dB), Mix 127 (the tail alone). +CHAMBER_PATCH = (85, 65, 64, 0, 99, 76, 127, 64, 19, 71, 102, 64, 127) + +#: Which of the rack's macros drive which of the chamber's. +_CHAMBER_MACROS = ((LOW_CUT_I, REVERB_LOW_CUT_I), (DECAY_I, REVERB_DECAY_I), + (DAMPING_I, REVERB_DAMPING_I)) + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +def slew_of(glide_ms): + """`delay_slew` for a Glide: the whole Time range over Glide.""" + return FULL_RANGE_MS / float(glide_ms) + + +def _chamber_options(): + """`Reverb`'s constructor options at `CHAMBER_PATCH`'s grid values, so + the Tank is cut once, as the chamber, and patch 0 moves no line.""" + spans = _reverb.Reverb._MACRO_RANGES + names = ("decay", "size", "predelay_ms", "diffusion", "damping_hz", + "bandwidth_hz", "low_cut_hz", "mod_depth_ms", "mod_rate_hz", + "width", "tone_db", "mix") + options = {"character": "chamber", "patch": 0} + for index, name in zip(range(1, 13), names): + mode = _reverb.Reverb.MACRO_MODES.get(index, "UNIPOLAR") + position = _component.position_of_midi(mode, CHAMBER_PATCH[index]) + options[name] = _component.macro_value(spans[index], position) + return options + + +class AirSpace(_component.Component): + """A tape echo sent into a chamber. The module docstring is the + player's page.""" + + NAME = 'AirSpace' + DISPLAY_NAME = 'Air Space' + CATEGORIES = ('Effect Rack', 'Reverb') + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho", "audiobiquad", "audioroute", "audioverb") + + class _Chamber(_reverb.Reverb): + """The rack's chamber: `Reverb` whose only patch is the chamber at + the rack's settings, so its own `reset()` lands there without a + re-cut. Nested, so the module holds one provider.""" + + PATCHES = {0: ("Chamber", CHAMBER_PATCH)} + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + INPUT_CEILING_DBFS = -17.0 + TAIL_SAMPLES = None + + #: The exact-value flags, which a rack's reset puts back with the knobs. + _KEPT = ("_snap",) + + MACRO_LABELS = ("Time", "Feedback", "Glide", "Drive", "Loop Top", + "Loop Bass", "Top Cut", "Low Cut", "Decay", "Damping", + "Mix") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + 9: "UNIPOLAR", + 10: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (1000.0, 10000.0, "log"), # 2 Glide, ms across the range + (0.0, 1.0), # 3 Drive, the loop's soft clip + (1000.0, 16000.0, "log"), # 4 Loop Top, Hz + (20.0, 1000.0, "log"), # 5 Loop Bass, Hz + (1000.0, 16000.0, "log"), # 6 Top Cut, Hz + (20.0, 500.0, "log"), # 7 Low Cut, Hz + (0.3, 10.0, "log"), # 8 Decay, s + (500.0, 16000.0, "log"), # 9 Damping, Hz + (0.0, 2.0), # 10 Mix; dry at unity to 1 + ) + + #: `_component.macro_of` of the card's settings; patch 0 is the + #: constructor's defaults on the grid (`DEFAULT_STEPS`). + PATCHES = { + 0: ("Chamber Send", DEFAULT_STEPS), + 1: ("Short Send", (43, 45, 76, 25, 82, 52, 82, 64, 50, 76, 22)), + 2: ("Long Send", (107, 90, 76, 25, 82, 52, 74, 64, 77, 76, 22)), + 3: ("Dark Loop", (68, 127, 76, 25, 32, 75, 74, 64, 65, 76, 22)), + 4: ("Bright Chamber", + (68, 56, 76, 25, 82, 52, 114, 43, 72, 110, 22)), + 5: ("Driven Tape", (68, 75, 76, 102, 64, 52, 74, 64, 65, 76, 22)), + 6: ("Plain Chamber", (0, 0, 76, 25, 82, 52, 74, 64, 65, 76, 22)), + } + + def _build(self, time_ms=111.0, feedback=0.375, glide_ms=4000.0, + drive=0.2, loop_top_hz=6000.0, loop_bass_hz=100.0, + top_cut_hz=5000.0, low_cut_hz=100.0, decay=1.8, + damping_hz=4000.0, mix=0.35, patch=None): + rate = self._sample_rate + channels = self._channel_count + options = (time_ms, feedback, glide_ms, drive, loop_top_hz, + loop_bass_hz, top_cut_hz, low_cut_hz, decay, damping_hz, + mix) + values = [] + for index, value in enumerate(options): + span = self._MACRO_RANGES[index] + values.append(_between(_option(value, DEFAULTS[index]), + span[0], span[1])) + + #: Per macro, whether its position arrived as a whole grid step and + #: so plays the default on the default's step (`_value`). The + #: constructor clears it for a value that is not the default. + self._snap = [values[index] == DEFAULTS[index] + for index in range(len(values))] + #: True once every macro has been seeded; before that a macro is + #: stored and not pushed. + self._ready = False + #: True once the voices have been played (`_wire`). + self._wired = False + #: What the nodes were last handed, by name. + self._handed = {} + self._frames = 1 + #: The longest delay, in frames, the read head may still be at: a + #: shorter Time walks the head down, so the old one holds until the + #: tape is next emptied. + self._reach = 1 + + # 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 + # whatever part of a source buffer it holds plays next whichever + # way the output goes. Its reset forwards nothing. + self._adapter = audioroute.MidSide( + self._source, width=1.0, sample_rate=rate, channel_count=channels) + # A MidSide with no source: a block of zeros per pull, which the + # voices prime from when they are wired (`_wire`). + self._hush = audioroute.MidSide( + width=1.0, sample_rate=rate, channel_count=channels) + self._in = audioroute.Port(self._hush) + self._split = audioroute.Splitter(self._in, taps=2) + self._fd = audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=LINE_MS, + delay_frames=whole_frames(DEFAULTS[TIME_I], rate), + feedback=0.0, mix=2.0, damping_hz=0.0, cut_hz=0.0, + loop_drive=0.0, delay_slew=0.0) + self._fd.play(self._split.tap(1)) + self._lp = audiobiquad.Biquad( + mode=audiobiquad.LOW_PASS, frequency=self._hz(DEFAULTS[TOP_CUT_I]), + sample_rate=rate, channel_count=channels) + self._lp.play(self._fd) + self._chamber = self._Chamber(self._lp, **_chamber_options()) + self._mixer = audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * channels * 4, + channel_count=channels, bits_per_sample=16, samples_signed=True, + sample_rate=rate) + # The output: a width-1 MidSide over the mixer, byte for byte, whose + # reset forwards nothing. A host's reset of the output (a mixer + # voice's `play()` does one) would otherwise reach the Mixer, and + # CircuitPython's own Mixer drops frames there that audiodsp's + # keeps. + self._tail = audioroute.MidSide( + self._mixer, width=1.0, sample_rate=rate, channel_count=channels) + #: Where `audiocore.get_buffer` is compiled out, the one-voice + #: mixer `_pull` primes from `_tail`; built the first time it is + #: needed. + self._puller = None + self._silence = audiocore.RawSample( + array("h", bytes(2 * 2 * channels)), sample_rate=rate, + channel_count=channels) + + # Walked tail first: the Mixer and the Splitter are not reset (a + # voice resets its source, and neither voice holds a block between + # two pulls), the chamber by its own reset, the filter and the tape + # by their clears. Nothing here reaches the borrowed source. + self._own(self._silence, reset=False) + self._own(self._adapter, reset=False) + self._own(self._hush, reset=False) + self._own(self._in, reset=False) + self._own(self._split, reset=False) + self._own(self._fd, reset=self._clear_tape) + self._own(self._lp, reset=self._lp.clear) + self._own(self._chamber, reset=self._reset_chamber, + deinit=self._chamber.deinit) + self._own(self._mixer, reset=False) + self._own(self._tail, reset=False) + self._output = self._adapter + + self._init_macros(tuple(values)) + # A default seeds its own step, so a fresh instance reads patch 0. + for index in range(len(values)): + if values[index] == DEFAULTS[index]: + self._macros[index] = _component.position_of_midi( + "UNIPOLAR", DEFAULT_STEPS[index]) + self._ready = True + for index in range(len(values)): + self._apply_macro(index, self._macros[index]) + if patch is not None: + self.program_change(patch) + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + """Macro `index` in its own units: the default itself on the + default's step when the position arrived as a step, the span's law + elsewhere.""" + position = self._macros[index] + step = DEFAULT_STEPS[index] / 127.0 + if self._snap[index] and abs(position - step) <= STEP_TOLERANCE: + return DEFAULTS[index] + return _component.macro_value(self._MACRO_RANGES[index], position) + + def macro(self, index): + """What macro `index` currently stands for, in its own units: what + plays, the default itself on the default's step.""" + self._check_live() + return self._value(index) + + def set_macro(self, index, value, channel=0, note_id=-1, + sample_position=0): + """Set macro `index` from the 0-127 scale. A whole step is a step + and plays the default on the default's step; a fraction is a value + of its own. A call that leaves the position where it is (a host + echoing `get_macro`) changes neither.""" + self._check_live() + self._macro_index(index) + flags = list(self._snap) + try: + position = _component.position_of_midi("UNIPOLAR", value) + if abs(position - self._macros[index]) > STEP_TOLERANCE: + fraction = value % 1.0 + limit = 127.0 * STEP_TOLERANCE + self._snap[index] = (fraction <= limit + or fraction >= 1.0 - limit) + _component.Component.set_macro( + self, index, value, channel, note_id, sample_position) + except Exception: + self._snap = flags + raise + + def _apply_positions(self, positions, patch_index): + """A patch lands every macro on a whole step, so every macro plays + the default on the default's step again; a rack's restore (while + `_seeding`) keeps the flags it put back (`_KEPT`). The one path a + patch and a restore share.""" + flags = list(self._snap) + if not self._seeding: + self._snap = [True] * len(flags) + try: + _component.Component._apply_positions(self, positions, + patch_index) + except Exception: + self._snap = flags + raise + + def _record(self): + positions, patch_index, kept = _component.Component._record(self) + return (positions, patch_index, (list(kept[0]),)) + + def _restore(self, record): + # Copy the flags: `set_macro` changes them in place, and the + # rack's record must stay what the rack built. + positions, patch_index, kept = record + _component.Component._restore( + self, (positions, patch_index, (list(kept[0]),))) + + def _dry_level(self, mix): + """The dry voice's level, min(1, 2 - Mix). At one channel unity is + handed as 1 - 2^-15, which every Mixer this class meets passes + through exactly: CircuitPython's own `audiomixer` turns 1.0 into + 32768 / 32767 and puts the samples nearest the rails one LSB hot. + Two channels keep 1.0 (`MultiTapDelay._dry_level` has why).""" + level = 1.0 if mix <= 1.0 else 2.0 - mix + if level >= 1.0 and self._channel_count == 1: + return 1.0 - 2.0 ** -15 + return level + + def _levels(self): + mix = self._value(MIX_I) + voices = self._mixer.voice + voices[0].level = self._dry_level(mix) + voices[1].level = mix if mix < 1.0 else 1.0 + + # -- hooks a planted fault overrides -------------------------------- + + def _hand_tape(self, **settings): + """Everything the tape node is handed goes through here.""" + self._handed.update(settings) + self._fd.set(**settings) + + def _wet_source(self): + """What the wet voice plays: the chamber.""" + return self._chamber.output + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del position + if not self._ready: + return + value = self._value(index) + if index == TIME_I: + frames = whole_frames(value, self._sample_rate) + if frames < 1: + frames = 1 + self._frames = frames + if frames > self._reach: + self._reach = frames + self._hand_tape(delay_frames=frames) + elif index == FEEDBACK_I: + self._hand_tape(feedback=value) + elif index == GLIDE_I: + self._hand_tape(delay_slew=slew_of(value)) + elif index == DRIVE_I: + self._hand_tape(loop_drive=value) + elif index == LOOP_TOP_I: + self._hand_tape(damping_hz=self._hz(value)) + elif index == LOOP_BASS_I: + self._hand_tape(cut_hz=self._hz(value)) + elif index == TOP_CUT_I: + frequency = self._hz(value) + self._handed["top_cut_hz"] = frequency + self._lp.frequency = frequency + elif index == MIX_I: + self._route() + else: + self._push_chamber(index) + + def _push_chamber(self, index): + """Hand the chamber the rack's Low Cut, Decay or Damping, as the + unquantized position of the value the rack plays.""" + for ours, theirs in _CHAMBER_MACROS: + if ours == index: + position = _component.macro_position( + self._MACRO_RANGES[ours], self._value(ours)) + self._chamber.set_macro(theirs, 127.0 * position) + self._handed["chamber_%d" % theirs] = position + + def _route(self): + """Mix 0 points the output at the input adapter (a wire); above 0 at + the mixer, wiring it the first time and emptying what the graph + held when it was routed around.""" + if self._value(MIX_I) <= 0.0: + self._route_around(self._adapter) + return + self._levels() + if not self._wired: + self._wire() + else: + self._rejoin() + self._output = self._tail + + def _wire(self): + """Play the voices, the first time Mix is above 0. + + A voice's `play()` primes a block from its source. With the + Splitter on `_hush` that block is zeros, and one pull of the mixer + hands it out, so wiring takes nothing from the borrowed source and + leaves no voice holding a block: whatever the adapter has handed + out, the output has played. The graph is then emptied, so the + chamber's modulation and the tape start where a `reset()` leaves + them. The voices first play silence for one pull, which opens + their level gates where `audiocore.get_buffer` exists + (`_component.open_level_gates`).""" + voices = self._mixer.voice + for voice in voices: + voice.play(self._silence, loop=True) + self._pull() + voices[0].play(self._split.tap(0)) + voices[1].play(self._wet_source()) + self._pull() + self._in.play(self._adapter) + self._clear_nodes() + self._wired = True + + def _pull(self): + """Pull one block from the mixer and drop it. + + Where `audiocore.get_buffer` is compiled out (CircuitPython's + default board builds), a voice of a one-voice mixer of our own + plays `_tail` and stops: its `play()` primes one block, which pulls + the mixer once. `_tail` forwards no reset, so the play rewinds + nothing behind it.""" + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(self._mixer) + return + if self._puller is None: + # Nothing ever pulls this mixer, so its own buffers are the + # smallest that hold a frame. + self._puller = self._own(audiomixer.Mixer( + voice_count=1, buffer_size=16, + channel_count=self._channel_count, bits_per_sample=16, + samples_signed=True, sample_rate=self._sample_rate), + reset=False) + voice = self._puller.voice[0] + voice.play(self._tail) + voice.stop() + + def _clear_tape(self): + """Empty the tape. The node then lands its read head on the next + Time it is handed instead of gliding there.""" + self._fd.clear() + self._reach = self._frames + + def _reset_chamber(self): + """The chamber's own reset lands on its one patch; the rack's three + chamber macros then go back in.""" + self._chamber.reset() + if self._ready: + for ours, _theirs in _CHAMBER_MACROS: + self._push_chamber(ours) + + # -- the contract's reads ------------------------------------------ + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound: the tape's repeats, the Top Cut's ring and the + chamber's own tail, end to end.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`. + + `laps_to_zero` laps at the Feedback in force, each as long as the + longest delay the read head may be at plus one frame for the + interpolated read plus the memory of the loop's two one-poles; + then `_component.biquad_ring` of the Top Cut; then the chamber's + `tail_samples`. 0 at Mix 0, where the output is the input.""" + if self._value(MIX_I) <= 0.0: + return 0 + rate = self._sample_rate + top_memory, excess = tone_excess(self._handed["damping_hz"], rate) + bass_memory, _bass = tone_excess(self._handed["cut_hz"], rate) + laps = laps_to_zero(self._handed["feedback"], excess) + repeats = laps * (self._reach + 1 + top_memory + bass_memory) + ring = _component.biquad_ring(self._lp) or 0 + return int(repeats + ring + self._chamber.tail_samples) diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py index e10dcbc..7214d73 100644 --- a/lib/audioeffects/rebuilt/analogdelay.py +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -216,6 +216,8 @@ class AnalogDelay(_component.Component): VERSION = '0.1.0' TIER = _component.AUDIODSP + #: The exact constructor values a rack's reset puts back with the knobs. + _KEPT = ('_time_exact',) REQUIRES = ("audioecho",) CAPABILITIES = ("tempo_sync",) @@ -413,18 +415,16 @@ def _apply_macro(self, index, position): if not self._deferred: self._refresh() - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then refresh once, so Time is read + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh once, so Time is read against the new patch's Sync and moves (and walks) once.""" self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = False - if type(self).PATCHES.get(index) is not None: - self._refresh() + self._refresh() def reset(self): _component.Component.reset(self) diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index a8baaff..5780aaf 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -471,20 +471,19 @@ 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 program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then synthesize once. In measured + 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 and inert. Inside the constructor the one synthesis is the constructor's own.""" outer = self._deferred self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = outer - if not outer and type(self).PATCHES.get(index) is not None: + if not outer: self._refresh() def _macro_index(self, index): diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index e8e8cfd..e1a4fff 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -311,6 +311,8 @@ class DigitalDelay(_component.Component): VERSION = '0.1.0' TIER = _component.AUDIODSP + #: The exact constructor values a rack's reset puts back with the knobs. + _KEPT = ('_glide_exact',) REQUIRES = ("audioecho",) CAPABILITIES = ("tempo_sync",) @@ -514,20 +516,18 @@ def _apply_macro(self, index, position): if not self._deferred: self._refresh() - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then refresh once. The base applies + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh once. The base applies the macros in index order, so a refresh per macro would read Time against the outgoing patch's Sync and, with a host transport, keep its synced Time instead of the new patch's own.""" self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = False - if type(self).PATCHES.get(index) is not None: - self._refresh() + self._refresh() def reset(self): _component.Component.reset(self) diff --git a/lib/audioeffects/rebuilt/harmonizer.py b/lib/audioeffects/rebuilt/harmonizer.py new file mode 100644 index 0000000..55cdd2d --- /dev/null +++ b/lib/audioeffects/rebuilt/harmonizer.py @@ -0,0 +1,540 @@ +"""`Harmonizer` - a harmony of pitch-shifted voices. + +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 graph works is told +where it is built, in `Harmonizer._build`. +""" + +VENDOR = "PyDevices" + +import math + +import audiocore + +from .. import _component + +try: + import audiodelays + import audiomixer + import audioroute +except ImportError: # pragma: no cover - a stock board + audiodelays = audiomixer = audioroute = None + + +PITCH_I = 0 +VOICE_I = (1, 2, 3) +LEVEL_I = (4, 5, 6) +DETUNE_I = 7 +SPREAD_I = 8 +MIX_I = 9 + +#: Frames a mixer pull hands out, and so what every node and tap takes per +#: pull. +BLOCK = 256 + +#: The Level span, in dB. MIDI 1 is -40 dB and 127 is 0 dB, 40/126 dB a +#: step; MIDI 0 sits one step below -40 and turns the voice off. +LEVEL_TOP_DB = 0.0 +LEVEL_FLOOR_DB = -40.0 +LEVEL_OFF_DB = LEVEL_FLOOR_DB - (LEVEL_TOP_DB - LEVEL_FLOOR_DB) / 126.0 + +DETUNE_MAX_CENTS = 25.0 + +WINDOW_MIN_MS = 5.0 +WINDOW_MAX_MS = 60.0 +#: 1024 frames at 48 kHz. +WINDOW_DEFAULT_MS = 1024.0 / 48.0 + +#: The constructor's macro defaults, which NaN falls back to (patch 0). +DEFAULTS = (0.0, 4.0, 7.0, -12.0, -6.0, -6.0, -9.0, 0.0, 0.0, 1.0) + +#: The shifter reads its line at `floor(256 * 2^(semitones / 12))` 256ths +#: of a frame per frame. +STEPS = 256 + + +def ratio_step(semitones): + """The shifter's nearest read-rate step, in 256ths, to an interval.""" + return int(math.floor(STEPS * 2.0 ** (semitones / 12.0) + 0.5)) + + +def step_semitones(step): + """The interval at the centre of read-rate step `step`: the node's + floor lands on `step` from it, with half a step to spare either way, + in single precision as in double.""" + return 12.0 * math.log((step + 0.5) / STEPS) / math.log(2.0) + + +def whole(value): + """A Pitch or Voice value as the whole semitone it plays.""" + return int(math.floor(value + 0.5)) + + +def mix_gains(mix): + """(dry, voice) gain at Mix `mix`: the dry at unity up to 1, then + 2 - Mix; each voice at Mix, capped at 1.""" + mix = min(2.0, max(0.0, mix)) + return (1.0 if mix <= 1.0 else 2.0 - mix), min(mix, 1.0) + + +def level_gain(db): + """A Level in dB as a gain; 0 for the bottom step, which is off.""" + if db < LEVEL_FLOOR_DB: + return 0.0 + return 10.0 ** (db / 20.0) + + +def _number(value, default): + try: + value = float(value) + except (TypeError, ValueError): + raise ValueError("expected a number, not %r" % (value,)) + if value != value: + return default + return value + + +class Harmonizer(_component.Component): + """Three pitch shifters, each a fixed interval from your note, blended + under the dry: the rig of three Eventide H910s played from one + keyboard, so one singer is three-part harmony. + + **The controls.** Voice 1 to Voice 3 set each voice's interval in whole + semitones, an octave either way, and Pitch moves all three together, so + a voice reaches two octaves either way. + Level 1 to Level 3 run from -40 to 0 dB, and the bottom step turns the + voice off. + Detune pulls voice 1 down and voice 2 up, by up to 25 cents. + Spread pans voice 1 left and voice 2 right; the dry and voice 3 stay + in the centre. + Mix holds the dry at unity up to 1 while the voices rise to their + Levels; from 1 to 2 the dry fades, and at 2 the voices play alone. + Constructor values land on the nearest of each knob's 128 steps; + `window_ms`, 5 to 60 ms (21.33 by default), is fixed for good. + + **What you hear.** Each voice is its own machine: turning one on or + off never changes the dry or the other voices. + Each voice sits on the closest pitch its shifter can make: within 3.4 + cents of its interval from unison up, 6.8 cents down to an octave + below and 13.7 cents below that. + So Detune moves in steps of about 7 cents near unison, and voices 1 + and 2 sit evenly apart only to within a step. + 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. + 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 + it played before. + + **Limits.** It moves notes by fixed intervals and does not listen for + the key. + Each voice rounds off peaks above about -1.4 dBFS. + The voices add to the dry: at patch 0 an input that peaks at or below + 13 900 (-7.4 dBFS) cannot reach full scale. + Patch 6 is patch 0 without the octave below, for a board that cannot + run three voices in time. + The shifter reads its line without interpolation, which you may hear + as grit on a shifted voice. + On a mono source Spread does nothing. + An interval move leaves each voice's splice where it was. + A control that jumps makes the output step: move it in small steps + from the host if you need it smooth. + + **Wire, latency, tail.** At Mix 0 the output is the input, byte for + byte. + There is no latency: the dry is a wire, and each shifted voice trails + it by between the crossfade, a sixteenth of the window, and the whole + window. + `tail_samples` covers the window, the crossfade and one block, and is + 0 at Mix 0 or with every voice off. + `reset()` empties the three lines, starts their splices where a new + instance's start, and returns to patch 0. + On CircuitPython's own pitch shifter a reset does not restart the + splices. + The class never reads the host's tempo. + """ + + NAME = 'Harmonizer' + DISPLAY_NAME = 'Harmonizer' + CATEGORIES = ('Pitch Shift',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioroute",) + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + #: The loudest sum over every setting is the dry at unity and all three + #: Levels at 0 dB at Mix 1: 4.0 times the input (patch 1 with Level 3 + #: at 0 dB sums 3.44), so -12.1 dBFS keeps it inside full scale + #: (4.0 times is 12.04 dB, and -12.0 would leave the sum 0.5 % over). + INPUT_CEILING_DBFS = -12.1 + + MACRO_LABELS = ("Pitch", "Voice 1", "Voice 2", "Voice 3", "Level 1", + "Level 2", "Level 3", "Detune", "Spread", "Mix") + MACRO_MODES = {0: "BIPOLAR", 1: "BIPOLAR", 2: "BIPOLAR", 3: "BIPOLAR", + 4: "UNIPOLAR", 5: "UNIPOLAR", 6: "UNIPOLAR", + 7: "UNIPOLAR", 8: "UNIPOLAR", 9: "UNIPOLAR"} + _MACRO_RANGES = ( + (-12.0, 12.0), # 0 Pitch, semitones + (-12.0, 12.0), # 1 Voice 1, semitones + (-12.0, 12.0), # 2 Voice 2 + (-12.0, 12.0), # 3 Voice 3 + (LEVEL_OFF_DB, LEVEL_TOP_DB), # 4 Level 1, dB; MIDI 0 = off + (LEVEL_OFF_DB, LEVEL_TOP_DB), # 5 Level 2 + (LEVEL_OFF_DB, LEVEL_TOP_DB), # 6 Level 3 + (0.0, DETUNE_MAX_CENTS), # 7 Detune, cents + (0.0, 1.0), # 8 Spread + (0.0, 2.0), # 9 Mix; dry at unity to 1 + ) + + #: `_component.macro_of` of the card's settings: +3/+4/+5/+7/+12 are + #: 80/85/90/101/127 and -12 is 0; -3/-6/-9/-12 dB are 118/108/99/89 and + #: off is 0; 14 cents is 71; Spread 0.5 and Mix 1 are 64. + PATCHES = { + 0: ("Third and fifth above, octave below", + (64, 85, 101, 0, 108, 108, 99, 0, 0, 64)), + 1: ("Octave below, doubled", + (64, 0, 0, 64, 118, 118, 0, 71, 64, 64)), + 2: ("Wide unison", + (64, 64, 64, 64, 108, 108, 0, 71, 127, 64)), + 3: ("Fourths and fifths", + (64, 90, 101, 127, 99, 99, 89, 0, 64, 64)), + 4: ("Octave up and down", + (64, 127, 0, 64, 108, 108, 0, 0, 64, 64)), + 5: ("Minor triad, voices alone", + (64, 80, 101, 64, 108, 108, 108, 0, 64, 127)), + 6: ("Third and fifth - lean", + (64, 85, 101, 0, 108, 108, 0, 0, 0, 64)), + } + + def _build(self, pitch=0.0, voice1=4.0, voice2=7.0, voice3=-12.0, + level1_db=-6.0, level2_db=-6.0, level3_db=-9.0, + detune_cents=0.0, spread=0.0, mix=1.0, + window_ms=WINDOW_DEFAULT_MS, patch=None): + grid = self._grid((pitch, voice1, voice2, voice3, level1_db, + level2_db, level3_db, detune_cents, spread, mix)) + window_ms = _number(window_ms, WINDOW_DEFAULT_MS) + self._window_ms = min(WINDOW_MAX_MS, max(WINDOW_MIN_MS, window_ms)) + rate = self._sample_rate + channels = self._channel_count + self._ready = False + self._armed = False + #: Which voices play their shifter; the others play their tap, silenced. + self._on = [True, True, True] + self._puller = None + + # The borrowed source's only reader: a width-1 MidSide is the source + # byte for byte, in whole 256-frame blocks whatever buffers the + # source hands out. At Mix 0 the output is this node, and the + # Splitter reads it too, so routing around the graph and back never + # skips or repeats a source frame. + self._head = self._own(audioroute.MidSide( + self._source, width=1.0, sample_rate=rate, + channel_count=channels), reset=False) + # A MidSide with no source hands out one block of zeros per pull. + # The Splitter reads it while the voices are armed (`_arm`), so + # arming takes nothing from the source. + self._hush = self._own(audioroute.MidSide( + width=1.0, sample_rate=rate, channel_count=channels), + reset=False) + self._in = self._own(audioroute.Port(self._hush), reset=False) + split = self._own(audioroute.Splitter(self._in, taps=4), + reset=False) + self._taps = [split.tap(i) for i in range(4)] + + # Three shifters, each on its own tap: each voice is its own + # machine, with its own line and its own splice rate. + frames = self._window_frames() + self._frames = frames + self._overlap = frames // 16 + self._shifters = [] + for k in range(3): + shifter = self._own(self._shifter(frames, self._overlap)) + shifter.play(self._taps[k + 1], loop=True) + self._shifters.append(shifter) + + # Each mixer voice plays a Port, so switching a voice between its + # shifter and its silenced tap re-points the port and primes nothing. + self._ports = [self._own(audioroute.Port(self._taps[0]), + reset=False)] + for k in range(3): + self._ports.append(self._own(audioroute.Port(self._shifters[k]), + reset=False)) + # What a voice that is off plays: its tap, held at level 0 for good. + self._drains = [] + self._feeds = [] + for k in range(3): + feed = self._own(audioroute.Port(self._hush), reset=False) + drain = self._own(audiomixer.Mixer( + voice_count=1, **self._pcm(1024 * channels)), reset=False) + drain.voice[0].level = 0.0 + drain.voice[0].play(feed, loop=True) + # Its play() primed a block of the hush; one pull drops it, so + # the drain holds nothing when it first reads its tap. + drop = audiomixer.Mixer(voice_count=1, **self._pcm(16)) + drop.voice[0].play(drain) + drop.voice[0].stop() + drop.deinit() + self._feeds.append(feed) + self._drains.append(drain) + # Not reset by the walk: no voice holds a block between two pulls. + self._mixer = self._own(audiomixer.Mixer( + voice_count=4, **self._pcm(1024 * channels)), reset=False) + # A voice's play() primes a block; with the Splitter on the hush it + # is zeros, and one pull drops it. + for i in range(4): + self._mixer.voice[i].play(self._ports[i], loop=True) + # The output: a width-1 MidSide, byte for byte, whose reset forwards + # nothing, so a host's reset of the output never reaches the Mixer. + self._tail = self._own(audioroute.MidSide( + self._mixer, width=1.0, sample_rate=rate, + channel_count=channels), reset=False) + self._output = self._tail + self._pull() + + self._init_macros(tuple( + _component.macro_value(self._MACRO_RANGES[i], + _component.position_of_midi( + self._mode(i), grid[i])) + for i in range(10))) + # Land each knob exactly on its step, as patch 0 does. + for i in range(10): + self._macros[i] = _component.position_of_midi(self._mode(i), + grid[i]) + # A constructor patch lands before the voices are first armed, so + # their level gates open at the patch's levels. + if patch is not None: + self.program_change(patch) + self._ready = True + self._refresh() + + def _grid(self, asked): + """Each constructor value as its knob's nearest MIDI step: NaN is + the default, anything past a stop is that stop, and a Level below + -40 dB is off.""" + grid = [] + for i in range(10): + value = _number(asked[i], DEFAULTS[i]) + span = self._MACRO_RANGES[i] + if i in LEVEL_I and value < LEVEL_FLOOR_DB: + grid.append(0) + continue + low = LEVEL_FLOOR_DB if i in LEVEL_I else span[0] + value = min(span[1], max(low, value)) + grid.append(_component.macro_of(span, value, self._mode(i))) + return grid + + # -- the parts; each a method, so a test can plant a fault in one ----- + + def _window_frames(self): + """The window in frames, from milliseconds at the running rate.""" + return max(1, int(self._window_ms * self._sample_rate / 1000.0 + + 0.5)) + + def _shifter(self, frames, overlap): + width = 2 * self._channel_count + return audiodelays.PitchShift( + semitones=0.0, mix=1.0, window=frames * width, + overlap=overlap * width, **self._pcm(BLOCK * width)) + + def _voice_source(self, k): + """What voice `k` (0, 1, 2) plays while it is on: its shifter.""" + return self._shifters[k] + + def _off_source(self, k): + """What voice `k` plays while it is off: its tap through a + one-voice mixer that has only ever been at level 0. The voice reads + the tap, which keeps the split even and does not pull its shifter, + and plays silence from the first frame; the bare tap would play the + dry at the voice's old level until the voice's level gate closed. + The mixer's own voice never plays again, so nothing is primed from + the live tap when a voice goes off.""" + self._feeds[k].play(self._taps[k + 1]) + return self._drains[k] + + def _empty(self, k): + """Empty voice `k`'s line before it comes back on.""" + audiocore.reset_buffer(self._shifters[k]) + + def _semitones(self, k): + """What voice `k` (0, 1, 2) asks for: Pitch + Voice + its share of + Detune (voice 1 down, voice 2 up, voice 3 none).""" + detune = self._value(DETUNE_I) / 100.0 + share = (-detune, detune, 0.0)[k] + return (whole(self._value(PITCH_I)) + + whole(self._value(VOICE_I[k])) + share) + + def _node_semitones(self, asked): + """The nearest step the node can play, handed over at its centre.""" + return step_semitones(ratio_step(asked)) + + def _level(self, k): + return level_gain(self._value(LEVEL_I[k])) + + def _dry_level(self): + return mix_gains(self._value(MIX_I))[0] + + def _voice_level(self, k): + return self._level(k) * mix_gains(self._value(MIX_I))[1] + + def _pans(self): + spread = min(1.0, max(0.0, self._value(SPREAD_I))) + return (0.0, -spread, spread, 0.0) + + # -- the controls ---------------------------------------------------- + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _apply_macro(self, index, position): + del index, position + if self._ready: + self._refresh() + + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then set the graph + once.""" + ready = self._ready + self._ready = False + try: + _component.Component._apply_positions(self, positions, + patch_index) + finally: + self._ready = ready + if ready: + self._refresh() + + def reset(self): + """Empty the three lines, return their splices to a new node's + start, and restore patch 0 (or, under a rack, what the rack built + this with).""" + self._check_live() + self._clear_nodes() + self._stranded = False + # The voices are armed again, as the constructor arms them, the + # next time the output reaches the mixer. + self._armed = False + if self._reset_to is None: + self.program_change(0) + else: + self._restore(self._reset_to) + + def _refresh(self): + for k in range(3): + self._shifters[k].semitones = self._node_semitones( + self._semitones(k)) + self._route() + + def _wanted(self, k): + return self._level(k) > 0.0 + + def _levels(self): + voices = self._mixer.voice + pans = self._pans() + voices[0].level = self._dry_level() + for k in range(3): + voices[k + 1].level = self._voice_level(k) + if self._channel_count == 2: + for i in range(4): + voices[i].panning = pans[i] + + def _route(self): + """Mix 0 hands out the source; anything else plays the mixer, armed + again if the graph was left or reset.""" + if self._macros[MIX_I] <= 0.0: + self._route_around(self._head) + return + if self._rejoin() or not self._armed: + self._arm() + else: + self._switch() + self._output = self._tail + + def _switch(self): + """Turn voices on and off. A voice going off plays its tap through + a mixer held at level 0, so the split stays even, its shifter is + not pulled and the dry never leaks at the voice's old level; a + voice coming back empties its shifter's line, plays it, then takes + its level.""" + voices = self._mixer.voice + for k in range(3): + wanted = self._wanted(k) + if wanted == self._on[k]: + continue + if wanted: + self._empty(k) + self._ports[k + 1].play(self._voice_source(k)) + else: + voices[k + 1].level = 0.0 + self._ports[k + 1].play(self._off_source(k)) + self._on[k] = wanted + self._levels() + + def _arm(self): + """Arm the voices, the same way every time: with the Splitter on + the hush and every voice on its shifter, one pull of the mixer opens + the voices' level gates on zeros and leaves no voice and no shifter + holding a block. Then every line is emptied, which also puts its + splice where a new node's starts, the Splitter goes back to the + source and the voices that are off go to their taps. So a reset, a + return from Mix 0 and a voice turned back on all start their + shifters exactly as the constructor does.""" + self._levels() + self._in.play(self._hush) + for k in range(3): + self._ports[k + 1].play(self._voice_source(k)) + self._pull() + for k in range(3): + audiocore.reset_buffer(self._shifters[k]) + self._in.play(self._head) + for k in range(3): + self._on[k] = self._wanted(k) + if not self._on[k]: + self._ports[k + 1].play(self._off_source(k)) + self._armed = True + + def _pull(self): + """Pull one block from the mixer and drop it. + + Where `audiocore.get_buffer` is compiled out (CircuitPython's + default board builds), a voice of a one-voice mixer of our own + plays `_tail` and stops: its `play()` primes one block, which pulls + the mixer once. `_tail` forwards no reset, so the play rewinds + nothing behind it.""" + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(self._mixer) + return + if self._puller is None: + # Nothing ever pulls this mixer, so its own buffers are the + # smallest that hold a stereo frame. + self._puller = self._own(audiomixer.Mixer( + voice_count=1, **self._pcm(16)), reset=False) + voice = self._puller.voice[0] + voice.play(self._tail) + voice.stop() + + @property + def tail_samples(self): + """The shifters' lines, every one overwritten, and a block.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + if self._macros[MIX_I] <= 0.0: + return 0 + if not (self._wanted(0) or self._wanted(1) or self._wanted(2)): + return 0 + return self._frames + self._overlap + BLOCK + + @property + def window_ms(self): + """The window the instance was built with, in milliseconds.""" + self._check_live() + return self._window_ms diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index 209b40d..1a1bfce 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -564,19 +564,17 @@ def _apply_macro(self, index, position): if not self._deferred: self._refresh() - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then refresh once, so Time is never + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh once, so Time is never read against the outgoing patch's Sync and the graph re-plugs at most once.""" self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = False - if type(self).PATCHES.get(index) is not None: - self._refresh() + self._refresh() def _refresh(self): rate = self._sample_rate diff --git a/lib/audioeffects/rebuilt/octaver.py b/lib/audioeffects/rebuilt/octaver.py new file mode 100644 index 0000000..c378ea1 --- /dev/null +++ b/lib/audioeffects/rebuilt/octaver.py @@ -0,0 +1,569 @@ +"""`Octaver` - the analog octave-down. + +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 graph works is told +where it is built, in `Octaver._build`. +""" + +VENDOR = "PyDevices" + +from array import array + +import audiocore + +from .. import _component + +try: + import audiobiquad + import audiomath + import audiomixer + import audioroute + import audioshaper +except ImportError: # pragma: no cover - a stock board + audiobiquad = audiomath = audiomixer = audioroute = audioshaper = None + + +DIRECT_I, OCTAVE1_I, OCTAVE2_I, TRACKING_I, TONE1_I, TONE2_I, SENS_I = \ + range(7) + +#: The constructor defaults, which NaN falls back to (patch 0). +DEFAULTS = (1.0, 0.7, 0.0, 90.0, 324.0, 70.3, -80.0) + +#: The comparator's table: 4097 points over the shaper's -1..1, exact zero +#: across |i - 2048| <= 7, a tick of +-TICK out to |i - 2048| = 63, and the +#: rails beyond. Interpolating between two points of a one-point step is a +#: gain stage, which leaks an octave up and DC below Sensitivity; a dead band +#: many points wide reads exact zero. +#: +#: The rails are Sensitivity: only they reach the octave. The ticks clock the +#: dividers 18 dB under it, so a clock whose peaks are unequal (a second +#: harmonic the tracking low-pass left, or the last few LSB of a quiet one) +#: has crossed both sides by the time either side reaches a rail. Clocked on +#: the rails alone, such a clock crossed one rail and not the other: the +#: divider never turned over, and the signal times its one-sided pulses was +#: the dry note itself. +CURVE_POINTS = 4097 +DEAD_HALF = 63 +TICK_HALF = 7 +TICK = 2 +#: The comparator reaches the rail where its input times `pre_gain` is +#: 64/2048, so `pre_gain = EDGE / 10^(Sensitivity/20)` puts the rail at the +#: clock's level of Sensitivity. 1/32 is exact in every float format. +EDGE = 0.03125 + +#: The clock filters' Q: two sections of 0.5 are four coincident real +#: poles, whose phase a two-stage first-order all-pass matches exactly. +CLOCK_Q = 0.5 +TONE_Q = 0.7071 + +#: The dividers turn over on the comparator's ticks: one LSB, under TICK. +CLOCK_THRESHOLD = 1.0 / 32768.0 +CLOCK_HOLD_MS = 1.0 + +#: The table after each divider: the identity, with exact zero for +#: |x| < 16 LSB, which takes the ticks out of the square and leaves its rails +#: as they are. +GATE_HALF = 1 + +#: Frames a mixer pull hands out, and so what every node and tap takes per +#: pull: the palette's own block, so the Splitters' rings empty each pull. +BLOCK = 256 + +_CURVE = None +_GATE = None + + +def comparator_curve(): + """The comparator's table, built once and shared by every instance.""" + global _CURVE + if _CURVE is None: + rail = (CURVE_POINTS - 1) // 2 - DEAD_HALF + tick = DEAD_HALF - TICK_HALF + _CURVE = array("h", [-32767] * rail + [-TICK] * tick + + [0] * (2 * TICK_HALF + 1) + [TICK] * tick + + [32767] * rail) + return _CURVE + + +def gate_curve(): + """The table after each divider, built once and shared.""" + global _GATE + if _GATE is None: + middle = (CURVE_POINTS - 1) // 2 + _GATE = array("h", [0] * CURVE_POINTS) + for i in range(CURVE_POINTS): + step = i - middle + if abs(step) > GATE_HALF: + _GATE[i] = max(-32767, min(32767, 16 * step)) + _GATE[1] = -32767 + _GATE[CURVE_POINTS - 2] = 32767 + return _GATE + + +def pre_gain(sensitivity_db): + """The comparator's input gain for a Sensitivity in dBFS.""" + return EDGE / (10.0 ** (sensitivity_db / 20.0)) + + +def _allpass_ring(coefficient): + """Frames two first-order all-pass sections ring for, as an upper + bound: the cascade is a repeated real pole at -coefficient, which + `_component.biquad_ring` bounds as a biquad with that double pole.""" + a = -coefficient + + class _Pole: + coefficients = (1.0, 0.0, 0.0, -2.0 * a, a * a) + return _component.biquad_ring(_Pole) + + +class Octaver(_component.Component): + """An octave down that is your own note: like the Boss OC-2, it turns + every other cycle of what you play upside down, so the octave keeps + your attack, your decay and your tone. + + **The controls.** Direct, Octave 1 and Octave 2 are the three levels, + each from 0 to 1. + Tracking, from 40 to 400 Hz, moves the range of notes the divider + follows. + Tone 1 (100 to 2000 Hz) and Tone 2 (40 to 800 Hz) are two-pole + low-passes at the hertz they show, on the first and second octave. + Sensitivity, from -90 to -40 dBFS, is the level your note must keep + after Tracking's low-pass for the octaves to play, not the level you + play at. The higher the note, the more that low-pass takes, so a quiet + high note stops before a quiet low one: at patch 0 a sine at -60 dBFS + still has its octave on E2 and none on E4. + Constructor values land on the nearest of each knob's 128 steps. + + **What you hear.** The octave is exactly half your note and sounds + within 2 ms of it. + Octave 2 is Octave 1 divided again: two octaves down, not a fourth + below the note, until Octave 1 itself stops. + On an A2 the octave follows your note's decay within 1 dB: it has no + envelope of its own. + Below Sensitivity the octaves are silent, with no ghost and no hum, + and as a note fades into it the octave never turns into the note + itself. + With the Tones open the octave carries a fifth above it 4.4 dB + quieter, as the pedal's multiplication does, wherever Tracking is set. + Turning Tone 1 down thins that fifth more on high notes than on low + ones. + + **Its range.** Played at -8.7 dBFS peak, every note at patch 0 from + low E (E2) to A4 divides cleanly, even with a strong second harmonic. + Played at -8.7 dBFS peak, the Bass patch covers E1 to G3, and High on + the neck covers D3 to E5; played quieter, each range ends lower. + Below the range a low note's second harmonic clocks the divider and it + skips; above it the clock is low-passed under Sensitivity and the + octave goes silent. + Between -90 and -84.5 dBFS Sensitivity changes nothing: that whole + stretch is one step of the clock. + + **Limits.** It plays one note at a time: on a power chord (A2 and E3) + about two fifths of the octave is intermodulation. + A stereo source is clocked on its mid: with the right channel 45 + degrees ahead of the left, the fifth sits 3.2 dB under the octave + instead of 4.4, on both channels. + The output can peak at about 5 times the input (a low square, all + three levels at full with the Tones open), so keep the input under + -15 dBFS. + A control that jumps makes the output step: move it in small steps + from the host if you need it smooth. + + **Wire, latency, tail.** With both octaves at 0 and Direct at 1 the + output is the input, byte for byte, however the host's buffers are + cut. + There is no latency. + `tail_samples` covers the all-pass and the two Tone filters ringing + down. + `reset()` clears the filters and the dividers and returns to patch 0. + The class never reads the host's tempo. + """ + + NAME = 'Octaver' + DISPLAY_NAME = 'Octaver' + CATEGORIES = ('Pitch Shift',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioroute", "audiobiquad", "audioshaper", "audiomath") + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + #: The worst peak gain is 5.03: a square near E2 with all three levels + #: at full, the Tones open and Tracking at 40 Hz, over every semitone + #: from E1 to A2 at both Tracking stops (the kit's own 100 Hz square + #: reads 3.75). -15 dBFS keeps it 1 dB inside full scale. + INPUT_CEILING_DBFS = -15.0 + + MACRO_LABELS = ("Direct", "Octave 1", "Octave 2", "Tracking", "Tone 1", + "Tone 2", "Sensitivity") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", + 3: "UNIPOLAR", 4: "UNIPOLAR", 5: "UNIPOLAR", + 6: "UNIPOLAR"} + _MACRO_RANGES = ( + (0.0, 1.0), # 0 Direct + (0.0, 1.0), # 1 Octave 1 + (0.0, 1.0), # 2 Octave 2 + (40.0, 400.0, "log"), # 3 Tracking, Hz + (100.0, 2000.0, "log"), # 4 Tone 1, Hz + (40.0, 800.0, "log"), # 5 Tone 2, Hz + (-90.0, -40.0), # 6 Sensitivity, dBFS + ) + + #: `_component.macro_of` of the card's settings: 0.7 is 89, 0.8 is 102, + #: 0.6 is 76; Tracking 90/50/200 Hz is 45/12/89; Tone 1 324/250/700 Hz + #: is 50/39/82; Tone 2 70.3 Hz is 24; Sensitivity -80 dBFS is 25. + PATCHES = { + 0: ("Octave down under the dry", (127, 89, 0, 45, 50, 24, 25)), + 1: ("Both octaves", (102, 89, 89, 45, 50, 24, 25)), + 2: ("Octaves alone, no dry", (0, 127, 76, 45, 50, 24, 25)), + 3: ("Bass guitar", (127, 102, 0, 12, 39, 24, 25)), + 4: ("High on the neck", (127, 89, 0, 89, 82, 24, 25)), + 5: ("Two octaves down alone", (0, 0, 127, 45, 50, 24, 25)), + } + + def _build(self, direct=1.0, octave1=0.7, octave2=0.0, tracking_hz=90.0, + tone1_hz=324.0, tone2_hz=70.3, sensitivity_db=-80.0, + patch=None): + grid = self._grid((direct, octave1, octave2, tracking_hz, tone1_hz, + tone2_hz, sensitivity_db)) + rate = self._sample_rate + channels = self._channel_count + self._ready = False + self._armed = False + + self._silence = self._own(audiocore.RawSample( + array("h", bytes(2 * 2 * channels)), + sample_rate=rate, channel_count=channels), reset=False) + + # The input adapter: a width-1 MidSide, the identity byte for byte, + # that hands the source on in the palette's 256-frame blocks. The + # Splitter reads it, and the wire hands it out, so whatever part of + # a source buffer it holds plays next whichever way the output + # goes. Handing out the source itself skipped what the adapter, the + # Splitter and the dry voice had read ahead whenever the host's + # buffers were not 256 frames. + self._adapter = self._own(audioroute.MidSide( + width=1.0, sample_rate=rate, channel_count=channels), + reset=False) + self._adapter.play(self._source) + + # The graph reads the adapter through a Port. While the voices are + # armed it plays a MidSide with no source instead, one block of + # zeros per pull, which the arming pull drops (`_arm`): arming + # takes nothing from the source, so no voice ever holds a block the + # wire would skip. + self._hush = self._own(audioroute.MidSide( + width=1.0, sample_rate=rate, channel_count=channels), + reset=False) + self._in = self._own(audioroute.Port(self._hush), reset=False) + + # One Splitter, three legs: the dry voice, the signal the octaves + # are made of, and the clock that says when to turn it over. The + # clock and the signal are kept apart because `SubOctave` clocks on + # what it divides, so a tracking filter in front of it would darken + # the octave too (12 dB from E2 to G3, measured on the card's + # probes). + split = self._own(audioroute.Splitter(self._in, taps=3), + reset=False) + self._dry = split.tap(0) + self._allpass = None + self._clock = [] + signal = self._own(audioroute.Splitter( + self._signal_leg(split.tap(1)), taps=2), reset=False) + ticks = self._own(audioroute.Splitter( + self._clock_leg(split.tap(2)), taps=2), reset=False) + self._divider = self._first(ticks.tap(0)) + self._product = self._octave(signal.tap(0), self._divider) + self._tone1 = self._tone(self._product, 324.0) + self._octave1 = self._tone1 + + # Octave 2 is the pedal's second flip-flop, clocked by the first: + # the signal times a square at a quarter of the note, made from the + # same comparator, so it turns over on the crossings Octave 1 does. + # Divided on Octave 1's own waveform instead, the divider also + # counted the near-zero junctions where the multiplication's humps + # meet, and on a decaying note it turned into a fourth below. + self._product2 = self._second(signal.tap(1), ticks.tap(1)) + self._tone1b = self._tone(self._product2, 324.0) + self._tone2 = self._tone(self._tone1b, 70.3) + + # Not reset: no voice holds a block between two pulls (`_arm`), and + # a Mixer reset is no reset of anything this class holds. A Mixer + # renders half its buffer per pull, so BLOCK frames take four bytes + # a sample: with half that, every other pull left a voice holding + # half a block, which a move into the wire then skipped. + self._mixer = self._own(audiomixer.Mixer( + voice_count=3, **self._pcm(BLOCK * channels * 4)), reset=False) + # The mixer's output: a width-1 MidSide, byte for byte, whose reset + # forwards nothing. A host's reset of the output (a mixer voice's + # `play()` does one) would otherwise reach the Mixer, and + # CircuitPython's own Mixer drops its voices' blocks on one. + self._tail = self._own(audioroute.MidSide( + self._mixer, width=1.0, sample_rate=rate, + channel_count=channels), reset=False) + #: Where `audiocore.get_buffer` is compiled out, the one-voice + #: mixer `_pull` primes from `_tail`; built the first time it is + #: needed. + self._puller = None + self._output = self._tail + + self._init_macros(tuple( + _component.macro_value(self._MACRO_RANGES[i], grid[i] / 127.0) + for i in range(7))) + # Land each knob exactly on its step, as patch 0 does. + for i in range(7): + position = _component.position_of_midi("UNIPOLAR", grid[i]) + if self._macros[i] != position: + self._macros[i] = position + self._apply_macro(i, position) + # The patch lands before the voices are armed, so the instance + # starts at it: loaded after, the all-pass walked its first block + # from patch 0's Tracking and each level waited for a crossing, and + # `patch=` did not build what the patch's own values build. + if patch is not None: + self.program_change(patch) + + self._ready = True + self._route() + + def _grid(self, asked): + """Each constructor value as its knob's nearest MIDI step: NaN is + the default, anything past a stop is that stop.""" + grid = [] + for i in range(7): + value = float(asked[i]) + if value != value: + value = DEFAULTS[i] + span = self._MACRO_RANGES[i] + value = min(span[1], max(span[0], value)) + grid.append(int(round( + 127.0 * _component.macro_position(span, value)))) + return grid + + # -- the legs; each a method, so a test can plant a fault in one ------ + + def _signal_leg(self, tap): + """Two first-order all-pass sections at Tracking. The clock's two + Q 0.5 sections are four coincident real poles, whose phase is + exactly this cascade's, so the square turns over on the signal's own + zero crossings and the octave keeps the multiplication's balance.""" + self._allpass = self._own(audiobiquad.AllPass( + stages=2, frequency=90.0, feedback=0.0, mix=1.0, + sample_rate=self._sample_rate, + channel_count=self._channel_count)) + self._allpass.play(tap) + return self._allpass + + def _mid(self, tap): + """A stereo clock runs on the mid, so both channels turn over on + the same crossings; a mono one runs on the tap.""" + if self._channel_count != 2: + return tap + mid = self._own(audioroute.MidSide( + width=0.0, sample_rate=self._sample_rate, channel_count=2)) + mid.play(tap) + return mid + + def _clock_leg(self, tap): + """Low-pass at Tracking and a comparator with a dead band: a + +-rail square at the note, a tick of +-TICK while the clock is + under Sensitivity and over 18 dB under it, zero below that.""" + clock = self._mid(tap) + for _ in range(2): + section = self._own(audiobiquad.Biquad( + mode=audiobiquad.LOW_PASS, frequency=90.0, Q=CLOCK_Q, + mix=1.0, sample_rate=self._sample_rate, + channel_count=self._channel_count)) + section.play(clock) + clock = section + self._clock.append(section) + self._comparator = self._own(audioshaper.Waveshaper( + sample_rate=self._sample_rate, + channel_count=self._channel_count, curve=self._curve(), + oversample=1, mix=1.0, pre_gain=self._pre_gain(DEFAULTS[SENS_I]), + post_gain=1.0)) + self._comparator.play(clock) + return self._comparator + + def _divide(self, tap, order): + """A divider on the comparator: a +-1 square at a half (order 1) + or a quarter (order 2) of the note, zero while the comparator is + off its rails. Both orders count the same crossings, so the quarter + turns over only where the half does. + + The divider turns over on the ticks, and the gate after it takes + them out, so what reaches the multiply is the divider's square on + the rails and exact zero elsewhere.""" + divider = self._own(audiomath.SubOctave( + tap, order=order, mix=1.0, threshold=CLOCK_THRESHOLD, + hold_ms=CLOCK_HOLD_MS, sample_rate=self._sample_rate, + channel_count=self._channel_count)) + gate = self._own(audioshaper.Waveshaper( + sample_rate=self._sample_rate, + channel_count=self._channel_count, curve=gate_curve(), + oversample=1, mix=1.0, pre_gain=1.0, post_gain=1.0)) + gate.play(divider) + return gate + + def _first(self, tap): + return self._divide(tap, 1) + + def _curve(self): + return comparator_curve() + + def _pre_gain(self, sensitivity_db): + return pre_gain(sensitivity_db) + + def _octave(self, signal, square): + """The signal times the square: every other cycle turned over.""" + return self._multiply(signal, square) + + def _multiply(self, signal, square): + return self._own(audiomath.Multiply( + signal, square, mix=1.0, sample_rate=self._sample_rate, + channel_count=self._channel_count)) + + def _second(self, signal, tick): + """Octave 2: the signal times the quarter-note square, which is + Octave 1 with every other cycle turned over.""" + return self._multiply(signal, self._divide(tick, 2)) + + def _tone(self, source, hz): + """A Tone low-pass on `source`.""" + node = self._own(audiobiquad.Biquad( + mode=audiobiquad.LOW_PASS, frequency=hz, Q=TONE_Q, mix=1.0, + sample_rate=self._sample_rate, + channel_count=self._channel_count)) + node.play(source) + return node + + def _set_tracking(self, hz): + if self._allpass is not None: + self._allpass.frequency = hz + for section in self._clock: + section.frequency = hz + + def _tone1_hz(self): + return self._hz(self._value(TONE1_I)) + + # -- the controls ---------------------------------------------------- + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _apply_macro(self, index, position): + del position + if index == TRACKING_I: + self._set_tracking(self._hz(self._value(TRACKING_I))) + elif index == TONE1_I: + hz = self._tone1_hz() + self._tone1.frequency = hz + self._tone1b.frequency = hz + elif index == TONE2_I: + self._tone2.frequency = self._hz(self._value(TONE2_I)) + elif index == SENS_I: + sensitivity = self._value(SENS_I) + self._comparator.set(pre_gain=self._pre_gain(sensitivity)) + else: + self._route() + + def _is_wire(self): + return (self._macros[DIRECT_I] >= 1.0 + and self._macros[OCTAVE1_I] <= 0.0 + and self._macros[OCTAVE2_I] <= 0.0) + + def _levels(self): + voices = self._mixer.voice + voices[0].level = self._value(DIRECT_I) + voices[1].level = self._value(OCTAVE1_I) + voices[2].level = self._value(OCTAVE2_I) + + def _route(self): + """Direct at 1 with both octaves at 0 hands out the input adapter, + the source byte for byte; anything else plays the mixer, armed the + first time it is reached.""" + if self._is_wire(): + self._route_around(self._adapter) + return + if not self._ready: + self._levels() + return + self._rejoin() + if not self._armed: + self._arm() + self._armed = True + self._output = self._tail + self._levels() + + def _arm(self): + """Play the voices, the first time the output reaches the mixer. + + Each voice first plays silence for one pull, which opens its level + gate (as `_component.open_level_gates` does). A voice's `play()` + then primes one block from its source: with the Port on `_hush` + that block is zeros, and a second pull hands it out, so arming + takes nothing from the source. Between two pulls after that no + voice holds a block and every tap is caught up, because the adapter + hands 256-frame blocks and the Mixer pulls 256 frames: a move into + the wire hands out the adapter with nothing left behind, and the + way back carries on from where the wire stopped.""" + voices = self._mixer.voice + self._levels() + for voice in voices: + voice.play(self._silence, loop=True) + self._pull() + self._in.play(self._hush) + voices[0].play(self._dry, loop=True) + voices[1].play(self._octave1, loop=True) + voices[2].play(self._tone2, loop=True) + self._pull() + self._in.play(self._adapter) + # The all-pass glides a Tracking move over one block from the + # coefficient it last played at. It has played only the arming's + # zeros, so it holds nothing to keep: cleared, its first block of + # the source starts at the Tracking then in force, as a rejoin's + # does, and a move before the first pull lands at once. + if self._allpass is not None: + audiocore.reset_buffer(self._allpass) + + def _pull(self): + """Pull one block from the mixer and drop it. + + Where `audiocore.get_buffer` is compiled out (CircuitPython's + default board builds), a voice of a one-voice mixer of our own + plays `_tail` and stops: its `play()` primes one block, which pulls + the mixer once. `_tail` forwards no reset, so the play rewinds + nothing behind it.""" + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(self._mixer) + return + if self._puller is None: + # Nothing ever pulls this mixer, so its own buffers are the + # small: two stereo frames. + self._puller = self._own(audiomixer.Mixer( + voice_count=1, **self._pcm(16)), reset=False) + voice = self._puller.voice[0] + voice.play(self._tail) + voice.stop() + + @property + def tail_samples(self): + """The all-pass and both Tone filters ringing down, end to end.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + if self._is_wire(): + return 0 + ring = 0 + if self._allpass is not None: + ring = _allpass_ring(self._allpass.coefficient) + tones = _component.cascade_ring((self._tone1b, self._tone2)) + if ring is None or tones is None: + return None + return ring + tones diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py index 500fcee..6c501f3 100644 --- a/lib/audioeffects/rebuilt/pingpongdelay.py +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -167,6 +167,8 @@ class PingPongDelay(_component.Component): VERSION = '0.1.0' TIER = _component.AUDIODSP + #: The exact constructor values a rack's reset puts back with the knobs. + _KEPT = ('_time_exact',) REQUIRES = ("audioecho",) CAPABILITIES = ("tempo_sync",) @@ -380,18 +382,16 @@ def _apply_macro(self, index, position): if not self._deferred: self._refresh() - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then refresh the node once. A refresh + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh the node once. A refresh per macro would read Time against the outgoing patch's Sync.""" self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = False - if type(self).PATCHES.get(index) is not None: - self._refresh() + self._refresh() def reset(self): _component.Component.reset(self) diff --git a/lib/audioeffects/rebuilt/pitchshifter.py b/lib/audioeffects/rebuilt/pitchshifter.py new file mode 100644 index 0000000..44a26af --- /dev/null +++ b/lib/audioeffects/rebuilt/pitchshifter.py @@ -0,0 +1,540 @@ +"""`PitchShifter` - a fixed-interval splice pitch shifter. + +The player's text is the class docstring; the `CLAIMS` table in the class's +test file ties its claim sentences to tests. +""" + +VENDOR = "PyDevices" + +import math +from array import array + +import audiocore + +from .. import _component + +try: + from .digitaldelay import laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import laps_to_zero, whole_frames + +try: + import audiodelays + import audioecho + import audiomixer + import audioroute +except ImportError: # pragma: no cover - a stock board + audiodelays = audioecho = audiomixer = audioroute = None + + +PITCH_I, FINE_I, MIX_I, DELAY_I, FEEDBACK_I = range(5) + +#: The Delay and Feedback spans' tops. +DELAY_MAX_MS = 112.5 +FEEDBACK_MAX = 0.95 + +#: The shifter's window, in ms: the default and the clamp. +WINDOW_MS = 30.0 +WINDOW_MIN_MS = 15.0 +WINDOW_MAX_MS = 60.0 + +#: The line holds Delay's top and a little; the loop holds Delay's top, the +#: half window and crossfade it adds, and the window its splicer reads over. +LINE_MS = 120.0 +LOOP_MS = 250.0 + +#: The frames one pull of the output hands out. +BLOCK = 256 + +#: The node's read rate is whole 256ths of unity (`PitchShift.c:121`). +STEPS = 256 + +#: The constructor defaults, which NaN falls back to. +DEFAULTS = (7.0, 0.0, 1.0, 0.0, 0.0) + + +def crossfade_frames(sample_rate): + """The shifter's crossfade: 4/3 ms in whole frames, 64 at 48 kHz.""" + return (int(sample_rate) + 375) // 750 + + +def window_frames(window_ms, sample_rate): + """The shifter's window in whole frames at the running rate.""" + return whole_frames(window_ms, sample_rate) + + +def ratio_step(ratio): + """The node's read rate nearest `ratio`, in 256ths.""" + return int(math.floor(ratio * STEPS + 0.5)) + + +def step_centre_semitones(step): + """Semitones that put the node on `step`: the centre of the step, so + the node's truncation lands on it at any float width.""" + return 12.0 * math.log((step + 0.5) / STEPS) / math.log(2.0) + + +def step_semitones(step): + """The step itself in semitones, for the loop; exactly 0 at unity.""" + if step == STEPS: + return 0.0 + return 12.0 * math.log(float(step) / STEPS) / math.log(2.0) + + +def _option(value, default, low, high): + """A constructor option as a float in [low, high]; NaN is the default.""" + value = float(value) + if value != value: + return default + if value < low: + return low + if value > high: + return high + return value + + +def _rint(value): + """Round half away from zero.""" + if value >= 0.0: + return int(value + 0.5) + return -int(-value + 0.5) + + +class PitchShifter(_component.Component): + """Play your part again a fixed interval up or down, a little behind + you, the way the first harmonizer boxes did. + + **The controls.** Pitch is the interval in whole semitones, from an + octave down to an octave up. + Fine detunes it by up to 50 cents either way. + The shifter reads in steps of 1/256 of the speed, so Fine moves in steps + of about 7 cents near unity and 13.5 near the octave down, and the + interval is within 0.5 % of the one Pitch and Fine name. + Mix keeps your dry signal at unity up to its centre, 1, with the shifted + voice coming up to unity beside it; at 2 you hear the shifted voice + alone, and at 0 the output is your input. + Delay holds the shifted voice back by up to 112.5 ms. + Feedback, up to 0.95, sends the shifted voice round again, and every + repeat is shifted once more: at a fifth up the repeats climb a fifth, + then a ninth, then a thirteenth. + + **The splice.** The shifter keeps a short memory of what you play, the + window, and reads it at the new speed, splicing back to the other end + when it runs out. + Off unity, each note comes back anywhere from straight away to one + window and 1.3 ms late, plus Delay, the delay sweeping and jumping at + every splice. + At unity it sits still, 1.3 ms late, from a fresh start, a reset or a + return from Mix 0; after a Pitch move it sits wherever the splice left + it, up to one window and 1.3 ms late. + On CircuitPython's own pitch shifter a reset or a return from Mix 0 does + not restart the splice, so there it sits wherever the splice was. + Shifting down drops slices of what you play; shifting up repeats them. + The window is 30 ms unless you pass `window_ms`, 15 to 60, when you + build it: a longer window splices less often and arrives later. + + **The dry.** Up to Mix 1 your dry signal passes untouched, sample for + 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. + + **Mono, latency, tail.** A one-channel source gets a one-channel + shifter with the same window. + Latency is zero samples: the dry is never delayed. + `tail_samples` is an upper bound on how long the output takes to reach + exact zero once your input stops, at every Feedback. + `reset()` empties the shifter and both delay lines and returns to + patch 0. + The class never reads the host's tempo. + If your host takes the output before it loads a patch or moves Mix, or + loads a patch right after a reset, the first 256 frames, 5 ms at + 48 kHz, play at the old level. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + """ + + NAME = 'PitchShifter' + DISPLAY_NAME = 'Pitch Shifter' + CATEGORIES = ('Pitch Shift',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho", "audioroute") + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + #: Where no patch can reach the rail. At Mix 1 the dry and the shifted + #: voice each reach the input's peak, and the repeats add Feedback^n of + #: it: 1 + 1 / (1 - 0.5535) = 3.24 times the peak at patches 4 and 5, + #: which -10.21 dBFS keeps under full scale. Feedback at its top stacks + #: up to 21 times, and the kit declares those cells. + INPUT_CEILING_DBFS = -10.5 + + MACRO_LABELS = ("Pitch", "Fine", "Mix", "Delay", "Feedback") + MACRO_MODES = { + 0: "BIPOLAR", + 1: "BIPOLAR", + 2: "BIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + } + _MACRO_RANGES = ( + (-12.0, 12.0), # 0 Pitch, semitones; plays whole + (-50.0, 50.0), # 1 Fine, cents + (0.0, 2.0), # 2 Mix; MIDI 64 is 1.0 + (0.0, DELAY_MAX_MS), # 3 Delay, ms + (0.0, FEEDBACK_MAX), # 4 Feedback + ) + + #: `_component.macro_of` of the card's patch settings. + PATCHES = { + 0: ("Fifth Above", (101, 64, 64, 0, 0)), + 1: ("Octave Up", (127, 64, 64, 0, 0)), + 2: ("Octave Down", (0, 64, 64, 0, 0)), + 3: ("Doubler", (64, 75, 64, 17, 0)), + 4: ("Rising Fifths", (101, 64, 64, 102, 74)), + 5: ("Falling Fourths", (37, 64, 64, 113, 74)), + 6: ("Shifted Only", (101, 64, 127, 0, 0)), + } + + def _build(self, pitch=7.0, fine=0.0, mix=1.0, delay_ms=0.0, + feedback=0.0, window_ms=WINDOW_MS, patch=None): + spans = self._MACRO_RANGES + values = tuple( + _option(value, DEFAULTS[index], spans[index][0], spans[index][1]) + for index, value in enumerate( + (pitch, fine, mix, delay_ms, feedback))) + window_ms = _option(window_ms, WINDOW_MS, WINDOW_MIN_MS, + WINDOW_MAX_MS) + rate = self._sample_rate + #: The window and the crossfade, in whole frames at this rate. + self._window = self._window_frames(window_ms) + self._overlap = crossfade_frames(rate) + self._ready = False + self._wired = False + #: Whether the host has asked for the output yet. Until it has, + #: nothing has been pulled, and a control that moves a voice's + #: level re-arms the graph so the level holds from frame 0. A reset + #: or a return from Mix 0 does not start that window again: the + #: class cannot see a pull, and a host that keeps the node it took + #: would have every later Mix move empty the repeats. + self._taken = False + #: The two Mixer levels last handed to the voices. + self._applied = None + #: Whether the loop's splicer has run since its line was last + #: emptied: it then reads half a window later (`_loop_frames`). + self._engaged = False + self._step = STEPS + self._loop_shift = 0.0 + self._delay_frames = 1 + self._feedback = 0.0 + self._build_graph() + self._init_macros(values) + # Every knob lands on its 0-127 step, as a host would set it, so a + # fresh instance reads patch 0 exactly and no constructor value is + # kept beside the grid. + for index in range(len(values)): + span = spans[index] + mode = self._mode(index) + self._macros[index] = _component.position_of_midi( + mode, _component.macro_of(span, values[index], mode)) + if patch is not None: + self.program_change(patch) + self._ready = True + self._refresh() + + def _build_graph(self): + rate = self._sample_rate + channels = self._channel_count + width = 2 * channels + # The borrowed source's only reader: a width-1 MidSide is the source + # byte for byte in 256-frame blocks, and it is what Mix 0 hands + # back, so a move to or from Mix 0 never skips or repeats a frame. + self._head = self._own(audioroute.MidSide( + self._source, width=1.0, sample_rate=rate, + channel_count=channels), reset=False) + # A MidSide with no source: one block of zeros per pull, which the + # shifter and the voices prime from (`_wire`). + self._hush = self._own(audioroute.MidSide( + width=1.0, sample_rate=rate, channel_count=channels), + reset=False) + self._in = self._own(audioroute.Port(self._hush), reset=False) + # A Splitter's ring cannot be rewound; its taps are read in step, + # 256 frames a pull each, so it holds nothing between pulls. + self._split = self._own(audioroute.Splitter(self._in, taps=2), + reset=False) + self._shift = self._own(audiodelays.PitchShift( + semitones=step_centre_semitones(STEPS), mix=1.0, + window=self._window * width, overlap=self._overlap * width, + **self._pcm(BLOCK * width))) + self._shift.play(self._split.tap(1)) + self._line = self._own(audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=LINE_MS, + delay_frames=1, feedback=0.0, mix=2.0, damping_hz=0.0, + cut_hz=0.0), reset=self._clear_line) + self._line.play(self._shift) + self._loop = self._own(audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=LOOP_MS, + delay_frames=self._loop_frames(), feedback=0.0, mix=0.0, + damping_hz=0.0, cut_hz=0.0, + loop_window_ms=self._window * 1000.0 / rate), + reset=self._clear_loop) + self._loop.play(self._line) + # Not reset by the walk: neither voice holds a block between two + # pulls (`_wire`), so there is nothing of its own to clear. + self._mixer = self._own(audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * width * 2, + channel_count=channels, bits_per_sample=16, + samples_signed=True, sample_rate=rate), reset=False) + # The output: a width-1 MidSide, byte for byte, whose reset forwards + # nothing. A host's reset of the output would otherwise reach the + # Mixer, and CircuitPython's own Mixer handles that differently from + # audiodsp's. + self._tail = self._own(audioroute.MidSide( + self._mixer, width=1.0, sample_rate=rate, + channel_count=channels), reset=False) + self._silence = audiocore.RawSample( + array("h", bytes(2 * 2 * channels)), sample_rate=rate, + channel_count=channels) + #: Where `audiocore.get_buffer` is compiled out, the one-voice + #: mixer `_pull` primes from `_tail`; built the first time it is + #: needed. + self._puller = None + self._output = self._head + + def _wire(self): + """Play the voices, the first time the output reaches the mixer. + + A voice's `play()`, like the shifter's, primes a block from its + source. With the Splitter on `_hush` that block is zeros, and one + pull of the mixer hands it out, so wiring takes nothing from the + borrowed source and leaves no voice holding a block: a reset or a + return from Mix 0 never meets a block a voice is still waiting to + play. The level gates are opened first, as + `_component.open_level_gates` opens them.""" + voices = self._mixer.voice + self._levels() + for voice in voices: + voice.play(self._silence, loop=True) + self._pull() + voices[0].play(self._split.tap(0)) + voices[1].play(self._loop) + self._pull() + self._in.play(self._head) + self._clear_nodes(only=(self._shift, self._line, self._loop)) + self._wired = True + # A control touched at Mix 0 before the first wiring marked the + # graph stranded; wiring is what that mark waits for. + self._stranded = False + + def _rearm(self): + """Wire the graph again as the constructor does, once it has been + cleared: a voice's new level is picked up at its next zero crossing + unless the voice is played again, so a graph that is cleared while + a level changes would otherwise carry the old level into the first + frames. The input goes back to silence first, so no frame is taken + from the borrowed source.""" + self._in.play(self._hush) + self._wire() + + def _pull(self): + """Pull one block from the mixer and drop it. + + Where `audiocore.get_buffer` is compiled out (CircuitPython's + default board builds), a voice of a one-voice mixer of our own + plays `_tail` and stops: its `play()` primes one block, which pulls + the mixer once. `_tail` forwards no reset, so the play rewinds + nothing behind it.""" + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(self._mixer) + return + if self._puller is None: + # Nothing ever pulls this mixer, so its own buffers are the + # smallest that hold a frame. + self._puller = self._own(audiomixer.Mixer( + voice_count=1, buffer_size=16, + channel_count=self._channel_count, bits_per_sample=16, + samples_signed=True, sample_rate=self._sample_rate), + reset=False) + voice = self._puller.voice[0] + voice.play(self._tail) + voice.stop() + + def _clear_line(self): + self._line.clear() + + def _clear_loop(self): + """Empty the loop. Its splicer goes back to the unshifted read, so + its delay is set again for the read it will take.""" + self._loop.clear() + self._engaged = self._loop_shift != 0.0 + self._loop.set(delay_frames=self._loop_frames()) + + # -- the maps ------------------------------------------------------ + + def _window_frames(self, window_ms): + """The shifter's window in whole frames at the running rate.""" + return window_frames(window_ms, self._sample_rate) + + def _ratio(self, semitones): + """The speed an interval of `semitones` reads at.""" + return 2.0 ** (semitones / 12.0) + + def _loop_semitones(self, step): + """What the loop's splicer is handed: the shifter's own step.""" + return step_semitones(step) + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _pitch(self): + """Pitch in whole semitones.""" + pitch = _rint(self._value(PITCH_I)) + return max(-12, min(12, pitch)) + + def _mix(self): + return max(0.0, min(2.0, self._value(MIX_I))) + + def macro(self, index): + """What macro `index` currently stands for, in its own units; for + Pitch, the whole semitones it plays.""" + self._check_live() + if index == PITCH_I: + return float(self._pitch()) + return _component.Component.macro(self, index) + + def _loop_frames(self): + """The loop's delay: repeats fall every line delay plus half the + window and crossfade, the first pass's mean. Once its splicer has + run the loop reads half a window later of its own accord.""" + frames = self._delay_frames + (self._window + self._overlap) // 2 + if self._engaged: + return frames - self._window * 0.5 + return frames + + def _level_pair(self): + """The dry and the shifted voice's Mixer levels at this Mix.""" + mix = self._mix() + return (min(1.0, 2.0 - mix), min(1.0, mix)) + + def _levels(self): + voices = self._mixer.voice + pair = self._level_pair() + voices[0].level = pair[0] + voices[1].level = pair[1] + self._applied = pair + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + if self._ready: + self._refresh() + + def _apply_positions(self, positions, patch_index): + """Set the knobs whole, then refresh the graph once: a patch and a + rack's put-back after a reset both land here, so neither passes + through a Mix 0 or a half-moved interval on the way.""" + ready = self._ready + self._ready = False + try: + _component.Component._apply_positions(self, positions, + patch_index) + finally: + self._ready = ready + if ready: + self._refresh() + + def _refresh(self): + rate = self._sample_rate + cents = self._pitch() * 100.0 + self._value(FINE_I) + step = ratio_step(self._ratio(cents / 100.0)) + self._step = step + self._shift.semitones = step_centre_semitones(step) + self._delay_frames = max(1, whole_frames(self._value(DELAY_I), rate)) + self._line.set(delay_frames=self._delay_frames) + feedback = max(0.0, min(FEEDBACK_MAX, self._value(FEEDBACK_I))) + self._feedback = feedback + self._loop_shift = self._loop_semitones(step) + if self._loop_shift != 0.0: + self._engaged = True + self._loop.set(loop_semitones=self._loop_shift, + delay_frames=self._loop_frames(), + feedback=feedback, mix=feedback) + self._route() + + def _route(self): + """Point the output at the source's reader at Mix 0, or at the + mixer, emptying the shifter and both lines when it comes back.""" + if self._mix() <= 0.0: + self._route_around(self._head) + return + if not self._wired: + self._wire() + elif self._rejoin(): + self._rearm() + elif not self._taken and self._level_pair() != self._applied: + # Not pulled yet: a level moved now would wait for its voice's + # next zero crossing, a block of the old level, where an + # instance built at this setting plays the new one from frame 0. + # A move that leaves both levels alone has nothing to wait for. + self._rearm() + else: + self._levels() + self._output = self._tail + + @property + def output(self): + """The node the host plays. Asking for it is the host's first step + to pulling, so from here a level that moves is an ordinary move.""" + self._check_live() + self._taken = True + return self._output + + def reset(self): + """Empty the shifter and both lines, return to patch 0, and play + from there as a new instance does.""" + wired = self._wired + _component.Component.reset(self) + if not wired: + return + if self._mix() > 0.0: + self._rearm() + else: + # A rack's put-back can restore Mix 0: the output is the + # source's reader, and the graph behind it waits to be armed + # when Mix comes back up, as it does in a new instance. + self._stranded = True + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, + as an upper bound. + + The shifter hands out what it holds for at most one window and + crossfade after its input stops, the line adds its delay, and the + loop then goes round `laps_to_zero(Feedback)` times, each lap at + most its delay plus the window its splicer reads across, and a frame + for the read between two. At Feedback 0 the loop is a wire.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`.""" + if self._mix() <= 0.0: + return 0 + reach = self._delay_frames + self._window + self._overlap + 1 + if self._feedback <= 0.0: + return reach + lap = int(math.ceil(self._loop_frames())) + self._window + 2 + return reach + laps_to_zero(self._feedback) * lap diff --git a/lib/audioeffects/rebuilt/rack.py b/lib/audioeffects/rebuilt/rack.py new file mode 100644 index 0000000..a3fb892 --- /dev/null +++ b/lib/audioeffects/rebuilt/rack.py @@ -0,0 +1,234 @@ +"""`Rack` - a serial chain of effects, as a single effect. + +The player's text is the class docstring, and the sentences in it that make +a claim are tied to tests by the `CLAIMS` table in the class's test file. +""" + +VENDOR = "PyDevices" + +from .. import _component + + +class Rack(_component.Component): + """A pedalboard cable run: name the effects in order and get a single + effect back. + + For example: + + rack = Rack.create(source, 48000, chain=( + ("Fuzz", {}), + ("DigitalDelay", {"mix": 0.3, "feedback": 0.4}), + ("ConvolutionReverb", {}), + )) + audio_out.play(rack.output) + + `chain` is a tuple of entries, each an effect name, a `(name, options)` + pair whose options are that effect's constructor keywords, or an effect + class in place of a name. `("Rack", {"chain": ...})` nests a rack. + + A rack adds no sound of its own: it sounds exactly like the same pedals + patched by hand, one into the next. Empty, it is a patch cable, and its + output is your source. A mono source gives a mono rack. + + Order matters as soon as one pedal isn't a plain filter: drive into a + reverb isn't a reverb into drive. + + `latency_samples` and `tail_samples` are the sums of the pedals' own, + read live, so they follow a pedal's knobs; the tail is `None` as soon as + one pedal's is. + + There is no rack Mix, Bypass or Output knob, and patch 0 is its only + patch. Turn a pedal's own Mix down; `ShimmerHall` and `AirSpace` are + racks with knobs. The chain is set when the rack is built: to change + pedals, build a new rack. To turn a pedal's knob, reach it through + `rack.effects`, a tuple of the pedals in chain order. + + `reset()` clears every pedal and puts each back to the settings the + rack built it with, undoing knob moves made since. `program_change(0)` + does the putting back alone, without clearing anything. + + A host's `audiocore.reset_buffer(rack.output)` clears the last pedal + only, and on an empty rack it rewinds your own source, because the + output is your source. Call `rack.reset()` instead. + + A tempo-synced pedal follows the song's tempo wherever it sits, nested + racks included: each pedal gets the rack's transport unless its entry + names its own, and `transport_changed()` reaches every pedal. + `capabilities` is the union of the pedals' own, set when the rack is + built. + + A rack builds nothing of its own, so it costs what its pedals cost. + Heavy pedals add up quickly on a small board: if the sound breaks up, + trim the chain. The class itself runs on a stock board; a built rack + needs whatever modules its pedals need. + + `deinit()` releases every pedal, nested racks included, and never your + source. If an entry fails to build, or `patch` is refused, the pedals + already built are released before the error reaches you. + """ + + NAME = 'Rack' + DISPLAY_NAME = 'Effect Rack' + CATEGORIES = ('Effect Rack',) + VERSION = '0.1.0' + + TIER = _component.STOCK + + MACRO_LABELS = () + MACRO_MODES = {} + _MACRO_RANGES = () + PATCHES = {0: ("Default", ())} + + def _build(self, chain=(), patch=None): + built = [] + tail = self._source + try: + for entry in chain: + name, options = _entry(entry) + child = self._child(name, tail, options) + built.append(child) + tail = child.output + self._effects = tuple(built) + # What patch 0 and reset() restore: each child's settings as the + # rack built it (`_record`). + self._built = tuple(_record(child) for child in built) + capabilities = [] + for child in built: + for capability in child.capabilities: + if capability not in capabilities: + capabilities.append(capability) + self._capabilities = tuple(capabilities) + # No port of its own: the rack's output IS the last child's + # output (its port), and an empty rack's IS the source. Set + # directly, around the `_output` property, which would wrap it + # in a new port. + self._port = tail + # Last, and inside the release: a `patch` the rack refuses + # raises here, with every child built. + self._init_macros((), patch) + except BaseException: + # The constructor never returns, so nobody else can release + # what was built: do it here, tail first, and re-raise. + for child in reversed(built): + child.deinit() + raise + + def _child(self, name, source, options): + """One chain entry built around `source`, with the rack's transport + unless the entry names its own.""" + transport = options.pop("transport", self._transport) + if isinstance(name, str): + if name == Rack.NAME: + name = Rack + else: + import audioeffects + return audioeffects.create(name, source, self._sample_rate, + transport=transport, **options) + return name.create(source, self._sample_rate, transport=transport, + **options) + + # -- the reads ------------------------------------------------------ + + @property + def effects(self): + """The children, in chain order.""" + self._check_live() + return self._effects + + @property + def latency_samples(self): + self._check_live() + total = 0 + for child in self._effects: + total += child.latency_samples + return total + + @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 + + @property + def capabilities(self): + self._check_live() + return self._capabilities + + # -- the surface ---------------------------------------------------- + + def _apply_positions(self, positions, patch_index): + """Patch 0: every child back to the settings the rack built it + with. A nested rack restores inside itself.""" + _component.Component._apply_positions(self, positions, patch_index) + for child, record in zip(self._effects, self._built): + _restore(child, record) + + def transport_changed(self): + self._check_live() + for child in self._effects: + child.transport_changed() + + def reset(self): + """Each child's own reset, tail first, reloading the settings the + rack built it with in place of its patch 0, so no child passes + through a patch the rack never gave it: one built at Mix 0 would + re-arm there and pull a block of the source. A nested rack's reset + does the same inside itself. The borrowed source is never named.""" + self._check_live() + for position in range(len(self._effects) - 1, -1, -1): + child = self._effects[position] + record = self._built[position] + if isinstance(child, _component.Component): + child._reset_to = record + try: + child.reset() + finally: + child._reset_to = None + else: + child.reset() + _restore(child, record) + self._stranded = False + self._patch_index = 0 + + def deinit(self): + """Every child's own deinit, tail first. The port is never released + here: it is the last child's, or, for an empty rack, the source.""" + if self._deinited: + return + for child in reversed(self._effects): + child.deinit() + self._port = None + self._deinited = True + + +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.""" + if isinstance(child, _component.Component): + return child._record() + return (list(child._macros), child._patch_index) + + +def _restore(child, record): + """Put `child` back to `record`, without clearing anything.""" + if isinstance(child, _component.Component): + child._restore(record) + else: + child._apply_positions(list(record[0]), record[1]) + + +def _entry(entry): + """(name or class, options dict) for one chain entry.""" + if isinstance(entry, (str, type)): + return entry, {} + try: + name, options = entry + except (TypeError, ValueError): + raise ValueError("a chain entry is a NAME, an effect class or a " + "(NAME, options) pair, not %r" % (entry,)) + return name, dict(options) diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py index cb5ef91..9bcb6fe 100644 --- a/lib/audioeffects/rebuilt/reverb.py +++ b/lib/audioeffects/rebuilt/reverb.py @@ -461,18 +461,16 @@ def _apply_macro(self, index, position): if not self._deferred: self._refresh() - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then refresh the Tank once, so a + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh the Tank once, so a patch that moves Character and Size rebuilds it once.""" self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = False - if type(self).PATCHES.get(index) is not None: - self._refresh() + self._refresh() def _refresh(self): fs = self._sample_rate diff --git a/lib/audioeffects/rebuilt/shimmerhall.py b/lib/audioeffects/rebuilt/shimmerhall.py new file mode 100644 index 0000000..e858b5c --- /dev/null +++ b/lib/audioeffects/rebuilt/shimmerhall.py @@ -0,0 +1,736 @@ +"""`ShimmerHall` - a hall that rings an octave above what you play. + +You play into a hall, and a pitch shifter beside it makes the tail ring at +the Interval you choose. Character picks how it is built. In Bloom the +shifted copy goes into the hall once, Bloom after you play, and the whole +hall waits for it. In Rise the shifter sits in a feedback loop: the hall +starts at once, and each pass comes back shifted again, so the tail climbs +while Damping darkens every pass until it fades. + +Shimmer (0 to 1) is how loud the shifted copy goes into the hall. Bloom (20 +to 800 ms) is the wait in Bloom and the time between passes in Rise. Rise (0 +to 0.9) is how strongly each pass returns, in Rise only. Interval picks one +of five shifts: -12, -5, +7, +12 or +24 semitones. Decay (0.3 to 10 s), Size +(0.5 to 1.5) and Damping (500 Hz to 16 kHz) set the hall, and Damping also +darkens each pass of the loop. Mix (0 to 2) is `audiodelays.Echo`'s: your +dry signal at unity up to 1, the hall alone at 2. + +In Bloom, move Bloom and the shifted copy moves by the same time, within 512 +frames. Its energy centres within 2.5 semitones of the Interval. In Rise the +loop's short window spreads the first pass wider: within 2.5 semitones at -5 +and +7, 3.5 at +12 and 6 at -12 and +24. + +In Rise at +12 each octave arrives one Bloom after the one below, within +20 %. Moving Rise from 0.3 to 0.9 brings the third pass back 16 to 22 dB +stronger against the first, and in Bloom Rise changes nothing. With Damping +at 800 Hz the fourth pass is at least 12 dB weaker against the first than +at 16 kHz. + +Up to Mix 1 your dry signal passes untouched, sample for sample: the +shimmer is only ever in the hall. At Mix 0 the output is your input, byte +for byte. Latency is zero. Keep your input at or under -24 dBFS: with +Decay, Size, Shimmer and Rise at the top, a steady loud input fills the +hall to the rail. + +`tail_samples` bounds how long the output takes to reach exact zero once +your input stops: 82 s at Rise 0.9 and Bloom 800 ms. `reset()` empties the +hall, the line and the shifter, and restores patch 0. The patches: Octave +Rise (the defaults), Short Stagger, Fifth Halo, Slow Rise, Rise Damped, +Falling Octaves, Octave Bloom. Octave Rise is Octave Bloom in Rise at Rise +0: one octave, once, and the hall starts at once. On an ESP32-S3 the Bloom +patches (Octave Bloom, Short Stagger and Fifth Halo) take longer than a +block and cannot keep up; the defaults and every Rise patch keep up. + +**Limits** + +A control that jumps makes the output step: move it in small steps from the +host if you need it smooth. Moving Character or Size empties the hall: the +tail drops and the dry carries on. If your host takes the output before it +loads a patch or moves Mix, the first 256 frames, 5 ms at 48 kHz, play at the +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. + +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 +dry in Bloom: CircuitPython's own pitch shifter waits on a source that +comes back empty. + +A value outside a macro's span clamps to the nearer stop, and NaN takes the +default; an `interval` other than the five raises `ValueError`. +""" + +VENDOR = "PyDevices" + +import math +from array import array + +import audiocore + +from .. import _component + +try: + from .digitaldelay import laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import laps_to_zero, whole_frames +try: + from .tapedelay import tone_excess +except ImportError: # pragma: no cover - after it lands + from ..tapedelay import tone_excess +# The module, not the class: a Component class at this module's top level +# would be listed as a second provider here (`rebuilt.known()`). +try: + from . import reverb as _reverb +except ImportError: # pragma: no cover - after it lands + from .. import reverb as _reverb + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None +try: + import audiodelays +except ImportError: # pragma: no cover - a stock board + audiodelays = None +try: + import audioroute +except ImportError: # pragma: no cover - a stock board + audioroute = None +try: + import audiomixer +except ImportError: # pragma: no cover - a stock board + audiomixer = None + + +#: Frames per pull: the palette's block. +BLOCK = 256 + +(CHARACTER_I, SHIMMER_I, RISE_I, BLOOM_I, INTERVAL_I, DECAY_I, SIZE_I, + DAMPING_I, MIX_I) = range(9) + +CHARACTERS = ("bloom", "rise") + +#: Interval's five zones, in semitones, low to high on the 0-127 grid. +INTERVALS = (-12, -5, 7, 12, 24) + +#: Where each zone sits on the grid when the constructor or a patch names it. +INTERVAL_MIDI = (0, 32, 64, 96, 127) + +RISE_MAX = 0.9 + +#: Bloom's shifter: its window and crossfade, in ms. +WINDOW_MS = 40.0 +OVERLAP_MS = 10.0 + +#: Rise's shifter: the loop's window. A shifted loop reads half of it late +#: (`docs/upstream-diff.md`, `loop_semitones`), so Rise's line is set that +#: much short of Bloom. +LOOP_WINDOW_MS = 25.0 + +#: The line: Bloom's top plus the loop window, the furthest a shifted tap +#: reads. +LINE_MS = 800.0 + LOOP_WINDOW_MS + +#: `audiodelays.PitchShift` reads in whole 256ths of unity +#: (`PitchShift.c:121`), rounding down. +STEPS = 256 + +#: Each macro's patch-0 step; the constructor defaults are these steps' +#: values, so a fresh instance is patch 0. +DEFAULT_STEPS = (127, 89, 0, 90, 96, 94, 64, 91, 32) + + +def _default(index, span): + return _component.macro_value( + span, _component.position_of_midi("UNIPOLAR", DEFAULT_STEPS[index])) + + +#: The constructor defaults: each patch-0 step's value. NaN falls back to +#: these. +SHIMMER = _default(1, (0.0, 1.0)) +RISE = _default(2, (0.0, RISE_MAX)) +BLOOM_MS = _default(3, (20.0, 800.0, "log")) +DECAY = _default(5, (0.3, 10.0, "log")) +SIZE = _default(6, (0.5, 1.5)) +DAMPING_HZ = _default(7, (500.0, 16000.0, "log")) +MIX = _default(8, (0.0, 2.0)) +DEFAULTS = (1.0, SHIMMER, RISE, BLOOM_MS, _default(4, (0.0, 5.0)), DECAY, + SIZE, DAMPING_HZ, MIX) + +#: The hall's macros the rack drives, by `Reverb`'s labels. +_HALL_DECAY_I = _reverb.Reverb.MACRO_LABELS.index("Decay") +_HALL_SIZE_I = _reverb.Reverb.MACRO_LABELS.index("Size") +_HALL_DAMPING_I = _reverb.Reverb.MACRO_LABELS.index("Damping") + +#: Which of the rack's macros drive which of the hall's. +_HALL_MACROS = ((DECAY_I, _HALL_DECAY_I), (SIZE_I, _HALL_SIZE_I), + (DAMPING_I, _HALL_DAMPING_I)) + + +def _hall_patch(): + """The hall at the rack's patch 0, on `Reverb`'s grid: Concert Hall's + Diffusion, Bandwidth, Low Cut, Mod Depth, Mod Rate, Width and Tone, the + hall character, no Predelay (Bloom is the predelay), the tail alone, + and the rack's own Decay, Size and Damping steps.""" + concert = None + for _index, (name, values) in _reverb.Reverb.PATCHES.items(): + if name == "Concert Hall": + concert = list(values) + labels = _reverb.Reverb.MACRO_LABELS + concert[labels.index("Character")] = 127 + concert[labels.index("Predelay")] = 0 + concert[labels.index("Mix")] = 127 + for ours, theirs in _HALL_MACROS: + concert[theirs] = DEFAULT_STEPS[ours] + return tuple(concert) + + +HALL_PATCH = _hall_patch() + + +def _hall_options(): + """`Reverb`'s constructor options at `HALL_PATCH`'s grid values.""" + spans = _reverb.Reverb._MACRO_RANGES + names = ("decay", "size", "predelay_ms", "diffusion", "damping_hz", + "bandwidth_hz", "low_cut_hz", "mod_depth_ms", "mod_rate_hz", + "width", "tone_db", "mix") + options = {"character": "hall"} + for index, name in zip(range(1, 13), names): + mode = _reverb.Reverb.MACRO_MODES.get(index, "UNIPOLAR") + position = _component.position_of_midi(mode, HALL_PATCH[index]) + options[name] = _component.macro_value(spans[index], position) + return options + + +def step_centre_semitones(step): + """Semitones that put `audiodelays.PitchShift` on `step`: the centre of + the step, so the node's truncation lands on it at any float width.""" + return 12.0 * math.log((step + 0.5) / STEPS) / math.log(2.0) + + +def interval_step(semitones): + """The shifter's read rate nearest `semitones`, in 256ths.""" + return int(math.floor(2.0 ** (semitones / 12.0) * STEPS + 0.5)) + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +class ShimmerHall(_component.Component): + """A pitch shifter around a hall. The module docstring is the player's + page.""" + + NAME = 'ShimmerHall' + DISPLAY_NAME = 'Shimmer Hall' + CATEGORIES = ('Effect Rack', 'Reverb') + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioroute", "audioecho", "audioverb") + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + INPUT_CEILING_DBFS = -24.0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Character", "Shimmer", "Rise", "Bloom", "Interval", + "Decay", "Size", "Damping", "Mix") + MACRO_MODES = { + 0: "TOGGLE", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + } + _MACRO_RANGES = ( + (0.0, 1.0), # 0 Character: Bloom < 0.5 <= Rise + (0.0, 1.0), # 1 Shimmer + (0.0, RISE_MAX), # 2 Rise, the loop's feedback + (20.0, 800.0, "log"), # 3 Bloom, ms + (0.0, 5.0), # 4 Interval, zone min(4, int(v)) + (0.3, 10.0, "log"), # 5 Decay, s (the hall's) + (0.5, 1.5), # 6 Size (the hall's) + (500.0, 16000.0, "log"), # 7 Damping, Hz + (0.0, 2.0), # 8 Mix; dry at unity to 1 + ) + + #: `_component.macro_of` of the card's settings. + PATCHES = { + 0: ("Octave Rise", DEFAULT_STEPS), + 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. + 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)), + # Patch 0 in Bloom, at the card's Rise: every Bloom patch runs over + # a block on an ESP32-S3, so the defaults are its Rise twin. + 6: ("Octave Bloom", (0, 89, 85, 90, 96, 94, 64, 91, 32)), + } + + def _build(self, character="rise", shimmer=SHIMMER, rise=RISE, + bloom_ms=BLOOM_MS, interval=12, decay=DECAY, size=SIZE, + damping_hz=DAMPING_HZ, mix=MIX, patch=None): + if character not in CHARACTERS: + raise ValueError("character must be 'bloom' or 'rise', not %r" + % (character,)) + if isinstance(interval, bool) or interval not in INTERVALS: + raise ValueError("interval must be one of %r semitones" + % (INTERVALS,)) + spans = self._MACRO_RANGES + defaults = DEFAULTS + options = (None, shimmer, rise, bloom_ms, None, decay, size, + damping_hz, mix) + # A default lands on its own 0-127 step, so a fresh instance reads + # patch 0 exactly. Any other value is kept as the knob's position, + # between steps if it falls there, as a host with finer resolution + # than MIDI would set it: it is the knob, not a value kept beside + # it, so a patch or a move replaces it. + positions = [1.0 if character == "rise" else 0.0] + for index in range(1, len(spans)): + if index == INTERVAL_I: + positions.append(_component.position_of_midi( + "UNIPOLAR", INTERVAL_MIDI[INTERVALS.index(interval)])) + continue + span = spans[index] + value = _between(_option(options[index], defaults[index]), + span[0], span[1]) + if value == defaults[index]: + positions.append(_component.position_of_midi( + "UNIPOLAR", DEFAULT_STEPS[index])) + else: + positions.append(_component.macro_position(span, value)) + + rate = self._sample_rate + channels = self._channel_count + width = 2 * channels + #: True once the macros are seeded; until then nothing is pushed. + self._ready = False + #: True once the voices have been played (`_wire`). + self._wired = False + #: Whether the host has asked for the output yet. Until it has, + #: nothing has been pulled, and a control that moves a voice's level + #: re-arms the graph so the level holds from frame 0. A reset or a + #: return from Mix 0 does not start that window again: the class + #: cannot see a pull, and a host that keeps the node it took would + #: have every later level move empty the hall. + self._taken = False + #: The two Mixer levels last handed to the voices. + self._applied = None + #: True while the wet chain is Rise's. + self._rise = None + #: What each node was last handed, by name. + self._handed = {} + self._feedback = 0.0 + self._delay = 1.0 + self._bloom = 1 + + # The borrowed source's only reader: a width-1 MidSide is the source + # byte for byte in 256-frame blocks, and it is what Mix 0 hands + # back, so a move to or from Mix 0 never skips or repeats a frame. + self._head = self._own(audioroute.MidSide( + self._source, width=1.0, sample_rate=rate, + channel_count=channels), reset=False) + # A MidSide with no source: one block of zeros per pull, which the + # shifter and the voices prime from (`_wire`). + self._hush = self._own(audioroute.MidSide( + width=1.0, sample_rate=rate, channel_count=channels), + reset=False) + self._in = self._own(audioroute.Port(self._hush), reset=False) + # A Splitter's taps are read in step, 256 frames a pull each, so it + # holds nothing between pulls and is not reset. + self._split = self._own(audioroute.Splitter(self._in, taps=2), + reset=False) + self._dry_tap = self._split.tap(0) + self._wet_tap = self._split.tap(1) + # Bloom's shifter reads the wet tap through a port of its own. A + # shifter's `play()` primes a block from its source and holds it, + # so it is played on `_hush` and that block is pulled out at once + # (`_drain`): the shifter then holds nothing, and whichever of the + # two characters reads the wet tap reads it in step with the dry. + self._shift_in = self._own(audioroute.Port(self._hush), reset=False) + self._shift = self._own(audiodelays.PitchShift( + semitones=0.0, mix=0.0, + window=whole_frames(WINDOW_MS, rate) * width, + overlap=whole_frames(OVERLAP_MS, rate) * width, + **self._pcm(BLOCK * width))) + self._shift.play(self._shift_in) + self._fd = self._own(audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=LINE_MS, + delay_frames=1, feedback=0.0, mix=2.0, damping_hz=0.0, + cut_hz=0.0, loop_drive=0.0, loop_window_ms=LOOP_WINDOW_MS), + reset=self._clear_line) + self._hall = self._Hall(self._fd) + self._own(self._hall, reset=self._hall._clear, + deinit=self._hall.deinit) + # Not reset by the walk: neither voice holds a block between two + # pulls (`_wire`), so there is nothing of its own to clear, and a + # Mixer's reset reaches its voices' sources. + self._mixer = self._own(audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * width * 2, + channel_count=channels, bits_per_sample=16, + samples_signed=True, sample_rate=rate), reset=False) + # The output: a width-1 MidSide over the mixer, byte for byte, whose + # reset forwards nothing. A host's reset of the output would + # otherwise reach the Mixer, and CircuitPython's own Mixer handles + # that differently from audiodsp's. + self._tail = self._own(audioroute.MidSide( + self._mixer, width=1.0, sample_rate=rate, + channel_count=channels), reset=False) + self._silence = audiocore.RawSample( + array("h", bytes(2 * 2 * channels)), sample_rate=rate, + channel_count=channels) + #: Where `audiocore.get_buffer` is compiled out, the one-voice + #: mixer `_drain` primes from; built the first time it is needed. + self._puller = None + self._drain(self._shift) + self._shift_in.play(self._wet_tap) + self._output = self._head + + self._init_macros(tuple(defaults)) + self._macros = positions + if patch is not None: + self.program_change(patch) + self._ready = True + self._refresh() + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _is_rise(self): + return self._macros[CHARACTER_I] >= 0.5 + + def _interval(self): + """Interval in semitones: the zone its position sits in.""" + zone = int(self._value(INTERVAL_I)) + if zone > 4: + zone = 4 + if zone < 0: + zone = 0 + return INTERVALS[zone] + + def _mix(self): + return _between(self._value(MIX_I), 0.0, 2.0) + + def macro(self, index): + """What macro `index` currently stands for, in its own units; for + Interval, the semitones it plays.""" + self._check_live() + if index == INTERVAL_I: + return float(self._interval()) + return _component.Component.macro(self, index) + + # -- hooks a planted fault overrides -------------------------------- + + def _semitones(self): + """The interval the shifters are handed.""" + return self._interval() + + def _stagger_frames(self, bloom_ms): + """Bloom's stagger: the line's delay, in whole frames.""" + return max(1, whole_frames(bloom_ms, self._sample_rate)) + + def _loop_frames(self, bloom_ms): + """Rise's line: Bloom less the half window a shifted read adds, so + the repeats fall Bloom apart.""" + frames = whole_frames(bloom_ms, self._sample_rate) \ + - LOOP_WINDOW_MS * 0.0005 * self._sample_rate + return max(1.0, frames) + + def _loop_feedback(self): + """What the line sends round: Rise in Rise, nothing in Bloom.""" + if self._is_rise(): + return _between(self._value(RISE_I), 0.0, RISE_MAX) + return 0.0 + + def _loop_damping(self): + """The loop's low-pass: Damping in Rise, out in Bloom.""" + if self._is_rise(): + return self._hz(_between(self._value(DAMPING_I), 500.0, + 16000.0)) + return 0.0 + + def _dry_source(self): + """What the dry voice plays: the Splitter's dry tap.""" + return self._dry_tap + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del index, position + if self._ready: + self._refresh() + + def _apply_positions(self, positions, patch_index): + """Set the knobs whole, then refresh the graph once: a patch and a + rack's put-back after a reset both land here, so neither passes + through a Mix 0 or a half-moved Character on the way.""" + ready = self._ready + self._ready = False + try: + _component.Component._apply_positions(self, positions, + patch_index) + finally: + self._ready = ready + if ready: + self._refresh() + + def _hand(self, node, key, **settings): + """Hand `node` only the settings that changed since it was last + handed them.""" + changed = {} + for name, value in settings.items(): + tag = key + name + if self._handed.get(tag) != value: + self._handed[tag] = value + changed[name] = value + if changed: + if node is self._fd: + node.set(**changed) + else: + for name, value in changed.items(): + setattr(node, name, value) + + def _refresh(self): + rise = self._is_rise() + bloom_ms = self._value(BLOOM_I) + shimmer = _between(self._value(SHIMMER_I), 0.0, 1.0) + semitones = self._semitones() + recut = self._rise is not None and rise != self._rise + self._feedback = self._loop_feedback() + self._bloom = whole_frames(bloom_ms, self._sample_rate) + if rise: + self._delay = self._loop_frames(bloom_ms) + if recut or self._rise is None: + self._fd.play(self._wet_tap) + self._hand(self._fd, "fd.", delay_frames=self._delay, + feedback=self._feedback, mix=shimmer, + damping_hz=self._loop_damping(), + loop_semitones=float(semitones)) + else: + self._delay = float(self._stagger_frames(bloom_ms)) + if recut or self._rise is None: + self._fd.play(self._shift) + self._hand(self._shift, "ps.", + semitones=step_centre_semitones( + interval_step(semitones)), + mix=shimmer * 0.5) + self._hand(self._fd, "fd.", delay_frames=self._delay, + feedback=self._feedback, mix=2.0, + damping_hz=self._loop_damping(), loop_semitones=0.0) + self._rise = rise + for ours, theirs in _HALL_MACROS: + position = self._macros[ours] + tag = "hall.%d" % theirs + if self._handed.get(tag) != position: + self._handed[tag] = position + self._hall.set_macro(theirs, 127.0 * position) + if recut: + # The wet chain is re-cut: it renders as a fresh instance's. + # The dry carries on. + self._clear_nodes(only=(self._shift, self._fd, self._hall)) + self._route() + + def _level_pair(self): + """The dry and the hall's Mixer levels at this Mix.""" + mix = self._mix() + return (min(1.0, 2.0 - mix), min(1.0, mix)) + + def _levels(self): + voices = self._mixer.voice + pair = self._level_pair() + voices[0].level = pair[0] + voices[1].level = pair[1] + self._applied = pair + + def _route(self): + """Point the output at the source's reader at Mix 0, or at the + mixer, emptying the wet chain when it comes back.""" + if self._mix() <= 0.0: + self._route_around(self._head) + return + if not self._wired: + self._wire() + elif self._rejoin(): + self._rearm() + elif not self._taken and self._level_pair() != self._applied: + # Not pulled yet: a level moved now would wait for its voice's + # next zero crossing, a block of the old level, where an + # instance built at this setting plays the new one from frame 0. + # A move that leaves both levels alone has nothing to wait for. + self._rearm() + else: + self._levels() + self._output = self._tail + + @property + def output(self): + """The node the host plays. Asking for it is the host's first step + to pulling, so from here a level that moves is an ordinary move.""" + self._check_live() + self._taken = True + return self._output + + def reset(self): + """Empty the hall, the line and the shifter, return to patch 0, and + play from there as a new instance does.""" + wired = self._wired + _component.Component.reset(self) + if not wired: + return + if self._mix() > 0.0: + self._rearm() + else: + # A rack's put-back can restore Mix 0: the output is the + # source's reader, and the graph behind it waits to be armed + # when Mix comes back up, as it does in a new instance. + self._stranded = True + + def _rearm(self): + """Wire the graph again as the constructor does, once it has been + cleared: a voice's new level is picked up at its next zero crossing + unless the voice is played again, so a graph that is cleared while + a level changes would otherwise carry the old level into the first + frames. The input goes back to silence first, so no frame is taken + from the borrowed source.""" + self._in.play(self._hush) + self._wire() + + def _wire(self): + """Play the voices, the first time the output reaches the mixer. + + A voice's `play()`, like the shifter's, primes a block from its + source. With the Splitter on `_hush` that block is zeros, and one + pull of the mixer hands it out, so wiring takes nothing from the + borrowed source and leaves no voice holding a block: a reset or a + return from Mix 0 never meets a block a voice is still waiting to + play. The level gates are opened first, as + `_component.open_level_gates` opens them.""" + voices = self._mixer.voice + self._levels() + for voice in voices: + voice.play(self._silence, loop=True) + self._pull() + voices[0].play(self._dry_source()) + voices[1].play(self._hall.output) + self._pull() + self._in.play(self._head) + self._clear_nodes(only=(self._shift, self._fd, self._hall)) + self._wired = True + # A control touched at Mix 0 before the first wiring marked the + # graph stranded; wiring is what that mark waits for. + self._stranded = False + + def _pull(self): + """Pull one block from the mixer and drop it. `_tail` forwards no + reset, so pulling through it rewinds nothing behind it.""" + self._drain(self._tail) + + def _drain(self, node): + """Pull one block from `node` and drop it. + + Where `audiocore.get_buffer` is compiled out (CircuitPython's + default board builds), a voice of a one-voice mixer of our own + plays `node` and stops: its `play()` primes one block, which pulls + `node` once.""" + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(node) + return + if self._puller is None: + # Nothing ever pulls this mixer, so its own buffers are the + # smallest that hold a frame. + self._puller = self._own(audiomixer.Mixer( + voice_count=1, buffer_size=16, + channel_count=self._channel_count, bits_per_sample=16, + samples_signed=True, sample_rate=self._sample_rate), + reset=False) + voice = self._puller.voice[0] + voice.play(node) + voice.stop() + + def _clear_line(self): + """Empty the line. A shifted loop goes back to its single-tap read + and starts its splice from where a new node does.""" + self._fd.clear() + + # -- the contract's reads ------------------------------------------ + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`. + + Bloom: the stagger, then the shifter's window and crossfade (what + it holds once its input stops), then the hall's own tail. Rise: the + line goes round `laps_to_zero` laps at Rise (the node's stall rule + ends it), each at most Bloom plus the loop window, the read between + two frames and the low-pass's memory, and one more for what the line + holds at Rise 0; then the hall's tail. 0 at Mix 0.""" + if self._mix() <= 0.0: + return 0 + rate = self._sample_rate + hall = self._hall.tail_samples + if not self._rise: + reach = self._bloom + whole_frames(WINDOW_MS, rate) \ + + whole_frames(OVERLAP_MS, rate) + 1 + return int(reach + hall) + memory, excess = tone_excess(self._handed.get("fd.damping_hz", 0.0), + rate) + lap = self._bloom + int(math.ceil(LOOP_WINDOW_MS * rate / 1000.0)) \ + + 2 + memory + laps = laps_to_zero(self._feedback, excess) + 1 + return int(laps * lap + hall) + + class _Hall(_reverb.Reverb): + """The rack's hall: `Reverb` whose only patch is the hall at the + rack's patch-0 settings, and whose constructor defaults are that + patch, so its own `reset()` lands there. Nested, so the module holds + one provider; last, so the rack's `_build` is the first in this + source.""" + + PATCHES = {0: ("Hall", HALL_PATCH)} + + def _build(self, patch=None, **options): + settings = _hall_options() + settings.update(options) + _reverb.Reverb._build(self, patch=patch, **settings) + + def _clear(self): + """Empty the tank and keep every macro: a reset without its + patch 0.""" + self._clear_nodes() diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index 59bb943..c479ef8 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -173,6 +173,8 @@ class SlapbackDelay(_component.Component): VERSION = '0.1.0' TIER = _component.AUDIODSP + #: The exact constructor values a rack's reset puts back with the knobs. + _KEPT = ('_time_exact',) REQUIRES = ("audioecho",) CAPABILITIES = () @@ -323,17 +325,15 @@ def _apply_macro(self, index, position): if not self._deferred: self._refresh() - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then refresh the node once.""" + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh the node once.""" self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = False - if type(self).PATCHES.get(index) is not None: - self._refresh() + self._refresh() def reset(self): _component.Component.reset(self) diff --git a/lib/audioeffects/rebuilt/stereowidener.py b/lib/audioeffects/rebuilt/stereowidener.py new file mode 100644 index 0000000..887e139 --- /dev/null +++ b/lib/audioeffects/rebuilt/stereowidener.py @@ -0,0 +1,426 @@ +"""`StereoWidener` - a stereo widener with Haas and Mid/Side characters. + +The player's text is the class docstring; the `CLAIMS` table in the class's +test file ties its claim sentences to tests. +""" + +VENDOR = "PyDevices" + +from array import array + +import audiocore + +from .. import _component + +try: + from .digitaldelay import whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import whole_frames + +try: + import audioecho + import audiomixer + import audioroute +except ImportError: # pragma: no cover - a stock board + audioecho = audiomixer = audioroute = None + + +#: The Delay span, in ms, swept by ratio. +DELAY_MIN_MS = 1.0 +DELAY_MAX_MS = 40.0 + +#: The line: Delay's top plus 1 ms, so the node never clamps the read. +LINE_MS = DELAY_MAX_MS + 1.0 + +#: How fast a Delay move walks the read head, in delay-seconds per second: +#: 3/16, exact in every float format, a pitch bend of x1.1875 while it moves. +SLEW = 0.1875 + +#: The hardest a Mixer voice pans: the right (or left) scale is then exactly +#: 0, where a panning of -1 or +1 leaves one LSB of it. +HARD = 32767.0 / 32768.0 + +#: The frames a mixer pull hands out, and so the frames each voice and each +#: Splitter tap take per pull. +BLOCK = 256 + +#: How close, as a 0..1 knob position, a Delay write must come to the grid +#: step the constructor's `delay_ms` seeded to count as a host echoing it +#: back: 1e-6 is 1/7 874 of one MIDI step, and holds a single-precision +#: round trip. +ECHO_TOLERANCE = 1e-6 + +#: The patches' Delays, exactly, by MIDI step: Delay on one of these steps +#: plays the round number the patch table names (14 ms, not the step's +#: 14.06), whichever way it got there. +DELAY_STEPS = {62: 6.0, 91: 14.0, 111: 25.0} + +#: How close, in MIDI steps, Delay must sit to a step in `DELAY_STEPS`. +STEP_TOLERANCE = 1e-4 + +WIDTH_I, DELAY_I, CHARACTER_I = range(3) + +CHARACTERS = ("haas", "mid_side") + +#: The constructor defaults, which NaN falls back to. +DEFAULT_WIDTH = 1.0 +DEFAULT_DELAY_MS = 14.0 + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +def haas_voices(width): + """((lead level, lead panning), (copy level, copy panning)) for a Haas + Width: up to 1 the lead keeps the left at unity and turns its right down + by the Width while the copy comes up in the right; above 1 the copy + stays at unity and the lead turns down to 1/Width.""" + if width <= 1.0: + return (1.0, -min(width, HARD)), (width, HARD) + return (1.0 / width, -HARD), (1.0, HARD) + + +class StereoWidener(_component.Component): + """Widen a stereo part: Haas delays the right channel a few milliseconds + behind the left, and Mid/Side scales the difference between the channels. + + **The controls.** Width runs from 0 to 2, with its centre detent at 1. + Delay runs from 1 to 40 ms, and only Haas uses it. + Character picks Haas or Mid/Side. + + **Haas.** The ear places a sound by the first arrival and hears the late + copy as width. + At Width 1 the left channel passes untouched and the right channel is + your right channel, exactly Delay late. + Between Width 0 and 1 the right channel crossfades from itself to its + late copy, which colours it most at 0.5. + Width 0 is a wire. + Above Width 1 the copy stays where it is and the left turns down, 6 dB + at Width 2, to pull back the image the left's early arrival leans its + way. + Turning Delay glides the copy to the new time instead of clicking, + bending its pitch while it moves. + Haas folds to mono as a comb filter: at Width 1 with the default 14 ms, + the mono sum has full-depth notches at 35.7, 107 and 179 Hz and on up. + Use Mid/Side when the mix may be summed. + + **Mid/Side.** Summed to mono, the output is the same at every Width, + within one LSB. + Width 0 is mono, 1 is a wire and 2 doubles the sides. + Above Width 1, a part panned hard left shows up inverted on the right. + A part that is the same in both channels comes out unchanged. + Peaks below half of full scale never clip at any Width. + + **Mono, latency, tail.** A one-channel source comes out exactly as it + went in, whatever the controls say. + To widen a mono part, make it stereo first (a stereo chorus, say) and + put this after it. + A click in the left comes out on the frame it went in: there is no + latency. + `tail_samples` is the copy's reach in Haas, and 0 in Mid/Side or at + Width 0. + `reset()` empties the line and returns to patch 0. + The class never reads the host's tempo. + + **Limits shared by the family.** + A control that jumps, Width or Character, makes the output step: move it + in small steps from the host if you need it smooth. + """ + + NAME = 'StereoWidener' + DISPLAY_NAME = 'Stereo Widener' + CATEGORIES = ('Spatial',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho", "audioroute") + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + #: Half of full scale, rounded down a step: Mid/Side at Width 2 doubles + #: an anti-phase pair's side, and 2 x 16 383 still fits. + INPUT_CEILING_DBFS = -6.03 + + #: The constructor's Delay, exactly, which a rack's reset puts back + #: with the knobs. + _KEPT = ("_delay_exact",) + + MACRO_LABELS = ("Width", "Delay", "Character") + MACRO_MODES = { + 0: "BIPOLAR", + 1: "UNIPOLAR", + 2: "TOGGLE", + } + _MACRO_RANGES = ( + (0.0, 2.0), # 0 Width; MIDI 64 is 1.0 + (DELAY_MIN_MS, DELAY_MAX_MS, "log"), # 1 Delay, ms + (0.0, 1.0), # 2 Character + ) + + #: `_component.macro_of` of the card's patch settings. + PATCHES = { + 0: ("Haas 14 ms", (64, 91, 0)), + 1: ("Haas Tight", (64, 62, 0)), + 2: ("Haas Wide", (64, 111, 0)), + 3: ("Haas Recentred", (96, 91, 0)), + 4: ("Mid/Side Wide", (96, 91, 127)), + 5: ("Mid/Side Narrow", (32, 91, 127)), + 6: ("Mono", (0, 91, 127)), + } + + def _build(self, width=DEFAULT_WIDTH, delay_ms=DEFAULT_DELAY_MS, + character="haas", patch=None): + if character not in CHARACTERS: + raise ValueError("character must be %r or %r, not %r" + % (CHARACTERS[0], CHARACTERS[1], character)) + width = _between(_option(width, DEFAULT_WIDTH), 0.0, 2.0) + delay_ms = _between(_option(delay_ms, DEFAULT_DELAY_MS), + DELAY_MIN_MS, DELAY_MAX_MS) + #: The constructor's Delay, exactly, and the grid step it seeds, so + #: a fresh instance reads a whole MIDI step. The exact value plays + #: until Delay moves (a host echoing `get_macro(1)` does not move + #: it) or a patch is loaded. + self._delay_exact = delay_ms + span = self._MACRO_RANGES[DELAY_I] + self._delay_seed = _component.position_of_midi( + "UNIPOLAR", _component.macro_of(span, delay_ms)) + self._frames = 1 + #: The longest delay, in frames, the read head may still sit at: a + #: falling move walks the head down, so the old Delay holds until + #: the line is next emptied. None while the line is empty and no + #: Delay has been set since: an emptied line snaps onto its Delay. + self._reach = None + self._wired = False + self._ready = False + self._ms = None + if self._channel_count == 2: + self._build_graph() + else: + # One channel has no width to work on: the output is the source. + self._output = self._source + values = (width, delay_ms, + 1.0 if character == CHARACTERS[1] else 0.0) + self._init_macros(values) + self._macros[DELAY_I] = self._delay_seed + # A constructor patch lands before the graph is first set up, so a + # Mid/Side or Width 0 patch never wires the Haas voices (`_wire`). + if patch is not None: + self.program_change(patch) + self._ready = True + self._refresh() + + def _build_graph(self): + rate = self._sample_rate + # The borrowed source's only reader, at Width 1 in Haas and so the + # source byte for byte: a switch between the characters re-points + # the output and never skips or repeats a source frame. + self._ms = self._own(audioroute.MidSide( + self._source, width=1.0, sample_rate=rate, channel_count=2), + reset=False) + # A MidSide with no source: one block of zeros per pull, which the + # voices prime from when they are wired (`_wire`). + self._hush = self._own(audioroute.MidSide( + width=1.0, sample_rate=rate, channel_count=2), reset=False) + self._in = self._own(audioroute.Port(self._hush), reset=False) + self._split = self._own(audioroute.Splitter(self._in, taps=2), + reset=False) + self._line = self._own(audioecho.FeedbackDelay( + sample_rate=rate, channel_count=2, max_delay_ms=LINE_MS, + delay_frames=1, feedback=0.0, mix=2.0, damping_hz=0.0, + cut_hz=0.0, delay_slew=SLEW), reset=self._clear) + self._line.play(self._split.tap(1)) + # Not reset by the walk: neither voice holds a block between two + # pulls (`_wire`), so there is nothing of its own to clear. + self._mixer = self._own(audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * 2 * 4, channel_count=2, + bits_per_sample=16, samples_signed=True, sample_rate=rate), + reset=False) + # The Haas output: a width-1 MidSide, byte for byte, whose reset + # forwards nothing. A host's reset of the output (a mixer voice's + # `play()` does one) would otherwise reach the Mixer, and + # CircuitPython's own Mixer handles that differently from + # audiodsp's, so the interpreters would part ways. + self._tail = self._own(audioroute.MidSide( + self._mixer, width=1.0, sample_rate=rate, channel_count=2), + reset=False) + self._silence = audiocore.RawSample( + array("h", bytes(2 * 2 * 2)), sample_rate=rate, channel_count=2) + #: Where `audiocore.get_buffer` is compiled out, the one-voice + #: mixer `_pull` primes from `_tail`; built the first time it is + #: needed. + self._puller = None + self._output = self._ms + + def _wire(self): + """Play the voices, the first time the output reaches the mixer. + + A voice's `play()` primes a block from its source. With the + Splitter on `_hush` that block is zeros, and one pull of the mixer + hands it out, so wiring takes nothing from the borrowed source and + leaves no voice holding a block: whatever the MidSide has handed + out, the output has played, at every Width and Character. So a + reset or a return to Haas that empties the line never empties a + block a voice is still waiting to play, and a voice never plays a + block read at a Delay the line has since left. The level gates are + opened first, as `_component.open_level_gates` opens them.""" + voices = self._mixer.voice + self._levels() + for voice in voices: + voice.play(self._silence, loop=True) + self._pull() + voices[0].play(self._split.tap(0)) + voices[1].play(self._line) + self._pull() + self._in.play(self._ms) + self._clear() + self._wired = True + + def _pull(self): + """Pull one block from the mixer and drop it. + + Where `audiocore.get_buffer` is compiled out (CircuitPython's + default board builds), a voice of a one-voice mixer of our own + plays `_tail` and stops: its `play()` primes one block, which pulls + the mixer once. `_tail` forwards no reset, so the play rewinds + nothing behind it.""" + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(self._mixer) + return + if self._puller is None: + # Nothing ever pulls this mixer, so its own buffers are the + # smallest that hold a stereo frame. + self._puller = self._own(audiomixer.Mixer( + voice_count=1, buffer_size=16, channel_count=2, + bits_per_sample=16, samples_signed=True, + sample_rate=self._sample_rate), reset=False) + voice = self._puller.voice[0] + voice.play(self._tail) + voice.stop() + + def _clear(self): + self._line.clear() + self._reach = None + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _width(self): + return _between(self._value(WIDTH_I), 0.0, 2.0) + + def _mid_side(self): + return self._macros[CHARACTER_I] >= 0.5 + + def _delay_ms(self): + if self._delay_exact is not None: + return self._delay_exact + midi = _component.midi_of_position("UNIPOLAR", + self._macros[DELAY_I]) + step = int(midi + 0.5) + if abs(midi - step) <= STEP_TOLERANCE and step in DELAY_STEPS: + return DELAY_STEPS[step] + return self._value(DELAY_I) + + def macro(self, index): + """What macro `index` currently stands for, in its own units; for + Delay, the milliseconds it plays.""" + self._check_live() + if index == DELAY_I: + return self._delay_ms() + return _component.Component.macro(self, index) + + def _levels(self): + lead, copy = haas_voices(self._width()) + voices = self._mixer.voice + voices[0].level, voices[0].panning = lead + voices[1].level, voices[1].panning = copy + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + if index == DELAY_I and self._ready and not self._seeding and ( + abs(position - self._delay_seed) > ECHO_TOLERANCE): + self._delay_exact = None + if self._ready: + self._refresh() + + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh the graph + once. A patch drops the constructor's Delay; a restore keeps the + one it put back (`_KEPT`), so the two share this one path.""" + ready = self._ready + self._ready = False + try: + _component.Component._apply_positions(self, positions, + patch_index) + finally: + self._ready = ready + if not self._seeding: + self._delay_exact = None + if ready: + self._refresh() + + def _refresh(self): + if self._ms is None: + return + self._frames = max(1, whole_frames(self._delay_ms(), + self._sample_rate)) + self._line.set(delay_frames=self._frames) + self._route() + if self._reach is None or self._frames > self._reach: + self._reach = self._frames + + def _route(self): + """Point the output at the MidSide (Mid/Side, or Haas Width 0) or + at the Haas mixer, emptying the line when the mixer comes back.""" + width = self._width() + if self._mid_side(): + self._ms.set(width=width) + self._route_around(self._ms) + return + self._ms.set(width=1.0) + if width <= 0.0: + self._route_around(self._ms) + return + if not self._wired: + self._wire() + else: + self._levels() + self._rejoin(keep=(self._ms,)) + self._output = self._tail + + def _haas_live(self): + return (self._ms is not None and not self._mid_side() + and self._width() > 0.0) + + @property + def tail_samples(self): + """What this bound promises is in the class docstring.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check: a plain method, so a + subclass can reach it on MicroPython, whose `property` has no + `fget`.""" + if not self._haas_live(): + return 0 + return (self._reach or self._frames) + 1 diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py index dcc53a5..6e2aa62 100644 --- a/lib/audioeffects/rebuilt/tapedelay.py +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -343,6 +343,8 @@ class TapeDelay(_component.Component): VERSION = '0.1.0' TIER = _component.AUDIODSP + #: The exact constructor values a rack's reset puts back with the knobs. + _KEPT = ('_glide_exact', '_time_exact') REQUIRES = ("audioecho",) CAPABILITIES = ("tempo_sync",) @@ -604,18 +606,16 @@ def _apply_macro(self, index, position): if not self._deferred: self._refresh() - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - """Apply patch `index` whole, then refresh the node once, so Time + def _apply_positions(self, positions, patch_index): + """Apply a patch (or a rack's restore) whole, then refresh the node once, so Time is read against the new patch's Sync and moves once.""" self._deferred = True try: - _component.Component.program_change( - self, index, channel, note_id, sample_position) + _component.Component._apply_positions(self, positions, + patch_index) finally: self._deferred = False - if type(self).PATCHES.get(index) is not None: - self._refresh() + self._refresh() def reset(self): _component.Component.reset(self) diff --git a/lib/audioeffects/reverb.py b/lib/audioeffects/reverb.py index ce5dca9..f63dc1b 100644 --- a/lib/audioeffects/reverb.py +++ b/lib/audioeffects/reverb.py @@ -162,17 +162,12 @@ def decay_seconds(self): def set_mix(self, mix): self.node.set(mix=mix) - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): + 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. - if int(index) not in self.PATCHES: - return self._ready = False try: - _core.Effect.program_change(self, index, channel=channel, - note_id=note_id, - sample_position=sample_position) + _core.Effect._apply_positions(self, positions, patch_index) finally: self._ready = True self._rebuild() diff --git a/lib/audioeffects/saturation.py b/lib/audioeffects/saturation.py index d055278..8c0a5f8 100644 --- a/lib/audioeffects/saturation.py +++ b/lib/audioeffects/saturation.py @@ -1189,10 +1189,8 @@ def _rearm(self): self._silence) self._attached = False - def program_change(self, index, channel=0, note_id=-1, - sample_position=0): - _component.Component.program_change(self, index, channel, note_id, - sample_position) + def _apply_positions(self, positions, patch_index): + _component.Component._apply_positions(self, positions, patch_index) self._route() self._refresh_output() diff --git a/tests/airspace_plants.py b/tests/airspace_plants.py new file mode 100644 index 0000000..c3bbba5 --- /dev/null +++ b/tests/airspace_plants.py @@ -0,0 +1,115 @@ +"""`AirSpace`'s planted faults, one or two per trait of its card. + +Kept apart from the test module, and free of numpy and unittest, so the +kit's plant-validity check runs them on MicroPython and CircuitPython too +(`tests/support/kit.py AirSpace --plants-module airspace_plants`). The test +module imports them from here and lists them in its own `KIT_PLANTS`. +""" + +import audiobiquad + +from audioeffects import rebuilt +from audioeffects.rebuilt import airspace as space + +#: Read by `_component`'s metadata check off the module a class lives in. +VENDOR = "PyDevices" + +AirSpace = rebuilt.module_class("AirSpace") + + +class SendFirst(AirSpace): + """T1: the Top Cut ahead of the Splitter, as the old rack had it, so the + dry is filtered too; the send's own filter is left open.""" + + def _build(self, *args, **options): + self._pre = None + AirSpace._build(self, *args, **options) + self._pre = audiobiquad.Biquad( + mode=audiobiquad.LOW_PASS, + frequency=self._hz(self._value(space.TOP_CUT_I)), + sample_rate=self._sample_rate, channel_count=self._channel_count) + self._pre.play(self._source) + self._adapter.play(self._pre) + self._own(self._pre, reset=self._pre.clear) + self._lp.frequency = self._hz(20000.0) + + def _apply_macro(self, index, position): + AirSpace._apply_macro(self, index, position) + pre = getattr(self, "_pre", None) + if pre is not None and index == space.TOP_CUT_I: + pre.frequency = self._hz(self._value(space.TOP_CUT_I)) + self._lp.frequency = self._hz(20000.0) + + +class LoopOnSend(AirSpace): + """T2, first half: the loop's low-pass taken out of the loop and put on + the send as a second Biquad, so every repeat is darkened alike.""" + + def _build(self, *args, **options): + self._loop_lp = None + self._loop_hz = space.DEFAULTS[space.LOOP_TOP_I] + AirSpace._build(self, *args, **options) + self._loop_lp = audiobiquad.Biquad( + mode=audiobiquad.LOW_PASS, frequency=self._loop_hz, + sample_rate=self._sample_rate, channel_count=self._channel_count) + self._loop_lp.play(self._fd) + self._lp.play(self._loop_lp) + self._own(self._loop_lp, reset=self._loop_lp.clear) + + def _hand_tape(self, **settings): + if "damping_hz" in settings: + self._loop_hz = settings["damping_hz"] + settings["damping_hz"] = 0.0 + loop_lp = getattr(self, "_loop_lp", None) + if loop_lp is not None: + loop_lp.frequency = self._loop_hz + AirSpace._hand_tape(self, **settings) + + +class TopCutInLoop(AirSpace): + """T2, second half: Top Cut wired to the loop's low-pass instead of the + send's filter, so it darkens each repeat more than the last.""" + + def _apply_macro(self, index, position): + AirSpace._apply_macro(self, index, position) + if self._ready and index in (space.TOP_CUT_I, space.LOOP_TOP_I): + self._hand_tape(damping_hz=self._hz(self._value(space.TOP_CUT_I))) + self._lp.frequency = self._hz(20000.0) + + +class NoFeedback(AirSpace): + """T3: the node is handed no feedback, whatever Feedback says.""" + + def _hand_tape(self, **settings): + if "feedback" in settings: + settings["feedback"] = 0.0 + AirSpace._hand_tape(self, **settings) + + +class BareRepeats(AirSpace): + """T4: the chamber is a wire: the repeats reach the output bare.""" + + def _wet_source(self): + return self._lp + + +class JumpTime(AirSpace): + """T5: the node is handed no slew, so a Time move jumps.""" + + def _hand_tape(self, **settings): + if "delay_slew" in settings: + settings["delay_slew"] = 0.0 + AirSpace._hand_tape(self, **settings) + + +#: The kit's plant table (KIT.md, plants). NoFeedback is judged at Long +#: Send: at the defaults it is what Feedback 0 renders, a setting rather +#: than a fault. +KIT_PLANTS = { + "T1": SendFirst, + "T2a": LoopOnSend, + "T2b": TopCutInLoop, + "T3": (NoFeedback, 2), + "T4": BareRepeats, + "T5": JumpTime, +} diff --git a/tests/support/KIT.md b/tests/support/KIT.md new file mode 100644 index 0000000..b72b1ca --- /dev/null +++ b/tests/support/KIT.md @@ -0,0 +1,252 @@ +# The effects kit + +Seven 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. + +To run all seven on one class, from the repo root: + + python tests/support/kit.py DigitalDelay + python tests/support/kit.py DigitalDelay --only tail,ceiling --quick + MICROPYPATH=lib:tests/support micropython -X heapsize=256M \ + tests/support/kit.py DigitalDelay --quick + +Each check also runs alone (`python tests/support/kit_tail.py +DigitalDelay`). `--quick` trims the grid (below); `--no-run` makes the +claims check look the tests up without running them; `--plants-module` and +`--claims-module` name a test module other than the class's own. + +It prints one line per cell, `name|check|key|V:verdict[|h:digest]`, a +`SUMMARY|name|check|cells N|red R|declared D|na A` line per check, and +`DONE`. A verdict is `ok`, `na(...)` (the cell cannot show anything, said +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. + +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 +the ceiling check is red on it. + +## The checks + +**tail** (`kit_tail.py`). The output reaches exact zero inside the tail +the class reports, read after every event. The material: a DC step to +-6 dBFS, noise at +-3 LSB, noise at -60 dBFS and full-scale noise, 4096 +frames each, then silence. The events are the lifecycle matrix's own +(reset, each macro to each stop, Mix to 0, each patch change), plus none. +The move lands inside the material; with `back` its way back lands 1536 +frames after the input stops. Bar: the output's last non-zero frame is +within `tail_samples` (read after the last action) + `latency_samples` + +one block of the input's end or the last action, whichever is later +(lifecycle P2's bound). A class whose `tail_samples` is None promises no +bound, so it must never fall silent: 3 s past the input the output must +still be non-zero in the last second, `na(tail None)`, or the cell is red +(the class could have stated a bound). Grid: every patch, 48 and 22.05 kHz, +stereo and mono; `--quick` runs the full events at the defaults and the +lifecycle's quick subset at the patches, with no `back` placements. + +**plants** (`kit_plants.py`). Each planted fault the class's tests use +differs from the clean class, and no knob position or patch of the clean +class renders what it renders. The class's test module (by default +`tests/test_cpython_effects_.py`) declares them: + + KIT_PLANTS = {"T1": JumpTimeSlapback, "T3": (SplitSlapback, 4)} + +A value is a planted subclass, judged against the clean class at the +defaults, or `(plant, patch)`, judged at that patch (a class with two +characters plants faults in the one the defaults do not use); a third +element, a dict, may give `rate`, `channels` and constructor `options`. The +digest covers the render (an 8192-frame click and noise train, then +silence to the clean class's tail, 16384 frames to 4 s) and the reported +`tail_samples` and `latency_samples`. The walk: the clean class at the +defaults, each macro alone at each stop (0 and 127, and 64 for a bipolar +macro), and every patch. A module that does not import on an interpreter +prints `na(...)`; plants kept in a numpy-free module check natively. + +**claims** (`kit_claims.py`). Every docstring sentence that makes a claim +names a test, which exists and passes. The class's test module holds a +`CLAIMS` table, one row per sentence, word for word: + + CLAIMS = ( + ("Level 0 is a wire.", "test_level_zero_is_a_wire"), + ("Time runs from 40 to 250 ms.", + ("TestKnobs.test_the_spans", "test_the_time_law")), + ("A control that jumps makes the output step: ...", + "DECLARED:TapeDelay,E4-m2=,P5"), + ) + +A test is a method name (any TestCase that defines it), `Class.test_name`, +a tuple of those, or `DECLARED:,,`, a row of +the lifecycle matrix's DECLARED table. A scoped row is named with its +scopes after the property: `patches=1/2/d` (`d` the defaults) and each +interpreter by name, as in `DECLARED:PitchShifter,E10-reset,P4,circuitpython`. "Exists" is an AST lookup in the test +module; "passes" runs that test alone under unittest (a skip is not a pass). +The text is the class's module docstring and then its class docstring. A +sentence is a claim if it holds a digit, a number word, or a bound or +absolute word (always, never, exactly, every, each, all, no, only, at most, +at least, up to, within, under, over, same, unchanged, ...: `CLAIM_WORDS`). +The rule errs on catching too much: a sentence it catches that asserts +nothing is reworded or struck. Cells: `table`, `row` per row, and +`loose` per claim-shaped sentence no row covers. + +**args** (`kit_args.py`). What the control surface does with what a host +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`). +- `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-