test: add pitch shifting and modulator tests
Covers PitchShifter passthrough, frequency shift direction, output length preservation, semitone clamping. AudioPipeline tests verify pitch property defaults, get/set, clamping, and dequeue integration.
This commit is contained in:
@@ -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
|
||||
|
||||
78
tests/test_modulator.py
Normal file
78
tests/test_modulator.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user