diff --git a/.github/workflows/test_conda.yml b/.github/workflows/test_conda.yml index 83663bf..3525906 100644 --- a/.github/workflows/test_conda.yml +++ b/.github/workflows/test_conda.yml @@ -34,6 +34,6 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest + - name: Test with unittest run: | - pytest \ No newline at end of file + python tests \ No newline at end of file diff --git a/.github/workflows/test_pip.yml b/.github/workflows/test_pip.yml index 76eb21b..2734b6e 100644 --- a/.github/workflows/test_pip.yml +++ b/.github/workflows/test_pip.yml @@ -7,11 +7,11 @@ on: push: branches: - main - - unit_tests + - revision pull_request: branches: - main - - unit_tests + - revision jobs: build: @@ -50,6 +50,6 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest + - name: Test with unittest run: | - pytest \ No newline at end of file + python tests \ No newline at end of file diff --git a/libsoni/core/f0.py b/libsoni/core/f0.py index 6869bff..57b24e7 100644 --- a/libsoni/core/f0.py +++ b/libsoni/core/f0.py @@ -59,6 +59,12 @@ def sonify_f0(time_f0: np.ndarray, f0_sonification: np.ndarray (np.float32 / np.float64) [shape=(M, )] Sonified f0-trajectory. """ + if time_f0.ndim != 2: + raise IndexError('time_f0 must be a numpy array of size [N, 2]') + if time_f0.shape[1] != 2: + raise IndexError('time_f0 must be a numpy array of size [N, 2]') + + if gains is not None: assert len(gains) == time_f0.shape[0], 'Array for confidence must have same length as time_f0.' else: diff --git a/libsoni/core/methods.py b/libsoni/core/methods.py index 5dd413b..0796b22 100644 --- a/libsoni/core/methods.py +++ b/libsoni/core/methods.py @@ -397,8 +397,8 @@ def generate_tone_instantaneous_phase(frequency_vector: np.ndarray, partials_amplitudes = np.ones(len(partials)) if partials_amplitudes is None else partials_amplitudes partials_phase_offsets = np.zeros(len(partials)) if partials_phase_offsets is None else partials_phase_offsets - assert len(partials) == len(partials_amplitudes) == len(partials_phase_offsets), \ - 'Partials, Partials_amplitudes and Partials_phase_offsets must be of equal length.' + if not (len(partials) == len(partials_amplitudes) == len(partials_phase_offsets)): + raise ValueError('Partials, Partials_amplitudes and Partials_phase_offsets must be of equal length.') generated_tone = np.zeros_like(frequency_vector) diff --git a/libsoni/util/utils.py b/libsoni/util/utils.py index 4c37f36..6716334 100644 --- a/libsoni/util/utils.py +++ b/libsoni/util/utils.py @@ -176,7 +176,7 @@ def plot_sonify_novelty_beats(fn_wav, fn_ann, title=''): def format_df(df: pd.DataFrame) -> pd.DataFrame: df = df.copy().rename(columns=str.lower) - + check_df_schema(df) if 'duration' not in df.columns: try: df['duration'] = df['end'] - df['start'] @@ -385,3 +385,13 @@ def visualize_pianoroll(pianoroll_df: pd.DataFrame, plt.tight_layout() return fig, ax + + +def check_df_schema(df: pd.DataFrame): + try: + columns_bool = (df.columns == ['start', 'duration', 'pitch', 'velocity', 'label']).all() and \ + len(df.columns) == 5 + if not columns_bool: + raise ValueError("Columns of the dataframe must be ['start', 'duration', 'pitch', 'velocity', 'label'].") + except: + raise ValueError("Columns of the dataframe must be ['start', 'duration', 'pitch', 'velocity', 'label'].") diff --git a/tests/__main__.py b/tests/__main__.py new file mode 100644 index 0000000..617f0a3 --- /dev/null +++ b/tests/__main__.py @@ -0,0 +1,11 @@ +import unittest + + +if __name__ == '__main__': + root_dir = './' + loader = unittest.TestLoader() + testSuite = loader.discover(start_dir='tests', + pattern="test_*.py") + + runner = unittest.TextTestRunner(verbosity=2) + runner.run(testSuite) diff --git a/tests/data/f0_None_0.wav b/tests/data/f0_None_0.wav deleted file mode 100644 index 7263558..0000000 Binary files a/tests/data/f0_None_0.wav and /dev/null differ diff --git a/tests/data/f0_None_1.wav b/tests/data/f0_None_1.wav deleted file mode 100644 index 3b8dc0a..0000000 Binary files a/tests/data/f0_None_1.wav and /dev/null differ diff --git a/tests/test_f0.py b/tests/test_f0.py index e64547e..71f4149 100644 --- a/tests/test_f0.py +++ b/tests/test_f0.py @@ -1,32 +1,58 @@ import numpy as np import soundfile as sf +from unittest import TestCase + from libsoni.core import f0 -Fs = 22050 -C_MAJOR_SCALE = [261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88, 523.25, 0.0] -DURATIONS = [None, 3.0, 5.0] -PARTIALS = [np.array([1]), np.array([1, 2, 3])] -PARTIALS_AMPLITUDES = [np.array([1]), np.array([1, 0.5, 0.25])] - - -def test_f0(): - time_positions = np.arange(0.2, len(C_MAJOR_SCALE) * 0.5, 0.5) - time_f0 = np.column_stack((time_positions, C_MAJOR_SCALE)) - - for duration in DURATIONS: - for par_idx, partials in enumerate(PARTIALS): - if duration is None: - duration_in_samples = None - else: - duration_in_samples = int(duration * Fs) - - y = f0.sonify_f0(time_f0=time_f0, - partials=partials, - partials_amplitudes=PARTIALS_AMPLITUDES[par_idx], - sonification_duration=duration_in_samples, - fs=Fs) - - ref, _ = sf.read(f'tests/data/f0_{duration_in_samples}_{par_idx}.wav') - assert len(y) == len(ref), 'Length of the generated sonification does not match with the reference!' - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) + +class TestF0(TestCase): + def setUp(self) -> None: + c_major_scale = [261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88, 523.25, 0.0] + time_positions = np.arange(0.2, len(c_major_scale) * 0.5, 0.5) + self.fs = 22050 + self.durations = [int(3.0*self.fs), int(5.0*self.fs)] + self.partials = [np.array([1]), np.array([1, 2, 3])] + self.partials_amplitudes = [np.array([1]), np.array([1, 0.5, 0.25])] + self.time_f0 = np.column_stack((time_positions, c_major_scale)) + + def test_input_types(self) -> None: + [self.assertIsInstance(duration, int) for duration in self.durations] + [self.assertIsInstance(partials, np.ndarray) for partials in self.partials] + [self.assertIsInstance(partials_amplitude, np.ndarray) for partials_amplitude in self.partials_amplitudes] + self.assertIsInstance(self.fs, int) + self.assertIsInstance(self.time_f0, np.ndarray) + + def test_input_shape(self) -> None: + with self.assertRaises(IndexError) as context: + _ = f0.sonify_f0(time_f0=np.zeros(1)) + self.assertEqual(str(context.exception), 'time_f0 must be a numpy array of size [N, 2]') + + with self.assertRaises(IndexError) as context: + _ = f0.sonify_f0(time_f0=np.zeros((3, 3))) + self.assertEqual(str(context.exception), 'time_f0 must be a numpy array of size [N, 2]') + + def test_invalid_partial_sizes(self): + with self.assertRaises(ValueError) as context: + _ = f0.sonify_f0(time_f0=self.time_f0, + partials=self.partials[0], + partials_amplitudes=self.partials_amplitudes[1], + sonification_duration=self.durations[0], + fs=self.fs) + + self.assertEqual(str(context.exception), 'Partials, Partials_amplitudes and Partials_phase_offsets must be ' + 'of equal length.') + + def test_sonification(self) -> None: + for duration in self.durations: + for par_idx, partials in enumerate(self.partials): + y = f0.sonify_f0(time_f0=self.time_f0, + partials=self.partials[par_idx], + partials_amplitudes=self.partials_amplitudes[par_idx], + sonification_duration=duration, + fs=self.fs) + + ref, _ = sf.read(f'tests/data/f0_{duration}_{par_idx}.wav') + self.assertEqual(len(y), len(ref), msg='Length of the generated sonification ' + 'does not match with the reference!') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) diff --git a/tests/test_methods.py b/tests/test_methods.py index 52e95f1..2792e9b 100644 --- a/tests/test_methods.py +++ b/tests/test_methods.py @@ -1,53 +1,63 @@ import numpy as np import soundfile as sf +from unittest import TestCase -from libsoni.core.methods import generate_click, generate_shepard_tone, generate_sinusoid +from libsoni.core.methods import generate_click, generate_shepard_tone, generate_sinusoid,\ + generate_tone_instantaneous_phase from libsoni.util.utils import pitch_to_frequency -DURATIONS = [0.2, 0.5, 1.0] -FADE = [0.05, 0.1] -PITCHES = [60, 69] -Fs = 22050 - - -def test_click(): - for duration in DURATIONS: - for pitch in PITCHES: - y = generate_click(pitch=pitch, - click_fading_duration=duration) - - ref, _ = sf.read(f'tests/data/click_{pitch}_{(int(Fs * duration))}.wav') - - assert len(y) == len(ref), 'Length of the generated sonification does not match with the reference!' - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) - - -def test_sinusoid(): - for duration in DURATIONS: - for pitch in PITCHES: - for fading_duration in FADE: - freq = pitch_to_frequency(pitch=pitch) - y = generate_sinusoid(frequency=freq, - duration=duration, - fading_duration=fading_duration) - dur_samples = int(Fs * duration) - fade_samples = int(Fs * fading_duration) - ref, _ = sf.read(f'tests/data/sin_{pitch}_{dur_samples}_{fade_samples}.wav') - assert len(y) == len(ref), 'Length of the generated sonification does not match with the reference!' - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) - - -def test_shepard_tone(): - for duration in DURATIONS: - for pitch in PITCHES: - for fading_duration in FADE: - pitch_class = pitch % 12 - y = generate_shepard_tone(pitch_class=pitch_class, - duration=duration, - fading_duration=fading_duration) - dur_samples = int(Fs * duration) - fade_samples = int(Fs * fading_duration) - ref, _ = sf.read(f'tests/data/shepard_{pitch_class}_{dur_samples}_{fade_samples}.wav') - assert len(y) == len(ref), 'Length of the generated sonification does not match with the reference!' - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) +class TestMethods(TestCase): + def setUp(self) -> None: + self.frequency_vector = np.array([261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88, 523.25, 0.0]) + self.pitches = [60, 69] + self.fade_vals = [0.05, 0.1] + self.fs = 22050 + self.durations = [int(0.2*self.fs), int(0.5*self.fs), int(1.0*self.fs)] + self.partials = [np.array([1]), np.array([1, 2, 3])] + self.partials_amplitudes = [np.array([1]), np.array([1, 0.5, 0.25])] + + def test_input_types(self) -> None: + [self.assertIsInstance(duration, int) for duration in self.durations] + [self.assertIsInstance(freq, float) for freq in self.frequency_vector] + [self.assertIsInstance(partials, np.ndarray) for partials in self.partials] + [self.assertIsInstance(partials_amplitude, np.ndarray) for partials_amplitude in self.partials_amplitudes] + self.assertIsInstance(self.fs, int) + + def test_invalid_partial_sizes(self) -> None: + with self.assertRaises(ValueError) as context: + _ = generate_tone_instantaneous_phase(self.frequency_vector, + partials=self.partials[0], + partials_amplitudes=self.partials_amplitudes[1], + fs=self.fs) + + self.assertEqual(str(context.exception), 'Partials, Partials_amplitudes and Partials_phase_offsets must be ' + 'of equal length.') + + def test_click(self) -> None: + for duration in self.durations: + for pitch in self.pitches: + for fade_val in self.fade_vals: + freq = pitch_to_frequency(pitch=pitch) + y = generate_sinusoid(frequency=freq, + duration=duration/self.fs, + fading_duration=fade_val) + fade_samples = int(self.fs * fade_val) + ref, _ = sf.read(f'tests/data/sin_{pitch}_{duration}_{fade_samples}.wav') + self.assertEqual(len(y), len(ref), msg='Length of the generated sonification ' + 'does not match with the reference!') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) + + def test_shepard_tone(self) -> None: + for duration in self.durations: + for pitch in self.pitches: + for fade_val in self.fade_vals: + pitch_class = pitch % 12 + y = generate_shepard_tone(pitch_class=pitch_class, + duration=duration/self.fs, + fading_duration=fade_val) + fade_samples = int(self.fs * fade_val) + ref, _ = sf.read(f'tests/data/shepard_{pitch_class}_{duration}_{fade_samples}.wav') + self.assertEqual(len(y), len(ref), msg='Length of the generated sonification ' + 'does not match with the reference!') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) diff --git a/tests/test_pianoroll.py b/tests/test_pianoroll.py index 3380e56..344a8b0 100644 --- a/tests/test_pianoroll.py +++ b/tests/test_pianoroll.py @@ -1,51 +1,75 @@ import numpy as np import pandas as pd import soundfile as sf +from unittest import TestCase from libsoni.core.pianoroll import sonify_pianoroll_clicks,\ sonify_pianoroll_additive_synthesis, sonify_pianoroll_fm_synthesis, sonify_pianoroll_sample +from libsoni.util.utils import format_df, check_df_schema -Fs = 22050 -SAMPLE, _ = sf.read('data_audio/samples/01Pia1F060f_np___0_short.wav', Fs) -DF_PIANOROLL = pd.read_csv('data_csv/demo_pianoroll/FMP_B_Sonify_Pitch_Schubert_D911-11_SC06.csv', - sep=';') -PARTIALS = [np.array([1]), np.array([1, 2, 3])] -PARTIALS_AMPLITUDES = [np.array([1]), np.array([1, 0.5, 0.25])] +class TestPianoRoll(TestCase): + def setUp(self) -> None: + self.df_pianoroll = pd.read_csv('data_csv/demo_pianoroll/FMP_B_Sonify_Pitch_Schubert_D911-11_SC06.csv', sep=';') + self.fs = 22050 + self.sample, _ = sf.read('data_audio/samples/01Pia1F060f_np___0_short.wav', self.fs) + self.durations = [int(3.0*self.fs), int(5.0*self.fs)] + self.partials = [np.array([1]), np.array([1, 2, 3])] + self.partials_amplitudes = [np.array([1]), np.array([1, 0.5, 0.25])] -def test_pianoroll_additive(): - for partial_idx, partials in enumerate(PARTIALS): - y = sonify_pianoroll_additive_synthesis(pianoroll_df=DF_PIANOROLL, - partials=partials, - partials_amplitudes=PARTIALS_AMPLITUDES[partial_idx], - sonification_duration=5.0) + def test_input_types(self) -> None: + [self.assertIsInstance(duration, int) for duration in self.durations] + [self.assertIsInstance(partials, np.ndarray) for partials in self.partials] + [self.assertIsInstance(partials_amplitude, np.ndarray) for partials_amplitude in self.partials_amplitudes] + self.assertIsInstance(self.fs, int) + check_df_schema(self.df_pianoroll) - ref, _ = sf.read(f'tests/data/pianoroll_add_{partial_idx}.wav') - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) + def test_input_df(self) -> None: + incorrect_df = self.df_pianoroll.copy().rename(columns=str.lower) + incorrect_df['tmp'] = None + with self.assertRaises(ValueError) as context: + _ = format_df(incorrect_df) + self.assertEqual(str(context.exception), "Columns of the dataframe must be ['start', 'duration'," + " 'pitch', 'velocity', 'label'].") -def test_pianoroll_sample(): - y = sonify_pianoroll_sample(pianoroll_df=DF_PIANOROLL, - sample=SAMPLE, - reference_pitch=60, - sonification_duration=3.0) - ref, _ = sf.read(f'tests/data/pianoroll_sample.wav') - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) + def test_pianoroll_additive(self) -> None: + for partial_idx, partials in enumerate(self.partials): + y = sonify_pianoroll_additive_synthesis(pianoroll_df=self.df_pianoroll, + partials=partials, + partials_amplitudes=self.partials_amplitudes[partial_idx], + sonification_duration=int(5*self.fs)) + ref, _ = sf.read(f'tests/data/pianoroll_add_{partial_idx}.wav') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) -def test_pianoroll_fm(): - y = sonify_pianoroll_fm_synthesis(pianoroll_df=DF_PIANOROLL, - sonification_duration=3.0, - mod_rate_relative=0.5, - mod_amp=0.1) + def test_pianoroll_sample(self) -> None: + y = sonify_pianoroll_sample(pianoroll_df=self.df_pianoroll, + sample=self.sample, + reference_pitch=60, + sonification_duration=int(3.0*self.fs)) + ref, _ = sf.read(f'tests/data/pianoroll_sample.wav') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) - ref, _ = sf.read(f'tests/data/pianoroll_fm.wav') - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) + def test_pianoroll_sample(self) -> None: + y = sonify_pianoroll_sample(pianoroll_df=self.df_pianoroll, + sample=self.sample, + reference_pitch=60, + sonification_duration=int(3.0*self.fs)) + ref, _ = sf.read(f'tests/data/pianoroll_sample.wav') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) + def test_pianoroll_fm(self) -> None: + y = sonify_pianoroll_fm_synthesis(pianoroll_df=self.df_pianoroll, + sonification_duration=int(3.0 * self.fs), + mod_rate_relative=0.5, + mod_amp=0.1) + ref, _ = sf.read(f'tests/data/pianoroll_fm.wav') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) -def test_pianoroll_clicks(): - y = sonify_pianoroll_clicks(pianoroll_df=DF_PIANOROLL, - sonification_duration=3.0) + def test_pianoroll_clicks(self) -> None: + y = sonify_pianoroll_clicks(pianoroll_df=self.df_pianoroll, + sonification_duration=int(3.0 * self.fs)) + ref, _ = sf.read(f'tests/data/pianoroll_clicks.wav') + assert np.allclose(y, ref, atol=1e-4, rtol=1e-5) - ref, _ = sf.read(f'tests/data/pianoroll_clicks.wav') - assert np.allclose(y, ref, atol=1e-4, rtol=1e-5)