@alia.onl/sdk 7.2.9 → 10.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 +142 -6
- package/README.md +242 -0
- package/package.json +8 -12
- package/src/components/AliaChatContent.tsx +0 -1
- package/src/components/AliaChatMessageList.tsx +0 -4
- package/src/components/IdentityMark.tsx +3 -0
- package/src/components/Markdown.tsx +89 -8
- 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 +48 -63
- package/src/components/voice/VoiceSession.tsx +6 -19
- package/src/hooks/useAliaChat.ts +24 -10
- package/src/hooks/useAudioLevelMonitor.ts +26 -122
- package/src/hooks/useSpeechToText.ts +196 -147
- package/src/hooks/useTTS.ts +33 -38
- package/src/hooks/useVoiceRoom.ts +581 -416
- package/src/index.ts +6 -19
- package/src/lib/audio-level.ts +3 -2
- package/src/lib/catalogue.ts +116 -103
- package/src/lib/chat-stream.ts +0 -5
- package/src/lib/chat-transport.ts +14 -2
- package/src/lib/speech-language.ts +20 -0
- package/src/lib/speech-messages.ts +76 -0
- package/src/lib/speech-recognition-types.ts +108 -0
- package/src/lib/speech-recognition.native.ts +226 -0
- package/src/lib/speech-recognition.ts +288 -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 +86 -0
- package/src/types.ts +3 -3
- package/src/voice.ts +11 -3
- package/src/lib/config.ts +0 -36
|
@@ -1,505 +1,670 @@
|
|
|
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
|
|
23
|
-
import {
|
|
33
|
+
import type { RoomState, AgentState, VoiceMessage } from '../types';
|
|
34
|
+
import {
|
|
35
|
+
isSpeechRecognitionAvailable,
|
|
36
|
+
chooseSpeechRecognizer,
|
|
37
|
+
requestSpeechRecognitionPermission,
|
|
38
|
+
startSpeechRecognition,
|
|
39
|
+
} from '../lib/speech-recognition';
|
|
40
|
+
import type { SpeechRecognitionFailure, SpeechRecognitionSession } from '../lib/speech-recognition-types';
|
|
41
|
+
import { speechFailureCode, VOICE_ERROR_MESSAGES, type VoiceErrorCode } 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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* 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.
|
|
69
|
+
* Speech model for the answers. Omitted, the request carries no `model` and
|
|
70
|
+
* the server's own speech model answers.
|
|
43
71
|
*/
|
|
44
72
|
model?: string;
|
|
45
73
|
/**
|
|
46
|
-
*
|
|
74
|
+
* Chat model (`publisher/model`) the default sender asks for; omitted, the
|
|
75
|
+
* server's default. Ignored when `sendTurn`
|
|
76
|
+
* is given — that sender carries its own conversation's choice.
|
|
77
|
+
*/
|
|
78
|
+
chatModel?: string;
|
|
79
|
+
/**
|
|
80
|
+
* The agent this call belongs to, when it is a thread with one.
|
|
47
81
|
*
|
|
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.
|
|
82
|
+
* Without it the call is ordinary Alia. Only the default sender reads it; a
|
|
83
|
+
* custom `sendTurn` is already bound to its conversation's agent.
|
|
52
84
|
*/
|
|
53
85
|
agentId?: string;
|
|
86
|
+
/** Language to recognize (BCP-47). Defaults to the platform's. */
|
|
87
|
+
lang?: string;
|
|
88
|
+
/**
|
|
89
|
+
* How each turn reaches Alia. Defaults to `createAliaVoiceTurnSender`. The
|
|
90
|
+
* Alia app passes its conversation's own send, so a call's turns are that
|
|
91
|
+
* conversation's turns — persisted and shown like typed ones.
|
|
92
|
+
*/
|
|
93
|
+
sendTurn?: VoiceTurnSender;
|
|
94
|
+
/** Silence, in ms, that ends an utterance. */
|
|
95
|
+
endOfUtteranceMs?: number;
|
|
96
|
+
/** Let the person interrupt by talking. On by default. */
|
|
97
|
+
bargeIn?: boolean;
|
|
54
98
|
}
|
|
55
99
|
|
|
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
|
-
}
|
|
100
|
+
// ============== TUNING ==============
|
|
101
|
+
|
|
102
|
+
const DEFAULT_END_OF_UTTERANCE_MS = 1_100;
|
|
103
|
+
/** How long a requested stop may take to deliver the final words. */
|
|
104
|
+
const COMMIT_TIMEOUT_MS = 1_500;
|
|
105
|
+
/** Words needed before talking over the answer counts as an interruption. */
|
|
106
|
+
const BARGE_IN_MIN_WORDS = 2;
|
|
107
|
+
/** A recognizer that keeps ending within this of starting is failing, not idling. */
|
|
108
|
+
const RAPID_END_MS = 400;
|
|
109
|
+
const MAX_RAPID_ENDS = 5;
|
|
110
|
+
|
|
111
|
+
// ============== INTERNAL STATE ==============
|
|
112
|
+
|
|
113
|
+
type Phase = 'off' | 'listening' | 'thinking' | 'speaking';
|
|
114
|
+
|
|
115
|
+
interface ActiveTurn {
|
|
116
|
+
readonly controller: AbortController;
|
|
117
|
+
readonly assistantId: string;
|
|
118
|
+
/** Characters of the answer already handed to the speech queue. */
|
|
119
|
+
consumed: number;
|
|
120
|
+
chunkCount: number;
|
|
121
|
+
/** The answer so far, title tags stripped and whitespace kept (see `stripTitleTags`). */
|
|
122
|
+
text: string;
|
|
123
|
+
/** Everything this turn has said aloud so far, for telling echo from the person. */
|
|
124
|
+
speaking: string;
|
|
125
|
+
readonly clips: Array<{ readonly text: string; clip: Promise<string | null> | null }>;
|
|
126
|
+
closed: boolean;
|
|
127
|
+
/** Wakes the player when a clip is queued or the answer ends. */
|
|
128
|
+
wake: (() => void) | null;
|
|
129
|
+
synthesisFailed: boolean;
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
// ============== HOOK ==============
|
|
122
133
|
|
|
123
134
|
export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
|
|
124
135
|
const apiUrl = options.apiUrl || API_URL;
|
|
125
|
-
const voicePref = options.voicePreference ?? 'female';
|
|
126
|
-
const
|
|
127
|
-
const
|
|
136
|
+
const voicePref: ProductVoice = options.voicePreference ?? 'female';
|
|
137
|
+
const speechModel = options.model;
|
|
138
|
+
const endOfUtteranceMs = options.endOfUtteranceMs ?? DEFAULT_END_OF_UTTERANCE_MS;
|
|
139
|
+
const bargeIn = options.bargeIn ?? true;
|
|
128
140
|
|
|
129
141
|
const [roomState, setRoomState] = useState<RoomState>('disconnected');
|
|
130
|
-
const [agentState,
|
|
142
|
+
const [agentState, setAgentStateValue] = useState<AgentState>('idle');
|
|
131
143
|
const [isMuted, setIsMuted] = useState(false);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
// Per-speaker streaming transcript refs
|
|
144
|
-
const primaryTextRef = useRef('');
|
|
145
|
-
const cohostTextRef = useRef('');
|
|
144
|
+
/**
|
|
145
|
+
* Why the call ended, as a code and as the sentence to show. The sentence is
|
|
146
|
+
* the code's English copy, except for `turn-failed`, which carries what the
|
|
147
|
+
* turn sender threw.
|
|
148
|
+
*/
|
|
149
|
+
const [callFailure, setCallFailure] = useState<{ code: VoiceErrorCode; message: string } | null>(null);
|
|
150
|
+
/** A turn that failed without ending the call: no answer, or no audio for it. */
|
|
151
|
+
const [turnErrorCode, setTurnErrorCode] = useState<VoiceErrorCode | null>(null);
|
|
152
|
+
const [messages, setMessagesState] = useState<VoiceMessage[]>([]);
|
|
153
|
+
const [currentSpeaker, setCurrentSpeaker] = useState<'primary' | 'user' | null>(null);
|
|
146
154
|
|
|
147
155
|
const { oxyServices } = useOxy();
|
|
148
156
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
cohostTextRef.current = '';
|
|
174
|
-
}, []);
|
|
157
|
+
const levels = useMemo(() => new VoiceLevelChannel(), []);
|
|
158
|
+
|
|
159
|
+
// Everything the loop reads from callbacks lives in refs: recognizer events
|
|
160
|
+
// arrive outside React, and a value closed over at `connect` would be stale.
|
|
161
|
+
const phaseRef = useRef<Phase>('off');
|
|
162
|
+
const mutedRef = useRef(false);
|
|
163
|
+
const mountedRef = useRef(true);
|
|
164
|
+
const messagesRef = useRef<VoiceMessage[]>([]);
|
|
165
|
+
const sessionRef = useRef<SpeechRecognitionSession | null>(null);
|
|
166
|
+
/** The recognizer and tag the call listens with, chosen when it connects. */
|
|
167
|
+
const recognizerRef = useRef<{ lang: string; service?: string }>({ lang: '' });
|
|
168
|
+
const heardRef = useRef('');
|
|
169
|
+
const committingRef = useRef(false);
|
|
170
|
+
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
171
|
+
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
172
|
+
const turnRef = useRef<ActiveTurn | null>(null);
|
|
173
|
+
const draftIdRef = useRef<string | null>(null);
|
|
174
|
+
/** What the call had said when the person cut in; this session's echo of it is not theirs. */
|
|
175
|
+
const echoContextRef = useRef('');
|
|
176
|
+
const sessionStartedAtRef = useRef(0);
|
|
177
|
+
const rapidEndsRef = useRef(0);
|
|
178
|
+
const sessionFailedRef = useRef<SpeechRecognitionFailure | null>(null);
|
|
179
|
+
const msgIdRef = useRef(0);
|
|
180
|
+
const sessionPrefixRef = useRef(`vm-${Date.now().toString(36)}`);
|
|
175
181
|
|
|
176
|
-
|
|
182
|
+
const config = {
|
|
183
|
+
apiUrl,
|
|
184
|
+
voicePref,
|
|
185
|
+
speechModel,
|
|
186
|
+
endOfUtteranceMs,
|
|
187
|
+
bargeIn,
|
|
188
|
+
lang: options.lang ?? defaultSpeechLanguage(),
|
|
189
|
+
accessToken: options.accessToken,
|
|
190
|
+
sendTurn:
|
|
191
|
+
options.sendTurn ??
|
|
192
|
+
createAliaVoiceTurnSender({
|
|
193
|
+
oxyServices,
|
|
194
|
+
apiUrl,
|
|
195
|
+
...(options.chatModel === undefined ? {} : { model: options.chatModel }),
|
|
196
|
+
...(options.agentId === undefined ? {} : { agentId: options.agentId }),
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
const configRef = useRef(config);
|
|
200
|
+
configRef.current = config;
|
|
177
201
|
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
const msg: AgentDataMessage = JSON.parse(new TextDecoder().decode(payload));
|
|
202
|
+
// ============== STATE HELPERS ==============
|
|
181
203
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
}
|
|
204
|
+
const setAgent = useCallback((phase: Phase) => {
|
|
205
|
+
phaseRef.current = phase;
|
|
206
|
+
if (!mountedRef.current) return;
|
|
207
|
+
setAgentStateValue(phase === 'off' ? 'idle' : phase);
|
|
208
|
+
setCurrentSpeaker(phase === 'thinking' || phase === 'speaking' ? 'primary' : null);
|
|
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 updateMessages = useCallback((update: (previous: VoiceMessage[]) => VoiceMessage[]) => {
|
|
212
|
+
messagesRef.current = update(messagesRef.current);
|
|
213
|
+
if (mountedRef.current) setMessagesState(messagesRef.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 nextId = (): string => {
|
|
217
|
+
msgIdRef.current += 1;
|
|
218
|
+
return `${sessionPrefixRef.current}-${msgIdRef.current}`;
|
|
219
|
+
};
|
|
287
220
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
}
|
|
221
|
+
const patchMessage = useCallback((id: string, patch: Partial<VoiceMessage>) => {
|
|
222
|
+
updateMessages((previous) => previous.map((message) => (message.id === id ? { ...message, ...patch } : message)));
|
|
223
|
+
}, [updateMessages]);
|
|
298
224
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
break;
|
|
312
|
-
}
|
|
225
|
+
const getToken = useCallback((): string | null => {
|
|
226
|
+
const { accessToken } = configRef.current;
|
|
227
|
+
if (accessToken) return accessToken;
|
|
228
|
+
return oxyServices.session.accessToken;
|
|
229
|
+
}, [oxyServices]);
|
|
230
|
+
|
|
231
|
+
const clearTimers = (): void => {
|
|
232
|
+
if (silenceTimerRef.current !== null) clearTimeout(silenceTimerRef.current);
|
|
233
|
+
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
|
|
234
|
+
silenceTimerRef.current = null;
|
|
235
|
+
commitTimerRef.current = null;
|
|
236
|
+
};
|
|
313
237
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
238
|
+
// ============== TEARDOWN ==============
|
|
239
|
+
|
|
240
|
+
const endTurn = useCallback((turn: ActiveTurn) => {
|
|
241
|
+
turn.controller.abort();
|
|
242
|
+
turn.closed = true;
|
|
243
|
+
turn.wake?.();
|
|
244
|
+
if (turnRef.current === turn) turnRef.current = null;
|
|
245
|
+
const message = messagesRef.current.find((candidate) => candidate.id === turn.assistantId);
|
|
246
|
+
if (message?.isStreaming) {
|
|
247
|
+
if (message.content.trim() === '') {
|
|
248
|
+
updateMessages((previous) => previous.filter((candidate) => candidate.id !== turn.assistantId));
|
|
249
|
+
} else {
|
|
250
|
+
patchMessage(turn.assistantId, { isStreaming: false });
|
|
317
251
|
}
|
|
318
|
-
} catch {
|
|
319
|
-
// Ignore non-JSON data
|
|
320
252
|
}
|
|
321
|
-
|
|
253
|
+
levels.setPlayback(0);
|
|
254
|
+
}, [levels, patchMessage, updateMessages]);
|
|
255
|
+
|
|
256
|
+
const discardDraft = useCallback(() => {
|
|
257
|
+
const draftId = draftIdRef.current;
|
|
258
|
+
draftIdRef.current = null;
|
|
259
|
+
if (draftId !== null) updateMessages((previous) => previous.filter((message) => message.id !== draftId));
|
|
260
|
+
}, [updateMessages]);
|
|
261
|
+
|
|
262
|
+
/** Stop everything; the caller decides what state the room is left in. */
|
|
263
|
+
const teardown = useCallback(() => {
|
|
264
|
+
phaseRef.current = 'off';
|
|
265
|
+
clearTimers();
|
|
266
|
+
committingRef.current = false;
|
|
267
|
+
const turn = turnRef.current;
|
|
268
|
+
if (turn !== null) endTurn(turn);
|
|
269
|
+
sessionRef.current?.abort();
|
|
270
|
+
sessionRef.current = null;
|
|
271
|
+
heardRef.current = '';
|
|
272
|
+
levels.reset();
|
|
273
|
+
}, [endTurn, levels]);
|
|
274
|
+
|
|
275
|
+
const fail = useCallback((code: VoiceErrorCode, message: string = VOICE_ERROR_MESSAGES[code]) => {
|
|
276
|
+
teardown();
|
|
277
|
+
discardDraft();
|
|
278
|
+
if (!mountedRef.current) return;
|
|
279
|
+
setCallFailure({ code, message });
|
|
280
|
+
setRoomState('error');
|
|
281
|
+
setAgentStateValue('idle');
|
|
282
|
+
setCurrentSpeaker(null);
|
|
283
|
+
}, [discardDraft, teardown]);
|
|
322
284
|
|
|
323
|
-
// ==============
|
|
285
|
+
// ============== SPEAKING ==============
|
|
324
286
|
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
287
|
+
const synthesize = useCallback((turn: ActiveTurn, text: string): Promise<string | null> => {
|
|
288
|
+
const token = getToken();
|
|
289
|
+
if (token === null) {
|
|
290
|
+
turn.synthesisFailed = true;
|
|
291
|
+
return Promise.resolve(null);
|
|
292
|
+
}
|
|
293
|
+
const { apiUrl: url, speechModel: model, voicePref: voice } = configRef.current;
|
|
294
|
+
return requestSpeechClip({ apiUrl: url, token, model, input: text, voice, signal: turn.controller.signal })
|
|
295
|
+
.catch((caught: unknown) => {
|
|
296
|
+
if (!turn.controller.signal.aborted) {
|
|
297
|
+
console.error('[useVoiceRoom] Speech synthesis failed:', caught);
|
|
298
|
+
turn.synthesisFailed = true;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
});
|
|
302
|
+
}, [getToken]);
|
|
303
|
+
|
|
304
|
+
/** Play the turn's clips in order as they arrive, fetching one ahead. */
|
|
305
|
+
const playTurn = useCallback(async (turn: ActiveTurn): Promise<void> => {
|
|
306
|
+
const signal = turn.controller.signal;
|
|
307
|
+
for (let index = 0; ; index += 1) {
|
|
308
|
+
while (index >= turn.clips.length && !turn.closed) {
|
|
309
|
+
await new Promise<void>((resolve) => {
|
|
310
|
+
turn.wake = resolve;
|
|
311
|
+
});
|
|
312
|
+
turn.wake = null;
|
|
335
313
|
}
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
314
|
+
if (signal.aborted || index >= turn.clips.length) return;
|
|
315
|
+
const item = turn.clips[index];
|
|
316
|
+
item.clip ??= synthesize(turn, item.text);
|
|
317
|
+
const next = turn.clips[index + 1];
|
|
318
|
+
if (next !== undefined) next.clip ??= synthesize(turn, next.text);
|
|
319
|
+
|
|
320
|
+
const uri = await item.clip;
|
|
321
|
+
if (signal.aborted) return;
|
|
322
|
+
if (uri === null) continue;
|
|
323
|
+
turn.speaking = `${turn.speaking} ${item.text}`;
|
|
324
|
+
setAgent('speaking');
|
|
325
|
+
try {
|
|
326
|
+
await playSpeechClip(uri, { signal, onLevel: (level) => levels.setPlayback(level) });
|
|
327
|
+
} catch (caught: unknown) {
|
|
328
|
+
console.error('[useVoiceRoom] Playback failed:', caught);
|
|
329
|
+
turn.synthesisFailed = true;
|
|
342
330
|
}
|
|
331
|
+
}
|
|
332
|
+
}, [levels, setAgent, synthesize]);
|
|
333
|
+
|
|
334
|
+
const queueChunks = useCallback((turn: ActiveTurn, final: boolean) => {
|
|
335
|
+
const pending = turn.text.slice(turn.consumed);
|
|
336
|
+
const { chunks, rest } = takeSpeechChunks(pending, { final, isFirst: turn.chunkCount === 0 });
|
|
337
|
+
turn.consumed = turn.text.length - rest.length;
|
|
338
|
+
for (const text of chunks) {
|
|
339
|
+
turn.chunkCount += 1;
|
|
340
|
+
// The next clip is fetched now if the player is waiting on it; the
|
|
341
|
+
// player fetches the rest one ahead of what it is playing.
|
|
342
|
+
turn.clips.push({ text, clip: null });
|
|
343
|
+
}
|
|
344
|
+
if (chunks.length > 0) turn.wake?.();
|
|
345
|
+
}, []);
|
|
343
346
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
// ============== LISTENING ==============
|
|
348
|
+
|
|
349
|
+
// Declared through refs so the recognizer callbacks and the turn runner can
|
|
350
|
+
// call each other without a cycle in their dependency lists.
|
|
351
|
+
const startListeningRef = useRef<() => void>(() => undefined);
|
|
352
|
+
const runTurnRef = useRef<(text: string) => void>(() => undefined);
|
|
353
|
+
|
|
354
|
+
const commitUtterance = useCallback(() => {
|
|
355
|
+
const session = sessionRef.current;
|
|
356
|
+
if (session === null || committingRef.current) return;
|
|
357
|
+
committingRef.current = true;
|
|
358
|
+
session.stop();
|
|
359
|
+
commitTimerRef.current = setTimeout(() => {
|
|
360
|
+
commitTimerRef.current = null;
|
|
361
|
+
sessionRef.current?.abort();
|
|
362
|
+
}, COMMIT_TIMEOUT_MS);
|
|
363
|
+
}, []);
|
|
364
|
+
|
|
365
|
+
const showDraft = useCallback((transcript: string) => {
|
|
366
|
+
const draftId = draftIdRef.current;
|
|
367
|
+
if (draftId !== null) {
|
|
368
|
+
patchMessage(draftId, { content: transcript });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const id = nextId();
|
|
372
|
+
draftIdRef.current = id;
|
|
373
|
+
updateMessages((previous) => [
|
|
374
|
+
...previous,
|
|
375
|
+
{ id, role: 'user', content: transcript, timestamp: Date.now(), isStreaming: true },
|
|
376
|
+
]);
|
|
377
|
+
if (mountedRef.current) setCurrentSpeaker('user');
|
|
378
|
+
}, [patchMessage, updateMessages]);
|
|
379
|
+
|
|
380
|
+
const startListening = useCallback(() => {
|
|
381
|
+
if (phaseRef.current === 'off' || mutedRef.current || sessionRef.current !== null) return;
|
|
382
|
+
heardRef.current = '';
|
|
383
|
+
committingRef.current = false;
|
|
384
|
+
sessionFailedRef.current = null;
|
|
385
|
+
echoContextRef.current = '';
|
|
386
|
+
sessionStartedAtRef.current = Date.now();
|
|
387
|
+
|
|
388
|
+
sessionRef.current = startSpeechRecognition(
|
|
389
|
+
{ ...recognizerRef.current, echoCancellation: true },
|
|
390
|
+
{
|
|
391
|
+
onResult: ({ transcript }) => {
|
|
392
|
+
if (phaseRef.current === 'off' || committingRef.current) return;
|
|
393
|
+
|
|
394
|
+
const turn = turnRef.current;
|
|
395
|
+
if (turn !== null) {
|
|
396
|
+
// Alia is thinking or speaking: this is the person cutting in, or
|
|
397
|
+
// the answer coming back through the microphone.
|
|
398
|
+
heardRef.current = '';
|
|
399
|
+
if (!configRef.current.bargeIn) return;
|
|
400
|
+
if (!isInterruption(transcript, turn.speaking, BARGE_IN_MIN_WORDS)) return;
|
|
401
|
+
echoContextRef.current = turn.speaking;
|
|
402
|
+
endTurn(turn);
|
|
403
|
+
setAgent('listening');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const utterance = dropEchoPrefix(transcript, echoContextRef.current);
|
|
407
|
+
heardRef.current = utterance;
|
|
408
|
+
if (utterance === '') return;
|
|
409
|
+
showDraft(utterance);
|
|
410
|
+
if (silenceTimerRef.current !== null) clearTimeout(silenceTimerRef.current);
|
|
411
|
+
silenceTimerRef.current = setTimeout(() => {
|
|
412
|
+
silenceTimerRef.current = null;
|
|
413
|
+
commitUtterance();
|
|
414
|
+
}, configRef.current.endOfUtteranceMs);
|
|
350
415
|
},
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
416
|
+
onLevel: (level) => {
|
|
417
|
+
levels.setCapture(mutedRef.current ? 0 : level);
|
|
418
|
+
},
|
|
419
|
+
onError: (failure) => {
|
|
420
|
+
if (failure.code === 'no-speech' || failure.code === 'aborted') return;
|
|
421
|
+
sessionFailedRef.current = failure;
|
|
422
|
+
},
|
|
423
|
+
onEnd: () => {
|
|
424
|
+
sessionRef.current = null;
|
|
425
|
+
clearTimers();
|
|
426
|
+
const committing = committingRef.current;
|
|
427
|
+
committingRef.current = false;
|
|
428
|
+
const heard = heardRef.current.trim();
|
|
429
|
+
heardRef.current = '';
|
|
430
|
+
if (phaseRef.current === 'off') return;
|
|
431
|
+
|
|
432
|
+
const failure = sessionFailedRef.current;
|
|
433
|
+
if (failure !== null && failure.code !== 'other') {
|
|
434
|
+
fail(speechFailureCode(failure) ?? 'speech-failed');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// An utterance in progress when the engine ended is still an utterance.
|
|
439
|
+
if ((committing || draftIdRef.current !== null) && heard !== '' && turnRef.current === null) {
|
|
440
|
+
rapidEndsRef.current = 0;
|
|
441
|
+
runTurnRef.current(heard);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (Date.now() - sessionStartedAtRef.current < RAPID_END_MS) {
|
|
446
|
+
rapidEndsRef.current += 1;
|
|
447
|
+
if (rapidEndsRef.current >= MAX_RAPID_ENDS) {
|
|
448
|
+
fail(speechFailureCode(failure ?? { code: 'other' }) ?? 'speech-failed');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
rapidEndsRef.current = 0;
|
|
453
|
+
}
|
|
454
|
+
startListeningRef.current();
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
}, [commitUtterance, endTurn, fail, levels, setAgent, showDraft]);
|
|
459
|
+
startListeningRef.current = startListening;
|
|
460
|
+
|
|
461
|
+
// ============== THINKING ==============
|
|
462
|
+
|
|
463
|
+
const runTurn = useCallback((text: string) => {
|
|
464
|
+
// Settle the person's words as a message of their own.
|
|
465
|
+
const draftId = draftIdRef.current;
|
|
466
|
+
draftIdRef.current = null;
|
|
467
|
+
if (draftId !== null) {
|
|
468
|
+
patchMessage(draftId, { content: text, isStreaming: false });
|
|
469
|
+
} else {
|
|
470
|
+
updateMessages((previous) => [
|
|
471
|
+
...previous,
|
|
472
|
+
{ id: nextId(), role: 'user', content: text, timestamp: Date.now(), isStreaming: false },
|
|
473
|
+
]);
|
|
474
|
+
}
|
|
360
475
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
476
|
+
const history: VoiceTurnMessage[] = messagesRef.current
|
|
477
|
+
.filter((message) => message.content.trim() !== '' && message.id !== draftId)
|
|
478
|
+
.map((message) => ({ role: message.role, content: message.content }))
|
|
479
|
+
// The turn itself is `text`, not history.
|
|
480
|
+
.slice(0, draftId === null ? -1 : undefined);
|
|
481
|
+
|
|
482
|
+
const assistantId = nextId();
|
|
483
|
+
updateMessages((previous) => [
|
|
484
|
+
...previous,
|
|
485
|
+
{ id: assistantId, role: 'assistant', speaker: 'primary', content: '', timestamp: Date.now(), isStreaming: true },
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
const turn: ActiveTurn = {
|
|
489
|
+
controller: new AbortController(),
|
|
490
|
+
assistantId,
|
|
491
|
+
consumed: 0,
|
|
492
|
+
chunkCount: 0,
|
|
493
|
+
text: '',
|
|
494
|
+
speaking: '',
|
|
495
|
+
clips: [],
|
|
496
|
+
closed: false,
|
|
497
|
+
wake: null,
|
|
498
|
+
synthesisFailed: false,
|
|
499
|
+
};
|
|
500
|
+
turnRef.current = turn;
|
|
501
|
+
setAgent('thinking');
|
|
502
|
+
if (mountedRef.current) setTurnErrorCode(null);
|
|
503
|
+
|
|
504
|
+
// The microphone stays open through thinking and speaking, for barge-in.
|
|
505
|
+
startListeningRef.current();
|
|
506
|
+
|
|
507
|
+
const playing = playTurn(turn);
|
|
508
|
+
const signal = turn.controller.signal;
|
|
509
|
+
|
|
510
|
+
void (async () => {
|
|
511
|
+
let lastText = '';
|
|
512
|
+
try {
|
|
513
|
+
await configRef.current.sendTurn({
|
|
514
|
+
text,
|
|
515
|
+
history,
|
|
516
|
+
signal,
|
|
517
|
+
onText: (answerSoFar) => {
|
|
518
|
+
if (signal.aborted) return;
|
|
519
|
+
lastText = answerSoFar;
|
|
520
|
+
turn.text = stripTitleTagsPartial(answerSoFar, { trim: false });
|
|
521
|
+
patchMessage(assistantId, { content: turn.text.trim() });
|
|
522
|
+
queueChunks(turn, false);
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
} catch (caught: unknown) {
|
|
526
|
+
if (signal.aborted) return;
|
|
527
|
+
console.error('[useVoiceRoom] Turn failed:', caught);
|
|
528
|
+
fail('turn-failed', errorMessage(caught, VOICE_ERROR_MESSAGES['turn-failed']));
|
|
366
529
|
return;
|
|
367
530
|
}
|
|
531
|
+
if (signal.aborted) return;
|
|
532
|
+
|
|
533
|
+
turn.text = stripTitleTags(lastText, { trim: false });
|
|
534
|
+
patchMessage(assistantId, { content: turn.text.trim(), isStreaming: false });
|
|
535
|
+
queueChunks(turn, true);
|
|
536
|
+
turn.closed = true;
|
|
537
|
+
turn.wake?.();
|
|
538
|
+
await playing;
|
|
539
|
+
if (signal.aborted || turnRef.current !== turn) return;
|
|
540
|
+
|
|
541
|
+
turnRef.current = null;
|
|
542
|
+
levels.setPlayback(0);
|
|
543
|
+
// The session that listened through the answer has heard the answer; a
|
|
544
|
+
// fresh one starts the next utterance clean.
|
|
545
|
+
sessionRef.current?.abort();
|
|
546
|
+
if (turn.text.trim() === '') {
|
|
547
|
+
updateMessages((previous) => previous.filter((message) => message.id !== assistantId));
|
|
548
|
+
if (mountedRef.current) setTurnErrorCode('no-answer');
|
|
549
|
+
} else if (turn.synthesisFailed) {
|
|
550
|
+
if (mountedRef.current) setTurnErrorCode('not-played');
|
|
551
|
+
}
|
|
552
|
+
setAgent('listening');
|
|
553
|
+
startListeningRef.current();
|
|
554
|
+
})();
|
|
555
|
+
}, [fail, levels, patchMessage, playTurn, queueChunks, setAgent, updateMessages]);
|
|
556
|
+
runTurnRef.current = runTurn;
|
|
368
557
|
|
|
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
|
-
});
|
|
558
|
+
// ============== CONNECT ==============
|
|
401
559
|
|
|
402
|
-
|
|
403
|
-
|
|
560
|
+
const connect = useCallback(async () => {
|
|
561
|
+
if (phaseRef.current !== 'off') return;
|
|
562
|
+
setCallFailure(null);
|
|
563
|
+
setTurnErrorCode(null);
|
|
564
|
+
setRoomState('connecting');
|
|
404
565
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
}
|
|
566
|
+
if (!isSpeechRecognitionAvailable()) {
|
|
567
|
+
setCallFailure({ code: 'voice-unavailable', message: VOICE_ERROR_MESSAGES['voice-unavailable'] });
|
|
568
|
+
setRoomState('error');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (getToken() === null) {
|
|
572
|
+
setCallFailure({ code: 'not-authenticated', message: VOICE_ERROR_MESSAGES['not-authenticated'] });
|
|
573
|
+
setRoomState('error');
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
425
576
|
|
|
426
|
-
|
|
427
|
-
|
|
577
|
+
const refusal = await requestSpeechRecognitionPermission();
|
|
578
|
+
if (!mountedRef.current) return;
|
|
579
|
+
if (refusal !== null) {
|
|
580
|
+
const code = speechFailureCode(refusal) ?? 'microphone-denied';
|
|
581
|
+
setCallFailure({ code, message: VOICE_ERROR_MESSAGES[code] });
|
|
582
|
+
setRoomState('error');
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
428
585
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
586
|
+
// The recognizer that knows the call's language, as dictation picks it.
|
|
587
|
+
const choice = await chooseSpeechRecognizer(configRef.current.lang);
|
|
588
|
+
if (!mountedRef.current) return;
|
|
589
|
+
if ('failure' in choice) {
|
|
590
|
+
const code = speechFailureCode(choice.failure) ?? 'speech-language';
|
|
591
|
+
setCallFailure({ code, message: VOICE_ERROR_MESSAGES[code] });
|
|
432
592
|
setRoomState('error');
|
|
433
|
-
|
|
593
|
+
return;
|
|
434
594
|
}
|
|
435
|
-
|
|
595
|
+
recognizerRef.current = { lang: choice.lang, service: choice.service };
|
|
596
|
+
|
|
597
|
+
rapidEndsRef.current = 0;
|
|
598
|
+
setRoomState('connected');
|
|
599
|
+
setAgent('listening');
|
|
600
|
+
startListeningRef.current();
|
|
601
|
+
}, [getToken, setAgent]);
|
|
436
602
|
|
|
437
603
|
// ============== DISCONNECT ==============
|
|
438
604
|
|
|
439
605
|
const disconnect = useCallback(() => {
|
|
440
|
-
|
|
606
|
+
teardown();
|
|
607
|
+
draftIdRef.current = null;
|
|
608
|
+
messagesRef.current = [];
|
|
609
|
+
mutedRef.current = false;
|
|
441
610
|
setRoomState('disconnected');
|
|
442
|
-
|
|
443
|
-
|
|
611
|
+
setAgentStateValue('idle');
|
|
612
|
+
setCallFailure(null);
|
|
613
|
+
setTurnErrorCode(null);
|
|
444
614
|
setIsMuted(false);
|
|
445
|
-
|
|
446
|
-
setCohostActive(false);
|
|
615
|
+
setMessagesState([]);
|
|
447
616
|
setCurrentSpeaker(null);
|
|
448
|
-
|
|
449
|
-
}, [cleanup]);
|
|
617
|
+
}, [teardown]);
|
|
450
618
|
|
|
451
619
|
// ============== MUTE ==============
|
|
452
620
|
|
|
453
621
|
const toggleMute = useCallback(() => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
room.localParticipant.setMicrophoneEnabled(!next);
|
|
622
|
+
if (phaseRef.current === 'off') return;
|
|
623
|
+
const next = !mutedRef.current;
|
|
624
|
+
mutedRef.current = next;
|
|
458
625
|
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]);
|
|
626
|
+
if (next) {
|
|
627
|
+
// What was half-said is dropped, not sent: muting mid-sentence means "not that".
|
|
628
|
+
clearTimers();
|
|
629
|
+
committingRef.current = false;
|
|
630
|
+
heardRef.current = '';
|
|
631
|
+
discardDraft();
|
|
632
|
+
sessionRef.current?.abort();
|
|
633
|
+
levels.setCapture(0);
|
|
634
|
+
} else {
|
|
635
|
+
startListeningRef.current();
|
|
636
|
+
}
|
|
637
|
+
}, [discardDraft, levels]);
|
|
482
638
|
|
|
483
639
|
// ============== CLEANUP ON UNMOUNT ==============
|
|
484
640
|
|
|
485
|
-
useEffect(() =>
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
mountedRef.current = true;
|
|
643
|
+
return () => {
|
|
644
|
+
mountedRef.current = false;
|
|
645
|
+
teardown();
|
|
646
|
+
};
|
|
647
|
+
}, [teardown]);
|
|
648
|
+
|
|
649
|
+
const room: VoiceLevelSource = levels;
|
|
486
650
|
|
|
487
651
|
return {
|
|
488
|
-
room
|
|
652
|
+
/** The call's live levels, for `useAudioLevelMonitor`. Named for the room it replaced. */
|
|
653
|
+
room,
|
|
489
654
|
roomState,
|
|
490
655
|
agentState,
|
|
491
656
|
isMuted,
|
|
492
|
-
|
|
657
|
+
/** Why the call ended, in English. */
|
|
658
|
+
error: callFailure?.message ?? null,
|
|
659
|
+
/** The same, as a code an app can translate. */
|
|
660
|
+
errorCode: callFailure?.code ?? null,
|
|
661
|
+
turnError: turnErrorCode === null ? null : VOICE_ERROR_MESSAGES[turnErrorCode],
|
|
662
|
+
turnErrorCode,
|
|
493
663
|
messages,
|
|
494
|
-
cohostActive,
|
|
495
664
|
currentSpeaker,
|
|
496
|
-
roundComplete,
|
|
497
665
|
connect,
|
|
498
666
|
disconnect,
|
|
499
667
|
toggleMute,
|
|
500
|
-
enableCohost,
|
|
501
|
-
disableCohost,
|
|
502
|
-
continueCohost,
|
|
503
668
|
isConnected: roomState === 'connected',
|
|
504
669
|
};
|
|
505
670
|
}
|