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>
This commit is contained in:
Rene Kievits
2026-08-26 10:21:00 +02:00
co-authored by Claude Opus 5
commit 7529a60650
73 changed files with 12826 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
# Configuration and the Luna API
Everything the UI does goes through the service's Luna API, so anything the UI
can do you can also do from an ssh session with `luna-send`.
## Where the settings live
```text
/var/lib/webosbrew/audiocap/config.json when the service can write there
/tmp/audiocap-config.json fallback, lost on reboot
$AUDIOCAP_CONFIG overrides both
```
The service falls back to `/tmp` when it is not running as root, and the UI says
so under *System → Settings file*. Settings written there survive the session
but not the TV.
Edits are merged, not replaced: sending `{"hyperhdr":{"port":5005}}` changes the
port and leaves everything else alone. Writes are atomic — a temporary file,
`fsync`, `rename` — so a power cut during a save cannot leave a truncated
config behind.
## Settings
### Top level
| Key | Default | Meaning |
| --- | --- | --- |
| `autoStart` | `false` | start capturing as soon as the service starts |
| `logLevel` | `"info"` | `error`, `warn`, `info` or `debug` |
| `sinks` | `["hyperhdr"]` | which outputs to open |
`autoStart` only matters if something starts the service at boot — that is what
the *Start on boot* toggle installs, a script in `/var/lib/webosbrew/init.d/`
that pokes the service so the Homebrew Channel launches it.
### `capture`
| Key | Default | Meaning |
| --- | --- | --- |
| `backend` | `"auto"` | `auto`, `pulse`, `alsa`, `exec` or `tone` |
| `device` | `""` | PulseAudio source, or an ALSA PCM like `hw:0,0` |
| `server` | `""` | PulseAudio server address; blank autodetects |
| `command` | `""` | for the `exec` backend |
| `rate` | `48000` | 44100 or 48000 |
| `channels` | `2` | 1 or 2 |
`auto` tries PulseAudio, then ALSA. It never tries `exec` — that one needs a
command only you can supply.
The backends:
| id | Name | Notes |
| --- | --- | --- |
| `pulse` | PulseAudio monitor | records the monitor source of the active sink; `libpulse.so.0` is `dlopen`ed at run time, so a TV without it simply reports the backend unavailable |
| `alsa` | ALSA PCM | same arrangement with `libasound.so.2` |
| `exec` | External command | runs a command and reads raw interleaved S16LE from its stdout, e.g. `arecord -D hw:0,0 -f S16_LE -r 48000 -c 2 -t raw` |
| `tone` | Test tone | a sweep; proves the network path without touching the TV's audio at all |
### `dsp`
| Key | Default | Meaning |
| --- | --- | --- |
| `attack` | `0.6` | seconds for the reported level to catch a rise |
| `release` | `0.12` | seconds for it to fall away |
These shape the numbers in the status document and the on-TV visualiser. They do
not touch the audio sent to any sink.
### `hyperhdr` — RTP/L16 audio
| Key | Default | Meaning |
| --- | --- | --- |
| `host` | `""` | the receiving machine |
| `port` | `5004` | UDP port |
| `multicast` | `false` | send to a group instead of a host |
| `multicastTtl` | `4` | hop limit when multicasting |
| `sapAnnounce` | `true` | announce over SAP so PulseAudio can find the stream |
Payload type 96, 16-bit big-endian PCM, packets kept under 1400 bytes of
payload so nothing fragments on a normal Ethernet MTU.
### `hyperhdrViz` — Flatbuffers images
| Key | Default | Meaning |
| --- | --- | --- |
| `host` | `""` | HyperHDR's address |
| `port` | `19400` | Flatbuffers port |
| `priority` | `150` | HyperHDR priority; lower wins |
| `width` / `height` | `64` / `36` | image size |
| `fps` | `30` | frames per second |
| `mode` | `"spectrum"` | `spectrum`, `level` or `pulse` |
| `saturation` | `1.0` | colour intensity |
| `minBrightness` | `0.02` | floor so the lights never go fully black |
### `udp`, `tcp`, `http`
| Key | Default | Meaning |
| --- | --- | --- |
| `udp.host` | `""` | destination; a host, a multicast group, or `255.255.255.255` |
| `udp.port` | `4010` | |
| `udp.multicastTtl` | `4` | |
| `tcp.port` | `4011` | the TV listens on this |
| `tcp.maxClients` | `4` | |
| `http.port` | `4012` | `/audio.wav` and `/audio.raw` |
| `http.maxClients` | `4` | |
All three carry interleaved S16**LE** — little-endian, unlike the RTP sink,
because that is what everything reading a raw pipe expects.
---
## The Luna API
Service name `org.webosbrew.audiocap.service`, all methods on `/`.
```sh
luna-send -n 1 -f luna://org.webosbrew.audiocap.service/getStatus '{}'
```
| Method | Payload | Reply |
| --- | --- | --- |
| `start` | optional settings patch, applied and saved first | the status document |
| `stop` | `{}` | the status document |
| `getStatus` | `{"subscribe":true}` for a feed every 100 ms | the status document |
| `isRunning` | `{}` | `{isRunning, state}` |
| `getConfig` | `{}` | `{path, persistent, settings}` |
| `setConfig` | a patch, bare or under `settings` | `{saved, restartRequired, settings}` |
| `resetConfig` | `{}` | `{saved, settings}` |
| `listBackends` | `{}` | `{backends:[{id,name,description,available}]}` |
| `listSinks` | `{}` | `{sinks:[{id,name,description}]}` |
| `getDiagnostics` | `{}` | `{backends, system}` — see below |
| `getLogs` | `{"clear":true}` optional | `{logs}` |
| `quit` | `{}` | ends the process; the next call starts a new one |
`setConfig` reports `restartRequired: true` when the capture is running, because
most settings are read when a run starts.
### The status document
```json
{
"returnValue": true,
"state": "running",
"running": true,
"error": null,
"capture": {
"backend": "pulse",
"backendName": "PulseAudio monitor",
"device": "…monitor",
"rate": 48000,
"channels": 2,
"frames": 4915200,
"blocks": 9600,
"timeouts": 0,
"uptimeMs": 102400
},
"levels": {
"peak": 0.42, "rms": 0.19,
"peakDb": -7.5, "rmsDb": -14.4,
"clipping": false,
"bands": [0.0, "…16 values…"]
},
"sinks": [
{ "id": "hyperhdr", "ok": true, "name": "HyperHDR audio (RTP)",
"error": null, "target": "192.168.1.50", "port": 5004,
"packetsSent": 12345, "bytesSent": 4321000, "sendErrors": 0 }
],
"configPath": "/var/lib/webosbrew/audiocap/config.json",
"configPersistent": true
}
```
`state` is `stopped`, `starting`, `running` or `error`. A sink that failed to
open reports `ok: false` and an `error`, and the run continues without it — one
broken output does not take the others down.
Each sink adds its own fields. `packetsSent`/`bytesSent`/`sendErrors` for the
datagram sinks, `clients`/`droppedBytes` for the stream servers,
`connected`/`registered`/`framesSent`/`connectFailures`/`lastError` for the
visualiser.
### Diagnostics
```json
{
"backends": [{ "id": "pulse", "name": "…", "available": false }],
"system": {
"root": true,
"uid": 0,
"libraries": { "libpulse.so.0": "/usr/lib/libpulse.so.0",
"libasound.so.2": null },
"binaries": { "parec": false, "pactl": true },
"pulseSockets": ["/var/run/pulse/native"],
"pactlSources": "…",
"alsaCards": ["0 [Loopback]: …"],
"alsaCapturePcms": ["00-01: …"]
}
}
```
This is the fastest way to find out why a backend reports itself unavailable.
`tools/tv-probe.sh` collects the same picture from a shell, plus a few things
the service does not look at.
## Doing it from a shell
```sh
# change settings and start in one call (the patch is saved, like setConfig)
luna-send -n 1 -f luna://org.webosbrew.audiocap.service/start \
'{"capture":{"backend":"tone"},"sinks":["http"]}'
# point the RTP sink somewhere else and keep it
luna-send -n 1 -f luna://org.webosbrew.audiocap.service/setConfig \
'{"settings":{"hyperhdr":{"host":"192.168.1.50"}}}'
# watch the level
luna-send -i -f luna://org.webosbrew.audiocap.service/getStatus '{"subscribe":true}'
# what went wrong
luna-send -n 1 -f luna://org.webosbrew.audiocap.service/getLogs '{}'
```
+152
View File
@@ -0,0 +1,152 @@
# Building, testing and publishing
## What you need
| For | Install |
| --- | --- |
| the native service | the [openlgtv buildroot NDK](https://github.com/openlgtv/buildroot-nc4/releases), `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:
```sh
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):
```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
```
`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:
```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
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:
```sh
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](https://github.com/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
```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
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.
+163
View File
@@ -0,0 +1,163 @@
# Connecting to HyperHDR
HyperHDR's music effects read a **local capture device**. There is no network
audio input to send to, no API to push samples into. Everything below is a way
of working around that.
Three routes, in the order you should try them.
---
## 1. RTP audio into a loopback device (recommended)
The TV sends RTP/L16 to the HyperHDR machine; a receiver there plays it into a
loopback; HyperHDR captures the other end of that loopback. HyperHDR sees a
perfectly ordinary sound card and does its own analysis, so every effect works
exactly as it would with a real input.
```
TV ──RTP/L16 udp/5004──► lgtv-audiocap-receiver.py ──► hw:Loopback,0,0
║ snd-aloop
HyperHDR ◄──── hw:Loopback,1,0
```
### On the HyperHDR machine
```sh
git clone <this repo> && cd lgtv-audio-cap
sudo ./host/install-loopback.sh --install-service
```
That loads `snd-aloop` (persisting it across reboots), keeps PulseAudio's hands
off the loopback card, installs the receiver into `/usr/local/bin` and starts it
as a systemd unit. It finishes by printing the exact device name to give
HyperHDR.
To do it by hand instead:
```sh
sudo modprobe snd-aloop index=10 pcm_substreams=1 id=Loopback
./host/lgtv-audiocap-receiver.py --output aplay --device hw:Loopback,0,0
```
### On the TV
*Outputs → HyperHDR audio (RTP/L16)*
| Setting | Value |
| --- | --- |
| Receiver address | the HyperHDR machine's IP |
| UDP port | 5004 |
| Multicast | off |
| Announce over SAP | on (harmless, and needed for route 2) |
Press **Start**.
### In HyperHDR
Settings → *Sound capture* (in newer builds; older ones put it under the music
effect itself) → input device `hw:Loopback,1,0`, then choose a music effect.
### Checking it
```sh
# is anything arriving at all?
./host/lgtv-audiocap-receiver.py --port 5004 --output - | \
aplay -f S16_LE -r 48000 -c 2 -
```
The receiver prints a line every 30 seconds with packet, loss and restart
counts. Losses in the low hundreds over hours are normal on Wi-Fi; a steady
stream of them means the TV's Wi-Fi is the bottleneck and the set really wants
Ethernet.
---
## 2. RTP straight into PulseAudio, nothing installed
If the HyperHDR machine runs PulseAudio or PipeWire and HyperHDR can reach it
through the ALSA `pulse` device, you do not need the receiver at all. The TV
announces the stream over SAP and PulseAudio builds a source from it.
```sh
pactl load-module module-rtp-recv sap_address=224.0.0.56
```
With *Announce over SAP* enabled on the TV, a source called something like
`rtp_recv.LG TV Audio Cap` appears within five seconds. Point HyperHDR at its
monitor.
This is the least code, but it is also the least predictable: PulseAudio's RTP
receiver has no jitter buffer worth the name, and PipeWire's compatibility layer
does not always implement the module. Treat it as a nice surprise if it works.
For unicast rather than SAP discovery, turn *Announce over SAP* off and load:
```sh
pactl load-module module-rtp-recv sap_address=0.0.0.0 port=5004
```
---
## 3. The TV does the visualising
No host software, no sound device. The TV analyses the audio, renders a small
image and sends it to HyperHDR's Flatbuffers port, the same way a
`hyperion-remote` or a screen grabber would.
*Outputs → HyperHDR visualiser*
| Setting | Value |
| --- | --- |
| HyperHDR address | the HyperHDR machine's IP |
| Flatbuffers port | 19400 |
| Style | Spectrum, Level bar or Pulse |
| Priority | 150 (lower numbers win in HyperHDR) |
In HyperHDR, make sure the Flatbuffers server is enabled (Settings → Network
Services → Flatbuffers server, default port 19400).
What you give up: HyperHDR's own effects, colour calibration on the audio path,
and any hope of the lights matching an effect you have configured elsewhere. The
TV decides what the lights show. What you gain: it works in about a minute.
The three styles:
- **Spectrum** — 16 bands across the image, hue by frequency.
- **Level bar** — one bar that tracks the overall level.
- **Pulse** — the whole image flashes with the beat.
`saturation` and `minBrightness` shape the output; `minBrightness: 0` lets the
lights go fully dark between beats, which looks dramatic and slightly broken.
---
## Which one to use
| | Route 1 | Route 2 | Route 3 |
| --- | --- | --- | --- |
| Host software | receiver + loopback | none | none |
| HyperHDR effects | all of them | all of them | none, the TV renders |
| Latency | ~100 ms | ~100 ms, less stable | ~40 ms |
| Robustness | good | depends on your PulseAudio | good |
| Setup time | 10 minutes | 2 minutes if it works | 1 minute |
Route 1 unless you have a reason.
---
## Latency
Roughly, end to end on route 1:
| Stage | Typical |
| --- | --- |
| TV capture block | 11 ms (512 frames at 48 kHz) |
| Network | 1–5 ms wired, 5–40 ms Wi-Fi |
| Receiver prebuffer | 60 ms, `--prebuffer-ms` |
| Playback buffer | 80 ms, `--latency-ms` |
| HyperHDR's own analysis | 20–50 ms |
Around 150–200 ms in total, which for ambient lighting is imperceptible. If you
want it tighter, lower `--latency-ms` and `--prebuffer-ms` until the audio
starts crackling, then go back up one step.
+136
View File
@@ -0,0 +1,136 @@
# When it does not work
Work down the chain: does the service run, does it capture, does the audio
leave the TV, does it arrive, does HyperHDR see it.
## The app says "No service"
The service did not start. Reinstall the app, then check from a shell on the TV:
```sh
luna-send -n 1 -f luna://org.webosbrew.audiocap.service/isRunning '{}'
ls -l /media/developer/apps/usr/palm/services/org.webosbrew.audiocap.service/
```
The binary must be there and executable. If `luna-send` reports
`Service does not exist`, the service failed to register on the bus — usually a
missing `services.json` or a binary built for the wrong architecture. Check with
`file audiocap-service`: it should say ARM, EABI5.
## "The service is running as uid …" instead of as root
It has not been elevated. Press *System → Grant root access*, or run:
```sh
/media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/elevate-service \
org.webosbrew.audiocap.service
```
Then restart the service (`quit` on the bus, or just relaunch the app).
Without root the audio devices are unreadable and the settings file falls back
to `/tmp`.
## Nothing is captured
*Capture → Backend* on Automatic and the meter stays flat.
1. **System → Run diagnostics.** Look at `libraries` and `pulseSockets`. If
`libpulse.so.0` and `libasound.so.2` are both `null`, no backend can work as
built and you need the `exec` backend with whatever binary the TV does have.
2. **Run the probe** for the fuller picture:
```sh
ssh -p 9922 root@TV-IP 'sh -s' < tools/tv-probe.sh
```
3. **Prove the rest of the chain first.** Set the backend to *Test tone* and
start. If the tone reaches HyperHDR, the problem is only the capture end.
Common outcomes by firmware:
| What the probe shows | What to do |
| --- | --- |
| a PulseAudio socket and `pactl` lists a `.monitor` source | backend `pulse`, device = that source name (or blank) |
| `/proc/asound` with a capture PCM | backend `alsa`, device `hw:X,Y` from `arecord -l` |
| only `arecord` or `gst-launch-1.0` | backend `exec`, e.g. `arecord -D hw:0,0 -f S16_LE -r 48000 -c 2 -t raw` |
| nothing at all | the audio path is inside the closed audio daemon; only the test tone will run |
## The meter moves but the lights do not
Audio is being captured, so it is the transport or HyperHDR.
**Is it leaving the TV?** Enable the HTTP output and open
`http://TV-IP:4012/audio.wav` in VLC. If you hear the TV, the TV's side is
fine.
**Is it arriving?** On the HyperHDR machine:
```sh
./host/lgtv-audiocap-receiver.py --port 5004 --output - | \
aplay -f S16_LE -r 48000 -c 2 -
```
Nothing? Check the firewall (`sudo ufw allow 5004/udp`) and that the receiver
address on the TV is right. Watch the sink's own counters in
*Status → Outputs*: `sendErrors` climbing means the TV cannot even send.
**Is HyperHDR listening to the right device?** The loopback has two ends and
they are easy to swap. The receiver plays into `hw:Loopback,0,0`; HyperHDR must
capture `hw:Loopback,1,0`.
**Is something else holding the loopback?** PulseAudio grabs cards it finds.
`install-loopback.sh` writes a udev rule to keep it away; if you set the card up
by hand, add it yourself:
```text
/etc/udev/rules.d/89-lgtv-audiocap-loopback.rules
ATTRS{id}=="Loopback", ENV{PULSE_IGNORE}="1", ENV{ACP_IGNORE}="1"
```
## The audio crackles or drops out
The receiver prints loss statistics every 30 seconds. Read those first.
| Symptom | Cause | Fix |
| --- | --- | --- |
| steady loss on Wi-Fi | the TV's radio | Ethernet, or the visualiser output instead |
| loss in bursts | buffer too small for the jitter | `--latency-ms 150 --prebuffer-ms 120` |
| clicks with no reported loss | the loopback and the stream disagree about the rate | make the TV's rate, the receiver's `--rate` and HyperHDR's device all 48000 |
| audio slowly drifts out of sync | free-running clocks, unavoidable in a one-way stream | it is a few ms per hour; restart the receiver if it ever matters |
## The lights react to the wrong thing
HyperHDR is showing another source at a higher priority. The visualiser sink
sends at priority 150 by default; a screen grabber usually sits at 240 and a
static colour at 100. Lower numbers win. Set the visualiser's priority below
whatever is currently on screen.
## Settings do not survive a reboot
*System → Settings file* says `Temporary (/tmp)`. The service is not root, so it
cannot write `/var/lib/webosbrew/audiocap/`. Grant root access; the next save
lands in the persistent path.
## It does not start with the TV
*System → Start on boot* installs a symlink in `/var/lib/webosbrew/init.d/`,
which the Homebrew Channel runs at boot. Check it:
```sh
ls -l /var/lib/webosbrew/init.d/audiocapautostart
```
If it is missing, the Homebrew Channel's `exec` refused the call — its
"root access" toggle has to be on. If it is there and nothing happens at boot,
check that *autoStart* is also on: the script only wakes the service, and the
service decides for itself whether to start capturing.
## Reading the log
```sh
luna-send -n 1 -f luna://org.webosbrew.audiocap.service/getLogs '{}'
```
or *System → Show log* in the app. Set *Log level* to Debug first if you are
chasing something specific — it is the last 200 lines only, and at Debug they
go by quickly.