Files
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

9.7 KiB

Building, testing and publishing

What you need

For Install
the native service the openlgtv buildroot NDK — 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:

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:

./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):

ares-setup-device --add tv \
  --info "{'host':'192.168.1.20','port':9922,'username':'root'}"

Build and deploy

./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:

./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:

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

./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:

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:

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 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:

    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 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

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 dlopened, 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.