Audio capture and streaming app for webOS 5/6
Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.
native/ the service: capture backends (PulseAudio, ALSA, exec,
test tone, all dlopen-based), DSP, and one file per sink
frontend/ D-pad driven UI at a fixed 1920x1080
servicefiles/ native service manifest plus the boot script
host/ RTP receiver and the loopback installer for the HyperHDR
machine
tools/ build/package, asset generation, Homebrew Channel
manifest, on-TV probe
test/ host-side suites: FlatBuffers and RTP verified against
real decoders, the engine end to end, the page in jsdom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
project(lgtv-audio-cap C)
|
||||
|
||||
# Built with the openlgtv arm-webos-linux-gnueabi buildroot SDK:
|
||||
#
|
||||
# source /path/to/arm-webos-linux-gnueabi_sdk-buildroot/environment-setup
|
||||
# cmake -B build -DCMAKE_BUILD_TYPE=Release native
|
||||
# cmake --build build
|
||||
#
|
||||
# The SDK's environment-setup exports CC/SYSROOT and puts its pkg-config in
|
||||
# front, so luna-service2 and glib resolve to the TV's versions rather than the
|
||||
# host's.
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(LUNASERVICE REQUIRED luna-service2)
|
||||
pkg_check_modules(GLIB REQUIRED glib-2.0)
|
||||
pkg_check_modules(GTHREAD REQUIRED gthread-2.0)
|
||||
# PmLogLib is how webOS services normally reach the system log. It is optional
|
||||
# here: the logger writes to stderr, which the service launcher captures.
|
||||
pkg_check_modules(PMLOG PmLogLib)
|
||||
|
||||
add_executable(audiocap-service
|
||||
src/main.c
|
||||
src/service.c
|
||||
src/engine.c
|
||||
src/config.c
|
||||
src/dsp.c
|
||||
src/common/log.c
|
||||
src/common/json.c
|
||||
src/common/ringbuf.c
|
||||
src/capture/capture.c
|
||||
src/capture/cap_pulse.c
|
||||
src/capture/cap_alsa.c
|
||||
src/capture/cap_exec.c
|
||||
src/capture/cap_tone.c
|
||||
src/net/flatbuf.c
|
||||
src/net/hyperion.c
|
||||
src/net/streamserv.c
|
||||
src/sinks/sink.c
|
||||
src/sinks/sink_hyperhdr.c
|
||||
src/sinks/sink_hyperhdr_viz.c
|
||||
src/sinks/sink_udp.c
|
||||
src/sinks/sink_tcp.c
|
||||
src/sinks/sink_http.c
|
||||
)
|
||||
|
||||
target_include_directories(audiocap-service PRIVATE
|
||||
src
|
||||
${LUNASERVICE_INCLUDE_DIRS}
|
||||
${GLIB_INCLUDE_DIRS}
|
||||
${GTHREAD_INCLUDE_DIRS}
|
||||
${PMLOG_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
target_compile_options(audiocap-service PRIVATE
|
||||
-Wall -Wextra -Wno-unused-parameter
|
||||
${LUNASERVICE_CFLAGS_OTHER}
|
||||
${GLIB_CFLAGS_OTHER}
|
||||
)
|
||||
|
||||
target_compile_definitions(audiocap-service PRIVATE
|
||||
_GNU_SOURCE
|
||||
# Lets LSRegisterPubPriv resolve on firmware older than 3.5 without making
|
||||
# the symbol mandatory on newer builds.
|
||||
SECURITY_COMPATIBILITY
|
||||
)
|
||||
|
||||
# The audio backends are dlopen'd at runtime, so libpulse and libasound are
|
||||
# deliberately absent from this list: the service has to start on a TV that has
|
||||
# neither, report that in the diagnostics, and let the user pick another path.
|
||||
target_link_libraries(audiocap-service PRIVATE
|
||||
${LUNASERVICE_LIBRARIES}
|
||||
${GLIB_LIBRARIES}
|
||||
${GTHREAD_LIBRARIES}
|
||||
${PMLOG_LIBRARIES}
|
||||
pthread
|
||||
dl
|
||||
m
|
||||
)
|
||||
|
||||
target_link_directories(audiocap-service PRIVATE
|
||||
${LUNASERVICE_LIBRARY_DIRS}
|
||||
${GLIB_LIBRARY_DIRS}
|
||||
${GTHREAD_LIBRARY_DIRS}
|
||||
${PMLOG_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
if(CMAKE_C_COMPILER_ID STREQUAL "GNU" AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm")
|
||||
# webOS 5/6 TVs are Cortex-A9 (and A53 on later chassis, which runs A9 code
|
||||
# fine). softfp matches the SDK's ABI.
|
||||
target_compile_options(audiocap-service PRIVATE
|
||||
-mcpu=cortex-a9 -mfloat-abi=softfp -mfpu=neon -ffast-math
|
||||
)
|
||||
endif()
|
||||
|
||||
# Homebrew services are unpacked to an arbitrary directory, so anything we ship
|
||||
# alongside the binary has to be found relative to it.
|
||||
set_target_properties(audiocap-service PROPERTIES
|
||||
BUILD_RPATH "$ORIGIN"
|
||||
INSTALL_RPATH "$ORIGIN"
|
||||
)
|
||||
|
||||
install(TARGETS audiocap-service RUNTIME DESTINATION .)
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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__)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user