@unoverse-platform/studio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * useAudioCapture — continuous microphone capture for full-duplex voice.
3
+ *
4
+ * Streams raw mic audio continuously while a call is active. The remote provider
5
+ * (Grok / Nova) handles VAD server-side, so we don't gate locally. Audio is
6
+ * downsampled to 16 kHz mono Int16 PCM and emitted in ~80 ms chunks via
7
+ * `onAudioData`.
8
+ *
9
+ * Ported verbatim from gravity-client/src/realtime/useAudioCapture.ts (the proven,
10
+ * live implementation) — the Unoverse `voice` service reuses it unchanged.
11
+ */
12
+
13
+ import { useRef, useCallback, useEffect, useState } from "react";
14
+ import { float32ToInt16, downsampleFloat32 } from "./audioUtils";
15
+
16
+ const TARGET_SAMPLE_RATE = 16000;
17
+ // ~80 ms of audio at 16 kHz: 1280 samples
18
+ const CHUNK_SAMPLES = 1280;
19
+
20
+ export interface UseAudioCaptureOptions {
21
+ /** Callback when an audio chunk is captured (PCM Int16 16 kHz mono ArrayBuffer) */
22
+ onAudioData?: (audioData: ArrayBuffer) => void;
23
+ /** Whether to mute capture. Defaults to false — full-duplex barge-in. */
24
+ isMuted?: boolean;
25
+ }
26
+
27
+ export interface UseAudioCaptureReturn {
28
+ startCapture: () => Promise<{ success: boolean; reason?: string }>;
29
+ stopCapture: () => Promise<{ success: boolean; reason?: string }>;
30
+ toggleCapture: () => Promise<{ success: boolean; reason?: string }>;
31
+ /** Manually mute / unmute outgoing mic frames (keeps the stream open) */
32
+ setMuted: (muted: boolean) => void;
33
+ isCapturing: boolean;
34
+ /** Server-driven (Grok / Nova VAD); updated externally via onAudioState. */
35
+ isSpeaking: boolean;
36
+ isLoading: boolean;
37
+ error: string | null;
38
+ }
39
+
40
+ export function useAudioCapture(options: UseAudioCaptureOptions = {}): UseAudioCaptureReturn {
41
+ const { onAudioData, isMuted = false } = options;
42
+
43
+ const [isCapturing, setIsCapturing] = useState(false);
44
+ const [isSpeaking] = useState(false);
45
+ const [isLoading, setIsLoading] = useState(false);
46
+ const [error, setError] = useState<string | null>(null);
47
+
48
+ const streamRef = useRef<MediaStream | null>(null);
49
+ const audioContextRef = useRef<AudioContext | null>(null);
50
+ const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
51
+ const workletRef = useRef<AudioWorkletNode | null>(null);
52
+ const scriptRef = useRef<ScriptProcessorNode | null>(null);
53
+ const onAudioDataRef = useRef(onAudioData);
54
+ const isMutedRef = useRef(isMuted);
55
+ const pendingRef = useRef<number[]>([]);
56
+ const isMountedRef = useRef(true);
57
+
58
+ useEffect(() => {
59
+ onAudioDataRef.current = onAudioData;
60
+ }, [onAudioData]);
61
+
62
+ useEffect(() => {
63
+ isMutedRef.current = isMuted;
64
+ }, [isMuted]);
65
+
66
+ const setMuted = useCallback((muted: boolean) => {
67
+ isMutedRef.current = muted;
68
+ }, []);
69
+
70
+ // Convert a Float32 frame at the source sample rate to 16 kHz Int16 chunks and
71
+ // emit them via onAudioData. Frames accumulate until we have at least
72
+ // CHUNK_SAMPLES at 16 kHz, then flush.
73
+ const handleFrame = useCallback((frame: Float32Array, fromRate: number) => {
74
+ if (isMutedRef.current) return;
75
+ const cb = onAudioDataRef.current;
76
+ if (!cb) return;
77
+
78
+ const downsampled = fromRate === TARGET_SAMPLE_RATE ? frame : downsampleFloat32(frame, fromRate, TARGET_SAMPLE_RATE);
79
+
80
+ const pending = pendingRef.current;
81
+ for (let i = 0; i < downsampled.length; i++) pending.push(downsampled[i]);
82
+
83
+ while (pending.length >= CHUNK_SAMPLES) {
84
+ const chunk = new Float32Array(pending.splice(0, CHUNK_SAMPLES));
85
+ const int16 = float32ToInt16(chunk);
86
+ const buf = int16.buffer.slice(int16.byteOffset, int16.byteOffset + int16.byteLength) as ArrayBuffer;
87
+ cb(buf);
88
+ }
89
+ }, []);
90
+
91
+ const teardownGraph = useCallback(async () => {
92
+ try {
93
+ if (workletRef.current) {
94
+ workletRef.current.port.onmessage = null;
95
+ workletRef.current.disconnect();
96
+ workletRef.current = null;
97
+ }
98
+ if (scriptRef.current) {
99
+ scriptRef.current.onaudioprocess = null;
100
+ scriptRef.current.disconnect();
101
+ scriptRef.current = null;
102
+ }
103
+ if (sourceRef.current) {
104
+ sourceRef.current.disconnect();
105
+ sourceRef.current = null;
106
+ }
107
+ if (streamRef.current) {
108
+ streamRef.current.getTracks().forEach((t) => t.stop());
109
+ streamRef.current = null;
110
+ }
111
+ if (audioContextRef.current && audioContextRef.current.state !== "closed") {
112
+ await audioContextRef.current.close();
113
+ }
114
+ audioContextRef.current = null;
115
+ pendingRef.current = [];
116
+ } catch (err) {
117
+ console.warn("[AudioCapture] teardown error:", err);
118
+ }
119
+ }, []);
120
+
121
+ const startCapture = useCallback(async (): Promise<{ success: boolean; reason?: string }> => {
122
+ if (isCapturing || streamRef.current) {
123
+ return { success: true };
124
+ }
125
+
126
+ setIsLoading(true);
127
+ setError(null);
128
+
129
+ try {
130
+ const stream = await navigator.mediaDevices.getUserMedia({
131
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
132
+ });
133
+ streamRef.current = stream;
134
+
135
+ const AudioContextClass = (window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).AudioContext ||
136
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
137
+ const audioContext: AudioContext = new (AudioContextClass as typeof AudioContext)();
138
+ audioContextRef.current = audioContext;
139
+ const fromRate = audioContext.sampleRate;
140
+
141
+ const source = audioContext.createMediaStreamSource(stream);
142
+ sourceRef.current = source;
143
+
144
+ // Prefer AudioWorklet (modern). Fall back to ScriptProcessor.
145
+ let usedWorklet = false;
146
+ if (audioContext.audioWorklet) {
147
+ try {
148
+ const workletCode = `
149
+ class CaptureProcessor extends AudioWorkletProcessor {
150
+ process(inputs) {
151
+ const ch = inputs[0] && inputs[0][0];
152
+ if (ch && ch.length) {
153
+ this.port.postMessage(new Float32Array(ch));
154
+ }
155
+ return true;
156
+ }
157
+ }
158
+ registerProcessor('gravity-capture-processor', CaptureProcessor);
159
+ `;
160
+ const blob = new Blob([workletCode], { type: "application/javascript" });
161
+ const url = URL.createObjectURL(blob);
162
+ await audioContext.audioWorklet.addModule(url);
163
+ URL.revokeObjectURL(url);
164
+
165
+ const node = new AudioWorkletNode(audioContext, "gravity-capture-processor");
166
+ node.port.onmessage = (ev: MessageEvent<Float32Array>) => {
167
+ handleFrame(ev.data, fromRate);
168
+ };
169
+ source.connect(node);
170
+ // The worklet must connect to destination to keep ticking; route through a
171
+ // muted gain so the mic is not played back.
172
+ const sink = audioContext.createGain();
173
+ sink.gain.value = 0;
174
+ node.connect(sink).connect(audioContext.destination);
175
+ workletRef.current = node;
176
+ usedWorklet = true;
177
+ } catch (err) {
178
+ console.warn("[AudioCapture] AudioWorklet unavailable, falling back to ScriptProcessor:", err);
179
+ }
180
+ }
181
+
182
+ if (!usedWorklet) {
183
+ const bufferSize = 4096;
184
+ const node = audioContext.createScriptProcessor(bufferSize, 1, 1);
185
+ node.onaudioprocess = (ev) => {
186
+ const ch = ev.inputBuffer.getChannelData(0);
187
+ handleFrame(new Float32Array(ch), fromRate);
188
+ };
189
+ source.connect(node);
190
+ const sink = audioContext.createGain();
191
+ sink.gain.value = 0;
192
+ node.connect(sink).connect(audioContext.destination);
193
+ scriptRef.current = node;
194
+ }
195
+
196
+ if (isMountedRef.current) {
197
+ setIsCapturing(true);
198
+ setIsLoading(false);
199
+ }
200
+ return { success: true };
201
+ } catch (err: unknown) {
202
+ const msg = err instanceof Error ? err.message : String(err);
203
+ console.error("[AudioCapture] Failed to start:", err);
204
+ if (isMountedRef.current) {
205
+ setError(msg);
206
+ setIsLoading(false);
207
+ }
208
+ await teardownGraph();
209
+ return { success: false, reason: msg };
210
+ }
211
+ }, [isCapturing, handleFrame, teardownGraph]);
212
+
213
+ const stopCapture = useCallback(async (): Promise<{ success: boolean; reason?: string }> => {
214
+ if (!streamRef.current && !audioContextRef.current) {
215
+ return { success: false, reason: "not_running" };
216
+ }
217
+ await teardownGraph();
218
+ if (isMountedRef.current) {
219
+ setIsCapturing(false);
220
+ }
221
+ return { success: true };
222
+ }, [teardownGraph]);
223
+
224
+ const toggleCapture = useCallback(async () => {
225
+ if (isCapturing) return await stopCapture();
226
+ return await startCapture();
227
+ }, [isCapturing, startCapture, stopCapture]);
228
+
229
+ useEffect(() => {
230
+ return () => {
231
+ isMountedRef.current = false;
232
+ teardownGraph();
233
+ };
234
+ }, [teardownGraph]);
235
+
236
+ return { startCapture, stopCapture, toggleCapture, setMuted, isCapturing, isSpeaking, isLoading, error };
237
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * useAudioPlayback — LPCM audio playback with timeline-based scheduling.
3
+ *
4
+ * Web Audio API for low-latency playback and accurate timing. Audio format from
5
+ * the server (Nova / Grok): LPCM Int16, 24 kHz, mono.
6
+ *
7
+ * Ported from gravity-client/src/realtime/useAudioPlayback.ts (the proven, live
8
+ * implementation), trimmed to the surface the `voice` service uses.
9
+ */
10
+
11
+ import { useRef, useCallback, useEffect, useState } from "react";
12
+
13
+ /** Decode LPCM (Int16, 24 kHz, mono) → AudioBuffer. */
14
+ function decodeLPCM(audioContext: AudioContext, data: ArrayBuffer): AudioBuffer {
15
+ const sampleRate = 24000;
16
+ const int16 = new Int16Array(data);
17
+ const numSamples = int16.length;
18
+ const audioBuffer = audioContext.createBuffer(1, numSamples, sampleRate);
19
+ const channelData = audioBuffer.getChannelData(0);
20
+ for (let i = 0; i < numSamples; i++) channelData[i] = int16[i] / 32768;
21
+ return audioBuffer;
22
+ }
23
+
24
+ export interface UseAudioPlaybackReturn {
25
+ playAudio: (audioData: ArrayBuffer) => void;
26
+ stopAll: () => void;
27
+ isPlaying: boolean;
28
+ /** Mark that no more chunks are coming — isPlaying clears when the last finishes. */
29
+ markAsLastChunk: () => void;
30
+ }
31
+
32
+ interface QueuedAudio {
33
+ audioData: ArrayBuffer;
34
+ }
35
+
36
+ export function useAudioPlayback(): UseAudioPlaybackReturn {
37
+ const [isPlaying, setIsPlaying] = useState(false);
38
+ const audioContextRef = useRef<AudioContext | null>(null);
39
+ const gainNodeRef = useRef<GainNode | null>(null);
40
+ const queueRef = useRef<QueuedAudio[]>([]);
41
+ const nextTimeRef = useRef<number>(0);
42
+ const isProcessingRef = useRef(false);
43
+ const activeSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
44
+ const hasStartedRef = useRef(false);
45
+ const isLastChunkRef = useRef(false);
46
+
47
+ const getAudioContext = useCallback(() => {
48
+ if (!audioContextRef.current) {
49
+ audioContextRef.current = new AudioContext({ sampleRate: 24000 });
50
+ gainNodeRef.current = audioContextRef.current.createGain();
51
+ gainNodeRef.current.connect(audioContextRef.current.destination);
52
+ }
53
+ if (audioContextRef.current.state === "suspended") {
54
+ audioContextRef.current.resume();
55
+ }
56
+ return audioContextRef.current;
57
+ }, []);
58
+
59
+ const processQueue = useCallback(async () => {
60
+ if (isProcessingRef.current) return;
61
+ if (queueRef.current.length === 0) return;
62
+
63
+ isProcessingRef.current = true;
64
+ const audioContext = getAudioContext();
65
+ const gainNode = gainNodeRef.current!;
66
+
67
+ while (queueRef.current.length > 0) {
68
+ const item = queueRef.current.shift()!;
69
+ try {
70
+ const audioBuffer = decodeLPCM(audioContext, item.audioData);
71
+ const now = audioContext.currentTime;
72
+ const startTime = Math.max(now, nextTimeRef.current);
73
+
74
+ const source = audioContext.createBufferSource();
75
+ source.buffer = audioBuffer;
76
+ source.connect(gainNode);
77
+ activeSourcesRef.current.add(source);
78
+
79
+ if (!hasStartedRef.current) {
80
+ hasStartedRef.current = true;
81
+ setIsPlaying(true);
82
+ }
83
+
84
+ source.start(startTime);
85
+ nextTimeRef.current = startTime + audioBuffer.duration;
86
+
87
+ source.onended = () => {
88
+ activeSourcesRef.current.delete(source);
89
+ if (isLastChunkRef.current && activeSourcesRef.current.size === 0 && queueRef.current.length === 0) {
90
+ setIsPlaying(false);
91
+ hasStartedRef.current = false;
92
+ isLastChunkRef.current = false;
93
+ }
94
+ };
95
+ } catch (error) {
96
+ console.error("[AudioPlayback] Failed to decode audio:", error);
97
+ }
98
+ }
99
+
100
+ isProcessingRef.current = false;
101
+ }, [getAudioContext]);
102
+
103
+ const playAudio = useCallback(
104
+ (audioData: ArrayBuffer) => {
105
+ queueRef.current.push({ audioData });
106
+ processQueue();
107
+ },
108
+ [processQueue],
109
+ );
110
+
111
+ const stopAll = useCallback(() => {
112
+ activeSourcesRef.current.forEach((source) => {
113
+ try {
114
+ source.stop();
115
+ } catch {
116
+ // already stopped
117
+ }
118
+ });
119
+ activeSourcesRef.current.clear();
120
+ queueRef.current = [];
121
+ nextTimeRef.current = 0;
122
+ hasStartedRef.current = false;
123
+ isProcessingRef.current = false;
124
+ isLastChunkRef.current = false;
125
+ setIsPlaying(false);
126
+ }, []);
127
+
128
+ const markAsLastChunk = useCallback(() => {
129
+ isLastChunkRef.current = true;
130
+ if (activeSourcesRef.current.size === 0 && queueRef.current.length === 0 && hasStartedRef.current) {
131
+ setIsPlaying(false);
132
+ hasStartedRef.current = false;
133
+ isLastChunkRef.current = false;
134
+ }
135
+ }, []);
136
+
137
+ useEffect(() => {
138
+ return () => {
139
+ stopAll();
140
+ if (audioContextRef.current) audioContextRef.current.close();
141
+ };
142
+ }, [stopAll]);
143
+
144
+ return { playAudio, stopAll, isPlaying, markAsLastChunk };
145
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * useRealtimeWebSocket — binary WebSocket for real-time audio streaming.
3
+ *
4
+ * Bidirectional audio over the `/ws/gravity` lane: sends binary PCM Int16 16 kHz
5
+ * up, receives binary LPCM Int16 24 kHz back, plus JSON control messages for
6
+ * audio-state events. This is the ONLY remaining WS use (docs/VOICE_STREAMING_GUIDE);
7
+ * the audio-state control events ride this lane WITH the frames so they stay
8
+ * frame-synchronized — they never go on the MCP component stream.
9
+ *
10
+ * Ported verbatim from gravity-client/src/realtime/useRealtimeWebSocket.ts — it is
11
+ * already self-contained (no dependency on the legacy unified client), which is why
12
+ * the Unoverse `voice` service can own it directly.
13
+ */
14
+
15
+ import { useCallback, useRef, useState, useEffect } from "react";
16
+ import type { ControlMessage, AudioStateEvent } from "./types";
17
+
18
+ export interface UseRealtimeWebSocketOptions {
19
+ /** Conversation ID for the WebSocket session. */
20
+ sessionId: string;
21
+ userId?: string;
22
+ chatId?: string;
23
+ /** WebSocket base URL (e.g. ws://localhost:4100). Defaults to current origin. */
24
+ wsUrl?: string;
25
+ /** Function to get a fresh access token (called on each connect). */
26
+ getAccessToken?: () => Promise<string | null> | string | null;
27
+ onAudioReceived?: (audioData: ArrayBuffer) => void;
28
+ onConnectionChange?: (connected: boolean) => void;
29
+ onControlMessage?: (message: ControlMessage) => void;
30
+ onAudioState?: (event: AudioStateEvent) => void;
31
+ }
32
+
33
+ export interface UseRealtimeWebSocketReturn {
34
+ isConnected: boolean;
35
+ sendAudio: (audioData: ArrayBuffer) => void;
36
+ sendControl: (type: string, data?: Record<string, unknown>) => void;
37
+ connect: () => Promise<void>;
38
+ disconnect: () => void;
39
+ }
40
+
41
+ export function useRealtimeWebSocket(options: UseRealtimeWebSocketOptions): UseRealtimeWebSocketReturn {
42
+ const { sessionId, userId, chatId, wsUrl, getAccessToken, onAudioReceived, onConnectionChange, onControlMessage, onAudioState } = options;
43
+
44
+ const wsRef = useRef<WebSocket | null>(null);
45
+ const [isConnected, setIsConnected] = useState(false);
46
+
47
+ const getWsUrl = useCallback(async () => {
48
+ let baseUrl: string;
49
+ if (wsUrl) {
50
+ baseUrl = `${wsUrl}/ws/gravity`;
51
+ } else {
52
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
53
+ baseUrl = `${protocol}//${window.location.host}/ws/gravity`;
54
+ }
55
+ let token: string | null = null;
56
+ if (getAccessToken) {
57
+ try {
58
+ token = await getAccessToken();
59
+ } catch (error) {
60
+ console.warn("[RealtimeWebSocket] Failed to get access token:", error);
61
+ }
62
+ }
63
+ return token ? `${baseUrl}?token=${encodeURIComponent(token)}` : baseUrl;
64
+ }, [wsUrl, getAccessToken]);
65
+
66
+ const connect = useCallback(async (): Promise<void> => {
67
+ if (wsRef.current?.readyState === WebSocket.OPEN) return;
68
+ if (wsRef.current?.readyState === WebSocket.CONNECTING) {
69
+ return new Promise((resolve) => {
70
+ const checkInterval = setInterval(() => {
71
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
72
+ clearInterval(checkInterval);
73
+ resolve();
74
+ }
75
+ }, 50);
76
+ });
77
+ }
78
+
79
+ const url = await getWsUrl();
80
+ console.log("[RealtimeWebSocket] Connecting to:", url.replace(/token=[^&]+/, "token=***"));
81
+
82
+ return new Promise((resolve, reject) => {
83
+ const ws = new WebSocket(url);
84
+ ws.binaryType = "arraybuffer";
85
+
86
+ const timeout = setTimeout(() => reject(new Error("WebSocket connection timeout")), 10000);
87
+
88
+ ws.onopen = () => {
89
+ clearTimeout(timeout);
90
+ const initMessage = {
91
+ type: "INIT_SESSION",
92
+ conversationId: sessionId,
93
+ userId: userId || "anonymous",
94
+ chatId: chatId || `chat_${sessionId}`,
95
+ };
96
+ ws.send(JSON.stringify(initMessage));
97
+ setIsConnected(true);
98
+ onConnectionChange?.(true);
99
+ resolve();
100
+ };
101
+
102
+ ws.onmessage = (event) => {
103
+ if (event.data instanceof ArrayBuffer) {
104
+ onAudioReceived?.(event.data);
105
+ } else {
106
+ try {
107
+ const message: ControlMessage = JSON.parse(event.data as string);
108
+ onControlMessage?.(message);
109
+ // Only honour control-channel state events (`message.state`).
110
+ // `message.audioState` is per-chunk metadata on audio publishes.
111
+ if (message.state && onAudioState) {
112
+ onAudioState({ state: message.state, metadata: message.metadata });
113
+ }
114
+ } catch (error) {
115
+ console.error("[RealtimeWebSocket] Failed to parse control message:", error);
116
+ }
117
+ }
118
+ };
119
+
120
+ ws.onerror = (error) => {
121
+ clearTimeout(timeout);
122
+ console.error("[RealtimeWebSocket] Error:", error);
123
+ reject(error);
124
+ };
125
+
126
+ ws.onclose = () => {
127
+ setIsConnected(false);
128
+ onConnectionChange?.(false);
129
+ };
130
+
131
+ wsRef.current = ws;
132
+ });
133
+ }, [getWsUrl, sessionId, userId, chatId, onAudioReceived, onConnectionChange, onControlMessage, onAudioState]);
134
+
135
+ const disconnect = useCallback(() => {
136
+ if (wsRef.current) {
137
+ wsRef.current.close();
138
+ wsRef.current = null;
139
+ }
140
+ }, []);
141
+
142
+ const sendAudio = useCallback((audioData: ArrayBuffer) => {
143
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
144
+ wsRef.current.send(audioData);
145
+ }
146
+ }, []);
147
+
148
+ const sendControl = useCallback((type: string, data?: Record<string, unknown>) => {
149
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
150
+ wsRef.current.send(JSON.stringify({ type, ...data }));
151
+ }
152
+ }, []);
153
+
154
+ useEffect(() => {
155
+ return () => {
156
+ if (wsRef.current) {
157
+ wsRef.current.close();
158
+ wsRef.current = null;
159
+ }
160
+ };
161
+ }, []);
162
+
163
+ return { isConnected, sendAudio, sendControl, connect, disconnect };
164
+ }