diff --git a/lib/audioeffects/highpass.py b/lib/audioeffects/highpass.py index 443d5ab..91a8438 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,32 @@ 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. + + **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/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 new file mode 100644 index 0000000..bf13140 --- /dev/null +++ b/tests/support/kit_player.py @@ -0,0 +1,109 @@ +"""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.""" + 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)) + 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, 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. `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) if patches is None else 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..0dc7201 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,68 @@ 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.", + ("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.", + ("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() 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."""