@ai-sdk/google 4.0.53 → 4.0.55
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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +69 -9
- package/dist/index.js +614 -65
- package/dist/index.js.map +1 -1
- package/dist/internal/index.d.ts +7 -6
- package/dist/internal/index.js +59 -37
- package/dist/internal/index.js.map +1 -1
- package/package.json +1 -1
- package/src/google-language-model.ts +71 -34
- package/src/google-provider.ts +27 -0
- package/src/index.ts +5 -0
- package/src/transcription/google-transcription-model-options.ts +50 -0
- package/src/transcription/google-transcription-model.ts +714 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InvalidArgumentError,
|
|
3
|
+
type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions,
|
|
4
|
+
type Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart,
|
|
5
|
+
type JSONObject,
|
|
6
|
+
type SharedV4Warning,
|
|
7
|
+
type TranscriptionModelV4,
|
|
8
|
+
} from '@ai-sdk/provider';
|
|
9
|
+
import {
|
|
10
|
+
combineHeaders,
|
|
11
|
+
connectToWebSocket,
|
|
12
|
+
convertToBase64,
|
|
13
|
+
createJsonResponseHandler,
|
|
14
|
+
parseProviderOptions,
|
|
15
|
+
postJsonToApi,
|
|
16
|
+
resolve,
|
|
17
|
+
safeParseJSON,
|
|
18
|
+
serializeModelOptions,
|
|
19
|
+
waitForWebSocketBufferDrain,
|
|
20
|
+
WORKFLOW_DESERIALIZE,
|
|
21
|
+
WORKFLOW_SERIALIZE,
|
|
22
|
+
type FetchFunction,
|
|
23
|
+
type Resolvable,
|
|
24
|
+
type WebSocketConnection,
|
|
25
|
+
type WebSocketConstructor,
|
|
26
|
+
type WebSocketLike,
|
|
27
|
+
} from '@ai-sdk/provider-utils';
|
|
28
|
+
import { z } from 'zod/v4';
|
|
29
|
+
import { getModelPath } from '../get-model-path';
|
|
30
|
+
import { getRealtimeWebSocketURL } from '../get-realtime-base-url';
|
|
31
|
+
import { googleFailedResponseHandler } from '../google-error';
|
|
32
|
+
import {
|
|
33
|
+
googleTranscriptionModelOptions,
|
|
34
|
+
type GoogleTranscriptionModelId,
|
|
35
|
+
type GoogleTranscriptionModelOptions,
|
|
36
|
+
} from './google-transcription-model-options';
|
|
37
|
+
|
|
38
|
+
const liveWebSocketPath =
|
|
39
|
+
'google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* After the input audio has ended, finish when no terminal signal
|
|
43
|
+
* (`turnComplete` / idle `interactionStatus`) arrives within this window.
|
|
44
|
+
* Trailing transcripts reset the timer.
|
|
45
|
+
*/
|
|
46
|
+
const defaultFinishGraceMs = 3000;
|
|
47
|
+
|
|
48
|
+
function getLiveWebSocketURL(baseURL: string, apiKey: string): URL {
|
|
49
|
+
const url = getRealtimeWebSocketURL(baseURL, liveWebSocketPath);
|
|
50
|
+
url.searchParams.set('key', apiKey);
|
|
51
|
+
return url;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Live transcription is only supported by `*-live` model variants. */
|
|
55
|
+
function isLiveTranscriptionModelId(modelId: string): boolean {
|
|
56
|
+
return modelId.includes('-live');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type GoogleLiveWordInfo = {
|
|
60
|
+
text?: string;
|
|
61
|
+
word?: string;
|
|
62
|
+
startOffset?: string;
|
|
63
|
+
endOffset?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type GoogleLiveTranscription = {
|
|
67
|
+
text?: string;
|
|
68
|
+
finished?: boolean;
|
|
69
|
+
languageCode?: string;
|
|
70
|
+
speakerLabel?: string;
|
|
71
|
+
words?: GoogleLiveWordInfo[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type GoogleLiveServerMessage = {
|
|
75
|
+
setupComplete?: unknown;
|
|
76
|
+
serverContent?: {
|
|
77
|
+
inputTranscription?: GoogleLiveTranscription;
|
|
78
|
+
interimInputTranscription?: GoogleLiveTranscription;
|
|
79
|
+
turnComplete?: boolean;
|
|
80
|
+
generationComplete?: boolean;
|
|
81
|
+
interactionStatus?: string;
|
|
82
|
+
};
|
|
83
|
+
inputTranscription?: GoogleLiveTranscription;
|
|
84
|
+
usageMetadata?: JSONObject;
|
|
85
|
+
error?: { message?: string };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
interface GoogleTranscriptionModelConfig {
|
|
89
|
+
provider: string;
|
|
90
|
+
baseURL: string;
|
|
91
|
+
headers?: Resolvable<Record<string, string | undefined>>;
|
|
92
|
+
fetch?: FetchFunction;
|
|
93
|
+
webSocket?: WebSocketConstructor;
|
|
94
|
+
_internal?: {
|
|
95
|
+
currentDate?: () => Date;
|
|
96
|
+
finishGraceMs?: number;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class GoogleTranscriptionModel implements TranscriptionModelV4 {
|
|
101
|
+
readonly specificationVersion = 'v4';
|
|
102
|
+
|
|
103
|
+
static [WORKFLOW_SERIALIZE](model: GoogleTranscriptionModel) {
|
|
104
|
+
return serializeModelOptions({
|
|
105
|
+
modelId: model.modelId,
|
|
106
|
+
config: model.config,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static [WORKFLOW_DESERIALIZE](options: {
|
|
111
|
+
modelId: GoogleTranscriptionModelId;
|
|
112
|
+
config: GoogleTranscriptionModelConfig;
|
|
113
|
+
}) {
|
|
114
|
+
return new GoogleTranscriptionModel(options.modelId, options.config);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get provider(): string {
|
|
118
|
+
return this.config.provider;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
constructor(
|
|
122
|
+
readonly modelId: GoogleTranscriptionModelId,
|
|
123
|
+
private readonly config: GoogleTranscriptionModelConfig,
|
|
124
|
+
) {}
|
|
125
|
+
|
|
126
|
+
private async parseOptions(
|
|
127
|
+
providerOptions: Record<string, unknown> | undefined,
|
|
128
|
+
): Promise<GoogleTranscriptionModelOptions | undefined> {
|
|
129
|
+
return parseProviderOptions({
|
|
130
|
+
provider: 'google',
|
|
131
|
+
providerOptions,
|
|
132
|
+
schema: googleTranscriptionModelOptions,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async doGenerate(
|
|
137
|
+
options: Parameters<TranscriptionModelV4['doGenerate']>[0],
|
|
138
|
+
): Promise<Awaited<ReturnType<TranscriptionModelV4['doGenerate']>>> {
|
|
139
|
+
if (isLiveTranscriptionModelId(this.modelId)) {
|
|
140
|
+
throw new InvalidArgumentError({
|
|
141
|
+
argument: 'modelId',
|
|
142
|
+
message:
|
|
143
|
+
`Model '${this.modelId}' only supports streaming transcription. ` +
|
|
144
|
+
`Use experimental_streamTranscribe, or a unary model such as 'gemini-3.5-transcribe'.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
149
|
+
const warnings: SharedV4Warning[] = [];
|
|
150
|
+
const googleOptions = await this.parseOptions(options.providerOptions);
|
|
151
|
+
const transcriptionConfig = buildTranscriptionConfig(googleOptions);
|
|
152
|
+
|
|
153
|
+
// Unary transcription is served by the Interactions API
|
|
154
|
+
// (https://ai.google.dev/gemini-api/docs/transcribe).
|
|
155
|
+
const requestBody = {
|
|
156
|
+
model: this.modelId,
|
|
157
|
+
input: [
|
|
158
|
+
{
|
|
159
|
+
type: 'audio',
|
|
160
|
+
data: convertToBase64(options.audio),
|
|
161
|
+
mime_type: options.mediaType,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
...(transcriptionConfig != null
|
|
165
|
+
? { generation_config: { transcription_config: transcriptionConfig } }
|
|
166
|
+
: {}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const {
|
|
170
|
+
value: response,
|
|
171
|
+
responseHeaders,
|
|
172
|
+
rawValue: rawResponse,
|
|
173
|
+
} = await postJsonToApi({
|
|
174
|
+
url: `${this.config.baseURL}/interactions`,
|
|
175
|
+
headers: combineHeaders(
|
|
176
|
+
this.config.headers ? await resolve(this.config.headers) : undefined,
|
|
177
|
+
options.headers,
|
|
178
|
+
),
|
|
179
|
+
body: requestBody,
|
|
180
|
+
failedResponseHandler: googleFailedResponseHandler,
|
|
181
|
+
successfulResponseHandler: createJsonResponseHandler(
|
|
182
|
+
googleInteractionsTranscriptionResponseSchema,
|
|
183
|
+
),
|
|
184
|
+
abortSignal: options.abortSignal,
|
|
185
|
+
fetch: this.config.fetch,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
let text = '';
|
|
189
|
+
const segments: Array<{
|
|
190
|
+
text: string;
|
|
191
|
+
startSecond: number;
|
|
192
|
+
endSecond: number;
|
|
193
|
+
}> = [];
|
|
194
|
+
for (const step of response.steps ?? []) {
|
|
195
|
+
for (const content of step.content ?? []) {
|
|
196
|
+
if (content.type !== 'text' || content.text == null) continue;
|
|
197
|
+
text += content.text;
|
|
198
|
+
for (const annotation of content.annotations ?? []) {
|
|
199
|
+
if (annotation.type !== 'word_info') continue;
|
|
200
|
+
const startSecond = parseOffsetSeconds(annotation.start_offset);
|
|
201
|
+
const endSecond = parseOffsetSeconds(annotation.end_offset);
|
|
202
|
+
if (
|
|
203
|
+
annotation.text == null ||
|
|
204
|
+
startSecond == null ||
|
|
205
|
+
endSecond == null
|
|
206
|
+
) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
segments.push({ text: annotation.text, startSecond, endSecond });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
text,
|
|
216
|
+
segments,
|
|
217
|
+
language: undefined,
|
|
218
|
+
durationInSeconds: undefined,
|
|
219
|
+
warnings,
|
|
220
|
+
response: {
|
|
221
|
+
timestamp: currentDate,
|
|
222
|
+
modelId: this.modelId,
|
|
223
|
+
headers: responseHeaders,
|
|
224
|
+
body: rawResponse,
|
|
225
|
+
},
|
|
226
|
+
...(response.usage != null
|
|
227
|
+
? {
|
|
228
|
+
providerMetadata: {
|
|
229
|
+
google: { usage: response.usage as JSONObject },
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
: {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async doStream(
|
|
237
|
+
options: TranscriptionModelV4StreamOptions,
|
|
238
|
+
): Promise<
|
|
239
|
+
Awaited<ReturnType<NonNullable<TranscriptionModelV4['doStream']>>>
|
|
240
|
+
> {
|
|
241
|
+
if (!isLiveTranscriptionModelId(this.modelId)) {
|
|
242
|
+
throw new InvalidArgumentError({
|
|
243
|
+
argument: 'modelId',
|
|
244
|
+
message:
|
|
245
|
+
`Model '${this.modelId}' does not support streaming transcription. ` +
|
|
246
|
+
`Use a live model such as 'gemini-3.5-transcribe-live'.`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
251
|
+
const warnings: SharedV4Warning[] = [];
|
|
252
|
+
const googleOptions = await this.parseOptions(options.providerOptions);
|
|
253
|
+
|
|
254
|
+
validateLiveInputAudioFormat(options.inputAudioFormat);
|
|
255
|
+
|
|
256
|
+
const headers = combineHeaders(
|
|
257
|
+
this.config.headers ? await resolve(this.config.headers) : undefined,
|
|
258
|
+
options.headers,
|
|
259
|
+
);
|
|
260
|
+
// last case-variant wins: combineHeaders keeps case-distinct keys and
|
|
261
|
+
// spreads per-call headers after configuration headers
|
|
262
|
+
let apiKey: string | undefined;
|
|
263
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
264
|
+
if (key.toLowerCase() === 'x-goog-api-key' && value != null) {
|
|
265
|
+
apiKey = value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (apiKey == null) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
'Google Generative AI API key is required for streaming transcription.',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const webSocketHeaders = Object.fromEntries(
|
|
274
|
+
Object.entries(headers).filter(
|
|
275
|
+
([key]) => key.toLowerCase() !== 'x-goog-api-key',
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// NOTE: Google's GA announcement shows the setup with
|
|
280
|
+
// `generationConfig: { responseModalities: ['TEXT'] }`, but sending it
|
|
281
|
+
// suppresses the final `inputTranscription` segments on the current
|
|
282
|
+
// endpoint (only interim partials arrive). Omit generationConfig — the
|
|
283
|
+
// empirically working shape — until the endpoint honors the documented
|
|
284
|
+
// form.
|
|
285
|
+
const setup = {
|
|
286
|
+
model: getModelPath(this.modelId),
|
|
287
|
+
inputAudioTranscription:
|
|
288
|
+
buildAudioTranscriptionConfig(googleOptions) ?? {},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
request: { body: setup },
|
|
293
|
+
response: {
|
|
294
|
+
timestamp: currentDate,
|
|
295
|
+
modelId: this.modelId,
|
|
296
|
+
},
|
|
297
|
+
stream: createGoogleLiveTranscriptionStream({
|
|
298
|
+
webSocket: this.config.webSocket,
|
|
299
|
+
url: getLiveWebSocketURL(this.config.baseURL, apiKey),
|
|
300
|
+
headers: webSocketHeaders,
|
|
301
|
+
setup,
|
|
302
|
+
inputAudioRate: options.inputAudioFormat.rate ?? 16000,
|
|
303
|
+
finishGraceMs:
|
|
304
|
+
this.config._internal?.finishGraceMs ?? defaultFinishGraceMs,
|
|
305
|
+
warnings,
|
|
306
|
+
audio: options.audio,
|
|
307
|
+
abortSignal: options.abortSignal,
|
|
308
|
+
includeRawChunks: options.includeRawChunks,
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function createGoogleLiveTranscriptionStream({
|
|
315
|
+
webSocket,
|
|
316
|
+
url,
|
|
317
|
+
headers,
|
|
318
|
+
setup,
|
|
319
|
+
inputAudioRate,
|
|
320
|
+
finishGraceMs,
|
|
321
|
+
warnings,
|
|
322
|
+
audio,
|
|
323
|
+
abortSignal,
|
|
324
|
+
includeRawChunks,
|
|
325
|
+
}: {
|
|
326
|
+
webSocket: WebSocketConstructor | undefined;
|
|
327
|
+
url: URL;
|
|
328
|
+
headers: Record<string, string | undefined>;
|
|
329
|
+
setup: unknown;
|
|
330
|
+
inputAudioRate: number;
|
|
331
|
+
finishGraceMs: number;
|
|
332
|
+
warnings: SharedV4Warning[];
|
|
333
|
+
audio: ReadableStream<Uint8Array | string>;
|
|
334
|
+
abortSignal: AbortSignal | undefined;
|
|
335
|
+
includeRawChunks: boolean | undefined;
|
|
336
|
+
}) {
|
|
337
|
+
let finished = false;
|
|
338
|
+
let cleanup: (closeCode?: number) => void = () => {};
|
|
339
|
+
|
|
340
|
+
return new ReadableStream<TranscriptionModelV4StreamPart>({
|
|
341
|
+
start: controller => {
|
|
342
|
+
let audioReader:
|
|
343
|
+
| ReadableStreamDefaultReader<Uint8Array | string>
|
|
344
|
+
| undefined;
|
|
345
|
+
let connection: WebSocketConnection | undefined;
|
|
346
|
+
|
|
347
|
+
// The Live API contract requires waiting for the `setupComplete`
|
|
348
|
+
// server message before sending realtime input: the audio send loop
|
|
349
|
+
// is gated on this promise.
|
|
350
|
+
let resolveSetupComplete!: () => void;
|
|
351
|
+
const setupComplete = new Promise<void>(resolve => {
|
|
352
|
+
resolveSetupComplete = resolve;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Google Live messages carry no response/item IDs; a segment counter
|
|
356
|
+
// generates consistent synthetic IDs. Transcription fragments arrive
|
|
357
|
+
// incrementally and are accumulated per segment; a `finished: true`
|
|
358
|
+
// transcription or `turnComplete` finalizes the current segment.
|
|
359
|
+
let segmentCounter = 0;
|
|
360
|
+
let segmentBuffer = '';
|
|
361
|
+
let fullText = '';
|
|
362
|
+
// Latest revisable interim text: the fallback final when the server
|
|
363
|
+
// never delivers a finished `inputTranscription` segment.
|
|
364
|
+
let latestInterim = '';
|
|
365
|
+
let language: string | undefined;
|
|
366
|
+
let audioEnded = false;
|
|
367
|
+
let usageMetadata: JSONObject | undefined;
|
|
368
|
+
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
|
369
|
+
|
|
370
|
+
const segmentId = () => `google-segment-${segmentCounter}`;
|
|
371
|
+
|
|
372
|
+
const cancelPendingFinish = () => {
|
|
373
|
+
if (finishTimer != null) {
|
|
374
|
+
clearTimeout(finishTimer);
|
|
375
|
+
finishTimer = undefined;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// Trailing transcripts can arrive after audioStreamEnd; without a
|
|
380
|
+
// terminal signal, finish after a quiet grace window. Transcript
|
|
381
|
+
// activity reschedules the timer.
|
|
382
|
+
const schedulePendingFinish = () => {
|
|
383
|
+
if (finished || !audioEnded) return;
|
|
384
|
+
cancelPendingFinish();
|
|
385
|
+
finishTimer = setTimeout(() => {
|
|
386
|
+
finishTimer = undefined;
|
|
387
|
+
finish();
|
|
388
|
+
}, finishGraceMs);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
cleanup = (closeCode?: number) => {
|
|
392
|
+
cancelPendingFinish();
|
|
393
|
+
if (audioReader != null) {
|
|
394
|
+
void audioReader.cancel().catch(() => {});
|
|
395
|
+
} else {
|
|
396
|
+
// pre-open failure or abort: cancel the caller's audio stream so an
|
|
397
|
+
// upstream producer piping into it does not hang:
|
|
398
|
+
void audio.cancel().catch(() => {});
|
|
399
|
+
}
|
|
400
|
+
connection?.close(closeCode);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const finishWithError = (error: unknown) => {
|
|
404
|
+
if (finished) return;
|
|
405
|
+
finished = true;
|
|
406
|
+
cleanup();
|
|
407
|
+
controller.error(error);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const completeSegment = () => {
|
|
411
|
+
// A finished segment supersedes any interim text it revises.
|
|
412
|
+
if (segmentBuffer === '') {
|
|
413
|
+
if (latestInterim === '') return;
|
|
414
|
+
segmentBuffer = latestInterim;
|
|
415
|
+
}
|
|
416
|
+
latestInterim = '';
|
|
417
|
+
controller.enqueue({
|
|
418
|
+
type: 'transcript-final',
|
|
419
|
+
id: segmentId(),
|
|
420
|
+
text: segmentBuffer,
|
|
421
|
+
});
|
|
422
|
+
fullText += fullText === '' ? segmentBuffer : ` ${segmentBuffer}`;
|
|
423
|
+
segmentBuffer = '';
|
|
424
|
+
segmentCounter++;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const finish = () => {
|
|
428
|
+
if (finished) return;
|
|
429
|
+
completeSegment();
|
|
430
|
+
finished = true;
|
|
431
|
+
controller.enqueue({
|
|
432
|
+
type: 'finish',
|
|
433
|
+
text: fullText,
|
|
434
|
+
segments: [],
|
|
435
|
+
language,
|
|
436
|
+
durationInSeconds: undefined,
|
|
437
|
+
...(usageMetadata != null
|
|
438
|
+
? { providerMetadata: { google: { usageMetadata } } }
|
|
439
|
+
: {}),
|
|
440
|
+
});
|
|
441
|
+
controller.close();
|
|
442
|
+
cleanup(1000);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const sendAudio = async (socket: WebSocketLike) => {
|
|
446
|
+
audioReader = audio.getReader();
|
|
447
|
+
try {
|
|
448
|
+
while (true) {
|
|
449
|
+
const { done, value } = await audioReader.read();
|
|
450
|
+
if (done || finished) break;
|
|
451
|
+
socket.send(
|
|
452
|
+
JSON.stringify({
|
|
453
|
+
realtimeInput: {
|
|
454
|
+
audio: {
|
|
455
|
+
data: convertToBase64(value),
|
|
456
|
+
mimeType: `audio/pcm;rate=${inputAudioRate}`,
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
// backpressure: pause reads while the socket buffer is full
|
|
462
|
+
await waitForWebSocketBufferDrain(socket);
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
audioReader.releaseLock();
|
|
466
|
+
// unlocked again: cleanup must cancel `audio`, not the reader
|
|
467
|
+
audioReader = undefined;
|
|
468
|
+
}
|
|
469
|
+
if (!finished) {
|
|
470
|
+
socket.send(
|
|
471
|
+
JSON.stringify({ realtimeInput: { audioStreamEnd: true } }),
|
|
472
|
+
);
|
|
473
|
+
audioEnded = true;
|
|
474
|
+
schedulePendingFinish();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
connection = connectToWebSocket({
|
|
479
|
+
url,
|
|
480
|
+
headers,
|
|
481
|
+
webSocket,
|
|
482
|
+
abortSignal,
|
|
483
|
+
onAbort: finishWithError,
|
|
484
|
+
onProcessingError: finishWithError,
|
|
485
|
+
onOpen: socket => {
|
|
486
|
+
controller.enqueue({ type: 'stream-start', warnings });
|
|
487
|
+
socket.send(JSON.stringify({ setup }));
|
|
488
|
+
// audio may only be sent after the server acknowledged the setup:
|
|
489
|
+
void setupComplete
|
|
490
|
+
.then(() => (finished ? undefined : sendAudio(socket)))
|
|
491
|
+
.catch(finishWithError);
|
|
492
|
+
},
|
|
493
|
+
onMessageText: async text => {
|
|
494
|
+
if (finished) return;
|
|
495
|
+
const parsed = await safeParseJSON({ text });
|
|
496
|
+
if (!parsed.success) return;
|
|
497
|
+
const message = parsed.value as GoogleLiveServerMessage;
|
|
498
|
+
|
|
499
|
+
if (includeRawChunks) {
|
|
500
|
+
controller.enqueue({ type: 'raw', rawValue: message });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (message.setupComplete != null) {
|
|
504
|
+
resolveSetupComplete();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (message.usageMetadata != null) {
|
|
508
|
+
usageMetadata = message.usageMetadata;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (message.error != null) {
|
|
512
|
+
finishWithError(
|
|
513
|
+
new Error(message.error.message ?? 'Google Live API error'),
|
|
514
|
+
);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const serverContent = message.serverContent;
|
|
519
|
+
|
|
520
|
+
// Low-latency revisable transcription while the user is speaking.
|
|
521
|
+
const interim = serverContent?.interimInputTranscription;
|
|
522
|
+
if (interim?.text) {
|
|
523
|
+
schedulePendingFinish();
|
|
524
|
+
latestInterim = interim.text;
|
|
525
|
+
controller.enqueue({
|
|
526
|
+
type: 'transcript-partial',
|
|
527
|
+
id: segmentId(),
|
|
528
|
+
text: interim.text,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const transcription =
|
|
533
|
+
serverContent?.inputTranscription ?? message.inputTranscription;
|
|
534
|
+
if (transcription != null) {
|
|
535
|
+
if (transcription.languageCode != null) {
|
|
536
|
+
language = transcription.languageCode;
|
|
537
|
+
}
|
|
538
|
+
if (transcription.text) {
|
|
539
|
+
schedulePendingFinish();
|
|
540
|
+
// A real transcription delta supersedes interim fallback text.
|
|
541
|
+
latestInterim = '';
|
|
542
|
+
segmentBuffer += transcription.text;
|
|
543
|
+
controller.enqueue({
|
|
544
|
+
type: 'transcript-delta',
|
|
545
|
+
id: segmentId(),
|
|
546
|
+
delta: transcription.text,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (transcription.finished === true) {
|
|
550
|
+
completeSegment();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (serverContent?.turnComplete) {
|
|
555
|
+
completeSegment();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// `interactionStatus` idle (REQUIRES_ACTION in the EAP builds) is
|
|
559
|
+
// the definitive all-processing-complete signal: finish as soon as
|
|
560
|
+
// the input audio has ended.
|
|
561
|
+
const interactionStatus = serverContent?.interactionStatus;
|
|
562
|
+
if (
|
|
563
|
+
audioEnded &&
|
|
564
|
+
(interactionStatus === 'IDLE' ||
|
|
565
|
+
interactionStatus === 'REQUIRES_ACTION' ||
|
|
566
|
+
(serverContent?.turnComplete === true &&
|
|
567
|
+
interactionStatus == null))
|
|
568
|
+
) {
|
|
569
|
+
finish();
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
onSocketError: () => {
|
|
573
|
+
finishWithError(new Error('Google Live transcription error'));
|
|
574
|
+
},
|
|
575
|
+
onClose: ({ code, reason }) => {
|
|
576
|
+
if (finished) return;
|
|
577
|
+
// a close after the input audio ended means the server delivered
|
|
578
|
+
// everything it will deliver: finish with the accumulated text
|
|
579
|
+
if (audioEnded) {
|
|
580
|
+
finish();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
finishWithError(
|
|
584
|
+
new Error(
|
|
585
|
+
`Google Live transcription WebSocket closed unexpectedly before finishing` +
|
|
586
|
+
` (code ${code ?? 'unknown'}${reason ? `, reason: ${reason}` : ''}).`,
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
cancel: () => {
|
|
594
|
+
if (finished) return;
|
|
595
|
+
finished = true;
|
|
596
|
+
cleanup();
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Builds Google's `AudioTranscriptionConfig` from provider options; returns
|
|
603
|
+
* undefined when no options are set.
|
|
604
|
+
*/
|
|
605
|
+
function buildAudioTranscriptionConfig(
|
|
606
|
+
options: GoogleTranscriptionModelOptions | undefined,
|
|
607
|
+
): Record<string, unknown> | undefined {
|
|
608
|
+
if (options == null) return undefined;
|
|
609
|
+
const config: Record<string, unknown> = {};
|
|
610
|
+
if (options.languageCodes != null) {
|
|
611
|
+
config.languageCodes = options.languageCodes;
|
|
612
|
+
}
|
|
613
|
+
if (options.customVocabulary != null) {
|
|
614
|
+
config.customVocabulary = options.customVocabulary;
|
|
615
|
+
}
|
|
616
|
+
if (options.wordTimestamp != null) {
|
|
617
|
+
config.wordTimestamp = options.wordTimestamp;
|
|
618
|
+
}
|
|
619
|
+
if (options.diarization != null) {
|
|
620
|
+
config.diarization = options.diarization;
|
|
621
|
+
}
|
|
622
|
+
if (options.mode != null) {
|
|
623
|
+
config.mode = options.mode;
|
|
624
|
+
}
|
|
625
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Builds the Interactions API `transcription_config` (snake_case wire) from
|
|
630
|
+
* provider options; returns undefined when no options are set. Diarization
|
|
631
|
+
* and word timestamps are expressed inside the `mode` object per
|
|
632
|
+
* https://ai.google.dev/gemini-api/docs/transcribe.
|
|
633
|
+
*/
|
|
634
|
+
function buildTranscriptionConfig(
|
|
635
|
+
options: GoogleTranscriptionModelOptions | undefined,
|
|
636
|
+
): Record<string, unknown> | undefined {
|
|
637
|
+
if (options == null) return undefined;
|
|
638
|
+
const config: Record<string, unknown> = {};
|
|
639
|
+
if (options.languageCodes != null) {
|
|
640
|
+
config.language_codes = options.languageCodes;
|
|
641
|
+
}
|
|
642
|
+
if (options.customVocabulary != null) {
|
|
643
|
+
config.custom_vocabulary = options.customVocabulary;
|
|
644
|
+
}
|
|
645
|
+
if (
|
|
646
|
+
options.mode != null ||
|
|
647
|
+
options.diarization === true ||
|
|
648
|
+
options.wordTimestamp === true
|
|
649
|
+
) {
|
|
650
|
+
config.mode = {
|
|
651
|
+
type: (options.mode ?? 'VERBATIM').toLowerCase(),
|
|
652
|
+
...(options.diarization === true ? { diarization_mode: 'speaker' } : {}),
|
|
653
|
+
...(options.wordTimestamp === true
|
|
654
|
+
? { timestamp_granularities: ['word'] }
|
|
655
|
+
: {}),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Parses a Google duration offset such as `"1s"` or `"9.400s"` to seconds. */
|
|
662
|
+
function parseOffsetSeconds(
|
|
663
|
+
offset: string | undefined | null,
|
|
664
|
+
): number | undefined {
|
|
665
|
+
if (offset == null) return undefined;
|
|
666
|
+
const parsed = Number.parseFloat(offset);
|
|
667
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function validateLiveInputAudioFormat(
|
|
671
|
+
inputAudioFormat: TranscriptionModelV4StreamOptions['inputAudioFormat'],
|
|
672
|
+
) {
|
|
673
|
+
if (
|
|
674
|
+
inputAudioFormat.type !== 'audio/pcm' ||
|
|
675
|
+
(inputAudioFormat.rate != null && inputAudioFormat.rate !== 16000)
|
|
676
|
+
) {
|
|
677
|
+
throw new InvalidArgumentError({
|
|
678
|
+
argument: 'inputAudioFormat',
|
|
679
|
+
message:
|
|
680
|
+
'The Gemini Live transcription API only supports 16kHz 16-bit PCM input audio.',
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const googleInteractionsWordAnnotationSchema = z.object({
|
|
686
|
+
type: z.string().nullish(),
|
|
687
|
+
text: z.string().nullish(),
|
|
688
|
+
speaker: z.string().nullish(),
|
|
689
|
+
start_offset: z.string().nullish(),
|
|
690
|
+
end_offset: z.string().nullish(),
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const googleInteractionsTranscriptionResponseSchema = z.object({
|
|
694
|
+
status: z.string().nullish(),
|
|
695
|
+
steps: z
|
|
696
|
+
.array(
|
|
697
|
+
z.object({
|
|
698
|
+
type: z.string().nullish(),
|
|
699
|
+
content: z
|
|
700
|
+
.array(
|
|
701
|
+
z.object({
|
|
702
|
+
type: z.string().nullish(),
|
|
703
|
+
text: z.string().nullish(),
|
|
704
|
+
annotations: z
|
|
705
|
+
.array(googleInteractionsWordAnnotationSchema)
|
|
706
|
+
.nullish(),
|
|
707
|
+
}),
|
|
708
|
+
)
|
|
709
|
+
.nullish(),
|
|
710
|
+
}),
|
|
711
|
+
)
|
|
712
|
+
.nullish(),
|
|
713
|
+
usage: z.record(z.string(), z.unknown()).nullish(),
|
|
714
|
+
});
|