Every existing HyperHDR route replaces whatever else is on the LEDs: routes 1/2 hand HyperHDR's own audio effect a device to read, route 3 sends a synthetic spectrum image, and both take over via HyperHDR's priority system. For a setup that already has a real colour source (a screen grabber, a USB capture card) feeding an ambilight-style LED run, none of that is what's wanted -- the colour should stay put and only brightness should react. Read HyperHDR's own source (sources/api/JSONRPC_schema/schema-adjustment.json) rather than guess: "adjustment" is a post-processing command with a scaleOutput parameter (0-2.0) that applies regardless of which priority is currently active. Confirmed the wire format too -- sources/jsonserver/JsonClientConnection.cpp frames it as plain newline-delimited JSON over TCP (default port 19444), nothing like the length-prefixed Flatbuffers protocol the visualiser sink speaks, and with no handshake or registration needed before the first write. sink_hyperhdr_adjust.c sends only that: no image, no priority, so it never competes with an existing grabber. Non-blocking connect with the same poll()+SO_ERROR pattern net/hyperion.c already uses, reconnects every 5s, rate-limited to 20 Hz (a HyperHDR command every audio block would be pointless flooding), and resets scaleOutput to 1.0 on close rather than leaving the LEDs stuck at whatever it last sent. Cross- compiles clean under -Wall -Wextra on the real webOS toolchain. Wired through the same path every other sink follows: registered in sink.c/sink.h, defaults in config.c, fields in frontend/js/app.js (SINK_FIELDS/SINK_HELP), mock.js and ui_smoke.js updated for the new sink card. Bumped to 1.0.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
8.0 KiB
JavaScript
217 lines
8.0 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,
|
|
},
|
|
hyperhdrAdjust: {
|
|
host: '', port: 19444, minScale: 0.3, maxScale: 1.3, level: 'rms',
|
|
},
|
|
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: 'hyperhdrAdjust', name: 'HyperHDR brightness (JSON-RPC)', description: 'Only adjusts brightness; colour stays with HyperHDR\'s own grabber.' },
|
|
{ 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);
|