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:
parent
bb72040396
commit
97dbec9106
20 changed files with 4793 additions and 197 deletions
80
tests/unit/soundbuffer_compose_test.py
Normal file
80
tests/unit/soundbuffer_compose_test.py
Normal 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)
|
||||
59
tests/unit/soundbuffer_core_test.py
Normal file
59
tests/unit/soundbuffer_core_test.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Test SoundBuffer core creation and properties."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
import struct
|
||||
|
||||
# Test 1: SoundBuffer type exists
|
||||
assert hasattr(mcrfpy, 'SoundBuffer'), "mcrfpy.SoundBuffer not found"
|
||||
print("PASS: SoundBuffer type exists")
|
||||
|
||||
# Test 2: from_samples factory
|
||||
# Create 1 second of silence (44100 mono samples of int16 zeros)
|
||||
sample_rate = 44100
|
||||
channels = 1
|
||||
num_samples = sample_rate # 1 second
|
||||
raw_data = b'\x00\x00' * num_samples # int16 zeros
|
||||
buf = mcrfpy.SoundBuffer.from_samples(raw_data, channels, sample_rate)
|
||||
assert buf is not None
|
||||
print("PASS: from_samples creates SoundBuffer")
|
||||
|
||||
# Test 3: Properties
|
||||
assert abs(buf.duration - 1.0) < 0.01, f"Expected ~1.0s duration, got {buf.duration}"
|
||||
assert buf.sample_count == num_samples, f"Expected {num_samples} samples, got {buf.sample_count}"
|
||||
assert buf.sample_rate == sample_rate, f"Expected {sample_rate} rate, got {buf.sample_rate}"
|
||||
assert buf.channels == channels, f"Expected {channels} channels, got {buf.channels}"
|
||||
print("PASS: Properties correct (duration, sample_count, sample_rate, channels)")
|
||||
|
||||
# Test 4: sfxr_params is None for non-sfxr buffer
|
||||
assert buf.sfxr_params is None
|
||||
print("PASS: sfxr_params is None for non-sfxr buffer")
|
||||
|
||||
# Test 5: repr works
|
||||
r = repr(buf)
|
||||
assert "SoundBuffer" in r
|
||||
assert "duration" in r
|
||||
print(f"PASS: repr = {r}")
|
||||
|
||||
# Test 6: from_samples with actual waveform data
|
||||
# Generate a 440Hz sine wave, 0.5 seconds
|
||||
import math
|
||||
num_samples2 = int(sample_rate * 0.5)
|
||||
samples = []
|
||||
for i in range(num_samples2):
|
||||
t = i / sample_rate
|
||||
val = int(32000 * math.sin(2 * math.pi * 440 * t))
|
||||
samples.append(val)
|
||||
raw = struct.pack(f'<{num_samples2}h', *samples)
|
||||
buf2 = mcrfpy.SoundBuffer.from_samples(raw, 1, 44100)
|
||||
assert abs(buf2.duration - 0.5) < 0.01
|
||||
print("PASS: from_samples with sine wave data")
|
||||
|
||||
# Test 7: stereo from_samples
|
||||
stereo_samples = b'\x00\x00' * (44100 * 2) # 1 second stereo
|
||||
buf3 = mcrfpy.SoundBuffer.from_samples(stereo_samples, 2, 44100)
|
||||
assert buf3.channels == 2
|
||||
assert abs(buf3.duration - 1.0) < 0.01
|
||||
print("PASS: Stereo from_samples")
|
||||
|
||||
print("\nAll soundbuffer_core tests passed!")
|
||||
sys.exit(0)
|
||||
102
tests/unit/soundbuffer_effects_test.py
Normal file
102
tests/unit/soundbuffer_effects_test.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""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)
|
||||
94
tests/unit/soundbuffer_sfxr_test.py
Normal file
94
tests/unit/soundbuffer_sfxr_test.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Test SoundBuffer sfxr synthesis."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
# Test 1: All presets work
|
||||
presets = ["coin", "laser", "explosion", "powerup", "hurt", "jump", "blip"]
|
||||
for preset in presets:
|
||||
buf = mcrfpy.SoundBuffer.sfxr(preset)
|
||||
assert buf is not None, f"sfxr('{preset}') returned None"
|
||||
assert buf.sample_count > 0, f"sfxr('{preset}') has 0 samples"
|
||||
assert buf.duration > 0, f"sfxr('{preset}') has 0 duration"
|
||||
assert buf.sfxr_params is not None, f"sfxr('{preset}') has no params"
|
||||
print(f" PASS: sfxr('{preset}') -> {buf.duration:.3f}s, {buf.sample_count} samples")
|
||||
|
||||
print("PASS: All sfxr presets generate audio")
|
||||
|
||||
# Test 2: Deterministic with seed
|
||||
buf1 = mcrfpy.SoundBuffer.sfxr("coin", seed=42)
|
||||
buf2 = mcrfpy.SoundBuffer.sfxr("coin", seed=42)
|
||||
assert buf1.sample_count == buf2.sample_count, "Same seed should produce same sample count"
|
||||
assert buf1.duration == buf2.duration, "Same seed should produce same duration"
|
||||
print("PASS: Deterministic with seed")
|
||||
|
||||
# Test 3: Different seeds produce different results
|
||||
buf3 = mcrfpy.SoundBuffer.sfxr("coin", seed=99)
|
||||
# May have same count by chance, but params should differ
|
||||
p1 = buf1.sfxr_params
|
||||
p3 = buf3.sfxr_params
|
||||
# At least one param should differ (with very high probability)
|
||||
differs = any(p1[k] != p3[k] for k in p1.keys() if k != 'wave_type')
|
||||
assert differs, "Different seeds should produce different params"
|
||||
print("PASS: Different seeds produce different results")
|
||||
|
||||
# Test 4: sfxr_params dict contains expected keys
|
||||
params = buf1.sfxr_params
|
||||
expected_keys = [
|
||||
'wave_type', 'base_freq', 'freq_limit', 'freq_ramp', 'freq_dramp',
|
||||
'duty', 'duty_ramp', 'vib_strength', 'vib_speed',
|
||||
'env_attack', 'env_sustain', 'env_decay', 'env_punch',
|
||||
'lpf_freq', 'lpf_ramp', 'lpf_resonance',
|
||||
'hpf_freq', 'hpf_ramp',
|
||||
'pha_offset', 'pha_ramp', 'repeat_speed',
|
||||
'arp_speed', 'arp_mod'
|
||||
]
|
||||
for key in expected_keys:
|
||||
assert key in params, f"Missing key '{key}' in sfxr_params"
|
||||
print("PASS: sfxr_params has all expected keys")
|
||||
|
||||
# Test 5: sfxr with custom params
|
||||
buf_custom = mcrfpy.SoundBuffer.sfxr(wave_type=2, base_freq=0.5, env_decay=0.3)
|
||||
assert buf_custom is not None
|
||||
assert buf_custom.sfxr_params is not None
|
||||
assert buf_custom.sfxr_params['wave_type'] == 2
|
||||
assert abs(buf_custom.sfxr_params['base_freq'] - 0.5) < 0.001
|
||||
print("PASS: sfxr with custom params")
|
||||
|
||||
# Test 6: sfxr_mutate
|
||||
mutated = buf1.sfxr_mutate(0.1)
|
||||
assert mutated is not None
|
||||
assert mutated.sfxr_params is not None
|
||||
assert mutated.sample_count > 0
|
||||
# Params should be similar but different
|
||||
mp = mutated.sfxr_params
|
||||
op = buf1.sfxr_params
|
||||
differs = any(abs(mp[k] - op[k]) > 0.0001 for k in mp.keys() if isinstance(mp[k], float))
|
||||
# Note: with small mutation and few params, there's a chance all stay same.
|
||||
# But with 0.1 amount and ~20 float params, extremely unlikely all stay same.
|
||||
print(f"PASS: sfxr_mutate produces {'different' if differs else 'similar'} params")
|
||||
|
||||
# Test 7: sfxr_mutate with seed for reproducibility
|
||||
m1 = buf1.sfxr_mutate(0.05, 42)
|
||||
m2 = buf1.sfxr_mutate(0.05, 42)
|
||||
assert m1.sample_count == m2.sample_count, "Same seed should produce same mutation"
|
||||
print("PASS: sfxr_mutate deterministic with seed")
|
||||
|
||||
# Test 8: sfxr_mutate on non-sfxr buffer raises error
|
||||
tone_buf = mcrfpy.SoundBuffer.tone(440, 0.5, "sine")
|
||||
try:
|
||||
tone_buf.sfxr_mutate(0.1)
|
||||
assert False, "Should have raised RuntimeError"
|
||||
except RuntimeError:
|
||||
pass
|
||||
print("PASS: sfxr_mutate on non-sfxr buffer raises RuntimeError")
|
||||
|
||||
# Test 9: Invalid preset raises ValueError
|
||||
try:
|
||||
mcrfpy.SoundBuffer.sfxr("nonexistent_preset")
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
print("PASS: Invalid preset raises ValueError")
|
||||
|
||||
print("\nAll soundbuffer_sfxr tests passed!")
|
||||
sys.exit(0)
|
||||
85
tests/unit/soundbuffer_sound_test.py
Normal file
85
tests/unit/soundbuffer_sound_test.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Test Sound integration with SoundBuffer."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
# Test 1: Sound accepts SoundBuffer
|
||||
buf = mcrfpy.SoundBuffer.tone(440, 0.5, "sine")
|
||||
sound = mcrfpy.Sound(buf)
|
||||
assert sound is not None
|
||||
print("PASS: Sound(SoundBuffer) works")
|
||||
|
||||
# Test 2: Sound.buffer returns the SoundBuffer
|
||||
got_buf = sound.buffer
|
||||
assert got_buf is not None
|
||||
assert abs(got_buf.duration - buf.duration) < 0.02
|
||||
print("PASS: sound.buffer returns SoundBuffer")
|
||||
|
||||
# Test 3: Sound.pitch property
|
||||
assert sound.pitch == 1.0, f"Default pitch should be 1.0, got {sound.pitch}"
|
||||
sound.pitch = 1.5
|
||||
assert abs(sound.pitch - 1.5) < 0.001
|
||||
sound.pitch = 1.0
|
||||
print("PASS: sound.pitch get/set")
|
||||
|
||||
# Test 4: Sound.play_varied (in headless mode, just verifies no crash)
|
||||
sound.play_varied(pitch_range=0.1, volume_range=3.0)
|
||||
print("PASS: sound.play_varied() works")
|
||||
|
||||
# Test 5: Sound from SoundBuffer has duration
|
||||
assert sound.duration > 0
|
||||
print(f"PASS: Sound from SoundBuffer has duration {sound.duration:.3f}s")
|
||||
|
||||
# Test 6: Sound from SoundBuffer has source '<SoundBuffer>'
|
||||
assert sound.source == "<SoundBuffer>"
|
||||
print("PASS: Sound.source is '<SoundBuffer>' for buffer-created sounds")
|
||||
|
||||
# Test 7: Backward compatibility - Sound still accepts string
|
||||
# File may not exist, so we test that a string is accepted (not TypeError)
|
||||
# and that RuntimeError is raised for missing files
|
||||
sound2 = None
|
||||
try:
|
||||
sound2 = mcrfpy.Sound("test.ogg")
|
||||
print("PASS: Sound(str) backward compatible (file loaded)")
|
||||
except RuntimeError:
|
||||
# File doesn't exist - that's fine, the important thing is it accepted a string
|
||||
print("PASS: Sound(str) backward compatible (raises RuntimeError for missing file)")
|
||||
|
||||
# Test 8: Sound from SoundBuffer - standard playback controls
|
||||
sound.volume = 75.0
|
||||
assert abs(sound.volume - 75.0) < 0.1
|
||||
sound.loop = True
|
||||
assert sound.loop == True
|
||||
sound.loop = False
|
||||
print("PASS: Standard playback controls work with SoundBuffer")
|
||||
|
||||
# Test 9: sfxr buffer -> Sound pipeline
|
||||
sfx = mcrfpy.SoundBuffer.sfxr("coin", seed=42)
|
||||
coin_sound = mcrfpy.Sound(sfx)
|
||||
assert coin_sound is not None
|
||||
assert coin_sound.duration > 0
|
||||
print(f"PASS: sfxr -> Sound pipeline ({coin_sound.duration:.3f}s)")
|
||||
|
||||
# Test 10: Effect chain -> Sound pipeline
|
||||
processed = mcrfpy.SoundBuffer.tone(440, 0.3, "saw").low_pass(2000).normalize()
|
||||
proc_sound = mcrfpy.Sound(processed)
|
||||
assert proc_sound is not None
|
||||
assert proc_sound.duration > 0
|
||||
print(f"PASS: Effects -> Sound pipeline ({proc_sound.duration:.3f}s)")
|
||||
|
||||
# Test 11: Sound with invalid argument type
|
||||
try:
|
||||
mcrfpy.Sound(42)
|
||||
assert False, "Should have raised TypeError"
|
||||
except TypeError:
|
||||
pass
|
||||
print("PASS: Sound(int) raises TypeError")
|
||||
|
||||
# Test 12: Sound.buffer is None for file-loaded sounds
|
||||
if sound2 is not None:
|
||||
assert sound2.buffer is None
|
||||
print("PASS: Sound.buffer is None for file-loaded sounds")
|
||||
else:
|
||||
print("PASS: Sound.buffer test skipped (file not available)")
|
||||
|
||||
print("\nAll soundbuffer_sound tests passed!")
|
||||
sys.exit(0)
|
||||
68
tests/unit/soundbuffer_tone_test.py
Normal file
68
tests/unit/soundbuffer_tone_test.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Test SoundBuffer tone generation."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
# Test 1: Basic sine tone
|
||||
buf = mcrfpy.SoundBuffer.tone(440, 0.5, "sine")
|
||||
assert buf is not None
|
||||
assert abs(buf.duration - 0.5) < 0.02, f"Expected ~0.5s, got {buf.duration}"
|
||||
assert buf.sample_rate == 44100
|
||||
assert buf.channels == 1
|
||||
print("PASS: Sine tone 440Hz 0.5s")
|
||||
|
||||
# Test 2: Square wave
|
||||
buf = mcrfpy.SoundBuffer.tone(220, 0.3, "square")
|
||||
assert abs(buf.duration - 0.3) < 0.02
|
||||
print("PASS: Square wave")
|
||||
|
||||
# Test 3: Saw wave
|
||||
buf = mcrfpy.SoundBuffer.tone(330, 0.2, "saw")
|
||||
assert abs(buf.duration - 0.2) < 0.02
|
||||
print("PASS: Saw wave")
|
||||
|
||||
# Test 4: Triangle wave
|
||||
buf = mcrfpy.SoundBuffer.tone(550, 0.4, "triangle")
|
||||
assert abs(buf.duration - 0.4) < 0.02
|
||||
print("PASS: Triangle wave")
|
||||
|
||||
# Test 5: Noise
|
||||
buf = mcrfpy.SoundBuffer.tone(1000, 0.1, "noise")
|
||||
assert abs(buf.duration - 0.1) < 0.02
|
||||
print("PASS: Noise")
|
||||
|
||||
# Test 6: ADSR envelope
|
||||
buf = mcrfpy.SoundBuffer.tone(440, 1.0, "sine",
|
||||
attack=0.1, decay=0.2, sustain=0.5, release=0.3)
|
||||
assert abs(buf.duration - 1.0) < 0.02
|
||||
print("PASS: ADSR envelope")
|
||||
|
||||
# Test 7: Custom sample rate
|
||||
buf = mcrfpy.SoundBuffer.tone(440, 0.5, "sine", sample_rate=22050)
|
||||
assert buf.sample_rate == 22050
|
||||
assert abs(buf.duration - 0.5) < 0.02
|
||||
print("PASS: Custom sample rate")
|
||||
|
||||
# Test 8: Invalid waveform raises ValueError
|
||||
try:
|
||||
mcrfpy.SoundBuffer.tone(440, 0.5, "invalid_waveform")
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
print("PASS: Invalid waveform raises ValueError")
|
||||
|
||||
# Test 9: Negative duration raises ValueError
|
||||
try:
|
||||
mcrfpy.SoundBuffer.tone(440, -0.5, "sine")
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
print("PASS: Negative duration raises ValueError")
|
||||
|
||||
# Test 10: Samples are non-zero (tone actually generates audio)
|
||||
buf = mcrfpy.SoundBuffer.tone(440, 0.1, "sine")
|
||||
# In headless mode, sample_count should be nonzero
|
||||
assert buf.sample_count > 0, "Expected non-zero sample count"
|
||||
print(f"PASS: Tone has {buf.sample_count} samples")
|
||||
|
||||
print("\nAll soundbuffer_tone tests passed!")
|
||||
sys.exit(0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue