把一段双人访谈稿丢进脚本,几分钟后拿到一条带上停顿、语气分明的 MP3——这件事用 Gemini 的 TTS 接口可以做,而且不需要任何配音软件。这篇教程走完从纯文本到成品音频的完整链路:单角色出声、多角色对话、情绪指令、长文切分拼接、导出播客或短视频配音。
> 说明:TTS 模型名、可用音色清单、单次请求长度上限、配额与计费都以官方文档当前版本为准。文中用环境变量 TTS_MODEL 统一管理模型名,避免写死在代码里。
这篇能做出什么
- 一条 5~20 分钟的音频文件,含 2~4 个区分明显的角色音色
- 能在脚本里用自然语言控制语气,例如「压低声音,说得慢一点」
- 一套可复用的分块 + 拼接流水线,脚本再长也不会被单次请求长度卡住
- 两种成品导出:播客用的 MP3,短视频用的逐行配音 + 时间轴
前置条件清单
- 一个可用的 Gemini API Key,并已开通 TTS 相关模型的调用权限
- Python 3.9 及以上(只用标准库,本文代码不依赖第三方包)
- 本机安装
ffmpeg与ffprobe,命令行能直接调用 - 一段准备好的文本脚本,建议先用 300~800 字的小样本试通
- 一个空目录,用来存放分块 WAV、中间清单和最终成品
把 API Key 放进环境变量,不要写进代码文件:
```bash
export GEMINI_API_KEY="你的密钥"
export TTS_MODEL="官方文档中支持语音合成的模型名"
```
第一步:跑通一句话的单角色合成
先用最简单的请求确认链路通畅。接口是标准的 generateContent,区别在于要求返回音频模态,并指定音色。
```bash
curl -s -X POST \
"https://generativelanguage.googleapis.com/v1beta/models/${TTS_MODEL}:generateContent" \
-H "x-goog-api-key: ${GEMINI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"contents": [{"parts": [{"text": "这是一段单角色测试旁白。"}]}],
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {
"voiceConfig": {
"prebuiltVoiceConfig": {"voiceName": "Kore"}
}
}
}
}' > out.json
```
返回的 out.json 里,音频在 candidates[0].content.parts[*].inlineData.data,是 Base64 编码的裸 PCM,不是可以直接双击播放的文件。下面这段 Python 把它落成真正的 WAV:
```python
import os, re, json, base64, wave
def extract_audio(resp):
"""从响应里取出 PCM 字节和采样率,兼容驼峰与下划线两种字段名。"""
parts = resp["candidates"][0]["content"]["parts"]
for p in parts:
inline = p.get("inlineData") or p.get("inline_data")
if not inline:
continue
mime = inline.get("mimeType") or inline.get("mime_type") or ""
data = inline["data"]
if isinstance(data, str):
data = base64.b64decode(data)
m = re.search(r"rate=(\d+)", mime)
rate = int(m.group(1)) if m else 24000
return data, rate
raise RuntimeError("响应中没有音频:" + json.dumps(resp)[:500])
def write_wav(path, pcm, rate, channels=1, sample_width=2):
with wave.open(path, "wb") as w:
w.setnchannels(channels)
w.setsampwidth(sample_width)
w.setframerate(rate)
w.writeframes(pcm)
```
采样率不要凭感觉写死。响应里的 mimeType 通常包含 rate=24000 这类信息,从中解析出来最稳。
第二步:多角色对话怎么配
多角色的关键是把「角色名 → 音色」的映射交给 multiSpeakerVoiceConfig,脚本本身用「角色名: 台词」的格式书写。
```json
{
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{"speaker": "小夏", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}},
{"speaker": "老周", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Charon"}}}
]
}
}
}
}
```
对应的脚本文本:
```text
TTS the following conversation between 小夏 and 老周.
小夏: 今天想聊聊长文切分这件事,很多人第一步就做错了。
老周: 嗯,先别急着切。你得先想清楚每一段想让人听出什么情绪。
小夏: 那如果一段话特别长呢?
老周: 拆。但拆的位置比拆的数量重要得多。
```
三个细节决定成败:
1. 名字必须完全一致。 配置里写 小夏,脚本里写 小夏:,多一个空格、用英文冒号混排,都可能让模型把角色名当台词念出来。
2. 音色要拉开差异。 挑音色时优先选音区、语速感差别明显的组合,听感上的角色区分度会比音色本身的「好听程度」更重要。常用音色如 Kore、Puck、Charon 等,完整清单以官方文档为准。
3. 保留一行自然语言说明。 开头的 TTS the following conversation between ... 这类提示有助于模型进入对话模式,不建议省掉。
第三步:情绪与语气指令
语气控制不靠参数,靠写在文本里的自然语言指令。文档示例里的 Say cheerfully: ... 就是这个思路,指令本身不会被朗读出来。
```text
用纪录片旁白的语气,语速偏慢,句子之间留出明显停顿。
四十年前,这条胡同里还住着三十多户人家。后来,一间一间地空了。
```
可以调的方向大致有这几类,一次用一到两个就够,堆多了反而互相打架:
| 维度 | 写法示例 |
|---|---|
| 情绪 | 平静地、兴奋地、压低声音、带着笑意 |
| 语速 | 语速偏慢、加快一点、拖长尾音 |
| 停顿 | 句间停顿一拍、逗号处不换气 |
| 音色质感 | 更低沉、更明亮、带一点沙哑 |
| 角色设定 | 像在给小朋友讲睡前故事、像在念财报 |
多角色场景下,情绪可以逐行写进台词:
```text
小夏: (轻快,语速略快)所以我们今天就把这条流水线跑通。
老周: (放慢,音量压低)跑通之前,先说清楚哪里会翻车。
```
括号里的舞台提示有可能被念出来。稳妥做法是在整段文本前加一句「括号内为语气提示,不要朗读」,或者先在短样本上验证一遍再批量跑。
第四步:长文切分
单次请求有输入长度上限,具体数值以官方文档为准。长稿必须切分,但切分点选在哪里,直接决定成品听起来像不像一个人念的。
切分顺序是:先按空行分段,段落仍然超长再按句号、问号、叹号切句,最后按字符数装箱。这样能保证切点尽量落在语义边界上,而不是句子中间。
```python
import re
SENT = re.compile(r"(?<=[。!?;!?;])")
def pack(segments, max_chars):
out, buf = [], ""
for s in segments:
s = s.strip()
if not s:
continue
if buf and len(buf) + len(s) > max_chars:
out.append(buf)
buf = s
else:
buf += s
if buf:
out.append(buf)
return out
def split_script(text, max_chars=600):
chunks = []
for para in re.split(r"\n\s*\n", text):
para = para.strip()
if not para:
continue
if len(para) <= max_chars:
chunks.append(para)
else:
chunks.extend(pack(SENT.split(para), max_chars))
return chunks
```
max_chars 是经验值,不是 token 数。中文场景下从 400~800 字符起步,实际跑一遍看有没有被截断,再往上调。
每一块都要自带风格指令和角色配置。 模型在每次请求之间没有记忆,第二块不会自动继承第一块的语气,也不会记得谁是「小夏」。所以要把「风格前缀 + 角色映射」跟着每一块一起发出去。
```python
def build_payload(text, style=None, voice=None, speakers=None):
prompt = f"{style}\n\n{text}" if style else text
if speakers:
speech = {"multiSpeakerVoiceConfig": {"speakerVoiceConfigs": [
{"speaker": name, "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": v}}}
for name, v in speakers.items()
]}}
else:
speech = {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}}
return {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"responseModalities": ["AUDIO"], "speechConfig": speech},
}
```
最后加一层断点续跑:每块合成完立刻落盘,文件已存在就跳过。这样中途限流或超时,不用从头再来。
```python
import os, json, time, urllib.request, urllib.error
ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/"
f"{os.environ['TTS_MODEL']}:generateContent")
def call_tts(payload, retries=4):
body = json.dumps(payload).encode("utf-8")
for i in range(retries):
req = urllib.request.Request(ENDPOINT, data=body, headers={
"Content-Type": "application/json",
"x-goog-api-key": os.environ["GEMINI_API_KEY"],
})
try:
with urllib.request.urlopen(req, timeout=180) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "ignore")
if e.code in (429, 500, 503) and i < retries - 1:
time.sleep(2 ** i * 2)
continue
raise RuntimeError(f"HTTP {e.code}: {detail}")
```
第五步:拼接成一条完整音频
分块产出的是 chunk_01.wav、chunk_02.wav 这样的零散文件。拼接之前先做两件事:每块前后补一小段静音,避免句尾被切得太生硬;所有块使用同一采样率,否则拼接会变速或爆音。
```bash
生成 0.35 秒静音(采样率与你的分块保持一致)
ffmpeg -y -f lavfi -i anullsrc=r=24000:cl=mono -t 0.35 -c:a pcm_s16le silence.wav
逐块转成统一格式,并各补一小段静音
for f in chunk_*.wav; do
ffmpeg -y -i "$f" -af "adelay=120|120,apad=pad_dur=0.25" \
-ar 24000 -ac 1 -c:a pcm_s16le "pad_$f"
done
```
拼接用 ffmpeg 的 concat 协议:
```bash
printf "file '%s'\n" "$PWD"/pad_chunk_*.wav > list.txt
ffmpeg -y -f concat -safe 0 -i list.txt -c:a pcm_s16le joined.wav
```
拼接处如果出现轻微「咔哒」声,通常是波形突变。可以在每块首尾各加 20~30 毫秒淡入淡出:
```bash
ffmpeg -y -i in.wav -af "afade=t=in:d=0.03,areverse,afade=t=in:d=0.03,areverse" out.wav
```
第六步:导出播客与短视频配音
播客版:统一响度后转 MP3,并写入元数据。播客常见的响度目标是 -16 LUFS 附近,短视频平台偏好稍响一些,大约 -14 LUFS,具体以各平台发布规范为准。
```bash
ffmpeg -y -i joined.wav \
-af "loudnorm=I=-16:TP=-1.5:LRA=11" \
-c:a libmp3lame -b:a 192k \
-metadata title="第 1 期:长文切分怎么做" \
-metadata artist="示例播客" \
-metadata album="示例播客 第一季" \
episode.mp3
```
短视频版:不要一次合成整段。逐行合成,每行一个文件,这样每行的时长就是现成的时间轴,字幕、画面切换点都能直接对齐。
```python
import subprocess
def duration(path):
out = subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", path,
])
return float(out.strip())
def to_srt(lines, path="subtitle.srt"):
t, rows = 0.0, []
for i, (text, wav) in enumerate(lines, start=1):
d = duration(wav)
fmt = lambda s: f"{int(s//3600):02d}:{int(s%3600//60):02d}:{s%60:06.3f}".replace(".", ",")
rows.append(f"{i}\n{fmt(t)} --> {fmt(t + d)}\n{text}\n")
t += d
open(path, "w", encoding="utf-8").write("\n".join(rows))
```
拿到 SRT 后,把音频拼成一条整轨,导入剪辑软件,字幕轨直接导入即可。此时音频总时长和字幕总时长天然一致,不需要手动对轴。
常见坑与排错
| 现象 | 原因与处理 |
|---|---|
| 保存的 WAV 播放全是噪音 | 把裸 PCM 直接写成了 .wav,缺少 WAV 头。用 wave 模块写,不能直接写文件 |
| 声音变调、速度不对 | 采样率猜错了。从响应的 mimeType 中解析 rate 值 |
| 只有前几秒有声音,后面消失 | 单块太长被截断。缩小 max_chars,或按段落再拆 |
| 模型把角色名念了出来 | 脚本里的名字与配置里的 speaker 不完全一致,或格式不统一。改用逐行合成更可控 |
| 括号里的舞台提示被朗读 | 在文本开头显式声明「括号内为语气提示,不要朗读」,并先在短样本上验证 |
| 语气完全没变化 | 指令被放在了另一块里。风格前缀必须和该块正文放在同一次请求 |
| 后半小时语气明显漂移 | 每块都带上风格前缀,且切点尽量落在段落或场景切换处 |
| 返回 429 或 503 | 触发限流。串行或低并发执行,指数退避重试,配合断点续跑 |
| 拼接后响度忽大忽小 | 各块单独听正常,拼起来不齐。最终统一跑一遍 loudnorm |
| 中文数字、英文缩写读错 | 在脚本里改写成读音,例如把 3.5 写成「三点五」,把缩写拆成字母或中文读法 |
下一步建议
顺着这条流水线往下走,有三个方向投入产出比不错。一是加背景音乐和响度闪避,用 sidechaincompress 让音乐在有人声时自动压低;二是把整季脚本批量化,脚本、音色映射、输出路径都写进一个 YAML,一条命令出一整季;三是把逐行合成的时间轴接到视频上,自动生成分镜时长,画面切换点就不用一个个手动拖了。
先拿一段 500 字的双人对话跑通全流程,再把手上的真实长稿喂进去。第一次跑通之后,剩下的都是参数微调。
