diff --git a/lib/audioeffects/bandpass.py b/lib/audioeffects/bandpass.py index fb5af85..9b90759 100644 --- a/lib/audioeffects/bandpass.py +++ b/lib/audioeffects/bandpass.py @@ -1,123 +1,128 @@ """`BandPass` - the two-pole resonant band-pass, rebuilt on `audiobiquad`. -The middle output of every state-variable filter ever built: a wah pedal, a -telephone, and the mid leg of a multiband crossover are all this one circuit. -Two knobs and no nonlinearity - a **centre frequency** and a **width** - in -RBJ's *constant 0 dB peak gain* form, whose numerator `(alpha, 0, -alpha)` -has zeros at both `z = 1` and `z = -1`. So it is exactly zero at DC and at -Nyquist, and its peak is 0 dB **at every width**: narrowing the band changes -what gets through, never how loud it is. That is the property that makes the -width knob musical rather than a gain staircase. - -**Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`), and the reason -is a defect, not a preference. On the ported `synthio.Biquad` the recursion -keeps its output memory in Q12 sample units with no dither and no leak, so it -lands on states that reproduce themselves: `BandPass(80 Hz, q=4)` there holds --1 LSB of DC for ever after the music stops (audiodsp#23; the dossier's A3). -`audiobiquad.Biquad` is the float-state answer Phase 1 landed for exactly -that, and on it every setting this class offers settles to bit-exact zero -(the dossier's A14). A stock CircuitPython board does not have the module and -this class says so on construction rather than sounding different there. - -**Cost**, in a musician's terms: two float biquad sections and no tables, no -lookahead and no buffers - the cheapest thing in the effects library after a -gain stage. Both sections are always built, so the cost is the same whichever -way `Slope` sits, and it is the same at every patch: 91.2 ns per stereo frame -at patch 0 and 95.5 at patch 1 on the desktop, against 20 833 ns of real time. -**Measured on both boards** 2026-09-07: 3.8 % of one 256-frame stereo block at -48 kHz on the ESP32-P4 and 6.2 % on the ESP32-S3, against the dossier's budget -of P4 <= 4 % and S3 <= 13 %. Inside both. There is no `" - lean"` patch and -none is owed: no reachable setting costs less than patch 0 does. - -**Latency: zero, at every rate and every setting.** No option this class -offers adds any - there is nothing here that looks ahead, so no option needs -a millisecond figure in this docstring. - -Two things about the surface that the algebra, not the taste, decided: - -* **`Mix` rides on both sections.** `mix = 0` is a byte-exact wire in both - slope settings, which is what Tier 1 asks of it. With `Slope` off the blend - is the plain linear dry/wet `(1-m)*x + m*H(x)`. With `Slope` on it is that - mixed section run twice, `((1-m)I + mH)^2 x`, and **not** a linear blend - between the dry signal and the two-section output: per-section crossfades - cannot cancel their own cross terms, and no arrangement of this palette's - nodes gets a linear one without a splitter and a mixer. -* **`Slope` on divides the working Q by 1.5538** - it multiplies by - `sqrt(sqrt(2) - 1)` - so that two cascaded sections keep the -3 dB width - the `Width` knob asks for. Without it the cascade's width would be 41 % of - the number on the panel. - -**Five of the dossier's rows are disconfirmed, and they are two different -things.** One belongs to this build and bounds what the class promises; four -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 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 audiodsp 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 -node and run through a `float64` recursion, give `-0.003 dB`. Run through the -kernel's `float32` one they give `-0.480`, which is the class to a ten -thousandth of a decibel. `audiodsp_filter_f32.c:220-238` is a direct-form I -biquad with float state, and at `w0 = 0.0026 rad` its two feedback -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 -audiodsp#64 with the fix (a transposed direct form II costs nothing at run -time), and the fix landed; the one cell left is what it did not reach -(audiocomponents#66). - -**The prototype's four**, each measured against RBJ's closed form at the -running rate and each tracking it to <= 0.009 dB, so none of them is a defect -of this build - what fails is the tolerance the dossier wrote around a warped -axis: - -* **T4's `|H(f0/100)| = -37.0 +- 0.1 dB` clause, above about 2.5 kHz.** - Measured `-36.994` at f0 500 Hz, `-37.001` at 1 kHz, `-37.037` at 2 kHz, - then `-37.279` at 4.8 kHz, `-37.837` at 8 kHz and `-41.350` at 16 kHz, - against a closed form of `-36.991 / -37.001 / -37.038 / -37.281 / -37.837 - / -41.359`. -* **T2's `-3 dB` edges, above about 2 kHz.** At f0 4 kHz - patch 3 `Presence - Window` sits at 4 072 Hz - the predicted edges read `-3.209 / -3.222` at - Q 16 and `-3.130 / -3.803` at Q 0.5, against a `-3.0 +- 0.15` bar; the - closed form reads the same four numbers. -* **T4's low skirt, at Q >= 8.** `+6.435 dB/oct` at f0 500 Hz Q 8 against a - `+(5.7...6.3)` band - and `+6.435` from the closed form. Patch 4 `Narrow - Probe` is Q 23.8, so it is a shipped setting, not a corner. -* **T4's high skirt, at Q >= 8.** `-6.586 dB/oct` at f0 500 Hz Q 8 inside the - clause's own `f0 <= 600 Hz` condition; closed form `-6.585`. - -Read together they say one thing: the +-6 dB/oct and -3 dB clauses are Q 0.707 -statements below about 2 kHz, and the dossier did not write the conditions -down. The same warp is the defect the trait-critic pass fixed for T4's high -skirt and left standing in the rest. - -**One thing to know before automating it.** The biquad's coefficients step -at the block boundary - they are deliberately not interpolated, because -sliding between two high-Q sections can pass through an unstable pair -(`audiodsp/docs/upstream-diff.md:1953`). So a *hard jump* in `Frequency` or -`Width` - a program change from a high centre to a low one, say - rings the -energy stored at the old centre out through the new filter, and that is -audible as a click: measured 11 964 peak from a 2 637 peak steady state on a -1 421 Hz -> 203 Hz jump, against 9 442 for the same jump on the stock palette, -so it is the shape of the circuit and not of this rebuild. Automate the knob -in steps and it is a sweep; jump it and it is a click. - -`capabilities = ()`: nothing in a band-pass is measured in beats, and the -class never reads `self._transport()`. - -Traits frozen 2026-09-07. +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. +# +# The middle output of every state-variable filter ever built: a wah pedal, a +# telephone, and the mid leg of a multiband crossover are all this one circuit. +# Two knobs and no nonlinearity - a **centre frequency** and a **width** - in +# RBJ's *constant 0 dB peak gain* form, whose numerator `(alpha, 0, -alpha)` +# has zeros at both `z = 1` and `z = -1`. So it is exactly zero at DC and at +# Nyquist, and its peak is 0 dB **at every width**: narrowing the band changes +# what gets through, never how loud it is. That is the property that makes the +# width knob musical rather than a gain staircase. +# +# **Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`), and the reason +# is a defect, not a preference. On the ported `synthio.Biquad` the recursion +# keeps its output memory in Q12 sample units with no dither and no leak, so it +# lands on states that reproduce themselves: `BandPass(80 Hz, q=4)` there holds +# -1 LSB of DC for ever after the music stops (audiodsp#23; the dossier's A3). +# `audiobiquad.Biquad` is the float-state answer Phase 1 landed for exactly +# that, and on it every setting this class offers settles to bit-exact zero +# (the dossier's A14). A stock CircuitPython board does not have the module and +# this class says so on construction rather than sounding different there. +# +# **Cost**, in a musician's terms: two float biquad sections and no tables, no +# lookahead and no buffers - the cheapest thing in the effects library after a +# gain stage. Both sections are always built, so the cost is the same whichever +# way `Slope` sits, and it is the same at every patch: 91.2 ns per stereo frame +# at patch 0 and 95.5 at patch 1 on the desktop, against 20 833 ns of real time. +# **Measured on both boards** 2026-09-07: 3.8 % of one 256-frame stereo block at +# 48 kHz on the ESP32-P4 and 6.2 % on the ESP32-S3, against the dossier's budget +# of P4 <= 4 % and S3 <= 13 %. Inside both. There is no `" - lean"` patch and +# none is owed: no reachable setting costs less than patch 0 does. +# +# **Latency: zero, at every rate and every setting.** No option this class +# offers adds any - there is nothing here that looks ahead, so no option needs +# a millisecond figure in this docstring. +# +# Two things about the surface that the algebra, not the taste, decided: +# +# * **`Mix` rides on both sections.** `mix = 0` is a byte-exact wire in both +# slope settings, which is what Tier 1 asks of it. With `Slope` off the blend +# is the plain linear dry/wet `(1-m)*x + m*H(x)`. With `Slope` on it is that +# mixed section run twice, `((1-m)I + mH)^2 x`, and **not** a linear blend +# between the dry signal and the two-section output: per-section crossfades +# cannot cancel their own cross terms, and no arrangement of this palette's +# nodes gets a linear one without a splitter and a mixer. +# * **`Slope` on divides the working Q by 1.5538** - it multiplies by +# `sqrt(sqrt(2) - 1)` - so that two cascaded sections keep the -3 dB width +# the `Width` knob asks for. Without it the cascade's width would be 41 % of +# the number on the panel. +# +# **Five of the dossier's rows are disconfirmed, and they are two different +# things.** One belongs to this build and bounds what the class promises; four +# 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 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 audiodsp 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 +# node and run through a `float64` recursion, give `-0.003 dB`. Run through the +# kernel's `float32` one they give `-0.480`, which is the class to a ten +# thousandth of a decibel. `audiodsp_filter_f32.c:220-238` is a direct-form I +# biquad with float state, and at `w0 = 0.0026 rad` its two feedback +# 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 +# audiodsp#64 with the fix (a transposed direct form II costs nothing at run +# time), and the fix landed; the one cell left is what it did not reach +# (audiocomponents#66). +# +# **The prototype's four**, each measured against RBJ's closed form at the +# running rate and each tracking it to <= 0.009 dB, so none of them is a defect +# of this build - what fails is the tolerance the dossier wrote around a warped +# axis: +# +# * **T4's `|H(f0/100)| = -37.0 +- 0.1 dB` clause, above about 2.5 kHz.** +# Measured `-36.994` at f0 500 Hz, `-37.001` at 1 kHz, `-37.037` at 2 kHz, +# then `-37.279` at 4.8 kHz, `-37.837` at 8 kHz and `-41.350` at 16 kHz, +# against a closed form of `-36.991 / -37.001 / -37.038 / -37.281 / -37.837 +# / -41.359`. +# * **T2's `-3 dB` edges, above about 2 kHz.** At f0 4 kHz - patch 3 `Presence +# Window` sits at 4 072 Hz - the predicted edges read `-3.209 / -3.222` at +# Q 16 and `-3.130 / -3.803` at Q 0.5, against a `-3.0 +- 0.15` bar; the +# closed form reads the same four numbers. +# * **T4's low skirt, at Q >= 8.** `+6.435 dB/oct` at f0 500 Hz Q 8 against a +# `+(5.7...6.3)` band - and `+6.435` from the closed form. Patch 4 `Narrow +# Probe` is Q 23.8, so it is a shipped setting, not a corner. +# * **T4's high skirt, at Q >= 8.** `-6.586 dB/oct` at f0 500 Hz Q 8 inside the +# clause's own `f0 <= 600 Hz` condition; closed form `-6.585`. +# +# Read together they say one thing: the +-6 dB/oct and -3 dB clauses are Q 0.707 +# statements below about 2 kHz, and the dossier did not write the conditions +# down. The same warp is the defect the trait-critic pass fixed for T4's high +# skirt and left standing in the rest. +# +# **One thing to know before automating it.** The biquad's coefficients step +# at the block boundary - they are deliberately not interpolated, because +# sliding between two high-Q sections can pass through an unstable pair +# (`audiodsp/docs/upstream-diff.md:1953`). So a *hard jump* in `Frequency` or +# `Width` - a program change from a high centre to a low one, say - rings the +# energy stored at the old centre out through the new filter, and that is +# audible as a click: measured 11 964 peak from a 2 637 peak steady state on a +# 1 421 Hz -> 203 Hz jump, against 9 442 for the same jump on the stock palette, +# so it is the shape of the circuit and not of this rebuild. Automate the knob +# in steps and it is a sweep; jump it and it is a click. +# +# `capabilities = ()`: nothing in a band-pass is measured in beats, and the +# class never reads `self._transport()`. +# +# Traits frozen 2026-09-07. + VENDOR = "PyDevices" import audiobiquad @@ -126,10 +131,26 @@ class never reads `self._transport()`. class BandPass(_component.Component): - """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.""" + """A resonant band-pass: the band around the centre comes through and + the rest falls away. + + **Controls.** Frequency is the centre. Width is how wide the band is, + and narrowing it changes what gets through, never how loud the centre + is. Slope on puts a second section behind the first, for steeper skirts + at the same width. Mix at 0 is your dry signal. The Effect controls page + lists what the knobs span. + + **Response.** The centre stands at 0 dB at every width, except a tenth + of a decibel low at 31.5 Hz with the band at its narrowest. DC and the + top of the band are cut to exact zero. + + **Timing.** Latency is zero at every setting. Silence in settles to + exact silence out. A hard jump in Frequency or Width rings out as a + click; move the knob in steps and it sweeps. + + **On a board.** The class needs audiodsp's `audiobiquad`, and on a board + without it construction raises `ImportError`. + """ NAME = 'BandPass' DISPLAY_NAME = 'Band Pass' diff --git a/lib/audioeffects/compressor.py b/lib/audioeffects/compressor.py index 15e7376..0b57baa 100644 --- a/lib/audioeffects/compressor.py +++ b/lib/audioeffects/compressor.py @@ -1,106 +1,108 @@ -"""`Compressor` - four compressor characters over one palette. - -Rebuilt from scratch at Phase 2 against a dossier whose -trait table was frozen at Station A on 2026-09-07. The four characters are -the dossier's four standouts, and they differ in **detector law, release law, -ratio law and side-chain weighting** - not in three time constants, which is -all the class this replaces had (dossier §7.2). - -Mix back up from 0 comes back as the class was built: the two stages -cleared and the voices primed afresh. An instance built wet and moved to 0 -before it played used to keep the block its construction primed and play it -256 frames after the input stopped (audiocomponents#113). - -* **`fet`** - the 1176. Peak detector, both time knobs live and *faster - clockwise* across 800 to 20 microseconds and 1.1 seconds to 50 - milliseconds, threshold rising with ratio. Turn everything up and it - distorts, which is the sound the panel's all-buttons-in trick is famous for. -* **`optical`** - the LA-2A, and the default. **Attack, Release, Release Slow - and Memory do nothing here**, because the T4 cell sets both times and the - unit has no time knobs: ten milliseconds of attack, half the recovery in the - first sixty milliseconds and the rest over a couple of seconds, slower after - a long or a deep passage. That inertness is trait O3 and is measured, not - asserted. -* **`vca`** - the dbx 160. A true-RMS detector, so a square and a sine of the - same RMS get the same gain where a peak detector is 3 dB apart on them, and - a release that is a straight line in decibels rather than an exponential. -* **`varimu`** - the Fairchild 670. No ratio to speak of: the slope climbs - with level, from gentle on small peaks to a hard limit on loud ones, and - the six factory time-constant positions are patches 8 through 13. - -**Two `Dynamics` in series**, fast then slow. The slow one is the *second -release stage*: its own attack runs in seconds, so its envelope is still -climbing after a short burst and settled after a long one, which is a memory -of how long the passage was without a new state variable. At `Memory` 0 it is -set to `ratio=1`, which is byte-identical to a wire, so a single-stage patch -pays the node and none of its sound. - -**Latency is zero at every setting**, both rates, every patch: this class -builds no latency-adding option at all. `lookahead_ms` stays at its default 0 -and is not on the surface - lookahead belongs to `Limiter`, where the latency -buys a brickwall. `tail_samples` is 0 for the same reason a compressor has no -tail: the gain multiplies the audio, so silence in is silence out on the same -sample. - -**Levels.** Keep the input under -6 dBFS and the defaults and patches stay -off the rails. - -**Two traits this palette cannot carry, recorded rather than hidden** -(dossier App. J.3, J.4): - -* The side chain is **feed-forward on all four characters**. The node does - have a `feedback_detector`, and the 1176, the LA-2A and the 670 are all - feedback designs - but on this gain computer a feedback detector settles at - half the overshoot, 2:1 effective, at *every* ratio setting, so a character - that also has to make 4:1 through 20:1 cannot use it. -* On the `vca` character the RMS averaging window trait V3 needs floors the - attack above the 3 ms trait V2 asks for at 30 dB over threshold. The - shipped patch takes the RMS detector, which is the dbx's headline; V2's - level-dependent attack law is demonstrated on the peak detector instead. - -**What it does not do, and where.** Three of the dossier's claims did not -survive the Phase 2 refutation pass and its gate audit, and the class was -not edited to save them: the numbers below are this class's own behaviour -at settings a player dials, measured over the span each row quantifies -over rather than at one point. - -* **It is not clean on bass material with a fast release.** At 10 dB of - gain reduction on a 50 Hz tone, patch 1 reads **0.021 %** THD at the - Release knob's slow end and **3.89 %** at its fast end, against the - dossier's 0.5 % bar, and **three of the four `fet` patches are over that - bar at 50 Hz** (1.13 / 0.94 / 1.09 %) - the fourth is All-Button, which - is meant to be dirty. Patch 0 `Level Ride`, the constructor's own - defaults, reads 0.83 % on the same probe, on a character whose rows state - no cleanliness bar at all. The bar holds under 0.5 % for a release slower - than about 300 ms at 50 Hz, about 80 ms at 200 Hz, and at every Release - position at 1 kHz (0.24 % at the fast end). That is the envelope - following the waveform, which is what a fast peak compressor does to a - bass note - the 1176 does it too - but traits F5 and V6 said "clean - everywhere except All-Button", so they are **disconfirmed** and bounded - here. -* **The true-RMS detector stops being level-honest below about 65 Hz.** - Trait V3 is the dbx's: a square and a sine of the same RMS get the same - gain where a peak detector is 3 dB apart on them. Measured on patch 3, - they are 0.079 dB apart at 400 Hz, 0.322 at 100 Hz, **0.509 at 63 Hz and - 0.769 at 40 Hz** against a 0.5 dB bar, because the shipped 10 ms window - cannot average a 50 Hz square. The fix is on the surface: at the `RMS - Window` macro's 100 ms end the same reading is **0.086 dB** at 50 Hz. -* **The FET threshold tilt holds at Threshold −8 dB and below, not - below −4.2 dB.** F2's rise is 6 dB per decade of ratio above 4:1 — - 4.18 dB at 20:1. The independent refutation of 2026-09-07 (commit - `17a4cc9`) attacked inside the old bound: at Threshold −7 dB the - fitted knee is 12.0 / 8.5 / 6.5 / 10.5 (20:1 widens), and at −6 dB - and −5 dB both columns fail. At −12 / −10 / −8 and at the row's own - −36 dB the threshold still rises and the knee still narrows. The - 0 dBFS curve window cannot finish a narrowing knee once the 20:1 - tilt has pushed the threshold near the ceiling; the 0 dB clamp then - wrecks the 20:1 point from about −5.5 dB up. F2 is demonstrated at - Threshold −8 dB and below. - -`capabilities` is `()`: a compressor's timing is program-dependent, not -tempo-dependent, and nothing here reads the transport. +"""`Compressor` - compressor characters on a shared palette. + +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 at Phase 2 against a dossier whose +# trait table was frozen at Station A on 2026-09-07. The four characters are +# the dossier's four standouts, and they differ in **detector law, release law, +# ratio law and side-chain weighting** - not in three time constants, which is +# all the class this replaces had (dossier §7.2). +# +# Mix back up from 0 comes back as the class was built: the two stages +# cleared and the voices primed afresh. An instance built wet and moved to 0 +# before it played used to keep the block its construction primed and play it +# 256 frames after the input stopped (audiocomponents#113). +# +# * **`fet`** - the 1176. Peak detector, both time knobs live and *faster +# clockwise* across 800 to 20 microseconds and 1.1 seconds to 50 +# milliseconds, threshold rising with ratio. Turn everything up and it +# distorts, which is the sound the panel's all-buttons-in trick is famous for. +# * **`optical`** - the LA-2A, and the default. **Attack, Release, Release Slow +# and Memory do nothing here**, because the T4 cell sets both times and the +# unit has no time knobs: ten milliseconds of attack, half the recovery in the +# first sixty milliseconds and the rest over a couple of seconds, slower after +# a long or a deep passage. That inertness is trait O3 and is measured, not +# asserted. +# * **`vca`** - the dbx 160. A true-RMS detector, so a square and a sine of the +# same RMS get the same gain where a peak detector is 3 dB apart on them, and +# a release that is a straight line in decibels rather than an exponential. +# * **`varimu`** - the Fairchild 670. No ratio to speak of: the slope climbs +# with level, from gentle on small peaks to a hard limit on loud ones, and +# the six factory time-constant positions are patches 8 through 13. +# +# **Two `Dynamics` in series**, fast then slow. The slow one is the *second +# release stage*: its own attack runs in seconds, so its envelope is still +# climbing after a short burst and settled after a long one, which is a memory +# of how long the passage was without a new state variable. At `Memory` 0 it is +# set to `ratio=1`, which is byte-identical to a wire, so a single-stage patch +# pays the node and none of its sound. +# +# **Latency is zero at every setting**, both rates, every patch: this class +# builds no latency-adding option at all. `lookahead_ms` stays at its default 0 +# and is not on the surface - lookahead belongs to `Limiter`, where the latency +# buys a brickwall. `tail_samples` is 0 for the same reason a compressor has no +# tail: the gain multiplies the audio, so silence in is silence out on the same +# sample. +# +# **Two traits this palette cannot carry, recorded rather than hidden** +# (dossier App. J.3, J.4): +# +# * The side chain is **feed-forward on all four characters**. The node does +# have a `feedback_detector`, and the 1176, the LA-2A and the 670 are all +# feedback designs - but on this gain computer a feedback detector settles at +# half the overshoot, 2:1 effective, at *every* ratio setting, so a character +# that also has to make 4:1 through 20:1 cannot use it. +# * On the `vca` character the RMS averaging window trait V3 needs floors the +# attack above the 3 ms trait V2 asks for at 30 dB over threshold. The +# shipped patch takes the RMS detector, which is the dbx's headline; V2's +# level-dependent attack law is demonstrated on the peak detector instead. +# +# **What it does not do, and where.** Three of the dossier's claims did not +# survive the Phase 2 refutation pass and its gate audit, and the class was +# not edited to save them: the numbers below are this class's own behaviour +# at settings a player dials, measured over the span each row quantifies +# over rather than at one point. +# +# * **It is not clean on bass material with a fast release.** At 10 dB of +# gain reduction on a 50 Hz tone, patch 1 reads **0.021 %** THD at the +# Release knob's slow end and **3.89 %** at its fast end, against the +# dossier's 0.5 % bar, and **three of the four `fet` patches are over that +# bar at 50 Hz** (1.13 / 0.94 / 1.09 %) - the fourth is All-Button, which +# is meant to be dirty. Patch 0 `Level Ride`, the constructor's own +# defaults, reads 0.83 % on the same probe, on a character whose rows state +# no cleanliness bar at all. The bar holds under 0.5 % for a release slower +# than about 300 ms at 50 Hz, about 80 ms at 200 Hz, and at every Release +# position at 1 kHz (0.24 % at the fast end). That is the envelope +# following the waveform, which is what a fast peak compressor does to a +# bass note - the 1176 does it too - but traits F5 and V6 said "clean +# everywhere except All-Button", so they are **disconfirmed** and bounded +# here. +# * **The true-RMS detector stops being level-honest below about 65 Hz.** +# Trait V3 is the dbx's: a square and a sine of the same RMS get the same +# gain where a peak detector is 3 dB apart on them. Measured on patch 3, +# they are 0.079 dB apart at 400 Hz, 0.322 at 100 Hz, **0.509 at 63 Hz and +# 0.769 at 40 Hz** against a 0.5 dB bar, because the shipped 10 ms window +# cannot average a 50 Hz square. The fix is on the surface: at the `RMS +# Window` macro's 100 ms end the same reading is **0.086 dB** at 50 Hz. +# * **The FET threshold tilt holds at Threshold −8 dB and below, not +# below −4.2 dB.** F2's rise is 6 dB per decade of ratio above 4:1 — +# 4.18 dB at 20:1. The independent refutation of 2026-09-07 (commit +# `17a4cc9`) attacked inside the old bound: at Threshold −7 dB the +# fitted knee is 12.0 / 8.5 / 6.5 / 10.5 (20:1 widens), and at −6 dB +# and −5 dB both columns fail. At −12 / −10 / −8 and at the row's own +# −36 dB the threshold still rises and the knee still narrows. The +# 0 dBFS curve window cannot finish a narrowing knee once the 20:1 +# tilt has pushed the threshold near the ceiling; the 0 dB clamp then +# wrecks the 20:1 point from about −5.5 dB up. F2 is demonstrated at +# Threshold −8 dB and below. +# +# `capabilities` is `()`: a compressor's timing is program-dependent, not +# tempo-dependent, and nothing here reads the transport. + VENDOR = "PyDevices" import math @@ -182,7 +184,41 @@ class Compressor(_component.Component): - """Four compressor characters on two `audiodynamics.Dynamics` nodes.""" + """Compressor characters after classic studio units, on a shared panel. + + **Characters.** Character picks the unit. On `fet`, after the Urei + compressor, the time knobs get faster as you turn them up, and with + everything up it distorts, which is the sound its buttons-in trick is + famous for. On `optical`, after the Teletronix and the default, a light + cell sets the times, so the time knobs and Memory are idle; recovery is + quick at first and slow after, and slower again after a long or deep + passage. On `vca`, after the dbx, the detector reads true RMS and the + release falls in a straight line in decibels. On `varimu`, after the + Fairchild, the slope climbs with level, gentle on small peaks and hard + on loud ones, and the Vari-Mu patches are its time-constant positions. + + **Controls.** Threshold, Ratio, Knee and Makeup set the curve. Release + Slow and Memory run a second, slower stage that remembers how long the + passage was. Detector switches between peak and RMS, and RMS Window sets + how long the RMS average is. Emphasis and Emphasis Freq tilt the side + chain toward a band. Mix at 0 is your dry signal. The Effect controls + page lists what the knobs span. + + **Timing.** Latency is zero at every setting. The gain multiplies the + audio, so silence in is silence out on the same sample. + + **Where it is not clean.** On bass with a fast release the gain follows + the waveform and the output thickens, as it does on the hardware; a + slower release cleans it up. The RMS detector reads a deep square and + sine differently at its default window; a wider RMS Window fixes it. + + **Levels.** Keep the input under -6 dBFS and the defaults and patches + stay off the rails. + + **On a board.** The class needs audiodsp's `audiodynamics` and + `audioroute`, and on a board without them construction raises + `ImportError`. + """ #: Construction arms the voices on silence (`Component._play_voice`), #: so a Mix 0 set before the first pull hands out the source whole. diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index 026a54b..9130d96 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -1,147 +1,152 @@ -"""ProCo Rat distortion, with the Boss DS-1 as a second character. - -**What it is.** A hard clip to ground after a gain stage, not a diode pair -in the feedback loop (that is `Overdrive`). Character `filter` is the Rat: -two feedback legs that brighten into the clipper, the op-amp's own bandwidth -folding that rise into a mid hump at high Distortion, then a first-order -Filter that runs backwards (clockwise darker). Character `scoop` is the -DS-1: a 35 dB booster, a second gain stage, the same kind of clipper under -a 7.2 kHz cap, and a passive V-shaped tone. Mix 0 is the wire; Distortion -0 is not -- the Rat is nearly clean there (F4) and the DS-1 never is (SC3). -The DS-1's booster is in the circuit, not on the panel, so `scoop` starts -with Boost at 35 dB; the Rat has no booster and starts at 0. - -**Mix back up from 0 plays nothing from before.** At Mix 0 nothing behind -the source is pulled, so the circuit used to keep what it held and play it -when Mix came back - 24 589 LSB out of silence at shipped patch 1. It now -comes back as it was built: cleared, the output capacitor charged on the -bias at the scoop patches, the voices re-armed (audiocomponents#113). - -**Portability tier: audiodsp** (`REQUIRES = ("audioshaper", "audiobiquad", -"audioroute")`). The clipper is `audioshaper.Waveshaper` with a table -derived from the 1N4148 pair (tools/curves/distortion_curve.py). Tone and -the Rat's legs are `audiobiquad` so a 60 Hz pole still decays to exact -zero; `synthio.Biquad` / `audiofilters.Filter` would not (audiodsp#77). -`scoop` also carries the DS-1's **output coupling capacitor** at 20 Hz. -It is no longer load-bearing: **Asymmetry is a second table now, not a -bias.** `STOCK_ASYMMETRY * macro` was a 3.5 V offset into diodes that clip -at 0.62 V, which parked the operating point at 0.97 of full scale and -answered a silent input with up to **-6.8 dBFS of DC**; the capacitor held -that back to the biquad's dead band and left the *step* - 0.45 of full -scale, 86 ms, every time the input went from silence to a note (audit 3 -(p)4). The DS-1's asymmetric clipping section is one 1N4148 one way and -two in series the other, so Asymmetry blends `_CURVE_DS1_DIODE` into -`_CURVE_DS1_ASYM` and **both answer zero with zero**: silence in is -silence out at every shipped patch, the tail settles inside its -declaration, and a click reads its own latency on all eleven patches -where four of them used to be unmeasurable. It costs 1.8 to 2.3 dB of -level at the four scoop patches, because the asymmetric table's full -scale stands for the *quieter* side's 1.220 V against the pair's 0.622. - -**Each character's table is a normalised shape, not volts.** Both fill Q15 -to the rails, and `_CURVE_RAT_DIODE_VOLTS` / `_CURVE_DS1_DIODE_VOLTS` are -what their full scale stands for; `_push` multiplies the live one into the -shaper's `post_gain` beside Ceiling. Level and curve are exactly what they -were, and the tables keep all the resolution they have - they used to -reach 66 % and 62 % of int16 (audiocomponents#77). - -**Cost, reference patch 0 (filter, Mix 1, Distortion mid, 48 kHz).** -MidSide + Splitter x2 (2-tap) + Biquad x4 + Mixer x2 + Waveshaper x4. The -Rat's gain stage is 1 + a*HP(60 Hz) + b*HP(1539 Hz), normalised by its own -treble gain so the two mixer levels sum to 1; the +1 is voice 0's dry -side, through `Biquad.mix`, and costs no node. Priced sum 1.849 / 3.323 ms -+ glue 0.0 -> **P4 <= 35 %, S3 <= 63 %**, re-derived 2026-09-17 from a -graph three nodes smaller than the 2.245 / 4.128 (43 % / 78 %) one the -board ran. At 22.05 kHz the same graph runs the shaper at x8 and the block -is 11.61 ms, so it is 2.868 / 5.126 ms -> **25 % / 45 %**: fewer node -sums per second than 48 kHz, not more. Constructor `oversample=2` is -26 % / 47 %; it is not a patch. - -**Latency.** `latency_samples` is **2 at Mix 1 and 0 at every Mix below -it**. The x4 half-band is the only pure delay in the graph, and 2 is the -click's integer onset at full wet - the same at 48, 44.1 and 22.05 kHz, in -mono and in stereo, on both characters (x1 reports 0, x2 1, x8 2). But the -dry leg is not delayed, so as soon as any dry is in the mix the class's -own onset is the dry one: a click at Mix 126 arrives at sample 0, and -reporting 2 there was true of one point on the axis and wrong at the other -126. The sub-sample click reads 15.2 / 14.3 / 8.7 samples at the three -rates on `filter` and 4.0 / 4.0 / 3.7 on `scoop`; that is the -minimum-phase group delay of the biquads, which `effect_measurements. -click` says is a property of the class and not the latency this reports. -Oversampling is not a macro; it is the construction default, named here -because it is the latency-adding option and it is on. `oversample=1` is 0 -samples of half-band delay and misses A1 by 25 dB. - -**What the default surrenders.** Patch 0 is Distortion 64, Filter 64, Mix -127, silicon Ceiling. - -* F1's 11-18 dB 100 Hz->1 kHz tilt is a *low Distortion* claim, read at - Distortion 2 % and 10 % with under 0.30 V on the clipper. At the shipped - Distortion 64 the tilt is **+1.1 dB**, because F2's mid hump is already - on. The tilt also moves with level - -12.2 dB at Distortion 10 % with - 0.21 V of pre-clip drive, -7.0 dB at 0.53 V - and the row's own 0.30 V - line is what bounds it. -* F2 is read by Appendix G's equal-drive contour at maximum Distortion: - the peak is at 800 Hz and 10 kHz is 15.5 dB below it at 48 kHz, 14.2 at - 44.1 kHz. At **22.05 kHz the row cannot be read at all** - 10 kHz is - above this class's Nyquist margin. -* F5's 5 kHz clause is **false at 22.05 kHz**. The default 1 kHz->5 kHz - fall is -11.7 dB at 48 and 44.1 kHz and **-13.7 dB** at 22.05 kHz, and - the closed Filter reads -23.2 dB at 5 kHz against the dossier's - -20.48 +/-1.5, because `_fo_lp` parks its second pole at - min(40 kHz, 0.45*rate) and that is 9.9 kHz at 22.05 kHz. The 1 kHz and - 2 kHz clauses hold at all three rates. -* **Volume and Mix stop moving the output above a knee, and the class - says where it is.** `wet_ceiling()` returns the largest wet level the - headroom split can deliver at the Ceiling and Asymmetry in force: at the - shipped Ceiling MIDI 42 it is **1.0 - the whole travel works** - and at - Ceiling MIDI 108 it is **0.6317, which is Volume MIDI 80**, above which - 84, 96, 112 and 127 all read -6.622 dBFS. That is 43 positions and - 3.6 dB of label travel delivering 0.00 dB at shipped patch 3, and it was - undisclosed until the third fix round (audit 3 ruling (o)). - `TestDistortionVolumeAndMixTravel` walks both knobs end to end at three - Ceilings and holds the class to its own knee. -* **The Filter macro works over its whole travel on `scoop` now.** It read - the Rat's series resistance and then took `max(fc, CLIP_LP_HZ)`, which - pinned MIDI 8 to 127 on one section - 120 positions that did nothing - - and made MIDI 1 to 6 *brighter* than MIDI 0. `FILTER_R0_SCOOP` puts the - pot's own top on the DS-1's 7230 Hz corner instead, so Filter 0 is the - stock pedal byte for byte and 10 kHz falls monotonically from +1.26 dB - to -16.59 dB across the travel. -* **Ceiling stops raising the level before its travel ends.** It is - output gain, and the clipper's output is held at or below 0.74 of full - scale (`CLIPPER_CEILING`), so the knob goes quiet at about **MIDI 53 at - Volume maximum** and **MIDI 78 at the shipped Volume 100**. The top of - the travel is 2.5 dB below where the saturating build sat and 18 dB - cleaner, because past that the node clipped its own output at the base - rate and the alias floor fell to -44 dB. -* **A1 is a -20 dBFS number, and the row says so now.** The bar is - **-60 dB at 1010 Hz over -22 to -16 dBFS**, graded at both ends and the - middle: -62.43 / -63.33 / -60.56 at -22, -62.44 / -60.55 / -60.66 at - -19, -68.37 / -66.97 / -65.06 at -16, at 48 / 44.1 / 22.05 kHz. Outside - that window it does not hold: **-55.7 / -52.3 / -52.5 at -12 dBFS**, and - at playing level **-46.5 / -44.7 / -45.0 at -6 dBFS** and -43.8 at -0.9 - (audit 3 ruling (m)). Below the window the reading stops being the - class's - the pack's instrument reads -58.6 at -24 dBFS and the suite's - -63.1 at the same cell, which is the int16 floor of a quiet render and - not a number either of them should be graded on. It is a claim about - 1010 Hz too: at **7010 Hz** the same default reads -42.2 / -34.3 / - -30.1 at -20 dBFS. **And "it is not aliasing" is a x4 sentence**: the - ladder at -20 dBFS, 48 kHz is x1 -36.14, x2 -56.44, x4 -61.66, x8 - -62.63, so what the shipped factor buys is 25 dB and what it leaves is - the quantisation the redefinition names. It holds at the shipped default - in that window and stays under -50 dB - anywhere on `filter` with Boost at the 0 the Rat ships with. Raise - Boost, or switch to `scoop`, whose DS-1 booster is 35 dB in circuit, and - the floor follows the boost: **-40.4 dB at patch 6** and -39.0 dB at the - worst scoop cell measured (patch 6 at Ceiling 96, 44.1 kHz) - both about - 3 and 15 dB better than the bias model read, because the asymmetric - table does not park the clipper against its own rail. Redefined under - vision 7.2 on 2026-09-17; the dossier's revision carries the old target - and the measurements that replaced it. -* Scoop traits are not the shipped character. +"""`Distortion` - the ProCo Rat, with a Boss pedal as a second character. + +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. +# +# **What it is.** A hard clip to ground after a gain stage, not a diode pair +# in the feedback loop (that is `Overdrive`). Character `filter` is the Rat: +# two feedback legs that brighten into the clipper, the op-amp's own bandwidth +# folding that rise into a mid hump at high Distortion, then a first-order +# Filter that runs backwards (clockwise darker). Character `scoop` is the +# DS-1: a 35 dB booster, a second gain stage, the same kind of clipper under +# a 7.2 kHz cap, and a passive V-shaped tone. Mix 0 is the wire; Distortion +# 0 is not -- the Rat is nearly clean there (F4) and the DS-1 never is (SC3). +# The DS-1's booster is in the circuit, not on the panel, so `scoop` starts +# with Boost at 35 dB; the Rat has no booster and starts at 0. +# +# **Mix back up from 0 plays nothing from before.** At Mix 0 nothing behind +# the source is pulled, so the circuit used to keep what it held and play it +# when Mix came back - 24 589 LSB out of silence at shipped patch 1. It now +# comes back as it was built: cleared, the output capacitor charged on the +# bias at the scoop patches, the voices re-armed (audiocomponents#113). +# +# **Portability tier: audiodsp** (`REQUIRES = ("audioshaper", "audiobiquad", +# "audioroute")`). The clipper is `audioshaper.Waveshaper` with a table +# derived from the 1N4148 pair (tools/curves/distortion_curve.py). Tone and +# the Rat's legs are `audiobiquad` so a 60 Hz pole still decays to exact +# zero; `synthio.Biquad` / `audiofilters.Filter` would not (audiodsp#77). +# `scoop` also carries the DS-1's **output coupling capacitor** at 20 Hz. +# It is no longer load-bearing: **Asymmetry is a second table now, not a +# bias.** `STOCK_ASYMMETRY * macro` was a 3.5 V offset into diodes that clip +# at 0.62 V, which parked the operating point at 0.97 of full scale and +# answered a silent input with up to **-6.8 dBFS of DC**; the capacitor held +# that back to the biquad's dead band and left the *step* - 0.45 of full +# scale, 86 ms, every time the input went from silence to a note (audit 3 +# (p)4). The DS-1's asymmetric clipping section is one 1N4148 one way and +# two in series the other, so Asymmetry blends `_CURVE_DS1_DIODE` into +# `_CURVE_DS1_ASYM` and **both answer zero with zero**: silence in is +# silence out at every shipped patch, the tail settles inside its +# declaration, and a click reads its own latency on all eleven patches +# where four of them used to be unmeasurable. It costs 1.8 to 2.3 dB of +# level at the four scoop patches, because the asymmetric table's full +# scale stands for the *quieter* side's 1.220 V against the pair's 0.622. +# +# **Each character's table is a normalised shape, not volts.** Both fill Q15 +# to the rails, and `_CURVE_RAT_DIODE_VOLTS` / `_CURVE_DS1_DIODE_VOLTS` are +# what their full scale stands for; `_push` multiplies the live one into the +# shaper's `post_gain` beside Ceiling. Level and curve are exactly what they +# were, and the tables keep all the resolution they have - they used to +# reach 66 % and 62 % of int16 (audiocomponents#77). +# +# **Cost, reference patch 0 (filter, Mix 1, Distortion mid, 48 kHz).** +# MidSide + Splitter x2 (2-tap) + Biquad x4 + Mixer x2 + Waveshaper x4. The +# Rat's gain stage is 1 + a*HP(60 Hz) + b*HP(1539 Hz), normalised by its own +# treble gain so the two mixer levels sum to 1; the +1 is voice 0's dry +# side, through `Biquad.mix`, and costs no node. Priced sum 1.849 / 3.323 ms +# + glue 0.0 -> **P4 <= 35 %, S3 <= 63 %**, re-derived 2026-09-17 from a +# graph three nodes smaller than the 2.245 / 4.128 (43 % / 78 %) one the +# board ran. At 22.05 kHz the same graph runs the shaper at x8 and the block +# is 11.61 ms, so it is 2.868 / 5.126 ms -> **25 % / 45 %**: fewer node +# sums per second than 48 kHz, not more. Constructor `oversample=2` is +# 26 % / 47 %; it is not a patch. +# +# **Latency.** `latency_samples` is **2 at Mix 1 and 0 at every Mix below +# it**. The x4 half-band is the only pure delay in the graph, and 2 is the +# click's integer onset at full wet - the same at 48, 44.1 and 22.05 kHz, in +# mono and in stereo, on both characters (x1 reports 0, x2 1, x8 2). But the +# dry leg is not delayed, so as soon as any dry is in the mix the class's +# own onset is the dry one: a click at Mix 126 arrives at sample 0, and +# reporting 2 there was true of one point on the axis and wrong at the other +# 126. The sub-sample click reads 15.2 / 14.3 / 8.7 samples at the three +# rates on `filter` and 4.0 / 4.0 / 3.7 on `scoop`; that is the +# minimum-phase group delay of the biquads, which `effect_measurements. +# click` says is a property of the class and not the latency this reports. +# Oversampling is not a macro; it is the construction default, named here +# because it is the latency-adding option and it is on. `oversample=1` is 0 +# samples of half-band delay and misses A1 by 25 dB. +# +# **What the default surrenders.** Patch 0 is Distortion 64, Filter 64, Mix +# 127, silicon Ceiling. +# +# * F1's 11-18 dB 100 Hz->1 kHz tilt is a *low Distortion* claim, read at +# Distortion 2 % and 10 % with under 0.30 V on the clipper. At the shipped +# Distortion 64 the tilt is **+1.1 dB**, because F2's mid hump is already +# on. The tilt also moves with level - -12.2 dB at Distortion 10 % with +# 0.21 V of pre-clip drive, -7.0 dB at 0.53 V - and the row's own 0.30 V +# line is what bounds it. +# * F2 is read by Appendix G's equal-drive contour at maximum Distortion: +# the peak is at 800 Hz and 10 kHz is 15.5 dB below it at 48 kHz, 14.2 at +# 44.1 kHz. At **22.05 kHz the row cannot be read at all** - 10 kHz is +# above this class's Nyquist margin. +# * F5's 5 kHz clause is **false at 22.05 kHz**. The default 1 kHz->5 kHz +# fall is -11.7 dB at 48 and 44.1 kHz and **-13.7 dB** at 22.05 kHz, and +# the closed Filter reads -23.2 dB at 5 kHz against the dossier's +# -20.48 +/-1.5, because `_fo_lp` parks its second pole at +# min(40 kHz, 0.45*rate) and that is 9.9 kHz at 22.05 kHz. The 1 kHz and +# 2 kHz clauses hold at all three rates. +# * **Volume and Mix stop moving the output above a knee, and the class +# says where it is.** `wet_ceiling()` returns the largest wet level the +# headroom split can deliver at the Ceiling and Asymmetry in force: at the +# shipped Ceiling MIDI 42 it is **1.0 - the whole travel works** - and at +# Ceiling MIDI 108 it is **0.6317, which is Volume MIDI 80**, above which +# 84, 96, 112 and 127 all read -6.622 dBFS. That is 43 positions and +# 3.6 dB of label travel delivering 0.00 dB at shipped patch 3, and it was +# undisclosed until the third fix round (audit 3 ruling (o)). +# `TestDistortionVolumeAndMixTravel` walks both knobs end to end at three +# Ceilings and holds the class to its own knee. +# * **The Filter macro works over its whole travel on `scoop` now.** It read +# the Rat's series resistance and then took `max(fc, CLIP_LP_HZ)`, which +# pinned MIDI 8 to 127 on one section - 120 positions that did nothing - +# and made MIDI 1 to 6 *brighter* than MIDI 0. `FILTER_R0_SCOOP` puts the +# pot's own top on the DS-1's 7230 Hz corner instead, so Filter 0 is the +# stock pedal byte for byte and 10 kHz falls monotonically from +1.26 dB +# to -16.59 dB across the travel. +# * **Ceiling stops raising the level before its travel ends.** It is +# output gain, and the clipper's output is held at or below 0.74 of full +# scale (`CLIPPER_CEILING`), so the knob goes quiet at about **MIDI 53 at +# Volume maximum** and **MIDI 78 at the shipped Volume 100**. The top of +# the travel is 2.5 dB below where the saturating build sat and 18 dB +# cleaner, because past that the node clipped its own output at the base +# rate and the alias floor fell to -44 dB. +# * **A1 is a -20 dBFS number, and the row says so now.** The bar is +# **-60 dB at 1010 Hz over -22 to -16 dBFS**, graded at both ends and the +# middle: -62.43 / -63.33 / -60.56 at -22, -62.44 / -60.55 / -60.66 at +# -19, -68.37 / -66.97 / -65.06 at -16, at 48 / 44.1 / 22.05 kHz. Outside +# that window it does not hold: **-55.7 / -52.3 / -52.5 at -12 dBFS**, and +# at playing level **-46.5 / -44.7 / -45.0 at -6 dBFS** and -43.8 at -0.9 +# (audit 3 ruling (m)). Below the window the reading stops being the +# class's - the pack's instrument reads -58.6 at -24 dBFS and the suite's +# -63.1 at the same cell, which is the int16 floor of a quiet render and +# not a number either of them should be graded on. It is a claim about +# 1010 Hz too: at **7010 Hz** the same default reads -42.2 / -34.3 / +# -30.1 at -20 dBFS. **And "it is not aliasing" is a x4 sentence**: the +# ladder at -20 dBFS, 48 kHz is x1 -36.14, x2 -56.44, x4 -61.66, x8 +# -62.63, so what the shipped factor buys is 25 dB and what it leaves is +# the quantisation the redefinition names. It holds at the shipped default +# in that window and stays under -50 dB +# anywhere on `filter` with Boost at the 0 the Rat ships with. Raise +# Boost, or switch to `scoop`, whose DS-1 booster is 35 dB in circuit, and +# the floor follows the boost: **-40.4 dB at patch 6** and -39.0 dB at the +# worst scoop cell measured (patch 6 at Ceiling 96, 44.1 kHz) - both about +# 3 and 15 dB better than the bias model read, because the asymmetric +# table does not park the clipper against its own rail. Redefined under +# vision 7.2 on 2026-09-17; the dossier's revision carries the old target +# and the measurements that replaced it. +# * Scoop traits are not the shipped character. + VENDOR = "PyDevices" from array import array @@ -492,10 +497,44 @@ def _fo_hp(frequency, rate): class Distortion(_component.Component): - """Hard clipping after a shaped gain stage. `audiodsp` tier. - - Character `filter` (default) follows the ProCo Rat; `scoop` follows the - Boss DS-1. Mix 0 is the wire. Distortion 0 is clean only in `filter`. + """A hard-clipping distortion with a pair of pedals inside it: the ProCo + Rat (`filter`, the default) and Boss's orange distortion (`scoop`). + + **Controls.** Distortion is the gain in front of the clip. Filter is the + tone control after it, and on the Rat it runs backwards: clockwise is + darker. Tone is the Boss's scooped bass-to-treble balance. Volume is the + output level and Mix how much of the clipped signal replaces your note. + Character picks the pedal. Ceiling is how tall the clipped signal + stands. Boost is a booster in front of the clip, and `scoop` starts + with it at 35 dB because the Boss has one in its circuit. Asymmetry + clips the Boss's sides unevenly, which brings in even harmonics. + Body moves the Rat's treble leg between the stock pedal and a common + modification. The Effect controls page lists what the knobs span. + + **Switching.** Mix at 0 hands back your signal untouched. Leaving 0 + switches the pedal on fresh, rather than replaying what it held when it + went off. + + **Sound.** At Distortion 0 the Rat is nearly clean and the Boss never + is. Turned up, the Rat's brightness folds into a mid hump. On `scoop` + every Filter position is its own, and Filter at 0 is the stock Boss. + At the defaults, aliasing on a 1010 Hz tone between -22 and -16 dBFS + stays 60 dB under the note; louder than that it does not. + + **Timing.** Latency is 2 samples at Mix 1 and zero at any Mix below it. + Silence in is silence out at every shipped patch, and the tail settles + inside what the class reports. + + **Levels.** A full-scale input plays without wrapping at every shipped + patch. The clip keeps its output inside its own headroom at any Ceiling, + so Ceiling stops raising the level before the top of its travel, and + with Ceiling high, Volume and Mix stop moving the output above a knee + that `wet_ceiling()` reports. No shipped patch sits on a DC offset, and + the first note does not arrive on one. + + **On a board.** The class needs audiodsp's `audioshaper`, `audiobiquad` + and `audioroute`, and on a board without them construction raises + `ImportError`. """ #: Construction arms the voices on silence (`Component._play_voice`), diff --git a/lib/audioeffects/exciter.py b/lib/audioeffects/exciter.py index 036719b..beebd62 100644 --- a/lib/audioeffects/exciter.py +++ b/lib/audioeffects/exciter.py @@ -1,223 +1,228 @@ -"""`Exciter` - dry plus a one-sided high-passed harmonic branch. - -Rebuilt from scratch at Phase 4 against its dossier, traits frozen -2026-09-17 at Station A. The old class in `drive.py` was consulted only for -the defects that dossier §7 names: no macros, no Harmonics knob, Mix to -1.0, no transient character, and an unexplained mixer buffer size. That -module went when this class was adopted on the boards on 2026-09-18. - -**What it is.** The note always passes at unity. A second-order Butterworth -high-pass (Tune) feeds a 1N914 one-sided clipper; that wet branch is -summed under the dry at Mix ≤ 0.7. Character `classic` is the 1977 patent -(fixed threshold). Character `transient` puts `DYN_TRANSIENT` ahead of the -clipper so harmonics are loud at onset (S2's shape), **+6 dB over 8 ms**. -Envelope-following `Waveshaper.bias` was not granted; bias is static. -Transient Time is not on the surface: T5's τ clause is disconfirmed. - -**Mix back up from 0 plays nothing from before.** At Mix 0 (with Output -at 0 dB) the class hands back its source and the harmonic branch is not -pulled; it used to keep what it held and play it when Mix came back, up to -10 768 LSB out of silence. It is now cleared and its voices re-armed first -(audiocomponents#113). - -**Harmonics is 0…+14 dB of drive across the diode, not 0…+24.** Measured -on this class's own curve, the top ten of those twenty-four decibels buy -**1.76 dB** of h2 at −6 dBFS and cost **6.7 dB** of alias: past the knee -the 1N914's shape has stopped changing, while the time the signal takes -to cross the knee keeps shrinking, and a shorter corner folds harder. At -+24 dB T7 was red at four points of its own grid (−57.0 to −59.0 dB at -Tune 5000 and 6000, 48 and 44.1 kHz) and at 22.05 kHz above Harmonics -≈ 0.3; at +14 dB the whole grid is green at all three rates, worst -**−63.7 dB**, and both of those exclusions are withdrawn — see the 22.05 kHz -bullet below for what replaces the second one. The knob's own -0…1 span is unchanged; a user who wants the old drive turns it further -up. Raising `oversample` to ×8 would have fixed the same four points -(−72.9 to −77.2 dB) and does not fit: the palette sum on the S3 is -3.804 ms/block on `classic` and 4.557 on `transient`, which against the -2026-09-17 board run is 5.106 ms total (**rt 1.04**) and 5.859 ms -(**rt 0.91**). - -**Output is an output.** The Output macro is −24…0 dB and trims the -whole effect — both mixer voices — so wet:dry does not move with it. -Before 2026-09-17 it reached `Waveshaper.post_gain` only, which trimmed -the wet branch alone and meant the class could not attenuate its own -output anywhere on its surface; its old +6 dB top also put the wet over -the dry (audiocomponents#72). There is no makeup gain above unity: the -dry path is already unity and the sum is s16. - -**Tune is clamped to rate/7.** A biquad's bilinear transform compresses -the frequency axis towards Nyquist, so the sidechain's high-pass reads -steeper than second order as its corner rises — by an amount that is a -function of Tune/rate alone (rate/8 → 12.95 dB down an octave below, -rate/6 → 13.50, rate/5 → 14.14, against T1's 12.3 ± 1.5 dB). At 48 and -44.1 kHz rate/7 is above the macro's own 6 kHz top and never bites. **At -22.05 kHz Tune tops out at 3150 Hz**, whatever the knob reads. - -**Portability: audiodsp.** `audioshaper.Waveshaper` at ×4, `audiobiquad` -for the high-pass, `audioroute.Splitter` for the dry copy, and -`audiodynamics.Dynamics` **only on `transient`**. It will not import on a -stock CircuitPython board. - -**Cost**, re-derived 2026-09-18 at the rows both boards took that morning. -`classic` is Splitter-2 0.207/0.368 + **2×**Biquad 0.216/0.408 + -Waveshaper ×4 1.069/1.918 + a two-voice Mixer 0.050/0.175 + glue 0 -= **1.542 / 2.869 ms → 29 % / 54 %** of a 5.333 ms stereo block. Two -Biquads and not three: `_chain` is an *alias* — it is `_hp` on `classic` -and `_dyn` on `transient`, never a node of its own. `transient` adds -`Dynamics@transient` 0.495/0.985: **2.037 / 3.854 ms → 39 % / 73 %**. - -**The `Dynamics` row is not priced at the wrong settings, and that was the -open question.** The palette builds it with both gains at 0 dB, where it -applies unity, and this class runs it at +6 / −12 dB with an 8 ms fast -release — so the board asked whether the row under-prices the node -(audiocomponents#72). Measured on desktop MicroPython, order-balanced, -8000 blocks a run, three reps each, twice: **a ratio of 1.034 and 1.062** -between this class's own gains and 0 dB. A few per cent, not the 27 % the -S3 is missing. **The gains are not what the row was missing.** - -**What the boards measured on the 128-frame graph is 32.3 % / 57.8 % and -42.5 % / 81.6 %** — over by 3.4 / 4.0 and 4.3 / 9.3 points. The S3's extra -five points on -`transient` are the `Dynamics` costing this class **1.234 ms** where the -palette prices it 0.985, against **0.537 against 0.495 on the P4**: 27 % -over on the S3 and 9 % on the P4. The suspect, unproven: the class handed -back a **128-frame** block, and a `Dynamics` renders in 256-frame blocks by -construction (`audiodsp_dynamics.h:56`, "the effects library's latency -assumptions … are written around it"), so it sat in a graph shaped the -wrong way for it. `MIXER_BUFFER_BYTES × channel_count` makes one pull one -block, and it **moves no byte** — `59f2e64ed63c1362` at patch 0 and -`56b7966ed074ab8b` at patch 1, at either length, on all three interpreters. -**Desktop MicroPython cannot confirm the saving and in one arrangement -reads the other way** (the node behind a 128-frame mixer measured 0.850× a -256-frame one, over six order-balanced runs); an x86 build's per-call -overhead is under a microsecond, which is why. **The second board pass -measured the 256-frame graph on 2026-09-18**: 28.9 % / 54.3 % on `classic`, -39.3 % / 77.0 % on `transient`, 39.4 % / 77.5 % at the costliest patch. The -block was worth 3.4 / 3.5 points on `classic` and 3.2 / 4.6 on `transient`; -the `Dynamics` on the S3 is still 22 % over its row and still unexplained. - -**Alone on an ESP32-S3 this uses 54 % of a block on `classic` and 77 % on -`transient`, 78 % at its costliest patch; there is no lean patch, so run it -alone or with something cheap.** - -(Setting `transient_fast_release_ms` still costs nothing — the four -transient time constants are config fields whose defaults are the literals -the node used before they were fields, `audiodsp_dynamics.c:48-53`.) -**No lean position.** `oversample=2` was documented as one until -2026-09-17 on the strength of the two points that sentence named. Over -T7's own grid ×2 is red at **12 of 48** readings, worst **−45.1 dB** -(44.1 kHz, Tune 5000, Harmonics 1.0), and at the drive this class shipped -until today it was red on patch 2 `Mix Sheen` as well (−59.9 dB; it reads -−63.4 now). A position that does not hold the trait is not a lean -position; the constructor argument stays, undocumented as a saving. - -**Latency: zero samples** at every macro setting. The dry tap is a copy. -`oversample` (constructor, default 4) puts group delay on the *wet -branch only*: ×4 is 3.3 samples / **0.069 ms** at 48 kHz, ×8 is 3.9 / -**0.081 ms**, ×1 is 0. It is not on the sixteen-macro surface. -`tail_samples` is **4096** since the third fix round - the 30 Hz output -coupling pole rings, and the worst measured is **3 085 frames** across -three rates and eight builds after a 0 dBFS burst. It was 512. There is no -delay line. - -**What this class gives up, and where its traits are weaker than at a -convenient setting.** Patch 0 is Tune 4 kHz, Harmonics 0.25, Mix 0.3, -`classic`, Output 0 dB, and every headline below is that default: - -- **No makeup gain, and a stated input ceiling.** Output is −24…0 dB and - the dry path is already unity, so the class only attenuates and has no - headroom control at all. What it has instead is a number: - `INPUT_CEILING_DBFS` is **−3 dBFS**, measured with sines at 1, 3, 5.5 - and 8 kHz over every shipped patch and the Harmonics-max / Mix-max - corner — the first railed sample anywhere is at −2 dBFS (patch 6, - 5.5 kHz, 250 of 4 800) and −3 dBFS is clean everywhere (audit 3 (p)4, - ruling (o)). -- **There is a coupling capacitor behind the diode now.** The diode is - asymmetric on purpose and the only high-pass was **upstream** of it, so - the rectified offset walked out on any in-band tone: −122 to −1601 LSB - on the shipped patches and **−4310 LSB (−17.6 dBFS)** at Harmonics 1.0 / - Mix max / −1 dBFS. A silence test never saw it, because the offset only - exists under material. `DC_BLOCK_HZ` is 30 Hz, far below Tune's own - floor; the same cells read **−1.8 LSB** and **−0.1** now, and - `tail_samples` went 512 → 4096 for the pole's own ring (audit 3 (p)5). -- **T1 is a −40 dBFS row and says so.** The trait holds Harmonics 0 and - −40 dBFS fixed, which is where the clipper is within 0.05 dB of linear, - and it holds there at all three rates. Walked up the level axis it - leaves its 10.8–13.8 dB bar at **32 of 72 readings, in 9 of 9 rows** — - and every one of them is at **−12 dBFS or hotter**. At −40, −30 and - −20 dBFS it is inside the bar at 27 of 27 cells, at all three rates and - at Harmonics 0, 0.25 and 1.0. The worst reading anywhere is - **−9.635 dB** (48 kHz, Harmonics 0.25, 0 dBFS), 1.165 dB outside. - (Audit 3 (p)7 quoted 13 of 72 in 6 of 9 rows and audit 4 recorded it as - not re-derived; re-derived here at `ac96c72`, after the coupling pole, - the count is 32 and the rows are all nine.) -- **At 22.05 kHz the corner tops out at 3150 Hz** (rate/7), whatever - Tune reads back. -- **At 22.05 kHz T7 is disconfirmed, and the column is not green.** The - claim that "the whole 22.05 kHz column is green (worst −64.4 dB)" - appeared in four places and was read at the Tune grid's own stops: - walked at **every eighth MIDI stop** with Harmonics at maximum, **6 of - 12 readable stops are over the −60 dB bar** — −41.661 at Tune MIDI 56 - (1656 Hz), −52.393 at MIDI 24, −56.584 at 48, −56.767 at 72, −57.773 at - 16, −59.935 at 40 — every one of them a fold landing between 8.8 and - 11.0 kHz. The row is claimed at **48 and 44.1 kHz** and disconfirmed at - 22.05 kHz, where the class's own harmonic series runs out of room - before the bar does (audit 3 (p)1 and (p)2). -- **T7 is claimed up to the stated input ceiling and no further.** At - 48 kHz with Tune and Harmonics at maximum the worst in-band line is - **−63.461 dB at −6 dBFS and −63.475 at −3**, which is - `INPUT_CEILING_DBFS`, and **−59.856 at −1 dBFS and −59.058 at 0** — over - the bar, above a level this class already says it cannot take without - railing (ruling (m)). -- **The class's own harmonics fold at 22.05 kHz whatever the factor.** - Send it a tone whose harmonics - reach past the output Nyquist and they fold, and no oversampling factor - reaches them, because the fold is in the last decimation stage and that - stage's transition band sits on the output Nyquist. A 1010 Hz tone at - full drive puts its 11th harmonic at 11110 Hz against a Nyquist of - 11025 and reads **−48.7 dB at 10940.8 Hz**; a 3010 Hz tone puts its 4th - there (**−51.9**); a 8662.5 Hz tone puts its *second* there - (**−54.9**). At 48 and 44.1 kHz the same sweep is green to rate/8 - (−68.5 and −66.3). -- **On material with step edges the wet exceeds the dry.** T4's 20–70 % - is a claim about a tone at or above the corner: at Mix maximum a - 1 kHz **square** puts the wet at **164.7 %** of the dry, because the - high-pass differentiates the edge and the reverse side of the 1N914 - curve is a wire. That is the circuit, not a defect, and Mix is the - control for it. -- **The class adds content BELOW the corner, and no trait covers it.** - The high-pass sits *upstream* of the diode, so the difference tones the - diode makes out of two highs land under the corner with nothing to - remove them. Two tones both above it — 5000 + 5500 Hz at −6 dBFS, - shipped default — put their **500 Hz difference tone at −17.7 dB** on - the wet branch and **−31.7 dB** on the mixed output at the shipped - Mix 0.3 (−25.9 dB at Mix maximum); on noise the wet branch carries - **12.3 dB less** below the corner than the dry does, not none. What - you hear on dense high material is a low rumble that follows the - music's intervals rather than its pitches — thicker than the dry, and - it gets louder with Harmonics and with Mix. A high-pass *after* the - diode would take it out and would also make the sidechain fourth-order, - which is T1's own disconfirmation (24.6 dB an octave below), so this is - disclosed rather than filtered. -- **T7 is a `classic` claim.** On `transient` the Dynamics stage's own - gain ripple folds at base rate, ahead of the shaper, so oversampling - cannot remove it: 12 of 36 grid readings above the bar, worst - **−52.3 dB**, the same at ×2, ×4 and ×8, where `classic` at the same - settings reads −90.6. The shorter onset boost improved it by 12.8 dB - and did not clear it. -- **On `transient`, T4 is read after the onset.** The onset is what the - character is for: on a steady sine at Mix maximum the wet is - **59.6–61.9 %** of the dry once the render's own start has passed, and - **124–135 %** over a window that still contains it. That is the same - named exception as a step edge, and the boost is spent within ~25 ms. -- T6 is a `transient` row and is **disconfirmed**: onset emphasis is - ≥6 dB at both of its levels now (**17.6 dB** at −30 dBFS, **6.7 dB** at - −6 dBFS, 48 kHz) but the two differ by 11.0 dB against a 3 dB bar. T5's - shape holds on `transient` (**12.75 dB** at the frozen 5 kHz/−18 dBFS - against 6 dB) and is false at the shipped `classic` default, which is - the control: **0.00 dB**. +"""`Exciter` - dry plus a high-passed harmonic branch. + +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 at Phase 4 against its dossier, traits frozen +# 2026-09-17 at Station A. The old class in `drive.py` was consulted only for +# the defects that dossier §7 names: no macros, no Harmonics knob, Mix to +# 1.0, no transient character, and an unexplained mixer buffer size. That +# module went when this class was adopted on the boards on 2026-09-18. +# +# **What it is.** The note always passes at unity. A second-order Butterworth +# high-pass (Tune) feeds a 1N914 one-sided clipper; that wet branch is +# summed under the dry at Mix ≤ 0.7. Character `classic` is the 1977 patent +# (fixed threshold). Character `transient` puts `DYN_TRANSIENT` ahead of the +# clipper so harmonics are loud at onset (S2's shape), **+6 dB over 8 ms**. +# Envelope-following `Waveshaper.bias` was not granted; bias is static. +# Transient Time is not on the surface: T5's τ clause is disconfirmed. +# +# **Mix back up from 0 plays nothing from before.** At Mix 0 (with Output +# at 0 dB) the class hands back its source and the harmonic branch is not +# pulled; it used to keep what it held and play it when Mix came back, up to +# 10 768 LSB out of silence. It is now cleared and its voices re-armed first +# (audiocomponents#113). +# +# **Harmonics is 0…+14 dB of drive across the diode, not 0…+24.** Measured +# on this class's own curve, the top ten of those twenty-four decibels buy +# **1.76 dB** of h2 at −6 dBFS and cost **6.7 dB** of alias: past the knee +# the 1N914's shape has stopped changing, while the time the signal takes +# to cross the knee keeps shrinking, and a shorter corner folds harder. At +# +24 dB T7 was red at four points of its own grid (−57.0 to −59.0 dB at +# Tune 5000 and 6000, 48 and 44.1 kHz) and at 22.05 kHz above Harmonics +# ≈ 0.3; at +14 dB the whole grid is green at all three rates, worst +# **−63.7 dB**, and both of those exclusions are withdrawn — see the 22.05 kHz +# bullet below for what replaces the second one. The knob's own +# 0…1 span is unchanged; a user who wants the old drive turns it further +# up. Raising `oversample` to ×8 would have fixed the same four points +# (−72.9 to −77.2 dB) and does not fit: the palette sum on the S3 is +# 3.804 ms/block on `classic` and 4.557 on `transient`, which against the +# 2026-09-17 board run is 5.106 ms total (**rt 1.04**) and 5.859 ms +# (**rt 0.91**). +# +# **Output is an output.** The Output macro is −24…0 dB and trims the +# whole effect — both mixer voices — so wet:dry does not move with it. +# Before 2026-09-17 it reached `Waveshaper.post_gain` only, which trimmed +# the wet branch alone and meant the class could not attenuate its own +# output anywhere on its surface; its old +6 dB top also put the wet over +# the dry (audiocomponents#72). There is no makeup gain above unity: the +# dry path is already unity and the sum is s16. +# +# **Tune is clamped to rate/7.** A biquad's bilinear transform compresses +# the frequency axis towards Nyquist, so the sidechain's high-pass reads +# steeper than second order as its corner rises — by an amount that is a +# function of Tune/rate alone (rate/8 → 12.95 dB down an octave below, +# rate/6 → 13.50, rate/5 → 14.14, against T1's 12.3 ± 1.5 dB). At 48 and +# 44.1 kHz rate/7 is above the macro's own 6 kHz top and never bites. **At +# 22.05 kHz Tune tops out at 3150 Hz**, whatever the knob reads. +# +# **Portability: audiodsp.** `audioshaper.Waveshaper` at ×4, `audiobiquad` +# for the high-pass, `audioroute.Splitter` for the dry copy, and +# `audiodynamics.Dynamics` **only on `transient`**. It will not import on a +# stock CircuitPython board. +# +# **Cost**, re-derived 2026-09-18 at the rows both boards took that morning. +# `classic` is Splitter-2 0.207/0.368 + **2×**Biquad 0.216/0.408 + +# Waveshaper ×4 1.069/1.918 + a two-voice Mixer 0.050/0.175 + glue 0 +# = **1.542 / 2.869 ms → 29 % / 54 %** of a 5.333 ms stereo block. Two +# Biquads and not three: `_chain` is an *alias* — it is `_hp` on `classic` +# and `_dyn` on `transient`, never a node of its own. `transient` adds +# `Dynamics@transient` 0.495/0.985: **2.037 / 3.854 ms → 39 % / 73 %**. +# +# **The `Dynamics` row is not priced at the wrong settings, and that was the +# open question.** The palette builds it with both gains at 0 dB, where it +# applies unity, and this class runs it at +6 / −12 dB with an 8 ms fast +# release — so the board asked whether the row under-prices the node +# (audiocomponents#72). Measured on desktop MicroPython, order-balanced, +# 8000 blocks a run, three reps each, twice: **a ratio of 1.034 and 1.062** +# between this class's own gains and 0 dB. A few per cent, not the 27 % the +# S3 is missing. **The gains are not what the row was missing.** +# +# **What the boards measured on the 128-frame graph is 32.3 % / 57.8 % and +# 42.5 % / 81.6 %** — over by 3.4 / 4.0 and 4.3 / 9.3 points. The S3's extra +# five points on +# `transient` are the `Dynamics` costing this class **1.234 ms** where the +# palette prices it 0.985, against **0.537 against 0.495 on the P4**: 27 % +# over on the S3 and 9 % on the P4. The suspect, unproven: the class handed +# back a **128-frame** block, and a `Dynamics` renders in 256-frame blocks by +# construction (`audiodsp_dynamics.h:56`, "the effects library's latency +# assumptions … are written around it"), so it sat in a graph shaped the +# wrong way for it. `MIXER_BUFFER_BYTES × channel_count` makes one pull one +# block, and it **moves no byte** — `59f2e64ed63c1362` at patch 0 and +# `56b7966ed074ab8b` at patch 1, at either length, on all three interpreters. +# **Desktop MicroPython cannot confirm the saving and in one arrangement +# reads the other way** (the node behind a 128-frame mixer measured 0.850× a +# 256-frame one, over six order-balanced runs); an x86 build's per-call +# overhead is under a microsecond, which is why. **The second board pass +# measured the 256-frame graph on 2026-09-18**: 28.9 % / 54.3 % on `classic`, +# 39.3 % / 77.0 % on `transient`, 39.4 % / 77.5 % at the costliest patch. The +# block was worth 3.4 / 3.5 points on `classic` and 3.2 / 4.6 on `transient`; +# the `Dynamics` on the S3 is still 22 % over its row and still unexplained. +# +# **Alone on an ESP32-S3 this uses 54 % of a block on `classic` and 77 % on +# `transient`, 78 % at its costliest patch; there is no lean patch, so run it +# alone or with something cheap.** +# +# (Setting `transient_fast_release_ms` still costs nothing — the four +# transient time constants are config fields whose defaults are the literals +# the node used before they were fields, `audiodsp_dynamics.c:48-53`.) +# **No lean position.** `oversample=2` was documented as one until +# 2026-09-17 on the strength of the two points that sentence named. Over +# T7's own grid ×2 is red at **12 of 48** readings, worst **−45.1 dB** +# (44.1 kHz, Tune 5000, Harmonics 1.0), and at the drive this class shipped +# until today it was red on patch 2 `Mix Sheen` as well (−59.9 dB; it reads +# −63.4 now). A position that does not hold the trait is not a lean +# position; the constructor argument stays, undocumented as a saving. +# +# **Latency: zero samples** at every macro setting. The dry tap is a copy. +# `oversample` (constructor, default 4) puts group delay on the *wet +# branch only*: ×4 is 3.3 samples / **0.069 ms** at 48 kHz, ×8 is 3.9 / +# **0.081 ms**, ×1 is 0. It is not on the sixteen-macro surface. +# `tail_samples` is **4096** since the third fix round - the 30 Hz output +# coupling pole rings, and the worst measured is **3 085 frames** across +# three rates and eight builds after a 0 dBFS burst. It was 512. There is no +# delay line. +# +# **What this class gives up, and where its traits are weaker than at a +# convenient setting.** Patch 0 is Tune 4 kHz, Harmonics 0.25, Mix 0.3, +# `classic`, Output 0 dB, and every headline below is that default: +# +# - **No makeup gain, and a stated input ceiling.** Output is −24…0 dB and +# the dry path is already unity, so the class only attenuates and has no +# headroom control at all. What it has instead is a number: +# `INPUT_CEILING_DBFS` is **−3 dBFS**, measured with sines at 1, 3, 5.5 +# and 8 kHz over every shipped patch and the Harmonics-max / Mix-max +# corner — the first railed sample anywhere is at −2 dBFS (patch 6, +# 5.5 kHz, 250 of 4 800) and −3 dBFS is clean everywhere (audit 3 (p)4, +# ruling (o)). +# - **There is a coupling capacitor behind the diode now.** The diode is +# asymmetric on purpose and the only high-pass was **upstream** of it, so +# the rectified offset walked out on any in-band tone: −122 to −1601 LSB +# on the shipped patches and **−4310 LSB (−17.6 dBFS)** at Harmonics 1.0 / +# Mix max / −1 dBFS. A silence test never saw it, because the offset only +# exists under material. `DC_BLOCK_HZ` is 30 Hz, far below Tune's own +# floor; the same cells read **−1.8 LSB** and **−0.1** now, and +# `tail_samples` went 512 → 4096 for the pole's own ring (audit 3 (p)5). +# - **T1 is a −40 dBFS row and says so.** The trait holds Harmonics 0 and +# −40 dBFS fixed, which is where the clipper is within 0.05 dB of linear, +# and it holds there at all three rates. Walked up the level axis it +# leaves its 10.8–13.8 dB bar at **32 of 72 readings, in 9 of 9 rows** — +# and every one of them is at **−12 dBFS or hotter**. At −40, −30 and +# −20 dBFS it is inside the bar at 27 of 27 cells, at all three rates and +# at Harmonics 0, 0.25 and 1.0. The worst reading anywhere is +# **−9.635 dB** (48 kHz, Harmonics 0.25, 0 dBFS), 1.165 dB outside. +# (Audit 3 (p)7 quoted 13 of 72 in 6 of 9 rows and audit 4 recorded it as +# not re-derived; re-derived here at `ac96c72`, after the coupling pole, +# the count is 32 and the rows are all nine.) +# - **At 22.05 kHz the corner tops out at 3150 Hz** (rate/7), whatever +# Tune reads back. +# - **At 22.05 kHz T7 is disconfirmed, and the column is not green.** The +# claim that "the whole 22.05 kHz column is green (worst −64.4 dB)" +# appeared in four places and was read at the Tune grid's own stops: +# walked at **every eighth MIDI stop** with Harmonics at maximum, **6 of +# 12 readable stops are over the −60 dB bar** — −41.661 at Tune MIDI 56 +# (1656 Hz), −52.393 at MIDI 24, −56.584 at 48, −56.767 at 72, −57.773 at +# 16, −59.935 at 40 — every one of them a fold landing between 8.8 and +# 11.0 kHz. The row is claimed at **48 and 44.1 kHz** and disconfirmed at +# 22.05 kHz, where the class's own harmonic series runs out of room +# before the bar does (audit 3 (p)1 and (p)2). +# - **T7 is claimed up to the stated input ceiling and no further.** At +# 48 kHz with Tune and Harmonics at maximum the worst in-band line is +# **−63.461 dB at −6 dBFS and −63.475 at −3**, which is +# `INPUT_CEILING_DBFS`, and **−59.856 at −1 dBFS and −59.058 at 0** — over +# the bar, above a level this class already says it cannot take without +# railing (ruling (m)). +# - **The class's own harmonics fold at 22.05 kHz whatever the factor.** +# Send it a tone whose harmonics +# reach past the output Nyquist and they fold, and no oversampling factor +# reaches them, because the fold is in the last decimation stage and that +# stage's transition band sits on the output Nyquist. A 1010 Hz tone at +# full drive puts its 11th harmonic at 11110 Hz against a Nyquist of +# 11025 and reads **−48.7 dB at 10940.8 Hz**; a 3010 Hz tone puts its 4th +# there (**−51.9**); a 8662.5 Hz tone puts its *second* there +# (**−54.9**). At 48 and 44.1 kHz the same sweep is green to rate/8 +# (−68.5 and −66.3). +# - **On material with step edges the wet exceeds the dry.** T4's 20–70 % +# is a claim about a tone at or above the corner: at Mix maximum a +# 1 kHz **square** puts the wet at **164.7 %** of the dry, because the +# high-pass differentiates the edge and the reverse side of the 1N914 +# curve is a wire. That is the circuit, not a defect, and Mix is the +# control for it. +# - **The class adds content BELOW the corner, and no trait covers it.** +# The high-pass sits *upstream* of the diode, so the difference tones the +# diode makes out of two highs land under the corner with nothing to +# remove them. Two tones both above it — 5000 + 5500 Hz at −6 dBFS, +# shipped default — put their **500 Hz difference tone at −17.7 dB** on +# the wet branch and **−31.7 dB** on the mixed output at the shipped +# Mix 0.3 (−25.9 dB at Mix maximum); on noise the wet branch carries +# **12.3 dB less** below the corner than the dry does, not none. What +# you hear on dense high material is a low rumble that follows the +# music's intervals rather than its pitches — thicker than the dry, and +# it gets louder with Harmonics and with Mix. A high-pass *after* the +# diode would take it out and would also make the sidechain fourth-order, +# which is T1's own disconfirmation (24.6 dB an octave below), so this is +# disclosed rather than filtered. +# - **T7 is a `classic` claim.** On `transient` the Dynamics stage's own +# gain ripple folds at base rate, ahead of the shaper, so oversampling +# cannot remove it: 12 of 36 grid readings above the bar, worst +# **−52.3 dB**, the same at ×2, ×4 and ×8, where `classic` at the same +# settings reads −90.6. The shorter onset boost improved it by 12.8 dB +# and did not clear it. +# - **On `transient`, T4 is read after the onset.** The onset is what the +# character is for: on a steady sine at Mix maximum the wet is +# **59.6–61.9 %** of the dry once the render's own start has passed, and +# **124–135 %** over a window that still contains it. That is the same +# named exception as a step edge, and the boost is spent within ~25 ms. +# - T6 is a `transient` row and is **disconfirmed**: onset emphasis is +# ≥6 dB at both of its levels now (**17.6 dB** at −30 dBFS, **6.7 dB** at +# −6 dBFS, 48 kHz) but the two differ by 11.0 dB against a 3 dB bar. T5's +# shape holds on `transient` (**12.75 dB** at the frozen 5 kHz/−18 dBFS +# against 6 dB) and is false at the shipped `classic` default, which is +# the control: **0.00 dB**. + VENDOR = "PyDevices" from array import array @@ -479,7 +484,42 @@ class Exciter(_component.Component): - """Dry plus a one-sided high-passed harmonic branch (Aphex structure).""" + """A harmonic exciter: your note at unity, with a high-passed copy, + clipped on a single side, added for air and edge. + + **Controls.** Tune is the corner of the high-pass that feeds the clip, + so it picks which part of the note gets brighter. Harmonics drives the + copy into the clip, from 0 to +14 dB. Mix is how much of the copy is + added; it does not turn the note down. Character picks `classic`, a + fixed clip, or `transient`, which drives the copy 6 dB harder for the + first 8 ms of each onset. Output trims the whole effect, the note and + the copy together, so the balance between them does not move with it, + and it only turns down. The Effect controls page lists what the knobs + span. + + **Switching.** Mix at 0, with Output at 0 dB, hands back your signal + untouched. Bringing Mix back up switches the copy on fresh, rather than + replaying what it held when it went off. + + **Sound.** The copy's high-pass is second order at every rate, because + Tune is held under a seventh of the sample rate: at 22.05 kHz it tops + out at 3150 Hz. The clip is one-sided, so the copy carries more second + harmonic than third, and its second harmonic rises with level. At full + Mix the copy stays under the note. The clip runs oversampled, so at + 48 kHz aliasing stays 60 dB under the copy at the defaults and with + Harmonics at the top, up to the input ceiling. + + **Timing.** Latency is zero at every setting. Silence in is silence out + at every shipped patch, and the tail falls to zero within 4096 frames. + + **Levels.** Keep the input under -3 dBFS and nothing rails, on any + shipped patch or with Harmonics and Mix at the top; at -2 dBFS it can. + No shipped patch leaves a DC offset on its output while it plays. + + **On a board.** The class needs audiodsp's `audioshaper`, `audiobiquad`, + `audioroute` and `audiodynamics`, and on a board without them + construction raises `ImportError`. + """ #: Construction arms the voices on silence (`Component._play_voice`), #: so a Mix 0 set before the first pull hands out the source whole. diff --git a/lib/audioeffects/notch.py b/lib/audioeffects/notch.py index 7d163f8..f7a5368 100644 --- a/lib/audioeffects/notch.py +++ b/lib/audioeffects/notch.py @@ -1,68 +1,61 @@ """`Notch` - the two-pole band-stop: everything except one band, tuned. -Rebuilt from scratch for the effects program's Phase 2 against -its dossier. The old `eq.py:Notch` is not consulted except for -the seven defects the dossier's section 7 names. - -**What it sounds like.** One knob says where, one says how wide, and what comes -out is the input with a slice taken out of it and nothing else touched - a -whistle, a feedback tone or a mains hum goes and the rest of the mix does not -move, because a notch is exactly unity at DC and at Nyquist by construction. -Width is a bandwidth, not a depth: at Q 0.7 the notch is wider than the note it -sits on and reads as a scoop; at Q 30 it is a few hertz across and you hear the -tone leave without hearing the music change. Harmonics adds a second notch an -octave up, half as wide in hertz, because mains hum is a series and one notch -at 50 or 60 Hz leaves the buzz. Depth is a blend, not a filter control - at 0 -the class is a wire. Keep the input under -6 dBFS and the defaults and patches -stay off the rails. Trim at its top can still push the output onto the rails. - -**Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). All three -sections are `audiobiquad.Biquad`: float state, and a tail that reaches -exact zero. The ported `synthio.Biquad` cannot - the mains-hum setting -`Notch(60 Hz, q=8)` parks on 2 LSB of DC and `Notch(20 Hz, q=32)` on 18, and -they hold it for ever (audiodsp#23, dossier A3 and A16), which is Tier 1's -first invariant failing at exactly the settings this class is for. - -**What that costs, said here because a caller has to know it.** A `float` -coefficient set cannot put the notch's zeros exactly on the unit circle: -`b0` and `b1` are rounded independently, so the rejection at the centre is -`|2*b0*cos(w0) + b1| * (1+alpha) / (2*alpha*sin(w0))` rather than nothing at -all. Measured (dossier A16, 48 kHz): under one LSB - a true null - from -500 Hz up at Q <= 12, **-35.65 dB at 60 Hz Q 12**, and **-11.21 dB at -20 Hz Q 32**. So this class is a clean utility notch across the musical range and -a 35 dB hum *reducer* at the bottom, not a hum eliminator. The dossier's T1 -is recorded disconfirmed below about 250 Hz for that reason, and its section -5 carries the one node ask that would recover 17-36 dB of it. The ported -kernel is deeper there and cannot hold the Tier 1 invariant; that trade is -dossier section 8, D1 and D2. - -**And one more measured edge.** T2 asks for unity within 0.1 dB at 0.48*Fs -at every setting. It holds everywhere except the top of the Frequency span -at the bottom of the Width span, where the notch's *own band* reaches that -far: at 16 kHz and Q 0.5 the band is 32 kHz wide and 0.48*Fs reads --0.21 dB. That is the probe sitting inside the band, not the filter -leaking, and it is why Frequency clamps at 0.4*Fs rather than at Nyquist. - -**Cost.** Three sections, always built, two of them wires at patch 0 - and -a wire costs what a notch costs, because the kernel runs each recursion -before it blends and there is no branch on `mix` -(`audiodsp/src/shared/audiodsp_filter_f32.c:216-241`). So Harmonics buys back -no CPU: the class costs three sections at every setting. The dossier budgets -1.5 % of one stereo block's real-time deadline on the ESP32-P4 and 5 % on -the S3 with one notch and 2.5 % / 9 % with two, and since the two are the -same figure it is the higher pair that governs; the board run in the -evidence pack is what settles it. - -**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 notch is measured in beats - its controls -are hertz and a dimensionless Q - and this class never reads -`self._transport()`. +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:Notch` is not consulted except for +# the seven defects the dossier's section 7 names. +# +# **Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). All three +# sections are `audiobiquad.Biquad`: float state, and a tail that reaches +# exact zero. The ported `synthio.Biquad` cannot - the mains-hum setting +# `Notch(60 Hz, q=8)` parks on 2 LSB of DC and `Notch(20 Hz, q=32)` on 18, and +# they hold it for ever (audiodsp#23, dossier A3 and A16), which is Tier 1's +# first invariant failing at exactly the settings this class is for. +# +# **What that costs, said here because a caller has to know it.** A `float` +# coefficient set cannot put the notch's zeros exactly on the unit circle: +# `b0` and `b1` are rounded independently, so the rejection at the centre is +# `|2*b0*cos(w0) + b1| * (1+alpha) / (2*alpha*sin(w0))` rather than nothing at +# all. Measured (dossier A16, 48 kHz): under one LSB - a true null - from +# 500 Hz up at Q <= 12, **-35.65 dB at 60 Hz Q 12**, and **-11.21 dB at +# 20 Hz Q 32**. So this class is a clean utility notch across the musical range and +# a 35 dB hum *reducer* at the bottom, not a hum eliminator. The dossier's T1 +# is recorded disconfirmed below about 250 Hz for that reason, and its section +# 5 carries the one node ask that would recover 17-36 dB of it. The ported +# kernel is deeper there and cannot hold the Tier 1 invariant; that trade is +# dossier section 8, D1 and D2. +# +# **And one more measured edge.** T2 asks for unity within 0.1 dB at 0.48*Fs +# at every setting. It holds everywhere except the top of the Frequency span +# at the bottom of the Width span, where the notch's *own band* reaches that +# far: at 16 kHz and Q 0.5 the band is 32 kHz wide and 0.48*Fs reads +# -0.21 dB. That is the probe sitting inside the band, not the filter +# leaking, and it is why Frequency clamps at 0.4*Fs rather than at Nyquist. +# +# **Cost.** Three sections, always built, two of them wires at patch 0 - and +# a wire costs what a notch costs, because the kernel runs each recursion +# before it blends and there is no branch on `mix` +# (`audiodsp/src/shared/audiodsp_filter_f32.c:216-241`). So Harmonics buys back +# no CPU: the class costs three sections at every setting. The dossier budgets +# 1.5 % of one stereo block's real-time deadline on the ESP32-P4 and 5 % on +# the S3 with one notch and 2.5 % / 9 % with two, and since the two are the +# same figure it is the higher pair that governs; the board run in the +# evidence pack is what settles it. +# +# **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 notch is measured in beats - its controls +# are hertz and a dimensionless Q - and this class never reads +# `self._transport()`. + VENDOR = "PyDevices" try: @@ -112,9 +105,29 @@ class Notch(_component.Component): - """A tuned band-stop with a bandwidth knob, an octave-up harmonic notch - and a make-up trim. Three `audiobiquad` sections; audiodsp tier; zero - latency.""" + """A notch: one band taken out, and the rest of the signal left alone. + + **Controls.** Frequency is where the notch sits. Width is its + bandwidth: low, the notch is a wide scoop; high, it is a few hertz + across and you hear a tone leave without hearing the music change. + Harmonics adds a second notch an octave up, as narrow in hertz as the + first, for hum that comes as a series. Depth is a blend, and at 0 the + class is a wire. Trim is a flat gain. The Effect controls page lists + what the knobs span. + + **Response.** DC and the top of the band pass untouched. At low centres + the notch is shallower: there it reduces a hum rather than removing it. + + **Timing.** Latency is zero at every setting. Silence in settles to + exact silence out. + + **Levels.** Keep the input under -6 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 = 'Notch' DISPLAY_NAME = 'Notch' diff --git a/lib/audioeffects/overdrive.py b/lib/audioeffects/overdrive.py index 52710d9..dfd3675 100644 --- a/lib/audioeffects/overdrive.py +++ b/lib/audioeffects/overdrive.py @@ -1,191 +1,196 @@ """`Overdrive` - a Tube Screamer-shaped boost: dry plus a clipped shelf. -Rebuilt from scratch at Phase 4 against its dossier, traits frozen -2026-09-17 at Station A. The old class in `drive.py` is consulted only for -the defects that dossier section 7 names: no macros, an asymmetric -OVERDRIVE curve, a hidden -3 dB trim, and a 4.5 kHz low-pass after mix. - -**What it is.** The note always passes at unity. A high-passed copy is -gained, diode-limited, and added on top, then a movable low-pass. Drive is -never clean (dossier T6): even at the knob's floor a -14 dBFS 1 kHz tone is -already a double-digit THD. Mix 0 is the Tier 1 wire; Drive 0 is still that -12x stage. - -**Mix is how much clipped voltage is added, not a crossfade to bypass.** -At Mix 0 the class hands back the source byte for byte - the pedal is out -of circuit, the way a true-bypass footswitch takes it out. One step above -zero the tone stack is in circuit on the dry note as well, so the step off -Mix 0 is a tone change, and that is the pedal switching on rather than a -fade. It switches on as it was built, not as it was left: the circuit is -cleared and armed again - the output capacitor charged on the offset - -before it is heard, where it used to play what it held when Mix went to 0, -up to 11 640 LSB out of silence (audiocomponents#113). - -**The clip table is a normalised shape, not volts.** `CURVE` fills Q15 to -both rails and `CURVE_VOLTS` is what its full scale stands for; the class -multiplies that into the shaper's `post_gain`. Level and curve are exactly -what they were, and the table keeps all the resolution it has - it used to -reach only 35 % of int16 (audiocomponents#77). - -**Portability: audiodsp.** `audioshaper.Waveshaper`, `audiobiquad` for the -100 Hz tone points Q15 cannot carry, `audioroute` for the dry split. It -will not import on a stock CircuitPython board. - -**Cost.** Palette sum at patch 0, x4, Mix 1: **1.684 / 2.928 ms -> 32 % / -55 %** of a 5.333 ms stereo block (dossier section 3). It was 1.584 / 2.759 -ms until the third fix round added the output pole, which is one more -`audiobiquad` section on the clip branch - 0.100 ms on the P4 and 0.169 on -the S3, from the palette table. **No board has measured the graph with the -pole in it.** Lean patch: none. -The lean position is the constructor option `oversample=2`, which is -cheaper on the S3 and **surrenders T7** - at x2 the wet floor reads --64.5 dB at the constructor default against a -70 dB bar, and -35.8 dB -at the worst macro corner against -40 dB. - -**There is an output capacitor now.** A Tube Screamer is DC-coupled -inside and AC-coupled at the jack; this class had the first half only, so -Symmetry's operating-point offset walked out of the shaper and stood on -the output for ever - **+4461 LSB at shipped patch 6**, into digital -silence, at every rate (audit 3 ruling (n)). `DC_BLOCK_HZ` is a 30 Hz -pole on the clip branch behind the shaper, charged before the first block -and again whenever Symmetry, Mix or Ceiling moves the offset. Silence in -is now silence out at **every shipped patch at 48, 44.1 and 22.05 kHz - -peak 0, not merely zero by the last frame** - and a `program_change` onto -digital silence stays at 0. It costs two things, both measured: the clip -branch carries one more int16 node, which is worth **3.4 dB of alias -floor at the constructor default at 48 kHz** (-81.291 -> -77.939, 1.8 dB -at 44.1, 0.3 dB at 22.05), and a live macro move that changes the offset -re-charges the pole, which costs **one block of input** (5.3 ms at -48 kHz). A CircuitPython board build, which has no -`audiocore.get_buffer`, charges it too, and renders byte for byte what a -desktop does. - -**Latency: zero samples** at every macro setting. The dry tap is a copy. -`oversample` puts group delay on the *clip branch only*: 3.3 samples at -x4, 3.9 at x8, which is **0.069 ms** at 48 kHz x4, **0.081 ms** at 48 kHz -x8 and **0.088 ms** at the 44.1 kHz x8 this class ships. x1 is 0. It is -not on the sixteen-macro surface. Measured integer onset is **0 at every -rate, both channel counts, on all three interpreters**. `tail_samples` is -**4096**, over a worst measured ring of **2692 frames** (48 kHz, patch 6, -a 0 dBFS 1 kHz burst) - the 30 Hz pole is what made it long; it was 512 -over a 222-frame ring before. The declaration is **level-free**: the ring -is 2639 to 2692 frames over 100 Hz and 1 kHz bursts at 0 dBFS and shorter -at every quieter input, because what rings is the pole and not the -signal. No delay line. - -**The input ceiling is -1 dBFS at the shipped patches and -6 dBFS -anywhere on the surface** (`INPUT_CEILING_DBFS`, `SURFACE_CEILING_DBFS`). -The class adds a clipped shelf to a dry note at unity, so it runs out of -int16 somewhere, and until the third fix round it said nowhere. - -**The oversampling factor follows the rate: x8 below 48 kHz, x4 at -48 kHz and above.** Every doubling is worth 11-16 dB of alias floor, and -x8 is where the node stops. 48 kHz keeps x4 because x8 there costs -+0.799 ms a block on the P4 and **+1.408 ms on the S3**, which projects -the S3 to a real-time factor of about 1.00 - the deadline itself. 44.1 -and 22.05 kHz pay x8 against blocks 1.09x and 2.18x as long, so it is -free where the deadline is longer. **Every rate under 24 kHz leaves the -internal rate under the 192 kHz floor** and takes x8 because x8 is all -there is: 22.05 kHz runs 176.4 kHz, 16 kHz runs 128 kHz. **No board has -measured 44.1 or 22.05 kHz**, and the 48 kHz board figures are the only -ones this class has. - -**Alias floors are stated on the wet branch, with the dry copy muted.** -The dry note is summed on the *circuit* mixer, one stage before the -output, so a mixed reading flatters every floor by **5.6 to 6.6 dB**. -`tools/effect_measurements.mute_dry` reaches the inner mixer; every -number below is that reading (audiocomponents#68, #81). - -**Traits that are weaker at the shipped default than at a convenient -setting, named here.** Patch 0 is mid Drive (plateau ~36), Level 100/127, -Tone centre. Headline numbers are those defaults (CPython 48 kHz, 1 kHz -sine unless named): - -- T1 at the default, **at -14 dBFS**: 100 Hz THD 1.883 % < 1 kHz - 12.549 %, and that holds at 44.1 (1.873 / 12.245) and 22.05 kHz (1.853 - / 11.080). **It is a claim about a sine at Body 720 Hz.** The - pair reverses at Drive max (20.3 % vs 16.1 %), at the **bottom of the - Body travel** - Body 360 crosses at all three rates, by less than 0.1 - percentage points - and on a square wave at the default (58.7 % vs - 20.0 %), whose own harmonics swamp the 100 Hz leg. **It reverses on a - sawtooth too**, at all three rates and by a wide margin (86.2 % vs - 70.5 % at 48 kHz) - and a saw is closer to a pickup than either. Shipped - patches **2** (Drive max) and **4** (Body 12) reverse it for the first - two reasons. Small-signal gains are not SPICE's 23.3 / 38.8 / 37.7 dB; - the fitted -3 dB pole at Drive max is **854 Hz**, outside 720 +/- 110. -- T2 even lines, **at -14 dBFS**: the trait names h2 **and h4**. At the - constructor default h2 is -106.608 dB and h4 does not read at all; - **patch 0 read back is h2 -119.206 dB** - it was -64.5 dB until - audiocomponents#87 gave a bipolar macro an exact centre at MIDI 64. h3 at Drive max is -16.6 dB, outside - SPICE -12.6 +/- 2. Shipped patch 6 (Asymmetric) is h2 -25.8 dB with - **h4 -31.8 dB** beside it - the same character, one more line. -- T3 Delta-peak at the default is 0.160 / 0.174 / 0.109 V at -26 / -14 / - -6 dBFS, below the 0.30-0.55 V band; at Drive max it is 0.291 / 0.311 / - 0.284 V. **Those are the readings behind the output pole**, which takes - the clip branch's own offset off the peak; they were 0.297 / 0.326 / - 0.299 at Drive max without it. THD still falls -14 -> -6 dBFS at the default. **Outside that - stated pair the level law is not monotone**: THD *rises* -20 -> -14 - dBFS at 48 and 44.1 kHz, turns back up at -1 dBFS at 22.05 kHz, and - rises with level at 100 Hz. No output rail is hit at any of those - levels, so the turn is the shaper and not the mixer. -- T4 is **disconfirmed**: the tone stack is a moving first-order low-pass, - not the TS wiper network, and the brightest setting is up at 5 kHz where - SPICE is 3.5 dB down. -- T5 THD at **-26 dBFS** rises with Drive - 2.346 / 9.631 / 14.630 % at - Drive MIDI 0 / 64 / 127 - **and that is a claim at Symmetry centred**. - Off centre the law turns over: at Symmetry MIDI 0, 32, 96 and 127 the - THD peaks at Drive MIDI 64 and *falls* above it (at Symmetry 0: 8.601 / - 19.875 / 24.900 / 20.291 / 17.660 % over Drive 0 / 32 / 64 / 96 / 127), - at all three rates. Symmetry is a bias into the shaper, so it decides - which part of the curve the drive is pushing into, and the trait row - never said so. End-to-end plateau gain is **not** 10-15 / 90-130 (Drive - min/max at -72 dBFS is about x4 / x34). -- T6 at Drive *minimum* is 8.305 % THD at -14 dBFS, **under** the 10-40 % - bar; at the default the class is dirtier (12.549 %). -- T7's bar was **restated on 2026-09-17** under vision section 7.2 and - narrowed again by the third fix round, which found the redefinition was - claiming more rates than it had earned. It is now four bars **and a - rate rule**, all on the wet branch at 1010 Hz, **-6 dBFS unless the row - says otherwise**: - **-70 dB at the constructor default** (measured -77.939 / -78.698 / - -75.565 at 48 / 44.1 / 22.05 kHz), **-50 dB at every shipped patch as - shipped** (worst patch 3, -56.078 / -71.365 / -54.443), **-40 dB - anywhere on the macro surface** (worst -47.642 / -61.049 / -46.608, at - Drive max with Body at the bottom of its travel and Tone open), and - **-40 dB at that corner at every input level up to full scale** (worst - -41.956 / -52.157 / **-41.328**, all three at 0 dBFS). A fifth, T7e, - says what the oversampling is worth: `ALIAS_OVERSAMPLE_COST_DB`. -- **Where the old -60 dB target still stands: 44.1 kHz.** The - redefinition's first condition is that the old target is unreachable, - and that is true at two of this class's three rates, not three. At - **44.1 kHz** the shipped x8 build meets -60 dB on the wet branch - everywhere - default -78.698, worst shipped patch -71.365, **worst - surface cell -61.049** - so the old bar is kept there and tested there, - and the redefinition is not invoked. At **48 kHz** x4 reads -47.642 at - that corner and x8 would read **-63.870**; the class declines x8 on - cost (+0.799 ms a block on the P4, +1.408 on the S3, which projects the - S3 to rt 1.00) and that projection **has no board behind it** - a board - run at x8 is what would settle it. At **22.05 kHz** x8 reads -46.608 - and x16 does not exist: `AUDIODSP_SHAPER_MAX_OVERSAMPLE` is 8 - (`audiodsp/src/shared/audiodsp_shaper.h:72`), which vision section 6 - routes to a node ask, and it is filed as **audiodsp#104**. -- T7's **3700 Hz** diagnostic at full Drive does **not** hold, and the - figure the pack published for it was a **mixed** reading. On the wet - branch, with the dry voice muted and a whole number of periods in the - transform, it is **-33.898 dB** at 48 kHz on T7's held line (Drive max) - and **-32.065** at the worst macro corner; mixed, the same cell reads - -37.804, which is where the old -37.7 came from. **5000 Hz is worse and - was named nowhere**: -27.750 held and -30.726 at the corner at 48 kHz, - -35.247 / -37.421 at 44.1 kHz, and **-24.117 / -29.684 at 22.05 kHz**, - which is the worst inharmonic cell this class has anywhere. The tone - stack and the high-pass sit in front of the clip, so the class is worse - than the bare node up there. T7's own bars are 1010 Hz numbers and the - class does not claim them at 3700 or 5000 Hz. -- **Level is the output stage and Mix does not attenuate the dry.** At - Mix 1, Level MIDI 0 is silence, not a wire: the one mixer leg carrying - the whole graph is at zero. +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 at Phase 4 against its dossier, traits frozen +# 2026-09-17 at Station A. The old class in `drive.py` is consulted only for +# the defects that dossier section 7 names: no macros, an asymmetric +# OVERDRIVE curve, a hidden -3 dB trim, and a 4.5 kHz low-pass after mix. +# +# **What it is.** The note always passes at unity. A high-passed copy is +# gained, diode-limited, and added on top, then a movable low-pass. Drive is +# never clean (dossier T6): even at the knob's floor a -14 dBFS 1 kHz tone is +# already a double-digit THD. Mix 0 is the Tier 1 wire; Drive 0 is still that +# 12x stage. +# +# **Mix is how much clipped voltage is added, not a crossfade to bypass.** +# At Mix 0 the class hands back the source byte for byte - the pedal is out +# of circuit, the way a true-bypass footswitch takes it out. One step above +# zero the tone stack is in circuit on the dry note as well, so the step off +# Mix 0 is a tone change, and that is the pedal switching on rather than a +# fade. It switches on as it was built, not as it was left: the circuit is +# cleared and armed again - the output capacitor charged on the offset - +# before it is heard, where it used to play what it held when Mix went to 0, +# up to 11 640 LSB out of silence (audiocomponents#113). +# +# **The clip table is a normalised shape, not volts.** `CURVE` fills Q15 to +# both rails and `CURVE_VOLTS` is what its full scale stands for; the class +# multiplies that into the shaper's `post_gain`. Level and curve are exactly +# what they were, and the table keeps all the resolution it has - it used to +# reach only 35 % of int16 (audiocomponents#77). +# +# **Portability: audiodsp.** `audioshaper.Waveshaper`, `audiobiquad` for the +# 100 Hz tone points Q15 cannot carry, `audioroute` for the dry split. It +# will not import on a stock CircuitPython board. +# +# **Cost.** Palette sum at patch 0, x4, Mix 1: **1.684 / 2.928 ms -> 32 % / +# 55 %** of a 5.333 ms stereo block (dossier section 3). It was 1.584 / 2.759 +# ms until the third fix round added the output pole, which is one more +# `audiobiquad` section on the clip branch - 0.100 ms on the P4 and 0.169 on +# the S3, from the palette table. **No board has measured the graph with the +# pole in it.** Lean patch: none. +# The lean position is the constructor option `oversample=2`, which is +# cheaper on the S3 and **surrenders T7** - at x2 the wet floor reads +# -64.5 dB at the constructor default against a -70 dB bar, and -35.8 dB +# at the worst macro corner against -40 dB. +# +# **There is an output capacitor now.** A Tube Screamer is DC-coupled +# inside and AC-coupled at the jack; this class had the first half only, so +# Symmetry's operating-point offset walked out of the shaper and stood on +# the output for ever - **+4461 LSB at shipped patch 6**, into digital +# silence, at every rate (audit 3 ruling (n)). `DC_BLOCK_HZ` is a 30 Hz +# pole on the clip branch behind the shaper, charged before the first block +# and again whenever Symmetry, Mix or Ceiling moves the offset. Silence in +# is now silence out at **every shipped patch at 48, 44.1 and 22.05 kHz - +# peak 0, not merely zero by the last frame** - and a `program_change` onto +# digital silence stays at 0. It costs two things, both measured: the clip +# branch carries one more int16 node, which is worth **3.4 dB of alias +# floor at the constructor default at 48 kHz** (-81.291 -> -77.939, 1.8 dB +# at 44.1, 0.3 dB at 22.05), and a live macro move that changes the offset +# re-charges the pole, which costs **one block of input** (5.3 ms at +# 48 kHz). A CircuitPython board build, which has no +# `audiocore.get_buffer`, charges it too, and renders byte for byte what a +# desktop does. +# +# **Latency: zero samples** at every macro setting. The dry tap is a copy. +# `oversample` puts group delay on the *clip branch only*: 3.3 samples at +# x4, 3.9 at x8, which is **0.069 ms** at 48 kHz x4, **0.081 ms** at 48 kHz +# x8 and **0.088 ms** at the 44.1 kHz x8 this class ships. x1 is 0. It is +# not on the sixteen-macro surface. Measured integer onset is **0 at every +# rate, both channel counts, on all three interpreters**. `tail_samples` is +# **4096**, over a worst measured ring of **2692 frames** (48 kHz, patch 6, +# a 0 dBFS 1 kHz burst) - the 30 Hz pole is what made it long; it was 512 +# over a 222-frame ring before. The declaration is **level-free**: the ring +# is 2639 to 2692 frames over 100 Hz and 1 kHz bursts at 0 dBFS and shorter +# at every quieter input, because what rings is the pole and not the +# signal. No delay line. +# +# **The input ceiling is -1 dBFS at the shipped patches and -6 dBFS +# anywhere on the surface** (`INPUT_CEILING_DBFS`, `SURFACE_CEILING_DBFS`). +# The class adds a clipped shelf to a dry note at unity, so it runs out of +# int16 somewhere, and until the third fix round it said nowhere. +# +# **The oversampling factor follows the rate: x8 below 48 kHz, x4 at +# 48 kHz and above.** Every doubling is worth 11-16 dB of alias floor, and +# x8 is where the node stops. 48 kHz keeps x4 because x8 there costs +# +0.799 ms a block on the P4 and **+1.408 ms on the S3**, which projects +# the S3 to a real-time factor of about 1.00 - the deadline itself. 44.1 +# and 22.05 kHz pay x8 against blocks 1.09x and 2.18x as long, so it is +# free where the deadline is longer. **Every rate under 24 kHz leaves the +# internal rate under the 192 kHz floor** and takes x8 because x8 is all +# there is: 22.05 kHz runs 176.4 kHz, 16 kHz runs 128 kHz. **No board has +# measured 44.1 or 22.05 kHz**, and the 48 kHz board figures are the only +# ones this class has. +# +# **Alias floors are stated on the wet branch, with the dry copy muted.** +# The dry note is summed on the *circuit* mixer, one stage before the +# output, so a mixed reading flatters every floor by **5.6 to 6.6 dB**. +# `tools/effect_measurements.mute_dry` reaches the inner mixer; every +# number below is that reading (audiocomponents#68, #81). +# +# **Traits that are weaker at the shipped default than at a convenient +# setting, named here.** Patch 0 is mid Drive (plateau ~36), Level 100/127, +# Tone centre. Headline numbers are those defaults (CPython 48 kHz, 1 kHz +# sine unless named): +# +# - T1 at the default, **at -14 dBFS**: 100 Hz THD 1.883 % < 1 kHz +# 12.549 %, and that holds at 44.1 (1.873 / 12.245) and 22.05 kHz (1.853 +# / 11.080). **It is a claim about a sine at Body 720 Hz.** The +# pair reverses at Drive max (20.3 % vs 16.1 %), at the **bottom of the +# Body travel** - Body 360 crosses at all three rates, by less than 0.1 +# percentage points - and on a square wave at the default (58.7 % vs +# 20.0 %), whose own harmonics swamp the 100 Hz leg. **It reverses on a +# sawtooth too**, at all three rates and by a wide margin (86.2 % vs +# 70.5 % at 48 kHz) - and a saw is closer to a pickup than either. Shipped +# patches **2** (Drive max) and **4** (Body 12) reverse it for the first +# two reasons. Small-signal gains are not SPICE's 23.3 / 38.8 / 37.7 dB; +# the fitted -3 dB pole at Drive max is **854 Hz**, outside 720 +/- 110. +# - T2 even lines, **at -14 dBFS**: the trait names h2 **and h4**. At the +# constructor default h2 is -106.608 dB and h4 does not read at all; +# **patch 0 read back is h2 -119.206 dB** - it was -64.5 dB until +# audiocomponents#87 gave a bipolar macro an exact centre at MIDI 64. h3 at Drive max is -16.6 dB, outside +# SPICE -12.6 +/- 2. Shipped patch 6 (Asymmetric) is h2 -25.8 dB with +# **h4 -31.8 dB** beside it - the same character, one more line. +# - T3 Delta-peak at the default is 0.160 / 0.174 / 0.109 V at -26 / -14 / +# -6 dBFS, below the 0.30-0.55 V band; at Drive max it is 0.291 / 0.311 / +# 0.284 V. **Those are the readings behind the output pole**, which takes +# the clip branch's own offset off the peak; they were 0.297 / 0.326 / +# 0.299 at Drive max without it. THD still falls -14 -> -6 dBFS at the default. **Outside that +# stated pair the level law is not monotone**: THD *rises* -20 -> -14 +# dBFS at 48 and 44.1 kHz, turns back up at -1 dBFS at 22.05 kHz, and +# rises with level at 100 Hz. No output rail is hit at any of those +# levels, so the turn is the shaper and not the mixer. +# - T4 is **disconfirmed**: the tone stack is a moving first-order low-pass, +# not the TS wiper network, and the brightest setting is up at 5 kHz where +# SPICE is 3.5 dB down. +# - T5 THD at **-26 dBFS** rises with Drive - 2.346 / 9.631 / 14.630 % at +# Drive MIDI 0 / 64 / 127 - **and that is a claim at Symmetry centred**. +# Off centre the law turns over: at Symmetry MIDI 0, 32, 96 and 127 the +# THD peaks at Drive MIDI 64 and *falls* above it (at Symmetry 0: 8.601 / +# 19.875 / 24.900 / 20.291 / 17.660 % over Drive 0 / 32 / 64 / 96 / 127), +# at all three rates. Symmetry is a bias into the shaper, so it decides +# which part of the curve the drive is pushing into, and the trait row +# never said so. End-to-end plateau gain is **not** 10-15 / 90-130 (Drive +# min/max at -72 dBFS is about x4 / x34). +# - T6 at Drive *minimum* is 8.305 % THD at -14 dBFS, **under** the 10-40 % +# bar; at the default the class is dirtier (12.549 %). +# - T7's bar was **restated on 2026-09-17** under vision section 7.2 and +# narrowed again by the third fix round, which found the redefinition was +# claiming more rates than it had earned. It is now four bars **and a +# rate rule**, all on the wet branch at 1010 Hz, **-6 dBFS unless the row +# says otherwise**: +# **-70 dB at the constructor default** (measured -77.939 / -78.698 / +# -75.565 at 48 / 44.1 / 22.05 kHz), **-50 dB at every shipped patch as +# shipped** (worst patch 3, -56.078 / -71.365 / -54.443), **-40 dB +# anywhere on the macro surface** (worst -47.642 / -61.049 / -46.608, at +# Drive max with Body at the bottom of its travel and Tone open), and +# **-40 dB at that corner at every input level up to full scale** (worst +# -41.956 / -52.157 / **-41.328**, all three at 0 dBFS). A fifth, T7e, +# says what the oversampling is worth: `ALIAS_OVERSAMPLE_COST_DB`. +# - **Where the old -60 dB target still stands: 44.1 kHz.** The +# redefinition's first condition is that the old target is unreachable, +# and that is true at two of this class's three rates, not three. At +# **44.1 kHz** the shipped x8 build meets -60 dB on the wet branch +# everywhere - default -78.698, worst shipped patch -71.365, **worst +# surface cell -61.049** - so the old bar is kept there and tested there, +# and the redefinition is not invoked. At **48 kHz** x4 reads -47.642 at +# that corner and x8 would read **-63.870**; the class declines x8 on +# cost (+0.799 ms a block on the P4, +1.408 on the S3, which projects the +# S3 to rt 1.00) and that projection **has no board behind it** - a board +# run at x8 is what would settle it. At **22.05 kHz** x8 reads -46.608 +# and x16 does not exist: `AUDIODSP_SHAPER_MAX_OVERSAMPLE` is 8 +# (`audiodsp/src/shared/audiodsp_shaper.h:72`), which vision section 6 +# routes to a node ask, and it is filed as **audiodsp#104**. +# - T7's **3700 Hz** diagnostic at full Drive does **not** hold, and the +# figure the pack published for it was a **mixed** reading. On the wet +# branch, with the dry voice muted and a whole number of periods in the +# transform, it is **-33.898 dB** at 48 kHz on T7's held line (Drive max) +# and **-32.065** at the worst macro corner; mixed, the same cell reads +# -37.804, which is where the old -37.7 came from. **5000 Hz is worse and +# was named nowhere**: -27.750 held and -30.726 at the corner at 48 kHz, +# -35.247 / -37.421 at 44.1 kHz, and **-24.117 / -29.684 at 22.05 kHz**, +# which is the worst inharmonic cell this class has anywhere. The tone +# stack and the high-pass sit in front of the clip, so the class is worse +# than the bare node up there. T7's own bars are 1010 Hz numbers and the +# class does not claim them at 3700 or 5000 Hz. +# - **Level is the output stage and Mix does not attenuate the dry.** At +# Mix 1, Level MIDI 0 is silence, not a wire: the one mixer leg carrying +# the whole graph is at zero. + VENDOR = "PyDevices" from array import array @@ -488,7 +493,40 @@ def _one_pole(freq, park): class Overdrive(_component.Component): - """Dry plus a diode-limited high-pass; Tube Screamer structure.""" + """A Tube Screamer-shaped overdrive: your note at unity, with a + high-passed, clipped copy added on top. + + **Controls.** Drive is how hard the copy is clipped, and even at its + floor the pedal is not clean. Tone is a low-pass after the clip. Body is + the corner of the high-pass in front of the clip, so it sets how much + bass reaches it. Ceiling is how tall the clipped copy stands. Symmetry + biases the clip off centre and brings in even harmonics. Level is the + output stage. Mix is how much clipped signal is added to your note; it + does not turn the note down. The Effect controls page lists what the + knobs span. + + **Switching.** Mix at 0 takes the pedal out of circuit and hands back + your signal byte for byte. Leaving 0 switches the pedal on fresh, + rather than replaying what it held when it went off. At Mix 1 with Level + at 0 the pedal is silent. + + **Sound.** At the defaults, bass comes through cleaner than the mids, as + the high-pass in front of the clip makes it; at the bottom of Body, or + on a square, that reverses. With Symmetry centred the + dirt rises with Drive; off centre it peaks partway up the knob. The clip + runs oversampled, so aliasing stays at least 40 dB under the note + anywhere on the panel, up to a full-scale input. + + **Timing.** Latency is zero at every setting. Silence in is silence out + at every shipped patch. + + **Levels.** Keep the input under -1 dBFS and the shipped patches stay + off the rails; anywhere on the panel, keep it under -6 dBFS. + + **On a board.** The class needs audiodsp's `audioshaper`, `audiobiquad` + and `audioroute`, and on a board without them construction raises + `ImportError`. + """ #: Construction arms the voices on silence (`Component._play_voice`), #: so a Mix 0 set before the first pull hands out the source whole. diff --git a/lib/audioeffects/parametriceq.py b/lib/audioeffects/parametriceq.py index 59748c0..0c48587 100644 --- a/lib/audioeffects/parametriceq.py +++ b/lib/audioeffects/parametriceq.py @@ -1,82 +1,75 @@ """`ParametricEQ` - the Pultec EQP-1A's two low networks and its resonant top, with three API 550A proportional-Q bells in the middle. -Rebuilt from scratch for the effects program's Phase 2 against -its dossier. The old `eq.py:ParametricEQ` is not -consulted except for the six defects the dossier's section 7 names. - -**What it sounds like.** The bottom is a Pultec: boost and attenuate at once -and you do not get silence, you get the record trick - a lift under 60 Hz -with a scoop just above it, because the two networks sit on different -corners. The top is a Pultec too: the HF boost is a *resonant bell* whose -`Bandwidth` control makes it both narrower and louder as it sharpens - up to -9 dB louder at the same boost setting - while the HF attenuator is a -separate shelf on its own frequency selector. The three bells in between are -an API 550A: turn one up and the skirt stays where it is while the curve -narrows, and its cut is the exact mirror of its boost. - -**Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). Every section -is an `audiobiquad.Biquad` - float state, and a tail that reaches exact -zero. The ported `synthio.Biquad` cannot: a 20 Hz low shelf at +10 dB parks -on 32 LSB of DC and holds it for ever (audiodsp#23, dossier appendix B), and -this class's own useful settings sit right in that band. - -**Headroom, and what it costs the traits.** Every section writes int16 and -clips there (`audiodsp_filter_f32.c:43-51`), so a boost is delivered whole -only while the signal has room for it. Measured at 1 kHz through the middle -bell at +16 dB, RMS of the rendered tone: **+16.00 dB at -20 dBFS, +13.77 dB -at -12 dBFS, +8.37 dB at -6 dBFS**, while the matching -16 dB cut reads --15.99 dB at every one of them. - -Four of the dossier's five traits are bounded by that, and the evidence pack -carries the level each stops at (its section 1d, twelve stops from -40 to --6 dBFS): - -* **T5**, cut is the exact reciprocal of boost - at G = 16 it holds to - **-16 dBFS** (0.0005 dB) and misses from -15 (0.3734 dB against a 0.1 dB - bar); at G = 12 it holds to -12 dBFS and first misses at -10 (0.9960); at - G = 6 it holds at every level run, down to -6. -* **T3**, bandwidth changes peak gain - holds to **-14 dBFS** (+6.43 dB), - misses from -13 (+5.58 against a 6-12 dB band). -* **T4**, the bells' proportional-Q bandwidth ratio - holds to **-10 dBFS** - (3.074x), misses at -6 (2.231x against a floor of 3x). -* **T2**, the resonant top - its first two clauses hold at every level; the - clause that the cut selector does not move the boost's peak has no reading - at -6 dBFS, because the bell is gone from the full-cut build. -* **T1**, the Pultec low pair, is the one row with no level bound: its three - figures move by 0.003 dB from -40 to -6 dBFS. - -All of it is the clip, not the filter: the coefficients are reciprocal and -proportional at every level, which is what -`tests/test_cpython_effects_parametriceq.py` reads. Drive this EQ from a signal -with headroom for the boost you dial, or trim into it, and the curves are the -ones the traits describe. Keep the input under -12 dBFS and the defaults and -patches stay off the rails. A boost or Output at its top can still push the -output onto the rails. - -**Cost.** Eight sections, always built, and no patch changes that. Measured -2026-09-07 on both boards at construction defaults - where patch 0 is a wire, -so it is a graph idling: **15.2 % of a 256-frame stereo block on the ESP32-P4 -and 25.3 % on the ESP32-S3**, inside the dossier's 26 % and 43 %. At patch 3, -where five sections are live, the desktop digest moves off the bare probe's -(`395946e53d4bca94` against `4169efd90ecf44dd`) and the board figure at that -patch is still owed. - -**Latency: zero samples, at every setting and every rate.** Every section is -a recursive biquad reading no sample it has not been given, and no option on -this class adds a lookahead, a partition or a window, so there is no -latency-adding option to default off. - -`capabilities = ()`: an equaliser has no tempo-dependent behaviour and this -class never reads `self._transport()`. - -The macro units are the panel's own. The EQP-1A's four gain pots and its -bandwidth pot are dials marked 0-10, so that is what macros 1, 2, 9, 11 and -13 read; the API 550A's band gains are switch positions marked in dB, so -macros 4, 6 and 8 read dB. `Low Freq`, `Bell n Freq`, `High Freq` and -`Atten Freq` read hertz. +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:ParametricEQ` is not +# consulted except for the six defects the dossier's section 7 names. +# +# **Portability tier: audiodsp** (`REQUIRES = ("audiobiquad",)`). Every section +# is an `audiobiquad.Biquad` - float state, and a tail that reaches exact +# zero. The ported `synthio.Biquad` cannot: a 20 Hz low shelf at +10 dB parks +# on 32 LSB of DC and holds it for ever (audiodsp#23, dossier appendix B), and +# this class's own useful settings sit right in that band. +# +# **Headroom, and what it costs the traits.** Every section writes int16 and +# clips there (`audiodsp_filter_f32.c:43-51`), so a boost is delivered whole +# only while the signal has room for it. Measured at 1 kHz through the middle +# bell at +16 dB, RMS of the rendered tone: **+16.00 dB at -20 dBFS, +13.77 dB +# at -12 dBFS, +8.37 dB at -6 dBFS**, while the matching -16 dB cut reads +# -15.99 dB at every one of them. +# +# Four of the dossier's five traits are bounded by that, and the evidence pack +# carries the level each stops at (its section 1d, twelve stops from -40 to +# -6 dBFS): +# +# * **T5**, cut is the exact reciprocal of boost - at G = 16 it holds to +# **-16 dBFS** (0.0005 dB) and misses from -15 (0.3734 dB against a 0.1 dB +# bar); at G = 12 it holds to -12 dBFS and first misses at -10 (0.9960); at +# G = 6 it holds at every level run, down to -6. +# * **T3**, bandwidth changes peak gain - holds to **-14 dBFS** (+6.43 dB), +# misses from -13 (+5.58 against a 6-12 dB band). +# * **T4**, the bells' proportional-Q bandwidth ratio - holds to **-10 dBFS** +# (3.074x), misses at -6 (2.231x against a floor of 3x). +# * **T2**, the resonant top - its first two clauses hold at every level; the +# clause that the cut selector does not move the boost's peak has no reading +# at -6 dBFS, because the bell is gone from the full-cut build. +# * **T1**, the Pultec low pair, is the one row with no level bound: its three +# figures move by 0.003 dB from -40 to -6 dBFS. +# +# All of it is the clip, not the filter: the coefficients are reciprocal and +# proportional at every level, which is what +# `tests/test_cpython_effects_parametriceq.py` reads. Drive this EQ from a signal +# with headroom for the boost you dial, or trim into it, and the curves are the +# ones the traits describe. +# +# **Cost.** Eight sections, always built, and no patch changes that. Measured +# 2026-09-07 on both boards at construction defaults - where patch 0 is a wire, +# so it is a graph idling: **15.2 % of a 256-frame stereo block on the ESP32-P4 +# and 25.3 % on the ESP32-S3**, inside the dossier's 26 % and 43 %. At patch 3, +# where five sections are live, the desktop digest moves off the bare probe's +# (`395946e53d4bca94` against `4169efd90ecf44dd`) and the board figure at that +# patch is still owed. +# +# **Latency: zero samples, at every setting and every rate.** Every section is +# a recursive biquad reading no sample it has not been given, and no option on +# this class adds a lookahead, a partition or a window, so there is no +# latency-adding option to default off. +# +# `capabilities = ()`: an equaliser has no tempo-dependent behaviour and this +# class never reads `self._transport()`. +# +# The macro units are the panel's own. The EQP-1A's four gain pots and its +# bandwidth pot are dials marked 0-10, so that is what macros 1, 2, 9, 11 and +# 13 read; the API 550A's band gains are switch positions marked in dB, so +# macros 4, 6 and 8 read dB. `Low Freq`, `Bell n Freq`, `High Freq` and +# `Atten Freq` read hertz. + VENDOR = "PyDevices" import math @@ -197,9 +190,32 @@ def _nearest(value, steps): class ParametricEQ(_component.Component): - """Pultec EQP-1A shelves and a resonant top, API 550A proportional-Q - bells in between. Eight `audiobiquad` sections; audiodsp tier; zero - latency.""" + """A Pultec at the ends and an API in the middle: the EQP-1A's two low + networks and its resonant top, with three proportional-Q bells between. + + **Controls.** Low Freq is the corner for both low networks. Low Boost + and Low Atten work at once: turn both up and you get the record trick, + a lift at the bottom with a scoop just above it. Each bell has a Freq + and a Gain; turn one up and its skirt stays where it is while the curve + narrows, and its cut is the exact mirror of its boost. High Boost is a + resonant bell at High Freq, and Bandwidth sets how sharp it is. High + Atten is a separate shelf at Atten Freq. Q Law switches the bells from + proportional to constant Q. Output is a gain after everything. The + Effect controls page lists what the knobs span. + + **Response.** With every gain at 0 the class is a wire. A boost is + delivered whole only while the signal has room for it; a cut always is. + + **Timing.** Latency is zero at every setting. Silence in settles to + exact silence out. + + **Levels.** Keep the input under -12 dBFS and the defaults and patches + stay off the rails. A boost or Output 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 = 'ParametricEQ' DISPLAY_NAME = 'Parametric EQ' diff --git a/tests/support/kit_plants.py b/tests/support/kit_plants.py index 7dc66f3..445e4dc 100644 --- a/tests/support/kit_plants.py +++ b/tests/support/kit_plants.py @@ -6,8 +6,8 @@ plant some knob reaches is a setting, not a fault: a test that goes red on it would go red on a user turning that knob. -**Declaring plants.** A class's test module (by default -`tests/test_cpython_effects_.py`, name in lower case) declares +**Declaring plants.** A class's test module (the one its `CLAIMS` table is +read from, usually `tests/test_cpython_effects_.py`) declares `KIT_PLANTS = {"T1": PlantedSubclass, ...}`: a label (the trait or test the plant serves) to a subclass of the class. A value may instead be `(PlantedSubclass, patch)`: the plant is then judged against the clean @@ -36,6 +36,7 @@ class at that patch instead of the defaults (a class with two characters sys.path.insert(0, "lib") sys.path.insert(0, "tests/support") +import kit_claims import kit_common as kc import lifecycle @@ -53,17 +54,20 @@ def probe(channels): return _PROBE[channels] -def module_name(cls): - return "test_cpython_effects_%s" % cls.NAME.lower() - - def load_plants(cls, module=None): - """(KIT_PLANTS dict or None, why not).""" - name = module or module_name(cls) + """(KIT_PLANTS dict or None, why not). With no `module`, the class's + test module is the one its CLAIMS table is read from.""" + name = module or "test module for %s" % cls.NAME try: - mod = __import__(name) + if module: + mod = __import__(module) + else: + mod, _ = kit_claims.module_for(cls) except Exception as exc: # noqa: BLE001 return None, "%s does not import: %s" % (name, type(exc).__name__) + if mod is None: + return None, "%s has no test module" % cls.NAME + name = mod.__name__ plants = getattr(mod, "KIT_PLANTS", None) if plants is None: return None, "%s has no KIT_PLANTS" % name diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index 927d163..a3e6710 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -2723,5 +2723,22 @@ def test_every_row_is_red_on_a_wire(self): self.assertTrue(result["control"]["passed"], name) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `DoubleLineN6144`, +#: `JumpAnalogDelay`, `ConstantGlideWalk`, `UnflooredWalk`, `PlainHandOff`, +#: `SpreadInMono`, `TargetOnlyTail`, `NoSwingTail`, `SteppedAnalog`, +#: `PerMacroPatch`; they change only a reported tail or latency: `HalfTail`, +#: `OneLapTail`. +KIT_PLANTS = { + "CornerN6144": CornerN6144, + "MidWalkReadStep": (MidWalkReadStep, 0), + "RightReadStep": (RightReadStep, 0), + "EarlyStepWalk": (EarlyStepWalk, 0), + "LandingBlip": (LandingBlip, 0), + "DryGainDelay": DryGainDelay, + "JumpModAnalog": (JumpModAnalog, 3), +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_autopan.py b/tests/test_cpython_effects_autopan.py index 9d050fc..0b35857 100644 --- a/tests/test_cpython_effects_autopan.py +++ b/tests/test_cpython_effects_autopan.py @@ -672,5 +672,17 @@ def test_every_claim_is_in_the_docstring_and_tested(self): "out.", ("TierOne.test_nothing_here",)),))) +#: Not here, because they change only a reported tail or latency: +#: `ShortLatency`. +KIT_PLANTS = { + "MixerLaw": MixerLaw, + "HalfExponent": HalfExponent, + "BlockHeld": BlockHeld, + "ShallowHardOver": ShallowHardOver, + "SwappedSides": (SwappedSides, 5), + "MonoStillPans": (MonoStillPans, None, {"channels": 1}), +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_bandpass.py b/tests/test_cpython_effects_bandpass.py index 80fd0c8..6dae9ea 100644 --- a/tests/test_cpython_effects_bandpass.py +++ b/tests/test_cpython_effects_bandpass.py @@ -37,6 +37,7 @@ #: tests still import the home module so a planted-fault subclass is #: measured against this file, not only `create()`. import kit_faults as faults # noqa: E402 +import kit_player # noqa: E402 import kit_probes as probes # noqa: E402 from tools import effect_measurements as kit # noqa: E402 @@ -1001,3 +1002,56 @@ def test_the_walk_keeps_the_ring_inside_the_old_bound(self): "BandPass", rate, channels, patch, start=((0, high),), moves=((0, low),), cls=OldRing) self.assertLessEqual(got, bound, (rate, channels, patch)) + + +class ThePlayersText(unittest.TestCase): + def test_construction_needs_audiobiquad(self): + self.assertEqual(bandpass.BandPass.REQUIRES, ("audiobiquad",)) + self.assertEqual(kit_player.missing_module_problems( + bandpass.BandPass, 48000), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, bandpass.__doc__ + " " + bandpass.BandPass.__doc__, + CLAIMS, globals(), "Mix at 0 is your dry signal.") + + +CLAIMS = ( + ("`BandPass` - the two-pole resonant band-pass, rebuilt on " + "`audiobiquad`.", + ("TierTwo.test_t6_matches_the_closed_form_at_the_running_rate", + "ThePlayersText.test_construction_needs_audiobiquad")), + ("Width is how wide the band is, and narrowing it changes what gets " + "through, never how loud the centre is.", + ("TierTwo.test_t2_minus_three_db_edges_are_the_closed_form", + "TierTwo.test_t1_zero_db_peak_where_the_bound_says_it_holds")), + ("Slope on puts a second section behind the first, for steeper skirts " + "at the same width.", + ("TierTwo.test_t2_holds_across_the_cascade_with_the_compensation", + "TierTwo.test_planted_fault_t2_the_cascade_without_its_" + "compensation")), + ("Mix at 0 is your dry signal.", + ("TierOne.test_wire_mix_zero_is_byte_identical_at_every_rate", + "TierOne.test_planted_fault_one_lsb_on_the_dry_path")), + ("**Response.** The centre stands at 0 dB at every width, except a " + "tenth of a decibel low at 31.5 Hz with the band at its narrowest.", + ("TierTwo.test_t1_zero_db_peak_where_the_bound_says_it_holds", + "TierTwo.test_t1_holds_at_the_two_macro_stops", + "TierTwo.test_t1_is_still_disconfirmed_at_31_5_hz_and_q_32")), + ("DC and the top of the band are cut to exact zero.", + ("TierTwo.test_t3_nyquist_alternation_settles_to_zero", + "TierTwo.test_t3_a_held_dc_offset_settles_to_zero_at_every_centre")), + ("**Timing.** Latency is zero at every setting.", + ("TierOne.test_click_matches_the_reported_latency", + "TierOne.test_planted_fault_latency_reported_256_short")), +) + + +#: The state faults differ only after a reset or a deinit, and `OldRing` +#: only in the tail the class reports, so none of them is here. The +#: cascade switches in at patch 1, so its fault is judged there. +KIT_PLANTS = { + "PeakingNumerator": PeakingNumerator, + "WrongWidth": WrongWidth, + "UncompensatedCascade": (UncompensatedCascade, 1), +} diff --git a/tests/test_cpython_effects_bitcrusher.py b/tests/test_cpython_effects_bitcrusher.py index 8490a24..78d95a0 100644 --- a/tests/test_cpython_effects_bitcrusher.py +++ b/tests/test_cpython_effects_bitcrusher.py @@ -1373,3 +1373,16 @@ def test_every_claim_is_in_the_docstring_and_tested(self): "within 0.1 dB", "within 1 dB"))) self.assertTrue(claim_problems( doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) + + +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `NoBranchClear`, +#: `FirstSectionClear`; they change only a reported tail or latency: +#: `LateClick`. +KIT_PLANTS = { + "FloorQuantize": FloorQuantize, + "WrongStep": WrongStep, + "ForcedBandLimit": ForcedBandLimit, + "SnapRate": SnapRate, + "SkewedHold": SkewedHold, +} diff --git a/tests/test_cpython_effects_cabinetsim.py b/tests/test_cpython_effects_cabinetsim.py index 989e26c..fea336f 100644 --- a/tests/test_cpython_effects_cabinetsim.py +++ b/tests/test_cpython_effects_cabinetsim.py @@ -1671,5 +1671,16 @@ def test_every_claim_is_in_the_docstring_and_tested(self): doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) +KIT_PLANTS = { + "ShallowCliffCabinet": ShallowCliffCabinet, + "LowPassCabinet": LowPassCabinet, + "SinglePeakCabinet": SinglePeakCabinet, + "OpenBassCabinet": OpenBassCabinet, + "FlatCharacterCabinet": FlatCharacterCabinet, + "BellOnlyFlatCabinet": (BellOnlyFlatCabinet, 1), + "NoNotchCabinet": (NoNotchCabinet, 1), +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_combfilter.py b/tests/test_cpython_effects_combfilter.py index 7e34d9c..1eaef1e 100644 --- a/tests/test_cpython_effects_combfilter.py +++ b/tests/test_cpython_effects_combfilter.py @@ -1343,3 +1343,19 @@ def test_every_claim_is_in_the_docstring_and_tested(self): if __name__ == "__main__": # pragma: no cover unittest.main() + + +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `NoGlideCombFilter`, +#: `UnresetCombFilter`, `TrackingCombFilter`, `LeakyTrackCombFilter`, +#: `NoTrimTermCombFilter`, `SteppedCombFilter`; they change only a reported +#: tail or latency: `ShortLatencyCombFilter`, `LapShortCombFilter`. +KIT_PLANTS = { + "WholeSampleCombFilter": WholeSampleCombFilter, + "SixCentSharpCombFilter": SixCentSharpCombFilter, + "SquaredFeedbackCombFilter": SquaredFeedbackCombFilter, + "NoUnitySnapCombFilter": (NoUnitySnapCombFilter, 0), + "LeakyWireCombFilter": LeakyWireCombFilter, + "TwoSampleShortCombFilter": TwoSampleShortCombFilter, + "FrozenToneCombFilter": FrozenToneCombFilter, +} diff --git a/tests/test_cpython_effects_compressor.py b/tests/test_cpython_effects_compressor.py index 269c622..bd1541d 100644 --- a/tests/test_cpython_effects_compressor.py +++ b/tests/test_cpython_effects_compressor.py @@ -32,6 +32,7 @@ import audiocore # noqa: E402 import kit_faults as faults # noqa: E402 +import kit_player # noqa: E402 import kit_probes as probes # noqa: E402 import compressor_evidence as ev # noqa: E402 from tools import effect_measurements as kit # noqa: E402 @@ -383,10 +384,6 @@ def test_patch_14_and_back_mid_stream_settles(self): bytes(cout[tail:400 * 1024])) -if __name__ == "__main__": - unittest.main() - - import kit_ceiling # noqa: E402 @@ -397,3 +394,84 @@ def test_clean_at_the_stated_input_level(self): kit_ceiling.stated_level_reds(compressor.Compressor, "square"), []) self.assertTrue(kit_ceiling.stated_level_reds( compressor.Compressor, "square", db=-3, keys=("p3",))) + + +def _burst_then_silence(rate=RATE, frames=4096): + """A loud burst into exact zeros, interleaved stereo.""" + values = array("h", bytes(2 * 2 * frames)) + for frame in range(frames // 2): + value = 20000 if (frame // 24) % 2 else -20000 + values[2 * frame] = values[2 * frame + 1] = value + return values + + +class ThePlayersText(unittest.TestCase): + def silent_frames_that_sound(self, patch, shift=0): + """Samples where the input is zero and the output is not; `shift` + reads the input that many frames ahead, the planted fault.""" + values = _burst_then_silence() + effect = compressor.Compressor.create( + audiocore.RawSample(values, sample_rate=RATE, channel_count=2), + RATE, patch=patch) + try: + out = array("h") + while len(out) < len(values): + data = audiocore.get_buffer(effect.output)[1] + data = memoryview(bytes(data)).cast("h") + if not len(data): + break + out.extend(data) + finally: + effect.deinit() + bad = 0 + for i in range(min(len(values), len(out)) - 2 * shift): + if values[i + 2 * shift] == 0 and out[i] != 0: + bad += 1 + return bad + + def test_silence_in_is_silence_out_on_the_same_sample(self): + for patch in sorted(compressor.Compressor.PATCHES): + with self.subTest(patch=patch): + self.assertEqual(self.silent_frames_that_sound(patch), 0) + # Read the input a frame ahead and the burst's last frame sounds + # against a zero. + self.assertGreater(self.silent_frames_that_sound(0, shift=1), 0) + + def test_latency_is_zero_on_every_patch(self): + self.assertEqual(kit_player.latency_problems( + compressor.Compressor, RATE), []) + self.assertTrue(kit_player.latency_problems( + compressor.Compressor, RATE, delayed=True)) + + def test_construction_needs_audiodynamics_and_audioroute(self): + self.assertEqual(compressor.Compressor.REQUIRES, + ("audiodynamics", "audioroute")) + self.assertEqual(kit_player.missing_module_problems( + compressor.Compressor, RATE), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, compressor.__doc__ + " " + compressor.Compressor.__doc__, + CLAIMS, globals(), "Mix at 0 is your dry signal.") + + +CLAIMS = ( + ("`Compressor` - compressor characters on a shared palette.", + ("TheLeanRouteFollowsTheMacros.test_macros_back_to_patch_0_leave_the_" + "lean_route",)), + ("Mix at 0 is your dry signal.", + ("MixZeroIsAWireTest.test_mix_zero_is_a_wire_at_both_channel_counts", + "MixZeroIsAWireTest.test_the_wire_goes_red_on_a_one_lsb_dry_path")), + ("**Timing.** Latency is zero at every setting.", + ("ThePlayersText.test_latency_is_zero_on_every_patch",)), + ("The gain multiplies the audio, so silence in is silence out on the " + "same sample.", + ("ThePlayersText.test_silence_in_is_silence_out_on_the_same_sample",)), + ("**Levels.** Keep the input under -6 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_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 8a8deb6..746e4ea 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -3545,5 +3545,28 @@ def test_a_host_reset_buffer_silences_the_block_and_keeps_held_frames( self.assertEqual(digest(out), digest(want), label) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `TrimLate`, `TrimRounded`, +#: `NoChecks`, `HotterCeiling`, `ResetOnMove`, `AllocatedSeconds`, +#: `DecayKeptSquared`, `DecayMidiSquared`, `PredelayKeptSquared`, +#: `PredelayMidiSquared`, `NanIsSpanBottom`, `NoReset`, +#: `OneSynthesisPerBlock`, `PatchPerKnob`, `RetryOnEmpty`, `RetryOnShort`, +#: `KeepShortReads`, `ResetSilentOnEmpty`, `MixOnePullLate`, +#: `ReplugOnReset`; they change only a reported tail or latency: +#: `ZeroLatency`, `TailTwoShort`. +KIT_PLANTS = { + "ExtraPartition": ExtraPartition, + "OneLsbAfter": OneLsbAfter, + "StuckDcAfter": StuckDcAfter, + "UnnormalisedAfter": UnnormalisedAfter, + "HotRoom": HotRoom, + "SideTilt": SideTilt, + "SideNudge": SideNudge, + "Untrimmed": Untrimmed, + "OldTrims": OldTrims, + "LongDecay": LongDecay, +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_distortion.py b/tests/test_cpython_effects_distortion.py index c3b29b4..9afba2b 100644 --- a/tests/test_cpython_effects_distortion.py +++ b/tests/test_cpython_effects_distortion.py @@ -25,6 +25,8 @@ from tools import effect_measurements as kit # noqa: E402 import kit_faults as faults # noqa: E402 import kit_probes # noqa: E402 +import kit_ceiling # noqa: E402 +import kit_player # noqa: E402 VENDOR = "PyDevices" @@ -1664,8 +1666,112 @@ def test_a_reset_that_leaves_the_drive_up_is_red(self): self.BOUND, channels) -if __name__ == "__main__": - unittest.main() +class ThePlayersText(unittest.TestCase): + def test_construction_needs_its_three_modules(self): + self.assertEqual(Distortion.REQUIRES, + ("audioshaper", "audiobiquad", "audioroute")) + self.assertEqual( + kit_player.missing_module_problems(Distortion, RATE), []) + + def test_a_full_scale_input_does_not_wrap(self): + # The class states no ceiling, so the kit plays full scale. There is + # no step above it to show red; the clip's own headroom is planted + # in `test_the_clipper_output_stays_inside_the_node`. + for material in ("square", "sine"): + self.assertEqual( + kit_ceiling.stated_level_reds(Distortion, material), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, rebuilt.__doc__ + " " + Distortion.__doc__, CLAIMS, + globals(), "Latency is 2 samples at Mix 1 and zero at any Mix " + "below it.") + + +CLAIMS = ( + ("Boost is a booster in front of the clip, and `scoop` starts with it at " + "35 dB because the Boss has one in its circuit.", + ("TestDistortionInvariants.test_scoop_starts_with_its_booster_in_" + "circuit", + "TestDistortionTraits.test_sc3_never_clean_on_scoop_and_null_build")), + ("**Switching.** Mix at 0 hands back your signal untouched.", + ("TestDistortionInvariants.test_mix_zero_is_a_wire",)), + ("Leaving 0 switches the pedal on fresh, rather than replaying what it " + "held when it went off.", + ("TheBypassComesBackAsBuilt.test_mix_back_after_silence_plays_nothing", + "TheBypassComesBackAsBuilt.test_at_every_patch", + "TheBypassComesBackAsBuilt.test_the_old_rejoin_and_a_clear_without_" + "rearming_are_red")), + ("**Sound.** At Distortion 0 the Rat is nearly clean and the Boss never " + "is.", + ("TestDistortionTraits.test_f4_distortion_is_the_only_drive_and_null_" + "build", + "TestDistortionTraits.test_sc3_never_clean_on_scoop_and_null_build")), + ("On `scoop` every Filter position is its own, and Filter at 0 is the " + "stock Boss.", + ("TestDistortionTheScoopFilterMacro.test_every_position_is_its_own_" + "section", + "TestDistortionTheScoopFilterMacro.test_filter_zero_is_the_stock_ds1_" + "corner")), + ("At the defaults, aliasing on a 1010 Hz tone between -22 and -16 dBFS " + "stays 60 dB under the note; louder than that it does not.", + ("TestDistortionTraits.test_a1_alias_floor_at_the_shipped_default", + "TestDistortionA1AcrossItsLevelSpan.test_a1_holds_across_the_span_it_" + "names", + "TestDistortionA1AcrossItsLevelSpan.test_a1_does_not_hold_outside_it_" + "and_the_row_says_so")), + ("**Timing.** Latency is 2 samples at Mix 1 and zero at any Mix below " + "it.", + ("TestDistortionInvariants.test_latency_matches_click_at_48k_and_44k1", + "TestDistortionInvariants.test_latency_is_true_at_every_mix", + "TestDistortionInvariants.test_click_is_green_on_every_patch_now")), + ("Silence in is silence out at every shipped patch, and the tail settles " + "inside what the class reports.", + ("TheTierOneRowsAtEveryShippedPatch.test_silence_in_is_silence_out_at_" + "every_shipped_patch", + "TheTierOneRowsAtEveryShippedPatch.test_the_declared_tail_holds_at_" + "every_shipped_patch", + "TestDistortionInvariants.test_silence_tail_reaches_zero")), + ("**Levels.** A full-scale input plays without wrapping at every shipped " + "patch.", + ("ThePlayersText.test_a_full_scale_input_does_not_wrap",)), + ("The clip keeps its output inside its own headroom at any Ceiling, so " + "Ceiling stops raising the level before the top of its travel, and " + "with Ceiling high, Volume and Mix stop moving the output above a knee " + "that `wet_ceiling()` reports.", + ("TestDistortionTraits.test_the_clipper_output_stays_inside_the_node", + "TestDistortionVolumeAndMixTravel.test_volume_is_monotone_below_the_" + "knee", + "TestDistortionVolumeAndMixTravel.test_above_the_knee_volume_is_" + "declared_inoperative_and_is", + "TestDistortionVolumeAndMixTravel.test_the_shipped_ceiling_has_no_knee", + "TestDistortionVolumeAndMixTravel.test_mix_is_a_crossfade_below_the_" + "knee")), + ("No shipped patch sits on a DC offset, and the first note does not " + "arrive on one.", + ("TestDistortionInvariants.test_scoop_patches_do_not_sit_on_dc", + "TestDistortionTheCouplingCapacitorUnderMaterial.test_the_first_note_" + "does_not_arrive_on_an_offset", + "TestDistortionTheCouplingCapacitorUnderMaterial.test_the_bias_model_" + "is_what_fails_it")), +) + + +#: `DriveLeftUpAfterReset` is not here: it differs only after a reset, and +#: the kit renders fresh builds, so it reads the same as the clean class. +KIT_PLANTS = { + "SwapLegs": SwapLegs, + "NoGBW": NoGBW, + "LinearClipper": LinearClipper, + "NoDrive": NoDrive, + "AlwaysDriven": AlwaysDriven, + "SecondOrderFilter": SecondOrderFilter, + "BaseRateShaper": BaseRateShaper, + "NoOutputCap": (NoOutputCap, 6), + "BiasedScoop": (BiasedScoop, 6), + "BiasedScoopWithCap": (BiasedScoopWithCap, 6), + "NoBoost": (NoBoost, 6), +} class _NoHiding: @@ -1825,3 +1931,7 @@ def test_a_patch_moved_to_before_the_first_pull_is_a_fresh_build(self): with self.subTest(rate=rate, channels=channels, patch=patch): self.assertEqual(got, want) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_dynamiceq.py b/tests/test_cpython_effects_dynamiceq.py index 36b9a5c..b3b83c9 100644 --- a/tests/test_cpython_effects_dynamiceq.py +++ b/tests/test_cpython_effects_dynamiceq.py @@ -1104,10 +1104,6 @@ def test_the_walk_keeps_the_ring_inside_the_old_bound(self): self.assertLessEqual(got, bound, (rate, channels, patch)) -if __name__ == '__main__': - unittest.main() - - class TheClaims(unittest.TestCase): """The docstring's claims that no older test holds on its own.""" @@ -1225,3 +1221,14 @@ def test_every_claim_is_in_the_docstring_and_tested(self): "within 0.35 dB", "within 0.3 dB"))) self.assertTrue(claim_problems( doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) + + +#: Not here, because they change only a reported tail or latency: +#: `OldPeriodsTail`. +KIT_PLANTS = { + "ThroughTheDryVoice": (ThroughTheDryVoice, 0), +} + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_cpython_effects_exciter.py b/tests/test_cpython_effects_exciter.py index b25d5c2..4f199a2 100644 --- a/tests/test_cpython_effects_exciter.py +++ b/tests/test_cpython_effects_exciter.py @@ -20,6 +20,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import kit_faults # noqa: E402 +import kit_player # noqa: E402 import kit_probes as probes # noqa: E402 from audioeffects import exciter as rebuilt # noqa: E402 from tools import effect_measurements as kit # noqa: E402 @@ -1050,7 +1051,7 @@ def test_t7_holds_up_to_the_stated_input_ceiling(self): """And not above it. The row is a -6 dBFS reading and the class rails at -2, so its level clause ends where the ceiling does.""" for dbfs in (-6.0, -3.0): - worst, _tune = self.t7_at(RATE, 127) + worst, _tune = self.t7_at(RATE, 127, dbfs=dbfs) self.assertLess(worst, -60.0, dbfs) for dbfs in (-1.0, 0.0): worst, _tune = self.t7_at(RATE, 127, dbfs=dbfs) @@ -1484,5 +1485,100 @@ def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): 1000) +class ThePlayersText(unittest.TestCase): + def test_latency_is_zero_at_every_patch(self): + self.assertEqual(kit_player.latency_problems(Exciter, RATE), []) + self.assertTrue(kit_player.latency_problems(Exciter, RATE, + delayed=True)) + + def test_construction_needs_its_four_modules(self): + self.assertEqual(Exciter.REQUIRES, ("audioshaper", "audiobiquad", + "audioroute", "audiodynamics")) + self.assertEqual(kit_player.missing_module_problems(Exciter, RATE), + []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, rebuilt.__doc__ + " " + Exciter.__doc__, CLAIMS, globals(), + "At full Mix the copy stays under the note.") + + +CLAIMS = ( + ("Harmonics drives the copy into the clip, from 0 to +14 dB.", + ("TestExciter.test_harmonics_span_is_fourteen_db",)), + ("Character picks `classic`, a fixed clip, or `transient`, which drives " + "the copy 6 dB harder for the first 8 ms of each onset.", + ("TestExciter.test_transient_onset_gain_is_short_and_declared", + "TestExciter.test_t5_classic_default_has_no_onset_emphasis", + "TestExciter.test_classic_does_not_build_a_dynamics_node")), + ("Output trims the whole effect, the note and the copy together, so the " + "balance between them does not move with it, and it only turns down.", + ("TestExciter.test_output_attenuates_the_whole_effect", + "TestExciter.test_t4_wet_dry_does_not_move_with_output", + "TestExciter.test_output_span_has_no_makeup_above_unity")), + ("**Switching.** Mix at 0, with Output at 0 dB, hands back your signal " + "untouched.", + ("TestExciter.test_mix_zero_is_wire",)), + ("Bringing Mix back up switches the copy on fresh, rather than replaying " + "what it held when it went off.", + ("TheBypassComesBackAsBuilt.test_mix_back_after_silence_plays_nothing", + "TheBypassComesBackAsBuilt.test_at_every_patch", + "TheBypassComesBackAsBuilt.test_the_old_rejoin_and_a_clear_without_" + "rearming_are_red")), + ("**Sound.** The copy's high-pass is second order at every rate, because " + "Tune is held under a seventh of the sample rate: at 22.05 kHz it tops " + "out at 3150 Hz.", + ("TestExciter.test_t1_second_order_at_default_tune", + "TestExciter.test_t1_fault_first_order_at_default", + "TestExciter.test_t1_holds_at_every_rate_because_tune_is_clamped")), + ("The clip is one-sided, so the copy carries more second harmonic than " + "third, and its second harmonic rises with level.", + ("TestExciter.test_t2_asymmetry_is_h2_over_h3_at_default", + "TestExciter.test_t2_fault_odd_curve_kills_h2_at_default", + "TestExciter.test_t3_h2_rises_with_level")), + ("At full Mix the copy stays under the note.", + ("TestExciter.test_t4_mix_max_under_dry", + "TestExciter.test_t4_fault_wet_over_dry_fires_at_every_output", + "TestExciter.test_t4_holds_on_transient_once_the_onset_has_passed")), + ("The clip runs oversampled, so at 48 kHz aliasing stays 60 dB under the " + "copy at the defaults and with Harmonics at the top, up to the input " + "ceiling.", + ("TestExciter.test_t7_alias_at_default_and_full_drive", + "TestExciter.test_t7_fault_oversample_1_at_default", + "TheThirdFixRoundsRows.test_t7_holds_up_to_the_stated_input_ceiling")), + ("**Timing.** Latency is zero at every setting.", + ("TestExciter.test_latency_zero_click", + "ThePlayersText.test_latency_is_zero_at_every_patch")), + ("Silence in is silence out at every shipped patch, and the tail falls to " + "zero within 4096 frames.", + ("TheTierOneRowsAtEveryShippedPatch.test_silence_in_is_silence_out_at_" + "every_shipped_patch", + "TheTierOneRowsAtEveryShippedPatch.test_the_declared_tail_holds_at_" + "every_shipped_patch", + "TestExciter.test_tail_returns_to_zero_at_default", + "TestExciter.test_tail_fault_held_dc_at_default")), + ("**Levels.** Keep the input under -3 dBFS and nothing rails, on any " + "shipped patch or with Harmonics and Mix at the top; at -2 dBFS it can.", + ("TheThirdFixRoundsRows.test_nothing_rails_at_the_stated_ceiling", + "TheThirdFixRoundsRows.test_above_the_ceiling_the_rail_is_reachable")), + ("No shipped patch leaves a DC offset on its output while it plays.", + ("TheThirdFixRoundsRows.test_no_shipped_patch_stands_dc_on_its_own_tone", + "TheThirdFixRoundsRows.test_the_hot_corner_does_not_either", + "TheThirdFixRoundsRows.test_the_planted_class_is_the_one_that_does")), +) + + +#: Faults built on the whole class. The `WetExciter` plants are measured on +#: a wet-only harness that no knob reaches, so the kit would be judging the +#: harness rather than the fault; `IdentityCurve` and `WireShaper` are +#: controls. +KIT_PLANTS = { + "DryHalf": DryHalf, + "WetOverDry": WetOverDry, + "NoOutputPole": NoOutputPole, + "ClassicLocked": (ClassicLocked, 1), +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_expander.py b/tests/test_cpython_effects_expander.py index eb16b4f..46e468d 100644 --- a/tests/test_cpython_effects_expander.py +++ b/tests/test_cpython_effects_expander.py @@ -603,5 +603,14 @@ def test_every_claim_is_in_the_docstring_and_tested(self): doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `ZeroTailKeyListenExpander`; they +#: render what a knob or a patch renders, so the kit cannot tell them from a +#: setting: `DepthSignFlippedExpander`, `DepthPinnedAtZeroExpander`. +KIT_PLANTS = { + "RatioPinnedExpander": RatioPinnedExpander, +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_fuzz.py b/tests/test_cpython_effects_fuzz.py index f4eb30c..37535f9 100644 --- a/tests/test_cpython_effects_fuzz.py +++ b/tests/test_cpython_effects_fuzz.py @@ -2529,5 +2529,21 @@ def test_every_claim_is_in_the_docstring_and_tested(self): doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `NoOutputPole`; they change only +#: a reported tail or latency: `ShortLatencyFuzz`; they do not build on +#: their own: `DryTapRebalance`. +KIT_PLANTS = { + "SymmetricFuzz": SymmetricFuzz, + "NoOversampleFuzz": NoOversampleFuzz, + "AlwaysTiltFuzz": AlwaysTiltFuzz, + "HighHpFuzz": HighHpFuzz, + "GermaniumCascade": GermaniumCascade, + "NoScoopFuzz": NoScoopFuzz, + "FrozenToneFuzz": FrozenToneFuzz, + "NoOutputCharge": (NoOutputCharge, 2), +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_graphiceq.py b/tests/test_cpython_effects_graphiceq.py index 3677cd7..1020690 100644 --- a/tests/test_cpython_effects_graphiceq.py +++ b/tests/test_cpython_effects_graphiceq.py @@ -1180,10 +1180,6 @@ def test_every_claim_is_in_the_docstring_and_tested(self): doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) -if __name__ == "__main__": - unittest.main() - - class TheTailAfterAMove(unittest.TestCase): """A Gain or Volume move from the top to the bottom as the input stops rings out inside the bound read after the move (audiocomponents#114). @@ -1202,3 +1198,17 @@ def test_the_ring_ends_inside_the_bound_and_the_old_one_now_holds(self): self.assertLessEqual(got, bound, index) # Red up to audiodsp rc8, where the move rang past it. self.assertLessEqual(got, int(22050 * 0.47), index) + + +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `NoWakeGraphicEQ`, +#: `AlreadyMutedGraphicEQ`; they change only a reported tail or latency: +#: `NoDetentGraphicEQ`; they render what a knob or a patch renders, so the +#: kit cannot tell them from a setting: `RailedBandGraphicEQ`. +KIT_PLANTS = { + "InvertedQGraphicEQ": (InvertedQGraphicEQ, 1), +} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_lowpass.py b/tests/test_cpython_effects_lowpass.py index 764d972..e2f64c3 100644 --- a/tests/test_cpython_effects_lowpass.py +++ b/tests/test_cpython_effects_lowpass.py @@ -692,10 +692,6 @@ def test_clean_at_the_stated_input_level(self): lowpass.LowPass, "sine", db=0, keys=("p5",))) -if __name__ == "__main__": - 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 @@ -801,3 +797,14 @@ def test_the_walk_keeps_the_ring_inside_the_old_constant(self): "LowPass", rate, channels, patch, start=((0, high),), moves=((0, low),), cls=OldConstantTail) self.assertLessEqual(got, bound, (rate, channels, patch)) + + +#: `OldConstantTail` differs only in the tail the class reports, so it is +#: not here. +KIT_PLANTS = { + "MostlyDryLowPass": MostlyDryLowPass, +} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_multiband.py b/tests/test_cpython_effects_multiband.py index 7ededd7..529bb15 100644 --- a/tests/test_cpython_effects_multiband.py +++ b/tests/test_cpython_effects_multiband.py @@ -1104,5 +1104,17 @@ def test_every_claim_is_in_the_docstring_and_tested(self): doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `NoGuard`, `ThroughTheDryVoice`, +#: `MixerResetTailFirst`. +KIT_PLANTS = { + "NoLevelGates": (NoLevelGates, 0), + "LinkwitzRiley2": LinkwitzRiley2, + "NoRatioClamp": (NoRatioClamp, 0), + "PlayBeforeMacros": PlayBeforeMacros, + "PortedBiquads": PortedBiquads, +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 7426039..3a41b28 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -3317,5 +3317,27 @@ def test_a_reset_alone_is_a_fresh_instance(self): (patch, ch, k)) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `PrimingReset`, `StaleReset`, +#: `LateReset`, `MixerTail`, `PrimingPlug`, `PullingResync`, `NoRateFloor`, +#: `NoClear`, `SteppedLaps`, `BareAdapter`; they change only a reported tail +#: or latency: `NoTapLap`; they render what a knob or a patch renders, so +#: the kit cannot tell them from a setting: `RotatedTable`. +KIT_PLANTS = { + "LateHeads": LateHeads, + "LongLap": LongLap, + "ShortTapLap": ShortTapLap, + "NarrowTime": (NarrowTime, 0), + "FilteredHead": FilteredHead, + "FrontFilter": FrontFilter, + "PrimedWire": (PrimedWire, 0), + "PannedLaps": PannedLaps, + "CrossedLaps": CrossedLaps, + "LapMixOne": LapMixOne, + "ToneHalf": ToneHalf, + "Head2OwnLoop": Head2OwnLoop, +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_notch.py b/tests/test_cpython_effects_notch.py index 7c7510e..3590b05 100644 --- a/tests/test_cpython_effects_notch.py +++ b/tests/test_cpython_effects_notch.py @@ -32,6 +32,7 @@ class actually built and not a re-derivation of RBJ standing beside it. The sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) import kit_ceiling # noqa: E402 +import kit_player # noqa: E402 import kit_faults as faults # noqa: E402 from effects_measure import SAMPLE_RATE # noqa: E402 @@ -583,8 +584,65 @@ def test_clean_at_the_stated_input_level(self): notch_module.Notch, "square", db=-3, keys=("p1",))) -if __name__ == "__main__": - unittest.main() +class ThePlayersText(unittest.TestCase): + def depth(self, f0, q): + effect = built(f0=f0, q=q) + try: + return response_db(effect, f0) + finally: + effect.deinit() + + def test_a_low_centre_is_shallower(self): + # A true null in the musical range, a hum reducer at the bottom. + self.assertLess(self.depth(1000.0, 12.0), -70.0) + low = self.depth(60.0, 12.0) + self.assertGreater(low, -50.0) + self.assertLess(low, -30.0) + # The checker can fail: the low centre misses the musical bar. + self.assertFalse(low < -70.0) + + def test_latency_is_zero_on_every_patch(self): + self.assertEqual(kit_player.latency_problems( + notch_module.Notch, SAMPLE_RATE), []) + self.assertTrue(kit_player.latency_problems( + notch_module.Notch, SAMPLE_RATE, delayed=True)) + + def test_construction_needs_audiobiquad(self): + self.assertEqual(notch_module.Notch.REQUIRES, ("audiobiquad",)) + self.assertEqual(kit_player.missing_module_problems( + notch_module.Notch, SAMPLE_RATE), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, notch_module.__doc__ + " " + notch_module.Notch.__doc__, + CLAIMS, globals(), + "Depth is a blend, and at 0 the class is a wire.") + + +CLAIMS = ( + ("`Notch` - the two-pole band-stop: everything except one band, " + "tuned.", + ("TheWidthIsTheBandwidth.test_the_predicted_edges_read_three_" + "decibels_down", + "TheClassIsUnityOutsideItsBand.test_dc_and_nyquist_pass_untouched")), + ("A notch: one band taken out, and the rest of the signal left alone.", + ("TheClassIsUnityOutsideItsBand.test_dc_and_nyquist_pass_untouched", + "TheSurface.test_every_patch_notches_where_its_comment_says")), + ("Depth is a blend, and at 0 the class is a wire.", + ("TheBypassIsAWire.test_depth_zero_is_byte_identical_to_the_source", + "TheBypassIsAWire.test_one_lsb_on_the_dry_path_is_red")), + ("**Response.** DC and the top of the band pass untouched.", + ("TheClassIsUnityOutsideItsBand.test_dc_and_nyquist_pass_untouched", + "TheClassIsUnityOutsideItsBand.test_a_band_pass_numerator_is_red")), + ("At low centres the notch is shallower: there it reduces a hum rather " + "than removing it.", + ("ThePlayersText.test_a_low_centre_is_shallower",)), + ("**Timing.** Latency is zero at every setting.", + ("ThePlayersText.test_latency_is_zero_on_every_patch",)), + ("**Levels.** Keep the input under -6 dBFS and the defaults and " + "patches stay off the rails.", + ("TheCeiling.test_clean_at_the_stated_input_level",)), +) class OldConstantTail(Notch): @@ -629,3 +687,19 @@ def test_the_walk_keeps_the_ring_inside_the_old_constant(self): "Notch", rate, channels, patch, start=((0, high),), moves=((0, low),), cls=OldConstantTail) self.assertLessEqual(got, bound, (rate, channels, patch)) + + +#: `ShortTail` and `OldConstantTail` differ only in the tail the class +#: reports, which is bookkeeping, so they are not here. The harmonic notch +#: is on at patch 1, so `SharedHarmonicQ` is judged there. `RateBlind` is the +#: shipped class at 48 kHz, so it is judged at 22.05 kHz. +KIT_PLANTS = { + "WidenedQ": WidenedQ, + "BandPassNumerator": BandPassNumerator, + "SharedHarmonicQ": (SharedHarmonicQ, 1), + "RateBlind": (RateBlind, None, {"rate": 22050}), +} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_effects_overdrive.py b/tests/test_cpython_effects_overdrive.py index 6f2be4e..f45aabc 100644 --- a/tests/test_cpython_effects_overdrive.py +++ b/tests/test_cpython_effects_overdrive.py @@ -17,6 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import kit_faults # noqa: E402 +import kit_player # noqa: E402 import kit_probes as probes # noqa: E402 from audioeffects import overdrive as rebuilt # noqa: E402 from tools import effect_measurements as kit # noqa: E402 @@ -1307,5 +1308,77 @@ def test_a_second_charge_is_red(self): self.assertTrue(any(c[0] == 22050 for c in reds), reds) +#: `DoubleChargeOverdrive` is not here: it differs only on a rejoin, and the +#: kit renders fresh builds. +KIT_PLANTS = { + "BiasedOverdrive": BiasedOverdrive, + "BaseRateOverdrive": BaseRateOverdrive, + "RawClipOverdrive": RawClipOverdrive, + "NoOutputPole": NoOutputPole, + "WetOnlyOverdrive": WetOnlyOverdrive, + "NoClipAdd": NoClipAdd, + "NoHighPass": NoHighPass, +} + + +class ThePlayersText(unittest.TestCase): + def test_construction_needs_its_three_modules(self): + self.assertEqual(rebuilt.Overdrive.REQUIRES, + ("audioshaper", "audiobiquad", "audioroute")) + self.assertEqual(kit_player.missing_module_problems( + rebuilt.Overdrive, 48000), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, rebuilt.__doc__ + " " + rebuilt.Overdrive.__doc__, + CLAIMS, globals(), + "Silence in is silence out at every shipped patch.") + + +CLAIMS = ( + ("**Switching.** Mix at 0 takes the pedal out of circuit and hands back " + "your signal byte for byte.", + ("TestOverdrive.test_mix_zero_is_wire", + "TestOverdrive.test_level_honest_on_mix_zero")), + ("Leaving 0 switches the pedal on fresh, rather than replaying what it " + "held when it went off.", + ("TheBypassComesBackAsBuilt.test_mix_back_after_silence_plays_nothing", + "TheBypassComesBackAsBuilt.test_the_old_rejoin_and_a_clear_without_" + "rearming_are_red", + "TheRejoinIsAFreshInstance.test_mix_back_at_patch_6_is_a_fresh_" + "build")), + ("At Mix 1 with Level at 0 the pedal is silent.", + ("TestOverdriveDisclosure.test_level_zero_at_mix_one_is_silence",)), + ("**Sound.** At the defaults, bass comes through cleaner than the mids, " + "as the high-pass in front of the clip makes it; at the bottom of " + "Body, or on a square, that reverses.", + ("TestOverdriveT1Span.test_pair_holds_at_the_default_at_every_rate", + "TestOverdriveT1Span.test_pair_reverses_at_the_bottom_of_the_body_" + "travel", + "TestOverdriveT1Span.test_pair_reverses_on_a_square_at_the_default", + "TestOverdriveT1Fault.test_hp_off_raises_100hz_thd_at_default")), + ("The clip runs oversampled, so aliasing stays at least 40 dB under the " + "note anywhere on the panel, up to a full-scale input.", + ("TestOverdrive.test_t7_alias_at_the_worst_macro_corner", + "TestOverdrive.test_t7_at_the_worst_corner_up_to_full_scale", + "TestOverdriveRatePolicy.test_t7_at_drive_max_at_every_rate", + "TestOverdrive.test_t7_null_build_red")), + ("**Timing.** Latency is zero at every setting.", + ("TestOverdrive.test_latency_zero_click",)), + ("Silence in is silence out at every shipped patch.", + ("TheTierOneRowsAtEveryShippedPatch.test_silence_in_is_silence_out_at_" + "every_shipped_patch", + "TestOverdriveTheOutputPole.test_the_planted_class_stands_dc_where_" + "this_one_does_not")), + ("**Levels.** Keep the input under -1 dBFS and the shipped patches stay " + "off the rails; anywhere on the panel, keep it under -6 dBFS.", + ("TestOverdriveTheInputCeiling.test_no_shipped_patch_rails_at_the_" + "stated_ceiling", + "TestOverdriveTheInputCeiling.test_the_ceiling_is_where_it_says_it_is", + "TestOverdriveTheInputCeiling.test_the_surface_ceiling_holds_at_the_" + "hot_corner")), +) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_parametriceq.py b/tests/test_cpython_effects_parametriceq.py index b1c88c1..1758bbb 100644 --- a/tests/test_cpython_effects_parametriceq.py +++ b/tests/test_cpython_effects_parametriceq.py @@ -28,6 +28,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) import effects_measure # noqa: E402 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 @@ -466,5 +467,66 @@ def test_clean_at_the_stated_input_level(self): parametriceq.ParametricEQ, "dc", db=-9, keys=("p3",))) +class ThePlayersText(unittest.TestCase): + def test_latency_is_zero_on_every_patch(self): + self.assertEqual(kit_player.latency_problems( + parametriceq.ParametricEQ, SAMPLE_RATE), []) + self.assertTrue(kit_player.latency_problems( + parametriceq.ParametricEQ, SAMPLE_RATE, delayed=True)) + + def test_construction_needs_audiobiquad(self): + self.assertEqual(parametriceq.ParametricEQ.REQUIRES, ("audiobiquad",)) + self.assertEqual(kit_player.missing_module_problems( + parametriceq.ParametricEQ, SAMPLE_RATE), []) + + def test_every_claim_is_in_the_docstring_and_tested(self): + kit_player.check_claims( + self, parametriceq.__doc__ + " " + + parametriceq.ParametricEQ.__doc__, CLAIMS, globals(), + "With every gain at 0 the class is a wire.") + + +CLAIMS = ( + ("`ParametricEQ` - the Pultec EQP-1A's two low networks and its " + "resonant top, with three API 550A proportional-Q bells in the " + "middle.", + ("LowNetworksTest.test_boost_and_atten_together_lift_the_bottom_and_" + "scoop_above_it", + "BellLawTest.test_the_skirt_stays_put_while_the_curve_narrows")), + ("A Pultec at the ends and an API in the middle: the EQP-1A's two low " + "networks and its resonant top, with three proportional-Q bells " + "between.", + ("LowNetworksTest.test_boost_and_atten_together_lift_the_bottom_and_" + "scoop_above_it", + "BellLawTest.test_the_skirt_stays_put_while_the_curve_narrows")), + ("Low Boost and Low Atten work at once: turn both up and you get the " + "record trick, a lift at the bottom with a scoop just above it.", + ("LowNetworksTest.test_boost_and_atten_together_lift_the_bottom_and_" + "scoop_above_it", + "LowNetworksTest.test_planted_fault_one_shared_corner_cancels")), + ("Each bell has a Freq and a Gain; turn one up and its skirt stays " + "where it is while the curve narrows, and its cut is the exact mirror " + "of its boost.", + ("BellLawTest.test_the_skirt_stays_put_while_the_curve_narrows", + "BellLawTest.test_planted_fault_constant_q_moves_the_skirt", + "BellLawTest.test_cut_is_the_exact_reciprocal_of_boost")), + ("**Response.** With every gain at 0 the class is a wire.", + ("WireTest.test_patch_zero_is_byte_identical_to_the_source", + "WireTest.test_an_exactly_zero_db_section_is_a_wire_at_unity_mix_" + "too")), + ("A boost is delivered whole only while the signal has room for it; a " + "cut always is.", + ("HeadroomTest.test_the_boost_arrives_whole_at_the_level_the_traits_" + "were_measured", + "HeadroomTest.test_a_hot_probe_clips_the_boost_away_and_leaves_the_" + "cut_alone")), + ("**Timing.** Latency is zero at every setting.", + ("ThePlayersText.test_latency_is_zero_on_every_patch",)), + ("**Levels.** Keep the input under -12 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_phaser.py b/tests/test_cpython_effects_phaser.py index f02910a..45042ae 100644 --- a/tests/test_cpython_effects_phaser.py +++ b/tests/test_cpython_effects_phaser.py @@ -1016,3 +1016,16 @@ def test_every_claim_is_in_the_docstring_and_tested(self): "shallower than"))) self.assertTrue(claim_problems( doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) + + +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `MeterDeaf`; they change only a +#: reported tail or latency: `ShortLatency`; they render what a knob or a +#: patch renders, so the kit cannot tell them from a setting: +#: `MixOneCascade`, `NoDriveLaw`, `FeedbackStuck`. +KIT_PLANTS = { + "SixStagesAlways": SixStagesAlways, + "OldDriveTable": OldDriveTable, + "NoPostGain": NoPostGain, + "ReplaysTheSource": ReplaysTheSource, +} diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py index ce61078..dfb591a 100644 --- a/tests/test_cpython_effects_reverb.py +++ b/tests/test_cpython_effects_reverb.py @@ -2013,5 +2013,21 @@ def test_the_family_limits_are_word_for_word(self): ) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `RebuildOnMove`, +#: `ResetDropsHeldFrames`, `ResetClearsNothing`, `NoCeiling`. +KIT_PLANTS = { + "DattorroDiffuserPlate": DattorroDiffuserPlate, + "InvertedCut": InvertedCut, + "FlatDamper": FlatDamper, + "LowCutRadians": LowCutRadians, + "PlateDiffuserRooms": (PlateDiffuserRooms, 4), + "HallOnRoomLines": (HallOnRoomLines, 6), + "RateReciprocal": RateReciprocal, + "DepthMirrored": DepthMirrored, + "OneMultiply": OneMultiply, +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_saturation.py b/tests/test_cpython_effects_saturation.py index e081b18..47c39fe 100644 --- a/tests/test_cpython_effects_saturation.py +++ b/tests/test_cpython_effects_saturation.py @@ -2254,5 +2254,18 @@ def test_every_claim_is_in_the_docstring_and_tested(self): doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `FixedLoss`; they change only a +#: reported tail or latency: `ShortLatency`. +KIT_PLANTS = { + "OddTube": OddTube, + "HardClip": HardClip, + "RoughCurve": RoughCurve, + "NoOversample": NoOversample, + "NodeBiasSaturation": (NodeBiasSaturation, 4), + "LevellingSaturation": (LevellingSaturation, 7), +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 4a9c88a..69f19a4 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -2350,5 +2350,28 @@ def test_the_tail_after_a_reset_is_a_fresh_instance_s(self): fresh.deinit() +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `SteppedSlapback`, +#: `TrackingSlapback`, `TargetOnlyTailSlapback`, `EchoForgetsTimeSlapback`, +#: `JumpTimeSlapback`; they change only a reported tail or latency: +#: `NoWowTailSlapback`; they render what a knob or a patch renders, so the +#: kit cannot tell them from a setting: `FrozenToneSlapback`, +#: `NoWowSlapback`. +KIT_PLANTS = { + "Frames441Slapback": Frames441Slapback, + "ShortHalfFrameSlapback": ShortHalfFrameSlapback, + "FloorSlapback": FloorSlapback, + "HalfFrameSlapback": HalfFrameSlapback, + "SecondRepeatSlapback": SecondRepeatSlapback, + "SplitSlapback": SplitSlapback, + "PannedSlapback": PannedSlapback, + "OpenTopSlapback": OpenTopSlapback, + "RawTopSlapback": RawTopSlapback, + "RawToneSlapback": (RawToneSlapback, 5), + "NoTwoPiSlapback": NoTwoPiSlapback, + "JumpWowSlapback": JumpWowSlapback, +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 6fe4c48..d997f46 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -3117,5 +3117,26 @@ def test_every_claim_is_in_the_docstring_and_tested(self): ("The wobble is periodic, not random.", ("Tier1Fast.test_nothing_here",)),))) + +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `DoubleWalkTape`, +#: `GlideScaledVarispeed`, `WalkAtZeroTape`, `FastWalkAtZeroTape`, +#: `HalfFollowTape`, `StaircaseTape`, `PerBlockSlidingTape`, `SteppedTape`, +#: `NoneAtZeroTape`, `LeanResetTape`; they render what a knob or a patch +#: renders, so the kit cannot tell them from a setting: `NoFlutterLineTape`, +#: `DialableTape`, `LeanDriveOnTape`, `DriveOffTape`. +KIT_PLANTS = { + "LoopShiftTape": LoopShiftTape, + "DoubleCornerTape": DoubleCornerTape, + "PostLossTape": PostLossTape, + "SquareLawTape": (SquareLawTape, 0), + "FlutterOnWowLineTape": FlutterOnWowLineTape, + "Harmonic504Tape": Harmonic504Tape, + "NoDriftTape": NoDriftTape, + "LeanMovesMoreTape": (LeanMovesMoreTape, 8), + "JumpWowTape": JumpWowTape, +} + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_vibrato.py b/tests/test_cpython_effects_vibrato.py index 2b3e79d..3f2e857 100644 --- a/tests/test_cpython_effects_vibrato.py +++ b/tests/test_cpython_effects_vibrato.py @@ -982,3 +982,17 @@ def test_every_claim_is_in_the_docstring_and_tested(self): "within 3 dB"))) self.assertTrue(claim_problems( doc, CLAIMS + (("Latency", ("TheClaims.test_nothing_here",)),))) + + +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `StepEngageVibrato`, +#: `JumpingVibrato`; they change only a reported tail or latency: `OldTail`. +KIT_PLANTS = { + "DryWetVibrato": DryWetVibrato, + "WrongLatencyVibrato": WrongLatencyVibrato, + "HalfRateVibrato": HalfRateVibrato, + "TriangleVibrato": TriangleVibrato, + "OpenBodyVibrato": OpenBodyVibrato, + "HalfSampleVibrato": HalfSampleVibrato, + "SineWowVibrato": SineWowVibrato, +} diff --git a/tests/test_kit.py b/tests/test_kit.py index ef9dc57..2be0ec0 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -146,6 +146,15 @@ def test_a_missing_table_says_so(self): self.assertTrue(verdict(cells, "KitPlain", "plants", "-") .startswith("na(kit_common has no KIT_PLANTS")) + def test_plants_are_read_from_the_claims_module(self): + # Two classes whose test file is not named after them. + import audioeffects + for cls in (audioeffects.NoiseGate, + audioeffects.MultibandCompressor): + plants, why = kit_plants.load_plants(cls) + self.assertIsNone(why, cls.NAME) + self.assertTrue(plants, cls.NAME) + class TestArgs(unittest.TestCase): diff --git a/tests/test_noisegate.py b/tests/test_noisegate.py index 895c31d..9cabecd 100644 --- a/tests/test_noisegate.py +++ b/tests/test_noisegate.py @@ -1074,5 +1074,17 @@ def test_every_claim_is_in_the_docstring_and_tested(self): ("TheClaims.test_nothing_here",)),))) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `OpenKeyBand`, +#: `UnderReportedLatency`, `UnclearedGate`, `OldTail`; they render what a +#: knob or a patch renders, so the kit cannot tell them from a setting: +#: `NoHoldGate`. +KIT_PLANTS = { + "HalvedTimes": HalvedTimes, + "RmsDetector": (RmsDetector, 1), + "AddingDuck": AddingDuck, +} + + if __name__ == "__main__": unittest.main()