@ai-sdk/google 4.0.24 → 4.0.26
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 +17 -0
- package/dist/index.d.ts +50 -3
- package/dist/index.js +482 -15
- package/dist/index.js.map +1 -1
- package/docs/15-google.mdx +51 -0
- package/package.json +3 -3
- package/src/get-realtime-base-url.ts +31 -0
- package/src/google-provider.ts +32 -0
- package/src/index.ts +6 -0
- package/src/realtime/google-realtime-model.ts +5 -17
- package/src/translation/google-translation-model-options.ts +26 -0
- package/src/translation/google-translation-model.ts +612 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InvalidArgumentError,
|
|
3
|
+
type Experimental_SpeechTranslationModelV4 as TranslationModelV4,
|
|
4
|
+
type Experimental_SpeechTranslationModelV4StreamOptions as SpeechTranslationModelV4StreamOptions,
|
|
5
|
+
type Experimental_SpeechTranslationModelV4StreamPart as SpeechTranslationModelV4StreamPart,
|
|
6
|
+
type Experimental_SpeechTranslationModelV4Usage as SpeechTranslationModelV4Usage,
|
|
7
|
+
type SharedV4Warning,
|
|
8
|
+
} from '@ai-sdk/provider';
|
|
9
|
+
import {
|
|
10
|
+
connectToWebSocket,
|
|
11
|
+
combineHeaders,
|
|
12
|
+
convertBase64ToUint8Array,
|
|
13
|
+
convertToBase64,
|
|
14
|
+
parseProviderOptions,
|
|
15
|
+
safeParseJSON,
|
|
16
|
+
serializeModelOptions,
|
|
17
|
+
WORKFLOW_DESERIALIZE,
|
|
18
|
+
WORKFLOW_SERIALIZE,
|
|
19
|
+
waitForWebSocketBufferDrain,
|
|
20
|
+
type WebSocketConnection,
|
|
21
|
+
type WebSocketConstructor,
|
|
22
|
+
type WebSocketLike,
|
|
23
|
+
} from '@ai-sdk/provider-utils';
|
|
24
|
+
import { getModelPath } from '../get-model-path';
|
|
25
|
+
import { getRealtimeWebSocketURL } from '../get-realtime-base-url';
|
|
26
|
+
import {
|
|
27
|
+
googleTranslationModelOptions,
|
|
28
|
+
type GoogleTranslationModelId,
|
|
29
|
+
type GoogleTranslationModelOptions,
|
|
30
|
+
} from './google-translation-model-options';
|
|
31
|
+
|
|
32
|
+
const liveWebSocketPath =
|
|
33
|
+
'google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* After the input audio has ended, finish after this much trailing output
|
|
37
|
+
* silence. Live Translation is continuous and does not emit turnComplete.
|
|
38
|
+
*/
|
|
39
|
+
const defaultFinishGraceMs = 1000;
|
|
40
|
+
const googleLiveOutputAudioRate = 24000;
|
|
41
|
+
const pcm16SilenceAmplitudeThreshold = 128;
|
|
42
|
+
|
|
43
|
+
function getLiveWebSocketURL(baseURL: string, apiKey: string): URL {
|
|
44
|
+
const url = getRealtimeWebSocketURL(baseURL, liveWebSocketPath);
|
|
45
|
+
url.searchParams.set('key', apiKey);
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type GoogleLiveTokensDetail = {
|
|
50
|
+
modality?: string;
|
|
51
|
+
tokenCount?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type GoogleLiveServerMessage = {
|
|
55
|
+
setupComplete?: unknown;
|
|
56
|
+
serverContent?: {
|
|
57
|
+
modelTurn?: {
|
|
58
|
+
parts?: Array<{
|
|
59
|
+
inlineData?: { data?: string };
|
|
60
|
+
}>;
|
|
61
|
+
};
|
|
62
|
+
outputTranscription?: { text?: string };
|
|
63
|
+
inputTranscription?: { text?: string };
|
|
64
|
+
turnComplete?: boolean;
|
|
65
|
+
};
|
|
66
|
+
inputTranscription?: { text?: string };
|
|
67
|
+
usageMetadata?: {
|
|
68
|
+
promptTokensDetails?: GoogleLiveTokensDetail[];
|
|
69
|
+
responseTokensDetails?: GoogleLiveTokensDetail[];
|
|
70
|
+
};
|
|
71
|
+
error?: { message?: string };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type GoogleTranslationModelConfig = {
|
|
75
|
+
provider: string;
|
|
76
|
+
baseURL: string;
|
|
77
|
+
headers: () => Record<string, string | undefined>;
|
|
78
|
+
webSocket?: WebSocketConstructor;
|
|
79
|
+
_internal?: {
|
|
80
|
+
currentDate?: () => Date;
|
|
81
|
+
finishGraceMs?: number;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export class GoogleTranslationModel implements TranslationModelV4 {
|
|
86
|
+
readonly specificationVersion = 'v4';
|
|
87
|
+
readonly modelId: GoogleTranslationModelId;
|
|
88
|
+
|
|
89
|
+
private readonly config: GoogleTranslationModelConfig;
|
|
90
|
+
|
|
91
|
+
static [WORKFLOW_SERIALIZE](model: GoogleTranslationModel) {
|
|
92
|
+
return serializeModelOptions({
|
|
93
|
+
modelId: model.modelId,
|
|
94
|
+
config: model.config,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static [WORKFLOW_DESERIALIZE](options: {
|
|
99
|
+
modelId: GoogleTranslationModelId;
|
|
100
|
+
config: GoogleTranslationModelConfig;
|
|
101
|
+
}) {
|
|
102
|
+
return new GoogleTranslationModel(options.modelId, options.config);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get provider(): string {
|
|
106
|
+
return this.config.provider;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
constructor(
|
|
110
|
+
modelId: GoogleTranslationModelId,
|
|
111
|
+
config: GoogleTranslationModelConfig,
|
|
112
|
+
) {
|
|
113
|
+
this.modelId = modelId;
|
|
114
|
+
this.config = config;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async doStream(
|
|
118
|
+
options: SpeechTranslationModelV4StreamOptions,
|
|
119
|
+
): Promise<Awaited<ReturnType<TranslationModelV4['doStream']>>> {
|
|
120
|
+
if (options.targetLanguage == null) {
|
|
121
|
+
throw new InvalidArgumentError({
|
|
122
|
+
argument: 'targetLanguage',
|
|
123
|
+
message: `targetLanguage is required for translation model '${this.modelId}'.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
128
|
+
const googleOptions = await parseProviderOptions({
|
|
129
|
+
provider: 'google',
|
|
130
|
+
providerOptions: options.providerOptions,
|
|
131
|
+
schema: googleTranslationModelOptions,
|
|
132
|
+
});
|
|
133
|
+
const warnings: SharedV4Warning[] = [];
|
|
134
|
+
|
|
135
|
+
validateGoogleTranslationInputAudioFormat(options.inputAudioFormat);
|
|
136
|
+
|
|
137
|
+
if (options.sourceLanguage != null) {
|
|
138
|
+
warnings.push({
|
|
139
|
+
type: 'unsupported',
|
|
140
|
+
feature: 'sourceLanguage',
|
|
141
|
+
details:
|
|
142
|
+
'The Gemini Live translation API auto-detects the source language and does not accept a source language.',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (options.outputAudioFormat != null) {
|
|
147
|
+
warnings.push({
|
|
148
|
+
type: 'unsupported',
|
|
149
|
+
feature: 'outputAudioFormat',
|
|
150
|
+
details:
|
|
151
|
+
'The Gemini Live API always outputs 24kHz 16-bit PCM audio and does not accept an output audio format.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const headers = combineHeaders(this.config.headers(), options.headers);
|
|
156
|
+
// last case-variant wins: combineHeaders keeps case-distinct keys and
|
|
157
|
+
// spreads per-call headers after configuration headers
|
|
158
|
+
let apiKey: string | undefined;
|
|
159
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
160
|
+
if (key.toLowerCase() === 'x-goog-api-key' && value != null) {
|
|
161
|
+
apiKey = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (apiKey == null) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
'Google Generative AI API key is required for streaming translation.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const webSocketHeaders = Object.fromEntries(
|
|
170
|
+
Object.entries(headers).filter(
|
|
171
|
+
([key]) => key.toLowerCase() !== 'x-goog-api-key',
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const setup = buildGoogleLiveTranslationSetup({
|
|
176
|
+
modelId: this.modelId,
|
|
177
|
+
targetLanguage: options.targetLanguage,
|
|
178
|
+
providerOptions: googleOptions,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
request: { body: setup },
|
|
183
|
+
response: {
|
|
184
|
+
timestamp: currentDate,
|
|
185
|
+
modelId: this.modelId,
|
|
186
|
+
},
|
|
187
|
+
stream: createGoogleLiveTranslationStream({
|
|
188
|
+
webSocket: this.config.webSocket,
|
|
189
|
+
url: getLiveWebSocketURL(this.config.baseURL, apiKey),
|
|
190
|
+
headers: webSocketHeaders,
|
|
191
|
+
setup,
|
|
192
|
+
inputAudioRate: options.inputAudioFormat.rate ?? 16000,
|
|
193
|
+
finishGraceMs:
|
|
194
|
+
this.config._internal?.finishGraceMs ?? defaultFinishGraceMs,
|
|
195
|
+
warnings,
|
|
196
|
+
audio: options.audio,
|
|
197
|
+
abortSignal: options.abortSignal,
|
|
198
|
+
includeRawChunks: options.includeRawChunks,
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createGoogleLiveTranslationStream({
|
|
205
|
+
webSocket,
|
|
206
|
+
url,
|
|
207
|
+
headers,
|
|
208
|
+
setup,
|
|
209
|
+
inputAudioRate,
|
|
210
|
+
finishGraceMs,
|
|
211
|
+
warnings,
|
|
212
|
+
audio,
|
|
213
|
+
abortSignal,
|
|
214
|
+
includeRawChunks,
|
|
215
|
+
}: {
|
|
216
|
+
webSocket: WebSocketConstructor | undefined;
|
|
217
|
+
url: URL;
|
|
218
|
+
headers: Record<string, string | undefined>;
|
|
219
|
+
setup: unknown;
|
|
220
|
+
inputAudioRate: number;
|
|
221
|
+
finishGraceMs: number;
|
|
222
|
+
warnings: SharedV4Warning[];
|
|
223
|
+
audio: ReadableStream<Uint8Array | string>;
|
|
224
|
+
abortSignal: AbortSignal | undefined;
|
|
225
|
+
includeRawChunks: boolean | undefined;
|
|
226
|
+
}) {
|
|
227
|
+
let finished = false;
|
|
228
|
+
let cleanup: (closeCode?: number) => void = () => {};
|
|
229
|
+
|
|
230
|
+
return new ReadableStream<SpeechTranslationModelV4StreamPart>({
|
|
231
|
+
start: controller => {
|
|
232
|
+
let audioReader:
|
|
233
|
+
| ReadableStreamDefaultReader<Uint8Array | string>
|
|
234
|
+
| undefined;
|
|
235
|
+
let connection: WebSocketConnection | undefined;
|
|
236
|
+
|
|
237
|
+
// The Live API contract requires waiting for the `setupComplete`
|
|
238
|
+
// server message before sending realtime input: the audio send loop
|
|
239
|
+
// is gated on this promise.
|
|
240
|
+
let resolveSetupComplete!: () => void;
|
|
241
|
+
const setupComplete = new Promise<void>(resolve => {
|
|
242
|
+
resolveSetupComplete = resolve;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Google Live messages carry no response/item IDs; a turn counter
|
|
246
|
+
// generates consistent synthetic IDs (like the realtime event mapper).
|
|
247
|
+
let turnCounter = 0;
|
|
248
|
+
// Transcription fragments arrive incrementally and are accumulated per
|
|
249
|
+
// turn; `turnComplete` finalizes the current turn.
|
|
250
|
+
let sourceText = '';
|
|
251
|
+
let sourceTurnBuffer = '';
|
|
252
|
+
let translationText = '';
|
|
253
|
+
let translationTurnBuffer = '';
|
|
254
|
+
let audioEnded = false;
|
|
255
|
+
let usage: SpeechTranslationModelV4Usage | undefined;
|
|
256
|
+
|
|
257
|
+
// Live Translation is a continuous pipeline rather than a turn-based
|
|
258
|
+
// model. After audioStreamEnd it keeps sending PCM silence indefinitely
|
|
259
|
+
// and does not emit turnComplete. Drain translated speech, then finish
|
|
260
|
+
// after enough trailing silence. Keep turnComplete handling as a
|
|
261
|
+
// fallback for compatible server implementations and test doubles.
|
|
262
|
+
let openTurn = false;
|
|
263
|
+
let sawTurnComplete = false;
|
|
264
|
+
let trailingSilenceMs = 0;
|
|
265
|
+
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
|
266
|
+
|
|
267
|
+
const itemId = () => `google-item-${turnCounter}`;
|
|
268
|
+
|
|
269
|
+
const cancelPendingFinish = () => {
|
|
270
|
+
if (finishTimer != null) {
|
|
271
|
+
clearTimeout(finishTimer);
|
|
272
|
+
finishTimer = undefined;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const schedulePendingFinish = () => {
|
|
277
|
+
if (finished || finishTimer != null) return;
|
|
278
|
+
finishTimer = setTimeout(() => {
|
|
279
|
+
finishTimer = undefined;
|
|
280
|
+
finish();
|
|
281
|
+
}, finishGraceMs);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const onTurnActivity = () => {
|
|
285
|
+
openTurn = true;
|
|
286
|
+
trailingSilenceMs = 0;
|
|
287
|
+
cancelPendingFinish();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
cleanup = (closeCode?: number) => {
|
|
291
|
+
cancelPendingFinish();
|
|
292
|
+
if (audioReader != null) {
|
|
293
|
+
void audioReader.cancel().catch(() => {});
|
|
294
|
+
} else {
|
|
295
|
+
// pre-open failure or abort: cancel the caller's audio stream so an
|
|
296
|
+
// upstream producer piping into it does not hang:
|
|
297
|
+
void audio.cancel().catch(() => {});
|
|
298
|
+
}
|
|
299
|
+
connection?.close(closeCode);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const finishWithError = (error: unknown) => {
|
|
303
|
+
if (finished) return;
|
|
304
|
+
finished = true;
|
|
305
|
+
cleanup();
|
|
306
|
+
controller.error(error);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const finish = () => {
|
|
310
|
+
if (finished) return;
|
|
311
|
+
if (sourceTurnBuffer !== '' || translationTurnBuffer !== '') {
|
|
312
|
+
completeTurn();
|
|
313
|
+
}
|
|
314
|
+
finished = true;
|
|
315
|
+
controller.enqueue({
|
|
316
|
+
type: 'finish',
|
|
317
|
+
sourceText,
|
|
318
|
+
outputText: translationText,
|
|
319
|
+
usage,
|
|
320
|
+
});
|
|
321
|
+
controller.close();
|
|
322
|
+
cleanup(1000);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const completeTurn = () => {
|
|
326
|
+
if (sourceTurnBuffer !== '') {
|
|
327
|
+
controller.enqueue({
|
|
328
|
+
type: 'source-transcript-final',
|
|
329
|
+
id: itemId(),
|
|
330
|
+
text: sourceTurnBuffer,
|
|
331
|
+
});
|
|
332
|
+
sourceText += sourceTurnBuffer;
|
|
333
|
+
sourceTurnBuffer = '';
|
|
334
|
+
}
|
|
335
|
+
if (translationTurnBuffer !== '') {
|
|
336
|
+
controller.enqueue({
|
|
337
|
+
type: 'output-text-final',
|
|
338
|
+
id: itemId(),
|
|
339
|
+
text: translationTurnBuffer,
|
|
340
|
+
});
|
|
341
|
+
translationText += translationTurnBuffer;
|
|
342
|
+
translationTurnBuffer = '';
|
|
343
|
+
}
|
|
344
|
+
turnCounter++;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const sendAudio = async (socket: WebSocketLike) => {
|
|
348
|
+
audioReader = audio.getReader();
|
|
349
|
+
try {
|
|
350
|
+
while (true) {
|
|
351
|
+
const { done, value } = await audioReader.read();
|
|
352
|
+
if (done || finished) break;
|
|
353
|
+
socket.send(
|
|
354
|
+
JSON.stringify({
|
|
355
|
+
realtimeInput: {
|
|
356
|
+
audio: {
|
|
357
|
+
data: convertToBase64(value),
|
|
358
|
+
mimeType: `audio/pcm;rate=${inputAudioRate}`,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
// backpressure: pause reads while the socket buffer is full
|
|
364
|
+
await waitForWebSocketBufferDrain(socket);
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
audioReader.releaseLock();
|
|
368
|
+
// unlocked again: cleanup must cancel `audio`, not the reader
|
|
369
|
+
audioReader = undefined;
|
|
370
|
+
}
|
|
371
|
+
if (!finished) {
|
|
372
|
+
socket.send(
|
|
373
|
+
JSON.stringify({ realtimeInput: { audioStreamEnd: true } }),
|
|
374
|
+
);
|
|
375
|
+
audioEnded = true;
|
|
376
|
+
// a turnComplete already received after the final audio chunk
|
|
377
|
+
// satisfies the finish condition:
|
|
378
|
+
if (sawTurnComplete && !openTurn) {
|
|
379
|
+
schedulePendingFinish();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
connection = connectToWebSocket({
|
|
385
|
+
url,
|
|
386
|
+
headers,
|
|
387
|
+
webSocket,
|
|
388
|
+
abortSignal,
|
|
389
|
+
onAbort: finishWithError,
|
|
390
|
+
onProcessingError: finishWithError,
|
|
391
|
+
onOpen: socket => {
|
|
392
|
+
controller.enqueue({ type: 'stream-start', warnings });
|
|
393
|
+
socket.send(JSON.stringify({ setup }));
|
|
394
|
+
// audio may only be sent after the server acknowledged the setup:
|
|
395
|
+
void setupComplete
|
|
396
|
+
.then(() => (finished ? undefined : sendAudio(socket)))
|
|
397
|
+
.catch(finishWithError);
|
|
398
|
+
},
|
|
399
|
+
onMessageText: async text => {
|
|
400
|
+
if (finished) return;
|
|
401
|
+
const parsed = await safeParseJSON({ text });
|
|
402
|
+
if (!parsed.success) return;
|
|
403
|
+
const message = parsed.value as GoogleLiveServerMessage;
|
|
404
|
+
|
|
405
|
+
if (includeRawChunks) {
|
|
406
|
+
controller.enqueue({ type: 'raw', rawValue: message });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (message.setupComplete != null) {
|
|
410
|
+
resolveSetupComplete();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (message.usageMetadata != null) {
|
|
414
|
+
usage = accumulateGoogleLiveUsage(usage, message.usageMetadata);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (message.error != null) {
|
|
418
|
+
finishWithError(
|
|
419
|
+
new Error(message.error.message ?? 'Google Live API error'),
|
|
420
|
+
);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const inputTranscriptionText =
|
|
425
|
+
message.serverContent?.inputTranscription?.text ??
|
|
426
|
+
message.inputTranscription?.text;
|
|
427
|
+
if (inputTranscriptionText) {
|
|
428
|
+
onTurnActivity();
|
|
429
|
+
sourceTurnBuffer += inputTranscriptionText;
|
|
430
|
+
controller.enqueue({
|
|
431
|
+
type: 'source-transcript-delta',
|
|
432
|
+
id: itemId(),
|
|
433
|
+
delta: inputTranscriptionText,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const serverContent = message.serverContent;
|
|
438
|
+
if (serverContent == null) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const part of serverContent.modelTurn?.parts ?? []) {
|
|
443
|
+
if (part.inlineData?.data) {
|
|
444
|
+
controller.enqueue({
|
|
445
|
+
type: 'audio',
|
|
446
|
+
id: itemId(),
|
|
447
|
+
audio: part.inlineData.data,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const silenceDurationMs = getPcm16SilenceDurationMs(
|
|
451
|
+
part.inlineData.data,
|
|
452
|
+
);
|
|
453
|
+
if (audioEnded && silenceDurationMs != null) {
|
|
454
|
+
trailingSilenceMs += silenceDurationMs;
|
|
455
|
+
if (trailingSilenceMs >= finishGraceMs) {
|
|
456
|
+
finish();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
onTurnActivity();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (serverContent.outputTranscription?.text) {
|
|
466
|
+
onTurnActivity();
|
|
467
|
+
translationTurnBuffer += serverContent.outputTranscription.text;
|
|
468
|
+
controller.enqueue({
|
|
469
|
+
type: 'output-text-delta',
|
|
470
|
+
id: itemId(),
|
|
471
|
+
delta: serverContent.outputTranscription.text,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (serverContent.turnComplete) {
|
|
476
|
+
completeTurn();
|
|
477
|
+
openTurn = false;
|
|
478
|
+
sawTurnComplete = true;
|
|
479
|
+
if (audioEnded) {
|
|
480
|
+
schedulePendingFinish();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
onSocketError: () => {
|
|
485
|
+
finishWithError(new Error('Google Live translation error'));
|
|
486
|
+
},
|
|
487
|
+
onClose: ({ code, reason }) => {
|
|
488
|
+
if (finished) return;
|
|
489
|
+
// a close while a finish is pending confirms that no further turn
|
|
490
|
+
// activity follows:
|
|
491
|
+
if (finishTimer != null) {
|
|
492
|
+
finish();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// a close before the finish condition was reached is an abnormal
|
|
496
|
+
// termination: surface the close diagnostics
|
|
497
|
+
finishWithError(
|
|
498
|
+
new Error(
|
|
499
|
+
`Google Live translation WebSocket closed unexpectedly before finishing` +
|
|
500
|
+
` (code ${code ?? 'unknown'}${reason ? `, reason: ${reason}` : ''}).`,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
cancel: () => {
|
|
508
|
+
if (finished) return;
|
|
509
|
+
finished = true;
|
|
510
|
+
cleanup();
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function accumulateGoogleLiveUsage(
|
|
516
|
+
usage: SpeechTranslationModelV4Usage | undefined,
|
|
517
|
+
usageMetadata: {
|
|
518
|
+
promptTokensDetails?: GoogleLiveTokensDetail[];
|
|
519
|
+
responseTokensDetails?: GoogleLiveTokensDetail[];
|
|
520
|
+
},
|
|
521
|
+
): SpeechTranslationModelV4Usage | undefined {
|
|
522
|
+
let inputAudioTokens = usage?.inputAudioTokens;
|
|
523
|
+
let outputAudioTokens = usage?.outputAudioTokens;
|
|
524
|
+
|
|
525
|
+
// Live Translation emits periodic usage deltas. Its TEXT prompt detail is
|
|
526
|
+
// internal translation context (the public input is audio-only), so only
|
|
527
|
+
// aggregate the billable input/output audio modalities.
|
|
528
|
+
for (const detail of usageMetadata.promptTokensDetails ?? []) {
|
|
529
|
+
if (detail.modality === 'AUDIO' && detail.tokenCount != null) {
|
|
530
|
+
inputAudioTokens = (inputAudioTokens ?? 0) + detail.tokenCount;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (const detail of usageMetadata.responseTokensDetails ?? []) {
|
|
535
|
+
if (detail.modality === 'AUDIO' && detail.tokenCount != null) {
|
|
536
|
+
outputAudioTokens = (outputAudioTokens ?? 0) + detail.tokenCount;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (inputAudioTokens == null && outputAudioTokens == null) {
|
|
541
|
+
return usage;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
...usage,
|
|
546
|
+
...(inputAudioTokens != null ? { inputAudioTokens } : {}),
|
|
547
|
+
...(outputAudioTokens != null ? { outputAudioTokens } : {}),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function getPcm16SilenceDurationMs(audio: string): number | undefined {
|
|
552
|
+
let bytes: Uint8Array;
|
|
553
|
+
try {
|
|
554
|
+
bytes = convertBase64ToUint8Array(audio);
|
|
555
|
+
} catch {
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (bytes.byteLength < 2) {
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
564
|
+
const sampleCount = Math.floor(bytes.byteLength / 2);
|
|
565
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
566
|
+
if (Math.abs(view.getInt16(i * 2, true)) > pcm16SilenceAmplitudeThreshold) {
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return (sampleCount / googleLiveOutputAudioRate) * 1000;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function buildGoogleLiveTranslationSetup({
|
|
575
|
+
modelId,
|
|
576
|
+
targetLanguage,
|
|
577
|
+
providerOptions,
|
|
578
|
+
}: {
|
|
579
|
+
modelId: string;
|
|
580
|
+
targetLanguage: string;
|
|
581
|
+
providerOptions: GoogleTranslationModelOptions | undefined;
|
|
582
|
+
}) {
|
|
583
|
+
return {
|
|
584
|
+
model: getModelPath(modelId),
|
|
585
|
+
generationConfig: {
|
|
586
|
+
responseModalities: ['AUDIO'],
|
|
587
|
+
translationConfig: {
|
|
588
|
+
targetLanguageCode: targetLanguage,
|
|
589
|
+
...(providerOptions?.echoTargetLanguage != null
|
|
590
|
+
? { echoTargetLanguage: providerOptions.echoTargetLanguage }
|
|
591
|
+
: {}),
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
inputAudioTranscription: {},
|
|
595
|
+
outputAudioTranscription: {},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function validateGoogleTranslationInputAudioFormat(
|
|
600
|
+
inputAudioFormat: SpeechTranslationModelV4StreamOptions['inputAudioFormat'],
|
|
601
|
+
) {
|
|
602
|
+
if (
|
|
603
|
+
inputAudioFormat.type !== 'audio/pcm' ||
|
|
604
|
+
(inputAudioFormat.rate != null && inputAudioFormat.rate !== 16000)
|
|
605
|
+
) {
|
|
606
|
+
throw new InvalidArgumentError({
|
|
607
|
+
argument: 'inputAudioFormat',
|
|
608
|
+
message:
|
|
609
|
+
'The Gemini Live translation API only supports 16kHz 16-bit PCM input audio.',
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|