Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chorus audio effect #10044

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
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
1 change: 1 addition & 0 deletions py/circuitpy_defns.mk
Original file line number Diff line number Diff line change
@@ -624,6 +624,7 @@ SRC_SHARED_MODULE_ALL = \
audiocore/WaveFile.c \
audiocore/__init__.c \
audiodelays/Echo.c \
audiodelays/Chorus.c \
audiodelays/__init__.c \
audiofilters/Distortion.c \
audiofilters/Filter.c \
260 changes: 260 additions & 0 deletions shared-bindings/audiodelays/Chorus.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2025 Mark Komus
//
// SPDX-License-Identifier: MIT

#include <stdint.h>

#include "shared-bindings/audiodelays/Chorus.h"
#include "shared-module/audiodelays/Chorus.h"

#include "shared/runtime/context_manager_helpers.h"
#include "py/binary.h"
#include "py/objproperty.h"
#include "py/runtime.h"
#include "shared-bindings/util.h"
#include "shared-module/synthio/block.h"

//| class Chorus:
//| """An Chorus effect"""
//|
//| def __init__(
//| self,
//| max_delay_ms: int = 50,
//| delay_ms: BlockInput = 50.0,
//| voices: synthio.BlockInput = 1.0,
//| buffer_size: int = 512,
//| sample_rate: int = 8000,
//| bits_per_sample: int = 16,
//| samples_signed: bool = True,
//| channel_count: int = 1,
//| ) -> None:
//| """Create a Chorus effect by playing the current sample along with one or more samples
//| (the voices) from the delay buffer. The voices played are evenly spaced across the delay
//| buffer. So for 2 voices you would hear the current sample and the one delay milliseconds back.
//| The delay timing of the chorus can be changed at runtime with the delay_ms parameter but the delay
//| can never exceed the max_delay_ms parameter. The maximum delay is 100ms.
//|
//| :param int max_delay_ms: The maximum time the chorus can be in milliseconds
//| :param synthio.BlockInput delay_ms: The current time of the chorus delay in milliseconds. Must be less the max_delay_ms.
//| :param synthio.BlockInput voices: The number of voices playing split evenly over the delay buffer.
//| :param int buffer_size: The total size in bytes of each of the two playback buffers to use
//| :param int sample_rate: The sample rate to be used
//| :param int channel_count: The number of channels the source samples contain. 1 = mono; 2 = stereo.
//| :param int bits_per_sample: The bits per sample of the effect
//| :param bool samples_signed: Effect is signed (True) or unsigned (False)
//|
//| Playing adding an chorus to a synth::
//|
//| import time
//| import board
//| import audiobusio
//| import synthio
//| import audiodelays
//|
//| audio = audiobusio.I2SOut(bit_clock=board.GP20, word_select=board.GP21, data=board.GP22)
//| synth = synthio.Synthesizer(channel_count=1, sample_rate=44100)
//| chorus = audiodelays.Chorus(max_delay_ms=50, delay_ms=5, buffer_size=1024, channel_count=1, sample_rate=44100)
//| chorus.play(synth)
//| audio.play(chorus)
//|
//| note = synthio.Note(261)
//| while True:
//| synth.press(note)
//| time.sleep(0.25)
//| synth.release(note)
//| time.sleep(5)"""
//| ...
//|
static mp_obj_t audiodelays_chorus_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) {
enum { ARG_max_delay_ms, ARG_delay_ms, ARG_voices, ARG_buffer_size, ARG_sample_rate, ARG_bits_per_sample, ARG_samples_signed, ARG_channel_count, };
static const mp_arg_t allowed_args[] = {
{ MP_QSTR_max_delay_ms, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 50 } },
{ MP_QSTR_delay_ms, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NULL} },
{ MP_QSTR_voices, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NULL} },
{ MP_QSTR_buffer_size, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 512} },
{ MP_QSTR_sample_rate, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 8000} },
{ MP_QSTR_bits_per_sample, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 16} },
{ MP_QSTR_samples_signed, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = true} },
{ MP_QSTR_channel_count, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 1 } },
};

mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)];
mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args);

mp_int_t max_delay_ms = mp_arg_validate_int_range(args[ARG_max_delay_ms].u_int, 1, 100, MP_QSTR_max_delay_ms);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though it doesn't make too much sense from a "chorus" perspective to have a delay length greater than 100ms, should we limit the user to that range? I think we should encourage the user to use certain settings to achieve the typically desired effect in the documentation but not rule out other possibilities.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good point. I'll up the limit to match Echo. Who knows maybe something cool comes from it.


mp_int_t channel_count = mp_arg_validate_int_range(args[ARG_channel_count].u_int, 1, 2, MP_QSTR_channel_count);
mp_int_t sample_rate = mp_arg_validate_int_min(args[ARG_sample_rate].u_int, 1, MP_QSTR_sample_rate);
mp_int_t bits_per_sample = args[ARG_bits_per_sample].u_int;
if (bits_per_sample != 8 && bits_per_sample != 16) {
mp_raise_ValueError(MP_ERROR_TEXT("bits_per_sample must be 8 or 16"));
}

audiodelays_chorus_obj_t *self = mp_obj_malloc(audiodelays_chorus_obj_t, &audiodelays_chorus_type);
common_hal_audiodelays_chorus_construct(self, max_delay_ms, args[ARG_delay_ms].u_obj, args[ARG_voices].u_obj, args[ARG_buffer_size].u_int, bits_per_sample, args[ARG_samples_signed].u_bool, channel_count, sample_rate);

return MP_OBJ_FROM_PTR(self);
}

//| def deinit(self) -> None:
//| """Deinitialises the Chorus."""
//| ...
//|
static mp_obj_t audiodelays_chorus_deinit(mp_obj_t self_in) {
audiodelays_chorus_obj_t *self = MP_OBJ_TO_PTR(self_in);
common_hal_audiodelays_chorus_deinit(self);
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiodelays_chorus_deinit_obj, audiodelays_chorus_deinit);

static void check_for_deinit(audiodelays_chorus_obj_t *self) {
if (common_hal_audiodelays_chorus_deinited(self)) {
raise_deinited_error();
}
}

//| def __enter__(self) -> Chorus:
//| """No-op used by Context Managers."""
//| ...
//|
// Provided by context manager helper.

//| def __exit__(self) -> None:
//| """Automatically deinitializes when exiting a context. See
//| :ref:`lifetime-and-contextmanagers` for more info."""
//| ...
//|
static mp_obj_t audiodelays_chorus_obj___exit__(size_t n_args, const mp_obj_t *args) {
(void)n_args;
common_hal_audiodelays_chorus_deinit(args[0]);
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiodelays_chorus___exit___obj, 4, 4, audiodelays_chorus_obj___exit__);


//| delay_ms: synthio.BlockInput
//| """The current time of the chorus delay in milliseconds. Must be less the max_delay_ms."""
//|
static mp_obj_t audiodelays_chorus_obj_get_delay_ms(mp_obj_t self_in) {
audiodelays_chorus_obj_t *self = MP_OBJ_TO_PTR(self_in);

return common_hal_audiodelays_chorus_get_delay_ms(self);
}
MP_DEFINE_CONST_FUN_OBJ_1(audiodelays_chorus_get_delay_ms_obj, audiodelays_chorus_obj_get_delay_ms);

static mp_obj_t audiodelays_chorus_obj_set_delay_ms(mp_obj_t self_in, mp_obj_t delay_ms_in) {
audiodelays_chorus_obj_t *self = MP_OBJ_TO_PTR(self_in);
common_hal_audiodelays_chorus_set_delay_ms(self, delay_ms_in);
return mp_const_none;
}
MP_DEFINE_CONST_FUN_OBJ_2(audiodelays_chorus_set_delay_ms_obj, audiodelays_chorus_obj_set_delay_ms);

MP_PROPERTY_GETSET(audiodelays_chorus_delay_ms_obj,
(mp_obj_t)&audiodelays_chorus_get_delay_ms_obj,
(mp_obj_t)&audiodelays_chorus_set_delay_ms_obj);

//| voices: synthio.BlockInput
//| """The number of voices playing split evenly over the delay buffer."""
static mp_obj_t audiodelays_chorus_obj_get_voices(mp_obj_t self_in) {
return common_hal_audiodelays_chorus_get_voices(self_in);
}
MP_DEFINE_CONST_FUN_OBJ_1(audiodelays_chorus_get_voices_obj, audiodelays_chorus_obj_get_voices);

static mp_obj_t audiodelays_chorus_obj_set_voices(mp_obj_t self_in, mp_obj_t voices_in) {
audiodelays_chorus_obj_t *self = MP_OBJ_TO_PTR(self_in);
common_hal_audiodelays_chorus_set_voices(self, voices_in);
return mp_const_none;
}
MP_DEFINE_CONST_FUN_OBJ_2(audiodelays_chorus_set_voices_obj, audiodelays_chorus_obj_set_voices);

