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

@ -57,6 +57,7 @@ include_directories(${CMAKE_SOURCE_DIR}/src/3d)
include_directories(${CMAKE_SOURCE_DIR}/src/platform)
include_directories(${CMAKE_SOURCE_DIR}/src/tiled)
include_directories(${CMAKE_SOURCE_DIR}/src/ldtk)
include_directories(${CMAKE_SOURCE_DIR}/src/audio)
include_directories(${CMAKE_SOURCE_DIR}/modules/RapidXML)
include_directories(${CMAKE_SOURCE_DIR}/modules/json/single_include)

View file

@ -17,6 +17,7 @@
#include "PyMouseButton.h"
#include "PyInputState.h"
#include "PySound.h"
#include "PySoundBuffer.h"
#include "PyMusic.h"
#include "PyKeyboard.h"
#include "PyMouse.h"
@ -467,6 +468,7 @@ PyObject* PyInit_mcrfpy()
/*audio (#66)*/
&PySoundType,
&PySoundBufferType,
&PyMusicType,
/*keyboard state (#160)*/

View file

@ -1,7 +1,9 @@
#include "PySound.h"
#include "PySoundBuffer.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
#include <sstream>
#include <random>
PySound::PySound(const std::string& filename)
: source(filename), loaded(false)
@ -12,6 +14,16 @@ PySound::PySound(const std::string& filename)
}
}
PySound::PySound(std::shared_ptr<SoundBufferData> bufData)
: source("<SoundBuffer>"), loaded(false), bufferData(bufData)
{
if (bufData && !bufData->samples.empty()) {
buffer = bufData->getSfBuffer();
sound.setBuffer(buffer);
loaded = true;
}
}
void PySound::play()
{
if (loaded) {
@ -64,6 +76,16 @@ float PySound::getDuration() const
return buffer.getDuration().asSeconds();
}
float PySound::getPitch() const
{
return sound.getPitch();
}
void PySound::setPitch(float pitch)
{
sound.setPitch(std::max(0.01f, pitch));
}
PyObject* PySound::pyObject()
{
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sound");
@ -102,19 +124,42 @@ Py_hash_t PySound::hash(PyObject* obj)
int PySound::init(PySoundObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = {"filename", nullptr};
const char* filename = nullptr;
// Accept either a string filename or a SoundBuffer object
PyObject* source_obj = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char**>(keywords), &filename)) {
if (!PyArg_ParseTuple(args, "O", &source_obj)) {
return -1;
}
if (PyUnicode_Check(source_obj)) {
// String filename path
const char* filename = PyUnicode_AsUTF8(source_obj);
if (!filename) return -1;
self->data = std::make_shared<PySound>(filename);
if (!self->data->loaded) {
PyErr_Format(PyExc_RuntimeError, "Failed to load sound file: %s", filename);
return -1;
}
} else if (PyObject_IsInstance(source_obj, (PyObject*)&mcrfpydef::PySoundBufferType)) {
// SoundBuffer object
auto* sbObj = (PySoundBufferObject*)source_obj;
if (!sbObj->data || sbObj->data->samples.empty()) {
PyErr_SetString(PyExc_RuntimeError, "SoundBuffer is empty or invalid");
return -1;
}
self->data = std::make_shared<PySound>(sbObj->data);
if (!self->data->loaded) {
PyErr_SetString(PyExc_RuntimeError, "Failed to create sound from SoundBuffer");
return -1;
}
} else {
PyErr_SetString(PyExc_TypeError, "Sound() argument must be a filename string or SoundBuffer");
return -1;
}
return 0;
}
@ -155,6 +200,43 @@ PyObject* PySound::py_stop(PySoundObject* self, PyObject* args)
Py_RETURN_NONE;
}
PyObject* PySound::py_play_varied(PySoundObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = {"pitch_range", "volume_range", nullptr};
double pitch_range = 0.1;
double volume_range = 3.0;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|dd", const_cast<char**>(keywords),
&pitch_range, &volume_range)) {
return NULL;
}
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
// Save original values
float origPitch = self->data->getPitch();
float origVolume = self->data->getVolume();
// Randomize
static std::mt19937 rng(std::random_device{}());
std::uniform_real_distribution<double> pitchDist(-pitch_range, pitch_range);
std::uniform_real_distribution<double> volDist(-volume_range, volume_range);
self->data->setPitch(std::max(0.01f, origPitch + static_cast<float>(pitchDist(rng))));
self->data->setVolume(std::max(0.0f, std::min(100.0f, origVolume + static_cast<float>(volDist(rng)))));
self->data->play();
// Restore originals (SFML will use the values set at play time)
// Note: we restore after play() so the variation applies only to this instance
self->data->setPitch(origPitch);
self->data->setVolume(origVolume);
Py_RETURN_NONE;
}
// Property getters/setters
PyObject* PySound::get_volume(PySoundObject* self, void* closure)
{
@ -225,6 +307,42 @@ PyObject* PySound::get_source(PySoundObject* self, void* closure)
return PyUnicode_FromString(self->data->source.c_str());
}
PyObject* PySound::get_pitch(PySoundObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
return PyFloat_FromDouble(self->data->getPitch());
}
int PySound::set_pitch(PySoundObject* self, PyObject* value, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return -1;
}
float pitch = PyFloat_AsDouble(value);
if (PyErr_Occurred()) {
return -1;
}
self->data->setPitch(pitch);
return 0;
}
PyObject* PySound::get_buffer(PySoundObject* self, void* closure)
{
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid");
return NULL;
}
auto bufData = self->data->getBufferData();
if (!bufData) {
Py_RETURN_NONE;
}
return PySoundBuffer_from_data(bufData);
}
PyMethodDef PySound::methods[] = {
{"play", (PyCFunction)PySound::py_play, METH_NOARGS,
MCRF_METHOD(Sound, play,
@ -241,6 +359,14 @@ PyMethodDef PySound::methods[] = {
MCRF_SIG("()", "None"),
MCRF_DESC("Stop playing and reset to the beginning.")
)},
{"play_varied", (PyCFunction)PySound::py_play_varied, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Sound, play_varied,
MCRF_SIG("(pitch_range: float = 0.1, volume_range: float = 3.0)", "None"),
MCRF_DESC("Play with randomized pitch and volume for natural variation."),
MCRF_ARGS_START
MCRF_ARG("pitch_range", "Random pitch offset range (default 0.1)")
MCRF_ARG("volume_range", "Random volume offset range (default 3.0)")
)},
{NULL}
};
@ -255,5 +381,9 @@ PyGetSetDef PySound::getsetters[] = {
MCRF_PROPERTY(duration, "Total duration of the sound in seconds (read-only)."), NULL},
{"source", (getter)PySound::get_source, NULL,
MCRF_PROPERTY(source, "Filename path used to load this sound (read-only)."), NULL},
{"pitch", (getter)PySound::get_pitch, (setter)PySound::set_pitch,
MCRF_PROPERTY(pitch, "Playback pitch multiplier (1.0 = normal, >1 = higher, <1 = lower)."), NULL},
{"buffer", (getter)PySound::get_buffer, NULL,
MCRF_PROPERTY(buffer, "The SoundBuffer if created from one, else None (read-only)."), NULL},
{NULL}
};

View file

@ -3,6 +3,7 @@
#include "Python.h"
class PySound;
class SoundBufferData;
typedef struct {
PyObject_HEAD
@ -17,8 +18,12 @@ private:
std::string source;
bool loaded;
// SoundBuffer support: if created from a SoundBuffer, store reference
std::shared_ptr<SoundBufferData> bufferData;
public:
PySound(const std::string& filename);
PySound(std::shared_ptr<SoundBufferData> bufData);
// Playback control
void play();
@ -33,6 +38,13 @@ public:
bool isPlaying() const;
float getDuration() const;
// Pitch
float getPitch() const;
void setPitch(float pitch);
// Buffer data access
std::shared_ptr<SoundBufferData> getBufferData() const { return bufferData; }
// Python interface
PyObject* pyObject();
static PyObject* repr(PyObject* obj);
@ -44,6 +56,7 @@ public:
static PyObject* py_play(PySoundObject* self, PyObject* args);
static PyObject* py_pause(PySoundObject* self, PyObject* args);
static PyObject* py_stop(PySoundObject* self, PyObject* args);
static PyObject* py_play_varied(PySoundObject* self, PyObject* args, PyObject* kwds);
// Python getters/setters
static PyObject* get_volume(PySoundObject* self, void* closure);
@ -53,6 +66,9 @@ public:
static PyObject* get_playing(PySoundObject* self, void* closure);
static PyObject* get_duration(PySoundObject* self, void* closure);
static PyObject* get_source(PySoundObject* self, void* closure);
static PyObject* get_pitch(PySoundObject* self, void* closure);
static int set_pitch(PySoundObject* self, PyObject* value, void* closure);
static PyObject* get_buffer(PySoundObject* self, void* closure);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
@ -67,7 +83,20 @@ namespace mcrfpydef {
.tp_repr = PySound::repr,
.tp_hash = PySound::hash,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("Sound effect object for short audio clips"),
.tp_doc = PyDoc_STR(
"Sound(source)\n\n"
"Sound effect object for short audio clips.\n\n"
"Args:\n"
" source: Filename string or SoundBuffer object.\n\n"
"Properties:\n"
" volume (float): Volume 0-100.\n"
" loop (bool): Whether to loop.\n"
" playing (bool, read-only): True if playing.\n"
" duration (float, read-only): Duration in seconds.\n"
" source (str, read-only): Source filename.\n"
" pitch (float): Playback pitch (1.0 = normal).\n"
" buffer (SoundBuffer, read-only): The SoundBuffer, if created from one.\n"
),
.tp_methods = PySound::methods,
.tp_getset = PySound::getsetters,
.tp_init = (initproc)PySound::init,

766
src/PySoundBuffer.cpp Normal file
View file

@ -0,0 +1,766 @@
#include "PySoundBuffer.h"
#include "audio/SfxrSynth.h"
#include "audio/AudioEffects.h"
#include <sstream>
#include <cmath>
#include <random>
#include <algorithm>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
// Helper: create a Python SoundBuffer wrapping given data
PyObject* PySoundBuffer_from_data(std::shared_ptr<SoundBufferData> data) {
auto* obj = (PySoundBufferObject*)mcrfpydef::PySoundBufferType.tp_alloc(&mcrfpydef::PySoundBufferType, 0);
if (obj) {
new (&obj->data) std::shared_ptr<SoundBufferData>(std::move(data));
}
return (PyObject*)obj;
}
// ============================================================================
// Type infrastructure
// ============================================================================
PyObject* PySoundBuffer::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) {
auto* self = (PySoundBufferObject*)type->tp_alloc(type, 0);
if (self) {
new (&self->data) std::shared_ptr<SoundBufferData>();
}
return (PyObject*)self;
}
int PySoundBuffer::init(PySoundBufferObject* self, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"filename", nullptr};
const char* filename = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char**>(keywords), &filename)) {
return -1;
}
// Load from file via sf::SoundBuffer
sf::SoundBuffer tmpBuf;
if (!tmpBuf.loadFromFile(filename)) {
PyErr_Format(PyExc_RuntimeError, "Failed to load sound file: %s", filename);
return -1;
}
// Extract samples from the loaded buffer
auto data = std::make_shared<SoundBufferData>();
data->sampleRate = tmpBuf.getSampleRate();
data->channels = tmpBuf.getChannelCount();
// Copy sample data from sf::SoundBuffer
auto count = tmpBuf.getSampleCount();
if (count > 0) {
// SFML provides getSamples() on desktop; for headless/SDL2 we have no samples to copy
// On SFML desktop builds, sf::SoundBuffer has getSamples()
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
const sf::Int16* src = tmpBuf.getSamples();
data->samples.assign(src, src + count);
#else
// Headless/SDL2: samples not directly accessible from sf::SoundBuffer
// Create silence of the appropriate duration
float dur = tmpBuf.getDuration().asSeconds();
size_t numSamples = static_cast<size_t>(dur * data->sampleRate * data->channels);
data->samples.resize(numSamples, 0);
#endif
}
data->sfBufferDirty = true;
self->data = std::move(data);
return 0;
}
PyObject* PySoundBuffer::repr(PyObject* obj) {
auto* self = (PySoundBufferObject*)obj;
std::ostringstream ss;
if (!self->data) {
ss << "<SoundBuffer [invalid]>";
} else {
ss << "<SoundBuffer duration=" << std::fixed << std::setprecision(3)
<< self->data->duration() << "s samples=" << self->data->samples.size()
<< " rate=" << self->data->sampleRate
<< " ch=" << self->data->channels << ">";
}
std::string s = ss.str();
return PyUnicode_FromString(s.c_str());
}
// ============================================================================
// Properties
// ============================================================================
PyObject* PySoundBuffer::get_duration(PySoundBufferObject* self, void*) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
return PyFloat_FromDouble(self->data->duration());
}
PyObject* PySoundBuffer::get_sample_count(PySoundBufferObject* self, void*) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
return PyLong_FromSize_t(self->data->samples.size());
}
PyObject* PySoundBuffer::get_sample_rate(PySoundBufferObject* self, void*) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
return PyLong_FromUnsignedLong(self->data->sampleRate);
}
PyObject* PySoundBuffer::get_channels(PySoundBufferObject* self, void*) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
return PyLong_FromUnsignedLong(self->data->channels);
}
PyObject* PySoundBuffer::get_sfxr_params(PySoundBufferObject* self, void*) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
if (!self->data->sfxrParams) {
Py_RETURN_NONE;
}
return sfxr_params_to_dict(*self->data->sfxrParams);
}
// ============================================================================
// Class method: from_samples
// ============================================================================
PyObject* PySoundBuffer::from_samples(PyObject* cls, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"data", "channels", "sample_rate", nullptr};
Py_buffer buf;
unsigned int ch = 1;
unsigned int rate = 44100;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "y*II", const_cast<char**>(keywords),
&buf, &ch, &rate)) {
return NULL;
}
if (ch == 0 || rate == 0) {
PyBuffer_Release(&buf);
PyErr_SetString(PyExc_ValueError, "channels and sample_rate must be > 0");
return NULL;
}
size_t numSamples = buf.len / sizeof(int16_t);
auto data = std::make_shared<SoundBufferData>();
data->samples.resize(numSamples);
memcpy(data->samples.data(), buf.buf, numSamples * sizeof(int16_t));
data->channels = ch;
data->sampleRate = rate;
data->sfBufferDirty = true;
PyBuffer_Release(&buf);
return PySoundBuffer_from_data(std::move(data));
}
// ============================================================================
// Class method: tone
// ============================================================================
PyObject* PySoundBuffer::tone(PyObject* cls, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {
"frequency", "duration", "waveform",
"attack", "decay", "sustain", "release",
"sample_rate", nullptr
};
double freq = 440.0;
double dur = 0.5;
const char* waveform = "sine";
double attack = 0.01, decay_time = 0.0, sustain = 1.0, release = 0.01;
unsigned int rate = 44100;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "dd|sddddI", const_cast<char**>(keywords),
&freq, &dur, &waveform, &attack, &decay_time,
&sustain, &release, &rate)) {
return NULL;
}
if (dur <= 0.0 || freq <= 0.0) {
PyErr_SetString(PyExc_ValueError, "frequency and duration must be positive");
return NULL;
}
size_t totalSamples = static_cast<size_t>(dur * rate);
std::vector<int16_t> samples(totalSamples);
std::string wf(waveform);
std::mt19937 noiseRng(42); // Deterministic noise
// Generate waveform
for (size_t i = 0; i < totalSamples; i++) {
double t = static_cast<double>(i) / rate;
double phase = fmod(t * freq, 1.0);
double sample = 0.0;
if (wf == "sine") {
sample = sin(2.0 * M_PI * phase);
} else if (wf == "square") {
// PolyBLEP square
double naive = phase < 0.5 ? 1.0 : -1.0;
double dt = freq / rate;
// PolyBLEP correction at transitions
auto polyblep = [](double t, double dt) -> double {
if (t < dt) { t /= dt; return t + t - t * t - 1.0; }
if (t > 1.0 - dt) { t = (t - 1.0) / dt; return t * t + t + t + 1.0; }
return 0.0;
};
sample = naive + polyblep(phase, dt) - polyblep(fmod(phase + 0.5, 1.0), dt);
} else if (wf == "saw") {
// PolyBLEP saw
double naive = 2.0 * phase - 1.0;
double dt = freq / rate;
auto polyblep = [](double t, double dt) -> double {
if (t < dt) { t /= dt; return t + t - t * t - 1.0; }
if (t > 1.0 - dt) { t = (t - 1.0) / dt; return t * t + t + t + 1.0; }
return 0.0;
};
sample = naive - polyblep(phase, dt);
} else if (wf == "triangle") {
sample = 4.0 * fabs(phase - 0.5) - 1.0;
} else if (wf == "noise") {
std::uniform_real_distribution<double> dist(-1.0, 1.0);
sample = dist(noiseRng);
} else {
PyErr_Format(PyExc_ValueError,
"Unknown waveform '%s'. Use: sine, square, saw, triangle, noise", waveform);
return NULL;
}
// ADSR envelope
double env = 1.0;
double noteEnd = dur - release;
if (t < attack) {
env = (attack > 0.0) ? t / attack : 1.0;
} else if (t < attack + decay_time) {
double decayProgress = (decay_time > 0.0) ? (t - attack) / decay_time : 1.0;
env = 1.0 - (1.0 - sustain) * decayProgress;
} else if (t < noteEnd) {
env = sustain;
} else {
double releaseProgress = (release > 0.0) ? (t - noteEnd) / release : 1.0;
env = sustain * (1.0 - std::min(releaseProgress, 1.0));
}
sample *= env;
sample = std::max(-1.0, std::min(1.0, sample));
samples[i] = static_cast<int16_t>(sample * 32000.0);
}
auto data = std::make_shared<SoundBufferData>(std::move(samples), rate, 1);
return PySoundBuffer_from_data(std::move(data));
}
// ============================================================================
// Class method: sfxr
// ============================================================================
PyObject* PySoundBuffer::sfxr(PyObject* cls, PyObject* args, PyObject* kwds) {
// Accept either: sfxr("preset") or sfxr(wave_type=0, base_freq=0.3, ...)
static const char* keywords[] = {
"preset", "seed",
"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",
nullptr
};
const char* preset = nullptr;
PyObject* seed_obj = Py_None;
// sfxr params - initialized to -999 as sentinel (unset)
int wave_type = -999;
double base_freq = -999, freq_limit = -999, freq_ramp = -999, freq_dramp = -999;
double duty = -999, duty_ramp = -999;
double vib_strength = -999, vib_speed = -999;
double env_attack = -999, env_sustain = -999, env_decay = -999, env_punch = -999;
double lpf_freq = -999, lpf_ramp = -999, lpf_resonance = -999;
double hpf_freq = -999, hpf_ramp = -999;
double pha_offset = -999, pha_ramp = -999;
double repeat_speed = -999;
double arp_speed = -999, arp_mod = -999;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|zOidddddddddddddddddddddd",
const_cast<char**>(keywords),
&preset, &seed_obj,
&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)) {
return NULL;
}
// Get seed
uint32_t seed = 0;
bool hasSeed = false;
if (seed_obj != Py_None) {
if (PyLong_Check(seed_obj)) {
seed = static_cast<uint32_t>(PyLong_AsUnsignedLong(seed_obj));
if (PyErr_Occurred()) return NULL;
hasSeed = true;
} else {
PyErr_SetString(PyExc_TypeError, "seed must be an integer");
return NULL;
}
}
SfxrParams params;
if (preset) {
// Generate from preset
std::string presetName(preset);
std::mt19937 rng;
if (hasSeed) {
rng.seed(seed);
} else {
std::random_device rd;
rng.seed(rd());
}
if (!sfxr_preset(presetName, params, rng)) {
PyErr_Format(PyExc_ValueError,
"Unknown sfxr preset '%s'. Valid: coin, laser, explosion, powerup, hurt, jump, blip",
preset);
return NULL;
}
} else {
// Custom params - start with defaults
params = SfxrParams();
if (wave_type != -999) params.wave_type = wave_type;
if (base_freq != -999) params.base_freq = static_cast<float>(base_freq);
if (freq_limit != -999) params.freq_limit = static_cast<float>(freq_limit);
if (freq_ramp != -999) params.freq_ramp = static_cast<float>(freq_ramp);
if (freq_dramp != -999) params.freq_dramp = static_cast<float>(freq_dramp);
if (duty != -999) params.duty = static_cast<float>(duty);
if (duty_ramp != -999) params.duty_ramp = static_cast<float>(duty_ramp);
if (vib_strength != -999) params.vib_strength = static_cast<float>(vib_strength);
if (vib_speed != -999) params.vib_speed = static_cast<float>(vib_speed);
if (env_attack != -999) params.env_attack = static_cast<float>(env_attack);
if (env_sustain != -999) params.env_sustain = static_cast<float>(env_sustain);
if (env_decay != -999) params.env_decay = static_cast<float>(env_decay);
if (env_punch != -999) params.env_punch = static_cast<float>(env_punch);
if (lpf_freq != -999) params.lpf_freq = static_cast<float>(lpf_freq);
if (lpf_ramp != -999) params.lpf_ramp = static_cast<float>(lpf_ramp);
if (lpf_resonance != -999) params.lpf_resonance = static_cast<float>(lpf_resonance);
if (hpf_freq != -999) params.hpf_freq = static_cast<float>(hpf_freq);
if (hpf_ramp != -999) params.hpf_ramp = static_cast<float>(hpf_ramp);
if (pha_offset != -999) params.pha_offset = static_cast<float>(pha_offset);
if (pha_ramp != -999) params.pha_ramp = static_cast<float>(pha_ramp);
if (repeat_speed != -999) params.repeat_speed = static_cast<float>(repeat_speed);
if (arp_speed != -999) params.arp_speed = static_cast<float>(arp_speed);
if (arp_mod != -999) params.arp_mod = static_cast<float>(arp_mod);
}
// Synthesize
std::vector<int16_t> samples = sfxr_synthesize(params);
auto data = std::make_shared<SoundBufferData>(std::move(samples), 44100, 1);
data->sfxrParams = std::make_shared<SfxrParams>(params);
return PySoundBuffer_from_data(std::move(data));
}
// ============================================================================
// DSP effect methods (each returns new SoundBuffer)
// ============================================================================
PyObject* PySoundBuffer::pitch_shift(PySoundBufferObject* self, PyObject* args) {
double factor;
if (!PyArg_ParseTuple(args, "d", &factor)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
if (factor <= 0.0) { PyErr_SetString(PyExc_ValueError, "pitch factor must be positive"); return NULL; }
auto result = AudioEffects::pitchShift(self->data->samples, self->data->channels, factor);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::low_pass(PySoundBufferObject* self, PyObject* args) {
double cutoff;
if (!PyArg_ParseTuple(args, "d", &cutoff)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::lowPass(self->data->samples, self->data->sampleRate, self->data->channels, cutoff);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::high_pass(PySoundBufferObject* self, PyObject* args) {
double cutoff;
if (!PyArg_ParseTuple(args, "d", &cutoff)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::highPass(self->data->samples, self->data->sampleRate, self->data->channels, cutoff);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::echo(PySoundBufferObject* self, PyObject* args) {
double delay_ms, feedback, wet;
if (!PyArg_ParseTuple(args, "ddd", &delay_ms, &feedback, &wet)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::echo(self->data->samples, self->data->sampleRate, self->data->channels,
delay_ms, feedback, wet);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::reverb(PySoundBufferObject* self, PyObject* args) {
double room_size, damping, wet;
if (!PyArg_ParseTuple(args, "ddd", &room_size, &damping, &wet)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::reverb(self->data->samples, self->data->sampleRate, self->data->channels,
room_size, damping, wet);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::distortion(PySoundBufferObject* self, PyObject* args) {
double drive;
if (!PyArg_ParseTuple(args, "d", &drive)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::distortion(self->data->samples, drive);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::bit_crush(PySoundBufferObject* self, PyObject* args) {
int bits, rateDiv;
if (!PyArg_ParseTuple(args, "ii", &bits, &rateDiv)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::bitCrush(self->data->samples, bits, rateDiv);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::normalize(PySoundBufferObject* self, PyObject* args) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::normalize(self->data->samples);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::reverse(PySoundBufferObject* self, PyObject* args) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::reverse(self->data->samples, self->data->channels);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::slice(PySoundBufferObject* self, PyObject* args) {
double startSec, endSec;
if (!PyArg_ParseTuple(args, "dd", &startSec, &endSec)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
auto result = AudioEffects::slice(self->data->samples, self->data->sampleRate, self->data->channels,
startSec, endSec);
auto data = std::make_shared<SoundBufferData>(std::move(result), self->data->sampleRate, self->data->channels);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::sfxr_mutate(PySoundBufferObject* self, PyObject* args) {
double amount = 0.05;
PyObject* seed_obj = Py_None;
if (!PyArg_ParseTuple(args, "|dO", &amount, &seed_obj)) return NULL;
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
if (!self->data->sfxrParams) {
PyErr_SetString(PyExc_RuntimeError, "SoundBuffer was not created with sfxr - no params to mutate");
return NULL;
}
std::mt19937 rng;
if (seed_obj != Py_None && PyLong_Check(seed_obj)) {
rng.seed(static_cast<uint32_t>(PyLong_AsUnsignedLong(seed_obj)));
} else {
std::random_device rd;
rng.seed(rd());
}
SfxrParams mutated = sfxr_mutate_params(*self->data->sfxrParams, static_cast<float>(amount), rng);
std::vector<int16_t> samples = sfxr_synthesize(mutated);
auto data = std::make_shared<SoundBufferData>(std::move(samples), 44100, 1);
data->sfxrParams = std::make_shared<SfxrParams>(mutated);
return PySoundBuffer_from_data(std::move(data));
}
// ============================================================================
// Composition class methods
// ============================================================================
PyObject* PySoundBuffer::concat(PyObject* cls, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"buffers", "overlap", nullptr};
PyObject* bufList;
double overlap = 0.0;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|d", const_cast<char**>(keywords),
&bufList, &overlap)) {
return NULL;
}
if (!PySequence_Check(bufList)) {
PyErr_SetString(PyExc_TypeError, "buffers must be a sequence of SoundBuffer objects");
return NULL;
}
Py_ssize_t count = PySequence_Size(bufList);
if (count <= 0) {
PyErr_SetString(PyExc_ValueError, "buffers must not be empty");
return NULL;
}
// Gather all buffer data
std::vector<std::shared_ptr<SoundBufferData>> buffers;
for (Py_ssize_t i = 0; i < count; i++) {
PyObject* item = PySequence_GetItem(bufList, i);
if (!item || !PyObject_IsInstance(item, (PyObject*)&mcrfpydef::PySoundBufferType)) {
Py_XDECREF(item);
PyErr_SetString(PyExc_TypeError, "All items must be SoundBuffer objects");
return NULL;
}
auto* sbObj = (PySoundBufferObject*)item;
if (!sbObj->data) {
Py_DECREF(item);
PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer in list");
return NULL;
}
buffers.push_back(sbObj->data);
Py_DECREF(item);
}
// Verify matching channels
unsigned int ch = buffers[0]->channels;
unsigned int rate = buffers[0]->sampleRate;
for (auto& b : buffers) {
if (b->channels != ch) {
PyErr_SetString(PyExc_ValueError, "All buffers must have the same number of channels");
return NULL;
}
}
// Build concatenated samples with optional crossfade overlap
size_t overlapSamples = static_cast<size_t>(overlap * rate * ch);
std::vector<int16_t> result;
for (size_t i = 0; i < buffers.size(); i++) {
auto& src = buffers[i]->samples;
if (i == 0 || overlapSamples == 0 || result.size() < overlapSamples) {
result.insert(result.end(), src.begin(), src.end());
} else {
// Crossfade overlap region
size_t ovl = std::min(overlapSamples, std::min(result.size(), src.size()));
size_t startInResult = result.size() - ovl;
for (size_t j = 0; j < ovl; j++) {
float fade = static_cast<float>(j) / static_cast<float>(ovl);
float a = result[startInResult + j] * (1.0f - fade);
float b = src[j] * fade;
result[startInResult + j] = static_cast<int16_t>(std::max(-32768.0f, std::min(32767.0f, a + b)));
}
// Append remaining
if (ovl < src.size()) {
result.insert(result.end(), src.begin() + ovl, src.end());
}
}
}
auto data = std::make_shared<SoundBufferData>(std::move(result), rate, ch);
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::mix(PyObject* cls, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"buffers", nullptr};
PyObject* bufList;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast<char**>(keywords), &bufList)) {
return NULL;
}
if (!PySequence_Check(bufList)) {
PyErr_SetString(PyExc_TypeError, "buffers must be a sequence of SoundBuffer objects");
return NULL;
}
Py_ssize_t count = PySequence_Size(bufList);
if (count <= 0) {
PyErr_SetString(PyExc_ValueError, "buffers must not be empty");
return NULL;
}
std::vector<std::shared_ptr<SoundBufferData>> buffers;
for (Py_ssize_t i = 0; i < count; i++) {
PyObject* item = PySequence_GetItem(bufList, i);
if (!item || !PyObject_IsInstance(item, (PyObject*)&mcrfpydef::PySoundBufferType)) {
Py_XDECREF(item);
PyErr_SetString(PyExc_TypeError, "All items must be SoundBuffer objects");
return NULL;
}
auto* sbObj = (PySoundBufferObject*)item;
if (!sbObj->data) {
Py_DECREF(item);
PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer in list");
return NULL;
}
buffers.push_back(sbObj->data);
Py_DECREF(item);
}
unsigned int ch = buffers[0]->channels;
unsigned int rate = buffers[0]->sampleRate;
// Find longest buffer
size_t maxLen = 0;
for (auto& b : buffers) maxLen = std::max(maxLen, b->samples.size());
// Mix: sum and clamp
std::vector<int16_t> result(maxLen, 0);
for (auto& b : buffers) {
for (size_t i = 0; i < b->samples.size(); i++) {
int32_t sum = static_cast<int32_t>(result[i]) + static_cast<int32_t>(b->samples[i]);
result[i] = static_cast<int16_t>(std::max(-32768, std::min(32767, sum)));
}
}
auto data = std::make_shared<SoundBufferData>(std::move(result), rate, ch);
return PySoundBuffer_from_data(std::move(data));
}
// ============================================================================
// Method/GetSet tables
// ============================================================================
PyMethodDef PySoundBuffer::methods[] = {
// Class methods (factories)
{"from_samples", (PyCFunction)PySoundBuffer::from_samples, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
MCRF_METHOD(SoundBuffer, from_samples,
MCRF_SIG("(data: bytes, channels: int, sample_rate: int)", "SoundBuffer"),
MCRF_DESC("Create a SoundBuffer from raw int16 PCM sample data."),
MCRF_ARGS_START
MCRF_ARG("data", "Raw PCM data as bytes (int16 little-endian)")
MCRF_ARG("channels", "Number of audio channels (1=mono, 2=stereo)")
MCRF_ARG("sample_rate", "Sample rate in Hz (e.g. 44100)")
)},
{"tone", (PyCFunction)PySoundBuffer::tone, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
MCRF_METHOD(SoundBuffer, tone,
MCRF_SIG("(frequency: float, duration: float, waveform: str = 'sine', ...)", "SoundBuffer"),
MCRF_DESC("Generate a tone with optional ADSR envelope."),
MCRF_ARGS_START
MCRF_ARG("frequency", "Frequency in Hz")
MCRF_ARG("duration", "Duration in seconds")
MCRF_ARG("waveform", "One of: sine, square, saw, triangle, noise")
MCRF_ARG("attack", "ADSR attack time in seconds (default 0.01)")
MCRF_ARG("decay", "ADSR decay time in seconds (default 0.0)")
MCRF_ARG("sustain", "ADSR sustain level 0.0-1.0 (default 1.0)")
MCRF_ARG("release", "ADSR release time in seconds (default 0.01)")
)},
{"sfxr", (PyCFunction)PySoundBuffer::sfxr, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
MCRF_METHOD(SoundBuffer, sfxr,
MCRF_SIG("(preset: str = None, seed: int = None, **params)", "SoundBuffer"),
MCRF_DESC("Generate retro sound effects using sfxr synthesis."),
MCRF_ARGS_START
MCRF_ARG("preset", "One of: coin, laser, explosion, powerup, hurt, jump, blip")
MCRF_ARG("seed", "Random seed for deterministic generation")
MCRF_RETURNS("SoundBuffer with sfxr_params set for later mutation")
)},
{"concat", (PyCFunction)PySoundBuffer::concat, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
MCRF_METHOD(SoundBuffer, concat,
MCRF_SIG("(buffers: list[SoundBuffer], overlap: float = 0.0)", "SoundBuffer"),
MCRF_DESC("Concatenate multiple SoundBuffers with optional crossfade overlap."),
MCRF_ARGS_START
MCRF_ARG("buffers", "List of SoundBuffer objects to concatenate")
MCRF_ARG("overlap", "Crossfade overlap duration in seconds")
)},
{"mix", (PyCFunction)PySoundBuffer::mix, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
MCRF_METHOD(SoundBuffer, mix,
MCRF_SIG("(buffers: list[SoundBuffer])", "SoundBuffer"),
MCRF_DESC("Mix multiple SoundBuffers together (additive, clamped)."),
MCRF_ARGS_START
MCRF_ARG("buffers", "List of SoundBuffer objects to mix")
)},
// Instance methods (DSP effects)
{"pitch_shift", (PyCFunction)PySoundBuffer::pitch_shift, METH_VARARGS,
MCRF_METHOD(SoundBuffer, pitch_shift,
MCRF_SIG("(factor: float)", "SoundBuffer"),
MCRF_DESC("Resample to shift pitch. factor>1 = higher+shorter.")
)},
{"low_pass", (PyCFunction)PySoundBuffer::low_pass, METH_VARARGS,
MCRF_METHOD(SoundBuffer, low_pass,
MCRF_SIG("(cutoff_hz: float)", "SoundBuffer"),
MCRF_DESC("Apply single-pole IIR low-pass filter.")
)},
{"high_pass", (PyCFunction)PySoundBuffer::high_pass, METH_VARARGS,
MCRF_METHOD(SoundBuffer, high_pass,
MCRF_SIG("(cutoff_hz: float)", "SoundBuffer"),
MCRF_DESC("Apply single-pole IIR high-pass filter.")
)},
{"echo", (PyCFunction)PySoundBuffer::echo, METH_VARARGS,
MCRF_METHOD(SoundBuffer, echo,
MCRF_SIG("(delay_ms: float, feedback: float, wet: float)", "SoundBuffer"),
MCRF_DESC("Apply echo effect with delay, feedback, and wet/dry mix.")
)},
{"reverb", (PyCFunction)PySoundBuffer::reverb, METH_VARARGS,
MCRF_METHOD(SoundBuffer, reverb,
MCRF_SIG("(room_size: float, damping: float, wet: float)", "SoundBuffer"),
MCRF_DESC("Apply simplified Freeverb-style reverb.")
)},
{"distortion", (PyCFunction)PySoundBuffer::distortion, METH_VARARGS,
MCRF_METHOD(SoundBuffer, distortion,
MCRF_SIG("(drive: float)", "SoundBuffer"),
MCRF_DESC("Apply tanh soft clipping distortion.")
)},
{"bit_crush", (PyCFunction)PySoundBuffer::bit_crush, METH_VARARGS,
MCRF_METHOD(SoundBuffer, bit_crush,
MCRF_SIG("(bits: int, rate_divisor: int)", "SoundBuffer"),
MCRF_DESC("Reduce bit depth and sample rate for lo-fi effect.")
)},
{"normalize", (PyCFunction)PySoundBuffer::normalize, METH_NOARGS,
MCRF_METHOD(SoundBuffer, normalize,
MCRF_SIG("()", "SoundBuffer"),
MCRF_DESC("Scale samples to 95%% of int16 max.")
)},
{"reverse", (PyCFunction)PySoundBuffer::reverse, METH_NOARGS,
MCRF_METHOD(SoundBuffer, reverse,
MCRF_SIG("()", "SoundBuffer"),
MCRF_DESC("Reverse the sample order.")
)},
{"slice", (PyCFunction)PySoundBuffer::slice, METH_VARARGS,
MCRF_METHOD(SoundBuffer, slice,
MCRF_SIG("(start: float, end: float)", "SoundBuffer"),
MCRF_DESC("Extract a time range in seconds.")
)},
{"sfxr_mutate", (PyCFunction)PySoundBuffer::sfxr_mutate, METH_VARARGS,
MCRF_METHOD(SoundBuffer, sfxr_mutate,
MCRF_SIG("(amount: float = 0.05, seed: int = None)", "SoundBuffer"),
MCRF_DESC("Jitter sfxr params and re-synthesize. Only works on sfxr-generated buffers.")
)},
{NULL}
};
PyGetSetDef PySoundBuffer::getsetters[] = {
{"duration", (getter)PySoundBuffer::get_duration, NULL,
MCRF_PROPERTY(duration, "Total duration in seconds (read-only)."), NULL},
{"sample_count", (getter)PySoundBuffer::get_sample_count, NULL,
MCRF_PROPERTY(sample_count, "Total number of samples (read-only)."), NULL},
{"sample_rate", (getter)PySoundBuffer::get_sample_rate, NULL,
MCRF_PROPERTY(sample_rate, "Sample rate in Hz (read-only)."), NULL},
{"channels", (getter)PySoundBuffer::get_channels, NULL,
MCRF_PROPERTY(channels, "Number of audio channels (read-only)."), NULL},
{"sfxr_params", (getter)PySoundBuffer::get_sfxr_params, NULL,
MCRF_PROPERTY(sfxr_params, "Dict of sfxr parameters if sfxr-generated, else None (read-only)."), NULL},
{NULL}
};

123
src/PySoundBuffer.h Normal file
View file

@ -0,0 +1,123 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "McRFPy_Doc.h"
#include <vector>
#include <memory>
#include <string>
#include <cstdint>
// Forward declarations
struct SfxrParams;
// Core audio data container - holds authoritative sample data
class SoundBufferData : public std::enable_shared_from_this<SoundBufferData>
{
public:
std::vector<int16_t> samples;
unsigned int sampleRate = 44100;
unsigned int channels = 1;
// Optional sfxr params (set when created via sfxr synthesis)
std::shared_ptr<SfxrParams> sfxrParams;
// Lazy sf::SoundBuffer rebuild
sf::SoundBuffer sfBuffer;
bool sfBufferDirty = true;
SoundBufferData() = default;
SoundBufferData(std::vector<int16_t>&& s, unsigned int rate, unsigned int ch)
: samples(std::move(s)), sampleRate(rate), channels(ch), sfBufferDirty(true) {}
// Rebuild sf::SoundBuffer from samples if dirty
sf::SoundBuffer& getSfBuffer() {
if (sfBufferDirty && !samples.empty()) {
sfBuffer.loadFromSamples(samples.data(), samples.size(), channels, sampleRate);
sfBufferDirty = false;
}
return sfBuffer;
}
float duration() const {
if (sampleRate == 0 || channels == 0 || samples.empty()) return 0.0f;
return static_cast<float>(samples.size()) / static_cast<float>(channels) / static_cast<float>(sampleRate);
}
};
// Python object wrapper
typedef struct {
PyObject_HEAD
std::shared_ptr<SoundBufferData> data;
} PySoundBufferObject;
// Python type methods/getset declarations
namespace PySoundBuffer {
// tp_init, tp_new, tp_repr
int init(PySoundBufferObject* self, PyObject* args, PyObject* kwds);
PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
PyObject* repr(PyObject* obj);
// Class methods (factories)
PyObject* from_samples(PyObject* cls, PyObject* args, PyObject* kwds);
PyObject* tone(PyObject* cls, PyObject* args, PyObject* kwds);
PyObject* sfxr(PyObject* cls, PyObject* args, PyObject* kwds);
PyObject* concat(PyObject* cls, PyObject* args, PyObject* kwds);
PyObject* mix(PyObject* cls, PyObject* args, PyObject* kwds);
// Instance methods (DSP - each returns new SoundBuffer)
PyObject* pitch_shift(PySoundBufferObject* self, PyObject* args);
PyObject* low_pass(PySoundBufferObject* self, PyObject* args);
PyObject* high_pass(PySoundBufferObject* self, PyObject* args);
PyObject* echo(PySoundBufferObject* self, PyObject* args);
PyObject* reverb(PySoundBufferObject* self, PyObject* args);
PyObject* distortion(PySoundBufferObject* self, PyObject* args);
PyObject* bit_crush(PySoundBufferObject* self, PyObject* args);
PyObject* normalize(PySoundBufferObject* self, PyObject* args);
PyObject* reverse(PySoundBufferObject* self, PyObject* args);
PyObject* slice(PySoundBufferObject* self, PyObject* args);
PyObject* sfxr_mutate(PySoundBufferObject* self, PyObject* args);
// Properties
PyObject* get_duration(PySoundBufferObject* self, void* closure);
PyObject* get_sample_count(PySoundBufferObject* self, void* closure);
PyObject* get_sample_rate(PySoundBufferObject* self, void* closure);
PyObject* get_channels(PySoundBufferObject* self, void* closure);
PyObject* get_sfxr_params(PySoundBufferObject* self, void* closure);
extern PyMethodDef methods[];
extern PyGetSetDef getsetters[];
}
// Helper: create a new PySoundBufferObject wrapping given data
PyObject* PySoundBuffer_from_data(std::shared_ptr<SoundBufferData> data);
namespace mcrfpydef {
inline PyTypeObject PySoundBufferType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.SoundBuffer",
.tp_basicsize = sizeof(PySoundBufferObject),
.tp_itemsize = 0,
.tp_repr = PySoundBuffer::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"SoundBuffer(filename: str)\n"
"SoundBuffer.from_samples(data: bytes, channels: int, sample_rate: int)\n"
"SoundBuffer.tone(frequency: float, duration: float, waveform: str = 'sine', ...)\n"
"SoundBuffer.sfxr(preset: str, seed: int = None)\n\n"
"Audio sample buffer for procedural audio generation and effects.\n\n"
"Holds PCM sample data that can be created from files, raw samples,\n"
"tone synthesis, or sfxr presets. Effect methods return new SoundBuffer\n"
"instances (copy-modify pattern).\n\n"
"Properties:\n"
" duration (float, read-only): Duration in seconds.\n"
" sample_count (int, read-only): Total number of samples.\n"
" sample_rate (int, read-only): Samples per second (e.g. 44100).\n"
" channels (int, read-only): Number of audio channels.\n"
" sfxr_params (dict or None, read-only): sfxr parameters if sfxr-generated.\n"
),
.tp_methods = PySoundBuffer::methods,
.tp_getset = PySoundBuffer::getsetters,
.tp_init = (initproc)PySoundBuffer::init,
.tp_new = PySoundBuffer::pynew,
};
}

336
src/audio/AudioEffects.cpp Normal file
View file

@ -0,0 +1,336 @@
#include "AudioEffects.h"
#include <cmath>
#include <algorithm>
#include <cstring>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
namespace AudioEffects {
// ============================================================================
// Pitch shift via linear interpolation resampling
// ============================================================================
std::vector<int16_t> pitchShift(const std::vector<int16_t>& samples, unsigned int channels, double factor) {
if (samples.empty() || factor <= 0.0) return samples;
size_t frames = samples.size() / channels;
size_t newFrames = static_cast<size_t>(frames / factor);
if (newFrames == 0) newFrames = 1;
std::vector<int16_t> result(newFrames * channels);
for (size_t i = 0; i < newFrames; i++) {
double srcPos = i * factor;
size_t idx0 = static_cast<size_t>(srcPos);
double frac = srcPos - idx0;
size_t idx1 = std::min(idx0 + 1, frames - 1);
for (unsigned int ch = 0; ch < channels; ch++) {
double s0 = samples[idx0 * channels + ch];
double s1 = samples[idx1 * channels + ch];
double interp = s0 + (s1 - s0) * frac;
result[i * channels + ch] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, interp)));
}
}
return result;
}
// ============================================================================
// Low-pass filter (single-pole IIR)
// ============================================================================
std::vector<int16_t> lowPass(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels, double cutoffHz) {
if (samples.empty()) return samples;
double rc = 1.0 / (2.0 * M_PI * cutoffHz);
double dt = 1.0 / sampleRate;
double alpha = dt / (rc + dt);
std::vector<int16_t> result(samples.size());
std::vector<double> prev(channels, 0.0);
size_t frames = samples.size() / channels;
for (size_t i = 0; i < frames; i++) {
for (unsigned int ch = 0; ch < channels; ch++) {
double input = samples[i * channels + ch];
prev[ch] = prev[ch] + alpha * (input - prev[ch]);
result[i * channels + ch] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, prev[ch])));
}
}
return result;
}
// ============================================================================
// High-pass filter (complement of low-pass)
// ============================================================================
std::vector<int16_t> highPass(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels, double cutoffHz) {
if (samples.empty()) return samples;
double rc = 1.0 / (2.0 * M_PI * cutoffHz);
double dt = 1.0 / sampleRate;
double alpha = rc / (rc + dt);
std::vector<int16_t> result(samples.size());
std::vector<double> prevIn(channels, 0.0);
std::vector<double> prevOut(channels, 0.0);
size_t frames = samples.size() / channels;
for (size_t i = 0; i < frames; i++) {
for (unsigned int ch = 0; ch < channels; ch++) {
double input = samples[i * channels + ch];
prevOut[ch] = alpha * (prevOut[ch] + input - prevIn[ch]);
prevIn[ch] = input;
result[i * channels + ch] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, prevOut[ch])));
}
}
return result;
}
// ============================================================================
// Echo (circular delay buffer with feedback)
// ============================================================================
std::vector<int16_t> echo(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels,
double delayMs, double feedback, double wet) {
if (samples.empty()) return samples;
size_t delaySamples = static_cast<size_t>(delayMs * sampleRate * channels / 1000.0);
if (delaySamples == 0) return samples;
std::vector<double> delay(delaySamples, 0.0);
std::vector<int16_t> result(samples.size());
size_t pos = 0;
for (size_t i = 0; i < samples.size(); i++) {
double input = samples[i];
double delayed = delay[pos % delaySamples];
double output = input + delayed * wet;
delay[pos % delaySamples] = input + delayed * feedback;
result[i] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, output)));
pos++;
}
return result;
}
// ============================================================================
// Reverb (simplified Freeverb: 4 comb filters + 2 allpass)
// ============================================================================
namespace {
struct CombFilter {
std::vector<double> buffer;
size_t pos = 0;
double filterStore = 0.0;
CombFilter(size_t size) : buffer(size, 0.0) {}
double process(double input, double feedback, double damp) {
double output = buffer[pos];
filterStore = output * (1.0 - damp) + filterStore * damp;
buffer[pos] = input + filterStore * feedback;
pos = (pos + 1) % buffer.size();
return output;
}
};
struct AllpassFilter {
std::vector<double> buffer;
size_t pos = 0;
AllpassFilter(size_t size) : buffer(size, 0.0) {}
double process(double input) {
double buffered = buffer[pos];
double output = -input + buffered;
buffer[pos] = input + buffered * 0.5;
pos = (pos + 1) % buffer.size();
return output;
}
};
}
std::vector<int16_t> reverb(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels,
double roomSize, double damping, double wet) {
if (samples.empty()) return samples;
// Comb filter delays (in samples, scaled for sample rate)
double scale = sampleRate / 44100.0;
size_t combSizes[4] = {
static_cast<size_t>(1116 * scale),
static_cast<size_t>(1188 * scale),
static_cast<size_t>(1277 * scale),
static_cast<size_t>(1356 * scale)
};
size_t allpassSizes[2] = {
static_cast<size_t>(556 * scale),
static_cast<size_t>(441 * scale)
};
CombFilter combs[4] = {
CombFilter(combSizes[0]), CombFilter(combSizes[1]),
CombFilter(combSizes[2]), CombFilter(combSizes[3])
};
AllpassFilter allpasses[2] = {
AllpassFilter(allpassSizes[0]), AllpassFilter(allpassSizes[1])
};
double feedback = roomSize * 0.9 + 0.05;
double dry = 1.0 - wet;
std::vector<int16_t> result(samples.size());
// Process mono (mix channels if stereo, then duplicate)
for (size_t i = 0; i < samples.size(); i += channels) {
// Mix to mono for reverb processing
double mono = 0.0;
for (unsigned int ch = 0; ch < channels; ch++) {
mono += samples[i + ch];
}
mono /= channels;
mono /= 32768.0; // Normalize to -1..1
// Parallel comb filters
double reverbSample = 0.0;
for (int c = 0; c < 4; c++) {
reverbSample += combs[c].process(mono, feedback, damping);
}
// Series allpass filters
for (int a = 0; a < 2; a++) {
reverbSample = allpasses[a].process(reverbSample);
}
// Mix wet/dry and write to all channels
for (unsigned int ch = 0; ch < channels; ch++) {
double original = samples[i + ch] / 32768.0;
double output = original * dry + reverbSample * wet;
result[i + ch] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, output * 32768.0)));
}
}
return result;
}
// ============================================================================
// Distortion (tanh soft clip)
// ============================================================================
std::vector<int16_t> distortion(const std::vector<int16_t>& samples, double drive) {
if (samples.empty()) return samples;
std::vector<int16_t> result(samples.size());
for (size_t i = 0; i < samples.size(); i++) {
double s = samples[i] / 32768.0;
s = std::tanh(s * drive);
result[i] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, s * 32768.0)));
}
return result;
}
// ============================================================================
// Bit crush (quantize + sample rate reduce)
// ============================================================================
std::vector<int16_t> bitCrush(const std::vector<int16_t>& samples, int bits, int rateDivisor) {
if (samples.empty()) return samples;
bits = std::max(1, std::min(16, bits));
rateDivisor = std::max(1, rateDivisor);
int levels = 1 << bits;
double quantStep = 65536.0 / levels;
std::vector<int16_t> result(samples.size());
int16_t held = 0;
for (size_t i = 0; i < samples.size(); i++) {
if (i % rateDivisor == 0) {
// Quantize
double s = samples[i] + 32768.0; // Shift to 0..65536
s = std::floor(s / quantStep) * quantStep;
held = static_cast<int16_t>(s - 32768.0);
}
result[i] = held;
}
return result;
}
// ============================================================================
// Normalize (scale to 95% of int16 max)
// ============================================================================
std::vector<int16_t> normalize(const std::vector<int16_t>& samples) {
if (samples.empty()) return samples;
int16_t peak = 0;
for (auto s : samples) {
int16_t abs_s = (s < 0) ? static_cast<int16_t>(-s) : s;
if (abs_s > peak) peak = abs_s;
}
if (peak == 0) return samples;
double scale = 31128.0 / peak; // 95% of 32767
std::vector<int16_t> result(samples.size());
for (size_t i = 0; i < samples.size(); i++) {
double s = samples[i] * scale;
result[i] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, s)));
}
return result;
}
// ============================================================================
// Reverse (frame-aware for multichannel)
// ============================================================================
std::vector<int16_t> reverse(const std::vector<int16_t>& samples, unsigned int channels) {
if (samples.empty()) return samples;
size_t frames = samples.size() / channels;
std::vector<int16_t> result(samples.size());
for (size_t i = 0; i < frames; i++) {
size_t srcFrame = frames - 1 - i;
for (unsigned int ch = 0; ch < channels; ch++) {
result[i * channels + ch] = samples[srcFrame * channels + ch];
}
}
return result;
}
// ============================================================================
// Slice (extract sub-range by time)
// ============================================================================
std::vector<int16_t> slice(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels,
double startSec, double endSec) {
if (samples.empty()) return {};
size_t frames = samples.size() / channels;
size_t startFrame = static_cast<size_t>(std::max(0.0, startSec) * sampleRate);
size_t endFrame = static_cast<size_t>(std::max(0.0, endSec) * sampleRate);
startFrame = std::min(startFrame, frames);
endFrame = std::min(endFrame, frames);
if (startFrame >= endFrame) return {};
size_t numFrames = endFrame - startFrame;
std::vector<int16_t> result(numFrames * channels);
std::memcpy(result.data(), &samples[startFrame * channels], numFrames * channels * sizeof(int16_t));
return result;
}
} // namespace AudioEffects

42
src/audio/AudioEffects.h Normal file
View file

@ -0,0 +1,42 @@
#pragma once
#include <vector>
#include <cstdint>
// Pure DSP functions: vector<int16_t> -> vector<int16_t>
// All return NEW vectors, never modify input.
namespace AudioEffects {
// Resample to shift pitch. factor>1 = higher pitch + shorter duration.
std::vector<int16_t> pitchShift(const std::vector<int16_t>& samples, unsigned int channels, double factor);
// Single-pole IIR low-pass filter
std::vector<int16_t> lowPass(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels, double cutoffHz);
// High-pass filter (complement of low-pass)
std::vector<int16_t> highPass(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels, double cutoffHz);
// Delay-line echo with feedback
std::vector<int16_t> echo(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels,
double delayMs, double feedback, double wet);
// Simplified Freeverb: 4 comb filters + 2 allpass
std::vector<int16_t> reverb(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels,
double roomSize, double damping, double wet);
// tanh soft clipping
std::vector<int16_t> distortion(const std::vector<int16_t>& samples, double drive);
// Reduce bit depth and sample rate
std::vector<int16_t> bitCrush(const std::vector<int16_t>& samples, int bits, int rateDivisor);
// Scale to 95% of int16 max
std::vector<int16_t> normalize(const std::vector<int16_t>& samples);
// Reverse sample order (frame-aware for multichannel)
std::vector<int16_t> reverse(const std::vector<int16_t>& samples, unsigned int channels);
// Extract sub-range by time offsets
std::vector<int16_t> slice(const std::vector<int16_t>& samples, unsigned int sampleRate, unsigned int channels,
double startSec, double endSec);
} // namespace AudioEffects

499
src/audio/SfxrSynth.cpp Normal file
View file

@ -0,0 +1,499 @@
#include "SfxrSynth.h"
#include <cmath>
#include <algorithm>
#include <cstring>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
// ============================================================================
// sfxr synthesis engine
// Based on the original sfxr by DrPetter
// 8x supersampled, 44100 Hz mono output
// ============================================================================
std::vector<int16_t> sfxr_synthesize(const SfxrParams& p) {
// Convert parameters to internal representation
const int OVERSAMPLE = 8;
const int SAMPLE_RATE = 44100;
double fperiod;
double fmaxperiod;
double fslide;
double fdslide;
int period;
double square_duty;
double square_slide;
// Envelope
int env_length[3];
double env_vol;
int env_stage;
int env_time;
// Vibrato
double vib_phase;
double vib_speed;
double vib_amp;
// Low-pass filter
double fltp;
double fltdp;
double fltw;
double fltw_d;
double fltdmp;
double fltphp;
double flthp;
double flthp_d;
// Phaser
double phaser_buffer[1024];
int phaser_pos;
double phaser_offset;
double phaser_delta;
// Noise buffer
double noise_buffer[32];
// Arpeggio
double arp_time;
double arp_limit;
double arp_mod;
// Repeat
double rep_time;
double rep_limit;
int phase;
// Initialize
auto reset = [&](bool restart) {
if (!restart) {
phase = 0;
}
fperiod = 100.0 / (p.base_freq * p.base_freq + 0.001);
period = static_cast<int>(fperiod);
fmaxperiod = 100.0 / (p.freq_limit * p.freq_limit + 0.001);
fslide = 1.0 - std::pow(p.freq_ramp, 3.0) * 0.01;
fdslide = -std::pow(p.freq_dramp, 3.0) * 0.000001;
square_duty = 0.5 - p.duty * 0.5;
square_slide = -p.duty_ramp * 0.00005;
if (p.arp_mod >= 0.0f) {
arp_mod = 1.0 - std::pow(p.arp_mod, 2.0) * 0.9;
} else {
arp_mod = 1.0 + std::pow(p.arp_mod, 2.0) * 10.0;
}
arp_time = 0;
arp_limit = (p.arp_speed == 1.0f) ? 0 : static_cast<int>(std::pow(1.0 - p.arp_speed, 2.0) * 20000 + 32);
if (!restart) {
// Noise buffer
for (int i = 0; i < 32; i++) {
noise_buffer[i] = ((std::rand() % 20001) / 10000.0) - 1.0;
}
// Phaser
std::memset(phaser_buffer, 0, sizeof(phaser_buffer));
phaser_pos = 0;
phaser_offset = std::pow(p.pha_offset, 2.0) * 1020.0;
if (p.pha_offset < 0.0f) phaser_offset = -phaser_offset;
phaser_delta = std::pow(p.pha_ramp, 2.0) * 1.0;
if (p.pha_ramp < 0.0f) phaser_delta = -phaser_delta;
// Filter
fltp = 0.0;
fltdp = 0.0;
fltw = std::pow(p.lpf_freq, 3.0) * 0.1;
fltw_d = 1.0 + p.lpf_ramp * 0.0001;
fltdmp = 5.0 / (1.0 + std::pow(p.lpf_resonance, 2.0) * 20.0) * (0.01 + fltw);
if (fltdmp > 0.8) fltdmp = 0.8;
fltphp = 0.0;
flthp = std::pow(p.hpf_freq, 2.0) * 0.1;
flthp_d = 1.0 + p.hpf_ramp * 0.0003;
// Vibrato
vib_phase = 0.0;
vib_speed = std::pow(p.vib_speed, 2.0) * 0.01;
vib_amp = p.vib_strength * 0.5;
// Envelope
env_vol = 0.0;
env_stage = 0;
env_time = 0;
env_length[0] = static_cast<int>(p.env_attack * p.env_attack * 100000.0);
env_length[1] = static_cast<int>(p.env_sustain * p.env_sustain * 100000.0);
env_length[2] = static_cast<int>(p.env_decay * p.env_decay * 100000.0);
// Repeat
rep_time = 0;
rep_limit = (p.repeat_speed == 0.0f) ? 0 :
static_cast<int>(std::pow(1.0 - p.repeat_speed, 2.0) * 20000 + 32);
}
};
// Seed RNG deterministically based on params
std::srand(42);
reset(false);
// Generate samples - max 4 seconds of audio
int maxSamples = SAMPLE_RATE * 4;
std::vector<int16_t> output;
output.reserve(maxSamples);
for (int si = 0; si < maxSamples; si++) {
// Repeat
rep_time++;
if (rep_limit != 0 && rep_time >= rep_limit) {
rep_time = 0;
reset(true);
}
// Arpeggio
arp_time++;
if (arp_limit != 0 && arp_time >= arp_limit) {
arp_limit = 0;
fperiod *= arp_mod;
}
// Frequency slide
fslide += fdslide;
fperiod *= fslide;
if (fperiod > fmaxperiod) {
fperiod = fmaxperiod;
if (p.freq_limit > 0.0f) {
// Sound has ended
break;
}
}
// Vibrato
double rfperiod = fperiod;
if (vib_amp > 0.0) {
vib_phase += vib_speed;
rfperiod = fperiod * (1.0 + std::sin(vib_phase) * vib_amp);
}
period = static_cast<int>(rfperiod);
if (period < 8) period = 8;
// Duty cycle
square_duty += square_slide;
if (square_duty < 0.0) square_duty = 0.0;
if (square_duty > 0.5) square_duty = 0.5;
// Envelope
env_time++;
if (env_time > env_length[env_stage]) {
env_time = 0;
env_stage++;
if (env_stage == 3) {
break; // Sound complete
}
}
if (env_stage == 0) {
env_vol = (env_length[0] > 0) ?
static_cast<double>(env_time) / env_length[0] : 1.0;
} else if (env_stage == 1) {
env_vol = 1.0 + (1.0 - static_cast<double>(env_time) / std::max(1, env_length[1])) * 2.0 * p.env_punch;
} else {
env_vol = 1.0 - static_cast<double>(env_time) / std::max(1, env_length[2]);
}
// Phaser
phaser_offset += phaser_delta;
int iphaser_offset = std::abs(static_cast<int>(phaser_offset));
if (iphaser_offset > 1023) iphaser_offset = 1023;
// Filter
if (flthp_d != 0.0) {
flthp *= flthp_d;
if (flthp < 0.00001) flthp = 0.00001;
if (flthp > 0.1) flthp = 0.1;
}
// 8x supersampling
double ssample = 0.0;
for (int si2 = 0; si2 < OVERSAMPLE; si2++) {
double sample = 0.0;
phase++;
double fphase = static_cast<double>(phase) / period;
// Waveform generation
switch (p.wave_type) {
case 0: // Square
sample = (fphase < square_duty) ? 0.5 : -0.5;
break;
case 1: // Sawtooth
sample = 1.0 - fphase * 2.0;
break;
case 2: // Sine
sample = std::sin(fphase * 2.0 * M_PI);
break;
case 3: // Noise
sample = noise_buffer[static_cast<int>(fphase * 32) % 32];
break;
}
// Low-pass filter
double pp = fltp;
fltw *= fltw_d;
if (fltw < 0.0) fltw = 0.0;
if (fltw > 0.1) fltw = 0.1;
if (p.lpf_freq != 1.0f) {
fltdp += (sample - fltp) * fltw;
fltdp -= fltdp * fltdmp;
} else {
fltp = sample;
fltdp = 0.0;
}
fltp += fltdp;
// High-pass filter
fltphp += fltp - pp;
fltphp -= fltphp * flthp;
sample = fltphp;
// Phaser
phaser_buffer[phaser_pos & 1023] = sample;
sample += phaser_buffer[(phaser_pos - iphaser_offset + 1024) & 1023];
phaser_pos = (phaser_pos + 1) & 1023;
// Accumulate
ssample += sample * env_vol;
}
// Average supersamples and scale
ssample = ssample / OVERSAMPLE * 0.2; // master_vol
ssample *= 2.0; // Boost
// Clamp
if (ssample > 1.0) ssample = 1.0;
if (ssample < -1.0) ssample = -1.0;
output.push_back(static_cast<int16_t>(ssample * 32000.0));
}
return output;
}
// ============================================================================
// Presets
// ============================================================================
static float rnd(std::mt19937& rng, float range) {
std::uniform_real_distribution<float> dist(0.0f, range);
return dist(rng);
}
static float rnd01(std::mt19937& rng) {
return rnd(rng, 1.0f);
}
bool sfxr_preset(const std::string& name, SfxrParams& p, std::mt19937& rng) {
p = SfxrParams(); // Reset to defaults
if (name == "coin" || name == "pickup") {
p.base_freq = 0.4f + rnd(rng, 0.5f);
p.env_attack = 0.0f;
p.env_sustain = rnd(rng, 0.1f);
p.env_decay = 0.1f + rnd(rng, 0.4f);
p.env_punch = 0.3f + rnd(rng, 0.3f);
if (rnd01(rng) < 0.5f) {
p.arp_speed = 0.5f + rnd(rng, 0.2f);
p.arp_mod = 0.2f + rnd(rng, 0.4f);
}
}
else if (name == "laser" || name == "shoot") {
p.wave_type = static_cast<int>(rnd(rng, 3.0f));
if (p.wave_type == 2 && rnd01(rng) < 0.5f)
p.wave_type = static_cast<int>(rnd(rng, 2.0f));
p.base_freq = 0.5f + rnd(rng, 0.5f);
p.freq_limit = std::max(0.2f, p.base_freq - 0.2f - rnd(rng, 0.6f));
p.freq_ramp = -0.15f - rnd(rng, 0.2f);
if (rnd01(rng) < 0.33f) {
p.base_freq = 0.3f + rnd(rng, 0.6f);
p.freq_limit = rnd(rng, 0.1f);
p.freq_ramp = -0.35f - rnd(rng, 0.3f);
}
if (rnd01(rng) < 0.5f) {
p.duty = rnd(rng, 0.5f);
p.duty_ramp = rnd(rng, 0.2f);
} else {
p.duty = 0.4f + rnd(rng, 0.5f);
p.duty_ramp = -rnd(rng, 0.7f);
}
p.env_attack = 0.0f;
p.env_sustain = 0.1f + rnd(rng, 0.2f);
p.env_decay = rnd(rng, 0.4f);
if (rnd01(rng) < 0.5f) p.env_punch = rnd(rng, 0.3f);
if (rnd01(rng) < 0.33f) {
p.pha_offset = rnd(rng, 0.2f);
p.pha_ramp = -rnd(rng, 0.2f);
}
if (rnd01(rng) < 0.5f) p.hpf_freq = rnd(rng, 0.3f);
}
else if (name == "explosion") {
p.wave_type = 3; // noise
if (rnd01(rng) < 0.5f) {
p.base_freq = 0.1f + rnd(rng, 0.4f);
p.freq_ramp = -0.1f + rnd(rng, 0.4f);
} else {
p.base_freq = 0.2f + rnd(rng, 0.7f);
p.freq_ramp = -0.2f - rnd(rng, 0.2f);
}
p.base_freq *= p.base_freq;
if (rnd01(rng) < 0.2f) p.freq_ramp = 0.0f;
if (rnd01(rng) < 0.33f) p.repeat_speed = 0.3f + rnd(rng, 0.5f);
p.env_attack = 0.0f;
p.env_sustain = 0.1f + rnd(rng, 0.3f);
p.env_decay = rnd(rng, 0.5f);
if (rnd01(rng) < 0.5f) {
p.pha_offset = -0.3f + rnd(rng, 0.9f);
p.pha_ramp = -rnd(rng, 0.3f);
}
p.env_punch = 0.2f + rnd(rng, 0.6f);
if (rnd01(rng) < 0.5f) {
p.vib_strength = rnd(rng, 0.7f);
p.vib_speed = rnd(rng, 0.6f);
}
if (rnd01(rng) < 0.33f) {
p.arp_speed = 0.6f + rnd(rng, 0.3f);
p.arp_mod = 0.8f - rnd(rng, 1.6f);
}
}
else if (name == "powerup") {
if (rnd01(rng) < 0.5f) {
p.wave_type = 1; // saw
} else {
p.duty = rnd(rng, 0.6f);
}
if (rnd01(rng) < 0.5f) {
p.base_freq = 0.2f + rnd(rng, 0.3f);
p.freq_ramp = 0.1f + rnd(rng, 0.4f);
p.repeat_speed = 0.4f + rnd(rng, 0.4f);
} else {
p.base_freq = 0.2f + rnd(rng, 0.3f);
p.freq_ramp = 0.05f + rnd(rng, 0.2f);
if (rnd01(rng) < 0.5f) {
p.vib_strength = rnd(rng, 0.7f);
p.vib_speed = rnd(rng, 0.6f);
}
}
p.env_attack = 0.0f;
p.env_sustain = rnd(rng, 0.4f);
p.env_decay = 0.1f + rnd(rng, 0.4f);
}
else if (name == "hurt" || name == "hit") {
p.wave_type = static_cast<int>(rnd(rng, 3.0f));
if (p.wave_type == 2) p.wave_type = 3; // prefer noise over sine
if (p.wave_type == 0) p.duty = rnd(rng, 0.6f);
p.base_freq = 0.2f + rnd(rng, 0.6f);
p.freq_ramp = -0.3f - rnd(rng, 0.4f);
p.env_attack = 0.0f;
p.env_sustain = rnd(rng, 0.1f);
p.env_decay = 0.1f + rnd(rng, 0.2f);
if (rnd01(rng) < 0.5f) p.hpf_freq = rnd(rng, 0.3f);
}
else if (name == "jump") {
p.wave_type = 0; // square
p.duty = rnd(rng, 0.6f);
p.base_freq = 0.3f + rnd(rng, 0.3f);
p.freq_ramp = 0.1f + rnd(rng, 0.2f);
p.env_attack = 0.0f;
p.env_sustain = 0.1f + rnd(rng, 0.3f);
p.env_decay = 0.1f + rnd(rng, 0.2f);
if (rnd01(rng) < 0.5f) p.hpf_freq = rnd(rng, 0.3f);
if (rnd01(rng) < 0.5f) p.lpf_freq = 1.0f - rnd(rng, 0.6f);
}
else if (name == "blip" || name == "select") {
p.wave_type = static_cast<int>(rnd(rng, 2.0f));
if (p.wave_type == 0) p.duty = rnd(rng, 0.6f);
p.base_freq = 0.2f + rnd(rng, 0.4f);
p.env_attack = 0.0f;
p.env_sustain = 0.1f + rnd(rng, 0.1f);
p.env_decay = rnd(rng, 0.2f);
p.hpf_freq = 0.1f;
}
else {
return false;
}
return true;
}
// ============================================================================
// Mutate
// ============================================================================
SfxrParams sfxr_mutate_params(const SfxrParams& base, float amount, std::mt19937& rng) {
SfxrParams p = base;
std::uniform_real_distribution<float> dist(-1.0f, 1.0f);
auto jitter = [&](float val) -> float {
return std::max(0.0f, std::min(1.0f, val + dist(rng) * amount));
};
auto jitterSigned = [&](float val) -> float {
return std::max(-1.0f, std::min(1.0f, val + dist(rng) * amount));
};
p.base_freq = jitter(p.base_freq);
p.freq_ramp = jitterSigned(p.freq_ramp);
p.freq_dramp = jitterSigned(p.freq_dramp);
p.duty = jitter(p.duty);
p.duty_ramp = jitterSigned(p.duty_ramp);
p.vib_strength = jitter(p.vib_strength);
p.vib_speed = jitter(p.vib_speed);
p.env_attack = jitter(p.env_attack);
p.env_sustain = jitter(p.env_sustain);
p.env_decay = jitter(p.env_decay);
p.env_punch = jitter(p.env_punch);
p.lpf_freq = jitter(p.lpf_freq);
p.lpf_ramp = jitterSigned(p.lpf_ramp);
p.lpf_resonance = jitter(p.lpf_resonance);
p.hpf_freq = jitter(p.hpf_freq);
p.hpf_ramp = jitterSigned(p.hpf_ramp);
p.pha_offset = jitterSigned(p.pha_offset);
p.pha_ramp = jitterSigned(p.pha_ramp);
p.repeat_speed = jitter(p.repeat_speed);
p.arp_speed = jitter(p.arp_speed);
p.arp_mod = jitterSigned(p.arp_mod);
return p;
}
// ============================================================================
// Convert params to Python dict
// ============================================================================
PyObject* sfxr_params_to_dict(const SfxrParams& p) {
PyObject* d = PyDict_New();
if (!d) return NULL;
PyDict_SetItemString(d, "wave_type", PyLong_FromLong(p.wave_type));
PyDict_SetItemString(d, "base_freq", PyFloat_FromDouble(p.base_freq));
PyDict_SetItemString(d, "freq_limit", PyFloat_FromDouble(p.freq_limit));
PyDict_SetItemString(d, "freq_ramp", PyFloat_FromDouble(p.freq_ramp));
PyDict_SetItemString(d, "freq_dramp", PyFloat_FromDouble(p.freq_dramp));
PyDict_SetItemString(d, "duty", PyFloat_FromDouble(p.duty));
PyDict_SetItemString(d, "duty_ramp", PyFloat_FromDouble(p.duty_ramp));
PyDict_SetItemString(d, "vib_strength", PyFloat_FromDouble(p.vib_strength));
PyDict_SetItemString(d, "vib_speed", PyFloat_FromDouble(p.vib_speed));
PyDict_SetItemString(d, "env_attack", PyFloat_FromDouble(p.env_attack));
PyDict_SetItemString(d, "env_sustain", PyFloat_FromDouble(p.env_sustain));
PyDict_SetItemString(d, "env_decay", PyFloat_FromDouble(p.env_decay));
PyDict_SetItemString(d, "env_punch", PyFloat_FromDouble(p.env_punch));
PyDict_SetItemString(d, "lpf_freq", PyFloat_FromDouble(p.lpf_freq));
PyDict_SetItemString(d, "lpf_ramp", PyFloat_FromDouble(p.lpf_ramp));
PyDict_SetItemString(d, "lpf_resonance", PyFloat_FromDouble(p.lpf_resonance));
PyDict_SetItemString(d, "hpf_freq", PyFloat_FromDouble(p.hpf_freq));
PyDict_SetItemString(d, "hpf_ramp", PyFloat_FromDouble(p.hpf_ramp));
PyDict_SetItemString(d, "pha_offset", PyFloat_FromDouble(p.pha_offset));
PyDict_SetItemString(d, "pha_ramp", PyFloat_FromDouble(p.pha_ramp));
PyDict_SetItemString(d, "repeat_speed", PyFloat_FromDouble(p.repeat_speed));
PyDict_SetItemString(d, "arp_speed", PyFloat_FromDouble(p.arp_speed));
PyDict_SetItemString(d, "arp_mod", PyFloat_FromDouble(p.arp_mod));
return d;
}

