From 6fcd708b04e3cd5f8d29850fdcc0a9a2e80cd688 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 18 Mar 2026 12:11:58 +0200 Subject: [PATCH 1/2] Bitcrush: fix quantization formula and add tests for signed audio symmetry The scale factor was `pow(2, bitDepth)` which doesn't properly center quantization around zero for signed audio. Changed to `pow(2, bitDepth - 1) + 1` so that the quantization is symmetric. Added tests verifying quantization correctness, symmetry around zero, output range for signed audio, edge cases (bit_depth=1 and 16), and expected number of quantization levels. Fixes #396 Related: #397 Co-Authored-By: Claude Opus 4.6 --- pedalboard/plugins/Bitcrush.h | 4 +- tests/test_bitcrush.py | 120 +++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/pedalboard/plugins/Bitcrush.h b/pedalboard/plugins/Bitcrush.h index 1447e11e0..e043aef69 100644 --- a/pedalboard/plugins/Bitcrush.h +++ b/pedalboard/plugins/Bitcrush.h @@ -38,7 +38,7 @@ template class Bitcrush : public Plugin { }; virtual void prepare(const juce::dsp::ProcessSpec &spec) override { - scaleFactor = pow(2, bitDepth); + scaleFactor = pow(2, bitDepth - 1) + 1; inverseScaleFactor = 1.0 / scaleFactor; } virtual void reset() override {} @@ -114,6 +114,6 @@ inline void init_bitcrush(py::module &m) { BITCRUSH_MAX_BIT_DEPTH) " bits. May be an integer, decimal, or " "floating-point value. Each audio " "sample will be quantized onto ``2 ** " - "bit_depth`` values."); + "bit_depth - 1) + 1`` values."); } }; // namespace Pedalboard diff --git a/tests/test_bitcrush.py b/tests/test_bitcrush.py index cd3edf409..6b283e410 100644 --- a/tests/test_bitcrush.py +++ b/tests/test_bitcrush.py @@ -22,6 +22,11 @@ from .utils import generate_sine_at +def _scale_factor(bit_depth): + """Return the scale factor that Bitcrush uses internally after the fix.""" + return 2 ** (bit_depth - 1) + 1 + + @pytest.mark.parametrize("bit_depth", list(np.arange(1, 32, 0.5))) @pytest.mark.parametrize("fundamental_hz", [440]) @pytest.mark.parametrize("sample_rate", [22050, 48000]) @@ -36,7 +41,8 @@ def test_bitcrush(bit_depth: float, fundamental_hz: float, sample_rate: float, n assert np.all(np.isfinite(output)) - expected_output = np.around(sine_wave.astype(np.float64) * (2**bit_depth)) / (2**bit_depth) + sf = _scale_factor(bit_depth) + expected_output = np.around(sine_wave.astype(np.float64) * sf) / sf np.testing.assert_allclose(output, expected_output, atol=0.01) @@ -45,3 +51,115 @@ def test_invalid_bit_depth_raises_exception(): Bitcrush(bit_depth=-5) with pytest.raises(ValueError): Bitcrush(bit_depth=100) + + +class TestBitcrushQuantizationFormula: + """Tests that verify the corrected Bitcrush quantization formula (issue #396).""" + + @pytest.mark.parametrize("bit_depth", [1, 2, 4, 8, 16]) + def test_output_is_quantized(self, bit_depth: int): + """Output samples should snap to a discrete set of quantized levels.""" + sample_rate = 44100.0 + # Use a ramp from -1 to 1 so we cover the full range + samples = np.linspace(-1.0, 1.0, 4096, dtype=np.float32).reshape(1, -1) + + plugin = Bitcrush(bit_depth) + output = plugin.process(samples, sample_rate) + + sf = _scale_factor(bit_depth) + # Every output sample, when multiplied by scaleFactor, should be (close + # to) an integer — that is the definition of quantization. + quantized_indices = output * sf + np.testing.assert_allclose( + quantized_indices, + np.round(quantized_indices), + atol=1e-5, + err_msg=( + f"Output samples are not properly quantized at bit_depth={bit_depth}" + ), + ) + + def test_bit_depth_8_output_range_signed(self): + """For bit_depth=8 the output should stay within [-1, 1] and include + negative values — i.e. quantization is centered around zero for signed + audio.""" + sample_rate = 44100.0 + samples = np.linspace(-1.0, 1.0, 4096, dtype=np.float32).reshape(1, -1) + + plugin = Bitcrush(8) + output = plugin.process(samples, sample_rate) + + # Output must be within [-1, 1] + assert np.all(output >= -1.0 - 1e-6), "Output has values below -1" + assert np.all(output <= 1.0 + 1e-6), "Output has values above 1" + + # Must have both positive and negative values (symmetric around zero) + assert np.any(output > 0), "Output has no positive values" + assert np.any(output < 0), "Output has no negative values" + + @pytest.mark.parametrize("bit_depth", [2, 4, 8, 16]) + def test_quantization_symmetric_around_zero(self, bit_depth: int): + """Quantizing a symmetric input signal should produce a symmetric + output: quantize(-x) == -quantize(x) for all x.""" + sample_rate = 44100.0 + positive = np.linspace(0.0, 1.0, 2048, dtype=np.float32).reshape(1, -1) + negative = -positive + + plugin = Bitcrush(bit_depth) + out_pos = plugin.process(positive, sample_rate) + out_neg = plugin.process(negative, sample_rate) + + np.testing.assert_allclose( + out_neg, + -out_pos, + atol=1e-6, + err_msg=f"Quantization is not symmetric around zero at bit_depth={bit_depth}", + ) + + def test_bit_depth_1_produces_three_or_fewer_levels(self): + """With bit_depth=1 the scale factor is 2^0 + 1 = 2, so the only + representable values are -1, -0.5, 0, 0.5, 1 (at most 5 levels). + The exact count depends on rounding, but it must be a very small set.""" + sample_rate = 44100.0 + samples = np.linspace(-1.0, 1.0, 8192, dtype=np.float32).reshape(1, -1) + + plugin = Bitcrush(1) + output = plugin.process(samples, sample_rate) + + unique_values = np.unique(np.round(output, decimals=5)) + # scale factor = 2, so representable levels = round(x*2)/2 + # for x in [-1,1]: levels are {-1, -0.5, 0, 0.5, 1} = 5 + assert len(unique_values) <= 5, ( + f"bit_depth=1 should produce at most 5 unique output levels, " + f"got {len(unique_values)}: {unique_values}" + ) + + def test_bit_depth_16_preserves_signal_closely(self): + """At bit_depth=16, quantization should be very fine — the output + should be nearly identical to the input.""" + sample_rate = 44100.0 + samples = np.linspace(-1.0, 1.0, 4096, dtype=np.float32).reshape(1, -1) + + plugin = Bitcrush(16) + output = plugin.process(samples, sample_rate) + + # At 16 bits the step size is ~1/32769, so max error < 2e-5 + np.testing.assert_allclose(output, samples, atol=2e-5) + + def test_number_of_quantization_levels(self): + """The number of distinct output levels for a full-range signal should + be close to 2 * scaleFactor + 1 (levels from -sf to +sf mapped back).""" + sample_rate = 44100.0 + for bit_depth in [2, 4, 8]: + samples = np.linspace(-1.0, 1.0, 65536, dtype=np.float32).reshape(1, -1) + plugin = Bitcrush(bit_depth) + output = plugin.process(samples, sample_rate) + + sf = _scale_factor(bit_depth) + unique_values = np.unique(np.round(output, decimals=6)) + expected_levels = int(2 * sf) + 1 # from -sf/sf to +sf/sf in 1/sf steps + # Allow some tolerance — rounding at boundaries may merge levels + assert abs(len(unique_values) - expected_levels) <= 2, ( + f"bit_depth={bit_depth}: expected ~{expected_levels} levels, " + f"got {len(unique_values)}" + ) From c7f17b903597c4822532fb9be4862f2e84452616 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 18 Mar 2026 12:34:52 +0200 Subject: [PATCH 2/2] Bitcrush: use industry-standard 2^(n-1) scale factor The previous fix used pow(2, bitDepth - 1) + 1 which, while symmetric, can cause the negative peak (-2^(n-1)) to exceed -1.0 and clip. The industry standard (used by FFmpeg, JUCE, VST SDKs) is pow(2, bitDepth - 1) which maps the range to [-1.0, 0.9999...], preserving full dynamic range without risk of clipping. Co-Authored-By: Claude Opus 4.6 --- pedalboard/plugins/Bitcrush.h | 4 ++-- tests/test_bitcrush.py | 26 ++++++++++++-------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/pedalboard/plugins/Bitcrush.h b/pedalboard/plugins/Bitcrush.h index e043aef69..64b251c6b 100644 --- a/pedalboard/plugins/Bitcrush.h +++ b/pedalboard/plugins/Bitcrush.h @@ -38,7 +38,7 @@ template class Bitcrush : public Plugin { }; virtual void prepare(const juce::dsp::ProcessSpec &spec) override { - scaleFactor = pow(2, bitDepth - 1) + 1; + scaleFactor = pow(2, bitDepth - 1); inverseScaleFactor = 1.0 / scaleFactor; } virtual void reset() override {} @@ -114,6 +114,6 @@ inline void init_bitcrush(py::module &m) { BITCRUSH_MAX_BIT_DEPTH) " bits. May be an integer, decimal, or " "floating-point value. Each audio " "sample will be quantized onto ``2 ** " - "bit_depth - 1) + 1`` values."); + "(bit_depth - 1)`` values."); } }; // namespace Pedalboard diff --git a/tests/test_bitcrush.py b/tests/test_bitcrush.py index 6b283e410..4f963c78d 100644 --- a/tests/test_bitcrush.py +++ b/tests/test_bitcrush.py @@ -23,8 +23,8 @@ def _scale_factor(bit_depth): - """Return the scale factor that Bitcrush uses internally after the fix.""" - return 2 ** (bit_depth - 1) + 1 + """Return the scale factor that Bitcrush uses internally (industry standard 2^(n-1)).""" + return 2 ** (bit_depth - 1) @pytest.mark.parametrize("bit_depth", list(np.arange(1, 32, 0.5))) @@ -82,18 +82,19 @@ def test_output_is_quantized(self, bit_depth: int): def test_bit_depth_8_output_range_signed(self): """For bit_depth=8 the output should stay within [-1, 1] and include negative values — i.e. quantization is centered around zero for signed - audio.""" + audio. With the industry-standard 2^(n-1) divisor, the positive peak + maps to slightly below 1.0 and negative peak maps to exactly -1.0.""" sample_rate = 44100.0 samples = np.linspace(-1.0, 1.0, 4096, dtype=np.float32).reshape(1, -1) plugin = Bitcrush(8) output = plugin.process(samples, sample_rate) - # Output must be within [-1, 1] + # Output must not exceed [-1, 1] assert np.all(output >= -1.0 - 1e-6), "Output has values below -1" assert np.all(output <= 1.0 + 1e-6), "Output has values above 1" - # Must have both positive and negative values (symmetric around zero) + # Must have both positive and negative values assert np.any(output > 0), "Output has no positive values" assert np.any(output < 0), "Output has no negative values" @@ -116,10 +117,9 @@ def test_quantization_symmetric_around_zero(self, bit_depth: int): err_msg=f"Quantization is not symmetric around zero at bit_depth={bit_depth}", ) - def test_bit_depth_1_produces_three_or_fewer_levels(self): - """With bit_depth=1 the scale factor is 2^0 + 1 = 2, so the only - representable values are -1, -0.5, 0, 0.5, 1 (at most 5 levels). - The exact count depends on rounding, but it must be a very small set.""" + def test_bit_depth_1_produces_few_levels(self): + """With bit_depth=1 the scale factor is 2^0 = 1, so the only + representable values are -1, 0, 1 (at most 3 levels).""" sample_rate = 44100.0 samples = np.linspace(-1.0, 1.0, 8192, dtype=np.float32).reshape(1, -1) @@ -127,10 +127,8 @@ def test_bit_depth_1_produces_three_or_fewer_levels(self): output = plugin.process(samples, sample_rate) unique_values = np.unique(np.round(output, decimals=5)) - # scale factor = 2, so representable levels = round(x*2)/2 - # for x in [-1,1]: levels are {-1, -0.5, 0, 0.5, 1} = 5 - assert len(unique_values) <= 5, ( - f"bit_depth=1 should produce at most 5 unique output levels, " + assert len(unique_values) <= 3, ( + f"bit_depth=1 should produce at most 3 unique output levels, " f"got {len(unique_values)}: {unique_values}" ) @@ -143,7 +141,7 @@ def test_bit_depth_16_preserves_signal_closely(self): plugin = Bitcrush(16) output = plugin.process(samples, sample_rate) - # At 16 bits the step size is ~1/32769, so max error < 2e-5 + # At 16 bits the step size is ~1/32768, so max error < 2e-5 np.testing.assert_allclose(output, samples, atol=2e-5) def test_number_of_quantization_levels(self):