Skip to content

Tracking issue for equalizer Source #742

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

Open
UnknownSuperficialNight opened this issue May 19, 2025 · 12 comments
Open

Tracking issue for equalizer Source #742

UnknownSuperficialNight opened this issue May 19, 2025 · 12 comments

Comments

@UnknownSuperficialNight
Copy link
Contributor

I've been thinking about this for a while. What do you think of the possibility of adding an equalizer?

I don't have a specific implementation in mind, but a general idea would be to create a source that we can pass an array into, where this array represents the bands in an equalizer.

Image

In the image above, it defines each band in an array, then connects the values between, i.e., let's say the user adds 12hz and 18hz to the array. We can then amplify 8db on the 18hz, and no amplification for the 12hz. Then between 13,14,15,16,17hz will be linearly interpolated for them to ascend in amplification till it reaches the 18hz, then it will go down for a third number if we have one.

This allows users to define their own band ranges and how much they want them amplified.

Here is a visualization of [12, 18, 28]hz.

Image

We would need a baseline amplification separate from the array, which would control the overall band's amplification like a pre-amp. This would allow users to lower or increase all bands relative to the pre-amp before applying the individual amp on each band.

Regarding implementation, I would like some input before starting coding.

Note: If using this with AGC, this would need to take precedence before the AGC to avoid distortion issues. Otherwise, the AGC would amplify it and then the equalizer would amplify as well, creating distortion. However, if we amplify with the equalizer first and feed it into the AGC, there should be no issues.

Another thing to consider is real-time adjustments. Most people want to be able to change the equalizer settings in real time, so we would need to use periodic_access or atomic_float. Maybe implement both to satisfy the most users, and we would need mutable access to the array itself for the user to be able to add or remove bands during runtime.

@roderickvd
Copy link
Collaborator

I like the idea. I was anyway surprised that we didn’t have biquads in yet. This equalizer could be just a convenience struct with a bank of biquads underneath it.

Maybe implement both to satisfy the most users, and we would need mutable access to the array itself for the user to be able to add or remove bands during runtime.

Balancing between things we can do and complexity involved, I’d be happy with the band values mutable at runtime, and the number of bands fixed at compile time.

It’s up to the user to create the pipeline correctly and so AGC later.

@dvdsk
Copy link
Collaborator

dvdsk commented May 20, 2025

I've been thinking about this for a while. What do you think of the possibility of adding an equalizer?

I like the idea, it might get complex however, so be warned :)

Maybe implement both to satisfy the most users, and we would need mutable access to the array itself for the user to be able to add or remove bands during runtime.

I am in favor for implementing both, I think that is what we are moving towards API wise anyway. If your open to it you could try having the float one build from the periodic access variant.

Regarding implementation, I would like some input before starting coding.

Lets hash out an API and the start looking at a nice algorithm?

let source = Decoder::try_from(File::open("music.ogg")?)?;
// generic (number of bands) is inferred from array len.
// array elements are frequency ranges for each bin.
let equalized = source.equalized([0..440,440..1000,2000..4000]).expect("you passed in non overlapping ranges");

// The controller can be send to another thread, under the hood is uses the atomic floats 
// (or something else if that ends up being more performant)
// Alternatively a user could wrap equalized in periodic access and use that.
let (equalized, controller) = equalized.with_controller();

controller.set_bin_gain(2, 0.5).expect("band 2 exists");
// no need for runtime check, use const generics to ensure array is proper length;
controller.set_all_gains([1.0,1.0,1.0]); 

@dvdsk
Copy link
Collaborator

dvdsk commented May 20, 2025

Speeking of algorithms, @iluvcapra you know your stuff. Any suggestions for a good equalizer implementation?

@roderickvd
Copy link
Collaborator

I have an experimental equal-loudness algorithm here with a seven-band equalizer: https://github.com/roderickvd/pleezer/blob/main/src/loudness.rs

Eventually I want to extract and port these into Rodio, but haven’t gotten around to it and won’t in the foreseeable future. Happy if someone else already wants to.

@UnknownSuperficialNight
Copy link
Contributor Author

UnknownSuperficialNight commented May 20, 2025

Lets hash out an API and the start looking at a nice algorithm?

let equalized = source.equalized([0..440,440..1000,2000..4000]).expect("you passed in non overlapping ranges");

