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 support for tuple of Biquad objects within audiofilters.Filter. #9772

Merged
merged 5 commits into from
Nov 10, 2024

Conversation

relic-se
Copy link

Support the processing of multiple synthio.Biquad objects within audiofilters.Filter by renaming filter to filters and requiring it be a List[synthio.Biquad].

import board
import audiobusio
import audiofilters
import audiocore
import synthio

audio = audiobusio.I2SOut(bit_clock=board.GP0, word_select=board.GP1, data=board.GP2)

wave_file = open("StreetChicken.wav", "rb")
wave = audiocore.WaveFile(wave_file)

synth = synthio.Synthesizer(channel_count=wave.channel_count, sample_rate=wave.sample_rate)

effect = audiofilters.Filter(
    filters=[
        synth.high_pass_filter(200),
        synth.low_pass_filter(2000),
    ],
    buffer_size=1024,
    channel_count=wave.channel_count,
    sample_rate=wave.sample_rate,
    mix=1.0
)
effect.play(wave, loop=True)
audio.play(effect)

Comments:

  • Direct assignment of objects in list does not call synthio_biquad_filter_assign (ie: effect.filters[0] = ...)
  • effect.filters.append(...) and effect.filters.remove(...) are detected by testing length in audio buffer loop. There may be a better method of detecting these list manipulations.

@relic-se
Copy link
Author

relic-se commented Nov 6, 2024

I would like some insight into a better method of updating the biquad_filter_state objects when the list of Biquad objects is updated, but otherwise this PR is functional. If there isn't a better method, I'll need to update the docstring for audiofilters.Filter.filters to demonstrate proper usage.

@relic-se relic-se marked this pull request as ready for review November 6, 2024 13:43
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.

I don't think there's any more clever way to detect a changed list length. You could use realloc and only initialize any new slots.

But of course not all changes to the filters list would necessarily change the list length. For instance, code could use filters[0] = new_filter_zero. Is the code handling this case?

Another option is to say that the contents of the list are snapshotted on assignment or require the value supplied to be a read-only sequence (tuple).

Is this code enough to implement equalizer-type functionality? as it stands, the common biquad constructors (and this includes the new block biquads). In my reading I haven't seen it spelled out exactly how an equalizer would be constructed from biquads, but I feel like that seems to be the most obvious use of a bank of filters. An example which does this would be very illustrative & helpful for me.

Comment on lines 98 to 101
self->filter_states = m_malloc(self->filter_states_len * sizeof(biquad_filter_state));
if (self->filter_states == NULL) {
common_hal_audiofilters_filter_deinit(self);
m_malloc_fail(self->filter_states_len * sizeof(biquad_filter_state));
Copy link
Member

Choose a reason for hiding this comment

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

I'm relatively sure that m_malloc already throws in case of failed allocation, so the call to m_malloc_fail is not needed; but also, the call to common_hal_audiofilters_filter_deinit is ineffective. I see this idiom has been used across the audiofilters so it may need to be revised elsewhere as well.

Copy link
Author

Choose a reason for hiding this comment

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

I'll look into this issue and apply changes as necessary throughout. I'm still learning about how best to program within the MicroPython/CircuitPython ecosystem so bear with me! :)

Copy link
Author

@relic-se relic-se Nov 7, 2024

Choose a reason for hiding this comment

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

I began removing the ..._deinit and m_malloc_fail statements, but I'm finding that this idiom is fairly common within multiple modules of CircuitPython. As you certain that this isn't the proper handling of m_malloc?

Here are a few examples:

self->first_buffer = m_malloc(self->len);
if (self->first_buffer == NULL) {
common_hal_audiomixer_mixer_deinit(self);
m_malloc_fail(self->len);
}
self->second_buffer = m_malloc(self->len);
if (self->second_buffer == NULL) {
common_hal_audiomixer_mixer_deinit(self);
m_malloc_fail(self->len);
}

self->inbuf.buf = m_malloc(DEFAULT_INPUT_BUFFER_SIZE);
if (self->inbuf.buf == NULL) {
common_hal_audiomp3_mp3file_deinit(self);
m_malloc_fail(DEFAULT_INPUT_BUFFER_SIZE);
}
if (buffer_size >= 2 * MAX_BUFFER_LEN) {
self->pcm_buffer[0] = (int16_t *)(void *)buffer;
self->pcm_buffer[1] = (int16_t *)(void *)(buffer + MAX_BUFFER_LEN);
} else {
self->pcm_buffer[0] = m_malloc(MAX_BUFFER_LEN);
if (self->pcm_buffer[0] == NULL) {
common_hal_audiomp3_mp3file_deinit(self);
m_malloc_fail(MAX_BUFFER_LEN);
}
self->pcm_buffer[1] = m_malloc(MAX_BUFFER_LEN);
if (self->pcm_buffer[1] == NULL) {
common_hal_audiomp3_mp3file_deinit(self);
m_malloc_fail(MAX_BUFFER_LEN);
}
}

self->buffer = m_malloc(self->len);
if (self->buffer == NULL) {
common_hal_audioio_wavefile_deinit(self);
m_malloc_fail(self->len);
}
self->second_buffer = m_malloc(self->len);
if (self->second_buffer == NULL) {
common_hal_audioio_wavefile_deinit(self);
m_malloc_fail(self->len);
}

self->buffer = (uint16_t *)m_malloc(maxlen * sizeof(uint16_t));
if (self->buffer == NULL) {
// TODO: free the EXTI here?
m_malloc_fail(maxlen * sizeof(uint16_t));
}
(also in other ports with pulseio)

There are other cases where instead of m_malloc(...) there are calls to port_malloc(...), malloc(...), realloc(...), malloc_with_finaliser(...), ringbuf_alloc(...), and gc_alloc(...) before m_malloc_fail(...). Maybe someone down the line copied that over but with m_malloc(...) and it stuck within audio and pulseio modules?

Rather than experimenting and going outside of the accepted formatting, I'm copying a lot of my homework from other portions of CircuitPython. This is something I borrowed from audiodelays without fully understanding the underlying principles. I plan on keeping it as is for now until we develop a plan for all other instances of this methodology (likely another PR).

Copy link
Author

Choose a reason for hiding this comment

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

Finally looked this up, and I see what you mean. The additional check and m_malloc_fail is definitely not necessary. I'll remove it from this class, but let me know if you'd like me to go around and remove it throughout CircuitPython or save it for a separate PR. @jepler

void *ptr = malloc(num_bytes);
if (ptr == NULL && num_bytes != 0) {
m_malloc_fail(num_bytes);
}

@relic-se
Copy link
Author

relic-se commented Nov 6, 2024

@jepler Using a read-only tuple is the perfect solution here! The filter states would only need assignment within the setter. Thanks for the tip!

As for "equalizer" usage, the only applicable use at this time is to combine LPF and HPF to filter out unwanted signal. The example code in the first comment demonstrates that. Future additions of peakingEQ, lowShelf, and highShelf (from the Audio EQ Cookbook) to BlockBiquad would allow you to build a equalizer effect more akin to an analog audio mixer.

effect = audiofilters.Filter(
    filters=(
        synthio.BlockBiquad(
            mode=synthio.FilterMode.LOW_SHELF,
            frequency=250,
            A=0.5, # potential future parameter
            Q=1.0,
        ),
        synthio.BlockBiquad(
            mode=synthio.FilterMode.PEAKING_EQ,
            frequency=2000,
            A=2.0,
            Q=0.8,
        ),
        synthio.BlockBiquad(
            mode=synthio.FilterMode.HIGH_SHELF,
            frequency=6000,
            A=1.5,
            Q=1.2,
        ),
    ),
)

@jepler
Copy link
Member

jepler commented Nov 7, 2024

