Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,21 @@ there, and are recorded in its changelog.
itself; the E2 to A4 range is for an input with none. A plucked note rings
round an offset of 1 to 2 % of its peak, which is what the kit's phrase
found ([#190](https://github.com/PyDevices/audiocomponents/issues/190)).
- Each class whose detector or coupling state outlasts `tail_samples` says by
how much beside its tail: `Compressor`, `DynamicEQ`, `MultibandCompressor`,
`DeEsser`, `CombFilter`, `Exciter`, `Fuzz`, `Overdrive` and `Distortion`.
`Distortion`'s slow coupling-pole residue after a reset, up to about
800 LSB (787 mono and 630 stereo measured), is now held by a test
([#155](https://github.com/PyDevices/audiocomponents/issues/155)).

### Fixed

- `Expander`'s `tail_samples` is finite with Key Listen on. It said 0 while
the key filters rang for 2 304 frames or more; it now bounds that ring from
Key Low, Key High and the rate (at most about 5 100 frames at 48 kHz), and
is still 0 with Key Listen off
([#146](https://github.com/PyDevices/audiocomponents/issues/146)).

- `DeEsser`'s Range 0 is a wire after a reset and at every source buffer
size. A reset armed the new graph from the source, so a Range 0 straight
after it lost 256 frames; it now arms on silence, as the constructor does.
Expand Down
2 changes: 1 addition & 1 deletion docs/audio-component-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ blocks, `DynamicEQ` about 1,000 LSB for 5 to 20 blocks, and
`MultibandCompressor` and `DeEsser` under 16 LSB within three blocks.
`CombFilter` rings at 1 LSB about 500 frames past its tail, `Exciter`,
`Overdrive` and `Fuzz` carry a few LSB of float residue, and `Distortion`
keeps about 700 LSB of slow coupling-pole residue that persists. What you
keeps up to about 800 LSB of slow coupling-pole residue that persists. What you
hear is a re-attack, the same as switching a pedal on.

**Behaviours that are not a bypass.** Three are stated, not accidental:
Expand Down
3 changes: 3 additions & 0 deletions lib/audioeffects/combfilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ class CombFilter(_component.Component):
#: The class-level declaration stays `None`; the instance's
#: `tail_samples` is finite at every setting (2026-09-28), because the
#: bound depends on the setting.
#:
#: The comb can ring at 1 LSB for about 500 frames past
#: `tail_samples`, the last of its decay.
TAIL_SAMPLES = None

MACRO_LABELS = ("Frequency", "Feedback", "Mix", "Tone", "Trim", "Glide")
Expand Down
5 changes: 5 additions & 0 deletions lib/audioeffects/compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ class Compressor(_component.Component):
LATENCY_SAMPLES = 0
#: The gain multiplies the audio; silence in is silence out on the same
#: sample, so there is no tail to declare.
#:
#: After a reset, a rejoin or a control move the detector can leave
#: the output up to 1,575 LSB from an untouched copy for 20 to 50
#: blocks: a re-attack, which `tail_samples` does not bound. See
#: "Detector and coupling state" in docs/audio-component-api.md.
TAIL_SAMPLES = 0

MACRO_LABELS = ("Character", "Threshold", "Ratio", "Attack", "Release",
Expand Down
5 changes: 5 additions & 0 deletions lib/audioeffects/deesser.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ class DeEsser(_component.Component):
#: 170 samples at a 800 Hz corner at 48 kHz, 51 at 2.5 kHz, 14 at 8 kHz,
#: every one of them reaching exact zero, which is what `audiobiquad`
#: exists for. The numbers are in the evidence pack.
#:
#: After a reset, a rejoin or a control move the detector can leave
#: the output under 16 LSB from an untouched copy for up to three
#: blocks, which `tail_samples` does not bound. See "Detector and
#: coupling state" in docs/audio-component-api.md.
TAIL_SAMPLES = 256

MACRO_LABELS = ("Frequency", "Range", "Sensitivity", "Mode", "Release",
Expand Down
4 changes: 4 additions & 0 deletions lib/audioeffects/distortion.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,10 @@ class Distortion(_component.Component):

CAPABILITIES = ()
LATENCY_SAMPLES = 2
#: After a reset the coupling pole leaves a slow residue of up to
#: about 800 LSB beside an untouched copy (787 mono and 630 stereo
#: measured), past `tail_samples`; `TheResetResidue` holds it. See
#: "Detector and coupling state" in docs/audio-component-api.md.
TAIL_SAMPLES = 48000

MACRO_LABELS = (
Expand Down
5 changes: 5 additions & 0 deletions lib/audioeffects/dynamiceq.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ class DynamicEQ(_component.Component):

#: Overridden as a property below: the ring-down is the pole pair's, so
#: it depends on the rate and on where Frequency and Width are.
#:
#: After a reset, a rejoin or a control move the detectors can leave
#: the output about 1,000 LSB from an untouched copy for 5 to 20
#: blocks: a re-attack, which `tail_samples` does not bound. See
#: "Detector and coupling state" in docs/audio-component-api.md.
TAIL_SAMPLES = 0

MACRO_LABELS = ("Frequency", "Width", "Threshold", "Ratio", "Attack",
Expand Down
4 changes: 4 additions & 0 deletions lib/audioeffects/exciter.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,10 @@ class Exciter(_component.Component):

CAPABILITIES = ()
LATENCY_SAMPLES = 0
#: After a reset, a rejoin or a control move a few LSB of float
#: residue can remain in the output beside an untouched copy, past
#: `tail_samples`. See "Detector and coupling state" in docs/audio-
#: component-api.md.
TAIL_SAMPLES = 4096
INPUT_CEILING_DBFS = INPUT_CEILING_DBFS
ALIAS_OVERSAMPLE_COST_DB = ALIAS_OVERSAMPLE_COST_DB
Expand Down
41 changes: 35 additions & 6 deletions lib/audioeffects/expander.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@
own manual warns about. That is a detector effect and is never reported in
`latency_samples`.

**The tail is zero, with one named exception.** The gain is a per-sample
**The tail is zero, except with Key Listen on.** The gain is a per-sample
multiply on the audio, so silence in is exactly zero out in the same frame
however the envelope stands - measured on the kit's `burst_silence` probe,
there is no non-zero sample after the burst at all, at any of the three
rates. Key Listen is the exception: it puts the *side chain's* signal on the
output instead of the audio, and the key high-pass has memory, measured on
output instead of the audio, and the key filters have memory, measured on
that probe at 2373 / 2180 / 1089 frames at 48 / 44.1 / 22.05 kHz with Key Low
at its minimum. That is a diagnostic state, off in every patch, and it is
recorded rather than folded into `tail_samples`.
at its minimum. With Key Listen on, `tail_samples` is a bound on that ring
that follows Key Low, Key High and the rate (at most about 5 100 frames at
48 kHz, with Key Low at its minimum); with it off it is 0. Key Listen is
off in every patch.

**Two wire states, both byte-identical to the source:** Ratio at 1.0, where
the gain computer's `over x (ratio - 1)` is zero, and Depth at 0 dB, where the
Expand Down Expand Up @@ -110,6 +112,13 @@ class is one node, so there are no bands, stages or nodes to drop and no

from . import _component

import math

#: How long a two-pole key filter takes to ring from near full scale to
#: under half an LSB, in units of 1 / fc seconds: ln(65536 * 2) time
#: constants of 0.225 / fc each.
KEY_RING_CYCLES = 2.65

try:
import audiodynamics
except ImportError: # a stock CircuitPython board, or an old audiodsp
Expand Down Expand Up @@ -139,8 +148,8 @@ class Expander(_component.Component):

#: The VCA is a per-sample multiply on the audio, so silence in is
#: exactly zero out in the same frame however the envelope stands. The
#: Key Listen diagnostic is the one path with memory of its own, and it
#: is off in every patch.
#: Key Listen diagnostic is the one path with memory of its own, it is
#: off in every patch, and `tail_samples` bounds its ring while it is on.
TAIL_SAMPLES = 0

MACRO_LABELS = ("Threshold", "Ratio", "Depth", "Attack", "Release",
Expand Down Expand Up @@ -241,6 +250,26 @@ def _apply_macro(self, index, position):
else:
self._node.set(detector="rms" if position >= 0.5 else "peak")

@property
def tail_samples(self):
"""0, or with Key Listen on a bound on the key filters' ring.

Each key filter is a two-pole section. From a state near full scale
it is under half an LSB after about `KEY_RING_CYCLES` / fc seconds,
and the two add, so the bound is the sum at Key Low and Key High as
they stand.
"""
self._check_live()
if self._macros[7] < 0.5:
return 0
rate = float(self._sample_rate)
total = 0
for index in (5, 6):
hz = self._hz(_component.macro_value(self._MACRO_RANGES[index],
self._macros[index]))
total += int(math.ceil(KEY_RING_CYCLES * rate / hz))
return total

def gain_reduction_db(self):
"""What the gain computer applied on the last frame rendered, in dB.

Expand Down
5 changes: 5 additions & 0 deletions lib/audioeffects/fuzz.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,11 @@ class Fuzz(_component.Component):
#: 1, 5 and 7, at all three rates, after a -6 dBFS burst. It holds with
#: Bias centred; off centre the output settles near zero rather than on
#: it, and `tail_samples` is None there (see the docstring).
#:
#: After a reset, a rejoin or a control move a few LSB of float
#: residue can remain in the output beside an untouched copy, past
#: `tail_samples`. See "Detector and coupling state" in docs/audio-
#: component-api.md.
TAIL_SAMPLES = 24576

MACRO_LABELS = ("Fuzz", "Level", "Tone", "Bias", "Load", "Mix",
Expand Down
5 changes: 5 additions & 0 deletions lib/audioeffects/multibandcompressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ class MultibandCompressor(_component.Component):

#: Overridden as a property below - the ring-down is the lower
#: crossover's, so it depends on the rate and on where that macro is.
#:
#: After a reset, a rejoin or a control move the detectors can leave
#: the output under 16 LSB from an untouched copy for up to three
#: blocks, which `tail_samples` does not bound. See "Detector and
#: coupling state" in docs/audio-component-api.md.
TAIL_SAMPLES = 0

MACRO_LABELS = (
Expand Down
4 changes: 4 additions & 0 deletions lib/audioeffects/overdrive.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,10 @@ class Overdrive(_component.Component):

CAPABILITIES = ()
LATENCY_SAMPLES = 0
#: After a reset, a rejoin or a control move a few LSB of float
#: residue can remain in the output beside an untouched copy, past
#: `tail_samples`. See "Detector and coupling state" in docs/audio-
#: component-api.md.
TAIL_SAMPLES = 4096

#: T7's bars, so a test or a probe reads them from one place.
Expand Down
57 changes: 57 additions & 0 deletions tests/test_cpython_effects_distortion.py
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,63 @@ def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self):
1000)


class DriveLeftUpAfterReset(Distortion):
"""A reset that leaves the Distortion knob at full: the instance carries
on a different sound from the one that never met the reset."""

def reset(self):
Distortion.reset(self)
self.set_macro(DIST, 127)


class TheResetResidue(unittest.TestCase):
"""audiocomponents#155: after a reset the output keeps a residue from
the untouched copy that outlasts `tail_samples`, the coupling pole's
slow state. It is measured at 787 LSB mono and 630 stereo (looped click
and noise material, patch 0, the lifecycle matrix's E1 on a block
boundary) over the 20 000 frames after the tail, and the class
documents it as up to about 800; this holds it under 800."""

BOUND = 800

def residue(self, cls, channels, extra=20000):
import lifecycle
values = lifecycle.material_for("loop", RATE, channels)

def run(reset):
feed = lifecycle.Feed(values, RATE, channels, "256", loop=True)
effect = cls(feed.port, sample_rate=RATE)
try:
tail = effect.tail_samples
bound = 12 * 256 + tail + effect.latency_samples + 256
want = (bound + extra) * 2 * channels
out = bytearray()
pulls = 0
while len(out) < want:
if reset and pulls == 12:
effect.reset()
lifecycle._pull(effect, out)
pulls += 1
return array.array("h", bytes(out)), bound
finally:
effect.deinit()

control, bound = run(False)
moved, _ = run(True)
return max(abs(a - b) for a, b in zip(control[bound * channels:],
moved[bound * channels:]))

def test_the_residue_after_a_reset_stays_under_its_bound(self):
for channels in (1, 2):
self.assertLessEqual(self.residue(Distortion, channels),
self.BOUND, channels)

def test_a_reset_that_leaves_the_drive_up_is_red(self):
for channels in (1, 2):
self.assertGreater(self.residue(DriveLeftUpAfterReset, channels),
self.BOUND, channels)


if __name__ == "__main__":
unittest.main()

Expand Down
58 changes: 58 additions & 0 deletions tests/test_cpython_effects_expander.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,64 @@ def test_the_replacement_states_are_off_the_grid(self):
self.assertGreater(got["checked"], 100)


class ZeroTailKeyListenExpander(Expander):
"""The fault behind audiocomponents#146: the class declares no tail
with Key Listen on, as it did when `tail_samples` was a constant 0."""

@property
def tail_samples(self):
return 0


class TheKeyListenTailIsFinite(unittest.TestCase):
"""With Key Listen on the output is the key filters' signal, which
rings after the input stops. `tail_samples` bounds that ring at every
Key Low and Key High, and is 0 with Key Listen off."""

KEYS = ((25.0, 250.0), (25.0, 35000.0), (100.0, 3000.0),
(1000.0, 35000.0), (4000.0, 250.0))

def ring(self, cls, key_low, key_high, listen=1.0):
"""(frames of non-zero output after a loud 1 kHz burst, the class's
tail_samples at those settings)."""
burst = tone(1000.0, -3.0, 0.2)
silent = array.array("h", [0] * (RATE * CHANNELS))
data = array.array("h", burst)
data.extend(silent)
settings = {KEY_LISTEN: listen, KEY_LOW: key_low,
KEY_HIGH: key_high, THRESHOLD: -80.0}
adapter = source_of(data)
effect = cls.create(adapter, RATE)
try:
for index, value in settings.items():
effect.set_macro(index, position(index, value))
tail = effect.tail_samples
finally:
effect.deinit()
out = render(cls, data, settings)
last = 0
for index in range(len(out) - 1, -1, -1):
if out[index]:
last = index // CHANNELS + 1
break
return max(0, last - len(burst) // CHANNELS), tail

def test_the_tail_bounds_the_ring_at_every_key_setting(self):
for key_low, key_high in self.KEYS:
ring, tail = self.ring(Expander, key_low, key_high)
self.assertGreater(ring, 0, (key_low, key_high))
self.assertLessEqual(ring, tail, (key_low, key_high))
self.assertLess(tail, 6000, (key_low, key_high))

def test_the_tail_is_zero_with_key_listen_off(self):
ring, tail = self.ring(Expander, 25.0, 250.0, listen=0.0)
self.assertEqual((ring, tail), (0, 0))

def test_a_tail_of_zero_with_key_listen_on_is_red(self):
ring, tail = self.ring(ZeroTailKeyListenExpander, 25.0, 35000.0)
self.assertGreater(ring, tail)


class TheReadingsGoRedOnAWire(unittest.TestCase):
"""`kit_faults.null_build_red()` on what this pack cites, and on the
reading it replaces."""
Expand Down
Loading