Sample-rate conversion for audio in Zig: any rate to any other, as a stream
  • Zig 87%
  • Nix 13%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 948e180751
All checks were successful
test / test (push) Successful in 4m54s
test / docs (push) Successful in 5m44s
Move to Zig 0.17
Zig's own fuzzer now runs with coverage, given a test binary built by
LLVM, so the devshell's patched Zig goes and the fuzz tests ask for
use_llvm. fuzz-run stays, for its time limit, seeds and replays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6qkRGrtbtr4re1HuETeo7
2026-10-07 02:04:12 -05:00
.forgejo/workflows Move to Zig 0.17 2026-10-07 02:04:12 -05:00
LICENSES Sample-rate conversion for audio 2026-09-27 14:42:33 -05:00
src Sample-rate conversion for audio 2026-09-27 14:42:33 -05:00
tests Move to Zig 0.17 2026-10-07 02:04:12 -05:00
tools Move to Zig 0.17 2026-10-07 02:04:12 -05:00
.gitignore Sample-rate conversion for audio 2026-09-27 14:42:33 -05:00
build.zig Move to Zig 0.17 2026-10-07 02:04:12 -05:00
build.zig.zon Move to Zig 0.17 2026-10-07 02:04:12 -05:00
build.zig.zon.nix Move to Zig 0.17 2026-10-07 02:04:12 -05:00
flake.lock Move to Zig 0.17 2026-10-07 02:04:12 -05:00
flake.nix Move to Zig 0.17 2026-10-07 02:04:12 -05:00
package.nix Move to Zig 0.17 2026-10-07 02:04:12 -05:00
README.md Move to Zig 0.17 2026-10-07 02:04:12 -05:00
REUSE.toml Sample-rate conversion for audio 2026-09-27 14:42:33 -05:00

zig-resample

Sample-rate conversion for audio in Zig: any rate to any other, on interleaved f32, as a stream.

The API documentation, generated from the doc comments, is published at https://jeff.jcollie.page/zig-resample/.

const resample = @import("resample");

var r: resample.Resampler = try .init(gpa, .{
    .in_rate = 44100,
    .out_rate = 48000,
    .channels = 2,
    .quality = .medium,
});
defer r.deinit();

// As much of the input as it can take, into as much output as there is
// room for; call again with the rest.
const done = r.process(input, output);
// done.consumed input frames, done.produced output frames.

// At the end, until it returns 0:
const tail = r.flush(output);

Requires Zig 0.17.

What it does

It is bandlimited interpolation, as Julius O. Smith describes it. Each output sample is the input's value at a moment between its samples. That value is found by weighting the input samples around the moment by a windowed sinc: the ideal lowpass filter, cut off at the lower of the two rates' Nyquist frequencies, and shaped by a Kaiser window so that it ends. Going down in rate, the filter is stretched by the ratio. What the new rate cannot carry is then filtered out rather than folded back in as aliasing.

The filter is kept as a table: 512 values between each of its zero crossings, read between them linearly. There is one table whatever the ratio, and the ratio need not be a tidy fraction: 44 100 to 48 000 and 44 100 to 44 101 cost the same. Where each output falls between input samples is kept exactly, as a count in units of one output rate's worth of input, so a stream of any length never drifts.

Quality Zero crossings each side Kaiser β Passband
fast 16 7 to 90% of the lower Nyquist frequency
medium 32 9 to 94%
best 64 12 to 96%

After flush the output is exactly outputFrames(input frames) long, and it is aligned with the input: the first output sample is the input's value at its first sample, not the filter's start. Equal rates are a plain copy.

Any number of channels, and rates up to 2²⁴ Hz, which is far above any audio's and keeps every product of two rates and a frame count inside 64 bits.

Testing

$ nix develop
$ zig build test --summary all
$ zig build check      # everything compiles, including the fuzz loop and docs server
$ zig build docs-serve # the API documentation, at http://localhost:8000/
$ reuse lint

The tests hold it to what it is for:

  • a 1 kHz tone taken from 44.1 to 48 kHz comes out as the same tone, within 60, 80 and 100 dB for the three qualities;
  • taken from 48 to 8 kHz, a 6 kHz tone, which 8 kHz cannot carry, is more than 80 dB down rather than folded back to 2 kHz, and a 3 kHz tone is kept within 0.01 dB;
  • two channels stay apart through 44 100 to 44 101;
  • fed in random pieces with random room, the output is the same, bit for bit, as fed whole;
  • a step lands where it was, halfway at the moment it steps, not a filter's length later.

Fuzzing

tests/fuzz.zig holds one property over any pair of rates, any channel count and quality, and any samples, fed in any pieces:

  • all the input is taken, and flushed, exactly outputFrames come out;
  • every output sample is finite, and within what the filter can make of the input's largest;
  • the output fed in pieces is the output fed whole, bit for bit;
  • what was allocated is freed.
$ zig build fuzz --fuzz                             # Zig's fuzzer, until interrupted
$ zig build fuzz --fuzz=1M                          # a bounded run, then a report
$ zig build fuzz-run                                # a minute
$ zig build fuzz-run -- --seconds 300 --target streams
$ zig build fuzz-run -Dfuzz-optimize=debug -- --input fuzz-findings/x.bin --target streams

zig build fuzz --fuzz is Zig's own fuzzer, steered by coverage, which it finds only in a test binary built by LLVM; build.zig asks for that. A finding prints input saved to '.zig-cache/f/crash' above its report. fuzz-run is a loop of this project's own in tools/fuzz.zig, with no coverage but with a time limit, a seed to repeat a run by, and a way to run one input again; that file says more. A failing input is written to fuzz-findings/, and --input runs it again.

Using it

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-resample.git#main
const resample = b.dependency("resample", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("resample", resample.module("resample"));

zig-sendspin's server uses it to play one source to players that each take their own rate.

Where this lives

The repository has three homes, and they hold the same history.

Where How to get it
Forgejo git clone https://git.jcollie.dev/jeff/zig-resample.git
Tangled git clone https://tangled.org/jcollie.dev/zig-resample
Radicle rad clone rad:z344zt5S1AuiGNwaPGmNqx33Rgz91

A Radicle repository is findable only by its ID, so that one is written out in full: rad:z344zt5S1AuiGNwaPGmNqx33Rgz91.

Licensing

MIT, following the REUSE specification: every file says so, or REUSE.toml says it for it.

References cited

Kept in the zig-resample Zotero collection.

  • Smith, Julius O., and Phil Gossett. "A Flexible Sampling-Rate Conversion Method." In ICASSP '84. IEEE International Conference on Acoustics, Speech, and Signal Processing, 9:112–115. San Diego, CA, USA: IEEE, 1984. https://doi.org/10.1109/ICASSP.1984.1172555. The method: a windowed sinc kept as a table and read between its entries, stretched when going down.
  • Smith, Julius O. "Digital Audio Resampling Home Page." Center for Computer Research in Music and Acoustics (CCRMA), Stanford University. https://ccrma.stanford.edu/~jos/resample/. The same, worked through at length.
  • Kaiser, J., and R. Schafer. "On the Use of the I0-Sinh Window for Spectrum Analysis." IEEE Transactions on Acoustics, Speech, and Signal Processing 28, no. 1 (February 1980): 105–107. https://doi.org/10.1109/TASSP.1980.1163349. The window, and how its β sets how far down its sidelobes are.