From 7529a60650fd49526e0e1df743a0a11bb2edd1ee Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Wed, 26 Aug 2026 10:21:00 +0200 Subject: [PATCH] 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 --- .gitignore | 6 + README.md | 115 ++++ docs/configuration.md | 222 +++++++ docs/development.md | 152 +++++ docs/hyperhdr.md | 163 +++++ docs/troubleshooting.md | 136 ++++ frontend/appinfo.json | 15 + frontend/assets/icon.png | Bin 0 -> 2798 bytes frontend/assets/largeIcon.png | Bin 0 -> 4442 bytes frontend/assets/splash.png | Bin 0 -> 18466 bytes frontend/css/app.css | 514 +++++++++++++++ frontend/index.html | 123 ++++ frontend/js/app.js | 826 +++++++++++++++++++++++ frontend/js/luna.js | 132 ++++ frontend/js/mock.js | 203 ++++++ frontend/js/nav.js | 171 +++++ frontend/js/ui.js | 195 ++++++ host/install-loopback.sh | 298 +++++++++ host/lgtv-audiocap-receiver.py | 398 +++++++++++ native/CMakeLists.txt | 110 ++++ native/src/capture/cap_alsa.c | 268 ++++++++ native/src/capture/cap_exec.c | 199 ++++++ native/src/capture/cap_pulse.c | 226 +++++++ native/src/capture/cap_tone.c | 159 +++++ native/src/capture/capture.c | 286 ++++++++ native/src/capture/capture.h | 84 +++ native/src/common/audio.h | 28 + native/src/common/json.c | 876 +++++++++++++++++++++++++ native/src/common/json.h | 102 +++ native/src/common/log.c | 113 ++++ native/src/common/log.h | 31 + native/src/common/ringbuf.c | 145 ++++ native/src/common/ringbuf.h | 40 ++ native/src/config.c | 284 ++++++++ native/src/config.h | 37 ++ native/src/dsp.c | 270 ++++++++ native/src/dsp.h | 44 ++ native/src/engine.c | 510 ++++++++++++++ native/src/engine.h | 47 ++ native/src/main.c | 123 ++++ native/src/net/flatbuf.c | 263 ++++++++ native/src/net/flatbuf.h | 49 ++ native/src/net/hyperion.c | 515 +++++++++++++++ native/src/net/hyperion.h | 49 ++ native/src/net/streamserv.c | 485 ++++++++++++++ native/src/net/streamserv.h | 37 ++ native/src/service.c | 489 ++++++++++++++ native/src/service.h | 23 + native/src/sinks/sink.c | 53 ++ native/src/sinks/sink.h | 51 ++ native/src/sinks/sink_http.c | 219 +++++++ native/src/sinks/sink_hyperhdr.c | 374 +++++++++++ native/src/sinks/sink_hyperhdr_viz.c | 416 ++++++++++++ native/src/sinks/sink_tcp.c | 105 +++ native/src/sinks/sink_udp.c | 176 +++++ package.json | 27 + servicefiles/audiocapautostart | 9 + servicefiles/package.json | 6 + servicefiles/services.json | 12 + test/engine_smoke.c | 274 ++++++++ test/fb_dump.c | 57 ++ test/rtp_send.c | 77 +++ test/run-tests.sh | 89 +++ test/stubs/glib-unix.h | 6 + test/stubs/glib.h | 32 + test/stubs/luna-service2/lunaservice.h | 63 ++ test/ui_smoke.js | 234 +++++++ test/verify_flatbuf.py | 159 +++++ test/verify_rtp.py | 187 ++++++ tools/build.sh | 187 ++++++ tools/make-assets.py | 217 ++++++ tools/make-manifest.py | 92 +++ tools/tv-probe.sh | 143 ++++ 73 files changed, 12826 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 docs/hyperhdr.md create mode 100644 docs/troubleshooting.md create mode 100644 frontend/appinfo.json create mode 100644 frontend/assets/icon.png create mode 100644 frontend/assets/largeIcon.png create mode 100644 frontend/assets/splash.png create mode 100644 frontend/css/app.css create mode 100644 frontend/index.html create mode 100644 frontend/js/app.js create mode 100644 frontend/js/luna.js create mode 100644 frontend/js/mock.js create mode 100644 frontend/js/nav.js create mode 100644 frontend/js/ui.js create mode 100755 host/install-loopback.sh create mode 100755 host/lgtv-audiocap-receiver.py create mode 100644 native/CMakeLists.txt create mode 100644 native/src/capture/cap_alsa.c create mode 100644 native/src/capture/cap_exec.c create mode 100644 native/src/capture/cap_pulse.c create mode 100644 native/src/capture/cap_tone.c create mode 100644 native/src/capture/capture.c create mode 100644 native/src/capture/capture.h create mode 100644 native/src/common/audio.h create mode 100644 native/src/common/json.c create mode 100644 native/src/common/json.h create mode 100644 native/src/common/log.c create mode 100644 native/src/common/log.h create mode 100644 native/src/common/ringbuf.c create mode 100644 native/src/common/ringbuf.h create mode 100644 native/src/config.c create mode 100644 native/src/config.h create mode 100644 native/src/dsp.c create mode 100644 native/src/dsp.h create mode 100644 native/src/engine.c create mode 100644 native/src/engine.h create mode 100644 native/src/main.c create mode 100644 native/src/net/flatbuf.c create mode 100644 native/src/net/flatbuf.h create mode 100644 native/src/net/hyperion.c create mode 100644 native/src/net/hyperion.h create mode 100644 native/src/net/streamserv.c create mode 100644 native/src/net/streamserv.h create mode 100644 native/src/service.c create mode 100644 native/src/service.h create mode 100644 native/src/sinks/sink.c create mode 100644 native/src/sinks/sink.h create mode 100644 native/src/sinks/sink_http.c create mode 100644 native/src/sinks/sink_hyperhdr.c create mode 100644 native/src/sinks/sink_hyperhdr_viz.c create mode 100644 native/src/sinks/sink_tcp.c create mode 100644 native/src/sinks/sink_udp.c create mode 100644 package.json create mode 100755 servicefiles/audiocapautostart create mode 100644 servicefiles/package.json create mode 100644 servicefiles/services.json create mode 100644 test/engine_smoke.c create mode 100644 test/fb_dump.c create mode 100644 test/rtp_send.c create mode 100755 test/run-tests.sh create mode 100644 test/stubs/glib-unix.h create mode 100644 test/stubs/glib.h create mode 100644 test/stubs/luna-service2/lunaservice.h create mode 100644 test/ui_smoke.js create mode 100644 test/verify_flatbuf.py create mode 100644 test/verify_rtp.py create mode 100755 tools/build.sh create mode 100755 tools/make-assets.py create mode 100755 tools/make-manifest.py create mode 100755 tools/tv-probe.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb12106 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +out/ +node_modules/ +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d735d7c --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..3e2a834 --- /dev/null +++ b/docs/configuration.md @@ -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 '{}' +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f62a8f9 --- /dev/null +++ b/docs/development.md @@ -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 + + + + + + diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..5f06e38 --- /dev/null +++ b/frontend/js/app.js @@ -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://:/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); diff --git a/frontend/js/luna.js b/frontend/js/luna.js new file mode 100644 index 0000000..2257063 --- /dev/null +++ b/frontend/js/luna.js @@ -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); diff --git a/frontend/js/mock.js b/frontend/js/mock.js new file mode 100644 index 0000000..790a9d9 --- /dev/null +++ b/frontend/js/mock.js @@ -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); diff --git a/frontend/js/nav.js b/frontend/js/nav.js new file mode 100644 index 0000000..37e15e2 --- /dev/null +++ b/frontend/js/nav.js @@ -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); diff --git a/frontend/js/ui.js b/frontend/js/ui.js new file mode 100644 index 0000000..f117758 --- /dev/null +++ b/frontend/js/ui.js @@ -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); diff --git a/host/install-loopback.sh b/host/install-loopback.sh new file mode 100755 index 0000000..11d7730 --- /dev/null +++ b/host/install-loopback.sh @@ -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 <&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" < "$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" </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" < 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 diff --git a/host/lgtv-audiocap-receiver.py b/host/lgtv-audiocap-receiver.py new file mode 100755 index 0000000..75c2e66 --- /dev/null +++ b/host/lgtv-audiocap-receiver.py @@ -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()) diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt new file mode 100644 index 0000000..e496e6a --- /dev/null +++ b/native/CMakeLists.txt @@ -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 .) diff --git a/native/src/capture/cap_alsa.c b/native/src/capture/cap_alsa.c new file mode 100644 index 0000000..c629d0c --- /dev/null +++ b/native/src/capture/cap_alsa.c @@ -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 +#include +#include +#include +#include + +#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, +}; diff --git a/native/src/capture/cap_exec.c b/native/src/capture/cap_exec.c new file mode 100644 index 0000000..8607848 --- /dev/null +++ b/native/src/capture/cap_exec.c @@ -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 .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 +#include +#include +#include +#include +#include +#include +#include +#include + +#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, +}; diff --git a/native/src/capture/cap_pulse.c b/native/src/capture/cap_pulse.c new file mode 100644 index 0000000..f3b946b --- /dev/null +++ b/native/src/capture/cap_pulse.c @@ -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 +#include +#include +#include +#include + +// --- 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 : "", 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, +}; diff --git a/native/src/capture/cap_tone.c b/native/src/capture/cap_tone.c new file mode 100644 index 0000000..54d5dc6 --- /dev/null +++ b/native/src/capture/cap_tone.c @@ -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 +#include +#include +#include +#include +#include + +#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, +}; diff --git a/native/src/capture/capture.c b/native/src/capture/capture.c new file mode 100644 index 0000000..a9e72c5 --- /dev/null +++ b/native/src/capture/capture.c @@ -0,0 +1,286 @@ +#include "capture.h" +#include "../common/log.h" + +#include +#include +#include +#include +#include +#include + +// 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); +} diff --git a/native/src/capture/capture.h b/native/src/capture/capture.h new file mode 100644 index 0000000..ac6e501 --- /dev/null +++ b/native/src/capture/capture.h @@ -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 +#include +#include + +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); diff --git a/native/src/common/audio.h b/native/src/common/audio.h new file mode 100644 index 0000000..954d122 --- /dev/null +++ b/native/src/common/audio.h @@ -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 + +#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; +} diff --git a/native/src/common/json.c b/native/src/common/json.c new file mode 100644 index 0000000..8975f51 --- /dev/null +++ b/native/src/common/json.c @@ -0,0 +1,876 @@ +#include "json.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// 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); +} diff --git a/native/src/common/json.h b/native/src/common/json.h new file mode 100644 index 0000000..cca426c --- /dev/null +++ b/native/src/common/json.h @@ -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 +#include + +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); diff --git a/native/src/common/log.c b/native/src/common/log.c new file mode 100644 index 0000000..8005438 --- /dev/null +++ b/native/src/common/log.c @@ -0,0 +1,113 @@ +#include "log.h" + +#include +#include +#include +#include +#include +#include + +#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); +} diff --git a/native/src/common/log.h b/native/src/common/log.h new file mode 100644 index 0000000..46e7e4a --- /dev/null +++ b/native/src/common/log.h @@ -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 +#include + +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__) diff --git a/native/src/common/ringbuf.c b/native/src/common/ringbuf.c new file mode 100644 index 0000000..314837b --- /dev/null +++ b/native/src/common/ringbuf.c @@ -0,0 +1,145 @@ +#include "ringbuf.h" + +#include +#include +#include +#include +#include + +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; +} diff --git a/native/src/common/ringbuf.h b/native/src/common/ringbuf.h new file mode 100644 index 0000000..9d1bec7 --- /dev/null +++ b/native/src/common/ringbuf.h @@ -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 +#include +#include + +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); diff --git a/native/src/config.c b/native/src/config.c new file mode 100644 index 0000000..7302ebc --- /dev/null +++ b/native/src/config.c @@ -0,0 +1,284 @@ +#include "config.h" +#include "common/log.h" + +#include +#include +#include +#include +#include +#include +#include + +// 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; +} diff --git a/native/src/config.h b/native/src/config.h new file mode 100644 index 0000000..77ad695 --- /dev/null +++ b/native/src/config.h @@ -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 +#include + +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); diff --git a/native/src/dsp.c b/native/src/dsp.c new file mode 100644 index 0000000..906b389 --- /dev/null +++ b/native/src/dsp.c @@ -0,0 +1,270 @@ +#include "dsp.h" + +#include +#include +#include + +#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]; + } +} diff --git a/native/src/dsp.h b/native/src/dsp.h new file mode 100644 index 0000000..f3d1c71 --- /dev/null +++ b/native/src/dsp.h @@ -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 +#include + +#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); diff --git a/native/src/engine.c b/native/src/engine.c new file mode 100644 index 0000000..0fe96e7 --- /dev/null +++ b/native/src/engine.c @@ -0,0 +1,510 @@ +#include "engine.h" +#include "capture/capture.h" +#include "common/log.h" +#include "sinks/sink.h" + +#include +#include +#include +#include +#include +#include + +#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); +} diff --git a/native/src/engine.h b/native/src/engine.h new file mode 100644 index 0000000..d5c7959 --- /dev/null +++ b/native/src/engine.h @@ -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 +#include + +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); diff --git a/native/src/main.c b/native/src/main.c new file mode 100644 index 0000000..b39f1d8 --- /dev/null +++ b/native/src/main.c @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/native/src/net/flatbuf.c b/native/src/net/flatbuf.c new file mode 100644 index 0000000..da917a5 --- /dev/null +++ b/native/src/net/flatbuf.c @@ -0,0 +1,263 @@ +#include "flatbuf.h" + +#include +#include + +#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)); +} diff --git a/native/src/net/flatbuf.h b/native/src/net/flatbuf.h new file mode 100644 index 0000000..e5324c6 --- /dev/null +++ b/native/src/net/flatbuf.h @@ -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 +#include +#include + +#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); diff --git a/native/src/net/hyperion.c b/native/src/net/hyperion.c new file mode 100644 index 0000000..0899623 --- /dev/null +++ b/native/src/net/hyperion.c @@ -0,0 +1,515 @@ +#include "hyperion.h" +#include "../common/log.h" +#include "flatbuf.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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); +} diff --git a/native/src/net/hyperion.h b/native/src/net/hyperion.h new file mode 100644 index 0000000..2df3b5c --- /dev/null +++ b/native/src/net/hyperion.h @@ -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 +#include +#include +#include + +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); diff --git a/native/src/net/streamserv.c b/native/src/net/streamserv.c new file mode 100644 index 0000000..7b39c25 --- /dev/null +++ b/native/src/net/streamserv.c @@ -0,0 +1,485 @@ +#include "streamserv.h" +#include "../common/log.h" +#include "../common/ringbuf.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/native/src/net/streamserv.h b/native/src/net/streamserv.h new file mode 100644 index 0000000..b0f2a5c --- /dev/null +++ b/native/src/net/streamserv.h @@ -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 +#include + +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); diff --git a/native/src/service.c b/native/src/service.c new file mode 100644 index 0000000..b9ed66a --- /dev/null +++ b/native/src/service.c @@ -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 +#include +#include +#include +#include + +#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); +} diff --git a/native/src/service.h b/native/src/service.h new file mode 100644 index 0000000..3b678bd --- /dev/null +++ b/native/src/service.h @@ -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 +#include +#include +#include + +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); diff --git a/native/src/sinks/sink.c b/native/src/sinks/sink.c new file mode 100644 index 0000000..0ff4693 --- /dev/null +++ b/native/src/sinks/sink.c @@ -0,0 +1,53 @@ +#include "sink.h" +#include "../common/log.h" + +#include +#include + +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); +} diff --git a/native/src/sinks/sink.h b/native/src/sinks/sink.h new file mode 100644 index 0000000..c142516 --- /dev/null +++ b/native/src/sinks/sink.h @@ -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 +#include +#include + +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); diff --git a/native/src/sinks/sink_http.c b/native/src/sinks/sink_http.c new file mode 100644 index 0000000..a2aa2c4 --- /dev/null +++ b/native/src/sinks/sink_http.c @@ -0,0 +1,219 @@ +// HTTP audio stream served by the TV. +// +// The friendliest sink to test with, because everything already speaks HTTP: +// +// vlc http://:4012/audio.wav +// ffplay http://:4012/audio.wav +// mpv http://: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 +#include +#include + +#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://:%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://:4012/audio.wav in VLC, ffplay or mpv. Easiest way to confirm capture works.", + .open = http_open, +}; diff --git a/native/src/sinks/sink_hyperhdr.c b/native/src/sinks/sink_hyperhdr.c new file mode 100644 index 0000000..22f223e --- /dev/null +++ b/native/src/sinks/sink_hyperhdr.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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, +}; diff --git a/native/src/sinks/sink_hyperhdr_viz.c b/native/src/sinks/sink_hyperhdr_viz.c new file mode 100644 index 0000000..9d5343b --- /dev/null +++ b/native/src/sinks/sink_hyperhdr_viz.c @@ -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 +#include +#include +#include +#include + +#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, +}; diff --git a/native/src/sinks/sink_tcp.c b/native/src/sinks/sink_tcp.c new file mode 100644 index 0000000..b59f78b --- /dev/null +++ b/native/src/sinks/sink_tcp.c @@ -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 4011 | aplay -f S16_LE -r 48000 -c 2 + +#include "sink.h" +#include "../common/log.h" +#include "../net/streamserv.h" + +#include +#include +#include + +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, +}; diff --git a/native/src/sinks/sink_udp.c b/native/src/sinks/sink_udp.c new file mode 100644 index 0000000..56a4410 --- /dev/null +++ b/native/src/sinks/sink_udp.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +#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, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d3cd327 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/servicefiles/audiocapautostart b/servicefiles/audiocapautostart new file mode 100755 index 0000000..750d7db --- /dev/null +++ b/servicefiles/audiocapautostart @@ -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 diff --git a/servicefiles/package.json b/servicefiles/package.json new file mode 100644 index 0000000..bfef402 --- /dev/null +++ b/servicefiles/package.json @@ -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" +} diff --git a/servicefiles/services.json b/servicefiles/services.json new file mode 100644 index 0000000..251f93c --- /dev/null +++ b/servicefiles/services.json @@ -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" + } + ] +} diff --git a/test/engine_smoke.c b/test/engine_smoke.c new file mode 100644 index 0000000..501a5d8 --- /dev/null +++ b/test/engine_smoke.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/test/fb_dump.c b/test/fb_dump.c new file mode 100644 index 0000000..85216e8 --- /dev/null +++ b/test/fb_dump.c @@ -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 +#include +#include + +int main(int argc, char** argv) +{ + if (argc < 3) { + fprintf(stderr, "usage: %s \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; +} diff --git a/test/rtp_send.c b/test/rtp_send.c new file mode 100644 index 0000000..70c9ddd --- /dev/null +++ b/test/rtp_send.c @@ -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 [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 +#include +#include + +// 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 [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; +} diff --git a/test/run-tests.sh b/test/run-tests.sh new file mode 100755 index 0000000..e9fcd2b --- /dev/null +++ b/test/run-tests.sh @@ -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 diff --git a/test/stubs/glib-unix.h b/test/stubs/glib-unix.h new file mode 100644 index 0000000..ce5343a --- /dev/null +++ b/test/stubs/glib-unix.h @@ -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); diff --git a/test/stubs/glib.h b/test/stubs/glib.h new file mode 100644 index 0000000..fe8bab2 --- /dev/null +++ b/test/stubs/glib.h @@ -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 + +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); diff --git a/test/stubs/luna-service2/lunaservice.h b/test/stubs/luna-service2/lunaservice.h new file mode 100644 index 0000000..650e410 --- /dev/null +++ b/test/stubs/luna-service2/lunaservice.h @@ -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 +#include + +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); diff --git a/test/ui_smoke.js b/test/ui_smoke.js new file mode 100644 index 0000000..6f72eee --- /dev/null +++ b/test/ui_smoke.js @@ -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); +}); diff --git a/test/verify_flatbuf.py b/test/verify_flatbuf.py new file mode 100644 index 0000000..6c07873 --- /dev/null +++ b/test/verify_flatbuf.py @@ -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(" 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()) diff --git a/tools/build.sh b/tools/build.sh new file mode 100755 index 0000000..914d33a --- /dev/null +++ b/tools/build.sh @@ -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 </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*', '', 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 diff --git a/tools/make-assets.py b/tools/make-assets.py new file mode 100755 index 0000000..779d3f7 --- /dev/null +++ b/tools/make-assets.py @@ -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()) diff --git a/tools/make-manifest.py b/tools/make-manifest.py new file mode 100755 index 0000000..19dec7e --- /dev/null +++ b/tools/make-manifest.py @@ -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()) diff --git a/tools/tv-probe.sh b/tools/tv-probe.sh new file mode 100755 index 0000000..ed25d31 --- /dev/null +++ b/tools/tv-probe.sh @@ -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