@alia.onl/sdk 7.2.8 → 8.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.
@@ -1,505 +1,649 @@
1
1
  /**
2
- * Hook for real-time voice conversations via LiveKit rooms.
2
+ * A voice conversation, run on the device as a turn loop.
3
3
  *
4
- * Connects to a LiveKit room (created by POST /v1/voice/token),
5
- * publishes the user's microphone, and receives agent audio + data
6
- * messages (transcripts, state, cohost events) over WebRTC.
4
+ * listening ──(the person stops talking)──▶ thinking ──(first sentence)──▶ speaking
5
+ * ▲ │ │
6
+ * └──────────(answer spoken, or the person talks over it)──────────────────┘
7
+ *
8
+ * - **Listening** is on-device speech recognition (`lib/speech-recognition*`).
9
+ * An utterance ends after `endOfUtteranceMs` without a new word.
10
+ * - **Thinking** sends the utterance through the ordinary chat path — a
11
+ * `VoiceTurnSender`, by default `POST /v1/chat/completions` marked
12
+ * `responseMode: 'voice'` — and streams the answer into the transcript.
13
+ * - **Speaking** synthesizes the answer through `POST /v1/audio/speech`
14
+ * sentence by sentence as it streams, so the first sentence plays while the
15
+ * rest is still being written, and the next clip is fetched while the
16
+ * current one plays.
17
+ * - **Barge-in:** the microphone stays open while Alia thinks and speaks. When
18
+ * the person says a few words that are not the answer echoing back, the turn
19
+ * is aborted — playback stops, the chat request is cancelled — and what they
20
+ * are saying becomes the next utterance.
21
+ *
22
+ * It replaced a LiveKit room (`POST /v1/voice/token`) whose realtime model and
23
+ * transcription called providers directly; Alia's only inference path is
24
+ * Alia → Oxy → Kaana, which serves chat and speech but no realtime session or
25
+ * transcription. The public shape — `connect` / `disconnect`, `roomState`,
26
+ * `agentState`, `messages`, `toggleMute` — is unchanged, so its consumers did
27
+ * not change with it.
7
28
  */
8
29
 
9
- import { useState, useCallback, useRef, useEffect } from 'react';
10
- import {
11
- Room,
12
- RoomEvent,
13
- Track,
14
- DisconnectReason,
15
- type RemoteTrack,
16
- type RemoteTrackPublication,
17
- type RemoteParticipant,
18
- type DataPublishOptions,
19
- } from 'livekit-client';
30
+ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
20
31
  import { useOxy } from '@oxy.so/services';
21
32
  import { errorMessage } from '../lib/utils';
22
- import type { RoomState, AgentState, VoiceMessage, VoiceToolInvocation } from '../types';
33
+ import type { RoomState, AgentState, VoiceMessage } from '../types';
23
34
  import { PREFERRED_VOICE_MODEL_ID } from '../lib/config';
35
+ import {
36
+ isSpeechRecognitionAvailable,
37
+ requestSpeechRecognitionPermission,
38
+ startSpeechRecognition,
39
+ } from '../lib/speech-recognition';
40
+ import type { SpeechRecognitionFailure, SpeechRecognitionSession } from '../lib/speech-recognition-types';
41
+ import { speechFailureMessage } from '../lib/speech-messages';
42
+ import { defaultSpeechLanguage } from '../lib/speech-language';
43
+ import { playSpeechClip, requestSpeechClip, type ProductVoice } from '../lib/speech-synthesis';
44
+ import { VoiceLevelChannel, type VoiceLevelSource } from '../lib/voice-levels';
45
+ import {
46
+ createAliaVoiceTurnSender,
47
+ type VoiceTurnMessage,
48
+ type VoiceTurnSender,
49
+ } from '../lib/voice-turn';
50
+ import {
51
+ dropEchoPrefix,
52
+ isInterruption,
53
+ stripTitleTags,
54
+ stripTitleTagsPartial,
55
+ takeSpeechChunks,
56
+ } from '../lib/voice-text';
24
57
 
25
58
  const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
26
59
 
27
- /** On web, audio tracks must be attached to a DOM element to play.
28
- * On native (React Native), the WebRTC layer plays them automatically. */
29
- const hasDOM = typeof document !== 'undefined';
30
-
31
60
  // ============== OPTIONS ==============
32
61
 
33
62
  export interface UseVoiceRoomOptions {
34
63
  apiUrl?: string;
35
- voicePreference?: 'male' | 'female';
64
+ /** Which of the product's two voices answers. Sent to `/v1/audio/speech` as is. */
65
+ voicePreference?: ProductVoice;
66
+ /** Bearer for speech synthesis; defaults to the surrounding Oxy session's. */
36
67
  accessToken?: string;
37
68
  /**
38
- * Voice session model. Defaults to the build's `PREFERRED_VOICE_MODEL_ID`.
69
+ * Speech model for the answers. Defaults to the build's
70
+ * `PREFERRED_VOICE_MODEL_ID`, the one profile `/v1/audio/speech` accepts.
39
71
  *
40
72
  * Not checked against `GET /catalogue`, for the reason given in
41
- * `lib/config.ts`: the chat resolver filters to `chat_visible` entries and
42
- * would substitute something that cannot hold a voice session.
73
+ * `lib/config.ts`.
43
74
  */
44
75
  model?: string;
45
76
  /**
46
- * The agent this session belongs to, when it is a thread with one.
77
+ * Chat routing profile the default sender asks for. Ignored when `sendTurn`
78
+ * is given — that sender carries its own conversation's choice.
79
+ */
80
+ chatModel?: string;
81
+ /**
82
+ * The agent this call belongs to, when it is a thread with one.
47
83
  *
48
- * Without it the session is ordinary Alia: the API composes the generic
49
- * prompt and the identity guard says the model's name, so an agent's voice
50
- * answers as Alia. It is the caller's to supply because only the screen knows
51
- * whose thread is open.
84
+ * Without it the call is ordinary Alia. Only the default sender reads it; a
85
+ * custom `sendTurn` is already bound to its conversation's agent.
52
86
  */
53
87
  agentId?: string;
88
+ /** Language to recognize (BCP-47). Defaults to the platform's. */
89
+ lang?: string;
90
+ /**
91
+ * How each turn reaches Alia. Defaults to `createAliaVoiceTurnSender`. The
92
+ * Alia app passes its conversation's own send, so a call's turns are that
93
+ * conversation's turns — persisted and shown like typed ones.
94
+ */
95
+ sendTurn?: VoiceTurnSender;
96
+ /** Silence, in ms, that ends an utterance. */
97
+ endOfUtteranceMs?: number;
98
+ /** Let the person interrupt by talking. On by default. */
99
+ bargeIn?: boolean;
54
100
  }
55
101
 
56
- // ============== TITLE TAG UTILITIES ==============
57
-
58
- const TAG = String.raw`ALIA_TITLE|TITLE|TÍTULO|TITRE|TITOLO|TITEL|ЗАГОЛОВОК`;
59
-
60
- const TITLE_STRIP_RE = new RegExp(
61
- String.raw`\[(${TAG})\].*?\[\/\1\]|<(${TAG})>.*?<\/\2>`, 'gi',
62
- );
63
-
64
- const TITLE_PARTIAL_RE = new RegExp(
65
- String.raw`\[(${TAG})\].*?(\[\/\1\])?$|<(${TAG})>.*?(<\/\3>)?$`, 'si',
66
- );
67
-
68
- function stripTitleTags(content: string): string {
69
- return content.replace(TITLE_STRIP_RE, '').trim();
70
- }
71
-
72
- function stripTitleTagsPartial(content: string): string {
73
- return content.replace(TITLE_STRIP_RE, '').replace(TITLE_PARTIAL_RE, '').trim();
74
- }
75
-
76
- // ============== INTERNAL DATA MESSAGE TYPES ==============
77
-
78
- interface AgentStateMsg { type: 'agent.state'; state: 'listening' | 'thinking' | 'speaking'; speaker: 'primary' | 'cohost' }
79
- interface TranscriptDeltaMsg { type: 'transcript.delta'; delta: string; speaker: 'primary' | 'cohost' }
80
- interface TranscriptDoneMsg { type: 'transcript.done'; transcript: string; speaker: 'primary' | 'cohost' }
81
- interface TranscriptUserMsg { type: 'transcript.user'; transcript: string }
82
- interface CohostEnabledMsg { type: 'cohost.enabled' }
83
- interface CohostDisabledMsg { type: 'cohost.disabled' }
84
- interface CohostTurnMsg { type: 'cohost.turn_changed'; speaker: 'primary' | 'cohost' | 'user' }
85
- interface CohostRoundMsg { type: 'cohost.round_complete'; turns: number }
86
- interface ToolCallMsg { type: 'tool.call'; toolName: string; callId: string; args?: Record<string, unknown>; speaker: 'primary' | 'cohost' }
87
- interface ToolResultMsg { type: 'tool.result'; callId: string; speaker: 'primary' | 'cohost' }
88
- interface SessionEndedMsg { type: 'session.ended'; reason: string }
89
- interface ErrorMsg { type: 'error'; code: string; message: string }
90
-
91
- type AgentDataMessage =
92
- | AgentStateMsg | TranscriptDeltaMsg | TranscriptDoneMsg | TranscriptUserMsg
93
- | CohostEnabledMsg | CohostDisabledMsg | CohostTurnMsg | CohostRoundMsg
94
- | ToolCallMsg | ToolResultMsg | SessionEndedMsg | ErrorMsg;
95
-
96
- // ============== MIC DEVICE ERRORS ==============
97
-
98
- /**
99
- * Map a microphone-acquisition failure to actionable copy. When
100
- * `setMicrophoneEnabled(true)` calls getUserMedia and the device is missing,
101
- * blocked, or busy, it surfaces a DOMException whose `.name` identifies the
102
- * cause (legacy aliases included). Returns null for anything that isn't a known
103
- * device error so the caller can fall back to generic handling.
104
- */
105
- function micDeviceErrorMessage(name: string): string | null {
106
- switch (name) {
107
- case 'NotFoundError':
108
- case 'DevicesNotFoundError':
109
- return 'No microphone found — connect one and try again';
110
- case 'NotAllowedError':
111
- case 'PermissionDeniedError':
112
- return 'Microphone access denied — allow it in your browser';
113
- case 'NotReadableError':
114
- case 'TrackStartError':
115
- return 'Microphone is in use by another app';
116
- default:
117
- return null;
118
- }
102
+ // ============== TUNING ==============
103
+
104
+ const DEFAULT_END_OF_UTTERANCE_MS = 1_100;
105
+ /** How long a requested stop may take to deliver the final words. */
106
+ const COMMIT_TIMEOUT_MS = 1_500;
107
+ /** Words needed before talking over the answer counts as an interruption. */
108
+ const BARGE_IN_MIN_WORDS = 2;
109
+ /** A recognizer that keeps ending within this of starting is failing, not idling. */
110
+ const RAPID_END_MS = 400;
111
+ const MAX_RAPID_ENDS = 5;
112
+
113
+ // ============== INTERNAL STATE ==============
114
+
115
+ type Phase = 'off' | 'listening' | 'thinking' | 'speaking';
116
+
117
+ interface ActiveTurn {
118
+ readonly controller: AbortController;
119
+ readonly assistantId: string;
120
+ /** Characters of the answer already handed to the speech queue. */
121
+ consumed: number;
122
+ chunkCount: number;
123
+ /** The answer so far, title tags stripped and whitespace kept (see `stripTitleTags`). */
124
+ text: string;
125
+ /** Everything this turn has said aloud so far, for telling echo from the person. */
126
+ speaking: string;
127
+ readonly clips: Array<{ readonly text: string; clip: Promise<string | null> | null }>;
128
+ closed: boolean;
129
+ /** Wakes the player when a clip is queued or the answer ends. */
130
+ wake: (() => void) | null;
131
+ synthesisFailed: boolean;
119
132
  }
120
133
 
121
134
  // ============== HOOK ==============
122
135
 
123
136
  export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
124
137
  const apiUrl = options.apiUrl || API_URL;
125
- const voicePref = options.voicePreference ?? 'female';
126
- const voiceModel = options.model ?? PREFERRED_VOICE_MODEL_ID;
127
- const agentId = options.agentId;
138
+ const voicePref: ProductVoice = options.voicePreference ?? 'female';
139
+ const speechModel = options.model ?? PREFERRED_VOICE_MODEL_ID;
140
+ const endOfUtteranceMs = options.endOfUtteranceMs ?? DEFAULT_END_OF_UTTERANCE_MS;
141
+ const bargeIn = options.bargeIn ?? true;
128
142
 
129
143
  const [roomState, setRoomState] = useState<RoomState>('disconnected');
130
- const [agentState, setAgentState] = useState<AgentState>('idle');
144
+ const [agentState, setAgentStateValue] = useState<AgentState>('idle');
131
145
  const [isMuted, setIsMuted] = useState(false);
132
146
  const [error, setError] = useState<string | null>(null);
133
- const [messages, setMessages] = useState<VoiceMessage[]>([]);
134
- const [cohostActive, setCohostActive] = useState(false);
135
- const [currentSpeaker, setCurrentSpeaker] = useState<'primary' | 'cohost' | 'user' | null>(null);
136
- const [roundComplete, setRoundComplete] = useState(false);
147
+ /** A turn that failed without ending the call: no answer, or no audio for it. */
148
+ const [turnError, setTurnError] = useState<string | null>(null);
149
+ const [messages, setMessagesState] = useState<VoiceMessage[]>([]);
150
+ const [currentSpeaker, setCurrentSpeaker] = useState<'primary' | 'user' | null>(null);
137
151
 
138
- const roomRef = useRef<Room | null>(null);
139
- const sessionIdRef = useRef<string | null>(null);
152
+ const { oxyServices } = useOxy();
153
+
154
+ const levels = useMemo(() => new VoiceLevelChannel(), []);
155
+
156
+ // Everything the loop reads from callbacks lives in refs: recognizer events
157
+ // arrive outside React, and a value closed over at `connect` would be stale.
158
+ const phaseRef = useRef<Phase>('off');
159
+ const mutedRef = useRef(false);
160
+ const mountedRef = useRef(true);
161
+ const messagesRef = useRef<VoiceMessage[]>([]);
162
+ const sessionRef = useRef<SpeechRecognitionSession | null>(null);
163
+ const heardRef = useRef('');
164
+ const committingRef = useRef(false);
165
+ const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
166
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
167
+ const turnRef = useRef<ActiveTurn | null>(null);
168
+ const draftIdRef = useRef<string | null>(null);
169
+ /** What the call had said when the person cut in; this session's echo of it is not theirs. */
170
+ const echoContextRef = useRef('');
171
+ const sessionStartedAtRef = useRef(0);
172
+ const rapidEndsRef = useRef(0);
173
+ const sessionFailedRef = useRef<SpeechRecognitionFailure | null>(null);
140
174
  const msgIdRef = useRef(0);
141
175
  const sessionPrefixRef = useRef(`vm-${Date.now().toString(36)}`);
142
176
 
143
- // Per-speaker streaming transcript refs
144
- const primaryTextRef = useRef('');
145
- const cohostTextRef = useRef('');
146
-
147
- const { oxyServices } = useOxy();
177
+ const config = {
178
+ apiUrl,
179
+ voicePref,
180
+ speechModel,
181
+ endOfUtteranceMs,
182
+ bargeIn,
183
+ lang: options.lang ?? defaultSpeechLanguage(),
184
+ accessToken: options.accessToken,
185
+ sendTurn:
186
+ options.sendTurn ??
187
+ createAliaVoiceTurnSender({
188
+ oxyServices,
189
+ apiUrl,
190
+ ...(options.chatModel === undefined ? {} : { model: options.chatModel }),
191
+ ...(options.agentId === undefined ? {} : { agentId: options.agentId }),
192
+ }),
193
+ };
194
+ const configRef = useRef(config);
195
+ configRef.current = config;
148
196
 
149
- // ============== AUTH ==============
197
+ // ============== STATE HELPERS ==============
150
198
 
151
- const getToken = useCallback((): string | null => {
152
- if (options.accessToken) return options.accessToken;
153
- return oxyServices.httpService.getAccessToken();
154
- }, [options.accessToken, oxyServices]);
155
-
156
- // ============== CLEANUP ==============
157
-
158
- const cleanup = useCallback(() => {
159
- if (roomRef.current) {
160
- // On web, detach all remote audio tracks before disconnecting
161
- if (hasDOM) {
162
- roomRef.current.remoteParticipants.forEach((p) => {
163
- p.trackPublications.forEach((pub) => {
164
- pub.track?.detach();
165
- });
166
- });
167
- }
168
- roomRef.current.disconnect();
169
- roomRef.current = null;
170
- }
171
- sessionIdRef.current = null;
172
- primaryTextRef.current = '';
173
- cohostTextRef.current = '';
199
+ const setAgent = useCallback((phase: Phase) => {
200
+ phaseRef.current = phase;
201
+ if (!mountedRef.current) return;
202
+ setAgentStateValue(phase === 'off' ? 'idle' : phase);
203
+ setCurrentSpeaker(phase === 'thinking' || phase === 'speaking' ? 'primary' : null);
174
204
  }, []);
175
205
 
176
- // ============== DATA MESSAGE HANDLER ==============
177
-
178
- const handleDataMessage = useCallback((payload: Uint8Array) => {
179
- try {
180
- const msg: AgentDataMessage = JSON.parse(new TextDecoder().decode(payload));
181
-
182
- switch (msg.type) {
183
- case 'agent.state': {
184
- // Always update agentState so both primary and cohost drive wave animation
185
- setAgentState(msg.state);
186
- setCurrentSpeaker(msg.speaker);
187
- break;
188
- }
189
-
190
- case 'transcript.delta': {
191
- const textRef = msg.speaker === 'cohost' ? cohostTextRef : primaryTextRef;
192
- textRef.current += msg.delta;
193
- const text = stripTitleTagsPartial(textRef.current);
194
- setMessages(prev => {
195
- const last = prev[prev.length - 1];
196
- if (last && last.role === 'assistant' && last.isStreaming && last.speaker === msg.speaker) {
197
- return [...prev.slice(0, -1), { ...last, content: text }];
198
- }
199
- msgIdRef.current++;
200
- return [...prev, {
201
- id: `${sessionPrefixRef.current}-${msgIdRef.current}`,
202
- role: 'assistant',
203
- speaker: msg.speaker,
204
- content: text,
205
- timestamp: Date.now(),
206
- isStreaming: true,
207
- }];
208
- });
209
- break;
210
- }
211
-
212
- case 'transcript.done': {
213
- const textRef = msg.speaker === 'cohost' ? cohostTextRef : primaryTextRef;
214
- textRef.current = '';
215
- const clean = stripTitleTags(msg.transcript);
216
- setMessages(prev => {
217
- const last = prev[prev.length - 1];
218
- if (last && last.role === 'assistant' && last.isStreaming && last.speaker === msg.speaker) {
219
- return [...prev.slice(0, -1), { ...last, content: clean, isStreaming: false }];
220
- }
221
- return prev;
222
- });
223
- break;
224
- }
206
+ const updateMessages = useCallback((update: (previous: VoiceMessage[]) => VoiceMessage[]) => {
207
+ messagesRef.current = update(messagesRef.current);
208
+ if (mountedRef.current) setMessagesState(messagesRef.current);
209
+ }, []);
225
210
 
226
- case 'transcript.user': {
227
- if (!msg.transcript.trim()) break;
228
- const transcript = msg.transcript.trim();
229
- setMessages(prev => {
230
- const last = prev[prev.length - 1];
231
- // Update existing user message instead of creating duplicates
232
- if (last && last.role === 'user') {
233
- return [...prev.slice(0, -1), { ...last, content: transcript }];
234
- }
235
- msgIdRef.current++;
236
- return [...prev, {
237
- id: `${sessionPrefixRef.current}-${msgIdRef.current}`,
238
- role: 'user',
239
- content: transcript,
240
- timestamp: Date.now(),
241
- isStreaming: false,
242
- }];
243
- });
244
- break;
245
- }
211
+ const nextId = (): string => {
212
+ msgIdRef.current += 1;
213
+ return `${sessionPrefixRef.current}-${msgIdRef.current}`;
214
+ };
246
215
 
247
- case 'cohost.enabled':
248
- setCohostActive(true);
249
- break;
250
-
251
- case 'cohost.disabled':
252
- setCohostActive(false);
253
- setCurrentSpeaker(null);
254
- break;
255
-
256
- case 'cohost.turn_changed':
257
- setCurrentSpeaker(msg.speaker);
258
- break;
259
-
260
- case 'cohost.round_complete':
261
- setRoundComplete(true);
262
- break;
263
-
264
- case 'tool.call': {
265
- const tool: VoiceToolInvocation = { toolCallId: msg.callId, toolName: msg.toolName, state: 'call', args: msg.args };
266
- setMessages(prev => {
267
- const last = prev[prev.length - 1];
268
- if (last && last.role === 'assistant' && last.speaker === msg.speaker) {
269
- return [...prev.slice(0, -1), {
270
- ...last,
271
- toolInvocations: [...(last.toolInvocations || []), tool],
272
- }];
273
- }
274
- msgIdRef.current++;
275
- return [...prev, {
276
- id: `${sessionPrefixRef.current}-${msgIdRef.current}`,
277
- role: 'assistant' as const,
278
- speaker: msg.speaker,
279
- content: '',
280
- timestamp: Date.now(),
281
- isStreaming: true,
282
- toolInvocations: [tool],
283
- }];
284
- });
285
- break;
286
- }
216
+ const patchMessage = useCallback((id: string, patch: Partial<VoiceMessage>) => {
217
+ updateMessages((previous) => previous.map((message) => (message.id === id ? { ...message, ...patch } : message)));
218
+ }, [updateMessages]);
287
219
 
288
- case 'tool.result': {
289
- setMessages(prev => prev.map(m => {
290
- if (!m.toolInvocations) return m;
291
- const updated = m.toolInvocations.map(t =>
292
- t.toolCallId === msg.callId ? { ...t, state: 'result' as const } : t
293
- );
294
- return { ...m, toolInvocations: updated };
295
- }));
296
- break;
297
- }
220
+ const getToken = useCallback((): string | null => {
221
+ const { accessToken } = configRef.current;
222
+ if (accessToken) return accessToken;
223
+ return oxyServices.httpService.getAccessToken();
224
+ }, [oxyServices]);
298
225
 
299
- case 'session.ended': {
300
- cleanup();
301
- setRoomState('disconnected');
302
- setAgentState('idle');
303
- const reason = msg.reason;
304
- setError(
305
- reason === 'user_silent' ? 'Call ended due to inactivity'
306
- : reason === 'user_unresponsive' ? 'Call ended — no response'
307
- : reason === 'max_duration_exceeded' ? 'Voice minutes limit reached. Upgrade for more.'
308
- : reason === 'credits_exhausted' ? 'Not enough credits to continue. Add more or upgrade your plan.'
309
- : 'Voice session ended'
310
- );
311
- break;
312
- }
226
+ const clearTimers = (): void => {
227
+ if (silenceTimerRef.current !== null) clearTimeout(silenceTimerRef.current);
228
+ if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
229
+ silenceTimerRef.current = null;
230
+ commitTimerRef.current = null;
231
+ };
313
232
 
314
- case 'error':
315
- setError(msg.message);
316
- break;
233
+ // ============== TEARDOWN ==============
234
+
235
+ const endTurn = useCallback((turn: ActiveTurn) => {
236
+ turn.controller.abort();
237
+ turn.closed = true;
238
+ turn.wake?.();
239
+ if (turnRef.current === turn) turnRef.current = null;
240
+ const message = messagesRef.current.find((candidate) => candidate.id === turn.assistantId);
241
+ if (message?.isStreaming) {
242
+ if (message.content.trim() === '') {
243
+ updateMessages((previous) => previous.filter((candidate) => candidate.id !== turn.assistantId));
244
+ } else {
245
+ patchMessage(turn.assistantId, { isStreaming: false });
317
246
  }
318
- } catch {
319
- // Ignore non-JSON data
320
247
  }
321
- }, [cleanup]);
248
+ levels.setPlayback(0);
249
+ }, [levels, patchMessage, updateMessages]);
250
+
251
+ const discardDraft = useCallback(() => {
252
+ const draftId = draftIdRef.current;
253
+ draftIdRef.current = null;
254
+ if (draftId !== null) updateMessages((previous) => previous.filter((message) => message.id !== draftId));
255
+ }, [updateMessages]);
256
+
257
+ /** Stop everything; the caller decides what state the room is left in. */
258
+ const teardown = useCallback(() => {
259
+ phaseRef.current = 'off';
260
+ clearTimers();
261
+ committingRef.current = false;
262
+ const turn = turnRef.current;
263
+ if (turn !== null) endTurn(turn);
264
+ sessionRef.current?.abort();
265
+ sessionRef.current = null;
266
+ heardRef.current = '';
267
+ levels.reset();
268
+ }, [endTurn, levels]);
269
+
270
+ const fail = useCallback((message: string) => {
271
+ teardown();
272
+ discardDraft();
273
+ if (!mountedRef.current) return;
274
+ setError(message);
275
+ setRoomState('error');
276
+ setAgentStateValue('idle');
277
+ setCurrentSpeaker(null);
278
+ }, [discardDraft, teardown]);
322
279
 
323
- // ============== CONNECT ==============
280
+ // ============== SPEAKING ==============
324
281
 
325
- const connect = useCallback(async () => {
326
- try {
327
- setError(null);
328
- setRoomState('connecting');
329
-
330
- // Guard: WebRTC must be available (requires registerGlobals on RN)
331
- if (typeof globalThis.RTCPeerConnection === 'undefined') {
332
- setError('Voice is not available on this device');
333
- setRoomState('error');
334
- return;
282
+ const synthesize = useCallback((turn: ActiveTurn, text: string): Promise<string | null> => {
283
+ const token = getToken();
284
+ if (token === null) {
285
+ turn.synthesisFailed = true;
286
+ return Promise.resolve(null);
287
+ }
288
+ const { apiUrl: url, speechModel: model, voicePref: voice } = configRef.current;
289
+ return requestSpeechClip({ apiUrl: url, token, model, input: text, voice, signal: turn.controller.signal })
290
+ .catch((caught: unknown) => {
291
+ if (!turn.controller.signal.aborted) {
292
+ console.error('[useVoiceRoom] Speech synthesis failed:', caught);
293
+ turn.synthesisFailed = true;
294
+ }
295
+ return null;
296
+ });
297
+ }, [getToken]);
298
+
299
+ /** Play the turn's clips in order as they arrive, fetching one ahead. */
300
+ const playTurn = useCallback(async (turn: ActiveTurn): Promise<void> => {
301
+ const signal = turn.controller.signal;
302
+ for (let index = 0; ; index += 1) {
303
+ while (index >= turn.clips.length && !turn.closed) {
304
+ await new Promise<void>((resolve) => {
305
+ turn.wake = resolve;
306
+ });
307
+ turn.wake = null;
335
308
  }
336
-
337
- const token = getToken();
338
- if (!token) {
339
- setError('Not authenticated');
340
- setRoomState('error');
341
- return;
309
+ if (signal.aborted || index >= turn.clips.length) return;
310
+ const item = turn.clips[index];
311
+ item.clip ??= synthesize(turn, item.text);
312
+ const next = turn.clips[index + 1];
313
+ if (next !== undefined) next.clip ??= synthesize(turn, next.text);
314
+
315
+ const uri = await item.clip;
316
+ if (signal.aborted) return;
317
+ if (uri === null) continue;
318
+ turn.speaking = `${turn.speaking} ${item.text}`;
319
+ setAgent('speaking');
320
+ try {
321
+ await playSpeechClip(uri, { signal, onLevel: (level) => levels.setPlayback(level) });
322
+ } catch (caught: unknown) {
323
+ console.error('[useVoiceRoom] Playback failed:', caught);
324
+ turn.synthesisFailed = true;
342
325
  }
326
+ }
327
+ }, [levels, setAgent, synthesize]);
328
+
329
+ const queueChunks = useCallback((turn: ActiveTurn, final: boolean) => {
330
+ const pending = turn.text.slice(turn.consumed);
331
+ const { chunks, rest } = takeSpeechChunks(pending, { final, isFirst: turn.chunkCount === 0 });
332
+ turn.consumed = turn.text.length - rest.length;
333
+ for (const text of chunks) {
334
+ turn.chunkCount += 1;
335
+ // The next clip is fetched now if the player is waiting on it; the
336
+ // player fetches the rest one ahead of what it is playing.
337
+ turn.clips.push({ text, clip: null });
338
+ }
339
+ if (chunks.length > 0) turn.wake?.();
340
+ }, []);
341
+
342
+ // ============== LISTENING ==============
343
+
344
+ // Declared through refs so the recognizer callbacks and the turn runner can
345
+ // call each other without a cycle in their dependency lists.
346
+ const startListeningRef = useRef<() => void>(() => undefined);
347
+ const runTurnRef = useRef<(text: string) => void>(() => undefined);
348
+
349
+ const commitUtterance = useCallback(() => {
350
+ const session = sessionRef.current;
351
+ if (session === null || committingRef.current) return;
352
+ committingRef.current = true;
353
+ session.stop();
354
+ commitTimerRef.current = setTimeout(() => {
355
+ commitTimerRef.current = null;
356
+ sessionRef.current?.abort();
357
+ }, COMMIT_TIMEOUT_MS);
358
+ }, []);
343
359
 
344
- // Request session creation from backend
345
- const resp = await fetch(`${apiUrl}/v1/voice/token`, {
346
- method: 'POST',
347
- headers: {
348
- 'Content-Type': 'application/json',
349
- Authorization: `Bearer ${token}`,
360
+ const showDraft = useCallback((transcript: string) => {
361
+ const draftId = draftIdRef.current;
362
+ if (draftId !== null) {
363
+ patchMessage(draftId, { content: transcript });
364
+ return;
365
+ }
366
+ const id = nextId();
367
+ draftIdRef.current = id;
368
+ updateMessages((previous) => [
369
+ ...previous,
370
+ { id, role: 'user', content: transcript, timestamp: Date.now(), isStreaming: true },
371
+ ]);
372
+ if (mountedRef.current) setCurrentSpeaker('user');
373
+ }, [patchMessage, updateMessages]);
374
+
375
+ const startListening = useCallback(() => {
376
+ if (phaseRef.current === 'off' || mutedRef.current || sessionRef.current !== null) return;
377
+ heardRef.current = '';
378
+ committingRef.current = false;
379
+ sessionFailedRef.current = null;
380
+ echoContextRef.current = '';
381
+ sessionStartedAtRef.current = Date.now();
382
+
383
+ sessionRef.current = startSpeechRecognition(
384
+ { lang: configRef.current.lang, echoCancellation: true },
385
+ {
386
+ onResult: ({ transcript }) => {
387
+ if (phaseRef.current === 'off' || committingRef.current) return;
388
+
389
+ const turn = turnRef.current;
390
+ if (turn !== null) {
391
+ // Alia is thinking or speaking: this is the person cutting in, or
392
+ // the answer coming back through the microphone.
393
+ heardRef.current = '';
394
+ if (!configRef.current.bargeIn) return;
395
+ if (!isInterruption(transcript, turn.speaking, BARGE_IN_MIN_WORDS)) return;
396
+ echoContextRef.current = turn.speaking;
397
+ endTurn(turn);
398
+ setAgent('listening');
399
+ }
400
+
401
+ const utterance = dropEchoPrefix(transcript, echoContextRef.current);
402
+ heardRef.current = utterance;
403
+ if (utterance === '') return;
404
+ showDraft(utterance);
405
+ if (silenceTimerRef.current !== null) clearTimeout(silenceTimerRef.current);
406
+ silenceTimerRef.current = setTimeout(() => {
407
+ silenceTimerRef.current = null;
408
+ commitUtterance();
409
+ }, configRef.current.endOfUtteranceMs);
350
410
  },
351
- body: JSON.stringify({
352
- model: voiceModel,
353
- voice: voicePref === 'male' ? 'echo' : 'nova',
354
- // Omitted rather than sent as undefined: the API branches on the key
355
- // being a string, and a session with no agent must look exactly as it
356
- // did before this existed.
357
- ...(agentId ? { agentId } : {}),
358
- }),
359
- });
411
+ onLevel: (level) => {
412
+ levels.setCapture(mutedRef.current ? 0 : level);
413
+ },
414
+ onError: (failure) => {
415
+ if (failure.code === 'no-speech' || failure.code === 'aborted') return;
416
+ sessionFailedRef.current = failure;
417
+ },
418
+ onEnd: () => {
419
+ sessionRef.current = null;
420
+ clearTimers();
421
+ const committing = committingRef.current;
422
+ committingRef.current = false;
423
+ const heard = heardRef.current.trim();
424
+ heardRef.current = '';
425
+ if (phaseRef.current === 'off') return;
426
+
427
+ const failure = sessionFailedRef.current;
428
+ if (failure !== null && failure.code !== 'other') {
429
+ fail(speechFailureMessage(failure) ?? 'Speech recognition failed');
430
+ return;
431
+ }
432
+
433
+ // An utterance in progress when the engine ended is still an utterance.
434
+ if ((committing || draftIdRef.current !== null) && heard !== '' && turnRef.current === null) {
435
+ rapidEndsRef.current = 0;
436
+ runTurnRef.current(heard);
437
+ return;
438
+ }
439
+
440
+ if (Date.now() - sessionStartedAtRef.current < RAPID_END_MS) {
441
+ rapidEndsRef.current += 1;
442
+ if (rapidEndsRef.current >= MAX_RAPID_ENDS) {
443
+ fail(speechFailureMessage(failure ?? { code: 'other' }) ?? 'Speech recognition failed');
444
+ return;
445
+ }
446
+ } else {
447
+ rapidEndsRef.current = 0;
448
+ }
449
+ startListeningRef.current();
450
+ },
451
+ },
452
+ );
453
+ }, [commitUtterance, endTurn, fail, levels, setAgent, showDraft]);
454
+ startListeningRef.current = startListening;
455
+
456
+ // ============== THINKING ==============
457
+
458
+ const runTurn = useCallback((text: string) => {
459
+ // Settle the person's words as a message of their own.
460
+ const draftId = draftIdRef.current;
461
+ draftIdRef.current = null;
462
+ if (draftId !== null) {
463
+ patchMessage(draftId, { content: text, isStreaming: false });
464
+ } else {
465
+ updateMessages((previous) => [
466
+ ...previous,
467
+ { id: nextId(), role: 'user', content: text, timestamp: Date.now(), isStreaming: false },
468
+ ]);
469
+ }
360
470
 
361
- if (!resp.ok) {
362
- const body = await resp.json().catch(() => ({}));
363
- const errMsg = body?.error?.message || body?.error || `Voice session failed (${resp.status})`;
364
- setError(errMsg);
365
- setRoomState('error');
471
+ const history: VoiceTurnMessage[] = messagesRef.current
472
+ .filter((message) => message.content.trim() !== '' && message.id !== draftId)
473
+ .map((message) => ({ role: message.role, content: message.content }))
474
+ // The turn itself is `text`, not history.
475
+ .slice(0, draftId === null ? -1 : undefined);
476
+
477
+ const assistantId = nextId();
478
+ updateMessages((previous) => [
479
+ ...previous,
480
+ { id: assistantId, role: 'assistant', speaker: 'primary', content: '', timestamp: Date.now(), isStreaming: true },
481
+ ]);
482
+
483
+ const turn: ActiveTurn = {
484
+ controller: new AbortController(),
485
+ assistantId,
486
+ consumed: 0,
487
+ chunkCount: 0,
488
+ text: '',
489
+ speaking: '',
490
+ clips: [],
491
+ closed: false,
492
+ wake: null,
493
+ synthesisFailed: false,
494
+ };
495
+ turnRef.current = turn;
496
+ setAgent('thinking');
497
+ if (mountedRef.current) setTurnError(null);
498
+
499
+ // The microphone stays open through thinking and speaking, for barge-in.
500
+ startListeningRef.current();
501
+
502
+ const playing = playTurn(turn);
503
+ const signal = turn.controller.signal;
504
+
505
+ void (async () => {
506
+ let lastText = '';
507
+ try {
508
+ await configRef.current.sendTurn({
509
+ text,
510
+ history,
511
+ signal,
512
+ onText: (answerSoFar) => {
513
+ if (signal.aborted) return;
514
+ lastText = answerSoFar;
515
+ turn.text = stripTitleTagsPartial(answerSoFar, { trim: false });
516
+ patchMessage(assistantId, { content: turn.text.trim() });
517
+ queueChunks(turn, false);
518
+ },
519
+ });
520
+ } catch (caught: unknown) {
521
+ if (signal.aborted) return;
522
+ console.error('[useVoiceRoom] Turn failed:', caught);
523
+ fail(errorMessage(caught, 'Voice request failed'));
366
524
  return;
367
525
  }
526
+ if (signal.aborted) return;
527
+
528
+ turn.text = stripTitleTags(lastText, { trim: false });
529
+ patchMessage(assistantId, { content: turn.text.trim(), isStreaming: false });
530
+ queueChunks(turn, true);
531
+ turn.closed = true;
532
+ turn.wake?.();
533
+ await playing;
534
+ if (signal.aborted || turnRef.current !== turn) return;
535
+
536
+ turnRef.current = null;
537
+ levels.setPlayback(0);
538
+ // The session that listened through the answer has heard the answer; a
539
+ // fresh one starts the next utterance clean.
540
+ sessionRef.current?.abort();
541
+ if (turn.text.trim() === '') {
542
+ updateMessages((previous) => previous.filter((message) => message.id !== assistantId));
543
+ if (mountedRef.current) setTurnError('No answer came back — try saying it again');
544
+ } else if (turn.synthesisFailed) {
545
+ if (mountedRef.current) setTurnError('The answer could not be played aloud');
546
+ }
547
+ setAgent('listening');
548
+ startListeningRef.current();
549
+ })();
550
+ }, [fail, levels, patchMessage, playTurn, queueChunks, setAgent, updateMessages]);
551
+ runTurnRef.current = runTurn;
368
552
 
369
- const { token: livekitToken, url, roomName, sessionId } = await resp.json();
370
- sessionIdRef.current = sessionId;
371
-
372
- // Create LiveKit room and connect
373
- const room = new Room();
374
- roomRef.current = room;
375
-
376
- // Event: remote audio tracks (agent speaking)
377
- // On web, must attach to a DOM element to play. On native, WebRTC plays audio automatically.
378
- room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack) => {
379
- if (track.kind === Track.Kind.Audio && hasDOM) {
380
- track.attach();
381
- }
382
- });
383
-
384
- room.on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {
385
- if (hasDOM && track.kind === Track.Kind.Audio) {
386
- track.detach();
387
- }
388
- });
389
-
390
- // Event: data messages from agent
391
- room.on(RoomEvent.DataReceived, (payload: Uint8Array) => {
392
- handleDataMessage(payload);
393
- });
394
-
395
- // Event: disconnected
396
- room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
397
- setRoomState('disconnected');
398
- setAgentState('idle');
399
- setCohostActive(false);
400
- });
401
-
402
- // Connect to the room
403
- await room.connect(url, livekitToken, { autoSubscribe: true });
553
+ // ============== CONNECT ==============
404
554
 
405
- // Enable microphone — audio goes to LiveKit, agent picks it up server-side.
406
- // getUserMedia device failures (no mic, permission blocked, hardware busy)
407
- // surface here; map them to actionable copy instead of a generic failure.
408
- try {
409
- await room.localParticipant.setMicrophoneEnabled(true);
410
- } catch (micError: unknown) {
411
- // DOMException is not `instanceof Error`, so read `.name` off the object.
412
- const name =
413
- typeof micError === 'object' && micError !== null && 'name' in micError &&
414
- typeof micError.name === 'string'
415
- ? micError.name
416
- : '';
417
- const friendly = micDeviceErrorMessage(name);
418
- if (!friendly) throw micError; // unknown failure — let the generic handler report it
419
- console.error(`[useVoiceRoom] Microphone error (${name || 'unknown'}):`, micError);
420
- setError(friendly);
421
- setRoomState('error');
422
- cleanup();
423
- return;
424
- }
555
+ const connect = useCallback(async () => {
556
+ if (phaseRef.current !== 'off') return;
557
+ setError(null);
558
+ setTurnError(null);
559
+ setRoomState('connecting');
425
560
 
426
- setRoomState('connected');
427
- setAgentState('listening');
561
+ if (!isSpeechRecognitionAvailable()) {
562
+ setError('Voice is not available on this device');
563
+ setRoomState('error');
564
+ return;
565
+ }
566
+ if (getToken() === null) {
567
+ setError('Not authenticated');
568
+ setRoomState('error');
569
+ return;
570
+ }
428
571
 
429
- } catch (e: unknown) {
430
- console.error('[useVoiceRoom] Connection error:', e);
431
- setError(errorMessage(e, 'Failed to connect'));
572
+ const refusal = await requestSpeechRecognitionPermission();
573
+ if (!mountedRef.current) return;
574
+ if (refusal !== null) {
575
+ setError(speechFailureMessage(refusal) ?? 'Microphone permission required');
432
576
  setRoomState('error');
433
- cleanup();
577
+ return;
434
578
  }
435
- }, [getToken, cleanup, handleDataMessage, apiUrl, voicePref, voiceModel, agentId]);
579
+
580
+ rapidEndsRef.current = 0;
581
+ setRoomState('connected');
582
+ setAgent('listening');
583
+ startListeningRef.current();
584
+ }, [getToken, setAgent]);
436
585
 
437
586
  // ============== DISCONNECT ==============
438
587
 
439
588
  const disconnect = useCallback(() => {
440
- cleanup();
589
+ teardown();
590
+ draftIdRef.current = null;
591
+ messagesRef.current = [];
592
+ mutedRef.current = false;
441
593
  setRoomState('disconnected');
442
- setAgentState('idle');
594
+ setAgentStateValue('idle');
443
595
  setError(null);
596
+ setTurnError(null);
444
597
  setIsMuted(false);
445
- setMessages([]);
446
- setCohostActive(false);
598
+ setMessagesState([]);
447
599
  setCurrentSpeaker(null);
448
- setRoundComplete(false);
449
- }, [cleanup]);
600
+ }, [teardown]);
450
601
 
451
602
  // ============== MUTE ==============
452
603
 
453
604
  const toggleMute = useCallback(() => {
454
- const room = roomRef.current;
455
- if (!room) return;
456
- const next = !isMuted;
457
- room.localParticipant.setMicrophoneEnabled(!next);
605
+ if (phaseRef.current === 'off') return;
606
+ const next = !mutedRef.current;
607
+ mutedRef.current = next;
458
608
  setIsMuted(next);
459
- }, [isMuted]);
460
-
461
- // ============== COHOST CONTROLS ==============
462
-
463
- const sendClientData = useCallback((data: object) => {
464
- const room = roomRef.current;
465
- if (!room?.localParticipant) return;
466
- const encoded = new TextEncoder().encode(JSON.stringify(data));
467
- room.localParticipant.publishData(encoded, { reliable: true } as DataPublishOptions);
468
- }, []);
469
-
470
- const enableCohost = useCallback(() => {
471
- sendClientData({ type: 'cohost.enable' });
472
- }, [sendClientData]);
473
-
474
- const disableCohost = useCallback(() => {
475
- sendClientData({ type: 'cohost.disable' });
476
- }, [sendClientData]);
477
-
478
- const continueCohost = useCallback(() => {
479
- setRoundComplete(false);
480
- sendClientData({ type: 'cohost.continue' });
481
- }, [sendClientData]);
609
+ if (next) {
610
+ // What was half-said is dropped, not sent: muting mid-sentence means "not that".
611
+ clearTimers();
612
+ committingRef.current = false;
613
+ heardRef.current = '';
614
+ discardDraft();
615
+ sessionRef.current?.abort();
616
+ levels.setCapture(0);
617
+ } else {
618
+ startListeningRef.current();
619
+ }
620
+ }, [discardDraft, levels]);
482
621
 
483
622
  // ============== CLEANUP ON UNMOUNT ==============
484
623
 
485
- useEffect(() => cleanup, [cleanup]);
624
+ useEffect(() => {
625
+ mountedRef.current = true;
626
+ return () => {
627
+ mountedRef.current = false;
628
+ teardown();
629
+ };
630
+ }, [teardown]);
631
+
632
+ const room: VoiceLevelSource = levels;
486
633
 
487
634
  return {
488
- room: roomRef.current,
635
+ /** The call's live levels, for `useAudioLevelMonitor`. Named for the room it replaced. */
636
+ room,
489
637
  roomState,
490
638
  agentState,
491
639
  isMuted,
492
640
  error,
641
+ turnError,
493
642
  messages,
494
- cohostActive,
495
643
  currentSpeaker,
496
- roundComplete,
497
644
  connect,
498
645
  disconnect,
499
646
  toggleMute,
500
- enableCohost,
501
- disableCohost,
502
- continueCohost,
503
647
  isConnected: roomState === 'connected',
504
648
  };
505
649
  }