UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

558 lines (512 loc) • 17.6 kB
import { UnsupportedFunctionalityError, type Experimental_SpeechModelV4StreamPart, type Experimental_SpeechModelV4StreamTextPart, type JSONObject, type SharedV4AudioFormat, } from '@ai-sdk/provider'; import { convertAsyncIteratorToReadableStream, convertBase64ToUint8Array, DelayedPromise, withUserAgentSuffix, type ProviderOptions, } from '@ai-sdk/provider-utils'; import { NoSpeechGeneratedError } from '../error/no-speech-generated-error'; import { StreamProviderError } from '../error/stream-provider-error'; import { logWarnings } from '../logger/log-warnings'; import { resolveSpeechModel } from '../model/resolve-model'; import type { SpeechModel } from '../types/speech-model'; import type { SpeechModelResponseMetadata } from '../types/speech-model-response-metadata'; import type { Warning } from '../types/warning'; import { asAsyncIterableStream } from '../util/async-iterable-stream'; import { mergeAbortSignals } from '../util/merge-abort-signals'; import { VERSION } from '../version'; import { isStreamingSpeechModel } from './is-streaming-speech-model'; import type { SpeechStreamPart, SpeechStreamUsage, SpeechTextPart, StreamSpeechResult, } from './stream-speech-result'; /** * Streams speech audio using a speech model. * * @param model - The speech model to use. It must support streaming speech. * @param text - The text to convert to speech: a complete string, or a stream of text deltas * (e.g. the text stream of `streamText`) or text parts that is synthesized as it arrives. * A text part can set its own `voice` on providers that support several voices per stream. * @param voice - The voice to use for speech generation. * @param outputFormat - The format of the output audio chunks, e.g. `{ type: 'audio/pcm', rate: 24000 }`. * @param instructions - Instructions for the speech generation e.g. "Speak in a slow and steady tone". * @param speed - The speed of the speech generation. * @param language - The language for speech generation (ISO 639-1 code e.g. "en", "es", "fr") or "auto" for automatic detection. * @param providerOptions - Additional provider-specific options. * @param abortSignal - An optional abort signal that can be used to cancel the call. * @param headers - Additional HTTP/WebSocket headers to send when supported by the provider. * @param includeRawChunks - When true, raw provider chunks are included in `fullStream`. * @param onError - Called for each provider error part; defaults to `console.error`. * * @returns A result object with the audio stream and the final usage and metadata. */ export function streamSpeech({ model, text, voice, outputFormat, instructions, speed, language, providerOptions = {}, abortSignal, headers, includeRawChunks, onError = ({ error }) => { console.error(error); }, _internal: { currentDate = () => new Date() } = {}, }: { /** * The speech model to use. */ model: SpeechModel; /** * The text to convert to speech: a complete string, or an array, iterable, * or stream of text deltas and/or text parts. */ text: | string | Iterable<string | SpeechTextPart> | AsyncIterable<string | SpeechTextPart> | ReadableStream<string | SpeechTextPart>; /** * The voice to use for speech generation. */ voice?: string; /** * The desired format of the output audio chunks, e.g. * `{ type: 'audio/pcm', rate: 24000 }` or `{ type: 'audio/mpeg' }`. * Supported formats are provider-specific. */ outputFormat?: SharedV4AudioFormat; /** * Instructions for the speech generation e.g. "Speak in a slow and steady tone". */ instructions?: string; /** * The speed of the speech generation. */ speed?: number; /** * The language for speech generation. This should be an ISO 639-1 language code (e.g. "en", "es", "fr") * or "auto" for automatic language detection. Provider support varies. */ language?: string; /** * Additional provider-specific options. */ providerOptions?: ProviderOptions; /** * Abort signal. */ abortSignal?: AbortSignal; /** * Additional headers to include in the request, if supported by the provider. */ headers?: Record<string, string>; /** * When true, providers should include raw provider chunks in the stream. */ includeRawChunks?: boolean; /** * Called for each error part the provider reports. Error parts are not * fatal: `fullStream` includes them and `audioStream` skips them. The * callback is not awaited, and errors it throws are ignored. * Defaults to logging the error with `console.error`. */ onError?: (event: { error: unknown }) => void | PromiseLike<void>; /** * Internal test hooks. */ _internal?: { currentDate?: () => Date; }; }): StreamSpeechResult { const resolvedModel = resolveSpeechModel(model); if (!resolvedModel) { throw new Error('Model could not be resolved'); } if (!isStreamingSpeechModel(resolvedModel)) { throw new UnsupportedFunctionalityError({ functionality: 'streaming speech', message: `The ${resolvedModel.provider} model "${resolvedModel.modelId}" does not support streaming speech.` + (typeof model === 'string' ? ' String model IDs resolve through the global provider (AI Gateway by default).' + ' If that provider does not support streaming speech, pass a provider model' + " instance instead (e.g. elevenLabs.speech('eleven_flash_v2_5'))" + ' or upgrade @ai-sdk/gateway to a version with streaming speech support.' : ''), }); } const textStream = toTextStream(text); const headersWithUserAgent = withUserAgentSuffix( headers ?? {}, `ai/${VERSION}`, ); const usagePromise = new DelayedPromise<SpeechStreamUsage>(); const warningsPromise = new DelayedPromise<Array<Warning>>(); const responsesPromise = new DelayedPromise< Array<SpeechModelResponseMetadata> >(); const providerMetadataPromise = new DelayedPromise< Record<string, JSONObject> >(); const rejectPendingPromises = (error: unknown) => { for (const promise of [ usagePromise, warningsPromise, responsesPromise, providerMetadataPromise, ]) { if (promise.isPending()) { promise.reject(error); } } }; const startedAt = currentDate(); let response: SpeechModelResponseMetadata | undefined; const currentResponseMetadata = () => response ?? { timestamp: startedAt, modelId: resolvedModel.modelId }; const resolveWarnings = (warnings: Array<Warning>) => { warningsPromise.resolve(warnings); logWarnings({ warnings, provider: resolvedModel.provider, model: resolvedModel.modelId, }); }; // Cancelling the output stream aborts the model pipe with a defined reason // (an undefined reason surfaces as an unhandled rejection on newer Node.js). const pipeAbortController = new AbortController(); let hasAudio = false; let finished = false; // Kept so a failed stream reports the provider error that caused it. let lastError: unknown; // A stream that ends without a finish part: when nothing was heard it is a // no-speech failure; after audio it surfaces the provider's error instead. const incompleteStreamError = () => hasAudio ? (lastError ?? new StreamProviderError({ message: 'The speech stream ended before it finished.', })) : new NoSpeechGeneratedError({ responses: [currentResponseMetadata()], cause: lastError, }); // `Transformer.cancel` is supported at runtime but missing from the // ambient `Transformer` type, so widen it here. const transformer: Transformer< Experimental_SpeechModelV4StreamPart, SpeechStreamPart > & { cancel?: (reason?: unknown) => void } = { transform(value, controller) { switch (value.type) { case 'stream-start': { resolveWarnings(value.warnings); break; } case 'response-metadata': { response = { timestamp: value.timestamp ?? currentResponseMetadata().timestamp, modelId: value.modelId ?? currentResponseMetadata().modelId, headers: value.headers ?? response?.headers, body: value.body ?? response?.body, }; break; } case 'audio': { const audio = typeof value.audio === 'string' ? convertBase64ToUint8Array(value.audio) : value.audio; // Empty chunks are dropped unless they carry provider metadata // (e.g. an end-of-turn marker); those never count as audio. if (audio.length === 0 && value.providerMetadata == null) { break; } hasAudio ||= audio.length > 0; controller.enqueue({ type: 'audio', ...(value.id != null && { id: value.id }), audio, audioFormat: value.audioFormat, ...(value.providerMetadata != null && { providerMetadata: value.providerMetadata, }), }); break; } case 'raw': { controller.enqueue(value); break; } case 'error': { lastError = value.error; void Promise.resolve() .then(() => onError({ error: value.error })) .catch(() => {}); controller.enqueue(value); break; } case 'finish': { if (!warningsPromise.isResolved()) { resolveWarnings([]); } if (!hasAudio) { throw new NoSpeechGeneratedError({ responses: [currentResponseMetadata()], cause: lastError, }); } finished = true; usagePromise.resolve(value.usage ?? {}); responsesPromise.resolve([currentResponseMetadata()]); providerMetadataPromise.resolve(value.providerMetadata ?? {}); break; } } }, flush() { if (!finished) { throw incompleteStreamError(); } }, cancel(reason) { const cancelReason = reason ?? new Error('Speech stream was cancelled.'); // Reject now rather than when the provider reacts to the abort, which // may be late while doStream is still pending. rejectPendingPromises(cancelReason); pipeAbortController.abort(cancelReason); }, }; const transform = new TransformStream< Experimental_SpeechModelV4StreamPart, SpeechStreamPart >(transformer); // A user abort settles the result and errors the output immediately, even // while doStream is pending or the provider ignores the signal. if (abortSignal != null) { const onAbort = () => { const reason = abortSignal.reason; rejectPendingPromises(reason); pipeAbortController.abort(reason); // Rejects when the pipe already holds the writable; the pipe aborts it. transform.writable.abort(reason).catch(() => {}); }; if (abortSignal.aborted) { onAbort(); } else { abortSignal.addEventListener('abort', onAbort, { once: true }); } } // Piping (instead of an eager read loop) preserves consumer backpressure // and propagates cancellation of the output stream to the model stream. void (async () => { const result = await resolvedModel.doStream({ text: textStream, voice, outputFormat, instructions, speed, language, providerOptions, // merged so cancelling the output stream also aborts a pending doStream abortSignal: mergeAbortSignals(abortSignal, pipeAbortController.signal), headers: headersWithUserAgent, includeRawChunks, }); response = { timestamp: result.response?.timestamp ?? startedAt, modelId: result.response?.modelId ?? resolvedModel.modelId, headers: result.response?.headers, body: result.response?.body, }; await result.stream.pipeTo(transform.writable, { signal: pipeAbortController.signal, }); })().catch(error => { const reason = error ?? new Error('Speech stream was cancelled or errored.'); rejectPendingPromises(reason); // When doStream rejects before the model took a reader, nothing owns the // text stream yet, so cancel it here; otherwise the model's cleanup does. textStream.cancel(reason).catch(() => {}); transform.writable.abort(reason).catch(() => { // the writable is already errored when the model stream failed mid-pipe }); }); // Speech streams can be unbounded (e.g. text from a long-running LLM // stream), so the output has exactly one owner and is never buffered. let streamOwner: | 'unclaimed' | 'audio-stream' | 'full-stream' | 'result-promises' = 'unclaimed'; function consumeStream() { if (streamOwner !== 'unclaimed') { return; } streamOwner = 'result-promises'; const reader = transform.readable.getReader(); void (async () => { while (!(await reader.read()).done) { // drain; results surface via the promises } })().catch(() => { // stream errors reject the promises via the pipe handler above }); } function claimStream(owner: 'audio-stream' | 'full-stream') { if (streamOwner !== 'unclaimed') { throw new Error( streamOwner === 'result-promises' ? `${owner === 'full-stream' ? 'fullStream' : 'audioStream'} cannot be accessed after a result promise.` : 'Only one of audioStream or fullStream can be accessed, and only once.', ); } streamOwner = owner; return transform.readable; } return { get usage() { consumeStream(); return usagePromise.promise; }, get warnings() { consumeStream(); return warningsPromise.promise; }, get responses() { consumeStream(); return responsesPromise.promise; }, get providerMetadata() { consumeStream(); return providerMetadataPromise.promise; }, get fullStream() { return asAsyncIterableStream(claimStream('full-stream')); }, get audioStream() { return asAsyncIterableStream( createAudioByteStream(claimStream('audio-stream')), ); }, }; } /** * Projects speech parts to audio bytes with a pull-based stream rather than a * second `pipeThrough`, so an early consumer cancel cancels the part stream * directly instead of surfacing an unhandled rejection from a chained pipe. */ function createAudioByteStream( parts: ReadableStream<SpeechStreamPart>, ): ReadableStream<Uint8Array> { let reader: ReadableStreamDefaultReader<SpeechStreamPart> | undefined; return new ReadableStream<Uint8Array>({ async pull(controller) { reader ??= parts.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) { controller.close(); return; } // Error parts are non-fatal (reported via `onError`), as for the // text stream of `streamText`; only stream failures end the audio. if (value.type === 'audio' && value.audio.length > 0) { controller.enqueue(value.audio); return; } } }, async cancel(reason) { // Aborting the active model pipe can reject the part stream's cancel // promise; result promises already surface that cancellation reason. if (reader != null) { await reader.cancel(reason).catch(() => {}); } else { await parts.cancel(reason).catch(() => {}); } }, }); } function toTextStream( text: | string | Iterable<string | SpeechTextPart> | AsyncIterable<string | SpeechTextPart> | ReadableStream<string | SpeechTextPart>, ): ReadableStream<Experimental_SpeechModelV4StreamTextPart> { if (typeof text === 'string') { return new ReadableStream<Experimental_SpeechModelV4StreamTextPart>({ start(controller) { controller.enqueue({ type: 'text', text }); controller.close(); }, }); } const source = toSourceStream(text); return source.pipeThrough( new TransformStream< string | SpeechTextPart, Experimental_SpeechModelV4StreamTextPart >({ transform(value, controller) { controller.enqueue(toTextPart(value)); }, }), ); } function toSourceStream( text: | Iterable<string | SpeechTextPart> | AsyncIterable<string | SpeechTextPart> | ReadableStream<string | SpeechTextPart>, ): ReadableStream<string | SpeechTextPart> { if (text instanceof ReadableStream) { return text; } if ( typeof (text as Partial<AsyncIterable<unknown>>)?.[Symbol.asyncIterator] === 'function' ) { const iterable = text as AsyncIterable<string | SpeechTextPart>; return convertAsyncIteratorToReadableStream( iterable[Symbol.asyncIterator](), ); } const iterable = text as Iterable<string | SpeechTextPart>; return convertAsyncIteratorToReadableStream( (async function* () { yield* iterable; })(), ); } function toTextPart( value: string | SpeechTextPart, ): Experimental_SpeechModelV4StreamTextPart { if (typeof value === 'string') { return { type: 'text', text: value }; } return { type: 'text', text: value.text, ...(value.voice != null && { voice: value.voice }), ...(value.providerOptions != null && { providerOptions: value.providerOptions, }), }; }