Skip to main content

Text to Speech

Turn text into spoken audio using speech synthesis models through the platform API.

Overview

The platform provides an OpenAI-compatible POST /v1/audio/speech endpoint. If you already use the OpenAI SDK for synthesis, switching over requires only changing the base_url -- no other code changes needed.

Key features:

  • OpenAI SDK compatible -- use the official Python or Node.js SDK, or plain HTTP
  • Six output containers -- mp3, opus, aac, flac, wav and pcm
  • Two delivery envelopes -- the audio file itself, or Server-Sent Events carrying the same bytes
  • Adjustable speed -- 0.25 to 4.0 on models that support it
  • Predictable price -- billed on the characters you send, never on the audio you get back
Model availability

Speech synthesis models appear in your model list only where they are deployed. If you do not see one, contact your platform administrator.

Which speech endpoint?

  • Text in, audio out -- you are on the right page. Use POST /v1/audio/speech.
  • Audio in, text out -- use POST /v1/audio/transcriptions or POST /v1/audio/translations. See the Speech to Text guide.

The two families use different model types. Sending a speech-to-text model identifier to /v1/audio/speech returns 400 with a message telling you to use a text-to-speech model, and the reverse is equally true.

Quick Start

Python

from openai import OpenAI

client = OpenAI(
base_url="https://api.bulutistan.ai/v1",
api_key="sk-proj-your-api-key"
)

with client.audio.speech.with_streaming_response.create(
model="your-tts-model",
voice="your-voice",
input="Thanks everyone for joining the call today.",
response_format="mp3"
) as response:
response.stream_to_file("speech.mp3")

Node.js

import OpenAI from 'openai';
import fs from 'fs/promises';

const client = new OpenAI({
apiKey: 'sk-proj-your-api-key',
baseURL: 'https://api.bulutistan.ai/v1'
});

const speech = await client.audio.speech.create({
model: 'your-tts-model',
voice: 'your-voice',
input: 'Thanks everyone for joining the call today.',
response_format: 'mp3'
});

await fs.writeFile('speech.mp3', Buffer.from(await speech.arrayBuffer()));

cURL

curl -X POST https://api.bulutistan.ai/v1/audio/speech \
-H "Authorization: Bearer sk-proj-your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "your-tts-model",
"input": "Thanks everyone for joining the call today.",
"response_format": "mp3"
}' \
-o speech.mp3

The voice field is optional over plain HTTP -- omit it and the model's default voice is used. The OpenAI SDKs type it as required, which is why the SDK examples pass it. See Voices for how to find out which ones you can name.

API Reference

Endpoint

POST /v1/audio/speech
POST /api/v1/audio/speech

Both prefixes serve the same endpoint. Use /v1/... when setting the SDK base_url to https://api.bulutistan.ai/v1. Use the full /api/v1/... path for direct HTTP requests.

The request body is application/json. Unlike the transcription endpoints, this one does not accept multipart/form-data.

API keys need the inference:audio scope -- the same single scope that covers transcription and translation. Keys with full access or a general inference scope already cover it; keys created with a hand-picked scope list need inference:audio selected. See Authentication.

Request Parameters

ParameterTypeRequiredDefaultDescription
modelstringYes--The model identifier of a text-to-speech model.
inputstringYes--The text to speak. Up to 4096 Unicode code points per request. Must not be empty or whitespace only.
voicestringNothe model's default voiceA voice identifier the model publishes to your organization. See Voices.
response_formatstringNomp3Output container: mp3, opus, aac, flac, wav, pcm.
speedfloatNo1.0Playback rate between 0.25 and 4.0.
streambooleanNofalseAsk for the audio to be produced incrementally.
stream_formatstringNoaudioResponse envelope: audio (the container itself) or sse (Server-Sent Events).

Those seven fields are the whole contract. Any other field is refused with its own 400 rather than silently ignored, so a parameter you believed was applied never was -- see Error Handling.

Voices

Each model declares its own voice identifiers, and they are the engine's own names rather than a platform-wide list. Two things follow from that, and both matter before you hardcode a voice name.

The list is per organization. A model's voice catalogue can include voices that belong to one customer, so the list a model publishes is narrowed to the voices your organization may use. Two organizations calling the same model can legitimately see different lists, and the list you see is exactly the list synthesis accepts from you.

