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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+89
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user