Add a brightness-only sink: keep the grabber's colour, pulse with sound
Every existing HyperHDR route replaces whatever else is on the LEDs: routes 1/2 hand HyperHDR's own audio effect a device to read, route 3 sends a synthetic spectrum image, and both take over via HyperHDR's priority system. For a setup that already has a real colour source (a screen grabber, a USB capture card) feeding an ambilight-style LED run, none of that is what's wanted -- the colour should stay put and only brightness should react. Read HyperHDR's own source (sources/api/JSONRPC_schema/schema-adjustment.json) rather than guess: "adjustment" is a post-processing command with a scaleOutput parameter (0-2.0) that applies regardless of which priority is currently active. Confirmed the wire format too -- sources/jsonserver/JsonClientConnection.cpp frames it as plain newline-delimited JSON over TCP (default port 19444), nothing like the length-prefixed Flatbuffers protocol the visualiser sink speaks, and with no handshake or registration needed before the first write. sink_hyperhdr_adjust.c sends only that: no image, no priority, so it never competes with an existing grabber. Non-blocking connect with the same poll()+SO_ERROR pattern net/hyperion.c already uses, reconnects every 5s, rate-limited to 20 Hz (a HyperHDR command every audio block would be pointless flooding), and resets scaleOutput to 1.0 on close rather than leaving the LEDs stuck at whatever it last sent. Cross- compiles clean under -Wall -Wextra on the real webOS toolchain. Wired through the same path every other sink follows: registered in sink.c/sink.h, defaults in config.c, fields in frontend/js/app.js (SINK_FIELDS/SINK_HELP), mock.js and ui_smoke.js updated for the new sink card. Bumped to 1.0.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0f68a1aa7
commit
aae5a33283
@@ -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
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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,
|
||||
};
|
||||
Reference in New Issue
Block a user