The authoritative list is the model object read with your own credentials. Ask the models endpoint, not an error message:

curl https://api.bulutistan.ai/v1/models/your-tts-model \
-H "Authorization: Bearer sk-proj-your-api-key"
{
"id": "your-tts-model",
"object": "model",
"audio": {
"voices": ["default", "your-voice", "another-voice"],
"output_formats": ["mp3", "wav", "pcm"],
"supports_speed": true,
"supports_streaming": true
}
}

GET /v1/models carries the same audio block for every model in one response, if you would rather fetch the catalogue in one call.

default is always first

Every model that publishes a voice list publishes default as the first entry, and that is the voice an omitted voice field resolves to. It is a real identifier: sending "voice": "default" explicitly is the same request as omitting the field.

A voice you are not entitled to reads as a voice that does not exist

Naming a voice that exists on the model but was never granted to your organization returns the identical refusal as inventing a name that was never on the model at all -- same status, same voice_not_supported code, same sentence. That is deliberate: a distinguishable answer would let anyone probe for other customers' voice names.

So the voices enumerated in that 400 message are your voices, not the model's whole catalogue. Read the list from GET /v1/models/{id} and treat a refusal as "not available to you", not as "typo".

A model that publishes no voice list at all takes whatever string you send and applies its own default; there is nothing to choose and nothing to discover.

Limits

  • 4096 Unicode code points of input per request. The count is taken on the text you sent, after Unicode NFC normalization, with whitespace included. Longer input is refused up front with 400 input_too_long, and the message reports the measured length. Split longer documents into several requests. This ceiling is a fixed platform constant: it is not a per-tenant quota, it does not vary by plan, and an operator cannot raise it for you.
  • Output formats may be narrowed per model. All six containers are part of the public contract, but a model can declare a subset. Asking for one it does not offer returns 400 response_format_not_supported and the message lists the formats that model supports.
  • speed may not be supported by every model. A model that declares it cannot change speed refuses speed values other than 1.0 with 400 speed_not_supported, instead of accepting the parameter and ignoring it.
  • Background jobs are not available for audio. Synthesis is synchronous. Sending background: true (or the async alias) returns 400.
  • Voice cloning is not offered. Parameters that would clone a speaker from a reference recording are refused with 400 voice_cloning_not_supported.
A second, much wider wall on input

The published OpenAPI schema declares maxLength: 65536 on input, sixteen times the 4096-code-point cap. It is not a second limit to design around — it is a guard that lets the gateway refuse an absurd body without doing any work on it first.

Practically: an input above 65,536 characters is refused with the same 400 input_too_long, naming the same 4096 limit, that you would get at 5,000 characters. Nothing about the answer changed; it now arrives immediately instead of after the platform has normalized a body it was always going to reject. 4096 remains the only number to write your splitting logic against.

Response

With the default stream_format: "audio" the response body is the audio file, and the Content-Type is derived from the response_format you asked for:

response_formatContent-TypeNotes
mp3audio/mpegThe default. Smallest of the six on ordinary speech.
opusaudio/oggOpus in an Ogg container.
aacaudio/aacNot audited for truncation -- see below.
flacaudio/flacLossless.
wavaudio/wavUncompressed, roughly an order of magnitude larger than mp3.
pcmaudio/pcmRaw samples, no container.

The body is streamed rather than buffered, so no Content-Length is sent. Write it straight to a file or hand it to a player.

aac renders are never checked for truncation

Before returning audio, the platform measures the rendered duration and refuses a render that stopped short of the input, with 502 synthesis_truncated and no charge. That check needs a duration meter, and aac is the one format the platform has none for.

So an aac render is never audited: a truncated aac file comes back as a normal 200, and it is charged. If a silently short file would be a problem for you, ask for one of the other five formats and let the check do its work.

Input Text Normalization

The text the model speaks is not always the text you sent. Before synthesis, the gateway rewrites the input into the form it should be read aloud in -- this is a platform step, not something the model does, and it happens the same way on every model that has it enabled.

Normalization is Turkish-only and on by default. The language comes from the model's own configuration; a model declared as any other language passes its input through untouched.

Rewrites are applied in this order, and an earlier rule wins the text it claims:

