Compare commits

..
2 Commits
Author SHA1 Message Date
Rene KievitsandClaude Opus 5 d3e4cb6410 Restrict the brightness sink to one app, picked by name not typed
Confirmed the brightness command applies globally, not per-LED
(serverinfo showed exactly one adjustment object, "id": "default",
covering the whole string), so no LED-count configuration is needed
for this at all -- that question resolved itself once the mechanism
was actually inspected instead of assumed.

For "only react while Spotify is running": only one app can be in the
foreground on webOS at a time, so a "capture the current app" button
in this app's own UI can never work -- pressing it means this app is
foreground, not Spotify. The only workable UI is picking a target from
every *installed* app by name, regardless of what's currently running.

That needed a new native capability this service never had: calling
OUT to another Luna service, not just being called. Two additions:

  foreground_app.c   subscribes once, at startup, to
                      com.webos.applicationManager/getForegroundAppInfo
                      and keeps a thread-safe cache the audio thread can
                      read without a blocking Luna call
  service.c           new listApps method, bridging to
                      com.webos.applicationManager/listApps so the
                      frontend never has to call another service
                      directly -- same rule as everywhere else here

Until the subscription has delivered at least one reply, a restricted
sink treats the target app as inactive, not active -- reacting to
audio when the user explicitly restricted it to one app would be the
wrong failure mode. Verified end to end on the host: engine_smoke.c
opens the sink with a restriction set, confirms it reports itself
correctly inactive against the stub Luna bus (which always "fails" to
call out, exactly like a real host with no bus).

Needed real, linkable stub bodies for LSCall/LSCallOneReply/
LSCallCancel/LSMessageGetPayload/LSErrorInit/LSErrorFree
(test/stubs/luna-service2/lunaservice_stub.c) since foreground_app.c
is the first source file here that's actually linked into a host test
binary rather than only syntax-checked -- service.c/main.c's existing
stub declarations were never called, only compiled against. Confirmed
those really are the correct symbol names by cross-compiling clean
against the real webOS SDK's actual libluna-service2, not just the
stub.

Bumped to 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:46:09 +02:00
Rene KievitsandClaude Opus 5 e9f6c87d27 Fix hyperhdrAdjust: use "brightness", not "scaleOutput"
Tested against a real HyperHDR instance rather than trusting the
schema further. scaleOutput (from the current schema-adjustment.json)
produced no visible change and no trace in serverinfo's echoed-back
adjustment state. brightness (0-100, absent from that same schema)
round-tripped correctly through serverinfo and visibly dimmed real
LEDs -- confirmed live, with piccap as the sole colour source and
only this sink's JSON-RPC calls changing anything.

