This commit is contained in:
Steven Dan
2025-12-11 09:43:42 +08:00
commit d8b2974133
1822 changed files with 280037 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
# Copyright 2024-2025 XMOS LIMITED.
# This Software is subject to the terms of the XMOS Public Licence: Version 1.
"""This sub-package contains all the dynamic range control (DRC)
DSP components.
"""
from audio_dsp.dsp.drc.drc import (
compressor_limiter_base as compressor_limiter_base,
envelope_detector_peak as envelope_detector_peak,
envelope_detector_rms as envelope_detector_rms,
limiter_peak as limiter_peak,
hard_limiter_peak as hard_limiter_peak,
limiter_rms as limiter_rms,
compressor_rms as compressor_rms,
compressor_rms_softknee as compressor_rms_softknee,
clipper as clipper,
)
from audio_dsp.dsp.drc.stereo_compressor_limiter import (
limiter_peak_stereo as limiter_peak_stereo,
compressor_rms_stereo as compressor_rms_stereo,
)
from audio_dsp.dsp.drc.sidechain import (
compressor_rms_sidechain_mono as compressor_rms_sidechain_mono,
compressor_rms_sidechain_stereo as compressor_rms_sidechain_stereo,
)
from audio_dsp.dsp.drc.expander import (
noise_gate as noise_gate,
noise_suppressor_expander as noise_suppressor_expander,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
# Copyright 2024-2025 XMOS LIMITED.
# This Software is subject to the terms of the XMOS Public Licence: Version 1.
"""Generic utilities for the dynamic range control DSP blocks."""
from audio_dsp.dsp import utils as utils
import numpy as np
from math import sqrt, isqrt
import warnings
from audio_dsp.dsp.types import float32
FLT_MIN = np.finfo(float).tiny
# Q format for the drc alphas and gains
Q_alpha = 31
def calculate_rms_threshold(threshold_db, Q_sig) -> tuple[float, int]:
"""
Calculate the linear RMS threshold in floating and fixed point from a
target threshold in decibels.
If the threshold is higher than representable in the fixed point
format, it is saturated.
"""
return calculate_threshold(threshold_db, Q_sig, power=True)
def calculate_peak_threshold(threshold_db, Q_sig) -> tuple[float, int]:
"""
Calculate the linear peak threshold in floating and fixed point from a
target threshold in decibels.
If the threshold is higher than representable in the fixed point
format, it is saturated.
"""
return calculate_threshold(threshold_db, Q_sig, power=False)
def calculate_threshold(threshold_db, Q_sig, power=False) -> tuple[float, int]:
"""
Calculate the linear threshold in floating and fixed point from a
target threshold in decibels.
If the threshold is higher than representable in the fixed point
format, it is saturated.
"""
if power:
threshold = utils.db_pow2gain(threshold_db)
else:
threshold = utils.db2gain(threshold_db)
threshold = utils.saturate_float(threshold, Q_sig)
if power:
new_threshold_db = utils.db_pow(threshold)
else:
new_threshold_db = utils.db(threshold)
if threshold_db != new_threshold_db:
warnings.warn(
"Threshold %.2f not representable in Q format Q%d, saturating to %.2f"
% (threshold_db, Q_sig, new_threshold_db),
UserWarning,
)
threshold_int = utils.float_to_fixed(threshold, Q_sig)
# this avoids division by zero for expanders
threshold_int = max(1, threshold_int)
return threshold, threshold_int
def alpha_from_time(attack_or_release_time, fs):
"""
Calculate the exponential moving average time constant from an
attack/release time in seconds.
Attack times simplified from McNally, seem pretty close.
Assumes the time constant of a digital filter is the -3 dB
point where abs(H(z))**2 = 0.5.
This is also approximately the time constant of a first order
system, `alpha = 1 - exp(-T/tau)`, where `T` is the sample period
and `tau` is the time constant.
attack/release time can't be faster than the length of 2
samples, and alpha can't be greater than 1. This function will
saturate to those values.
"""
if attack_or_release_time < 0:
warnings.warn(
"Attack/release time must not be negative. For the fastest possible "
"attack/release time, use zero. Time set to zero",
UserWarning,
)
attack_or_release_time = 0
T = 1 / fs
alpha = 2 * T / (attack_or_release_time + FLT_MIN)
if alpha > 1:
alpha = 1
warnings.warn(
"Attack or release time too fast for sample rate, setting as fast as possible.",
UserWarning,
)
alpha_int = utils.int32(round(alpha * 2**31)) if alpha != 1.0 else utils.int32(2**31 - 1)
# This is possible if alpha > (4/fs)*(2**31), which is 49.7 hours @ 48kHz,
# in which case you should probably use a lower sample rate.
if alpha_int <= 0:
warnings.warn(
"alpha not > 0, this is possible if attack/release time > (4/fs)*(2**31)."
"Setting alpha_int to 0 (no smoothing)",
UserWarning,
)
alpha_int = 0
return alpha, alpha_int
def rms_compressor_slope_from_ratio(ratio):
"""Convert a compressor ratio to the slope, where the slope is
defined as (1 - 1 / ratio) / 2.0. The division by 2 compensates for
the RMS envelope detector returning the RMS².
"""
if ratio < 1:
warnings.warn("Compressor ratio must be >= 1, setting ratio to 1", UserWarning)
ratio = 1
slope = (1 - 1 / ratio) / 2.0
slope_f32 = float32(slope)
return slope, slope_f32
def peak_expander_slope_from_ratio(ratio):
"""Convert an expander ratio to the slope, where the slope is
defined as (1 - ratio).
"""
if ratio < 1:
warnings.warn("Expander ratio must be >= 1, setting ratio to 1", UserWarning)
ratio = 1
slope = 1 - ratio
slope_f32 = float32(slope)
return slope, slope_f32
def calc_ema_xcore(x, y, alpha):
"""Calculate fixed-point exponential moving average, given that alpha is in Q_alpha format."""
acc = int(x) << Q_alpha
mul = utils.int32(y - x)
acc += mul * alpha
x = utils.int32_mult_sat_extract(acc, 1, Q_alpha)
return x
def apply_gain_xcore(sample: int, gain: int) -> int:
"""Apply the gain to a sample using fixed-point math. Assumes that gain is in Q_alpha format."""
acc = 1 << (Q_alpha - 1)
acc += sample * gain
y = utils.int32_mult_sat_extract(acc, 1, Q_alpha)
return y
def limiter_peak_gain_calc(envelope, threshold, slope=None):
"""Calculate the float gain for the current sample."""
new_gain = threshold / envelope
new_gain = min(1, new_gain)
return new_gain
def limiter_peak_gain_calc_xcore(envelope_int, threshold_int, slope=None):
"""Calculate the int gain for the current sample."""
if threshold_int >= envelope_int:
new_gain_int = utils.int32(0x7FFFFFFF)
else:
new_gain_int = int(threshold_int) << 31
new_gain_int = utils.int32(new_gain_int // envelope_int)
return new_gain_int
def limiter_rms_gain_calc(envelope, threshold, slope=None):
"""Calculate the float gain for the current sample.
Note that as the RMS envelope detector returns x**2, we need to
sqrt the gain.
"""
new_gain = sqrt(threshold / envelope)
new_gain = min(1, new_gain)
return new_gain
def limiter_rms_gain_calc_xcore(envelope_int, threshold_int, slope=None):
"""Calculate the int gain for the current sample.
Note that as the RMS envelope detector returns x**2, we need to
sqrt the gain.
"""
if threshold_int >= envelope_int:
new_gain_int = utils.int32(0x7FFFFFFF)
else:
new_gain_int = int(threshold_int) << 31
new_gain_int = utils.int32(new_gain_int // envelope_int)
new_gain_int = utils.int32(isqrt(new_gain_int * (1 << 31)))
return new_gain_int
def compressor_rms_gain_calc(envelope, threshold, slope=None):
"""Calculate the float gain for the current sample.
Note that as the RMS envelope detector returns x**2, we need to
sqrt the gain. Slope is used instead of ratio to allow the gain
calculation to avoid the log domain.
"""
# if envelope below threshold, apply unity gain, otherwise scale
# down
new_gain = (threshold / envelope) ** slope
new_gain = min(1, new_gain)
return new_gain
def compressor_rms_gain_calc_xcore(envelope_int, threshold_int, slope_f32=None):
"""Calculate the int gain for the current sample.
Note that as the RMS envelope detector returns x**2, we need to
sqrt the gain. Slope is used instead of ratio to allow the gain
calculation to avoid the log domain.
"""
# if envelope below threshold, apply unity gain, otherwise scale
# down
if slope_f32 > float32(0) and threshold_int < envelope_int:
new_gain_int = int(threshold_int) << 31
new_gain_int = utils.int32(new_gain_int // envelope_int)
new_gain_f32 = float32(new_gain_int * 2**-31) ** slope_f32
# division result could be < 1 in int32, but ==1 in f32
if new_gain_f32 >= 1.0:
return utils.int32(0x7FFFFFFF)
new_gain_int = (new_gain_f32 * float32(2**31)).as_int32()
else:
new_gain_int = utils.int32(0x7FFFFFFF)
return new_gain_int
def noise_gate_gain_calc(envelope, threshold, slope=None):
"""Calculate the float gain for the current sample."""
if envelope < threshold:
new_gain = 0
else:
new_gain = 1
return new_gain
def noise_gate_gain_calc_xcore(envelope_int, threshold_int, slope_int=None):
"""Calculate the int gain for the current sample."""
if envelope_int < threshold_int:
new_gain_int = utils.int32(0)
else:
new_gain_int = utils.int32((1 << 31) - 1)
return new_gain_int
def noise_suppressor_expander_gain_calc(envelope, threshold, slope):
"""Calculate the float gain for the current sample."""
# if envelope above threshold, apply unity gain, otherwise scale
# down
new_gain = (threshold / envelope) ** slope
new_gain = min(1, new_gain)
return new_gain
def noise_suppressor_expander_gain_calc_xcore(envelope_int, threshold_int, slope_f32):
"""Calculate the int gain for the current sample."""
# if envelope above threshold, apply unity gain, otherwise scale
# down
# note this is rearranged to (envelope / threshold) ** -slope in order
# to be similar to the compressor implementation, which also allows
# 1/threshold to be precomputed
invt = utils.int64(((1 << 63) - 1) // threshold_int)
if -slope_f32 > float32(0) and threshold_int > envelope_int:
# this looks a bit scary, but as long as envelope < threshold,
# it can't overflow
new_gain_int = utils.int64(envelope_int * invt)
new_gain_f32 = float32(new_gain_int * 2**-63) ** -slope_f32
# division result could be < 1 in int32, but ==1 in f32
if new_gain_f32 >= 1.0:
return utils.int32(0x7FFFFFFF)
new_gain_int = (new_gain_f32 * float32(2**31)).as_int32()
else:
new_gain_int = utils.int32(0x7FFFFFFF)
return new_gain_int

View File

@@ -0,0 +1,250 @@
# Copyright 2024-2025 XMOS LIMITED.
# This Software is subject to the terms of the XMOS Public Licence: Version 1.
"""The expander DSP blocks."""
import numpy as np
from audio_dsp.dsp import utils as utils
from audio_dsp.dsp import generic as dspg
import audio_dsp.dsp.drc.drc_utils as drcu
from audio_dsp.dsp.types import float32
from audio_dsp.dsp.drc.drc import compressor_limiter_base, peak_compressor_limiter_base
FLT_MIN = np.finfo(float).tiny
class expander_base(compressor_limiter_base):
"""
A base class for expanders (including noise suppressors).
Expanders differ from compressors in that they reduce the level of a
signal when it falls below a threshold (instead of above). The
attack time is still defined as how quickly the gain is changed
after the envelope exceeds the threshold.
Expanders, noise gates and noise suppressors have very similar
structures, with differences in the gain calculation. All the shared
code and parameters are calculated in this base class.
Parameters
----------
n_chans : int
number of parallel channels the expander runs on. The
channels are expanded separately, only the constant
parameters are shared.
threshold_db : float
Threshold in decibels below which expansion occurs. This cannot
be greater than the maximum value representable in
Q_SIG format, and will saturate to that value.
attack_t : float
Attack time of the expander in seconds. This cannot be
faster than 2/fs seconds, and saturates to that
value. Exceptionally large attack times may converge to zero.
release_t: float
Release time of the expander in seconds. This cannot
be faster than 2/fs seconds, and saturates to that
value. Exceptionally large release times may converge to zero.
Attributes
----------
threshold : float
Value below which expanding occurs for floating point
processing.
threshold_int : int
Value below which expanding occurs for int32 fixed
point processing.
"""
def reset_state(self):
"""Reset the envelope detector to 1 and the gain to 1, so the
gate starts off.
"""
if self.env_detector is not None:
self.env_detector.envelope = [1] * self.n_chans
self.env_detector.envelope_int = [utils.int32(2**self.Q_sig - 1)] * self.n_chans
self.gain = [1] * self.n_chans
self.gain_int = [2**31 - 1] * self.n_chans
def process(self, sample, channel=0):
"""
Update the envelope for a signal, then calculate and apply the
required gain for expanding, using floating point
maths.
Take one new sample and return the expanded sample.
Input should be scaled with 0 dB = 1.0.
"""
# get envelope from envelope detector
envelope = self.env_detector.process(sample, channel)
# avoid /0
envelope = np.maximum(envelope, np.finfo(float).tiny)
# calculate the gain, this function should be defined by the
# child class
new_gain = self.gain_calc(envelope, self.threshold, self.slope) # type: ignore : base inits to None
# see if we're attacking or decaying
if new_gain < self.gain[channel]:
# below threshold, gain < unity
alpha = self.release_alpha
else:
# above threshold, gain = unity
alpha = self.attack_alpha
# do exponential moving average
self.gain[channel] = ((1 - alpha) * self.gain[channel]) + (alpha * new_gain)
# apply gain to input
y = self.gain[channel] * sample
return y, new_gain, envelope
def process_xcore(self, sample, channel=0, return_int=False):
"""
Update the envelope for a signal, then calculate and apply the
required gain for expanding, using int32 fixed point
maths.
Take one new sample and return the expanded sample.
Input should be scaled with 0 dB = 1.0.
"""
sample_int = utils.float_to_fixed(sample, self.Q_sig)
# get envelope from envelope detector
envelope_int = self.env_detector.process_xcore(sample_int, channel)
# avoid /0
envelope_int = max(envelope_int, 1)
# if envelope below threshold, apply unity gain, otherwise scale
# down
new_gain_int = self.gain_calc_xcore(envelope_int, self.threshold_int, self.slope_f32) # pyright: ignore : base inits to None
# see if we're attacking or decaying
if new_gain_int < self.gain_int[channel]:
# below threshold, gain < unity
alpha = self.release_alpha_int
else:
# above threshold, gain = unity
alpha = self.attack_alpha_int
# do exponential moving average
self.gain_int[channel] = drcu.calc_ema_xcore(self.gain_int[channel], new_gain_int, alpha)
# apply gain
y = drcu.apply_gain_xcore(sample_int, self.gain_int[channel])
if return_int:
return y, new_gain_int, envelope_int
else:
return (
utils.fixed_to_float(y, self.Q_sig),
utils.fixed_to_float(new_gain_int, self.Q_alpha),
utils.fixed_to_float(envelope_int, self.Q_sig),
)
class peak_expander_base(expander_base, peak_compressor_limiter_base):
"""A generic expander base class that uses a peak envelope detector.
Inheritance from expander_base is prioritised over
peak_compressor_limiter_base due to the order in the definition. To
confirm this, peak_expander_base.__mro__ can be inspected.
Parameters
----------
threshold_db : float
Threshold in decibels below which expansion occurs. This cannot
be greater than the maximum value representable in
Q_SIG format, and will saturate to that value.
"""
class noise_gate(peak_expander_base):
"""A noise gate that reduces the level of an audio signal when it
falls below a threshold.
When the signal envelope falls below the threshold, the gain applied
to the signal is reduced to 0 (based on the release time). When the
envelope returns above the threshold, the gain applied to the signal
is increased to 1 over the attack time.
The initial state of the noise gate is with the gate open (no
attenuation), assuming a full scale signal has been present before
t = 0.
"""
def __init__(self, fs, n_chans, threshold_db, attack_t, release_t, Q_sig=dspg.Q_SIG):
super().__init__(fs, n_chans, threshold_db, attack_t, release_t, Q_sig)
# set the gain calculation function handles
self.gain_calc = drcu.noise_gate_gain_calc
self.gain_calc_xcore = drcu.noise_gate_gain_calc_xcore
class noise_suppressor_expander(peak_expander_base):
"""A noise suppressor that reduces the level of an audio signal when
it falls below a threshold. This is also known as an expander.
When the signal envelope falls below the threshold, the gain applied
to the signal is reduced relative to the expansion ratio over the
release time. When the envelope returns above the threshold, the
gain applied to the signal is increased to 1 over the attack time.
The initial state of the noise suppressor is with the suppression
off, assuming a full scale signal has been present before
t = 0.
Parameters
----------
ratio : float
The expansion ratio applied to the signal when the envelope
falls below the threshold.
Attributes
----------
ratio : float
slope : float
The slope factor of the expander, defined as
`slope = 1 - ratio`.
slope_f32 : float32
The slope factor of the expander, used for int32 to float32
processing.
"""
def __init__(self, fs, n_chans, ratio, threshold_db, attack_t, release_t, Q_sig=dspg.Q_SIG):
super().__init__(fs, n_chans, threshold_db, attack_t, release_t, Q_sig)
# property calculates the slopes as well
self.ratio = ratio
# set the gain calculation function handles
self.gain_calc = drcu.noise_suppressor_expander_gain_calc
self.gain_calc_xcore = drcu.noise_suppressor_expander_gain_calc_xcore
@property
def ratio(self):
"""Expansion gain ratio applied when the signal is below the
threshold; changing this property also updates the slope used in
the fixed and floating point implementation.
"""
return self._ratio
@ratio.setter
def ratio(self, value):
self._ratio = value
self.slope, self.slope_f32 = drcu.peak_expander_slope_from_ratio(self.ratio)
if __name__ == "__main__":
import matplotlib.pyplot as plt
nse = noise_suppressor_expander(48000, 1, 3, -20, 0.01, 0.1)
ing, outg = nse.get_gain_curve()
plt.plot(ing, outg)
plt.axis("equal")
plt.xlim([ing[0], ing[-1]])
plt.ylim([ing[0], ing[-1]])
plt.grid()
plt.show()

View File

@@ -0,0 +1,413 @@
# Copyright 2024-2025 XMOS LIMITED.
# This Software is subject to the terms of the XMOS Public Licence: Version 1.
"""The sidechain compressor DSP blocks."""
from copy import deepcopy
import numpy as np
from audio_dsp.dsp import utils as utils
from audio_dsp.dsp import generic as dspg
import audio_dsp.dsp.drc.drc_utils as drcu
from audio_dsp.dsp.types import float32
from audio_dsp.dsp.drc.drc import rms_compressor_limiter_base
from audio_dsp.dsp.drc.stereo_compressor_limiter import rms_compressor_limiter_stereo_base
class compressor_rms_sidechain_mono(rms_compressor_limiter_base):
"""
A mono sidechain compressor based on the RMS value of the signal.
When the RMS envelope of the signal exceeds the threshold, the
signal amplitude is reduced by the compression ratio.
The threshold sets the value above which compression occurs. The
ratio sets how much the signal is compressed. A ratio of 1 results
in no compression, while a ratio of infinity results in the same
behaviour as a limiter. The attack time sets how fast the compressor
starts compressing. The release time sets how long the signal takes
to ramp up to it's original level after the envelope is below the
threshold.
Parameters
----------
ratio : float
Compression gain ratio applied when the signal is above the
threshold
Attributes
----------
ratio : float
slope : float
The slope factor of the compressor, defined as
`slope = (1 - 1/ratio) / 2`.
slope_f32 : float32
The slope factor of the compressor, used for int32 to float32
processing.
"""
def __init__(self, fs, ratio, threshold_db, attack_t, release_t, Q_sig=dspg.Q_SIG):
super().__init__(fs, 1, threshold_db, attack_t, release_t, Q_sig)
# property calculates the slopes as well
self.ratio = ratio
# set the gain calculation function handles
self.gain_calc = drcu.compressor_rms_gain_calc
self.gain_calc_xcore = drcu.compressor_rms_gain_calc_xcore
@property
def ratio(self):
"""Compression gain ratio applied when the signal is above the
threshold; changing this property also updates the slope used in
the fixed and floating point implementation.
"""
return self._ratio
@ratio.setter
def ratio(self, value):
self._ratio = value
self.slope, self.slope_f32 = drcu.rms_compressor_slope_from_ratio(self.ratio)
def reset_state(self):
"""Reset the envelope detectors to 0 and the gain to 1."""
if self.env_detector:
self.env_detector.reset_state()
self.gain = 1
self.gain_int = 2**31 - 1
def process(self, input_sample: float, detect_sample: float): # type: ignore : overloading base class
"""
Update the envelope for the detection signal, then calculate and
apply the required gain for compression/limiting, and apply to
the input signal using floating point maths.
Take one new sample and return the compressed/limited sample.
Input should be scaled with 0 dB = 1.0.
Parameters
----------
input_sample : float
The input sample to be compressed.
detect_sample : float
The sample used by the envelope detector to determine the
amount of compression to apply to the input_sample.
"""
# get envelope from envelope detector
envelope = self.env_detector.process(detect_sample)
# avoid /0
envelope = np.maximum(envelope, np.finfo(float).tiny)
# calculate the gain, this function should be defined by the
# child class
new_gain = self.gain_calc(envelope, self.threshold, self.slope)
# see if we're attacking or decaying
if new_gain < self.gain:
alpha = self.attack_alpha
else:
alpha = self.release_alpha
# do exponential moving average
self.gain = ((1 - alpha) * self.gain) + (alpha * new_gain)
# apply gain to input
y = self.gain * input_sample
return y, new_gain, envelope
def process_xcore(self, input_sample: float, detect_sample: float): # type: ignore : overloading base class
"""
Update the envelope for the detection signal, then calculate and
apply the required gain for compression/limiting, and apply to
the input signal using int32 maths.
Take one new sample and return the compressed/limited sample.
Input should be scaled with 0 dB = 1.0.
Parameters
----------
input_sample : float
The input sample to be compressed.
detect_sample : float
The sample used by the envelope detector to determine the
amount of compression to apply to the input_sample.
"""
# quantize
sample_int = utils.float_to_fixed(input_sample, self.Q_sig)
detect_sample_int = utils.float_to_fixed(detect_sample, self.Q_sig)
# get envelope from envelope detector
envelope_int = self.env_detector.process_xcore(detect_sample_int)
# avoid /0
envelope_int = max(envelope_int, 1)
assert isinstance(envelope_int, int)
# if envelope below threshold, apply unity gain, otherwise scale
# down
new_gain_int = self.gain_calc_xcore(envelope_int, self.threshold_int, self.slope_f32)
# see if we're attacking or decaying
if new_gain_int < self.gain_int:
alpha = self.attack_alpha_int
else:
alpha = self.release_alpha_int
# do exponential moving average
self.gain_int = drcu.calc_ema_xcore(self.gain_int, new_gain_int, alpha)
# apply gain
y = drcu.apply_gain_xcore(sample_int, self.gain_int)
return (
utils.fixed_to_float(y, self.Q_sig),
utils.fixed_to_float(new_gain_int, self.Q_alpha),
utils.fixed_to_float(envelope_int, self.Q_sig),
)
def process_frame(self, frame):
"""
Take a list frames of samples and return the processed frames.
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.
When calling self.process only take the first output.
"""
assert len(frame) == 2
frame_size = frame[0].shape[0]
output = np.zeros(frame_size)
for sample in range(frame_size):
output[sample] = self.process(frame[0][sample], frame[1][sample])[0]
return [output]
def process_frame_xcore(self, frame):
"""
Take a list frames of samples and return the processed frames,
using a bit exact xcore 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.
When calling self.process_xcore only take the first output.
"""
assert len(frame) == 2
frame_size = frame[0].shape[0]
output = np.zeros(frame_size)
for sample in range(frame_size):
output[sample] = self.process_xcore(frame[0][sample], frame[1][sample])[0]
return [output]
class compressor_rms_sidechain_stereo(rms_compressor_limiter_stereo_base):
"""
A stereo sidechain compressor based on the RMS value of the signal.
When the RMS envelope of the signal exceeds the threshold, the
signal amplitude is reduced by the compression ratio. The same
compression is applied to both channels, using the highest
individual channel envelope.
The threshold sets the value above which compression occurs. The
ratio sets how much the signal is compressed. A ratio of 1 results
in no compression, while a ratio of infinity results in the same
behaviour as a limiter. The attack time sets how fast the compressor
starts compressing. The release time sets how long the signal takes
to ramp up to it's original level after the envelope is below the
threshold.
Parameters
----------
ratio : float
Compression gain ratio applied when the signal is above the
threshold
Attributes
----------
ratio : float
slope : float
The slope factor of the compressor, defined as
`slope = (1 - 1/ratio)`.
slope_f32 : float32
The slope factor of the compressor, used for int32 to float32
processing.
"""
def __init__(self, fs, ratio, threshold_db, attack_t, release_t, Q_sig=dspg.Q_SIG):
n_chans = 2
super().__init__(fs, n_chans, threshold_db, attack_t, release_t, Q_sig)
# property calculates the slopes as well
self.ratio = ratio
# set the gain calculation function handles
self.gain_calc = drcu.compressor_rms_gain_calc
self.gain_calc_xcore = drcu.compressor_rms_gain_calc_xcore
@property
def ratio(self):
"""Compression gain ratio applied when the signal is above the
threshold; changing this property also updates the slope used in
the fixed and floating point implementation.
"""
return self._ratio
@ratio.setter
def ratio(self, value):
self._ratio = value
self.slope, self.slope_f32 = drcu.rms_compressor_slope_from_ratio(self.ratio)
def process_channels(self, input_samples: list[float], detect_samples: list[float]): # type: ignore : override base class
"""
Update the envelopes for a detection signal, then calculate and
apply the required gain for compression/limiting to the input,
using floating point maths. The same gain is applied to both
stereo channels.
Take one new sample and return the compressed/limited sample.
Input should be scaled with 0 dB = 1.0.
Parameters
----------
input_samples : list[float]
List of input samples to be compressed.
detect_samples : list[float]
List of samples used by the envelope detector to determine the
amount of compression to apply to the input_sample.
"""
# get envelope from envelope detector
env0 = self.env_detector.process(detect_samples[0], 0)
env1 = self.env_detector.process(detect_samples[1], 1)
envelope = np.maximum(env0, env1)
# avoid /0
envelope = np.maximum(envelope, np.finfo(float).tiny)
# calculate the gain, this function should be defined by the
# child class
new_gain = self.gain_calc(envelope, self.threshold, self.slope) # type: ignore : base inits to None
# see if we're attacking or decaying
if new_gain < self.gain:
alpha = self.attack_alpha
else:
alpha = self.release_alpha
# do exponential moving average
self.gain = ((1 - alpha) * self.gain) + (alpha * new_gain)
# apply gain to input
y = self.gain * input_samples
return y, new_gain, envelope
def process_channels_xcore( # type: ignore : override base class
self, input_samples: list[float], detect_samples: list[float]
):
"""
Update the envelopes for a detection signal, then calculate and
apply the required gain for compression/limiting to the input,
using int32 fixed point maths. The same gain is applied to both
stereo channels.
Take one new sample and return the compressed/limited sample.
Input should be scaled with 0 dB = 1.0.
Parameters
----------
input_samples : list[float]
List of input samples to be compressed.
detect_samples : list[float]
List of samples used by the envelope detector to determine the
amount of compression to apply to the input_sample.
"""
# quantize
samples_int = [int(0)] * len(input_samples)
detect_samples_int = [int(0)] * len(input_samples)
for i in range(len(input_samples)):
samples_int[i] = utils.float_to_fixed(input_samples[i], self.Q_sig)
detect_samples_int[i] = utils.float_to_fixed(detect_samples[i], self.Q_sig)
# get envelope from envelope detector
env0 = self.env_detector.process_xcore(detect_samples_int[0], 0)
env1 = self.env_detector.process_xcore(detect_samples_int[1], 1)
envelope_int = np.maximum(env0, env1)
# avoid /0
envelope_int = max(envelope_int, 1)
# if envelope below threshold, apply unity gain, otherwise scale
# down
new_gain_int = self.gain_calc_xcore(envelope_int, self.threshold_int, self.slope_f32) # type: ignore : base inits to None
# see if we're attacking or decaying
if new_gain_int < self.gain_int:
alpha = self.attack_alpha_int
else:
alpha = self.release_alpha_int
# do exponential moving average
self.gain_int = drcu.calc_ema_xcore(self.gain_int, new_gain_int, alpha)
y = [0.0] * self.n_chans
# apply gain in int32
for i in range(len(input_samples)):
y_uq = drcu.apply_gain_xcore(samples_int[i], self.gain_int)
y[i] = utils.fixed_to_float(y_uq, self.Q_sig)
return (
y,
utils.fixed_to_float(new_gain_int, self.Q_alpha),
utils.fixed_to_float(envelope_int, self.Q_sig),
)
def process_frame(self, frame):
"""
Take a list frames of samples and return the processed frames.
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.
When calling self.process only take the first output.
"""
assert len(frame) == 4, "has to be dual stereo"
frame_size = frame[0].shape[0]
output = deepcopy(frame[0:2])
for sample in range(frame_size):
out_samples = self.process_channels(
[frame[0][sample], frame[1][sample]],
[frame[2][sample], frame[3][sample]],
)[0]
output[0][sample] = out_samples[0]
output[1][sample] = out_samples[1]
return output
def process_frame_xcore(self, frame):
"""
Take a list frames of samples and return the processed frames,
using a bit exact xcore 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.
When calling self.process_xcore only take the first output.
"""
assert len(frame) == 4, "has to be dual stereo"
frame_size = frame[0].shape[0]
output = deepcopy(frame[0:2])
for sample in range(frame_size):
out_samples = self.process_channels_xcore(
[frame[0][sample], frame[1][sample]],
[frame[2][sample], frame[3][sample]],
)[0]
output[0][sample] = out_samples[0]
output[1][sample] = out_samples[1]
return output

View File

@@ -0,0 +1,440 @@
# Copyright 2024-2025 XMOS LIMITED.
# This Software is subject to the terms of the XMOS Public Licence: Version 1.
"""DSP blocks for stereo compressors and limiters."""
from copy import deepcopy
import numpy as np
from audio_dsp.dsp import utils as utils
from audio_dsp.dsp import generic as dspg
from audio_dsp.dsp.drc import envelope_detector_peak, envelope_detector_rms
import audio_dsp.dsp.drc.drc_utils as drcu
from audio_dsp.dsp.types import float32
class compressor_limiter_stereo_base(dspg.dsp_block):
"""
A base class shared by stereo compressor and limiter objects.
Stereo compressors and limiters work in a similar way to normal
compressor_limiter_base objects, but they process two channels and
apply the same gain to both channels.
The compressor and limiter have very similar structures, with
differences in the gain calculation. All the shared code and
parameters are calculated in this base class.
Parameters
----------
n_chans : int
Number of parallel channels the compressor/limiter runs on. The
channels are limited/compressed separately, only the constant
parameters are shared.
attack_t : float
Attack time of the compressor/limiter in seconds. This cannot be
faster than 2/fs seconds, and saturates to that
value. Exceptionally large attack times may converge to zero.
release_t: float
Release time of the compressor/limiter in seconds. This cannot
be faster than 2/fs seconds, and saturates to that
value. Exceptionally large release times may converge to zero.
Attributes
----------
threshold_db : float
attack_t : float
release_t : float
threshold : float
Value above which compression/limiting occurs for floating point
processing.
gain : list[float]
Current gain to be applied to the signal for each channel for
floating point processing.
attack_alpha : float
Attack time parameter used for exponential moving average in
floating point processing.
release_alpha : float
Release time parameter used for exponential moving average in
floating point processing.
threshold_int : int
Value above which compression/limiting occurs for int32 fixed
point processing.
gain_int : list[int]
Current gain to be applied to the signal for each channel for
int32 fixed point processing.
attack_alpha_int : int
attack_alpha in 32-bit int format.
release_alpha_int : int
release_alpha in 32-bit int format.
gain_calc : function
function pointer to floating point gain calculation function.
gain_calc_int : function
function pointer to fixed point gain calculation function.
"""
def __init__(self, fs, n_chans, attack_t, release_t, Q_sig=dspg.Q_SIG):
assert n_chans == 2, "has to be stereo"
super().__init__(fs, n_chans, Q_sig)
self.Q_alpha = drcu.Q_alpha
assert self.Q_alpha == 31, "When changing this the reset value will have to be updated"
# setting attack and release times sets the EWM coeffs in this and
# the envelope detector
self.attack_t = attack_t
self.release_t = release_t
# slope only used by compressors, but needs to be set for gain_calc API
self.slope = None
self.slope_f32 = None
# set the gain calculation function handles
self.gain_calc = None
self.gain_calc_xcore = None
# initialise gain states
self.reset_state()
@property
def attack_t(self):
"""The attack time in seconds. Changing this also sets the EWM alpha values."""
return self._attack_t
@attack_t.setter
def attack_t(self, value):
self._attack_t = value
# calculate EWM alpha from time constant
self.attack_alpha, self.attack_alpha_int = drcu.alpha_from_time(self._attack_t, self.fs)
# update the envelope detector
self.env_detector.attack_t = self.attack_t
@property
def release_t(self):
"""The release time in seconds. Changing this also sets the EWM alpha values."""
return self._release_t
@release_t.setter
def release_t(self, value):
self._release_t = value
# calculate EWM alpha from time constant
self.release_alpha, self.release_alpha_int = drcu.alpha_from_time(self._release_t, self.fs)
# update the envelope detector
self.env_detector.release_t = self.release_t
def reset_state(self):
"""Reset the envelope detectors to 0 and the gain to 1."""
if self.env_detector:
self.env_detector.reset_state()
self.gain = 1
self.gain_int = 2**31 - 1
def process_channels(self, sample_list: list[float]): # pyright: ignore overload
"""
Update the envelopes for a signal, then calculate and apply the
required gain for compression/limiting, using floating point
maths. The same gain is applied to both stereo channels.
Take one new pair of samples and return the compressed/limited
samples.
Input should be scaled with 0 dB = 1.0.
"""
# get envelope from envelope detector
env0 = self.env_detector.process(sample_list[0], 0) # type: ignore : base inits to None
env1 = self.env_detector.process(sample_list[1], 1) # type: ignore : base inits to None
envelope = np.maximum(env0, env1)
# avoid /0
envelope = np.maximum(envelope, np.finfo(float).tiny)
# calculate the gain, this function should be defined by the
# child class
new_gain = self.gain_calc(envelope, self.threshold, self.slope) # type: ignore : base inits to None
# see if we're attacking or decaying
if new_gain < self.gain:
alpha = self.attack_alpha
else:
alpha = self.release_alpha
# do exponential moving average
self.gain = ((1 - alpha) * self.gain) + (alpha * new_gain)
# apply gain to input
y = self.gain * sample_list
return y, new_gain, envelope
def process_channels_xcore(self, sample_list: list[float]): # pyright: ignore overload
"""
Update the envelopes for a signal, then calculate and apply the
required gain for compression/limiting, using int32 fixed point
maths. The same gain is applied to both stereo channels.
Take one new pair of samples and return the compressed/limited
samples. The float input sample is quantized to int32, and
returned to float before outputting. Input should be scaled with
0 dB = 1.0.
"""
samples_int = [int(0)] * len(sample_list)
for i in range(len(sample_list)):
samples_int[i] = utils.float_to_fixed(sample_list[i], self.Q_sig)
# get envelope from envelope detector
env0_int = self.env_detector.process_xcore(samples_int[0], 0) # type: ignore : base inits to None
env1_int = self.env_detector.process_xcore(samples_int[1], 1) # type: ignore : base inits to None
envelope_int = max(env0_int, env1_int)
# avoid /0
envelope_int = max(envelope_int, 1)
# if envelope below threshold, apply unity gain, otherwise scale
# down
new_gain_int = self.gain_calc_xcore(envelope_int, self.threshold_int, self.slope_f32) # type: ignore : base inits to None
# see if we're attacking or decaying
if new_gain_int < self.gain_int:
alpha = self.attack_alpha_int
else:
alpha = self.release_alpha_int
# do exponential moving average
self.gain_int = drcu.calc_ema_xcore(self.gain_int, new_gain_int, alpha)
y = []
for sample_int in samples_int:
y_uq = drcu.apply_gain_xcore(sample_int, self.gain_int)
y.append(utils.fixed_to_float(y_uq, self.Q_sig))
return (
y,
utils.fixed_to_float(new_gain_int, self.Q_alpha),
utils.fixed_to_float(envelope_int, self.Q_sig),
)
def process_frame(self, frame: list[np.ndarray]):
"""
Take a list frames of samples and return the processed frames.
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.
When calling self.process_channels only take the first output.
"""
n_outputs = len(frame)
assert n_outputs == 2, "has to be stereo"
frame_size = frame[0].shape[0]
output = deepcopy(frame)
for sample in range(frame_size):
out_samples = self.process_channels([frame[0][sample], frame[1][sample]])[0]
output[0][sample] = out_samples[0]
output[1][sample] = out_samples[1]
return output
def process_frame_xcore(self, frame: list[np.ndarray]):
"""
Take a list frames of samples and return the processed frames,
using a bit exact xcore 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.
When calling self.process_channel_xcore only take the first output.
"""
n_outputs = len(frame)
assert n_outputs == 2, "has to be stereo"
frame_size = frame[0].shape[0]
output = deepcopy(frame)
for sample in range(frame_size):
out_samples = self.process_channels_xcore([frame[0][sample], frame[1][sample]])[0]
output[0][sample] = out_samples[0]
output[1][sample] = out_samples[1]
return output
class peak_compressor_limiter_stereo_base(compressor_limiter_stereo_base):
"""
A compressor/limiter with a peak envelope detector.
Parameters
----------
threshold_db : float
Threshold in decibels above which limiting occurs.
Attributes
----------
threshold_db : float
env_detector : envelope_detector_peak
Peak envelope detector object used to calculate the envelope of
the signal.
"""
def __init__(self, fs, n_chans, threshold_db, attack_t, release_t, Q_sig=dspg.Q_SIG):
self.env_detector = envelope_detector_peak(
fs,
n_chans=n_chans,
attack_t=attack_t,
release_t=release_t,
Q_sig=Q_sig,
)
super().__init__(fs, n_chans, attack_t, release_t, Q_sig)
# threshold_db should be a property of the child class that sets
# threshold_int and threshold
self.threshold_db = threshold_db
@property
def threshold_db(self):
"""The threshold in decibels; changing this property also
updates the fixed and floating point thresholds in linear
gain.
"""
return self._threshold_db
@threshold_db.setter
def threshold_db(self, value):
self._threshold_db = value
self.threshold, self.threshold_int = drcu.calculate_threshold(
self._threshold_db, self.Q_sig
)
class rms_compressor_limiter_stereo_base(compressor_limiter_stereo_base):
"""
A compressor/limiter with an RMS envelope detector.
Note the threshold is saved in the power domain, as the RMS envelope
detector returns x².
Parameters
----------
threshold_db : float
Threshold in decibels above which limiting occurs.
Attributes
----------
env_detector : envelope_detector_rms
RMS envelope detector object used to calculate the envelope of
the signal.
threshold : float
Value above which compression/limiting occurs for floating point
processing. Note the threshold is saved in the power domain, as
the RMS envelope detector returns x².
"""
def __init__(self, fs, n_chans, threshold_db, attack_t, release_t, Q_sig=dspg.Q_SIG):
self.env_detector = envelope_detector_rms(
fs,
n_chans=n_chans,
attack_t=attack_t,
release_t=release_t,
Q_sig=Q_sig,
)
super().__init__(fs, n_chans, attack_t, release_t, Q_sig)
# threshold_db should be a property of the child class that sets
# threshold_int and threshold
self.threshold_db = threshold_db
@property
def threshold_db(self):
"""The threshold in decibels; changing this property also
updates the fixed and floating point thresholds in linear
gain.
"""
return self._threshold_db
@threshold_db.setter
def threshold_db(self, value):
self._threshold_db = value
self.threshold, self.threshold_int = drcu.calculate_threshold(
self._threshold_db, self.Q_sig, power=True
)
class limiter_peak_stereo(peak_compressor_limiter_stereo_base):
"""
A stereo limiter based on the peak value of the signal. When the
peak envelope of either signal channel, exceeds the threshold, the
amplitudes of both channels are reduced.
The threshold set the value above which limiting occurs. The attack
time sets how fast the limiter starts limiting. The release time
sets how long the signal takes to ramp up to it's original level
after the envelope is below the threshold.
Parameters
----------
threshold_dB : float
Threshold in decibels above which limiting occurs.
"""
def __init__(self, fs, threshold_dB, attack_t, release_t, Q_sig=dspg.Q_SIG):
n_chans = 2
super().__init__(fs, n_chans, threshold_dB, attack_t, release_t, Q_sig)
# set the gain calculation function handles
self.gain_calc = drcu.limiter_peak_gain_calc
self.gain_calc_xcore = drcu.limiter_peak_gain_calc_xcore
class compressor_rms_stereo(rms_compressor_limiter_stereo_base):
"""
A stereo compressor based on the RMS value of the signal. When the
RMS envelope of the either signal channel exceeds the threshold, the
amplitudes of both channels are reduced by the compression ratio.
The threshold sets the value above which compression occurs. The
ratio sets how much the signal is compressed. A ratio of 1 results
in no compression, while a ratio of infinity results in the same
behaviour as a limiter. The attack time sets how fast the compressor
starts compressing. The release time sets how long the signal takes
to ramp up to it's original level after the envelope is below the
threshold.
Parameters
----------
threshold_dB : float
Threshold in decibels above which limiting occurs.
ratio : float
Compression gain ratio applied when the signal is above the
threshold
Attributes
----------
ratio : float
slope : float
The slope factor of the compressor, defined as
`slope = (1 - 1/ratio) / 2`.
slope_f32 : float32
The slope factor of the compressor, used for int32 to float32
processing.
"""
def __init__(self, fs, ratio, threshold_dB, attack_t, release_t, Q_sig=dspg.Q_SIG):
n_chans = 2
super().__init__(fs, n_chans, threshold_dB, attack_t, release_t, Q_sig)
# property calculates the slopes as well
self.ratio = ratio
# set the gain calculation function handles
self.gain_calc = drcu.compressor_rms_gain_calc
self.gain_calc_xcore = drcu.compressor_rms_gain_calc_xcore
@property
def ratio(self):
"""Compression gain ratio applied when the signal is above the threshold."""
return self._ratio
@ratio.setter
def ratio(self, value):
self._ratio = value
self.slope, self.slope_f32 = drcu.rms_compressor_slope_from_ratio(self.ratio)