OrderWhat it rewrites
1Turkish IBANs, phone numbers, and any run of 10 or more digits -- read out digit by digit
2Dates and times
3Percentages and currency amounts
4Ordinals, including Roman numerals
5Units and degrees
6Plain cardinal numbers
7Abbreviations
8Consonant-only acronyms -- read out letter by letter

Numbers follow Turkish conventions, which matters if your source data was formatted for an English-speaking locale: a dot is a thousands separator and is silent, and a comma is the decimal point and is spoken as "virgül". 1.500,75 is read as the value 1500.75, and 1,500 is read as the value 1.5 -- not as fifteen hundred.

There is no per-request switch

Normalization is not a parameter you can send. There is no field for it, and language is not accepted either -- sending one returns 400 unsupported_parameter.

It can be turned off only on the model itself, through an audio.text_normalization flag on the model record, or per deployment by an operator. If a model is speaking your input in a form you do not want, that is the conversation to have with your platform administrator.

You are always charged for the exact string you sent. The expansion happens after the billable length has been taken, so a rewrite that doubles the spoken text cannot double the price. This is enforced by construction rather than by policy -- see Pricing.

Streaming

Two independent switches control delivery, and they answer different questions.

stream asks the model to produce the audio incrementally. It is a request, not a demand: on a model that cannot synthesize incrementally, stream: true still returns a normal 200 with the audio in one piece. It is never an error.

stream_format picks the envelope the answer is written in:

  • audio (the default) -- the container itself, as described above.
  • sse -- Server-Sent Events carrying the same bytes.

stream_format: "sse" is the only shape on this endpoint with an in-band error channel. Once a 200 status line is on the wire, a raw audio body that stops early is a truncated file the caller cannot tell from a complete one; on the SSE envelope, a synthesis that fails after the first byte sends an error event you can read in the stream you are already reading.

Event vocabulary

data: {"type":"speech.audio.delta","audio":"<base64>"}
data: {"type":"speech.audio.delta","audio":"<base64>"}
data: {"type":"speech.audio.done"}

data: [DONE]

and on a failure, wherever it occurs:

data: {"type":"speech.audio.delta","audio":"<base64>"}
data: {"type":"error","error":{"message":"...","type":"api_error","code":"synthesis_truncated"}}

data: [DONE]
EventMeaning
speech.audio.deltaOne chunk of audio, base64-encoded in the audio field.
speech.audio.doneEvery byte has been delivered and the file is complete.
errorThe synthesis failed after the stream had begun. Carries the same code the non-streaming call would have returned.

Both endings close with data: [DONE], so your read loop always terminates normally.

Decode each event, then concatenate the bytes

Every delta's audio value is base64 of that chunk alone, encoded independently, so each one carries its own padding. Decode each event and concatenate the resulting bytes. Concatenating the base64 strings and decoding once is wrong twice over: a strict decoder rejects it, and a lenient one returns corrupt audio without complaining.

speech.audio.done is the signal that the file is whole. A client that concatenates deltas and ignores the terminal event will happily write a short file -- the exact failure the SSE envelope exists to prevent. There is no usage block on speech.audio.done; this endpoint is billed on your input text, which you already know.

cURL

curl -N -X POST https://api.bulutistan.ai/v1/audio/speech \
-H "Authorization: Bearer sk-proj-your-api-key" \
-H "Content-Type: application/json" \
-H "Accept-Encoding: identity" \
-d '{
"model": "your-tts-model",
"input": "Thanks everyone for joining the call today.",
"response_format": "mp3",
"stream": true,
"stream_format": "sse"
}'

Python

stream_format is outside the OpenAI SDK's typed surface, so the SSE envelope is read over plain HTTP:

import base64
import json

import httpx

payload = {
"model": "your-tts-model",
"voice": "your-voice",
"input": "Thanks everyone for joining the call today.",
"response_format": "mp3",
"stream": True,
"stream_format": "sse"
}

complete = False

with open("speech.mp3", "wb") as out:
with httpx.stream(
"POST",
"https://api.bulutistan.ai/v1/audio/speech",
headers={
"Authorization": "Bearer sk-proj-your-api-key",
"Content-Type": "application/json"
},
json=payload,
timeout=None
) as response:
response.raise_for_status()

