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>
This commit is contained in:
John McCardle 2026-02-19 18:57:20 -05:00
commit 97dbec9106
20 changed files with 4793 additions and 197 deletions

View file

@ -0,0 +1,80 @@
"""Test SoundBuffer composition (concat, mix)."""
import mcrfpy
import sys
# Create test buffers
a = mcrfpy.SoundBuffer.tone(440, 0.3, "sine")
b = mcrfpy.SoundBuffer.tone(880, 0.2, "sine")
c = mcrfpy.SoundBuffer.tone(660, 0.4, "square")
# Test 1: concat two buffers
result = mcrfpy.SoundBuffer.concat([a, b])
assert result is not None
expected = a.duration + b.duration
assert abs(result.duration - expected) < 0.02, f"Expected ~{expected:.3f}s, got {result.duration:.3f}s"
print(f"PASS: concat([0.3s, 0.2s]) -> {result.duration:.3f}s")
# Test 2: concat three buffers
result3 = mcrfpy.SoundBuffer.concat([a, b, c])
expected3 = a.duration + b.duration + c.duration
assert abs(result3.duration - expected3) < 0.03
print(f"PASS: concat([0.3s, 0.2s, 0.4s]) -> {result3.duration:.3f}s")
# Test 3: concat with crossfade overlap
overlapped = mcrfpy.SoundBuffer.concat([a, b], overlap=0.05)
# Duration should be about 0.05s shorter than without overlap
expected_overlap = a.duration + b.duration - 0.05
assert abs(overlapped.duration - expected_overlap) < 0.03, \
f"Expected ~{expected_overlap:.3f}s, got {overlapped.duration:.3f}s"
print(f"PASS: concat with overlap=0.05 -> {overlapped.duration:.3f}s")
# Test 4: mix two buffers
mixed = mcrfpy.SoundBuffer.mix([a, b])
assert mixed is not None
# mix pads to longest buffer
assert abs(mixed.duration - max(a.duration, b.duration)) < 0.02
print(f"PASS: mix([0.3s, 0.2s]) -> {mixed.duration:.3f}s (padded to longest)")
# Test 5: mix same duration buffers
d = mcrfpy.SoundBuffer.tone(440, 0.5, "sine")
e = mcrfpy.SoundBuffer.tone(660, 0.5, "sine")
mixed2 = mcrfpy.SoundBuffer.mix([d, e])
assert abs(mixed2.duration - 0.5) < 0.02
print(f"PASS: mix([0.5s, 0.5s]) -> {mixed2.duration:.3f}s")
# Test 6: concat empty list raises ValueError
try:
mcrfpy.SoundBuffer.concat([])
assert False, "Should have raised ValueError"
except ValueError:
pass
print("PASS: concat([]) raises ValueError")
# Test 7: mix empty list raises ValueError
try:
mcrfpy.SoundBuffer.mix([])
assert False, "Should have raised ValueError"
except ValueError:
pass
print("PASS: mix([]) raises ValueError")
# Test 8: concat with non-SoundBuffer raises TypeError
try:
mcrfpy.SoundBuffer.concat([a, "not a buffer"])
assert False, "Should have raised TypeError"
except TypeError:
pass
print("PASS: concat with invalid types raises TypeError")
# Test 9: concat single buffer returns copy
single = mcrfpy.SoundBuffer.concat([a])
assert abs(single.duration - a.duration) < 0.02
print("PASS: concat single buffer works")
# Test 10: mix single buffer returns copy
single_mix = mcrfpy.SoundBuffer.mix([a])
assert abs(single_mix.duration - a.duration) < 0.02
print("PASS: mix single buffer works")
print("\nAll soundbuffer_compose tests passed!")
sys.exit(0)