From f01c255066d29f220377da034f4066f297797a5e Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 00:49:30 -0400 Subject: [PATCH 01/16] fix(cartesia): correct transport, pin dated snapshots, stage Sonic 3.6 The arena's four Cartesia voices are Pro Voice Clones. When the Sonic 3.5 clips were generated those voices only had fine-tunes for sonic-3-2026-01-12, so the 3.5 generations were not served by a 3.5-quality voice. All four now carry fine-tunes through sonic-3.6-2026-08-14, so the clips should be re-run. Transport (src/pipeline/transports/cartesia.ts): - Unify the API version pin at 2026-08-14 across synthesize and ttfbPlanFor, which previously sent 2024-11-13 and 2025-04-16 respectively. - Use Authorization: Bearer, the documented scheme, instead of x-api-key. - Send an explicit language on synthesize; it was relying on auto-detection. - Bench TTFB on a stock voice instead of the Clara PVC, matching how every other provider on the table is measured. - Replace the /voices/clone call (Instant Voice Clone, one ~10s clip) with the documented Pro Voice Clone flow: dataset, upload every sample, fine-tune, poll to completion, then read the voice the fine-tune produced. Registry: - Pin Sonic 3.5 to sonic-3.5-2026-05-04 so the entry names exact weights rather than an alias we repoint over time. Goldens re-derived. - Correct two copy claims that named 3.5 the current flagship. - Stage Sonic 3.6 as a NEW_MODELS row, per the clips-before-registration rule. Added postFormNoContent to http.ts: dataset file upload answers 204. check-types clean, 125 tests pass, build succeeds. Co-Authored-By: Claude Opus 5 (1M context) --- src/catalog/catalog.test.ts | 6 +- src/catalog/models.ts | 12 +- src/pipeline/models.ts | 15 ++ src/pipeline/transports/cartesia.ts | 205 +++++++++++++++++++++++----- src/pipeline/transports/http.ts | 16 +++ 5 files changed, 209 insertions(+), 45 deletions(-) diff --git a/src/catalog/catalog.test.ts b/src/catalog/catalog.test.ts index 6074438..c6a01a1 100644 --- a/src/catalog/catalog.test.ts +++ b/src/catalog/catalog.test.ts @@ -363,7 +363,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', }, @@ -545,9 +545,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) { diff --git a/src/catalog/models.ts b/src/catalog/models.ts index 62d3502..88f329c 100644 --- a/src/catalog/models.ts +++ b/src/catalog/models.ts @@ -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. Newer generations have superseded it, with Sonic 3.6 as the current flagship. Sonic remains in the arena as the baseline of the family.', }, ], }, @@ -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', @@ -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 reached general availability 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', diff --git a/src/pipeline/models.ts b/src/pipeline/models.ts index 9a19c28..837402f 100644 --- a/src/pipeline/models.ts +++ b/src/pipeline/models.ts @@ -90,6 +90,21 @@ const NEW_MODELS: PipelineModel[] = [ name: 'TTS-1.5 Mini', registered: false, }, + // Cartesia Sonic 3.6, GA 2026-08-14. Staged rather than registered because + // clips come first (see CONTRIBUTING: maintainers run the clip pipeline). + // arenaApiId carries the dated snapshot so the hashed clip set is pinned to + // exact weights: Cartesia's bare aliases are repointed over time, and + // `sonic-latest` / `sonic-3-latest` / `sonic-preview` all currently resolve + // to this snapshot, so only the dated id is stable enough to freeze. + { + id: 'cartesia-sonic-36', + slug: 'cartesia-sonic-3-6', + providerId: 'cartesia', + arenaApiId: 'sonic-3.6-2026-08-14', + vendorModelId: 'sonic-3.6-2026-08-14', + name: 'Sonic 3.6', + registered: false, + }, // Fish Audio s2.1-pro. Ids proposed in PR #3 (github.com/VapiAI/humanness-index/pull/3) // and FROZEN here, because the vendor-rendered clip set is hashed against them. { diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index a061a04..f18791a 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -1,24 +1,73 @@ /** - * 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; - * TTFB over the realtime WS, same 50-trial protocol (arena Clara clone). + * Cartesia transport. Synthesis over /tts/bytes (mp3 44.1 kHz / 128 kbps); + * Pro Voice Clone creation over the datasets + fine-tunes endpoints per + * https://docs.cartesia.ai/build-with-cartesia/capability-guides/clone-voices-pro; + * TTFB over the realtime WS, same 50-trial protocol. */ 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, + postFormNoContent, + postJsonForBytes, + requestJson, + 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. */ -const BENCH_VOICE = 'a5d537b0-4a5f-464d-ac12-d143fe1a0a36'; -const apiKey = (): string => requireEnv('CARTESIA_API_KEY'); +/** + * One version pin for every Cartesia call, HTTP and WS. Cartesia dates its + * API versions and serves the request shape matching the pin, so synthesis + * and the TTFB bench have to send the same one or they exercise different + * contracts. 2026-08-14 is the current published version. + * https://docs.cartesia.ai/use-the-api/api-conventions + */ +const VERSION = '2026-08-14'; + +/** + * Stock library voice for the TTFB bench, from Cartesia's realtime TTS + * quickstart. Every other provider is benched on a stock voice; benching + * Cartesia on an arena clone put a cloned-voice lookup in the measured path + * that no other row on the table pays. + */ +const BENCH_VOICE = 'f786b574-daa5-4673-aa0c-cbe3e8534c02'; + +/** The four licensed source voices are English (see pipeline/voices.ts). */ +const VOICE_LANGUAGE = 'en'; + +/** + * Base model for Pro Voice Clone fine-tunes, and the only value the + * CreateFineTuneRequest model_id enum accepts today. Cartesia forward-fills + * a PVC onto newer models as they ship, so a clone trained here also serves + * sonic-3.6 without retraining. + */ +const PVC_BASE_MODEL = 'sonic-3.5-2026-05-04'; + +const POLL_INTERVAL_MS = 30_000; +/** Cartesia documents PVC training as taking up to 3 hours. */ +const TRAINING_TIMEOUT_MS = 4 * 60 * 60 * 1000; + +/** Bearer is the documented scheme; x-api-key is the pre-2026 form. */ +const headers = (): Record => ({ + Authorization: `Bearer ${requireEnv('CARTESIA_API_KEY')}`, + 'Cartesia-Version': VERSION, +}); + +type Dataset = { id: string }; +type FineTuneStatus = 'created' | 'training' | 'completed' | 'failed'; +type FineTune = { + id: string; + status: FineTuneStatus; + user_errors?: Array<{ code: string; message: string }>; +}; +type FineTuneVoices = { data: Array<{ id: string }> }; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); export const cartesia: ProviderTransport = { providerId: 'cartesia', @@ -27,15 +76,15 @@ 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 }, + // Without an explicit language Cartesia infers one from the + // transcript, so a clip could be read with the wrong phonology on a + // short or ambiguous line. Every arena prompt is English. + language: VOICE_LANGUAGE, output_format: { container: 'mp3', sample_rate: 44100, bit_rate: 128000 }, }, 'cartesia', @@ -43,23 +92,107 @@ export const cartesia: ProviderTransport = { format: 'mp3' as const, }), + /** + * Pro Voice Clone, the four-step flow from the capability guide: dataset, + * files, fine-tune, then the voice the fine-tune produced. The previous + * implementation called /voices/clone, which is an Instant Voice Clone off + * a single ~10s clip and a materially different (lower fidelity) product + * than the PVCs the arena's Cartesia voices actually are. + */ 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 description = `Humanness Index source voice ${displayName}`; + + const dataset = await requestJson( + 'POST', + `${API}/datasets`, + headers(), + { name: displayName, description }, + 'cartesia datasets/create', + ); + + // Every sample, not just the first: a PVC trains on the whole dataset and + // needs 30+ minutes of audio (2 hours or more gives the best results). + for (const file of sampleFiles) { + const form = new FormData(); + form.append( + 'file', + new Blob([readFileSync(file)], { type: 'audio/wav' }), + basename(file), + ); + form.append('purpose', 'fine_tune'); + await postFormNoContent( + `${API}/datasets/${dataset.id}/files`, + headers(), + form, + `cartesia datasets/upload-file ${basename(file)}`, + ); + } + + const started = await requestJson( + 'POST', + `${API}/fine-tunes`, + headers(), + { + 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', + + // POST /fine-tunes returns a fine-tune, not a voice. The voice only + // exists once training completes, so poll rather than assume. + const deadline = Date.now() + TRAINING_TIMEOUT_MS; + for (;;) { + const current = await requestJson( + 'GET', + `${API}/fine-tunes/${started.id}`, + headers(), + undefined, + 'cartesia fine-tunes/get', + ); + if (current.status === 'completed') break; + if (current.status === 'failed') { + const detail = (current.user_errors ?? []) + .map((error) => `${error.code}: ${error.message}`) + .join('; '); + throw new TransportError( + `cartesia fine-tune ${started.id} failed${detail ? ` (${detail})` : ''}`, + ); + } + if (Date.now() > deadline) { + throw new TransportError( + `cartesia fine-tune ${started.id} still ${current.status} after ` + + `${TRAINING_TIMEOUT_MS / 3_600_000}h. Training was started, so re-attach ` + + `with GET /fine-tunes/${started.id} rather than starting a second one ` + + `(PVC fine-tunes consume a plan slot).`, + ); + } + console.log( + ` cartesia fine-tune ${started.id}: ${current.status} (polling every ${POLL_INTERVAL_MS / 1000}s)`, + ); + await sleep(POLL_INTERVAL_MS); + } + + const voices = await requestJson( + 'GET', + `${API}/fine-tunes/${started.id}/voices`, + headers(), + undefined, + 'cartesia fine-tunes/list-voices', ); - return result.id; + const voiceId = voices.data[0]?.id; + if (!voiceId) { + throw new TransportError( + `cartesia fine-tune ${started.id} completed but produced no voices`, + ); + } + return voiceId; }, ttfbPlanFor: (vendorModelId): TtfbPlan => ({ @@ -67,10 +200,7 @@ export const cartesia: ProviderTransport = { 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, @@ -83,10 +213,13 @@ export const cartesia: ProviderTransport = { }, context_id: `bench-${Date.now()}`, continue: false, - language: 'en', + language: VOICE_LANGUAGE, }), ], jsonHasAudio: (payload) => Boolean(payload.data), + // Cartesia-Version >= 2026-03-01 returns structured errors + // (error_code / title / message); keep the legacy shapes too so the + // bench still reports usefully if the pin is rolled back. jsonError: (payload) => payload.type === 'error' || payload.error ? JSON.stringify(payload) diff --git a/src/pipeline/transports/http.ts b/src/pipeline/transports/http.ts index 5edbb18..72b2fa1 100644 --- a/src/pipeline/transports/http.ts +++ b/src/pipeline/transports/http.ts @@ -73,6 +73,22 @@ export const postFormForJson = async ( return (await response.json()) as T; }; +/** Multipart POST for endpoints that answer 204 No Content (no body to parse). */ +export const postFormNoContent = async ( + url: string, + headers: Record, + form: FormData, + label: string, +): Promise => { + const response = await fetch(url, { + method: 'POST', + headers, + body: form, + signal: AbortSignal.timeout(TIMEOUT_MS * 3), + }); + await throwForStatus(response, label); +}; + export const decodeHexAudio = (hex: string): Uint8Array => { const compact = hex.replace(/\s+/g, ''); const bytes = new Uint8Array(compact.length / 2); From 0ff2e81504cced07c9c7d077cce849aa90fdada6 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 15:16:57 -0400 Subject: [PATCH 02/16] docs(cartesia): note that one fine-tune yields one voice The models a PVC serves sit a level below the fine-tune, which is why adapted versions do not consume a plan slot. Makes the data[0] read an explicit assumption rather than an accident. Co-Authored-By: Claude Opus 5 (1M context) --- PR_BODY.md | 233 ++++++++++++++++++++++++++++ src/pipeline/transports/cartesia.ts | 3 + 2 files changed, 236 insertions(+) create mode 100644 PR_BODY.md diff --git a/PR_BODY.md b/PR_BODY.md new file mode 100644 index 0000000..be977b6 --- /dev/null +++ b/PR_BODY.md @@ -0,0 +1,233 @@ +# Cartesia: correct the transport, pin dated snapshots, stage Sonic 3.6 + +Hi folks. Cartesia here. Sonic 3.6 went GA on 2026-08-14, and while putting together +the details to get it onto the Index we read through `src/pipeline` and found a few +things in the Cartesia orchestration that are holding our numbers below what the +models actually do. This PR fixes those and stages 3.6 for you to generate. + +`bun run check-types`, `bun test` (125 pass), and `bun run build` are all green. + +## The headline issue: the 3.5 clips were generated against 3.0 voices + +The four arena voices (Clara, Emma, Godfrey, Nelliot) are Pro Voice Clones. A PVC is +a fine-tune of one specific base model, and at the time the Sonic 3.5 clips were +generated those four voices only had fine-tunes for `sonic-3-2026-01-12`. There was +no 3.5 fine-tune to serve, so the 3.5 generations did not run on a 3.5-quality voice. +That is a real handicap in a blind listening test, and it is on us for not flagging it +when the clips were cut. + +All four now have fine-tunes across the line: + +| Voice | Voice ID | Fine-tune | Model availability | +| --- | --- | --- | --- | +| Clara | `a5d537b0-4a5f-464d-ac12-d143fe1a0a36` | `fine_tune_JNfFhUJYN5T8ChazR9gArt` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | +| Emma | `2206880c-55e3-445a-a83c-fa2196a9f304` | `fine_tune_7ra9vSuVGP7zvbiw5cf7jx` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | +| Godfrey | `589d6fbc-d04d-46c3-91e4-a97979eca151` | `fine_tune_jE6P5njqyJmFwtYZw1MnLW` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | +| Nelliot | `621a0684-2a58-4693-b762-a54093bf0c4c` | `fine_tune_UmPnKL6QqmDM6AoFbmHufV` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | + +This is the documented forward-fill behavior: *"A Pro Voice Clone starts out supported +on the TTS model it was trained on. As Cartesia releases newer models, we automatically +make your voice available on them."* +([Pro voice clone guide](https://docs.cartesia.ai/build-with-cartesia/capability-guides/clone-voices-pro)) + +**The ask: re-run the Sonic 3.5 clip generation.** Nothing else in this PR matters as +much as that. Same voices, same prompts, same protocol; they will now be served by 3.5 +fine-tunes instead of 3.0 ones. + +## Changes by file + +### `src/pipeline/transports/cartesia.ts` + +Four fixes. + +**1. One API version pin, and a current one.** `synthesize` sent +`cartesia-version: 2024-11-13` while `ttfbPlanFor` sent `2025-04-16`, so the two paths +were exercising different request contracts. Both now send `2026-08-14`, our current +published version ([API conventions](https://docs.cartesia.ai/use-the-api/api-conventions)). + +We checked the version-gated validation before proposing the bump; your existing +payloads satisfy every gate, so this is safe: + +| Gate | Your payload | Result | +| --- | --- | --- | +| `>= 2026-03-01` requires `voice.id`, rejects embeddings | `voice: { mode: 'id', id }` | passes | +| SSE/WS must use the `raw` container | WS sends `raw`/`pcm_s16le`; mp3 only on `/tts/bytes` | passes | +| `>= 2026-08-14` ignores deprecated top-level `speed` | never sets `speed` | no-op | +| `/tts/bytes` rejects timestamp flags | none requested | passes | + +One behavior change worth knowing: at `>= 2026-03-01` our HTTP and WS errors become +structured JSON (`error_code`, `title`, `message`, `request_id`) instead of plain +`Title: Message` text. `throwForStatus` reads the body as text, so it keeps working and +gets strictly more informative. The WS `jsonError` predicate still matches, since the +structured error event retains `type: "error"`. + +**2. `Authorization: Bearer` instead of `x-api-key`.** `synthesize` and the clone path +used `x-api-key`; the TTFB path already used Bearer. Bearer is the documented scheme for +all endpoints. Both forms authenticate today, but this removes the inconsistency. + +**3. `synthesize` now sends `language: 'en'`.** It was omitted, which means we ran +language auto-detection on every arena clip. Detection reads the transcript and can pick +wrong on short or ambiguous lines, and a mis-detected language changes the phonology the +model reads with. The TTFB path already passed `language`. All 20 arena prompts are +English and all four source voices are English, so this is deterministic now. + +**4. The TTFB bench uses a stock voice.** It benched on Clara, an arena PVC, while every +other provider on the table is benched on a stock voice. That put a cloned-voice lookup +inside our measured path that no other row pays. Now uses +`f786b574-daa5-4673-aa0c-cbe3e8534c02`, the stock voice from our +[realtime TTS quickstart](https://docs.cartesia.ai/get-started/realtime-text-to-speech-quickstart). + +Flagging plainly: this one moves our own published latency number, and we do not know +which way. It is a methodology correction, not a favor to us. Sonic 3.5 currently shows a +measured 128 ms median that was produced under the old setup, so that figure should be +re-benched before it is quoted again. + +**5. `createClone` now creates an actual Pro Voice Clone.** It called `/voices/clone`, +which is an **Instant** Voice Clone built from a single ~10 second clip. IVC and PVC are +different products at meaningfully different fidelity, and the four voices in the arena +are PVCs, so the function could never have reproduced them. It now follows the documented +four-step flow: + +1. `POST /datasets` +2. `POST /datasets/{id}/files` for **every** sample, not just `sampleFiles[0]` (a PVC + trains on the whole dataset and needs 30+ minutes of audio; 2 hours or more is better) +3. `POST /fine-tunes` +4. poll `GET /fine-tunes/{id}` until `status` is `completed`, then + `GET /fine-tunes/{id}/voices` + +Steps 3 and 4 are separate on purpose: `POST /fine-tunes` returns a fine-tune, not a +voice. The voice only exists once training finishes, which takes up to 3 hours, so the +function polls every 30s with a 4h ceiling. On timeout it reports the fine-tune id and +tells you to re-attach rather than start over, because **PVC fine-tunes consume a plan +slot** (Startup: 2, Scale: 4). + +Note this path needs a Startup plan or above and 30+ minutes of audio per voice, so it +is not a drop-in for the IVC-shaped call it replaces. It is here so the code matches what +the arena voices actually are. + +### `src/pipeline/transports/http.ts` + +Added `postFormNoContent`. `POST /datasets/{id}/files` answers `204 No Content`, and the +existing `postFormForJson` would throw parsing an empty body. + +### `src/catalog/models.ts` + +**Pinned Sonic 3.5 to a dated snapshot.** `apiModelId` and `arenaApiId` move from +`sonic-3.5` to `sonic-3.5-2026-05-04`. + +Being straight about what this does and does not do: the bare `sonic-3.5` alias resolves +to `sonic-3.5-2026-05-04` today, so **this does not change which weights answer the +request**. The value is that it cannot drift. We repoint bare aliases over time, and +`sonic-latest`, `sonic-3-latest`, and `sonic-preview` have all already been repointed to +the 3.6 snapshot. A benchmark that quotes an alias is quoting a moving target; a dated +snapshot is reproducible a year from now. + +**We know `arenaApiId` is frozen identity, and we are asking you to break that freeze.** +Per CONTRIBUTING it feeds the audio content hashes, so changing it re-derives the paths +for the Cartesia 3.5 clips: + +- `variant:voice-emma:cartesia:sonic-3.5|clip-20` → `5e21eefd…815d` +- `variant:voice-emma:cartesia:sonic-3.5-2026-05-04|clip-20` → `3e36a188…de03` + +The 20 clips currently hosted under the old hashes would be orphaned. We are proposing it +anyway **only because those clips are being regenerated regardless** for the reason at the +top of this PR: the current ones were cut against 3.0 fine-tunes. New clips get uploaded +under the new hashes and nothing is stranded. If you would rather keep `arenaApiId` frozen +at `sonic-3.5` and pin only `apiModelId`, that works too and we will take it. The +regeneration is the part that matters. We have updated the goldens either way so the +suite is green as submitted. + +`slug` is untouched, so `/models/cartesia-sonic-3-5` keeps working. + +**Copy corrections.** Two places called 3.5 "Cartesia's current flagship", which stopped +being true on 2026-08-14. Updated both, sources retained. + +### `src/catalog/catalog.test.ts` + +Updated the pinned `EXPECTED_MODELS` arenaId and the `voice-nelliot` golden hash to match +the re-derived identity. No count bumps: staging 3.6 in the pipeline rather than the +registry leaves the 88-variant matrix untouched. + +### `src/pipeline/models.ts` + +Staged Sonic 3.6 as a `NEW_MODELS` row so `humanness:generate cartesia-sonic-36` works +without a registry edit. We followed your sequencing rule here rather than registering it +directly, since CONTRIBUTING is explicit that clips are generated before registration and +that maintainers run that pipeline. + +`arenaApiId` is the dated `sonic-3.6-2026-08-14` rather than a bare `sonic-3.6` for a +concrete reason: **there is no bare `sonic-3.6` alias.** Our alias list stops at +`sonic-3.5`. 3.6 is reachable only by the dated id, or via `sonic-latest` / +`sonic-3-latest` / `sonic-preview`, all of which currently resolve to it and all of which +will move again. Since `arenaApiId` is frozen forever once clips are hashed, the dated id +is the only value that will still mean this model later. + +### `src/catalog/providers.ts`, deliberately unchanged + +We had "add pricing" on our list, then found pricing is encoded per **provider**, not per +model, and the Cartesia entry already carries it with the note "Same credit rate for every +Sonic." That note is still accurate for 3.6, so a per-model field would be redundant. The +`asOf` is 2026-06-10; if you want it refreshed we will send current numbers with a source +rather than have you chase them. + +## Ready to register when the clips land + +Once 3.6 clips are generated and verified, this drops into `src/catalog/models.ts` and the +`NEW_MODELS` row above gets deleted. `latencyMs` is left `null` deliberately: your house +rule is measured-only, and only you can run the bench. + +```ts +{ + id: 'cartesia-sonic-36', + slug: 'cartesia-sonic-3-6', + providerId: 'cartesia', + name: 'Sonic 3.6', + apiModelId: 'sonic-3.6-2026-08-14', + arenaApiId: 'sonic-3.6-2026-08-14', + status: 'active', + releaseDate: { + value: '2026-08-14', + sourceUrl: 'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest', + asOf: '2026-08-26', + note: 'Snapshot release, general availability.', + }, + stats: { + latencyMs: null, // measured-only: needs your bench run + }, + voiceProfile: 3, + sample: { + fallbackClip: clip( + 'voice-emma', + 'cartesia', + 'sonic-3.6-2026-08-14', + 'clip-20', + 'b70ab872a402c56c37c6367c290c062f', + ), + }, + copy: [ + { + heading: 'Background', + paragraphs: [ + 'Sonic 3.6 is Cartesia\'s current flagship, generally available since August 2026. It is an update to Sonic 3.5 that improves naturalness across all supported languages, and it keeps the state space model architecture and the 42 language coverage of the 3.x line.', + ], + sourceUrls: [ + 'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest', + ], + }, + ], + faq: [HOW_TESTED('Sonic 3.6')], +}, +``` + +The `fallbackClip` hash above is derived from the frozen identity, so it will resolve +once the clip exists. Registering it also bumps the pinned counts in `catalog.test.ts`. + +## What we are asking for + +1. Re-run Sonic 3.5 clip generation against the current PVC fine-tunes. This is the one + that changes the ranking. +2. Generate and register Sonic 3.6. +3. Re-bench Cartesia TTFB after the stock-voice change, and treat the existing 128 ms + figure as stale. + +Happy to jump on a call, and happy to be told no on the `arenaApiId` change specifically. diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index f18791a..329ca43 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -186,6 +186,9 @@ export const cartesia: ProviderTransport = { undefined, 'cartesia fine-tunes/list-voices', ); + // One fine-tune yields one voice; the models it serves sit a level below + // the fine-tune (Cartesia adapts the same clone onto newer models, which + // is why those adapted versions do not consume a PVC plan slot). const voiceId = voices.data[0]?.id; if (!voiceId) { throw new TransportError( From 3043cdf28a44f664edbaa62e810c1cf74a1068e8 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 15:27:44 -0400 Subject: [PATCH 03/16] chore: remove PR_BODY.md from the branch Working note, not part of the change set. Co-Authored-By: Claude Opus 5 (1M context) --- PR_BODY.md | 233 ----------------------------------------------------- 1 file changed, 233 deletions(-) delete mode 100644 PR_BODY.md diff --git a/PR_BODY.md b/PR_BODY.md deleted file mode 100644 index be977b6..0000000 --- a/PR_BODY.md +++ /dev/null @@ -1,233 +0,0 @@ -# Cartesia: correct the transport, pin dated snapshots, stage Sonic 3.6 - -Hi folks. Cartesia here. Sonic 3.6 went GA on 2026-08-14, and while putting together -the details to get it onto the Index we read through `src/pipeline` and found a few -things in the Cartesia orchestration that are holding our numbers below what the -models actually do. This PR fixes those and stages 3.6 for you to generate. - -`bun run check-types`, `bun test` (125 pass), and `bun run build` are all green. - -## The headline issue: the 3.5 clips were generated against 3.0 voices - -The four arena voices (Clara, Emma, Godfrey, Nelliot) are Pro Voice Clones. A PVC is -a fine-tune of one specific base model, and at the time the Sonic 3.5 clips were -generated those four voices only had fine-tunes for `sonic-3-2026-01-12`. There was -no 3.5 fine-tune to serve, so the 3.5 generations did not run on a 3.5-quality voice. -That is a real handicap in a blind listening test, and it is on us for not flagging it -when the clips were cut. - -All four now have fine-tunes across the line: - -| Voice | Voice ID | Fine-tune | Model availability | -| --- | --- | --- | --- | -| Clara | `a5d537b0-4a5f-464d-ac12-d143fe1a0a36` | `fine_tune_JNfFhUJYN5T8ChazR9gArt` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | -| Emma | `2206880c-55e3-445a-a83c-fa2196a9f304` | `fine_tune_7ra9vSuVGP7zvbiw5cf7jx` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | -| Godfrey | `589d6fbc-d04d-46c3-91e4-a97979eca151` | `fine_tune_jE6P5njqyJmFwtYZw1MnLW` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | -| Nelliot | `621a0684-2a58-4693-b762-a54093bf0c4c` | `fine_tune_UmPnKL6QqmDM6AoFbmHufV` | `sonic-3-2026-01-12`, `sonic-3.5-2026-05-04`, `sonic-3.6-2026-08-14` | - -This is the documented forward-fill behavior: *"A Pro Voice Clone starts out supported -on the TTS model it was trained on. As Cartesia releases newer models, we automatically -make your voice available on them."* -([Pro voice clone guide](https://docs.cartesia.ai/build-with-cartesia/capability-guides/clone-voices-pro)) - -**The ask: re-run the Sonic 3.5 clip generation.** Nothing else in this PR matters as -much as that. Same voices, same prompts, same protocol; they will now be served by 3.5 -fine-tunes instead of 3.0 ones. - -## Changes by file - -### `src/pipeline/transports/cartesia.ts` - -Four fixes. - -**1. One API version pin, and a current one.** `synthesize` sent -`cartesia-version: 2024-11-13` while `ttfbPlanFor` sent `2025-04-16`, so the two paths -were exercising different request contracts. Both now send `2026-08-14`, our current -published version ([API conventions](https://docs.cartesia.ai/use-the-api/api-conventions)). - -We checked the version-gated validation before proposing the bump; your existing -payloads satisfy every gate, so this is safe: - -| Gate | Your payload | Result | -| --- | --- | --- | -| `>= 2026-03-01` requires `voice.id`, rejects embeddings | `voice: { mode: 'id', id }` | passes | -| SSE/WS must use the `raw` container | WS sends `raw`/`pcm_s16le`; mp3 only on `/tts/bytes` | passes | -| `>= 2026-08-14` ignores deprecated top-level `speed` | never sets `speed` | no-op | -| `/tts/bytes` rejects timestamp flags | none requested | passes | - -One behavior change worth knowing: at `>= 2026-03-01` our HTTP and WS errors become -structured JSON (`error_code`, `title`, `message`, `request_id`) instead of plain -`Title: Message` text. `throwForStatus` reads the body as text, so it keeps working and -gets strictly more informative. The WS `jsonError` predicate still matches, since the -structured error event retains `type: "error"`. - -**2. `Authorization: Bearer` instead of `x-api-key`.** `synthesize` and the clone path -used `x-api-key`; the TTFB path already used Bearer. Bearer is the documented scheme for -all endpoints. Both forms authenticate today, but this removes the inconsistency. - -**3. `synthesize` now sends `language: 'en'`.** It was omitted, which means we ran -language auto-detection on every arena clip. Detection reads the transcript and can pick -wrong on short or ambiguous lines, and a mis-detected language changes the phonology the -model reads with. The TTFB path already passed `language`. All 20 arena prompts are -English and all four source voices are English, so this is deterministic now. - -**4. The TTFB bench uses a stock voice.** It benched on Clara, an arena PVC, while every -other provider on the table is benched on a stock voice. That put a cloned-voice lookup -inside our measured path that no other row pays. Now uses -`f786b574-daa5-4673-aa0c-cbe3e8534c02`, the stock voice from our -[realtime TTS quickstart](https://docs.cartesia.ai/get-started/realtime-text-to-speech-quickstart). - -Flagging plainly: this one moves our own published latency number, and we do not know -which way. It is a methodology correction, not a favor to us. Sonic 3.5 currently shows a -measured 128 ms median that was produced under the old setup, so that figure should be -re-benched before it is quoted again. - -**5. `createClone` now creates an actual Pro Voice Clone.** It called `/voices/clone`, -which is an **Instant** Voice Clone built from a single ~10 second clip. IVC and PVC are -different products at meaningfully different fidelity, and the four voices in the arena -are PVCs, so the function could never have reproduced them. It now follows the documented -four-step flow: - -1. `POST /datasets` -2. `POST /datasets/{id}/files` for **every** sample, not just `sampleFiles[0]` (a PVC - trains on the whole dataset and needs 30+ minutes of audio; 2 hours or more is better) -3. `POST /fine-tunes` -4. poll `GET /fine-tunes/{id}` until `status` is `completed`, then - `GET /fine-tunes/{id}/voices` - -Steps 3 and 4 are separate on purpose: `POST /fine-tunes` returns a fine-tune, not a -voice. The voice only exists once training finishes, which takes up to 3 hours, so the -function polls every 30s with a 4h ceiling. On timeout it reports the fine-tune id and -tells you to re-attach rather than start over, because **PVC fine-tunes consume a plan -slot** (Startup: 2, Scale: 4). - -Note this path needs a Startup plan or above and 30+ minutes of audio per voice, so it -is not a drop-in for the IVC-shaped call it replaces. It is here so the code matches what -the arena voices actually are. - -### `src/pipeline/transports/http.ts` - -Added `postFormNoContent`. `POST /datasets/{id}/files` answers `204 No Content`, and the -existing `postFormForJson` would throw parsing an empty body. - -### `src/catalog/models.ts` - -**Pinned Sonic 3.5 to a dated snapshot.** `apiModelId` and `arenaApiId` move from -`sonic-3.5` to `sonic-3.5-2026-05-04`. - -Being straight about what this does and does not do: the bare `sonic-3.5` alias resolves -to `sonic-3.5-2026-05-04` today, so **this does not change which weights answer the -request**. The value is that it cannot drift. We repoint bare aliases over time, and -`sonic-latest`, `sonic-3-latest`, and `sonic-preview` have all already been repointed to -the 3.6 snapshot. A benchmark that quotes an alias is quoting a moving target; a dated -snapshot is reproducible a year from now. - -**We know `arenaApiId` is frozen identity, and we are asking you to break that freeze.** -Per CONTRIBUTING it feeds the audio content hashes, so changing it re-derives the paths -for the Cartesia 3.5 clips: - -- `variant:voice-emma:cartesia:sonic-3.5|clip-20` → `5e21eefd…815d` -- `variant:voice-emma:cartesia:sonic-3.5-2026-05-04|clip-20` → `3e36a188…de03` - -The 20 clips currently hosted under the old hashes would be orphaned. We are proposing it -anyway **only because those clips are being regenerated regardless** for the reason at the -top of this PR: the current ones were cut against 3.0 fine-tunes. New clips get uploaded -under the new hashes and nothing is stranded. If you would rather keep `arenaApiId` frozen -at `sonic-3.5` and pin only `apiModelId`, that works too and we will take it. The -regeneration is the part that matters. We have updated the goldens either way so the -suite is green as submitted. - -`slug` is untouched, so `/models/cartesia-sonic-3-5` keeps working. - -**Copy corrections.** Two places called 3.5 "Cartesia's current flagship", which stopped -being true on 2026-08-14. Updated both, sources retained. - -### `src/catalog/catalog.test.ts` - -Updated the pinned `EXPECTED_MODELS` arenaId and the `voice-nelliot` golden hash to match -the re-derived identity. No count bumps: staging 3.6 in the pipeline rather than the -registry leaves the 88-variant matrix untouched. - -### `src/pipeline/models.ts` - -Staged Sonic 3.6 as a `NEW_MODELS` row so `humanness:generate cartesia-sonic-36` works -without a registry edit. We followed your sequencing rule here rather than registering it -directly, since CONTRIBUTING is explicit that clips are generated before registration and -that maintainers run that pipeline. - -`arenaApiId` is the dated `sonic-3.6-2026-08-14` rather than a bare `sonic-3.6` for a -concrete reason: **there is no bare `sonic-3.6` alias.** Our alias list stops at -`sonic-3.5`. 3.6 is reachable only by the dated id, or via `sonic-latest` / -`sonic-3-latest` / `sonic-preview`, all of which currently resolve to it and all of which -will move again. Since `arenaApiId` is frozen forever once clips are hashed, the dated id -is the only value that will still mean this model later. - -### `src/catalog/providers.ts`, deliberately unchanged - -We had "add pricing" on our list, then found pricing is encoded per **provider**, not per -model, and the Cartesia entry already carries it with the note "Same credit rate for every -Sonic." That note is still accurate for 3.6, so a per-model field would be redundant. The -`asOf` is 2026-06-10; if you want it refreshed we will send current numbers with a source -rather than have you chase them. - -## Ready to register when the clips land - -Once 3.6 clips are generated and verified, this drops into `src/catalog/models.ts` and the -`NEW_MODELS` row above gets deleted. `latencyMs` is left `null` deliberately: your house -rule is measured-only, and only you can run the bench. - -```ts -{ - id: 'cartesia-sonic-36', - slug: 'cartesia-sonic-3-6', - providerId: 'cartesia', - name: 'Sonic 3.6', - apiModelId: 'sonic-3.6-2026-08-14', - arenaApiId: 'sonic-3.6-2026-08-14', - status: 'active', - releaseDate: { - value: '2026-08-14', - sourceUrl: 'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest', - asOf: '2026-08-26', - note: 'Snapshot release, general availability.', - }, - stats: { - latencyMs: null, // measured-only: needs your bench run - }, - voiceProfile: 3, - sample: { - fallbackClip: clip( - 'voice-emma', - 'cartesia', - 'sonic-3.6-2026-08-14', - 'clip-20', - 'b70ab872a402c56c37c6367c290c062f', - ), - }, - copy: [ - { - heading: 'Background', - paragraphs: [ - 'Sonic 3.6 is Cartesia\'s current flagship, generally available since August 2026. It is an update to Sonic 3.5 that improves naturalness across all supported languages, and it keeps the state space model architecture and the 42 language coverage of the 3.x line.', - ], - sourceUrls: [ - 'https://docs.cartesia.ai/build-with-cartesia/tts-models/latest', - ], - }, - ], - faq: [HOW_TESTED('Sonic 3.6')], -}, -``` - -The `fallbackClip` hash above is derived from the frozen identity, so it will resolve -once the clip exists. Registering it also bumps the pinned counts in `catalog.test.ts`. - -## What we are asking for - -1. Re-run Sonic 3.5 clip generation against the current PVC fine-tunes. This is the one - that changes the ranking. -2. Generate and register Sonic 3.6. -3. Re-bench Cartesia TTFB after the stock-voice change, and treat the existing 128 ms - figure as stale. - -Happy to jump on a call, and happy to be told no on the `arenaApiId` change specifically. From aa7a55140523e1e7eca2c7cc5acabba4c5fd2115 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 15:30:51 -0400 Subject: [PATCH 04/16] docs: soften the Sonic 3.6 release wording 3.6 is in preview today and reaches GA 2026-08-27, so the registry copy should not assert general availability yet. Co-Authored-By: Claude Opus 5 (1M context) --- src/catalog/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/catalog/models.ts b/src/catalog/models.ts index 88f329c..4aefd09 100644 --- a/src/catalog/models.ts +++ b/src/catalog/models.ts @@ -693,7 +693,7 @@ export const MODEL_ENTRIES: ModelEntry[] = [ { heading: 'Background', paragraphs: [ - "Sonic 3.5 is the May 2026 Sonic snapshot, and was Cartesia's flagship until Sonic 3.6 reached general availability 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.", + "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', From 28e8e30c353ff096ab6ba7334815f41d02ccfd91 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 15:49:01 -0400 Subject: [PATCH 05/16] docs: reword the Sonic FAQ answer for the 3.6 release Co-Authored-By: Claude Opus 5 (1M context) --- src/catalog/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/catalog/models.ts b/src/catalog/models.ts index 4aefd09..1342a85 100644 --- a/src/catalog/models.ts +++ b/src/catalog/models.ts @@ -531,7 +531,7 @@ export const MODEL_ENTRIES: ModelEntry[] = [ { question: "Is Sonic still Cartesia's current model?", answer: - 'No. Newer generations have superseded it, with Sonic 3.6 as the current flagship. Sonic remains in the arena as the baseline of the family.', + 'No. Cartesia continues to iterate on its Sonic family of TTS models, having just released Sonic-3.6.', }, ], }, From 96b81610b3a21fb4b3e29c516baaa715a4c687d6 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 15:59:06 -0400 Subject: [PATCH 06/16] docs: name Sonic 3.6 as the current flagship in the Sonic FAQ Co-Authored-By: Claude Opus 5 (1M context) --- src/catalog/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/catalog/models.ts b/src/catalog/models.ts index 1342a85..315eff9 100644 --- a/src/catalog/models.ts +++ b/src/catalog/models.ts @@ -531,7 +531,7 @@ export const MODEL_ENTRIES: ModelEntry[] = [ { question: "Is Sonic still Cartesia's current model?", answer: - 'No. Cartesia continues to iterate on its Sonic family of TTS models, having just released Sonic-3.6.', + '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.', }, ], }, From 19c81d889fef4d1c0d6964f0fa4ed15da3e0dc56 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 16:18:08 -0400 Subject: [PATCH 07/16] refactor(cartesia): tighten the transport Restore the original file header, changing only the two facts that moved (IVC to PVC, Clara to a stock bench voice). Trim the constant comments to one line each. Inline the single-use FineTuneStatus union. Hoist the five headers() calls in createClone to one, destructure the poll response, and collapse the form and error-detail lines. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) --- src/pipeline/transports/cartesia.ts | 106 ++++++++-------------------- 1 file changed, 30 insertions(+), 76 deletions(-) diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index 329ca43..9547ec3 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -1,50 +1,29 @@ /** - * Cartesia transport. Synthesis over /tts/bytes (mp3 44.1 kHz / 128 kbps); - * Pro Voice Clone creation over the datasets + fine-tunes endpoints per + * Cartesia transport. Synthesis ported from the original prototype's + * Cartesia adapter (tts/bytes, mp3 44.1 kHz / + * 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. + * TTFB over the realtime WS, same 50-trial protocol (stock voice). */ import { readFileSync } from 'node:fs'; import { basename } from 'node:path'; import { requireEnv } from '../env'; -import { - BENCH_TEXT, - postFormNoContent, - postJsonForBytes, - requestJson, - wsTtfbTrial, -} from './http'; +import { BENCH_TEXT, postFormNoContent, postJsonForBytes, requestJson, wsTtfbTrial } from './http'; import { TransportError, type ProviderTransport, type TtfbPlan } from './types'; const API = 'https://api.cartesia.ai'; -/** - * One version pin for every Cartesia call, HTTP and WS. Cartesia dates its - * API versions and serves the request shape matching the pin, so synthesis - * and the TTFB bench have to send the same one or they exercise different - * contracts. 2026-08-14 is the current published version. - * https://docs.cartesia.ai/use-the-api/api-conventions - */ +/** Cartesia's current published API version, sent on every call. */ const VERSION = '2026-08-14'; -/** - * Stock library voice for the TTFB bench, from Cartesia's realtime TTS - * quickstart. Every other provider is benched on a stock voice; benching - * Cartesia on an arena clone put a cloned-voice lookup in the measured path - * that no other row on the table pays. - */ +/** Stock library voice, so the bench matches how other providers are measured. */ const BENCH_VOICE = 'f786b574-daa5-4673-aa0c-cbe3e8534c02'; /** The four licensed source voices are English (see pipeline/voices.ts). */ const VOICE_LANGUAGE = 'en'; -/** - * Base model for Pro Voice Clone fine-tunes, and the only value the - * CreateFineTuneRequest model_id enum accepts today. Cartesia forward-fills - * a PVC onto newer models as they ship, so a clone trained here also serves - * sonic-3.6 without retraining. - */ +/** The only base model /fine-tunes accepts today; PVCs forward-fill to newer ones. */ const PVC_BASE_MODEL = 'sonic-3.5-2026-05-04'; const POLL_INTERVAL_MS = 30_000; @@ -58,10 +37,9 @@ const headers = (): Record => ({ }); type Dataset = { id: string }; -type FineTuneStatus = 'created' | 'training' | 'completed' | 'failed'; type FineTune = { id: string; - status: FineTuneStatus; + status: 'created' | 'training' | 'completed' | 'failed'; user_errors?: Array<{ code: string; message: string }>; }; type FineTuneVoices = { data: Array<{ id: string }> }; @@ -81,9 +59,7 @@ export const cartesia: ProviderTransport = { model_id: vendorModelId, transcript: text, voice: { mode: 'id', id: providerVoiceId }, - // Without an explicit language Cartesia infers one from the - // transcript, so a clip could be read with the wrong phonology on a - // short or ambiguous line. Every arena prompt is English. + // Cartesia recommends setting the language explicitly where possible. language: VOICE_LANGUAGE, output_format: { container: 'mp3', sample_rate: 44100, bit_rate: 128000 }, }, @@ -92,40 +68,30 @@ export const cartesia: ProviderTransport = { format: 'mp3' as const, }), - /** - * Pro Voice Clone, the four-step flow from the capability guide: dataset, - * files, fine-tune, then the voice the fine-tune produced. The previous - * implementation called /voices/clone, which is an Instant Voice Clone off - * a single ~10s clip and a materially different (lower fidelity) product - * than the PVCs the arena's Cartesia voices actually are. - */ + /** Pro Voice Clone: dataset, files, fine-tune, then the voice it produced. */ createClone: async ({ displayName, sampleFiles }) => { 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( 'POST', `${API}/datasets`, - headers(), + auth, { name: displayName, description }, 'cartesia datasets/create', ); - // Every sample, not just the first: a PVC trains on the whole dataset and - // needs 30+ minutes of audio (2 hours or more gives the best results). + // A PVC trains on the whole dataset, so upload every sample. for (const file of sampleFiles) { const form = new FormData(); - form.append( - 'file', - new Blob([readFileSync(file)], { type: 'audio/wav' }), - basename(file), - ); + form.append('file', new Blob([readFileSync(file)], { type: 'audio/wav' }), basename(file)); form.append('purpose', 'fine_tune'); await postFormNoContent( `${API}/datasets/${dataset.id}/files`, - headers(), + auth, form, `cartesia datasets/upload-file ${basename(file)}`, ); @@ -134,7 +100,7 @@ export const cartesia: ProviderTransport = { const started = await requestJson( 'POST', `${API}/fine-tunes`, - headers(), + auth, { name: displayName, description, @@ -145,50 +111,43 @@ export const cartesia: ProviderTransport = { 'cartesia fine-tunes/create', ); - // POST /fine-tunes returns a fine-tune, not a voice. The voice only - // exists once training completes, so poll rather than assume. + // The voice only exists once training completes, so poll for it. const deadline = Date.now() + TRAINING_TIMEOUT_MS; for (;;) { - const current = await requestJson( + const { status, user_errors } = await requestJson( 'GET', `${API}/fine-tunes/${started.id}`, - headers(), + auth, undefined, 'cartesia fine-tunes/get', ); - if (current.status === 'completed') break; - if (current.status === 'failed') { - const detail = (current.user_errors ?? []) - .map((error) => `${error.code}: ${error.message}`) - .join('; '); + if (status === 'completed') break; + if (status === 'failed') { + const detail = (user_errors ?? []).map((e) => `${e.code}: ${e.message}`).join('; '); throw new TransportError( `cartesia fine-tune ${started.id} failed${detail ? ` (${detail})` : ''}`, ); } if (Date.now() > deadline) { throw new TransportError( - `cartesia fine-tune ${started.id} still ${current.status} after ` + - `${TRAINING_TIMEOUT_MS / 3_600_000}h. Training was started, so re-attach ` + - `with GET /fine-tunes/${started.id} rather than starting a second one ` + - `(PVC fine-tunes consume a plan slot).`, + `cartesia fine-tune ${started.id} still ${status} after ` + + `${TRAINING_TIMEOUT_MS / 3_600_000}h. Re-attach with GET /fine-tunes/${started.id} ` + + `rather than starting a second one (PVC fine-tunes consume a plan slot).`, ); } - console.log( - ` cartesia fine-tune ${started.id}: ${current.status} (polling every ${POLL_INTERVAL_MS / 1000}s)`, - ); + console.log(` cartesia fine-tune ${started.id}: ${status}`); await sleep(POLL_INTERVAL_MS); } const voices = await requestJson( 'GET', `${API}/fine-tunes/${started.id}/voices`, - headers(), + auth, undefined, 'cartesia fine-tunes/list-voices', ); // One fine-tune yields one voice; the models it serves sit a level below - // the fine-tune (Cartesia adapts the same clone onto newer models, which - // is why those adapted versions do not consume a PVC plan slot). + // the fine-tune. const voiceId = voices.data[0]?.id; if (!voiceId) { throw new TransportError( @@ -220,13 +179,8 @@ export const cartesia: ProviderTransport = { }), ], jsonHasAudio: (payload) => Boolean(payload.data), - // Cartesia-Version >= 2026-03-01 returns structured errors - // (error_code / title / message); keep the legacy shapes too so the - // bench still reports usefully if the pin is rolled back. jsonError: (payload) => - payload.type === 'error' || payload.error - ? JSON.stringify(payload) - : null, + payload.type === 'error' || payload.error ? JSON.stringify(payload) : null, }), }), }; From a1d2d5b2df1972049e03fb7f119b772da3072fbf Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 16:21:42 -0400 Subject: [PATCH 08/16] docs(cartesia): drop the time-bound claim on PVC_BASE_MODEL The enum gains newer base models, so 'the only one accepted today' goes stale immediately. State the choice and the forward-fill instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/pipeline/transports/cartesia.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index 9547ec3..179be2d 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -23,7 +23,7 @@ const BENCH_VOICE = 'f786b574-daa5-4673-aa0c-cbe3e8534c02'; /** The four licensed source voices are English (see pipeline/voices.ts). */ const VOICE_LANGUAGE = 'en'; -/** The only base model /fine-tunes accepts today; PVCs forward-fill to newer ones. */ +/** Train against Sonic 3.5; PVCs forward-fill onto new models as they ship. */ const PVC_BASE_MODEL = 'sonic-3.5-2026-05-04'; const POLL_INTERVAL_MS = 30_000; From a120337cda8f0eb874cdfeb30e60e65e2dbe8c80 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 16:30:03 -0400 Subject: [PATCH 09/16] refactor(cartesia): kick off PVC training instead of blocking on it createClone polled to completion, so a run held the process open for up to 3 hours per voice. cloneVoices loops the four voices sequentially, making that up to 12 hours for a full re-clone. It now creates the dataset, uploads every sample, starts the fine-tune, and returns. All four kick off in one run and train concurrently, and the maintainer collects the ids from the PVC dashboard and persists them with the existing --record flag. createClone widens to Promise for this: null means the clone is still training. Every other transport already returns a string, so none of them change. Drops the poll loop, sleep, both timing constants, the status union, and the two follow-up GETs. Co-Authored-By: Claude Opus 5 (1M context) --- src/pipeline/cloneVoices.ts | 8 +++- src/pipeline/transports/cartesia.ts | 67 ++++------------------------- src/pipeline/transports/types.ts | 7 ++- 3 files changed, 20 insertions(+), 62 deletions(-) diff --git a/src/pipeline/cloneVoices.ts b/src/pipeline/cloneVoices.ts index a1077ac..4dbc971 100644 --- a/src/pipeline/cloneVoices.ts +++ b/src/pipeline/cloneVoices.ts @@ -166,8 +166,12 @@ const main = async (): Promise => { 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)}`, diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index 179be2d..1523897 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -26,10 +26,6 @@ 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'; -const POLL_INTERVAL_MS = 30_000; -/** Cartesia documents PVC training as taking up to 3 hours. */ -const TRAINING_TIMEOUT_MS = 4 * 60 * 60 * 1000; - /** Bearer is the documented scheme; x-api-key is the pre-2026 form. */ const headers = (): Record => ({ Authorization: `Bearer ${requireEnv('CARTESIA_API_KEY')}`, @@ -37,15 +33,10 @@ const headers = (): Record => ({ }); type Dataset = { id: string }; -type FineTune = { - id: string; - status: 'created' | 'training' | 'completed' | 'failed'; - user_errors?: Array<{ code: string; message: string }>; -}; -type FineTuneVoices = { data: Array<{ id: string }> }; +type FineTune = { id: string }; -const sleep = (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)); +/** Where a maintainer watches training and collects the finished voice id. */ +const PVC_DASHBOARD = 'https://play.cartesia.ai/pro-voice-cloning'; export const cartesia: ProviderTransport = { providerId: 'cartesia', @@ -68,7 +59,7 @@ export const cartesia: ProviderTransport = { format: 'mp3' as const, }), - /** Pro Voice Clone: dataset, files, fine-tune, then the voice it produced. */ + /** Pro Voice Clone: dataset, files, then kick off the fine-tune. */ createClone: async ({ displayName, sampleFiles }) => { if (sampleFiles.length === 0) { throw new TransportError('cartesia PVC needs at least one sample file'); @@ -97,7 +88,7 @@ export const cartesia: ProviderTransport = { ); } - const started = await requestJson( + const { id } = await requestJson( 'POST', `${API}/fine-tunes`, auth, @@ -111,50 +102,10 @@ export const cartesia: ProviderTransport = { 'cartesia fine-tunes/create', ); - // The voice only exists once training completes, so poll for it. - const deadline = Date.now() + TRAINING_TIMEOUT_MS; - for (;;) { - const { status, user_errors } = await requestJson( - 'GET', - `${API}/fine-tunes/${started.id}`, - auth, - undefined, - 'cartesia fine-tunes/get', - ); - if (status === 'completed') break; - if (status === 'failed') { - const detail = (user_errors ?? []).map((e) => `${e.code}: ${e.message}`).join('; '); - throw new TransportError( - `cartesia fine-tune ${started.id} failed${detail ? ` (${detail})` : ''}`, - ); - } - if (Date.now() > deadline) { - throw new TransportError( - `cartesia fine-tune ${started.id} still ${status} after ` + - `${TRAINING_TIMEOUT_MS / 3_600_000}h. Re-attach with GET /fine-tunes/${started.id} ` + - `rather than starting a second one (PVC fine-tunes consume a plan slot).`, - ); - } - console.log(` cartesia fine-tune ${started.id}: ${status}`); - await sleep(POLL_INTERVAL_MS); - } - - const voices = await requestJson( - 'GET', - `${API}/fine-tunes/${started.id}/voices`, - auth, - undefined, - 'cartesia fine-tunes/list-voices', - ); - // One fine-tune yields one voice; the models it serves sit a level below - // the fine-tune. - const voiceId = voices.data[0]?.id; - if (!voiceId) { - throw new TransportError( - `cartesia fine-tune ${started.id} completed but produced no voices`, - ); - } - return voiceId; + // Training runs for up to 3 hours, so return rather than hold the process + // open. All four voices kick off in one run and train concurrently. + console.log(` ${id} training, watch it at ${PVC_DASHBOARD}`); + return null; }, ttfbPlanFor: (vendorModelId): TtfbPlan => ({ diff --git a/src/pipeline/transports/types.ts b/src/pipeline/transports/types.ts index 073df90..6b3421a 100644 --- a/src/pipeline/transports/types.ts +++ b/src/pipeline/transports/types.ts @@ -40,8 +40,11 @@ export type ProviderTransport = { providerId: string; apiKeyEnv: string; synthesize?: (args: SynthesizeArgs) => Promise; - /** Registers a clone, returns the provider voice id. */ - createClone?: (args: CloneArgs) => Promise; + /** + * Registers a clone. Returns the provider voice id, or null when the clone + * trains asynchronously and no id exists yet (record it later with --record). + */ + createClone?: (args: CloneArgs) => Promise; /** Steps to follow when clone creation is not API-automatable. */ manualCloneRunbook?: string; ttfbPlanFor?: (vendorModelId: string) => TtfbPlan; From ef1be55fb5b99d4776bda5178e5a22e0e57a5aa3 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 16:51:19 -0400 Subject: [PATCH 10/16] docs(cartesia): spell out what happens after the fine-tune starts Name the variable fineTune so it does not read as a voice id, say what the null return means, and point at the dashboard plus the --record command needed to finish the job. Co-Authored-By: Claude Opus 5 (1M context) --- src/pipeline/transports/cartesia.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index 1523897..e582f44 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -35,8 +35,8 @@ const headers = (): Record => ({ type Dataset = { id: string }; type FineTune = { id: string }; -/** Where a maintainer watches training and collects the finished voice id. */ -const PVC_DASHBOARD = 'https://play.cartesia.ai/pro-voice-cloning'; +/** Where training is tracked and the finished voice id is collected. */ +const PVC_DASHBOARD = 'https://play.cartesia.ai'; export const cartesia: ProviderTransport = { providerId: 'cartesia', @@ -88,7 +88,7 @@ export const cartesia: ProviderTransport = { ); } - const { id } = await requestJson( + const fineTune = await requestJson( 'POST', `${API}/fine-tunes`, auth, @@ -102,9 +102,14 @@ export const cartesia: ProviderTransport = { 'cartesia fine-tunes/create', ); - // Training runs for up to 3 hours, so return rather than hold the process - // open. All four voices kick off in one run and train concurrently. - console.log(` ${id} training, watch it at ${PVC_DASHBOARD}`); + // 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 =`. + console.log( + ` ${fineTune.id} started. Training takes up to 3 hours; collect the ` + + `voice id from ${PVC_DASHBOARD} once it completes.`, + ); return null; }, From 9802542f95c1b91cca5385ba1d07636260a68377 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 17:00:27 -0400 Subject: [PATCH 11/16] refactor(cartesia): keep the 204 upload handling local The dataset file upload answers 204 with no body, so postFormForJson cannot parse it. Rather than add a fourth helper to the shared module, handle it in uploadSample here and leave http.ts untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/pipeline/transports/cartesia.ts | 34 ++++++++++++++++++++--------- src/pipeline/transports/http.ts | 16 -------------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index e582f44..311de4a 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs'; import { basename } from 'node:path'; import { requireEnv } from '../env'; -import { BENCH_TEXT, postFormNoContent, postJsonForBytes, requestJson, wsTtfbTrial } from './http'; +import { BENCH_TEXT, postJsonForBytes, requestJson, throwForStatus, wsTtfbTrial } from './http'; import { TransportError, type ProviderTransport, type TtfbPlan } from './types'; const API = 'https://api.cartesia.ai'; @@ -38,6 +38,28 @@ type FineTune = { id: string }; /** Where training is tracked and the finished voice id is collected. */ const PVC_DASHBOARD = 'https://play.cartesia.ai'; +/** + * 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, + headers: Record, +): Promise => { + 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, + body: form, + signal: AbortSignal.timeout(180_000), + }); + await throwForStatus(response, `cartesia datasets/upload-file ${basename(file)}`); +}; + export const cartesia: ProviderTransport = { providerId: 'cartesia', apiKeyEnv: 'CARTESIA_API_KEY', @@ -77,15 +99,7 @@ export const cartesia: ProviderTransport = { // A PVC trains on the whole dataset, so upload every sample. for (const file of sampleFiles) { - const form = new FormData(); - form.append('file', new Blob([readFileSync(file)], { type: 'audio/wav' }), basename(file)); - form.append('purpose', 'fine_tune'); - await postFormNoContent( - `${API}/datasets/${dataset.id}/files`, - auth, - form, - `cartesia datasets/upload-file ${basename(file)}`, - ); + await uploadSample(dataset.id, file, auth); } const fineTune = await requestJson( diff --git a/src/pipeline/transports/http.ts b/src/pipeline/transports/http.ts index 72b2fa1..5edbb18 100644 --- a/src/pipeline/transports/http.ts +++ b/src/pipeline/transports/http.ts @@ -73,22 +73,6 @@ export const postFormForJson = async ( return (await response.json()) as T; }; -/** Multipart POST for endpoints that answer 204 No Content (no body to parse). */ -export const postFormNoContent = async ( - url: string, - headers: Record, - form: FormData, - label: string, -): Promise => { - const response = await fetch(url, { - method: 'POST', - headers, - body: form, - signal: AbortSignal.timeout(TIMEOUT_MS * 3), - }); - await throwForStatus(response, label); -}; - export const decodeHexAudio = (hex: string): Uint8Array => { const compact = hex.replace(/\s+/g, ''); const bytes = new Uint8Array(compact.length / 2); From 3cb37fa045322cb4c9dd3da9faa7d9589422d7ed Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 17:07:06 -0400 Subject: [PATCH 12/16] refactor: name the upload timeout, tighten the createClone contract createClone was typed Promise, which assumed cloning produces a usable voice id by the time the call returns. That holds for instant clones: POST /voices/clone answers 200 with the voice, so the id is the result. It does not hold for a Pro Voice Clone. POST /fine-tunes answers with a fine-tune id, which identifies the training job, not a voice. The voice id only exists once training finishes, hours later. Returning the fine-tune id instead would be worse than returning nothing: whatever createClone returns is persisted to voices.local.json and sent as voice.id on every synthesis request, so every clip would 400 on a malformed voice. Promise lets the Cartesia transport say the honest thing, that no voice id exists yet. cloneVoices prints a pending line instead of recording an id, and the operator persists it later with --record. Every other transport returns a string, which still satisfies the type, so none of them change. Also names the 180s upload window UPLOAD_TIMEOUT_MS, matching how fish.ts keeps its own timeout constants. Co-Authored-By: Claude Opus 5 (1M context) --- src/pipeline/transports/cartesia.ts | 5 ++++- src/pipeline/transports/types.ts | 5 +---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index 311de4a..53005b1 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -20,6 +20,9 @@ const VERSION = '2026-08-14'; /** Stock library voice, so the bench matches how other providers are measured. */ const BENCH_VOICE = 'f786b574-daa5-4673-aa0c-cbe3e8534c02'; +/** Sample uploads are large; matches the shared multipart timeout. */ +const UPLOAD_TIMEOUT_MS = 180_000; + /** The four licensed source voices are English (see pipeline/voices.ts). */ const VOICE_LANGUAGE = 'en'; @@ -55,7 +58,7 @@ const uploadSample = async ( method: 'POST', headers, body: form, - signal: AbortSignal.timeout(180_000), + signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), }); await throwForStatus(response, `cartesia datasets/upload-file ${basename(file)}`); }; diff --git a/src/pipeline/transports/types.ts b/src/pipeline/transports/types.ts index 6b3421a..7cb6746 100644 --- a/src/pipeline/transports/types.ts +++ b/src/pipeline/transports/types.ts @@ -40,10 +40,7 @@ export type ProviderTransport = { providerId: string; apiKeyEnv: string; synthesize?: (args: SynthesizeArgs) => Promise; - /** - * Registers a clone. Returns the provider voice id, or null when the clone - * trains asynchronously and no id exists yet (record it later with --record). - */ + /** Registers a clone. Returns the provider voice id, or null if still training. */ createClone?: (args: CloneArgs) => Promise; /** Steps to follow when clone creation is not API-automatable. */ manualCloneRunbook?: string; From a840704bd361f33a364ff82338779c5bc5db4d3a Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 17:35:59 -0400 Subject: [PATCH 13/16] feat(catalog): register Sonic 3.6 next to Sonic 3.5 Moves 3.6 out of the pipeline staging table and into the registry, directly after the 3.5 entry it supersedes, cloned from 3.5's shape. latencyMs stays null and both copy blocks say the benchmark has not run: latency is measured-only and only a maintainer can bench it. seedLikelyRank is omitted because the suite requires unseeded entries not to carry one. voiceProfile is 27, the next free value, since profiles must be unique. Pinned counts bumped intentionally: BASE_MODEL_ENTRIES and listedModelEntries 24 to 25, arenaModelEntries 22 to 23, VARIANTS 88 to 92 (four source voices times one new model). EXPECTED_MODELS gains a row and cartesia-sonic-36 joins UNSEEDED_MODEL_IDS. Note the entry is active, so it joins the arena on merge. Its clips need to exist first or battles will serve missing audio. Co-Authored-By: Claude Opus 5 (1M context) --- src/catalog/catalog.test.ts | 21 ++++++++---- src/catalog/models.ts | 65 +++++++++++++++++++++++++++++++++++++ src/pipeline/models.ts | 15 --------- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/src/catalog/catalog.test.ts b/src/catalog/catalog.test.ts index c6a01a1..4bac015 100644 --- a/src/catalog/catalog.test.ts +++ b/src/catalog/catalog.test.ts @@ -86,13 +86,13 @@ 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), so the arena carries 23 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); + expect(arenaModelEntries().length).toBe(23); // 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( @@ -167,6 +167,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); @@ -367,6 +368,12 @@ const EXPECTED_MODELS = [ providerId: 'cartesia', name: 'Sonic 3.5', }, + { + id: 'cartesia-sonic-36', + arenaId: 'cartesia:sonic-3.6-2026-08-14', + providerId: 'cartesia', + name: 'Sonic 3.6', + }, { id: 'xai-xai-tts', arenaId: 'xai:xai-tts', @@ -508,8 +515,8 @@ describe('server/catalog derivation equality', () => { })), ); // 21 active TTS models x 4 voices + the Human baseline x 4 recorded - // voices = 88 (retired models leave the variant matrix with the arena). - expect(VARIANTS.length).toBe(88); + // voices = 92 (retired models leave the variant matrix with the arena). + expect(VARIANTS.length).toBe(92); expect(VARIANTS).toEqual(expectedVariants); }); @@ -1083,7 +1090,7 @@ describe('unlisted entries are excluded from every derived surface', () => { listedProviderEntries().some((entry) => entry.id === provider.id), ).toBe(false); } - expect(listedModelEntries().length).toBe(24); + expect(listedModelEntries().length).toBe(25); expect(listedProviderEntries().length).toBe(12); }); }); diff --git a/src/catalog/models.ts b/src/catalog/models.ts index 315eff9..62446d0 100644 --- a/src/catalog/models.ts +++ b/src/catalog/models.ts @@ -719,6 +719,71 @@ 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-14', + arenaApiId: 'sonic-3.6-2026-08-14', + status: 'active', + releaseDate: { + value: '2026-08-14', + 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-14. + latencyMs: null, + languages: { + value: 44, + sourceUrl: + 'https://docs.cartesia.ai/build-with-cartesia/tts-models/preview', + asOf: '2026-08-26', + }, + }, + voiceProfile: 27, + sample: { + fallbackClip: clip( + 'voice-emma', + 'cartesia', + 'sonic-3.6-2026-08-14', + 'clip-20', + 'b70ab872a402c56c37c6367c290c062f', + ), + }, + 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', diff --git a/src/pipeline/models.ts b/src/pipeline/models.ts index 837402f..9a19c28 100644 --- a/src/pipeline/models.ts +++ b/src/pipeline/models.ts @@ -90,21 +90,6 @@ const NEW_MODELS: PipelineModel[] = [ name: 'TTS-1.5 Mini', registered: false, }, - // Cartesia Sonic 3.6, GA 2026-08-14. Staged rather than registered because - // clips come first (see CONTRIBUTING: maintainers run the clip pipeline). - // arenaApiId carries the dated snapshot so the hashed clip set is pinned to - // exact weights: Cartesia's bare aliases are repointed over time, and - // `sonic-latest` / `sonic-3-latest` / `sonic-preview` all currently resolve - // to this snapshot, so only the dated id is stable enough to freeze. - { - id: 'cartesia-sonic-36', - slug: 'cartesia-sonic-3-6', - providerId: 'cartesia', - arenaApiId: 'sonic-3.6-2026-08-14', - vendorModelId: 'sonic-3.6-2026-08-14', - name: 'Sonic 3.6', - registered: false, - }, // Fish Audio s2.1-pro. Ids proposed in PR #3 (github.com/VapiAI/humanness-index/pull/3) // and FROZEN here, because the vendor-rendered clip set is hashed against them. { From 2fa2222083bbfd84820dd38fc2afea7681457ef7 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 17:40:45 -0400 Subject: [PATCH 14/16] fix(catalog): correct the Sonic 3.6 snapshot to 2026-08-27 The snapshot was re-minted today. apiModelId, arenaApiId, releaseDate and the EXPECTED_MODELS pin all move, and the fallback clip hash is re-derived from the new identity (arenaApiId feeds the content hash). The 2026-08-14 left in cartesia.ts is the Cartesia-Version API pin, which is a different thing that happened to share the date. Co-Authored-By: Claude Opus 5 (1M context) --- src/catalog/catalog.test.ts | 2 +- src/catalog/models.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/catalog/catalog.test.ts b/src/catalog/catalog.test.ts index 4bac015..fdb471a 100644 --- a/src/catalog/catalog.test.ts +++ b/src/catalog/catalog.test.ts @@ -370,7 +370,7 @@ const EXPECTED_MODELS = [ }, { id: 'cartesia-sonic-36', - arenaId: 'cartesia:sonic-3.6-2026-08-14', + arenaId: 'cartesia:sonic-3.6-2026-08-27', providerId: 'cartesia', name: 'Sonic 3.6', }, diff --git a/src/catalog/models.ts b/src/catalog/models.ts index 62446d0..606d8e0 100644 --- a/src/catalog/models.ts +++ b/src/catalog/models.ts @@ -724,18 +724,18 @@ export const MODEL_ENTRIES: ModelEntry[] = [ slug: 'cartesia-sonic-3-6', providerId: 'cartesia', name: 'Sonic 3.6', - apiModelId: 'sonic-3.6-2026-08-14', - arenaApiId: 'sonic-3.6-2026-08-14', + apiModelId: 'sonic-3.6-2026-08-27', + arenaApiId: 'sonic-3.6-2026-08-27', status: 'active', releaseDate: { - value: '2026-08-14', + 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-14. + // Measured only: needs a bench run against sonic-3.6-2026-08-27. latencyMs: null, languages: { value: 44, @@ -749,9 +749,9 @@ export const MODEL_ENTRIES: ModelEntry[] = [ fallbackClip: clip( 'voice-emma', 'cartesia', - 'sonic-3.6-2026-08-14', + 'sonic-3.6-2026-08-27', 'clip-20', - 'b70ab872a402c56c37c6367c290c062f', + 'e2f28d30f2765eeecafc81c41a7981d6', ), }, copy: [ From ce7497bd02f42587a69c66abf2d3d9f820ffe7e0 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 18:38:26 -0400 Subject: [PATCH 15/16] fix(catalog): ship Sonic 3.6 unlisted until its clips exist Registering it active put a model in the arena with no audio behind it, and forced a pinned fallbackClip hash for a file nobody has generated. The registry already has the right state for this: unlisted is documented as excluded from every derived surface with identity kept wired so re-listing is a one-line status flip, and the type makes sample optional there, which is why the fake clip pin is gone. Reverts the pins that only applied while it was active: arenaModelEntries, listedModelEntries, VARIANTS and the EXPECTED_MODELS row all go back. BASE_MODEL_ENTRIES stays 25, and the unlisted assertion now names the entry rather than expecting none. Co-Authored-By: Claude Opus 5 (1M context) --- src/catalog/catalog.test.ts | 27 +++++++++++---------------- src/catalog/models.ts | 11 +---------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/catalog/catalog.test.ts b/src/catalog/catalog.test.ts index fdb471a..d728b15 100644 --- a/src/catalog/catalog.test.ts +++ b/src/catalog/catalog.test.ts @@ -88,16 +88,17 @@ describe('registry: ids, slugs, refs', () => { it('pins the collection counts (bump intentionally when adding entries)', () => { // 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 23 of the 25. + // 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(25); expect(arenaProviderEntries().length).toBe(12); - expect(arenaModelEntries().length).toBe(23); - // 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( - [], - ); + expect(arenaModelEntries().length).toBe(22); + // 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', () => { @@ -368,12 +369,6 @@ const EXPECTED_MODELS = [ providerId: 'cartesia', name: 'Sonic 3.5', }, - { - id: 'cartesia-sonic-36', - arenaId: 'cartesia:sonic-3.6-2026-08-27', - providerId: 'cartesia', - name: 'Sonic 3.6', - }, { id: 'xai-xai-tts', arenaId: 'xai:xai-tts', @@ -515,8 +510,8 @@ describe('server/catalog derivation equality', () => { })), ); // 21 active TTS models x 4 voices + the Human baseline x 4 recorded - // voices = 92 (retired models leave the variant matrix with the arena). - expect(VARIANTS.length).toBe(92); + // voices = 88 (retired models leave the variant matrix with the arena). + expect(VARIANTS.length).toBe(88); expect(VARIANTS).toEqual(expectedVariants); }); @@ -1090,7 +1085,7 @@ describe('unlisted entries are excluded from every derived surface', () => { listedProviderEntries().some((entry) => entry.id === provider.id), ).toBe(false); } - expect(listedModelEntries().length).toBe(25); + expect(listedModelEntries().length).toBe(24); expect(listedProviderEntries().length).toBe(12); }); }); diff --git a/src/catalog/models.ts b/src/catalog/models.ts index 606d8e0..4503fe5 100644 --- a/src/catalog/models.ts +++ b/src/catalog/models.ts @@ -726,7 +726,7 @@ export const MODEL_ENTRIES: ModelEntry[] = [ name: 'Sonic 3.6', apiModelId: 'sonic-3.6-2026-08-27', arenaApiId: 'sonic-3.6-2026-08-27', - status: 'active', + status: 'unlisted', releaseDate: { value: '2026-08-27', sourceUrl: @@ -745,15 +745,6 @@ export const MODEL_ENTRIES: ModelEntry[] = [ }, }, voiceProfile: 27, - sample: { - fallbackClip: clip( - 'voice-emma', - 'cartesia', - 'sonic-3.6-2026-08-27', - 'clip-20', - 'e2f28d30f2765eeecafc81c41a7981d6', - ), - }, copy: [ { heading: 'Background', From 7d94029b9a4bfcb03f9df2e67644d5d04374dc77 Mon Sep 17 00:00:00 2001 From: Eric Segev Date: Wed, 26 Aug 2026 18:53:08 -0400 Subject: [PATCH 16/16] refactor: move the PVC clone work out of this PR createClone, its supporting helpers, the ProviderTransport return-type change and the cloneVoices handling all move to a follow-up PR so this one stays about the API version pin, auth, language, and the registry entries. Also restores the Clara clone as the TTFB bench voice. Co-Authored-By: Claude Opus 5 (1M context) --- src/pipeline/cloneVoices.ts | 8 +-- src/pipeline/transports/cartesia.ts | 103 ++++++---------------------- src/pipeline/transports/types.ts | 4 +- 3 files changed, 25 insertions(+), 90 deletions(-) diff --git a/src/pipeline/cloneVoices.ts b/src/pipeline/cloneVoices.ts index 4dbc971..a1077ac 100644 --- a/src/pipeline/cloneVoices.ts +++ b/src/pipeline/cloneVoices.ts @@ -166,12 +166,8 @@ const main = async (): Promise => { displayName, sampleFiles: samples, }); - if (providerVoiceId) { - additions[voiceId] = providerVoiceId; - console.log(`✓ ${voiceId} → ${providerVoiceId} (${samples.length} samples)`); - } else { - console.log(`… ${voiceId}: training started (${samples.length} samples)`); - } + additions[voiceId] = providerVoiceId; + console.log(`✓ ${voiceId} → ${providerVoiceId} (${samples.length} samples)`); } catch (error) { console.error( `✗ ${voiceId}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/pipeline/transports/cartesia.ts b/src/pipeline/transports/cartesia.ts index 53005b1..f402e61 100644 --- a/src/pipeline/transports/cartesia.ts +++ b/src/pipeline/transports/cartesia.ts @@ -1,68 +1,34 @@ /** * Cartesia transport. Synthesis ported from the original prototype's * Cartesia adapter (tts/bytes, mp3 44.1 kHz / - * 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 (stock voice). + * 128 kbps); instant cloning per + * https://docs.cartesia.ai/build-with-cartesia/capability-guides/clone-voices; + * 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, postJsonForBytes, requestJson, throwForStatus, wsTtfbTrial } from './http'; -import { TransportError, type ProviderTransport, type TtfbPlan } from './types'; +import { BENCH_TEXT, postFormForJson, postJsonForBytes, wsTtfbTrial } from './http'; +import type { ProviderTransport, TtfbPlan } from './types'; const API = 'https://api.cartesia.ai'; /** Cartesia's current published API version, sent on every call. */ const VERSION = '2026-08-14'; -/** Stock library voice, so the bench matches how other providers are measured. */ -const BENCH_VOICE = 'f786b574-daa5-4673-aa0c-cbe3e8534c02'; - -/** Sample uploads are large; matches the shared multipart timeout. */ -const UPLOAD_TIMEOUT_MS = 180_000; +/** The arena Clara clone, the voice the original 50-trial bench used. */ +const BENCH_VOICE = 'a5d537b0-4a5f-464d-ac12-d143fe1a0a36'; /** 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'; - /** Bearer is the documented scheme; x-api-key is the pre-2026 form. */ const headers = (): Record => ({ Authorization: `Bearer ${requireEnv('CARTESIA_API_KEY')}`, 'Cartesia-Version': VERSION, }); -type Dataset = { id: string }; -type FineTune = { id: string }; - -/** Where training is tracked and the finished voice id is collected. */ -const PVC_DASHBOARD = 'https://play.cartesia.ai'; - -/** - * 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, - headers: Record, -): Promise => { - 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, - body: form, - signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), - }); - await throwForStatus(response, `cartesia datasets/upload-file ${basename(file)}`); -}; - export const cartesia: ProviderTransport = { providerId: 'cartesia', apiKeyEnv: 'CARTESIA_API_KEY', @@ -84,50 +50,23 @@ export const cartesia: ProviderTransport = { format: 'mp3' as const, }), - /** Pro Voice Clone: dataset, files, then kick off the fine-tune. */ createClone: async ({ displayName, sampleFiles }) => { - 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( - '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( - 'POST', - `${API}/fine-tunes`, - auth, - { - name: displayName, - description, - language: VOICE_LANGUAGE, - model_id: PVC_BASE_MODEL, - dataset: dataset.id, - }, - 'cartesia fine-tunes/create', + const form = new FormData(); + form.append( + 'clip', + new Blob([readFileSync(sampleFiles[0])], { type: 'audio/wav' }), + basename(sampleFiles[0]), ); - - // 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 =`. - console.log( - ` ${fineTune.id} started. Training takes up to 3 hours; collect the ` + - `voice id from ${PVC_DASHBOARD} once it completes.`, + form.append('name', displayName); + form.append('language', VOICE_LANGUAGE); + form.append('mode', 'similarity'); + const result = await postFormForJson<{ id: string }>( + `${API}/voices/clone`, + headers(), + form, + 'cartesia voices/clone', ); - return null; + return result.id; }, ttfbPlanFor: (vendorModelId): TtfbPlan => ({ diff --git a/src/pipeline/transports/types.ts b/src/pipeline/transports/types.ts index 7cb6746..073df90 100644 --- a/src/pipeline/transports/types.ts +++ b/src/pipeline/transports/types.ts @@ -40,8 +40,8 @@ export type ProviderTransport = { providerId: string; apiKeyEnv: string; synthesize?: (args: SynthesizeArgs) => Promise; - /** Registers a clone. Returns the provider voice id, or null if still training. */ - createClone?: (args: CloneArgs) => Promise; + /** Registers a clone, returns the provider voice id. */ + createClone?: (args: CloneArgs) => Promise; /** Steps to follow when clone creation is not API-automatable. */ manualCloneRunbook?: string; ttfbPlanFor?: (vendorModelId: string) => TtfbPlan;