# Copyright 2024-2025 XMOS LIMITED. # This Software is subject to the terms of the XMOS Public Licence: Version 1. """The biquad DSP block.""" import warnings from copy import deepcopy import numpy as np import numpy.typing as npt import scipy.signal as spsig import matplotlib.pyplot as plt from docstring_inheritance import inherit_numpy_docstring from audio_dsp.dsp import utils as utils from audio_dsp.dsp import generic as dspg class biquad(dspg.dsp_block): """ A second order biquadratic filter instance. This implements a direct form 1 biquad filter, using the coefficients provided at initialisation: `a0*y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]` For efficiency the biquad coefficients are normalised by a0 and the output `a` coefficients multiplied by -1. When the coefficients are updated, the biquad states are reset. This helps avoid large errors, but can make this implementation unsuitable for real time control. For real time control, :py:class:`biquad_slew` may be a better choice. Parameters ---------- coeffs : list[float] List of normalised biquad coefficients in the form in the form `[b0, b1, b2, -a1, -a2]/a0` Attributes ---------- coeffs : list[float] List of normalised float biquad coefficients in the form in the form `[b0, b1, b2, -a1, -a2]/a0`, rounded to int32 precision. int_coeffs : list[int] List of normalised int biquad coefficients in the form in the form `[b0, b1, b2, -a1, -a2]/a0`, scaled and rounded to int32. b_shift : int The number of right shift bits applied to the b coefficients. The default coefficient scaling allows for a maximum coefficient value of 2, but high gain shelf and peaking filters can have coefficients above this value. Shifting the b coefficients down allows coefficients greater than 2, with the cost of b_shift bits of precision. """ def __init__( self, coeffs: list[float], fs: int, n_chans: int = 1, Q_sig: int = dspg.Q_SIG, ): super().__init__(fs, n_chans, Q_sig) self.update_coeffs(coeffs) def update_coeffs(self, new_coeffs: list[float]): """Update the saved coefficients to the input values. Parameters ---------- new_coeffs : list[float] The new coefficients to be updated. """ self.b_shift = _get_bshift(new_coeffs) self.coeffs, self.int_coeffs = _round_and_check(new_coeffs, self.b_shift) self._check_gain() # reset states to avoid clicks self.reset_state() def process(self, sample: float, channel: int = 0) -> float: """ Filter a single sample using direct form 1 biquad using floating point maths. """ y = ( self.coeffs[0] * sample + self.coeffs[1] * self._x1[channel] + self.coeffs[2] * self._x2[channel] + self.coeffs[3] * self._y1[channel] + self.coeffs[4] * self._y2[channel] ) y = utils.saturate_float(y, self.Q_sig) self._x2[channel] = self._x1[channel] self._x1[channel] = sample self._y2[channel] = self._y1[channel] self._y1[channel] = y y = y * (1 << self.b_shift) y = utils.saturate_float(y, self.Q_sig) return y def process_int(self, sample: float, channel: int = 0) -> float: """ Filter a single sample using direct form 1 biquad using int32 fixed point maths. The float input sample is quantized to int32, and returned to float before outputting """ sample_int = utils.float_to_fixed(sample, self.Q_sig) # process a single sample using direct form 1 y = utils.int64( sample_int * self.int_coeffs[0] + self._x1[channel] * self.int_coeffs[1] + self._x2[channel] * self.int_coeffs[2] + self._y1[channel] * self.int_coeffs[3] + self._y2[channel] * self.int_coeffs[4] ) # the b_shift can be combined with the >> 30, which reduces # quantization noise, but this results in saturation at an # earlier point, and so is not used here for consistency y = utils.int64(y + (1 << 29)) y = utils.int32_mult_sat_extract(y, 1, 30) # save states self._x2[channel] = utils.int32(self._x1[channel]) self._x1[channel] = utils.int32(sample_int) self._y2[channel] = utils.int32(self._y1[channel]) self._y1[channel] = utils.int32(y) # compensate for coefficients y = utils.int64(y << self.b_shift) y = utils.saturate_int32(y) y_flt = utils.fixed_to_float(y, self.Q_sig) return y_flt def process_xcore(self, sample: float, channel: int = 0) -> float: """ Filter a single sample using direct form 1 biquad using int32 fixed point maths, with use of the XS3 VPU. The float input sample is quantized to int32, and returned to float before outputting. """ if isinstance(sample, float): sample_int = utils.float_to_fixed(sample, self.Q_sig) elif isinstance(sample, int): sample_int = sample else: raise TypeError("input must be float or int") # process a single sample using direct form 1. In the VPU the # ``>> 30`` comes before accumulation y = utils.vlmaccr( [ sample_int, self._x1[channel], self._x2[channel], self._y1[channel], self._y2[channel], ], self.int_coeffs, ) y = utils.saturate_int32(y) # save states self._x2[channel] = utils.int32(self._x1[channel]) self._x1[channel] = utils.int32(sample_int) self._y2[channel] = utils.int32(self._y1[channel]) self._y1[channel] = utils.int32(y) # compensate for coefficients y = utils.int64(y << self.b_shift) y = utils.saturate_int32(y) if isinstance(sample, float): return utils.fixed_to_float(y, self.Q_sig) else: return y def process_frame_int(self, frame: list[np.ndarray]) -> list[np.ndarray]: """ Take a list frames of samples and return the processed frames, using a bit exact int implementation. A frame is defined as a list of 1-D numpy arrays, where the number of arrays is equal to the number of channels, and the length of the arrays is equal to the frame size. Parameters ---------- frame : list List of frames, where each frame is a 1-D numpy array. Returns ------- list List of processed frames, with the same structure as the input frame. """ n_outputs = len(frame) frame_size = frame[0].shape[0] output = deepcopy(frame) for chan in range(n_outputs): this_chan = output[chan] for sample in range(frame_size): this_chan[sample] = self.process_int(this_chan[sample], channel=chan) return output def freq_response( self, nfft: int = 1024 ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: """ Calculate the frequency response of the biquad filter. The biquad filter coefficients are scaled and returned to numerator and denominator coefficients, before being passed to `scipy.signal.freqz` to calculate the frequency response. Parameters ---------- nfft : int The number of points to compute in the frequency response, by default 1024. Returns ------- tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]] A tuple containing the frequency vector and the complex frequency response. """ b = [self.coeffs[0], self.coeffs[1], self.coeffs[2]] b = _apply_biquad_bshift(b, -self.b_shift) a = [1, -self.coeffs[3], -self.coeffs[4]] f, h = spsig.freqz(b, a, worN=nfft, fs=self.fs) # type: ignore it thinks a is supposed to be an int? return f, h def _check_gain(self): """ Check if the gain of the biquad filter is greater than the available headroom. If so, display a warning. """ _, h = self.freq_response() max_gain = np.max(utils.db(h)) if max_gain > dspg.HEADROOM_DB: warnings.warn( "biquad gain (%.1f dB) is > headroom" % (max_gain) + " (%.0f dB), overflow may occur" % dspg.HEADROOM_DB + " unless signal level has previously been reduced" ) def reset_state(self): """Reset the biquad saved states to zero.""" self._x1 = [0.0] * self.n_chans self._x2 = [0.0] * self.n_chans self._y1 = [0.0] * self.n_chans self._y2 = [0.0] * self.n_chans class biquad_slew(biquad): """ A second order biquadratic filter instance that slews between coefficient updates. This implements a direct form 1 biquad filter, using the coefficients provided at initialisation: `a0*y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]` For efficiency the biquad coefficients are normalised by a0 and the output `a` coefficients multiplied by -1. When the target coefficients are updated, the applied coefficients are slewed towards the new target values. This makes this implementation suitable for real time control. A table of the first 10 slew shifts is shown below: +------------+--------------------+ | slew_shift | Time constant (ms) | +============+====================+ | 1 | 0.03 | +------------+--------------------+ | 2 | 0.07 | +------------+--------------------+ | 3 | 0.16 | +------------+--------------------+ | 4 | 0.32 | +------------+--------------------+ | 5 | 0.66 | +------------+--------------------+ | 6 | 1.32 | +------------+--------------------+ | 7 | 2.66 | +------------+--------------------+ | 8 | 5.32 | +------------+--------------------+ | 9 | 10.66 | +------------+--------------------+ | 10 | 21.32 | +------------+--------------------+ Parameters ---------- slew_shift : int The shift value used in the exponential slew. Attributes ---------- target_coeffs : list[float] List of normalised float target biquad coefficients in the form in the form `[b0, b1, b2, -a1, -a2]/a0`, rounded to int32 precision. The coeffs are slewed towards these values. target_coeffs_int : list[int] List of normalised int target biquad coefficients in the form in the form `[b0, b1, b2, -a1, -a2]/a0`, scaled and rounded to int32. The int_coeffs are slewed towards these values. """ def __init__( self, coeffs: list[float], fs: int, n_chans: int = 1, slew_shift: int = 6, Q_sig: int = dspg.Q_SIG, ): dspg.dsp_block.__init__(self, fs, n_chans, Q_sig) # call the superclass during init only biquad.update_coeffs(self, coeffs) # set target equal to initial self.target_coeffs = deepcopy(self.coeffs) self.target_coeffs_int = deepcopy(self.int_coeffs) self.slew_shift = slew_shift self.remaining_shifts = 0 def update_coeffs(self, new_coeffs: list[float]): """Update the saved coefficients to the input values. Parameters ---------- new_coeffs : list[float] The new coefficients to be updated. """ old_b_shift = self.b_shift self.b_shift = _get_bshift(new_coeffs) self.target_coeffs, self.target_coeffs_int = _round_and_check(new_coeffs, self.b_shift) b_shift_change = old_b_shift - self.b_shift if b_shift_change > 0: # we can't shift safely until we know we have headroom self.remaining_shifts = b_shift_change self.b_shift += self.remaining_shifts if b_shift_change < 0: b_shift_change = -b_shift_change self.coeffs[:3] = [x * 2**-b_shift_change for x in self.coeffs[:3]] self.int_coeffs[:3] = [x >> b_shift_change for x in self.int_coeffs[:3]] for chan in range(self.n_chans): if type(self._y1[chan]) is int: self._y1[chan] = self._y1[chan] >> b_shift_change self._y2[chan] = self._y2[chan] >> b_shift_change else: self._y1[chan] = self._y1[chan] * 2**-b_shift_change self._y2[chan] = self._y2[chan] * 2**-b_shift_change @property def slew_shift(self): """The shift value used in the exponential slew.""" return self._slew_shift @slew_shift.setter def slew_shift(self, value): self._slew_shift = value if value > 1 else 1 def process(self, sample: float, channel: int = 0) -> float: """ ``process`` is not implemented for the slewing biquad, as the coefficient slew is shared across the channels. """ raise NotImplementedError def process_int(self, sample: float, channel: int = 0) -> float: """ ``process_int`` is not implemented for the slewing biquad, as the coefficient slew is shared across the channels. Parameters ---------- sample : float The input sample to be processed. channel : int, optional The channel index to process the sample on. Default is 0. """ raise NotImplementedError def process_xcore(self, sample: float, channel: int = 0) -> float: """ ``process_xcore`` is not implemented for the slewing biquad, as the coefficient slew is shared across the channels. """ raise NotImplementedError def process_channels(self, sample_list: list[float]) -> list[float]: """ Slew the biquad coefficients towards the target, then filter the samples in each channel using floating point maths. Each sample is filtered using direct form 1 biquad using floating point maths. """ if self.remaining_shifts > 0: tmp_target = deepcopy(self.target_coeffs) tmp_target[:3] = [x * (2 ** (-self.remaining_shifts)) for x in tmp_target[:3]] for n in range(5): self.coeffs[n] += (tmp_target[n] - self.coeffs[n]) * 2**-self.slew_shift if ( abs(self.coeffs[0]) < 1 and abs(self.coeffs[1]) < 1 and abs(self.coeffs[2]) < 1 and all(abs(x) < 1 for x in self._y1) and all(abs(x) < 1 for x in self._y2) ): # we now have the headroom to shift self.coeffs[:3] = [x * 2 for x in self.coeffs[:3]] self._y1 = self._y1 * 2 self._y2 = self._y2 * 2 self.remaining_shifts -= 1 self.b_shift -= 1 else: for n in range(5): self.coeffs[n] += (self.target_coeffs[n] - self.coeffs[n]) * 2**-self.slew_shift out_samples = deepcopy(sample_list) for channel in range(len(sample_list)): # use basic biquad out_samples[channel] = super().process(sample_list[channel], channel) return out_samples def process_channels_xcore(self, sample_list: list[float]) -> list[float]: """ Slew the biquad coefficients towards the target, then filter the samples in each channel using fixed point maths. Each sample is filtered using direct form 1 biquad using int32 fixed point maths, with use of the XS3 VPU. The float input sample is quantized to int32, and returned to float before outputting. """ if self.remaining_shifts > 0: # change in b_shift to manage, target_coeffs have less headroom, so add the headroom back tmp_target = deepcopy(self.target_coeffs_int) tmp_target[:3] = [utils.int32(x >> self.remaining_shifts) for x in tmp_target[:3]] # do the slew for n in range(5): self.int_coeffs[n] += ( utils.saturate_int32_vpu(tmp_target[n] - self.int_coeffs[n]) >> self.slew_shift ) # see if we have headroom to do the shift if ( abs(self.int_coeffs[0]) < (2**30) and abs(self.int_coeffs[1]) < (2**30) and abs(self.int_coeffs[2]) < (2**30) and all(abs(x) < (2**30) for x in self._y1) and all(abs(x) < (2**30) for x in self._y2) ): # we now have the headroom to shift self.int_coeffs[:3] = [utils.int32(x << 1) for x in self.int_coeffs[:3]] self._y1 = [utils.int32(x << 1) for x in self._y1] self._y2 = [utils.int32(x << 1) for x in self._y2] self.remaining_shifts -= 1 self.b_shift -= 1 else: # no change in b_shift to manage, so can just slew for n in range(5): self.int_coeffs[n] += ( utils.saturate_int32_vpu(self.target_coeffs_int[n] - self.int_coeffs[n]) >> self.slew_shift ) out_samples = deepcopy(sample_list) for channel in range(len(sample_list)): # use basic biquad process out_samples[channel] = super().process_xcore(sample_list[channel], channel) return out_samples def _round_to_q30(coeffs: list[float]) -> tuple[list[float], list[int]]: """ Round a list of filter coefficients to Q1.30 format and int32 precision. The coefficients should already have any b_shift applied. Returns the rounded coefficients in float and int formats """ rounded_coeffs = [0.0] * len(coeffs) int_coeffs = [0] * len(coeffs) Q = 30 for n in range(len(coeffs)): # scale to Q1.30 ints, note this is intentionally not multiplied # (2**Q -1) to keep 1.0 as 1.0 rounded_coeffs[n] = round(coeffs[n] * (1 << Q)) # check for overflow if not (-(1 << 31)) <= rounded_coeffs[n] <= utils.Q_max(31): raise ValueError( "Filter coefficient will overflow (%.4f, %d), reduce gain" % (coeffs[n], n) ) int_coeffs[n] = utils.int32(rounded_coeffs[n]) # rescale to floats rounded_coeffs[n] = rounded_coeffs[n] / (1 << Q) return rounded_coeffs, int_coeffs def _apply_biquad_gain(coeffs: list[float], gain_db: float) -> list[float]: """Apply linear gain to the b coefficients.""" gain = 10 ** (gain_db / 20) coeffs[0] = coeffs[0] * gain coeffs[1] = coeffs[1] * gain coeffs[2] = coeffs[2] * gain return coeffs def _apply_biquad_bshift(coeffs: list[float], b_shift: int) -> list[float]: """ Apply linear bitshift to the b coefficients. This can be used for high gain shelf and peaking filters, where the filter coefficients are greater than 2, and so cannot be represented in Q1.30 format. """ gain = 2**-b_shift coeffs[0] = coeffs[0] * gain coeffs[1] = coeffs[1] * gain coeffs[2] = coeffs[2] * gain return coeffs def _normalise_biquad(coeffs: list[float]) -> list[float]: """ Normalise biquad coefficients by dividing by a0 and making a1 and a2 negative. Expected input format: [b0, b1, b2, a0, a1, a2] Expected output format: [b0, b1, b2, -a1, -a2]/a0 """ if len(coeffs) != 6: raise ValueError("expected list of 6 biquad coefficients") # divide by a0, make a1 and a2 negative coeffs = [ coeffs[0] / coeffs[3], coeffs[1] / coeffs[3], coeffs[2] / coeffs[3], -coeffs[4] / coeffs[3], -coeffs[5] / coeffs[3], ] return coeffs def _round_and_check(coeffs: list[float], b_shift: int = 0) -> tuple[list[float], list[int]]: """ Apply any b_shift to biquad coefficients, then round to int32 precision, and check the poles are inside the unit circle. """ # round to int32 precision if len(coeffs) != 5: raise ValueError("coeffs should be in the form [b0 b1 b2 -a1 -a2]") coeffs = _apply_biquad_bshift(coeffs.copy(), b_shift) coeffs, int_coeffs = _round_to_q30(coeffs) # check filter is stable poles = np.roots([1, -coeffs[3], -coeffs[4]]) if np.any(np.abs(poles) >= 1): raise ValueError("Poles lie outside the unit circle, the filter is unstable") return coeffs, int_coeffs def _check_filter_freq(filter_freq, fs): if filter_freq > fs / 2: warnings.warn("filter_freq must be less than fs/2, saturating to fs/2", UserWarning) filter_freq = fs / 2 return filter_freq def _check_max_gain(gain, max_gain): if gain > max_gain: warnings.warn( f"gain_db must be less than {max_gain:.2f}, saturating to {max_gain:.2f}", UserWarning ) gain = max_gain return gain def _get_bshift(coeffs: list[float]) -> int: if len(coeffs) != 5: raise ValueError("coeffs should be in the form [b0 b1 b2 -a1 -a2]") b_coeffs = coeffs[:3] max_b = np.max(np.abs(b_coeffs)) if max_b != 0: shr = int(np.floor(np.log2(max_b))) else: return 0 return shr if (shr >= 0) else 0 def make_biquad_bypass(fs: int) -> list[float]: """ Create a bypass biquad filter. Only the b0 coefficient is set. Parameters ---------- fs : int The sample rate of the audio signal. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. """ coeffs = np.array([1.0, 0.0, 0.0, 0.0, 0.0], dtype=float).tolist() return coeffs def make_biquad_mute(fs: int) -> list[float]: """ Create a biquad filter coefficients list that represents a mute filter. All the coefficients are 0. Parameters ---------- fs : int The sampling frequency in Hz. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. """ coeffs = [0.0, 0.0, 0.0, 0.0, 0.0] return coeffs def make_biquad_gain(fs: int, gain_db: float) -> list[float]: """ Calculate the coefficients for a biquad filter with a specified linear gain. Parameters ---------- fs : int The sampling frequency in Hz. gain_db : float The desired gain in decibels. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. """ coeffs = make_biquad_bypass(fs) coeffs = _apply_biquad_gain(coeffs, gain_db) return coeffs def make_biquad_lowpass(fs: int, filter_freq: float, q_factor: float) -> list[float]: """Create coefficients for a lowpass biquad filter. Parameters ---------- fs : int The sample rate of the audio signal. filter_freq : float The cutoff frequency of the filter. q_factor : float The Q factor of the filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than fs/2. """ filter_freq = _check_filter_freq(filter_freq, fs) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) / (2 * q_factor) b0 = (+1.0 - np.cos(w0)) / 2.0 b1 = +1.0 - np.cos(w0) b2 = (+1.0 - np.cos(w0)) / 2.0 a0 = +1.0 + alpha a1 = -2.0 * np.cos(w0) a2 = +1.0 - alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_highpass(fs: int, filter_freq: float, q_factor: float) -> list[float]: """Create coefficients for a highpass biquad filter. Parameters ---------- fs : int The sample rate of the audio signal. filter_freq : float The cutoff frequency of the highpass filter. q_factor : float The Q factor of the highpass filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than fs/2. """ filter_freq = _check_filter_freq(filter_freq, fs) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) / (2 * q_factor) b0 = (1.0 + np.cos(w0)) / 2.0 b1 = -(1.0 + np.cos(w0)) b2 = (1.0 + np.cos(w0)) / 2.0 a0 = +1.0 + alpha a1 = -2.0 * np.cos(w0) a2 = +1.0 - alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs # Constant 0 dB peak gain def make_biquad_bandpass(fs: int, filter_freq: float, BW) -> list[float]: """Create coefficients for a biquad bandpass filter. Parameters ---------- fs : int The sampling frequency. filter_freq : float The center frequency of the bandpass filter. BW : float The bandwidth of the bandpass filter in octaves, measured between -3 dB points. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If filter_freq is greater than fs/2. """ filter_freq = _check_filter_freq(filter_freq, fs) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) * np.sinh(np.log(2) / 2 * BW * w0 / np.sin(w0)) b0 = alpha b1 = +0.0 b2 = -alpha a0 = +1.0 + alpha a1 = -2.0 * np.cos(w0) a2 = +1.0 - alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs # Constant 0 dB peak gain def make_biquad_bandstop(fs: int, filter_freq: float, BW: float) -> list[float]: """Create coefficients for a biquad bandstop filter. Parameters ---------- fs : int The sampling frequency. filter_freq : float The center frequency of the bandstop filter. BW : float The bandwidth of the bandstop filter in octaves, measured between -3 dB points Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than half of the sample rate. """ filter_freq = _check_filter_freq(filter_freq, fs) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) * np.sinh(np.log(2) / 2 * BW * w0 / np.sin(w0)) b0 = +1.0 b1 = -2.0 * np.cos(w0) b2 = +1.0 a0 = +1.0 + alpha a1 = -2.0 * np.cos(w0) a2 = +1.0 - alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_notch(fs: int, filter_freq: float, q_factor: float) -> list[float]: """Create a biquad notch filter. Parameters ---------- fs : int The sampling frequency. filter_freq : float The center frequency of the notch filter. q_factor : float The Q factor of the notch filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than half of the sample rate. """ filter_freq = _check_filter_freq(filter_freq, fs) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) / (2.0 * q_factor) b0 = +1.0 b1 = -2.0 * np.cos(w0) b2 = +1.0 a0 = +1.0 + alpha a1 = -2.0 * np.cos(w0) a2 = +1.0 - alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_allpass(fs: int, filter_freq: float, q_factor: float) -> list[float]: """ Create coefficients for a biquad allpass filter. Parameters ---------- fs : int The sample rate of the audio signal. filter_freq : float The center frequency of the allpass filter. q_factor : float The Q factor of the allpass filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than half of the sample rate. """ filter_freq = _check_filter_freq(filter_freq, fs) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) / (2.0 * q_factor) b0 = +1.0 - alpha b1 = -2.0 * np.cos(w0) b2 = +1.0 + alpha a0 = +1.0 + alpha a1 = -2.0 * np.cos(w0) a2 = +1.0 - alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_peaking( fs: int, filter_freq: float, q_factor: float, boost_db: float ) -> list[float]: """Create coefficients for a biquad peaking filter. Parameters ---------- fs : int The sampling frequency in Hz. filter_freq : float The center frequency of the peaking filter in Hz. q_factor : float The Q factor of the peaking filter. boost_db : float The boost in decibels applied by the filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than half of the sample rate. """ filter_freq = _check_filter_freq(filter_freq, fs) A = np.sqrt(10 ** (boost_db / 20)) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) / (2.0 * q_factor) b0 = +1.0 + alpha * A b1 = -2.0 * np.cos(w0) b2 = +1.0 - alpha * A a0 = +1.0 + alpha / A a1 = -2.0 * np.cos(w0) a2 = +1.0 - alpha / A coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_constant_q( fs: int, filter_freq: float, q_factor: float, boost_db: float ) -> list[float]: """Create coefficients for a biquad peaking filter with constant Q. Constant Q means that the bandwidth of the filter remains constant as the gain varies. It is commonly used for graphic equalisers. Parameters ---------- fs : int The sample rate of the audio signal. filter_freq : float The center frequency of the filter in Hz. q_factor : float The Q factor of the filter. boost_db : float The boost in decibels applied to the filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than half of the sample rate. References ---------- - Zoelzer, U. (2011). DAFX: Digital Audio Effects. John Wiley & Sons, Table 2.4 - https://www.musicdsp.org/en/latest/Filters/37-zoelzer-biquad-filters.html """ filter_freq = _check_filter_freq(filter_freq, fs) V = 10 ** (boost_db / 20) w0 = 2.0 * np.pi * filter_freq / fs K = np.tan(w0 / 2) if boost_db > 0: b0 = 1 + V * K / q_factor + K**2 b1 = 2 * (K**2 - 1) b2 = 1 - V * K / q_factor + K**2 a0 = 1 + K / q_factor + K**2 a1 = 2 * (K**2 - 1) a2 = 1 - K / q_factor + K**2 else: V = 1 / V b0 = 1 + (K / q_factor) + K**2 b1 = 2 * (K**2 - 1) b2 = 1 - (K / q_factor) + K**2 a0 = 1 + (V * K / q_factor) + K**2 a1 = 2 * (K**2 - 1) a2 = 1 - (V * K / q_factor) + K**2 coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_lowshelf( fs: int, filter_freq: float, q_factor: float, gain_db: float ) -> list[float]: """Create coefficients for a lowshelf biquad filter. The Q factor is defined in a similar way to standard low pass, i.e. > 0.707 will yield peakiness (where the shelf response does not monotonically change). The level change at f will be boost_db/2. Parameters ---------- fs : int The sample rate of the audio signal. filter_freq : float The cutoff frequency of the filter. q_factor : float The Q factor of the filter. gain_db : float The gain in decibels of the filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than half of the sample rate. """ filter_freq = _check_filter_freq(filter_freq, fs) A = 10.0 ** (gain_db / 40.0) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) / (2 * q_factor) b0 = A * ((A + 1) - (A - 1) * np.cos(w0) + 2 * np.sqrt(A) * alpha) b1 = 2 * A * ((A - 1) - (A + 1) * np.cos(w0)) b2 = A * ((A + 1) - (A - 1) * np.cos(w0) - 2 * np.sqrt(A) * alpha) a0 = (A + 1) + (A - 1) * np.cos(w0) + 2 * np.sqrt(A) * alpha a1 = -2 * ((A - 1) + (A + 1) * np.cos(w0)) a2 = (A + 1) + (A - 1) * np.cos(w0) - 2 * np.sqrt(A) * alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_highshelf( fs: int, filter_freq: float, q_factor: float, gain_db: float ) -> list[float]: """Create coefficients for a highshelf biquad filter. The Q factor is defined in a similar way to standard high pass, i.e. > 0.707 will yield peakiness. The level change at f will be boost_db/2. Parameters ---------- fs : int The sample rate of the audio signal. filter_freq : float The cutoff frequency of the filter. q_factor : float The Q factor of the filter. gain_db : float The gain in decibels of the filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If the filter frequency is greater than half of the sample rate. """ filter_freq = _check_filter_freq(filter_freq, fs) A = 10.0 ** (gain_db / 40.0) w0 = 2.0 * np.pi * filter_freq / fs alpha = np.sin(w0) / (2 * q_factor) b0 = A * ((A + 1) + (A - 1) * np.cos(w0) + 2 * np.sqrt(A) * alpha) b1 = -2 * A * ((A - 1) + (A + 1) * np.cos(w0)) b2 = A * ((A + 1) + (A - 1) * np.cos(w0) - 2 * np.sqrt(A) * alpha) a0 = (A + 1) - (A - 1) * np.cos(w0) + 2 * np.sqrt(A) * alpha a1 = 2 * ((A - 1) - (A + 1) * np.cos(w0)) a2 = (A + 1) - (A - 1) * np.cos(w0) - 2 * np.sqrt(A) * alpha coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs def make_biquad_linkwitz(fs: int, f0: float, q0: float, fp: float, qp: float) -> list[float]: """ Create coefficients for a Linkwitz Transform biquad filter. The Linkwitz Transform is commonly used to change the low frequency roll off slope of a loudspeaker. When applied to a loudspeaker, it will change the cutoff frequency from f0 to fp, and the quality factor from q0 to qp. Parameters ---------- fs : int The sampling frequency of the audio signal. f0 : float The original cutoff frequency of the filter. q0 : float The original quality factor of the filter at f0. fp : float The target cutoff frequency for the filter. qp : float The target quality factor for the filter. Returns ------- list[float] The coefficients of the biquad filter in the order [b0, b1, b2, -a1, -a2]. The coefficients are normalised by a0 such that ``a0 = 1``. Raises ------ ValueError If either f0 or fp is greater than fs/2. References ---------- - Linkwitz Transform: https://www.linkwitzlab.com/filters.htm#9 - Linkwitz Transform in MiniDSP: https://www.minidsp.com/applications/advanced-tools/linkwitz-transform """ f0 = _check_filter_freq(f0, fs) fp = _check_filter_freq(fp, fs) fc = (f0 + fp) / 2 # these are translated from the MiniDSP spreadsheet d0i = (2 * np.pi * f0) ** 2 d1i = (2 * np.pi * f0) / q0 c0i = (2 * np.pi * fp) ** 2 c1i = (2 * np.pi * fp) / qp gn = (2 * np.pi * fc) / (np.tan(np.pi * fc / fs)) cci = c0i + gn * c1i + (gn**2) a0 = cci a1 = 2 * (c0i - (gn**2)) a2 = c0i - gn * c1i + (gn**2) b0 = d0i + gn * d1i + (gn**2) b1 = 2 * (d0i - (gn**2)) b2 = d0i - gn * d1i + (gn**2) coeffs = [b0, b1, b2, a0, a1, a2] coeffs = _normalise_biquad(coeffs) return coeffs if __name__ == "__main__": fs = 48000 biquad_1 = biquad(make_biquad_notch(fs, 20, 1), 0, Q_sig=30) biquad_2 = biquad(make_biquad_notch(fs, 20, 1), 3, Q_sig=30) biquad_3 = biquad(make_biquad_notch(fs, 20, 1), 0, Q_sig=27) biquad_4 = biquad(make_biquad_notch(fs, 20, 1), 3, Q_sig=27) biquad_5 = biquad(make_biquad_notch(fs, 20, 1), 3, Q_sig=27) t = np.arange(fs * 4) / fs # signal = spsig.chirp(t, 20, 1, 20000, 'log', phi=-90) signal = np.sin(2 * np.pi * 997 * t, dtype=np.float64) output_1 = np.zeros(len(signal)) output_2 = np.zeros(len(signal)) output_3 = np.zeros(len(signal)) output_4 = np.zeros(len(signal)) output_5 = np.zeros(len(signal)) for n in range(len(signal)): output_1[n] = biquad_1.process_xcore(signal[n]) output_2[n] = biquad_2.process_xcore(signal[n]) output_3[n] = biquad_3.process_xcore(signal[n]) output_4[n] = biquad_4.process_xcore(signal[n]) output_5[n] = biquad_5.process(signal[n]) # plt.plot(signal) # plt.plot(output_1) # plt.plot(output_2) plt.psd(output_1, 1024 * 16, fs, window=spsig.windows.blackmanharris(1024 * 16)) plt.psd(output_2, 1024 * 16, fs, window=spsig.windows.blackmanharris(1024 * 16)) plt.psd(output_3, 1024 * 16, fs, window=spsig.windows.blackmanharris(1024 * 16)) plt.psd(output_4, 1024 * 16, fs, window=spsig.windows.blackmanharris(1024 * 16)) plt.psd(output_5, 1024 * 16, fs, window=spsig.windows.blackmanharris(1024 * 16)) ax = plt.gca() ax.set_xscale("log") plt.legend(["Q30", "Q30, b_shift 3", "Q27", "Q27, b_shift 3", "double"]) plt.show() pass exit()