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

Add loop_start and loop_end properties to synthio.Note for waveshaping and sampling capabilities. #8629

Merged
merged 3 commits into from
Nov 21, 2023

Conversation

dcooperdalrymple
Copy link

Lately, I have been experimenting with utilizing synthio.Note as a sampling engine. This is done by loading audio data into the note.waveform property and manipulating the note.bend in such a way that the frequency of the note object reflects the actual frequency of the audio sample data (view code).

Currently, a limitation is that waveform data will always loop from 0 to buffer size - 1 (inclusive). By implementing simple loop_start and loop_end properties and some basic boolean logic, you can modify the looping start and end point of the waveform data when filling the audio buffer.

Additionally, when an envelope is triggered the accumulator will begin at 0 regardless of the loop settings and only start looping at loop_start once it's hit either loop_end or the end of waveform data.

Relevant Links

NOTE: This is my first attempt at a contribution to adafruit/circuitpython. Feel free to correct any improper formatting or language in the provided code updates.

@dhalbert dhalbert requested a review from jepler November 18, 2023 01:25
Copy link
Member

@jepler jepler left a comment

Choose a reason for hiding this comment

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

Thank you! This looks nice and simple and adds good functionality. I have two small comments that may help result in better docstrings, please take a look.

Comment on lines 303 to 307
//| loop_start: int
//| """The index of where to begin looping waveform data. Must be greater than 0 and less than the total size of the waveform data."""
STATIC mp_obj_t synthio_note_get_loop_start(mp_obj_t self_in) {
synthio_note_obj_t *self = MP_OBJ_TO_PTR(self_in);
return mp_obj_new_int(common_hal_synthio_note_get_loop_start(self));
Copy link
Member

Choose a reason for hiding this comment

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

Can you clarify this docstring? I think it "must be greater than or equal to zero and less than ..."

Comment on lines 321 to 322
//| loop_end: int
//| """The index of where to end looping waveform data. Must be greater than 0 or ``loop_start`` and less than the total size of the waveform data."""
Copy link
Member

Choose a reason for hiding this comment

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

Can you clarify this docstring too? I think I'd add "If it's zero, or greater than or equal to the length of the waveform, the loop occurs at the end of the waveform", or something along those lines.

}

