8 Commits
Author SHA1 Message Date
Rene KievitsandClaude Opus 5 d3e4cb6410 Restrict the brightness sink to one app, picked by name not typed
Confirmed the brightness command applies globally, not per-LED
(serverinfo showed exactly one adjustment object, "id": "default",
covering the whole string), so no LED-count configuration is needed
for this at all -- that question resolved itself once the mechanism
was actually inspected instead of assumed.

For "only react while Spotify is running": only one app can be in the
foreground on webOS at a time, so a "capture the current app" button
in this app's own UI can never work -- pressing it means this app is
foreground, not Spotify. The only workable UI is picking a target from
every *installed* app by name, regardless of what's currently running.

That needed a new native capability this service never had: calling
OUT to another Luna service, not just being called. Two additions:

  foreground_app.c   subscribes once, at startup, to
                      com.webos.applicationManager/getForegroundAppInfo
                      and keeps a thread-safe cache the audio thread can
                      read without a blocking Luna call
  service.c           new listApps method, bridging to
                      com.webos.applicationManager/listApps so the
                      frontend never has to call another service
                      directly -- same rule as everywhere else here

Until the subscription has delivered at least one reply, a restricted
sink treats the target app as inactive, not active -- reacting to
audio when the user explicitly restricted it to one app would be the
wrong failure mode. Verified end to end on the host: engine_smoke.c
opens the sink with a restriction set, confirms it reports itself
correctly inactive against the stub Luna bus (which always "fails" to
call out, exactly like a real host with no bus).

Needed real, linkable stub bodies for LSCall/LSCallOneReply/
LSCallCancel/LSMessageGetPayload/LSErrorInit/LSErrorFree
(test/stubs/luna-service2/lunaservice_stub.c) since foreground_app.c
is the first source file here that's actually linked into a host test
binary rather than only syntax-checked -- service.c/main.c's existing
stub declarations were never called, only compiled against. Confirmed
those really are the correct symbol names by cross-compiling clean
against the real webOS SDK's actual libluna-service2, not just the
stub.

Bumped to 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:46:09 +02:00
Rene KievitsandClaude Opus 5 e9f6c87d27 Fix hyperhdrAdjust: use "brightness", not "scaleOutput"
Tested against a real HyperHDR instance rather than trusting the
schema further. scaleOutput (from the current schema-adjustment.json)
produced no visible change and no trace in serverinfo's echoed-back
adjustment state. brightness (0-100, absent from that same schema)
round-tripped correctly through serverinfo and visibly dimmed real
LEDs -- confirmed live, with piccap as the sole colour source and
only this sink's JSON-RPC calls changing anything.

A schema documents what a command accepts; it does not guarantee
what a given build actually does with each field, and this is a
mismatch between HyperHDR's current dev-branch schema and whatever
build the target instance is actually running. Switched the sink to
brightness (int 0-100) throughout: wire format, config defaults
(minBrightness/maxBrightness, 20-100), status fields, and the reset
sent on close. Renamed the frontend fields and mock to match.
Cross-compiles clean. Bumped to 1.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:59:51 +02:00
Rene KievitsandClaude Opus 5 aae5a33283 Add a brightness-only sink: keep the grabber's colour, pulse with sound
Every existing HyperHDR route replaces whatever else is on the LEDs:
routes 1/2 hand HyperHDR's own audio effect a device to read, route 3
sends a synthetic spectrum image, and both take over via HyperHDR's
priority system. For a setup that already has a real colour source
(a screen grabber, a USB capture card) feeding an ambilight-style LED
run, none of that is what's wanted -- the colour should stay put and
only brightness should react.

Read HyperHDR's own source (sources/api/JSONRPC_schema/schema-adjustment.json)
rather than guess: "adjustment" is a post-processing command with a
scaleOutput parameter (0-2.0) that applies regardless of which
priority is currently active. Confirmed the wire format too --
sources/jsonserver/JsonClientConnection.cpp frames it as plain
newline-delimited JSON over TCP (default port 19444), nothing like the
length-prefixed Flatbuffers protocol the visualiser sink speaks, and
with no handshake or registration needed before the first write.

sink_hyperhdr_adjust.c sends only that: no image, no priority, so it
never competes with an existing grabber. Non-blocking connect with the
same poll()+SO_ERROR pattern net/hyperion.c already uses, reconnects
every 5s, rate-limited to 20 Hz (a HyperHDR command every audio block
would be pointless flooding), and resets scaleOutput to 1.0 on close
rather than leaving the LEDs stuck at whatever it last sent. Cross-
compiles clean under -Wall -Wextra on the real webOS toolchain.

Wired through the same path every other sink follows: registered in
sink.c/sink.h, defaults in config.c, fields in frontend/js/app.js
(SINK_FIELDS/SINK_HELP), mock.js and ui_smoke.js updated for the new
sink card. Bumped to 1.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:36:31 +02:00
Rene KievitsandClaude Opus 5 f0f68a1aa7 Tolerate space-padded pactl output, and show the running app version
The device-source parser assumed pactl list short sources is strictly
tab-separated, true for stock PulseAudio but not guaranteed for a
TV's own heavily customized audio stack (this one names sources
tpcm_output/tpmedia/tptts/... — clearly not vanilla). A different
separator would have silently produced zero parsed sources with no
error, and the picker's own `when` guard would then just hide the row
entirely rather than show anything broken. Split on any whitespace
run instead of a literal tab; source names never contain embedded
whitespace, so this is strictly more permissive with no new failure
mode. Confirmed end to end on real hardware: tptts.monitor lit up
during the accessibility voice guide and reached HyperHDR.

Also: the System panel now shows the app's actual running version,
read from a <meta> tag substituted at package time (tools/build.sh
stage()) from frontend/appinfo.json — not hand-maintained, so it can't
drift from what was actually built. Requested after a version bump
alone wasn't enough to tell whether a reinstall had truly picked up
new files versus served something cached along the way; this settles
that question by inspection instead of by inference. Bumped to 1.0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:11:29 +02:00
Rene KievitsandClaude Opus 5 d2931bee63 Bump to 1.0.1
Every rebuild since 1.0.0 kept the same version, so the ipk filename
and repo.json content never changed even though the contents did --
indistinguishable from a stale cache to both Homebrew Channel's
update check and any HTTP cache sitting between the release and the
TV. Bumping the version changes the ipk's filename outright, which
sidesteps that ambiguity regardless of what's caching what.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:00:46 +02:00
Rene KievitsandClaude Opus 5 0759cc00aa 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>
2026-08-26 13:51:02 +02:00
Rene KievitsandClaude Opus 5 f3a4cddfd6 Package through a pinned Node, not whatever the host has
Found the real cause of "-5: ipk verify failed": ares-package's own
packaging code (ar-async/fstream/tar, last touched 2017-2019) silently
zeroes every file's mtime in the ipk when run under a sufficiently new
Node (reproduced on v25.8.1; a Node-18 container was unaffected).
Confirmed by unpacking data.tar.gz from our built ipk (every entry
1970-01-01) versus webosbrew/hyperhdr-webos-loader's real published
ipk, structurally identical to ours (web app + native service, same
control file, same ares-cli) but with genuine October 2025 timestamps.
The archive still parses fine everywhere generic tooling looks —
Python's tarfile, our own ar/tar inspection — so nothing here ever
errors. The TV's own installer is what eventually rejects it, and it
gives no hint why.

This took three rounds of elimination to isolate: root elevation
wasn't it (Homebrew Channel's own root status was "ok"), and
system-wide native-code verification wasn't it either (a real
native-service app installed fine on the same TV). Comparing our ipk
against that known-good one byte-for-byte was what surfaced the
timestamp anomaly, and rebuilding under Node 18 reproduced correct
timestamps immediately.

