@alia.onl/sdk 1.0.0 → 3.0.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 (44) hide show
  1. package/package.json +37 -3
  2. package/src/components/AliaChatContent.tsx +313 -0
  3. package/src/components/AliaChatInput.tsx +174 -27
  4. package/src/components/AliaChatMessageList.tsx +220 -34
  5. package/src/components/AliaChatScreen.tsx +132 -0
  6. package/src/components/AliaChatSheet.tsx +36 -49
  7. package/src/components/AliaChatSuggestions.tsx +1 -1
  8. package/src/components/AliaFace.tsx +402 -0
  9. package/src/components/Markdown.tsx +288 -0
  10. package/src/components/Reasoning.tsx +154 -0
  11. package/src/components/ThinkingIndicator.tsx +134 -0
  12. package/src/components/ui/button.tsx +104 -0
  13. package/src/components/ui/chat-text-input.tsx +164 -0
  14. package/src/components/ui/dropdown-menu.tsx +2 -0
  15. package/src/components/ui/dropdown-menu.web.tsx +355 -0
  16. package/src/components/ui/prompt-input/actions.tsx +28 -0
  17. package/src/components/ui/prompt-input/add-menu.tsx +83 -0
  18. package/src/components/ui/prompt-input/attachments.tsx +183 -0
  19. package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
  20. package/src/components/ui/prompt-input/context.tsx +71 -0
  21. package/src/components/ui/prompt-input/mic-button.tsx +53 -0
  22. package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
  23. package/src/components/ui/prompt-input/submit-button.tsx +49 -0
  24. package/src/components/ui/prompt-input/textarea.tsx +64 -0
  25. package/src/components/ui/text.tsx +29 -0
  26. package/src/components/voice/AudioWaveVisualizer.tsx +377 -0
  27. package/src/components/voice/VoiceControls.tsx +180 -0
  28. package/src/components/voice/VoiceOverlay.tsx +43 -0
  29. package/src/hooks/useAliaChat.ts +278 -91
  30. package/src/hooks/useAudioLevelMonitor.ts +142 -0
  31. package/src/hooks/useAudioLevels.ts +51 -0
  32. package/src/hooks/useDocumentPicker.ts +37 -0
  33. package/src/hooks/useImagePicker.ts +38 -0
  34. package/src/hooks/useSoundEffects.ts +128 -0
  35. package/src/hooks/useSpeechToText.ts +182 -0
  36. package/src/hooks/useTTS.ts +233 -0
  37. package/src/hooks/useVoiceRoom.ts +420 -0
  38. package/src/index.ts +71 -6
  39. package/src/lib/keyboard.native.tsx +6 -0
  40. package/src/lib/keyboard.tsx +25 -0
  41. package/src/lib/utils.ts +14 -0
  42. package/src/optional-deps.d.ts +86 -0
  43. package/src/theme.ts +25 -2
  44. package/src/types.ts +63 -1
@@ -1,11 +1,11 @@
1
- import { useState, useRef, useCallback } from 'react';
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import { useOxy } from '@oxyhq/services';
3
3
  import type { ChatMessage, ToolInvocation } from '../types';
4
4
 
5
- const API_URL = process.env.EXPO_PUBLIC_API_URL;
5
+ const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
6
6
 
