@ai-sdk/google
Version:
424 lines (387 loc) • 13.1 kB
text/typescript
import {
InvalidArgumentError,
type SpeechModelV4,
type SharedV4Warning,
} from '@ai-sdk/provider';
import {
combineHeaders,
convertBase64ToUint8Array,
createJsonResponseHandler,
parseProviderOptions,
postJsonToApi,
resolve,
serializeModelOptions,
WORKFLOW_DESERIALIZE,
WORKFLOW_SERIALIZE,
type FetchFunction,
type Resolvable,
} from '@ai-sdk/provider-utils';
import { googleFailedResponseHandler } from './google-error';
import { googleSpeechResponseSchema } from './google-speech-api';
import { getGoogleSpeechInput } from './google-speech-input';
import {
googleSpeechProviderOptionsSchema,
type GoogleSpeechModelId,
type GoogleSpeechModelOptions,
} from './google-speech-model-options';
interface GoogleSpeechModelConfig {
provider: string;
baseURL: string;
headers?: Resolvable<Record<string, string | undefined>>;
fetch?: FetchFunction;
_internal?: {
currentDate?: () => Date;
};
}
const DEFAULT_VOICE = 'Kore';
// Gemini TTS returns raw PCM at 24kHz when the response does not specify a rate.
const DEFAULT_SAMPLE_RATE = 24000;
export class GoogleSpeechModel implements SpeechModelV4 {
readonly specificationVersion = 'v4';
static [WORKFLOW_SERIALIZE](model: GoogleSpeechModel) {
return serializeModelOptions({
modelId: model.modelId,
config: model.config,
});
}
static [WORKFLOW_DESERIALIZE](options: {
modelId: GoogleSpeechModelId;
config: GoogleSpeechModelConfig;
}) {
return new GoogleSpeechModel(options.modelId, options.config);
}
get provider(): string {
return this.config.provider;
}
constructor(
readonly modelId: GoogleSpeechModelId,
private readonly config: GoogleSpeechModelConfig,
) {}
private async getArgs({
text,
voice = DEFAULT_VOICE,
outputFormat,
instructions,
speed,
language,
providerOptions,
}: Parameters<SpeechModelV4['doGenerate']>[0]) {
const warnings: SharedV4Warning[] = [];
// Names to look up in providerOptions. The Vertex provider exposes these
// under `googleVertex`/`vertex` (matching the Google Vertex language model),
// while every other Google provider uses `google`.
const providerOptionsNames: readonly string[] =
this.config.provider.includes('vertex')
? (['googleVertex', 'vertex'] as const)
: (['google'] as const);
let googleOptions: GoogleSpeechModelOptions | undefined;
for (const name of providerOptionsNames) {
googleOptions = await parseProviderOptions({
provider: name,
providerOptions,
schema: googleSpeechProviderOptionsSchema,
});
if (googleOptions != null) {
break;
}
}
// Cross-namespace fallback: a Vertex provider may receive options under the
// `google` key (e.g. via the AI Gateway).
if (googleOptions == null && !providerOptionsNames.includes('google')) {
googleOptions = await parseProviderOptions({
provider: 'google',
providerOptions,
schema: googleSpeechProviderOptionsSchema,
});
}
// Older Gemini families require prompt-based directions. Default newer and
// custom model IDs to structured speech without enumerating their aliases.
const usesStructuredSpeech =
!this.modelId.startsWith('gemini-2.5-') &&
!this.modelId.startsWith('gemini-3.1-');
const input = getGoogleSpeechInput({
text,
voice,
providerOptions: { google: googleOptions },
});
if (input.usesCustomVoice) {
throw new InvalidArgumentError({
argument: 'voice',
message:
'Custom voices are not supported. Use a prebuilt voice instead.',
});
}
// Multi-speaker (provider option) takes precedence over the single voice.
const multiSpeakerVoiceConfig = googleOptions?.multiSpeakerVoiceConfig;
const languageCode = language === 'auto' ? undefined : language;
const speechConfig = multiSpeakerVoiceConfig
? {
multiSpeakerVoiceConfig,
...(languageCode != null && { languageCode }),
}
: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } },
...(languageCode != null && { languageCode }),
};
// Older models expect directions in the prompt. Prepending them to a
// labelled multi-speaker transcript would break speaker parsing.
let promptText = text;
if (instructions != null && !usesStructuredSpeech) {
if (multiSpeakerVoiceConfig) {
warnings.push({
type: 'unsupported',
feature: 'instructions',
details:
'Google Gemini TTS ignores `instructions` when `multiSpeakerVoiceConfig` is set, ' +
'because prepending them would break multi-speaker transcript parsing.',
});
} else {
promptText = `${instructions}: ${text}`;
}
}
let parts: Array<{
text: string;
speechMetadata?: { speaker?: string; style?: string };
}> = [{ text: promptText }];
if (usesStructuredSpeech) {
if (googleOptions?.turns && googleOptions.speechMetadata) {
throw new InvalidArgumentError({
argument: 'providerOptions',
message: 'Set speechMetadata on each turn when using turns.',
});
}
if (googleOptions?.turns && text !== '') {
warnings.push({
type: 'unsupported',
feature: 'text',
details: 'Google TTS turns replace the top-level text.',
});
}
parts = (
googleOptions?.turns ?? [
{ text, speechMetadata: googleOptions?.speechMetadata },
]
).map(part => {
const style = part.speechMetadata?.style ?? instructions;
const speaker = part.speechMetadata?.speaker;
if (
multiSpeakerVoiceConfig &&
!multiSpeakerVoiceConfig.speakerVoiceConfigs.some(
config => config.speaker === speaker,
)
) {
throw new InvalidArgumentError({
argument: 'speechMetadata.speaker',
message:
'Every multi-speaker turn must specify a speechMetadata.speaker matching a configured speaker.',
});
}
return {
text: part.text,
...(style != null || speaker != null
? { speechMetadata: { style, speaker } }
: {}),
};
});
} else if (googleOptions?.turns || googleOptions?.speechMetadata) {
throw new InvalidArgumentError({
argument: 'providerOptions',
message: 'Structured speech metadata and turns require Gemini 3.8 TTS.',
});
}
if (input.text.length === 0) {
throw new InvalidArgumentError({
argument: 'text',
message: 'Speech input must contain a non-empty transcript.',
});
}
if (speed != null) {
warnings.push({
type: 'unsupported',
feature: 'speed',
details:
'Google Gemini TTS models do not support the `speed` option. It was ignored.',
});
}
const formats: Record<string, string> = usesStructuredSpeech
? {
wav: 'AUDIO_WAV',
'audio/wav': 'AUDIO_WAV',
pcm: 'AUDIO_L16',
'audio/l16': 'AUDIO_L16',
mulaw: 'AUDIO_MULAW',
'audio/mulaw': 'AUDIO_MULAW',
alaw: 'AUDIO_ALAW',
'audio/alaw': 'AUDIO_ALAW',
}
: { wav: 'AUDIO_WAV', pcm: 'AUDIO_L16' };
let resolvedOutputFormat = 'wav';
if (
outputFormat != null &&
Object.prototype.hasOwnProperty.call(formats, outputFormat)
) {
resolvedOutputFormat = outputFormat;
} else if (outputFormat != null) {
warnings.push({
type: 'unsupported',
feature: 'outputFormat',
details: `Unsupported output format: ${outputFormat}. Using wav instead.`,
});
}
const requestBody = {
contents: [{ role: 'user', parts }],
generationConfig: {
responseModalities: ['AUDIO'],
speechConfig,
...(usesStructuredSpeech && outputFormat != null
? {
responseFormat: {
audio: { mimeType: formats[resolvedOutputFormat] },
},
}
: {}),
},
};
return {
requestBody,
warnings,
outputFormat: formats[resolvedOutputFormat],
usesStructuredSpeech,
};
}
async doGenerate(
options: Parameters<SpeechModelV4['doGenerate']>[0],
): Promise<Awaited<ReturnType<SpeechModelV4['doGenerate']>>> {
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
const { requestBody, warnings, outputFormat, usesStructuredSpeech } =
await this.getArgs(options);
const {
value: response,
responseHeaders,
rawValue: rawResponse,
} = await postJsonToApi({
url: `${this.config.baseURL}/models/${this.modelId}:generateContent`,
headers: combineHeaders(
this.config.headers ? await resolve(this.config.headers) : undefined,
options.headers,
),
body: requestBody,
failedResponseHandler: googleFailedResponseHandler,
successfulResponseHandler: createJsonResponseHandler(
googleSpeechResponseSchema,
),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
});
// `generateSpeech` returns a single audio result, and Gemini returns one
// inline audio part per request, so take the first inline-data part.
let base64Audio: string | undefined;
let mimeType: string | undefined;
for (const candidate of response.candidates ?? []) {
for (const part of candidate.content?.parts ?? []) {
if (part.inlineData?.data) {
base64Audio = part.inlineData.data;
mimeType = part.inlineData.mimeType ?? undefined;
break;
}
}
if (base64Audio != null) {
break;
}
}
const sampleRate = parseSampleRate(mimeType) ?? DEFAULT_SAMPLE_RATE;
const bytes =
base64Audio != null
? convertBase64ToUint8Array(base64Audio)
: new Uint8Array(0);
// Older models return PCM, which needs a container for default WAV output.
// Gemini 3.8 returns WAV itself; adding another header corrupts that audio.
const isPcm =
/^audio\/(?:l16|pcm)(?:;|$)/i.test(mimeType ?? '') ||
(mimeType == null && !usesStructuredSpeech);
const audio =
outputFormat === 'AUDIO_WAV' && isPcm && bytes.length > 0
? addWavHeader(bytes, sampleRate)
: bytes;
if (
outputFormat === 'AUDIO_L16' &&
bytes.length > 0 &&
!usesStructuredSpeech
) {
warnings.push({
type: 'unsupported',
feature: 'outputFormat',
details:
`Returning raw PCM audio (signed 16-bit little-endian, mono, ${sampleRate} Hz). ` +
'These bytes have no container header and are not directly playable; ' +
'see providerMetadata.google for the sample rate and mime type.',
});
}
return {
audio,
warnings,
...(response.usageMetadata != null && {
usage: response.usageMetadata,
}),
request: {
body: JSON.stringify(requestBody),
},
response: {
timestamp: currentDate,
modelId: this.modelId,
headers: responseHeaders,
body: rawResponse,
},
providerMetadata: {
google: {
sampleRate,
mimeType: mimeType ?? null,
},
},
};
}
}
/**
* Parses the sample rate from a PCM mime type such as `audio/L16;rate=24000`.
*/
function parseSampleRate(mimeType: string | undefined): number | undefined {
if (mimeType == null) {
return undefined;
}
const match = /rate=(\d+)/.exec(mimeType);
return match ? Number.parseInt(match[1], 10) : undefined;
}
/**
* Wraps raw signed 16-bit little-endian mono PCM in a minimal 44-byte WAV
* (RIFF/WAVE) container so the output is playable and detectable as `audio/wav`.
*/
function addWavHeader(pcm: Uint8Array, sampleRate: number): Uint8Array {
const numChannels = 1;
const bitsPerSample = 16;
const blockAlign = (numChannels * bitsPerSample) / 8;
const byteRate = sampleRate * blockAlign;
const dataSize = pcm.length;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true); // PCM fmt chunk size
view.setUint16(20, 1, true); // audio format = PCM
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataSize, true);
const out = new Uint8Array(buffer);
out.set(pcm, 44);
return out;
}
function writeAscii(view: DataView, offset: number, text: string): void {
for (let i = 0; i < text.length; i++) {
view.setUint8(offset + i, text.charCodeAt(i));
}
}