@memberjunction/ai-realtime-client 0.0.1 → 5.41.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/README.md +148 -28
- package/dist/audio/audioMeter.d.ts +101 -0
- package/dist/audio/audioMeter.d.ts.map +1 -0
- package/dist/audio/audioMeter.js +193 -0
- package/dist/audio/audioMeter.js.map +1 -0
- package/dist/audio/micCapture.d.ts +26 -0
- package/dist/audio/micCapture.d.ts.map +1 -0
- package/dist/audio/micCapture.js +69 -0
- package/dist/audio/micCapture.js.map +1 -0
- package/dist/audio/pcmPlayback.d.ts +73 -0
- package/dist/audio/pcmPlayback.d.ts.map +1 -0
- package/dist/audio/pcmPlayback.js +78 -0
- package/dist/audio/pcmPlayback.js.map +1 -0
- package/dist/audio/pcmUtils.d.ts +17 -0
- package/dist/audio/pcmUtils.d.ts.map +1 -0
- package/dist/audio/pcmUtils.js +46 -0
- package/dist/audio/pcmUtils.js.map +1 -0
- package/dist/drivers/assemblyAIRealtimeClient.d.ts +384 -0
- package/dist/drivers/assemblyAIRealtimeClient.d.ts.map +1 -0
- package/dist/drivers/assemblyAIRealtimeClient.js +732 -0
- package/dist/drivers/assemblyAIRealtimeClient.js.map +1 -0
- package/dist/drivers/elevenLabsRealtimeClient.d.ts +362 -0
- package/dist/drivers/elevenLabsRealtimeClient.d.ts.map +1 -0
- package/dist/drivers/elevenLabsRealtimeClient.js +686 -0
- package/dist/drivers/elevenLabsRealtimeClient.js.map +1 -0
- package/dist/drivers/geminiRealtimeClient.d.ts +406 -0
- package/dist/drivers/geminiRealtimeClient.d.ts.map +1 -0
- package/dist/drivers/geminiRealtimeClient.js +675 -0
- package/dist/drivers/geminiRealtimeClient.js.map +1 -0
- package/dist/drivers/openAIRealtimeClient.d.ts +381 -0
- package/dist/drivers/openAIRealtimeClient.d.ts.map +1 -0
- package/dist/drivers/openAIRealtimeClient.js +602 -0
- package/dist/drivers/openAIRealtimeClient.js.map +1 -0
- package/dist/drivers/xaiRealtimeClient.d.ts +430 -0
- package/dist/drivers/xaiRealtimeClient.d.ts.map +1 -0
- package/dist/drivers/xaiRealtimeClient.js +676 -0
- package/dist/drivers/xaiRealtimeClient.js.map +1 -0
- package/dist/generic/baseRealtimeClient.d.ts +382 -0
- package/dist/generic/baseRealtimeClient.d.ts.map +1 -0
- package/dist/generic/baseRealtimeClient.js +208 -0
- package/dist/generic/baseRealtimeClient.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -7
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
8
|
+
import { BaseRealtimeClient } from '../generic/baseRealtimeClient.js';
|
|
9
|
+
import { base64ToArrayBuffer } from '../audio/pcmUtils.js';
|
|
10
|
+
import { RealtimePcmPlayback } from '../audio/pcmPlayback.js';
|
|
11
|
+
import { RealtimeAudioMeter } from '../audio/audioMeter.js';
|
|
12
|
+
import { createPcmMicCapture } from '../audio/micCapture.js';
|
|
13
|
+
// ── Audio constants (AssemblyAI Voice Agent wire format) ───────────────────────
|
|
14
|
+
/** The single Voice Agent websocket endpoint (auth rides as a `?token=` query parameter). */
|
|
15
|
+
export const ASSEMBLYAI_AGENT_WS_URL = 'wss://agents.assemblyai.com/v1/ws';
|
|
16
|
+
/**
|
|
17
|
+
* The Voice Agent wire format is FIXED: 16-bit signed little-endian PCM, mono, 24 kHz,
|
|
18
|
+
* base64-encoded, in BOTH directions (`input.audio` up, `reply.audio` down) — no
|
|
19
|
+
* per-session format negotiation exists on this provider.
|
|
20
|
+
*/
|
|
21
|
+
export const ASSEMBLYAI_PCM_SAMPLE_RATE = 24000;
|
|
22
|
+
// ── The driver ─────────────────────────────────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* AssemblyAI Voice Agent implementation of {@link BaseRealtimeClient}: a **browser-direct**
|
|
25
|
+
* agent websocket authenticated with the server-minted ONE-TIME client token (the
|
|
26
|
+
* `EphemeralToken` — no API key ever reaches the browser).
|
|
27
|
+
*
|
|
28
|
+
* Registered with the ClassFactory under the key `'assemblyai'` — the `Provider` string the
|
|
29
|
+
* server's `AssemblyAIRealtime` driver stamps on its `ClientRealtimeSessionConfig`.
|
|
30
|
+
*
|
|
31
|
+
* Owns ALL AssemblyAI wire concerns (the behavioral sibling of the ElevenLabs client driver —
|
|
32
|
+
* the audio plane is client-owned over a websocket, no WebRTC):
|
|
33
|
+
* - **Connect handshake**: open `wss://…/v1/ws?token=…` → send the server-authored
|
|
34
|
+
* `session.update` (from the `SessionConfig` pact: system prompt, tools, voice, turn
|
|
35
|
+
* detection) as the FIRST frame → wait for `session.ready` → build the audio plane at the
|
|
36
|
+
* provider's FIXED 24 kHz PCM16 format → `'listening'`. The state is gated on
|
|
37
|
+
* `session.ready` (driver obligation #7): only then has the provider confirmed the session
|
|
38
|
+
* config is applied (audio sent earlier would be dropped anyway).
|
|
39
|
+
* - **Audio in**: mic PCM16 at 24 kHz via the shared {@link createPcmMicCapture} worklet
|
|
40
|
+
* pipeline, streamed as base64 `input.audio` frames.
|
|
41
|
+
* - **Audio out**: `reply.audio` chunks decoded into the shared {@link RealtimePcmPlayback}
|
|
42
|
+
* at 24 kHz; {@link IsAudioPlaying} comes from its playout clock.
|
|
43
|
+
* - **Transcripts**: user turns stream as `transcript.user.delta` fragments
|
|
44
|
+
* (`IsFinal: false` deltas) finalized by `transcript.user`; agent turns arrive as ONE
|
|
45
|
+
* final `transcript.agent` (after a barge-in it carries `interrupted: true` and the text
|
|
46
|
+
* already reflects the truncated turn — no ElevenLabs-style correction event follows).
|
|
47
|
+
* - **Busy mapping**: `IsBusy` is set on `reply.started` / first `reply.audio` (and eagerly
|
|
48
|
+
* when this client triggers a reply) and cleared on `reply.done` and on a `tool.call`
|
|
49
|
+
* frame (the agent has yielded the floor pending the result — deadlock guard,
|
|
50
|
+
* obligation #2).
|
|
51
|
+
* - **Narration is NATIVE**: {@link RequestSpokenUpdate} sends `reply.create` with the
|
|
52
|
+
* instructions (a real per-response-instructions channel — no user-turn emulation). The
|
|
53
|
+
* response kind is stamped `'narration'` at send time (sends are the turn triggers) and
|
|
54
|
+
* reset on the reply boundary; the kind tagging assumes `transcript.agent` precedes its
|
|
55
|
+
* `reply.done`, which is the provider's emission order.
|
|
56
|
+
* - **{@link SendText} is EMULATED via `reply.create`**: the protocol accepts no typed user
|
|
57
|
+
* input, so the typed text rides as response instructions framing it as the user's turn.
|
|
58
|
+
* Fidelity caveat: the text enters as instructions rather than a conversation user turn,
|
|
59
|
+
* so the agent may occasionally reference "the message you sent" — hosts should treat
|
|
60
|
+
* typed input on this provider as best-effort.
|
|
61
|
+
* - **{@link SendContextNote} is EMULATED via the mutable `system_prompt`**: notes are
|
|
62
|
+
* appended under a "Background updates" heading and the full prompt is re-sent via
|
|
63
|
+
* `session.update` — a config write that never triggers or disturbs generation, so it is
|
|
64
|
+
* sent immediately even mid-reply.
|
|
65
|
+
* - **Barge-in**: `input.speech.started` while a reply is active (or audio audibly playing)
|
|
66
|
+
* is the snappy flush point (per the provider's own guidance — ~300 ms faster than waiting
|
|
67
|
+
* for `reply.done`); `reply.done` with `status: 'interrupted'` is the authoritative
|
|
68
|
+
* verdict and is the fallback flush when the speech-start gate missed. A speech start
|
|
69
|
+
* while idle is a user simply taking their turn — NOT an interruption (base contract).
|
|
70
|
+
* - **Cancel**: the protocol has no cancel frame. {@link CancelActiveResponse} flushes the
|
|
71
|
+
* locally-owned playout queue (speech stops immediately), marks the reply inactive, and
|
|
72
|
+
* SUPPRESSES residual `reply.audio` frames of the cancelled reply until the next reply
|
|
73
|
+
* boundary — so late-arriving chunks of a cancelled turn are never played or allowed to
|
|
74
|
+
* re-assert `'speaking'`.
|
|
75
|
+
* - **No usage telemetry**: the streaming socket exposes no token-usage events, so this
|
|
76
|
+
* driver NEVER emits {@link OnUsage} (registering a handler is safe; it just never fires).
|
|
77
|
+
*/
|
|
78
|
+
let AssemblyAIRealtimeClient = class AssemblyAIRealtimeClient extends BaseRealtimeClient {
|
|
79
|
+
constructor() {
|
|
80
|
+
super(...arguments);
|
|
81
|
+
// ── Transport / audio resources ────────────────────────────────────────────
|
|
82
|
+
this.socket = null;
|
|
83
|
+
this.micStream = null;
|
|
84
|
+
this.micCapture = null;
|
|
85
|
+
this.playback = null;
|
|
86
|
+
// ── Response state machine ─────────────────────────────────────────────────
|
|
87
|
+
/** True while an agent reply is in flight; gates (queues) client-triggered sends. */
|
|
88
|
+
this.responseActive = false;
|
|
89
|
+
/** The kind of the reply currently in flight; stamped at send time, reset on boundary. */
|
|
90
|
+
this.activeResponseKind = 'normal';
|
|
91
|
+
/** Sends deferred while a reply is in flight; drained in order at the next boundary. */
|
|
92
|
+
this.queuedSends = [];
|
|
93
|
+
/**
|
|
94
|
+
* True between a {@link CancelActiveResponse} and the next reply boundary: residual
|
|
95
|
+
* `reply.audio` chunks of the cancelled reply are dropped instead of played.
|
|
96
|
+
*/
|
|
97
|
+
this.suppressReplyAudio = false;
|
|
98
|
+
/**
|
|
99
|
+
* Tool calls awaiting their result — used to make {@link SendToolResult} EXACTLY-ONCE:
|
|
100
|
+
* the id is consumed when the result is accepted (queued or sent), so a duplicate send
|
|
101
|
+
* for the same call is dropped with a warning instead of confusing the conversation.
|
|
102
|
+
*/
|
|
103
|
+
this.pendingToolCallIds = new Set();
|
|
104
|
+
/** True once Disconnect ran — an expected socket close must not surface as fatal. */
|
|
105
|
+
this.closedByConsumer = false;
|
|
106
|
+
// ── session.resume reconnect window ─────────────────────────────────────────
|
|
107
|
+
/** Provider-assigned session id (from `session.ready`) — the `session.resume` key. */
|
|
108
|
+
this.providerSessionId = null;
|
|
109
|
+
/** The token-authenticated endpoint URL, kept for the resume reattach socket. */
|
|
110
|
+
this.connectUrl = null;
|
|
111
|
+
/** One resume attempt per session: a second unexpected drop is fatal (as before). */
|
|
112
|
+
this.resumeAttempted = false;
|
|
113
|
+
/**
|
|
114
|
+
* The client's own view of the session state — mirrors what was last emitted, EXCEPT after
|
|
115
|
+
* a tool call: the host typically shows its own busy indicator then, so the client silently
|
|
116
|
+
* leaves `'speaking'` (no emission) until the result reply's first output re-asserts it.
|
|
117
|
+
*/
|
|
118
|
+
this.currentState = 'closed';
|
|
119
|
+
// ── Context-note prompt tracking ───────────────────────────────────────────
|
|
120
|
+
/** The server-authored base system prompt (from the SessionConfig pact). */
|
|
121
|
+
this.basePrompt = '';
|
|
122
|
+
/** Accumulated {@link SendContextNote} texts, re-sent with the full prompt each time. */
|
|
123
|
+
this.contextNotes = [];
|
|
124
|
+
// ── Connection internals ───────────────────────────────────────────────────
|
|
125
|
+
/** Resolver for the in-flight Connect's session.ready wait (null outside Connect). */
|
|
126
|
+
this.onSessionReady = null;
|
|
127
|
+
}
|
|
128
|
+
// ── BaseRealtimeClient: connection lifecycle ───────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Opens the client-direct session: socket to the token-authenticated endpoint, the
|
|
131
|
+
* server-authored `session.update` as the first frame, then — once `session.ready`
|
|
132
|
+
* confirms the config is applied — the audio plane at the provider's fixed 24 kHz
|
|
133
|
+
* format. Reports `'listening'` only after all of that (obligation #7).
|
|
134
|
+
*/
|
|
135
|
+
async Connect(config, micStream) {
|
|
136
|
+
this.micStream = micStream;
|
|
137
|
+
this.closedByConsumer = false;
|
|
138
|
+
this.setState('connecting');
|
|
139
|
+
const sessionObject = this.parseSessionObject(config);
|
|
140
|
+
this.basePrompt = typeof sessionObject['system_prompt'] === 'string' ? sessionObject['system_prompt'] : '';
|
|
141
|
+
this.contextNotes = [];
|
|
142
|
+
let openSocket = null;
|
|
143
|
+
let failOpen = null;
|
|
144
|
+
let confirmReady = null;
|
|
145
|
+
let failReady = null;
|
|
146
|
+
const opened = new Promise((resolve, reject) => {
|
|
147
|
+
openSocket = resolve;
|
|
148
|
+
failOpen = reject;
|
|
149
|
+
});
|
|
150
|
+
const ready = new Promise((resolve, reject) => {
|
|
151
|
+
confirmReady = resolve;
|
|
152
|
+
failReady = reject;
|
|
153
|
+
});
|
|
154
|
+
// When the socket dies BEFORE open, Connect throws at `await opened` and never awaits
|
|
155
|
+
// `ready` — pre-consume its rejection so it can't surface as an unhandled rejection.
|
|
156
|
+
ready.catch(() => undefined);
|
|
157
|
+
this.onSessionReady = confirmReady;
|
|
158
|
+
const failConnect = (error) => {
|
|
159
|
+
failOpen?.(error);
|
|
160
|
+
failReady?.(error);
|
|
161
|
+
};
|
|
162
|
+
this.connectUrl = `${ASSEMBLYAI_AGENT_WS_URL}?token=${encodeURIComponent(config.EphemeralToken)}`;
|
|
163
|
+
this.providerSessionId = null;
|
|
164
|
+
this.resumeAttempted = false;
|
|
165
|
+
const socket = this.createSocket(this.connectUrl);
|
|
166
|
+
this.socket = socket;
|
|
167
|
+
socket.onopen = () => openSocket?.();
|
|
168
|
+
socket.onmessage = (data) => this.handleSocketMessage(data);
|
|
169
|
+
socket.onerror = (message) => {
|
|
170
|
+
failConnect(new Error(message));
|
|
171
|
+
this.handleSocketError(message);
|
|
172
|
+
};
|
|
173
|
+
socket.onclose = () => {
|
|
174
|
+
failConnect(new Error('AssemblyAI agent socket closed during connect'));
|
|
175
|
+
this.handleSocketClose();
|
|
176
|
+
};
|
|
177
|
+
await opened;
|
|
178
|
+
this.setState('connected');
|
|
179
|
+
// The server-authored session config (the SessionConfig pact) is applied via the
|
|
180
|
+
// FIRST frame — prompt and tool authority stay server-side (obligation #8).
|
|
181
|
+
this.sendFrame({ type: 'session.update', session: sessionObject });
|
|
182
|
+
await ready;
|
|
183
|
+
this.playback = this.createPlayback(ASSEMBLYAI_PCM_SAMPLE_RATE);
|
|
184
|
+
this.micCapture = await this.createMicCapture(micStream, ASSEMBLYAI_PCM_SAMPLE_RATE, (base64Pcm16) => this.sendMicChunk(base64Pcm16));
|
|
185
|
+
// Audio-activity capability (base obligation #9): agent side taps the playout
|
|
186
|
+
// engine's master gain; user side meters the mic stream. Null-safe — test fakes /
|
|
187
|
+
// no-WebAudio environments simply leave the session un-metered.
|
|
188
|
+
this.attachOutputAudioMeter(this.playback?.CreateMeter?.() ?? null);
|
|
189
|
+
this.attachInputAudioMeter(RealtimeAudioMeter.ForMicStream(micStream));
|
|
190
|
+
this.setState('listening');
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Tears down the socket, mic capture, mic tracks, and playout engine, resets the response
|
|
194
|
+
* state machine, and emits a final `'closed'` (unless already `'error'`). Sends
|
|
195
|
+
* `session.end` first — without it the provider holds the session (billable) for a
|
|
196
|
+
* 30-second resume window. Safe to call more than once.
|
|
197
|
+
*/
|
|
198
|
+
async Disconnect() {
|
|
199
|
+
this.closedByConsumer = true;
|
|
200
|
+
this.closeAudioMeters();
|
|
201
|
+
this.micStream?.getTracks().forEach((track) => track.stop());
|
|
202
|
+
this.micStream = null;
|
|
203
|
+
this.micCapture?.Stop();
|
|
204
|
+
this.micCapture = null;
|
|
205
|
+
this.playback?.Close();
|
|
206
|
+
this.playback = null;
|
|
207
|
+
if (this.socket) {
|
|
208
|
+
try {
|
|
209
|
+
this.socket.send(JSON.stringify({ type: 'session.end' }));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* socket already dead — closing anyway */
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
this.socket.close();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* already closing */
|
|
219
|
+
}
|
|
220
|
+
this.socket = null;
|
|
221
|
+
}
|
|
222
|
+
this.resetResponseState();
|
|
223
|
+
if (this.currentState !== 'error') {
|
|
224
|
+
this.setState('closed');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// ── BaseRealtimeClient: outbound actions ──────────────────────────────────
|
|
228
|
+
/**
|
|
229
|
+
* Injects typed text — EMULATED via `reply.create` (the protocol accepts no typed user
|
|
230
|
+
* input), with instructions framing the text as the user's turn. No-op when the session
|
|
231
|
+
* is not open. See the class-level fidelity caveat.
|
|
232
|
+
*
|
|
233
|
+
* **SendText implies barge-in** (base-contract rule): an active spoken reply is cancelled
|
|
234
|
+
* via {@link CancelActiveResponse} first — playback flushed, reply marked inactive,
|
|
235
|
+
* queued sends drained — so the typed turn takes the floor immediately. If a drained
|
|
236
|
+
* queued send starts a new reply, the text queues behind it, preserving delivery order.
|
|
237
|
+
*/
|
|
238
|
+
SendText(text) {
|
|
239
|
+
if (!this.socket) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.CancelActiveResponse();
|
|
243
|
+
const instructions = `The user just sent this typed message. Treat it as their conversational turn and respond to it directly: ${text}`;
|
|
244
|
+
this.enqueueOrRun(() => this.sendReplyCreate(instructions, 'normal', true));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* @inheritdoc
|
|
248
|
+
*
|
|
249
|
+
* AssemblyAI has no cancel frame — the client OWNS the audio plane, so cancelling means:
|
|
250
|
+
* flush the local playout queue (speech stops immediately), mark the in-flight reply
|
|
251
|
+
* inactive, SUPPRESS residual `reply.audio` chunks of the cancelled reply until the next
|
|
252
|
+
* reply boundary, and drain queued sends (a queued narration takes the floor next —
|
|
253
|
+
* delivery is never dropped by a cancel). No-op when nothing is active.
|
|
254
|
+
*/
|
|
255
|
+
CancelActiveResponse() {
|
|
256
|
+
if (!this.socket) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!this.responseActive && !this.IsAudioPlaying) {
|
|
260
|
+
return; // nothing active — no-op by contract
|
|
261
|
+
}
|
|
262
|
+
this.playback?.Flush();
|
|
263
|
+
if (this.responseActive) {
|
|
264
|
+
this.suppressReplyAudio = true; // drop the cancelled reply's late chunks
|
|
265
|
+
}
|
|
266
|
+
this.responseActive = false;
|
|
267
|
+
this.activeResponseKind = 'normal';
|
|
268
|
+
this.flushQueuedSends();
|
|
269
|
+
if (this.currentState === 'speaking') {
|
|
270
|
+
this.setState('listening');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Injects background context — EMULATED via the mutable `system_prompt`: the note is
|
|
275
|
+
* appended under a "Background updates" heading and the FULL prompt is re-sent via
|
|
276
|
+
* `session.update`. A config write never triggers or disturbs generation, so it is sent
|
|
277
|
+
* immediately even while a reply is in flight (no queueing applies).
|
|
278
|
+
*/
|
|
279
|
+
SendContextNote(text) {
|
|
280
|
+
if (!this.socket) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.contextNotes.push(text);
|
|
284
|
+
this.sendFrame({ type: 'session.update', session: { system_prompt: this.composePromptWithNotes() } });
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Triggers ONE short spoken update — NATIVE via `reply.create` with per-response
|
|
288
|
+
* instructions. The resulting reply is stamped `Kind: 'narration'` at send time (reset
|
|
289
|
+
* on the reply boundary). Queued behind any in-flight reply per the base contract's
|
|
290
|
+
* collision rule (an overlapping `reply.create` would collide with active generation).
|
|
291
|
+
*/
|
|
292
|
+
RequestSpokenUpdate(instructions) {
|
|
293
|
+
if (!this.socket) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
this.enqueueOrRun(() => this.sendReplyCreate(instructions, 'narration', false));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Feeds an executed tool's result back via `tool.result`, correlated by the provider's
|
|
300
|
+
* `call_id`. EXACTLY-ONCE: the pending id is consumed when the result is accepted, and a
|
|
301
|
+
* duplicate (or unknown) callID is dropped with a warning. Sent immediately when idle —
|
|
302
|
+
* the provider speaks the result as the turn's continuation (no explicit generation
|
|
303
|
+
* trigger exists or is needed) — otherwise queued behind the in-flight reply (e.g. a
|
|
304
|
+
* progress narration) so the trigger is never lost and the narration's kind tagging is
|
|
305
|
+
* not clobbered. The wire `result` slot expects a JSON-STRING, which is exactly the
|
|
306
|
+
* contract's `outputJson` shape — it passes through verbatim.
|
|
307
|
+
*/
|
|
308
|
+
SendToolResult(callID, outputJson) {
|
|
309
|
+
if (!this.socket) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!this.pendingToolCallIds.has(callID)) {
|
|
313
|
+
console.warn(`AssemblyAIRealtimeClient.SendToolResult: no pending tool call '${callID}' — duplicate or unknown result dropped.`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.pendingToolCallIds.delete(callID);
|
|
317
|
+
this.enqueueOrRun(() => this.sendToolResultFrame(callID, outputJson));
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Mutes / unmutes by toggling the mic tracks' `enabled` flag: the capture pipeline stays
|
|
321
|
+
* up and streams SILENCE while muted (the provider's VAD sees a continuous stream and the
|
|
322
|
+
* un-mute is glitch-free — same policy as the other client drivers).
|
|
323
|
+
*/
|
|
324
|
+
SetMuted(muted) {
|
|
325
|
+
const tracks = this.micStream?.getAudioTracks() ?? [];
|
|
326
|
+
for (const track of tracks) {
|
|
327
|
+
track.enabled = !muted;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** @inheritdoc */
|
|
331
|
+
get IsBusy() {
|
|
332
|
+
return this.responseActive;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* @inheritdoc
|
|
336
|
+
*
|
|
337
|
+
* Computed directly from the playout engine's playhead clock — this client OWNS the
|
|
338
|
+
* output buffer, so "audibly playing" is precisely "scheduled audio extends beyond the
|
|
339
|
+
* audio context's current time".
|
|
340
|
+
*/
|
|
341
|
+
get IsAudioPlaying() {
|
|
342
|
+
return this.playback?.IsPlaying ?? false;
|
|
343
|
+
}
|
|
344
|
+
// ── Overridable creation seams (tests inject fakes — no network / audio) ──
|
|
345
|
+
/**
|
|
346
|
+
* Creation seam for the agent websocket. Production wraps the platform-global
|
|
347
|
+
* `WebSocket` opened against the token-authenticated URL; unit tests override this to
|
|
348
|
+
* return an in-memory fake. Handlers are attached by {@link Connect} AFTER this returns,
|
|
349
|
+
* so the implementation must not require them at construction time.
|
|
350
|
+
*/
|
|
351
|
+
createSocket(url) {
|
|
352
|
+
const WS = globalThis.WebSocket;
|
|
353
|
+
if (!WS) {
|
|
354
|
+
throw new Error('AssemblyAIRealtimeClient requires a global WebSocket (browser or Node 22+).');
|
|
355
|
+
}
|
|
356
|
+
const ws = new WS(url);
|
|
357
|
+
const seam = {
|
|
358
|
+
onopen: null,
|
|
359
|
+
onmessage: null,
|
|
360
|
+
onerror: null,
|
|
361
|
+
onclose: null,
|
|
362
|
+
send: (data) => ws.send(data),
|
|
363
|
+
close: () => ws.close(),
|
|
364
|
+
};
|
|
365
|
+
ws.onopen = () => seam.onopen?.();
|
|
366
|
+
ws.onmessage = (event) => seam.onmessage?.(String(event.data));
|
|
367
|
+
ws.onerror = () => seam.onerror?.('AssemblyAI agent websocket error');
|
|
368
|
+
ws.onclose = () => seam.onclose?.();
|
|
369
|
+
return seam;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Creation seam for the mic-capture pipeline at the provider's fixed 24 kHz rate.
|
|
373
|
+
* Production delegates to the shared {@link createPcmMicCapture}; unit tests override
|
|
374
|
+
* this with a no-op fake (and may capture `onPcmChunk` to simulate mic frames).
|
|
375
|
+
*/
|
|
376
|
+
async createMicCapture(micStream, sampleRate, onPcmChunk) {
|
|
377
|
+
return createPcmMicCapture(micStream, sampleRate, onPcmChunk);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Creation seam for the playout engine at the provider's fixed 24 kHz rate. Production
|
|
381
|
+
* returns the shared {@link RealtimePcmPlayback}.
|
|
382
|
+
*/
|
|
383
|
+
createPlayback(sampleRate) {
|
|
384
|
+
return new RealtimePcmPlayback(sampleRate);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Extracts the wire-shaped `session` object from the server-minted `SessionConfig` pact
|
|
388
|
+
* (`{ session, config }` — authored by the server's `AssemblyAIRealtime`). Falls back to
|
|
389
|
+
* an empty object when absent (the session then runs on provider defaults).
|
|
390
|
+
*/
|
|
391
|
+
parseSessionObject(config) {
|
|
392
|
+
const sessionConfig = config.SessionConfig ?? {};
|
|
393
|
+
const raw = sessionConfig['session'];
|
|
394
|
+
return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
395
|
+
}
|
|
396
|
+
/** Streams one base64 PCM16 mic chunk as an `input.audio` frame. */
|
|
397
|
+
sendMicChunk(base64Pcm16) {
|
|
398
|
+
if (this.socket) {
|
|
399
|
+
this.sendFrame({ type: 'input.audio', audio: base64Pcm16 });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** Surfaces a fatal socket error and marks the session unusable (obligation #6). */
|
|
403
|
+
handleSocketError(message) {
|
|
404
|
+
if (this.currentState === 'error' || this.currentState === 'closed') {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
this.emitError({ Message: `AssemblyAI realtime transport error: ${message}`, Fatal: true });
|
|
408
|
+
this.setState('error');
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* A socket close the CONSUMER didn't ask for is fatal: the provider hard-closes at token
|
|
412
|
+
* expiry and when it ends the session itself, so an unexpected close is how credential /
|
|
413
|
+
* session death reaches the host (obligation #6).
|
|
414
|
+
*/
|
|
415
|
+
handleSocketClose() {
|
|
416
|
+
if (this.closedByConsumer || this.currentState === 'error' || this.currentState === 'closed') {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// The provider holds the session for a 30-second resume window — try ONE reattach
|
|
420
|
+
// before declaring the session dead (the plan's "provider session.resume" item).
|
|
421
|
+
if (this.tryResumeSession()) {
|
|
422
|
+
return; // the resume path owns the state machine from here
|
|
423
|
+
}
|
|
424
|
+
this.emitError({ Message: 'AssemblyAI agent session closed unexpectedly', Fatal: true });
|
|
425
|
+
this.setState('error');
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* ONE-shot reattach inside the provider's 30-second resume window: a fresh socket to
|
|
429
|
+
* the same token-authenticated endpoint whose FIRST frame is `session.resume` with the
|
|
430
|
+
* `session_id` captured from `session.ready`. The provider re-confirms with another
|
|
431
|
+
* `session.ready`, which restores `'listening'` — the mic worklet and playout engine
|
|
432
|
+
* survive untouched (mic chunks simply flow into the new socket). A failed or second
|
|
433
|
+
* drop falls through to the pre-existing fatal path, so the worst case is exactly the
|
|
434
|
+
* old behavior. Returns `true` when a reattach was started.
|
|
435
|
+
*/
|
|
436
|
+
tryResumeSession() {
|
|
437
|
+
if (this.resumeAttempted || !this.providerSessionId || !this.connectUrl) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
this.resumeAttempted = true;
|
|
441
|
+
const sessionId = this.providerSessionId;
|
|
442
|
+
this.setState('connecting'); // hosts surface this as "reconnecting…"
|
|
443
|
+
try {
|
|
444
|
+
const socket = this.createSocket(this.connectUrl);
|
|
445
|
+
this.socket = socket;
|
|
446
|
+
socket.onopen = () => this.sendFrame({ type: 'session.resume', session_id: sessionId });
|
|
447
|
+
socket.onmessage = (data) => this.handleSocketMessage(data);
|
|
448
|
+
socket.onerror = (message) => this.handleSocketError(message);
|
|
449
|
+
socket.onclose = () => this.handleSocketClose(); // resumeAttempted → fatal path
|
|
450
|
+
// The resume handshake's `session.ready` restores the live state.
|
|
451
|
+
this.onSessionReady = () => this.setState('listening');
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return false; // socket construction failed — fall through to the fatal path
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ── Inbound message translation ────────────────────────────────────────────
|
|
459
|
+
/** Parses one raw socket payload; non-JSON frames are ignored. */
|
|
460
|
+
handleSocketMessage(data) {
|
|
461
|
+
let event;
|
|
462
|
+
try {
|
|
463
|
+
event = JSON.parse(data);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
this.handleServerEvent(event);
|
|
469
|
+
}
|
|
470
|
+
/** Multiplexes one inbound frame to the focused per-concern handlers. */
|
|
471
|
+
handleServerEvent(event) {
|
|
472
|
+
switch (event.type) {
|
|
473
|
+
case 'session.ready':
|
|
474
|
+
// The provider-assigned id is the `session.resume` key (30s reattach window).
|
|
475
|
+
this.providerSessionId = event.session_id ?? this.providerSessionId;
|
|
476
|
+
this.onSessionReady?.();
|
|
477
|
+
this.onSessionReady = null;
|
|
478
|
+
break;
|
|
479
|
+
case 'session.updated':
|
|
480
|
+
break; // config-apply confirmation — nothing to surface
|
|
481
|
+
case 'reply.started':
|
|
482
|
+
this.handleReplyStarted();
|
|
483
|
+
break;
|
|
484
|
+
case 'reply.audio':
|
|
485
|
+
this.handleReplyAudio(event.data);
|
|
486
|
+
break;
|
|
487
|
+
case 'transcript.user.delta':
|
|
488
|
+
this.handleUserTranscript(event.text, false);
|
|
489
|
+
break;
|
|
490
|
+
case 'transcript.user':
|
|
491
|
+
this.handleUserTranscript(event.text, true);
|
|
492
|
+
break;
|
|
493
|
+
case 'transcript.agent':
|
|
494
|
+
this.handleAgentTranscript(event.text);
|
|
495
|
+
break;
|
|
496
|
+
case 'reply.done':
|
|
497
|
+
this.handleReplyDone(event.status);
|
|
498
|
+
break;
|
|
499
|
+
case 'tool.call':
|
|
500
|
+
this.handleToolCall(event);
|
|
501
|
+
break;
|
|
502
|
+
case 'input.speech.started':
|
|
503
|
+
this.handleSpeechStarted();
|
|
504
|
+
break;
|
|
505
|
+
case 'input.speech.stopped':
|
|
506
|
+
break; // VAD telemetry — nothing to surface
|
|
507
|
+
case 'session.error':
|
|
508
|
+
this.emitError({
|
|
509
|
+
Message: event.message ?? 'AssemblyAI session error',
|
|
510
|
+
Code: event.code,
|
|
511
|
+
Fatal: false,
|
|
512
|
+
});
|
|
513
|
+
break;
|
|
514
|
+
case 'session.ended':
|
|
515
|
+
this.handleSessionEnded();
|
|
516
|
+
break;
|
|
517
|
+
default:
|
|
518
|
+
break; // unknown / future frame types are ignored
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/** A fresh reply is generating: lift any cancel suppression and mark the agent busy. */
|
|
522
|
+
handleReplyStarted() {
|
|
523
|
+
this.suppressReplyAudio = false; // a NEW reply — its audio is real
|
|
524
|
+
this.markGenerationStarted();
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Decodes one base64 reply-audio chunk into the playout queue and marks generation live —
|
|
528
|
+
* unless a local cancel suppressed the remainder of this reply, in which case the chunk
|
|
529
|
+
* is dropped (never played, never re-asserts `'speaking'`).
|
|
530
|
+
*/
|
|
531
|
+
handleReplyAudio(audioBase64) {
|
|
532
|
+
if (!audioBase64 || this.suppressReplyAudio) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.markGenerationStarted();
|
|
536
|
+
this.playback?.Enqueue(base64ToArrayBuffer(audioBase64));
|
|
537
|
+
}
|
|
538
|
+
/** User transcript: streaming deltas (`IsFinal: false`) finalized by `transcript.user`. */
|
|
539
|
+
handleUserTranscript(text, isFinal) {
|
|
540
|
+
if (text && text.trim().length > 0) {
|
|
541
|
+
this.emitTranscript({ Role: 'User', Text: text, IsFinal: isFinal, Kind: 'normal' });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Agent transcript: the turn's complete text, emitted FINAL with the ACTIVE reply kind
|
|
546
|
+
* so narration turns are tagged correctly (stamp-at-send — see the class doc). After a
|
|
547
|
+
* barge-in the provider sends the truncated text with `interrupted: true` — already the
|
|
548
|
+
* authoritative record of what was spoken, emitted the same way.
|
|
549
|
+
*/
|
|
550
|
+
handleAgentTranscript(text) {
|
|
551
|
+
if (text && text.trim().length > 0) {
|
|
552
|
+
this.emitTranscript({ Role: 'Assistant', Text: text, IsFinal: true, Kind: this.activeResponseKind });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Reply boundary. `status: 'interrupted'` is the provider's authoritative true-barge-in
|
|
557
|
+
* verdict — when the snappy `input.speech.started` gate already handled it this is a
|
|
558
|
+
* no-op fallback; when it didn't (e.g. the speech-start frame was missed), the flush +
|
|
559
|
+
* interruption surface here. Then: release the busy lock, lift any cancel suppression,
|
|
560
|
+
* reset the reply kind, drain queued sends (stopping at the first one that starts a new
|
|
561
|
+
* reply), and return the floor.
|
|
562
|
+
*/
|
|
563
|
+
handleReplyDone(status) {
|
|
564
|
+
if (status === 'interrupted' && (this.responseActive || this.IsAudioPlaying)) {
|
|
565
|
+
this.playback?.Flush();
|
|
566
|
+
this.emitInterruption();
|
|
567
|
+
}
|
|
568
|
+
this.responseActive = false;
|
|
569
|
+
this.suppressReplyAudio = false;
|
|
570
|
+
this.activeResponseKind = 'normal';
|
|
571
|
+
this.flushQueuedSends();
|
|
572
|
+
if (this.currentState === 'speaking') {
|
|
573
|
+
this.setState('listening');
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Surfaces the agent's tool call to the host. Two deliberate behaviors mirror the other
|
|
578
|
+
* drivers: (1) the client silently leaves `'speaking'` (no emission) so a host-rendered
|
|
579
|
+
* busy indicator isn't clobbered by the turn's trailing frames (obligation #1); (2)
|
|
580
|
+
* `responseActive` is CLEARED — the agent has yielded the floor pending the result, so a
|
|
581
|
+
* queued send can never deadlock (obligation #2). The queue is NOT drained here: a queued
|
|
582
|
+
* narration must not trigger a reply between the tool call and its result. The provider
|
|
583
|
+
* emits `arguments` ALREADY PARSED, so it is re-stringified to honor the base contract's
|
|
584
|
+
* JSON-string `ArgumentsJson` shape.
|
|
585
|
+
*/
|
|
586
|
+
handleToolCall(event) {
|
|
587
|
+
if (this.currentState === 'speaking') {
|
|
588
|
+
this.currentState = 'connected';
|
|
589
|
+
}
|
|
590
|
+
this.responseActive = false;
|
|
591
|
+
const callID = event.call_id ?? '';
|
|
592
|
+
this.pendingToolCallIds.add(callID);
|
|
593
|
+
this.emitToolCall({ CallID: callID, ToolName: event.name ?? '', ArgumentsJson: JSON.stringify(event.arguments ?? {}) });
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The user started speaking. When a reply is in flight or audio is audibly playing this
|
|
597
|
+
* is a TRUE barge-in: flush playout NOW (the provider's own guidance — ~300 ms snappier
|
|
598
|
+
* than waiting for `reply.done`), suppress the cancelled reply's residual audio, surface
|
|
599
|
+
* the interruption, and give the floor back. While idle it is a user simply taking their
|
|
600
|
+
* turn — NOT an interruption (base contract), so nothing is emitted. The queue is NOT
|
|
601
|
+
* drained — the user has the floor; queued sends flush at the next reply boundary.
|
|
602
|
+
*/
|
|
603
|
+
handleSpeechStarted() {
|
|
604
|
+
if (!this.responseActive && !this.IsAudioPlaying) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
this.playback?.Flush();
|
|
608
|
+
if (this.responseActive) {
|
|
609
|
+
this.suppressReplyAudio = true; // drop the interrupted reply's late chunks
|
|
610
|
+
}
|
|
611
|
+
this.responseActive = false;
|
|
612
|
+
this.activeResponseKind = 'normal';
|
|
613
|
+
this.emitInterruption();
|
|
614
|
+
this.setState('listening');
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* The provider ended the session itself. After a consumer {@link Disconnect} (which
|
|
618
|
+
* sends `session.end`) this is the expected acknowledgment and stays silent; otherwise
|
|
619
|
+
* it is surfaced as FATAL so the host tears down instead of idling on a dying socket.
|
|
620
|
+
*/
|
|
621
|
+
handleSessionEnded() {
|
|
622
|
+
if (this.closedByConsumer || this.currentState === 'error' || this.currentState === 'closed') {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
this.emitError({ Message: 'AssemblyAI agent session ended by the provider', Fatal: true });
|
|
626
|
+
this.setState('error');
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* First model output of a reply (reply.started or an audio chunk): the agent is busy and
|
|
630
|
+
* the client is audibly / imminently `'speaking'`.
|
|
631
|
+
*/
|
|
632
|
+
markGenerationStarted() {
|
|
633
|
+
this.responseActive = true;
|
|
634
|
+
if (this.currentState !== 'speaking') {
|
|
635
|
+
this.setState('speaking');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// ── Collision-safe send machinery ──────────────────────────────────────────
|
|
639
|
+
/**
|
|
640
|
+
* Runs a send immediately when no reply is in flight; otherwise queues it for the next
|
|
641
|
+
* boundary (`reply.done`). An overlapping `reply.create` would collide with active
|
|
642
|
+
* generation, so deferral is the safe default — mirroring the other drivers' rule.
|
|
643
|
+
*/
|
|
644
|
+
enqueueOrRun(send) {
|
|
645
|
+
if (this.responseActive) {
|
|
646
|
+
this.queuedSends.push(send);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
send();
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Drains queued sends in order at a reply boundary, stopping as soon as one starts a
|
|
653
|
+
* new reply (sets {@link responseActive}) — the rest wait for that reply to finish.
|
|
654
|
+
*/
|
|
655
|
+
flushQueuedSends() {
|
|
656
|
+
while (!this.responseActive && this.queuedSends.length > 0) {
|
|
657
|
+
const send = this.queuedSends.shift();
|
|
658
|
+
send?.();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Sends a `reply.create` that triggers a reply, stamping the upcoming reply's kind at
|
|
663
|
+
* send time and eagerly marking the agent busy. `emitSpeaking` mirrors the other
|
|
664
|
+
* drivers: typed text reflects `'speaking'` immediately; narration waits for the first
|
|
665
|
+
* model output.
|
|
666
|
+
*/
|
|
667
|
+
sendReplyCreate(instructions, kind, emitSpeaking) {
|
|
668
|
+
if (!this.socket) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
this.sendFrame({ type: 'reply.create', instructions });
|
|
672
|
+
this.responseActive = true;
|
|
673
|
+
this.activeResponseKind = kind;
|
|
674
|
+
if (emitSpeaking) {
|
|
675
|
+
this.setState('speaking');
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Sends the `tool.result` frame (the provider speaks the result as the turn's
|
|
680
|
+
* continuation — no explicit generation trigger exists or is needed) and eagerly marks
|
|
681
|
+
* the agent busy so a queued narration can't slip in before the spoken result.
|
|
682
|
+
*/
|
|
683
|
+
sendToolResultFrame(callID, outputJson) {
|
|
684
|
+
if (!this.socket) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
this.sendFrame({ type: 'tool.result', call_id: callID, result: outputJson });
|
|
688
|
+
this.responseActive = true;
|
|
689
|
+
this.activeResponseKind = 'normal';
|
|
690
|
+
this.setState('speaking');
|
|
691
|
+
}
|
|
692
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
693
|
+
/** The base prompt plus every accumulated context note under a "Background updates" heading. */
|
|
694
|
+
composePromptWithNotes() {
|
|
695
|
+
if (this.contextNotes.length === 0) {
|
|
696
|
+
return this.basePrompt;
|
|
697
|
+
}
|
|
698
|
+
return `${this.basePrompt}\n\n## Background updates\n${this.contextNotes.map((n) => `- ${n}`).join('\n')}`;
|
|
699
|
+
}
|
|
700
|
+
/** JSON-serializes and sends one client frame (no-op once the socket is gone). */
|
|
701
|
+
sendFrame(frame) {
|
|
702
|
+
this.socket?.send(JSON.stringify(frame));
|
|
703
|
+
}
|
|
704
|
+
/** Resets the per-session response state machine (used on Disconnect). */
|
|
705
|
+
resetResponseState() {
|
|
706
|
+
this.responseActive = false;
|
|
707
|
+
this.activeResponseKind = 'normal';
|
|
708
|
+
this.queuedSends = [];
|
|
709
|
+
this.suppressReplyAudio = false;
|
|
710
|
+
this.pendingToolCallIds.clear();
|
|
711
|
+
this.onSessionReady = null;
|
|
712
|
+
this.contextNotes = [];
|
|
713
|
+
}
|
|
714
|
+
/** Updates the client's own state view and emits the change to the host. */
|
|
715
|
+
setState(state) {
|
|
716
|
+
this.currentState = state;
|
|
717
|
+
this.emitStateChange(state);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
AssemblyAIRealtimeClient = __decorate([
|
|
721
|
+
RegisterClass(BaseRealtimeClient, 'assemblyai')
|
|
722
|
+
], AssemblyAIRealtimeClient);
|
|
723
|
+
export { AssemblyAIRealtimeClient };
|
|
724
|
+
/**
|
|
725
|
+
* Tree-shaking prevention: bundlers cannot see that {@link AssemblyAIRealtimeClient} is
|
|
726
|
+
* instantiated dynamically through the ClassFactory, so a consumer must call this no-op
|
|
727
|
+
* to create a static code path that keeps the `@RegisterClass` side effect alive.
|
|
728
|
+
*/
|
|
729
|
+
export function LoadAssemblyAIRealtimeClient() {
|
|
730
|
+
// intentional no-op — the static import of this module is the point
|
|
731
|
+
}
|
|
732
|
+
//# sourceMappingURL=assemblyAIRealtimeClient.js.map
|