Skip to content

Commit 7b4beab

Browse files
committed
Merge branch 'fix/issue-80'
# Conflicts: # CHANGELOG.md
2 parents ca54cdc + 832865e commit 7b4beab

18 files changed

Lines changed: 398 additions & 44 deletions

.github/workflows/clean-build.yml

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@ jobs:
4141
known-divergent: ""
4242
- name: float
4343
cflags: -DMICROPY_FLOAT_IMPL=MICROPY_FLOAT_IMPL_FLOAT
44-
# See #80. The double cell has no exceptions and must not gain any.
44+
# Two left of the six #80 found, and both are below the probes:
45+
# #101 (synthio.Biquad derives W0 in mp_float_t where the CPython
46+
# extension uses the shared double helper) and #102 (a filtered
47+
# Echo's per-sample arithmetic is mp_float_t in C and double in the
48+
# twin). Each moves board digests, so each is its own change.
49+
# The double cell has no exceptions and must not gain any.
4550
known-divergent: >-
4651
--known-divergent biquad_component_probe.py
47-
--known-divergent dynamics_extras_probe.py
48-
--known-divergent dynamics_options_probe.py
49-
--known-divergent dynamics_probe.py
5052
--known-divergent echo_filter_probe.py
51-
--known-divergent granular_pitch_shift_probe.py
5253
name: unix-usermod (${{ matrix.float-impl.name }}-precision)
5354
steps:
5455
- uses: actions/checkout@v7
@@ -97,12 +98,13 @@ jobs:
9798
#
9899
# CircuitPython is not built in CI, so this is the two-way; the three-way
99100
# including the patched CircuitPython build is a local run.
100-
# The float cell diverges on six probes because mp_float_t is 32-bit
101-
# there and CPython's is not -- that is #80, deliberately deferred.
102-
# --known-divergent narrows the gate to exactly those six rather than
103-
# switching it off: a fourth probe diverging still fails, and so does any
104-
# of these three starting to agree, which is what stops the exception
105-
# outliving its reason. Delete the flag when #80 closes; the gate will
101+
# The float cell diverges on two probes because mp_float_t is 32-bit
102+
# there and CPython's is not -- #101 and #102, both named above. It was
103+
# six until #80 fixed the four whose cause was in the probes themselves.
104+
# --known-divergent narrows the gate to exactly those two rather than
105+
# switching it off: a third probe diverging still fails, and so does
106+
# either of these starting to agree, which is what stops the exception
107+
# outliving its reason. Delete the flag when both close; the gate will
106108
# tell you if you forget.
107109
- name: Every target renders the DSP nodes identically
108110
run: |

