Merge branch 'sidecars-music' into sidecars

This commit is contained in:
2026-07-30 05:43:01 +00:00
11 changed files with 491 additions and 352 deletions
+9
View File
@@ -0,0 +1,9 @@
pcm.!default {
type pulse
fallback "sysdefault"
}
ctl.!default {
type pulse
fallback "sysdefault"
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'bun:test';
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
// The player socket refuses a path before it spawns anything, so these two cases exercise the whole
// server → handler → frame path without starting cliamp. Anything that would actually play needs a real
// file and a real audio sink, so it is not tested here.
function serveOnce() {
const server = Bun.serve({
port: 0,
hostname: '127.0.0.1',
fetch(req, srv) {
const url = new URL(req.url);
const data = cliampUpgradeData(url.pathname, url.searchParams);
if (data && srv.upgrade(req, { data })) return undefined as unknown as Response;
return new Response('nope', { status: 400 });
},
websocket: musicWebsocket,
});
return server;
}
function firstFrame(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => reject(new Error('no frame')), 3000);
ws.addEventListener('message', (ev) => {
clearTimeout(timer);
ws.close();
resolve(String(ev.data));
});
ws.addEventListener('error', () => {
clearTimeout(timer);
reject(new Error('socket error'));
});
});
}
describe('cliamp player socket', () => {
it('rejects a path that escapes the owner home', async () => {
const server = serveOnce();
try {
const frame = await firstFrame(`ws://127.0.0.1:${server.port}/cliamp/ws?files=../../etc/passwd`);
expect(JSON.parse(frame)).toEqual({ type: 'output', data: '\r\n[Error] Invalid file path.\r\n' });
} finally {
server.stop(true);
}
});
it('reports a missing files param instead of spawning', async () => {
const server = serveOnce();
try {
const frame = await firstFrame(`ws://127.0.0.1:${server.port}/cliamp/ws`);
expect(JSON.parse(frame)).toEqual({ type: 'output', data: '\r\n[Error] No files specified.\r\n' });
} finally {
server.stop(true);
}
});
it('routes only the two cliamp paths', () => {
const q = new URLSearchParams();
expect(cliampUpgradeData('/cliamp/ws', q)).toEqual({ kind: 'player', files: '' });
expect(cliampUpgradeData('/cliamp/audio/ws', q)).toEqual({ kind: 'capture' });
expect(cliampUpgradeData('/stream', q)).toBeNull();
});
});
+232
View File
@@ -0,0 +1,232 @@
import type { ServerWebSocket } from 'bun';
import { spawn, type Subprocess } from 'bun';
import { homedir } from 'node:os';
import { join, normalize, resolve, sep } from 'node:path';
import { VIRTUAL_SINK } from './pulse-audio';
// Local playback, both halves of it, owned by the process that owns the audio pipeline:
//
// /cliamp/ws — runs the `cliamp` TUI player against a file and pipes its terminal both ways
// /cliamp/audio/ws — captures what the sink hears and streams it to the browser as raw PCM
//
// Officer relays these two sockets and nothing else: it authenticates the browser and forwards frames.
// Every fact below — where the binary is, what a legal path is, which sink to play into, the ALSA config,
// the capture format — is pipeline knowledge and stays here. The frame shapes are the browser's contract
// ({type:'output'|'exit'} / {type:'input'} as JSON text, PCM as binary), so they are unchanged by the move.
//
// Both sockets are loopback-only, like the rest of this server: officer is the only client.
const ASOUNDRC_PATH = join(import.meta.dir, 'asoundrc');
// Single super user, so the owner's home is the root every path is resolved against — same convention as
// stream-audio.ts and the indexer.
const ROOT_DIR = process.env.HOME_DIR ?? homedir();
// parec's output format IS the contract with the browser's AudioWorklet: signed 16-bit LE, 44.1kHz, stereo.
const CAPTURE_ARGS = ['--format=s16le', '--rate=44100', '--channels=2', '-d', `${VIRTUAL_SINK}.monitor`];
export type MusicWSData = { kind: 'player'; files: string } | { kind: 'capture' };
type Session = {
proc: Subprocess;
closed: boolean;
};
const sessions = new Map<ServerWebSocket<MusicWSData>, Session>();
const sendOutput = (ws: ServerWebSocket<MusicWSData>, data: string) => {
try {
ws.send(JSON.stringify({ type: 'output', data }));
} catch {
/* ws already closed */
}
};
const sendExit = (ws: ServerWebSocket<MusicWSData>) => {
try {
ws.send(JSON.stringify({ type: 'exit' }));
} catch {
/* ws already closed */
}
};
// Home-relative or leading-slash paths both mean "under the owner's home"; anything that escapes it after
// normalisation is rejected. The trailing separator matters: without it a sibling directory whose name
// merely starts with the home path would pass.
const resolveInHome = (file: string): string | null => {
const abs = normalize(resolve(ROOT_DIR, file.startsWith('/') ? `.${file}` : file));
return abs === ROOT_DIR || abs.startsWith(ROOT_DIR + sep) ? abs : null;
};
const findCliamp = (): string | null => {
const which = Bun.which('cliamp');
if (which) return which;
const candidates = [
process.env.GOPATH ? `${process.env.GOPATH}/bin/cliamp` : null,
`${ROOT_DIR}/.local/go-path/bin/cliamp`,
`${ROOT_DIR}/go/bin/cliamp`,
];
for (const bin of candidates) {
if (!bin) continue;
try {
const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' });
if (stat.exitCode === 0) return bin;
} catch {
/* ignore */
}
}
return null;
};
const shellEscape = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
// Pump a byte stream into the socket until it ends; `frame` decides how it lands on the wire.
function pump(
ws: ServerWebSocket<MusicWSData>,
session: Session,
stream: ReadableStream<Uint8Array>,
frame: (ws: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => void,
onEnd?: () => void,
): void {
const reader = stream.getReader();
void (async () => {
try {
while (!session.closed) {
const { done, value } = await reader.read();
if (done) break;
if (value && !session.closed) frame(ws, value);
}
} catch {
/* stream ended */
} finally {
onEnd?.();
}
})();
}
function openPlayer(ws: ServerWebSocket<MusicWSData>, files: string): void {
if (!files) return sendOutput(ws, '\r\n[Error] No files specified.\r\n');
const cliampPath = findCliamp();
if (!cliampPath) return sendOutput(ws, '\r\n[Error] cliamp not found on host.\r\n');
const target = resolveInHome(files);
if (!target) return sendOutput(ws, '\r\n[Error] Invalid file path.\r\n');
// `script` fakes a PTY for cliamp, which avoids a node-pty native dependency here.
const cliampCmd = `${shellEscape(cliampPath)} ${shellEscape(target)}`;
console.log(`[music] cliamp spawning: ${cliampCmd}`);
let proc: Subprocess<'pipe', 'pipe', 'pipe'>;
try {
proc = spawn({
cmd: ['script', '-qfc', cliampCmd, '/dev/null'],
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
cwd: ROOT_DIR,
env: { ...process.env, TERM: 'xterm-256color', PULSE_SINK: VIRTUAL_SINK, ALSA_CONFIG_PATH: ASOUNDRC_PATH },
});
} catch (err) {
return sendOutput(ws, `\r\n[Error] ${err instanceof Error ? err.message : 'Failed to start cliamp'}\r\n`);
}
const session: Session = { proc, closed: false };
sessions.set(ws, session);
const decoder = new TextDecoder();
const asText = (sock: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => sendOutput(sock, decoder.decode(chunk));
const end = () => {
if (session.closed) return;
session.closed = true;
sendExit(ws);
};
pump(ws, session, proc.stdout, asText, end);
pump(ws, session, proc.stderr, asText); // cliamp writes some output there
void proc.exited.then((code) => {
console.log(`[music] cliamp exited code=${code}`);
end();
sessions.delete(ws);
});
}
function openCapture(ws: ServerWebSocket<MusicWSData>): void {
const parecPath = Bun.which('parec');
if (!parecPath) {
ws.close(4000, 'parec not found on host');
return;
}
let proc: Subprocess<'ignore', 'pipe', 'ignore'>;
try {
proc = spawn({ cmd: [parecPath, ...CAPTURE_ARGS], stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' });
} catch {
ws.close(4000, 'Failed to start audio capture');
return;
}
const session: Session = { proc, closed: false };
sessions.set(ws, session);
console.log('[music] parec started, streaming PCM to the relay');
pump(
ws,
session,
proc.stdout,
(sock, chunk) => {
try {
sock.sendBinary(chunk);
} catch {
session.closed = true;
}
},
() => {
if (session.closed) return;
session.closed = true;
try {
ws.close();
} catch {
/* already closed */
}
},
);
}
export const musicWebsocket = {
open(ws: ServerWebSocket<MusicWSData>) {
if (ws.data.kind === 'player') openPlayer(ws, ws.data.files);
else openCapture(ws);
},
message(ws: ServerWebSocket<MusicWSData>, raw: string | Buffer) {
const session = sessions.get(ws);
if (!session || session.closed || ws.data.kind !== 'player') return; // capture is one-way
try {
const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
if (msg.type === 'input' && msg.data) (session.proc as Subprocess<'pipe'>).stdin.write(msg.data);
} catch {
/* not a frame we understand */
}
},
close(ws: ServerWebSocket<MusicWSData>) {
const session = sessions.get(ws);
if (!session) return;
session.closed = true;
try {
session.proc.kill();
} catch {
/* already gone */
}
sessions.delete(ws);
},
drain() {},
};
/** Upgrade one of the two cliamp sockets, or return null if this request is not for them. */
export function cliampUpgradeData(pathname: string, search: URLSearchParams): MusicWSData | null {
if (pathname === '/cliamp/ws') return { kind: 'player', files: search.get('files') ?? '' };
if (pathname === '/cliamp/audio/ws') return { kind: 'capture' };
return null;
}
+16
View File
@@ -3,6 +3,8 @@ import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { streamAudioFile } from './stream-audio';
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
import { ensurePulseAudio } from './pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
import { startMusicWatcher, stopMusicWatcher } from './watcher';
import {
@@ -137,6 +139,10 @@ startNightlyReindex();
// Recursive watcher on ~/Music → localized reindex on any change.
startMusicWatcher();
// PulseAudio daemon + the `virtual_out` null sink both cliamp halves depend on. Officer used to do this at
// its own boot, which meant every restart of a process with no audio responsibilities re-checked the sink.
ensurePulseAudio();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
@@ -145,6 +151,15 @@ const server = Bun.serve({
idleTimeout: 255,
async fetch(req, server) {
const url = new URL(req.url);
// The two cliamp sockets. Officer has already authenticated the browser and is relaying frames; the
// player and the capture themselves live here (cliamp-ws.ts).
const wsData = cliampUpgradeData(url.pathname, url.searchParams);
if (wsData) {
if (server.upgrade(req, { data: wsData })) return undefined as unknown as Response;
return new Response('Expected a WebSocket upgrade', { status: 400 });
}
// A from-scratch reindex can take many minutes with no bytes flowing on the triggering request.
// Give the build endpoints a 30-min idle timeout so they aren't dropped (/manifest is a pure read now).
if (url.pathname === '/reindex' || url.pathname === '/reindex/stream') {
@@ -419,6 +434,7 @@ const server = Bun.serve({
return new Response('Not found', { status: 404 });
},
websocket: musicWebsocket,
});
console.log(`[music] audio server listening on http://127.0.0.1:${port}`);
+48
View File
@@ -0,0 +1,48 @@
// Host audio plumbing for local playback: a PulseAudio daemon and a null sink named `virtual_out`.
// cliamp plays *into* that sink (PULSE_SINK) and the capture side reads `virtual_out.monitor`, so the
// sink has to exist before either of them starts — which is why this runs at sidecar startup rather
// than on first play. Both steps are idempotent and both failures are non-fatal: a host without
// pulseaudio simply has no browser playback, and everything else the music sidecar does still works.
export const VIRTUAL_SINK = 'virtual_out';
export function ensurePulseAudio(): void {
const pulseaudio = Bun.which('pulseaudio');
const pactl = Bun.which('pactl');
if (!pulseaudio || !pactl) {
console.log('[music] pulseaudio not installed, skipping audio setup');
return;
}
const check = Bun.spawnSync({ cmd: [pulseaudio, '--check'], stdout: 'ignore', stderr: 'ignore' });
if (check.exitCode !== 0) {
const start = Bun.spawnSync({ cmd: [pulseaudio, '--start', '-D'], stdout: 'ignore', stderr: 'ignore' });
if (start.exitCode !== 0) {
console.error('[music] failed to start pulseaudio');
return;
}
console.log('[music] pulseaudio started');
} else {
console.log('[music] pulseaudio already running');
}
const sinks = Bun.spawnSync({ cmd: [pactl, 'list', 'short', 'sinks'], stdout: 'pipe', stderr: 'ignore' });
if (sinks.stdout.toString().includes(VIRTUAL_SINK)) {
console.log(`[music] ${VIRTUAL_SINK} sink already exists`);
return;
}
const load = Bun.spawnSync({
cmd: [
pactl,
'load-module',
'module-null-sink',
`sink_name=${VIRTUAL_SINK}`,
'sink_properties=device.description=Virtual_Output',
],
stdout: 'pipe',
stderr: 'pipe',
});
if (load.exitCode !== 0) console.error('[music] failed to load null sink:', load.stderr.toString().trim());
else console.log(`[music] ${VIRTUAL_SINK} null sink loaded`);
}