#!/usr/bin/env bun /** * vash-streamer: runs next to OBS and the osu! tourney client on the * streaming PC, and connects them to a Vash broadcast channel. * * tosu ──live scores──▶ agent ──▶ Vash (/ws/agent) ──▶ overlays, match page * Vash ──scene, lobby──▶ agent ──▶ OBS scenes, tourney client (#mp_…) * * Every part is optional except Vash itself: without tosu there are no live * scores from real lobbies, without OBS nothing switches scenes. * * vash-streamer --server https://esports.vash.software --channel --token * vash-streamer ... --obs-password --setup-obs create a ready OBS scene * * Build single binaries with `bun run streamer:build`. */ import { parseArgs } from 'node:util'; import { Obs } from './obs'; import { parseTosu, type TosuFrame } from './tosu'; import { Tourney } from './tourney'; const { values: args } = parseArgs({ options: { server: { type: 'string', default: process.env.VASH_SERVER ?? 'https://esports.vash.software' }, channel: { type: 'string', default: process.env.VASH_CHANNEL }, token: { type: 'string', default: process.env.VASH_TOKEN }, tosu: { type: 'string', default: process.env.TOSU_URL ?? 'ws://127.0.0.1:24050/websocket/v2' }, obs: { type: 'string', default: process.env.OBS_URL ?? 'ws://127.0.0.1:4455' }, 'obs-password': { type: 'string', default: process.env.OBS_PASSWORD ?? '' }, /** JSON: Vash scene → OBS scene name, e.g. {"gameplay":"Gameplay","idle":"Starting Soon"} */ 'obs-scenes': { type: 'string', default: process.env.OBS_SCENES ?? '' }, 'setup-obs': { type: 'boolean', default: false }, 'auto-join': { type: 'boolean', default: process.env.AUTO_JOIN === '1' }, 'osu-exe': { type: 'string', default: process.env.OSU_EXE ?? '' }, help: { type: 'boolean', short: 'h', default: false } } }); if (args.help || !args.channel || !args.token) { console.log(`vash-streamer: connects tosu and OBS to a Vash broadcast channel. --server URL Vash site (default https://esports.vash.software) --channel ID broadcast channel id (or VASH_CHANNEL) --token TOKEN the channel's agent token (or VASH_TOKEN) --tosu URL tosu websocket (default ws://127.0.0.1:24050/websocket/v2) --obs URL obs-websocket (default ws://127.0.0.1:4455) --obs-password PW obs-websocket password --obs-scenes JSON switch OBS scenes with the match, e.g. '{"gameplay":"Gameplay","results":"Results"}' --setup-obs create a "Vash" scene with the overlay and a tourney client capture, then exit --auto-join select the match in osu!tourney's control panel (Wine/X11) --osu-exe PATH launch osu!tourney from this executable under Wine`); process.exit(args.help ? 0 : 1); } const server = args.server!.replace(/\/$/, ''); const channel = args.channel!; const overlayUrl = `${server}/broadcast/${channel}/overlay`; const say = (...parts: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...parts); // ── OBS ───────────────────────────────────────────────────────────────── const obs = new Obs(args.obs!, args['obs-password']!); const sceneMap: Record = args['obs-scenes'] ? JSON.parse(args['obs-scenes']) : {}; if (args['setup-obs']) { await obs.connect(); const { scenes } = await obs.request<{ scenes: { sceneName: string }[] }>('GetSceneList'); if (!scenes.some((scene) => scene.sceneName === 'Vash')) await obs.request('CreateScene', { sceneName: 'Vash' }); // Inputs added later sit on top: the capture first, then the see-through overlay over it. const kind = process.platform === 'win32' ? 'window_capture' : process.platform === 'darwin' ? 'screen_capture' : 'xcomposite_input'; await obs .request('CreateInput', { sceneName: 'Vash', inputName: 'osu! tourney client', inputKind: kind, inputSettings: {} }) .catch((err) => say('capture:', err.message)); await obs .request('CreateInput', { sceneName: 'Vash', inputName: 'Vash overlay', inputKind: 'browser_source', inputSettings: { url: overlayUrl, width: 1920, height: 1080, fps_custom: true, fps: 60, reroute_audio: true } }) .catch((err) => say('overlay:', err.message)); await obs.request('SetCurrentProgramScene', { sceneName: 'Vash' }); say('OBS is set up: scene "Vash" with the overlay on top of "osu! tourney client".'); say('Open that capture source and pick the tourney client window.'); obs.close(); process.exit(0); } async function connectObs() { if (!Object.keys(sceneMap).length || obs.connected) return; try { await obs.connect(); say('OBS connected'); } catch (err) { say((err as Error).message); } } // ── Vash ──────────────────────────────────────────────────────────────── let vash: WebSocket | null = null; let vashAttempts = 0; let current: { scene: string; matchId: string | null; lobbyId: number | null } = { scene: 'idle', matchId: null, lobbyId: null }; const tourney = args['osu-exe'] ? new Tourney(args['osu-exe'], { display: process.env.GAMEPLAY_DISPLAY ?? ':98', width: Number(process.env.GAMEPLAY_WIDTH ?? 1280), height: Number(process.env.GAMEPLAY_HEIGHT ?? 720) }) : null; let roomName: string | null = null; let teamSize = 1; let selectedLobby: number | null = null; let lastJoinNotice = 0; function connectVash() { const url = `${server.replace(/^http/, 'ws')}/ws/agent`; const socket = new WebSocket(url); vash = socket; socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', channelId: channel, token: args.token })); socket.onmessage = (event) => { const message = JSON.parse(String(event.data)); if (message.type === 'welcome') { vashAttempts = 0; say(`Connected to ${server} as channel "${message.channel}". Overlay: ${overlayUrl}`); } else if (message.type === 'follow') void follow(message); }; socket.onclose = (event) => { if (event.code === 4001) { say('Vash refused the channel or token. Check them on the Broadcast page.'); process.exit(1); } const wait = Math.min(30_000, 1000 * 2 ** vashAttempts++); say(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`); setTimeout(connectVash, wait); }; } async function follow(next: { scene: string; matchId: string | null; lobbyId: number | null; title: string | null; roomName?: string | null; teamSize?: number | null; }) { const changedMatch = next.matchId !== current.matchId; const changedLobby = next.lobbyId != null && next.lobbyId !== current.lobbyId; if (changedMatch) say( next.title ? `Now showing ${next.title}${next.lobbyId ? ` (mp/${next.lobbyId})` : ''}` : 'Nothing to show; idle' ); if (next.scene !== current.scene) say(`Scene: ${next.scene}`); const sceneName = sceneMap[next.scene]; if (sceneName && next.scene !== current.scene) { await connectObs(); await obs.request('SetCurrentProgramScene', { sceneName }).catch((err) => say(err.message)); } if (changedLobby) selectedLobby = null; roomName = next.roomName ?? null; teamSize = next.teamSize ?? 1; if (changedLobby && args['auto-join'] && !tourney) say('Automatic room selection needs --osu-exe on a Wine/X11 host.'); current = { scene: next.scene, matchId: next.matchId, lobbyId: next.lobbyId }; } if (tourney && args['auto-join']) { let selecting = false; setInterval(async () => { if (selecting || !current.lobbyId || !roomName) return; selecting = true; try { if (await tourney.ensure(teamSize)) selectedLobby = null; if (selectedLobby === current.lobbyId) return; if (await tourney.select(roomName)) { selectedLobby = current.lobbyId; say(`osu!tourney is spectating ${roomName} (mp/${selectedLobby})`); } else if (Date.now() - lastJoinNotice > 30_000) { lastJoinNotice = Date.now(); say(`Waiting for ${roomName} in osu!tourney's room list`); } } catch (error) { if (Date.now() - lastJoinNotice > 30_000) { lastJoinNotice = Date.now(); say(`osu!tourney: ${(error as Error).message}`); } } finally { selecting = false; } }, 5000); } // ── tosu ──────────────────────────────────────────────────────────────── let tosuAttempts = 0; let lastSent = 0; let lastKey = ''; let pending: TosuFrame | null = null; function connectTosu() { const socket = new WebSocket(args.tosu!); socket.onopen = () => { tosuAttempts = 0; say('tosu connected'); }; socket.onmessage = (event) => { const frame = parseTosu(JSON.parse(String(event.data))); if (!frame || !frame.clients.length) return; pending = frame; flush(); }; socket.onclose = () => { // tosu is often started after the agent; keep trying quietly. const wait = Math.min(15_000, 1000 * 2 ** tosuAttempts++); if (tosuAttempts === 3) say(`tosu is not running at ${args.tosu}; live scores will start when it is.`); setTimeout(connectTosu, wait); }; } /** At most four updates a second, and only when something changed. */ function flush() { if (!pending || vash?.readyState !== WebSocket.OPEN) return; const wait = 250 - (Date.now() - lastSent); if (wait > 0) { setTimeout(flush, wait); return; } const frame = pending; pending = null; const key = frame.clients.map((client) => `${client.osuId}:${client.score}`).join('|'); if (key === lastKey) return; lastKey = key; lastSent = Date.now(); vash.send(JSON.stringify({ type: 'tosu', progress: frame.progress, clients: frame.clients })); } connectVash(); connectTosu();