MP_PROPERTY_GETSET(audiodelays_chorus_voices_obj,
(mp_obj_t)&audiodelays_chorus_get_voices_obj,
(mp_obj_t)&audiodelays_chorus_set_voices_obj);

//| playing: bool
//| """True when the effect is playing a sample. (read-only)"""
//|
static mp_obj_t audiodelays_chorus_obj_get_playing(mp_obj_t self_in) {
audiodelays_chorus_obj_t *self = MP_OBJ_TO_PTR(self_in);
check_for_deinit(self);
return mp_obj_new_bool(common_hal_audiodelays_chorus_get_playing(self));
}
MP_DEFINE_CONST_FUN_OBJ_1(audiodelays_chorus_get_playing_obj, audiodelays_chorus_obj_get_playing);

MP_PROPERTY_GETTER(audiodelays_chorus_playing_obj,
(mp_obj_t)&audiodelays_chorus_get_playing_obj);

//| def play(self, sample: circuitpython_typing.AudioSample, *, loop: bool = False) -> None:
//| """Plays the sample once when loop=False and continuously when loop=True.
//| Does not block. Use `playing` to block.
//|
//| The sample must match the encoding settings given in the constructor."""
//| ...
//|
static mp_obj_t audiodelays_chorus_obj_play(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) {
enum { ARG_sample, ARG_loop };
static const mp_arg_t allowed_args[] = {
{ MP_QSTR_sample, MP_ARG_OBJ | MP_ARG_REQUIRED, {} },
{ MP_QSTR_loop, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} },
};
audiodelays_chorus_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]);
check_for_deinit(self);
mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)];
mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args);


mp_obj_t sample = args[ARG_sample].u_obj;
common_hal_audiodelays_chorus_play(self, sample, args[ARG_loop].u_bool);

return mp_const_none;
}
MP_DEFINE_CONST_FUN_OBJ_KW(audiodelays_chorus_play_obj, 1, audiodelays_chorus_obj_play);

//| def stop(self) -> None:
//| """Stops playback of the sample."""
//| ...
//|
//|
static mp_obj_t audiodelays_chorus_obj_stop(mp_obj_t self_in) {
audiodelays_chorus_obj_t *self = MP_OBJ_TO_PTR(self_in);

common_hal_audiodelays_chorus_stop(self);
return mp_const_none;
}
MP_DEFINE_CONST_FUN_OBJ_1(audiodelays_chorus_stop_obj, audiodelays_chorus_obj_stop);

static const mp_rom_map_elem_t audiodelays_chorus_locals_dict_table[] = {
// Methods
{ MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&audiodelays_chorus_deinit_obj) },
{ MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&default___enter___obj) },
{ MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&audiodelays_chorus___exit___obj) },
{ MP_ROM_QSTR(MP_QSTR_play), MP_ROM_PTR(&audiodelays_chorus_play_obj) },
{ MP_ROM_QSTR(MP_QSTR_stop), MP_ROM_PTR(&audiodelays_chorus_stop_obj) },

// Properties
{ MP_ROM_QSTR(MP_QSTR_playing), MP_ROM_PTR(&audiodelays_chorus_playing_obj) },
{ MP_ROM_QSTR(MP_QSTR_delay_ms), MP_ROM_PTR(&audiodelays_chorus_delay_ms_obj) },
{ MP_ROM_QSTR(MP_QSTR_voices), MP_ROM_PTR(&audiodelays_chorus_voices_obj) },
};
static MP_DEFINE_CONST_DICT(audiodelays_chorus_locals_dict, audiodelays_chorus_locals_dict_table);

static const audiosample_p_t audiodelays_chorus_proto = {
MP_PROTO_IMPLEMENT(MP_QSTR_protocol_audiosample)
.sample_rate = (audiosample_sample_rate_fun)common_hal_audiodelays_chorus_get_sample_rate,
.bits_per_sample = (audiosample_bits_per_sample_fun)common_hal_audiodelays_chorus_get_bits_per_sample,
.channel_count = (audiosample_channel_count_fun)common_hal_audiodelays_chorus_get_channel_count,
.reset_buffer = (audiosample_reset_buffer_fun)audiodelays_chorus_reset_buffer,
.get_buffer = (audiosample_get_buffer_fun)audiodelays_chorus_get_buffer,
.get_buffer_structure = (audiosample_get_buffer_structure_fun)audiodelays_chorus_get_buffer_structure,
};

MP_DEFINE_CONST_OBJ_TYPE(
audiodelays_chorus_type,
MP_QSTR_Chorus,
MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS,
make_new, audiodelays_chorus_make_new,
locals_dict, &audiodelays_chorus_locals_dict,
protocol, &audiodelays_chorus_proto
);
33 changes: 33 additions & 0 deletions shared-bindings/audiodelays/Chorus.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2025 Mark Komus
//
// SPDX-License-Identifier: MIT

