The device-source parser assumed pactl list short sources is strictly tab-separated, true for stock PulseAudio but not guaranteed for a TV's own heavily customized audio stack (this one names sources tpcm_output/tpmedia/tptts/... — clearly not vanilla). A different separator would have silently produced zero parsed sources with no error, and the picker's own `when` guard would then just hide the row entirely rather than show anything broken. Split on any whitespace run instead of a literal tab; source names never contain embedded whitespace, so this is strictly more permissive with no new failure mode. Confirmed end to end on real hardware: tptts.monitor lit up during the accessibility voice guide and reached HyperHDR. Also: the System panel now shows the app's actual running version, read from a <meta> tag substituted at package time (tools/build.sh stage()) from frontend/appinfo.json — not hand-maintained, so it can't drift from what was actually built. Requested after a version bump alone wasn't enough to tell whether a reinstall had truly picked up new files versus served something cached along the way; this settles that question by inspection instead of by inference. Bumped to 1.0.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
7.8 KiB
JavaScript
213 lines
7.8 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'],
|
|
// Realistic shape: a TV that mixes several per-app sinks down to
|
|
// one common output, the case the device picker exists for.
|
|
// Space-padded, not tab-separated — some TVs' own pactl-alike
|
|
// formats it that way, and the parser has to tolerate both.
|
|
pactlSources: [
|
|
'0 tpcm_output.monitor module-combine-sink.c s16le 2ch 48000Hz RUNNING',
|
|
'1 tpmedia.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
|
|
'2 tpeffects.monitor module-alsa-card.c s16le 2ch 48000Hz IDLE',
|
|
'3 tptts.monitor module-alsa-card.c s16le 2ch 48000Hz SUSPENDED',
|
|
].join('\n'),
|
|
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);
|