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
Executable
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# Builds the native service, assembles the package layout and produces the ipk.
#
# ./tools/build.sh # native + stage + package
# ./tools/build.sh native # cross-compile the service only
# ./tools/build.sh package # assemble and run ares-package
# ./tools/build.sh install # ares-install the ipk on the TV
# ./tools/build.sh launch # ares-launch the app
# ./tools/build.sh logs # tail the service log over ssh
# ./tools/build.sh clean
#
# Needs the webOS NDK (arm-webos-linux-gnueabi buildroot SDK) for the native
# part and ares-cli for the packaging part. Point WEBOS_SDK at the SDK if it is
# not in the usual place; set DEVICE to the ares device name (default: tv).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
APP_ID=org.webosbrew.audiocap
SERVICE_ID=$APP_ID.service
BINARY=audiocap-service
WEBOS_SDK="${WEBOS_SDK:-$HOME/arm-webos-linux-gnueabi_sdk-buildroot}"
DEVICE="${DEVICE:-tv}"
BUILD_DIR="$ROOT/build"
STAGE_APP="$BUILD_DIR/stage/app"
STAGE_SERVICE="$BUILD_DIR/stage/service"
OUT_DIR="$ROOT/out"
say() { printf '%s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
version() {
python3 - "$ROOT/frontend/appinfo.json" <<'EOF'
import json, sys
print(json.load(open(sys.argv[1]))["version"])
EOF
}
check_sdk() {
local toolchain="$WEBOS_SDK/share/buildroot/toolchainfile.cmake"
if [ ! -f "$toolchain" ]; then
cat >&2 <<EOF
error: no webOS SDK at $WEBOS_SDK
Download and unpack the buildroot NDK, then point WEBOS_SDK at it:
https://github.com/openlgtv/buildroot-nc4/releases
tar xf arm-webos-linux-gnueabi_sdk-buildroot.tar.gz -C \$HOME
\$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh
WEBOS_SDK=\$HOME/arm-webos-linux-gnueabi_sdk-buildroot ./tools/build.sh
EOF
exit 1
fi
command -v cmake >/dev/null 2>&1 || die "cmake is not installed"
echo "$toolchain"
}
check_ares() {
command -v ares-package >/dev/null 2>&1 || cat >&2 <<'EOF'
error: ares-package is not on PATH
npm install -g @webosose/ares-cli
Then register the TV once (developer mode or the Homebrew Channel's ssh):
ares-setup-device --add tv --info "{'host':'192.168.1.20','port':9922,'username':'root'}"
EOF
command -v ares-package >/dev/null 2>&1
}
build_native() {
local toolchain
toolchain="$(check_sdk)"
step "Cross-compiling the service"
cmake -S native -B "$BUILD_DIR/native" \
-DCMAKE_TOOLCHAIN_FILE="$toolchain" \
-DCMAKE_BUILD_TYPE=Release
cmake --build "$BUILD_DIR/native" --parallel
local binary="$BUILD_DIR/native/$BINARY"
[ -f "$binary" ] || die "the build produced no $BINARY"
say "built $binary"
file "$binary" 2>/dev/null | sed 's/^/ /' || true
}
stage() {
step "Staging the package"
rm -rf "$BUILD_DIR/stage"
mkdir -p "$STAGE_APP" "$STAGE_SERVICE"
cp -R "$ROOT/frontend/." "$STAGE_APP/"
# The mock only exists so the UI can be opened in a desktop browser.
rm -f "$STAGE_APP/js/mock.js"
python3 - "$STAGE_APP/index.html" <<'EOF'
import re, sys
path = sys.argv[1]
html = open(path).read()
html = re.sub(r'\s*<script src="js/mock\.js"></script>', '', html)
open(path, "w").write(html)
EOF
cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/"
cp "$ROOT/servicefiles/package.json" "$STAGE_SERVICE/"
cp "$ROOT/servicefiles/audiocapautostart" "$STAGE_SERVICE/"
chmod +x "$STAGE_SERVICE/audiocapautostart"
local binary="$BUILD_DIR/native/$BINARY"
[ -f "$binary" ] || die "no service binary; run './tools/build.sh native' first"
cp "$binary" "$STAGE_SERVICE/$BINARY"
chmod +x "$STAGE_SERVICE/$BINARY"
say "app: $STAGE_APP"
say "service: $STAGE_SERVICE"
}
package() {
check_ares || exit 1
step "Packaging"
mkdir -p "$OUT_DIR"
rm -f "$OUT_DIR"/${APP_ID}_*.ipk
ares-package "$STAGE_APP" "$STAGE_SERVICE" -o "$OUT_DIR"
local ipk
ipk="$(ls -t "$OUT_DIR"/${APP_ID}_*.ipk | head -1)"
say ""
say "$ipk"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$ipk" | sed 's/^/ sha256 /'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$ipk" | sed 's/^/ sha256 /'
fi
}
latest_ipk() {
ls -t "$OUT_DIR"/${APP_ID}_*.ipk 2>/dev/null | head -1
}
install_ipk() {
local ipk
ipk="$(latest_ipk)" || true
[ -n "$ipk" ] || die "no ipk in $OUT_DIR; run './tools/build.sh' first"
step "Installing $ipk on device '$DEVICE'"
ares-install --device "$DEVICE" "$ipk"
say ""
say "The service needs root to reach the TV's audio devices. Either open the"
say "app and press 'Grant root access', or run it here:"
say " ares-shell --device $DEVICE -r \\"
say " '/media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/elevate-service $SERVICE_ID'"
}
launch() {
step "Launching on '$DEVICE'"
ares-launch --device "$DEVICE" "$APP_ID"
}
logs() {
step "Service log from '$DEVICE' (ctrl-c to stop)"
# The service keeps its own ring buffer, but journald/pmlog has the crashes.
ares-shell --device "$DEVICE" -r \
"tail -f /var/log/messages 2>/dev/null | grep -i audiocap || journalctl -f | grep -i audiocap"
}
clean() {
step "Cleaning"
rm -rf "$BUILD_DIR" "$OUT_DIR"
say "removed build/ and out/"
}
case "${1:-all}" in
all) build_native; stage; package ;;
native) build_native ;;
stage) stage ;;
package) stage; package ;;
install) install_ipk ;;
launch) launch ;;
logs) logs ;;
clean) clean ;;
version) version ;;
-h|--help)
awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" ;;
*) die "unknown command '$1' (try --help)" ;;
esac
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Generates the app icons and the splash screen.
Written against nothing but the standard library on purpose: the icons are
part of the package, so regenerating them must not depend on Pillow being
installed or on a checked-in binary nobody can edit.
python3 tools/make-assets.py
Everything is drawn supersampled and boxed down, which is what gives the
rounded corners and bar tops their edges.
"""
import os
import struct
import sys
import zlib
HERE = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(HERE, os.pardir, "frontend", "assets")
# Same palette as the UI.
BACKDROP = (11, 14, 19)
TILE_TOP = (18, 32, 52)
TILE_BOTTOM = (13, 17, 25)
BAR_TOP = (74, 163, 255)
BAR_BOTTOM = (31, 77, 118)
ACCENT = (87, 217, 138)
TEXT = (200, 214, 232)
# Fraction of the drawing height each bar reaches. Reads as a level meter
# caught mid-song rather than a generic equaliser.
BARS = [0.34, 0.62, 0.95, 0.48, 0.78, 0.40]
class Canvas:
"""RGBA pixel buffer with the handful of primitives this needs."""
def __init__(self, width, height, fill=(0, 0, 0, 0)):
self.w = width
self.h = height
self.px = bytearray(fill * width * height) if len(fill) == 4 else None
if self.px is None:
self.px = bytearray((fill + (255,)) * width * height)
def blend(self, x, y, colour, alpha=255):
if x < 0 or y < 0 or x >= self.w or y >= self.h or alpha <= 0:
return
i = (y * self.w + x) * 4
if alpha >= 255:
self.px[i:i + 4] = bytes(colour) + b"\xff"
return
a = alpha / 255.0
for c in range(3):
self.px[i + c] = int(self.px[i + c] * (1 - a) + colour[c] * a)
self.px[i + 3] = max(self.px[i + 3], alpha)
def rect(self, x0, y0, x1, y1, colour):
for y in range(max(0, int(y0)), min(self.h, int(y1))):
for x in range(max(0, int(x0)), min(self.w, int(x1))):
self.blend(x, y, colour)
def rounded_rect(self, x0, y0, x1, y1, radius, top, bottom=None):
"""Filled rounded rectangle, optionally with a vertical gradient."""
bottom = bottom if bottom is not None else top
height = max(1, y1 - y0 - 1)
for y in range(max(0, int(y0)), min(self.h, int(y1))):
# Fractional edges mean y can sit just outside the span; clamping
# keeps the gradient from extrapolating past either colour.
t = min(1.0, max(0.0, (y - y0) / height))
colour = tuple(int(top[c] + (bottom[c] - top[c]) * t) for c in range(3))
for x in range(max(0, int(x0)), min(self.w, int(x1))):
# Only the corners need the distance test.
cx = None
if x < x0 + radius and y < y0 + radius:
cx, cy = x0 + radius, y0 + radius
elif x >= x1 - radius and y < y0 + radius:
cx, cy = x1 - radius - 1, y0 + radius
elif x < x0 + radius and y >= y1 - radius:
cx, cy = x0 + radius, y1 - radius - 1
elif x >= x1 - radius and y >= y1 - radius:
cx, cy = x1 - radius - 1, y1 - radius - 1
if cx is not None:
dx, dy = x - cx, y - cy
if dx * dx + dy * dy > radius * radius:
continue
self.blend(x, y, colour)
def downsample(self, factor):
"""Box filter. This is the whole anti-aliasing strategy."""
w, h = self.w // factor, self.h // factor
out = Canvas(w, h)
area = factor * factor
for y in range(h):
for x in range(w):
r = g = b = a = 0
for sy in range(factor):
row = ((y * factor + sy) * self.w + x * factor) * 4
for sx in range(factor):
i = row + sx * 4
r += self.px[i]
g += self.px[i + 1]
b += self.px[i + 2]
a += self.px[i + 3]
i = (y * w + x) * 4
out.px[i] = r // area
out.px[i + 1] = g // area
out.px[i + 2] = b // area
out.px[i + 3] = a // area
return out
def paste(self, other, x0, y0):
for y in range(other.h):
for x in range(other.w):
i = (y * other.w + x) * 4
self.blend(x0 + x, y0 + y, tuple(other.px[i:i + 3]), other.px[i + 3])
def write_png(self, path):
raw = bytearray()
stride = self.w * 4
for y in range(self.h):
raw.append(0) # filter: none
raw += self.px[y * stride:(y + 1) * stride]
def chunk(kind, data):
head = struct.pack(">I", len(data)) + kind + data
return head + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(bytes(raw), 9))
png += chunk(b"IEND", b"")
with open(path, "wb") as fh:
fh.write(png)
# 5x7, only the letters the splash needs.
GLYPHS = {
"A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
"C": ["01110", "10001", "10000", "10000", "10000", "10001", "01110"],
"D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
"I": ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
"O": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
"P": ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
"U": ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
" ": ["00000"] * 7,
}
def text_width(text, scale, spacing):
return len(text) * (5 * scale + spacing) - spacing
def draw_text(canvas, text, x0, y0, scale, spacing, colour):
x = x0
for ch in text:
rows = GLYPHS[ch]
for ry, row in enumerate(rows):
for rx, on in enumerate(row):
if on == "1":
canvas.rect(x + rx * scale, y0 + ry * scale,
x + (rx + 1) * scale, y0 + (ry + 1) * scale, colour)
x += 5 * scale + spacing
def render_tile(size, supersample=4):
"""The logo: a rounded tile with a level meter on it."""
s = size * supersample
c = Canvas(s, s)
radius = int(s * 0.22)
c.rounded_rect(0, 0, s, s, radius, TILE_TOP, TILE_BOTTOM)
margin = s * 0.18
inner_w = s - margin * 2
inner_h = s - margin * 2
gap = inner_w / (len(BARS) * 4)
bar_w = (inner_w - gap * (len(BARS) - 1)) / len(BARS)
bar_radius = max(1, int(bar_w * 0.35))
base = s - margin
for i, height in enumerate(BARS):
x0 = margin + i * (bar_w + gap)
top = base - inner_h * height
colour_top = ACCENT if height > 0.9 else BAR_TOP
c.rounded_rect(x0, top, x0 + bar_w, base, bar_radius, colour_top, BAR_BOTTOM)
return c.downsample(supersample)
def render_splash(width=1920, height=1080):
c = Canvas(width, height, BACKDROP)
tile = render_tile(300, supersample=2)
c.paste(tile, (width - tile.w) // 2, height // 2 - 260)
scale, spacing = 10, 10
label = "AUDIO CAP"
draw_text(c, label, (width - text_width(label, scale, spacing)) // 2,
height // 2 + 120, scale, spacing, TEXT)
return c
def main():
os.makedirs(ASSETS, exist_ok=True)
targets = [
("icon.png", lambda: render_tile(80)),
("largeIcon.png", lambda: render_tile(130)),
("splash.png", render_splash),
]
for name, build in targets:
path = os.path.join(ASSETS, name)
build().write_png(path)
print("wrote %s (%d bytes)" % (os.path.relpath(path), os.path.getsize(path)))
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Writes the Homebrew Channel manifest for a built ipk.
The Homebrew Channel installs apps from a manifest that points at the ipk and
carries its hash. Publishing means putting this file somewhere stable (a GitHub
release asset, or the repository itself) and submitting the URL to
webosbrew/repo.
python3 tools/make-manifest.py \\
--base-url https://github.com/you/lgtv-audio-cap/releases/download/v1.0.0
Defaults to the newest ipk in out/ and writes out/manifest.json.
"""
import argparse
import glob
import hashlib
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, os.pardir)
SOURCE_URL = "https://github.com/webosbrew/lgtv-audio-cap"
def newest_ipk(directory):
matches = sorted(glob.glob(os.path.join(directory, "*.ipk")),
key=os.path.getmtime, reverse=True)
return matches[0] if matches else None
def sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--ipk", default=None, help="path to the ipk")
parser.add_argument("--base-url", default=None,
help="URL the ipk and icon will be served from")
parser.add_argument("--source-url", default=SOURCE_URL,
help="project page shown in the Homebrew Channel")
parser.add_argument("--out", default=os.path.join(ROOT, "out", "manifest.json"))
args = parser.parse_args()
appinfo = json.load(open(os.path.join(ROOT, "frontend", "appinfo.json")))
ipk = args.ipk or newest_ipk(os.path.join(ROOT, "out"))
if not ipk or not os.path.exists(ipk):
print("no ipk found; run ./tools/build.sh first", file=sys.stderr)
return 1
name = os.path.basename(ipk)
base = (args.base_url or "").rstrip("/")
manifest = {
"id": appinfo["id"],
"version": appinfo["version"],
"type": appinfo.get("type", "web"),
"title": appinfo.get("title", appinfo["id"]),
"appDescription": appinfo.get("appDescription", ""),
"iconUri": base + "/icon.png" if base else "icon.png",
"sourceUrl": args.source_url,
# The service reads the TV's audio devices, which are root-only. The
# Homebrew Channel uses this to elevate the service at install time.
"rootRequired": True,
"ipkUrl": base + "/" + name if base else name,
"ipkHash": {"sha256": sha256(ipk)},
"ipkSize": os.path.getsize(ipk),
}
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as fh:
json.dump(manifest, fh, indent=2)
fh.write("\n")
print(json.dumps(manifest, indent=2))
print("\nwrote %s" % os.path.relpath(args.out, ROOT), file=sys.stderr)
if not base:
print("note: no --base-url given, so ipkUrl and iconUri are relative",
file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -0,0 +1,143 @@
#!/bin/sh
# Reports what audio a rooted webOS TV actually exposes.
#
# Which capture backend works depends on the model and firmware: some sets run
# PulseAudio with a monitor source, some only offer ALSA, some need an external
# helper. Run this once on the TV and the answer is usually obvious.
#
# On the TV:
# sh tv-probe.sh
#
# From here, over the Homebrew Channel's ssh:
# ssh -p 9922 root@TV-IP 'sh -s' < tools/tv-probe.sh
# ares-shell --device tv -r "$(cat tools/tv-probe.sh)"
#
# Reads only. Nothing here changes the TV.
header() {
printf '\n=== %s\n' "$1"
}
have() {
command -v "$1" >/dev/null 2>&1
}
show() {
# show <label> <file>
if [ -r "$2" ]; then
printf -- '--- %s\n' "$1"
cat "$2"
fi
}
header "Identity"
printf 'uid: %s\n' "$(id -u 2>/dev/null)"
printf 'kernel: %s\n' "$(uname -r 2>/dev/null)"
printf 'machine: %s\n' "$(uname -m 2>/dev/null)"
for f in /etc/starfish-release /etc/os-release; do
show "$f" "$f"
done
if [ -r /var/run/nyx/device_info.json ]; then
printf -- '--- device info\n'
cat /var/run/nyx/device_info.json
printf '\n'
fi
header "Sound devices"
if [ -d /dev/snd ]; then
ls -l /dev/snd
else
echo "no /dev/snd — the ALSA backend cannot work"
fi
header "ALSA"
show "cards" /proc/asound/cards
show "pcm" /proc/asound/pcm
show "modules" /proc/asound/modules
if [ -d /proc/asound ]; then
for dir in /proc/asound/card*/pcm*c; do
[ -d "$dir" ] || continue
printf -- '--- capture stream %s\n' "$dir"
[ -r "$dir/info" ] && cat "$dir/info"
done
else
echo "no /proc/asound — no ALSA on this firmware"
fi
header "Tools"
for tool in arecord aplay amixer pactl parec pacat pulseaudio gst-launch-1.0 ffmpeg luna-send; do
if have "$tool"; then
printf '%-16s %s\n' "$tool" "$(command -v "$tool")"
else
printf '%-16s -\n' "$tool"
fi
done
header "Libraries"
# The service dlopen()s these rather than linking them, so what matters is
# whether the file exists at all and under which soname.
for pattern in \
/usr/lib/libasound.so* /lib/libasound.so* \
/usr/lib/libpulse.so* /lib/libpulse.so* \
/usr/lib/libpulse-simple.so* /lib/libpulse-simple.so*
do
for lib in $pattern; do
[ -e "$lib" ] && ls -l "$lib"
done
done 2>/dev/null
header "PulseAudio"
ps ax 2>/dev/null | grep -i '[p]ulseaudio' || ps 2>/dev/null | grep -i '[p]ulseaudio' \
|| echo "no pulseaudio process found"
printf -- '--- sockets\n'
for sock in /var/run/pulse/native /run/pulse/native /tmp/pulse-*/native \
/var/run/pulse/*.socket /dev/socket/pulse/*
do
[ -e "$sock" ] && ls -l "$sock"
done 2>/dev/null
printf -- '--- environment of the audio daemon\n'
for pid in $(ps ax 2>/dev/null | grep -i '[a]udiod\|[p]ulseaudio' | awk '{print $1}'); do
if [ -r "/proc/$pid/environ" ]; then
printf 'pid %s: ' "$pid"
tr '\0' '\n' < "/proc/$pid/environ" | grep -i 'PULSE\|XDG_RUNTIME' | tr '\n' ' '
printf '\n'
fi
done
if have pactl; then
printf -- '--- pactl info\n'
PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}" pactl info 2>&1 | head -20
printf -- '--- sources (monitors are what to capture)\n'
PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}" pactl list short sources 2>&1
fi
if have arecord; then
header "ALSA capture devices (arecord -l)"
arecord -l 2>&1
printf -- '--- PCMs (arecord -L)\n'
arecord -L 2>&1 | head -40
fi
header "What to put in the app"
cat <<'EOF'
Look at the output above and set Capture > Backend accordingly:
PulseAudio a pulseaudio process and a socket exist, and pactl lists a
source ending in ".monitor". Use that name as the Device, or
leave Device blank to take the default monitor.
ALSA /proc/asound lists a card with a capture PCM. Use hw:X,Y with
the card and device numbers from "arecord -l".
Command neither works but arecord/parec/gst-launch is present. Set the
Command to something that writes raw S16LE to stdout, e.g.
arecord -D hw:0,0 -f S16_LE -r 48000 -c 2 -t raw
Test tone nothing at all. Use this to prove the network path works while
you keep looking.
If everything is missing, the audio path on this firmware is locked inside the
proprietary audio daemon. Every sink depends on captured audio, so the test
tone is all that will run until a capture route is found — use it to prove the
network side, then come back to this list.
EOF