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,wavandpcm - Two delivery envelopes -- the audio file itself, or Server-Sent Events carrying the same bytes
- Adjustable speed --
0.25to4.0on models that support it - Predictable price -- billed on the characters you send, never on the audio you get back
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/transcriptionsorPOST /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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
model | string | Yes | -- | The model identifier of a text-to-speech model. |
input | string | Yes | -- | The text to speak. Up to 4096 Unicode code points per request. Must not be empty or whitespace only. |
voice | string | No | the model's default voice | A voice identifier the model publishes to your organization. See Voices. |
response_format | string | No | mp3 | Output container: mp3, opus, aac, flac, wav, pcm. |
speed | float | No | 1.0 | Playback rate between 0.25 and 4.0. |
stream | boolean | No | false | Ask for the audio to be produced incrementally. |
stream_format | string | No | audio | Response 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 firstEvery 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.
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
inputper request. The count is taken on the text you sent, after Unicode NFC normalization, with whitespace included. Longer input is refused up front with400 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_supportedand the message lists the formats that model supports. speedmay not be supported by every model. A model that declares it cannot change speed refusesspeedvalues other than1.0with400 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 theasyncalias) returns400. - Voice cloning is not offered. Parameters that would clone a speaker from a reference recording are refused with
400 voice_cloning_not_supported.
inputThe 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_format | Content-Type | Notes |
|---|---|---|
mp3 | audio/mpeg | The default. Smallest of the six on ordinary speech. |
opus | audio/ogg | Opus in an Ogg container. |
aac | audio/aac | Not audited for truncation -- see below. |
flac | audio/flac | Lossless. |
wav | audio/wav | Uncompressed, roughly an order of magnitude larger than mp3. |
pcm | audio/pcm | Raw 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 truncationBefore 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:
| Order | What it rewrites |
|---|---|
| 1 | Turkish IBANs, phone numbers, and any run of 10 or more digits -- read out digit by digit |
| 2 | Dates and times |
| 3 | Percentages and currency amounts |
| 4 | Ordinals, including Roman numerals |
| 5 | Units and degrees |
| 6 | Plain cardinal numbers |
| 7 | Abbreviations |
| 8 | Consonant-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.
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]
| Event | Meaning |
|---|---|
speech.audio.delta | One chunk of audio, base64-encoded in the audio field. |
speech.audio.done | Every byte has been delivered and the file is complete. |
error | The 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.
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
| Status | Error code | Cause | Resolution |
|---|---|---|---|
| 400 | invalid_json | The body is not valid JSON. | Send a well-formed JSON document. |
| 400 | invalid_request_body | The body is not a JSON object, or a field has the wrong type. | Check the field types against the reference above. |
| 400 | invalid_value | model or input is empty or whitespace only, or a boolean field is not a boolean. | Send real values. |
| 400 | unknown_parameter | The body contains a field this endpoint does not recognise. | Remove it. Only the seven documented fields are accepted. |
| 400 | unsupported_parameter | A 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. |
| 400 | voice_cloning_not_supported | A voice-cloning parameter was sent. | Voice cloning is not offered on this platform. |
| 400 | background_not_supported | background: true or async: true. | Send the request normally and read the response. |
| 400 | stream_format_not_supported | stream_format is a value other than audio or sse. | Use one of the two. |
| 400 | voice_not_supported | The 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. |
| 400 | response_format_not_supported | The model does not produce that container. | Use one of the formats the message lists. |
| 400 | speed_out_of_range | speed is outside 0.25-4.0. | Send a value in range. |
| 400 | speed_not_supported | The model does not honour speed. | Send speed as 1.0, or omit it. |
| 400 | input_too_long / audio_input_too_long | input exceeds 4096 Unicode code points. | Split the text into shorter requests. |
| 400 | Bad Request | The model is not a text-to-speech model, or audio endpoints are not available for it. | Use a text-to-speech model identifier. |
| 401 | Unauthorized | Missing or invalid API key. | Verify your API key is correct and active. |
| 402 | model_not_priced | The model has no audio pricing configured. | Contact your platform administrator. |
| 402 | insufficient_credits | A prepaid wallet balance is depleted. Prepaid accounts only -- unreachable on the postpaid default. | Add credits. See Billing. |
| 403 | Forbidden | The 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. |
| 404 | Not Found | The model does not exist. | Check available models in the Client Portal or via the models endpoint. |
| 429 | too_many_requests | Your key's request rate or concurrency limit was reached. | Back off and retry. See the Rate Limiting guide. |
| 429 | model_at_capacity | The model deployment is serving as many synthesis requests as it can at once. | Retry after the Retry-After interval. |
| 429 | audio_quota_exceeded | Not 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. |
| 502 | synthesis_truncated | The 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. |
| 502 | synthesis_incomplete | The connection to the synthesis engine failed before the audio was complete. The request is not charged. | Retry -- it usually succeeds. |
| 503 | Service Unavailable | The model is starting up, or the synthesis or billing service is temporarily unavailable. | Retry after the Retry-After interval. |
| 504 | audio_timeout | The 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 -- anaacrender 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.
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.