void common_hal_synthio_note_set_loop_start(synthio_note_obj_t *self, mp_int_t value_in) {
mp_int_t val = mp_arg_validate_int_range(value_in, 0, 32767, MP_QSTR_loop_start);
Copy link
Member

Choose a reason for hiding this comment

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

I used 16384 as the maximum length of a waveform (which maybe should have been 16383 since the upper bound is inclusive); this should probably match, which means it would be good if there was a #define for it in shared-module/synthio/__init__.h.

I don't have any notes that show why I used 16384 instead of a higher value like 32767. I may have been concerned about overflow in arithmetic.

Copy link
Author

Choose a reason for hiding this comment

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

Allowing a higher maximum size would definitely be a plus for sampling, but I vote we leave that for a separate PR. Creating the #define will help make the process of changing that value in the future easier, though.

Copy link
Member

@jepler jepler left a comment

Choose a reason for hiding this comment

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

Thank you! This looks like a nice bit of added functionality. I have a few small comments that I hope will lead to better docstrings, please take a look.

@dcooperdalrymple
Copy link
Author

Hi @jepler! Thanks for chiming on this. I have no issue updating those docstrings (which were rushed initially) and creating that global #define, but after further thought, I want discuss some potential modifications first.

  • These properties may be simplified and more semantic if they were a single tuple called waveform_loop such as (start, end).
  • I do like needing to specify the index of the loop end, but it may be wiser to instead ask for the loop length. Currently, loop_end is completely ignored in the case where is it less than loop_start. Ie: note.loop_start = 256, note.loop_end = 128. With length instead, as long as it is greater than 2 (there must be at least 2 samples to my understanding), it should always work.
  • I haven't played around with ring modulation too much, but there shouldn't be any reason we can't also have a ring_waveform_loop property.

Notes on Testing:

  • I haven't run computation tests with all 12 notes running simultaneously, but the only operation that was added per-frame of the buffer is the + offset which I'm guessing won't make a significant difference to the CPU overhead.
  • The only device I've tested this on is an RP2040. I don't see any reason why other platforms would cause compatibility issues, but I do have some ESP32 devices laying around if that is needed.

@jepler
Copy link
Member

jepler commented Nov 18, 2023

I don't have a strong feeling about a pair of properties vs a single property that is a tuple. Here are some things to consider:

  • with a tuple, you can guarantee the assignment is atomic (both start and end updated at the same time)
  • you can check that 0 <= start < end and reject some nonsense
  • (but you can't check end < len(waveform) since waveform can be updated dynamically with a waveform of a different length

Creating a fresh tuple requires allocations, but so do many things in synthio (new envelope, new note being two obvious examples), so that's no reason to exclude it.

Something else to consider: A memoryview() can be efficiently sliced, in that it slicing't create a copy of the underlying memory. So, assigning note.waveform=memoryview(full_waveform)[start:end] is real close to what this PR does. However, it doesn't provide any way to run the [0:start] segment of the waveform exactly once at the start of a note.

I don't mind that the behavior with end <= start is nonsensical; the docs can simply say that the result is undefined.

I'm not particularly concerned about the performance; as you note, you didn't add anything to an inner loop.

…form_loop_...', added 'ring_waveform_loop_...` parameters, and updated docstrings.
@dcooperdalrymple
Copy link
Author

  • I've decided to keep the current implementation mostly because I'm not a big fan of the current method of handling note envelopes.
  • I was not aware of the use of memoryview in this way. I see that as a great option for basic waveshaping, but it does conflict with the needs of sampling.
  • I've added in the define as SYNTHIO_WAVEFORM_SIZE and also made the slight adjustment of requiring that the loop_end parameter be at least 1. This makes sense from a semantic point of view, but make setting the property as 0 to quickly disable it not possible.
  • I've added parameters to control the looping of ring_waveform as well. By doing so, I've also renamed the properties to waveform_loop_... and ring_waveform_loop_.... I've updated the original example gist and added a new one to demonstrate this on the ring modulator: ring_waveform_loop_test.py & video demonstration.
  • I apologize for all of these updates being stashed into one commit. Pre-commit was fighting me on my attempts to separate them.

Let me know if you'd like to make any other adjustments, @jepler. Otherwise, I'm happy with the current iteration.

@jepler
Copy link
Member

jepler commented Nov 20, 2023

ping @todbot @jedgarpark in case you have feedback

@jepler
Copy link
Member

jepler commented Nov 20, 2023

I pushed some small tweaks to the documentation.

@todbot
Copy link

todbot commented Nov 20, 2023

This is a wonderful addition, thanks Cooper! I'll be loading this onto some boards shortly.

@dcooperdalrymple
Copy link
Author

Great job on the documentation, @jepler! I didn't want to get my hands too dirty on that one, but I think you nailed it. I'll try to put together a standalone gist soon to better demonstrate the use of synthio for sampling. Plus, I think the recent addition of synthio.Synthesizer.note_info could help make this even better.

@dcooperdalrymple
Copy link
Author

Here's my quick sampler demonstration using the loop parameters of this build: code.py & video demonstration.

Copy link
Member

@jepler jepler left a comment

Choose a reason for hiding this comment

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

OK, let's ship it! Thanks again for your contribution.

@@ -53,13 +57,17 @@ static const mp_arg_t note_properties[] = {
//| frequency: float,
//| panning: BlockInput = 0.0,
//| waveform: Optional[ReadableBuffer] = None,
//| waveform_loop_start: int = 0,
//| waveform_loop_end: int = 0,
Copy link
Member

Choose a reason for hiding this comment

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

Now the default is not 0 anymore.

@jepler jepler merged commit 383f797 into adafruit:main Nov 21, 2023
325 checks passed
@dcooperdalrymple dcooperdalrymple deleted the synthio-note-loop branch November 21, 2023 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants