diff --git a/pedalboard/process.h b/pedalboard/process.h index 22e0e265..1e01e921 100644 --- a/pedalboard/process.h +++ b/pedalboard/process.h @@ -18,6 +18,7 @@ #pragma once #include "JuceHeader.h" +#include #include #include @@ -29,6 +30,44 @@ namespace py = pybind11; namespace Pedalboard { +/** + * Check if the given audio buffer looks like it contains integer-valued + * samples (e.g.: raw 16-bit PCM values like -32768 to 32767) rather than + * the expected floating-point samples in the [-1, 1] range. + * + * The heuristic: if EVERY sample is an exact integer (f == std::trunc(f)) + * AND at least one sample falls outside [-1, 1], the user almost certainly + * passed unconverted integer data. + */ +inline void +throwIfInputLooksLikeIntegerSamples(const juce::AudioBuffer &buffer) { + bool allInteger = true; + bool anyOutsideUnitRange = false; + + for (int c = 0; c < buffer.getNumChannels() && allInteger; c++) { + const float *data = buffer.getReadPointer(c); + for (int s = 0; s < buffer.getNumSamples(); s++) { + float f = data[s]; + if (f != std::trunc(f)) { + allInteger = false; + break; + } + if (f < -1.0f || f > 1.0f) { + anyOutsideUnitRange = true; + } + } + } + + if (allInteger && anyOutsideUnitRange) { + throw std::domain_error( + "The provided audio data looks like it contains integer samples " + "(all values are whole numbers, with at least one outside the " + "[-1, 1] range). Pedalboard expects floating-point audio samples " + "in the range [-1.0, 1.0]. If your audio is 16-bit integer data, " + "divide by 32768.0; if 32-bit integer, divide by 2147483648.0."); + } +} + inline int process(juce::AudioBuffer &ioBuffer, juce::dsp::ProcessSpec spec, const std::vector> &plugins, @@ -174,6 +213,8 @@ processFloat32(const py::array_t inputArray, juce::AudioBuffer ioBuffer = copyPyArrayIntoJuceBuffer(inputArray, {inputChannelLayout}); + throwIfInputLooksLikeIntegerSamples(ioBuffer); + if (ioBuffer.getNumChannels() == 0) { unsigned int numChannels = 0; unsigned int numSamples = ioBuffer.getNumSamples(); diff --git a/tests/test_python_interface.py b/tests/test_python_interface.py index bdf4d9e7..ab95fa3e 100644 --- a/tests/test_python_interface.py +++ b/tests/test_python_interface.py @@ -102,3 +102,42 @@ def test_is_list_like(): with pytest.raises(TypeError): pb[0] = "not a plugin" # type: ignore + + +def test_error_on_integer_samples_outside_unit_range(sr=44100): + """Passing integer-valued samples that exceed [-1, 1] should raise a + descriptive error telling the user to convert to float.""" + # Simulate raw 16-bit PCM data cast to float32 + int_samples = np.array([-32768, -16384, 0, 16384, 32767], dtype=np.float32) + with pytest.raises(Exception, match="integer samples"): + Pedalboard([Gain(0)]).process(int_samples, sr) + + +def test_error_on_integer_samples_2d(sr=44100): + """Same check should trigger for 2-D (multi-channel) arrays.""" + int_samples = np.array([[0, 100, 200, -300]], dtype=np.float32) + with pytest.raises(Exception, match="integer samples"): + Pedalboard([Gain(0)]).process(int_samples, sr) + + +def test_no_error_on_silence(sr=44100): + """All-zero (silence) is integer-valued but within [-1, 1], so it + must NOT trigger the integer-sample check.""" + silence = np.zeros(44100, dtype=np.float32) + output = Pedalboard([Gain(0)]).process(silence, sr) + assert output.shape == silence.shape + + +def test_no_error_on_normal_float_audio(sr=44100): + """Normal float audio in [-1, 1] should process without error.""" + audio = np.random.uniform(-1.0, 1.0, 44100).astype(np.float32) + output = Pedalboard([Gain(0)]).process(audio, sr) + assert output.shape == audio.shape + + +def test_no_error_on_quiet_integer_valued_audio(sr=44100): + """Integer-valued samples that stay within [-1, 1] (e.g. 0, 1, -1) + should NOT trigger the check, to avoid false positives.""" + quiet = np.array([0.0, 1.0, -1.0, 0.0, 1.0], dtype=np.float32) + output = Pedalboard([Gain(0)]).process(quiet, sr) + assert output.shape == quiet.shape