Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3e4cb6410 | ||
|
|
e9f6c87d27 | ||
|
|
aae5a33283 | ||
|
|
f0f68a1aa7 | ||
|
|
d2931bee63 | ||
|
|
0759cc00aa | ||
|
|
f3a4cddfd6 | ||
|
|
f622c3a0bf |
@@ -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 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 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 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 |
|
| **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 |
|
| **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)
|
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 "$@"
|
||||||
+38
-2
@@ -93,6 +93,39 @@ payload so nothing fragments on a normal Ethernet MTU.
|
|||||||
| `saturation` | `1.0` | colour intensity |
|
| `saturation` | `1.0` | colour intensity |
|
||||||
| `minBrightness` | `0.02` | floor so the lights never go fully black |
|
| `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`
|
### `udp`, `tcp`, `http`
|
||||||
|
|
||||||
| Key | Default | Meaning |
|
| Key | Default | Meaning |
|
||||||
@@ -129,7 +162,8 @@ luna-send -n 1 -f luna://org.webosbrew.audiocap.service/getStatus '{}'
|
|||||||
| `resetConfig` | `{}` | `{saved, settings}` |
|
| `resetConfig` | `{}` | `{saved, settings}` |
|
||||||
| `listBackends` | `{}` | `{backends:[{id,name,description,available}]}` |
|
| `listBackends` | `{}` | `{backends:[{id,name,description,available}]}` |
|
||||||
| `listSinks` | `{}` | `{sinks:[{id,name,description}]}` |
|
| `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}` |
|
| `getLogs` | `{"clear":true}` optional | `{logs}` |
|
||||||
| `quit` | `{}` | ends the process; the next call starts a new one |
|
| `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
|
Each sink adds its own fields. `packetsSent`/`bytesSent`/`sendErrors` for the
|
||||||
datagram sinks, `clients`/`droppedBytes` for the stream servers,
|
datagram sinks, `clients`/`droppedBytes` for the stream servers,
|
||||||
`connected`/`registered`/`framesSent`/`connectFailures`/`lastError` for the
|
`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
|
### Diagnostics
|
||||||
|
|
||||||
|
|||||||
+37
-14
@@ -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
|
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
|
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
|
whichever matches the container, so on Apple Silicon it runs natively rather
|
||||||
than under emulation. Only the compile happens in the container; packaging and
|
than under emulation. Only the compile happens in the container; deployment
|
||||||
deployment run on the host, where the TV is reachable.
|
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,
|
Register the TV with ares once, using the Homebrew Channel's ssh (port 9922,
|
||||||
root):
|
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)
|
### Your own repository (no review, no waiting)
|
||||||
|
|
||||||
The Homebrew Channel's *Settings → Repositories → Add repository* accepts any
|
The Homebrew Channel's *Settings → Repositories → Add repository* accepts any
|
||||||
URL that returns `{"packages": [...]}`, where each entry is the same manifest
|
URL that returns `{"packages": [...]}`. Each entry needs its own `id`/
|
||||||
[`make-manifest.py`](../tools/make-manifest.py) already writes. Point one at
|
`title`/`iconUri` for the Browse grid, plus the full manifest nested under a
|
||||||
your own git host's release assets and the app shows up in Browse with no
|
`manifest` key for the details screen — [`make-manifest.py`](../tools/make-manifest.py)
|
||||||
submission process at all — this is what `--repo-out` (on by default) is for.
|
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
|
1. Bump `version` in `frontend/appinfo.json`, `servicefiles/package.json` and
|
||||||
`package.json`.
|
`package.json`.
|
||||||
2. Build the ipk: `./tools/docker-build.sh && ./tools/build.sh package` (or
|
2. Build the ipk: `./tools/docker-build.sh && ./tools/build.sh package` (or
|
||||||
`./tools/build.sh` on Linux with the NDK installed).
|
`./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
|
3. Create a release — note the **exact tag** Gitea/GitHub gives it, `1.0.0` or
|
||||||
ipk, `frontend/assets/icon.png`, and a repo index generated with
|
`v1.0.0`, whichever it actually is — and attach three files: the ipk,
|
||||||
`--base-url` set to that release's asset URL:
|
`frontend/assets/icon.png`, and a repo index generated with `--base-url`
|
||||||
|
set to that release's real download URL:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 tools/make-manifest.py \
|
python3 tools/make-manifest.py \
|
||||||
--base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/v1.0.0
|
--base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/1.0.0
|
||||||
# -> out/manifest.json (one app entry)
|
# -> out/manifest.json (one app entry, for the official-repo route below)
|
||||||
# -> out/repo.json (that entry wrapped as {"packages": [...]})
|
# -> 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
|
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
|
into the TV.
|
||||||
inside it point at the wrong place.
|
|
||||||
4. On the TV: Homebrew Channel → gear icon → *Add repository* → paste the
|
4. On the TV: Homebrew Channel → gear icon → *Add repository* → paste the
|
||||||
`repo.json` release URL → back out to Browse → find *Audio Cap* → Install.
|
`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
|
engine.c the capture thread: read a block, analyse it, hand it to every sink
|
||||||
config.c load/merge/atomic-save of config.json
|
config.c load/merge/atomic-save of config.json
|
||||||
dsp.c peak/RMS envelopes and the 16-band analysis
|
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
|
capture/ one file per backend, all dlopen-based
|
||||||
sinks/ one file per output
|
sinks/ one file per output
|
||||||
net/ RTP, FlatBuffers, the shared stream server
|
net/ RTP, FlatBuffers, the shared stream server
|
||||||
|
|||||||
+98
-8
@@ -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)*
|
||||||
@@ -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
|
## Which one to use
|
||||||
|
|
||||||
| | Route 1 | Route 2 | Route 3 |
|
| | Route 1 | Route 2 | Route 3 | Route 4 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Host software | receiver + loopback | none | none |
|
| Host software | receiver + loopback | none | none | none |
|
||||||
| HyperHDR effects | all of them | all of them | none, the TV renders |
|
| HyperHDR effects | all of them | all of them | none, the TV renders | your existing grabber, untouched |
|
||||||
| Latency | ~100 ms | ~100 ms, less stable | ~40 ms |
|
| 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 |
|
||||||
| Robustness | good | depends on your PulseAudio | good |
|
| Latency | ~100 ms | ~100 ms, less stable | ~40 ms | ~50 ms |
|
||||||
| Setup time | 10 minutes | 2 minutes if it works | 1 minute |
|
| 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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "org.webosbrew.audiocap",
|
"id": "org.webosbrew.audiocap",
|
||||||
"version": "1.0.0",
|
"version": "1.0.5",
|
||||||
"vendor": "Homebrew",
|
"vendor": "Homebrew",
|
||||||
"type": "web",
|
"type": "web",
|
||||||
"main": "index.html",
|
"main": "index.html",
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=1920, initial-scale=1">
|
<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>
|
<title>Audio Cap</title>
|
||||||
<link rel="stylesheet" href="css/app.css">
|
<link rel="stylesheet" href="css/app.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+147
-1
@@ -13,15 +13,27 @@
|
|||||||
var ELEVATE = '/media/developer/apps/usr/palm/services/'
|
var ELEVATE = '/media/developer/apps/usr/palm/services/'
|
||||||
+ 'org.webosbrew.hbchannel.service/elevate-service';
|
+ '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 = {
|
var state = {
|
||||||
settings: {},
|
settings: {},
|
||||||
status: null,
|
status: null,
|
||||||
backends: [],
|
backends: [],
|
||||||
sinkDefs: [],
|
sinkDefs: [],
|
||||||
|
installedApps: [],
|
||||||
configPath: '',
|
configPath: '',
|
||||||
persistent: true,
|
persistent: true,
|
||||||
bootLinked: false,
|
bootLinked: false,
|
||||||
diagnostics: null,
|
diagnostics: null,
|
||||||
|
appVersion: readAppVersion(),
|
||||||
};
|
};
|
||||||
|
|
||||||
var statusSub = null;
|
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 = [
|
var CAPTURE_FIELDS = [
|
||||||
{
|
{
|
||||||
path: 'capture.backend', label: 'Backend', type: 'choice',
|
path: 'capture.backend', label: 'Backend', type: 'choice',
|
||||||
@@ -199,7 +267,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,
|
||||||
@@ -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: [
|
udp: [
|
||||||
{
|
{
|
||||||
path: 'udp.host', label: 'Destination', type: 'text', wide: true,
|
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 '
|
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 '
|
+ 'finished images over the Flatbuffers port. Use it when you cannot add '
|
||||||
+ 'a sound device on the HyperHDR machine.',
|
+ '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 '
|
udp: 'Raw interleaved S16LE, no header, no framing. Lowest latency and no '
|
||||||
+ 'connection to lose.',
|
+ 'connection to lose.',
|
||||||
tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of '
|
tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of '
|
||||||
@@ -464,6 +589,7 @@
|
|||||||
|
|
||||||
var info = $('config-path');
|
var info = $('config-path');
|
||||||
UI.clear(info);
|
UI.clear(info);
|
||||||
|
info.appendChild(infoItem('App version', state.appVersion));
|
||||||
info.appendChild(infoItem('Path', state.configPath || '—'));
|
info.appendChild(infoItem('Path', state.configPath || '—'));
|
||||||
info.appendChild(infoItem('Storage', state.persistent
|
info.appendChild(infoItem('Storage', state.persistent
|
||||||
? 'Persistent' : 'Temporary (/tmp)'));
|
? 'Persistent' : 'Temporary (/tmp)'));
|
||||||
@@ -586,9 +712,15 @@
|
|||||||
if (s.framesSent !== undefined) {
|
if (s.framesSent !== undefined) {
|
||||||
bits.push(s.framesSent.toLocaleString() + ' frames');
|
bits.push(s.framesSent.toLocaleString() + ' frames');
|
||||||
}
|
}
|
||||||
|
if (s.updatesSent !== undefined) {
|
||||||
|
bits.push(s.updatesSent.toLocaleString() + ' updates');
|
||||||
|
}
|
||||||
if (s.connected !== undefined) {
|
if (s.connected !== undefined) {
|
||||||
bits.push(s.connected ? 'connected' : 'not connected');
|
bits.push(s.connected ? 'connected' : 'not connected');
|
||||||
}
|
}
|
||||||
|
if (s.restrictToApp) {
|
||||||
|
bits.push(s.restrictedAppActive ? 'active now' : 'waiting for that app');
|
||||||
|
}
|
||||||
if (s.sendErrors) {
|
if (s.sendErrors) {
|
||||||
bits.push(s.sendErrors + ' send errors');
|
bits.push(s.sendErrors + ' send errors');
|
||||||
}
|
}
|
||||||
@@ -714,6 +846,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));
|
||||||
@@ -788,6 +924,16 @@
|
|||||||
renderSinks();
|
renderSinks();
|
||||||
}, fail);
|
}, 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();
|
renderCapture();
|
||||||
renderSystem();
|
renderSystem();
|
||||||
checkBootLink();
|
checkBootLink();
|
||||||
|
|||||||
@@ -101,6 +101,9 @@
|
|||||||
listSinks: function (ok, fail) {
|
listSinks: function (ok, fail) {
|
||||||
return call(SERVICE + 'listSinks', {}, ok, fail);
|
return call(SERVICE + 'listSinks', {}, ok, fail);
|
||||||
},
|
},
|
||||||
|
listApps: function (ok, fail) {
|
||||||
|
return call(SERVICE + 'listApps', {}, ok, fail);
|
||||||
|
},
|
||||||
getDiagnostics: function (ok, fail) {
|
getDiagnostics: function (ok, fail) {
|
||||||
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
|
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
|
||||||
},
|
},
|
||||||
|
|||||||
+26
-1
@@ -15,6 +15,10 @@
|
|||||||
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
|
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
|
||||||
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02,
|
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 },
|
udp: { host: '', port: 4010, multicastTtl: 4 },
|
||||||
tcp: { port: 4011, maxClients: 4 },
|
tcp: { port: 4011, maxClients: 4 },
|
||||||
http: { port: 4012, maxClients: 4 },
|
http: { port: 4012, maxClients: 4 },
|
||||||
@@ -146,12 +150,24 @@
|
|||||||
sinks: [
|
sinks: [
|
||||||
{ id: 'hyperhdr', name: 'HyperHDR audio (RTP)', description: 'RTP/L16 audio to the HyperHDR host.' },
|
{ 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: '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: '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: '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.' },
|
{ 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,
|
// Same shape as capture_write_diagnostics(): backends at the top level,
|
||||||
// everything about the machine under "system".
|
// everything about the machine under "system".
|
||||||
case 'getDiagnostics':
|
case 'getDiagnostics':
|
||||||
@@ -173,7 +189,16 @@
|
|||||||
},
|
},
|
||||||
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.
|
||||||
|
// 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'],
|
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
|
||||||
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
|
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ add_executable(audiocap-service
|
|||||||
src/main.c
|
src/main.c
|
||||||
src/service.c
|
src/service.c
|
||||||
src/engine.c
|
src/engine.c
|
||||||
|
src/foreground_app.c
|
||||||
src/config.c
|
src/config.c
|
||||||
src/dsp.c
|
src/dsp.c
|
||||||
src/common/log.c
|
src/common/log.c
|
||||||
@@ -46,6 +47,7 @@ add_executable(audiocap-service
|
|||||||
src/sinks/sink.c
|
src/sinks/sink.c
|
||||||
src/sinks/sink_hyperhdr.c
|
src/sinks/sink_hyperhdr.c
|
||||||
src/sinks/sink_hyperhdr_viz.c
|
src/sinks/sink_hyperhdr_viz.c
|
||||||
|
src/sinks/sink_hyperhdr_adjust.c
|
||||||
src/sinks/sink_udp.c
|
src/sinks/sink_udp.c
|
||||||
src/sinks/sink_tcp.c
|
src/sinks/sink_tcp.c
|
||||||
src/sinks/sink_http.c
|
src/sinks/sink_http.c
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ static const char* DEFAULTS_JSON =
|
|||||||
" \"saturation\": 1.0,"
|
" \"saturation\": 1.0,"
|
||||||
" \"minBrightness\": 0.02"
|
" \"minBrightness\": 0.02"
|
||||||
" },"
|
" },"
|
||||||
|
" \"hyperhdrAdjust\": {"
|
||||||
|
" \"host\": \"\","
|
||||||
|
" \"port\": 19444,"
|
||||||
|
" \"minBrightness\": 20,"
|
||||||
|
" \"maxBrightness\": 100,"
|
||||||
|
" \"level\": \"rms\","
|
||||||
|
" \"restrictToApp\": \"\""
|
||||||
|
" },"
|
||||||
" \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 },"
|
" \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 },"
|
||||||
" \"tcp\": { \"port\": 4011, \"maxClients\": 4 },"
|
" \"tcp\": { \"port\": 4011, \"maxClients\": 4 },"
|
||||||
" \"http\": { \"port\": 4012, \"maxClients\": 4 }"
|
" \"http\": { \"port\": 4012, \"maxClients\": 4 }"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "common/log.h"
|
#include "common/log.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "engine.h"
|
#include "engine.h"
|
||||||
|
#include "foreground_app.h"
|
||||||
#include "sinks/sink.h"
|
#include "sinks/sink.h"
|
||||||
|
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
@@ -347,6 +348,67 @@ static bool method_list_sinks(LSHandle* sh, LSMessage* msg, void* ctx)
|
|||||||
return true;
|
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)
|
static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx)
|
||||||
{
|
{
|
||||||
(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_obj_open(&w, NULL);
|
||||||
jw_bool(&w, "returnValue", true);
|
jw_bool(&w, "returnValue", true);
|
||||||
capture_write_diagnostics(&w);
|
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);
|
jw_obj_close(&w);
|
||||||
reply_json(sh, msg, jw_take(&w));
|
reply_json(sh, msg, jw_take(&w));
|
||||||
return true;
|
return true;
|
||||||
@@ -407,6 +476,7 @@ static LSMethod s_methods[] = {
|
|||||||
{ "resetConfig", method_reset_config, LUNA_METHOD_FLAGS_NONE },
|
{ "resetConfig", method_reset_config, LUNA_METHOD_FLAGS_NONE },
|
||||||
{ "listBackends", method_list_backends, LUNA_METHOD_FLAGS_NONE },
|
{ "listBackends", method_list_backends, LUNA_METHOD_FLAGS_NONE },
|
||||||
{ "listSinks", method_list_sinks, 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 },
|
{ "getDiagnostics", method_get_diagnostics, LUNA_METHOD_FLAGS_NONE },
|
||||||
{ "getLogs", method_get_logs, LUNA_METHOD_FLAGS_NONE },
|
{ "getLogs", method_get_logs, LUNA_METHOD_FLAGS_NONE },
|
||||||
{ "quit", method_quit, 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;
|
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;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +531,7 @@ void service_destroy(service_t* s)
|
|||||||
{
|
{
|
||||||
if (!s)
|
if (!s)
|
||||||
return;
|
return;
|
||||||
|
foreground_app_stop(s->handle);
|
||||||
engine_destroy(s->engine);
|
engine_destroy(s->engine);
|
||||||
config_free(s->config);
|
config_free(s->config);
|
||||||
free(s);
|
free(s);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
static const sink_driver_t* const s_drivers[] = {
|
static const sink_driver_t* const s_drivers[] = {
|
||||||
&sink_driver_hyperhdr,
|
&sink_driver_hyperhdr,
|
||||||
&sink_driver_hyperhdr_viz,
|
&sink_driver_hyperhdr_viz,
|
||||||
|
&sink_driver_hyperhdr_adjust,
|
||||||
&sink_driver_udp,
|
&sink_driver_udp,
|
||||||
&sink_driver_tcp,
|
&sink_driver_tcp,
|
||||||
&sink_driver_http,
|
&sink_driver_http,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct sink {
|
|||||||
// own translation unit see one declaration.
|
// own translation unit see one declaration.
|
||||||
extern const sink_driver_t sink_driver_hyperhdr;
|
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_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_udp;
|
||||||
extern const sink_driver_t sink_driver_tcp;
|
extern const sink_driver_t sink_driver_tcp;
|
||||||
extern const sink_driver_t sink_driver_http;
|
extern const sink_driver_t sink_driver_http;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lgtv-audio-cap",
|
"name": "lgtv-audio-cap",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lgtv-audio-cap",
|
"name": "lgtv-audio-cap",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@webosose/ares-cli": "^2.4.0",
|
"@webosose/ares-cli": "^2.4.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lgtv-audio-cap",
|
"name": "lgtv-audio-cap",
|
||||||
"version": "1.0.0",
|
"version": "1.0.5",
|
||||||
"private": true,
|
"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.",
|
"description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "org.webosbrew.audiocap.service",
|
"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",
|
"description": "Captures TV audio and streams it to HyperHDR and other receivers",
|
||||||
"main": "audiocap-service"
|
"main": "audiocap-service"
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -204,12 +204,22 @@ static void test_status(engine_t* e)
|
|||||||
check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported");
|
check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported");
|
||||||
|
|
||||||
const json_value_t* sinks = json_get(v, "sinks");
|
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++) {
|
for (size_t i = 0; i < json_len(sinks); i++) {
|
||||||
const json_value_t* s = json_at(sinks, i);
|
const json_value_t* s = json_at(sinks, i);
|
||||||
char label[64];
|
char label[64];
|
||||||
snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?"));
|
snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?"));
|
||||||
check(json_bool(s, "ok", false), label);
|
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);
|
json_free(v);
|
||||||
}
|
}
|
||||||
@@ -224,9 +234,11 @@ int main(void)
|
|||||||
char cfg_text[512];
|
char cfg_text[512];
|
||||||
snprintf(cfg_text, sizeof(cfg_text),
|
snprintf(cfg_text, sizeof(cfg_text),
|
||||||
"{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2},"
|
"{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2},"
|
||||||
"\"sinks\":[\"tcp\",\"http\"],"
|
"\"sinks\":[\"tcp\",\"http\",\"hyperhdrAdjust\"],"
|
||||||
"\"tcp\":{\"port\":%d},"
|
"\"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);
|
TCP_PORT, HTTP_PORT);
|
||||||
|
|
||||||
json_value_t* cfg = json_parse(cfg_text);
|
json_value_t* cfg = json_parse(cfg_text);
|
||||||
|
|||||||
+20
-2
@@ -13,12 +13,14 @@ export PYTHONDONTWRITEBYTECODE=1
|
|||||||
OUT=$(mktemp -d)
|
OUT=$(mktemp -d)
|
||||||
trap 'rm -rf "$OUT"' EXIT
|
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=(
|
SOURCES=(
|
||||||
native/src/engine.c
|
native/src/engine.c
|
||||||
native/src/config.c
|
native/src/config.c
|
||||||
native/src/dsp.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/log.c
|
||||||
native/src/common/json.c
|
native/src/common/json.c
|
||||||
native/src/common/ringbuf.c
|
native/src/common/ringbuf.c
|
||||||
@@ -33,6 +35,7 @@ SOURCES=(
|
|||||||
native/src/sinks/sink.c
|
native/src/sinks/sink.c
|
||||||
native/src/sinks/sink_hyperhdr.c
|
native/src/sinks/sink_hyperhdr.c
|
||||||
native/src/sinks/sink_hyperhdr_viz.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_udp.c
|
||||||
native/src/sinks/sink_tcp.c
|
native/src/sinks/sink_tcp.c
|
||||||
native/src/sinks/sink_http.c
|
native/src/sinks/sink_http.c
|
||||||
@@ -40,7 +43,7 @@ SOURCES=(
|
|||||||
|
|
||||||
echo "== Syntax-checking the webOS-only sources against stub headers"
|
echo "== Syntax-checking the webOS-only sources against stub headers"
|
||||||
for f in native/src/service.c native/src/main.c; do
|
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"
|
echo " ok $f"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -64,6 +67,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
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ typedef struct {
|
|||||||
} LSError;
|
} LSError;
|
||||||
|
|
||||||
typedef bool (*LSMethodFunction)(LSHandle* sh, LSMessage* msg, void* category_context);
|
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 {
|
typedef enum {
|
||||||
LUNA_METHOD_FLAGS_NONE = 0,
|
LUNA_METHOD_FLAGS_NONE = 0,
|
||||||
@@ -58,6 +60,17 @@ bool LSGmainAttach(LSHandle* handle, GMainLoop* loop, LSError* error);
|
|||||||
const char* LSMessageGetPayload(LSMessage* message);
|
const char* LSMessageGetPayload(LSMessage* message);
|
||||||
bool LSMessageIsSubscription(LSMessage* message);
|
bool LSMessageIsSubscription(LSMessage* message);
|
||||||
bool LSMessageReply(LSHandle* sh, LSMessage* message, const char* reply, LSError* error);
|
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 LSSubscriptionAdd(LSHandle* sh, const char* key, LSMessage* message, LSError* error);
|
||||||
bool LSSubscriptionReply(LSHandle* sh, const char* key, const char* payload, 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
@@ -114,6 +114,13 @@ async function main() {
|
|||||||
check('mock bus in use', window.Luna.available === false);
|
check('mock bus in use', window.Luna.available === false);
|
||||||
check('settings loaded', !!(window.App.state.settings.capture));
|
check('settings loaded', !!(window.App.state.settings.capture));
|
||||||
eq('config path shown', $('config-path').textContent.indexOf('/var/lib/webosbrew') >= 0, true);
|
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');
|
console.log('status feed');
|
||||||
eq('starts stopped', $('state-pill').textContent, 'Stopped');
|
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.');
|
eq('no sinks listed while stopped', $('sink-status').textContent.trim(), 'Not running.');
|
||||||
|
|
||||||
console.log('panels');
|
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',
|
check('hyperhdr card is first and marked',
|
||||||
doc.querySelector('.sink-card .badge').textContent === 'Recommended');
|
doc.querySelector('.sink-card .badge').textContent === 'Recommended');
|
||||||
check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]'));
|
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('backend choice exists', !!doc.querySelector('[data-path="capture.backend"]'));
|
||||||
check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]'));
|
check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]'));
|
||||||
check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));
|
check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));
|
||||||
@@ -191,6 +208,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 +221,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())
|
||||||
+51
-11
@@ -68,17 +68,55 @@ EOF
|
|||||||
echo "$toolchain"
|
echo "$toolchain"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_ares() {
|
PACKAGER_NODE_IMAGE="${PACKAGER_NODE_IMAGE:-node:18-bookworm-slim}"
|
||||||
command -v ares-package >/dev/null 2>&1 || cat >&2 <<'EOF'
|
|
||||||
error: ares-package is not on PATH
|
|
||||||
|
|
||||||
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):
|
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'}"
|
ares-setup-device --add tv --info "{'host':'192.168.1.20','port':9922,'username':'root'}"
|
||||||
EOF
|
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() {
|
build_native() {
|
||||||
@@ -105,12 +143,14 @@ stage() {
|
|||||||
cp -R "$ROOT/frontend/." "$STAGE_APP/"
|
cp -R "$ROOT/frontend/." "$STAGE_APP/"
|
||||||
# The mock only exists so the UI can be opened in a desktop browser.
|
# The mock only exists so the UI can be opened in a desktop browser.
|
||||||
rm -f "$STAGE_APP/js/mock.js"
|
rm -f "$STAGE_APP/js/mock.js"
|
||||||
python3 - "$STAGE_APP/index.html" <<'EOF'
|
python3 - "$STAGE_APP/index.html" "$ROOT/frontend/appinfo.json" <<'EOF'
|
||||||
import re, sys
|
import json, re, sys
|
||||||
path = sys.argv[1]
|
html_path, appinfo_path = sys.argv[1], sys.argv[2]
|
||||||
html = open(path).read()
|
html = open(html_path).read()
|
||||||
html = re.sub(r'\s*<script src="js/mock\.js"></script>', '', html)
|
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
|
EOF
|
||||||
|
|
||||||
cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/"
|
cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/"
|
||||||
@@ -132,7 +172,7 @@ package() {
|
|||||||
step "Packaging"
|
step "Packaging"
|
||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
rm -f "$OUT_DIR"/${APP_ID}_*.ipk
|
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
|
local ipk
|
||||||
ipk="$(ls -t "$OUT_DIR"/${APP_ID}_*.ipk | head -1)"
|
ipk="$(ls -t "$OUT_DIR"/${APP_ID}_*.ipk | head -1)"
|
||||||
|
|||||||
+32
-6
@@ -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
|
on. It is what you submit to webosbrew/apps-repo to get into the official
|
||||||
store.
|
store.
|
||||||
|
|
||||||
The repo index (out/repo.json) is that same entry wrapped as
|
The repo index (out/repo.json) is `{"packages": [...]}`, which is the format
|
||||||
`{"packages": [...]}`, which is the format the Homebrew Channel's own
|
the Homebrew Channel's own "Add repository" dialog expects (Settings ->
|
||||||
"Add repository" dialog expects (Settings -> Repositories -> Add repository).
|
Repositories -> Add repository). Host it anywhere static, paste its URL in,
|
||||||
Host it anywhere static, paste its URL in, and the app shows up in Browse —
|
and the app shows up in Browse — no submission, no review, no shell access to
|
||||||
no submission, no review, no shell access to the TV at all.
|
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 \\
|
python3 tools/make-manifest.py \\
|
||||||
--base-url https://git.example/you/lgtv-audio-cap/releases/download/v1.0.0
|
--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.
|
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)
|
print("\nwrote %s" % os.path.relpath(args.out, ROOT), file=sys.stderr)
|
||||||
|
|
||||||
if args.repo_out:
|
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)
|
os.makedirs(os.path.dirname(args.repo_out) or ".", exist_ok=True)
|
||||||
with open(args.repo_out, "w") as fh:
|
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")
|
fh.write("\n")
|
||||||
print("wrote %s" % os.path.relpath(args.repo_out, ROOT), file=sys.stderr)
|
print("wrote %s" % os.path.relpath(args.repo_out, ROOT), file=sys.stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -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