Files
lgtv_audio_cap/docs/development.md
T
Rene KievitsandClaude Opus 5 7529a60650 Audio capture and streaming app for webOS 5/6
Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.

  native/       the service: capture backends (PulseAudio, ALSA, exec,
                test tone, all dlopen-based), DSP, and one file per sink
  frontend/     D-pad driven UI at a fixed 1920x1080
  servicefiles/ native service manifest plus the boot script
  host/         RTP receiver and the loopback installer for the HyperHDR
                machine
  tools/        build/package, asset generation, Homebrew Channel
                manifest, on-TV probe
  test/         host-side suites: FlatBuffers and RTP verified against
                real decoders, the engine end to end, the page in jsdom

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

5.5 KiB

Building, testing and publishing

What you need

For Install
the native service the openlgtv buildroot NDK, arm-webos-linux-gnueabi_sdk-buildroot
packaging npm install -g @webosose/ares-cli
the tests a host C compiler, Python 3, Node (optional: flatbuffers, jsdom)

Unpack the NDK and relocate it once:

tar xf arm-webos-linux-gnueabi_sdk-buildroot.tar.gz -C "$HOME"
"$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh"

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

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.

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

  1. Bump version in frontend/appinfo.json, servicefiles/package.json and package.json.

  2. ./tools/build.sh and test the ipk on a real set.

  3. Attach the ipk and frontend/assets/icon.png to a release.

  4. Generate the manifest and attach that too:

    python3 tools/make-manifest.py \
      --base-url https://github.com/you/lgtv-audio-cap/releases/download/v1.0.0
    
  5. 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

  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.