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
22 changes: 21 additions & 1 deletion AUDIODSP_PIN
Original file line number Diff line number Diff line change
Expand Up @@ -446,5 +446,25 @@
# component builds on renders differently. The pyproject floors rise to
# >=0.6.9.
#
# Moved to 0c0824e on 2026-10-29, for the sampler's filter, modulation
# envelope and LFOs (audiodsp#337, merged as this commit), which `soundfont`
# now hands every region's own to. CI installs audiodsp from this pin and
# nothing else, so without it play() refuses the new keywords. Named by
# commit; no release carries it yet. What else moved beneath the
# components, by `git log v0.6.9..0c0824e -- src`: audiofeatures is new;
# audiograph becomes an audio sample the pump pulls, gains a clipping
# two-pole filter, an overdrive, an exponential VCA curve and a TANH node,
# and pages its tables; audiobiquad takes buffer_size; and the CircuitPython
# modules are resynced with 11.0.0-alpha.2 (46f78dc). That resync is the one
# that renders differently: the Mixer now scales a voice as
# (x * level) >> 15, rounding toward minus infinity, where it scaled by
# level / 32767 and truncated. Level 1.0 is the exact wire it was, and a
# level below unity moves by up to one LSB. Every effect test that changed
# result was bisected to 46f78dc (all pass at 1877bb9, the commit before).
# MultiTapDelay and AirSpace drop the mono 1 - 2^-15 dry they used to
# dodge the float mixer, the Harmonizer tests model the new arithmetic, and
# AirSpace's get_buffer digests are re-read. A sampler voice that sets none
# of the new keywords renders the bytes it did.
#
# <ref> <commit> -- ref is what CI installs (git+https://...@<ref>)
v0.6.9 4de7609268d0ade9aaf652e232a0ffb1bae9e8ec
0c0824e3966998613d8c5314cefb1d752bc70693 0c0824e3966998613d8c5314cefb1d752bc70693
13 changes: 4 additions & 9 deletions lib/audioeffects/airspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,15 +439,10 @@ def _restore(self, record):
self, (positions, patch_index, (list(kept[0]),)))

def _dry_level(self, mix):
"""The dry voice's level, min(1, 2 - Mix). At one channel unity is
handed as 1 - 2^-15, which every Mixer this class meets passes
through exactly: CircuitPython's own `audiomixer` turns 1.0 into
32768 / 32767 and puts the samples nearest the rails one LSB hot.
Two channels keep 1.0 (`MultiTapDelay._dry_level` has why)."""
level = 1.0 if mix <= 1.0 else 2.0 - mix
if level >= 1.0 and self._channel_count == 1:
return 1.0 - 2.0 ** -15
return level
"""The dry voice's level, min(1, 2 - Mix). Unity is Q15 32768, which
a Mixer voice passes through exactly on every lane
(`MultiTapDelay._dry_level` has why)."""
return 1.0 if mix <= 1.0 else 2.0 - mix

