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:
@@ -0,0 +1,6 @@
|
||||
build/
|
||||
out/
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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 '{}'
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Executable
+298
@@ -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
|
||||
Executable
+398
@@ -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())
|
||||
@@ -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 .)
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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__)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+89
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
Executable
+217
@@ -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())
|
||||
Executable
+92
@@ -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())
|
||||
Executable
+143
@@ -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
|
||||
Reference in New Issue
Block a user