diff --git a/pedalboard/plugins/Bitcrush.h b/pedalboard/plugins/Bitcrush.h index 1447e11e..64b251c6 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); 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)`` values."); } }; // namespace Pedalboard diff --git a/tests/test_bitcrush.py b/tests/test_bitcrush.py index cd3edf40..4f963c78 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 (industry standard 2^(n-1)).""" + return 2 ** (bit_depth - 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,113 @@ 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. 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 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 + 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_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) + + plugin = Bitcrush(1) + output = plugin.process(samples, sample_rate) + + unique_values = np.unique(np.round(output, decimals=5)) + assert len(unique_values) <= 3, ( + f"bit_depth=1 should produce at most 3 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/32768, 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)}" + )