Gateway (transmitter.ts): - Auto-stop on FFmpeg crash: non-zero exit triggers stop() to prevent silent audio loss and resource leaks - Voice activity timeout (10s): auto-stops transmitter when no PCM received, preventing dead-air CPU waste on backgrounded tabs - Stderr cap (4KB): prevents unbounded memory growth in long sessions Gateway (voice.handler.ts): - Double-check voiceController.getStatus().connected before starting transmitter — detects stale player state after gateway disconnect Frontend (context.tsx): - Force-refetch voice status on WS reconnect — UI converges in <1s instead of waiting up to 4s for SWR poll interval All: tsc clean, biome clean
189 lines
5.5 KiB
TypeScript
189 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
type ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useSWRConfig } from "swr";
|
|
import { toast } from "@/components/primitives";
|
|
import { WsConnection } from "./connection";
|
|
import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types";
|
|
|
|
interface WsContextValue {
|
|
status: WsStatus;
|
|
connect: () => void;
|
|
disconnect: () => void;
|
|
sendText: (text: string) => void;
|
|
sendBinary: (data: ArrayBufferLike) => void;
|
|
/** Subscribe to a typed WS event. Returns unsubscribe function. */
|
|
on: <E extends WsEventType>(
|
|
eventType: E,
|
|
handler: WsEventHandler<E>,
|
|
) => () => void;
|
|
/** Subscribe to binary PCM events. Returns unsubscribe function. */
|
|
onPcm: (handler: (chunk: PcmChunk) => void) => () => void;
|
|
}
|
|
|
|
const WsContext = createContext<WsContextValue | null>(null);
|
|
|
|
export function WsProvider({
|
|
children,
|
|
url,
|
|
}: {
|
|
children: ReactNode;
|
|
url?: string;
|
|
}) {
|
|
const connRef = useRef<WsConnection | null>(null);
|
|
const [status, setStatus] = useState<WsStatus>("disconnected");
|
|
const { mutate } = useSWRConfig();
|
|
// Tracks whether we've ever been connected — used to suppress the
|
|
// "reconnecting" toast on initial page load.
|
|
const wasConnected = useRef(false);
|
|
|
|
// Event handler registry — Ref so listeners survive re-renders without reconnect
|
|
// Using unknown as internal store; typed at the subscribe interface
|
|
const handlersRef = useRef<Record<string, Set<(data: unknown) => void>>>({});
|
|
const pcmHandlersRef = useRef<Set<(chunk: PcmChunk) => void>>(new Set());
|
|
|
|
const handleJsonEvent = useCallback((json: string) => {
|
|
try {
|
|
const parsed = JSON.parse(json);
|
|
const eventType = parsed.type as string;
|
|
const data = parsed.data ?? parsed.state ?? parsed;
|
|
|
|
const handlers = handlersRef.current;
|
|
const eventHandlers = handlers[eventType as WsEventType];
|
|
if (eventHandlers && eventHandlers.size > 0) {
|
|
eventHandlers.forEach((h) => h(data));
|
|
}
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
}, []);
|
|
|
|
const handleBinaryEvent = useCallback((buffer: ArrayBuffer) => {
|
|
if (buffer.byteLength < 4 || pcmHandlersRef.current.size === 0) return;
|
|
|
|
const view = new DataView(buffer);
|
|
const userIdHash = view.getUint32(0, true);
|
|
const samples = new Int16Array(buffer, 4);
|
|
|
|
const chunk: PcmChunk = { userIdHash, samples };
|
|
pcmHandlersRef.current.forEach((h) => h(chunk));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const conn = new WsConnection(url);
|
|
connRef.current = conn;
|
|
|
|
const unsubStatus = conn.onStatusChange((s) => {
|
|
setStatus(s);
|
|
// User feedback on WS lifecycle transitions.
|
|
if (s === "connecting") {
|
|
// Only toast if we were previously connected (i.e. a disconnect,
|
|
// not the initial connect on page load).
|
|
if (wasConnected.current) {
|
|
toast({
|
|
title: "Reconnecting…",
|
|
description: "WebSocket connection lost. Attempting to reconnect.",
|
|
tone: "neutral",
|
|
});
|
|
}
|
|
wasConnected.current = false;
|
|
} else if (s === "connected") {
|
|
wasConnected.current = true;
|
|
// After reconnect, force-refetch voice status immediately so
|
|
// the UI converges faster instead of waiting up to 4s for SWR poll.
|
|
void mutate(["voice-status"]);
|
|
} else if (s === "error" && !wasConnected.current) {
|
|
toast({
|
|
title: "Connection error",
|
|
description:
|
|
"WebSocket failed to connect. Retrying in the background.",
|
|
tone: "vermilion",
|
|
});
|
|
}
|
|
});
|
|
const unsubEvent = conn.onEvent((event) => {
|
|
if (event.type === "text") {
|
|
handleJsonEvent(event.data);
|
|
} else {
|
|
handleBinaryEvent(event.data);
|
|
}
|
|
});
|
|
|
|
conn.connect();
|
|
|
|
return () => {
|
|
conn.destroy();
|
|
connRef.current = null;
|
|
unsubStatus();
|
|
unsubEvent();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [url, handleBinaryEvent, handleJsonEvent, mutate]);
|
|
|
|
const subscribe = useCallback(
|
|
<E extends WsEventType>(_eventType: E, handler: WsEventHandler<E>) => {
|
|
const eventType = _eventType as string;
|
|
if (!handlersRef.current[eventType]) {
|
|
handlersRef.current[eventType] = new Set();
|
|
}
|
|
handlersRef.current[eventType].add(handler as (data: unknown) => void);
|
|
return () => {
|
|
handlersRef.current[eventType]?.delete(
|
|
handler as (data: unknown) => void,
|
|
);
|
|
};
|
|
},
|
|
[],
|
|
);
|
|
|
|
const subscribePcm = useCallback((handler: (chunk: PcmChunk) => void) => {
|
|
pcmHandlersRef.current.add(handler);
|
|
return () => {
|
|
pcmHandlersRef.current.delete(handler);
|
|
};
|
|
}, []);
|
|
|
|
const connect = useCallback(() => connRef.current?.connect(), []);
|
|
const disconnect = useCallback(() => connRef.current?.disconnect(), []);
|
|
const sendText = useCallback(
|
|
(text: string) => connRef.current?.sendText(text),
|
|
[],
|
|
);
|
|
const sendBinary = useCallback(
|
|
(data: ArrayBufferLike) => connRef.current?.sendBinary(data),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<WsContext.Provider
|
|
value={{
|
|
status,
|
|
connect,
|
|
disconnect,
|
|
sendText,
|
|
sendBinary,
|
|
on: subscribe,
|
|
onPcm: subscribePcm,
|
|
}}
|
|
>
|
|
{children}
|
|
</WsContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useWebSocket(): WsContextValue {
|
|
const ctx = useContext(WsContext);
|
|
if (!ctx) {
|
|
throw new Error("useWebSocket must be used within a WsProvider");
|
|
}
|
|
return ctx;
|
|
}
|