接进来只是开始。MiniMax TTS 的"自然度",八成来自文稿准备——停顿节奏、情绪音效、多音字替词、数字读法。 这份指南先讲怎么接,再把一年多的实战坑一次讲透。
拿钥匙、填端点、选模型——三步就能让程序开口
<你的 MINIMAX_API_KEY>,这样脚本分享出去也不会带出密钥。
/v1/t2a_v2,模型选 speech-2.8-hd(支持停顿标记和拟声词)。请求头带 Authorization: Bearer <Key> 即可。
注册账号 + 实名认证后,进控制台 账户管理 → 接口密钥 创建 API Key。国内、海外是两套独立站点(账号不互通),按你的网络环境选一套:
| 版本 | 申请控制台 | 对应 API 端点 |
|---|---|---|
| 国内 | platform.minimaxi.com | api.minimaxi.com |
| 海外 | platform.minimax.io | api.minimax.io |
minimaxi.com)、海外站不带 i(minimax.io)。
旧国内域名 api.minimaxi.chat 目前仍可用;但 api.minimax.chat(没 i 又是 .chat)是常见笔误,连不上。
credentials.minimax.MINIMAX_API_KEY。
代码仓库里看不到任何明文,分享脚本零风险。新版 Key 是 sk-api- 开头,不需要再单独填 GroupId。
import requests, json
from pathlib import Path
# Key 从本地配置读,不硬编码
API_KEY = json.loads(Path.home().joinpath(".config/minimax.json").read_text())["key"]
ENDPOINT = "https://api.minimaxi.com/v1/t2a_v2" # 国内站,带 i
payload = {
"model": "speech-2.8-hd",
"text": "朋友们,今天聊个重要的话题。",
"voice_setting": {"voice_id": "<你的克隆音色 id>", "speed": 1.3},
"audio_setting": {"sample_rate": 24000, "format": "wav"},
}
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
resp = requests.post(ENDPOINT, json=payload, headers=headers, timeout=180).json()
# ⚠️ 返回的 data.audio 是 hex 编码,不是 base64
Path("out.wav").write_bytes(bytes.fromhex(resp["data"]["audio"]))
bytes.fromhex() 解,当成 base64 解出来全是杂音。
② 国内直连 api.minimaxi.chat 一般秒通;若本机超时,给请求挂一个 HTTPS 代理即可。
用 <#秒数#> 插入显式停顿,替你控制节奏
在文本任意位置插入 <#0.3#>,数字单位为秒。引擎会在该点插入对应时长的静音。
朋友们<#0.3#>今天聊个重要的话题<#0.5#>AI 正在改变什么。
| 值 | 效果 | 适用场景 |
|---|---|---|
| <#0.2#> | 换气感 | 逗号位置、短语之间 |
| <#0.3#> | 自然停顿 | 句间过渡、列举之间 |
| <#0.5#> | 强调停顿 | 话题转换、关键词前 |
| <#0.8#> 以上 | 拖沓 | 基本不用,会显得尬 |
<#0.3#>。标记用得越省越自然。
预设 emotion 决定基调,(音效词) 点缀真实感
API 参数 emotion 可选:happy / sad / angry / fearful / disgusted / surprised / calm / neutral。口播场景推荐 calm,最稳。
emotion: "auto"——文档里写过但实际会报错。不想指定就干脆不传这个字段,比传 auto 稳。另外:克隆音色不支持 emotion,只有系统音色支持。
在文本中用 (keyword) 格式嵌入,引擎会在对应位置合成真实音效。这是提升"人味"的关键手段。
// 对话开头给呼吸,调侃句配轻笑
(breath)说真的<#0.3#>这个需求(chuckle)我第一次听的时候也愣住了。
(breath) + (chuckle) 两个标记足够覆盖 95% 场景。其他的不用。越简单越不容易出戏。
pronunciation_dict 参数治多音字、生僻字、人名
每个字用 (拼音+声调数字),声调 1-5 分别对应阴平/阳平/上声/去声/轻声。
"pronunciation_dict": {
"tone": [
"单老师/(shan4)(lao3)(shi1)",
"曾国藩/(zeng1)(guo2)(fan1)",
"欧阳娜娜/(ou1)(yang2)(na4)(na4)"
]
}
更简单,用字典里已有的字替换。适合单字多音。
"tone": [
"仇/求",
"仇雪岑/求雪岑"
]
每次调 TTS 都携带一份通用人名字典,引擎自动匹配文中出现的名字。遇到新的读错名字,往表里加一条即可。
_PRONUNCIATION_DICT = {"tone": [
# 多音字人名(单、仇、查、曾、解、朴、纪...)
"单雄信/(shan4)(xiong2)(xin4)",
"曾国藩/(zeng1)(guo2)(fan1)",
# 复杂人名(全拼音确保连读不停顿)
"欧阳娜娜/(ou1)(yang2)(na4)(na4)",
"迪丽热巴/(di2)(li4)(re4)(ba1)",
]}
## 或 <#x#>——都会造成不自然停顿## 标记防止生僻长词被引擎切断、或被分块切成两半
用双井号 ##词## 包裹,告诉引擎"这几个字作为一个整体连续发音"。
最近##司美格鲁肽##非常火。
##奥美拉唑肠溶胶囊##主要用于治疗胃食管反流病。
| 标记 | 结果 | 说明 |
|---|---|---|
| ##词## | ✓ 稳定 | 各种长度词组均表现好 |
| {词} | △ 不稳 | 部分词有效部分无效 |
| [词] | ✗ 差 | 效果不好 |
| |词| | ✗ 差 | 效果不好 |
| <词> | ✗ 冲突 | 与停顿标记 <#x#> 冲突 |
## 虽让引擎连读,但标记前后会产生明显不自然停顿。
正确流程:LLM 输出带 ##(用于分块时保护)→ 分块逻辑检查 ## 是否闭合 → 发 TTS 前 text.replace("##", "") 剥离 → 前端显示也要过滤。
pronunciation_dict,不要用 ##前端显示阿拉伯数字,发 TTS 前预处理为中文读法
LLM 输出 13800138000,引擎会读成"一百三十八亿零一十三万八千"——荒谬。规则是:前端始终显示阿拉伯数字,仅在发送 TTS 前转换,按以下顺序处理:
(?<!\d)1\d{10}(?!\d)
\d{12,}
(?<![.\d])(\d+)(万|亿),小数不匹配(2.5万 不动)。
2个 → 两个、2人 → 两人、2倍 → 两倍。
排除:第2名(前有"第")、12人(前有数字)。
200 → 两百、2500 → 两千五百20000 → 两万120000 → 十二万(不是"一十二两万")4200 → 四千二(不是"四千两")_num_to_cn(n, _formal=False, _sub=False) 时,_formal 必须穿透到所有余数递归调用,否则内部子调用会偷偷做口语化省略——结果就是 21600 元 被读成"两万一千六元"(缺"百")。
长文本切块降低首字延迟,切分规则决定音质
| 规则 | 值 | 理由 |
|---|---|---|
| 切分位置 | 。!?!? | 句子边界,不按固定字数 |
| 最小块 | 60 字符 | 太短音质差、有拼接感 |
| 最大块 | 200 字符 | 太长首字延迟高 |
| 闭合检查 | ( 和 <# | 未闭合标记留到下一块 |
| 时间切分 | ✗ 禁用 | "1 秒后发送"会产生残句 |
给视频配旁白时,整篇文稿一次提交(段间用 <#0.4#> 分隔)语气最连贯,分段拼接会有断裂感。只有需要实时流式播放时才分块。
流式输出时,<#0.5#> 这种标记可能被拆成多个 chunk(如 <#0. 和 5#> 分开到达),普通正则匹配不到不完整的标记,UI 会闪现半截标记。
// 用两个存储:原始完整文本 vs 过滤后显示文本
val rawContent = StringBuilder() // 给 TTS 用
val content = StringBuilder() // 给 UI 用
// 完整标记 / 尾部不完整标记(避免流式闪现)
// 注意:不要用 <[^>]* 会误匹配中文书名号《》
val TTS_MARKER_REGEX = Regex("""<#[\d.]+#>|\([a-z-]+\)""")
val TTS_PARTIAL_TAIL = Regex("""(<#[\d.]*#?>?|\([a-z-]*)$""")
所有经验汇总到一次请求
import requests, json
from pathlib import Path
API_KEY = json.loads(Path.home().joinpath(".config/minimax.json").read_text())["key"]
ENDPOINT = "https://api.minimaxi.com/v1/t2a_v2"
# 文稿:已做过数字预处理 + pronunciation_dict + 情绪标记
text = (
"(breath)朋友们<#0.3#>今天聊个重要的话题"
"<#0.5#>AI 正在改变什么(chuckle)。"
)
payload = {
"model": "speech-2.8-hd",
"text": text,
"voice_setting": {
"voice_id": "<你的克隆音色 id>",
"speed": 1.3,
"vol": 2.0,
"pitch": 0,
},
"audio_setting": {
"sample_rate": 24000,
"format": "wav",
},
"subtitle_enable": True, # 顺手要字级时间码,省掉后期对轴
"pronunciation_dict": {
"tone": [
"单雄信/(shan4)(xiong2)(xin4)",
"曾国藩/(zeng1)(guo2)(fan1)",
]
},
}
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
resp = requests.post(ENDPOINT, json=payload, headers=headers, timeout=180).json()
# ⚠️ data.audio 是 hex 编码,不是 base64
audio_bytes = bytes.fromhex(resp["data"]["audio"])
Path("out.wav").write_bytes(audio_bytes)
api.minimaxi.com(带 i)、海外站 api.minimax.io 都可用。旧域名 api.minimaxi.chat 也通;但 api.minimax.chat(没 i)是常见笔误,连不上。
bytes.fromhex(resp["data"]["audio"])。拿 base64.b64decode 解出来是噪声文件。
sk-api- 开头的新 Key,query string 留空即可;老 JWT key 才要填 GroupId。