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."""