pw-stick-output
Version:
TypeScript utility for managing PipeWire audio node connections and routing with systemd integration
464 lines • 16.9 kB
JavaScript
import _ from 'lodash';
import { exec, spawn } from 'node:child_process';
import notify from 'sd-notify';
import { log } from './log.js';
const controller = new AbortController();
export function pmExec(shell, options = {}) {
let cp;
const result = new Promise((resolve, reject) => {
cp = exec(shell, { timeout: 60, maxBuffer: 100 * 1024, signal: controller.signal, ...options }, (error, stdout, stderr) => {
if (error) {
log(`Error invoking ${shell}: ${stderr}`, 2);
reject(error);
return;
}
resolve(stdout);
});
});
result.cp = cp;
return result;
}
export function extractSimplePort(line) {
const [id, ...rest] = line.trim().split(' ');
const [partName, type] = rest.join(' ').split(':', 2);
const [prefix, ...name] = partName.split('.');
const nameWithDots = name.join('.');
const isPlayback = type ? type.startsWith('playback') : undefined;
const isOutput = type ? type.startsWith('output') : undefined;
const isMonitor = type ? type.startsWith('monitor') : undefined;
return {
id,
type,
name: nameWithDots ? nameWithDots : prefix,
prefix: nameWithDots ? prefix : undefined,
isPlayback,
isOutput,
isMonitor,
};
}
export function extractSimplePorts(output) {
const blocks = [];
for (const line of output.split('\n')) {
const normalLine = line.trim();
if (normalLine) {
blocks.push(extractSimplePort(line));
}
}
return blocks;
}
export function extractSimpleLinks(output) {
const links = {};
let group = undefined;
for (const wholeLine of output.split('\n')) {
const line = wholeLine.trim();
if (!line) {
continue;
}
const linkParseMatch = line.match(/^(\d+)\s+(\|->|\|<-)\s+(\d+.+)$/);
if (!linkParseMatch) {
group = extractSimplePort(line);
continue;
}
if (!group) {
log(`Failed to parse port group before line: ${line}`, 2);
continue;
}
const [_, linkId, linkType, portLine] = linkParseMatch;
const item = extractSimplePort(portLine);
const from = linkType === '|<-' ? item : group;
const to = linkType === '|->' ? item : group;
links[linkId] = {
id: linkId,
from,
to,
};
}
return links;
}
export function extractNodes(output) {
let lastBlock = null;
const blocks = [];
for (const line of output.split('\n')) {
const matchId = line.match(/^\s+id\s(\d+),\s+type\s(.+)/);
if (matchId) {
if (lastBlock)
blocks.push(lastBlock);
lastBlock = { id: matchId[1], type: matchId[2] };
continue;
}
if (!line.trim() || !lastBlock)
continue;
const [rawKey, rawVal] = line.split(' = ', 2);
const key = rawKey.trim();
const val = rawVal.replace(/^"(.+)"$/, (_m, v) => v);
lastBlock[key] = val;
}
if (lastBlock)
blocks.push(lastBlock);
return blocks;
}
export function findBlockIdBy(blocks, field, name) {
const found = blocks.find((block) => block[field] === name);
return found?.id;
}
export function filterBlocksBy(blocks, field, re) {
return blocks.filter((block) => block[field]?.match(re));
}
export function parseMonitoringBlock(text) {
const result = { _changedType: null };
const lines = text.split(/\r?\n/);
let insideArgs = false;
let argsBuffer = [];
let changedType = '';
for (let raw of lines) {
const line = raw.trimEnd();
if (!changedType) {
const matchAction = line.match(/^(\w+):\n?$/);
if (matchAction) {
changedType = matchAction[1];
result._changedType = changedType;
continue;
}
else {
result._changedType = null;
changedType = 'unknown';
}
}
if (insideArgs) {
// Look for the closing brace + quote, e.g. }"
if (line.endsWith('}"')) {
// Capture up to the closing brace
argsBuffer.push(line.slice(0, -1));
result.args = argsBuffer.join('\n');
insideArgs = false;
argsBuffer = [];
}
else {
argsBuffer.push(line);
}
continue;
}
// Split on first colon
const sep = line.indexOf(':');
if (sep < 0)
continue;
const key = line.slice(0, sep).trim();
let val = line.slice(sep + 1).trim();
if (key === 'args' && val.startsWith('"')) {
// Strip the opening quote
val = val.slice(1);
// If it also ends with a closing quote, single-line
if (val.endsWith('"')) {
result.args = val.slice(0, -1).trim();
}
else {
insideArgs = true;
argsBuffer.push(val);
}
}
else {
// Remove surrounding quotes if they exist
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1);
}
result[key] = val;
}
}
return result;
}
let monitorChildProcess = null;
export async function monitor(onBlock) {
if (monitorChildProcess) {
throw new Error(`Processing ${monitorChildProcess.spawnargs.join(' ')}: not cleaned`);
}
const cp = spawn('pw-mon', ['--no-colors', '--hide-props', '--hide-params', '--print-separator', '\n\n'], {
shell: false,
signal: controller.signal,
});
monitorChildProcess = cp;
let stderr = '';
let buffer = '';
let exitCalled = false;
const infinite = new Promise((res, rej) => {
cp.once('close', (exitCode) => {
exitCalled = true;
if (exitCode !== 0 || stderr) {
rej({ exitCode, stderr });
}
else {
res();
}
});
function onStop(error) {
setTimeout(() => {
if (exitCalled)
return;
if (typeof error === 'number') {
const exitCode = error;
if (exitCode !== 0 || stderr) {
rej({ exitCode, stderr });
}
else {
res();
}
}
else if (error instanceof Error) {
rej({ exitCode: cp.exitCode, stderr, error });
}
else if (cp.exitCode !== 0 || stderr) {
rej({ exitCode: cp.exitCode, stderr });
}
else {
res();
}
exitCalled = true;
}, error instanceof Error ? 10 : 100);
}
cp.once('error', onStop);
cp.once('exit', onStop);
cp.once('disconnect', onStop);
cp.once('spawn', notify.ready);
});
cp.stderr.on('data', (data) => {
stderr += data.toString();
});
cp.stdout.on('data', (data) => {
buffer += data.toString();
const maybeFullBlocks = buffer.split('\n\n');
while (maybeFullBlocks.length > 1) {
setTimeout(onBlock, 10, parseMonitoringBlock(maybeFullBlocks.shift()));
}
buffer = maybeFullBlocks[0];
});
return infinite;
}
function normalizePortName(simplePort, mode = 'simple') {
const { prefix, type, name, id } = simplePort;
const simple = `${prefix ? prefix + '.' : ''}${name}:${type}`;
if (mode === 'simple') {
return simple;
}
if (mode === 'human') {
return id.toString().padStart(3, ' ') + ' ' + simple;
}
return id.toString() + ':' + simple;
}
async function watchChanges(stickConfig, watchedObjects) {
const currentAllLinks = {};
for (const id in watchedObjects) {
const meta = watchedObjects[id];
if (!('port' in meta)) {
continue;
}
const simpleName = normalizePortName(meta.simple, 'simple');
currentAllLinks[simpleName] = currentAllLinks[simpleName] || {};
currentAllLinks[simpleName].from = meta;
currentAllLinks[simpleName].from.humanName = normalizePortName(meta.simple, 'human');
currentAllLinks[simpleName].to = currentAllLinks[simpleName].to || new Set();
}
for (const id in watchedObjects) {
const meta = watchedObjects[id];
if (!('simpleLink' in meta)) {
continue;
}
const fromSimpleName = normalizePortName(meta.simpleLink.from, 'simple');
const humanName = normalizePortName(meta.simpleLink.to, 'human');
const humanLinkName = id.padStart(3, ' ') + ' |-> ' + humanName;
const currentLink = currentAllLinks[fromSimpleName];
const to = meta;
to.humanName = humanName;
to.humanLinkName = humanLinkName;
currentLink.to.add(to);
}
const debugArraay = Object.values(currentAllLinks);
debugArraay.sort((a, b) => a.from.humanName.localeCompare(b.from.humanName));
const toLines = debugArraay.map(({ from, to }) => from.humanName +
'\n' +
Array.from(to)
.map((o) => o.humanLinkName)
.sort()
.join('\n'));
const diffs = [];
for (const config of stickConfig) {
const portLinks = currentAllLinks[config.findByid];
if (!portLinks) {
log(`port not found. skip diff, port: ${config.findByid}`, 1);
continue;
}
if (config.ignoreMonitors) {
for (const link of portLinks.to) {
if (link.simpleLink.to.isMonitor === true) {
portLinks.to.delete(link);
}
}
}
if (config.ignore && config.ignore['object.path'] && config.ignore['object.path'].match) {
const { match } = config.ignore['object.path'];
for (const link of portLinks.to) {
const objectPath = link.toPort['object.path'];
if (!objectPath) {
continue;
}
// todo AND filter
if (objectPath.match(match)) {
portLinks.to.delete(link);
}
}
}
const currentDiff = {
from: normalizePortName(portLinks.from.simple, 'machine'),
add: [],
rm: [],
};
diffs.push(currentDiff);
// to remove
const stickToSimpleNames = new Set(config.stickTo.map((c) => c.findByid));
for (const to of portLinks.to) {
if (!stickToSimpleNames.has(normalizePortName(to.simpleLink.to, 'simple'))) {
currentDiff.rm.push(normalizePortName(to.simpleLink.to, 'machine'));
}
}
// to add
for (const { findByid } of config.stickTo) {
let existTo = false;
for (const to of portLinks.to) {
if (normalizePortName(to.simpleLink.to, 'simple') === findByid) {
existTo = true;
break;
}
}
if (existTo) {
continue;
}
const portMeta = Object.values(watchedObjects).find((o) => 'port' in o && o.simple && normalizePortName(o.simple, 'simple') === findByid);
if (!portMeta) {
log(`port to add not found. not going to remove anythin, port: ${config.findByid} , to port: ${findByid}.`, 1);
currentDiff.rm = [];
continue;
}
currentDiff.add.push(normalizePortName(portMeta.simple, 'machine'));
}
}
const filteredDiffs = diffs.filter((d) => d.add.length > 0 || d.rm.length > 0);
if (filteredDiffs.length > 0) {
// log('current links:\n' + toLines.join('\n'), 0);
log('diff to apply:\n' + JSON.stringify(diffs, null, 2), 0);
}
for (const diffBatch of filteredDiffs) {
try {
await Promise.all(diffBatch.add.map((second) => pmExec(`pw-link --passive --wait --verbose "${diffBatch.from}" "${second}"`)));
await Promise.all(diffBatch.rm.map((second) => pmExec(`pw-link --wait -d --verbose "${diffBatch.from}" "${second}"`)));
}
catch (e) {
log(e, 1);
}
}
}
async function startInternal(stickConfig) {
const watchedObjects = {};
async function processBatch(changedBatch) {
if (!changedBatch.length)
return;
for (let monBlock of changedBatch) {
if (!monBlock._changedType || !monBlock.id)
continue;
if ('added' === monBlock._changedType) {
watchedObjects[monBlock.id] = watchedObjects[monBlock.id] || { _changedType: 'added' };
}
else if ('changed' === monBlock._changedType) {
if (monBlock.id in watchedObjects) {
watchedObjects[monBlock.id]._changedType = 'changed';
}
else {
watchedObjects[monBlock.id] = { _changedType: 'added' };
}
}
else if ('removed' === monBlock._changedType) {
delete watchedObjects[monBlock.id];
}
}
const [outputNodes, outputPorts, outputSimplePorts, outputSimpleLinks, outputDevice, outputModules] = await Promise.all([
pmExec('pw-cli list-objects Node'),
pmExec('pw-cli list-objects Port'),
pmExec('pw-link --output --input --id'),
pmExec('pw-link --links --id'),
pmExec('pw-cli list-objects Device'),
pmExec('pw-cli list-objects Module'),
]);
const modules = extractNodes(outputModules);
for (const module of modules) {
watchedObjects[module.id] = { _changedType: null, _updatedAt: new Date(), module };
}
const devices = extractNodes(outputDevice);
for (const device of devices) {
watchedObjects[device.id] = { _changedType: null, _updatedAt: new Date(), device };
}
const nodes = extractNodes(outputNodes);
for (const node of nodes) {
watchedObjects[node.id] = { _changedType: null, _updatedAt: new Date(), node };
}
const ports = extractNodes(outputPorts);
const simplePorts = extractSimplePorts(outputSimplePorts);
for (const port of ports) {
watchedObjects[port.id] = {
_changedType: null,
_updatedAt: new Date(),
port,
simple: simplePorts.find(({ id }) => id === port.id),
};
}
const simpleLinks = extractSimpleLinks(outputSimpleLinks);
for (const linkId in simpleLinks) {
const link = simpleLinks[linkId];
watchedObjects[link.id] = {
_changedType: null,
_updatedAt: new Date(),
simpleLink: link,
toPort: watchedObjects[link.to.id].port,
fromPort: watchedObjects[link.from.id].port,
};
}
await watchChanges(stickConfig, watchedObjects);
}
let lastRunProcessBatch = Promise.resolve();
let newBatch = [];
let lastBatch = [];
async function sequenceProcessBatch() {
await lastRunProcessBatch;
lastBatch = newBatch;
newBatch = [];
lastRunProcessBatch = processBatch(lastBatch).catch((error) => {
log(error, 2);
newBatch = lastBatch.concat(newBatch);
});
}
const debouncedProcessBatch = _.debounce(sequenceProcessBatch, 1000);
const pingAlive = _.throttle(() => log('Alive', 0), 60000);
await monitor((monBlock) => {
newBatch.push(monBlock);
debouncedProcessBatch();
pingAlive();
});
}
export default function start(stickConfig) {
function onExit() {
log('Exiting...', 0);
controller.abort('exiting');
}
process.on('SIGINT', onExit);
process.on('SIGTERM', onExit);
return startInternal(stickConfig).then(() => {
log('Shutdown complete.', 1);
process.exit(0);
}, (err) => {
// If it was the user-initiated shutdown, exit cleanly:
if (err && err.name === 'AbortError') {
log('Shutdown complete.', 1);
process.exit(0);
}
// Otherwise it’s some other failure—rethrow / crash:
log(err, 3);
process.exit(1);
});
}
//# sourceMappingURL=findModuleByName.js.map