move the whole local-audio pipeline into the music sidecar

cliamp playback was implemented entirely in officer: it located the cliamp binary,
validated the requested path against the owner's home, faked a PTY with `script`,
injected PULSE_SINK and an ALSA config shipped inside the API tree, spawned parec to
capture the sink, and set up the pulseaudio daemon and the virtual_out null sink at
every boot — about 356 lines of audio-pipeline knowledge in a process that is meant to
be a proxy, and none of it owned by the sidecar whose whole job is music.

all of it now lives in sidecar/music: cliamp-ws.ts serves both sockets (/cliamp/ws for
the player, /cliamp/audio/ws for the PCM capture) on the loopback server it already
runs, pulse-audio.ts does the daemon + sink setup at sidecar startup instead of at
officer's, and the asoundrc moved next to the code that passes it. officer keeps the
part that is actually its job — authenticating the browser — and relays frames both
ways without reading them (api/cliamp/relay.ts, same dumb-pipe shape as the vault
notifications relay). the browser's frame contract is unchanged, so the frontend is not
touched.

two things fixed on the way: the traversal check now requires a separator after the
home path, so a sibling directory whose name merely starts with it can no longer pass;
and the music proxy no longer special-cases /reindex and /reindex/stream by name to
extend the idle timeout — it extends the whole prefix, because a proxy should not know
which of the sidecar's routes are slow.

