@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.
- package/README.md +11 -0
- package/dist/drivers/huggingFaceRealtimeClient.d.ts +27 -148
- package/dist/drivers/huggingFaceRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/huggingFaceRealtimeClient.js +41 -388
- package/dist/drivers/huggingFaceRealtimeClient.js.map +1 -1
- package/dist/drivers/openAIRealtimeClient.d.ts +28 -270
- package/dist/drivers/openAIRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/openAIRealtimeClient.js +42 -379
- package/dist/drivers/openAIRealtimeClient.js.map +1 -1
- package/dist/drivers/xaiRealtimeClient.d.ts +57 -370
- package/dist/drivers/xaiRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/xaiRealtimeClient.js +85 -554
- package/dist/drivers/xaiRealtimeClient.js.map +1 -1
- package/dist/generic/openAIProtocolClient.d.ts +527 -0
- package/dist/generic/openAIProtocolClient.d.ts.map +1 -0
- package/dist/generic/openAIProtocolClient.js +873 -0
- package/dist/generic/openAIProtocolClient.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -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
|