for line in response.iter_lines():
if not line.startswith("data: "):
continue
data = line[len("data: "):]
if data == "[DONE]":
break

event = json.loads(data)
if event["type"] == "speech.audio.delta":
# Decode per event, write the BYTES.
out.write(base64.b64decode(event["audio"]))
elif event["type"] == "speech.audio.done":
complete = True
elif event["type"] == "error":
raise RuntimeError(event["error"]["message"])

if not complete:
raise RuntimeError("The stream ended without speech.audio.done")

JavaScript

const response = await fetch('https://api.bulutistan.ai/v1/audio/speech', {
method: 'POST',
headers: {
'Authorization': 'Bearer sk-proj-your-api-key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'your-tts-model',
voice: 'your-voice',
input: 'Thanks everyone for joining the call today.',
response_format: 'mp3',
stream: true,
stream_format: 'sse'
})
});

const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
const chunks = [];
let buffer = '';
let complete = false;

while (true) {
const { value, done } = await reader.read();
if (done) break;

buffer += value;
const lines = buffer.split('\n');
buffer = lines.pop();

for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;

const event = JSON.parse(data);
if (event.type === 'speech.audio.delta') {
// Decode per event, keep the BYTES.
chunks.push(Uint8Array.from(atob(event.audio), c => c.charCodeAt(0)));
} else if (event.type === 'speech.audio.done') {
complete = true;
} else if (event.type === 'error') {
throw new Error(event.error.message);
}
}
}

if (!complete) {
throw new Error('The stream ended without speech.audio.done');
}

const audio = new Blob(chunks, { type: 'audio/mpeg' });

See the Streaming guide for general SSE handling.

Synthetic Audio Disclosure

Audio returned by this endpoint is artificially generated. It is produced by a model from the text you send; it is not a recording of a person.

What the platform does and does not do:

  • The audio is not watermarked and carries no audible or embedded marker identifying it as synthetic. What you receive is a plain audio file in the container you asked for.
  • Voice cloning is refused. Parameters that would clone a speaker from a reference recording are rejected with 400 voice_cloning_not_supported, so this endpoint cannot be used to reproduce a specific person's voice from a sample.

What this means for you:

If you make this audio available to people -- in a product, a call, a video, an announcement or anything else an end user hears -- you are responsible for disclosing to them that the audio is artificially generated. Under the EU AI Act (Article 50), that transparency obligation sits with the party deploying the system towards those people, which is you and not the platform. Build the disclosure into your own product: a spoken preamble, an on-screen label, a caption, or whatever fits the channel.

This section describes product behaviour and where the obligation lands. It is not legal advice. If you are unsure how the obligation applies to your specific use, take your own legal advice.

Error Handling

StatusError codeCauseResolution
400invalid_jsonThe body is not valid JSON.Send a well-formed JSON document.
400invalid_request_bodyThe body is not a JSON object, or a field has the wrong type.Check the field types against the reference above.
400invalid_valuemodel or input is empty or whitespace only, or a boolean field is not a boolean.Send real values.
400unknown_parameterThe body contains a field this endpoint does not recognise.Remove it. Only the seven documented fields are accepted.
400unsupported_parameterA real engine knob that is outside the public contract (for example language, instructions, seed).Remove it. Synthesis language comes from the model's own configuration.
400voice_cloning_not_supportedA voice-cloning parameter was sent.Voice cloning is not offered on this platform.
400background_not_supportedbackground: true or async: true.Send the request normally and read the response.
400stream_format_not_supportedstream_format is a value other than audio or sse.Use one of the two.
400voice_not_supportedThe model does not publish that voice to you -- either it does not exist, or it is not available to your organization. The two are answered identically.Use one of the voices the message lists, or omit voice. See Voices.
400response_format_not_supportedThe model does not produce that container.Use one of the formats the message lists.
400speed_out_of_rangespeed is outside 0.25-4.0.Send a value in range.
400speed_not_supportedThe model does not honour speed.Send speed as 1.0, or omit it.
400input_too_long / audio_input_too_longinput exceeds 4096 Unicode code points.Split the text into shorter requests.
400Bad RequestThe model is not a text-to-speech model, or audio endpoints are not available for it.Use a text-to-speech model identifier.
401UnauthorizedMissing or invalid API key.Verify your API key is correct and active.
402model_not_pricedThe model has no audio pricing configured.Contact your platform administrator.
402insufficient_creditsA prepaid wallet balance is depleted. Prepaid accounts only -- unreachable on the postpaid default.Add credits. See Billing.
403ForbiddenThe API key is not permitted to call this model, or the model belongs to another organization.Use a key whose scope covers audio inference and the requested model.
404Not FoundThe model does not exist.Check available models in the Client Portal or via the models endpoint.
429too_many_requestsYour key's request rate or concurrency limit was reached.Back off and retry. See the Rate Limiting guide.
429model_at_capacityThe model deployment is serving as many synthesis requests as it can at once.Retry after the Retry-After interval.
429audio_quota_exceededNot currently emitted. The client-side handling exists for forward compatibility, but nothing on the platform raises this code today.No action -- you will not see it.
502synthesis_truncatedThe model stopped speaking before it had said the whole input. The request is not charged. Not raised for aac, which carries no duration meter.Reword the text or split it. Highly repetitive text triggers this most often.
502synthesis_incompleteThe connection to the synthesis engine failed before the audio was complete. The request is not charged.Retry -- it usually succeeds.
503Service UnavailableThe model is starting up, or the synthesis or billing service is temporarily unavailable.Retry after the Retry-After interval.
504audio_timeoutThe request exceeded the synthesis deadline.Send shorter input.

