Text-to-speech API

MiniMax Speech 2.8 HD and Turbo text-to-speech through the OpenAI-compatible and native endpoints: parameters, system voices, streaming, and per-character billing.

HopBase serves MiniMax Speech 2.8 text-to-speech at the same Base URL and with the same Authorization: Bearer sk-your-key header as every other model — there is no separate audio host or credential. The two speech models belong to their own group, MiniMax Speech Official; a key bound to a chat or video group does not reach them.

MiniMax Speech 2.8 turns text into audio synchronously — the response is the audio itself, with no task to poll. Two entry points share the Base URL https://api.hop-base.com and Bearer authentication: the OpenAI-compatible POST /v1/audio/speech for drop-in use with OpenAI SDKs, and the native POST /v1/t2a_v2 for the full MiniMax parameter set and streaming.

Model IDPositioningText limit
speech-2.8-hdHighest quality4,096 characters on /v1/audio/speech, 10,000 on /v1/t2a_v2
speech-2.8-turboFaster, lower ratesame

IDs are case-sensitive and have no aliases; tts-1 / tts-1-hd are not recognised. Use a key with the MiniMax Speech Official group enabled and confirm the IDs with GET /v1/models.

OpenAI-compatible endpoint

POST /v1/audio/speech accepts the OpenAI request shape and responds with raw audio bytes.

FieldValuesNotes
modelspeech-2.8-hd / speech-2.8-turborequired
inputtext, up to 4,096 charactersrequired
voicea MiniMax voice ID such as English_expressive_narrator or Chinese (Mandarin)_News_Anchor, or an OpenAI name (alloy, nova, …)OpenAI names fall back to a default voice picked by the text's language: Mandarin when it contains Han characters, English otherwise
response_formatmp3 (default) / opus / flac / wav / pcmaac is not supported (400); opus is delivered in an OGG container
speed0.25-4outside that range returns 400; inside it the value is clamped to 0.5-2
instructionsaccepted and ignored

stream_format is not supported and returns 400. The response Content-Type is audio/mpeg, audio/ogg, audio/flac, audio/wav, or audio/pcm to match response_format.

curl https://api.hop-base.com/v1/audio/speech \
  -H "Authorization: Bearer sk-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "speech-2.8-turbo",
    "input": "Hello, world.",
    "voice": "English_expressive_narrator",
    "response_format": "mp3"
  }' \
  --output hello.mp3

With the official openai SDKs, only the Base URL and the model / voice values change:

from openai import OpenAI

client = OpenAI(base_url="https://api.hop-base.com/v1", api_key="sk-your-key")

response = client.audio.speech.create(
    model="speech-2.8-turbo",
    voice="English_expressive_narrator",
    input="Hello, world.",
    response_format="mp3",
)
response.write_to_file("hello.mp3")

# Long text: write the bytes to disk as they arrive instead of buffering the whole clip
with client.audio.speech.with_streaming_response.create(
    model="speech-2.8-turbo",
    voice="English_expressive_narrator",
    input="A longer script goes here.",
) as streamed:
    streamed.stream_to_file("long.mp3")

Native endpoint

POST /v1/t2a_v2 takes the MiniMax T2A v2 request body as is: model, text (up to 10,000 characters), voice_setting (voice_id, speed, vol, pitch, emotion), audio_setting (format, sample_rate, bitrate, channel), language_boost, stream, and stream_options pass through unchanged — every field and its accepted values are listed under Request parameters. The JSON response follows the official structure too: data.audio is hex-encoded audio and extra_info carries usage_characters plus the audio metadata.

  • output_format supports only hex (the default); url returns 400.
  • voice_setting.speed is clamped to 0.5-2.
  • With audio_setting.format: "opus", set sample_rate explicitly (for example 24000); without it the request fails with a parameter error.
  • stream: true switches to SSE: each data: {...} event carries an audio chunk, and the final event has data.status 2 plus extra_info. By default that final chunk's data.audio is the complete aggregated audio; set stream_options.exclude_aggregated_audio: true to leave it out.
import requests

resp = requests.post(
    "https://api.hop-base.com/v1/t2a_v2",
    headers={"Authorization": "Bearer sk-your-key"},
    json={
        "model": "speech-2.8-hd",
        "text": "你好,世界",
        "voice_setting": {"voice_id": "Chinese (Mandarin)_News_Anchor", "speed": 1.0},
        "audio_setting": {"format": "mp3", "sample_rate": 32000},
    },
)
body = resp.json()
open("hello.mp3", "wb").write(bytes.fromhex(body["data"]["audio"]))
print(body["extra_info"]["usage_characters"])  # billed characters

Streaming: read the SSE body line by line, decode each data: event's hex chunk and append it to the file. Set exclude_aggregated_audio: true so the final event (status 2) carries only extra_info — otherwise its data.audio repeats the whole clip and a naive append doubles the audio.

import json
import requests

with requests.post(
    "https://api.hop-base.com/v1/t2a_v2",
    headers={"Authorization": "Bearer sk-your-key"},
    json={
        "model": "speech-2.8-turbo",
        "text": "A long script goes here.",
        "stream": True,
        "stream_options": {"exclude_aggregated_audio": True},
        "voice_setting": {"voice_id": "English_expressive_narrator"},
        "audio_setting": {"format": "mp3", "sample_rate": 32000},
    },
    stream=True,
    timeout=(10, 300),
) as resp, open("long.mp3", "wb") as out:
    resp.raise_for_status()
    for line in resp.iter_lines():
        if not line.startswith(b"data:"):
            continue
        event = json.loads(line[5:])
        data = event.get("data") or {}
        if data.get("audio"):
            out.write(bytes.fromhex(data["audio"]))
        if data.get("status") == 2:
            print(event["extra_info"]["usage_characters"])  # billed characters

Request parameters

The native endpoint accepts the official T2A v2 body. Values and defaults below are the official ones; the last column marks where HopBase behaves differently or simply passes a field through.

Top-level fields

FieldValuesDefaultNotes
modelspeech-2.8-hd / speech-2.8-turborequired
textup to 10,000 charactersrequired; separate paragraphs with newlines; inline controls below. Officially, streaming is recommended above 3,000 characters
streamtrue / falsefalsetrue switches the response to SSE
stream_options.exclude_aggregated_audiotrue / falsefalsetrue drops the complete audio from the final chunk
language_boostauto, or one of Chinese, Chinese,Yue, English, Arabic, Russian, Spanish, French, Portuguese, German, Turkish, Dutch, Ukrainian, Vietnamese, Indonesian, Japanese, Italian, Korean, Thai, Polish, Romanian, Greek, Czech, Finnish, Hindi, Bulgarian, Danish, Hebrew, Malay, Persian, Slovak, Swedish, Croatian, Filipino, Hungarian, Norwegian, Slovenian, Catalan, Nynorsk, Tamil, AfrikaansunsetStrengthens recognition of that language or dialect; auto lets the model detect it. Cantonese voices need Chinese,Yue
pronunciation_dict.tonearray of original/replacement stringsThe replacement is plain text (omg/oh my god), pinyin with tone numbers 1-5 in parentheses (处理/(chu3)(li3)), IPA in parentheses (resume/(rɪˈzjuːm)), or Japanese kana (東京/トウキョウ); all rules apply at once
voice_modifypitch, intensity, timbre: integers -100 to 100; sound_effects: spacious_echo / auditorium_echo / lofi_telephone / roboticOfficial semantics: pitch deeper → brighter, intensity stronger → softer, timbre fuller → crisper; one effect at a time; mp3 / wav / flac when not streaming, mp3 only when streaming. Passed through per the official semantics, not validated on the gateway side
output_formathexhexurl returns 400

voice_setting

FieldValuesDefaultNotes
voice_ida system voice ID, see System voicesrequired
speed0.5-21.0values outside the range are clamped to it
volabove 0, up to 101.0volume
pitchinteger -12 to 1200 keeps the original pitch
emotionhappy / sad / angry / fearful / disgusted / surprised / calm / fluent / whisperchosen automatically from the textSet it only when you need a fixed emotion. fluent and whisper are documented for the 2.6 series; whisper is explicitly unsupported on Speech 2.8
text_normalizationtrue / falsefalseChinese / English number reading at slightly higher latency
latex_readtrue / falsefalseChinese only; wrap formulas in $$; forces language_boost to Chinese

audio_setting

FieldValuesDefaultNotes
formatmp3 / pcm / flac / wav / opusmp3opus is Ogg/Opus and requires an explicit sample_rate
sample_rate8000 / 16000 / 22050 / 24000 / 32000 / 44100not stated officially; the official examples use 32000
bitrate32000 / 64000 / 128000 / 256000not stated officially; the official examples use 128000mp3 only
channel1 / 21mono / stereo
force_cbrtrue / falsefalseconstant bitrate; streamed mp3 only

Inline text controls

  • Pause: <#x#> with x in seconds from 0.01 to 99.99, up to two decimals (Hello<#0.5#>world). Place it between speakable segments; two markers in a row are rejected. Each marker bills as 1 character.
  • Inline pronunciation: right after the target word, put pinyin with tone numbers 1-5, IPA, or Cantonese Jyutping with tone numbers 1-6 in half-width parentheses: This is (he2)平, not (huo4)面., pronounced (lɪv) as a verb, 去街市買啲(sung3)。
  • Interjections (Speech 2.8 only): (laughs), (chuckle), (coughs), (clear-throat), (groans), (breath), (pant), (inhale), (exhale), (gasps), (sniffs), (sighs), (snorts), (burps), (lip-smacking), (humming), (hissing), (emm), (sneezes).

