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,203 @@
|
||||
// Stand-in for the Luna bus so the UI can be opened in a desktop browser.
|
||||
// Only loaded when PalmServiceBridge is missing, which never happens on a TV.
|
||||
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var settings = {
|
||||
autoStart: false,
|
||||
logLevel: 'info',
|
||||
capture: { backend: 'auto', device: '', server: '', command: '', rate: 48000, channels: 2 },
|
||||
dsp: { attack: 0.6, release: 0.12 },
|
||||
sinks: ['hyperhdr'],
|
||||
hyperhdr: { host: '192.168.1.50', port: 5004, multicast: false, multicastTtl: 4, sapAnnounce: true },
|
||||
hyperhdrViz: {
|
||||
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
|
||||
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02,
|
||||
},
|
||||
udp: { host: '', port: 4010, multicastTtl: 4 },
|
||||
tcp: { port: 4011, maxClients: 4 },
|
||||
http: { port: 4012, maxClients: 4 },
|
||||
};
|
||||
|
||||
var running = false;
|
||||
var subscribers = [];
|
||||
var startedAt = 0;
|
||||
|
||||
function merge(base, patch) {
|
||||
Object.keys(patch).forEach(function (k) {
|
||||
if (patch[k] && typeof patch[k] === 'object' && !Array.isArray(patch[k])
|
||||
&& base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
|
||||
merge(base[k], patch[k]);
|
||||
} else {
|
||||
base[k] = patch[k];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function status(subscribed) {
|
||||
var t = Date.now() / 1000;
|
||||
var bands = [];
|
||||
for (var i = 0; i < 16; i++) {
|
||||
var v = running ? Math.abs(Math.sin(t * (1 + i * 0.25) + i)) * (1 - i / 24) : 0;
|
||||
bands.push(Math.max(0, Math.min(1, v)));
|
||||
}
|
||||
var peak = running ? 0.4 + 0.4 * Math.abs(Math.sin(t * 2)) : 0;
|
||||
|
||||
return {
|
||||
returnValue: true,
|
||||
subscribed: !!subscribed,
|
||||
state: running ? 'running' : 'stopped',
|
||||
running: running,
|
||||
error: null,
|
||||
capture: {
|
||||
backend: running ? 'pulse' : null,
|
||||
backendName: running ? 'PulseAudio (mock)' : null,
|
||||
device: '@DEFAULT_MONITOR@',
|
||||
rate: 48000,
|
||||
channels: 2,
|
||||
frames: running ? Math.round((Date.now() - startedAt) * 48) : 0,
|
||||
blocks: running ? Math.round((Date.now() - startedAt) / 10.7) : 0,
|
||||
timeouts: 0,
|
||||
uptimeMs: running ? Date.now() - startedAt : 0,
|
||||
},
|
||||
levels: {
|
||||
peak: peak,
|
||||
rms: peak * 0.6,
|
||||
peakDb: peak > 0 ? 20 * Math.log(peak) / Math.LN10 : -90,
|
||||
rmsDb: peak > 0 ? 20 * Math.log(peak * 0.6) / Math.LN10 : -90,
|
||||
clipping: peak > 0.98,
|
||||
bands: bands,
|
||||
},
|
||||
sinks: running ? settings.sinks.map(function (id) {
|
||||
return { id: id, ok: true, name: id, error: null, target: settings.hyperhdr.host, port: settings.hyperhdr.port, packetsSent: 1234 };
|
||||
}) : [],
|
||||
configPath: '/var/lib/webosbrew/audiocap/config.json',
|
||||
configPersistent: true,
|
||||
};
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
subscribers.forEach(function (s) {
|
||||
if (!s.cancelled) {
|
||||
s.onReply(status(true));
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
|
||||
function respond(onReply, payload) {
|
||||
setTimeout(function () { onReply(payload); }, 30);
|
||||
return { cancel: function () {} };
|
||||
}
|
||||
|
||||
var LunaMock = {
|
||||
call: function (uri, params, onReply, onError) {
|
||||
var method = uri.split('/').pop();
|
||||
|
||||
switch (method) {
|
||||
case 'start':
|
||||
if (params && Object.keys(params).length) { merge(settings, params); }
|
||||
running = true;
|
||||
startedAt = Date.now();
|
||||
return respond(onReply, status(false));
|
||||
|
||||
case 'stop':
|
||||
running = false;
|
||||
return respond(onReply, status(false));
|
||||
|
||||
case 'getStatus': {
|
||||
var sub = { cancelled: false, onReply: onReply };
|
||||
if (params && params.subscribe) {
|
||||
subscribers.push(sub);
|
||||
}
|
||||
setTimeout(function () { onReply(status(!!(params && params.subscribe))); }, 30);
|
||||
return { cancel: function () { sub.cancelled = true; } };
|
||||
}
|
||||
|
||||
case 'getConfig':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
path: '/var/lib/webosbrew/audiocap/config.json',
|
||||
persistent: true,
|
||||
settings: JSON.parse(JSON.stringify(settings)),
|
||||
});
|
||||
|
||||
case 'setConfig':
|
||||
merge(settings, params.settings || params);
|
||||
return respond(onReply, {
|
||||
returnValue: true, saved: true, restartRequired: running,
|
||||
settings: JSON.parse(JSON.stringify(settings)),
|
||||
});
|
||||
|
||||
case 'listBackends':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
backends: [
|
||||
{ id: 'pulse', name: 'PulseAudio', description: 'Records a PulseAudio monitor source.', available: true },
|
||||
{ id: 'alsa', name: 'ALSA', description: 'Records from an ALSA capture PCM.', available: true },
|
||||
{ id: 'exec', name: 'External command', description: 'Reads raw PCM from a command you supply.', available: true },
|
||||
{ id: 'tone', name: 'Test tone', description: 'Synthesised sweep, for testing the transport.', available: true },
|
||||
],
|
||||
});
|
||||
|
||||
case 'listSinks':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
sinks: [
|
||||
{ id: 'hyperhdr', name: 'HyperHDR audio (RTP)', description: 'RTP/L16 audio to the HyperHDR host.' },
|
||||
{ id: 'hyperhdrViz', name: 'HyperHDR visualiser', description: 'Renders on the TV, sends images. No host setup.' },
|
||||
{ id: 'udp', name: 'Raw PCM over UDP', description: 'Fire-and-forget S16LE datagrams.' },
|
||||
{ id: 'tcp', name: 'Raw PCM over TCP', description: 'The TV listens; connect to pull audio.' },
|
||||
{ id: 'http', name: 'HTTP WAV stream', description: 'Open the URL in VLC.' },
|
||||
],
|
||||
});
|
||||
|
||||
// Same shape as capture_write_diagnostics(): backends at the top level,
|
||||
// everything about the machine under "system".
|
||||
case 'getDiagnostics':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
backends: [
|
||||
{ id: 'pulse', name: 'PulseAudio monitor', available: true },
|
||||
{ id: 'alsa', name: 'ALSA PCM', available: true },
|
||||
{ id: 'exec', name: 'External command', available: true },
|
||||
{ id: 'tone', name: 'Test tone', available: true },
|
||||
],
|
||||
system: {
|
||||
root: true,
|
||||
uid: 0,
|
||||
libraries: {
|
||||
'libpulse.so.0': '/usr/lib/libpulse.so.0',
|
||||
'libpulse-simple.so.0': null,
|
||||
'libasound.so.2': '/usr/lib/libasound.so.2',
|
||||
},
|
||||
binaries: { parec: false, pactl: true, pacat: false, arecord: true },
|
||||
pulseSockets: ['/var/run/pulse/native'],
|
||||
pactlSources: 'mock output',
|
||||
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
|
||||
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
|
||||
},
|
||||
});
|
||||
|
||||
case 'getLogs':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
logs: '[info] running in the browser mock\n[info] no TV attached\n',
|
||||
});
|
||||
|
||||
case 'quit':
|
||||
running = false;
|
||||
return respond(onReply, { returnValue: true });
|
||||
|
||||
case 'exec':
|
||||
return respond(onReply, { returnValue: true, stdoutString: '' });
|
||||
|
||||
default:
|
||||
if (onError) { onError('mock: unknown method ' + method); }
|
||||
return { cancel: function () {} };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
global.LunaMock = LunaMock;
|
||||
})(window);
|
||||
Reference in New Issue
Block a user