Errors use the standard platform error envelope, with param naming the offending field where there is one:

{
"error": {
"message": "The input is 5120 characters long, which exceeds the per-request limit of 4096. Split the text into shorter requests.",
"type": "invalid_request_error",
"code": "input_too_long",
"param": "input"
}
}

Python Error Handling

from openai import (
OpenAI,
APIError,
RateLimitError,
AuthenticationError,
BadRequestError,
NotFoundError
)
import time

client = OpenAI(
api_key="sk-proj-your-api-key",
base_url="https://api.bulutistan.ai/v1"
)

def synthesize_with_retry(text, path, max_retries=3):
for attempt in range(max_retries):
try:
with client.audio.speech.with_streaming_response.create(
model="your-tts-model",
voice="your-voice",
input=text,
response_format="mp3"
) as response:
response.stream_to_file(path)
return path

except AuthenticationError:
print("Invalid API key. Check your credentials.")
raise

except NotFoundError:
print("Model not found. Verify the model identifier.")
raise

except BadRequestError as e:
# Includes input_too_long and every parameter refusal -- retrying
# the same body will not help.
print(f"Invalid request: {e.message}")
raise

except RateLimitError:
if attempt == max_retries - 1:
raise
wait = 2 ** attempt
print(f"Rate limited. Retrying in {wait}s...")
time.sleep(wait)

except APIError as e:
if attempt == max_retries - 1:
raise
if e.status_code >= 500:
wait = 2 ** attempt
print(f"Server error. Retrying in {wait}s...")
time.sleep(wait)
else:
raise

synthesize_with_retry("Thanks everyone for joining the call today.", "speech.mp3")

Rate Limits & Pricing

Synthesis requests count against the standard per-key request rate limits. See the Rate Limiting guide for the limits and response headers.

How synthesis is billed:

  • Priced per 1,000,000 input characters. A character is one Unicode code point of the text you sent, counted after Unicode NFC normalization, with whitespace included and no rounding.
  • Measured on your text, not on the audio. The length of the recording, the container you chose and the speed you asked for have no effect on the price. Normalization does not affect it either: the gateway takes the billable length from your string before it expands anything, so the longer text the model actually reads is never what you pay for.
  • Only fully delivered responses are billed. A synthesis that fails, is cut short, or is discarded as truncated is recorded for your visibility but carries no charge. The one exception is aac, which has no duration meter and therefore no truncation check -- an aac render is always charged.
  • Pay as you go. Audio usage is not drawn from plan included-token allowances -- it is billed at your organization's audio rate on top of the plan. See Plans and Usage.
tip

Because the price follows the input, you can compute the cost of a request before you send it: count the code points of your text, divide by 1,000,000 and multiply by the model's per-million-character rate. There is no output-side surprise.