Todays microcontrollers are already capable of processing real-time audio. But if you want to bring down the latency as low as possible, you can use an FPGA – its even faster than modern DSPs that typical use several samples as buffer. The following logic contains a stereo dynamic compressor with controllable threshold, ratio and makeup-gain.

This logic will set the output to a specific maximum gain-value depending on a specific maximum threshold. The gain-change will be filtered by a first-order low-pass filter for which two coefficients (for attack and release) have to be set. The calculation of the coefficients and the other parameters can be found down below. For more information have a look at my X/FBAPE-project on GitHub: https://www.github.com/xn--nding-jua/xfbape

VHDL
-- Stereo Compressor/Limiter Filter
-- Christian Noeding, christian@noeding-online.de
-- https://chrisdevblog.com | https://github.com/xn--nding-jua
--
-- Released under GNU General Public License v3

library ieee;
use ieee.std_logic_1164.all;
use ieee.std_logic_unsigned.all; 
use ieee.numeric_std.all; -- lib for unsigned and signed

entity compressor_stereo is
	generic (
		bit_width		:	natural range 16 to 48 := 24;
		threshold_bit_width	:	natural range 16 to 48 := 24
	);
	port (
		clk				:	in std_logic := '0';

		sample_l_in		:	in signed(bit_width - 1 downto 0) := (others=>'0');
		sample_r_in		:	in signed(bit_width - 1 downto 0) := (others=>'0');
		sync_in			:	in std_logic := '0';
		threshold		:	in unsigned(threshold_bit_width - 1 downto 0) := (others=>'0'); -- threshold with bitdepth of sample, that will be used to check the audio-level (between 0 and 8388608)
		ratio				:	in unsigned(7 downto 0) := (others=>'0'); -- ratio of gain-reduction (0=1:1, 1=2:1, 2=4:1, 3=8:1, 4=16:1, 5=32:1, 6=64:1, ...)
		makeup			:	in unsigned(7 downto 0) := (others=>'0'); -- makeup-gain. (0=x1, 1=x2, 2=x4, 3=x8, 4=x16, 5=x32, 6=x64, 7=x128, 8=x256, ...)
		coeff_attack	:	in signed(15 downto 0); -- Q15-format, 1 bit sign, 0 bit for integer-part, 15 bit for fraction-part
		hold_ticks		:	in unsigned(15 downto 0) := (others=>'0'); -- 16 bit value for hold_counter = 0...65535/48000 [0...1365ms]
		coeff_release	:	in signed(15 downto 0); -- Q15-format, 1 bit sign, 0 bit for integer-part, 15 bit for fraction-part
		
		sample_l_out	:	out signed(bit_width - 1 downto 0) := (others=>'0');
		sample_r_out	:	out signed(bit_width - 1 downto 0) := (others=>'0');
		sync_out			:	out std_logic := '0';
		comp_out			:	out std_logic := '0'
	);
end compressor_stereo;

architecture rtl of compressor_stereo is
	-- signals for the state-machines
	signal s_SM_Main	:	natural range 0 to 10 := 0;
	type t_SM_Effect is (s_Idle, s_Attack, s_Active, s_Hold, s_Release);
	signal s_SM_Effect 	: t_SM_Effect := s_Release; -- start in release mode

	-- signals for processing
	signal sample_l	:	signed(bit_width - 1 downto 0) := (others=>'0');
	signal sample_r	:	signed(bit_width - 1 downto 0) := (others=>'0');
	signal sample_tmp	:	signed(bit_width - 1 downto 0) := (others=>'0');
	signal coeff		:	signed(15 downto 0); -- Q15-format, 1 bit for sign, 0 bit for integer-part, 15 bit for fraction-part
	
	signal gain_set	:	unsigned(bit_width - 1 downto 0) := (others=>'0'); -- target-value that is calculated
	signal gain			:	unsigned(22 downto 0) := (others=>'0'); -- unsigned Q15-format, that will be used for current gain and holds value as gain_z1 for next step
	
	signal hold_counter	:	unsigned(15 downto 0) := (others=>'0'); -- unsigned 16 bit counter for hold_counter = 0...1365ms

	--signals for multiplier
	signal mult_in_a	:	signed(bit_width - 1 downto 0) := (others=>'0');
	signal mult_in_b	:	signed(15 downto 0) := (others=>'0');
	signal mult_out	:	signed((bit_width + 16) - 1 downto 0) := (others=>'0');

	-- signals for divider
	signal div_start		:	std_logic;
	signal div_dividend	:	unsigned(bit_width - 1 downto 0) := (others=>'0'); -- temporary value for gain_set
	signal div_busy		:	std_logic;
	signal div_quotient	:	unsigned(bit_width - 1 downto 0) := (others=>'0');
	-- copy inputs/outputs from Divider-entity
	component divider is
		generic (
			bit_width	: natural range 4 to 48 := 24
		);
		port (
			clk			: in   std_logic;
			start			: in   std_logic;
			dividend		: in   unsigned(bit_width - 1 downto 0);
			divisor		: in   unsigned(bit_width - 1 downto 0);
			quotient		: out  unsigned(bit_width - 1 downto 0);
			remainder	: out  unsigned(bit_width - 1 downto 0);
			busy			: out  std_logic
		);
	end component;
