Files
lgtv_audio_cap/frontend/js/mock.js
T
Rene KievitsandClaude Opus 5 d3e4cb6410 Restrict the brightness sink to one app, picked by name not typed
Confirmed the brightness command applies globally, not per-LED
(serverinfo showed exactly one adjustment object, "id": "default",
covering the whole string), so no LED-count configuration is needed
for this at all -- that question resolved itself once the mechanism
was actually inspected instead of assumed.

For "only react while Spotify is running": only one app can be in the
foreground on webOS at a time, so a "capture the current app" button
in this app's own UI can never work -- pressing it means this app is
foreground, not Spotify. The only workable UI is picking a target from
every *installed* app by name, regardless of what's currently running.

That needed a new native capability this service never had: calling
OUT to another Luna service, not just being called. Two additions:

  foreground_app.c   subscribes once, at startup, to
                      com.webos.applicationManager/getForegroundAppInfo
                      and keeps a thread-safe cache the audio thread can
                      read without a blocking Luna call
  service.c           new listApps method, bridging to
                      com.webos.applicationManager/listApps so the
                      frontend never has to call another service
                      directly -- same rule as everywhere else here

Until the subscription has delivered at least one reply, a restricted
sink treats the target app as inactive, not active -- reacting to
audio when the user explicitly restricted it to one app would be the
wrong failure mode. Verified end to end on the host: engine_smoke.c
opens the sink with a restriction set, confirms it reports itself
correctly inactive against the stub Luna bus (which always "fails" to
call out, exactly like a real host with no bus).

Needed real, linkable stub bodies for LSCall/LSCallOneReply/
LSCallCancel/LSMessageGetPayload/LSErrorInit/LSErrorFree
(test/stubs/luna-service2/lunaservice_stub.c) since foreground_app.c
is the first source file here that's actually linked into a host test
binary rather than only syntax-checked -- service.c/main.c's existing
stub declarations were never called, only compiled against. Confirmed
those really are the correct symbol names by cross-compiling clean
against the real webOS SDK's actual libluna-service2, not just the
stub.

Bumped to 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:46:09 +02:00

229 lines
8.4 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, minBrightness: 20, maxBrightness: 100, level: 'rms',
restrictToApp: '',
},
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.' },
],
});
case 'listApps':
return respond(onReply, {
returnValue: true,
apps: [
{ id: 'spotify-beehive', title: 'Spotify' },
{ id: 'netflix', title: 'Netflix' },
{ id: 'youtube.leanback.v4', title: 'YouTube' },
{ id: 'com.webos.app.livetv', title: 'Live TV' },
],
});
// 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);