From 90df7429359b1c9c028500d54f30641f980e6ea9 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:06:33 -0500 Subject: [PATCH 1/5] Tests catch up with four deliberate changes at the cebb7ca floor The pin move to audioif cebb7ca brought four behaviour changes these tests were written against, and each test was right to go red: - a released node raises ValueError on every target, as CircuitPython does (audioif e1f3704), not RuntimeError; - audioroute.Splitter has a deinit() (audioif#58), so the walk releases it: the NoiseGate walk checks it by its flag, since it is not a sample, and the kit's no-deinit gap is held to its behaviour with a planted node; - Dynamics.reset() clears the key filters (audioif 1f33077): the test that recorded the 33 LSB miss was written to go red on exactly this, and now asserts exact zero from -6 dBFS and from full scale; - acoustickit is the 55th instrument. Part of #66. --- tests/test_audio_component_api.py | 2 +- tests/test_component_foundation.py | 6 ++-- tests/test_effect_kit.py | 31 ++++++++++++++------- tests/test_noisegate.py | 44 +++++++++++++++--------------- 4 files changed, 48 insertions(+), 35 deletions(-) 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_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 From b8723123293da27d36edd936fea51243b4145f86 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:12:07 -0500 Subject: [PATCH 2/5] AutoPan: the sides and the first block, at CircuitPython 10.3.0's synthio The floor move to audioif cebb7ca brought 10.3.0's synthio loudness changes, and AutoPan's gain notes met both. Panning's sign reversed, so the pairing panning=+1 for the left table put it on the right. Every sweep test is symmetric and stayed green; Centre shows it, where -0.8 came out on the right. The pairing is swapped, and a new test holds Centre to its side with the old pairing planted as SwappedSides. A fresh voice renders at level 0 until its output crosses zero, and a gain table never does, so the first block was silent and the gain stepped in after it. The notes are pressed on a silent waveform for one block, which opens the gate at once, and then take their tables. Without the prime six tests go red. Part of #66. --- lib/audioeffects/autopan.py | 22 ++++++++++++--- tests/test_cpython_effects_autopan.py | 40 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/lib/audioeffects/autopan.py b/lib/audioeffects/autopan.py index dbf40d6..46a8c57 100644 --- a/lib/audioeffects/autopan.py +++ b/lib/audioeffects/autopan.py @@ -314,13 +314,27 @@ 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 voice 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. One block of a silent + # waveform opens the gate at its first sample; the real tables go + # on 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 + audiocore.get_buffer(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/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) From 3e281e2cad268074258205b766c9a3f340a6f180 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:16:50 -0500 Subject: [PATCH 3/5] Open the mixers' level gates before the first block Since CircuitPython 10.3.0 (audioif 4ec5718) a fresh mixer voice or synthio note starts at level 0 and takes its level only when its signal is zero or changes sign. The first block of anything that does not cross zero in it - a ramp, an impulse at frame 0, a gain table - came out silent, and the level then stepped in at the block boundary, which is a click. _component.open_level_gates plays silence on the voices for one pull, which opens every gate at the level already set; the caller then gives the voices their sources. MultibandCompressor does this in _play_voices, which already ran after the macros. DeEsser now plays its six voices at the end of construction, after the macros, instead of in the middle of it. AutoPan's prime gets the same guard: audiocore.get_buffer is compiled out of CircuitPython's default board builds, where the prime does nothing. With the helper switched off, the seven DeEsser and MultibandCompressor tests that went red at the pin move go red again. Part of #66. --- lib/audioeffects/_component.py | 32 +++++++++++++++++++++++++ lib/audioeffects/autopan.py | 13 ++++++---- lib/audioeffects/deesser.py | 24 ++++++++++++++----- lib/audioeffects/multibandcompressor.py | 20 +++++++++++++++- 4 files changed, 77 insertions(+), 12 deletions(-) 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 46a8c57..a96928c 100644 --- a/lib/audioeffects/autopan.py +++ b/lib/audioeffects/autopan.py @@ -319,12 +319,13 @@ def _modulate_synth(self, rate_hz, wave_l, wave_r): # 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 voice renders at level 0 until its output crosses zero + # 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. One block of a silent - # waveform opens the gate at its first sample; the real tables go - # on after it. + # 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=silent, panning=-1.0) @@ -332,7 +333,9 @@ def _modulate_synth(self, rate_hz, wave_l, wave_r): frequency=rate_hz, waveform=silent, panning=1.0) synth.press((note_l, note_r)) import audiocore - audiocore.get_buffer(synth) + 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 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/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, From 46bbae1b6be5ed4640ff55bc7a95b3add3037231 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:19:11 -0500 Subject: [PATCH 4/5] Saturation's shelves move to audiobiquad, where low shelves are still accurate audioif#77 split the biquads by ownership: synthio.Biquad, and so audiofilters.Filter, runs CircuitPython's Q15 arithmetic on every target, and the widened kernel is audiobiquad's alone. At the cebb7ca floor an 80 Hz low shelf asked for +1.5 dB reads -7.65 dB through synthio, so tape lost its head bump and five Saturation tests went red. Saturation now chains audiobiquad shelves, and falls back to synthio only where audiobiquad is missing; forcing that fallback turns the same five tests red again. The shelves are a chain, so reset() and deinit() walk the ones behind the output. The low-shelf resolution test reads audiobiquad, where the claim now lives, and the README's note on how low a filter can go says which node is accurate and why. Part of #66. --- lib/audioeffects/README.md | 55 +++++++++---------- lib/audioeffects/drive.py | 66 ++++++++++++++++++----- tests/test_cpython_effects_dynamics_eq.py | 21 ++++---- 3 files changed, 94 insertions(+), 48 deletions(-) diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index 34c43ab..e3564ed 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -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/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/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 From 56e88486190920ed70c8dde41c3904f9ddb64aa2 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:23:03 -0500 Subject: [PATCH 5/5] BandPass and GraphicEQ: what audiobiquad's transposed direct form II fixed audioif#64 moved audiobiquad's float sections to transposed direct form II. Three tests written to stay red on the old kernel's defects went green, and each was right to say so. BandPass T1 held the -0.50 dB miss at the knob stops, and said it would be the test to announce audioif#64. Re-running the low-corner probe at the new floor: 55 of 56 cells inside the 0.05 dB bar, stops included, and one miss, -0.091 dB at f0 31.5 Hz with Q 32, now pinned by its own test. The class docstring and the README row say so. GraphicEQ's retired plant is green on every band now, not six of ten. Its live plant, a patch jump on stale state, no longer slams from 5 to 1; from 2 to 1 it still reaches 2.72x steady against the check's 2x bar, and 1.32x through the class, so the plant moves there and is held to that bar. The CHANGELOG records all of #66. --- CHANGELOG.md | 18 +++++++++++ lib/audioeffects/README.md | 2 +- lib/audioeffects/bandpass.py | 35 +++++++++++---------- tests/test_cpython_effects_bandpass.py | 41 +++++++++++++------------ tests/test_cpython_effects_graphiceq.py | 24 ++++++++++----- 5 files changed, 76 insertions(+), 44 deletions(-) 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 e3564ed..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 | 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/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_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))