begin
	-- multiplier
	process(mult_in_a, mult_in_b)
	begin
		mult_out <= mult_in_a * mult_in_b;
	end process;
	
	-- divider
	div : divider
		generic map(
			bit_width => bit_width
		)
		port map(
			clk => clk,
			start => div_start,
			dividend => div_dividend,
			divisor => unsigned(sample_tmp), -- sample_tmp contains only the abs()-value of sample. So its OK to cast to unsigned without additional abs()
			quotient => div_quotient,
			remainder => open,
			busy => div_busy
		);

	process(clk)
		variable effect_triggered : std_logic := '0';
	begin
		if rising_edge(clk) then
			if (sync_in = '1' and s_SM_Main = 0) then
				-- we are receiving a new sample
				
				-- copy sample to internal signal
				sample_l <= sample_l_in;
				sample_r <= sample_r_in;

				-- create a processing-sample containing the loudest signal
				if (unsigned(abs(sample_l_in)) > unsigned(abs(sample_r_in))) then
					-- left is louder than right
					sample_tmp <= abs(sample_l_in);

					-- check if we have to reduce gain
					if (unsigned(abs(sample_l_in)) > threshold) then
						effect_triggered := '1';
					else
						effect_triggered := '0';
					end if;
				else
					-- right is louder than left
					sample_tmp <= abs(sample_r_in);
					
					-- check if we have to reduce gain
					if (unsigned(abs(sample_r_in)) > threshold) then
						effect_triggered := '1';
					else
						effect_triggered := '0';
					end if;
				end if;

				s_SM_Main <= s_SM_Main + 1; -- go into next state
			elsif (s_SM_Main = 1) then
				-- precalculate a temporary value for gain_set
				-- overshoot = sample_tmp - threshold
				-- output = overshoot / ratio + threshold
				-- gain = (output / sample_tmp) * 256
				-- we will calculate the divisor including factor of 256:
				div_dividend <= shift_left(resize((shift_right(unsigned(sample_tmp) - threshold, to_integer(ratio)) + threshold), div_dividend'length), 8) - 5; -- division has limited precision, so we will keep a larger distance away from 256 to mitigate overflow
				-- sample_tmp is already mapped to divider as divisor
				
				-- start divider
				div_start <= '1';
				
				s_SM_Main <= s_SM_Main + 1; -- go into next state
			elsif (s_SM_Main = 2) then
				-- one wait-state

				-- set divider-start to 0 for next cycle
				div_start <= '0';

				s_SM_Main <= s_SM_Main + 1; -- go into next state
			elsif (s_SM_Main = 3 and div_busy = '0') then
				-- check state of Effect-Statemachine
				if (s_SM_Effect = s_Idle) then
					-- Effect is idle (off)
					
					-- check level for entering attack-state
					if (effect_triggered = '1') then
						s_SM_Effect <= s_Attack;
					end if;
				elsif (s_SM_Effect = s_Attack) then
					-- Effect entered attack-state
					
					-- we are limiting with a specific ratio: 0=1:1, 1=2:1, 2=4:1, 3=8:1, 4=16:1, 5=32:1, 6=64:1, ..., 24=oo:1=Limiter
					-- general equation is: output = (input - threshold)/ratio + threshold
					-- equation for desired gain is: gain = output / input

					--gain_set <= to_unsigned(32, gain_set'length); -- for debugging: set volume during compression to 12.5% (=32/256)
					gain_set <= div_quotient; -- take the desired gain from division
					coeff <= coeff_attack;
					
					-- go directly into on-state
					-- we could add a attack-state-counter here, but the wait-time
					-- will depend on coefficients, so keep it simple
					s_SM_Effect <= s_Active;
				elsif (s_SM_Effect = s_Active) then
					-- Effect is in on-state

					if (effect_triggered = '1') then
						-- value is still above threshold -> update gain if below last gain
						if (div_quotient < gain_set) then
							-- we have to reduce gain even more
							gain_set <= div_quotient; -- take the desired gain from division
						end if;
					else
						-- we are below the threshold
						
						-- load counter and enter release-hold-state
						hold_counter <= hold_ticks;
						s_SM_Effect <= s_Hold;
					end if;
				elsif (s_SM_Effect = s_Hold) then
					-- Effect is in hold-state before release

					if (effect_triggered = '1') then
						-- value is above threshold again -> re-enter on-State
						s_SM_Effect <= s_Active;
					else
						-- we are below the threshold
						
						-- check hold_counter
						if (hold_counter = 0) then
							-- we reached end of hold -> enter release-state
							s_SM_Effect <= s_Release;
						else
							-- we still have to stay in hold
							-- decrement hold-counter
							hold_counter <= hold_counter - 1;
						end if;
					end if;
				elsif (s_SM_Effect = s_Release) then
					-- Effect entered release-state
					
					-- set desired gain-level to 100%
					-- set release-coefficient for LP-filter
					-- gain itself will be set via low-pass-filter down below
					gain_set <= to_unsigned(255, gain_set'length);
					coeff <= coeff_release;
					
					-- go directly into idle-state (TODO: we could add a release-state-counter here, but the wait-time will depend on coefficients)
					s_SM_Effect <= s_Idle;
				end if;
				
				s_SM_Main <= s_SM_Main + 1; -- go into next state
			elsif (s_SM_Main = 4) then
				-- now calculate low-pass-filter: gain = coeff * gain_z1 - coeff * gain + gain

				-- first, calculate the front part of filter-equation: part1 = coeff * gain_z1

				-- load multiplier
				mult_in_a <= resize(signed("0" & std_logic_vector(gain)), mult_in_a'length); -- gain-value (from last sample = z^-1) is in unsigned Q8.15-format -> convert to signed Q8.15
				mult_in_b <= coeff; -- coefficient in Q0.15-format

				s_SM_Main <= s_SM_Main + 1; -- go into next state
			elsif (s_SM_Main = 5) then
				-- store first part and calculate next part of filter: coeff * gain
			
				-- convert resulting Q8.30 back to unsigned Q8.15 by removing/ignoring least-significant 15 bits
				gain <= unsigned(std_logic_vector(mult_out)(22+15 downto 0+15)); -- we ignore the signed-bit here, as gain is unsigned
				
				-- load multiplier
				mult_in_a <= resize(signed("0" & std_logic_vector(gain_set)), mult_in_a'length); -- desired gain is in unsigned Q8.0-format -> convert to signed Q8.0
				mult_in_b <= coeff; -- coefficient in Q0.15-format
				
				s_SM_Main <= s_SM_Main + 1; -- go into next state
			elsif (s_SM_Main = 6) then
				-- calculate full value: gain = coeff * gain_z1 - coeff * gain + gain 
				
				-- convert gain_set from unsigned Q8.0 to unsigned Q8.15
				-- convert multiplication-result (alpha*gain_set) from signed Q8.15 to unsigned Q8.15
				-- calc: (alpha*gain) + (gain_set - alpha*gain_set)
				
				gain <= gain + (resize(unsigned(std_logic_vector(gain_set) & "000000000000000"), gain'length) - unsigned(std_logic_vector(mult_out)(22 downto 0)));
				
				s_SM_Main <= s_SM_Main + 1; -- go into next state
			elsif (s_SM_Main = 7) then
				-- calculate the output signal
				
				-- load multiplier
				mult_in_a <= sample_l; -- audio-sample for left-channel
				mult_in_b <= signed("0" & std_logic_vector(gain)(22 downto 8)); -- convert current gain-value from Q8.15 into Q8.7-format
				
				s_SM_Main <= s_SM_Main + 1;
			elsif (s_SM_Main = 8) then
				-- convert resulting signed Q8.7 back to Q8.0, divide by 256 and store sample into signal
				-- applying makeup-gain by bitshift to the left again
				sample_tmp <= resize(shift_right(mult_out, 8 + 7 - to_integer(makeup)) , bit_width);

				-- load multiplier
				mult_in_a <= sample_r; -- audio-sample for right channel
				mult_in_b <= signed("0" & std_logic_vector(gain)(22 downto 8)); -- convert current gain-value from Q8.15 into Q8.7-format
				
				s_SM_Main <= s_SM_Main + 1;
			elsif (s_SM_Main = 9) then
				-- output samples and set sync_out
				sample_l_out <= sample_tmp;
				sample_r_out <= resize(shift_right(mult_out, 8 + 7 - to_integer(makeup)) , bit_width); -- convert resulting signed Q8.7 back to Q8.0, divide by 256 and output signal

				-- indicate, if we are compressing
				if (shift_right(gain, 15) < 250) then
					comp_out <= '1';
				else
					comp_out <= '0';
				end if;

				sync_out <= '1';
				
				s_SM_Main <= s_SM_Main + 1;
			elsif (s_SM_Main = 10) then
				-- cleanup and return to state 0
				sync_out <= '0';
				
				s_SM_Main <= 0;
			end if;
		end if;
	end process;
end rtl;

The coefficients for the compressor can be calculated like this:

C
typedef union 
{
    uint32_t u32;
    int32_t s32;
    uint16_t u16[2];
    int16_t s16[2];
    uint8_t u8[4];
    int8_t s8[4];
    float   f;
}data_32b;

typedef union 
{
    uint16_t u16;
    int16_t s16;
    uint8_t u8[2];
    int8_t s8[2];
}data_16b;

struct sCompressor {
  // user-settings
  float threshold = 0; // value between 0 dBfs (no compression) and -80 dBfs (full compression) -> 2^23..2^13.33
  uint8_t ratio = 1; // value between 0=oo:1, 1=1:1, 2=2:1, 4=4:1, 8=8:1, 16=16:1, 32=32:1, 64=64:1
  uint8_t makeup = 0; // value between 0dB, 6dB, 12dB, 18dB, 24dB, 30dB, 36dB, 42dB, 48dB
  float attackTime_ms = 10;
  float holdTime_ms = 10;
  float releaseTime_ms = 151;

  // filter-data
  const float audio_bitwidth = 24; // depends on implementation of FPGA
  data_32b value_threshold;
  data_16b value_ratio;
  data_16b value_makeup;
  data_16b value_coeff_attack;
  data_16b value_hold_ticks;
  data_16b value_coeff_release;
};

void recalcCompressor(struct sCompressor *Compressor) {
  Compressor->value_threshold.u32 = pow(2, (Compressor->audio_bitwidth-1) + (Compressor->threshold/6.0f)) - 1; // only bitwidth-1 can be used for samples, as MSB is for sign

  // convert ratio (0=oo:1, 1=1:1, 2=2:1, 4=4:1, 8=8:1, 16=16:1, 32=32:1, 64=64:1) to bitshift 1:1=0bit, 2:1=1bit, 4:1=2bit, 8:1=3bit, 16:1=4bit, 32:1=5bit, 64:1=6bit, oo:1=24bit
  if (Compressor->ratio > 0) {
    Compressor->value_ratio.u16 = log(Compressor->ratio) / log(2); // convert real ratio-values into bit-shift-values
  }else{
    // ratio == 0 -> limiter = oo:1
    Compressor->value_ratio.u16 = Compressor->audio_bitwidth; // move 24 bits to the right ^= 1/oo ^= 0
  }

  Compressor->value_makeup.u16 = round(Compressor->makeup/6.0f); // we are allowing only 6dB-steps, so we have to round to optimize the user-input

  Compressor->value_coeff_attack.s16 = round(exp(-2197.22457734f/(audiomixer.sampleRate * Compressor->attackTime_ms)) * 32767); // convert to Q15

  Compressor->value_hold_ticks.u16 = Compressor->holdTime_ms * (audiomixer.sampleRate / 1000.0f);

  Compressor->value_coeff_release.s16 = round(exp(-2197.22457734f/(audiomixer.sampleRate * Compressor->releaseTime_ms)) * 32767); // convert to Q15
}

Leave a comment

Your email address will not be published. Required fields are marked *