the music-specific `files` query param is out of the shared WS envelope too: upgradeWs
now carries the raw query string, which any relayed provider can use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 05:27:42 +00:00
co-authored by Claude Opus 4.8
parent b27dd7512b
commit b83a6c7e77
11 changed files with 491 additions and 352 deletions
-9
View File
@@ -1,9 +0,0 @@
pcm.!default {
type pulse
fallback "sysdefault"
}
ctl.!default {
type pulse
fallback "sysdefault"
}
-91
View File
@@ -1,91 +0,0 @@
import type { ServerWebSocket } from 'bun';
import { spawn, type Subprocess } from 'bun';
type WSData = {
userId: number;
email: string;
username: string;
role: string;
};
type AudioSession = {
proc: Subprocess;
closed: boolean;
};
const sessions = new Map<ServerWebSocket<WSData>, AudioSession>();
export const cliampAudioWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
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, '--format=s16le', '--rate=44100', '--channels=2', '-d', 'virtual_out.monitor'],
stdout: 'pipe',
stderr: 'ignore',
stdin: 'ignore',
});
} catch {
ws.close(4000, 'Failed to start audio capture');
return;
}
const session: AudioSession = { proc, closed: false };
sessions.set(ws, session);
console.log('[cliamp-audio] parec started, streaming to WS');
// Stream stdout chunks as binary WS frames
const reader = proc.stdout.getReader();
let totalBytes = 0;
const pump = async () => {
try {
while (!session.closed) {
const { done, value } = await reader.read();
if (done) break;
if (value && !session.closed) {
totalBytes += value.byteLength;
if (totalBytes <= value.byteLength) {
console.log(`[cliamp-audio] first chunk: ${value.byteLength} bytes`);
}
try {
ws.sendBinary(value);
} catch {
break;
}
}
}
} catch {
// stream ended or error
} finally {
if (!session.closed) {
session.closed = true;
try { ws.close(); } catch { /* ignore */ }
}
}
};
pump();
},
message() {
// No client-to-server messages expected
},
close(ws: ServerWebSocket<WSData>) {
console.log('[cliamp-audio] WS closed');
const session = sessions.get(ws);
if (session) {
session.closed = true;
try { session.proc.kill(); } catch { /* ignore */ }
sessions.delete(ws);
}
},
drain() {},
};
+110
View File
@@ -0,0 +1,110 @@
import type { ServerWebSocket } from 'bun';
import { getMusicServerWsUrl } from '../music/sidecar-server';
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
export type CliampWSData = {
provider: 'cliamp' | 'cliamp-audio';
search?: string; // the browser's query string, forwarded minus the platform token
};
type UpstreamState = {
ws: WebSocket | null;
queue: (string | Uint8Array<ArrayBuffer>)[];
ready: boolean;
};
// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is
// rather than copying every PCM chunk.
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
// The sidecar has no use for the platform JWT and should not see it.
const forwardedQuery = (search: string | undefined): string => {
const params = new URLSearchParams(search ?? '');
params.delete('token');
const qs = params.toString();
return qs ? `?${qs}` : '';
};
function createCliampRelay(path: string) {
const upstreams = new Map<ServerWebSocket<CliampWSData>, UpstreamState>();
return {
open(ws: ServerWebSocket<CliampWSData>) {
const base = getMusicServerWsUrl();
if (!base) {
try {
ws.close(1011, 'Music sidecar not available');
} catch {
/* already closed */
}
return;
}
const state: UpstreamState = { ws: null, queue: [], ready: false };
upstreams.set(ws, state);
const upstream = new WebSocket(`${base}${path}${forwardedQuery(ws.data.search)}`);
upstream.binaryType = 'arraybuffer';
state.ws = upstream;
upstream.addEventListener('open', () => {
state.ready = true;
for (const m of state.queue) upstream.send(m);
state.queue.length = 0;
});
upstream.addEventListener('message', (ev) => {
try {
ws.send(ev.data as string | ArrayBuffer);
} catch {
/* client gone */
}
});
upstream.addEventListener('close', (ev) => {
upstreams.delete(ws);
try {
ws.close(ev.code || 1000, ev.reason || '');
} catch {
/* already closed */
}
});
upstream.addEventListener('error', () => {
upstreams.delete(ws);
try {
ws.close(1011, 'upstream error');
} catch {
/* already closed */
}
});
},
message(ws: ServerWebSocket<CliampWSData>, raw: string | Buffer) {
const state = upstreams.get(ws);
if (!state) return;
const payload = asPayload(raw);
if (state.ready && state.ws) state.ws.send(payload);
else state.queue.push(payload); // buffer until the upstream socket opens
},
close(ws: ServerWebSocket<CliampWSData>) {
const state = upstreams.get(ws);
if (!state) return;
try {
state.ws?.close();
} catch {
/* already closed */
}
upstreams.delete(ws);
},
drain() {},
};
}
export const cliampWebsocket = createCliampRelay('/cliamp/ws');
export const cliampAudioWebsocket = createCliampRelay('/cliamp/audio/ws');
-201
View File
@@ -1,201 +0,0 @@
import type { ServerWebSocket } from 'bun';
import { spawn, type Subprocess } from 'bun';
import { resolve, normalize, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getOwnerHomeDir } from '@@/data-path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ASOUNDRC_PATH = join(__dirname, 'asoundrc');
type WSData = {
userId: number;
email: string;
username: string;
files: string;
};
type CliampSession = {
proc: Subprocess<'pipe', 'pipe', 'pipe'>;
closed: boolean;
};
const sessions = new Map<ServerWebSocket<WSData>, CliampSession>();
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
try {
ws.send(JSON.stringify({ type: 'output', data }));
} catch {
// ws already closed
}
};
const sendExit = (ws: ServerWebSocket<WSData>) => {
try {
ws.send(JSON.stringify({ type: 'exit' }));
} catch {
// ws already closed
}
};
const validateFilePaths = (files: string[], homeDir: string): string[] | null => {
const resolved = files.map((f) => normalize(resolve(homeDir, f)));
for (const p of resolved) {
if (!p.startsWith(homeDir)) return null;
}
return resolved;
};
const findCliamp = (): string | null => {
const which = Bun.which('cliamp');
if (which) return which;
const candidates = [
process.env.GOPATH ? `${process.env.GOPATH}/bin/cliamp` : null,
`${process.env.HOME}/.local/go-path/bin/cliamp`,
`${process.env.HOME}/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, "'\\''")}'`;
export const cliampWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { email, files: filesParam } = ws.data;
if (!filesParam) {
sendOutput(ws, '\r\n[Error] No files specified.\r\n');
return;
}
const cliampPath = findCliamp();
if (!cliampPath) {
sendOutput(ws, '\r\n[Error] cliamp not found on host.\r\n');
return;
}
const homeDir = getOwnerHomeDir(email);
const rawFiles = [filesParam];
// Resolve paths relative to user home dir
const absoluteFiles = rawFiles.map((f) => {
if (f.startsWith('/')) return `${homeDir}${f}`;
return `${homeDir}/${f}`;
});
const validated = validateFilePaths(absoluteFiles, homeDir);
if (!validated) {
sendOutput(ws, '\r\n[Error] Invalid file path.\r\n');
return;
}
// Use `script` to allocate a PTY for cliamp (avoids node-pty dependency)
// script -qfc '<command> <args...>' /dev/null
const cliampCmd = [shellEscape(cliampPath), ...validated.map(shellEscape)].join(' ');
console.log(`[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: homeDir,
env: { ...process.env, TERM: 'xterm-256color', PULSE_SINK: 'virtual_out', ALSA_CONFIG_PATH: ASOUNDRC_PATH },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start cliamp';
sendOutput(ws, `\r\n[Error] ${message}\r\n`);
return;
}
const session: CliampSession = { proc, closed: false };
sessions.set(ws, session);
// Pump stdout → WS
const reader = proc.stdout.getReader();
const pump = async () => {
try {
while (!session.closed) {
const { done, value } = await reader.read();
if (done) break;
if (value && !session.closed) {
sendOutput(ws, new TextDecoder().decode(value));
}
}
} catch {
// stream ended
} finally {
if (!session.closed) {
session.closed = true;
sendExit(ws);
}
}
};
pump();
// Also read stderr (cliamp may write there)
const stderrReader = proc.stderr.getReader();
const pumpStderr = async () => {
try {
while (!session.closed) {
const { done, value } = await stderrReader.read();
if (done) break;
if (value && !session.closed) {
sendOutput(ws, new TextDecoder().decode(value));
}
}
} catch {
// stream ended
}
};
pumpStderr();
// On process exit → notify client
proc.exited.then((code) => {
console.log(`[cliamp] process exited code=${code}`);
if (!session.closed) {
session.closed = true;
sendExit(ws);
}
sessions.delete(ws);
});
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
const session = sessions.get(ws);
if (!session || session.closed) return;
try {
const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
if (msg.type === 'input' && msg.data) {
session.proc.stdin.write(msg.data);
}
} catch {
// ignore
}
},
close(ws: ServerWebSocket<WSData>) {
const session = sessions.get(ws);
if (session) {
session.closed = true;
try {
session.proc.kill();
} catch {
/* ignore */
}
sessions.delete(ws);
}
},
drain() {},
};