def _levels(self):
mix = self._value(MIX_I)
Expand Down
15 changes: 8 additions & 7 deletions lib/audioeffects/deesser.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,15 @@
# 902's claim, and this class's, is that the same setting works on a
# whisper and a belt. Measured over the Sensitivity macro's whole travel on
# one spectral balance at four absolute levels, the reductions agree within
# 0.316 dB across -6, -20 and -40 dBFS - well inside the trait's 1 dB -
# and within 0.366 dB at both HF-only patches. Add a -55 dBFS take and
# the spread reaches 2.643 dB, because at that level the `1 - Range`
# dry voice is a handful of LSB and the 16-bit mixer truncates it: the same
# blend with the gain cell muted already spreads 0.643 to 1.253 dB with no
# detector in the sum. So dossier D1 is demonstrated to -40 dBFS and
# 0.213 dB across -6, -20 and -40 dBFS - well inside the trait's 1 dB -
# and within 0.090 dB at both HF-only patches. Add a -55 dBFS take and
# the spread reaches 1.176 dB at Sensitivity 45 dB, because at that level
# the `1 - Range` dry voice is a handful of LSB and the 16-bit mixer
# rounds it. With the -55 take, patch 3 holds D1 (0.583 dB) and patch 4
# misses it (1.321 dB). So dossier D1 is demonstrated to -40 dBFS and
# disconfirmed at -55; the cause is the palette's int16 path, not the
# relative threshold.
# relative threshold. (CircuitPython 10's float mixer, which truncated
# toward zero, spread 2.643 dB and missed at both HF-only patches.)
# * The 902's release is a constant 925 dB/sec - a straight *line* in
# dB. `audiodynamics` releases with a one-pole in linear gain, so the rate
# can be tuned to the 902's and the shape cannot. `Release` is therefore in
Expand Down
42 changes: 35 additions & 7 deletions lib/audioeffects/distortion.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@
# 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
# board ran. Since the integer mixer the two levels are set on two x1
# `Waveshaper` level nodes, which round, with the mixer voices at an exact 1
# (`LEVEL_LINE`); those two nodes are not in the priced sum, and no board
# has timed them. 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.
Expand Down Expand Up @@ -401,6 +404,16 @@ def _f32(value):
)
# END CURVE DS1_ASYM
V_SCALE = 10.0

#: The Rat's leg levels are set in float, not in the leg mixer. They are
#: below unity and sit ahead of up to 67 dB of gain, and an integer mixer
#: voice floors `x * level`, which is half an LSB of DC on every sample that
#: is not a multiple of the step: at Distortion max a 4 LSB input stood at
#: -0.070 of the output's full scale. A `Waveshaper` at x1 on this two-point
#: straight line, at a `post_gain` of `level * LEVEL_GAIN`, rounds to the
#: nearest LSB instead, so it adds no bias; at a level of 1 it is exact.
LEVEL_LINE = array("h", (-32767, 32767))
LEVEL_GAIN = 32768.0 / 32767.0
GBW_HZ = 320000.0
R_GAIN = 100000.0
R_BODY_STOCK = 47.0
Expand Down Expand Up @@ -709,6 +722,14 @@ def _hp(self, frequency):
channel_count=self._channel_count)
return self._own(node)

def _level_node(self, level):
node = audioshaper.Waveshaper(
sample_rate=self._sample_rate,
channel_count=self._channel_count,
curve=LEVEL_LINE, oversample=1, pre_gain=1.0,
post_gain=_f32(level * LEVEL_GAIN), mix=1.0, bias=0.0)
return self._own(node)

