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

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

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

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

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

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

Bumped to 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:46:09 +02:00

233 lines
9.7 KiB
Markdown

# Building, testing and publishing
## What you need
| For | Install |
| --- | --- |
| the native service | the [openlgtv buildroot NDK](https://github.com/openlgtv/buildroot-nc4/releases) — or Docker, see below |
| packaging | `npm install` (ares-cli), or `npm install -g @webosose/ares-cli` |
| the tests | a host C compiler, Python 3, Node (optional: `flatbuffers`, `jsdom`) |
### The toolchain
The NDK is a **Linux** toolchain — there is no macOS or Windows build of it. On
Linux, unpack and relocate it once:
```sh
tar xf arm-webos-linux-gnueabi_sdk-buildroot-x86_64.tar.gz -C "$HOME"
"$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh"
```
Everywhere else, build the native part in a container:
```sh
./tools/docker-build.sh # -> build/native/audiocap-service
```
That bakes the SDK into an image, so it downloads once and later builds start
immediately. There are aarch64 and x86_64 SDK builds and the image picks
whichever matches the container, so on Apple Silicon it runs natively rather
than under emulation. Only the compile happens in the container; deployment
runs on the host, where the TV is reachable.
Packaging (`ares-package`) also runs in a container by default — pinned to
Node 18, not whatever Node the host has. This isn't optional hygiene: on a
newer Node (v22+, confirmed on v25) `ares-package`'s own dependencies
(`fstream`/`tar`, last touched around 2017-2019) silently zero out every
timestamp in the ipk instead of erroring. The archive still parses fine
everywhere generic tools look, so nothing here fails — the TV's installer is
what eventually rejects it, as an opaque `-5: ipk verify failed` with no
indication why. If Docker isn't available, `build.sh` falls back to the host's
own Node with a warning; if installs fail mysteriously in that mode, this is
the first thing to suspect — checked by unpacking `data.tar.gz` from the ipk
and confirming the timestamps aren't 1970-01-01.
Register the TV with ares once, using the Homebrew Channel's ssh (port 9922,
root):
```sh
ares-setup-device --add tv \
--info "{'host':'192.168.1.20','port':9922,'username':'root'}"
```
## Build and deploy
```sh
./tools/build.sh # cross-compile, stage, package -> out/*.ipk
./tools/build.sh install # ares-install on device "tv"
./tools/build.sh launch
./tools/build.sh logs
```
With the container toolchain it is two steps, since `build.sh` only knows how
to drive a local NDK:
```sh
./tools/docker-build.sh && ./tools/build.sh package
```
`DEVICE=livingroom ./tools/build.sh install` targets a different device;
`WEBOS_SDK=/opt/webos-sdk ./tools/build.sh` a differently placed NDK.
The same commands exist as npm scripts (`npm run build`, `npm run deploy`, …)
if that is more your habit. `build.sh` puts `node_modules/.bin` on PATH first,
so a local `npm install` of ares-cli is enough — no global install needed.
### What the packaging step does
`ares-package` takes two directories:
```text
build/stage/app frontend/, minus js/mock.js and its <script> tag
build/stage/service servicefiles/ plus the compiled audiocap-service
```
The service is declared native in `servicefiles/services.json`
(`"engine": "native"`), which is what makes webOS exec the binary rather than
look for a Node entry point.
## Tests
```sh
./test/run-tests.sh
```
Everything runs on the host — no TV involved:
| Suite | What it proves |
| --- | --- |
| syntax check | `service.c` and `main.c` compile against stub Luna/glib headers |
| `verify_flatbuf.py` | the hand-rolled FlatBuffers encoder matches what the official Python runtime decodes — union tags, defaults, vector contents |
| `verify_rtp.py` | the RTP sink and `host/lgtv-audiocap-receiver.py` agree: header layout, sequencing, timestamps, big-endian payload, MTU, and the SDP text |
| `engine_smoke.c` | capture → DSP → fan-out → sockets, including all 44 bytes of the WAV header and the status document |
| `ui_smoke.js` | the real `index.html` loaded in jsdom and driven like a remote |
The two optional dependencies:
```sh
python3 -m venv /tmp/fbvenv && /tmp/fbvenv/bin/pip install flatbuffers
npm install # jsdom
```
Without them those two suites print SKIP and the rest still runs.
To open the UI in a desktop browser — `js/mock.js` stands in for the Luna bus:
```sh
npm run serve # http://localhost:8000
```
## Publishing to the Homebrew Channel
Two ways to get an ipk into the Browse tab. Neither one touches `ares-install`
or ssh — the app installs itself once the TV can reach a URL.
### Your own repository (no review, no waiting)
The Homebrew Channel's *Settings → Repositories → Add repository* accepts any
URL that returns `{"packages": [...]}`. Each entry needs its own `id`/
`title`/`iconUri` for the Browse grid, plus the full manifest nested under a
`manifest` key for the details screen — [`make-manifest.py`](../tools/make-manifest.py)
builds exactly that shape. Point one at your own git host's release assets and
the app shows up in Browse with no submission process at all — this is what
`--repo-out` (on by default) is for.
1. Bump `version` in `frontend/appinfo.json`, `servicefiles/package.json` and
`package.json`.
2. Build the ipk: `./tools/docker-build.sh && ./tools/build.sh package` (or
`./tools/build.sh` on Linux with the NDK installed).
3. Create a release — note the **exact tag** Gitea/GitHub gives it, `1.0.0` or
`v1.0.0`, whichever it actually is — and attach three files: the ipk,
`frontend/assets/icon.png`, and a repo index generated with `--base-url`
set to that release's real download URL:
```sh
python3 tools/make-manifest.py \
--base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/1.0.0
# -> out/manifest.json (one app entry, for the official-repo route below)
# -> out/repo.json ({"packages": [{id, title, iconUri, manifest: {...}}]})
```
A mismatched tag in `--base-url` doesn't error — it just makes the icon and
ipk links inside `repo.json` 404 silently, which looks identical to "the
details screen hangs" from the client's point of view. If the app was
already added and only the tag was wrong, re-run with the fixed tag and
re-upload `repo.json`; no need to touch the "Add repository" entry itself,
since its URL didn't change.
Attach `out/repo.json` itself too — its own download URL is what you paste
into the TV.
4. On the TV: Homebrew Channel → gear icon → *Add repository* → paste the
`repo.json` release URL → back out to Browse → find *Audio Cap* → Install.
Shipping an update later is the same four steps with a bumped version and a
new release tag; the Channel diffs against the installed version and offers
an update once the repo URL is already added.
### The official repo (public listing, reviewed)
Submit once to [webosbrew/apps-repo](https://github.com/webosbrew/apps-repo)
and anyone with a stock Homebrew Channel install can find it without adding a
custom repository. That project wants a `.yml` descriptor pointing at your own
hosted `manifest.json` (the un-wrapped file from step 3 above) — see its README
for the submission format.
---
`rootRequired: true` in the manifest tells the Homebrew Channel the service
needs elevating, from either path. The app can also do it on demand —
*System → Grant root access* runs the Channel's `elevate-service` itself, over
the Luna bus, so it works whether SSH is enabled on the TV or not.
## How the service is put together
```text
main.c registers on the bus, runs the glib loop
service.c the Luna methods and the status subscription
engine.c the capture thread: read a block, analyse it, hand it to every sink
config.c load/merge/atomic-save of config.json
dsp.c peak/RMS envelopes and the 16-band analysis
foreground_app.c tracks which app is in front, for the "restrict to app"
brightness option -- the one place this service calls out
to another Luna service instead of being called
capture/ one file per backend, all dlopen-based
sinks/ one file per output
net/ RTP, FlatBuffers, the shared stream server
common/ JSON, logging, ring buffer, audio format
```
Three rules hold the design together:
**One format inside.** Everything between a backend and a sink is interleaved
signed 16-bit little-endian PCM at the configured rate. Backends convert on the
way in, sinks convert on the way out. Nothing in the middle branches on sample
type.
**Sinks must never block.** The engine calls every sink from the capture thread,
in order, and a sink that stalls stalls capture. Anything that can wait — a TCP
client that stopped reading, a HyperHDR host that is switched off — buffers
internally and drops the oldest audio instead. The Flatbuffers sink connects
non-blockingly and finishes the handshake on later blocks; sends are bounded to
200 ms.
**Audio libraries are `dlopen`ed, never linked.** A TV without `libpulse` must
still run the ALSA backend, and one with neither must still run the test tone
and start up cleanly. `ldd` on the binary shows glib, luna-service2, libc — no
audio.
### Adding a sink
1. Write `native/src/sinks/sink_yours.c` with an `open` that reads its own key
out of the settings object, plus `write`, `status` and `close`.
2. Define `const sink_driver_t sink_driver_yours` at the bottom and declare it
in `sinks/sink.h`.
3. Add it to the table in `sinks/sink.c` and to `DEFAULTS_JSON` in `config.c`.
4. Add its fields to `SINK_FIELDS` in `frontend/js/app.js`.
The UI needs nothing else: it builds the Outputs panel from `listSinks`.
### Adding a capture backend
The same shape in `native/src/capture/`, with an `available()` that answers
honestly on a TV that lacks the library, and an optional `describe()` that adds
its own fields to the diagnostics.