Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions src/catalog/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,19 @@ const MARKS_DIR = resolve(import.meta.dir, '../../public/marks');

describe('registry: ids, slugs, refs', () => {
it('pins the collection counts (bump intentionally when adding entries)', () => {
// 12 providers / 24 models: the 11 vendors + 23 TTS models, plus the Human
// 12 providers / 25 models: the 11 vendors + 24 TTS models, plus the Human
// baseline (provider `human`, model `human`). Two ElevenLabs entries are
// retired (2026-07-23), so the arena carries 22 of the 24.
// retired (2026-07-23) and Sonic 3.6 is unlisted pending its clips, so the
// arena carries 22 of the 25.
expect(BASE_PROVIDER_ENTRIES.length).toBe(12);
expect(BASE_MODEL_ENTRIES.length).toBe(24);
expect(BASE_MODEL_ENTRIES.length).toBe(25);
expect(arenaProviderEntries().length).toBe(12);
expect(arenaModelEntries().length).toBe(22);
// The committed registry carries no unlisted entries today; embargoed
// ones live in the private overlay until their providers announce them.
expect(BASE_MODEL_ENTRIES.filter((m) => m.status === 'unlisted')).toEqual(
[],
);
// Sonic 3.6 is committed but unlisted until its clips are generated;
// flipping status to 'active' is the one-line re-list.
expect(
BASE_MODEL_ENTRIES.filter((m) => m.status === 'unlisted').map((m) => m.id),
).toEqual(['cartesia-sonic-36']);
});

it('constrains the overlay to unlisted models with resolvable providers', () => {
Expand Down Expand Up @@ -167,6 +168,7 @@ describe('registry: ids, slugs, refs', () => {
'neuphonic-neu-hq',
'speechify-simba-3-2',
'fish-s21-pro',
'cartesia-sonic-36',
]);
const seedIds = SEED_STANDINGS.models.map((row) => row.id);
expect(new Set(seedIds).size).toBe(seedIds.length);
Expand Down Expand Up @@ -363,7 +365,7 @@ const EXPECTED_MODELS = [
},
{
id: 'cartesia-sonic-35',
arenaId: 'cartesia:sonic-3.5',
arenaId: 'cartesia:sonic-3.5-2026-05-04',
providerId: 'cartesia',
name: 'Sonic 3.5',
},
Expand Down Expand Up @@ -545,9 +547,9 @@ describe('server/catalog derivation equality', () => {
'2f77e73d0fe4bcfe4769ed7f4f44329c',
],
[
'variant:voice-nelliot:cartesia:sonic-3.5',
'variant:voice-nelliot:cartesia:sonic-3.5-2026-05-04',
'clip-20',
'7d3e067846de9940a7f4e3e0bde1cf68',
'76f803cefea4c90953672d0c763d8fc2',
],
];
for (const [variantId, promptId, hash] of goldens) {
Expand Down
68 changes: 62 additions & 6 deletions src/catalog/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ export const MODEL_ENTRIES: ModelEntry[] = [
{
question: "Is Sonic still Cartesia's current model?",
answer:
'No. Three newer generations have superseded it, with Sonic 3.5 as the current flagship. Sonic remains in the arena as the baseline of the family.',
'No. Four newer generations have superseded it, with Sonic 3.6 as the current flagship. Sonic remains in the arena as the baseline of the family.',
},
],
},
Expand Down Expand Up @@ -665,8 +665,8 @@ export const MODEL_ENTRIES: ModelEntry[] = [
slug: 'cartesia-sonic-3-5',
providerId: 'cartesia',
name: 'Sonic 3.5',
apiModelId: 'sonic-3.5',
arenaApiId: 'sonic-3.5',
apiModelId: 'sonic-3.5-2026-05-04',
arenaApiId: 'sonic-3.5-2026-05-04',
status: 'active',
releaseDate: {
value: '2026-05-04',
Expand All @@ -684,16 +684,16 @@ export const MODEL_ENTRIES: ModelEntry[] = [
fallbackClip: clip(
'voice-emma',
'cartesia',
'sonic-3.5',
'sonic-3.5-2026-05-04',
'clip-20',
'5e21eefdad513597d684cb9ec3b0815d',
'3e36a1883267dd73b9e11f42b78ede03',
),
},
copy: [
{
heading: 'Background',
paragraphs: [
"Sonic 3.5 is Cartesia's current flagship, released in May 2026. Cartesia positions it as its most natural and fastest model, with sub 90 ms latency and native support for 42 languages. It is tuned for production agent transcripts: it reads order numbers, emails, and confirmation codes correctly without preprocessing, and it resolves heteronyms like read and bow from the surrounding words.",
"Sonic 3.5 is the May 2026 Sonic snapshot, and was Cartesia's flagship until Sonic 3.6 shipped in August 2026. Cartesia positioned it as its most natural and fastest model at release, with sub 90 ms latency and native support for 42 languages. It is tuned for production agent transcripts: it reads order numbers, emails, and confirmation codes correctly without preprocessing, and it resolves heteronyms like read and bow from the surrounding words.",
],
sourceUrls: [
'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest',
Expand All @@ -719,6 +719,62 @@ export const MODEL_ENTRIES: ModelEntry[] = [
],
},

{
id: 'cartesia-sonic-36',
slug: 'cartesia-sonic-3-6',
providerId: 'cartesia',
name: 'Sonic 3.6',
apiModelId: 'sonic-3.6-2026-08-27',
arenaApiId: 'sonic-3.6-2026-08-27',
status: 'unlisted',
releaseDate: {
value: '2026-08-27',
sourceUrl:
'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest',
asOf: '2026-08-26',
note: 'Snapshot release.',
},
stats: {
// Measured only: needs a bench run against sonic-3.6-2026-08-27.
latencyMs: null,
languages: {
value: 44,
sourceUrl:
'https://docs.cartesia.ai/build-with-cartesia/tts-models/preview',
asOf: '2026-08-26',
},
},
voiceProfile: 27,
copy: [
{
heading: 'Background',
paragraphs: [
"Sonic 3.6 is Cartesia's current flagship model. Cartesia positions it as another step change in naturalness with sub 90 ms latency and native support for 44 languages. The model adapts intonation, pacing, and emotiveness to the conversation like a human would.",
],
sourceUrls: [
'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest',
],
},
{
heading: 'At a glance',
paragraphs: [
'Alphanumerics and heteronyms without preprocessing, 44 languages, and a published sub 90 ms latency claim. It has not been through our 50 trial streaming benchmark yet.',
],
sourceUrls: [
'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest',
],
},
],
faq: [
HOW_TESTED('Sonic 3.6'),
{
question: 'How fast is Sonic 3.6?',
answer:
'Cartesia publishes sub 90 ms latency. We have not yet measured it in our 50 trial streaming benchmark.',
},
],
},

/* --------------------------------- xAI ---------------------------------- */
{
id: 'xai-xai-tts',
Expand Down
8 changes: 6 additions & 2 deletions src/pipeline/cloneVoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,12 @@ const main = async (): Promise<void> => {
displayName,
sampleFiles: samples,
});
additions[voiceId] = providerVoiceId;
console.log(`✓ ${voiceId} → ${providerVoiceId} (${samples.length} samples)`);
if (providerVoiceId) {
additions[voiceId] = providerVoiceId;
console.log(`✓ ${voiceId} → ${providerVoiceId} (${samples.length} samples)`);
} else {
console.log(`… ${voiceId}: training started (${samples.length} samples)`);
}
} catch (error) {
console.error(
`✗ ${voiceId}: ${error instanceof Error ? error.message : String(error)}`,
Expand Down
133 changes: 98 additions & 35 deletions src/pipeline/transports/cartesia.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,67 @@
/**
* Cartesia transport. Synthesis ported from the original prototype's
* Cartesia adapter (tts/bytes, mp3 44.1 kHz /
* 128 kbps); instant cloning per
* https://docs.cartesia.ai/build-with-cartesia/capability-guides/clone-voices;
* 128 kbps); pro voice cloning per
* https://docs.cartesia.ai/build-with-cartesia/capability-guides/clone-voices-pro;
* TTFB over the realtime WS, same 50-trial protocol (arena Clara clone).
*/
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';

import { requireEnv } from '../env';
import { BENCH_TEXT, postFormForJson, postJsonForBytes, wsTtfbTrial } from './http';
import type { ProviderTransport, TtfbPlan } from './types';
import { BENCH_TEXT, postJsonForBytes, requestJson, throwForStatus, wsTtfbTrial } from './http';
import { TransportError, type ProviderTransport, type TtfbPlan } from './types';

const API = 'https://api.cartesia.ai';
const VERSION = '2024-11-13';
const WS_VERSION = '2025-04-16';
/** The arena Clara clone — the voice the original 50-trial bench used. */

/** Cartesia's current published API version, sent on every call. */
const VERSION = '2026-08-14';

/** The arena Clara clone, the voice the original 50-trial bench used. */
const BENCH_VOICE = 'a5d537b0-4a5f-464d-ac12-d143fe1a0a36';

const apiKey = (): string => requireEnv('CARTESIA_API_KEY');
/** The four licensed source voices are English (see pipeline/voices.ts). */
const VOICE_LANGUAGE = 'en';

/** Train against Sonic 3.5; PVCs forward-fill onto new models as they ship. */
const PVC_BASE_MODEL = 'sonic-3.5-2026-05-04';

/** Where training is tracked and the finished voice id is collected. */
const PVC_DASHBOARD = 'https://play.cartesia.ai';

/** Sample uploads are large; matches the shared multipart timeout. */
const UPLOAD_TIMEOUT_MS = 180_000;

/** Bearer is the documented scheme; x-api-key is the pre-2026 form. */
const headers = (): Record<string, string> => ({
Authorization: `Bearer ${requireEnv('CARTESIA_API_KEY')}`,
'Cartesia-Version': VERSION,
});

type Dataset = { id: string };
type FineTune = { id: string };

/**
* Upload one sample to a dataset. Separate from the shared postFormForJson
* helper because this endpoint answers 204 with no body, so there is nothing
* to parse.
*/
const uploadSample = async (
datasetId: string,
file: string,
auth: Record<string, string>,
): Promise<void> => {
const form = new FormData();
form.append('file', new Blob([readFileSync(file)], { type: 'audio/wav' }), basename(file));
form.append('purpose', 'fine_tune');
const response = await fetch(`${API}/datasets/${datasetId}/files`, {
method: 'POST',
headers: auth,
body: form,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
});
await throwForStatus(response, `cartesia datasets/upload-file ${basename(file)}`);
};

export const cartesia: ProviderTransport = {
providerId: 'cartesia',
Expand All @@ -27,50 +70,72 @@ export const cartesia: ProviderTransport = {
synthesize: async ({ vendorModelId, providerVoiceId, text }) => ({
bytes: await postJsonForBytes(
`${API}/tts/bytes`,
{
'x-api-key': apiKey(),
'cartesia-version': VERSION,
accept: 'audio/mpeg',
},
{ ...headers(), accept: 'audio/mpeg' },
{
model_id: vendorModelId,
transcript: text,
voice: { mode: 'id', id: providerVoiceId },
// Cartesia recommends setting the language explicitly where possible.
language: VOICE_LANGUAGE,
output_format: { container: 'mp3', sample_rate: 44100, bit_rate: 128000 },
},
'cartesia',
),
format: 'mp3' as const,
}),

/** Pro Voice Clone: dataset, files, then kick off the fine-tune. */
createClone: async ({ displayName, sampleFiles }) => {
const form = new FormData();
form.append(
'clip',
new Blob([readFileSync(sampleFiles[0])], { type: 'audio/wav' }),
basename(sampleFiles[0]),
if (sampleFiles.length === 0) {
throw new TransportError('cartesia PVC needs at least one sample file');
}
const auth = headers();
const description = `Humanness Index source voice ${displayName}`;

const dataset = await requestJson<Dataset>(
'POST',
`${API}/datasets`,
auth,
{ name: displayName, description },
'cartesia datasets/create',
);

// A PVC trains on the whole dataset, so upload every sample.
for (const file of sampleFiles) {
await uploadSample(dataset.id, file, auth);
}

const fineTune = await requestJson<FineTune>(
'POST',
`${API}/fine-tunes`,
auth,
{
name: displayName,
description,
language: VOICE_LANGUAGE,
model_id: PVC_BASE_MODEL,
dataset: dataset.id,
},
'cartesia fine-tunes/create',
);
form.append('name', displayName);
form.append('language', 'en');
form.append('mode', 'similarity');
const result = await postFormForJson<{ id: string }>(
`${API}/voices/clone`,
{ 'x-api-key': apiKey(), 'cartesia-version': VERSION },
form,
'cartesia voices/clone',

// Training can take up to 3 hours, so this returns instead of waiting.
// All four voices kick off in one run and train concurrently. Null means
// there is no voice id yet; collect it once training finishes and persist
// it with `humanness:clone cartesia --record <voice-key>=<voice-id>`.
console.log(
` ${fineTune.id} started. Training takes up to 3 hours; collect the ` +
`voice id from ${PVC_DASHBOARD} once it completes.`,
);
return result.id;
return null;
},

ttfbPlanFor: (vendorModelId): TtfbPlan => ({
transport: 'websocket',
trial: () =>
wsTtfbTrial({
url: 'wss://api.cartesia.ai/tts/websocket',
headers: {
Authorization: `Bearer ${apiKey()}`,
'Cartesia-Version': WS_VERSION,
},
headers: headers(),
framesFor: () => [
JSON.stringify({
model_id: vendorModelId,
Expand All @@ -83,14 +148,12 @@ export const cartesia: ProviderTransport = {
},
context_id: `bench-${Date.now()}`,
continue: false,
language: 'en',
language: VOICE_LANGUAGE,
}),
],
jsonHasAudio: (payload) => Boolean(payload.data),
jsonError: (payload) =>
payload.type === 'error' || payload.error
? JSON.stringify(payload)
: null,
payload.type === 'error' || payload.error ? JSON.stringify(payload) : null,
}),
}),
};
4 changes: 2 additions & 2 deletions src/pipeline/transports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export type ProviderTransport = {
providerId: string;
apiKeyEnv: string;
synthesize?: (args: SynthesizeArgs) => Promise<SynthesisResult>;
/** Registers a clone, returns the provider voice id. */
createClone?: (args: CloneArgs) => Promise<string>;
/** Registers a clone. Returns the provider voice id, or null if still training. */
createClone?: (args: CloneArgs) => Promise<string | null>;
/** Steps to follow when clone creation is not API-automatable. */
manualCloneRunbook?: string;
ttfbPlanFor?: (vendorModelId: string) => TtfbPlan;
Expand Down