Files
lgtv_audio_cap/native/src/service.c
T
Rene KievitsandClaude Opus 5 7529a60650 Audio capture and streaming app for webOS 5/6
Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.

  native/       the service: capture backends (PulseAudio, ALSA, exec,
                test tone, all dlopen-based), DSP, and one file per sink
  frontend/     D-pad driven UI at a fixed 1920x1080
  servicefiles/ native service manifest plus the boot script
  host/         RTP receiver and the loopback installer for the HyperHDR
                machine
  tools/        build/package, asset generation, Homebrew Channel
                manifest, on-TV probe
  test/         host-side suites: FlatBuffers and RTP verified against
                real decoders, the engine end to end, the page in jsdom

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:21:00 +02:00

490 lines
13 KiB
C

#include "service.h"
#include "capture/capture.h"
#include "common/json.h"
#include "common/log.h"
#include "config.h"
#include "engine.h"
#include "sinks/sink.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define STATUS_SUBSCRIPTION_KEY "status"
struct service {
LSHandle* handle;
GMainLoop* loop;
config_t* config;
engine_t* engine;
// Set from the engine thread, cleared by the idle handler on the main
// loop. Coalesces a burst of updates into a single subscription push.
gint status_pending;
};
// ---------------------------------------------------------------------------
// Reply helpers
// ---------------------------------------------------------------------------
static void reply_json(LSHandle* sh, LSMessage* msg, char* payload)
{
if (!payload)
return;
LSError lserror;
LSErrorInit(&lserror);
if (!LSMessageReply(sh, msg, payload, &lserror)) {
ERR("Luna reply failed: %s", lserror.message);
LSErrorFree(&lserror);
}
free(payload);
}
static void reply_error(LSHandle* sh, LSMessage* msg, const char* fmt, ...)
__attribute__((format(printf, 3, 4)));
static void reply_error(LSHandle* sh, LSMessage* msg, const char* fmt, ...)
{
char text[320];
va_list ap;
va_start(ap, fmt);
vsnprintf(text, sizeof(text), fmt, ap);
va_end(ap);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", false);
jw_str(&w, "errorText", text);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
}
static void reply_ok(LSHandle* sh, LSMessage* msg)
{
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
}
// Parses the incoming payload. Returns NULL for an empty or malformed body,
// which every handler treats as "no arguments".
static json_value_t* message_payload(LSMessage* msg)
{
const char* text = LSMessageGetPayload(msg);
if (!text || !*text)
return NULL;
return json_parse(text);
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
static char* build_status(service_t* s, bool subscribed)
{
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
if (subscribed)
jw_bool(&w, "subscribed", true);
engine_write_status(s->engine, &w);
jw_str(&w, "configPath", config_path(s->config));
jw_bool(&w, "configPersistent", config_is_persistent(s->config));
jw_obj_close(&w);
return jw_take(&w);
}
static void push_status(service_t* s)
{
char* payload = build_status(s, true);
if (!payload)
return;
LSError lserror;
LSErrorInit(&lserror);
if (!LSSubscriptionReply(s->handle, STATUS_SUBSCRIPTION_KEY, payload, &lserror)) {
// Not fatal: it usually just means nobody is listening any more.
DBG("Status push failed: %s", lserror.message);
LSErrorFree(&lserror);
}
free(payload);
}
static gboolean status_idle(gpointer user)
{
service_t* s = user;
g_atomic_int_set(&s->status_pending, 0);
push_status(s);
return G_SOURCE_REMOVE;
}
// Called on the engine thread; must not touch Luna directly.
static void on_engine_notify(void* user)
{
service_t* s = user;
if (g_atomic_int_compare_and_exchange(&s->status_pending, 0, 1))
g_idle_add(status_idle, s);
}
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
static bool method_start(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
// An optional settings patch can be sent with start, so the UI can hit
// "apply and start" in one call.
json_value_t* payload = message_payload(msg);
if (payload && payload->type == JSON_OBJECT && payload->u.object.count > 0) {
char err[256];
config_apply(s->config, payload, err, sizeof(err));
}
json_free(payload);
char err[256] = { 0 };
if (!engine_start(s->engine, config_root(s->config), err, sizeof(err))) {
reply_error(sh, msg, "%s", err);
return true;
}
reply_json(sh, msg, build_status(s, false));
return true;
}
static bool method_stop(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
engine_stop(s->engine);
reply_json(sh, msg, build_status(s, false));
return true;
}
static bool method_get_status(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
bool subscribed = false;
if (LSMessageIsSubscription(msg)) {
LSError lserror;
LSErrorInit(&lserror);
if (LSSubscriptionAdd(sh, STATUS_SUBSCRIPTION_KEY, msg, &lserror)) {
subscribed = true;
} else {
WARN("Cannot add subscriber: %s", lserror.message);
LSErrorFree(&lserror);
}
}
reply_json(sh, msg, build_status(s, subscribed));
return true;
}
// The autostart script calls this: the act of calling it is what launches the
// service, and the reply tells the caller what happened.
static bool method_is_running(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_bool(&w, "isRunning", engine_state(s->engine) == ENGINE_RUNNING);
jw_str(&w, "state", engine_state_name(engine_state(s->engine)));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_get_config(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_str(&w, "path", config_path(s->config));
jw_bool(&w, "persistent", config_is_persistent(s->config));
jw_value(&w, "settings", config_root(s->config));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_set_config(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_value_t* payload = message_payload(msg);
if (!payload || payload->type != JSON_OBJECT) {
json_free(payload);
reply_error(sh, msg, "expected an object of settings to change");
return true;
}
// Accept either the settings directly or wrapped in "settings", so the
// frontend can send whichever reads better at the call site.
const json_value_t* patch = json_get(payload, "settings");
if (!patch)
patch = payload;
char err[256] = { 0 };
bool saved = config_apply(s->config, patch, err, sizeof(err));
const char* level = json_str(config_root(s->config), "logLevel", "info");
if (strcmp(level, "debug") == 0)
log_set_level(LOG_DEBUG);
else if (strcmp(level, "warn") == 0)
log_set_level(LOG_WARN);
else if (strcmp(level, "error") == 0)
log_set_level(LOG_ERROR);
else
log_set_level(LOG_INFO);
json_free(payload);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_bool(&w, "saved", saved);
if (!saved)
jw_str(&w, "warning", err);
// Changing settings while capturing does nothing until the next start;
// say so rather than silently ignoring half of them.
jw_bool(&w, "restartRequired", engine_is_active(s->engine));
jw_value(&w, "settings", config_root(s->config));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
on_engine_notify(s);
return true;
}
static bool method_reset_config(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
json_value_t* defaults = config_defaults();
if (!defaults) {
reply_error(sh, msg, "cannot build default settings");
return true;
}
char err[256] = { 0 };
bool saved = config_apply(s->config, defaults, err, sizeof(err));
json_free(defaults);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_bool(&w, "saved", saved);
jw_value(&w, "settings", config_root(s->config));
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_list_backends(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
size_t count = 0;
const capture_driver_t* const* drivers = capture_drivers(&count);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_arr_open(&w, "backends");
for (size_t i = 0; i < count; i++) {
jw_obj_open(&w, NULL);
jw_str(&w, "id", drivers[i]->id);
jw_str(&w, "name", drivers[i]->name);
jw_str(&w, "description", drivers[i]->description);
jw_bool(&w, "available", drivers[i]->available());
jw_obj_close(&w);
}
jw_arr_close(&w);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_list_sinks(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
size_t count = 0;
const sink_driver_t* const* drivers = sink_drivers(&count);
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_arr_open(&w, "sinks");
for (size_t i = 0; i < count; i++) {
jw_obj_open(&w, NULL);
jw_str(&w, "id", drivers[i]->id);
jw_str(&w, "name", drivers[i]->name);
jw_str(&w, "description", drivers[i]->description);
jw_obj_close(&w);
}
jw_arr_close(&w);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
capture_write_diagnostics(&w);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
return true;
}
static bool method_get_logs(LSHandle* sh, LSMessage* msg, void* ctx)
{
(void)ctx;
json_value_t* payload = message_payload(msg);
bool clear = json_bool(payload, "clear", false);
json_free(payload);
char* text = log_dump_recent();
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
jw_bool(&w, "returnValue", true);
jw_str(&w, "logs", text);
jw_obj_close(&w);
reply_json(sh, msg, jw_take(&w));
free(text);
if (clear)
log_clear_recent();
return true;
}
// Deliberately last: stopping the service is how the UI gets the TV back to a
// clean state without a reboot.
static bool method_quit(LSHandle* sh, LSMessage* msg, void* ctx)
{
service_t* s = ctx;
reply_ok(sh, msg);
INFO("Quit requested over Luna");
engine_stop(s->engine);
g_main_loop_quit(s->loop);
return true;
}
static LSMethod s_methods[] = {
{ "start", method_start, LUNA_METHOD_FLAGS_NONE },
{ "stop", method_stop, LUNA_METHOD_FLAGS_NONE },
{ "getStatus", method_get_status, LUNA_METHOD_FLAGS_NONE },
{ "isRunning", method_is_running, LUNA_METHOD_FLAGS_NONE },
{ "getConfig", method_get_config, LUNA_METHOD_FLAGS_NONE },
{ "setConfig", method_set_config, LUNA_METHOD_FLAGS_NONE },
{ "resetConfig", method_reset_config, LUNA_METHOD_FLAGS_NONE },
{ "listBackends", method_list_backends, LUNA_METHOD_FLAGS_NONE },
{ "listSinks", method_list_sinks, 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 },
{ NULL, NULL, 0 },
};
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
service_t* service_create(LSHandle* handle, GMainLoop* loop)
{
service_t* s = calloc(1, sizeof(*s));
if (!s)
return NULL;
s->handle = handle;
s->loop = loop;
s->config = config_load();
if (!s->config) {
free(s);
return NULL;
}
const char* level = json_str(config_root(s->config), "logLevel", "info");
if (strcmp(level, "debug") == 0)
log_set_level(LOG_DEBUG);
else if (strcmp(level, "warn") == 0)
log_set_level(LOG_WARN);
else if (strcmp(level, "error") == 0)
log_set_level(LOG_ERROR);
s->engine = engine_create(on_engine_notify, s);
if (!s->engine) {
config_free(s->config);
free(s);
return NULL;
}
return s;
}
void service_destroy(service_t* s)
{
if (!s)
return;
engine_destroy(s->engine);
config_free(s->config);
free(s);
}
bool service_register(service_t* s, char* err, size_t errlen)
{
LSError lserror;
LSErrorInit(&lserror);
if (!LSRegisterCategory(s->handle, "/", s_methods, NULL, NULL, &lserror)) {
snprintf(err, errlen, "cannot register methods: %s", lserror.message);
LSErrorFree(&lserror);
return false;
}
if (!LSCategorySetData(s->handle, "/", s, &lserror)) {
snprintf(err, errlen, "cannot attach service data: %s", lserror.message);
LSErrorFree(&lserror);
return false;
}
return true;
}
void service_autostart(service_t* s)
{
if (!json_bool(config_root(s->config), "autoStart", false))
return;
char err[256] = { 0 };
INFO("Autostart is enabled; starting capture");
if (!engine_start(s->engine, config_root(s->config), err, sizeof(err)))
ERR("Autostart failed: %s", err);
}