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:
Rene Kievits
2026-08-26 10:21:00 +02:00
co-authored by Claude Opus 5
commit 7529a60650
73 changed files with 12826 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
// ALSA capture via libasound, loaded with dlopen.
//
// ALSA is unusually friendly to runtime loading: every configuration struct
// is opaque and allocated by the library itself, so there is no struct layout
// to guess at. That makes this the lowest-risk backend to load dynamically.
//
// Useful device names on a rooted TV:
// hw:Loopback,1 - if snd-aloop is loaded and audio is routed into it
// hw:0,0 - a real capture PCM, when the SoC exposes one
// pulse_monitor - an /etc/asound.conf alias for a PulseAudio monitor
#include "capture.h"
#include "../common/log.h"
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#define SND_PCM_STREAM_CAPTURE 1
#define SND_PCM_ACCESS_RW_INTERLEAVED 3
#define SND_PCM_FORMAT_S16_LE 2
typedef struct _snd_pcm snd_pcm_t;
typedef struct _snd_pcm_hw_params snd_pcm_hw_params_t;
typedef long snd_pcm_sframes_t;
typedef unsigned long snd_pcm_uframes_t;
// X-macro keeps the symbol table, the typedefs and the resolution loop from
// drifting apart as functions are added.
#define ALSA_SYMBOLS(X) \
X(int, snd_pcm_open, (snd_pcm_t * *, const char*, int, int)) \
X(int, snd_pcm_close, (snd_pcm_t*)) \
X(int, snd_pcm_prepare, (snd_pcm_t*)) \
X(int, snd_pcm_start, (snd_pcm_t*)) \
X(int, snd_pcm_drop, (snd_pcm_t*)) \
X(snd_pcm_sframes_t, snd_pcm_readi, (snd_pcm_t*, void*, snd_pcm_uframes_t)) \
X(int, snd_pcm_recover, (snd_pcm_t*, int, int)) \
X(int, snd_pcm_hw_params_malloc, (snd_pcm_hw_params_t**)) \
X(void, snd_pcm_hw_params_free, (snd_pcm_hw_params_t*)) \
X(int, snd_pcm_hw_params_any, (snd_pcm_t*, snd_pcm_hw_params_t*)) \
X(int, snd_pcm_hw_params_set_access, (snd_pcm_t*, snd_pcm_hw_params_t*, int)) \
X(int, snd_pcm_hw_params_set_format, (snd_pcm_t*, snd_pcm_hw_params_t*, int)) \
X(int, snd_pcm_hw_params_set_channels_near, (snd_pcm_t*, snd_pcm_hw_params_t*, unsigned*)) \
X(int, snd_pcm_hw_params_set_rate_near, (snd_pcm_t*, snd_pcm_hw_params_t*, unsigned*, int*)) \
X(int, snd_pcm_hw_params_set_period_size_near, (snd_pcm_t*, snd_pcm_hw_params_t*, snd_pcm_uframes_t*, int*)) \
X(int, snd_pcm_hw_params_set_buffer_size_near, (snd_pcm_t*, snd_pcm_hw_params_t*, snd_pcm_uframes_t*)) \
X(int, snd_pcm_hw_params, (snd_pcm_t*, snd_pcm_hw_params_t*)) \
X(const char*, snd_strerror, (int))
#define DECLARE_FN(ret, name, args) typedef ret (*fn_##name) args;
ALSA_SYMBOLS(DECLARE_FN)
#undef DECLARE_FN
typedef struct {
void* handle;
bool loaded;
bool tried;
char load_error[256];
#define FIELD_FN(ret, name, args) fn_##name name;
ALSA_SYMBOLS(FIELD_FN)
#undef FIELD_FN
} alsa_lib_t;
static alsa_lib_t s_lib;
static bool alsa_load(void)
{
if (s_lib.tried)
return s_lib.loaded;
s_lib.tried = true;
s_lib.handle = dlopen("libasound.so.2", RTLD_NOW);
if (!s_lib.handle) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libasound.so.2: %s", dlerror());
return false;
}
#define RESOLVE_FN(ret, name, args) \
s_lib.name = (fn_##name)dlsym(s_lib.handle, #name); \
if (!s_lib.name) { \
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libasound missing %s", #name); \
return false; \
}
ALSA_SYMBOLS(RESOLVE_FN)
#undef RESOLVE_FN
s_lib.loaded = true;
INFO("ALSA client library loaded");
return true;
}
// --- Backend ---------------------------------------------------------------
typedef struct {
snd_pcm_t* pcm;
int channels;
} alsa_priv_t;
static int alsa_read(capture_t* c, int16_t* dst, int max_frames)
{
alsa_priv_t* p = c->priv;
snd_pcm_sframes_t n = s_lib.snd_pcm_readi(p->pcm, dst, (snd_pcm_uframes_t)max_frames);
if (n < 0) {
// Overruns are routine when a sink stalls; recover in place rather
// than tearing the whole pipeline down.
int rc = s_lib.snd_pcm_recover(p->pcm, (int)n, 1);
if (rc < 0) {
ERR("snd_pcm_readi failed: %s", s_lib.snd_strerror((int)n));
return -1;
}
WARN("ALSA stream recovered from %s", s_lib.snd_strerror((int)n));
return 0;
}
return (int)n;
}
static void alsa_close(capture_t* c)
{
alsa_priv_t* p = c->priv;
if (p) {
if (p->pcm) {
s_lib.snd_pcm_drop(p->pcm);
s_lib.snd_pcm_close(p->pcm);
}
free(p);
}
free(c);
}
static bool alsa_available(void)
{
if (!alsa_load())
return false;
// libasound present but no sound cards means nothing to open.
struct stat st;
return stat("/proc/asound", &st) == 0;
}
static void alsa_describe(json_writer_t* w)
{
if (!alsa_load()) {
jw_str(w, "detail", s_lib.load_error[0] ? s_lib.load_error : "libasound.so.2 not found");
return;
}
struct stat st;
if (stat("/proc/asound", &st) != 0) {
jw_str(w, "detail", "libasound loaded but /proc/asound is absent (no ALSA cards)");
return;
}
jw_str(w, "detail", "libasound loaded; see alsaCapturePcms for openable devices");
}
static capture_t* alsa_open(const capture_opts_t* opts, char* err, size_t errlen)
{
if (!alsa_load()) {
snprintf(err, errlen, "%s", s_lib.load_error[0] ? s_lib.load_error : "libasound unavailable");
return NULL;
}
const char* device = (opts->device && *opts->device) ? opts->device : "default";
snd_pcm_t* pcm = NULL;
int rc = s_lib.snd_pcm_open(&pcm, device, SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
snprintf(err, errlen, "snd_pcm_open(%s): %s", device, s_lib.snd_strerror(rc));
return NULL;
}
snd_pcm_hw_params_t* hw = NULL;
if ((rc = s_lib.snd_pcm_hw_params_malloc(&hw)) < 0) {
snprintf(err, errlen, "hw_params_malloc: %s", s_lib.snd_strerror(rc));
s_lib.snd_pcm_close(pcm);
return NULL;
}
unsigned rate = (unsigned)opts->fmt.rate;
unsigned channels = (unsigned)opts->fmt.channels;
snd_pcm_uframes_t period = AUDIO_BLOCK_FRAMES;
snd_pcm_uframes_t buffer = AUDIO_BLOCK_FRAMES * 8;
const char* stage = NULL;
do {
stage = "hw_params_any";
if ((rc = s_lib.snd_pcm_hw_params_any(pcm, hw)) < 0)
break;
stage = "set_access";
if ((rc = s_lib.snd_pcm_hw_params_set_access(pcm, hw, SND_PCM_ACCESS_RW_INTERLEAVED)) < 0)
break;
stage = "set_format(S16_LE)";
if ((rc = s_lib.snd_pcm_hw_params_set_format(pcm, hw, SND_PCM_FORMAT_S16_LE)) < 0)
break;
stage = "set_channels";
if ((rc = s_lib.snd_pcm_hw_params_set_channels_near(pcm, hw, &channels)) < 0)
break;
stage = "set_rate";
if ((rc = s_lib.snd_pcm_hw_params_set_rate_near(pcm, hw, &rate, NULL)) < 0)
break;
stage = "set_period_size";
if ((rc = s_lib.snd_pcm_hw_params_set_period_size_near(pcm, hw, &period, NULL)) < 0)
break;
stage = "set_buffer_size";
if ((rc = s_lib.snd_pcm_hw_params_set_buffer_size_near(pcm, hw, &buffer)) < 0)
break;
stage = "hw_params";
if ((rc = s_lib.snd_pcm_hw_params(pcm, hw)) < 0)
break;
stage = NULL;
} while (0);
s_lib.snd_pcm_hw_params_free(hw);
if (stage) {
snprintf(err, errlen, "ALSA %s on '%s': %s", stage, device, s_lib.snd_strerror(rc));
s_lib.snd_pcm_close(pcm);
return NULL;
}
if (channels > AUDIO_MAX_CHANNELS) {
snprintf(err, errlen, "device '%s' forced %u channels; only mono and stereo are supported",
device, channels);
s_lib.snd_pcm_close(pcm);
return NULL;
}
if ((rc = s_lib.snd_pcm_prepare(pcm)) < 0) {
snprintf(err, errlen, "snd_pcm_prepare: %s", s_lib.snd_strerror(rc));
s_lib.snd_pcm_close(pcm);
return NULL;
}
capture_t* c = calloc(1, sizeof(*c));
alsa_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
s_lib.snd_pcm_close(pcm);
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->pcm = pcm;
p->channels = (int)channels;
c->driver = &capture_driver_alsa;
c->priv = p;
// Report what the hardware actually gave us; the engine re-tunes the DSP
// and the sinks around this rather than assuming the request was honoured.
c->fmt.rate = (int)rate;
c->fmt.channels = (int)channels;
c->read = alsa_read;
c->close = alsa_close;
INFO("ALSA capture open: device=%s rate=%u channels=%u period=%lu", device, rate,
channels, (unsigned long)period);
return c;
}
const capture_driver_t capture_driver_alsa = {
.id = "alsa",
.name = "ALSA PCM",
.description = "Reads an ALSA capture device such as hw:Loopback,1 or a monitor alias.",
.describe = alsa_describe,
.available = alsa_available,
.open = alsa_open,
};
+199
View File
@@ -0,0 +1,199 @@
// Runs an arbitrary shell command and treats its stdout as raw S16LE PCM.
//
// This is the escape hatch. Because LG's audio routing differs by model and
// firmware, the command that actually yields audio on a given TV is something
// the owner has to discover. Rather than requiring a rebuild for each finding,
// the command is a setting:
//
// parec --format=s16le --rate=48000 --channels=2 -d <source>.monitor
// arecord -D hw:Loopback,1 -f S16_LE -r 48000 -c 2 -t raw
// ffmpeg -f alsa -i default -f s16le -ar 48000 -ac 2 -
//
// The child runs in its own process group so that killing it takes down every
// stage of a shell pipeline, not just the leftmost process.
#include "capture.h"
#include "../common/log.h"
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#define EXEC_READ_TIMEOUT_MS 2000
typedef struct {
pid_t pid;
int fd;
int frame_bytes;
// Carries a partial frame between reads so callers always see whole frames.
unsigned char partial[AUDIO_MAX_CHANNELS * sizeof(int16_t)];
int partial_len;
} exec_priv_t;
static int exec_read(capture_t* c, int16_t* dst, int max_frames)
{
exec_priv_t* p = c->priv;
unsigned char* out = (unsigned char*)dst;
size_t want = (size_t)max_frames * (size_t)p->frame_bytes;
size_t have = 0;
if (p->partial_len > 0) {
memcpy(out, p->partial, (size_t)p->partial_len);
have = (size_t)p->partial_len;
p->partial_len = 0;
}
while (have < want) {
struct pollfd pfd = { .fd = p->fd, .events = POLLIN };
int pr = poll(&pfd, 1, EXEC_READ_TIMEOUT_MS);
if (pr < 0) {
if (errno == EINTR)
continue;
ERR("exec backend poll failed: %s", strerror(errno));
return -1;
}
if (pr == 0) {
// No data within the timeout. Return whatever whole frames we have
// (possibly none) so the engine can keep its status fresh.
break;
}
ssize_t n = read(p->fd, out + have, want - have);
if (n < 0) {
if (errno == EINTR)
continue;
ERR("exec backend read failed: %s", strerror(errno));
return -1;
}
if (n == 0) {
ERR("exec backend: command exited (stdout closed)");
return -1;
}
have += (size_t)n;
}
int frames = (int)(have / (size_t)p->frame_bytes);
size_t leftover = have - (size_t)frames * (size_t)p->frame_bytes;
if (leftover > 0) {
memcpy(p->partial, out + (size_t)frames * (size_t)p->frame_bytes, leftover);
p->partial_len = (int)leftover;
}
return frames;
}
static void exec_close(capture_t* c)
{
exec_priv_t* p = c->priv;
if (p) {
if (p->fd >= 0)
close(p->fd);
if (p->pid > 0) {
// Negative pid targets the whole process group.
kill(-p->pid, SIGTERM);
for (int i = 0; i < 20; i++) {
if (waitpid(p->pid, NULL, WNOHANG) == p->pid) {
p->pid = -1;
break;
}
usleep(50000);
}
if (p->pid > 0) {
WARN("exec backend: command ignored SIGTERM, sending SIGKILL");
kill(-p->pid, SIGKILL);
waitpid(p->pid, NULL, 0);
}
}
free(p);
}
free(c);
}
static bool exec_available(void)
{
return access("/bin/sh", X_OK) == 0;
}
static void exec_describe(json_writer_t* w)
{
jw_str(w, "detail",
"Always usable. Set captureCommand to any program that writes raw S16LE PCM to stdout.");
}
static capture_t* exec_open(const capture_opts_t* opts, char* err, size_t errlen)
{
if (!opts->command || !*opts->command) {
snprintf(err, errlen, "exec backend selected but captureCommand is empty");
return NULL;
}
int pipefd[2];
if (pipe(pipefd) != 0) {
snprintf(err, errlen, "pipe(): %s", strerror(errno));
return NULL;
}
pid_t pid = fork();
if (pid < 0) {
snprintf(err, errlen, "fork(): %s", strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
return NULL;
}
if (pid == 0) {
// Child.
setpgid(0, 0);
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
// Leave stderr attached so the command's own diagnostics land in the
// service log next to ours.
execl("/bin/sh", "sh", "-c", opts->command, (char*)NULL);
_exit(127);
}
// Parent. Set the group here too so there is no window where a kill would
// race the child's own setpgid.
setpgid(pid, pid);
close(pipefd[1]);
capture_t* c = calloc(1, sizeof(*c));
exec_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
close(pipefd[0]);
kill(-pid, SIGKILL);
waitpid(pid, NULL, 0);
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->pid = pid;
p->fd = pipefd[0];
p->frame_bytes = audio_frame_bytes(&opts->fmt);
c->driver = &capture_driver_exec;
c->priv = p;
c->fmt = opts->fmt;
c->read = exec_read;
c->close = exec_close;
INFO("exec capture started (pid %d): %s", (int)pid, opts->command);
return c;
}
const capture_driver_t capture_driver_exec = {
.id = "exec",
.name = "External command",
.description = "Pipes raw S16LE PCM from any command, e.g. parec or arecord.",
.describe = exec_describe,
.available = exec_available,
.open = exec_open,
};
+226
View File
@@ -0,0 +1,226 @@
// PulseAudio capture via the `pa_simple` blocking API, loaded with dlopen.
//
// Why dlopen instead of linking: the buildroot/NDK sysroots used to build
// webOS homebrew do not reliably ship PulseAudio development files, and a
// hard link-time dependency would make the whole service fail to start on a
// TV that has no libpulse at all. Loading at runtime lets the service come
// up, report "PulseAudio not present" in Diagnostics, and fall back.
//
// Only `pa_simple` is used, which keeps the ABI surface to five functions and
// one struct (pa_sample_spec) that has been stable since PulseAudio 0.9.
// The richer introspection API would require redeclaring large structs whose
// layout we cannot verify against the TV's build, so source enumeration is
// left to Diagnostics (pactl, when present) instead.
#include "capture.h"
#include "../common/log.h"
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// --- Minimal PulseAudio ABI ------------------------------------------------
#define PA_SAMPLE_S16LE 3
#define PA_STREAM_RECORD 2
typedef struct {
int format;
uint32_t rate;
uint8_t channels;
} pa_sample_spec;
typedef struct {
uint32_t maxlength;
uint32_t tlength;
uint32_t prebuf;
uint32_t minreq;
uint32_t fragsize;
} pa_buffer_attr;
typedef struct pa_simple pa_simple;
typedef pa_simple* (*fn_pa_simple_new)(const char* server, const char* name, int dir,
const char* dev, const char* stream_name, const pa_sample_spec* ss,
const void* map, const pa_buffer_attr* attr, int* error);
typedef int (*fn_pa_simple_read)(pa_simple* s, void* data, size_t bytes, int* error);
typedef void (*fn_pa_simple_free)(pa_simple* s);
typedef int (*fn_pa_simple_flush)(pa_simple* s, int* error);
typedef const char* (*fn_pa_strerror)(int error);
typedef struct {
void* handle_simple;
void* handle_core;
fn_pa_simple_new simple_new;
fn_pa_simple_read simple_read;
fn_pa_simple_free simple_free;
fn_pa_simple_flush simple_flush;
fn_pa_strerror strerror_fn;
bool loaded;
bool tried;
char load_error[256];
} pulse_lib_t;
static pulse_lib_t s_lib;
static bool pulse_load(void)
{
if (s_lib.tried)
return s_lib.loaded;
s_lib.tried = true;
// libpulse must be resolvable for libpulse-simple's own relocations.
s_lib.handle_core = dlopen("libpulse.so.0", RTLD_NOW | RTLD_GLOBAL);
if (!s_lib.handle_core) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libpulse.so.0: %s", dlerror());
return false;
}
s_lib.handle_simple = dlopen("libpulse-simple.so.0", RTLD_NOW);
if (!s_lib.handle_simple) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error), "libpulse-simple.so.0: %s", dlerror());
return false;
}
s_lib.simple_new = (fn_pa_simple_new)dlsym(s_lib.handle_simple, "pa_simple_new");
s_lib.simple_read = (fn_pa_simple_read)dlsym(s_lib.handle_simple, "pa_simple_read");
s_lib.simple_free = (fn_pa_simple_free)dlsym(s_lib.handle_simple, "pa_simple_free");
s_lib.simple_flush = (fn_pa_simple_flush)dlsym(s_lib.handle_simple, "pa_simple_flush");
s_lib.strerror_fn = (fn_pa_strerror)dlsym(s_lib.handle_core, "pa_strerror");
if (!s_lib.simple_new || !s_lib.simple_read || !s_lib.simple_free) {
snprintf(s_lib.load_error, sizeof(s_lib.load_error),
"libpulse-simple.so.0 is missing expected pa_simple_* symbols");
return false;
}
s_lib.loaded = true;
INFO("PulseAudio client library loaded");
return true;
}
static const char* pulse_err(int code)
{
if (s_lib.strerror_fn) {
const char* s = s_lib.strerror_fn(code);
if (s)
return s;
}
return "unknown PulseAudio error";
}
// --- Backend ---------------------------------------------------------------
typedef struct {
pa_simple* stream;
int frame_bytes;
} pulse_priv_t;
static int pulse_read(capture_t* c, int16_t* dst, int max_frames)
{
pulse_priv_t* p = c->priv;
size_t want = (size_t)max_frames * (size_t)p->frame_bytes;
int error = 0;
// pa_simple_read blocks until the full request is satisfied, so the block
// size alone sets our latency floor.
if (s_lib.simple_read(p->stream, dst, want, &error) < 0) {
ERR("pa_simple_read failed: %s", pulse_err(error));
return -1;
}
return max_frames;
}
static void pulse_close(capture_t* c)
{
pulse_priv_t* p = c->priv;
if (p) {
if (p->stream)
s_lib.simple_free(p->stream);
free(p);
}
free(c);
}
static bool pulse_available(void) { return pulse_load(); }
static void pulse_describe(json_writer_t* w)
{
if (pulse_load()) {
jw_str(w, "detail", "libpulse-simple loaded; capture from a sink monitor source");
} else {
jw_str(w, "detail", s_lib.load_error[0] ? s_lib.load_error : "PulseAudio client libraries not found");
}
}
static capture_t* pulse_open(const capture_opts_t* opts, char* err, size_t errlen)
{
if (!pulse_load()) {
snprintf(err, errlen, "%s", s_lib.load_error[0] ? s_lib.load_error : "libpulse unavailable");
return NULL;
}
// "@DEFAULT_MONITOR@" is resolved by the daemon (pa_namereg_get), so we
// get the monitor of whatever sink the TV is currently playing through
// without needing the introspection API to enumerate sources.
const char* device = (opts->device && *opts->device) ? opts->device : "@DEFAULT_MONITOR@";
const char* server = (opts->server && *opts->server) ? opts->server : NULL;
pa_sample_spec ss = {
.format = PA_SAMPLE_S16LE,
.rate = (uint32_t)opts->fmt.rate,
.channels = (uint8_t)opts->fmt.channels,
};
int frame_bytes = audio_frame_bytes(&opts->fmt);
pa_buffer_attr attr = {
.maxlength = (uint32_t)-1,
.tlength = (uint32_t)-1,
.prebuf = (uint32_t)-1,
.minreq = (uint32_t)-1,
.fragsize = (uint32_t)(AUDIO_BLOCK_FRAMES * frame_bytes),
};
int error = 0;
pa_simple* stream = s_lib.simple_new(server, "LG TV Audio Cap", PA_STREAM_RECORD,
device, "tv-audio", &ss, NULL, &attr, &error);
if (!stream) {
snprintf(err, errlen, "pa_simple_new(server=%s, device=%s): %s",
server ? server : "<default>", device, pulse_err(error));
return NULL;
}
capture_t* c = calloc(1, sizeof(*c));
pulse_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
s_lib.simple_free(stream);
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->stream = stream;
p->frame_bytes = frame_bytes;
c->driver = &capture_driver_pulse;
c->priv = p;
c->fmt = opts->fmt;
c->read = pulse_read;
c->close = pulse_close;
INFO("PulseAudio capture open: device=%s rate=%d channels=%d", device,
opts->fmt.rate, opts->fmt.channels);
return c;
}
const capture_driver_t capture_driver_pulse = {
.id = "pulse",
.name = "PulseAudio monitor",
.description = "Records the monitor source of the TV's active PulseAudio sink.",
.describe = pulse_describe,
.available = pulse_available,
.open = pulse_open,
};
+159
View File
@@ -0,0 +1,159 @@
// Synthetic signal generator.
//
// Exists so the transport half of the app can be commissioned independently of
// the capture half. Getting audio off an LG TV is the uncertain part; getting
// it into HyperHDR is not. Selecting `tone` proves the network path, the host
// receiver, the loopback device and the HyperHDR effect all work before
// anyone starts guessing at PulseAudio source names.
//
// The signal is a slow log sweep from 60 Hz to 12 kHz with an amplitude
// pulse roughly once a second, so a spectrum display shows a moving peak and
// a VU meter visibly bounces.
#include "capture.h"
#include "../common/log.h"
#include <errno.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define SWEEP_LOW_HZ 60.0
#define SWEEP_HIGH_HZ 12000.0
#define SWEEP_SECONDS 8.0
#define PULSE_HZ 1.0
typedef struct {
audio_format_t fmt;
double phase; // carrier phase, radians
double t; // seconds since start
struct timespec next_deadline;
bool paced;
} tone_priv_t;
static void advance_deadline(struct timespec* ts, double seconds)
{
ts->tv_nsec += (long)(seconds * 1e9);
while (ts->tv_nsec >= 1000000000L) {
ts->tv_nsec -= 1000000000L;
ts->tv_sec++;
}
}
// Sleeps until the absolute monotonic deadline. clock_nanosleep is the right
// tool but is Linux-only; the fallback keeps host builds of the test harness
// compiling on macOS.
static void sleep_until(const struct timespec* deadline)
{
#ifdef TIMER_ABSTIME
while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, deadline, NULL) == EINTR) {
// retry
}
#else
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
struct timespec delta = {
.tv_sec = deadline->tv_sec - now.tv_sec,
.tv_nsec = deadline->tv_nsec - now.tv_nsec,
};
if (delta.tv_nsec < 0) {
delta.tv_nsec += 1000000000L;
delta.tv_sec--;
}
if (delta.tv_sec < 0)
return;
while (nanosleep(&delta, &delta) != 0 && errno == EINTR) {
// retry with the remaining time
}
#endif
}
static int tone_read(capture_t* c, int16_t* dst, int max_frames)
{
tone_priv_t* p = c->priv;
const int ch = p->fmt.channels;
const double sr = (double)p->fmt.rate;
const double dt = 1.0 / sr;
// Pace to wall-clock so downstream sinks see a realistic data rate rather
// than a flood.
if (!p->paced) {
clock_gettime(CLOCK_MONOTONIC, &p->next_deadline);
p->paced = true;
}
advance_deadline(&p->next_deadline, (double)max_frames / sr);
sleep_until(&p->next_deadline);
for (int i = 0; i < max_frames; i++) {
double sweep_pos = fmod(p->t, SWEEP_SECONDS) / SWEEP_SECONDS;
double freq = SWEEP_LOW_HZ * pow(SWEEP_HIGH_HZ / SWEEP_LOW_HZ, sweep_pos);
p->phase += 2.0 * M_PI * freq * dt;
if (p->phase > 2.0 * M_PI)
p->phase -= 2.0 * M_PI;
// Half-wave rectified sine envelope gives a clear rhythmic pulse.
double env = 0.25 + 0.75 * fabs(sin(M_PI * PULSE_HZ * p->t));
double sample = 0.6 * env * sin(p->phase);
int16_t v = (int16_t)(sample * 32000.0);
for (int cch = 0; cch < ch; cch++) {
// Slightly quieter right channel so stereo handling is visible.
dst[i * ch + cch] = (cch == 1) ? (int16_t)(v * 0.7) : v;
}
p->t += dt;
}
return max_frames;
}
static void tone_close(capture_t* c)
{
free(c->priv);
free(c);
}
static bool tone_available(void) { return true; }
static void tone_describe(json_writer_t* w)
{
jw_str(w, "detail", "Built-in sweep generator for verifying the network path end to end.");
}
static capture_t* tone_open(const capture_opts_t* opts, char* err, size_t errlen)
{
capture_t* c = calloc(1, sizeof(*c));
tone_priv_t* p = calloc(1, sizeof(*p));
if (!c || !p) {
free(c);
free(p);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->fmt = opts->fmt;
c->driver = &capture_driver_tone;
c->priv = p;
c->fmt = opts->fmt;
c->read = tone_read;
c->close = tone_close;
INFO("Test tone generator started: rate=%d channels=%d", opts->fmt.rate, opts->fmt.channels);
return c;
}
const capture_driver_t capture_driver_tone = {
.id = "tone",
.name = "Test tone",
.description = "Generates a sweeping tone instead of capturing, to validate the output chain.",
.describe = tone_describe,
.available = tone_available,
.open = tone_open,
};
+286
View File
@@ -0,0 +1,286 @@
#include "capture.h"
#include "../common/log.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
// Order matters: capture_open("auto") walks this list and takes the first
// backend that reports itself available.
static const capture_driver_t* const s_drivers[] = {
&capture_driver_pulse,
&capture_driver_alsa,
&capture_driver_exec,
&capture_driver_tone,
};
const capture_driver_t* const* capture_drivers(size_t* count)
{
*count = sizeof(s_drivers) / sizeof(s_drivers[0]);
return s_drivers;
}
const capture_driver_t* capture_find(const char* id)
{
if (!id)
return NULL;
for (size_t i = 0; i < sizeof(s_drivers) / sizeof(s_drivers[0]); i++) {
if (strcmp(s_drivers[i]->id, id) == 0)
return s_drivers[i];
}
return NULL;
}
capture_t* capture_open(const char* id, const capture_opts_t* opts, char* err, size_t errlen)
{
if (err && errlen)
err[0] = '\0';
if (id && *id && strcmp(id, "auto") != 0) {
const capture_driver_t* drv = capture_find(id);
if (!drv) {
snprintf(err, errlen, "unknown capture backend '%s'", id);
return NULL;
}
INFO("Opening capture backend '%s'", drv->id);
return drv->open(opts, err, errlen);
}
for (size_t i = 0; i < sizeof(s_drivers) / sizeof(s_drivers[0]); i++) {
const capture_driver_t* drv = s_drivers[i];
// `tone` is always "available" by construction; never auto-select it,
// or a broken capture setup would silently stream a test tone to the
// user's lights and look like it was working.
if (strcmp(drv->id, "tone") == 0)
continue;
if (!drv->available())
continue;
char local_err[256] = { 0 };
capture_t* c = drv->open(opts, local_err, sizeof(local_err));
if (c) {
INFO("Auto-selected capture backend '%s'", drv->id);
return c;
}
WARN("Auto-probe: backend '%s' failed: %s", drv->id, local_err);
}
snprintf(err, errlen,
"no capture backend could be opened; run Diagnostics to see what this TV exposes");
return NULL;
}
void capture_close(capture_t* c)
{
if (!c)
return;
c->close(c);
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
char* capture_read_file(const char* path)
{
FILE* f = fopen(path, "rb");
if (!f)
return NULL;
size_t cap = 8192, len = 0;
char* buf = malloc(cap);
if (!buf) {
fclose(f);
return NULL;
}
for (;;) {
if (len + 1024 > cap) {
cap *= 2;
char* grown = realloc(buf, cap);
if (!grown) {
free(buf);
fclose(f);
return NULL;
}
buf = grown;
}
size_t n = fread(buf + len, 1, cap - len - 1, f);
if (n == 0)
break;
len += n;
}
buf[len] = '\0';
fclose(f);
return buf;
}
char* capture_run_command(const char* cmd, size_t limit)
{
FILE* p = popen(cmd, "r");
if (!p)
return NULL;
char* buf = malloc(limit + 1);
if (!buf) {
pclose(p);
return NULL;
}
size_t len = fread(buf, 1, limit, p);
buf[len] = '\0';
pclose(p);
return buf;
}
bool capture_have_binary(const char* name)
{
const char* path = getenv("PATH");
if (!path || !*path)
path = "/usr/sbin:/usr/bin:/sbin:/bin";
char* copy = strdup(path);
if (!copy)
return false;
bool found = false;
char* saveptr = NULL;
for (char* dir = strtok_r(copy, ":", &saveptr); dir; dir = strtok_r(NULL, ":", &saveptr)) {
char full[512];
snprintf(full, sizeof(full), "%s/%s", dir, name);
if (access(full, X_OK) == 0) {
found = true;
break;
}
}
free(copy);
return found;
}
// ---------------------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------------------
static void write_alsa_devices(json_writer_t* w)
{
jw_arr_open(w, "alsaCards");
char* cards = capture_read_file("/proc/asound/cards");
if (cards) {
// Each card occupies two lines; the first starts with its index.
char* saveptr = NULL;
for (char* line = strtok_r(cards, "\n", &saveptr); line;
line = strtok_r(NULL, "\n", &saveptr)) {
while (*line == ' ')
line++;
if (*line >= '0' && *line <= '9')
jw_str(w, NULL, line);
}
free(cards);
}
jw_arr_close(w);
jw_arr_open(w, "alsaCapturePcms");
char* pcms = capture_read_file("/proc/asound/pcm");
if (pcms) {
char* saveptr = NULL;
for (char* line = strtok_r(pcms, "\n", &saveptr); line;
line = strtok_r(NULL, "\n", &saveptr)) {
if (strstr(line, "capture"))
jw_str(w, NULL, line);
}
free(pcms);
}
jw_arr_close(w);
}
static void write_pulse_devices(json_writer_t* w)
{
jw_arr_open(w, "pulseSockets");
static const char* candidates[] = {
"/var/run/pulse/native",
"/run/pulse/native",
"/tmp/pulse/native",
"/var/run/user/0/pulse/native",
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
struct stat st;
if (stat(candidates[i], &st) == 0)
jw_str(w, NULL, candidates[i]);
}
jw_arr_close(w);
// pactl is usually absent from stock firmware, but when it is present it
// is by far the fastest way to see the real source list.
if (capture_have_binary("pactl")) {
char* out = capture_run_command("pactl list short sources 2>&1", 8192);
jw_str(w, "pactlSources", out ? out : "");
free(out);
} else {
jw_null(w, "pactlSources");
}
}
static void write_library_presence(json_writer_t* w)
{
static const char* libs[] = {
"libpulse.so.0",
"libpulse-simple.so.0",
"libasound.so.2",
};
static const char* dirs[] = {
"/usr/lib",
"/lib",
"/usr/lib/arm-linux-gnueabi",
"/usr/local/lib",
};
jw_obj_open(w, "libraries");
for (size_t i = 0; i < sizeof(libs) / sizeof(libs[0]); i++) {
const char* found = NULL;
static char full[512];
for (size_t d = 0; d < sizeof(dirs) / sizeof(dirs[0]) && !found; d++) {
snprintf(full, sizeof(full), "%s/%s", dirs[d], libs[i]);
if (access(full, R_OK) == 0)
found = full;
}
jw_str(w, libs[i], found);
}
jw_obj_close(w);
}
static void write_binary_presence(json_writer_t* w)
{
static const char* bins[] = { "parec", "pactl", "pacat", "arecord", "amixer", "ffmpeg", "gst-launch-1.0" };
jw_obj_open(w, "binaries");
for (size_t i = 0; i < sizeof(bins) / sizeof(bins[0]); i++)
jw_bool(w, bins[i], capture_have_binary(bins[i]));
jw_obj_close(w);
}
void capture_write_diagnostics(json_writer_t* w)
{
jw_arr_open(w, "backends");
size_t count = 0;
const capture_driver_t* const* drivers = capture_drivers(&count);
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());
if (drivers[i]->describe)
drivers[i]->describe(w);
jw_obj_close(w);
}
jw_arr_close(w);
jw_obj_open(w, "system");
jw_bool(w, "root", geteuid() == 0);
jw_int(w, "uid", (long long)geteuid());
write_library_presence(w);
write_binary_presence(w);
write_pulse_devices(w);
write_alsa_devices(w);
jw_obj_close(w);
}
+84
View File
@@ -0,0 +1,84 @@
// Capture backend abstraction.
//
// There is no published, known-good way to tap the audio a webOS TV is
// playing: PicCap and hyperion-webos both capture video only, and LG's audio
// path differs across models (some route everything through PulseAudio, some
// hand broadcast/HDMI audio to the SoC DSP and never expose it to userspace).
//
// So rather than betting the app on one mechanism, every backend is probed at
// runtime and the UI reports what actually exists on *this* TV. `exec` is the
// deliberate escape hatch: whatever command turns out to work on a given
// model can be wired up from the settings screen without a rebuild.
#pragma once
#include "../common/audio.h"
#include "../common/json.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct capture capture_t;
typedef struct {
audio_format_t fmt;
const char* device; // pulse source name / ALSA PCM name; NULL for default
const char* server; // PulseAudio server string, e.g. "unix:/var/run/pulse/native"
const char* command; // shell command for the `exec` backend
} capture_opts_t;
typedef struct {
const char* id;
const char* name;
const char* description;
// Reports whether this backend could plausibly run here, appending a
// human-readable explanation to `w` as an object member.
void (*describe)(json_writer_t* w);
bool (*available)(void);
// Returns NULL on failure and writes a reason into `err`.
capture_t* (*open)(const capture_opts_t* opts, char* err, size_t errlen);
} capture_driver_t;
struct capture {
const capture_driver_t* driver;
void* priv;
audio_format_t fmt; // format actually negotiated, may differ from request
// Blocking read of up to `max_frames` interleaved S16LE frames.
// Returns frames read, 0 on timeout, negative on unrecoverable error.
int (*read)(capture_t* c, int16_t* dst, int max_frames);
void (*close)(capture_t* c);
};
// Registry -------------------------------------------------------------------
// The drivers themselves, declared here so both the registry and each driver's
// own translation unit see one declaration.
extern const capture_driver_t capture_driver_pulse;
extern const capture_driver_t capture_driver_alsa;
extern const capture_driver_t capture_driver_exec;
extern const capture_driver_t capture_driver_tone;
const capture_driver_t* capture_find(const char* id);
const capture_driver_t* const* capture_drivers(size_t* count);
// Opens the named backend, or the first available one when `id` is NULL or
// "auto". Order of preference: pulse, alsa, exec, tone.
capture_t* capture_open(const char* id, const capture_opts_t* opts, char* err, size_t errlen);
void capture_close(capture_t* c);
// Diagnostics ----------------------------------------------------------------
// Writes a "backends" array plus a "devices" object describing the sound
// hardware this TV exposes. Everything here is best-effort and read-only.
void capture_write_diagnostics(json_writer_t* w);
// Shared helper: reads a whole file into a malloc'd string, or NULL.
char* capture_read_file(const char* path);
// Shared helper: runs a command, capturing up to `limit` bytes of stdout.
// Returns NULL if the command could not be started.
char* capture_run_command(const char* cmd, size_t limit);
// Shared helper: true if any of the colon-separated PATH dirs holds `name`.
bool capture_have_binary(const char* name);
+28
View File
@@ -0,0 +1,28 @@
// Shared audio vocabulary.
//
// Everything downstream of a capture backend speaks one format: interleaved
// signed 16-bit little-endian PCM. Backends convert on the way in, sinks
// convert on the way out. Keeping a single internal format means the DSP and
// fan-out code never branch on sample type.
#pragma once
#include <stdint.h>
#define AUDIO_MAX_CHANNELS 2
#define AUDIO_DEFAULT_RATE 48000
#define AUDIO_DEFAULT_CHANNELS 2
// Frames per capture block. At 48 kHz this is ~10.7 ms, which keeps
// visualisation latency low while staying large enough that per-block
// overhead (syscalls, UDP headers, FFT setup) stays negligible.
#define AUDIO_BLOCK_FRAMES 512
typedef struct {
int rate; // samples per second
int channels; // 1 or 2
} audio_format_t;
static inline int audio_frame_bytes(const audio_format_t* f)
{
return (int)sizeof(int16_t) * f->channels;
}
+876
View File
@@ -0,0 +1,876 @@
#include "json.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
typedef struct {
const char* p;
int depth;
} parser_t;
#define MAX_DEPTH 32
static json_value_t* parse_value(parser_t* ps);
static void skip_ws(parser_t* ps)
{
while (*ps->p == ' ' || *ps->p == '\t' || *ps->p == '\n' || *ps->p == '\r')
ps->p++;
}
static json_value_t* alloc_value(json_type_t type)
{
json_value_t* v = calloc(1, sizeof(*v));
if (v)
v->type = type;
return v;
}
static int hex_nibble(char c)
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
// Encodes a code point as UTF-8 into `out`, returning the byte count.
static size_t utf8_encode(unsigned cp, char* out)
{
if (cp < 0x80) {
out[0] = (char)cp;
return 1;
}
if (cp < 0x800) {
out[0] = (char)(0xC0 | (cp >> 6));
out[1] = (char)(0x80 | (cp & 0x3F));
return 2;
}
if (cp < 0x10000) {
out[0] = (char)(0xE0 | (cp >> 12));
out[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[2] = (char)(0x80 | (cp & 0x3F));
return 3;
}
out[0] = (char)(0xF0 | (cp >> 18));
out[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
out[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[3] = (char)(0x80 | (cp & 0x3F));
return 4;
}
// Parses a quoted string starting at ps->p (which must point at the opening
// quote). Returns a malloc'd NUL-terminated string.
static char* parse_string_raw(parser_t* ps)
{
if (*ps->p != '"')
return NULL;
ps->p++;
size_t cap = 32, len = 0;
char* out = malloc(cap);
if (!out)
return NULL;
while (*ps->p && *ps->p != '"') {
// Worst case one escape expands to 4 UTF-8 bytes.
if (len + 5 > cap) {
cap *= 2;
char* grown = realloc(out, cap);
if (!grown) {
free(out);
return NULL;
}
out = grown;
}
if (*ps->p != '\\') {
out[len++] = *ps->p++;
continue;
}
ps->p++;
char esc = *ps->p++;
switch (esc) {
case '"':
out[len++] = '"';
break;
case '\\':
out[len++] = '\\';
break;
case '/':
out[len++] = '/';
break;
case 'b':
out[len++] = '\b';
break;
case 'f':
out[len++] = '\f';
break;
case 'n':
out[len++] = '\n';
break;
case 'r':
out[len++] = '\r';
break;
case 't':
out[len++] = '\t';
break;
case 'u': {
unsigned cp = 0;
for (int i = 0; i < 4; i++) {
int nib = hex_nibble(ps->p[i]);
if (nib < 0) {
free(out);
return NULL;
}
cp = (cp << 4) | (unsigned)nib;
}
ps->p += 4;
// Combine surrogate pairs so astral characters survive round-trip.
if (cp >= 0xD800 && cp <= 0xDBFF && ps->p[0] == '\\' && ps->p[1] == 'u') {
unsigned lo = 0;
bool ok = true;
for (int i = 0; i < 4; i++) {
int nib = hex_nibble(ps->p[2 + i]);
if (nib < 0) {
ok = false;
break;
}
lo = (lo << 4) | (unsigned)nib;
}
if (ok && lo >= 0xDC00 && lo <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
ps->p += 6;
}
}
len += utf8_encode(cp, out + len);
break;
}
default:
free(out);
return NULL;
}
}
if (*ps->p != '"') {
free(out);
return NULL;
}
ps->p++;
out[len] = '\0';
return out;
}
static json_value_t* parse_array(parser_t* ps)
{
ps->p++; // consume '['
json_value_t* v = alloc_value(JSON_ARRAY);
if (!v)
return NULL;
skip_ws(ps);
if (*ps->p == ']') {
ps->p++;
return v;
}
size_t cap = 8;
v->u.array.items = malloc(cap * sizeof(json_value_t*));
if (!v->u.array.items) {
json_free(v);
return NULL;
}
for (;;) {
json_value_t* item = parse_value(ps);
if (!item) {
json_free(v);
return NULL;
}
if (v->u.array.count == cap) {
cap *= 2;
json_value_t** grown = realloc(v->u.array.items, cap * sizeof(json_value_t*));
if (!grown) {
json_free(item);
json_free(v);
return NULL;
}
v->u.array.items = grown;
}
v->u.array.items[v->u.array.count++] = item;
skip_ws(ps);
if (*ps->p == ',') {
ps->p++;
skip_ws(ps);
continue;
}
if (*ps->p == ']') {
ps->p++;
return v;
}
json_free(v);
return NULL;
}
}
static json_value_t* parse_object(parser_t* ps)
{
ps->p++; // consume '{'
json_value_t* v = alloc_value(JSON_OBJECT);
if (!v)
return NULL;
skip_ws(ps);
if (*ps->p == '}') {
ps->p++;
return v;
}
size_t cap = 8;
v->u.object.keys = malloc(cap * sizeof(char*));
v->u.object.values = malloc(cap * sizeof(json_value_t*));
if (!v->u.object.keys || !v->u.object.values) {
json_free(v);
return NULL;
}
for (;;) {
skip_ws(ps);
char* key = parse_string_raw(ps);
if (!key) {
json_free(v);
return NULL;
}
skip_ws(ps);
if (*ps->p != ':') {
free(key);
json_free(v);
return NULL;
}
ps->p++;
json_value_t* val = parse_value(ps);
if (!val) {
free(key);
json_free(v);
return NULL;
}
if (v->u.object.count == cap) {
cap *= 2;
char** gk = realloc(v->u.object.keys, cap * sizeof(char*));
json_value_t** gv = realloc(v->u.object.values, cap * sizeof(json_value_t*));
if (gk)
v->u.object.keys = gk;
if (gv)
v->u.object.values = gv;
if (!gk || !gv) {
free(key);
json_free(val);
json_free(v);
return NULL;
}
}
v->u.object.keys[v->u.object.count] = key;
v->u.object.values[v->u.object.count] = val;
v->u.object.count++;
skip_ws(ps);
if (*ps->p == ',') {
ps->p++;
continue;
}
if (*ps->p == '}') {
ps->p++;
return v;
}
json_free(v);
return NULL;
}
}
static json_value_t* parse_value(parser_t* ps)
{
if (++ps->depth > MAX_DEPTH) {
ps->depth--;
return NULL;
}
skip_ws(ps);
json_value_t* v = NULL;
switch (*ps->p) {
case '{':
v = parse_object(ps);
break;
case '[':
v = parse_array(ps);
break;
case '"': {
char* s = parse_string_raw(ps);
if (s) {
v = alloc_value(JSON_STRING);
if (v)
v->u.string = s;
else
free(s);
}
break;
}
case 't':
if (strncmp(ps->p, "true", 4) == 0) {
ps->p += 4;
v = alloc_value(JSON_BOOL);
if (v)
v->u.boolean = true;
}
break;
case 'f':
if (strncmp(ps->p, "false", 5) == 0) {
ps->p += 5;
v = alloc_value(JSON_BOOL);
if (v)
v->u.boolean = false;
}
break;
case 'n':
if (strncmp(ps->p, "null", 4) == 0) {
ps->p += 4;
v = alloc_value(JSON_NULL);
}
break;
default: {
char* end = NULL;
double d = strtod(ps->p, &end);
if (end && end != ps->p) {
ps->p = end;
v = alloc_value(JSON_NUMBER);
if (v)
v->u.number = d;
}
break;
}
}
ps->depth--;
return v;
}
json_value_t* json_parse(const char* text)
{
if (!text)
return NULL;
parser_t ps = { .p = text, .depth = 0 };
json_value_t* v = parse_value(&ps);
if (!v)
return NULL;
skip_ws(&ps);
if (*ps.p != '\0') {
json_free(v);
return NULL;
}
return v;
}
void json_free(json_value_t* v)
{
if (!v)
return;
switch (v->type) {
case JSON_STRING:
free(v->u.string);
break;
case JSON_ARRAY:
for (size_t i = 0; i < v->u.array.count; i++)
json_free(v->u.array.items[i]);
free(v->u.array.items);
break;
case JSON_OBJECT:
for (size_t i = 0; i < v->u.object.count; i++) {
free(v->u.object.keys[i]);
json_free(v->u.object.values[i]);
}
free(v->u.object.keys);
free(v->u.object.values);
break;
default:
break;
}
free(v);
}
// ---------------------------------------------------------------------------
// Accessors
// ---------------------------------------------------------------------------
const json_value_t* json_get(const json_value_t* obj, const char* key)
{
if (!obj || obj->type != JSON_OBJECT || !key)
return NULL;
for (size_t i = 0; i < obj->u.object.count; i++) {
if (strcmp(obj->u.object.keys[i], key) == 0)
return obj->u.object.values[i];
}
return NULL;
}
const char* json_str(const json_value_t* obj, const char* key, const char* def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_STRING) ? v->u.string : def;
}
double json_num(const json_value_t* obj, const char* key, double def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_NUMBER) ? v->u.number : def;
}
int json_int(const json_value_t* obj, const char* key, int def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_NUMBER) ? (int)v->u.number : def;
}
bool json_bool(const json_value_t* obj, const char* key, bool def)
{
const json_value_t* v = json_get(obj, key);
return (v && v->type == JSON_BOOL) ? v->u.boolean : def;
}
const json_value_t* json_at(const json_value_t* arr, size_t index)
{
if (!arr || arr->type != JSON_ARRAY || index >= arr->u.array.count)
return NULL;
return arr->u.array.items[index];
}
size_t json_len(const json_value_t* arr)
{
if (!arr || arr->type != JSON_ARRAY)
return 0;
return arr->u.array.count;
}
// ---------------------------------------------------------------------------
// Clone and merge
// ---------------------------------------------------------------------------
// Appends `key`/`val` to an object, taking ownership of both. Returns false
// (having freed nothing) if the object could not grow.
static bool object_append(json_value_t* obj, char* key, json_value_t* val)
{
size_t n = obj->u.object.count;
char** gk = realloc(obj->u.object.keys, (n + 1) * sizeof(char*));
if (gk)
obj->u.object.keys = gk;
json_value_t** gv = realloc(obj->u.object.values, (n + 1) * sizeof(json_value_t*));
if (gv)
obj->u.object.values = gv;
if (!gk || !gv)
return false;
obj->u.object.keys[n] = key;
obj->u.object.values[n] = val;
obj->u.object.count = n + 1;
return true;
}
json_value_t* json_clone(const json_value_t* v)
{
if (!v)
return NULL;
json_value_t* out = alloc_value(v->type);
if (!out)
return NULL;
switch (v->type) {
case JSON_BOOL:
out->u.boolean = v->u.boolean;
break;
case JSON_NUMBER:
out->u.number = v->u.number;
break;
case JSON_STRING:
out->u.string = strdup(v->u.string ? v->u.string : "");
if (!out->u.string) {
free(out);
return NULL;
}
break;
case JSON_ARRAY:
if (v->u.array.count) {
out->u.array.items = calloc(v->u.array.count, sizeof(json_value_t*));
if (!out->u.array.items) {
free(out);
return NULL;
}
for (size_t i = 0; i < v->u.array.count; i++) {
out->u.array.items[i] = json_clone(v->u.array.items[i]);
out->u.array.count = i + 1;
if (!out->u.array.items[i]) {
json_free(out);
return NULL;
}
}
}
break;
case JSON_OBJECT:
for (size_t i = 0; i < v->u.object.count; i++) {
char* key = strdup(v->u.object.keys[i]);
json_value_t* val = json_clone(v->u.object.values[i]);
if (!key || !val || !object_append(out, key, val)) {
free(key);
json_free(val);
json_free(out);
return NULL;
}
}
break;
default:
break;
}
return out;
}
json_value_t* json_merge(const json_value_t* base, const json_value_t* patch)
{
if (!patch)
return json_clone(base);
if (!base || base->type != JSON_OBJECT || patch->type != JSON_OBJECT)
return json_clone(patch);
json_value_t* out = alloc_value(JSON_OBJECT);
if (!out)
return NULL;
// Base keys first, so the on-disk field order stays stable across saves.
for (size_t i = 0; i < base->u.object.count; i++) {
const char* k = base->u.object.keys[i];
const json_value_t* pv = json_get(patch, k);
char* key = strdup(k);
json_value_t* val = pv ? json_merge(base->u.object.values[i], pv)
: json_clone(base->u.object.values[i]);
if (!key || !val || !object_append(out, key, val)) {
free(key);
json_free(val);
json_free(out);
return NULL;
}
}
// Then anything the patch introduced.
for (size_t i = 0; i < patch->u.object.count; i++) {
const char* k = patch->u.object.keys[i];
if (json_get(base, k))
continue;
char* key = strdup(k);
json_value_t* val = json_clone(patch->u.object.values[i]);
if (!key || !val || !object_append(out, key, val)) {
free(key);
json_free(val);
json_free(out);
return NULL;
}
}
return out;
}
// ---------------------------------------------------------------------------
// Writer
// ---------------------------------------------------------------------------
static void jw_reserve(json_writer_t* w, size_t extra)
{
if (w->failed)
return;
if (w->len + extra + 1 <= w->cap)
return;
size_t cap = w->cap ? w->cap : 256;
while (cap < w->len + extra + 1)
cap *= 2;
char* grown = realloc(w->buf, cap);
if (!grown) {
w->failed = true;
return;
}
w->buf = grown;
w->cap = cap;
}
static void jw_raw(json_writer_t* w, const char* s)
{
size_t n = strlen(s);
jw_reserve(w, n);
if (w->failed)
return;
memcpy(w->buf + w->len, s, n);
w->len += n;
w->buf[w->len] = '\0';
}
static void jw_raw_escaped(json_writer_t* w, const char* s)
{
jw_reserve(w, strlen(s) * 6 + 2);
if (w->failed)
return;
char* p = w->buf + w->len;
*p++ = '"';
for (const unsigned char* c = (const unsigned char*)s; *c; c++) {
switch (*c) {
case '"':
*p++ = '\\';
*p++ = '"';
break;
case '\\':
*p++ = '\\';
*p++ = '\\';
break;
case '\n':
*p++ = '\\';
*p++ = 'n';
break;
case '\r':
*p++ = '\\';
*p++ = 'r';
break;
case '\t':
*p++ = '\\';
*p++ = 't';
break;
case '\b':
*p++ = '\\';
*p++ = 'b';
break;
case '\f':
*p++ = '\\';
*p++ = 'f';
break;
default:
if (*c < 0x20) {
p += sprintf(p, "\\u%04x", *c);
} else {
*p++ = (char)*c;
}
}
}
*p++ = '"';
w->len = (size_t)(p - w->buf);
w->buf[w->len] = '\0';
}
static void jw_newline(json_writer_t* w, int depth)
{
jw_reserve(w, (size_t)depth * 2 + 1);
if (w->failed)
return;
w->buf[w->len++] = '\n';
for (int i = 0; i < depth * 2; i++)
w->buf[w->len++] = ' ';
w->buf[w->len] = '\0';
}
// Emits the comma + key prefix for the next member at the current depth.
static void jw_prefix(json_writer_t* w, const char* key)
{
if (w->depth > 0 && w->depth <= (int)(sizeof(w->need_comma) / sizeof(w->need_comma[0]))) {
if (w->need_comma[w->depth - 1])
jw_raw(w, ",");
w->need_comma[w->depth - 1] = true;
if (w->pretty)
jw_newline(w, w->depth);
}
if (key) {
jw_raw_escaped(w, key);
jw_raw(w, w->pretty ? ": " : ":");
}
}
// True if the container we are about to close received at least one member.
static bool jw_container_used(const json_writer_t* w)
{
return w->depth > 0 && w->depth <= (int)(sizeof(w->need_comma) / sizeof(w->need_comma[0]))
&& w->need_comma[w->depth - 1];
}
static void jw_push(json_writer_t* w)
{
if (w->depth < (int)(sizeof(w->need_comma) / sizeof(w->need_comma[0])))
w->need_comma[w->depth] = false;
w->depth++;
}
static void jw_pop(json_writer_t* w)
{
if (w->depth > 0)
w->depth--;
}
void jw_init(json_writer_t* w)
{
memset(w, 0, sizeof(*w));
}
void jw_free(json_writer_t* w)
{
free(w->buf);
memset(w, 0, sizeof(*w));
}
char* jw_take(json_writer_t* w)
{
if (w->failed) {
jw_free(w);
return NULL;
}
char* out = w->buf;
if (!out) {
out = strdup("");
}
memset(w, 0, sizeof(*w));
return out;
}
void jw_obj_open(json_writer_t* w, const char* key)
{
jw_prefix(w, key);
jw_raw(w, "{");
jw_push(w);
}
void jw_obj_close(json_writer_t* w)
{
bool used = jw_container_used(w);
jw_pop(w);
if (w->pretty && used)
jw_newline(w, w->depth);
jw_raw(w, "}");
}
void jw_arr_open(json_writer_t* w, const char* key)
{
jw_prefix(w, key);
jw_raw(w, "[");
jw_push(w);
}
void jw_arr_close(json_writer_t* w)
{
bool used = jw_container_used(w);
jw_pop(w);
if (w->pretty && used)
jw_newline(w, w->depth);
jw_raw(w, "]");
}
void jw_str(json_writer_t* w, const char* key, const char* value)
{
jw_prefix(w, key);
if (value)
jw_raw_escaped(w, value);
else
jw_raw(w, "null");
}
void jw_num(json_writer_t* w, const char* key, double value)
{
jw_prefix(w, key);
char tmp[40];
// %.6g keeps float levels compact; they are display values, not data.
snprintf(tmp, sizeof(tmp), "%.6g", value);
jw_raw(w, tmp);
}
void jw_int(json_writer_t* w, const char* key, long long value)
{
jw_prefix(w, key);
char tmp[32];
snprintf(tmp, sizeof(tmp), "%lld", value);
jw_raw(w, tmp);
}
void jw_bool(json_writer_t* w, const char* key, bool value)
{
jw_prefix(w, key);
jw_raw(w, value ? "true" : "false");
}
void jw_null(json_writer_t* w, const char* key)
{
jw_prefix(w, key);
jw_raw(w, "null");
}
void jw_value(json_writer_t* w, const char* key, const json_value_t* v)
{
if (!v) {
jw_null(w, key);
return;
}
switch (v->type) {
case JSON_NULL:
jw_null(w, key);
break;
case JSON_BOOL:
jw_bool(w, key, v->u.boolean);
break;
case JSON_NUMBER:
jw_prefix(w, key);
{
char tmp[40];
// Integral values must not round-trip as "1.0", or a reparse would
// still be a number but the UI would render it oddly.
if (v->u.number == (double)(long long)v->u.number)
snprintf(tmp, sizeof(tmp), "%lld", (long long)v->u.number);
else
snprintf(tmp, sizeof(tmp), "%.17g", v->u.number);
jw_raw(w, tmp);
}
break;
case JSON_STRING:
jw_str(w, key, v->u.string);
break;
case JSON_ARRAY:
jw_arr_open(w, key);
for (size_t i = 0; i < v->u.array.count; i++)
jw_value(w, NULL, v->u.array.items[i]);
jw_arr_close(w);
break;
case JSON_OBJECT:
jw_obj_open(w, key);
for (size_t i = 0; i < v->u.object.count; i++)
jw_value(w, v->u.object.keys[i], v->u.object.values[i]);
jw_obj_close(w);
break;
}
}
char* json_serialize(const json_value_t* v, bool pretty)
{
json_writer_t w;
jw_init(&w);
w.pretty = pretty;
jw_value(&w, NULL, v);
return jw_take(&w);
}
char* json_escape(const char* s)
{
json_writer_t w;
jw_init(&w);
jw_raw_escaped(&w, s ? s : "");
return jw_take(&w);
}
+102
View File
@@ -0,0 +1,102 @@
// Small dependency-free JSON reader/writer.
//
// The webOS SDK ships pbnjson, but pulling it in drags glib schema plumbing
// into every translation unit for what amounts to reading a dozen config keys
// and building small Luna replies. This is deliberately minimal: no schema
// validation, no streaming, no number formatting beyond %.17g / %lld.
#pragma once
#include <stdbool.h>
#include <stddef.h>
typedef enum {
JSON_NULL,
JSON_BOOL,
JSON_NUMBER,
JSON_STRING,
JSON_ARRAY,
JSON_OBJECT,
} json_type_t;
typedef struct json_value json_value_t;
struct json_value {
json_type_t type;
union {
bool boolean;
double number;
char* string;
struct {
json_value_t** items;
size_t count;
} array;
struct {
char** keys;
json_value_t** values;
size_t count;
} object;
} u;
};
// Returns NULL on malformed input. Trailing whitespace is allowed.
json_value_t* json_parse(const char* text);
void json_free(json_value_t* v);
// Object/array accessors. All tolerate NULL and wrong types by returning the
// default, so callers can chain without checking every step.
const json_value_t* json_get(const json_value_t* obj, const char* key);
const char* json_str(const json_value_t* obj, const char* key, const char* def);
double json_num(const json_value_t* obj, const char* key, double def);
int json_int(const json_value_t* obj, const char* key, int def);
bool json_bool(const json_value_t* obj, const char* key, bool def);
const json_value_t* json_at(const json_value_t* arr, size_t index);
size_t json_len(const json_value_t* arr);
// Deep copy. Returns NULL if `v` is NULL or allocation fails.
json_value_t* json_clone(const json_value_t* v);
// Recursive merge: keys present in `patch` win, except where both sides hold
// an object, in which case the objects are merged member by member. Used so
// the UI can send just the settings it changed. Returns a new value; both
// inputs are left untouched.
json_value_t* json_merge(const json_value_t* base, const json_value_t* patch);
// ---------------------------------------------------------------------------
// Writer: append-only string builder that tracks comma placement per nesting
// level so callers never write separators by hand.
// ---------------------------------------------------------------------------
typedef struct {
char* buf;
size_t len;
size_t cap;
int depth;
bool need_comma[32];
bool failed;
bool pretty; // set after jw_init for indented output (config files)
} json_writer_t;
void jw_init(json_writer_t* w);
void jw_free(json_writer_t* w);
// Hands ownership of the finished buffer to the caller and resets the writer.
char* jw_take(json_writer_t* w);
void jw_obj_open(json_writer_t* w, const char* key);
void jw_obj_close(json_writer_t* w);
void jw_arr_open(json_writer_t* w, const char* key);
void jw_arr_close(json_writer_t* w);
void jw_str(json_writer_t* w, const char* key, const char* value);
void jw_num(json_writer_t* w, const char* key, double value);
void jw_int(json_writer_t* w, const char* key, long long value);
void jw_bool(json_writer_t* w, const char* key, bool value);
void jw_null(json_writer_t* w, const char* key);
// Writes an existing DOM value verbatim (objects and arrays included).
void jw_value(json_writer_t* w, const char* key, const json_value_t* v);
// Renders `v` as JSON text. Caller frees.
char* json_serialize(const json_value_t* v, bool pretty);
// Escapes `s` into a JSON string literal (including surrounding quotes).
// Caller frees.
char* json_escape(const char* s);
+113
View File
@@ -0,0 +1,113 @@
#include "log.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <time.h>
#define RING_LINES 200
#define RING_LINE_LEN 256
static log_level_t s_level = LOG_INFO;
static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER;
static char s_ring[RING_LINES][RING_LINE_LEN];
static int s_head = 0; // next slot to write
static int s_count = 0;
static const char* level_name(log_level_t l)
{
switch (l) {
case LOG_ERROR:
return "ERROR";
case LOG_WARN:
return "WARN";
case LOG_INFO:
return "INFO";
default:
return "DEBUG";
}
}
void log_init(log_level_t level)
{
s_level = level;
setvbuf(stderr, NULL, _IOLBF, 0);
}
void log_set_level(log_level_t level) { s_level = level; }
log_level_t log_get_level(void) { return s_level; }
void log_printf(log_level_t level, const char* file, int line, const char* fmt, ...)
{
if (level > s_level)
return;
const char* base = strrchr(file, '/');
base = base ? base + 1 : file;
struct timeval tv;
gettimeofday(&tv, NULL);
struct tm tm;
localtime_r(&tv.tv_sec, &tm);
char stamp[32];
snprintf(stamp, sizeof(stamp), "%02d:%02d:%02d.%03d", tm.tm_hour, tm.tm_min,
tm.tm_sec, (int)(tv.tv_usec / 1000));
char body[RING_LINE_LEN];
va_list ap;
va_start(ap, fmt);
vsnprintf(body, sizeof(body), fmt, ap);
va_end(ap);
char line_buf[RING_LINE_LEN];
snprintf(line_buf, sizeof(line_buf), "%s [%-5s] %s:%d %s", stamp,
level_name(level), base, line, body);
fprintf(stderr, "%s\n", line_buf);
pthread_mutex_lock(&s_lock);
memcpy(s_ring[s_head], line_buf, sizeof(line_buf));
s_head = (s_head + 1) % RING_LINES;
if (s_count < RING_LINES)
s_count++;
pthread_mutex_unlock(&s_lock);
}
char* log_dump_recent(void)
{
pthread_mutex_lock(&s_lock);
size_t cap = (size_t)s_count * RING_LINE_LEN + 1;
char* out = malloc(cap);
if (!out) {
pthread_mutex_unlock(&s_lock);
char* empty = malloc(1);
if (empty)
empty[0] = '\0';
return empty;
}
size_t used = 0;
int start = (s_head - s_count + RING_LINES) % RING_LINES;
for (int i = 0; i < s_count; i++) {
const char* src = s_ring[(start + i) % RING_LINES];
size_t len = strlen(src);
if (used + len + 2 > cap)
break;
memcpy(out + used, src, len);
used += len;
out[used++] = '\n';
}
out[used] = '\0';
pthread_mutex_unlock(&s_lock);
return out;
}
void log_clear_recent(void)
{
pthread_mutex_lock(&s_lock);
s_head = 0;
s_count = 0;
pthread_mutex_unlock(&s_lock);
}
+31
View File
@@ -0,0 +1,31 @@
// Minimal leveled logger. Writes to stderr (captured by the webOS service
// launcher) and optionally to a ring of recent lines that the UI can fetch
// over Luna, so users can debug a TV they cannot SSH into.
#pragma once
#include <stdarg.h>
#include <stddef.h>
typedef enum {
LOG_ERROR = 0,
LOG_WARN = 1,
LOG_INFO = 2,
LOG_DEBUG = 3,
} log_level_t;
void log_init(log_level_t level);
void log_set_level(log_level_t level);
log_level_t log_get_level(void);
void log_printf(log_level_t level, const char* file, int line, const char* fmt, ...)
__attribute__((format(printf, 4, 5)));
// Copies the most recent log lines (oldest first) into a newly allocated
// NUL-terminated string. Caller frees. Never returns NULL.
char* log_dump_recent(void);
void log_clear_recent(void);
#define ERR(...) log_printf(LOG_ERROR, __FILE__, __LINE__, __VA_ARGS__)
#define WARN(...) log_printf(LOG_WARN, __FILE__, __LINE__, __VA_ARGS__)
#define INFO(...) log_printf(LOG_INFO, __FILE__, __LINE__, __VA_ARGS__)
#define DBG(...) log_printf(LOG_DEBUG, __FILE__, __LINE__, __VA_ARGS__)
+145
View File
@@ -0,0 +1,145 @@
#include "ringbuf.h"
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <time.h>
bool ringbuf_init(ringbuf_t* rb, size_t capacity)
{
memset(rb, 0, sizeof(*rb));
rb->data = malloc(capacity);
if (!rb->data)
return false;
rb->cap = capacity;
pthread_mutex_init(&rb->lock, NULL);
pthread_cond_init(&rb->readable, NULL);
return true;
}
void ringbuf_destroy(ringbuf_t* rb)
{
if (!rb->data)
return;
pthread_mutex_destroy(&rb->lock);
pthread_cond_destroy(&rb->readable);
free(rb->data);
rb->data = NULL;
rb->cap = 0;
}
static void discard_locked(ringbuf_t* rb, size_t n)
{
if (n > rb->used)
n = rb->used;
rb->tail = (rb->tail + n) % rb->cap;
rb->used -= n;
rb->dropped_bytes += n;
}
size_t ringbuf_write(ringbuf_t* rb, const void* src, size_t len)
{
if (len == 0)
return 0;
pthread_mutex_lock(&rb->lock);
size_t dropped = 0;
// A write larger than the whole buffer can only keep its tail end.
if (len >= rb->cap) {
dropped = rb->used + (len - rb->cap);
rb->dropped_bytes += dropped;
src = (const unsigned char*)src + (len - rb->cap);
len = rb->cap;
rb->head = rb->tail = rb->used = 0;
} else if (rb->used + len > rb->cap) {
size_t need = rb->used + len - rb->cap;
discard_locked(rb, need);
dropped = need;
}
size_t first = rb->cap - rb->head;
if (first > len)
first = len;
memcpy(rb->data + rb->head, src, first);
if (len > first)
memcpy(rb->data, (const unsigned char*)src + first, len - first);
rb->head = (rb->head + len) % rb->cap;
rb->used += len;
pthread_cond_signal(&rb->readable);
pthread_mutex_unlock(&rb->lock);
return dropped;
}
size_t ringbuf_read(ringbuf_t* rb, void* dst, size_t len, int timeout_ms)
{
pthread_mutex_lock(&rb->lock);
while (rb->used == 0 && !rb->closed) {
if (timeout_ms < 0) {
pthread_cond_wait(&rb->readable, &rb->lock);
continue;
}
struct timeval now;
gettimeofday(&now, NULL);
struct timespec deadline;
deadline.tv_sec = now.tv_sec + timeout_ms / 1000;
deadline.tv_nsec = now.tv_usec * 1000L + (long)(timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) {
deadline.tv_sec++;
deadline.tv_nsec -= 1000000000L;
}
if (pthread_cond_timedwait(&rb->readable, &rb->lock, &deadline) == ETIMEDOUT)
break;
}
size_t n = rb->used < len ? rb->used : len;
if (n > 0) {
size_t first = rb->cap - rb->tail;
if (first > n)
first = n;
memcpy(dst, rb->data + rb->tail, first);
if (n > first)
memcpy((unsigned char*)dst + first, rb->data, n - first);
rb->tail = (rb->tail + n) % rb->cap;
rb->used -= n;
}
pthread_mutex_unlock(&rb->lock);
return n;
}
void ringbuf_close(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
rb->closed = true;
pthread_cond_broadcast(&rb->readable);
pthread_mutex_unlock(&rb->lock);
}
void ringbuf_reset(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
rb->head = rb->tail = rb->used = 0;
rb->closed = false;
pthread_mutex_unlock(&rb->lock);
}
size_t ringbuf_used(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
size_t n = rb->used;
pthread_mutex_unlock(&rb->lock);
return n;
}
unsigned long long ringbuf_dropped(ringbuf_t* rb)
{
pthread_mutex_lock(&rb->lock);
unsigned long long n = rb->dropped_bytes;
pthread_mutex_unlock(&rb->lock);
return n;
}
+40
View File
@@ -0,0 +1,40 @@
// Byte ring buffer for one producer and one consumer, guarded by a mutex.
//
// Used to decouple the capture thread from sinks that can block (TCP, HTTP).
// On overflow the oldest bytes are dropped rather than stalling the producer:
// for a live audio stream, falling behind should cost you a glitch, not
// backpressure into the capture device.
#pragma once
#include <pthread.h>
#include <stdbool.h>
#include <stddef.h>
typedef struct {
unsigned char* data;
size_t cap;
size_t head; // write offset
size_t tail; // read offset
size_t used;
unsigned long long dropped_bytes;
bool closed;
pthread_mutex_t lock;
pthread_cond_t readable;
} ringbuf_t;
bool ringbuf_init(ringbuf_t* rb, size_t capacity);
void ringbuf_destroy(ringbuf_t* rb);
// Always accepts the whole write, discarding oldest data if needed.
// Returns the number of bytes dropped to make room.
size_t ringbuf_write(ringbuf_t* rb, const void* src, size_t len);
// Blocks until at least one byte is available, the buffer is closed, or
// `timeout_ms` elapses. Returns bytes read (0 on timeout or close).
size_t ringbuf_read(ringbuf_t* rb, void* dst, size_t len, int timeout_ms);
// Wakes any blocked reader and makes subsequent reads return 0.
void ringbuf_close(ringbuf_t* rb);
void ringbuf_reset(ringbuf_t* rb);
size_t ringbuf_used(ringbuf_t* rb);
unsigned long long ringbuf_dropped(ringbuf_t* rb);
+284
View File
@@ -0,0 +1,284 @@
#include "config.h"
#include "common/log.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
// Homebrew services keep their state under /var/lib/webosbrew, which survives
// app upgrades (the app directory does not). The fallbacks matter mostly for
// running this on a desktop while developing.
#define PRIMARY_DIR "/var/lib/webosbrew/audiocap"
#define PRIMARY_PATH PRIMARY_DIR "/config.json"
#define FALLBACK_PATH "/tmp/audiocap-config.json"
static const char* DEFAULTS_JSON =
"{"
" \"autoStart\": false,"
" \"logLevel\": \"info\","
" \"capture\": {"
" \"backend\": \"auto\","
" \"device\": \"\","
" \"server\": \"\","
" \"command\": \"\","
" \"rate\": 48000,"
" \"channels\": 2"
" },"
" \"dsp\": { \"attack\": 0.6, \"release\": 0.12 },"
" \"sinks\": [\"hyperhdr\"],"
" \"hyperhdr\": {"
" \"host\": \"\","
" \"port\": 5004,"
" \"multicast\": false,"
" \"multicastTtl\": 4,"
" \"sapAnnounce\": true"
" },"
" \"hyperhdrViz\": {"
" \"host\": \"\","
" \"port\": 19400,"
" \"priority\": 150,"
" \"width\": 64,"
" \"height\": 36,"
" \"fps\": 30,"
" \"mode\": \"spectrum\","
" \"saturation\": 1.0,"
" \"minBrightness\": 0.02"
" },"
" \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 },"
" \"tcp\": { \"port\": 4011, \"maxClients\": 4 },"
" \"http\": { \"port\": 4012, \"maxClients\": 4 }"
"}";
struct config {
json_value_t* root;
char path[256];
bool persisted; // false when we fell back to a volatile location
};
json_value_t* config_defaults(void)
{
return json_parse(DEFAULTS_JSON);
}
// mkdir -p, ignoring components that already exist.
static bool make_dirs(const char* dir)
{
char tmp[256];
size_t n = strlen(dir);
if (n == 0 || n >= sizeof(tmp))
return false;
memcpy(tmp, dir, n + 1);
for (char* p = tmp + 1; *p; p++) {
if (*p != '/')
continue;
*p = '\0';
if (mkdir(tmp, 0755) != 0 && errno != EEXIST)
return false;
*p = '/';
}
return mkdir(tmp, 0755) == 0 || errno == EEXIST;
}
static bool dir_writable(const char* dir)
{
return access(dir, W_OK | X_OK) == 0;
}
// Picks where to store settings, creating the directory if we can.
static void choose_path(config_t* c)
{
const char* env = getenv("AUDIOCAP_CONFIG");
if (env && *env) {
snprintf(c->path, sizeof(c->path), "%s", env);
c->persisted = true;
return;
}
if (make_dirs(PRIMARY_DIR) && dir_writable(PRIMARY_DIR)) {
snprintf(c->path, sizeof(c->path), "%s", PRIMARY_PATH);
c->persisted = true;
return;
}
WARN("%s is not writable; settings will not survive a reboot", PRIMARY_DIR);
snprintf(c->path, sizeof(c->path), "%s", FALLBACK_PATH);
c->persisted = false;
}
static char* read_file(const char* path)
{
FILE* f = fopen(path, "rb");
if (!f)
return NULL;
if (fseek(f, 0, SEEK_END) != 0) {
fclose(f);
return NULL;
}
long size = ftell(f);
// A settings file this large is corrupt, not something to load.
if (size < 0 || size > 1 << 20) {
fclose(f);
return NULL;
}
rewind(f);
char* buf = malloc((size_t)size + 1);
if (!buf) {
fclose(f);
return NULL;
}
size_t got = fread(buf, 1, (size_t)size, f);
fclose(f);
buf[got] = '\0';
return buf;
}
// Writes to a temporary file and renames, so an interrupted save cannot leave
// a half-written config that fails to parse on next boot.
static bool write_atomic(const char* path, const char* text, char* err, size_t errlen)
{
char tmp[300];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE* f = fopen(tmp, "wb");
if (!f) {
snprintf(err, errlen, "cannot open %s: %s", tmp, strerror(errno));
return false;
}
size_t len = strlen(text);
bool ok = fwrite(text, 1, len, f) == len && fputc('\n', f) != EOF;
if (ok)
ok = fflush(f) == 0;
if (ok) {
int fd = fileno(f);
if (fd >= 0)
fsync(fd);
}
if (fclose(f) != 0)
ok = false;
if (!ok) {
snprintf(err, errlen, "cannot write %s: %s", tmp, strerror(errno));
unlink(tmp);
return false;
}
if (rename(tmp, path) != 0) {
snprintf(err, errlen, "cannot replace %s: %s", path, strerror(errno));
unlink(tmp);
return false;
}
return true;
}
config_t* config_load(void)
{
config_t* c = calloc(1, sizeof(*c));
if (!c)
return NULL;
choose_path(c);
json_value_t* defaults = config_defaults();
if (!defaults) {
// Only reachable if DEFAULTS_JSON above is malformed.
ERR("built-in defaults failed to parse");
free(c);
return NULL;
}
char* text = read_file(c->path);
if (!text) {
INFO("No settings at %s; using defaults", c->path);
c->root = defaults;
return c;
}
json_value_t* stored = json_parse(text);
free(text);
if (!stored) {
WARN("Settings at %s are not valid JSON; using defaults", c->path);
c->root = defaults;
return c;
}
c->root = json_merge(defaults, stored);
json_free(stored);
if (!c->root) {
c->root = defaults;
} else {
json_free(defaults);
INFO("Loaded settings from %s", c->path);
}
return c;
}
void config_free(config_t* c)
{
if (!c)
return;
json_free(c->root);
free(c);
}
const json_value_t* config_root(const config_t* c)
{
return c ? c->root : NULL;
}
const char* config_path(const config_t* c)
{
return c ? c->path : "";
}
bool config_is_persistent(const config_t* c)
{
return c ? c->persisted : false;
}
char* config_serialize(const config_t* c)
{
return c ? json_serialize(c->root, true) : NULL;
}
bool config_apply(config_t* c, const json_value_t* patch, char* err, size_t errlen)
{
if (!c) {
snprintf(err, errlen, "no config loaded");
return false;
}
if (!patch || patch->type != JSON_OBJECT) {
snprintf(err, errlen, "settings patch must be an object");
return false;
}
json_value_t* merged = json_merge(c->root, patch);
if (!merged) {
snprintf(err, errlen, "out of memory merging settings");
return false;
}
json_free(c->root);
c->root = merged;
char* text = json_serialize(c->root, true);
if (!text) {
snprintf(err, errlen, "out of memory serialising settings");
return false;
}
bool ok = write_atomic(c->path, text, err, errlen);
free(text);
if (ok)
DBG("Settings saved to %s", c->path);
else
WARN("Settings applied but not saved: %s", err);
return ok;
}
+37
View File
@@ -0,0 +1,37 @@
// Persistent settings.
//
// One JSON document, written pretty-printed so it stays editable over ssh on
// a rooted TV. The UI never sends the whole document back: setConfig takes a
// partial object which is deep-merged over the current one, so a new setting
// added in a later version does not get wiped by an older frontend.
#pragma once
#include "common/json.h"
#include <stdbool.h>
#include <stddef.h>
typedef struct config config_t;
// Never returns NULL: a missing or corrupt file falls back to defaults.
config_t* config_load(void);
void config_free(config_t* c);
// The merged document (defaults + whatever was on disk). Valid until the next
// config_apply().
const json_value_t* config_root(const config_t* c);
const char* config_path(const config_t* c);
// False when settings landed in /tmp because nothing writable was found; the
// UI surfaces this so "my settings vanished after a reboot" is explainable.
bool config_is_persistent(const config_t* c);
// Deep-merges `patch` and persists the result. The in-memory config is updated
// even if the write fails, so a read-only filesystem degrades to "settings
// work until reboot" rather than "settings do nothing".
bool config_apply(config_t* c, const json_value_t* patch, char* err, size_t errlen);
// Pretty JSON of the whole document. Caller frees.
char* config_serialize(const config_t* c);
// The defaults, for the UI's "reset" button. Caller frees.
json_value_t* config_defaults(void);
+270
View File
@@ -0,0 +1,270 @@
#include "dsp.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define DB_FLOOR (-90.0f)
#define BAND_DB_FLOOR (-70.0f) // band energy below this maps to 0
#define BAND_LOW_HZ 40.0f
#define BAND_HIGH_HZ 16000.0f
struct dsp {
audio_format_t fmt;
// Sliding mono window; blocks overlap by 50% so the spectrum updates every
// block instead of every other one.
float window_samples[DSP_FFT_SIZE];
int window_fill;
float hann[DSP_FFT_SIZE];
float re[DSP_FFT_SIZE];
float im[DSP_FFT_SIZE];
int band_start[DSP_BANDS]; // inclusive bin index
int band_end[DSP_BANDS]; // exclusive bin index
float smoothed[DSP_BANDS];
float attack;
float release;
};
// ---------------------------------------------------------------------------
// FFT
// ---------------------------------------------------------------------------
void dsp_fft(float* re, float* im, int n)
{
// Bit-reversal permutation.
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j) {
float tr = re[i];
re[i] = re[j];
re[j] = tr;
float ti = im[i];
im[i] = im[j];
im[j] = ti;
}
}
// Iterative Cooley-Tukey. Twiddles are recomputed per stage with sin/cos
// rather than cached: at n=1024 that is ~10 calls per block, far cheaper
// than carrying a table around, and it avoids recurrence drift.
for (int len = 2; len <= n; len <<= 1) {
float ang = -2.0f * (float)M_PI / (float)len;
float wr = cosf(ang);
float wi = sinf(ang);
for (int i = 0; i < n; i += len) {
float cr = 1.0f, ci = 0.0f;
for (int k = 0; k < len / 2; k++) {
float ur = re[i + k];
float ui = im[i + k];
float vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
float vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
re[i + k] = ur + vr;
im[i + k] = ui + vi;
re[i + k + len / 2] = ur - vr;
im[i + k + len / 2] = ui - vi;
float ncr = cr * wr - ci * wi;
ci = cr * wi + ci * wr;
cr = ncr;
}
}
}
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
static void compute_bands(dsp_t* d)
{
float nyquist = (float)d->fmt.rate / 2.0f;
float high = BAND_HIGH_HZ < nyquist ? BAND_HIGH_HZ : nyquist * 0.95f;
float low = BAND_LOW_HZ;
if (low >= high)
low = high / 2.0f;
float bin_hz = (float)d->fmt.rate / (float)DSP_FFT_SIZE;
int max_bin = DSP_FFT_SIZE / 2;
for (int b = 0; b < DSP_BANDS; b++) {
float f0 = low * powf(high / low, (float)b / (float)DSP_BANDS);
float f1 = low * powf(high / low, (float)(b + 1) / (float)DSP_BANDS);
int s = (int)(f0 / bin_hz);
int e = (int)(f1 / bin_hz);
if (s < 1)
s = 1; // skip DC
if (e <= s)
e = s + 1; // every band owns at least one bin
if (e > max_bin)
e = max_bin;
if (s >= e)
s = e - 1;
d->band_start[b] = s;
d->band_end[b] = e;
}
}
dsp_t* dsp_create(const audio_format_t* fmt)
{
dsp_t* d = calloc(1, sizeof(*d));
if (!d)
return NULL;
d->fmt = *fmt;
d->attack = 0.6f;
d->release = 0.12f;
for (int i = 0; i < DSP_FFT_SIZE; i++)
d->hann[i] = 0.5f * (1.0f - cosf(2.0f * (float)M_PI * (float)i / (float)(DSP_FFT_SIZE - 1)));
compute_bands(d);
return d;
}
void dsp_destroy(dsp_t* d) { free(d); }
void dsp_set_format(dsp_t* d, const audio_format_t* fmt)
{
if (!d)
return;
d->fmt = *fmt;
d->window_fill = 0;
memset(d->window_samples, 0, sizeof(d->window_samples));
compute_bands(d);
}
void dsp_set_smoothing(dsp_t* d, float attack, float release)
{
if (!d)
return;
if (attack < 0.01f)
attack = 0.01f;
if (attack > 1.0f)
attack = 1.0f;
if (release < 0.01f)
release = 0.01f;
if (release > 1.0f)
release = 1.0f;
d->attack = attack;
d->release = release;
}
// ---------------------------------------------------------------------------
// Processing
// ---------------------------------------------------------------------------
static float to_db(float amplitude)
{
if (amplitude <= 1e-9f)
return DB_FLOOR;
float db = 20.0f * log10f(amplitude);
return db < DB_FLOOR ? DB_FLOOR : db;
}
void dsp_process(dsp_t* d, const int16_t* pcm, int frames, dsp_levels_t* out)
{
if (!d || !out)
return;
memset(out, 0, sizeof(*out));
if (frames <= 0)
return;
const int ch = d->fmt.channels < 1 ? 1 : d->fmt.channels;
// --- Peak / RMS over the raw block -------------------------------------
double sum_sq = 0.0;
int peak_abs = 0;
int clipped = 0;
const int total_samples = frames * ch;
for (int i = 0; i < total_samples; i++) {
int s = pcm[i];
int a = s < 0 ? -s : s;
if (a > peak_abs)
peak_abs = a;
if (a >= 32767)
clipped++;
double f = (double)s / 32768.0;
sum_sq += f * f;
}
out->peak = (float)peak_abs / 32768.0f;
out->rms = (float)sqrt(sum_sq / (double)total_samples);
out->peak_db = to_db(out->peak);
out->rms_db = to_db(out->rms);
// A couple of full-scale samples is normal on loud content; a sustained
// run is what actually indicates clipping.
out->clipping = clipped > total_samples / 100;
// --- Slide new mono samples into the FFT window ------------------------
for (int i = 0; i < frames; i++) {
float mono = 0.0f;
for (int c = 0; c < ch; c++)
mono += (float)pcm[i * ch + c] / 32768.0f;
mono /= (float)ch;
if (d->window_fill < DSP_FFT_SIZE) {
d->window_samples[d->window_fill++] = mono;
} else {
memmove(d->window_samples, d->window_samples + 1,
(DSP_FFT_SIZE - 1) * sizeof(float));
d->window_samples[DSP_FFT_SIZE - 1] = mono;
}
}
if (d->window_fill < DSP_FFT_SIZE) {
// Not enough history yet; report levels but leave bands at zero.
memcpy(out->bands, d->smoothed, sizeof(out->bands));
return;
}
// --- Spectrum ----------------------------------------------------------
for (int i = 0; i < DSP_FFT_SIZE; i++) {
d->re[i] = d->window_samples[i] * d->hann[i];
d->im[i] = 0.0f;
}
dsp_fft(d->re, d->im, DSP_FFT_SIZE);
for (int b = 0; b < DSP_BANDS; b++) {
double acc = 0.0;
int n = d->band_end[b] - d->band_start[b];
for (int k = d->band_start[b]; k < d->band_end[b]; k++) {
float mag = sqrtf(d->re[k] * d->re[k] + d->im[k] * d->im[k]);
acc += mag;
}
// Mean magnitude, scaled back up for the Hann window's 0.5 coherent
// gain and the FFT's unnormalised forward transform.
float mean = n > 0 ? (float)(acc / n) : 0.0f;
float amp = mean * 4.0f / (float)(DSP_FFT_SIZE / 2);
float db = to_db(amp);
float norm = (db - BAND_DB_FLOOR) / (0.0f - BAND_DB_FLOOR);
if (norm < 0.0f)
norm = 0.0f;
if (norm > 1.0f)
norm = 1.0f;
// Pink-noise tilt: high bands carry less energy in real programme
// material, so lift them or the top of the bar graph never moves.
float tilt = 1.0f + 0.5f * ((float)b / (float)(DSP_BANDS - 1));
norm *= tilt;
if (norm > 1.0f)
norm = 1.0f;
float coeff = norm > d->smoothed[b] ? d->attack : d->release;
d->smoothed[b] += (norm - d->smoothed[b]) * coeff;
out->bands[b] = d->smoothed[b];
}
}
+44
View File
@@ -0,0 +1,44 @@
// Level metering and spectrum analysis.
//
// Two consumers: the UI meter (peak/RMS, cheap) and the on-TV visualiser that
// renders an image for HyperHDR's flatbuffer input (band energies, needs an
// FFT). Both run on the capture thread, so this has to stay cheap enough to
// finish well inside one 512-frame block.
#pragma once
#include "common/audio.h"
#include <stdbool.h>
#include <stdint.h>
#define DSP_FFT_SIZE 1024 // must be a power of two
#define DSP_BANDS 16 // log-spaced bands reported to the visualiser
typedef struct {
float peak; // 0..1, highest absolute sample in the last block
float rms; // 0..1, root mean square of the last block
float peak_db; // dBFS, clamped to -90
float rms_db; // dBFS, clamped to -90
float bands[DSP_BANDS]; // 0..1 normalised band energies, smoothed
bool clipping; // a sample hit full scale in the last block
} dsp_levels_t;
typedef struct dsp dsp_t;
dsp_t* dsp_create(const audio_format_t* fmt);
void dsp_destroy(dsp_t* d);
// Reconfigures band edges after a sample-rate change. Cheap; safe to call
// whenever the capture format is renegotiated.
void dsp_set_format(dsp_t* d, const audio_format_t* fmt);
// `attack` and `release` are per-block smoothing coefficients in 0..1, where
// 1 means "follow instantly". Separate values let bars snap up and fall slowly.
void dsp_set_smoothing(dsp_t* d, float attack, float release);
// Feeds one block of interleaved S16LE frames and updates `out`.
void dsp_process(dsp_t* d, const int16_t* pcm, int frames, dsp_levels_t* out);
// Standalone real FFT over `n` samples (n must be a power of two).
// `re` and `im` are in/out arrays of length n.
void dsp_fft(float* re, float* im, int n);
+510
View File
@@ -0,0 +1,510 @@
#include "engine.h"
#include "capture/capture.h"
#include "common/log.h"
#include "sinks/sink.h"
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define MAX_SINKS 8
#define NOTIFY_INTERVAL_MS 100
// How long to keep retrying the capture device before giving up. Autostart
// runs early in boot, where PulseAudio may not have come up yet.
#define OPEN_RETRY_SECONDS 30
#define OPEN_RETRY_DELAY_MS 2000
#define STOP_JOIN_TIMEOUT_SEC 5
typedef struct {
char id[32];
sink_t* sink; // NULL when this sink failed to open
char error[192];
} sink_slot_t;
struct engine {
pthread_t thread;
bool thread_valid;
pthread_mutex_t lock;
engine_notify_fn notify;
void* notify_user;
volatile bool stop_requested;
// --- guarded by `lock` ---
engine_state_t state;
char error[256];
json_value_t* cfg;
capture_t* cap;
dsp_t* dsp;
char backend_id[32];
char backend_name[64];
char device[128];
audio_format_t fmt;
sink_slot_t sinks[MAX_SINKS];
size_t sink_count;
dsp_levels_t levels;
bool have_levels;
struct timespec started_at;
unsigned long long frames_captured;
unsigned long long blocks;
unsigned long long timeouts;
};
const char* engine_state_name(engine_state_t s)
{
switch (s) {
case ENGINE_STOPPED:
return "stopped";
case ENGINE_STARTING:
return "starting";
case ENGINE_RUNNING:
return "running";
default:
return "error";
}
}
static long ms_since(const struct timespec* since)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (now.tv_sec - since->tv_sec) * 1000L + (now.tv_nsec - since->tv_nsec) / 1000000L;
}
static void sleep_ms(int ms)
{
struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L };
nanosleep(&ts, NULL);
}
static void set_state(engine_t* e, engine_state_t state, const char* err)
{
pthread_mutex_lock(&e->lock);
e->state = state;
if (err)
snprintf(e->error, sizeof(e->error), "%s", err);
else if (state != ENGINE_ERROR)
e->error[0] = '\0';
pthread_mutex_unlock(&e->lock);
if (e->notify)
e->notify(e->notify_user);
}
// ---------------------------------------------------------------------------
// Setup, on the engine thread
// ---------------------------------------------------------------------------
static bool open_capture(engine_t* e, char* err, size_t errlen)
{
const json_value_t* cc = json_get(e->cfg, "capture");
const char* backend = json_str(cc, "backend", "auto");
const char* device = json_str(cc, "device", "");
const char* server = json_str(cc, "server", "");
const char* command = json_str(cc, "command", "");
capture_opts_t opts = {
.fmt = {
.rate = json_int(cc, "rate", AUDIO_DEFAULT_RATE),
.channels = json_int(cc, "channels", AUDIO_DEFAULT_CHANNELS),
},
.device = (device && *device) ? device : NULL,
.server = (server && *server) ? server : NULL,
.command = (command && *command) ? command : NULL,
};
if (opts.fmt.channels < 1 || opts.fmt.channels > AUDIO_MAX_CHANNELS)
opts.fmt.channels = AUDIO_DEFAULT_CHANNELS;
if (opts.fmt.rate < 8000 || opts.fmt.rate > 192000)
opts.fmt.rate = AUDIO_DEFAULT_RATE;
struct timespec first_try;
clock_gettime(CLOCK_MONOTONIC, &first_try);
for (;;) {
capture_t* cap = capture_open(backend, &opts, err, errlen);
if (cap) {
pthread_mutex_lock(&e->lock);
e->cap = cap;
e->fmt = cap->fmt;
snprintf(e->backend_id, sizeof(e->backend_id), "%s", cap->driver->id);
snprintf(e->backend_name, sizeof(e->backend_name), "%s", cap->driver->name);
snprintf(e->device, sizeof(e->device), "%s", opts.device ? opts.device : "(default)");
pthread_mutex_unlock(&e->lock);
return true;
}
if (e->stop_requested)
return false;
if (ms_since(&first_try) > OPEN_RETRY_SECONDS * 1000L)
return false;
WARN("Capture open failed (%s); retrying", err);
// Broken up so a stop request during the wait is noticed quickly.
for (int waited = 0; waited < OPEN_RETRY_DELAY_MS && !e->stop_requested; waited += 100)
sleep_ms(100);
}
}
static void open_sinks(engine_t* e)
{
const json_value_t* list = json_get(e->cfg, "sinks");
size_t count = json_len(list);
if (count > MAX_SINKS) {
WARN("Only the first %d sinks will be started", MAX_SINKS);
count = MAX_SINKS;
}
for (size_t i = 0; i < count; i++) {
const json_value_t* item = json_at(list, i);
if (!item || item->type != JSON_STRING)
continue;
sink_slot_t slot;
memset(&slot, 0, sizeof(slot));
snprintf(slot.id, sizeof(slot.id), "%s", item->u.string);
char err[192] = { 0 };
slot.sink = sink_open(slot.id, e->cfg, &e->fmt, err, sizeof(err));
if (!slot.sink) {
snprintf(slot.error, sizeof(slot.error), "%s", err);
// A misconfigured sink must not take the whole pipeline down: the
// others keep running and the UI shows what went wrong.
ERR("Sink '%s' failed to start: %s", slot.id, err);
}
pthread_mutex_lock(&e->lock);
e->sinks[e->sink_count++] = slot;
pthread_mutex_unlock(&e->lock);
}
if (e->sink_count == 0)
WARN("No sinks configured; capturing for level metering only");
}
static void close_everything(engine_t* e)
{
pthread_mutex_lock(&e->lock);
sink_slot_t slots[MAX_SINKS];
size_t n = e->sink_count;
memcpy(slots, e->sinks, sizeof(slots));
memset(e->sinks, 0, sizeof(e->sinks));
e->sink_count = 0;
capture_t* cap = e->cap;
dsp_t* dsp = e->dsp;
e->cap = NULL;
e->dsp = NULL;
e->have_levels = false;
memset(&e->levels, 0, sizeof(e->levels));
pthread_mutex_unlock(&e->lock);
// Done outside the lock: closing a sink can send a farewell message.
for (size_t i = 0; i < n; i++) {
if (slots[i].sink)
sink_close(slots[i].sink);
}
if (dsp)
dsp_destroy(dsp);
if (cap)
capture_close(cap);
}
// ---------------------------------------------------------------------------
// The capture loop
// ---------------------------------------------------------------------------
static void* engine_thread(void* arg)
{
engine_t* e = arg;
char err[256] = { 0 };
if (!open_capture(e, err, sizeof(err))) {
if (e->stop_requested) {
set_state(e, ENGINE_STOPPED, NULL);
} else {
ERR("Capture could not be started: %s", err);
set_state(e, ENGINE_ERROR, err);
}
return NULL;
}
dsp_t* dsp = dsp_create(&e->fmt);
if (!dsp) {
close_everything(e);
set_state(e, ENGINE_ERROR, "out of memory creating the analyser");
return NULL;
}
const json_value_t* dc = json_get(e->cfg, "dsp");
dsp_set_smoothing(dsp, (float)json_num(dc, "attack", 0.6), (float)json_num(dc, "release", 0.12));
pthread_mutex_lock(&e->lock);
e->dsp = dsp;
clock_gettime(CLOCK_MONOTONIC, &e->started_at);
e->frames_captured = 0;
e->blocks = 0;
e->timeouts = 0;
pthread_mutex_unlock(&e->lock);
open_sinks(e);
set_state(e, ENGINE_RUNNING, NULL);
INFO("Capture running: %s at %d Hz, %d channel(s), %zu sink(s)", e->backend_id,
e->fmt.rate, e->fmt.channels, e->sink_count);
int16_t* block = malloc((size_t)AUDIO_BLOCK_FRAMES * AUDIO_MAX_CHANNELS * sizeof(int16_t));
if (!block) {
close_everything(e);
set_state(e, ENGINE_ERROR, "out of memory allocating the capture block");
return NULL;
}
struct timespec last_notify;
clock_gettime(CLOCK_MONOTONIC, &last_notify);
bool failed = false;
while (!e->stop_requested) {
int frames = e->cap->read(e->cap, block, AUDIO_BLOCK_FRAMES);
if (frames < 0) {
snprintf(err, sizeof(err), "capture backend '%s' stopped delivering audio",
e->backend_id);
failed = true;
break;
}
pthread_mutex_lock(&e->lock);
dsp_levels_t* levels = NULL;
if (frames > 0) {
dsp_process(e->dsp, block, frames, &e->levels);
e->have_levels = true;
e->frames_captured += (unsigned long long)frames;
e->blocks++;
levels = &e->levels;
} else {
e->timeouts++;
}
// Sinks are called even for an empty block so the ones that maintain a
// connection get a chance to reconnect while the input is silent.
for (size_t i = 0; i < e->sink_count; i++) {
sink_t* s = e->sinks[i].sink;
if (s)
s->write(s, block, frames, levels);
}
pthread_mutex_unlock(&e->lock);
if (e->notify && ms_since(&last_notify) >= NOTIFY_INTERVAL_MS) {
clock_gettime(CLOCK_MONOTONIC, &last_notify);
e->notify(e->notify_user);
}
}
free(block);
close_everything(e);
if (failed) {
ERR("%s", err);
set_state(e, ENGINE_ERROR, err);
} else {
INFO("Capture stopped");
set_state(e, ENGINE_STOPPED, NULL);
}
return NULL;
}
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
engine_t* engine_create(engine_notify_fn notify, void* user)
{
engine_t* e = calloc(1, sizeof(*e));
if (!e)
return NULL;
pthread_mutex_init(&e->lock, NULL);
e->notify = notify;
e->notify_user = user;
e->state = ENGINE_STOPPED;
e->fmt.rate = AUDIO_DEFAULT_RATE;
e->fmt.channels = AUDIO_DEFAULT_CHANNELS;
return e;
}
void engine_destroy(engine_t* e)
{
if (!e)
return;
engine_stop(e);
json_free(e->cfg);
pthread_mutex_destroy(&e->lock);
free(e);
}
bool engine_start(engine_t* e, const json_value_t* cfg, char* err, size_t errlen)
{
if (!e) {
snprintf(err, errlen, "no engine");
return false;
}
if (engine_is_active(e)) {
snprintf(err, errlen, "already running");
return false;
}
// A previous run may have ended on its own; reap the thread before reusing
// the slot.
if (e->thread_valid) {
pthread_join(e->thread, NULL);
e->thread_valid = false;
}
// The settings are snapshotted so a setConfig mid-capture cannot change
// things out from under the running pipeline.
json_value_t* snapshot = json_clone(cfg);
if (!snapshot) {
snprintf(err, errlen, "out of memory copying settings");
return false;
}
pthread_mutex_lock(&e->lock);
json_free(e->cfg);
e->cfg = snapshot;
e->state = ENGINE_STARTING;
e->error[0] = '\0';
pthread_mutex_unlock(&e->lock);
e->stop_requested = false;
if (pthread_create(&e->thread, NULL, engine_thread, e) != 0) {
snprintf(err, errlen, "cannot create capture thread: %s", strerror(errno));
set_state(e, ENGINE_ERROR, err);
return false;
}
e->thread_valid = true;
if (e->notify)
e->notify(e->notify_user);
return true;
}
void engine_stop(engine_t* e)
{
if (!e || !e->thread_valid)
return;
e->stop_requested = true;
#if defined(__GLIBC__) && defined(_GNU_SOURCE)
// A capture backend that has wedged in a blocking read must not take the
// Luna main loop down with it: give up on the thread and carry on.
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += STOP_JOIN_TIMEOUT_SEC;
if (pthread_timedjoin_np(e->thread, NULL, &deadline) != 0) {
WARN("Capture thread did not stop within %ds; abandoning it", STOP_JOIN_TIMEOUT_SEC);
pthread_detach(e->thread);
e->thread_valid = false;
set_state(e, ENGINE_ERROR, "capture thread did not stop");
return;
}
#else
pthread_join(e->thread, NULL);
#endif
e->thread_valid = false;
}
engine_state_t engine_state(engine_t* e)
{
if (!e)
return ENGINE_STOPPED;
pthread_mutex_lock(&e->lock);
engine_state_t s = e->state;
pthread_mutex_unlock(&e->lock);
return s;
}
bool engine_is_active(engine_t* e)
{
engine_state_t s = engine_state(e);
return s == ENGINE_STARTING || s == ENGINE_RUNNING;
}
void engine_write_status(engine_t* e, json_writer_t* w)
{
if (!e)
return;
pthread_mutex_lock(&e->lock);
jw_str(w, "state", engine_state_name(e->state));
jw_bool(w, "running", e->state == ENGINE_RUNNING);
if (e->error[0])
jw_str(w, "error", e->error);
else
jw_null(w, "error");
jw_obj_open(w, "capture");
jw_str(w, "backend", e->backend_id[0] ? e->backend_id : NULL);
jw_str(w, "backendName", e->backend_name[0] ? e->backend_name : NULL);
jw_str(w, "device", e->device[0] ? e->device : NULL);
jw_int(w, "rate", e->fmt.rate);
jw_int(w, "channels", e->fmt.channels);
jw_int(w, "frames", (long long)e->frames_captured);
jw_int(w, "blocks", (long long)e->blocks);
jw_int(w, "timeouts", (long long)e->timeouts);
jw_int(w, "uptimeMs", e->state == ENGINE_RUNNING ? ms_since(&e->started_at) : 0);
jw_obj_close(w);
jw_obj_open(w, "levels");
if (e->have_levels) {
jw_num(w, "peak", e->levels.peak);
jw_num(w, "rms", e->levels.rms);
jw_num(w, "peakDb", e->levels.peak_db);
jw_num(w, "rmsDb", e->levels.rms_db);
jw_bool(w, "clipping", e->levels.clipping);
jw_arr_open(w, "bands");
for (int i = 0; i < DSP_BANDS; i++)
jw_num(w, NULL, e->levels.bands[i]);
jw_arr_close(w);
} else {
jw_num(w, "peak", 0);
jw_num(w, "rms", 0);
jw_num(w, "peakDb", -90);
jw_num(w, "rmsDb", -90);
jw_bool(w, "clipping", false);
jw_arr_open(w, "bands");
for (int i = 0; i < DSP_BANDS; i++)
jw_num(w, NULL, 0);
jw_arr_close(w);
}
jw_obj_close(w);
jw_arr_open(w, "sinks");
for (size_t i = 0; i < e->sink_count; i++) {
sink_slot_t* slot = &e->sinks[i];
jw_obj_open(w, NULL);
jw_str(w, "id", slot->id);
jw_bool(w, "ok", slot->sink != NULL);
if (slot->sink) {
jw_str(w, "name", slot->sink->driver->name);
jw_null(w, "error");
if (slot->sink->status)
slot->sink->status(slot->sink, w);
} else {
const sink_driver_t* drv = sink_find(slot->id);
jw_str(w, "name", drv ? drv->name : slot->id);
jw_str(w, "error", slot->error);
}
jw_obj_close(w);
}
jw_arr_close(w);
pthread_mutex_unlock(&e->lock);
}
+47
View File
@@ -0,0 +1,47 @@
// The capture pipeline.
//
// One thread owns everything that touches audio: it opens the backend, reads
// blocks, runs the DSP and hands each block to every enabled sink in turn.
// Sinks are contractually non-blocking, so the fan-out is synchronous and no
// audio is ever copied more than it has to be.
//
// Everything the Luna service needs to read is behind one mutex, so status
// queries never interfere with capture beyond a few microseconds.
#pragma once
#include "common/json.h"
#include "dsp.h"
#include <stdbool.h>
#include <stddef.h>
typedef struct engine engine_t;
typedef enum {
ENGINE_STOPPED,
ENGINE_STARTING,
ENGINE_RUNNING,
ENGINE_ERROR,
} engine_state_t;
// Fired from the engine thread on every state change and roughly ten times a
// second while running. Must not block: the service uses it to schedule a
// subscription update on the main loop.
typedef void (*engine_notify_fn)(void* user);
engine_t* engine_create(engine_notify_fn notify, void* user);
void engine_destroy(engine_t* e);
// Returns as soon as the thread is spawned; opening the capture device and
// the sinks happens on that thread, because either can take a moment and the
// Luna handler must not stall. Watch the state for the outcome.
bool engine_start(engine_t* e, const json_value_t* cfg, char* err, size_t errlen);
void engine_stop(engine_t* e);
engine_state_t engine_state(engine_t* e);
bool engine_is_active(engine_t* e); // starting or running
// Writes the status fields into an object the caller has already opened.
void engine_write_status(engine_t* e, json_writer_t* w);
const char* engine_state_name(engine_state_t s);
+123
View File
@@ -0,0 +1,123 @@
// Entry point for the native Luna service.
//
// Nothing interesting happens here: register on the bus, hand control to the
// glib main loop, and make sure a SIGTERM from the service launcher shuts the
// capture down cleanly so sinks get to say goodbye (HyperHDR in particular
// needs its Clear, or the LEDs freeze on the last frame we sent).
#include "common/log.h"
#include "service.h"
#include <glib-unix.h>
#include <glib.h>
#include <luna-service2/lunaservice.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define SERVICE_NAME "org.webosbrew.audiocap.service"
// webOS 3.5 and earlier need the service registered on the public bus too.
// Declared weak so the same binary keeps loading on newer firmware where the
// symbol was removed.
extern bool LSRegisterPubPriv(const char* name, LSHandle** handle, bool public_bus,
LSError* error) __attribute__((weak));
static gboolean on_signal(gpointer user)
{
GMainLoop* loop = user;
INFO("Signal received, shutting down");
g_main_loop_quit(loop);
return G_SOURCE_REMOVE;
}
static void log_environment(void)
{
uid_t uid = getuid();
INFO("lgtv-audio-cap service starting (uid=%d%s)", (int)uid,
uid == 0 ? ", root" : ", unprivileged");
if (uid != 0) {
// Without root the PulseAudio socket and the ALSA devices are usually
// out of reach, so say this once rather than leaving the user to
// decode a permission error later.
WARN("Not running as root: audio devices are likely inaccessible.");
WARN("Install the Homebrew Channel 'elevate-service' patch and restart the service.");
}
}
int main(int argc, char** argv)
{
bool debug = false;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-d") == 0 || strcmp(argv[i], "--debug") == 0)
debug = true;
}
log_init(debug ? LOG_DEBUG : LOG_INFO);
log_environment();
// A client that hangs up mid-stream must not kill the service. Every send
// path already asks for MSG_NOSIGNAL; this covers the rest.
signal(SIGPIPE, SIG_IGN);
GMainLoop* loop = g_main_loop_new(NULL, false);
LSError lserror;
LSErrorInit(&lserror);
LSHandle* handle = NULL;
bool registered = LSRegisterPubPriv
? LSRegisterPubPriv(SERVICE_NAME, &handle, true, &lserror)
: LSRegister(SERVICE_NAME, &handle, &lserror);
if (!registered) {
ERR("Cannot register %s on the Luna bus: %s", SERVICE_NAME, lserror.message);
LSErrorFree(&lserror);
g_main_loop_unref(loop);
return 1;
}
if (!LSGmainAttach(handle, loop, &lserror)) {
ERR("Cannot attach to the main loop: %s", lserror.message);
LSErrorFree(&lserror);
LSUnregister(handle, &lserror);
g_main_loop_unref(loop);
return 1;
}
service_t* service = service_create(handle, loop);
if (!service) {
ERR("Cannot create the service");
LSUnregister(handle, &lserror);
g_main_loop_unref(loop);
return 1;
}
char err[256] = { 0 };
if (!service_register(service, err, sizeof(err))) {
ERR("%s", err);
service_destroy(service);
LSUnregister(handle, &lserror);
g_main_loop_unref(loop);
return 1;
}
g_unix_signal_add(SIGTERM, on_signal, loop);
g_unix_signal_add(SIGINT, on_signal, loop);
INFO("Registered as %s", SERVICE_NAME);
service_autostart(service);
g_main_loop_run(loop);
INFO("Shutting down");
service_destroy(service);
if (!LSUnregister(handle, &lserror)) {
ERR("Unregister failed: %s", lserror.message);
LSErrorFree(&lserror);
}
g_main_loop_unref(loop);
return 0;
}
+263
View File
@@ -0,0 +1,263 @@
#include "flatbuf.h"
#include <stdlib.h>
#include <string.h>
#define VTABLE_METADATA_FIELDS 2
static size_t fb_offset(const fb_t* b) { return b->cap - b->head; }
static bool fb_ensure(fb_t* b, size_t need)
{
if (b->failed)
return false;
if (b->head >= need)
return true;
size_t used = b->cap - b->head;
size_t new_cap = b->cap ? b->cap : 1024;
while (new_cap - used < need)
new_cap *= 2;
uint8_t* fresh = malloc(new_cap);
if (!fresh) {
b->failed = true;
return false;
}
// Data grows downward from the top, so the live region keeps its
// right-alignment in the new allocation.
memcpy(fresh + new_cap - used, b->bytes + b->head, used);
free(b->bytes);
b->bytes = fresh;
b->cap = new_cap;
b->head = new_cap - used;
return true;
}
static void fb_pad(fb_t* b, size_t n)
{
if (n == 0 || !fb_ensure(b, n))
return;
b->head -= n;
memset(b->bytes + b->head, 0, n);
}
// Reserves room for a `size`-byte scalar that will be followed by
// `additional` bytes already accounted for, inserting alignment padding.
static void fb_prep(fb_t* b, size_t size, size_t additional)
{
if (b->failed)
return;
if (size > b->minalign)
b->minalign = size;
size_t align_size = (~(fb_offset(b) + additional) + 1) & (size - 1);
if (!fb_ensure(b, align_size + size + additional))
return;
fb_pad(b, align_size);
}
static void fb_place_u8(fb_t* b, uint8_t v)
{
if (!fb_ensure(b, 1))
return;
b->head -= 1;
b->bytes[b->head] = v;
}
static void fb_place_u16(fb_t* b, uint16_t v)
{
if (!fb_ensure(b, 2))
return;
b->head -= 2;
b->bytes[b->head + 0] = (uint8_t)(v & 0xFF);
b->bytes[b->head + 1] = (uint8_t)((v >> 8) & 0xFF);
}
static void fb_place_u32(fb_t* b, uint32_t v)
{
if (!fb_ensure(b, 4))
return;
b->head -= 4;
b->bytes[b->head + 0] = (uint8_t)(v & 0xFF);
b->bytes[b->head + 1] = (uint8_t)((v >> 8) & 0xFF);
b->bytes[b->head + 2] = (uint8_t)((v >> 16) & 0xFF);
b->bytes[b->head + 3] = (uint8_t)((v >> 24) & 0xFF);
}
static void fb_write_u32_at(fb_t* b, size_t offset_from_end, uint32_t v)
{
size_t idx = b->cap - offset_from_end;
b->bytes[idx + 0] = (uint8_t)(v & 0xFF);
b->bytes[idx + 1] = (uint8_t)((v >> 8) & 0xFF);
b->bytes[idx + 2] = (uint8_t)((v >> 16) & 0xFF);
b->bytes[idx + 3] = (uint8_t)((v >> 24) & 0xFF);
}
// ---------------------------------------------------------------------------
bool fb_init(fb_t* b, size_t initial_capacity)
{
memset(b, 0, sizeof(*b));
if (initial_capacity < 64)
initial_capacity = 64;
b->bytes = malloc(initial_capacity);
if (!b->bytes) {
b->failed = true;
return false;
}
b->cap = initial_capacity;
b->head = initial_capacity;
b->minalign = 1;
return true;
}
void fb_free(fb_t* b)
{
free(b->bytes);
memset(b, 0, sizeof(*b));
}
bool fb_ok(const fb_t* b) { return !b->failed; }
const uint8_t* fb_data(const fb_t* b, size_t* len)
{
if (b->failed) {
if (len)
*len = 0;
return NULL;
}
if (len)
*len = b->cap - b->head;
return b->bytes + b->head;
}
uint32_t fb_create_uint8_vector(fb_t* b, const uint8_t* data, size_t count)
{
// Element alignment is 1, so only the uint32 length needs alignment.
fb_prep(b, 4, count);
if (!fb_ensure(b, count))
return 0;
b->head -= count;
if (count)
memcpy(b->bytes + b->head, data, count);
fb_place_u32(b, (uint32_t)count);
return (uint32_t)fb_offset(b);
}
uint32_t fb_create_string(fb_t* b, const char* s)
{
size_t len = s ? strlen(s) : 0;
fb_prep(b, 4, len + 1);
if (!fb_ensure(b, len + 1))
return 0;
b->head -= 1;
b->bytes[b->head] = 0; // strings carry a NUL terminator outside the length
b->head -= len;
if (len)
memcpy(b->bytes + b->head, s, len);
fb_place_u32(b, (uint32_t)len);
return (uint32_t)fb_offset(b);
}
void fb_start_table(fb_t* b, int num_fields)
{
if (b->failed)
return;
if (num_fields > FB_MAX_FIELDS) {
b->failed = true;
return;
}
memset(b->vtable, 0, sizeof(b->vtable));
b->vtable_count = num_fields;
b->object_end = fb_offset(b);
b->nested = true;
}
static void fb_slot(fb_t* b, int slot)
{
if (slot < 0 || slot >= b->vtable_count) {
b->failed = true;
return;
}
b->vtable[slot] = (uint16_t)fb_offset(b);
}
void fb_add_offset(fb_t* b, int slot, uint32_t offset)
{
if (b->failed || offset == 0)
return; // 0 means the field is absent
fb_prep(b, 4, 0);
size_t here = fb_offset(b);
if (offset > here) {
b->failed = true;
return;
}
// Offsets are stored relative to their own position.
fb_place_u32(b, (uint32_t)(here - offset + 4));
fb_slot(b, slot);
}
void fb_add_int32(fb_t* b, int slot, int32_t value, int32_t default_value)
{
if (b->failed || value == default_value)
return; // defaults are omitted from the buffer
fb_prep(b, 4, 0);
fb_place_u32(b, (uint32_t)value);
fb_slot(b, slot);
}
void fb_add_uint8(fb_t* b, int slot, uint8_t value, uint8_t default_value)
{
if (b->failed || value == default_value)
return;
fb_prep(b, 1, 0);
fb_place_u8(b, value);
fb_slot(b, slot);
}
uint32_t fb_end_table(fb_t* b)
{
if (b->failed || !b->nested) {
b->failed = true;
return 0;
}
b->nested = false;
// Placeholder for the soffset to the vtable, patched once it is written.
fb_prep(b, 4, 0);
fb_place_u32(b, 0);
size_t object_offset = fb_offset(b);
// Trailing empty slots carry no information and are trimmed.
int count = b->vtable_count;
while (count > 0 && b->vtable[count - 1] == 0)
count--;
for (int i = count - 1; i >= 0; i--) {
uint16_t off = b->vtable[i] ? (uint16_t)(object_offset - b->vtable[i]) : 0;
fb_place_u16(b, off);
}
fb_place_u16(b, (uint16_t)(object_offset - b->object_end)); // inline table size
fb_place_u16(b, (uint16_t)((count + VTABLE_METADATA_FIELDS) * 2)); // vtable size
if (b->failed)
return 0;
fb_write_u32_at(b, object_offset, (uint32_t)(fb_offset(b) - object_offset));
return (uint32_t)object_offset;
}
void fb_finish(fb_t* b, uint32_t root)
{
if (b->failed)
return;
fb_prep(b, b->minalign, 4);
size_t here = fb_offset(b);
if (root > here) {
b->failed = true;
return;
}
fb_place_u32(b, (uint32_t)(here - root + 4));
}
+49
View File
@@ -0,0 +1,49 @@
// A small FlatBuffers builder.
//
// HyperHDR's image input speaks the hyperion.ng FlatBuffers schema. The
// upstream client generates code with flatcc and carries it as a submodule;
// that is a lot of build machinery for four message shapes, so this
// implements the builder algorithm directly. It follows the same back-to-front
// construction as the reference implementations, so buffers it produces are
// byte-comparable with flatc-generated output (minus vtable deduplication,
// which is an optimisation, not a format requirement).
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define FB_MAX_FIELDS 8
typedef struct {
uint8_t* bytes; // full allocation; live data is bytes[head..cap)
size_t cap;
size_t head;
size_t minalign;
uint16_t vtable[FB_MAX_FIELDS];
int vtable_count;
size_t object_end; // fb_offset() captured at fb_start_table
bool nested;
bool failed;
} fb_t;
bool fb_init(fb_t* b, size_t initial_capacity);
void fb_free(fb_t* b);
// Offsets are distances from the end of the buffer, matching the reference
// builders. Zero means "absent".
uint32_t fb_create_uint8_vector(fb_t* b, const uint8_t* data, size_t count);
uint32_t fb_create_string(fb_t* b, const char* s);
void fb_start_table(fb_t* b, int num_fields);
void fb_add_offset(fb_t* b, int slot, uint32_t offset);
void fb_add_int32(fb_t* b, int slot, int32_t value, int32_t default_value);
void fb_add_uint8(fb_t* b, int slot, uint8_t value, uint8_t default_value);
uint32_t fb_end_table(fb_t* b);
void fb_finish(fb_t* b, uint32_t root);
// Valid until the next mutation or fb_free.
const uint8_t* fb_data(const fb_t* b, size_t* len);
bool fb_ok(const fb_t* b);
+515
View File
@@ -0,0 +1,515 @@
#include "hyperion.h"
#include "../common/log.h"
#include "flatbuf.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/uio.h>
#include <time.h>
#include <unistd.h>
// macOS has no MSG_NOSIGNAL; it uses the SO_NOSIGPIPE socket option instead.
// Only relevant when building the host-side unit tests.
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
// hyperionnet.Command union tags, in schema declaration order.
#define CMD_COLOR 1
#define CMD_IMAGE 2
#define CMD_CLEAR 3
#define CMD_REGISTER 4
// hyperionnet.ImageType union tags.
#define IMGTYPE_RAW 1
#define CONNECT_TIMEOUT_MS 3000
// Upper bound on how long a single send may spend waiting for socket buffer
// space. This runs on the capture thread, so a stalled link has to become a
// dropped connection rather than a dropped audio block.
#define SEND_BUDGET_MS 200
#define REPLY_MAX 4096
struct hyperion_client {
int fd;
int priority;
bool connected; // TCP handshake finished and Register sent
bool registered; // HyperHDR confirmed our priority
char origin[64];
char error[192];
struct timespec started;
unsigned char rx[REPLY_MAX];
size_t rx_len;
};
static long elapsed_ms(const struct timespec* since)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (now.tv_sec - since->tv_sec) * 1000L + (now.tv_nsec - since->tv_nsec) / 1000000L;
}
// ---------------------------------------------------------------------------
// Message construction
// ---------------------------------------------------------------------------
// Prefixes `payload` with its big-endian length, as the Hyperion framing
// requires, and returns a single malloc'd buffer.
static uint8_t* frame(const uint8_t* payload, size_t payload_len, size_t* out_len)
{
uint8_t* buf = malloc(payload_len + 4);
if (!buf)
return NULL;
buf[0] = (uint8_t)((payload_len >> 24) & 0xFF);
buf[1] = (uint8_t)((payload_len >> 16) & 0xFF);
buf[2] = (uint8_t)((payload_len >> 8) & 0xFF);
buf[3] = (uint8_t)(payload_len & 0xFF);
memcpy(buf + 4, payload, payload_len);
*out_len = payload_len + 4;
return buf;
}
uint8_t* hyperion_build_register(const char* origin, int priority, size_t* len)
{
fb_t b;
if (!fb_init(&b, 256))
return NULL;
uint32_t origin_off = fb_create_string(&b, origin);
// table Register { origin:string (required); priority:int; }
fb_start_table(&b, 2);
fb_add_offset(&b, 0, origin_off);
fb_add_int32(&b, 1, priority, 0);
uint32_t reg = fb_end_table(&b);
// table Request { command:Command (required); }
// A union occupies two slots: the type byte then the value offset.
fb_start_table(&b, 2);
fb_add_offset(&b, 1, reg);
fb_add_uint8(&b, 0, CMD_REGISTER, 0);
uint32_t req = fb_end_table(&b);
fb_finish(&b, req);
size_t payload_len = 0;
const uint8_t* payload = fb_data(&b, &payload_len);
uint8_t* out = payload ? frame(payload, payload_len, len) : NULL;
fb_free(&b);
return out;
}
uint8_t* hyperion_build_image(const uint8_t* rgb, int width, int height, size_t* len)
{
size_t pixels = (size_t)width * (size_t)height * 3;
fb_t b;
if (!fb_init(&b, pixels + 256))
return NULL;
uint32_t data_off = fb_create_uint8_vector(&b, rgb, pixels);
// table RawImage { data:[ubyte]; width:int = -1; height:int = -1; }
fb_start_table(&b, 3);
fb_add_offset(&b, 0, data_off);
fb_add_int32(&b, 1, width, -1);
fb_add_int32(&b, 2, height, -1);
uint32_t raw = fb_end_table(&b);
// table Image { data:ImageType (required); duration:int = -1; }
fb_start_table(&b, 3);
fb_add_offset(&b, 1, raw);
fb_add_int32(&b, 2, -1, -1); // duration: keep the default (no timeout)
fb_add_uint8(&b, 0, IMGTYPE_RAW, 0);
uint32_t img = fb_end_table(&b);
fb_start_table(&b, 2);
fb_add_offset(&b, 1, img);
fb_add_uint8(&b, 0, CMD_IMAGE, 0);
uint32_t req = fb_end_table(&b);
fb_finish(&b, req);
size_t payload_len = 0;
const uint8_t* payload = fb_data(&b, &payload_len);
uint8_t* out = payload ? frame(payload, payload_len, len) : NULL;
fb_free(&b);
return out;
}
static uint8_t* build_clear(int priority, size_t* len)
{
fb_t b;
if (!fb_init(&b, 128))
return NULL;
// table Clear { priority:int; }
fb_start_table(&b, 1);
fb_add_int32(&b, 0, priority, 0);
uint32_t clear = fb_end_table(&b);
fb_start_table(&b, 2);
fb_add_offset(&b, 1, clear);
fb_add_uint8(&b, 0, CMD_CLEAR, 0);
uint32_t req = fb_end_table(&b);
fb_finish(&b, req);
size_t payload_len = 0;
const uint8_t* payload = fb_data(&b, &payload_len);
uint8_t* out = payload ? frame(payload, payload_len, len) : NULL;
fb_free(&b);
return out;
}
// ---------------------------------------------------------------------------
// Minimal FlatBuffers reader for hyperionnet.Reply
//
// table Reply { error:string; video:int = -1; registered:int = -1; }
// ---------------------------------------------------------------------------
static uint32_t rd_u32(const uint8_t* p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static uint16_t rd_u16(const uint8_t* p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
static int32_t rd_i32(const uint8_t* p) { return (int32_t)rd_u32(p); }
// Returns the byte offset of field `slot` within `buf`, or 0 if absent.
static size_t reply_field(const uint8_t* buf, size_t len, int slot)
{
if (len < 8)
return 0;
size_t table = rd_u32(buf);
if (table + 4 > len)
return 0;
int32_t soffset = rd_i32(buf + table);
// The vtable sits before the table for buffers built back-to-front.
if (soffset <= 0 || (size_t)soffset > table)
return 0;
size_t vtable = table - (size_t)soffset;
if (vtable + 4 > len)
return 0;
uint16_t vtable_size = rd_u16(buf + vtable);
size_t field_index = 4 + (size_t)slot * 2;
if (field_index + 2 > vtable_size || vtable + field_index + 2 > len)
return 0;
uint16_t field_off = rd_u16(buf + vtable + field_index);
if (field_off == 0)
return 0;
size_t pos = table + field_off;
return pos < len ? pos : 0;
}
static void parse_reply(hyperion_client_t* c, const uint8_t* buf, size_t len)
{
size_t err_pos = reply_field(buf, len, 0);
if (err_pos && err_pos + 4 <= len) {
size_t str_at = err_pos + rd_u32(buf + err_pos);
if (str_at + 4 <= len) {
uint32_t slen = rd_u32(buf + str_at);
if (str_at + 4 + slen <= len && slen > 0) {
snprintf(c->error, sizeof(c->error), "%.*s", (int)slen, buf + str_at + 4);
WARN("HyperHDR replied with error: %s", c->error);
return;
}
}
}
size_t reg_pos = reply_field(buf, len, 2);
if (reg_pos && reg_pos + 4 <= len) {
int32_t registered = rd_i32(buf + reg_pos);
if (registered == c->priority) {
if (!c->registered)
INFO("HyperHDR accepted registration at priority %d", registered);
c->registered = true;
c->error[0] = '\0';
}
}
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
static bool write_all(int fd, const uint8_t* buf, size_t len)
{
struct timespec start;
clock_gettime(CLOCK_MONOTONIC, &start);
size_t off = 0;
while (off < len) {
ssize_t n = send(fd, buf + off, len - off, MSG_NOSIGNAL);
if (n > 0) {
off += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR)
continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
long left = SEND_BUDGET_MS - elapsed_ms(&start);
if (left <= 0)
return false;
struct pollfd pfd = { .fd = fd, .events = POLLOUT };
if (poll(&pfd, 1, (int)left) > 0)
continue;
}
return false;
}
return true;
}
static bool send_framed(hyperion_client_t* c, uint8_t* framed, size_t len)
{
if (!framed) {
snprintf(c->error, sizeof(c->error), "failed to build message");
return false;
}
bool ok = write_all(c->fd, framed, len);
free(framed);
if (!ok)
snprintf(c->error, sizeof(c->error), "send failed: %s", strerror(errno));
return ok;
}
// The TCP handshake is done: disable Nagle and send Register. Returns false
// with c->error set if the registration could not be written.
static bool finish_connect(hyperion_client_t* c)
{
int one = 1;
setsockopt(c->fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
c->connected = true;
size_t len = 0;
uint8_t* msg = hyperion_build_register(c->origin, c->priority, &len);
if (!send_framed(c, msg, len))
return false;
INFO("Connected to HyperHDR, registering as '%s' priority %d", c->origin, c->priority);
return true;
}
bool hyperion_resolve(const char* host, int port, hyperion_target_t* out, char* err, size_t errlen)
{
memset(out, 0, sizeof(*out));
snprintf(out->host, sizeof(out->host), "%s", host ? host : "");
out->port = port;
out->addr.sin_family = AF_INET;
out->addr.sin_port = htons((uint16_t)port);
// An IP literal needs no resolver, which is the overwhelmingly common case
// here and keeps the whole path free of DNS.
if (host && inet_pton(AF_INET, host, &out->addr.sin_addr) == 1)
return true;
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo* res = NULL;
int rc = getaddrinfo(host, portstr, &hints, &res);
if (rc != 0 || !res) {
snprintf(err, errlen, "cannot resolve '%s': %s", host ? host : "(null)", gai_strerror(rc));
return false;
}
memcpy(&out->addr, res->ai_addr, sizeof(struct sockaddr_in));
freeaddrinfo(res);
return true;
}
hyperion_client_t* hyperion_connect(const hyperion_target_t* target, const char* origin,
int priority, char* err, size_t errlen)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
return NULL;
}
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
int rc = connect(fd, (const struct sockaddr*)&target->addr, sizeof(target->addr));
if (rc != 0 && errno != EINPROGRESS) {
snprintf(err, errlen, "connect to %s:%d: %s", target->host, target->port, strerror(errno));
close(fd);
return NULL;
}
hyperion_client_t* c = calloc(1, sizeof(*c));
if (!c) {
close(fd);
snprintf(err, errlen, "out of memory");
return NULL;
}
c->fd = fd;
c->priority = priority;
snprintf(c->origin, sizeof(c->origin), "%s", origin ? origin : "lgtv-audio-cap");
clock_gettime(CLOCK_MONOTONIC, &c->started);
// Connected already (loopback, or the host answered inside the syscall):
// finish the handshake now so the first frame is not delayed a whole pump.
if (rc == 0)
finish_connect(c);
return c;
}
void hyperion_disconnect(hyperion_client_t* c)
{
if (!c)
return;
if (c->fd >= 0) {
if (c->registered) {
size_t len = 0;
uint8_t* msg = build_clear(c->priority, &len);
if (msg) {
// Best effort: tell HyperHDR to release our priority so the
// LEDs fall back to whatever was underneath instead of
// freezing on the last frame we sent.
write_all(c->fd, msg, len);
free(msg);
}
}
close(c->fd);
}
free(c);
}
// Completes a connect that was still in flight. Returns false if it failed or
// ran out of time.
static bool pump_connect(hyperion_client_t* c)
{
struct pollfd pfd = { .fd = c->fd, .events = POLLOUT };
int pr = poll(&pfd, 1, 0);
if (pr < 0)
return errno == EINTR;
if (pr == 0) {
if (elapsed_ms(&c->started) > CONNECT_TIMEOUT_MS) {
snprintf(c->error, sizeof(c->error), "connect timed out");
return false;
}
return true; // still in progress; try again next block
}
int soerr = 0;
socklen_t slen = sizeof(soerr);
if (getsockopt(c->fd, SOL_SOCKET, SO_ERROR, &soerr, &slen) != 0)
soerr = errno;
if (soerr != 0) {
snprintf(c->error, sizeof(c->error), "connect: %s", strerror(soerr));
return false;
}
return finish_connect(c);
}
bool hyperion_pump(hyperion_client_t* c)
{
if (!c || c->fd < 0)
return false;
if (!c->connected) {
if (!pump_connect(c))
return false;
if (!c->connected)
return true; // handshake still pending, nothing to read yet
}
for (;;) {
struct pollfd pfd = { .fd = c->fd, .events = POLLIN };
int pr = poll(&pfd, 1, 0);
if (pr <= 0)
return true;
if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) {
snprintf(c->error, sizeof(c->error), "connection closed by HyperHDR");
return false;
}
ssize_t n = recv(c->fd, c->rx + c->rx_len, sizeof(c->rx) - c->rx_len, 0);
if (n == 0) {
snprintf(c->error, sizeof(c->error), "connection closed by HyperHDR");
return false;
}
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
return true;
snprintf(c->error, sizeof(c->error), "recv failed: %s", strerror(errno));
return false;
}
c->rx_len += (size_t)n;
// Replies are length-prefixed the same way requests are.
while (c->rx_len >= 4) {
size_t msg_len = ((size_t)c->rx[0] << 24) | ((size_t)c->rx[1] << 16)
| ((size_t)c->rx[2] << 8) | (size_t)c->rx[3];
if (msg_len > sizeof(c->rx) - 4) {
snprintf(c->error, sizeof(c->error), "reply of %zu bytes exceeds buffer", msg_len);
return false;
}
if (c->rx_len < msg_len + 4)
break;
parse_reply(c, c->rx + 4, msg_len);
size_t consumed = msg_len + 4;
memmove(c->rx, c->rx + consumed, c->rx_len - consumed);
c->rx_len -= consumed;
}
}
}
bool hyperion_connected(const hyperion_client_t* c) { return c && c->connected; }
bool hyperion_registered(const hyperion_client_t* c) { return c && c->registered; }
const char* hyperion_last_error(const hyperion_client_t* c)
{
return (c && c->error[0]) ? c->error : NULL;
}
bool hyperion_send_image(hyperion_client_t* c, const uint8_t* rgb, int width, int height)
{
if (!c || c->fd < 0)
return false;
if (!c->registered)
return true; // still waiting on the registration reply
size_t len = 0;
uint8_t* msg = hyperion_build_image(rgb, width, height, &len);
return send_framed(c, msg, len);
}
bool hyperion_send_clear(hyperion_client_t* c)
{
if (!c || c->fd < 0 || !c->connected)
return false;
size_t len = 0;
uint8_t* msg = build_clear(c->priority, &len);
return send_framed(c, msg, len);
}
+49
View File
@@ -0,0 +1,49 @@
// FlatBuffers client for HyperHDR / hyperion.ng image input (TCP 19400).
//
// Used by the on-TV visualiser sink: the TV runs the FFT itself and pushes a
// rendered image, so HyperHDR drives the LEDs without needing any audio
// device at all. That is the zero-host-setup path.
#pragma once
#include <netinet/in.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct hyperion_client hyperion_client_t;
// Address resolution is separated from connecting because it is the one step
// that can block for seconds (DNS), and the caller runs on the audio thread.
// Resolve once when the sink opens, then reconnect as often as needed.
typedef struct {
struct sockaddr_in addr;
char host[128];
int port;
} hyperion_target_t;
bool hyperion_resolve(const char* host, int port, hyperion_target_t* out, char* err, size_t errlen);
// Starts a non-blocking connect and returns immediately: the socket is
// probably still connecting, and Register has not been sent yet. Everything
// after this point is driven by hyperion_pump(), so no call here ever waits
// on the network. Returns NULL only if the socket could not be created.
hyperion_client_t* hyperion_connect(const hyperion_target_t* target, const char* origin,
int priority, char* err, size_t errlen);
void hyperion_disconnect(hyperion_client_t* c);
// Advances the handshake and drains pending replies. Call regularly; this is
// what completes the connect, sends Register and flips the client into the
// registered state. Returns false once the connection is dead or timed out.
bool hyperion_pump(hyperion_client_t* c);
bool hyperion_connected(const hyperion_client_t* c);
bool hyperion_registered(const hyperion_client_t* c);
const char* hyperion_last_error(const hyperion_client_t* c);
// `rgb` holds width*height*3 bytes. No-op (returns true) until registered.
bool hyperion_send_image(hyperion_client_t* c, const uint8_t* rgb, int width, int height);
bool hyperion_send_clear(hyperion_client_t* c);
// Exposed for tests: builds the wire bytes without needing a socket.
// Caller frees via free(). Includes the 4-byte big-endian length prefix.
uint8_t* hyperion_build_register(const char* origin, int priority, size_t* len);
uint8_t* hyperion_build_image(const uint8_t* rgb, int width, int height, size_t* len);
+485
View File
@@ -0,0 +1,485 @@
#include "streamserv.h"
#include "../common/log.h"
#include "../common/ringbuf.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
#define MAX_CLIENTS_HARD 16
#define REQUEST_MAX 2048
typedef struct {
int fd;
bool in_use;
bool greeted; // greeting fully flushed; PCM may now flow
char peer[64];
// Pending greeting bytes (HTTP headers or WAV header) not yet written.
char* pending;
size_t pending_len;
size_t pending_off;
// Tail of a PCM chunk the socket would not accept in full. Held here so
// the stream stays byte-ordered across a partial send.
unsigned char carry[8192];
size_t carry_len;
size_t carry_off;
// HTTP mode: accumulates the request line before the greeting is built.
char request[REQUEST_MAX];
size_t request_len;
ringbuf_t out;
} client_t;
struct streamserv {
streamserv_config_t cfg;
int listen_fd;
int wake_fd[2]; // self-pipe so stop() interrupts poll() promptly
client_t clients[MAX_CLIENTS_HARD];
pthread_mutex_t lock;
pthread_t thread;
bool running;
int client_count;
unsigned long long dropped;
};
static void set_nonblock(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags >= 0)
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// Caller must hold s->lock.
static void drop_client(streamserv_t* s, client_t* c, const char* why)
{
if (!c->in_use)
return;
INFO("streamserv: client %s disconnected (%s)", c->peer, why);
close(c->fd);
c->fd = -1;
c->in_use = false;
c->greeted = false;
c->request_len = 0;
free(c->pending);
c->pending = NULL;
c->pending_len = c->pending_off = 0;
ringbuf_destroy(&c->out);
s->client_count--;
}
// Sends as much of buf[off..len) as the socket accepts.
// Returns 1 if fully sent, 0 if the socket is full, -1 on a fatal error.
static int send_all(int fd, const unsigned char* buf, size_t len, size_t* off)
{
while (*off < len) {
ssize_t n = send(fd, buf + *off, len - *off, MSG_NOSIGNAL);
if (n > 0) {
*off += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR)
continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
return 0;
return -1;
}
return 1;
}
// Caller must hold s->lock. Returns false if the client should be dropped.
static bool flush_client(streamserv_t* s, client_t* c)
{
(void)s;
// 1. Greeting first: HTTP headers or the WAV header must land intact
// before any PCM, so nothing else is sent until this drains.
if (c->pending) {
int rc = send_all(c->fd, (const unsigned char*)c->pending, c->pending_len,
&c->pending_off);
if (rc < 0)
return false;
if (rc == 0)
return true;
free(c->pending);
c->pending = NULL;
c->pending_len = c->pending_off = 0;
c->greeted = true;
}
if (!c->greeted)
return true;
// 2. Anything left over from a previous partial send.
if (c->carry_off < c->carry_len) {
int rc = send_all(c->fd, c->carry, c->carry_len, &c->carry_off);
if (rc < 0)
return false;
if (rc == 0)
return true;
}
c->carry_len = c->carry_off = 0;
// 3. Fresh audio, one carry-sized chunk at a time.
for (;;) {
size_t n = ringbuf_read(&c->out, c->carry, sizeof(c->carry), 0);
if (n == 0)
return true;
c->carry_len = n;
c->carry_off = 0;
int rc = send_all(c->fd, c->carry, c->carry_len, &c->carry_off);
if (rc < 0)
return false;
if (rc == 0)
return true; // retry the remainder on the next writable event
c->carry_len = c->carry_off = 0;
}
}
static void accept_client(streamserv_t* s)
{
struct sockaddr_in addr;
socklen_t len = sizeof(addr);
int fd = accept(s->listen_fd, (struct sockaddr*)&addr, &len);
if (fd < 0)
return;
set_nonblock(fd);
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
pthread_mutex_lock(&s->lock);
int max = s->cfg.max_clients > 0 && s->cfg.max_clients < MAX_CLIENTS_HARD
? s->cfg.max_clients
: MAX_CLIENTS_HARD;
client_t* slot = NULL;
for (int i = 0; i < max; i++) {
if (!s->clients[i].in_use) {
slot = &s->clients[i];
break;
}
}
if (!slot) {
pthread_mutex_unlock(&s->lock);
WARN("streamserv: refusing connection, %d client slots all in use", max);
close(fd);
return;
}
memset(slot, 0, sizeof(*slot));
if (!ringbuf_init(&slot->out, s->cfg.client_buffer)) {
pthread_mutex_unlock(&s->lock);
close(fd);
return;
}
slot->fd = fd;
slot->in_use = true;
snprintf(slot->peer, sizeof(slot->peer), "%s:%d", inet_ntoa(addr.sin_addr),
ntohs(addr.sin_port));
s->client_count++;
// In raw mode there is nothing to negotiate, so greet immediately.
if (!s->cfg.http_mode) {
char* out = NULL;
size_t out_len = 0;
if (s->cfg.hello && !s->cfg.hello(s->cfg.user, NULL, &out, &out_len)) {
drop_client(s, slot, "rejected by hello");
pthread_mutex_unlock(&s->lock);
return;
}
slot->pending = out;
slot->pending_len = out_len;
if (!out)
slot->greeted = true;
}
INFO("streamserv: client %s connected", slot->peer);
pthread_mutex_unlock(&s->lock);
}
// Caller must hold s->lock. Returns false if the client should be dropped.
static bool read_request(streamserv_t* s, client_t* c)
{
char buf[512];
ssize_t n = recv(c->fd, buf, sizeof(buf), 0);
if (n == 0)
return false;
if (n < 0)
return errno == EAGAIN || errno == EWOULDBLOCK;
if (c->request_len + (size_t)n >= sizeof(c->request))
return false; // absurd request, drop it
memcpy(c->request + c->request_len, buf, (size_t)n);
c->request_len += (size_t)n;
c->request[c->request_len] = '\0';
// Wait for the end of the HTTP header block.
if (!strstr(c->request, "\r\n\r\n") && !strstr(c->request, "\n\n"))
return true;
char* out = NULL;
size_t out_len = 0;
if (s->cfg.hello && !s->cfg.hello(s->cfg.user, c->request, &out, &out_len))
return false;
c->pending = out;
c->pending_len = out_len;
c->pending_off = 0;
if (!out)
c->greeted = true;
return true;
}
static void* serve_loop(void* arg)
{
streamserv_t* s = arg;
while (s->running) {
struct pollfd pfds[MAX_CLIENTS_HARD + 2];
client_t* mapped[MAX_CLIENTS_HARD + 2];
int n = 0;
pfds[n].fd = s->listen_fd;
pfds[n].events = POLLIN;
mapped[n] = NULL;
n++;
pfds[n].fd = s->wake_fd[0];
pfds[n].events = POLLIN;
mapped[n] = NULL;
n++;
pthread_mutex_lock(&s->lock);
for (int i = 0; i < MAX_CLIENTS_HARD; i++) {
client_t* c = &s->clients[i];
if (!c->in_use)
continue;
short events = 0;
if (s->cfg.http_mode && !c->pending && !c->greeted)
events |= POLLIN;
if (c->pending || c->carry_off < c->carry_len
|| (c->greeted && ringbuf_used(&c->out) > 0))
events |= POLLOUT;
// Always watch for hangup even when idle.
pfds[n].fd = c->fd;
pfds[n].events = events;
mapped[n] = c;
n++;
}
pthread_mutex_unlock(&s->lock);
// 20 ms keeps outbound audio moving even when no event fires.
int pr = poll(pfds, (nfds_t)n, 20);
if (pr < 0 && errno != EINTR) {
ERR("streamserv: poll failed: %s", strerror(errno));
break;
}
if (!s->running)
break;
if (pfds[0].revents & POLLIN)
accept_client(s);
if (pfds[1].revents & POLLIN) {
char drain[64];
while (read(s->wake_fd[0], drain, sizeof(drain)) > 0) { }
}
pthread_mutex_lock(&s->lock);
for (int i = 2; i < n; i++) {
client_t* c = mapped[i];
if (!c || !c->in_use)
continue;
if (pfds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
drop_client(s, c, "socket error or hangup");
continue;
}
if ((pfds[i].revents & POLLIN) && !c->greeted && !c->pending) {
if (!read_request(s, c)) {
drop_client(s, c, "bad or closed request");
continue;
}
}
if (!flush_client(s, c)) {
drop_client(s, c, "write failed");
continue;
}
}
pthread_mutex_unlock(&s->lock);
}
pthread_mutex_lock(&s->lock);
for (int i = 0; i < MAX_CLIENTS_HARD; i++)
drop_client(s, &s->clients[i], "server stopping");
pthread_mutex_unlock(&s->lock);
return NULL;
}
streamserv_t* streamserv_start(const streamserv_config_t* cfg, char* err, size_t errlen)
{
streamserv_t* s = calloc(1, sizeof(*s));
if (!s) {
snprintf(err, errlen, "out of memory");
return NULL;
}
s->cfg = *cfg;
if (s->cfg.client_buffer == 0)
s->cfg.client_buffer = 256 * 1024;
if (s->cfg.max_clients <= 0)
s->cfg.max_clients = 4;
s->listen_fd = -1;
s->wake_fd[0] = s->wake_fd[1] = -1;
pthread_mutex_init(&s->lock, NULL);
s->listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (s->listen_fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
goto fail;
}
int one = 1;
setsockopt(s->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)cfg->port);
addr.sin_addr.s_addr = (cfg->bind_addr && *cfg->bind_addr)
? inet_addr(cfg->bind_addr)
: htonl(INADDR_ANY);
if (bind(s->listen_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
snprintf(err, errlen, "bind(port %d): %s", cfg->port, strerror(errno));
goto fail;
}
if (listen(s->listen_fd, 4) != 0) {
snprintf(err, errlen, "listen(): %s", strerror(errno));
goto fail;
}
set_nonblock(s->listen_fd);
if (pipe(s->wake_fd) != 0) {
snprintf(err, errlen, "pipe(): %s", strerror(errno));
goto fail;
}
set_nonblock(s->wake_fd[0]);
set_nonblock(s->wake_fd[1]);
s->running = true;
if (pthread_create(&s->thread, NULL, serve_loop, s) != 0) {
snprintf(err, errlen, "pthread_create(): %s", strerror(errno));
s->running = false;
goto fail;
}
INFO("streamserv: listening on port %d (%s)", cfg->port, cfg->http_mode ? "http" : "raw");
return s;
fail:
if (s->listen_fd >= 0)
close(s->listen_fd);
if (s->wake_fd[0] >= 0)
close(s->wake_fd[0]);
if (s->wake_fd[1] >= 0)
close(s->wake_fd[1]);
pthread_mutex_destroy(&s->lock);
free(s);
return NULL;
}
void streamserv_stop(streamserv_t* s)
{
if (!s)
return;
if (s->running) {
s->running = false;
if (s->wake_fd[1] >= 0) {
char b = 1;
ssize_t ignored = write(s->wake_fd[1], &b, 1);
(void)ignored;
}
pthread_join(s->thread, NULL);
}
if (s->listen_fd >= 0)
close(s->listen_fd);
if (s->wake_fd[0] >= 0)
close(s->wake_fd[0]);
if (s->wake_fd[1] >= 0)
close(s->wake_fd[1]);
pthread_mutex_destroy(&s->lock);
free(s);
}
void streamserv_broadcast(streamserv_t* s, const void* data, size_t len)
{
if (!s || len == 0)
return;
pthread_mutex_lock(&s->lock);
for (int i = 0; i < MAX_CLIENTS_HARD; i++) {
client_t* c = &s->clients[i];
// Queue only once the greeting is out, or the stream would interleave
// with the header the client is still reading.
if (!c->in_use || !c->greeted)
continue;
s->dropped += ringbuf_write(&c->out, data, len);
}
bool any = s->client_count > 0;
pthread_mutex_unlock(&s->lock);
if (any && s->wake_fd[1] >= 0) {
char b = 1;
ssize_t ignored = write(s->wake_fd[1], &b, 1);
(void)ignored;
}
}
int streamserv_client_count(streamserv_t* s)
{
if (!s)
return 0;
pthread_mutex_lock(&s->lock);
int n = s->client_count;
pthread_mutex_unlock(&s->lock);
return n;
}
unsigned long long streamserv_dropped_bytes(streamserv_t* s)
{
if (!s)
return 0;
pthread_mutex_lock(&s->lock);
unsigned long long n = s->dropped;
pthread_mutex_unlock(&s->lock);
return n;
}
+37
View File
@@ -0,0 +1,37 @@
// A tiny broadcast TCP server.
//
// Backs both the raw-PCM and HTTP-WAV sinks: accept clients, optionally read
// and answer a request line, then push the same live byte stream to everyone
// connected. Each client gets its own ring buffer, so one slow reader drops
// its own audio instead of stalling the capture thread.
#pragma once
#include <stdbool.h>
#include <stddef.h>
typedef struct streamserv streamserv_t;
// Builds the greeting sent to a newly accepted client. `request` is the first
// line the client sent (HTTP mode only, otherwise NULL). Return false to
// reject the connection. On success, set `*out`/`*out_len` to a malloc'd
// buffer the server will send and then free.
typedef bool (*streamserv_hello_fn)(void* user, const char* request, char** out, size_t* out_len);
typedef struct {
int port;
const char* bind_addr; // NULL for 0.0.0.0
size_t client_buffer; // bytes of backlog tolerated per client
int max_clients;
bool http_mode; // wait for a request line before greeting
void* user;
streamserv_hello_fn hello;
} streamserv_config_t;
streamserv_t* streamserv_start(const streamserv_config_t* cfg, char* err, size_t errlen);
void streamserv_stop(streamserv_t* s);
// Non-blocking: queues `len` bytes for every connected client.
void streamserv_broadcast(streamserv_t* s, const void* data, size_t len);
int streamserv_client_count(streamserv_t* s);
unsigned long long streamserv_dropped_bytes(streamserv_t* s);
+489
View File
@@ -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);
}
+23
View File
@@ -0,0 +1,23 @@
// The Luna service surface.
//
// Everything the frontend can do goes through these methods on
// luna://org.webosbrew.audiocap.service. Status is a subscription, so the UI
// gets level meters and sink state pushed at ~10 Hz without polling.
#pragma once
#include <glib.h>
#include <luna-service2/lunaservice.h>
#include <stdbool.h>
#include <stddef.h>
typedef struct service service_t;
service_t* service_create(LSHandle* handle, GMainLoop* loop);
void service_destroy(service_t* s);
// Attaches the method table to the handle.
bool service_register(service_t* s, char* err, size_t errlen);
// Starts capture immediately when the saved settings ask for it. Called once
// after registration.
void service_autostart(service_t* s);
+53
View File
@@ -0,0 +1,53 @@
#include "sink.h"
#include "../common/log.h"
#include <stdio.h>
#include <string.h>
static const sink_driver_t* const s_drivers[] = {
&sink_driver_hyperhdr,
&sink_driver_hyperhdr_viz,
&sink_driver_udp,
&sink_driver_tcp,
&sink_driver_http,
};
const sink_driver_t* const* sink_drivers(size_t* count)
{
*count = sizeof(s_drivers) / sizeof(s_drivers[0]);
return s_drivers;
}
const sink_driver_t* sink_find(const char* id)
{
if (!id)
return NULL;
for (size_t i = 0; i < sizeof(s_drivers) / sizeof(s_drivers[0]); i++) {
if (strcmp(s_drivers[i]->id, id) == 0)
return s_drivers[i];
}
return NULL;
}
sink_t* sink_open(const char* id, const json_value_t* cfg, const audio_format_t* fmt,
char* err, size_t errlen)
{
const sink_driver_t* drv = sink_find(id);
if (!drv) {
snprintf(err, errlen, "unknown sink '%s'", id ? id : "(null)");
return NULL;
}
sink_t* s = drv->open(cfg, fmt, err, errlen);
if (s)
INFO("Sink '%s' started", drv->id);
return s;
}
void sink_close(sink_t* s)
{
if (!s)
return;
const char* id = s->driver ? s->driver->id : "?";
s->close(s);
INFO("Sink '%s' stopped", id);
}
+51
View File
@@ -0,0 +1,51 @@
// Output sink abstraction.
//
// Every enabled sink receives the same captured block from the engine thread,
// so several transports can run at once. Sinks must never block: anything
// that can stall (a TCP client, a dead HyperHDR host) buffers internally and
// drops old audio rather than holding up capture.
#pragma once
#include "../common/audio.h"
#include "../common/json.h"
#include "../dsp.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct sink sink_t;
typedef struct {
const char* id;
const char* name;
const char* description;
// `cfg` is the whole settings object; each sink reads the keys it owns.
sink_t* (*open)(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen);
} sink_driver_t;
struct sink {
const sink_driver_t* driver;
void* priv;
audio_format_t fmt;
void (*write)(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels);
// Appends sink-specific fields to an object the caller has already opened.
void (*status)(sink_t* s, json_writer_t* w);
void (*close)(sink_t* s);
};
// The drivers themselves, declared here so both the registry and each driver's
// 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_udp;
extern const sink_driver_t sink_driver_tcp;
extern const sink_driver_t sink_driver_http;
const sink_driver_t* sink_find(const char* id);
const sink_driver_t* const* sink_drivers(size_t* count);
sink_t* sink_open(const char* id, const json_value_t* cfg, const audio_format_t* fmt,
char* err, size_t errlen);
void sink_close(sink_t* s);
+219
View File
@@ -0,0 +1,219 @@
// HTTP audio stream served by the TV.
//
// The friendliest sink to test with, because everything already speaks HTTP:
//
// vlc http://<tv-ip>:4012/audio.wav
// ffplay http://<tv-ip>:4012/audio.wav
// mpv http://<tv-ip>:4012/audio.wav
//
// The WAV header declares an unknown length (0xFFFFFFFF sizes), which is the
// usual convention for endless streams and what every player above expects.
// Request /audio.raw instead to get headerless S16LE, for the odd consumer
// that would rather be told the format out of band.
#include "sink.h"
#include "../common/log.h"
#include "../net/streamserv.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define WAV_HEADER_BYTES 44
#define WAV_UNKNOWN_SIZE 0xFFFFFFFFu
typedef struct {
streamserv_t* server;
int port;
audio_format_t fmt;
} http_priv_t;
static void put_u32le(uint8_t* p, uint32_t v)
{
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)((v >> 8) & 0xFF);
p[2] = (uint8_t)((v >> 16) & 0xFF);
p[3] = (uint8_t)((v >> 24) & 0xFF);
}
static void put_u16le(uint8_t* p, uint16_t v)
{
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)((v >> 8) & 0xFF);
}
static void write_wav_header(uint8_t* h, const audio_format_t* fmt)
{
const uint16_t bits = 16;
const uint16_t channels = (uint16_t)fmt->channels;
const uint32_t rate = (uint32_t)fmt->rate;
const uint16_t block_align = (uint16_t)(channels * (bits / 8));
memcpy(h + 0, "RIFF", 4);
put_u32le(h + 4, WAV_UNKNOWN_SIZE);
memcpy(h + 8, "WAVE", 4);
memcpy(h + 12, "fmt ", 4);
put_u32le(h + 16, 16); // PCM fmt chunk length
put_u16le(h + 20, 1); // WAVE_FORMAT_PCM
put_u16le(h + 22, channels);
put_u32le(h + 24, rate);
put_u32le(h + 28, rate * block_align); // byte rate
put_u16le(h + 32, block_align);
put_u16le(h + 34, bits);
memcpy(h + 36, "data", 4);
put_u32le(h + 40, WAV_UNKNOWN_SIZE);
}
// Extracts the path from "GET /audio.wav HTTP/1.1". Returns false for anything
// that is not a GET, so the server drops the connection.
static bool parse_request(const char* request, char* path, size_t pathlen)
{
if (!request)
return false;
if (strncmp(request, "GET ", 4) != 0)
return false;
const char* p = request + 4;
while (*p == ' ')
p++;
size_t n = 0;
while (p[n] && p[n] != ' ' && p[n] != '\r' && p[n] != '\n' && n < pathlen - 1)
n++;
memcpy(path, p, n);
path[n] = '\0';
return n > 0;
}
static bool http_hello(void* user, const char* request, char** out, size_t* out_len)
{
http_priv_t* p = user;
char path[256];
if (!parse_request(request, path, sizeof(path))) {
DBG("HTTP sink: rejecting non-GET request");
return false;
}
// Browsers probe for these; answering them with an audio stream is worse
// than refusing outright.
if (strcmp(path, "/favicon.ico") == 0 || strcmp(path, "/robots.txt") == 0)
return false;
bool raw = strstr(path, ".raw") != NULL || strstr(path, ".pcm") != NULL;
char headers[512];
int hlen = snprintf(headers, sizeof(headers),
"HTTP/1.0 200 OK\r\n"
"Content-Type: %s\r\n"
"Cache-Control: no-cache, no-store\r\n"
"Pragma: no-cache\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Connection: close\r\n"
"\r\n",
raw ? "application/octet-stream" : "audio/wav");
if (hlen < 0 || hlen >= (int)sizeof(headers))
return false;
size_t total = (size_t)hlen + (raw ? 0 : WAV_HEADER_BYTES);
uint8_t* buf = malloc(total);
if (!buf)
return false;
memcpy(buf, headers, (size_t)hlen);
if (!raw)
write_wav_header(buf + hlen, &p->fmt);
INFO("HTTP sink: client requested %s (%s)", path, raw ? "raw S16LE" : "WAV");
*out = (char*)buf;
*out_len = total;
return true;
}
static void http_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
http_priv_t* p = s->priv;
streamserv_broadcast(p->server, pcm, (size_t)frames * (size_t)audio_frame_bytes(&s->fmt));
}
static void http_status(sink_t* s, json_writer_t* w)
{
http_priv_t* p = s->priv;
jw_int(w, "port", p->port);
jw_int(w, "clients", streamserv_client_count(p->server));
jw_int(w, "droppedBytes", (long long)streamserv_dropped_bytes(p->server));
jw_str(w, "wavPath", "/audio.wav");
jw_str(w, "rawPath", "/audio.raw");
}
static void http_close(sink_t* s)
{
http_priv_t* p = s->priv;
if (p) {
streamserv_stop(p->server);
free(p);
}
free(s);
}
static sink_t* http_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "http");
int port = json_int(sc, "port", 4012);
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid HTTP port %d", port);
return NULL;
}
http_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
p->port = port;
p->fmt = *fmt;
// Players buffer ahead; give them a couple of seconds of slack before we
// start dropping.
size_t buffer = (size_t)fmt->rate * (size_t)audio_frame_bytes(fmt) * 2;
streamserv_config_t scfg = {
.port = port,
.client_buffer = buffer,
.max_clients = json_int(sc, "maxClients", 4),
.http_mode = true,
.user = p,
.hello = http_hello,
};
p->server = streamserv_start(&scfg, err, errlen);
if (!p->server) {
free(p);
free(s);
return NULL;
}
s->driver = &sink_driver_http;
s->priv = p;
s->fmt = *fmt;
s->write = http_write;
s->status = http_status;
s->close = http_close;
INFO("HTTP sink: http://<tv-ip>:%d/audio.wav (%d Hz, %d ch)", port, fmt->rate, fmt->channels);
return s;
}
const sink_driver_t sink_driver_http = {
.id = "http",
.name = "HTTP WAV stream",
.description = "Open http://<tv-ip>:4012/audio.wav in VLC, ffplay or mpv. Easiest way to confirm capture works.",
.open = http_open,
};
+374
View File
@@ -0,0 +1,374 @@
// The main HyperHDR path: stream TV audio to the HyperHDR host as RTP/L16.
//
// HyperHDR has no network audio input. Its sound-reactive effects read a
// *local* capture device (a USB grabber's audio, a USB sound card, a virtual
// cable). So the job here is to get TV audio onto the HyperHDR machine in a
// form something can hand to a sound device. Two consumers understand what
// this sink emits:
//
// * host/lgtv-audiocap-receiver.py, which feeds an ALSA snd-aloop or a
// PulseAudio null sink that HyperHDR then selects as its input device.
// * PulseAudio's own module-rtp-recv, which needs no custom software at
// all when SAP announcements are enabled.
//
// RTP/L16 (RFC 3551) is the common denominator both understand: 16-bit
// big-endian PCM behind a 12-byte RTP header.
#include "sink.h"
#include "../common/log.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#define RTP_HEADER_BYTES 12
#define RTP_DYNAMIC_PAYLOAD_TYPE 96
#define RTP_MAX_PAYLOAD 1400 // stays under a 1500-byte Ethernet MTU
#define SAP_ADDRESS "224.0.0.56" // PulseAudio's default SAP group
#define SAP_PORT 9875
#define SAP_INTERVAL_SEC 5
typedef struct {
int fd;
struct sockaddr_in dest;
int sap_fd;
struct sockaddr_in sap_dest;
bool sap_enabled;
time_t sap_last_sent;
uint16_t sap_msg_id;
uint32_t local_addr; // network byte order, for the SDP origin line
char host[128];
int port;
bool multicast;
audio_format_t fmt;
int frames_per_packet;
uint16_t sequence;
uint32_t timestamp;
uint32_t ssrc;
// Assembled outside the send loop so each packet is one sendto().
uint8_t packet[RTP_HEADER_BYTES + RTP_MAX_PAYLOAD];
unsigned long long packets_sent;
unsigned long long bytes_sent;
unsigned long long send_errors;
bool warned;
} hh_priv_t;
// ---------------------------------------------------------------------------
// SAP / SDP announcements
// ---------------------------------------------------------------------------
// Builds the SDP body describing this stream. PulseAudio's module-rtp-recv
// creates a matching source purely from what it reads here.
static int build_sdp(hh_priv_t* p, char* out, size_t cap)
{
struct in_addr src = { .s_addr = p->local_addr };
char src_str[INET_ADDRSTRLEN];
snprintf(src_str, sizeof(src_str), "%s", inet_ntoa(src));
char conn[128];
if (p->multicast) {
// The /255 suffix is the TTL, required for multicast connection lines.
snprintf(conn, sizeof(conn), "IN IP4 %s/255", p->host);
} else {
snprintf(conn, sizeof(conn), "IN IP4 %s", p->host);
}
return snprintf(out, cap,
"v=0\r\n"
"o=- %u %u IN IP4 %s\r\n"
"s=LG TV Audio Cap\r\n"
"i=Audio captured from an LG webOS TV\r\n"
"c=%s\r\n"
"t=0 0\r\n"
"a=recvonly\r\n"
"m=audio %d RTP/AVP %d\r\n"
"a=rtpmap:%d L16/%d/%d\r\n"
"a=type:broadcast\r\n",
p->ssrc, p->ssrc, src_str, conn, p->port, RTP_DYNAMIC_PAYLOAD_TYPE,
RTP_DYNAMIC_PAYLOAD_TYPE, p->fmt.rate, p->fmt.channels);
}
static void send_sap(hh_priv_t* p)
{
if (!p->sap_enabled || p->sap_fd < 0)
return;
time_t now = time(NULL);
if (now - p->sap_last_sent < SAP_INTERVAL_SEC)
return;
p->sap_last_sent = now;
char sdp[512];
int sdp_len = build_sdp(p, sdp, sizeof(sdp));
if (sdp_len <= 0)
return;
// RFC 2974 header: version 1, IPv4 source, announcement, no auth.
uint8_t msg[768];
size_t n = 0;
msg[n++] = 0x20;
msg[n++] = 0x00; // no authentication data
msg[n++] = (uint8_t)(p->sap_msg_id >> 8);
msg[n++] = (uint8_t)(p->sap_msg_id & 0xFF);
memcpy(msg + n, &p->local_addr, 4);
n += 4;
static const char mime[] = "application/sdp";
memcpy(msg + n, mime, sizeof(mime)); // includes the NUL terminator
n += sizeof(mime);
if (n + (size_t)sdp_len > sizeof(msg))
return;
memcpy(msg + n, sdp, (size_t)sdp_len);
n += (size_t)sdp_len;
if (sendto(p->sap_fd, msg, n, 0, (struct sockaddr*)&p->sap_dest,
sizeof(p->sap_dest))
< 0) {
DBG("SAP announcement failed: %s", strerror(errno));
}
}
// ---------------------------------------------------------------------------
// Finds the source address the kernel would use to reach `dest`, without
// sending anything. Needed for the SDP origin and SAP source fields.
static uint32_t discover_local_address(const struct sockaddr_in* dest)
{
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0)
return htonl(INADDR_LOOPBACK);
uint32_t addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (const struct sockaddr*)dest, sizeof(*dest)) == 0) {
struct sockaddr_in local;
socklen_t len = sizeof(local);
if (getsockname(fd, (struct sockaddr*)&local, &len) == 0)
addr = local.sin_addr.s_addr;
}
close(fd);
return addr;
}
static void hh_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
hh_priv_t* p = s->priv;
const int ch = p->fmt.channels;
send_sap(p);
int offset = 0;
while (offset < frames) {
int chunk = frames - offset;
if (chunk > p->frames_per_packet)
chunk = p->frames_per_packet;
uint8_t* hdr = p->packet;
hdr[0] = 0x80; // version 2, no padding, no extension, no CSRCs
hdr[1] = RTP_DYNAMIC_PAYLOAD_TYPE; // marker bit clear
hdr[2] = (uint8_t)(p->sequence >> 8);
hdr[3] = (uint8_t)(p->sequence & 0xFF);
hdr[4] = (uint8_t)((p->timestamp >> 24) & 0xFF);
hdr[5] = (uint8_t)((p->timestamp >> 16) & 0xFF);
hdr[6] = (uint8_t)((p->timestamp >> 8) & 0xFF);
hdr[7] = (uint8_t)(p->timestamp & 0xFF);
hdr[8] = (uint8_t)((p->ssrc >> 24) & 0xFF);
hdr[9] = (uint8_t)((p->ssrc >> 16) & 0xFF);
hdr[10] = (uint8_t)((p->ssrc >> 8) & 0xFF);
hdr[11] = (uint8_t)(p->ssrc & 0xFF);
// L16 is network byte order; our capture format is little-endian.
const int16_t* src = pcm + (size_t)offset * (size_t)ch;
uint8_t* payload = p->packet + RTP_HEADER_BYTES;
int samples = chunk * ch;
for (int i = 0; i < samples; i++) {
uint16_t v = (uint16_t)src[i];
payload[i * 2 + 0] = (uint8_t)((v >> 8) & 0xFF);
payload[i * 2 + 1] = (uint8_t)(v & 0xFF);
}
size_t packet_len = RTP_HEADER_BYTES + (size_t)samples * 2;
ssize_t sent = sendto(p->fd, p->packet, packet_len, 0,
(struct sockaddr*)&p->dest, sizeof(p->dest));
if (sent < 0) {
p->send_errors++;
// A host that is off produces one error per packet; log the first
// and then stay quiet rather than filling the log ring.
if (!p->warned) {
WARN("HyperHDR RTP send to %s:%d failed: %s", p->host, p->port, strerror(errno));
p->warned = true;
}
} else {
p->packets_sent++;
p->bytes_sent += (unsigned long long)sent;
p->warned = false;
}
p->sequence++;
p->timestamp += (uint32_t)chunk; // RTP clock for L16 is the sample rate
offset += chunk;
}
}
static void hh_status(sink_t* s, json_writer_t* w)
{
hh_priv_t* p = s->priv;
jw_str(w, "target", p->host);
jw_int(w, "port", p->port);
jw_bool(w, "multicast", p->multicast);
jw_bool(w, "sapAnnounce", p->sap_enabled);
jw_int(w, "payloadType", RTP_DYNAMIC_PAYLOAD_TYPE);
jw_int(w, "framesPerPacket", p->frames_per_packet);
jw_int(w, "packetsSent", (long long)p->packets_sent);
jw_int(w, "bytesSent", (long long)p->bytes_sent);
jw_int(w, "sendErrors", (long long)p->send_errors);
}
static void hh_close(sink_t* s)
{
hh_priv_t* p = s->priv;
if (p) {
if (p->fd >= 0)
close(p->fd);
if (p->sap_fd >= 0)
close(p->sap_fd);
free(p);
}
free(s);
}
static sink_t* hh_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "hyperhdr");
const char* host = json_str(sc, "host", NULL);
int port = json_int(sc, "port", 5004);
bool multicast = json_bool(sc, "multicast", false);
bool sap = json_bool(sc, "sapAnnounce", false);
if (multicast && (!host || !*host))
host = SAP_ADDRESS;
if (!host || !*host) {
snprintf(err, errlen, "set the HyperHDR host address first");
return NULL;
}
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid HyperHDR audio port %d", port);
return NULL;
}
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
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;
}
hh_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;
p->sap_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->multicast = multicast;
p->fmt = *fmt;
p->fd = socket(AF_INET, SOCK_DGRAM, 0);
if (p->fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
free(p);
free(s);
return NULL;
}
if (multicast) {
unsigned char ttl = (unsigned char)json_int(sc, "multicastTtl", 4);
setsockopt(p->fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
int loop = 0;
setsockopt(p->fd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop));
}
// A larger send buffer absorbs bursts when the interface is busy.
int sndbuf = 256 * 1024;
setsockopt(p->fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
p->local_addr = discover_local_address(&p->dest);
// Derive an SSRC from the address and port so restarts keep the same
// identity; receivers treat an SSRC change as a brand new stream.
p->ssrc = ntohl(p->local_addr) ^ ((uint32_t)port << 16) ^ 0x4C475456u;
p->sap_msg_id = (uint16_t)(p->ssrc & 0xFFFF);
int frame_bytes = audio_frame_bytes(fmt);
p->frames_per_packet = RTP_MAX_PAYLOAD / frame_bytes;
if (p->frames_per_packet < 1)
p->frames_per_packet = 1;
if (sap) {
p->sap_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (p->sap_fd >= 0) {
unsigned char ttl = 4;
setsockopt(p->sap_fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
memset(&p->sap_dest, 0, sizeof(p->sap_dest));
p->sap_dest.sin_family = AF_INET;
p->sap_dest.sin_port = htons(SAP_PORT);
p->sap_dest.sin_addr.s_addr = inet_addr(SAP_ADDRESS);
p->sap_enabled = true;
} else {
WARN("Could not open SAP socket: %s", strerror(errno));
}
}
s->driver = &sink_driver_hyperhdr;
s->priv = p;
s->fmt = *fmt;
s->write = hh_write;
s->status = hh_status;
s->close = hh_close;
INFO("HyperHDR audio sink: RTP/L16 %d Hz %d ch to %s:%d (%s%s)", fmt->rate,
fmt->channels, host, port, multicast ? "multicast" : "unicast",
p->sap_enabled ? ", SAP on" : "");
return s;
}
const sink_driver_t sink_driver_hyperhdr = {
.id = "hyperhdr",
.name = "HyperHDR audio (RTP)",
.description = "Streams PCM to the HyperHDR host as RTP/L16 for its sound-reactive effects.",
.open = hh_open,
};
+416
View File
@@ -0,0 +1,416 @@
// On-TV visualiser: analyse the audio here, send HyperHDR a picture.
//
// The RTP sink needs a virtual sound device set up on the HyperHDR machine.
// This one needs nothing: the TV runs the FFT, renders a small RGB image and
// pushes it to HyperHDR's FlatBuffers image input (TCP 19400), exactly as a
// video grabber would. HyperHDR maps the image onto the LED layout it already
// has, so the lights react to sound with no host-side configuration.
//
// The trade-off is that HyperHDR's own audio effects are bypassed — the look
// is defined here instead. Use the RTP sink when you want HyperHDR's effects,
// this one when you want it to just work.
#include "sink.h"
#include "../common/log.h"
#include "../net/hyperion.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define VIZ_MAX_WIDTH 128
#define VIZ_MAX_HEIGHT 128
#define RECONNECT_INTERVAL_SEC 5
typedef enum {
VIZ_SPECTRUM, // bars across the width, hue by frequency
VIZ_LEVEL, // whole frame lit, colour mixed from band energy
VIZ_PULSE, // whole frame lit, brightness follows loudness only
} viz_mode_t;
typedef struct {
hyperion_target_t target; // resolved once at open; reconnects never do DNS
char host[128];
int port;
int priority;
viz_mode_t mode;
int width;
int height;
int fps;
float saturation;
float floor_level; // minimum brightness so the lights never go fully dark
hyperion_client_t* client;
time_t last_connect_attempt;
char last_error[192];
uint8_t* frame;
size_t frame_bytes;
struct timespec last_send;
dsp_levels_t latest;
bool have_levels;
unsigned long long frames_sent;
unsigned long long connect_failures;
} viz_priv_t;
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
static void hsv_to_rgb(float h, float s, float v, uint8_t* out)
{
h = fmodf(h, 1.0f);
if (h < 0.0f)
h += 1.0f;
float i = floorf(h * 6.0f);
float f = h * 6.0f - i;
float p = v * (1.0f - s);
float q = v * (1.0f - f * s);
float t = v * (1.0f - (1.0f - f) * s);
float r, g, b;
switch ((int)i % 6) {
case 0:
r = v, g = t, b = p;
break;
case 1:
r = q, g = v, b = p;
break;
case 2:
r = p, g = v, b = t;
break;
case 3:
r = p, g = q, b = v;
break;
case 4:
r = t, g = p, b = v;
break;
default:
r = v, g = p, b = q;
break;
}
out[0] = (uint8_t)(r * 255.0f + 0.5f);
out[1] = (uint8_t)(g * 255.0f + 0.5f);
out[2] = (uint8_t)(b * 255.0f + 0.5f);
}
static void fill_frame(viz_priv_t* p, const uint8_t rgb[3])
{
for (int i = 0; i < p->width * p->height; i++) {
p->frame[i * 3 + 0] = rgb[0];
p->frame[i * 3 + 1] = rgb[1];
p->frame[i * 3 + 2] = rgb[2];
}
}
// Bars rise from the bottom of the image, one group of columns per band, hue
// running red (bass) through to violet (treble).
static void render_spectrum(viz_priv_t* p, const dsp_levels_t* lv)
{
memset(p->frame, 0, p->frame_bytes);
for (int x = 0; x < p->width; x++) {
int band = x * DSP_BANDS / p->width;
if (band >= DSP_BANDS)
band = DSP_BANDS - 1;
float level = lv->bands[band];
if (level < p->floor_level)
level = p->floor_level;
int lit = (int)(level * (float)p->height + 0.5f);
if (lit > p->height)
lit = p->height;
// 0.0 (red) through 0.8 (violet); avoids wrapping back to red.
float hue = 0.8f * ((float)band / (float)(DSP_BANDS - 1));
uint8_t colour[3];
hsv_to_rgb(hue, p->saturation, level, colour);
for (int y = 0; y < lit; y++) {
int row = p->height - 1 - y; // row 0 is the top of the image
uint8_t* px = &p->frame[((size_t)row * p->width + x) * 3];
px[0] = colour[0];
px[1] = colour[1];
px[2] = colour[2];
}
}
}
// Splits the spectrum into three groups and treats them as an RGB mix, which
// gives bass-heavy content a warm cast and bright content a cool one.
static void render_level(viz_priv_t* p, const dsp_levels_t* lv)
{
float low = 0.0f, mid = 0.0f, high = 0.0f;
const int third = DSP_BANDS / 3;
for (int b = 0; b < DSP_BANDS; b++) {
if (b < third)
low += lv->bands[b];
else if (b < third * 2)
mid += lv->bands[b];
else
high += lv->bands[b];
}
low /= (float)third;
mid /= (float)third;
high /= (float)(DSP_BANDS - third * 2);
float strongest = low > mid ? low : mid;
if (high > strongest)
strongest = high;
if (strongest < 0.001f)
strongest = 0.001f;
float brightness = lv->rms * 3.0f; // RMS of music rarely exceeds ~0.33
if (brightness > 1.0f)
brightness = 1.0f;
if (brightness < p->floor_level)
brightness = p->floor_level;
uint8_t rgb[3] = {
(uint8_t)(low / strongest * brightness * 255.0f),
(uint8_t)(mid / strongest * brightness * 255.0f),
(uint8_t)(high / strongest * brightness * 255.0f),
};
fill_frame(p, rgb);
}
static void render_pulse(viz_priv_t* p, const dsp_levels_t* lv)
{
float brightness = lv->peak;
if (brightness < p->floor_level)
brightness = p->floor_level;
uint8_t rgb[3];
// Warm white that shifts slightly warmer as it gets quieter.
hsv_to_rgb(0.09f, p->saturation * 0.5f, brightness, rgb);
fill_frame(p, rgb);
}
// ---------------------------------------------------------------------------
// Never blocks: the connect is started here and completed by hyperion_pump()
// on later blocks, because this runs on the capture thread.
static bool ensure_connected(viz_priv_t* p)
{
if (p->client)
return true;
time_t now = time(NULL);
if (now - p->last_connect_attempt < RECONNECT_INTERVAL_SEC)
return false;
p->last_connect_attempt = now;
char err[192] = { 0 };
p->client = hyperion_connect(&p->target, "lgtv-audio-cap", p->priority, err, sizeof(err));
if (!p->client) {
p->connect_failures++;
// Only log when the message changes, so an unreachable host does not
// spam one line every five seconds forever.
if (strcmp(err, p->last_error) != 0) {
WARN("HyperHDR visualiser: %s", err);
snprintf(p->last_error, sizeof(p->last_error), "%s", err);
}
return false;
}
p->last_error[0] = '\0';
return true;
}
static bool frame_due(viz_priv_t* p)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
double elapsed = (double)(now.tv_sec - p->last_send.tv_sec)
+ (double)(now.tv_nsec - p->last_send.tv_nsec) / 1e9;
if (elapsed < 1.0 / (double)p->fps)
return false;
p->last_send = now;
return true;
}
static void viz_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)pcm;
(void)frames;
viz_priv_t* p = s->priv;
if (levels) {
p->latest = *levels;
p->have_levels = true;
}
if (!ensure_connected(p))
return;
if (!hyperion_pump(p->client)) {
const char* why = hyperion_last_error(p->client);
WARN("HyperHDR visualiser disconnected: %s", why ? why : "unknown");
snprintf(p->last_error, sizeof(p->last_error), "%s", why ? why : "disconnected");
hyperion_disconnect(p->client);
p->client = NULL;
return;
}
// Capture blocks arrive far faster than the LEDs need updating; rate-limit
// so we are not shipping an image every 10 ms over the network.
if (!p->have_levels || !frame_due(p))
return;
switch (p->mode) {
case VIZ_SPECTRUM:
render_spectrum(p, &p->latest);
break;
case VIZ_LEVEL:
render_level(p, &p->latest);
break;
case VIZ_PULSE:
render_pulse(p, &p->latest);
break;
}
if (!hyperion_send_image(p->client, p->frame, p->width, p->height)) {
const char* why = hyperion_last_error(p->client);
WARN("HyperHDR visualiser send failed: %s", why ? why : "unknown");
hyperion_disconnect(p->client);
p->client = NULL;
return;
}
if (hyperion_registered(p->client))
p->frames_sent++;
}
static const char* mode_name(viz_mode_t m)
{
switch (m) {
case VIZ_SPECTRUM:
return "spectrum";
case VIZ_LEVEL:
return "level";
default:
return "pulse";
}
}
static void viz_status(sink_t* s, json_writer_t* w)
{
viz_priv_t* p = s->priv;
jw_str(w, "target", p->host);
jw_int(w, "port", p->port);
jw_int(w, "priority", p->priority);
jw_str(w, "mode", mode_name(p->mode));
jw_int(w, "width", p->width);
jw_int(w, "height", p->height);
jw_int(w, "fps", p->fps);
jw_bool(w, "connected", hyperion_connected(p->client));
jw_bool(w, "registered", p->client && hyperion_registered(p->client));
jw_int(w, "framesSent", (long long)p->frames_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 viz_close(sink_t* s)
{
viz_priv_t* p = s->priv;
if (p) {
if (p->client)
hyperion_disconnect(p->client);
free(p->frame);
free(p);
}
free(s);
}
static int clamp_int(int v, int lo, int hi)
{
return v < lo ? lo : (v > hi ? hi : v);
}
static sink_t* viz_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "hyperhdrViz");
const char* host = json_str(sc, "host", NULL);
// Fall back to the audio sink's host so the common case needs one address.
if (!host || !*host)
host = json_str(json_get(cfg, "hyperhdr"), "host", NULL);
if (!host || !*host) {
snprintf(err, errlen, "set the HyperHDR host address first");
return NULL;
}
viz_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
snprintf(p->host, sizeof(p->host), "%s", host);
p->port = clamp_int(json_int(sc, "port", 19400), 1, 65535);
if (!hyperion_resolve(p->host, p->port, &p->target, err, errlen)) {
free(p);
free(s);
return NULL;
}
p->priority = clamp_int(json_int(sc, "priority", 150), 1, 253);
p->width = clamp_int(json_int(sc, "width", 64), 4, VIZ_MAX_WIDTH);
p->height = clamp_int(json_int(sc, "height", 36), 4, VIZ_MAX_HEIGHT);
p->fps = clamp_int(json_int(sc, "fps", 30), 1, 60);
p->saturation = (float)json_num(sc, "saturation", 1.0);
p->floor_level = (float)json_num(sc, "minBrightness", 0.02);
const char* mode = json_str(sc, "mode", "spectrum");
if (strcmp(mode, "level") == 0)
p->mode = VIZ_LEVEL;
else if (strcmp(mode, "pulse") == 0)
p->mode = VIZ_PULSE;
else
p->mode = VIZ_SPECTRUM;
p->frame_bytes = (size_t)p->width * (size_t)p->height * 3;
p->frame = calloc(1, p->frame_bytes);
if (!p->frame) {
free(p);
free(s);
snprintf(err, errlen, "out of memory allocating %dx%d frame", p->width, p->height);
return NULL;
}
clock_gettime(CLOCK_MONOTONIC, &p->last_send);
s->driver = &sink_driver_hyperhdr_viz;
s->priv = p;
s->fmt = *fmt;
s->write = viz_write;
s->status = viz_status;
s->close = viz_close;
INFO("HyperHDR visualiser sink: %s:%d mode=%s %dx%d @%d fps priority=%d", p->host,
p->port, mode_name(p->mode), p->width, p->height, p->fps, p->priority);
return s;
}
const sink_driver_t sink_driver_hyperhdr_viz = {
.id = "hyperhdrViz",
.name = "HyperHDR visualiser (FlatBuffers)",
.description = "Runs the spectrum analysis on the TV and pushes images to HyperHDR. No host setup.",
.open = viz_open,
};
+105
View File
@@ -0,0 +1,105 @@
// Raw PCM over TCP, with the TV acting as the server.
//
// The TV listens and whoever connects gets the live stream. Useful when the
// receiver cannot be given a fixed port to listen on, or when you want
// lossless delivery and can tolerate the buffering that implies:
//
// nc <tv-ip> 4011 | aplay -f S16_LE -r 48000 -c 2
#include "sink.h"
#include "../common/log.h"
#include "../net/streamserv.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
streamserv_t* server;
int port;
audio_format_t fmt;
} tcp_priv_t;
static void tcp_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
tcp_priv_t* p = s->priv;
streamserv_broadcast(p->server, pcm, (size_t)frames * (size_t)audio_frame_bytes(&s->fmt));
}
static void tcp_status(sink_t* s, json_writer_t* w)
{
tcp_priv_t* p = s->priv;
jw_int(w, "port", p->port);
jw_int(w, "clients", streamserv_client_count(p->server));
jw_int(w, "droppedBytes", (long long)streamserv_dropped_bytes(p->server));
jw_str(w, "format", "S16_LE interleaved");
}
static void tcp_close(sink_t* s)
{
tcp_priv_t* p = s->priv;
if (p) {
streamserv_stop(p->server);
free(p);
}
free(s);
}
static sink_t* tcp_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "tcp");
int port = json_int(sc, "port", 4011);
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid TCP port %d", port);
return NULL;
}
tcp_priv_t* p = calloc(1, sizeof(*p));
sink_t* s = calloc(1, sizeof(*s));
if (!p || !s) {
free(p);
free(s);
snprintf(err, errlen, "out of memory");
return NULL;
}
// Roughly one second of audio before a stalled client starts losing data.
size_t buffer = (size_t)fmt->rate * (size_t)audio_frame_bytes(fmt);
streamserv_config_t scfg = {
.port = port,
.client_buffer = buffer,
.max_clients = json_int(sc, "maxClients", 4),
.http_mode = false,
.user = NULL,
.hello = NULL,
};
p->server = streamserv_start(&scfg, err, errlen);
if (!p->server) {
free(p);
free(s);
return NULL;
}
p->port = port;
p->fmt = *fmt;
s->driver = &sink_driver_tcp;
s->priv = p;
s->fmt = *fmt;
s->write = tcp_write;
s->status = tcp_status;
s->close = tcp_close;
INFO("TCP sink: serving raw S16LE %d Hz %d ch on port %d", fmt->rate, fmt->channels, port);
return s;
}
const sink_driver_t sink_driver_tcp = {
.id = "tcp",
.name = "Raw PCM over TCP",
.description = "The TV listens; connect to it to pull a lossless S16LE stream.",
.open = tcp_open,
};
+176
View File
@@ -0,0 +1,176 @@
// Raw PCM over UDP.
//
// No framing, no headers: just little-endian S16 samples straight out of the
// capture buffer. Deliberately the dumbest possible transport, so anything
// can consume it:
//
// nc -u -l 4010 | aplay -f S16_LE -r 48000 -c 2
// ffplay -f s16le -ar 48000 -ac 2 udp://0.0.0.0:4010
//
// Use the hyperhdr sink instead when you want something to reconstruct
// timing; without RTP sequence numbers a receiver cannot detect loss.
#include "sink.h"
#include "../common/log.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define UDP_MAX_PAYLOAD 1400
typedef struct {
int fd;
struct sockaddr_in dest;
char host[128];
int port;
int frames_per_packet;
unsigned long long packets_sent;
unsigned long long bytes_sent;
unsigned long long send_errors;
bool warned;
} udp_priv_t;
static void udp_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
{
(void)levels;
udp_priv_t* p = s->priv;
const int frame_bytes = audio_frame_bytes(&s->fmt);
int offset = 0;
while (offset < frames) {
int chunk = frames - offset;
if (chunk > p->frames_per_packet)
chunk = p->frames_per_packet;
const void* src = (const uint8_t*)pcm + (size_t)offset * (size_t)frame_bytes;
size_t len = (size_t)chunk * (size_t)frame_bytes;
if (sendto(p->fd, src, len, 0, (struct sockaddr*)&p->dest, sizeof(p->dest)) < 0) {
p->send_errors++;
if (!p->warned) {
WARN("UDP send to %s:%d failed: %s", p->host, p->port, strerror(errno));
p->warned = true;
}
} else {
p->packets_sent++;
p->bytes_sent += len;
p->warned = false;
}
offset += chunk;
}
}
static void udp_status(sink_t* s, json_writer_t* w)
{
udp_priv_t* p = s->priv;
jw_str(w, "target", p->host);
jw_int(w, "port", p->port);
jw_int(w, "framesPerPacket", p->frames_per_packet);
jw_int(w, "packetsSent", (long long)p->packets_sent);
jw_int(w, "bytesSent", (long long)p->bytes_sent);
jw_int(w, "sendErrors", (long long)p->send_errors);
}
static void udp_close(sink_t* s)
{
udp_priv_t* p = s->priv;
if (p) {
if (p->fd >= 0)
close(p->fd);
free(p);
}
free(s);
}
static sink_t* udp_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
{
const json_value_t* sc = json_get(cfg, "udp");
const char* host = json_str(sc, "host", NULL);
int port = json_int(sc, "port", 4010);
if (!host || !*host) {
snprintf(err, errlen, "set a destination host for the UDP sink");
return NULL;
}
if (port <= 0 || port > 65535) {
snprintf(err, errlen, "invalid UDP port %d", port);
return NULL;
}
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
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;
}
udp_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;
}
memcpy(&p->dest, res->ai_addr, sizeof(struct sockaddr_in));
freeaddrinfo(res);
p->fd = socket(AF_INET, SOCK_DGRAM, 0);
if (p->fd < 0) {
snprintf(err, errlen, "socket(): %s", strerror(errno));
free(p);
free(s);
return NULL;
}
// Multicast and broadcast destinations both need explicit opt-in.
uint32_t addr = ntohl(p->dest.sin_addr.s_addr);
if ((addr & 0xF0000000u) == 0xE0000000u) {
unsigned char ttl = (unsigned char)json_int(sc, "multicastTtl", 4);
setsockopt(p->fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
} else if (addr == 0xFFFFFFFFu) {
int on = 1;
setsockopt(p->fd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
}
snprintf(p->host, sizeof(p->host), "%s", host);
p->port = port;
p->frames_per_packet = UDP_MAX_PAYLOAD / audio_frame_bytes(fmt);
if (p->frames_per_packet < 1)
p->frames_per_packet = 1;
s->driver = &sink_driver_udp;
s->priv = p;
s->fmt = *fmt;
s->write = udp_write;
s->status = udp_status;
s->close = udp_close;
INFO("UDP sink: raw S16LE %d Hz %d ch to %s:%d", fmt->rate, fmt->channels, host, port);
return s;
}
const sink_driver_t sink_driver_udp = {
.id = "udp",
.name = "Raw PCM over UDP",
.description = "Fire-and-forget S16LE datagrams to any host. Lowest latency, no error recovery.",
.open = udp_open,
};