#pragma once

#include "shared-module/audiodelays/Chorus.h"

extern const mp_obj_type_t audiodelays_chorus_type;

void common_hal_audiodelays_chorus_construct(audiodelays_chorus_obj_t *self, uint32_t max_delay_ms,
mp_obj_t delay_ms, mp_obj_t voices,
uint32_t buffer_size, uint8_t bits_per_sample,
bool samples_signed, uint8_t channel_count, uint32_t sample_rate);

void common_hal_audiodelays_chorus_deinit(audiodelays_chorus_obj_t *self);
bool common_hal_audiodelays_chorus_deinited(audiodelays_chorus_obj_t *self);

uint32_t common_hal_audiodelays_chorus_get_sample_rate(audiodelays_chorus_obj_t *self);
uint8_t common_hal_audiodelays_chorus_get_channel_count(audiodelays_chorus_obj_t *self);
uint8_t common_hal_audiodelays_chorus_get_bits_per_sample(audiodelays_chorus_obj_t *self);

mp_obj_t common_hal_audiodelays_chorus_get_delay_ms(audiodelays_chorus_obj_t *self);
void common_hal_audiodelays_chorus_set_delay_ms(audiodelays_chorus_obj_t *self, mp_obj_t delay_ms);

mp_obj_t common_hal_audiodelays_chorus_get_voices(audiodelays_chorus_obj_t *self);
void common_hal_audiodelays_chorus_set_voices(audiodelays_chorus_obj_t *self, mp_obj_t voices);

bool common_hal_audiodelays_chorus_get_playing(audiodelays_chorus_obj_t *self);
void common_hal_audiodelays_chorus_play(audiodelays_chorus_obj_t *self, mp_obj_t sample, bool loop);
void common_hal_audiodelays_chorus_stop(audiodelays_chorus_obj_t *self);
2 changes: 2 additions & 0 deletions shared-bindings/audiodelays/__init__.c
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@

#include "shared-bindings/audiodelays/__init__.h"
#include "shared-bindings/audiodelays/Echo.h"
#include "shared-bindings/audiodelays/Chorus.h"

//| """Support for audio delay effects
//|
@@ -21,6 +22,7 @@
static const mp_rom_map_elem_t audiodelays_module_globals_table[] = {
{ MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiodelays) },
{ MP_ROM_QSTR(MP_QSTR_Echo), MP_ROM_PTR(&audiodelays_echo_type) },
{ MP_ROM_QSTR(MP_QSTR_Chorus), MP_ROM_PTR(&audiodelays_chorus_type) },
};

static MP_DEFINE_CONST_DICT(audiodelays_module_globals, audiodelays_module_globals_table);
367 changes: 367 additions & 0 deletions shared-module/audiodelays/Chorus.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,367 @@
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2025 Mark Komus
//
// SPDX-License-Identifier: MIT
#include "shared-bindings/audiodelays/Chorus.h"

#include <stdint.h>
#include <math.h>
#include "py/runtime.h"

