豆包 TTS 接入手册
语音合成与情感控制技术蓝图
这个系列是什么:可以直接执行的技术手册——不讲故事,只讲怎么干。替换占位符,接入你的项目,跑起来。
豆包 TTS:克隆声音 + 逐句情感控制
这篇覆盖火山引擎豆包 Seed-ICL 2.0 语音合成的完整接入流程——控制台配置、声音克隆、API 细节、自然语言情感控制、逐句多调用合成、NDJSON 响应解析,以及我踩过的每一个坑。
目录
1. 为什么是豆包
做语音项目的时候,需求就两个:克隆声音 + 逐句情感控制。评估了好几家,豆包 Seed-ICL 2.0 在中文语音合成上赢得很明显。
| 维度 | 豆包 Seed-ICL 2.0 | MiniMax Speech 2.8 HD |
|---|---|---|
| 相对成本 | 1x(基准) | ~2.3x |
| 情感控制 | 自然语言 context_texts + COT 标签 | 只能调语速/音高/音量 |
| 声音克隆 | Clone 2.0——完整情感 + 韵律 | 静态声音档案 |
| 免费额度 | 20,000 字符 | 有限 |
说白了,最关键的区别就是:豆包让你用自然语言描述情感——"用撒娇甜蜜的语气"——模型真的能听懂。MiniMax 只给你几个数字滑块调语速音高。表现力差距巨大。
价格比 MiniMax HD 便宜一半多(具体价格看火山引擎控制台),情感还原度却高出一截。
但要注意——豆包是为中文优化的。英文内容建议还是用 MiniMax 或其他方案。
2. 控制台配置
第一步:注册火山引擎账号
打开控制台:https://console.volcengine.com/speech/app
第二步:创建 TTS 应用
- 左侧导航栏找到豆包语音。
- 选择声音复刻大模型。
- 点创建应用。
- 起个名字,提交。
- 在应用详情页记下 APP ID(纯数字,比如
1234567890)。
第三步:克隆声音
- 在应用里找到声音克隆入口。
- 上传一段音频:
- 时长:10–30 秒
- 只能有一个人说话——不能有背景音乐或多人重叠
- 录音干净,噪音尽量小
- MP3 或 WAV 格式
- 处理完成后,页面会显示一个 Speaker ID,格式是
S_xxxxxxxxx。 - 把这个 ID 记下来——每次 API 调用都要用。
第四步:开通合成服务
- 在应用设置里确认语音合成已开通。
- Clone 1.0 和 Clone 2.0 用的是同一个 Speaker ID——切换版本不需要重新克隆。
第五步:获取 Access Key
- 控制台进入访问控制 → API 访问密钥。
- 创建或复制一个 Access Key。
- 每次请求都要在
X-Api-Access-Keyheader 里带上。
3. 环境变量
# 必填
DOUBAO_TTS_APP_ID=your_app_id_here
DOUBAO_TTS_ACCESS_KEY=your_access_key_here
DOUBAO_TTS_SPEAKER=S_your_speaker_id_here
| 变量 | 用途 | 格式示例 |
|---|---|---|
DOUBAO_TTS_APP_ID | 控制台里的数字 APP ID | 1234567890 |
DOUBAO_TTS_ACCESS_KEY | 访问控制里的 API 密钥 | AbCdEfGhIj... |
DOUBAO_TTS_SPEAKER | 克隆声音的 Speaker ID | S_EVeoGUVU1 |
三个缺一个,provider 就应该返回不可用,让系统自动降级到备用 TTS。
4. API 细节
接口地址
POST https://openspeech.bytedance.com/api/v3/tts/unidirectional
必带 Headers
Content-Type: application/json
X-Api-App-Id: <DOUBAO_TTS_APP_ID>
X-Api-Access-Key: <DOUBAO_TTS_ACCESS_KEY>
X-Api-Resource-Id: <见下面的路由表>
Resource ID 路由
X-Api-Resource-Id 这个 header 必须跟你用的音色类型对上。传错了会报 55000000: resource ID is mismatched,没有任何多余解释,就给你甩一个错误码。
| 音色类型 | Speaker ID 特征 | Resource ID | model_type |
|---|---|---|---|
| 克隆声音 (ICL 2.0) | S_xxxxxxxxx | seed-icl-2.0 | 4(必须加) |
| 官方 2.0 音色 | *_uranus_bigtts、saturn_* | seed-tts-2.0 | 不需要 |
| 官方 1.0 音色 | *_mars_bigtts、*_moon_bigtts、ICL_* | seed-tts-1.0 | 不需要 |
| 官方 1.0(高并发) | 同上 | seed-tts-1.0-concurr | 不需要 |
代码里根据 Speaker ID 自动判断:
const isCloned = speakerId.startsWith('S_')
const is2dot0 = speakerId.includes('_uranus_') || speakerId.startsWith('saturn_')
const resourceId = isCloned ? 'seed-icl-2.0' : is2dot0 ? 'seed-tts-2.0' : 'seed-tts-1.0'
克隆声音用 seed-icl-2.0(推荐)。Clone 1.0 的区别见第 5 节。
最简请求体
{
"user": {
"uid": "your-app-name"
},
"req_params": {
"text": "你好呀!",
"speaker": "S_your_speaker_id_here",
"audio_params": {
"format": "mp3",
"sample_rate": 24000
}
}
}
带情感控制的请求体
{
"user": {
"uid": "your-app-name"
},
"req_params": {
"text": "你好呀!",
"speaker": "S_your_speaker_id_here",
"audio_params": {
"format": "mp3",
"sample_rate": 24000
},
"additions": "{\"context_texts\":[\"用撒娇甜蜜的语气\"],\"model_type\":4}"
}
}
注意:additions 是一个字符串(序列化后的 JSON),不是对象。这是接入时最常见的错误——见坑 1。
字段完整参考
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
user.uid | string | 是 | 调用方标识,用于日志和计费归属 |
req_params.text | string | 是 | 要合成的文本 |
req_params.speaker | string | 是 | 控制台里的 Speaker ID(S_xxxxx) |
req_params.audio_params.format | string | 是 | mp3 或 wav |
req_params.audio_params.sample_rate | number | 是 | 推荐 24000 |
req_params.additions | string | 否 | JSON 序列化后的字符串,承载 context_texts 和 model_type |
响应格式(NDJSON)
响应是 NDJSON(换行分隔的 JSON),不是标准 JSON。每行一个独立的 JSON 对象:
{"code":0,"data":"<base64 编码的音频片段 1>"}
{"code":0,"data":"<base64 编码的音频片段 2>"}
{"code":0,"data":"<base64 编码的音频片段 3>"}
{"code":20000000}
解析规则:
- 按
\n分割响应体。 - 每一行单独
JSON.parse。 code === 0且有data:把data从 base64 解码,加入 chunk 数组。code === 20000000:流结束,停止解析。- 其他
code:当错误处理。 - 最后
Buffer.concat(chunks)拼起来。
直接调 response.json() 会炸。见坑 4。
curl 冒烟测试
不带情感:
curl -X POST 'https://openspeech.bytedance.com/api/v3/tts/unidirectional' \
-H 'Content-Type: application/json' \
-H 'X-Api-App-Id: YOUR_APP_ID' \
-H 'X-Api-Access-Key: YOUR_ACCESS_KEY' \
-H 'X-Api-Resource-Id: seed-icl-2.0' \
--data-raw '{
"user": { "uid": "test" },
"req_params": {
"text": "你好呀!",
"speaker": "YOUR_SPEAKER_ID",
"audio_params": { "format": "mp3", "sample_rate": 24000 },
"additions": "{\"model_type\":4}"
}
}'
带情感:
curl -X POST 'https://openspeech.bytedance.com/api/v3/tts/unidirectional' \
-H 'Content-Type: application/json' \
-H 'X-Api-App-Id: YOUR_APP_ID' \
-H 'X-Api-Access-Key: YOUR_ACCESS_KEY' \
-H 'X-Api-Resource-Id: seed-icl-2.0' \
--data-raw '{
"user": { "uid": "test" },
"req_params": {
"text": "你好呀!",
"speaker": "YOUR_SPEAKER_ID",
"audio_params": { "format": "mp3", "sample_rate": 24000 },
"additions": "{\"context_texts\":[\"用开心撒娇的语气\"],\"model_type\":4}"
}
}'
5. Clone 1.0 vs Clone 2.0
两个版本用的是同一个 Speaker ID——不需要重新克隆。
| 特性 | Clone 1.0 | Clone 2.0 |
|---|---|---|
X-Api-Resource-Id header | volc.seedicl.default | seed-icl-2.0 |
model_type(在 additions 里) | 不需要 | 4(必须加,否则走 1.0) |
context_texts 支持 | 有限 | 完整的自然语言支持 |
| COT 内联标签 | 不支持 | 支持 |
| 情感表现力 | 基础 | 明显更好 |
新项目直接用 Clone 2.0(seed-icl-2.0)。Clone 1.0 这里只是留个参考。
Clone 2.0 COT 标签
Clone 2.0 支持在文本里直接内嵌 COT(chain-of-thought)标签做逐句情感:
<cot text=开心>你好呀!</cot><cot text=温柔>今天辛苦了。</cot>
这是逐句多调用方案的替代方案。COT 标签可以一次调用带多种情感,但实测下来,逐句分开调用的情感一致性更好,控制粒度也更细。
6. 情感控制——豆包的杀手锏
大部分 TTS 给你的是数字参数——语速、音高、音量。豆包让你用中文自然语言描述情感,模型自己去理解。
不同音色的情感控制方式
但问题是——不是所有音色都能用同一种方式控制情感。方法和效果取决于音色类型:
| 音色类型 | 控制方式 | 效果 |
|---|---|---|
克隆声音(S_xxx)+ seed-icl-2.0 | context_texts(在 additions 里) | 强——实测有明确的情感差异 |
官方 2.0(_uranus_)+ seed-tts-2.0 | context_texts(在 additions 里) | 看具体音色——vivi/刘飞效果好,灿灿基本没反应 |
官方 1.0 多情感(_emo_)+ seed-tts-1.0 | audio_params.emotion 参数 | 关键词式(angry、happy、sad 等) |
官方 1.0 普通音色 + seed-tts-1.0 | 无 | 没有情感控制能力 |
几个实测发现,挺重要的:
context_texts 对克隆声音也有效。 官方文档写的是"仅适用于豆包语音合成模型2.0的音色",但实测发现克隆声音配 seed-icl-2.0 也能用——音频差异在 +25-65% 之间。说白了,文档没写不代表不能用。
官方 2.0 音色看脸。 我测了几个:zh_female_vv_uranus_bigtts(Vivi)效果好,zh_male_liufei_uranus_bigtts(刘飞)效果好,zh_male_m191_uranus_bigtts(云舟)效果好,但 zh_female_cancan_uranus_bigtts(灿灿)几乎没变化。同一套参数,不同音色表现完全不一样。
seed-tts-2.0-expressive 模型参数可以给部分 2.0 音色加强情感。在 additions JSON 里传 model: 'seed-tts-2.0-expressive'——实测对能响应的音色有 +26% 的音频差异提升。
context_texts 怎么用
context_texts 是 additions 字符串里的一个数组。接受自然语言中文,描述想要的情感或说话风格。只有第一个元素生效——多写了也没用。
context_texts 里的文本不计费——只有 text 字段的字符数算钱。
"additions": "{\"context_texts\":[\"用撒娇甜蜜的语气\"],\"model_type\":4}"
情感提示示例
这里有个关键发现:短关键词基本没用。"开心"两个字扔进去,出来的声音和不带情感差别极小。
你得画面感拉满。越具体、越有场景感,情感就越到位。
不好(太笼统——几乎没效果):
开心
用温柔的语气说
撒娇
好(有画面、有细节——情感明显):
用甜蜜撒娇的声音,像在跟男朋友撒娇,语调上扬很开心
用哭泣的声音,边哭边说,很伤心,声音颤抖带着哽咽
用非常激动兴奋的语气,开心到快要尖叫了
用ASMR悄悄话的声音,非常小声非常轻柔,像在耳边低语
用愤怒嫌弃的语气,非常不满在骂人,声音拔高
用疲惫慵懒的声音,边打哈欠边撒娇,声音软绵绵的
用低沉磁性的声音,表面平淡但充满关心,像大叔在叮嘱
用压抑悲伤的声音,故意克制但声音微微颤抖,不想让人看出来
其实跟写图片生成的 prompt 一个道理:"一只狗"和"一只金毛幼犬趴在红色沙发上晒下午的太阳"——出来的东西完全不是一个级别。描述声音的物理质感、场景、强度,效果才会出来。
逐句合成方案
豆包的 context_texts 是作用于整次调用的。如果一段话里有多种情感,就需要逐句拆开,每句单独调 API。
带情感标记的输入:
[开心]你好呀![温柔]今天辛苦了。[害羞]你别这么说嘛。
处理流程:
- 解析
[情感]文本标记格式,拆成片段。 - 每个片段单独调一次 API,带上对应的
context_texts。 - 把所有返回的音频 buffer 拼成一个 MP3。
- 去掉标记,生成用户看到的
displayText。
为什么不一次调用把整段话发过去?
一次发一整段,context_texts 只对开头生效。后面的句子情感会越来越平,慢慢变成机器人。逐句调用才能保证每句话都带上正确的情感。
LLM 系统提示词(配合 TTS 使用)
如果你用 LLM 生成的文本会送入豆包 TTS,在系统提示词里加上这段:
当你生成语音内容时:
- 每一句前都加一个方括号情感指令
- 例如:[开心]、[伤心]、[温柔]、[害羞]、[愤怒]
- 也可以写具体语气描述,例如:[用温柔甜蜜的声音]、[她正在生气地质问对方]
- 这些标记仅用于 TTS 处理,不展示给用户
- 如果多句话情绪不同,每句都需要自己的方括号标记
7. 价格
| 档位 | 备注 |
|---|---|
| 按量付费(Clone 2.0) | 默认;具体价格看火山引擎控制台 |
| 批量折扣 | 需要谈,大概能打七折 |
| MiniMax HD(对比) | 贵约 2.3 倍 |
| 免费额度 | 每个应用 20,000 字符 |
计费注意:
- 只按
text字段的字符数计费。 context_texts里的内容不收费——情感控制是免费的。- 具体单价去火山引擎控制台查,接入成本追踪的时候用最新价格。
8. 完整 TypeScript 实现
这是一个可以直接用的独立模块。根据你的项目结构调整 import 和类型定义。
情感片段解析器
// 情感解析模块
export interface EmotionSegment {
contextText?: string
text: string
}
export function parseEmotionSegments(input: string): EmotionSegment[] {
const segments: EmotionSegment[] = []
const parts = input.split(/\[([^\]]+)\]/)
let pendingContext: string | undefined
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) {
const chunk = parts[i].trim()
if (chunk) {
segments.push({ contextText: pendingContext, text: chunk })
pendingContext = undefined
}
} else {
pendingContext = parts[i]?.trim() || undefined
}
}
return segments
}
export function stripEmotionMarkers(text: string): string {
return text.replace(/\[([^\]]+)\]/g, '').replace(/\s{2,}/g, ' ').trim()
}
核心 TTS 调用
// 豆包 TTS 模块
const DOUBAO_TTS_URL = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional'
function getResourceId(speakerId: string): string {
const isCloned = speakerId.startsWith('S_')
const is2dot0 = speakerId.includes('_uranus_') || speakerId.startsWith('saturn_')
return isCloned ? 'seed-icl-2.0' : is2dot0 ? 'seed-tts-2.0' : 'seed-tts-1.0'
}
export interface DoubaoSpeechParams {
text: string
speakerId: string
userId?: string
contextText?: string
format?: 'mp3' | 'wav'
}
async function callDoubaoTTS(params: DoubaoSpeechParams): Promise<Buffer> {
const appId = process.env.DOUBAO_TTS_APP_ID!
const accessKey = process.env.DOUBAO_TTS_ACCESS_KEY!
const { text, speakerId, contextText, format = 'mp3' } = params
const isCloned = speakerId.startsWith('S_')
const additions = JSON.stringify({
...(contextText ? { context_texts: [contextText] } : {}),
...(isCloned ? { model_type: 4 } : {}),
})
const body = {
user: { uid: params.userId ?? 'default' },
req_params: {
text,
speaker: speakerId,
audio_params: { format, sample_rate: 24000 },
additions,
},
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 30_000)
try {
const response = await fetch(DOUBAO_TTS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-App-Id': appId,
'X-Api-Access-Key': accessKey,
'X-Api-Resource-Id': getResourceId(speakerId),
},
body: JSON.stringify(body),
signal: controller.signal,
})
if (!response.ok) {
const errText = await response.text().catch(() => '')
throw new Error(`Doubao TTS HTTP ${response.status}: ${errText}`)
}
// NDJSON 响应——逐行解析
const raw = await response.text()
const chunks: Buffer[] = []
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
let parsed: { code?: number; data?: string; message?: string }
try {
parsed = JSON.parse(trimmed)
} catch {
continue
}
if (parsed.code === 0 && parsed.data) {
chunks.push(Buffer.from(parsed.data, 'base64'))
continue
}
if (parsed.code === 20000000) break
if (parsed.code !== undefined && parsed.code !== 0) {
throw new Error(
`Doubao TTS stream error: code=${parsed.code} message=${parsed.message ?? ''}`
)
}
}
if (chunks.length === 0) throw new Error('Doubao TTS returned no audio data')
return Buffer.concat(chunks)
} finally {
clearTimeout(timer)
}
}
多片段情感合成
// 多片段情感合成模块
import { parseEmotionSegments, stripEmotionMarkers } from './emotion-parser'
import { callDoubaoTTS, type DoubaoSpeechParams } from './doubao-tts'
export async function synthesizeWithEmotion(
input: string,
speakerId: string,
userId?: string,
): Promise<{ audio: Buffer; displayText: string }> {
const segments = parseEmotionSegments(input)
const buffers: Buffer[] = []
for (const segment of segments) {
const audioBuffer = await callDoubaoTTS({
text: segment.text,
speakerId,
userId,
contextText: segment.contextText,
})
buffers.push(audioBuffer)
}
return {
audio: Buffer.concat(buffers),
displayText: stripEmotionMarkers(input),
}
}
可用性检查
export function isDoubaoTTSAvailable(): boolean {
return !!(
process.env.DOUBAO_TTS_APP_ID &&
process.env.DOUBAO_TTS_ACCESS_KEY &&
process.env.DOUBAO_TTS_SPEAKER
)
}
9. 踩坑记录(先读这段能省你一天)
每个坑都是真金白银的调试时间换来的。
坑 1:additions 是字符串,不是对象
接入时最常犯的错。additions 字段要求的是 JSON 序列化后的字符串,不是一个对象。
错误写法:
"additions": {
"context_texts": ["开心"],
"model_type": 4
}
正确写法:
"additions": "{\"context_texts\":[\"开心\"],\"model_type\":4}"
TypeScript 里:
const additions = JSON.stringify({ context_texts: [contextText], model_type: 4 })
// additions 现在是字符串: '{"context_texts":["开心"],"model_type":4}'
最坑的是——传对象进去 API 不会报错。它会默默忽略你的情感设置,合成出来的声音平平淡淡。你会怀疑是 context_texts 不好使,debug 一个小时才发现是数据类型传错了。
坑 2:model_type 放在 additions 里面,不是 req_params 层级
model_type: 4(启用 Clone 2.0)要放在 additions 字符串里面,和 context_texts 在一起。放在 req_params 根层级没有任何效果——你会得到 Clone 1.0 的行为,但不会有任何错误提示。
正确位置:
req_params: {
text: '...',
speaker: '...',
audio_params: { format: 'mp3', sample_rate: 24000 },
additions: JSON.stringify({ context_texts: ['开心'], model_type: 4 }),
}
坑 3:不要一次发整段话
一次调用把多句话都塞在 text 里,只配一个 context_texts,结果就是:第一句情感到位,后面的句子越来越平,最后变成毫无感情的朗读。
解决办法:一句话一次调用,每句带自己的 context_texts。是的,调用次数多了。但情感质量的提升是值得的。
坑 4:响应是 NDJSON,不是标准 JSON
直接调 response.json() 会抛 SyntaxError,因为响应体里是多个 JSON 对象,一行一个。
解决办法:先 .text() 拿到原始文本,按 \n 分割,每行单独 JSON.parse。
// 错误——会抛 SyntaxError
const data = await response.json()
// 正确——逐行解析 NDJSON
const raw = await response.text()
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const parsed = JSON.parse(trimmed)
// ... 处理每个 chunk
}
坑 5:先解析再去标记,顺序不能反
[情感] 标记在调用 parseEmotionSegments() 的时候必须还在。先去标记再解析 = 所有情感信息全丢。
正确顺序:
- 拿到原始文本:
[开心]你好呀![温柔]今天辛苦了。 - 调
parseEmotionSegments()拆成带contextText的片段。 - 每个片段单独合成。
- 拼接音频。
- 最后调
stripEmotionMarkers()生成给用户看的displayText。
搞反了的后果:每句话都没有情感——你会觉得是 API 的问题,其实是你自己管线里的 bug。
坑 6:延迟特性
豆包合成长段多句文本大约要 14 秒,MiniMax 大约 5 秒。听着差距很大,但实际上:
- 逐句调用的话,每次只处理 5–15 个字,2–4 秒就回来了。
- 第一句先回来,可以边合成边播放。
- 情感质量的提升完全值得这点延迟。
如果你需要真正的低延迟实时语音(首字节 < 1 秒),豆包不合适。直接用 MiniMax 或其他流式优先的方案。
坑 7:MP3 片段拼接兼容性
多次 API 调用返回的 MP3 buffer 直接拼在一起,大部分播放器没问题。但有些播放器在片段边界会出现间断、爆音或卡顿。
如果生产环境遇到这个问题,用 ffmpeg 重新封装一下:
ffmpeg -i input.mp3 -c copy output.mp3
Node.js 里可以 spawn 一个 ffmpeg 进程做这件事,返回最终 buffer 之前跑一遍。
10. 上线前最后过一遍
上线前逐条确认:
- 三个环境变量已配置:
DOUBAO_TTS_APP_ID、DOUBAO_TTS_ACCESS_KEY、DOUBAO_TTS_SPEAKER - curl 冒烟测试通过(不带情感)——确认凭证和网络
- curl 冒烟测试通过(带
context_texts)——确认 Clone 2.0 情感管线 - 所有代码路径中
additions都序列化成了字符串 -
model_type: 4放在了序列化后的additions里面 - 响应按 NDJSON 逐行解析,没有用
response.json() - 多情感文本走逐句合成
-
stripEmotionMarkers()在合成之后调用(不是之前) - 可用性检查在环境变量缺失时返回
false——自动降级到备用方案 - 成本追踪已接入:字符数 x 火山引擎控制台最新单价
- 每次 API 调用有 30 秒超时
- LLM 系统提示词已加入
[情感]文本标记格式(如适用)
This post is also available in English.