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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ audio pump holding up under live use such as a stompbox or a stage rig.
- A level ramp on `audiomixer` voices: a level move ramps over a set number of
frames instead of stepping, so an effect can fade its wet path in or out
without a click when you switch it in mid-note.
- `audiopump.DriftResampler` converting down as well as up, with its filter's
cutoff following the output rate, so a 48 kHz stream can play on a
44.1 kHz output.

## Housekeeping

Expand Down
1 change: 1 addition & 0 deletions circuitpython.mk
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ AUDIODSP_CP_SOURCES += $(addprefix $(AUDIODSP_CP_SRC)/shared/, \
audiodsp_vorbis.c \
audiodsp_port.c \
audiodsp_graph.c \
audiodsp_drift.c \
)

# The graph's OVERDRIVE node needs double-precision libm the RP2040 build
Expand Down
3 changes: 2 additions & 1 deletion docs/how-it-works/additions.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ levels for a spectrum meter) and `audiofeatures` (speech features for a wake
word model) are the two CircuitPython doesn't get: their sources are a board's
pump and a USB sound card, and CircuitPython has neither. `audiovorbis` plays Ogg Vorbis files, which CircuitPython has no
module for. And `audiopump.Ring` lets CircuitPython play audio your own code
computes.
computes, with `audiopump.DriftResampler` to play one filled on another
device's clock ([the drift resampler](drift-resampler.md)).

## Why `audioroute.Port` exists

Expand Down
122 changes: 122 additions & 0 deletions docs/how-it-works/drift-resampler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# The drift resampler

`audiopump.DriftResampler` plays a `Ring` that something else fills on its
own clock: a USB microphone, a network stream, a second codec. You hand it
the Ring and play it instead of the Ring, and it keeps the Ring at a target
fill for as long as the two clocks run, without a gap or a skip:

```python
ring = audiopump.Ring(sample_rate=48000, channel_count=2, frames=256, capacity=8)
drift = audiopump.DriftResampler(ring) # holds the Ring at 1024 frames
mixer.voice[0].play(drift)
```

This page is why it is built the way it is, and what it measured.

## Why a fixed ratio isn't enough

Two devices that both say 48 kHz differ by tens of parts per million, and
up to a few hundred for cheap crystals. At 100 ppm a Ring between them
gains or loses 4.8 frames a second, so a 1024-frame cushion is gone in
under four minutes. The fix is to read the Ring very slightly faster or
slower than nominal, by exactly the clocks' difference, which nobody knows
in advance and which wanders with temperature.

`audiospeed.SpeedChanger` holds its rate in 16.16 fixed point, a step of
about 15 ppm: the size of the error being corrected. Here the read position
and the ratio are 32.32 fixed point, a step of about 0.0002 ppm, so the
ratio can sit where the clocks are rather than hunting between two steps
either side of it.

## Where it lives

The node is in `audiopump`, beside `Ring`, rather than beside
`audiospeed.Resampler`. CircuitPython has its own `audiospeed`, and a module
of ours never shadows one of CircuitPython's
([the correctness standard](correctness-standard.md)). `audiopump` is ours
on all three runtimes, and the node only reads a Ring, so it belongs with
the Ring.

## Interpolation

The read position falls between input samples, so the node interpolates
with a 32-tap Kaiser-windowed sinc (beta 9, cutoff 0.45 of the input rate).
The filter is tabulated at 128 fractional positions, plus one more so a
neighbour always exists, and the node blends linearly between the two rows
either side of the read position. Each row is scaled to sum to exactly 1, so
a constant input comes out unchanged at every position. The table is
[`src/shared/audiodsp_drift_table.h`](../../src/shared/audiodsp_drift_table.h),
written by [`tools/design_drift_table.py`](../../tools/design_drift_table.py);
it is a checked-in constant rather than something each runtime computes,
because CircuitPython's floats are single precision and its libm is not
glibc's, and every target has to render the node identically.

The cutoff is set for the input rate, so the node converts up or across,
never down: an output rate from the Ring's own up to twice it. Converting
down would fold everything between the two Nyquist frequencies back into
the band.

**Cost.** Per output frame: 32 coefficient blends, then 32 multiply-adds a
channel, all single-precision float. That is about 96 floating-point
multiply-adds a stereo frame, 4.6 million a second at 48 kHz, or roughly 3%
of one ESP32-P4 core at 360 MHz. That figure is an estimate from the
operation count; it has not been timed on a board. The work buffer is the block plus 36
frames, as floats.

## Control

Once a block, the node measures the fill: the frames the Ring holds plus
those already taken into the filter beyond its look-ahead, less the
fractional read position. The ring's level jumps by a whole packet each
time the producer writes, so the measurement goes through two one-pole
smoothers before a PI loop. The loop's output is the ratio's offset from
nominal, clamped to `max_ppm` (1000 by default), and the integrator is
clamped to the same bound so it cannot wind up.

