@memberjunction/ai-realtime-client 5.48.0 → 5.50.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.
@@ -0,0 +1,873 @@
1
+ import { BaseRealtimeClient } from './baseRealtimeClient.js';
2
+ import { base64ToArrayBuffer } from '../audio/pcmUtils.js';
3
+ import { RealtimePcmPlayback } from '../audio/pcmPlayback.js';
4
+ import { RealtimeAudioMeter } from '../audio/audioMeter.js';
5
+ import { createPcmMicCapture } from '../audio/micCapture.js';
6
+ // ── Layer 1: the protocol brain ─────────────────────────────────────────────────
7
+ /**
8
+ * The shared **OpenAI-protocol brain** for realtime CLIENT drivers — the transport-agnostic
9
+ * middle layer between {@link BaseRealtimeClient} and the per-provider drivers.
10
+ *
11
+ * Every OpenAI-protocol provider (OpenAI itself over WebRTC, xAI Grok Voice and self-hosted
12
+ * HuggingFace over websockets) shares the exact same event vocabulary and turn discipline, so
13
+ * this class owns — ONCE — the pieces that were previously cloned per driver:
14
+ *
15
+ * - **Inbound event dispatch** ({@link handleEvent}): GA + beta transcript names, input
16
+ * transcription, tool calls, barge-in gating, playback-buffer events, provider error frames.
17
+ * - **The response state machine**: `responseActive` set on `response.created` / cleared on
18
+ * `response.done`; tool-result `response.create`s queued while a response is in flight and
19
+ * flushed on `response.done` so the model ALWAYS voices delegated results.
20
+ * - **Narration-kind tagging**: {@link RequestSpokenUpdate} marks the NEXT response as
21
+ * `'narration'` so its transcripts are emitted with `Kind: 'narration'` (ephemeral).
22
+ * - **The outbound actions**: {@link SendText} (implies barge-in), {@link SendContextNote},
23
+ * {@link SendToolResult}, {@link CancelActiveResponse}, {@link SetMuted}.
24
+ *
25
+ * Transport specifics stay in subclasses through a small set of seams: {@link canSendEvents} /
26
+ * {@link sendProtocolEvent} (the wire), {@link stopAudioOutput} (how already-generated speech is
27
+ * silenced), {@link handleAudioDeltaFrame} (websocket transports enqueue PCM; WebRTC ignores —
28
+ * audio rides the peer connection), and per-provider behavioral overrides
29
+ * ({@link onUserTranscriptFrame}, {@link onSpeechStartedFrame}, {@link releasesBusyFlagOnToolCall}).
30
+ */
31
+ export class OpenAIProtocolRealtimeClient extends BaseRealtimeClient {
32
+ constructor() {
33
+ super(...arguments);
34
+ // ── Response state machine (shared verbatim across the protocol family) ────
35
+ /** Accumulates the in-flight assistant transcript across delta frames. */
36
+ this.pendingAssistantText = '';
37
+ /** True while the model has a response in flight; gates narration + queues the tool result. */
38
+ this.responseActive = false;
39
+ /** Set when a tool result is ready while a response is active; sent on the next response.done. */
40
+ this.pendingResultResponse = false;
41
+ /**
42
+ * Count of locally-initiated `response.create`s whose `response.created` echo has not arrived
43
+ * yet. While non-zero, a `response.done` belongs to an EARLIER (typically just-cancelled)
44
+ * response — it must not clear the busy flag, flush the queued trigger, or flip the state for
45
+ * the response we just started (usage is still emitted). Consumed by `response.created`.
46
+ */
47
+ this.pendingLocalResponseCreates = 0;
48
+ /**
49
+ * True while a response the provider has CONFIRMED (`response.created` seen, `response.done` not
50
+ * yet) is in flight. Distinct from {@link responseActive}, which is set EAGERLY before a local
51
+ * `response.create` is even sent: if the provider REJECTS that create (an `error` frame with no
52
+ * `response.created`), `responseActive` would otherwise stay stuck true forever. This flag lets
53
+ * {@link onErrorFrame} tell "the eager flag is a phantom for a rejected create" (no confirmed
54
+ * response) from "a real response is genuinely active" (e.g. a concurrent VAD turn), so it clears
55
+ * the phantom without disturbing a live turn. Set on `response.created`, cleared on `response.done`.
56
+ */
57
+ this.confirmedResponseActive = false;
58
+ /**
59
+ * Set by {@link RequestSpokenUpdate} just before it sends its `response.create`, and
60
+ * CONSUMED by the very next `response.created` frame, which stamps
61
+ * {@link activeResponseKind} for that turn. Narration is only requested while the model is
62
+ * idle (hosts gate on {@link IsBusy}), so under normal ordering the next `response.created`
63
+ * is ours.
64
+ */
65
+ this.pendingNarrationKind = false;
66
+ /**
67
+ * The kind of the response currently in flight. Event ordering (confirmed against the live
68
+ * OpenAI API): `response.created` → transcript deltas → `*_audio_transcript.done` →
69
+ * `response.done`. The transcript-done frame therefore arrives while the kind is still set,
70
+ * letting {@link onAssistantDone} classify the turn; `response.done` then resets it.
71
+ */
72
+ this.activeResponseKind = 'normal';
73
+ /**
74
+ * The client's own view of the session state — mirrors what `emitStateChange` last
75
+ * reported, EXCEPT after a tool call is emitted: the host typically shows its own busy
76
+ * state then, so the client silently leaves `'speaking'` (no emission) to preserve the
77
+ * host's indicator until the result reply starts (see {@link onToolCallFrame}).
78
+ */
79
+ this.currentState = 'closed';
80
+ /** The mic stream owned by the current connection (used by the shared {@link SetMuted}). */
81
+ this.micStream = null;
82
+ }
83
+ // ── Virtual per-provider behavior seams (sensible OpenAI defaults) ─────────
84
+ /** Debug label used in the shared console diagnostics. */
85
+ get providerDebugLabel() {
86
+ return this.constructor.name;
87
+ }
88
+ /**
89
+ * Handles one base64 PCM16 audio delta. Default: no-op — on the WebRTC transport the agent's
90
+ * audio rides the peer connection's remote track, not data-channel frames. Websocket
91
+ * transports override to enqueue into their local playout engine.
92
+ */
93
+ handleAudioDeltaFrame(_deltaBase64) {
94
+ // WebRTC: audio arrives on the remote media track, not as protocol frames.
95
+ }
96
+ /**
97
+ * Emits the user's spoken-input transcription. Default (OpenAI/HuggingFace): each
98
+ * `.completed` frame is one final caption. Providers that STREAM the completed event
99
+ * (Grok re-sends the full growing text each time) override to collapse the stream into a
100
+ * single in-place-updating bubble.
101
+ */
102
+ onUserTranscriptFrame(transcript) {
103
+ this.emitTranscript({ Role: 'User', Text: transcript, IsFinal: true, Kind: 'normal' });
104
+ }
105
+ /**
106
+ * The user started speaking. TRUE barge-in only when it cut off active model output (a
107
+ * response in flight or audio audibly playing) — a normal turn while the model is idle is
108
+ * NOT an interruption, so the emission is gated (base-contract rule). Transports that own
109
+ * their playback override to also flush the local playout queue.
110
+ */
111
+ onSpeechStartedFrame() {
112
+ if (this.responseActive || this.IsAudioPlaying) {
113
+ // The user took the floor. Drop the queued AUTO-trigger: the tool-result item is
114
+ // already in the conversation, so the user's next turn (server-VAD response) voices
115
+ // it contextually instead of a queued response.create stomping the fresh user turn.
116
+ this.pendingResultResponse = false;
117
+ this.emitInterruption();
118
+ }
119
+ this.setState('listening');
120
+ }
121
+ /**
122
+ * Whether a completed tool call CLEARS {@link responseActive}. WebRTC keeps the flag (the
123
+ * provider reliably follows with `response.done`); websocket transports clear it as a
124
+ * deadlock guard so a queued {@link SendToolResult} can never wedge if the endpoint skips
125
+ * the trailing frame.
126
+ */
127
+ get releasesBusyFlagOnToolCall() {
128
+ return false;
129
+ }
130
+ /** Hook for transports that gate on the endpoint's `session.created` frame. Default: no-op. */
131
+ onSessionCreatedFrame() {
132
+ // Only deferring transports care.
133
+ }
134
+ /** Diagnostic hook for each parsed inbound event. Default: silent. */
135
+ logInboundEvent(_event) {
136
+ // Overridden by drivers that need wire diagnostics.
137
+ }
138
+ /** Diagnostic hook for each non-JSON inbound frame. Default: silent. */
139
+ onNonJsonFrame(_raw) {
140
+ // Overridden by drivers that need wire diagnostics.
141
+ }
142
+ /** Diagnostic hook for each outbound event. Default: silent. */
143
+ logOutboundEvent(_event) {
144
+ // Overridden by drivers that need wire diagnostics.
145
+ }
146
+ // ── BaseRealtimeClient: shared outbound actions ────────────────────────────
147
+ /**
148
+ * Injects typed text as a user-role `message` conversation item, then triggers a reply
149
+ * through the SAME collision-safe path tool results use ({@link requestResultResponse}).
150
+ * No-op when the transport isn't open.
151
+ *
152
+ * **SendText implies barge-in** (base-contract rule): an active spoken response is
153
+ * cancelled via {@link CancelActiveResponse} before the text is injected, so the typed
154
+ * turn takes the floor immediately instead of waiting behind stale speech. When nothing is
155
+ * active the cancel is a no-op and the reply triggers immediately.
156
+ */
157
+ SendText(text) {
158
+ if (!this.canSendEvents()) {
159
+ return;
160
+ }
161
+ this.CancelActiveResponse();
162
+ this.sendEvent({
163
+ type: 'conversation.item.create',
164
+ item: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] },
165
+ });
166
+ this.requestResultResponse();
167
+ }
168
+ /**
169
+ * @inheritdoc
170
+ *
171
+ * Sends `response.cancel` (only when a response is actually in flight) and silences
172
+ * already-generated speech via the transport's {@link stopAudioOutput}. Resets the local
173
+ * response state machine (active flag, narration kind, accumulated transcript) but
174
+ * PRESERVES any queued tool-result trigger: delegated work is never affected by a
175
+ * floor-control cancel, and the queued trigger still fires on the cancelled response's
176
+ * trailing `response.done`. No-op when idle or when the transport is not open.
177
+ */
178
+ CancelActiveResponse() {
179
+ if (!this.canSendEvents()) {
180
+ return;
181
+ }
182
+ if (!this.responseActive && !this.IsAudioPlaying) {
183
+ return; // nothing active — no-op by contract
184
+ }
185
+ if (this.responseActive) {
186
+ this.sendEvent({ type: 'response.cancel' });
187
+ this.responseActive = false;
188
+ this.pendingNarrationKind = false;
189
+ this.activeResponseKind = 'normal';
190
+ this.pendingAssistantText = '';
191
+ }
192
+ this.stopAudioOutput();
193
+ if (this.currentState === 'speaking') {
194
+ this.setState('listening');
195
+ }
196
+ }
197
+ /**
198
+ * Injects a system-role context item the model can draw on the next time it speaks,
199
+ * WITHOUT forcing a reply. Item creation is always safe mid-response.
200
+ *
201
+ * NOTE: role must be 'system' — gpt-realtime (and the compatible endpoints) reject
202
+ * 'developer' items ("Developer messages are only supported for quicksilver sessions").
203
+ */
204
+ SendContextNote(text) {
205
+ if (!this.canSendEvents()) {
206
+ return;
207
+ }
208
+ this.sendEvent({
209
+ type: 'conversation.item.create',
210
+ item: { type: 'message', role: 'system', content: [{ type: 'input_text', text }] },
211
+ });
212
+ }
213
+ /**
214
+ * Triggers ONE short spoken update with the given instructions. Marks the upcoming
215
+ * response as `'narration'` (flag consumed by the next `response.created`) so its
216
+ * transcripts are emitted with `Kind: 'narration'` — ephemeral by contract. Sets
217
+ * {@link responseActive} eagerly so a tool result landing mid-narration queues instead of
218
+ * colliding.
219
+ *
220
+ * **Skips when busy** (base-contract collision rule — drivers MUST queue or skip): a
221
+ * `response.create` sent while a response is in flight would be rejected/garbled by the
222
+ * provider, and narration is disposable by contract, so the update is dropped with a debug
223
+ * log rather than queued to come out late and stale. Hosts SHOULD still gate on
224
+ * {@link IsBusy} / {@link IsAudioPlaying} for timing quality.
225
+ */
226
+ RequestSpokenUpdate(instructions) {
227
+ if (!this.canSendEvents()) {
228
+ return;
229
+ }
230
+ if (this.responseActive) {
231
+ console.debug(`[${this.providerDebugLabel}] RequestSpokenUpdate skipped — a response is already in flight (narration is disposable).`);
232
+ return;
233
+ }
234
+ this.responseActive = true;
235
+ this.pendingNarrationKind = true;
236
+ this.pendingLocalResponseCreates++;
237
+ this.sendEvent({ type: 'response.create', response: { instructions } });
238
+ }
239
+ /**
240
+ * Sends the tool result back as a `function_call_output` conversation item, then triggers
241
+ * a reply — immediately if the model is idle, otherwise queued until the current response
242
+ * (e.g. a progress narration) finishes. Without the queueing the result's `response.create`
243
+ * would collide with an in-flight narration and be dropped, leaving the model silent when
244
+ * delegated work comes back.
245
+ */
246
+ SendToolResult(callID, outputJson) {
247
+ if (!this.canSendEvents()) {
248
+ return;
249
+ }
250
+ this.sendEvent({
251
+ type: 'conversation.item.create',
252
+ item: { type: 'function_call_output', call_id: callID, output: outputJson },
253
+ });
254
+ this.requestResultResponse();
255
+ }
256
+ /**
257
+ * Mutes / unmutes by toggling the mic tracks' `enabled` flag: the transport stays up and
258
+ * streams SILENCE while muted (the provider's VAD sees a continuous stream and the un-mute
259
+ * is glitch-free — the same policy across the client driver family).
260
+ */
261
+ SetMuted(muted) {
262
+ for (const track of this.micStream?.getAudioTracks() ?? []) {
263
+ track.enabled = !muted;
264
+ }
265
+ }
266
+ /** @inheritdoc */
267
+ get IsBusy() {
268
+ return this.responseActive;
269
+ }
270
+ // ── Inbound event translation (the shared dispatcher) ──────────────────────
271
+ /**
272
+ * Parses one raw inbound payload and dispatches it. Non-JSON frames and non-object JSON
273
+ * values are ignored (with the {@link onNonJsonFrame} diagnostic hook for the former).
274
+ */
275
+ handleProtocolMessage(raw) {
276
+ let event;
277
+ try {
278
+ event = JSON.parse(raw);
279
+ }
280
+ catch {
281
+ this.onNonJsonFrame(raw);
282
+ return;
283
+ }
284
+ if (event === null || typeof event !== 'object') {
285
+ return; // valid JSON but not an event object (e.g. "null", a number) — ignore
286
+ }
287
+ this.logInboundEvent(event);
288
+ this.handleEvent(event);
289
+ }
290
+ /** Dispatches a typed OpenAI-protocol server event to the appropriate behavior. */
291
+ handleEvent(event) {
292
+ switch (event.type) {
293
+ case 'session.created':
294
+ this.onSessionCreatedFrame();
295
+ break;
296
+ case 'response.output_audio_transcript.delta':
297
+ case 'response.audio_transcript.delta':
298
+ this.onAssistantDelta(event.delta);
299
+ break;
300
+ case 'response.output_audio_transcript.done':
301
+ case 'response.audio_transcript.done':
302
+ this.onAssistantDone(event.transcript);
303
+ break;
304
+ case 'response.audio.delta':
305
+ case 'response.output_audio.delta':
306
+ this.onAudioDelta(event.delta);
307
+ break;
308
+ case 'conversation.item.input_audio_transcription.completed':
309
+ this.onUserTranscriptFrame(event.transcript);
310
+ break;
311
+ case 'response.function_call_arguments.done':
312
+ this.onToolCallFrame(event);
313
+ break;
314
+ case 'input_audio_buffer.speech_started':
315
+ this.onSpeechStartedFrame();
316
+ break;
317
+ case 'response.created': {
318
+ this.responseActive = true;
319
+ this.confirmedResponseActive = true; // a real response is now in flight
320
+ this.onResponseStarted();
321
+ // Only a LOCALLY-initiated create (counter-tracked) can be our narration. A VAD /
322
+ // unsolicited response.created arrives with the counter already at 0 — it must NOT
323
+ // consume the pending narration kind, or it would mistag a genuine user turn as
324
+ // ephemeral narration (and lose the flag meant for our own create).
325
+ const wasLocalCreate = this.pendingLocalResponseCreates > 0;
326
+ if (wasLocalCreate) {
327
+ this.pendingLocalResponseCreates--;
328
+ }
329
+ // Stamp the kind of THIS response: 'narration' only when the flag was set by
330
+ // RequestSpokenUpdate immediately before ITS OWN local response.create (consumed here).
331
+ this.activeResponseKind = (wasLocalCreate && this.pendingNarrationKind) ? 'narration' : 'normal';
332
+ if (wasLocalCreate) {
333
+ this.pendingNarrationKind = false;
334
+ }
335
+ break;
336
+ }
337
+ case 'output_audio_buffer.started':
338
+ this.onOutputAudioBufferStarted();
339
+ break;
340
+ case 'output_audio_buffer.stopped':
341
+ case 'output_audio_buffer.cleared':
342
+ this.onOutputAudioBufferStopped();
343
+ break;
344
+ case 'response.done':
345
+ // The confirmed response that was in flight has ended (whether this is its real done
346
+ // or the trailing done of a since-cancelled one) — no response.created is outstanding.
347
+ this.confirmedResponseActive = false;
348
+ // A STALE done (the trailing frame of a response we just cancelled while our own
349
+ // replacement response.create is in flight) must not release the lock, flush the
350
+ // queue, or flip the state out from under the response we just started — the real
351
+ // done for OUR response handles all of that. Usage is still emitted (the cancelled
352
+ // response consumed tokens).
353
+ if (this.pendingLocalResponseCreates > 0) {
354
+ this.emitResponseUsage(event);
355
+ break;
356
+ }
357
+ // A turn finished — release the lock and speak any queued tool result so the
358
+ // model always voices the answer when delegated work comes back. The
359
+ // transcript-done frame for this turn has already arrived (it precedes
360
+ // response.done), so it's safe to reset the response kind here.
361
+ this.responseActive = false;
362
+ this.activeResponseKind = 'normal';
363
+ this.emitResponseUsage(event);
364
+ this.flushPendingResultResponse();
365
+ if (this.currentState === 'speaking') {
366
+ this.setState('listening');
367
+ }
368
+ break;
369
+ case 'error':
370
+ this.onErrorFrame(event);
371
+ break;
372
+ default:
373
+ // Unhandled event types are expected (the provider emits many); no-op.
374
+ break;
375
+ }
376
+ }
377
+ /**
378
+ * Called when the model takes the floor (`response.created`), i.e. the user's turn is definitively
379
+ * over. Drivers that track per-user-turn transcription state override this to reset it. No-op here.
380
+ */
381
+ onResponseStarted() {
382
+ // Only streamed-transcription drivers carry per-user-turn state worth clearing.
383
+ }
384
+ /** WebRTC-only playback-buffer hooks; websocket transports never receive these frames. */
385
+ onOutputAudioBufferStarted() {
386
+ // Only the WebRTC transport tracks provider-managed playback.
387
+ }
388
+ onOutputAudioBufferStopped() {
389
+ // Only the WebRTC transport tracks provider-managed playback.
390
+ }
391
+ /** Reflects `'speaking'` on the first audio delta, then hands the chunk to the transport. */
392
+ onAudioDelta(deltaBase64) {
393
+ if (!deltaBase64) {
394
+ return;
395
+ }
396
+ if (this.currentState !== 'speaking') {
397
+ this.setState('speaking');
398
+ }
399
+ this.handleAudioDeltaFrame(deltaBase64);
400
+ }
401
+ /** Appends an assistant transcript delta, reflects `'speaking'`, and emits the delta. */
402
+ onAssistantDelta(delta) {
403
+ if (!delta) {
404
+ return;
405
+ }
406
+ if (this.currentState !== 'speaking') {
407
+ this.setState('speaking');
408
+ }
409
+ this.pendingAssistantText += delta;
410
+ this.emitTranscript({ Role: 'Assistant', Text: delta, IsFinal: false, Kind: this.activeResponseKind });
411
+ }
412
+ /**
413
+ * Finalizes the assistant turn: emits the final transcript tagged with the ACTIVE response
414
+ * kind (the transcript-done frame arrives BEFORE `response.done`, so
415
+ * {@link activeResponseKind} still reflects this turn), then returns to `'listening'`.
416
+ * Empty turns emit nothing.
417
+ */
418
+ onAssistantDone(transcript) {
419
+ const finalText = transcript || this.pendingAssistantText;
420
+ this.pendingAssistantText = '';
421
+ if (finalText.trim().length > 0) {
422
+ this.emitTranscript({ Role: 'Assistant', Text: finalText, IsFinal: true, Kind: this.activeResponseKind });
423
+ }
424
+ if (this.currentState === 'speaking') {
425
+ this.setState('listening');
426
+ }
427
+ }
428
+ /**
429
+ * Surfaces a completed tool call to the host. The client silently leaves the `'speaking'`
430
+ * state (NO emission) so a host-rendered busy indicator (e.g. "thinking") isn't clobbered
431
+ * by this turn's trailing `response.done` / playback-stopped frames. Websocket transports
432
+ * additionally clear the busy flag ({@link releasesBusyFlagOnToolCall}) as a deadlock guard.
433
+ */
434
+ onToolCallFrame(call) {
435
+ if (this.currentState === 'speaking') {
436
+ this.currentState = 'connected';
437
+ }
438
+ if (this.releasesBusyFlagOnToolCall) {
439
+ this.responseActive = false;
440
+ }
441
+ this.emitToolCall({ CallID: call.call_id, ToolName: call.name, ArgumentsJson: call.arguments });
442
+ }
443
+ /**
444
+ * Emits the completed response's usage to the host as a DELTA (the `response.done` usage
445
+ * payload covers exactly this response, so it is already incremental — the `OnUsage`
446
+ * contract's preferred shape). Frames without a usage payload emit nothing.
447
+ */
448
+ emitResponseUsage(event) {
449
+ const usage = event.response?.usage;
450
+ if (!usage) {
451
+ return;
452
+ }
453
+ this.emitUsage({
454
+ InputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : undefined,
455
+ OutputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : undefined,
456
+ Raw: usage,
457
+ });
458
+ }
459
+ /**
460
+ * Surfaces a provider error frame (non-fatal; the session continues).
461
+ *
462
+ * SELF-HEAL for the stale-response counter (see {@link pendingLocalResponseCreates}): a
463
+ * `response.create` the provider REJECTS (overlap, invalid params) produces an `error` frame
464
+ * and **no** `response.created`, so the counter would otherwise never decrement and every
465
+ * future `response.done` would take the stale branch — wedging the client (`IsBusy` stuck true,
466
+ * narration + tool results silently dropped). Decrementing here (floored at 0) reverts to the
467
+ * pre-counter self-healing behavior for that turn rather than leaving the session dead. An
468
+ * error unrelated to a local create at worst under-protects one `done` (the old default), never
469
+ * wedges.
470
+ */
471
+ onErrorFrame(event) {
472
+ if (this.pendingLocalResponseCreates > 0) {
473
+ this.pendingLocalResponseCreates--;
474
+ // The counter dropping here means this error plausibly REJECTED one of our local
475
+ // response.creates (rejected creates emit an error and NO response.created). The rejected
476
+ // create's narration kind belongs to THAT create — it will never arrive, so drop the flag
477
+ // UNCONDITIONALLY, or it would arm the NEXT local create (e.g. a delegated tool-result
478
+ // reply) and mistag that durable turn as ephemeral narration. This must NOT be gated on
479
+ // confirmedResponseActive: a narration create can be rejected while a since-cancelled
480
+ // response is still draining (confirmedResponseActive still true), and the flag must die
481
+ // regardless.
482
+ this.pendingNarrationKind = false;
483
+ // The EAGER responseActive / 'speaking' state, by contrast, is only a phantom to clear
484
+ // when NO provider-CONFIRMED response is in flight; a genuinely-active response (concurrent
485
+ // VAD turn, or a cancelled one still draining) owns that state and its response.done clears
486
+ // it. Clearing it here would cut a live turn.
487
+ if (!this.confirmedResponseActive) {
488
+ this.responseActive = false;
489
+ if (this.currentState === 'speaking') {
490
+ this.setState('listening');
491
+ }
492
+ }
493
+ }
494
+ this.emitError({
495
+ Message: event.error?.message ?? 'Unknown provider error',
496
+ Code: event.error?.code,
497
+ Fatal: false,
498
+ });
499
+ }
500
+ // ── Response state machine helpers ─────────────────────────────────────────
501
+ /**
502
+ * Asks the model to speak (a tool result or typed-text reply) — immediately if it's idle,
503
+ * otherwise queued until the current response finishes. An immediate trigger also
504
+ * CONSUMES any queued trigger debt: every payload item is already in the conversation, so
505
+ * one `response.create` voices everything (e.g. typed text barging in over a narration
506
+ * that had tool results queued behind it).
507
+ */
508
+ requestResultResponse() {
509
+ if (!this.canSendEvents()) {
510
+ return;
511
+ }
512
+ if (this.responseActive) {
513
+ this.pendingResultResponse = true;
514
+ return;
515
+ }
516
+ this.pendingResultResponse = false;
517
+ this.responseActive = true;
518
+ this.pendingLocalResponseCreates++;
519
+ this.sendEvent({ type: 'response.create' });
520
+ this.setState('speaking');
521
+ }
522
+ /** On a turn completing, fire any queued tool-result response so the answer is spoken. */
523
+ flushPendingResultResponse() {
524
+ if (!this.pendingResultResponse || !this.canSendEvents()) {
525
+ return;
526
+ }
527
+ this.pendingResultResponse = false;
528
+ this.responseActive = true;
529
+ this.pendingLocalResponseCreates++;
530
+ this.sendEvent({ type: 'response.create' });
531
+ this.setState('speaking');
532
+ }
533
+ /** Resets the per-session response state machine (used on Disconnect). */
534
+ resetResponseState() {
535
+ this.pendingAssistantText = '';
536
+ this.responseActive = false;
537
+ this.confirmedResponseActive = false;
538
+ this.pendingResultResponse = false;
539
+ this.pendingLocalResponseCreates = 0;
540
+ this.pendingNarrationKind = false;
541
+ this.activeResponseKind = 'normal';
542
+ }
543
+ // ── Shared helpers ─────────────────────────────────────────────────────────
544
+ /** Updates the client's own state view and emits the change to the host. */
545
+ setState(state) {
546
+ this.currentState = state;
547
+ this.emitStateChange(state);
548
+ }
549
+ /** Logs (diagnostic hook) and delivers one client event when the transport is open. */
550
+ sendEvent(event) {
551
+ if (!this.canSendEvents()) {
552
+ return;
553
+ }
554
+ this.logOutboundEvent(event);
555
+ this.sendProtocolEvent(event);
556
+ }
557
+ }
558
+ /**
559
+ * The shared **websocket + client-owned-PCM transport** for OpenAI-protocol client drivers
560
+ * (xAI Grok Voice, self-hosted HuggingFace). Extends the protocol brain with everything the
561
+ * websocket drivers previously each owned:
562
+ *
563
+ * - The socket lifecycle: wire-up, open/created gating (profile-driven via
564
+ * {@link waitsForSessionCreated}), fatal error / unexpected-close handling.
565
+ * - The PCM audio plane: shared mic-capture worklet streaming `input_audio_buffer.append`
566
+ * frames up, shared playout engine scheduling base64 deltas down, audio meters both ways.
567
+ * - The transport implementations of the brain's seams: {@link sendProtocolEvent} /
568
+ * {@link canSendEvents} over the socket, {@link stopAudioOutput} → local playout flush,
569
+ * {@link handleAudioDeltaFrame} → playout enqueue, `IsAudioPlaying` → the playout clock.
570
+ *
571
+ * Concrete drivers supply only: the socket target ({@link openProviderSocket}), the session
572
+ * object + sample rate resolution from the server pact ({@link resolveSessionObject} /
573
+ * {@link resolveSampleRate}), and any provider behavior overrides (streamed user transcripts,
574
+ * close semantics).
575
+ */
576
+ export class OpenAIProtocolWebSocketRealtimeClient extends OpenAIProtocolRealtimeClient {
577
+ constructor() {
578
+ super(...arguments);
579
+ /** The live socket (null when disconnected). Protected so test subclasses can inspect. */
580
+ this.socket = null;
581
+ /** The local playout engine (client-owned audio plane). */
582
+ this.playback = null;
583
+ /** The mic-capture pipeline streaming PCM16 up to the provider. */
584
+ this.micCapture = null;
585
+ /**
586
+ * The wire-shaped session object applied via `session.update` at the readiness boundary.
587
+ * Protected so test subclasses can seed it without a full Connect.
588
+ */
589
+ this.sessionObject = {};
590
+ /** True once Disconnect ran — an expected socket close must not surface as fatal. */
591
+ this.closedByConsumer = false;
592
+ /** True while the socket is OPEN — gates every send so a CONNECTING socket never throws. */
593
+ this.socketOpen = false;
594
+ /** Resolver for the in-flight Connect's `session.created` wait (deferring providers only). */
595
+ this.sessionCreatedResolver = null;
596
+ /** Rejects the in-flight Connect (open OR created phase); null outside Connect. */
597
+ this.failPendingConnect = null;
598
+ }
599
+ /** Extracts the wire-shaped `session` object from the server pact. Default: the pact itself. */
600
+ resolveSessionObject(config) {
601
+ return config.SessionConfig ?? {};
602
+ }
603
+ /**
604
+ * Whether the readiness boundary waits for the endpoint's `session.created` frame before
605
+ * applying the session config (HuggingFace) or applies it on socket open (xAI — the
606
+ * protocol has no separate readiness ack there).
607
+ */
608
+ get waitsForSessionCreated() {
609
+ return false;
610
+ }
611
+ /**
612
+ * Connect-phase deadline in milliseconds, covering socket-open AND (when the provider
613
+ * defers) the `session.created` wait. On expiry `Connect` rejects with a fatal error
614
+ * instead of hanging forever against a silent endpoint. Override per driver/deployment.
615
+ */
616
+ get connectTimeoutMs() {
617
+ return 15_000;
618
+ }
619
+ // ── Connection lifecycle ────────────────────────────────────────────────────
620
+ /**
621
+ * Opens the websocket, gates on open (and `session.created` when the provider defers),
622
+ * applies the server-authored session config as the FIRST frame (prompt + tool authority
623
+ * stay server-side), builds the PCM audio plane at the provider's rate, and reports
624
+ * `'listening'` only after all of that (obligation #7).
625
+ */
626
+ async Connect(config, micStream) {
627
+ this.sessionObject = this.resolveSessionObject(config);
628
+ this.micStream = micStream;
629
+ this.closedByConsumer = false;
630
+ this.setState('connecting');
631
+ let openSocket = null;
632
+ const opened = new Promise((resolve) => {
633
+ openSocket = resolve;
634
+ });
635
+ const created = this.waitsForSessionCreated
636
+ ? new Promise((resolve) => {
637
+ this.sessionCreatedResolver = resolve;
638
+ })
639
+ : null;
640
+ // ONE failure channel covers BOTH connect phases (open + created): socket error/close,
641
+ // a consumer Disconnect, and the connect deadline all reject the in-flight Connect —
642
+ // no phase can hang an awaited Connect() forever against a silent endpoint.
643
+ const failure = new Promise((_, reject) => {
644
+ this.failPendingConnect = reject;
645
+ });
646
+ failure.catch(() => undefined); // consumed via Promise.race; guard stray-rejection noise
647
+ const deadline = setTimeout(() => this.failPendingConnect?.(new Error(`${this.providerDebugLabel} connect timed out after ${this.connectTimeoutMs}ms`)), this.connectTimeoutMs);
648
+ // Node parity with the server readiness timer — never hold the event loop for the deadline.
649
+ deadline.unref?.();
650
+ // S2: build the socket + wire handlers INSIDE the try so a synchronous throw from
651
+ // openProviderSocket (no global WebSocket, malformed URL) still clears the deadline timer
652
+ // and nulls failPendingConnect via the finally.
653
+ try {
654
+ this.socketOpen = false; // S3: never inherit a prior connect's open state on reuse
655
+ const socket = this.openProviderSocket(config);
656
+ this.socket = socket;
657
+ // S3: identity-guard every handler — a PREVIOUS socket's late onerror/onclose (they
658
+ // fire asynchronously after a Disconnect+reconnect on a reused instance) must not
659
+ // corrupt the NEW socket's state or drive the fresh session to error/closed.
660
+ socket.onopen = () => {
661
+ if (this.socket !== socket) {
662
+ return;
663
+ }
664
+ this.socketOpen = true;
665
+ openSocket?.();
666
+ };
667
+ socket.onmessage = (data) => {
668
+ if (this.socket !== socket) {
669
+ return;
670
+ }
671
+ this.handleProtocolMessage(data);
672
+ };
673
+ socket.onerror = (message) => {
674
+ if (this.socket !== socket) {
675
+ return;
676
+ }
677
+ this.socketOpen = false;
678
+ this.failPendingConnect?.(new Error(message));
679
+ this.handleSocketError(message);
680
+ };
681
+ socket.onclose = () => {
682
+ if (this.socket !== socket) {
683
+ return;
684
+ }
685
+ this.socketOpen = false;
686
+ this.failPendingConnect?.(new Error(`${this.providerDebugLabel} socket closed during connect`));
687
+ this.handleSocketClose();
688
+ };
689
+ await Promise.race([opened, failure]);
690
+ this.setState('connected');
691
+ if (created) {
692
+ await Promise.race([created, failure]);
693
+ }
694
+ }
695
+ catch (error) {
696
+ // Deadline expiry reaches the host ONLY through this rejection — surface it as a
697
+ // fatal error + error state (socket error/close paths already emitted their own).
698
+ const message = error instanceof Error ? error.message : String(error);
699
+ if (message.includes('timed out')) {
700
+ this.emitError({ Message: message, Fatal: true });
701
+ this.setState('error');
702
+ try {
703
+ this.socket?.close();
704
+ }
705
+ catch { /* already closing */ }
706
+ }
707
+ throw error;
708
+ }
709
+ finally {
710
+ clearTimeout(deadline);
711
+ this.failPendingConnect = null;
712
+ this.sessionCreatedResolver = null;
713
+ }
714
+ // The server-authored session config (the SessionConfig pact) is applied as the FIRST
715
+ // frame — prompt and tool authority stay server-side (obligation #8).
716
+ this.applySessionConfig();
717
+ const sampleRate = this.resolveSampleRate(config);
718
+ this.playback = this.createPlayback(sampleRate);
719
+ this.micCapture = await this.createMicCapture(micStream, sampleRate, (base64Pcm16) => this.sendMicChunk(base64Pcm16));
720
+ // Audio-activity capability (base obligation #9): agent side taps the playout engine's
721
+ // master gain; user side meters the mic stream. Null-safe — test fakes / no-WebAudio
722
+ // environments simply leave the session un-metered.
723
+ this.attachOutputAudioMeter(this.playback?.CreateMeter?.() ?? null);
724
+ this.attachInputAudioMeter(RealtimeAudioMeter.ForMicStream(micStream));
725
+ this.setState('listening');
726
+ }
727
+ /**
728
+ * Tears down the socket, mic capture, mic tracks, and playout engine, resets the response
729
+ * state machine, and emits a final `'closed'` (unless already `'error'`). Safe to call
730
+ * more than once.
731
+ */
732
+ async Disconnect() {
733
+ this.closedByConsumer = true;
734
+ // Release an in-flight Connect (open or created phase) so the awaited promise cannot
735
+ // outlive the session the consumer just tore down.
736
+ this.failPendingConnect?.(new Error(`${this.providerDebugLabel} disconnected during connect`));
737
+ this.failPendingConnect = null;
738
+ this.sessionCreatedResolver = null;
739
+ this.socketOpen = false;
740
+ this.closeAudioMeters();
741
+ this.micStream?.getTracks().forEach((track) => track.stop());
742
+ this.micStream = null;
743
+ this.micCapture?.Stop();
744
+ this.micCapture = null;
745
+ this.playback?.Close();
746
+ this.playback = null;
747
+ if (this.socket) {
748
+ try {
749
+ this.socket.close();
750
+ }
751
+ catch {
752
+ /* already closing */
753
+ }
754
+ this.socket = null;
755
+ }
756
+ this.sessionObject = {};
757
+ this.resetResponseState();
758
+ if (this.currentState !== 'error') {
759
+ this.setState('closed');
760
+ }
761
+ }
762
+ // ── Brain seam implementations ─────────────────────────────────────────────
763
+ /** @inheritdoc — requires an OPEN socket (a CONNECTING raw WebSocket throws on send). */
764
+ canSendEvents() {
765
+ return this.socket !== null && this.socketOpen;
766
+ }
767
+ /** @inheritdoc */
768
+ sendProtocolEvent(event) {
769
+ this.socket?.send(JSON.stringify(event));
770
+ }
771
+ /** @inheritdoc — the client OWNS the audio plane, so a cancel flushes the local queue. */
772
+ stopAudioOutput() {
773
+ this.playback?.Flush();
774
+ }
775
+ /** @inheritdoc — enqueues one base64 PCM16 chunk into the local playout engine. */
776
+ handleAudioDeltaFrame(deltaBase64) {
777
+ this.playback?.Enqueue(base64ToArrayBuffer(deltaBase64));
778
+ }
779
+ /**
780
+ * @inheritdoc
781
+ *
782
+ * Computed directly from the playout engine's playhead clock — this client OWNS the output
783
+ * buffer, so "audibly playing" is precisely "scheduled audio extends beyond the audio
784
+ * context's current time".
785
+ */
786
+ get IsAudioPlaying() {
787
+ return this.playback?.IsPlaying ?? false;
788
+ }
789
+ /**
790
+ * @inheritdoc — websocket transports flush their local playout on TRUE barge-in
791
+ * (obligation #3); the provider cancels its own turn and emits a terminal `response.done`.
792
+ */
793
+ onSpeechStartedFrame() {
794
+ if (this.responseActive || this.IsAudioPlaying) {
795
+ this.playback?.Flush();
796
+ // Floor to the user — drop the queued auto-trigger (see the brain's docstring).
797
+ this.pendingResultResponse = false;
798
+ this.emitInterruption();
799
+ }
800
+ this.setState('listening');
801
+ }
802
+ /** @inheritdoc — deadlock guard: compat endpoints may skip `response.done` after a tool call. */
803
+ get releasesBusyFlagOnToolCall() {
804
+ return true;
805
+ }
806
+ /** @inheritdoc — releases a deferring Connect's `session.created` gate. */
807
+ onSessionCreatedFrame() {
808
+ this.sessionCreatedResolver?.();
809
+ this.sessionCreatedResolver = null;
810
+ }
811
+ // ── Transport internals ─────────────────────────────────────────────────────
812
+ /**
813
+ * Sends the server-controlled session config (instructions + tools) as a `session.update`
814
+ * so the co-agent's identity and tool set apply. Skipped when the pact carried no config
815
+ * (e.g. the host failed to parse the server payload — it already logged that; sending an
816
+ * EMPTY `session.update` would be wrong).
817
+ */
818
+ applySessionConfig() {
819
+ if (Object.keys(this.sessionObject).length === 0) {
820
+ return;
821
+ }
822
+ this.sendEvent({ type: 'session.update', session: this.sessionObject });
823
+ }
824
+ /** Streams one base64 PCM16 mic chunk as an `input_audio_buffer.append` frame. */
825
+ sendMicChunk(base64Pcm16) {
826
+ if (this.socket) {
827
+ this.sendEvent({ type: 'input_audio_buffer.append', audio: base64Pcm16 });
828
+ }
829
+ }
830
+ /** Formats a socket-level error message for the host. Providers brand this (see xAI). */
831
+ formatTransportError(message) {
832
+ return message;
833
+ }
834
+ /** The fatal-error message surfaced when the socket closes unexpectedly. Providers brand this. */
835
+ get unexpectedCloseMessage() {
836
+ return `${this.providerDebugLabel} connection closed unexpectedly`;
837
+ }
838
+ /** Surfaces a fatal socket error and marks the session unusable (obligation #6). */
839
+ handleSocketError(message) {
840
+ if (this.currentState === 'error' || this.currentState === 'closed') {
841
+ return;
842
+ }
843
+ this.emitError({ Message: this.formatTransportError(message), Fatal: true });
844
+ this.setState('error');
845
+ }
846
+ /**
847
+ * A socket close the CONSUMER didn't ask for. Default: FATAL — providers hard-close at
848
+ * token expiry and when they end the session themselves, so an unexpected close is how
849
+ * credential / session death reaches the host (obligation #6). Providers with benign
850
+ * close semantics (a self-hosted proxy hop) override.
851
+ */
852
+ handleSocketClose() {
853
+ if (this.closedByConsumer || this.currentState === 'error' || this.currentState === 'closed') {
854
+ return;
855
+ }
856
+ this.emitError({ Message: this.unexpectedCloseMessage, Fatal: true });
857
+ this.setState('error');
858
+ }
859
+ // ── Overridable creation seams (tests inject fakes — no audio stack) ───────
860
+ /**
861
+ * Creation seam for the mic-capture pipeline at the provider's rate. Production delegates
862
+ * to the shared {@link createPcmMicCapture}; unit tests override this with a no-op fake
863
+ * (and may capture `onPcmChunk` to simulate mic frames).
864
+ */
865
+ async createMicCapture(micStream, sampleRate, onPcmChunk) {
866
+ return createPcmMicCapture(micStream, sampleRate, onPcmChunk);
867
+ }
868
+ /** Creation seam for the playout engine. Production returns the shared {@link RealtimePcmPlayback}. */
869
+ createPlayback(sampleRate) {
870
+ return new RealtimePcmPlayback(sampleRate);
871
+ }
872
+ }
873
+ //# sourceMappingURL=openAIProtocolClient.js.map