Skip to content

soundfont: play a region's filter, modulation envelope and LFOs, with the default modulators - #243

Merged
bdbarnett merged 10 commits into
mainfrom
sampler/phase5
Oct 29, 2026
Merged

bdbarnett merged 10 commits into
mainfrom
sampler/phase5

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

soundfont and soundfont_drums now play the rest of what a bank says about a note. That is the region's low-pass filter, its modulation envelope (on pitch and cutoff), its vibrato LFO, and its modulation LFO (on pitch, cutoff and volume). It needs audiodsp#337, and AUDIODSP_PIN moves to that branch's head.

The default modulators (SoundFont 2.04, section 8.4)

  • Velocity to attenuation: the existing square law.
  • Velocity to cutoff: down to two octaves lower at velocity 0, so a soft note is darker.
    • This applies only to a region whose own filter is below 13500 cents. A region that doesn't use its filter stays unfiltered at every velocity, on the sampler's cheaper path.
    • The specification applies it to every region, so this is a deliberate departure, and the docstring says so.
  • Mod wheel (CC 1) and channel pressure: each adds up to 50 cents of vibrato, on held notes and new ones.
  • Volume (CC 7) and Expression (CC 11): each scales the level by the square of its value. They start at General MIDI's 100 and 127.
    • A channel that never sends CC 7 now plays about 4 dB quieter than before.

A bank's own modulators (pmod/imod) are still not applied, and the docstring and README say so.

Tests

The reader's tests read every new generator from a bank the suite writes. tests/test_cpython_soundfont_modulation.py measures what the instrument renders from that bank:

  • An unfiltered region is the same brightness soft and hard.
  • Velocity 40 is 5.1 dB darker than 127 on a filtered region (bar 3.5 to 6.5).
  • The modulation envelope's sweep is 8.6 dB brighter early than late (bar 6).
  • The mod wheel swings the pitch 88 cents peak to peak, against 2.4 without (bars 60 and 5).
  • Expression at 64 is 11.9 dB down, within 0.2 dB.

Two planted faults each failed tests:

  • The velocity-to-cutoff sign turned around failed three traits.
  • CC 7 and 11 on a straight line instead of the square failed the level trait.

Checks run

  • The General MIDI gate on FluidR3_GM passes: all 128 programs sound, and the pitched presets are within 1%.
  • validate_api and the effects library smoke are clean.
  • Ostinato's SoundFont check passes on the desktop app against this branch: all four tracks are heard, at -23 to -32 dBFS.
  • The GM gate's 16 held notes take 80 µs of a 5333 µs block, up from 57 µs, because most FluidR3 regions use their filter.

… 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.
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.
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.
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.
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.
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.
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.
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.
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.
@bdbarnett
bdbarnett merged commit 6773c89 into main Oct 29, 2026
7 checks passed
@bdbarnett
bdbarnett deleted the sampler/phase5 branch October 29, 2026 23:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant