feat(audio): HDMI audio works out of the box — pipewire in ISO + router daemon + ELD boot-race heal

Root cause of silent HDMI on kiosk TVs (framework-pt, LG TV):
1) fresh ISOs installed NO audio stack even though the kiosk launcher
   expects PipeWire-Pulse — add pipewire/pipewire-pulse/pipewire-alsa/
   wireplumber/alsa-utils to the rootfs and put the archipelago user in
   'audio' (linger user manager gets no logind seat ACLs on /dev/snd);
2) the kiosk's boot-time Xorg modeset races the i915→HDA audio-component
   bind, the ELD notify is lost, and every HDMI profile stays
   'available: no' forever. Verified live: one off/on modeset re-delivers
   the ELD and WirePlumber immediately switches to HDMI.

New archipelago-audio-router daemon (same splice-from-configs pattern as
the kiosk, ISO + include_str! self-heal): routes the card to the best
available HDMI profile, keeps the default sink on it, migrates live
streams on hot-plug, unmutes (HDMI forced 100% — the TV owns volume), and
re-modesets a connected external output once when no ELD reports a
monitor. bootstrap::ensure_audio_stack() installs packages + router on
already-deployed kiosk nodes via OTA (apt through systemd-run — the
service sandbox keeps /usr and dpkg read-only). main.rs also spawns the
pine satellite keeper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-23 15:28:47 -04:00
parent 852ffc5b18
commit 8a450bb8f8
5 changed files with 298 additions and 1 deletions

View File

@ -39,6 +39,19 @@ const KIOSK_LAUNCHER: &str =
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
// The router daemon handles routing + the ELD re-modeset nudge; this heal
// installs the packages, group membership, script and unit on deployed nodes.
const AUDIO_ROUTER: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
const AUDIO_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
// write the identical file at build time (image-recipe/_archived/
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
@ -794,6 +807,78 @@ pub async fn ensure_kiosk_hardened() {
}
}
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
/// under the lingering user manager — no logind seat, so no udev ACLs on
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
/// nudge) installed and current. No-op on nodes without the kiosk — audio
/// only matters where media plays on an attached display.
pub async fn ensure_audio_stack() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return; // no kiosk → no display audio to route
}
// Package install runs via systemd-run (host_sudo), outside the service
// sandbox — /usr and the dpkg database are read-only in our namespace.
if fs::metadata("/usr/bin/pactl").await.is_err() {
info!("audio: PipeWire stack missing — installing packages");
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
match host_sudo(&[
"apt-get",
"install",
"-y",
"-qq",
"--no-install-recommends",
"pipewire",
"pipewire-pulse",
"pipewire-alsa",
"wireplumber",
"alsa-utils",
])
.await
{
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
Ok(s) => {
warn!("audio: package install exited with {} — will retry next start", s);
return;
}
Err(e) => {
warn!("audio: package install failed: {:#} — will retry next start", e);
return;
}
}
}
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
.await
.unwrap_or(false);
if script_changed {
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
}
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
.await
.unwrap_or(false);
if script_changed || unit_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("audio: daemon-reload failed: {:#}", e);
}
}
if unit_was_missing {
// First install on this node — bring it up now and on every boot.
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
} else if script_changed || unit_changed {
// Content update: restart only if it's running — never re-enable a
// unit an operator deliberately disabled.
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
info!("audio: router updated");
}
}
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// configs shipped individual per-endpoint `location` blocks, so missing
/// endpoints silently fell through to the SPA `index.html` and the frontend

View File

@ -386,6 +386,15 @@ async fn main() -> Result<()> {
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
tokio::spawn(bootstrap::ensure_kiosk_hardened());
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
// nodes (older ISOs shipped no audio stack; the router also heals the
// boot-time ELD race that leaves HDMI silently unavailable).
tokio::spawn(bootstrap::ensure_audio_stack());
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
// DHCP renumbering strands them — HA never re-resolves on its own.
tokio::spawn(api::rpc::wyoming_satellite_keeper());
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());

View File