This it might be easier to do what I proposed instead of putting in a range itself i.e. 100 to 400 (ill clarify what I'm thinking)

We can infer a transition between each number

let my_array = [12.0, 18.0, 28.0, 33.0, 45.0, 60.0, 78.0, 83.0];

This will simplify the logic a little bit and allow seamless transitions between ranges.

Example:

12 = +8 db
15 = +8 db
23 = 0 db
35 = +4 db

Then in between each one we can calculate a transition.

16 = 7.8db
17 = 6.6db
18 = 5.4db
19 = 4.2db
20 = 3.0db
21 = 1.8db
22 = 0.6db

While something like 12 to 15 are both at 8db, so between them everything would be +8db.

This was the idea I'm thinking about, but I'm open to others I'm just trying to clarify the idea with more examples, so people can understand and critique it.

Edit1:

Came up with something like this to possibly calculate in bulk all the numbers in-between:

pub fn calculate_db_transition(
    start_num: i32,
    start_db: f32,
    end_num: i32,
    end_db: f32,
) -> Result<Vec<f32>, Box<dyn Error>> {
    // Input validation
    if end_num <= start_num {
        return Err("End number must be greater than start number".into());
    }

    let steps = (end_num - start_num) as usize;
    let mut results = Vec::with_capacity(steps + 1);

    // Precompute the constant step size
    let step_size = (end_db - start_db) / steps as f32;

    // Generate results using the precomputed step size
    for i in 0..=steps {
        results.push(start_db + step_size * i as f32);
    }

    Ok(results)
}

Output:

calculate_db_transition: 190ns
Number 15: 8.0 dB
Number 16: 7.0 dB
Number 17: 6.0 dB
Number 18: 5.0 dB
Number 19: 4.0 dB
Number 20: 3.0 dB
Number 21: 2.0 dB
Number 22: 1.0 dB
Number 23: 0.0 dB

@iluvcapra
Copy link
Contributor

If you needed a graphic EQ like this I think you'd just cascade a set of biquads, though in real life each filter would have more orders for tighter slopes on each band?

@dvdsk
Copy link
Collaborator

dvdsk commented May 21, 2025

While something like 12 to 15 are both at 8db, so between them everything would be +8db.

makes sense 👍, so that would look something like this then:

let source = Decoder::try_from(File::open("music.ogg")?)?;
// generic (number of bands) is inferred from array len.
// array elements are frequency ranges for each bin.
let equalized = source.equalized([500,1000,2000,4000]).expect("ranges should be monotonically increasing");

// The controller can be send to another thread, under the hood is uses the atomic floats 
// (or something else if that ends up being more performant)
// Alternatively a user could wrap equalized in periodic access and use that.
let (equalized, controller) = equalized.with_controller();

controller.set_bin_gain(2, 0.5).expect("band 2 exists");
// no need for runtime check, use const generics to ensure array is proper length;
controller.set_all_gains([1.0,0.5,0.5,1.0]); 

which would slowly turn the gain from 1.0 to 0.5 (eventually halving 'volume') between 500hz and 1000hz. Then between 1000 and 2000 the gain is 0.5 from 2k to 4khz it then changes to 1.0 again.

Unresolved questions:

  • Do we set a linear gain or do we offer logarithmic options? See Better volume control #714
  • How does the gain transition between two "bands"

@dvdsk dvdsk changed the title Potentially implementing a equalizer Tracking issue for equalizer Source May 21, 2025
@dvdsk
Copy link
Collaborator

dvdsk commented May 21, 2025

If you needed a graphic EQ like this I think you'd just cascade a set of biquads

Thank you!. For me there are some magic words in there, @UnknownSuperficialNight is this clear for you?

I was anyway surprised that we didn’t have biquads in yet.

Should we add those first and then build this on top of that?

@roderickvd
Copy link
Collaborator

If you needed a graphic EQ like this I think you'd just cascade a set of biquads

Thank you!. For me there are some magic words in there, @UnknownSuperficialNight is this clear for you?

You can look at the example I linked above how it processes a bank ("cascades") of biquads. You can take out all of the equal-loudness normalization and what remains is pretty simple:

pub struct EqualLoudnessFilter {
    // change this into a `Vec`
    filters: [biquad::DirectForm1<f32>; NUM_BANDS],
}

impl EqualLoudnessFilter {
    pub fn process(&mut self, input: f32) -> f32 {
        let mut output = input;
        for filter in &mut self.filters {
            output = filter.run(output);
        }
        output
    }
}

I was anyway surprised that we didn’t have biquads in yet.

Should we add those first and then build this on top of that?

Yes.

Yes, offer logarithmic options.

  • How does the gain transition between two "bands"
calculate_db_transition: 190ns
Number 15: 8.0 dB
Number 16: 7.0 dB
Number 17: 6.0 dB
Number 18: 5.0 dB
Number 19: 4.0 dB
Number 20: 3.0 dB
Number 21: 2.0 dB
Number 22: 1.0 dB
Number 23: 0.0 dB

You need to watch out for cascading too many biquads. That can:
a) introduce too large of a phase shift
b) become unstable
c) increase CPU usage

Instead of introducing many "steps" you will probably want to use reasonable Q values to create overlapping responses in between the center frequencies ("bands") you've set. From my example:

/// Center frequencies for each filter band in Hz
const BAND_FREQUENCIES: [f32; NUM_BANDS] = [
    31.5,    // Low shelf
    80.0,    // Low-mid peak
    250.0,   // Mid peak 1
    500.0,   // Mid peak 2
    2000.0,  // Upper-mid peak
    6300.0,  // Presence peak
    12500.0, // High shelf
];

/// Q factors for each filter band
const BAND_Q: [f32; NUM_BANDS] = [
    Q_BUTTERWORTH_F32, // Low shelf
    1.0,               // Low-mid peak
    1.2,               // Mid peak 1
    SQRT_2,            // Mid peak 2
    1.2,               // Upper-mid peak
    1.5,               // Presence peak
    Q_BUTTERWORTH_F32, // High shelf
];

@iluvcapra
Copy link
Contributor

iluvcapra commented May 21, 2025 via email

@UnknownSuperficialNight
Copy link
Contributor Author

If you needed a graphic EQ like this I think you'd just cascade a set of biquads

Thank you!. For me there are some magic words in there, @UnknownSuperficialNight is this clear for you?

A little, I would have to read up more on this before I can fully understand it.

@dvdsk
Copy link
Collaborator

dvdsk commented May 22, 2025

A little, I would have to read up more on this before I can fully understand it.

please ask for help if you get stuck on anything, your time is valuable!

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

No branches or pull requests

4 participants