void common_hal_audiodelays_chorus_construct(audiodelays_chorus_obj_t *self, uint32_t max_delay_ms,
mp_obj_t delay_ms, mp_obj_t voices,
uint32_t buffer_size, uint8_t bits_per_sample,
bool samples_signed, uint8_t channel_count, uint32_t sample_rate) {

// Basic settings every effect and audio sample has
// These are the effects values, not the source sample(s)
self->bits_per_sample = bits_per_sample; // Most common is 16, but 8 is also supported in many places
self->samples_signed = samples_signed; // Are the samples we provide signed (common is true)
self->channel_count = channel_count; // Channels can be 1 for mono or 2 for stereo
self->sample_rate = sample_rate; // Sample rate for the effect, this generally needs to match all audio objects

// To smooth things out as CircuitPython is doing other tasks most audio objects have a buffer
// A double buffer is set up here so the audio output can use DMA on buffer 1 while we
// write to and create buffer 2.
// This buffer is what is passed to the audio component that plays the effect.
// Samples are set sequentially. For stereo audio they are passed L/R/L/R/...
self->buffer_len = buffer_size; // in bytes

self->buffer[0] = m_malloc(self->buffer_len);
if (self->buffer[0] == NULL) {
common_hal_audiodelays_chorus_deinit(self);
m_malloc_fail(self->buffer_len);
}
memset(self->buffer[0], 0, self->buffer_len);

self->buffer[1] = m_malloc(self->buffer_len);
if (self->buffer[1] == NULL) {
common_hal_audiodelays_chorus_deinit(self);
m_malloc_fail(self->buffer_len);
}
memset(self->buffer[1], 0, self->buffer_len);

self->last_buf_idx = 1; // Which buffer to use first, toggle between 0 and 1

// Initialize other values most effects will need.
self->sample = NULL; // The current playing sample
self->sample_remaining_buffer = NULL; // Pointer to the start of the sample buffer we have not played
self->sample_buffer_length = 0; // How many samples do we have left to play (these may be 16 bit!)
self->loop = false; // When the sample is done do we loop to the start again or stop (e.g. in a wav file)
self->more_data = false; // Is there still more data to read from the sample or did we finish

// The below section sets up the chorus effect's starting values. For a different effect this section will change

// If we did not receive a BlockInput we need to create a default float value
if (voices == MP_OBJ_NULL) {
voices = mp_obj_new_float(MICROPY_FLOAT_CONST(1.0));
}
synthio_block_assign_slot(voices, &self->voices, MP_QSTR_voices);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if BlockInput support is necessary for voices, especially since changes in this property are integer-based. I think it'd only need to be an integer >= 1.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had it was BlockInput so the number of voices could be changed slowly from an LFO. It rounds at the moment. It may never be used but I'm not sure if there is a downside?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine by me, but I think it needs testing to ensure that the output isn't too distorted during the voice changes.


if (delay_ms == MP_OBJ_NULL) {
delay_ms = mp_obj_new_float(MICROPY_FLOAT_CONST(50.0));
}
synthio_block_assign_slot(delay_ms, &self->delay_ms, MP_QSTR_delay_ms);

// Many effects may need buffers of what was played this shows how it was done for the chorus
// A maximum length buffer was created and then the current chorus length can be dynamically changes
// without having to reallocate a large chunk of memory.

// Allocate the chorus buffer for the max possible delay, chorus is always 16-bit
self->max_delay_ms = max_delay_ms;
self->max_chorus_buffer_len = self->sample_rate / MICROPY_FLOAT_CONST(1000.0) * max_delay_ms * (self->channel_count * sizeof(uint16_t)); // bytes
self->chorus_buffer = m_malloc(self->max_chorus_buffer_len);
if (self->chorus_buffer == NULL) {
common_hal_audiodelays_chorus_deinit(self);
m_malloc_fail(self->max_chorus_buffer_len);
}
memset(self->chorus_buffer, 0, self->max_chorus_buffer_len);

// calculate the length of a single sample in milliseconds
self->sample_ms = MICROPY_FLOAT_CONST(1000.0) / self->sample_rate;

// calculate everything needed for the current delay
mp_float_t f_delay_ms = synthio_block_slot_get(&self->delay_ms);
chorus_recalculate_delay(self, f_delay_ms);

// where we are storing the next chorus sample
self->chorus_buffer_pos = 0;
}

bool common_hal_audiodelays_chorus_deinited(audiodelays_chorus_obj_t *self) {
if (self->chorus_buffer == NULL) {
return true;
}
return false;
}

void common_hal_audiodelays_chorus_deinit(audiodelays_chorus_obj_t *self) {
if (common_hal_audiodelays_chorus_deinited(self)) {
return;
}
self->chorus_buffer = NULL;
self->buffer[0] = NULL;
self->buffer[1] = NULL;
}

mp_obj_t common_hal_audiodelays_chorus_get_delay_ms(audiodelays_chorus_obj_t *self) {
return self->delay_ms.obj;
}

void common_hal_audiodelays_chorus_set_delay_ms(audiodelays_chorus_obj_t *self, mp_obj_t delay_ms) {
synthio_block_assign_slot(delay_ms, &self->delay_ms, MP_QSTR_delay_ms);

mp_float_t f_delay_ms = synthio_block_slot_get(&self->delay_ms);

chorus_recalculate_delay(self, f_delay_ms);
}

void chorus_recalculate_delay(audiodelays_chorus_obj_t *self, mp_float_t f_delay_ms) {
// Require that delay is at least 1 sample long
f_delay_ms = MAX(f_delay_ms, self->sample_ms);

// Calculate the current chorus buffer length in bytes
uint32_t new_chorus_buffer_len = (uint32_t)(self->sample_rate / MICROPY_FLOAT_CONST(1000.0) * f_delay_ms) * (self->channel_count * sizeof(uint16_t));

if (new_chorus_buffer_len < 0) { // or too short!
return;
}

self->chorus_buffer_len = new_chorus_buffer_len;

self->current_delay_ms = f_delay_ms;
}

mp_obj_t common_hal_audiodelays_chorus_get_voices(audiodelays_chorus_obj_t *self) {
return self->voices.obj;
}

void common_hal_audiodelays_chorus_set_voices(audiodelays_chorus_obj_t *self, mp_obj_t voices) {
synthio_block_assign_slot(voices, &self->voices, MP_QSTR_voices);
}

uint32_t common_hal_audiodelays_chorus_get_sample_rate(audiodelays_chorus_obj_t *self) {
return self->sample_rate;
}

uint8_t common_hal_audiodelays_chorus_get_channel_count(audiodelays_chorus_obj_t *self) {
return self->channel_count;
}

uint8_t common_hal_audiodelays_chorus_get_bits_per_sample(audiodelays_chorus_obj_t *self) {
return self->bits_per_sample;
}

void audiodelays_chorus_reset_buffer(audiodelays_chorus_obj_t *self,
bool single_channel_output,
uint8_t channel) {

memset(self->buffer[0], 0, self->buffer_len);
memset(self->buffer[1], 0, self->buffer_len);
memset(self->chorus_buffer, 0, self->chorus_buffer_len);
}

bool common_hal_audiodelays_chorus_get_playing(audiodelays_chorus_obj_t *self) {
return self->sample != NULL;
}

void common_hal_audiodelays_chorus_play(audiodelays_chorus_obj_t *self, mp_obj_t sample, bool loop) {
// When a sample is to be played we must ensure the samples values matches what we expect
// Then we reset the sample and get the first buffer to play
// The get_buffer function will actually process that data

if (audiosample_sample_rate(sample) != self->sample_rate) {
mp_raise_ValueError_varg(MP_ERROR_TEXT("The sample's %q does not match"), MP_QSTR_sample_rate);
}
if (audiosample_channel_count(sample) != self->channel_count) {
mp_raise_ValueError_varg(MP_ERROR_TEXT("The sample's %q does not match"), MP_QSTR_channel_count);
}
if (audiosample_bits_per_sample(sample) != self->bits_per_sample) {
mp_raise_ValueError_varg(MP_ERROR_TEXT("The sample's %q does not match"), MP_QSTR_bits_per_sample);
}
bool single_buffer;
bool samples_signed;
uint32_t max_buffer_length;
uint8_t spacing;
audiosample_get_buffer_structure(sample, false, &single_buffer, &samples_signed, &max_buffer_length, &spacing);
if (samples_signed != self->samples_signed) {
mp_raise_ValueError_varg(MP_ERROR_TEXT("The sample's %q does not match"), MP_QSTR_signedness);
}

self->sample = sample;
self->loop = loop;

audiosample_reset_buffer(self->sample, false, 0);
audioio_get_buffer_result_t result = audiosample_get_buffer(self->sample, false, 0, (uint8_t **)&self->sample_remaining_buffer, &self->sample_buffer_length);

// Track remaining sample length in terms of bytes per sample
self->sample_buffer_length /= (self->bits_per_sample / 8);
// Store if we have more data in the sample to retrieve
self->more_data = result == GET_BUFFER_MORE_DATA;

return;
}

void common_hal_audiodelays_chorus_stop(audiodelays_chorus_obj_t *self) {
// When the sample is set to stop playing do any cleanup here
// For chorus we clear the sample but the chorus continues until the object reading our effect stops
self->sample = NULL;
return;
}

