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
+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}`);