Audio capture and streaming app for webOS 5/6

Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rene Kievits
2026-08-26 10:21:00 +02:00
co-authored by Claude Opus 5
commit 7529a60650
73 changed files with 12826 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
// End-to-end check of the capture pipeline, minus webOS.
//
// Runs the engine with the `tone` backend feeding the TCP and HTTP sinks, then
// connects to both as a client and verifies that real audio comes out with the
// right framing. Everything here works identically on the TV; only the Luna
// layer is missing, so this exercises capture -> DSP -> fan-out -> socket.
//
// Build and run: test/run-tests.sh
#include "common/log.h"
#include "engine.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#define HTTP_PORT 45812
#define TCP_PORT 45811
static int failures = 0;
static void check(bool ok, const char* what)
{
printf("%s %s\n", ok ? " ok " : " FAIL", what);
if (!ok)
failures++;
}
static void sleep_ms(int ms)
{
struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L };
nanosleep(&ts, NULL);
}
static int connect_local(int port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
return -1;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
close(fd);
return -1;
}
struct timeval tv = { .tv_sec = 3, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
return fd;
}
// Reads exactly `len` bytes or fails.
static bool read_exact(int fd, void* dst, size_t len)
{
size_t got = 0;
while (got < len) {
ssize_t n = read(fd, (char*)dst + got, len - got);
if (n <= 0)
return false;
got += (size_t)n;
}
return true;
}
static uint32_t rd_u32le(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_u16le(const uint8_t* p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
// True if the buffer contains something other than digital silence.
static bool has_signal(const int16_t* pcm, size_t samples)
{
for (size_t i = 0; i < samples; i++) {
if (pcm[i] > 500 || pcm[i] < -500)
return true;
}
return false;
}
static void test_http(void)
{
printf("HTTP WAV sink\n");
int fd = connect_local(HTTP_PORT);
if (fd < 0) {
check(false, "connect to the HTTP sink");
return;
}
const char* req = "GET /audio.wav HTTP/1.1\r\nHost: tv\r\n\r\n";
check(write(fd, req, strlen(req)) == (ssize_t)strlen(req), "send the request");
// Headers end at the blank line; read a byte at a time so we do not eat
// into the WAV header that follows.
char headers[1024];
size_t hlen = 0;
bool complete = false;
while (hlen < sizeof(headers) - 1) {
if (!read_exact(fd, headers + hlen, 1))
break;
hlen++;
headers[hlen] = '\0';
if (hlen >= 4 && memcmp(headers + hlen - 4, "\r\n\r\n", 4) == 0) {
complete = true;
break;
}
}
check(complete, "receive complete HTTP headers");
check(strstr(headers, "200 OK") != NULL, "status line is 200 OK");
check(strstr(headers, "Content-Type: audio/wav") != NULL, "content type is audio/wav");
uint8_t wav[44];
if (!read_exact(fd, wav, sizeof(wav))) {
check(false, "receive the WAV header");
close(fd);
return;
}
check(memcmp(wav, "RIFF", 4) == 0, "RIFF magic");
check(memcmp(wav + 8, "WAVE", 4) == 0, "WAVE magic");
check(memcmp(wav + 12, "fmt ", 4) == 0, "fmt chunk");
check(rd_u32le(wav + 16) == 16, "fmt chunk length is 16");
check(rd_u16le(wav + 20) == 1, "format is PCM");
check(rd_u16le(wav + 22) == 2, "2 channels");
check(rd_u32le(wav + 24) == 48000, "48000 Hz");
check(rd_u32le(wav + 28) == 48000 * 4, "byte rate matches");
check(rd_u16le(wav + 32) == 4, "block align is 4");
check(rd_u16le(wav + 34) == 16, "16 bits per sample");
check(memcmp(wav + 36, "data", 4) == 0, "data chunk");
check(rd_u32le(wav + 4) == 0xFFFFFFFFu, "RIFF size is the unknown-length marker");
check(rd_u32le(wav + 40) == 0xFFFFFFFFu, "data size is the unknown-length marker");
int16_t pcm[4096];
bool got = read_exact(fd, pcm, sizeof(pcm));
check(got, "receive 16 KB of audio");
check(got && has_signal(pcm, sizeof(pcm) / sizeof(pcm[0])), "audio is not silence");
close(fd);
}
static void test_tcp(void)
{
printf("Raw PCM TCP sink\n");
int fd = connect_local(TCP_PORT);
if (fd < 0) {
check(false, "connect to the TCP sink");
return;
}
int16_t pcm[4096];
bool got = read_exact(fd, pcm, sizeof(pcm));
check(got, "receive 16 KB of audio");
check(got && has_signal(pcm, sizeof(pcm) / sizeof(pcm[0])), "audio is not silence");
close(fd);
}
static void test_status(engine_t* e)
{
printf("Status document\n");
json_writer_t w;
jw_init(&w);
jw_obj_open(&w, NULL);
engine_write_status(e, &w);
jw_obj_close(&w);
char* text = jw_take(&w);
check(text != NULL, "status serialises");
if (!text)
return;
json_value_t* v = json_parse(text);
check(v != NULL, "status is valid JSON");
if (v) {
check(strcmp(json_str(v, "state", ""), "running") == 0, "state is running");
const json_value_t* cap = json_get(v, "capture");
check(strcmp(json_str(cap, "backend", ""), "tone") == 0, "backend is the tone generator");
check(json_int(cap, "rate", 0) == 48000, "reports 48000 Hz");
check(json_int(cap, "frames", 0) > 0, "frames have been captured");
const json_value_t* levels = json_get(v, "levels");
check(json_num(levels, "peak", 0) > 0.05, "peak level is non-trivial");
check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported");
const json_value_t* sinks = json_get(v, "sinks");
check(json_len(sinks) == 2, "two sinks reported");
for (size_t i = 0; i < json_len(sinks); i++) {
const json_value_t* s = json_at(sinks, i);
char label[64];
snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?"));
check(json_bool(s, "ok", false), label);
}
json_free(v);
}
free(text);
}
int main(void)
{
log_init(LOG_WARN); // keep the test output readable
char cfg_text[512];
snprintf(cfg_text, sizeof(cfg_text),
"{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2},"
"\"sinks\":[\"tcp\",\"http\"],"
"\"tcp\":{\"port\":%d},"
"\"http\":{\"port\":%d}}",
TCP_PORT, HTTP_PORT);
json_value_t* cfg = json_parse(cfg_text);
if (!cfg) {
fprintf(stderr, "test bug: config does not parse\n");
return 1;
}
engine_t* e = engine_create(NULL, NULL);
if (!e) {
fprintf(stderr, "cannot create the engine\n");
return 1;
}
char err[256] = { 0 };
printf("Engine\n");
if (!engine_start(e, cfg, err, sizeof(err))) {
printf(" FAIL start: %s\n", err);
return 1;
}
// Startup happens on the engine thread; wait for it to settle.
for (int i = 0; i < 100 && engine_state(e) == ENGINE_STARTING; i++)
sleep_ms(50);
check(engine_state(e) == ENGINE_RUNNING, "engine reaches the running state");
if (engine_state(e) != ENGINE_RUNNING) {
engine_destroy(e);
json_free(cfg);
return 1;
}
test_tcp();
test_http();
test_status(e);
engine_stop(e);
check(engine_state(e) == ENGINE_STOPPED, "engine stops cleanly");
engine_destroy(e);
json_free(cfg);
printf("\n%s\n", failures ? "FAILED" : "All engine checks passed.");
return failures ? 1 : 0;
}
+57
View File
@@ -0,0 +1,57 @@
// Emits the exact bytes hyperion.c would put on the wire, so the FlatBuffers
// encoding can be checked against the reference implementation.
//
// ./fb_dump register out.bin
// ./fb_dump image out.bin
#include "../native/src/net/hyperion.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char** argv)
{
if (argc < 3) {
fprintf(stderr, "usage: %s <register|image> <output-file>\n", argv[0]);
return 2;
}
size_t len = 0;
uint8_t* buf = NULL;
if (strcmp(argv[1], "register") == 0) {
buf = hyperion_build_register("lgtv-audio-cap", 150, &len);
} else if (strcmp(argv[1], "image") == 0) {
// 4x2 RGB gradient: distinctive enough that a wrong vector offset or
// a swapped width/height shows up immediately.
const int w = 4, h = 2;
uint8_t rgb[4 * 2 * 3];
for (int i = 0; i < w * h; i++) {
rgb[i * 3 + 0] = (uint8_t)(i * 10);
rgb[i * 3 + 1] = (uint8_t)(i * 10 + 1);
rgb[i * 3 + 2] = (uint8_t)(i * 10 + 2);
}
buf = hyperion_build_image(rgb, w, h, &len);
} else {
fprintf(stderr, "unknown message '%s'\n", argv[1]);
return 2;
}
if (!buf) {
fprintf(stderr, "build failed\n");
return 1;
}
FILE* f = fopen(argv[2], "wb");
if (!f) {
perror("fopen");
free(buf);
return 1;
}
fwrite(buf, 1, len, f);
fclose(f);
free(buf);
fprintf(stderr, "wrote %zu bytes to %s\n", len, argv[2]);
return 0;
}
+77
View File
@@ -0,0 +1,77 @@
// Harness for verify_rtp.py: opens the real HyperHDR RTP sink, points it at a
// port on the loopback and writes a deterministic ramp through it. The Python
// side receives the datagrams and checks that what comes out of the wire is
// exactly what went in.
//
// rtp_send <port> <blocks> [frames-per-block] [sap]
#include "../native/src/common/audio.h"
#include "../native/src/common/json.h"
#include "../native/src/common/log.h"
#include "../native/src/dsp.h"
#include "../native/src/sinks/sink.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Must match sample_at() in verify_rtp.py.
static int16_t sample_at(long index)
{
return (int16_t)((index * 251) % 65536 - 32768);
}
int main(int argc, char** argv)
{
if (argc < 3) {
fprintf(stderr, "usage: %s <port> <blocks> [frames]\n", argv[0]);
return 2;
}
int port = atoi(argv[1]);
int blocks = atoi(argv[2]);
int frames = argc > 3 ? atoi(argv[3]) : AUDIO_BLOCK_FRAMES;
bool sap = argc > 4 && strcmp(argv[4], "sap") == 0;
log_set_level(LOG_ERROR);
char cfg_text[256];
snprintf(cfg_text, sizeof(cfg_text),
"{\"hyperhdr\":{\"host\":\"127.0.0.1\",\"port\":%d,"
"\"multicast\":false,\"sapAnnounce\":%s}}",
port, sap ? "true" : "false");
json_value_t* cfg = json_parse(cfg_text);
if (!cfg) {
fprintf(stderr, "bad config\n");
return 1;
}
audio_format_t fmt = { .rate = 48000, .channels = 2 };
char err[256] = { 0 };
sink_t* sink = sink_open("hyperhdr", cfg, &fmt, err, sizeof(err));
if (!sink) {
fprintf(stderr, "sink_open failed: %s\n", err);
json_free(cfg);
return 1;
}
int16_t* pcm = malloc((size_t)frames * fmt.channels * sizeof(int16_t));
dsp_levels_t levels;
memset(&levels, 0, sizeof(levels));
long index = 0;
for (int b = 0; b < blocks; b++) {
for (int i = 0; i < frames * fmt.channels; i++)
pcm[i] = sample_at(index++);
sink->write(sink, pcm, frames, &levels);
}
printf("%ld\n", index); // samples written, for the receiver to expect
fflush(stdout);
free(pcm);
sink_close(sink);
json_free(cfg);
return 0;
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Host-side tests. These build the parts of the service that are not tied to
# webOS with the system compiler and run them, so the risky code (FlatBuffers
# encoding, the capture pipeline, socket framing) is verified before anything
# is ever copied to a TV.
set -euo pipefail
cd "$(dirname "$0")/.."
CC="${CC:-cc}"
# verify_rtp.py imports the host receiver by path; without this it would leave a
# __pycache__ next to it.
export PYTHONDONTWRITEBYTECODE=1
OUT=$(mktemp -d)
trap 'rm -rf "$OUT"' EXIT
CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -O1 -g)
SOURCES=(
native/src/engine.c
native/src/config.c
native/src/dsp.c
native/src/common/log.c
native/src/common/json.c
native/src/common/ringbuf.c
native/src/capture/capture.c
native/src/capture/cap_pulse.c
native/src/capture/cap_alsa.c
native/src/capture/cap_exec.c
native/src/capture/cap_tone.c
native/src/net/flatbuf.c
native/src/net/hyperion.c
native/src/net/streamserv.c
native/src/sinks/sink.c
native/src/sinks/sink_hyperhdr.c
native/src/sinks/sink_hyperhdr_viz.c
native/src/sinks/sink_udp.c
native/src/sinks/sink_tcp.c
native/src/sinks/sink_http.c
)
echo "== Syntax-checking the webOS-only sources against stub headers"
for f in native/src/service.c native/src/main.c; do
"$CC" "${CFLAGS[@]}" -Itest/stubs -fsyntax-only "$f"
echo " ok $f"
done
echo
echo "== FlatBuffers wire format"
if python3 -c "import flatbuffers" 2>/dev/null; then
python3 test/verify_flatbuf.py
else
echo " SKIP: the 'flatbuffers' Python package is not installed"
echo " python3 -m venv /tmp/fbvenv && /tmp/fbvenv/bin/pip install flatbuffers"
echo " CC=$CC /tmp/fbvenv/bin/python test/verify_flatbuf.py"
fi
echo
echo "== RTP wire format, against the host receiver"
"$CC" "${CFLAGS[@]}" -o "$OUT/rtp_send" test/rtp_send.c "${SOURCES[@]}" -lpthread -lm
python3 test/verify_rtp.py "$OUT/rtp_send"
echo
echo "== Capture pipeline end to end"
"$CC" "${CFLAGS[@]}" -o "$OUT/engine_smoke" test/engine_smoke.c "${SOURCES[@]}" -lpthread -lm
"$OUT/engine_smoke"
echo
echo "== Frontend"
if command -v node >/dev/null 2>&1; then
for f in frontend/js/*.js; do
node --check "$f"
echo " ok $f"
done
# jsdom is a test-only dependency; the app itself has none. `npm install`
# puts it in node_modules, or point JSDOM_PATH at an install elsewhere.
if [ -z "${JSDOM_PATH:-}" ] && [ -d node_modules/jsdom ]; then
JSDOM_PATH="$PWD/node_modules"
fi
JSDOM_PATH="${JSDOM_PATH:-/tmp/audiocap-domtest/node_modules}"
if NODE_PATH="$JSDOM_PATH" node -e "require('jsdom')" 2>/dev/null; then
NODE_PATH="$JSDOM_PATH" node test/ui_smoke.js
else
echo " SKIP: jsdom is not installed, so the page was not run"
echo " mkdir -p /tmp/audiocap-domtest && cd /tmp/audiocap-domtest && npm i jsdom"
fi
else
echo " SKIP: node is not installed"
fi
+6
View File
@@ -0,0 +1,6 @@
// See glib.h in this directory: syntax-check scaffolding, not a real header.
#pragma once
#include "glib.h"
guint g_unix_signal_add(gint signum, GSourceFunc handler, gpointer user_data);
+32
View File
@@ -0,0 +1,32 @@
// Minimal glib stand-in, used only to syntax-check service.c and main.c on a
// development machine that has no webOS SDK installed. It declares exactly the
// handful of symbols this project uses and nothing else; the real headers are
// what the TV build compiles against.
#pragma once
#include <stdbool.h>
typedef int gboolean;
typedef int gint;
typedef unsigned int guint;
typedef void* gpointer;
typedef struct _GMainLoop GMainLoop;
typedef struct _GMainContext GMainContext;
#define TRUE 1
#define FALSE 0
#define G_SOURCE_REMOVE FALSE
#define G_SOURCE_CONTINUE TRUE
typedef gboolean (*GSourceFunc)(gpointer user_data);
GMainLoop* g_main_loop_new(GMainContext* context, gboolean is_running);
void g_main_loop_run(GMainLoop* loop);
void g_main_loop_quit(GMainLoop* loop);
void g_main_loop_unref(GMainLoop* loop);
guint g_idle_add(GSourceFunc function, gpointer data);
gboolean g_atomic_int_compare_and_exchange(gint* atomic, gint oldval, gint newval);
void g_atomic_int_set(gint* atomic, gint newval);
+63
View File
@@ -0,0 +1,63 @@
// Minimal luna-service2 stand-in for host-side syntax checks. Mirrors the
// signatures this project calls, so a typo or a wrong argument count is caught
// without a webOS SDK. See ../glib.h.
#pragma once
#include <glib.h>
#include <stdbool.h>
typedef struct LSHandle LSHandle;
typedef struct LSMessage LSMessage;
typedef struct {
int error_code;
char* message;
const char* file;
int line;
const char* func;
void* padding;
unsigned long magic;
} LSError;
typedef bool (*LSMethodFunction)(LSHandle* sh, LSMessage* msg, void* category_context);
typedef enum {
LUNA_METHOD_FLAGS_NONE = 0,
} LSMethodFlags;
typedef struct {
const char* name;
LSMethodFunction function;
LSMethodFlags flags;
} LSMethod;
typedef struct {
const char* name;
void* function;
unsigned int flags;
} LSSignal;
typedef struct {
const char* name;
void* function;
unsigned int flags;
} LSProperty;
void LSErrorInit(LSError* error);
void LSErrorFree(LSError* error);
bool LSRegister(const char* name, LSHandle** handle, LSError* error);
bool LSUnregister(LSHandle* handle, LSError* error);
bool LSRegisterCategory(LSHandle* handle, const char* category, LSMethod* methods,
LSSignal* signals, LSProperty* properties, LSError* error);
bool LSCategorySetData(LSHandle* handle, const char* category, void* user_data, LSError* error);
bool LSGmainAttach(LSHandle* handle, GMainLoop* loop, LSError* error);
const char* LSMessageGetPayload(LSMessage* message);
bool LSMessageIsSubscription(LSMessage* message);
bool LSMessageReply(LSHandle* sh, LSMessage* message, const char* reply, LSError* error);
bool LSSubscriptionAdd(LSHandle* sh, const char* key, LSMessage* message, LSError* error);
bool LSSubscriptionReply(LSHandle* sh, const char* key, const char* payload, LSError* error);
+234
View File
@@ -0,0 +1,234 @@
// Loads the real index.html in jsdom, against the browser mock of the Luna
// bus, and drives it the way a remote would. Catches the mistakes that only
// show up when the page actually runs: a typo'd element id, a control wired to
// a setting that does not exist, a render that throws on the first status
// frame.
//
// jsdom is not vendored. Install it anywhere and point NODE_PATH at it:
// mkdir -p /tmp/audiocap-domtest && cd /tmp/audiocap-domtest && npm i jsdom
// NODE_PATH=/tmp/audiocap-domtest/node_modules node test/ui_smoke.js
'use strict';
const path = require('path');
const { JSDOM, VirtualConsole } = require('jsdom');
const ROOT = path.resolve(__dirname, '..');
const PAGE = path.join(ROOT, 'frontend', 'index.html');
let checks = 0;
let failures = 0;
function check(name, condition, detail) {
checks++;
if (condition) {
console.log(' ok ' + name);
} else {
failures++;
console.log(' FAIL ' + name + (detail === undefined ? '' : ' — ' + detail));
}
}
function eq(name, actual, expected) {
check(name, actual === expected, 'got ' + JSON.stringify(actual)
+ ', wanted ' + JSON.stringify(expected));
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// jsdom has no layout, so every rect is zero and the geometric navigator has
// nothing to work with. Fake a plausible screen: the header button top right,
// the tabs in a row, every other control stacked down the page.
function fakeLayout(window) {
const rects = new WeakMap();
const focusables = window.document.querySelectorAll('.focusable');
let row = 0;
focusables.forEach((el) => {
let rect;
if (el.id === 'power') {
rect = { left: 1600, top: 40, width: 200, height: 60 };
} else if (el.classList.contains('tab')) {
const index = Array.prototype.indexOf.call(
window.document.querySelectorAll('.tab'), el);
rect = { left: 60 + index * 220, top: 160, width: 200, height: 60 };
} else {
rect = { left: 1200, top: 280 + row * 90, width: 360, height: 60 };
row++;
}
rect.right = rect.left + rect.width;
rect.bottom = rect.top + rect.height;
rects.set(el, rect);
});
window.Element.prototype.getBoundingClientRect = function () {
return rects.get(this) || { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 };
};
}
function press(window, keyCode) {
const event = new window.KeyboardEvent('keydown', {
keyCode: keyCode, bubbles: true, cancelable: true,
});
// jsdom's KeyboardEvent ignores the legacy keyCode field.
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
window.document.dispatchEvent(event);
}
function click(el) {
el.dispatchEvent(new el.ownerDocument.defaultView.MouseEvent('click', { bubbles: true }));
}
async function main() {
const errors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on('jsdomError', (e) => errors.push(String(e && e.message || e)));
virtualConsole.on('error', (...args) => errors.push(args.join(' ')));
const dom = await JSDOM.fromFile(PAGE, {
runScripts: 'dangerously',
resources: 'usable',
pretendToBeVisual: true,
virtualConsole,
});
const window = dom.window;
window.addEventListener('error', (e) => errors.push(String(e.message)));
await new Promise((resolve) => {
if (window.document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
// The mock answers after 30 ms; give the whole load sequence room.
await wait(250);
const doc = window.document;
const $ = (id) => doc.getElementById(id);
console.log('page load');
check('no script errors', errors.length === 0, errors.join(' | '));
check('mock bus in use', window.Luna.available === false);
check('settings loaded', !!(window.App.state.settings.capture));
eq('config path shown', $('config-path').textContent.indexOf('/var/lib/webosbrew') >= 0, true);
console.log('status feed');
eq('starts stopped', $('state-pill').textContent, 'Stopped');
eq('power button offers start', $('power').textContent, 'Start');
eq('sixteen band bars', doc.querySelectorAll('.band').length, 16);
eq('no sinks listed while stopped', $('sink-status').textContent.trim(), 'Not running.');
console.log('panels');
eq('five sink cards', doc.querySelectorAll('.sink-card').length, 5);
check('hyperhdr card is first and marked',
doc.querySelector('.sink-card .badge').textContent === 'Recommended');
check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]'));
check('backend choice exists', !!doc.querySelector('[data-path="capture.backend"]'));
check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]'));
check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));
// The mock reports the service already running as root.
eq('root state reflected',
doc.querySelector('[data-path="elevate"]').textContent, 'Re-apply');
// Conditional fields: multicast is off by default, so its TTL stays hidden.
check('multicast ttl hidden while multicast is off',
!doc.querySelector('[data-path="hyperhdr.multicastTtl"]'));
// exec-only fields stay out of the way of the default pulse/alsa setup.
check('command field hidden for automatic backend',
!doc.querySelector('[data-path="capture.command"]'));
console.log('editing');
const host = doc.querySelector('[data-path="hyperhdr.host"]');
host.value = '10.0.0.9';
host.dispatchEvent(new window.Event('change'));
await wait(600);
eq('host edit reached the service', window.App.state.settings.hyperhdr.host, '10.0.0.9');
const multicast = doc.querySelector('[data-path="hyperhdr.multicast"]');
click(multicast);
await wait(600);
eq('multicast toggled', window.App.state.settings.hyperhdr.multicast, true);
check('multicast ttl appears once enabled',
!!doc.querySelector('[data-path="hyperhdr.multicastTtl"]'));
const backend = doc.querySelector('[data-path="capture.backend"]');
click(backend); // auto -> pulse
await wait(600);
eq('backend cycled', window.App.state.settings.capture.backend, 'pulse');
check('server field appears for pulse',
!!doc.querySelector('[data-path="capture.server"]'));
check('command field still hidden for pulse',
!doc.querySelector('[data-path="capture.command"]'));
const udpToggle = doc.querySelector('[data-sink="udp"]');
click(udpToggle);
await wait(600);
check('udp sink enabled',
window.App.state.settings.sinks.indexOf('udp') >= 0,
JSON.stringify(window.App.state.settings.sinks));
console.log('running');
click($('power'));
await wait(300);
eq('pill reports running', $('state-pill').textContent, 'Running');
eq('power button offers stop', $('power').textContent, 'Stop');
check('sinks listed while running',
doc.querySelectorAll('.sink-line').length >= 2,
doc.querySelectorAll('.sink-line').length + ' lines');
check('meter moved', parseFloat($('meter-peak').firstChild.style.width) > 0,
$('meter-peak').firstChild.style.width);
const tallest = Array.prototype.reduce.call(doc.querySelectorAll('.band'),
(max, b) => Math.max(max, parseFloat(b.style.height) || 0), 0);
check('bands moved', tallest > 3, tallest + 'px');
check('capture info filled',
$('capture-info').textContent.indexOf('48000 Hz') >= 0,
$('capture-info').textContent);
click($('power'));
await wait(300);
eq('stops again', $('state-pill').textContent, 'Stopped');
console.log('diagnostics');
click($('run-diagnostics'));
await wait(200);
check('diagnostics output shown',
!$('output').classList.contains('hidden')
&& $('output').textContent.indexOf('libpulse') >= 0);
click($('load-logs'));
await wait(200);
check('log output shown', $('output').textContent.indexOf('browser mock') >= 0);
console.log('navigation');
fakeLayout(window);
const tabs = doc.querySelectorAll('.tab');
tabs[0].focus();
press(window, 39);
eq('right moves along the tab row', doc.activeElement, tabs[1]);
press(window, 37);
eq('left comes back', doc.activeElement, tabs[0]);
press(window, 38);
eq('up reaches the header button', doc.activeElement, $('power'));
press(window, 40);
check('down leaves the header', doc.activeElement !== $('power'));
console.log('tabs');
click(tabs[1]);
check('outputs panel shown', !$('panel-sinks').classList.contains('hidden'));
check('status panel hidden', $('panel-status').classList.contains('hidden'));
press(window, 461); // Back
check('back returns to status', !$('panel-status').classList.contains('hidden'));
check('still no script errors', errors.length === 0, errors.join(' | '));
window.close();
console.log('\n' + (checks - failures) + '/' + checks + ' checks passed');
process.exit(failures ? 1 : 0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Verify the hand-rolled FlatBuffers encoder against the reference runtime.
The C code in native/src/net/flatbuf.c builds Hyperion protocol messages
without flatcc. This decodes those bytes using the upstream `flatbuffers`
Python package, so a layout mistake fails here rather than silently producing
a message HyperHDR drops on the floor.
Schema (hyperion.ng libsrc/flatbufserver/hyperion_request.fbs):
table Register { origin:string (required); priority:int; }
table RawImage { data:[ubyte]; width:int = -1; height:int = -1; }
table Image { data:ImageType (required); duration:int = -1; }
table Clear { priority:int; }
union ImageType { RawImage, NV12Image } // RawImage = 1
union Command { Color, Image, Clear, Register } // Image = 2, Register = 4
table Request { command:Command (required); }
root_type Request;
"""
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
from flatbuffers import number_types as N
from flatbuffers.table import Table
CMD_IMAGE = 2
CMD_REGISTER = 4
IMGTYPE_RAWIMAGE = 1
FAILURES = []
def check(label, actual, expected):
ok = actual == expected
status = "ok " if ok else "FAIL"
shown = actual if not isinstance(actual, (bytes, bytearray)) else bytes(actual).hex()
exp = expected if not isinstance(expected, (bytes, bytearray)) else bytes(expected).hex()
print(f" [{status}] {label}: {shown!r}" + ("" if ok else f" (expected {exp!r})"))
if not ok:
FAILURES.append(label)
def unframe(raw: bytes) -> bytes:
"""Strip and validate the 4-byte big-endian length prefix."""
assert len(raw) >= 4, "message shorter than its length prefix"
(declared,) = struct.unpack(">I", raw[:4])
check("length prefix matches payload", declared, len(raw) - 4)
return raw[4:]
def root_table(payload: bytes) -> Table:
pos = struct.unpack_from("<I", payload, 0)[0]
return Table(bytearray(payload), pos)
def field(tbl: Table, slot: int):
"""Return the vtable offset for a slot, or 0 when the field is absent."""
return tbl.Offset(slot * 2 + 4)
def read_u8(tbl: Table, slot: int, default=0):
o = field(tbl, slot)
return tbl.Get(N.Uint8Flags, o + tbl.Pos) if o else default
def read_i32(tbl: Table, slot: int, default=0):
o = field(tbl, slot)
return tbl.Get(N.Int32Flags, o + tbl.Pos) if o else default
def read_sub(tbl: Table, slot: int):
o = field(tbl, slot)
if not o:
return None
return Table(tbl.Bytes, tbl.Indirect(o + tbl.Pos))
def read_str(tbl: Table, slot: int):
o = field(tbl, slot)
return tbl.String(o + tbl.Pos).decode() if o else None
def read_bytes(tbl: Table, slot: int):
o = field(tbl, slot)
if not o:
return None
start = tbl.Vector(o)
length = tbl.VectorLen(o)
return bytes(tbl.Bytes[start : start + length])
def verify_register(raw: bytes):
print("Register message:")
req = root_table(unframe(raw))
check("Request.command_type", read_u8(req, 0), CMD_REGISTER)
reg = read_sub(req, 1)
assert reg is not None, "Request.command missing"
check("Register.origin", read_str(reg, 0), "lgtv-audio-cap")
check("Register.priority", read_i32(reg, 1), 150)
def verify_image(raw: bytes):
print("Image message:")
req = root_table(unframe(raw))
check("Request.command_type", read_u8(req, 0), CMD_IMAGE)
img = read_sub(req, 1)
assert img is not None, "Request.command missing"
check("Image.data_type", read_u8(img, 0), IMGTYPE_RAWIMAGE)
# duration defaults to -1 and is omitted from the buffer.
check("Image.duration (default)", read_i32(img, 2, default=-1), -1)
raw_img = read_sub(img, 1)
assert raw_img is not None, "Image.data missing"
check("RawImage.width", read_i32(raw_img, 1, default=-1), 4)
check("RawImage.height", read_i32(raw_img, 2, default=-1), 2)
expected = bytes(b for i in range(8) for b in (i * 10, i * 10 + 1, i * 10 + 2))
check("RawImage.data length", len(read_bytes(raw_img, 0) or b""), 24)
check("RawImage.data contents", read_bytes(raw_img, 0), expected)
def main():
repo = Path(__file__).resolve().parent.parent
sources = [
repo / "test" / "fb_dump.c",
repo / "native" / "src" / "net" / "hyperion.c",
repo / "native" / "src" / "net" / "flatbuf.c",
repo / "native" / "src" / "common" / "log.c",
]
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
binary = tmp / "fb_dump"
compile_cmd = ["cc", "-std=c11", "-Wall", "-Wextra", "-O1", "-o", str(binary)]
compile_cmd += [str(s) for s in sources]
print("$ " + " ".join(compile_cmd))
subprocess.run(compile_cmd, check=True)
for kind, verifier in (("register", verify_register), ("image", verify_image)):
out = tmp / f"{kind}.bin"
subprocess.run([str(binary), kind, str(out)], check=True)
verifier(out.read_bytes())
if FAILURES:
print(f"\n{len(FAILURES)} check(s) failed: {', '.join(FAILURES)}")
return 1
print("\nAll FlatBuffers checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Checks the TV's RTP sink against the host receiver.
The C sink writes a known ramp; this binds the port, collects the datagrams and
decodes them with the very functions host/lgtv-audiocap-receiver.py uses. If
the two ever disagree about the header layout or the sample byte order, the
ramp comes back wrong and this fails.
Also checks the SAP/SDP announcement, since PulseAudio's module-rtp-recv builds
its source purely from that text.
python3 test/verify_rtp.py [path-to-rtp_send]
"""
import os
import re
import socket
import struct
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, os.pardir, "host"))
# The receiver's filename is not an identifier, so load it by path.
try:
import importlib.util
spec = importlib.util.spec_from_file_location(
"audiocap_receiver",
os.path.join(HERE, os.pardir, "host", "lgtv-audiocap-receiver.py"))
receiver = importlib.util.module_from_spec(spec)
spec.loader.exec_module(receiver)
except Exception as exc: # pragma: no cover - only when the file is missing
print("cannot load the host receiver: %s" % exc)
sys.exit(1)
BLOCKS = 8
FRAMES = 512
CHANNELS = 2
RATE = 48000
checks = 0
failures = 0
def check(name, condition, detail=None):
global checks, failures
checks += 1
if condition:
print(" ok %s" % name)
else:
failures += 1
print(" FAIL %s%s" % (name, "" if detail is None else " — %s" % detail))
def eq(name, actual, expected):
check(name, actual == expected, "got %r, wanted %r" % (actual, expected))
def sample_at(index):
"""Must match sample_at() in test/rtp_send.c."""
value = (index * 251) % 65536 - 32768
return value
def main():
binary = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "rtp_send")
if not os.path.exists(binary):
print("build test/rtp_send.c first (run-tests.sh does it for you)")
return 1
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.settimeout(2.0)
proc = subprocess.run([binary, str(port), str(BLOCKS), str(FRAMES)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
print("rtp_send failed: %s" % proc.stderr.decode("utf-8", "replace"))
return 1
expected_samples = int(proc.stdout.decode().strip())
packets = []
try:
while True:
data, _ = sock.recvfrom(4096)
packets.append(data)
except socket.timeout:
pass
sock.close()
print("wire format")
check("packets arrived", len(packets) > 0, "%d packets" % len(packets))
if not packets:
return 1
first = receiver.parse_rtp(packets[0])
check("receiver parses the header", first is not None)
eq("payload type", first.payload_type, 96)
eq("version 2, no CSRCs, no extension", packets[0][0], 0x80)
eq("marker bit clear", packets[0][1] >> 7, 0)
# Every packet must stay inside a 1500-byte MTU with room for the IP and
# UDP headers, or the stream fragments and loss goes from bad to total.
largest = max(len(p) for p in packets)
check("no packet exceeds the MTU budget", largest <= 1472, "%d bytes" % largest)
print("sequencing")
parsed = [receiver.parse_rtp(p) for p in packets]
check("all packets parse", all(p is not None for p in parsed))
ssrcs = set(p.ssrc for p in parsed)
eq("one SSRC for the run", len(ssrcs), 1)
sequences = [p.sequence for p in parsed]
expected_sequences = [(sequences[0] + i) & 0xFFFF for i in range(len(sequences))]
eq("sequence numbers increment by one", sequences, expected_sequences)
frame_bytes = 2 * CHANNELS
stamps = [p.timestamp for p in parsed]
steps = set((stamps[i + 1] - stamps[i]) & 0xFFFFFFFF for i in range(len(stamps) - 1))
frames_per_packet = set(len(p.payload) // frame_bytes for p in parsed[:-1])
eq("timestamp advances by the frame count", steps, frames_per_packet)
print("payload")
pcm = b"".join(receiver.to_native_pcm(p.payload) for p in parsed)
samples = struct.unpack("<%dh" % (len(pcm) // 2), pcm)
eq("every sample arrived", len(samples), expected_samples)
wrong = [i for i, v in enumerate(samples) if v != sample_at(i)]
check("the ramp survives the round trip", not wrong,
"%d samples differ, first at %s" % (len(wrong), wrong[:1]))
# A wrong byte order still produces "audio", just noise; check explicitly
# that the payload really is big-endian on the wire.
raw_be = struct.unpack(">%dh" % (len(parsed[0].payload) // 2), parsed[0].payload)
eq("payload is big-endian on the wire", raw_be[0], sample_at(0))
print("SAP announcement")
sdp = capture_sap()
if sdp is None:
print(" SKIP: no announcement seen (multicast on loopback is often"
" blocked); the SDP text itself is unchecked")
else:
check("SDP names an L16 stream", "L16/%d/%d" % (RATE, CHANNELS) in sdp, sdp)
check("SDP carries a media line", re.search(r"m=audio \d+ RTP/AVP 96", sdp)
is not None, sdp)
check("SDP is recvonly", "a=recvonly" in sdp, sdp)
print("\n%d/%d checks passed" % (checks - failures, checks))
return 1 if failures else 0
def capture_sap(timeout=1.5):
"""Listens for one SAP announcement from a second, SAP-enabled run."""
binary = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "rtp_send")
sap = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sap.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sap.bind(("", 9875))
# Join on whichever interface the kernel picks: the announcement leaves
# by the default route, so that is where it can loop back from.
membership = socket.inet_aton("224.0.0.56") + struct.pack("=I", socket.INADDR_ANY)
sap.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
except OSError:
sap.close()
return None
sap.settimeout(timeout)
subprocess.run([binary, "9999", "2", "512", "sap"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
try:
data, _ = sap.recvfrom(2048)
except socket.timeout:
return None
finally:
sap.close()
# RFC 2974: 4-byte header, 4-byte source, NUL-terminated MIME type.
body = data[8:]
end = body.find(b"\x00")
return body[end + 1:].decode("utf-8", "replace") if end >= 0 else None
if __name__ == "__main__":
sys.exit(main())