Sonic 3.6 vs Eleven v4 Turbo: one script, one Python A/B run on Sume
Send the same text to sonic-3.6 and eleven-v4-turbo through the TTS Router with one Python script, poll both jobs, and compare the files by ear.

To compare Cartesia Sonic 3.6 and Eleven v4 Turbo on your own script, send the same transcript to POST /v1/tts-router/generate twice, once with model set to sonic-3.6 and once with eleven-v4-turbo, then poll both jobs and listen to the two files. The only things that change between the calls are the model id and the voice, because a Sonic row takes a Sume voice and an Eleven row takes an ElevenLabs voice id.
The request fields come from the router description in Sume's API reference and the polling steps from Sume's Jobs and results page, both read 2026-10-11. ElevenLabs' models page, read the same day, describes v4 Turbo as a low-latency model with a median inference latency of about 100 ms, which matters for live agents; a finished-file job like this one does not depend on it.
What has to match, and what cannot?
For a fair listen, hold the text, the language and the output container constant. Two things cannot match: the voices, since each family has its own voice list, and the settings, since Sonic takes generation_config and Eleven takes voice_settings.
- Same:
transcript,language, and an MP3 output (the Eleven rows return MP3 only). - Different: Sonic's
voice.idis a Sume voice id, Eleven's is a 20-character ElevenLabs id fromGET /v1/tts-router/voices?family=eleven. - Different: leave
generation_configoff the Eleven call andvoice_settingsoff the Sonic call, because each row refuses the other family's field. - Keep the script at or under 10,000 characters so both rows accept it in one request.
How do I run both jobs from Python?
Set SUME_API_KEY, SONIC_VOICE and ELEVEN_VOICE, then run the script. It stops if the key is missing, polls each job until terminal is true, and prints the result only when result_ready is true. A fixed Idempotency-Key per model and label means a second run of the same label returns the same jobs instead of billing again.
import os, time, requests
BASE = "https://api.sume.com"
KEY = os.environ.get("SUME_API_KEY", "")
if not KEY:
raise SystemExit("set SUME_API_KEY")
AUTH = {"Authorization": f"Bearer {KEY}"}
TEXT = "Our spring sale starts Friday. Everything ships free."
VOICES = {"sonic-3.6": os.environ["SONIC_VOICE"],
"eleven-v4-turbo": os.environ["ELEVEN_VOICE"]}
def run(model, label="ab-1"):
body = {"model": model, "transcript": TEXT, "language": "en",
"voice": {"id": VOICES[model]}}
head = {**AUTH, "Idempotency-Key": f"{label}-{model}"}
r = requests.post(f"{BASE}/v1/tts-router/generate", json=body,
headers=head, timeout=30)
r.raise_for_status()
jid = r.json()["job"]["id"]
while True:
s = requests.get(f"{BASE}/v1/jobs/{jid}/status", headers=AUTH,
timeout=30).json()
if s.get("terminal"):
break
time.sleep(float(s.get("next_poll_after_seconds") or 3))
if not s.get("result_ready"):
raise RuntimeError(f"{model} finished without a result")
return requests.get(f"{BASE}/v1/jobs/{jid}/result", headers=AUTH,
timeout=30).json()
for m in VOICES:
print(m, run(m))What should I compare when I listen?
Listen for the things that decide a voiceover: whether numbers and brand names are read correctly, whether the pacing fits the length of your video, and whether the delivery is steady across a second take. One short line is not enough, so use three or four lines from the real script, including one with a number and one with a product name, and have two people choose without knowing which file is which.
Then compare the price. At the catalog rates, the short test line above is a cent or less on either row, and a 10,000-character script is $0.48 on Sonic 3.6 and $0.50 on v4 Turbo, rounded up to the cent. The difference is small, so choose on sound and on the limits in the table below.
| Item | sonic-3.6 | eleven-v4-turbo |
|---|---|---|
| Price per 1,000 characters | $0.0475 | $0.05 |
| Largest request | 20,000 characters | 10,000 characters |
| Output | MP3 or WAV | MP3 only |
| Word timings and sentence slices | yes | refused |
| Voice selector | Sume voice or avatar | ElevenLabs voice id |
What do I do with the winner?
Pin the winning model id in your code rather than a moving alias. sonic-latest always resolves to the newest stable Sonic, which is useful until the day it changes a voice mid-campaign. Keep the A/B script, and re-run it when the catalog adds a row. See Text to speech API in Python for the single-model version.
Sources
Related posts
More in Developers
- Sume 400 invalid_request: read details.errors[].path to find the field
A Sume 400 invalid_request lists each bad field in details.errors with path, loc, message and type. Read them in Python instead of guessing from the message.
- A Sume 400 that returns a job id: input rejected at provider submit
Most Sume 400s create nothing, but a provider input rejected at submit leaves a failed job, a request_id and a refund. How to read it and what key to use.
- Sume 404 resource_not_found: job_not_found and model_not_found overlap
Every Sume 404 reports public_reason resource_not_found, whatever the code. Branch on error.code to tell a missing job from a bad model id or a missing asset.
- Sume 409 job_not_queued: the job left the queue before it started
What the Sume 409 job_not_queued means: a submit found its job no longer queued, sent nothing to the provider, and the error is not retryable by resending.
Written by Sume