这套流程最终能做出什么
把一份中文脚本,变成多语种的成品音频:中文、英文、西班牙语各一版,同一个音色贯穿全片,句子之间有情绪起伏(兴奋、压低声音、停顿),并且整批文件可以自动拼接成一条完整的节目音频。
具体交付物是这样几样:
- 一份
script.jsonl:每行一个配音块,带语言标记和情绪标签 - 一个
out/目录:每个配音块一个 mp3,文件名与脚本 id 对应 - 一个
manifest.json:记录每个块用了什么文本、生成了哪个文件 - 每个语种一条拼接好的成品音频,响度统一,可直接上传播客或视频平台
整套流程的核心只有三件事:脚本分块、标签标注、音色与参数锁定。三者缺一个,批量出来就会听起来像好几个人配的。
---
前置条件清单
开工前确认这几项,缺哪项先补哪项:
1. 账号与 API Key:在官方控制台生成一个 API Key,具备文字转语音的调用权限。配额、并发上限、计费方式以官方页面当前说明为准。
2. 一个固定的音色:也就是 voice_id。可以选自带的预设音色,也可以用自己的音频素材做声音克隆。长期批量产出时,优先选一个已经保存到账号里的音色,避免每次现场生成导致音色漂移。
3. Python 环境:能跑 requests 即可,Python 3.10 及以上比较省事。
4. ffmpeg:用于拼接和响度处理,命令行能直接调用。
5. 官方模型列表:打开官方文档的 models 页面,确认当前可用的多语种模型标识(model_id)和它支持的音频标签写法。版本与标识以官方文档为准,不要凭记忆写。
6. 术语表:品牌名、人名、专业词的统一写法。多语种场景下,这份表决定了三个语种读出来是不是同一个东西。
环境变量先设好:
```bash
export ELEVENLABS_API_KEY="你的 key"
```
---
分步骤
第 1 步:把脚本切成"可配音块"
不要一次性把三千字丢给接口。单次请求过长容易在中间出现语气漂移,出错重来的成本也高。切成 300~800 字的块,边界放在句末标点处。
```python
chunk.py
import re
MAX_CHARS = 700
def split_script(text, max_chars=MAX_CHARS):
blocks = []
for para in re.split(r"\n+", text.strip()):
para = para.strip()
if not para:
continue
buf = ""
按句末标点切,保留标点
for sent in re.split(r"(?<=[。!?!?;;])", para):
if not sent:
continue
if len(buf) + len(sent) <= max_chars:
buf += sent
else:
if buf:
blocks.append(buf.strip())
buf = sent
if buf:
blocks.append(buf.strip())
return blocks
if __name__ == "__main__":
raw = open("raw_script.txt", encoding="utf-8").read()
for i, b in enumerate(split_script(raw), 1):
print(i, len(b), b[:40])
```
判断切得好不好的标准:随便拿一个块单独听,语义是完整的,语气不会半路断掉。如果某块结尾是逗号或者半句话,把它和前一块合并。
第 2 步:加情绪标签
在需要表演变化的位置插入方括号标签,例如兴奋、低语、停顿、笑声这类。具体支持哪些标签、大小写是否敏感,以官方文档当前给出的列表为准,不要自己造词,否则标签会被当成正文念出来。
标签的使用原则:
- 一条块里放 1~2 个就够,堆多了表演会变得夸张、不自然
- 标签放在它要生效的那句话前面,而不是段尾
- 停顿类标签不要用来做"留白",长留白交给后期剪辑更可控
转成 JSONL,每行一个块:
```json
{"id": "zh_001", "lang": "zh", "text": "[excited] 这次更新,把配音流程整个换了一遍。"}
{"id": "zh_002", "lang": "zh", "text": "[pause] 你需要的,只是一份排好版的脚本。"}
{"id": "zh_003", "lang": "zh", "text": "[whispers] 剩下的事情,交给批处理。"}
```
id 里带语言前缀(zh_、en_、es_),后面拼接和分组都靠它。
第 3 步:生产多语种版本
把中文底稿翻译成英文、西班牙语等目标语言。翻译时要求标签原样保留、位置不变,可以这样给翻译工具下指令:
> 把下面的文本翻译成英语。保持方括号标签 [xxx] 原样不动,位置也不变。专有名词按下述术语表处理:……
翻译完要人工过一遍,重点看三件事:
1. 标签有没有被翻译掉或改写
2. 数字、单位、缩写是否改成了适合朗读的写法(比如 3.5kg 写成 three point five kilograms)
3. 目标语言里有没有过长且无标点的句子——有的话手动断句,否则合成出来会一口气念完
每个语种生成一份独立的 JSONL,命名如 script.zh.jsonl、script.en.jsonl。
第 4 步:锁定音色与参数
音色一致性不是靠"选同一个音色"就够,而是靠所有参数在所有语种所有块上完全一致。把参数写进配置文件,不要散落在代码里:
```json
{
"voice_id": "REPLACE_WITH_VOICE_ID",
"model_id": "REPLACE_WITH_MODEL_ID_FROM_OFFICIAL_DOCS",
"output_format": "REPLACE_WITH_SUPPORTED_FORMAT",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.75,
"style": 0.0,
"use_speaker_boost": true
}
}
```
说明几点:
model_id和output_format的取值,从官方文档当前页面复制,不同账号可用的值可能有差异voice_settings里各项的名字和取值范围,不同模型支持情况不完全一样,以官方文档为准- 一旦定下这组参数,所有语种共用。中日英西混排时分别调参,音色会明显分裂
- 配置文件放进版本管理,以后想复现某期节目,checkout 回去就能重跑
第 5 步:先跑一条基线
别急着批量。先拿一条 30 秒左右的块,用配置里的参数跑一遍,听三件事:
1. 音色是不是你要的
2. 情绪标签有没有生效
3. 换一个语种跑同一句话,音色是否还是"同一个人"
这一步花五分钟,能省掉后面整批重做的几小时。基线不满意就调参数,调好了再把值写回配置文件。
第 6 步:批量合成
下面是完整的批量脚本,包含重试和上下文传递:
```python
synth_batch.py
import json, os, pathlib, sys, time
import requests
BASE = "https://api.elevenlabs.io/v1/text-to-speech"
API_KEY = os.environ["ELEVENLABS_API_KEY"]
HEADERS = {"xi-api-key": API_KEY, "Content-Type": "application/json"}
cfg = json.loads(pathlib.Path("config.json").read_text(encoding="utf-8"))
OUT = pathlib.Path("out")
OUT.mkdir(exist_ok=True)
def synth(text, prev_text=None, next_text=None, retries=5):
url = f"{BASE}/{cfg['voice_id']}"
payload = {
"text": text,
"model_id": cfg["model_id"],
"voice_settings": cfg["voice_settings"],
"output_format": cfg["output_format"],
}
上下文能让相邻块的语气更连贯
if prev_text:
payload["previous_text"] = prev_text
if next_text:
payload["next_text"] = next_text
for attempt in range(retries):
r = requests.post(url, headers=HEADERS, json=payload, timeout=180)
if r.status_code == 200:
return r.content
if r.status_code in (429, 500, 502, 503, 504):
wait = min(2 ** attempt, 30)
print(f" [{r.status_code}] 第 {attempt+1} 次重试,等待 {wait}s")
time.sleep(wait)
continue
raise RuntimeError(f"{r.status_code}: {r.text[:300]}")
raise RuntimeError("重试次数用尽")
def run(jsonl_path):
rows = [json.loads(l) for l in
pathlib.Path(jsonl_path).read_text(encoding="utf-8").splitlines() if l.strip()]
manifest = []
for i, row in enumerate(rows):
上下文只在同语种内传递,跨语言会干扰发音
prev_text = rows[i - 1]["text"] if i > 0 else None
next_text = rows[i + 1]["text"] if i + 1 < len(rows) else None
audio = synth(row["text"], prev_text, next_text)
fp = OUT / f"{row['id']}.mp3"
fp.write_bytes(audio)
manifest.append({"id": row["id"], "lang": row["lang"],
"file": str(fp), "chars": len(row["text"])})
print(row["id"], "ok")
time.sleep(0.4) # 控制请求节奏,具体限速以账号方案为准
return manifest
if __name__ == "__main__":
all_items = []
for f in sys.argv[1:]:
all_items += run(f)
pathlib.Path("manifest.json").write_text(
json.dumps(all_items, ensure_ascii=False, indent=2), encoding="utf-8")
print("完成,共", len(all_items), "块")
```
运行:
```bash
python synth_batch.py script.zh.jsonl script.en.jsonl script.es.jsonl
```
注意 previous_text / next_text 只在同一语种内部传,跨语言传会让模型在中文里带出英文口音。
第 7 步:拼接与响度统一
先按语种生成 ffmpeg 的拼接清单:
```python
make_list.py
import json, pathlib
m = json.loads(pathlib.Path("manifest.json").read_text(encoding="utf-8"))
lang = "zh"
with open(f"list_{lang}.txt", "w", encoding="utf-8") as f:
for item in m:
if item["id"].startswith(lang + "_"):
f.write(f"file '{item['file']}'\n")
```
```bash
python make_list.py
拼接
ffmpeg -f concat -safe 0 -i list_zh.txt -c copy joined_zh.mp3
统一响度与采样率,便于直接上传平台
ffmpeg -i joined_zh.mp3 \
-af loudnorm=I=-16:TP=-1.5:LRA=11 \
-ar 44100 -b:a 192k final_zh.mp3
```
如果 -c copy 拼接报错,改成重编码:
```bash
ffmpeg -f concat -safe 0 -i list_zh.txt -c:a libmp3lame -b:a 192k joined_zh.mp3
```
三个语种各跑一遍,得到 final_zh.mp3、final_en.mp3、final_es.mp3。
第 8 步:抽检与交付
整批跑完不要直接发布。抽检清单:
- 随机挑 5 个块单独听,确认没有念标签、没有漏字
- 听每个语种的第一块和最后一块,确认音色没有变化
- 听每两个块的接缝处,确认语气没有突然断裂
- 确认成品音频总时长与脚本预估时长接近(差太多说明有块生成失败)
---
常见坑与排错
音色前后不一致
九成情况是参数不一致:某次调用漏传了 voice_settings,或者中途换了 model_id。排查方法是把每次请求的 payload 打到日志里,逐字段比对。另一个原因是块边界断在句子中间,previous_text / next_text 能缓解,但把边界挪到句末更彻底。
情绪标签被当成正文念出来
先确认这个标签在当前 model_id 的支持列表里(以官方文档为准)。其次是位置问题——标签写在句子中间容易失效,写在句首更稳。还有一种情况是翻译环节把标签改写成了别的词,检查 JSONL 里标签是不是原样。
多语种读音错误
专有名词靠"文字注音"解决,比如把品牌名按目标语言的发音习惯拼写后再提交。同一段文本里混两种语言,除非模型明确支持,否则拆成两个块分别合成。
数字和单位读得别扭
统一在脚本阶段就写成朗读形式。2024年3月 写成 二零二四年三月 还是 两千零二十四年三月,取决于你要哪种语感;英文里 $3.5M 写成 three point five million dollars 更稳。这一步在文本层做,比后期修音频便宜。
返回 429 或超时
脚本里已经有指数退避,把 time.sleep(0.4) 的间隔调大、或者分批在不同时间段跑。并发数不要开太高,具体上限以账号方案说明为准。
拼接处有爆音或静音
不同块的输出格式如果被改动过,采样率不一致会让拼接出问题。统一 output_format,拼接后统一重采样到同一采样率即可。
字符计费与标签
标签是否计入计费字符、试听是否消耗额度,这类规则各账号方案不同,以官方页面当前说明为准。批量跑之前先用小样本估一次成本。
---
下一步建议
1. 加字幕时间戳:拿到音频后做一次强制对齐,产出 srt,视频和播客都能直接用。
2. 把脚本与配置进版本管理:script.*.jsonl + config.json + manifest.json 一起提交,任何一期节目都能精确复现。
3. 定时任务化:脚本稳定之后,把 synth_batch.py 挂到定时任务里,新稿子入库就自动出音频。
4. 建一个音色库:为不同内容线(播客、课程、短视频)各固定一个 voice_id 和一组参数,写进文档,避免每次都从头试。
5. 做一次 A/B 听测:同一段文本用两组 voice_settings 各生成一版,找几个真实听众盲听打分,用数据决定长期参数。
6. 留意官方更新:模型标识、支持的标签、音频格式都会随版本迭代变化,定期回官方文档核对一遍配置里的值。
