From 386c7c390c3d1ce3a1fcfa2989200b503b0860f6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:23:20 -0500 Subject: [PATCH] audiosampler: raise the voice ceiling from 64 to 128 A dense General MIDI piece holds more than 60 keys down at once, and the tails of the notes just let go push the voices sounding higher still. At 64 the sampler had to cut notes still held, and shorten many tails that were still audible; at 128 neither piece in the check needed a held note cut. The ceiling costs nothing until a voice sounds: the table is sized to the voice_count asked for, and an idle voice is skipped. Tests: 128 voices build and sum, the first 64 of a table of 128 render what a table of 64 does, a steal on the last voice fades the old note, and 128 voices render inside the block. The roadmap bullet is done. --- ROADMAP.md | 3 - docs/upstream-diff.md | 16 ++++- .../shared-bindings/audiosampler/Sampler.c | 2 +- .../shared-bindings/audiosampler/__init__.c | 2 +- src/shared/audiodsp_sampler.h | 10 ++- tests/test_cpython_audiosampler.py | 72 +++++++++++++++++++ 6 files changed, 94 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 482feed5..b21ad47d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,9 +13,6 @@ audio pump holding up under live use such as a stompbox or a stage rig. - A level ramp on `audiomixer` voices: a level move ramps over a set number of frames instead of stepping, so an effect can fade its wet path in or out without a click when you switch it in mid-note. -- More sampler voices: `audiosampler.Sampler` caps at 64 voices - (`SAMPLER_MAX_VOICES`), while 64 voices render in under half a millisecond per - 21 ms period on a desktop. A dense General MIDI piece wants more. Bugs, and things you need that don't work yet, go to [issues](https://github.com/PyDevices/audiodsp/issues). diff --git a/docs/upstream-diff.md b/docs/upstream-diff.md index 83567b97..7857bb82 100644 --- a/docs/upstream-diff.md +++ b/docs/upstream-diff.md @@ -70,9 +70,19 @@ run). On an ESP32-P4 at 400 MHz, 64 voices of a dense General MIDI file took about 5 ms of the block before the frames between a voice's events got a loop of their own (state in registers, two frames' interpolation worked out together for the in-order core, the same arithmetic in the same order, -so the same bits): about 2.4 ms after. Memory is the voice table (about 100 bytes a voice), a float -accumulator of one block, and the output block. The buffers are the caller's, -held by reference while a voice sounds. +so the same bits): about 2.4 ms after. Memory is the voice table (about 100 bytes a voice, two +slots each), a float accumulator of one block, and the output block. The +buffers are the caller's, held by reference while a voice sounds. + +A `Sampler` carries up to 128 voices (`audiosampler.MAX_VOICES`). Cost +grows with the voices sounding, not the table: a thousand and twenty-four +frames (one 21.3 ms period) take 0.28 ms with 64 voices sounding and 0.55 ms +with 128 on CPython, and 0.67 ms and 1.27 ms on the MicroPython unix port +(x86-64, one core, every voice looping, panned and pitched apart); 64 voices +sounding in a table of 128 take the same as in a table of 64. A board +renders voices at its own pace, so scale the P4's 64 voices (2.4 ms of a +5.3 ms block) to what you ask of it, and give `voice_count` the number it +can keep up with. ### How it is verified diff --git a/src/circuitpython_spike/shared-bindings/audiosampler/Sampler.c b/src/circuitpython_spike/shared-bindings/audiosampler/Sampler.c index 0d214f09..29a280e9 100644 --- a/src/circuitpython_spike/shared-bindings/audiosampler/Sampler.c +++ b/src/circuitpython_spike/shared-bindings/audiosampler/Sampler.c @@ -39,7 +39,7 @@ //| samples_signed: bool = True, //| sample_rate: int = 48000, //| ) -> None: -//| """Create a sampler. ``voice_count`` (1 to 64) and ``channel_count`` +//| """Create a sampler. ``voice_count`` (1 to 128) and ``channel_count`` //| (1 or 2) size the allocation and cannot change afterwards. //| ``buffer_size`` is the output block in bytes, 256 frames by //| default. ``bits_per_sample`` and ``samples_signed`` are accepted so diff --git a/src/circuitpython_spike/shared-bindings/audiosampler/__init__.c b/src/circuitpython_spike/shared-bindings/audiosampler/__init__.c index 3fe46508..1f6b3bd1 100644 --- a/src/circuitpython_spike/shared-bindings/audiosampler/__init__.c +++ b/src/circuitpython_spike/shared-bindings/audiosampler/__init__.c @@ -40,7 +40,7 @@ //| """The most frames a block may carry (1024).""" //| //| MAX_VOICES: int -//| """The most voices one Sampler may carry (64).""" +//| """The most voices one Sampler may carry (128).""" //| //| LOOP_NONE: int //| """Play to the end of the buffer and stop (SoundFont sampleModes 0).""" diff --git a/src/shared/audiodsp_sampler.h b/src/shared/audiodsp_sampler.h index 289f0cb3..f4d3ba02 100644 --- a/src/shared/audiodsp_sampler.h +++ b/src/shared/audiodsp_sampler.h @@ -73,9 +73,13 @@ #define AUDIODSP_SAMPLER_MAX_FRAMES 1024u //: The most voices one sampler may carry. The budget target is 24 at 48 kHz -//: stereo; 64 leaves headroom without letting a Python mistake allocate -//: unboundedly. -#define AUDIODSP_SAMPLER_MAX_VOICES 64u +//: stereo. 128 covers a dense General MIDI piece, which has more than 60 keys +//: down at once and the release tails of the notes just let go besides. It +//: costs nothing until a voice sounds: a binding sizes its table to the +//: `voice_count` it is asked for, at most this, and an idle voice is skipped, +//: so a board that cannot render this many asks for fewer. The ceiling stops +//: a Python mistake allocating without bound. +#define AUDIODSP_SAMPLER_MAX_VOICES 128u //: Pitch bounds, as a ratio. 1/64 is six octaves down; 64 is six up. #define AUDIODSP_SAMPLER_MIN_PITCH (1.0f / 64.0f) diff --git a/tests/test_cpython_audiosampler.py b/tests/test_cpython_audiosampler.py index 6eeea10d..edeef276 100644 --- a/tests/test_cpython_audiosampler.py +++ b/tests/test_cpython_audiosampler.py @@ -32,6 +32,10 @@ | S17 | 24 looping stereo voices render inside the 5333 us block | measured | | S18 | `play()` over a sounding voice fades the old note out under the new one, as S7 | exact | | S19 | A voice already releasing keeps releasing when stopped, no slower than the 5 ms fade | exact | +| S20 | A Sampler of 128 voices builds, sounds on every voice and sums them; one more than `MAX_VOICES` is refused | exact; raises | +| S21 | The first 64 voices of a table of 128 render what a table of 64 does | exact | +| S22 | A steal on the last voice of a table of 128 fades the old note out, as S18 | exact | +| S23 | 128 looping stereo voices render inside the 5333 us block | measured | S3 is this module's form of the identity trait `docs/correctness-standard.md` asks of every own node: an exact answer through the interpolator rather than @@ -465,6 +469,74 @@ def test_s17_twenty_four_voices_inside_the_block(self): self.assertLess(best, budget) + def test_s20_a_hundred_and_twenty_eight_voices_sound_and_sum(self): + self.assertGreaterEqual(audiosampler.MAX_VOICES, 128) + sampler = mono(128) + self.assertEqual(len(sampler.voice), 128) + for voice in sampler.voice: + voice.play(flat(100), loop_mode=audiosampler.LOOP_FOREVER) + self.assertTrue(all(voice.playing for voice in sampler.voice)) + render(sampler, 1) # a new note's level ramps in across its first block + self.assertEqual(render(sampler, 1), [128 * 100] * BLOCK) + sampler.deinit() + with self.assertRaises(ValueError): + mono(audiosampler.MAX_VOICES + 1) + + def test_s21_a_bigger_table_renders_the_same_for_the_voices_in_use(self): + cycle = sine_cycle() + outs = [] + for table in (64, 128): + sampler = audiosampler.Sampler(voice_count=table, channel_count=2, + sample_rate=RATE) + for index in range(64): + sampler.voice[index].play( + cycle, pitch=2.0 ** ((index % 24 - 12) / 12.0), + loop_mode=audiosampler.LOOP_FOREVER, level=0.01, + pan=(index % 5 - 2) / 2.0, attack=0.01, decay=0.5, + sustain=0.5) + outs.append(render(sampler, 8)) + sampler.deinit() + self.assertGreater(peak(outs[0]), 1000) + self.assertEqual(outs[0], outs[1]) + + def test_s22_a_steal_on_the_last_voice_fades_the_old_note(self): + sampler = mono(128) + for voice in sampler.voice[:127]: + voice.play(flat(10), loop_mode=audiosampler.LOOP_FOREVER) + sampler.voice[127].play(flat(), loop_mode=audiosampler.LOOP_FOREVER) + render(sampler, 2) + sampler.voice[127].play(flat(10000), + loop_mode=audiosampler.LOOP_FOREVER) + out = render(sampler, 1) + self.assertFadesOut([v - 127 * 10 - 10000 for v in out], 20000) + self.assertEqual(render(sampler, 1), [127 * 10 + 10000] * BLOCK) + sampler.deinit() + + def test_s23_a_hundred_and_twenty_eight_voices_inside_the_block(self): + sampler = audiosampler.Sampler(voice_count=128, channel_count=2, + sample_rate=RATE) + cycle = sine_cycle() + for index, voice in enumerate(sampler.voice): + voice.play(cycle, pitch=2.0 ** ((index % 24 - 12) / 12.0), + loop_mode=audiosampler.LOOP_FOREVER, level=0.01, + pan=(index % 5 - 2) / 2.0, attack=0.01, decay=0.5, + sustain=0.5) + state = sampler._state + for _ in range(20): + state.render() + best = float("inf") + for _ in range(3): + started = time.perf_counter() + for _ in range(500): + state.render() + best = min(best, (time.perf_counter() - started) / 500 * 1e6) + budget = BLOCK / RATE * 1e6 + print("\n128 looping stereo voices: %.1f us per %d-frame block " + "(budget %.0f us)" % (best, BLOCK, budget)) + self.assertTrue(all(voice.playing for voice in sampler.voice)) + self.assertLess(best, budget) + + class SampleBufferTraits(unittest.TestCase): """Memory for samples: zeroed, writable through a view, and a voice playing from a view of it sounds as it does from an array."""