For this kind of equalizer does it work to apply the filters sequentially (e.g., output_samples = high_shelf(peaking_eq1(peaking_eq2(low_shelf(input_samples)))) using function call notation? I had an impression from somewhere that the filters needed to be parallel rather than sequential, but maybe this is mistaken on my part.

@relic-se
Copy link
Author

relic-se commented Nov 7, 2024

@jepler I think that cascading the biquad filters as you've demonstrated will perform well enough for our needs. The primary use of parallel eq is within graphic equalizers where you have a large bank of band pass filters at frequency intervals and mix them together into the output. I could see this as a separate class with a predesignated filter design, but I don't think it's a high priority right now. Here's a research paper which covers this topic: https://mycourses.aalto.fi/pluginfile.php/666520/course/section/128564/Aleksi%20Peussa_1929990_assignsubmission_file_AAT_Seminar_Paper_724441.pdf. Here's a snippet from the conclusion which I think nails it on the head: "While both methods have their benefits, each also has their downsides. Simplicity of design and speed benefit the cascade, while simultaneous computation and accuracy benefit the parallel method."

@relic-se relic-se marked this pull request as draft November 7, 2024 16:57
@relic-se relic-se changed the title Add support for list of Biquad objects within audiofilters.Filter. Add support for tuple of Biquad objects within audiofilters.Filter. Nov 7, 2024
@jepler
Copy link
Member

jepler commented Nov 7, 2024

thank you, I took a quick glance at the paper you linked and I think it will be enlightening when I get a chance to read it through. In CircuitPython we're not likely to be taking advantage of parallel methods of computation anytime soon, so simplicity and adequate single thread performance are great features to enjoy.

jepler
jepler previously approved these changes Nov 7, 2024
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.

Hi! I mostly have just nitpicky things, plus one concern about backwards compatibility. Given that in practice there's not much audiofilters code deployed so far I'm inclined not to worry but I'd like Dan to sign off on that as well.

shared-bindings/audiofilters/Filter.c Outdated Show resolved Hide resolved
shared-module/audiofilters/Filter.c Outdated Show resolved Hide resolved
shared-bindings/audiofilters/Filter.c Outdated Show resolved Hide resolved
shared-module/audiofilters/Filter.c Outdated Show resolved Hide resolved
shared-bindings/audiofilters/Filter.c Outdated Show resolved Hide resolved
shared-bindings/audiofilters/Filter.c Outdated Show resolved Hide resolved
@relic-se
Copy link
Author

relic-se commented Nov 7, 2024

@jepler I may have become heavy-handed with my most recent changes, but hopefully it's for the best. I've done the following:

  • Rename filters back to filter
  • Support the following values:
    • individual Biquad object
    • tuple of Biquad objects
    • list of Biquad objects (which is converted to a tuple)
    • None

This retains backwards compatibility while still allowing the use of multiple filters.

The only issue I am having now with the property is that if you set it to a tuple/list of invalid objects, it generates a TypeError during synthio_biquad_filter_assign, but the value of self->filter has already been updated in common_hal_audiofilters_filter_set_filter.

import audiofilters
import synthio

synth = synthio.Synthesizer()
effect = audiofilters.Filter()

try:
    effect.filter = ["test"]
except TypeError:
    pass

print(effect.filter)
# ('test',)

The solution here would be to iterate through items within common_hal_audiofilters_filter_set_filter and test their type before calling reset_filter_states. Do you think it's worth addressing this problem?

@jepler
Copy link
Member

jepler commented Nov 7, 2024

We have a helper for checking items in a sequence for the correct type. Here's an example of its use:

    for (size_t i = 0; i < nmatch; i++) {
        matches[i] = mp_arg_validate_type_in(match_objects[i], &canio_match_type, MP_QSTR_matches);
    }

so you'd iterate over the collection and check the type of each object. If you don't do this first, it seems like the object ends up in an inconsistent state.

@relic-se
Copy link
Author

relic-se commented Nov 7, 2024

That did the trick! Thank you, @jepler

@relic-se relic-se marked this pull request as ready for review November 7, 2024 22:22
Copy link
Member

@gamblor21 gamblor21 left a comment

Choose a reason for hiding this comment

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

Tried it out with a bunch of filter combos and worked great for me. Code looks good as well.
As @jepler had comments I'll let him approve if he thinks all his requests were complete. But looks good to me.

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.

thanks!

@jepler jepler merged commit de9b0b6 into adafruit:main Nov 10, 2024
30 checks passed
@relic-se relic-se deleted the audiofilters_filterlist branch November 10, 2024 15:34
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.

3 participants