music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
Reference in New Issue
Block a user