From 867305d23a34a9d1b14b71c50b6fd032f72d4db9 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:08:08 -0500 Subject: [PATCH 1/4] Take CircuitPython 11.0.0-alpha.2's integer mixer and inline synthio_sat16 audiomixer (11a329885, fc57c0f36; adafruit/circuitpython#11617): a voice is scaled as (ai * mul) >> 15 where it was ai * (mul / 32767.0f) truncated toward zero, and a centred pan is 32768 - panning where it was 32767 - panning. Taken in upstream's words in src/audiomixer/Mixer.c, in the CPython extension's scaling kernel and in src/cpython/audiomixer.py, whose float32 helpers go. Every level below 1.0 moves by up to one LSB, and a small negative sample now rounds down (-1 at level 0.5 stays -1). A voice at level 1.0 is a wire without help, so the unity branch this port added in audiodsp#95 is deleted, and a stereo voice at pan 0 no longer has its left channel one step below the right. The CPython target's block-level pass-through at unity is keyed on Q15 32768 alone now: 32767 no longer multiplies by exactly 1. synthio (cbbde1208; adafruit/circuitpython#11621): synthio_sat16 is inline in src/synthio/__init__.h as upstream has it, with __ssat on cores that define __ARM_FEATURE_SAT. The rounding is the same arithmetic without a branch, so no sample changes. mixer_q15_level_probe.py replaces mixer_level_precision_probe.py, whose material was the float32-versus-float64 flips that no longer exist. It renders the rails and the values either side of zero at four levels and five pans, mono and stereo, and verify_dsp.py holds CircuitPython, MicroPython and CPython to the same bytes. test_cpython_audiomixer_level.py and test_cpython_mixer_scale.py are rewritten around the integer arithmetic. --- src/audiomixer/Mixer.c | 19 +- src/cpython/_audiodsp.c | 26 +- src/cpython/audiomixer.py | 83 +----- src/synthio/__init__.c | 5 - src/synthio/__init__.h | 22 +- tests/parity/mixer_borrowed_block_probe.py | 6 +- tests/parity/mixer_level_precision_probe.py | 75 ----- tests/parity/mixer_q15_level_probe.py | 67 +++++ tests/parity/verify_dsp.py | 2 +- tests/test_cpython_audiomixer_level.py | 305 ++++++++------------ tests/test_cpython_mixer_scale.py | 8 +- 11 files changed, 245 insertions(+), 373 deletions(-) delete mode 100644 tests/parity/mixer_level_precision_probe.py create mode 100644 tests/parity/mixer_q15_level_probe.py diff --git a/src/audiomixer/Mixer.c b/src/audiomixer/Mixer.c index a5ac12a2..1c000e19 100644 --- a/src/audiomixer/Mixer.c +++ b/src/audiomixer/Mixer.c @@ -264,24 +264,11 @@ static inline uint32_t add16signed(uint32_t a, uint32_t b) { return result; } -// Deviation from upstream: a multiplier of 1<<15 is unity, and passes the -// sample through. Upstream maps a voice level of 1.0 to Q15 32768 and then -// scales by `level / 32767.0f`, so unity is really 1.0000305 and every -// sample at |value| >= 32736 comes out one LSB larger. See -// docs/upstream-diff.md, "A voice at level 1.0 is a wire". Only the unity -// multiplier takes this branch; every other level is upstream's arithmetic, -// bit for bit. static inline uint32_t mult16signed(uint32_t val, int32_t lomul, int32_t himul) { uint32_t result = 0; for (int8_t i = 0; i < 2; i++) { - int32_t mul = i ? himul : lomul; int16_t ai = (val >> (sizeof(uint16_t) * 8 * i)); - if (mul == (1 << 15)) { - result |= (((uint32_t)ai) & 0xffff) << (sizeof(int16_t) * 8 * i); - continue; - } - float mod_mul = (float)mul / (float)((1 << 15) - 1); - int32_t intermediate = (int32_t)(ai * mod_mul); + int32_t intermediate = (ai * (i ? himul : lomul)) >> 15; if (intermediate > SHRT_MAX) { intermediate = SHRT_MAX; } else if (intermediate < SHRT_MIN) { @@ -430,9 +417,9 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, // panning > 0 attenuates the LEFT channel. CircuitPython 10.3.0 // flipped this and matched synthio.Note to it. if (panning >= 0) { - left_panning_scaled = 32767 - panning; + left_panning_scaled = 32768 - panning; } else { - right_panning_scaled = 32767 + panning; + right_panning_scaled = 32768 + panning; } } diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 8977adc2..19bd1643 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -4538,17 +4538,9 @@ static PyObject *audiodsp_mix_s16(PyObject *module, PyObject *args) { return result; } -/* One voice's level, as the single-precision multiplier the native mixer - * uses: `(float)level / (float)32767`, with Q15 32768 (a level of 1.0) as an - * exact 1.0. `_mod_mul` in audiomixer.py is the same arithmetic. */ -static float mixer_mod_mul(long level) { - if (level == 32768) return 1.0f; - return (float)level / (float)32767; -} - /* Scale a block of interleaved int16 pairs by a voice's per-channel level, the * loop `Mixer._get_buffer` runs in Python for any voice that is not at - * unity: product in float32, truncation toward zero, clamp to int16. A level + * unity: `(sample * level) >> 15`, clamp to int16. A level * move (`pending`) lands at the first zero crossing, or is forced at each * index in `forced` (ascending sample indices, where a source stretch ends), * exactly as the Python loop does. */ @@ -4581,8 +4573,8 @@ static PyObject *audiodsp_scale_s16_levels(PyObject *module, PyObject *args) { int16_t *out = (int16_t *)PyBytes_AS_STRING(result); const Py_ssize_t count = data.len / 2; long active[2] = {active_lo, active_hi}; - float mul_lo = mixer_mod_mul(active[0]); - float mul_hi = mixer_mod_mul(active[1]); + long mul_lo = active[0]; + long mul_hi = active[1]; int32_t last_lo = 0, last_hi = 0; Py_ssize_t next_forced = 0; for (Py_ssize_t index = 0; index < count; index += 2) { @@ -4599,8 +4591,8 @@ static PyObject *audiodsp_scale_s16_levels(PyObject *module, PyObject *args) { if (at == index && index) { active[0] = pending_lo; active[1] = pending_hi; - mul_lo = mixer_mod_mul(active[0]); - mul_hi = mixer_mod_mul(active[1]); + mul_lo = active[0]; + mul_hi = active[1]; last_lo = last_hi = 0; } break; @@ -4615,18 +4607,18 @@ static PyObject *audiodsp_scale_s16_levels(PyObject *module, PyObject *args) { && ((last_lo < 0) != (lo < 0) || (last_hi < 0) != (hi < 0)))) { active[0] = pending_lo; active[1] = pending_hi; - mul_lo = mixer_mod_mul(active[0]); - mul_hi = mixer_mod_mul(active[1]); + mul_lo = active[0]; + mul_hi = active[1]; } else { last_lo = lo; last_hi = hi; } } - int32_t scaled = (int32_t)((float)lo * mul_lo); + int32_t scaled = (int32_t)((lo * mul_lo) >> 15); out[index] = (int16_t)(scaled > INT16_MAX ? INT16_MAX : scaled < INT16_MIN ? INT16_MIN : scaled); if (index + 1 < count) { - scaled = (int32_t)((float)hi * mul_hi); + scaled = (int32_t)((hi * mul_hi) >> 15); out[index + 1] = (int16_t)(scaled > INT16_MAX ? INT16_MAX : scaled < INT16_MIN ? INT16_MIN : scaled); } diff --git a/src/cpython/audiomixer.py b/src/cpython/audiomixer.py index 25dbefdb..501cf689 100644 --- a/src/cpython/audiomixer.py +++ b/src/cpython/audiomixer.py @@ -1,61 +1,13 @@ """CircuitPython-compatible PCM mixer.""" -from struct import pack as _pack, unpack as _unpack - from audiocore import ( GET_BUFFER_MORE_DATA, _AudioSample, _borrow, reset_buffer, ) import _audiodsp -def _f32(value): - """`value` rounded to `float`, the way a C `(float)` cast rounds it. - - `struct` is the standard library and this wheel has no dependencies, so - this is not numpy: `audiomixer` is a core module that has to import on a - bare install. See `_mod_mul` for why it is here at all. - """ - return _unpack("f", _pack("f", value))[0] - - -def _mod_mul(level): - """A voice's Q15 level as the single-precision multiplier the native - kernel uses. - - `src/audiomixer/Mixer.c`'s `mult16signed` computes - - float mod_mul = (float)level / (float)((1 << 15) - 1); - int32_t intermediate = (int32_t)(ai * mod_mul); - - -- the quotient and the product both in `float`, then truncation toward - zero. This twin used to form the same product in `float64` and truncate - that, which lands on a different integer whenever the true product sits - within a float32 rounding of one: 56 of the 65536 `int16` values at level - 100/127 alone, each off by a whole LSB. Inaudible at -90 dBFS, but the - effects program's class gate compares CPython bytes with MicroPython bytes, - so it read as nondeterminism. audiodsp#84. - - Both roundings here are single, not double: `level` and 32767 are exact in - `float`, and a `float` divide computed in `double` and rounded once is the - correctly rounded `float` quotient (53 >= 2*24 + 2). The product of two - `float`s is exact in `double` for the same reason, so rounding it once is - the `float` product itself. - - The one deviation from upstream is the unity case. Q15 32768 is what a - voice level of 1.0 becomes, and `32768 / 32767` is 1.0000305, which lifts - every sample at |value| >= 32736 by one LSB. A level of 1.0 is a wire - here, on every target. See `docs/upstream-diff.md`, "A voice at level 1.0 - is a wire"; the native side is the same branch in `mult16signed`. - """ - if level == (1 << 15): - return 1.0 - return _f32(_f32(level) / _f32(32767.0)) - - -#: The Q15 levels `_mod_mul` maps to exactly 1.0: 32767 divided by itself, and -#: 32768, the unity special case. A stereo voice at panning 0 sits at -#: (32767, 32768), so both count. -_UNITY_Q15 = (32767, 32768) +#: Q15 32768 is a voice level of 1.0: `(x * 32768) >> 15` is `x`. +_UNITY_Q15 = 1 << 15 def _scale_s16_levels_py(data, active_lo, active_hi, pending_lo, pending_hi, forced): @@ -76,15 +28,14 @@ def _scale_s16_levels_py(data, active_lo, active_hi, pending_lo, pending_hi, for samples.frombytes(data) active = [active_lo, active_hi] pending = (pending_lo, pending_hi) - mul_lo, mul_hi = _mod_mul(active[0]), _mod_mul(active[1]) + mul_lo, mul_hi = active[0], active[1] last_lo = last_hi = 0 forced = set(forced) scaled = [] for index in range(0, len(samples), 2): if index in forced and index: active[0], active[1] = pending - mul_lo = _mod_mul(active[0]) - mul_hi = _mod_mul(active[1]) + mul_lo, mul_hi = active last_lo = last_hi = 0 lo = samples[index] hi = samples[index + 1] if index + 1 < len(samples) else 0 @@ -94,13 +45,12 @@ def _scale_s16_levels_py(data, active_lo, active_hi, pending_lo, pending_hi, for and ((last_lo < 0) != (lo < 0) or (last_hi < 0) != (hi < 0)))): active[0], active[1] = pending - mul_lo = _mod_mul(active[0]) - mul_hi = _mod_mul(active[1]) + mul_lo, mul_hi = active else: last_lo, last_hi = lo, hi - scaled.append(max(-32768, min(32767, int(_f32(lo * mul_lo))))) + scaled.append(max(-32768, min(32767, (lo * mul_lo) >> 15))) if index + 1 < len(samples): - scaled.append(max(-32768, min(32767, int(_f32(hi * mul_hi))))) + scaled.append(max(-32768, min(32767, (hi * mul_hi) >> 15))) return array.array("h", scaled).tobytes() @@ -326,20 +276,17 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): if self.channel_count == 2: # panning > 0 attenuates the LEFT channel. CircuitPython # 10.3.0 flipped this and matched synthio.Note to it. - left_scale = 32767 - panning if panning >= 0 else 32768 - right_scale = 32768 if panning >= 0 else 32767 + panning + left_scale = 32768 - panning if panning >= 0 else 32768 + right_scale = 32768 if panning >= 0 else 32768 + panning left = (left_scale * level) >> 15 right = (right_scale * level) >> 15 pending = (left, right) - if (voice._active_level[0] in _UNITY_Q15 - and voice._active_level[1] in _UNITY_Q15 - and left in _UNITY_Q15 and right in _UNITY_Q15): - # Unity on both channels, now and pending: the multiplier - # is exactly 1.0 for the whole block, whichever word a - # pending move would have landed on, and `int(_f32(x * - # 1.0))` is `x` for every int16. The block is a wire, so - # it is mixed as it came and the scaling below -- two struct - # round trips a sample -- is skipped. audiodsp#273. + if (voice._active_level == (_UNITY_Q15, _UNITY_Q15) + and left == _UNITY_Q15 and right == _UNITY_Q15): + # Unity on both channels, now and pending: `(x * 32768) + # >> 15` is `x` for every int16, whichever word a pending + # move would have landed on. The block is mixed as it came + # and the scaling below is skipped. audiodsp#273. voice._active_level = pending chunks.append(data) continue diff --git a/src/synthio/__init__.c b/src/synthio/__init__.c index 277d0b07..c96e09f8 100644 --- a/src/synthio/__init__.c +++ b/src/synthio/__init__.c @@ -51,11 +51,6 @@ static const int16_t square_wave[] = {-32768, 32767}; static const uint16_t notes[] = {8372, 8870, 9397, 9956, 10548, 11175, 11840, 12544, 13290, 14080, 14917, 15804}; // 9th octave -// cleaner sat16 by http://www.moseleyinstruments.com/ -int16_t synthio_sat16(int32_t n, int rshift) { - return audiodsp_sat16(n, rshift); -} - static int64_t round_float_to_int64(mp_float_t f) { return (int64_t)(f + MICROPY_FLOAT_CONST(0.5)); } diff --git a/src/synthio/__init__.h b/src/synthio/__init__.h index baf59249..7aab64f6 100644 --- a/src/synthio/__init__.h +++ b/src/synthio/__init__.h @@ -93,6 +93,10 @@ #include "cp_compat/namedtuple.h" #include "shared/audiodsp_envelope.h" +#if defined(__ARM_FEATURE_SAT) && __ARM_FEATURE_SAT +#include +#endif + #define SYNTHIO_WAVEFORM_SIZE 16384 typedef audiodsp_envelope_kind_t envelope_state_e; @@ -224,4 +228,20 @@ extern mp_float_t synthio_global_rate_scale, synthio_global_W_scale; extern uint8_t synthio_global_tick; void shared_bindings_synthio_lfo_tick(uint32_t sample_rate, uint16_t num_samples); -int16_t synthio_sat16(int32_t n, int rshift); +// cleaner sat16 by http://www.moseleyinstruments.com/ +static inline int16_t synthio_sat16(int32_t n, int rshift) { + // Round towards 0 to avoid recirculating round-off noise. + n += (n >> 31) & ((1 << rshift) - 1); + n >>= rshift; + #if defined(__ARM_FEATURE_SAT) && __ARM_FEATURE_SAT + return __ssat(n, 16); + #else + if (n > 32767) { + return 32767; + } + if (n < -32768) { + return -32768; + } + return n; + #endif +} diff --git a/tests/parity/mixer_borrowed_block_probe.py b/tests/parity/mixer_borrowed_block_probe.py index 859aac6c..e142578e 100644 --- a/tests/parity/mixer_borrowed_block_probe.py +++ b/tests/parity/mixer_borrowed_block_probe.py @@ -20,9 +20,9 @@ boards. audiodsp#89. A probe of its own rather than a case appended to -`mixer_level_precision_probe.py`: one comparison covers a probe's whole output, -so a case added there would move the very numbers that say the 56 flipping -values still scale the way they did. +`mixer_q15_level_probe.py`: one comparison covers a probe's whole output, +so a case added there would move the very numbers that say the Q15 scaling +still rounds the way it did. Agreement is the whole gate here for the usual reason and one more: the mixer's voice bookkeeping is C on two targets and Python on the third, so there is no diff --git a/tests/parity/mixer_level_precision_probe.py b/tests/parity/mixer_level_precision_probe.py deleted file mode 100644 index 5c7e93f7..00000000 --- a/tests/parity/mixer_level_precision_probe.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Every value an `audiomixer` voice scales differently in float32 and float64. - -The native kernel forms a voice's scaled sample in **single** precision -(`src/audiomixer/Mixer.c`, `mult16signed`: `float mod_mul = (float)level / -(float)((1 << 15) - 1); int32_t intermediate = (int32_t)(ai * mod_mul);`). -Both precisions truncate toward zero, so they part company exactly where the -true product sits within a float32 rounding of an integer -- and then they -differ by a whole LSB, not by a rounding. - -The material is the 56 `int16` values that flip at level `100/127`, enumerated -rather than sampled: 28 against the left channel's Q15 scale of 25800 and 28 -against the right's 25801. Each appears twice in a row, so each is scaled once -by the left scale and once by the right. The leading zero pair is what opens -the level gate on the first word, so no sample here renders at level zero. - -A float64 twin and a float32 native disagree on all 56. audiodsp#84. -""" - -from array import array - -import audiocore -import audiomixer - -SAMPLE_RATE = 48000 -BUFFER_SIZE = 2048 -LEVEL = 100 / 127 - -# Enumerated, not chosen: every int16 whose truncated product differs between -# the two precisions at this level. Left (Q15 25800) and right (25801) have 28 -# each and share none. -FLIPPING = ( - -32466, -31544, -31135, -30321, -29503, -29098, -27875, -27871, - -26652, -26239, -25429, -24908, -24607, -24206, -23276, -22983, - -21760, -21644, -18681, -17049, -15772, -14549, -13326, -12454, - -12103, -10880, -10822, -6227, 6227, 10822, 10880, 12103, - 12454, 13326, 14549, 15772, 17049, 18681, 21644, 21760, - 22983, 23276, 24206, 24607, 24908, 25429, 26239, 26652, - 27871, 27875, 29098, 29503, 30321, 31135, 31544, 32466, -) - - -def material(): - values = array("h", [0, 0]) - for value in FLIPPING: - values.append(value) - values.append(value) - return values - - -mixer = audiomixer.Mixer( - voice_count=1, buffer_size=BUFFER_SIZE, sample_rate=SAMPLE_RATE, - channel_count=2, bits_per_sample=16, samples_signed=True) -mixer.voice[0].level = LEVEL -mixer.voice[0].panning = 0.0 -mixer.voice[0].play( - audiocore.RawSample(material(), sample_rate=SAMPLE_RATE, channel_count=2), - loop=True) - -block = bytes(audiocore.get_buffer(mixer)[1]) - - -def sample(data, index): - """One signed 16-bit little-endian sample. `array.frombytes` is CPython's - alone -- MicroPython's `array` does not have it, and the probe has to read - the same bytes on all three interpreters.""" - value = data[index * 2] | (data[index * 2 + 1] << 8) - return value - 65536 if value & 0x8000 else value - - -# One line per input value: what the left scale and the right scale make of it. -for index in range(len(FLIPPING)): - print("flip", FLIPPING[index], - sample(block, 2 + index * 2), sample(block, 3 + index * 2)) -print("block", len(block) // 2, - sum(sample(block, i) for i in range(len(block) // 2))) diff --git a/tests/parity/mixer_q15_level_probe.py b/tests/parity/mixer_q15_level_probe.py new file mode 100644 index 00000000..86f756d6 --- /dev/null +++ b/tests/parity/mixer_q15_level_probe.py @@ -0,0 +1,67 @@ +"""A voice's level and pan are Q15 integers, and a sample is `(x * q) >> 15`. + +CircuitPython 11.0.0-alpha.2 (adafruit/circuitpython#11617) scales a voice in +integers on every core: `src/audiomixer/Mixer.c`'s `mult16signed` is +`(ai * mul) >> 15`, with the product floored rather than truncated toward zero, +and a centred pan is `32768 - panning`, so a voice at level 1.0 and pan 0 is +exactly 32768 on both channels and a wire. Before that, the quotient was +`mul / 32767.0f`, and this port added a unity special case to make 1.0 a wire. + +The material is chosen where those differ: the rails, the small values either +side of zero (a floor sends -1 at level 0.5 to -1; truncation sent it to 0), and +a spread of mid-range values. Each pair of values is scaled once by the left +multiplier and once by the right, at four levels and five pans, in stereo and +mono. The leading zero pair opens the level gate on the first word, so no +sample here renders at level zero. +""" + +from array import array + +import audiocore +import audiomixer + +SAMPLE_RATE = 48000 +BUFFER_SIZE = 2048 + +VALUES = ( + -32768, -32767, -30000, -22983, -10822, -3, -2, -1, + 1, 2, 3, 6227, 10822, 12103, 21760, 25429, 32766, 32767, +) +LEVELS = (0.5, 100 / 127, 0.999, 1.0) +PANS = (0.0, 0.5, -0.5, 1.0, -1.0) + + +def sample(data, index): + """One signed 16-bit little-endian sample. `array.frombytes` is CPython's + alone -- MicroPython's `array` does not have it, and the probe has to read + the same bytes on all three interpreters.""" + value = data[index * 2] | (data[index * 2 + 1] << 8) + return value - 65536 if value & 0x8000 else value + + +def render(channels, level, panning): + material = array("h", [0] * channels) + for value in VALUES: + for _ in range(channels): + material.append(value) + for _ in range(channels): + material.append(value) + mixer = audiomixer.Mixer( + voice_count=1, buffer_size=BUFFER_SIZE, sample_rate=SAMPLE_RATE, + channel_count=channels, bits_per_sample=16, samples_signed=True) + mixer.voice[0].level = level + if channels == 2: + mixer.voice[0].panning = panning + mixer.voice[0].play( + audiocore.RawSample(material, sample_rate=SAMPLE_RATE, + channel_count=channels), loop=True) + block = bytes(audiocore.get_buffer(mixer)[1]) + return [sample(block, i) for i in range(len(block) // 2)] + + +for level in LEVELS: + for panning in PANS: + out = render(2, level, panning) + print("stereo", level, panning, out[2:2 + len(VALUES) * 4]) + out = render(1, level, 0.0) + print("mono", level, out[1:1 + len(VALUES) * 2]) diff --git a/tests/parity/verify_dsp.py b/tests/parity/verify_dsp.py index c1cb0635..c203d79c 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -161,7 +161,7 @@ # truncated a different integer on 56 of the 65536 int16 values at level # 100/127 -- invisible to every stored digest in the repository, because # none of their material lands on one. audiodsp#84. - ("mixer_level_precision_probe.py", "audiomixer", {}, None), + ("mixer_q15_level_probe.py", "audiomixer", {}, None), # A voice mixes from its source's buffer as that buffer stands at mix # time, because what it holds is a pointer into it. The twin copied the # block at play() instead, so a class that settles a filter behind a voice diff --git a/tests/test_cpython_audiomixer_level.py b/tests/test_cpython_audiomixer_level.py index c512469d..069416f2 100644 --- a/tests/test_cpython_audiomixer_level.py +++ b/tests/test_cpython_audiomixer_level.py @@ -1,117 +1,116 @@ -"""A voice is scaled in float32, because that is what the kernel does. +"""A voice is scaled in integers, because that is what the kernel does. -`src/audiomixer/Mixer.c`'s `mult16signed` is the authority:: +`src/audiomixer/Mixer.c`'s `mult16signed` is the authority, as CircuitPython +11.0.0-alpha.2 has it (adafruit/circuitpython#11617):: - float mod_mul = (float)(i ? himul : lomul) / (float)((1 << 15) - 1); int16_t ai = (val >> (sizeof(uint16_t) * 8 * i)); - int32_t intermediate = (int32_t)(ai * mod_mul); + int32_t intermediate = (ai * (i ? himul : lomul)) >> 15; -Single-precision quotient, single-precision product, truncation toward zero. -The CPython twin formed the same product in `float64` until audiodsp#84, and the -two precisions truncate to different integers exactly where the true product -sits within a float32 rounding of one -- a whole LSB apart, not a rounding. +The product is floored by the shift, not truncated toward zero, so a small +negative sample rounds down: -1 at level 0.5 stays -1. A level of 1.0 is Q15 +32768 and a centred pan is `32768 - panning`, so a voice at level 1.0 and pan 0 +multiplies by exactly 32768 on both channels and passes every sample through. | what it checks | bar | |---|---| -| the twin's multiplier is the float32 one | `_mod_mul(level)` is bit-equal to `(float)level / 32767.0f` for every Q15 level 0..32767 | -| unity is the one exception, and is exact | `_mod_mul(32768)` is 1.0, not 32768/32767 -- audiodsp#95 | +| every int16 at a spread of Q15 levels | the Python loop and the answer `(x * q) >> 15` agree for all 65536 values | +| the product is floored | -1 at level 0.5 is -1, and 1 is 0 | | a level-1.0 voice is a wire at the rails | every int16 value renders back unchanged, mono and stereo | -| the flipping values are enumerated, not sampled | at level 100/127 exactly 56 of the 65536 int16 values flip -- 28 on the left scale (Q15 25800), 28 on the right (25801), sharing none | -| the twin renders the float32 answer | all 56, both channels, through a real `Mixer` block | -| the old float64 twin would fail this | the float64 formula differs on every one of the 56 | -| a voice at unity is passed through, not scaled | after the block that opens the level gate, a unity voice's output is byte-identical to its input and renders without one `_f32` call -- audiodsp#273 | -| any other level still scales | the same material at 0.5 differs from its input, and matches the float32 arithmetic sample for sample | -| the level gate is not what is being measured | the leading zero pair opens it on the first word, so nothing here renders at level zero | +| 32767 is not unity | `(x * 32767) >> 15` differs from `x` for some int16, so only 32768 may skip the scaling | +| a centred pan is exact | a stereo voice at pan 0 scales both channels by the level itself | +| a hard pan | pan 1.0 scales the left channel by 1/32768 of the level, pan -1.0 the right | +| a voice at unity is passed through, not scaled | after the block that opens the level gate, a unity voice's output is byte-identical to its input and the scaling kernel is not called -- audiodsp#273 | +| any other level still scales | the same material at 0.5 differs from its input, and matches `(x * q) >> 15` sample for sample | The other half of the evidence is not here, because CPython alone cannot -supply it: `tests/parity/mixer_level_precision_probe.py` renders this same -material under desktop MicroPython and under the pinned CircuitPython oracle -and `verify_dsp.py` requires all three to agree byte for byte. This file is -what CI can run, which has no interpreters. +supply it: `tests/parity/mixer_q15_level_probe.py` renders material chosen at +the rails and around zero under desktop MicroPython and under the pinned +CircuitPython oracle and `verify_dsp.py` requires all three to agree byte for +byte. This file is what CI can run, which has no interpreters. """ import array -import struct import unittest from unittest import mock +import _audiodsp import audiocore import audiomixer -from audiomixer import _f32, _mod_mul SAMPLE_RATE = 48000 BUFFER_SIZE = 2048 -LEVEL = 100 / 127 -#: Q15 scales the mixer derives for level 100/127 at panning 0.0. The left -#: channel runs one below the right because `left_scale` is `32767 - panning` -#: where `right_scale` is `32768` -- the panning-zero asymmetry this port keeps -#: verbatim from CircuitPython. -LEFT_SCALE, RIGHT_SCALE = 25800, 25801 +LEVELS = (0, 1, 100, 8192, 16384, 25800, 25801, 32766, 32767, 32768) -#: Every int16 whose truncated product differs between the two precisions at -#: that level. Enumerated by `_flips` below over the whole int16 range, and -#: pinned here so a change to the arithmetic has to move a written-down number. -LEFT_FLIPS = ( - -32466, -31135, -29503, -27871, -26239, -24908, -24607, -23276, - -21644, -18681, -17049, -12454, -10822, -6227, 6227, 10822, - 12454, 17049, 18681, 21644, 23276, 24607, 24908, 26239, - 27871, 29503, 31135, 32466, -) -RIGHT_FLIPS = ( - -31544, -30321, -29098, -27875, -26652, -25429, -24206, -22983, - -21760, -15772, -14549, -13326, -12103, -10880, 10880, 12103, - 13326, 14549, 15772, 21760, 22983, 24206, 25429, 26652, - 27875, 29098, 30321, 31544, -) - -def _float64_scaled(value, scale): - """What the twin did before audiodsp#84.""" - return max(-32768, min(32767, int(value * (scale / 32767.0)))) - - -def _float32_scaled(value, scale): +def _scaled(value, scale): """What `mult16signed` does.""" - return max(-32768, min(32767, int(_f32(value * _mod_mul(scale))))) - - -def _flips(scale): - return tuple(value for value in range(-32768, 32768) - if _float64_scaled(value, scale) - != _float32_scaled(value, scale)) - - -class MixerLevelIsSinglePrecision(unittest.TestCase): - - def test_the_multiplier_is_the_float32_quotient(self): - """Every Q15 level a voice can carry, not a sampled few. - - Unity is excluded and tested on its own below: audiodsp#95 made - 32768 mean 1.0 exactly, where the quotient would be 1.0000305. - """ - for level in range(0, 32768): - expected = struct.unpack( - "f", struct.pack("f", level / 32767.0))[0] - self.assertEqual( - struct.pack("f", _mod_mul(level)), - struct.pack("f", expected), - "level %d" % level) - - def test_unity_is_exactly_one(self): - """audiodsp#95. A voice level of 1.0 becomes Q15 32768, and upstream - then scales by 32768/32767 = 1.0000305 -- which lifts every sample at - |value| >= 32736 by one LSB. Here 32768 is unity, exactly.""" - self.assertEqual(_mod_mul(32768), 1.0) - self.assertNotEqual( - struct.pack("f", _mod_mul(32768)), - struct.pack("f", struct.unpack("f", struct.pack( - "f", 32768 / 32767.0))[0]), - "the deviation this pins has vanished") + return max(-32768, min(32767, (value * scale) >> 15)) + + +def _render(channels, level, panning, values, loop=False): + """One block of a single voice at `level` and `panning`, as a list.""" + mixer = audiomixer.Mixer( + voice_count=1, sample_rate=SAMPLE_RATE, channel_count=channels, + bits_per_sample=16, samples_signed=True, buffer_size=BUFFER_SIZE) + mixer.voice[0].level = level + if channels == 2: + mixer.voice[0].panning = panning + mixer.play(audiocore.RawSample( + array.array("h", values), sample_rate=SAMPLE_RATE, + channel_count=channels), loop=loop) + block = array.array("h") + block.frombytes(bytes(audiocore.get_buffer(mixer)[1])) + return list(block) + + +class MixerLevelIsInteger(unittest.TestCase): + + def test_both_loops_are_the_integer_product_for_every_int16(self): + values = array.array("h", range(-32768, 32768)) + every = values.tobytes() + kernels = [audiomixer._scale_s16_levels_py] + if hasattr(_audiodsp, "scale_s16_levels"): + kernels.append(_audiodsp.scale_s16_levels) + for left, right in zip(LEVELS, LEVELS[3:] + LEVELS[:3]): + for kernel in kernels: + with self.subTest(left=left, right=right, kernel=kernel): + out = array.array("h") + out.frombytes(kernel(every, left, right, left, right, [])) + want = [_scaled(v, left if i % 2 == 0 else right) + for i, v in enumerate(values)] + # Not assertEqual on the lists: a failure there spends + # minutes in difflib over 65536 items. + bad = next((i for i, (got, w) in enumerate(zip(out, want)) + if got != w), None) + self.assertIsNone( + bad, "sample %s: %s, want %s" % ( + bad, None if bad is None else out[bad], + None if bad is None else want[bad])) + + def test_the_product_is_floored_not_truncated(self): + """Upstream's change in rounding, spelled out. Truncation toward zero + sent -1 at level 0.5 to 0; the shift floors it to -1.""" + self.assertEqual(_scaled(-1, 16384), -1) + self.assertEqual(_scaled(1, 16384), 0) + self.assertEqual(_scaled(-3, 16384), -2) + self.assertEqual(_scaled(3, 16384), 1) + # Through the real kernel, not just the helper. + out = _render(1, 0.5, 0.0, [0, -1, 1, -3, 3] * 3) + self.assertEqual(out[1:5], [-1, 0, -2, 1]) + + def test_32767_is_not_unity(self): + """The CPython target skips the scaling at unity; it may do that only + for 32768, the one multiplier that is the identity for every int16.""" + self.assertEqual(audiomixer._UNITY_Q15, 32768) + self.assertTrue(all(_scaled(v, 32768) == v + for v in range(-32768, 32768))) + self.assertTrue(any(_scaled(v, 32767) != v + for v in range(-32768, 32768))) def test_a_level_one_voice_is_a_wire_at_the_rails(self): - """audiodsp#95, measured rather than argued: every int16 value, once, - through a level-1.0 voice, comes back as itself. + """Every int16 value, once, through a level-1.0 voice, comes back as + itself, mono and stereo. The leading block of silence opens the level gate -- without it the ramp renders at level 0 until its own zero crossing, and the @@ -140,9 +139,32 @@ def test_a_level_one_voice_is_a_wire_at_the_rails(self): self.assertEqual(list(rendered[head:len(values)]), list(values[head:])) + def test_a_centred_pan_scales_both_channels_by_the_level(self): + """`32768 - panning` at pan 0: no channel sits one below the other. + The material is a run of one value on both channels, so a difference + between the channels would be a difference between the scales.""" + for level in (0.25, 0.5, 100 / 127, 0.999): + with self.subTest(level=level): + q = int(level * 32768) + values = [0, 0] + [10822, 10822] * 8 + out = _render(2, level, 0.0, values, loop=True) + self.assertEqual(out[2], _scaled(10822, q)) + self.assertEqual(out[2], out[3]) + + def test_a_hard_pan_leaves_one_unit_on_the_far_channel(self): + """Pan 1.0 is the clamped 32767/32768, so the left scale is + `32768 - 32767`: 1 in Q15, scaled again by the level.""" + values = [0, 0] + [32767, 32767] * 8 + out = _render(2, 1.0, 1.0, values, loop=True) + self.assertEqual(out[2], _scaled(32767, (1 * 32768) >> 15)) + self.assertEqual(out[3], 32767) + out = _render(2, 1.0, -1.0, values, loop=True) + self.assertEqual(out[2], 32767) + self.assertEqual(out[3], _scaled(32767, (1 * 32768) >> 15)) + def _steady_state(self, channels, level, blocks=3): """Render `blocks` blocks of a looping ramp after one that opens the - level gate, returning (input blocks, output blocks, `_f32` calls + level gate, returning (input blocks, output blocks, kernel calls made while rendering them).""" mixer = audiomixer.Mixer( voice_count=1, sample_rate=SAMPLE_RATE, channel_count=channels, @@ -158,12 +180,12 @@ def _steady_state(self, channels, level, blocks=3): loop=True) audiocore.get_buffer(mixer) # the block that opens the gate calls = [] - real = audiomixer._f32 + real = audiomixer._scale_s16_levels - def counting(value): - calls.append(value) - return real(value) - with mock.patch.object(audiomixer, "_f32", counting): + def counting(*args): + calls.append(args) + return real(*args) + with mock.patch.object(audiomixer, "_scale_s16_levels", counting): out = [bytes(audiocore.get_buffer(mixer)[1]) for _ in range(blocks)] size = mixer._render_size @@ -171,111 +193,28 @@ def counting(value): return [src[i * size:(i + 1) * size] for i in range(blocks)], out, calls def test_a_unity_voice_is_passed_through_unscaled(self): - """audiodsp#273: at level 1.0 the multiplier is exactly 1.0, so the - block is the source's bytes, and the per-sample scaling is skipped - rather than computed to the same answer.""" + """audiodsp#273: at level 1.0 the multiplier is 32768, so the block is + the source's bytes, and the scaling is skipped rather than computed to + the same answer.""" for channels in (1, 2): with self.subTest(channels=channels): src, out, calls = self._steady_state(channels, 1.0) self.assertEqual(out, src) self.assertEqual(calls, []) - def test_the_unity_levels_are_the_ones_whose_multiplier_is_one(self): - """The pass-through is keyed on a set of Q15 levels; this pins it to - the multiplier it stands for, so the two cannot drift apart.""" - self.assertEqual( - tuple(level for level in range(32769) if _mod_mul(level) == 1.0), - audiomixer._UNITY_Q15) - def test_a_non_unity_voice_is_still_scaled(self): for channels in (1, 2): with self.subTest(channels=channels): src, out, calls = self._steady_state(channels, 0.5) self.assertNotEqual(out, src) - # `calls` is not asserted: the C kernel scales without the - # Python `_f32` (audiodsp#273); tests/test_cpython_mixer_scale.py - # holds the two to the same bytes. - level = int(0.5 * 32768) - scales = (32767 * level >> 15, level) if channels == 2 else (level,) + self.assertTrue(calls) + scale = int(0.5 * 32768) for want, got in zip(src, out): a, b = array.array("h"), array.array("h") a.frombytes(want) b.frombytes(got) - for index, (x, y) in enumerate(zip(a, b)): - scale = scales[index % channels] - self.assertEqual(y, _float32_scaled(x, scale)) - - def test_the_flipping_values_are_exactly_these(self): - """The enumeration is the test: a sampled probe would miss them. - - 56 of 65536 is 0.085% of the int16 range, and on real material about - 1% of samples -- 77 of 8192 on the render that found this. - """ - self.assertEqual(_flips(LEFT_SCALE), LEFT_FLIPS) - self.assertEqual(_flips(RIGHT_SCALE), RIGHT_FLIPS) - self.assertEqual(len(LEFT_FLIPS) + len(RIGHT_FLIPS), 56) - self.assertEqual(set(LEFT_FLIPS) & set(RIGHT_FLIPS), set()) - - def test_the_old_twin_would_fail_on_all_of_them(self): - """A planted check: if the two formulas ever agree here, this file has - stopped being able to fail.""" - for value in LEFT_FLIPS: - self.assertNotEqual(_float64_scaled(value, LEFT_SCALE), - _float32_scaled(value, LEFT_SCALE)) - for value in RIGHT_FLIPS: - self.assertNotEqual(_float64_scaled(value, RIGHT_SCALE), - _float32_scaled(value, RIGHT_SCALE)) - - def test_the_named_sample_from_the_issue(self): - """10822 * 25800 / 32767 is 8520.999786: float32 rounds the product up - to 8521.0 and truncates to 8521, float64 keeps the .9998 and truncates - to 8520. One value, spelled out, so the mechanism is readable.""" - self.assertEqual(_float64_scaled(10822, LEFT_SCALE), 8520) - self.assertEqual(_float32_scaled(10822, LEFT_SCALE), 8521) - self.assertEqual(_float64_scaled(10822, RIGHT_SCALE), 8521) - self.assertEqual(_float32_scaled(10822, RIGHT_SCALE), 8521) - - def test_a_rendered_block_carries_the_float32_answer(self): - """Through a real Mixer, both channels, all 56. - - Each value appears twice in a row, so it lands once at an even index - (scaled by the left multiplier) and once at an odd one (the right). - """ - values = sorted(set(LEFT_FLIPS) | set(RIGHT_FLIPS)) - material = array.array("h", [0, 0]) - for value in values: - material.append(value) - material.append(value) - - mixer = audiomixer.Mixer( - voice_count=1, buffer_size=BUFFER_SIZE, sample_rate=SAMPLE_RATE, - channel_count=2, bits_per_sample=16, samples_signed=True) - mixer.voice[0].level = LEVEL - mixer.voice[0].panning = 0.0 - mixer.voice[0].play(audiocore.RawSample( - material, sample_rate=SAMPLE_RATE, channel_count=2), loop=True) - - rendered = array.array("h") - rendered.frombytes(bytes(audiocore.get_buffer(mixer)[1])) - - for index, value in enumerate(values): - self.assertEqual(rendered[2 + index * 2], - _float32_scaled(value, LEFT_SCALE), - "left, sample %d" % value) - self.assertEqual(rendered[3 + index * 2], - _float32_scaled(value, RIGHT_SCALE), - "right, sample %d" % value) - - def test_the_scales_are_the_ones_named(self): - """The Q15 pair above is derived, not asserted: if the panning-zero - asymmetry ever goes, this says so rather than quietly testing the - wrong two numbers.""" - level = int(min(1.0, max(0.0, LEVEL)) * 32768) - panning = 0 - left_scale = 32767 - panning - right_scale = 32768 - self.assertEqual((left_scale * level) >> 15, LEFT_SCALE) - self.assertEqual((right_scale * level) >> 15, RIGHT_SCALE) + for x, y in zip(a, b): + self.assertEqual(y, _scaled(x, scale)) if __name__ == "__main__": diff --git a/tests/test_cpython_mixer_scale.py b/tests/test_cpython_mixer_scale.py index 6c57d92e..e998af78 100644 --- a/tests/test_cpython_mixer_scale.py +++ b/tests/test_cpython_mixer_scale.py @@ -1,7 +1,7 @@ """The C scaling kernel renders the same bytes the Python loop does. `Mixer._get_buffer` scales every voice that is not at unity by its per-channel -Q15 level -- a float32 product, truncated toward zero, clamped to int16 -- and +Q15 level -- `(sample * level) >> 15`, clamped to int16 -- and lets a pending level move land at a zero crossing or at the end of a source stretch. That loop ran in Python, a struct round trip per sample, until `_audiodsp.scale_s16_levels` (audiodsp#273). The Python loop stays as the @@ -10,7 +10,7 @@ | what it checks | bar | |---|---| | the kernel is in the build | `_audiodsp.scale_s16_levels` exists, and `audiomixer` picked it up | -| every int16, every level that matters | all 65536 values at a spread of Q15 levels (unity, near-unity, the 100/127 flips, quiet, zero) match the Python loop byte for byte | +| every int16, every level that matters | all 65536 values at a spread of Q15 levels (unity, near-unity, 100/127, quiet, zero) match the Python loop byte for byte | | level moves | a pending move landing at a zero crossing, forced at a stretch end, never arriving, or arriving at the first pair, over seeded random blocks with rails, zeros and sign changes | | through a real Mixer | the same graph -- several voices, mono and stereo, levels and pans changed between blocks, looping sources -- rendered with the kernel and with the Python loop swapped in is byte-identical, block for block | """ @@ -88,8 +88,8 @@ def test_a_move_with_no_zero_crossing_waits(self): self.assertEqual(out, audiomixer._scale_s16_levels_py(*args)) got = array.array("h") got.frombytes(out) - self.assertEqual(got[0], 1000 * 16384 // 32767) - self.assertEqual(got[-1], 2000 if forced else 2000 * 16384 // 32767) + self.assertEqual(got[0], 1000 * 16384 >> 15) + self.assertEqual(got[-1], 2000 if forced else 2000 * 16384 >> 15) def test_bad_arguments_raise(self): with self.assertRaises(ValueError): From 26c4c7635b548e1730772b34d8b45f0fe0166303 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:08:08 -0500 Subject: [PATCH 2/4] Move the oracle to CircuitPython 11.0.0-alpha.2 and re-capture two digests CIRCUITPYTHON_ORACLE names d53b2277c, and tests/test_voice_ceiling_consistency.py pins the sha256 of the 64-voice binary built from it (ee0cc93c...). Two stored digests moved with the integer mixer, re-captured from the new oracle's own output, which CPython and desktop MicroPython reproduce: - streaming_component, streaming half: f3ffad8e... to 33b27a2f.... The mixer voices in streaming_component_probe.py are below unity. - synthtools_acceptance: b6e3b06f... to 757d944a.... The lead, bend and bass lines moved; the other five lines are unchanged. Every other stored digest held and reproduces on the new oracle: midi_component, effects_component, biquad_component and mixdown_knee. The oracle labels in effects_component and streaming_component follow the pin. --- CIRCUITPYTHON_ORACLE | 13 +++++++------ tests/parity/golden/effects_component.json | 2 +- tests/parity/golden/streaming_component.json | 4 ++-- tests/parity/golden/synthtools_acceptance.json | 18 +++++++++--------- tests/test_voice_ceiling_consistency.py | 17 ++++++++++++++--- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/CIRCUITPYTHON_ORACLE b/CIRCUITPYTHON_ORACLE index 74db4926..785fe659 100644 --- a/CIRCUITPYTHON_ORACLE +++ b/CIRCUITPYTHON_ORACLE @@ -1,9 +1,10 @@ # The CircuitPython oracle pin. Every parity golden is measured against -# this exact commit (tag 11.0.0-alpha.1; the pin moved from 10.3.0 on -# 2026-10-08 with audiodsp#201, from 10.2.1 to 10.3.0 on 2026-09-09). +# this exact commit (tag 11.0.0-alpha.2; the pin moved from 11.0.0-alpha.1 on +# 2026-10-22, from 10.3.0 on 2026-10-08 with audiodsp#201, from 10.2.1 to +# 10.3.0 on 2026-09-09). # Moving it is a re-port, not a bump: every move is a resync with upstream -# (docs/upstream-sync.md; the last was audiodsp#220). See also AGENTS.md -# "The CircuitPython oracle" and docs/upstream-diff.md. The 64-voice oracle binary built from -# it lives at bin/circuitpython-oracle-11.0.0-alpha.1 (build_interpreters.sh +# (docs/upstream-sync.md; the one before this was audiodsp#220). See also +# AGENTS.md "The CircuitPython oracle" and docs/upstream-diff.md. The 64-voice oracle binary built from +# it lives at bin/circuitpython-oracle-11.0.0-alpha.2 (build_interpreters.sh # cp-oracle); tests/test_voice_ceiling_consistency.py pins its sha256. -11.0.0-alpha.1 520805e12127bdb51722ba1caa5c670ecd8c4f72 +11.0.0-alpha.2 d53b2277cfad71589028e5da77c984c045db990d diff --git a/tests/parity/golden/effects_component.json b/tests/parity/golden/effects_component.json index bdd18680..bbbaf594 100644 --- a/tests/parity/golden/effects_component.json +++ b/tests/parity/golden/effects_component.json @@ -1,5 +1,5 @@ { - "oracle": "CircuitPython 11.0.0-alpha.1 unix coverage (bin/circuitpython-oracle-11.0.0-alpha.1)", + "oracle": "CircuitPython 11.0.0-alpha.2 unix coverage (bin/circuitpython-oracle-11.0.0-alpha.2)", "circuitpython_stdout_sha256": "ff57b9ea0989dbbfe2357cd05f38beb83bf6468839aa7969ad7c37cf52644ebc", "circuitpython_modules": [ "audiofilters", diff --git a/tests/parity/golden/streaming_component.json b/tests/parity/golden/streaming_component.json index c069f275..d8001bca 100644 --- a/tests/parity/golden/streaming_component.json +++ b/tests/parity/golden/streaming_component.json @@ -1,5 +1,5 @@ { "midi_stdout_sha256": "268020bee2b5c9ca0bbd6b322d683695e86157f81dfafb10c284942a372df572", - "oracle": "CircuitPython 11.0.0-alpha.1 unix coverage (bin/circuitpython-oracle-11.0.0-alpha.1)", - "streaming_stdout_sha256": "f3ffad8ed578907a6dc9e5c9821223161877de4b831a9c60a8defc87f4bdceab" + "oracle": "CircuitPython 11.0.0-alpha.2 unix coverage (bin/circuitpython-oracle-11.0.0-alpha.2)", + "streaming_stdout_sha256": "33b27a2fb69726f9f7baa31d7c3eec1be5e0fc20cb644459f89a72f17087c30b" } diff --git a/tests/parity/golden/synthtools_acceptance.json b/tests/parity/golden/synthtools_acceptance.json index 7fe78836..62e85fc6 100644 --- a/tests/parity/golden/synthtools_acceptance.json +++ b/tests/parity/golden/synthtools_acceptance.json @@ -1,24 +1,24 @@ { - "circuitpython": "10.3.0", + "circuitpython": "11.0.0-alpha.2", "circuitpython_stdout": [ - "lead nbytes 20480 checksum 2466195 order 706098954", - "bend nbytes 9216 checksum 1152889 order 508356948", + "lead nbytes 20480 checksum 2466056 order 562618965", + "bend nbytes 9216 checksum 1153252 order 581011999", "json roundtrip filt_f 800.0 wave ASAW detune 1.004", - "bass nbytes 16384 checksum 2011754 order 2633567105", + "bass nbytes 16384 checksum 2017069 order 1783014070", "echo delay_ms 230.76923076923077", "tail nbytes 2048 checksum 0 order 3523624389", "mixer playing False", "done" ], - "circuitpython_stdout_note": "CircuitPython 10.3.0's own answer, at the same ceiling this port ships (64). ALL EIGHT LINES are identical to `stdout` and to desktop MicroPython. Until 2026-09-09 three differed, and this note blamed the deliberate biquad deviations for all three; that was wrong on every count. Each had its own cause, and all four are fixed: (1) `lead` -- the biquad deviation was applied on ONE TARGET ONLY (audiodsp#77); synthio.Biquad now runs CircuitPython's Q15 on every target. (2) `bend` -- the zero-crossing loudness gate read the wrong voice slot on the CPython twin, whose note list compacts where CircuitPython's channel array does not; each note now carries the channel it was admitted on. (3) part of the SAW waveform -- the test-only ulab bridge rounded where real ulab truncates (tests/support/ulab). (4) `bass` -- the twin rendered voices already at envelope level 0, which the native builds skip. Harmless until the gate existed, because pending loudness 0 contributes silence; with the gate the voice kept sounding at its previous loudness for a whole block (audiodsp#78). Pinned by tests/test_cpython_silenced_voice.py, whose three cases all fail with the skip removed.", + "circuitpython_stdout_note": "CircuitPython 11.0.0-alpha.2's own answer, at the same ceiling this port ships (64). ALL EIGHT LINES are identical to `stdout` and to desktop MicroPython. Until 2026-09-09 three differed, and this note blamed the deliberate biquad deviations for all three; that was wrong on every count. Each had its own cause, and all four are fixed: (1) `lead` -- the biquad deviation was applied on ONE TARGET ONLY (audiodsp#77); synthio.Biquad now runs CircuitPython's Q15 on every target. (2) `bend` -- the zero-crossing loudness gate read the wrong voice slot on the CPython twin, whose note list compacts where CircuitPython's channel array does not; each note now carries the channel it was admitted on. (3) part of the SAW waveform -- the test-only ulab bridge rounded where real ulab truncates (tests/support/ulab). (4) `bass` -- the twin rendered voices already at envelope level 0, which the native builds skip. Harmless until the gate existed, because pending loudness 0 contributes silence; with the gate the voice kept sounding at its previous loudness for a whole block (audiodsp#78). Pinned by tests/test_cpython_silenced_voice.py, whose three cases all fail with the skip removed. Re-captured for audiodsp's resync with 11.0.0-alpha.2: upstream's mixer now scales a voice in integers, `(x * level) >> 15`, so `lead`, `bend` and `bass` moved, and the oracle, this port and desktop MicroPython agree on all eight lines again.", "fixture": "synthtools_acceptance.py stdout", "microcontroller_port": "parent workspace's built micropython", - "sha256": "b6e3b06fb9edeea12c55c67603ce6a8969e1a8144ae3c147f147923f52aa4774", + "sha256": "757d944acffef29d9d57899916beef3b45aa05b802444c1eb29c98b03d0748be", "stdout": [ - "lead nbytes 20480 checksum 2466195 order 706098954", - "bend nbytes 9216 checksum 1152889 order 508356948", + "lead nbytes 20480 checksum 2466056 order 562618965", + "bend nbytes 9216 checksum 1153252 order 581011999", "json roundtrip filt_f 800.0 wave ASAW detune 1.004", - "bass nbytes 16384 checksum 2011754 order 2633567105", + "bass nbytes 16384 checksum 2017069 order 1783014070", "echo delay_ms 230.76923076923077", "tail nbytes 2048 checksum 0 order 3523624389", "mixer playing False", diff --git a/tests/test_voice_ceiling_consistency.py b/tests/test_voice_ceiling_consistency.py index 48ce6463..4ab2e41e 100644 --- a/tests/test_voice_ceiling_consistency.py +++ b/tests/test_voice_ceiling_consistency.py @@ -44,7 +44,7 @@ #: The pinned CircuitPython oracle binary, and its hash. #: #: The oracle is built at the SAME ceiling this port ships (64) and at the same -#: CircuitPython version the port tracks (11.0.0-alpha.1). It is rebuilt deliberately +#: CircuitPython version the port tracks (11.0.0-alpha.2). It is rebuilt deliberately #: whenever either moves, and re-pinned in that change -- the old rule #: ("built at 14 and must never be rebuilt") is retired: an oracle at a #: different configuration cannot answer the only question worth asking, which @@ -151,9 +151,20 @@ #: `verify_dsp` agrees three ways (87 comparisons, 0 failures, where the #: previous binary stops at `tank_return_probe`); and `cp11_files_probe` #: passes. Previous: 8b8aacebdaff738a (audiodsp 43a871e). -ORACLE = ROOT.parent / "bin" / "circuitpython-oracle-11.0.0-alpha.1" +#: +#: MOVED 2026-10-22 to CircuitPython 11.0.0-alpha.2 (d53b2277c) for the resync +#: that took upstream's integer mixer (11a329885, fc57c0f36). Built with the +#: cp-oracle build_mp.py command at 64 voices, audiodsp at d2c502b and +#: CircuitPython's own `audiospeed`. Verified: it answers 64 voices; +#: `effects_component` and `mixdown_knee` reproduce their stored captures; +#: `midi_component` is unchanged; `streaming_component` and +#: `synthtools_acceptance` moved, and were re-captured from this binary's +#: output; and `verify_dsp` agrees three ways. Previous: 34ca4b524bd7d9b1 +#: (11.0.0-alpha.1, audiodsp 12cf627), still on disk as +#: bin/circuitpython-oracle-11.0.0-alpha.1. +ORACLE = ROOT.parent / "bin" / "circuitpython-oracle-11.0.0-alpha.2" ORACLE_SHA256 = ( - "34ca4b524bd7d9b1d81759974a8823adb21e4428994fda2d2afcb596d252961d") + "ee0cc93c12691859ec1588df89e1839056cef52d4c95ed0515a14d2461757b66") def _search(relative, pattern): From d42665a998402d98e894aaabaa1d111d9744674e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:08:08 -0500 Subject: [PATCH 3/4] Record the alpha.2 resync and retire "a voice at level 1.0 is a wire" upstream-sync.md gets the new "last resync" entry (the alpha.1 one becomes the one before it), upstream-diff.md says what upstream's integer mixer changed and which digests moved, and the audiodsp#95 section becomes a record of a difference upstream now shares, as the alpha.1 resync did for its five. --- AGENTS.md | 2 +- README.md | 2 +- docs/upstream-diff.md | 111 +++++++++++++++++++++++++++--------------- docs/upstream-sync.md | 35 +++++++++++-- 4 files changed, 107 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1316cb9a..bfe8645a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ of this repository in the parent workspace, same pattern as `.venv/bin/python tests/parity/verify_dsp.py --micropython ../micropython/ports/unix/build-pydevices/micropython` is the first. There is **no stored digest** — the gate is the comparison, and it **refuses a run with fewer than two interpreters** rather than passing one - that cannot fail. Add `--circuitpython ../bin/circuitpython-oracle-11.0.0-alpha.1` + that cannot fail. Add `--circuitpython ../bin/circuitpython-oracle-11.0.0-alpha.2` for the three-way. The arithmetic is all in `src/shared/`, so two interpreters disagreeing is never a difference of intent: it is a width, an undefined shift, a compiler's choice or an architecture. diff --git a/README.md b/README.md index 4186c59b..ce573eb7 100644 --- a/README.md +++ b/README.md @@ -850,7 +850,7 @@ it. **CPython:** the public surface and wheel plumbing are present, and the committed synthesis, mixer, MIDI, streaming, and effects fixtures match -CircuitPython 11.0.0-alpha.1 PCM byte-for-byte — built at the same voice ceiling this +CircuitPython 11.0.0-alpha.2 PCM byte-for-byte — built at the same voice ceiling this port ships, which is the only comparison worth making. Import/API smoke success is not used as a substitute for those comparisons. diff --git a/docs/upstream-diff.md b/docs/upstream-diff.md index bbf1c5b4..d281ef20 100644 --- a/docs/upstream-diff.md +++ b/docs/upstream-diff.md @@ -218,13 +218,64 @@ skipped on that probe, since its own nodes keep upstream's behaviour, and `pitchshift_freeze_probe.py` no longer renders after its reset, so it stays held to CircuitPython's bytes. +## Resynced with 11.0.0-alpha.2 + +The oracle moved from 11.0.0-alpha.1 to 11.0.0-alpha.2 (d53b2277c) on +2026-10-22, with the binary at `bin/circuitpython-oracle-11.0.0-alpha.2`. +Three upstream commits touched the ported code in between, all in +`audiomixer` and `synthio_sat16`. What was taken is in +[upstream-sync.md](upstream-sync.md#the-last-resync-1100-alpha2). + +**The mixer scales in integers (11a329885, fc57c0f36; adafruit/circuitpython#11617).** +Upstream's non-DSP `mult16signed` was the float quotient `mul / 32767.0f` +times the sample, truncated toward zero. It is `(ai * mul) >> 15` now, which +upstream says "matches the DSP result bit for bit", and a centred pan is +`32768 - panning` where it was `32767 - panning`. Both changes are in +`src/audiomixer/Mixer.c`, in the CPython extension's scaling kernel +(`src/cpython/_audiodsp.c`) and in its Python twin (`src/cpython/audiomixer.py`), +as upstream has them. Three things follow: + +- Every level below 1.0 moves by up to one LSB. The divisor is 32768 where it + was 32767, and the shift floors where the cast truncated, so a small + negative sample rounds down: -1 at level 0.5 stays -1, where it used to + become 0. Material that never goes through a mixer voice below unity does + not move. +- Level 1.0 is a wire without help. This port's unity branch is deleted, and + with it the difference recorded under + [A voice at level 1.0 is a wire](#a-voice-at-level-10-is-a-wire-audiodsp95). + On the CPython target the block-level shortcut that skips the scaling at + unity stays, keyed on Q15 32768 alone: 32767 used to scale by exactly 1.0 + and no longer does. +- A stereo voice at pan 0 is the same on both channels. The left channel no + longer sits one Q15 step below the right. + +`mixer_q15_level_probe.py` (it replaces `mixer_level_precision_probe.py`, +whose material was the float32-versus-float64 flips that no longer exist) +renders the rails and the values either side of zero at four levels and five +pans, mono and stereo, and `verify_dsp.py` holds all three interpreters to +the same bytes. A build from before the change prints different bytes from +the oracle's. + +**`synthio_sat16` is inline (cbbde1208; adafruit/circuitpython#11621).** Taken +as upstream has it in `src/synthio/__init__.h`. The rounding is the same +arithmetic written without a branch, so it cannot change a sample; the +`__ssat` form is for cores that define `__ARM_FEATURE_SAT`. `audiodsp_sat16`, the same +function for the shared C and the CPython extension, is unchanged. + +**What moved.** Two stored digests: `streaming_component`'s streaming half +(`f3ffad8e…` to `33b27a2f…`) and `synthtools_acceptance` (`b6e3b06f…` to +`757d944a…`; `lead`, `bend` and `bass`). Both were re-captured from the new +oracle's output, and CPython, desktop MicroPython and the oracle agree on +them. Every other stored digest held: `midi_component`, `effects_component`, +`biquad_component`, `mixdown_knee`. + ## Resynced with 11.0.0-alpha.1 (audiodsp#220) Every audio fix CircuitPython made between 10.3.0 and 11.0.0-alpha.1 is in this port now, and so is its one audio addition, `PitchShift.freeze`, which followed in audiodsp#222. The finalisers upstream gave every audio object are left out on purpose. What was taken, module by module, and what was left -out is in [upstream-sync.md](upstream-sync.md#the-last-resync-1100-alpha1-audiodsp220). +out is in [upstream-sync.md](upstream-sync.md#the-resync-before-1100-alpha1-audiodsp220). It retires five differences recorded below, because upstream now does the same: the stream I/O in `WaveFile` and `synthio.from_file` (upstream switched @@ -241,6 +292,9 @@ in audiocomponents' parity sequences on CPython and on MicroPython. ## The oracle is CircuitPython 11.0.0-alpha.1 (audiodsp#201) +> The oracle has moved on to 11.0.0-alpha.2, see +> [above](#resynced-with-1100-alpha2). This is how it got to alpha.1. + The oracle moved from 10.3.0 to 11.0.0-alpha.1 (520805e12) on 2026-10-08, with the binary at `bin/circuitpython-oracle-11.0.0-alpha.1` (`CIRCUITPYTHON_ORACLE`, `tests/test_voice_ceiling_consistency.py`). It moved @@ -1950,17 +2004,21 @@ peaking-EQ fix below let it drop the Mixer entirely.) ## A voice at level 1.0 is a wire (audiodsp#95) -Upstream maps a voice level of 1.0 to Q15 **32768**: +> **Upstream since 11.0.0-alpha.2** (11a329885, fc57c0f36), so no longer a +> deviation from the oracle. The unity branch described below is gone from +> this port; what is left is the record of what was wrong. + +Upstream mapped a voice level of 1.0 to Q15 **32768**: ```c uint16_t level = (uint16_t)(synthio_block_slot_get_limited( &voice->level, 0.0, 1.0) * (1 << 15)); ``` -and then scales by `level / 32767.0f` in `mult16signed`. So "unity" is really -**1.0000305**, and every sample whose magnitude reaches 32736 comes out one -LSB larger — away from zero — with 32767 clamping. A voice at level 1.0 is -not a wire, and never was. +and then scaled by `level / 32767.0f` in `mult16signed`. So "unity" was really +**1.0000305**, and every sample whose magnitude reached 32736 came out one +LSB larger, away from zero, with 32767 clamping. A voice at level 1.0 was +not a wire. Measured here on the full int16 range, once per value, through a one-voice Mixer with the level gate opened on a leading block of silence: @@ -1970,38 +2028,15 @@ channels=1: 63 samples differ, |value| >= 32736, deltas [-1, 1], channel index [ channels=2: 63 samples differ, |value| >= 32736, deltas [-1, 1], channel index [1] ``` -Stereo differs on the right channel only, because a voice at panning 0 gets -`32767 - panning` on the left and a flat `32768` on the right: the left -scale's quotient is 32767/32767, which is exactly one, and only the right -carries the lifted multiplier. Mono takes the right-hand path on both. - -**The deviation.** A multiplier of exactly `1 << 15` means unity and passes -the sample through, in `mult16signed` (`src/audiomixer/Mixer.c`) and in -`_mod_mul` (`src/cpython/audiomixer.py`). Every other level is upstream's -arithmetic bit for bit — the single-precision quotient and product that -audiodsp#84 pinned, which `tests/test_cpython_audiomixer_level.py` still -enumerates over Q15 0..32767. - -**Why deviate rather than route around it.** Answer (1) on the issue — a -class claiming a wire at Mix 0 routes through no mixer — is what the shipped -effect classes took, and five of them were rewired for it. That fixed the -classes; it did not fix `audiomixer`, and the next class to put a full-scale -signal through a level-1.0 voice would have found it again. The scale is one -number in one kernel and the fix is one compare, so the module now means what -it says. - -**What moved.** Nothing any committed gate covers: `verify_acceptance`, -`verify_effects`, `verify_streaming`, `verify_biquad` and -`verify_mixdown_knee` all match their recorded hashes unchanged, because none -of their material carries a full-scale sample through a level-1.0 voice. What -does move is any render that does — by one LSB, on samples at |value| >= 32736 -only, and toward the sample's own value rather than away from it. - -**Not applied to the CircuitPython target,** per the standing rule: the oracle -keeps upstream's arithmetic, so this is one of the enumerated places where our -targets and the oracle disagree by design. A parity script that renders -full-scale material through a level-1.0 voice will see it, and should expect -it. +Stereo differed on the right channel only, because a voice at panning 0 got +`32767 - panning` on the left and a flat `32768` on the right. + +This port answered with a branch: a multiplier of exactly `1 << 15` passed +the sample through, in `mult16signed` and in `_mod_mul`. Upstream's answer, in +11.0.0-alpha.2, is to change the arithmetic: a sample is `(x * level) >> 15`, +and a centred pan is `32768 - panning`, so level 1.0 at pan 0 is 32768 on both +channels and the product is the sample itself. See +[Resynced with 11.0.0-alpha.2](#resynced-with-1100-alpha2). ## Peaking EQ computed `b2` with the wrong sign (effects-extension tier) diff --git a/docs/upstream-sync.md b/docs/upstream-sync.md index 38afc6f3..287d6b8b 100644 --- a/docs/upstream-sync.md +++ b/docs/upstream-sync.md @@ -63,7 +63,38 @@ to turn up. 7. Update the record below: what was taken, what was held back, and any difference that a fix retired or added. -## The last resync: 11.0.0-alpha.1 (audiodsp#220) +## The last resync: 11.0.0-alpha.2 + +The pin moved from 11.0.0-alpha.1 to 11.0.0-alpha.2 (d53b2277c). Between the +two, upstream touched the ported modules in three commits, all in `audiomixer` +and `synthio_sat16`; nothing else in the eight modules changed, and `lib/mp3` +is the same commit (aac02afd9f) as at the pin. + +- **audiomixer** (11a329885, fc57c0f36). A voice is scaled in integers: + `(ai * mul) >> 15`, where it was `ai * (mul / 32767.0f)` truncated toward + zero, and a centred pan is `32768 - panning`. Taken in upstream's words in + `src/audiomixer/Mixer.c`, in the CPython extension's scaling kernel and in + `src/cpython/audiomixer.py`. Every level below 1.0 moves by up to one LSB, + and a small negative sample now rounds down (-1 at level 0.5 stays -1). The + ARM `smulwb` path and the `__ARM_FEATURE_DSP` guards are not here, as before: + this port's `mult16signed` is the portable C, which upstream says gives the + same bytes. +- **synthio** (cbbde1208). `synthio_sat16` is inline in `src/synthio/__init__.h` + as upstream has it, with the `__ssat` form on cores that define + `__ARM_FEATURE_SAT`. It cannot change a sample. + +`mixer_q15_level_probe.py` covers the mixer in `verify_dsp.py`, where +CircuitPython, MicroPython and CPython print the same bytes, and a build from +before the change prints different ones. Two stored digests moved, both +re-captured from the alpha.2 oracle's output: the streaming half of +`streaming_component`, and `synthtools_acceptance`. The rest held, and so did +audiocomponents' instrument parity sequences on CPython and on MicroPython. + +**A difference retired:** a voice at level 1.0 is a wire (audiodsp#95). +Upstream's arithmetic makes it so, and the unity branch in `mult16signed` is +deleted. It also retires the left-below-right pan asymmetry at pan 0. + +## The resync before: 11.0.0-alpha.1 (audiodsp#220) The pin moved from 10.3.0 to 11.0.0-alpha.1 with audiodsp#201, which took 6dddbda87 (buffer lengths and silence fills) and nothing else. audiodsp#220 @@ -198,8 +229,6 @@ reason, and a resync should not take them. - **audiomixer**: a reset keeps every voice playing where it was instead of stopping them ([why](upstream-diff.md#resetting-a-mixer-silenced-it-permanently-audioeffects-tier)). - A voice at level 1.0 is a wire - ([why](upstream-diff.md#a-voice-at-level-10-is-a-wire-audiodsp95)). A looping sample under one word is refused ([why](upstream-diff.md#audiomixer-a-looping-sample-under-one-word-is-refused-not-spun-on-audiodsp85)). Level and panning are always block inputs, and the ARM CMSIS intrinsics are From b8f86a90d0ee7bd5b20825db68fe7519c98a8d8a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:32:51 -0500 Subject: [PATCH 4/4] Print the Q15 probe's levels by name A float32 MicroPython build prints 100 / 127 with fewer digits than CPython, so the probe's label line differed where the samples agreed. --- tests/parity/mixer_q15_level_probe.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/parity/mixer_q15_level_probe.py b/tests/parity/mixer_q15_level_probe.py index 86f756d6..85b23eb8 100644 --- a/tests/parity/mixer_q15_level_probe.py +++ b/tests/parity/mixer_q15_level_probe.py @@ -27,7 +27,10 @@ -32768, -32767, -30000, -22983, -10822, -3, -2, -1, 1, 2, 3, 6227, 10822, 12103, 21760, 25429, 32766, 32767, ) -LEVELS = (0.5, 100 / 127, 0.999, 1.0) +# Each level is printed by its name: a float32 build prints 100 / 127 with +# fewer digits than CPython, and the label would differ where the samples +# don't. +LEVELS = (("0.5", 0.5), ("100/127", 100 / 127), ("0.999", 0.999), ("1.0", 1.0)) PANS = (0.0, 0.5, -0.5, 1.0, -1.0) @@ -59,9 +62,9 @@ def render(channels, level, panning): return [sample(block, i) for i in range(len(block) // 2)] -for level in LEVELS: +for name, level in LEVELS: for panning in PANS: out = render(2, level, panning) - print("stereo", level, panning, out[2:2 + len(VALUES) * 4]) + print("stereo", name, panning, out[2:2 + len(VALUES) * 4]) out = render(1, level, 0.0) - print("mono", level, out[1:1 + len(VALUES) * 2]) + print("mono", name, out[1:1 + len(VALUES) * 2])