New AssemblyAI-TTS-1: Text-to-speech for voice agents. Learn more
Insights & Use Cases

Migrate from OpenAI TTS to AssemblyAI before the January 2027 shutdown

OpenAI shuts down tts-1, tts-1-hd and gpt-4o-mini-tts on January 6, 2027. Keep your /audio/speech code and move to AssemblyAI text-to-speech by changing the base URL, the key and the model name.

Abstract green mobius illustration

Written by

Kelsey Foster

Published on

3 November 2026

OpenAI will remove tts-1, tts-1-hd and gpt-4o-mini-tts from its API on January 6, 2027. Its deprecations page recommends gpt-realtime-2.1-mini as the replacement, but its own text-to-speech guide says that model isn't supported on /v1/audio/speech. So if your product calls OpenAI's speech endpoint today, something in your code has to change before January, whichever way you go.

Here's a shorter path. AssemblyAI's Text-to-Speech API answers the same POST /audio/speech request shape under its own base URL. You keep the OpenAI SDK you already use, change the base URL and the key, then pick a model and voice. This guide walks through that swap in Python and JavaScript, maps OpenAI voices to AssemblyAI voices, lists every behavior that changes, and shows when it's worth moving to the native WebSocket session instead.

What OpenAI is retiring, and when

OpenAI posted the deprecation on October 1, 2026. These models stop working on January 6, 2027:

Model Shutdown date OpenAI's recommended replacement
tts-1 Jan 6, 2027 gpt-realtime-2.1-mini
tts-1-hd Jan 6, 2027 gpt-realtime-2.1-mini
gpt-4o-mini-tts (2025-03-20 and 2025-12-15 snapshots) Jan 6, 2027 gpt-realtime-2.1-mini

The catch is in the guide's own wording: moving to gpt-realtime-2.1-mini is "a change of API workflow." The Realtime API is a different protocol, with sessions and events in place of one HTTP request per utterance. Staying with OpenAI means rewriting your speech layer around it.

If what you actually need is the speech endpoint you already have, with voices built for live conversation, you can move providers without touching that request shape at all.

AssemblyAI's text-to-speech model, AssemblyAI-TTS-1, is built for conversational voice agents. It's trained on millions of hours of audio, including real conversational data, and it's built to get the details agents trip on right: confirmation codes, prices, dates and names. It speaks six languages (English, Spanish, French, German, Italian and Portuguese) with thousands of voices to choose from, and it comes with unlimited concurrency, so you don't negotiate capacity as call volume grows. It costs $15 per 1M characters, billed on input characters (pricing).

Swap the base URL and key

The OpenAI compatibility route lives at:

Data zone Base URL
US https://tts.us.assemblyai.com/v1/openai
EU https://tts.eu.assemblyai.com/v1/openai

Point any OpenAI SDK at that base URL with your AssemblyAI API key as its API key. The SDK sends the key as Authorization: Bearer <key>, which the API accepts.

Before: OpenAI

A typical streaming call to OpenAI's speech endpoint looks like this:

from pathlib import Path

from openai import OpenAI

client = OpenAI()  # reads OPENAI_API_KEY

with client.audio.speech.with_streaming_response.create(
    model="tts-1",
    voice="alloy",
    input="Thanks for calling. How can I help you today?",
    response_format="pcm",
) as response:
    response.stream_to_file(Path("reply.pcm"))

After: AssemblyAI, Python

