Every "best text-to-speech API" list I have read is ranked. Number one, number two, number three, with a verdict at the bottom.
That shape cannot express the actual decision, and I want to show you why with something you can run.
The problem is that the three things that decide a TTS vendor are measured in units that do not convert into each other. Price is dollars per million characters. Transport is a shape β held-open socket, chunked body, finished file. Compliance is a document that either exists or does not. There is no exchange rate between them, so there is no ordering. A ranked list has to pick one axis and pretend the others are tiebreakers.
They are not tiebreakers. They are filters, and filters compose by intersection.
Price spans about 40x. Google Cloud's legacy voices and Amazon Polly's standard engine sit at $4 per million characters. The mid-market β OpenAI's tts-1
, Deepgram Aura-1, Inworld TTS-2 Flash β clusters at $15. Cartesia runs $37.38 to $50. ElevenLabs is $166.11 at its Scale tier. A million characters is roughly 22 hours of speech, so at prototype volume this axis is noise; at a hundred million characters a year it is the difference between a $400 bill and a $16,600 one.
Transport comes in three shapes and the difference is architectural, not incremental.
wav
or pcm
output specifically because those start playing sooner than a compressed container. Meaningfully better than waiting for a whole file; meaningfully worse than a held-open socket.Two entries in that column are routinely stated wrong, so they are worth getting right.
Amazon Polly has no WebSocket TTS API, but it is not batch-only. StartSpeechSynthesisStream
is a real bidirectional streaming API over HTTP/2 β you send text incrementally as events and receive audio as it becomes available. It will serve a conversational agent. The constraint is not the transport, it is the ceiling: generative engine only, 8 transactions per second, up to 8 concurrent requests. That is a real number and it is small.
Google supports streaming on Chirp 3: HD, with a caveat that will find you in integration rather than in evaluation: SSML is not supported on streaming requests. You can have streaming or you can have your markup.
Compliance is the axis where price frequently never enters the conversation at all. As published at the time of writing: Google Cloud Text-to-Speech is named in Google's HIPAA BAA covered-products list and Polly is on the AWS HIPAA-eligible services list β if your compliance team wants a document rather than a blog post, those two already have one. OpenAI offers a BAA for the API without an enterprise agreement. Rime publishes the strongest specialist position and, unusually, publishes dates rather than logos: compliant since February 2024, most recent audit March 2026, BAA on Enterprise, with VPC or full on-prem. Deepgram offers a BAA on request. Inworld gates HIPAA behind the Growth tier, which means you price the compliance tier and not the entry rate. Cartesia does not publish a position.
Here is the whole idea as forty lines of standard library. No network, no API key.
from dataclasses import dataclass
@dataclass(frozen=True)
class Provider:
name: str
usd_per_million_chars: float
transport: str # websocket | http2-stream | chunked | batch
max_concurrent_streams: int | None # None = no stated ceiling
hipaa: str # published | on-request | plan-gated | enterprise | unpublished
CATALOGUE = [
Provider("Google Cloud TTS", 4.00, "websocket", None, "published"),
Provider("Amazon Polly", 4.00, "http2-stream", 8, "published"),
Provider("OpenAI TTS", 15.00, "chunked", None, "published"),
Provider("Deepgram Aura-1", 15.00, "websocket", None, "on-request"),
Provider("Inworld TTS-2 Flash", 15.00, "websocket", None, "plan-gated"),
Provider("Inworld TTS-2", 25.00, "websocket", None, "plan-gated"),
Provider("Deepgram Aura-2", 30.00, "websocket", None, "on-request"),
Provider("Rime Mist v3", 30.00, "websocket", None, "published"),
Provider("Cartesia", 37.38, "websocket", None, "unpublished"),
Provider("Rime Coda", 50.00, "websocket", None, "published"),
Provider("ElevenLabs", 166.11, "websocket", None, "enterprise"),
]
BUDGET = 15.0
CONVERSATIONAL = {"websocket", "http2-stream"}
CONCURRENCY = 25
HIPAA_OK = {"published"}
price = {p.name for p in CATALOGUE if p.usd_per_million_chars <= BUDGET}
transport = {p.name for p in CATALOGUE if p.transport in CONVERSATIONAL}
concurrency = {p.name for p in CATALOGUE
if p.max_concurrent_streams is None or p.max_concurrent_streams >= CONCURRENCY}
compliance = {p.name for p in CATALOGUE if p.hipaa in HIPAA_OK}
print(sorted(price & transport & concurrency & compliance))
Run it with those four constraints β a mid-market budget, a conversational transport, twenty-five concurrent streams, and a HIPAA position your compliance team can read β and this is the real output:
budget <= $15/M chars : 5 ['Amazon Polly', 'Deepgram Aura-1', 'Google Cloud TTS', 'Inworld TTS-2 Flash', 'OpenAI TTS']
transport conversational : 10 ['Amazon Polly', 'Cartesia', 'Deepgram Aura-1', ...]
concurrency >= 25 streams : 10 ['Cartesia', 'Deepgram Aura-1', 'Deepgram Aura-2', ...]
compliance published HIPAA : 5 ['Amazon Polly', 'Google Cloud TTS', 'OpenAI TTS', 'Rime Coda', 'Rime Mist v3']
intersection : 1 ['Google Cloud TTS']
Eleven rows in, one row out.
Notice that no individual filter looked severe. The loosest kept ten of eleven. The tightest kept five. Any one of them, read on its own, leaves you feeling like you have a comfortable field of candidates β which is exactly the feeling a ranked list is designed to give you, and exactly the feeling that gets teams to month six before the constraint they never filtered on shows up.
The intersection tells you the answer. It does not tell you what to negotiate, and that is the more useful output. So drop each constraint in turn:
without budget -> 3 ['Google Cloud TTS', 'Rime Coda', 'Rime Mist v3']
without transport -> 2 ['Google Cloud TTS', 'OpenAI TTS']
without concurrency -> 2 ['Amazon Polly', 'Google Cloud TTS']
without compliance -> 3 ['Deepgram Aura-1', 'Google Cloud TTS', 'Inworld TTS-2 Flash']
Every constraint is load-bearing here β each one is holding back one or two additional candidates β and none of them individually opens the field. That is a genuinely useful thing to know before you go and argue with someone about it, because it tells you no single concession rescues the shortlist.
It also puts a number on the Polly ceiling. Polly disappears from the viable set solely because of the 8-concurrent-request limit; relax concurrency and it comes back at the cheapest price band in the table. If your workload is bursty-but-small, that ceiling is fine and Polly is a $4 answer. If you are running two dozen simultaneous calls, the ceiling is not a detail, it is a disqualification. Same vendor, same price, opposite verdict β and no ranked list can hold both.
Latency. You will have noticed it is absent from the script, and that is on purpose.
Every latency figure any of these vendors publishes is a vendor claim, and they are not measured the same way:
| Provider | Claim | The qualifier that matters |
|---|---|---|
| Inworld TTS-2 Flash | 20ms TTFB | P90, server-side β excludes network |
| Rime Mist v3 | 37ms P50 TTFA | at 1 concurrency |
| Deepgram Flux | 80ms first audio | "under production load", undefined |
| Cartesia Sonic | sub-90ms TTFA | vendor advertisement |
| Deepgram Aura-2 | sub-200ms TTFB | no methodology stated |
| OpenAI / Google / AWS | not published | |
| β |
A server-side P90 that excludes network time and a single-concurrency P50 are not the same measurement. Neither predicts what your users experience at your concurrency from your region. Putting those in a sorted()
call would produce a number that looks like a decision and is not one.
I have not benchmarked these APIs myself and I am not going to pretend otherwise. The honest way to model latency is as a gate you measure yourself, after the intersection has cut the field to something you can actually stand up and test. Two or three candidates you can benchmark is a tractable afternoon. Eleven is not, which is the other reason to intersect first.
The catalogue in the script is a snapshot, and every number in it will drift. That is fine β swap the numbers and the method still holds, which is the point of writing the decision as code rather than as a ranking.
I keep the current per-million prices, the full transport table including the per-model caveats, and the compliance positions in a longer write-up of eight text-to-speech APIs on one axis if you want the underlying numbers rather than the method.