@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/LICENSE +25 -0
- package/NOTICE +9 -0
- package/dist/_constants.d.ts +4 -0
- package/dist/_constants.js +9 -0
- package/dist/base_llm.d.ts +272 -0
- package/dist/base_llm.js +619 -0
- package/dist/image.d.ts +116 -0
- package/dist/image.js +179 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +12 -0
- package/dist/live/events.d.ts +768 -0
- package/dist/live/events.js +1020 -0
- package/dist/live/index.d.ts +2 -0
- package/dist/live/index.js +8 -0
- package/dist/live/llm.d.ts +566 -0
- package/dist/live/llm.js +1652 -0
- package/dist/llm.d.ts +30 -0
- package/dist/llm.js +50 -0
- package/dist/open_ai_live_adapter.d.ts +94 -0
- package/dist/open_ai_live_adapter.js +192 -0
- package/dist/open_ai_realtime_adapter.d.ts +95 -0
- package/dist/open_ai_realtime_adapter.js +221 -0
- package/dist/open_ai_responses_adapter.d.ts +95 -0
- package/dist/open_ai_responses_adapter.js +358 -0
- package/dist/realtime/events.d.ts +1401 -0
- package/dist/realtime/events.js +1639 -0
- package/dist/realtime/index.d.ts +2 -0
- package/dist/realtime/index.js +8 -0
- package/dist/realtime/llm.d.ts +457 -0
- package/dist/realtime/llm.js +1550 -0
- package/dist/responses/index.d.ts +2 -0
- package/dist/responses/index.js +8 -0
- package/dist/responses/llm.d.ts +448 -0
- package/dist/responses/llm.js +1517 -0
- package/dist/stt.d.ts +409 -0
- package/dist/stt.js +741 -0
- package/dist/tts.d.ts +86 -0
- package/dist/tts.js +238 -0
- package/package.json +49 -3
- package/README.md +0 -5
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
|