Audio capture and streaming app for webOS 5/6

Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.

  native/       the service: capture backends (PulseAudio, ALSA, exec,
                test tone, all dlopen-based), DSP, and one file per sink
  frontend/     D-pad driven UI at a fixed 1920x1080
  servicefiles/ native service manifest plus the boot script
  host/         RTP receiver and the loopback installer for the HyperHDR
                machine
  tools/        build/package, asset generation, Homebrew Channel
                manifest, on-TV probe
  test/         host-side suites: FlatBuffers and RTP verified against
                real decoders, the engine end to end, the page in jsdom

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rene Kievits
2026-08-26 10:21:00 +02:00
co-authored by Claude Opus 5
commit 7529a60650
73 changed files with 12826 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env bash
# Sets up the sound device HyperHDR will listen to, and optionally installs the
# receiver as a service.
#
# HyperHDR's sound-reactive effects read a local capture device. This script
# creates one that is fed by the TV:
#
# TV ──RTP──> lgtv-audiocap-receiver.py ──> loopback playback
# │
# HyperHDR <─────┘ loopback capture
#
# Two ways to make that loopback:
#
# alsa snd-aloop, a kernel module that pairs a playback device with a
# capture device. HyperHDR enumerates ALSA devices, so it sees this
# one directly. This is the default and the one to prefer.
# pulse A null sink whose monitor is the capture side. Only useful if
# HyperHDR is reaching audio through the PulseAudio ALSA plugin.
#
# sudo ./install-loopback.sh # snd-aloop, no service
# sudo ./install-loopback.sh --install-service # ... and run at boot
# sudo ./install-loopback.sh --method pulse
# sudo ./install-loopback.sh --uninstall
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
METHOD=alsa
NAME=lgtv-audio
PORT=5004
RATE=48000
CHANNELS=2
CARD_INDEX=10
PREFIX=/usr/local
SERVICE_USER=""
INSTALL_SERVICE=0
UNINSTALL=0
RULES_FILE=/etc/udev/rules.d/89-lgtv-audiocap-loopback.rules
MODPROBE_FILE=/etc/modprobe.d/lgtv-audiocap-loopback.conf
MODULES_FILE=/etc/modules-load.d/lgtv-audiocap-loopback.conf
UNIT_FILE=/etc/systemd/system/lgtv-audiocap.service
usage() {
# The header comment is the help text, up to the first line of code.
awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0"
cat <<EOF
Options:
--method alsa|pulse loopback to create (default: $METHOD)
--name NAME PulseAudio sink name (default: $NAME)
--port PORT UDP port the TV sends to (default: $PORT)
--rate HZ sample rate the TV sends (default: $RATE)
--channels N channel count the TV sends (default: $CHANNELS)
--card-index N ALSA card index for snd-aloop (default: $CARD_INDEX)
--prefix DIR where to install the receiver (default: $PREFIX)
--user NAME user the service runs as (default: the invoking user)
--install-service install and start a systemd unit for the receiver
--uninstall undo everything this script installs
-h, --help this text
EOF
}
say() { printf '%s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
need_root() {
[ "$(id -u)" -eq 0 ] || die "run this with sudo"
}
while [ $# -gt 0 ]; do
case "$1" in
--method) METHOD="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--rate) RATE="$2"; shift 2 ;;
--channels) CHANNELS="$2"; shift 2 ;;
--card-index) CARD_INDEX="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--install-service) INSTALL_SERVICE=1; shift ;;
--uninstall) UNINSTALL=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option $1 (try --help)" ;;
esac
done
case "$METHOD" in
alsa|pulse) ;;
*) die "--method must be alsa or pulse" ;;
esac
# The user whose PulseAudio session we touch, and who the service runs as.
if [ -z "$SERVICE_USER" ]; then
SERVICE_USER="${SUDO_USER:-$(id -un)}"
fi
# ---------------------------------------------------------------------------
uninstall() {
need_root
step "Removing the ALSA loopback"
rm -f "$MODPROBE_FILE" "$MODULES_FILE" "$RULES_FILE"
modprobe -r snd-aloop 2>/dev/null || say "snd-aloop is in use; it will go at the next reboot"
step "Removing the service"
if [ -f "$UNIT_FILE" ]; then
systemctl disable --now lgtv-audiocap.service 2>/dev/null || true
rm -f "$UNIT_FILE"
systemctl daemon-reload
fi
rm -f "$PREFIX/bin/lgtv-audiocap-receiver.py"
step "PulseAudio"
say "If you used --method pulse, remove the module-null-sink line from"
say " ~/.config/pulse/default.pa (user $SERVICE_USER)"
say "and unload it now with: pactl unload-module module-null-sink"
say ""
say "Done."
}
setup_alsa() {
need_root
step "Loading snd-aloop"
cat > "$MODPROBE_FILE" <<EOF
# LG TV Audio Cap: one loopback card, one substream, kept out of the way of
# the real sound cards at a high index.
options snd-aloop index=$CARD_INDEX pcm_substreams=1 id=Loopback
EOF
echo snd-aloop > "$MODULES_FILE"
if ! lsmod 2>/dev/null | grep -q '^snd_aloop'; then
modprobe snd-aloop || die "could not load snd-aloop; is alsa-utils / the kernel module package installed?"
else
say "snd-aloop already loaded (reboot to pick up the new options)"
fi
# PulseAudio grabs every card it finds. Left alone it opens the loopback,
# which is at best a wasted device and at worst a fight over the substream.
if command -v pulseaudio >/dev/null 2>&1 || command -v pipewire >/dev/null 2>&1; then
step "Hiding the loopback from PulseAudio/PipeWire"
cat > "$RULES_FILE" <<'EOF'
# LG TV Audio Cap: the loopback belongs to the receiver and HyperHDR, not to
# the desktop sound server.
ATTRS{id}=="Loopback", ENV{PULSE_IGNORE}="1", ENV{ACP_IGNORE}="1"
EOF
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=sound 2>/dev/null || true
fi
PLAY_DEVICE="hw:Loopback,0,0"
CAPTURE_DEVICE="hw:Loopback,1,0"
step "Checking the loopback"
if aplay -l 2>/dev/null | grep -q 'Loopback'; then
aplay -l | grep -i loopback | sed 's/^/ /'
else
say " aplay does not list the Loopback card yet; a reboot will fix it"
fi
}
setup_pulse() {
step "Creating the null sink"
local as_user=(sudo -u "$SERVICE_USER")
[ "$(id -un)" = "$SERVICE_USER" ] && as_user=()
if ! "${as_user[@]}" pactl info >/dev/null 2>&1; then
die "no PulseAudio/PipeWire session for user $SERVICE_USER"
fi
if "${as_user[@]}" pactl list short sinks | grep -q "^[0-9]*[[:space:]]*$NAME"; then
say "sink $NAME already exists"
else
"${as_user[@]}" pactl load-module module-null-sink \
sink_name="$NAME" \
sink_properties="device.description='LG TV Audio Cap'" \
rate="$RATE" channels="$CHANNELS" >/dev/null
say "created sink $NAME"
fi
local conf="/home/$SERVICE_USER/.config/pulse/default.pa"
[ -d "/home/$SERVICE_USER" ] || conf=""
if [ -n "$conf" ]; then
mkdir -p "$(dirname "$conf")"
if [ ! -f "$conf" ]; then
printf '.include /etc/pulse/default.pa\n' > "$conf"
fi
if ! grep -q "sink_name=$NAME" "$conf"; then
cat >> "$conf" <<EOF
# LG TV Audio Cap
load-module module-null-sink sink_name=$NAME sink_properties=device.description='LG_TV_Audio_Cap' rate=$RATE channels=$CHANNELS
EOF
chown "$SERVICE_USER" "$conf" 2>/dev/null || true
say "persisted in $conf"
fi
fi
PLAY_DEVICE="$NAME"
CAPTURE_DEVICE="$NAME.monitor"
}
install_service() {
need_root
step "Installing the receiver"
install -Dm755 "$HERE/lgtv-audiocap-receiver.py" "$PREFIX/bin/lgtv-audiocap-receiver.py"
say "installed $PREFIX/bin/lgtv-audiocap-receiver.py"
local output device
if [ "$METHOD" = alsa ]; then
output=aplay
device="$PLAY_DEVICE"
else
output=pacat
device="$PLAY_DEVICE"
fi
cat > "$UNIT_FILE" <<EOF
[Unit]
Description=LG TV Audio Cap receiver
Documentation=https://github.com/webosbrew
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
SupplementaryGroups=audio
ExecStart=$PREFIX/bin/lgtv-audiocap-receiver.py \\
--port $PORT --rate $RATE --channels $CHANNELS \\
--output $output --device $device
Restart=always
RestartSec=2
# The receiver holds a socket and a pipe and nothing else.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now lgtv-audiocap.service
say "started lgtv-audiocap.service"
systemctl --no-pager --lines=5 status lgtv-audiocap.service || true
}
summary() {
step "What to do next"
cat <<EOF
On the TV, in the Audio Cap app:
Outputs > HyperHDR audio (RTP/L16) > on
Receiver address: this machine's IP
UDP port: $PORT
In HyperHDR:
Sound capture (or the LED device's music effect) > input device
$CAPTURE_DEVICE
then pick a music effect such as "Waves" or "Spectrum".
If the receiver is not running as a service, start it by hand:
$HERE/lgtv-audiocap-receiver.py --port $PORT --rate $RATE \\
--channels $CHANNELS --output $( [ "$METHOD" = alsa ] && echo aplay || echo pacat ) \\
--device $PLAY_DEVICE
Check that audio is arriving at all:
$HERE/lgtv-audiocap-receiver.py --port $PORT --output - | \\
aplay -f S16_LE -r $RATE -c $CHANNELS -
EOF
}
# ---------------------------------------------------------------------------
if [ "$UNINSTALL" -eq 1 ]; then
uninstall
exit 0
fi
say "LG TV Audio Cap — host setup"
say "method: $METHOD, user: $SERVICE_USER, port: $PORT, format: $RATE Hz x $CHANNELS"
if [ "$METHOD" = alsa ]; then
setup_alsa
else
setup_pulse
fi
if [ "$INSTALL_SERVICE" -eq 1 ]; then
install_service
fi
summary