From 573821426cc19b7c4556c7d0e3f2771b6a8bc8e6 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Wed, 1 Jul 2026 04:49:15 -0700 Subject: [PATCH] audio: add PipeWire+BT audio-bridge, mopidy (Qobuz+local), librespot (Spotify Connect) - audio-bridge: alpine + pipewire + pipewire-pulse + wireplumber + bluez + dbus + tini Routes audio from librespot/mopidy/phone-BT to USB DAC (Q5K) via ALSA. Privileged + host network for bluetoothd HCI access. - mopidy: built from python:3.12-slim-bookworm + pip (no ghcr.io dep). Qobuz (auth from developer.qobuz.com) + local files. PA bridge to audio-bridge. - librespot: built from rust:1.85.1 on RPi (cross-compile too slow under qemu). Spotify Connect target named 'Klubhaus'. PA bridge to audio-bridge. Both mopidy and librespot build locally to avoid ghcr.io auth/rate-limit issues. PEQ stays on Q5K side (Qudelix app, 20 bands) for source-independent EQ. --- infra/audio-bridge/Dockerfile | 26 +++++++++++++ infra/audio-bridge/entrypoint.sh | 36 +++++++++++++++++ infra/compose.yaml | 67 ++++++++++++++++++++++++++++++++ infra/librespot/Dockerfile | 51 ++++++++++++++++++++++++ infra/mopidy/Dockerfile | 32 +++++++++++++++ infra/mopidy/mopidy.conf | 38 ++++++++++++++++++ 6 files changed, 250 insertions(+) create mode 100644 infra/audio-bridge/Dockerfile create mode 100644 infra/audio-bridge/entrypoint.sh create mode 100644 infra/librespot/Dockerfile create mode 100644 infra/mopidy/Dockerfile create mode 100644 infra/mopidy/mopidy.conf diff --git a/infra/audio-bridge/Dockerfile b/infra/audio-bridge/Dockerfile new file mode 100644 index 0000000..0ceeef8 --- /dev/null +++ b/infra/audio-bridge/Dockerfile @@ -0,0 +1,26 @@ +# Audio bridge: PipeWire + PulseAudio bridge + BlueZ +# Routes audio from librespot / mopidy / phone-Bluetooth to USB DAC (Qudelix 5K) +# Cross-built for linux/arm64 on x86_64 build host via buildx + qemu. + +FROM alpine:3.20 + +# PipeWire = audio router, handles ALSA sink + bluetooth A2DP sink + PA bridge. +# wireplumber = PipeWire session manager (loads bluetooth modules on demand). +# bluez = Bluetooth stack (needed for "bluetooth target" — phone → Pi audio). +RUN apk add --no-cache \ + pipewire \ + pipewire-pulse \ + wireplumber \ + bluez \ + alsa-utils \ + alsa-plugins-pulse \ + dbus \ + tini + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# D-Bus socket dir for bluetoothd / wireplumber / pipewire +VOLUME ["/run/dbus", "/run/user/0"] + +ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"] diff --git a/infra/audio-bridge/entrypoint.sh b/infra/audio-bridge/entrypoint.sh new file mode 100644 index 0000000..7d05a93 --- /dev/null +++ b/infra/audio-bridge/entrypoint.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# audio-bridge entrypoint +# Starts DBus, BlueZ, WirePlumber, then PipeWire. PA clients (librespot, mopidy) +# connect via pipewire-pulse bridge on TCP localhost:4713. + +set -e + +mkdir -p /run/dbus /run/user/0 +chmod 0755 /run/dbus +chmod 0700 /run/user/0 + +# System DBus (needed by bluetoothd, wireplumber, pipewire) +dbus-daemon --system --fork +sleep 0.5 + +# BlueZ (Bluetooth stack). --compat = legacy mode for older audio devices. +# Disabled if no /dev/bluetooth/rfkill — wireplumber will skip BT modules. +if [ -d /sys/class/bluetooth ]; then + bluetoothd --compat --nodetach & + sleep 0.5 + echo "[audio-bridge] bluetoothd started" +else + echo "[audio-bridge] no bluetooth hardware — skipping bluetoothd" +fi + +# PipeWire runtime env +export XDG_RUNTIME_DIR=/run/user/0 +export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket + +# WirePlumber first (it manages PipeWire session/policy, loads BT modules) +wireplumber & +sleep 1 + +# PipeWire last. The pipewire-pulse package auto-loads the PA bridge module +# so PA clients (librespot, mopidy) can connect via TCP localhost:4713. +exec pipewire diff --git a/infra/compose.yaml b/infra/compose.yaml index 350b93a..0639753 100644 --- a/infra/compose.yaml +++ b/infra/compose.yaml @@ -78,4 +78,71 @@ services: FLASH_DURATION_SECONDS: "14" TZ: America/Los_Angeles + # Audio router: PipeWire + WirePlumber + PulseAudio bridge + BlueZ. + # Routes audio from librespot / mopidy / phone-Bluetooth to USB DAC (Q5K) + # via ALSA. Falls back to RPi 3.5mm / HDMI if no USB DAC. + # PEQ is applied on the DAC side (Q5K's Qudelix app, 20 bands), not here. + audio-bridge: + build: ./audio-bridge + container_name: audio-bridge + restart: unless-stopped + privileged: true + network_mode: host + devices: + - /dev/snd:/dev/snd + volumes: + - /run/dbus:/run/dbus # system DBus (bluetoothd, wireplumber) + - /run/user/0:/run/user/0 # PipeWire runtime dir + cap_add: + - SYS_ADMIN # bluetoothd needs CAP_SYS_ADMIN for HCI + - NET_ADMIN + + # Spotify Connect: phone's Spotify app shows "Klubhaus" as a Connect target. + # Outputs audio to audio-bridge via PulseAudio (localhost:4713). + # Built locally on RPi (cross-compile is too slow under qemu). + librespot: + build: ./librespot + image: infra-librespot:latest + container_name: librespot + restart: unless-stopped + depends_on: + - audio-bridge + network_mode: host + command: > + --name "Klubhaus" + --backend pulseaudio + --bitrate 320 + --initial-volume 100 + --enable-audio-cache + --cache /var/cache/librespot + volumes: + - librespot_cache:/var/cache/librespot + + # Mopidy: Qobuz + local files + internet radio. Web UI on http://rpi:6680. + # Outputs audio to audio-bridge via PulseAudio (localhost:4713). + # Get Qobuz app_id + secret from https://developer.qobuz.com/api/v1/oauth + # Built locally from python:3.12-slim + pip install (no ghcr.io image). + mopidy: + build: ./mopidy + image: infra-mopidy:latest + container_name: mopidy + restart: unless-stopped + depends_on: + - audio-bridge + network_mode: host + ports: + - "6680:6680" + volumes: + - ./mopidy/data:/var/lib/mopidy + - ./mopidy/music:/music # drop FLACs in infra/mopidy/music/ + environment: + TZ: America/Los_Angeles + QOBUZ_APP_ID: "${QOBUZ_APP_ID:-}" + QOBUZ_APP_SECRET: "${QOBUZ_APP_SECRET:-}" + QOBUZ_USERNAME: "${QOBUZ_USERNAME:-}" + QOBUZ_PASSWORD: "${QOBUZ_PASSWORD:-}" + +volumes: + librespot_cache: + diff --git a/infra/librespot/Dockerfile b/infra/librespot/Dockerfile new file mode 100644 index 0000000..47708f2 --- /dev/null +++ b/infra/librespot/Dockerfile @@ -0,0 +1,51 @@ +# Librespot (Spotify Connect) for ARM64 +# Multi-stage: cross-compile Rust app on x86_64 builder, ship slim runtime. +# Audio output: PulseAudio (connect to audio-bridge via TCP localhost:4713). + +FROM rust:1.85.1-bookworm AS builder + +# Cross-compile toolchain for aarch64 +RUN rustup target add aarch64-unknown-linux-gnu +RUN dpkg --add-architecture arm64 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + gcc-aarch64-linux-gnu \ + libpulse-dev:arm64 \ + libasound2-dev:arm64 \ + libssl-dev:arm64 \ + && rm -rf /var/lib/apt/lists/* + +# Build librespot. Pin to a known tag. +ARG LIBRESPOT_VERSION=v0.8.0 +RUN git clone --depth 1 --branch ${LIBRESPOT_VERSION} \ + https://github.com/librespot-org/librespot.git /src +WORKDIR /src +ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc +RUN cargo build --release --target aarch64-unknown-linux-gnu --no-default-features --features="pulseaudio-backend" + +FROM debian:bookworm-slim + +RUN dpkg --add-architecture arm64 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libpulse0:arm64 \ + libasound2:arm64 \ + libssl3:arm64 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /src/target/aarch64-unknown-linux-gnu/release/librespot /usr/local/bin/librespot + +# Cache dir for Spotify credentials +VOLUME ["/var/cache/librespot"] + +# PulseAudio bridge is at localhost:4713 (audio-bridge) +ENV PULSE_SERVER=tcp:127.0.0.1:4713 + +CMD ["librespot", \ + "--name", "Klubhaus", \ + "--backend", "pulseaudio", \ + "--device", "alsa_output.default", \ + "--bitrate", "320", \ + "--enable-audio-cache", \ + "--cache", "/var/cache/librespot"] diff --git a/infra/mopidy/Dockerfile b/infra/mopidy/Dockerfile new file mode 100644 index 0000000..7c690d3 --- /dev/null +++ b/infra/mopidy/Dockerfile @@ -0,0 +1,32 @@ +# Mopidy music server with Qobuz + local files +# Builds from python:3.12-slim + pip (no ghcr.io dependency) + +FROM python:3.12-slim-bookworm + +# System deps for mopidy + extensions + GStreamer playback +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + gstreamer1.0-tools \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + libcairo2 \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libgirepository1.0-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir \ + Mopidy==3.4.2 \ + Mopidy-Qobuz \ + Mopidy-Local + +# Default config (overridable via bind mount) +COPY mopidy.conf /config/mopidy.conf + +VOLUME ["/var/lib/mopidy", "/music"] +EXPOSE 6680 + +# Run as the unprivileged "mopidy" user from python base +USER nobody + +CMD ["mopidy", "--config", "/config/mopidy.conf"] diff --git a/infra/mopidy/mopidy.conf b/infra/mopidy/mopidy.conf new file mode 100644 index 0000000..7a1ecb6 --- /dev/null +++ b/infra/mopidy/mopidy.conf @@ -0,0 +1,38 @@ +[core] +cache_dir = /var/lib/mopidy/cache +config_dir = /config +data_dir = /var/lib/mopidy/data +max_tracklist_length = 10000 + +[logging] +verbosity = info +format = %(levelname)-8s [%(name)s] %(message)s + +# Audio output → PipeWire (via PulseAudio bridge on TCP localhost:4713). +# PipeWire handles routing to Q5K (USB) or fallback to RPi 3.5mm/HDMI. +[audio] +output = pulsesink server=127.0.0.1 name=mopidy +mixer_volume = 100 +buffer_time = 1000 + +[http] +enabled = true +hostname = 0.0.0.0 +port = 6680 +zeroconf = Klubhaus Mopidy + +# Qobuz streaming. Fill in credentials via env vars (set in compose.yaml): +# QOBUZ_APP_ID, QOBUZ_APP_SECRET, QOBUZ_USERNAME, QOBUZ_PASSWORD +[qobuz] +enabled = true +app_id = ${QOBUZ_APP_ID} +app_secret = ${QOBUZ_APP_SECRET} +username = ${QOBUZ_USERNAME} +password = ${QOBUZ_PASSWORD} + +# Local music files (mount /music in compose) +[local] +enabled = true +media_dirs = /music +scan_timeout = 5000 +playlists_dir = /var/lib/mopidy/playlists