Skip to content

Commit 977ef26

Browse files
committed
Merge fix/speedchanger-rate-and-phase: SpeedChanger rounds its rate and carries its phase (#91, #92)
Both defects are upstream CircuitPython 10.3.0's own; fixed on the MicroPython and CPython targets as documented deviations, with upstream reports in docs/upstream-reports/. PR #93, 43 checks green.
2 parents 3658e12 + 28b587e commit 977ef26

11 files changed

Lines changed: 954 additions & 42 deletions

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
## Unreleased
22

3+
- `audiospeed.SpeedChanger` **carries its phase across a source buffer**.
4+
Upstream zeroes the accumulator every time it takes a new buffer, so what the
5+
node renders depends on how the node above it chunks its output: the same
6+
hold over 64- and 256-frame buffers differed in 1947 frames of 2048. A
7+
sample-and-hold built from a pair therefore drifted (73 codes over 65536
8+
frames at 48 kHz, 430 at 44.1) and spread its images instead of placing them
9+
— a 1 kHz image under a 7 kHz tone held at 8 kHz read −39.9 dB where a
10+
zero-order hold puts it at −0.22. All of it is 0 now, and the render matches
11+
`source[(((n·up)>>16)·down)>>16]` exactly. A rate that divides the buffer
12+
length was always right, which is why the parity gate never saw it.
13+
`reset_buffer` still starts the stream over. Upstream still restarts, so this
14+
is a named departure — `docs/upstream-diff.md`, report drafted (audioif#91).
15+
16+
- `audiospeed`: the 16.16 rate **rounds** now instead of truncating. Upstream
17+
casts, so a float landing a hair below its Q16 neighbour lost a whole LSB —
18+
`1/1.0000000000000004` came back as 65535/65536, and a `SpeedChanger` pair
19+
asked for "the hold rate = the running rate" was not an identity at 44.1 kHz
20+
(27666 codes of error on a full-scale tone; 48 and 22.05 kHz happened to land
21+
on 1.0 and looked fine). `Resampler`'s bound ratio rounds too: 48000/44100 is
22+
71332, not 71331. Upstream CircuitPython still truncates, so this is a named
23+
departure — `docs/upstream-diff.md`, report drafted (audioif#92).
24+
325
- `audioroute.Splitter` lost the head of any block bigger than its
426
8192-frame ring. `audiocore.get_buffer` takes no length, so a source hands
527
back what it has — a `RawSample` over a table returns the whole table — and

docs/upstream-diff.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,101 @@
11
# Deltas from upstream CircuitPython
22

3+
## `audiospeed`: the Q16 rate rounds here, upstream truncates (audioif#92)
4+
5+
CircuitPython 10.3.0 converts a rate to 16.16 fixed point with a cast
6+
(`shared-module/audiospeed/__init__.c:18-21`):
7+
8+
```c
9+
return (uint32_t)(rate * (1 << SPEED_SHIFT));
10+
```
11+
12+
A cast truncates. So a float that lands a hair *below* its Q16 neighbour loses
13+
a whole LSB rather than arriving at it, and the worst case of the conversion is
14+
one full step instead of half a step. `Resampler` reaches the same arithmetic by
15+
another road — `calculate_rate` in `shared-module/audiospeed/Resampler.c:11`
16+
casts the same way — so binding a ratio from two sample rates truncates too.
17+
18+
Measured on `bin/circuitpython`, whose `shared-module/audiospeed/` is stock
19+
10.3.0, and reproduced identically on this port before the fix:
20+
21+
| asked for | upstream's Q16 | wanted |
22+
|---|---:|---:|
23+
| `0.5 - 1e-6` | 32767 | 32768 |
24+
| `1.0/4.0000000000000036` | 16383 | 16384 |
25+
| `1.0/1.0000000000000004` | 65535 | 65536 |
26+
| `48000/44100`, through a `Resampler` binding | 71331 | 71332 |
27+
28+
The third row is the one that bites. A class asking for "the hold rate = the
29+
running rate" computes `fs / rate_hz` in Python float, gets 1.0000000000000004
30+
off a log-mapped knob, and the `SpeedChanger` pair built from it is **not** an
31+
identity — at 44.1 kHz only, because 48 and 22.05 kHz happen to land on 1.0. A
32+
full-scale 441 Hz tone through that pair read **max |wet − dry| = 27666 codes**
33+
where a wire reads 0.
34+
35+
**This port rounds**: `src/audiospeed/SpeedChanger.c` (`rate_to_fp`),
36+
`src/audiospeed/Resampler.c` (`audiospeed_resampler_set_sample_rate`) and
37+
`src/cpython/audiospeed.py` (the `rate` setter and `_bind_sample_rate`). So a
38+
rate strictly between two Q16 steps renders different bytes here and on
39+
CircuitPython 10.3.0, and ours is the arithmetically closer one. A rate already
40+
*on* a step is untouched by the change, which is why no stored golden moved:
41+
`resampler_probe.py` only ever asks for 2.0, 1.0 and 0.5.
42+
43+
`tests/parity/speedchanger_hold_probe.py` renders the divergence and
44+
`verify_dsp` carries a stated skip for it on circuitpython naming both issues,
45+
so the three-way is not red on a departure that is deliberate. It is also where
46+
a MicroPython build that predates these two fixes shows up: any such binary
47+
renders CircuitPython's bytes here to the byte, because the port was faithful.
48+
49+
Found by the effects program's `Bitcrusher` (audiocomponents#71) and reported
50+
in [upstream-reports/speedchanger-rate-rounding.md](upstream-reports/speedchanger-rate-rounding.md).
51+
52+
## `audiospeed`: the phase accumulator crosses a source buffer, upstream's restarts (audioif#91)
53+
54+
`audiospeed_fetch_source_buffer` calls `audiospeed_reset_phase` when it takes a
55+
new buffer from the source (`shared-module/audiospeed/__init__.c:87`, and the
56+
same line in `__init__.h:39-41`), so whatever fraction — or whole number — of a
57+
source frame the accumulator was carrying is thrown away at every buffer
58+
boundary. Only the very first frame of a buffer is ever the right one to resume
59+
on, and it is right only when the rate divides the buffer length exactly.
60+
61+
What that does to a `SpeedChanger` used as a sample-and-hold, which is how the
62+
palette builds a lo-fi rate reducer (decimate at N, restore at 1/N):
63+
64+
| measured on the CPython twin, 256-frame source buffers unless said otherwise | upstream | this port |
65+
|---|---:|---:|
66+
| the same hold rendered over 64-, 100-, 256- and 1000-frame buffers, frames differing from the 64-frame render, N = 1.8433 | 8023, 8077, 8076 of 8192 | 0, 0, 0 |
67+
| frames differing from `source[(((n·up)>>16)·down)>>16]` over 16384, N = 1.8433 / 6.0 / 2.5 | 15657 / 16066 / 16090 | 0 / 0 / 0 |
68+
| run lengths outside the hold's own alphabet `{2, 3}`, N = 2.5 | 12 | 0 |
69+
| a 1 kHz image under a 7 kHz tone held at 8 kHz, where a zero-order hold puts it at −0.22 dB | −39.86 dB | −0.22 dB |
70+
| worst miss against `20·log₁₀|sinc(f·T)|` over 500 Hz … 5 kHz, N = 1.8433 | 1.41 dB | 0.02 dB |
71+
| a full-scale ramp of one code per frame, lag after 65536 frames, at 48 / 44.1 kHz | 73 / 430 codes | 0 / 1 codes |
72+
73+
Two ratios see none of it and are worth naming, because they are why the gate
74+
was green: at N = 4 over 256-frame buffers the accumulator lands exactly on the
75+
boundary every time and upstream renders the right answer, and `resampler_probe`
76+
only ever asks for 2.0, 1.0 and 0.5, which do the same.
77+
78+
**This port carries the remainder across**, `src/audiospeed/SpeedChanger.c` and
79+
`src/cpython/audiospeed.py`: `fetch_source_buffer` subtracts the frames the
80+
*previous* buffer held rather than zeroing, and `get_buffer` pulls buffers until
81+
the index the accumulator names lands inside one, instead of assuming it lands
82+
on frame 0. `reset_buffer` still zeroes, because a reset resets the source too
83+
and the stream genuinely restarts there.
84+
85+
Two things the carry made necessary and that upstream does not have. A source
86+
handing back a buffer shorter than one frame is now treated as exhausted; with
87+
the carry, upstream's `src_index = 0` against a zero-frame buffer would be an
88+
unbounded loop rather than the out-of-bounds read it is today. And the
89+
subtraction is guarded (`phase >= consumed`), so the accumulator cannot wrap
90+
below zero. Neither changes anything for a well-formed source.
91+
92+
Upstream's `phase` is `uint32_t` in Q16, so a source buffer longer than 65535
93+
frames cannot be indexed at all. That limit is upstream's and this change
94+
neither widens nor narrows it.
95+
96+
Found by the effects program's `Bitcrusher` (audiocomponents#71) and reported
97+
in [upstream-reports/speedchanger-phase-carry.md](upstream-reports/speedchanger-phase-carry.md).
98+
399
## `audiodelays.Flanger`: we do not reproduce upstream's int32 overflow (audioif#76)
4100

5101
CircuitPython 10.3.0's `shared-module/audiodelays/Flanger.c:365` computes the

docs/upstream-reports/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Drafts for adafruit/circuitpython
22

3+
Two later drafts are **not filed yet** and are held with the rest. Both are in
4+
`audiospeed`, which is new in 10.3.0, and both were measured on a build of that
5+
tag rather than on this port (see "Provenance of the numbers").
6+
7+
| draft | what | shape of the fix |
8+
|---|---|---|
9+
| [speedchanger-rate-rounding.md](speedchanger-rate-rounding.md) | the 16.16 rate truncates, so `1/1.0000000000000004` is 65535/65536 and a `SpeedChanger` pair asked for unity is not one | one character, twice |
10+
| [speedchanger-phase-carry.md](speedchanger-phase-carry.md) | the phase accumulator is zeroed at every source buffer, so what the node renders depends on the block size above it: 1947 frames of 2048 differ between a 64- and a 256-frame source | a few lines |
11+
312
Six bugs this port found in CircuitPython, written up as issue bodies.
413
**Filed 2026-08-28** (all re-verified by inspection of `10.3.0-rc.0` first):
514

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Draft: `audiospeed` throws away its phase at every source buffer
2+
3+
**Note to the poster — strip everything above the `---`.**
4+
5+
The larger of the two `audiospeed` drafts, and the one with an audible symptom.
6+
Independent of [speedchanger-rate-rounding.md](speedchanger-rate-rounding.md)
7+
different lines of the same file — but the repro reads better with that one
8+
applied, because otherwise the rates in it are a hair off as well. Suggested
9+
title:
10+
11+
> `audiospeed_fetch_source_buffer` resets the phase, so a `SpeedChanger` drifts
12+
> and only renders correctly when the rate divides the buffer length
13+
14+
Verified present on `10.3.0` 2026-09-17 on a build of that tag with
15+
`CIRCUITPY_AUDIOSPEED` on. The fix was verified by applying it to a port of the
16+
same code: every measurement below goes to its ideal value, and the node's
17+
output stops depending on the source's block size at all.
18+
19+
---
20+
21+
### `audiospeed_fetch_source_buffer` resets the phase, so a `SpeedChanger` drifts and only renders correctly when the rate divides the buffer length
22+
23+
`shared-module/audiospeed/__init__.c`:
24+
25+
```c
26+
bool audiospeed_fetch_source_buffer(audiospeed_base_t *self) {
27+
...
28+
self->src_sample_count = len / bytes_per_frame;
29+
self->source_done = (result == GET_BUFFER_DONE);
30+
// Reset phase to index within this new buffer
31+
audiospeed_reset_phase(&self->speed);
32+
return true;
33+
}
34+
```
35+
36+
and `audiospeed_get_buffer` assumes that reset a few lines later:
37+
38+
```c
39+
if (src_index >= self->src_sample_count) {
40+
...
41+
if (!audiospeed_fetch_source_buffer(self)) {
42+
break;
43+
}
44+
src_index = 0; // phase was reset by fetch
45+
}
46+
```
47+
48+
The accumulator is in units of **source frames since the stream began**, not
49+
since this buffer began, so the remainder it is holding when a buffer runs out
50+
belongs to the next one. Zeroing it discards that remainder.
51+
52+
A buffer boundary is only ever the right place to resume from frame 0 when the
53+
rate divides the buffer length exactly. At `rate` 1.8433 with a 256-frame
54+
upstream, 0.2 of a frame is dropped every 139 output frames; at `rate` 6 with
55+
the same upstream, four whole frames are dropped every 43.
56+
57+
#### What it sounds like
58+
59+
A `SpeedChanger` pair used as a sample-and-hold — decimate at N, restore at 1/N,
60+
which is how a lo-fi rate reducer is built out of this module — is the clearest
61+
case, because a hold has an exactly known answer.
62+
63+
Measured with a 256-frame source, everything else at defaults:
64+
65+
| | 10.3.0 | with the fix |
66+
|---|---:|---:|
67+
| the same hold over 64-, 100-, 256- and 1000-frame source buffers: frames differing from the 64-frame render, of 8192, N = 1.8433 | 8023, 8077, 8076 | 0, 0, 0 |
68+
| frames differing from `source[(((n·up)>>16)·down)>>16]`, of 16384, at N = 1.8433 / 6.0 / 2.5 | 15657 / 16066 / 16090 | 0 / 0 / 0 |
69+
| a 1 kHz image under a 7 kHz tone held at 8 kHz, where a zero-order hold puts it at −0.22 dB | −39.86 dB | −0.22 dB |
70+
| worst miss against `20·log₁₀\|sinc(f·T)\|` from 500 Hz to 5 kHz, N = 1.8433 | 1.41 dB | 0.02 dB |
71+
| a full-scale ramp of one code per frame: lag after 65536 frames at 48 / 44.1 kHz | 73 / 430 codes | 0 / 1 |
72+
73+
The first row is the property worth keeping: **what a `SpeedChanger` renders
74+
should not depend on how the node above it happens to chunk its output**, and
75+
today it depends on it almost entirely. The rest follow from it — the staircase
76+
restarts 187 times a second at 48 kHz, so the held waveform is not the hold that
77+
was asked for, its images are spread instead of placed, and the stream drifts
78+
against everything playing beside it.
79+
80+
A rate that divides the buffer length is exempt, which is why this is easy to
81+
miss: at N = 4 over 256-frame buffers, and at the 2.0 / 1.0 / 0.5 a `Resampler`
82+
is usually bound to, the accumulator lands on the boundary every time and the
83+
output is correct.
84+
85+
#### Fix
86+
87+
Subtract what the previous buffer held instead of zeroing, and let the caller
88+
ask the accumulator where it is rather than assuming frame 0:
89+
90+
```diff
91+
bool audiospeed_fetch_source_buffer(audiospeed_base_t *self) {
92+
...
93+
- self->src_sample_count = len / bytes_per_frame;
94+
- self->source_done = (result == GET_BUFFER_DONE);
95+
- // Reset phase to index within this new buffer
96+
- audiospeed_reset_phase(&self->speed);
97+
+ // The frames just consumed are the ones the previous buffer held; the
98+
+ // remainder belongs to this one.
99+
+ audiospeed_consume_frames(&self->speed, self->src_sample_count);
100+
+ self->src_sample_count = len / bytes_per_frame;
101+
+ self->source_done = (result == GET_BUFFER_DONE);
102+
return true;
103+
}
104+
```
105+
106+
with, in `__init__.h`:
107+
108+
```c
109+
static inline void audiospeed_consume_frames(audiospeed_speed_t *self, uint32_t frames) {
110+
uint32_t consumed = frames << SPEED_SHIFT;
111+
self->phase = self->phase >= consumed ? self->phase - consumed : 0;
112+
}
113+
```
114+
115+
and, in both arms of `audiospeed_get_buffer`, pulling until the index lands
116+
inside a buffer instead of assuming one pull is enough — at a rate above 1.0 the
117+
carry can be several frames, and the next buffer is free to be shorter than
118+
that:
119+
120+
```diff
121+
- uint32_t src_index = audiospeed_get_index(&self->speed);
122+
- if (src_index >= self->src_sample_count) {
123+
+ while (audiospeed_get_index(&self->speed) >= self->src_sample_count) {
124+
if (self->source_done) {
125+
self->source_exhausted = true;
126+
break;
127+
}
128+
if (!audiospeed_fetch_source_buffer(self)) {
129+
break;
130+
}
131+
- src_index = 0; // phase was reset by fetch
132+
}
133+
+ if (audiospeed_get_index(&self->speed) >= self->src_sample_count) {
134+
+ break;
135+
+ }
136+
+ uint32_t src_index = audiospeed_get_index(&self->speed);
137+
```
138+
139+
One guard goes with it. `audiospeed_fetch_source_buffer` tests `len == 0`; with
140+
the carry it needs `len < bytes_per_frame`, because a buffer holding no whole
141+
frame can never advance the index. Today that case is an unconditional
142+
`src_index = 0` against a zero-frame buffer, which reads off the end of it, so
143+
the guard is worth having either way.
144+
145+
`audiospeed_reset_buffer` should keep zeroing the phase: it resets the source as
146+
well, so the stream genuinely restarts there.
147+
148+
#### Repro
149+
150+
```python
151+
# What a SpeedChanger renders should not depend on the block size of the node
152+
# above it. Two identical holds, two different upstream buffer sizes.
153+
import array, math
154+
import audiocore, audiofilters, audiospeed
155+
156+
RATE, FRAMES, DOWN = 48000, 4096, 1.8433
157+
158+
values = array.array("h", bytes(FRAMES * 4))
159+
for frame in range(FRAMES):
160+
value = int(round(16384 * math.sin(2 * math.pi * 100 * frame / RATE)))
161+
values[frame * 2] = values[frame * 2 + 1] = value
162+
163+
164+
def hold(block):
165+
wire = audiofilters.Filter(sample_rate=RATE, channel_count=2,
166+
buffer_size=block * 4)
167+
wire.play(audiocore.RawSample(values, sample_rate=RATE, channel_count=2))
168+
node = audiospeed.SpeedChanger(
169+
audiospeed.SpeedChanger(wire, DOWN), 1.0 / DOWN)
170+
out = array.array("h")
171+
while len(out) < FRAMES * 2:
172+
out.extend(array.array("h", bytes(audiocore.get_buffer(node)[1])))
173+
return [out[index * 2] for index in range(FRAMES // 2)]
174+
175+
176+
small, large = hold(64), hold(256)
177+
wrong = sum(1 for a, b in zip(small, large) if a != b)
178+
print("%d of %d frames differ between a 64- and a 256-frame source"
179+
% (wrong, len(small)))
180+
```
181+
182+
```
183+
1947 of 2048 frames differ between a 64- and a 256-frame source
184+
```
185+
186+
With the fix applied it prints `0 of 2048`.
187+
188+
`audiocore.get_buffer` is not upstream; on a board the same two graphs played
189+
through an `AudioOut` differ audibly, since one of them is not the hold that was
190+
asked for.

0 commit comments

Comments
 (0)