diff --git a/README.md b/README.md index b96d197..6be502b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Each can run at the same time as the others. | --- | --- | --- | | **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 | +| **HyperHDR brightness** | JSON-RPC, port 19444 | an existing grabber/ambilight setup — keeps its colour, only pulses brightness | | **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 | diff --git a/docs/configuration.md b/docs/configuration.md index 3e2a834..8f9e887 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,6 +93,21 @@ payload so nothing fragments on a normal Ethernet MTU. | `saturation` | `1.0` | colour intensity | | `minBrightness` | `0.02` | floor so the lights never go fully black | +### `hyperhdrAdjust` — brightness only, via HyperHDR's JSON-RPC + +Sends no image at all. Instead it calls HyperHDR's `adjustment` command — +a post-processing stage that scales output brightness regardless of which +priority is currently active — so an existing grabber or effect keeps +deciding colour and only overall brightness reacts to sound. + +| Key | Default | Meaning | +| --- | --- | --- | +| `host` | `""` | HyperHDR's address | +| `port` | `19444` | HyperHDR's classic JSON-RPC port (not 8090, not 19400) | +| `level` | `"rms"` | `rms` (steadier) or `peak` (punchier) | +| `minScale` | `0.3` | brightness during silence; `1.0` is HyperHDR's normal level | +| `maxScale` | `1.3` | brightness at full level; HyperHDR accepts up to `2.0` | + ### `udp`, `tcp`, `http` | Key | Default | Meaning | @@ -178,7 +193,8 @@ 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. +visualiser, `connected`/`updatesSent`/`minScale`/`maxScale`/`lastError` for +the brightness sink. ### Diagnostics diff --git a/docs/hyperhdr.md b/docs/hyperhdr.md index be3b4f6..04a2a00 100644 --- a/docs/hyperhdr.md +++ b/docs/hyperhdr.md @@ -166,17 +166,54 @@ lights go fully dark between beats, which looks dramatic and slightly broken. --- +## 4. Keep your grabber's colour, only pulse the brightness + +For an ambilight-style setup that already has a real colour source — a +screen grabber, a USB capture card, an HDMI splitter — routes 1–3 all have +the same problem: they compete for HyperHDR's priority and *replace* that +colour with something audio-derived. This route doesn't touch colour at all. + +HyperHDR has a JSON-RPC `adjustment` command that scales output brightness as +a post-processing step, applied on top of whatever priority is currently +active. This sink sends nothing but that: no image, no priority +registration, so the grabber keeps deciding hue and this only turns the +result up and down with the sound. + +``` +TV ──RTP or local──► audiocap-service ──JSON-RPC "adjustment"──► HyperHDR + (still showing + the grabber's colour) +``` + +*Outputs → HyperHDR brightness (JSON-RPC)* + +| Setting | Value | +| --- | --- | +| HyperHDR address | the HyperHDR machine's IP | +| JSON-RPC port | 19444 (HyperHDR's classic control port — not 8090, the web UI; not 19400, Flatbuffers) | +| Follows | Average level (steadier) or Peak level (punchier) | +| Minimum brightness | `1.0` = HyperHDR's normal brightness; lower dims during quiet parts | +| Maximum brightness | up to `2.0`; boosts past normal on loud peaks | + +Needs a working capture source the same as every other route — see the top +of this document for picking one. On close, the sink resets `scaleOutput` to +`1.0` rather than leaving the LEDs stuck at whatever it last sent. + +--- + ## Which one to use -| | Route 1 | Route 2 | Route 3 | -| --- | --- | --- | --- | -| Host software | receiver + loopback | none | none | -| HyperHDR effects | all of them | all of them | none, the TV renders | -| Latency | ~100 ms | ~100 ms, less stable | ~40 ms | -| Robustness | good | depends on your PulseAudio | good | -| Setup time | 10 minutes | 2 minutes if it works | 1 minute | +| | Route 1 | Route 2 | Route 3 | Route 4 | +| --- | --- | --- | --- | --- | +| Host software | receiver + loopback | none | none | none | +| HyperHDR effects | all of them | all of them | none, the TV renders | your existing grabber, untouched | +| Colour source | HyperHDR's built-in audio effect | HyperHDR's built-in audio effect | this app's synthetic spectrum | your grabber — this only adjusts brightness | +| Latency | ~100 ms | ~100 ms, less stable | ~40 ms | ~50 ms | +| Robustness | good | depends on your PulseAudio | good | good | +| Setup time | 10 minutes | 2 minutes if it works | 1 minute | 1 minute | -Route 1 unless you have a reason. +Route 1 for HyperHDR's own audio effects. Route 4 if you already have a +grabber and just want it to breathe with the sound instead of being replaced. --- diff --git a/frontend/appinfo.json b/frontend/appinfo.json index d543072..08c8942 100644 --- a/frontend/appinfo.json +++ b/frontend/appinfo.json @@ -1,6 +1,6 @@ { "id": "org.webosbrew.audiocap", - "version": "1.0.2", + "version": "1.0.3", "vendor": "Homebrew", "type": "web", "main": "index.html", diff --git a/frontend/js/app.js b/frontend/js/app.js index a34f9f3..e0a8111 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -383,6 +383,33 @@ }, ], + hyperhdrAdjust: [ + { + path: 'hyperhdrAdjust.host', label: 'HyperHDR address', type: 'text', wide: true, + placeholder: '192.168.1.50', + }, + { + path: 'hyperhdrAdjust.port', label: 'JSON-RPC port', type: 'number', + hint: 'HyperHDR\'s classic control port, 19444 by default. Not the ' + + 'web UI port (8090) or the Flatbuffers port (19400).', + }, + { + path: 'hyperhdrAdjust.level', label: 'Follows', type: 'choice', + options: [ + { value: 'rms', label: 'Average level (steadier)' }, + { value: 'peak', label: 'Peak level (punchier)' }, + ], + }, + { + path: 'hyperhdrAdjust.minScale', label: 'Minimum brightness', type: 'number', + hint: '1.0 is HyperHDR\'s normal brightness. Below that dims during quiet parts.', + }, + { + path: 'hyperhdrAdjust.maxScale', label: 'Maximum brightness', type: 'number', + hint: 'Above 1.0 boosts past normal on loud peaks. HyperHDR accepts up to 2.0.', + }, + ], + udp: [ { path: 'udp.host', label: 'Destination', type: 'text', wide: true, @@ -414,6 +441,10 @@ 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.', + hyperhdrAdjust: 'For an existing ambilight/grabber setup: leaves colour ' + + 'entirely to whatever HyperHDR is already showing, and only turns its ' + + 'overall brightness up and down with the sound. Sends no image, so it ' + + 'never competes for priority with a grabber.', 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 ' @@ -662,6 +693,9 @@ if (s.framesSent !== undefined) { bits.push(s.framesSent.toLocaleString() + ' frames'); } + if (s.updatesSent !== undefined) { + bits.push(s.updatesSent.toLocaleString() + ' updates'); + } if (s.connected !== undefined) { bits.push(s.connected ? 'connected' : 'not connected'); } diff --git a/frontend/js/mock.js b/frontend/js/mock.js index b5003af..175f5ad 100644 --- a/frontend/js/mock.js +++ b/frontend/js/mock.js @@ -15,6 +15,9 @@ host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30, mode: 'spectrum', saturation: 1.0, minBrightness: 0.02, }, + hyperhdrAdjust: { + host: '', port: 19444, minScale: 0.3, maxScale: 1.3, level: 'rms', + }, udp: { host: '', port: 4010, multicastTtl: 4 }, tcp: { port: 4011, maxClients: 4 }, http: { port: 4012, maxClients: 4 }, @@ -146,6 +149,7 @@ 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: 'hyperhdrAdjust', name: 'HyperHDR brightness (JSON-RPC)', description: 'Only adjusts brightness; colour stays with HyperHDR\'s own grabber.' }, { 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.' }, diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index e496e6a..7255622 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(audiocap-service src/sinks/sink.c src/sinks/sink_hyperhdr.c src/sinks/sink_hyperhdr_viz.c + src/sinks/sink_hyperhdr_adjust.c src/sinks/sink_udp.c src/sinks/sink_tcp.c src/sinks/sink_http.c diff --git a/native/src/config.c b/native/src/config.c index 7302ebc..7367f7d 100644 --- a/native/src/config.c +++ b/native/src/config.c @@ -48,6 +48,13 @@ static const char* DEFAULTS_JSON = " \"saturation\": 1.0," " \"minBrightness\": 0.02" " }," + " \"hyperhdrAdjust\": {" + " \"host\": \"\"," + " \"port\": 19444," + " \"minScale\": 0.3," + " \"maxScale\": 1.3," + " \"level\": \"rms\"" + " }," " \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 }," " \"tcp\": { \"port\": 4011, \"maxClients\": 4 }," " \"http\": { \"port\": 4012, \"maxClients\": 4 }" diff --git a/native/src/sinks/sink.c b/native/src/sinks/sink.c index 0ff4693..9507e90 100644 --- a/native/src/sinks/sink.c +++ b/native/src/sinks/sink.c @@ -7,6 +7,7 @@ static const sink_driver_t* const s_drivers[] = { &sink_driver_hyperhdr, &sink_driver_hyperhdr_viz, + &sink_driver_hyperhdr_adjust, &sink_driver_udp, &sink_driver_tcp, &sink_driver_http, diff --git a/native/src/sinks/sink.h b/native/src/sinks/sink.h index c142516..748e7e7 100644 --- a/native/src/sinks/sink.h +++ b/native/src/sinks/sink.h @@ -39,6 +39,7 @@ struct sink { // 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_hyperhdr_adjust; extern const sink_driver_t sink_driver_udp; extern const sink_driver_t sink_driver_tcp; extern const sink_driver_t sink_driver_http; diff --git a/native/src/sinks/sink_hyperhdr_adjust.c b/native/src/sinks/sink_hyperhdr_adjust.c new file mode 100644 index 0000000..85d532c --- /dev/null +++ b/native/src/sinks/sink_hyperhdr_adjust.c @@ -0,0 +1,286 @@ +// Global brightness modulation via HyperHDR's own JSON-RPC "adjustment" +// command, so a real picture source (HyperHDR's screen grabber, USB capture +// card, whatever is already driving the LEDs) keeps deciding colour, and +// only the overall brightness reacts to sound. Unlike every other sink here, +// this one deliberately sends no picture at all: send one would mean +// competing for priority against the grabber, replacing its colour outright +// instead of layering on top of it. "adjustment" is a post-processing stage +// that applies regardless of which priority is currently active, which is +// exactly the layering this needs. +// +// Plain newline-delimited JSON over TCP -- HyperHDR's classic control port, +// default 19444 -- a world simpler than the Flatbuffers image protocol the +// visualiser sink speaks. There is no handshake or registration: any client +// on this port can send commands immediately after connecting. + +#include "sink.h" +#include "../common/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define RECONNECT_INTERVAL_SEC 5 +#define CONNECT_TIMEOUT_MS 3000 +#define SEND_INTERVAL_MS 50 // 20 Hz; smoother than that buys nothing visible + +typedef struct { + struct sockaddr_in dest; + char host[128]; + int port; + + float min_scale; + float max_scale; + bool use_rms; // rms is steadier than peak, which reacts to single transients + + int fd; // -1 when not connected or still connecting + bool connected; // fd is open and the non-blocking connect finished + struct timespec connect_started; + time_t last_connect_attempt; + struct timespec last_send; + char last_error[192]; + + unsigned long long updates_sent; + unsigned long long connect_failures; +} adjust_priv_t; + +static long elapsed_ms(const struct timespec* since) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (now.tv_sec - since->tv_sec) * 1000 + (now.tv_nsec - since->tv_nsec) / 1000000; +} + +static void adjust_disconnect(adjust_priv_t* p) +{ + if (p->fd >= 0) + close(p->fd); + p->fd = -1; + p->connected = false; +} + +static void adjust_try_connect(adjust_priv_t* p) +{ + if (p->fd >= 0) + return; + time_t now = time(NULL); + if (now - p->last_connect_attempt < RECONNECT_INTERVAL_SEC) + return; + p->last_connect_attempt = now; + + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + snprintf(p->last_error, sizeof(p->last_error), "socket(): %s", strerror(errno)); + return; + } + int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + + int rc = connect(fd, (struct sockaddr*)&p->dest, sizeof(p->dest)); + if (rc != 0 && errno != EINPROGRESS) { + snprintf(p->last_error, sizeof(p->last_error), "connect(): %s", strerror(errno)); + p->connect_failures++; + close(fd); + return; + } + p->fd = fd; + p->connected = (rc == 0); + clock_gettime(CLOCK_MONOTONIC, &p->connect_started); +} + +// Finishes a connect that was started non-blocking. Returns true once the fd +// is usable (successfully connected), false if it is still pending or has +// failed -- in which case the fd is already closed. +static bool adjust_pump_connect(adjust_priv_t* p) +{ + if (p->connected) + return true; + if (p->fd < 0) + return false; + + struct pollfd pfd = { .fd = p->fd, .events = POLLOUT }; + int pr = poll(&pfd, 1, 0); + if (pr < 0) + return false; + if (pr == 0) { + if (elapsed_ms(&p->connect_started) > CONNECT_TIMEOUT_MS) { + snprintf(p->last_error, sizeof(p->last_error), "connect timed out"); + adjust_disconnect(p); + p->connect_failures++; + } + return false; + } + + int soerr = 0; + socklen_t slen = sizeof(soerr); + if (getsockopt(p->fd, SOL_SOCKET, SO_ERROR, &soerr, &slen) != 0) + soerr = errno; + if (soerr != 0) { + snprintf(p->last_error, sizeof(p->last_error), "connect: %s", strerror(soerr)); + adjust_disconnect(p); + p->connect_failures++; + return false; + } + + p->connected = true; + return true; +} + +static void adjust_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels) +{ + (void)pcm; + (void)frames; + adjust_priv_t* p = s->priv; + + adjust_try_connect(p); + if (!adjust_pump_connect(p)) + return; + + if (elapsed_ms(&p->last_send) < SEND_INTERVAL_MS) + return; + clock_gettime(CLOCK_MONOTONIC, &p->last_send); + + float level = p->use_rms ? levels->rms : levels->peak; + if (level < 0) + level = 0; + if (level > 1) + level = 1; + float scale = p->min_scale + level * (p->max_scale - p->min_scale); + + char msg[128]; + int n = snprintf(msg, sizeof(msg), + "{\"command\":\"adjustment\",\"adjustment\":{\"scaleOutput\":%.3f}}\n", (double)scale); + if (n <= 0 || (size_t)n >= sizeof(msg)) + return; + + ssize_t sent = send(p->fd, msg, (size_t)n, MSG_NOSIGNAL); + if (sent < 0) { + snprintf(p->last_error, sizeof(p->last_error), "send(): %s", strerror(errno)); + adjust_disconnect(p); + return; + } + p->updates_sent++; + + // HyperHDR acks every command. Drain it so the socket's receive buffer + // never backs up; MSG_DONTWAIT keeps this off the capture thread's + // critical path even if HyperHDR is slow to reply. + char ack[256]; + while (recv(p->fd, ack, sizeof(ack), MSG_DONTWAIT) > 0) { } +} + +static void adjust_status(sink_t* s, json_writer_t* w) +{ + adjust_priv_t* p = s->priv; + jw_str(w, "target", p->host); + jw_int(w, "port", p->port); + jw_bool(w, "connected", p->connected); + jw_num(w, "minScale", p->min_scale); + jw_num(w, "maxScale", p->max_scale); + jw_int(w, "updatesSent", (long long)p->updates_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 adjust_close(sink_t* s) +{ + adjust_priv_t* p = s->priv; + if (p) { + // Best effort: hand brightness back to normal rather than leaving + // the LEDs stuck at whatever scale was last sent. + if (p->connected) { + static const char reset[] = "{\"command\":\"adjustment\",\"adjustment\":{\"scaleOutput\":1.0}}\n"; + send(p->fd, reset, sizeof(reset) - 1, MSG_NOSIGNAL); + } + adjust_disconnect(p); + free(p); + } + free(s); +} + +static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen) +{ + const json_value_t* sc = json_get(cfg, "hyperhdrAdjust"); + const char* host = json_str(sc, "host", NULL); + int port = json_int(sc, "port", 19444); + float min_scale = (float)json_num(sc, "minScale", 0.3); + float max_scale = (float)json_num(sc, "maxScale", 1.3); + const char* level_source = json_str(sc, "level", "rms"); + + if (!host || !*host) { + snprintf(err, errlen, "set the HyperHDR host address first"); + return NULL; + } + if (port <= 0 || port > 65535) { + snprintf(err, errlen, "invalid HyperHDR JSON port %d", port); + return NULL; + } + if (max_scale <= min_scale) { + snprintf(err, errlen, "maxScale must be greater than minScale"); + return NULL; + } + + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + 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; + } + + adjust_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; + memcpy(&p->dest, res->ai_addr, sizeof(struct sockaddr_in)); + freeaddrinfo(res); + + snprintf(p->host, sizeof(p->host), "%s", host); + p->port = port; + p->min_scale = min_scale; + p->max_scale = max_scale; + p->use_rms = strcmp(level_source, "peak") != 0; + + s->driver = &sink_driver_hyperhdr_adjust; + s->priv = p; + s->fmt = *fmt; + s->write = adjust_write; + s->status = adjust_status; + s->close = adjust_close; + + INFO("HyperHDR adjustment sink: %s:%d, scaleOutput %.2f..%.2f from %s", host, port, + min_scale, max_scale, p->use_rms ? "rms" : "peak"); + return s; +} + +const sink_driver_t sink_driver_hyperhdr_adjust = { + .id = "hyperhdrAdjust", + .name = "HyperHDR brightness (JSON-RPC)", + .description = "Leaves colour to HyperHDR's own grabber/effect and only " + "modulates overall brightness with the audio level.", + .open = adjust_open, +}; diff --git a/package.json b/package.json index a5f6e36..29a6326 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lgtv-audio-cap", - "version": "1.0.2", + "version": "1.0.3", "private": true, "description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.", "keywords": [ diff --git a/servicefiles/package.json b/servicefiles/package.json index ba85780..dbfc9f4 100644 --- a/servicefiles/package.json +++ b/servicefiles/package.json @@ -1,6 +1,6 @@ { "id": "org.webosbrew.audiocap.service", - "version": "1.0.2", + "version": "1.0.3", "description": "Captures TV audio and streams it to HyperHDR and other receivers", "main": "audiocap-service" } diff --git a/test/run-tests.sh b/test/run-tests.sh index 59af1c9..2ac31fc 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -33,6 +33,7 @@ SOURCES=( native/src/sinks/sink.c native/src/sinks/sink_hyperhdr.c native/src/sinks/sink_hyperhdr_viz.c + native/src/sinks/sink_hyperhdr_adjust.c native/src/sinks/sink_udp.c native/src/sinks/sink_tcp.c native/src/sinks/sink_http.c diff --git a/test/ui_smoke.js b/test/ui_smoke.js index ce128ba..8ff2288 100644 --- a/test/ui_smoke.js +++ b/test/ui_smoke.js @@ -129,7 +129,7 @@ async function main() { 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); + eq('six sink cards', doc.querySelectorAll('.sink-card').length, 6); 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"]'));