Audio fixes: gain() DSP effect, sfxr phase wrap, SDL2 backend compat

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-02-20 23:17:41 -05:00
commit 732897426a
9 changed files with 125 additions and 1 deletions

View file

@ -18,7 +18,10 @@ PySound::PySound(std::shared_ptr<SoundBufferData> bufData)
: source("<SoundBuffer>"), loaded(false), bufferData(bufData)
{
if (bufData && !bufData->samples.empty()) {
buffer = bufData->getSfBuffer();
// Rebuild the sf::SoundBuffer from sample data directly
// (avoids copy-assign which is deleted on SDL2 backend)
buffer.loadFromSamples(bufData->samples.data(), bufData->samples.size(),
bufData->channels, bufData->sampleRate);
sound.setBuffer(buffer);
loaded = true;
}

View file

@ -446,6 +446,16 @@ PyObject* PySoundBuffer::bit_crush(PySoundBufferObject* self, PyObject* args) {
return PySoundBuffer_from_data(std::move(data));
}
PyObject* PySoundBuffer::gain(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; }
auto result = AudioEffects::gain(self->data->samples, 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::normalize(PySoundBufferObject* self, PyObject* args) {
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid SoundBuffer"); return NULL; }
@ -728,6 +738,13 @@ PyMethodDef PySoundBuffer::methods[] = {
MCRF_SIG("(bits: int, rate_divisor: int)", "SoundBuffer"),
MCRF_DESC("Reduce bit depth and sample rate for lo-fi effect.")
)},
{"gain", (PyCFunction)PySoundBuffer::gain, METH_VARARGS,
MCRF_METHOD(SoundBuffer, gain,
MCRF_SIG("(factor: float)", "SoundBuffer"),
MCRF_DESC("Multiply all samples by a scalar factor. Use for volume/amplitude control before mixing."),
MCRF_ARGS_START
MCRF_ARG("factor", "Amplitude multiplier (0.5 = half volume, 2.0 = double). Clamps to int16 range.")
)},
{"normalize", (PyCFunction)PySoundBuffer::normalize, METH_NOARGS,
MCRF_METHOD(SoundBuffer, normalize,
MCRF_SIG("()", "SoundBuffer"),

View file

@ -72,6 +72,7 @@ namespace PySoundBuffer {
PyObject* reverb(PySoundBufferObject* self, PyObject* args);
PyObject* distortion(PySoundBufferObject* self, PyObject* args);
PyObject* bit_crush(PySoundBufferObject* self, PyObject* args);
PyObject* gain(PySoundBufferObject* self, PyObject* args);
PyObject* normalize(PySoundBufferObject* self, PyObject* args);
PyObject* reverse(PySoundBufferObject* self, PyObject* args);
PyObject* slice(PySoundBufferObject* self, PyObject* args);

View file

@ -289,6 +289,21 @@ std::vector<int16_t> normalize(const std::vector<int16_t>& samples) {
return result;
}
// ============================================================================
// Gain (multiply all samples by scalar factor)
// ============================================================================
std::vector<int16_t> gain(const std::vector<int16_t>& samples, double factor) {
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] * factor;
result[i] = static_cast<int16_t>(std::max(-32768.0, std::min(32767.0, s)));
}
return result;
}
// ============================================================================
// Reverse (frame-aware for multichannel)
// ============================================================================

View file

@ -32,6 +32,9 @@ std::vector<int16_t> bitCrush(const std::vector<int16_t>& samples, int bits, int
// Scale to 95% of int16 max
std::vector<int16_t> normalize(const std::vector<int16_t>& samples);
// Multiply all samples by a scalar factor (volume/amplitude control)
std::vector<int16_t> gain(const std::vector<int16_t>& samples, double factor);
// Reverse sample order (frame-aware for multichannel)
std::vector<int16_t> reverse(const std::vector<int16_t>& samples, unsigned int channels);

View file

@ -218,6 +218,17 @@ std::vector<int16_t> sfxr_synthesize(const SfxrParams& p) {
for (int si2 = 0; si2 < OVERSAMPLE; si2++) {
double sample = 0.0;
phase++;
// Wrap phase at period boundary (critical for square/saw waveforms)
if (phase >= period) {
phase %= period;
if (p.wave_type == 3) { // Refresh noise buffer each period
for (int i = 0; i < 32; i++) {
noise_buffer[i] = ((std::rand() % 20001) / 10000.0) - 1.0;
}
}
}
double fphase = static_cast<double>(phase) / period;
// Waveform generation

View file

@ -459,6 +459,12 @@ public:
pixels_.resize(width * height * 4, 0);
}
void create(unsigned int width, unsigned int height, const Uint8* pixels) {
size_ = Vector2u(width, height);
size_t byteCount = static_cast<size_t>(width) * height * 4;
pixels_.assign(pixels, pixels + byteCount);
}
bool loadFromFile(const std::string& filename) { return false; }
bool saveToFile(const std::string& filename) const { return false; }

View file

@ -621,6 +621,12 @@ public:
}
}
void create(unsigned int width, unsigned int height, const Uint8* pixels) {
size_ = Vector2u(width, height);
size_t byteCount = static_cast<size_t>(width) * height * 4;
pixels_.assign(pixels, pixels + byteCount);
}
bool loadFromFile(const std::string& filename); // Implemented in SDL2Renderer.cpp (uses stb_image)
bool saveToFile(const std::string& filename) const; // Implemented in SDL2Renderer.cpp (uses stb_image_write)