From 1e06afb1a395812068dc3113155165f157d4ed1d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:52:12 -0500 Subject: [PATCH 1/4] audiosampler: keep the filter, modulation envelope and LFO state out of the voice table --- src/audiosampler/SamplerVoice.c | 25 ++ src/audiosampler/SamplerVoice.h | 4 + .../audiosampler/SamplerVoice.c | 21 ++ .../shared-module/audiosampler/Sampler.h | 4 + src/cpython/_audiodsp.c | 39 +++ src/shared/audiodsp_sampler.c | 307 +++++++++++------- src/shared/audiodsp_sampler.h | 99 ++++-- 7 files changed, 351 insertions(+), 148 deletions(-) diff --git a/src/audiosampler/SamplerVoice.c b/src/audiosampler/SamplerVoice.c index 2e62e4a..61399dd 100644 --- a/src/audiosampler/SamplerVoice.c +++ b/src/audiosampler/SamplerVoice.c @@ -21,6 +21,7 @@ mp_obj_t audiosampler_samplervoice_new(audiosampler_sampler_obj_t *parent, self->pitch = mp_obj_new_float(1.0); self->cutoff = mp_const_none; self->vibrato_depth = mp_obj_new_float(0.0); + self->mod = NULL; return MP_OBJ_FROM_PTR(self); } @@ -128,6 +129,10 @@ static mp_obj_t audiosampler_samplervoice_play(size_t n_args, note.lfo_to_cutoff = float_arg(args[ARG_lfo_to_cutoff].u_obj, 0.0f); note.lfo_to_volume = float_arg(args[ARG_lfo_to_volume].u_obj, 0.0f); + if (audiodsp_sampler_note_modulated(¬e)) { + samplervoice_need_mod(self); + } + // Read and checked first, stored under the lock: a pull landing between // the kernel's play() and the reference store would read a buffer the // GC may already be free to take. @@ -278,6 +283,20 @@ MP_PROPERTY_GETSET(audiosampler_samplervoice_pitch_obj, (mp_obj_t)&audiosampler_samplervoice_get_pitch_obj, (mp_obj_t)&audiosampler_samplervoice_set_pitch_obj); +// Gives the voice its filter, envelope and LFO state the first time it needs +// any of them, so a sampler whose notes never ask carries none. Allocated +// outside the pump lock, since an allocation may collect. +static void samplervoice_need_mod(audiosampler_samplervoice_obj_t *self) { + if (self->mod != NULL) { + return; + } + audiodsp_sampler_mod_t *pair = m_malloc(2 * sizeof(audiodsp_sampler_mod_t)); + audiodsp_pump_lock_acquire(); + audiodsp_sampler_attach_mod(&self->parent->sampler, self->index, pair); + self->mod = pair; + audiodsp_pump_lock_release(); +} + // The low-pass cutoff in Hz, None for no filter. Live: a change lands // within 32 frames, and a filter switched on starts from rest. static mp_obj_t audiosampler_samplervoice_obj_get_cutoff(mp_obj_t self_in) { @@ -293,6 +312,9 @@ static mp_obj_t audiosampler_samplervoice_obj_set_cutoff(mp_obj_t self_in, audiosample_check_for_deinit(&self->parent->base); float cutoff = value == mp_const_none ? 0.0f : (float)mp_obj_get_float(value); + if (cutoff > 0.0f) { + samplervoice_need_mod(self); + } audiodsp_pump_lock_acquire(); audiodsp_sampler_set_cutoff(&self->parent->sampler, self->index, cutoff); self->cutoff = value; @@ -320,6 +342,9 @@ static mp_obj_t audiosampler_samplervoice_obj_set_vibrato_depth( audiosampler_samplervoice_obj_t *self = MP_OBJ_TO_PTR(self_in); audiosample_check_for_deinit(&self->parent->base); float cents = (float)mp_obj_get_float(value); + if (cents != 0.0f) { + samplervoice_need_mod(self); + } audiodsp_pump_lock_acquire(); audiodsp_sampler_set_vibrato_depth(&self->parent->sampler, self->index, cents); diff --git a/src/audiosampler/SamplerVoice.h b/src/audiosampler/SamplerVoice.h index 6bf6485..4e20084 100644 --- a/src/audiosampler/SamplerVoice.h +++ b/src/audiosampler/SamplerVoice.h @@ -29,6 +29,10 @@ typedef struct { mp_obj_t pitch; mp_obj_t cutoff; mp_obj_t vibrato_depth; + // The voice's and its tail's filter, envelope and LFO state, allocated + // the first time a note or a live change asks for any of them, and held + // here as well as in the voice table so the GC keeps it. + audiodsp_sampler_mod_t *mod; } audiosampler_samplervoice_obj_t; extern const mp_obj_type_t audiosampler_samplervoice_type; diff --git a/src/circuitpython_spike/shared-bindings/audiosampler/SamplerVoice.c b/src/circuitpython_spike/shared-bindings/audiosampler/SamplerVoice.c index f968dc4..2a67e5a 100644 --- a/src/circuitpython_spike/shared-bindings/audiosampler/SamplerVoice.c +++ b/src/circuitpython_spike/shared-bindings/audiosampler/SamplerVoice.c @@ -28,9 +28,21 @@ mp_obj_t audiosampler_samplervoice_new(audiosampler_sampler_obj_t *parent, self->pitch = mp_obj_new_float(1.0); self->cutoff = mp_const_none; self->vibrato_depth = mp_obj_new_float(0.0); + self->mod = NULL; return MP_OBJ_FROM_PTR(self); } +// Gives the voice its filter, envelope and LFO state the first time it needs +// any of them, so a sampler whose notes never ask carries none. +static void samplervoice_need_mod(audiosampler_samplervoice_obj_t *self) { + if (self->mod != NULL) { + return; + } + audiodsp_sampler_mod_t *pair = m_malloc(2 * sizeof(audiodsp_sampler_mod_t)); + audiodsp_sampler_attach_mod(&self->parent->sampler, self->index, pair); + self->mod = pair; +} + static float float_arg(mp_obj_t value, float fallback) { return value == mp_const_none ? fallback : (float)mp_obj_get_float(value); } @@ -187,6 +199,9 @@ static mp_obj_t audiosampler_samplervoice_play(size_t n_args, note.lfo_to_pitch = float_arg(args[ARG_lfo_to_pitch].u_obj, 0.0f); note.lfo_to_cutoff = float_arg(args[ARG_lfo_to_cutoff].u_obj, 0.0f); note.lfo_to_volume = float_arg(args[ARG_lfo_to_volume].u_obj, 0.0f); + if (audiodsp_sampler_note_modulated(¬e)) { + samplervoice_need_mod(self); + } bool handed = audiodsp_sampler_audible(&self->parent->sampler, self->index); if (!audiodsp_sampler_play(&self->parent->sampler, self->index, @@ -353,6 +368,9 @@ static mp_obj_t audiosampler_samplervoice_obj_set_cutoff(mp_obj_t self_in, audiosample_check_for_deinit(&self->parent->base); float cutoff = value == mp_const_none ? 0.0f : (float)mp_obj_get_float(value); + if (cutoff > 0.0f) { + samplervoice_need_mod(self); + } audiodsp_sampler_set_cutoff(&self->parent->sampler, self->index, cutoff); self->cutoff = value; return mp_const_none; @@ -380,6 +398,9 @@ static mp_obj_t audiosampler_samplervoice_obj_set_vibrato_depth( audiosampler_samplervoice_obj_t *self = MP_OBJ_TO_PTR(self_in); audiosample_check_for_deinit(&self->parent->base); float cents = (float)mp_obj_get_float(value); + if (cents != 0.0f) { + samplervoice_need_mod(self); + } audiodsp_sampler_set_vibrato_depth(&self->parent->sampler, self->index, cents); self->vibrato_depth = value; diff --git a/src/circuitpython_spike/shared-module/audiosampler/Sampler.h b/src/circuitpython_spike/shared-module/audiosampler/Sampler.h index 6e57092..ee11a21 100644 --- a/src/circuitpython_spike/shared-module/audiosampler/Sampler.h +++ b/src/circuitpython_spike/shared-module/audiosampler/Sampler.h @@ -47,6 +47,10 @@ typedef struct { mp_obj_t pitch; mp_obj_t cutoff; mp_obj_t vibrato_depth; + // The voice's and its tail's filter, envelope and LFO state, allocated + // the first time a note or a live change asks for any of them, and held + // here as well as in the voice table so the GC keeps it. + audiodsp_sampler_mod_t *mod; } audiosampler_samplervoice_obj_t; void audiosampler_sampler_reset_buffer(audiosampler_sampler_obj_t *self, diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 9d2ed3a..ff1c4bb 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -2882,6 +2882,32 @@ static void sampler_state_hand(audiodsp_sampler_object_t *self, uint32_t index) } } +// Each voice's filter, envelope and LFO state, with its tail's, is a pair +// allocated the first time a note or a live change on the voice asks for any +// of them; the voice table holds the pointer. +static int sampler_state_need_mod(audiodsp_sampler_object_t *self, + uint32_t index) { + if (audiodsp_sampler_has_mod(&self->sampler, index)) { + return 0; + } + audiodsp_sampler_mod_t *pair = PyMem_Calloc(2, + sizeof(audiodsp_sampler_mod_t)); + if (pair == NULL) { + PyErr_NoMemory(); + return -1; + } + audiodsp_sampler_attach_mod(&self->sampler, index, pair); + return 0; +} + +static void sampler_state_free_mods(audiodsp_sampler_object_t *self) { + for (uint32_t i = 0; self->voices != NULL && + i < self->sampler.voice_count; ++i) { + PyMem_Free(self->voices[i].mod); + self->voices[i].mod = NULL; + } +} + static int sampler_state_init(audiodsp_sampler_object_t *self, PyObject *args, PyObject *kwargs) { static char *keywords[] = {"sample_rate", "channel_count", "frames", @@ -2911,6 +2937,7 @@ static int sampler_state_init(audiodsp_sampler_object_t *self, PyObject *args, return -1; } sampler_state_drop_all(self); + sampler_state_free_mods(self); PyMem_Free(self->voices); PyMem_Free(self->mix); PyMem_Free(self->out); @@ -2935,6 +2962,7 @@ static int sampler_state_init(audiodsp_sampler_object_t *self, PyObject *args, static void sampler_state_dealloc(audiodsp_sampler_object_t *self) { PyTypeObject *type = Py_TYPE(self); sampler_state_drop_all(self); + sampler_state_free_mods(self); PyMem_Free(self->voices); PyMem_Free(self->mix); PyMem_Free(self->out); @@ -3053,6 +3081,11 @@ static PyObject *sampler_state_play(audiodsp_sampler_object_t *self, note.lfo_to_pitch = (float)mod[15]; note.lfo_to_cutoff = (float)mod[16]; note.lfo_to_volume = (float)mod[17]; + if (audiodsp_sampler_note_modulated(¬e) && + sampler_state_need_mod(self, index) < 0) { + PyBuffer_Release(&view); + return NULL; + } // Let go of what this voice was reading only once the new buffer is // known to be good, so a refused play() leaves the voice as it was. // The kernel leaves the voice and its tail untouched on a refusal. @@ -3155,6 +3188,9 @@ static PyObject *sampler_state_set_cutoff(audiodsp_sampler_object_t *self, if (!PyArg_ParseTuple(args, "Od:set_cutoff", &index_obj, &value)) return NULL; uint32_t index; if (sampler_state_voice(self, index_obj, &index) < 0) return NULL; + if ((float)value > 0.0f && sampler_state_need_mod(self, index) < 0) { + return NULL; + } audiodsp_sampler_set_cutoff(&self->sampler, index, (float)value); Py_RETURN_NONE; } @@ -3168,6 +3204,9 @@ static PyObject *sampler_state_set_vibrato_depth( } uint32_t index; if (sampler_state_voice(self, index_obj, &index) < 0) return NULL; + if ((float)value != 0.0f && sampler_state_need_mod(self, index) < 0) { + return NULL; + } audiodsp_sampler_set_vibrato_depth(&self->sampler, index, (float)value); Py_RETURN_NONE; } diff --git a/src/shared/audiodsp_sampler.c b/src/shared/audiodsp_sampler.c index 9e77a36..8dd0f09 100644 --- a/src/shared/audiodsp_sampler.c +++ b/src/shared/audiodsp_sampler.c @@ -71,11 +71,36 @@ void audiodsp_sampler_init(audiodsp_sampler_t *self, uint32_t sample_rate, voices[i].pitch = 1.0f; voices[i].rate_ratio = 1.0f; voices[i].sustain = 1.0f; - voices[i].mod_gain = 1.0f; - voices[i].filter_gain = 1.0f; } } +bool audiodsp_sampler_note_modulated(const audiodsp_sampler_note_t *note) { + audiodsp_sampler_note_t plain; + audiodsp_sampler_note_init(&plain); + return note->cutoff != plain.cutoff || + note->resonance != plain.resonance || + note->mod_delay != plain.mod_delay || + note->mod_attack != plain.mod_attack || + note->mod_hold != plain.mod_hold || + note->mod_decay != plain.mod_decay || + note->mod_release != plain.mod_release || + note->mod_sustain != plain.mod_sustain || + note->mod_to_pitch != plain.mod_to_pitch || + note->mod_to_cutoff != plain.mod_to_cutoff || + note->vibrato_delay != plain.vibrato_delay || + note->vibrato_rate != plain.vibrato_rate || + note->vibrato_depth != plain.vibrato_depth || + note->lfo_delay != plain.lfo_delay || + note->lfo_rate != plain.lfo_rate || + note->lfo_to_pitch != plain.lfo_to_pitch || + note->lfo_to_cutoff != plain.lfo_to_cutoff || + note->lfo_to_volume != plain.lfo_to_volume; +} + +bool audiodsp_sampler_has_mod(const audiodsp_sampler_t *self, uint32_t index) { + return index < self->voice_count && self->voices[index].mod != NULL; +} + void audiodsp_sampler_note_init(audiodsp_sampler_note_t *note) { memset(note, 0, sizeof(*note)); note->pitch = 1.0f; @@ -175,84 +200,86 @@ static void stage_done(audiodsp_sampler_voice_t *voice) { // The same six stages as the amplitude envelope, linear in its own units (a // fraction of full scale), and run once a chunk rather than once a sample. static void mod_enter(audiodsp_sampler_voice_t *voice, uint32_t stage) { + audiodsp_sampler_mod_t *m = voice->mod; for (;;) { - voice->mod_stage = stage; + m->mod_stage = stage; switch (stage) { case AUDIODSP_SAMPLER_STAGE_DELAY: - voice->mod_env = 0.0f; - voice->mod_step = 0.0f; - if (voice->mod_delay_samples == 0u) { + m->mod_env = 0.0f; + m->mod_step = 0.0f; + if (m->mod_delay_samples == 0u) { stage = AUDIODSP_SAMPLER_STAGE_ATTACK; continue; } - voice->mod_left = voice->mod_delay_samples; + m->mod_left = m->mod_delay_samples; return; case AUDIODSP_SAMPLER_STAGE_ATTACK: - voice->mod_env = 0.0f; - if (voice->mod_attack_samples == 0u) { + m->mod_env = 0.0f; + if (m->mod_attack_samples == 0u) { stage = AUDIODSP_SAMPLER_STAGE_HOLD; continue; } - voice->mod_left = voice->mod_attack_samples; - voice->mod_step = 1.0f / (float)voice->mod_attack_samples; + m->mod_left = m->mod_attack_samples; + m->mod_step = 1.0f / (float)m->mod_attack_samples; return; case AUDIODSP_SAMPLER_STAGE_HOLD: - voice->mod_env = 1.0f; - voice->mod_step = 0.0f; - if (voice->mod_hold_samples == 0u) { + m->mod_env = 1.0f; + m->mod_step = 0.0f; + if (m->mod_hold_samples == 0u) { stage = AUDIODSP_SAMPLER_STAGE_DECAY; continue; } - voice->mod_left = voice->mod_hold_samples; + m->mod_left = m->mod_hold_samples; return; case AUDIODSP_SAMPLER_STAGE_DECAY: - voice->mod_env = 1.0f; - if (voice->mod_decay_samples == 0u) { + m->mod_env = 1.0f; + if (m->mod_decay_samples == 0u) { stage = AUDIODSP_SAMPLER_STAGE_SUSTAIN; continue; } - voice->mod_left = voice->mod_decay_samples; - voice->mod_step = (voice->mod_sustain - 1.0f) / - (float)voice->mod_decay_samples; + m->mod_left = m->mod_decay_samples; + m->mod_step = (m->mod_sustain - 1.0f) / + (float)m->mod_decay_samples; return; case AUDIODSP_SAMPLER_STAGE_SUSTAIN: - voice->mod_env = voice->mod_sustain; - voice->mod_step = 0.0f; - voice->mod_left = 0; + m->mod_env = m->mod_sustain; + m->mod_step = 0.0f; + m->mod_left = 0; return; case AUDIODSP_SAMPLER_STAGE_RELEASE: - if (voice->mod_release_samples == 0u || - voice->mod_env <= 0.0f) { + if (m->mod_release_samples == 0u || + m->mod_env <= 0.0f) { stage = AUDIODSP_SAMPLER_STAGE_IDLE; continue; } - voice->mod_left = voice->mod_release_samples; - voice->mod_step = -voice->mod_env / - (float)voice->mod_release_samples; + m->mod_left = m->mod_release_samples; + m->mod_step = -m->mod_env / + (float)m->mod_release_samples; return; case AUDIODSP_SAMPLER_STAGE_IDLE: default: - voice->mod_stage = AUDIODSP_SAMPLER_STAGE_IDLE; - voice->mod_env = 0.0f; - voice->mod_step = 0.0f; - voice->mod_left = 0; + m->mod_stage = AUDIODSP_SAMPLER_STAGE_IDLE; + m->mod_env = 0.0f; + m->mod_step = 0.0f; + m->mod_left = 0; return; } } } static void mod_advance(audiodsp_sampler_voice_t *voice, uint32_t frames) { + audiodsp_sampler_mod_t *m = voice->mod; while (frames > 0u) { - const uint32_t stage = voice->mod_stage; + const uint32_t stage = m->mod_stage; if (stage == AUDIODSP_SAMPLER_STAGE_SUSTAIN || stage == AUDIODSP_SAMPLER_STAGE_IDLE) { return; } - uint32_t take = frames < voice->mod_left ? frames : voice->mod_left; - voice->mod_env += voice->mod_step * (float)take; - voice->mod_left -= take; + uint32_t take = frames < m->mod_left ? frames : m->mod_left; + m->mod_env += m->mod_step * (float)take; + m->mod_left -= take; frames -= take; - if (voice->mod_left == 0u) { + if (m->mod_left == 0u) { switch (stage) { case AUDIODSP_SAMPLER_STAGE_DELAY: mod_enter(voice, AUDIODSP_SAMPLER_STAGE_ATTACK); @@ -302,7 +329,8 @@ static inline float cents_ratio(float cents) { // voice's cutoff moved by `cents`. static void filter_coefficients(const audiodsp_sampler_t *self, audiodsp_sampler_voice_t *voice, float cents) { - float fc = voice->cutoff; + audiodsp_sampler_mod_t *m = voice->mod; + float fc = m->cutoff; if (cents != 0.0f) { fc *= cents_ratio(cents); } @@ -313,47 +341,48 @@ static void filter_coefficients(const audiodsp_sampler_t *self, fc = 5.0f; } const float g = tan_poly(SAMPLER_PI * fc / (float)self->sample_rate); - voice->fa1 = 1.0f / (1.0f + g * (g + voice->filter_k)); - voice->fa2 = g * voice->fa1; - voice->fa3 = g * voice->fa2; - voice->filter_cents = cents; - voice->filter_dirty = false; + m->fa1 = 1.0f / (1.0f + g * (g + m->filter_k)); + m->fa2 = g * m->fa1; + m->fa3 = g * m->fa2; + m->filter_cents = cents; + m->filter_dirty = false; } // Works out what the voice needs from its settings, and puts the plain // path's step back when nothing moves the pitch any more. static void update_mods(audiodsp_sampler_voice_t *voice) { + audiodsp_sampler_mod_t *m = voice->mod; uint32_t mods = 0; - if (voice->mod_to_pitch != 0.0f || voice->vibrato_depth != 0.0f || - voice->lfo_to_pitch != 0.0f) { + if (m->mod_to_pitch != 0.0f || m->vibrato_depth != 0.0f || + m->lfo_to_pitch != 0.0f) { mods |= AUDIODSP_SAMPLER_MOD_PITCH; } - if (voice->mod_to_cutoff != 0.0f || voice->lfo_to_cutoff != 0.0f) { + if (m->mod_to_cutoff != 0.0f || m->lfo_to_cutoff != 0.0f) { mods |= AUDIODSP_SAMPLER_MOD_CUTOFF; } - if (voice->lfo_to_volume != 0.0f) { + if (m->lfo_to_volume != 0.0f) { mods |= AUDIODSP_SAMPLER_MOD_VOLUME; } - if (voice->mod_to_pitch != 0.0f || voice->mod_to_cutoff != 0.0f) { + if (m->mod_to_pitch != 0.0f || m->mod_to_cutoff != 0.0f) { mods |= AUDIODSP_SAMPLER_MOD_ENV; } - if (voice->cutoff > 0.0f && (voice->cutoff < AUDIODSP_SAMPLER_OPEN_CUTOFF || - voice->resonance > 0.0f || (mods & AUDIODSP_SAMPLER_MOD_CUTOFF))) { + if (m->cutoff > 0.0f && (m->cutoff < AUDIODSP_SAMPLER_OPEN_CUTOFF || + m->resonance > 0.0f || (mods & AUDIODSP_SAMPLER_MOD_CUTOFF))) { mods |= AUDIODSP_SAMPLER_MOD_FILTER; } else { mods &= ~AUDIODSP_SAMPLER_MOD_CUTOFF; } if ((mods & AUDIODSP_SAMPLER_MOD_FILTER) && !(voice->mods & AUDIODSP_SAMPLER_MOD_FILTER)) { - voice->ic1 = 0.0f; - voice->ic2 = 0.0f; - voice->filter_dirty = true; + m->ic1 = 0.0f; + m->ic2 = 0.0f; + m->filter_dirty = true; } if (!(mods & AUDIODSP_SAMPLER_MOD_PITCH)) { - voice->step = voice->step_base; + voice->step = m->step_base; } - voice->filter_gain = (mods & AUDIODSP_SAMPLER_MOD_FILTER) ? - audiodsp_exp2_poly(-voice->resonance * (0.5f * 0.16609640f)) : 1.0f; + m->filter_gain = (mods & AUDIODSP_SAMPLER_MOD_FILTER) ? + audiodsp_exp2_poly(-m->resonance * (0.5f * 0.16609640f)) : 1.0f; voice->mods = mods; } @@ -404,6 +433,35 @@ static inline uint64_t scale_step(uint64_t base, float factor) { return (((base >> 32) * fq) << 8) + (((base & 0xffffffffu) * fq) >> 24); } +// What play() leaves in a voice's modulation record for a note that asks for +// none of it, which is everything a voice without a record has been playing. +// The envelope's stage is left at rest: with both of its depths zero nothing +// reads it until the next play() sets it. +static void mod_plain(audiodsp_sampler_mod_t *m, uint64_t step) { + memset(m, 0, sizeof(*m)); + m->step_base = step; + m->filter_k = resonance_k(0.0f); + m->filter_gain = 1.0f; + m->mod_sustain = 1.0f; + m->mod_gain = 1.0f; +} + +void audiodsp_sampler_attach_mod(audiodsp_sampler_t *self, uint32_t index, + audiodsp_sampler_mod_t *pair) { + if (index >= self->voice_count || pair == NULL) { + return; + } + audiodsp_sampler_voice_t *voice = &self->voices[index]; + audiodsp_sampler_voice_t *tail = &self->voices[self->voice_count + index]; + if (voice->mod != NULL) { + return; + } + mod_plain(&pair[0], voice->step); + mod_plain(&pair[1], tail->step); + voice->mod = &pair[0]; + tail->mod = &pair[1]; +} + // --- the voice table ----------------------------------------------------------- static bool audible(const audiodsp_sampler_voice_t *voice) { @@ -424,7 +482,13 @@ static void hand_to_tail(audiodsp_sampler_t *self, uint32_t index) { const uint32_t fade = self->fade_samples; bool keep = voice->stage == AUDIODSP_SAMPLER_STAGE_RELEASE && voice->stage_left <= fade; + // The tail keeps its own record; the voice's state is copied into it. + audiodsp_sampler_mod_t *mod = tail->mod; *tail = *voice; + tail->mod = mod; + if (mod != NULL) { + *mod = *voice->mod; + } tail->stage = AUDIODSP_SAMPLER_STAGE_RELEASE; if (!keep) { tail->stage_left = fade; @@ -466,40 +530,46 @@ bool audiodsp_sampler_play(audiodsp_sampler_t *self, uint32_t index, voice->pitch = clampf(note->pitch, AUDIODSP_SAMPLER_MIN_PITCH, AUDIODSP_SAMPLER_MAX_PITCH); voice->step = step_for(voice); - voice->step_base = voice->step; voice->age = 0; - - voice->cutoff = note->cutoff > 0.0f ? note->cutoff : 0.0f; - voice->resonance = clampf(note->resonance, 0.0f, - AUDIODSP_SAMPLER_MAX_RESONANCE); - voice->filter_k = resonance_k(voice->resonance); - voice->mod_delay_samples = seconds_to_samples(note->mod_delay, - self->sample_rate); - voice->mod_attack_samples = seconds_to_samples(note->mod_attack, - self->sample_rate); - voice->mod_hold_samples = seconds_to_samples(note->mod_hold, - self->sample_rate); - voice->mod_decay_samples = seconds_to_samples(note->mod_decay, - self->sample_rate); - voice->mod_release_samples = seconds_to_samples(note->mod_release, - self->sample_rate); - voice->mod_sustain = clampf(note->mod_sustain, 0.0f, 1.0f); - const float most = AUDIODSP_SAMPLER_MAX_CENTS; - voice->mod_to_pitch = clampf(note->mod_to_pitch, -most, most); - voice->mod_to_cutoff = clampf(note->mod_to_cutoff, -most, most); - voice->vibrato_delay = seconds_to_samples(note->vibrato_delay, - self->sample_rate); - voice->vibrato_inc = lfo_inc(note->vibrato_rate, self->sample_rate); - voice->vibrato_depth = clampf(note->vibrato_depth, -most, most); - voice->lfo_delay = seconds_to_samples(note->lfo_delay, self->sample_rate); - voice->lfo_inc = lfo_inc(note->lfo_rate, self->sample_rate); - voice->lfo_to_pitch = clampf(note->lfo_to_pitch, -most, most); - voice->lfo_to_cutoff = clampf(note->lfo_to_cutoff, -most, most); - voice->lfo_to_volume = clampf(note->lfo_to_volume, -96.0f, 96.0f); voice->mods = 0; - update_mods(voice); - voice->mod_gain = voice->filter_gain; - mod_enter(voice, AUDIODSP_SAMPLER_STAGE_DELAY); + // A voice with no record yet has only ever been asked for the plain + // path, and the binding attaches one before a note that asks for more. + if (voice->mod != NULL) { + audiodsp_sampler_mod_t *m = voice->mod; + m->step_base = voice->step; + + m->cutoff = note->cutoff > 0.0f ? note->cutoff : 0.0f; + m->resonance = clampf(note->resonance, 0.0f, + AUDIODSP_SAMPLER_MAX_RESONANCE); + m->filter_k = resonance_k(m->resonance); + m->mod_delay_samples = seconds_to_samples(note->mod_delay, + self->sample_rate); + m->mod_attack_samples = seconds_to_samples(note->mod_attack, + self->sample_rate); + m->mod_hold_samples = seconds_to_samples(note->mod_hold, + self->sample_rate); + m->mod_decay_samples = seconds_to_samples(note->mod_decay, + self->sample_rate); + m->mod_release_samples = seconds_to_samples(note->mod_release, + self->sample_rate); + m->mod_sustain = clampf(note->mod_sustain, 0.0f, 1.0f); + const float most = AUDIODSP_SAMPLER_MAX_CENTS; + m->mod_to_pitch = clampf(note->mod_to_pitch, -most, most); + m->mod_to_cutoff = clampf(note->mod_to_cutoff, -most, most); + m->vibrato_delay = seconds_to_samples(note->vibrato_delay, + self->sample_rate); + m->vibrato_inc = lfo_inc(note->vibrato_rate, self->sample_rate); + m->vibrato_depth = clampf(note->vibrato_depth, -most, most); + m->lfo_delay = seconds_to_samples(note->lfo_delay, self->sample_rate); + m->lfo_inc = lfo_inc(note->lfo_rate, self->sample_rate); + m->lfo_to_pitch = clampf(note->lfo_to_pitch, -most, most); + m->lfo_to_cutoff = clampf(note->lfo_to_cutoff, -most, most); + m->lfo_to_volume = clampf(note->lfo_to_volume, -96.0f, 96.0f); + voice->mods = 0; + update_mods(voice); + m->mod_gain = m->filter_gain; + mod_enter(voice, AUDIODSP_SAMPLER_STAGE_DELAY); + } voice->attack_samples = seconds_to_samples(note->attack, self->sample_rate); voice->hold_samples = seconds_to_samples(note->hold, self->sample_rate); @@ -546,7 +616,8 @@ void audiodsp_sampler_release(audiodsp_sampler_t *self, uint32_t index) { voice->looping = false; } enter_stage(voice, AUDIODSP_SAMPLER_STAGE_RELEASE); - if (voice->mod_stage != AUDIODSP_SAMPLER_STAGE_IDLE) { + if (voice->mod != NULL && + voice->mod->mod_stage != AUDIODSP_SAMPLER_STAGE_IDLE) { mod_enter(voice, AUDIODSP_SAMPLER_STAGE_RELEASE); } } @@ -605,25 +676,27 @@ void audiodsp_sampler_set_pitch(audiodsp_sampler_t *self, uint32_t index, voice->pitch = clampf(pitch, AUDIODSP_SAMPLER_MIN_PITCH, AUDIODSP_SAMPLER_MAX_PITCH); voice->step = step_for(voice); - voice->step_base = voice->step; + if (voice->mod != NULL) { + voice->mod->step_base = voice->step; + } } } void audiodsp_sampler_set_cutoff(audiodsp_sampler_t *self, uint32_t index, float cutoff) { - if (index < self->voice_count) { + if (index < self->voice_count && self->voices[index].mod != NULL) { audiodsp_sampler_voice_t *voice = &self->voices[index]; - voice->cutoff = cutoff > 0.0f ? cutoff : 0.0f; - voice->filter_dirty = true; + voice->mod->cutoff = cutoff > 0.0f ? cutoff : 0.0f; + voice->mod->filter_dirty = true; update_mods(voice); } } void audiodsp_sampler_set_vibrato_depth(audiodsp_sampler_t *self, uint32_t index, float cents) { - if (index < self->voice_count) { + if (index < self->voice_count && self->voices[index].mod != NULL) { audiodsp_sampler_voice_t *voice = &self->voices[index]; - voice->vibrato_depth = clampf(cents, -AUDIODSP_SAMPLER_MAX_CENTS, + voice->mod->vibrato_depth = clampf(cents, -AUDIODSP_SAMPLER_MAX_CENTS, AUDIODSP_SAMPLER_MAX_CENTS); update_mods(voice); } @@ -1007,35 +1080,36 @@ static void make_frames(audiodsp_sampler_voice_t *voice, float *out, static float modulate(const audiodsp_sampler_t *self, audiodsp_sampler_voice_t *voice) { const uint32_t mods = voice->mods; + audiodsp_sampler_mod_t *m = voice->mod; const uint32_t age = voice->age; - const float env = (mods & AUDIODSP_SAMPLER_MOD_ENV) ? voice->mod_env : 0.0f; - const float vibrato = (voice->vibrato_inc != 0u && - age >= voice->vibrato_delay) ? - triangle(age - voice->vibrato_delay, voice->vibrato_inc) : 0.0f; - const float lfo = (voice->lfo_inc != 0u && age >= voice->lfo_delay) ? - triangle(age - voice->lfo_delay, voice->lfo_inc) : 0.0f; + const float env = (mods & AUDIODSP_SAMPLER_MOD_ENV) ? m->mod_env : 0.0f; + const float vibrato = (m->vibrato_inc != 0u && + age >= m->vibrato_delay) ? + triangle(age - m->vibrato_delay, m->vibrato_inc) : 0.0f; + const float lfo = (m->lfo_inc != 0u && age >= m->lfo_delay) ? + triangle(age - m->lfo_delay, m->lfo_inc) : 0.0f; if (mods & AUDIODSP_SAMPLER_MOD_PITCH) { - float cents = env * voice->mod_to_pitch + - vibrato * voice->vibrato_depth + lfo * voice->lfo_to_pitch; + float cents = env * m->mod_to_pitch + + vibrato * m->vibrato_depth + lfo * m->lfo_to_pitch; cents = clampf(cents, -7200.0f, 7200.0f); - voice->step = cents == 0.0f ? voice->step_base : - scale_step(voice->step_base, cents_ratio(cents)); + voice->step = cents == 0.0f ? m->step_base : + scale_step(m->step_base, cents_ratio(cents)); } if (mods & AUDIODSP_SAMPLER_MOD_FILTER) { float cents = 0.0f; if (mods & AUDIODSP_SAMPLER_MOD_CUTOFF) { - cents = clampf(env * voice->mod_to_cutoff + - lfo * voice->lfo_to_cutoff, -AUDIODSP_SAMPLER_MAX_CENTS, + cents = clampf(env * m->mod_to_cutoff + + lfo * m->lfo_to_cutoff, -AUDIODSP_SAMPLER_MAX_CENTS, AUDIODSP_SAMPLER_MAX_CENTS); } - if (voice->filter_dirty || cents != voice->filter_cents) { + if (m->filter_dirty || cents != m->filter_cents) { filter_coefficients(self, voice, cents); } } - float gain = voice->filter_gain; + float gain = m->filter_gain; if (mods & AUDIODSP_SAMPLER_MOD_VOLUME) { // dB to a ratio: 2^(dB * log2(10) / 20). - gain *= audiodsp_exp2_poly(lfo * voice->lfo_to_volume * 0.16609640f); + gain *= audiodsp_exp2_poly(lfo * m->lfo_to_volume * 0.16609640f); } return gain; } @@ -1043,6 +1117,7 @@ static float modulate(const audiodsp_sampler_t *self, AUDIODSP_HOT static void render_voice_mod(audiodsp_sampler_t *self, audiodsp_sampler_voice_t *voice) { + audiodsp_sampler_mod_t *m = voice->mod; const uint32_t frames = self->frames; const uint32_t channels = self->channel_count; float *mix = self->mix; @@ -1067,8 +1142,8 @@ static void render_voice_mod(audiodsp_sampler_t *self, const float want = modulate(self, voice); make_frames(voice, row, n); if (voice->mods & AUDIODSP_SAMPLER_MOD_FILTER) { - const float a1 = voice->fa1, a2 = voice->fa2, a3 = voice->fa3; - float ic1 = voice->ic1, ic2 = voice->ic2; + const float a1 = m->fa1, a2 = m->fa2, a3 = m->fa3; + float ic1 = m->ic1, ic2 = m->ic2; for (uint32_t j = 0; j < n; ++j) { const float v3 = row[j] - ic2; const float v1 = a1 * ic1 + a2 * v3; @@ -1077,10 +1152,10 @@ static void render_voice_mod(audiodsp_sampler_t *self, ic2 = 2.0f * v2 - ic2; row[j] = v2; } - voice->ic1 = ic1; - voice->ic2 = ic2; + m->ic1 = ic1; + m->ic2 = ic2; } - float gain = voice->mod_gain; + float gain = m->mod_gain; const float dgain = (want - gain) / (float)n; if (channels == 2u) { float *out = mix + 2u * c; @@ -1100,7 +1175,7 @@ static void render_voice_mod(audiodsp_sampler_t *self, gain += dgain; } } - voice->mod_gain = want; + m->mod_gain = want; voice->age += n; mod_advance(voice, n); c += n; diff --git a/src/shared/audiodsp_sampler.h b/src/shared/audiodsp_sampler.h index cd08ff5..6ebf74c 100644 --- a/src/shared/audiodsp_sampler.h +++ b/src/shared/audiodsp_sampler.h @@ -61,6 +61,11 @@ // asks for none of them -- no cutoff below the open one, no resonance, every // depth zero -- takes the path above, untouched, and costs what it did // before any of this existed; the one addition is a counter of its age. +// It costs no memory either: what the filter, the envelope and the LFOs keep +// is a separate record, attached to a voice the first time it asks for any of +// them. A voice table is every voice and every tail, so a field on the voice +// is paid 2 x voice_count times by every sampler, and a table that grew from +// 7 KB to 18 KB at 32 voices was the one allocation a crowded heap refused. // // A voice that asks for any of them renders in chunks of // AUDIODSP_SAMPLER_CONTROL frames. The modulators are worked out once a @@ -84,8 +89,9 @@ // // The pulling loop is not here, for the same reason it is not in // audiodsp_modal.c: each runtime reaches its audio graph differently. Nor is -// any memory: the binding owns the voice table, the mix accumulator and the -// sample buffers, and tells the kernel where they are. +// any memory: the binding owns the voice table, the mix accumulator, the +// modulation records and the sample buffers, and tells the kernel where they +// are. // // SPDX-License-Identifier: MIT @@ -204,37 +210,11 @@ typedef struct { #define AUDIODSP_SAMPLER_MOD_VOLUME 8u // the LFO moves the volume #define AUDIODSP_SAMPLER_MOD_ENV 16u // the modulation envelope is used -//: One voice. Written by the kernel only; a binding reads `stage` to answer -//: `playing` and to know when to let go of the buffer it handed over. +//: What a voice needs only for the filter, the modulation envelope and the +//: LFOs. It lives outside the voice table, one pair (the voice's and its +//: tail's) per voice that has ever asked for any of them, so a sampler whose +//: notes never do carries none of it. See audiodsp_sampler_attach_mod(). typedef struct { - const int16_t *data; // borrowed from the binding; NULL when idle - uint32_t length; // frames - uint32_t loop_start; - uint32_t loop_end; - uint32_t loop_mode; - uint32_t stage; // audiodsp_sampler_stage_t - bool looping; // still wrapping at loop_end - uint64_t position; // Q32.32 frames - uint64_t step; // Q32.32 frames per output frame - float rate_ratio; // rate / sample_rate, so a pitch change is one multiply - float pitch; - // The envelope: where it is, how far it moves each sample, and how many - // samples are left in the stage (unused in sustain). - float env; - float env_step; - uint32_t stage_left; - uint32_t attack_samples, hold_samples, decay_samples, release_samples; - float sustain; - // Where the fader is asked to be, and where the last block left it. - float level; - float pan; - float gain[2]; - // Where a run of clean frames left the gains, mid-block (render only). - float gain_run[2]; - // Frames since play(), for the LFOs. Wraps after a day at 48 kHz. - uint32_t age; - // AUDIODSP_SAMPLER_MOD_*: zero for a voice on the plain path. - uint32_t mods; // The step pitch alone asks for; a modulated voice scales it each chunk. uint64_t step_base; // The low-pass: its cutoff (Hz) and 1/Q, the DC gain the resonance @@ -265,6 +245,42 @@ typedef struct { // The volume factor the last chunk ended on (the filter's DC gain times // the LFO's tremolo), so the next ramps from it. float mod_gain; +} audiodsp_sampler_mod_t; + +//: One voice. Written by the kernel only; a binding reads `stage` to answer +//: `playing` and to know when to let go of the buffer it handed over. +typedef struct { + const int16_t *data; // borrowed from the binding; NULL when idle + uint32_t length; // frames + uint32_t loop_start; + uint32_t loop_end; + uint32_t loop_mode; + uint32_t stage; // audiodsp_sampler_stage_t + bool looping; // still wrapping at loop_end + uint64_t position; // Q32.32 frames + uint64_t step; // Q32.32 frames per output frame + float rate_ratio; // rate / sample_rate, so a pitch change is one multiply + float pitch; + // The envelope: where it is, how far it moves each sample, and how many + // samples are left in the stage (unused in sustain). + float env; + float env_step; + uint32_t stage_left; + uint32_t attack_samples, hold_samples, decay_samples, release_samples; + float sustain; + // Where the fader is asked to be, and where the last block left it. + float level; + float pan; + float gain[2]; + // Where a run of clean frames left the gains, mid-block (render only). + float gain_run[2]; + // Frames since play(), for the LFOs. Wraps after a day at 48 kHz. + uint32_t age; + // AUDIODSP_SAMPLER_MOD_*: zero for a voice on the plain path. + uint32_t mods; + // Borrowed from the binding; NULL until the voice first needs it, and + // then for good. Never nonzero `mods` without it. + audiodsp_sampler_mod_t *mod; } audiodsp_sampler_voice_t; typedef struct { @@ -285,6 +301,25 @@ void audiodsp_sampler_init(audiodsp_sampler_t *self, uint32_t sample_rate, void audiodsp_sampler_note_init(audiodsp_sampler_note_t *note); +// True when a note asks for anything a voice keeps in its +// audiodsp_sampler_mod_t: any filter, modulation-envelope or LFO setting +// other than audiodsp_sampler_note_init's. A binding asks before play(), and +// on true attaches the voice's pair first. +bool audiodsp_sampler_note_modulated(const audiodsp_sampler_note_t *note); + +// True once voice `index` has its modulation state. +bool audiodsp_sampler_has_mod(const audiodsp_sampler_t *self, uint32_t index); + +// Gives voice `index` and its tail their modulation state: `pair[0]` is the +// voice's and `pair[1]` the tail's, borrowed until the sampler goes. Set up +// as a note with none of it asks for, so a note already sounding renders on +// exactly as it would have. Done once per voice; a second call is ignored. +// Without it, play() takes no filter, envelope or LFO setting from its note, +// and set_cutoff() and set_vibrato_depth() change nothing: a binding attaches +// before any of those that asks for more than the defaults. +void audiodsp_sampler_attach_mod(audiodsp_sampler_t *self, uint32_t index, + audiodsp_sampler_mod_t *pair); + // Starts `index` on `data` (mono signed 16-bit, `length` frames). A note the // voice was still sounding moves to the voice's tail and fades out there, // under the new one. Values are clamped here, once, rather than in each binding. Returns false, and starts From 89a55da09bfc36841ed1170478ec670e8513ff20 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:53:01 -0500 Subject: [PATCH 2/4] audiosampler: declare the record helper before play() --- src/audiosampler/SamplerVoice.c | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/audiosampler/SamplerVoice.c b/src/audiosampler/SamplerVoice.c index 61399dd..f826d48 100644 --- a/src/audiosampler/SamplerVoice.c +++ b/src/audiosampler/SamplerVoice.c @@ -25,6 +25,20 @@ mp_obj_t audiosampler_samplervoice_new(audiosampler_sampler_obj_t *parent, return MP_OBJ_FROM_PTR(self); } +// Gives the voice its filter, envelope and LFO state the first time it needs +// any of them, so a sampler whose notes never ask carries none. Allocated +// outside the pump lock, since an allocation may collect. +static void samplervoice_need_mod(audiosampler_samplervoice_obj_t *self) { + if (self->mod != NULL) { + return; + } + audiodsp_sampler_mod_t *pair = m_malloc(2 * sizeof(audiodsp_sampler_mod_t)); + audiodsp_pump_lock_acquire(); + audiodsp_sampler_attach_mod(&self->parent->sampler, self->index, pair); + self->mod = pair; + audiodsp_pump_lock_release(); +} + static float float_arg(mp_obj_t value, float fallback) { return value == mp_const_none ? fallback : (float)mp_obj_get_float(value); } @@ -283,20 +297,6 @@ MP_PROPERTY_GETSET(audiosampler_samplervoice_pitch_obj, (mp_obj_t)&audiosampler_samplervoice_get_pitch_obj, (mp_obj_t)&audiosampler_samplervoice_set_pitch_obj); -// Gives the voice its filter, envelope and LFO state the first time it needs -// any of them, so a sampler whose notes never ask carries none. Allocated -// outside the pump lock, since an allocation may collect. -static void samplervoice_need_mod(audiosampler_samplervoice_obj_t *self) { - if (self->mod != NULL) { - return; - } - audiodsp_sampler_mod_t *pair = m_malloc(2 * sizeof(audiodsp_sampler_mod_t)); - audiodsp_pump_lock_acquire(); - audiodsp_sampler_attach_mod(&self->parent->sampler, self->index, pair); - self->mod = pair; - audiodsp_pump_lock_release(); -} - // The low-pass cutoff in Hz, None for no filter. Live: a change lands // within 32 frames, and a filter switched on starts from rest. static mp_obj_t audiosampler_samplervoice_obj_get_cutoff(mp_obj_t self_in) { From 8d39c5d659991dbf7b5b769f3678cfd620e74bf3 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:54:43 -0500 Subject: [PATCH 3/4] audiosampler: pack a voice's byte-sized fields into its padding --- src/shared/audiodsp_sampler.c | 6 +++--- src/shared/audiodsp_sampler.h | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/shared/audiodsp_sampler.c b/src/shared/audiodsp_sampler.c index 8dd0f09..a88bd04 100644 --- a/src/shared/audiodsp_sampler.c +++ b/src/shared/audiodsp_sampler.c @@ -119,7 +119,7 @@ static void enter_stage(audiodsp_sampler_voice_t *voice, uint32_t stage); // the one before cannot accumulate across the envelope. static void enter_stage(audiodsp_sampler_voice_t *voice, uint32_t stage) { for (;;) { - voice->stage = stage; + voice->stage = (uint8_t)stage; switch (stage) { case AUDIODSP_SAMPLER_STAGE_ATTACK: voice->env = 0.0f; @@ -383,7 +383,7 @@ static void update_mods(audiodsp_sampler_voice_t *voice) { } m->filter_gain = (mods & AUDIODSP_SAMPLER_MOD_FILTER) ? audiodsp_exp2_poly(-m->resonance * (0.5f * 0.16609640f)) : 1.0f; - voice->mods = mods; + voice->mods = (uint8_t)mods; } // The resonance as the filter's 1/Q. Q's response at the cutoff is Q itself, @@ -522,7 +522,7 @@ bool audiodsp_sampler_play(audiodsp_sampler_t *self, uint32_t index, voice->length = length; voice->loop_start = loop_start; voice->loop_end = loop_end; - voice->loop_mode = note->loop_mode; + voice->loop_mode = (uint8_t)note->loop_mode; voice->looping = note->loop_mode != AUDIODSP_SAMPLER_LOOP_NONE; voice->position = 0; float rate = note->rate > 0.0f ? note->rate : (float)self->sample_rate; diff --git a/src/shared/audiodsp_sampler.h b/src/shared/audiodsp_sampler.h index 6ebf74c..458a064 100644 --- a/src/shared/audiodsp_sampler.h +++ b/src/shared/audiodsp_sampler.h @@ -254,9 +254,12 @@ typedef struct { uint32_t length; // frames uint32_t loop_start; uint32_t loop_end; - uint32_t loop_mode; - uint32_t stage; // audiodsp_sampler_stage_t + // Four bytes together, in the padding before `position`: every field + // here is paid twice per voice, by every sampler. + uint8_t loop_mode; // audiodsp_sampler_loop_t + uint8_t stage; // audiodsp_sampler_stage_t bool looping; // still wrapping at loop_end + uint8_t mods; // AUDIODSP_SAMPLER_MOD_*: zero on the plain path uint64_t position; // Q32.32 frames uint64_t step; // Q32.32 frames per output frame float rate_ratio; // rate / sample_rate, so a pitch change is one multiply @@ -276,8 +279,6 @@ typedef struct { float gain_run[2]; // Frames since play(), for the LFOs. Wraps after a day at 48 kHz. uint32_t age; - // AUDIODSP_SAMPLER_MOD_*: zero for a voice on the plain path. - uint32_t mods; // Borrowed from the binding; NULL until the voice first needs it, and // then for good. Never nonzero `mods` without it. audiodsp_sampler_mod_t *mod; From 2919c0d4d682f73bb5cbea7a6f63b0349d43bb07 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:57:24 -0500 Subject: [PATCH 4/4] audiosampler: fail the build if a voice grows; say what the filter and LFOs cost in memory --- docs/guides/play-recorded-notes.md | 7 +++++++ src/shared/audiodsp_sampler.c | 9 +++++++++ src/shared/audiodsp_sampler.h | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/guides/play-recorded-notes.md b/docs/guides/play-recorded-notes.md index 188c118..6d46402 100644 --- a/docs/guides/play-recorded-notes.md +++ b/docs/guides/play-recorded-notes.md @@ -85,6 +85,13 @@ on an ESP32-P4, where 48 filtered voices fit a 256-frame block and 64 only just do: see [what the filter and the LFOs cost](../how-it-works/upstream-diff.md#what-the-filter-and-the-lfos-cost). +They cost no memory until a voice uses them. The first `play()` that asks for +any of them, or the first `cutoff` or nonzero `vibrato_depth` set on a voice, +gives that voice about 270 bytes for their state, which it then keeps. A +`Sampler(voice_count=32)` on MicroPython takes about 19 KB whatever its notes +ask for, and one whose 32 voices all use them takes about 7 KB more, in 32 +small pieces rather than one large one. + ## How it renders Every voice is mixed in one C call per block, summed in float and quantised diff --git a/src/shared/audiodsp_sampler.c b/src/shared/audiodsp_sampler.c index a88bd04..e421b3d 100644 --- a/src/shared/audiodsp_sampler.c +++ b/src/shared/audiodsp_sampler.c @@ -12,6 +12,15 @@ #define Q32_ONE 4294967296.0 +// Every field of a voice is paid twice per voice by every sampler, whether +// its notes use it or not, so the voice stays as small as it was before the +// filter and the LFOs: 112 bytes on a 32-bit board, 120 on a 64-bit desktop. +// What only a modulated voice needs goes in audiodsp_sampler_mod_t. +// A voice that grows past that fails to compile here, as an array of -1. +typedef char audiodsp_sampler_voice_grew[ + sizeof(audiodsp_sampler_voice_t) <= (sizeof(void *) == 8 ? 120u : 112u) + ? 1 : -1]; + static float clampf(float value, float low, float high) { if (value < low) { return low; diff --git a/src/shared/audiodsp_sampler.h b/src/shared/audiodsp_sampler.h index 458a064..f665c6d 100644 --- a/src/shared/audiodsp_sampler.h +++ b/src/shared/audiodsp_sampler.h @@ -65,7 +65,7 @@ // is a separate record, attached to a voice the first time it asks for any of // them. A voice table is every voice and every tail, so a field on the voice // is paid 2 x voice_count times by every sampler, and a table that grew from -// 7 KB to 18 KB at 32 voices was the one allocation a crowded heap refused. +// 7 KB to 16 KB at 32 voices was the one allocation a crowded heap refused. // // A voice that asks for any of them renders in chunks of // AUDIODSP_SAMPLER_CONTROL frames. The modulators are worked out once a