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
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""Receives the TV's RTP/L16 audio and plays it into a local sound device.
This is the piece that makes HyperHDR work. HyperHDR's sound-reactive effects
read a *local* capture device, so the TV's audio has to arrive as one. This
script takes the RTP stream and writes it to a playback device; pair it with
a loopback (host/install-loopback.sh) and HyperHDR sees a normal input.
./lgtv-audiocap-receiver.py # auto-detect an output
./lgtv-audiocap-receiver.py --output pacat --device lgtv-audio
./lgtv-audiocap-receiver.py --output aplay --device hw:Loopback,0
./lgtv-audiocap-receiver.py --output - > /tmp/tv.raw
Standard library only, so it runs on anything with Python 3.6 and either
PulseAudio/PipeWire (pacat) or ALSA (aplay) installed.
"""
import argparse
import array
import errno
import os
import shutil
import signal
import socket
import struct
import subprocess
import sys
import time
RTP_HEADER_BYTES = 12
RTP_PAYLOAD_TYPE = 96 # matches the TV sink
DEFAULT_PORT = 5004
def log(message):
sys.stderr.write(message + "\n")
sys.stderr.flush()
# ---------------------------------------------------------------------------
# RTP
# ---------------------------------------------------------------------------
class RtpPacket(object):
__slots__ = ("sequence", "timestamp", "ssrc", "payload", "payload_type")
def __init__(self, sequence, timestamp, ssrc, payload_type, payload):
self.sequence = sequence
self.timestamp = timestamp
self.ssrc = ssrc
self.payload_type = payload_type
self.payload = payload
def parse_rtp(data):
"""Returns an RtpPacket, or None if this is not RTP we can use."""
if len(data) < RTP_HEADER_BYTES:
return None
byte0, byte1, sequence, timestamp, ssrc = struct.unpack("!BBHII",
data[:RTP_HEADER_BYTES])
if (byte0 >> 6) != 2: # version
return None
offset = RTP_HEADER_BYTES + (byte0 & 0x0F) * 4 # CSRC list
if byte0 & 0x10: # extension header
if len(data) < offset + 4:
return None
ext_words = struct.unpack("!H", data[offset + 2:offset + 4])[0]
offset += 4 + ext_words * 4
end = len(data)
if byte0 & 0x20: # padding: the last byte counts the padding bytes
pad = data[-1] if isinstance(data[-1], int) else ord(data[-1])
if pad and pad <= end - offset:
end -= pad
if offset >= end:
return None
return RtpPacket(sequence, timestamp, ssrc, byte1 & 0x7F, data[offset:end])
def to_native_pcm(payload):
"""L16 is big-endian; sound devices want the host's order."""
samples = array.array("h")
if len(payload) % 2:
payload = payload[:-1]
samples.frombytes(payload)
if sys.byteorder == "little":
samples.byteswap()
return samples.tobytes()
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def detect_output():
"""Pick a player. PulseAudio/PipeWire first: it needs no card set up."""
if shutil.which("pacat"):
try:
subprocess.check_output(["pactl", "info"], stderr=subprocess.DEVNULL,
timeout=3)
return "pacat"
except Exception:
pass
if shutil.which("aplay"):
return "aplay"
if shutil.which("pacat"):
return "pacat"
if shutil.which("ffplay"):
return "ffplay"
return "-"
def build_command(kind, device, rate, channels, latency_ms):
if kind == "pacat":
cmd = ["pacat", "--playback", "--format=s16le",
"--rate=%d" % rate, "--channels=%d" % channels,
"--stream-name=LG TV Audio Cap",
"--latency-msec=%d" % latency_ms]
if device:
cmd += ["--device=%s" % device]
return cmd
if kind == "aplay":
cmd = ["aplay", "-t", "raw", "-f", "S16_LE",
"-r", str(rate), "-c", str(channels), "-q",
# aplay's default buffer is far larger than we want in a chain
# that already has a jitter buffer in front of it.
"--buffer-time=%d" % (latency_ms * 1000)]
if device:
cmd += ["-D", device]
return cmd
if kind == "ffplay":
return ["ffplay", "-hide_banner", "-loglevel", "error", "-nodisp",
"-autoexit", "-f", "s16le", "-ar", str(rate),
"-ac", str(channels), "-i", "pipe:0"]
raise ValueError("unknown output %r" % kind)
class Output(object):
"""Where the audio goes. Restarts the player if it dies."""
def __init__(self, kind, device, rate, channels, latency_ms):
self.kind = kind
self.device = device
self.rate = rate
self.channels = channels
self.latency_ms = latency_ms
self.process = None
self.stream = None
self.restarts = 0
self._open()
def _open(self):
if self.kind == "-":
self.stream = getattr(sys.stdout, "buffer", sys.stdout)
return
cmd = build_command(self.kind, self.device, self.rate, self.channels,
self.latency_ms)
log("playing into: %s" % " ".join(cmd))
self.process = subprocess.Popen(cmd, stdin=subprocess.PIPE)
self.stream = self.process.stdin
def write(self, data):
try:
self.stream.write(data)
self.stream.flush()
return True
except (IOError, OSError, ValueError) as exc:
if getattr(exc, "errno", None) == errno.EINTR:
return True
if self.kind == "-":
raise
log("output died (%s); restarting" % exc)
self.close()
self.restarts += 1
time.sleep(0.5)
self._open()
return False
def close(self):
if self.process:
try:
if self.process.stdin:
self.process.stdin.close()
except Exception:
pass
try:
self.process.terminate()
self.process.wait(timeout=2)
except Exception:
pass
self.process = None
self.stream = None
# ---------------------------------------------------------------------------
# Receiver
# ---------------------------------------------------------------------------
def open_socket(bind, port, group, iface, rcvbuf):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except OSError:
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
except OSError:
pass
sock.bind(("" if group else bind, port))
if group:
# Joining on the wildcard interface lets the kernel choose; an explicit
# one is needed on hosts with several networks.
local = socket.inet_aton(iface) if iface else struct.pack("=I", socket.INADDR_ANY)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(group) + local)
log("joined multicast group %s" % group)
return sock
def run(args):
frame_bytes = 2 * args.channels
sock = open_socket(args.bind, args.port, args.multicast, args.iface,
args.rcvbuf)
sock.settimeout(0.2)
out = Output(args.output, args.device, args.rate, args.channels,
args.latency_ms)
# A block of silence sized to roughly one packet, reused for gap filling.
gap_frames = max(1, int(args.rate * 0.01))
silence = b"\x00" * (gap_frames * frame_bytes)
# Prime the device so the first real packet is not chasing an empty buffer.
prime = int(args.rate * args.prebuffer_ms / 1000.0)
if prime and args.output != "-":
out.write(b"\x00" * (prime * frame_bytes))
stats = {"packets": 0, "bytes": 0, "lost": 0, "late": 0, "resets": 0}
expected = None
ssrc = None
last_packet = time.time()
last_stats = time.time()
running = [True]
def stop(signum, frame):
running[0] = False
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
log("listening on %s:%d for %d Hz %d-channel L16"
% (args.multicast or args.bind, args.port, args.rate, args.channels))
while running[0]:
try:
data, sender = sock.recvfrom(4096)
except socket.timeout:
now = time.time()
# Keep the device fed while the TV is quiet or gone, otherwise the
# player underruns and HyperHDR's effect freezes on the last frame
# instead of fading out.
if args.fill_silence and args.output != "-" and expected is not None:
out.write(silence)
if expected is not None and now - last_packet > args.reset_after:
log("no audio for %.0f s; waiting for the stream to come back"
% args.reset_after)
expected = None
continue
except OSError as exc:
if exc.errno == errno.EINTR:
continue
raise
packet = parse_rtp(data)
if packet is None or packet.payload_type != args.payload_type:
continue
if ssrc is None or packet.ssrc != ssrc:
if ssrc is not None:
log("stream restarted (new SSRC from %s)" % sender[0])
stats["resets"] += 1
else:
log("stream started from %s" % sender[0])
ssrc = packet.ssrc
expected = packet.sequence
# 16-bit sequence numbers wrap; compare in that space.
delta = (packet.sequence - expected) & 0xFFFF
if delta == 0:
pass
elif delta < args.max_gap:
# Lost packets. Substitute silence so playback keeps its timing
# rather than jumping forward.
missing = delta
stats["lost"] += missing
payload_frames = len(packet.payload) // frame_bytes
if payload_frames:
out.write(b"\x00" * (payload_frames * frame_bytes) * missing)
else:
# Either a very late packet or a huge jump. Late ones would play
# out of order, so drop them and resynchronise on a jump.
if delta > 0xFFFF - args.max_gap:
stats["late"] += 1
continue
log("sequence jumped by %d; resynchronising" % delta)
expected = packet.sequence
out.write(to_native_pcm(packet.payload))
expected = (packet.sequence + 1) & 0xFFFF
stats["packets"] += 1
stats["bytes"] += len(packet.payload)
last_packet = time.time()
if args.stats and last_packet - last_stats >= args.stats:
last_stats = last_packet
seconds = stats["bytes"] / float(frame_bytes * args.rate)
log("%d packets, %.1f s audio, %d lost, %d late, %d restarts"
% (stats["packets"], seconds, stats["lost"], stats["late"],
stats["resets"] + out.restarts))
log("stopping")
out.close()
sock.close()
return 0
def main():
parser = argparse.ArgumentParser(
description=__doc__.split("\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__[__doc__.index("This is the piece"):])
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
help="UDP port to listen on (default %d)" % DEFAULT_PORT)
parser.add_argument("--bind", default="0.0.0.0",
help="address to bind (default all interfaces)")
parser.add_argument("--multicast", default=None,
help="multicast group to join, if the TV sends to one")
parser.add_argument("--iface", default=None,
help="local address to join the multicast group on")
parser.add_argument("--rate", type=int, default=48000,
help="sample rate the TV is sending (default 48000)")
parser.add_argument("--channels", type=int, default=2,
help="channel count the TV is sending (default 2)")
parser.add_argument("--payload-type", type=int, default=RTP_PAYLOAD_TYPE,
help="RTP payload type to accept (default %d)"
% RTP_PAYLOAD_TYPE)
parser.add_argument("--output", default="auto",
choices=["auto", "pacat", "aplay", "ffplay", "-"],
help="how to play the audio; '-' writes raw PCM to stdout")
parser.add_argument("--device", default=None,
help="sink or PCM to play into, e.g. lgtv-audio or hw:Loopback,0")
parser.add_argument("--latency-ms", type=int, default=80,
help="playback buffer to ask the device for (default 80)")
parser.add_argument("--prebuffer-ms", type=int, default=60,
help="silence written before the first packet (default 60)")
parser.add_argument("--max-gap", type=int, default=200,
help="packets of loss to paper over before resynchronising")
parser.add_argument("--reset-after", type=float, default=5.0,
help="seconds of silence before the stream is considered gone")
parser.add_argument("--no-fill-silence", dest="fill_silence",
action="store_false",
help="do not write silence while no packets arrive")
parser.add_argument("--rcvbuf", type=int, default=1 << 20,
help="socket receive buffer in bytes")
parser.add_argument("--stats", type=float, default=30.0,
help="seconds between statistics lines, 0 to disable")
args = parser.parse_args()
if args.output == "auto":
args.output = detect_output()
if args.output == "-":
log("no pacat, aplay or ffplay found; writing raw PCM to stdout")
if args.output == "-" and os.isatty(sys.stdout.fileno()):
parser.error("refusing to write raw PCM to a terminal; redirect stdout")
return run(args)
if __name__ == "__main__":
sys.exit(main())