@ -382,6 +382,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
xorg \
xdotool \
chromium \
pipewire \
pipewire-pulse \
pipewire-alsa \
wireplumber \
alsa-utils \
unclutter \
fonts-liberation \
fonts-noto-color-emoji \
@ -420,7 +425,10 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
# Create archipelago user with password "archipelago"
RUN useradd -m -s /bin/bash -G sudo,dialout archipelago && \
# audio group: PipeWire runs under the lingering user manager (no logind seat
# session), so udev's seat ACLs on /dev/snd never apply — group access is the
# only way the kiosk's audio can open the hardware.
RUN useradd -m -s /bin/bash -G sudo,dialout,audio archipelago && \
echo "archipelago:archipelago" | chpasswd && \
echo "root:archipelago" | chpasswd && \
echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago
@ -2876,6 +2884,35 @@ fi
echo "KIOSKSVC"
} >> "$ARCH_DIR/auto-install.sh"
# Audio router: same splice-from-configs/ pattern as the kiosk (single source
# of truth, also embedded in the binary for bootstrap self-heal). Keeps HDMI
# audio working: profile/default-sink routing + the ELD boot-race re-modeset.
AUDIO_ROUTER_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.sh"
AUDIO_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.service"
for _audio_src in "$AUDIO_ROUTER_SRC" "$AUDIO_SERVICE_SRC"; do
if [ ! -f "$_audio_src" ]; then
echo "ERROR: audio config file missing: $_audio_src" >&2
exit 1
fi
done
if grep -qx 'AUDIOROUTER' "$AUDIO_ROUTER_SRC" || grep -qx 'AUDIOSVC' "$AUDIO_SERVICE_SRC"; then
echo "ERROR: audio config contains a reserved heredoc terminator line (AUDIOROUTER/AUDIOSVC)" >&2
exit 1
fi
{
echo ""
echo "# Audio router — HDMI profile/default-sink follow + ELD boot-race heal."
echo "# Payloads spliced at ISO-build time from image-recipe/configs/ (source of truth)."
echo "cat > /mnt/target/usr/local/bin/archipelago-audio-router <<'AUDIOROUTER'"
cat "$AUDIO_ROUTER_SRC"
echo "AUDIOROUTER"
echo "chmod +x /mnt/target/usr/local/bin/archipelago-audio-router"
echo ""
echo "cat > /mnt/target/etc/systemd/system/archipelago-audio-router.service <<'AUDIOSVC'"
cat "$AUDIO_SERVICE_SRC"
echo "AUDIOSVC"
} >> "$ARCH_DIR/auto-install.sh"
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
# Toggle script: sudo archipelago-kiosk enable|disable|status
@ -3255,6 +3292,7 @@ chroot /mnt/target systemctl enable archipelago-first-boot-secrets.service 2>/de
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true
chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true
chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true
chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true
# Enable claude-api-proxy (create symlink manually — chroot systemctl can fail)
chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \

View File

@ -0,0 +1,20 @@
[Unit]
Description=Archipelago audio router (HDMI hot-plug follow + ELD boot-race heal)
# Talks to the archipelago user's PipeWire (running under the lingering user
# manager) and pokes the kiosk X server for the ELD re-modeset nudge; start
# after both are plausibly up. Missing/inactive units here are harmless.
After=user@1000.service archipelago-kiosk.service
Wants=user@1000.service
ConditionPathExists=/usr/local/bin/archipelago-audio-router
[Service]
Type=simple
User=archipelago
ExecStart=/usr/local/bin/archipelago-audio-router
Restart=always
RestartSec=10
# Polls a few pactl calls every 5s — keep it invisible to the scheduler.
Nice=10
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,145 @@
#!/bin/bash
# Archipelago audio router — keep audio flowing out of the display the user is
# actually looking at.
#
# The kiosk's Chromium plays through PipeWire-Pulse, but WirePlumber's stock
# profile priorities rank the laptop's analog output above HDMI, so a node
# driving a TV plays video sound out of its own tiny speakers (or nowhere).
# ALSA jack detection (ELD) tells us when an HDMI/DP sink has a listening
# monitor; this daemon polls that via the card's profile availability and:
#
# - switches the card to the best *available* HDMI stereo profile (the
# "+input:analog-stereo" combined variant when offered, so the mic keeps
# working), and back to analog when HDMI is unplugged;
# - keeps the default sink pointed at the routed output and migrates any
# live streams so playback follows a hot-plug without a page reload;
# - unmutes the routed sink (HDMI additionally forced to 100% — the TV owns
# the real volume control; analog volume is left where the user set it).
#
# Runs as the archipelago user (systemd system unit with User=archipelago),
# talks only to the user-session PipeWire; polling is a few pactl calls every
# 5s — negligible. Surround profiles are deliberately ignored: stereo is the
# lowest-common-denominator every TV decodes.
# - re-modesets an external output once when it is connected but no ELD
# reports a monitor: the kiosk's boot-time Xorg modeset can beat the
# i915→HDA audio-component bind, the ELD notify is lost, and every HDMI
# profile stays "available: no" forever (no sound, no error). One
# off/on cycle re-delivers the ELD (verified on Framework PT / LG TV).
RUNTIME_DIR="/run/user/$(id -u)"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME_DIR}"
export DISPLAY="${DISPLAY:-:0}"
# The user manager socket-activates pipewire, but after a live package install
# (bootstrap self-heal) nothing has poked it yet — start best-effort.
systemctl --user start pipewire.socket pipewire-pulse.socket 2>/dev/null || true
systemctl --user start wireplumber.service 2>/dev/null || true
LAST_SINK=""
NUDGED_OUTPUTS=""
# Re-deliver a lost ELD by cycling the connected external output once. Guarded:
# only when X answers, only when NO ELD anywhere reports a monitor, and only
# once per connector while it stays connected (a display with no audio support
# never produces an ELD — without the flag we would blank it every pass).
eld_nudge_once() {
# Any ELD already valid → audio path is live; reset the nudge memory.
if grep -q "monitor_present[[:space:]]*1" /proc/asound/card*/eld* 2>/dev/null; then
NUDGED_OUTPUTS=""
return 0
fi
local xrandr_out conn name output mode
xrandr_out=$(xrandr --query 2>/dev/null) || return 0
for conn in /sys/class/drm/card*-*/status; do
[ -e "$conn" ] || continue
[ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue
name=${conn%/status}; name=${name##*/card?-}
case "$name" in eDP*|LVDS*) continue ;; esac
case " $NUDGED_OUTPUTS " in *" $name "*) continue ;; esac
# DRM connector names match the modesetting driver's output names.
output=$(printf '%s\n' "$xrandr_out" | awk -v n="$name" '$1 == n && $2 == "connected" {print $1; exit}')
[ -n "$output" ] || continue
# Keep the mode the kiosk chose (it may have capped a 4K panel);
# --auto only as a fallback.
mode=$(printf '%s\n' "$xrandr_out" | awk -v out="$output" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ { if ($0 ~ /\*/) { print $1; exit } }
active && /^[^[:space:]]/ { active = 0 }')
NUDGED_OUTPUTS="$NUDGED_OUTPUTS $name"
xrandr --output "$output" --off 2>/dev/null || true
sleep 1
if [ -n "$mode" ]; then
xrandr --output "$output" --mode "$mode" 2>/dev/null \
|| xrandr --output "$output" --auto 2>/dev/null || true
else
xrandr --output "$output" --auto 2>/dev/null || true
fi
sleep 2
done
}
route_once() {
local cards_dump card active want sink default_sink
cards_dump=$(pactl list cards 2>/dev/null) || return 0
[ -n "$cards_dump" ] || return 0
# One line per card: "<card>\t<active>\t<wanted-profile>"
# wanted = highest-priority available output:hdmi-stereo* profile, else
# highest-priority available output:analog* profile.
while IFS=$'\t' read -r card active want; do
[ -n "$card" ] && [ -n "$want" ] || continue
if [ "$active" != "$want" ]; then
pactl set-card-profile "$card" "$want" 2>/dev/null || true
fi
done < <(printf '%s\n' "$cards_dump" | awk '
function flush() {
if (card != "") print card "\t" active "\t" (hdmi != "" ? hdmi : analog)
card=""; active=""; hdmi=""; analog=""; hdmi_p=-1; analog_p=-1
}
/^Card #/ { flush() }
/^\tName: / { card=$2 }
/^\t\toutput:/ {
line=$0; sub(/^\t\t/, "", line)
prof=line; sub(/: .*/, "", prof)
prio=0
if (match(line, /priority: [0-9]+/)) prio=substr(line, RSTART+10, RLENGTH-10)+0
avail = (line ~ /available: yes/ || line ~ /availability unknown/)
if (!avail) next
if (prof ~ /^output:hdmi-stereo/) { if (prio > hdmi_p) { hdmi=prof; hdmi_p=prio } }
else if (prof ~ /^output:analog/) { if (prio > analog_p) { analog=prof; analog_p=prio } }
}
/^\tActive Profile: / { active=$3 }
END { flush() }
')
# Point the default sink at HDMI when one exists, else the first sink.
sink=$(pactl list short sinks 2>/dev/null | awk '/hdmi/{print $2; exit}')
[ -n "$sink" ] || sink=$(pactl list short sinks 2>/dev/null | awk 'NR==1{print $2}')
[ -n "$sink" ] || return 0
default_sink=$(pactl get-default-sink 2>/dev/null)
if [ "$sink" != "$default_sink" ] || [ "$sink" != "$LAST_SINK" ]; then
pactl set-default-sink "$sink" 2>/dev/null || true
pactl set-sink-mute "$sink" 0 2>/dev/null || true
case "$sink" in
*hdmi*) pactl set-sink-volume "$sink" 100% 2>/dev/null || true ;;
esac
# Migrate live streams so playing audio follows the hot-plug.
pactl list short sink-inputs 2>/dev/null | while read -r id _; do
pactl move-sink-input "$id" "$sink" 2>/dev/null || true
done
LAST_SINK="$sink"
fi
}
while true; do
eld_nudge_once
route_once
sleep 5
done