The loop is a second-order system with damping 0.8 and natural frequency
`bandwidth` (0.01 Hz by default, 0.001 to 0.1 allowed). It starts at eight
times that and narrows with a 15-second time constant, so it locks in
seconds and then holds still. The smoothers sit at four times the loop's
frequency. Above about 0.1 Hz the loop starts following the producer's
packets instead of its clock, and the ratio swings by hundreds of ppm from
one block to the next, so the bound stops there.

Playback starts when the fill first reaches the target, and anything beyond
it is dropped on that block, so the loop does not start by draining an
excess. If the Ring runs short of a block, the node plays a block of
silence, counts an underrun on itself and on the Ring, and waits for the
target again. It keeps the rate it had learned, since the clocks have not
changed.

## What it measured

On the desktop, through the CPython build, with a producer writing a Ring
of 8 blocks of 256 frames (target 1024) on a clock offset from the output's.
Two packet sizes: 48 frames (1 ms, a USB microphone) and 480 (10 ms, a
network or Bluetooth burst). 90 to 120 seconds each, default settings.

| Producer's clock | Packet | Underruns, overruns | Within 8 frames of target after | Ratio after lock |
| --- | --- | --- | --- | --- |
| +100 ppm | 48 | 0, 0 | 2.3 s | 100.00 ± 0.36 ppm |
| −100 ppm | 48 | 0, 0 | 6.2 s | −99.94 ± 0.36 ppm |
| +300 ppm | 48 | 0, 0 | 5.6 s | — |
| +100 ppm | 480 | 0, 0 | 32 s | 101.7 ± 6.4 ppm |
| −100 ppm | 480 | 0, 0 | 28 s | −98.1 ± 5.2 ppm |
| ±100 ppm over 2 minutes | 48 and 480 | 0, 0 | tracks within 54 frames | follows it |

The 480-frame cases take longer because the first measurement lands
somewhere in the packet's sawtooth, up to about 100 frames from where the
smoothed fill settles. The ±5 ppm left on the ratio there is the packets
showing through the smoothers, a fraction of a cent of pitch moving below
1 Hz.

**THD+N**, a 0.9 full-scale sine in, everything from 20 Hz to 20 kHz but the
fundamental out: −93.7 to −95.2 dB at 1 kHz and 10 kHz across every case
above. A sine quantized once to 16 bits measures −97.5 dB at 1 kHz on the
same analysis, and the node's input and its output are each 16-bit, so two
roundings alone come to about −94.5 dB. The interpolation adds little to
what 16-bit audio already has.

`tests/test_cpython_drift.py` holds these as traits, with bars a little
looser than the figures, and `tests/parity/drift_probe.py` checks that
MicroPython, CircuitPython and CPython render the node byte for byte.
1 change: 1 addition & 0 deletions docs/how-it-works/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,5 +137,6 @@ push-PCM transports.
- [Differences from upstream, with measurements](upstream-diff.md): the reasons behind each one.
- [Upstream reports](../upstream-reports/README.md): the fixes sent to CircuitPython.
- [The graph runner](audiograph.md): how `audiograph.Graph` renders a patch.
- [The drift resampler](drift-resampler.md): how `audiopump.DriftResampler` holds a Ring filled on another clock.
- [Writing a pump port](pump-ports.md): how a platform plugs in a clock, a thread and a sink.
- [Wheels and platforms](wheels-and-platforms.md): what the release builds, and what it skips.
35 changes: 31 additions & 4 deletions docs/reference/audiopump.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ the loop hands the thread back on a full ring and the caller's next
`park_spin` — a driver that is not finished, which now says so rather than
quietly losing blocks.

## `DriftResampler`

```python
drift = audiopump.DriftResampler(ring, *, sample_rate=None, target=None,
max_ppm=1000, bandwidth=0.01)
```

Plays `ring`, which something else fills on its own clock, and steers its
ratio so the Ring holds `target` frames. Play the DriftResampler, not the
Ring: it is the Ring's only reader. On every runtime, CircuitPython included.

| Argument | Meaning |
| --- | --- |
| `sample_rate` | The output's rate: the Ring's own (the default) up to twice it. |
| `target` | The fill to hold, in frames. Half the Ring by default; at least a block, and at most the Ring less 16 frames, the filter's look-ahead. Settable while playing; the fill moves there at the loop's pace. |
| `max_ppm` | How far the ratio may move from nominal, 0 to 10000. |
| `bandwidth` | How fast the loop follows the clocks once locked, 0.001 to 0.1 Hz. It starts eight times faster and narrows over about a minute. |

