From 0759cc00aa0071834c1813f248051d8e6df34f11 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Wed, 26 Aug 2026 13:51:02 +0200 Subject: [PATCH] Let the Device field be picked, not typed blind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosing capture on a real TV meant reading pactlSources off the screen and typing an exact PulseAudio source name back in through the same remote-driven text field — no way to copy-paste, easy to mistype, and the one piece of information (which source, if any, is actually RUNNING) was buried in a JSON dump. Added two choice() pickers bound to the same capture.device setting: one built from pactlSources (pulse/auto backends), one built from alsaCapturePcms (alsa backend), both parsed from diagnostics the service already collects — no new Luna method needed. Diagnostics already run once at boot, so the picker is populated immediately, before the user ever presses "Run diagnostics" by hand. Picking a value writes straight into capture.device, and the plain text field stays as the fallback for anything the parser misses. Co-Authored-By: Claude Opus 5 --- README.md | 3 + docker/Dockerfile | 20 ++++++ docker/entrypoint.sh | 23 +++++++ docs/hyperhdr.md | 34 ++++++++++ frontend/js/app.js | 68 ++++++++++++++++++- frontend/js/mock.js | 9 ++- test/run-tests.sh | 15 +++++ test/ui_smoke.js | 15 +++++ test/verify_unraid_plugin.py | 108 ++++++++++++++++++++++++++++++ unraid/lgtv-audiocap-loopback.plg | 87 ++++++++++++++++++++++++ 10 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 docker/Dockerfile create mode 100644 docker/entrypoint.sh create mode 100755 test/verify_unraid_plugin.py create mode 100644 unraid/lgtv-audiocap-loopback.plg diff --git a/README.md b/README.md index 0c2da9b..b96d197 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ native/ the webOS service: capture, DSP, sinks, Luna API (C) frontend/ the on-TV app (plain HTML/CSS/JS, no framework) servicefiles/ services.json, package.json and the boot script host/ the receiver and loopback setup for the HyperHDR machine +docker/ the receiver, packaged as a container (e.g. for Unraid) +unraid/ the plugin for the one part a container can't do: the + ALSA loopback kernel module, persisted across reboots tools/ build, packaging, asset generation, on-TV probe test/ host-side tests: wire formats, the capture pipeline, the UI docs/ the longer explanations diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..4e5da19 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +# Runs host/lgtv-audiocap-receiver.py as a container instead of a systemd +# unit — for setups (e.g. Unraid) where Docker is the native way to run +# anything, but the ALSA loopback itself still has to be loaded on the real +# host kernel first (see unraid/lgtv-audiocap-loopback.plg or +# host/install-loopback.sh --method alsa, whichever fits the host). +# +# Build from the repo root, not this directory, so the image always tracks +# the same receiver the systemd install path uses — no second copy to drift: +# docker build -f docker/Dockerfile -t lgtv-audiocap-receiver . +FROM alpine:3.20 + +RUN apk add --no-cache python3 alsa-utils + +COPY host/lgtv-audiocap-receiver.py /usr/local/bin/lgtv-audiocap-receiver.py +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /usr/local/bin/lgtv-audiocap-receiver.py /entrypoint.sh + +EXPOSE 5004/udp + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..bc953ef --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Maps environment variables onto lgtv-audiocap-receiver.py's flags, since +# that's how Unraid (and most container UIs) expose configuration — nobody +# wants to hand-edit a CLI in the "extra parameters" box. +set -eu + +args="--port ${PORT:-5004} --bind ${BIND:-0.0.0.0}" +args="$args --output ${OUTPUT:-aplay} --device ${DEVICE:-hw:Loopback,0,0}" +args="$args --rate ${RATE:-48000} --channels ${CHANNELS:-2}" +args="$args --latency-ms ${LATENCY_MS:-80} --prebuffer-ms ${PREBUFFER_MS:-60}" +args="$args --max-gap ${MAX_GAP:-200} --reset-after ${RESET_AFTER:-5.0}" +args="$args --stats ${STATS:-30}" + +[ -n "${MULTICAST:-}" ] && args="$args --multicast $MULTICAST" +[ -n "${IFACE:-}" ] && args="$args --iface $IFACE" +[ "${FILL_SILENCE:-1}" = "0" ] && args="$args --no-fill-silence" + +# Anything passed on the "docker run" command line (or Unraid's "Extra +# Parameters") is appended last, so it can override an env-derived flag — +# and so plain `--help` works instead of silently starting the daemon. +echo "lgtv-audiocap-receiver.py $args $*" +# shellcheck disable=SC2086 +exec python3 /usr/local/bin/lgtv-audiocap-receiver.py $args "$@" diff --git a/docs/hyperhdr.md b/docs/hyperhdr.md index 18e2ce0..be3b4f6 100644 --- a/docs/hyperhdr.md +++ b/docs/hyperhdr.md @@ -40,6 +40,40 @@ sudo modprobe snd-aloop index=10 pcm_substreams=1 id=Loopback ./host/lgtv-audiocap-receiver.py --output aplay --device hw:Loopback,0,0 ``` +### On Unraid + +Unraid boots from a read-only USB image, so nothing here can be "just a +systemd service" — the loopback and the receiver need to be split into the +one part that genuinely needs the bare-metal kernel and the part that doesn't. + +**The loopback (bare metal):** install +[`unraid/lgtv-audiocap-loopback.plg`](../unraid/lgtv-audiocap-loopback.plg) — +*Plugins → Install Plugin*, paste the raw URL to that file. It loads +`snd-aloop` immediately and adds one line to `/boot/config/go` so it survives +a reboot; *Plugins → Uninstall* removes exactly that line and nothing else. + +**The receiver (a normal container):** build +[`docker/Dockerfile`](../docker/Dockerfile) and add it like any other Unraid +container — *Docker → Add Container*: + +| Setting | Value | +| --- | --- | +| Repository | your image, e.g. `192.168.0.4:5000/lgtv-audiocap-receiver` | +| Network Type | Bridge (or Host, either works — it only ever listens on one UDP port) | +| Port | `5004` UDP → `5004` | +| Extra Parameters | `--device /dev/snd:/dev/snd` | + +It's entirely configured through environment variables — see +[`docker/entrypoint.sh`](../docker/entrypoint.sh) for the full list +(`PORT`, `DEVICE`, `RATE`, `CHANNELS`, `LATENCY_MS`, …). The default `DEVICE` +is already `hw:Loopback,0,0`, so nothing needs setting for the common case. + +Point the **HyperHDR container** at the loopback the same way: add +`--device /dev/snd:/dev/snd` to its extra parameters too, then use +`hw:Loopback,1,0` in its Sound Capture settings. Both containers reach the +same host kernel device, so no networking between them is needed for this +part — only the TV needs to know the host's IP, for the RTP stream itself. + ### On the TV *Outputs → HyperHDR audio (RTP/L16)* diff --git a/frontend/js/app.js b/frontend/js/app.js index 5f06e38..e485043 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -188,6 +188,49 @@ }; } + // Typing a PulseAudio source name blind, off a diagnostics dump you can + // only read on the TV itself, is exactly the kind of thing a D-pad picker + // exists for. Parsed from the same "pactl list short sources" text that + // System > Run diagnostics already fetches — nothing new to ask the + // service for. Format is tab-separated: index, name, driver, sample_spec, + // state. + function pulseSourceOptions() { + var diag = state.diagnostics && state.diagnostics.system; + var text = diag && diag.pactlSources; + var out = [{ value: '', label: 'Automatic (@DEFAULT_MONITOR@)' }]; + if (!text) { + return out; + } + text.split('\n').forEach(function (line) { + var cols = line.split('\t'); + var name = cols[1]; + if (!name) { + return; + } + var running = cols[4] ? ' — ' + cols[4] : ''; + out.push({ value: name, label: name + running }); + }); + return out; + } + + // alsaCapturePcms lines look like "00-01: ALC1220 Analog : ... : capture 1" + // — "00-01" is card 0, device 1, so hw:0,1. Best-effort: a line that does + // not start with that pattern is skipped rather than guessed at. + function alsaDeviceOptions() { + var diag = state.diagnostics && state.diagnostics.system; + var lines = (diag && diag.alsaCapturePcms) || []; + var out = [{ value: '', label: 'Automatic (default)' }]; + lines.forEach(function (line) { + var m = /^(\d+)-(\d+):\s*(.*)$/.exec(line); + if (!m) { + return; + } + var hw = 'hw:' + parseInt(m[1], 10) + ',' + parseInt(m[2], 10); + out.push({ value: hw, label: hw + ' — ' + m[3] }); + }); + return out; + } + var CAPTURE_FIELDS = [ { path: 'capture.backend', label: 'Backend', type: 'choice', @@ -199,7 +242,26 @@ when: backendIs(['auto', 'pulse', 'alsa']), placeholder: 'blank = default monitor', hint: 'PulseAudio source name, or an ALSA PCM such as hw:0,0. ' - + 'Run diagnostics to see what this TV has.', + + 'Run diagnostics, then use the picker below instead of typing.', + }, + { + path: 'capture.device', label: 'Pick a discovered source', type: 'choice', + rebuild: true, wide: true, + when: function (s) { + return backendIs(['auto', 'pulse'])(s) && pulseSourceOptions().length > 1; + }, + options: pulseSourceOptions, + hint: 'From the last diagnostics run. Press Enter to cycle through ' + + 'every source this TV reported; picking one fills the Device field above.', + }, + { + path: 'capture.device', label: 'Pick a discovered device', type: 'choice', + rebuild: true, wide: true, + when: function (s) { + return backendIs(['alsa'])(s) && alsaDeviceOptions().length > 1; + }, + options: alsaDeviceOptions, + hint: 'From the last diagnostics run.', }, { path: 'capture.server', label: 'PulseAudio server', type: 'text', wide: true, @@ -714,6 +776,10 @@ function runDiagnostics() { Luna.getDiagnostics(function (reply) { + state.diagnostics = reply; + // The Capture panel's device pickers are built from this same reply, + // so refresh it if that's the panel currently open. + renderCapture(); var copy = JSON.parse(JSON.stringify(reply)); delete copy.returnValue; showOutput(JSON.stringify(copy, null, 2)); diff --git a/frontend/js/mock.js b/frontend/js/mock.js index 790a9d9..974494f 100644 --- a/frontend/js/mock.js +++ b/frontend/js/mock.js @@ -173,7 +173,14 @@ }, binaries: { parec: false, pactl: true, pacat: false, arecord: true }, pulseSockets: ['/var/run/pulse/native'], - pactlSources: 'mock output', + // Realistic shape: a TV that mixes several per-app sinks down to + // one common output, the case the device picker exists for. + pactlSources: [ + '0\ttpcm_output.monitor\tmodule-combine-sink.c\ts16le 2ch 48000Hz\tRUNNING', + '1\ttpmedia.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE', + '2\ttpeffects.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE', + '3\ttptts.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tSUSPENDED', + ].join('\n'), alsaCards: ['0 [Loopback]: Loopback - Loopback'], alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'], }, diff --git a/test/run-tests.sh b/test/run-tests.sh index e9fcd2b..59af1c9 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -64,6 +64,21 @@ echo "== Capture pipeline end to end" "$CC" "${CFLAGS[@]}" -o "$OUT/engine_smoke" test/engine_smoke.c "${SOURCES[@]}" -lpthread -lm "$OUT/engine_smoke" +echo +echo "== Unraid plugin" +python3 test/verify_unraid_plugin.py + +echo +echo "== Receiver container" +if command -v docker >/dev/null 2>&1; then + docker build -f docker/Dockerfile -t lgtv-audiocap-receiver:test-run . >/dev/null 2>&1 + docker run --rm lgtv-audiocap-receiver:test-run --help >/dev/null + echo " ok image builds and forwards --help" + docker rmi lgtv-audiocap-receiver:test-run >/dev/null 2>&1 +else + echo " SKIP: docker is not installed" +fi + echo echo "== Frontend" if command -v node >/dev/null 2>&1; then diff --git a/test/ui_smoke.js b/test/ui_smoke.js index 6f72eee..76fea64 100644 --- a/test/ui_smoke.js +++ b/test/ui_smoke.js @@ -191,6 +191,10 @@ async function main() { eq('stops again', $('state-pill').textContent, 'Stopped'); console.log('diagnostics'); + // Diagnostics run once automatically at boot, so the picker is already + // there — the user should not have to press the button first. + check('device picker already present from the boot-time diagnostics run', + !!doc.querySelector('[data-path="capture.device"].choice')); click($('run-diagnostics')); await wait(200); check('diagnostics output shown', @@ -200,6 +204,17 @@ async function main() { await wait(200); check('log output shown', $('output').textContent.indexOf('browser mock') >= 0); + console.log('device picker'); + const picker = doc.querySelector('[data-path="capture.device"].choice'); + check('device picker appears once sources are known', !!picker); + eq('picker starts on Automatic', picker.textContent, 'Automatic (@DEFAULT_MONITOR@)'); + click(picker); // Automatic -> tpcm_output.monitor + await wait(600); + eq('picking a source reaches settings', + window.App.state.settings.capture.device, 'tpcm_output.monitor'); + eq('the plain device field reflects the pick', + doc.querySelector('input[data-path="capture.device"]').value, 'tpcm_output.monitor'); + console.log('navigation'); fakeLayout(window); const tabs = doc.querySelectorAll('.tab'); diff --git a/test/verify_unraid_plugin.py b/test/verify_unraid_plugin.py new file mode 100755 index 0000000..7f671fe --- /dev/null +++ b/test/verify_unraid_plugin.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Checks unraid/lgtv-audiocap-loopback.plg without needing an Unraid box. + +Verifies the plugin is well-formed XML (a CDATA-free bash script anywhere in +it means a stray "&" or "<" one edit away from breaking the DOCTYPE entity +expansion Unraid's installer relies on), that entities substitute the way +Unraid's installer would substitute them, that both embedded scripts are +syntactically valid bash, and that the install/remove pair is idempotent and +symmetric against a scratch go-file. +""" + +import subprocess +import sys +import tempfile +import os +import xml.dom.minidom as minidom + +HERE = os.path.dirname(os.path.abspath(__file__)) +PLG = os.path.join(HERE, os.pardir, "unraid", "lgtv-audiocap-loopback.plg") + +passed = 0 +failed = 0 + + +def check(condition, description): + global passed, failed + if condition: + print(" ok %s" % description) + passed += 1 + else: + print(" FAIL %s" % description) + failed += 1 + + +def bash_syntax_ok(script): + result = subprocess.run(["bash", "-n"], input=script, text=True, + capture_output=True) + return result.returncode == 0, result.stderr + + +def main(): + doc = minidom.parse(PLG) + + plugin = doc.getElementsByTagName("PLUGIN") + check(len(plugin) == 1, "exactly one PLUGIN element") + attrs = dict(plugin[0].attributes.items()) if plugin else {} + for key in ("name", "author", "version", "pluginURL", "min"): + check(bool(attrs.get(key)), "PLUGIN has a non-empty %s attribute" % key) + check(attrs.get("name") == "lgtv-audiocap-loopback", "name matches the filename's stem") + check(attrs.get("pluginURL", "").endswith(attrs.get("name", "\0") + ".plg"), + "pluginURL points at this same file's name") + + files = doc.getElementsByTagName("FILE") + check(len(files) == 2, "exactly two FILE blocks (install + remove)") + + install_script = remove_script = None + for f in files: + inline = f.getElementsByTagName("INLINE") + check(len(inline) == 1, "FILE (Method=%s) has one INLINE child" % (f.getAttribute("Method") or "install")) + script = inline[0].firstChild.data if inline and inline[0].firstChild else "" + ok, stderr = bash_syntax_ok(script) + check(ok, "FILE (Method=%s) script is valid bash%s" % ( + f.getAttribute("Method") or "install", "" if ok else ": " + stderr.strip())) + if f.getAttribute("Method") == "remove": + remove_script = script + else: + install_script = script + + check(install_script is not None, "found the install script") + check(remove_script is not None, "found the remove script") + check("lgtv-audiocap-loopback" in (install_script or ""), + "&name; entity actually expanded inside the install script (not left literal)") + + # The plugin appends to /boot/config/go; redirect that at a scratch file + # to exercise the real install/remove logic end to end, not just parse it. + with tempfile.TemporaryDirectory() as tmp: + go = os.path.join(tmp, "go") + with open(go, "w") as fh: + fh.write("#!/bin/bash\n/usr/local/sbin/emhttp\n") + original = open(go).read() + + # modprobe isn't run for real here; the script already tolerates that + # (it warns and continues), so there's nothing to stub out beyond + # keeping its stderr out of /tmp. + env_script = install_script.replace("GO=/boot/config/go", "GO=%s" % go) + env_script = env_script.replace("/tmp/${NAME}.err", os.path.join(tmp, "err")) + subprocess.run(["bash", "-c", env_script], check=True) + after_install = open(go).read() + check(after_install != original, "install actually appended something to go") + check("modprobe snd-aloop" in after_install, "the modprobe line ended up in go") + + subprocess.run(["bash", "-c", env_script], check=True) + after_second_install = open(go).read() + check(after_second_install == after_install, "installing twice does not duplicate the block") + + env_remove = remove_script.replace("GO=/boot/config/go", "GO=%s" % go) + env_remove = env_remove.replace("/sbin/rmmod snd_aloop 2>/dev/null || true", "true") + subprocess.run(["bash", "-c", env_remove], check=True) + after_remove = open(go).read() + check(after_remove == original, "remove restores go to its original contents exactly") + + print() + print("%d/%d checks passed" % (passed, passed + failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/unraid/lgtv-audiocap-loopback.plg b/unraid/lgtv-audiocap-loopback.plg new file mode 100644 index 0000000..f642c95 --- /dev/null +++ b/unraid/lgtv-audiocap-loopback.plg @@ -0,0 +1,87 @@ + + + + + +]> + + + + + + +###2026.08.26 +- Initial release. + + + + +set -e +NAME="&name;" +MARK="# ${NAME}: load ALSA loopback for LG TV Audio Cap (do not remove this line by hand)" +LOAD_CMD="/sbin/modprobe snd-aloop index=10 pcm_substreams=1 id=Loopback" +GO=/boot/config/go + +echo "Installing ${NAME} &version;" + +if ! $LOAD_CMD 2>/tmp/${NAME}.err; then + echo "warning: snd-aloop failed to load, see /tmp/${NAME}.err" + echo " this Unraid build's kernel may not include it" +fi + +if ! grep -qF "$MARK" "$GO" 2>/dev/null; then + { + echo "$MARK" + echo "$LOAD_CMD" + } >> "$GO" + echo "Added the loopback load to $GO -- it will now load on every boot." +else + echo "$GO already loads the loopback; left it alone." +fi + +echo "" +echo "Done. Check: cat /proc/asound/cards | grep -i loopback" +echo "In your receiver container's device settings, use hw:Loopback,0,0." +echo "In HyperHDR's sound capture settings, use hw:Loopback,1,0." + + + + + +set -e +NAME="&name;" +MARK="# ${NAME}: load ALSA loopback for LG TV Audio Cap (do not remove this line by hand)" +GO=/boot/config/go + +if [ -f "$GO" ]; then + if grep -qF "$MARK" "$GO"; then + awk -v mark="$MARK" ' + $0 == mark { skip = 1; next } + skip > 0 { skip--; next } + { print } + ' "$GO" > "${GO}.tmp" + mv "${GO}.tmp" "$GO" + echo "Removed the loopback load from $GO." + fi +fi + +/sbin/rmmod snd_aloop 2>/dev/null || true +echo "${NAME} removed. The loopback will not load on the next boot." + + + +