Skip to content

audiopump: DriftResampler, a Ring filled on another clock held at its target fill - #339

Merged
bdbarnett merged 1 commit into
mainfrom
audiospeed-drift
Oct 30, 2026
Merged

bdbarnett merged 1 commit into
mainfrom
audiospeed-drift

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

Adds audiopump.DriftResampler. It plays a Ring that something else fills on its own clock, such as a USB microphone, a network stream or a second codec, and steers its ratio so the Ring stays at a target fill. Two devices that both say 48 kHz differ by tens of ppm, so without this a Ring between them slowly runs dry or overflows.

ring = audiopump.Ring(sample_rate=48000, channel_count=2, frames=256, capacity=8)
drift = audiopump.DriftResampler(ring)   # holds the Ring at 1024 frames
out.play(drift)

How it works

The full write-up is docs/how-it-works/drift-resampler.md.

  • Interpolation. A 32-tap Kaiser-windowed sinc (beta 9, cutoff 0.45 of the input rate), tabulated at 128 phases with a linear blend between neighbouring rows. Each row sums to 1. The table is a checked-in constant written by tools/design_drift_table.py, so every target uses the same numbers. Per stereo output frame this costs about 96 float multiply-adds. At 48 kHz that is about 3% of an ESP32-P4 core. That figure is estimated from the operation count and has not been timed on a board.
  • Ratio. The read position is 32.32 fixed point, which gives a step of about 0.0002 ppm. SpeedChanger's 16.16 rate moves in steps of about 15 ppm.
  • Control. The fill goes through two one-pole smoothers into a PI loop (damping 0.8), and the loop's output is clamped to max_ppm. The loop starts at 8 times its bandwidth (default 0.01 Hz, allowed 0.001 to 0.1 Hz) and narrows with a 15 s time constant. Playback starts at the target, and anything beyond it is dropped on the first block. An underrun plays a block of silence, is counted on the node and on the Ring, and the node then waits for the target again, keeping the rate it has learned.
  • Placement. The class is in audiopump beside Ring, not in audiospeed, because CircuitPython has its own audiospeed and our modules never shadow CircuitPython's.
  • Up-sampling only. The output rate can be anywhere from the Ring's rate to twice it. Converting down is a ROADMAP item.

The Ring gains consumer-side calls (readable, read, starve) on MicroPython and CircuitPython so the node can read it at its own pace.

Measured

These are desktop runs through the CPython build. A producer writes a Ring of 8 blocks of 256 frames (target 1024) on an offset clock, in 48-frame and 480-frame packets, for 90 to 120 s.

Producer 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 28 to 32 s ±100 ppm, ±6 ppm wobble
±100 ppm wandering over 2 min 48 and 480 0 / 0 tracks within 54 frames follows it

THD+N for a 0.9 FS sine (20 Hz to 20 kHz, fundamental excluded) is −93.7 to −95.2 dB at 1 kHz and 10 kHz in every case. A single 16-bit quantization measures −97.5 dB on the same analysis, and two quantizations (16-bit in, 16-bit out) come to about −94.5 dB.

Tests

  • tests/test_cpython_drift.py opens with a trait table and runs in about 1.5 s. It covers lock at ±100 ppm with 48-frame and 480-frame packets, a wandering clock, THD+N below −92 dB at 1 kHz and 10 kHz, exact pass-through of a constant input, starting at the target, a stall, reset(), and every argument bound.
    • Two planted faults were caught. Flipping the integrator's sign failed all four lock tests, and removing the phase blend failed both THD+N cases.
  • tests/parity/drift_probe.py, added to verify_dsp.py, renders five cases three ways: ±3000 ppm, a stall, a retarget, and 44.1 to 48 kHz. MicroPython (unix), CircuitPython (unix coverage) and CPython give byte-identical output.
    • The probe was run on its own. A full verify_dsp.py run was not possible because the local MicroPython build lacked other modules.
  • tests/test_binding_parity.py now holds the two DriftResampler bindings to the same names.

Not done

  • Not run on a board yet.
  • No downsampling.

… at its target fill

A 32-tap windowed-sinc interpolator with a 32.32 read position, steered by a
PI loop on the Ring's smoothed fill. The kernel is shared C; MicroPython,
CircuitPython and CPython bind it and render it byte for byte. It lives in
audiopump because CircuitPython owns audiospeed.
@bdbarnett
bdbarnett merged commit 9f8832e into main Oct 30, 2026
32 checks passed
@bdbarnett
bdbarnett deleted the audiospeed-drift branch October 30, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant