How to Generate Text-to-Speech Audio with the MiniMax (Hailuo) API

August 24, 2026

Table of contents

  1. Three ways to get audio
  2. Your first call
  3. Picking a voice
  4. Steering the delivery
    1. Emotion
    2. Sounds
    3. Pauses
    4. Speed and pitch
  5. Choosing a model
  6. Word timings for captions
  7. Long text without waiting
  8. Streaming
  9. Cloning a voice
    1. Slots
  10. Managing what you have generated
  11. What it costs
  12. Examples
  13. 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:

curlPOST 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:

curlPOST 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:

curlPOST 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:

curlPOST 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:

curlPOST 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:

curlPOST 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.

curlPOST 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.

curlPOST 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:

curlPOST 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:

curlPOST 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:

curlPOST 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:

curlPOST 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:

curlPOST 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:

curlPOST 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_credit appears only on GET speech/audioId, not on the list or on the create-mp3 response — that one returns usage_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.
  • turbo is charged at 0.6 credits a character, and the discount is real credits rather than a list price. GET speech/audioId reports what was charged as cost_credit.
  • Speech needs a MiniMax audio subscription. Without one the call returns 412 however 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 429 and 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