ENZH

豆包 TTS 接入手册

语音合成与情感控制技术蓝图语音合成与情感控制技术蓝图

这个系列是什么:可以直接执行的技术手册——不讲故事,只讲怎么干。替换占位符,接入你的项目,跑起来。

豆包 TTS:克隆声音 + 逐句情感控制

这篇覆盖火山引擎豆包 Seed-ICL 2.0 语音合成的完整接入流程——控制台配置、声音克隆、API 细节、自然语言情感控制、逐句多调用合成、NDJSON 响应解析,以及我踩过的每一个坑。


目录

  1. 为什么选豆包
  2. 控制台配置
  3. 环境变量
  4. API 细节
  5. Clone 1.0 vs Clone 2.0
  6. 情感控制
  7. 价格
  8. 完整 TypeScript 实现
  9. 踩坑记录
  10. 上线检查清单

1. 为什么是豆包

做语音项目的时候,需求就两个:克隆声音 + 逐句情感控制。评估了好几家,豆包 Seed-ICL 2.0 在中文语音合成上赢得很明显。

维度豆包 Seed-ICL 2.0MiniMax 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 应用

  1. 左侧导航栏找到豆包语音
  2. 选择声音复刻大模型
  3. 创建应用
  4. 起个名字,提交。
  5. 在应用详情页记下 APP ID(纯数字,比如 1234567890)。

第三步:克隆声音

  1. 在应用里找到声音克隆入口。
  2. 上传一段音频:
    • 时长:10–30 秒
    • 只能有一个人说话——不能有背景音乐或多人重叠
    • 录音干净,噪音尽量小
    • MP3 或 WAV 格式
  3. 处理完成后,页面会显示一个 Speaker ID,格式是 S_xxxxxxxxx
  4. 把这个 ID 记下来——每次 API 调用都要用。

第四步:开通合成服务

  1. 在应用设置里确认语音合成已开通。
  2. Clone 1.0 和 Clone 2.0 用的是同一个 Speaker ID——切换版本不需要重新克隆。

第五步:获取 Access Key

  1. 控制台进入访问控制API 访问密钥
  2. 创建或复制一个 Access Key。
  3. 每次请求都要在 X-Api-Access-Key header 里带上。

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 ID1234567890
DOUBAO_TTS_ACCESS_KEY访问控制里的 API 密钥AbCdEfGhIj...
DOUBAO_TTS_SPEAKER克隆声音的 Speaker IDS_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 IDmodel_type
克隆声音 (ICL 2.0)S_xxxxxxxxxseed-icl-2.04(必须加)
官方 2.0 音色*_uranus_bigttssaturn_*seed-tts-2.0不需要
官方 1.0 音色*_mars_bigtts*_moon_bigttsICL_*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.uidstring调用方标识,用于日志和计费归属
req_params.textstring要合成的文本
req_params.speakerstring控制台里的 Speaker ID(S_xxxxx
req_params.audio_params.formatstringmp3wav
req_params.audio_params.sample_ratenumber推荐 24000
req_params.additionsstringJSON 序列化后的字符串,承载 context_textsmodel_type

响应格式(NDJSON)

响应是 NDJSON(换行分隔的 JSON),不是标准 JSON。每行一个独立的 JSON 对象:

{"code":0,"data":"<base64 编码的音频片段 1>"}
{"code":0,"data":"<base64 编码的音频片段 2>"}
{"code":0,"data":"<base64 编码的音频片段 3>"}
{"code":20000000}

解析规则:

  1. \n 分割响应体。
  2. 每一行单独 JSON.parse
  3. code === 0 且有 data:把 data 从 base64 解码,加入 chunk 数组。
  4. code === 20000000:流结束,停止解析。
  5. 其他 code:当错误处理。
  6. 最后 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.0Clone 2.0
X-Api-Resource-Id headervolc.seedicl.defaultseed-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.0context_texts(在 additions 里)——实测有明确的情感差异
官方 2.0(_uranus_)+ seed-tts-2.0context_texts(在 additions 里)看具体音色——vivi/刘飞效果好,灿灿基本没反应
官方 1.0 多情感(_emo_)+ seed-tts-1.0audio_params.emotion 参数关键词式(angryhappysad 等)
官方 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_textsadditions 字符串里的一个数组。接受自然语言中文,描述想要的情感或说话风格。只有第一个元素生效——多写了也没用。

context_texts 里的文本不计费——只有 text 字段的字符数算钱。

"additions": "{\"context_texts\":[\"用撒娇甜蜜的语气\"],\"model_type\":4}"

情感提示示例

这里有个关键发现:短关键词基本没用。"开心"两个字扔进去,出来的声音和不带情感差别极小。

你得画面感拉满。越具体、越有场景感,情感就越到位。

不好(太笼统——几乎没效果):

开心
用温柔的语气说
撒娇

好(有画面、有细节——情感明显):

用甜蜜撒娇的声音,像在跟男朋友撒娇,语调上扬很开心
用哭泣的声音,边哭边说,很伤心,声音颤抖带着哽咽
用非常激动兴奋的语气,开心到快要尖叫了
用ASMR悄悄话的声音,非常小声非常轻柔,像在耳边低语
用愤怒嫌弃的语气,非常不满在骂人,声音拔高
用疲惫慵懒的声音,边打哈欠边撒娇,声音软绵绵的
用低沉磁性的声音,表面平淡但充满关心,像大叔在叮嘱
用压抑悲伤的声音,故意克制但声音微微颤抖,不想让人看出来

其实跟写图片生成的 prompt 一个道理:"一只狗"和"一只金毛幼犬趴在红色沙发上晒下午的太阳"——出来的东西完全不是一个级别。描述声音的物理质感、场景、强度,效果才会出来。

逐句合成方案

豆包的 context_texts 是作用于整次调用的。如果一段话里有多种情感,就需要逐句拆开,每句单独调 API。

带情感标记的输入:

[开心]你好呀![温柔]今天辛苦了。[害羞]你别这么说嘛。

处理流程:

  1. 解析 [情感]文本 标记格式,拆成片段。
  2. 每个片段单独调一次 API,带上对应的 context_texts
  3. 把所有返回的音频 buffer 拼成一个 MP3。
  4. 去掉标记,生成用户看到的 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() 的时候必须还在。先去标记再解析 = 所有情感信息全丢。

正确顺序:

  1. 拿到原始文本:[开心]你好呀![温柔]今天辛苦了。
  2. parseEmotionSegments() 拆成带 contextText 的片段。
  3. 每个片段单独合成。
  4. 拼接音频。
  5. 最后调 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_IDDOUBAO_TTS_ACCESS_KEYDOUBAO_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.


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0