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

259 lines
9.8 KiB
Markdown

# 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 |
### `hyperhdrAdjust` — brightness only, via HyperHDR's JSON-RPC
Sends no image at all. Instead it calls HyperHDR's `adjustment` command with
a `brightness` value (0-100) — a post-processing stage that applies
regardless of which priority is currently active — so an existing grabber or
capture app keeps deciding colour and only overall brightness reacts to
sound. Confirmed against a real HyperHDR instance by watching `brightness`
round-trip through `serverinfo` and the LEDs visibly respond; HyperHDR's own
`schema-adjustment.json` also documents a `scaleOutput` float (0.0-2.0), but
that field produced no effect on the same instance — what a schema declares
and what a given build actually acts on are not always the same thing.
| Key | Default | Meaning |
| --- | --- | --- |
| `host` | `""` | HyperHDR's address |
| `port` | `19444` | HyperHDR's classic JSON-RPC port (not 8090, not 19400) |
| `level` | `"rms"` | `rms` (steadier) or `peak` (punchier) |
| `minBrightness` | `20` | brightness (0-100) during silence |
| `maxBrightness` | `100` | brightness (0-100) at full level; HyperHDR does not go above 100 |
| `restrictToApp` | `""` | a webOS app id; blank means always active |
`restrictToApp` is set from a picker of installed apps in the UI (by name,
never typed), backed by a new `listApps` Luna method that proxies to
`com.webos.applicationManager/listApps` — the frontend never calls another
service's Luna API directly, everything goes through this service, same as
everywhere else. The service also subscribes once, at startup, to
`com.webos.applicationManager/getForegroundAppInfo` to know which app is
currently in front. Until that subscription has delivered at least one
reply, a restricted sink treats the target app as *not* active — the safe
failure mode, since silently reacting to audio when the user explicitly
restricted it to one app would be the wrong one. `getDiagnostics` exposes
the live value as `foregroundApp` if you want to confirm tracking is working.
### `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}]}` |
| `listApps` | `{}` | `{apps:[{id,title}]}` — every installed app, for the "restrict to app" picker |
| `getDiagnostics` | `{}` | `{backends, system, foregroundApp}` — 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, `connected`/`updatesSent`/`minBrightness`/`maxBrightness`/`lastError`
for the brightness sink, plus `restrictToApp`/`restrictedAppActive` when a
restriction is set.
### 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 '{}'
```