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
+6 -51
View File
@@ -8,8 +8,7 @@ import { terminalWebsocket } from './servers/api/terminal/websocket';
import { chatWebsocket } from './servers/api/chat/websocket';
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
import { cliampWebsocket } from './servers/api/cliamp/websocket';
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
import { cliampWebsocket, cliampAudioWebsocket } from './servers/api/cliamp/relay';
import { desktopWebsocket } from './servers/api/desktop/websocket';
import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket';
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
@@ -50,7 +49,7 @@ type WSData = {
command?: string;
cols?: number;
rows?: number;
files?: string;
search?: string; // raw query string, for providers that relay it to a sidecar
devServerPort?: number;
devServerSlug?: string;
wsProxyPath?: string;
@@ -250,7 +249,6 @@ async function upgradeWs(
const command = url.searchParams.get('command') ?? undefined;
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
const files = url.searchParams.get('files') ?? undefined;
const ok = server.upgrade(req, {
data: {
userId: user.id,
@@ -262,7 +260,7 @@ async function upgradeWs(
command,
cols,
rows,
files,
search: url.search,
},
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
@@ -388,51 +386,8 @@ initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager';
cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err));
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
(async () => {
const pulseaudio = Bun.which('pulseaudio');
const pactl = Bun.which('pactl');
if (!pulseaudio || !pactl) {
console.log('[cliamp] pulseaudio not installed, skipping audio setup');
return;
}
// Start PulseAudio daemon if not running
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('[cliamp] failed to start pulseaudio');
return;
}
console.log('[cliamp] pulseaudio started');
} else {
console.log('[cliamp] pulseaudio already running');
}
// Load null sink if not already loaded
const sinks = Bun.spawnSync({ cmd: [pactl, 'list', 'short', 'sinks'], stdout: 'pipe', stderr: 'ignore' });
const sinkList = sinks.stdout.toString();
if (!sinkList.includes('virtual_out')) {
const load = Bun.spawnSync({
cmd: [
pactl,
'load-module',
'module-null-sink',
'sink_name=virtual_out',
'sink_properties=device.description=Virtual_Output',
],
stdout: 'pipe',
stderr: 'pipe',
});
if (load.exitCode !== 0) {
console.error('[cliamp] failed to load null sink:', load.stderr.toString().trim());
} else {
console.log('[cliamp] virtual_out null sink loaded');
}
} else {
console.log('[cliamp] virtual_out sink already exists');
}
})();
// PulseAudio and the `virtual_out` sink used to be set up here, at every boot of a process that has no
// audio responsibilities. They belong to the music sidecar, which owns both cliamp halves now
// (sidecar/music/pulse-audio.ts).
// Pi check/install is handled by bootstrap.ts (imported above)