nix, opus: let the packager choose where libopus comes from

`opus` conflated two axes: decoding Ogg-Opus, and vendoring the C library
to do it. So the only way to avoid a cmake build of libopus was to give up
Opus playback -- the wrong trade for Nix, which already ships one.

The adapter crate draws the line already: symphonia-adapter-libopus own
`bundled` feature is what pulls opusic-sys/bundled and with it cmake. So
declare the dependency `default-features = false` and add
`opus-bundled = ["symphonia-adapter-libopus?/bundled"]`, forwarded up
through crabidy-server and cbd. It stays in every `default`, so a plain
cargo build still needs nothing installed; opting out is the packager act.
The weak `?/` is load-bearing -- a plain `/bundled` would enable the
optional dependency itself, and `opus-bundled` would quietly become a
second "do we decode Opus" flag.

The flake native build then drops cmake and takes libopus from nixpkgs;
`headlessFeatures` already omitted opus-bundled, so it opts out for free.
The cross build keeps the vendored copy: it links statically, and an
unbundled -lopus would need a static aarch64 libopus staged for the target
the way alsa-lib is.

That exposed an older bug. rustc stamps no RUNPATH, and the -L from
buildInputs arrives through NIX_LDFLAGS, which ld-wrapper does not mirror
into the binary -- so the package linked cleanly and then refused to start.
RUNPATH was empty, meaning libasound.so.2 had never resolved either: the
package always depended on the caller having it on LD_LIBRARY_PATH, which
this repo dev shell happens to set. autoPatchelfHook now fills the RUNPATH
from buildInputs (plus stdenv.cc.cc for libgcc_s, the compiler own
unwinding runtime, which no crate declares) and fails the build on anything
it cannot resolve. Verified by running each binary under `env -i`, and with
LD_BIND_NOW=1 so every opus symbol binds eagerly.

Also: devenv sets OPUS_LIB_DIR, without which the unbundled build dies as
"mold: fatal: library not found: opus"; check-features *builds* the two
libopus variants rather than clippy-ing them, since clippy links nothing and
cannot tell a resolvable -lopus from a missing one; and cbd was missing an
`rss` pass-through, so the bundle could not select that provider alone.

Requested alongside this: the spectrum shadows fall over 10 seconds instead
of 4. The config default and SpectrumStyle::default are two spellings of
one thing, so the config test now asserts the whole resolved style equals
SpectrumStyle::default() rather than field-by-field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-28 00:23:29 +02:00
parent 87b98193d8
commit 621823d930
14 changed files with 266 additions and 50 deletions

View File

