@pipedog/openai 0.0.0 → 0.3.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/dist/stt.js ADDED
@@ -0,0 +1,741 @@
1
+ // Copyright (c) 2024-2026, Daily
2
+ // Copyright (c) 2026, pipedog contributors
3
+ // SPDX-License-Identifier: BSD-2-Clause
4
+ //
5
+ // Ported from pipecat: src/pipecat/services/openai/stt.py
6
+ /**
7
+ * OpenAI Speech-to-Text service implementations.
8
+ *
9
+ * Provides two STT services:
10
+ *
11
+ * - `OpenAISTTService`: REST-based transcription using the Audio API
12
+ * (Whisper / GPT transcription models).
13
+ * - `OpenAIRealtimeSTTService`: WebSocket-based streaming transcription
14
+ * using the Realtime API in transcription-only mode.
15
+ *
16
+ * Porting notes:
17
+ * - `OpenAISTTService` uses the `openai` JS SDK client built by
18
+ * `BaseWhisperSTTService` (`@pipedog/whisper`).
19
+ * - `OpenAIRealtimeSTTService` uses the global `WebSocket` through
20
+ * `WebsocketService._websocketConnect()`. The `Authorization` header is sent
21
+ * on the opening handshake, which the Bun, Node and Deno `WebSocket`
22
+ * support; on Cloudflare Workers override `_connectWebsocket()` or the
23
+ * connect options to open the socket through `fetch`.
24
+ * - Deprecated APIs are not ported: the `model`, `language`, `prompt`,
25
+ * `temperature` (`OpenAISTTService`) and `model`, `language`, `prompt`,
26
+ * `noiseReduction` (`OpenAIRealtimeSTTService`) constructor options.
27
+ */
28
+ import { rethrowIfCancelled } from "@pipedog/asyncio";
29
+ import { createStreamResampler } from "@pipedog/core/audio/utils";
30
+ import { InterimTranscriptionFrame, ProposedUserStartedSpeakingFrame, ProposedUserStoppedSpeakingFrame, TranscriptionFrame, VADUserStoppedSpeakingFrame, } from "@pipedog/core/frames/frames";
31
+ import { STTSettings } from "@pipedog/core/services/settings";
32
+ import { OPENAI_REALTIME_TTFS_P99, OPENAI_TTFS_P99 } from "@pipedog/core/services/stt_latency";
33
+ import { WebsocketSTTService, } from "@pipedog/core/services/stt_service";
34
+ import { State } from "@pipedog/core/services/websocket_service";
35
+ import { Language } from "@pipedog/core/transcriptions/language";
36
+ import { ExternalUserTurnStrategies } from "@pipedog/core/turns/user_turn_strategies";
37
+ import { bytesToBase64 } from "@pipedog/core/utils/base64";
38
+ import { logger } from "@pipedog/core/utils/logger";
39
+ import { pyJsonDumps, pyJsonLoads } from "@pipedog/core/utils/py_json";
40
+ import { timeNowIso8601 } from "@pipedog/core/utils/time";
41
+ import { installTracedStt } from "@pipedog/core/utils/tracing/service_decorators";
42
+ import { assertGiven, NOT_GIVEN } from "@pipedog/core/utils/types";
43
+ import { BaseWhisperSTTService, BaseWhisperSTTSettings, } from "@pipedog/whisper/base_stt";
44
+ import { toFile } from "openai";
45
+ import { OPENAI_REALTIME_WHISPER_MODEL, OPENAI_SAMPLE_RATE } from "./_constants.js";
46
+ function errorMessage(e) {
47
+ return e instanceof Error ? e.message : String(e);
48
+ }
49
+ /**
50
+ * Settings for the OpenAI STT service.
51
+ *
52
+ * - `keywords`: words or phrases that guide transcription of the input
53
+ * audio, such as product names or acronyms. Supported by
54
+ * "gpt-transcribe".
55
+ */
56
+ export class OpenAISTTSettings extends BaseWhisperSTTSettings {
57
+ keywords = NOT_GIVEN;
58
+ constructor(options = {}) {
59
+ super(options);
60
+ this._assignOptions(options);
61
+ }
62
+ }
63
+ /**
64
+ * OpenAI Speech-to-Text service that generates text from audio.
65
+ *
66
+ * Uses OpenAI's transcription API to convert audio to text. Requires an
67
+ * OpenAI API key set via the apiKey option or OPENAI_API_KEY environment
68
+ * variable.
69
+ *
70
+ * With `includeProbMetrics: true`, GPT transcription models report per-token
71
+ * logprobs and Whisper models report per-segment logprobs. Diarization models
72
+ * support neither, so they report no probabilities.
73
+ */
74
+ export class OpenAISTTService extends BaseWhisperSTTService {
75
+ static Settings = OpenAISTTSettings;
76
+ /**
77
+ * Model already reported as unable to supply probabilities, so that warning
78
+ * is logged once per model rather than once per utterance.
79
+ */
80
+ _probMetricsWarnedModel = null;
81
+ /**
82
+ * Initialize OpenAI STT service.
83
+ *
84
+ * @param options.apiKey OpenAI API key. Defaults to null.
85
+ * @param options.baseUrl API base URL. Defaults to null.
86
+ * @param options.settings Runtime-updatable settings. Its given fields
87
+ * override the defaults (model "gpt-transcribe", language English).
88
+ * @param options.ttfsP99Latency P99 latency from speech end to final
89
+ * transcript in seconds. Override for your deployment. See
90
+ * https://github.com/pipecat-ai/stt-benchmark
91
+ * Other options are passed to BaseWhisperSTTService.
92
+ */
93
+ constructor(options = {}) {
94
+ // --- 1. Hardcoded defaults ---
95
+ const defaultSettings = new new.target.Settings({
96
+ model: "gpt-transcribe",
97
+ language: Language.EN,
98
+ prompt: null,
99
+ temperature: null,
100
+ keywords: null,
101
+ });
102
+ // --- 2. Settings delta (canonical API, always wins) ---
103
+ if (options.settings !== undefined && options.settings !== null) {
104
+ defaultSettings.applyUpdate(options.settings);
105
+ }
106
+ super({
107
+ ...options,
108
+ settings: defaultSettings,
109
+ ttfsP99Latency: options.ttfsP99Latency === undefined ? OPENAI_TTFS_P99 : options.ttfsP99Latency,
110
+ });
111
+ }
112
+ async _transcribe(audio) {
113
+ if (this._settings.language === null) {
114
+ throw new Error("AssertionError: language must be set");
115
+ }
116
+ const model = assertGiven(this._settings.model);
117
+ if (model === null) {
118
+ throw new Error("AssertionError: model must be set");
119
+ }
120
+ // Build kwargs dict with only set parameters
121
+ const kwargs = {
122
+ file: await toFile(audio, "audio.wav", { type: "audio/wav" }),
123
+ model,
124
+ language: this._settings.language,
125
+ };
126
+ if (this._includeProbMetrics) {
127
+ // GPT transcription models return logprobs alongside a "json" response;
128
+ // Whisper models carry per-segment logprobs in "verbose_json" instead.
129
+ // Diarization models support neither and reject the logprobs request.
130
+ if (model.startsWith("whisper")) {
131
+ kwargs.response_format = "verbose_json";
132
+ }
133
+ else if (model.includes("diarize")) {
134
+ if (this._probMetricsWarnedModel !== model) {
135
+ this._probMetricsWarnedModel = model;
136
+ logger.warning(`${this}: ${model} does not support probability metrics; ` +
137
+ "transcription results will carry no probability.");
138
+ }
139
+ }
140
+ else {
141
+ kwargs.response_format = "json";
142
+ kwargs.include = ["logprobs"];
143
+ }
144
+ }
145
+ if (this._settings.prompt !== null) {
146
+ kwargs.prompt = this._settings.prompt;
147
+ }
148
+ if (this._settings.temperature !== null) {
149
+ kwargs.temperature = this._settings.temperature;
150
+ }
151
+ const keywords = this._settings.keywords;
152
+ if (Array.isArray(keywords) && keywords.length > 0) {
153
+ kwargs.keywords = keywords;
154
+ }
155
+ return (await this._client.audio.transcriptions.create(kwargs));
156
+ }
157
+ }
158
+ /**
159
+ * Settings for OpenAIRealtimeSTTService.
160
+ *
161
+ * - `prompt`: optional prompt text to guide transcription style. Not
162
+ * supported by "gpt-realtime-whisper".
163
+ * - `noiseReduction`: noise reduction mode. "near_field" for close
164
+ * microphones, "far_field" for distant microphones, or null to disable.
165
+ * - `delay`: how long the model waits before emitting transcription text,
166
+ * trading latency for accuracy. Only supported by "gpt-realtime-whisper".
167
+ */
168
+ export class OpenAIRealtimeSTTSettings extends STTSettings {
169
+ prompt = NOT_GIVEN;
170
+ noiseReduction = NOT_GIVEN;
171
+ delay = NOT_GIVEN;
172
+ constructor(options = {}) {
173
+ super(options);
174
+ this._assignOptions(options);
175
+ }
176
+ }
177
+ /**
178
+ * OpenAI Realtime Speech-to-Text service using WebSocket transcription sessions.
179
+ *
180
+ * Uses OpenAI's Realtime API in transcription-only mode for real-time
181
+ * streaming speech recognition with optional server-side VAD and noise
182
+ * reduction. The model does not generate conversational responses — only
183
+ * transcription output.
184
+ *
185
+ * This service supports two VAD modes:
186
+ *
187
+ * **Local VAD** (default): Disable server-side VAD and use a local VAD
188
+ * processor in the pipeline instead. When a `VADUserStoppedSpeakingFrame` is
189
+ * received, the service commits the audio buffer so that the server begins
190
+ * transcription for the completed speech segment.
191
+ *
192
+ * **Server-side VAD** (`turnDetection: { type: "server_vad" }`): The OpenAI
193
+ * server performs voice-activity detection. The service proposes turn
194
+ * boundaries when the server detects them, and recommends the external user
195
+ * turn strategies that resolve those proposals into turn frames. Do **not**
196
+ * use a separate VAD processor in the pipeline in this mode. Requires a model
197
+ * that supports turn detection — `gpt-transcribe` does, the default
198
+ * `gpt-realtime-whisper` does not.
199
+ *
200
+ * Audio is sent as 24 kHz 16-bit mono PCM as required by the OpenAI Realtime
201
+ * API. If the pipeline runs at a different sample rate (e.g. 16 kHz for
202
+ * Silero VAD compatibility), audio is automatically upsampled before sending.
203
+ *
204
+ * @example
205
+ * const stt = new OpenAIRealtimeSTTService({
206
+ * apiKey: "sk-...",
207
+ * settings: new OpenAIRealtimeSTTService.Settings({
208
+ * model: "gpt-realtime-whisper",
209
+ * noiseReduction: "near_field",
210
+ * }),
211
+ * });
212
+ */
213
+ export class OpenAIRealtimeSTTService extends WebsocketSTTService {
214
+ static Settings = OpenAIRealtimeSTTSettings;
215
+ static {
216
+ installTracedStt(OpenAIRealtimeSTTService.prototype, "_handleTranscriptionTrace");
217
+ }
218
+ _apiKey;
219
+ _baseUrl;
220
+ _turnDetection;
221
+ _shouldInterrupt;
222
+ _receiveTask = null;
223
+ _sessionReady = false;
224
+ _resampler;
225
+ _serverVadEnabled;
226
+ /**
227
+ * Initialize the OpenAI Realtime STT service.
228
+ *
229
+ * @param options.apiKey OpenAI API key for authentication.
230
+ * @param options.baseUrl WebSocket base URL for the Realtime API. Defaults
231
+ * to "wss://api.openai.com/v1/realtime".
232
+ * @param options.turnDetection Server-side VAD configuration. Defaults to
233
+ * `false` (disabled).
234
+ * @param options.shouldInterrupt Whether to interrupt bot output when speech
235
+ * is detected by server-side VAD. Defaults to true.
236
+ * @param options.settings Runtime-updatable settings. Its given fields
237
+ * override the defaults (model "gpt-realtime-whisper", language English).
238
+ * @param options.ttfsP99Latency P99 latency from speech end to final
239
+ * transcript in seconds. Override for your deployment. See
240
+ * https://github.com/pipecat-ai/stt-benchmark
241
+ * Other options are passed to parent WebsocketSTTService.
242
+ */
243
+ constructor(options) {
244
+ // --- 1. Hardcoded defaults ---
245
+ const defaultSettings = new new.target.Settings({
246
+ model: OPENAI_REALTIME_WHISPER_MODEL,
247
+ language: Language.EN,
248
+ prompt: null,
249
+ noiseReduction: null,
250
+ delay: null,
251
+ });
252
+ // --- 2. Settings delta (canonical API, always wins) ---
253
+ if (options.settings !== undefined && options.settings !== null) {
254
+ defaultSettings.applyUpdate(options.settings);
255
+ }
256
+ OpenAIRealtimeSTTService._omitUnsupportedPrompt(defaultSettings);
257
+ super({
258
+ ...options,
259
+ ttfsP99Latency: options.ttfsP99Latency === undefined ? OPENAI_REALTIME_TTFS_P99 : options.ttfsP99Latency,
260
+ settings: defaultSettings,
261
+ });
262
+ this._apiKey = options.apiKey;
263
+ this._baseUrl = options.baseUrl ?? "wss://api.openai.com/v1/realtime";
264
+ const turnDetection = options.turnDetection === undefined ? false : options.turnDetection;
265
+ this._turnDetection = turnDetection;
266
+ this._shouldInterrupt = options.shouldInterrupt ?? true;
267
+ this._resampler = createStreamResampler();
268
+ // Server-side VAD is disabled by default (turnDetection: false).
269
+ // Set to null or an object to enable server-side VAD.
270
+ this._serverVadEnabled = turnDetection !== false;
271
+ }
272
+ /** Drop prompt settings that are not accepted by the selected model. */
273
+ static _omitUnsupportedPrompt(settings) {
274
+ if (settings.model === OPENAI_REALTIME_WHISPER_MODEL && settings.prompt) {
275
+ const oldPrompt = settings.prompt;
276
+ settings.prompt = null;
277
+ logger.warning(`${OPENAI_REALTIME_WHISPER_MODEL} does not support the prompt parameter; ` +
278
+ "omitting prompt from OpenAI Realtime transcription session.");
279
+ return { prompt: oldPrompt };
280
+ }
281
+ return {};
282
+ }
283
+ /**
284
+ * Convert a Language value to an ISO-639-1 code.
285
+ *
286
+ * @param language The Language value.
287
+ * @returns Two-letter ISO-639-1 language code.
288
+ */
289
+ static _languageToCode(language) {
290
+ // Language value is e.g. "en", "en-US", "fr", "zh".
291
+ return String(language).split("-")[0].toLowerCase();
292
+ }
293
+ /**
294
+ * Check if the service can generate processing metrics.
295
+ *
296
+ * @returns True, as this service supports metrics generation.
297
+ */
298
+ canGenerateMetrics() {
299
+ return true;
300
+ }
301
+ /**
302
+ * Request external turn strategies when server-side VAD drives turns.
303
+ *
304
+ * With server-side VAD enabled the OpenAI server detects speech boundaries
305
+ * and this service proposes turns from them, so the user aggregator
306
+ * resolves those rather than running local VAD/smart-turn. With
307
+ * `turnDetection: false` (the default) the server emits no VAD events and
308
+ * the defaults are left in place. Applied unless the user passed their own
309
+ * `userTurnStrategies`.
310
+ */
311
+ serviceMetadataFrame() {
312
+ const frame = super.serviceMetadataFrame();
313
+ if (this._serverVadEnabled) {
314
+ frame.userTurnStrategies = new ExternalUserTurnStrategies({
315
+ enableInterruptions: this._shouldInterrupt,
316
+ });
317
+ }
318
+ return frame;
319
+ }
320
+ /**
321
+ * Apply a settings delta and send session update if needed.
322
+ *
323
+ * Sends a `session.update` to the server when the session is active.
324
+ *
325
+ * @param delta An STTSettings (or `OpenAIRealtimeSTTService.Settings`) delta.
326
+ * @returns Record mapping changed field names to their previous values.
327
+ */
328
+ async _updateSettings(delta) {
329
+ const changed = await super._updateSettings(delta);
330
+ for (const [fieldName, previousValue] of Object.entries(OpenAIRealtimeSTTService._omitUnsupportedPrompt(this._settings))) {
331
+ if (!Object.hasOwn(changed, fieldName)) {
332
+ changed[fieldName] = previousValue;
333
+ }
334
+ }
335
+ if (Object.keys(changed).length > 0 && this._sessionReady) {
336
+ await this._sendSessionUpdate();
337
+ }
338
+ return changed;
339
+ }
340
+ /**
341
+ * Set up the service and connect.
342
+ *
343
+ * @param setup Configuration object containing setup parameters.
344
+ */
345
+ async setup(setup) {
346
+ await super.setup(setup);
347
+ await this._connect();
348
+ }
349
+ /**
350
+ * Stop the service and close WebSocket connection.
351
+ *
352
+ * @param frame The end frame triggering service shutdown.
353
+ */
354
+ async stop(frame) {
355
+ await super.stop(frame);
356
+ await this._disconnect();
357
+ }
358
+ /**
359
+ * Cancel the service and close WebSocket connection.
360
+ *
361
+ * @param frame The cancel frame triggering service cancellation.
362
+ */
363
+ async cancel(frame) {
364
+ await super.cancel(frame);
365
+ await this._disconnect();
366
+ }
367
+ /**
368
+ * Send audio data to the transcription session.
369
+ *
370
+ * Audio is streamed over the WebSocket. Transcription results arrive
371
+ * asynchronously via the receive task and are pushed as
372
+ * `InterimTranscriptionFrame` or `TranscriptionFrame`.
373
+ *
374
+ * @param audio Raw audio bytes (16-bit mono PCM at the pipeline sample
375
+ * rate). Automatically resampled to 24 kHz.
376
+ * @returns null — results are delivered via the WebSocket receive task.
377
+ */
378
+ async *runStt(audio) {
379
+ await this._sendAudio(audio);
380
+ yield null;
381
+ }
382
+ /**
383
+ * Process frames from the pipeline.
384
+ *
385
+ * Extends the base STT service to handle local VAD events when server-side
386
+ * VAD is disabled. On `VADUserStoppedSpeakingFrame`, commits the audio
387
+ * buffer so the server begins transcription for the completed speech
388
+ * segment.
389
+ *
390
+ * @param frame The frame to process.
391
+ * @param direction The direction of frame flow in the pipeline.
392
+ */
393
+ async processFrame(frame, direction) {
394
+ await super.processFrame(frame, direction);
395
+ // Handle local VAD events when server-side VAD is disabled.
396
+ if (!this._serverVadEnabled) {
397
+ if (frame instanceof VADUserStoppedSpeakingFrame) {
398
+ await this._commitAudioBuffer();
399
+ }
400
+ }
401
+ }
402
+ // ------------------------------------------------------------------
403
+ // WebSocket connection management
404
+ // ------------------------------------------------------------------
405
+ /** Connect to the transcription endpoint and start receiving. */
406
+ async _connect() {
407
+ await super._connect();
408
+ await this._connectWebsocket();
409
+ // Started even when the connection failed: with no socket the receive
410
+ // loop goes straight to its reconnect path, which retries the connect.
411
+ if (!this._receiveTask) {
412
+ this._receiveTask = this.createTask(() => this._receiveTaskHandler((error, options) => this._reportError(error, options)));
413
+ }
414
+ }
415
+ /** Disconnect and clean up background tasks. */
416
+ async _disconnect() {
417
+ await super._disconnect();
418
+ if (this._receiveTask) {
419
+ await this.cancelTask(this._receiveTask, 1.0);
420
+ this._receiveTask = null;
421
+ }
422
+ await this._disconnectWebsocket();
423
+ }
424
+ /** Establish the WebSocket connection to the transcription endpoint. */
425
+ async _connectWebsocket() {
426
+ try {
427
+ if (this._websocket && this._websocket.state === State.OPEN) {
428
+ return;
429
+ }
430
+ this._sessionReady = false;
431
+ const url = `${this._baseUrl}?intent=transcription`;
432
+ this._websocket = await this._websocketConnect(url, {
433
+ additionalHeaders: {
434
+ Authorization: `Bearer ${this._apiKey}`,
435
+ },
436
+ });
437
+ await this._callEventHandler("on_connected");
438
+ }
439
+ catch (e) {
440
+ rethrowIfCancelled(e);
441
+ await this.pushError(`Error connecting to OpenAI Realtime STT: ${errorMessage(e)}`, {
442
+ exception: e,
443
+ });
444
+ this._websocket = null;
445
+ }
446
+ }
447
+ /** Close the WebSocket connection. */
448
+ async _disconnectWebsocket() {
449
+ try {
450
+ this._sessionReady = false;
451
+ if (this._websocket) {
452
+ await this._websocket.close();
453
+ }
454
+ }
455
+ catch (e) {
456
+ rethrowIfCancelled(e);
457
+ await this.pushError(`Error disconnecting: ${errorMessage(e)}`, { exception: e });
458
+ }
459
+ finally {
460
+ this._websocket = null;
461
+ await this._callEventHandler("on_disconnected");
462
+ }
463
+ }
464
+ /**
465
+ * Send a JSON message over the WebSocket.
466
+ *
467
+ * @param message The message object to serialize and send.
468
+ */
469
+ async _wsSend(message) {
470
+ try {
471
+ if (!this._disconnecting && this._websocket) {
472
+ await this._websocket.send(pyJsonDumps(message));
473
+ }
474
+ }
475
+ catch (e) {
476
+ rethrowIfCancelled(e);
477
+ if (this._disconnecting || !this._websocket) {
478
+ return;
479
+ }
480
+ await this.pushError(`Error sending message: ${errorMessage(e)}`, { exception: e });
481
+ }
482
+ }
483
+ // ------------------------------------------------------------------
484
+ // Client events
485
+ // ------------------------------------------------------------------
486
+ /** Send `session.update` to configure the transcription session. */
487
+ async _sendSessionUpdate() {
488
+ const transcription = { model: this._settings.model };
489
+ // Technically `_settings.language` could be a raw string, but Language
490
+ // values are strings so downstream handles either.
491
+ const language = assertGiven(this._settings.language);
492
+ const languageCode = language ? OpenAIRealtimeSTTService._languageToCode(language) : null;
493
+ if (languageCode) {
494
+ transcription.language = languageCode;
495
+ }
496
+ if (this._settings.prompt) {
497
+ transcription.prompt = this._settings.prompt;
498
+ }
499
+ if (this._settings.delay) {
500
+ transcription.delay = this._settings.delay;
501
+ }
502
+ const inputAudio = {
503
+ format: {
504
+ type: "audio/pcm",
505
+ rate: OPENAI_SAMPLE_RATE,
506
+ },
507
+ transcription,
508
+ };
509
+ // Turn detection
510
+ if (this._turnDetection === false) {
511
+ inputAudio.turn_detection = null;
512
+ }
513
+ else if (this._turnDetection !== null) {
514
+ inputAudio.turn_detection = this._turnDetection;
515
+ }
516
+ // Noise reduction
517
+ if (this._settings.noiseReduction) {
518
+ inputAudio.noise_reduction = {
519
+ type: this._settings.noiseReduction,
520
+ };
521
+ }
522
+ await this._wsSend({
523
+ type: "session.update",
524
+ session: {
525
+ type: "transcription",
526
+ audio: {
527
+ input: inputAudio,
528
+ },
529
+ },
530
+ });
531
+ }
532
+ /**
533
+ * Send audio data via `input_audio_buffer.append`.
534
+ *
535
+ * Resamples from the pipeline sample rate to 24 kHz if needed.
536
+ *
537
+ * @param audio Raw audio bytes at the pipeline sample rate.
538
+ */
539
+ async _sendAudio(audio) {
540
+ const resampled = await this._resampler.resample(audio, this.sampleRate, OPENAI_SAMPLE_RATE);
541
+ if (!resampled || resampled.length === 0) {
542
+ return;
543
+ }
544
+ const payload = bytesToBase64(resampled);
545
+ await this._wsSend({
546
+ type: "input_audio_buffer.append",
547
+ audio: payload,
548
+ });
549
+ }
550
+ /** Commit the current audio buffer for transcription. */
551
+ async _commitAudioBuffer() {
552
+ await this._wsSend({ type: "input_audio_buffer.commit" });
553
+ }
554
+ /** Clear the current audio buffer. */
555
+ async _clearAudioBuffer() {
556
+ await this._wsSend({ type: "input_audio_buffer.clear" });
557
+ }
558
+ // ------------------------------------------------------------------
559
+ // Server event handling
560
+ // ------------------------------------------------------------------
561
+ /**
562
+ * Receive and dispatch server events from the transcription session.
563
+ *
564
+ * Called by `WebsocketService._receiveTaskHandler` which wraps this method
565
+ * with automatic reconnection on connection errors.
566
+ */
567
+ async _receiveMessages() {
568
+ if (!this._websocket) {
569
+ throw new Error("Websocket not connected");
570
+ }
571
+ for await (const message of this._websocket) {
572
+ let evt;
573
+ try {
574
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message);
575
+ evt = pyJsonLoads(text);
576
+ }
577
+ catch (e) {
578
+ rethrowIfCancelled(e);
579
+ logger.warning("Failed to parse WebSocket message");
580
+ continue;
581
+ }
582
+ const evtType = evt.type ?? "";
583
+ if (evtType === "session.created") {
584
+ await this._handleSessionCreated(evt);
585
+ }
586
+ else if (evtType === "session.updated") {
587
+ await this._handleSessionUpdated(evt);
588
+ }
589
+ else if (evtType === "conversation.item.input_audio_transcription.delta") {
590
+ await this._handleTranscriptionDelta(evt);
591
+ }
592
+ else if (evtType === "conversation.item.input_audio_transcription.completed") {
593
+ await this._handleTranscriptionCompleted(evt);
594
+ }
595
+ else if (evtType === "conversation.item.input_audio_transcription.failed") {
596
+ await this._handleTranscriptionFailed(evt);
597
+ }
598
+ else if (evtType === "input_audio_buffer.speech_started") {
599
+ await this._handleSpeechStarted(evt);
600
+ }
601
+ else if (evtType === "input_audio_buffer.speech_stopped") {
602
+ await this._handleSpeechStopped(evt);
603
+ }
604
+ else if (evtType === "input_audio_buffer.committed") {
605
+ logger.trace(`Audio buffer committed: item_id=${evt.item_id ?? ""}`);
606
+ }
607
+ else if (evtType === "error") {
608
+ await this._handleError(evt);
609
+ }
610
+ else {
611
+ logger.trace(`Unhandled event: ${evtType}`);
612
+ }
613
+ }
614
+ }
615
+ /**
616
+ * Handle `session.created`.
617
+ *
618
+ * Sent immediately after connecting. We respond by configuring the session
619
+ * with our desired settings.
620
+ *
621
+ * @param _evt The session created event from the server.
622
+ */
623
+ async _handleSessionCreated(_evt) {
624
+ logger.debug("Transcription session created, sending configuration");
625
+ await this._sendSessionUpdate();
626
+ }
627
+ /**
628
+ * Handle `session.updated`.
629
+ *
630
+ * The session is now fully configured and ready to transcribe.
631
+ *
632
+ * @param _evt The session updated event from the server.
633
+ */
634
+ async _handleSessionUpdated(_evt) {
635
+ logger.debug("Transcription session configured and ready");
636
+ this._sessionReady = true;
637
+ }
638
+ /**
639
+ * Handle incremental transcription text.
640
+ *
641
+ * For "gpt-realtime-whisper", "gpt-live-transcribe", and "gpt-transcribe",
642
+ * deltas contain low-latency streaming partial text.
643
+ *
644
+ * @param evt The delta event from the server.
645
+ */
646
+ async _handleTranscriptionDelta(evt) {
647
+ const delta = evt.delta ?? "";
648
+ if (delta) {
649
+ await this.pushFrame(new InterimTranscriptionFrame({
650
+ text: delta,
651
+ userId: this._userId,
652
+ timestamp: timeNowIso8601(),
653
+ result: evt,
654
+ }));
655
+ }
656
+ }
657
+ /**
658
+ * Handle a completed transcription for a speech segment.
659
+ *
660
+ * Pushes a `TranscriptionFrame` and records the result for tracing.
661
+ *
662
+ * @param evt The completed event containing the full transcript.
663
+ */
664
+ async _handleTranscriptionCompleted(evt) {
665
+ const transcript = evt.transcript ?? "";
666
+ if (transcript) {
667
+ // Report usage before the transcription frame so tracing can attach it
668
+ // to the STT span the frame closes.
669
+ await this.emitSttUsageMetrics();
670
+ await this.pushFrame(new TranscriptionFrame({
671
+ text: transcript,
672
+ userId: this._userId,
673
+ timestamp: timeNowIso8601(),
674
+ result: evt,
675
+ }));
676
+ await this._handleTranscriptionTrace(transcript, true);
677
+ }
678
+ }
679
+ /**
680
+ * Record transcription result for tracing.
681
+ *
682
+ * @param _transcript The transcribed text.
683
+ * @param _isFinal Whether this is a final transcription result.
684
+ * @param _language Optional language of the transcription.
685
+ */
686
+ async _handleTranscriptionTrace(_transcript, _isFinal, _language = null) { }
687
+ /**
688
+ * Handle server-side VAD speech start.
689
+ *
690
+ * Proposes a turn start, which the user turn strategies resolve into a
691
+ * `UserStartedSpeakingFrame` and an interruption.
692
+ *
693
+ * @param _evt The `input_audio_buffer.speech_started` event.
694
+ */
695
+ async _handleSpeechStarted(_evt) {
696
+ logger.debug("Server VAD: speech started");
697
+ await this.broadcastFrame(ProposedUserStartedSpeakingFrame);
698
+ }
699
+ /**
700
+ * Handle server-side VAD speech stop.
701
+ *
702
+ * Proposes a turn stop. The audio buffer is automatically committed by the
703
+ * server when VAD is enabled.
704
+ *
705
+ * @param _evt The `input_audio_buffer.speech_stopped` event.
706
+ */
707
+ async _handleSpeechStopped(_evt) {
708
+ logger.debug("Server VAD: speech stopped");
709
+ await this.broadcastFrame(ProposedUserStoppedSpeakingFrame);
710
+ }
711
+ /**
712
+ * Handle a transcription failure for a speech segment.
713
+ *
714
+ * Logs the error but does not treat it as fatal — the session remains
715
+ * active for subsequent turns.
716
+ *
717
+ * @param evt The failed event containing error details.
718
+ */
719
+ async _handleTranscriptionFailed(evt) {
720
+ const error = evt.error ?? {};
721
+ const errorMsg = error.message ?? "Transcription failed";
722
+ await this.pushError(`OpenAI Realtime STT error: ${errorMsg}`);
723
+ }
724
+ /**
725
+ * Handle a fatal error from the transcription session.
726
+ *
727
+ * Throws so that `WebsocketService` can decide whether to attempt
728
+ * reconnection.
729
+ *
730
+ * @param evt The error event.
731
+ */
732
+ async _handleError(evt) {
733
+ const error = evt.error ?? {};
734
+ const errorMsg = error.message ?? "Unknown error";
735
+ const errorCode = error.code ?? "";
736
+ const msg = `OpenAI Realtime STT error [${errorCode}]: ${errorMsg}`;
737
+ await this.pushError(msg);
738
+ throw new Error(msg);
739
+ }
740
+ }
741
+ //# sourceMappingURL=stt.js.map