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:
Rene Kievits
2026-08-26 10:21:00 +02:00
co-authored by Claude Opus 5
commit 7529a60650
73 changed files with 12826 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
// Luna bus access.
//
// Talks to PalmServiceBridge directly rather than pulling in webOSTV.js: it is
// the same object webOSTV.js wraps, it is injected into every webOS app, and
// it means there is no third-party file to keep in sync.
//
// Opened in a desktop browser the bridge is absent, so everything falls back
// to a small mock. That is what makes the UI developable without a TV.
(function (global) {
'use strict';
var SERVICE = 'luna://org.webosbrew.audiocap.service/';
var HBCHANNEL = 'luna://org.webosbrew.hbchannel.service/';
var haveBridge = typeof global.PalmServiceBridge !== 'undefined';
// A call in flight. `cancel()` tears down a subscription.
function Request(bridge) {
this.bridge = bridge;
this.cancelled = false;
}
Request.prototype.cancel = function () {
this.cancelled = true;
if (this.bridge && this.bridge.cancel) {
this.bridge.cancel();
}
};
// Low-level call. `onReply` fires once per reply, so subscriptions keep
// calling it until cancelled.
function call(uri, params, onReply, onError) {
if (!haveBridge) {
return global.LunaMock.call(uri, params, onReply, onError);
}
var bridge = new global.PalmServiceBridge();
var request = new Request(bridge);
bridge.onservicecallback = function (raw) {
if (request.cancelled) {
return;
}
var reply;
try {
reply = JSON.parse(raw);
} catch (e) {
if (onError) {
onError('Malformed reply from ' + uri);
}
return;
}
// Luna reports both its own failures and ours through returnValue.
if (reply.returnValue === false) {
if (onError) {
onError(reply.errorText || reply.errorMessage || 'Call to ' + uri + ' failed');
}
return;
}
if (onReply) {
onReply(reply);
}
};
try {
bridge.call(uri, JSON.stringify(params || {}));
} catch (e) {
if (onError) {
onError(String(e));
}
}
return request;
}
var Luna = {
available: haveBridge,
// --- our service -------------------------------------------------------
start: function (patch, ok, fail) {
return call(SERVICE + 'start', patch || {}, ok, fail);
},
stop: function (ok, fail) {
return call(SERVICE + 'stop', {}, ok, fail);
},
subscribeStatus: function (ok, fail) {
return call(SERVICE + 'getStatus', { subscribe: true }, ok, fail);
},
getConfig: function (ok, fail) {
return call(SERVICE + 'getConfig', {}, ok, fail);
},
setConfig: function (patch, ok, fail) {
return call(SERVICE + 'setConfig', { settings: patch }, ok, fail);
},
resetConfig: function (ok, fail) {
return call(SERVICE + 'resetConfig', {}, ok, fail);
},
listBackends: function (ok, fail) {
return call(SERVICE + 'listBackends', {}, ok, fail);
},
listSinks: function (ok, fail) {
return call(SERVICE + 'listSinks', {}, ok, fail);
},
getDiagnostics: function (ok, fail) {
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
},
// Ends the service process. Any later call starts a fresh one, which is
// how the service picks up new bus permissions after being elevated.
quit: function (ok, fail) {
return call(SERVICE + 'quit', {}, ok, fail);
},
// `clear` empties the ring buffer after reading it, so the reply is the
// last thing anyone sees of those lines.
getLogs: function (clear, ok, fail) {
return call(SERVICE + 'getLogs', { clear: !!clear }, ok, fail);
},
// --- Homebrew Channel --------------------------------------------------
// Used for the boot symlink. Needs the Homebrew Channel installed, which
// it will be on any TV that can run this app.
exec: function (command, ok, fail) {
return call(HBCHANNEL + 'exec', { command: command }, ok, fail);
},
// --- system ------------------------------------------------------------
getNetworkStatus: function (ok, fail) {
return call('luna://com.palm.connectionmanager/getStatus', {}, ok, fail);
},
};
global.Luna = Luna;
})(window);