Skip to content

About

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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

libopus-prebuilt

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.

Using it from Rust

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.

CPU floors

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).

Where the archive comes from

build.rs looks in three places, in order:

  1. LIBOPUS_PREBUILT_DIR — a prefix containing lib/, used as-is. The escape hatch for an unsupported target, and the way to build with no network whatsoever.
  2. crates/libopus-prebuilt-sys/prebuilt/<target>/ — what ./build.sh + ./sync-prebuilt.sh leave behind locally. Gitignored.
  3. 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)

Still on opus = "0.3"

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 --release

All 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.

Something to listen to

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 input

Why this exists

Every 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_required below 3.5, and CMake 4 — which the GitHub macOS runners now ship — refuses to configure it outright. Every project has to know to pass CMAKE_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_sys 0.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.

What is verified, and why each check exists

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 cc script. Checked with nm on Unix, and with the compiled object file names on Windows, where nm is 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.rs is generated from the pinned headers by gen-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 against audiopus_sys's bindings: 73 names in common, zero disagreements.)
  • ./sync-prebuilt.sh --check proves the committed headers are byte-identical to the pinned tarball's and that consts.rs matches them. CI runs it before anything compiles.
  • crates/opus-prebuilt/tests/prebuilt.rs sets 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-e2e is 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.a must hash identically, against each other and against the sha256(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.

Does the audio survive?

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.

Leaks

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.

Building and testing locally

./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_64

On 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 watch

That 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.

Which workflow does what

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.

ABI notes, which are the real maintenance cost

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 /MT library against a /MD binary is the classic mismatched-CRT link error.
  • PIC is on everywhere, so the archive links into shared objects as well as executables.

Releasing

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.

Bumping opus

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.rs

Then 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-missing

For 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.

Licensing

  • libopus itself: BSD-3-Clause, Xiph.Org. The pinned tarball's COPYING applies to the archives and to the committed headers.
  • crates/opus-prebuilt/src/lib.rs: MIT OR Apache-2.0, © 2016 Tad Hardesty — the opus crate, 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.

About

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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages