Skip to content

audiosampler: keep the filter and LFO state out of the voice table - #355

Merged
bdbarnett merged 4 commits into
mainfrom
fix/sampler-voice-memory
Oct 31, 2026
Merged

bdbarnett merged 4 commits into
mainfrom
fix/sampler-voice-memory

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

Since the sampler gained its low-pass filter, modulation envelope and LFOs (#337), every voice slot carried their state whether a note used them or not. A voice grew from 112 to 256 bytes, and a sampler allocates two slots per voice (the voice and the tail that fades its last note) in one block. Sampler(voice_count=32) went from 16 KB to 27 KB, and its voice table from 7 KB to 16 KB.

That broke SoundFont instruments on a crowded heap. In an 8 MB MicroPython heap, once a General MIDI bank (FluidR3_GM.sf2) has loaded, the 16 KB table no longer fits in any free block. The instrument raises MemoryError: memory allocation failed, allocating 16384 bytes, and its notes are silent.

What changes

The filter, envelope and LFO state moves out of the voice into its own record, audiodsp_sampler_mod_t. A voice gets one pair of records (its own and its tail's, 272 bytes on a 64-bit build) the first time it needs them: a play() that sets any of those arguments away from its default, a cutoff set on the voice, or a nonzero vibrato_depth. The voice keeps the pair after that. A voice that never asks carries none, and a sampler whose voices all ask allocates 32 small pieces rather than one large one.

The voice's byte-sized fields (stage, loop_mode, looping, mods) now share the padding before position. A plain voice is 112 bytes on a 32-bit board, the same as before #337, and 120 on a 64-bit desktop. A size check in audiodsp_sampler.c fails the build if the voice grows past that.

Measured on the unix port (64-bit):

v0.6.11 this PR before #337
voice slot 256 B 120 B (112 B on 32-bit) 112 B
Sampler(voice_count=32), stereo 27328 B 18624 B 16064 B
Sampler(voice_count=32), mono 25696 B 16992 B
Sampler(voice_count=128), stereo 99424 B 64608 B
records once all 32 voices use a filter (included) 7168 B, in 32 blocks

Renders don't change

A record attached to a voice that is already sounding starts as play() would have left it for a note that asked for none of this. The note goes on rendering exactly as it would have.

  • tests/parity/sampler_probe.py gives byte-identical output before and after the change on CPython and on the unix MicroPython build, and the two agree. Its closed section turns a filter on live, on a voice that had played plainly.
  • A second script covers paths the probe doesn't. It hands modulated and plain notes to their tails, plays a plain note on a voice that already has a record, attaches a record mid-note through vibrato_depth, and runs mono and stereo. Its output is byte-identical before and after on both runtimes. Two planted faults each changed its output: not copying the record to the tail, and a wrong default in a new record.
  • tests/test_cpython_audiosampler.py, tests/test_cpython_audiosampler_modulation.py and tests/test_binding_parity.py pass.
  • The CircuitPython bindings compile with -Wall -Werror against CircuitPython 11's unix headers. No CircuitPython build was run.

With this change alone, the mpvst unix engine at an 8 MB heap loads FluidR3_GM.sf2 and plays: SoundFont note 60 peaks at 11094 and SoundFont Drums note 36 at 13222. Forty notes held at once leave 2 MB free.

@bdbarnett
bdbarnett merged commit 4f6f4b2 into main Oct 31, 2026
31 checks passed
@bdbarnett
bdbarnett deleted the fix/sampler-voice-memory branch October 31, 2026 03:06
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