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

@ -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,17 +124,40 @@ 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;
}
self->data = std::make_shared<PySound>(filename);
if (PyUnicode_Check(source_obj)) {
// String filename path
const char* filename = PyUnicode_AsUTF8(source_obj);
if (!filename) return -1;
if (!self->data->loaded) {
PyErr_Format(PyExc_RuntimeError, "Failed to load sound file: %s", filename);
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;
}
@ -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}
};