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:
Executable
+298
@@ -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
|
||||
Reference in New Issue
Block a user