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

@ -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,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}
};

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;
};