54
src/audio/SfxrSynth.h Normal file
View file

@ -0,0 +1,54 @@
#pragma once
#include <vector>
#include <cstdint>
#include <random>
#include <string>
#include "Python.h"
// sfxr parameter set (24 floats + wave_type)
struct SfxrParams {
int wave_type = 0; // 0=square, 1=sawtooth, 2=sine, 3=noise
float base_freq = 0.3f; // Base frequency
float freq_limit = 0.0f; // Frequency cutoff
float freq_ramp = 0.0f; // Frequency slide
float freq_dramp = 0.0f; // Delta slide
float duty = 0.5f; // Square wave duty cycle
float duty_ramp = 0.0f; // Duty sweep
float vib_strength = 0.0f; // Vibrato depth
float vib_speed = 0.0f; // Vibrato speed
float env_attack = 0.0f; // Envelope attack
float env_sustain = 0.3f; // Envelope sustain
float env_decay = 0.4f; // Envelope decay
float env_punch = 0.0f; // Sustain punch
float lpf_freq = 1.0f; // Low-pass filter cutoff
float lpf_ramp = 0.0f; // Low-pass filter sweep
float lpf_resonance = 0.0f; // Low-pass filter resonance
float hpf_freq = 0.0f; // High-pass filter cutoff
float hpf_ramp = 0.0f; // High-pass filter sweep
float pha_offset = 0.0f; // Phaser offset
float pha_ramp = 0.0f; // Phaser sweep
float repeat_speed = 0.0f; // Repeat speed
float arp_speed = 0.0f; // Arpeggiator speed
float arp_mod = 0.0f; // Arpeggiator frequency multiplier
};
// Synthesize samples from sfxr parameters (44100 Hz, mono, int16)
std::vector<int16_t> sfxr_synthesize(const SfxrParams& params);
// Generate preset parameters
bool sfxr_preset(const std::string& name, SfxrParams& out, std::mt19937& rng);
// Mutate existing parameters
SfxrParams sfxr_mutate_params(const SfxrParams& base, float amount, std::mt19937& rng);
// Convert params to Python dict
PyObject* sfxr_params_to_dict(const SfxrParams& params);