build.sh now runs ares-package inside a pinned node:18 container by
default, falling back to the host's Node with a warning if Docker
isn't available. Rebuilt the actual release ipk this way and
regenerated manifest.json/repo.json against its corrected hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:51:13 +02:00
Rene KievitsandClaude Opus 5 f622c3a0bf Fix repo.json: embed the manifest, don't just list its fields
Read the Homebrew Channel's own DetailsPanel.js: when an app is opened
from Browse, refresh() only ever takes entry.manifest directly, or
fetches entry.manifestUrl if that's absent — never the entry's own
top-level fields. Our repo.json had neither, so
resolveURL(undefined, repositoryURL) threw before the fetch could even
start, and the details screen spun on "Loading" forever with no visible
error. This is what happened when it was tried against a real repo.

Fixed by nesting the full manifest under a "manifest" key per package
entry, alongside the id/title/iconUri the Browse grid reads directly.
Verified structurally (grid fields present, manifest embedded and
non-empty, ipkUrl absolute) since there's no local Homebrew Channel
build to run this against.

Also caught in the same investigation: a stale --base-url in the docs
used the tag v1.0.0 while the actual release was tagged 1.0.0, which
404s the icon and ipk silently — same symptom, different cause. Noted
in both the tool's docstring and the docs that a tag mismatch looks
identical to the manifest bug from the client's side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:30:24 +02:00
31 changed files with 1359 additions and 54 deletions
+4
View File
@@ -105,6 +105,7 @@ Each can run at the same time as the others.
| --- | --- | --- |
| **HyperHDR audio** | RTP/L16, port 5004 | the main path; also readable by PulseAudio's `module-rtp-recv` with no custom software |
| **HyperHDR visualiser** | Flatbuffers images, port 19400 | HyperHDR with nothing installed on the host |
| **HyperHDR brightness** | JSON-RPC, port 19444 | an existing grabber/ambilight setup — keeps its colour, only pulses brightness |
| **Raw PCM over UDP** | S16LE datagrams, port 4010 | your own scripts; lowest latency |
| **Raw PCM over TCP** | S16LE stream, port 4011 | anything that would rather connect than listen |
| **HTTP WAV** | `http://tv:4012/audio.wav` | opening the TV's audio in VLC |
@@ -116,6 +117,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
+20
View File
@@ -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"]
+23
View File
@@ -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 "$@"
+38 -2
View File
@@ -93,6 +93,39 @@ payload so nothing fragments on a normal Ethernet MTU.
| `saturation` | `1.0` | colour intensity |
| `minBrightness` | `0.02` | floor so the lights never go fully black |
### `hyperhdrAdjust` — brightness only, via HyperHDR's JSON-RPC
Sends no image at all. Instead it calls HyperHDR's `adjustment` command with
a `brightness` value (0-100) — a post-processing stage that applies
regardless of which priority is currently active — so an existing grabber or
capture app keeps deciding colour and only overall brightness reacts to
sound. Confirmed against a real HyperHDR instance by watching `brightness`
round-trip through `serverinfo` and the LEDs visibly respond; HyperHDR's own
`schema-adjustment.json` also documents a `scaleOutput` float (0.0-2.0), but
that field produced no effect on the same instance — what a schema declares
and what a given build actually acts on are not always the same thing.
| Key | Default | Meaning |
| --- | --- | --- |
| `host` | `""` | HyperHDR's address |
| `port` | `19444` | HyperHDR's classic JSON-RPC port (not 8090, not 19400) |
| `level` | `"rms"` | `rms` (steadier) or `peak` (punchier) |
| `minBrightness` | `20` | brightness (0-100) during silence |
| `maxBrightness` | `100` | brightness (0-100) at full level; HyperHDR does not go above 100 |
| `restrictToApp` | `""` | a webOS app id; blank means always active |
`restrictToApp` is set from a picker of installed apps in the UI (by name,
never typed), backed by a new `listApps` Luna method that proxies to
`com.webos.applicationManager/listApps` — the frontend never calls another
service's Luna API directly, everything goes through this service, same as
everywhere else. The service also subscribes once, at startup, to
`com.webos.applicationManager/getForegroundAppInfo` to know which app is
currently in front. Until that subscription has delivered at least one
reply, a restricted sink treats the target app as *not* active — the safe
failure mode, since silently reacting to audio when the user explicitly
restricted it to one app would be the wrong one. `getDiagnostics` exposes
the live value as `foregroundApp` if you want to confirm tracking is working.
### `udp`, `tcp`, `http`
| Key | Default | Meaning |
@@ -129,7 +162,8 @@ luna-send -n 1 -f luna://org.webosbrew.audiocap.service/getStatus '{}'
| `resetConfig` | `{}` | `{saved, settings}` |
| `listBackends` | `{}` | `{backends:[{id,name,description,available}]}` |
| `listSinks` | `{}` | `{sinks:[{id,name,description}]}` |
| `getDiagnostics` | `{}` | `{backends, system}` — see below |
| `listApps` | `{}` | `{apps:[{id,title}]}` — every installed app, for the "restrict to app" picker |
| `getDiagnostics` | `{}` | `{backends, system, foregroundApp}` — see below |
| `getLogs` | `{"clear":true}` optional | `{logs}` |
| `quit` | `{}` | ends the process; the next call starts a new one |
@@ -178,7 +212,9 @@ broken output does not take the others down.
Each sink adds its own fields. `packetsSent`/`bytesSent`/`sendErrors` for the
datagram sinks, `clients`/`droppedBytes` for the stream servers,
`connected`/`registered`/`framesSent`/`connectFailures`/`lastError` for the
visualiser.
visualiser, `connected`/`updatesSent`/`minBrightness`/`maxBrightness`/`lastError`
for the brightness sink, plus `restrictToApp`/`restrictedAppActive` when a
restriction is set.
### Diagnostics
+37 -14
View File
@@ -27,8 +27,20 @@ Everywhere else, build the native part in a container:
That bakes the SDK into an image, so it downloads once and later builds start
immediately. There are aarch64 and x86_64 SDK builds and the image picks
whichever matches the container, so on Apple Silicon it runs natively rather
than under emulation. Only the compile happens in the container; packaging and
deployment run on the host, where the TV is reachable.
than under emulation. Only the compile happens in the container; deployment
runs on the host, where the TV is reachable.
Packaging (`ares-package`) also runs in a container by default — pinned to
Node 18, not whatever Node the host has. This isn't optional hygiene: on a
newer Node (v22+, confirmed on v25) `ares-package`'s own dependencies
(`fstream`/`tar`, last touched around 2017-2019) silently zero out every
timestamp in the ipk instead of erroring. The archive still parses fine
everywhere generic tools look, so nothing here fails — the TV's installer is
what eventually rejects it, as an opaque `-5: ipk verify failed` with no
indication why. If Docker isn't available, `build.sh` falls back to the host's
own Node with a warning; if installs fail mysteriously in that mode, this is
the first thing to suspect — checked by unpacking `data.tar.gz` from the ipk
and confirming the timestamps aren't 1970-01-01.
Register the TV with ares once, using the Homebrew Channel's ssh (port 9922,
root):
@@ -113,29 +125,37 @@ or ssh — the app installs itself once the TV can reach a URL.
### Your own repository (no review, no waiting)
The Homebrew Channel's *Settings → Repositories → Add repository* accepts any
URL that returns `{"packages": [...]}`, where each entry is the same manifest
[`make-manifest.py`](../tools/make-manifest.py) already writes. Point one at
your own git host's release assets and the app shows up in Browse with no
submission process at all — this is what `--repo-out` (on by default) is for.
URL that returns `{"packages": [...]}`. Each entry needs its own `id`/
`title`/`iconUri` for the Browse grid, plus the full manifest nested under a
`manifest` key for the details screen — [`make-manifest.py`](../tools/make-manifest.py)
builds exactly that shape. Point one at your own git host's release assets and
the app shows up in Browse with no submission process at all — this is what
`--repo-out` (on by default) is for.
1. Bump `version` in `frontend/appinfo.json`, `servicefiles/package.json` and
`package.json`.
2. Build the ipk: `./tools/docker-build.sh && ./tools/build.sh package` (or
`./tools/build.sh` on Linux with the NDK installed).
3. Create a release tagged e.g. `v1.0.0` and attach three files to it: the
ipk, `frontend/assets/icon.png`, and a repo index generated with
`--base-url` set to that release's asset URL:
3. Create a release — note the **exact tag** Gitea/GitHub gives it, `1.0.0` or
`v1.0.0`, whichever it actually is — and attach three files: the ipk,
`frontend/assets/icon.png`, and a repo index generated with `--base-url`
set to that release's real download URL:
```sh
python3 tools/make-manifest.py \
--base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/v1.0.0
# -> out/manifest.json (one app entry)
# -> out/repo.json (that entry wrapped as {"packages": [...]})
--base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/1.0.0
# -> out/manifest.json (one app entry, for the official-repo route below)
# -> out/repo.json ({"packages": [{id, title, iconUri, manifest: {...}}]})
```
A mismatched tag in `--base-url` doesn't error — it just makes the icon and
ipk links inside `repo.json` 404 silently, which looks identical to "the
details screen hangs" from the client's point of view. If the app was
already added and only the tag was wrong, re-run with the fixed tag and
re-upload `repo.json`; no need to touch the "Add repository" entry itself,
since its URL didn't change.
Attach `out/repo.json` itself too — its own download URL is what you paste
into the TV, and it must match `--base-url` exactly or the ipk/icon links
inside it point at the wrong place.
into the TV.
4. On the TV: Homebrew Channel → gear icon → *Add repository* → paste the
`repo.json` release URL → back out to Browse → find *Audio Cap* → Install.
@@ -166,6 +186,9 @@ service.c the Luna methods and the status subscription
engine.c the capture thread: read a block, analyse it, hand it to every sink
config.c load/merge/atomic-save of config.json
dsp.c peak/RMS envelopes and the 16-band analysis
foreground_app.c tracks which app is in front, for the "restrict to app"
brightness option -- the one place this service calls out
to another Luna service instead of being called
capture/ one file per backend, all dlopen-based
sinks/ one file per output
net/ RTP, FlatBuffers, the shared stream server
+98 -8
View File
@@ -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)*
@@ -132,17 +166,73 @@ lights go fully dark between beats, which looks dramatic and slightly broken.
---
## 4. Keep your grabber's colour, only pulse the brightness
For an ambilight-style setup that already has a real colour source — a
screen grabber, a USB capture card, a webOS capture app like piccap — routes
1–3 all have the same problem: they compete for HyperHDR's priority and
*replace* that colour with something audio-derived. This route doesn't touch
colour at all.
HyperHDR has a JSON-RPC `adjustment` command that sets output brightness
(0-100) as a post-processing step, applied on top of whatever priority is
currently active. This sink sends nothing but that: no image, no priority
registration, so the grabber (or piccap, or whatever else) keeps deciding
hue and this only turns the result up and down with the sound.
```
TV ──RTP or local──► audiocap-service ──JSON-RPC "adjustment"──► HyperHDR
(still showing
the grabber's colour)
```
*Outputs → HyperHDR brightness (JSON-RPC)*
| Setting | Value |
| --- | --- |
| HyperHDR address | the HyperHDR machine's IP |
| JSON-RPC port | 19444 (HyperHDR's classic control port — not 8090, the web UI; not 19400, Flatbuffers) |
| Follows | Average level (steadier) or Peak level (punchier) |
| Minimum brightness | `0`-`100`; applied during quiet parts |
| Maximum brightness | `0`-`100`; applied at full level. HyperHDR does not go above 100 |
| Restrict to app | optional — only react to audio while one specific app is in the foreground |
*Restrict to app* is picked from a list of installed apps by name (e.g.
"Spotify"), never typed — only one app can be foreground at a time, so
there is no way to "capture the current app" from a button in this app's
own UI; you'd just be capturing yourself. Until the TV confirms which app
is actually in front, the sink treats the target as inactive rather than
guessing yes, so it can't accidentally react to the wrong thing while
starting up.
Needs a working capture source the same as every other route — see the top
of this document for picking one. On close, the sink resets brightness to
`100` rather than leaving the LEDs stuck at whatever it last sent.
The field is `brightness`, confirmed by watching it round-trip through
`serverinfo` and the LEDs visibly respond on a real HyperHDR/Docker
instance. HyperHDR's current `schema-adjustment.json` documents a
`scaleOutput` float (0.0-2.0) instead, which looked like the obvious choice
and is what this sink sent originally — it had no visible or server-reported
effect on that same instance. If a future HyperHDR version drops
`brightness`, this needs re-verifying the same way, not just re-reading the
schema.
---
## Which one to use
| | Route 1 | Route 2 | Route 3 |
| --- | --- | --- | --- |
| Host software | receiver + loopback | none | none |
| HyperHDR effects | all of them | all of them | none, the TV renders |
| Latency | ~100 ms | ~100 ms, less stable | ~40 ms |
| Robustness | good | depends on your PulseAudio | good |
| Setup time | 10 minutes | 2 minutes if it works | 1 minute |
| | Route 1 | Route 2 | Route 3 | Route 4 |
| --- | --- | --- | --- | --- |
| Host software | receiver + loopback | none | none | none |
| HyperHDR effects | all of them | all of them | none, the TV renders | your existing grabber, untouched |
| Colour source | HyperHDR's built-in audio effect | HyperHDR's built-in audio effect | this app's synthetic spectrum | your grabber — this only adjusts brightness |
| Latency | ~100 ms | ~100 ms, less stable | ~40 ms | ~50 ms |
| Robustness | good | depends on your PulseAudio | good | good |
| Setup time | 10 minutes | 2 minutes if it works | 1 minute | 1 minute |
Route 1 unless you have a reason.
Route 1 for HyperHDR's own audio effects. Route 4 if you already have a
grabber and just want it to breathe with the sound instead of being replaced.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "org.webosbrew.audiocap",
"version": "1.0.0",
"version": "1.0.5",
"vendor": "Homebrew",
"type": "web",
"main": "index.html",
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1920, initial-scale=1">
<!-- Substituted at package time from frontend/appinfo.json (tools/build.sh
stage()), so what the System panel shows is what actually got built
into this ipk — not something that can itself go stale in a cache. -->
<meta name="app-version" content="__APP_VERSION__">
<title>Audio Cap</title>
<link rel="stylesheet" href="css/app.css">
</head>
+147 -1
View File
@@ -13,15 +13,27 @@
var ELEVATE = '/media/developer/apps/usr/palm/services/'
+ 'org.webosbrew.hbchannel.service/elevate-service';
// Substituted into index.html at package time (tools/build.sh stage()).
// Unstaged — opened straight from the source tree, e.g. npm run serve —
// it is still the literal placeholder, which is exactly the tell that
// this isn't a packaged build.
function readAppVersion() {
var meta = document.querySelector('meta[name="app-version"]');
var content = meta && meta.getAttribute('content');
return content && content.indexOf('__') !== 0 ? content : 'dev build';
}
var state = {
settings: {},
status: null,
backends: [],
sinkDefs: [],
installedApps: [],
configPath: '',
persistent: true,
bootLinked: false,
diagnostics: null,
appVersion: readAppVersion(),
};
var statusSub = null;
@@ -188,6 +200,62 @@
};
}
// 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. Stock PulseAudio tab-separates columns (index, name,
// driver, sample_spec, state), but split on any whitespace run rather than
// a literal tab: a TV's own pactl-alike is free to pad with spaces
// instead, and source names never contain embedded whitespace themselves.
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.trim().split(/\s+/);
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;
}
// Fetched once at boot (see the listApps call near the bottom of this
// file). Sorted by title there, so this just adds the "no restriction"
// default at the front.
function installedAppOptions() {
var out = [{ value: '', label: 'Always active' }];
state.installedApps.forEach(function (a) {
out.push({ value: a.id, label: a.title });
});
return out;
}
var CAPTURE_FIELDS = [
{
path: 'capture.backend', label: 'Backend', type: 'choice',
@@ -199,7 +267,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,
@@ -308,6 +395,40 @@
},
],
hyperhdrAdjust: [
{
path: 'hyperhdrAdjust.host', label: 'HyperHDR address', type: 'text', wide: true,
placeholder: '192.168.1.50',
},
{
path: 'hyperhdrAdjust.port', label: 'JSON-RPC port', type: 'number',
hint: 'HyperHDR\'s classic control port, 19444 by default. Not the '
+ 'web UI port (8090) or the Flatbuffers port (19400).',
},
{
path: 'hyperhdrAdjust.level', label: 'Follows', type: 'choice',
options: [
{ value: 'rms', label: 'Average level (steadier)' },
{ value: 'peak', label: 'Peak level (punchier)' },
],
},
{
path: 'hyperhdrAdjust.minBrightness', label: 'Minimum brightness', type: 'number',
hint: '0-100. Applied during quiet parts. 100 is HyperHDR\'s normal brightness.',
},
{
path: 'hyperhdrAdjust.maxBrightness', label: 'Maximum brightness', type: 'number',
hint: '0-100. Applied at full level. HyperHDR does not go above 100.',
},
{
path: 'hyperhdrAdjust.restrictToApp', label: 'Restrict to app', type: 'choice',
options: installedAppOptions,
hint: 'Only react to audio while this app is in the foreground. '
+ 'Picked by name, not typed — only one app can be in front at a '
+ 'time, so there is no way to "capture the current app" from here.',
},
],
udp: [
{
path: 'udp.host', label: 'Destination', type: 'text', wide: true,
@@ -339,6 +460,10 @@
hyperhdrViz: 'No host setup at all: the TV does the analysis and sends '
+ 'finished images over the Flatbuffers port. Use it when you cannot add '
+ 'a sound device on the HyperHDR machine.',
hyperhdrAdjust: 'For an existing ambilight/grabber setup: leaves colour '
+ 'entirely to whatever HyperHDR is already showing, and only turns its '
+ 'overall brightness up and down with the sound. Sends no image, so it '
+ 'never competes for priority with a grabber.',
udp: 'Raw interleaved S16LE, no header, no framing. Lowest latency and no '
+ 'connection to lose.',
tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of '
@@ -464,6 +589,7 @@
var info = $('config-path');
UI.clear(info);
info.appendChild(infoItem('App version', state.appVersion));
info.appendChild(infoItem('Path', state.configPath || '—'));
info.appendChild(infoItem('Storage', state.persistent
? 'Persistent' : 'Temporary (/tmp)'));
@@ -586,9 +712,15 @@
if (s.framesSent !== undefined) {
bits.push(s.framesSent.toLocaleString() + ' frames');
}
if (s.updatesSent !== undefined) {
bits.push(s.updatesSent.toLocaleString() + ' updates');
}
if (s.connected !== undefined) {
bits.push(s.connected ? 'connected' : 'not connected');
}
if (s.restrictToApp) {
bits.push(s.restrictedAppActive ? 'active now' : 'waiting for that app');
}
if (s.sendErrors) {
bits.push(s.sendErrors + ' send errors');
}
@@ -714,6 +846,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));
@@ -788,6 +924,16 @@
renderSinks();
}, fail);
// Powers the "restrict to app" picker on the brightness sink. Fetched
// once at boot, same as backends/sinks above — the installed-app list
// does not change during a session.
Luna.listApps(function (reply) {
state.installedApps = (reply.apps || []).slice().sort(function (a, b) {
return a.title.localeCompare(b.title);
});
renderSinks();
}, fail);
renderCapture();
renderSystem();
checkBootLink();
+3
View File
@@ -101,6 +101,9 @@
listSinks: function (ok, fail) {
return call(SERVICE + 'listSinks', {}, ok, fail);
},
listApps: function (ok, fail) {
return call(SERVICE + 'listApps', {}, ok, fail);
},
getDiagnostics: function (ok, fail) {
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
},
+26 -1
View File
@@ -15,6 +15,10 @@
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02,
},
hyperhdrAdjust: {
host: '', port: 19444, minBrightness: 20, maxBrightness: 100, level: 'rms',
restrictToApp: '',
},
udp: { host: '', port: 4010, multicastTtl: 4 },
tcp: { port: 4011, maxClients: 4 },
http: { port: 4012, maxClients: 4 },
@@ -146,12 +150,24 @@
sinks: [
{ id: 'hyperhdr', name: 'HyperHDR audio (RTP)', description: 'RTP/L16 audio to the HyperHDR host.' },
{ id: 'hyperhdrViz', name: 'HyperHDR visualiser', description: 'Renders on the TV, sends images. No host setup.' },
{ id: 'hyperhdrAdjust', name: 'HyperHDR brightness (JSON-RPC)', description: 'Only adjusts brightness; colour stays with HyperHDR\'s own grabber.' },
{ id: 'udp', name: 'Raw PCM over UDP', description: 'Fire-and-forget S16LE datagrams.' },
{ id: 'tcp', name: 'Raw PCM over TCP', description: 'The TV listens; connect to pull audio.' },
{ id: 'http', name: 'HTTP WAV stream', description: 'Open the URL in VLC.' },
],
});
case 'listApps':
return respond(onReply, {
returnValue: true,
apps: [
{ id: 'spotify-beehive', title: 'Spotify' },
{ id: 'netflix', title: 'Netflix' },
{ id: 'youtube.leanback.v4', title: 'YouTube' },
{ id: 'com.webos.app.livetv', title: 'Live TV' },
],
});
// Same shape as capture_write_diagnostics(): backends at the top level,
// everything about the machine under "system".
case 'getDiagnostics':
@@ -173,7 +189,16 @@
},
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.
// Space-padded, not tab-separated — some TVs' own pactl-alike
// formats it that way, and the parser has to tolerate both.
pactlSources: [
'0 tpcm_output.monitor module-combine-sink.c s16le 2ch 48000Hz RUNNING',
'1 tpmedia.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
'2 tpeffects.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
'3 tptts.monitor module-alsa-card.c s16le 2ch 48000Hz SUSPENDED',
].join('\n'),
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
},
+2
View File
@@ -30,6 +30,7 @@ add_executable(audiocap-service
src/main.c
src/service.c
src/engine.c
src/foreground_app.c
src/config.c
src/dsp.c
src/common/log.c
@@ -46,6 +47,7 @@ add_executable(audiocap-service
src/sinks/sink.c
src/sinks/sink_hyperhdr.c
src/sinks/sink_hyperhdr_viz.c
src/sinks/sink_hyperhdr_adjust.c
src/sinks/sink_udp.c
src/sinks/sink_tcp.c
src/sinks/sink_http.c
+8
View File
@@ -48,6 +48,14 @@ static const char* DEFAULTS_JSON =
" \"saturation\": 1.0,"
" \"minBrightness\": 0.02"
" },"
" \"hyperhdrAdjust\": {"
" \"host\": \"\","
" \"port\": 19444,"
" \"minBrightness\": 20,"
" \"maxBrightness\": 100,"
" \"level\": \"rms\","
" \"restrictToApp\": \"\""
" },"
" \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 },"
" \"tcp\": { \"port\": 4011, \"maxClients\": 4 },"
" \"http\": { \"port\": 4012, \"maxClients\": 4 }"
+77
View File
@@ -0,0 +1,77 @@
#include "foreground_app.h"
#include "common/json.h"
#include "common/log.h"
#include <luna-service2/lunaservice.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER;
static char s_current[192] = { 0 };
static bool s_known = false;
static LSMessageToken s_token = 0;
static bool s_subscribed = false;
static bool on_reply(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)sh;
(void)ctx;
const char* payload = LSMessageGetPayload(msg);
json_value_t* root = payload ? json_parse(payload) : NULL;
if (!root)
return true;
const char* app_id = json_str(root, "appId", "");
pthread_mutex_lock(&s_lock);
snprintf(s_current, sizeof(s_current), "%s", app_id);
s_known = true;
pthread_mutex_unlock(&s_lock);
json_free(root);
return true;
}
bool foreground_app_start(LSHandle* handle, char* err, size_t errlen)
{
LSError lserror;
LSErrorInit(&lserror);
bool ok = LSCall(handle, "luna://com.webos.applicationManager/getForegroundAppInfo",
"{\"subscribe\":true}", on_reply, NULL, &s_token, &lserror);
if (!ok) {
if (err)
snprintf(err, errlen, "getForegroundAppInfo: %s", lserror.message);
LSErrorFree(&lserror);
return false;
}
s_subscribed = true;
return true;
}
void foreground_app_stop(LSHandle* handle)
{
if (!s_subscribed)
return;
LSError lserror;
LSErrorInit(&lserror);
if (!LSCallCancel(handle, s_token, &lserror)) {
DBG("LSCallCancel(foreground app subscription): %s", lserror.message);
LSErrorFree(&lserror);
}
s_subscribed = false;
}
bool foreground_app_known(void)
{
pthread_mutex_lock(&s_lock);
bool known = s_known;
pthread_mutex_unlock(&s_lock);
return known;
}
void foreground_app_current(char* out, size_t outlen)
{
pthread_mutex_lock(&s_lock);
snprintf(out, outlen, "%s", s_current);
pthread_mutex_unlock(&s_lock);
}
+28
View File
@@ -0,0 +1,28 @@
// Tracks which app is currently in the foreground on the TV, so a sink can
// ask "is it Spotify right now" without making a blocking Luna call from the
// audio capture thread.
#pragma once
#include <stdbool.h>
#include <stddef.h>
// Opaque: only foreground_app.c itself needs the real luna-service2 API
// surface. Everything else here (in particular sink_hyperhdr_adjust.c, which
// only ever calls the two read-only accessors below) stays free of that
// dependency.
typedef struct LSHandle LSHandle;
// Subscribes once to com.webos.applicationManager/getForegroundAppInfo.
// Not fatal if it fails (logs and returns false): callers should treat an
// unknown foreground app as "no restriction applies" rather than silently
// freezing every app-restricted sink forever.
bool foreground_app_start(LSHandle* handle, char* err, size_t errlen);
void foreground_app_stop(LSHandle* handle);
// True once at least one reply has come back, i.e. `current` is meaningful
// rather than just "nothing heard yet".
bool foreground_app_known(void);
// Copies the current foreground app id into `out` (best effort; may lag the
// real state by a fraction of a second). Empty string if not known yet.
void foreground_app_current(char* out, size_t outlen);
+78
View File
@@ -4,6 +4,7 @@
#include "common/log.h"
#include "config.h"
#include "engine.h"
#include "foreground_app.h"
#include "sinks/sink.h"
#include <stdarg.h>
@@ -347,6 +348,67 @@ static bool method_list_sinks(LSHandle* sh, LSMessage* msg, void* ctx)
return true;
}
// Bridges to com.webos.applicationManager/listApps so the UI can offer a
// picker of installed apps by name, without the frontend needing its own
// permission to call another service directly -- everything it does goes
// through us, the same as every other method here. `msg` outlives this
// handler's return (the reply comes later, from `on_list_apps_reply`), so it
// is ref-counted for that stretch and always unref'd exactly once.
static bool on_list_apps_reply(LSHandle* sh, LSMessage* reply, void* ctx)
{
LSMessage* original = ctx;
const char* payload = LSMessageGetPayload(reply);
json_value_t* root = payload ? json_parse(payload) : NULL;
const json_value_t* apps = root ? json_get(root, "apps") : NULL;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_arr_open(&w, "apps");
size_t count = json_len(apps);
for (size_t i = 0; i < count; i++) {
const json_value_t* app = json_at(apps, i);
const char* id = json_str(app, "id", NULL);
const char* title = json_str(app, "title", NULL);
// Skip anything without a real title (bare service ids, mostly) and
// this app itself -- restricting the sink "to itself" is meaningless.
if (!id || !title || !*title)
continue;
if (strcmp(id, "org.webosbrew.audiocap") == 0)
continue;
jw_obj_open(&w, NULL);
jw_str(&w, "id", id);
jw_str(&w, "title", title);
jw_obj_close(&w);
}
jw_arr_close(&w);
jw_obj_close(&w);
reply_json(sh, original, jw_take(&w));
if (root)
json_free(root);
LSMessageUnref(original);
return true;
}
static bool method_list_apps(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
LSMessageRef(msg);
LSError lserror;
LSErrorInit(&lserror);
bool ok = LSCallOneReply(sh, "luna://com.webos.applicationManager/listApps",
"{\"properties\":[\"id\",\"title\",\"type\"]}", on_list_apps_reply, msg, NULL, &lserror);
if (!ok) {
reply_error(sh, msg, "listApps: %s", lserror.message);
LSErrorFree(&lserror);
LSMessageUnref(msg);
}
return true;
}
static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
@@ -356,6 +418,13 @@ static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx)
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
capture_write_diagnostics(&w);
if (foreground_app_known()) {
char app[192];
foreground_app_current(app, sizeof(app));
jw_str(&w, "foregroundApp", app);
} else {
jw_null(&w, "foregroundApp");
}
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
@@ -407,6 +476,7 @@ static LSMethod s_methods[] = {
{ "resetConfig", method_reset_config, LUNA_METHOD_FLAGS_NONE },
{ "listBackends", method_list_backends, LUNA_METHOD_FLAGS_NONE },
{ "listSinks", method_list_sinks, LUNA_METHOD_FLAGS_NONE },
{ "listApps", method_list_apps, LUNA_METHOD_FLAGS_NONE },
{ "getDiagnostics", method_get_diagnostics, LUNA_METHOD_FLAGS_NONE },
{ "getLogs", method_get_logs, LUNA_METHOD_FLAGS_NONE },
{ "quit", method_quit, LUNA_METHOD_FLAGS_NONE },
@@ -447,6 +517,13 @@ service_t* service_create(LSHandle* handle, GMainLoop* loop)
return NULL;
}
// Powers the brightness sink's "only while this app is running" option.
// Not fatal on failure: that option just has nothing to compare against,
// same as if it were left unset.
char err[192];
if (!foreground_app_start(handle, err, sizeof(err)))
WARN("Foreground app tracking unavailable: %s", err);
return s;
}
@@ -454,6 +531,7 @@ void service_destroy(service_t* s)
{
if (!s)
return;
foreground_app_stop(s->handle);
engine_destroy(s->engine);
config_free(s->config);
free(s);
+1
View File
@@ -7,6 +7,7 @@
static const sink_driver_t* const s_drivers[] = {
&sink_driver_hyperhdr,
&sink_driver_hyperhdr_viz,
&sink_driver_hyperhdr_adjust,
&sink_driver_udp,
&sink_driver_tcp,
&sink_driver_http,
+1
View File
@@ -39,6 +39,7 @@ struct sink {
// own translation unit see one declaration.
extern const sink_driver_t sink_driver_hyperhdr;
extern const sink_driver_t sink_driver_hyperhdr_viz;
extern const sink_driver_t sink_driver_hyperhdr_adjust;
extern const sink_driver_t sink_driver_udp;
extern const sink_driver_t sink_driver_tcp;
extern const sink_driver_t sink_driver_http;
+342
View File
@@ -0,0 +1,342 @@
// Global brightness modulation via HyperHDR's own JSON-RPC "adjustment"
// command, so a real picture source (HyperHDR's screen grabber, a webOS
// capture app like piccap, whatever is already driving the LEDs) keeps
// deciding colour, and only the overall brightness reacts to sound. Unlike
// every other sink here, this one deliberately sends no picture at all:
// sending one would mean competing for priority against that source,
// replacing its colour outright instead of layering on top of it.
// "adjustment" is a post-processing stage that applies regardless of which
// priority is currently active, which is exactly the layering this needs.
//
// The field is "brightness", an integer 0-100 -- confirmed against a real
// HyperHDR instance (serverinfo echoes it back, and the LEDs visibly
// responded). The "scaleOutput" float (0-2.0) in HyperHDR's current
// schema-adjustment.json looked like the obvious candidate and is what an
// earlier version of this file sent, but it produced no visible or
// server-reported effect on that same instance -- API docs describe the
// schema; they do not guarantee what a given build actually does with it.
// If a future HyperHDR drops "brightness" in favour of "scaleOutput", this
// will need re-verifying the same way, not just re-reading the schema.
//
// Plain newline-delimited JSON over TCP -- HyperHDR's classic control port,
// default 19444 -- a world simpler than the Flatbuffers image protocol the
// visualiser sink speaks. There is no handshake or registration: any client
// on this port can send commands immediately after connecting.
#include "sink.h"
#include "../common/log.h"
#include "../foreground_app.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <math.h>
#include <netdb.h>
#include <netinet/in.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#define RECONNECT_INTERVAL_SEC 5
#define CONNECT_TIMEOUT_MS 3000
#define SEND_INTERVAL_MS 50 // 20 Hz; smoother than that buys nothing visible
typedef struct {
struct sockaddr_in dest;
char host[128];
int port;
int min_brightness; // 0-100
int max_brightness; // 0-100
bool use_rms; // rms is steadier than peak, which reacts to single transients
char restrict_to_app[192]; // empty = always active
int fd; // -1 when not connected or still connecting
bool connected; // fd is open and the non-blocking connect finished
struct timespec connect_started;
time_t last_connect_attempt;
struct timespec last_send;
char last_error[192];
bool app_was_active; // for sending exactly one reset on the active->inactive edge
unsigned long long updates_sent;
unsigned long long connect_failures;
} adjust_priv_t;
static long elapsed_ms(const struct timespec* since)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (now.tv_sec - since->tv_sec) * 1000 + (now.tv_nsec - since->tv_nsec) / 1000000;
}
static void adjust_disconnect(adjust_priv_t* p)
{
if (p->fd >= 0)
close(p->fd);
p->fd = -1;
p->connected = false;
}
static void adjust_try_connect(adjust_priv_t* p)
{
if (p->fd >= 0)
return;
time_t now = time(NULL);
if (now - p->last_connect_attempt < RECONNECT_INTERVAL_SEC)
return;
p->last_connect_attempt = now;
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
snprintf(p->last_error, sizeof(p->last_error), "socket(): %s", strerror(errno));
return;
}
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
int rc = connect(fd, (struct sockaddr*)&p->dest, sizeof(p->dest));
if (rc != 0 && errno != EINPROGRESS) {
snprintf(p->last_error, sizeof(p->last_error), "connect(): %s", strerror(errno));
p->connect_failures++;
close(fd);
return;
}
p->fd = fd;
p->connected = (rc == 0);
clock_gettime(CLOCK_MONOTONIC, &p->connect_started);
}
// Finishes a connect that was started non-blocking. Returns true once the fd
// is usable (successfully connected), false if it is still pending or has
// failed -- in which case the fd is already closed.
static bool adjust_pump_connect(adjust_priv_t* p)
{
if (p->connected)
return true;
if (p->fd < 0)
return false;
struct pollfd pfd = { .fd = p->fd, .events = POLLOUT };
int pr = poll(&pfd, 1, 0);
if (pr < 0)
return false;
if (pr == 0) {
if (elapsed_ms(&p->connect_started) > CONNECT_TIMEOUT_MS) {
snprintf(p->last_error, sizeof(p->last_error), "connect timed out");
adjust_disconnect(p);
p->connect_failures++;
}
return false;
}
int soerr = 0;
socklen_t slen = sizeof(soerr);
if (getsockopt(p->fd, SOL_SOCKET, SO_ERROR, &soerr, &slen) != 0)
soerr = errno;
if (soerr != 0) {
snprintf(p->last_error, sizeof(p->last_error), "connect: %s", strerror(soerr));
adjust_disconnect(p);
p->connect_failures++;
return false;
}
p->connected = true;
return true;
}
static void adjust_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)pcm;
(void)frames;
adjust_priv_t* p = s->priv;
adjust_try_connect(p);
if (!adjust_pump_connect(p))
return;
// "Only while Spotify is running": until we can actually confirm that,
// the safe default is inactive, not active -- silently reacting to audio
// when the user asked to restrict it would be the wrong failure mode.
bool restricted = p->restrict_to_app[0] != '\0';
bool app_active = true;
if (restricted) {
if (!foreground_app_known()) {
app_active = false;
} else {
char current[192];
foreground_app_current(current, sizeof(current));
app_active = strcmp(current, p->restrict_to_app) == 0;
}
}
if (!app_active) {
// Send exactly one reset on the active->inactive edge, then go
// quiet, rather than leaving the LEDs stuck at whatever level the
// last audio block happened to produce.
if (p->app_was_active) {
char reset_msg[80];
int rn = snprintf(reset_msg, sizeof(reset_msg),
"{\"command\":\"adjustment\",\"adjustment\":{\"brightness\":%d}}\n", p->max_brightness);
if (rn > 0 && (size_t)rn < sizeof(reset_msg)
&& send(p->fd, reset_msg, (size_t)rn, MSG_NOSIGNAL) >= 0)
p->updates_sent++;
p->app_was_active = false;
}
return;
}
p->app_was_active = true;
if (elapsed_ms(&p->last_send) < SEND_INTERVAL_MS)
return;
clock_gettime(CLOCK_MONOTONIC, &p->last_send);
float level = p->use_rms ? levels->rms : levels->peak;
if (level < 0)
level = 0;
if (level > 1)
level = 1;
int brightness = p->min_brightness
+ (int)lroundf(level * (float)(p->max_brightness - p->min_brightness));
char msg[128];
int n = snprintf(msg, sizeof(msg),
"{\"command\":\"adjustment\",\"adjustment\":{\"brightness\":%d}}\n", brightness);
if (n <= 0 || (size_t)n >= sizeof(msg))
return;
ssize_t sent = send(p->fd, msg, (size_t)n, MSG_NOSIGNAL);
if (sent < 0) {
snprintf(p->last_error, sizeof(p->last_error), "send(): %s", strerror(errno));
adjust_disconnect(p);
return;
}
p->updates_sent++;
// HyperHDR acks every command. Drain it so the socket's receive buffer
// never backs up; MSG_DONTWAIT keeps this off the capture thread's
// critical path even if HyperHDR is slow to reply.
char ack[256];
while (recv(p->fd, ack, sizeof(ack), MSG_DONTWAIT) > 0) { }
}
static void adjust_status(sink_t* s, json_writer_t* w)
{
adjust_priv_t* p = s->priv;
jw_str(w, "target", p->host);
jw_int(w, "port", p->port);
jw_bool(w, "connected", p->connected);
jw_int(w, "minBrightness", p->min_brightness);
jw_int(w, "maxBrightness", p->max_brightness);
if (p->restrict_to_app[0]) {
jw_str(w, "restrictToApp", p->restrict_to_app);
jw_bool(w, "restrictedAppActive", p->app_was_active);
} else {
jw_null(w, "restrictToApp");
}
jw_int(w, "updatesSent", (long long)p->updates_sent);
jw_int(w, "connectFailures", (long long)p->connect_failures);
if (p->last_error[0])
jw_str(w, "lastError", p->last_error);
else
jw_null(w, "lastError");
}
static void adjust_close(sink_t* s)
{
adjust_priv_t* p = s->priv;
if (p) {
// Best effort: hand brightness back to normal rather than leaving
// the LEDs stuck at whatever scale was last sent.
if (p->connected) {
static const char reset[] = "{\"command\":\"adjustment\",\"adjustment\":{\"brightness\":100}}\n";
send(p->fd, reset, sizeof(reset) - 1, MSG_NOSIGNAL);
}
adjust_disconnect(p);
free(p);
}
free(s);
}
static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "hyperhdrAdjust");
const char* host = json_str(sc, "host", NULL);
int port = json_int(sc, "port", 19444);
int min_brightness = json_int(sc, "minBrightness", 20);
int max_brightness = json_int(sc, "maxBrightness", 100);
const char* level_source = json_str(sc, "level", "rms");
const char* restrict_to_app = json_str(sc, "restrictToApp", "");
if (!host || !*host) {
snprintf(err, errlen, "set the HyperHDR host address first");
return NULL;
}
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid HyperHDR JSON port %d", port);
return NULL;
}
if (min_brightness < 0 || max_brightness > 100 || max_brightness <= min_brightness) {
snprintf(err, errlen, "brightness range must be 0-100 with max greater than min");
return NULL;
}
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
struct addrinfo* res = NULL;
int rc = getaddrinfo(host, portstr, &hints, &res);
if (rc != 0 || !res) {
snprintf(err, errlen, "cannot resolve '%s': %s", host, gai_strerror(rc));
return NULL;
}
adjust_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
freeaddrinfo(res);
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->fd = -1;
memcpy(&p->dest, res->ai_addr, sizeof(struct sockaddr_in));
freeaddrinfo(res);
snprintf(p->host, sizeof(p->host), "%s", host);
p->port = port;
p->min_brightness = min_brightness;
p->max_brightness = max_brightness;
p->use_rms = strcmp(level_source, "peak") != 0;
snprintf(p->restrict_to_app, sizeof(p->restrict_to_app), "%s", restrict_to_app);
s->driver = &sink_driver_hyperhdr_adjust;
s->priv = p;
s->fmt = *fmt;
s->write = adjust_write;
s->status = adjust_status;
s->close = adjust_close;
INFO("HyperHDR adjustment sink: %s:%d, brightness %d..%d from %s%s%s", host, port,
min_brightness, max_brightness, p->use_rms ? "rms" : "peak",
p->restrict_to_app[0] ? ", restricted to " : "", p->restrict_to_app);
return s;
}
const sink_driver_t sink_driver_hyperhdr_adjust = {
.id = "hyperhdrAdjust",
.name = "HyperHDR brightness (JSON-RPC)",
.description = "Leaves colour to HyperHDR's own grabber/effect and only "
"modulates overall brightness with the audio level.",
.open = adjust_open,
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lgtv-audio-cap",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lgtv-audio-cap",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"devDependencies": {
"@webosose/ares-cli": "^2.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lgtv-audio-cap",
"version": "1.0.0",
"version": "1.0.5",
"private": true,
"description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "org.webosbrew.audiocap.service",
"version": "1.0.0",
"version": "1.0.5",
"description": "Captures TV audio and streams it to HyperHDR and other receivers",
"main": "audiocap-service"
}
+15 -3
View File
@@ -204,12 +204,22 @@ static void test_status(engine_t* e)
check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported");
const json_value_t* sinks = json_get(v, "sinks");
check(json_len(sinks) == 2, "two sinks reported");
check(json_len(sinks) == 3, "three sinks reported");
for (size_t i = 0; i < json_len(sinks); i++) {
const json_value_t* s = json_at(sinks, i);
char label[64];
snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?"));
check(json_bool(s, "ok", false), label);
if (strcmp(json_str(s, "id", ""), "hyperhdrAdjust") == 0) {
// A host has no Luna bus (see lunaservice_stub.c), so the
// sink can never confirm the restricted app is foreground.
// The correct failure mode is inactive, not "assume yes".
check(strcmp(json_str(s, "restrictToApp", ""), "some.other.app") == 0,
"restriction target reported back");
check(json_bool(s, "restrictedAppActive", true) == false,
"restricted app correctly reported as not active (fail-closed)");
}
}
json_free(v);
}
@@ -224,9 +234,11 @@ int main(void)
char cfg_text[512];
snprintf(cfg_text, sizeof(cfg_text),
"{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2},"
"\"sinks\":[\"tcp\",\"http\"],"
"\"sinks\":[\"tcp\",\"http\",\"hyperhdrAdjust\"],"
"\"tcp\":{\"port\":%d},"
"\"http\":{\"port\":%d}}",
"\"http\":{\"port\":%d},"
"\"hyperhdrAdjust\":{\"host\":\"127.0.0.1\",\"port\":19444,"
"\"restrictToApp\":\"some.other.app\"}}",
TCP_PORT, HTTP_PORT);
json_value_t* cfg = json_parse(cfg_text);
+20 -2
View File
@@ -13,12 +13,14 @@ export PYTHONDONTWRITEBYTECODE=1
OUT=$(mktemp -d)
trap 'rm -rf "$OUT"' EXIT
CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -O1 -g)
CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -Itest/stubs -O1 -g)
SOURCES=(
native/src/engine.c
native/src/config.c
native/src/dsp.c
native/src/foreground_app.c
test/stubs/luna-service2/lunaservice_stub.c
native/src/common/log.c
native/src/common/json.c
native/src/common/ringbuf.c
@@ -33,6 +35,7 @@ SOURCES=(
native/src/sinks/sink.c
native/src/sinks/sink_hyperhdr.c
native/src/sinks/sink_hyperhdr_viz.c
native/src/sinks/sink_hyperhdr_adjust.c
native/src/sinks/sink_udp.c
native/src/sinks/sink_tcp.c
native/src/sinks/sink_http.c
@@ -40,7 +43,7 @@ SOURCES=(
echo "== Syntax-checking the webOS-only sources against stub headers"
for f in native/src/service.c native/src/main.c; do
"$CC" "${CFLAGS[@]}" -Itest/stubs -fsyntax-only "$f"
"$CC" "${CFLAGS[@]}" -fsyntax-only "$f"
echo " ok $f"
done
@@ -64,6 +67,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
+13
View File
@@ -20,6 +20,8 @@ typedef struct {
} LSError;
typedef bool (*LSMethodFunction)(LSHandle* sh, LSMessage* msg, void* category_context);
typedef bool (*LSFilterFunc)(LSHandle* sh, LSMessage* reply, void* ctx);
typedef unsigned long LSMessageToken;
typedef enum {
LUNA_METHOD_FLAGS_NONE = 0,
@@ -58,6 +60,17 @@ bool LSGmainAttach(LSHandle* handle, GMainLoop* loop, LSError* error);
const char* LSMessageGetPayload(LSMessage* message);
bool LSMessageIsSubscription(LSMessage* message);
bool LSMessageReply(LSHandle* sh, LSMessage* message, const char* reply, LSError* error);
void LSMessageRef(LSMessage* message);
void LSMessageUnref(LSMessage* message);
bool LSSubscriptionAdd(LSHandle* sh, const char* key, LSMessage* message, LSError* error);
bool LSSubscriptionReply(LSHandle* sh, const char* key, const char* payload, LSError* error);
// Client-call API: this service acting as a caller of another service, not
// just a callee. LSCall keeps calling `callback` for every reply (used for
// subscribe:true); LSCallOneReply auto-cancels after the first one.
bool LSCall(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error);
bool LSCallOneReply(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error);
bool LSCallCancel(LSHandle* sh, LSMessageToken token, LSError* error);
@@ -0,0 +1,58 @@
// Linkable bodies for the handful of luna-service2 client-call functions
// foreground_app.c calls. service.c/main.c only ever get -fsyntax-only'd, so
// declarations alone are enough for them; foreground_app.c is linked into
// real host test binaries (engine_smoke, rtp_send) via SOURCES[] in
// run-tests.sh, so those symbols need bodies too, or the link fails.
//
// A host has no Luna bus, so "the call failed" is exactly the right
// simulated behaviour -- every caller here already treats that as
// "foreground app tracking unavailable" and degrades accordingly, which is
// also genuinely exercised by the test suite (see the fail-closed check in
// engine_smoke.c).
#include "luna-service2/lunaservice.h"
#include <string.h>
void LSErrorInit(LSError* error)
{
memset(error, 0, sizeof(*error));
}
void LSErrorFree(LSError* error)
{
(void)error;
}
bool LSCall(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error)
{
(void)sh;
(void)uri;
(void)payload;
(void)callback;
(void)ctx;
(void)ret_token;
if (error)
error->message = (char*)"no Luna bus on this host";
return false;
}
bool LSCallOneReply(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error)
{
return LSCall(sh, uri, payload, callback, ctx, ret_token, error);
}
bool LSCallCancel(LSHandle* sh, LSMessageToken token, LSError* error)
{
(void)sh;
(void)token;
(void)error;
return true;
}
const char* LSMessageGetPayload(LSMessage* message)
{
(void)message;
return NULL;
}
+33 -1
View File
@@ -114,6 +114,13 @@ async function main() {
check('mock bus in use', window.Luna.available === false);
check('settings loaded', !!(window.App.state.settings.capture));
eq('config path shown', $('config-path').textContent.indexOf('/var/lib/webosbrew') >= 0, true);
// This loads the raw source tree's index.html, not a packaged build, so
// the __APP_VERSION__ placeholder was never substituted — the fallback
// is the correct, honest thing to see here.
eq('unpackaged run shows the dev-build fallback, not a stale version',
window.App.state.appVersion, 'dev build');
check('app version shown in the System panel',
$('config-path').textContent.indexOf('dev build') >= 0);
console.log('status feed');
eq('starts stopped', $('state-pill').textContent, 'Stopped');
@@ -122,10 +129,20 @@ async function main() {
eq('no sinks listed while stopped', $('sink-status').textContent.trim(), 'Not running.');
console.log('panels');
eq('five sink cards', doc.querySelectorAll('.sink-card').length, 5);
eq('six sink cards', doc.querySelectorAll('.sink-card').length, 6);
check('hyperhdr card is first and marked',
doc.querySelector('.sink-card .badge').textContent === 'Recommended');
check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]'));
const restrictPicker = doc.querySelector('[data-path="hyperhdrAdjust.restrictToApp"]');
check('restrict-to-app picker exists', !!restrictPicker);
eq('restrict-to-app picker starts on Always active', restrictPicker.textContent, 'Always active');
// Always active -> the first installed app alphabetically by title
// ("Live TV", ahead of Netflix/Spotify/YouTube in the mock's list).
click(restrictPicker);
await wait(600);
eq('picking an app reaches settings by id, not a typed value',
window.App.state.settings.hyperhdrAdjust.restrictToApp, 'com.webos.app.livetv');
eq('picker now shows the app name, not the id', restrictPicker.textContent, 'Live TV');
check('backend choice exists', !!doc.querySelector('[data-path="capture.backend"]'));
check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]'));
check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));
@@ -191,6 +208,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 +221,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');
+108
View File
@@ -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())
+51 -11
View File
@@ -68,17 +68,55 @@ EOF
echo "$toolchain"
}
check_ares() {
command -v ares-package >/dev/null 2>&1 || cat >&2 <<'EOF'
error: ares-package is not on PATH
PACKAGER_NODE_IMAGE="${PACKAGER_NODE_IMAGE:-node:18-bookworm-slim}"
npm install -g @webosose/ares-cli
check_ares() {
[ -f "$ROOT/node_modules/@webosose/ares-cli/bin/ares-package.js" ] && return 0
command -v ares-package >/dev/null 2>&1 && return 0
cat >&2 <<'EOF'
error: ares-cli is not installed
npm install
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
return 1
}
# ares-package's own packaging code (ar-async/fstream/tar, all last touched
# around 2017-2019) silently mishandles file metadata on very new Node
# releases: every mtime in the ipk comes out as 1970-01-01 instead of the real
# date. The archive still parses, so nothing here errors — the TV's installer
# is what eventually rejects it, as "ipk verify failed" with no clue why.
# Running the same ares-cli under a pinned, known-good Node avoids the whole
# class of bug regardless of what's on the host.
run_ares_package() {
local ares_js="$ROOT/node_modules/@webosose/ares-cli/bin/ares-package.js"
if [ -f "$ares_js" ] && command -v docker >/dev/null 2>&1; then
# Arguments are host paths under $ROOT; rewrite them to the container's
# mount point since nothing outside $ROOT is visible in there.
local args=() a
for a in "$@"; do
args+=("${a/#$ROOT//src}")
done
docker run --rm \
--volume "$ROOT:/src" \
--workdir /src \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
"$PACKAGER_NODE_IMAGE" \
node /src/node_modules/@webosose/ares-cli/bin/ares-package.js "${args[@]}"
return
fi
if [ -f "$ares_js" ]; then
say "warning: no docker, running ares-package on the host's own Node ($(node --version 2>/dev/null))"
say " if the ipk fails to install with a vague error, re-run with docker installed"
node "$ares_js" "$@"
return
fi
ares-package "$@"
}
build_native() {
@@ -105,12 +143,14 @@ stage() {
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()
python3 - "$STAGE_APP/index.html" "$ROOT/frontend/appinfo.json" <<'EOF'
import json, re, sys
html_path, appinfo_path = sys.argv[1], sys.argv[2]
html = open(html_path).read()
html = re.sub(r'\s*<script src="js/mock\.js"></script>', '', html)
open(path, "w").write(html)
version = json.load(open(appinfo_path))["version"]
html = html.replace("__APP_VERSION__", version)
open(html_path, "w").write(html)
EOF
cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/"
@@ -132,7 +172,7 @@ package() {
step "Packaging"
mkdir -p "$OUT_DIR"
rm -f "$OUT_DIR"/${APP_ID}_*.ipk
ares-package "$STAGE_APP" "$STAGE_SERVICE" -o "$OUT_DIR"
run_ares_package "$STAGE_APP" "$STAGE_SERVICE" -o "$OUT_DIR"
local ipk
ipk="$(ls -t "$OUT_DIR"/${APP_ID}_*.ipk | head -1)"
+32 -6
View File
@@ -6,15 +6,27 @@ The manifest (out/manifest.json) is one app entry: id, ipkUrl, ipkHash, and so
on. It is what you submit to webosbrew/apps-repo to get into the official
store.
The repo index (out/repo.json) is that same entry wrapped as
`{"packages": [...]}`, which is the format the Homebrew Channel's own
"Add repository" dialog expects (Settings -> Repositories -> Add repository).
Host it anywhere static, paste its URL in, and the app shows up in Browse —
no submission, no review, no shell access to the TV at all.
The repo index (out/repo.json) is `{"packages": [...]}`, which is the format
the Homebrew Channel's own "Add repository" dialog expects (Settings ->
Repositories -> Add repository). Host it anywhere static, paste its URL in,
and the app shows up in Browse — no submission, no review, no shell access to
the TV at all.
Each package entry embeds the full manifest under a "manifest" key. That is
not decoration: the app's details screen (DetailsPanel.refresh(), read
straight from its source) only ever uses entry.manifest directly, or fetches
entry.manifestUrl if entry.manifest is absent. Ship a repo.json without either
one and the details view calls resolveURL(undefined, ...), throws, and spins
on "Loading" forever with no error shown. Embedding beats a manifestUrl
because there is only one file to keep in sync.
python3 tools/make-manifest.py \\
--base-url https://git.example/you/lgtv-audio-cap/releases/download/v1.0.0
--base-url must be the exact release download URL, tag and all — the icon and
ipk links are absolute, so a tag typo (v1.0.0 vs 1.0.0) 404s silently rather
than falling back to anything.
Defaults to the newest ipk in out/ and writes out/manifest.json + out/repo.json.
"""
@@ -91,9 +103,23 @@ def main():
print("\nwrote %s" % os.path.relpath(args.out, ROOT), file=sys.stderr)
if args.repo_out:
# The grid view (BrowserPanel) reads id/title/iconUri straight off the
# package entry. The details view (DetailsPanel) only ever looks at
# entry.manifest directly, or fetches entry.manifestUrl if that is
# missing — never the entry's own top-level fields. Skipping
# manifestUrl (a second file, a second URL to keep in sync) by
# embedding the manifest here means DetailsPanel takes its
# already-ready fast path and never issues that fetch at all.
package_entry = {
"id": manifest["id"],
"title": manifest["title"],
"iconUri": manifest["iconUri"],
"shortDescription": manifest["appDescription"],
"manifest": manifest,
}
os.makedirs(os.path.dirname(args.repo_out) or ".", exist_ok=True)
with open(args.repo_out, "w") as fh:
json.dump({"packages": [manifest]}, fh, indent=2)
json.dump({"packages": [package_entry]}, fh, indent=2)
fh.write("\n")
print("wrote %s" % os.path.relpath(args.repo_out, ROOT), file=sys.stderr)
+87
View File
@@ -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>