def _wire(self, voice, source):
"""Remember what a mixer voice will play. Nothing is attached until
`_connect`, because `Mixer.play` pulls a chunk there and then: a
Expand Down Expand Up @@ -790,11 +811,15 @@ def _install_graph(self, scoop):
self._leg_treble = self._hp(1539.0)
self._leg_bass.play(legs.tap(0))
self._leg_treble.play(legs.tap(1))
self._bass_level = self._level_node(1.0)
self._treble_level = self._level_node(0.0)
self._bass_level.play(self._leg_bass)
self._treble_level.play(self._leg_treble)
self._leg_mix = self._mixer_node(2)
self._leg_mix.voice[0].level = 1.0
self._leg_mix.voice[1].level = 0.0
self._wire(self._leg_mix.voice[0], self._leg_bass)
self._wire(self._leg_mix.voice[1], self._leg_treble)
self._leg_mix.voice[1].level = 1.0
self._wire(self._leg_mix.voice[0], self._bass_level)
self._wire(self._leg_mix.voice[1], self._treble_level)
self._gain = self._leg_mix
self._gbw = self._lp(1000.0)
self._gbw.play(self._gain)
Expand All @@ -815,6 +840,8 @@ def _install_graph(self, scoop):
self._leg_mix = None
self._leg_bass = None
self._leg_treble = None
self._bass_level = None
self._treble_level = None
if not scoop:
self._out_hp = None

Expand Down Expand Up @@ -1120,11 +1147,12 @@ def _push_filter(self):
# crossfaded at a/(1+a), so its dry side is the follower. voice 1 is
# b*HP(1539) at b/total. The two levels sum to 1 exactly, which is
# where the 6 dB of clipper headroom over the old dry-minus-lowpass
# pair comes from.
# pair comes from. They are set on the level nodes, which round,
# and the mixer voices stay at an exact 1 (`LEVEL_LINE`).
self._leg_bass.mix = a / (1.0 + a)
self._leg_treble.mix = 1.0
self._leg_mix.voice[0].level = (1.0 + a) / total
self._leg_mix.voice[1].level = b / total
self._bass_level.set(post_gain=_f32((1.0 + a) / total * LEVEL_GAIN))
self._treble_level.set(post_gain=_f32(b / total * LEVEL_GAIN))
boost = _component.db_to_gain(self.macro(BOOST))
self._shaper.set(
pre_gain=total * boost / V_SCALE,
Expand Down
24 changes: 7 additions & 17 deletions lib/audioeffects/multitapdelay.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,29 +637,19 @@ def _refresh(self):
self._route()

def _dry_level(self, mix):
"""The dry voice's level, min(1, 2 - Mix). At one channel unity is
handed as 1 - 2^-15, which every Mixer this class meets passes
through exactly: CircuitPython's stock `audiomixer` turns a level
of 1.0 into 32768 / 32767 and puts the samples nearest the rails
one LSB hot, while 32767 / 32767 is 1. Two channels cannot do that
(the stock Mixer's pan law hands the left lane 32767 x level >> 15,
so 1 - 2^-15 is one LSB cold there), so they keep 1.0, which every
interpreter at AUDIODSP_PIN passes through exactly on both lanes."""
level = min(1.0, 2.0 - mix)
if level >= 1.0 and self._channel_count == 1:
return 1.0 - 2.0 ** -15
return level
"""The dry voice's level, min(1, 2 - Mix). A Mixer voice scales by
(sample x Q15 level) >> 15 and a level of 1.0 is Q15 32768, so unity
passes every sample through exactly, mono and stereo, on every
interpreter at AUDIODSP_PIN."""
return min(1.0, 2.0 - mix)

def _route(self):
"""Where the output port points, and what the tap node reads.

Mix 0 hands the input adapter out through the port (`_bypass`, by
way of `_through`), a width-1 MidSide that passes the source byte
for byte, so it is a
wire on every interpreter: CircuitPython's stock `audiomixer`
scales a voice at level 1.0 by 32768 / 32767, which puts every
sample at |value| >= 32736 one LSB out (audiodsp's own Mixer passes
unity through). The source is read through the adapter at every
for byte, so it is a wire on every interpreter without a Mixer
voice in the way. The source is read through the adapter at every
Mix, so whatever part of a buffer it holds plays next either way.
Nothing is pulled through the graph while the port is on the
adapter, so nothing here may prime from it then: a `play()` would
Expand Down
42 changes: 40 additions & 2 deletions lib/audioeffects/overdrive.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@
# `audiocore.get_buffer`, charges it too, and renders byte for byte what a
# desktop does.
#
# **The offset is taken off ahead of the pole too.** On its own the pole's
# float32 state stalls short of zero on a large constant, and at 44.1 kHz
# shipped patch 6 came to rest at -1 LSB in silence once the Level stage
# became an integer mixer. A second `Waveshaper` at x1 on a straight line
# (`OFFSET_LINE`) subtracts the offset exactly, so the pole only ever holds
# a few LSB: silence after a burst of noise or a sine is **0 at every
# shipped patch at 48, 44.1, 22.05 and 96 kHz**. It moves patch 6's render
# by at most 4 LSB and no other patch's at all, adds no latency, and is one
# more x1 node on the clip branch that no board has timed.
#
# **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
Expand Down Expand Up @@ -474,6 +484,19 @@ def shipped_oversample(sample_rate):
DC_BLOCK_HZ = 30.0
DC_BLOCK_Q = 0.5

