DEV Community

Cover image for Your TTS shortlist is three shortlists, and they barely intersect
AI Alleyway
AI Alleyway

Posted on

Your TTS shortlist is three shortlists, and they barely intersect

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.

The three sets

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.

  • WebSocket streaming holds a connection open and pushes audio as it is synthesised. The first syllable can reach the caller while the model is still working on the sentence. This is what a live agent needs.
  • Chunked REST streams the response body back progressively. OpenAI works this way, and its docs recommend 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.
  • Batch returns a finished file. Correct for narration, e-learning, anything rendered ahead of time. Wrong for conversation.

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.

Intersecting instead of ranking

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"),
]

# Your constraints, not mine.
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))
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

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.

Which constraint is actually binding

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']
Enter fullscreen mode Exit fullscreen mode

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.

About the axis I deliberately did not model

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 shape I would actually use

  1. Write down the three constraints as sets before you look at any vendor. If you cannot state your concurrency requirement, you are not ready to choose.
  2. Intersect. Expect the survivors to be far fewer than the field.
  3. If the intersection is empty, drop-one to find out what to negotiate — budget, ceiling or paperwork — rather than quietly abandoning the constraint that is easiest to forget.
  4. Only now benchmark latency, on the two or three that survived, from your region at your concurrency.

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.

Top comments (0)