cliamp moves into the plugin, and the platform loses its last music file

The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.

The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.

Everything cliamp now lives in plugins/music/cliamp/:

  sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
  api/cliamp/relay.ts
  apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx

src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.

Two of the things that moved were live, not inert.

The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.

That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.

The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.

Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.

Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.

bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
This commit is contained in:
2026-08-15 13:52:53 +00:00
parent 8bfcd40bd2
commit a9bf51407e
25 changed files with 504 additions and 398 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns
- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
+49 -17
View File
@@ -34,30 +34,62 @@ scripts/ the reindex CLI, which talks to the sidecar port directly
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
means each one is written down with what would close it.
### 1. cliamp — out of scope by decision
### 1. cliamp — parked in the plugin, not left in the platform
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
terminal and its PulseAudio null sink piped to the browser. The owner's call was that it is the least
important part of music and not worth blocking the extraction on.
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
It was already inert before any of this — the two sockets are declared in `server.tsx`'s route table and
upgrade into `handlers` entries that are commented out. So:
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
- `src/servers/sidecar/music/` still holds `cliamp-ws.ts`, `pulse-audio.ts`, `asoundrc` and
`cliamp-ws.test.ts`. **Untouched.**
- This plugin's sidecar still serves those sockets, so it imports both modules from
`@@/sidecar/music/`. A plugin importing platform code is ordinary; the reverse would not be.
- `src/servers/api/cliamp/relay.ts` stays, and it is what keeps the next item alive.
```
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
api/cliamp/relay.ts → cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
```
### 2. `src/servers/api/music/router.ts` — kept alive by the relay
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
`server.tsx` has no cliamp import, provider name, handler entry or route left.
`relay.ts` imports `getMusicServerWsUrl` from it. So the platform's proxy could not move, and this
plugin's `api/router.ts` **re-exports it** rather than building a second one.
Two things went with it that were live rather than inert:
That is not laziness. `createSidecarProxy` learns its port from a one-shot `music:server` event and
subscribes at import. Two proxies would mean two subscribers, both working today, and a `503` on the
first reconnect where only one of them happened to be listening — the same class of failure as the
install-order bug offscale found, and just as invisible from reading.
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
its two menu entries are removed; the components are parked here.
- **The two socket routes.** They now 404. Verified live.
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
and never called. A platform gap, not a music one.
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
`getMusicServerWsUrl` from it, so it could not move.
That reason was three layers of nothing:
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
A dead import held a music-named file in the platform. The proxy is now built in
`plugins/music/api/router.ts`; the relay takes its URL from there.
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
to know about provenance. It now calls `mountPrefix`.
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
### 3. The player — the one open judgement call, and it is decided
+41 -13
View File
@@ -1,18 +1,46 @@
import { musicRouter } from '@@/api/music/router';
import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { mountPrefix } from '@@/plugins/manifest';
import { manifest } from '../manifest';
// /api/music/* — auth, then forward to officer-music.
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
//
// ── Why this re-exports the platform's proxy instead of creating its own ──
// ── This used to live in the platform, and that was the bug ──
//
// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports
// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is
// deliberately out of scope — it is a second playback path that the platform still owns.
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
// knew the string 'music' — and this file merely re-exported it. The justification was that
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
//
// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would
// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the
// first reconnect where only one of them was listening would produce a 503 nobody could explain. One
// proxy, one subscription, mounted by whoever needs it.
// That justification was three layers of nothing. The relay's functions were only reachable through
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
//
// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable
// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin.
export const router = musicRouter;
// ── The prefix is DERIVED, not written ──
//
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
//
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
// become two systems with only one of them tested.
//
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
// factory the installer calls instead. Recorded rather than worked around.
const proxy = createSidecarProxy({
name: 'music',
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
timeoutSeconds: 1800,
});
export const router = proxy.router;
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
export const getMusicServerWsUrl = proxy.getWsUrl;
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from 'react';
import { Volume2, VolumeX } from 'lucide-react';
type AudioStreamPlayerProps = {
wsUrl: string;
onError?: (message: string) => void;
};
const SAMPLE_RATE = 44100;
const CHANNELS = 2;
const buildWsUrl = (wsPath: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
};
const WORKLET_CODE = `
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.port.onmessage = (e) => {
const incoming = e.data;
const merged = new Float32Array(this.buffer.length + incoming.length);
merged.set(this.buffer);
merged.set(incoming, this.buffer.length);
this.buffer = merged;
const max = ${SAMPLE_RATE * CHANNELS * 2};
if (this.buffer.length > max) {
this.buffer = this.buffer.slice(this.buffer.length - max);
}
};
}
process(inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const channels = output.length;
const frameSize = output[0].length;
const samplesNeeded = frameSize * channels;
if (this.buffer.length >= samplesNeeded) {
for (let i = 0; i < frameSize; i++) {
for (let ch = 0; ch < channels; ch++) {
output[ch][i] = this.buffer[i * channels + ch];
}
}
this.buffer = this.buffer.slice(samplesNeeded);
} else {
for (let ch = 0; ch < channels; ch++) {
output[ch].fill(0);
}
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
`;
const workletBlobUrl = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => {
const [muted, setMuted] = useState(false);
const [started, setStarted] = useState(false);
const ctxRef = useRef<AudioContext | null>(null);
const nodeRef = useRef<AudioWorkletNode | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const gainRef = useRef<GainNode | null>(null);
// The effect below runs once per `wsUrl` and registers listeners that outlive every render after it, so a
// named `onError` dependency would either tear the stream down on each render or freeze the first render's
// callback. A ref is the third option: one stream, current callback.
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
let disposed = false;
let audioCtx: AudioContext | null = null;
const init = async () => {
try {
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
ctxRef.current = audioCtx;
await audioCtx.audioWorklet.addModule(workletBlobUrl);
if (disposed) {
audioCtx.close();
return;
}
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
outputChannelCount: [CHANNELS],
});
nodeRef.current = workletNode;
const gainNode = audioCtx.createGain();
gainRef.current = gainNode;
workletNode.connect(gainNode);
gainNode.connect(audioCtx.destination);
const ws = new WebSocket(buildWsUrl(wsUrl));
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.addEventListener('open', () => {
if (!disposed) setStarted(true);
});
ws.addEventListener('message', (ev) => {
if (disposed || !(ev.data instanceof ArrayBuffer)) return;
// Resume context if suspended (autoplay policy — will unlock on user gesture)
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
const int16 = new Int16Array(ev.data);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i]! / 32768;
}
workletNode.port.postMessage(float32);
});
ws.addEventListener('error', () => {
if (!disposed) onErrorRef.current?.('Audio stream connection failed');
});
ws.addEventListener('close', () => {
if (!disposed) setStarted(false);
});
} catch (err) {
if (!disposed) {
onErrorRef.current?.(err instanceof Error ? err.message : 'Audio playback failed');
}
}
};
init();
return () => {
disposed = true;
try {
wsRef.current?.close();
} catch {
/* ignore */
}
wsRef.current = null;
try {
nodeRef.current?.disconnect();
} catch {
/* ignore */
}
nodeRef.current = null;
try {
audioCtx?.close();
} catch {
/* ignore */
}
ctxRef.current = null;
gainRef.current = null;
};
}, [wsUrl]);
useEffect(() => {
if (gainRef.current) {
gainRef.current.gain.value = muted ? 0 : 1;
}
}, [muted]);
return (
<button
onClick={() => setMuted((m) => !m)}
className="p-1.5 rounded hover:bg-duck-dark/10 transition-colors cursor-pointer"
title={muted ? 'Unmute' : 'Mute'}
>
{muted ? (
<VolumeX className={`h-4 w-4 ${started ? 'text-red-500' : 'text-duck-dark/40'}`} />
) : (
<Volume2 className={`h-4 w-4 ${started ? 'text-duck-teal' : 'text-duck-dark/40'}`} />
)}
</button>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router';
import { Music } from 'lucide-react';
import { TerminalView } from 'officerdev';
import { AudioStreamPlayer } from './AudioStreamPlayer';
export const CliampPanelHeader = () => {
const [searchParams] = useSearchParams();
const playPath = searchParams.get('play') ?? '';
const fileName = playPath.split('/').pop() ?? 'cliamp';
return (
<>
<Music className="h-4 w-4 shrink-0 opacity-60" />
<span className="text-xs font-medium truncate flex-1">{fileName}</span>
<AudioStreamPlayer wsUrl="/api/cliamp/audio/ws" />
</>
);
};
export const CliampPanelBody = () => {
const [searchParams, setSearchParams] = useSearchParams();
const playPath = searchParams.get('play') ?? '';
const wsPath = `/api/cliamp/ws?files=${encodeURIComponent(playPath)}`;
const handleExit = useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('play');
return next;
});
}, [setSearchParams]);
return <TerminalView className="h-full w-full" wsPath={wsPath} onExit={handleExit} autoFocus />;
};
+9
View File
@@ -0,0 +1,9 @@
pcm.!default {
type pulse
fallback "sysdefault"
}
ctl.!default {
type pulse
fallback "sysdefault"
}
+66
View File
@@ -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 = 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;
}
+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`);
}
+110
View File
@@ -0,0 +1,110 @@
import type { ServerWebSocket } from 'bun';
import { getMusicServerWsUrl } from '../api/router';
// 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 PLUGIN (`plugins/music/cliamp/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');
+9 -9
View File
@@ -3,21 +3,21 @@ import type { PluginManifest } from '@@/plugins/manifest';
// Music — the library, the player, and the phone and tablet apps that stream from it.
//
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
// a clean cut: three pieces stay behind deliberately. Each is a documented seam rather than a loose end,
// and each is recorded in ./PLUGIN.md with what would have to change to close it.
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
// of them here after the owner read the code and asked why the platform still had files named for music.
// He was right — one of the three "seams" turned out to be dead code holding the door open.
//
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
// db/ music_favorites, _playlists, _playlist_items, _now_playing
// web/ the library panels; the shell renders the Workspace
//
// ── What stayed in the platform, and why ──
// ── What stayed in the platform, and why ── (one item, down from three)
//
// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`,
// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its
// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out
// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it
// imports both modules from `@@/sidecar/music/`.
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
//
// 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
// the mechanism was not worth inventing for one.
+2 -2
View File
@@ -3,8 +3,8 @@ import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio';
import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws';
import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio';
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
import { ensurePulseAudio } from '../cliamp/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
import {
reindexNow,