Compare commits

...
2 Commits
Author SHA1 Message Date
Rene KievitsandClaude Opus 5 aae5a33283 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>
2026-08-26 14:36:31 +02:00
Rene KievitsandClaude Opus 5 f0f68a1aa7 Tolerate space-padded pactl output, and show the running app version
The device-source parser assumed pactl list short sources is strictly
tab-separated, true for stock PulseAudio but not guaranteed for a
TV's own heavily customized audio stack (this one names sources
tpcm_output/tpmedia/tptts/... — clearly not vanilla). A different
separator would have silently produced zero parsed sources with no
error, and the picker's own `when` guard would then just hide the row
entirely rather than show anything broken. Split on any whitespace
run instead of a literal tab; source names never contain embedded
whitespace, so this is strictly more permissive with no new failure
mode. Confirmed end to end on real hardware: tptts.monitor lit up
during the accessibility voice guide and reached HyperHDR.

Also: the System panel now shows the app's actual running version,
read from a <meta> tag substituted at package time (tools/build.sh
stage()) from frontend/appinfo.json — not hand-maintained, so it can't
drift from what was actually built. Requested after a version bump
alone wasn't enough to tell whether a reinstall had truly picked up
new files versus served something cached along the way; this settles
that question by inspection instead of by inference. Bumped to 1.0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:11:29 +02:00
18 changed files with 445 additions and 27 deletions
+1
View File
@@ -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 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 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 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 | | **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 | | **HTTP WAV** | `http://tv:4012/audio.wav` | opening the TV's audio in VLC |
+17 -1
View File
@@ -93,6 +93,21 @@ payload so nothing fragments on a normal Ethernet MTU.
| `saturation` | `1.0` | colour intensity | | `saturation` | `1.0` | colour intensity |
| `minBrightness` | `0.02` | floor so the lights never go fully black | | `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` ### `udp`, `tcp`, `http`
| Key | Default | Meaning | | 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 Each sink adds its own fields. `packetsSent`/`bytesSent`/`sendErrors` for the
datagram sinks, `clients`/`droppedBytes` for the stream servers, datagram sinks, `clients`/`droppedBytes` for the stream servers,
`connected`/`registered`/`framesSent`/`connectFailures`/`lastError` for the `connected`/`registered`/`framesSent`/`connectFailures`/`lastError` for the
visualiser. visualiser, `connected`/`updatesSent`/`minScale`/`maxScale`/`lastError` for
the brightness sink.
### Diagnostics ### Diagnostics
+45 -8
View File
@@ -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 ## Which one to use
| | Route 1 | Route 2 | Route 3 | | | Route 1 | Route 2 | Route 3 | Route 4 |
| --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Host software | receiver + loopback | none | none | | Host software | receiver + loopback | none | none | none |
| HyperHDR effects | all of them | all of them | none, the TV renders | | HyperHDR effects | all of them | all of them | none, the TV renders | your existing grabber, untouched |
| Latency | ~100 ms | ~100 ms, less stable | ~40 ms | | 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 |
| Robustness | good | depends on your PulseAudio | good | | Latency | ~100 ms | ~100 ms, less stable | ~40 ms | ~50 ms |
| Setup time | 10 minutes | 2 minutes if it works | 1 minute | | 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.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "org.webosbrew.audiocap", "id": "org.webosbrew.audiocap",
"version": "1.0.1", "version": "1.0.3",
"vendor": "Homebrew", "vendor": "Homebrew",
"type": "web", "type": "web",
"main": "index.html", "main": "index.html",
+4
View File
@@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=1920, initial-scale=1"> <meta name="viewport" content="width=1920, initial-scale=1">
<!-- Substituted at package time from frontend/appinfo.json (tools/build.sh
stage()), so what the System panel shows is what actually got built
into this ipk — not something that can itself go stale in a cache. -->
<meta name="app-version" content="__APP_VERSION__">
<title>Audio Cap</title> <title>Audio Cap</title>
<link rel="stylesheet" href="css/app.css"> <link rel="stylesheet" href="css/app.css">
</head> </head>
+51 -3
View File
@@ -13,6 +13,16 @@
var ELEVATE = '/media/developer/apps/usr/palm/services/' var ELEVATE = '/media/developer/apps/usr/palm/services/'
+ 'org.webosbrew.hbchannel.service/elevate-service'; + 'org.webosbrew.hbchannel.service/elevate-service';
// Substituted into index.html at package time (tools/build.sh stage()).
// Unstaged — opened straight from the source tree, e.g. npm run serve —
// it is still the literal placeholder, which is exactly the tell that
// this isn't a packaged build.
function readAppVersion() {
var meta = document.querySelector('meta[name="app-version"]');
var content = meta && meta.getAttribute('content');
return content && content.indexOf('__') !== 0 ? content : 'dev build';
}
var state = { var state = {
settings: {}, settings: {},
status: null, status: null,
@@ -22,6 +32,7 @@
persistent: true, persistent: true,
bootLinked: false, bootLinked: false,
diagnostics: null, diagnostics: null,
appVersion: readAppVersion(),
}; };
var statusSub = null; var statusSub = null;
@@ -192,8 +203,10 @@
// only read on the TV itself, is exactly the kind of thing a D-pad picker // only read on the TV itself, is exactly the kind of thing a D-pad picker
// exists for. Parsed from the same "pactl list short sources" text that // exists for. Parsed from the same "pactl list short sources" text that
// System > Run diagnostics already fetches — nothing new to ask the // System > Run diagnostics already fetches — nothing new to ask the
// service for. Format is tab-separated: index, name, driver, sample_spec, // service for. Stock PulseAudio tab-separates columns (index, name,
// state. // driver, sample_spec, state), but split on any whitespace run rather than
// a literal tab: a TV's own pactl-alike is free to pad with spaces
// instead, and source names never contain embedded whitespace themselves.
function pulseSourceOptions() { function pulseSourceOptions() {
var diag = state.diagnostics && state.diagnostics.system; var diag = state.diagnostics && state.diagnostics.system;
var text = diag && diag.pactlSources; var text = diag && diag.pactlSources;
@@ -202,7 +215,7 @@
return out; return out;
} }
text.split('\n').forEach(function (line) { text.split('\n').forEach(function (line) {
var cols = line.split('\t'); var cols = line.trim().split(/\s+/);
var name = cols[1]; var name = cols[1];
if (!name) { if (!name) {
return; return;
@@ -370,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: [ udp: [
{ {
path: 'udp.host', label: 'Destination', type: 'text', wide: true, path: 'udp.host', label: 'Destination', type: 'text', wide: true,
@@ -401,6 +441,10 @@
hyperhdrViz: 'No host setup at all: the TV does the analysis and sends ' 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 ' + 'finished images over the Flatbuffers port. Use it when you cannot add '
+ 'a sound device on the HyperHDR machine.', + '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 ' udp: 'Raw interleaved S16LE, no header, no framing. Lowest latency and no '
+ 'connection to lose.', + 'connection to lose.',
tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of ' tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of '
@@ -526,6 +570,7 @@
var info = $('config-path'); var info = $('config-path');
UI.clear(info); UI.clear(info);
info.appendChild(infoItem('App version', state.appVersion));
info.appendChild(infoItem('Path', state.configPath || '—')); info.appendChild(infoItem('Path', state.configPath || '—'));
info.appendChild(infoItem('Storage', state.persistent info.appendChild(infoItem('Storage', state.persistent
? 'Persistent' : 'Temporary (/tmp)')); ? 'Persistent' : 'Temporary (/tmp)'));
@@ -648,6 +693,9 @@
if (s.framesSent !== undefined) { if (s.framesSent !== undefined) {
bits.push(s.framesSent.toLocaleString() + ' frames'); bits.push(s.framesSent.toLocaleString() + ' frames');
} }
if (s.updatesSent !== undefined) {
bits.push(s.updatesSent.toLocaleString() + ' updates');
}
if (s.connected !== undefined) { if (s.connected !== undefined) {
bits.push(s.connected ? 'connected' : 'not connected'); bits.push(s.connected ? 'connected' : 'not connected');
} }
+10 -4
View File
@@ -15,6 +15,9 @@
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30, host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02, 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 }, udp: { host: '', port: 4010, multicastTtl: 4 },
tcp: { port: 4011, maxClients: 4 }, tcp: { port: 4011, maxClients: 4 },
http: { port: 4012, maxClients: 4 }, http: { port: 4012, maxClients: 4 },
@@ -146,6 +149,7 @@
sinks: [ sinks: [
{ id: 'hyperhdr', name: 'HyperHDR audio (RTP)', description: 'RTP/L16 audio to the HyperHDR host.' }, { 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: '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: '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: '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.' }, { id: 'http', name: 'HTTP WAV stream', description: 'Open the URL in VLC.' },
@@ -175,11 +179,13 @@
pulseSockets: ['/var/run/pulse/native'], pulseSockets: ['/var/run/pulse/native'],
// Realistic shape: a TV that mixes several per-app sinks down to // Realistic shape: a TV that mixes several per-app sinks down to
// one common output, the case the device picker exists for. // one common output, the case the device picker exists for.
// Space-padded, not tab-separated — some TVs' own pactl-alike
// formats it that way, and the parser has to tolerate both.
pactlSources: [ pactlSources: [
'0\ttpcm_output.monitor\tmodule-combine-sink.c\ts16le 2ch 48000Hz\tRUNNING', '0 tpcm_output.monitor module-combine-sink.c s16le 2ch 48000Hz RUNNING',
'1\ttpmedia.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE', '1 tpmedia.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
'2\ttpeffects.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE', '2 tpeffects.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
'3\ttptts.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tSUSPENDED', '3 tptts.monitor module-alsa-card.c s16le 2ch 48000Hz SUSPENDED',
].join('\n'), ].join('\n'),
alsaCards: ['0 [Loopback]: Loopback - Loopback'], alsaCards: ['0 [Loopback]: Loopback - Loopback'],
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'], alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
+1
View File
@@ -46,6 +46,7 @@ add_executable(audiocap-service
src/sinks/sink.c src/sinks/sink.c
src/sinks/sink_hyperhdr.c src/sinks/sink_hyperhdr.c
src/sinks/sink_hyperhdr_viz.c src/sinks/sink_hyperhdr_viz.c
src/sinks/sink_hyperhdr_adjust.c
src/sinks/sink_udp.c src/sinks/sink_udp.c
src/sinks/sink_tcp.c src/sinks/sink_tcp.c
src/sinks/sink_http.c src/sinks/sink_http.c
+7
View File
@@ -48,6 +48,13 @@ static const char* DEFAULTS_JSON =
" \"saturation\": 1.0," " \"saturation\": 1.0,"
" \"minBrightness\": 0.02" " \"minBrightness\": 0.02"
" }," " },"
" \"hyperhdrAdjust\": {"
" \"host\": \"\","
" \"port\": 19444,"
" \"minScale\": 0.3,"
" \"maxScale\": 1.3,"
" \"level\": \"rms\""
" },"
" \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 }," " \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 },"
" \"tcp\": { \"port\": 4011, \"maxClients\": 4 }," " \"tcp\": { \"port\": 4011, \"maxClients\": 4 },"
" \"http\": { \"port\": 4012, \"maxClients\": 4 }" " \"http\": { \"port\": 4012, \"maxClients\": 4 }"
+1
View File
@@ -7,6 +7,7 @@
static const sink_driver_t* const s_drivers[] = { static const sink_driver_t* const s_drivers[] = {
&sink_driver_hyperhdr, &sink_driver_hyperhdr,
&sink_driver_hyperhdr_viz, &sink_driver_hyperhdr_viz,
&sink_driver_hyperhdr_adjust,
&sink_driver_udp, &sink_driver_udp,
&sink_driver_tcp, &sink_driver_tcp,
&sink_driver_http, &sink_driver_http,
+1
View File
@@ -39,6 +39,7 @@ struct sink {
// own translation unit see one declaration. // own translation unit see one declaration.
extern const sink_driver_t sink_driver_hyperhdr; 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_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_udp;
extern const sink_driver_t sink_driver_tcp; extern const sink_driver_t sink_driver_tcp;
extern const sink_driver_t sink_driver_http; extern const sink_driver_t sink_driver_http;
+286
View File
@@ -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,
};
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lgtv-audio-cap", "name": "lgtv-audio-cap",
"version": "1.0.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lgtv-audio-cap", "name": "lgtv-audio-cap",
"version": "1.0.0", "version": "1.0.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@webosose/ares-cli": "^2.4.0", "@webosose/ares-cli": "^2.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lgtv-audio-cap", "name": "lgtv-audio-cap",
"version": "1.0.1", "version": "1.0.3",
"private": true, "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.", "description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.",
"keywords": [ "keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "org.webosbrew.audiocap.service", "id": "org.webosbrew.audiocap.service",
"version": "1.0.1", "version": "1.0.3",
"description": "Captures TV audio and streams it to HyperHDR and other receivers", "description": "Captures TV audio and streams it to HyperHDR and other receivers",
"main": "audiocap-service" "main": "audiocap-service"
} }
+1
View File
@@ -33,6 +33,7 @@ SOURCES=(
native/src/sinks/sink.c native/src/sinks/sink.c
native/src/sinks/sink_hyperhdr.c native/src/sinks/sink_hyperhdr.c
native/src/sinks/sink_hyperhdr_viz.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_udp.c
native/src/sinks/sink_tcp.c native/src/sinks/sink_tcp.c
native/src/sinks/sink_http.c native/src/sinks/sink_http.c
+8 -1
View File
@@ -114,6 +114,13 @@ async function main() {
check('mock bus in use', window.Luna.available === false); check('mock bus in use', window.Luna.available === false);
check('settings loaded', !!(window.App.state.settings.capture)); check('settings loaded', !!(window.App.state.settings.capture));
eq('config path shown', $('config-path').textContent.indexOf('/var/lib/webosbrew') >= 0, true); eq('config path shown', $('config-path').textContent.indexOf('/var/lib/webosbrew') >= 0, true);
// This loads the raw source tree's index.html, not a packaged build, so
// the __APP_VERSION__ placeholder was never substituted — the fallback
// is the correct, honest thing to see here.
eq('unpackaged run shows the dev-build fallback, not a stale version',
window.App.state.appVersion, 'dev build');
check('app version shown in the System panel',
$('config-path').textContent.indexOf('dev build') >= 0);
console.log('status feed'); console.log('status feed');
eq('starts stopped', $('state-pill').textContent, 'Stopped'); eq('starts stopped', $('state-pill').textContent, 'Stopped');
@@ -122,7 +129,7 @@ async function main() {
eq('no sinks listed while stopped', $('sink-status').textContent.trim(), 'Not running.'); eq('no sinks listed while stopped', $('sink-status').textContent.trim(), 'Not running.');
console.log('panels'); 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', check('hyperhdr card is first and marked',
doc.querySelector('.sink-card .badge').textContent === 'Recommended'); doc.querySelector('.sink-card .badge').textContent === 'Recommended');
check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]')); check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]'));
+7 -5
View File
@@ -143,12 +143,14 @@ stage() {
cp -R "$ROOT/frontend/." "$STAGE_APP/" cp -R "$ROOT/frontend/." "$STAGE_APP/"
# The mock only exists so the UI can be opened in a desktop browser. # The mock only exists so the UI can be opened in a desktop browser.
rm -f "$STAGE_APP/js/mock.js" rm -f "$STAGE_APP/js/mock.js"
python3 - "$STAGE_APP/index.html" <<'EOF' python3 - "$STAGE_APP/index.html" "$ROOT/frontend/appinfo.json" <<'EOF'
import re, sys import json, re, sys
path = sys.argv[1] html_path, appinfo_path = sys.argv[1], sys.argv[2]
html = open(path).read() html = open(html_path).read()
html = re.sub(r'\s*<script src="js/mock\.js"></script>', '', html) html = re.sub(r'\s*<script src="js/mock\.js"></script>', '', html)
open(path, "w").write(html) version = json.load(open(appinfo_path))["version"]
html = html.replace("__APP_VERSION__", version)
open(html_path, "w").write(html)
EOF EOF
cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/" cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/"