View file

@ -734,15 +734,32 @@ public:
// =============================================================================
class SoundBuffer {
unsigned int sampleRate_ = 44100;
unsigned int channelCount_ = 1;
std::size_t sampleCount_ = 0;
public:
SoundBuffer() = default;
// In headless mode, pretend sound loading succeeded
bool loadFromFile(const std::string& filename) { return true; }
bool loadFromMemory(const void* data, size_t sizeInBytes) { return true; }
Time getDuration() const { return Time(); }
bool loadFromSamples(const Int16* samples, Uint64 sampleCount, unsigned int channelCount, unsigned int sampleRate) {
sampleCount_ = sampleCount;
channelCount_ = channelCount;
sampleRate_ = sampleRate;
return true;
}
Time getDuration() const {
if (sampleRate_ == 0 || channelCount_ == 0) return Time();
float secs = static_cast<float>(sampleCount_) / static_cast<float>(channelCount_) / static_cast<float>(sampleRate_);
return seconds(secs);
}
unsigned int getSampleRate() const { return sampleRate_; }
unsigned int getChannelCount() const { return channelCount_; }
Uint64 getSampleCount() const { return sampleCount_; }
};
class Sound {
float pitch_ = 1.0f;
public:
enum Status { Stopped, Paused, Playing };
@ -759,6 +776,8 @@ public:
float getVolume() const { return 100.0f; }
void setLoop(bool loop) {}
bool getLoop() const { return false; }
void setPitch(float pitch) { pitch_ = pitch; }
float getPitch() const { return pitch_; }
};
class Music {

View file

@ -983,8 +983,52 @@ public:
return true;
}
bool loadFromSamples(const Int16* samples, Uint64 sampleCount, unsigned int channelCount, unsigned int sampleRate) {
if (chunk_) { Mix_FreeChunk(chunk_); chunk_ = nullptr; }
// Build a WAV file in memory: 44-byte header + PCM data
uint32_t dataSize = static_cast<uint32_t>(sampleCount * sizeof(Int16));
uint32_t fileSize = 44 + dataSize;
std::vector<uint8_t> wav(fileSize);
uint8_t* p = wav.data();
// RIFF header
memcpy(p, "RIFF", 4); p += 4;
uint32_t chunkSize = fileSize - 8;
memcpy(p, &chunkSize, 4); p += 4;
memcpy(p, "WAVE", 4); p += 4;
// fmt sub-chunk
memcpy(p, "fmt ", 4); p += 4;
uint32_t fmtSize = 16;
memcpy(p, &fmtSize, 4); p += 4;
uint16_t audioFormat = 1; // PCM
memcpy(p, &audioFormat, 2); p += 2;
uint16_t numChannels = static_cast<uint16_t>(channelCount);
memcpy(p, &numChannels, 2); p += 2;
uint32_t sr = sampleRate;
memcpy(p, &sr, 4); p += 4;
uint32_t byteRate = sampleRate * channelCount * 2;
memcpy(p, &byteRate, 4); p += 4;
uint16_t blockAlign = static_cast<uint16_t>(channelCount * 2);
memcpy(p, &blockAlign, 2); p += 2;
uint16_t bitsPerSample = 16;
memcpy(p, &bitsPerSample, 2); p += 2;
// data sub-chunk
memcpy(p, "data", 4); p += 4;
memcpy(p, &dataSize, 4); p += 4;
memcpy(p, samples, dataSize);
// Load via SDL_mixer
SDL_RWops* rw = SDL_RWFromConstMem(wav.data(), static_cast<int>(fileSize));
if (!rw) return false;
chunk_ = Mix_LoadWAV_RW(rw, 1);
if (!chunk_) return false;
computeDuration();
return true;
}
Time getDuration() const { return duration_; }
Mix_Chunk* getChunk() const { return chunk_; }
unsigned int getSampleRate() const { return 44100; } // SDL_mixer default
unsigned int getChannelCount() const { return 1; } // Approximate
Uint64 getSampleCount() const { return chunk_ ? chunk_->alen / 2 : 0; }
private:
void computeDuration() {
@ -1106,6 +1150,10 @@ public:
void setLoop(bool loop) { loop_ = loop; }
bool getLoop() const { return loop_; }
// Pitch: SDL_mixer doesn't support per-channel pitch, so store value only
void setPitch(float pitch) { pitch_ = pitch; }
float getPitch() const { return pitch_; }
// Called by Mix_ChannelFinished callback
static void onChannelFinished(int channel) {
if (channel >= 0 && channel < 16 && g_channelOwners[channel]) {
@ -1118,6 +1166,7 @@ private:
Mix_Chunk* chunk_ = nullptr; // Borrowed from SoundBuffer
int channel_ = -1;
float volume_ = 100.f;
float pitch_ = 1.0f;
bool loop_ = false;
};

File diff suppressed because it is too large Load diff

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)