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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user