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
+195
View File
@@ -0,0 +1,195 @@
// Form controls built for a remote control.
//
// Everything is a button. Dropdowns and checkboxes are miserable to operate
// with a D-pad, so a choice cycles through its values on Enter and a toggle
// flips. Text fields are the one exception: focusing one and pressing Enter
// brings up the TV's on-screen keyboard, which is the only way to type.
(function (global) {
'use strict';
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined && text !== null) {
node.textContent = String(text);
}
return node;
}
// Reads "hyperhdr.host" out of a settings object.
function get(obj, path) {
var parts = path.split('.');
var cur = obj;
for (var i = 0; i < parts.length; i++) {
if (cur === null || cur === undefined) {
return undefined;
}
cur = cur[parts[i]];
}
return cur;
}
// Builds { hyperhdr: { host: value } } so setConfig only carries the change.
function patchFor(path, value) {
var parts = path.split('.');
var root = {};
var cur = root;
for (var i = 0; i < parts.length - 1; i++) {
cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
return root;
}
function row(label, hint, control) {
var wrap = el('div', 'field');
var text = el('div', 'field-text');
text.appendChild(el('div', 'field-label', label));
if (hint) {
text.appendChild(el('div', 'field-hint', hint));
}
wrap.appendChild(text);
var holder = el('div', 'field-control');
holder.appendChild(control);
wrap.appendChild(holder);
return wrap;
}
function button(label, onClick, extraClass) {
var b = el('button', 'focusable btn' + (extraClass ? ' ' + extraClass : ''), label);
b.type = 'button';
b.addEventListener('click', onClick);
return b;
}
function toggle(value, onChange) {
var b = el('button', 'focusable btn toggle');
b.type = 'button';
function paint(v) {
b.textContent = v ? 'On' : 'Off';
b.classList.toggle('on', !!v);
b.setAttribute('aria-pressed', v ? 'true' : 'false');
}
paint(value);
b.addEventListener('click', function () {
value = !value;
paint(value);
onChange(value);
});
b.setValue = paint;
return b;
}
// `options` is [{ value, label }].
function choice(options, value, onChange) {
var b = el('button', 'focusable btn choice');
b.type = 'button';
function indexOf(v) {
for (var i = 0; i < options.length; i++) {
if (options[i].value === v) {
return i;
}
}
return 0;
}
var index = indexOf(value);
function paint() {
b.textContent = options.length ? options[index].label : '—';
b.title = options.length ? String(options[index].value) : '';
}
paint();
b.addEventListener('click', function () {
if (!options.length) {
return;
}
index = (index + 1) % options.length;
paint();
onChange(options[index].value);
});
b.setValue = function (v) {
index = indexOf(v);
paint();
};
b.setOptions = function (list, v) {
options = list;
index = indexOf(v);
paint();
};
return b;
}
function text(value, onChange, opts) {
opts = opts || {};
var input = el('input', 'focusable input');
input.type = 'text';
input.value = value === undefined || value === null ? '' : String(value);
if (opts.placeholder) {
input.placeholder = opts.placeholder;
}
if (opts.numeric) {
input.inputMode = 'numeric';
}
function commit() {
var raw = input.value.trim();
onChange(opts.numeric ? (raw === '' ? 0 : parseFloat(raw)) : raw);
}
input.addEventListener('change', commit);
input.addEventListener('blur', commit);
input.addEventListener('keydown', function (e) {
// Let the arrow keys move the caret inside a focused field, but hand Up
// and Down back to the navigator so the user can leave it.
if (e.keyCode === 37 || e.keyCode === 39) {
e.stopPropagation();
}
if (e.keyCode === 13) {
commit();
}
});
input.setValue = function (v) {
input.value = v === undefined || v === null ? '' : String(v);
};
return input;
}
// A horizontal bar, 0..1.
function bar(className) {
var outer = el('div', 'bar ' + (className || ''));
var fill = el('div', 'bar-fill');
outer.appendChild(fill);
outer.setValue = function (v) {
var pct = Math.max(0, Math.min(1, v)) * 100;
fill.style.width = pct.toFixed(1) + '%';
};
return outer;
}
function clear(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
global.UI = {
el: el,
get: get,
patchFor: patchFor,
row: row,
button: button,
toggle: toggle,
choice: choice,
text: text,
bar: bar,
clear: clear,
};
})(window);