# Copyright 2024-2025 XMOS LIMITED. # This Software is subject to the terms of the XMOS Public Licence: Version 1. from pathlib import Path import scipy.io.wavfile import numpy as np import subprocess import pytest from copy import deepcopy import re import subprocess from filelock import FileLock import os import shutil from audio_dsp.design.pipeline import Pipeline from audio_dsp.stages.biquad import Biquad from audio_dsp.stages.cascaded_biquads import CascadedBiquads from audio_dsp.stages.signal_chain import Bypass from audio_dsp.stages.limiter import LimiterRMS, LimiterPeak from audio_dsp.design.pipeline import generate_dsp_main import audio_dsp.dsp.signal_gen as gen from audio_dsp.dsp.generic import HEADROOM_BITS import audio_dsp.dsp.utils as utils from python import build_utils, run_pipeline_xcoreai, audio_helpers from stages.add_n import AddN # Test config PKG_DIR = Path(__file__).parent APP_DIR = PKG_DIR BUILD_DIR = APP_DIR / "build" num_in_channels = 2 num_out_channels = 2 input_dtype = np.int32 test_duration = 0.1 # in seconds infile = "test_input.wav" outfile = "test_output.wav" Fs = 48000 def gen_build(app_dir, p, target): with FileLock(build_utils.BUILD_LOCK): # Autogenerate C code generate_dsp_main(p, out_dir = BUILD_DIR / "dsp_pipeline_default") # Build pipeline test executable. This will download xscope_fileio if not present build_utils.build(APP_DIR, BUILD_DIR, target) os.makedirs(app_dir / "bin", exist_ok=True) shutil.copytree(APP_DIR / "bin", app_dir / "bin", dirs_exist_ok=True) def create_pipeline(): # Create pipeline p, i = Pipeline.begin(num_in_channels) bi = p.stage(Biquad, i, label="biquad") cb = p.stage(CascadedBiquads, bi, label="casc_biquad") by = p.stage(Bypass, cb, label="byp_1") lp = p.stage(LimiterPeak, by) lr = p.stage(LimiterRMS, lp) p.next_thread() # ch 0 1 by1 = p.stage(Bypass, lr, label="byp_2") p.set_outputs(by1) stages = 2 return p, stages def test_pipeline(): """ Basic test playing a sine wave through a stage """ p, n_stages = create_pipeline() app_dir = PKG_DIR / "test_pipeline" os.makedirs(app_dir, exist_ok=True) target = "default" gen_build(app_dir, p, target) outfile_py = Path(outfile).parent / (str(Path(outfile).stem) + '_py.wav') outfile_c = Path(outfile).parent / (str(Path(outfile).stem) + '_c.wav') # Generate input input_sig_py = np.empty((int(Fs*test_duration), num_in_channels), dtype=np.float64) for i in range(num_in_channels): # precision of 28 gives Q27 signal input_sig_py[:, i] = (gen.sin(fs=Fs, length=test_duration, freq=1000, amplitude=0.1, precision=28)).T if (input_dtype == np.int32) or (input_dtype == np.int16): int_sig = (np.array(input_sig_py) * utils.Q_max(27)).astype(np.int32) if input_dtype == np.int32: qformat = 31 int_sig <<= 4 else: qformat = 15 int_sig >>= (27-15) input_sig_c = np.clip(int_sig, np.iinfo(input_dtype).min, np.iinfo(input_dtype).max).astype(input_dtype) else: input_sig_c = deepcopy(input_sig_py) print(input_sig_py) print(input_sig_c) scipy.io.wavfile.write(infile, Fs, input_sig_c) # Run Python sim_sig = p.executor().process(input_sig_py).data np.testing.assert_equal(sim_sig, input_sig_py) sim_sig = utils.float_to_fixed_array(sim_sig, 27) << 4 audio_helpers.write_wav(outfile_py, Fs, sim_sig) # Run C xe = app_dir / f"bin/{target}/pipeline_test_{target}.xe" run_pipeline_xcoreai.run(xe, infile, outfile_c, num_out_channels, n_stages) # since gen.sin already generates a quantised input, no need to truncate exp_fs, exp_sig = audio_helpers.read_wav(infile) # Compare out_fs, out_sig_py = audio_helpers.read_wav(outfile_py) out_fs, out_sig_c = audio_helpers.read_wav(outfile_c) np.testing.assert_equal(out_sig_py, exp_sig[:out_sig_py.shape[0],:]) # Compare Python with input np.testing.assert_equal(out_sig_c, exp_sig[:out_sig_c.shape[0],:]) # Compare C with input INT32_MIN = -(2**31) INT32_MAX = (-INT32_MIN) - 1 @pytest.mark.parametrize("input, add", [(3, 3), # input too insignificant, output is shifted (-3, 0), # negative small numbers truncate in the negative direction (INT32_MIN, -3), (INT32_MAX, 3), (INT32_MAX, -3)]) def test_pipeline_q27(input, add): """ Check that the pipeline operates at q5.27 and outputs saturated Q1.31 This is done by having a stage which adds a constant to its input. Inputing small values will result in the small value being too insgnificant and shifted out. and the result is the constant shifted up. Check for saturation by adding a constant to large values and checking the output hasn't overflowed """ name = f"q27_{input}_{add}.wav" infile = "in" + name outfile = "out" + name n_samps, channels, rate = 1024, 2, 48000 output = ((input >> HEADROOM_BITS) + add) << HEADROOM_BITS output = min(output, INT32_MAX) output = max(output, INT32_MIN) p, i = Pipeline.begin(channels) addn = p.stage(AddN, i, n=add) p.set_outputs(addn) if input == INT32_MAX: app_str = "max" elif input == INT32_MIN: app_str = "min" else: app_str = f"{input}" app_dir = PKG_DIR / f"test_pipeline_q27_{app_str}" os.makedirs(app_dir, exist_ok=True) target = "default" gen_build(app_dir, p, target) sig = np.multiply(np.ones((n_samps, channels), dtype=np.int32), input, dtype=np.int32) audio_helpers.write_wav(infile, rate, sig) xe = app_dir / f"bin/{target}/pipeline_test_{target}.xe" run_pipeline_xcoreai.run(xe, infile, outfile, num_out_channels, pipeline_stages=1) expected = np.multiply(np.ones((n_samps, channels), dtype=np.int32), output, dtype=np.int32) _, out_data = audio_helpers.read_wav(outfile) np.testing.assert_equal(expected, out_data) @pytest.mark.group0 def test_complex_pipeline(): """ Generate a multithreaded pipeline and check the output is as expected """ infile = "incomplex.wav" outfile = "outcomplex.wav" n_samps, channels, rate = 1024, 2, 48000 p, i = Pipeline.begin(channels) # ch 0 1 # this thread has both channels a = p.stage(AddN, i, n=1) # +1 +1 a = p.stage(AddN, a, n=1) # +2 +2 a0 = p.stage(AddN, a[:1], n=1) # +3 +2 p.next_thread() # this thread has channel 1 a1 = p.stage(AddN, a[1:], n=1) # +3 +3 a1 = p.stage(AddN, a1, n=1) # +3 +4 a1 = p.stage(AddN, a1, n=1) # +3 +5 p.next_thread() # this thread has channel 0 a0 = p.stage(AddN, a0, n=1) # +4 +5 p.next_thread() # this thread has both channels a = p.stage(AddN, a0 + a1, n=1) # +5 +6 p.set_outputs(a) n_stages = 3 # 2 of the 4 threads are parallel app_dir = PKG_DIR / "test_pipeline_complex" os.makedirs(app_dir, exist_ok=True) target = "default" gen_build(app_dir, p, target) in_val = 1000 # expected output is +5 on left, +6 on right, in the Q1.27 format expected = (np.array([[5, 6]]*n_samps) + (in_val >> HEADROOM_BITS)) << HEADROOM_BITS sig = np.multiply(np.ones((n_samps, channels), dtype=np.int32), in_val, dtype=np.int32) audio_helpers.write_wav(infile, rate, sig) xe = APP_DIR / f"bin/{target}/pipeline_test_{target}.xe" run_pipeline_xcoreai.run(xe, infile, outfile, num_out_channels, n_stages) _, out_data = audio_helpers.read_wav(outfile) np.testing.assert_equal(expected, out_data) @pytest.mark.group0 def test_stage_labels(): """ Test for the user defined stage labels. Compares the autogenerated adsp_instance_id.h file to the stage labels dictionary in the resolved pipeline and confirms that they match. Also, runs some code on the device to send an internal control command using the define in adsp_instance_id.h for the instance ID used in the command and checks if control works as expected. """ p, n_stages = create_pipeline() # Autogenerate C code with FileLock(build_utils.BUILD_LOCK): generate_dsp_main(p, out_dir = BUILD_DIR / "dsp_pipeline") target = "default" # Check if the adsp_instance_id.h file exists and the labels are present in it label_defines_file = BUILD_DIR / "dsp_pipeline" / "adsp_instance_id_auto.h" assert label_defines_file.is_file(), f"{label_defines_file} not found" resolved_pipe = p.resolve_pipeline() labels_py = resolved_pipe["labels"] labels_autogen = {} with open(label_defines_file, "r") as fp: lines = fp.read().splitlines() for line in lines: m = re.match(r"^\#define\s+(\S+)_stage_index\s+\((\d+)\)", line) if m: assert(len(m.groups()) == 2) labels_autogen[m.groups()[0]] = int(m.groups()[1]) assert(labels_py == labels_autogen), f"stage labels in Python pipeline do not match the stage labels in the autogenerated code"