McRogueFace/tests/unit/soundbuffer_waveform_test.py
John McCardle 732897426a Audio fixes: gain() DSP effect, sfxr phase wrap, SDL2 backend compat
- SoundBuffer.gain(factor): new DSP method for amplitude scaling before
  mixing (0.5 = half volume, 2.0 = double, clamped to int16 range)
- Fix sfxr square/saw waveform artifacts: phase now wraps at period
  boundary instead of growing unbounded; noise buffer refreshes per period
- Fix PySound construction from SoundBuffer on SDL2 backend: use
  loadFromSamples() directly instead of copy-assign (deleted on SDL2)
- Add Image::create(w, h, pixels) overload to HeadlessTypes and
  SDL2Types for pixel data initialization
- Waveform test suite (62 lines)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:17:41 -05:00

62 lines
3 KiB
Python

"""Test that sfxr waveforms produce sustained audio (not single-cycle pops).
Before the phase-wrap fix, square and sawtooth waveforms would only produce
one cycle of audio then become DC, resulting in very quiet output with pops.
After the fix, all waveforms should produce comparable output levels.
"""
import mcrfpy
import sys
# Generate each waveform with identical envelope params
WAVEFORMS = {0: "square", 1: "sawtooth", 2: "sine", 3: "noise"}
durations = {}
sample_counts = {}
for wt, name in WAVEFORMS.items():
buf = mcrfpy.SoundBuffer.sfxr(wave_type=wt, base_freq=0.3,
env_attack=0.0, env_sustain=0.3, env_decay=0.4)
durations[name] = buf.duration
sample_counts[name] = buf.sample_count
print(f"{name}: {buf.sample_count} samples, {buf.duration:.4f}s")
# All waveforms should produce similar duration (same envelope)
# Before fix, they all had the same envelope params so durations should match
for name, dur in durations.items():
assert dur > 0.1, f"FAIL: {name} duration too short ({dur:.4f}s)"
print(f" {name} duration OK: {dur:.4f}s")
# Test that normalize() on a middle slice doesn't massively amplify
# (If the signal is DC/near-silent, normalize would boost enormously,
# changing sample values from near-0 to near-max. With sustained waveforms,
# the signal is already substantial so normalize has less effect.)
for wt, name in [(0, "square"), (1, "sawtooth")]:
buf = mcrfpy.SoundBuffer.sfxr(wave_type=wt, base_freq=0.3,
env_attack=0.0, env_sustain=0.3, env_decay=0.4)
# Slice the sustain portion (not attack/decay edges)
mid = buf.slice(0.05, 0.15)
if mid.sample_count > 0:
# Apply pitch_shift as a transformation test - should change duration
shifted = mid.pitch_shift(2.0)
expected_count = mid.sample_count // 2
actual_count = shifted.sample_count
ratio = actual_count / max(1, expected_count)
print(f" {name} pitch_shift(2.0): {mid.sample_count} -> {shifted.sample_count} "
f"(expected ~{expected_count}, ratio={ratio:.2f})")
assert 0.8 < ratio < 1.2, f"FAIL: {name} pitch shift ratio off ({ratio:.2f})"
else:
print(f" {name} slice returned empty (skipping pitch test)")
# Generate a tone and sfxr with same waveform to compare
# The tone generator was already working, sfxr was broken
tone_sq = mcrfpy.SoundBuffer.tone(440, 0.3, "square")
sfxr_sq = mcrfpy.SoundBuffer.sfxr(wave_type=0, base_freq=0.5,
env_attack=0.0, env_sustain=0.3, env_decay=0.0)
print(f"\nComparison - tone square: {tone_sq.sample_count} samples, {tone_sq.duration:.4f}s")
print(f"Comparison - sfxr square: {sfxr_sq.sample_count} samples, {sfxr_sq.duration:.4f}s")
# Both should have substantial sample counts
assert tone_sq.sample_count > 10000, f"FAIL: tone square too short"
assert sfxr_sq.sample_count > 5000, f"FAIL: sfxr square too short"
print("\nPASS: All waveform tests passed")
sys.exit(0)