From 2e43a900615af6cabcffac6f8fa08c6b4abd2e90 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:00:37 -0500 Subject: [PATCH 01/10] soundfont: play a region's filter, modulation envelope and LFOs, with the default modulators The reader now gives a region the rest of what a bank says about a note: its low-pass cutoff and resonance, its modulation envelope and the depths it drives pitch and cutoff by, and both LFOs with their rates, delays and depths. The instrument hands them to the sampler's voice. Over them, the SoundFont 2.04 default modulators: velocity darkens a filtered region by up to two octaves, the mod wheel and channel pressure each add up to 50 cents of vibrato, and Control Change 7 and 11 set the level by the square of their value. Volume starts at General MIDI's 100, so a channel that never sends it plays about 4 dB under where it did. A region that doesn't use its filter stays unfiltered at every velocity. A bank's own modulators are still not applied; the docstring says so. tests/test_cpython_soundfont_modulation.py measures each on a bank the suite writes, with planted faults that fail them. --- lib/audioinstruments/README.md | 7 +- lib/audioinstruments/_sf2.py | 83 ++++++++- lib/audioinstruments/soundfont.py | 166 ++++++++++++++++-- tests/support/sf2_builder.py | 46 +++++ tests/test_cpython_sf2.py | 37 ++++ tests/test_cpython_soundfont.py | 5 +- tests/test_cpython_soundfont_modulation.py | 186 +++++++++++++++++++++ 7 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 tests/test_cpython_soundfont_modulation.py diff --git a/lib/audioinstruments/README.md b/lib/audioinstruments/README.md index 736b6ec0..ac99c877 100644 --- a/lib/audioinstruments/README.md +++ b/lib/audioinstruments/README.md @@ -269,7 +269,12 @@ the error names all three. Patch n is Program Change n in bank 0, named from the bank, and Bank Select (CC 0 and 32) reaches the other melodic banks on the next program change. Four macros: Volume, Attack, Release (each scaling the bank's own envelope) and Tune (a semitone either way). -The module docstring says how attenuation is scaled and why. +Each note plays its region's low-pass filter, modulation envelope and two +LFOs, with the SoundFont 2.04 default modulators over them: velocity +darkens a filtered region, the mod wheel and channel pressure add vibrato, +and Control Change 7 and 11 set the level. A bank's own modulators are not +applied. The module docstring has the details, and how attenuation is +scaled and why. Each instrument has 32 voices of its own (`voices=` to `create()` asks for another number, and `inst.voices` says how many it has). For a General MIDI diff --git a/lib/audioinstruments/_sf2.py b/lib/audioinstruments/_sf2.py index fb3adfae..c125c1a9 100644 --- a/lib/audioinstruments/_sf2.py +++ b/lib/audioinstruments/_sf2.py @@ -22,8 +22,19 @@ *added* to the instrument's values, except key and velocity ranges, which intersect, and the generators the specification forbids at preset level (sample addresses, sampleModes, exclusiveClass, overridingRootKey, keynum, -velocity), which are ignored there. Modulators are not implemented; the -reader only records whether a bank carries any (`has_modulators`). +velocity), which are ignored there. A bank's own modulators are not read; +the reader only records whether a bank carries any (`has_modulators`). The +specification's default modulators belong to whoever plays the region: +`audioinstruments.soundfont` applies them. + +Besides the volume envelope, a region carries the rest of what a bank says +about a note: the low-pass (`filter_cutoff` in absolute cents and +`filter_q` in centibels, as written, and `filter_hz` and `resonance` in Hz +and dB), the modulation envelope (`mod_delay` ... `mod_release` in seconds +and `mod_sustain` as a level, with `mod_to_pitch` and `mod_to_cutoff` in +cents), the vibrato LFO (`vibrato_delay`, `vibrato_rate` in Hz, +`vibrato_to_pitch` in cents) and the modulation LFO (`lfo_delay`, +`lfo_rate`, `lfo_to_pitch`, `lfo_to_cutoff`, and `lfo_to_volume` in dB). Samples stay on disk. The reader keeps the `smpl` chunk's offset and `read_sample()` reads a region's frames when asked, which is what a board @@ -48,10 +59,28 @@ GEN_STARTLOOP_OFFSET = 2 GEN_ENDLOOP_OFFSET = 3 GEN_START_COARSE = 4 +GEN_MOD_LFO_TO_PITCH = 5 +GEN_VIB_LFO_TO_PITCH = 6 +GEN_MOD_ENV_TO_PITCH = 7 GEN_FILTER_FC = 8 GEN_FILTER_Q = 9 +GEN_MOD_LFO_TO_FILTER_FC = 10 +GEN_MOD_ENV_TO_FILTER_FC = 11 GEN_END_COARSE = 12 +GEN_MOD_LFO_TO_VOLUME = 13 GEN_PAN = 17 +GEN_DELAY_MOD_LFO = 21 +GEN_FREQ_MOD_LFO = 22 +GEN_DELAY_VIB_LFO = 23 +GEN_FREQ_VIB_LFO = 24 +GEN_DELAY_MOD_ENV = 25 +GEN_ATTACK_MOD_ENV = 26 +GEN_HOLD_MOD_ENV = 27 +GEN_DECAY_MOD_ENV = 28 +GEN_SUSTAIN_MOD_ENV = 29 +GEN_RELEASE_MOD_ENV = 30 +GEN_KEYNUM_TO_MOD_ENV_HOLD = 31 +GEN_KEYNUM_TO_MOD_ENV_DECAY = 32 GEN_DELAY_VOL_ENV = 33 GEN_ATTACK_VOL_ENV = 34 GEN_HOLD_VOL_ENV = 35 @@ -87,6 +116,13 @@ # 8.1.3). Everything absent here defaults to zero. _DEFAULTS = { GEN_FILTER_FC: 13500, + GEN_DELAY_MOD_LFO: -12000, + GEN_DELAY_VIB_LFO: -12000, + GEN_DELAY_MOD_ENV: -12000, + GEN_ATTACK_MOD_ENV: -12000, + GEN_HOLD_MOD_ENV: -12000, + GEN_DECAY_MOD_ENV: -12000, + GEN_RELEASE_MOD_ENV: -12000, GEN_DELAY_VOL_ENV: -12000, GEN_ATTACK_VOL_ENV: -12000, GEN_HOLD_VOL_ENV: -12000, @@ -226,6 +262,23 @@ def __init__(self): self.filter_cutoff = 13500 self.filter_q = 0 self.filter_hz = 0.0 + self.resonance = 0.0 + self.mod_delay = 0.0 + self.mod_attack = 0.0 + self.mod_hold = 0.0 + self.mod_decay = 0.0 + self.mod_sustain = 1.0 + self.mod_release = 0.0 + self.mod_to_pitch = 0 + self.mod_to_cutoff = 0 + self.vibrato_delay = 0.0 + self.vibrato_rate = 8.176 + self.vibrato_to_pitch = 0 + self.lfo_delay = 0.0 + self.lfo_rate = 8.176 + self.lfo_to_pitch = 0 + self.lfo_to_cutoff = 0 + self.lfo_to_volume = 0.0 self.generators = None def __repr__(self): @@ -663,4 +716,30 @@ def gen(oper): r.filter_cutoff = gen(GEN_FILTER_FC) r.filter_q = gen(GEN_FILTER_Q) r.filter_hz = cents_to_hz(r.filter_cutoff) + r.resonance = max(0, r.filter_q) / 10.0 + + # The modulation envelope: the volume envelope's timing, its sustain + # in tenths of a percent below full scale (section 8.1.3, #29). + r.mod_delay = timecents_to_seconds(gen(GEN_DELAY_MOD_ENV)) + r.mod_attack = timecents_to_seconds(gen(GEN_ATTACK_MOD_ENV)) + r.mod_hold = timecents_to_seconds( + gen(GEN_HOLD_MOD_ENV) + + (60 - played) * gen(GEN_KEYNUM_TO_MOD_ENV_HOLD)) + r.mod_decay = timecents_to_seconds( + gen(GEN_DECAY_MOD_ENV) + + (60 - played) * gen(GEN_KEYNUM_TO_MOD_ENV_DECAY)) + sustain = gen(GEN_SUSTAIN_MOD_ENV) + r.mod_sustain = 1.0 - min(1000, max(0, sustain)) / 1000.0 + r.mod_release = timecents_to_seconds(gen(GEN_RELEASE_MOD_ENV)) + r.mod_to_pitch = gen(GEN_MOD_ENV_TO_PITCH) + r.mod_to_cutoff = gen(GEN_MOD_ENV_TO_FILTER_FC) + + r.vibrato_delay = timecents_to_seconds(gen(GEN_DELAY_VIB_LFO)) + r.vibrato_rate = cents_to_hz(gen(GEN_FREQ_VIB_LFO)) + r.vibrato_to_pitch = gen(GEN_VIB_LFO_TO_PITCH) + r.lfo_delay = timecents_to_seconds(gen(GEN_DELAY_MOD_LFO)) + r.lfo_rate = cents_to_hz(gen(GEN_FREQ_MOD_LFO)) + r.lfo_to_pitch = gen(GEN_MOD_LFO_TO_PITCH) + r.lfo_to_cutoff = gen(GEN_MOD_LFO_TO_FILTER_FC) + r.lfo_to_volume = gen(GEN_MOD_LFO_TO_VOLUME) / 10.0 return r diff --git a/lib/audioinstruments/soundfont.py b/lib/audioinstruments/soundfont.py index e8d774f6..90a0b240 100644 --- a/lib/audioinstruments/soundfont.py +++ b/lib/audioinstruments/soundfont.py @@ -34,6 +34,35 @@ has `SampleBuffer`, the arena's buffers sit outside the collected heap, so a `gc.collect()` doesn't read megabytes of samples looking for pointers. +What a bank says, and what it doesn't. Each note plays with its region's +volume envelope, low-pass filter (initial cutoff and resonance), modulation +envelope (to pitch and cutoff), vibrato LFO and modulation LFO (to pitch, +cutoff and volume), and the default modulators of the SoundFont 2.04 +specification (section 8.4) over them: + +- Note-on velocity to attenuation: the level goes as the square of the + velocity, which is how this instrument has always played velocity and + close to the specification's concave curve. +- Note-on velocity to cutoff: down to 2400 cents (two octaves) lower at + velocity 0, linearly, so a softer note is darker. This applies only to a + region whose own filter is below its open 13500 cents: a region that + doesn't use its filter stays unfiltered at every velocity, and costs + nothing for it. The specification applies it to every region, which makes a + soft note on an unfiltered region darker too (5 kHz at velocity 0); here + such a region plays as the bank wrote it, on the sampler's cheaper path. +- Mod wheel (Control Change 1) to vibrato depth: up to 50 cents at full + wheel, added to the region's own vibrato. Channel pressure does the same. +- Volume (Control Change 7) and Expression (Control Change 11) to + attenuation, each by the same concave curve: the level goes as the square + of each. They start at 100 and 127, General MIDI's reset values, so a + channel that never sends Control Change 7 plays about 4 dB under full. +- Pan (Control Change 10) and the pitch wheel, as described below. + +A bank's own modulators (its `pmod` and `imod` records), including any that +would override these defaults, are not applied; the reader only notes +whether a bank has any. Reverb and chorus sends (Control Change 91 and 93) +have no effect here. + Attenuation. A region's `initialAttenuation` is applied scaled by `ATTENUATION_SCALE` (0.4): the banks in circulation were voiced on players that scale it so, and played per the specification's centibels they come out @@ -46,8 +75,9 @@ added to each region's own pan, so a stereo pair keeps its spread around the new centre, clamped at the edges. Moving it re-pans the voices already sounding, with the engine's ramp, and Control Change 10 on the instrument -drives it, as it does Bank Select; the other GM controllers are the -host's. A program change puts every macro back to the patch's defaults, +drives it, as it does Bank Select. Control Change 1, 7 and 11 and channel +pressure work as the default modulators above; the other GM controllers are +the host's. A program change puts every macro back to the patch's defaults, Pan at centre. Thirty-two engine voices, one per region a note resolves to (a stereo pair @@ -147,7 +177,7 @@ def ticks_diff(a, b): import audioinstruments._sf2 as _sf2 from audioinstruments._support import ( EVENT_NOTE_ON, EVENT_NOTE_OFF, EVENT_PARAMETER, EVENT_PITCH_BEND, - EVENT_CONTROL_CHANGE, key_of, + EVENT_CONTROL_CHANGE, EVENT_CHANNEL_PRESSURE, key_of, ) from audioinstruments._support import Instrument @@ -177,10 +207,28 @@ def _slab(nbytes): BEND_RANGE = 2.0 _CC_BANK_MSB = 0 -_CC_BANK_LSB = 32 +_CC_MOD_WHEEL = 1 +_CC_VOLUME = 7 _CC_PAN = 10 +_CC_EXPRESSION = 11 +_CC_BANK_LSB = 32 _CC_SUSTAIN = 64 +#: The default modulators' amounts (SoundFont 2.04, section 8.4): velocity +#: to cutoff, in cents at velocity 0; the mod wheel and channel pressure to +#: vibrato depth, in cents at full scale. +VELOCITY_TO_CUTOFF = -2400 +MOD_WHEEL_TO_VIBRATO = 50.0 +PRESSURE_TO_VIBRATO = 50.0 + +#: A filter at or above this many absolute cents, with no resonance, is +#: open: the specification's default, 19.9 kHz. +OPEN_CUTOFF = 13500 + +#: General MIDI's reset values for Control Change 7 and 11. +DEFAULT_VOLUME = 100 +DEFAULT_EXPRESSION = 127 + def _bipolar(value0): """A bipolar macro's -1..1 from the 0..1 the event carries: the centre @@ -192,6 +240,24 @@ def _bipolar(value0): return (midi - 64.0) / 64.0 +def _concave(value): + """A 0..127 controller as the gain of the specification's concave, + negative, unipolar curve over 96 dB: the square of its fraction.""" + return (value / 127.0) ** 2 + + +def _voice_filter(region, velocity): + """(cutoff in Hz or None, resonance in dB) for `region` at `velocity` + (0..127), with the default velocity-to-cutoff modulator.""" + cents = region.filter_cutoff + moved = region.mod_to_cutoff or region.lfo_to_cutoff + if cents >= OPEN_CUTOFF and region.resonance <= 0 and not moved: + return None, 0.0 + if cents < OPEN_CUTOFF: + cents += VELOCITY_TO_CUTOFF * (1.0 - velocity / 127.0) + return _sf2.cents_to_hz(cents), region.resonance + + def _panned(region_pan, offset): pan = region_pan + offset if pan < -1.0: @@ -622,10 +688,11 @@ def __init__(self, sample_rate, channel_count=2, self.channel_count = channel_count self.shared = shared #: `slots[i]` is engine voice i: None when free, else `[key, serial, - #: state, exclusive_class, level, pitch, pan, player]`, where `state` - #: is `HELD`, `RELEASING` or `FADED`, `level`, `pitch` and `pan` are - #: the region's own, before the live macros, and `player` is the - #: instrument's that holds it. + #: state, exclusive_class, level, pitch, pan, player, vibrato]`, + #: where `state` is `HELD`, `RELEASING` or `FADED`, `level`, `pitch`, + #: `pan` and `vibrato` (cents) are the region's own, before the live + #: macros and controllers, and `player` is the instrument's that + #: holds it. self.slots = [None] * int(voice_count) self._queue = None # the writes waiting, while deferred self._pending = {} # voice index -> playing, as the queue leaves it @@ -836,6 +903,22 @@ def pan(self): def pan(self, value): self._send(setattr, (self._voice, "pan", value)) + @property + def cutoff(self): + return self._voice.cutoff + + @cutoff.setter + def cutoff(self, value): + self._send(setattr, (self._voice, "cutoff", value)) + + @property + def vibrato_depth(self): + return self._voice.vibrato_depth + + @vibrato_depth.setter + def vibrato_depth(self, value): + self._send(setattr, (self._voice, "vibrato_depth", value)) + # --- the player ------------------------------------------------------------ @@ -865,6 +948,10 @@ def __init__(self, bank, voices, default_bank=0): self.tune = 1.0 self.bend = 1.0 self.pan = 0.0 + self.volume_cc = {} # channel -> Control Change 7 + self.expression = {} # channel -> Control Change 11 + self.wheel = {} # channel -> vibrato cents from the mod wheel + self.pressure = {} # channel -> vibrato cents from pressure # -- preset selection --------------------------------------------------- @@ -952,6 +1039,10 @@ def note_on(self, key, pitch, velocity, detune): serial = pool.serial voices = self.pool.voice detune_ratio = 2.0 ** (detune / 12.0) if detune else 1.0 + channel = key[0] + controllers = self._controllers(channel) + extra_vibrato = (self.wheel.get(channel, 0.0) + + self.pressure.get(channel, 0.0)) # Choke before any of this note's regions start, so a stereo pair # in one class does not stop its own other half. for region in regions: @@ -968,21 +1059,37 @@ def note_on(self, key, pitch, velocity, detune): vel = region.velocity / 127.0 level = (region.attenuation ** ATTENUATION_SCALE) * vel * vel ratio = region.pitch_ratio * detune_ratio + cutoff, resonance = _voice_filter(region, vel * 127.0) i = self._allocate() voices[i].play( pcm, rate=region.sample_rate, pitch=ratio * self.tune * self.bend, loop_start=loop_start, loop_end=loop_end, loop_mode=mode, - level=level * self.volume * pool.gain, + level=level * controllers * self.volume * pool.gain, pan=_panned(region.pan, self.pan), delay=region.delay, attack=region.attack * self.attack_scale, hold=region.hold, decay=region.decay, sustain=region.sustain, - release=region.release * self.release_scale) + release=region.release * self.release_scale, + cutoff=cutoff, resonance=resonance, + mod_delay=region.mod_delay, mod_attack=region.mod_attack, + mod_hold=region.mod_hold, mod_decay=region.mod_decay, + mod_sustain=region.mod_sustain, + mod_release=region.mod_release * self.release_scale, + mod_to_pitch=region.mod_to_pitch, + mod_to_cutoff=region.mod_to_cutoff if cutoff else 0, + vibrato_delay=region.vibrato_delay, + vibrato_rate=region.vibrato_rate, + vibrato_depth=region.vibrato_to_pitch + extra_vibrato, + lfo_delay=region.lfo_delay, lfo_rate=region.lfo_rate, + lfo_to_pitch=region.lfo_to_pitch, + lfo_to_cutoff=region.lfo_to_cutoff if cutoff else 0, + lfo_to_volume=region.lfo_to_volume) self.bank.hold(voices[i], region.sample.index) self.slots[i] = [key, serial, HELD, region.exclusive_class, - level, ratio, region.pan, self] + level, ratio, region.pan, self, + region.vibrato_to_pitch] indices.append(i) self.held[key] = indices if pool.reserve: @@ -1036,11 +1143,29 @@ def sustain(self, channel, down): for key in [k for k in self.sustained if k[0] == channel]: self.release(key) - def relevel(self): + def _controllers(self, channel): + """The gain Control Change 7 and 11 give `channel`.""" + return (_concave(self.volume_cc.get(channel, DEFAULT_VOLUME)) + * _concave(self.expression.get(channel, DEFAULT_EXPRESSION))) + + def relevel(self, channel=None): voices = self.pool.voice for i, slot in enumerate(self.slots): if slot is not None and slot[7] is self and slot[2] != FADED: - voices[i].level = slot[4] * self.volume * self.pool.gain + if channel is not None and slot[0][0] != channel: + continue + voices[i].level = (slot[4] * self._controllers(slot[0][0]) + * self.volume * self.pool.gain) + + def revibrato(self, channel): + """Give `channel`'s sounding notes the mod wheel and pressure's + vibrato over their region's own.""" + extra = self.wheel.get(channel, 0.0) + self.pressure.get(channel, 0.0) + voices = self.pool.voice + for i, slot in enumerate(self.slots): + if (slot is not None and slot[7] is self + and slot[0][0] == channel): + voices[i].vibrato_depth = slot[8] + extra def repitch(self): voices = self.pool.voice @@ -1086,6 +1211,9 @@ def handle_event(self, event_type, channel, note_id, data0, value0, elif event_type == EVENT_PITCH_BEND: self.bend = 2.0 ** (value1 * BEND_RANGE / 12.0) self.repitch() + elif event_type == EVENT_CHANNEL_PRESSURE: + self.pressure[channel] = value0 * PRESSURE_TO_VIBRATO + self.revibrato(channel) elif event_type == EVENT_CONTROL_CHANGE: if data0 == _CC_BANK_MSB: self.bank_msb = int(value0 * 127 + 0.5) @@ -1093,6 +1221,15 @@ def handle_event(self, event_type, channel, note_id, data0, value0, self.bank_lsb = int(value0 * 127 + 0.5) elif data0 == _CC_SUSTAIN: self.sustain(channel, int(value0 * 127 + 0.5) >= 64) + elif data0 == _CC_MOD_WHEEL: + self.wheel[channel] = value0 * MOD_WHEEL_TO_VIBRATO + self.revibrato(channel) + elif data0 == _CC_VOLUME: + self.volume_cc[channel] = int(value0 * 127 + 0.5) + self.relevel(channel) + elif data0 == _CC_EXPRESSION: + self.expression[channel] = int(value0 * 127 + 0.5) + self.relevel(channel) class SoundFontInstrument(Instrument): @@ -1151,6 +1288,9 @@ def program_change(self, index, channel=0, note_id=-1, sample_position=0): def reset(self): self._player.stop_all() self._player.bank_msb = self._player.bank_lsb = 0 + for state in (self._player.volume_cc, self._player.expression, + self._player.wheel, self._player.pressure): + state.clear() super().reset() @property diff --git a/tests/support/sf2_builder.py b/tests/support/sf2_builder.py index f84e280d..ab00b139 100644 --- a/tests/support/sf2_builder.py +++ b/tests/support/sf2_builder.py @@ -215,3 +215,49 @@ def tiny_bank(modulators=()): [(G.GEN_SAMPLE_MODES, 0), (G.GEN_INSTRUMENT, pad)], ]) return b + + +# The filter bank's one sample: a sawtooth with an 84-frame period (262.5 Hz +# at 22050 Hz, near C4), every harmonic present for a low-pass to take. +SAW_PERIOD = 84 + + +def _saw(periods): + return [int((i % SAW_PERIOD) * 600 - 25000) + for i in range(SAW_PERIOD * periods)] + + +def filter_bank(): + """A bank whose regions use the rest of a voice: one looped sawtooth, + played open, through a resonant low-pass, through one a modulation + envelope sweeps, and with a vibrato LFO. See + `tests/test_cpython_soundfont_modulation.py`.""" + b = Bank("Filter Test Bank") + G = sf2 + saw = b.sample("saw", _saw(40), 0, SAW_PERIOD * 40, pitch=60) + loop = (G.GEN_SAMPLE_MODES, 1) + open_ = b.instrument("Open", [[loop, (G.GEN_SAMPLE_ID, saw)]]) + # 9023 absolute cents is 1500 Hz; 60 centibels is 6 dB of resonance. + shut = b.instrument("Filtered", [ + [(G.GEN_FILTER_FC, 9023), (G.GEN_FILTER_Q, 60), loop, + (G.GEN_SAMPLE_ID, saw)]]) + # Closed at 600 Hz, opened three octaves by an envelope that decays to + # nothing over about half a second; a little pitch on the same envelope. + swept = b.instrument("Swept", [ + [(G.GEN_FILTER_FC, 7437), (G.GEN_MOD_ENV_TO_FILTER_FC, 3600), + (G.GEN_MOD_ENV_TO_PITCH, 100), (G.GEN_DECAY_MOD_ENV, -1200), + (G.GEN_SUSTAIN_MOD_ENV, 1000), (G.GEN_RELEASE_MOD_ENV, 0), + (G.GEN_ATTACK_MOD_ENV, -7973), (G.GEN_HOLD_MOD_ENV, -3986), + loop, (G.GEN_SAMPLE_ID, saw)]]) + # A 5 Hz vibrato of 30 cents after a quarter second, and a modulation + # LFO at 2 Hz on pitch, cutoff and volume. + lfo = b.instrument("LFOs", [ + [(G.GEN_VIB_LFO_TO_PITCH, 30), (G.GEN_FREQ_VIB_LFO, -851), + (G.GEN_DELAY_VIB_LFO, -2400), (G.GEN_MOD_LFO_TO_PITCH, -20), + (G.GEN_MOD_LFO_TO_FILTER_FC, 1200), (G.GEN_MOD_LFO_TO_VOLUME, -60), + (G.GEN_FREQ_MOD_LFO, -2438), loop, (G.GEN_SAMPLE_ID, saw)]]) + b.preset("Open", 0, 0, [[(G.GEN_INSTRUMENT, open_)]]) + b.preset("Filtered", 0, 1, [[(G.GEN_INSTRUMENT, shut)]]) + b.preset("Swept", 0, 2, [[(G.GEN_INSTRUMENT, swept)]]) + b.preset("LFOs", 0, 3, [[(G.GEN_INSTRUMENT, lfo)]]) + return b diff --git a/tests/test_cpython_sf2.py b/tests/test_cpython_sf2.py index d40f8e80..a8dd86c7 100644 --- a/tests/test_cpython_sf2.py +++ b/tests/test_cpython_sf2.py @@ -229,6 +229,43 @@ def test_modulators_are_noticed_not_applied(self): self.assertAlmostEqual(bank.regions(128, 0, 38, 100)[0].attenuation, 1.0) + def test_defaults_leave_a_region_plain(self): + # Nothing in the tiny bank uses the rest of a voice: no filter, a + # flat modulation envelope, no LFO depth. + r = self.one(0, 0, 40) + self.assertEqual(r.filter_cutoff, 13500) + self.assertEqual(r.resonance, 0.0) + self.assertEqual((r.mod_to_pitch, r.mod_to_cutoff), (0, 0)) + self.assertEqual((r.vibrato_to_pitch, r.lfo_to_pitch, + r.lfo_to_cutoff, r.lfo_to_volume), (0, 0, 0, 0.0)) + self.assertAlmostEqual(r.mod_sustain, 1.0) + self.assertAlmostEqual(r.mod_attack, 0.001, places=4) + self.assertAlmostEqual(r.vibrato_rate, 8.176, places=3) + self.assertAlmostEqual(r.lfo_delay, 0.001, places=4) + + def test_filter_modulation_envelope_and_lfos(self): + path = os.path.join(self.tmp, "filter.sf2") + sf2_builder.filter_bank().write(path) + with sf2.SoundFont(path) as bank: + r = bank.regions(0, 1, 60, 100)[0] + self.assertEqual((r.filter_cutoff, r.filter_q), (9023, 60)) + self.assertAlmostEqual(r.filter_hz, 1500, delta=2) + self.assertAlmostEqual(r.resonance, 6.0) + r = bank.regions(0, 2, 60, 100)[0] + self.assertEqual((r.mod_to_cutoff, r.mod_to_pitch), (3600, 100)) + self.assertAlmostEqual(r.mod_attack, 0.010, places=4) + self.assertAlmostEqual(r.mod_hold, 0.100, places=4) + self.assertAlmostEqual(r.mod_decay, 0.5, places=4) + self.assertAlmostEqual(r.mod_release, 1.0, places=4) + self.assertAlmostEqual(r.mod_sustain, 0.0) + r = bank.regions(0, 3, 60, 100)[0] + self.assertEqual(r.vibrato_to_pitch, 30) + self.assertAlmostEqual(r.vibrato_rate, 5.0, delta=0.01) + self.assertAlmostEqual(r.vibrato_delay, 0.25, places=4) + self.assertAlmostEqual(r.lfo_rate, 2.0, delta=0.01) + self.assertEqual((r.lfo_to_pitch, r.lfo_to_cutoff), (-20, 1200)) + self.assertAlmostEqual(r.lfo_to_volume, -6.0) + def test_not_a_soundfont(self): path = os.path.join(self.tmp, "junk.sf2") with open(path, "wb") as f: diff --git a/tests/test_cpython_soundfont.py b/tests/test_cpython_soundfont.py index fd0a34f2..26761529 100644 --- a/tests/test_cpython_soundfont.py +++ b/tests/test_cpython_soundfont.py @@ -220,7 +220,10 @@ def test_macros_reach_held_voices(self): self.assertAlmostEqual(voice.pitch, ratio * 2 ** (2 / 12.0), places=4) level = player.slots[slot][4] - self.assertAlmostEqual(voice.level, level * 100 / 127.0, places=5) + # Volume at 100 and Control Change 7 at its default 100. + self.assertAlmostEqual(voice.level, + level * 100 / 127.0 * (100 / 127.0) ** 2, + places=5) self.inst.set_macro(0, 0) self.assertEqual(voice.level, 0.0) self.inst.set_macro(1, 127) diff --git a/tests/test_cpython_soundfont_modulation.py b/tests/test_cpython_soundfont_modulation.py new file mode 100644 index 00000000..3129b066 --- /dev/null +++ b/tests/test_cpython_soundfont_modulation.py @@ -0,0 +1,186 @@ +"""`soundfont` playing the rest of what a bank says, on `sf2_builder`'s +filter bank, and the SoundFont 2.04 default modulators. + +Each trait is measured on what the instrument renders, with the bar beside +it: + +- A region that doesn't use its filter plays unfiltered at every velocity + (the voice gets no cutoff), and sounds the same brightness soft or hard. +- Velocity moves a filtered region's cutoff down to two octaves at + velocity 0, and a soft note comes out darker. The brightness of a + sawtooth through a plain low-pass goes about as the square root of the + cutoff, so 1500 Hz against velocity 40's 581 Hz is about 4.1 dB; the + 6 dB resonance adds to it, and 5.1 dB was measured. The bar is 3.5 to + 6.5 dB. +- The modulation envelope sweeps the cutoff: the first tenth of a second is + at least 6 dB brighter than the sustain, where the envelope has gone + (8.6 dB measured). +- The mod wheel and channel pressure each add up to 50 cents of vibrato to + the region's own, on held notes and new ones, and the output's pitch then + swings at least 60 cents peak to peak (88 measured, through 20 ms + windows) where it held within 5 (2.4 measured). +- Control Change 7 and 11 each scale the level by the square of their + fraction: Expression at 64 is 11.9 dB under 127, within 0.2 dB. + +The brightness measure is the RMS of the first difference over the RMS, +in dB, which takes level out. + +A fault planted to show these can fail (2026-10-29): velocity to cutoff +with its sign turned (`velocity / 127.0` for `1.0 - velocity / 127.0`) +failed the velocity, brightness and sweep traits; Control Change 7 and 11 +on a straight line rather than the square failed the level trait. +""" + +import math +import os +import pathlib +import shutil +import sys +import tempfile +import unittest + +import audioinstruments +import audioinstruments.soundfont as soundfont + +SUPPORT = pathlib.Path(__file__).resolve().parent / "support" +if str(SUPPORT) not in sys.path: + sys.path.insert(0, str(SUPPORT)) + +import sf2_builder # noqa: E402 +import sf2_play # noqa: E402 + +SR = 48000 +OPEN, FILTERED, SWEPT, LFOS = 0, 1, 2, 3 + + +def brightness_db(values): + """RMS of the first difference over the RMS, in dB.""" + diff = [values[i] - values[i - 1] for i in range(1, len(values))] + return 20.0 * math.log10(sf2_play.rms(diff) / sf2_play.rms(values)) + + +def pitch_swing_cents(values, window=960): + """Peak-to-peak pitch over `values`, in cents, from rising zero + crossings counted in `window`-frame windows.""" + rates = [] + for start in range(0, len(values) - window, window): + chunk = values[start:start + window] + crossings = [i for i in range(1, window) + if chunk[i - 1] < 0 <= chunk[i]] + if len(crossings) > 2: + rates.append((len(crossings) - 1) + / float(crossings[-1] - crossings[0])) + return 1200.0 * math.log2(max(rates) / min(rates)) + + +class SoundFontModulationTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp(prefix="sf2mod") + cls.path = os.path.join(cls.tmp, "filter.sf2") + sf2_builder.filter_bank().write(cls.path) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def setUp(self): + self.inst = audioinstruments.create("soundfont", SR, bank=self.path) + + def tearDown(self): + self.inst.deinit() + + def voice(self, key=60): + slot = self.inst._player.held[(0, key)][0] + return self.inst.output.voice[slot] + + def play(self, program, velocity, seconds, key=60): + self.inst.program_change(program) + self.inst.note_on(key, velocity) + out = sf2_play.render(self.inst, seconds, channel=0) + self.inst.note_off(key) + sf2_play.render(self.inst, 0.3) + return out + + def test_an_open_region_is_never_filtered(self): + self.inst.program_change(OPEN) + for velocity in (127, 30): + self.inst.note_on(60, velocity) + self.assertIsNone(self.voice().cutoff) + self.inst.note_off(60) + hard = brightness_db(self.play(OPEN, 127, 0.3)[4800:]) + soft = brightness_db(self.play(OPEN, 30, 0.3)[4800:]) + self.assertAlmostEqual(hard, soft, delta=0.1) + + def test_velocity_moves_the_cutoff(self): + self.inst.program_change(FILTERED) + for velocity in (127, 64, 1): + self.inst.note_on(60, velocity) + expected = 1500.0 * 2.0 ** (-2.0 * (1.0 - velocity / 127.0)) + self.assertAlmostEqual(self.voice().cutoff, expected, + delta=expected * 0.005) + self.inst.note_off(60) + + def test_velocity_changes_brightness(self): + hard = brightness_db(self.play(FILTERED, 127, 0.3)[4800:]) + soft = brightness_db(self.play(FILTERED, 40, 0.3)[4800:]) + self.assertGreater(hard - soft, 3.5) + self.assertLess(hard - soft, 6.5) + + def test_modulation_envelope_sweeps_the_cutoff(self): + out = self.play(SWEPT, 127, 1.2) + early = brightness_db(out[480:4800]) + late = brightness_db(out[48000:57600]) + self.assertGreater(early - late, 6.0) + + def test_wheel_and_pressure_add_vibrato(self): + self.inst.program_change(LFOS) + self.inst.note_on(60, 100) + voice = self.voice() + self.assertAlmostEqual(voice.vibrato_depth, 30.0) + self.inst.control_change(1, 127) + self.assertAlmostEqual(voice.vibrato_depth, 80.0) + self.inst.channel_pressure(127) + self.assertAlmostEqual(voice.vibrato_depth, 130.0) + self.inst.control_change(1, 0) + self.assertAlmostEqual(voice.vibrato_depth, 80.0) + self.inst.note_on(64, 100) + self.assertAlmostEqual(self.voice(64).vibrato_depth, 80.0) + self.inst.channel_pressure(0) + self.assertAlmostEqual(voice.vibrato_depth, 30.0) + + def test_wheel_vibrato_is_heard(self): + steady = pitch_swing_cents(self.play(OPEN, 100, 0.6)) + self.inst.control_change(1, 127) + swung = pitch_swing_cents(self.play(OPEN, 100, 0.6)) + self.assertLess(steady, 5.0) + self.assertGreater(swung, 60.0) + + def test_volume_and_expression_go_as_the_square(self): + full = sf2_play.rms(self.play(OPEN, 100, 0.2)[2400:]) + self.inst.control_change(11, 64) + half = sf2_play.rms(self.play(OPEN, 100, 0.2)[2400:]) + expected = 40.0 * math.log10(64 / 127.0) + self.assertAlmostEqual(20.0 * math.log10(half / full), expected, + delta=0.2) + # A held note follows, and Volume's default of 100 is in the level. + self.inst.control_change(11, 127) + self.inst.note_on(60, 100) + voice = self.voice() + default = voice.level + self.inst.control_change(7, 127) + self.assertAlmostEqual(default / voice.level, (100 / 127.0) ** 2, + places=5) + self.inst.reset() + self.assertEqual(self.inst._player.volume_cc, {}) + + def test_bank_modulator_defaults_are_the_specifications(self): + self.assertEqual(soundfont.VELOCITY_TO_CUTOFF, -2400) + self.assertEqual(soundfont.MOD_WHEEL_TO_VIBRATO, 50.0) + self.assertEqual((soundfont.DEFAULT_VOLUME, + soundfont.DEFAULT_EXPRESSION), (100, 127)) + + +if __name__ == "__main__": + unittest.main() From 8dc71af0c291ecc54a6a6a53ab1507c495f68e97 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:06:42 -0500 Subject: [PATCH 02/10] Move AUDIODSP_PIN to 9f078c4 for the sampler's filter and LFOs --- AUDIODSP_PIN | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 18b201ba..b222631f 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -446,5 +446,20 @@ # component builds on renders differently. The pyproject floors rise to # >=0.6.9. # +# Moved to 9f078c4 on 2026-10-29, for the sampler's filter, modulation +# envelope and LFOs (audiodsp#337), 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..9f078c4 -- 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. No node a component builds on renders differently, +# and a sampler voice that sets none of the new keywords renders the bytes +# it did. Re-run here at the pin: the SoundFont tests (sf2, soundfont, +# soundfont_modulation, and the General MIDI gate on FluidR3_GM), +# validate_api 55 instruments and 45 effects, flake8. +# # -- ref is what CI installs (git+https://...@) -v0.6.9 4de7609268d0ade9aaf652e232a0ffb1bae9e8ec +9f078c4d37a7909e8a37d1a07ae9ad674e5ec4fb 9f078c4d37a7909e8a37d1a07ae9ad674e5ec4fb From 2bee33843af86ff51b01441f7bcc1e3116dd21b5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:06:52 -0500 Subject: [PATCH 03/10] MultiTapDelay, AirSpace: a mono dry at unity is level 1.0 again Both handed a mono dry voice 1 - 2^-15 so CircuitPython's float mixer, which scaled by level / 32767, would pass it through exactly. CircuitPython 11.0.0-alpha.2 scales by (x * level) >> 15, and audiodsp follows it from 46f78dc: 1.0 is now the exact wire and 1 - 2^-15 is one LSB cold on every positive sample (MultiTapDelay's dry read 19999 for 20000, AirSpace's T1 dry was off on up to 11952 samples). Measured: both effects' test files pass at audiodsp 46f78dc and 0c0824e with level 1.0. --- lib/audioeffects/airspace.py | 13 ++++--------- lib/audioeffects/multitapdelay.py | 24 +++++++----------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/lib/audioeffects/airspace.py b/lib/audioeffects/airspace.py index e6f94f95..07b33901 100644 --- a/lib/audioeffects/airspace.py +++ b/lib/audioeffects/airspace.py @@ -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) diff --git a/lib/audioeffects/multitapdelay.py b/lib/audioeffects/multitapdelay.py index fb6d4499..985484af 100644 --- a/lib/audioeffects/multitapdelay.py +++ b/lib/audioeffects/multitapdelay.py @@ -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 From 8db508dff38bd52557122d4576ed460909de8b96 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:06:52 -0500 Subject: [PATCH 04/10] Harmonizer tests: model the integer mixer mixer_scale and pan_muls reproduced the float kernel (x * level / 32767, truncated; pan scaled from 32767). From audiodsp 46f78dc the Mixer is CircuitPython 11.0.0-alpha.2's: (x * level) >> 15, with pan from 32768. The model now follows it; T5 passes at 46f78dc and 0c0824e and fails at the commit before, so it still tells the two apart. --- tests/test_cpython_effects_harmonizer.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/tests/test_cpython_effects_harmonizer.py b/tests/test_cpython_effects_harmonizer.py index 17da57d6..3697d6cf 100644 --- a/tests/test_cpython_effects_harmonizer.py +++ b/tests/test_cpython_effects_harmonizer.py @@ -177,20 +177,11 @@ def gains(mix): return (1.0 if mix <= 1.0 else 2.0 - mix), min(mix, 1.0) -def f32(x): - """`x` rounded to single precision.""" - import struct - return struct.unpack("f", struct.pack("f", x))[0] - - def mixer_scale(x, mul): """One sample through the Mixer's voice multiply at integer level `mul` - (32768 is unity): times `mul / 32767` in single precision, truncated - toward zero, clamped.""" - if mul == 32768: - return x - y = int(f32(x * f32(mul / 32767.0))) - return max(-32768, min(32767, y)) + (32768 is unity): CircuitPython 11.0.0-alpha.2's integer kernel, + (x * mul) >> 15, which rounds toward minus infinity, clamped.""" + return max(-32768, min(32767, (x * mul) >> 15)) def level_int(gain): @@ -199,16 +190,16 @@ def level_int(gain): def pan_muls(level, pan): """(left, right) integer levels of a stereo voice at gain `level` and - pan `pan`: a pan right of centre attenuates the left, and the side that - is not attenuated still runs at 32767/32768 of the level at centre.""" + pan `pan`: a pan right of centre attenuates the left, by 32768 - pan in + Q15, and the side that is not attenuated keeps the full level.""" p = int(round(min(32767.0 / 32768, max(-32767.0 / 32768, pan)) * 32768.0)) lvl = level_int(level) left, right = 32768, 32768 if p >= 0: - left = 32767 - p + left = 32768 - p else: - right = 32767 + p + right = 32768 + p return (left * lvl) >> 15, (right * lvl) >> 15 From e76adf73683cb29bbaaa844def369e7b6dc7fa08 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:06:52 -0500 Subject: [PATCH 05/10] AirSpace: re-read the get_buffer render digests for the integer mixer The digests anchor the renders from before _replay was taken out. The class's code is unchanged; the old digests still reproduce on audiodsp 1877bb9, the commit before the integer mixer (46f78dc), and the new ones, the same at 46f78dc and 0c0824e, keep the same scenarios equal to each other as the old ones did. --- tests/test_cpython_effects_airspace.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test_cpython_effects_airspace.py b/tests/test_cpython_effects_airspace.py index 05e0246b..6f92d9a4 100644 --- a/tests/test_cpython_effects_airspace.py +++ b/tests/test_cpython_effects_airspace.py @@ -787,8 +787,6 @@ def test_the_mix_law(self): with self.subTest(channels=channels, mix=mix): set_value(effect, MIX_I, mix) dry, wet = law_levels(mix) - if channels == 1 and dry == 1.0: - dry = 1.0 - 2.0 ** -15 self.assertAlmostEqual(voices[0].level, dry) self.assertAlmostEqual(voices[1].level, wet) effect.deinit() @@ -1202,15 +1200,20 @@ def moves_render(data, rate, channels, pulls, actions, hidden): #: sha1 of `moves_render` at 48 kHz stereo, 40 pulls, LCG noise at 4000 rms #: seed 11, with `get_buffer` present, per scenario in the order above: #: what the class rendered before `_replay` was taken out (07bf68a). +#: Re-read once, when audiodsp took CircuitPython 11.0.0-alpha.2's integer +#: mixer ((x * level) >> 15 for x * level / 32767 truncated): the class's +#: code was the same on both sides, the old digests still reproduce on the +#: audiodsp commit before the change, and the new ones keep the same +#: scenarios equal to each other as the old ones did. GET_BUFFER_RENDERS = ( - "c80ffad8df5fe4a5ae16ec41c51517575377f23c", - "2b4ab24f79b4dfd68f815bdff5f0e5b7e152e055", - "2b4ab24f79b4dfd68f815bdff5f0e5b7e152e055", - "7e03a853d8fddfa0fbc4b9f7efe034611c2fadd9", - "c80ffad8df5fe4a5ae16ec41c51517575377f23c", - "bc5deee2233815d6a61d8db1db2700ac1f5b5676", - "ae7ef874a87e15d9bbc1d6800aaa48015969c45e", - "2b4ab24f79b4dfd68f815bdff5f0e5b7e152e055", + "ee269d59de182b592e1039d0285f5e27b3238b71", + "34021bf776f3789e3813afc73094bb9bef3ecb58", + "34021bf776f3789e3813afc73094bb9bef3ecb58", + "6062fc883a56ca4767f0de40934c9f0accba8f55", + "ee269d59de182b592e1039d0285f5e27b3238b71", + "3621908f5c28db1295ffe8d2c3e2c0c9696d358a", + "08b65156619b4852fa5c0be1ebffc68a1f117cc0", + "34021bf776f3789e3813afc73094bb9bef3ecb58", ) From bcb2f1900ad5f925ee16e77ce5f02b9d3bab3af6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:12:03 -0500 Subject: [PATCH 06/10] Move AUDIODSP_PIN to 0c0824e, audiodsp#337 as merged The sampler's filter, modulation envelope and LFOs merged as 0c0824e. The pin note now says what renders differently beneath the components: the 11.0.0-alpha.2 integer mixer from 46f78dc. --- AUDIODSP_PIN | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index b222631f..58c0e81a 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -446,20 +446,25 @@ # component builds on renders differently. The pyproject floors rise to # >=0.6.9. # -# Moved to 9f078c4 on 2026-10-29, for the sampler's filter, modulation -# envelope and LFOs (audiodsp#337), 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..9f078c4 -- 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. No node a component builds on renders differently, -# and a sampler voice that sets none of the new keywords renders the bytes -# it did. Re-run here at the pin: the SoundFont tests (sf2, soundfont, -# soundfont_modulation, and the General MIDI gate on FluidR3_GM), -# validate_api 55 instruments and 45 effects, flake8. +# 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 is what CI installs (git+https://...@) -9f078c4d37a7909e8a37d1a07ae9ad674e5ec4fb 9f078c4d37a7909e8a37d1a07ae9ad674e5ec4fb +0c0824e3966998613d8c5314cefb1d752bc70693 0c0824e3966998613d8c5314cefb1d752bc70693 From f675e70f4e9be2f7a7689567ec710e14ddca3f9d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:07:44 -0500 Subject: [PATCH 07/10] StereoWidener: a hard pan exact again on the integer mixer CircuitPython 11's integer mixer clamps a voice's panning to 32767/32768, so the far channel of a hard-panned voice at level 1 keeps a scale of 1 and gets x >> 15: -1 on every negative sample. At Width 1 that put -1 in the left wherever the delayed left was negative, and -1 in the right wherever the source's right was negative, so the copy was no longer the right channel exactly Delay late. No pan or level gives an exact near channel and a silent far one, so the class now cancels the -1 instead. A branch multiplies the source by 1 and then by -32768 (audiomath.Multiply), which is +1 on each channel whose sample is negative and 0 otherwise, exactly. The line is fed from a small mixer that adds that +1 to the source panned hard right, so its left is an exact 0 and the copy voice has nothing to leave there; a third voice adds it to the right while the lead sits at unity hard left. Widths between 0 and 1 render as before. The CopyFromMid plant now centres the line's feed as well, so the line still folds both channels into the copy. --- lib/audioeffects/stereowidener.py | 75 ++++++++++++++++++--- tests/test_cpython_effects_stereowidener.py | 7 +- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/lib/audioeffects/stereowidener.py b/lib/audioeffects/stereowidener.py index 8aa8adbc..59f0fbd2 100644 --- a/lib/audioeffects/stereowidener.py +++ b/lib/audioeffects/stereowidener.py @@ -16,10 +16,11 @@ try: import audioecho + import audiomath import audiomixer import audioroute except ImportError: # pragma: no cover - a stock board - audioecho = audiomixer = audioroute = None + audioecho = audiomath = audiomixer = audioroute = None #: The Delay span, in ms, swept by ratio. @@ -33,10 +34,23 @@ #: 3/16, exact in every float format, a pitch bend of x1.1875 while it moves. SLEW = 0.1875 -#: The hardest a Mixer voice pans: the right (or left) scale is then exactly -#: 0, where a panning of -1 or +1 leaves one LSB of it. +#: The hardest a Mixer voice pans. The integer mixer clamps any panning to +#: this, and its far channel keeps a scale of 1/32768: a voice at level 1 +#: then hands that channel `x >> 15`, which is -1 for every negative sample +#: and 0 otherwise. `SIGN` and `NEGATE` build the +1 that cancels it. HARD = 32767.0 / 32768.0 +#: The two modulators the cancelling branch multiplies by, in +#: `audiomath.Multiply`'s Q15: 1 turns a sample into the far channel's +#: `x >> 15` (-1 below zero, else 0), and -32768 turns that into +1 or 0, +#: exactly. +SIGN = 1 +NEGATE = -32768 + +#: Frames in each modulator table: more than one, so the native mixer and +#: Multiply never take a table shorter than the word they consume. +TABLE_FRAMES = 16 + #: The frames a mixer pull hands out, and so the frames each voice and each #: Splitter tap take per pull. BLOCK = 256 @@ -143,7 +157,7 @@ class StereoWidener(_component.Component): VERSION = '0.1.0' TIER = _component.AUDIODSP - REQUIRES = ("audioecho", "audioroute") + REQUIRES = ("audioecho", "audiomath", "audioroute") CAPABILITIES = () LATENCY_SAMPLES = 0 @@ -235,17 +249,41 @@ def _build_graph(self): self._hush = self._own(audioroute.MidSide( width=1.0, sample_rate=rate, channel_count=2), reset=False) self._in = self._own(audioroute.Port(self._hush), reset=False) - self._split = self._own(audioroute.Splitter(self._in, taps=2), + # Tap 0 is the lead, tap 1 what the line delays, tap 2 the + # cancelling branch. + self._split = self._own(audioroute.Splitter(self._in, taps=3), reset=False) + # The cancelling branch: +1 on each channel whose sample is below + # zero, else 0. A hard-panned voice at level 1 leaves -1 in its far + # channel on exactly those samples (`HARD`), and this puts it back. + self._sign = self._own(audiomath.Multiply( + self._split.tap(2), self._table(SIGN), mix=1.0, + sample_rate=rate, channel_count=2), reset=False) + self._negate = self._own(audiomath.Multiply( + self._sign, self._table(NEGATE), mix=1.0, + sample_rate=rate, channel_count=2), reset=False) + self._fix = self._own(audioroute.Splitter(self._negate, taps=2), + reset=False) + # What the line delays: the right channel, and an exact zero in the + # left, so the copy's hard pan has nothing negative to leave there. + self._feed = self._own(audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * 2 * 4, channel_count=2, + bits_per_sample=16, samples_signed=True, sample_rate=rate), + reset=False) + # The Mixer's own reset never reaches it, for the reason `_tail` + # gives. + self._fed = self._own(audioroute.MidSide( + self._feed, width=1.0, sample_rate=rate, channel_count=2), + reset=False) self._line = self._own(audioecho.FeedbackDelay( sample_rate=rate, channel_count=2, max_delay_ms=LINE_MS, delay_frames=1, feedback=0.0, mix=2.0, damping_hz=0.0, cut_hz=0.0, delay_slew=SLEW), reset=self._clear) - self._line.play(self._split.tap(1)) + self._line.play(self._fed) # Not reset by the walk: neither voice holds a block between two # pulls (`_wire`), so there is nothing of its own to clear. self._mixer = self._own(audiomixer.Mixer( - voice_count=2, buffer_size=BLOCK * 2 * 4, channel_count=2, + voice_count=3, buffer_size=BLOCK * 2 * 4, channel_count=2, bits_per_sample=16, samples_signed=True, sample_rate=rate), reset=False) # The Haas output: a width-1 MidSide, byte for byte, whose reset @@ -264,6 +302,14 @@ def _build_graph(self): self._puller = None self._output = self._ms + def _table(self, value): + """A looping stereo modulator that is `value` in both channels.""" + table = audiocore.RawSample( + array("h", [value] * (2 * TABLE_FRAMES)), + sample_rate=self._sample_rate, channel_count=2) + self._tables = getattr(self, "_tables", ()) + (table,) + return table + def _wire(self): """Play the voices, the first time the output reaches the mixer. @@ -277,12 +323,20 @@ def _wire(self): block read at a Delay the line has since left. The level gates are opened first, as `_component.open_level_gates` opens them.""" voices = self._mixer.voice + feeds = self._feed.voice self._levels() - for voice in voices: + # The line's voices: the source panned hard right, and the + # cancelling branch's left, which together leave the left at 0. + feeds[0].level, feeds[0].panning = 1.0, HARD + feeds[1].level, feeds[1].panning = 1.0, -HARD + for voice in voices + feeds: voice.play(self._silence, loop=True) self._pull() + feeds[0].play(self._split.tap(1)) + feeds[1].play(self._fix.tap(0)) voices[0].play(self._split.tap(0)) voices[1].play(self._line) + voices[2].play(self._fix.tap(1)) self._pull() self._in.play(self._ms) self._clear() @@ -350,6 +404,11 @@ def _levels(self): voices = self._mixer.voice voices[0].level, voices[0].panning = lead voices[1].level, voices[1].panning = copy + # The lead at unity panned hard left leaves -1 in the right on + # every negative sample; the cancelling branch, panned hard right, + # adds it back. Anywhere else the lead's right is a real level. + voices[2].level = 1.0 if lead == (1.0, -HARD) else 0.0 + voices[2].panning = HARD # -- applying ------------------------------------------------------ diff --git a/tests/test_cpython_effects_stereowidener.py b/tests/test_cpython_effects_stereowidener.py index e3dcacc9..eff32424 100644 --- a/tests/test_cpython_effects_stereowidener.py +++ b/tests/test_cpython_effects_stereowidener.py @@ -172,12 +172,17 @@ def render(cls, values, rate, channels=2, frames=None, moves=None, class CopyFromMid(StereoWidener): - """H1: the copy is built from the mid (`input_pan=+1`), not the right.""" + """H1: the copy is built from the mid (`input_pan=+1`), not the right: + the line is fed both channels, and folds them into its right.""" def _build_graph(self): StereoWidener._build_graph(self) self._line.set(input_pan=1.0) + def _wire(self): + StereoWidener._wire(self) + self._feed.voice[0].panning = 0.0 + class BackwardsPan(StereoWidener): """H2: the lead voice's panning sign flipped, so it turns the left down From 5b6dd93fba47e259d012a310607c8f8dc6a2d149 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:07:44 -0500 Subject: [PATCH 08/10] DeEsser: D1 now holds at Guitar Pick Noise The integer mixer rounds the -55 dBFS probe's few-LSB dry voice where the float mixer truncated it toward zero, so the level spread fell at every patch. Patch 3 (HF-only) went from 2.262 dB to 0.583 dB and meets D1's 1 dB; patch 4 went from 2.479 dB to 1.321 dB and still misses it. The test now records the miss at patch 4 only, and the module's figures are the integer mixer's: 0.213 dB over -6..-40 dBFS, 1.176 dB at worst with the -55 dBFS take. --- lib/audioeffects/deesser.py | 15 +++++++------ tests/test_cpython_effects_deesser.py | 32 ++++++++++++++------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/lib/audioeffects/deesser.py b/lib/audioeffects/deesser.py index 8933b48c..ed338e29 100644 --- a/lib/audioeffects/deesser.py +++ b/lib/audioeffects/deesser.py @@ -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 diff --git a/tests/test_cpython_effects_deesser.py b/tests/test_cpython_effects_deesser.py index e9309d5b..efa9d334 100644 --- a/tests/test_cpython_effects_deesser.py +++ b/tests/test_cpython_effects_deesser.py @@ -72,12 +72,16 @@ #: patch and cannot dial it (`FrozenCrossover` in `KitChecks`). FROZEN_CORNER_HZ = 500.0 -#: The two patches that ship in HF-only mode. D1's level independence is -#: recorded disconfirmed at both: the crossover is in the audio path there, -#: and the low band it leaves alone carries no reduction, so the balance the +#: The patch at which D1's level independence is recorded disconfirmed: +#: Cymbal Edge, HF-only. The crossover is in the audio path there, and the +#: low band it leaves alone carries no reduction, so the balance the #: detector reads moves with the programme level in a way broadband mode's -#: single stream does not. Measured in `PatchSweep`. -DEESS_HF_ONLY_PATCHES = (3, 4) +#: single stream does not. Measured in `PatchSweep`: 1.321 dB over -6..-55 +#: dBFS. The other HF-only patch, Guitar Pick Noise (3), holds D1 at +#: 0.583 dB since audiodsp took CircuitPython 11's integer mixer, which +#: rounds the -55 dBFS probe's few-LSB dry voice where the float mixer +#: truncated it toward zero (2.262 dB there). +D1_MISSED_PATCHES = (4,) #: The Release-macro fault, as a multiplier on what the class pushes into #: the node. 250 puts every position of the 1-200 ms span past 200 ms, so @@ -766,8 +770,8 @@ def cell(settings): # And the bound the verdict carries: drop the -55 dBFS probe and the # spread collapses at every Sensitivity. The trait holds from -6 to - # -40 dBFS; what fails is the bottom probe, whose own dry blend - # already spreads 0.643-1.253 dB with the gain cell muted (D7). + # -40 dBFS; what fails is the bottom probe, whose dry blend is a + # handful of LSB that the int16 mixer rounds. worst_loud = max(loud.values()) print(" D1 over -6..-40 dBFS alone: worst %.3f dB at Sensitivity " "%.1f dB" % (worst_loud, @@ -1540,17 +1544,15 @@ def _build(self, *arguments, **keywords): del release_position # What the table is held to, and it is not "all green". D1 is - # recorded disconfirmed above Sensitivity 34 dB and it misses at the - # two HF-only patches, which is the disconfirmation meeting a + # recorded disconfirmed above Sensitivity 34 dB and it misses at + # HF-only patch 4, which is the disconfirmation meeting a # setting the class's own author published - the template's "not a # corner case, it is the product". The rows below are the verdict # this pack carries, so a change in either direction fails here. - broadband = [index for index in table - if index not in DEESS_HF_ONLY_PATCHES] - for index in broadband: - self.assertLess(table[index][0], 1.0, - "D1 at broadband patch %d" % index) - for index in DEESS_HF_ONLY_PATCHES: + held = [index for index in table if index not in D1_MISSED_PATCHES] + for index in held: + self.assertLess(table[index][0], 1.0, "D1 at patch %d" % index) + for index in D1_MISSED_PATCHES: self.assertGreater(table[index][0], 1.0, "D1 is recorded disconfirmed at HF-only " "patch %d" % index) From 5087642ca4b02a13c6f6f6c89ee1980eab7f63b7 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:28:28 -0500 Subject: [PATCH 09/10] Overdrive: take the offset off ahead of the output pole The output pole's float32 state stalls short of zero when it holds a large constant: at 44.1 kHz shipped patch 6's 5666 LSB offset left it resting at -0.7 LSB, which the integer Level stage floors to a standing -1 LSB in silence, and a burst of signal leaves it elsewhere in that band. A second Waveshaper at x1 on a two-point straight line, at a post_gain of 32768/32767, plays x + bias back exactly for every int16 x. It subtracts the offset before the pole, so the pole holds a few LSB. Silence after a noise or sine burst is now 0 at every shipped patch at 48, 44.1, 22.05 and 96 kHz. Patch 6 renders within 4 LSB of before and no other patch moves; patch 7 still reaches the rail at 0 dBFS. Two Overdrive E10 rows in the lifecycle ledger go from 4 red cells to 2. --- lib/audioeffects/overdrive.py | 42 ++++++++++++++++++++++++++++++-- tests/support/lifecycle_known.py | 4 +-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/lib/audioeffects/overdrive.py b/lib/audioeffects/overdrive.py index dfd36751..a167cbaf 100644 --- a/lib/audioeffects/overdrive.py +++ b/lib/audioeffects/overdrive.py @@ -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 @@ -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 @@ -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 @@ -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( @@ -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) @@ -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) @@ -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. @@ -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) @@ -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) diff --git a/tests/support/lifecycle_known.py b/tests/support/lifecycle_known.py index 86cfd75f..08c3d7db 100644 --- a/tests/support/lifecycle_known.py +++ b/tests/support/lifecycle_known.py @@ -256,8 +256,8 @@ ('Notch', 'E9-', 'P4'): (35, '8596d61b', None, None, R_STATE), ('Overdrive', 'E1-reset@block', 'P4'): (34, 'aaf11cd2', None, None, R_STATE), ('Overdrive', 'E1-reset@part', 'P4'): (34, '75164359', None, None, R_STATE), - ('Overdrive', 'E10-macros', 'P4'): (4, 'ca627259', None, None, R_STATE), - ('Overdrive', 'E10-patch', 'P4'): (4, 'a20f495c', None, None, R_STATE), + ('Overdrive', 'E10-macros', 'P4'): (2, '0af84d6b', None, None, R_STATE), + ('Overdrive', 'E10-patch', 'P4'): (2, '9ee38023', None, None, R_STATE), ('Overdrive', 'E10-reset', 'P4'): (4, '34caff5e', None, None, R_STATE), ('Overdrive', 'E11-', 'P4'): (8, '294ebbde', None, None, R_STATE), ('Overdrive', 'E11-', 'P5'): (2, '51122eee', None, None, R_JUMP), From a260329bb41ef974c2164141f965124e708ce421 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:51:13 -0500 Subject: [PATCH 10/10] Distortion: set the Rat's leg levels where they round The Rat's two leg levels, (1+a)/total and b/total, sit below unity ahead of up to 67 dB of gain. The integer mixer floors x * level, which is half an LSB of DC on most samples: at Distortion max a 4 LSB input stood at -0.070 of full scale and a -40 dBFS one at -0.017, where the float mixer read 0 and -0.0003. The levels now sit on two x1 Waveshaper level nodes, on a two-point straight line at a post_gain of level * 32768/32767, which round to the nearest LSB. The mixer voices stay at an exact 1. DC at low input is back to the float mixer's figures, F2's contour and the reset residue pass again, and the clean class renders the same on both mixers to a fraction of an LSB of DC. The DS-1 graph's Distortion 0 now reads -70.8 dB THD where the float mixer's truncation gave -60.8, which puts it inside F4's row at Character 64 and up. F4's two guard sweeps leave those positions out, because the faults are planted in the Rat's _push_filter and the DS-1 graph never calls it. The leg levels now move at the block instead of at a zero crossing, so two Distortion P5 rows in the lifecycle ledger change: E4 goes from 12 red cells to 13, and E6 keeps 77 with a new digest. --- lib/audioeffects/distortion.py | 42 ++++++++++++++++++++---- tests/support/lifecycle_known.py | 4 +-- tests/test_cpython_effects_distortion.py | 27 ++++++++++++--- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index 9130d968..b9b2e93a 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -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. @@ -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 @@ -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 @@ -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) @@ -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 @@ -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, diff --git a/tests/support/lifecycle_known.py b/tests/support/lifecycle_known.py index 08c3d7db..763645a1 100644 --- a/tests/support/lifecycle_known.py +++ b/tests/support/lifecycle_known.py @@ -132,11 +132,11 @@ ('Distortion', 'E11-', 'P4'): (4, 'b2d5e10e', None, None, R_STATE), ('Distortion', 'E11-', 'P5'): (12, '5be4892b', None, None, R_JUMP), ('Distortion', 'E4-', 'P4'): (8, '8fd46470', None, None, R_STATE), - ('Distortion', 'E4-', 'P5'): (12, '30b1a8d2', None, None, R_JUMP), + ('Distortion', 'E4-', 'P5'): (13, '82c778dc', None, None, R_JUMP), ('Distortion', 'E5-mix0', 'P4'): (44, '14ea31c6', None, None, R_STATE), ('Distortion', 'E5-mix0', 'P5'): (38, '8e927361', None, None, R_JUMP), ('Distortion', 'E6-', 'P4'): (46, '365ceaaa', None, None, R_STATE), - ('Distortion', 'E6-', 'P5'): (77, '18923193', None, None, R_JUMP), + ('Distortion', 'E6-', 'P5'): (77, '842e56c5', None, None, R_JUMP), ('Distortion', 'E9-', 'P4'): (236, 'f3a8ddc3', None, None, R_STATE), ('Distortion', 'E9-', 'P5'): (174, '91062d1d', None, None, R_JUMP), ('DynamicEQ', 'E1-reset@block', 'P4'): (28, '0b10ffa7', None, None, R_STATE), diff --git a/tests/test_cpython_effects_distortion.py b/tests/test_cpython_effects_distortion.py index 9afba2b7..776e6820 100644 --- a/tests/test_cpython_effects_distortion.py +++ b/tests/test_cpython_effects_distortion.py @@ -915,12 +915,18 @@ class TestDistortionGuardsAreNotOnTheSurface(unittest.TestCase): GRID = (0, 16, 32, 48, 64, 80, 96, 112, 127) def _sweep(self, criterion, faulted, held=(), patches=None, - scoop=False): + scoop=False, rat_only=False): """Walk every macro the row does not hold, plus the patches. Returns `(restored, checked, claimed)`: positions where the fault passes the row **and** the clean class passes it, how many were visited, and how many of those the clean class holds. + + `rat_only` leaves out the Character positions that build the DS-1 + graph instead (64 and up), for a fault planted in the Rat's + `_push_filter`, which that graph never calls: there the faulted + build is the clean one, and it can only pass where the clean one + does. """ restored, checked, claimed = [], 0, 0 options = {"character": 1.0} if scoop else {} @@ -928,6 +934,8 @@ def _sweep(self, criterion, faulted, held=(), patches=None, if index in held: continue for position in self.GRID: + if rat_only and index == CHAR and position >= 64: + continue checked += 1 clean = _reading(criterion, Distortion, macros={index: position}, **options) @@ -977,14 +985,23 @@ def test_always_driven_is_red_everywhere_f4_is_claimed(self): `pre_gain` at 8.0, which Body 64 and shipped patch 5 reach from the panel - so the default clause cannot tell them apart there - but the row also says Distortion 0 is clean, and a forced drive is not - clean at Distortion 0 anywhere.""" - restored, checked, claimed = self._sweep(_f4_at, AlwaysDriven) - self.assertEqual(checked, 101) + clean at Distortion 0 anywhere. + + Character 64 and up is the DS-1 graph, which the fault does not + touch. The clean class failed F4 there until the integer mixer: + Distortion 0 read -60.8 dB THD on the float mixer, whose truncation + toward zero added the distortion, and reads -70.8 dB now, inside + the row. So those five positions are left out, not counted green. + """ + restored, checked, claimed = self._sweep(_f4_at, AlwaysDriven, + rat_only=True) + self.assertEqual(checked, 96) self.assertGreater(claimed, 40) self.assertEqual(restored, []) def test_no_drive_is_red_everywhere_f4_is_claimed(self): - restored, _checked, claimed = self._sweep(_f4_at, NoDrive) + restored, _checked, claimed = self._sweep(_f4_at, NoDrive, + rat_only=True) self.assertGreater(claimed, 40) self.assertEqual(restored, [])