summaryrefslogtreecommitdiff
path: root/makima/frontend/src/hooks/useWebSocket.ts
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-22 04:50:25 +0000
committersoryu <soryu@soryu.co>2025-12-23 14:47:18 +0000
commit0741a8b8e9a2099c82bff6d6b9ebbce9c07cad53 (patch)
tree88cbd5fecb9ca72a04aa07f1a6db4e1a751b1fd7 /makima/frontend/src/hooks/useWebSocket.ts
parentaee2e4e784afd6d115fb5f7b40284c4efd2da966 (diff)
downloadsoryu-0741a8b8e9a2099c82bff6d6b9ebbce9c07cad53.tar.gz
soryu-0741a8b8e9a2099c82bff6d6b9ebbce9c07cad53.zip
Update makima FE to add initial listening system
Diffstat (limited to 'makima/frontend/src/hooks/useWebSocket.ts')
-rw-r--r--makima/frontend/src/hooks/useWebSocket.ts244
1 files changed, 244 insertions, 0 deletions
diff --git a/makima/frontend/src/hooks/useWebSocket.ts b/makima/frontend/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..de6c1a6
--- /dev/null
+++ b/makima/frontend/src/hooks/useWebSocket.ts
@@ -0,0 +1,244 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { LISTEN_ENDPOINT } from "../lib/api";
+import type {
+ ClientMessage,
+ ServerMessage,
+ TranscriptEntry,
+} from "../types/messages";
+
+export type ConnectionStatus =
+ | "disconnected"
+ | "connecting"
+ | "connected"
+ | "error";
+
+export interface WebSocketState {
+ status: ConnectionStatus;
+ sessionId: string | null;
+ error: string | null;
+ transcripts: TranscriptEntry[];
+}
+
+interface UseWebSocketOptions {
+ onReady?: (sessionId: string) => void;
+ onTranscript?: (transcript: TranscriptEntry) => void;
+ onError?: (code: string, message: string) => void;
+ onStopped?: (reason: string) => void;
+}
+
+export function useWebSocket(options: UseWebSocketOptions = {}) {
+ const { onReady, onTranscript, onError, onStopped } = options;
+
+ const [state, setState] = useState<WebSocketState>({
+ status: "disconnected",
+ sessionId: null,
+ error: null,
+ transcripts: [],
+ });
+
+ const wsRef = useRef<WebSocket | null>(null);
+ const transcriptIdRef = useRef(0);
+
+ // Store callbacks in refs to avoid recreating handlers
+ const callbacksRef = useRef({ onReady, onTranscript, onError, onStopped });
+ useEffect(() => {
+ callbacksRef.current = { onReady, onTranscript, onError, onStopped };
+ }, [onReady, onTranscript, onError, onStopped]);
+
+ const connect = useCallback((): Promise<boolean> => {
+ return new Promise((resolve) => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ resolve(true);
+ return;
+ }
+
+ // Close any existing connection
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+
+ setState((s) => ({ ...s, status: "connecting", error: null }));
+
+ try {
+ const ws = new WebSocket(LISTEN_ENDPOINT);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ setState((s) => ({ ...s, status: "connected", error: null }));
+ resolve(true);
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message: ServerMessage = JSON.parse(event.data);
+
+ switch (message.type) {
+ case "ready":
+ setState((s) => ({ ...s, sessionId: message.sessionId }));
+ callbacksRef.current.onReady?.(message.sessionId);
+ break;
+
+ case "transcript": {
+ const entry: TranscriptEntry = {
+ id: `t-${++transcriptIdRef.current}`,
+ speaker: message.speaker,
+ start: message.start,
+ end: message.end,
+ text: message.text,
+ isFinal: message.isFinal,
+ };
+
+ setState((s) => {
+ if (message.isFinal) {
+ // Final transcript replaces all previous transcripts from this speaker
+ const filtered = s.transcripts.filter(
+ (t) => t.speaker !== message.speaker
+ );
+ return { ...s, transcripts: [...filtered, entry] };
+ } else {
+ // Non-final: replace if same speaker and overlapping time, otherwise append
+ const existingIdx = s.transcripts.findIndex(
+ (t) =>
+ !t.isFinal &&
+ t.speaker === message.speaker &&
+ Math.abs(t.start - message.start) < 0.1
+ );
+ if (existingIdx >= 0) {
+ const newTranscripts = [...s.transcripts];
+ newTranscripts[existingIdx] = entry;
+ return { ...s, transcripts: newTranscripts };
+ }
+ return { ...s, transcripts: [...s.transcripts, entry] };
+ }
+ });
+
+ callbacksRef.current.onTranscript?.(entry);
+ break;
+ }
+
+ case "error":
+ setState((s) => ({ ...s, error: message.message }));
+ callbacksRef.current.onError?.(message.code, message.message);
+ break;
+
+ case "stopped":
+ setState((s) => ({ ...s, status: "disconnected" }));
+ callbacksRef.current.onStopped?.(message.reason);
+ break;
+ }
+ } catch {
+ console.error("Failed to parse WebSocket message:", event.data);
+ }
+ };
+
+ ws.onerror = () => {
+ setState((s) => ({
+ ...s,
+ status: "error",
+ error: "Failed to connect to server",
+ }));
+ resolve(false);
+ };
+
+ ws.onclose = (event) => {
+ // Check for specific close codes
+ let errorMessage: string | null = null;
+ if (event.code === 1006) {
+ errorMessage = "Connection failed - server may be unavailable";
+ } else if (event.code !== 1000 && event.code !== 1001) {
+ errorMessage = `Connection closed unexpectedly (code: ${event.code})`;
+ }
+
+ setState((s) => ({
+ ...s,
+ status: "disconnected",
+ sessionId: null,
+ error: errorMessage || s.error,
+ }));
+ wsRef.current = null;
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to create WebSocket connection";
+ setState((s) => ({
+ ...s,
+ status: "error",
+ error: message,
+ }));
+ resolve(false);
+ }
+ });
+ }, []);
+
+ const disconnect = useCallback(() => {
+ if (wsRef.current) {
+ wsRef.current.close(1000, "User disconnected");
+ wsRef.current = null;
+ }
+ setState((s) => ({ ...s, status: "disconnected", sessionId: null }));
+ }, []);
+
+ const sendMessage = useCallback((message: ClientMessage) => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify(message));
+ }
+ }, []);
+
+ const sendAudio = useCallback((samples: Float32Array) => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ // Convert Float32Array to bytes (little-endian)
+ const bytes = new Uint8Array(samples.length * 4);
+ const view = new DataView(bytes.buffer);
+ for (let i = 0; i < samples.length; i++) {
+ view.setFloat32(i * 4, samples[i], true);
+ }
+ wsRef.current.send(bytes);
+ }
+ }, []);
+
+ const startSession = useCallback(
+ (sampleRate: number, channels: number = 1) => {
+ sendMessage({
+ type: "start",
+ sampleRate,
+ channels,
+ encoding: "pcm32f",
+ });
+ },
+ [sendMessage]
+ );
+
+ const stopSession = useCallback(
+ (reason?: string) => {
+ sendMessage({
+ type: "stop",
+ reason,
+ });
+ },
+ [sendMessage]
+ );
+
+ const clearTranscripts = useCallback(() => {
+ setState((s) => ({ ...s, transcripts: [], error: null }));
+ }, []);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (wsRef.current) {
+ wsRef.current.close();
+ }
+ };
+ }, []);
+
+ return {
+ ...state,
+ connect,
+ disconnect,
+ sendAudio,
+ startSession,
+ stopSession,
+ clearTranscripts,
+ isConnected: state.status === "connected",
+ };
+}