System voices

Use the voice_id column verbatim — case, spaces, and parentheses included. Name is the official label.

voice_idLanguageName
English_expressive_narratorEnglishExpressive Narrator
English_radiant_girlEnglishRadiant Girl
English_magnetic_voiced_manEnglishMagnetic-voiced Male
English_compelling_lady1EnglishCompelling Lady
English_Aussie_BlokeEnglishAussie Bloke
English_captivating_female1EnglishCaptivating Female
English_Upbeat_WomanEnglishUpbeat Woman
English_Trustworth_ManEnglishTrustworthy Man
Chinese (Mandarin)_Reliable_ExecutiveMandarinReliable Executive
Chinese (Mandarin)_News_AnchorMandarinNews Anchor
Chinese (Mandarin)_Unrestrained_Young_ManMandarinUnrestrained Young Man
Chinese (Mandarin)_Mature_WomanMandarinMature Woman
Arrogant_MissMandarinArrogant Miss
Robot_ArmorMandarinRobot Armor
Chinese (Mandarin)_Kind-hearted_AntieMandarinKind-hearted Antie
Chinese (Mandarin)_HK_Flight_AttendantMandarinHK Flight Attendant
Japanese_IntellectualSeniorJapaneseIntellectual Senior
Japanese_DecisivePrincessJapaneseDecisive Princess
Japanese_LoyalKnightJapaneseLoyal Knight
Spanish_SereneWomanSpanishSerene Woman
Spanish_MaturePartnerSpanishMature Partner
Spanish_CaptivatingStorytellerSpanishCaptivating Storyteller
Cantonese_GentleLadyCantoneseGentle Lady

Cantonese voices need language_boost: "Chinese,Yue". The table above is only a starter selection: every voice on MiniMax's official System Voice ID List — 332 voices across 24 languages — works here; pass the voice_id exactly as listed there. Verified on 2026-09-16: all of them resolve except Cantonese_ProfessionalHost (F) and Cantonese_ProfessionalHost (M), which the official list shows but the service rejects with "voice id not exist". HopBase does not offer a voice-listing endpoint yet.

Performance and timeouts

Measured on short text (a sentence or two): the synchronous endpoints return the whole clip in about 1.5-2.4 s, and with stream: true the first audio chunk arrives after roughly 1 s. Latency grows with text length, so stream anything long (MiniMax recommends streaming above 3,000 characters) and write chunks as they arrive, as in the SSE example above. For synchronous requests, follow the same client guidance as the other synchronous endpoints on Concurrency, timeouts, and billing: a read timeout of 300 s or more.

Billing

Speech is billed per billed character. The authoritative count is extra_info.usage_characters in the native response; the OpenAI-compatible endpoint returns audio only, so read the same number as tts_characters in the usage record in the console. Every Unicode character counts 1, and every Han (CJK) character counts 1 more, so a Han character counts 2; punctuation, spaces, emoji, and pause markers such as <#0.5#> count 1 each.

  • Hello, world. → 13 billed characters
  • 你好,世界 → 5 characters, 4 of them Han → 9 billed characters

A request rejected with 400 or failed during synthesis is not billed, and a stream that ends before the final event is not billed. Official list prices per million characters are on the pricing page; your group's rate is in the signed-in model catalog.

Errors and tips

Every 400 has the body {"error":{"message":…,"type":"invalid_request_error","code":…}}; message names the offending field.

StatusCauseWhat to do
400Invalid parameter: a value out of range, stream_format, output_format: "url", or opus without sample_rateFix the field named in message; do not retry as is
400Unknown voice IDUse an ID from System voices verbatim
400Text over the limit (4,096 / 10,000 characters)Split the script and send it in parts
400Unsupported format, such as response_format: "aac"Use mp3 / opus / flac / wav / pcm
402Account balance or key quota exhaustedTop up or raise the key quota; do not retry in a loop
404Model not in the key's groupConfirm with GET /v1/models
429Rate or concurrency limitRetry after Retry-After and lower your concurrency
503No service available right nowBack off and retry (2 s, 5 s, 15 s, up to three attempts)
  • Use a Mandarin voice for Chinese text.
  • Split long scripts yourself: 4,096 characters per request on the OpenAI-compatible endpoint, 10,000 on the native one.
  • Always send sample_rate together with opus.

Compared with OpenAI's own TTS: there is no tts-1 model name (use the two IDs above), stream_format is unavailable, instructions has no effect, and voice values are MiniMax voice IDs.

On this page