Compare commits
2
Commits
d2931bee63
...
aae5a33283
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae5a33283 | ||
|
|
f0f68a1aa7 |
@@ -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 |
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
|
||||
+45
-8
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "org.webosbrew.audiocap",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.3",
|
||||
"vendor": "Homebrew",
|
||||
"type": "web",
|
||||
"main": "index.html",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
</head>
|
||||
|
||||
+51
-3
@@ -13,6 +13,16 @@
|
||||
var ELEVATE = '/media/developer/apps/usr/palm/services/'
|
||||
+ '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 = {
|
||||
settings: {},
|
||||
status: null,
|
||||
@@ -22,6 +32,7 @@
|
||||
persistent: true,
|
||||
bootLinked: false,
|
||||
diagnostics: null,
|
||||
appVersion: readAppVersion(),
|
||||
};
|
||||
|
||||
var statusSub = null;
|
||||
@@ -192,8 +203,10 @@
|
||||
// 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
|
||||
// System > Run diagnostics already fetches — nothing new to ask the
|
||||
// service for. Format is tab-separated: index, name, driver, sample_spec,
|
||||
// state.
|
||||
// service for. Stock PulseAudio tab-separates columns (index, name,
|
||||
// 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() {
|
||||
var diag = state.diagnostics && state.diagnostics.system;
|
||||
var text = diag && diag.pactlSources;
|
||||
@@ -202,7 +215,7 @@
|
||||
return out;
|
||||
}
|
||||
text.split('\n').forEach(function (line) {
|
||||
var cols = line.split('\t');
|
||||
var cols = line.trim().split(/\s+/);
|
||||
var name = cols[1];
|
||||
if (!name) {
|
||||
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: [
|
||||
{
|
||||
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 '
|
||||
+ '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 '
|
||||
@@ -526,6 +570,7 @@
|
||||
|
||||
var info = $('config-path');
|
||||
UI.clear(info);
|
||||
info.appendChild(infoItem('App version', state.appVersion));
|
||||
info.appendChild(infoItem('Path', state.configPath || '—'));
|
||||
info.appendChild(infoItem('Storage', state.persistent
|
||||
? 'Persistent' : 'Temporary (/tmp)'));
|
||||
@@ -648,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');
|
||||
}
|
||||
|
||||
+10
-4
@@ -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.' },
|
||||
@@ -175,11 +179,13 @@
|
||||
pulseSockets: ['/var/run/pulse/native'],
|
||||
// Realistic shape: a TV that mixes several per-app sinks down to
|
||||
// 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: [
|
||||
'0\ttpcm_output.monitor\tmodule-combine-sink.c\ts16le 2ch 48000Hz\tRUNNING',
|
||||
'1\ttpmedia.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE',
|
||||
'2\ttpeffects.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE',
|
||||
'3\ttptts.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tSUSPENDED',
|
||||
'0 tpcm_output.monitor module-combine-sink.c s16le 2ch 48000Hz RUNNING',
|
||||
'1 tpmedia.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
|
||||
'2 tpeffects.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
|
||||
'3 tptts.monitor module-alsa-card.c s16le 2ch 48000Hz SUSPENDED',
|
||||
].join('\n'),
|
||||
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
|
||||
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lgtv-audio-cap",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lgtv-audio-cap",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@webosose/ares-cli": "^2.4.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lgtv-audio-cap",
|
||||
"version": "1.0.1",
|
||||
"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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "audiocap-service"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-1
@@ -114,6 +114,13 @@ async function main() {
|
||||
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);
|
||||
// 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');
|
||||
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.');
|
||||
|
||||
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"]'));
|
||||
|
||||
+7
-5
@@ -143,12 +143,14 @@ stage() {
|
||||
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()
|
||||
python3 - "$STAGE_APP/index.html" "$ROOT/frontend/appinfo.json" <<'EOF'
|
||||
import json, re, sys
|
||||
html_path, appinfo_path = sys.argv[1], sys.argv[2]
|
||||
html = open(html_path).read()
|
||||
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
|
||||
|
||||
cp "$ROOT/servicefiles/services.json" "$STAGE_SERVICE/"
|
||||
|
||||
Reference in New Issue
Block a user