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>
80 lines
2.7 KiB
Python
80 lines
2.7 KiB
Python
"""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)
|