From 480cab0768407478a57dcc9df7e459766819b134 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:14:24 -0500 Subject: [PATCH 1/3] HighPass: player text in behaviour sentences, each tied to a test The module docstring's notes move to design-note comments. The class docstring says what the knobs do, the exact zero at DC, the slopes, where the corner reads Resonance, latency and level, and the CLAIMS table ties each of those to a test. kit_player holds the claims checker and the latency and missing-module readings the next tables share. --- lib/audioeffects/highpass.py | 237 +++++++++++++------------ tests/support/kit_player.py | 106 +++++++++++ tests/test_cpython_effects_highpass.py | 78 ++++++++ 3 files changed, 311 insertions(+), 110 deletions(-) create mode 100644 tests/support/kit_player.py diff --git a/lib/audioeffects/highpass.py b/lib/audioeffects/highpass.py index 443d5ab..278800f 100644 --- a/lib/audioeffects/highpass.py +++ b/lib/audioeffects/highpass.py @@ -1,115 +1,108 @@ """`HighPass` - the low-cut: a two-pole analog high-pass whose transmission zero at DC is exact, not nearly. -Rebuilt from scratch for the effects program's Phase 2 against -its dossier. The old `eq.py:HighPass` is not consulted except -for the eight defects the dossier's section 7 names. - -**What it sounds like.** One knob decides where the bottom stops, and above -that corner nothing is touched: rumble, handling noise, a subsonic DC offset -and the mud under a vocal go, and the voice does not. The second knob is how -loud the corner itself stands - at Resonance 0.707 it is 3 dB down and the cut -is as gentle as a cut gets, at 2 it is 6 dB up and you can hear where you put -it, at 16 it is 24 dB up and the filter whistles at its own corner. The Slope -switch is a desk's: 12 dB/oct for a low-cut you do not want to notice, 24 -dB/oct when the rumble has to actually leave. There is no drive and no growl -anywhere in it - a high-pass is linear. Keep the input under -9 dBFS and the -defaults and patches stay off the rails. Trim at its top can still push the -output onto the rails. - -**Three things the knobs do not do exactly, found by the Phase 2 gate audit -and its patch sweep, and -bounded here rather than hidden.** - -*The corner gain reads Q down to about 40 Hz, and drifts below that.* Set -Resonance to 8 with the corner at 10 Hz and the steep slope - which is one -knob turn from patch 0's own default corner - and the peak stands -**+17.46 dB where the knob says +18.06**, 0.6 dB shy; at 44.1 kHz with -Resonance 16 it is 2.68 dB shy. The cause is under this class and it is -arithmetic, not a bug in the cut: `audiobiquad` derives its coefficients in -double and stores the five it uses as `float` -(`audiodsp/src/shared/audiodsp_filter_f32.h:103`), and a resonant pole pair's -corner gain is set by `1 + a1 + a2`, which at a 10 Hz corner is 1.7e-6 - -smaller than the rounding of `a1 ~ -2` into single precision. Evaluating the -class's own cascade with the coefficients rounded that way predicts every -rendered figure to within 0.005 dB. **The bound: at f0 with `2*pi*f0/F_s >= 5.2e-3` - 40 Hz at -48 kHz, 18 Hz at 22.05 kHz - the corner reads Q to within 0.05 dB at every Q -and both slopes, measured. Below it the knob is a direction, not a number.** - -*And below that same corner it is not quite one curve either.* The shape is -`f0`-independent on the bilinear-warped axis to 0.0125 dB from 31.5 Hz up, -which is what the class is for; at a **10 Hz** corner - patch 0's own - it -is **0.21 dB** off that curve at f0/4, against the same 0.05 dB bar, and the -figure jumps about with tiny changes of Q because the rounding does. One -cause, one bound, three traits: above 40 Hz at 48 kHz the corner gain, the -curve and the rate-honesty all hold; below it the low-cut still cuts and -still removes DC exactly, and the two numbers on the panel stop being exact. -The skirt (12 and 24 dB/oct) and the transmission zero at DC hold at every -setting, including there. - -*The corner rings 1.85 times longer at 24 dB/oct than the knob says.* Q is -also a decay time - a released tone at the corner falls to e^-pi of its -level in about Q periods - and it does, at 12 dB/oct. At 24 dB/oct the -resonance rides the steeper Butterworth section alone so that the *gain* -reads the same at both slopes (see `BUTTERWORTH_HIGH` below), and the price -is exactly that ratio: `1.3066 / 0.7071 = 1.848`. Measured 1.91 / 1.86 / -1.85 Q at Resonance 2 / 8 / 16. Musically it is a longer, more obvious -whistle at the same loudness, which is what the steep slope is for. - -**The one thing a high-pass does that a low-pass cannot.** The numerator -`(1+cos w0)/2, -(1+cos w0), (1+cos w0)/2` sums to exactly zero at `z = 1` -(RBJ, the dossier's S1), so DC is not attenuated, it is *removed*: a step -settles to exact zero and a held offset decays away. That is the class's -whole musical job, and it is why this class is `audiobiquad` and not -`synthio.Biquad`. - -**Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). Both filter -sections and the trim are `audiobiquad.Biquad` - float state, and a state -word below 1e-20 written as exact zero. The ported `synthio.Biquad` cannot -do the paragraph above: its Q12 integer memory has fixed points, and a -`HighPass` parks on **-71 LSB of DC at a 10 Hz corner, -18 at 20 Hz and -8 -at 30 Hz, and holds them for ever** (audiodsp#23; dossier A3 and A15). A -stock CircuitPython board therefore cannot construct this class. That is the -dossier's section 8 question 1, settled there as D1, and the cost is real: -the old class ran on a stock board and quietly failed the invariant. - -**Cost, measured.** Three sections, always built. On the ESP32-P4 this -class costs **5.7 %** of one stereo block's real-time deadline and on the -ESP32-S3 **9.6 %** (2026-09-07, 256-frame stereo blocks at 48 kHz, marginal -over the probe source alone), against dossier budgets of 1.5 % and 5 %: -**over on both**, and it stays parked on that gate item. There is no lean -patch to offer, and the reason is worth knowing before optimising anything -here - `mix` 0 takes a section out of the *sound*, not out of the *work* -(`audiodsp/src/shared/audiodsp_filter_f32.c:216-241` computes and stores the -recursion for every sample and only the output line reads `mix`), so patch -0's two wired-out sections cost what live ones cost. What would fit the -budget is a construction option that builds one section instead of three, -at the cost of Slope no longer being a live macro; it is not taken here. -Both boards render this class byte for byte identically. - -**Latency: zero samples, at every setting and every rate.** Every section is -a recursive biquad reading no sample it has not been given. No option on -this class adds a lookahead, a partition or a window, so there is no -latency-adding option to default off and none to name in milliseconds. - -`capabilities = ()`: nothing in a high-pass is measured in beats - its two -controls are hertz and a dimensionless Q - and this class never reads -`self._transport()`. - -**Mix, and what it means at 24 dB/oct.** Mix is the section-level crossfade -`audiobiquad` already has, set on every section in use at once. At 0 every -section writes its input sample back unchanged, so the whole class is a -byte-exact wire. At 1 it is the full cascade, and T1's exact zero at DC is a -statement about *that* setting: between the two the dry path carries DC -through in proportion, because that is what a blend is. At 12 dB/oct the -blend reads exactly as a dry/wet control; at 24 dB/oct it is that blend -applied to each of the two poles in turn, which is a smooth walk from wire -to cascade but is not one half of the fourth-order response. The -alternative - a splitter and a mixer around the pair - costs two more nodes -and would put an int16 sum on the dry path, so it is not taken and this -sentence is here instead. +The player's text is the class docstring; the `CLAIMS` table in the +class's test file ties its claim sentences to tests. """ +# Design notes, moved from the module docstring. +# +# Rebuilt from scratch for the effects program's Phase 2 against +# its dossier. The old `eq.py:HighPass` is not consulted except +# for the eight defects the dossier's section 7 names. +# +# **Three things the knobs do not do exactly, found by the Phase 2 gate audit +# and its patch sweep, and +# bounded here rather than hidden.** +# +# *The corner gain reads Q down to about 40 Hz, and drifts below that.* Set +# Resonance to 8 with the corner at 10 Hz and the steep slope - which is one +# knob turn from patch 0's own default corner - and the peak stands +# **+17.46 dB where the knob says +18.06**, 0.6 dB shy; at 44.1 kHz with +# Resonance 16 it is 2.68 dB shy. The cause is under this class and it is +# arithmetic, not a bug in the cut: `audiobiquad` derives its coefficients in +# double and stores the five it uses as `float` +# (`audiodsp/src/shared/audiodsp_filter_f32.h:103`), and a resonant pole pair's +# corner gain is set by `1 + a1 + a2`, which at a 10 Hz corner is 1.7e-6 - +# smaller than the rounding of `a1 ~ -2` into single precision. Evaluating the +# class's own cascade with the coefficients rounded that way predicts every +# rendered figure to within 0.005 dB. **The bound: at f0 with `2*pi*f0/F_s >= 5.2e-3` - 40 Hz at +# 48 kHz, 18 Hz at 22.05 kHz - the corner reads Q to within 0.05 dB at every Q +# and both slopes, measured. Below it the knob is a direction, not a number.** +# +# *And below that same corner it is not quite one curve either.* The shape is +# `f0`-independent on the bilinear-warped axis to 0.0125 dB from 31.5 Hz up, +# which is what the class is for; at a **10 Hz** corner - patch 0's own - it +# is **0.21 dB** off that curve at f0/4, against the same 0.05 dB bar, and the +# figure jumps about with tiny changes of Q because the rounding does. One +# cause, one bound, three traits: above 40 Hz at 48 kHz the corner gain, the +# curve and the rate-honesty all hold; below it the low-cut still cuts and +# still removes DC exactly, and the two numbers on the panel stop being exact. +# The skirt (12 and 24 dB/oct) and the transmission zero at DC hold at every +# setting, including there. +# +# *The corner rings 1.85 times longer at 24 dB/oct than the knob says.* Q is +# also a decay time - a released tone at the corner falls to e^-pi of its +# level in about Q periods - and it does, at 12 dB/oct. At 24 dB/oct the +# resonance rides the steeper Butterworth section alone so that the *gain* +# reads the same at both slopes (see `BUTTERWORTH_HIGH` below), and the price +# is exactly that ratio: `1.3066 / 0.7071 = 1.848`. Measured 1.91 / 1.86 / +# 1.85 Q at Resonance 2 / 8 / 16. Musically it is a longer, more obvious +# whistle at the same loudness, which is what the steep slope is for. +# +# **The one thing a high-pass does that a low-pass cannot.** The numerator +# `(1+cos w0)/2, -(1+cos w0), (1+cos w0)/2` sums to exactly zero at `z = 1` +# (RBJ, the dossier's S1), so DC is not attenuated, it is *removed*: a step +# settles to exact zero and a held offset decays away. That is the class's +# whole musical job, and it is why this class is `audiobiquad` and not +# `synthio.Biquad`. +# +# **Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). Both filter +# sections and the trim are `audiobiquad.Biquad` - float state, and a state +# word below 1e-20 written as exact zero. The ported `synthio.Biquad` cannot +# do the paragraph above: its Q12 integer memory has fixed points, and a +# `HighPass` parks on **-71 LSB of DC at a 10 Hz corner, -18 at 20 Hz and -8 +# at 30 Hz, and holds them for ever** (audiodsp#23; dossier A3 and A15). A +# stock CircuitPython board therefore cannot construct this class. That is the +# dossier's section 8 question 1, settled there as D1, and the cost is real: +# the old class ran on a stock board and quietly failed the invariant. +# +# **Cost, measured.** Three sections, always built. On the ESP32-P4 this +# class costs **5.7 %** of one stereo block's real-time deadline and on the +# ESP32-S3 **9.6 %** (2026-09-07, 256-frame stereo blocks at 48 kHz, marginal +# over the probe source alone), against dossier budgets of 1.5 % and 5 %: +# **over on both**, and it stays parked on that gate item. There is no lean +# patch to offer, and the reason is worth knowing before optimising anything +# here - `mix` 0 takes a section out of the *sound*, not out of the *work* +# (`audiodsp/src/shared/audiodsp_filter_f32.c:216-241` computes and stores the +# recursion for every sample and only the output line reads `mix`), so patch +# 0's two wired-out sections cost what live ones cost. What would fit the +# budget is a construction option that builds one section instead of three, +# at the cost of Slope no longer being a live macro; it is not taken here. +# Both boards render this class byte for byte identically. +# +# **Latency: zero samples, at every setting and every rate.** Every section is +# a recursive biquad reading no sample it has not been given. No option on +# this class adds a lookahead, a partition or a window, so there is no +# latency-adding option to default off and none to name in milliseconds. +# +# `capabilities = ()`: nothing in a high-pass is measured in beats - its two +# controls are hertz and a dimensionless Q - and this class never reads +# `self._transport()`. +# +# **Mix, and what it means at 24 dB/oct.** Mix is the section-level crossfade +# `audiobiquad` already has, set on every section in use at once. At 0 every +# section writes its input sample back unchanged, so the whole class is a +# byte-exact wire. At 1 it is the full cascade, and T1's exact zero at DC is a +# statement about *that* setting: between the two the dry path carries DC +# through in proportion, because that is what a blend is. At 12 dB/oct the +# blend reads exactly as a dry/wet control; at 24 dB/oct it is that blend +# applied to each of the two poles in turn, which is a smooth walk from wire +# to cascade but is not one half of the fourth-order response. The +# alternative - a splitter and a mixer around the pair - costs two more nodes +# and would put an int16 sum on the dry path, so it is not taken and this +# sentence is here instead. + VENDOR = "PyDevices" try: @@ -164,9 +157,33 @@ class costs **5.7 %** of one stereo block's real-time deadline and on the class HighPass(_component.Component): - """A two-pole high-pass with a resonant corner, a 12/24 dB/oct slope - switch and a make-up trim. Three `audiobiquad` sections; audiodsp tier; - zero latency; DC removed exactly rather than nearly.""" + """A low-cut: under the corner the signal goes, and above it nothing is + touched. + + **Controls.** Frequency is the corner. Resonance is how loud the corner + itself stands: at 0.707 it is 3 dB down and the cut is as gentle as a cut + gets, and turned up it stands above the passband and the filter whistles + at its own corner. Slope is 12 or 24 dB an octave. Trim is a flat gain + across the band. Mix at 0 is your dry signal. The Effect controls page + lists what the knobs span. + + **Response.** DC is removed exactly: a held offset settles to zero, not + nearly zero. Below the corner the cut falls 12 or 24 dB an octave, and + the top of the band passes at unity. From 40 Hz up at 48 kHz the corner + stands as loud as Resonance says at either slope; below that the knob is + a direction rather than a number. + + **Timing.** Latency is zero at every setting. Silence in is silence out + once the filter rings down, and the class reports a tail that covers its + lowest corner and highest Resonance. + + **Levels.** Keep the input under -9 dBFS and the defaults and patches + stay off the rails. Trim at its top can still push the output onto the + rails. + + **On a board.** The class needs audiodsp's `audiobiquad`, and on a board + without it construction raises `ImportError`. + """ NAME = 'HighPass' DISPLAY_NAME = 'High Pass' diff --git a/tests/support/kit_player.py b/tests/support/kit_player.py new file mode 100644 index 0000000..b56f541 --- /dev/null +++ b/tests/support/kit_player.py @@ -0,0 +1,106 @@ +"""The player's text against its `CLAIMS` table, and the two readings every +class's table leans on: latency and the modules it needs. + +A test file passes its own `globals()` as `owners`, so a row naming +`TheBypassIsAWire.test_...` is checked against the test classes that file +actually defines. +""" + +import array +import re +import sys + +import audiocore + + +def _flat(text): + return " ".join(text.split()) + + +def claim_problems(doc, claims, owners): + """What is wrong between a docstring and `claims`: a sentence missing, a + named test that does not exist, a figure outside every claim.""" + doc = _flat(doc) + problems = [] + rest = doc + for sentence, tests in claims: + if sentence not in doc: + problems.append("missing: %s" % sentence) + rest = rest.replace(sentence, " ") + for name in tests: + owner, _, test = name.partition(".") + if not hasattr(owners.get(owner), test): + problems.append("no test %s" % name) + for match in re.finditer(r"\S*\d\S*", rest): + problems.append("figure outside a claim: %s" % match.group()) + return problems + + +def check_claims(case, doc, claims, owners, sentence): + """The table holds, and the checker can fail: a figure outside a claim, + `sentence` dropped from the docstring, a row naming a missing test.""" + case.assertEqual(claim_problems(doc, claims, owners), []) + case.assertTrue(claim_problems(doc + " It reads 12 ms.", claims, owners)) + case.assertTrue(claim_problems(doc.replace(sentence, ""), claims, owners)) + case.assertTrue(claim_problems( + doc, claims + (("Latency", ("Nobody.test_nothing",)),), owners)) + + +def impulse(rate, channels=2, frames=256): + values = array.array("h", bytes(2 * channels * frames)) + for channel in range(channels): + values[channel] = 16000 + return audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + + +def first_frame(effect): + """The first output frame's samples.""" + data = audiocore.get_buffer(effect.output)[1] + data = memoryview(bytes(data)).cast("h") + return list(data[:effect.channel_count]) + + +def latency_problems(cls, rate, delayed=False): + """Every patch whose impulse does not show in the first output frame, + or whose reported latency is not zero. `delayed` plants the fault: the + impulse is moved one frame late, which a zero-latency reading must + catch.""" + problems = [] + for patch in sorted(cls.PATCHES): + source = impulse(rate) + if delayed: + values = array.array("h", bytes(2 * 2 * 256)) + values[2] = values[3] = 16000 + source = audiocore.RawSample(values, sample_rate=rate, + channel_count=2) + effect = cls.create(source, rate, patch=patch) + try: + if effect.latency_samples != 0: + problems.append("p%d reports %d" % (patch, + effect.latency_samples)) + if not any(first_frame(effect)): + problems.append("p%d is silent on the impulse's frame" % patch) + finally: + effect.deinit() + return problems + + +def missing_module_problems(cls, rate): + """Each module in `REQUIRES` that construction does not refuse to do + without.""" + problems = [] + for name in cls.REQUIRES: + saved = sys.modules.get(name) + sys.modules[name] = None + try: + cls.create(impulse(rate), rate) + problems.append("built without %s" % name) + except ImportError: + pass + finally: + if saved is None: + del sys.modules[name] + else: + sys.modules[name] = saved + return problems diff --git a/tests/test_cpython_effects_highpass.py b/tests/test_cpython_effects_highpass.py index e1a75b1..118b1c6 100644 --- a/tests/test_cpython_effects_highpass.py +++ b/tests/test_cpython_effects_highpass.py @@ -32,6 +32,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) import kit_ceiling # noqa: E402 +import kit_player # noqa: E402 from effects_measure import SAMPLE_RATE, source # noqa: E402 from audioeffects import _component # noqa: E402 @@ -269,6 +270,18 @@ def test_the_corner_gain_is_q_at_every_rate(self): self.corner_db(1000.0, 4.0, slope, rate), 20.0 * math.log10(4.0), delta=0.05) + def test_the_corner_reads_q_from_forty_hertz_up_and_not_at_ten(self): + for q in (0.5, FLAT_Q, 2.0, 8.0, 16.0): + for slope in (12, 24): + with self.subTest(q=q, slope=slope): + self.assertAlmostEqual( + self.corner_db(40.0, q, slope), + 20.0 * math.log10(q), delta=0.05) + # Below the bound the knob is a direction: 10 Hz at Resonance 8 on + # the steep slope stands about 0.6 dB shy. + self.assertGreater(abs(self.corner_db(10.0, 8.0, 24) + - 20.0 * math.log10(8.0)), 0.3) + def test_scaling_both_butterworth_qs_is_red(self): # The fault of the same kind: put the resonance on both sections of # the steep pair and the knob squares. Resonance 2 would stand @@ -612,5 +625,70 @@ def test_clean_at_the_stated_input_level(self): highpass.HighPass, "square", db=-6, keys=("p1",))) +class ThePlayersText(unittest.TestCase): + def test_latency_is_zero_on_every_patch(self): + self.assertEqual( + kit_player.latency_problems(highpass.HighPass, SAMPLE_RATE), []) + self.assertTrue(kit_player.latency_problems( + highpass.HighPass, SAMPLE_RATE, delayed=True)) + + def test_construction_needs_audiobiquad(self): + self.assertEqual(highpass.HighPass.REQUIRES, ("audiobiquad",)) + self.assertEqual(kit_player.missing_module_problems( + highpass.HighPass, SAMPLE_RATE), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, highpass.__doc__ + " " + highpass.HighPass.__doc__, + CLAIMS, globals(), "Slope is 12 or 24 dB an octave.") + + +CLAIMS = ( + ("`HighPass` - the low-cut: a two-pole analog high-pass whose " + "transmission zero at DC is exact, not nearly.", + ("TheZeroAtDCIsExact.test_the_settled_output_is_bit_exactly_zero", + "TheSkirtIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave")), + ("A low-cut: under the corner the signal goes, and above it nothing is " + "touched.", + ("TheSkirtIsTwelveDecibelsAnOctave.test_nyquist_passes_at_unity", + "TheShapeIsOneCurve.test_the_warped_curve_is_the_same_at_every_" + "corner")), + ("Resonance is how loud the corner itself stands: at 0.707 it is 3 dB " + "down and the cut is as gentle as a cut gets, and turned up it stands " + "above the passband and the filter whistles at its own corner.", + ("CornerTest.test_the_corner_sits_at_minus_three_across_the_whole_span", + "CornerTest.test_the_corner_gain_reads_q_at_both_slopes")), + ("Slope is 12 or 24 dB an octave.", + ("TheSkirtIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave",)), + ("Mix at 0 is your dry signal.", + ("TheBypassIsAWire.test_mix_zero_is_byte_identical_to_the_source", + "TheBypassIsAWire.test_mix_zero_with_a_trim_asked_for_is_still_a_" + "wire")), + ("**Response.** DC is removed exactly: a held offset settles to zero, " + "not nearly zero.", + ("TheZeroAtDCIsExact.test_the_settled_output_is_bit_exactly_zero", + "TheZeroAtDCIsExact.test_the_cascades_gain_at_dc_is_zero_at_every_" + "setting")), + ("Below the corner the cut falls 12 or 24 dB an octave, and the top of " + "the band passes at unity.", + ("TheSkirtIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave", + "TheSkirtIsTwelveDecibelsAnOctave.test_nyquist_passes_at_unity")), + ("From 40 Hz up at 48 kHz the corner stands as loud as Resonance says " + "at either slope; below that the knob is a direction rather than a " + "number.", + ("CornerTest.test_the_corner_reads_q_from_forty_hertz_up_and_not_at_" + "ten", + "CornerTest.test_the_corner_gain_is_q_at_every_rate")), + ("**Timing.** Latency is zero at every setting.", + ("ThePlayersText.test_latency_is_zero_on_every_patch",)), + ("**Levels.** Keep the input under -9 dBFS and the defaults and " + "patches stay off the rails.", + ("TheCeiling.test_clean_at_the_stated_input_level",)), +) + + if __name__ == "__main__": unittest.main() From 6b3b94016b89e106e6f2ea35cdd6b43caa2a874e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:16:46 -0500 Subject: [PATCH 2/3] LowPass: player text in behaviour sentences, each tied to a test Same shape as HighPass. The corner is now checked at 40 Hz, where the text says it starts to read Resonance, and shown to fall short at 20 Hz. --- lib/audioeffects/highpass.py | 3 +- lib/audioeffects/lowpass.py | 213 +++++++++++++------------ tests/support/kit_player.py | 7 +- tests/test_cpython_effects_highpass.py | 8 +- tests/test_cpython_effects_lowpass.py | 74 +++++++++ 5 files changed, 197 insertions(+), 108 deletions(-) diff --git a/lib/audioeffects/highpass.py b/lib/audioeffects/highpass.py index 278800f..91a8438 100644 --- a/lib/audioeffects/highpass.py +++ b/lib/audioeffects/highpass.py @@ -157,8 +157,7 @@ class HighPass(_component.Component): - """A low-cut: under the corner the signal goes, and above it nothing is - touched. + """A low-cut: under the corner the signal goes. **Controls.** Frequency is the corner. Resonance is how loud the corner itself stands: at 0.707 it is 3 dB down and the cut is as gentle as a cut diff --git a/lib/audioeffects/lowpass.py b/lib/audioeffects/lowpass.py index 9a56899..d284bf6 100644 --- a/lib/audioeffects/lowpass.py +++ b/lib/audioeffects/lowpass.py @@ -1,103 +1,96 @@ """`LowPass` - the two-pole analog prototype every synth and console low-pass is a version of, digitised without losing its tail. -Rebuilt from scratch for the effects program's Phase 2 against -its dossier. The old `eq.py:LowPass` is not consulted except -for the seven defects the dossier's section 7 names. - -**What it sounds like.** One knob slides the whole curve along the frequency -axis without changing its shape, and one knob decides how loud the corner -itself stands: at Resonance 0.707 the corner is 3 dB down and the curve is the -gentlest roll-off there is, at 2 it is 6 dB up and the filter starts to whistle -where you put it, at 16 it is 24 dB up and a click at the input rings for -seconds. There is no drive, no saturation and no growl anywhere in it - a -low-pass is linear, and the thing that makes a Moog a Moog belongs to -`LadderFilter`. The Slope switch is a console's: 12 dB/oct is the analog -prototype, 24 dB/oct is a second section behind it for when a rumble has to -actually go away. Keep the input under -3 dBFS and the defaults and patches -stay off the rails. Trim at its top can still push the output onto the rails. - -**Where those two sentences stop being exact.** The Phase 2 gate audit -(2026-09-07) disconfirmed four of the five traits off the point they were -measured at, and the evidence pack's section 1 carries each with its cause. -In a player's terms: - -* **The corner gain is the resonance to 0.05 dB everywhere except the bottom - of the Frequency knob at the top of the Resonance knob, at 24 dB/oct.** At - f0 20 Hz, Q 16, 24 dB/oct the corner stands **0.44 dB low** (0.09 dB at - 31.5 Hz, inside the bar by 40 Hz). The cause is float32 coefficients at a - pole that close to z = 1, not the design; the same reading comes off the - built sections' own coefficients with no audio in the loop. It is a - quarter-decibel on a peak 24 dB tall - it does not change what the filter - is for, and it is written here because a trait that says "exactly" has to - say where. -* **The shape is one curve at every corner from 31.5 Hz up. Below 25 Hz it - is not**, by 0.079 dB between 20 and 25 Hz against a 0.05 bar - same - cause, and it gets worse rather than better on a board where `mp_float_t` - is single. -* **"Rings for Q periods" is a 12 dB/oct statement, below about a sixth of - the sample rate.** Above 12 kHz at 48 kHz the ring measured in periods of - f0 stretches (2.75 periods at Q 2, against a 1.60-2.50 band), because the - decay is set by the warped pole and f0 is not it. At 24 dB/oct the ring is - **1.36x the Resonance number at Q 2 rising to 1.71x at Q 16** - measured - 2.74 / 6.54 / 13.49 / 27.42 periods at 500 Hz, 48 kHz - so it is longer - than the knob says at every Q and the knob is not a period count at that - slope. (The evidence pack's independent record calls this "a uniform - 1.85x"; its own numbers, which reproduce exactly, do not say that, and the - correction is in the pack.) -* **Rate-honest to 0.1 dB of the closed form everywhere except that same - corner**: f0 20 Hz, Q 16, 24 dB/oct reads 0.378 dB out at 48 kHz and - 0.293 dB at 44.1 kHz, and 0.008 dB at 22.05 kHz. The failure is - rate-dependent because the warp is smallest at a low corner. - -The one trait that survived an independent refutation is the roll-off: -**-12.03 dB/oct on the warped axis, unity at DC, at every corner and every -rate** - swept over the whole Frequency travel it is within **0.0104 dB** of -its 0.05 dB bar. - -**Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). Both filter -sections and the trim are `audiobiquad.Biquad` - float state, and a tail -that reaches exact zero. The ported `synthio.Biquad` cannot: a `LowPass` at -100 Hz parks on 1 LSB of DC and a 40 Hz one at Q 8 on 4 LSB, and they hold -it for ever (audiodsp#23, dossier A3 and A14), which is Tier 1's first -invariant failing at exactly the corners this class is for. That is the -dossier's section 8 question 1, settled here for this class. - -**Cost.** Three sections, always built - and *all three run whatever their -`mix` is*: `audiodsp_filter_f32.c:216-241` has no branch on `mix`, so a -section left as a wire costs what a working section costs. Measured on the -boards 2026-09-07: **5.6 % of one stereo block's real-time deadline on the -ESP32-P4 and 9.6 % on the S3**, against a dossier budget of 1.5 % / 5 %. -**Over budget on both, and there is no `" - lean"` patch**: the class has no -macro that removes a section, and measured across every setting its surface -can reach the desktop cost moves less than the same setting moves between -passes (0.013 ms/block against 0.016-0.022). It still runs in real time on both -boards with room to spare - the budget was instruction-count arithmetic that -could not see a node graph's per-block Python, and it is the budget that is -being contested, not the class's fitness for use. - -**Latency: zero samples, at every setting and every rate.** Every section is -a recursive biquad reading no sample it has not been given. No option on -this class adds a lookahead, a partition or a window, so there is no -latency-adding option to default off and none to name in milliseconds. - -`capabilities = ()`: nothing in a low-pass is measured in beats - its two -controls are hertz and a dimensionless Q - and this class never reads -`self._transport()`. - -**Mix, and what it means at 24 dB/oct.** Mix is the section-level crossfade -`audiobiquad` already has, set on every section at once. At 0 every section -writes its input sample back unchanged, so the whole class is a byte-exact -wire (measured: a three-section chain at `mix=0` hashes to the source's own -digest). At 1 it is the full cascade. In between, at 12 dB/oct it is exactly -the dry/wet blend it reads as; at 24 dB/oct it is that blend applied to each -of the two sections in turn, which is a smooth walk from wire to cascade but -is not one half of the fourth-order response. The alternative - a splitter -and a mixer around the pair - costs two more nodes and would put an int16 -sum on the dry path, so it is not taken; the honest sentence is here -instead. +The player's text is the class docstring; the `CLAIMS` table in the +class's test file ties its claim sentences to tests. """ +# Design notes, moved from the module docstring. +# +# Rebuilt from scratch for the effects program's Phase 2 against +# its dossier. The old `eq.py:LowPass` is not consulted except +# for the seven defects the dossier's section 7 names. +# +# **Where those two sentences stop being exact.** The Phase 2 gate audit +# (2026-09-07) disconfirmed four of the five traits off the point they were +# measured at, and the evidence pack's section 1 carries each with its cause. +# In a player's terms: +# +# * **The corner gain is the resonance to 0.05 dB everywhere except the bottom +# of the Frequency knob at the top of the Resonance knob, at 24 dB/oct.** At +# f0 20 Hz, Q 16, 24 dB/oct the corner stands **0.44 dB low** (0.09 dB at +# 31.5 Hz, inside the bar by 40 Hz). The cause is float32 coefficients at a +# pole that close to z = 1, not the design; the same reading comes off the +# built sections' own coefficients with no audio in the loop. It is a +# quarter-decibel on a peak 24 dB tall - it does not change what the filter +# is for, and it is written here because a trait that says "exactly" has to +# say where. +# * **The shape is one curve at every corner from 31.5 Hz up. Below 25 Hz it +# is not**, by 0.079 dB between 20 and 25 Hz against a 0.05 bar - same +# cause, and it gets worse rather than better on a board where `mp_float_t` +# is single. +# * **"Rings for Q periods" is a 12 dB/oct statement, below about a sixth of +# the sample rate.** Above 12 kHz at 48 kHz the ring measured in periods of +# f0 stretches (2.75 periods at Q 2, against a 1.60-2.50 band), because the +# decay is set by the warped pole and f0 is not it. At 24 dB/oct the ring is +# **1.36x the Resonance number at Q 2 rising to 1.71x at Q 16** - measured +# 2.74 / 6.54 / 13.49 / 27.42 periods at 500 Hz, 48 kHz - so it is longer +# than the knob says at every Q and the knob is not a period count at that +# slope. (The evidence pack's independent record calls this "a uniform +# 1.85x"; its own numbers, which reproduce exactly, do not say that, and the +# correction is in the pack.) +# * **Rate-honest to 0.1 dB of the closed form everywhere except that same +# corner**: f0 20 Hz, Q 16, 24 dB/oct reads 0.378 dB out at 48 kHz and +# 0.293 dB at 44.1 kHz, and 0.008 dB at 22.05 kHz. The failure is +# rate-dependent because the warp is smallest at a low corner. +# +# The one trait that survived an independent refutation is the roll-off: +# **-12.03 dB/oct on the warped axis, unity at DC, at every corner and every +# rate** - swept over the whole Frequency travel it is within **0.0104 dB** of +# its 0.05 dB bar. +# +# **Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). Both filter +# sections and the trim are `audiobiquad.Biquad` - float state, and a tail +# that reaches exact zero. The ported `synthio.Biquad` cannot: a `LowPass` at +# 100 Hz parks on 1 LSB of DC and a 40 Hz one at Q 8 on 4 LSB, and they hold +# it for ever (audiodsp#23, dossier A3 and A14), which is Tier 1's first +# invariant failing at exactly the corners this class is for. That is the +# dossier's section 8 question 1, settled here for this class. +# +# **Cost.** Three sections, always built - and *all three run whatever their +# `mix` is*: `audiodsp_filter_f32.c:216-241` has no branch on `mix`, so a +# section left as a wire costs what a working section costs. Measured on the +# boards 2026-09-07: **5.6 % of one stereo block's real-time deadline on the +# ESP32-P4 and 9.6 % on the S3**, against a dossier budget of 1.5 % / 5 %. +# **Over budget on both, and there is no `" - lean"` patch**: the class has no +# macro that removes a section, and measured across every setting its surface +# can reach the desktop cost moves less than the same setting moves between +# passes (0.013 ms/block against 0.016-0.022). It still runs in real time on both +# boards with room to spare - the budget was instruction-count arithmetic that +# could not see a node graph's per-block Python, and it is the budget that is +# being contested, not the class's fitness for use. +# +# **Latency: zero samples, at every setting and every rate.** Every section is +# a recursive biquad reading no sample it has not been given. No option on +# this class adds a lookahead, a partition or a window, so there is no +# latency-adding option to default off and none to name in milliseconds. +# +# `capabilities = ()`: nothing in a low-pass is measured in beats - its two +# controls are hertz and a dimensionless Q - and this class never reads +# `self._transport()`. +# +# **Mix, and what it means at 24 dB/oct.** Mix is the section-level crossfade +# `audiobiquad` already has, set on every section at once. At 0 every section +# writes its input sample back unchanged, so the whole class is a byte-exact +# wire (measured: a three-section chain at `mix=0` hashes to the source's own +# digest). At 1 it is the full cascade. In between, at 12 dB/oct it is exactly +# the dry/wet blend it reads as; at 24 dB/oct it is that blend applied to each +# of the two sections in turn, which is a smooth walk from wire to cascade but +# is not one half of the fourth-order response. The alternative - a splitter +# and a mixer around the pair - costs two more nodes and would put an int16 +# sum on the dry path, so it is not taken; the honest sentence is here +# instead. + VENDOR = "PyDevices" try: @@ -150,9 +143,33 @@ class LowPass(_component.Component): - """A two-pole low-pass with a resonant corner, a 12/24 dB/oct slope - switch and a make-up trim. Three `audiobiquad` sections; audiodsp tier; - zero latency.""" + """A low-pass: above the corner the signal rolls away. + + **Controls.** Frequency slides the whole curve along without changing + its shape. Resonance is how loud the corner itself stands: at 0.707 it + is 3 dB down and the roll-off is as gentle as it gets, and turned up it + stands above the passband and a click at the input rings where you put + it. Slope is 12 or 24 dB an octave. Trim is a flat gain across the band. + Mix at 0 is your dry signal. The Effect controls page lists what the + knobs span. + + **Response.** Above the corner the roll-off is 12 or 24 dB an octave. + The curve keeps one shape at every corner from 31.5 Hz up. From 40 Hz + up at 48 kHz the corner stands as loud as Resonance says at either + slope; at the bottom of the Frequency knob with Resonance high on the + steep slope it stands a little low. + + **Timing.** Latency is zero at every setting. Silence in is silence out + once the filter rings down, and the class reports a tail that covers its + lowest corner and highest Resonance. + + **Levels.** Keep the input under -3 dBFS and the defaults and patches + stay off the rails. Trim at its top can still push the output onto the + rails. + + **On a board.** The class needs audiodsp's `audiobiquad`, and on a board + without it construction raises `ImportError`. + """ NAME = 'LowPass' DISPLAY_NAME = 'Low Pass' diff --git a/tests/support/kit_player.py b/tests/support/kit_player.py index b56f541..cc43aba 100644 --- a/tests/support/kit_player.py +++ b/tests/support/kit_player.py @@ -61,13 +61,14 @@ def first_frame(effect): return list(data[:effect.channel_count]) -def latency_problems(cls, rate, delayed=False): +def latency_problems(cls, rate, delayed=False, patches=None): """Every patch whose impulse does not show in the first output frame, or whose reported latency is not zero. `delayed` plants the fault: the impulse is moved one frame late, which a zero-latency reading must - catch.""" + catch. `patches` narrows the walk where a patch's first output sample + rounds to zero for a reason other than delay.""" problems = [] - for patch in sorted(cls.PATCHES): + for patch in sorted(cls.PATCHES) if patches is None else patches: source = impulse(rate) if delayed: values = array.array("h", bytes(2 * 2 * 256)) diff --git a/tests/test_cpython_effects_highpass.py b/tests/test_cpython_effects_highpass.py index 118b1c6..0dc7201 100644 --- a/tests/test_cpython_effects_highpass.py +++ b/tests/test_cpython_effects_highpass.py @@ -649,11 +649,9 @@ def test_every_claim_is_in_the_docstring_and_tested(self): ("TheZeroAtDCIsExact.test_the_settled_output_is_bit_exactly_zero", "TheSkirtIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" "decibels_an_octave")), - ("A low-cut: under the corner the signal goes, and above it nothing is " - "touched.", - ("TheSkirtIsTwelveDecibelsAnOctave.test_nyquist_passes_at_unity", - "TheShapeIsOneCurve.test_the_warped_curve_is_the_same_at_every_" - "corner")), + ("A low-cut: under the corner the signal goes.", + ("TheSkirtIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave",)), ("Resonance is how loud the corner itself stands: at 0.707 it is 3 dB " "down and the cut is as gentle as a cut gets, and turned up it stands " "above the passband and the filter whistles at its own corner.", diff --git a/tests/test_cpython_effects_lowpass.py b/tests/test_cpython_effects_lowpass.py index e395962..764d972 100644 --- a/tests/test_cpython_effects_lowpass.py +++ b/tests/test_cpython_effects_lowpass.py @@ -29,6 +29,7 @@ class clamps now, and the dossier's section 7 names the raise as the defect) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) import kit_ceiling # noqa: E402 +import kit_player # noqa: E402 from effects_measure import SAMPLE_RATE, source # noqa: E402 import kit_faults as faults # noqa: E402 @@ -170,6 +171,16 @@ def test_the_corner_gain_reads_q_at_both_slopes(self): self.assertAlmostEqual(self.corner_gain(slope, q), 20.0 * math.log10(q), delta=0.05) + def test_the_corner_reads_q_from_forty_hertz_up(self): + for slope in (12, 24): + for q in self.QS: + with self.subTest(slope=slope, q=q): + self.assertAlmostEqual(self.corner_gain(slope, q, 40.0), + 20.0 * math.log10(q), delta=0.05) + # At the bottom of the knob, top Resonance, steep slope: low. + self.assertLess(self.corner_gain(24, 16.0, 20.0), + 20.0 * math.log10(16.0) - 0.2) + def test_scaling_both_butterworth_qs_is_red(self): """The fault of the same kind: the design decision A15 records. @@ -685,6 +696,69 @@ def test_clean_at_the_stated_input_level(self): unittest.main() +class ThePlayersText(unittest.TestCase): + def test_latency_is_zero_on_every_patch(self): + # Patches 4 and 5 sit too low for an impulse's first sample to + # clear one LSB through the steep pair; they are the same sections. + self.assertEqual(kit_player.latency_problems( + lowpass.LowPass, SAMPLE_RATE, patches=(0, 1, 2, 3)), []) + self.assertTrue(kit_player.latency_problems( + lowpass.LowPass, SAMPLE_RATE, delayed=True, patches=(0,))) + + def test_construction_needs_audiobiquad(self): + self.assertEqual(lowpass.LowPass.REQUIRES, ("audiobiquad",)) + self.assertEqual(kit_player.missing_module_problems( + lowpass.LowPass, SAMPLE_RATE), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, lowpass.__doc__ + " " + lowpass.LowPass.__doc__, + CLAIMS, globals(), "Slope is 12 or 24 dB an octave.") + + +CLAIMS = ( + ("`LowPass` - the two-pole analog prototype every synth and console " + "low-pass is a version of, digitised without losing its tail.", + ("TheTailReachesExactZero.test_the_rebuilt_class_settles_on_exact_zero", + "TheSlopeIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave")), + ("A low-pass: above the corner the signal rolls away.", + ("TheSlopeIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave",)), + ("Resonance is how loud the corner itself stands: at 0.707 it is 3 dB " + "down and the roll-off is as gentle as it gets, and turned up it " + "stands above the passband and a click at the input rings where you " + "put it.", + ("TheCornerIsTheResonance.test_the_corner_gain_reads_q_at_both_slopes", + "TheDeclaredTailIsTheMeasuredOne.test_the_tail_is_longer_at_a_lower_" + "corner")), + ("Slope is 12 or 24 dB an octave.", + ("TheSlopeIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave",)), + ("Mix at 0 is your dry signal.", + ("TheBypassIsAWire.test_mix_zero_is_byte_identical_to_the_source", + "TheBypassIsAWire.test_mix_zero_with_a_trim_asked_for_is_still_a_" + "wire")), + ("**Response.** Above the corner the roll-off is 12 or 24 dB an " + "octave.", + ("TheSlopeIsTwelveDecibelsAnOctave.test_twelve_and_twenty_four_" + "decibels_an_octave",)), + ("The curve keeps one shape at every corner from 31.5 Hz up.", + ("TheShapeIsOneCurve.test_the_warped_curve_is_the_same_at_every_" + "corner",)), + ("From 40 Hz up at 48 kHz the corner stands as loud as Resonance says " + "at either slope; at the bottom of the Frequency knob with Resonance " + "high on the steep slope it stands a little low.", + ("TheCornerIsTheResonance.test_the_corner_reads_q_from_forty_hertz_up", + "TheClassIsRateHonest.test_the_corner_gain_is_q_at_every_rate")), + ("**Timing.** Latency is zero at every setting.", + ("ThePlayersText.test_latency_is_zero_on_every_patch",)), + ("**Levels.** Keep the input under -3 dBFS and the defaults and " + "patches stay off the rails.", + ("TheCeiling.test_clean_at_the_stated_input_level",)), +) + + class OldConstantTail(lowpass.LowPass): """The bound before audiocomponents#114: one measured class constant, 204 800 samples, from a burst at fixed settings.""" From 67a38378b525a3581ee6f37a58c0dfc67f8e0619 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:18:52 -0500 Subject: [PATCH 3/3] kit_player: flatten the docstring before dropping a sentence from it A sentence wrapped across two lines was never found by the plant, so the check that a missing sentence goes red passed vacuously. --- tests/support/kit_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/support/kit_player.py b/tests/support/kit_player.py index cc43aba..bf13140 100644 --- a/tests/support/kit_player.py +++ b/tests/support/kit_player.py @@ -39,6 +39,8 @@ def claim_problems(doc, claims, owners): def check_claims(case, doc, claims, owners, sentence): """The table holds, and the checker can fail: a figure outside a claim, `sentence` dropped from the docstring, a row naming a missing test.""" + doc = _flat(doc) + case.assertIn(sentence, doc) case.assertEqual(claim_problems(doc, claims, owners), []) case.assertTrue(claim_problems(doc + " It reads 12 ms.", claims, owners)) case.assertTrue(claim_problems(doc.replace(sentence, ""), claims, owners))