diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2139a..3358379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,24 @@ there, and are recorded in its changelog. nearest tom for every tom number a kit does not have. Each machine now sounds exactly the notes in its `NOTE_MAP`, and nothing else. +### Fixed + +- **The effects catch up with the audioif floor at cebb7ca** (#66). Moving + `AUDIOIF_PIN` there for `acoustickit` brought five deliberate audioif + changes, and 27 tests went red. `AutoPan`'s Centre was mirrored, because + CircuitPython 10.3.0 reversed synthio's panning sign; its notes are paired + the other way now. A fresh mixer voice or synthio note now starts at level 0 + and waits for a zero crossing, so the first block of `AutoPan`, `DeEsser` + and `MultibandCompressor` came out silent and then clicked in; each opens + its gates on one block of silence before it takes its source + (`_component.open_level_gates`). `Saturation`'s shelves moved to + `audiobiquad`, because `synthio.Biquad` is CircuitPython's Q15 arithmetic + again (audioif#77) and tape had lost its head bump. `BandPass` T1 now + holds in 55 of 56 cells, both stops included (audioif#64). The rest were + tests holding audioif's old behaviour: released nodes raise `ValueError`, + `Splitter` releases, `Dynamics.reset()` clears its key filters, and there + are 55 instruments. + ### Removed - **`Rotary` is removed.** The Leslie-style rotating-speaker effect is no longer in `audioeffects`; nothing replaces it yet. diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index 34c43ab..6dfd544 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -125,7 +125,7 @@ marginal share of one block on each board at construction defaults. | `DynamicEQ` | audioif (`audiobiquad`, `audiodynamics`, `audioroute`) | 8 | 0 samples | 14.5 % P4 / 26.0 % S3 of a block at patch 0 against the palette-derived 17 % / 29 % (listed remainder; `Splitter` taps=3 is a named gap). T2/T5 miss above ~5 ms attack; T4/T6 out-of-band miss below Q 2 | none - the exactly complementary split: one bell that does nothing until the sound *in that band* crosses a threshold, and a measured **wire** when it is idle | | `LowPass` | audioif (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget, and no `" - lean"` patch is possible: every section runs whatever its `mix` is, so no macro position is cheaper than any other | none - the two-pole analog prototype `H(s) = 1/(s² + 2Rs + 1)`: one knob slides the curve, one decides how loud the corner stands, −3 dB at Resonance 0.707 and +24 dB at 16. **Exact except at the bottom of the Frequency knob**: at f₀ 20 Hz with Q 16 at 24 dB/oct the corner stands 0.44 dB low and the curve stops being one shape below 25 Hz (float32 coefficients near z = 1), and "rings for Q periods" is a 12 dB/oct statement below about 0.17·F_s - at 24 dB/oct the ring runs 1.36× the Resonance number at Q 2 up to 1.71× at Q 16. The roll-off, −12.03 dB/oct on the warped axis with unity DC, holds everywhere | | `HighPass` | audioif (`audiobiquad`) | 5 | 0 samples | 5.7 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget, and no patch on this surface is cheaper - patch 0 is already one live section and two wires | none - RBJ's two-pole low-cut, with an exact transmission zero at DC: a held offset decays to zero rather than to the 71 LSB the ported node parks on at a 10 Hz corner. Two bounds, measured: the two numbers on the panel are exact only above about 40 Hz at 48 kHz - at a 10 Hz corner the peak is 0.6 dB shy of `Resonance` (Q 8, 24 dB/oct) and the curve is 0.21 dB off its own shape, both the node's `float` coefficients rather than the cut - and at 24 dB/oct the corner **rings 1.85x longer** than the knob says, which is the price of reading the same gain at both slopes. The cut, the 12/24 dB/oct skirt and the exact zero at DC hold everywhere | -| `BandPass` | audioif (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. **Above 100 Hz.** Below it, at Q >= 4, the peak loses up to 0.50 dB and the loss changes sign with the level: the kernel's float32 recursion, not the form (audioif#64). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB | +| `BandPass` | audioif (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audioif#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB | | `Notch` | audioif (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audioif (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | | `CombFilter` | audioif (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. Above Feedback 0.5 the parked ring's period is the nearest whole number of samples to F_s/Frequency, not the fractional delay the comb was asked for: +17.4 cents at 1760 Hz / Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — about 70 cents — near 4 kHz. The first-repeat tap still lands within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, the tail reaches exact zero. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | @@ -266,33 +266,34 @@ macros move their children's controls. ## A note on how low a filter can go -Anywhere in the band, is the short answer - but it is worth knowing that -this was not always true, because the failure was silent and you may still -meet it on a stock CircuitPython board (below). - -Every biquad in the engine used to keep its coefficients as Q15 integers, -which is the right trade on a microcontroller and costs low frequencies. -Below about 300 Hz they quantized into something that was no longer the -filter you asked for: a `LowPass` at 100 Hz returned **silence**, a -`HighPass` at 30 Hz returned **+21 dB of noise**, and a low shelf at 80 Hz -lifted the whole band by 13.4 dB instead of its 1.5. A second, unrelated -shortcut in the same file - one polynomial fitted to sine and cosine over -[0, π/2], which is only 12 kHz at 48 kHz - broke the *top* of the band too, -badly enough that a `HighPass` at 22 kHz passed its entire stopband. - -Both are fixed. Coefficients now get as many fractional bits as each -individual filter has room for, the recursion accumulates in 64 bits and -keeps its feedback below the sample grid, and the trigonometry is a proper -series. Measured against the closed-form response, every mode lands within -**0.03 dB from 50 Hz to 22 kHz**. Ten octave bands all read +6.01 dB or -better on a +6 dB request - the claim `GraphicEQ` used to carry, and read on -`ParametricEQ` since both were rebuilt onto `audiobiquad`. The pre-rebuild -`MultibandCompressor`'s three bands recombined flat to 0.23 dB from 30 Hz to -8 kHz on those Q15 biquads; the rebuilt class is on `audiobiquad`'s float -sections instead, for the tail rather than the shape, and sums to 0.12 dB -from 30 Hz to 20 kHz. [audioif's `docs/upstream-diff.md`](https://github.com/PyDevices/audioif/blob/main/docs/upstream-diff.md), -"The biquads were Q15, so they could not go low", has the arithmetic, the -before-and-after table, and what it cost in instructions on an M0. +Anywhere in the band on `audiobiquad`, which is what the rebuilt filters and +EQs here are built on. On `synthio.Biquad` - and so `audiofilters.Filter` and +a `Note.filter` chain - the low end is CircuitPython's, on every target, and +it is worth knowing why. + +CircuitPython keeps a biquad's coefficients as Q15 integers, which is the +right trade on a microcontroller and costs low frequencies. Below about +300 Hz they quantize into something that is no longer the filter you asked +for: a `LowPass` at 100 Hz returned **silence**, a `HighPass` at 30 Hz +returned **+21 dB of noise**, and a low shelf at 80 Hz lifted the whole band +by 13.4 dB instead of its 1.5. A low-pass fed a DC burst can also park at a +fixed point for ever - half of full scale from a 40 Hz low-pass. + +audioif widened that arithmetic once for every biquad. Since audioif#77 the +split is by ownership: `synthio.Biquad` is a node CircuitPython also has, so +it runs CircuitPython's arithmetic everywhere (at the current floor that +80 Hz shelf reads -7.65 dB), and the widened kernel belongs to +`audiobiquad`, which is audioif's own. There, coefficients get as many +fractional bits as each filter has room for, the recursion accumulates in +64 bits and keeps its feedback below the sample grid, and the trigonometry +is a proper series. Measured against the closed-form response, every mode +lands within **0.03 dB from 50 Hz to 22 kHz**. Ten octave bands all read ++6.01 dB or better on a +6 dB request, read on `ParametricEQ`. The rebuilt +`MultibandCompressor` sums to 0.12 dB from 30 Hz to 20 kHz on +`audiobiquad`'s float sections. `Saturation` builds its shelves there too, +and falls back to `synthio.Biquad` only where `audiobiquad` is missing. +[audioif's `docs/upstream-diff.md`](https://github.com/PyDevices/audioif/blob/main/docs/upstream-diff.md), +"The biquads were Q15, so they could not go low", has the arithmetic. Nothing refuses a low frequency and nothing ever did, because a `LadderFilter` sweeping down through 40 Hz is a legitimate thing to do. diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py index 9a99276..4fa0c96 100644 --- a/lib/audioeffects/_component.py +++ b/lib/audioeffects/_component.py @@ -188,6 +188,38 @@ def macro_of(span, value): return int(round(macro_position(span, value) * 127)) +# -------------------------------------------------------------------------- +# Level gates + + +def open_level_gates(node, voices, silence): + """Render one block of silence through `node` so every voice starts at + its level instead of at zero. Returns whether it could. + + Since CircuitPython 10.3.0 a mixer voice or a synthio note does not take + a level, amplitude or pan change until its signal is at zero or changes + sign, and a fresh one starts at level 0 (audioif 4ec5718). So a class's + first block is gated: silent for material that does not cross zero in + it - a gain table, a ramp, an impulse at frame 0 - and then the level + steps in at the block boundary, which is a click. A zero sample opens + the gate at once, so each voice in `voices` plays `silence` (a looped + all-zero sample the node accepts) for one pull; the caller then hands + the voices their real sources. Set the levels first: a level moved + after this waits for a zero crossing, as it should. + + `audiocore.get_buffer` is compiled out of CircuitPython's default board + builds (CIRCUITPY_AUDIOCORE_DEBUG), so there this does nothing and the + first block keeps upstream's behaviour. + """ + pull = getattr(audiocore, "get_buffer", None) + if pull is None: + return False + for voice in voices: + voice.play(silence, loop=True) + pull(node) + return True + + # -------------------------------------------------------------------------- # Metadata diff --git a/lib/audioeffects/autopan.py b/lib/audioeffects/autopan.py index dbf40d6..a96928c 100644 --- a/lib/audioeffects/autopan.py +++ b/lib/audioeffects/autopan.py @@ -314,13 +314,30 @@ def _modulate_synth(self, rate_hz, wave_l, wave_r): if synth is None: synth = synthio.Synthesizer( sample_rate=self._sample_rate, channel_count=2) - # CPython synthio pans +1 to the left column and -1 to the right - # (scaled panning in synthio.py). Native builds match that pair. + # Since CircuitPython 10.3.0, panning > 0 attenuates the LEFT + # channel, so -1 lands a note on the left column alone and +1 on + # the right, on every target (audioif 4ec5718). The pre-10.3.0 + # pairing was the reverse and put each gain on the wrong side. + # + # A fresh note renders at level 0 until its output crosses zero + # (the same 10.3.0 change), and a gain table never does, so left + # alone the first block is silent and the gain then steps in - + # a click on whatever is already playing. The notes start on a + # silent waveform for one block, which opens the gate at its + # first sample (see `_component.open_level_gates`), and take + # their tables after it. + silent = array("h", bytes(2 * len(wave_l))) note_l = synthio.Note( - frequency=rate_hz, waveform=wave_l, panning=1.0) + frequency=rate_hz, waveform=silent, panning=-1.0) note_r = synthio.Note( - frequency=rate_hz, waveform=wave_r, panning=-1.0) + frequency=rate_hz, waveform=silent, panning=1.0) synth.press((note_l, note_r)) + import audiocore + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(synth) + note_l.waveform = wave_l + note_r.waveform = wave_r self._synth = synth self._note_l = note_l self._note_r = note_r diff --git a/lib/audioeffects/bandpass.py b/lib/audioeffects/bandpass.py index c3208bb..bc56ff3 100644 --- a/lib/audioeffects/bandpass.py +++ b/lib/audioeffects/bandpass.py @@ -52,17 +52,17 @@ belong to RBJ's prototype, which this class tracks to nine thousandths of a decibel wherever it was checked. -**The build's one - the 0 dB peak is not held at a low centre with a narrow -width.** T1 says the gain at f0 is 0.00 dB +- 0.05 at every width. Measured -2026-09-07 over the whole `Frequency` x `Width` grid at four probe levels, it -holds **at every width for f0 >= 100 Hz, and at every centre for Q <= 2**, and -it does not hold in eleven of fifty-six cells below that - all of them f0 -<= 63 Hz with Q >= 4, worst **-0.50 dB at f0 20 Hz with Q 32**, which is the -`Frequency` knob's bottom stop against the `Width` knob's top. Two knob turns -from patch 5 `Sub Window`. What it sounds like: a sub-bass resonance up to -half a decibel quieter than the same knob setting an octave higher, and the -error changes sign with the signal level (+0.09 dB at -3 dBFS, -0.48 at -12, -+0.44 at -20), so it is not a trim anyone can dial out. +**The build's one - the 0 dB peak is not held at one low, narrow cell.** T1 +says the gain at f0 is 0.00 dB +- 0.05 at every width. Measured 2026-09-17 at +the audioif cebb7ca floor over the whole `Frequency` x `Width` grid at four +probe levels, it holds in fifty-five of fifty-six cells, both knob stops +included, and misses in one: **-0.091 dB at f0 31.5 Hz with Q 32**. What it +sounds like: nothing anyone will hear - a tenth of a decibel on the narrowest +sub-bass band the knobs reach. + +It used to be eleven cells, all f0 <= 63 Hz with Q >= 4, worst -0.50 dB at +the `Frequency` knob's bottom stop against the `Width` knob's top, with an +error that changed sign with the signal level. The history of why follows. The cause is not this class and not the prototype. RBJ's closed form at that cell is `+0.00000 dB`, and `audiobiquad`'s own five coefficients, read off the @@ -73,8 +73,9 @@ coefficients cancel to seven parts in a million, so the increment single precision has to carry is 2e-5 of the numbers being differenced. Filed as audioif#64 with the fix (a transposed direct form II costs nothing at run -time); this class is parked on it as audiocomponents#39. The measurement, the -map and the four-way decomposition are +time), and the fix landed; the one cell left is what it did not reach +(audiocomponents#66). The measurement, the map and the four-way +decomposition are `workspace docs/effects-internal/probes/phase2_probes/bandpass_lowcorner.py`. **The prototype's four**, each measured against RBJ's closed form at the @@ -130,10 +131,10 @@ class never reads `self._transport()`. class BandPass(_component.Component): - """A resonant band-pass: 0 dB at the centre at every width above 100 Hz, - exact zeros at DC and Nyquist, +-6 dB/octave skirts (+-12 with `Slope` - on). Below 100 Hz at Q >= 4 the peak loses up to half a decibel - the - kernel's float32 recursion, audioif#64, module docstring.""" + """A resonant band-pass: 0 dB at the centre at every width, exact zeros + at DC and Nyquist, +-6 dB/octave skirts (+-12 with `Slope` on). The one + exception is a tenth of a decibel at f0 31.5 Hz with Q 32 - module + docstring.""" NAME = 'BandPass' DISPLAY_NAME = 'Band Pass' diff --git a/lib/audioeffects/deesser.py b/lib/audioeffects/deesser.py index 33627da..678fdfd 100644 --- a/lib/audioeffects/deesser.py +++ b/lib/audioeffects/deesser.py @@ -280,8 +280,6 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0, band_only = bool(hf_only) or bool(listen) pre.voice[0].level = 0.0 if band_only else 1.0 pre.voice[1].level = 1.0 if band_only else 0.0 - pre.voice[0].play(raw.tap(0)) - pre.voice[1].play(band.tap(0)) duck = audiodynamics.Dynamics( audiodynamics.DYN_LIMIT, @@ -300,11 +298,10 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0, # comparison needs the whole signal, so the key cannot be the band. duck.key(top.tap(0)) + # Voice 0 plays the dry, broadband; 1 the low half, HF-only; 2 the + # dry high half, HF-only; 3 the ducked stream, both modes. They are + # handed their sources at the end of construction - see there. out = audiomixer.Mixer(voice_count=4, **self._pcm(1024)) - out.voice[0].play(raw.tap(1)) # the dry, broadband - out.voice[1].play(lows[1]) # the low half, HF-only - out.voice[2].play(band.tap(1)) # the dry high half, HF-only - out.voice[3].play(duck) # the ducked stream, both modes # The class does NOT end in a mixer, and that is not decoration. # On CircuitPython `audiomixer.Mixer.reset_buffer` *stops* every @@ -388,6 +385,21 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0, 1.0 if hf_only else 0.0, release_ms, attack_ms, 1.0 if listen else 0.0), patch) + # The voices get their sources last, after the macros have set every + # level and corner. Since CircuitPython 10.3.0 a fresh mixer voice + # starts at level 0 and takes its level only when its signal crosses + # zero, so a Range 0 bypass rendered its first 256 frames silent on + # a ramp, and an impulse at frame 0 never came through at all. One + # block of silence opens every gate at the level just set + # (`_component.open_level_gates`); then each voice takes its source, + # `pre` before `out` because `out`'s voice 3 pulls through `pre`. + _component.open_level_gates(pre, [pre.voice[0], pre.voice[1]], + self._silence) + _component.open_level_gates( + out, [out.voice[index] for index in range(4)], self._silence) + for mixer, index, sample in self._voices: + mixer.voice[index].play(sample) + # -- reset --------------------------------------------------------- def _reset_chain(self): diff --git a/lib/audioeffects/drive.py b/lib/audioeffects/drive.py index 8dc3246..95a00c2 100644 --- a/lib/audioeffects/drive.py +++ b/lib/audioeffects/drive.py @@ -35,6 +35,15 @@ _DM = audiofilters.DistortionMode _FM = synthio.FilterMode +try: + import audiobiquad +except ImportError: + audiobiquad = None + _SHELF_MODES = {} +else: + _SHELF_MODES = {_FM.LOW_SHELF: audiobiquad.LOW_SHELF, + _FM.HIGH_SHELF: audiobiquad.HIGH_SHELF} + def _push(drive, unity): """A 0..1 drive knob as (pre_gain_db, post_gain_db). @@ -127,12 +136,13 @@ def __init__(self, source, drive=0.95, mix=1.0): #: gain. `shelves` is the tone shaping that comes with the medium, as #: (filter mode, frequency, gain at full amount). #: -#: The low shelves here would have been meaningless a phase ago: the -#: engine's biquads were Q15 and anything below roughly 300 Hz quantized -#: into nonsense, so "tape" had a top octave and no head bump. They are -#: accurate to a hundredth of a decibel now - see README, "A note on how -#: low a filter can go" - which is what lets the two mediums differ at both -#: ends rather than only above 10 kHz. +#: The low shelves here need `audiobiquad`. `synthio.Biquad` runs +#: CircuitPython's Q15 arithmetic on every target (audioif#77), and there +#: an 80 Hz shelf asked for +1.5 dB reads -7.65 dB, so "tape" would have a +#: top octave and no head bump. `audiobiquad` is accurate to a hundredth of +#: a decibel down there - see README, "A note on how low a filter can go" - +#: which is what lets the two mediums differ at both ends rather than only +#: above 10 kHz. Where it is missing, the shelves fall back to synthio. _CHARACTERS = { # Asymmetric, so a 2nd harmonic level with the 3rd: the valve # signature. This is also the chain this class has always built, which @@ -189,13 +199,45 @@ def __init__(self, source, amount=0.25, character="tube", drive=0.35): if not shelves: self.tone = None return - self.tone = audiofilters.Filter( - filter=[synthio.Biquad(shelf_mode, _core.check_hz(hz), Q=0.707, - A=_core.db_to_amplitude(gain_db * amount)) + if audiobiquad is None: + self.tone = audiofilters.Filter( + filter=[synthio.Biquad( + shelf_mode, _core.check_hz(hz), Q=0.707, + A=_core.db_to_amplitude(gain_db * amount)) for shelf_mode, hz, gain_db in shelves], - **_core.pcm()) - self.tone.play(self.node) - self._output = self.tone + **_core.pcm()) + self.tone.play(self.node) + self._output = self.tone + return + node = self.node + self.shelves = [] + for shelf_mode, hz, gain_db in shelves: + shelf = audiobiquad.Biquad( + mode=_SHELF_MODES[shelf_mode], frequency=_core.check_hz(hz), + Q=0.707, gain_db=gain_db * amount, + sample_rate=_core.SAMPLE_RATE, + channel_count=_core.CHANNEL_COUNT) + shelf.play(node) + self.shelves.append(shelf) + node = shelf + self.tone = node + self._output = node + + # The base class resets and releases only its output node, which was + # the whole tone stage when that was one Filter. The shelves are a + # chain now, so the ones behind the output are walked here. + + def reset(self): + _core.Effect.reset(self) + import audiocore + for shelf in getattr(self, "shelves", ())[:-1]: + audiocore.reset_buffer(shelf) + + def deinit(self): + _core.Effect.deinit(self) + for shelf in getattr(self, "shelves", ())[:-1]: + shelf.deinit() + self.shelves = [] class Bitcrusher(_core.Effect): diff --git a/lib/audioeffects/multibandcompressor.py b/lib/audioeffects/multibandcompressor.py index d472009..e7cce28 100644 --- a/lib/audioeffects/multibandcompressor.py +++ b/lib/audioeffects/multibandcompressor.py @@ -105,6 +105,8 @@ VENDOR = "PyDevices" +from array import array + from . import _component try: @@ -320,6 +322,11 @@ def _build(self, bands=3, crossover_low_hz=200.0, #: filters that are already silent. self._mixer = self._own(audiomixer_mixer(rate, channels, taps), reset=self._reset_mixer) + #: The silence `_play_voices` opens the mixer's level gates on. It + #: holds no state, so it declines its own reset. + self._silence = self._own(audiocore.RawSample( + array("h", bytes(2 * 2 * channels)), + sample_rate=rate, channel_count=channels), reset=False) self._sections = [] self._detectors = [] for band in range(bands): @@ -412,7 +419,18 @@ def _band_chain(self, band): return node def _play_voices(self): - """Hand every voice its source. Also the second half of `reset()`.""" + """Hand every voice its source. Also the second half of `reset()`. + + The gates open first, on one block of silence, so the dry voice is + at unity from its first sample (`_component.open_level_gates`); + without it a Mix 0 bypass rendered its first 256 frames silent on a + ramp. This runs after the macros, so the levels it opens at are the + ones the class was built with. + """ + _component.open_level_gates( + self._mixer, + [self._mixer.voice[index] for index in range(self._bands + 1)], + self._silence) self._mixer.play(self._taps[0], voice=0, loop=True) for band in range(self._bands): self._mixer.play(self._detectors[band], voice=band + 1, diff --git a/tests/test_audio_component_api.py b/tests/test_audio_component_api.py index 281f7a7..8f4c890 100644 --- a/tests/test_audio_component_api.py +++ b/tests/test_audio_component_api.py @@ -38,7 +38,7 @@ def test_discovery_lists_are_stable(self): tuple(dict.fromkeys(audioinstruments.ALL))) self.assertEqual(audioeffects.ALL, tuple(sorted(audioeffects.ALL))) - self.assertEqual(len(audioinstruments.ALL), 54) + self.assertEqual(len(audioinstruments.ALL), 55) self.assertEqual(len(audioeffects.ALL), 45) def test_all_instruments_implement_live_surface(self): diff --git a/tests/test_component_foundation.py b/tests/test_component_foundation.py index 7e126f5..ba91ad9 100644 --- a/tests/test_component_foundation.py +++ b/tests/test_component_foundation.py @@ -272,14 +272,16 @@ def test_deinit_releases_every_node_the_class_built(self): effect = TwoNodes.create(source(), RATE) delay = effect.delay effect.deinit() - with self.assertRaises(RuntimeError): + # A released audioif node raises ValueError on every target, as + # CircuitPython does (audioif e1f3704). + with self.assertRaises(ValueError): audiocore.get_buffer(delay) def test_planted_fault_an_intermediate_node_left_live(self): effect = KeepsTheDelayLive.create(source(), RATE) delay = effect.delay effect.deinit() - audiocore.get_buffer(delay) # no RuntimeError: the fault + audiocore.get_buffer(delay) # no ValueError: the fault def test_deinit_is_idempotent_and_spares_the_source(self): signal = source() diff --git a/tests/test_cpython_effects_autopan.py b/tests/test_cpython_effects_autopan.py index 75415b1..a56c205 100644 --- a/tests/test_cpython_effects_autopan.py +++ b/tests/test_cpython_effects_autopan.py @@ -256,6 +256,35 @@ class ShortLatency(AutoPan): LATENCY_SAMPLES = 256 +class SwappedSides(AutoPan): + """The pre-10.3.0 note pairing: each gain table on the other column. + + CircuitPython 10.3.0 reversed synthio's panning sign, so the pairing the + class shipped with put the left table on the right. Every sweep test is + symmetric and stayed green through it; only Centre shows which side is + which.""" + + NAME = 'AutoPan' + + def _modulate_synth(self, rate_hz, wave_l, wave_r): + AutoPan._modulate_synth(self, rate_hz, wave_r, wave_l) + + +def side_reading(cls=None, centre=-0.8): + """Output RMS per channel after the first block, at a Centre offset.""" + data = sine(1000.0, 0.5, -6.0) + source = probes.ArraySource(data, rate=RATE, channels=2, block=256) + effect = (cls or AutoPan).create(source, RATE, rate=2.0, depth=0.1, + centre=centre) + try: + wet = probes.render(effect.output, 16000, rate=RATE, channels=2, + block=256, class_name="AutoPan") + finally: + effect.deinit() + rms = np.sqrt((wet.float[2048:] ** 2).mean(axis=0)) + return float(rms[0]), float(rms[1]) + + def _surface_reading(effect): cls = type(effect) return ( @@ -343,6 +372,17 @@ def pull(blocks): class TierTwo(unittest.TestCase): + def test_centre_left_sounds_left_and_right_sounds_right(self): + # The law puts pan -1 at theta 0: left gain 1, right gain 0. + left, right = side_reading(centre=-0.8) + self.assertGreater(left, 4.0 * right, (left, right)) + left, right = side_reading(centre=0.8) + self.assertGreater(right, 4.0 * left, (left, right)) + + def test_swapped_sides_are_red(self): + left, right = side_reading(cls=SwappedSides, centre=-0.8) + self.assertGreater(right, 4.0 * left, (left, right)) + def test_a1_constant_power_at_defaults_rate(self): # Default Rate is 2 Hz; two periods is enough to see the sweep. result = a1_reading(seconds=1.0, rate_hz=2.0) diff --git a/tests/test_cpython_effects_bandpass.py b/tests/test_cpython_effects_bandpass.py index a41ee10..602a46f 100644 --- a/tests/test_cpython_effects_bandpass.py +++ b/tests/test_cpython_effects_bandpass.py @@ -559,12 +559,14 @@ class TierTwo(unittest.TestCase): # -- T1: 0 dB peak, at every Q ------------------------------------ # - # DISCONFIRMED below 100 Hz once Q >= 4, and the cause is the kernel's - # float32 recursion, not this class (audioif#64; audiocomponents#39; the - # decomposition and the map are workspace docs/effects-internal/probes/phase2_probes/bandpass_lowcorner.py). - # The four tests below are the bound: where the row holds, where it does - # not, that the fault fires through the class, and that the measurement - # can fail at all. + # DISCONFIRMED at one cell. Until audioif#64 the kernel's float32 + # direct-form I recursion missed the bar in 11 of 56 cells below 100 Hz + # at Q >= 4, worst -0.50 dB at the macro stops. Its transposed direct + # form II, at the cebb7ca floor, leaves 1 of 56: -0.091 dB at f0 31.5 Hz, + # Q 32 (workspace docs/effects-internal/probes/phase2_probes/ + # bandpass_lowcorner.py, `map`; audiocomponents#66). The tests below are + # the bound: where the row holds, the one cell where it does not, that + # the fault fires through the class, and that the measurement can fail. #: Where T1 holds, measured rather than assumed: every width the surface #: reaches at f0 >= 100 Hz, and every centre it reaches at Q <= 2. @@ -597,14 +599,11 @@ def test_t1_holds_with_the_second_section_switched_in(self): measured = tone_gain_db(1000.0, 1000.0, q, sections=2) self.assertLess(abs(measured), 0.05) - def test_t1_is_disconfirmed_at_the_two_macro_stops(self): - """The disconfirmation, kept as a test so it cannot come back green - without anyone noticing - and so that the day audioif#64 lands, this - is the test that says so. - - The sweep is the kit's own driver over the spans T1 quantifies over, - in the class's macro units, and it is run at the stops first because - that is where the worst cell is. + def test_t1_holds_at_the_two_macro_stops(self): + """This test used to hold the disconfirmation at the stops - worst + -0.50 dB at Frequency 0 against Width 127 - and said it would be the + one to announce audioif#64. It did: at the cebb7ca floor the kit's + sweep over the spans T1 quantifies over passes, stops included. """ effect = build(frequency=1000.0, q=0.707) @@ -621,11 +620,15 @@ def measure_at(settings): measure_at, bar=0.05, unit="dB", name="T1") finally: effect.deinit() - self.assertFalse(swept["passed"], swept["values"]) - self.assertTrue(swept["values"]["at_a_stop"]) - self.assertEqual(swept["values"]["at_units"], - {"Frequency": 20.0, "Width": 32.0}) - self.assertLess(swept["values"]["worst"], -0.4) + self.assertTrue(swept["passed"], swept["values"]) + self.assertLess(abs(swept["values"]["worst"]), 0.05) + + def test_t1_is_still_disconfirmed_at_31_5_hz_and_q_32(self): + """The one cell of 56 the transposed kernel leaves outside the bar, + kept as a test so it cannot come back green unnoticed. Settled for + twenty seconds it still reads -0.08 dB, so it is not the probe's + settle rule; the pack's map reads -0.091 at -20 dBFS.""" + self.assertLess(tone_gain_db(31.5, 31.5, 32.0), -0.05) def test_planted_fault_t1_a_numerator_that_is_not_the_constant_zero_db( self): diff --git a/tests/test_cpython_effects_dynamics_eq.py b/tests/test_cpython_effects_dynamics_eq.py index 82d9d07..dda377a 100644 --- a/tests/test_cpython_effects_dynamics_eq.py +++ b/tests/test_cpython_effects_dynamics_eq.py @@ -53,8 +53,6 @@ LowPass = _lowpass.LowPass ParametricEQ = _parametriceq.ParametricEQ FILTERS = {"LowPass": LowPass, "HighPass": HighPass} -import audiofilters -import synthio sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) from effects_measure import (SAMPLE_RATE, peak, source, # noqa: E402 @@ -163,14 +161,19 @@ def test_a_low_filter_passes_what_it_should_and_stops_what_it_should(self): def test_a_low_shelf_lifts_its_shelf_and_not_the_whole_band(self): # An 80 Hz LOW_SHELF asked for +1.5 dB used to lift everything below - # it by +13.4 - the coefficients had nowhere near enough resolution to - # describe a gentle shelf that low. + # it by +13.4 - Q15 coefficients have nowhere near enough resolution + # to describe a gentle shelf that low. audioif widened them, and since + # audioif#77 the widened kernel is audiobiquad's alone: synthio.Biquad + # runs CircuitPython's Q15 arithmetic on every target again (-7.65 dB + # here at the cebb7ca floor), so the claim is read where it lives, and + # `Saturation` builds its shelves there. + import audiobiquad + def shelf(source_sample): - node = audiofilters.Filter( - filter=synthio.Biquad( - synthio.FilterMode.LOW_SHELF, 80.0, Q=0.707, - A=audioeffects._core.db_to_amplitude(1.5)), - **audioeffects._core.pcm()) + node = audiobiquad.Biquad( + mode=audiobiquad.LOW_SHELF, frequency=80.0, Q=0.707, + gain_db=1.5, sample_rate=audioeffects._core.SAMPLE_RATE, + channel_count=audioeffects._core.CHANNEL_COUNT) node.play(source_sample) return node diff --git a/tests/test_cpython_effects_graphiceq.py b/tests/test_cpython_effects_graphiceq.py index 42f6ac8..cc1f37d 100644 --- a/tests/test_cpython_effects_graphiceq.py +++ b/tests/test_cpython_effects_graphiceq.py @@ -334,10 +334,14 @@ def test_planted_fault_the_detent_branch_deleted_fires_on_every_band(self): "band %d: the detent-branch fault did not change the render, " "so T4 proves nothing at that band" % band) - def test_the_fault_it_replaced_is_green_on_six_of_the_ten_bands(self): - # Kept as the record of why it was replaced, not as evidence. This - # is the auditor's own reproduction: RED at bands 0-2, green from 4 - # up, at the pack's own probe level. + def test_the_fault_it_replaced_is_green_on_every_band(self): + # Kept as the record of why it was replaced, not as evidence. The + # auditor's reproduction read RED at bands 0-2 and green from 4 up, + # at the pack's own probe level. Since audiobiquad's transposed + # direct form II (audioif#64, at the cebb7ca floor) a flat section + # forced to mix 1 is bit-transparent at all ten centres, so the old + # plant could not fail anywhere - which is the case for replacing it, + # made complete. data = noise(24000) red = [] for band in range(BANDS): @@ -354,7 +358,7 @@ def test_the_fault_it_replaced_is_green_on_six_of_the_ten_bands(self): effect.deinit() if faulted.digest != control.digest: red.append(band) - self.assertEqual(red, [0, 1, 2], + self.assertEqual(red, [], "the retired fault's reach moved: red at %r" % red) def test_the_fault_is_not_one_the_surface_can_dial(self): @@ -549,8 +553,14 @@ def test_a_patch_change_does_not_overshoot_its_own_steady_level(self): "patch %d -> %d clips" % (before, after)) def test_planted_fault_the_jump_taken_on_the_old_curves_state(self): - steady, peak = self._transition(5, 1, clear=False) - self.assertGreater(peak, steady * 4, + # Held to the check's own 2x bar, on a pair that check reads. On the + # direct-form I sections this was 5 -> 1 at more than 4x; since + # audiobiquad moved to transposed direct form II (audioif#64) a jump + # on stale state rings far less, and 2 -> 1 is the pair that still + # crosses the bar: 12032 against 4424 (2.72x) with the fault planted, + # 1.32x through the class, measured at the cebb7ca floor. + steady, peak = self._transition(2, 1, clear=False) + self.assertGreater(peak, steady * 2, "the base program_change no longer slams, so the " "check above proves nothing (peak %d, steady %d)" % (peak, steady)) diff --git a/tests/test_effect_kit.py b/tests/test_effect_kit.py index 9f49cf6..9593e25 100644 --- a/tests/test_effect_kit.py +++ b/tests/test_effect_kit.py @@ -222,7 +222,7 @@ def test_reporting_256_samples_short_is_red_at_both_rates(self): class StateTest(unittest.TestCase): """STATE - reset(), deinit(), capabilities, and the allocation rule.""" - def _run(self, cls, **options): + def _run(self, cls, extra_nodes=(), **options): probe, _ = probes.burst_silence(hz=1000.0, on_ms=200.0, total_s=2.0) source = probes.ArraySource(probe, rate=RATE, block=256) quiet = probes.ArraySource(probes.silence(96000), rate=RATE, @@ -236,9 +236,12 @@ def pull(blocks): block=256, probe="burst_silence", class_name=name) + nodes = None + if extra_nodes: + nodes = kit.enumerate_nodes(effect) + list(extra_nodes) return kit.state(effect, pull=pull, swap=switch.swap, probe_source=source, silent_source=quiet, - blocks=64, alloc_pulls=100) + blocks=64, alloc_pulls=100, nodes=nodes) def test_a_delay_line_left_full_after_reset_is_red(self): options = {"time_ms": 150.0, "feedback": 0.5, "mix": 0.5} @@ -305,17 +308,25 @@ def test_a_section_built_and_never_registered_is_red(self): def test_a_node_with_no_deinit_is_recorded_and_is_not_a_leak(self): """The other half of the split, and the reason there is one. - `audioroute.Splitter` has no `deinit()` on any of the three - interpreters (audioif#58), so a class that fans a source out cannot - release it and is not at fault for that. Reported as one number with - real leaks, it made every audioif-tier class read like a leak, and - ruling the whole row `partial` hid genuine leaks behind the gap. + A node type with no `deinit()` cannot be released by anybody, so a + class that builds one is not at fault for it. Reported as one number + with real leaks, it made every audioif-tier class read like a leak. + `audioroute.Splitter` was that node until audioif#58 gave it a + `deinit()`; Compressor now releases everything, and the gap half is + held to its behaviour with a planted node that has none. """ result = self._run(audioeffects.Compressor) self.assertEqual(result["values"]["leaked_nodes_after_deinit"], []) - gaps = result["values"]["nodes_without_deinit"] - self.assertEqual(len(gaps), 1) - self.assertIn("Splitter", gaps[0]) + self.assertEqual(result["values"]["nodes_without_deinit"], []) + + class NoDeinit: + channel_count = 2 + + planted = self._run(audioeffects.Compressor, + extra_nodes=[("planted", NoDeinit())]) + self.assertEqual(planted["values"]["leaked_nodes_after_deinit"], []) + self.assertEqual(planted["values"]["nodes_without_deinit"], + ["planted [NoDeinit]"]) class DigestTest(unittest.TestCase): diff --git a/tests/test_noisegate.py b/tests/test_noisegate.py index 7db995c..d236ca0 100644 --- a/tests/test_noisegate.py +++ b/tests/test_noisegate.py @@ -473,12 +473,11 @@ def _after_reset(self, cls=NoiseGate, loud_dbfs=-20.0, material, not silence, and the readout is the first block after the reset. - The loud pass is at -20 dBFS rather than full scale for a reason - the next test measures: `audioif_dynamics_reset` keeps the - side-chain filter memory on purpose (`audioif_dynamics.c:281-288`), - and after a full-scale pass that stale state alone is loud enough - to reopen the gate - which would mask the fault instead of - exposing it. + The loud pass is at -20 dBFS: before audioif 1f33077 the node kept + its side-chain filter memory across reset, and after a full-scale + pass that stale state alone reopened the gate and masked the fault. + The node clears it now (see the next test); the level stays so the + planted fault's reading is unchanged. """ loud = probes.sine(1000.0, 0.2, loud_dbfs) quiet = probes.sine(1000.0, 0.2, -60.0) @@ -510,22 +509,18 @@ def test_planted_fault_a_gate_left_open_across_reset(self): "a memoryless class's reset; see this class's own " "reset test") - def test_reset_does_not_clear_the_key_filters_and_the_node_says_so(self): - """A measured Tier 1 miss, committed rather than described. - - `audioif_dynamics_reset` keeps the side-chain filter memory on - purpose (`audioif_dynamics.c:281-288`), and the class has no way to - clear it: the state is private to the node and nothing in the - keyword table reaches it. So after a loud pass the detector reads a - stale high-pass state as signal for a few milliseconds and opens - the gate on material that should leave it shut - here, a -60 dBFS - tone under a -39.7 dB threshold, on a class whose reset walk did - everything it could. Committed so the number is in the record; if - audioif ever clears those filters this test goes red on purpose, - and the evidence pack's Tier 1 reset row is what should then - change. + def test_reset_clears_the_key_filters_too(self): + """Once a measured Tier 1 miss, now closed by the node. + + `audioif_dynamics_reset` used to keep the side-chain filter memory, + so after a loud pass the detector read a stale high-pass state as + signal and opened the gate on a -60 dBFS tone under the threshold + (33 LSB). audioif 1f33077 clears the key filters on reset, as a + fresh build does, so the first block after a loud pass is exact + zero - from full scale as well as from -6 dBFS. """ - self.assertGreater(self._after_reset(loud_dbfs=-6.0), 16) + self.assertEqual(self._after_reset(loud_dbfs=-6.0), 0) + self.assertEqual(self._after_reset(loud_dbfs=0.0), 0) def test_a_duck_build_still_renders_after_reset(self): """Upstream CircuitPython's `Mixer.reset_buffer` stops its voices @@ -561,7 +556,12 @@ def test_every_node_the_class_built_is_walked(self): for node in nodes: if not hasattr(node, "deinit"): continue - with self.assertRaises(RuntimeError): + if not hasattr(node, "_get_buffer"): + # audioroute.Splitter releases (audioif#58) but is not a + # sample itself; its taps are, and they are in the list. + self.assertTrue(node._deinited) + continue + with self.assertRaises(ValueError): audiocore.get_buffer(node) effect.deinit() # idempotent