Merge branch 'sidecars-music' into sidecars
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
pcm.!default {
|
||||
type pulse
|
||||
fallback "sysdefault"
|
||||
}
|
||||
|
||||
ctl.!default {
|
||||
type pulse
|
||||
fallback "sysdefault"
|
||||
}
|
||||
@@ -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() {},
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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() {},
|
||||
};
|
||||
@@ -24,15 +24,14 @@ musicRouter.all('/*', async (ctx) => {
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
// A from-scratch reindex holds this proxied connection open for minutes with no bytes flowing, which
|
||||
// the main server's 60s idle timeout would drop. Extend it to 30 min for the build/progress endpoints
|
||||
// (Bun passes the server as Hono's env). Matches the sidecar's own per-request extension.
|
||||
if (subpath === '/reindex' || subpath === '/reindex/stream') {
|
||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
||||
try {
|
||||
server?.timeout?.(ctx.req.raw, 1800);
|
||||
} catch {
|
||||
/* older Bun / no per-request timeout — the build still completes in the background */
|
||||
}
|
||||
// the main server's 60s idle timeout would drop. Extend every request under this prefix to 30 min (Bun
|
||||
// passes the server as Hono's env) — the proxy must not know which of the sidecar's routes are slow, and
|
||||
// the sidecar applies its own per-request extension anyway.
|
||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
||||
try {
|
||||
server?.timeout?.(ctx.req.raw, 1800);
|
||||
} catch {
|
||||
/* older Bun / no per-request timeout — the build still completes in the background */
|
||||
}
|
||||
|
||||
const method = ctx.req.method;
|
||||
|
||||
@@ -16,3 +16,8 @@ sidecar.on('music:server', (msg) => {
|
||||
export function getMusicServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
|
||||
/** Same server, ws:// scheme — for the cliamp sockets the platform relays (see api/cliamp/relay.ts). */
|
||||
export function getMusicServerWsUrl(): string | null {
|
||||
return serverPort ? `ws://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user