Let the Device field be picked, not typed blind
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f3a4cddfd6
commit
0759cc00aa
@@ -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)
|
frontend/ the on-TV app (plain HTML/CSS/JS, no framework)
|
||||||
servicefiles/ services.json, package.json and the boot script
|
servicefiles/ services.json, package.json and the boot script
|
||||||
host/ the receiver and loopback setup for the HyperHDR machine
|
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
|
tools/ build, packaging, asset generation, on-TV probe
|
||||||
test/ host-side tests: wire formats, the capture pipeline, the UI
|
test/ host-side tests: wire formats, the capture pipeline, the UI
|
||||||
docs/ the longer explanations
|
docs/ the longer explanations
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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 "$@"
|
||||||
@@ -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
|
./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
|
### On the TV
|
||||||
|
|
||||||
*Outputs → HyperHDR audio (RTP/L16)*
|
*Outputs → HyperHDR audio (RTP/L16)*
|
||||||
|
|||||||
+67
-1
@@ -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 = [
|
var CAPTURE_FIELDS = [
|
||||||
{
|
{
|
||||||
path: 'capture.backend', label: 'Backend', type: 'choice',
|
path: 'capture.backend', label: 'Backend', type: 'choice',
|
||||||
@@ -199,7 +242,26 @@
|
|||||||
when: backendIs(['auto', 'pulse', 'alsa']),
|
when: backendIs(['auto', 'pulse', 'alsa']),
|
||||||
placeholder: 'blank = default monitor',
|
placeholder: 'blank = default monitor',
|
||||||
hint: 'PulseAudio source name, or an ALSA PCM such as hw:0,0. '
|
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,
|
path: 'capture.server', label: 'PulseAudio server', type: 'text', wide: true,
|
||||||
@@ -714,6 +776,10 @@
|
|||||||
|
|
||||||
function runDiagnostics() {
|
function runDiagnostics() {
|
||||||
Luna.getDiagnostics(function (reply) {
|
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));
|
var copy = JSON.parse(JSON.stringify(reply));
|
||||||
delete copy.returnValue;
|
delete copy.returnValue;
|
||||||
showOutput(JSON.stringify(copy, null, 2));
|
showOutput(JSON.stringify(copy, null, 2));
|
||||||
|
|||||||
+8
-1
@@ -173,7 +173,14 @@
|
|||||||
},
|
},
|
||||||
binaries: { parec: false, pactl: true, pacat: false, arecord: true },
|
binaries: { parec: false, pactl: true, pacat: false, arecord: true },
|
||||||
pulseSockets: ['/var/run/pulse/native'],
|
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'],
|
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
|
||||||
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
|
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ echo "== Capture pipeline end to end"
|
|||||||
"$CC" "${CFLAGS[@]}" -o "$OUT/engine_smoke" test/engine_smoke.c "${SOURCES[@]}" -lpthread -lm
|
"$CC" "${CFLAGS[@]}" -o "$OUT/engine_smoke" test/engine_smoke.c "${SOURCES[@]}" -lpthread -lm
|
||||||
"$OUT/engine_smoke"
|
"$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
|
||||||
echo "== Frontend"
|
echo "== Frontend"
|
||||||
if command -v node >/dev/null 2>&1; then
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ async function main() {
|
|||||||
eq('stops again', $('state-pill').textContent, 'Stopped');
|
eq('stops again', $('state-pill').textContent, 'Stopped');
|
||||||
|
|
||||||
console.log('diagnostics');
|
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'));
|
click($('run-diagnostics'));
|
||||||
await wait(200);
|
await wait(200);
|
||||||
check('diagnostics output shown',
|
check('diagnostics output shown',
|
||||||
@@ -200,6 +204,17 @@ async function main() {
|
|||||||
await wait(200);
|
await wait(200);
|
||||||
check('log output shown', $('output').textContent.indexOf('browser mock') >= 0);
|
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');
|
console.log('navigation');
|
||||||
fakeLayout(window);
|
fakeLayout(window);
|
||||||
const tabs = doc.querySelectorAll('.tab');
|
const tabs = doc.querySelectorAll('.tab');
|
||||||
|
|||||||
Executable
+108
@@ -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())
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
|
<!DOCTYPE PLUGIN [
|
||||||
|
<!ENTITY name "lgtv-audiocap-loopback">
|
||||||
|
<!ENTITY author "Crylia">
|
||||||
|
<!ENTITY version "2026.08.26">
|
||||||
|
<!ENTITY pluginURL "https://git.crylia.de/Crylia/lgtv_audio_cap/raw/branch/main/unraid/&name;.plg">
|
||||||
|
]>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
This plugin does exactly one thing: load the ALSA loopback (snd-aloop) that
|
||||||
|
the LG TV Audio Cap RTP receiver plays into, and make that persist across an
|
||||||
|
Unraid reboot. Everything else - actually receiving the TV's audio and
|
||||||
|
writing it into the loopback - runs as a normal Docker container (see
|
||||||
|
docker/Dockerfile in the project repo), because that part doesn't need
|
||||||
|
bare-metal access. Only the kernel module load does: Unraid boots from a
|
||||||
|
read-only USB image each time, so anything not re-applied via /boot/config/go
|
||||||
|
or a plugin is gone on the next boot, and a container can't load a host
|
||||||
|
kernel module for itself.
|
||||||
|
|
||||||
|
hw:Loopback,0,0 - playback end, feed this to the receiver container
|
||||||
|
hw:Loopback,1,0 - capture end, point HyperHDR's sound capture at this
|
||||||
|
-->
|
||||||
|
|
||||||
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="6.9.0">
|
||||||
|
|
||||||
|
<CHANGES>
|
||||||
|
###2026.08.26
|
||||||
|
- Initial release.
|
||||||
|
</CHANGES>
|
||||||
|
|
||||||
|
<FILE Run="/bin/bash">
|
||||||
|
<INLINE>
|
||||||
|
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."
|
||||||
|
</INLINE>
|
||||||
|
</FILE>
|
||||||
|
|
||||||
|
<FILE Run="/bin/bash" Method="remove">
|
||||||
|
<INLINE>
|
||||||
|
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."
|
||||||
|
</INLINE>
|
||||||
|
</FILE>
|
||||||
|
|
||||||
|
</PLUGIN>
|
||||||
Reference in New Issue
Block a user