audioio_get_buffer_result_t audiodelays_chorus_get_buffer(audiodelays_chorus_obj_t *self, bool single_channel_output, uint8_t channel,
uint8_t **buffer, uint32_t *buffer_length) {

// Switch our buffers to the other buffer
self->last_buf_idx = !self->last_buf_idx;

// If we are using 16 bit samples we need a 16 bit pointer, 8 bit needs an 8 bit pointer
int16_t *word_buffer = (int16_t *)self->buffer[self->last_buf_idx];
int8_t *hword_buffer = self->buffer[self->last_buf_idx];
uint32_t length = self->buffer_len / (self->bits_per_sample / 8);

// The chorus buffer is always stored as a 16-bit value internally
int16_t *chorus_buffer = (int16_t *)self->chorus_buffer;
uint32_t chorus_buf_len = self->chorus_buffer_len / sizeof(uint16_t);

// Loop over the entire length of our buffer to fill it, this may require several calls to get data from the sample
while (length != 0) {
// Check if there is no more sample to play, we will either load more data, reset the sample if loop is on or clear the sample
if (self->sample_buffer_length == 0) {
if (!self->more_data) { // The sample has indicated it has no more data to play
if (self->loop && self->sample) { // If we are supposed to loop reset the sample to the start
audiosample_reset_buffer(self->sample, false, 0);
} else { // If we were not supposed to loop the sample, stop playing it but we still need to play the chorus
self->sample = NULL;
}
}
if (self->sample) {
// Load another sample buffer to play
audioio_get_buffer_result_t result = audiosample_get_buffer(self->sample, false, 0, (uint8_t **)&self->sample_remaining_buffer, &self->sample_buffer_length);
// Track length in terms of words.
self->sample_buffer_length /= (self->bits_per_sample / 8);
self->more_data = result == GET_BUFFER_MORE_DATA;
}
}

// Determine how many bytes we can process to our buffer, the less of the sample we have left and our buffer remaining
uint32_t n;
if (self->sample == NULL) {
n = MIN(length, SYNTHIO_MAX_DUR * self->channel_count);
} else {
n = MIN(MIN(self->sample_buffer_length, length), SYNTHIO_MAX_DUR * self->channel_count);
}

// get the effect values we need from the BlockInput. These may change at run time so you need to do bounds checking if required
shared_bindings_synthio_lfo_tick(self->sample_rate, n / self->channel_count);

int32_t voices = MAX(synthio_block_slot_get(&self->voices), 1.0);

mp_float_t f_delay_ms = synthio_block_slot_get(&self->delay_ms);
if (MICROPY_FLOAT_C_FUN(fabs)(self->current_delay_ms - f_delay_ms) >= self->sample_ms) {
chorus_recalculate_delay(self, f_delay_ms);
}

if (self->sample == NULL) {
if (self->samples_signed) {
memset(word_buffer, 0, n * (self->bits_per_sample / 8));
} else {
// For unsigned samples set to the middle which is "quiet"
if (MP_LIKELY(self->bits_per_sample == 16)) {
uint16_t *uword_buffer = (uint16_t *)word_buffer;
for (uint32_t i = 0; i < n; i++) {
*uword_buffer++ = 32768;
}
} else {
memset(hword_buffer, 128, n * (self->bits_per_sample / 8));
}
}
} else {
int16_t *sample_src = (int16_t *)self->sample_remaining_buffer; // for 16-bit samples
int8_t *sample_hsrc = (int8_t *)self->sample_remaining_buffer; // for 8-bit samples

for (uint32_t i = 0; i < n; i++) {
int32_t sample_word = 0;
if (MP_LIKELY(self->bits_per_sample == 16)) {
sample_word = sample_src[i];
} else {
if (self->samples_signed) {
sample_word = sample_hsrc[i];
} else {
// Be careful here changing from an 8 bit unsigned to signed into a 32-bit signed
sample_word = (int8_t)(((uint8_t)sample_hsrc[i]) ^ 0x80);
}
}

chorus_buffer[self->chorus_buffer_pos++] = (int16_t)sample_word;

int32_t word = 0;
if (voices == 1) {
word = sample_word;
} else {
int32_t step = chorus_buf_len / (voices - 1) - 1;
int32_t c_pos = self->chorus_buffer_pos - 1;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If chorus_buffer_pos == 0, c_pos could be -1. If the if (c_pos < 0) { ... check is moved to the top of the for loop below, it would avoid going out of the range of the buffer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, good catch!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched it to also use the full buffer for the delay


for (int32_t v = 0; v < voices; v++) {
word += chorus_buffer[c_pos];

c_pos -= step;
if (c_pos < 0) {
c_pos += chorus_buf_len;
}
}
word = word / voices;

word = synthio_mix_down_sample(word, SYNTHIO_MIX_DOWN_SCALE(2));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't synthio_mix_down_sample(word, SYNTHIO_MIX_DOWN_SCALE(voices)) have a similar effect rather than calculating the average and then performing the mix down?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SYNTHIO_MIX_DOWN_SCALE value may also need to be pre-calculated in common_hal_audiodelays_chorus_set_voices to avoid unnecessary float calculations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

synthio_mix_down_sample works to remove peaks that would push close to/beyond the upper range of the int16. It won't actually perform and average at all, especially if you are dealing with quieter sounds.

E.G. say a range of -20 to +20 and you have 4 voice samples -5, 0, +5, +5 which results in +5/4 or 1.25. The mix down call would just pass +5 on.

The mix down call is required cause you could have 4 voice samples of +15, +15, +15, +15 giving you +60 in a +20 upper range.

That said I should change the 2 to voices and whether we pre-calculate that is based on the next comment I believe.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my experimentation, I've found that averaging reduces the perceived volume of the effect, and that the mix down call is appropriate enough to prevent peaking. This is the same paradigm used in other audio effects. For example, audiodelays.Echo adds echo and sample together without dividing by 2 before mixing down.

word = (int32_t)(echo_buffer[j % echo_buf_len] * decay + sample_word);
word = synthio_mix_down_sample(word, SYNTHIO_MIX_DOWN_SCALE(2));

Another example is synthio which adds each note buffer into the output buffer via sum_with_loudness then applies mix down.

// mix down audio
for (size_t i = 0; i < dur * synth->base.channel_count; i++) {
int32_t sample = out_buffer32[i];
out_buffer16[i] = synthio_mix_down_sample(sample, SYNTHIO_MIX_DOWN_SCALE(CIRCUITPY_SYNTHIO_MAX_CHANNELS));
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had some time (and brain power) to think about this some more and I agree. Going to test it out to make sure nothing strange happens.

}

if (MP_LIKELY(self->bits_per_sample == 16)) {
word_buffer[i] = word;
if (!self->samples_signed) {
word_buffer[i] ^= 0x8000;
}
} else {
int8_t out = word;
if (self->samples_signed) {
hword_buffer[i] = out;
} else {
hword_buffer[i] = (uint8_t)out ^ 0x80;
}
}

if (self->chorus_buffer_pos >= chorus_buf_len) {
self->chorus_buffer_pos = 0;
}
}
self->sample_remaining_buffer += (n * (self->bits_per_sample / 8));
self->sample_buffer_length -= n;
}
// Update the remaining length and the buffer positions based on how much we wrote into our buffer
length -= n;
word_buffer += n;
hword_buffer += n;
}

// Finally pass our buffer and length to the calling audio function
*buffer = (uint8_t *)self->buffer[self->last_buf_idx];
*buffer_length = self->buffer_len;

// Chorus always returns more data but some effects may return GET_BUFFER_DONE or GET_BUFFER_ERROR (see audiocore/__init__.h)
return GET_BUFFER_MORE_DATA;
}

void audiodelays_chorus_get_buffer_structure(audiodelays_chorus_obj_t *self, bool single_channel_output,
bool *single_buffer, bool *samples_signed, uint32_t *max_buffer_length, uint8_t *spacing) {

// Return information about the effect's buffer (not the sample's)
// These are used by calling audio objects to determine how to handle the effect's buffer
*single_buffer = false;
*samples_signed = self->samples_signed;
*max_buffer_length = self->buffer_len;
if (single_channel_output) {
*spacing = self->channel_count;
} else {
*spacing = 1;
}
}
61 changes: 61 additions & 0 deletions shared-module/audiodelays/Chorus.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2025 Mark Komus
//
// SPDX-License-Identifier: MIT
#pragma once

#include "py/obj.h"

#include "shared-module/audiocore/__init__.h"
#include "shared-module/synthio/block.h"

extern const mp_obj_type_t audiodelays_chorus_type;

typedef struct {
mp_obj_base_t base;
uint32_t max_delay_ms;
synthio_block_slot_t delay_ms;
mp_float_t current_delay_ms;
mp_float_t sample_ms;
synthio_block_slot_t voices;

uint8_t bits_per_sample;
bool samples_signed;
uint8_t channel_count;
uint32_t sample_rate;

int8_t *buffer[2];
uint8_t last_buf_idx;
uint32_t buffer_len; // max buffer in bytes

uint8_t *sample_remaining_buffer;
uint32_t sample_buffer_length;

bool loop;
bool more_data;

int8_t *chorus_buffer;
uint32_t chorus_buffer_len; // bytes
uint32_t max_chorus_buffer_len; // bytes

uint32_t chorus_buffer_pos; // words

mp_obj_t sample;
} audiodelays_chorus_obj_t;

void chorus_recalculate_delay(audiodelays_chorus_obj_t *self, mp_float_t f_delay_ms);

void audiodelays_chorus_reset_buffer(audiodelays_chorus_obj_t *self,
bool single_channel_output,
uint8_t channel);

audioio_get_buffer_result_t audiodelays_chorus_get_buffer(audiodelays_chorus_obj_t *self,
bool single_channel_output,
uint8_t channel,
uint8_t **buffer,
uint32_t *buffer_length); // length in bytes

void audiodelays_chorus_get_buffer_structure(audiodelays_chorus_obj_t *self, bool single_channel_output,
bool *single_buffer, bool *samples_signed,
uint32_t *max_buffer_length, uint8_t *spacing);