diff --git a/tests/test_audio.py b/tests/test_audio.py index 3baf48d..bcb4431 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -2,6 +2,8 @@ import struct +import numpy as np + from tuimble.audio import FRAME_SIZE, AudioPipeline, _apply_gain @@ -227,3 +229,43 @@ def test_playback_callback_applies_output_gain(): ap._playback_callback(outdata, 2, None, None) result = struct.unpack("<2h", bytes(outdata)) assert result == (500, -500) + + +# -- pitch property tests ---------------------------------------------------- + + +def test_pitch_default(): + ap = AudioPipeline() + assert ap.pitch == 0.0 + + +def test_pitch_set_and_get(): + ap = AudioPipeline() + ap.pitch = 5.0 + assert ap.pitch == 5.0 + ap.pitch = -3.0 + assert ap.pitch == -3.0 + + +def test_pitch_clamping(): + ap = AudioPipeline() + ap.pitch = 20.0 + assert ap.pitch == 12.0 + ap.pitch = -20.0 + assert ap.pitch == -12.0 + + +def test_pitch_applied_on_dequeue(): + """Pitch shifting runs in get_capture_frame, not the callback.""" + ap = AudioPipeline() + ap.capturing = True + ap.pitch = 4.0 + + t = np.arange(FRAME_SIZE) / 48000.0 + pcm = (np.sin(2 * np.pi * 440.0 * t) * 16000).astype(np.int16).tobytes() + + ap._capture_callback(pcm, FRAME_SIZE, None, None) + frame = ap.get_capture_frame() + assert frame is not None + assert len(frame) == len(pcm) + assert frame != pcm diff --git a/tests/test_modulator.py b/tests/test_modulator.py new file mode 100644 index 0000000..ac456a0 --- /dev/null +++ b/tests/test_modulator.py @@ -0,0 +1,78 @@ +"""Tests for PitchShifter.""" + +import numpy as np + +from tuimble.modulator import PitchShifter + +SAMPLE_RATE = 48000 +FRAME_SIZE = 960 # 20ms at 48kHz + + +def _sine_pcm(freq: float, n_samples: int = FRAME_SIZE) -> bytes: + """Generate a single-frequency int16 PCM frame.""" + t = np.arange(n_samples) / SAMPLE_RATE + samples = (np.sin(2 * np.pi * freq * t) * 16000).astype(np.int16) + return samples.tobytes() + + +def _dominant_freq(pcm: bytes) -> float: + """Return the dominant frequency in an int16 PCM buffer.""" + samples = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) + fft = np.abs(np.fft.rfft(samples)) + freqs = np.fft.rfftfreq(len(samples), 1.0 / SAMPLE_RATE) + return freqs[np.argmax(fft)] + + +def test_zero_semitones_passthrough(): + ps = PitchShifter(SAMPLE_RATE) + pcm = _sine_pcm(440.0) + assert ps.process(pcm) == pcm + + +def test_pitch_shift_changes_output(): + ps = PitchShifter(SAMPLE_RATE) + pcm = _sine_pcm(440.0) + ps.semitones = 3.0 + result = ps.process(pcm) + assert result != pcm + + +def test_output_length_preserved(): + ps = PitchShifter(SAMPLE_RATE) + pcm = _sine_pcm(440.0) + ps.semitones = 5.0 + result = ps.process(pcm) + assert len(result) == len(pcm) + + +def test_semitones_clamping(): + ps = PitchShifter() + ps.semitones = 20.0 + assert ps.semitones == 12.0 + ps.semitones = -20.0 + assert ps.semitones == -12.0 + ps.semitones = 5.0 + assert ps.semitones == 5.0 + + +def test_empty_input(): + ps = PitchShifter() + ps.semitones = 3.0 + assert ps.process(b"") == b"" + assert ps.process(b"\x00") == b"\x00" + + +def test_pitch_up_frequency_increases(): + ps = PitchShifter(SAMPLE_RATE) + pcm = _sine_pcm(440.0) + ps.semitones = 4.0 + result = ps.process(pcm) + assert _dominant_freq(result) > _dominant_freq(pcm) + + +def test_pitch_down_frequency_decreases(): + ps = PitchShifter(SAMPLE_RATE) + pcm = _sine_pcm(440.0) + ps.semitones = -4.0 + result = ps.process(pcm) + assert _dominant_freq(result) < _dominant_freq(pcm)