Static libopus 1.6.1, built once with cmake so that nothing which links it needs
cmake at all — plus a Rust crate that replaces opus + audiopus_sys and links it.
| target | library | CPU floor |
|---|---|---|
macos-arm64 |
libopus.a |
apple-m1, deployment target 11.0 |
linux-x86_64 |
libopus.a |
x86-64 baseline — SSE4.1/AVX2 kernels dispatched by CPUID |
linux-aarch64 |
libopus.a |
ARMv8-A, NEON unconditional |
windows-x86_64-msvc |
opus.lib |
x86-64 baseline, dispatched the same way; dynamic CRT |
The arm archives call NEON unconditionally, because ARMv8-A guarantees it. The x86_64 archives keep opus's runtime CPUID dispatch, so one archive runs on any x86-64 and still uses the AVX2 kernels where the CPU has them.
One line, and no source changes:
# was: opus = "0.3"
opus = { package = "opus-prebuilt", git = "https://github.com/andrewtheguy/libopus-prebuilt", tag = "v1.6.1-20260731035112-731adf1" }(An example — use a tag that exists. See Releasing below for what the parts mean. The tag pins the crate; the archives always come from the repository's latest release.)
opus-prebuilt sets [lib] name = "opus", so every use opus::… and
opus::Decoder::new keeps compiling untouched. No cmake, no C compiler, no pkg-config,
no LIBOPUS_* environment variables anywhere — not in a Dockerfile, not in a packaging
script, not in CI.
Two crates are involved:
| crate | what it is |
|---|---|
libopus-prebuilt-sys |
the FFI, and a build script that finds the right archive and emits two link flags |
opus-prebuilt |
opus 0.3.1, verbatim, with its extern crate audiopus_sys line pointing at the above |
opus-e2e |
not a library: a consumer, written with the same dependency line you would use, built and run by the pipeline on all four targets |
Keeping the safe wrapper byte-identical to upstream is deliberate: the consuming projects
are written against its semantics, and this repository should have no opinion about
them. A future opus release is re-forked by copying its lib.rs and changing that one
line again.
The x86_64 archives have no floor above the x86-64 baseline. opus compiles its SSE4.1 and
AVX2 kernels per file with that file's own flags and selects them through
opus_select_arch() at run time (OPUS_X86_MAY_HAVE_*, the upstream default), so a
Coffee Lake runs the AVX2 NSQ and pitch kernels and an Ivy Bridge runs the SSE4.1 ones.
build.sh asserts both halves of that: the dispatcher is in the archive, and no AVX2 or
FMA instruction exists outside a function named *_avx2.
What a -march=x86-64-v3 floor added on top was measured before it was dropped: the
scalar code autovectorized, and 60 s of 48 kHz stereo encoded in 0.65% of a core instead
of 0.73%. That is the whole price of one archive that runs everywhere, and the consuming
projects pay it rather than build, test and release a second one.
macOS needs no such choice. Every arm64 Mac is an M1 or later, so naming the M1 as the floor costs no compatibility at all and buys ARMv8.4 with dotprod and fp16 over the generic ARMv8-A the compiler assumes otherwise. NEON is unconditional there either way — opus's cmake presumes it for any aarch64 target.
Two things this does not do, both on purpose: -march=native (the artifact runs on
machines other than the builder) and -ffast-math / OPUS_FLOAT_APPROX (it changes
libopus's arithmetic, and a project with checked-in Opus fixtures would notice).
build.rs looks in three places, in order:
LIBOPUS_PREBUILT_DIR— a prefix containinglib/, used as-is. The escape hatch for an unsupported target, and the way to build with no network whatsoever.crates/libopus-prebuilt-sys/prebuilt/<target>/— what./build.sh+./sync-prebuilt.shleave behind locally. Gitignored.- the repository's latest GitHub release, downloaded into
$CARGO_HOME/libopus-prebuilt/<release tag>/— one directory per release.
(3) is what makes a fresh clone of a consuming project build with nothing installed. The
cache living under CARGO_HOME means the many Docker builds that already cache ~/.cargo
get it for free. It is keyed by the release tag and not by the libopus version, because
latest moves: two releases of the same libopus carry different archives, so a directory
named after the version alone would answer for whichever release this machine downloaded
first, forever — and the symptom is a feature quietly missing rather than a failure. Asking
which release latest is costs one redirect with no body; where it cannot be asked at all
(CARGO_NET_OFFLINE, or a network that does not answer) the newest cached release is used
and the provenance line says it was not revalidated. To confirm which one was used:
cargo build -vv 2>&1 | grep 'cargo:info=libopus'
# cargo:info=libopus 1.6.1 linked statically from prebuilt/macos-arm64 (aarch64-apple-darwin)
# cargo:info=libopus cpu_floor apple-m1 (armv8.4, NEON unconditional)The archives are also consumable the old way — unpack one and point audiopus_sys at the
prefix — which is what a project not yet migrated is already doing:
export LIBOPUS_LIB_DIR=/path/to/libopus-1.6.1-macos-arm64
export LIBOPUS_STATIC=1
export LIBOPUS_NO_PKG=1
cargo build --releaseAll three matter, and each for its own reason:
| variable | why |
|---|---|
LIBOPUS_LIB_DIR |
the prefix, not the lib/ directory — audiopus_sys appends lib itself (build.rs:62) |
LIBOPUS_STATIC |
without it, default_library_linking() links dynamically on gnu targets, and the binary then wants a libopus.so.0 that a slim runtime image does not ship |
LIBOPUS_NO_PKG |
stops pkg-config finding a system libopus first — a Homebrew libopus.dylib linked into something you ship fails on every machine without Homebrew |
OPUS_LIB_DIR / OPUS_STATIC / OPUS_NO_PKG are accepted as aliases by the same build
script. Note what this route still requires and the crate route does not: three variables
set correctly in every Dockerfile, packaging script and CI job, and audiopus_sys present
in the dependency tree — which is cmake in the tree, whether or not it gets used.
wav-demo/ is a standalone project that takes the crate from git by tag, the
way a real consumer does, and encodes a WAV to playable .opus files at several bitrates —
plus the decode and the difference between them, so you can hear what the codec discarded.
It is a sample and nothing in CI builds it; its pinned tag is deliberately left to go stale.
cd wav-demo && cargo run --release # synthesises a clip if you give it no inputEvery Rust project using opus pulls audiopus_sys, which builds a vendored copy of
opus with cmake on every clean build. Three consequences, all of which showed up in
practice:
- It breaks. That vendored opus declares
cmake_minimum_requiredbelow 3.5, and CMake 4 — which the GitHub macOS runners now ship — refuses to configure it outright. Every project has to know to passCMAKE_POLICY_VERSION_MINIMUM=3.5, and finds out when a release fails rather than when it is written, because a developer machine on CMake 3 builds happily. - It is stale.
audiopus_sys0.2.2 is edition 2018 and its vendored opus is 1.3-era. Nothing about that improves while the crate is unmaintained, and a project cannot reach a newer opus without doing this. - It is duplicated. Each project re-derives the same environment variables, and each runner recompiles the same C.
Building it here once fixes all three, and adds two things none of the per-project builds had: a named CPU floor, and SIMD paths that are asserted rather than assumed.
build.sh refuses to package an archive that fails either check:
>> verifying the SIMD objects made it in
17 neon symbol references
>> verifying the CPU floor
133 AVX2/FMA instructions, all inside _avx2 kernels
floor: x86-64 baseline (SSE4.1 and AVX2 kernels dispatched at run time)
- The SIMD paths. opus's cmake detects the architecture, compiles the intrinsics it
finds support for, and wires up runtime dispatch. Reproducing that by hand is how you
end up silently shipping the scalar fallbacks, and "audio is slower than it should be"
is about the hardest regression there is to notice. This is also why the repo builds
with cmake rather than a hand-rolled
ccscript. Checked withnmon Unix, and with the compiled object file names on Windows, wherenmis not on the runner's PATH. - The CPU floor. A library that quietly kept its dispatch is merely slower; one that quietly lost it executes an illegal instruction in the field, on whoever runs the oldest machine. So the claim in the MANIFEST is checked against the CMake cache and, where a disassembler can read the format, against the instructions themselves.
The Rust half is checked too, in three layers, because the risk there is not in the safe wrapper — that is upstream's code, covered by upstream's tests — but underneath it:
src/consts.rsis generated from the pinned headers bygen-consts.sh, since every one of those ~90 values is a bare integer where a typo is not a compile error but a CTL that configures a different parameter than the one named. (Cross-checked once againstaudiopus_sys's bindings: 73 names in common, zero disagreements.)./sync-prebuilt.sh --checkproves the committed headers are byte-identical to the pinned tarball's and thatconsts.rsmatches them. CI runs it before anything compiles.crates/opus-prebuilt/tests/prebuilt.rssets and reads back every CTL the consuming projects touch, and encodes and decodes a sine wave. A wrong constant fails a test instead of producing bad audio months later.crates/opus-e2eis a binary that depends on the crate the way a project does, rename and all, and the pipeline builds and runs it on every target. Three things only a binary can show: that the archive links into a shippable executable in release mode, that the CPU floor is real — it runs on the runner's own processor, so an AVX2 archive that cannot execute is a failed pipeline rather than a support ticket — and that Windows works, which nothing else here can check. It exercises 48/16/8 kHz and mono/stereo, which is what moves libopus between its SILK, CELT and hybrid paths, and those paths are where the hand-written SIMD kernels live.- the archive is reproducible, and CI asserts it: a second runner rebuilds the same
target and the two
libopus.amust hash identically, against each other and against thesha256(library)the first one recorded. A library that changed without anybody changing it is the thing that would otherwise be discovered much later. ./check-static.sh <binary>then asks the question the name of this repository implies: is libopus in the binary (its version string is), and is there a dynamic dependency on one as well or instead (there must not be). That second one passes every test on the build machine and fails on a slim runtime image, which is the exact failure this repo exists to remove.
Two questions, and only one of them has an exact answer.
The exact one. OPUS_GET_FINAL_RANGE returns the entropy coder's end state, and if the
decoder's matches the encoder's for a packet then the decoder walked an identical path
through that packet's range coder. libopus's own test suite uses it the same way. It is a
much stronger claim than hashing a bitstream — and unlike a hash it is valid across
platforms, since the AVX2 and NEON archives take different float paths and would produce
different bytes for reasons that are not bugs. opus-e2e asserts it for all 800 packets it
encodes.
The approximate one, because the codec is lossy on purpose. Four signal classes — tone, chord, band-limited noise, speech-like — over 48 kHz stereo and mono, 16 kHz and 8 kHz:
| measure | asserted | why |
|---|---|---|
| per-band energy error | ≤ 6 dB | phase-blind, so it survives what the other two trip over. The gate. |
| correlation | per signal class | catches waveform damage that band energy would not |
| SNR | reported only | collapses on noise by design — CELT preserves band energy there, not the waveform |
The correlation floor travels with the signal (0.99 tone, 0.95 chord and speech, 0.60 noise) because one global number cannot work: a healthy encoder round-trips noise at 0.70 at 8 kHz, and any threshold strict enough to be meaningful for a chord would fail every clean build. Worst measured band error across all sixteen combinations is 4.1 dB against the 6 dB gate.
Two things learned by measuring rather than reasoning, both of which broke the first version of these tests:
- Alignment cannot be a fixed offset.
get_lookahead()is exact — the aperiodic noise signal locks onto it off-by-zero at every rate, which is itself asserted — but periodic content aligns equally well a whole period late, and the speech-like signal genuinely lands 16 samples early at 16 kHz. Forcing the reported value dropped its correlation to 0.7180 and failed six checks against a perfectly healthy encoder. - A search window has to leave room for the largest lag it tries. At 8 kHz a one-second signal is 8000 samples, and a fixed 8192-sample window left room for exactly one candidate. Every metric downstream then compared signals 52 samples apart and reported a broken codec.
Granting that upstream libopus does not leak, the only way this layer can is a *_create
with no matching destroy. That set is small and closed — five allocating entry points in
the FFI, five Drop impls — so the checks are about keeping the pairing intact:
| where | tool | covers |
|---|---|---|
opus-e2e, every target |
RSS after 12,000 create/drop cycles | Windows, which has neither of the below |
./test-docker.sh |
valgrind --leak-check=full |
Linux, exactly, including leaks inside libopus |
CI leaks job |
leaks --atExit |
macOS |
Each one is paired with a control that leaks on purpose, and the control going unnoticed
is itself a failing check. That is not ceremony. libopus allocates with C malloc, so
the obvious Rust approach — a counting GlobalAlloc — sees nothing it does and would
report a clean run however badly the handles leaked. A leak checker that cannot see the
allocations is indistinguishable from one that found none, and the only way to tell them
apart is to hand it something that definitely leaks. opus-e2e --leak-only exists for
exactly that and has no other purpose.
Measured separation, so the thresholds are not guesses: leaking 1200 handles moves RSS by about 30 MB on both macOS and Linux, while 12,000 created and dropped move it by 48 KB and 0 KB respectively. The check trips at 4 MB, roughly a hundred times the honest noise and a seventh of the smallest real leak.
OPUS_E2E_MEMORY=off skips the RSS section, and both external tools set it — partly
because twelve thousand encoder states under valgrind is unbearably slow, and partly
because that section's own control would otherwise show up as 31 MB of leaked memory in
their reports, which is true and also entirely deliberate.
./build.sh macos-arm64 # or any target from the table
./sync-prebuilt.sh # make cargo link what you just built
cargo test --offline --workspace
cargo run --offline --release -p opus-e2e # 35 checks, and the RMS of each round trip
./check-static.sh target/release/opus-e2e./build.sh needs cmake and a C toolchain; nothing downstream of it does. The source
tarball is fetched once into build/ and verified against the SHA-256 in opus.env
before it is unpacked (source.sh), so bytes that are not the pinned release never
reach a compiler. Output lands in dist/<target>/ with a MANIFEST recording the
version, the checksum, the CPU floor, the flags and the SIMD evidence.
Linux is tested in Docker:
./test-docker.sh # every Linux target this machine can run
./test-docker.sh linux-x86_64On Apple silicon linux/arm64 runs natively and linux/amd64 is emulated. If an
emulated run dies with SIGILL on the AVX2 archive, suspect the emulator before the
artifact — Rosetta does not implement AVX2, QEMU does.
Windows cannot be built or tested anywhere else here, so the workflow's manual trigger is its test loop:
gh workflow run build.yml -f targets=windows # or: all, x86_64, arm64
gh run watchThat builds both Windows archives, links the crates against them, runs the test suite, and
then builds and runs opus-e2e on the runner — which is the only place opus_encode and
opus_decode from an MSVC opus.lib are ever actually executed.
| workflow | trigger | what it does |
|---|---|---|
ci.yml |
every push and PR | shellcheck; parses every script under macOS's stock bash 3.2; builds and tests linux-x86_64 by calling build.yml; and builds as a consumer does, with an empty prebuilt/, to exercise the download-and-verify path nothing else takes |
build.yml |
manual, or called | the four-target matrix: build, test, e2e binary, check-static.sh. Cannot publish — its token is read-only |
release.yml |
manual | calls build.yml for all four, then packages, uploads and publishes draft-first |
The split is about what changes commit to commit. The archives change only when opus.env
or build.sh does, so rebuilding four of them on every push would be four runners proving a
pinned tarball is still pinned. The crates and the scripts change constantly — and for a
while nothing checked them unless somebody remembered to press a button, which is how a
bash 3.2 incompatibility reached a commit.
Not opus versions — the pin only moves when you move it. These:
- macOS: built with a deployment target of 11.0, lower than anything likely to link it. A static library built for a newer minimum than its consumer produces link warnings now and confusion later. arm64 only: there is no Intel Mac artifact.
- Linux: built against glibc. libopus barely touches libc so this generally links into musl binaries too, but a fully static musl build wants a musl-built copy.
- Windows: built with the dynamic CRT (
/MD), which is what Rust's MSVC targets use. A/MTlibrary against a/MDbinary is the classic mismatched-CRT link error. - PIC is on everywhere, so the archive links into shared objects as well as executables.
Two workflows, deliberately not one. Build libopus builds and tests and never publishes; Release libopus archives publishes, and does the building by calling the first one — so what gets released is what passed, run by the same code rather than a copy of it.
gh workflow run release.yml # or the Actions tab. No tag to type, nothing to commit after.
Tags are computed, never typed: v<opus version>-<YYYYMMDDHHMMSS>-<short sha>, e.g.
v1.6.1-20260731035112-731adf1. The version says what is inside, the UTC timestamp says
when, and the hash says which commit — and since the workflow derives all three from the
tree it is building, none of them can disagree with it. That is also why there is nothing
to validate: the failure mode of a hand-typed tag is a tag that lies, and the way to fix it
is to stop typing tags.
The timestamp runs to seconds so that tags sort into the order they were released.
Dated to the day, two releases on one day were distinguished only by a commit hash, which
has no order at all, so they listed arbitrarily. Releases before this change carry the
older -<YYYYMMDD>- form and sort before everything after it, which is correct by
accident: a shorter numeric field compares as smaller.
The order is draft first, publish last, and the reason is worth knowing: a draft release does not create the git tag. Four builds, their tests, the e2e binaries, packaging and upload all happen while the tag still does not exist, and it comes into being only when the draft is published as the final step. A release that fails half way leaves a draft somebody can delete rather than a tag pointing at archives nobody should link. A run from any branch but the default one is marked pre-release.
Assets are the four archives, each carrying a MANIFEST with sha256(library) — the hash of
the .a or .lib itself, which unlike the tarball is reproducible. The release refuses to
publish if any of the four is missing, because a release short an archive is discovered
later, by somebody else, on the platform nobody tested.
Publishing is the whole of releasing. build.rs fetches from releases/latest, so
there is no pin to update and no follow-up commit — which is the point: a pin naming a tag
that encodes a commit can only ever be committed after that commit, leaving main
permanently one commit ahead of whatever was released.
Edit opus.env — the version and its checksum, which is the only place either lives:
curl -sSLO https://downloads.xiph.org/releases/opus/opus-<version>.tar.gz
shasum -a 256 opus-<version>.tar.gz
./sync-prebuilt.sh --headers # re-extract the headers, regenerate consts.rsThen tag. One thing worth knowing before a bump: opus keeps its bitstream and its ABI stable, and 1.6.1 was verified against a consumer whose checked-in Opus fixtures were generated by the 1.3-era vendored copy — they still matched byte for byte. That is a good sign, not a guarantee; a project with pinned fixtures should re-run its own encoder tests.
The library is reproducible: the same source and the same flags produce a
byte-identical libopus.a, verified on every push by rebuilding a target on a second runner
and requiring the two to hash the same. Each archive records its own sha256(library) in
its MANIFEST, so that hash is comparable across runs, machines and releases — if it is the
same, it is the same library.
The .tar.gz around it is a different matter and cannot be reproducible: gzip stamps an
mtime into its header, so packing identical bytes twice gives two different files. A
checksum of one can only ever say these are the bytes that were published — useful for
catching a bad download, and nothing more. Three questions, and only one of them needs a
checksum this repository maintains:
| question | answered by | checked by |
|---|---|---|
| is the source the pinned upstream release? | OPUS_SHA256 in opus.env |
build.sh, before anything is unpacked |
| did the download arrive intact? | SHA256SUMS, a release asset |
build.rs and sync-prebuilt.sh --fetch |
| is the archive on disk still what was built? | sha256(library) in each MANIFEST |
build.rs, on every resolution path |
| is this the same library as last time? | the same sha256(library) |
CI's reproducible job, across two runners |
Only the first is a supply-chain check. opus.env pins a third party's tarball, which
is the one link in the chain this repository does not control, and it is the one value kept
by hand. The other two are corruption checks: SHA256SUMS is published on the same release
as the archives it covers and the MANIFEST rides inside the archive it describes, so
whoever could replace one could replace both. They are worth having because corruption is
what actually happens — a truncated download, a cache half-written by a killed build, an
.a overwritten by hand — and each of those otherwise surfaces as a page of undefined
symbols rather than one sentence naming the file.
Neither is committed. SHA256SUMS is generated by the release job from the archives it
just built, which is what a checked-in file of our own hashes could never be: correct
without anybody remembering to regenerate it. And both are plain GETs from the URL
build.rs already uses — GitHub does publish a per-asset digest, but only through
api.github.com, which is a second request, rate-limited to 60/hour unauthenticated, and
JSON for a build script that has no dependencies to parse it with.
To check a download by hand:
curl -sSLO https://github.com/andrewtheguy/libopus-prebuilt/releases/latest/download/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missingFor a project with pinned Opus fixtures, the honest answer is to run its own encoder tests
after switching. This repository used to publish an ENCODER-DIGESTS file intended to
answer that centrally; it was removed, because its first implementation generated its input
with f64::sin — which on Linux is glibc's, IFUNC-dispatched per CPU — and so reported
differences that were the measurement moving rather than the library. The archives were
byte-identical throughout.
- libopus itself: BSD-3-Clause, Xiph.Org. The pinned tarball's
COPYINGapplies to the archives and to the committed headers. crates/opus-prebuilt/src/lib.rs: MIT OR Apache-2.0, © 2016 Tad Hardesty — theopuscrate, with one line changed. Both licence texts are in that directory.- Everything else here: same terms as libopus.
audiopus_sys itself is not vendored; the FFI in libopus-prebuilt-sys is written from
the opus headers. Its constant values were compared against audiopus_sys's bindings once
as a cross-check, which is a fact about testing rather than a derivation.