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>
299 lines
9.0 KiB
Bash
Executable File
299 lines
9.0 KiB
Bash
Executable File
#!/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
|