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
+6
View File
@@ -0,0 +1,6 @@
build/
out/
node_modules/
__pycache__/
*.pyc
.DS_Store
+115
View File
@@ -0,0 +1,115 @@
# LG TV Audio Cap
Captures the audio playing on an LG webOS 5 or 6 TV and streams it off the set.
The reason it exists is HyperHDR: ambient lighting that reacts to what the TV is
actually playing, without a microphone in the room or an audio splitter behind
the telly.
It installs through the [Homebrew Channel](https://github.com/webosbrew/webos-homebrew-channel)
and runs as a native background service with a remote-friendly UI in front of
it.
```
┌────────────────────── LG webOS TV ──────────────────────┐
│ PulseAudio / ALSA / a command │
│ │ │
│ capture ─► level + 16-band analysis │
│ │ │
│ ├─► RTP/L16 ──────────────► HyperHDR host │ ← the main path
│ ├─► Flatbuffers images ───► HyperHDR │
│ ├─► raw PCM over UDP │
│ ├─► raw PCM over TCP │
│ └─► WAV over HTTP │
└─────────────────────────────────────────────────────────┘
```
## Why it works this way
HyperHDR has **no network audio input**. Its sound-reactive effects read a
*local* capture device. So the main path here does not try to talk to HyperHDR
at all: it sends the TV's audio to the HyperHDR machine as RTP/L16, and a small
receiver there turns it into a normal sound device that HyperHDR can select.
That is [`host/lgtv-audiocap-receiver.py`](host/lgtv-audiocap-receiver.py), and
[`host/install-loopback.sh`](host/install-loopback.sh) sets up the loopback for
it.
If you would rather not run anything on the HyperHDR machine, there is a second
path: the TV does the frequency analysis itself and sends finished images to
HyperHDR's Flatbuffers port. Fewer moving parts, but the lights react to the
TV's idea of the spectrum rather than to real audio.
See [docs/hyperhdr.md](docs/hyperhdr.md) for both, step by step.
## Requirements
- An LG TV on webOS 5 or 6, rooted, with the Homebrew Channel installed.
- Root for the service. The TV's audio devices are not readable otherwise; the
app has a **Grant root access** button that calls the Homebrew Channel's
`elevate-service` for you.
- For the main HyperHDR path: a Linux machine running HyperHDR with either
`snd-aloop` or PulseAudio/PipeWire available.
## Installing
**From the Homebrew Channel.** Open the Homebrew Channel on the TV, find
*Audio Cap*, install, launch.
**From an ipk.** Copy the ipk to the TV and install it with the Homebrew
Channel's *Install from file*, or from a workstation:
```sh
ares-install --device tv out/org.webosbrew.audiocap_1.0.0_all.ipk
```
**From source.** See [docs/development.md](docs/development.md).
## First run
1. Launch **Audio Cap** on the TV.
2. **System → Root access**: press *Grant root access* if it says the service is
not root. It restarts itself.
3. **Capture → Backend**: leave it on *Automatic* to begin with. If nothing is
captured, run [`tools/tv-probe.sh`](tools/tv-probe.sh) on the TV to see what
your firmware actually offers, then pick a backend by hand.
4. **Outputs → HyperHDR audio (RTP/L16)**: turn it on and enter the address of
the machine running HyperHDR.
5. On that machine: `sudo ./host/install-loopback.sh --install-service`, then
point HyperHDR's sound capture at the device it prints.
6. Press **Start** on the TV. The level meter should move.
Nothing captured, no idea why? [docs/troubleshooting.md](docs/troubleshooting.md).
## The other outputs
Each can run at the same time as the others.
| Output | What it is | Use it for |
| --- | --- | --- |
| **HyperHDR audio** | RTP/L16, port 5004 | the main path; also readable by PulseAudio's `module-rtp-recv` with no custom software |
| **HyperHDR visualiser** | Flatbuffers images, port 19400 | HyperHDR with nothing installed on the host |
| **Raw PCM over UDP** | S16LE datagrams, port 4010 | your own scripts; lowest latency |
| **Raw PCM over TCP** | S16LE stream, port 4011 | anything that would rather connect than listen |
| **HTTP WAV** | `http://tv:4012/audio.wav` | opening the TV's audio in VLC |
## Layout
```
native/ the webOS service: capture, DSP, sinks, Luna API (C)
frontend/ the on-TV app (plain HTML/CSS/JS, no framework)
servicefiles/ services.json, package.json and the boot script
host/ the receiver and loopback setup for the HyperHDR machine
tools/ build, packaging, asset generation, on-TV probe
test/ host-side tests: wire formats, the capture pipeline, the UI
docs/ the longer explanations
```
## Documentation
- [docs/hyperhdr.md](docs/hyperhdr.md) — connecting it to HyperHDR, both ways
- [docs/configuration.md](docs/configuration.md) — every setting, and the Luna API
- [docs/development.md](docs/development.md) — building, testing, packaging, publishing
- [docs/troubleshooting.md](docs/troubleshooting.md) — when it does not work
## License
MIT.
+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.
+15
View File
@@ -0,0 +1,15 @@
{
"id": "org.webosbrew.audiocap",
"version": "1.0.0",
"vendor": "Homebrew",
"type": "web",
"main": "index.html",
"title": "Audio Cap",
"appDescription": "Capture TV audio and stream it to HyperHDR and other receivers",
"icon": "assets/icon.png",
"largeIcon": "assets/largeIcon.png",
"iconColor": "#101820",
"splashBackground": "assets/splash.png",
"bgImage": "assets/splash.png",
"disableBackHistoryAPI": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+514
View File
@@ -0,0 +1,514 @@
/* Designed against a 1920x1080 viewport, which is what webOS reports to an app
* on both a 1080p and a 4K panel. Sizes are absolute px for that reason.
*
* Two rules shape everything here: text is read from the sofa, so nothing is
* smaller than 22px; and focus is the only cursor, so the focused element has
* to be unmistakable across the room. */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0b0e13;
color: #e8ecf3;
font-family: "LG Smart UI", "Museo Sans", "Helvetica Neue", Arial, sans-serif;
font-size: 26px;
line-height: 1.4;
/* The remote is the pointer. A caret blinking somewhere off-screen is only
* ever confusing. */
-webkit-user-select: none;
user-select: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
/* Overscan safe area: older sets crop up to 5% of each edge. */
padding: 40px 60px 30px;
}
/* --- header --------------------------------------------------------------- */
#header {
display: flex;
align-items: center;
flex: none;
gap: 30px;
padding-bottom: 22px;
border-bottom: 2px solid #1d2430;
}
.brand h1 {
margin: 0;
font-size: 46px;
font-weight: 300;
letter-spacing: 0.5px;
}
.subtitle {
color: #7c8798;
font-size: 22px;
}
.header-status {
display: flex;
align-items: center;
gap: 18px;
margin-left: auto;
}
.pill {
display: inline-block;
padding: 8px 24px;
border-radius: 999px;
background: #1d2430;
color: #9aa6b8;
font-size: 24px;
white-space: nowrap;
}
.pill.running {
background: #123a24;
color: #57d98a;
}
.pill.starting {
background: #3a3212;
color: #e2c657;
}
.pill.error {
background: #3d1a1c;
color: #ff7b7b;
}
.uptime {
color: #7c8798;
font-size: 22px;
min-width: 110px;
}
/* --- tabs ----------------------------------------------------------------- */
#tabs {
display: flex;
flex: none;
gap: 14px;
padding: 20px 0;
}
.tab {
padding: 12px 34px;
border: 2px solid transparent;
border-radius: 12px;
background: #151b25;
color: #9aa6b8;
font: inherit;
cursor: default;
}
.tab.active {
background: #1e2a3d;
color: #ffffff;
}
/* --- content -------------------------------------------------------------- */
#content {
flex: 1;
overflow-y: auto;
padding-right: 14px;
/* Room to scroll the last card clear of the bottom edge. */
padding-bottom: 120px;
}
#content::-webkit-scrollbar {
width: 10px;
}
#content::-webkit-scrollbar-thumb {
border-radius: 5px;
background: #2a3444;
}
.panel {
display: flex;
flex-direction: column;
gap: 22px;
}
.card {
padding: 26px 32px;
border: 2px solid #1d2430;
border-radius: 16px;
background: #121722;
}
.card h2 {
margin: 0 0 6px;
color: #cfd8e6;
font-size: 30px;
font-weight: 400;
}
.card.error {
border-color: #5a2226;
background: #1c1215;
color: #ff9d9d;
}
.blurb {
margin: 0 0 14px;
max-width: 1200px;
color: #7c8798;
font-size: 22px;
}
.muted {
color: #7c8798;
}
.hidden {
display: none !important;
}
/* --- fields --------------------------------------------------------------- */
.field {
display: flex;
align-items: center;
gap: 30px;
padding: 14px 0;
border-top: 1px solid #1a212c;
}
.field:first-child {
border-top: none;
}
.field-text {
flex: 1;
min-width: 0;
}
.field-label {
font-size: 26px;
}
.field-hint {
color: #7c8798;
font-size: 21px;
}
.field-control {
flex: none;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding-top: 16px;
}
/* --- controls ------------------------------------------------------------- */
.btn {
min-width: 150px;
padding: 12px 30px;
border: 2px solid #2c3646;
border-radius: 12px;
background: #1c2432;
color: #e8ecf3;
font: inherit;
text-align: center;
cursor: default;
}
.btn.primary {
background: #1f4d76;
border-color: #2b6ba6;
}
.btn.danger {
border-color: #5a2226;
background: #2a171a;
color: #ff9d9d;
}
.btn.toggle {
min-width: 120px;
}
.btn.toggle.on {
border-color: #2f7a4d;
background: #143324;
color: #6ee29a;
}
.btn.choice {
min-width: 300px;
}
.input {
width: 360px;
padding: 12px 18px;
border: 2px solid #2c3646;
border-radius: 12px;
background: #0d1119;
color: #e8ecf3;
font: inherit;
/* Text fields are the one place typing happens. */
-webkit-user-select: text;
user-select: text;
}
.input.wide {
width: 620px;
}
/* One focus treatment for every control: a bright ring plus a lift. Visible
* from across the room on both an OLED and a dim LCD. */
.focusable:focus {
outline: none;
border-color: #4aa3ff;
box-shadow: 0 0 0 4px rgba(74, 163, 255, 0.35);
}
.btn.focusable:focus,
.tab.focusable:focus {
background: #2b6ba6;
color: #ffffff;
}
.btn.toggle.on.focusable:focus {
background: #2f7a4d;
color: #ffffff;
}
.btn.danger.focusable:focus {
background: #8a2f36;
color: #ffffff;
}
/* --- meter ---------------------------------------------------------------- */
.meter {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px 0 18px;
}
.meter-row {
display: flex;
align-items: center;
gap: 20px;
}
.meter-label {
width: 90px;
color: #7c8798;
font-size: 22px;
}
.meter-db {
width: 130px;
color: #9aa6b8;
font-size: 22px;
text-align: right;
/* dB numbers jitter constantly; tabular digits stop the label dancing. */
font-variant-numeric: tabular-nums;
}
.bar {
flex: 1;
height: 26px;
overflow: hidden;
border-radius: 13px;
background: #0d1119;
}
.bar-fill {
width: 0;
height: 100%;
border-radius: 13px;
background: linear-gradient(90deg, #3aa76d, #7bd44f 70%, #e2c657 88%, #ff5f5f);
/* Short enough to feel live at the service's 100 ms notify interval. */
transition: width 80ms linear;
}
.bar.rms .bar-fill {
background: linear-gradient(90deg, #2b6ba6, #4aa3ff);
}
.bands {
display: flex;
align-items: flex-end;
gap: 8px;
height: 180px;
padding: 12px 0 0;
border-top: 1px solid #1a212c;
}
.band {
flex: 1;
min-height: 3px;
border-radius: 5px 5px 2px 2px;
background: linear-gradient(180deg, #4aa3ff, #1f4d76);
transition: height 80ms linear;
}
.clip {
margin-top: 14px;
color: #ff7b7b;
font-size: 24px;
}
/* --- info grid ------------------------------------------------------------ */
.info-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 18px 30px;
padding-top: 12px;
}
.info-item {
min-width: 0;
}
.info-key {
color: #7c8798;
font-size: 21px;
}
.info-value {
overflow: hidden;
font-size: 27px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- sink status ---------------------------------------------------------- */
.sink-status {
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 12px;
}
.sink-line {
display: flex;
align-items: baseline;
gap: 18px;
}
.dot {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4b5666;
}
.dot.ok {
background: #57d98a;
}
.dot.bad {
background: #ff5f5f;
}
.sink-name {
min-width: 320px;
}
.sink-detail {
color: #7c8798;
font-size: 22px;
}
/* --- sink cards ----------------------------------------------------------- */
#sink-cards {
display: flex;
flex-direction: column;
gap: 22px;
}
.sink-card .sink-head {
display: flex;
align-items: center;
gap: 30px;
}
.sink-card .sink-head h2 {
margin: 0;
}
.sink-card .sink-head .field-control {
margin-left: auto;
}
.sink-card.off .sink-body {
opacity: 0.45;
}
.badge {
padding: 4px 16px;
border-radius: 999px;
background: #1f4d76;
color: #9fd0ff;
font-size: 20px;
}
/* --- output / logs -------------------------------------------------------- */
.output {
max-height: 520px;
margin: 18px 0 0;
padding: 20px;
overflow: auto;
border-radius: 12px;
background: #0d1119;
color: #b8c4d4;
font-family: "Courier New", monospace;
font-size: 20px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
-webkit-user-select: text;
user-select: text;
}
/* --- toast ---------------------------------------------------------------- */
.toast {
position: fixed;
left: 50%;
bottom: 48px;
z-index: 10;
padding: 16px 40px;
transform: translateX(-50%);
border: 2px solid #2c3646;
border-radius: 14px;
background: #1c2432;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
font-size: 24px;
}
.toast.bad {
border-color: #5a2226;
background: #2a171a;
color: #ff9d9d;
}
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1920, initial-scale=1">
<title>Audio Cap</title>
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<div id="app">
<header id="header">
<div class="brand">
<h1>Audio Cap</h1>
<div class="subtitle">TV audio to HyperHDR and friends</div>
</div>
<div class="header-status">
<span id="state-pill" class="pill">Connecting</span>
<span id="uptime" class="uptime"></span>
</div>
<button id="power" type="button" class="focusable btn primary">Start</button>
</header>
<nav id="tabs">
<button type="button" class="focusable tab" data-panel="panel-status">Status</button>
<button type="button" class="focusable tab" data-panel="panel-sinks">Outputs</button>
<button type="button" class="focusable tab" data-panel="panel-capture">Capture</button>
<button type="button" class="focusable tab" data-panel="panel-system">System</button>
</nav>
<main id="content">
<section id="panel-status" class="panel">
<div class="card">
<h2>Level</h2>
<div class="meter">
<div class="meter-row">
<span class="meter-label">Peak</span>
<div id="meter-peak" class="bar peak"><div class="bar-fill"></div></div>
<span id="meter-peak-db" class="meter-db">—</span>
</div>
<div class="meter-row">
<span class="meter-label">RMS</span>
<div id="meter-rms" class="bar rms"><div class="bar-fill"></div></div>
<span id="meter-rms-db" class="meter-db">—</span>
</div>
</div>
<div id="bands" class="bands"></div>
<div id="clip" class="clip hidden">Clipping</div>
</div>
<div class="card">
<h2>Capture</h2>
<div id="capture-info" class="info-grid"></div>
</div>
<div class="card">
<h2>Outputs</h2>
<div id="sink-status" class="sink-status">
<div class="muted">Not running.</div>
</div>
</div>
<div id="error-card" class="card error hidden">
<h2>Error</h2>
<div id="error-text"></div>
</div>
</section>
<section id="panel-sinks" class="panel hidden">
<div id="sink-cards"></div>
</section>
<section id="panel-capture" class="panel hidden">
<div class="card">
<h2>Source</h2>
<div id="capture-fields"></div>
</div>
<div class="card">
<h2>Level follower</h2>
<p class="blurb">
How quickly the reported level rises and falls. Lower is faster.
</p>
<div id="dsp-fields"></div>
</div>
</section>
<section id="panel-system" class="panel hidden">
<div class="card">
<h2>Service</h2>
<div id="system-fields"></div>
</div>
<div class="card">
<h2>Diagnostics</h2>
<p class="blurb">
What the service can see on this TV: audio libraries, capture
devices and whether it is running as root.
</p>
<div class="actions">
<button id="run-diagnostics" type="button" class="focusable btn">Run diagnostics</button>
<button id="load-logs" type="button" class="focusable btn">Show log</button>
<button id="clear-logs" type="button" class="focusable btn">Clear log</button>
</div>
<pre id="output" class="output hidden"></pre>
</div>
<div class="card">
<h2>Settings file</h2>
<div id="config-path" class="info-grid"></div>
<div class="actions">
<button id="reset-config" type="button" class="focusable btn danger">Reset to defaults</button>
</div>
</div>
</section>
</main>
<div id="toast" class="toast hidden"></div>
</div>
<script src="js/mock.js"></script>
<script src="js/luna.js"></script>
<script src="js/nav.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+826
View File
@@ -0,0 +1,826 @@
// Wiring: load the settings, draw the panels, subscribe to the status feed.
//
// The service owns the settings; this file never keeps a second copy of the
// truth. Every edit goes out as a patch and the reply is what updates `state`.
(function (global) {
'use strict';
var SERVICE_ID = 'org.webosbrew.audiocap.service';
var SERVICE_DIR = '/media/developer/apps/usr/palm/services/' + SERVICE_ID;
var BOOT_SCRIPT = SERVICE_DIR + '/audiocapautostart';
var BOOT_LINK = '/var/lib/webosbrew/init.d/audiocapautostart';
var ELEVATE = '/media/developer/apps/usr/palm/services/'
+ 'org.webosbrew.hbchannel.service/elevate-service';
var state = {
settings: {},
status: null,
backends: [],
sinkDefs: [],
configPath: '',
persistent: true,
bootLinked: false,
diagnostics: null,
};
var statusSub = null;
var saveTimer = null;
var pendingPatch = null;
var toastTimer = null;
// --- small helpers --------------------------------------------------------
function $(id) {
return document.getElementById(id);
}
function merge(base, patch) {
Object.keys(patch).forEach(function (k) {
var v = patch[k];
if (v && typeof v === 'object' && !Array.isArray(v)
&& base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
merge(base[k], v);
} else {
base[k] = v;
}
});
return base;
}
function toast(message, bad) {
var node = $('toast');
node.textContent = message;
node.classList.toggle('bad', !!bad);
node.classList.remove('hidden');
if (toastTimer) {
clearTimeout(toastTimer);
}
toastTimer = setTimeout(function () {
node.classList.add('hidden');
}, bad ? 6000 : 3000);
}
function fail(message) {
toast(message, true);
}
function duration(ms) {
if (!ms) {
return '';
}
var total = Math.floor(ms / 1000);
var h = Math.floor(total / 3600);
var m = Math.floor((total % 3600) / 60);
var s = total % 60;
function pad(n) {
return n < 10 ? '0' + n : String(n);
}
return h ? h + ':' + pad(m) + ':' + pad(s) : m + ':' + pad(s);
}
// --- saving ---------------------------------------------------------------
// Edits are coalesced: holding Enter on a choice fires a change per press and
// there is no reason to write the settings file that often.
function setSetting(path, value) {
var patch = UI.patchFor(path, value);
merge(state.settings, patch);
pendingPatch = pendingPatch ? merge(pendingPatch, patch) : patch;
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(flush, 400);
}
function flush() {
saveTimer = null;
if (!pendingPatch) {
return;
}
var patch = pendingPatch;
pendingPatch = null;
Luna.setConfig(patch, function (reply) {
if (reply.settings) {
state.settings = reply.settings;
}
if (!reply.saved) {
toast('Saved to /tmp only — settings will be lost on reboot', true);
} else if (reply.restartRequired) {
toast('Restart the capture to apply');
}
}, fail);
}
// --- field building -------------------------------------------------------
// `specs` is a list of { path, label, hint, type, options, wide, when,
// rebuild }. `rebuild` marks a field whose value changes which other fields
// are shown, so the panel is redrawn after it changes.
function buildFields(container, specs, redraw) {
var focusedPath = document.activeElement
&& document.activeElement.getAttribute
&& document.activeElement.getAttribute('data-path');
UI.clear(container);
specs.forEach(function (spec) {
if (spec.when && !spec.when(state.settings)) {
return;
}
var value = UI.get(state.settings, spec.path);
var control;
function changed(v) {
setSetting(spec.path, v);
if (spec.rebuild && redraw) {
redraw();
}
}
if (spec.type === 'toggle') {
control = UI.toggle(!!value, changed);
} else if (spec.type === 'choice') {
var options = typeof spec.options === 'function' ? spec.options() : spec.options;
control = UI.choice(options, value, changed);
} else {
control = UI.text(value, changed, {
numeric: spec.type === 'number',
placeholder: spec.placeholder,
});
if (spec.wide) {
control.classList.add('wide');
}
}
control.setAttribute('data-path', spec.path);
container.appendChild(UI.row(spec.label, spec.hint, control));
});
if (focusedPath) {
var again = container.querySelector('[data-path="' + focusedPath + '"]');
if (again) {
again.focus();
}
}
}
// --- capture panel --------------------------------------------------------
function backendOptions() {
var out = [{ value: 'auto', label: 'Automatic' }];
state.backends.forEach(function (b) {
out.push({
value: b.id,
label: b.available === false ? b.name + ' (unavailable)' : b.name,
});
});
return out;
}
// Which backends a field applies to. "auto" has to be named explicitly:
// automatic only ever picks PulseAudio or ALSA, so the exec-only fields stay
// hidden until the user asks for that backend by name.
function backendIs(list) {
return function (s) {
return list.indexOf(s.capture && s.capture.backend) >= 0;
};
}
var CAPTURE_FIELDS = [
{
path: 'capture.backend', label: 'Backend', type: 'choice',
options: backendOptions, rebuild: true,
hint: 'Automatic tries PulseAudio, then ALSA.',
},
{
path: 'capture.device', label: 'Device', type: 'text', wide: true,
when: backendIs(['auto', 'pulse', 'alsa']),
placeholder: 'blank = default monitor',
hint: 'PulseAudio source name, or an ALSA PCM such as hw:0,0. '
+ 'Run diagnostics to see what this TV has.',
},
{
path: 'capture.server', label: 'PulseAudio server', type: 'text', wide: true,
when: backendIs(['auto', 'pulse']),
placeholder: 'blank = autodetect',
hint: 'Usually left blank. Example: unix:/var/run/pulse/native',
},
{
path: 'capture.command', label: 'Command', type: 'text', wide: true,
when: backendIs(['exec']),
placeholder: 'parec --format=s16le --rate=48000 --channels=2',
hint: 'Must write raw interleaved S16LE at the rate and channel count below.',
},
{
path: 'capture.rate', label: 'Sample rate', type: 'choice',
options: [
{ value: 44100, label: '44100 Hz' },
{ value: 48000, label: '48000 Hz' },
],
hint: 'The TV mixes at 48 kHz; anything else costs a resample.',
},
{
path: 'capture.channels', label: 'Channels', type: 'choice',
options: [
{ value: 1, label: 'Mono' },
{ value: 2, label: 'Stereo' },
],
},
];
var DSP_FIELDS = [
{
path: 'dsp.attack', label: 'Attack', type: 'number',
hint: 'Seconds to catch a rising level. 0.6 is a slow, calm meter.',
},
{
path: 'dsp.release', label: 'Release', type: 'number',
hint: 'Seconds to fall away after a peak.',
},
];
function renderCapture() {
buildFields($('capture-fields'), CAPTURE_FIELDS, renderCapture);
buildFields($('dsp-fields'), DSP_FIELDS, renderCapture);
}
// --- sinks panel ----------------------------------------------------------
var SINK_FIELDS = {
hyperhdr: [
{
path: 'hyperhdr.host', label: 'Receiver address', type: 'text', wide: true,
placeholder: '192.168.1.50',
hint: 'The machine running HyperHDR and the receiver script.',
},
{ path: 'hyperhdr.port', label: 'UDP port', type: 'number' },
{
path: 'hyperhdr.multicast', label: 'Multicast', type: 'toggle', rebuild: true,
hint: 'Send to a group address instead of one host, so several '
+ 'machines can listen.',
},
{
path: 'hyperhdr.multicastTtl', label: 'Multicast TTL', type: 'number',
when: function (s) { return !!(s.hyperhdr && s.hyperhdr.multicast); },
hint: '1 keeps it on this subnet.',
},
{
path: 'hyperhdr.sapAnnounce', label: 'Announce over SAP', type: 'toggle',
hint: 'Lets PulseAudio find the stream on its own '
+ '(module-rtp-recv, no manual SDP).',
},
],
hyperhdrViz: [
{
path: 'hyperhdrViz.host', label: 'HyperHDR address', type: 'text', wide: true,
placeholder: '192.168.1.50',
},
{
path: 'hyperhdrViz.port', label: 'Flatbuffers port', type: 'number',
hint: 'HyperHDR listens on 19400 by default.',
},
{
path: 'hyperhdrViz.mode', label: 'Style', type: 'choice',
options: [
{ value: 'spectrum', label: 'Spectrum' },
{ value: 'level', label: 'Level bar' },
{ value: 'pulse', label: 'Pulse' },
],
},
{ path: 'hyperhdrViz.width', label: 'Image width', type: 'number' },
{ path: 'hyperhdrViz.height', label: 'Image height', type: 'number' },
{
path: 'hyperhdrViz.fps', label: 'Frames per second', type: 'number',
hint: 'Above 30 buys nothing and costs the TV.',
},
{
path: 'hyperhdrViz.priority', label: 'Priority', type: 'number',
hint: 'Lower wins in HyperHDR. Keep it above your capture source '
+ 'unless you want this to take over.',
},
{ path: 'hyperhdrViz.saturation', label: 'Saturation', type: 'number' },
{
path: 'hyperhdrViz.minBrightness', label: 'Minimum brightness', type: 'number',
hint: '0 lets the lights go fully dark between beats.',
},
],
udp: [
{
path: 'udp.host', label: 'Destination', type: 'text', wide: true,
placeholder: '192.168.1.50',
hint: 'A host, a multicast group, or 255.255.255.255 to broadcast.',
},
{ path: 'udp.port', label: 'Port', type: 'number' },
{ path: 'udp.multicastTtl', label: 'Multicast TTL', type: 'number' },
],
tcp: [
{
path: 'tcp.port', label: 'Listen port', type: 'number',
hint: 'The TV listens; connect to it to pull the audio.',
},
{ path: 'tcp.maxClients', label: 'Maximum clients', type: 'number' },
],
http: [
{ path: 'http.port', label: 'Listen port', type: 'number' },
{ path: 'http.maxClients', label: 'Maximum clients', type: 'number' },
],
};
var SINK_HELP = {
hyperhdr: 'Run host/lgtv-audiocap-receiver.py on the HyperHDR machine. It '
+ 'turns this stream into a sound device HyperHDR can listen to, which is '
+ 'the closest thing to a real audio input HyperHDR has.',
hyperhdrViz: 'No host setup at all: the TV does the analysis and sends '
+ 'finished images over the Flatbuffers port. Use it when you cannot add '
+ 'a sound device on the HyperHDR machine.',
udp: 'Raw interleaved S16LE, no header, no framing. Lowest latency and no '
+ 'connection to lose.',
tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of '
+ 'latency when the network stalls.',
http: 'Point VLC at http://<tv>:<port>/audio.wav.',
};
function sinkEnabled(id) {
var list = state.settings.sinks || [];
return list.indexOf(id) >= 0;
}
function setSinkEnabled(id, on) {
var list = (state.settings.sinks || []).slice();
var at = list.indexOf(id);
if (on && at < 0) {
list.push(id);
} else if (!on && at >= 0) {
list.splice(at, 1);
}
setSetting('sinks', list);
}
function renderSinks() {
var host = $('sink-cards');
var focusedId = document.activeElement
&& document.activeElement.getAttribute
&& document.activeElement.getAttribute('data-sink');
UI.clear(host);
state.sinkDefs.forEach(function (def) {
var on = sinkEnabled(def.id);
var card = UI.el('div', 'card sink-card' + (on ? '' : ' off'));
var head = UI.el('div', 'sink-head');
head.appendChild(UI.el('h2', null, def.name || def.id));
if (def.id === 'hyperhdr') {
head.appendChild(UI.el('span', 'badge', 'Recommended'));
}
var holder = UI.el('div', 'field-control');
var sw = UI.toggle(on, function (v) {
setSinkEnabled(def.id, v);
renderSinks();
});
sw.setAttribute('data-sink', def.id);
holder.appendChild(sw);
head.appendChild(holder);
card.appendChild(head);
var body = UI.el('div', 'sink-body');
body.appendChild(UI.el('p', 'blurb', SINK_HELP[def.id] || def.description || ''));
buildFields(body, SINK_FIELDS[def.id] || [], renderSinks);
card.appendChild(body);
host.appendChild(card);
});
if (focusedId) {
var again = host.querySelector('[data-sink="' + focusedId + '"]');
if (again) {
again.focus();
}
}
}
// --- system panel ---------------------------------------------------------
var SYSTEM_FIELDS = [
{
path: 'logLevel', label: 'Log level', type: 'choice',
options: [
{ value: 'error', label: 'Errors only' },
{ value: 'warn', label: 'Warnings' },
{ value: 'info', label: 'Info' },
{ value: 'debug', label: 'Debug' },
],
},
];
function renderSystem() {
var host = $('system-fields');
UI.clear(host);
// Two separate things wear one switch: the boot script that launches the
// service, and the setting that tells the service to start capturing.
// Splitting them would only invite the half-on state where the service
// wakes up at boot and then sits there doing nothing.
var boot = UI.toggle(state.settings.autoStart && state.bootLinked, function (v) {
setSetting('autoStart', v);
setBootLink(v);
});
boot.setAttribute('data-path', 'autoStart');
host.appendChild(UI.row(
'Start on boot',
'Installs a Homebrew Channel startup script and starts capturing '
+ 'as soon as the TV comes up.',
boot
));
// The TV's audio devices are root-only. The Homebrew Channel ships the
// tool that grants a service root, but it has to be asked.
var diag = state.diagnostics && state.diagnostics.system;
var rooted = diag && diag.root;
var elevate = UI.button(rooted ? 'Re-apply' : 'Grant root access', grantRoot,
rooted ? null : 'primary');
elevate.setAttribute('data-path', 'elevate');
host.appendChild(UI.row(
'Root access',
diag
? (rooted
? 'The service is running as root.'
: 'The service is running as uid ' + diag.uid + ' and will not be '
+ 'able to open the TV\'s audio devices. Grant it root, then it '
+ 'restarts by itself.')
: 'Checking…',
elevate
));
var fields = UI.el('div');
host.appendChild(fields);
buildFields(fields, SYSTEM_FIELDS, renderSystem);
var info = $('config-path');
UI.clear(info);
info.appendChild(infoItem('Path', state.configPath || '—'));
info.appendChild(infoItem('Storage', state.persistent
? 'Persistent' : 'Temporary (/tmp)'));
info.appendChild(infoItem('Boot script', state.bootLinked
? 'Installed' : 'Not installed'));
}
// Elevation only takes effect on a fresh process, so the service is asked to
// quit and is started again by the next call the page makes.
function grantRoot() {
Luna.exec(ELEVATE + ' ' + SERVICE_ID, function () {
toast('Elevated — restarting the service');
Luna.quit(refreshAfterRestart, refreshAfterRestart);
}, function (err) {
fail('Could not elevate the service: ' + err
+ ' — is the Homebrew Channel installed?');
});
}
function refreshAfterRestart() {
setTimeout(function () {
if (statusSub) {
statusSub.cancel();
}
statusSub = Luna.subscribeStatus(renderStatus, fail);
refreshDiagnostics();
}, 1500);
}
function refreshDiagnostics() {
Luna.getDiagnostics(function (reply) {
state.diagnostics = reply;
renderSystem();
}, function () {
// Not fatal: the panel just says "Checking…" until the next attempt.
});
}
function setBootLink(on) {
var command = on
? 'mkdir -p /var/lib/webosbrew/init.d && chmod +x ' + BOOT_SCRIPT
+ ' && ln -sf ' + BOOT_SCRIPT + ' ' + BOOT_LINK
: 'rm -f ' + BOOT_LINK;
Luna.exec(command, function () {
state.bootLinked = on;
renderSystem();
toast(on ? 'Will start with the TV' : 'Boot script removed');
}, function (err) {
state.bootLinked = !on;
renderSystem();
fail('Could not change the boot script: ' + err
+ ' — is the Homebrew Channel installed?');
});
}
function checkBootLink() {
Luna.exec('test -e ' + BOOT_LINK + ' && echo yes || echo no', function (reply) {
state.bootLinked = String(reply.stdoutString || '').indexOf('yes') >= 0;
renderSystem();
}, function () {
// No Homebrew Channel service, or it refused. Leave the switch off
// rather than claiming a boot script that is not there.
state.bootLinked = false;
});
}
// --- status ---------------------------------------------------------------
function infoItem(key, value) {
var item = UI.el('div', 'info-item');
item.appendChild(UI.el('div', 'info-key', key));
var v = UI.el('div', 'info-value', value);
v.title = String(value);
item.appendChild(v);
return item;
}
var bandNodes = [];
function buildBands() {
var host = $('bands');
UI.clear(host);
bandNodes = [];
for (var i = 0; i < 16; i++) {
var b = UI.el('div', 'band');
b.style.height = '3px';
host.appendChild(b);
bandNodes.push(b);
}
}
function setBar(id, value) {
var fill = $(id).firstChild;
fill.style.width = (Math.max(0, Math.min(1, value)) * 100).toFixed(1) + '%';
}
function db(value) {
if (value === undefined || value === null || value <= -89) {
return '−∞ dB';
}
return value.toFixed(1) + ' dB';
}
// Detail line under each sink in the status panel. Every sink reports
// different counters, so pick out the ones worth reading at a glance.
function sinkDetail(s) {
var bits = [];
if (s.target) {
bits.push(s.target + ':' + s.port);
} else if (s.port !== undefined) {
bits.push('port ' + s.port);
}
if (s.clients !== undefined) {
bits.push(s.clients + ' client' + (s.clients === 1 ? '' : 's'));
}
if (s.packetsSent !== undefined) {
bits.push(s.packetsSent.toLocaleString() + ' packets');
}
if (s.framesSent !== undefined) {
bits.push(s.framesSent.toLocaleString() + ' frames');
}
if (s.connected !== undefined) {
bits.push(s.connected ? 'connected' : 'not connected');
}
if (s.sendErrors) {
bits.push(s.sendErrors + ' send errors');
}
if (s.droppedBytes) {
bits.push(Math.round(s.droppedBytes / 1024) + ' kB dropped');
}
if (s.lastError) {
bits.push(s.lastError);
}
if (s.error) {
bits.push(s.error);
}
return bits.join(' · ');
}
function renderStatus(st) {
state.status = st;
var pill = $('state-pill');
pill.textContent = {
running: 'Running', starting: 'Starting', error: 'Error',
}[st.state] || 'Stopped';
pill.className = 'pill ' + (st.state || 'stopped');
$('power').textContent = st.running ? 'Stop' : 'Start';
$('uptime').textContent = duration(st.capture && st.capture.uptimeMs);
var levels = st.levels || {};
setBar('meter-peak', levels.peak || 0);
setBar('meter-rms', levels.rms || 0);
$('meter-peak-db').textContent = db(levels.peakDb);
$('meter-rms-db').textContent = db(levels.rmsDb);
$('clip').classList.toggle('hidden', !levels.clipping);
var bands = levels.bands || [];
for (var i = 0; i < bandNodes.length; i++) {
var v = Math.max(0, Math.min(1, bands[i] || 0));
bandNodes[i].style.height = Math.max(3, v * 168).toFixed(0) + 'px';
}
var cap = st.capture || {};
var info = $('capture-info');
UI.clear(info);
info.appendChild(infoItem('Backend', cap.backendName || cap.backend || '—'));
info.appendChild(infoItem('Device', cap.device || 'default'));
info.appendChild(infoItem('Format', cap.rate
? cap.rate + ' Hz · ' + (cap.channels === 1 ? 'mono' : 'stereo') : '—'));
info.appendChild(infoItem('Frames', (cap.frames || 0).toLocaleString()));
if (cap.timeouts) {
info.appendChild(infoItem('Read timeouts', cap.timeouts));
}
var sinks = $('sink-status');
UI.clear(sinks);
if (!st.sinks || !st.sinks.length) {
sinks.appendChild(UI.el('div', 'muted', st.running
? 'No outputs enabled.' : 'Not running.'));
} else {
st.sinks.forEach(function (s) {
var line = UI.el('div', 'sink-line');
line.appendChild(UI.el('span', 'dot ' + (s.ok ? 'ok' : 'bad')));
line.appendChild(UI.el('span', 'sink-name', s.name || s.id));
line.appendChild(UI.el('span', 'sink-detail', sinkDetail(s)));
sinks.appendChild(line);
});
}
$('error-card').classList.toggle('hidden', !st.error);
$('error-text').textContent = st.error || '';
}
// --- tabs -----------------------------------------------------------------
var tabs = [];
function activateTab(panelId) {
tabs.forEach(function (t) {
var on = t.getAttribute('data-panel') === panelId;
t.classList.toggle('active', on);
$(t.getAttribute('data-panel')).classList.toggle('hidden', !on);
});
$('content').scrollTop = 0;
}
function currentTab() {
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].classList.contains('active')) {
return i;
}
}
return 0;
}
// --- actions --------------------------------------------------------------
function togglePower() {
var running = state.status && state.status.running;
// Send any settings the user just touched before restarting, so the run
// picks them up instead of the previous values.
if (saveTimer) {
clearTimeout(saveTimer);
flush();
}
if (running) {
Luna.stop(function () { toast('Stopped'); }, fail);
} else {
Luna.start({}, function (reply) {
if (reply.error) {
fail(reply.error);
} else {
toast('Started');
}
}, fail);
}
}
function showOutput(text) {
var out = $('output');
out.textContent = text;
out.classList.remove('hidden');
out.scrollTop = 0;
}
function runDiagnostics() {
Luna.getDiagnostics(function (reply) {
var copy = JSON.parse(JSON.stringify(reply));
delete copy.returnValue;
showOutput(JSON.stringify(copy, null, 2));
}, fail);
}
function loadLogs(clear) {
Luna.getLogs(clear, function (reply) {
showOutput(reply.logs || '(empty)');
if (clear) {
toast('Log cleared');
}
}, fail);
}
// --- boot -----------------------------------------------------------------
function loadConfig(then) {
Luna.getConfig(function (reply) {
state.settings = reply.settings || {};
state.configPath = reply.path || '';
state.persistent = reply.persistent !== false;
if (then) {
then();
}
}, fail);
}
function init() {
buildBands();
tabs = Array.prototype.slice.call(document.querySelectorAll('.tab'));
tabs.forEach(function (t) {
t.addEventListener('click', function () {
activateTab(t.getAttribute('data-panel'));
});
});
activateTab('panel-status');
$('power').addEventListener('click', togglePower);
$('run-diagnostics').addEventListener('click', runDiagnostics);
$('load-logs').addEventListener('click', function () { loadLogs(false); });
$('clear-logs').addEventListener('click', function () { loadLogs(true); });
$('reset-config').addEventListener('click', function () {
Luna.resetConfig(function (reply) {
state.settings = reply.settings || {};
renderCapture();
renderSinks();
renderSystem();
toast('Settings reset');
}, fail);
});
// Back steps to the Status tab first, and only leaves the app from there.
Nav.onBack(function () {
if (currentTab() !== 0) {
activateTab('panel-status');
Nav.focus(tabs[0]);
return true;
}
return false;
});
loadConfig(function () {
Luna.listBackends(function (reply) {
state.backends = reply.backends || [];
renderCapture();
}, fail);
Luna.listSinks(function (reply) {
state.sinkDefs = reply.sinks || [];
renderSinks();
}, fail);
renderCapture();
renderSystem();
checkBootLink();
refreshDiagnostics();
});
statusSub = Luna.subscribeStatus(renderStatus, function (err) {
fail('Lost contact with the service: ' + err);
$('state-pill').textContent = 'No service';
$('state-pill').className = 'pill error';
});
Nav.focus($('power'));
// webOS suspends the page rather than unloading it, so drop the
// subscription on the way out and pick it back up on return.
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
if (statusSub) {
statusSub.cancel();
statusSub = null;
}
} else if (!statusSub) {
statusSub = Luna.subscribeStatus(renderStatus, fail);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
global.App = { state: state };
})(window);
+132
View File
@@ -0,0 +1,132 @@
// Luna bus access.
//
// Talks to PalmServiceBridge directly rather than pulling in webOSTV.js: it is
// the same object webOSTV.js wraps, it is injected into every webOS app, and
// it means there is no third-party file to keep in sync.
//
// Opened in a desktop browser the bridge is absent, so everything falls back
// to a small mock. That is what makes the UI developable without a TV.
(function (global) {
'use strict';
var SERVICE = 'luna://org.webosbrew.audiocap.service/';
var HBCHANNEL = 'luna://org.webosbrew.hbchannel.service/';
var haveBridge = typeof global.PalmServiceBridge !== 'undefined';
// A call in flight. `cancel()` tears down a subscription.
function Request(bridge) {
this.bridge = bridge;
this.cancelled = false;
}
Request.prototype.cancel = function () {
this.cancelled = true;
if (this.bridge && this.bridge.cancel) {
this.bridge.cancel();
}
};
// Low-level call. `onReply` fires once per reply, so subscriptions keep
// calling it until cancelled.
function call(uri, params, onReply, onError) {
if (!haveBridge) {
return global.LunaMock.call(uri, params, onReply, onError);
}
var bridge = new global.PalmServiceBridge();
var request = new Request(bridge);
bridge.onservicecallback = function (raw) {
if (request.cancelled) {
return;
}
var reply;
try {
reply = JSON.parse(raw);
} catch (e) {
if (onError) {
onError('Malformed reply from ' + uri);
}
return;
}
// Luna reports both its own failures and ours through returnValue.
if (reply.returnValue === false) {
if (onError) {
onError(reply.errorText || reply.errorMessage || 'Call to ' + uri + ' failed');
}
return;
}
if (onReply) {
onReply(reply);
}
};
try {
bridge.call(uri, JSON.stringify(params || {}));
} catch (e) {
if (onError) {
onError(String(e));
}
}
return request;
}
var Luna = {
available: haveBridge,
// --- our service -------------------------------------------------------
start: function (patch, ok, fail) {
return call(SERVICE + 'start', patch || {}, ok, fail);
},
stop: function (ok, fail) {
return call(SERVICE + 'stop', {}, ok, fail);
},
subscribeStatus: function (ok, fail) {
return call(SERVICE + 'getStatus', { subscribe: true }, ok, fail);
},
getConfig: function (ok, fail) {
return call(SERVICE + 'getConfig', {}, ok, fail);
},
setConfig: function (patch, ok, fail) {
return call(SERVICE + 'setConfig', { settings: patch }, ok, fail);
},
resetConfig: function (ok, fail) {
return call(SERVICE + 'resetConfig', {}, ok, fail);
},
listBackends: function (ok, fail) {
return call(SERVICE + 'listBackends', {}, ok, fail);
},
listSinks: function (ok, fail) {
return call(SERVICE + 'listSinks', {}, ok, fail);
},
getDiagnostics: function (ok, fail) {
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
},
// Ends the service process. Any later call starts a fresh one, which is
// how the service picks up new bus permissions after being elevated.
quit: function (ok, fail) {
return call(SERVICE + 'quit', {}, ok, fail);
},
// `clear` empties the ring buffer after reading it, so the reply is the
// last thing anyone sees of those lines.
getLogs: function (clear, ok, fail) {
return call(SERVICE + 'getLogs', { clear: !!clear }, ok, fail);
},
// --- Homebrew Channel --------------------------------------------------
// Used for the boot symlink. Needs the Homebrew Channel installed, which
// it will be on any TV that can run this app.
exec: function (command, ok, fail) {
return call(HBCHANNEL + 'exec', { command: command }, ok, fail);
},
// --- system ------------------------------------------------------------
getNetworkStatus: function (ok, fail) {
return call('luna://com.palm.connectionmanager/getStatus', {}, ok, fail);
},
};
global.Luna = Luna;
})(window);
+203
View File
@@ -0,0 +1,203 @@
// Stand-in for the Luna bus so the UI can be opened in a desktop browser.
// Only loaded when PalmServiceBridge is missing, which never happens on a TV.
(function (global) {
'use strict';
var settings = {
autoStart: false,
logLevel: 'info',
capture: { backend: 'auto', device: '', server: '', command: '', rate: 48000, channels: 2 },
dsp: { attack: 0.6, release: 0.12 },
sinks: ['hyperhdr'],
hyperhdr: { host: '192.168.1.50', port: 5004, multicast: false, multicastTtl: 4, sapAnnounce: true },
hyperhdrViz: {
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02,
},
udp: { host: '', port: 4010, multicastTtl: 4 },
tcp: { port: 4011, maxClients: 4 },
http: { port: 4012, maxClients: 4 },
};
var running = false;
var subscribers = [];
var startedAt = 0;
function merge(base, patch) {
Object.keys(patch).forEach(function (k) {
if (patch[k] && typeof patch[k] === 'object' && !Array.isArray(patch[k])
&& base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
merge(base[k], patch[k]);
} else {
base[k] = patch[k];
}
});
}
function status(subscribed) {
var t = Date.now() / 1000;
var bands = [];
for (var i = 0; i < 16; i++) {
var v = running ? Math.abs(Math.sin(t * (1 + i * 0.25) + i)) * (1 - i / 24) : 0;
bands.push(Math.max(0, Math.min(1, v)));
}
var peak = running ? 0.4 + 0.4 * Math.abs(Math.sin(t * 2)) : 0;
return {
returnValue: true,
subscribed: !!subscribed,
state: running ? 'running' : 'stopped',
running: running,
error: null,
capture: {
backend: running ? 'pulse' : null,
backendName: running ? 'PulseAudio (mock)' : null,
device: '@DEFAULT_MONITOR@',
rate: 48000,
channels: 2,
frames: running ? Math.round((Date.now() - startedAt) * 48) : 0,
blocks: running ? Math.round((Date.now() - startedAt) / 10.7) : 0,
timeouts: 0,
uptimeMs: running ? Date.now() - startedAt : 0,
},
levels: {
peak: peak,
rms: peak * 0.6,
peakDb: peak > 0 ? 20 * Math.log(peak) / Math.LN10 : -90,
rmsDb: peak > 0 ? 20 * Math.log(peak * 0.6) / Math.LN10 : -90,
clipping: peak > 0.98,
bands: bands,
},
sinks: running ? settings.sinks.map(function (id) {
return { id: id, ok: true, name: id, error: null, target: settings.hyperhdr.host, port: settings.hyperhdr.port, packetsSent: 1234 };
}) : [],
configPath: '/var/lib/webosbrew/audiocap/config.json',
configPersistent: true,
};
}
setInterval(function () {
subscribers.forEach(function (s) {
if (!s.cancelled) {
s.onReply(status(true));
}
});
}, 100);
function respond(onReply, payload) {
setTimeout(function () { onReply(payload); }, 30);
return { cancel: function () {} };
}
var LunaMock = {
call: function (uri, params, onReply, onError) {
var method = uri.split('/').pop();
switch (method) {
case 'start':
if (params && Object.keys(params).length) { merge(settings, params); }
running = true;
startedAt = Date.now();
return respond(onReply, status(false));
case 'stop':
running = false;
return respond(onReply, status(false));
case 'getStatus': {
var sub = { cancelled: false, onReply: onReply };
if (params && params.subscribe) {
subscribers.push(sub);
}
setTimeout(function () { onReply(status(!!(params && params.subscribe))); }, 30);
return { cancel: function () { sub.cancelled = true; } };
}
case 'getConfig':
return respond(onReply, {
returnValue: true,
path: '/var/lib/webosbrew/audiocap/config.json',
persistent: true,
settings: JSON.parse(JSON.stringify(settings)),
});
case 'setConfig':
merge(settings, params.settings || params);
return respond(onReply, {
returnValue: true, saved: true, restartRequired: running,
settings: JSON.parse(JSON.stringify(settings)),
});
case 'listBackends':
return respond(onReply, {
returnValue: true,
backends: [
{ id: 'pulse', name: 'PulseAudio', description: 'Records a PulseAudio monitor source.', available: true },
{ id: 'alsa', name: 'ALSA', description: 'Records from an ALSA capture PCM.', available: true },
{ id: 'exec', name: 'External command', description: 'Reads raw PCM from a command you supply.', available: true },
{ id: 'tone', name: 'Test tone', description: 'Synthesised sweep, for testing the transport.', available: true },
],
});
case 'listSinks':
return respond(onReply, {
returnValue: true,
sinks: [
{ id: 'hyperhdr', name: 'HyperHDR audio (RTP)', description: 'RTP/L16 audio to the HyperHDR host.' },
{ id: 'hyperhdrViz', name: 'HyperHDR visualiser', description: 'Renders on the TV, sends images. No host setup.' },
{ id: 'udp', name: 'Raw PCM over UDP', description: 'Fire-and-forget S16LE datagrams.' },
{ id: 'tcp', name: 'Raw PCM over TCP', description: 'The TV listens; connect to pull audio.' },
{ id: 'http', name: 'HTTP WAV stream', description: 'Open the URL in VLC.' },
],
});
// Same shape as capture_write_diagnostics(): backends at the top level,
// everything about the machine under "system".
case 'getDiagnostics':
return respond(onReply, {
returnValue: true,
backends: [
{ id: 'pulse', name: 'PulseAudio monitor', available: true },
{ id: 'alsa', name: 'ALSA PCM', available: true },
{ id: 'exec', name: 'External command', available: true },
{ id: 'tone', name: 'Test tone', available: true },
],
system: {
root: true,
uid: 0,
libraries: {
'libpulse.so.0': '/usr/lib/libpulse.so.0',
'libpulse-simple.so.0': null,
'libasound.so.2': '/usr/lib/libasound.so.2',
},
binaries: { parec: false, pactl: true, pacat: false, arecord: true },
pulseSockets: ['/var/run/pulse/native'],
pactlSources: 'mock output',
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
},
});
case 'getLogs':
return respond(onReply, {
returnValue: true,
logs: '[info] running in the browser mock\n[info] no TV attached\n',
});
case 'quit':
running = false;
return respond(onReply, { returnValue: true });
case 'exec':
return respond(onReply, { returnValue: true, stdoutString: '' });
default:
if (onError) { onError('mock: unknown method ' + method); }
return { cancel: function () {} };
}
},
};
global.LunaMock = LunaMock;
})(window);
+171
View File
@@ -0,0 +1,171 @@
// Remote-control navigation.
//
// webOS TVs have no tab key and no pointer worth designing for, so focus moves
// geometrically: pressing Right picks the nearest focusable element whose
// centre lies to the right, biased towards ones on the same row. Anything with
// the `focusable` class joins in, which keeps the markup free of tab indices.
(function (global) {
'use strict';
var KEY = {
LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40,
ENTER: 13, BACK: 461, ESCAPE: 27,
RED: 403, GREEN: 404, YELLOW: 405, BLUE: 406,
};
function visible(el) {
if (el.disabled || el.classList.contains('hidden')) {
return false;
}
var rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function candidates() {
var all = document.querySelectorAll('.focusable');
var out = [];
for (var i = 0; i < all.length; i++) {
if (visible(all[i])) {
out.push(all[i]);
}
}
return out;
}
function centre(el) {
var r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: r };
}
// Minimum travel before a neighbour counts as being in that direction, so
// elements that merely overlap slightly do not steal focus.
var MIN_TRAVEL = 4;
// How much drifting off-axis costs. High enough that a row of buttons is
// traversed in order rather than diagonally.
var ACROSS_PENALTY = 3;
function move(direction) {
var current = document.activeElement;
var list = candidates();
if (!list.length) {
return;
}
if (!current || !current.classList || !current.classList.contains('focusable')) {
focus(list[0]);
return;
}
var from = centre(current);
var best = null;
var bestScore = Infinity;
for (var i = 0; i < list.length; i++) {
if (list[i] === current) {
continue;
}
var to = centre(list[i]);
var dx = to.x - from.x;
var dy = to.y - from.y;
var along;
var across;
if (direction === 'right') {
along = dx;
across = dy;
} else if (direction === 'left') {
along = -dx;
across = dy;
} else if (direction === 'down') {
along = dy;
across = dx;
} else {
along = -dy;
across = dx;
}
if (along < MIN_TRAVEL) {
continue;
}
var s = along + Math.abs(across) * ACROSS_PENALTY;
if (s < bestScore) {
bestScore = s;
best = list[i];
}
}
if (best) {
focus(best);
}
}
function focus(el) {
if (!el) {
return;
}
el.focus();
// Keep the focused control clear of the sticky header.
if (el.scrollIntoView) {
var rect = el.getBoundingClientRect();
if (rect.top < 120 || rect.bottom > global.innerHeight - 40) {
el.scrollIntoView({ block: 'center' });
}
}
}
function focusFirstIn(container) {
if (!container) {
return;
}
var list = container.querySelectorAll('.focusable');
for (var i = 0; i < list.length; i++) {
if (visible(list[i])) {
focus(list[i]);
return;
}
}
}
var backHandler = null;
document.addEventListener('keydown', function (e) {
switch (e.keyCode) {
case KEY.LEFT:
move('left');
break;
case KEY.RIGHT:
move('right');
break;
case KEY.UP:
move('up');
break;
case KEY.DOWN:
move('down');
break;
case KEY.BACK:
case KEY.ESCAPE:
if (backHandler && backHandler()) {
break;
}
// Nothing wanted the Back press: leave the app the way the platform
// expects rather than trapping the user inside it.
if (global.webOS && global.webOS.platformBack) {
global.webOS.platformBack();
} else {
global.close();
}
break;
default:
return;
}
e.preventDefault();
});
global.Nav = {
KEY: KEY,
focus: focus,
focusFirstIn: focusFirstIn,
onBack: function (fn) { backHandler = fn; },
};
})(window);
+195
View File
@@ -0,0 +1,195 @@
// Form controls built for a remote control.
//
// Everything is a button. Dropdowns and checkboxes are miserable to operate
// with a D-pad, so a choice cycles through its values on Enter and a toggle
// flips. Text fields are the one exception: focusing one and pressing Enter
// brings up the TV's on-screen keyboard, which is the only way to type.
(function (global) {
'use strict';
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined && text !== null) {
node.textContent = String(text);
}
return node;
}
// Reads "hyperhdr.host" out of a settings object.
function get(obj, path) {
var parts = path.split('.');
var cur = obj;
for (var i = 0; i < parts.length; i++) {
if (cur === null || cur === undefined) {
return undefined;
}
cur = cur[parts[i]];
}
return cur;
}
// Builds { hyperhdr: { host: value } } so setConfig only carries the change.
function patchFor(path, value) {
var parts = path.split('.');
var root = {};
var cur = root;
for (var i = 0; i < parts.length - 1; i++) {
cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
return root;
}
function row(label, hint, control) {
var wrap = el('div', 'field');
var text = el('div', 'field-text');
text.appendChild(el('div', 'field-label', label));
if (hint) {
text.appendChild(el('div', 'field-hint', hint));
}
wrap.appendChild(text);
var holder = el('div', 'field-control');
holder.appendChild(control);
wrap.appendChild(holder);
return wrap;
}
function button(label, onClick, extraClass) {
var b = el('button', 'focusable btn' + (extraClass ? ' ' + extraClass : ''), label);
b.type = 'button';
b.addEventListener('click', onClick);
return b;
}
function toggle(value, onChange) {
var b = el('button', 'focusable btn toggle');
b.type = 'button';
function paint(v) {
b.textContent = v ? 'On' : 'Off';
b.classList.toggle('on', !!v);
b.setAttribute('aria-pressed', v ? 'true' : 'false');
}
paint(value);
b.addEventListener('click', function () {
value = !value;
paint(value);
onChange(value);
});
b.setValue = paint;
return b;
}
// `options` is [{ value, label }].
function choice(options, value, onChange) {
var b = el('button', 'focusable btn choice');
b.type = 'button';
function indexOf(v) {
for (var i = 0; i < options.length; i++) {
if (options[i].value === v) {
return i;
}
}
return 0;
}
var index = indexOf(value);
function paint() {
b.textContent = options.length ? options[index].label : '—';
b.title = options.length ? String(options[index].value) : '';
}
paint();
b.addEventListener('click', function () {
if (!options.length) {
return;
}
index = (index + 1) % options.length;
paint();
onChange(options[index].value);
});
b.setValue = function (v) {
index = indexOf(v);
paint();
};
b.setOptions = function (list, v) {
options = list;
index = indexOf(v);
paint();
};
return b;
}
function text(value, onChange, opts) {
opts = opts || {};
var input = el('input', 'focusable input');
input.type = 'text';
input.value = value === undefined || value === null ? '' : String(value);
if (opts.placeholder) {
input.placeholder = opts.placeholder;
}
if (opts.numeric) {
input.inputMode = 'numeric';
}
function commit() {
var raw = input.value.trim();
onChange(opts.numeric ? (raw === '' ? 0 : parseFloat(raw)) : raw);
}
input.addEventListener('change', commit);
input.addEventListener('blur', commit);
input.addEventListener('keydown', function (e) {
// Let the arrow keys move the caret inside a focused field, but hand Up
// and Down back to the navigator so the user can leave it.
if (e.keyCode === 37 || e.keyCode === 39) {
e.stopPropagation();
}
if (e.keyCode === 13) {
commit();
}
});
input.setValue = function (v) {
input.value = v === undefined || v === null ? '' : String(v);
};
return input;
}
// A horizontal bar, 0..1.
function bar(className) {
var outer = el('div', 'bar ' + (className || ''));
var fill = el('div', 'bar-fill');
outer.appendChild(fill);
outer.setValue = function (v) {
var pct = Math.max(0, Math.min(1, v)) * 100;
fill.style.width = pct.toFixed(1) + '%';
};
return outer;
}
function clear(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
global.UI = {
el: el,
get: get,
patchFor: patchFor,
row: row,
button: button,
toggle: toggle,
choice: choice,
text: text,
bar: bar,
clear: clear,
};
})(window);
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env bash
# Sets up the sound device HyperHDR will listen to, and optionally installs the
# receiver as a service.
#
# HyperHDR's sound-reactive effects read a local capture device. This script
# creates one that is fed by the TV:
#
# TV ──RTP──> lgtv-audiocap-receiver.py ──> loopback playback
# │
# HyperHDR <─────┘ loopback capture
#
# Two ways to make that loopback:
#
# alsa snd-aloop, a kernel module that pairs a playback device with a
# capture device. HyperHDR enumerates ALSA devices, so it sees this
# one directly. This is the default and the one to prefer.
# pulse A null sink whose monitor is the capture side. Only useful if
# HyperHDR is reaching audio through the PulseAudio ALSA plugin.
#
# sudo ./install-loopback.sh # snd-aloop, no service
# sudo ./install-loopback.sh --install-service # ... and run at boot
# sudo ./install-loopback.sh --method pulse
# sudo ./install-loopback.sh --uninstall
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
METHOD=alsa
NAME=lgtv-audio
PORT=5004
RATE=48000
CHANNELS=2
CARD_INDEX=10
PREFIX=/usr/local
SERVICE_USER=""
INSTALL_SERVICE=0
UNINSTALL=0
RULES_FILE=/etc/udev/rules.d/89-lgtv-audiocap-loopback.rules
MODPROBE_FILE=/etc/modprobe.d/lgtv-audiocap-loopback.conf
MODULES_FILE=/etc/modules-load.d/lgtv-audiocap-loopback.conf
UNIT_FILE=/etc/systemd/system/lgtv-audiocap.service
usage() {
# The header comment is the help text, up to the first line of code.
awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0"
cat <<EOF
Options:
--method alsa|pulse loopback to create (default: $METHOD)
--name NAME PulseAudio sink name (default: $NAME)
--port PORT UDP port the TV sends to (default: $PORT)
--rate HZ sample rate the TV sends (default: $RATE)
--channels N channel count the TV sends (default: $CHANNELS)
--card-index N ALSA card index for snd-aloop (default: $CARD_INDEX)
--prefix DIR where to install the receiver (default: $PREFIX)
--user NAME user the service runs as (default: the invoking user)
--install-service install and start a systemd unit for the receiver
--uninstall undo everything this script installs
-h, --help this text
EOF
}
say() { printf '%s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
need_root() {
[ "$(id -u)" -eq 0 ] || die "run this with sudo"
}
while [ $# -gt 0 ]; do
case "$1" in
--method) METHOD="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--rate) RATE="$2"; shift 2 ;;
--channels) CHANNELS="$2"; shift 2 ;;
--card-index) CARD_INDEX="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--install-service) INSTALL_SERVICE=1; shift ;;
--uninstall) UNINSTALL=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option $1 (try --help)" ;;
esac
done
case "$METHOD" in
alsa|pulse) ;;
*) die "--method must be alsa or pulse" ;;
esac
# The user whose PulseAudio session we touch, and who the service runs as.
if [ -z "$SERVICE_USER" ]; then
SERVICE_USER="${SUDO_USER:-$(id -un)}"
fi
# ---------------------------------------------------------------------------
uninstall() {
need_root
step "Removing the ALSA loopback"
rm -f "$MODPROBE_FILE" "$MODULES_FILE" "$RULES_FILE"
modprobe -r snd-aloop 2>/dev/null || say "snd-aloop is in use; it will go at the next reboot"
step "Removing the service"
if [ -f "$UNIT_FILE" ]; then
systemctl disable --now lgtv-audiocap.service 2>/dev/null || true
rm -f "$UNIT_FILE"
systemctl daemon-reload
fi
rm -f "$PREFIX/bin/lgtv-audiocap-receiver.py"
step "PulseAudio"
say "If you used --method pulse, remove the module-null-sink line from"
say " ~/.config/pulse/default.pa (user $SERVICE_USER)"
say "and unload it now with: pactl unload-module module-null-sink"
say ""
say "Done."
}
setup_alsa() {
need_root
step "Loading snd-aloop"
cat > "$MODPROBE_FILE" <<EOF
# LG TV Audio Cap: one loopback card, one substream, kept out of the way of
# the real sound cards at a high index.
options snd-aloop index=$CARD_INDEX pcm_substreams=1 id=Loopback
EOF
echo snd-aloop > "$MODULES_FILE"
if ! lsmod 2>/dev/null | grep -q '^snd_aloop'; then
modprobe snd-aloop || die "could not load snd-aloop; is alsa-utils / the kernel module package installed?"
else
say "snd-aloop already loaded (reboot to pick up the new options)"
fi
# PulseAudio grabs every card it finds. Left alone it opens the loopback,
# which is at best a wasted device and at worst a fight over the substream.
if command -v pulseaudio >/dev/null 2>&1 || command -v pipewire >/dev/null 2>&1; then
step "Hiding the loopback from PulseAudio/PipeWire"
cat > "$RULES_FILE" <<'EOF'
# LG TV Audio Cap: the loopback belongs to the receiver and HyperHDR, not to
# the desktop sound server.
ATTRS{id}=="Loopback", ENV{PULSE_IGNORE}="1", ENV{ACP_IGNORE}="1"
EOF
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=sound 2>/dev/null || true
fi
PLAY_DEVICE="hw:Loopback,0,0"
CAPTURE_DEVICE="hw:Loopback,1,0"
step "Checking the loopback"
if aplay -l 2>/dev/null | grep -q 'Loopback'; then
aplay -l | grep -i loopback | sed 's/^/ /'
else
say " aplay does not list the Loopback card yet; a reboot will fix it"
fi
}
setup_pulse() {
step "Creating the null sink"
local as_user=(sudo -u "$SERVICE_USER")
[ "$(id -un)" = "$SERVICE_USER" ] && as_user=()
if ! "${as_user[@]}" pactl info >/dev/null 2>&1; then
die "no PulseAudio/PipeWire session for user $SERVICE_USER"
fi
if "${as_user[@]}" pactl list short sinks | grep -q "^[0-9]*[[:space:]]*$NAME"; then
say "sink $NAME already exists"
else
"${as_user[@]}" pactl load-module module-null-sink \
sink_name="$NAME" \
sink_properties="device.description='LG TV Audio Cap'" \
rate="$RATE" channels="$CHANNELS" >/dev/null
say "created sink $NAME"
fi
local conf="/home/$SERVICE_USER/.config/pulse/default.pa"
[ -d "/home/$SERVICE_USER" ] || conf=""
if [ -n "$conf" ]; then
mkdir -p "$(dirname "$conf")"
if [ ! -f "$conf" ]; then
printf '.include /etc/pulse/default.pa\n' > "$conf"
fi
if ! grep -q "sink_name=$NAME" "$conf"; then
cat >> "$conf" <<EOF
# LG TV Audio Cap
load-module module-null-sink sink_name=$NAME sink_properties=device.description='LG_TV_Audio_Cap' rate=$RATE channels=$CHANNELS
EOF
chown "$SERVICE_USER" "$conf" 2>/dev/null || true
say "persisted in $conf"
fi
fi
PLAY_DEVICE="$NAME"
CAPTURE_DEVICE="$NAME.monitor"
}
install_service() {
need_root
step "Installing the receiver"
install -Dm755 "$HERE/lgtv-audiocap-receiver.py" "$PREFIX/bin/lgtv-audiocap-receiver.py"
say "installed $PREFIX/bin/lgtv-audiocap-receiver.py"
local output device
if [ "$METHOD" = alsa ]; then
output=aplay
device="$PLAY_DEVICE"
else
output=pacat
device="$PLAY_DEVICE"
fi
cat > "$UNIT_FILE" <<EOF
[Unit]
Description=LG TV Audio Cap receiver
Documentation=https://github.com/webosbrew
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
SupplementaryGroups=audio
ExecStart=$PREFIX/bin/lgtv-audiocap-receiver.py \\
--port $PORT --rate $RATE --channels $CHANNELS \\
--output $output --device $device
Restart=always
RestartSec=2
# The receiver holds a socket and a pipe and nothing else.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now lgtv-audiocap.service
say "started lgtv-audiocap.service"
systemctl --no-pager --lines=5 status lgtv-audiocap.service || true
}
summary() {
step "What to do next"
cat <<EOF
On the TV, in the Audio Cap app:
Outputs > HyperHDR audio (RTP/L16) > on
Receiver address: this machine's IP
UDP port: $PORT
In HyperHDR:
Sound capture (or the LED device's music effect) > input device
$CAPTURE_DEVICE
then pick a music effect such as "Waves" or "Spectrum".
If the receiver is not running as a service, start it by hand:
$HERE/lgtv-audiocap-receiver.py --port $PORT --rate $RATE \\
--channels $CHANNELS --output $( [ "$METHOD" = alsa ] && echo aplay || echo pacat ) \\
--device $PLAY_DEVICE
Check that audio is arriving at all:
$HERE/lgtv-audiocap-receiver.py --port $PORT --output - | \\
aplay -f S16_LE -r $RATE -c $CHANNELS -
EOF
}
# ---------------------------------------------------------------------------
if [ "$UNINSTALL" -eq 1 ]; then
uninstall
exit 0
fi
say "LG TV Audio Cap — host setup"
say "method: $METHOD, user: $SERVICE_USER, port: $PORT, format: $RATE Hz x $CHANNELS"
if [ "$METHOD" = alsa ]; then
setup_alsa
else
setup_pulse
fi
if [ "$INSTALL_SERVICE" -eq 1 ]; then
install_service
fi
summary
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""Receives the TV's RTP/L16 audio and plays it into a local sound device.
This is the piece that makes HyperHDR work. HyperHDR's sound-reactive effects
read a *local* capture device, so the TV's audio has to arrive as one. This
script takes the RTP stream and writes it to a playback device; pair it with
a loopback (host/install-loopback.sh) and HyperHDR sees a normal input.
./lgtv-audiocap-receiver.py # auto-detect an output
./lgtv-audiocap-receiver.py --output pacat --device lgtv-audio
./lgtv-audiocap-receiver.py --output aplay --device hw:Loopback,0
./lgtv-audiocap-receiver.py --output - > /tmp/tv.raw
Standard library only, so it runs on anything with Python 3.6 and either
PulseAudio/PipeWire (pacat) or ALSA (aplay) installed.
"""
import argparse
import array
import errno
import os
import shutil
import signal
import socket
import struct
import subprocess
import sys
import time
RTP_HEADER_BYTES = 12
RTP_PAYLOAD_TYPE = 96 # matches the TV sink
DEFAULT_PORT = 5004
def log(message):
sys.stderr.write(message + "\n")
sys.stderr.flush()
# ---------------------------------------------------------------------------
# RTP
# ---------------------------------------------------------------------------
class RtpPacket(object):
__slots__ = ("sequence", "timestamp", "ssrc", "payload", "payload_type")
def __init__(self, sequence, timestamp, ssrc, payload_type, payload):
self.sequence = sequence
self.timestamp = timestamp
self.ssrc = ssrc
self.payload_type = payload_type
self.payload = payload
def parse_rtp(data):
"""Returns an RtpPacket, or None if this is not RTP we can use."""
if len(data) < RTP_HEADER_BYTES:
return None
byte0, byte1, sequence, timestamp, ssrc = struct.unpack("!BBHII",
data[:RTP_HEADER_BYTES])
if (byte0 >> 6) != 2: # version
return None
offset = RTP_HEADER_BYTES + (byte0 & 0x0F) * 4 # CSRC list
if byte0 & 0x10: # extension header
if len(data) < offset + 4:
return None
ext_words = struct.unpack("!H", data[offset + 2:offset + 4])[0]
offset += 4 + ext_words * 4
end = len(data)
if byte0 & 0x20: # padding: the last byte counts the padding bytes
pad = data[-1] if isinstance(data[-1], int) else ord(data[-1])
if pad and pad <= end - offset:
end -= pad
if offset >= end:
return None
return RtpPacket(sequence, timestamp, ssrc, byte1 & 0x7F, data[offset:end])
def to_native_pcm(payload):
"""L16 is big-endian; sound devices want the host's order."""
samples = array.array("h")
if len(payload) % 2:
payload = payload[:-1]
samples.frombytes(payload)
if sys.byteorder == "little":
samples.byteswap()
return samples.tobytes()
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def detect_output():
"""Pick a player. PulseAudio/PipeWire first: it needs no card set up."""
if shutil.which("pacat"):
try:
subprocess.check_output(["pactl", "info"], stderr=subprocess.DEVNULL,
timeout=3)
return "pacat"
except Exception:
pass
if shutil.which("aplay"):
return "aplay"
if shutil.which("pacat"):
return "pacat"
if shutil.which("ffplay"):
return "ffplay"
return "-"
def build_command(kind, device, rate, channels, latency_ms):
if kind == "pacat":
cmd = ["pacat", "--playback", "--format=s16le",
"--rate=%d" % rate, "--channels=%d" % channels,
"--stream-name=LG TV Audio Cap",
"--latency-msec=%d" % latency_ms]
if device:
cmd += ["--device=%s" % device]
return cmd
if kind == "aplay":
cmd = ["aplay", "-t", "raw", "-f", "S16_LE",
"-r", str(rate), "-c", str(channels), "-q",
# aplay's default buffer is far larger than we want in a chain
# that already has a jitter buffer in front of it.
"--buffer-time=%d" % (latency_ms * 1000)]
if device:
cmd += ["-D", device]
return cmd
if kind == "ffplay":
return ["ffplay", "-hide_banner", "-loglevel", "error", "-nodisp",
"-autoexit", "-f", "s16le", "-ar", str(rate),
"-ac", str(channels), "-i", "pipe:0"]
raise ValueError("unknown output %r" % kind)
class Output(object):
"""Where the audio goes. Restarts the player if it dies."""
def __init__(self, kind, device, rate, channels, latency_ms):
self.kind = kind
self.device = device
self.rate = rate
self.channels = channels
self.latency_ms = latency_ms
self.process = None
self.stream = None
self.restarts = 0
self._open()
def _open(self):
if self.kind == "-":
self.stream = getattr(sys.stdout, "buffer", sys.stdout)
return
cmd = build_command(self.kind, self.device, self.rate, self.channels,
self.latency_ms)
log("playing into: %s" % " ".join(cmd))
self.process = subprocess.Popen(cmd, stdin=subprocess.PIPE)
self.stream = self.process.stdin
def write(self, data):
try:
self.stream.write(data)
self.stream.flush()
return True
except (IOError, OSError, ValueError) as exc:
if getattr(exc, "errno", None) == errno.EINTR:
return True
if self.kind == "-":
raise
log("output died (%s); restarting" % exc)
self.close()
self.restarts += 1
time.sleep(0.5)
self._open()
return False
def close(self):
if self.process:
try:
if self.process.stdin:
self.process.stdin.close()
except Exception:
pass
try:
self.process.terminate()
self.process.wait(timeout=2)
except Exception:
pass
self.process = None
self.stream = None
# ---------------------------------------------------------------------------
# Receiver
# ---------------------------------------------------------------------------
def open_socket(bind, port, group, iface, rcvbuf):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except OSError:
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
except OSError:
pass
sock.bind(("" if group else bind, port))
if group:
# Joining on the wildcard interface lets the kernel choose; an explicit
# one is needed on hosts with several networks.
local = socket.inet_aton(iface) if iface else struct.pack("=I", socket.INADDR_ANY)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(group) + local)
log("joined multicast group %s" % group)
return sock
def run(args):
frame_bytes = 2 * args.channels
sock = open_socket(args.bind, args.port, args.multicast, args.iface,
args.rcvbuf)
sock.settimeout(0.2)
out = Output(args.output, args.device, args.rate, args.channels,
args.latency_ms)
# A block of silence sized to roughly one packet, reused for gap filling.
gap_frames = max(1, int(args.rate * 0.01))
silence = b"\x00" * (gap_frames * frame_bytes)
# Prime the device so the first real packet is not chasing an empty buffer.
prime = int(args.rate * args.prebuffer_ms / 1000.0)
if prime and args.output != "-":
out.write(b"\x00" * (prime * frame_bytes))
stats = {"packets": 0, "bytes": 0, "lost": 0, "late": 0, "resets": 0}
expected = None
ssrc = None
last_packet = time.time()
last_stats = time.time()
running = [True]
def stop(signum, frame):
running[0] = False
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
log("listening on %s:%d for %d Hz %d-channel L16"
% (args.multicast or args.bind, args.port, args.rate, args.channels))
while running[0]:
try:
data, sender = sock.recvfrom(4096)
except socket.timeout:
now = time.time()
# Keep the device fed while the TV is quiet or gone, otherwise the
# player underruns and HyperHDR's effect freezes on the last frame
# instead of fading out.
if args.fill_silence and args.output != "-" and expected is not None:
out.write(silence)
if expected is not None and now - last_packet > args.reset_after:
log("no audio for %.0f s; waiting for the stream to come back"
% args.reset_after)
expected = None
continue
except OSError as exc:
if exc.errno == errno.EINTR:
continue
raise
packet = parse_rtp(data)
if packet is None or packet.payload_type != args.payload_type:
continue
if ssrc is None or packet.ssrc != ssrc:
if ssrc is not None:
log("stream restarted (new SSRC from %s)" % sender[0])
stats["resets"] += 1
else:
log("stream started from %s" % sender[0])
ssrc = packet.ssrc
expected = packet.sequence
# 16-bit sequence numbers wrap; compare in that space.
delta = (packet.sequence - expected) & 0xFFFF
if delta == 0:
pass
elif delta < args.max_gap:
# Lost packets. Substitute silence so playback keeps its timing
# rather than jumping forward.
missing = delta
stats["lost"] += missing
payload_frames = len(packet.payload) // frame_bytes
if payload_frames:
out.write(b"\x00" * (payload_frames * frame_bytes) * missing)
else:
# Either a very late packet or a huge jump. Late ones would play
# out of order, so drop them and resynchronise on a jump.
if delta > 0xFFFF - args.max_gap:
stats["late"] += 1
continue
log("sequence jumped by %d; resynchronising" % delta)
expected = packet.sequence
out.write(to_native_pcm(packet.payload))
expected = (packet.sequence + 1) & 0xFFFF
stats["packets"] += 1
stats["bytes"] += len(packet.payload)
last_packet = time.time()
if args.stats and last_packet - last_stats >= args.stats:
last_stats = last_packet
seconds = stats["bytes"] / float(frame_bytes * args.rate)
log("%d packets, %.1f s audio, %d lost, %d late, %d restarts"
% (stats["packets"], seconds, stats["lost"], stats["late"],
stats["resets"] + out.restarts))
log("stopping")
out.close()
sock.close()
return 0
def main():
parser = argparse.ArgumentParser(
description=__doc__.split("\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__[__doc__.index("This is the piece"):])
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
help="UDP port to listen on (default %d)" % DEFAULT_PORT)
parser.add_argument("--bind", default="0.0.0.0",
help="address to bind (default all interfaces)")
parser.add_argument("--multicast", default=None,
help="multicast group to join, if the TV sends to one")
parser.add_argument("--iface", default=None,
help="local address to join the multicast group on")
parser.add_argument("--rate", type=int, default=48000,
help="sample rate the TV is sending (default 48000)")
parser.add_argument("--channels", type=int, default=2,
help="channel count the TV is sending (default 2)")
parser.add_argument("--payload-type", type=int, default=RTP_PAYLOAD_TYPE,
help="RTP payload type to accept (default %d)"
% RTP_PAYLOAD_TYPE)
parser.add_argument("--output", default="auto",
choices=["auto", "pacat", "aplay", "ffplay", "-"],
help="how to play the audio; '-' writes raw PCM to stdout")
parser.add_argument("--device", default=None,
help="sink or PCM to play into, e.g. lgtv-audio or hw:Loopback,0")
parser.add_argument("--latency-ms", type=int, default=80,
help="playback buffer to ask the device for (default 80)")
parser.add_argument("--prebuffer-ms", type=int, default=60,
help="silence written before the first packet (default 60)")
parser.add_argument("--max-gap", type=int, default=200,
help="packets of loss to paper over before resynchronising")
parser.add_argument("--reset-after", type=float, default=5.0,
help="seconds of silence before the stream is considered gone")
parser.add_argument("--no-fill-silence", dest="fill_silence",
action="store_false",
help="do not write silence while no packets arrive")
parser.add_argument("--rcvbuf", type=int, default=1 << 20,
help="socket receive buffer in bytes")
parser.add_argument("--stats", type=float, default=30.0,
help="seconds between statistics lines, 0 to disable")
args = parser.parse_args()
if args.output == "auto":
args.output = detect_output()
if args.output == "-":
log("no pacat, aplay or ffplay found; writing raw PCM to stdout")
if args.output == "-" and os.isatty(sys.stdout.fileno()):
parser.error("refusing to write raw PCM to a terminal; redirect stdout")
return run(args)
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
cmake_minimum_required(VERSION 3.5)
project(lgtv-audio-cap C)
# Built with the openlgtv arm-webos-linux-gnueabi buildroot SDK:
#
# source /path/to/arm-webos-linux-gnueabi_sdk-buildroot/environment-setup
# cmake -B build -DCMAKE_BUILD_TYPE=Release native
# cmake --build build
#
# The SDK's environment-setup exports CC/SYSROOT and puts its pkg-config in
# front, so luna-service2 and glib resolve to the TV's versions rather than the
# host's.
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
find_package(PkgConfig REQUIRED)
pkg_check_modules(LUNASERVICE REQUIRED luna-service2)
pkg_check_modules(GLIB REQUIRED glib-2.0)
pkg_check_modules(GTHREAD REQUIRED gthread-2.0)
# PmLogLib is how webOS services normally reach the system log. It is optional
# here: the logger writes to stderr, which the service launcher captures.
pkg_check_modules(PMLOG PmLogLib)
add_executable(audiocap-service
src/main.c
src/service.c
src/engine.c
src/config.c
src/dsp.c
src/common/log.c
src/common/json.c
src/common/ringbuf.c
src/capture/capture.c
src/capture/cap_pulse.c
src/capture/cap_alsa.c
src/capture/cap_exec.c
src/capture/cap_tone.c
src/net/flatbuf.c
src/net/hyperion.c
src/net/streamserv.c
src/sinks/sink.c
src/sinks/sink_hyperhdr.c
src/sinks/sink_hyperhdr_viz.c
src/sinks/sink_udp.c
src/sinks/sink_tcp.c
src/sinks/sink_http.c
)
target_include_directories(audiocap-service PRIVATE
src
${LUNASERVICE_INCLUDE_DIRS}
${GLIB_INCLUDE_DIRS}
${GTHREAD_INCLUDE_DIRS}
${PMLOG_INCLUDE_DIRS}
)
target_compile_options(audiocap-service PRIVATE
-Wall -Wextra -Wno-unused-parameter
${LUNASERVICE_CFLAGS_OTHER}
${GLIB_CFLAGS_OTHER}
)
target_compile_definitions(audiocap-service PRIVATE
_GNU_SOURCE
# Lets LSRegisterPubPriv resolve on firmware older than 3.5 without making
# the symbol mandatory on newer builds.
SECURITY_COMPATIBILITY
)
# The audio backends are dlopen'd at runtime, so libpulse and libasound are
# deliberately absent from this list: the service has to start on a TV that has
# neither, report that in the diagnostics, and let the user pick another path.
target_link_libraries(audiocap-service PRIVATE
${LUNASERVICE_LIBRARIES}
${GLIB_LIBRARIES}
${GTHREAD_LIBRARIES}
${PMLOG_LIBRARIES}
pthread
dl
m
)
target_link_directories(audiocap-service PRIVATE
${LUNASERVICE_LIBRARY_DIRS}
${GLIB_LIBRARY_DIRS}
${GTHREAD_LIBRARY_DIRS}
${PMLOG_LIBRARY_DIRS}
)
if(CMAKE_C_COMPILER_ID STREQUAL "GNU" AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm")
# webOS 5/6 TVs are Cortex-A9 (and A53 on later chassis, which runs A9 code
# fine). softfp matches the SDK's ABI.
target_compile_options(audiocap-service PRIVATE
-mcpu=cortex-a9 -mfloat-abi=softfp -mfpu=neon -ffast-math
)
endif()
# Homebrew services are unpacked to an arbitrary directory, so anything we ship
# alongside the binary has to be found relative to it.
set_target_properties(audiocap-service PROPERTIES
BUILD_RPATH "$ORIGIN"
INSTALL_RPATH "$ORIGIN"
)
install(TARGETS audiocap-service RUNTIME DESTINATION .)
+268
View File
@@ -0,0 +1,268 @@
// ALSA capture via libasound, loaded with dlopen.
//
// ALSA is unusually friendly to runtime loading: every configuration struct
// is opaque and allocated by the library itself, so there is no struct layout
// to guess at. That makes this the lowest-risk backend to load dynamically.
//
// Useful device names on a rooted TV:
// hw:Loopback,1 - if snd-aloop is loaded and audio is routed into it
// hw:0,0 - a real capture PCM, when the SoC exposes one
// pulse_monitor - an /etc/asound.conf alias for a PulseAudio monitor
#include "capture.h"
#include "../common/log.h"
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#define SND_PCM_STREAM_CAPTURE 1
#define SND_PCM_ACCESS_RW_INTERLEAVED 3
#define SND_PCM_FORMAT_S16_LE 2
typedef struct _snd_pcm snd_pcm_t;
typedef struct _snd_pcm_hw_params snd_pcm_hw_params_t;
typedef long snd_pcm_sframes_t;
typedef unsigned long snd_pcm_uframes_t;
// X-macro keeps the symbol table, the typedefs and the resolution loop from
// drifting apart as functions are added.
#define ALSA_SYMBOLS(X) \
X(int, snd_pcm_open, (snd_pcm_t * *, const char*, int, int)) \
X(int, snd_pcm_close, (snd_pcm_t*)) \
X(int, snd_pcm_prepare, (snd_pcm_t*)) \
X(int, snd_pcm_start, (snd_pcm_t*)) \
X(int, snd_pcm_drop, (snd_pcm_t*)) \
X(snd_pcm_sframes_t, snd_pcm_readi, (snd_pcm_t*, void*, snd_pcm_uframes_t)) \
X(int, snd_pcm_recover, (snd_pcm_t*, int, int)) \
X(int, snd_pcm_hw_params_malloc, (snd_pcm_hw_params_t**)) \
X(void, snd_pcm_hw_params_free, (snd_pcm_hw_params_t*)) \
X(int, snd_pcm_hw_params_any, (snd_pcm_t*, snd_pcm_hw_params_t*)) \
X(int, snd_pcm_hw_params_set_access, (snd_pcm_t*, snd_pcm_hw_params_t*, int)) \
X(int, snd_pcm_hw_params_set_format, (snd_pcm_t*, snd_pcm_hw_params_t*, int)) \
X(int, snd_pcm_hw_params_set_channels_near, (snd_pcm_t*, snd_pcm_hw_params_t*, unsigned*)) \
X(int, snd_pcm_hw_params_set_rate_near, (snd_pcm_t*, snd_pcm_hw_params_t*, unsigned*, int*)) \
X(int, snd_pcm_hw_params_set_period_size_near, (snd_pcm_t*, snd_pcm_hw_params_t*, snd_pcm_uframes_t*, int*)) \
X(int, snd_pcm_hw_params_set_buffer_size_near, (snd_pcm_t*, snd_pcm_hw_params_t*, snd_pcm_uframes_t*)) \
X(int, snd_pcm_hw_params, (snd_pcm_t*, snd_pcm_hw_params_t*)) \
X(const char*, snd_strerror, (int))
#define DECLARE_FN(ret, name, args) typedef ret (*fn_##name) args;
ALSA_SYMBOLS(DECLARE_FN)
#undef DECLARE_FN
typedef struct {
void* handle;
bool loaded;
bool tried;
char load_error[256];
#define FIELD_FN(ret, name, args) fn_##name name;
ALSA_SYMBOLS(FIELD_FN)
#undef FIELD_FN
} alsa_lib_t;
static alsa_lib_t s_lib;
static bool alsa_load(void)
{
if (s_lib.tried)
return s_lib.loaded;
s_lib.tried = true;
s_lib.handle = dlopen("libasound.so.2", RTLD_NOW);
if (!s_lib.handle) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libasound.so.2: %s", dlerror());
return false;
}
#define RESOLVE_FN(ret, name, args) \
s_lib.name = (fn_##name)dlsym(s_lib.handle, #name); \
if (!s_lib.name) { \
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libasound missing %s", #name); \
return false; \
}
ALSA_SYMBOLS(RESOLVE_FN)
#undef RESOLVE_FN
s_lib.loaded = true;
INFO("ALSA client library loaded");
return true;
}
// --- Backend ---------------------------------------------------------------
typedef struct {
snd_pcm_t* pcm;
int channels;
} alsa_priv_t;
static int alsa_read(capture_t* c, int16_t* dst, int max_frames)
{
alsa_priv_t* p = c->priv;
snd_pcm_sframes_t n = s_lib.snd_pcm_readi(p->pcm, dst, (snd_pcm_uframes_t)max_frames);
if (n < 0) {
// Overruns are routine when a sink stalls; recover in place rather
// than tearing the whole pipeline down.
int rc = s_lib.snd_pcm_recover(p->pcm, (int)n, 1);
if (rc < 0) {
ERR("snd_pcm_readi failed: %s", s_lib.snd_strerror((int)n));
return -1;
}
WARN("ALSA stream recovered from %s", s_lib.snd_strerror((int)n));
return 0;
}
return (int)n;
}
static void alsa_close(capture_t* c)
{
alsa_priv_t* p = c->priv;
if (p) {
if (p->pcm) {
s_lib.snd_pcm_drop(p->pcm);
s_lib.snd_pcm_close(p->pcm);
}
free(p);
}
free(c);
}
static bool alsa_available(void)
{
if (!alsa_load())
return false;
// libasound present but no sound cards means nothing to open.
struct stat st;
return stat("/proc/asound", &st) == 0;
}
static void alsa_describe(json_writer_t* w)
{
if (!alsa_load()) {
jw_str(w, "detail", s_lib.load_error[0] ? s_lib.load_error : "libasound.so.2 not found");
return;
}
struct stat st;
if (stat("/proc/asound", &st) != 0) {
jw_str(w, "detail", "libasound loaded but /proc/asound is absent (no ALSA cards)");
return;
}
jw_str(w, "detail", "libasound loaded; see alsaCapturePcms for openable devices");
}
static capture_t* alsa_open(const capture_opts_t* opts, char* err, size_t errlen)
{
if (!alsa_load()) {
snprintf(err, errlen, "%s", s_lib.load_error[0] ? s_lib.load_error : "libasound unavailable");
return NULL;
}
const char* device = (opts->device && *opts->device) ? opts->device : "default";
snd_pcm_t* pcm = NULL;
int rc = s_lib.snd_pcm_open(&pcm, device, SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
snprintf(err, errlen, "snd_pcm_open(%s): %s", device, s_lib.snd_strerror(rc));
return NULL;
}
snd_pcm_hw_params_t* hw = NULL;
if ((rc = s_lib.snd_pcm_hw_params_malloc(&hw)) < 0) {
snprintf(err, errlen, "hw_params_malloc: %s", s_lib.snd_strerror(rc));
s_lib.snd_pcm_close(pcm);
return NULL;
}
unsigned rate = (unsigned)opts->fmt.rate;
unsigned channels = (unsigned)opts->fmt.channels;
snd_pcm_uframes_t period = AUDIO_BLOCK_FRAMES;
snd_pcm_uframes_t buffer = AUDIO_BLOCK_FRAMES * 8;
const char* stage = NULL;
do {
stage = "hw_params_any";
if ((rc = s_lib.snd_pcm_hw_params_any(pcm, hw)) < 0)
break;
stage = "set_access";
if ((rc = s_lib.snd_pcm_hw_params_set_access(pcm, hw, SND_PCM_ACCESS_RW_INTERLEAVED)) < 0)
break;
stage = "set_format(S16_LE)";
if ((rc = s_lib.snd_pcm_hw_params_set_format(pcm, hw, SND_PCM_FORMAT_S16_LE)) < 0)
break;
stage = "set_channels";
if ((rc = s_lib.snd_pcm_hw_params_set_channels_near(pcm, hw, &channels)) < 0)
break;
stage = "set_rate";
if ((rc = s_lib.snd_pcm_hw_params_set_rate_near(pcm, hw, &rate, NULL)) < 0)
break;
stage = "set_period_size";
if ((rc = s_lib.snd_pcm_hw_params_set_period_size_near(pcm, hw, &period, NULL)) < 0)
break;
stage = "set_buffer_size";
if ((rc = s_lib.snd_pcm_hw_params_set_buffer_size_near(pcm, hw, &buffer)) < 0)
break;
stage = "hw_params";
if ((rc = s_lib.snd_pcm_hw_params(pcm, hw)) < 0)
break;
stage = NULL;
} while (0);
s_lib.snd_pcm_hw_params_free(hw);
if (stage) {
snprintf(err, errlen, "ALSA %s on '%s': %s", stage, device, s_lib.snd_strerror(rc));
s_lib.snd_pcm_close(pcm);
return NULL;
}
if (channels > AUDIO_MAX_CHANNELS) {
snprintf(err, errlen, "device '%s' forced %u channels; only mono and stereo are supported",
device, channels);
s_lib.snd_pcm_close(pcm);
return NULL;
}
if ((rc = s_lib.snd_pcm_prepare(pcm)) < 0) {
snprintf(err, errlen, "snd_pcm_prepare: %s", s_lib.snd_strerror(rc));
s_lib.snd_pcm_close(pcm);
return NULL;
}
capture_t* c = calloc(1, sizeof(*c));
alsa_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
s_lib.snd_pcm_close(pcm);
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->pcm = pcm;
p->channels = (int)channels;
c->driver = &capture_driver_alsa;
c->priv = p;
// Report what the hardware actually gave us; the engine re-tunes the DSP
// and the sinks around this rather than assuming the request was honoured.
c->fmt.rate = (int)rate;
c->fmt.channels = (int)channels;
c->read = alsa_read;
c->close = alsa_close;
INFO("ALSA capture open: device=%s rate=%u channels=%u period=%lu", device, rate,
channels, (unsigned long)period);
return c;
}
const capture_driver_t capture_driver_alsa = {
.id = "alsa",
.name = "ALSA PCM",
.description = "Reads an ALSA capture device such as hw:Loopback,1 or a monitor alias.",
.describe = alsa_describe,
.available = alsa_available,
.open = alsa_open,
};
+199
View File
@@ -0,0 +1,199 @@
// Runs an arbitrary shell command and treats its stdout as raw S16LE PCM.
//
// This is the escape hatch. Because LG's audio routing differs by model and
// firmware, the command that actually yields audio on a given TV is something
// the owner has to discover. Rather than requiring a rebuild for each finding,
// the command is a setting:
//
// parec --format=s16le --rate=48000 --channels=2 -d <source>.monitor
// arecord -D hw:Loopback,1 -f S16_LE -r 48000 -c 2 -t raw
// ffmpeg -f alsa -i default -f s16le -ar 48000 -ac 2 -
//
// The child runs in its own process group so that killing it takes down every
// stage of a shell pipeline, not just the leftmost process.
#include "capture.h"
#include "../common/log.h"
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#define EXEC_READ_TIMEOUT_MS 2000
typedef struct {
pid_t pid;
int fd;
int frame_bytes;
// Carries a partial frame between reads so callers always see whole frames.
unsigned char partial[AUDIO_MAX_CHANNELS * sizeof(int16_t)];
int partial_len;
} exec_priv_t;
static int exec_read(capture_t* c, int16_t* dst, int max_frames)
{
exec_priv_t* p = c->priv;
unsigned char* out = (unsigned char*)dst;
size_t want = (size_t)max_frames * (size_t)p->frame_bytes;
size_t have = 0;
if (p->partial_len > 0) {
memcpy(out, p->partial, (size_t)p->partial_len);
have = (size_t)p->partial_len;
p->partial_len = 0;
}
while (have < want) {
struct pollfd pfd = { .fd = p->fd, .events = POLLIN };
int pr = poll(&pfd, 1, EXEC_READ_TIMEOUT_MS);
if (pr < 0) {
if (errno == EINTR)
continue;
ERR("exec backend poll failed: %s", strerror(errno));
return -1;
}
if (pr == 0) {
// No data within the timeout. Return whatever whole frames we have
// (possibly none) so the engine can keep its status fresh.
break;
}
ssize_t n = read(p->fd, out + have, want - have);
if (n < 0) {
if (errno == EINTR)
continue;
ERR("exec backend read failed: %s", strerror(errno));
return -1;
}
if (n == 0) {
ERR("exec backend: command exited (stdout closed)");
return -1;
}
have += (size_t)n;
}
int frames = (int)(have / (size_t)p->frame_bytes);
size_t leftover = have - (size_t)frames * (size_t)p->frame_bytes;
if (leftover > 0) {
memcpy(p->partial, out + (size_t)frames * (size_t)p->frame_bytes, leftover);
p->partial_len = (int)leftover;
}
return frames;
}
static void exec_close(capture_t* c)
{
exec_priv_t* p = c->priv;
if (p) {
if (p->fd >= 0)
close(p->fd);
if (p->pid > 0) {
// Negative pid targets the whole process group.
kill(-p->pid, SIGTERM);
for (int i = 0; i < 20; i++) {
if (waitpid(p->pid, NULL, WNOHANG) == p->pid) {
p->pid = -1;
break;
}
usleep(50000);
}
if (p->pid > 0) {
WARN("exec backend: command ignored SIGTERM, sending SIGKILL");
kill(-p->pid, SIGKILL);
waitpid(p->pid, NULL, 0);
}
}
free(p);
}
free(c);
}
static bool exec_available(void)
{
return access("/bin/sh", X_OK) == 0;
}
static void exec_describe(json_writer_t* w)
{
jw_str(w, "detail",
"Always usable. Set captureCommand to any program that writes raw S16LE PCM to stdout.");
}
static capture_t* exec_open(const capture_opts_t* opts, char* err, size_t errlen)
{
if (!opts->command || !*opts->command) {
snprintf(err, errlen, "exec backend selected but captureCommand is empty");
return NULL;
}
int pipefd[2];
if (pipe(pipefd) != 0) {
snprintf(err, errlen, "pipe(): %s", strerror(errno));
return NULL;
}
pid_t pid = fork();
if (pid < 0) {
snprintf(err, errlen, "fork(): %s", strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
return NULL;
}
if (pid == 0) {
// Child.
setpgid(0, 0);
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
// Leave stderr attached so the command's own diagnostics land in the
// service log next to ours.
execl("/bin/sh", "sh", "-c", opts->command, (char*)NULL);
_exit(127);
}
// Parent. Set the group here too so there is no window where a kill would
// race the child's own setpgid.
setpgid(pid, pid);
close(pipefd[1]);
capture_t* c = calloc(1, sizeof(*c));
exec_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
close(pipefd[0]);
kill(-pid, SIGKILL);
waitpid(pid, NULL, 0);
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->pid = pid;
p->fd = pipefd[0];
p->frame_bytes = audio_frame_bytes(&opts->fmt);
c->driver = &capture_driver_exec;
c->priv = p;
c->fmt = opts->fmt;
c->read = exec_read;
c->close = exec_close;
INFO("exec capture started (pid %d): %s", (int)pid, opts->command);
return c;
}
const capture_driver_t capture_driver_exec = {
.id = "exec",
.name = "External command",
.description = "Pipes raw S16LE PCM from any command, e.g. parec or arecord.",
.describe = exec_describe,
.available = exec_available,
.open = exec_open,
};
+226
View File
@@ -0,0 +1,226 @@
// PulseAudio capture via the `pa_simple` blocking API, loaded with dlopen.
//
// Why dlopen instead of linking: the buildroot/NDK sysroots used to build
// webOS homebrew do not reliably ship PulseAudio development files, and a
// hard link-time dependency would make the whole service fail to start on a
// TV that has no libpulse at all. Loading at runtime lets the service come
// up, report "PulseAudio not present" in Diagnostics, and fall back.
//
// Only `pa_simple` is used, which keeps the ABI surface to five functions and
// one struct (pa_sample_spec) that has been stable since PulseAudio 0.9.
// The richer introspection API would require redeclaring large structs whose
// layout we cannot verify against the TV's build, so source enumeration is
// left to Diagnostics (pactl, when present) instead.
#include "capture.h"
#include "../common/log.h"
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// --- Minimal PulseAudio ABI ------------------------------------------------
#define PA_SAMPLE_S16LE 3
#define PA_STREAM_RECORD 2
typedef struct {
int format;
uint32_t rate;
uint8_t channels;
} pa_sample_spec;
typedef struct {
uint32_t maxlength;
uint32_t tlength;
uint32_t prebuf;
uint32_t minreq;
uint32_t fragsize;
} pa_buffer_attr;
typedef struct pa_simple pa_simple;
typedef pa_simple* (*fn_pa_simple_new)(const char* server, const char* name, int dir,
const char* dev, const char* stream_name, const pa_sample_spec* ss,
const void* map, const pa_buffer_attr* attr, int* error);
typedef int (*fn_pa_simple_read)(pa_simple* s, void* data, size_t bytes, int* error);
typedef void (*fn_pa_simple_free)(pa_simple* s);
typedef int (*fn_pa_simple_flush)(pa_simple* s, int* error);
typedef const char* (*fn_pa_strerror)(int error);
typedef struct {
void* handle_simple;
void* handle_core;
fn_pa_simple_new simple_new;
fn_pa_simple_read simple_read;
fn_pa_simple_free simple_free;
fn_pa_simple_flush simple_flush;
fn_pa_strerror strerror_fn;
bool loaded;
bool tried;
char load_error[256];
} pulse_lib_t;
static pulse_lib_t s_lib;
static bool pulse_load(void)
{
if (s_lib.tried)
return s_lib.loaded;
s_lib.tried = true;
// libpulse must be resolvable for libpulse-simple's own relocations.
s_lib.handle_core = dlopen("libpulse.so.0", RTLD_NOW | RTLD_GLOBAL);
if (!s_lib.handle_core) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libpulse.so.0: %s", dlerror());
return false;
}
s_lib.handle_simple = dlopen("libpulse-simple.so.0", RTLD_NOW);
if (!s_lib.handle_simple) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libpulse-simple.so.0: %s", dlerror());
return false;
}
s_lib.simple_new = (fn_pa_simple_new)dlsym(s_lib.handle_simple, "pa_simple_new");
s_lib.simple_read = (fn_pa_simple_read)dlsym(s_lib.handle_simple, "pa_simple_read");
s_lib.simple_free = (fn_pa_simple_free)dlsym(s_lib.handle_simple, "pa_simple_free");
s_lib.simple_flush = (fn_pa_simple_flush)dlsym(s_lib.handle_simple, "pa_simple_flush");
s_lib.strerror_fn = (fn_pa_strerror)dlsym(s_lib.handle_core, "pa_strerror");
if (!s_lib.simple_new || !s_lib.simple_read || !s_lib.simple_free) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error),
"libpulse-simple.so.0 is missing expected pa_simple_* symbols");
return false;
}
s_lib.loaded = true;
INFO("PulseAudio client library loaded");
return true;
}
static const char* pulse_err(int code)
{
if (s_lib.strerror_fn) {
const char* s = s_lib.strerror_fn(code);
if (s)
return s;
}
return "unknown PulseAudio error";
}
// --- Backend ---------------------------------------------------------------
typedef struct {
pa_simple* stream;
int frame_bytes;
} pulse_priv_t;
static int pulse_read(capture_t* c, int16_t* dst, int max_frames)
{
pulse_priv_t* p = c->priv;
size_t want = (size_t)max_frames * (size_t)p->frame_bytes;
int error = 0;
// pa_simple_read blocks until the full request is satisfied, so the block
// size alone sets our latency floor.
if (s_lib.simple_read(p->stream, dst, want, &error) < 0) {
ERR("pa_simple_read failed: %s", pulse_err(error));
return -1;
}
return max_frames;
}
static void pulse_close(capture_t* c)
{
pulse_priv_t* p = c->priv;
if (p) {
if (p->stream)
s_lib.simple_free(p->stream);
free(p);
}
free(c);
}
static bool pulse_available(void) { return pulse_load(); }
static void pulse_describe(json_writer_t* w)
{
if (pulse_load()) {
jw_str(w, "detail", "libpulse-simple loaded; capture from a sink monitor source");
} else {
jw_str(w, "detail", s_lib.load_error[0] ? s_lib.load_error : "PulseAudio client libraries not found");
}
}
static capture_t* pulse_open(const capture_opts_t* opts, char* err, size_t errlen)
{
if (!pulse_load()) {
snprintf(err, errlen, "%s", s_lib.load_error[0] ? s_lib.load_error : "libpulse unavailable");
return NULL;
}
// "@DEFAULT_MONITOR@" is resolved by the daemon (pa_namereg_get), so we
// get the monitor of whatever sink the TV is currently playing through
// without needing the introspection API to enumerate sources.
const char* device = (opts->device && *opts->device) ? opts->device : "@DEFAULT_MONITOR@";
const char* server = (opts->server && *opts->server) ? opts->server : NULL;
pa_sample_spec ss = {
.format = PA_SAMPLE_S16LE,
.rate = (uint32_t)opts->fmt.rate,
.channels = (uint8_t)opts->fmt.channels,
};
int frame_bytes = audio_frame_bytes(&opts->fmt);
pa_buffer_attr attr = {
.maxlength = (uint32_t)-1,
.tlength = (uint32_t)-1,
.prebuf = (uint32_t)-1,
.minreq = (uint32_t)-1,
.fragsize = (uint32_t)(AUDIO_BLOCK_FRAMES * frame_bytes),
};
int error = 0;
pa_simple* stream = s_lib.simple_new(server, "LG TV Audio Cap", PA_STREAM_RECORD,
device, "tv-audio", &ss, NULL, &attr, &error);
if (!stream) {
snprintf(err, errlen, "pa_simple_new(server=%s, device=%s): %s",
server ? server : "<default>", device, pulse_err(error));
return NULL;
}
capture_t* c = calloc(1, sizeof(*c));
pulse_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
s_lib.simple_free(stream);
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->stream = stream;
p->frame_bytes = frame_bytes;
c->driver = &capture_driver_pulse;
c->priv = p;
c->fmt = opts->fmt;
c->read = pulse_read;
c->close = pulse_close;
INFO("PulseAudio capture open: device=%s rate=%d channels=%d", device,
opts->fmt.rate, opts->fmt.channels);
return c;
}
const capture_driver_t capture_driver_pulse = {
.id = "pulse",
.name = "PulseAudio monitor",
.description = "Records the monitor source of the TV's active PulseAudio sink.",
.describe = pulse_describe,
.available = pulse_available,
.open = pulse_open,
};
+159
View File
@@ -0,0 +1,159 @@
// Synthetic signal generator.
//
// Exists so the transport half of the app can be commissioned independently of
// the capture half. Getting audio off an LG TV is the uncertain part; getting
// it into HyperHDR is not. Selecting `tone` proves the network path, the host
// receiver, the loopback device and the HyperHDR effect all work before
// anyone starts guessing at PulseAudio source names.
//
// The signal is a slow log sweep from 60 Hz to 12 kHz with an amplitude
// pulse roughly once a second, so a spectrum display shows a moving peak and
// a VU meter visibly bounces.
#include "capture.h"
#include "../common/log.h"
#include <errno.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define SWEEP_LOW_HZ 60.0
#define SWEEP_HIGH_HZ 12000.0
#define SWEEP_SECONDS 8.0
#define PULSE_HZ 1.0
typedef struct {
audio_format_t fmt;
double phase; // carrier phase, radians
double t; // seconds since start
struct timespec next_deadline;
bool paced;
} tone_priv_t;
static void advance_deadline(struct timespec* ts, double seconds)
{
ts->tv_nsec += (long)(seconds * 1e9);
while (ts->tv_nsec >= 1000000000L) {
ts->tv_nsec -= 1000000000L;
ts->tv_sec++;
}
}
// Sleeps until the absolute monotonic deadline. clock_nanosleep is the right
// tool but is Linux-only; the fallback keeps host builds of the test harness
// compiling on macOS.
static void sleep_until(const struct timespec* deadline)
{
#ifdef TIMER_ABSTIME
while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, deadline, NULL) == EINTR) {
// retry
}
#else
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
struct timespec delta = {
.tv_sec = deadline->tv_sec - now.tv_sec,
.tv_nsec = deadline->tv_nsec - now.tv_nsec,
};
if (delta.tv_nsec < 0) {
delta.tv_nsec += 1000000000L;
delta.tv_sec--;
}
if (delta.tv_sec < 0)
return;
while (nanosleep(&delta, &delta) != 0 && errno == EINTR) {
// retry with the remaining time
}
#endif
}
static int tone_read(capture_t* c, int16_t* dst, int max_frames)
{
tone_priv_t* p = c->priv;
const int ch = p->fmt.channels;
const double sr = (double)p->fmt.rate;
const double dt = 1.0 / sr;
// Pace to wall-clock so downstream sinks see a realistic data rate rather
// than a flood.
if (!p->paced) {
clock_gettime(CLOCK_MONOTONIC, &p->next_deadline);
p->paced = true;
}
advance_deadline(&p->next_deadline, (double)max_frames / sr);
sleep_until(&p->next_deadline);
for (int i = 0; i < max_frames; i++) {
double sweep_pos = fmod(p->t, SWEEP_SECONDS) / SWEEP_SECONDS;
double freq = SWEEP_LOW_HZ * pow(SWEEP_HIGH_HZ / SWEEP_LOW_HZ, sweep_pos);
p->phase += 2.0 * M_PI * freq * dt;
if (p->phase > 2.0 * M_PI)
p->phase -= 2.0 * M_PI;
// Half-wave rectified sine envelope gives a clear rhythmic pulse.
double env = 0.25 + 0.75 * fabs(sin(M_PI * PULSE_HZ * p->t));
double sample = 0.6 * env * sin(p->phase);
int16_t v = (int16_t)(sample * 32000.0);
for (int cch = 0; cch < ch; cch++) {
// Slightly quieter right channel so stereo handling is visible.
dst[i * ch + cch] = (cch == 1) ? (int16_t)(v * 0.7) : v;
}
p->t += dt;
}
return max_frames;
}
static void tone_close(capture_t* c)
{
free(c->priv);
free(c);
}
static bool tone_available(void) { return true; }
static void tone_describe(json_writer_t* w)
{
jw_str(w, "detail", "Built-in sweep generator for verifying the network path end to end.");
}
static capture_t* tone_open(const capture_opts_t* opts, char* err, size_t errlen)
{
capture_t* c = calloc(1, sizeof(*c));
tone_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->fmt = opts->fmt;
c->driver = &capture_driver_tone;
c->priv = p;
c->fmt = opts->fmt;
c->read = tone_read;
c->close = tone_close;
INFO("Test tone generator started: rate=%d channels=%d", opts->fmt.rate, opts->fmt.channels);
return c;
}
const capture_driver_t capture_driver_tone = {
.id = "tone",
.name = "Test tone",
.description = "Generates a sweeping tone instead of capturing, to validate the output chain.",
.describe = tone_describe,
.available = tone_available,
.open = tone_open,
};
+286
View File
@@ -0,0 +1,286 @@
#include "capture.h"
#include "../common/log.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
// Order matters: capture_open("auto") walks this list and takes the first
// backend that reports itself available.
static const capture_driver_t* const s_drivers[] = {
&capture_driver_pulse,
&capture_driver_alsa,
&capture_driver_exec,
&capture_driver_tone,
};
const capture_driver_t* const* capture_drivers(size_t* count)
{
*count = sizeof(s_drivers) / sizeof(s_drivers[0]);
return s_drivers;
}
const capture_driver_t* capture_find(const char* id)
{
if (!id)
return NULL;
for (size_t i = 0; i < sizeof(s_drivers) / sizeof(s_drivers[0]); i++) {
if (strcmp(s_drivers[i]->id, id) == 0)
return s_drivers[i];
}
return NULL;
}
capture_t* capture_open(const char* id, const capture_opts_t* opts, char* err, size_t errlen)
{
if (err && errlen)
err[0] = '\0';
if (id && *id && strcmp(id, "auto") != 0) {
const capture_driver_t* drv = capture_find(id);
if (!drv) {
snprintf(err, errlen, "unknown capture backend '%s'", id);
return NULL;
}
INFO("Opening capture backend '%s'", drv->id);
return drv->open(opts, err, errlen);
}
for (size_t i = 0; i < sizeof(s_drivers) / sizeof(s_drivers[0]); i++) {
const capture_driver_t* drv = s_drivers[i];
// `tone` is always "available" by construction; never auto-select it,
// or a broken capture setup would silently stream a test tone to the
// user's lights and look like it was working.
if (strcmp(drv->id, "tone") == 0)
continue;
if (!drv->available())
continue;
char local_err[256] = { 0 };
capture_t* c = drv->open(opts, local_err, sizeof(local_err));
if (c) {
INFO("Auto-selected capture backend '%s'", drv->id);
return c;
}
WARN("Auto-probe: backend '%s' failed: %s", drv->id, local_err);
}
snprintf(err, errlen,
"no capture backend could be opened; run Diagnostics to see what this TV exposes");
return NULL;
}
void capture_close(capture_t* c)
{
if (!c)
return;
c->close(c);
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
char* capture_read_file(const char* path)
{
FILE* f = fopen(path, "rb");
if (!f)
return NULL;
size_t cap = 8192, len = 0;
char* buf = malloc(cap);
if (!buf) {
fclose(f);
return NULL;
}
for (;;) {
if (len + 1024 > cap) {
cap *= 2;
char* grown = realloc(buf, cap);
if (!grown) {
free(buf);
fclose(f);
return NULL;
}
buf = grown;
}
size_t n = fread(buf + len, 1, cap - len - 1, f);
if (n == 0)
break;
len += n;
}
buf[len] = '\0';
fclose(f);
return buf;
}
char* capture_run_command(const char* cmd, size_t limit)
{
FILE* p = popen(cmd, "r");
if (!p)
return NULL;
char* buf = malloc(limit + 1);
if (!buf) {
pclose(p);
return NULL;
}
size_t len = fread(buf, 1, limit, p);
buf[len] = '\0';
pclose(p);
return buf;
}
bool capture_have_binary(const char* name)
{
const char* path = getenv("PATH");
if (!path || !*path)
path = "/usr/sbin:/usr/bin:/sbin:/bin";
char* copy = strdup(path);
if (!copy)
return false;
bool found = false;
char* saveptr = NULL;
for (char* dir = strtok_r(copy, ":", &saveptr); dir; dir = strtok_r(NULL, ":", &saveptr)) {
char full[512];
snprintf(full, sizeof(full), "%s/%s", dir, name);
if (access(full, X_OK) == 0) {
found = true;
break;
}
}
free(copy);
return found;
}
// ---------------------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------------------
static void write_alsa_devices(json_writer_t* w)
{
jw_arr_open(w, "alsaCards");
char* cards = capture_read_file("/proc/asound/cards");
if (cards) {
// Each card occupies two lines; the first starts with its index.
char* saveptr = NULL;
for (char* line = strtok_r(cards, "\n", &saveptr); line;
line = strtok_r(NULL, "\n", &saveptr)) {
while (*line == ' ')
line++;
if (*line >= '0' && *line <= '9')
jw_str(w, NULL, line);
}
free(cards);
}
jw_arr_close(w);
jw_arr_open(w, "alsaCapturePcms");
char* pcms = capture_read_file("/proc/asound/pcm");
if (pcms) {
char* saveptr = NULL;
for (char* line = strtok_r(pcms, "\n", &saveptr); line;
line = strtok_r(NULL, "\n", &saveptr)) {
if (strstr(line, "capture"))
jw_str(w, NULL, line);
}
free(pcms);
}
jw_arr_close(w);
}
static void write_pulse_devices(json_writer_t* w)
{
jw_arr_open(w, "pulseSockets");
static const char* candidates[] = {
"/var/run/pulse/native",
"/run/pulse/native",
"/tmp/pulse/native",
"/var/run/user/0/pulse/native",
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
struct stat st;
if (stat(candidates[i], &st) == 0)
jw_str(w, NULL, candidates[i]);
}
jw_arr_close(w);
// pactl is usually absent from stock firmware, but when it is present it
// is by far the fastest way to see the real source list.
if (capture_have_binary("pactl")) {
char* out = capture_run_command("pactl list short sources 2>&1", 8192);
jw_str(w, "pactlSources", out ? out : "");
free(out);
} else {
jw_null(w, "pactlSources");
}
}
static void write_library_presence(json_writer_t* w)
{
static const char* libs[] = {
"libpulse.so.0",
"libpulse-simple.so.0",
"libasound.so.2",
};
static const char* dirs[] = {
"/usr/lib",
"/lib",
"/usr/lib/arm-linux-gnueabi",
"/usr/local/lib",
};
jw_obj_open(w, "libraries");
for (size_t i = 0; i < sizeof(libs) / sizeof(libs[0]); i++) {
const char* found = NULL;
static char full[512];
for (size_t d = 0; d < sizeof(dirs) / sizeof(dirs[0]) && !found; d++) {
snprintf(full, sizeof(full), "%s/%s", dirs[d], libs[i]);
if (access(full, R_OK) == 0)
found = full;
}
jw_str(w, libs[i], found);
}
jw_obj_close(w);
}
static void write_binary_presence(json_writer_t* w)
{
static const char* bins[] = { "parec", "pactl", "pacat", "arecord", "amixer", "ffmpeg", "gst-launch-1.0" };
jw_obj_open(w, "binaries");
for (size_t i = 0; i < sizeof(bins) / sizeof(bins[0]); i++)
jw_bool(w, bins[i], capture_have_binary(bins[i]));
jw_obj_close(w);
}
void capture_write_diagnostics(json_writer_t* w)
{
jw_arr_open(w, "backends");
size_t count = 0;
const capture_driver_t* const* drivers = capture_drivers(&count);
for (size_t i = 0; i < count; i++) {
jw_obj_open(w, NULL);
jw_str(w, "id", drivers[i]->id);
jw_str(w, "name", drivers[i]->name);
jw_str(w, "description", drivers[i]->description);
jw_bool(w, "available", drivers[i]->available());
if (drivers[i]->describe)
drivers[i]->describe(w);
jw_obj_close(w);
}
jw_arr_close(w);
jw_obj_open(w, "system");
jw_bool(w, "root", geteuid() == 0);
jw_int(w, "uid", (long long)geteuid());
write_library_presence(w);
write_binary_presence(w);
write_pulse_devices(w);
write_alsa_devices(w);
jw_obj_close(w);
}
+84
View File
@@ -0,0 +1,84 @@
// Capture backend abstraction.
//
// There is no published, known-good way to tap the audio a webOS TV is
// playing: PicCap and hyperion-webos both capture video only, and LG's audio
// path differs across models (some route everything through PulseAudio, some
// hand broadcast/HDMI audio to the SoC DSP and never expose it to userspace).
//
// So rather than betting the app on one mechanism, every backend is probed at
// runtime and the UI reports what actually exists on *this* TV. `exec` is the
// deliberate escape hatch: whatever command turns out to work on a given
// model can be wired up from the settings screen without a rebuild.
#pragma once
#include "../common/audio.h"
#include "../common/json.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct capture capture_t;
typedef struct {
audio_format_t fmt;
const char* device; // pulse source name / ALSA PCM name; NULL for default
const char* server; // PulseAudio server string, e.g. "unix:/var/run/pulse/native"
const char* command; // shell command for the `exec` backend
} capture_opts_t;
typedef struct {
const char* id;
const char* name;
const char* description;
// Reports whether this backend could plausibly run here, appending a
// human-readable explanation to `w` as an object member.
void (*describe)(json_writer_t* w);
bool (*available)(void);
// Returns NULL on failure and writes a reason into `err`.
capture_t* (*open)(const capture_opts_t* opts, char* err, size_t errlen);
} capture_driver_t;
struct capture {
const capture_driver_t* driver;
void* priv;
audio_format_t fmt; // format actually negotiated, may differ from request
// Blocking read of up to `max_frames` interleaved S16LE frames.
// Returns frames read, 0 on timeout, negative on unrecoverable error.
int (*read)(capture_t* c, int16_t* dst, int max_frames);
void (*close)(capture_t* c);
};
// Registry -------------------------------------------------------------------
// The drivers themselves, declared here so both the registry and each driver's
// own translation unit see one declaration.
extern const capture_driver_t capture_driver_pulse;
extern const capture_driver_t capture_driver_alsa;
extern const capture_driver_t capture_driver_exec;
extern const capture_driver_t capture_driver_tone;
const capture_driver_t* capture_find(const char* id);
const capture_driver_t* const* capture_drivers(size_t* count);
// Opens the named backend, or the first available one when `id` is NULL or
// "auto". Order of preference: pulse, alsa, exec, tone.
capture_t* capture_open(const char* id, const capture_opts_t* opts, char* err, size_t errlen);
void capture_close(capture_t* c);
// Diagnostics ----------------------------------------------------------------
// Writes a "backends" array plus a "devices" object describing the sound
// hardware this TV exposes. Everything here is best-effort and read-only.
void capture_write_diagnostics(json_writer_t* w);
// Shared helper: reads a whole file into a malloc'd string, or NULL.
char* capture_read_file(const char* path);
// Shared helper: runs a command, capturing up to `limit` bytes of stdout.
// Returns NULL if the command could not be started.
char* capture_run_command(const char* cmd, size_t limit);
// Shared helper: true if any of the colon-separated PATH dirs holds `name`.
bool capture_have_binary(const char* name);
+28
View File
@@ -0,0 +1,28 @@
// Shared audio vocabulary.
//
// Everything downstream of a capture backend speaks one format: interleaved
// signed 16-bit little-endian PCM. Backends convert on the way in, sinks
// convert on the way out. Keeping a single internal format means the DSP and
// fan-out code never branch on sample type.
#pragma once
#include <stdint.h>
#define AUDIO_MAX_CHANNELS 2
#define AUDIO_DEFAULT_RATE 48000
#define AUDIO_DEFAULT_CHANNELS 2
// Frames per capture block. At 48 kHz this is ~10.7 ms, which keeps
// visualisation latency low while staying large enough that per-block
// overhead (syscalls, UDP headers, FFT setup) stays negligible.
#define AUDIO_BLOCK_FRAMES 512
typedef struct {
int rate; // samples per second
int channels; // 1 or 2
} audio_format_t;
static inline int audio_frame_bytes(const audio_format_t* f)
{
return (int)sizeof(int16_t) * f->channels;
}
+876
View File
@@ -0,0 +1,876 @@
#include "json.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
typedef struct {
const char* p;
int depth;
} parser_t;
#define MAX_DEPTH 32
static json_value_t* parse_value(parser_t* ps);
static void skip_ws(parser_t* ps)
{
while (*ps->p == ' ' || *ps->p == '\t' || *ps->p == '\n' || *ps->p == '\r')
ps->p++;
}
static json_value_t* alloc_value(json_type_t type)
{
json_value_t* v = calloc(1, sizeof(*v));
if (v)
v->type = type;
return v;
}
static int hex_nibble(char c)
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
// Encodes a code point as UTF-8 into `out`, returning the byte count.
static size_t utf8_encode(unsigned cp, char* out)
{
if (cp < 0x80) {
out[0] = (char)cp;
return 1;
}
if (cp < 0x800) {
out[0] = (char)(0xC0 | (cp >> 6));
out[1] = (char)(0x80 | (cp & 0x3F));
return 2;
}
if (cp < 0x10000) {
out[0] = (char)(0xE0 | (cp >> 12));
out[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[2] = (char)(0x80 | (cp & 0x3F));
return 3;
}
out[0] = (char)(0xF0 | (cp >> 18));
out[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
out[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[3] = (char)(0x80 | (cp & 0x3F));
return 4;
}
// Parses a quoted string starting at ps->p (which must point at the opening
// quote). Returns a malloc'd NUL-terminated string.
static char* parse_string_raw(parser_t* ps)
{
if (*ps->p != '"')
return NULL;
ps->p++;
size_t cap = 32, len = 0;
char* out = malloc(cap);
if (!out)
return NULL;
while (*ps->p && *ps->p != '"') {
// Worst case one escape expands to 4 UTF-8 bytes.
if (len + 5 > cap) {
cap *= 2;
char* grown = realloc(out, cap);
if (!grown) {
free(out);
return NULL;
}
out = grown;
}
if (*ps->p != '\\') {
out[len++] = *ps->p++;
continue;
}
ps->p++;
char esc = *ps->p++;
switch (esc) {
case '"':
out[len++] = '"';
break;
case '\\':
out[len++] = '\\';
break;
case '/':
out[len++] = '/';
break;
case 'b':
out[len++] = '\b';
break;
case 'f':
out[len++] = '\f';
break;
case 'n':
out[len++] = '\n';
break;
case 'r':
out[len++] = '\r';
break;
case 't':
out[len++] = '\t';
break;
case 'u': {
unsigned cp = 0;
for (int i = 0; i < 4; i++) {
int nib = hex_nibble(ps->p[i]);
if (nib < 0) {
free(out);
return NULL;
}
cp = (cp << 4) | (unsigned)nib;
}
ps->p += 4;
// Combine surrogate pairs so astral characters survive round-trip.
if (cp >= 0xD800 && cp <= 0xDBFF && ps->p[0] == '\\' && ps->p[1] == 'u') {
unsigned lo = 0;
bool ok = true;
for (int i = 0; i < 4; i++) {
int nib = hex_nibble(ps->p[2 + i]);
if (nib < 0) {
ok = false;
break;
}
lo = (lo << 4) | (unsigned)nib;
}
if (ok && lo >= 0xDC00 && lo <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
ps->p += 6;
}
}
len += utf8_encode(cp, out + len);
break;
}
default:
free(out);
return NULL;
}
}
if (*ps->p != '"') {
free(out);
return NULL;
}
ps->p++;
out[len] = '\0';
return out;
}
static json_value_t* parse_array(parser_t* ps)
{
ps->p++; // consume '['
json_value_t* v = alloc_value(JSON_ARRAY);
if (!v)
return NULL;
skip_ws(ps);
if (*ps->p == ']') {
ps->p++;
return v;
}
size_t cap = 8;
v->u.array.items = malloc(cap * sizeof(json_value_t*));
if (!v->u.array.items) {
json_free(v);
return NULL;
}
for (;;) {
json_value_t* item = parse_value(ps);
if (!item) {
json_free(v);
return NULL;
}
if (v->u.array.count == cap) {
cap *= 2;
json_value_t** grown = realloc(v->u.array.items, cap * sizeof(json_value_t*));
if (!grown) {
json_free(item);
json_free(v);
return NULL;
}
v->u.array.items = grown;
}
v->u.array.items[v->u.array.count++] = item;
skip_ws(ps);
if (*ps->p == ',') {
ps->p++;
skip_ws(ps);
continue;
}
if (*ps->p == ']') {
ps->p++;
return v;
}
json_free(v);
return NULL;
}
}
static json_value_t* parse_object(parser_t* ps)
{
ps->p++; // consume '{'
json_value_t* v = alloc_value(JSON_OBJECT);
if (!v)
return NULL;
skip_ws(ps);
if (*ps->p == '}') {
ps->p++;
return v;
}
size_t cap = 8;
v->u.object.keys = malloc(cap * sizeof(char*));
v->u.object.values = malloc(cap * sizeof(json_value_t*));
if (!v->u.object.keys || !v->u.object.values) {
json_free(v);
return NULL;
}
for (;;) {
skip_ws(ps);
char* key = parse_string_raw(ps);
if (!key) {
json_free(v);
return NULL;
}
skip_ws(ps);
if (*ps->p != ':') {
free(key);
json_free(v);
return NULL;
}
ps->p++;
json_value_t* val = parse_value(ps);
if (!val) {
free(key);
json_free(v);
return NULL;
}
if (v->u.object.count == cap) {
cap *= 2;
char** gk = realloc(v->u.object.keys, cap * sizeof(char*));
json_value_t** gv = realloc(v->u.object.values, cap * sizeof(json_value_t*));
if (gk)
v->u.object.keys = gk;
if (gv)
v->u.object.values = gv;
if (!gk || !gv) {
free(key);
json_free(val);
json_free(v);
return NULL;
}
}
v->u.object.keys[v->u.object.count] = key;
v->u.object.values[v->u.object.count] = val;
v->u.object.count++;
skip_ws(ps);
if (*ps->p == ',') {
ps->p++;
continue;
}
if (*ps->p == '}') {
ps->p++;
return v;
}
json_free(v);
return NULL;
}
}
static json_value_t* parse_value(parser_t* ps)
{
if (++ps->depth > MAX_DEPTH) {
ps->depth--;
return NULL;
}
skip_ws(ps);
json_value_t* v = NULL;
switch (*ps->p) {
case '{':
v = parse_object(ps);
break;
case '[':
v = parse_array(ps);
break;
case '"': {
char* s = parse_string_raw(ps);
if (s) {
v = alloc_value(JSON_STRING);
if (v)
v->u.string = s;
else
free(s);
}
break;
}
case 't':
if (strncmp(ps->p, "true", 4) == 0) {
ps->p += 4;
v = alloc_value(JSON_BOOL);
if (v)
v->u.boolean = true;
}
break;
case 'f':
if (strncmp(ps->p, "false", 5) == 0) {
ps->p += 5;
v = alloc_value(JSON_BOOL);
if (v)
v->u.boolean = false;
}
break;
case 'n':
if (strncmp(ps->p, "null", 4) == 0) {
ps->p += 4;
v = alloc_value(JSON_NULL);
}
break;
default: {
char* end = NULL;
double d = strtod(ps->p, &end);
if (end && end != ps->p) {
ps->p = end;
v = alloc_value(JSON_NUMBER);
if (v)
v->u.number = d;
}
break;
}
}
ps->depth--;
return v;
}
json_value_t* json_parse(const char* text)
{
if (!text)
return NULL;
parser_t ps = { .p = text, .depth = 0 };
json_value_t* v = parse_value(&ps);
if (!v)
return NULL;
skip_ws(&ps);
if (*ps.p != '\0') {
json_free(v);
return NULL;
}
return v;
}
void json_free(json_value_t* v)
{
if (!v)
return;
switch (v->type) {
case JSON_STRING:
free(v->u.string);
break;
case JSON_ARRAY:
for (size_t i = 0; i < v->u.array.count; i++)
json_free(v->u.array.items[i]);
free(v->u.array.items);
break;
case JSON_OBJECT:
for (size_t i = 0; i < v->u.object.count; i++) {
free(v->u.object.keys[i]);
json_free(v->u.object.values[i]);
}
free(v->u.object.keys);
free(v->u.object.values);
break;
default:
break;
}
free(v);
}
// ---------------------------------------------------------------------------
// Accessors
// ---------------------------------------------------------------------------
const json_value_t* json_get(const json_value_t* obj, const char* key)
{
if (!obj || obj->type != JSON_OBJECT || !key)
return NULL;
for (size_t i = 0; i < obj->u.object.count; i++) {
if (strcmp(obj->u.object.keys[i], key) == 0)
return obj->u.object.values[i];
}
return NULL;
}
const char* json_str(const json_value_t* obj, const char* key, const char* def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_STRING) ? v->u.string : def;
}
double json_num(const json_value_t* obj, const char* key, double def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_NUMBER) ? v->u.number : def;
}
int json_int(const json_value_t* obj, const char* key, int def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_NUMBER) ? (int)v->u.number : def;
}
bool json_bool(const json_value_t* obj, const char* key, bool def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_BOOL) ? v->u.boolean : def;
}
const json_value_t* json_at(const json_value_t* arr, size_t index)
{
if (!arr || arr->type != JSON_ARRAY || index >= arr->u.array.count)
return NULL;
return arr->u.array.items[index];
}
size_t json_len(const json_value_t* arr)
{
if (!arr || arr->type != JSON_ARRAY)
return 0;
return arr->u.array.count;
}
// ---------------------------------------------------------------------------
// Clone and merge
// ---------------------------------------------------------------------------
// Appends `key`/`val` to an object, taking ownership of both. Returns false
// (having freed nothing) if the object could not grow.
static bool object_append(json_value_t* obj, char* key, json_value_t* val)
{
size_t n = obj->u.object.count;
char** gk = realloc(obj->u.object.keys, (n + 1) * sizeof(char*));
if (gk)
obj->u.object.keys = gk;
json_value_t** gv = realloc(obj->u.object.values, (n + 1) * sizeof(json_value_t*));
if (gv)
obj->u.object.values = gv;
if (!gk || !gv)
return false;
obj->u.object.keys[n] = key;
obj->u.object.values[n] = val;
obj->u.object.count = n + 1;
return true;
}
json_value_t* json_clone(const json_value_t* v)
{
if (!v)
return NULL;
json_value_t* out = alloc_value(v->type);
if (!out)
return NULL;
switch (v->type) {
case JSON_BOOL:
out->u.boolean = v->u.boolean;
break;
case JSON_NUMBER:
out->u.number = v->u.number;
break;
case JSON_STRING:
out->u.string = strdup(v->u.string ? v->u.string : "");
if (!out->u.string) {
free(out);
return NULL;
}
break;
case JSON_ARRAY:
if (v->u.array.count) {
out->u.array.items = calloc(v->u.array.count, sizeof(json_value_t*));
if (!out->u.array.items) {
free(out);
return NULL;
}
for (size_t i = 0; i < v->u.array.count; i++) {
out->u.array.items[i] = json_clone(v->u.array.items[i]);
out->u.array.count = i + 1;
if (!out->u.array.items[i]) {
json_free(out);
return NULL;
}
}
}
break;
case JSON_OBJECT:
for (size_t i = 0; i < v->u.object.count; i++) {
char* key = strdup(v->u.object.keys[i]);
json_value_t* val = json_clone(v->u.object.values[i]);
if (!key || !val || !object_append(out, key, val)) {
free(key);
json_free(val);
json_free(out);
return NULL;
}
}
break;
default:
break;
}
return out;
}
json_value_t* json_merge(const json_value_t* base, const json_value_t* patch)
{
if (!patch)
return json_clone(base);
if (!base || base->type != JSON_OBJECT || patch->type != JSON_OBJECT)
return json_clone(patch);
json_value_t* out = alloc_value(JSON_OBJECT);
if (!out)
return NULL;
// Base keys first, so the on-disk field order stays stable across saves.
for (size_t i = 0; i < base->u.object.count; i++) {
const char* k = base->u.object.keys[i];
const json_value_t* pv = json_get(patch, k);
char* key = strdup(k);
json_value_t* val = pv ? json_merge(base->u.object.values[i], pv)
: json_clone(base->u.object.values[i]);
if (!key || !val || !object_append(out, key, val)) {
free(key);
json_free(val);
json_free(out);
return NULL;
}
}
// Then anything the patch introduced.
for (size_t i = 0; i < patch->u.object.count; i++) {
const char* k = patch->u.object.keys[i];
if (json_get(base, k))
continue;
char* key = strdup(k);
json_value_t* val = json_clone(patch->u.object.values[i]);
if (!key || !val || !object_append(out, key, val)) {
free(key);
json_free(val);
json_free(out);
return NULL;
}
}
return out;
}
// ---------------------------------------------------------------------------
// Writer
// ---------------------------------------------------------------------------
static void jw_reserve(json_writer_t* w, size_t extra)
{
if (w->failed)
return;
if (w->len + extra + 1 <= w->cap)
return;
size_t cap = w->cap ? w->cap : 256;
while (cap < w->len + extra + 1)
cap *= 2;
char* grown = realloc(w->buf, cap);
if (!grown) {
w->failed = true;
return;
}
w->buf = grown;
w->cap = cap;
}
static void jw_raw(json_writer_t* w, const char* s)
{
size_t n = strlen(s);
jw_reserve(w, n);
if (w->failed)
return;
memcpy(w->buf + w->len, s, n);
w->len += n;
w->buf[w->len] = '\0';
}
static void jw_raw_escaped(json_writer_t* w, const char* s)
{
jw_reserve(w, strlen(s) * 6 + 2);
if (w->failed)
return;
char* p = w->buf + w->len;
*p++ = '"';
for (const unsigned char* c = (const unsigned char*)s; *c; c++) {
switch (*c) {
case '"':
*p++ = '\\';
*p++ = '"';
break;
case '\\':
*p++ = '\\';
*p++ = '\\';
break;
case '\n':
*p++ = '\\';
*p++ = 'n';
break;
case '\r':
*p++ = '\\';
*p++ = 'r';
break;
case '\t':
*p++ = '\\';
*p++ = 't';
break;
case '\b':
*p++ = '\\';
*p++ = 'b';
break;
case '\f':
*p++ = '\\';
*p++ = 'f';
break;
default:
if (*c < 0x20) {
p += sprintf(p, "\\u%04x", *c);
} else {
*p++ = (char)*c;
}
}
}
*p++ = '"';
w->len = (size_t)(p - w->buf);
w->buf[w->len] = '\0';
}
static void jw_newline(json_writer_t* w, int depth)
{
jw_reserve(w, (size_t)depth * 2 + 1);
if (w->failed)
return;
w->buf[w->len++] = '\n';
for (int i = 0; i < depth * 2; i++)
w->buf[w->len++] = ' ';
w->buf[w->len] = '\0';
}
// Emits the comma + key prefix for the next member at the current depth.
static void jw_prefix(json_writer_t* w, const char* key)
{
if (w->depth > 0 && w->depth <= (int)(sizeof(w->need_comma) / sizeof(w->need_comma[0]))) {
if (w->need_comma[w->depth - 1])
jw_raw(w, ",");
w->need_comma[w->depth - 1] = true;
if (w->pretty)
jw_newline(w, w->depth);
}
if (key) {
jw_raw_escaped(w, key);
jw_raw(w, w->pretty ? ": " : ":");
}
}
// True if the container we are about to close received at least one member.
static bool jw_container_used(const json_writer_t* w)
{
return w->depth > 0 && w->depth <= (int)(sizeof(w->need_comma) / sizeof(w->need_comma[0]))
&& w->need_comma[w->depth - 1];
}
static void jw_push(json_writer_t* w)
{
if (w->depth < (int)(sizeof(w->need_comma) / sizeof(w->need_comma[0])))
w->need_comma[w->depth] = false;
w->depth++;
}
static void jw_pop(json_writer_t* w)
{
if (w->depth > 0)
w->depth--;
}
void jw_init(json_writer_t* w)
{
memset(w, 0, sizeof(*w));
}
void jw_free(json_writer_t* w)
{
free(w->buf);
memset(w, 0, sizeof(*w));
}
char* jw_take(json_writer_t* w)
{
if (w->failed) {
jw_free(w);
return NULL;
}
char* out = w->buf;
if (!out) {
out = strdup("");
}
memset(w, 0, sizeof(*w));
return out;
}
void jw_obj_open(json_writer_t* w, const char* key)
{
jw_prefix(w, key);
jw_raw(w, "{");
jw_push(w);
}
void jw_obj_close(json_writer_t* w)
{
bool used = jw_container_used(w);
jw_pop(w);
if (w->pretty && used)
jw_newline(w, w->depth);
jw_raw(w, "}");
}
void jw_arr_open(json_writer_t* w, const char* key)
{
jw_prefix(w, key);
jw_raw(w, "[");
jw_push(w);
}
void jw_arr_close(json_writer_t* w)
{
bool used = jw_container_used(w);
jw_pop(w);
if (w->pretty && used)
jw_newline(w, w->depth);
jw_raw(w, "]");
}
void jw_str(json_writer_t* w, const char* key, const char* value)
{
jw_prefix(w, key);
if (value)
jw_raw_escaped(w, value);
else
jw_raw(w, "null");
}
void jw_num(json_writer_t* w, const char* key, double value)
{
jw_prefix(w, key);
char tmp[40];
// %.6g keeps float levels compact; they are display values, not data.
snprintf(tmp, sizeof(tmp), "%.6g", value);
jw_raw(w, tmp);
}
void jw_int(json_writer_t* w, const char* key, long long value)
{
jw_prefix(w, key);
char tmp[32];
snprintf(tmp, sizeof(tmp), "%lld", value);
jw_raw(w, tmp);
}
void jw_bool(json_writer_t* w, const char* key, bool value)
{
jw_prefix(w, key);
jw_raw(w, value ? "true" : "false");
}
void jw_null(json_writer_t* w, const char* key)
{
jw_prefix(w, key);
jw_raw(w, "null");
}
void jw_value(json_writer_t* w, const char* key, const json_value_t* v)
{
if (!v) {
jw_null(w, key);
return;
}
switch (v->type) {
case JSON_NULL:
jw_null(w, key);
break;
case JSON_BOOL:
jw_bool(w, key, v->u.boolean);
break;
case JSON_NUMBER:
jw_prefix(w, key);
{
char tmp[40];
// Integral values must not round-trip as "1.0", or a reparse would
// still be a number but the UI would render it oddly.
if (v->u.number == (double)(long long)v->u.number)
snprintf(tmp, sizeof(tmp), "%lld", (long long)v->u.number);
else
snprintf(tmp, sizeof(tmp), "%.17g", v->u.number);
jw_raw(w, tmp);
}
break;
case JSON_STRING:
jw_str(w, key, v->u.string);
break;
case JSON_ARRAY:
jw_arr_open(w, key);
for (size_t i = 0; i < v->u.array.count; i++)
jw_value(w, NULL, v->u.array.items[i]);
jw_arr_close(w);
break;
case JSON_OBJECT:
jw_obj_open(w, key);
for (size_t i = 0; i < v->u.object.count; i++)
jw_value(w, v->u.object.keys[i], v->u.object.values[i]);
jw_obj_close(w);
break;
}
}
char* json_serialize(const json_value_t* v, bool pretty)
{
json_writer_t w;
jw_init(&w);
w.pretty = pretty;
jw_value(&w, NULL, v);
return jw_take(&w);
}
char* json_escape(const char* s)
{
json_writer_t w;
jw_init(&w);
jw_raw_escaped(&w, s ? s : "");
return jw_take(&w);
}
+102
View File
@@ -0,0 +1,102 @@
// Small dependency-free JSON reader/writer.
//
// The webOS SDK ships pbnjson, but pulling it in drags glib schema plumbing
// into every translation unit for what amounts to reading a dozen config keys
// and building small Luna replies. This is deliberately minimal: no schema
// validation, no streaming, no number formatting beyond %.17g / %lld.
#pragma once
#include <stdbool.h>
#include <stddef.h>
typedef enum {
JSON_NULL,
JSON_BOOL,
JSON_NUMBER,
JSON_STRING,
JSON_ARRAY,
JSON_OBJECT,
} json_type_t;
typedef struct json_value json_value_t;
struct json_value {
json_type_t type;
union {
bool boolean;
double number;
char* string;
struct {
json_value_t** items;
size_t count;
} array;
struct {
char** keys;
json_value_t** values;
size_t count;
} object;
} u;
};
// Returns NULL on malformed input. Trailing whitespace is allowed.
json_value_t* json_parse(const char* text);
void json_free(json_value_t* v);
// Object/array accessors. All tolerate NULL and wrong types by returning the
// default, so callers can chain without checking every step.
const json_value_t* json_get(const json_value_t* obj, const char* key);
const char* json_str(const json_value_t* obj, const char* key, const char* def);
double json_num(const json_value_t* obj, const char* key, double def);
int json_int(const json_value_t* obj, const char* key, int def);
bool json_bool(const json_value_t* obj, const char* key, bool def);
const json_value_t* json_at(const json_value_t* arr, size_t index);
size_t json_len(const json_value_t* arr);
// Deep copy. Returns NULL if `v` is NULL or allocation fails.
json_value_t* json_clone(const json_value_t* v);
// Recursive merge: keys present in `patch` win, except where both sides hold
// an object, in which case the objects are merged member by member. Used so
// the UI can send just the settings it changed. Returns a new value; both
// inputs are left untouched.
json_value_t* json_merge(const json_value_t* base, const json_value_t* patch);
// ---------------------------------------------------------------------------
// Writer: append-only string builder that tracks comma placement per nesting
// level so callers never write separators by hand.
// ---------------------------------------------------------------------------
typedef struct {
char* buf;
size_t len;
size_t cap;
int depth;
bool need_comma[32];
bool failed;
bool pretty; // set after jw_init for indented output (config files)
} json_writer_t;
void jw_init(json_writer_t* w);
void jw_free(json_writer_t* w);
// Hands ownership of the finished buffer to the caller and resets the writer.
char* jw_take(json_writer_t* w);
void jw_obj_open(json_writer_t* w, const char* key);
void jw_obj_close(json_writer_t* w);
void jw_arr_open(json_writer_t* w, const char* key);
void jw_arr_close(json_writer_t* w);
void jw_str(json_writer_t* w, const char* key, const char* value);
void jw_num(json_writer_t* w, const char* key, double value);
void jw_int(json_writer_t* w, const char* key, long long value);
void jw_bool(json_writer_t* w, const char* key, bool value);
void jw_null(json_writer_t* w, const char* key);
// Writes an existing DOM value verbatim (objects and arrays included).
void jw_value(json_writer_t* w, const char* key, const json_value_t* v);
// Renders `v` as JSON text. Caller frees.
char* json_serialize(const json_value_t* v, bool pretty);
// Escapes `s` into a JSON string literal (including surrounding quotes).
// Caller frees.
char* json_escape(const char* s);
+113
View File
@@ -0,0 +1,113 @@
#include "log.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <time.h>
#define RING_LINES 200
#define RING_LINE_LEN 256
static log_level_t s_level = LOG_INFO;
static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER;
static char s_ring[RING_LINES][RING_LINE_LEN];
static int s_head = 0; // next slot to write
static int s_count = 0;
static const char* level_name(log_level_t l)
{
switch (l) {
case LOG_ERROR:
return "ERROR";
case LOG_WARN:
return "WARN";
case LOG_INFO:
return "INFO";
default:
return "DEBUG";
}
}
void log_init(log_level_t level)
{
s_level = level;
setvbuf(stderr, NULL, _IOLBF, 0);
}
void log_set_level(log_level_t level) { s_level = level; }
log_level_t log_get_level(void) { return s_level; }
void log_printf(log_level_t level, const char* file, int line, const char* fmt, ...)
{
if (level > s_level)
return;
const char* base = strrchr(file, '/');
base = base ? base + 1 : file;
struct timeval tv;
gettimeofday(&tv, NULL);
struct tm tm;
localtime_r(&tv.tv_sec, &tm);
char stamp[32];
snprintf(stamp, sizeof(stamp), "%02d:%02d:%02d.%03d", tm.tm_hour, tm.tm_min,
tm.tm_sec, (int)(tv.tv_usec / 1000));
char body[RING_LINE_LEN];
va_list ap;
va_start(ap, fmt);
vsnprintf(body, sizeof(body), fmt, ap);
va_end(ap);
char line_buf[RING_LINE_LEN];
snprintf(line_buf, sizeof(line_buf), "%s [%-5s] %s:%d %s", stamp,
level_name(level), base, line, body);
fprintf(stderr, "%s\n", line_buf);
pthread_mutex_lock(&s_lock);
memcpy(s_ring[s_head], line_buf, sizeof(line_buf));
s_head = (s_head + 1) % RING_LINES;
if (s_count < RING_LINES)
s_count++;
pthread_mutex_unlock(&s_lock);
}
char* log_dump_recent(void)
{
pthread_mutex_lock(&s_lock);
size_t cap = (size_t)s_count * RING_LINE_LEN + 1;
char* out = malloc(cap);
if (!out) {
pthread_mutex_unlock(&s_lock);
char* empty = malloc(1);
if (empty)
empty[0] = '\0';
return empty;
}
size_t used = 0;
int start = (s_head - s_count + RING_LINES) % RING_LINES;
for (int i = 0; i < s_count; i++) {
const char* src = s_ring[(start + i) % RING_LINES];
size_t len = strlen(src);
if (used + len + 2 > cap)
break;
memcpy(out + used, src, len);
used += len;
out[used++] = '\n';
}
out[used] = '\0';
pthread_mutex_unlock(&s_lock);
return out;
}
void log_clear_recent(void)
{
pthread_mutex_lock(&s_lock);
s_head = 0;
s_count = 0;
pthread_mutex_unlock(&s_lock);
}
+31
View File
@@ -0,0 +1,31 @@
// Minimal leveled logger. Writes to stderr (captured by the webOS service
// launcher) and optionally to a ring of recent lines that the UI can fetch
// over Luna, so users can debug a TV they cannot SSH into.
#pragma once
#include <stdarg.h>
#include <stddef.h>
typedef enum {
LOG_ERROR = 0,
LOG_WARN = 1,
LOG_INFO = 2,
LOG_DEBUG = 3,
} log_level_t;
void log_init(log_level_t level);
void log_set_level(log_level_t level);
log_level_t log_get_level(void);
void log_printf(log_level_t level, const char* file, int line, const char* fmt, ...)
__attribute__((format(printf, 4, 5)));
// Copies the most recent log lines (oldest first) into a newly allocated
// NUL-terminated string. Caller frees. Never returns NULL.
char* log_dump_recent(void);
void log_clear_recent(void);
#define ERR(...) log_printf(LOG_ERROR, __FILE__, __LINE__, __VA_ARGS__)
#define WARN(...) log_printf(LOG_WARN, __FILE__, __LINE__, __VA_ARGS__)
#define INFO(...) log_printf(LOG_INFO, __FILE__, __LINE__, __VA_ARGS__)
#define DBG(...) log_printf(LOG_DEBUG, __FILE__, __LINE__, __VA_ARGS__)
+145
View File
@@ -0,0 +1,145 @@
#include "ringbuf.h"
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <time.h>
bool ringbuf_init(ringbuf_t* rb, size_t capacity)
{
memset(rb, 0, sizeof(*rb));
rb->data = malloc(capacity);
if (!rb->data)
return false;
rb->cap = capacity;
pthread_mutex_init(&rb->lock, NULL);
pthread_cond_init(&rb->readable, NULL);
return true;
}
void ringbuf_destroy(ringbuf_t* rb)
{
if (!rb->data)
return;
pthread_mutex_destroy(&rb->lock);
pthread_cond_destroy(&rb->readable);
free(rb->data);
rb->data = NULL;
rb->cap = 0;
}
static void discard_locked(ringbuf_t* rb, size_t n)
{
if (n > rb->used)
n = rb->used;
rb->tail = (rb->tail + n) % rb->cap;
rb->used -= n;
rb->dropped_bytes += n;
}
size_t ringbuf_write(ringbuf_t* rb, const void* src, size_t len)
{
if (len == 0)
return 0;
pthread_mutex_lock(&rb->lock);
size_t dropped = 0;
// A write larger than the whole buffer can only keep its tail end.
if (len >= rb->cap) {
dropped = rb->used + (len - rb->cap);
rb->dropped_bytes += dropped;
src = (const unsigned char*)src + (len - rb->cap);
len = rb->cap;
rb->head = rb->tail = rb->used = 0;
} else if (rb->used + len > rb->cap) {
size_t need = rb->used + len - rb->cap;
discard_locked(rb, need);
dropped = need;
}
size_t first = rb->cap - rb->head;
if (first > len)
first = len;
memcpy(rb->data + rb->head, src, first);
if (len > first)
memcpy(rb->data, (const unsigned char*)src + first, len - first);
rb->head = (rb->head + len) % rb->cap;
rb->used += len;
pthread_cond_signal(&rb->readable);
pthread_mutex_unlock(&rb->lock);
return dropped;
}
size_t ringbuf_read(ringbuf_t* rb, void* dst, size_t len, int timeout_ms)
{
pthread_mutex_lock(&rb->lock);
while (rb->used == 0 && !rb->closed) {
if (timeout_ms < 0) {
pthread_cond_wait(&rb->readable, &rb->lock);
continue;
}
struct timeval now;
gettimeofday(&now, NULL);
struct timespec deadline;
deadline.tv_sec = now.tv_sec + timeout_ms / 1000;
deadline.tv_nsec = now.tv_usec * 1000L + (long)(timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) {
deadline.tv_sec++;
deadline.tv_nsec -= 1000000000L;
}
if (pthread_cond_timedwait(&rb->readable, &rb->lock, &deadline) == ETIMEDOUT)
break;
}
size_t n = rb->used < len ? rb->used : len;
if (n > 0) {
size_t first = rb->cap - rb->tail;
if (first > n)
first = n;
memcpy(dst, rb->data + rb->tail, first);
if (n > first)
memcpy((unsigned char*)dst + first, rb->data, n - first);
rb->tail = (rb->tail + n) % rb->cap;
rb->used -= n;
}
pthread_mutex_unlock(&rb->lock);
return n;
}
void ringbuf_close(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
rb->closed = true;
pthread_cond_broadcast(&rb->readable);
pthread_mutex_unlock(&rb->lock);
}
void ringbuf_reset(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
rb->head = rb->tail = rb->used = 0;
rb->closed = false;
pthread_mutex_unlock(&rb->lock);
}
size_t ringbuf_used(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
size_t n = rb->used;
pthread_mutex_unlock(&rb->lock);
return n;
}
unsigned long long ringbuf_dropped(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
unsigned long long n = rb->dropped_bytes;
pthread_mutex_unlock(&rb->lock);
return n;
}
+40
View File
@@ -0,0 +1,40 @@
// Byte ring buffer for one producer and one consumer, guarded by a mutex.
//
// Used to decouple the capture thread from sinks that can block (TCP, HTTP).
// On overflow the oldest bytes are dropped rather than stalling the producer:
// for a live audio stream, falling behind should cost you a glitch, not
// backpressure into the capture device.
#pragma once
#include <pthread.h>
#include <stdbool.h>
#include <stddef.h>
typedef struct {
unsigned char* data;
size_t cap;
size_t head; // write offset
size_t tail; // read offset
size_t used;
unsigned long long dropped_bytes;
bool closed;
pthread_mutex_t lock;
pthread_cond_t readable;
} ringbuf_t;
bool ringbuf_init(ringbuf_t* rb, size_t capacity);
void ringbuf_destroy(ringbuf_t* rb);
// Always accepts the whole write, discarding oldest data if needed.
// Returns the number of bytes dropped to make room.
size_t ringbuf_write(ringbuf_t* rb, const void* src, size_t len);
// Blocks until at least one byte is available, the buffer is closed, or
// `timeout_ms` elapses. Returns bytes read (0 on timeout or close).
size_t ringbuf_read(ringbuf_t* rb, void* dst, size_t len, int timeout_ms);
// Wakes any blocked reader and makes subsequent reads return 0.
void ringbuf_close(ringbuf_t* rb);
void ringbuf_reset(ringbuf_t* rb);
size_t ringbuf_used(ringbuf_t* rb);
unsigned long long ringbuf_dropped(ringbuf_t* rb);
+284
View File
@@ -0,0 +1,284 @@
#include "config.h"
#include "common/log.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
// Homebrew services keep their state under /var/lib/webosbrew, which survives
// app upgrades (the app directory does not). The fallbacks matter mostly for
// running this on a desktop while developing.
#define PRIMARY_DIR "/var/lib/webosbrew/audiocap"
#define PRIMARY_PATH PRIMARY_DIR "/config.json"
#define FALLBACK_PATH "/tmp/audiocap-config.json"
static const char* DEFAULTS_JSON =
"{"
" \"autoStart\": false,"
" \"logLevel\": \"info\","
" \"capture\": {"
" \"backend\": \"auto\","
" \"device\": \"\","
" \"server\": \"\","
" \"command\": \"\","
" \"rate\": 48000,"
" \"channels\": 2"
" },"
" \"dsp\": { \"attack\": 0.6, \"release\": 0.12 },"
" \"sinks\": [\"hyperhdr\"],"
" \"hyperhdr\": {"
" \"host\": \"\","
" \"port\": 5004,"
" \"multicast\": false,"
" \"multicastTtl\": 4,"
" \"sapAnnounce\": true"
" },"
" \"hyperhdrViz\": {"
" \"host\": \"\","
" \"port\": 19400,"
" \"priority\": 150,"
" \"width\": 64,"
" \"height\": 36,"
" \"fps\": 30,"
" \"mode\": \"spectrum\","
" \"saturation\": 1.0,"
" \"minBrightness\": 0.02"
" },"
" \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 },"
" \"tcp\": { \"port\": 4011, \"maxClients\": 4 },"
" \"http\": { \"port\": 4012, \"maxClients\": 4 }"
"}";
struct config {
json_value_t* root;
char path[256];
bool persisted; // false when we fell back to a volatile location
};
json_value_t* config_defaults(void)
{
return json_parse(DEFAULTS_JSON);
}
// mkdir -p, ignoring components that already exist.
static bool make_dirs(const char* dir)
{
char tmp[256];
size_t n = strlen(dir);
if (n == 0 || n >= sizeof(tmp))
return false;
memcpy(tmp, dir, n + 1);
for (char* p = tmp + 1; *p; p++) {
if (*p != '/')
continue;
*p = '\0';
if (mkdir(tmp, 0755) != 0 && errno != EEXIST)
return false;
*p = '/';
}
return mkdir(tmp, 0755) == 0 || errno == EEXIST;
}
static bool dir_writable(const char* dir)
{
return access(dir, W_OK | X_OK) == 0;
}
// Picks where to store settings, creating the directory if we can.
static void choose_path(config_t* c)
{
const char* env = getenv("AUDIOCAP_CONFIG");
if (env && *env) {
snprintf(c->path, sizeof(c->path), "%s", env);
c->persisted = true;
return;
}
if (make_dirs(PRIMARY_DIR) && dir_writable(PRIMARY_DIR)) {
snprintf(c->path, sizeof(c->path), "%s", PRIMARY_PATH);
c->persisted = true;
return;
}
WARN("%s is not writable; settings will not survive a reboot", PRIMARY_DIR);
snprintf(c->path, sizeof(c->path), "%s", FALLBACK_PATH);
c->persisted = false;
}
static char* read_file(const char* path)
{
FILE* f = fopen(path, "rb");
if (!f)
return NULL;
if (fseek(f, 0, SEEK_END) != 0) {
fclose(f);
return NULL;
}
long size = ftell(f);
// A settings file this large is corrupt, not something to load.
if (size < 0 || size > 1 << 20) {
fclose(f);
return NULL;
}
rewind(f);
char* buf = malloc((size_t)size + 1);
if (!buf) {
fclose(f);
return NULL;
}
size_t got = fread(buf, 1, (size_t)size, f);
fclose(f);
buf[got] = '\0';
return buf;
}
// Writes to a temporary file and renames, so an interrupted save cannot leave
// a half-written config that fails to parse on next boot.
static bool write_atomic(const char* path, const char* text, char* err, size_t errlen)
{
char tmp[300];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE* f = fopen(tmp, "wb");
if (!f) {
snprintf(err, errlen, "cannot open %s: %s", tmp, strerror(errno));
return false;
}
size_t len = strlen(text);
bool ok = fwrite(text, 1, len, f) == len && fputc('\n', f) != EOF;
if (ok)
ok = fflush(f) == 0;
if (ok) {
int fd = fileno(f);
if (fd >= 0)
fsync(fd);
}
if (fclose(f) != 0)
ok = false;
if (!ok) {
snprintf(err, errlen, "cannot write %s: %s", tmp, strerror(errno));
unlink(tmp);
return false;
}
if (rename(tmp, path) != 0) {
snprintf(err, errlen, "cannot replace %s: %s", path, strerror(errno));
unlink(tmp);
return false;
}
return true;
}
config_t* config_load(void)
{
config_t* c = calloc(1, sizeof(*c));
if (!c)
return NULL;
choose_path(c);
json_value_t* defaults = config_defaults();
if (!defaults) {
// Only reachable if DEFAULTS_JSON above is malformed.
ERR("built-in defaults failed to parse");
free(c);
return NULL;
}
char* text = read_file(c->path);
if (!text) {
INFO("No settings at %s; using defaults", c->path);
c->root = defaults;
return c;
}
json_value_t* stored = json_parse(text);
free(text);
if (!stored) {
WARN("Settings at %s are not valid JSON; using defaults", c->path);
c->root = defaults;
return c;
}
c->root = json_merge(defaults, stored);
json_free(stored);
if (!c->root) {
c->root = defaults;
} else {
json_free(defaults);
INFO("Loaded settings from %s", c->path);
}
return c;
}
void config_free(config_t* c)
{
if (!c)
return;
json_free(c->root);
free(c);
}
const json_value_t* config_root(const config_t* c)
{
return c ? c->root : NULL;
}
const char* config_path(const config_t* c)
{
return c ? c->path : "";
}
bool config_is_persistent(const config_t* c)
{
return c ? c->persisted : false;
}
char* config_serialize(const config_t* c)
{
return c ? json_serialize(c->root, true) : NULL;
}
bool config_apply(config_t* c, const json_value_t* patch, char* err, size_t errlen)
{
if (!c) {
snprintf(err, errlen, "no config loaded");
return false;
}
if (!patch || patch->type != JSON_OBJECT) {
snprintf(err, errlen, "settings patch must be an object");
return false;
}
json_value_t* merged = json_merge(c->root, patch);
if (!merged) {
snprintf(err, errlen, "out of memory merging settings");
return false;
}
json_free(c->root);
c->root = merged;
char* text = json_serialize(c->root, true);
if (!text) {
snprintf(err, errlen, "out of memory serialising settings");
return false;
}
bool ok = write_atomic(c->path, text, err, errlen);
free(text);
if (ok)
DBG("Settings saved to %s", c->path);
else
WARN("Settings applied but not saved: %s", err);
return ok;
}
+37
View File
@@ -0,0 +1,37 @@
// Persistent settings.
//
// One JSON document, written pretty-printed so it stays editable over ssh on
// a rooted TV. The UI never sends the whole document back: setConfig takes a
// partial object which is deep-merged over the current one, so a new setting
// added in a later version does not get wiped by an older frontend.
#pragma once
#include "common/json.h"
#include <stdbool.h>
#include <stddef.h>
typedef struct config config_t;
// Never returns NULL: a missing or corrupt file falls back to defaults.
config_t* config_load(void);
void config_free(config_t* c);
// The merged document (defaults + whatever was on disk). Valid until the next
// config_apply().
const json_value_t* config_root(const config_t* c);
const char* config_path(const config_t* c);
// False when settings landed in /tmp because nothing writable was found; the
// UI surfaces this so "my settings vanished after a reboot" is explainable.
bool config_is_persistent(const config_t* c);
// Deep-merges `patch` and persists the result. The in-memory config is updated
// even if the write fails, so a read-only filesystem degrades to "settings
// work until reboot" rather than "settings do nothing".
bool config_apply(config_t* c, const json_value_t* patch, char* err, size_t errlen);
// Pretty JSON of the whole document. Caller frees.
char* config_serialize(const config_t* c);
// The defaults, for the UI's "reset" button. Caller frees.
json_value_t* config_defaults(void);
+270
View File
@@ -0,0 +1,270 @@
#include "dsp.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define DB_FLOOR (-90.0f)
#define BAND_DB_FLOOR (-70.0f) // band energy below this maps to 0
#define BAND_LOW_HZ 40.0f
#define BAND_HIGH_HZ 16000.0f
struct dsp {
audio_format_t fmt;
// Sliding mono window; blocks overlap by 50% so the spectrum updates every
// block instead of every other one.
float window_samples[DSP_FFT_SIZE];
int window_fill;
float hann[DSP_FFT_SIZE];
float re[DSP_FFT_SIZE];
float im[DSP_FFT_SIZE];
int band_start[DSP_BANDS]; // inclusive bin index
int band_end[DSP_BANDS]; // exclusive bin index
float smoothed[DSP_BANDS];
float attack;
float release;
};
// ---------------------------------------------------------------------------
// FFT
// ---------------------------------------------------------------------------
void dsp_fft(float* re, float* im, int n)
{
// Bit-reversal permutation.
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j) {
float tr = re[i];
re[i] = re[j];
re[j] = tr;
float ti = im[i];
im[i] = im[j];
im[j] = ti;
}
}
// Iterative Cooley-Tukey. Twiddles are recomputed per stage with sin/cos
// rather than cached: at n=1024 that is ~10 calls per block, far cheaper
// than carrying a table around, and it avoids recurrence drift.
for (int len = 2; len <= n; len <<= 1) {
float ang = -2.0f * (float)M_PI / (float)len;
float wr = cosf(ang);
float wi = sinf(ang);
for (int i = 0; i < n; i += len) {
float cr = 1.0f, ci = 0.0f;
for (int k = 0; k < len / 2; k++) {
float ur = re[i + k];
float ui = im[i + k];
float vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
float vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
re[i + k] = ur + vr;
im[i + k] = ui + vi;
re[i + k + len / 2] = ur - vr;
im[i + k + len / 2] = ui - vi;
float ncr = cr * wr - ci * wi;
ci = cr * wi + ci * wr;
cr = ncr;
}
}
}
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
static void compute_bands(dsp_t* d)
{
float nyquist = (float)d->fmt.rate / 2.0f;
float high = BAND_HIGH_HZ < nyquist ? BAND_HIGH_HZ : nyquist * 0.95f;
float low = BAND_LOW_HZ;
if (low >= high)
low = high / 2.0f;
float bin_hz = (float)d->fmt.rate / (float)DSP_FFT_SIZE;
int max_bin = DSP_FFT_SIZE / 2;
for (int b = 0; b < DSP_BANDS; b++) {
float f0 = low * powf(high / low, (float)b / (float)DSP_BANDS);
float f1 = low * powf(high / low, (float)(b + 1) / (float)DSP_BANDS);
int s = (int)(f0 / bin_hz);
int e = (int)(f1 / bin_hz);
if (s < 1)
s = 1; // skip DC
if (e <= s)
e = s + 1; // every band owns at least one bin
if (e > max_bin)
e = max_bin;
if (s >= e)
s = e - 1;
d->band_start[b] = s;
d->band_end[b] = e;
}
}
dsp_t* dsp_create(const audio_format_t* fmt)
{
dsp_t* d = calloc(1, sizeof(*d));
if (!d)
return NULL;
d->fmt = *fmt;
d->attack = 0.6f;
d->release = 0.12f;
for (int i = 0; i < DSP_FFT_SIZE; i++)
d->hann[i] = 0.5f * (1.0f - cosf(2.0f * (float)M_PI * (float)i / (float)(DSP_FFT_SIZE - 1)));
compute_bands(d);
return d;
}
void dsp_destroy(dsp_t* d) { free(d); }
void dsp_set_format(dsp_t* d, const audio_format_t* fmt)
{
if (!d)
return;
d->fmt = *fmt;
d->window_fill = 0;
memset(d->window_samples, 0, sizeof(d->window_samples));
compute_bands(d);
}
void dsp_set_smoothing(dsp_t* d, float attack, float release)
{
if (!d)
return;
if (attack < 0.01f)
attack = 0.01f;
if (attack > 1.0f)
attack = 1.0f;
if (release < 0.01f)
release = 0.01f;
if (release > 1.0f)
release = 1.0f;
d->attack = attack;
d->release = release;
}
// ---------------------------------------------------------------------------
// Processing
// ---------------------------------------------------------------------------
static float to_db(float amplitude)
{
if (amplitude <= 1e-9f)
return DB_FLOOR;
float db = 20.0f * log10f(amplitude);
return db < DB_FLOOR ? DB_FLOOR : db;
}
void dsp_process(dsp_t* d, const int16_t* pcm, int frames, dsp_levels_t* out)
{
if (!d || !out)
return;
memset(out, 0, sizeof(*out));
if (frames <= 0)
return;
const int ch = d->fmt.channels < 1 ? 1 : d->fmt.channels;
// --- Peak / RMS over the raw block -------------------------------------
double sum_sq = 0.0;
int peak_abs = 0;
int clipped = 0;
const int total_samples = frames * ch;
for (int i = 0; i < total_samples; i++) {
int s = pcm[i];
int a = s < 0 ? -s : s;
if (a > peak_abs)
peak_abs = a;
if (a >= 32767)
clipped++;
double f = (double)s / 32768.0;
sum_sq += f * f;
}
out->peak = (float)peak_abs / 32768.0f;
out->rms = (float)sqrt(sum_sq / (double)total_samples);
out->peak_db = to_db(out->peak);
out->rms_db = to_db(out->rms);
// A couple of full-scale samples is normal on loud content; a sustained
// run is what actually indicates clipping.
out->clipping = clipped > total_samples / 100;
// --- Slide new mono samples into the FFT window ------------------------
for (int i = 0; i < frames; i++) {
float mono = 0.0f;
for (int c = 0; c < ch; c++)
mono += (float)pcm[i * ch + c] / 32768.0f;
mono /= (float)ch;
if (d->window_fill < DSP_FFT_SIZE) {
d->window_samples[d->window_fill++] = mono;
} else {
memmove(d->window_samples, d->window_samples + 1,
(DSP_FFT_SIZE - 1) * sizeof(float));
d->window_samples[DSP_FFT_SIZE - 1] = mono;
}
}
if (d->window_fill < DSP_FFT_SIZE) {
// Not enough history yet; report levels but leave bands at zero.
memcpy(out->bands, d->smoothed, sizeof(out->bands));
return;
}
// --- Spectrum ----------------------------------------------------------
for (int i = 0; i < DSP_FFT_SIZE; i++) {
d->re[i] = d->window_samples[i] * d->hann[i];
d->im[i] = 0.0f;
}
dsp_fft(d->re, d->im, DSP_FFT_SIZE);
for (int b = 0; b < DSP_BANDS; b++) {
double acc = 0.0;
int n = d->band_end[b] - d->band_start[b];
for (int k = d->band_start[b]; k < d->band_end[b]; k++) {
float mag = sqrtf(d->re[k] * d->re[k] + d->im[k] * d->im[k]);
acc += mag;
}
// Mean magnitude, scaled back up for the Hann window's 0.5 coherent
// gain and the FFT's unnormalised forward transform.
float mean = n > 0 ? (float)(acc / n) : 0.0f;
float amp = mean * 4.0f / (float)(DSP_FFT_SIZE / 2);
float db = to_db(amp);
float norm = (db - BAND_DB_FLOOR) / (0.0f - BAND_DB_FLOOR);
if (norm < 0.0f)
norm = 0.0f;
if (norm > 1.0f)
norm = 1.0f;
// Pink-noise tilt: high bands carry less energy in real programme
// material, so lift them or the top of the bar graph never moves.
float tilt = 1.0f + 0.5f * ((float)b / (float)(DSP_BANDS - 1));
norm *= tilt;
if (norm > 1.0f)
norm = 1.0f;
float coeff = norm > d->smoothed[b] ? d->attack : d->release;
d->smoothed[b] += (norm - d->smoothed[b]) * coeff;
out->bands[b] = d->smoothed[b];
}
}
+44
View File
@@ -0,0 +1,44 @@
// Level metering and spectrum analysis.
//
// Two consumers: the UI meter (peak/RMS, cheap) and the on-TV visualiser that
// renders an image for HyperHDR's flatbuffer input (band energies, needs an
// FFT). Both run on the capture thread, so this has to stay cheap enough to
// finish well inside one 512-frame block.
#pragma once
#include "common/audio.h"
#include <stdbool.h>
#include <stdint.h>
#define DSP_FFT_SIZE 1024 // must be a power of two
#define DSP_BANDS 16 // log-spaced bands reported to the visualiser
typedef struct {
float peak; // 0..1, highest absolute sample in the last block
float rms; // 0..1, root mean square of the last block
float peak_db; // dBFS, clamped to -90
float rms_db; // dBFS, clamped to -90
float bands[DSP_BANDS]; // 0..1 normalised band energies, smoothed
bool clipping; // a sample hit full scale in the last block
} dsp_levels_t;
typedef struct dsp dsp_t;
dsp_t* dsp_create(const audio_format_t* fmt);
void dsp_destroy(dsp_t* d);
// Reconfigures band edges after a sample-rate change. Cheap; safe to call
// whenever the capture format is renegotiated.
void dsp_set_format(dsp_t* d, const audio_format_t* fmt);
// `attack` and `release` are per-block smoothing coefficients in 0..1, where
// 1 means "follow instantly". Separate values let bars snap up and fall slowly.
void dsp_set_smoothing(dsp_t* d, float attack, float release);
// Feeds one block of interleaved S16LE frames and updates `out`.
void dsp_process(dsp_t* d, const int16_t* pcm, int frames, dsp_levels_t* out);
// Standalone real FFT over `n` samples (n must be a power of two).
// `re` and `im` are in/out arrays of length n.
void dsp_fft(float* re, float* im, int n);
+510
View File
@@ -0,0 +1,510 @@
#include "engine.h"
#include "capture/capture.h"
#include "common/log.h"
#include "sinks/sink.h"
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define MAX_SINKS 8
#define NOTIFY_INTERVAL_MS 100
// How long to keep retrying the capture device before giving up. Autostart
// runs early in boot, where PulseAudio may not have come up yet.
#define OPEN_RETRY_SECONDS 30
#define OPEN_RETRY_DELAY_MS 2000
#define STOP_JOIN_TIMEOUT_SEC 5
typedef struct {
char id[32];
sink_t* sink; // NULL when this sink failed to open
char error[192];
} sink_slot_t;
struct engine {
pthread_t thread;
bool thread_valid;
pthread_mutex_t lock;
engine_notify_fn notify;
void* notify_user;
volatile bool stop_requested;
// --- guarded by `lock` ---
engine_state_t state;
char error[256];
json_value_t* cfg;
capture_t* cap;
dsp_t* dsp;
char backend_id[32];
char backend_name[64];
char device[128];
audio_format_t fmt;
sink_slot_t sinks[MAX_SINKS];
size_t sink_count;
dsp_levels_t levels;
bool have_levels;
struct timespec started_at;
unsigned long long frames_captured;
unsigned long long blocks;
unsigned long long timeouts;
};
const char* engine_state_name(engine_state_t s)
{
switch (s) {
case ENGINE_STOPPED:
return "stopped";
case ENGINE_STARTING:
return "starting";
case ENGINE_RUNNING:
return "running";
default:
return "error";
}
}
static long ms_since(const struct timespec* since)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (now.tv_sec - since->tv_sec) * 1000L + (now.tv_nsec - since->tv_nsec) / 1000000L;
}
static void sleep_ms(int ms)
{
struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L };
nanosleep(&ts, NULL);
}
static void set_state(engine_t* e, engine_state_t state, const char* err)
{
pthread_mutex_lock(&e->lock);
e->state = state;
if (err)
snprintf(e->error, sizeof(e->error), "%s", err);
else if (state != ENGINE_ERROR)
e->error[0] = '\0';
pthread_mutex_unlock(&e->lock);
if (e->notify)
e->notify(e->notify_user);
}
// ---------------------------------------------------------------------------
// Setup, on the engine thread
// ---------------------------------------------------------------------------
static bool open_capture(engine_t* e, char* err, size_t errlen)
{
const json_value_t* cc = json_get(e->cfg, "capture");
const char* backend = json_str(cc, "backend", "auto");
const char* device = json_str(cc, "device", "");
const char* server = json_str(cc, "server", "");
const char* command = json_str(cc, "command", "");
capture_opts_t opts = {
.fmt = {
.rate = json_int(cc, "rate", AUDIO_DEFAULT_RATE),
.channels = json_int(cc, "channels", AUDIO_DEFAULT_CHANNELS),
},
.device = (device && *device) ? device : NULL,
.server = (server && *server) ? server : NULL,
.command = (command && *command) ? command : NULL,
};
if (opts.fmt.channels < 1 || opts.fmt.channels > AUDIO_MAX_CHANNELS)
opts.fmt.channels = AUDIO_DEFAULT_CHANNELS;
if (opts.fmt.rate < 8000 || opts.fmt.rate > 192000)
opts.fmt.rate = AUDIO_DEFAULT_RATE;
struct timespec first_try;
clock_gettime(CLOCK_MONOTONIC, &first_try);
for (;;) {
capture_t* cap = capture_open(backend, &opts, err, errlen);
if (cap) {
pthread_mutex_lock(&e->lock);
e->cap = cap;
e->fmt = cap->fmt;
snprintf(e->backend_id, sizeof(e->backend_id), "%s", cap->driver->id);
snprintf(e->backend_name, sizeof(e->backend_name), "%s", cap->driver->name);
snprintf(e->device, sizeof(e->device), "%s", opts.device ? opts.device : "(default)");
pthread_mutex_unlock(&e->lock);
return true;
}
if (e->stop_requested)
return false;
if (ms_since(&first_try) > OPEN_RETRY_SECONDS * 1000L)
return false;
WARN("Capture open failed (%s); retrying", err);
// Broken up so a stop request during the wait is noticed quickly.
for (int waited = 0; waited < OPEN_RETRY_DELAY_MS && !e->stop_requested; waited += 100)
sleep_ms(100);
}
}
static void open_sinks(engine_t* e)
{
const json_value_t* list = json_get(e->cfg, "sinks");
size_t count = json_len(list);
if (count > MAX_SINKS) {
WARN("Only the first %d sinks will be started", MAX_SINKS);
count = MAX_SINKS;
}
for (size_t i = 0; i < count; i++) {
const json_value_t* item = json_at(list, i);
if (!item || item->type != JSON_STRING)
continue;
sink_slot_t slot;
memset(&slot, 0, sizeof(slot));
snprintf(slot.id, sizeof(slot.id), "%s", item->u.string);
char err[192] = { 0 };
slot.sink = sink_open(slot.id, e->cfg, &e->fmt, err, sizeof(err));
if (!slot.sink) {
snprintf(slot.error, sizeof(slot.error), "%s", err);
// A misconfigured sink must not take the whole pipeline down: the
// others keep running and the UI shows what went wrong.
ERR("Sink '%s' failed to start: %s", slot.id, err);
}
pthread_mutex_lock(&e->lock);
e->sinks[e->sink_count++] = slot;
pthread_mutex_unlock(&e->lock);
}
if (e->sink_count == 0)
WARN("No sinks configured; capturing for level metering only");
}
static void close_everything(engine_t* e)
{
pthread_mutex_lock(&e->lock);
sink_slot_t slots[MAX_SINKS];
size_t n = e->sink_count;
memcpy(slots, e->sinks, sizeof(slots));
memset(e->sinks, 0, sizeof(e->sinks));
e->sink_count = 0;
capture_t* cap = e->cap;
dsp_t* dsp = e->dsp;
e->cap = NULL;
e->dsp = NULL;
e->have_levels = false;
memset(&e->levels, 0, sizeof(e->levels));
pthread_mutex_unlock(&e->lock);
// Done outside the lock: closing a sink can send a farewell message.
for (size_t i = 0; i < n; i++) {
if (slots[i].sink)
sink_close(slots[i].sink);
}
if (dsp)
dsp_destroy(dsp);
if (cap)
capture_close(cap);
}
// ---------------------------------------------------------------------------
// The capture loop
// ---------------------------------------------------------------------------
static void* engine_thread(void* arg)
{
engine_t* e = arg;
char err[256] = { 0 };
if (!open_capture(e, err, sizeof(err))) {
if (e->stop_requested) {
set_state(e, ENGINE_STOPPED, NULL);
} else {
ERR("Capture could not be started: %s", err);
set_state(e, ENGINE_ERROR, err);
}
return NULL;
}
dsp_t* dsp = dsp_create(&e->fmt);
if (!dsp) {
close_everything(e);
set_state(e, ENGINE_ERROR, "out of memory creating the analyser");
return NULL;
}
const json_value_t* dc = json_get(e->cfg, "dsp");
dsp_set_smoothing(dsp, (float)json_num(dc, "attack", 0.6), (float)json_num(dc, "release", 0.12));
pthread_mutex_lock(&e->lock);
e->dsp = dsp;
clock_gettime(CLOCK_MONOTONIC, &e->started_at);
e->frames_captured = 0;
e->blocks = 0;
e->timeouts = 0;
pthread_mutex_unlock(&e->lock);
open_sinks(e);
set_state(e, ENGINE_RUNNING, NULL);
INFO("Capture running: %s at %d Hz, %d channel(s), %zu sink(s)", e->backend_id,
e->fmt.rate, e->fmt.channels, e->sink_count);
int16_t* block = malloc((size_t)AUDIO_BLOCK_FRAMES * AUDIO_MAX_CHANNELS * sizeof(int16_t));
if (!block) {
close_everything(e);
set_state(e, ENGINE_ERROR, "out of memory allocating the capture block");
return NULL;
}
struct timespec last_notify;
clock_gettime(CLOCK_MONOTONIC, &last_notify);
bool failed = false;
while (!e->stop_requested) {
int frames = e->cap->read(e->cap, block, AUDIO_BLOCK_FRAMES);
if (frames < 0) {
snprintf(err, sizeof(err), "capture backend '%s' stopped delivering audio",
e->backend_id);
failed = true;
break;
}
pthread_mutex_lock(&e->lock);
dsp_levels_t* levels = NULL;
if (frames > 0) {
dsp_process(e->dsp, block, frames, &e->levels);
e->have_levels = true;
e->frames_captured += (unsigned long long)frames;
e->blocks++;
levels = &e->levels;
} else {
e->timeouts++;
}
// Sinks are called even for an empty block so the ones that maintain a
// connection get a chance to reconnect while the input is silent.
for (size_t i = 0; i < e->sink_count; i++) {
sink_t* s = e->sinks[i].sink;
if (s)
s->write(s, block, frames, levels);
}
pthread_mutex_unlock(&e->lock);
if (e->notify && ms_since(&last_notify) >= NOTIFY_INTERVAL_MS) {
clock_gettime(CLOCK_MONOTONIC, &last_notify);
e->notify(e->notify_user);
}
}
free(block);
close_everything(e);
if (failed) {
ERR("%s", err);
set_state(e, ENGINE_ERROR, err);
} else {
INFO("Capture stopped");
set_state(e, ENGINE_STOPPED, NULL);
}
return NULL;
}
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
engine_t* engine_create(engine_notify_fn notify, void* user)
{
engine_t* e = calloc(1, sizeof(*e));
if (!e)
return NULL;
pthread_mutex_init(&e->lock, NULL);
e->notify = notify;
e->notify_user = user;
e->state = ENGINE_STOPPED;
e->fmt.rate = AUDIO_DEFAULT_RATE;
e->fmt.channels = AUDIO_DEFAULT_CHANNELS;
return e;
}
void engine_destroy(engine_t* e)
{
if (!e)
return;
engine_stop(e);
json_free(e->cfg);
pthread_mutex_destroy(&e->lock);
free(e);
}
bool engine_start(engine_t* e, const json_value_t* cfg, char* err, size_t errlen)
{
if (!e) {
snprintf(err, errlen, "no engine");
return false;
}
if (engine_is_active(e)) {
snprintf(err, errlen, "already running");
return false;
}
// A previous run may have ended on its own; reap the thread before reusing
// the slot.
if (e->thread_valid) {
pthread_join(e->thread, NULL);
e->thread_valid = false;
}
// The settings are snapshotted so a setConfig mid-capture cannot change
// things out from under the running pipeline.
json_value_t* snapshot = json_clone(cfg);
if (!snapshot) {
snprintf(err, errlen, "out of memory copying settings");
return false;
}
pthread_mutex_lock(&e->lock);
json_free(e->cfg);
e->cfg = snapshot;
e->state = ENGINE_STARTING;
e->error[0] = '\0';
pthread_mutex_unlock(&e->lock);
e->stop_requested = false;
if (pthread_create(&e->thread, NULL, engine_thread, e) != 0) {
snprintf(err, errlen, "cannot create capture thread: %s", strerror(errno));
set_state(e, ENGINE_ERROR, err);
return false;
}
e->thread_valid = true;
if (e->notify)
e->notify(e->notify_user);
return true;
}
void engine_stop(engine_t* e)
{
if (!e || !e->thread_valid)
return;
e->stop_requested = true;
#if defined(__GLIBC__) && defined(_GNU_SOURCE)
// A capture backend that has wedged in a blocking read must not take the
// Luna main loop down with it: give up on the thread and carry on.
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += STOP_JOIN_TIMEOUT_SEC;
if (pthread_timedjoin_np(e->thread, NULL, &deadline) != 0) {
WARN("Capture thread did not stop within %ds; abandoning it", STOP_JOIN_TIMEOUT_SEC);
pthread_detach(e->thread);
e->thread_valid = false;
set_state(e, ENGINE_ERROR, "capture thread did not stop");
return;
}
#else
pthread_join(e->thread, NULL);
#endif
e->thread_valid = false;
}
engine_state_t engine_state(engine_t* e)
{
if (!e)
return ENGINE_STOPPED;
pthread_mutex_lock(&e->lock);
engine_state_t s = e->state;
pthread_mutex_unlock(&e->lock);
return s;
}
bool engine_is_active(engine_t* e)
{
engine_state_t s = engine_state(e);
return s == ENGINE_STARTING || s == ENGINE_RUNNING;
}
void engine_write_status(engine_t* e, json_writer_t* w)
{
if (!e)
return;
pthread_mutex_lock(&e->lock);
jw_str(w, "state", engine_state_name(e->state));
jw_bool(w, "running", e->state == ENGINE_RUNNING);
if (e->error[0])
jw_str(w, "error", e->error);
else
jw_null(w, "error");
jw_obj_open(w, "capture");
jw_str(w, "backend", e->backend_id[0] ? e->backend_id : NULL);
jw_str(w, "backendName", e->backend_name[0] ? e->backend_name : NULL);
jw_str(w, "device", e->device[0] ? e->device : NULL);
jw_int(w, "rate", e->fmt.rate);
jw_int(w, "channels", e->fmt.channels);
jw_int(w, "frames", (long long)e->frames_captured);
jw_int(w, "blocks", (long long)e->blocks);
jw_int(w, "timeouts", (long long)e->timeouts);
jw_int(w, "uptimeMs", e->state == ENGINE_RUNNING ? ms_since(&e->started_at) : 0);
jw_obj_close(w);
jw_obj_open(w, "levels");
if (e->have_levels) {
jw_num(w, "peak", e->levels.peak);
jw_num(w, "rms", e->levels.rms);
jw_num(w, "peakDb", e->levels.peak_db);
jw_num(w, "rmsDb", e->levels.rms_db);
jw_bool(w, "clipping", e->levels.clipping);
jw_arr_open(w, "bands");
for (int i = 0; i < DSP_BANDS; i++)
jw_num(w, NULL, e->levels.bands[i]);
jw_arr_close(w);
} else {
jw_num(w, "peak", 0);
jw_num(w, "rms", 0);
jw_num(w, "peakDb", -90);
jw_num(w, "rmsDb", -90);
jw_bool(w, "clipping", false);
jw_arr_open(w, "bands");
for (int i = 0; i < DSP_BANDS; i++)
jw_num(w, NULL, 0);
jw_arr_close(w);
}
jw_obj_close(w);
jw_arr_open(w, "sinks");
for (size_t i = 0; i < e->sink_count; i++) {
sink_slot_t* slot = &e->sinks[i];
jw_obj_open(w, NULL);
jw_str(w, "id", slot->id);
jw_bool(w, "ok", slot->sink != NULL);
if (slot->sink) {
jw_str(w, "name", slot->sink->driver->name);
jw_null(w, "error");
if (slot->sink->status)
slot->sink->status(slot->sink, w);
} else {
const sink_driver_t* drv = sink_find(slot->id);
jw_str(w, "name", drv ? drv->name : slot->id);
jw_str(w, "error", slot->error);
}
jw_obj_close(w);
}
jw_arr_close(w);
pthread_mutex_unlock(&e->lock);
}
+47
View File
@@ -0,0 +1,47 @@
// The capture pipeline.
//
// One thread owns everything that touches audio: it opens the backend, reads
// blocks, runs the DSP and hands each block to every enabled sink in turn.
// Sinks are contractually non-blocking, so the fan-out is synchronous and no
// audio is ever copied more than it has to be.
//
// Everything the Luna service needs to read is behind one mutex, so status
// queries never interfere with capture beyond a few microseconds.
#pragma once
#include "common/json.h"
#include "dsp.h"
#include <stdbool.h>
#include <stddef.h>
typedef struct engine engine_t;
typedef enum {
ENGINE_STOPPED,
ENGINE_STARTING,
ENGINE_RUNNING,
ENGINE_ERROR,
} engine_state_t;
// Fired from the engine thread on every state change and roughly ten times a
// second while running. Must not block: the service uses it to schedule a
// subscription update on the main loop.
typedef void (*engine_notify_fn)(void* user);
engine_t* engine_create(engine_notify_fn notify, void* user);
void engine_destroy(engine_t* e);
// Returns as soon as the thread is spawned; opening the capture device and
// the sinks happens on that thread, because either can take a moment and the
// Luna handler must not stall. Watch the state for the outcome.
bool engine_start(engine_t* e, const json_value_t* cfg, char* err, size_t errlen);
void engine_stop(engine_t* e);
engine_state_t engine_state(engine_t* e);
bool engine_is_active(engine_t* e); // starting or running
// Writes the status fields into an object the caller has already opened.
void engine_write_status(engine_t* e, json_writer_t* w);
const char* engine_state_name(engine_state_t s);
+123
View File
@@ -0,0 +1,123 @@
// Entry point for the native Luna service.
//
// Nothing interesting happens here: register on the bus, hand control to the
// glib main loop, and make sure a SIGTERM from the service launcher shuts the
// capture down cleanly so sinks get to say goodbye (HyperHDR in particular
// needs its Clear, or the LEDs freeze on the last frame we sent).
#include "common/log.h"
#include "service.h"
#include <glib-unix.h>
#include <glib.h>
#include <luna-service2/lunaservice.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define SERVICE_NAME "org.webosbrew.audiocap.service"
// webOS 3.5 and earlier need the service registered on the public bus too.
// Declared weak so the same binary keeps loading on newer firmware where the
// symbol was removed.
extern bool LSRegisterPubPriv(const char* name, LSHandle** handle, bool public_bus,
LSError* error) __attribute__((weak));
static gboolean on_signal(gpointer user)
{
GMainLoop* loop = user;
INFO("Signal received, shutting down");
g_main_loop_quit(loop);
return G_SOURCE_REMOVE;
}
static void log_environment(void)
{
uid_t uid = getuid();
INFO("lgtv-audio-cap service starting (uid=%d%s)", (int)uid,
uid == 0 ? ", root" : ", unprivileged");
if (uid != 0) {
// Without root the PulseAudio socket and the ALSA devices are usually
// out of reach, so say this once rather than leaving the user to
// decode a permission error later.
WARN("Not running as root: audio devices are likely inaccessible.");
WARN("Install the Homebrew Channel 'elevate-service' patch and restart the service.");
}
}
int main(int argc, char** argv)
{
bool debug = false;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-d") == 0 || strcmp(argv[i], "--debug") == 0)
debug = true;
}
log_init(debug ? LOG_DEBUG : LOG_INFO);
log_environment();
// A client that hangs up mid-stream must not kill the service. Every send
// path already asks for MSG_NOSIGNAL; this covers the rest.
signal(SIGPIPE, SIG_IGN);
GMainLoop* loop = g_main_loop_new(NULL, false);
LSError lserror;
LSErrorInit(&lserror);
LSHandle* handle = NULL;
bool registered = LSRegisterPubPriv
? LSRegisterPubPriv(SERVICE_NAME, &handle, true, &lserror)
: LSRegister(SERVICE_NAME, &handle, &lserror);
if (!registered) {
ERR("Cannot register %s on the Luna bus: %s", SERVICE_NAME, lserror.message);
LSErrorFree(&lserror);
g_main_loop_unref(loop);
return 1;
}
if (!LSGmainAttach(handle, loop, &lserror)) {
ERR("Cannot attach to the main loop: %s", lserror.message);
LSErrorFree(&lserror);
LSUnregister(handle, &lserror);
g_main_loop_unref(loop);
return 1;
}
service_t* service = service_create(handle, loop);
if (!service) {
ERR("Cannot create the service");
LSUnregister(handle, &lserror);
g_main_loop_unref(loop);
return 1;
}
char err[256] = { 0 };
if (!service_register(service, err, sizeof(err))) {
ERR("%s", err);
service_destroy(service);
LSUnregister(handle, &lserror);
g_main_loop_unref(loop);
return 1;
}
g_unix_signal_add(SIGTERM, on_signal, loop);
g_unix_signal_add(SIGINT, on_signal, loop);
INFO("Registered as %s", SERVICE_NAME);
service_autostart(service);
g_main_loop_run(loop);
INFO("Shutting down");
service_destroy(service);
if (!LSUnregister(handle, &lserror)) {
ERR("Unregister failed: %s", lserror.message);
LSErrorFree(&lserror);
}
g_main_loop_unref(loop);
return 0;
}
+263
View File
@@ -0,0 +1,263 @@
#include "flatbuf.h"
#include <stdlib.h>
#include <string.h>
#define VTABLE_METADATA_FIELDS 2
static size_t fb_offset(const fb_t* b) { return b->cap - b->head; }
static bool fb_ensure(fb_t* b, size_t need)
{
if (b->failed)
return false;
if (b->head >= need)
return true;
size_t used = b->cap - b->head;
size_t new_cap = b->cap ? b->cap : 1024;
while (new_cap - used < need)
new_cap *= 2;
uint8_t* fresh = malloc(new_cap);
if (!fresh) {
b->failed = true;
return false;
}
// Data grows downward from the top, so the live region keeps its
// right-alignment in the new allocation.
memcpy(fresh + new_cap - used, b->bytes + b->head, used);
free(b->bytes);
b->bytes = fresh;
b->cap = new_cap;
b->head = new_cap - used;
return true;
}
static void fb_pad(fb_t* b, size_t n)
{
if (n == 0 || !fb_ensure(b, n))
return;
b->head -= n;
memset(b->bytes + b->head, 0, n);
}
// Reserves room for a `size`-byte scalar that will be followed by
// `additional` bytes already accounted for, inserting alignment padding.
static void fb_prep(fb_t* b, size_t size, size_t additional)
{
if (b->failed)
return;
if (size > b->minalign)
b->minalign = size;
size_t align_size = (~(fb_offset(b) + additional) + 1) & (size - 1);
if (!fb_ensure(b, align_size + size + additional))
return;
fb_pad(b, align_size);
}
static void fb_place_u8(fb_t* b, uint8_t v)
{
if (!fb_ensure(b, 1))
return;
b->head -= 1;
b->bytes[b->head] = v;
}
static void fb_place_u16(fb_t* b, uint16_t v)
{
if (!fb_ensure(b, 2))
return;
b->head -= 2;
b->bytes[b->head + 0] = (uint8_t)(v & 0xFF);
b->bytes[b->head + 1] = (uint8_t)((v >> 8) & 0xFF);
}
static void fb_place_u32(fb_t* b, uint32_t v)
{
if (!fb_ensure(b, 4))
return;
b->head -= 4;
b->bytes[b->head + 0] = (uint8_t)(v & 0xFF);
b->bytes[b->head + 1] = (uint8_t)((v >> 8) & 0xFF);
b->bytes[b->head + 2] = (uint8_t)((v >> 16) & 0xFF);
b->bytes[b->head + 3] = (uint8_t)((v >> 24) & 0xFF);
}
static void fb_write_u32_at(fb_t* b, size_t offset_from_end, uint32_t v)
{
size_t idx = b->cap - offset_from_end;
b->bytes[idx + 0] = (uint8_t)(v & 0xFF);
b->bytes[idx + 1] = (uint8_t)((v >> 8) & 0xFF);
b->bytes[idx + 2] = (uint8_t)((v >> 16) & 0xFF);
b->bytes[idx + 3] = (uint8_t)((v >> 24) & 0xFF);
}
// ---------------------------------------------------------------------------
bool fb_init(fb_t* b, size_t initial_capacity)
{
memset(b, 0, sizeof(*b));
if (initial_capacity < 64)
initial_capacity = 64;
b->bytes = malloc(initial_capacity);
if (!b->bytes) {
b->failed = true;
return false;
}
b->cap = initial_capacity;
b->head = initial_capacity;
b->minalign = 1;
return true;
}
void fb_free(fb_t* b)
{
free(b->bytes);
memset(b, 0, sizeof(*b));
}
bool fb_ok(const fb_t* b) { return !b->failed; }
const uint8_t* fb_data(const fb_t* b, size_t* len)
{
if (b->failed) {
if (len)
*len = 0;
return NULL;
}
if (len)
*len = b->cap - b->head;
return b->bytes + b->head;
}
uint32_t fb_create_uint8_vector(fb_t* b, const uint8_t* data, size_t count)
{
// Element alignment is 1, so only the uint32 length needs alignment.
fb_prep(b, 4, count);
if (!fb_ensure(b, count))
return 0;
b->head -= count;
if (count)
memcpy(b->bytes + b->head, data, count);
fb_place_u32(b, (uint32_t)count);
return (uint32_t)fb_offset(b);
}
uint32_t fb_create_string(fb_t* b, const char* s)
{
size_t len = s ? strlen(s) : 0;
fb_prep(b, 4, len + 1);
if (!fb_ensure(b, len + 1))
return 0;
b->head -= 1;
b->bytes[b->head] = 0; // strings carry a NUL terminator outside the length
b->head -= len;
if (len)
memcpy(b->bytes + b->head, s, len);
fb_place_u32(b, (uint32_t)len);
return (uint32_t)fb_offset(b);
}
void fb_start_table(fb_t* b, int num_fields)
{
if (b->failed)
return;
if (num_fields > FB_MAX_FIELDS) {
b->failed = true;
return;
}
memset(b->vtable, 0, sizeof(b->vtable));
b->vtable_count = num_fields;
b->object_end = fb_offset(b);
b->nested = true;
}
static void fb_slot(fb_t* b, int slot)
{
if (slot < 0 || slot >= b->vtable_count) {
b->failed = true;
return;
}
b->vtable[slot] = (uint16_t)fb_offset(b);
}
void fb_add_offset(fb_t* b, int slot, uint32_t offset)
{
if (b->failed || offset == 0)
return; // 0 means the field is absent
fb_prep(b, 4, 0);
size_t here = fb_offset(b);
if (offset > here) {
b->failed = true;
return;
}
// Offsets are stored relative to their own position.
fb_place_u32(b, (uint32_t)(here - offset + 4));
fb_slot(b, slot);
}
void fb_add_int32(fb_t* b, int slot, int32_t value, int32_t default_value)
{
if (b->failed || value == default_value)
return; // defaults are omitted from the buffer
fb_prep(b, 4, 0);
fb_place_u32(b, (uint32_t)value);
fb_slot(b, slot);
}
void fb_add_uint8(fb_t* b, int slot, uint8_t value, uint8_t default_value)
{
if (b->failed || value == default_value)
return;
fb_prep(b, 1, 0);
fb_place_u8(b, value);
fb_slot(b, slot);
}
uint32_t fb_end_table(fb_t* b)
{
if (b->failed || !b->nested) {
b->failed = true;
return 0;
}
b->nested = false;
// Placeholder for the soffset to the vtable, patched once it is written.
fb_prep(b, 4, 0);
fb_place_u32(b, 0);
size_t object_offset = fb_offset(b);
// Trailing empty slots carry no information and are trimmed.
int count = b->vtable_count;
while (count > 0 && b->vtable[count - 1] == 0)
count--;
for (int i = count - 1; i >= 0; i--) {
uint16_t off = b->vtable[i] ? (uint16_t)(object_offset - b->vtable[i]) : 0;
fb_place_u16(b, off);
}
fb_place_u16(b, (uint16_t)(object_offset - b->object_end)); // inline table size
fb_place_u16(b, (uint16_t)((count + VTABLE_METADATA_FIELDS) * 2)); // vtable size
if (b->failed)
return 0;
fb_write_u32_at(b, object_offset, (uint32_t)(fb_offset(b) - object_offset));
return (uint32_t)object_offset;
}
void fb_finish(fb_t* b, uint32_t root)
{
if (b->failed)
return;
fb_prep(b, b->minalign, 4);
size_t here = fb_offset(b);
if (root > here) {
b->failed = true;
return;
}
fb_place_u32(b, (uint32_t)(here - root + 4));
}
+49
View File
@@ -0,0 +1,49 @@
// A small FlatBuffers builder.
//
// HyperHDR's image input speaks the hyperion.ng FlatBuffers schema. The
// upstream client generates code with flatcc and carries it as a submodule;
// that is a lot of build machinery for four message shapes, so this
// implements the builder algorithm directly. It follows the same back-to-front
// construction as the reference implementations, so buffers it produces are
// byte-comparable with flatc-generated output (minus vtable deduplication,
// which is an optimisation, not a format requirement).
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define FB_MAX_FIELDS 8
typedef struct {
uint8_t* bytes; // full allocation; live data is bytes[head..cap)
size_t cap;
size_t head;
size_t minalign;
uint16_t vtable[FB_MAX_FIELDS];
int vtable_count;
size_t object_end; // fb_offset() captured at fb_start_table
bool nested;
bool failed;
} fb_t;
bool fb_init(fb_t* b, size_t initial_capacity);
void fb_free(fb_t* b);
// Offsets are distances from the end of the buffer, matching the reference
// builders. Zero means "absent".
uint32_t fb_create_uint8_vector(fb_t* b, const uint8_t* data, size_t count);
uint32_t fb_create_string(fb_t* b, const char* s);
void fb_start_table(fb_t* b, int num_fields);
void fb_add_offset(fb_t* b, int slot, uint32_t offset);
void fb_add_int32(fb_t* b, int slot, int32_t value, int32_t default_value);
void fb_add_uint8(fb_t* b, int slot, uint8_t value, uint8_t default_value);
uint32_t fb_end_table(fb_t* b);
void fb_finish(fb_t* b, uint32_t root);
// Valid until the next mutation or fb_free.
const uint8_t* fb_data(const fb_t* b, size_t* len);
bool fb_ok(const fb_t* b);
+515
View File
@@ -0,0 +1,515 @@
#include "hyperion.h"
#include "../common/log.h"
#include "flatbuf.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/uio.h>
#include <time.h>
#include <unistd.h>
// macOS has no MSG_NOSIGNAL; it uses the SO_NOSIGPIPE socket option instead.
// Only relevant when building the host-side unit tests.
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
// hyperionnet.Command union tags, in schema declaration order.
#define CMD_COLOR 1
#define CMD_IMAGE 2
#define CMD_CLEAR 3
#define CMD_REGISTER 4
// hyperionnet.ImageType union tags.
#define IMGTYPE_RAW 1
#define CONNECT_TIMEOUT_MS 3000
// Upper bound on how long a single send may spend waiting for socket buffer
// space. This runs on the capture thread, so a stalled link has to become a
// dropped connection rather than a dropped audio block.
#define SEND_BUDGET_MS 200
#define REPLY_MAX 4096
struct hyperion_client {
int fd;
int priority;
bool connected; // TCP handshake finished and Register sent
bool registered; // HyperHDR confirmed our priority
char origin[64];
char error[192];
struct timespec started;
unsigned char rx[REPLY_MAX];
size_t rx_len;
};
static long elapsed_ms(const struct timespec* since)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (now.tv_sec - since->tv_sec) * 1000L + (now.tv_nsec - since->tv_nsec) / 1000000L;
}
// ---------------------------------------------------------------------------
// Message construction
// ---------------------------------------------------------------------------
// Prefixes `payload` with its big-endian length, as the Hyperion framing
// requires, and returns a single malloc'd buffer.
static uint8_t* frame(const uint8_t* payload, size_t payload_len, size_t* out_len)
{
uint8_t* buf = malloc(payload_len + 4);
if (!buf)
return NULL;
buf[0] = (uint8_t)((payload_len >> 24) & 0xFF);
buf[1] = (uint8_t)((payload_len >> 16) & 0xFF);
buf[2] = (uint8_t)((payload_len >> 8) & 0xFF);
buf[3] = (uint8_t)(payload_len & 0xFF);
memcpy(buf + 4, payload, payload_len);
*out_len = payload_len + 4;
return buf;
}
uint8_t* hyperion_build_register(const char* origin, int priority, size_t* len)
{
fb_t b;
if (!fb_init(&b, 256))
return NULL;
uint32_t origin_off = fb_create_string(&b, origin);
// table Register { origin:string (required); priority:int; }
fb_start_table(&b, 2);
fb_add_offset(&b, 0, origin_off);
fb_add_int32(&b, 1, priority, 0);
uint32_t reg = fb_end_table(&b);
// table Request { command:Command (required); }
// A union occupies two slots: the type byte then the value offset.
fb_start_table(&b, 2);
fb_add_offset(&b, 1, reg);
fb_add_uint8(&b, 0, CMD_REGISTER, 0);
uint32_t req = fb_end_table(&b);
fb_finish(&b, req);
size_t payload_len = 0;
const uint8_t* payload = fb_data(&b, &payload_len);
uint8_t* out = payload ? frame(payload, payload_len, len) : NULL;
fb_free(&b);
return out;
}
uint8_t* hyperion_build_image(const uint8_t* rgb, int width, int height, size_t* len)
{
size_t pixels = (size_t)width * (size_t)height * 3;
fb_t b;
if (!fb_init(&b, pixels + 256))
return NULL;
uint32_t data_off = fb_create_uint8_vector(&b, rgb, pixels);
// table RawImage { data:[ubyte]; width:int = -1; height:int = -1; }
fb_start_table(&b, 3);
fb_add_offset(&b, 0, data_off);
fb_add_int32(&b, 1, width, -1);
fb_add_int32(&b, 2, height, -1);
uint32_t raw = fb_end_table(&b);
// table Image { data:ImageType (required); duration:int = -1; }
fb_start_table(&b, 3);
fb_add_offset(&b, 1, raw);
fb_add_int32(&b, 2, -1, -1); // duration: keep the default (no timeout)
fb_add_uint8(&b, 0, IMGTYPE_RAW, 0);
uint32_t img = fb_end_table(&b);
fb_start_table(&b, 2);
fb_add_offset(&b, 1, img);
fb_add_uint8(&b, 0, CMD_IMAGE, 0);
uint32_t req = fb_end_table(&b);
fb_finish(&b, req);
size_t payload_len = 0;
const uint8_t* payload = fb_data(&b, &payload_len);
uint8_t* out = payload ? frame(payload, payload_len, len) : NULL;
fb_free(&b);
return out;
}
static uint8_t* build_clear(int priority, size_t* len)
{
fb_t b;
if (!fb_init(&b, 128))
return NULL;
// table Clear { priority:int; }
fb_start_table(&b, 1);
fb_add_int32(&b, 0, priority, 0);
uint32_t clear = fb_end_table(&b);
fb_start_table(&b, 2);
fb_add_offset(&b, 1, clear);
fb_add_uint8(&b, 0, CMD_CLEAR, 0);
uint32_t req = fb_end_table(&b);
fb_finish(&b, req);
size_t payload_len = 0;
const uint8_t* payload = fb_data(&b, &payload_len);
uint8_t* out = payload ? frame(payload, payload_len, len) : NULL;
fb_free(&b);
return out;
}
// ---------------------------------------------------------------------------
// Minimal FlatBuffers reader for hyperionnet.Reply
//
// table Reply { error:string; video:int = -1; registered:int = -1; }
// ---------------------------------------------------------------------------
static uint32_t rd_u32(const uint8_t* p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static uint16_t rd_u16(const uint8_t* p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
static int32_t rd_i32(const uint8_t* p) { return (int32_t)rd_u32(p); }
// Returns the byte offset of field `slot` within `buf`, or 0 if absent.
static size_t reply_field(const uint8_t* buf, size_t len, int slot)
{
if (len < 8)
return 0;
size_t table = rd_u32(buf);
if (table + 4 > len)
return 0;
int32_t soffset = rd_i32(buf + table);
// The vtable sits before the table for buffers built back-to-front.
if (soffset <= 0 || (size_t)soffset > table)
return 0;
size_t vtable = table - (size_t)soffset;
if (vtable + 4 > len)
return 0;
uint16_t vtable_size = rd_u16(buf + vtable);
size_t field_index = 4 + (size_t)slot * 2;
if (field_index + 2 > vtable_size || vtable + field_index + 2 > len)
return 0;
uint16_t field_off = rd_u16(buf + vtable + field_index);
if (field_off == 0)
return 0;
size_t pos = table + field_off;
return pos < len ? pos : 0;
}
static void parse_reply(hyperion_client_t* c, const uint8_t* buf, size_t len)
{
size_t err_pos = reply_field(buf, len, 0);
if (err_pos && err_pos + 4 <= len) {
size_t str_at = err_pos + rd_u32(buf + err_pos);
if (str_at + 4 <= len) {
uint32_t slen = rd_u32(buf + str_at);
if (str_at + 4 + slen <= len && slen > 0) {
snprintf(c->error, sizeof(c->error), "%.*s", (int)slen, buf + str_at + 4);
WARN("HyperHDR replied with error: %s", c->error);
return;
}
}
}
size_t reg_pos = reply_field(buf, len, 2);
if (reg_pos && reg_pos + 4 <= len) {
int32_t registered = rd_i32(buf + reg_pos);
if (registered == c->priority) {
if (!c->registered)
INFO("HyperHDR accepted registration at priority %d", registered);
c->registered = true;
c->error[0] = '\0';
}
}
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
static bool write_all(int fd, const uint8_t* buf, size_t len)
{
struct timespec start;
clock_gettime(CLOCK_MONOTONIC, &start);
size_t off = 0;
while (off < len) {
ssize_t n = send(fd, buf + off, len - off, MSG_NOSIGNAL);
if (n > 0) {
off += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR)
continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
long left = SEND_BUDGET_MS - elapsed_ms(&start);
if (left <= 0)
return false;
struct pollfd pfd = { .fd = fd, .events = POLLOUT };
if (poll(&pfd, 1, (int)left) > 0)
continue;
}
return false;
}
return true;
}
static bool send_framed(hyperion_client_t* c, uint8_t* framed, size_t len)
{
if (!framed) {
snprintf(c->error, sizeof(c->error), "failed to build message");
return false;
}
bool ok = write_all(c->fd, framed, len);
free(framed);
if (!ok)
snprintf(c->error, sizeof(c->error), "send failed: %s", strerror(errno));
return ok;
}
// The TCP handshake is done: disable Nagle and send Register. Returns false
// with c->error set if the registration could not be written.
static bool finish_connect(hyperion_client_t* c)
{
int one = 1;
setsockopt(c->fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
c->connected = true;
size_t len = 0;
uint8_t* msg = hyperion_build_register(c->origin, c->priority, &len);
if (!send_framed(c, msg, len))
return false;
INFO("Connected to HyperHDR, registering as '%s' priority %d", c->origin, c->priority);
return true;
}
bool hyperion_resolve(const char* host, int port, hyperion_target_t* out, char* err, size_t errlen)
{
memset(out, 0, sizeof(*out));
snprintf(out->host, sizeof(out->host), "%s", host ? host : "");
out->port = port;
out->addr.sin_family = AF_INET;
out->addr.sin_port = htons((uint16_t)port);
// An IP literal needs no resolver, which is the overwhelmingly common case
// here and keeps the whole path free of DNS.
if (host && inet_pton(AF_INET, host, &out->addr.sin_addr) == 1)
return true;
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo* res = NULL;
int rc = getaddrinfo(host, portstr, &hints, &res);
if (rc != 0 || !res) {
snprintf(err, errlen, "cannot resolve '%s': %s", host ? host : "(null)", gai_strerror(rc));
return false;
}
memcpy(&out->addr, res->ai_addr, sizeof(struct sockaddr_in));
freeaddrinfo(res);
return true;
}
hyperion_client_t* hyperion_connect(const hyperion_target_t* target, const char* origin,
int priority, char* err, size_t errlen)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
return NULL;
}
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
int rc = connect(fd, (const struct sockaddr*)&target->addr, sizeof(target->addr));
if (rc != 0 && errno != EINPROGRESS) {
snprintf(err, errlen, "connect to %s:%d: %s", target->host, target->port, strerror(errno));
close(fd);
return NULL;
}
hyperion_client_t* c = calloc(1, sizeof(*c));
if (!c) {
close(fd);
snprintf(err, errlen, "out of memory");
return NULL;
}
c->fd = fd;
c->priority = priority;
snprintf(c->origin, sizeof(c->origin), "%s", origin ? origin : "lgtv-audio-cap");
clock_gettime(CLOCK_MONOTONIC, &c->started);
// Connected already (loopback, or the host answered inside the syscall):
// finish the handshake now so the first frame is not delayed a whole pump.
if (rc == 0)
finish_connect(c);
return c;
}
void hyperion_disconnect(hyperion_client_t* c)
{
if (!c)
return;
if (c->fd >= 0) {
if (c->registered) {
size_t len = 0;
uint8_t* msg = build_clear(c->priority, &len);
if (msg) {
// Best effort: tell HyperHDR to release our priority so the
// LEDs fall back to whatever was underneath instead of
// freezing on the last frame we sent.
write_all(c->fd, msg, len);
free(msg);
}
}
close(c->fd);
}
free(c);
}
// Completes a connect that was still in flight. Returns false if it failed or
// ran out of time.
static bool pump_connect(hyperion_client_t* c)
{
struct pollfd pfd = { .fd = c->fd, .events = POLLOUT };
int pr = poll(&pfd, 1, 0);
if (pr < 0)
return errno == EINTR;
if (pr == 0) {
if (elapsed_ms(&c->started) > CONNECT_TIMEOUT_MS) {
snprintf(c->error, sizeof(c->error), "connect timed out");
return false;
}
return true; // still in progress; try again next block
}
int soerr = 0;
socklen_t slen = sizeof(soerr);
if (getsockopt(c->fd, SOL_SOCKET, SO_ERROR, &soerr, &slen) != 0)
soerr = errno;
if (soerr != 0) {
snprintf(c->error, sizeof(c->error), "connect: %s", strerror(soerr));
return false;
}
return finish_connect(c);
}
bool hyperion_pump(hyperion_client_t* c)
{
if (!c || c->fd < 0)
return false;
if (!c->connected) {
if (!pump_connect(c))
return false;
if (!c->connected)
return true; // handshake still pending, nothing to read yet
}
for (;;) {
struct pollfd pfd = { .fd = c->fd, .events = POLLIN };
int pr = poll(&pfd, 1, 0);
if (pr <= 0)
return true;
if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
snprintf(c->error, sizeof(c->error), "connection closed by HyperHDR");
return false;
}
ssize_t n = recv(c->fd, c->rx + c->rx_len, sizeof(c->rx) - c->rx_len, 0);
if (n == 0) {
snprintf(c->error, sizeof(c->error), "connection closed by HyperHDR");
return false;
}
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
return true;
snprintf(c->error, sizeof(c->error), "recv failed: %s", strerror(errno));
return false;
}
c->rx_len += (size_t)n;
// Replies are length-prefixed the same way requests are.
while (c->rx_len >= 4) {
size_t msg_len = ((size_t)c->rx[0] << 24) | ((size_t)c->rx[1] << 16)
| ((size_t)c->rx[2] << 8) | (size_t)c->rx[3];
if (msg_len > sizeof(c->rx) - 4) {
snprintf(c->error, sizeof(c->error), "reply of %zu bytes exceeds buffer", msg_len);
return false;
}
if (c->rx_len < msg_len + 4)
break;
parse_reply(c, c->rx + 4, msg_len);
size_t consumed = msg_len + 4;
memmove(c->rx, c->rx + consumed, c->rx_len - consumed);
c->rx_len -= consumed;
}
}
}
bool hyperion_connected(const hyperion_client_t* c) { return c && c->connected; }
bool hyperion_registered(const hyperion_client_t* c) { return c && c->registered; }
const char* hyperion_last_error(const hyperion_client_t* c)
{
return (c && c->error[0]) ? c->error : NULL;
}
bool hyperion_send_image(hyperion_client_t* c, const uint8_t* rgb, int width, int height)
{
if (!c || c->fd < 0)
return false;
if (!c->registered)
return true; // still waiting on the registration reply
size_t len = 0;
uint8_t* msg = hyperion_build_image(rgb, width, height, &len);
return send_framed(c, msg, len);
}
bool hyperion_send_clear(hyperion_client_t* c)
{
if (!c || c->fd < 0 || !c->connected)
return false;
size_t len = 0;
uint8_t* msg = build_clear(c->priority, &len);
return send_framed(c, msg, len);
}
+49
View File
@@ -0,0 +1,49 @@
// FlatBuffers client for HyperHDR / hyperion.ng image input (TCP 19400).
//
// Used by the on-TV visualiser sink: the TV runs the FFT itself and pushes a
// rendered image, so HyperHDR drives the LEDs without needing any audio
// device at all. That is the zero-host-setup path.
#pragma once
#include <netinet/in.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct hyperion_client hyperion_client_t;
// Address resolution is separated from connecting because it is the one step
// that can block for seconds (DNS), and the caller runs on the audio thread.
// Resolve once when the sink opens, then reconnect as often as needed.
typedef struct {
struct sockaddr_in addr;
char host[128];
int port;
} hyperion_target_t;
bool hyperion_resolve(const char* host, int port, hyperion_target_t* out, char* err, size_t errlen);
// Starts a non-blocking connect and returns immediately: the socket is
// probably still connecting, and Register has not been sent yet. Everything
// after this point is driven by hyperion_pump(), so no call here ever waits
// on the network. Returns NULL only if the socket could not be created.
hyperion_client_t* hyperion_connect(const hyperion_target_t* target, const char* origin,
int priority, char* err, size_t errlen);
void hyperion_disconnect(hyperion_client_t* c);
// Advances the handshake and drains pending replies. Call regularly; this is
// what completes the connect, sends Register and flips the client into the
// registered state. Returns false once the connection is dead or timed out.
bool hyperion_pump(hyperion_client_t* c);
bool hyperion_connected(const hyperion_client_t* c);
bool hyperion_registered(const hyperion_client_t* c);
const char* hyperion_last_error(const hyperion_client_t* c);
// `rgb` holds width*height*3 bytes. No-op (returns true) until registered.
bool hyperion_send_image(hyperion_client_t* c, const uint8_t* rgb, int width, int height);
bool hyperion_send_clear(hyperion_client_t* c);
// Exposed for tests: builds the wire bytes without needing a socket.
// Caller frees via free(). Includes the 4-byte big-endian length prefix.
uint8_t* hyperion_build_register(const char* origin, int priority, size_t* len);
uint8_t* hyperion_build_image(const uint8_t* rgb, int width, int height, size_t* len);
+485
View File
@@ -0,0 +1,485 @@
#include "streamserv.h"
#include "../common/log.h"
#include "../common/ringbuf.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
#define MAX_CLIENTS_HARD 16
#define REQUEST_MAX 2048
typedef struct {
int fd;
bool in_use;
bool greeted; // greeting fully flushed; PCM may now flow
char peer[64];
// Pending greeting bytes (HTTP headers or WAV header) not yet written.
char* pending;
size_t pending_len;
size_t pending_off;
// Tail of a PCM chunk the socket would not accept in full. Held here so
// the stream stays byte-ordered across a partial send.
unsigned char carry[8192];
size_t carry_len;
size_t carry_off;
// HTTP mode: accumulates the request line before the greeting is built.
char request[REQUEST_MAX];
size_t request_len;
ringbuf_t out;
} client_t;
struct streamserv {
streamserv_config_t cfg;
int listen_fd;
int wake_fd[2]; // self-pipe so stop() interrupts poll() promptly
client_t clients[MAX_CLIENTS_HARD];
pthread_mutex_t lock;
pthread_t thread;
bool running;
int client_count;
unsigned long long dropped;
};
static void set_nonblock(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags >= 0)
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// Caller must hold s->lock.
static void drop_client(streamserv_t* s, client_t* c, const char* why)
{
if (!c->in_use)
return;
INFO("streamserv: client %s disconnected (%s)", c->peer, why);
close(c->fd);
c->fd = -1;
c->in_use = false;
c->greeted = false;
c->request_len = 0;
free(c->pending);
c->pending = NULL;
c->pending_len = c->pending_off = 0;
ringbuf_destroy(&c->out);
s->client_count--;
}
// Sends as much of buf[off..len) as the socket accepts.
// Returns 1 if fully sent, 0 if the socket is full, -1 on a fatal error.
static int send_all(int fd, const unsigned char* buf, size_t len, size_t* off)
{
while (*off < len) {
ssize_t n = send(fd, buf + *off, len - *off, MSG_NOSIGNAL);
if (n > 0) {
*off += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR)
continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
return 0;
return -1;
}
return 1;
}
// Caller must hold s->lock. Returns false if the client should be dropped.
static bool flush_client(streamserv_t* s, client_t* c)
{
(void)s;
// 1. Greeting first: HTTP headers or the WAV header must land intact
// before any PCM, so nothing else is sent until this drains.
if (c->pending) {
int rc = send_all(c->fd, (const unsigned char*)c->pending, c->pending_len,
&c->pending_off);
if (rc < 0)
return false;
if (rc == 0)
return true;
free(c->pending);
c->pending = NULL;
c->pending_len = c->pending_off = 0;
c->greeted = true;
}
if (!c->greeted)
return true;
// 2. Anything left over from a previous partial send.
if (c->carry_off < c->carry_len) {
int rc = send_all(c->fd, c->carry, c->carry_len, &c->carry_off);
if (rc < 0)
return false;
if (rc == 0)
return true;
}
c->carry_len = c->carry_off = 0;
// 3. Fresh audio, one carry-sized chunk at a time.
for (;;) {
size_t n = ringbuf_read(&c->out, c->carry, sizeof(c->carry), 0);
if (n == 0)
return true;
c->carry_len = n;
c->carry_off = 0;
int rc = send_all(c->fd, c->carry, c->carry_len, &c->carry_off);
if (rc < 0)
return false;
if (rc == 0)
return true; // retry the remainder on the next writable event
c->carry_len = c->carry_off = 0;
}
}
static void accept_client(streamserv_t* s)
{
struct sockaddr_in addr;
socklen_t len = sizeof(addr);
int fd = accept(s->listen_fd, (struct sockaddr*)&addr, &len);
if (fd < 0)
return;
set_nonblock(fd);
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
pthread_mutex_lock(&s->lock);
int max = s->cfg.max_clients > 0 && s->cfg.max_clients < MAX_CLIENTS_HARD
? s->cfg.max_clients
: MAX_CLIENTS_HARD;
client_t* slot = NULL;
for (int i = 0; i < max; i++) {
if (!s->clients[i].in_use) {
slot = &s->clients[i];
break;
}
}
if (!slot) {
pthread_mutex_unlock(&s->lock);
WARN("streamserv: refusing connection, %d client slots all in use", max);
close(fd);
return;
}
memset(slot, 0, sizeof(*slot));
if (!ringbuf_init(&slot->out, s->cfg.client_buffer)) {
pthread_mutex_unlock(&s->lock);
close(fd);
return;
}
slot->fd = fd;
slot->in_use = true;
snprintf(slot->peer, sizeof(slot->peer), "%s:%d", inet_ntoa(addr.sin_addr),
ntohs(addr.sin_port));
s->client_count++;
// In raw mode there is nothing to negotiate, so greet immediately.
if (!s->cfg.http_mode) {
char* out = NULL;
size_t out_len = 0;
if (s->cfg.hello && !s->cfg.hello(s->cfg.user, NULL, &out, &out_len)) {
drop_client(s, slot, "rejected by hello");
pthread_mutex_unlock(&s->lock);
return;
}
slot->pending = out;
slot->pending_len = out_len;
if (!out)
slot->greeted = true;
}
INFO("streamserv: client %s connected", slot->peer);
pthread_mutex_unlock(&s->lock);
}
// Caller must hold s->lock. Returns false if the client should be dropped.
static bool read_request(streamserv_t* s, client_t* c)
{
char buf[512];
ssize_t n = recv(c->fd, buf, sizeof(buf), 0);
if (n == 0)
return false;
if (n < 0)
return errno == EAGAIN || errno == EWOULDBLOCK;
if (c->request_len + (size_t)n >= sizeof(c->request))
return false; // absurd request, drop it
memcpy(c->request + c->request_len, buf, (size_t)n);
c->request_len += (size_t)n;
c->request[c->request_len] = '\0';
// Wait for the end of the HTTP header block.
if (!strstr(c->request, "\r\n\r\n") && !strstr(c->request, "\n\n"))
return true;
char* out = NULL;
size_t out_len = 0;
if (s->cfg.hello && !s->cfg.hello(s->cfg.user, c->request, &out, &out_len))
return false;
c->pending = out;
c->pending_len = out_len;
c->pending_off = 0;
if (!out)
c->greeted = true;
return true;
}
static void* serve_loop(void* arg)
{
streamserv_t* s = arg;
while (s->running) {
struct pollfd pfds[MAX_CLIENTS_HARD + 2];
client_t* mapped[MAX_CLIENTS_HARD + 2];
int n = 0;
pfds[n].fd = s->listen_fd;
pfds[n].events = POLLIN;
mapped[n] = NULL;
n++;
pfds[n].fd = s->wake_fd[0];
pfds[n].events = POLLIN;
mapped[n] = NULL;
n++;
pthread_mutex_lock(&s->lock);
for (int i = 0; i < MAX_CLIENTS_HARD; i++) {
client_t* c = &s->clients[i];
if (!c->in_use)
continue;
short events = 0;
if (s->cfg.http_mode && !c->pending && !c->greeted)
events |= POLLIN;
if (c->pending || c->carry_off < c->carry_len
|| (c->greeted && ringbuf_used(&c->out) > 0))
events |= POLLOUT;
// Always watch for hangup even when idle.
pfds[n].fd = c->fd;
pfds[n].events = events;
mapped[n] = c;
n++;
}
pthread_mutex_unlock(&s->lock);
// 20 ms keeps outbound audio moving even when no event fires.
int pr = poll(pfds, (nfds_t)n, 20);
if (pr < 0 && errno != EINTR) {
ERR("streamserv: poll failed: %s", strerror(errno));
break;
}
if (!s->running)
break;
if (pfds[0].revents & POLLIN)
accept_client(s);
if (pfds[1].revents & POLLIN) {
char drain[64];
while (read(s->wake_fd[0], drain, sizeof(drain)) > 0) { }
}
pthread_mutex_lock(&s->lock);
for (int i = 2; i < n; i++) {
client_t* c = mapped[i];
if (!c || !c->in_use)
continue;
if (pfds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
drop_client(s, c, "socket error or hangup");
continue;
}
if ((pfds[i].revents & POLLIN) && !c->greeted && !c->pending) {
if (!read_request(s, c)) {
drop_client(s, c, "bad or closed request");
continue;
}
}
if (!flush_client(s, c)) {
drop_client(s, c, "write failed");
continue;
}
}
pthread_mutex_unlock(&s->lock);
}
pthread_mutex_lock(&s->lock);
for (int i = 0; i < MAX_CLIENTS_HARD; i++)
drop_client(s, &s->clients[i], "server stopping");
pthread_mutex_unlock(&s->lock);
return NULL;
}
streamserv_t* streamserv_start(const streamserv_config_t* cfg, char* err, size_t errlen)
{
streamserv_t* s = calloc(1, sizeof(*s));
if (!s) {
snprintf(err, errlen, "out of memory");
return NULL;
}
s->cfg = *cfg;
if (s->cfg.client_buffer == 0)
s->cfg.client_buffer = 256 * 1024;
if (s->cfg.max_clients <= 0)
s->cfg.max_clients = 4;
s->listen_fd = -1;
s->wake_fd[0] = s->wake_fd[1] = -1;
pthread_mutex_init(&s->lock, NULL);
s->listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (s->listen_fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
goto fail;
}
int one = 1;
setsockopt(s->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)cfg->port);
addr.sin_addr.s_addr = (cfg->bind_addr && *cfg->bind_addr)
? inet_addr(cfg->bind_addr)
: htonl(INADDR_ANY);
if (bind(s->listen_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
snprintf(err, errlen, "bind(port %d): %s", cfg->port, strerror(errno));
goto fail;
}
if (listen(s->listen_fd, 4) != 0) {
snprintf(err, errlen, "listen(): %s", strerror(errno));
goto fail;
}
set_nonblock(s->listen_fd);
if (pipe(s->wake_fd) != 0) {
snprintf(err, errlen, "pipe(): %s", strerror(errno));
goto fail;
}
set_nonblock(s->wake_fd[0]);
set_nonblock(s->wake_fd[1]);
s->running = true;
if (pthread_create(&s->thread, NULL, serve_loop, s) != 0) {
snprintf(err, errlen, "pthread_create(): %s", strerror(errno));
s->running = false;
goto fail;
}
INFO("streamserv: listening on port %d (%s)", cfg->port, cfg->http_mode ? "http" : "raw");
return s;
fail:
if (s->listen_fd >= 0)
close(s->listen_fd);
if (s->wake_fd[0] >= 0)
close(s->wake_fd[0]);
if (s->wake_fd[1] >= 0)
close(s->wake_fd[1]);
pthread_mutex_destroy(&s->lock);
free(s);
return NULL;
}
void streamserv_stop(streamserv_t* s)
{
if (!s)
return;
if (s->running) {
s->running = false;
if (s->wake_fd[1] >= 0) {
char b = 1;
ssize_t ignored = write(s->wake_fd[1], &b, 1);
(void)ignored;
}
pthread_join(s->thread, NULL);
}
if (s->listen_fd >= 0)
close(s->listen_fd);
if (s->wake_fd[0] >= 0)
close(s->wake_fd[0]);
if (s->wake_fd[1] >= 0)
close(s->wake_fd[1]);
pthread_mutex_destroy(&s->lock);
free(s);
}
void streamserv_broadcast(streamserv_t* s, const void* data, size_t len)
{
if (!s || len == 0)
return;
pthread_mutex_lock(&s->lock);
for (int i = 0; i < MAX_CLIENTS_HARD; i++) {
client_t* c = &s->clients[i];
// Queue only once the greeting is out, or the stream would interleave
// with the header the client is still reading.
if (!c->in_use || !c->greeted)
continue;
s->dropped += ringbuf_write(&c->out, data, len);
}
bool any = s->client_count > 0;
pthread_mutex_unlock(&s->lock);
if (any && s->wake_fd[1] >= 0) {
char b = 1;
ssize_t ignored = write(s->wake_fd[1], &b, 1);
(void)ignored;
}
}
int streamserv_client_count(streamserv_t* s)
{
if (!s)
return 0;
pthread_mutex_lock(&s->lock);
int n = s->client_count;
pthread_mutex_unlock(&s->lock);
return n;
}
unsigned long long streamserv_dropped_bytes(streamserv_t* s)
{
if (!s)
return 0;
pthread_mutex_lock(&s->lock);
unsigned long long n = s->dropped;
pthread_mutex_unlock(&s->lock);
return n;
}
+37
View File
@@ -0,0 +1,37 @@
// A tiny broadcast TCP server.
//
// Backs both the raw-PCM and HTTP-WAV sinks: accept clients, optionally read
// and answer a request line, then push the same live byte stream to everyone
// connected. Each client gets its own ring buffer, so one slow reader drops
// its own audio instead of stalling the capture thread.
#pragma once
#include <stdbool.h>
#include <stddef.h>
typedef struct streamserv streamserv_t;
// Builds the greeting sent to a newly accepted client. `request` is the first
// line the client sent (HTTP mode only, otherwise NULL). Return false to
// reject the connection. On success, set `*out`/`*out_len` to a malloc'd
// buffer the server will send and then free.
typedef bool (*streamserv_hello_fn)(void* user, const char* request, char** out, size_t* out_len);
typedef struct {
int port;
const char* bind_addr; // NULL for 0.0.0.0
size_t client_buffer; // bytes of backlog tolerated per client
int max_clients;
bool http_mode; // wait for a request line before greeting
void* user;
streamserv_hello_fn hello;
} streamserv_config_t;
streamserv_t* streamserv_start(const streamserv_config_t* cfg, char* err, size_t errlen);
void streamserv_stop(streamserv_t* s);
// Non-blocking: queues `len` bytes for every connected client.
void streamserv_broadcast(streamserv_t* s, const void* data, size_t len);
int streamserv_client_count(streamserv_t* s);
unsigned long long streamserv_dropped_bytes(streamserv_t* s);
+489
View File
@@ -0,0 +1,489 @@
#include "service.h"
#include "capture/capture.h"
#include "common/json.h"
#include "common/log.h"
#include "config.h"
#include "engine.h"
#include "sinks/sink.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define STATUS_SUBSCRIPTION_KEY "status"
struct service {
LSHandle* handle;
GMainLoop* loop;
config_t* config;
engine_t* engine;
// Set from the engine thread, cleared by the idle handler on the main
// loop. Coalesces a burst of updates into a single subscription push.
gint status_pending;
};
// ---------------------------------------------------------------------------
// Reply helpers
// ---------------------------------------------------------------------------
static void reply_json(LSHandle* sh, LSMessage* msg, char* payload)
{
if (!payload)
return;
LSError lserror;
LSErrorInit(&lserror);
if (!LSMessageReply(sh, msg, payload, &lserror)) {
ERR("Luna reply failed: %s", lserror.message);
LSErrorFree(&lserror);
}
free(payload);
}
static void reply_error(LSHandle* sh, LSMessage* msg, const char* fmt, ...)
__attribute__((format(printf, 3, 4)));
static void reply_error(LSHandle* sh, LSMessage* msg, const char* fmt, ...)
{
char text[320];
va_list ap;
va_start(ap, fmt);
vsnprintf(text, sizeof(text), fmt, ap);
va_end(ap);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", false);
jw_str(&w, "errorText", text);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
}
static void reply_ok(LSHandle* sh, LSMessage* msg)
{
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
}
// Parses the incoming payload. Returns NULL for an empty or malformed body,
// which every handler treats as "no arguments".
static json_value_t* message_payload(LSMessage* msg)
{
const char* text = LSMessageGetPayload(msg);
if (!text || !*text)
return NULL;
return json_parse(text);
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
static char* build_status(service_t* s, bool subscribed)
{
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
if (subscribed)
jw_bool(&w, "subscribed", true);
engine_write_status(s->engine, &w);
jw_str(&w, "configPath", config_path(s->config));
jw_bool(&w, "configPersistent", config_is_persistent(s->config));
jw_obj_close(&w);
return jw_take(&w);
}
static void push_status(service_t* s)
{
char* payload = build_status(s, true);
if (!payload)
return;
LSError lserror;
LSErrorInit(&lserror);
if (!LSSubscriptionReply(s->handle, STATUS_SUBSCRIPTION_KEY, payload, &lserror)) {
// Not fatal: it usually just means nobody is listening any more.
DBG("Status push failed: %s", lserror.message);
LSErrorFree(&lserror);
}
free(payload);
}
static gboolean status_idle(gpointer user)
{
service_t* s = user;
g_atomic_int_set(&s->status_pending, 0);
push_status(s);
return G_SOURCE_REMOVE;
}
// Called on the engine thread; must not touch Luna directly.
static void on_engine_notify(void* user)
{
service_t* s = user;
if (g_atomic_int_compare_and_exchange(&s->status_pending, 0, 1))
g_idle_add(status_idle, s);
}
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
static bool method_start(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
// An optional settings patch can be sent with start, so the UI can hit
// "apply and start" in one call.
json_value_t* payload = message_payload(msg);
if (payload && payload->type == JSON_OBJECT && payload->u.object.count > 0) {
char err[256];
config_apply(s->config, payload, err, sizeof(err));
}
json_free(payload);
char err[256] = { 0 };
if (!engine_start(s->engine, config_root(s->config), err, sizeof(err))) {
reply_error(sh, msg, "%s", err);
return true;
}
reply_json(sh, msg, build_status(s, false));
return true;
}
static bool method_stop(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
engine_stop(s->engine);
reply_json(sh, msg, build_status(s, false));
return true;
}
static bool method_get_status(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
bool subscribed = false;
if (LSMessageIsSubscription(msg)) {
LSError lserror;
LSErrorInit(&lserror);
if (LSSubscriptionAdd(sh, STATUS_SUBSCRIPTION_KEY, msg, &lserror)) {
subscribed = true;
} else {
WARN("Cannot add subscriber: %s", lserror.message);
LSErrorFree(&lserror);
}
}
reply_json(sh, msg, build_status(s, subscribed));
return true;
}
// The autostart script calls this: the act of calling it is what launches the
// service, and the reply tells the caller what happened.
static bool method_is_running(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_bool(&w, "isRunning", engine_state(s->engine) == ENGINE_RUNNING);
jw_str(&w, "state", engine_state_name(engine_state(s->engine)));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_get_config(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_str(&w, "path", config_path(s->config));
jw_bool(&w, "persistent", config_is_persistent(s->config));
jw_value(&w, "settings", config_root(s->config));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_set_config(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_value_t* payload = message_payload(msg);
if (!payload || payload->type != JSON_OBJECT) {
json_free(payload);
reply_error(sh, msg, "expected an object of settings to change");
return true;
}
// Accept either the settings directly or wrapped in "settings", so the
// frontend can send whichever reads better at the call site.
const json_value_t* patch = json_get(payload, "settings");
if (!patch)
patch = payload;
char err[256] = { 0 };
bool saved = config_apply(s->config, patch, err, sizeof(err));
const char* level = json_str(config_root(s->config), "logLevel", "info");
if (strcmp(level, "debug") == 0)
log_set_level(LOG_DEBUG);
else if (strcmp(level, "warn") == 0)
log_set_level(LOG_WARN);
else if (strcmp(level, "error") == 0)
log_set_level(LOG_ERROR);
else
log_set_level(LOG_INFO);
json_free(payload);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_bool(&w, "saved", saved);
if (!saved)
jw_str(&w, "warning", err);
// Changing settings while capturing does nothing until the next start;
// say so rather than silently ignoring half of them.
jw_bool(&w, "restartRequired", engine_is_active(s->engine));
jw_value(&w, "settings", config_root(s->config));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
on_engine_notify(s);
return true;
}
static bool method_reset_config(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_value_t* defaults = config_defaults();
if (!defaults) {
reply_error(sh, msg, "cannot build default settings");
return true;
}
char err[256] = { 0 };
bool saved = config_apply(s->config, defaults, err, sizeof(err));
json_free(defaults);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_bool(&w, "saved", saved);
jw_value(&w, "settings", config_root(s->config));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_list_backends(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
size_t count = 0;
const capture_driver_t* const* drivers = capture_drivers(&count);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_arr_open(&w, "backends");
for (size_t i = 0; i < count; i++) {
jw_obj_open(&w, NULL);
jw_str(&w, "id", drivers[i]->id);
jw_str(&w, "name", drivers[i]->name);
jw_str(&w, "description", drivers[i]->description);
jw_bool(&w, "available", drivers[i]->available());
jw_obj_close(&w);
}
jw_arr_close(&w);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_list_sinks(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
size_t count = 0;
const sink_driver_t* const* drivers = sink_drivers(&count);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_arr_open(&w, "sinks");
for (size_t i = 0; i < count; i++) {
jw_obj_open(&w, NULL);
jw_str(&w, "id", drivers[i]->id);
jw_str(&w, "name", drivers[i]->name);
jw_str(&w, "description", drivers[i]->description);
jw_obj_close(&w);
}
jw_arr_close(&w);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
capture_write_diagnostics(&w);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_get_logs(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
json_value_t* payload = message_payload(msg);
bool clear = json_bool(payload, "clear", false);
json_free(payload);
char* text = log_dump_recent();
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_str(&w, "logs", text);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
free(text);
if (clear)
log_clear_recent();
return true;
}
// Deliberately last: stopping the service is how the UI gets the TV back to a
// clean state without a reboot.
static bool method_quit(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
reply_ok(sh, msg);
INFO("Quit requested over Luna");
engine_stop(s->engine);
g_main_loop_quit(s->loop);
return true;
}
static LSMethod s_methods[] = {
{ "start", method_start, LUNA_METHOD_FLAGS_NONE },
{ "stop", method_stop, LUNA_METHOD_FLAGS_NONE },
{ "getStatus", method_get_status, LUNA_METHOD_FLAGS_NONE },
{ "isRunning", method_is_running, LUNA_METHOD_FLAGS_NONE },
{ "getConfig", method_get_config, LUNA_METHOD_FLAGS_NONE },
{ "setConfig", method_set_config, LUNA_METHOD_FLAGS_NONE },
{ "resetConfig", method_reset_config, LUNA_METHOD_FLAGS_NONE },
{ "listBackends", method_list_backends, LUNA_METHOD_FLAGS_NONE },
{ "listSinks", method_list_sinks, LUNA_METHOD_FLAGS_NONE },
{ "getDiagnostics", method_get_diagnostics, LUNA_METHOD_FLAGS_NONE },
{ "getLogs", method_get_logs, LUNA_METHOD_FLAGS_NONE },
{ "quit", method_quit, LUNA_METHOD_FLAGS_NONE },
{ NULL, NULL, 0 },
};
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
service_t* service_create(LSHandle* handle, GMainLoop* loop)
{
service_t* s = calloc(1, sizeof(*s));
if (!s)
return NULL;
s->handle = handle;
s->loop = loop;
s->config = config_load();
if (!s->config) {
free(s);
return NULL;
}
const char* level = json_str(config_root(s->config), "logLevel", "info");
if (strcmp(level, "debug") == 0)
log_set_level(LOG_DEBUG);
else if (strcmp(level, "warn") == 0)
log_set_level(LOG_WARN);
else if (strcmp(level, "error") == 0)
log_set_level(LOG_ERROR);
s->engine = engine_create(on_engine_notify, s);
if (!s->engine) {
config_free(s->config);
free(s);
return NULL;
}
return s;
}
void service_destroy(service_t* s)
{
if (!s)
return;
engine_destroy(s->engine);
config_free(s->config);
free(s);
}
bool service_register(service_t* s, char* err, size_t errlen)
{
LSError lserror;
LSErrorInit(&lserror);
if (!LSRegisterCategory(s->handle, "/", s_methods, NULL, NULL, &lserror)) {
snprintf(err, errlen, "cannot register methods: %s", lserror.message);
LSErrorFree(&lserror);
return false;
}
if (!LSCategorySetData(s->handle, "/", s, &lserror)) {
snprintf(err, errlen, "cannot attach service data: %s", lserror.message);
LSErrorFree(&lserror);
return false;
}
return true;
}
void service_autostart(service_t* s)
{
if (!json_bool(config_root(s->config), "autoStart", false))
return;
char err[256] = { 0 };
INFO("Autostart is enabled; starting capture");
if (!engine_start(s->engine, config_root(s->config), err, sizeof(err)))
ERR("Autostart failed: %s", err);
}
+23
View File
@@ -0,0 +1,23 @@
// The Luna service surface.
//
// Everything the frontend can do goes through these methods on
// luna://org.webosbrew.audiocap.service. Status is a subscription, so the UI
// gets level meters and sink state pushed at ~10 Hz without polling.
#pragma once
#include <glib.h>
#include <luna-service2/lunaservice.h>
#include <stdbool.h>
#include <stddef.h>
typedef struct service service_t;
service_t* service_create(LSHandle* handle, GMainLoop* loop);
void service_destroy(service_t* s);
// Attaches the method table to the handle.
bool service_register(service_t* s, char* err, size_t errlen);
// Starts capture immediately when the saved settings ask for it. Called once
// after registration.
void service_autostart(service_t* s);
+53
View File
@@ -0,0 +1,53 @@
#include "sink.h"
#include "../common/log.h"
#include <stdio.h>
#include <string.h>
static const sink_driver_t* const s_drivers[] = {
&sink_driver_hyperhdr,
&sink_driver_hyperhdr_viz,
&sink_driver_udp,
&sink_driver_tcp,
&sink_driver_http,
};
const sink_driver_t* const* sink_drivers(size_t* count)
{
*count = sizeof(s_drivers) / sizeof(s_drivers[0]);
return s_drivers;
}
const sink_driver_t* sink_find(const char* id)
{
if (!id)
return NULL;
for (size_t i = 0; i < sizeof(s_drivers) / sizeof(s_drivers[0]); i++) {
if (strcmp(s_drivers[i]->id, id) == 0)
return s_drivers[i];
}
return NULL;
}
sink_t* sink_open(const char* id, const json_value_t* cfg, const audio_format_t* fmt,
char* err, size_t errlen)
{
const sink_driver_t* drv = sink_find(id);
if (!drv) {
snprintf(err, errlen, "unknown sink '%s'", id ? id : "(null)");
return NULL;
}
sink_t* s = drv->open(cfg, fmt, err, errlen);
if (s)
INFO("Sink '%s' started", drv->id);
return s;
}
void sink_close(sink_t* s)
{
if (!s)
return;
const char* id = s->driver ? s->driver->id : "?";
s->close(s);
INFO("Sink '%s' stopped", id);
}
+51
View File
@@ -0,0 +1,51 @@
// Output sink abstraction.
//
// Every enabled sink receives the same captured block from the engine thread,
// so several transports can run at once. Sinks must never block: anything
// that can stall (a TCP client, a dead HyperHDR host) buffers internally and
// drops old audio rather than holding up capture.
#pragma once
#include "../common/audio.h"
#include "../common/json.h"
#include "../dsp.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct sink sink_t;
typedef struct {
const char* id;
const char* name;
const char* description;
// `cfg` is the whole settings object; each sink reads the keys it owns.
sink_t* (*open)(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen);
} sink_driver_t;
struct sink {
const sink_driver_t* driver;
void* priv;
audio_format_t fmt;
void (*write)(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels);
// Appends sink-specific fields to an object the caller has already opened.
void (*status)(sink_t* s, json_writer_t* w);
void (*close)(sink_t* s);
};
// The drivers themselves, declared here so both the registry and each driver's
// own translation unit see one declaration.
extern const sink_driver_t sink_driver_hyperhdr;
extern const sink_driver_t sink_driver_hyperhdr_viz;
extern const sink_driver_t sink_driver_udp;
extern const sink_driver_t sink_driver_tcp;
extern const sink_driver_t sink_driver_http;
const sink_driver_t* sink_find(const char* id);
const sink_driver_t* const* sink_drivers(size_t* count);
sink_t* sink_open(const char* id, const json_value_t* cfg, const audio_format_t* fmt,
char* err, size_t errlen);
void sink_close(sink_t* s);
+219
View File
@@ -0,0 +1,219 @@
// HTTP audio stream served by the TV.
//
// The friendliest sink to test with, because everything already speaks HTTP:
//
// vlc http://<tv-ip>:4012/audio.wav
// ffplay http://<tv-ip>:4012/audio.wav
// mpv http://<tv-ip>:4012/audio.wav
//
// The WAV header declares an unknown length (0xFFFFFFFF sizes), which is the
// usual convention for endless streams and what every player above expects.
// Request /audio.raw instead to get headerless S16LE, for the odd consumer
// that would rather be told the format out of band.
#include "sink.h"
#include "../common/log.h"
#include "../net/streamserv.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define WAV_HEADER_BYTES 44
#define WAV_UNKNOWN_SIZE 0xFFFFFFFFu
typedef struct {
streamserv_t* server;
int port;
audio_format_t fmt;
} http_priv_t;
static void put_u32le(uint8_t* p, uint32_t v)
{
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)((v >> 8) & 0xFF);
p[2] = (uint8_t)((v >> 16) & 0xFF);
p[3] = (uint8_t)((v >> 24) & 0xFF);
}
static void put_u16le(uint8_t* p, uint16_t v)
{
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)((v >> 8) & 0xFF);
}
static void write_wav_header(uint8_t* h, const audio_format_t* fmt)
{
const uint16_t bits = 16;
const uint16_t channels = (uint16_t)fmt->channels;
const uint32_t rate = (uint32_t)fmt->rate;
const uint16_t block_align = (uint16_t)(channels * (bits / 8));
memcpy(h + 0, "RIFF", 4);
put_u32le(h + 4, WAV_UNKNOWN_SIZE);
memcpy(h + 8, "WAVE", 4);
memcpy(h + 12, "fmt ", 4);
put_u32le(h + 16, 16); // PCM fmt chunk length
put_u16le(h + 20, 1); // WAVE_FORMAT_PCM
put_u16le(h + 22, channels);
put_u32le(h + 24, rate);
put_u32le(h + 28, rate * block_align); // byte rate
put_u16le(h + 32, block_align);
put_u16le(h + 34, bits);
memcpy(h + 36, "data", 4);
put_u32le(h + 40, WAV_UNKNOWN_SIZE);
}
// Extracts the path from "GET /audio.wav HTTP/1.1". Returns false for anything
// that is not a GET, so the server drops the connection.
static bool parse_request(const char* request, char* path, size_t pathlen)
{
if (!request)
return false;
if (strncmp(request, "GET ", 4) != 0)
return false;
const char* p = request + 4;
while (*p == ' ')
p++;
size_t n = 0;
while (p[n] && p[n] != ' ' && p[n] != '\r' && p[n] != '\n' && n < pathlen - 1)
n++;
memcpy(path, p, n);
path[n] = '\0';
return n > 0;
}
static bool http_hello(void* user, const char* request, char** out, size_t* out_len)
{
http_priv_t* p = user;
char path[256];
if (!parse_request(request, path, sizeof(path))) {
DBG("HTTP sink: rejecting non-GET request");
return false;
}
// Browsers probe for these; answering them with an audio stream is worse
// than refusing outright.
if (strcmp(path, "/favicon.ico") == 0 || strcmp(path, "/robots.txt") == 0)
return false;
bool raw = strstr(path, ".raw") != NULL || strstr(path, ".pcm") != NULL;
char headers[512];
int hlen = snprintf(headers, sizeof(headers),
"HTTP/1.0 200 OK\r\n"
"Content-Type: %s\r\n"
"Cache-Control: no-cache, no-store\r\n"
"Pragma: no-cache\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Connection: close\r\n"
"\r\n",
raw ? "application/octet-stream" : "audio/wav");
if (hlen < 0 || hlen >= (int)sizeof(headers))
return false;
size_t total = (size_t)hlen + (raw ? 0 : WAV_HEADER_BYTES);
uint8_t* buf = malloc(total);
if (!buf)
return false;
memcpy(buf, headers, (size_t)hlen);
if (!raw)
write_wav_header(buf + hlen, &p->fmt);
INFO("HTTP sink: client requested %s (%s)", path, raw ? "raw S16LE" : "WAV");
*out = (char*)buf;
*out_len = total;
return true;
}
static void http_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
http_priv_t* p = s->priv;
streamserv_broadcast(p->server, pcm, (size_t)frames * (size_t)audio_frame_bytes(&s->fmt));
}
static void http_status(sink_t* s, json_writer_t* w)
{
http_priv_t* p = s->priv;
jw_int(w, "port", p->port);
jw_int(w, "clients", streamserv_client_count(p->server));
jw_int(w, "droppedBytes", (long long)streamserv_dropped_bytes(p->server));
jw_str(w, "wavPath", "/audio.wav");
jw_str(w, "rawPath", "/audio.raw");
}
static void http_close(sink_t* s)
{
http_priv_t* p = s->priv;
if (p) {
streamserv_stop(p->server);
free(p);
}
free(s);
}
static sink_t* http_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "http");
int port = json_int(sc, "port", 4012);
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid HTTP port %d", port);
return NULL;
}
http_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->port = port;
p->fmt = *fmt;
// Players buffer ahead; give them a couple of seconds of slack before we
// start dropping.
size_t buffer = (size_t)fmt->rate * (size_t)audio_frame_bytes(fmt) * 2;
streamserv_config_t scfg = {
.port = port,
.client_buffer = buffer,
.max_clients = json_int(sc, "maxClients", 4),
.http_mode = true,
.user = p,
.hello = http_hello,
};
p->server = streamserv_start(&scfg, err, errlen);
if (!p->server) {
free(p);
free(s);
return NULL;
}
s->driver = &sink_driver_http;
s->priv = p;
s->fmt = *fmt;
s->write = http_write;
s->status = http_status;
s->close = http_close;
INFO("HTTP sink: http://<tv-ip>:%d/audio.wav (%d Hz, %d ch)", port, fmt->rate, fmt->channels);
return s;
}
const sink_driver_t sink_driver_http = {
.id = "http",
.name = "HTTP WAV stream",
.description = "Open http://<tv-ip>:4012/audio.wav in VLC, ffplay or mpv. Easiest way to confirm capture works.",
.open = http_open,
};
+374
View File
@@ -0,0 +1,374 @@
// The main HyperHDR path: stream TV audio to the HyperHDR host as RTP/L16.
//
// HyperHDR has no network audio input. Its sound-reactive effects read a
// *local* capture device (a USB grabber's audio, a USB sound card, a virtual
// cable). So the job here is to get TV audio onto the HyperHDR machine in a
// form something can hand to a sound device. Two consumers understand what
// this sink emits:
//
// * host/lgtv-audiocap-receiver.py, which feeds an ALSA snd-aloop or a
// PulseAudio null sink that HyperHDR then selects as its input device.
// * PulseAudio's own module-rtp-recv, which needs no custom software at
// all when SAP announcements are enabled.
//
// RTP/L16 (RFC 3551) is the common denominator both understand: 16-bit
// big-endian PCM behind a 12-byte RTP header.
#include "sink.h"
#include "../common/log.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#define RTP_HEADER_BYTES 12
#define RTP_DYNAMIC_PAYLOAD_TYPE 96
#define RTP_MAX_PAYLOAD 1400 // stays under a 1500-byte Ethernet MTU
#define SAP_ADDRESS "224.0.0.56" // PulseAudio's default SAP group
#define SAP_PORT 9875
#define SAP_INTERVAL_SEC 5
typedef struct {
int fd;
struct sockaddr_in dest;
int sap_fd;
struct sockaddr_in sap_dest;
bool sap_enabled;
time_t sap_last_sent;
uint16_t sap_msg_id;
uint32_t local_addr; // network byte order, for the SDP origin line
char host[128];
int port;
bool multicast;
audio_format_t fmt;
int frames_per_packet;
uint16_t sequence;
uint32_t timestamp;
uint32_t ssrc;
// Assembled outside the send loop so each packet is one sendto().
uint8_t packet[RTP_HEADER_BYTES + RTP_MAX_PAYLOAD];
unsigned long long packets_sent;
unsigned long long bytes_sent;
unsigned long long send_errors;
bool warned;
} hh_priv_t;
// ---------------------------------------------------------------------------
// SAP / SDP announcements
// ---------------------------------------------------------------------------
// Builds the SDP body describing this stream. PulseAudio's module-rtp-recv
// creates a matching source purely from what it reads here.
static int build_sdp(hh_priv_t* p, char* out, size_t cap)
{
struct in_addr src = { .s_addr = p->local_addr };
char src_str[INET_ADDRSTRLEN];
snprintf(src_str, sizeof(src_str), "%s", inet_ntoa(src));
char conn[128];
if (p->multicast) {
// The /255 suffix is the TTL, required for multicast connection lines.
snprintf(conn, sizeof(conn), "IN IP4 %s/255", p->host);
} else {
snprintf(conn, sizeof(conn), "IN IP4 %s", p->host);
}
return snprintf(out, cap,
"v=0\r\n"
"o=- %u %u IN IP4 %s\r\n"
"s=LG TV Audio Cap\r\n"
"i=Audio captured from an LG webOS TV\r\n"
"c=%s\r\n"
"t=0 0\r\n"
"a=recvonly\r\n"
"m=audio %d RTP/AVP %d\r\n"
"a=rtpmap:%d L16/%d/%d\r\n"
"a=type:broadcast\r\n",
p->ssrc, p->ssrc, src_str, conn, p->port, RTP_DYNAMIC_PAYLOAD_TYPE,
RTP_DYNAMIC_PAYLOAD_TYPE, p->fmt.rate, p->fmt.channels);
}
static void send_sap(hh_priv_t* p)
{
if (!p->sap_enabled || p->sap_fd < 0)
return;
time_t now = time(NULL);
if (now - p->sap_last_sent < SAP_INTERVAL_SEC)
return;
p->sap_last_sent = now;
char sdp[512];
int sdp_len = build_sdp(p, sdp, sizeof(sdp));
if (sdp_len <= 0)
return;
// RFC 2974 header: version 1, IPv4 source, announcement, no auth.
uint8_t msg[768];
size_t n = 0;
msg[n++] = 0x20;
msg[n++] = 0x00; // no authentication data
msg[n++] = (uint8_t)(p->sap_msg_id >> 8);
msg[n++] = (uint8_t)(p->sap_msg_id & 0xFF);
memcpy(msg + n, &p->local_addr, 4);
n += 4;
static const char mime[] = "application/sdp";
memcpy(msg + n, mime, sizeof(mime)); // includes the NUL terminator
n += sizeof(mime);
if (n + (size_t)sdp_len > sizeof(msg))
return;
memcpy(msg + n, sdp, (size_t)sdp_len);
n += (size_t)sdp_len;
if (sendto(p->sap_fd, msg, n, 0, (struct sockaddr*)&p->sap_dest,
sizeof(p->sap_dest))
< 0) {
DBG("SAP announcement failed: %s", strerror(errno));
}
}
// ---------------------------------------------------------------------------
// Finds the source address the kernel would use to reach `dest`, without
// sending anything. Needed for the SDP origin and SAP source fields.
static uint32_t discover_local_address(const struct sockaddr_in* dest)
{
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0)
return htonl(INADDR_LOOPBACK);
uint32_t addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (const struct sockaddr*)dest, sizeof(*dest)) == 0) {
struct sockaddr_in local;
socklen_t len = sizeof(local);
if (getsockname(fd, (struct sockaddr*)&local, &len) == 0)
addr = local.sin_addr.s_addr;
}
close(fd);
return addr;
}
static void hh_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
hh_priv_t* p = s->priv;
const int ch = p->fmt.channels;
send_sap(p);
int offset = 0;
while (offset < frames) {
int chunk = frames - offset;
if (chunk > p->frames_per_packet)
chunk = p->frames_per_packet;
uint8_t* hdr = p->packet;
hdr[0] = 0x80; // version 2, no padding, no extension, no CSRCs
hdr[1] = RTP_DYNAMIC_PAYLOAD_TYPE; // marker bit clear
hdr[2] = (uint8_t)(p->sequence >> 8);
hdr[3] = (uint8_t)(p->sequence & 0xFF);
hdr[4] = (uint8_t)((p->timestamp >> 24) & 0xFF);
hdr[5] = (uint8_t)((p->timestamp >> 16) & 0xFF);
hdr[6] = (uint8_t)((p->timestamp >> 8) & 0xFF);
hdr[7] = (uint8_t)(p->timestamp & 0xFF);
hdr[8] = (uint8_t)((p->ssrc >> 24) & 0xFF);
hdr[9] = (uint8_t)((p->ssrc >> 16) & 0xFF);
hdr[10] = (uint8_t)((p->ssrc >> 8) & 0xFF);
hdr[11] = (uint8_t)(p->ssrc & 0xFF);
// L16 is network byte order; our capture format is little-endian.
const int16_t* src = pcm + (size_t)offset * (size_t)ch;
uint8_t* payload = p->packet + RTP_HEADER_BYTES;
int samples = chunk * ch;
for (int i = 0; i < samples; i++) {
uint16_t v = (uint16_t)src[i];
payload[i * 2 + 0] = (uint8_t)((v >> 8) & 0xFF);
payload[i * 2 + 1] = (uint8_t)(v & 0xFF);
}
size_t packet_len = RTP_HEADER_BYTES + (size_t)samples * 2;
ssize_t sent = sendto(p->fd, p->packet, packet_len, 0,
(struct sockaddr*)&p->dest, sizeof(p->dest));
if (sent < 0) {
p->send_errors++;
// A host that is off produces one error per packet; log the first
// and then stay quiet rather than filling the log ring.
if (!p->warned) {
WARN("HyperHDR RTP send to %s:%d failed: %s", p->host, p->port, strerror(errno));
p->warned = true;
}
} else {
p->packets_sent++;
p->bytes_sent += (unsigned long long)sent;
p->warned = false;
}
p->sequence++;
p->timestamp += (uint32_t)chunk; // RTP clock for L16 is the sample rate
offset += chunk;
}
}
static void hh_status(sink_t* s, json_writer_t* w)
{
hh_priv_t* p = s->priv;
jw_str(w, "target", p->host);
jw_int(w, "port", p->port);
jw_bool(w, "multicast", p->multicast);
jw_bool(w, "sapAnnounce", p->sap_enabled);
jw_int(w, "payloadType", RTP_DYNAMIC_PAYLOAD_TYPE);
jw_int(w, "framesPerPacket", p->frames_per_packet);
jw_int(w, "packetsSent", (long long)p->packets_sent);
jw_int(w, "bytesSent", (long long)p->bytes_sent);
jw_int(w, "sendErrors", (long long)p->send_errors);
}
static void hh_close(sink_t* s)
{
hh_priv_t* p = s->priv;
if (p) {
if (p->fd >= 0)
close(p->fd);
if (p->sap_fd >= 0)
close(p->sap_fd);
free(p);
}
free(s);
}
static sink_t* hh_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "hyperhdr");
const char* host = json_str(sc, "host", NULL);
int port = json_int(sc, "port", 5004);
bool multicast = json_bool(sc, "multicast", false);
bool sap = json_bool(sc, "sapAnnounce", false);
if (multicast && (!host || !*host))
host = SAP_ADDRESS;
if (!host || !*host) {
snprintf(err, errlen, "set the HyperHDR host address first");
return NULL;
}
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid HyperHDR audio port %d", port);
return NULL;
}
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
struct addrinfo* res = NULL;
int rc = getaddrinfo(host, portstr, &hints, &res);
if (rc != 0 || !res) {
snprintf(err, errlen, "cannot resolve '%s': %s", host, gai_strerror(rc));
return NULL;
}
hh_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
freeaddrinfo(res);
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->fd = -1;
p->sap_fd = -1;
memcpy(&p->dest, res->ai_addr, sizeof(struct sockaddr_in));
freeaddrinfo(res);
snprintf(p->host, sizeof(p->host), "%s", host);
p->port = port;
p->multicast = multicast;
p->fmt = *fmt;
p->fd = socket(AF_INET, SOCK_DGRAM, 0);
if (p->fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
free(p);
free(s);
return NULL;
}
if (multicast) {
unsigned char ttl = (unsigned char)json_int(sc, "multicastTtl", 4);
setsockopt(p->fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
int loop = 0;
setsockopt(p->fd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop));
}
// A larger send buffer absorbs bursts when the interface is busy.
int sndbuf = 256 * 1024;
setsockopt(p->fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
p->local_addr = discover_local_address(&p->dest);
// Derive an SSRC from the address and port so restarts keep the same
// identity; receivers treat an SSRC change as a brand new stream.
p->ssrc = ntohl(p->local_addr) ^ ((uint32_t)port << 16) ^ 0x4C475456u;
p->sap_msg_id = (uint16_t)(p->ssrc & 0xFFFF);
int frame_bytes = audio_frame_bytes(fmt);
p->frames_per_packet = RTP_MAX_PAYLOAD / frame_bytes;
if (p->frames_per_packet < 1)
p->frames_per_packet = 1;
if (sap) {
p->sap_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (p->sap_fd >= 0) {
unsigned char ttl = 4;
setsockopt(p->sap_fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
memset(&p->sap_dest, 0, sizeof(p->sap_dest));
p->sap_dest.sin_family = AF_INET;
p->sap_dest.sin_port = htons(SAP_PORT);
p->sap_dest.sin_addr.s_addr = inet_addr(SAP_ADDRESS);
p->sap_enabled = true;
} else {
WARN("Could not open SAP socket: %s", strerror(errno));
}
}
s->driver = &sink_driver_hyperhdr;
s->priv = p;
s->fmt = *fmt;
s->write = hh_write;
s->status = hh_status;
s->close = hh_close;
INFO("HyperHDR audio sink: RTP/L16 %d Hz %d ch to %s:%d (%s%s)", fmt->rate,
fmt->channels, host, port, multicast ? "multicast" : "unicast",
p->sap_enabled ? ", SAP on" : "");
return s;
}
const sink_driver_t sink_driver_hyperhdr = {
.id = "hyperhdr",
.name = "HyperHDR audio (RTP)",
.description = "Streams PCM to the HyperHDR host as RTP/L16 for its sound-reactive effects.",
.open = hh_open,
};
+416
View File
@@ -0,0 +1,416 @@
// On-TV visualiser: analyse the audio here, send HyperHDR a picture.
//
// The RTP sink needs a virtual sound device set up on the HyperHDR machine.
// This one needs nothing: the TV runs the FFT, renders a small RGB image and
// pushes it to HyperHDR's FlatBuffers image input (TCP 19400), exactly as a
// video grabber would. HyperHDR maps the image onto the LED layout it already
// has, so the lights react to sound with no host-side configuration.
//
// The trade-off is that HyperHDR's own audio effects are bypassed — the look
// is defined here instead. Use the RTP sink when you want HyperHDR's effects,
// this one when you want it to just work.
#include "sink.h"
#include "../common/log.h"
#include "../net/hyperion.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define VIZ_MAX_WIDTH 128
#define VIZ_MAX_HEIGHT 128
#define RECONNECT_INTERVAL_SEC 5
typedef enum {
VIZ_SPECTRUM, // bars across the width, hue by frequency
VIZ_LEVEL, // whole frame lit, colour mixed from band energy
VIZ_PULSE, // whole frame lit, brightness follows loudness only
} viz_mode_t;
typedef struct {
hyperion_target_t target; // resolved once at open; reconnects never do DNS
char host[128];
int port;
int priority;
viz_mode_t mode;
int width;
int height;
int fps;
float saturation;
float floor_level; // minimum brightness so the lights never go fully dark
hyperion_client_t* client;
time_t last_connect_attempt;
char last_error[192];
uint8_t* frame;
size_t frame_bytes;
struct timespec last_send;
dsp_levels_t latest;
bool have_levels;
unsigned long long frames_sent;
unsigned long long connect_failures;
} viz_priv_t;
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
static void hsv_to_rgb(float h, float s, float v, uint8_t* out)
{
h = fmodf(h, 1.0f);
if (h < 0.0f)
h += 1.0f;
float i = floorf(h * 6.0f);
float f = h * 6.0f - i;
float p = v * (1.0f - s);
float q = v * (1.0f - f * s);
float t = v * (1.0f - (1.0f - f) * s);
float r, g, b;
switch ((int)i % 6) {
case 0:
r = v, g = t, b = p;
break;
case 1:
r = q, g = v, b = p;
break;
case 2:
r = p, g = v, b = t;
break;
case 3:
r = p, g = q, b = v;
break;
case 4:
r = t, g = p, b = v;
break;
default:
r = v, g = p, b = q;
break;
}
out[0] = (uint8_t)(r * 255.0f + 0.5f);
out[1] = (uint8_t)(g * 255.0f + 0.5f);
out[2] = (uint8_t)(b * 255.0f + 0.5f);
}
static void fill_frame(viz_priv_t* p, const uint8_t rgb[3])
{
for (int i = 0; i < p->width * p->height; i++) {
p->frame[i * 3 + 0] = rgb[0];
p->frame[i * 3 + 1] = rgb[1];
p->frame[i * 3 + 2] = rgb[2];
}
}
// Bars rise from the bottom of the image, one group of columns per band, hue
// running red (bass) through to violet (treble).
static void render_spectrum(viz_priv_t* p, const dsp_levels_t* lv)
{
memset(p->frame, 0, p->frame_bytes);
for (int x = 0; x < p->width; x++) {
int band = x * DSP_BANDS / p->width;
if (band >= DSP_BANDS)
band = DSP_BANDS - 1;
float level = lv->bands[band];
if (level < p->floor_level)
level = p->floor_level;
int lit = (int)(level * (float)p->height + 0.5f);
if (lit > p->height)
lit = p->height;
// 0.0 (red) through 0.8 (violet); avoids wrapping back to red.
float hue = 0.8f * ((float)band / (float)(DSP_BANDS - 1));
uint8_t colour[3];
hsv_to_rgb(hue, p->saturation, level, colour);
for (int y = 0; y < lit; y++) {
int row = p->height - 1 - y; // row 0 is the top of the image
uint8_t* px = &p->frame[((size_t)row * p->width + x) * 3];
px[0] = colour[0];
px[1] = colour[1];
px[2] = colour[2];
}
}
}
// Splits the spectrum into three groups and treats them as an RGB mix, which
// gives bass-heavy content a warm cast and bright content a cool one.
static void render_level(viz_priv_t* p, const dsp_levels_t* lv)
{
float low = 0.0f, mid = 0.0f, high = 0.0f;
const int third = DSP_BANDS / 3;
for (int b = 0; b < DSP_BANDS; b++) {
if (b < third)
low += lv->bands[b];
else if (b < third * 2)
mid += lv->bands[b];
else
high += lv->bands[b];
}
low /= (float)third;
mid /= (float)third;
high /= (float)(DSP_BANDS - third * 2);
float strongest = low > mid ? low : mid;
if (high > strongest)
strongest = high;
if (strongest < 0.001f)
strongest = 0.001f;
float brightness = lv->rms * 3.0f; // RMS of music rarely exceeds ~0.33
if (brightness > 1.0f)
brightness = 1.0f;
if (brightness < p->floor_level)
brightness = p->floor_level;
uint8_t rgb[3] = {
(uint8_t)(low / strongest * brightness * 255.0f),
(uint8_t)(mid / strongest * brightness * 255.0f),
(uint8_t)(high / strongest * brightness * 255.0f),
};
fill_frame(p, rgb);
}
static void render_pulse(viz_priv_t* p, const dsp_levels_t* lv)
{
float brightness = lv->peak;
if (brightness < p->floor_level)
brightness = p->floor_level;
uint8_t rgb[3];
// Warm white that shifts slightly warmer as it gets quieter.
hsv_to_rgb(0.09f, p->saturation * 0.5f, brightness, rgb);
fill_frame(p, rgb);
}
// ---------------------------------------------------------------------------
// Never blocks: the connect is started here and completed by hyperion_pump()
// on later blocks, because this runs on the capture thread.
static bool ensure_connected(viz_priv_t* p)
{
if (p->client)
return true;
time_t now = time(NULL);
if (now - p->last_connect_attempt < RECONNECT_INTERVAL_SEC)
return false;
p->last_connect_attempt = now;
char err[192] = { 0 };
p->client = hyperion_connect(&p->target, "lgtv-audio-cap", p->priority, err, sizeof(err));
if (!p->client) {
p->connect_failures++;
// Only log when the message changes, so an unreachable host does not
// spam one line every five seconds forever.
if (strcmp(err, p->last_error) != 0) {
WARN("HyperHDR visualiser: %s", err);
snprintf(p->last_error, sizeof(p->last_error), "%s", err);
}
return false;
}
p->last_error[0] = '\0';
return true;
}
static bool frame_due(viz_priv_t* p)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
double elapsed = (double)(now.tv_sec - p->last_send.tv_sec)
+ (double)(now.tv_nsec - p->last_send.tv_nsec) / 1e9;
if (elapsed < 1.0 / (double)p->fps)
return false;
p->last_send = now;
return true;
}
static void viz_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)pcm;
(void)frames;
viz_priv_t* p = s->priv;
if (levels) {
p->latest = *levels;
p->have_levels = true;
}
if (!ensure_connected(p))
return;
if (!hyperion_pump(p->client)) {
const char* why = hyperion_last_error(p->client);
WARN("HyperHDR visualiser disconnected: %s", why ? why : "unknown");
snprintf(p->last_error, sizeof(p->last_error), "%s", why ? why : "disconnected");
hyperion_disconnect(p->client);
p->client = NULL;
return;
}
// Capture blocks arrive far faster than the LEDs need updating; rate-limit
// so we are not shipping an image every 10 ms over the network.
if (!p->have_levels || !frame_due(p))
return;
switch (p->mode) {
case VIZ_SPECTRUM:
render_spectrum(p, &p->latest);
break;
case VIZ_LEVEL:
render_level(p, &p->latest);
break;
case VIZ_PULSE:
render_pulse(p, &p->latest);
break;
}
if (!hyperion_send_image(p->client, p->frame, p->width, p->height)) {
const char* why = hyperion_last_error(p->client);
WARN("HyperHDR visualiser send failed: %s", why ? why : "unknown");
hyperion_disconnect(p->client);
p->client = NULL;
return;
}
if (hyperion_registered(p->client))
p->frames_sent++;
}
static const char* mode_name(viz_mode_t m)
{
switch (m) {
case VIZ_SPECTRUM:
return "spectrum";
case VIZ_LEVEL:
return "level";
default:
return "pulse";
}
}
static void viz_status(sink_t* s, json_writer_t* w)
{
viz_priv_t* p = s->priv;
jw_str(w, "target", p->host);
jw_int(w, "port", p->port);
jw_int(w, "priority", p->priority);
jw_str(w, "mode", mode_name(p->mode));
jw_int(w, "width", p->width);
jw_int(w, "height", p->height);
jw_int(w, "fps", p->fps);
jw_bool(w, "connected", hyperion_connected(p->client));
jw_bool(w, "registered", p->client && hyperion_registered(p->client));
jw_int(w, "framesSent", (long long)p->frames_sent);
jw_int(w, "connectFailures", (long long)p->connect_failures);
if (p->last_error[0])
jw_str(w, "lastError", p->last_error);
else
jw_null(w, "lastError");
}
static void viz_close(sink_t* s)
{
viz_priv_t* p = s->priv;
if (p) {
if (p->client)
hyperion_disconnect(p->client);
free(p->frame);
free(p);
}
free(s);
}
static int clamp_int(int v, int lo, int hi)
{
return v < lo ? lo : (v > hi ? hi : v);
}
static sink_t* viz_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "hyperhdrViz");
const char* host = json_str(sc, "host", NULL);
// Fall back to the audio sink's host so the common case needs one address.
if (!host || !*host)
host = json_str(json_get(cfg, "hyperhdr"), "host", NULL);
if (!host || !*host) {
snprintf(err, errlen, "set the HyperHDR host address first");
return NULL;
}
viz_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
snprintf(p->host, sizeof(p->host), "%s", host);
p->port = clamp_int(json_int(sc, "port", 19400), 1, 65535);
if (!hyperion_resolve(p->host, p->port, &p->target, err, errlen)) {
free(p);
free(s);
return NULL;
}
p->priority = clamp_int(json_int(sc, "priority", 150), 1, 253);
p->width = clamp_int(json_int(sc, "width", 64), 4, VIZ_MAX_WIDTH);
p->height = clamp_int(json_int(sc, "height", 36), 4, VIZ_MAX_HEIGHT);
p->fps = clamp_int(json_int(sc, "fps", 30), 1, 60);
p->saturation = (float)json_num(sc, "saturation", 1.0);
p->floor_level = (float)json_num(sc, "minBrightness", 0.02);
const char* mode = json_str(sc, "mode", "spectrum");
if (strcmp(mode, "level") == 0)
p->mode = VIZ_LEVEL;
else if (strcmp(mode, "pulse") == 0)
p->mode = VIZ_PULSE;
else
p->mode = VIZ_SPECTRUM;
p->frame_bytes = (size_t)p->width * (size_t)p->height * 3;
p->frame = calloc(1, p->frame_bytes);
if (!p->frame) {
free(p);
free(s);
snprintf(err, errlen, "out of memory allocating %dx%d frame", p->width, p->height);
return NULL;
}
clock_gettime(CLOCK_MONOTONIC, &p->last_send);
s->driver = &sink_driver_hyperhdr_viz;
s->priv = p;
s->fmt = *fmt;
s->write = viz_write;
s->status = viz_status;
s->close = viz_close;
INFO("HyperHDR visualiser sink: %s:%d mode=%s %dx%d @%d fps priority=%d", p->host,
p->port, mode_name(p->mode), p->width, p->height, p->fps, p->priority);
return s;
}
const sink_driver_t sink_driver_hyperhdr_viz = {
.id = "hyperhdrViz",
.name = "HyperHDR visualiser (FlatBuffers)",
.description = "Runs the spectrum analysis on the TV and pushes images to HyperHDR. No host setup.",
.open = viz_open,
};
+105
View File
@@ -0,0 +1,105 @@
// Raw PCM over TCP, with the TV acting as the server.
//
// The TV listens and whoever connects gets the live stream. Useful when the
// receiver cannot be given a fixed port to listen on, or when you want
// lossless delivery and can tolerate the buffering that implies:
//
// nc <tv-ip> 4011 | aplay -f S16_LE -r 48000 -c 2
#include "sink.h"
#include "../common/log.h"
#include "../net/streamserv.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
streamserv_t* server;
int port;
audio_format_t fmt;
} tcp_priv_t;
static void tcp_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
tcp_priv_t* p = s->priv;
streamserv_broadcast(p->server, pcm, (size_t)frames * (size_t)audio_frame_bytes(&s->fmt));
}
static void tcp_status(sink_t* s, json_writer_t* w)
{
tcp_priv_t* p = s->priv;
jw_int(w, "port", p->port);
jw_int(w, "clients", streamserv_client_count(p->server));
jw_int(w, "droppedBytes", (long long)streamserv_dropped_bytes(p->server));
jw_str(w, "format", "S16_LE interleaved");
}
static void tcp_close(sink_t* s)
{
tcp_priv_t* p = s->priv;
if (p) {
streamserv_stop(p->server);
free(p);
}
free(s);
}
static sink_t* tcp_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "tcp");
int port = json_int(sc, "port", 4011);
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid TCP port %d", port);
return NULL;
}
tcp_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
// Roughly one second of audio before a stalled client starts losing data.
size_t buffer = (size_t)fmt->rate * (size_t)audio_frame_bytes(fmt);
streamserv_config_t scfg = {
.port = port,
.client_buffer = buffer,
.max_clients = json_int(sc, "maxClients", 4),
.http_mode = false,
.user = NULL,
.hello = NULL,
};
p->server = streamserv_start(&scfg, err, errlen);
if (!p->server) {
free(p);
free(s);
return NULL;
}
p->port = port;
p->fmt = *fmt;
s->driver = &sink_driver_tcp;
s->priv = p;
s->fmt = *fmt;
s->write = tcp_write;
s->status = tcp_status;
s->close = tcp_close;
INFO("TCP sink: serving raw S16LE %d Hz %d ch on port %d", fmt->rate, fmt->channels, port);
return s;
}
const sink_driver_t sink_driver_tcp = {
.id = "tcp",
.name = "Raw PCM over TCP",
.description = "The TV listens; connect to it to pull a lossless S16LE stream.",
.open = tcp_open,
};
+176
View File
@@ -0,0 +1,176 @@
// Raw PCM over UDP.
//
// No framing, no headers: just little-endian S16 samples straight out of the
// capture buffer. Deliberately the dumbest possible transport, so anything
// can consume it:
//
// nc -u -l 4010 | aplay -f S16_LE -r 48000 -c 2
// ffplay -f s16le -ar 48000 -ac 2 udp://0.0.0.0:4010
//
// Use the hyperhdr sink instead when you want something to reconstruct
// timing; without RTP sequence numbers a receiver cannot detect loss.
#include "sink.h"
#include "../common/log.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define UDP_MAX_PAYLOAD 1400
typedef struct {
int fd;
struct sockaddr_in dest;
char host[128];
int port;
int frames_per_packet;
unsigned long long packets_sent;
unsigned long long bytes_sent;
unsigned long long send_errors;
bool warned;
} udp_priv_t;
static void udp_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
udp_priv_t* p = s->priv;
const int frame_bytes = audio_frame_bytes(&s->fmt);
int offset = 0;
while (offset < frames) {
int chunk = frames - offset;
if (chunk > p->frames_per_packet)
chunk = p->frames_per_packet;
const void* src = (const uint8_t*)pcm + (size_t)offset * (size_t)frame_bytes;
size_t len = (size_t)chunk * (size_t)frame_bytes;
if (sendto(p->fd, src, len, 0, (struct sockaddr*)&p->dest, sizeof(p->dest)) < 0) {
p->send_errors++;
if (!p->warned) {
WARN("UDP send to %s:%d failed: %s", p->host, p->port, strerror(errno));
p->warned = true;
}
} else {
p->packets_sent++;
p->bytes_sent += len;
p->warned = false;
}
offset += chunk;
}
}
static void udp_status(sink_t* s, json_writer_t* w)
{
udp_priv_t* p = s->priv;
jw_str(w, "target", p->host);
jw_int(w, "port", p->port);
jw_int(w, "framesPerPacket", p->frames_per_packet);
jw_int(w, "packetsSent", (long long)p->packets_sent);
jw_int(w, "bytesSent", (long long)p->bytes_sent);
jw_int(w, "sendErrors", (long long)p->send_errors);
}
static void udp_close(sink_t* s)
{
udp_priv_t* p = s->priv;
if (p) {
if (p->fd >= 0)
close(p->fd);
free(p);
}
free(s);
}
static sink_t* udp_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "udp");
const char* host = json_str(sc, "host", NULL);
int port = json_int(sc, "port", 4010);
if (!host || !*host) {
snprintf(err, errlen, "set a destination host for the UDP sink");
return NULL;
}
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid UDP port %d", port);
return NULL;
}
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
struct addrinfo* res = NULL;
int rc = getaddrinfo(host, portstr, &hints, &res);
if (rc != 0 || !res) {
snprintf(err, errlen, "cannot resolve '%s': %s", host, gai_strerror(rc));
return NULL;
}
udp_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
freeaddrinfo(res);
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
memcpy(&p->dest, res->ai_addr, sizeof(struct sockaddr_in));
freeaddrinfo(res);
p->fd = socket(AF_INET, SOCK_DGRAM, 0);
if (p->fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
free(p);
free(s);
return NULL;
}
// Multicast and broadcast destinations both need explicit opt-in.
uint32_t addr = ntohl(p->dest.sin_addr.s_addr);
if ((addr & 0xF0000000u) == 0xE0000000u) {
unsigned char ttl = (unsigned char)json_int(sc, "multicastTtl", 4);
setsockopt(p->fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
} else if (addr == 0xFFFFFFFFu) {
int on = 1;
setsockopt(p->fd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
}
snprintf(p->host, sizeof(p->host), "%s", host);
p->port = port;
p->frames_per_packet = UDP_MAX_PAYLOAD / audio_frame_bytes(fmt);
if (p->frames_per_packet < 1)
p->frames_per_packet = 1;
s->driver = &sink_driver_udp;
s->priv = p;
s->fmt = *fmt;
s->write = udp_write;
s->status = udp_status;
s->close = udp_close;
INFO("UDP sink: raw S16LE %d Hz %d ch to %s:%d", fmt->rate, fmt->channels, host, port);
return s;
}
const sink_driver_t sink_driver_udp = {
.id = "udp",
.name = "Raw PCM over UDP",
.description = "Fire-and-forget S16LE datagrams to any host. Lowest latency, no error recovery.",
.open = udp_open,
};
+27
View File
@@ -0,0 +1,27 @@
{
"name": "lgtv-audio-cap",
"version": "1.0.0",
"private": true,
"description": "Captures audio on an LG webOS 5/6 TV and streams it out — HyperHDR first, plus raw UDP, TCP and HTTP.",
"keywords": ["webos", "lgtv", "hyperhdr", "hyperion", "audio", "webosbrew"],
"license": "MIT",
"scripts": {
"build": "tools/build.sh",
"native": "tools/build.sh native",
"stage": "tools/build.sh stage",
"package": "tools/build.sh package",
"deploy": "tools/build.sh install && tools/build.sh launch",
"install-tv": "tools/build.sh install",
"launch": "tools/build.sh launch",
"logs": "tools/build.sh logs",
"clean": "tools/build.sh clean",
"test": "test/run-tests.sh",
"assets": "python3 tools/make-assets.py",
"manifest": "python3 tools/make-manifest.py",
"serve": "python3 -m http.server 8000 --directory frontend"
},
"devDependencies": {
"@webosose/ares-cli": "^3.0.0",
"jsdom": "^24.0.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Symlinked into /var/lib/webosbrew/init.d/ by the app's "Start on boot"
# toggle. The Homebrew Channel runs everything in that directory at boot.
#
# Calling any method on the service is enough to launch it; the service then
# reads its own autoStart setting and starts capturing if it is enabled. Run in
# the background so a slow bus does not hold up the rest of the boot scripts.
luna-send -n 1 -f luna://org.webosbrew.audiocap.service/isRunning '{}' &
exit 0
+6
View File
@@ -0,0 +1,6 @@
{
"id": "org.webosbrew.audiocap.service",
"version": "1.0.0",
"description": "Captures TV audio and streams it to HyperHDR and other receivers",
"main": "audiocap-service"
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "org.webosbrew.audiocap.service",
"description": "Captures TV audio and streams it to HyperHDR and other receivers",
"engine": "native",
"executable": "audiocap-service",
"services": [
{
"name": "org.webosbrew.audiocap.service",
"description": "Audio Cap capture service"
}
]
}
+274
View File
@@ -0,0 +1,274 @@
// End-to-end check of the capture pipeline, minus webOS.
//
// Runs the engine with the `tone` backend feeding the TCP and HTTP sinks, then
// connects to both as a client and verifies that real audio comes out with the
// right framing. Everything here works identically on the TV; only the Luna
// layer is missing, so this exercises capture -> DSP -> fan-out -> socket.
//
// Build and run: test/run-tests.sh
#include "common/log.h"
#include "engine.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#define HTTP_PORT 45812
#define TCP_PORT 45811
static int failures = 0;
static void check(bool ok, const char* what)
{
printf("%s %s\n", ok ? " ok " : " FAIL", what);
if (!ok)
failures++;
}
static void sleep_ms(int ms)
{
struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L };
nanosleep(&ts, NULL);
}
static int connect_local(int port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
return -1;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
close(fd);
return -1;
}
struct timeval tv = { .tv_sec = 3, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
return fd;
}
// Reads exactly `len` bytes or fails.
static bool read_exact(int fd, void* dst, size_t len)
{
size_t got = 0;
while (got < len) {
ssize_t n = read(fd, (char*)dst + got, len - got);
if (n <= 0)
return false;
got += (size_t)n;
}
return true;
}
static uint32_t rd_u32le(const uint8_t* p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static uint16_t rd_u16le(const uint8_t* p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
// True if the buffer contains something other than digital silence.
static bool has_signal(const int16_t* pcm, size_t samples)
{
for (size_t i = 0; i < samples; i++) {
if (pcm[i] > 500 || pcm[i] < -500)
return true;
}
return false;
}
static void test_http(void)
{
printf("HTTP WAV sink\n");
int fd = connect_local(HTTP_PORT);
if (fd < 0) {
check(false, "connect to the HTTP sink");
return;
}
const char* req = "GET /audio.wav HTTP/1.1\r\nHost: tv\r\n\r\n";
check(write(fd, req, strlen(req)) == (ssize_t)strlen(req), "send the request");
// Headers end at the blank line; read a byte at a time so we do not eat
// into the WAV header that follows.
char headers[1024];
size_t hlen = 0;
bool complete = false;
while (hlen < sizeof(headers) - 1) {
if (!read_exact(fd, headers + hlen, 1))
break;
hlen++;
headers[hlen] = '\0';
if (hlen >= 4 && memcmp(headers + hlen - 4, "\r\n\r\n", 4) == 0) {
complete = true;
break;
}
}
check(complete, "receive complete HTTP headers");
check(strstr(headers, "200 OK") != NULL, "status line is 200 OK");
check(strstr(headers, "Content-Type: audio/wav") != NULL, "content type is audio/wav");
uint8_t wav[44];
if (!read_exact(fd, wav, sizeof(wav))) {
check(false, "receive the WAV header");
close(fd);
return;
}
check(memcmp(wav, "RIFF", 4) == 0, "RIFF magic");
check(memcmp(wav + 8, "WAVE", 4) == 0, "WAVE magic");
check(memcmp(wav + 12, "fmt ", 4) == 0, "fmt chunk");
check(rd_u32le(wav + 16) == 16, "fmt chunk length is 16");
check(rd_u16le(wav + 20) == 1, "format is PCM");
check(rd_u16le(wav + 22) == 2, "2 channels");
check(rd_u32le(wav + 24) == 48000, "48000 Hz");
check(rd_u32le(wav + 28) == 48000 * 4, "byte rate matches");
check(rd_u16le(wav + 32) == 4, "block align is 4");
check(rd_u16le(wav + 34) == 16, "16 bits per sample");
check(memcmp(wav + 36, "data", 4) == 0, "data chunk");
check(rd_u32le(wav + 4) == 0xFFFFFFFFu, "RIFF size is the unknown-length marker");
check(rd_u32le(wav + 40) == 0xFFFFFFFFu, "data size is the unknown-length marker");
int16_t pcm[4096];
bool got = read_exact(fd, pcm, sizeof(pcm));
check(got, "receive 16 KB of audio");
check(got && has_signal(pcm, sizeof(pcm) / sizeof(pcm[0])), "audio is not silence");
close(fd);
}
static void test_tcp(void)
{
printf("Raw PCM TCP sink\n");
int fd = connect_local(TCP_PORT);
if (fd < 0) {
check(false, "connect to the TCP sink");
return;
}
int16_t pcm[4096];
bool got = read_exact(fd, pcm, sizeof(pcm));
check(got, "receive 16 KB of audio");
check(got && has_signal(pcm, sizeof(pcm) / sizeof(pcm[0])), "audio is not silence");
close(fd);
}
static void test_status(engine_t* e)
{
printf("Status document\n");
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
engine_write_status(e, &w);
jw_obj_close(&w);
char* text = jw_take(&w);
check(text != NULL, "status serialises");
if (!text)
return;
json_value_t* v = json_parse(text);
check(v != NULL, "status is valid JSON");
if (v) {
check(strcmp(json_str(v, "state", ""), "running") == 0, "state is running");
const json_value_t* cap = json_get(v, "capture");
check(strcmp(json_str(cap, "backend", ""), "tone") == 0, "backend is the tone generator");
check(json_int(cap, "rate", 0) == 48000, "reports 48000 Hz");
check(json_int(cap, "frames", 0) > 0, "frames have been captured");
const json_value_t* levels = json_get(v, "levels");
check(json_num(levels, "peak", 0) > 0.05, "peak level is non-trivial");
check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported");
const json_value_t* sinks = json_get(v, "sinks");
check(json_len(sinks) == 2, "two sinks reported");
for (size_t i = 0; i < json_len(sinks); i++) {
const json_value_t* s = json_at(sinks, i);
char label[64];
snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?"));
check(json_bool(s, "ok", false), label);
}
json_free(v);
}
free(text);
}
int main(void)
{
log_init(LOG_WARN); // keep the test output readable
char cfg_text[512];
snprintf(cfg_text, sizeof(cfg_text),
"{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2},"
"\"sinks\":[\"tcp\",\"http\"],"
"\"tcp\":{\"port\":%d},"
"\"http\":{\"port\":%d}}",
TCP_PORT, HTTP_PORT);
json_value_t* cfg = json_parse(cfg_text);
if (!cfg) {
fprintf(stderr, "test bug: config does not parse\n");
return 1;
}
engine_t* e = engine_create(NULL, NULL);
if (!e) {
fprintf(stderr, "cannot create the engine\n");
return 1;
}
char err[256] = { 0 };
printf("Engine\n");
if (!engine_start(e, cfg, err, sizeof(err))) {
printf(" FAIL start: %s\n", err);
return 1;
}
// Startup happens on the engine thread; wait for it to settle.
for (int i = 0; i < 100 && engine_state(e) == ENGINE_STARTING; i++)
sleep_ms(50);
check(engine_state(e) == ENGINE_RUNNING, "engine reaches the running state");
if (engine_state(e) != ENGINE_RUNNING) {
engine_destroy(e);
json_free(cfg);
return 1;
}
test_tcp();
test_http();
test_status(e);
engine_stop(e);
check(engine_state(e) == ENGINE_STOPPED, "engine stops cleanly");
engine_destroy(e);
json_free(cfg);
printf("\n%s\n", failures ? "FAILED" : "All engine checks passed.");
return failures ? 1 : 0;
}
+57
View File
@@ -0,0 +1,57 @@
// Emits the exact bytes hyperion.c would put on the wire, so the FlatBuffers
// encoding can be checked against the reference implementation.
//
// ./fb_dump register out.bin
// ./fb_dump image out.bin
#include "../native/src/net/hyperion.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char** argv)
{
if (argc < 3) {
fprintf(stderr, "usage: %s <register|image> <output-file>\n", argv[0]);
return 2;
}
size_t len = 0;
uint8_t* buf = NULL;
if (strcmp(argv[1], "register") == 0) {
buf = hyperion_build_register("lgtv-audio-cap", 150, &len);
} else if (strcmp(argv[1], "image") == 0) {
// 4x2 RGB gradient: distinctive enough that a wrong vector offset or
// a swapped width/height shows up immediately.
const int w = 4, h = 2;
uint8_t rgb[4 * 2 * 3];
for (int i = 0; i < w * h; i++) {
rgb[i * 3 + 0] = (uint8_t)(i * 10);
rgb[i * 3 + 1] = (uint8_t)(i * 10 + 1);
rgb[i * 3 + 2] = (uint8_t)(i * 10 + 2);
}
buf = hyperion_build_image(rgb, w, h, &len);
} else {
fprintf(stderr, "unknown message '%s'\n", argv[1]);
return 2;
}
if (!buf) {
fprintf(stderr, "build failed\n");
return 1;
}
FILE* f = fopen(argv[2], "wb");
if (!f) {
perror("fopen");
free(buf);
return 1;
}
fwrite(buf, 1, len, f);
fclose(f);
free(buf);
fprintf(stderr, "wrote %zu bytes to %s\n", len, argv[2]);
return 0;
}
+77
View File
@@ -0,0 +1,77 @@
// Harness for verify_rtp.py: opens the real HyperHDR RTP sink, points it at a
// port on the loopback and writes a deterministic ramp through it. The Python
// side receives the datagrams and checks that what comes out of the wire is
// exactly what went in.
//
// rtp_send <port> <blocks> [frames-per-block] [sap]
#include "../native/src/common/audio.h"
#include "../native/src/common/json.h"
#include "../native/src/common/log.h"
#include "../native/src/dsp.h"
#include "../native/src/sinks/sink.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Must match sample_at() in verify_rtp.py.
static int16_t sample_at(long index)
{
return (int16_t)((index * 251) % 65536 - 32768);
}
int main(int argc, char** argv)
{
if (argc < 3) {
fprintf(stderr, "usage: %s <port> <blocks> [frames]\n", argv[0]);
return 2;
}
int port = atoi(argv[1]);
int blocks = atoi(argv[2]);
int frames = argc > 3 ? atoi(argv[3]) : AUDIO_BLOCK_FRAMES;
bool sap = argc > 4 && strcmp(argv[4], "sap") == 0;
log_set_level(LOG_ERROR);
char cfg_text[256];
snprintf(cfg_text, sizeof(cfg_text),
"{\"hyperhdr\":{\"host\":\"127.0.0.1\",\"port\":%d,"
"\"multicast\":false,\"sapAnnounce\":%s}}",
port, sap ? "true" : "false");
json_value_t* cfg = json_parse(cfg_text);
if (!cfg) {
fprintf(stderr, "bad config\n");
return 1;
}
audio_format_t fmt = { .rate = 48000, .channels = 2 };
char err[256] = { 0 };
sink_t* sink = sink_open("hyperhdr", cfg, &fmt, err, sizeof(err));
if (!sink) {
fprintf(stderr, "sink_open failed: %s\n", err);
json_free(cfg);
return 1;
}
int16_t* pcm = malloc((size_t)frames * fmt.channels * sizeof(int16_t));
dsp_levels_t levels;
memset(&levels, 0, sizeof(levels));
long index = 0;
for (int b = 0; b < blocks; b++) {
for (int i = 0; i < frames * fmt.channels; i++)
pcm[i] = sample_at(index++);
sink->write(sink, pcm, frames, &levels);
}
printf("%ld\n", index); // samples written, for the receiver to expect
fflush(stdout);
free(pcm);
sink_close(sink);
json_free(cfg);
return 0;
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Host-side tests. These build the parts of the service that are not tied to
# webOS with the system compiler and run them, so the risky code (FlatBuffers
# encoding, the capture pipeline, socket framing) is verified before anything
# is ever copied to a TV.
set -euo pipefail
cd "$(dirname "$0")/.."
CC="${CC:-cc}"
# verify_rtp.py imports the host receiver by path; without this it would leave a
# __pycache__ next to it.
export PYTHONDONTWRITEBYTECODE=1
OUT=$(mktemp -d)
trap 'rm -rf "$OUT"' EXIT
CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -O1 -g)
SOURCES=(
native/src/engine.c
native/src/config.c
native/src/dsp.c
native/src/common/log.c
native/src/common/json.c
native/src/common/ringbuf.c
native/src/capture/capture.c
native/src/capture/cap_pulse.c
native/src/capture/cap_alsa.c
native/src/capture/cap_exec.c
native/src/capture/cap_tone.c
native/src/net/flatbuf.c
native/src/net/hyperion.c
native/src/net/streamserv.c
native/src/sinks/sink.c
native/src/sinks/sink_hyperhdr.c
native/src/sinks/sink_hyperhdr_viz.c
native/src/sinks/sink_udp.c
native/src/sinks/sink_tcp.c
native/src/sinks/sink_http.c
)
echo "== Syntax-checking the webOS-only sources against stub headers"
for f in native/src/service.c native/src/main.c; do
"$CC" "${CFLAGS[@]}" -Itest/stubs -fsyntax-only "$f"
echo " ok $f"
done
echo
echo "== FlatBuffers wire format"
if python3 -c "import flatbuffers" 2>/dev/null; then
python3 test/verify_flatbuf.py
else
echo " SKIP: the 'flatbuffers' Python package is not installed"
echo " python3 -m venv /tmp/fbvenv && /tmp/fbvenv/bin/pip install flatbuffers"
echo " CC=$CC /tmp/fbvenv/bin/python test/verify_flatbuf.py"
fi
echo
echo "== RTP wire format, against the host receiver"
"$CC" "${CFLAGS[@]}" -o "$OUT/rtp_send" test/rtp_send.c "${SOURCES[@]}" -lpthread -lm
python3 test/verify_rtp.py "$OUT/rtp_send"
echo
echo "== Capture pipeline end to end"
"$CC" "${CFLAGS[@]}" -o "$OUT/engine_smoke" test/engine_smoke.c "${SOURCES[@]}" -lpthread -lm
"$OUT/engine_smoke"
echo
echo "== Frontend"
if command -v node >/dev/null 2>&1; then
for f in frontend/js/*.js; do
node --check "$f"
echo " ok $f"
done
# jsdom is a test-only dependency; the app itself has none. `npm install`
# puts it in node_modules, or point JSDOM_PATH at an install elsewhere.
if [ -z "${JSDOM_PATH:-}" ] && [ -d node_modules/jsdom ]; then
JSDOM_PATH="$PWD/node_modules"
fi
JSDOM_PATH="${JSDOM_PATH:-/tmp/audiocap-domtest/node_modules}"
if NODE_PATH="$JSDOM_PATH" node -e "require('jsdom')" 2>/dev/null; then
NODE_PATH="$JSDOM_PATH" node test/ui_smoke.js
else
echo " SKIP: jsdom is not installed, so the page was not run"
echo " mkdir -p /tmp/audiocap-domtest && cd /tmp/audiocap-domtest && npm i jsdom"
fi
else
echo " SKIP: node is not installed"
fi
+6
View File
@@ -0,0 +1,6 @@
// See glib.h in this directory: syntax-check scaffolding, not a real header.
#pragma once
#include "glib.h"
guint g_unix_signal_add(gint signum, GSourceFunc handler, gpointer user_data);
+32
View File
@@ -0,0 +1,32 @@
// Minimal glib stand-in, used only to syntax-check service.c and main.c on a
// development machine that has no webOS SDK installed. It declares exactly the
// handful of symbols this project uses and nothing else; the real headers are
// what the TV build compiles against.
#pragma once
#include <stdbool.h>
typedef int gboolean;
typedef int gint;
typedef unsigned int guint;
typedef void* gpointer;
typedef struct _GMainLoop GMainLoop;
typedef struct _GMainContext GMainContext;
#define TRUE 1
#define FALSE 0
#define G_SOURCE_REMOVE FALSE
#define G_SOURCE_CONTINUE TRUE
typedef gboolean (*GSourceFunc)(gpointer user_data);
GMainLoop* g_main_loop_new(GMainContext* context, gboolean is_running);
void g_main_loop_run(GMainLoop* loop);
void g_main_loop_quit(GMainLoop* loop);
void g_main_loop_unref(GMainLoop* loop);
guint g_idle_add(GSourceFunc function, gpointer data);
gboolean g_atomic_int_compare_and_exchange(gint* atomic, gint oldval, gint newval);
void g_atomic_int_set(gint* atomic, gint newval);
+63
View File
@@ -0,0 +1,63 @@
// Minimal luna-service2 stand-in for host-side syntax checks. Mirrors the
// signatures this project calls, so a typo or a wrong argument count is caught
// without a webOS SDK. See ../glib.h.
#pragma once
#include <glib.h>
#include <stdbool.h>
typedef struct LSHandle LSHandle;
typedef struct LSMessage LSMessage;
typedef struct {
int error_code;
char* message;
const char* file;
int line;
const char* func;
void* padding;
unsigned long magic;
} LSError;
typedef bool (*LSMethodFunction)(LSHandle* sh, LSMessage* msg, void* category_context);
typedef enum {
LUNA_METHOD_FLAGS_NONE = 0,
} LSMethodFlags;
typedef struct {
const char* name;
LSMethodFunction function;
LSMethodFlags flags;
} LSMethod;
typedef struct {
const char* name;
void* function;
unsigned int flags;
} LSSignal;
typedef struct {
const char* name;
void* function;
unsigned int flags;
} LSProperty;
void LSErrorInit(LSError* error);
void LSErrorFree(LSError* error);
bool LSRegister(const char* name, LSHandle** handle, LSError* error);
bool LSUnregister(LSHandle* handle, LSError* error);
bool LSRegisterCategory(LSHandle* handle, const char* category, LSMethod* methods,
LSSignal* signals, LSProperty* properties, LSError* error);
bool LSCategorySetData(LSHandle* handle, const char* category, void* user_data, LSError* error);
bool LSGmainAttach(LSHandle* handle, GMainLoop* loop, LSError* error);
const char* LSMessageGetPayload(LSMessage* message);
bool LSMessageIsSubscription(LSMessage* message);
bool LSMessageReply(LSHandle* sh, LSMessage* message, const char* reply, LSError* error);
bool LSSubscriptionAdd(LSHandle* sh, const char* key, LSMessage* message, LSError* error);
bool LSSubscriptionReply(LSHandle* sh, const char* key, const char* payload, LSError* error);
+234
View File
@@ -0,0 +1,234 @@
// Loads the real index.html in jsdom, against the browser mock of the Luna
// bus, and drives it the way a remote would. Catches the mistakes that only
// show up when the page actually runs: a typo'd element id, a control wired to
// a setting that does not exist, a render that throws on the first status
// frame.
//
// jsdom is not vendored. Install it anywhere and point NODE_PATH at it:
// mkdir -p /tmp/audiocap-domtest && cd /tmp/audiocap-domtest && npm i jsdom
// NODE_PATH=/tmp/audiocap-domtest/node_modules node test/ui_smoke.js
'use strict';
const path = require('path');
const { JSDOM, VirtualConsole } = require('jsdom');
const ROOT = path.resolve(__dirname, '..');
const PAGE = path.join(ROOT, 'frontend', 'index.html');
let checks = 0;
let failures = 0;
function check(name, condition, detail) {
checks++;
if (condition) {
console.log(' ok ' + name);
} else {
failures++;
console.log(' FAIL ' + name + (detail === undefined ? '' : ' — ' + detail));
}
}
function eq(name, actual, expected) {
check(name, actual === expected, 'got ' + JSON.stringify(actual)
+ ', wanted ' + JSON.stringify(expected));
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// jsdom has no layout, so every rect is zero and the geometric navigator has
// nothing to work with. Fake a plausible screen: the header button top right,
// the tabs in a row, every other control stacked down the page.
function fakeLayout(window) {
const rects = new WeakMap();
const focusables = window.document.querySelectorAll('.focusable');
let row = 0;
focusables.forEach((el) => {
let rect;
if (el.id === 'power') {
rect = { left: 1600, top: 40, width: 200, height: 60 };
} else if (el.classList.contains('tab')) {
const index = Array.prototype.indexOf.call(
window.document.querySelectorAll('.tab'), el);
rect = { left: 60 + index * 220, top: 160, width: 200, height: 60 };
} else {
rect = { left: 1200, top: 280 + row * 90, width: 360, height: 60 };
row++;
}
rect.right = rect.left + rect.width;
rect.bottom = rect.top + rect.height;
rects.set(el, rect);
});
window.Element.prototype.getBoundingClientRect = function () {
return rects.get(this) || { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 };
};
}
function press(window, keyCode) {
const event = new window.KeyboardEvent('keydown', {
keyCode: keyCode, bubbles: true, cancelable: true,
});
// jsdom's KeyboardEvent ignores the legacy keyCode field.
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
window.document.dispatchEvent(event);
}
function click(el) {
el.dispatchEvent(new el.ownerDocument.defaultView.MouseEvent('click', { bubbles: true }));
}
async function main() {
const errors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on('jsdomError', (e) => errors.push(String(e && e.message || e)));
virtualConsole.on('error', (...args) => errors.push(args.join(' ')));
const dom = await JSDOM.fromFile(PAGE, {
runScripts: 'dangerously',
resources: 'usable',
pretendToBeVisual: true,
virtualConsole,
});
const window = dom.window;
window.addEventListener('error', (e) => errors.push(String(e.message)));
await new Promise((resolve) => {
if (window.document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
// The mock answers after 30 ms; give the whole load sequence room.
await wait(250);
const doc = window.document;
const $ = (id) => doc.getElementById(id);
console.log('page load');
check('no script errors', errors.length === 0, errors.join(' | '));
check('mock bus in use', window.Luna.available === false);
check('settings loaded', !!(window.App.state.settings.capture));
eq('config path shown', $('config-path').textContent.indexOf('/var/lib/webosbrew') >= 0, true);
console.log('status feed');
eq('starts stopped', $('state-pill').textContent, 'Stopped');
eq('power button offers start', $('power').textContent, 'Start');
eq('sixteen band bars', doc.querySelectorAll('.band').length, 16);
eq('no sinks listed while stopped', $('sink-status').textContent.trim(), 'Not running.');
console.log('panels');
eq('five sink cards', doc.querySelectorAll('.sink-card').length, 5);
check('hyperhdr card is first and marked',
doc.querySelector('.sink-card .badge').textContent === 'Recommended');
check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]'));
check('backend choice exists', !!doc.querySelector('[data-path="capture.backend"]'));
check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]'));
check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));
// The mock reports the service already running as root.
eq('root state reflected',
doc.querySelector('[data-path="elevate"]').textContent, 'Re-apply');
// Conditional fields: multicast is off by default, so its TTL stays hidden.
check('multicast ttl hidden while multicast is off',
!doc.querySelector('[data-path="hyperhdr.multicastTtl"]'));
// exec-only fields stay out of the way of the default pulse/alsa setup.
check('command field hidden for automatic backend',
!doc.querySelector('[data-path="capture.command"]'));
console.log('editing');
const host = doc.querySelector('[data-path="hyperhdr.host"]');
host.value = '10.0.0.9';
host.dispatchEvent(new window.Event('change'));
await wait(600);
eq('host edit reached the service', window.App.state.settings.hyperhdr.host, '10.0.0.9');
const multicast = doc.querySelector('[data-path="hyperhdr.multicast"]');
click(multicast);
await wait(600);
eq('multicast toggled', window.App.state.settings.hyperhdr.multicast, true);
check('multicast ttl appears once enabled',
!!doc.querySelector('[data-path="hyperhdr.multicastTtl"]'));
const backend = doc.querySelector('[data-path="capture.backend"]');
click(backend); // auto -> pulse
await wait(600);
eq('backend cycled', window.App.state.settings.capture.backend, 'pulse');
check('server field appears for pulse',
!!doc.querySelector('[data-path="capture.server"]'));
check('command field still hidden for pulse',
!doc.querySelector('[data-path="capture.command"]'));
const udpToggle = doc.querySelector('[data-sink="udp"]');
click(udpToggle);
await wait(600);
check('udp sink enabled',
window.App.state.settings.sinks.indexOf('udp') >= 0,
JSON.stringify(window.App.state.settings.sinks));
console.log('running');
click($('power'));
await wait(300);
eq('pill reports running', $('state-pill').textContent, 'Running');
eq('power button offers stop', $('power').textContent, 'Stop');
check('sinks listed while running',
doc.querySelectorAll('.sink-line').length >= 2,
doc.querySelectorAll('.sink-line').length + ' lines');
check('meter moved', parseFloat($('meter-peak').firstChild.style.width) > 0,
$('meter-peak').firstChild.style.width);
const tallest = Array.prototype.reduce.call(doc.querySelectorAll('.band'),
(max, b) => Math.max(max, parseFloat(b.style.height) || 0), 0);
check('bands moved', tallest > 3, tallest + 'px');
check('capture info filled',
$('capture-info').textContent.indexOf('48000 Hz') >= 0,
$('capture-info').textContent);
click($('power'));
await wait(300);
eq('stops again', $('state-pill').textContent, 'Stopped');
console.log('diagnostics');
click($('run-diagnostics'));
await wait(200);
check('diagnostics output shown',
!$('output').classList.contains('hidden')
&& $('output').textContent.indexOf('libpulse') >= 0);
click($('load-logs'));
await wait(200);
check('log output shown', $('output').textContent.indexOf('browser mock') >= 0);
console.log('navigation');
fakeLayout(window);
const tabs = doc.querySelectorAll('.tab');
tabs[0].focus();
press(window, 39);
eq('right moves along the tab row', doc.activeElement, tabs[1]);
press(window, 37);
eq('left comes back', doc.activeElement, tabs[0]);
press(window, 38);
eq('up reaches the header button', doc.activeElement, $('power'));
press(window, 40);
check('down leaves the header', doc.activeElement !== $('power'));
console.log('tabs');
click(tabs[1]);
check('outputs panel shown', !$('panel-sinks').classList.contains('hidden'));
check('status panel hidden', $('panel-status').classList.contains('hidden'));
press(window, 461); // Back
check('back returns to status', !$('panel-status').classList.contains('hidden'));
check('still no script errors', errors.length === 0, errors.join(' | '));
window.close();
console.log('\n' + (checks - failures) + '/' + checks + ' checks passed');
process.exit(failures ? 1 : 0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Verify the hand-rolled FlatBuffers encoder against the reference runtime.
The C code in native/src/net/flatbuf.c builds Hyperion protocol messages
without flatcc. This decodes those bytes using the upstream `flatbuffers`
Python package, so a layout mistake fails here rather than silently producing
a message HyperHDR drops on the floor.
Schema (hyperion.ng libsrc/flatbufserver/hyperion_request.fbs):
table Register { origin:string (required); priority:int; }
table RawImage { data:[ubyte]; width:int = -1; height:int = -1; }
table Image { data:ImageType (required); duration:int = -1; }
table Clear { priority:int; }
union ImageType { RawImage, NV12Image } // RawImage = 1
union Command { Color, Image, Clear, Register } // Image = 2, Register = 4
table Request { command:Command (required); }
root_type Request;
"""
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
from flatbuffers import number_types as N
from flatbuffers.table import Table
CMD_IMAGE = 2
CMD_REGISTER = 4
IMGTYPE_RAWIMAGE = 1
FAILURES = []
def check(label, actual, expected):
ok = actual == expected
status = "ok " if ok else "FAIL"
shown = actual if not isinstance(actual, (bytes, bytearray)) else bytes(actual).hex()
exp = expected if not isinstance(expected, (bytes, bytearray)) else bytes(expected).hex()
print(f" [{status}] {label}: {shown!r}" + ("" if ok else f" (expected {exp!r})"))
if not ok:
FAILURES.append(label)
def unframe(raw: bytes) -> bytes:
"""Strip and validate the 4-byte big-endian length prefix."""
assert len(raw) >= 4, "message shorter than its length prefix"
(declared,) = struct.unpack(">I", raw[:4])
check("length prefix matches payload", declared, len(raw) - 4)
return raw[4:]
def root_table(payload: bytes) -> Table:
pos = struct.unpack_from("<I", payload, 0)[0]
return Table(bytearray(payload), pos)
def field(tbl: Table, slot: int):
"""Return the vtable offset for a slot, or 0 when the field is absent."""
return tbl.Offset(slot * 2 + 4)
def read_u8(tbl: Table, slot: int, default=0):
o = field(tbl, slot)
return tbl.Get(N.Uint8Flags, o + tbl.Pos) if o else default
def read_i32(tbl: Table, slot: int, default=0):
o = field(tbl, slot)
return tbl.Get(N.Int32Flags, o + tbl.Pos) if o else default
def read_sub(tbl: Table, slot: int):
o = field(tbl, slot)
if not o:
return None
return Table(tbl.Bytes, tbl.Indirect(o + tbl.Pos))
def read_str(tbl: Table, slot: int):
o = field(tbl, slot)
return tbl.String(o + tbl.Pos).decode() if o else None
def read_bytes(tbl: Table, slot: int):
o = field(tbl, slot)
if not o:
return None
start = tbl.Vector(o)
length = tbl.VectorLen(o)
return bytes(tbl.Bytes[start : start + length])
def verify_register(raw: bytes):
print("Register message:")
req = root_table(unframe(raw))
check("Request.command_type", read_u8(req, 0), CMD_REGISTER)
reg = read_sub(req, 1)
assert reg is not None, "Request.command missing"
check("Register.origin", read_str(reg, 0), "lgtv-audio-cap")
check("Register.priority", read_i32(reg, 1), 150)
def verify_image(raw: bytes):
print("Image message:")
req = root_table(unframe(raw))
check("Request.command_type", read_u8(req, 0), CMD_IMAGE)
img = read_sub(req, 1)
assert img is not None, "Request.command missing"
check("Image.data_type", read_u8(img, 0), IMGTYPE_RAWIMAGE)
# duration defaults to -1 and is omitted from the buffer.
check("Image.duration (default)", read_i32(img, 2, default=-1), -1)
raw_img = read_sub(img, 1)
assert raw_img is not None, "Image.data missing"
check("RawImage.width", read_i32(raw_img, 1, default=-1), 4)
check("RawImage.height", read_i32(raw_img, 2, default=-1), 2)
expected = bytes(b for i in range(8) for b in (i * 10, i * 10 + 1, i * 10 + 2))
check("RawImage.data length", len(read_bytes(raw_img, 0) or b""), 24)
check("RawImage.data contents", read_bytes(raw_img, 0), expected)
def main():
repo = Path(__file__).resolve().parent.parent
sources = [
repo / "test" / "fb_dump.c",
repo / "native" / "src" / "net" / "hyperion.c",
repo / "native" / "src" / "net" / "flatbuf.c",
repo / "native" / "src" / "common" / "log.c",
]
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
binary = tmp / "fb_dump"
compile_cmd = ["cc", "-std=c11", "-Wall", "-Wextra", "-O1", "-o", str(binary)]
compile_cmd += [str(s) for s in sources]
print("$ " + " ".join(compile_cmd))
subprocess.run(compile_cmd, check=True)
for kind, verifier in (("register", verify_register), ("image", verify_image)):
out = tmp / f"{kind}.bin"
subprocess.run([str(binary), kind, str(out)], check=True)
verifier(out.read_bytes())
if FAILURES:
print(f"\n{len(FAILURES)} check(s) failed: {', '.join(FAILURES)}")
return 1
print("\nAll FlatBuffers checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Checks the TV's RTP sink against the host receiver.
The C sink writes a known ramp; this binds the port, collects the datagrams and
decodes them with the very functions host/lgtv-audiocap-receiver.py uses. If
the two ever disagree about the header layout or the sample byte order, the
ramp comes back wrong and this fails.
Also checks the SAP/SDP announcement, since PulseAudio's module-rtp-recv builds
its source purely from that text.
python3 test/verify_rtp.py [path-to-rtp_send]
"""
import os
import re
import socket
import struct
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, os.pardir, "host"))
# The receiver's filename is not an identifier, so load it by path.
try:
import importlib.util
spec = importlib.util.spec_from_file_location(
"audiocap_receiver",
os.path.join(HERE, os.pardir, "host", "lgtv-audiocap-receiver.py"))
receiver = importlib.util.module_from_spec(spec)
spec.loader.exec_module(receiver)
except Exception as exc: # pragma: no cover - only when the file is missing
print("cannot load the host receiver: %s" % exc)
sys.exit(1)
BLOCKS = 8
FRAMES = 512
CHANNELS = 2
RATE = 48000
checks = 0
failures = 0
def check(name, condition, detail=None):
global checks, failures
checks += 1
if condition:
print(" ok %s" % name)
else:
failures += 1
print(" FAIL %s%s" % (name, "" if detail is None else " — %s" % detail))
def eq(name, actual, expected):
check(name, actual == expected, "got %r, wanted %r" % (actual, expected))
def sample_at(index):
"""Must match sample_at() in test/rtp_send.c."""
value = (index * 251) % 65536 - 32768
return value
def main():
binary = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "rtp_send")
if not os.path.exists(binary):
print("build test/rtp_send.c first (run-tests.sh does it for you)")
return 1
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.settimeout(2.0)
proc = subprocess.run([binary, str(port), str(BLOCKS), str(FRAMES)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
print("rtp_send failed: %s" % proc.stderr.decode("utf-8", "replace"))
return 1
expected_samples = int(proc.stdout.decode().strip())
packets = []
try:
while True:
data, _ = sock.recvfrom(4096)
packets.append(data)
except socket.timeout:
pass
sock.close()
print("wire format")
check("packets arrived", len(packets) > 0, "%d packets" % len(packets))
if not packets:
return 1
first = receiver.parse_rtp(packets[0])
check("receiver parses the header", first is not None)
eq("payload type", first.payload_type, 96)
eq("version 2, no CSRCs, no extension", packets[0][0], 0x80)
eq("marker bit clear", packets[0][1] >> 7, 0)
# Every packet must stay inside a 1500-byte MTU with room for the IP and
# UDP headers, or the stream fragments and loss goes from bad to total.
largest = max(len(p) for p in packets)
check("no packet exceeds the MTU budget", largest <= 1472, "%d bytes" % largest)
print("sequencing")
parsed = [receiver.parse_rtp(p) for p in packets]
check("all packets parse", all(p is not None for p in parsed))
ssrcs = set(p.ssrc for p in parsed)
eq("one SSRC for the run", len(ssrcs), 1)
sequences = [p.sequence for p in parsed]
expected_sequences = [(sequences[0] + i) & 0xFFFF for i in range(len(sequences))]
eq("sequence numbers increment by one", sequences, expected_sequences)
frame_bytes = 2 * CHANNELS
stamps = [p.timestamp for p in parsed]
steps = set((stamps[i + 1] - stamps[i]) & 0xFFFFFFFF for i in range(len(stamps) - 1))
frames_per_packet = set(len(p.payload) // frame_bytes for p in parsed[:-1])
eq("timestamp advances by the frame count", steps, frames_per_packet)
print("payload")
pcm = b"".join(receiver.to_native_pcm(p.payload) for p in parsed)
samples = struct.unpack("<%dh" % (len(pcm) // 2), pcm)
eq("every sample arrived", len(samples), expected_samples)
wrong = [i for i, v in enumerate(samples) if v != sample_at(i)]
check("the ramp survives the round trip", not wrong,
"%d samples differ, first at %s" % (len(wrong), wrong[:1]))
# A wrong byte order still produces "audio", just noise; check explicitly
# that the payload really is big-endian on the wire.
raw_be = struct.unpack(">%dh" % (len(parsed[0].payload) // 2), parsed[0].payload)
eq("payload is big-endian on the wire", raw_be[0], sample_at(0))
print("SAP announcement")
sdp = capture_sap()
if sdp is None:
print(" SKIP: no announcement seen (multicast on loopback is often"
" blocked); the SDP text itself is unchecked")
else:
check("SDP names an L16 stream", "L16/%d/%d" % (RATE, CHANNELS) in sdp, sdp)
check("SDP carries a media line", re.search(r"m=audio \d+ RTP/AVP 96", sdp)
is not None, sdp)
check("SDP is recvonly", "a=recvonly" in sdp, sdp)
print("\n%d/%d checks passed" % (checks - failures, checks))
return 1 if failures else 0
def capture_sap(timeout=1.5):
"""Listens for one SAP announcement from a second, SAP-enabled run."""
binary = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "rtp_send")
sap = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sap.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sap.bind(("", 9875))
# Join on whichever interface the kernel picks: the announcement leaves
# by the default route, so that is where it can loop back from.
membership = socket.inet_aton("224.0.0.56") + struct.pack("=I", socket.INADDR_ANY)
sap.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
except OSError:
sap.close()
return None
sap.settimeout(timeout)
subprocess.run([binary, "9999", "2", "512", "sap"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
try:
data, _ = sap.recvfrom(2048)
except socket.timeout:
return None
finally:
sap.close()
# RFC 2974: 4-byte header, 4-byte source, NUL-terminated MIME type.
body = data[8:]
end = body.find(b"\x00")
return body[end + 1:].decode("utf-8", "replace") if end >= 0 else None
if __name__ == "__main__":
sys.exit(main())
Executable
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# Builds the native service, assembles the package layout and produces the ipk.
#
# ./tools/build.sh # native + stage + package
# ./tools/build.sh native # cross-compile the service only
# ./tools/build.sh package # assemble and run ares-package
# ./tools/build.sh install # ares-install the ipk on the TV
# ./tools/build.sh launch # ares-launch the app
# ./tools/build.sh logs # tail the service log over ssh
# ./tools/build.sh clean
#
# Needs the webOS NDK (arm-webos-linux-gnueabi buildroot SDK) for the native
# part and ares-cli for the packaging part. Point WEBOS_SDK at the SDK if it is
# not in the usual place; set DEVICE to the ares device name (default: tv).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
APP_ID=org.webosbrew.audiocap
SERVICE_ID=$APP_ID.service
BINARY=audiocap-service
WEBOS_SDK="${WEBOS_SDK:-$HOME/arm-webos-linux-gnueabi_sdk-buildroot}"
DEVICE="${DEVICE:-tv}"
BUILD_DIR="$ROOT/build"
STAGE_APP="$BUILD_DIR/stage/app"
STAGE_SERVICE="$BUILD_DIR/stage/service"
OUT_DIR="$ROOT/out"
say() { printf '%s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
version() {
python3 - "$ROOT/frontend/appinfo.json" <<'EOF'
import json, sys
print(json.load(open(sys.argv[1]))["version"])
EOF
}
check_sdk() {
local toolchain="$WEBOS_SDK/share/buildroot/toolchainfile.cmake"
if [ ! -f "$toolchain" ]; then
cat >&2 <<EOF
error: no webOS SDK at $WEBOS_SDK
Download and unpack the buildroot NDK, then point WEBOS_SDK at it:
https://github.com/openlgtv/buildroot-nc4/releases
tar xf arm-webos-linux-gnueabi_sdk-buildroot.tar.gz -C \$HOME
\$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh
WEBOS_SDK=\$HOME/arm-webos-linux-gnueabi_sdk-buildroot ./tools/build.sh
EOF
exit 1
fi
command -v cmake >/dev/null 2>&1 || die "cmake is not installed"
echo "$toolchain"
}
check_ares() {
command -v ares-package >/dev/null 2>&1 || cat >&2 <<'EOF'
error: ares-package is not on PATH
npm install -g @webosose/ares-cli
Then register the TV once (developer mode or the Homebrew Channel's ssh):
ares-setup-device --add tv --info "{'host':'192.168.1.20','port':9922,'username':'root'}"
EOF
command -v ares-package >/dev/null 2>&1
}
build_native() {
local toolchain
toolchain="$(check_sdk)"
step "Cross-compiling the service"
cmake -S native -B "$BUILD_DIR/native" \
-DCMAKE_TOOLCHAIN_FILE="$toolchain" \
-DCMAKE_BUILD_TYPE=Release
cmake --build "$BUILD_DIR/native" --parallel
local binary="$BUILD_DIR/native/$BINARY"
[ -f "$binary" ] || die "the build produced no $BINARY"
say "built $binary"
file "$binary" 2>/dev/null | sed 's/^/ /' || true
}
stage() {
step "Staging the package"
rm -rf "$BUILD_DIR/stage"
mkdir -p "$STAGE_APP" "$STAGE_SERVICE"
cp -R "$ROOT/frontend/." "$STAGE_APP/"
# The mock only exists so the UI can be opened in a desktop browser.
rm -f "$STAGE_APP/js/mock.js"
python3 - "$STAGE_APP/index.html" <<'EOF'
import re, sys
path = sys.argv[1]
html = open(path).read()
html = re.sub(r'\s*<script src="js/mock\.js"></script>', '', html)
open(path, "w").write(html)
EOF
cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/"
cp "$ROOT/servicefiles/package.json" "$STAGE_SERVICE/"
cp "$ROOT/servicefiles/audiocapautostart" "$STAGE_SERVICE/"
chmod +x "$STAGE_SERVICE/audiocapautostart"
local binary="$BUILD_DIR/native/$BINARY"
[ -f "$binary" ] || die "no service binary; run './tools/build.sh native' first"
cp "$binary" "$STAGE_SERVICE/$BINARY"
chmod +x "$STAGE_SERVICE/$BINARY"
say "app: $STAGE_APP"
say "service: $STAGE_SERVICE"
}
package() {
check_ares || exit 1
step "Packaging"
mkdir -p "$OUT_DIR"
rm -f "$OUT_DIR"/${APP_ID}_*.ipk
ares-package "$STAGE_APP" "$STAGE_SERVICE" -o "$OUT_DIR"
local ipk
ipk="$(ls -t "$OUT_DIR"/${APP_ID}_*.ipk | head -1)"
say ""
say "$ipk"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$ipk" | sed 's/^/ sha256 /'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$ipk" | sed 's/^/ sha256 /'
fi
}
latest_ipk() {
ls -t "$OUT_DIR"/${APP_ID}_*.ipk 2>/dev/null | head -1
}
install_ipk() {
local ipk
ipk="$(latest_ipk)" || true
[ -n "$ipk" ] || die "no ipk in $OUT_DIR; run './tools/build.sh' first"
step "Installing $ipk on device '$DEVICE'"
ares-install --device "$DEVICE" "$ipk"
say ""
say "The service needs root to reach the TV's audio devices. Either open the"
say "app and press 'Grant root access', or run it here:"
say " ares-shell --device $DEVICE -r \\"
say " '/media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/elevate-service $SERVICE_ID'"
}
launch() {
step "Launching on '$DEVICE'"
ares-launch --device "$DEVICE" "$APP_ID"
}
logs() {
step "Service log from '$DEVICE' (ctrl-c to stop)"
# The service keeps its own ring buffer, but journald/pmlog has the crashes.
ares-shell --device "$DEVICE" -r \
"tail -f /var/log/messages 2>/dev/null | grep -i audiocap || journalctl -f | grep -i audiocap"
}
clean() {
step "Cleaning"
rm -rf "$BUILD_DIR" "$OUT_DIR"
say "removed build/ and out/"
}
case "${1:-all}" in
all) build_native; stage; package ;;
native) build_native ;;
stage) stage ;;
package) stage; package ;;
install) install_ipk ;;
launch) launch ;;
logs) logs ;;
clean) clean ;;
version) version ;;
-h|--help)
awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" ;;
*) die "unknown command '$1' (try --help)" ;;
esac
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Generates the app icons and the splash screen.
Written against nothing but the standard library on purpose: the icons are
part of the package, so regenerating them must not depend on Pillow being
installed or on a checked-in binary nobody can edit.
python3 tools/make-assets.py
Everything is drawn supersampled and boxed down, which is what gives the
rounded corners and bar tops their edges.
"""
import os
import struct
import sys
import zlib
HERE = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(HERE, os.pardir, "frontend", "assets")
# Same palette as the UI.
BACKDROP = (11, 14, 19)
TILE_TOP = (18, 32, 52)
TILE_BOTTOM = (13, 17, 25)
BAR_TOP = (74, 163, 255)
BAR_BOTTOM = (31, 77, 118)
ACCENT = (87, 217, 138)
TEXT = (200, 214, 232)
# Fraction of the drawing height each bar reaches. Reads as a level meter
# caught mid-song rather than a generic equaliser.
BARS = [0.34, 0.62, 0.95, 0.48, 0.78, 0.40]
class Canvas:
"""RGBA pixel buffer with the handful of primitives this needs."""
def __init__(self, width, height, fill=(0, 0, 0, 0)):
self.w = width
self.h = height
self.px = bytearray(fill * width * height) if len(fill) == 4 else None
if self.px is None:
self.px = bytearray((fill + (255,)) * width * height)
def blend(self, x, y, colour, alpha=255):
if x < 0 or y < 0 or x >= self.w or y >= self.h or alpha <= 0:
return
i = (y * self.w + x) * 4
if alpha >= 255:
self.px[i:i + 4] = bytes(colour) + b"\xff"
return
a = alpha / 255.0
for c in range(3):
self.px[i + c] = int(self.px[i + c] * (1 - a) + colour[c] * a)
self.px[i + 3] = max(self.px[i + 3], alpha)
def rect(self, x0, y0, x1, y1, colour):
for y in range(max(0, int(y0)), min(self.h, int(y1))):
for x in range(max(0, int(x0)), min(self.w, int(x1))):
self.blend(x, y, colour)
def rounded_rect(self, x0, y0, x1, y1, radius, top, bottom=None):
"""Filled rounded rectangle, optionally with a vertical gradient."""
bottom = bottom if bottom is not None else top
height = max(1, y1 - y0 - 1)
for y in range(max(0, int(y0)), min(self.h, int(y1))):
# Fractional edges mean y can sit just outside the span; clamping
# keeps the gradient from extrapolating past either colour.
t = min(1.0, max(0.0, (y - y0) / height))
colour = tuple(int(top[c] + (bottom[c] - top[c]) * t) for c in range(3))
for x in range(max(0, int(x0)), min(self.w, int(x1))):
# Only the corners need the distance test.
cx = None
if x < x0 + radius and y < y0 + radius:
cx, cy = x0 + radius, y0 + radius
elif x >= x1 - radius and y < y0 + radius:
cx, cy = x1 - radius - 1, y0 + radius
elif x < x0 + radius and y >= y1 - radius:
cx, cy = x0 + radius, y1 - radius - 1
elif x >= x1 - radius and y >= y1 - radius:
cx, cy = x1 - radius - 1, y1 - radius - 1
if cx is not None:
dx, dy = x - cx, y - cy
if dx * dx + dy * dy > radius * radius:
continue
self.blend(x, y, colour)
def downsample(self, factor):
"""Box filter. This is the whole anti-aliasing strategy."""
w, h = self.w // factor, self.h // factor
out = Canvas(w, h)
area = factor * factor
for y in range(h):
for x in range(w):
r = g = b = a = 0
for sy in range(factor):
row = ((y * factor + sy) * self.w + x * factor) * 4
for sx in range(factor):
i = row + sx * 4
r += self.px[i]
g += self.px[i + 1]
b += self.px[i + 2]
a += self.px[i + 3]
i = (y * w + x) * 4
out.px[i] = r // area
out.px[i + 1] = g // area
out.px[i + 2] = b // area
out.px[i + 3] = a // area
return out
def paste(self, other, x0, y0):
for y in range(other.h):
for x in range(other.w):
i = (y * other.w + x) * 4
self.blend(x0 + x, y0 + y, tuple(other.px[i:i + 3]), other.px[i + 3])
def write_png(self, path):
raw = bytearray()
stride = self.w * 4
for y in range(self.h):
raw.append(0) # filter: none
raw += self.px[y * stride:(y + 1) * stride]
def chunk(kind, data):
head = struct.pack(">I", len(data)) + kind + data
return head + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(bytes(raw), 9))
png += chunk(b"IEND", b"")
with open(path, "wb") as fh:
fh.write(png)
# 5x7, only the letters the splash needs.
GLYPHS = {
"A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
"C": ["01110", "10001", "10000", "10000", "10000", "10001", "01110"],
"D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
"I": ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
"O": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
"P": ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
"U": ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
" ": ["00000"] * 7,
}
def text_width(text, scale, spacing):
return len(text) * (5 * scale + spacing) - spacing
def draw_text(canvas, text, x0, y0, scale, spacing, colour):
x = x0
for ch in text:
rows = GLYPHS[ch]
for ry, row in enumerate(rows):
for rx, on in enumerate(row):
if on == "1":
canvas.rect(x + rx * scale, y0 + ry * scale,
x + (rx + 1) * scale, y0 + (ry + 1) * scale, colour)
x += 5 * scale + spacing
def render_tile(size, supersample=4):
"""The logo: a rounded tile with a level meter on it."""
s = size * supersample
c = Canvas(s, s)
radius = int(s * 0.22)
c.rounded_rect(0, 0, s, s, radius, TILE_TOP, TILE_BOTTOM)
margin = s * 0.18
inner_w = s - margin * 2
inner_h = s - margin * 2
gap = inner_w / (len(BARS) * 4)
bar_w = (inner_w - gap * (len(BARS) - 1)) / len(BARS)
bar_radius = max(1, int(bar_w * 0.35))
base = s - margin
for i, height in enumerate(BARS):
x0 = margin + i * (bar_w + gap)
top = base - inner_h * height
colour_top = ACCENT if height > 0.9 else BAR_TOP
c.rounded_rect(x0, top, x0 + bar_w, base, bar_radius, colour_top, BAR_BOTTOM)
return c.downsample(supersample)
def render_splash(width=1920, height=1080):
c = Canvas(width, height, BACKDROP)
tile = render_tile(300, supersample=2)
c.paste(tile, (width - tile.w) // 2, height // 2 - 260)
scale, spacing = 10, 10
label = "AUDIO CAP"
draw_text(c, label, (width - text_width(label, scale, spacing)) // 2,
height // 2 + 120, scale, spacing, TEXT)
return c
def main():
os.makedirs(ASSETS, exist_ok=True)
targets = [
("icon.png", lambda: render_tile(80)),
("largeIcon.png", lambda: render_tile(130)),
("splash.png", render_splash),
]
for name, build in targets:
path = os.path.join(ASSETS, name)
build().write_png(path)
print("wrote %s (%d bytes)" % (os.path.relpath(path), os.path.getsize(path)))
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Writes the Homebrew Channel manifest for a built ipk.
The Homebrew Channel installs apps from a manifest that points at the ipk and
carries its hash. Publishing means putting this file somewhere stable (a GitHub
release asset, or the repository itself) and submitting the URL to
webosbrew/repo.
python3 tools/make-manifest.py \\
--base-url https://github.com/you/lgtv-audio-cap/releases/download/v1.0.0
Defaults to the newest ipk in out/ and writes out/manifest.json.
"""
import argparse
import glob
import hashlib
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, os.pardir)
SOURCE_URL = "https://github.com/webosbrew/lgtv-audio-cap"
def newest_ipk(directory):
matches = sorted(glob.glob(os.path.join(directory, "*.ipk")),
key=os.path.getmtime, reverse=True)
return matches[0] if matches else None
def sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--ipk", default=None, help="path to the ipk")
parser.add_argument("--base-url", default=None,
help="URL the ipk and icon will be served from")
parser.add_argument("--source-url", default=SOURCE_URL,
help="project page shown in the Homebrew Channel")
parser.add_argument("--out", default=os.path.join(ROOT, "out", "manifest.json"))
args = parser.parse_args()
appinfo = json.load(open(os.path.join(ROOT, "frontend", "appinfo.json")))
ipk = args.ipk or newest_ipk(os.path.join(ROOT, "out"))
if not ipk or not os.path.exists(ipk):
print("no ipk found; run ./tools/build.sh first", file=sys.stderr)
return 1
name = os.path.basename(ipk)
base = (args.base_url or "").rstrip("/")
manifest = {
"id": appinfo["id"],
"version": appinfo["version"],
"type": appinfo.get("type", "web"),
"title": appinfo.get("title", appinfo["id"]),
"appDescription": appinfo.get("appDescription", ""),
"iconUri": base + "/icon.png" if base else "icon.png",
"sourceUrl": args.source_url,
# The service reads the TV's audio devices, which are root-only. The
# Homebrew Channel uses this to elevate the service at install time.
"rootRequired": True,
"ipkUrl": base + "/" + name if base else name,
"ipkHash": {"sha256": sha256(ipk)},
"ipkSize": os.path.getsize(ipk),
}
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as fh:
json.dump(manifest, fh, indent=2)
fh.write("\n")
print(json.dumps(manifest, indent=2))
print("\nwrote %s" % os.path.relpath(args.out, ROOT), file=sys.stderr)
if not base:
print("note: no --base-url given, so ipkUrl and iconUri are relative",
file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -0,0 +1,143 @@
#!/bin/sh
# Reports what audio a rooted webOS TV actually exposes.
#
# Which capture backend works depends on the model and firmware: some sets run
# PulseAudio with a monitor source, some only offer ALSA, some need an external
# helper. Run this once on the TV and the answer is usually obvious.
#
# On the TV:
# sh tv-probe.sh
#
# From here, over the Homebrew Channel's ssh:
# ssh -p 9922 root@TV-IP 'sh -s' < tools/tv-probe.sh
# ares-shell --device tv -r "$(cat tools/tv-probe.sh)"
#
# Reads only. Nothing here changes the TV.
header() {
printf '\n=== %s\n' "$1"
}
have() {
command -v "$1" >/dev/null 2>&1
}
show() {
# show <label> <file>
if [ -r "$2" ]; then
printf -- '--- %s\n' "$1"
cat "$2"
fi
}
header "Identity"
printf 'uid: %s\n' "$(id -u 2>/dev/null)"
printf 'kernel: %s\n' "$(uname -r 2>/dev/null)"
printf 'machine: %s\n' "$(uname -m 2>/dev/null)"
for f in /etc/starfish-release /etc/os-release; do
show "$f" "$f"
done
if [ -r /var/run/nyx/device_info.json ]; then
printf -- '--- device info\n'
cat /var/run/nyx/device_info.json
printf '\n'
fi
header "Sound devices"
if [ -d /dev/snd ]; then
ls -l /dev/snd
else
echo "no /dev/snd — the ALSA backend cannot work"
fi
header "ALSA"
show "cards" /proc/asound/cards
show "pcm" /proc/asound/pcm
show "modules" /proc/asound/modules
if [ -d /proc/asound ]; then
for dir in /proc/asound/card*/pcm*c; do
[ -d "$dir" ] || continue
printf -- '--- capture stream %s\n' "$dir"
[ -r "$dir/info" ] && cat "$dir/info"
done
else
echo "no /proc/asound — no ALSA on this firmware"
fi
header "Tools"
for tool in arecord aplay amixer pactl parec pacat pulseaudio gst-launch-1.0 ffmpeg luna-send; do
if have "$tool"; then
printf '%-16s %s\n' "$tool" "$(command -v "$tool")"
else
printf '%-16s -\n' "$tool"
fi
done
header "Libraries"
# The service dlopen()s these rather than linking them, so what matters is
# whether the file exists at all and under which soname.
for pattern in \
/usr/lib/libasound.so* /lib/libasound.so* \
/usr/lib/libpulse.so* /lib/libpulse.so* \
/usr/lib/libpulse-simple.so* /lib/libpulse-simple.so*
do
for lib in $pattern; do
[ -e "$lib" ] && ls -l "$lib"
done
done 2>/dev/null
header "PulseAudio"
ps ax 2>/dev/null | grep -i '[p]ulseaudio' || ps 2>/dev/null | grep -i '[p]ulseaudio' \
|| echo "no pulseaudio process found"
printf -- '--- sockets\n'
for sock in /var/run/pulse/native /run/pulse/native /tmp/pulse-*/native \
/var/run/pulse/*.socket /dev/socket/pulse/*
do
[ -e "$sock" ] && ls -l "$sock"
done 2>/dev/null
printf -- '--- environment of the audio daemon\n'
for pid in $(ps ax 2>/dev/null | grep -i '[a]udiod\|[p]ulseaudio' | awk '{print $1}'); do
if [ -r "/proc/$pid/environ" ]; then
printf 'pid %s: ' "$pid"
tr '\0' '\n' < "/proc/$pid/environ" | grep -i 'PULSE\|XDG_RUNTIME' | tr '\n' ' '
printf '\n'
fi
done
if have pactl; then
printf -- '--- pactl info\n'
PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}" pactl info 2>&1 | head -20
printf -- '--- sources (monitors are what to capture)\n'
PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}" pactl list short sources 2>&1
fi
if have arecord; then
header "ALSA capture devices (arecord -l)"
arecord -l 2>&1
printf -- '--- PCMs (arecord -L)\n'
arecord -L 2>&1 | head -40
fi
header "What to put in the app"
cat <<'EOF'
Look at the output above and set Capture > Backend accordingly:
PulseAudio a pulseaudio process and a socket exist, and pactl lists a
source ending in ".monitor". Use that name as the Device, or
leave Device blank to take the default monitor.
ALSA /proc/asound lists a card with a capture PCM. Use hw:X,Y with
the card and device numbers from "arecord -l".
Command neither works but arecord/parec/gst-launch is present. Set the
Command to something that writes raw S16LE to stdout, e.g.
arecord -D hw:0,0 -f S16_LE -r 48000 -c 2 -t raw
Test tone nothing at all. Use this to prove the network path works while
you keep looking.
If everything is missing, the audio path on this firmware is locked inside the
proprietary audio daemon. Every sink depends on captured audio, so the test
tone is all that will run until a capture route is found — use it to prove the
network side, then come back to this list.
EOF