AGENTS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ for source compatibility; only this repo's own name differs.
2525
which of the three runtimes is underneath. Nothing puts this directory on
2626
`sys.path`: audioif is a dependency, imported from wherever it is installed.
2727
- `lib/` — the pure-Python tier: `lib/audiorender/` (whole-composition
28-
offline rendering — numpy, desktop-only, never frozen), which ships inside
29-
the `pydevices-audioif` wheel. The instrument and effect libraries that
28+
offline rendering — numpy, desktop-only, never frozen) and
29+
`lib/audioif_util/` (`float32`, the round trip that makes a setting derived
30+
in Python the same number on a board as on a desktop — see
31+
[docs/correctness-standard.md](docs/correctness-standard.md)). Both ship
32+
inside the `pydevices-audioif` wheel. The instrument and effect libraries that
3033
used to sit beside it — `audioinstruments` (53 `synthio` instruments) and
3134
`audioeffects` (46 effect classes, racks included) — live in
3235
[audiocomponents](https://github.com/PyDevices/audiocomponents) now, as

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,39 @@
4040
and the soundtrack has grown since; whether that baseline is still the
4141
reference or should be re-taken against today's pieces and today's DSP is
4242
Brad's call, not an agent's (audioif#88).
43+
- **`audioif_util.float32`, and the rule that a setting derived in Python
44+
goes through it.** A Python float is the interpreter's `mp_float_t` — a
45+
double here and on the desktop MicroPython, a **single** on every board and
46+
on a `MICROPY_FLOAT_IMPL_FLOAT` build — so `node.mix = 0.35` is two
47+
different numbers and the node renders different bytes on a board before
48+
its kernel is reached. `lib/audioif_util/` is `struct` and two functions:
49+
`float32(value)`, the round trip that is the identity on a single-precision
50+
target and a rounding on a double one, and `float32_bits(value)`, the exact
51+
way to print a float that two interpreters have to agree on.
52+
`docs/correctness-standard.md` carries the rule; it is also what
53+
audiocomponents#75 needs on the class side.
54+
55+
What found it: `clean-build.yml`'s `unix-usermod (float-precision)` cell,
56+
where `verify_dsp` had six probes disagreeing with CPython. Four were the
57+
probes' own arithmetic and are fixed here. Three printed a gain reduction
58+
as `"%.6f"` whose **float32 bits were identical on all three targets**
59+
MicroPython's single-precision formatter is not correctly rounded to seven
60+
significant digits, so that column compared formatters, not DSP; they print
61+
the bit pattern now. `granular_pitch_shift_probe` passed `mix=0.35`.
62+
`biquad_component_probe` derived its square wave from a Python float phase,
63+
so on a single-precision build it was filtering a *different waveform* (8
64+
frames flipped in mono, 96 in stereo); its material is integer arithmetic
65+
now, which is not byte-identical to the old float form on a double build
66+
either, so `golden/biquad_component.json` was re-captured. The CI cell's
67+
`--known-divergent` list drops from six probes to two.
68+
69+
The two that are left are below the probes and each has its own issue:
70+
audioif#101, `synthio.Biquad` deriving W0 at `mp_float_t` width where the
71+
CPython extension calls the shared `double` `audioif_biquad_cp_w0()`; and
72+
audioif#102, a filtered `audiodelays.Echo` doing `echo * decay + sample` at
73+
`mp_float_t` width in C and in double in the twin. Both were proved by
74+
landing the CPython twin on the float build's bytes exactly, and both move
75+
board digests, so neither is folded in here (audioif#80).
4376

4477
- **`audioshaper.Waveshaper`'s own headroom is documented, and pinned by a
4578
trait test.** A curve that reaches the rails and a `post_gain` above about

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
include VERSION
22
recursive-include src/shared *.h
33
recursive-include lib/audiorender *.py *.md
4+
recursive-include lib/audioif_util *.py

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,16 @@ repository for how to install them.
9191

9292
## What's here
9393

94-
One pure-Python tier sits on top of the CircuitPython-compatible core:
95-
94+
Two pure-Python modules sit beside the CircuitPython-compatible core:
95+
96+
- **`lib/audioif_util/`**`float32(value)` and `float32_bits(value)`,
97+
`struct` and nothing else. A Python float is the interpreter's own width:
98+
a double here, a **single** on every board. So a setting written in Python
99+
`node.mix = 0.35` — is two different numbers, and the node renders
100+
different bytes on a board than on a desktop before its kernel is reached.
101+
Put a derived setting through `float32` and both targets hold the number
102+
the board would have held. See
103+
[docs/correctness-standard.md](docs/correctness-standard.md).
96104
- **`lib/audiorender/`** — renders a whole composition offline: tracks,
97105
tempo map, notes and automation in, a mixed stereo master and a level
98106
report out. This is the one part of the repository written for a desktop

docs/correctness-standard.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,46 @@ being inferred and starts being measured
197197
([docs/building-wheels.md](building-wheels.md)). Proving the board half still
198198
needs board digests. See audioif#79 and audioif#55.
199199

200+
## The other way: a number derived in Python
201+
202+
Contraction is the compiler choosing. This one is the *interpreter* choosing,
203+
and it happens before the kernel is reached at all.
204+
205+
**A setting derived in Python passes through `audioif_util.float32` before it
206+
reaches a node.** That is the rule; the rest of this section is why.
207+
208+
Python's float is the interpreter's `mp_float_t`. On CPython and on the desktop
209+
MicroPython that is a double; on an ESP32-P4, an ESP32-S3, an RP2040 and any
210+
build carrying `-DMICROPY_FLOAT_IMPL=MICROPY_FLOAT_IMPL_FLOAT` it is a single.
211+
So `node.mix = 0.35` is not one setting -- it is two numbers a ULP apart -- and
212+
a node whose blend runs from it renders different bytes on a board than on a
213+
desktop without anything in the kernel being wrong. `audioif_util.float32(x)` is
214+
a pure-Python round trip through `struct`: the identity on a single-precision
215+
target, a rounding on a double one, and the same number afterwards on both.
216+
217+
`audioif_util.float32_bits(x)` is the same rule for output. `"%.6f" % value` is
218+
seven significant digits, and MicroPython's single-precision formatter is not
219+
correctly rounded that far, so a probe printing a float at that width compares
220+
formatters rather than DSP. The bit pattern is exact everywhere.
221+
222+
This is where audioif#80 landed: six probes disagreed with CPython on a
223+
single-precision build, and four of the six were the probes' own arithmetic --
224+
three printing a gain reduction whose *bits were identical on all three
225+
targets*, one passing `mix=0.35`, and one deriving its square-wave material from
226+
a Python float phase, so the two builds were being compared on two different
227+
input signals. The two that remain are below the probes and each has its own
228+
issue: audioif#101 (`synthio.Biquad` derives W0 at `mp_float_t` width where the
229+
CPython extension calls the shared `double` helper) and audioif#102 (a filtered
230+
`Echo` does its per-sample arithmetic at `mp_float_t` width in C and in double
231+
in the twin).
232+
233+
**The same rule belongs to the classes.** audiocomponents#75 is this shape one
234+
tier up -- a class computing `360 * 4 ** (macro / 127)` for a filter frequency
235+
lands its board and its desktop a ULP apart, and the effects programme's board
236+
proofs of 2026-09-17 name it as one of the three causes of a drive class's
237+
digests differing. The derivation ends in `float32` there too, or the board and
238+
the desktop are not running the same filter.
239+
200240
## The one thing this page does not cover
201241

202242
Nothing establishes that a node of ours *sounds right*, or that its algorithm is

lib/audioif_util/__init__.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""One number, the same number, on every target audioif runs on.
2+
3+
``import audioif_util`` -- a pure-Python tier with no dependencies beyond
4+
``struct``, so it imports on CPython, MicroPython and CircuitPython alike.
5+
Nothing here touches the DSP; it exists so the numbers *handed* to the DSP
6+
stop depending on which interpreter derived them.
7+
8+
## Why this is needed at all
9+
10+
Python's float is the interpreter's ``mp_float_t``. On CPython and on a
11+
desktop MicroPython that is a **double**; on an ESP32-P4, an ESP32-S3, an
12+
RP2040 and any MicroPython built with ``MICROPY_FLOAT_IMPL_FLOAT`` it is a
13+
**single**. So::
14+
15+
node.mix = 0.35
16+
17+
is not one setting. It is ``0.34999999403953552`` on a board and
18+
``0.34999999999999998`` on a desktop, and a node that runs its dry/wet blend
19+
from it renders different bytes on the two. That is not a bug in the node,
20+
and no amount of care inside the kernel can fix it: the number was already
21+
two different numbers before it arrived.
22+
23+
`docs/correctness-standard.md` holds our nodes to every target rendering
24+
them identically, so a setting derived in Python arithmetic passes through
25+
:func:`float32` before it reaches a node. On a single-precision target that
26+
call is the identity. On a double one it rounds to the value the board would
27+
have held. Both then agree, which is the whole point.
28+
29+
The same rule is what audiocomponents#75 needs on the class side: a class
30+
computing ``360 * 4 ** (macro / 127)`` for a filter frequency is deriving a
31+
setting in Python, and its board and its desktop land a ULP apart until that
32+
derivation ends in :func:`float32`.
33+
34+
## And why printing needs its own function
35+
36+
:func:`float32_bits` is for output rather than settings. ``"%.6f" % value``
37+
is not one string across interpreters even when ``value`` is bit-for-bit the
38+
same float32: six decimals of a single-precision number is seven significant
39+
digits, and MicroPython's single-precision formatter is not correctly
40+
rounded that far -- it prints ``-5.836908`` where the value is
41+
``-5.836907386779785``. A probe that prints a float at that width is
42+
comparing formatters, not DSP. The bit pattern is exact everywhere and
43+
strictly more sensitive than six decimals, so it is what the parity probes
44+
print.
45+
"""
46+
47+
import struct
48+
49+
__all__ = ("float32", "float32_bits")
50+
51+
52+
def float32(value):
53+
"""``value`` rounded to the nearest IEEE-754 single, as a float.
54+
55+
The identity on any interpreter whose float already *is* a single, and a
56+
rounding on one whose float is a double -- which is exactly what makes
57+
the result the same number on both.
58+
59+
Idempotent: ``float32(float32(x)) == float32(x)`` everywhere.
60+
61+
**One stated limit, measured rather than assumed.** A magnitude past
62+
single-precision range is the one input on which the three runtimes do
63+
not agree: CPython's ``struct`` raises ``OverflowError`` and
64+
MicroPython's and CircuitPython's return an infinity. Keep settings
65+
inside the range a board can hold -- every audio setting is -- and this
66+
never arises; a value out there is a defect on the board too, and one
67+
that would render as silence rather than as an error.
68+
"""
69+
return struct.unpack("<f", struct.pack("<f", value))[0]
70+
71+
72+
def float32_bits(value):
73+
"""``value`` as its IEEE-754 single bit pattern, lower-case hex.
74+
75+
Eight characters, little-endian byte order, e.g. ``"f2c7bac0"`` for
76+
-5.8369074. Exact on every interpreter, unlike a decimal conversion of
77+
the same number, so it is what a cross-target probe prints for a float
78+
it has to compare.
79+
"""
80+
return struct.pack("<f", value).hex()

pyproject.toml

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,18 @@ Issues = "https://github.com/PyDevices/audioif/issues"
3939
py-modules = ["audiocore", "synthio", "audiomixer", "audiofilters", "audiodelays", "audiofreeverb", "audiospeed", "audiodynamics", "audioroute", "audiomath", "audioecho", "audioconvolve", "audiobiquad", "audioshaper", "audioladder", "audioverb", "audiomodal"]
4040
# Keep py-modules explicit: src/cpython is the root package directory now, and
4141
# an automatic scan there would also pick up anything else that lands in it.
42-
packages = ["audiorender"]
43-
# audiorender is the only pure-Python package this wheel carries. The
44-
# instrument and effect libraries (audioinstruments, audioeffects) are not
45-
# in this repository any more: they are developed and published from
46-
# https://github.com/PyDevices/audiocomponents as their own distributions,
47-
# each depending on pydevices-audioif.
42+
packages = ["audiorender", "audioif_util"]
43+
# audiorender and audioif_util are the pure-Python packages this wheel
44+
# carries. The instrument and effect libraries (audioinstruments,
45+
# audioeffects) are not in this repository any more: they are developed and
46+
# published from https://github.com/PyDevices/audiocomponents as their own
47+
# distributions, each depending on pydevices-audioif.
48+
#
49+
# audioif_util is `struct` and two functions, and it is here rather than
50+
# beside the wrappers because it is the one pure-Python thing in this
51+
# repository that has to run on a *board*: a class deriving a setting in
52+
# Python rounds it differently there, and audioif_util.float32 is how it
53+
# stops (docs/correctness-standard.md, audioif#80, audiocomponents#75).
4854

4955
# The CPython target is one directory: src/cpython holds the extension source,
5056
# the extension built in place, and the thirteen modules that wrap it. They are
@@ -59,6 +65,7 @@ packages = ["audiorender"]
5965
[tool.setuptools.package-dir]
6066
"" = "src/cpython"
6167
audiorender = "lib/audiorender"
68+
audioif_util = "lib/audioif_util"
6269

6370
[tool.setuptools.dynamic]
6471
version = {file = "VERSION"}

tests/parity/biquad_component_probe.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,39 @@
1010
1111
Running this against `bin/circuitpython` is expected to differ. That is the
1212
point of it; see docs/upstream-diff.md for the direction of each difference.
13+
14+
**The material is integer arithmetic, deliberately.** The square wave used to
15+
be `(2*pi*hz*frame/RATE) % 2*pi < pi`, and a Python float is the interpreter's
16+
`mp_float_t` -- so on a single-precision build the *input waveform* was a
17+
different waveform (96000 against 120000 summed, 8 frames flipped in mono and
18+
96 in stereo), and this fixture was comparing two DSP kernels on two different
19+
signals. `(2 * hz * frame) % (2 * RATE) < RATE` is the same square wave stated
20+
exactly, identical on every target. It is not byte-identical to what the float
21+
expression produced on a double build -- the float form classified a handful of
22+
exact-zero and exact-half crossings the other way -- so `golden/
23+
biquad_component.json` was re-captured with it (audioif#80).
24+
25+
Q and A go through `audioif_util.float32` for the same reason one layer up:
26+
0.4, 0.7079 and 1.4125 are not one number on a single-precision target.
27+
28+
**What is left, and why it is not fixed here.** Four lines -- `biquad_qa
29+
high_pass` at Q 0.4 -- still diverge on a single-precision MicroPython, because
30+
`src/synthio/Biquad.c:79` derives W0 in `mp_float_t` where the CPython
31+
extension calls the shared `audioif_biquad_cp_w0()` in double. audioif#101 has
32+
the measurement and the fix; it moves board digests, so it is its own change.
1333
"""
1434

1535
from array import array
1636

1737
import audiocore
1838
import audiofilters
1939
import synthio
40+
from audioif_util import float32
2041

2142

2243
#: A and Q the shelf/peaking modes need. A is RBJ's amplitude parameter,
2344
#: 10**(gain_db/40) - so 1.4125 is a +6 dB bell rather than a +1.4 dB one.
24-
GAIN_A = 1.4125
45+
GAIN_A = float32(1.4125)
2546
Q = 1.0
2647
CENTER = 1200
2748
RATE = 8000
@@ -30,8 +51,8 @@
3051
#: arguments to audioif_biquad_configure could be hardcoded to the probe's own
3152
#: value without moving the hash - two of them silently untested. Frequency was
3253
#: the one scalar that varied. These sweep them.
33-
Q_VALUES = (0.4, 1.0, 4.0)
34-
A_VALUES = (0.7079, 1.4125)
54+
Q_VALUES = (float32(0.4), 1.0, 4.0)
55+
A_VALUES = (float32(0.7079), float32(1.4125))
3556

3657

3758
def checksum(data):
@@ -71,10 +92,11 @@ def source(channel_count):
7192
for frame in range(768):
7293
for channel in range(channel_count):
7394
hz = 300 if channel == 0 else 2400
74-
phase = 2.0 * 3.141592653589793 * hz * frame / RATE
7595
# A cheap square-ish shape: deterministic, and rich enough above
76-
# and below CENTER that every mode has something to act on.
77-
values.append(12000 if (phase % 6.283185307179586) < 3.141592653589793
96+
# and below CENTER that every mode has something to act on. In
97+
# integers, so it is the same shape on every interpreter -- see
98+
# the module docstring.
99+
values.append(12000 if (2 * hz * frame) % (2 * RATE) < RATE
78100
else -12000)
79101
return audiocore.RawSample(values, sample_rate=RATE,
80102
channel_count=channel_count)
@@ -168,7 +190,7 @@ def source(channel_count):
168190
for i in range(stages)
169191
)
170192
try:
171-
note = synthio.Note(220.0, amplitude=0.6,
193+
note = synthio.Note(220.0, amplitude=float32(0.6),
172194
filter=stack[0] if stages == 1 else stack)
173195
except TypeError:
174196
print("note_filter_cascade unsupported", stages)

tests/parity/dynamics_extras_probe.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from array import array
1717

1818
import audiocore
19+
from audioif_util import float32_bits
1920

2021
MODULE = sys.argv[1] if len(sys.argv) > 1 else "audiodynamics"
2122
# A built-in module under MicroPython, which does not record those in
@@ -62,8 +63,13 @@ def between_samples(frames=2400):
6263
def emit(tag, node, blocks):
6364
for index in range(blocks):
6465
data = bytes(audiocore.get_buffer(node)[1])
66+
# The gain reduction prints as its float32 bit pattern, not as
67+
# `"%.6f"`: the three targets hold the identical float32 and the
68+
# PCM beside it is byte-for-byte the same, but MicroPython's
69+
# single-precision formatter is not correctly rounded to seven
70+
# significant digits. audioif#80.
6571
print("dynx", tag, index, len(data), sum(data), checksum(data),
66-
"%.6f" % node.gain_reduction_db())
72+
float32_bits(node.gain_reduction_db()))
6773

6874

6975
BASE = {"threshold_db": -12.0, "attack_ms": 0.05, "release_ms": 60.0,

0 commit comments

Comments
 (0)