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
+187
@@ -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
|
||||
Executable
+217
@@ -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())
|
||||
Executable
+92
@@ -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())
|
||||
Executable
+143
@@ -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
|
||||
Reference in New Issue
Block a user