From a382a9031bf511c80da90f9932bf27ee13cde9c8 Mon Sep 17 00:00:00 2001 From: Carlton Aikins Date: Mon, 6 Jul 2026 15:24:19 -0400 Subject: [PATCH 1/7] feat(provider/cartesia): add Sonic TTS and Ink-Whisper STT provider Introduce @ai-sdk/cartesia with batch speech and transcription support, docs, examples, and model table updates following the ElevenLabs audio provider pattern. Co-authored-by: Cursor --- .github/konsistent.json | 1 + .../docs/03-ai-sdk-core/36-transcription.mdx | 1 + content/docs/03-ai-sdk-core/37-speech.mdx | 4 + .../01-ai-sdk-providers/95-cartesia.mdx | 220 +++++++++++++++ examples/ai-functions/package.json | 1 + .../src/generate-speech/cartesia/basic.ts | 19 ++ .../src/transcribe/cartesia/basic.ts | 18 ++ packages/cartesia/CHANGELOG.md | 1 + packages/cartesia/README.md | 39 +++ packages/cartesia/package.json | 78 +++++ .../__fixtures__/cartesia-transcription.json | 13 + packages/cartesia/src/cartesia-api-types.ts | 18 ++ packages/cartesia/src/cartesia-config.ts | 9 + packages/cartesia/src/cartesia-error.test.ts | 24 ++ packages/cartesia/src/cartesia-error.ts | 13 + packages/cartesia/src/cartesia-provider.ts | 151 ++++++++++ .../cartesia/src/cartesia-speech-api-types.ts | 39 +++ .../src/cartesia-speech-model-options.ts | 23 ++ .../src/cartesia-speech-model.test.ts | 266 ++++++++++++++++++ .../cartesia/src/cartesia-speech-model.ts | 233 +++++++++++++++ .../cartesia/src/cartesia-speech-options.ts | 10 + .../cartesia-transcription-model-options.ts | 13 + .../src/cartesia-transcription-model.test.ts | 151 ++++++++++ .../src/cartesia-transcription-model.ts | 157 +++++++++++ .../src/cartesia-transcription-options.ts | 1 + packages/cartesia/src/index.ts | 15 + packages/cartesia/src/transcript-test.mp3 | Bin 0 -> 40169 bytes packages/cartesia/src/version.ts | 6 + packages/cartesia/tsconfig.build.json | 7 + packages/cartesia/tsconfig.json | 25 ++ packages/cartesia/tsup.config.ts | 16 ++ packages/cartesia/turbo.json | 12 + packages/cartesia/vitest.edge.config.js | 13 + packages/cartesia/vitest.node.config.js | 13 + pnpm-lock.yaml | 31 ++ 35 files changed, 1641 insertions(+) create mode 100644 content/providers/01-ai-sdk-providers/95-cartesia.mdx create mode 100644 examples/ai-functions/src/generate-speech/cartesia/basic.ts create mode 100644 examples/ai-functions/src/transcribe/cartesia/basic.ts create mode 100644 packages/cartesia/CHANGELOG.md create mode 100644 packages/cartesia/README.md create mode 100644 packages/cartesia/package.json create mode 100644 packages/cartesia/src/__fixtures__/cartesia-transcription.json create mode 100644 packages/cartesia/src/cartesia-api-types.ts create mode 100644 packages/cartesia/src/cartesia-config.ts create mode 100644 packages/cartesia/src/cartesia-error.test.ts create mode 100644 packages/cartesia/src/cartesia-error.ts create mode 100644 packages/cartesia/src/cartesia-provider.ts create mode 100644 packages/cartesia/src/cartesia-speech-api-types.ts create mode 100644 packages/cartesia/src/cartesia-speech-model-options.ts create mode 100644 packages/cartesia/src/cartesia-speech-model.test.ts create mode 100644 packages/cartesia/src/cartesia-speech-model.ts create mode 100644 packages/cartesia/src/cartesia-speech-options.ts create mode 100644 packages/cartesia/src/cartesia-transcription-model-options.ts create mode 100644 packages/cartesia/src/cartesia-transcription-model.test.ts create mode 100644 packages/cartesia/src/cartesia-transcription-model.ts create mode 100644 packages/cartesia/src/cartesia-transcription-options.ts create mode 100644 packages/cartesia/src/index.ts create mode 100644 packages/cartesia/src/transcript-test.mp3 create mode 100644 packages/cartesia/src/version.ts create mode 100644 packages/cartesia/tsconfig.build.json create mode 100644 packages/cartesia/tsconfig.json create mode 100644 packages/cartesia/tsup.config.ts create mode 100644 packages/cartesia/turbo.json create mode 100644 packages/cartesia/vitest.edge.config.js create mode 100644 packages/cartesia/vitest.node.config.js diff --git a/.github/konsistent.json b/.github/konsistent.json index 7833564055f1..b406db90d369 100644 --- a/.github/konsistent.json +++ b/.github/konsistent.json @@ -363,6 +363,7 @@ "kebabToPascalMap": { "assemblyai": "AssemblyAI", "bytedance": "ByteDance", + "cartesia": "Cartesia", "deepagents": "DeepAgents", "deepinfra": "DeepInfra", "deepseek": "DeepSeek", diff --git a/content/docs/03-ai-sdk-core/36-transcription.mdx b/content/docs/03-ai-sdk-core/36-transcription.mdx index 04c401e610fc..1afc54bd53b2 100644 --- a/content/docs/03-ai-sdk-core/36-transcription.mdx +++ b/content/docs/03-ai-sdk-core/36-transcription.mdx @@ -306,5 +306,6 @@ try { | [Google Vertex](/providers/ai-sdk-providers/google-vertex#transcription-models) | `chirp_3` | | [Google Vertex](/providers/ai-sdk-providers/google-vertex#transcription-models) | `telephony` | | [xAI](/providers/ai-sdk-providers/xai#transcription-models) | `default` | +| [Cartesia](/providers/ai-sdk-providers/cartesia#transcription-models) | `ink-whisper` | Above are a small subset of the transcription models supported by the AI SDK providers. For more, see the respective provider documentation. diff --git a/content/docs/03-ai-sdk-core/37-speech.mdx b/content/docs/03-ai-sdk-core/37-speech.mdx index 0cb50dbb9ca9..0a6ce153ee31 100644 --- a/content/docs/03-ai-sdk-core/37-speech.mdx +++ b/content/docs/03-ai-sdk-core/37-speech.mdx @@ -168,5 +168,9 @@ try { | [Google Vertex](/providers/ai-sdk-providers/google-vertex#speech-models) | `gemini-2.5-flash-lite-preview-tts` | | [Google Vertex](/providers/ai-sdk-providers/google-vertex#speech-models) | `gemini-3.1-flash-tts-preview` | | [xAI](/providers/ai-sdk-providers/xai#speech-models) | `default` | +| [Cartesia](/providers/ai-sdk-providers/cartesia#speech-models) | `sonic-3.5` | +| [Cartesia](/providers/ai-sdk-providers/cartesia#speech-models) | `sonic-3` | +| [Cartesia](/providers/ai-sdk-providers/cartesia#speech-models) | `sonic-2` | +| [Cartesia](/providers/ai-sdk-providers/cartesia#speech-models) | `sonic-turbo` | Above are a small subset of the speech models supported by the AI SDK providers. For more, see the respective provider documentation. diff --git a/content/providers/01-ai-sdk-providers/95-cartesia.mdx b/content/providers/01-ai-sdk-providers/95-cartesia.mdx new file mode 100644 index 000000000000..76f9b056a66d --- /dev/null +++ b/content/providers/01-ai-sdk-providers/95-cartesia.mdx @@ -0,0 +1,220 @@ +--- +title: Cartesia +description: Learn how to use the Cartesia provider for the AI SDK. +--- + +# Cartesia Provider + +The [Cartesia](https://cartesia.ai/) provider contains language model support for the Cartesia speech generation (Sonic) and transcription (Ink) APIs. + +## Setup + +The Cartesia provider is available in the `@ai-sdk/cartesia` module. You can install it with + + + + + + + + + + + + + + + + + +## Provider Instance + +You can import the default provider instance `cartesia` from `@ai-sdk/cartesia`: + +```ts +import { cartesia } from '@ai-sdk/cartesia'; +``` + +If you need a customized setup, you can import `createCartesia` from `@ai-sdk/cartesia` and create a provider instance with your settings: + +```ts +import { createCartesia } from '@ai-sdk/cartesia'; + +const cartesia = createCartesia({ + // custom settings, e.g. + fetch: customFetch, +}); +``` + +You can use the following optional settings to customize the Cartesia provider instance: + +- **apiKey** _string_ + + API key that is being sent using the `Authorization` header. + It defaults to the `CARTESIA_API_KEY` environment variable. + +- **version** _string_ + + The Cartesia API version to use (sent via the `Cartesia-Version` header). + +- **headers** _Record<string,string>_ + + Custom headers to include in the requests. + +- **fetch** _(input: RequestInfo, init?: RequestInit) => Promise<Response>_ + + Custom [fetch](https://developer.mozilla.org/en-US/docs/Web/API/fetch) implementation. + Defaults to the global `fetch` function. + You can use it as a middleware to intercept requests, + or to provide a custom fetch implementation for e.g. testing. + +## Speech Models + +You can create models that call the [Cartesia text-to-speech API](https://docs.cartesia.ai/api-reference/tts/bytes) +using the `.speech()` factory method. + +The first argument is the model id, e.g. `sonic-3.5`. + +```ts +const model = cartesia.speech('sonic-3.5'); +``` + +You can use the model with the `generateSpeech` function. Cartesia requires a `voice` id: + +```ts +import { generateSpeech } from 'ai'; +import { cartesia } from '@ai-sdk/cartesia'; + +const result = await generateSpeech({ + model: cartesia.speech('sonic-3.5'), + text: 'Hello, world!', + voice: '694f9389-aac1-45b6-b726-9d9369183238', +}); +``` + +You can also pass additional provider-specific options using the `providerOptions` argument: + +```ts highlight="7-12" +import { generateSpeech } from 'ai'; +import { cartesia, type CartesiaSpeechModelOptions } from '@ai-sdk/cartesia'; + +const result = await generateSpeech({ + model: cartesia.speech('sonic-3.5'), + text: 'Hello, world!', + voice: '694f9389-aac1-45b6-b726-9d9369183238', + providerOptions: { + cartesia: { + container: 'wav', + encoding: 'pcm_s16le', + sampleRate: 24000, + } satisfies CartesiaSpeechModelOptions, + }, +}); +``` + +The following provider options are available: + +- **container** _string_ + + Container format for the output audio. + Supported values: `'raw'`, `'wav'`, `'mp3'`. + Optional. + +- **encoding** _string_ + + Encoding type for the audio output. + Supported values: `'pcm_f32le'`, `'pcm_s16le'`, `'pcm_mulaw'`, `'pcm_alaw'`. + Optional. + +- **sampleRate** _number_ + + Sample rate for the output audio in Hz (e.g. `8000`, `16000`, `22050`, `24000`, `44100`, `48000`). + Optional. + +- **bitRate** _number_ + + Bitrate for `mp3` output in bits per second (e.g. `32000`, `64000`, `128000`, `192000`). + Optional. + +- **speed** _number_ + + Controls the speed of the generated speech. + Optional. + +- **language** _string_ + + The language to generate speech in (ISO 639-1 code). + Optional. + +### Model Capabilities + +| Model | +| ------------- | +| `sonic-3.5` | +| `sonic-3` | +| `sonic-2` | +| `sonic-turbo` | +| `sonic` | + +## Transcription Models + +You can create models that call the [Cartesia transcription API](https://docs.cartesia.ai/api-reference/stt/transcribe) +using the `.transcription()` factory method. + +The first argument is the model id e.g. `ink-whisper`. + +```ts +const model = cartesia.transcription('ink-whisper'); +``` + +You can use the model with the `transcribe` function: + +```ts +import { transcribe } from 'ai'; +import { cartesia } from '@ai-sdk/cartesia'; +import { readFile } from 'fs/promises'; + +const result = await transcribe({ + model: cartesia.transcription('ink-whisper'), + audio: await readFile('audio.mp3'), +}); +``` + +You can also pass additional provider-specific options using the `providerOptions` argument: + +```ts highlight="6" +import { transcribe } from 'ai'; +import { + cartesia, + type CartesiaTranscriptionModelOptions, +} from '@ai-sdk/cartesia'; +import { readFile } from 'fs/promises'; + +const result = await transcribe({ + model: cartesia.transcription('ink-whisper'), + audio: await readFile('audio.mp3'), + providerOptions: { + cartesia: { + language: 'en', + } satisfies CartesiaTranscriptionModelOptions, + }, +}); +``` + +The following provider options are available: + +- **language** _string_ + + The language of the audio (ISO 639-1 code). If not specified, the language is auto-detected. + Optional. + +- **timestampGranularities** _array of strings_ + + The timestamp granularities to populate. Currently only `'word'` is supported. + Optional. + +### Model Capabilities + +| Model | Transcription | Duration | Segments | Language | +| ------------- | ------------------- | ------------------- | ------------------- | ------------------- | +| `ink-whisper` | | | | | diff --git a/examples/ai-functions/package.json b/examples/ai-functions/package.json index cb0290ccfe9d..d788ac309147 100644 --- a/examples/ai-functions/package.json +++ b/examples/ai-functions/package.json @@ -13,6 +13,7 @@ "@ai-sdk/baseten": "workspace:*", "@ai-sdk/black-forest-labs": "workspace:*", "@ai-sdk/bytedance": "workspace:*", + "@ai-sdk/cartesia": "workspace:*", "@ai-sdk/cerebras": "workspace:*", "@ai-sdk/cohere": "workspace:*", "@ai-sdk/deepgram": "workspace:*", diff --git a/examples/ai-functions/src/generate-speech/cartesia/basic.ts b/examples/ai-functions/src/generate-speech/cartesia/basic.ts new file mode 100644 index 000000000000..01dfe196aa32 --- /dev/null +++ b/examples/ai-functions/src/generate-speech/cartesia/basic.ts @@ -0,0 +1,19 @@ +import { cartesia } from '@ai-sdk/cartesia'; +import { generateSpeech } from 'ai'; +import { saveAudioFile } from '../../lib/save-audio'; +import { run } from '../../lib/run'; + +run(async () => { + const result = await generateSpeech({ + model: cartesia.speech('sonic-3.5'), + text: 'Hello, welcome to Cartesia! This is a test of the text-to-speech API.', + voice: 'a0e99841-438c-4a64-b679-ae501e7d6091', + }); + + console.log('Audio:', result.audio); + console.log('Warnings:', result.warnings); + console.log('Responses:', result.responses); + console.log('Provider Metadata:', result.providerMetadata); + + await saveAudioFile(result.audio); +}); diff --git a/examples/ai-functions/src/transcribe/cartesia/basic.ts b/examples/ai-functions/src/transcribe/cartesia/basic.ts new file mode 100644 index 000000000000..204384e57162 --- /dev/null +++ b/examples/ai-functions/src/transcribe/cartesia/basic.ts @@ -0,0 +1,18 @@ +import { cartesia } from '@ai-sdk/cartesia'; +import { transcribe } from 'ai'; +import { readFile } from 'fs/promises'; +import { run } from '../../lib/run'; + +run(async () => { + const result = await transcribe({ + model: cartesia.transcription('ink-whisper'), + audio: await readFile('data/galileo.mp3'), + }); + + console.log('Text:', result.text); + console.log('Duration:', result.durationInSeconds); + console.log('Language:', result.language); + console.log('Segments:', result.segments); + console.log('Warnings:', result.warnings); + console.log('Responses:', result.responses); +}); diff --git a/packages/cartesia/CHANGELOG.md b/packages/cartesia/CHANGELOG.md new file mode 100644 index 000000000000..cefc3fc02bb6 --- /dev/null +++ b/packages/cartesia/CHANGELOG.md @@ -0,0 +1 @@ +# @ai-sdk/cartesia diff --git a/packages/cartesia/README.md b/packages/cartesia/README.md new file mode 100644 index 000000000000..40a5d64dd323 --- /dev/null +++ b/packages/cartesia/README.md @@ -0,0 +1,39 @@ +# AI SDK - Cartesia Provider + +The **[Cartesia provider](https://ai-sdk.dev/providers/ai-sdk-providers/cartesia)** for the [AI SDK](https://ai-sdk.dev/docs) +contains speech model support for the Cartesia text-to-speech API (Sonic) and transcription model support for the Cartesia speech-to-text API (Ink-Whisper). + +> **Deploying to Vercel?** With Vercel's AI Gateway you can access Cartesia (and hundreds of models from other providers) — no additional packages, API keys, or extra cost. [Get started with AI Gateway](https://vercel.com/ai-gateway). + +## Setup + +The Cartesia provider is available in the `@ai-sdk/cartesia` module. You can install it with + +```bash +npm i @ai-sdk/cartesia +``` + +## Provider Instance + +You can import the default provider instance `cartesia` from `@ai-sdk/cartesia`: + +```ts +import { cartesia } from '@ai-sdk/cartesia'; +``` + +## Example + +```ts +import { cartesia } from '@ai-sdk/cartesia'; +import { experimental_generateSpeech as generateSpeech } from 'ai'; + +const { audio } = await generateSpeech({ + model: cartesia.speech('sonic-2'), + text: 'Hello from the Vercel AI SDK!', + voice: 'a0e99841-438c-4a64-b679-ae501e7d6091', +}); +``` + +## Documentation + +Please check out the **[Cartesia provider documentation](https://ai-sdk.dev/providers/ai-sdk-providers/cartesia)** for more information. diff --git a/packages/cartesia/package.json b/packages/cartesia/package.json new file mode 100644 index 000000000000..c03600349799 --- /dev/null +++ b/packages/cartesia/package.json @@ -0,0 +1,78 @@ +{ + "name": "@ai-sdk/cartesia", + "version": "0.0.0", + "type": "module", + "license": "Apache-2.0", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**/*", + "docs/**/*", + "src", + "!src/**/*.test.ts", + "!src/**/*.test-d.ts", + "!src/**/__snapshots__", + "!src/**/__fixtures__", + "CHANGELOG.md", + "README.md" + ], + "directories": { + "doc": "./docs" + }, + "scripts": { + "build": "tsup --tsconfig tsconfig.build.json", + "build:watch": "tsup --tsconfig tsconfig.build.json --watch", + "clean": "del-cli dist docs", + "prepack": "mkdir -p docs && cp ../../content/providers/01-ai-sdk-providers/95-cartesia.mdx ./docs/", + "postpack": "del-cli docs", + "type-check": "tsc --noEmit", + "test": "pnpm test:node && pnpm test:edge", + "test:update": "pnpm test:node -u", + "test:watch": "vitest --config vitest.node.config.js", + "test:edge": "vitest --config vitest.edge.config.js --run", + "test:node": "vitest --config vitest.node.config.js --run" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "dependencies": { + "@ai-sdk/provider": "workspace:*", + "@ai-sdk/provider-utils": "workspace:*" + }, + "devDependencies": { + "@ai-sdk/test-server": "workspace:*", + "@types/node": "22.19.19", + "@vercel/ai-tsconfig": "workspace:*", + "tsup": "^8.5.1", + "typescript": "5.6.3", + "zod": "3.25.76" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "homepage": "https://ai-sdk.dev/docs", + "repository": { + "type": "git", + "url": "https://github.com/vercel/ai", + "directory": "packages/cartesia" + }, + "bugs": { + "url": "https://github.com/vercel/ai/issues" + }, + "keywords": [ + "ai" + ] +} diff --git a/packages/cartesia/src/__fixtures__/cartesia-transcription.json b/packages/cartesia/src/__fixtures__/cartesia-transcription.json new file mode 100644 index 000000000000..45afafa6750d --- /dev/null +++ b/packages/cartesia/src/__fixtures__/cartesia-transcription.json @@ -0,0 +1,13 @@ +{ + "text": "Hello from the Vercel AI SDK.", + "language": "en", + "duration": 2.479, + "words": [ + { "word": "Hello", "start": 0.199, "end": 0.479 }, + { "word": "from", "start": 0.5, "end": 0.639 }, + { "word": "the", "start": 0.66, "end": 0.759 }, + { "word": "Vercel", "start": 0.759, "end": 1.12 }, + { "word": "AI", "start": 1.2, "end": 1.519 }, + { "word": "SDK.", "start": 1.58, "end": 2.479 } + ] +} diff --git a/packages/cartesia/src/cartesia-api-types.ts b/packages/cartesia/src/cartesia-api-types.ts new file mode 100644 index 000000000000..11ccb27a62f4 --- /dev/null +++ b/packages/cartesia/src/cartesia-api-types.ts @@ -0,0 +1,18 @@ +export type CartesiaTranscriptionAPITypes = { + /** + * The ID of the model to use for transcription. + */ + model: string; + + /** + * The language of the audio (ISO 639-1 code). If not specified, the language + * is auto-detected. + */ + language?: string; + + /** + * The timestamp granularities to populate for this transcription. + * Currently only `word` is supported. + */ + 'timestamp_granularities[]'?: 'word'; +}; diff --git a/packages/cartesia/src/cartesia-config.ts b/packages/cartesia/src/cartesia-config.ts new file mode 100644 index 000000000000..29866093deb1 --- /dev/null +++ b/packages/cartesia/src/cartesia-config.ts @@ -0,0 +1,9 @@ +import type { FetchFunction } from '@ai-sdk/provider-utils'; + +export type CartesiaConfig = { + provider: string; + url: (options: { modelId: string; path: string }) => string; + headers?: () => Record; + fetch?: FetchFunction; + generateId?: () => string; +}; diff --git a/packages/cartesia/src/cartesia-error.test.ts b/packages/cartesia/src/cartesia-error.test.ts new file mode 100644 index 000000000000..ffe18a94d41f --- /dev/null +++ b/packages/cartesia/src/cartesia-error.test.ts @@ -0,0 +1,24 @@ +import { safeParseJSON } from '@ai-sdk/provider-utils'; +import { cartesiaErrorDataSchema } from './cartesia-error'; +import { describe, expect, it } from 'vitest'; + +describe('cartesiaErrorDataSchema', () => { + it('should parse a Cartesia error', async () => { + const error = `{"error":"Invalid API key."}`; + + const result = await safeParseJSON({ + text: error, + schema: cartesiaErrorDataSchema, + }); + + expect(result).toStrictEqual({ + success: true, + value: { + error: 'Invalid API key.', + }, + rawValue: { + error: 'Invalid API key.', + }, + }); + }); +}); diff --git a/packages/cartesia/src/cartesia-error.ts b/packages/cartesia/src/cartesia-error.ts new file mode 100644 index 000000000000..08a6bfef65f2 --- /dev/null +++ b/packages/cartesia/src/cartesia-error.ts @@ -0,0 +1,13 @@ +import { z } from 'zod/v4'; +import { createJsonErrorResponseHandler } from '@ai-sdk/provider-utils'; + +export const cartesiaErrorDataSchema = z.object({ + error: z.string(), +}); + +export type CartesiaErrorData = z.infer; + +export const cartesiaFailedResponseHandler = createJsonErrorResponseHandler({ + errorSchema: cartesiaErrorDataSchema, + errorToMessage: data => data.error, +}); diff --git a/packages/cartesia/src/cartesia-provider.ts b/packages/cartesia/src/cartesia-provider.ts new file mode 100644 index 000000000000..078cd63a9031 --- /dev/null +++ b/packages/cartesia/src/cartesia-provider.ts @@ -0,0 +1,151 @@ +import { + NoSuchModelError, + type TranscriptionModelV4, + type SpeechModelV4, + type ProviderV4, +} from '@ai-sdk/provider'; +import { + loadApiKey, + withUserAgentSuffix, + type FetchFunction, +} from '@ai-sdk/provider-utils'; +import { CartesiaTranscriptionModel } from './cartesia-transcription-model'; +import type { CartesiaTranscriptionModelId } from './cartesia-transcription-options'; +import { CartesiaSpeechModel } from './cartesia-speech-model'; +import type { CartesiaSpeechModelId } from './cartesia-speech-options'; +import { VERSION } from './version'; + +/** + * The Cartesia API version sent with every request via the `Cartesia-Version` + * header. See https://docs.cartesia.ai/api-reference/api-versioning + */ +const CARTESIA_API_VERSION = '2025-04-16'; + +export interface CartesiaProvider extends ProviderV4 { + ( + modelId: CartesiaSpeechModelId, + settings?: {}, + ): { + speech: CartesiaSpeechModel; + }; + + /** + * Creates a model for transcription. + */ + transcription(modelId: CartesiaTranscriptionModelId): TranscriptionModelV4; + + /** + * Creates a model for speech generation. + */ + speech(modelId: CartesiaSpeechModelId): SpeechModelV4; + + /** + * @deprecated Use `embeddingModel` instead. + */ + textEmbeddingModel(modelId: string): never; +} + +export interface CartesiaProviderSettings { + /** + * API key for authenticating requests. + */ + apiKey?: string; + + /** + * The Cartesia API version to use (sent via the `Cartesia-Version` header). + */ + version?: string; + + /** + * Custom headers to include in the requests. + */ + headers?: Record; + + /** + * Custom fetch implementation. You can use it as a middleware to intercept requests, + * or to provide a custom fetch implementation for e.g. testing. + */ + fetch?: FetchFunction; +} + +/** + * Create a Cartesia provider instance. + */ +export function createCartesia( + options: CartesiaProviderSettings = {}, +): CartesiaProvider { + const getHeaders = () => + withUserAgentSuffix( + { + Authorization: `Bearer ${loadApiKey({ + apiKey: options.apiKey, + environmentVariableName: 'CARTESIA_API_KEY', + description: 'Cartesia', + })}`, + 'Cartesia-Version': options.version ?? CARTESIA_API_VERSION, + ...options.headers, + }, + `ai-sdk/cartesia/${VERSION}`, + ); + + const createTranscriptionModel = (modelId: CartesiaTranscriptionModelId) => + new CartesiaTranscriptionModel(modelId, { + provider: `cartesia.transcription`, + url: ({ path }) => `https://api.cartesia.ai${path}`, + headers: getHeaders, + fetch: options.fetch, + }); + + const createSpeechModel = (modelId: CartesiaSpeechModelId) => + new CartesiaSpeechModel(modelId, { + provider: `cartesia.speech`, + url: ({ path }) => `https://api.cartesia.ai${path}`, + headers: getHeaders, + fetch: options.fetch, + }); + + const provider = function (modelId: CartesiaSpeechModelId) { + return { + speech: createSpeechModel(modelId), + }; + }; + + provider.specificationVersion = 'v4' as const; + provider.transcription = createTranscriptionModel; + provider.transcriptionModel = createTranscriptionModel; + provider.speech = createSpeechModel; + provider.speechModel = createSpeechModel; + + // Required ProviderV4 methods that are not supported + provider.languageModel = (modelId: string) => { + throw new NoSuchModelError({ + modelId, + modelType: 'languageModel', + message: 'Cartesia does not provide language models', + }); + }; + + provider.embeddingModel = (modelId: string) => { + throw new NoSuchModelError({ + modelId, + modelType: 'embeddingModel', + message: 'Cartesia does not provide embedding models', + }); + }; + provider.textEmbeddingModel = provider.embeddingModel; + + provider.imageModel = (modelId: string) => { + throw new NoSuchModelError({ + modelId, + modelType: 'imageModel', + message: 'Cartesia does not provide image models', + }); + }; + + return provider as CartesiaProvider; +} + +/** + * Default Cartesia provider instance. + */ +export const cartesia = createCartesia(); diff --git a/packages/cartesia/src/cartesia-speech-api-types.ts b/packages/cartesia/src/cartesia-speech-api-types.ts new file mode 100644 index 000000000000..4ac74306ed47 --- /dev/null +++ b/packages/cartesia/src/cartesia-speech-api-types.ts @@ -0,0 +1,39 @@ +export type CartesiaSpeechAPITypes = { + /** + * The ID of the model to use for the generation. + */ + model_id: string; + + /** + * The transcript to generate speech for. + */ + transcript: string; + + /** + * The voice specifier. Cartesia uses id mode with a voice id. + */ + voice: { + mode: 'id'; + id: string; + }; + + /** + * The language to generate speech in (ISO 639-1 code). + */ + language?: string; + + /** + * The output audio format. + */ + output_format: { + container: string; + encoding: string; + sample_rate: number; + bit_rate?: number; + }; + + /** + * Controls the speed of the generated speech. + */ + speed?: number; +}; diff --git a/packages/cartesia/src/cartesia-speech-model-options.ts b/packages/cartesia/src/cartesia-speech-model-options.ts new file mode 100644 index 000000000000..80a41fd67fd0 --- /dev/null +++ b/packages/cartesia/src/cartesia-speech-model-options.ts @@ -0,0 +1,23 @@ +import { z } from 'zod/v4'; + +// https://docs.cartesia.ai/api-reference/tts/bytes +export const cartesiaSpeechModelOptionsSchema = z.object({ + /** Container format for the output audio (raw, wav, mp3). */ + container: z.enum(['raw', 'wav', 'mp3']).nullish(), + /** Encoding type for the audio output (pcm_f32le, pcm_s16le, pcm_mulaw, pcm_alaw). */ + encoding: z + .enum(['pcm_f32le', 'pcm_s16le', 'pcm_mulaw', 'pcm_alaw']) + .nullish(), + /** Sample rate for the output audio in Hz (e.g. 8000, 16000, 22050, 24000, 44100, 48000). */ + sampleRate: z.number().nullish(), + /** Bitrate for mp3 output in bits per second (e.g. 32000, 64000, 128000, 192000). */ + bitRate: z.number().nullish(), + /** Controls the speed of the generated speech. */ + speed: z.number().nullish(), + /** The language to generate speech in (ISO 639-1 code). */ + language: z.string().nullish(), +}); + +export type CartesiaSpeechModelOptions = z.infer< + typeof cartesiaSpeechModelOptionsSchema +>; diff --git a/packages/cartesia/src/cartesia-speech-model.test.ts b/packages/cartesia/src/cartesia-speech-model.test.ts new file mode 100644 index 000000000000..2017748805c2 --- /dev/null +++ b/packages/cartesia/src/cartesia-speech-model.test.ts @@ -0,0 +1,266 @@ +import { createTestServer } from '@ai-sdk/test-server/with-vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { createCartesia } from './cartesia-provider'; +import { CartesiaSpeechModel } from './cartesia-speech-model'; + +vi.mock('./version', () => ({ + VERSION: '0.0.0-test', +})); + +const provider = createCartesia({ apiKey: 'test-api-key' }); +const model = provider.speech('sonic-3.5'); + +const server = createTestServer({ + 'https://api.cartesia.ai/tts/bytes': {}, +}); + +describe('CartesiaSpeechModel', () => { + function prepareAudioResponse({ + headers, + format = 'mp3', + }: { + headers?: Record; + format?: string; + } = {}) { + const audioBuffer = new Uint8Array(100); // Mock audio data + server.urls['https://api.cartesia.ai/tts/bytes'].response = { + type: 'binary', + headers: { + 'content-type': `audio/${format}`, + ...headers, + }, + body: Buffer.from(audioBuffer), + }; + return audioBuffer; + } + + describe('doGenerate', () => { + it('should generate speech with required parameters', async () => { + prepareAudioResponse(); + + await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + model_id: 'sonic-3.5', + transcript: 'Hello, world!', + voice: { + mode: 'id', + id: 'test-voice-id', + }, + output_format: { + container: 'mp3', + encoding: 'mp3', + sample_rate: 44100, + bit_rate: 128000, + }, + }); + }); + + it('should throw when no voice is provided', async () => { + prepareAudioResponse(); + + await expect( + model.doGenerate({ + text: 'Hello, world!', + }), + ).rejects.toThrow('Cartesia speech models require a `voice` to be set.'); + }); + + it('should map wav output format', async () => { + prepareAudioResponse(); + + await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + outputFormat: 'wav', + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + output_format: { + container: 'wav', + encoding: 'pcm_s16le', + sample_rate: 44100, + }, + }); + }); + + it('should map pcm output format with sample rate suffix', async () => { + prepareAudioResponse(); + + await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + outputFormat: 'pcm_24000', + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + output_format: { + container: 'raw', + encoding: 'pcm_f32le', + sample_rate: 24000, + }, + }); + }); + + it('should handle language parameter', async () => { + prepareAudioResponse(); + + await model.doGenerate({ + text: 'Hola, mundo!', + voice: 'test-voice-id', + language: 'es', + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + transcript: 'Hola, mundo!', + language: 'es', + }); + }); + + it('should handle speed parameter', async () => { + prepareAudioResponse(); + + await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + speed: 1.5, + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + speed: 1.5, + }); + }); + + it('should warn about unsupported instructions parameter', async () => { + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + instructions: 'Speak slowly', + }); + + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "details": "Cartesia speech models do not support instructions. Instructions parameter was ignored.", + "feature": "instructions", + "type": "unsupported", + }, + ] + `); + }); + + it('should pass provider-specific options', async () => { + prepareAudioResponse(); + + await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + providerOptions: { + cartesia: { + container: 'raw', + encoding: 'pcm_s16le', + sampleRate: 16000, + speed: 0.8, + }, + }, + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + output_format: { + container: 'raw', + encoding: 'pcm_s16le', + sample_rate: 16000, + }, + speed: 0.8, + }); + }); + + it('should pass headers', async () => { + prepareAudioResponse(); + + const provider = createCartesia({ + apiKey: 'test-api-key', + headers: { + 'Custom-Provider-Header': 'provider-header-value', + }, + }); + + await provider.speech('sonic-3.5').doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + headers: { + 'Custom-Request-Header': 'request-header-value', + }, + }); + + expect(server.calls[0].requestHeaders).toMatchObject({ + authorization: 'Bearer test-api-key', + 'cartesia-version': '2025-04-16', + 'custom-provider-header': 'provider-header-value', + 'custom-request-header': 'request-header-value', + }); + }); + + it('should include user-agent header', async () => { + prepareAudioResponse(); + + await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + }); + + expect(server.calls[0].requestUserAgent).toContain( + `ai-sdk/cartesia/0.0.0-test`, + ); + }); + + it('should return audio data', async () => { + const audio = new Uint8Array(100); + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + }); + + expect(result.audio).toStrictEqual(audio); + }); + + it('should include response data with timestamp, modelId and headers', async () => { + prepareAudioResponse({ + headers: { + 'x-request-id': 'test-request-id', + }, + }); + + const testDate = new Date(0); + const customModel = new CartesiaSpeechModel('sonic-3.5', { + provider: 'test-provider', + url: () => 'https://api.cartesia.ai/tts/bytes', + headers: () => ({}), + _internal: { + currentDate: () => testDate, + }, + }); + + const result = await customModel.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + }); + + expect(result.response).toMatchObject({ + timestamp: testDate, + modelId: 'sonic-3.5', + headers: { + 'content-type': 'audio/mp3', + 'x-request-id': 'test-request-id', + }, + }); + }); + }); +}); diff --git a/packages/cartesia/src/cartesia-speech-model.ts b/packages/cartesia/src/cartesia-speech-model.ts new file mode 100644 index 000000000000..92afb92a2eb9 --- /dev/null +++ b/packages/cartesia/src/cartesia-speech-model.ts @@ -0,0 +1,233 @@ +import type { SpeechModelV4, SharedV4Warning } from '@ai-sdk/provider'; +import { + combineHeaders, + createBinaryResponseHandler, + parseProviderOptions, + postJsonToApi, + serializeModelOptions, + WORKFLOW_SERIALIZE, + WORKFLOW_DESERIALIZE, +} from '@ai-sdk/provider-utils'; +import type { CartesiaConfig } from './cartesia-config'; +import { cartesiaFailedResponseHandler } from './cartesia-error'; +import { cartesiaSpeechModelOptionsSchema } from './cartesia-speech-model-options'; +import type { CartesiaSpeechAPITypes } from './cartesia-speech-api-types'; +import type { + CartesiaSpeechModelId, + CartesiaSpeechVoiceId, +} from './cartesia-speech-options'; + +interface CartesiaSpeechModelConfig extends CartesiaConfig { + _internal?: { + currentDate?: () => Date; + }; +} + +// Default output format used when no outputFormat / provider options are set. +const DEFAULT_OUTPUT_FORMAT: CartesiaSpeechAPITypes['output_format'] = { + container: 'mp3', + encoding: 'mp3', + sample_rate: 44100, + bit_rate: 128000, +}; + +export class CartesiaSpeechModel implements SpeechModelV4 { + readonly specificationVersion = 'v4'; + + get provider(): string { + return this.config.provider; + } + + static [WORKFLOW_SERIALIZE](model: CartesiaSpeechModel) { + return serializeModelOptions({ + modelId: model.modelId, + config: model.config, + }); + } + + static [WORKFLOW_DESERIALIZE](options: { + modelId: CartesiaSpeechModelId; + config: CartesiaSpeechModelConfig; + }) { + return new CartesiaSpeechModel(options.modelId, options.config); + } + + constructor( + readonly modelId: CartesiaSpeechModelId, + private readonly config: CartesiaSpeechModelConfig, + ) {} + + private async getArgs({ + text, + voice, + outputFormat = 'mp3', + instructions, + language, + speed, + providerOptions, + }: Parameters[0]) { + const warnings: SharedV4Warning[] = []; + + // Parse provider options + const cartesiaOptions = await parseProviderOptions({ + provider: 'cartesia', + providerOptions, + schema: cartesiaSpeechModelOptionsSchema, + }); + + if (!voice) { + throw new Error('Cartesia speech models require a `voice` to be set.'); + } + + // Build the base output format from the SDK's flat `outputFormat` string. + const outputFormatObject: CartesiaSpeechAPITypes['output_format'] = { + ...DEFAULT_OUTPUT_FORMAT, + }; + + if (outputFormat) { + const formatLower = outputFormat.toLowerCase(); + + // Common format mappings. Cartesia expects a structured output_format. + // https://docs.cartesia.ai/api-reference/tts/bytes + const formatMap: Record< + string, + { container: string; encoding: string; sampleRate: number } + > = { + mp3: { container: 'mp3', encoding: 'mp3', sampleRate: 44100 }, + wav: { container: 'wav', encoding: 'pcm_s16le', sampleRate: 44100 }, + pcm: { container: 'raw', encoding: 'pcm_f32le', sampleRate: 44100 }, + raw: { container: 'raw', encoding: 'pcm_f32le', sampleRate: 44100 }, + mulaw: { container: 'raw', encoding: 'pcm_mulaw', sampleRate: 8000 }, + alaw: { container: 'raw', encoding: 'pcm_alaw', sampleRate: 8000 }, + }; + + // Try direct match first, then try "_" pattern. + const parts = formatLower.split('_'); + const mapped = formatMap[parts[0]]; + + if (mapped) { + outputFormatObject.container = mapped.container; + outputFormatObject.encoding = mapped.encoding; + outputFormatObject.sample_rate = mapped.sampleRate; + if (mapped.container === 'mp3') { + outputFormatObject.bit_rate = 128000; + } else { + delete outputFormatObject.bit_rate; + } + + // Optional sample rate suffix, e.g. "wav_24000" or "pcm_16000". + if (parts.length >= 2) { + const parsedRate = parseInt(parts[1], 10); + if (!Number.isNaN(parsedRate)) { + outputFormatObject.sample_rate = parsedRate; + } + } + } else { + warnings.push({ + type: 'unsupported', + feature: 'outputFormat', + details: `Unknown output format "${outputFormat}". Falling back to mp3. Use providerOptions.cartesia to configure container/encoding/sampleRate directly.`, + }); + } + } + + // Create request body + const requestBody: CartesiaSpeechAPITypes = { + model_id: this.modelId, + transcript: text, + voice: { + mode: 'id', + id: voice as CartesiaSpeechVoiceId, + }, + output_format: outputFormatObject, + }; + + // Map generic language + if (language) { + requestBody.language = language; + } + + // Map generic speed + if (speed != null) { + requestBody.speed = speed; + } + + // Add provider-specific options - map camelCase to snake_case + if (cartesiaOptions) { + if (cartesiaOptions.container != null) { + requestBody.output_format.container = cartesiaOptions.container; + } + if (cartesiaOptions.encoding != null) { + requestBody.output_format.encoding = cartesiaOptions.encoding; + } + if (cartesiaOptions.sampleRate != null) { + requestBody.output_format.sample_rate = cartesiaOptions.sampleRate; + } + if (cartesiaOptions.bitRate != null) { + requestBody.output_format.bit_rate = cartesiaOptions.bitRate; + } + if (cartesiaOptions.speed != null) { + requestBody.speed = cartesiaOptions.speed; + } + if (cartesiaOptions.language != null) { + requestBody.language = cartesiaOptions.language; + } + } + + // Remove bit_rate for non-mp3 containers where it is not applicable. + if (requestBody.output_format.container !== 'mp3') { + delete requestBody.output_format.bit_rate; + } + + if (instructions) { + warnings.push({ + type: 'unsupported', + feature: 'instructions', + details: `Cartesia speech models do not support instructions. Instructions parameter was ignored.`, + }); + } + + return { + requestBody, + warnings, + }; + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + const currentDate = this.config._internal?.currentDate?.() ?? new Date(); + const { requestBody, warnings } = await this.getArgs(options); + + const { + value: audio, + responseHeaders, + rawValue: rawResponse, + } = await postJsonToApi({ + url: this.config.url({ + path: '/tts/bytes', + modelId: this.modelId, + }), + headers: combineHeaders(this.config.headers?.(), options.headers), + body: requestBody, + failedResponseHandler: cartesiaFailedResponseHandler, + successfulResponseHandler: createBinaryResponseHandler(), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }); + + return { + audio, + warnings, + request: { + body: JSON.stringify(requestBody), + }, + response: { + timestamp: currentDate, + modelId: this.modelId, + headers: responseHeaders, + body: rawResponse, + }, + }; + } +} diff --git a/packages/cartesia/src/cartesia-speech-options.ts b/packages/cartesia/src/cartesia-speech-options.ts new file mode 100644 index 000000000000..2794cc5ad7c2 --- /dev/null +++ b/packages/cartesia/src/cartesia-speech-options.ts @@ -0,0 +1,10 @@ +export type CartesiaSpeechModelId = + | 'sonic-3.5' + | 'sonic-3' + | 'sonic-2' + | 'sonic-turbo' + | 'sonic-latest' + | 'sonic' + | (string & {}); + +export type CartesiaSpeechVoiceId = string; diff --git a/packages/cartesia/src/cartesia-transcription-model-options.ts b/packages/cartesia/src/cartesia-transcription-model-options.ts new file mode 100644 index 000000000000..6b5837dc513b --- /dev/null +++ b/packages/cartesia/src/cartesia-transcription-model-options.ts @@ -0,0 +1,13 @@ +import { z } from 'zod/v4'; + +// https://docs.cartesia.ai/api-reference/stt/transcribe +export const cartesiaTranscriptionModelOptionsSchema = z.object({ + /** The language of the audio (ISO 639-1 code). If not specified, the language is auto-detected. */ + language: z.string().nullish(), + /** The timestamp granularities to populate. Currently only `word` is supported. */ + timestampGranularities: z.array(z.enum(['word'])).nullish(), +}); + +export type CartesiaTranscriptionModelOptions = z.infer< + typeof cartesiaTranscriptionModelOptionsSchema +>; diff --git a/packages/cartesia/src/cartesia-transcription-model.test.ts b/packages/cartesia/src/cartesia-transcription-model.test.ts new file mode 100644 index 000000000000..e8fa76e90352 --- /dev/null +++ b/packages/cartesia/src/cartesia-transcription-model.test.ts @@ -0,0 +1,151 @@ +import { createTestServer } from '@ai-sdk/test-server/with-vitest'; +import { CartesiaTranscriptionModel } from './cartesia-transcription-model'; +import { createCartesia } from './cartesia-provider'; +import { readFile } from 'node:fs/promises'; +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('./version', () => ({ + VERSION: '0.0.0-test', +})); + +const audioData = await readFile(path.join(__dirname, 'transcript-test.mp3')); +const provider = createCartesia({ apiKey: 'test-api-key' }); +const model = provider.transcription('ink-whisper'); + +const server = createTestServer({ + 'https://api.cartesia.ai/stt': {}, +}); + +function prepareJsonFixtureResponse( + filename: string, + headers?: Record, +) { + server.urls['https://api.cartesia.ai/stt'].response = { + type: 'json-value', + headers, + body: JSON.parse( + fs.readFileSync(`src/__fixtures__/${filename}.json`, 'utf8'), + ), + }; +} + +describe('doGenerate', () => { + describe('transcription', () => { + beforeEach(() => prepareJsonFixtureResponse('cartesia-transcription')); + + it('should pass the model', async () => { + await model.doGenerate({ + audio: audioData, + mediaType: 'audio/wav', + }); + + expect(await server.calls[0].requestBodyMultipart).toMatchObject({ + model: 'ink-whisper', + }); + }); + + it('should pass headers', async () => { + const provider = createCartesia({ + apiKey: 'test-api-key', + headers: { + 'Custom-Provider-Header': 'provider-header-value', + }, + }); + + await provider.transcription('ink-whisper').doGenerate({ + audio: audioData, + mediaType: 'audio/wav', + headers: { + 'Custom-Request-Header': 'request-header-value', + }, + }); + + expect(server.calls[0].requestHeaders).toMatchObject({ + authorization: 'Bearer test-api-key', + 'cartesia-version': expect.any(String), + 'content-type': expect.stringMatching( + /^multipart\/form-data; boundary=----formdata-undici-\d+$/, + ), + 'custom-provider-header': 'provider-header-value', + 'custom-request-header': 'request-header-value', + }); + expect(server.calls[0].requestUserAgent).toContain( + `ai-sdk/cartesia/0.0.0-test`, + ); + }); + + it('should extract the transcription text', async () => { + const result = await model.doGenerate({ + audio: audioData, + mediaType: 'audio/wav', + }); + + expect(result.text).toMatchInlineSnapshot( + `"Hello from the Vercel AI SDK."`, + ); + }); + + it('should extract segments, language and duration', async () => { + const result = await model.doGenerate({ + audio: audioData, + mediaType: 'audio/wav', + }); + + expect(result.language).toBe('en'); + expect(result.durationInSeconds).toBe(2.479); + expect(result.segments[0]).toStrictEqual({ + text: 'Hello', + startSecond: 0.199, + endSecond: 0.479, + }); + }); + + it('should pass language and timestamp granularities', async () => { + await model.doGenerate({ + audio: audioData, + mediaType: 'audio/wav', + providerOptions: { + cartesia: { + language: 'en', + timestampGranularities: ['word'], + }, + }, + }); + + const body = await server.calls[0].requestBodyMultipart; + expect(body!.file).toBeInstanceOf(File); + const { file: _, ...rest } = body!; + expect(rest).toMatchObject({ + model: 'ink-whisper', + language: 'en', + 'timestamp_granularities[]': 'word', + }); + }); + }); + + describe('response metadata', () => { + it('should include response data with timestamp and modelId', async () => { + prepareJsonFixtureResponse('cartesia-transcription'); + + const testDate = new Date(0); + const customModel = new CartesiaTranscriptionModel('ink-whisper', { + provider: 'test-provider', + url: () => 'https://api.cartesia.ai/stt', + headers: () => ({}), + _internal: { + currentDate: () => testDate, + }, + }); + + const result = await customModel.doGenerate({ + audio: audioData, + mediaType: 'audio/wav', + }); + + expect(result.response.timestamp.getTime()).toEqual(testDate.getTime()); + expect(result.response.modelId).toBe('ink-whisper'); + }); + }); +}); diff --git a/packages/cartesia/src/cartesia-transcription-model.ts b/packages/cartesia/src/cartesia-transcription-model.ts new file mode 100644 index 000000000000..dbfdf2ba2a36 --- /dev/null +++ b/packages/cartesia/src/cartesia-transcription-model.ts @@ -0,0 +1,157 @@ +import type { TranscriptionModelV4, SharedV4Warning } from '@ai-sdk/provider'; +import { + combineHeaders, + convertBase64ToUint8Array, + createJsonResponseHandler, + mediaTypeToExtension, + parseProviderOptions, + postFormDataToApi, + serializeModelOptions, + WORKFLOW_SERIALIZE, + WORKFLOW_DESERIALIZE, +} from '@ai-sdk/provider-utils'; +import { z } from 'zod/v4'; +import type { CartesiaConfig } from './cartesia-config'; +import { cartesiaFailedResponseHandler } from './cartesia-error'; +import { cartesiaTranscriptionModelOptionsSchema } from './cartesia-transcription-model-options'; +import type { CartesiaTranscriptionModelId } from './cartesia-transcription-options'; + +interface CartesiaTranscriptionModelConfig extends CartesiaConfig { + _internal?: { + currentDate?: () => Date; + }; +} + +export class CartesiaTranscriptionModel implements TranscriptionModelV4 { + readonly specificationVersion = 'v4'; + + get provider(): string { + return this.config.provider; + } + + static [WORKFLOW_SERIALIZE](model: CartesiaTranscriptionModel) { + return serializeModelOptions({ + modelId: model.modelId, + config: model.config, + }); + } + + static [WORKFLOW_DESERIALIZE](options: { + modelId: CartesiaTranscriptionModelId; + config: CartesiaTranscriptionModelConfig; + }) { + return new CartesiaTranscriptionModel(options.modelId, options.config); + } + + constructor( + readonly modelId: CartesiaTranscriptionModelId, + private readonly config: CartesiaTranscriptionModelConfig, + ) {} + + private async getArgs({ + audio, + mediaType, + providerOptions, + }: Parameters[0]) { + const warnings: SharedV4Warning[] = []; + + // Parse provider options + const cartesiaOptions = await parseProviderOptions({ + provider: 'cartesia', + providerOptions, + schema: cartesiaTranscriptionModelOptionsSchema, + }); + + // Create form data with base fields + const formData = new FormData(); + const blob = + audio instanceof Uint8Array + ? new Blob([audio]) + : new Blob([convertBase64ToUint8Array(audio)]); + + const fileExtension = mediaTypeToExtension(mediaType); + formData.append('model', this.modelId); + formData.append( + 'file', + new File([blob], 'audio', { type: mediaType }), + `audio.${fileExtension}`, + ); + + // Add provider-specific options + if (cartesiaOptions) { + if (cartesiaOptions.language != null) { + formData.set('language', cartesiaOptions.language); + } + if (cartesiaOptions.timestampGranularities != null) { + for (const granularity of cartesiaOptions.timestampGranularities) { + formData.append('timestamp_granularities[]', granularity); + } + } + } + + return { + formData, + warnings, + }; + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + const currentDate = this.config._internal?.currentDate?.() ?? new Date(); + const { formData, warnings } = await this.getArgs(options); + + const { + value: response, + responseHeaders, + rawValue: rawResponse, + } = await postFormDataToApi({ + url: this.config.url({ + path: '/stt', + modelId: this.modelId, + }), + headers: combineHeaders(this.config.headers?.(), options.headers), + formData, + failedResponseHandler: cartesiaFailedResponseHandler, + successfulResponseHandler: createJsonResponseHandler( + cartesiaTranscriptionResponseSchema, + ), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }); + + return { + text: response.text, + segments: + response.words?.map(word => ({ + text: word.word, + startSecond: word.start, + endSecond: word.end, + })) ?? [], + language: response.language ?? undefined, + durationInSeconds: response.duration ?? undefined, + warnings, + response: { + timestamp: currentDate, + modelId: this.modelId, + headers: responseHeaders, + body: rawResponse, + }, + }; + } +} + +const cartesiaTranscriptionResponseSchema = z.object({ + text: z.string(), + language: z.string().nullish(), + duration: z.number().nullish(), + words: z + .array( + z.object({ + word: z.string(), + start: z.number(), + end: z.number(), + }), + ) + .nullish(), +}); diff --git a/packages/cartesia/src/cartesia-transcription-options.ts b/packages/cartesia/src/cartesia-transcription-options.ts new file mode 100644 index 000000000000..9b2ebb396ed6 --- /dev/null +++ b/packages/cartesia/src/cartesia-transcription-options.ts @@ -0,0 +1 @@ +export type CartesiaTranscriptionModelId = 'ink-whisper' | (string & {}); diff --git a/packages/cartesia/src/index.ts b/packages/cartesia/src/index.ts new file mode 100644 index 000000000000..66a7962eb57b --- /dev/null +++ b/packages/cartesia/src/index.ts @@ -0,0 +1,15 @@ +export { createCartesia, cartesia } from './cartesia-provider'; +export type { + CartesiaProvider, + CartesiaProviderSettings, +} from './cartesia-provider'; +export { CartesiaSpeechModel } from './cartesia-speech-model'; +export { CartesiaTranscriptionModel } from './cartesia-transcription-model'; +export type { + CartesiaSpeechModelId, + CartesiaSpeechVoiceId, +} from './cartesia-speech-options'; +export type { CartesiaTranscriptionModelId } from './cartesia-transcription-options'; +export type { CartesiaSpeechModelOptions } from './cartesia-speech-model-options'; +export type { CartesiaTranscriptionModelOptions } from './cartesia-transcription-model-options'; +export { VERSION } from './version'; diff --git a/packages/cartesia/src/transcript-test.mp3 b/packages/cartesia/src/transcript-test.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..6a4cf7b67483c076cebf897b5456f64d0372df90 GIT binary patch literal 40169 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 VY0mcaZ;SOm=>PwZ{$EGnzW{t`1;GFS literal 0 HcmV?d00001 diff --git a/packages/cartesia/src/version.ts b/packages/cartesia/src/version.ts new file mode 100644 index 000000000000..7a35d46f5d20 --- /dev/null +++ b/packages/cartesia/src/version.ts @@ -0,0 +1,6 @@ +// Version string of this package injected at build time. +declare const __PACKAGE_VERSION__: string | undefined; +export const VERSION: string = + typeof __PACKAGE_VERSION__ !== 'undefined' + ? __PACKAGE_VERSION__ + : '0.0.0-test'; diff --git a/packages/cartesia/tsconfig.build.json b/packages/cartesia/tsconfig.build.json new file mode 100644 index 000000000000..8181e407dd6d --- /dev/null +++ b/packages/cartesia/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // Disable project configuration for tsup builds + "composite": false + } +} diff --git a/packages/cartesia/tsconfig.json b/packages/cartesia/tsconfig.json new file mode 100644 index 000000000000..238c525be5a1 --- /dev/null +++ b/packages/cartesia/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "./node_modules/@vercel/ai-tsconfig/ts-library.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "exclude": [ + "dist", + "build", + "node_modules", + "tsup.config.ts" + ], + "references": [ + { + "path": "../provider" + }, + { + "path": "../provider-utils" + }, + { + "path": "../test-server" + } + ] +} diff --git a/packages/cartesia/tsup.config.ts b/packages/cartesia/tsup.config.ts new file mode 100644 index 000000000000..aef994baf5cc --- /dev/null +++ b/packages/cartesia/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + define: { + __PACKAGE_VERSION__: JSON.stringify( + (await import('./package.json', { with: { type: 'json' } })).default + .version, + ), + }, + }, +]); diff --git a/packages/cartesia/turbo.json b/packages/cartesia/turbo.json new file mode 100644 index 000000000000..620b8380e744 --- /dev/null +++ b/packages/cartesia/turbo.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "//" + ], + "tasks": { + "build": { + "outputs": [ + "**/dist/**" + ] + } + } +} diff --git a/packages/cartesia/vitest.edge.config.js b/packages/cartesia/vitest.edge.config.js new file mode 100644 index 000000000000..954b579ee4ca --- /dev/null +++ b/packages/cartesia/vitest.edge.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import packageJson from './package.json'; + +// https://vitejs.dev/config/ +export default defineConfig({ + test: { + environment: 'edge-runtime', + include: ['**/*.test.ts', '**/*.test.tsx'], + }, + define: { + __PACKAGE_VERSION__: JSON.stringify(packageJson.version), + }, +}); diff --git a/packages/cartesia/vitest.node.config.js b/packages/cartesia/vitest.node.config.js new file mode 100644 index 000000000000..112fd8f8729b --- /dev/null +++ b/packages/cartesia/vitest.node.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import packageJson from './package.json'; + +// https://vitejs.dev/config/ +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.test.ts', '**/*.test.tsx'], + }, + define: { + __PACKAGE_VERSION__: JSON.stringify(packageJson.version), + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d2d1764bbcc..a884758f1f9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: '@ai-sdk/bytedance': specifier: workspace:* version: link:../../packages/bytedance + '@ai-sdk/cartesia': + specifier: workspace:* + version: link:../../packages/cartesia '@ai-sdk/cerebras': specifier: workspace:* version: link:../../packages/cerebras @@ -1985,6 +1988,34 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/cartesia: + dependencies: + '@ai-sdk/provider': + specifier: workspace:* + version: link:../provider + '@ai-sdk/provider-utils': + specifier: workspace:* + version: link:../provider-utils + devDependencies: + '@ai-sdk/test-server': + specifier: workspace:* + version: link:../test-server + '@types/node': + specifier: 22.19.19 + version: 22.19.19 + '@vercel/ai-tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + tsup: + specifier: ^8.5.1 + version: 8.5.1(@swc/core@1.15.3(@swc/helpers@0.5.21))(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.0)(typescript@5.6.3)(yaml@2.9.0) + typescript: + specifier: 5.6.3 + version: 5.6.3 + zod: + specifier: 3.25.76 + version: 3.25.76 + packages/cerebras: dependencies: '@ai-sdk/openai-compatible': From 0e23cd074ea9b6322633a525b366e718aa93ccf7 Mon Sep 17 00:00:00 2001 From: Carlton Aikins Date: Mon, 6 Jul 2026 17:02:59 -0400 Subject: [PATCH 2/7] feat(examples): add Cartesia text-to-speech demo Co-authored-by: Cursor --- examples/cartesia-tts-demo/.env.example | 10 + examples/cartesia-tts-demo/package.json | 15 + examples/cartesia-tts-demo/server.mjs | 587 ++++++++++++++++++++++++ pnpm-lock.yaml | 9 + 4 files changed, 621 insertions(+) create mode 100644 examples/cartesia-tts-demo/.env.example create mode 100644 examples/cartesia-tts-demo/package.json create mode 100644 examples/cartesia-tts-demo/server.mjs diff --git a/examples/cartesia-tts-demo/.env.example b/examples/cartesia-tts-demo/.env.example new file mode 100644 index 000000000000..9b268d39f285 --- /dev/null +++ b/examples/cartesia-tts-demo/.env.example @@ -0,0 +1,10 @@ +# cartesia-tts-demo credentials — read as environment variables by server.mjs. +# Copy this file to `.env` and fill in your key, then run `pnpm start`. +# Do NOT commit your real `.env`. + +# Cartesia API key from https://play.cartesia.ai/ +CARTESIA_API_KEY= + +# Default voice ID for Sonic TTS (optional — the UI also accepts a voice ID). +# Find voice IDs in the Cartesia playground or API docs. +CARTESIA_VOICE_ID=a0e99841-438c-4a64-b679-ae501e7d6091 diff --git a/examples/cartesia-tts-demo/package.json b/examples/cartesia-tts-demo/package.json new file mode 100644 index 000000000000..ce2ced799e0e --- /dev/null +++ b/examples/cartesia-tts-demo/package.json @@ -0,0 +1,15 @@ +{ + "name": "@example/cartesia-tts-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Local browser demo for Cartesia text-to-speech and speech-to-text via @ai-sdk/cartesia (uses workspace source).", + "scripts": { + "prestart": "pnpm --filter ai... --filter @ai-sdk/cartesia... build", + "start": "NODE_EXTRA_CA_CERTS=${NODE_EXTRA_CA_CERTS:-/etc/ssl/cert.pem} node --env-file-if-exists=.env server.mjs" + }, + "dependencies": { + "@ai-sdk/cartesia": "workspace:*", + "ai": "workspace:*" + } +} diff --git a/examples/cartesia-tts-demo/server.mjs b/examples/cartesia-tts-demo/server.mjs new file mode 100644 index 000000000000..59cad0027967 --- /dev/null +++ b/examples/cartesia-tts-demo/server.mjs @@ -0,0 +1,587 @@ +// Minimal local app to test Cartesia TTS and STT via @ai-sdk/cartesia. +// Type text, pick a model + voice, hit Generate — the browser plays the audio. +// Reads CARTESIA_API_KEY from the environment (never sent to the browser). +import { createServer } from 'node:http'; +import { lookup } from 'node:dns/promises'; +import { createCartesia } from '@ai-sdk/cartesia'; +import { generateSpeech, transcribe } from 'ai'; + +const cartesia = createCartesia(); + +function formatApiError(error) { + const message = error instanceof Error ? error.message : String(error); + const cause = error instanceof Error ? error.cause : undefined; + const code = + cause && typeof cause === 'object' && 'code' in cause + ? String(cause.code) + : ''; + + if (code === 'ENOTFOUND' || /getaddrinfo ENOTFOUND/i.test(message)) { + return ( + 'Cannot reach api.cartesia.ai (DNS lookup failed). ' + + 'Start the demo from macOS Terminal.app — Cursor\'s integrated terminal can block outbound DNS. ' + + 'Run: cd ai/examples/cartesia-tts-demo && pnpm start' + ); + } + + if ( + code === 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' || + /unable to get local issuer certificate/i.test(message) + ) { + return ( + 'TLS certificate verification failed when calling api.cartesia.ai. ' + + 'Use `pnpm start` (sets NODE_EXTRA_CA_CERTS) or export NODE_EXTRA_CA_CERTS=/etc/ssl/cert.pem.' + ); + } + + return message; +} + +async function checkConnectivity() { + try { + await lookup('api.cartesia.ai'); + return true; + } catch { + console.log( + '⚠️ Cannot resolve api.cartesia.ai from this process. ' + + 'If you started the server from Cursor\'s terminal, re-run it from macOS Terminal.app instead.', + ); + return false; + } +} + +const PORT = Number(process.env.PORT) || 5052; + +const DEFAULT_VOICE_ID = + process.env.CARTESIA_VOICE_ID || 'a0e99841-438c-4a64-b679-ae501e7d6091'; + +const SPEECH_MODELS = [ + 'sonic-3.5', + 'sonic-3', + 'sonic-2', + 'sonic-turbo', + 'sonic-latest', + 'sonic', +]; + +const DEFAULT_MODEL = SPEECH_MODELS[0]; + +const OUTPUT_FORMATS = ['mp3', 'wav', 'pcm', 'mulaw', 'alaw']; + +const page = ` + + + + + + + + Cartesia Text-to-Speech + + + +
+

🎙️ Cartesia Text-to-Speech

+

+ Text-to-speech via @ai-sdk/cartesia + .speech() through generateSpeech. Pick a Sonic model and voice ID. +

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+

+ 🎙️ Push to talk: hold the button, speak, release — transcribed with + @ai-sdk/cartesia .transcription() (Ink-Whisper). +

+
+ + +
+ +
+

+  
+ + + +`; + +function readJson(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', c => (data += c)); + req.on('end', () => { + try { + resolve(JSON.parse(data || '{}')); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + +function readBuffer(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +const MEDIA_TYPES = { + mp3: 'audio/mpeg', + wav: 'audio/wav', + pcm: 'audio/pcm', + mulaw: 'audio/basic', + alaw: 'audio/alaw', +}; + +const server = createServer(async (req, res) => { + if (req.method === 'GET' && req.url === '/') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(page); + return; + } + + if (req.method === 'POST' && req.url === '/api/speech') { + try { + if (!process.env.CARTESIA_API_KEY) { + throw new Error('CARTESIA_API_KEY is not set in the environment.'); + } + + const { text, voice, model, outputFormat, language } = await readJson(req); + if (!text) throw new Error('text is required'); + + const modelId = model || DEFAULT_MODEL; + const voiceId = voice || DEFAULT_VOICE_ID; + const codec = OUTPUT_FORMATS.includes(outputFormat) ? outputFormat : 'mp3'; + + const result = await generateSpeech({ + model: cartesia.speech(modelId), + text, + voice: voiceId, + outputFormat: codec, + language: language || undefined, + }); + + if (result.warnings?.length) { + console.log('warnings:', JSON.stringify(result.warnings)); + } + console.log( + 'generated', + result.audio.uint8Array.length, + 'bytes', + result.audio.mediaType, + '| model:', + modelId, + ); + + res.writeHead(200, { + 'content-type': result.audio.mediaType || MEDIA_TYPES[codec] || 'audio/mpeg', + }); + res.end(Buffer.from(result.audio.uint8Array)); + } catch (e) { + const message = formatApiError(e); + console.error('speech error:', message); + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: message })); + } + return; + } + + if (req.method === 'POST' && req.url.startsWith('/api/transcribe')) { + try { + if (!process.env.CARTESIA_API_KEY) { + throw new Error('CARTESIA_API_KEY is not set in the environment.'); + } + + const reqUrl = new URL(req.url, 'http://localhost'); + const language = reqUrl.searchParams.get('language') || undefined; + const audio = await readBuffer(req); + if (!audio.length) throw new Error('no audio provided'); + + const contentType = req.headers['content-type'] || 'audio/webm'; + + const result = await transcribe({ + model: cartesia.transcription('ink-whisper'), + audio: new Uint8Array(audio), + mediaType: contentType.split(';')[0], + providerOptions: { + cartesia: { + language, + timestampGranularities: ['word'], + }, + }, + }); + + console.log( + 'transcribed', + audio.length, + 'bytes →', + JSON.stringify(result.text).slice(0, 100), + ); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + text: result.text, + language: result.language, + durationInSeconds: result.durationInSeconds, + }), + ); + } catch (e) { + const message = formatApiError(e); + console.error('transcribe error:', message); + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: message })); + } + return; + } + + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); +}); + +server.listen(PORT, () => { + console.log(`🎙️ Cartesia voice demo → http://localhost:${PORT}`); + if (!process.env.CARTESIA_API_KEY) { + console.log('⚠️ CARTESIA_API_KEY is not set — copy .env.example to .env and add your key.'); + } + void checkConnectivity(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a884758f1f9f..c36db94a0e13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,6 +550,15 @@ importers: specifier: 5.8.3 version: 5.8.3 + examples/cartesia-tts-demo: + dependencies: + '@ai-sdk/cartesia': + specifier: workspace:* + version: link:../../packages/cartesia + ai: + specifier: workspace:* + version: link:../../packages/ai + examples/express: dependencies: '@ai-sdk/openai': From 35f0d40b318a760d647b85e853f5cf1ee1743b50 Mon Sep 17 00:00:00 2001 From: Carlton Aikins Date: Wed, 8 Jul 2026 10:21:00 -0700 Subject: [PATCH 3/7] fix(provider/cartesia): align API contract and CI --- .changeset/bright-dolphins-speak.md | 5 + .../01-ai-sdk-providers/95-cartesia.mdx | 21 +- examples/ai-functions/tsconfig.json | 3 + packages/cartesia/README.md | 12 +- packages/cartesia/src/cartesia-api-types.ts | 3 +- packages/cartesia/src/cartesia-error.test.ts | 17 +- packages/cartesia/src/cartesia-error.ts | 8 +- packages/cartesia/src/cartesia-provider.ts | 2 +- .../cartesia/src/cartesia-speech-api-types.ts | 41 +++- .../src/cartesia-speech-model-options.ts | 23 +- .../src/cartesia-speech-model.test.ts | 93 +++++++- .../cartesia/src/cartesia-speech-model.ts | 206 +++++++++++------- .../cartesia/src/cartesia-speech-options.ts | 1 - .../cartesia-transcription-model-options.ts | 2 +- 14 files changed, 322 insertions(+), 115 deletions(-) create mode 100644 .changeset/bright-dolphins-speak.md diff --git a/.changeset/bright-dolphins-speak.md b/.changeset/bright-dolphins-speak.md new file mode 100644 index 000000000000..537ce18575e4 --- /dev/null +++ b/.changeset/bright-dolphins-speak.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/cartesia': minor +--- + +Add the Cartesia provider with Sonic speech generation and Ink-Whisper transcription support. diff --git a/content/providers/01-ai-sdk-providers/95-cartesia.mdx b/content/providers/01-ai-sdk-providers/95-cartesia.mdx index 76f9b056a66d..ca7c8a77ed98 100644 --- a/content/providers/01-ai-sdk-providers/95-cartesia.mdx +++ b/content/providers/01-ai-sdk-providers/95-cartesia.mdx @@ -5,7 +5,7 @@ description: Learn how to use the Cartesia provider for the AI SDK. # Cartesia Provider -The [Cartesia](https://cartesia.ai/) provider contains language model support for the Cartesia speech generation (Sonic) and transcription (Ink) APIs. +The [Cartesia](https://cartesia.ai/) provider contains speech generation (Sonic) and transcription (Ink) model support. ## Setup @@ -138,7 +138,7 @@ The following provider options are available: - **speed** _number_ - Controls the speed of the generated speech. + Controls the speed of the generated speech between `0.6` and `1.5`. Optional. - **language** _string_ @@ -148,13 +148,14 @@ The following provider options are available: ### Model Capabilities -| Model | -| ------------- | -| `sonic-3.5` | -| `sonic-3` | -| `sonic-2` | -| `sonic-turbo` | -| `sonic` | +| Model | +| -------------- | +| `sonic-3.5` | +| `sonic-3` | +| `sonic-2` | +| `sonic-turbo` | +| `sonic-latest` | +| `sonic` | ## Transcription Models @@ -205,7 +206,7 @@ The following provider options are available: - **language** _string_ - The language of the audio (ISO 639-1 code). If not specified, the language is auto-detected. + The language of the audio (ISO 639-1 code). If not specified, it defaults to English. Optional. - **timestampGranularities** _array of strings_ diff --git a/examples/ai-functions/tsconfig.json b/examples/ai-functions/tsconfig.json index 0eacc06af9ae..019ac2b54527 100644 --- a/examples/ai-functions/tsconfig.json +++ b/examples/ai-functions/tsconfig.json @@ -54,6 +54,9 @@ { "path": "../../packages/bytedance" }, + { + "path": "../../packages/cartesia" + }, { "path": "../../packages/cerebras" }, diff --git a/packages/cartesia/README.md b/packages/cartesia/README.md index 40a5d64dd323..823cbc4bb3e0 100644 --- a/packages/cartesia/README.md +++ b/packages/cartesia/README.md @@ -13,6 +13,14 @@ The Cartesia provider is available in the `@ai-sdk/cartesia` module. You can ins npm i @ai-sdk/cartesia ``` +## Skill for Coding Agents + +If you use coding agents such as Claude Code or Cursor, we highly recommend adding the AI SDK skill to your repository: + +```shell +npx skills add vercel/ai +``` + ## Provider Instance You can import the default provider instance `cartesia` from `@ai-sdk/cartesia`: @@ -25,10 +33,10 @@ import { cartesia } from '@ai-sdk/cartesia'; ```ts import { cartesia } from '@ai-sdk/cartesia'; -import { experimental_generateSpeech as generateSpeech } from 'ai'; +import { generateSpeech } from 'ai'; const { audio } = await generateSpeech({ - model: cartesia.speech('sonic-2'), + model: cartesia.speech('sonic-3.5'), text: 'Hello from the Vercel AI SDK!', voice: 'a0e99841-438c-4a64-b679-ae501e7d6091', }); diff --git a/packages/cartesia/src/cartesia-api-types.ts b/packages/cartesia/src/cartesia-api-types.ts index 11ccb27a62f4..21ae184dc5e5 100644 --- a/packages/cartesia/src/cartesia-api-types.ts +++ b/packages/cartesia/src/cartesia-api-types.ts @@ -5,8 +5,7 @@ export type CartesiaTranscriptionAPITypes = { model: string; /** - * The language of the audio (ISO 639-1 code). If not specified, the language - * is auto-detected. + * The language of the audio (ISO 639-1 code). Defaults to English. */ language?: string; diff --git a/packages/cartesia/src/cartesia-error.test.ts b/packages/cartesia/src/cartesia-error.test.ts index ffe18a94d41f..fa8928b10188 100644 --- a/packages/cartesia/src/cartesia-error.test.ts +++ b/packages/cartesia/src/cartesia-error.test.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from 'vitest'; describe('cartesiaErrorDataSchema', () => { it('should parse a Cartesia error', async () => { - const error = `{"error":"Invalid API key."}`; + const error = JSON.stringify({ + error_code: 'authentication_failed', + title: 'Authentication failed', + message: 'Invalid API key.', + request_id: '550e8400-e29b-41d4-a716-446655440000', + }); const result = await safeParseJSON({ text: error, @@ -14,10 +19,16 @@ describe('cartesiaErrorDataSchema', () => { expect(result).toStrictEqual({ success: true, value: { - error: 'Invalid API key.', + error_code: 'authentication_failed', + title: 'Authentication failed', + message: 'Invalid API key.', + request_id: '550e8400-e29b-41d4-a716-446655440000', }, rawValue: { - error: 'Invalid API key.', + error_code: 'authentication_failed', + title: 'Authentication failed', + message: 'Invalid API key.', + request_id: '550e8400-e29b-41d4-a716-446655440000', }, }); }); diff --git a/packages/cartesia/src/cartesia-error.ts b/packages/cartesia/src/cartesia-error.ts index 08a6bfef65f2..56d3d343eb27 100644 --- a/packages/cartesia/src/cartesia-error.ts +++ b/packages/cartesia/src/cartesia-error.ts @@ -2,12 +2,16 @@ import { z } from 'zod/v4'; import { createJsonErrorResponseHandler } from '@ai-sdk/provider-utils'; export const cartesiaErrorDataSchema = z.object({ - error: z.string(), + error_code: z.string().nullish(), + title: z.string(), + message: z.string(), + request_id: z.string(), + doc_url: z.string().optional(), }); export type CartesiaErrorData = z.infer; export const cartesiaFailedResponseHandler = createJsonErrorResponseHandler({ errorSchema: cartesiaErrorDataSchema, - errorToMessage: data => data.error, + errorToMessage: data => `${data.title}: ${data.message}`, }); diff --git a/packages/cartesia/src/cartesia-provider.ts b/packages/cartesia/src/cartesia-provider.ts index 078cd63a9031..aa9c5b6b2933 100644 --- a/packages/cartesia/src/cartesia-provider.ts +++ b/packages/cartesia/src/cartesia-provider.ts @@ -19,7 +19,7 @@ import { VERSION } from './version'; * The Cartesia API version sent with every request via the `Cartesia-Version` * header. See https://docs.cartesia.ai/api-reference/api-versioning */ -const CARTESIA_API_VERSION = '2025-04-16'; +const CARTESIA_API_VERSION = '2026-03-01'; export interface CartesiaProvider extends ProviderV4 { ( diff --git a/packages/cartesia/src/cartesia-speech-api-types.ts b/packages/cartesia/src/cartesia-speech-api-types.ts index 4ac74306ed47..f29861567c52 100644 --- a/packages/cartesia/src/cartesia-speech-api-types.ts +++ b/packages/cartesia/src/cartesia-speech-api-types.ts @@ -1,3 +1,31 @@ +export type CartesiaSpeechOutputFormat = + | { + container: 'mp3'; + sample_rate: CartesiaSpeechSampleRate; + bit_rate: CartesiaSpeechBitRate; + } + | { + container: 'raw' | 'wav'; + encoding: CartesiaSpeechEncoding; + sample_rate: CartesiaSpeechSampleRate; + }; + +export type CartesiaSpeechEncoding = + | 'pcm_f32le' + | 'pcm_s16le' + | 'pcm_mulaw' + | 'pcm_alaw'; + +export type CartesiaSpeechSampleRate = + | 8000 + | 16000 + | 22050 + | 24000 + | 44100 + | 48000; + +export type CartesiaSpeechBitRate = 32000 | 64000 | 96000 | 128000 | 192000; + export type CartesiaSpeechAPITypes = { /** * The ID of the model to use for the generation. @@ -25,15 +53,12 @@ export type CartesiaSpeechAPITypes = { /** * The output audio format. */ - output_format: { - container: string; - encoding: string; - sample_rate: number; - bit_rate?: number; - }; + output_format: CartesiaSpeechOutputFormat; /** - * Controls the speed of the generated speech. + * Controls supported generation attributes. */ - speed?: number; + generation_config?: { + speed?: number; + }; }; diff --git a/packages/cartesia/src/cartesia-speech-model-options.ts b/packages/cartesia/src/cartesia-speech-model-options.ts index 80a41fd67fd0..a0669489ca33 100644 --- a/packages/cartesia/src/cartesia-speech-model-options.ts +++ b/packages/cartesia/src/cartesia-speech-model-options.ts @@ -9,11 +9,28 @@ export const cartesiaSpeechModelOptionsSchema = z.object({ .enum(['pcm_f32le', 'pcm_s16le', 'pcm_mulaw', 'pcm_alaw']) .nullish(), /** Sample rate for the output audio in Hz (e.g. 8000, 16000, 22050, 24000, 44100, 48000). */ - sampleRate: z.number().nullish(), + sampleRate: z + .union([ + z.literal(8000), + z.literal(16000), + z.literal(22050), + z.literal(24000), + z.literal(44100), + z.literal(48000), + ]) + .nullish(), /** Bitrate for mp3 output in bits per second (e.g. 32000, 64000, 128000, 192000). */ - bitRate: z.number().nullish(), + bitRate: z + .union([ + z.literal(32000), + z.literal(64000), + z.literal(96000), + z.literal(128000), + z.literal(192000), + ]) + .nullish(), /** Controls the speed of the generated speech. */ - speed: z.number().nullish(), + speed: z.number().min(0.6).max(1.5).nullish(), /** The language to generate speech in (ISO 639-1 code). */ language: z.string().nullish(), }); diff --git a/packages/cartesia/src/cartesia-speech-model.test.ts b/packages/cartesia/src/cartesia-speech-model.test.ts index 2017748805c2..33868c07df47 100644 --- a/packages/cartesia/src/cartesia-speech-model.test.ts +++ b/packages/cartesia/src/cartesia-speech-model.test.ts @@ -43,7 +43,7 @@ describe('CartesiaSpeechModel', () => { voice: 'test-voice-id', }); - expect(await server.calls[0].requestBodyJson).toMatchObject({ + expect(await server.calls[0].requestBodyJson).toStrictEqual({ model_id: 'sonic-3.5', transcript: 'Hello, world!', voice: { @@ -52,7 +52,6 @@ describe('CartesiaSpeechModel', () => { }, output_format: { container: 'mp3', - encoding: 'mp3', sample_rate: 44100, bit_rate: 128000, }, @@ -130,10 +129,35 @@ describe('CartesiaSpeechModel', () => { }); expect(await server.calls[0].requestBodyJson).toMatchObject({ - speed: 1.5, + generation_config: { + speed: 1.5, + }, }); }); + it('should warn and ignore an out-of-range generic speed', async () => { + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + speed: 2, + }); + + expect(await server.calls[0].requestBodyJson).not.toHaveProperty( + 'generation_config', + ); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "details": "Cartesia speed must be between 0.6 and 1.5. The speed option was ignored.", + "feature": "speed", + "type": "unsupported", + }, + ] + `); + }); + it('should warn about unsupported instructions parameter', async () => { prepareAudioResponse(); @@ -176,8 +200,67 @@ describe('CartesiaSpeechModel', () => { encoding: 'pcm_s16le', sample_rate: 16000, }, - speed: 0.8, + generation_config: { + speed: 0.8, + }, + }); + }); + + it('should ignore encoding for mp3 output', async () => { + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + providerOptions: { + cartesia: { + encoding: 'pcm_s16le', + }, + }, + }); + + expect( + (await server.calls[0].requestBodyJson).output_format, + ).toStrictEqual({ + container: 'mp3', + sample_rate: 44100, + bit_rate: 128000, + }); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "details": "Cartesia MP3 output does not accept an encoding. The encoding option was ignored.", + "feature": "providerOptions.cartesia.encoding", + "type": "unsupported", + }, + ] + `); + }); + + it('should warn about an unsupported sample rate suffix', async () => { + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + outputFormat: 'wav_12345', + }); + + expect( + (await server.calls[0].requestBodyJson).output_format, + ).toMatchObject({ + container: 'wav', + sample_rate: 44100, }); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "details": "Unsupported Cartesia sample rate in output format \"wav_12345\". Using 44100 Hz instead.", + "feature": "outputFormat", + "type": "unsupported", + }, + ] + `); }); it('should pass headers', async () => { @@ -200,7 +283,7 @@ describe('CartesiaSpeechModel', () => { expect(server.calls[0].requestHeaders).toMatchObject({ authorization: 'Bearer test-api-key', - 'cartesia-version': '2025-04-16', + 'cartesia-version': '2026-03-01', 'custom-provider-header': 'provider-header-value', 'custom-request-header': 'request-header-value', }); diff --git a/packages/cartesia/src/cartesia-speech-model.ts b/packages/cartesia/src/cartesia-speech-model.ts index 92afb92a2eb9..0225adb76220 100644 --- a/packages/cartesia/src/cartesia-speech-model.ts +++ b/packages/cartesia/src/cartesia-speech-model.ts @@ -11,7 +11,13 @@ import { import type { CartesiaConfig } from './cartesia-config'; import { cartesiaFailedResponseHandler } from './cartesia-error'; import { cartesiaSpeechModelOptionsSchema } from './cartesia-speech-model-options'; -import type { CartesiaSpeechAPITypes } from './cartesia-speech-api-types'; +import type { + CartesiaSpeechAPITypes, + CartesiaSpeechBitRate, + CartesiaSpeechEncoding, + CartesiaSpeechOutputFormat, + CartesiaSpeechSampleRate, +} from './cartesia-speech-api-types'; import type { CartesiaSpeechModelId, CartesiaSpeechVoiceId, @@ -26,11 +32,114 @@ interface CartesiaSpeechModelConfig extends CartesiaConfig { // Default output format used when no outputFormat / provider options are set. const DEFAULT_OUTPUT_FORMAT: CartesiaSpeechAPITypes['output_format'] = { container: 'mp3', - encoding: 'mp3', sample_rate: 44100, bit_rate: 128000, }; +const OUTPUT_FORMATS: Record = { + alaw: { container: 'raw', encoding: 'pcm_alaw', sample_rate: 8000 }, + mp3: DEFAULT_OUTPUT_FORMAT, + mulaw: { container: 'raw', encoding: 'pcm_mulaw', sample_rate: 8000 }, + pcm: { container: 'raw', encoding: 'pcm_f32le', sample_rate: 44100 }, + raw: { container: 'raw', encoding: 'pcm_f32le', sample_rate: 44100 }, + wav: { container: 'wav', encoding: 'pcm_s16le', sample_rate: 44100 }, +}; + +const SAMPLE_RATES = [8000, 16000, 22050, 24000, 44100, 48000] as const; + +function isSampleRate(value: number): value is CartesiaSpeechSampleRate { + return SAMPLE_RATES.includes(value as CartesiaSpeechSampleRate); +} + +function resolveOutputFormat({ + outputFormat, + providerOptions, + warnings, +}: { + outputFormat: string; + providerOptions: + | { + container?: 'raw' | 'wav' | 'mp3' | null; + encoding?: CartesiaSpeechEncoding | null; + sampleRate?: CartesiaSpeechSampleRate | null; + bitRate?: CartesiaSpeechBitRate | null; + } + | undefined; + warnings: SharedV4Warning[]; +}): CartesiaSpeechOutputFormat { + const [formatName, sampleRateText, ...extraParts] = outputFormat + .toLowerCase() + .split('_'); + const mapped = OUTPUT_FORMATS[formatName]; + let resolved = mapped ? { ...mapped } : { ...DEFAULT_OUTPUT_FORMAT }; + + if (!mapped) { + warnings.push({ + type: 'unsupported', + feature: 'outputFormat', + details: `Unknown output format "${outputFormat}". Falling back to mp3. Use providerOptions.cartesia to configure container, encoding, and sampleRate directly.`, + }); + } else if (sampleRateText != null) { + const parsedRate = Number(sampleRateText); + if ( + extraParts.length === 0 && + Number.isInteger(parsedRate) && + isSampleRate(parsedRate) + ) { + resolved.sample_rate = parsedRate; + } else { + warnings.push({ + type: 'unsupported', + feature: 'outputFormat', + details: `Unsupported Cartesia sample rate in output format "${outputFormat}". Using ${resolved.sample_rate} Hz instead.`, + }); + } + } + + const container = providerOptions?.container ?? resolved.container; + const sampleRate = providerOptions?.sampleRate ?? resolved.sample_rate; + + if (container === 'mp3') { + if (providerOptions?.encoding != null) { + warnings.push({ + type: 'unsupported', + feature: 'providerOptions.cartesia.encoding', + details: + 'Cartesia MP3 output does not accept an encoding. The encoding option was ignored.', + }); + } + + return { + container, + sample_rate: sampleRate, + bit_rate: + providerOptions?.bitRate ?? + (resolved.container === 'mp3' ? resolved.bit_rate : 128000), + }; + } + + if (providerOptions?.bitRate != null) { + warnings.push({ + type: 'unsupported', + feature: 'providerOptions.cartesia.bitRate', + details: + 'Cartesia raw and WAV output do not accept a bit rate. The bitRate option was ignored.', + }); + } + + return { + container, + encoding: + providerOptions?.encoding ?? + (resolved.container === 'mp3' + ? container === 'wav' + ? 'pcm_s16le' + : 'pcm_f32le' + : resolved.encoding), + sample_rate: sampleRate, + }; +} + export class CartesiaSpeechModel implements SpeechModelV4 { readonly specificationVersion = 'v4'; @@ -79,57 +188,11 @@ export class CartesiaSpeechModel implements SpeechModelV4 { throw new Error('Cartesia speech models require a `voice` to be set.'); } - // Build the base output format from the SDK's flat `outputFormat` string. - const outputFormatObject: CartesiaSpeechAPITypes['output_format'] = { - ...DEFAULT_OUTPUT_FORMAT, - }; - - if (outputFormat) { - const formatLower = outputFormat.toLowerCase(); - - // Common format mappings. Cartesia expects a structured output_format. - // https://docs.cartesia.ai/api-reference/tts/bytes - const formatMap: Record< - string, - { container: string; encoding: string; sampleRate: number } - > = { - mp3: { container: 'mp3', encoding: 'mp3', sampleRate: 44100 }, - wav: { container: 'wav', encoding: 'pcm_s16le', sampleRate: 44100 }, - pcm: { container: 'raw', encoding: 'pcm_f32le', sampleRate: 44100 }, - raw: { container: 'raw', encoding: 'pcm_f32le', sampleRate: 44100 }, - mulaw: { container: 'raw', encoding: 'pcm_mulaw', sampleRate: 8000 }, - alaw: { container: 'raw', encoding: 'pcm_alaw', sampleRate: 8000 }, - }; - - // Try direct match first, then try "_" pattern. - const parts = formatLower.split('_'); - const mapped = formatMap[parts[0]]; - - if (mapped) { - outputFormatObject.container = mapped.container; - outputFormatObject.encoding = mapped.encoding; - outputFormatObject.sample_rate = mapped.sampleRate; - if (mapped.container === 'mp3') { - outputFormatObject.bit_rate = 128000; - } else { - delete outputFormatObject.bit_rate; - } - - // Optional sample rate suffix, e.g. "wav_24000" or "pcm_16000". - if (parts.length >= 2) { - const parsedRate = parseInt(parts[1], 10); - if (!Number.isNaN(parsedRate)) { - outputFormatObject.sample_rate = parsedRate; - } - } - } else { - warnings.push({ - type: 'unsupported', - feature: 'outputFormat', - details: `Unknown output format "${outputFormat}". Falling back to mp3. Use providerOptions.cartesia to configure container/encoding/sampleRate directly.`, - }); - } - } + const outputFormatObject = resolveOutputFormat({ + outputFormat, + providerOptions: cartesiaOptions, + warnings, + }); // Create request body const requestBody: CartesiaSpeechAPITypes = { @@ -147,36 +210,25 @@ export class CartesiaSpeechModel implements SpeechModelV4 { requestBody.language = language; } - // Map generic speed - if (speed != null) { - requestBody.speed = speed; - } - - // Add provider-specific options - map camelCase to snake_case + // Provider-specific options override the corresponding generic options. if (cartesiaOptions) { - if (cartesiaOptions.container != null) { - requestBody.output_format.container = cartesiaOptions.container; - } - if (cartesiaOptions.encoding != null) { - requestBody.output_format.encoding = cartesiaOptions.encoding; - } - if (cartesiaOptions.sampleRate != null) { - requestBody.output_format.sample_rate = cartesiaOptions.sampleRate; - } - if (cartesiaOptions.bitRate != null) { - requestBody.output_format.bit_rate = cartesiaOptions.bitRate; - } - if (cartesiaOptions.speed != null) { - requestBody.speed = cartesiaOptions.speed; - } if (cartesiaOptions.language != null) { requestBody.language = cartesiaOptions.language; } } - // Remove bit_rate for non-mp3 containers where it is not applicable. - if (requestBody.output_format.container !== 'mp3') { - delete requestBody.output_format.bit_rate; + const resolvedSpeed = cartesiaOptions?.speed ?? speed; + if (resolvedSpeed != null) { + if (resolvedSpeed >= 0.6 && resolvedSpeed <= 1.5) { + requestBody.generation_config = { speed: resolvedSpeed }; + } else { + warnings.push({ + type: 'unsupported', + feature: 'speed', + details: + 'Cartesia speed must be between 0.6 and 1.5. The speed option was ignored.', + }); + } } if (instructions) { diff --git a/packages/cartesia/src/cartesia-speech-options.ts b/packages/cartesia/src/cartesia-speech-options.ts index 2794cc5ad7c2..5614220e958c 100644 --- a/packages/cartesia/src/cartesia-speech-options.ts +++ b/packages/cartesia/src/cartesia-speech-options.ts @@ -4,7 +4,6 @@ export type CartesiaSpeechModelId = | 'sonic-2' | 'sonic-turbo' | 'sonic-latest' - | 'sonic' | (string & {}); export type CartesiaSpeechVoiceId = string; diff --git a/packages/cartesia/src/cartesia-transcription-model-options.ts b/packages/cartesia/src/cartesia-transcription-model-options.ts index 6b5837dc513b..ab53964b7efe 100644 --- a/packages/cartesia/src/cartesia-transcription-model-options.ts +++ b/packages/cartesia/src/cartesia-transcription-model-options.ts @@ -2,7 +2,7 @@ import { z } from 'zod/v4'; // https://docs.cartesia.ai/api-reference/stt/transcribe export const cartesiaTranscriptionModelOptionsSchema = z.object({ - /** The language of the audio (ISO 639-1 code). If not specified, the language is auto-detected. */ + /** The language of the audio (ISO 639-1 code). Defaults to English. */ language: z.string().nullish(), /** The timestamp granularities to populate. Currently only `word` is supported. */ timestampGranularities: z.array(z.enum(['word'])).nullish(), From dc9dabda5e6aeb3ef2ceaea2ee6a336d97e9e94d Mon Sep 17 00:00:00 2001 From: Carlton Aikins Date: Wed, 8 Jul 2026 10:21:00 -0700 Subject: [PATCH 4/7] fix(provider/cartesia): align API contract and CI --- .changeset/bright-dolphins-speak.md | 5 + .../01-ai-sdk-providers/95-cartesia.mdx | 21 +- examples/ai-functions/tsconfig.json | 3 + packages/cartesia/README.md | 12 +- packages/cartesia/src/cartesia-api-types.ts | 3 +- packages/cartesia/src/cartesia-error.test.ts | 17 +- packages/cartesia/src/cartesia-error.ts | 8 +- packages/cartesia/src/cartesia-provider.ts | 2 +- .../cartesia/src/cartesia-speech-api-types.ts | 41 +++- .../src/cartesia-speech-model-options.ts | 23 +- .../src/cartesia-speech-model.test.ts | 93 +++++++- .../cartesia/src/cartesia-speech-model.ts | 206 +++++++++++------- .../cartesia/src/cartesia-speech-options.ts | 1 - .../cartesia-transcription-model-options.ts | 2 +- 14 files changed, 322 insertions(+), 115 deletions(-) create mode 100644 .changeset/bright-dolphins-speak.md diff --git a/.changeset/bright-dolphins-speak.md b/.changeset/bright-dolphins-speak.md new file mode 100644 index 000000000000..537ce18575e4 --- /dev/null +++ b/.changeset/bright-dolphins-speak.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/cartesia': minor +--- + +Add the Cartesia provider with Sonic speech generation and Ink-Whisper transcription support. diff --git a/content/providers/01-ai-sdk-providers/95-cartesia.mdx b/content/providers/01-ai-sdk-providers/95-cartesia.mdx index 76f9b056a66d..ca7c8a77ed98 100644 --- a/content/providers/01-ai-sdk-providers/95-cartesia.mdx +++ b/content/providers/01-ai-sdk-providers/95-cartesia.mdx @@ -5,7 +5,7 @@ description: Learn how to use the Cartesia provider for the AI SDK. # Cartesia Provider -The [Cartesia](https://cartesia.ai/) provider contains language model support for the Cartesia speech generation (Sonic) and transcription (Ink) APIs. +The [Cartesia](https://cartesia.ai/) provider contains speech generation (Sonic) and transcription (Ink) model support. ## Setup @@ -138,7 +138,7 @@ The following provider options are available: - **speed** _number_ - Controls the speed of the generated speech. + Controls the speed of the generated speech between `0.6` and `1.5`. Optional. - **language** _string_ @@ -148,13 +148,14 @@ The following provider options are available: ### Model Capabilities -| Model | -| ------------- | -| `sonic-3.5` | -| `sonic-3` | -| `sonic-2` | -| `sonic-turbo` | -| `sonic` | +| Model | +| -------------- | +| `sonic-3.5` | +| `sonic-3` | +| `sonic-2` | +| `sonic-turbo` | +| `sonic-latest` | +| `sonic` | ## Transcription Models @@ -205,7 +206,7 @@ The following provider options are available: - **language** _string_ - The language of the audio (ISO 639-1 code). If not specified, the language is auto-detected. + The language of the audio (ISO 639-1 code). If not specified, it defaults to English. Optional. - **timestampGranularities** _array of strings_ diff --git a/examples/ai-functions/tsconfig.json b/examples/ai-functions/tsconfig.json index 0eacc06af9ae..019ac2b54527 100644 --- a/examples/ai-functions/tsconfig.json +++ b/examples/ai-functions/tsconfig.json @@ -54,6 +54,9 @@ { "path": "../../packages/bytedance" }, + { + "path": "../../packages/cartesia" + }, { "path": "../../packages/cerebras" }, diff --git a/packages/cartesia/README.md b/packages/cartesia/README.md index 40a5d64dd323..823cbc4bb3e0 100644 --- a/packages/cartesia/README.md +++ b/packages/cartesia/README.md @@ -13,6 +13,14 @@ The Cartesia provider is available in the `@ai-sdk/cartesia` module. You can ins npm i @ai-sdk/cartesia ``` +## Skill for Coding Agents + +If you use coding agents such as Claude Code or Cursor, we highly recommend adding the AI SDK skill to your repository: + +```shell +npx skills add vercel/ai +``` + ## Provider Instance You can import the default provider instance `cartesia` from `@ai-sdk/cartesia`: @@ -25,10 +33,10 @@ import { cartesia } from '@ai-sdk/cartesia'; ```ts import { cartesia } from '@ai-sdk/cartesia'; -import { experimental_generateSpeech as generateSpeech } from 'ai'; +import { generateSpeech } from 'ai'; const { audio } = await generateSpeech({ - model: cartesia.speech('sonic-2'), + model: cartesia.speech('sonic-3.5'), text: 'Hello from the Vercel AI SDK!', voice: 'a0e99841-438c-4a64-b679-ae501e7d6091', }); diff --git a/packages/cartesia/src/cartesia-api-types.ts b/packages/cartesia/src/cartesia-api-types.ts index 11ccb27a62f4..21ae184dc5e5 100644 --- a/packages/cartesia/src/cartesia-api-types.ts +++ b/packages/cartesia/src/cartesia-api-types.ts @@ -5,8 +5,7 @@ export type CartesiaTranscriptionAPITypes = { model: string; /** - * The language of the audio (ISO 639-1 code). If not specified, the language - * is auto-detected. + * The language of the audio (ISO 639-1 code). Defaults to English. */ language?: string; diff --git a/packages/cartesia/src/cartesia-error.test.ts b/packages/cartesia/src/cartesia-error.test.ts index ffe18a94d41f..fa8928b10188 100644 --- a/packages/cartesia/src/cartesia-error.test.ts +++ b/packages/cartesia/src/cartesia-error.test.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from 'vitest'; describe('cartesiaErrorDataSchema', () => { it('should parse a Cartesia error', async () => { - const error = `{"error":"Invalid API key."}`; + const error = JSON.stringify({ + error_code: 'authentication_failed', + title: 'Authentication failed', + message: 'Invalid API key.', + request_id: '550e8400-e29b-41d4-a716-446655440000', + }); const result = await safeParseJSON({ text: error, @@ -14,10 +19,16 @@ describe('cartesiaErrorDataSchema', () => { expect(result).toStrictEqual({ success: true, value: { - error: 'Invalid API key.', + error_code: 'authentication_failed', + title: 'Authentication failed', + message: 'Invalid API key.', + request_id: '550e8400-e29b-41d4-a716-446655440000', }, rawValue: { - error: 'Invalid API key.', + error_code: 'authentication_failed', + title: 'Authentication failed', + message: 'Invalid API key.', + request_id: '550e8400-e29b-41d4-a716-446655440000', }, }); }); diff --git a/packages/cartesia/src/cartesia-error.ts b/packages/cartesia/src/cartesia-error.ts index 08a6bfef65f2..56d3d343eb27 100644 --- a/packages/cartesia/src/cartesia-error.ts +++ b/packages/cartesia/src/cartesia-error.ts @@ -2,12 +2,16 @@ import { z } from 'zod/v4'; import { createJsonErrorResponseHandler } from '@ai-sdk/provider-utils'; export const cartesiaErrorDataSchema = z.object({ - error: z.string(), + error_code: z.string().nullish(), + title: z.string(), + message: z.string(), + request_id: z.string(), + doc_url: z.string().optional(), }); export type CartesiaErrorData = z.infer; export const cartesiaFailedResponseHandler = createJsonErrorResponseHandler({ errorSchema: cartesiaErrorDataSchema, - errorToMessage: data => data.error, + errorToMessage: data => `${data.title}: ${data.message}`, }); diff --git a/packages/cartesia/src/cartesia-provider.ts b/packages/cartesia/src/cartesia-provider.ts index 078cd63a9031..aa9c5b6b2933 100644 --- a/packages/cartesia/src/cartesia-provider.ts +++ b/packages/cartesia/src/cartesia-provider.ts @@ -19,7 +19,7 @@ import { VERSION } from './version'; * The Cartesia API version sent with every request via the `Cartesia-Version` * header. See https://docs.cartesia.ai/api-reference/api-versioning */ -const CARTESIA_API_VERSION = '2025-04-16'; +const CARTESIA_API_VERSION = '2026-03-01'; export interface CartesiaProvider extends ProviderV4 { ( diff --git a/packages/cartesia/src/cartesia-speech-api-types.ts b/packages/cartesia/src/cartesia-speech-api-types.ts index 4ac74306ed47..f29861567c52 100644 --- a/packages/cartesia/src/cartesia-speech-api-types.ts +++ b/packages/cartesia/src/cartesia-speech-api-types.ts @@ -1,3 +1,31 @@ +export type CartesiaSpeechOutputFormat = + | { + container: 'mp3'; + sample_rate: CartesiaSpeechSampleRate; + bit_rate: CartesiaSpeechBitRate; + } + | { + container: 'raw' | 'wav'; + encoding: CartesiaSpeechEncoding; + sample_rate: CartesiaSpeechSampleRate; + }; + +export type CartesiaSpeechEncoding = + | 'pcm_f32le' + | 'pcm_s16le' + | 'pcm_mulaw' + | 'pcm_alaw'; + +export type CartesiaSpeechSampleRate = + | 8000 + | 16000 + | 22050 + | 24000 + | 44100 + | 48000; + +export type CartesiaSpeechBitRate = 32000 | 64000 | 96000 | 128000 | 192000; + export type CartesiaSpeechAPITypes = { /** * The ID of the model to use for the generation. @@ -25,15 +53,12 @@ export type CartesiaSpeechAPITypes = { /** * The output audio format. */ - output_format: { - container: string; - encoding: string; - sample_rate: number; - bit_rate?: number; - }; + output_format: CartesiaSpeechOutputFormat; /** - * Controls the speed of the generated speech. + * Controls supported generation attributes. */ - speed?: number; + generation_config?: { + speed?: number; + }; }; diff --git a/packages/cartesia/src/cartesia-speech-model-options.ts b/packages/cartesia/src/cartesia-speech-model-options.ts index 80a41fd67fd0..a0669489ca33 100644 --- a/packages/cartesia/src/cartesia-speech-model-options.ts +++ b/packages/cartesia/src/cartesia-speech-model-options.ts @@ -9,11 +9,28 @@ export const cartesiaSpeechModelOptionsSchema = z.object({ .enum(['pcm_f32le', 'pcm_s16le', 'pcm_mulaw', 'pcm_alaw']) .nullish(), /** Sample rate for the output audio in Hz (e.g. 8000, 16000, 22050, 24000, 44100, 48000). */ - sampleRate: z.number().nullish(), + sampleRate: z + .union([ + z.literal(8000), + z.literal(16000), + z.literal(22050), + z.literal(24000), + z.literal(44100), + z.literal(48000), + ]) + .nullish(), /** Bitrate for mp3 output in bits per second (e.g. 32000, 64000, 128000, 192000). */ - bitRate: z.number().nullish(), + bitRate: z + .union([ + z.literal(32000), + z.literal(64000), + z.literal(96000), + z.literal(128000), + z.literal(192000), + ]) + .nullish(), /** Controls the speed of the generated speech. */ - speed: z.number().nullish(), + speed: z.number().min(0.6).max(1.5).nullish(), /** The language to generate speech in (ISO 639-1 code). */ language: z.string().nullish(), }); diff --git a/packages/cartesia/src/cartesia-speech-model.test.ts b/packages/cartesia/src/cartesia-speech-model.test.ts index 2017748805c2..33868c07df47 100644 --- a/packages/cartesia/src/cartesia-speech-model.test.ts +++ b/packages/cartesia/src/cartesia-speech-model.test.ts @@ -43,7 +43,7 @@ describe('CartesiaSpeechModel', () => { voice: 'test-voice-id', }); - expect(await server.calls[0].requestBodyJson).toMatchObject({ + expect(await server.calls[0].requestBodyJson).toStrictEqual({ model_id: 'sonic-3.5', transcript: 'Hello, world!', voice: { @@ -52,7 +52,6 @@ describe('CartesiaSpeechModel', () => { }, output_format: { container: 'mp3', - encoding: 'mp3', sample_rate: 44100, bit_rate: 128000, }, @@ -130,10 +129,35 @@ describe('CartesiaSpeechModel', () => { }); expect(await server.calls[0].requestBodyJson).toMatchObject({ - speed: 1.5, + generation_config: { + speed: 1.5, + }, }); }); + it('should warn and ignore an out-of-range generic speed', async () => { + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + speed: 2, + }); + + expect(await server.calls[0].requestBodyJson).not.toHaveProperty( + 'generation_config', + ); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "details": "Cartesia speed must be between 0.6 and 1.5. The speed option was ignored.", + "feature": "speed", + "type": "unsupported", + }, + ] + `); + }); + it('should warn about unsupported instructions parameter', async () => { prepareAudioResponse(); @@ -176,8 +200,67 @@ describe('CartesiaSpeechModel', () => { encoding: 'pcm_s16le', sample_rate: 16000, }, - speed: 0.8, + generation_config: { + speed: 0.8, + }, + }); + }); + + it('should ignore encoding for mp3 output', async () => { + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + providerOptions: { + cartesia: { + encoding: 'pcm_s16le', + }, + }, + }); + + expect( + (await server.calls[0].requestBodyJson).output_format, + ).toStrictEqual({ + container: 'mp3', + sample_rate: 44100, + bit_rate: 128000, + }); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "details": "Cartesia MP3 output does not accept an encoding. The encoding option was ignored.", + "feature": "providerOptions.cartesia.encoding", + "type": "unsupported", + }, + ] + `); + }); + + it('should warn about an unsupported sample rate suffix', async () => { + prepareAudioResponse(); + + const result = await model.doGenerate({ + text: 'Hello, world!', + voice: 'test-voice-id', + outputFormat: 'wav_12345', + }); + + expect( + (await server.calls[0].requestBodyJson).output_format, + ).toMatchObject({ + container: 'wav', + sample_rate: 44100, }); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "details": "Unsupported Cartesia sample rate in output format \"wav_12345\". Using 44100 Hz instead.", + "feature": "outputFormat", + "type": "unsupported", + }, + ] + `); }); it('should pass headers', async () => { @@ -200,7 +283,7 @@ describe('CartesiaSpeechModel', () => { expect(server.calls[0].requestHeaders).toMatchObject({ authorization: 'Bearer test-api-key', - 'cartesia-version': '2025-04-16', + 'cartesia-version': '2026-03-01', 'custom-provider-header': 'provider-header-value', 'custom-request-header': 'request-header-value', }); diff --git a/packages/cartesia/src/cartesia-speech-model.ts b/packages/cartesia/src/cartesia-speech-model.ts index 92afb92a2eb9..0225adb76220 100644 --- a/packages/cartesia/src/cartesia-speech-model.ts +++ b/packages/cartesia/src/cartesia-speech-model.ts @@ -11,7 +11,13 @@ import { import type { CartesiaConfig } from './cartesia-config'; import { cartesiaFailedResponseHandler } from './cartesia-error'; import { cartesiaSpeechModelOptionsSchema } from './cartesia-speech-model-options'; -import type { CartesiaSpeechAPITypes } from './cartesia-speech-api-types'; +import type { + CartesiaSpeechAPITypes, + CartesiaSpeechBitRate, + CartesiaSpeechEncoding, + CartesiaSpeechOutputFormat, + CartesiaSpeechSampleRate, +} from './cartesia-speech-api-types'; import type { CartesiaSpeechModelId, CartesiaSpeechVoiceId, @@ -26,11 +32,114 @@ interface CartesiaSpeechModelConfig extends CartesiaConfig { // Default output format used when no outputFormat / provider options are set. const DEFAULT_OUTPUT_FORMAT: CartesiaSpeechAPITypes['output_format'] = { container: 'mp3', - encoding: 'mp3', sample_rate: 44100, bit_rate: 128000, }; +const OUTPUT_FORMATS: Record = { + alaw: { container: 'raw', encoding: 'pcm_alaw', sample_rate: 8000 }, + mp3: DEFAULT_OUTPUT_FORMAT, + mulaw: { container: 'raw', encoding: 'pcm_mulaw', sample_rate: 8000 }, + pcm: { container: 'raw', encoding: 'pcm_f32le', sample_rate: 44100 }, + raw: { container: 'raw', encoding: 'pcm_f32le', sample_rate: 44100 }, + wav: { container: 'wav', encoding: 'pcm_s16le', sample_rate: 44100 }, +}; + +const SAMPLE_RATES = [8000, 16000, 22050, 24000, 44100, 48000] as const; + +function isSampleRate(value: number): value is CartesiaSpeechSampleRate { + return SAMPLE_RATES.includes(value as CartesiaSpeechSampleRate); +} + +function resolveOutputFormat({ + outputFormat, + providerOptions, + warnings, +}: { + outputFormat: string; + providerOptions: + | { + container?: 'raw' | 'wav' | 'mp3' | null; + encoding?: CartesiaSpeechEncoding | null; + sampleRate?: CartesiaSpeechSampleRate | null; + bitRate?: CartesiaSpeechBitRate | null; + } + | undefined; + warnings: SharedV4Warning[]; +}): CartesiaSpeechOutputFormat { + const [formatName, sampleRateText, ...extraParts] = outputFormat + .toLowerCase() + .split('_'); + const mapped = OUTPUT_FORMATS[formatName]; + let resolved = mapped ? { ...mapped } : { ...DEFAULT_OUTPUT_FORMAT }; + + if (!mapped) { + warnings.push({ + type: 'unsupported', + feature: 'outputFormat', + details: `Unknown output format "${outputFormat}". Falling back to mp3. Use providerOptions.cartesia to configure container, encoding, and sampleRate directly.`, + }); + } else if (sampleRateText != null) { + const parsedRate = Number(sampleRateText); + if ( + extraParts.length === 0 && + Number.isInteger(parsedRate) && + isSampleRate(parsedRate) + ) { + resolved.sample_rate = parsedRate; + } else { + warnings.push({ + type: 'unsupported', + feature: 'outputFormat', + details: `Unsupported Cartesia sample rate in output format "${outputFormat}". Using ${resolved.sample_rate} Hz instead.`, + }); + } + } + + const container = providerOptions?.container ?? resolved.container; + const sampleRate = providerOptions?.sampleRate ?? resolved.sample_rate; + + if (container === 'mp3') { + if (providerOptions?.encoding != null) { + warnings.push({ + type: 'unsupported', + feature: 'providerOptions.cartesia.encoding', + details: + 'Cartesia MP3 output does not accept an encoding. The encoding option was ignored.', + }); + } + + return { + container, + sample_rate: sampleRate, + bit_rate: + providerOptions?.bitRate ?? + (resolved.container === 'mp3' ? resolved.bit_rate : 128000), + }; + } + + if (providerOptions?.bitRate != null) { + warnings.push({ + type: 'unsupported', + feature: 'providerOptions.cartesia.bitRate', + details: + 'Cartesia raw and WAV output do not accept a bit rate. The bitRate option was ignored.', + }); + } + + return { + container, + encoding: + providerOptions?.encoding ?? + (resolved.container === 'mp3' + ? container === 'wav' + ? 'pcm_s16le' + : 'pcm_f32le' + : resolved.encoding), + sample_rate: sampleRate, + }; +} + export class CartesiaSpeechModel implements SpeechModelV4 { readonly specificationVersion = 'v4'; @@ -79,57 +188,11 @@ export class CartesiaSpeechModel implements SpeechModelV4 { throw new Error('Cartesia speech models require a `voice` to be set.'); } - // Build the base output format from the SDK's flat `outputFormat` string. - const outputFormatObject: CartesiaSpeechAPITypes['output_format'] = { - ...DEFAULT_OUTPUT_FORMAT, - }; - - if (outputFormat) { - const formatLower = outputFormat.toLowerCase(); - - // Common format mappings. Cartesia expects a structured output_format. - // https://docs.cartesia.ai/api-reference/tts/bytes - const formatMap: Record< - string, - { container: string; encoding: string; sampleRate: number } - > = { - mp3: { container: 'mp3', encoding: 'mp3', sampleRate: 44100 }, - wav: { container: 'wav', encoding: 'pcm_s16le', sampleRate: 44100 }, - pcm: { container: 'raw', encoding: 'pcm_f32le', sampleRate: 44100 }, - raw: { container: 'raw', encoding: 'pcm_f32le', sampleRate: 44100 }, - mulaw: { container: 'raw', encoding: 'pcm_mulaw', sampleRate: 8000 }, - alaw: { container: 'raw', encoding: 'pcm_alaw', sampleRate: 8000 }, - }; - - // Try direct match first, then try "_" pattern. - const parts = formatLower.split('_'); - const mapped = formatMap[parts[0]]; - - if (mapped) { - outputFormatObject.container = mapped.container; - outputFormatObject.encoding = mapped.encoding; - outputFormatObject.sample_rate = mapped.sampleRate; - if (mapped.container === 'mp3') { - outputFormatObject.bit_rate = 128000; - } else { - delete outputFormatObject.bit_rate; - } - - // Optional sample rate suffix, e.g. "wav_24000" or "pcm_16000". - if (parts.length >= 2) { - const parsedRate = parseInt(parts[1], 10); - if (!Number.isNaN(parsedRate)) { - outputFormatObject.sample_rate = parsedRate; - } - } - } else { - warnings.push({ - type: 'unsupported', - feature: 'outputFormat', - details: `Unknown output format "${outputFormat}". Falling back to mp3. Use providerOptions.cartesia to configure container/encoding/sampleRate directly.`, - }); - } - } + const outputFormatObject = resolveOutputFormat({ + outputFormat, + providerOptions: cartesiaOptions, + warnings, + }); // Create request body const requestBody: CartesiaSpeechAPITypes = { @@ -147,36 +210,25 @@ export class CartesiaSpeechModel implements SpeechModelV4 { requestBody.language = language; } - // Map generic speed - if (speed != null) { - requestBody.speed = speed; - } - - // Add provider-specific options - map camelCase to snake_case + // Provider-specific options override the corresponding generic options. if (cartesiaOptions) { - if (cartesiaOptions.container != null) { - requestBody.output_format.container = cartesiaOptions.container; - } - if (cartesiaOptions.encoding != null) { - requestBody.output_format.encoding = cartesiaOptions.encoding; - } - if (cartesiaOptions.sampleRate != null) { - requestBody.output_format.sample_rate = cartesiaOptions.sampleRate; - } - if (cartesiaOptions.bitRate != null) { - requestBody.output_format.bit_rate = cartesiaOptions.bitRate; - } - if (cartesiaOptions.speed != null) { - requestBody.speed = cartesiaOptions.speed; - } if (cartesiaOptions.language != null) { requestBody.language = cartesiaOptions.language; } } - // Remove bit_rate for non-mp3 containers where it is not applicable. - if (requestBody.output_format.container !== 'mp3') { - delete requestBody.output_format.bit_rate; + const resolvedSpeed = cartesiaOptions?.speed ?? speed; + if (resolvedSpeed != null) { + if (resolvedSpeed >= 0.6 && resolvedSpeed <= 1.5) { + requestBody.generation_config = { speed: resolvedSpeed }; + } else { + warnings.push({ + type: 'unsupported', + feature: 'speed', + details: + 'Cartesia speed must be between 0.6 and 1.5. The speed option was ignored.', + }); + } } if (instructions) { diff --git a/packages/cartesia/src/cartesia-speech-options.ts b/packages/cartesia/src/cartesia-speech-options.ts index 2794cc5ad7c2..5614220e958c 100644 --- a/packages/cartesia/src/cartesia-speech-options.ts +++ b/packages/cartesia/src/cartesia-speech-options.ts @@ -4,7 +4,6 @@ export type CartesiaSpeechModelId = | 'sonic-2' | 'sonic-turbo' | 'sonic-latest' - | 'sonic' | (string & {}); export type CartesiaSpeechVoiceId = string; diff --git a/packages/cartesia/src/cartesia-transcription-model-options.ts b/packages/cartesia/src/cartesia-transcription-model-options.ts index 6b5837dc513b..ab53964b7efe 100644 --- a/packages/cartesia/src/cartesia-transcription-model-options.ts +++ b/packages/cartesia/src/cartesia-transcription-model-options.ts @@ -2,7 +2,7 @@ import { z } from 'zod/v4'; // https://docs.cartesia.ai/api-reference/stt/transcribe export const cartesiaTranscriptionModelOptionsSchema = z.object({ - /** The language of the audio (ISO 639-1 code). If not specified, the language is auto-detected. */ + /** The language of the audio (ISO 639-1 code). Defaults to English. */ language: z.string().nullish(), /** The timestamp granularities to populate. Currently only `word` is supported. */ timestampGranularities: z.array(z.enum(['word'])).nullish(), From 1b37dd2d758ba61c94d54c7aa14be70eea1f7bc3 Mon Sep 17 00:00:00 2001 From: Carlton Aikins Date: Wed, 8 Jul 2026 10:23:20 -0700 Subject: [PATCH 5/7] fix(provider/cartesia): use patch changeset --- .changeset/bright-dolphins-speak.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bright-dolphins-speak.md b/.changeset/bright-dolphins-speak.md index 537ce18575e4..ff92434dcdcc 100644 --- a/.changeset/bright-dolphins-speak.md +++ b/.changeset/bright-dolphins-speak.md @@ -1,5 +1,5 @@ --- -'@ai-sdk/cartesia': minor +'@ai-sdk/cartesia': patch --- Add the Cartesia provider with Sonic speech generation and Ink-Whisper transcription support. From 026a88f745d887ae42e1c0b99191d4b2b3a83884 Mon Sep 17 00:00:00 2001 From: Carlton Aikins Date: Wed, 8 Jul 2026 10:23:20 -0700 Subject: [PATCH 6/7] fix(provider/cartesia): use patch changeset --- .changeset/bright-dolphins-speak.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bright-dolphins-speak.md b/.changeset/bright-dolphins-speak.md index 537ce18575e4..ff92434dcdcc 100644 --- a/.changeset/bright-dolphins-speak.md +++ b/.changeset/bright-dolphins-speak.md @@ -1,5 +1,5 @@ --- -'@ai-sdk/cartesia': minor +'@ai-sdk/cartesia': patch --- Add the Cartesia provider with Sonic speech generation and Ink-Whisper transcription support. From 9ddc0179a487c631f77419685b28a17eb453e2b7 Mon Sep 17 00:00:00 2001 From: Carlton Aikins Date: Wed, 8 Jul 2026 11:39:26 -0700 Subject: [PATCH 7/7] feat(provider/cartesia): add Ink 2 realtime support --- .changeset/bright-dolphins-speak.md | 3 +- .../01-ai-sdk-providers/95-cartesia.mdx | 47 ++- .../browser-realtime-transport.test.ts | 31 ++ .../realtime/browser-realtime-transport.ts | 11 +- packages/cartesia/README.md | 2 +- packages/cartesia/src/cartesia-provider.ts | 39 +++ .../src/cartesia-realtime-model-options.ts | 3 + .../src/cartesia-realtime-model.test.ts | 269 +++++++++++++++ .../cartesia/src/cartesia-realtime-model.ts | 326 ++++++++++++++++++ packages/cartesia/src/index.ts | 5 + 10 files changed, 730 insertions(+), 6 deletions(-) create mode 100644 packages/cartesia/src/cartesia-realtime-model-options.ts create mode 100644 packages/cartesia/src/cartesia-realtime-model.test.ts create mode 100644 packages/cartesia/src/cartesia-realtime-model.ts diff --git a/.changeset/bright-dolphins-speak.md b/.changeset/bright-dolphins-speak.md index ff92434dcdcc..52ffc5cfe0b3 100644 --- a/.changeset/bright-dolphins-speak.md +++ b/.changeset/bright-dolphins-speak.md @@ -1,5 +1,6 @@ --- '@ai-sdk/cartesia': patch +'ai': patch --- -Add the Cartesia provider with Sonic speech generation and Ink-Whisper transcription support. +Add the Cartesia provider with Sonic 3.5 speech generation, Ink-Whisper batch transcription, and Ink 2 realtime transcription support. diff --git a/content/providers/01-ai-sdk-providers/95-cartesia.mdx b/content/providers/01-ai-sdk-providers/95-cartesia.mdx index ca7c8a77ed98..01a4c75dba53 100644 --- a/content/providers/01-ai-sdk-providers/95-cartesia.mdx +++ b/content/providers/01-ai-sdk-providers/95-cartesia.mdx @@ -5,7 +5,8 @@ description: Learn how to use the Cartesia provider for the AI SDK. # Cartesia Provider -The [Cartesia](https://cartesia.ai/) provider contains speech generation (Sonic) and transcription (Ink) model support. +The [Cartesia](https://cartesia.ai/) provider contains Sonic speech generation, +Ink-Whisper batch transcription, and Ink 2 realtime transcription support. ## Setup @@ -155,9 +156,49 @@ The following provider options are available: | `sonic-2` | | `sonic-turbo` | | `sonic-latest` | -| `sonic` | -## Transcription Models +## Realtime Transcription Models + +Realtime is an experimental feature. + +You can create a model for Cartesia's realtime [Ink 2](https://docs.cartesia.ai/build-with-cartesia/stt/latest) +API using the `.experimental_realtime()` factory method: + +```ts +import { cartesia } from '@ai-sdk/cartesia'; + +const model = cartesia.experimental_realtime('ink-2'); +``` + +Realtime sessions run in the browser and require a short-lived access token +created on your server with `cartesia.experimental_realtime.getToken()`: + +```ts +const token = await cartesia.experimental_realtime.getToken({ + model: 'ink-2', + sessionConfig: { + inputAudioFormat: { type: 'audio/pcm', rate: 24000 }, + inputAudioTranscription: { language: 'en' }, + turnDetection: { type: 'server-vad' }, + }, +}); +``` + +Ink 2 produces input transcription events and supports English audio. Turn +detection is enabled by default. To use manual finalization instead, set +`turnDetection` to `null` in the session config and call `commitAudio()` when +the current input is complete. + +See [Realtime](/docs/ai-sdk-core/realtime) for the complete browser session +setup. + +### Model Capabilities + +| Model | Streaming Transcription | Turn Detection | +| ------- | ----------------------- | ------------------- | +| `ink-2` | | | + +## Batch Transcription Models You can create models that call the [Cartesia transcription API](https://docs.cartesia.ai/api-reference/stt/transcribe) using the `.transcription()` factory method. diff --git a/packages/ai/src/realtime/browser-realtime-transport.test.ts b/packages/ai/src/realtime/browser-realtime-transport.test.ts index 98a2c668f7d3..8d9335b1043e 100644 --- a/packages/ai/src/realtime/browser-realtime-transport.test.ts +++ b/packages/ai/src/realtime/browser-realtime-transport.test.ts @@ -166,4 +166,35 @@ describe('BrowserRealtimeTransport', () => { JSON.stringify({ type: 'second' }), ); }); + + it('sends serialized strings and binary data without JSON encoding', async () => { + const bytes = new Uint8Array([1, 2, 3]); + const rawModel = { + getWebSocketConfig: ({ url }: { url: string }) => ({ url }), + serializeClientEvent: (event: { type: string }) => + event.type === 'binary' ? bytes : 'finalize', + parseServerEvent: (raw: unknown) => raw, + } as never; + const transport = new BrowserRealtimeTransport({ + model: rawModel, + onServerEvent: vi.fn(), + onError: vi.fn(), + onClose: vi.fn(), + }); + + transport.connect({ + token: 'token', + url: 'wss://example.com', + onOpen: vi.fn(), + }); + const ws = MockWebSocket.instances[0]; + ws.open(); + + transport.sendEvent({ type: 'text' } as never); + transport.sendEvent({ type: 'binary' } as never); + await flush(); + + expect(ws.send).toHaveBeenNthCalledWith(1, 'finalize'); + expect(ws.send).toHaveBeenNthCalledWith(2, bytes); + }); }); diff --git a/packages/ai/src/realtime/browser-realtime-transport.ts b/packages/ai/src/realtime/browser-realtime-transport.ts index e3bbded14b70..d106125ecea5 100644 --- a/packages/ai/src/realtime/browser-realtime-transport.ts +++ b/packages/ai/src/realtime/browser-realtime-transport.ts @@ -94,7 +94,16 @@ export class BrowserRealtimeTransport { sendRaw(data: unknown): void { if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(data)); + if ( + typeof data === 'string' || + data instanceof ArrayBuffer || + ArrayBuffer.isView(data) || + data instanceof Blob + ) { + this.ws.send(data); + } else { + this.ws.send(JSON.stringify(data)); + } } } diff --git a/packages/cartesia/README.md b/packages/cartesia/README.md index 823cbc4bb3e0..b2548fa5111b 100644 --- a/packages/cartesia/README.md +++ b/packages/cartesia/README.md @@ -1,7 +1,7 @@ # AI SDK - Cartesia Provider The **[Cartesia provider](https://ai-sdk.dev/providers/ai-sdk-providers/cartesia)** for the [AI SDK](https://ai-sdk.dev/docs) -contains speech model support for the Cartesia text-to-speech API (Sonic) and transcription model support for the Cartesia speech-to-text API (Ink-Whisper). +contains Sonic 3.5 speech generation, Ink-Whisper batch transcription, and Ink 2 realtime transcription support. > **Deploying to Vercel?** With Vercel's AI Gateway you can access Cartesia (and hundreds of models from other providers) — no additional packages, API keys, or extra cost. [Get started with AI Gateway](https://vercel.com/ai-gateway). diff --git a/packages/cartesia/src/cartesia-provider.ts b/packages/cartesia/src/cartesia-provider.ts index aa9c5b6b2933..b852b1cf60d9 100644 --- a/packages/cartesia/src/cartesia-provider.ts +++ b/packages/cartesia/src/cartesia-provider.ts @@ -1,4 +1,6 @@ import { + type Experimental_RealtimeFactoryV4 as RealtimeFactoryV4, + type Experimental_RealtimeFactoryV4GetTokenOptions as RealtimeFactoryV4GetTokenOptions, NoSuchModelError, type TranscriptionModelV4, type SpeechModelV4, @@ -13,6 +15,8 @@ import { CartesiaTranscriptionModel } from './cartesia-transcription-model'; import type { CartesiaTranscriptionModelId } from './cartesia-transcription-options'; import { CartesiaSpeechModel } from './cartesia-speech-model'; import type { CartesiaSpeechModelId } from './cartesia-speech-options'; +import { CartesiaRealtimeModel } from './cartesia-realtime-model'; +import type { CartesiaRealtimeModelId } from './cartesia-realtime-model-options'; import { VERSION } from './version'; /** @@ -39,6 +43,11 @@ export interface CartesiaProvider extends ProviderV4 { */ speech(modelId: CartesiaSpeechModelId): SpeechModelV4; + /** + * Creates a realtime Ink speech-to-text model. + */ + experimental_realtime: RealtimeFactoryV4; + /** * @deprecated Use `embeddingModel` instead. */ @@ -104,6 +113,35 @@ export function createCartesia( fetch: options.fetch, }); + const createRealtimeModel = (modelId: CartesiaRealtimeModelId) => + new CartesiaRealtimeModel(modelId, { + provider: `cartesia.realtime`, + baseURL: 'https://api.cartesia.ai', + version: options.version ?? CARTESIA_API_VERSION, + headers: getHeaders, + fetch: options.fetch, + }); + + const experimentalRealtimeFactory = Object.assign( + (modelId: CartesiaRealtimeModelId) => createRealtimeModel(modelId), + { + getToken: async (tokenOptions: RealtimeFactoryV4GetTokenOptions) => { + const secret = await createRealtimeModel( + tokenOptions.model as CartesiaRealtimeModelId, + ).doCreateClientSecret({ + sessionConfig: tokenOptions.sessionConfig, + expiresAfterSeconds: tokenOptions.expiresAfterSeconds, + }); + + return { + token: secret.token, + url: secret.url, + expiresAt: secret.expiresAt, + }; + }, + }, + ) as RealtimeFactoryV4; + const provider = function (modelId: CartesiaSpeechModelId) { return { speech: createSpeechModel(modelId), @@ -115,6 +153,7 @@ export function createCartesia( provider.transcriptionModel = createTranscriptionModel; provider.speech = createSpeechModel; provider.speechModel = createSpeechModel; + provider.experimental_realtime = experimentalRealtimeFactory; // Required ProviderV4 methods that are not supported provider.languageModel = (modelId: string) => { diff --git a/packages/cartesia/src/cartesia-realtime-model-options.ts b/packages/cartesia/src/cartesia-realtime-model-options.ts new file mode 100644 index 000000000000..ef1d2e4cd466 --- /dev/null +++ b/packages/cartesia/src/cartesia-realtime-model-options.ts @@ -0,0 +1,3 @@ +export type CartesiaRealtimeModelId = 'ink-2' | (string & {}); + +export type CartesiaRealtimeModelOptions = Record; diff --git a/packages/cartesia/src/cartesia-realtime-model.test.ts b/packages/cartesia/src/cartesia-realtime-model.test.ts new file mode 100644 index 000000000000..5cf47624efed --- /dev/null +++ b/packages/cartesia/src/cartesia-realtime-model.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CartesiaRealtimeModel } from './cartesia-realtime-model'; + +const createModel = (fetch: typeof globalThis.fetch = vi.fn()) => + new CartesiaRealtimeModel('ink-2', { + provider: 'cartesia.realtime', + baseURL: 'https://api.cartesia.ai', + version: '2026-03-01', + headers: () => ({ + Authorization: 'Bearer test-key', + 'Cartesia-Version': '2026-03-01', + }), + fetch, + _internal: { + currentDate: () => new Date('2026-07-08T12:00:00.000Z'), + }, + }); + +describe('CartesiaRealtimeModel', () => { + describe('doCreateClientSecret', () => { + it('creates an Ink 2 auto-finalize client secret', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ token: 'access-token' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const model = createModel(mockFetch as unknown as typeof fetch); + + const result = await model.doCreateClientSecret({ + expiresAfterSeconds: 60, + sessionConfig: { + inputAudioFormat: { type: 'audio/pcm', rate: 16000 }, + inputAudioTranscription: { language: 'en' }, + turnDetection: { type: 'server-vad' }, + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.cartesia.ai/access-token', + { + method: 'POST', + headers: { + Authorization: 'Bearer test-key', + 'Cartesia-Version': '2026-03-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grants: { stt: true }, + expires_in: 60, + }), + }, + ); + expect(result).toEqual({ + token: 'access-token', + url: 'wss://api.cartesia.ai/stt/turns/websocket?model=ink-2&encoding=pcm_s16le&sample_rate=16000&cartesia_version=2026-03-01', + expiresAt: 1783512060, + }); + }); + + it('uses the manual-finalize endpoint when turn detection is disabled', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ token: 'access-token' }), { + status: 200, + }), + ); + const model = createModel(mockFetch as unknown as typeof fetch); + + const result = await model.doCreateClientSecret({ + sessionConfig: { + inputAudioFormat: { type: 'audio/pcmu', rate: 8000 }, + inputAudioTranscription: { language: 'en' }, + turnDetection: null, + }, + }); + + expect(result.url).toBe( + 'wss://api.cartesia.ai/stt/websocket?model=ink-2&encoding=pcm_mulaw&sample_rate=8000&cartesia_version=2026-03-01&language=en', + ); + }); + + it('rejects unsupported languages and token lifetimes', async () => { + const model = createModel(); + + await expect( + model.doCreateClientSecret({ + sessionConfig: { + inputAudioTranscription: { language: 'es' }, + }, + }), + ).rejects.toThrow('currently supports English only'); + await expect( + model.doCreateClientSecret({ expiresAfterSeconds: 3601 }), + ).rejects.toThrow('between 1 and 3600 seconds'); + }); + }); + + it('adds the access token to the WebSocket URL', () => { + const model = createModel(); + + expect( + model.getWebSocketConfig({ + token: 'access-token', + url: 'wss://api.cartesia.ai/stt/turns/websocket?model=ink-2', + }), + ).toEqual({ + url: 'wss://api.cartesia.ai/stt/turns/websocket?model=ink-2&access_token=access-token', + }); + }); + + describe('serializeClientEvent', () => { + it('sends audio as binary and lets auto-finalize detect turns', () => { + const model = createModel(); + model.getWebSocketConfig({ + token: 'token', + url: 'wss://api.cartesia.ai/stt/turns/websocket?model=ink-2', + }); + + expect( + model.serializeClientEvent({ + type: 'input-audio-append', + audio: 'AQID', + }), + ).toEqual(new Uint8Array([1, 2, 3])); + expect( + model.serializeClientEvent({ type: 'input-audio-commit' }), + ).toBeNull(); + expect( + model.serializeClientEvent({ + type: 'session-update', + config: {}, + }), + ).toBeNull(); + }); + + it('sends finalize for manual turn detection', () => { + const model = createModel(); + model.getWebSocketConfig({ + token: 'token', + url: 'wss://api.cartesia.ai/stt/websocket?model=ink-2', + }); + + expect(model.serializeClientEvent({ type: 'input-audio-commit' })).toBe( + 'finalize', + ); + }); + }); + + describe('parseServerEvent', () => { + it('maps Ink 2 turn events to normalized realtime events', () => { + const model = createModel(); + const connected = { type: 'connected', request_id: 'request-1' }; + const turnStart = { type: 'turn.start', request_id: 'request-1' }; + const turnEnd = { + type: 'turn.end', + request_id: 'request-1', + transcript: 'Hello world', + }; + + expect(model.parseServerEvent(connected)).toEqual({ + type: 'session-created', + sessionId: 'request-1', + raw: connected, + }); + expect(model.parseServerEvent(turnStart)).toEqual({ + type: 'speech-started', + itemId: 'request-1', + raw: turnStart, + }); + expect(model.parseServerEvent(turnEnd)).toEqual([ + { + type: 'speech-stopped', + itemId: 'request-1', + raw: turnEnd, + }, + { + type: 'input-transcription-completed', + itemId: 'request-1', + transcript: 'Hello world', + raw: turnEnd, + }, + ]); + }); + + it('assembles manual transcript chunks when a flush completes', () => { + const model = createModel(); + const first = { + type: 'transcript', + request_id: 'request-1', + text: 'Hello ', + is_final: true, + duration: 0.5, + }; + const second = { + type: 'transcript', + request_id: 'request-1', + text: 'world', + is_final: true, + duration: 0.4, + }; + const flush = { type: 'flush_done', request_id: 'request-1' }; + + expect(model.parseServerEvent(first)).toEqual([ + { + type: 'session-created', + sessionId: 'request-1', + raw: first, + }, + { type: 'custom', rawType: 'transcript', raw: first }, + ]); + model.parseServerEvent(second); + expect(model.parseServerEvent(flush)).toEqual([ + { + type: 'audio-committed', + itemId: 'request-1', + raw: flush, + }, + { + type: 'input-transcription-completed', + itemId: 'request-1', + transcript: 'Hello world', + raw: { + event: flush, + transcriptEvents: [first, second], + duration: 0.9, + }, + }, + ]); + }); + + it('emits one custom event when a manual stream closes without text', () => { + const model = createModel(); + const done = { type: 'done', request_id: 'request-1' }; + + expect(model.parseServerEvent(done)).toEqual([ + { + type: 'session-created', + sessionId: 'request-1', + raw: done, + }, + { type: 'custom', rawType: 'done', raw: done }, + ]); + }); + + it('maps structured Cartesia errors', () => { + const model = createModel(); + const error = { + type: 'error', + message: 'Invalid model', + error_code: 'model_not_found', + request_id: 'request-1', + }; + + expect(model.parseServerEvent(error)).toEqual([ + { + type: 'session-created', + sessionId: 'request-1', + raw: error, + }, + { + type: 'error', + message: 'Invalid model', + code: 'model_not_found', + raw: error, + }, + ]); + }); + }); +}); diff --git a/packages/cartesia/src/cartesia-realtime-model.ts b/packages/cartesia/src/cartesia-realtime-model.ts new file mode 100644 index 000000000000..dc19d279fb11 --- /dev/null +++ b/packages/cartesia/src/cartesia-realtime-model.ts @@ -0,0 +1,326 @@ +import type { + Experimental_RealtimeModelV4 as RealtimeModelV4, + Experimental_RealtimeModelV4ClientEvent as RealtimeModelV4ClientEvent, + Experimental_RealtimeModelV4ClientSecretOptions as RealtimeModelV4ClientSecretOptions, + Experimental_RealtimeModelV4ClientSecretResult as RealtimeModelV4ClientSecretResult, + Experimental_RealtimeModelV4ServerEvent as RealtimeModelV4ServerEvent, + Experimental_RealtimeModelV4SessionConfig as RealtimeModelV4SessionConfig, +} from '@ai-sdk/provider'; +import { + convertBase64ToUint8Array, + type FetchFunction, +} from '@ai-sdk/provider-utils'; +import type { CartesiaRealtimeModelId } from './cartesia-realtime-model-options'; + +export type CartesiaRealtimeModelConfig = { + provider: string; + baseURL: string; + version: string; + headers: () => Record; + fetch?: FetchFunction; + _internal?: { + currentDate?: () => Date; + }; +}; + +type CartesiaRealtimeWireEvent = { + type: string; + request_id?: string; + transcript?: string; + text?: string; + is_final?: boolean; + duration?: number; + message?: string; + error_code?: string; +}; + +type ManualTranscriptState = { + duration: number; + events: CartesiaRealtimeWireEvent[]; + text: string; +}; + +/** + * Realtime Ink model using Cartesia's STT WebSocket APIs. + */ +export class CartesiaRealtimeModel implements RealtimeModelV4 { + readonly specificationVersion = 'v4' as const; + readonly provider: string; + readonly modelId: CartesiaRealtimeModelId; + + private readonly config: CartesiaRealtimeModelConfig; + private manualTranscript: ManualTranscriptState = { + duration: 0, + events: [], + text: '', + }; + private sessionCreated = false; + private useTurnDetection = true; + + constructor( + modelId: CartesiaRealtimeModelId, + config: CartesiaRealtimeModelConfig, + ) { + this.modelId = modelId; + this.provider = config.provider; + this.config = config; + } + + async doCreateClientSecret( + options: RealtimeModelV4ClientSecretOptions, + ): Promise { + const expiresIn = options.expiresAfterSeconds; + if ( + expiresIn != null && + (!Number.isInteger(expiresIn) || expiresIn <= 0 || expiresIn > 3600) + ) { + throw new Error( + 'Cartesia realtime client secrets must expire between 1 and 3600 seconds.', + ); + } + + const sessionConfig = options.sessionConfig; + const language = sessionConfig?.inputAudioTranscription?.language; + if (language != null && language !== 'en') { + throw new Error('Cartesia Ink 2 currently supports English only.'); + } + + const fetchFn = this.config.fetch ?? fetch; + const response = await fetchFn(`${this.config.baseURL}/access-token`, { + method: 'POST', + headers: { + ...this.config.headers(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grants: { stt: true }, + ...(expiresIn != null ? { expires_in: expiresIn } : {}), + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Cartesia realtime client secret request failed: ${response.status} ${text}`, + ); + } + + const data = (await response.json()) as { token?: unknown }; + if (typeof data.token !== 'string' || data.token.length === 0) { + throw new Error( + 'Cartesia realtime client secret response did not include a token.', + ); + } + + const turnDetection = sessionConfig?.turnDetection; + const useTurnDetection = + turnDetection === undefined || + (turnDetection !== null && turnDetection.type !== 'disabled'); + const inputAudioFormat = sessionConfig?.inputAudioFormat; + const url = new URL( + useTurnDetection + ? `${this.config.baseURL}/stt/turns/websocket` + : `${this.config.baseURL}/stt/websocket`, + ); + url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'; + url.searchParams.set('model', this.modelId); + url.searchParams.set( + 'encoding', + resolveCartesiaEncoding(inputAudioFormat?.type), + ); + url.searchParams.set( + 'sample_rate', + String(inputAudioFormat?.rate ?? 24000), + ); + url.searchParams.set('cartesia_version', this.config.version); + if (!useTurnDetection && language != null) { + url.searchParams.set('language', language); + } + + return { + token: data.token, + url: url.toString(), + ...(expiresIn != null + ? { + expiresAt: + Math.floor( + ( + this.config._internal?.currentDate?.() ?? new Date() + ).getTime() / 1000, + ) + expiresIn, + } + : {}), + }; + } + + getWebSocketConfig(options: { token: string; url: string }): { + url: string; + } { + const url = new URL(options.url); + this.useTurnDetection = url.pathname.includes('/stt/turns/websocket'); + url.searchParams.set('access_token', options.token); + return { url: url.toString() }; + } + + parseServerEvent( + raw: unknown, + ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] { + const event = raw as CartesiaRealtimeWireEvent; + const requestId = event.request_id ?? 'cartesia-realtime'; + let mapped: RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[]; + + switch (event.type) { + case 'connected': + this.sessionCreated = true; + return { + type: 'session-created', + sessionId: event.request_id, + raw, + }; + + case 'turn.start': + mapped = { type: 'speech-started', itemId: requestId, raw }; + break; + + case 'turn.resume': + mapped = [ + { type: 'speech-started', itemId: requestId, raw }, + { type: 'custom', rawType: event.type, raw }, + ]; + break; + + case 'turn.end': + mapped = [ + { type: 'speech-stopped', itemId: requestId, raw }, + { + type: 'input-transcription-completed', + itemId: requestId, + transcript: event.transcript ?? '', + raw, + }, + ]; + break; + + case 'transcript': + if (event.is_final === true) { + this.manualTranscript.text += event.text ?? ''; + this.manualTranscript.duration += event.duration ?? 0; + this.manualTranscript.events.push(event); + } + mapped = { type: 'custom', rawType: event.type, raw }; + break; + + case 'flush_done': + mapped = this.finishManualTranscript(event, raw); + break; + + case 'done': { + if (this.manualTranscript.text.length > 0) { + const completed = this.finishManualTranscript(event, raw); + mapped = Array.isArray(completed) + ? [...completed, { type: 'custom', rawType: event.type, raw }] + : [completed, { type: 'custom', rawType: event.type, raw }]; + } else { + mapped = { type: 'custom', rawType: event.type, raw }; + } + break; + } + + case 'error': + mapped = { + type: 'error', + message: event.message ?? 'Unknown Cartesia realtime error', + code: event.error_code, + raw, + }; + break; + + default: + mapped = { type: 'custom', rawType: event.type, raw }; + break; + } + + return this.withSessionCreated(mapped, requestId, raw); + } + + serializeClientEvent(event: RealtimeModelV4ClientEvent): unknown { + switch (event.type) { + case 'session-update': + case 'input-audio-clear': + case 'response-create': + return null; + case 'input-audio-append': + return convertBase64ToUint8Array(event.audio); + case 'input-audio-commit': + return this.useTurnDetection ? null : 'finalize'; + default: + throw new Error( + `Cartesia Ink 2 does not support realtime client event "${event.type}".`, + ); + } + } + + buildSessionConfig(_config: RealtimeModelV4SessionConfig): null { + return null; + } + + private finishManualTranscript( + event: CartesiaRealtimeWireEvent, + raw: unknown, + ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] { + const state = this.manualTranscript; + this.manualTranscript = { duration: 0, events: [], text: '' }; + if (state.text.length === 0) { + return { type: 'custom', rawType: event.type, raw }; + } + + return [ + { + type: 'audio-committed', + itemId: event.request_id, + raw, + }, + { + type: 'input-transcription-completed', + itemId: event.request_id ?? 'cartesia-realtime', + transcript: state.text, + raw: { + event, + transcriptEvents: state.events, + duration: state.duration, + }, + }, + ]; + } + + private withSessionCreated( + event: RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[], + sessionId: string, + raw: unknown, + ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] { + if (this.sessionCreated) { + return event; + } + this.sessionCreated = true; + const created: RealtimeModelV4ServerEvent = { + type: 'session-created', + sessionId, + raw, + }; + return Array.isArray(event) ? [created, ...event] : [created, event]; + } +} + +function resolveCartesiaEncoding(type: string | undefined): string { + switch (type) { + case undefined: + case 'audio/pcm': + return 'pcm_s16le'; + case 'audio/pcmu': + return 'pcm_mulaw'; + case 'audio/pcma': + return 'pcm_alaw'; + default: + throw new Error(`Unsupported Cartesia realtime audio format: ${type}`); + } +} diff --git a/packages/cartesia/src/index.ts b/packages/cartesia/src/index.ts index 66a7962eb57b..1bb4e9091368 100644 --- a/packages/cartesia/src/index.ts +++ b/packages/cartesia/src/index.ts @@ -5,6 +5,11 @@ export type { } from './cartesia-provider'; export { CartesiaSpeechModel } from './cartesia-speech-model'; export { CartesiaTranscriptionModel } from './cartesia-transcription-model'; +export { CartesiaRealtimeModel } from './cartesia-realtime-model'; +export type { + CartesiaRealtimeModelId, + CartesiaRealtimeModelOptions, +} from './cartesia-realtime-model-options'; export type { CartesiaSpeechModelId, CartesiaSpeechVoiceId,