The AssemblyAI version changes the client constructor and the model name. Everything after create( keeps its shape:

import os

import httpx2
from openai import DefaultHttpx2Client, OpenAI

client = OpenAI(
    base_url="https://tts.us.assemblyai.com/v1/openai",
    api_key=os.environ["ASSEMBLYAI_API_KEY"],
    # The SDK's own pool sizes, with idle connections kept for 2 minutes
    # instead of 5 seconds; see "Keep connections warm between replies" below.
    http_client=DefaultHttpx2Client(
        limits=httpx2.Limits(
            max_connections=1000, max_keepalive_connections=100, keepalive_expiry=120
        )
    ),
)

with client.audio.speech.with_streaming_response.create(
    model="assemblyai-tts-en",
    voice="alloy",
    input="Hello from AssemblyAI.",
    response_format="pcm",
) as response:
    print("request id:", response.headers.get("x-request-id"))
    response.stream_to_file("hello.pcm")

This example targets openai 3, which is built on httpx2. On openai 2 or earlier, import httpx in place of httpx2 and use DefaultHttpxClient in place of DefaultHttpx2Client. The output, hello.pcm, is raw 16-bit mono PCM at 24 kHz, the same layout OpenAI's pcm format uses.

After: AssemblyAI, JavaScript

On Node, the JavaScript version also swaps in undici's fetch so the client can keep connections open longer:

import fs from "node:fs/promises";
import OpenAI from "openai";
import { Agent, fetch } from "undici";

const client = new OpenAI({
  baseURL: "https://tts.us.assemblyai.com/v1/openai",
  apiKey: process.env.ASSEMBLYAI_API_KEY,
  // Idle connections kept for 2 minutes instead of Node's 4 seconds.
  fetch,
  fetchOptions: { dispatcher: new Agent({ keepAliveTimeout: 120_000 }) },
});

const response = await client.audio.speech.create({
  model: "assemblyai-tts-en",
  voice: "alloy",
  input: "Hello from AssemblyAI.",
  response_format: "wav",
});
await fs.writeFile("hello.wav", Buffer.from(await response.arrayBuffer()));

Install undici with npm install undici; the SDK only takes a dispatcher together with undici's own fetch. In a browser, leave both out, and never ship your API key to the client. Give the SDK a temporary token minted with product=tts as its apiKey, and set dangerouslyAllowBrowser: true.

That's the whole migration for most integrations: two constructor arguments and a model name.

Get an AssemblyAI API key

The compatibility route works with the OpenAI SDK you already run. Create a free account, copy your key, and point your client at the new base URL.

Sign up free

Map models and voices

Models

OpenAI model names don't carry over. Sending tts-1 returns a 400 with the code model_not_found, and the error message tells you to pick a name from the models list. Use one of these:

model value What it does
assemblyai-tts-en, -es, -de, -it, -pt, -fr The default model for that language. The suffix also sets the language, so assemblyai-tts-es speaks Spanish
assemblyai-tts-1 The model itself, with no language set. The language comes from the language extension, or from the voice

Names are case-insensitive. GET https://tts.us.assemblyai.com/v1/openai/models returns the six language models and needs no key, which makes it a handy smoke test from a deploy script.

Voices

You can keep sending OpenAI voice names. In English, each one speaks with an AssemblyAI voice:

OpenAI voice AssemblyAI voice
alloy aai-jane
ash, verse aai-michael
ballad, fable aai-charles
cedar, onyx aai-george
coral aai-anna
echo aai-paul
marin aai-mary
nova aai-eve
sage aai-vera
shimmer aai-alba

Two things to know about that table:

  • It's English-only. In any other language, every OpenAI voice name speaks with aai-jane. For a Spanish or German agent, send the slug or ID of a voice recorded in that language.
  • It's a starting point. The mapped voices are the model's built-in voices. The voice catalog in the AssemblyAI dashboard holds thousands more, and any of them can go in voice as a slug (such as aai-audrey) or a UUID.

Bare names don't work. voice="jane" comes back as a 422, and the error message names the slug to send instead, aai-jane. Every response carries an X-TTS-Voice-Id header with the voice that actually spoke, plus X-TTS-Voice-Alias when you sent an OpenAI name, which helps when you're auditing a migration.

Pick a native voice and language

AssemblyAI-specific options go through the SDK's extra-body option. This request names Audrey by UUID and asks for English at 16 kHz:

import os

from openai import OpenAI

client = OpenAI(
    base_url="https://tts.us.assemblyai.com/v1/openai",
    api_key=os.environ["ASSEMBLYAI_API_KEY"],
)

with client.audio.speech.with_streaming_response.create(
    model="assemblyai-tts-en",
    voice="49c443a4-8a1d-5f7e-b70b-98bdbd9823c3",  # Audrey
    input="Thanks for calling.",
    response_format="pcm",
    extra_body={"language": "en", "sample_rate": 16000},
) as response:
    response.stream_to_file("thanks.pcm")

The extensions are language (a name like english or an ISO 639-1 code like en), sample_rate (8000, 16000, 22050, 24000, 44100 or 48000; default 24000), voice_id (for clients that can't send voice as an object) and extend_biometric_consent (for cloned voices). A cloned voice goes in voice as {"id": "<voice ID>"} or as its UUID string.

What changes: formats, speed, instructions, limits and errors

The request shape is OpenAI's. A handful of fields behave differently, and you'll want to know about them before production traffic finds them for you.

Area OpenAI speech API AssemblyAI compatibility route
response_format mp3, opus, aac, flac, wav, pcm mp3 (default), pcm, wav, mulaw, alaw. opus, aac and flac are refused
speed Adjustable Accepted only when it equals 1; any other value is refused
instructions Steers tone on gpt-4o-mini-tts Ignored, and listed in the X-TTS-Unknown-Params response header (so is user)
input length Per OpenAI's limits 1 to 5,000 Unicode code points, not blank
Word timings Not returned Not on this route; use the speech events endpoint or the WebSocket API
SSE usage block Token counts Character counts; token fields are always 0

A few of these need more than a table row.

Formats. If you stored opus or aac files, switch to mp3, wav or pcm. For a voice agent, ask for pcm: it starts playing on the first bytes and needs no decoding, while MP3 adds encoder silence to the start of every sentence. For phone calls, the route also speaks raw G.711, which is new compared to OpenAI's format list:

import os

from openai import OpenAI

client = OpenAI(
    base_url="https://tts.us.assemblyai.com/v1/openai",
    api_key=os.environ["ASSEMBLYAI_API_KEY"],
)

# 8 kHz mu-law, the format Twilio Media Streams expect.
with client.audio.speech.with_streaming_response.create(
    model="assemblyai-tts-en",
    voice="49c443a4-8a1d-5f7e-b70b-98bdbd9823c3",  # Audrey
    input="Your appointment is confirmed for Tuesday at 3 PM.",
    response_format="mulaw",
    extra_body={"sample_rate": 8000},
) as response:
    response.stream_to_file("confirm.ulaw")

OpenAI's Python type hints list only OpenAI's formats, so a type checker may flag "mulaw". The SDK sends it as is.

Instructions. If you prompted gpt-4o-mini-tts with tone instructions like "speak in a cheerful tone," that steering is gone on this route; the field is ignored without an error. Write the tone into the text itself. Punctuation, sentence length and conversational fillers carry a lot of the delivery for a conversational model.

Speed. Any speed other than 1 is refused. If you sped speech up for an impatient UI, pick a voice with a faster natural pace from the catalog; several of the starter voices are described as fast-paced.

Errors. Error bodies follow OpenAI's format, so your existing except clauses keep working:

import os

import openai
from openai import OpenAI

client = OpenAI(
    base_url="https://tts.us.assemblyai.com/v1/openai",
    api_key=os.environ["ASSEMBLYAI_API_KEY"],
    max_retries=0,  # back off on Retry-After yourself; SDK retries count against your RPM
)

try:
    with client.audio.speech.with_streaming_response.create(
        model="assemblyai-tts-en",
        voice="alloy",
        input="One moment while I look that up.",
        response_format="pcm",
    ) as response:
        response.stream_to_file("wait.pcm")
except openai.BadRequestError as err:
    # 400: a bad field, model or voice, e.g. code "model_not_found" for "tts-1"
    print("bad request:", err.code, err.message)
except openai.RateLimitError as err:
    # 429: "rate_limited" (requests per minute) or "at_capacity"
    print("retry after:", err.response.headers.get("retry-after"))

The SDKs retry a 429 twice by default, and each retry counts against your requests-per-minute limit, including retries after an at_capacity refusal. Set max_retries and honor Retry-After yourself. Other statuses you might see: 401 for the credential, 402 for funds, 422 for a voice string that isn't an OpenAI name, slug or ID, and 503 when the service is unavailable. Every response carries X-Request-Id; quote it in support tickets.

Speech events. With stream_format="sse", the route returns OpenAI-style speech.audio.delta and speech.audio.done events. Two differences: a : keepalive comment line arrives after 15 quiet seconds (skip lines that start with :), and the usage block in speech.audio.done counts characters (input_characters, billed_characters) with token fields always at 0. If your billing dashboards read token counts from that event, update them.

Keep connections warm between replies

This is the change most migrations miss. The AssemblyAI API holds an idle connection open for an hour, but the OpenAI SDKs close it far sooner: after 5 seconds in Python, and after 4 seconds on Node (the default of Node's fetch). A voice agent's replies are often further apart than that, so without a change, nearly every reply pays a fresh TCP and TLS handshake before the first byte of audio.

The http_client limits in the Python example and the undici Agent in the JavaScript example both keep idle connections for 2 minutes. Two more habits help:

  1. Build the client once per process and reuse it across calls and conversations.
  2. Warm the connection when a call starts. client.models.list() reads the public models endpoint, synthesizes nothing and needs no credential, so the first real reply doesn't pay the handshake.

For a deeper look at where the wait goes before the first audio, read TTS latency explained, and check the Server-Timing header on a 200 to separate server time from network and connection setup.

LiveKit and Pipecat pipelines already on OpenAI plugins

If your agent runs on LiveKit or Pipecat with the OpenAI TTS plugin, the swap is the same two arguments.

LiveKit:

import os

from livekit.plugins import openai

tts = openai.TTS(
    base_url="https://tts.us.assemblyai.com/v1/openai",
    api_key=os.environ["ASSEMBLYAI_API_KEY"],
    model="assemblyai-tts-en",
    voice="alloy",
    response_format="pcm",
)

The LiveKit plugin already keeps idle connections for 2 minutes and opens one when the agent session starts.

Pipecat:

import os

import httpx2
from openai import DefaultAsyncHttpx2Client
from pipecat.services.openai.tts import OpenAITTSService

tts = OpenAITTSService(
    api_key=os.environ["ASSEMBLYAI_API_KEY"],
    base_url="https://tts.us.assemblyai.com/v1/openai",
    # Idle connections kept for 2 minutes instead of 5 seconds.
    http_client=DefaultAsyncHttpx2Client(
        limits=httpx2.Limits(
            max_connections=1000, max_keepalive_connections=100, keepalive_expiry=120
        )
    ),
    settings=OpenAITTSService.Settings(model="assemblyai-tts-en", voice="alloy"),
)

Pipecat takes http_client from version 1.7 and openai 3 from 1.10. On 1.7 to 1.9, or with openai 2, build it with httpx and DefaultAsyncHttpxClient. Pipecat's OpenAI service always asks for 24 kHz pcm and only accepts OpenAI voice names.

Both frameworks also have native AssemblyAI plugins, documented for LiveKit and Pipecat (AssemblyAITTSService in pipecat-ai 1.15.0+). They run on the WebSocket API with one session per call, barge-in without reconnecting, and word-timed transcripts. The OpenAI plugins give up those word timings, so treat them as the fast way off OpenAI, then plan the move to the native plugin.

Hear your agent's lines before you switch

Listen to AssemblyAI-TTS-1 voices on the replies your agent says most often, and pick one before you change a line of code.

Explore Text-to-Speech API

When to move to the native WebSocket session

The compatibility route is HTTP: one request per utterance. That's the right call for a quick migration, batch generation, or a pipeline you don't want to touch before January. For a live voice agent, the native WebSocket session does more:

  • One connection per conversation. Each sentence is a request on the same session, so there's no handshake per reply and no per-session rate-limit pressure from opening a connection for every sentence.
  • Streamed LLM text. generate() sends tokens as your LLM produces them, and flush() marks the end of the reply. The server starts speaking before the flush once it has enough complete sentences.
  • Barge-in. cancel() stops the request in flight when the caller starts talking, and the session stays open for the next turn.
  • Word timings. Per-word boundaries for captions, read-along highlighting and accurate interruption handling. These aren't available on the compatibility route.

The native Python SDK version of a single reply looks like this:

import os

import assemblyai as aai

config = aai.TTSConfig(voice_id="49c443a4-8a1d-5f7e-b70b-98bdbd9823c3", language="english")  # Audrey
reply = [
    "Hi, thanks for calling.",
    "You have reached the accounts team.",
    "Would you like to pay your open invoice today?",
]

with aai.TTSSession(config, api_key=os.environ["ASSEMBLYAI_API_KEY"]) as session:
    with open("reply.pcm", "wb") as out:
        for sentence in reply:
            for chunk in session.speak(sentence):  # raw 24 kHz 16-bit mono PCM
                out.write(chunk)

In JavaScript, the equivalent session is client.tts.session({ voiceId: "49c443a4-8a1d-5f7e-b70b-98bdbd9823c3" }) from the assemblyai package (4.42.0 or later). For the full walkthrough, including streaming LLM tokens and cancelling on barge-in, read how to stream text to speech in Python over a WebSocket.

If you were also weighing OpenAI's Realtime API for the whole conversation, compare the trade-offs first. OpenAI Realtime API vs. a chained pipeline with AssemblyAI covers when a speech-to-speech model makes sense, OpenAI Realtime API alternatives covers the options, and the Realtime migration guide covers moving an existing Realtime integration. A chained stack pairs Universal-3.6 Pro Realtime for streaming speech-to-text with an LLM through LLM Gateway and AssemblyAI TTS, all on one AssemblyAI API key.

A migration checklist

Before January 6, run through this list:

  1. Change base_url and the API key; keep everything else.
  2. Replace tts-1, tts-1-hd or gpt-4o-mini-tts with assemblyai-tts-en (or the code for your language).
  3. Keep OpenAI voice names for English, or pick native voices from the catalog. For other languages, always send a native voice.
  4. Replace opus, aac and flac with pcm, wav or mp3; consider mulaw or alaw for telephony.
  5. Remove speed values other than 1, and move instructions content into the text itself.
  6. Raise the client's keep-alive and warm the connection at call start.
  7. Set max_retries and handle Retry-After.
  8. Update any dashboards that read token counts from SSE usage.
  9. Schedule the move to the native SDK or plugin if you need word timings or barge-in.

The deadline makes this feel like a chore, but it's also the first time in a while you get to pick a speech layer on purpose. A model built for conversation, on the same platform as your speech-to-text, is a better place to land than a forced rewrite. Compare it against the field in our roundup of the top text-to-speech APIs if you want a wider view.

Move your speech layer before January 6

Two constructor arguments and a model name. Create a free AssemblyAI account and run your first request against the compatibility route today.

Sign up free

Frequently Asked Questions (FAQs)

When does OpenAI shut down tts-1 and gpt-4o-mini-tts?

OpenAI removes tts-1, tts-1-hd and the gpt-4o-mini-tts snapshots from its API on January 6, 2027. It announced the deprecation on October 1, 2026, and recommends gpt-realtime-2.1-mini as the replacement. That model doesn't run on the /v1/audio/speech endpoint, so staying with OpenAI means moving to the Realtime API workflow.

Is there an OpenAI-compatible text-to-speech API?

Yes. AssemblyAI's Text-to-Speech API answers OpenAI's POST /audio/speech request shape at https://tts.us.assemblyai.com/v1/openai (or the EU host). Point an OpenAI SDK at that base URL with an AssemblyAI API key, change the model to assemblyai-tts-en or another language model, and the rest of your call keeps working.

Can I keep using OpenAI voice names like alloy and nova?

Yes, in English. Each OpenAI voice name maps to an AssemblyAI voice, such as alloy to aai-jane and nova to aai-eve. In other languages, every OpenAI name speaks with aai-jane, so send the slug or UUID of a voice recorded in that language instead.

What doesn't carry over from OpenAI's speech API?

OpenAI model names, the opus, aac and flac formats, and speed values other than 1 are refused. The instructions and user fields are ignored and listed in the X-TTS-Unknown-Params header. Word timings aren't available on the compatibility route; use the speech events endpoint or the WebSocket API for those.

Does the OpenAI-compatible route work with LiveKit and Pipecat?

Yes. LiveKit's openai.TTS and Pipecat's OpenAITTSService both take a base_url and an API key, so the same two-argument swap works. For a new agent, the native AssemblyAI plugins for both frameworks add word timings and barge-in on one WebSocket session per call.

How much does AssemblyAI text-to-speech cost?

AssemblyAI TTS costs $15 per 1M characters, billed on input characters, with unlimited concurrency. The compatibility route bills the same way as the native API, and with stream_format="sse" the speech.audio.done event reports billed_characters for each request. See the pricing page for current details.