@ -73,9 +73,11 @@ serde_json = "1"
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
# Opus decoding: symphonia has no Opus decoder, so we demux Ogg-Opus with # Opus decoding: symphonia has no Opus decoder, so we demux Ogg-Opus with
# symphonia's Ogg reader and decode via the libopus adapter (matches # symphonia's Ogg reader and decode via the libopus adapter (matches
# rodio's symphonia 0.5). The adapter bundles libopus (needs cmake). # rodio's symphonia 0.5). default-features = false drops the adapter's own
# `bundled`, so audio-player's `opus-bundled` decides whether libopus is
# compiled from source (cmake) or linked from the system.
symphonia = { version = "0.5", default-features = false, features = ["ogg"] } symphonia = { version = "0.5", default-features = false, features = ["ogg"] }
symphonia-adapter-libopus = "0.2" symphonia-adapter-libopus = { version = "0.2", default-features = false }
stream-download = { version = "0.24", default-features = false, features = [ stream-download = { version = "0.24", default-features = false, features = [
"reqwest", "reqwest",
"reqwest-rustls", "reqwest-rustls",

View File

@ -60,7 +60,8 @@ another terminal, `cargo run -p cbd-tui`.
Every provider — plus Opus decoding, the spectrum bars, the embedded web Every provider — plus Opus decoding, the spectrum bars, the embedded web
UI, and the TUI's desktop notifications — sits behind a Cargo feature, UI, and the TUI's desktop notifications — sits behind a Cargo feature,
all on by default. Drop what you do not need and the dependencies go with all on by default. Drop what you do not need and the dependencies go with
it: it (`opus-bundled` is the one to drop if you would rather link the system
libopus than compile the vendored copy):
```sh ```sh
# a local-files appliance: no network providers, no web UI, no FFT # a local-files appliance: no network providers, no web UI, no FFT
@ -146,7 +147,7 @@ spectrum_gradient = true # shade them by height
spectrum_top_color = "#b48ead" # what the shading reaches at the top spectrum_top_color = "#b48ead" # what the shading reaches at the top
spectrum_peak_color = "#81a1c1" # peak-hold shadows; "none" draws none spectrum_peak_color = "#81a1c1" # peak-hold shadows; "none" draws none
spectrum_peak_fill = true # false: a thin rule at the peak instead spectrum_peak_fill = true # false: a thin rule at the peak instead
spectrum_peak_fall = 4.0 # seconds for a full-scale shadow to fall spectrum_peak_fall = 10.0 # seconds for a full-scale shadow to fall
spectrum_bar_width = 1 # least bar width, in cells spectrum_bar_width = 1 # least bar width, in cells
spectrum_bar_gap = 1 # seam dividing two bars, in cells spectrum_bar_gap = 1 # seam dividing two bars, in cells
spectrum_row_gap = 1 # line dividing two rows, in eighths spectrum_row_gap = 1 # line dividing two rows, in eighths
@ -439,7 +440,13 @@ nix build .#crabidy-server-aarch64
scp ./result/bin/crabidy-server pi:/usr/local/bin/ scp ./result/bin/crabidy-server pi:/usr/local/bin/
``` ```
Nix cross-compiles the Rust *and* the C dependencies (ALSA, aws-lc) The native package links the libopus that nixpkgs ships (`opus` without
`opus-bundled`, see
[docs/src/build-features.md](docs/src/build-features.md)), so nothing is
compiled from vendored C and the binaries carry a RUNPATH — they run from a
bare login shell, not only from this repo's dev shell.
Nix cross-compiles the Rust *and* the C dependencies (ALSA, libopus, aws-lc)
hermetically on an x86_64 host — the isolated build avoids the host-linker hermetically on an x86_64 host — the isolated build avoids the host-linker
pitfalls of cross-compiling in a plain shell. The aarch64 server **includes pitfalls of cross-compiling in a plain shell. The aarch64 server **includes
the embedded web UI**: the flake builds the `cbd-web` wasm bundle with trunk the embedded web UI**: the flake builds the `cbd-web` wasm bundle with trunk

View File

@ -246,6 +246,56 @@ combinations, and `-D warnings` in the pre-commit hook. A curated matrix
gives the coverage that matters; `cargo hack --each-feature` is gives the coverage that matters; `cargo hack --each-feature` is
available in devenv for a deeper sweep when the flags change. available in devenv for a deeper sweep when the flags change.
**D12 — `opus-bundled` splits *decoding Opus* from *vendoring libopus*
(added 2026-07-28).** D6 treated the two as one axis, so the only way to
avoid the vendored C build was to give up Opus playback. That is the
wrong trade for a packaged build: Nix, and any distribution, would rather
link the libopus it already ships than run cmake inside the sandbox.
The adapter crate already draws this line — `symphonia-adapter-libopus`'s
`bundled` feature is what pulls `opusic-sys/bundled` and with it the
`cmake` crate — so the split costs one feature and no code:
```toml
# audio-player
default = ["opus", "opus-bundled"]
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
opus-bundled = ["symphonia-adapter-libopus?/bundled"]
```
with the workspace dependency declared `default-features = false` and
pass-throughs up through `crabidy-server` and `cbd`. Two details are
load-bearing:
- The **weak** `?/` in `opus-bundled`. A plain `symphonia-adapter-libopus/bundled`
would enable the optional dependency itself, so `opus-bundled` alone
would silently switch Opus decoding back on — turning a "where does the
library come from" flag into a second "do we decode Opus" flag.
- `opus-bundled` stays in `default`, so a plain `cargo build` still needs
nothing installed. Opting *out* is the packager's explicit act, not the
developer's default.
Consequences for the Nix builds (D10): the native package drops `cmake`,
takes `libopus` as a `buildInput`, and leaves `opus-bundled` out of its
feature list. The aarch64 cross build keeps the vendored copy — it links
statically, and an unbundled `-lopus` would need a static aarch64 libopus
staged for the target the way `alsa-lib` is. `devenv.nix` sets
`OPUS_LIB_DIR` so the unbundled path links in the dev shell too, where the
linker knows nothing of the store.
**D13 — the native Nix package sets its own RUNPATH (added
2026-07-28).** Uncovered by D12: rustc stamps no RUNPATH, and the `-L`
that `buildInputs` contributes reaches the linker through `NIX_LDFLAGS`,
which ld-wrapper does not mirror into the binary. The installed binaries
therefore linked cleanly and then refused to *start* wherever the
libraries were not already on `LD_LIBRARY_PATH` — a latent bug for
`libasound` since the package was first written, which the added
`libopus` only made louder. `autoPatchelfHook` on the final derivation
fills the RUNPATH from `buildInputs` (plus `stdenv.cc.cc`'s `libgcc_s`,
which is the compiler's own runtime and so declared by no crate) and
fails the build on anything it cannot resolve. Verified by running each
binary under `env -i`.
## Structure ## Structure
```d2 ```d2

View File

@ -4,14 +4,21 @@ version.workspace = true
edition.workspace = true edition.workspace = true
# The one axis worth a flag: Ogg-Opus decoding pulls symphonia *and* # The one axis worth a flag: Ogg-Opus decoding pulls symphonia *and*
# `symphonia-adapter-libopus`, which bundles libopus and so makes cmake + # `symphonia-adapter-libopus`. Everything else in this crate (HLS, the
# ninja build requirements. Everything else in this crate (HLS, the
# windowed-HTTP source, the spectrum tap) shares its dependencies with # windowed-HTTP source, the spectrum tap) shares its dependencies with
# code that always ships, so gating it would buy nothing # code that always ships, so gating it would buy nothing
# (architecture/build-features.md D6/D7/D8). # (architecture/build-features.md D6/D7/D8).
[features] [features]
default = ["opus"] default = ["opus", "opus-bundled"]
opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"] opus = ["dep:symphonia", "dep:symphonia-adapter-libopus"]
# Where libopus comes from (architecture/build-features.md D12). On (the
# default) compiles it from the vendored source, which needs cmake and a C
# compiler but nothing installed. Off links the system libopus instead, so a
# packaged build can use the distribution's copy: `--no-default-features
# --features opus`. The weak `?/` is load-bearing — it must not pull the
# optional adapter in by itself, or `opus-bundled` would silently re-enable
# `opus`.
opus-bundled = ["symphonia-adapter-libopus?/bundled"]
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true

View File

@ -77,7 +77,7 @@ impl Default for SpectrumStyle {
gradient: true, gradient: true,
peak: Some(COLOR_PRIMARY), peak: Some(COLOR_PRIMARY),
peak_fill: true, peak_fill: true,
peak_fall: 4.0, peak_fall: 10.0,
bar_width: 1, bar_width: 1,
bar_gap: 1, bar_gap: 1,
row_gap: 1, row_gap: 1,
@ -816,7 +816,7 @@ mod tests {
(1, 1, 1), (1, 1, 1),
"connected bars, a seam between them, a line between rows" "connected bars, a seam between them, a line between rows"
); );
assert_eq!(style.peak_fall, 4.0); assert_eq!(style.peak_fall, 10.0, "shadows linger, they do not chase");
} }
#[test] #[test]

View File

@ -215,7 +215,7 @@ pub struct ServerConfig {
/// Seconds a full-scale shadow takes to fall to the floor, 0.05-60 /// Seconds a full-scale shadow takes to fall to the floor, 0.05-60
/// (out of range is clamped with a warning). Higher lingers longer. /// (out of range is clamped with a warning). Higher lingers longer.
#[default(4.0)] #[default(10.0)]
#[clap(long)] #[clap(long)]
pub spectrum_peak_fall: f32, pub spectrum_peak_fall: f32,
@ -468,7 +468,12 @@ mod tests {
assert_eq!(style.bar_gap, 1, "a one-cell seam"); assert_eq!(style.bar_gap, 1, "a one-cell seam");
assert_eq!(style.row_gap, 1, "an eighth of a row between segments"); assert_eq!(style.row_gap, 1, "an eighth of a row between segments");
assert!(style.peak_fill, "shadows filled"); assert!(style.peak_fill, "shadows filled");
assert_eq!(style.peak_fall, 4.0); assert_eq!(style.peak_fall, 10.0, "ten seconds to the floor");
assert_eq!(
style,
crate::app::SpectrumStyle::default(),
"the config defaults and the renderer's own must agree"
);
} }
#[test] #[test]

View File

@ -7,7 +7,14 @@ edition.workspace = true
# (architecture/build-features.md D1). `cargo build -p cbd # (architecture/build-features.md D1). `cargo build -p cbd
# --no-default-features --features fs,opus` is a local-files-only bundle. # --no-default-features --features fs,opus` is a local-files-only bundle.
[features] [features]
default = ["all-providers", "opus", "spectrum", "web-ui", "notifications"] default = [
"all-providers",
"opus",
"opus-bundled",
"spectrum",
"web-ui",
"notifications",
]
all-providers = ["crabidy-server/all-providers"] all-providers = ["crabidy-server/all-providers"]
tidal = ["crabidy-server/tidal"] tidal = ["crabidy-server/tidal"]
@ -16,8 +23,10 @@ fyyd = ["crabidy-server/fyyd"]
abs = ["crabidy-server/abs"] abs = ["crabidy-server/abs"]
soundcloud = ["crabidy-server/soundcloud"] soundcloud = ["crabidy-server/soundcloud"]
jamendo = ["crabidy-server/jamendo"] jamendo = ["crabidy-server/jamendo"]
rss = ["crabidy-server/rss"]
fs = ["crabidy-server/fs"] fs = ["crabidy-server/fs"]
opus = ["crabidy-server/opus"] opus = ["crabidy-server/opus"]
opus-bundled = ["crabidy-server/opus-bundled"]
spectrum = ["crabidy-server/spectrum"] spectrum = ["crabidy-server/spectrum"]
web-ui = ["crabidy-server/web-ui"] web-ui = ["crabidy-server/web-ui"]
notifications = ["cbd-tui/notifications"] notifications = ["cbd-tui/notifications"]

View File

@ -13,7 +13,7 @@ path = "src/main.rs"
# names in `crabidy-server.toml`'s `providers` list (D3), so one # names in `crabidy-server.toml`'s `providers` list (D3), so one
# vocabulary covers the compile-time and the runtime switch. # vocabulary covers the compile-time and the runtime switch.
[features] [features]
default = ["all-providers", "opus", "spectrum", "web-ui"] default = ["all-providers", "opus", "opus-bundled", "spectrum", "web-ui"]
# Every provider this binary can mount. # Every provider this binary can mount.
all-providers = [ all-providers = [
@ -44,9 +44,13 @@ rss = ["dep:rssdy", "_any-provider"]
# persistence, and the `scan` command. Off means the server keeps its # persistence, and the `scan` command. Off means the server keeps its
# queue in memory only. # queue in memory only.
fs = ["dep:fsdy", "dep:blake3", "dep:reqwest", "_any-provider"] fs = ["dep:fsdy", "dep:blake3", "dep:reqwest", "_any-provider"]
# Ogg-Opus decoding. Off also drops the bundled libopus C build # Ogg-Opus decoding. Off also drops the libopus dependency entirely and
# (cmake + ninja) and `.opus` from what `scan` indexes (D6). # `.opus` from what `scan` indexes (D6).
opus = ["audio-player/opus"] opus = ["audio-player/opus"]
# Compile the vendored libopus (needs cmake) instead of linking the system
# one. On by default so a plain `cargo build` needs nothing installed; a
# distribution build drops it and links libopus from the system (D12).
opus-bundled = ["audio-player/opus-bundled"]
# The server-side FFT that feeds clients' spectrum bars (D8). # The server-side FFT that feeds clients' spectrum bars (D8).
spectrum = ["dep:realfft"] spectrum = ["dep:realfft"]
# The embedded web client (architecture/web-client.md). Disable for a # The embedded web client (architecture/web-client.md). Disable for a

View File

@ -8,7 +8,13 @@
let let
pkgs-unstable = import inputs.nixpkgs-unstable { system = pkgs.stdenv.system; }; pkgs-unstable = import inputs.nixpkgs-unstable { system = pkgs.stdenv.system; };
commonLibs = with pkgs; [ alsa-lib ]; # Linked by every build that plays audio. libopus is only reached by an
# unbundled `opus` build (see OPUS_LIB_DIR below); the default `opus-bundled`
# compiles its own copy and ignores it.
commonLibs = with pkgs; [
alsa-lib
libopus
];
extraPackages = with pkgs; [ extraPackages = with pkgs; [
d2 d2
@ -46,6 +52,12 @@ in
env = { env = {
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs;
# Where an unbundled Opus build finds libopus. opusic-sys emits a bare
# `-lopus` and leaves the search path to the linker, which in a nix shell
# knows nothing of the store — so `--no-default-features --features
# …,opus` would fail with "mold: library not found: opus". This is the
# crate's own knob and the bundled build ignores it.
OPUS_LIB_DIR = "${pkgs.libopus}/lib";
}; };
# https://devenv.sh/packages/ # https://devenv.sh/packages/
@ -99,6 +111,12 @@ in
echo "==> test $*" echo "==> test $*"
cargo test "$@" cargo test "$@"
} }
# Clippy never links a binary, so it cannot tell a resolvable `-lopus`
# from a missing one. The unbundled Opus build is checked by building it.
build_it() {
echo "==> build $*"
cargo build "$@"
}
# The two extremes, tests included. # The two extremes, tests included.
clippy -p crabidy-server clippy -p crabidy-server
@ -119,8 +137,15 @@ in
clippy -p crabidy-server --no-default-features --features fs,opus clippy -p crabidy-server --no-default-features --features fs,opus
clippy -p crabidy-server --no-default-features --features tidal,web-ui,opus,spectrum clippy -p crabidy-server --no-default-features --features tidal,web-ui,opus,spectrum
# Where libopus comes from. `opus` alone means the system copy (what the
# nix package and a distribution build use); adding `opus-bundled`
# compiles the vendored source. Both must link, so both are built.
build_it -p crabidy-server --no-default-features --features fs,opus
build_it -p crabidy-server --no-default-features --features fs,opus,opus-bundled
# The other feature-carrying crates. # The other feature-carrying crates.
clippy -p audio-player --no-default-features clippy -p audio-player --no-default-features
clippy -p audio-player --no-default-features --features opus
clippy -p cbd-tui --no-default-features clippy -p cbd-tui --no-default-features
test_it -p cbd-tui --no-default-features test_it -p cbd-tui --no-default-features
clippy -p cbd --no-default-features clippy -p cbd --no-default-features

View File

@ -29,7 +29,7 @@ The same list goes into the server's startup log, so a support question
## The features ## The features
| Feature | Turning it off drops | | Feature | Turning it off drops |
| ------------ | ----------------------------------------------------------- | | -------------- | --------------------------------------------------------- |
| `tidal` | the `/tidal` provider (`tidaldy`) | | `tidal` | the `/tidal` provider (`tidaldy`) |
| `youtube` | the `/youtube` provider (`ytdy`, and with it `rustypipe`) | | `youtube` | the `/youtube` provider (`ytdy`, and with it `rustypipe`) |
| `fyyd` | the `/fyyd` podcast provider | | `fyyd` | the `/fyyd` podcast provider |
@ -38,7 +38,8 @@ The same list goes into the server's startup log, so a support question
| `jamendo` | the `/jamendo` provider | | `jamendo` | the `/jamendo` provider |
| `rss` | the `/rss` podcast-subscription provider | | `rss` | the `/rss` podcast-subscription provider |
| `fs` | local files **and persistent state** — see below | | `fs` | local files **and persistent state** — see below |
| `opus` | Ogg-Opus decoding (`symphonia` + a bundled libopus C build) | | `opus` | Ogg-Opus decoding (`symphonia` + libopus) |
| `opus-bundled` | compiling libopus; links the system copy instead |
| `spectrum` | the server-side FFT feeding clients' spectrum bars | | `spectrum` | the server-side FFT feeding clients' spectrum bars |
| `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) | | `web-ui` | the embedded web client (`tonic-web` + the wasm bundle) |
@ -67,16 +68,39 @@ clients report an ordinary error instead of hanging.
If you want `/fs` but not `/crabidy`, keep the feature and prune the If you want `/fs` but not `/crabidy`, keep the feature and prune the
`providers` list at runtime instead — that is what it is for. `providers` list at runtime instead — that is what it is for.
### `opus` and the libopus build ### `opus` and where libopus comes from
`symphonia` (and so rodio) has no Opus decoder, so Ogg-Opus files are `symphonia` (and so rodio) has no Opus decoder, so Ogg-Opus files are
decoded through a bundled libopus, which needs `cmake` at build time. decoded through libopus. Turning `opus` off removes the crates, the library,
Turning `opus` off removes both the crates and that build requirement. and the C build with them.
The feature also decides whether `scan` treats `.opus` files as playable at `opus-bundled` picks *which* libopus. It is on by default, so a plain
all, so a build that cannot decode Opus will not index Opus files either. An `cargo build` compiles the vendored source and needs `cmake` and a C
Opus file that reaches such a build fails to decode with a message naming compiler but nothing installed. Drop it — `--no-default-features --features
the missing feature and is skipped, exactly like any unplayable file. …,opus` — and the build links the system libopus instead:
```sh
# a distribution build: Opus decoding, no vendored C build
cargo build --release -p crabidy-server \
--no-default-features --features all-providers,opus,spectrum,web-ui
```
That is what this repo's Nix package does, taking libopus from nixpkgs. The
build script emits a bare `-lopus` and leaves the search path to the linker
(the bindings are pregenerated, so no headers are needed); point
`OPUS_LIB_DIR` at the directory holding the library if the linker does not
find it on its own, as the dev shell does. The aarch64 cross build keeps
`opus-bundled`, because a static binary needs a static libopus for the
target and compiling the vendored copy is the way to get one.
`opus-bundled` without `opus` does nothing at all — it only chooses a source
for a library the `opus` feature decides to use.
The `opus` feature also decides whether `scan` treats `.opus` files as
playable at all, so a build that cannot decode Opus will not index Opus
files either. An Opus file that reaches such a build fails to decode with a
message naming the missing feature and is skipped, exactly like any
unplayable file.
### `spectrum` and `web-ui` ### `spectrum` and `web-ui`
@ -132,5 +156,7 @@ the feature matrix checks, not a useful deployment.
`devenv shell -- check-features` runs the curated matrix — defaults, no `devenv shell -- check-features` runs the curated matrix — defaults, no
features, each provider alone, each extra dropped, both examples above, and features, each provider alone, each extra dropped, both examples above, and
the client crates — and requires every one to be clippy-clean. Run it after the client crates — and requires every one to be clippy-clean. The two
changing anything that sits behind a feature. libopus variants are *built*, not just checked: clippy links nothing, so
only a real build can tell a resolvable `-lopus` from a missing one. Run it
after changing anything that sits behind a feature.

View File

@ -200,7 +200,7 @@ primary blue by default, so it reads as a shadow of the red bar rather than
part of it. It shows what a transient reached after the bar has dropped away. part of it. It shows what a transient reached after the bar has dropped away.
- `spectrum_peak_fall` is how long, in seconds, a full-scale shadow takes to - `spectrum_peak_fall` is how long, in seconds, a full-scale shadow takes to
fall to the floor (4 by default). It is real time, not frames, so the fall to the floor (10 by default). It is real time, not frames, so the
server's frame rate does not change the feel. server's frame rate does not change the feel.
- `spectrum_peak_fill = false` draws a thin rule (`▔`) at the peak instead of - `spectrum_peak_fill = false` draws a thin rule (`▔`) at the peak instead of
filling the space below it — also the thing to do if your terminal font filling the space below it — also the thing to do if your terminal font

View File

@ -130,7 +130,7 @@ spectrum_peak_color = "#81a1c1"
spectrum_peak_fill = true spectrum_peak_fill = true
# Seconds a full-scale shadow takes to fall to the floor, 0.05-60. # Seconds a full-scale shadow takes to fall to the floor, 0.05-60.
spectrum_peak_fall = 4.0 spectrum_peak_fall = 10.0
# Least bar width in cells, 1-16. Bars take any spare columns beyond # Least bar width in cells, 1-16. Bars take any spare columns beyond
# this, so raise it only to force wider bars and fewer bands. # this, so raise it only to force wider bars and fewer bands.

View File

@ -63,7 +63,9 @@
# or this package ships an empty server. The # or this package ships an empty server. The
# aarch64 server (below) takes the full defaults and stages the wasm # aarch64 server (below) takes the full defaults and stages the wasm
# bundle. `notifications` is cbd-tui's own default and needs no # bundle. `notifications` is cbd-tui's own default and needs no
# mention here. # mention here. `opus-bundled` is deliberately absent: Opus decoding
# is on, but libopus comes from nixpkgs rather than a cmake build of
# the vendored source.
headlessFeatures = "all-providers,opus,spectrum,notifications"; headlessFeatures = "all-providers,opus,spectrum,notifications";
# --- Web UI (wasm) bundle --------------------------------------------- # --- Web UI (wasm) bundle ---------------------------------------------
@ -140,16 +142,18 @@
cargoExtraArgs = "--locked --no-default-features --features ${headlessFeatures} ${workspaceBins}"; cargoExtraArgs = "--locked --no-default-features --features ${headlessFeatures} ${workspaceBins}";
# protoc + pkg-config are build-host tools (run during the build); # protoc + pkg-config are build-host tools (run during the build);
# with strictDeps they must be nativeBuildInputs or alsa-sys cannot # with strictDeps they must be nativeBuildInputs or alsa-sys cannot
# find pkg-config. alsa-lib is the actual linked library. cmake # find pkg-config. alsa-lib and libopus are the actual linked
# builds the bundled libopus (opusic-sys, via the `opus` feature) — # libraries: `headlessFeatures` leaves `opus-bundled` off, so
# deliberately without ninja, whose mere presence flips cmake's # opusic-sys emits a plain `-lopus` instead of compiling the
# generator and then clashes with a cached build dir. # vendored copy with cmake, and nixpkgs' libopus satisfies it.
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.protobuf pkgs.protobuf
pkgs.pkg-config pkgs.pkg-config
pkgs.cmake
]; ];
buildInputs = [ pkgs.alsa-lib ]; buildInputs = [
pkgs.alsa-lib
pkgs.libopus
];
# audio-player links libasound; yt-dlp for /youtube stays a runtime # audio-player links libasound; yt-dlp for /youtube stays a runtime
# dependency, not wired here. # dependency, not wired here.
}; };
@ -159,6 +163,20 @@
// { // {
cargoArtifacts = nativeDeps; cargoArtifacts = nativeDeps;
doCheck = false; # tests need a config/network; `nix flake check` can run them later doCheck = false; # tests need a config/network; `nix flake check` can run them later
# rustc stamps no RUNPATH of its own, and the `-L` that buildInputs
# contributes arrives through NIX_LDFLAGS, which ld-wrapper does not
# mirror into the binary. Without this the binaries link cleanly and
# then refuse to *start* — "libasound.so.2: cannot open shared
# object file" — anywhere the libraries are not already on
# LD_LIBRARY_PATH, which is to say everywhere outside this repo's
# dev shell. autoPatchelfHook walks the finished binaries and fills
# their RUNPATH from buildInputs, so an installed crabidy runs on a
# bare login shell. It also fails the build on any library it cannot
# resolve, which is how a missing buildInput should surface.
nativeBuildInputs = nativeArgs.nativeBuildInputs ++ [ pkgs.autoPatchelfHook ];
# Rust links libgcc_s for unwinding; it is the compiler's own
# runtime, so no crate declares it and the hook cannot guess it.
buildInputs = nativeArgs.buildInputs ++ [ (pkgs.lib.getLib pkgs.stdenv.cc.cc) ];
} }
); );
@ -193,8 +211,13 @@
''; '';
# Build scripts / proc-macros compile for the build host; deps for the # Build scripts / proc-macros compile for the build host; deps for the
# target use the cross toolchain. protoc, pkg-config, and cmake (for # target use the cross toolchain. protoc, pkg-config, and cmake are
# the bundled libopus) are host tools. # host tools. Unlike the native build this one keeps the default
# `opus-bundled`: cmake cross-compiles the vendored libopus into the
# static binary, whereas an unbundled `-lopus` would need a static
# aarch64 libopus staged for the target the way alsa-lib is. cmake
# comes deliberately without ninja, whose mere presence flips cmake's
# generator and then clashes with a cached build dir.
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.protobuf pkgs.protobuf
pkgs.pkg-config pkgs.pkg-config

View File

@ -1812,3 +1812,61 @@ the service 25 methods and the suite stayed green. It now reads the method
names out of `crabidy.proto` with `include_str!` and compares sets, so the names out of `crabidy.proto` with `include_str!` and compares sets, so the
next RPC cannot be forgotten. A count copied out of a file is not a check next RPC cannot be forgotten. A count copied out of a file is not a check
against that file. against that file.
## Unbundling libopus, and the RUNPATH it exposed (2026-07-28)
From a report of installing the flake: the native Nix build needed `cmake`
only to compile the vendored libopus, while nixpkgs already ships one. The
axes were conflated — `opus` meant *decode Opus* **and** *vendor the C
library* — so the only way to skip the C build was to give up Opus playback.
Splitting them costs one feature and no code, because the adapter crate
already draws the line: `symphonia-adapter-libopus`'s own `bundled` feature is
what pulls `opusic-sys/bundled` and with it the `cmake` crate. So the
workspace dependency is declared `default-features = false`, `audio-player`
grows `opus-bundled = ["symphonia-adapter-libopus?/bundled"]`, and
`crabidy-server` and `cbd` forward it. It stays in every `default`, so a plain
`cargo build` still needs nothing installed; opting out is the packager's
explicit act. The **weak** `?/` is load-bearing: a plain
`symphonia-adapter-libopus/bundled` would enable the optional dependency
itself, so `opus-bundled` alone would quietly switch Opus decoding back on and
the flag would stop being about *where the library comes from*. Checked with
`cargo tree`, not by reading: `--features opus-bundled` alone pulls neither
the adapter nor `opusic-sys`.
The flake's native build then drops `cmake` and takes `libopus` as a
`buildInput`; `headlessFeatures` already omitted `opus-bundled`, so it opts
out for free. The cross build keeps the vendored copy on purpose — it links
statically, and an unbundled `-lopus` would need a static aarch64 libopus
staged for the target the way `alsa-lib` is.
**The build then succeeded and produced a binary that could not start.**
`ldd` said `libopus.so.0 => not found`: rustc stamps no RUNPATH, and the `-L`
that `buildInputs` contributes arrives through `NIX_LDFLAGS`, which
ld-wrapper does not mirror into the binary. Chasing it turned up something
older — `RUNPATH: []` — so `libasound.so.2` had never been resolvable either;
the package had always depended on the caller having it on
`LD_LIBRARY_PATH`, which this repo's dev shell happens to set. The added
`libopus` only made a latent bug audible. `autoPatchelfHook` on the final
derivation fills the RUNPATH from `buildInputs` and, just as usefully, fails
the build on anything it cannot resolve — that is how it named `libgcc_s.so.1`,
the compiler's own unwinding runtime, which no crate declares and which
therefore needs `stdenv.cc.cc` added explicitly. Verified the way the bug
showed up in the first place: every binary run under `env -i`.
Two verification notes worth keeping. `check-features` gained real *builds*
for the two libopus variants, because clippy links nothing and so cannot tell
a resolvable `-lopus` from a missing one — the dev shell's link failure
("mold: fatal: library not found: opus") is invisible to the matrix
otherwise, and is why `devenv.nix` now sets `OPUS_LIB_DIR`. And `cbd` was
missing an `rss` pass-through, so the bundle could not select that provider
alone even though `all-providers` reached it; found while adding the
`opus-bundled` pass-through beside it.
## Spectrum: a slower default fall (2026-07-28)
`spectrum_peak_fall` moves from 4 s to 10 s. Everything else in the requested
default block was already the default. The renderer's `SpectrumStyle::default`
and the config's `#[default]` are two spellings of one thing, so the config
test now asserts the whole resolved style equals `SpectrumStyle::default()`
rather than field-by-field — the pair cannot drift apart silently.