Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
16 changes: 13 additions & 3 deletions docs/upstream-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
10 changes: 7 additions & 3 deletions src/shared/audiodsp_sampler.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions tests/test_cpython_audiosampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
Loading