@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.
- package/CHANGELOG.md +82 -0
- package/README.md +234 -0
- package/package.json +9 -13
- package/src/components/AliaChatContent.tsx +0 -1
- package/src/components/AliaChatMessageList.tsx +0 -4
- package/src/components/ResearchProgressCard.tsx +3 -0
- package/src/components/ui/prompt-input/mic-button.tsx +2 -3
- package/src/components/ui/prompt-input/prompt-input.tsx +1 -4
- package/src/components/voice/VoiceControls.tsx +3 -54
- package/src/components/voice/VoiceSession.tsx +6 -19
- package/src/hooks/useAliaChat.ts +16 -1
- package/src/hooks/useAudioLevelMonitor.ts +26 -122
- package/src/hooks/useSpeechToText.ts +165 -150
- package/src/hooks/useTTS.ts +29 -29
- package/src/hooks/useVoiceRoom.ts +553 -409
- package/src/index.ts +0 -14
- package/src/lib/audio-level.ts +3 -2
- package/src/lib/chat-transport.ts +11 -0
- package/src/lib/speech-language.ts +20 -0
- package/src/lib/speech-messages.ts +31 -0
- package/src/lib/speech-recognition-types.ts +75 -0
- package/src/lib/speech-recognition.native.ts +171 -0
- package/src/lib/speech-recognition.ts +279 -0
- package/src/lib/speech-synthesis.ts +122 -0
- package/src/lib/voice-levels.ts +60 -0
- package/src/lib/voice-text.ts +192 -0
- package/src/lib/voice-turn.ts +87 -0
- package/src/types.ts +3 -3
- package/src/voice.ts +7 -3
|
@@ -1,505 +1,649 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* A voice conversation, run on the device as a turn loop.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
42
|
-
* would substitute something that cannot hold a voice session.
|
|
73
|
+
* `lib/config.ts`.
|
|
43
74
|
*/
|
|
44
75
|
model?: string;
|
|
45
76
|
/**
|
|
46
|
-
*
|
|
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
|
|
49
|
-
*
|
|
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
|
-
// ==============
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
127
|
-
const
|
|
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,
|
|
144
|
+
const [agentState, setAgentStateValue] = useState<AgentState>('idle');
|
|
131
145
|
const [isMuted, setIsMuted] = useState(false);
|
|
132
146
|
const [error, setError] = useState<string | null>(null);
|
|
133
|
-
|
|
134
|
-
const [
|
|
135
|
-
const [
|
|
136
|
-
const [
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
// ==============
|
|
197
|
+
// ============== STATE HELPERS ==============
|
|
150
198
|
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
// ==============
|
|
280
|
+
// ============== SPEAKING ==============
|
|
324
281
|
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
577
|
+
return;
|
|
434
578
|
}
|
|
435
|
-
|
|
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
|
-
|
|
589
|
+
teardown();
|
|
590
|
+
draftIdRef.current = null;
|
|
591
|
+
messagesRef.current = [];
|
|
592
|
+
mutedRef.current = false;
|
|
441
593
|
setRoomState('disconnected');
|
|
442
|
-
|
|
594
|
+
setAgentStateValue('idle');
|
|
443
595
|
setError(null);
|
|
596
|
+
setTurnError(null);
|
|
444
597
|
setIsMuted(false);
|
|
445
|
-
|
|
446
|
-
setCohostActive(false);
|
|
598
|
+
setMessagesState([]);
|
|
447
599
|
setCurrentSpeaker(null);
|
|
448
|
-
|
|
449
|
-
}, [cleanup]);
|
|
600
|
+
}, [teardown]);
|
|
450
601
|
|
|
451
602
|
// ============== MUTE ==============
|
|
452
603
|
|
|
453
604
|
const toggleMute = useCallback(() => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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(() =>
|
|
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
|
|
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
|
}
|