Commits
Previously a single fetch failure advanced next_play_seq immediately,
burning through the segment list in seconds and killing the stream.
Now we back off (500ms → 5s cap), retry the same seg, and switch to
Buffering while reconnecting. Also handles the live-edge-rolled-past
case so we can resume after long outages instead of looping forever
on an expired seq.
Replace per-segment symphonia decoder with one persistent
StreamDecoder, eliminating the encoder-priming click at every HLS
segment boundary. Player loop refactored to fetch in the background
and decode serially through the shared decoder (ffplay's model).
Also lands the cpal_sink rewrite (rtrb SPSC ring, linear-interp
resampler, 1.5 s pre-buffer) and background per-segment prefetch from
the previous round.
CpalSink: replace the Mutex<VecDeque>+Condvar ring with an rtrb SPSC
ring and a linear-interp resampler. The old sink locked, allocated,
and called notify_all from the CoreAudio realtime thread, and
"resampled" 44.1k→48k by zero-order hold — producing both aliasing
hiss and occasional lock-contention clicks. The new sink does no
locks/allocs/syscalls in the callback, interpolates between adjacent
source frames, and pre-buffers 1.5s before priming so HLS producer
gaps don't immediately underrun.
Player: stop awaiting per-segment prefetch. Spawning it instead lets
the playback loop start writing the next segment as soon as it's
cached/decoded, removing HTTP latency from the inter-segment gap.
Server-streaming events bus, SnapcastService, Spotify Connect via
librespot, and A2DP sink mode (BlueZ pairing agent + bluealsa
delegation).
Surfaces the four new subsystems in the Features list, adds CLI
examples for each (`zerod events tail`, `snapcast clients`, `stream
spotify start`, `a2dp enable`, `bluetooth discoverable on`,
`respond-pairing`), and updates the crate layout diagram to include
the new `events` and `snapcast` crates.
Registers a BlueZ pairing agent at server boot when
`[bluetooth.a2dp].enabled = true`. The agent's RequestConfirmation
callback publishes `BluetoothPairingRequest` onto the event bus and
either auto-accepts (kiosk mode) or parks on a per-address oneshot
waiting for `BluetoothService.RespondPairing` — 30s timeout falls back
to rejection so a stuck prompt doesn't leak the channel.
AuthorizeService accepts the A2DP Source UUID
(`0000110a-…`) and emits `BluetoothA2dpConnected` so phones can stream
audio without a second confirm after pairing.
Adapter setup at boot: optional alias, set_pairable(true), and
discoverable + discoverable_timeout when requested.
New RPCs: `SetDiscoverable`, `RespondPairing`, `A2dpEnable`,
`A2dpDisable`. A2dpEnable preflights `bluealsa-aplay.service` via the
existing systemd allowlist and surfaces a clear "install
bluez-alsa-utils" error if the unit is missing. The unit is
auto-appended to the systemd allowlist whenever a2dp is enabled, so
users don't need to remember to list it under [systemd].
Non-Linux: start_agent is a silent no-op so the server still boots
with `a2dp.enabled = true` on macOS for development; RPC-level paths
return "linux only".
bluer pinned to =0.17.4 exact — the Agent callback shape varies
across minor versions and a silent rewire of the pairing flow is
worse than a build break.
Verified: host build green; cross --target arm-unknown-linux-gnueabihf
green; macOS smoke covers disabled-by-config, stub fall-through,
preflight error path, and auto-allowlist.
`crates/stream/src/sources/librespot.rs` (Linux) spawns `librespot
--backend pipe --format S16 --device -` so the child writes raw S16LE
PCM at 44.1 kHz / 2ch to stdout. A tokio reader converts byte pairs to
i16 via `from_le_bytes` (endian-safe across cross-compile targets),
applies the existing per-stream gain, and pushes into the same
AudioSink as HLS/DASH. `kill_on_drop` on the Child reaps librespot
when `Player::cancel()` aborts the driver task. Stderr is mirrored to
tracing at debug.
Player gets a new `source: AtomicU8` (PlaybackSource: Hls/Dash/Spotify
/Unspecified) and shared `install(player, task)` helper so HLS/DASH
and Spotify use the same singleton-swap path. The HLS/DASH branch now
sets source from `ManifestKind` instead of inferring at status time.
Settings: new `[librespot]` block (enabled / binary / name / bitrate /
cache_path); when `enabled = false`, `StreamService.SpotifyStart`
returns FAILED_PRECONDITION. Non-Linux builds get a stub that returns
"librespot: linux only" so cross-platform dev compiles.
CLI: `zerod stream spotify start [--output ...]` / `stream spotify
stop`. `stream status` now prints `source=Spotify` etc.
New leaf crate `zerod-snapcast` runs a single long-lived TCP connection
to snapserver on port 1705, with backoff reconnect and per-request id
correlation via oneshots. While disconnected the supervisor drains
queued commands with a fail-fast "not connected" error so RPCs don't
hang.
`SnapcastService` exposes GetServerStatus / ListClients / ListSnapStreams
plus the Set* mutators (Client volume/latency/name, Group stream/mute/
clients). Disabled mode keeps the service registered but returns
FAILED_PRECONDITION so reflection-based UIs still see it.
`Client.OnVolumeChanged` notifications are forwarded onto the event bus
as `SnapcastClientChanged` (reserved variant from PR1), so external
`snapctl` changes show up in `zerod events tail --filter snap.*`.
CLI: `zerod snapcast {status, clients, streams, volume, latency, name,
group-stream, group-mute, group-clients}`.
Smoke-tested against a Python mock snapserver — happy-path response
parses through the nested host/config/volume shape and flattens into
the proto SnapClient.
New leaf crate `zerod-events` owns a `broadcast::Sender<Envelope>` that
the stream, bluetooth, systemd, and volume subsystems publish into.
The server exposes `EventsService.Subscribe(SubscribeRequest) returns
(stream Event)` with kind-label filtering (exact match or `.*` suffix
wildcard) and maps `broadcast` lag into a synthetic `LaggedEvent` so a
slow subscriber doesn't break the stream.
CLI: `zerod events tail [--filter ...]` prints one JSON line per event.
Snapcast and Librespot event variants are reserved in the proto now so
PRs 2 and 3 don't need a wire bump to start emitting.
Server advertises `_zerod._tcp.local.` via a new pure-Rust `zerod-discovery`
crate wrapping mdns-sd. Clients omit `--host` and the binary browses the
LAN for ~1.5s; multiple responders error out with a listing, `--name`
(or `ZEROD_NAME`) filters. `zerod discover` lists every responder.
IPv4 picker skips loopback and Docker's 172.17.0.0/16 default bridge so
the wrong interface doesn't leak into the URL.
`[mdns] enabled / name` in zerod.toml; instance name defaults to
gethostname().
Adds RGB-color Styles to the clap derive entry point so the help
menu picks up Charm's signature purple-violet-blue (#7D56F4) on
headers/usage, brighter violet on commands/flags, and soft lavender
on placeholders. Drops in a preview screenshot at the top of the
README.
Tagging a `v*` release now fans out across five build jobs and uploads
prebuilt tarballs (+ sha256) to the GitHub release:
- macOS: aarch64-apple-darwin, x86_64-apple-darwin (native cargo build
via dtolnay/rust-toolchain + brew protoc)
- Linux: x86_64 / aarch64 / armhf (cross + docker buildx; the per-target
Dockerfile handles protoc 25.1 + multiarch dev libs)
taiki-e/install-action installs cross from the prebuilt binary so we
skip the cold cargo install. Swatinem/rust-cache keyed per target.
softprops/action-gh-release@v2 attaches the artifacts with
fail_on_unmatched_files so a missing build fails the release.
README gains an "Install → From a release tarball" section with a
curl one-liner pointing at github.com/tsirysndr/zerod (the GitHub
mirror used for CI/releases; tangled.org/tsiry-sandratraina.com/zerod
remains the canonical source host).
The previous template left ZEROD_BEARER_TOKEN unset, which meant zerod
would generate a new random token on every restart and log it once —
unworkable for any client that wants a stable token, and easy to miss
in the journal.
Now `zerod service install`:
- generates a 32-byte hex token via getrandom (or accepts --token <v>),
- substitutes it into Environment=ZEROD_BEARER_TOKEN= in the unit,
- writes the file mode 0600 so other users can't read the secret,
- prints the token to stdout with the client-side ZEROD_BEARER_TOKEN
export to copy-paste.
Re-running with --force rotates the token (unless --token is supplied);
without --force the install errors out so an in-flight client doesn't
silently break.
Also fixes Documentation= URL to point at the actual repo
(tangled.org/tsiry-sandratraina.com/zerod).
Adds three subcommands so the Pi/SBC user flow is a copy-paste from
README rather than hand-writing a service file:
zerod service install # writes ~/.config/systemd/user/zerod.service
zerod service uninstall # removes it
zerod service path # prints where it would land
The unit template lives at assets/zerod.service.in and is embedded via
include_str!. `service install` substitutes {{exec_start}} with
current_exe() (canonicalised), writes atomically via tempfile + rename,
and prints the systemctl --user follow-up commands plus a loginctl
enable-linger hint so it survives logout.
Linux-only — non-Linux builds expose the subcommand but every action
returns "zerod service: linux only" rather than producing a unit file
no one can use.
cpal's ALSA backend uses Access::MMapInterleaved, which crashes inside
libasound's pulse plugin on Raspberry Pi OS (build_output_stream →
snd_pcm_mmap_begin SIGSEGV). aplay / speaker-test work because they
use snd_pcm_writei. Match that path.
- New crates/stream/src/alsa_sink.rs: alsa::pcm::PCM with
Access::RWInterleaved, dedicated writer thread draining a bounded
mpsc channel (cap 64 ≈ 3s buffer), EPIPE/xrun recovery via
pcm.try_recover. Linux only.
- player::build_sink: route AudioOutput::Cpal to AlsaSink on Linux,
CpalSink elsewhere. Proto variant stays Cpal for cross-platform
API parity.
- crates/stream/Cargo.toml: cpal moves to non-Linux target deps,
alsa = "0.9" added for Linux. Smaller dep tree on the Pi.
- CLI: drop --cpal-device. The named-device path walked cpal's
output_devices() enumeration, which triggers libasound plugin
probes that segfault on the JACK plugin destructor. ALSA routing
belongs in ~/.asoundrc, not a CLI knob.
- cpal_sink.rs: trace logs around each cpal call kept for the
non-Linux diagnostic path.
127.0.0.1 default left the daemon unreachable from the LAN, which is
the common deployment shape (Pi audio appliance + remote CLI). Flip the
default to all-interfaces and lean on the bearer token (random fallback)
for the no-zerod.toml case. Document the security trade-off in the
Security section and tell users to set bearer_token explicitly when
exposing on a network they don't fully trust.
Two cooperating layers, Linux-only for the system part:
- crates/volume: alsa-lib wrapper for mixer ops (list / get / set /
mute / unmute) gated on cfg(target_os = "linux"). Defaults to the
`default` card and `Master` selem, falling back to `PCM` when
`Master` isn't exposed. Cross-platform stubs return Err on macOS.
- VolumeService in zerod.v1alpha1 wired into the tonic server, plus a
`zerod volume` client subcommand tree.
- Per-stream software gain applied in the player loop before the
sink, so it works uniformly across cpal / stdout / pipe with one
atomic. Exposed via SetStreamVolume / GetStreamVolume on
StreamService and `zerod stream volume {get,set}`.
- Cross.toml + per-target Dockerfiles for arm-unknown-linux-gnueabihf,
aarch64-unknown-linux-gnu, and x86_64-unknown-linux-gnu. Each installs
upstream protoc 25.1, multiarch ALSA/D-Bus/systemd dev libs, and sets
CARGO_TARGET_<TRIPLE>_RUSTFLAGS with -rpath-link so ld can find
libdbus-1's indirect libsystemd.so.0 dep.
- crates/proto/build.rs: pass --experimental_allow_proto3_optional so
older protoc accepts proto3 optional (no-op on 3.15+).
- LICENSE (MIT) and README with cross-build instructions per target.
Previously a single fetch failure advanced next_play_seq immediately,
burning through the segment list in seconds and killing the stream.
Now we back off (500ms → 5s cap), retry the same seg, and switch to
Buffering while reconnecting. Also handles the live-edge-rolled-past
case so we can resume after long outages instead of looping forever
on an expired seq.
Replace per-segment symphonia decoder with one persistent
StreamDecoder, eliminating the encoder-priming click at every HLS
segment boundary. Player loop refactored to fetch in the background
and decode serially through the shared decoder (ffplay's model).
Also lands the cpal_sink rewrite (rtrb SPSC ring, linear-interp
resampler, 1.5 s pre-buffer) and background per-segment prefetch from
the previous round.
CpalSink: replace the Mutex<VecDeque>+Condvar ring with an rtrb SPSC
ring and a linear-interp resampler. The old sink locked, allocated,
and called notify_all from the CoreAudio realtime thread, and
"resampled" 44.1k→48k by zero-order hold — producing both aliasing
hiss and occasional lock-contention clicks. The new sink does no
locks/allocs/syscalls in the callback, interpolates between adjacent
source frames, and pre-buffers 1.5s before priming so HLS producer
gaps don't immediately underrun.
Player: stop awaiting per-segment prefetch. Spawning it instead lets
the playback loop start writing the next segment as soon as it's
cached/decoded, removing HTTP latency from the inter-segment gap.
Registers a BlueZ pairing agent at server boot when
`[bluetooth.a2dp].enabled = true`. The agent's RequestConfirmation
callback publishes `BluetoothPairingRequest` onto the event bus and
either auto-accepts (kiosk mode) or parks on a per-address oneshot
waiting for `BluetoothService.RespondPairing` — 30s timeout falls back
to rejection so a stuck prompt doesn't leak the channel.
AuthorizeService accepts the A2DP Source UUID
(`0000110a-…`) and emits `BluetoothA2dpConnected` so phones can stream
audio without a second confirm after pairing.
Adapter setup at boot: optional alias, set_pairable(true), and
discoverable + discoverable_timeout when requested.
New RPCs: `SetDiscoverable`, `RespondPairing`, `A2dpEnable`,
`A2dpDisable`. A2dpEnable preflights `bluealsa-aplay.service` via the
existing systemd allowlist and surfaces a clear "install
bluez-alsa-utils" error if the unit is missing. The unit is
auto-appended to the systemd allowlist whenever a2dp is enabled, so
users don't need to remember to list it under [systemd].
Non-Linux: start_agent is a silent no-op so the server still boots
with `a2dp.enabled = true` on macOS for development; RPC-level paths
return "linux only".
bluer pinned to =0.17.4 exact — the Agent callback shape varies
across minor versions and a silent rewire of the pairing flow is
worse than a build break.
Verified: host build green; cross --target arm-unknown-linux-gnueabihf
green; macOS smoke covers disabled-by-config, stub fall-through,
preflight error path, and auto-allowlist.
`crates/stream/src/sources/librespot.rs` (Linux) spawns `librespot
--backend pipe --format S16 --device -` so the child writes raw S16LE
PCM at 44.1 kHz / 2ch to stdout. A tokio reader converts byte pairs to
i16 via `from_le_bytes` (endian-safe across cross-compile targets),
applies the existing per-stream gain, and pushes into the same
AudioSink as HLS/DASH. `kill_on_drop` on the Child reaps librespot
when `Player::cancel()` aborts the driver task. Stderr is mirrored to
tracing at debug.
Player gets a new `source: AtomicU8` (PlaybackSource: Hls/Dash/Spotify
/Unspecified) and shared `install(player, task)` helper so HLS/DASH
and Spotify use the same singleton-swap path. The HLS/DASH branch now
sets source from `ManifestKind` instead of inferring at status time.
Settings: new `[librespot]` block (enabled / binary / name / bitrate /
cache_path); when `enabled = false`, `StreamService.SpotifyStart`
returns FAILED_PRECONDITION. Non-Linux builds get a stub that returns
"librespot: linux only" so cross-platform dev compiles.
CLI: `zerod stream spotify start [--output ...]` / `stream spotify
stop`. `stream status` now prints `source=Spotify` etc.
New leaf crate `zerod-snapcast` runs a single long-lived TCP connection
to snapserver on port 1705, with backoff reconnect and per-request id
correlation via oneshots. While disconnected the supervisor drains
queued commands with a fail-fast "not connected" error so RPCs don't
hang.
`SnapcastService` exposes GetServerStatus / ListClients / ListSnapStreams
plus the Set* mutators (Client volume/latency/name, Group stream/mute/
clients). Disabled mode keeps the service registered but returns
FAILED_PRECONDITION so reflection-based UIs still see it.
`Client.OnVolumeChanged` notifications are forwarded onto the event bus
as `SnapcastClientChanged` (reserved variant from PR1), so external
`snapctl` changes show up in `zerod events tail --filter snap.*`.
CLI: `zerod snapcast {status, clients, streams, volume, latency, name,
group-stream, group-mute, group-clients}`.
Smoke-tested against a Python mock snapserver — happy-path response
parses through the nested host/config/volume shape and flattens into
the proto SnapClient.
New leaf crate `zerod-events` owns a `broadcast::Sender<Envelope>` that
the stream, bluetooth, systemd, and volume subsystems publish into.
The server exposes `EventsService.Subscribe(SubscribeRequest) returns
(stream Event)` with kind-label filtering (exact match or `.*` suffix
wildcard) and maps `broadcast` lag into a synthetic `LaggedEvent` so a
slow subscriber doesn't break the stream.
CLI: `zerod events tail [--filter ...]` prints one JSON line per event.
Snapcast and Librespot event variants are reserved in the proto now so
PRs 2 and 3 don't need a wire bump to start emitting.
Server advertises `_zerod._tcp.local.` via a new pure-Rust `zerod-discovery`
crate wrapping mdns-sd. Clients omit `--host` and the binary browses the
LAN for ~1.5s; multiple responders error out with a listing, `--name`
(or `ZEROD_NAME`) filters. `zerod discover` lists every responder.
IPv4 picker skips loopback and Docker's 172.17.0.0/16 default bridge so
the wrong interface doesn't leak into the URL.
`[mdns] enabled / name` in zerod.toml; instance name defaults to
gethostname().
Tagging a `v*` release now fans out across five build jobs and uploads
prebuilt tarballs (+ sha256) to the GitHub release:
- macOS: aarch64-apple-darwin, x86_64-apple-darwin (native cargo build
via dtolnay/rust-toolchain + brew protoc)
- Linux: x86_64 / aarch64 / armhf (cross + docker buildx; the per-target
Dockerfile handles protoc 25.1 + multiarch dev libs)
taiki-e/install-action installs cross from the prebuilt binary so we
skip the cold cargo install. Swatinem/rust-cache keyed per target.
softprops/action-gh-release@v2 attaches the artifacts with
fail_on_unmatched_files so a missing build fails the release.
README gains an "Install → From a release tarball" section with a
curl one-liner pointing at github.com/tsirysndr/zerod (the GitHub
mirror used for CI/releases; tangled.org/tsiry-sandratraina.com/zerod
remains the canonical source host).
The previous template left ZEROD_BEARER_TOKEN unset, which meant zerod
would generate a new random token on every restart and log it once —
unworkable for any client that wants a stable token, and easy to miss
in the journal.
Now `zerod service install`:
- generates a 32-byte hex token via getrandom (or accepts --token <v>),
- substitutes it into Environment=ZEROD_BEARER_TOKEN= in the unit,
- writes the file mode 0600 so other users can't read the secret,
- prints the token to stdout with the client-side ZEROD_BEARER_TOKEN
export to copy-paste.
Re-running with --force rotates the token (unless --token is supplied);
without --force the install errors out so an in-flight client doesn't
silently break.
Also fixes Documentation= URL to point at the actual repo
(tangled.org/tsiry-sandratraina.com/zerod).
Adds three subcommands so the Pi/SBC user flow is a copy-paste from
README rather than hand-writing a service file:
zerod service install # writes ~/.config/systemd/user/zerod.service
zerod service uninstall # removes it
zerod service path # prints where it would land
The unit template lives at assets/zerod.service.in and is embedded via
include_str!. `service install` substitutes {{exec_start}} with
current_exe() (canonicalised), writes atomically via tempfile + rename,
and prints the systemctl --user follow-up commands plus a loginctl
enable-linger hint so it survives logout.
Linux-only — non-Linux builds expose the subcommand but every action
returns "zerod service: linux only" rather than producing a unit file
no one can use.
cpal's ALSA backend uses Access::MMapInterleaved, which crashes inside
libasound's pulse plugin on Raspberry Pi OS (build_output_stream →
snd_pcm_mmap_begin SIGSEGV). aplay / speaker-test work because they
use snd_pcm_writei. Match that path.
- New crates/stream/src/alsa_sink.rs: alsa::pcm::PCM with
Access::RWInterleaved, dedicated writer thread draining a bounded
mpsc channel (cap 64 ≈ 3s buffer), EPIPE/xrun recovery via
pcm.try_recover. Linux only.
- player::build_sink: route AudioOutput::Cpal to AlsaSink on Linux,
CpalSink elsewhere. Proto variant stays Cpal for cross-platform
API parity.
- crates/stream/Cargo.toml: cpal moves to non-Linux target deps,
alsa = "0.9" added for Linux. Smaller dep tree on the Pi.
- CLI: drop --cpal-device. The named-device path walked cpal's
output_devices() enumeration, which triggers libasound plugin
probes that segfault on the JACK plugin destructor. ALSA routing
belongs in ~/.asoundrc, not a CLI knob.
- cpal_sink.rs: trace logs around each cpal call kept for the
non-Linux diagnostic path.
127.0.0.1 default left the daemon unreachable from the LAN, which is
the common deployment shape (Pi audio appliance + remote CLI). Flip the
default to all-interfaces and lean on the bearer token (random fallback)
for the no-zerod.toml case. Document the security trade-off in the
Security section and tell users to set bearer_token explicitly when
exposing on a network they don't fully trust.
Two cooperating layers, Linux-only for the system part:
- crates/volume: alsa-lib wrapper for mixer ops (list / get / set /
mute / unmute) gated on cfg(target_os = "linux"). Defaults to the
`default` card and `Master` selem, falling back to `PCM` when
`Master` isn't exposed. Cross-platform stubs return Err on macOS.
- VolumeService in zerod.v1alpha1 wired into the tonic server, plus a
`zerod volume` client subcommand tree.
- Per-stream software gain applied in the player loop before the
sink, so it works uniformly across cpal / stdout / pipe with one
atomic. Exposed via SetStreamVolume / GetStreamVolume on
StreamService and `zerod stream volume {get,set}`.
- Cross.toml + per-target Dockerfiles for arm-unknown-linux-gnueabihf,
aarch64-unknown-linux-gnu, and x86_64-unknown-linux-gnu. Each installs
upstream protoc 25.1, multiarch ALSA/D-Bus/systemd dev libs, and sets
CARGO_TARGET_<TRIPLE>_RUSTFLAGS with -rpath-link so ld can find
libdbus-1's indirect libsystemd.so.0 dep.
- crates/proto/build.rs: pass --experimental_allow_proto3_optional so
older protoc accepts proto3 optional (no-op on 3.15+).
- LICENSE (MIT) and README with cross-build instructions per target.