How to Generate Text-to-Speech Audio with the MiniMax (Hailuo) API
August 24, 2026
Table of contents
- Three ways to get audio
- Your first call
- Picking a voice
- Steering the delivery
- Choosing a model
- Word timings for captions
- Long text without waiting
- Streaming
- Cloning a voice
- Managing what you have generated
- What it costs
- Examples
- Where to go next
MiniMax Speech turns text into narration with ten models, several hundred voices, and enough control over emotion and pacing that the result does not sound like a screen reader. This walks through the whole surface — choosing a voice, steering delivery, captions, cloning, and the three different ways to get the audio out — using Speech 2.8 throughout.
Everything here runs against the MiniMax API v1 on useapi.net. You need a MiniMax account configured once, see Setup MiniMax.
Every recording on this page was generated by the calls shown next to it.
Cost first, since it is usually the deciding factor. All three prices below are MiniMax’s own — what changes is which MiniMax product you buy:
Cost of 1,000,000 characters on speech-2.8-hd | |
|---|---|
| minimax.io/audio — Pro plan, $99/mo (what this API uses) | $33 |
| platform.minimax.io — Audio Subscription, Pro $99/mo | $90 |
| platform.minimax.io — pay-as-you-go | $100 |
A third of the price of MiniMax’s own developer API, for the same audio. On turbo it is $19.80 a million against pay-as-you-go’s $60 — the same gap. What it costs has the rest.
Three ways to get audio
Pick by how long the text is and whether you want to wait.
| Endpoint | Text limit | You get | |
|---|---|---|---|
| Short and simple | POST speech/create-mp3 | 3,000 characters | The finished MP3 URL in one call |
| Anything longer | POST speech/create | 5,000, or 10,000 on turbo | An audioId straight away, plus a replyUrl callback |
| Start playing sooner | POST speech/create with "stream": true | 5,000, or 10,000 on turbo | The audio streamed back as it renders, so playback can start before it is finished |
create-mp3 holds the connection open for the whole generation, which is why it is capped below what the models accept — 3,000 characters is about 22 seconds of waiting. For anything longer, use create.
Your first call
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "380426458095854",
"text": "The last train leaves at midnight, and I still have not packed."
}'
That is speech-2.8-hd, the default, with no styling at all. Everything below is that same line with one thing changed.
Picking a voice
GET speech/voices lists what the account can use. It returns 600+ voices across 40 languages, and MiniMax keeps adding:
English, Arabic, Cantonese, Chinese (Mandarin), Dutch, French, German, Indonesian, Italian, Japanese, Korean, Portuguese, Russian, Spanish, Turkish, Ukrainian, Vietnamese, Thai, Polish, Romanian, Greek, Czech, Finnish, Hindi, Bulgarian, Danish, Hebrew, Malay, Persian, Slovak, Swedish, Croatian, Filipino, Hungarian, Norwegian, Slovenian, Catalan, Nynorsk, Tamil, Afrikaans
Each voice carries tags for language, accent, gender and age, and tag_list filters on them. Matching is exact and case-sensitive, and a tag it does not recognise is ignored rather than rejected — so Female,Middle-Aged returns the same 310 voices as Female alone rather than none. A typo widens the result instead of narrowing it, so check the count when you add a filter. GET speech/config returns the current vocabulary.
curl "https://api.useapi.net/v1/minimax/speech/voices?is_system=true&tag_list=English" \
-H "Authorization: Bearer …"
The voice does more to the result than any other parameter. Same text, same model, a British female voice instead of the US male above:
curl — POST speech/create-mp3 (Compelling Lady, EN-British)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "236835177529411",
"model": "speech-2.8-hd",
"text": "The last train leaves at midnight, and I still have not packed."
}'
Steering the delivery
Everything from here uses a cloned voice called Scarlett, built from a one-minute sample — see Cloning a voice. The controls work identically on the built-in voices. Each sample has the exact call that produced it folded up above it.
Three kinds of markup go directly inside text, and they combine. Only emotion tags are free, because they are stripped before billing. Sounds and pauses are billed in full, markup and all.
Emotion
Wrap a span in {emotion}…{/emotion}. Eight are available — happy, sad, angry, fearful, disgusted, surprised, neutral, fluent — and a single request may carry up to 15 spans.
The five below are the ones that separate most cleanly. surprised and happy tend to converge, as do neutral and sad, so pick across the range rather than adjacent to it. The line is the same six words every time — only the delivery decides whether it is an accusation, dread, revulsion, delight or grief.
{angry} — accusation:
curl — POST speech/create-mp3 (angry)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "{angry}Look at what you have done.{/angry}"
}'
{fearful} — dread:
curl — POST speech/create-mp3 (fearful)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "{fearful}Look at what you have done.{/fearful}"
}'
{disgusted} — revulsion:
curl — POST speech/create-mp3 (disgusted)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "{disgusted}Look at what you have done.{/disgusted}"
}'
{happy} — delight:
curl — POST speech/create-mp3 (happy)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "{happy}Look at what you have done.{/happy}"
}'
{sad} — grief:
curl — POST speech/create-mp3 (sad)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "{sad}Look at what you have done.{/sad}"
}'
How far a voice can swing is set by the voice, not the tag. A clone inherits the range of whatever you fed it, so a one-minute sample of even-toned speech will not shout. The built-in voices tagged as expressive have more headroom — the same five emotions on Radiant Girl (236835177529409), for comparison: angry, fearful, disgusted, happy, sad.
Sounds
Nineteen non-speech sounds render in place when written in parentheses: laughs, chuckle, coughs, clear-throat, groans, breath, pant, inhale, exhale, gasps, sniffs, sighs, snorts, burps, lip-smacking, humming, hissing, emm, sneezes.
curl — POST speech/create-mp3 (sighs and laughs)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "I told you this would happen (sighs). Every single time (laughs)."
}'
Pauses
<#seconds#> inserts a silence, anywhere from 0.01 to 99.99 seconds. This is the one that most improves long narration — models tend to run sentences together, and a beat between them fixes it.
curl — POST speech/create-mp3 (1.5 second pause)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "I told you this would happen. <#1.5#> Every single time."
}'
Speed and pitch
speed runs 0.5 to 2 and pitch runs -12 to 12, both independent of the markup above. Back to the longer line so the difference is easier to hear.
"speed": 0.7 — 7.6 seconds:
curl — POST speech/create-mp3 (speed 0.7)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "The last train leaves at midnight, and I still have not packed.",
"speed": 0.7
}'
"speed": 1.5 — 3.2 seconds:
curl — POST speech/create-mp3 (speed 1.5)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "The last train leaves at midnight, and I still have not packed.",
"speed": 1.5
}'
"pitch": -6:
curl — POST speech/create-mp3 (pitch -6)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "The last train leaves at midnight, and I still have not packed.",
"pitch": -6
}'
"pitch": 6:
curl — POST speech/create-mp3 (pitch +6)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "The last train leaves at midnight, and I still have not packed.",
"pitch": 6
}'
There are also voice effects — deepen_lighten, stronger_softer and nasal_crisp take -100 to 100, and spacious_echo, lofi_telephone, robotic and auditorium_echo are switches.
Choosing a model
| Model | Max characters | Credits per character | Status |
|---|---|---|---|
speech-2.8-hd | 5,000 | 1 | Current, and the default |
speech-2.8-turbo | 10,000 | 0.6 | Current |
speech-2.6-hd | 5,000 | 1 | Previous generation |
speech-2.6-turbo | 10,000 | 0.6 | Previous generation |
speech-2.5-hd-preview | 5,000 | 1 | Superseded by 2.6 |
speech-2.5-turbo-preview | 10,000 | 0.6 | Superseded by 2.6 |
speech-02-hd | 5,000 | 1 | Legacy |
speech-02-turbo | 10,000 | 0.6 | Legacy |
speech-01-hd | 5,000 | 1 | Legacy |
speech-01-turbo | 10,000 | 0.6 | Legacy |
Use speech-2.8-hd unless you have a reason not to. The older models stay selectable because they are still live at MiniMax, and because regenerating audio to match something made earlier needs the model that made it — not because they are worth picking for new work.
Within a generation, hd and turbo are the same model tuned differently. turbo costs 40% less per character and accepts twice the text in a single call. It is not measurably faster — on identical 990-character text the two came out level — so pick turbo for the cheaper rate and the higher ceiling, and hd when voice quality matters most.
Sending more than a model accepts returns 400 before anything is generated, so an oversized request never costs credits. The message names the model and its limit — Parameter text length (5001) exceeds 5000 characters for model speech-2.8-hd — except at the absolute 10,000 ceiling, which is checked first and reports the limit without the model.
hd and turbo are the same family tuned differently. Turbo on the same line:
curl — POST speech/create-mp3 (speech-2.8-turbo)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-turbo",
"text": "The last train leaves at midnight, and I still have not packed."
}'
For narration where nobody is waiting, speech-2.8-turbo is usually the better trade — it takes twice the text per call and costs 0.6 credits a character against hd’s 1, which MiniMax mirrors as $60 against $100 per million on their own API.
Word timings for captions
A finished generation carries a subtitles array with millisecond boundaries per phrase.
"subtitles": [
{ "text": "The last train leaves at midnight", "time_begin": 0, "time_end": 2245.2 }
]
Use time_begin and time_end. The text_begin and text_end fields come back from MiniMax but do not reliably index into the text, so treat them as opaque.
One limitation worth planning around: timings only reach you through POST speech/create-mp3 or the streaming done event. MiniMax does not store them, so polling GET speech/audioId or waiting on a replyUrl callback gives you has_srt but not the timings themselves. If you need captions for more than 3,000 characters, use stream and keep the done payload.
Long text without waiting
POST speech/create answers in about four seconds with an audioId, whatever the length, and keeps generating in the background.
curl "https://api.useapi.net/v1/minimax/speech/create" \
-H "Authorization: Bearer …" \
-H "Content-Type: application/json" \
-d '{
"voice_id": "380426458095854",
"model": "speech-2.8-turbo",
"text": "…up to 10,000 characters…",
"replyUrl": "https://your.app/webhook"
}'
{ "audioId": "user:12345-minimax:1234…-audio:178737039436895391", "statusLabel": "pending", "statusFinal": false }
Then either wait for the POST to replyUrl, or poll GET speech/audioId until statusFinal is true. A 10,000-character request takes around 78 seconds and yields 12 to 16 minutes of speech, so poll every few seconds rather than tightly.
Streaming
Set stream to true and the response is text/event-stream instead of JSON, with the id in an X-Audio-Id header. First audio lands about four seconds in rather than at the end.
The audio events carry two different things, and format tells them apart. format: "mp3" events are chunks — append them in arrival order and the result is a plain MP3. A short line sends one, a 10,000-character generation sends around 40 over a minute or two. format: "wav" with complete: true is the same speech again as a whole WAV file, delivered after the chunks. Do not append that one.
const chunks = [];
if (event === "audio" && payload.format === "mp3")
chunks.push(Uint8Array.from(atob(payload.chunk), c => c.charCodeAt(0)));
A streamed generation is tracked like any other, so if the connection drops the recording still completes and the audioId still resolves. Full worked example on the endpoint page.
Cloning a voice
Every sample from Steering the delivery onward uses a cloned voice. This is how it was made.
Two calls. POST speech/upload-sample takes the raw bytes of an MP3 or WAV and returns a fileId, then POST speech/clone-voice turns that into a voice you pass as voice_id anywhere above.
curl "https://api.useapi.net/v1/minimax/speech/upload-sample" \
-H "Authorization: Bearer …" \
-H "Content-Type: audio/mpeg" \
--data-binary "@sample.mp3"
{ "fileID": "user:12345-minimax:1234…-file:54768197611603968682433" }
Feed that straight into the clone:
curl "https://api.useapi.net/v1/minimax/speech/clone-voice" \
-H "Authorization: Bearer …" \
-H "Content-Type: application/json" \
-d '{
"voice_name": "Scarlett",
"language_tag": "English",
"files": "user:12345-minimax:1234…-file:54768197611603968682433"
}'
{ "voice_id": "user:12345-minimax:1234…-voice:433688037470324", "voice_name": "Scarlett", "voice_status": 2 }
This is the source sample that was uploaded — one minute of ordinary speech, nothing special:
And this is the clone reading a line it has never seen, 35 seconds after that upload:
curl — POST speech/create-mp3 (cloned voice)
curl 'https://api.useapi.net/v1/minimax/speech/create-mp3' \
--header 'Authorization: Bearer user:1234-…' \
--header 'Content-Type: application/json' \
--data '{
"voice_id": "user:12345-minimax:1234…-voice:433688037470324",
"model": "speech-2.8-hd",
"text": "The last train leaves at midnight, and I still have not packed."
}'
The returned voice_id goes anywhere voice_id is accepted, exactly like a built-in one.
POST speech/upload-sample takes ?account= in the query string, like the read endpoints — there is no identifier to infer it from, since the body is raw bytes. The fileID it returns then pins the voice to that account, which is why POST speech/clone-voice takes no account of its own and reads it from files. POST speech/delete-voice works the same way, taking only a voice_id.
Slots
Cloned voices occupy a slot, and how many you get depends on the MiniMax plan. Check before you spend 35 seconds discovering there is no room:
curl "https://api.useapi.net/v1/minimax/speech/equity" -H "Authorization: Bearer …"
{ "used": 2, "total": 3, "voice_clone_preview_free_quota": "inf" }
Free one with POST speech/delete-voice. Note used is allowed to exceed total — treat these as advisory and let the clone call itself be the authority.
Only clone voices you have permission to use.
Managing what you have generated
GET speech lists every recording on the account, newest first, including any made on minimax.io directly. Each row carries the audio_url, the voice name, and has_srt / has_wav telling you whether timings and a WAV rendition exist.
curl "https://api.useapi.net/v1/minimax/speech/?page_size=10" \
-H "Authorization: Bearer …"
GET speech/audioId returns one recording in full — the voice it used, the model, cost_credit, and the speed, pitch and effects it was generated with. It is also what you poll after an async create.
DELETE speech/audioId removes one permanently. A generation still in flight cannot be deleted, because there is nothing stored yet — cancel that with DELETE scheduler/id instead.
What it costs
Speech bills per character against the MiniMax Audio balance — the same pool music draws on. One credit per character on an hd model, 0.6 on a turbo one.
That works out to $33 a million characters on the $99 Pro plan, against $100 on MiniMax’s own pay-as-you-go API — the comparison at the top of this page has all three options side by side.
A few things worth knowing:
cost_creditappears only on GET speech/audioId, not on the list or on thecreate-mp3response — that one returnsusage_characters, the raw count before the model’s ratio.- Emotion tags are free. Sound and pause markup is billed as written: 79 characters including
(sighs)and(laughs)billed as 79. turbois charged at 0.6 credits a character, and the discount is real credits rather than a list price. GET speech/audioIdreports what was charged ascost_credit.- Speech needs a MiniMax audio subscription. Without one the call returns
412however much video credit the account holds, though a new account gets a small allowance first. - Only a few generations run concurrently per account. Past that a request returns
429and the ones already running are unaffected, so retry when one finishes. The ceiling looks plan-dependent — a free account allows three.
Examples
Every recording on this page is a real generation, and the call that produced it is folded up directly above it. The first two use built-in voices. Everything from Steering the delivery onward uses the cloned Scarlett voice on speech-2.8-hd unless noted, apart from the long-text example, which is back on a built-in voice.
Clone source and result: the one-minute sample that was uploaded, and the clone reading a line it had never seen.
Emotion, the same six words each time — angry, fearful, disgusted, happy, sad. The same five on the built-in Radiant Girl, which has more range: angry, fearful, disgusted, happy, sad.
Everything else: sounds, pauses, slow, fast, low pitch, high pitch, turbo.
Where to go next
- POST speech/create — every parameter, response and error
- GET speech/voices — the voice catalogue
- GET speech/config — emotion and tag vocabularies, for building your own picker
minimax-apiGitHub repo — a runnable batch script that reads a list of lines, generates each one, and downloads the MP3s, with a--voicesmode for finding avoice_id