McRogueFace/tests/unit/soundbuffer_effects_test.py
John McCardle 97dbec9106 Add SoundBuffer type: procedural audio, sfxr synthesis, DSP effects
New SoundBuffer Python type enables procedural audio generation:
- Tone synthesis (sine, square, saw, triangle, noise) with ADSR envelopes
- sfxr retro sound effect engine (7 presets, 24 params, mutation, seeding)
- DSP effects chain: pitch_shift, low/high pass, echo, reverb,
  distortion, bit_crush, normalize, reverse, slice
- Composition: concat (with crossfade overlap) and mix
- Sound() now accepts SoundBuffer or filename string
- Sound gains pitch property and play_varied() method
- Platform stubs for HeadlessTypes and SDL2Types (loadFromSamples, pitch)
- Interactive demo: sfxr clone UI + Animalese speech synthesizer
- 62 unit tests across 6 test files (all passing)

Refs #251

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:58:11 -05:00

102 lines
3.1 KiB
Python

"""Test SoundBuffer DSP effects."""
import mcrfpy
import sys
# Create a test buffer: 0.5s 440Hz sine
src = mcrfpy.SoundBuffer.tone(440, 0.5, "sine")
# Test 1: pitch_shift
higher = src.pitch_shift(2.0)
assert higher is not None
assert higher.sample_count > 0
# Higher pitch = shorter duration
assert higher.duration < src.duration, f"pitch_shift(2.0) should be shorter: {higher.duration} vs {src.duration}"
print(f"PASS: pitch_shift(2.0) -> {higher.duration:.3f}s (was {src.duration:.3f}s)")
lower = src.pitch_shift(0.5)
assert lower.duration > src.duration, f"pitch_shift(0.5) should be longer: {lower.duration} vs {src.duration}"
print(f"PASS: pitch_shift(0.5) -> {lower.duration:.3f}s")
# Test 2: low_pass
lp = src.low_pass(500.0)
assert lp is not None
assert lp.sample_count == src.sample_count
assert lp.duration == src.duration
print("PASS: low_pass preserves sample count and duration")
# Test 3: high_pass
hp = src.high_pass(500.0)
assert hp is not None
assert hp.sample_count == src.sample_count
print("PASS: high_pass preserves sample count")
# Test 4: echo
echoed = src.echo(200.0, 0.4, 0.5)
assert echoed is not None
assert echoed.sample_count == src.sample_count # same length
print("PASS: echo works")
# Test 5: reverb
reverbed = src.reverb(0.8, 0.5, 0.3)
assert reverbed is not None
assert reverbed.sample_count == src.sample_count
print("PASS: reverb works")
# Test 6: distortion
dist = src.distortion(2.0)
assert dist is not None
assert dist.sample_count == src.sample_count
print("PASS: distortion works")
# Test 7: bit_crush
crushed = src.bit_crush(8, 4)
assert crushed is not None
assert crushed.sample_count == src.sample_count
print("PASS: bit_crush works")
# Test 8: normalize
normed = src.normalize()
assert normed is not None
assert normed.sample_count == src.sample_count
print("PASS: normalize works")
# Test 9: reverse
rev = src.reverse()
assert rev is not None
assert rev.sample_count == src.sample_count
print("PASS: reverse preserves sample count")
# Test 10: slice
sliced = src.slice(0.1, 0.3)
assert sliced is not None
expected_duration = 0.2
assert abs(sliced.duration - expected_duration) < 0.02, f"Expected ~{expected_duration}s, got {sliced.duration}s"
print(f"PASS: slice(0.1, 0.3) -> {sliced.duration:.3f}s")
# Test 11: slice out of bounds is safe
empty = src.slice(0.5, 0.5) # zero-length
assert empty.sample_count == 0
print("PASS: slice with start==end returns empty")
# Test 12: Chaining effects (effects return new buffers)
chained = src.low_pass(1000).distortion(1.5).normalize()
assert chained is not None
assert chained.sample_count > 0
print("PASS: Chaining effects works")
# Test 13: Effects don't modify original
orig_count = src.sample_count
src.pitch_shift(2.0)
assert src.sample_count == orig_count, "Original should not be modified"
print("PASS: Effects don't modify original buffer")
# Test 14: pitch_shift with invalid factor raises ValueError
try:
src.pitch_shift(-1.0)
assert False, "Should have raised ValueError"
except ValueError:
pass
print("PASS: pitch_shift with negative factor raises ValueError")
print("\nAll soundbuffer_effects tests passed!")
sys.exit(0)