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>
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
#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);
|
||||
}
|
||||
Reference in New Issue
Block a user