A schema documents what a command accepts; it does not guarantee
what a given build actually does with each field, and this is a
mismatch between HyperHDR's current dev-branch schema and whatever
build the target instance is actually running. Switched the sink to
brightness (int 0-100) throughout: wire format, config defaults
(minBrightness/maxBrightness, 20-100), status fields, and the reset
sent on close. Renamed the frontend fields and mock to match.
Cross-compiles clean. Bumped to 1.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:59:51 +02:00
20 changed files with 485 additions and 60 deletions
+29 -9
View File
@@ -95,18 +95,36 @@ payload so nothing fragments on a normal Ethernet MTU.
### `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.
Sends no image at all. Instead it calls HyperHDR's `adjustment` command with
a `brightness` value (0-100) — a post-processing stage that applies
regardless of which priority is currently active — so an existing grabber or
capture app keeps deciding colour and only overall brightness reacts to
sound. Confirmed against a real HyperHDR instance by watching `brightness`
round-trip through `serverinfo` and the LEDs visibly respond; HyperHDR's own
`schema-adjustment.json` also documents a `scaleOutput` float (0.0-2.0), but
that field produced no effect on the same instance — what a schema declares
and what a given build actually acts on are not always the same thing.
| 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` |
| `minBrightness` | `20` | brightness (0-100) during silence |
| `maxBrightness` | `100` | brightness (0-100) at full level; HyperHDR does not go above 100 |
| `restrictToApp` | `""` | a webOS app id; blank means always active |
`restrictToApp` is set from a picker of installed apps in the UI (by name,
never typed), backed by a new `listApps` Luna method that proxies to
`com.webos.applicationManager/listApps` — the frontend never calls another
service's Luna API directly, everything goes through this service, same as
everywhere else. The service also subscribes once, at startup, to
`com.webos.applicationManager/getForegroundAppInfo` to know which app is
currently in front. Until that subscription has delivered at least one
reply, a restricted sink treats the target app as *not* active — the safe
failure mode, since silently reacting to audio when the user explicitly
restricted it to one app would be the wrong one. `getDiagnostics` exposes
the live value as `foregroundApp` if you want to confirm tracking is working.
### `udp`, `tcp`, `http`
@@ -144,7 +162,8 @@ luna-send -n 1 -f luna://org.webosbrew.audiocap.service/getStatus '{}'
| `resetConfig` | `{}` | `{saved, settings}` |
| `listBackends` | `{}` | `{backends:[{id,name,description,available}]}` |
| `listSinks` | `{}` | `{sinks:[{id,name,description}]}` |
| `getDiagnostics` | `{}` | `{backends, system}` — see below |
| `listApps` | `{}` | `{apps:[{id,title}]}` — every installed app, for the "restrict to app" picker |
| `getDiagnostics` | `{}` | `{backends, system, foregroundApp}` — see below |
| `getLogs` | `{"clear":true}` optional | `{logs}` |
| `quit` | `{}` | ends the process; the next call starts a new one |
@@ -193,8 +212,9 @@ 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, `connected`/`updatesSent`/`minScale`/`maxScale`/`lastError` for
the brightness sink.
visualiser, `connected`/`updatesSent`/`minBrightness`/`maxBrightness`/`lastError`
for the brightness sink, plus `restrictToApp`/`restrictedAppActive` when a
restriction is set.
### Diagnostics
+3
View File
@@ -186,6 +186,9 @@ service.c the Luna methods and the status subscription
engine.c the capture thread: read a block, analyse it, hand it to every sink
config.c load/merge/atomic-save of config.json
dsp.c peak/RMS envelopes and the 16-band analysis
foreground_app.c tracks which app is in front, for the "restrict to app"
brightness option -- the one place this service calls out
to another Luna service instead of being called
capture/ one file per backend, all dlopen-based
sinks/ one file per output
net/ RTP, FlatBuffers, the shared stream server
+31 -12
View File
@@ -169,15 +169,16 @@ 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.
screen grabber, a USB capture card, a webOS capture app like piccap — 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.
HyperHDR has a JSON-RPC `adjustment` command that sets output brightness
(0-100) 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 (or piccap, or whatever else) 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
@@ -192,12 +193,30 @@ TV ──RTP or local──► audiocap-service ──JSON-RPC "adjustment"─
| 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 |
| Minimum brightness | `0`-`100`; applied during quiet parts |
| Maximum brightness | `0`-`100`; applied at full level. HyperHDR does not go above 100 |
| Restrict to app | optional — only react to audio while one specific app is in the foreground |
*Restrict to app* is picked from a list of installed apps by name (e.g.
"Spotify"), never typed — only one app can be foreground at a time, so
there is no way to "capture the current app" from a button in this app's
own UI; you'd just be capturing yourself. Until the TV confirms which app
is actually in front, the sink treats the target as inactive rather than
guessing yes, so it can't accidentally react to the wrong thing while
starting up.
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.
of this document for picking one. On close, the sink resets brightness to
`100` rather than leaving the LEDs stuck at whatever it last sent.
The field is `brightness`, confirmed by watching it round-trip through
`serverinfo` and the LEDs visibly respond on a real HyperHDR/Docker
instance. HyperHDR's current `schema-adjustment.json` documents a
`scaleOutput` float (0.0-2.0) instead, which looked like the obvious choice
and is what this sink sent originally — it had no visible or server-reported
effect on that same instance. If a future HyperHDR version drops
`brightness`, this needs re-verifying the same way, not just re-reading the
schema.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "org.webosbrew.audiocap",
"version": "1.0.3",
"version": "1.0.5",
"vendor": "Homebrew",
"type": "web",
"main": "index.html",
+36 -4
View File
@@ -28,6 +28,7 @@
status: null,
backends: [],
sinkDefs: [],
installedApps: [],
configPath: '',
persistent: true,
bootLinked: false,
@@ -244,6 +245,17 @@
return out;
}
// Fetched once at boot (see the listApps call near the bottom of this
// file). Sorted by title there, so this just adds the "no restriction"
// default at the front.
function installedAppOptions() {
var out = [{ value: '', label: 'Always active' }];
state.installedApps.forEach(function (a) {
out.push({ value: a.id, label: a.title });
});
return out;
}
var CAPTURE_FIELDS = [
{
path: 'capture.backend', label: 'Backend', type: 'choice',
@@ -401,12 +413,19 @@
],
},
{
path: 'hyperhdrAdjust.minScale', label: 'Minimum brightness', type: 'number',
hint: '1.0 is HyperHDR\'s normal brightness. Below that dims during quiet parts.',
path: 'hyperhdrAdjust.minBrightness', label: 'Minimum brightness', type: 'number',
hint: '0-100. Applied during quiet parts. 100 is HyperHDR\'s normal brightness.',
},
{
path: 'hyperhdrAdjust.maxScale', label: 'Maximum brightness', type: 'number',
hint: 'Above 1.0 boosts past normal on loud peaks. HyperHDR accepts up to 2.0.',
path: 'hyperhdrAdjust.maxBrightness', label: 'Maximum brightness', type: 'number',
hint: '0-100. Applied at full level. HyperHDR does not go above 100.',
},
{
path: 'hyperhdrAdjust.restrictToApp', label: 'Restrict to app', type: 'choice',
options: installedAppOptions,
hint: 'Only react to audio while this app is in the foreground. '
+ 'Picked by name, not typed — only one app can be in front at a '
+ 'time, so there is no way to "capture the current app" from here.',
},
],
@@ -699,6 +718,9 @@
if (s.connected !== undefined) {
bits.push(s.connected ? 'connected' : 'not connected');
}
if (s.restrictToApp) {
bits.push(s.restrictedAppActive ? 'active now' : 'waiting for that app');
}
if (s.sendErrors) {
bits.push(s.sendErrors + ' send errors');
}
@@ -902,6 +924,16 @@
renderSinks();
}, fail);
// Powers the "restrict to app" picker on the brightness sink. Fetched
// once at boot, same as backends/sinks above — the installed-app list
// does not change during a session.
Luna.listApps(function (reply) {
state.installedApps = (reply.apps || []).slice().sort(function (a, b) {
return a.title.localeCompare(b.title);
});
renderSinks();
}, fail);
renderCapture();
renderSystem();
checkBootLink();
+3
View File
@@ -101,6 +101,9 @@
listSinks: function (ok, fail) {
return call(SERVICE + 'listSinks', {}, ok, fail);
},
listApps: function (ok, fail) {
return call(SERVICE + 'listApps', {}, ok, fail);
},
getDiagnostics: function (ok, fail) {
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
},
+13 -1
View File
@@ -16,7 +16,8 @@
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02,
},
hyperhdrAdjust: {
host: '', port: 19444, minScale: 0.3, maxScale: 1.3, level: 'rms',
host: '', port: 19444, minBrightness: 20, maxBrightness: 100, level: 'rms',
restrictToApp: '',
},
udp: { host: '', port: 4010, multicastTtl: 4 },
tcp: { port: 4011, maxClients: 4 },
@@ -156,6 +157,17 @@
],
});
case 'listApps':
return respond(onReply, {
returnValue: true,
apps: [
{ id: 'spotify-beehive', title: 'Spotify' },
{ id: 'netflix', title: 'Netflix' },
{ id: 'youtube.leanback.v4', title: 'YouTube' },
{ id: 'com.webos.app.livetv', title: 'Live TV' },
],
});
// Same shape as capture_write_diagnostics(): backends at the top level,
// everything about the machine under "system".
case 'getDiagnostics':
+1
View File
@@ -30,6 +30,7 @@ add_executable(audiocap-service
src/main.c
src/service.c
src/engine.c
src/foreground_app.c
src/config.c
src/dsp.c
src/common/log.c
+4 -3
View File
@@ -51,9 +51,10 @@ static const char* DEFAULTS_JSON =
" \"hyperhdrAdjust\": {"
" \"host\": \"\","
" \"port\": 19444,"
" \"minScale\": 0.3,"
" \"maxScale\": 1.3,"
" \"level\": \"rms\""
" \"minBrightness\": 20,"
" \"maxBrightness\": 100,"
" \"level\": \"rms\","
" \"restrictToApp\": \"\""
" },"
" \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 },"
" \"tcp\": { \"port\": 4011, \"maxClients\": 4 },"
+77
View File
@@ -0,0 +1,77 @@
#include "foreground_app.h"
#include "common/json.h"
#include "common/log.h"
#include <luna-service2/lunaservice.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER;
static char s_current[192] = { 0 };
static bool s_known = false;
static LSMessageToken s_token = 0;
static bool s_subscribed = false;
static bool on_reply(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)sh;
(void)ctx;
const char* payload = LSMessageGetPayload(msg);
json_value_t* root = payload ? json_parse(payload) : NULL;
if (!root)
return true;
const char* app_id = json_str(root, "appId", "");
pthread_mutex_lock(&s_lock);
snprintf(s_current, sizeof(s_current), "%s", app_id);
s_known = true;
pthread_mutex_unlock(&s_lock);
json_free(root);
return true;
}
bool foreground_app_start(LSHandle* handle, char* err, size_t errlen)
{
LSError lserror;
LSErrorInit(&lserror);
bool ok = LSCall(handle, "luna://com.webos.applicationManager/getForegroundAppInfo",
"{\"subscribe\":true}", on_reply, NULL, &s_token, &lserror);
if (!ok) {
if (err)
snprintf(err, errlen, "getForegroundAppInfo: %s", lserror.message);
LSErrorFree(&lserror);
return false;
}
s_subscribed = true;
return true;
}
void foreground_app_stop(LSHandle* handle)
{
if (!s_subscribed)
return;
LSError lserror;
LSErrorInit(&lserror);
if (!LSCallCancel(handle, s_token, &lserror)) {
DBG("LSCallCancel(foreground app subscription): %s", lserror.message);
LSErrorFree(&lserror);
}
s_subscribed = false;
}
bool foreground_app_known(void)
{
pthread_mutex_lock(&s_lock);
bool known = s_known;
pthread_mutex_unlock(&s_lock);
return known;
}
void foreground_app_current(char* out, size_t outlen)
{
pthread_mutex_lock(&s_lock);
snprintf(out, outlen, "%s", s_current);
pthread_mutex_unlock(&s_lock);
}
+28
View File
@@ -0,0 +1,28 @@
// Tracks which app is currently in the foreground on the TV, so a sink can
// ask "is it Spotify right now" without making a blocking Luna call from the
// audio capture thread.
#pragma once
#include <stdbool.h>
#include <stddef.h>
// Opaque: only foreground_app.c itself needs the real luna-service2 API
// surface. Everything else here (in particular sink_hyperhdr_adjust.c, which
// only ever calls the two read-only accessors below) stays free of that
// dependency.
typedef struct LSHandle LSHandle;
// Subscribes once to com.webos.applicationManager/getForegroundAppInfo.
// Not fatal if it fails (logs and returns false): callers should treat an
// unknown foreground app as "no restriction applies" rather than silently
// freezing every app-restricted sink forever.
bool foreground_app_start(LSHandle* handle, char* err, size_t errlen);
void foreground_app_stop(LSHandle* handle);
// True once at least one reply has come back, i.e. `current` is meaningful
// rather than just "nothing heard yet".
bool foreground_app_known(void);
// Copies the current foreground app id into `out` (best effort; may lag the
// real state by a fraction of a second). Empty string if not known yet.
void foreground_app_current(char* out, size_t outlen);
+78
View File
@@ -4,6 +4,7 @@
#include "common/log.h"
#include "config.h"
#include "engine.h"
#include "foreground_app.h"
#include "sinks/sink.h"
#include <stdarg.h>
@@ -347,6 +348,67 @@ static bool method_list_sinks(LSHandle* sh, LSMessage* msg, void* ctx)
return true;
}
// Bridges to com.webos.applicationManager/listApps so the UI can offer a
// picker of installed apps by name, without the frontend needing its own
// permission to call another service directly -- everything it does goes
// through us, the same as every other method here. `msg` outlives this
// handler's return (the reply comes later, from `on_list_apps_reply`), so it
// is ref-counted for that stretch and always unref'd exactly once.
static bool on_list_apps_reply(LSHandle* sh, LSMessage* reply, void* ctx)
{
LSMessage* original = ctx;
const char* payload = LSMessageGetPayload(reply);
json_value_t* root = payload ? json_parse(payload) : NULL;
const json_value_t* apps = root ? json_get(root, "apps") : NULL;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_arr_open(&w, "apps");
size_t count = json_len(apps);
for (size_t i = 0; i < count; i++) {
const json_value_t* app = json_at(apps, i);
const char* id = json_str(app, "id", NULL);
const char* title = json_str(app, "title", NULL);
// Skip anything without a real title (bare service ids, mostly) and
// this app itself -- restricting the sink "to itself" is meaningless.
if (!id || !title || !*title)
continue;
if (strcmp(id, "org.webosbrew.audiocap") == 0)
continue;
jw_obj_open(&w, NULL);
jw_str(&w, "id", id);
jw_str(&w, "title", title);
jw_obj_close(&w);
}
jw_arr_close(&w);
jw_obj_close(&w);
reply_json(sh, original, jw_take(&w));
if (root)
json_free(root);
LSMessageUnref(original);
return true;
}
static bool method_list_apps(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
LSMessageRef(msg);
LSError lserror;
LSErrorInit(&lserror);
bool ok = LSCallOneReply(sh, "luna://com.webos.applicationManager/listApps",
"{\"properties\":[\"id\",\"title\",\"type\"]}", on_list_apps_reply, msg, NULL, &lserror);
if (!ok) {
reply_error(sh, msg, "listApps: %s", lserror.message);
LSErrorFree(&lserror);
LSMessageUnref(msg);
}
return true;
}
static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
@@ -356,6 +418,13 @@ static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx)
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
capture_write_diagnostics(&w);
if (foreground_app_known()) {
char app[192];
foreground_app_current(app, sizeof(app));
jw_str(&w, "foregroundApp", app);
} else {
jw_null(&w, "foregroundApp");
}
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
@@ -407,6 +476,7 @@ static LSMethod s_methods[] = {
{ "resetConfig", method_reset_config, LUNA_METHOD_FLAGS_NONE },
{ "listBackends", method_list_backends, LUNA_METHOD_FLAGS_NONE },
{ "listSinks", method_list_sinks, LUNA_METHOD_FLAGS_NONE },
{ "listApps", method_list_apps, LUNA_METHOD_FLAGS_NONE },
{ "getDiagnostics", method_get_diagnostics, LUNA_METHOD_FLAGS_NONE },
{ "getLogs", method_get_logs, LUNA_METHOD_FLAGS_NONE },
{ "quit", method_quit, LUNA_METHOD_FLAGS_NONE },
@@ -447,6 +517,13 @@ service_t* service_create(LSHandle* handle, GMainLoop* loop)
return NULL;
}
// Powers the brightness sink's "only while this app is running" option.
// Not fatal on failure: that option just has nothing to compare against,
// same as if it were left unset.
char err[192];
if (!foreground_app_start(handle, err, sizeof(err)))
WARN("Foreground app tracking unavailable: %s", err);
return s;
}
@@ -454,6 +531,7 @@ void service_destroy(service_t* s)
{
if (!s)
return;
foreground_app_stop(s->handle);
engine_destroy(s->engine);
config_free(s->config);
free(s);
+79 -23
View File
@@ -1,12 +1,22 @@
// 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.
// command, so a real picture source (HyperHDR's screen grabber, a webOS
// capture app like piccap, 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:
// sending one would mean competing for priority against that source,
// 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.
//
// The field is "brightness", an integer 0-100 -- confirmed against a real
// HyperHDR instance (serverinfo echoes it back, and the LEDs visibly
// responded). The "scaleOutput" float (0-2.0) in HyperHDR's current
// schema-adjustment.json looked like the obvious candidate and is what an
// earlier version of this file sent, but it produced no visible or
// server-reported effect on that same instance -- API docs describe the
// schema; they do not guarantee what a given build actually does with it.
// If a future HyperHDR drops "brightness" in favour of "scaleOutput", this
// will need re-verifying the same way, not just re-reading the schema.
//
// Plain newline-delimited JSON over TCP -- HyperHDR's classic control port,
// default 19444 -- a world simpler than the Flatbuffers image protocol the
@@ -15,10 +25,12 @@
#include "sink.h"
#include "../common/log.h"
#include "../foreground_app.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <math.h>
#include <netdb.h>
#include <netinet/in.h>
#include <poll.h>
@@ -38,9 +50,10 @@ typedef struct {
char host[128];
int port;
float min_scale;
float max_scale;
int min_brightness; // 0-100
int max_brightness; // 0-100
bool use_rms; // rms is steadier than peak, which reacts to single transients
char restrict_to_app[192]; // empty = always active
int fd; // -1 when not connected or still connecting
bool connected; // fd is open and the non-blocking connect finished
@@ -48,6 +61,7 @@ typedef struct {
time_t last_connect_attempt;
struct timespec last_send;
char last_error[192];
bool app_was_active; // for sending exactly one reset on the active->inactive edge
unsigned long long updates_sent;
unsigned long long connect_failures;
@@ -145,6 +159,38 @@ static void adjust_write(sink_t* s, const int16_t* pcm, int frames, const dsp_le
if (!adjust_pump_connect(p))
return;
// "Only while Spotify is running": until we can actually confirm that,
// the safe default is inactive, not active -- silently reacting to audio
// when the user asked to restrict it would be the wrong failure mode.
bool restricted = p->restrict_to_app[0] != '\0';
bool app_active = true;
if (restricted) {
if (!foreground_app_known()) {
app_active = false;
} else {
char current[192];
foreground_app_current(current, sizeof(current));
app_active = strcmp(current, p->restrict_to_app) == 0;
}
}
if (!app_active) {
// Send exactly one reset on the active->inactive edge, then go
// quiet, rather than leaving the LEDs stuck at whatever level the
// last audio block happened to produce.
if (p->app_was_active) {
char reset_msg[80];
int rn = snprintf(reset_msg, sizeof(reset_msg),
"{\"command\":\"adjustment\",\"adjustment\":{\"brightness\":%d}}\n", p->max_brightness);
if (rn > 0 && (size_t)rn < sizeof(reset_msg)
&& send(p->fd, reset_msg, (size_t)rn, MSG_NOSIGNAL) >= 0)
p->updates_sent++;
p->app_was_active = false;
}
return;
}
p->app_was_active = true;
if (elapsed_ms(&p->last_send) < SEND_INTERVAL_MS)
return;
clock_gettime(CLOCK_MONOTONIC, &p->last_send);
@@ -154,11 +200,12 @@ static void adjust_write(sink_t* s, const int16_t* pcm, int frames, const dsp_le
level = 0;
if (level > 1)
level = 1;
float scale = p->min_scale + level * (p->max_scale - p->min_scale);
int brightness = p->min_brightness
+ (int)lroundf(level * (float)(p->max_brightness - p->min_brightness));
char msg[128];
int n = snprintf(msg, sizeof(msg),
"{\"command\":\"adjustment\",\"adjustment\":{\"scaleOutput\":%.3f}}\n", (double)scale);
"{\"command\":\"adjustment\",\"adjustment\":{\"brightness\":%d}}\n", brightness);
if (n <= 0 || (size_t)n >= sizeof(msg))
return;
@@ -183,8 +230,14 @@ static void adjust_status(sink_t* s, json_writer_t* w)
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, "minBrightness", p->min_brightness);
jw_int(w, "maxBrightness", p->max_brightness);
if (p->restrict_to_app[0]) {
jw_str(w, "restrictToApp", p->restrict_to_app);
jw_bool(w, "restrictedAppActive", p->app_was_active);
} else {
jw_null(w, "restrictToApp");
}
jw_int(w, "updatesSent", (long long)p->updates_sent);
jw_int(w, "connectFailures", (long long)p->connect_failures);
if (p->last_error[0])
@@ -200,7 +253,7 @@ static void adjust_close(sink_t* s)
// 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";
static const char reset[] = "{\"command\":\"adjustment\",\"adjustment\":{\"brightness\":100}}\n";
send(p->fd, reset, sizeof(reset) - 1, MSG_NOSIGNAL);
}
adjust_disconnect(p);
@@ -214,9 +267,10 @@ static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, c
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);
int min_brightness = json_int(sc, "minBrightness", 20);
int max_brightness = json_int(sc, "maxBrightness", 100);
const char* level_source = json_str(sc, "level", "rms");
const char* restrict_to_app = json_str(sc, "restrictToApp", "");
if (!host || !*host) {
snprintf(err, errlen, "set the HyperHDR host address first");
@@ -226,8 +280,8 @@ static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, c
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");
if (min_brightness < 0 || max_brightness > 100 || max_brightness <= min_brightness) {
snprintf(err, errlen, "brightness range must be 0-100 with max greater than min");
return NULL;
}
@@ -261,9 +315,10 @@ static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, c
snprintf(p->host, sizeof(p->host), "%s", host);
p->port = port;
p->min_scale = min_scale;
p->max_scale = max_scale;
p->min_brightness = min_brightness;
p->max_brightness = max_brightness;
p->use_rms = strcmp(level_source, "peak") != 0;
snprintf(p->restrict_to_app, sizeof(p->restrict_to_app), "%s", restrict_to_app);
s->driver = &sink_driver_hyperhdr_adjust;
s->priv = p;
@@ -272,8 +327,9 @@ static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, c
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");
INFO("HyperHDR adjustment sink: %s:%d, brightness %d..%d from %s%s%s", host, port,
min_brightness, max_brightness, p->use_rms ? "rms" : "peak",
p->restrict_to_app[0] ? ", restricted to " : "", p->restrict_to_app);
return s;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lgtv-audio-cap",
"version": "1.0.3",
"version": "1.0.5",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"id": "org.webosbrew.audiocap.service",
"version": "1.0.3",
"version": "1.0.5",
"description": "Captures TV audio and streams it to HyperHDR and other receivers",
"main": "audiocap-service"
}
+15 -3
View File
@@ -204,12 +204,22 @@ static void test_status(engine_t* e)
check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported");
const json_value_t* sinks = json_get(v, "sinks");
check(json_len(sinks) == 2, "two sinks reported");
check(json_len(sinks) == 3, "three sinks reported");
for (size_t i = 0; i < json_len(sinks); i++) {
const json_value_t* s = json_at(sinks, i);
char label[64];
snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?"));
check(json_bool(s, "ok", false), label);
if (strcmp(json_str(s, "id", ""), "hyperhdrAdjust") == 0) {
// A host has no Luna bus (see lunaservice_stub.c), so the
// sink can never confirm the restricted app is foreground.
// The correct failure mode is inactive, not "assume yes".
check(strcmp(json_str(s, "restrictToApp", ""), "some.other.app") == 0,
"restriction target reported back");
check(json_bool(s, "restrictedAppActive", true) == false,
"restricted app correctly reported as not active (fail-closed)");
}
}
json_free(v);
}
@@ -224,9 +234,11 @@ int main(void)
char cfg_text[512];
snprintf(cfg_text, sizeof(cfg_text),
"{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2},"
"\"sinks\":[\"tcp\",\"http\"],"
"\"sinks\":[\"tcp\",\"http\",\"hyperhdrAdjust\"],"
"\"tcp\":{\"port\":%d},"
"\"http\":{\"port\":%d}}",
"\"http\":{\"port\":%d},"
"\"hyperhdrAdjust\":{\"host\":\"127.0.0.1\",\"port\":19444,"
"\"restrictToApp\":\"some.other.app\"}}",
TCP_PORT, HTTP_PORT);
json_value_t* cfg = json_parse(cfg_text);
+4 -2
View File
@@ -13,12 +13,14 @@ export PYTHONDONTWRITEBYTECODE=1
OUT=$(mktemp -d)
trap 'rm -rf "$OUT"' EXIT
CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -O1 -g)
CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -Itest/stubs -O1 -g)
SOURCES=(
native/src/engine.c
native/src/config.c
native/src/dsp.c
native/src/foreground_app.c
test/stubs/luna-service2/lunaservice_stub.c
native/src/common/log.c
native/src/common/json.c
native/src/common/ringbuf.c
@@ -41,7 +43,7 @@ SOURCES=(
echo "== Syntax-checking the webOS-only sources against stub headers"
for f in native/src/service.c native/src/main.c; do
"$CC" "${CFLAGS[@]}" -Itest/stubs -fsyntax-only "$f"
"$CC" "${CFLAGS[@]}" -fsyntax-only "$f"
echo " ok $f"
done
+13
View File
@@ -20,6 +20,8 @@ typedef struct {
} LSError;
typedef bool (*LSMethodFunction)(LSHandle* sh, LSMessage* msg, void* category_context);
typedef bool (*LSFilterFunc)(LSHandle* sh, LSMessage* reply, void* ctx);
typedef unsigned long LSMessageToken;
typedef enum {
LUNA_METHOD_FLAGS_NONE = 0,
@@ -58,6 +60,17 @@ bool LSGmainAttach(LSHandle* handle, GMainLoop* loop, LSError* error);
const char* LSMessageGetPayload(LSMessage* message);
bool LSMessageIsSubscription(LSMessage* message);
bool LSMessageReply(LSHandle* sh, LSMessage* message, const char* reply, LSError* error);
void LSMessageRef(LSMessage* message);
void LSMessageUnref(LSMessage* message);
bool LSSubscriptionAdd(LSHandle* sh, const char* key, LSMessage* message, LSError* error);
bool LSSubscriptionReply(LSHandle* sh, const char* key, const char* payload, LSError* error);
// Client-call API: this service acting as a caller of another service, not
// just a callee. LSCall keeps calling `callback` for every reply (used for
// subscribe:true); LSCallOneReply auto-cancels after the first one.
bool LSCall(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error);
bool LSCallOneReply(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error);
bool LSCallCancel(LSHandle* sh, LSMessageToken token, LSError* error);
@@ -0,0 +1,58 @@
// Linkable bodies for the handful of luna-service2 client-call functions
// foreground_app.c calls. service.c/main.c only ever get -fsyntax-only'd, so
// declarations alone are enough for them; foreground_app.c is linked into
// real host test binaries (engine_smoke, rtp_send) via SOURCES[] in
// run-tests.sh, so those symbols need bodies too, or the link fails.
//
// A host has no Luna bus, so "the call failed" is exactly the right
// simulated behaviour -- every caller here already treats that as
// "foreground app tracking unavailable" and degrades accordingly, which is
// also genuinely exercised by the test suite (see the fail-closed check in
// engine_smoke.c).
#include "luna-service2/lunaservice.h"
#include <string.h>
void LSErrorInit(LSError* error)
{
memset(error, 0, sizeof(*error));
}
void LSErrorFree(LSError* error)
{
(void)error;
}
bool LSCall(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error)
{
(void)sh;
(void)uri;
(void)payload;
(void)callback;
(void)ctx;
(void)ret_token;
if (error)
error->message = (char*)"no Luna bus on this host";
return false;
}
bool LSCallOneReply(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback,
void* ctx, LSMessageToken* ret_token, LSError* error)
{
return LSCall(sh, uri, payload, callback, ctx, ret_token, error);
}
bool LSCallCancel(LSHandle* sh, LSMessageToken token, LSError* error)
{
(void)sh;
(void)token;
(void)error;
return true;
}
const char* LSMessageGetPayload(LSMessage* message)
{
(void)message;
return NULL;
}
+10
View File
@@ -133,6 +133,16 @@ async function main() {
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"]'));
const restrictPicker = doc.querySelector('[data-path="hyperhdrAdjust.restrictToApp"]');
check('restrict-to-app picker exists', !!restrictPicker);
eq('restrict-to-app picker starts on Always active', restrictPicker.textContent, 'Always active');
// Always active -> the first installed app alphabetically by title
// ("Live TV", ahead of Netflix/Spotify/YouTube in the mock's list).
click(restrictPicker);
await wait(600);
eq('picking an app reaches settings by id, not a typed value',
window.App.state.settings.hyperhdrAdjust.restrictToApp, 'com.webos.app.livetv');
eq('picker now shows the app name, not the id', restrictPicker.textContent, 'Live TV');
check('backend choice exists', !!doc.querySelector('[data-path="capture.backend"]'));
check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]'));
check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));