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,858 @@
"""McRogueFace Audio Synth Demo - SFXR Clone + Animalese Speech
Two-scene interactive demo showcasing the SoundBuffer procedural audio system:
- Scene 1 (SFXR): Full sfxr parameter editor with presets, waveform selection,
24 synthesis parameters, DSP effect chain, and real-time playback
- Scene 2 (Animalese): Animal Crossing-style speech synthesis with formant
generation, character personality presets, and text-to-speech playback
Controls:
SFXR Scene: SPACE=play, R=randomize, M=mutate, 1-4=waveform, TAB=switch
Animalese Scene: Type text, ENTER=speak, 1-5=personality, TAB=switch
Both: ESC=quit
"""
import mcrfpy
import sys
import random
# ============================================================
# Constants
# ============================================================
W, H = 1024, 768
# Retro sfxr color palette
C_BG = mcrfpy.Color(198, 186, 168)
C_PANEL = mcrfpy.Color(178, 166, 148)
C_BTN = mcrfpy.Color(158, 148, 135)
C_BTN_ON = mcrfpy.Color(115, 168, 115)
C_BTN_ACC = mcrfpy.Color(168, 115, 115)
C_TEXT = mcrfpy.Color(35, 30, 25)
C_LABEL = mcrfpy.Color(55, 48, 40)
C_HEADER = mcrfpy.Color(25, 20, 15)
C_SL_BG = mcrfpy.Color(80, 72, 62)
C_SL_FILL = mcrfpy.Color(192, 152, 58)
C_VALUE = mcrfpy.Color(68, 60, 50)
C_OUTLINE = mcrfpy.Color(95, 85, 72)
C_ACCENT = mcrfpy.Color(200, 75, 55)
C_BG2 = mcrfpy.Color(45, 50, 65)
C_BG2_PNL = mcrfpy.Color(55, 62, 78)
# ============================================================
# Shared State
# ============================================================
class S:
"""Global mutable state."""
wave_type = 0
params = {
'env_attack': 0.0, 'env_sustain': 0.3, 'env_punch': 0.0,
'env_decay': 0.4,
'base_freq': 0.3, 'freq_limit': 0.0, 'freq_ramp': 0.0,
'freq_dramp': 0.0,
'vib_strength': 0.0, 'vib_speed': 0.0,
'arp_mod': 0.0, 'arp_speed': 0.0,
'duty': 0.0, 'duty_ramp': 0.0,
'repeat_speed': 0.0,
'pha_offset': 0.0, 'pha_ramp': 0.0,
'lpf_freq': 1.0, 'lpf_ramp': 0.0, 'lpf_resonance': 0.0,
'hpf_freq': 0.0, 'hpf_ramp': 0.0,
}
volume = 80.0
auto_play = True
# Post-processing DSP
fx_on = {
'low_pass': False, 'high_pass': False, 'echo': False,
'reverb': False, 'distortion': False, 'bit_crush': False,
}
# Animalese
text = "HELLO WORLD"
base_pitch = 180.0
speech_rate = 12.0
pitch_jitter = 2.0
breathiness = 0.2
# UI refs (populated during setup)
sliders = {}
wave_btns = []
fx_btns = {}
text_cap = None
letter_cap = None
speak_idx = 0
speaking = False
# Prevent GC of sound/timer objects
sound = None
anim_sound = None
speak_timer = None
# Scene refs
sfxr_scene = None
anim_scene = None
# Animalese sliders
anim_sliders = {}
# ============================================================
# UI Helpers
# ============================================================
# Keep all widget objects alive
_widgets = []
def _cap(parent, x, y, text, size=11, color=None):
"""Add a Caption to parent.children."""
c = mcrfpy.Caption(text=text, pos=(x, y),
fill_color=color or C_LABEL)
c.font_size = size
parent.children.append(c)
return c
def _btn(parent, x, y, w, h, label, cb, color=None, fsize=11):
"""Clickable button frame with centered text."""
f = mcrfpy.Frame(pos=(x, y), size=(w, h),
fill_color=color or C_BTN,
outline_color=C_OUTLINE, outline=1.0)
parent.children.append(f)
tx = max(2, (w - len(label) * fsize * 0.58) / 2)
ty = max(1, (h - fsize) / 2)
c = mcrfpy.Caption(text=label, pos=(int(tx), int(ty)),
fill_color=C_TEXT)
c.font_size = fsize
f.children.append(c)
def click(pos, button, action):
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED:
cb()
f.on_click = click
c.on_click = click
return f, c
class Slider:
"""Horizontal slider widget with label and value display."""
def __init__(self, parent, x, y, label, lo, hi, val, cb,
sw=140, sh=10, lw=108):
_widgets.append(self)
self.lo, self.hi, self.val, self.cb = lo, hi, val, cb
self.sw = sw
self.tx = x + lw # track absolute x
# label
_cap(parent, x, y, label)
# track
self.track = mcrfpy.Frame(
pos=(self.tx, y), size=(sw, sh),
fill_color=C_SL_BG, outline_color=C_OUTLINE, outline=1.0)
parent.children.append(self.track)
# fill
pct = self._pct(val)
self.fill = mcrfpy.Frame(
pos=(0, 0), size=(max(1, int(sw * pct)), sh),
fill_color=C_SL_FILL)
self.track.children.append(self.fill)
# value text
self.vcap = mcrfpy.Caption(
text=self._fmt(val),
pos=(self.tx + sw + 4, y), fill_color=C_VALUE)
self.vcap.font_size = 10
parent.children.append(self.vcap)
self.track.on_click = self._click
self.fill.on_click = self._click
def _pct(self, v):
r = self.hi - self.lo
return (v - self.lo) / r if r else 0.0
def _fmt(self, v):
if abs(v) < 0.001 and v != 0:
return f"{v:.4f}"
return f"{v:.3f}"
def _click(self, pos, button, action):
if button != mcrfpy.MouseButton.LEFT:
return
if action != mcrfpy.InputState.PRESSED:
return
p = max(0.0, min(1.0, (pos.x - self.tx) / self.sw))
self.val = self.lo + p * (self.hi - self.lo)
self.fill.w = max(1, int(self.sw * p))
self.vcap.text = self._fmt(self.val)
self.cb(self.val)
def set(self, v):
self.val = max(self.lo, min(self.hi, v))
p = self._pct(self.val)
self.fill.w = max(1, int(self.sw * p))
self.vcap.text = self._fmt(self.val)
# ============================================================
# SFXR Audio Logic
# ============================================================
def play_sfxr():
"""Generate sfxr buffer from current params and play it."""
p = dict(S.params)
p['wave_type'] = S.wave_type
try:
buf = mcrfpy.SoundBuffer.sfxr(**p)
except Exception as e:
print(f"sfxr generation error: {e}")
return
# Post-processing DSP chain
if S.fx_on['low_pass']:
buf = buf.low_pass(2000.0)
if S.fx_on['high_pass']:
buf = buf.high_pass(500.0)
if S.fx_on['echo']:
buf = buf.echo(200.0, 0.4, 0.5)
if S.fx_on['reverb']:
buf = buf.reverb(0.8, 0.5, 0.3)
if S.fx_on['distortion']:
buf = buf.distortion(2.0)
if S.fx_on['bit_crush']:
buf = buf.bit_crush(8, 4)
buf = buf.normalize()
if buf.sample_count == 0:
# Some param combos produce silence (e.g. freq_limit > base_freq)
return
S.sound = mcrfpy.Sound(buf)
S.sound.volume = S.volume
S.sound.play()
def load_preset(name):
"""Load sfxr preset, sync UI, optionally auto-play."""
try:
buf = mcrfpy.SoundBuffer.sfxr(name)
except Exception as e:
print(f"Preset error: {e}")
return
mp = buf.sfxr_params
if not mp:
return
S.wave_type = int(mp.get('wave_type', 0))
for k in S.params:
if k in mp:
S.params[k] = mp[k]
_sync_sfxr_ui()
if S.auto_play:
play_sfxr()
def mutate_sfxr():
"""Mutate current params slightly."""
p = dict(S.params)
p['wave_type'] = S.wave_type
try:
buf = mcrfpy.SoundBuffer.sfxr(**p)
m = buf.sfxr_mutate(0.05)
except Exception as e:
print(f"Mutate error: {e}")
return
mp = m.sfxr_params
if mp:
S.wave_type = int(mp.get('wave_type', S.wave_type))
for k in S.params:
if k in mp:
S.params[k] = mp[k]
_sync_sfxr_ui()
if S.auto_play:
play_sfxr()
def randomize_sfxr():
"""Load a random preset with random seed."""
presets = ["coin", "laser", "explosion", "powerup", "hurt", "jump", "blip"]
buf = mcrfpy.SoundBuffer.sfxr(random.choice(presets),
seed=random.randint(0, 999999))
mp = buf.sfxr_params
if mp:
S.wave_type = int(mp.get('wave_type', 0))
for k in S.params:
if k in mp:
S.params[k] = mp[k]
_sync_sfxr_ui()
if S.auto_play:
play_sfxr()
def _sync_sfxr_ui():
"""Push state to all sfxr UI widgets."""
for k, sl in S.sliders.items():
if k in S.params:
sl.set(S.params[k])
_update_wave_btns()
def _update_wave_btns():
for i, (btn, _cap) in enumerate(S.wave_btns):
btn.fill_color = C_BTN_ON if i == S.wave_type else C_BTN
def set_wave(i):
S.wave_type = i
_update_wave_btns()
if S.auto_play:
play_sfxr()
def toggle_fx(key):
S.fx_on[key] = not S.fx_on[key]
if key in S.fx_btns:
S.fx_btns[key].fill_color = C_BTN_ON if S.fx_on[key] else C_BTN
# ============================================================
# Animalese Audio Logic
# ============================================================
# Vowel formant frequencies (F1, F2)
FORMANTS = {
'ah': (660, 1700),
'eh': (530, 1850),
'ee': (270, 2300),
'oh': (570, 870),
'oo': (300, 870),
}
LETTER_VOWEL = {}
for _c in 'AHLR':
LETTER_VOWEL[_c] = 'ah'
for _c in 'EDTSNZ':
LETTER_VOWEL[_c] = 'eh'
for _c in 'ICJY':
LETTER_VOWEL[_c] = 'ee'
for _c in 'OGKQX':
LETTER_VOWEL[_c] = 'oh'
for _c in 'UBFMPVW':
LETTER_VOWEL[_c] = 'oo'
CONSONANTS = set('BCDFGJKPQSTVXZ')
# Cache generated vowel base sounds per pitch
_vowel_cache = {}
def _make_vowel(vowel_key, pitch, breathiness):
"""Generate a single vowel sound (~120ms) at given pitch."""
f1, f2 = FORMANTS[vowel_key]
dur = 0.12
# Glottal source: sawtooth at fundamental
source = mcrfpy.SoundBuffer.tone(pitch, dur, "saw",
attack=0.005, decay=0.015, sustain=0.7, release=0.015)
# Formant approximation: low-pass at F1 frequency
# (single-pole filter, so we use a higher cutoff for approximation)
filtered = source.low_pass(float(f1) * 1.5)
# Add breathiness as noise
if breathiness > 0.05:
noise = mcrfpy.SoundBuffer.tone(1000, dur, "noise",
attack=0.003, decay=0.01, sustain=breathiness * 0.25,
release=0.01)
filtered = mcrfpy.SoundBuffer.mix([filtered, noise])
return filtered.normalize()
def _make_letter_sound(char, pitch, breathiness):
"""Generate audio for a single letter."""
ch = char.upper()
if ch not in LETTER_VOWEL:
return None
vowel = _make_vowel(LETTER_VOWEL[ch], pitch, breathiness)
# Add consonant noise burst
if ch in CONSONANTS:
burst = mcrfpy.SoundBuffer.tone(2500, 0.012, "noise",
attack=0.001, decay=0.003, sustain=0.6, release=0.003)
vowel = mcrfpy.SoundBuffer.concat([burst, vowel], overlap=0.004)
return vowel
def speak_text():
"""Generate and play animalese speech from current text."""
text = S.text.upper()
if not text.strip():
return
rate = S.speech_rate
letter_dur = 1.0 / rate
overlap = letter_dur * 0.25
bufs = []
for ch in text:
if ch == ' ':
# Short silence for spaces
sil = mcrfpy.SoundBuffer.from_samples(
b'\x00\x00' * int(44100 * 0.04), 1, 44100)
bufs.append(sil)
elif ch in '.!?':
# Longer pause for punctuation
sil = mcrfpy.SoundBuffer.from_samples(
b'\x00\x00' * int(44100 * 0.12), 1, 44100)
bufs.append(sil)
elif ch.isalpha():
# Pitch jitter in semitones
jitter = random.uniform(-S.pitch_jitter, S.pitch_jitter)
pitch = S.base_pitch * (2.0 ** (jitter / 12.0))
lsnd = _make_letter_sound(ch, pitch, S.breathiness)
if lsnd:
# Trim to letter duration
if lsnd.duration > letter_dur:
lsnd = lsnd.slice(0, letter_dur)
bufs.append(lsnd)
if not bufs:
return
result = mcrfpy.SoundBuffer.concat(bufs, overlap=overlap)
result = result.normalize()
# Optional: add room reverb for warmth
result = result.reverb(0.3, 0.5, 0.15)
S.anim_sound = mcrfpy.Sound(result)
S.anim_sound.volume = S.volume
S.anim_sound.play()
# Start letter animation
S.speak_idx = 0
S.speaking = True
if S.letter_cap:
S.letter_cap.text = ""
interval = int(1000.0 / S.speech_rate)
S.speak_timer = mcrfpy.Timer("speak_tick", _tick_letter, interval)
def _tick_letter(timer, runtime):
"""Advance the speaking letter display."""
text = S.text.upper()
if S.speak_idx < len(text):
ch = text[S.speak_idx]
if S.letter_cap:
S.letter_cap.text = ch if ch.strip() else "_"
S.speak_idx += 1
else:
if S.letter_cap:
S.letter_cap.text = ""
S.speaking = False
timer.stop()
# Personality presets
PERSONALITIES = {
'CRANKY': {'pitch': 90, 'rate': 10, 'jitter': 1.5, 'breath': 0.4},
'NORMAL': {'pitch': 180, 'rate': 12, 'jitter': 2.0, 'breath': 0.2},
'PEPPY': {'pitch': 280, 'rate': 18, 'jitter': 3.5, 'breath': 0.1},
'LAZY': {'pitch': 120, 'rate': 8, 'jitter': 1.0, 'breath': 0.5},
'JOCK': {'pitch': 100, 'rate': 15, 'jitter': 2.5, 'breath': 0.3},
}
def load_personality(name):
p = PERSONALITIES[name]
S.base_pitch = p['pitch']
S.speech_rate = p['rate']
S.pitch_jitter = p['jitter']
S.breathiness = p['breath']
_sync_anim_ui()
def _sync_anim_ui():
for k, sl in S.anim_sliders.items():
if k == 'pitch':
sl.set(S.base_pitch)
elif k == 'rate':
sl.set(S.speech_rate)
elif k == 'jitter':
sl.set(S.pitch_jitter)
elif k == 'breath':
sl.set(S.breathiness)
# ============================================================
# Build SFXR Scene
# ============================================================
def build_sfxr():
scene = mcrfpy.Scene("sfxr")
bg = mcrfpy.Frame(pos=(0, 0), size=(W, H), fill_color=C_BG)
scene.children.append(bg)
# --- Left Panel: Presets ---
_cap(bg, 12, 8, "GENERATOR", size=13, color=C_HEADER)
presets = [
("PICKUP/COIN", "coin"),
("LASER/SHOOT", "laser"),
("EXPLOSION", "explosion"),
("POWERUP", "powerup"),
("HIT/HURT", "hurt"),
("JUMP", "jump"),
("BLIP/SELECT", "blip"),
]
py = 30
for label, preset in presets:
_btn(bg, 10, py, 118, 22, label,
lambda p=preset: load_preset(p))
py += 26
py += 10
_btn(bg, 10, py, 118, 22, "MUTATE", mutate_sfxr, color=C_BTN_ACC)
py += 26
_btn(bg, 10, py, 118, 22, "RANDOMIZE", randomize_sfxr, color=C_BTN_ACC)
# --- Top: Waveform Selection ---
_cap(bg, 145, 8, "MANUAL SETTINGS", size=13, color=C_HEADER)
wave_names = ["SQUARE", "SAWTOOTH", "SINEWAVE", "NOISE"]
S.wave_btns = []
for i, wn in enumerate(wave_names):
bx = 145 + i * 105
b, c = _btn(bg, bx, 28, 100, 22, wn,
lambda idx=i: set_wave(idx))
S.wave_btns.append((b, c))
_update_wave_btns()
# --- Center: SFXR Parameter Sliders ---
# Column 1
col1_x = 140
col1_params = [
("ATTACK TIME", 'env_attack', 0.0, 1.0),
("SUSTAIN TIME", 'env_sustain', 0.0, 1.0),
("SUSTAIN PUNCH", 'env_punch', 0.0, 1.0),
("DECAY TIME", 'env_decay', 0.0, 1.0),
("", None, 0, 0), # spacer
("START FREQ", 'base_freq', 0.0, 1.0),
("MIN FREQ", 'freq_limit', 0.0, 1.0),
("SLIDE", 'freq_ramp', -1.0, 1.0),
("DELTA SLIDE", 'freq_dramp', -1.0, 1.0),
("", None, 0, 0),
("VIB DEPTH", 'vib_strength', 0.0, 1.0),
("VIB SPEED", 'vib_speed', 0.0, 1.0),
]
cy = 58
ROW = 22
for label, key, lo, hi in col1_params:
if key is None:
cy += 8
continue
val = S.params[key]
sl = Slider(bg, col1_x, cy, label, lo, hi, val,
lambda v, k=key: _sfxr_param_changed(k, v),
sw=140, lw=108)
S.sliders[key] = sl
cy += ROW
# Column 2
col2_x = 530
col2_params = [
("SQUARE DUTY", 'duty', 0.0, 1.0),
("DUTY SWEEP", 'duty_ramp', -1.0, 1.0),
("", None, 0, 0),
("REPEAT SPEED", 'repeat_speed', 0.0, 1.0),
("", None, 0, 0),
("PHA OFFSET", 'pha_offset', -1.0, 1.0),
("PHA SWEEP", 'pha_ramp', -1.0, 1.0),
("", None, 0, 0),
("LP CUTOFF", 'lpf_freq', 0.0, 1.0),
("LP SWEEP", 'lpf_ramp', -1.0, 1.0),
("LP RESONANCE", 'lpf_resonance', 0.0, 1.0),
("HP CUTOFF", 'hpf_freq', 0.0, 1.0),
("HP SWEEP", 'hpf_ramp', -1.0, 1.0),
]
cy = 58
for label, key, lo, hi in col2_params:
if key is None:
cy += 8
continue
val = S.params[key]
sl = Slider(bg, col2_x, cy, label, lo, hi, val,
lambda v, k=key: _sfxr_param_changed(k, v),
sw=140, lw=108)
S.sliders[key] = sl
cy += ROW
# Column 2 extras: arpeggiation
col2_params2 = [
("ARP MOD", 'arp_mod', -1.0, 1.0),
("ARP SPEED", 'arp_speed', 0.0, 1.0),
]
cy += 8
for label, key, lo, hi in col2_params2:
val = S.params[key]
sl = Slider(bg, col2_x, cy, label, lo, hi, val,
lambda v, k=key: _sfxr_param_changed(k, v),
sw=140, lw=108)
S.sliders[key] = sl
cy += ROW
# --- Right Panel ---
rx = 790
# Volume
_cap(bg, rx, 8, "VOLUME", size=12, color=C_HEADER)
Slider(bg, rx, 26, "", 0, 100, S.volume,
lambda v: setattr(S, 'volume', v),
sw=180, lw=0)
# Play button
_btn(bg, rx, 50, 180, 28, "PLAY SOUND", play_sfxr,
color=mcrfpy.Color(180, 100, 80), fsize=13)
# Auto-play toggle
auto_btn, auto_cap = _btn(bg, rx, 86, 180, 22, "AUTO-PLAY: ON",
lambda: None, color=C_BTN_ON)
def toggle_auto():
S.auto_play = not S.auto_play
auto_btn.fill_color = C_BTN_ON if S.auto_play else C_BTN
auto_cap.text = "AUTO-PLAY: ON" if S.auto_play else "AUTO-PLAY: OFF"
auto_btn.on_click = lambda p, b, a: (
toggle_auto() if b == mcrfpy.MouseButton.LEFT
and a == mcrfpy.InputState.PRESSED else None)
auto_cap.on_click = auto_btn.on_click
# DSP Effects
_cap(bg, rx, 120, "DSP EFFECTS", size=12, color=C_HEADER)
fx_list = [
("LOW PASS", 'low_pass'),
("HIGH PASS", 'high_pass'),
("ECHO", 'echo'),
("REVERB", 'reverb'),
("DISTORTION", 'distortion'),
("BIT CRUSH", 'bit_crush'),
]
fy = 140
for label, key in fx_list:
fb, fc = _btn(bg, rx, fy, 180, 20, label,
lambda k=key: toggle_fx(k))
S.fx_btns[key] = fb
fy += 24
# Navigation
_cap(bg, rx, fy + 16, "NAVIGATION", size=12, color=C_HEADER)
_btn(bg, rx, fy + 36, 180, 26, "ANIMALESE >>",
lambda: setattr(mcrfpy, 'current_scene', S.anim_scene))
# --- Keyboard hints ---
hints_y = H - 90
_cap(bg, 10, hints_y, "Keyboard:", size=11, color=C_HEADER)
_cap(bg, 10, hints_y + 16, "SPACE = Play R = Randomize M = Mutate",
size=10, color=C_VALUE)
_cap(bg, 10, hints_y + 30, "1-4 = Waveform TAB = Animalese ESC = Quit",
size=10, color=C_VALUE)
# --- Key handler ---
def on_key(key, action):
if action != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.ESCAPE:
sys.exit(0)
elif key == mcrfpy.Key.TAB:
mcrfpy.current_scene = S.anim_scene
elif key == mcrfpy.Key.SPACE:
play_sfxr()
elif key == mcrfpy.Key.R:
randomize_sfxr()
elif key == mcrfpy.Key.M:
mutate_sfxr()
elif key == mcrfpy.Key.NUM_1:
set_wave(0)
elif key == mcrfpy.Key.NUM_2:
set_wave(1)
elif key == mcrfpy.Key.NUM_3:
set_wave(2)
elif key == mcrfpy.Key.NUM_4:
set_wave(3)
scene.on_key = on_key
return scene
def _sfxr_param_changed(key, val):
"""Called when a slider changes an sfxr param."""
S.params[key] = val
if S.auto_play:
play_sfxr()
# ============================================================
# Build Animalese Scene
# ============================================================
def build_animalese():
scene = mcrfpy.Scene("animalese")
bg = mcrfpy.Frame(pos=(0, 0), size=(W, H), fill_color=C_BG2)
scene.children.append(bg)
# Title
_cap(bg, 20, 10, "ANIMALESE SPEECH SYNTH", size=16, color=mcrfpy.Color(220, 215, 200))
# --- Text Display ---
_cap(bg, 20, 50, "TEXT (type to edit, ENTER to speak):", size=11,
color=mcrfpy.Color(160, 155, 140))
# Text input display
text_frame = mcrfpy.Frame(pos=(20, 70), size=(700, 36),
fill_color=mcrfpy.Color(30, 35, 48),
outline_color=mcrfpy.Color(100, 110, 130),
outline=1.0)
bg.children.append(text_frame)
S.text_cap = mcrfpy.Caption(text=S.text + "_", pos=(6, 8),
fill_color=mcrfpy.Color(220, 220, 180))
S.text_cap.font_size = 16
text_frame.children.append(S.text_cap)
# Current letter display (large)
_cap(bg, 740, 50, "NOW:", size=11, color=mcrfpy.Color(160, 155, 140))
S.letter_cap = mcrfpy.Caption(text="", pos=(740, 68),
fill_color=C_ACCENT)
S.letter_cap.font_size = 42
bg.children.append(S.letter_cap)
# --- Personality Presets ---
_cap(bg, 20, 120, "CHARACTER PRESETS", size=13,
color=mcrfpy.Color(200, 195, 180))
px = 20
for name in ['CRANKY', 'NORMAL', 'PEPPY', 'LAZY', 'JOCK']:
_btn(bg, px, 142, 95, 24, name,
lambda n=name: load_personality(n),
color=C_BG2_PNL)
px += 102
# --- Voice Parameters ---
_cap(bg, 20, 185, "VOICE PARAMETERS", size=13,
color=mcrfpy.Color(200, 195, 180))
sy = 208
S.anim_sliders['pitch'] = Slider(
bg, 20, sy, "BASE PITCH", 60, 350, S.base_pitch,
lambda v: setattr(S, 'base_pitch', v),
sw=200, lw=110)
sy += 28
S.anim_sliders['rate'] = Slider(
bg, 20, sy, "SPEECH RATE", 4, 24, S.speech_rate,
lambda v: setattr(S, 'speech_rate', v),
sw=200, lw=110)
sy += 28
S.anim_sliders['jitter'] = Slider(
bg, 20, sy, "PITCH JITTER", 0, 6, S.pitch_jitter,
lambda v: setattr(S, 'pitch_jitter', v),
sw=200, lw=110)
sy += 28
S.anim_sliders['breath'] = Slider(
bg, 20, sy, "BREATHINESS", 0, 1.0, S.breathiness,
lambda v: setattr(S, 'breathiness', v),
sw=200, lw=110)
# --- Speak Button ---
_btn(bg, 20, sy + 38, 200, 32, "SPEAK", speak_text,
color=mcrfpy.Color(80, 140, 80), fsize=14)
# --- Formant Reference ---
ry = 185
_cap(bg, 550, ry, "LETTER -> VOWEL MAPPING", size=12,
color=mcrfpy.Color(180, 175, 160))
ry += 22
mappings = [
("A H L R", "-> 'ah' (F1=660, F2=1700)"),
("E D T S N Z", "-> 'eh' (F1=530, F2=1850)"),
("I C J Y", "-> 'ee' (F1=270, F2=2300)"),
("O G K Q X", "-> 'oh' (F1=570, F2=870)"),
("U B F M P V W", "-> 'oo' (F1=300, F2=870)"),
]
for letters, desc in mappings:
_cap(bg, 555, ry, letters, size=11,
color=mcrfpy.Color(200, 180, 120))
_cap(bg, 680, ry, desc, size=10,
color=mcrfpy.Color(140, 135, 125))
ry += 18
_cap(bg, 555, ry + 8, "Consonants (B,C,D,...) add", size=10,
color=mcrfpy.Color(120, 115, 105))
_cap(bg, 555, ry + 22, "a noise burst before the vowel", size=10,
color=mcrfpy.Color(120, 115, 105))
# --- How it works ---
hy = 420
_cap(bg, 20, hy, "HOW IT WORKS", size=13,
color=mcrfpy.Color(200, 195, 180))
steps = [
"1. Each letter maps to a vowel class (ah/eh/ee/oh/oo)",
"2. Sawtooth tone at base_pitch filtered through low_pass (formant F1)",
"3. Noise mixed in for breathiness, burst prepended for consonants",
"4. Pitch jittered per-letter for natural variation",
"5. Letters concatenated with overlap for babble effect",
"6. Light reverb applied for warmth",
]
for i, step in enumerate(steps):
_cap(bg, 25, hy + 22 + i * 17, step, size=10,
color=mcrfpy.Color(140, 138, 128))
# --- Navigation ---
_btn(bg, 20, H - 50, 200, 28, "<< SFXR SYNTH",
lambda: setattr(mcrfpy, 'current_scene', S.sfxr_scene),
color=C_BG2_PNL)
# --- Keyboard hints ---
_cap(bg, 250, H - 46, "Type letters to edit text | ENTER = Speak | "
"1-5 = Presets | TAB = SFXR | ESC = Quit",
size=10, color=mcrfpy.Color(110, 108, 98))
# Build key-to-char map
key_chars = {}
for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
k = getattr(mcrfpy.Key, c, None)
if k is not None:
key_chars[k] = c
# --- Key handler ---
def on_key(key, action):
if action != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.ESCAPE:
sys.exit(0)
elif key == mcrfpy.Key.TAB:
mcrfpy.current_scene = S.sfxr_scene
elif key == mcrfpy.Key.ENTER:
speak_text()
elif key == mcrfpy.Key.BACKSPACE:
if S.text:
S.text = S.text[:-1]
S.text_cap.text = S.text + "_"
elif key == mcrfpy.Key.SPACE:
S.text += ' '
S.text_cap.text = S.text + "_"
elif key == mcrfpy.Key.NUM_1:
load_personality('CRANKY')
elif key == mcrfpy.Key.NUM_2:
load_personality('NORMAL')
elif key == mcrfpy.Key.NUM_3:
load_personality('PEPPY')
elif key == mcrfpy.Key.NUM_4:
load_personality('LAZY')
elif key == mcrfpy.Key.NUM_5:
load_personality('JOCK')
elif key in key_chars:
S.text += key_chars[key]
S.text_cap.text = S.text + "_"
scene.on_key = on_key
return scene
# ============================================================
# Main
# ============================================================
S.sfxr_scene = build_sfxr()
S.anim_scene = build_animalese()
mcrfpy.current_scene = S.sfxr_scene

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)

View 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)

View 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)

View 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)

View 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)

View 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)