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>
275 lines
7.8 KiB
C
275 lines
7.8 KiB
C
// 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;
|
|
}
|