Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pedalboard/plugins/Bitcrush.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ template <typename SampleType> 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 {}
Expand Down Expand Up @@ -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
118 changes: 117 additions & 1 deletion tests/test_bitcrush.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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)


Expand All @@ -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)}"
)
Loading