#: The offset is also taken off *before* the pole, so the pole holds a few
#: LSB rather than thousands. Its float32 recursion cannot step a large
#: constant down by less than one rounding of it: fed shipped patch 6's
#: offset at 44.1 kHz it came to rest at -0.7 LSB rather than on zero, which
#: the Level stage's integer mixer floors to a standing -1 LSB for ever,
#: and a burst of signal leaves it somewhere else in that band. The node
#: that takes it off is a second `Waveshaper` at x1 on a straight line: a
#: two-point curve at a `post_gain` of 32768 / 32767 plays `x + bias` back
#: exactly, to the LSB, for every int16 `x`, so it adds no rounding to the
#: clip branch and no latency.
OFFSET_LINE = array("h", (-32767, 32767))
OFFSET_GAIN = 32768.0 / 32767.0

#: How many blocks `_settle_dc` will pull before it gives up. The pole needs
#: about 3000 frames at 48 kHz; 64 blocks is 16384 frames, five times that.
DC_SETTLE_BLOCKS = 64
Expand Down Expand Up @@ -637,6 +660,10 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0,
self._dc = audiobiquad.Biquad(
mode=audiobiquad.HIGH_PASS, frequency=DC_BLOCK_HZ,
Q=DC_BLOCK_Q, sample_rate=rate, channel_count=channels)
self._trim = audioshaper.Waveshaper(
sample_rate=rate, channel_count=channels, curve=OFFSET_LINE,
oversample=1, mix=1.0, pre_gain=1.0, post_gain=OFFSET_GAIN,
bias=0.0, hysteresis=0.0)
# The clip branch is wired to the split *after* the offset has been
# settled, below: a `play()` takes a block from whatever it is
# handed, so re-pointing the shaper at `_c4` once the offset is
Expand All @@ -646,7 +673,8 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0,
self._hp.play(tap1)
self._c4.play(self._hp)
self._shaper.play(self._c4)
self._dc.play(self._shaper)
self._trim.play(self._shaper)
self._dc.play(self._trim)

circuit = audiomixer.Mixer(voice_count=2, **self._pcm())
self._lp = audiobiquad.Biquad(
Expand All @@ -672,6 +700,7 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0,
self._own(self._lp)
self._own(circuit, reset=False)
self._own(self._dc)
self._own(self._trim)
self._own(self._shaper)
self._own(self._c4)
self._own(self._hp)
Expand Down Expand Up @@ -699,6 +728,7 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0,
# zero one - and charged once nothing will touch it again.
if self._clip_bias:
self._shaper.set(post_gain=0.0)
self._trim.set(bias=0.0)
_component.open_level_gates(
circuit, [circuit.voice[0], circuit.voice[1]], self._silence)
_component.open_level_gates(out, [out.voice[0]], self._silence)
Expand All @@ -725,6 +755,11 @@ def _shaper_dc(self):
return (CURVE[index] * (1.0 - rise)
+ CURVE[index + 1] * rise) * self._post_gain

def _offset_bias(self):
"""The cancel's `bias`: the offset to the nearest LSB, negated,
in the curve's units."""
return -round(self._shaper_dc()) / 32768.0

def _settle_dc(self):
"""Charge the output capacitor before the first real block.

Expand All @@ -748,6 +783,7 @@ def _settle_dc(self):
blocks back, so it runs all `DC_SETTLE_BLOCKS` and the pole lands
where the early stop leaves it, to the byte.
"""
self._trim.set(bias=self._offset_bias())
if not (self._clip_bias or self._dc_charged):
return False
self._dc_charged = bool(self._clip_bias)
Expand Down Expand Up @@ -899,13 +935,15 @@ def _rearm(self, level):
self._hp.play(self._tap1)
self._c4.play(self._hp)
self._shaper.play(self._c4)
self._dc.play(self._shaper)
self._trim.play(self._shaper)
self._dc.play(self._trim)
self._lp.play(self._circuit)
self._circuit.voice[0].level = 1.0
self._circuit.voice[1].level = 1.0
self._out.voice[0].level = level
if self._clip_bias:
self._shaper.set(post_gain=0.0)
self._trim.set(bias=0.0)
_component.open_level_gates(
self._circuit, [self._circuit.voice[0], self._circuit.voice[1]],
self._silence)
Expand Down
Loading
Loading