The Ring's block must be 16 to 1024 frames. `ppm` (the ratio's offset from
nominal), `fill` (the smoothed fill it is holding) and `rate` (Ring frames per
output frame) say what the loop is doing; `underruns` counts blocks of
silence played because the Ring ran dry, after which it waits for `target`
again. `reset()` starts over and forgets the rate. Why it is built this way,
and what it measured: [the drift resampler](../how-it-works/drift-resampler.md).

## What feeds the pump

Three kinds of source feed it and they are all the same kind of thing to it:
a graph; `audiopump.Ring`, an audiosample node Python writes PCM into, so a
pushed stream can sit behind a Mixer with effects on it like anything else;
Expand Down Expand Up @@ -62,10 +89,10 @@ while playing: # from your app's timer, say every 20 ms
their own tick. What CPython does not have is `pull()` under a heap lock,
because it has no heap lock, and audiocore's pull-depth cap: a graph that
leads back into itself is caught at Python's recursion limit instead, and
reported as the same fault. **CircuitPython carries only `Ring`**: its playback
layer already pulls natively from its own audio thread, and a second pull loop
would be a second owner of the same graph, so there a board's audio output
pulls the `Ring` itself X. It still takes the lock and the
reported as the same fault. **CircuitPython carries only `Ring` and
`DriftResampler`**: its playback layer already pulls natively from its own
audio thread, and a second pull loop would be a second owner of the same
graph, so there a board's audio output pulls the `Ring` itself. It still takes the lock and the
hook table, on default hooks, which cost a load and a branch.

The hardware — the channel a board writes into, and a live microphone as a
Expand Down
2 changes: 2 additions & 0 deletions micropython.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ target_sources(usermod_mpaudio INTERFACE
${MPAUDIO_SRC_DIR}/audiopump/audiopump_ring.c
${MPAUDIO_SRC_DIR}/audiopump/audiopump_events.c
${MPAUDIO_SRC_DIR}/audiopump/audiopump_tap.c
${MPAUDIO_SRC_DIR}/audiopump/audiopump_drift.c
${MPAUDIO_SRC_DIR}/shared/audiodsp_drift.c
${MPAUDIO_SRC_DIR}/shared/audiodsp_rawsample.c
${MPAUDIO_SRC_DIR}/shared/audiodsp_synth_dsp.c
${MPAUDIO_SRC_DIR}/shared/audiodsp_envelope.c
Expand Down
4 changes: 3 additions & 1 deletion micropython.mk
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ SRC_USERMOD_C += \
$(MPAUDIO_SRC_DIR)/audiopump/audiopump.c \
$(MPAUDIO_SRC_DIR)/audiopump/audiopump_ring.c \
$(MPAUDIO_SRC_DIR)/audiopump/audiopump_events.c \
$(MPAUDIO_SRC_DIR)/audiopump/audiopump_tap.c
$(MPAUDIO_SRC_DIR)/audiopump/audiopump_tap.c \
$(MPAUDIO_SRC_DIR)/audiopump/audiopump_drift.c \
$(MPAUDIO_SRC_DIR)/shared/audiodsp_drift.c

# --- module skeleton: empty `import <name>` targets for every top-level
# module this tree will grow into (see docs/porting-plan.md). Tiers add
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"src/shared/audiodsp_filter_f32.c",
"src/shared/audiodsp_shaper.c",
"src/shared/audiodsp_samplehold.c",
"src/shared/audiodsp_drift.c",
"src/shared/audiodsp_ladder.c",
"src/shared/audiodsp_oscillator.c",
"src/shared/audiodsp_trig.c",
Expand Down
2 changes: 2 additions & 0 deletions src/audiopump/audiopump.c
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
#include "audiopump/audiopump_events.h"
#include "audiopump/audiopump_ring.h"
#include "audiopump/audiopump_tap.h"
#include "audiopump/audiopump_drift.h"

// The same acquire/release pair the push ring uses, and here for the same
// reason: `volatile` orders the compiler, not the machine, and a 64-bit store
Expand Down Expand Up @@ -2381,6 +2382,7 @@ static const mp_rom_map_elem_t audiopump_globals_table[] = {
// The push side. Every port, not just esp32: the ring is RAM and a
// memcpy, so the unix build is where its correctness is settled.
{ MP_ROM_QSTR(MP_QSTR_Ring), MP_ROM_PTR(&audiopump_ring_type) },
{ MP_ROM_QSTR(MP_QSTR_DriftResampler), MP_ROM_PTR(&audiopump_drift_type) },
// Sequenced music, and the way out to a meter. Same reasoning: RAM,
// arithmetic and a memcpy, so the unix build settles both.
{ MP_ROM_QSTR(MP_QSTR_Events), MP_ROM_PTR(&audiopump_events_type) },
Expand Down
Loading
Loading