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>
220 lines
6.1 KiB
C
220 lines
6.1 KiB
C
// HTTP audio stream served by the TV.
|
|
//
|
|
// The friendliest sink to test with, because everything already speaks HTTP:
|
|
//
|
|
// vlc http://<tv-ip>:4012/audio.wav
|
|
// ffplay http://<tv-ip>:4012/audio.wav
|
|
// mpv http://<tv-ip>:4012/audio.wav
|
|
//
|
|
// The WAV header declares an unknown length (0xFFFFFFFF sizes), which is the
|
|
// usual convention for endless streams and what every player above expects.
|
|
// Request /audio.raw instead to get headerless S16LE, for the odd consumer
|
|
// that would rather be told the format out of band.
|
|
|
|
#include "sink.h"
|
|
#include "../common/log.h"
|
|
#include "../net/streamserv.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#define WAV_HEADER_BYTES 44
|
|
#define WAV_UNKNOWN_SIZE 0xFFFFFFFFu
|
|
|
|
typedef struct {
|
|
streamserv_t* server;
|
|
int port;
|
|
audio_format_t fmt;
|
|
} http_priv_t;
|
|
|
|
static void put_u32le(uint8_t* p, uint32_t v)
|
|
{
|
|
p[0] = (uint8_t)(v & 0xFF);
|
|
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
|
p[2] = (uint8_t)((v >> 16) & 0xFF);
|
|
p[3] = (uint8_t)((v >> 24) & 0xFF);
|
|
}
|
|
|
|
static void put_u16le(uint8_t* p, uint16_t v)
|
|
{
|
|
p[0] = (uint8_t)(v & 0xFF);
|
|
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
|
}
|
|
|
|
static void write_wav_header(uint8_t* h, const audio_format_t* fmt)
|
|
{
|
|
const uint16_t bits = 16;
|
|
const uint16_t channels = (uint16_t)fmt->channels;
|
|
const uint32_t rate = (uint32_t)fmt->rate;
|
|
const uint16_t block_align = (uint16_t)(channels * (bits / 8));
|
|
|
|
memcpy(h + 0, "RIFF", 4);
|
|
put_u32le(h + 4, WAV_UNKNOWN_SIZE);
|
|
memcpy(h + 8, "WAVE", 4);
|
|
|
|
memcpy(h + 12, "fmt ", 4);
|
|
put_u32le(h + 16, 16); // PCM fmt chunk length
|
|
put_u16le(h + 20, 1); // WAVE_FORMAT_PCM
|
|
put_u16le(h + 22, channels);
|
|
put_u32le(h + 24, rate);
|
|
put_u32le(h + 28, rate * block_align); // byte rate
|
|
put_u16le(h + 32, block_align);
|
|
put_u16le(h + 34, bits);
|
|
|
|
memcpy(h + 36, "data", 4);
|
|
put_u32le(h + 40, WAV_UNKNOWN_SIZE);
|
|
}
|
|
|
|
// Extracts the path from "GET /audio.wav HTTP/1.1". Returns false for anything
|
|
// that is not a GET, so the server drops the connection.
|
|
static bool parse_request(const char* request, char* path, size_t pathlen)
|
|
{
|
|
if (!request)
|
|
return false;
|
|
if (strncmp(request, "GET ", 4) != 0)
|
|
return false;
|
|
|
|
const char* p = request + 4;
|
|
while (*p == ' ')
|
|
p++;
|
|
|
|
size_t n = 0;
|
|
while (p[n] && p[n] != ' ' && p[n] != '\r' && p[n] != '\n' && n < pathlen - 1)
|
|
n++;
|
|
memcpy(path, p, n);
|
|
path[n] = '\0';
|
|
return n > 0;
|
|
}
|
|
|
|
static bool http_hello(void* user, const char* request, char** out, size_t* out_len)
|
|
{
|
|
http_priv_t* p = user;
|
|
|
|
char path[256];
|
|
if (!parse_request(request, path, sizeof(path))) {
|
|
DBG("HTTP sink: rejecting non-GET request");
|
|
return false;
|
|
}
|
|
|
|
// Browsers probe for these; answering them with an audio stream is worse
|
|
// than refusing outright.
|
|
if (strcmp(path, "/favicon.ico") == 0 || strcmp(path, "/robots.txt") == 0)
|
|
return false;
|
|
|
|
bool raw = strstr(path, ".raw") != NULL || strstr(path, ".pcm") != NULL;
|
|
|
|
char headers[512];
|
|
int hlen = snprintf(headers, sizeof(headers),
|
|
"HTTP/1.0 200 OK\r\n"
|
|
"Content-Type: %s\r\n"
|
|
"Cache-Control: no-cache, no-store\r\n"
|
|
"Pragma: no-cache\r\n"
|
|
"Access-Control-Allow-Origin: *\r\n"
|
|
"Connection: close\r\n"
|
|
"\r\n",
|
|
raw ? "application/octet-stream" : "audio/wav");
|
|
if (hlen < 0 || hlen >= (int)sizeof(headers))
|
|
return false;
|
|
|
|
size_t total = (size_t)hlen + (raw ? 0 : WAV_HEADER_BYTES);
|
|
uint8_t* buf = malloc(total);
|
|
if (!buf)
|
|
return false;
|
|
|
|
memcpy(buf, headers, (size_t)hlen);
|
|
if (!raw)
|
|
write_wav_header(buf + hlen, &p->fmt);
|
|
|
|
INFO("HTTP sink: client requested %s (%s)", path, raw ? "raw S16LE" : "WAV");
|
|
*out = (char*)buf;
|
|
*out_len = total;
|
|
return true;
|
|
}
|
|
|
|
static void http_write(sink_t* s, const int16_t* pcm, int frames, const dsp_levels_t* levels)
|
|
{
|
|
(void)levels;
|
|
http_priv_t* p = s->priv;
|
|
streamserv_broadcast(p->server, pcm, (size_t)frames * (size_t)audio_frame_bytes(&s->fmt));
|
|
}
|
|
|
|
static void http_status(sink_t* s, json_writer_t* w)
|
|
{
|
|
http_priv_t* p = s->priv;
|
|
jw_int(w, "port", p->port);
|
|
jw_int(w, "clients", streamserv_client_count(p->server));
|
|
jw_int(w, "droppedBytes", (long long)streamserv_dropped_bytes(p->server));
|
|
jw_str(w, "wavPath", "/audio.wav");
|
|
jw_str(w, "rawPath", "/audio.raw");
|
|
}
|
|
|
|
static void http_close(sink_t* s)
|
|
{
|
|
http_priv_t* p = s->priv;
|
|
if (p) {
|
|
streamserv_stop(p->server);
|
|
free(p);
|
|
}
|
|
free(s);
|
|
}
|
|
|
|
static sink_t* http_open(const json_value_t* cfg, const audio_format_t* fmt, char* err, size_t errlen)
|
|
{
|
|
const json_value_t* sc = json_get(cfg, "http");
|
|
int port = json_int(sc, "port", 4012);
|
|
if (port <= 0 || port > 65535) {
|
|
snprintf(err, errlen, "invalid HTTP port %d", port);
|
|
return NULL;
|
|
}
|
|
|
|
http_priv_t* p = calloc(1, sizeof(*p));
|
|
sink_t* s = calloc(1, sizeof(*s));
|
|
if (!p || !s) {
|
|
free(p);
|
|
free(s);
|
|
snprintf(err, errlen, "out of memory");
|
|
return NULL;
|
|
}
|
|
|
|
p->port = port;
|
|
p->fmt = *fmt;
|
|
|
|
// Players buffer ahead; give them a couple of seconds of slack before we
|
|
// start dropping.
|
|
size_t buffer = (size_t)fmt->rate * (size_t)audio_frame_bytes(fmt) * 2;
|
|
|
|
streamserv_config_t scfg = {
|
|
.port = port,
|
|
.client_buffer = buffer,
|
|
.max_clients = json_int(sc, "maxClients", 4),
|
|
.http_mode = true,
|
|
.user = p,
|
|
.hello = http_hello,
|
|
};
|
|
|
|
p->server = streamserv_start(&scfg, err, errlen);
|
|
if (!p->server) {
|
|
free(p);
|
|
free(s);
|
|
return NULL;
|
|
}
|
|
|
|
s->driver = &sink_driver_http;
|
|
s->priv = p;
|
|
s->fmt = *fmt;
|
|
s->write = http_write;
|
|
s->status = http_status;
|
|
s->close = http_close;
|
|
|
|
INFO("HTTP sink: http://<tv-ip>:%d/audio.wav (%d Hz, %d ch)", port, fmt->rate, fmt->channels);
|
|
return s;
|
|
}
|
|
|
|
const sink_driver_t sink_driver_http = {
|
|
.id = "http",
|
|
.name = "HTTP WAV stream",
|
|
.description = "Open http://<tv-ip>:4012/audio.wav in VLC, ffplay or mpv. Easiest way to confirm capture works.",
|
|
.open = http_open,
|
|
};
|