7
7
  export interface UseAliaChatOptions {
8
- /** Alia API base URL (default: EXPO_PUBLIC_API_URL) */
8
+ /** Alia API base URL (default: EXPO_PUBLIC_ALIA_API_URL or https://api.alia.onl) */
9
9
  apiUrl?: string;
10
10
  /** Alia model to use (default: 'alia-v1') */
11
11
  model?: string;
@@ -17,22 +17,27 @@ export interface UseAliaChatOptions {
17
17
 
18
18
  export interface UseAliaChatReturn {
19
19
  messages: ChatMessage[];
20
+ setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
20
21
  send: (text: string) => void;
21
22
  isStreaming: boolean;
23
+ stop: () => void;
22
24
  clear: () => void;
23
25
  error: string | null;
24
26
  }
25
27
 
26
- let nextId = 0;
27
28
  function generateId(): string {
28
- return `msg-${Date.now()}-${nextId++}`;
29
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
30
+ return `msg-${crypto.randomUUID()}`;
31
+ }
32
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
29
33
  }
30
34
 
31
35
  /**
32
36
  * SSE streaming chat hook for Alia.
33
37
  *
34
38
  * Sends messages to Alia's /v1/chat/completions endpoint and streams
35
- * responses back, including tool invocations.
39
+ * responses back, including tool invocations, reasoning, research progress,
40
+ * and plan previews.
36
41
  */
37
42
  export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
38
43
  const {
@@ -48,11 +53,98 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
48
53
  const [error, setError] = useState<string | null>(null);
49
54
  const abortRef = useRef<AbortController | null>(null);
50
55
 
56
+ // Use a ref to read current messages without putting it in send's dep array
57
+ const messagesRef = useRef<ChatMessage[]>([]);
58
+ useEffect(() => { messagesRef.current = messages; }, [messages]);
59
+
60
+ // Batching: accumulate streaming content and flush at ~20fps
61
+ const pendingContentRef = useRef('');
62
+ const pendingReasoningRef = useRef('');
63
+ const toolInvocationsRef = useRef<ToolInvocation[]>([]);
64
+ const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
65
+
66
+ const flushPendingUpdates = useCallback(() => {
67
+ const content = pendingContentRef.current;
68
+ const reasoning = pendingReasoningRef.current;
69
+ const tools = toolInvocationsRef.current;
70
+ if (!content && !reasoning && tools.length === 0) return;
71
+
72
+ pendingContentRef.current = '';
73
+ pendingReasoningRef.current = '';
74
+
75
+ setMessages((prev) => {
76
+ const updated = [...prev];
77
+ const last = updated[updated.length - 1];
78
+ if (last?.role === 'assistant') {
79
+ const changes: Partial<ChatMessage> = {};
80
+ if (content) changes.content = last.content + content;
81
+ if (reasoning) changes.thinking = (last.thinking || '') + reasoning;
82
+ if (tools.length > 0) changes.toolInvocations = [...tools];
83
+ updated[updated.length - 1] = { ...last, ...changes };
84
+ }
85
+ return updated;
86
+ });
87
+ }, []);
88
+
89
+ const scheduleFlush = useCallback(() => {
90
+ if (flushTimerRef.current) return;
91
+ flushTimerRef.current = setTimeout(() => {
92
+ flushTimerRef.current = null;
93
+ flushPendingUpdates();
94
+ }, 50);
95
+ }, [flushPendingUpdates]);
96
+
97
+ // Cleanup flush timer on unmount
98
+ useEffect(() => {
99
+ return () => {
100
+ flushPendingUpdates();
101
+ if (flushTimerRef.current) {
102
+ clearTimeout(flushTimerRef.current);
103
+ flushTimerRef.current = null;
104
+ }
105
+ };
106
+ }, [flushPendingUpdates]);
107
+
51
108
  const getToken = useCallback((): string | null => {
52
109
  if (accessTokenProp) return accessTokenProp;
53
110
  return oxyServices.httpService.getAccessToken();
54
111
  }, [accessTokenProp, oxyServices]);
55
112
 
113
+ /** Update the last assistant message with partial changes (callback receives current message) */
114
+ const updateAssistant = useCallback(
115
+ (updater: (msg: ChatMessage) => Partial<ChatMessage>) => {
116
+ setMessages((prev) => {
117
+ const updated = [...prev];
118
+ const last = updated[updated.length - 1];
119
+ if (last?.role === 'assistant') {
120
+ updated[updated.length - 1] = { ...last, ...updater(last) };
121
+ }
122
+ return updated;
123
+ });
124
+ },
125
+ [],
126
+ );
127
+
128
+ /** Apply a tool result to the last assistant message and sync the ref */
129
+ const applyToolResult = useCallback(
130
+ (toolCallId: string, name: string | undefined, output: any) => {
131
+ updateAssistant((last) => {
132
+ const invocations = [...(last.toolInvocations || [])];
133
+ const idx = invocations.findIndex((t) => t.toolCallId === toolCallId);
134
+ if (idx >= 0) {
135
+ invocations[idx] = { ...invocations[idx], state: 'result', result: output };
136
+ } else {
137
+ invocations.push({ toolCallId, toolName: name || 'unknown', state: 'result', result: output });
138
+ }
139
+ return { toolInvocations: invocations };
140
+ });
141
+ toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
142
+ t.toolCallId === toolCallId ? { ...t, state: 'result' as const, result: output } : t
143
+ );
144
+ },
145
+ [updateAssistant],
146
+ );
147
+
56
148
  const send = useCallback(
57
149
  async (text: string) => {
58
150
  const trimmed = text.trim();
@@ -86,26 +178,40 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
86
178
  setMessages((prev) => [...prev, userMsg, assistantMsg]);
87
179
  setIsStreaming(true);
88
180
 
89
- // Build messages payload for the API
181
+ // Build messages payload for the API (read from ref, not closure)
90
182
  const apiMessages: Array<{ role: string; content: string }> = [];
91
183
 
92
- // System context (tells Alia which app the user is in)
93
184
  if (clientContext) {
94
185
  apiMessages.push({ role: 'system', content: clientContext });
95
186
  }
96
187
 
97
- // Previous conversation history
98
- for (const msg of messages) {
188
+ // Include full conversation history with tool context
189
+ for (const msg of messagesRef.current) {
99
190
  if (msg.role === 'system') continue;
100
191
  apiMessages.push({ role: msg.role, content: msg.content });
192
+ // Include tool results so the AI has full context
193
+ if (msg.toolInvocations?.length) {
194
+ for (const tool of msg.toolInvocations) {
195
+ if (tool.state === 'result' && tool.result != null) {
196
+ apiMessages.push({
197
+ role: 'system',
198
+ content: `[Tool result from ${tool.toolName}: ${JSON.stringify(tool.result).slice(0, 500)}]`,
199
+ });
200
+ }
201
+ }
202
+ }
101
203
  }
102
204
 
103
- // New user message
104
205
  apiMessages.push({ role: 'user', content: trimmed });
105
206
 
106
207
  const controller = new AbortController();
107
208
  abortRef.current = controller;
108
209
 
210
+ // Reset batching state
211
+ pendingContentRef.current = '';
212
+ pendingReasoningRef.current = '';
213
+ toolInvocationsRef.current = [];
214
+
109
215
  try {
110
216
  const response = await fetch(`${apiUrl}/v1/chat/completions`, {
111
217
  method: 'POST',
@@ -130,23 +236,15 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
130
236
  if (!response.body || typeof response.body.getReader !== 'function') {
131
237
  const json = await response.json();
132
238
  const content = json.choices?.[0]?.message?.content ?? '';
133
- setMessages((prev) => {
134
- const updated = [...prev];
135
- const last = updated[updated.length - 1];
136
- if (last?.role === 'assistant') {
137
- updated[updated.length - 1] = { ...last, content };
138
- }
139
- return updated;
140
- });
239
+ updateAssistant(() => ({ content }));
141
240
  return;
142
241
  }
143
242
 
144
- // Stream SSE
243
+ // Stream SSE (supports named events: event: X\ndata: Y)
145
244
  const reader = response.body.getReader();
146
245
  const decoder = new TextDecoder();
147
246
  let buffer = '';
148
- let accumulatedContent = '';
149
- const toolInvocations: ToolInvocation[] = [];
247
+ let currentEventType = '';
150
248
 
151
249
  try {
152
250
  while (true) {
@@ -158,72 +256,151 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
158
256
  buffer = lines.pop() ?? '';
159
257
 
160
258
  for (const line of lines) {
259
+ // Track named SSE event type
260
+ if (line.startsWith('event: ')) {
261
+ currentEventType = line.slice(7).trim();
262
+ continue;
263
+ }
264
+
265
+ // Reset event type on empty line (SSE event boundary)
266
+ if (line === '') {
267
+ currentEventType = '';
268
+ continue;
269
+ }
270
+
161
271
  const trimmedLine = line.trim();
162
- if (!trimmedLine || !trimmedLine.startsWith('data: ')) continue;
272
+ if (!trimmedLine.startsWith('data: ')) continue;
163
273
  const data = trimmedLine.slice(6);
164
- if (data === '[DONE]') continue;
274
+ if (data === '[DONE]') { currentEventType = ''; continue; }
165
275
 
166
276
  try {
167
277
  const parsed = JSON.parse(data);
168
278
 
169
- // Standard content delta
170
- const delta = parsed.choices?.[0]?.delta?.content;
171
- if (delta) {
172
- accumulatedContent += delta;
173
- setMessages((prev) => {
174
- const updated = [...prev];
175
- const last = updated[updated.length - 1];
176
- if (last?.role === 'assistant') {
177
- updated[updated.length - 1] = {
178
- ...last,
179
- content: accumulatedContent,
180
- toolInvocations: toolInvocations.length > 0 ? [...toolInvocations] : undefined,
181
- };
279
+ // ── Named SSE events (Alia extensions) ──
280
+ if (currentEventType) {
281
+ switch (currentEventType) {
282
+ case 'alia.reasoning': {
283
+ const content = parsed.content;
284
+ if (content) {
285
+ pendingReasoningRef.current += content;
286
+ scheduleFlush();
287
+ }
288
+ currentEventType = '';
289
+ continue;
290
+ }
291
+ case 'alia.tool_result': {
292
+ const { tool_call_id, name, output } = parsed;
293
+ if (tool_call_id) {
294
+ applyToolResult(tool_call_id, name, output);
295
+ }
296
+ currentEventType = '';
297
+ continue;
182
298
  }
183
- return updated;
184
- });
299
+ case 'alia.research_progress': {
300
+ updateAssistant((last) => ({
301
+ researchProgress: {
302
+ phase: parsed.phase,
303
+ message: parsed.message,
304
+ subQuestions: parsed.subQuestions || last.researchProgress?.subQuestions,
305
+ sourcesFound: parsed.sourcesFound,
306
+ currentQuery: parsed.currentQuery,
307
+ iteration: parsed.iteration,
308
+ },
309
+ }));
310
+ currentEventType = '';
311
+ continue;
312
+ }
313
+ case 'alia.plan_preview': {
314
+ updateAssistant(() => ({
315
+ pendingPlan: {
316
+ planId: parsed.planId,
317
+ steps: parsed.steps || [],
318
+ approved: false,
319
+ rejected: false,
320
+ },
321
+ }));
322
+ currentEventType = '';
323
+ continue;
324
+ }
325
+ default:
326
+ // Unknown named event — skip
327
+ currentEventType = '';
328
+ continue;
329
+ }
330
+ }
331
+
332
+ // ── Standard OpenAI data events ──
333
+
334
+ const choice = parsed.choices?.[0];
335
+ if (!choice) {
336
+ // Legacy alia.tool_call event (type-based, not named SSE)
337
+ if (parsed.type === 'alia.tool_call') {
338
+ toolInvocationsRef.current = [
339
+ ...toolInvocationsRef.current,
340
+ {
341
+ toolName: parsed.tool || 'unknown',
342
+ state: 'call',
343
+ args: parsed.args,
344
+ },
345
+ ];
346
+ flushPendingUpdates();
347
+ }
348
+ // Legacy format matches by toolName (no toolCallId), so can't use applyToolResult
349
+ if (parsed.type === 'alia.tool_result') {
350
+ toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
351
+ t.toolName === parsed.tool && t.state === 'call'
352
+ ? { ...t, state: 'result' as const, result: parsed.result }
353
+ : t,
354
+ );
355
+ flushPendingUpdates();
356
+ }
357
+ continue;
358
+ }
359
+
360
+ const delta = choice.delta;
361
+ if (!delta) continue;
362
+
363
+ // Reasoning/thinking content (batched)
364
+ if (delta.reasoning) {
365
+ pendingReasoningRef.current += delta.reasoning;
366
+ scheduleFlush();
185
367
  }
186
368
 
187
- // Alia tool call event
188
- if (parsed.type === 'alia.tool_call') {
189
- toolInvocations.push({
190
- toolName: parsed.tool || 'unknown',
191
- state: 'call',
192
- args: parsed.args,
193
- });
194
- setMessages((prev) => {
195
- const updated = [...prev];
196
- const last = updated[updated.length - 1];
197
- if (last?.role === 'assistant') {
198
- updated[updated.length - 1] = {
199
- ...last,
200
- toolInvocations: [...toolInvocations],
201
- };
369
+ // Text content (batched)
370
+ if (delta.content) {
371
+ pendingContentRef.current += delta.content;
372
+ scheduleFlush();
373
+ }
374
+
375
+ // Tool calls (OpenAI format: delta.tool_calls)
376
+ if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
377
+ for (const tc of delta.tool_calls) {
378
+ const toolCallId = tc.id;
379
+ const toolName = tc.function?.name;
380
+ if (!toolCallId || !toolName) continue;
381
+
382
+ let args: any;
383
+ if (tc.function?.arguments) {
384
+ try { args = JSON.parse(tc.function.arguments); } catch { args = { _raw: tc.function.arguments }; }
202
385
  }
203
- return updated;
204
- });
386
+
387
+ const invocation: ToolInvocation = { toolCallId, toolName, state: 'call', args };
388
+ const idx = toolInvocationsRef.current.findIndex((t) => t.toolCallId === toolCallId);
389
+ if (idx >= 0) {
390
+ toolInvocationsRef.current[idx] = invocation;
391
+ } else {
392
+ toolInvocationsRef.current = [...toolInvocationsRef.current, invocation];
393
+ }
394
+ flushPendingUpdates();
395
+ }
205
396
  }
206
397
 
207
- // Alia tool result event
208
- if (parsed.type === 'alia.tool_result') {
209
- const existing = toolInvocations.find(
210
- (t) => t.toolName === parsed.tool && t.state === 'call',
211
- );
212
- if (existing) {
213
- existing.state = 'result';
214
- existing.result = parsed.result;
398
+ // Tool results (delta.tool_result)
399
+ if (delta.tool_result) {
400
+ const { tool_call_id, name, output } = delta.tool_result;
401
+ if (tool_call_id) {
402
+ applyToolResult(tool_call_id, name, output);
215
403
  }
216
- setMessages((prev) => {
217
- const updated = [...prev];
218
- const last = updated[updated.length - 1];
219
- if (last?.role === 'assistant') {
220
- updated[updated.length - 1] = {
221
- ...last,
222
- toolInvocations: [...toolInvocations],
223
- };
224
- }
225
- return updated;
226
- });
227
404
  }
228
405
  } catch {
229
406
  // Skip malformed chunks
@@ -237,34 +414,44 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
237
414
  if (err?.name === 'AbortError') return;
238
415
  const errorMessage = err?.message || 'Something went wrong';
239
416
  setError(errorMessage);
240
- setMessages((prev) => {
241
- const updated = [...prev];
242
- const last = updated[updated.length - 1];
243
- if (last?.role === 'assistant' && !last.content) {
244
- updated[updated.length - 1] = {
245
- ...last,
246
- content: "I'm having trouble connecting right now. Please try again.",
247
- };
248
- }
249
- return updated;
250
- });
417
+ updateAssistant((last) =>
418
+ !last.content ? { content: "I'm having trouble connecting right now. Please try again." } : {}
419
+ );
251
420
  } finally {
421
+ // Flush any remaining batched content
422
+ flushPendingUpdates();
423
+ if (flushTimerRef.current) {
424
+ clearTimeout(flushTimerRef.current);
425
+ flushTimerRef.current = null;
426
+ }
252
427
  setIsStreaming(false);
253
428
  abortRef.current = null;
254
429
  }
255
430
  },
256
- [isStreaming, messages, getToken, apiUrl, model, clientContext],
431
+ [isStreaming, getToken, apiUrl, model, clientContext, scheduleFlush, flushPendingUpdates, updateAssistant, applyToolResult],
257
432
  );
258
433
 
259
- const clear = useCallback(() => {
434
+ const stop = useCallback(() => {
260
435
  if (abortRef.current) {
261
436
  abortRef.current.abort();
262
437
  abortRef.current = null;
263
438
  }
264
- setMessages([]);
439
+ flushPendingUpdates();
440
+ if (flushTimerRef.current) {
441
+ clearTimeout(flushTimerRef.current);
442
+ flushTimerRef.current = null;
443
+ }
265
444
  setIsStreaming(false);
445
+ }, [flushPendingUpdates]);
446
+
447
+ const clear = useCallback(() => {
448
+ stop();
449
+ setMessages([]);
266
450
  setError(null);
267
- }, []);
451
+ pendingContentRef.current = '';
452
+ pendingReasoningRef.current = '';
453
+ toolInvocationsRef.current = [];
454
+ }, [stop]);
268
455
 
269
- return { messages, send, isStreaming, clear, error };
456
+ return { messages, setMessages, send, isStreaming, stop, clear, error };
270
457
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Real-time audio level monitoring using Web Audio API.
3
+ *
4
+ * Extracts RMS levels from LiveKit audio tracks (local mic + remote agents)
5
+ * and returns them at ~20fps for driving wave animations.
6
+ *
7
+ * Supports multiple remote audio tracks (primary + cohost) by taking
8
+ * the max RMS level across all subscribed remote tracks.
9
+ */
10
+
11
+ import { useState, useEffect, useRef } from 'react';
12
+ import { Room, RoomEvent, Track } from 'livekit-client';
13
+
14
+ /** Compute RMS (root mean square) amplitude from an AnalyserNode. Returns 0-1. */
15
+ function getRMS(analyser: AnalyserNode, buffer: any): number {
16
+ analyser.getFloatTimeDomainData(buffer);
17
+ let sum = 0;
18
+ for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
19
+ return Math.min(1, Math.sqrt(sum / buffer.length) * 8);
20
+ }
21
+
22
+ export function useAudioLevelMonitor(room: Room | null, isConnected: boolean) {
23
+ const [captureLevel, setCaptureLevel] = useState(0);
24
+ const [playbackLevel, setPlaybackLevel] = useState(0);
25
+ const cleanupRef = useRef<(() => void) | null>(null);
26
+
27
+ useEffect(() => {
28
+ // Tear down previous monitors
29
+ cleanupRef.current?.();
30
+ cleanupRef.current = null;
31
+
32
+ if (!room || !isConnected) {
33
+ setCaptureLevel(0);
34
+ setPlaybackLevel(0);
35
+ return;
36
+ }
37
+
38
+ // Guard: Web Audio API required
39
+ if (typeof AudioContext === 'undefined') return;
40
+
41
+ const audioCtx = new AudioContext();
42
+ // Resume AudioContext (may be suspended until user gesture on mobile/WebView)
43
+ if (audioCtx.state === 'suspended') {
44
+ audioCtx.resume().catch(() => {});
45
+ }
46
+
47
+ let localAnalyser: AnalyserNode | null = null;
48
+ let localBuffer: Float32Array | null = null;
49
+ const remoteAnalysers = new Map<string, { analyser: AnalyserNode; buffer: Float32Array }>();
50
+ let rafId = 0;
51
+ let disposed = false;
52
+
53
+ // ---------- Local mic ----------
54
+ const localTrack = room.localParticipant?.getTrackPublication(Track.Source.Microphone)?.track;
55
+ if (localTrack?.mediaStreamTrack) {
56
+ try {
57
+ const source = audioCtx.createMediaStreamSource(new MediaStream([localTrack.mediaStreamTrack]));
58
+ localAnalyser = audioCtx.createAnalyser();
59
+ localAnalyser.fftSize = 256;
60
+ source.connect(localAnalyser);
61
+ localBuffer = new Float32Array(localAnalyser.fftSize);
62
+ } catch {
63
+ // MediaStream creation can fail in some environments
64
+ }
65
+ }
66
+
67
+ // ---------- Remote agent audio (supports multiple tracks) ----------
68
+ const setupRemoteAnalyser = (mediaStreamTrack: MediaStreamTrack, key: string) => {
69
+ if (remoteAnalysers.has(key) || disposed) return;
70
+ try {
71
+ const source = audioCtx.createMediaStreamSource(new MediaStream([mediaStreamTrack]));
72
+ const analyser = audioCtx.createAnalyser();
73
+ analyser.fftSize = 256;
74
+ source.connect(analyser);
75
+ const buffer = new Float32Array(analyser.fftSize);
76
+ remoteAnalysers.set(key, { analyser, buffer });
77
+ } catch {
78
+ // Ignore errors
79
+ }
80
+ };
81
+
82
+ // Check already-subscribed remote tracks
83
+ for (const [, p] of room.remoteParticipants) {
84
+ for (const [, pub] of p.audioTrackPublications) {
85
+ if (pub.track?.mediaStreamTrack) {
86
+ setupRemoteAnalyser(pub.track.mediaStreamTrack, `${p.identity}-${pub.trackSid}`);
87
+ }
88
+ }
89
+ }
90
+
91
+ // Listen for new remote audio tracks
92
+ const onTrackSubscribed = (track: any, publication: any, participant: any) => {
93
+ if (track.kind === Track.Kind.Audio && track.mediaStreamTrack) {
94
+ setupRemoteAnalyser(track.mediaStreamTrack, `${participant.identity}-${publication.trackSid}`);
95
+ }
96
+ };
97
+ room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
98
+
99
+ // Clean up stale analysers when tracks are unsubscribed
100
+ const onTrackUnsubscribed = (track: any, publication: any, participant: any) => {
101
+ const key = `${participant.identity}-${publication.trackSid}`;
102
+ remoteAnalysers.delete(key);
103
+ };
104
+ room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
105
+
106
+ // ---------- Poll loop (~20fps) ----------
107
+ let lastUpdate = 0;
108
+ const poll = (time: number) => {
109
+ if (disposed) return;
110
+ if (time - lastUpdate > 50) {
111
+ lastUpdate = time;
112
+ if (localAnalyser && localBuffer) {
113
+ setCaptureLevel(getRMS(localAnalyser, localBuffer));
114
+ }
115
+ // Take max RMS across all remote tracks (primary + cohost)
116
+ let maxRemoteLevel = 0;
117
+ for (const [, { analyser, buffer }] of remoteAnalysers) {
118
+ const level = getRMS(analyser, buffer);
119
+ if (level > maxRemoteLevel) maxRemoteLevel = level;
120
+ }
121
+ setPlaybackLevel(maxRemoteLevel);
122
+ }
123
+ rafId = requestAnimationFrame(poll);
124
+ };
125
+ rafId = requestAnimationFrame(poll);
126
+
127
+ // ---------- Cleanup ----------
128
+ const dispose = () => {
129
+ disposed = true;
130
+ cancelAnimationFrame(rafId);
131
+ room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
132
+ room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
133
+ remoteAnalysers.clear();
134
+ audioCtx.close().catch(() => {});
135
+ };
136
+ cleanupRef.current = dispose;
137
+
138
+ return dispose;
139
+ }, [room, isConnected]);
140
+
141
+ return { captureLevel, playbackLevel };
142
+ }
@@ -0,0 +1,51 @@
1
+ import { useEffect } from 'react';
2
+ import { useSharedValue, withTiming, Easing } from 'react-native-reanimated';
3
+ import type { AgentState } from '../types';
4
+
5
+ interface UseAudioLevelsOptions {
6
+ captureLevel: number;
7
+ playbackLevel: number;
8
+ agentState: AgentState;
9
+ isConnected: boolean;
10
+ }
11
+
12
+ export function useAudioLevels({
13
+ captureLevel,
14
+ playbackLevel,
15
+ agentState,
16
+ isConnected,
17
+ }: UseAudioLevelsOptions) {
18
+ const waveAmplitude = useSharedValue(0);
19
+
20
+ useEffect(() => {
21
+ if (!isConnected) {
22
+ waveAmplitude.value = withTiming(0, { duration: 300 });
23
+ return;
24
+ }
25
+
26
+ const amplifiedPlayback = Math.min(1, playbackLevel * 3);
27
+
28
+ let target = 0;
29
+ if (agentState === 'thinking') {
30
+ target = Math.max(0.15, captureLevel);
31
+ } else if (agentState === 'speaking') {
32
+ // 0.4 baseline ensures visible waves while AI speaks
33
+ target = Math.max(0.4, amplifiedPlayback, captureLevel * 0.5);
34
+ } else if (agentState === 'listening') {
35
+ // Subtle pulse while listening; mic level takes over when available
36
+ target = Math.max(0.08, captureLevel, amplifiedPlayback * 0.5);
37
+ } else {
38
+ // Idle — calm, no minimum
39
+ target = Math.max(captureLevel, amplifiedPlayback * 0.5);
40
+ }
41
+
42
+ // Fast attack, slower decay for natural VU-meter feel
43
+ const duration = target > waveAmplitude.value ? 60 : 200;
44
+ waveAmplitude.value = withTiming(target, {
45
+ duration,
46
+ easing: Easing.bezier(0.33, 1, 0.68, 1),
47
+ });
48
+ }, [captureLevel, playbackLevel, agentState, isConnected]);
49
+
50
+ return { waveAmplitude };
51
+ }