The openlgtv NDK is a Linux toolchain with no macOS or Windows build, so
tools/build.sh could not produce a binary anywhere else. docker-build.sh
bakes the SDK into an image and compiles there; packaging and deploy stay
on the host, where the TV is reachable. The SDK ships aarch64 as well as
x86_64, so the image picks the one matching the daemon and Apple Silicon
builds natively rather than under emulation.
Cross-compiling for real turned up three things the host compiler did
not:
sink_hyperhdr_viz.c read p->width and p->height to format the error
message after free(p)
sink_hyperhdr.c an SDP connection line of 128 bytes cannot hold
"IN IP4 " plus a 127-byte host plus "/255", so a
long hostname would silently lose its TTL suffix
common/log.c the log body was sized to the whole ring line,
leaving nothing for the prefix; budget for it so
the bound is provable rather than left to
snprintf
A clean cross-compile is now warning-free, and readelf confirms the
design rule holds: luna-service2, glib, PmLogLib and libc, with no
libpulse or libasound.
Also: @webosose/ares-cli was pinned to ^3.0.0, which does not exist
(latest is 2.4.0), so npm install failed outright. build.sh now puts
node_modules/.bin on PATH so a local install is enough.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.4 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; packaging and deployment run on the host, where the TV is reachable.
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
-
Bump
versioninfrontend/appinfo.json,servicefiles/package.jsonandpackage.json. -
./tools/build.shand test the ipk on a real set. -
Attach the ipk and
frontend/assets/icon.pngto a release. -
Generate the manifest and attach that too:
python3 tools/make-manifest.py \ --base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/v1.0.0 -
Submit the manifest URL to webosbrew/repo.
The manifest sets "rootRequired": true, which tells the Homebrew Channel the
service needs elevating. The app can also do it on demand — System → Grant root
access runs the Channel's elevate-service.
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
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
- Write
native/src/sinks/sink_yours.cwith anopenthat reads its own key out of the settings object, pluswrite,statusandclose. - Define
const sink_driver_t sink_driver_yoursat the bottom and declare it insinks/sink.h. - Add it to the table in
sinks/sink.cand toDEFAULTS_JSONinconfig.c. - Add its fields to
SINK_FIELDSinfrontend/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.