Files
esports/streamer/tosu.ts
Stan Runge 54444a77f0 feat: add native Windows broadcaster and live score modules
- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:25 +00:00

151 lines
4.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* tosu (https://github.com/tosuapp/tosu) reads the osu! tourney client's
* memory and serves it over a websocket. We need each spectated player's
* live score, how far through the map they are, and the team names of the
* room being spectated (to confirm the right room was selected).
*/
export interface TosuClientScore {
osuId: number;
name: string;
score: number;
/** Percent. */
accuracy: number;
combo: number;
failed: boolean;
}
export interface TosuFrame {
/** 1 idle, 2 waiting for clients, 3 playing, 4 results (tosu's ipcState). */
ipcState: number | null;
beatmapId: number | null;
/** 0–1 through the map. */
progress: number | null;
/** Team names osu!tourney parsed from the spectated room's name; empty when none. */
teams: { left: string; right: string };
clients: TosuClientScore[];
}
/** Picks what Vash needs out of one tosu v2 message. Returns null outside tourney mode. */
export function parseTosu(message: unknown): TosuFrame | null {
const root = message as {
tourney?: {
ipcState?: number;
team?: { left?: string; right?: string };
clients?: {
user?: { id?: number; name?: string };
play?: {
playerName?: string;
score?: number;
accuracy?: number;
failed?: boolean;
combo?: { current?: number };
};
}[];
};
beatmap?: { id?: number; time?: { live?: number; firstObject?: number; lastObject?: number } };
};
if (!root?.tourney || !Array.isArray(root.tourney.clients)) return null;
const time = root.beatmap?.time;
const span =
time && time.lastObject != null && time.firstObject != null
? time.lastObject - time.firstObject
: 0;
return {
ipcState: root.tourney.ipcState ?? null,
beatmapId: root.beatmap?.id ?? null,
progress:
span > 0 && time?.live != null
? Math.max(0, Math.min(1, (time.live - time.firstObject!) / span))
: null,
teams: {
left: String(root.tourney.team?.left ?? '').trim(),
right: String(root.tourney.team?.right ?? '').trim()
},
clients: root.tourney.clients
.filter(
(client) =>
(client.user?.id ?? 0) > 0 || client.user?.name?.trim() || client.play?.playerName?.trim()
)
.map((client) => ({
osuId: client.user?.id ?? 0,
name: client.user?.name?.trim() || client.play?.playerName?.trim() || '',
score: client.play?.score ?? 0,
accuracy: client.play?.accuracy ?? 0,
combo: client.play?.combo?.current ?? 0,
failed: client.play?.failed ?? false
}))
};
}
/** Keeps a websocket to tosu open and remembers its latest tourney frame. */
export class TosuFeed {
latest: TosuFrame | null = null;
latestAt = 0;
connected = false;
private socket: WebSocket | null = null;
private attempts = 0;
private stopped = false;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private url: string,
private onFrame: (frame: TosuFrame) => void,
private log: (...parts: unknown[]) => void = () => {}
) {}
start() {
this.stopped = false;
this.connect();
}
private connect() {
if (this.stopped) return;
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => {
this.attempts = 0;
this.connected = true;
this.log('tosu connected');
};
socket.onmessage = (event) => {
let frame: TosuFrame | null;
try {
frame = parseTosu(JSON.parse(String(event.data)));
} catch {
return;
}
if (!frame) return;
this.latest = frame;
this.latestAt = Date.now();
this.onFrame(frame);
};
socket.onclose = () => {
if (this.socket !== socket) return;
if (this.connected) this.log('tosu disconnected');
this.connected = false;
this.socket = null;
if (this.stopped) return;
// tosu often starts after us; keep trying quietly.
const wait = Math.min(15_000, 1000 * 2 ** this.attempts++);
if (this.attempts === 4)
this.log(`tosu is not answering at ${this.url}; live scores start when it does.`);
this.timer = setTimeout(() => this.connect(), wait);
};
}
/** The latest frame if it is recent enough to trust. */
fresh(maxAgeMs = 5000) {
return this.connected && Date.now() - this.latestAt <= maxAgeMs ? this.latest : null;
}
stop() {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
const socket = this.socket;
this.socket = null;
this.connected = false;
socket?.close();
}
}