Files
lgtv_audio_cap/frontend/js/mock.js
T
Rene KievitsandClaude Opus 5 7529a60650 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>
2026-08-26 10:21:00 +02:00

204 lines
7.1 KiB
JavaScript

// 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);