Files
codename-206/src/dsp/biquad.rs
T
mikkeli 466ab478c9 chore: relicense to GPL-3.0-or-later and credit the ISC upstream
Commit 057d630 switched this repo from GPL-3.0 to Apache-2.0, which the
build contradicts: src/lib.rs calls nih_export_vst3! unconditionally and
nih_plug is taken with default features, so every build links vst3-sys —
GPLv3-or-later by its own authors' choice. deploy.ps1 then installs that
binary. The declared licence and the artifact's actual terms disagreed,
and the original GPL-3.0 was correct.

Given the intent to distribute non-profit and open-source, GPL costs
nothing here, so this restores it rather than dropping VST3 to keep a
permissive licence.

Also adds the ISC notice owed to Robbert van der Helm: the plugin
scaffolding derives from the NIH-plug cookiecutter template and example
plugins, whose one condition is that the copyright and permission notice
travel with all copies. The README credited the template under Reference
Material, which is courtesy, not compliance. ISC is GPL-compatible, so the
notice is retained in NOTICE rather than superseded.

Corrects the README's claim that Steinberg's SDK licence must be accepted
before distributing. No SDK source is vendored or redistributed, so there
is nothing to accept at build time. The binding constraint is vst3-sys's
GPLv3; Steinberg's trademark in "VST" is a separate regime that no source
licence resolves.

Copyright in the Irene <irene@novoyuuparosk.org> commits is held by the
repository owner (an agent identity), so no third-party agreement was
needed for this relicense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-08-31 22:32:05 +09:00

150 lines
4.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Generic second-order IIR biquad, Transposed Direct Form II.
//!
//! Coefficient formulas are the RBJ Audio EQ Cookbook
//! (<https://www.w3.org/TR/audio-eq-cookbook/>), prenormalised by `a0`. Scalar `f32`; we run
//! one filter per channel rather than SIMD to match the rest of the per-channel DSP.
use std::f32::consts;
/// Butterworth Q (= 1/√2). Two cascaded Butterworth sections make a 4th-order Linkwitz-Riley.
pub const NEUTRAL_Q: f32 = consts::FRAC_1_SQRT_2;
/// Prenormalised biquad coefficients `[b0, b1, b2, a1, a2]` (already divided by `a0`).
#[derive(Clone, Copy)]
pub struct BiquadCoefficients {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
}
impl Default for BiquadCoefficients {
fn default() -> Self {
Self::identity()
}
}
impl BiquadCoefficients {
/// Passes the signal through unchanged.
pub fn identity() -> Self {
Self { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 }
}
pub fn lowpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
let a0 = 1.0 + alpha;
Self {
b0: ((1.0 - cos_w0) / 2.0) / a0,
b1: (1.0 - cos_w0) / a0,
b2: ((1.0 - cos_w0) / 2.0) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
}
}
pub fn highpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
let a0 = 1.0 + alpha;
Self {
b0: ((1.0 + cos_w0) / 2.0) / a0,
b1: -(1.0 + cos_w0) / a0,
b2: ((1.0 + cos_w0) / 2.0) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
}
}
pub fn allpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
let a0 = 1.0 + alpha;
Self {
b0: (1.0 - alpha) / a0,
b1: (-2.0 * cos_w0) / a0,
b2: (1.0 + alpha) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
}
}
/// Shared intermediate terms: `(cos ω0, α)`.
fn omega(sample_rate: f32, frequency: f32, q: f32) -> (f32, f32) {
let w0 = consts::TAU * (frequency / sample_rate);
(w0.cos(), w0.sin() / (2.0 * q))
}
}
/// A biquad filter holding its two state variables.
#[derive(Clone, Copy, Default)]
pub struct Biquad {
coefficients: BiquadCoefficients,
s1: f32,
s2: f32,
}
impl Biquad {
/// Replace the coefficients (keeps the state — fine for smooth coefficient changes).
pub fn set_coefficients(&mut self, coefficients: BiquadCoefficients) {
self.coefficients = coefficients;
}
/// Process one sample (Transposed Direct Form II).
#[inline]
pub fn process(&mut self, x: f32) -> f32 {
let c = &self.coefficients;
let y = c.b0 * x + self.s1;
self.s1 = c.b1 * x - c.a1 * y + self.s2;
self.s2 = c.b2 * x - c.a2 * y;
y
}
/// Clear the filter state.
pub fn reset(&mut self) {
self.s1 = 0.0;
self.s2 = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f32 = 48_000.0;
fn magnitude_at(mut coeffs_filter: Biquad, freq: f32) -> f32 {
use std::f32::consts::TAU;
let n = 16_000usize;
let mut acc = 0.0f64;
for i in 0..n {
let x = (TAU * freq * i as f32 / SR).sin();
let y = coeffs_filter.process(x);
if i >= n - 8_000 {
acc += (y * y) as f64;
}
}
// RMS of a unit sine is 1/√2; divide it out to get the magnitude response.
((acc / 8_000.0).sqrt() as f32) * std::f32::consts::SQRT_2
}
#[test]
fn lowpass_passes_dc_blocks_highs() {
let lp = {
let mut b = Biquad::default();
b.set_coefficients(BiquadCoefficients::lowpass(SR, 1_000.0, NEUTRAL_Q));
b
};
assert!((magnitude_at(lp, 100.0) - 1.0).abs() < 0.05); // ~passband
assert!(magnitude_at(lp, 12_000.0) < 0.05); // ~stopband
}
#[test]
fn allpass_is_unity_magnitude() {
for &f in &[100.0, 1_000.0, 8_000.0] {
let mut b = Biquad::default();
b.set_coefficients(BiquadCoefficients::allpass(SR, 2_000.0, NEUTRAL_Q));
assert!((magnitude_at(b, f) - 1.0).abs() < 0.02, "allpass not flat at {f} Hz");
}
}
}