# Copyright 2024-2025 XMOS LIMITED. # This Software is subject to the terms of the XMOS Public Licence: Version 1. import numpy as np import pytest from functools import partial import warnings import audio_dsp.dsp.utils as utils import audio_dsp.dsp.signal_gen as gen import audio_dsp.dsp.generic as dspg import audio_dsp.dsp.reverb as rv import audio_dsp.dsp.reverb_stereo as rvs import audio_dsp.dsp.reverb_plate as rvp @pytest.mark.parametrize("signal, freq", [["sine", 20], ["sine", 1000], ["sine", 10000], ["sine", 23000], ["noise", None]]) @pytest.mark.parametrize("algo, param", [["mono_room", 0.1], ["mono_room", 1], ["mono_room", 4], ["stereo_room", 0.1], ["stereo_room", 1], ["stereo_room", 4], ["stereo_plate", 0.95],] ) def test_reverb_overflow(signal, freq, algo, param): # check no overflow errors occur fs = 48000 q_format = 31 if signal == "sine": sig = gen.sin(fs, 5, freq, 1) elif signal == "chirp": sig = gen.log_chirp(fs, 5, 1, 20, 20000) elif signal == "noise": sig = gen.white_noise(fs, 5, 1) sig = sig/np.max(np.abs(sig)) if algo == "stereo_room": sig = np.tile(sig, [2, 1]) reverb = rvs.reverb_room_stereo(fs, 2, max_room_size=param, room_size=1, decay=1.0, damping=0.0, Q_sig=q_format) elif algo == "mono_room": reverb = rv.reverb_room(fs, 1, max_room_size=param, room_size=1, decay=1.0, damping=0.0, Q_sig=q_format) elif algo == "stereo_plate": sig = np.tile(sig, [2, 1]) reverb = rvp.reverb_plate_stereo(fs, 2, decay=param, damping=0.0, Q_sig=q_format) #print(reverb.get_buffer_lens()) output_xcore = np.zeros_like(sig) output_flt = np.zeros_like(sig) if "stereo" in algo: for n in range(sig.shape[1]): output_xcore[:, n] = reverb.process_channels_xcore(sig[:, n]) reverb.reset_state() for n in range(sig.shape[1]): output_flt[:, n] = reverb.process_channels(sig[:, n]) else: for n in range(len(sig)): output_xcore[n] = reverb.process_xcore(sig[n]) reverb.reset_state() for n in range(len(sig)): output_flt[n] = reverb.process(sig[n]) def calc_reverb_time(in_sig, reverb_output): # extend by 2x sig = np.concatenate((in_sig, np.zeros_like(in_sig))) output_xcore = np.concatenate((reverb_output, np.zeros_like(reverb_output))) sig_spect = np.fft.rfft(sig) output_xcore_spect = np.fft.rfft(output_xcore) # Y = HX, Y/X = H H_xcore_spect = output_xcore_spect/sig_spect h_xcore = np.fft.irfft(H_xcore_spect) h_xcore = h_xcore[:len(h_xcore)//2] return h_xcore @pytest.mark.parametrize("max_room_size_diffusion", [0.5, 0.9]) @pytest.mark.parametrize("decay, damping", [[0.5, 0.35], [1.0, 0.0]]) @pytest.mark.parametrize("q_format", [27, 31]) @pytest.mark.parametrize("algo, width", [["mono_room", None], ["stereo_room", 1.0], ["stereo_plate", 1.0],] ) @pytest.mark.parametrize("wdmix", [0.5, 1.0]) def test_reverb_time(max_room_size_diffusion, decay, damping, q_format, width, algo, wdmix): # measure reverb time with chirp fs = 48000 if "plate" in algo: pregain = 0.5**(q_format - 26) else: pregain = 0.015 * 2**(27 - q_format) sig = np.zeros(int(fs*max_room_size_diffusion*6) + fs) sig[:1*fs] = gen.log_chirp(fs, 1, 1, 20, 20000) sig = sig* (2**q_format - 1)/(2**q_format) if algo =="stereo_room": sig = np.tile(sig, [2, 1]) reverb = rvs.reverb_room_stereo(fs, 2, max_room_size=max_room_size_diffusion, room_size=1, decay=decay, damping=damping, Q_sig=q_format, pregain=pregain, width=width) elif algo =="mono_room": reverb = rv.reverb_room(fs, 1, max_room_size=max_room_size_diffusion, room_size=1, decay=decay, damping=damping, Q_sig=q_format, pregain=pregain) elif algo =="stereo_plate": sig = np.tile(sig, [2, 1]) reverb = rvp.reverb_plate_stereo(fs, 2, early_diffusion=max_room_size_diffusion, late_diffusion=max_room_size_diffusion, decay=decay, damping=damping, Q_sig=q_format, pregain=pregain, width=width) reverb.set_wet_dry_mix(wdmix) output_xcore = np.zeros_like(sig) output_flt = np.zeros_like(sig) if "stereo" in algo: for n in range(sig.shape[1]): output_flt[:, n] = reverb.process_channels(sig[:, n]) reverb.reset_state() with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always', utils.SaturationWarning) for n in range(sig.shape[1]): output_xcore[:, n] = reverb.process_channels_xcore(sig[:, n]) else: for n in range(len(sig)): output_flt[n] = reverb.process(sig[n]) reverb.reset_state() with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always', utils.SaturationWarning) for n in range(len(sig)): output_xcore[n] = reverb.process_xcore(sig[n]) # if we triggered a saturation warning, can't guarantee arrays are the same sat_warn_flag = any([wi.category is utils.SaturationWarning for wi in w]) # # in this case, pregain should be adjusted # if sat_warn_flag: assert False # small signals are always going to be ropey due to quantizing, so just check average error of top half top_half = np.logical_and(utils.db(output_flt) > -50, utils.db(output_flt) < (6*(31-q_format))) if np.any(top_half): error_flt = np.abs(utils.db(output_xcore[top_half])-utils.db(output_flt[top_half])) mean_error_flt = utils.db(np.nanmean(utils.db2gain(error_flt))) assert mean_error_flt < 0.055 @pytest.mark.parametrize("max_room_size", [0.5]) @pytest.mark.parametrize("decay", [0.5]) @pytest.mark.parametrize("damping", [0.5]) @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_noise_floor(max_room_size, decay, damping, algo): # check the reverb decays to 0 (no limit cycle noise) fs = 48000 q_format = 27 sig = np.zeros(int(fs*max_room_size*40) + fs) sig[:1*fs] = gen.log_chirp(fs, 1, 1, 20, 20000) sig = sig* (2**q_format - 1)/(2**q_format) if algo =="stereo_room": sig = np.tile(sig, [2, 1]) reverb = rvs.reverb_room_stereo(fs, 2, max_room_size=max_room_size, room_size=1, decay=decay, damping=damping, Q_sig=q_format) elif algo =="mono_room": reverb = rv.reverb_room(fs, 1, max_room_size=max_room_size, room_size=1, decay=decay, damping=damping, Q_sig=q_format) elif algo == "stereo_plate": sig = np.tile(sig, [2, 1]) reverb = rvp.reverb_plate_stereo(fs, 2, decay=decay, damping=damping, Q_sig=q_format) #print(reverb.get_buffer_lens()) output_xcore = np.zeros_like(sig) output_flt = np.zeros_like(sig) if "stereo" in algo: for n in range(sig.shape[1]): output_flt[:, n] = reverb.process_channels(sig[:, n]) reverb.reset_state() with warnings.catch_warnings(record=True) as w: for n in range(sig.shape[1]): output_xcore[:, n] = reverb.process_channels_xcore(sig[:, n]) else: for n in range(len(sig)): output_flt[n] = reverb.process(sig[n]) reverb.reset_state() with warnings.catch_warnings(record=True) as w: for n in range(len(sig)): output_xcore[n] = reverb.process_xcore(sig[n]) # check noise floor if "stereo" in algo: assert np.max(np.abs(output_xcore[:, -1000:])) < 2**-(reverb.Q_sig + 1) else: assert np.max(np.abs(output_xcore[-1000:])) < 2**-(reverb.Q_sig + 1) # small signals are always going to be ropey due to quantizing, so just check average error of top half top_half = np.logical_and(utils.db(output_flt) > -50, utils.db(output_flt) < (6*(31-q_format))) if np.any(top_half): error_flt = np.abs(utils.db(output_xcore[top_half])-utils.db(output_flt[top_half])) mean_error_flt = utils.db(np.nanmean(utils.db2gain(error_flt))) assert mean_error_flt < 0.055 @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_bypass(algo): # test that a drc component is bit exact when the signal is below # the threshold (or above in the case of a noise gate). fs = 48000 signal = gen.log_chirp(fs, 0.5, 1) if algo == "stereo_room": signal = np.tile(signal, [2, 1]) reverb = rvs.reverb_room_stereo(fs, 2, dry_gain_db=0, wet_gain_db=-np.inf) elif algo == "mono_room": reverb = rv.reverb_room(fs, 1, dry_gain_db=0, wet_gain_db=-np.inf) elif algo == "stereo_plate": signal = np.tile(signal, [2, 1]) reverb = rvp.reverb_plate_stereo(fs, 2, dry_gain_db=0, wet_gain_db=-np.inf) output_xcore = np.zeros_like(signal) output_flt = np.zeros_like(signal) if "stereo" in algo: for n in range(signal.shape[1]): output_xcore[:, n] = reverb.process_channels_xcore(signal[:, n]) reverb.reset_state() for n in range(signal.shape[1]): output_flt[:, n] = reverb.process_channels(signal[:, n]) else: for n in range(len(signal)): output_xcore[n] = reverb.process_xcore(signal[n]) reverb.reset_state() for n in range(len(signal)): output_flt[n] = reverb.process(signal[n]) np.testing.assert_array_equal(signal, output_flt) # quantization noise from multiply by dry gain np.testing.assert_allclose(signal, output_xcore, atol=2**-(reverb.Q_sig-1)) @pytest.mark.parametrize("algo", ["stereo_room", "stereo_plate"]) @pytest.mark.parametrize("width", [0, 1]) def test_reverb_width(algo, width): # test that a drc component is bit exact when the signal is below # the threshold (or above in the case of a noise gate). fs = 48000 signal = gen.log_chirp(fs, 0.5, 1) if algo == "stereo_room": signal = np.tile(signal, [2, 1]) reverb = rvs.reverb_room_stereo(fs, 2) elif algo == "mono_room": reverb = rv.reverb_room(fs, 1) elif algo == "stereo_plate": signal = np.tile(signal, [2, 1]) reverb = rvp.reverb_plate_stereo(fs, 2) reverb.width = width output_xcore = np.zeros_like(signal) output_flt = np.zeros_like(signal) if "stereo" in algo: for n in range(signal.shape[1]): output_xcore[:, n] = reverb.process_channels_xcore(signal[:, n]) reverb.reset_state() for n in range(signal.shape[1]): output_flt[:, n] = reverb.process_channels(signal[:, n]) else: for n in range(len(signal)): output_xcore[n] = reverb.process_xcore(signal[n]) reverb.reset_state() for n in range(len(signal)): output_flt[n] = reverb.process(signal[n]) # small signals are always going to be ropey due to quantizing, so just check average error of top half q_format = 27 top_half = np.logical_and(utils.db(output_flt) > -50, utils.db(output_flt) < (6*(31-q_format))) if np.any(top_half): error_flt = np.abs(utils.db(output_xcore[top_half])-utils.db(output_flt[top_half])) mean_error_flt = utils.db(np.nanmean(utils.db2gain(error_flt))) assert mean_error_flt < 0.055 if width == 0: assert np.all(output_xcore[0, :] == output_xcore) assert np.all(output_flt[0, :] == output_flt) else: assert not np.all(output_xcore[0, :] == output_xcore[1:, :]) assert not np.all(output_flt[0, :] == output_flt[1:, :]) @pytest.mark.parametrize("fs", [48000]) @pytest.mark.parametrize("q_format", [27, 31]) @pytest.mark.parametrize("algo, param", [["mono_room", 0.1], ["mono_room", 1], ["mono_room", 4], ["stereo_room", 0.1], ["stereo_room", 1], ["stereo_room", 4], ["stereo_plate", 0.1], ["stereo_plate", 0.5], ["stereo_plate", 0.9],] ) def test_reverb_frames(fs, q_format, algo, param): # test the process_frame functions of the reverb components # if q_format > 27: # pytest.xfail("This test is not meant to pass with a q more then 27") if algo == "stereo_room": reverb = rvs.reverb_room_stereo(fs, 2, max_room_size=param, width=0, Q_sig=q_format) signal = gen.log_chirp(fs, 0.5, 1) t = np.arange(len(signal))/fs signal *= np.sin(t*2*np.pi*0.5) signal = np.tile(signal, [2, 1]) elif algo == "mono_room": reverb = rv.reverb_room(fs, 1, max_room_size=param, Q_sig=q_format) signal = gen.log_chirp(fs, 0.5, 1) t = np.arange(len(signal))/fs signal *= np.sin(t*2*np.pi*0.5) signal = np.tile(signal, [1, 1]) elif algo == "stereo_plate": reverb = rvp.reverb_plate_stereo(fs, 2, decay=param, width=0, Q_sig=q_format) signal = gen.log_chirp(fs, 0.5, 1) t = np.arange(len(signal))/fs signal *= np.sin(t*2*np.pi*0.5) signal = np.tile(signal, [2, 1]) frame_size = 1 signal_frames = utils.frame_signal(signal, frame_size, 1) output_int = np.zeros_like(signal) output_flt = np.zeros_like(signal) for n in range(len(signal_frames)): output_int[:, n*frame_size:(n+1)*frame_size] = reverb.process_frame_xcore(signal_frames[n]) reverb.reset_state() for n in range(len(signal_frames)): output_flt[:, n*frame_size:(n+1)*frame_size] = reverb.process_frame(signal_frames[n]) assert np.all(output_int[0, :] == output_int) assert np.all(output_flt[0, :] == output_flt) @pytest.mark.parametrize("ratio", [0, 0.5, 1]) @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_wet_dry_mix(ratio, algo): fs = 48000 q_format = 27 max_room_sz = 1 room_sz = 1 damp = 0.22 a = utils.db2gain(-10) sig = gen.pink_noise(fs, 1, a) if algo == "stereo_room": sig = np.tile(sig, [2, 1]) verb = rvs.reverb_room_stereo(fs, 2, max_room_size=max_room_sz, damping=damp, room_size=room_sz, Q_sig=q_format) elif algo =="mono_room": verb = rv.reverb_room(fs, 1, max_room_size=max_room_sz, damping=damp, room_size=room_sz, Q_sig=q_format) elif algo == "stereo_plate": sig = np.tile(sig, [2, 1]) verb = rvp.reverb_plate_stereo(fs, 2, Q_sig=q_format) verb.set_wet_dry_mix(ratio) sig_py = np.zeros_like(sig) sig_xc = np.zeros_like(sig) if "stereo" in algo: for i in range(sig.shape[1]): sig_py[:, i] = verb.process_channels(sig[:, i]) verb.reset_state() for i in range(sig.shape[1]): sig_xc[:, i] = verb.process_channels_xcore(sig[:, i]) else: for i in range(len(sig)): sig_py[i] = verb.process(sig[i]) verb.reset_state() for i in range(len(sig)): sig_xc[i] = verb.process_xcore(sig[i]) # small signals are always going to be ropey due to quantizing, so just check average error of top half top_half = utils.db(sig_py) > -50 if np.any(top_half): error_vpu = np.abs(utils.db(sig_py[top_half])-utils.db(sig_xc[top_half])) mean_error_vpu = utils.db(np.nanmean(utils.db2gain(error_vpu))) assert mean_error_vpu < 0.005 def get_algo_partial(algo): if algo =="stereo_room": r = partial(rvs.reverb_room_stereo, 48000, 2) elif algo =="mono_room": r = partial(rv.reverb_room, 48000, 1) elif algo =="stereo_plate": r = partial(rvp.reverb_plate_stereo, 48000, 2) return r @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_properties_decay(algo): """Basic tests to check for consistency when setting the properties.""" r = get_algo_partial(algo) val = 0.1 a = r(decay=val) b = r() b.decay = val should_be_val = np.array([i.decay for i in (a, b)]) np.testing.assert_allclose(should_be_val, val) @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_properties_pregain(algo): """Basic tests to check for consistency when setting the properties.""" r = get_algo_partial(algo) val = 0.1 a = r(pregain=val) b = r() b.pregain = val should_be_val = np.array([i.pregain for i in (a, b)]) np.testing.assert_allclose(should_be_val, val) @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_properties_wet_db(algo): """Basic tests to check for consistency when setting the properties.""" r = get_algo_partial(algo) val = -6 a = r(wet_gain_db=val) b = r() b.wet_db = val should_be_val = np.array([i.wet_db for i in (a, b)]) np.testing.assert_allclose(should_be_val, val) @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_properties_dry_db(algo): """Basic tests to check for consistency when setting the properties.""" r = get_algo_partial(algo) val = -6 a = r(dry_gain_db=val) b = r() b.dry_db = val should_be_val = np.array([i.dry_db for i in (a, b)]) np.testing.assert_allclose(should_be_val, val) @pytest.mark.parametrize("algo", ["mono_room", "stereo_room", "stereo_plate"]) def test_reverb_properties_damping(algo): """Basic tests to check for consistency when setting the properties.""" r = get_algo_partial(algo) val = 0.5 a = r(damping=val) b = r() b.damping = val should_be_val = np.array([i.damping for i in (a, b)]) np.testing.assert_allclose(should_be_val, val) @pytest.mark.parametrize("stereo", [True, False]) def test_reverb_properties_room_size(stereo): """Basic tests to check for consistency when setting the properties.""" if stereo: r = partial(rvs.reverb_room_stereo, 48000, 2) else: r = partial(rv.reverb_room, 48000, 1) val = 0.5 a = r(room_size=val) b = r() b.room_size = val should_be_val = np.array([i.room_size for i in (a, b)]) np.testing.assert_allclose(should_be_val, val) if __name__ == "__main__": test_reverb_width("stereo_plate", 1) # test_reverb_time(0.5, 0.25, 0.35, 29, 0.5, 1, "stereo_plate") # test_reverb_overflow("sine", 20, "stereo_plate", 0.1) # test_reverb_time(0.01, 1) # test_reverb_frames(48000, 27, "stereo_plate", 0.5) # test_reverb_wet_dry_mix(1.0, "stereo_plate") # test_reverb_bypass_stereo() # test_reverb_noise_floor_stereo(1.0, 1.0, 0) # test_reverb_time(0.01, 1, 0, 31, 0.001, 0.5) # test_reverb_properties_room_size(True)