185 lines
9.3 KiB
Markdown
185 lines
9.3 KiB
Markdown
# Headless audio endpoint
|
||
|
||
For rooms that don't get a Sway thin client (`hosts/thin-client/`) but still want a
|
||
speaker: a small headless appliance running its own independent Spotify Connect
|
||
receiver — its own device name, its own room, no shared/synced stream. Two
|
||
completely separate build pipelines, one per architecture, because the hardware
|
||
paths genuinely differ (see below) — not one image with a flag.
|
||
|
||
| | arm64 (Raspberry Pi) | amd64 (mini PC) |
|
||
|---|---|---|
|
||
| Audio hardware | HiFiBerry Amp2 HAT (GPIO, 60W, drives 4–8Ω passive speakers directly) | A USB DAC/amp, e.g. Fosi Audio V3 (USB Audio Class, drives passive speakers directly) |
|
||
| Build tool | [rpi-image-gen](https://github.com/raspberrypi/rpi-image-gen) | live-build (same tool `hosts/thin-client/` uses) |
|
||
| Output | A bootable `.img` | A bootable live ISO |
|
||
| Per-room identity | Set per physical unit, after building, via Raspberry Pi Imager | Baked in at build time — one build per room |
|
||
|
||
Both variants share one thing: `configs/spotify-connect.service` +
|
||
`configs/spotify-connect-start`, an architecture-independent systemd
|
||
unit/wrapper pair. Nothing else is shared — see each variant's own section below.
|
||
|
||
## Why two separate pipelines, not one
|
||
|
||
- **The HiFiBerry Amp2 is a Raspberry Pi GPIO HAT.** There's no amd64 equivalent
|
||
slot to attach one to — an x86 mini PC needs a USB DAC/amp instead, plug-in
|
||
hardware, not a HAT.
|
||
- **rpi-image-gen is Raspberry-Pi-specific.** It doesn't target amd64. Rather than
|
||
reach for a third, unfamiliar toolchain for the x86 side, the amd64 image reuses
|
||
**the exact tool this repo already has and has already proven for a bootable
|
||
image**: live-build, the same one `hosts/thin-client/scripts/build-thin-client-iso.sh`
|
||
drives. Lower risk than learning a new toolchain for one variant.
|
||
|
||
## Spotify Connect install: apt-first, documented fallback
|
||
|
||
Neither `spotifyd` nor `librespot` is in Debian bookworm main — same situation
|
||
`hosts/thin-client/live-build/config/hooks/normal/0500-spotify-connect.hook.chroot`
|
||
already solved for the thin client. Both variants here reuse that exact logic and
|
||
package order (`spotifyd` then `librespot`): try `apt-get install` for each (in
|
||
case a backport/third-party repo has been added), and if neither installs, stop
|
||
with a documented message rather than hardcoding a release URL that would
|
||
silently 404 later. If you hit that message on a real build:
|
||
|
||
- fetch a release binary from https://github.com/librespot-org/librespot/releases
|
||
(check the current tag yourself) into `/usr/bin/librespot`, or
|
||
- `cargo build` librespot in the relevant hook script, or
|
||
- use [raspotify](https://github.com/dtcooper/raspotify) instead — it wraps
|
||
librespot with its own packaging and systemd unit. If you go this route, drop
|
||
this repo's `spotify-connect.service`/`spotify-connect-start` in favour of
|
||
raspotify's own unit rather than layering both, or
|
||
- add a third-party apt repo that carries one of the above.
|
||
|
||
Spotify **Premium** is required either way — Spotify Connect doesn't work on Free.
|
||
|
||
## Audio output: direct ALSA, not PipeWire
|
||
|
||
Deliberately different from the thin client's PipeWire/WirePlumber stack: this is
|
||
a single-purpose headless appliance with exactly one audio-producing process, so
|
||
there's no multi-app mixing need PipeWire exists to solve, and neither image runs
|
||
a desktop session that would start a per-session PipeWire daemon anyway.
|
||
`spotify-connect-start` talks straight to ALSA via `--device "${ALSA_DEVICE:-default}"`.
|
||
|
||
## arm64: Raspberry Pi + HiFiBerry Amp2
|
||
|
||
### ⚠️ The biggest risk in this whole directory
|
||
|
||
`rpi-image-gen/config/audio-endpoint.yaml` and the build script were written from
|
||
rpi-image-gen's own README/documentation summary, **not hands-on validated** — no
|
||
ARM build environment or the tool itself was available while writing this. If
|
||
`rpi-image-gen build -c config/audio-endpoint.yaml` rejects the config:
|
||
|
||
```sh
|
||
rpi-image-gen layer --list
|
||
rpi-image-gen layer --describe <name>
|
||
```
|
||
|
||
on the real build host is the authoritative source. Adjust the *keys* in
|
||
`audio-endpoint.yaml` to match what that reports — the intent behind each section
|
||
(device target, packages, files to inject, which service to enable, the
|
||
build-time install hook) should carry over even if the exact key names don't.
|
||
|
||
### Build
|
||
|
||
```sh
|
||
cd hosts/audio-endpoint/rpi-image-gen/scripts
|
||
./build-audio-endpoint-image-arm64.sh
|
||
```
|
||
|
||
Clones `rpi-image-gen` (to `/opt/smart-home/src/rpi-image-gen` by default,
|
||
override with `RPI_IMAGE_GEN_SRC`) if not already present, then builds. Output is
|
||
a `.img` under that clone's `work/` directory.
|
||
|
||
### Per-room identity
|
||
|
||
**Build one generic image, not one per room.** Raspberry Pi Imager's own **OS
|
||
Customisation** dialog (gear icon, or Ctrl+Shift+X) works on *any* `.img`,
|
||
including local custom ones — hostname, Wi-Fi SSID/password, and an SSH key, all
|
||
set per-flash without touching the image itself. Use it on this image, once per
|
||
physical unit, before writing the SD card. `spotify-connect-start` reads
|
||
`$(hostname)` at every service start, so whatever hostname you set in Imager *is*
|
||
that unit's Spotify Connect device name in the picker — no separate config file,
|
||
no rebuild per room.
|
||
|
||
### Hardware setup
|
||
|
||
`dtoverlay=hifiberry-dacplus` in `/boot/firmware/config.txt` (from HiFiBerry's own
|
||
current docs, grouped under "DAC+ Standard/Amp2/Amp4" — worth confirming against
|
||
your specific board revision before first boot) plus disabling onboard audio, so
|
||
the HiFiBerry ends up as the only ALSA card (`hw:0`, which
|
||
`systemd/spotify-connect.service.d/10-alsa-device.conf` pins `ALSA_DEVICE` to).
|
||
|
||
## amd64: mini PC + USB DAC/amp
|
||
|
||
### Build
|
||
|
||
```sh
|
||
cd hosts/audio-endpoint/live-build-amd64/scripts
|
||
sudo ./build-audio-endpoint-iso-amd64.sh
|
||
```
|
||
|
||
Same shape as `hosts/thin-client/scripts/build-thin-client-iso.sh`: installs
|
||
`live-build` if missing, regenerates `config/includes.chroot/` from the shared
|
||
`../configs/` (git-ignored, generated — never hand-edit it), then runs
|
||
`lb config && lb build`. Output is a hybrid ISO, written to a USB stick and
|
||
live-booted — this image doesn't install itself to disk, same as the thin client.
|
||
|
||
### Per-room identity
|
||
|
||
No Raspberry-Pi-Imager equivalent exists for a generic x86 ISO, so this variant
|
||
uses the thin client's own already-proven pattern instead: **`IMAGE_HOSTNAME` in
|
||
the build script's CONFIGURATION block, baked in at build time.** Edit it and
|
||
re-run the script once per room — same convention as the thin client's own
|
||
`THINCLIENT_NAME`/`IMAGE_HOSTNAME`. `spotify-connect-start` still just reads
|
||
`$(hostname)` at start time; on this variant that value was set at build time
|
||
instead of per-flash.
|
||
|
||
### Hardware setup
|
||
|
||
Plug in a USB DAC/amp (e.g. Fosi Audio V3) — no driver needed, it's a standard
|
||
USB Audio Class device. **One manual step is required per physical unit**: this
|
||
repo can't know in advance which ALSA card index your specific mini PC assigns
|
||
the USB device (it may also have onboard audio competing for card 0, and which
|
||
one wins varies by board) — guessing one would be exactly the kind of "build
|
||
against a guess" this project avoids elsewhere (`gesture-config.json`'s
|
||
`camera_device`, the ESP32 firmware's `weather_entity_id`). After first boot,
|
||
over SSH:
|
||
|
||
```sh
|
||
aplay -l # find the USB DAC's card index, e.g. card 1
|
||
sudo mkdir -p /etc/systemd/system/spotify-connect.service.d
|
||
sudo tee /etc/systemd/system/spotify-connect.service.d/10-alsa-device.conf <<'EOF'
|
||
[Service]
|
||
Environment=ALSA_DEVICE=hw:1
|
||
EOF
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl restart spotify-connect
|
||
```
|
||
|
||
## Manual verification still outstanding
|
||
|
||
Nothing here has been built, flashed, or booted on real hardware — no Raspberry
|
||
Pi, no HiFiBerry Amp2, no x86 test box, and no rpi-image-gen install were
|
||
available while writing this. In rough order of what to check first:
|
||
|
||
1. **rpi-image-gen's actual config/layer schema** — see the risk callout above.
|
||
The single most likely thing to need hand-adjustment.
|
||
2. **The `dtoverlay=hifiberry-dacplus` line** — cross-checked against HiFiBerry's
|
||
own current docs, but not against your specific Amp2 board revision.
|
||
3. **Whether `spotifyd`/`librespot` actually install from bookworm's apt sources
|
||
at all** — if not, the documented fallback list above is untested against
|
||
which of those routes is actually easiest today.
|
||
4. **The amd64 live-build tree's structure** — checked for internal consistency
|
||
against `build-thin-client-iso.sh`'s own already-working structure, but the
|
||
`lb config`/`lb build` invocation itself hasn't been run for this specific,
|
||
much-smaller package/hook set.
|
||
5. **USB DAC/amp enumeration** — whether `aplay -l` reliably shows the DAC as a
|
||
distinct, stable card index across reboots on a given mini PC, or whether it
|
||
shifts (some boards' USB enumeration order isn't fully deterministic).
|
||
6. **`Restart=always` behaviour** — whether librespot/spotifyd/raspotify ever get
|
||
into a crash-loop state that `RestartSec=5` doesn't recover from cleanly on a
|
||
headless box nobody is watching.
|
||
7. Neither image has Home Assistant integration of any kind — no MQTT, no HA
|
||
entities, nothing reachable from the smart-home control surface at all. This
|
||
is a deliberate scope decision (a self-contained per-room Spotify Connect
|
||
appliance, matching the "per-room independent" choice this design started
|
||
from), not an oversight — if remote control/monitoring from HA is wanted
|
||
later, that's a separate addition, not assumed here.
|