跳到主内容
快讯直播
AI智模界
教程

用 Gemini TTS 做多角色有声内容:音色、情绪与分块

把一段双人访谈稿丢进脚本,几分钟后拿到一条带上停顿、语气分明的 MP3——这件事用 Gemini 的 TTS 接口可以做,而且不需要任何配音软件。这篇教程走完从纯文本到成品音频的完整链路:单角色出声、多角色对话、情绪指令、长文切分拼接、导出播客或短视频配音。

> 说明:TTS 模型名、可用音色清单、单次请求长度上限、配额与计费都以官方文档当前版本为准。文中用环境变量 TTS_MODEL 统一管理模型名,避免写死在代码里。

这篇能做出什么

  • 一条 5~20 分钟的音频文件,含 2~4 个区分明显的角色音色
  • 能在脚本里用自然语言控制语气,例如「压低声音,说得慢一点」
  • 一套可复用的分块 + 拼接流水线,脚本再长也不会被单次请求长度卡住
  • 两种成品导出:播客用的 MP3,短视频用的逐行配音 + 时间轴

前置条件清单

  • 一个可用的 Gemini API Key,并已开通 TTS 相关模型的调用权限
  • Python 3.9 及以上(只用标准库,本文代码不依赖第三方包)
  • 本机安装 ffmpegffprobe,命令行能直接调用
  • 一段准备好的文本脚本,建议先用 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. 音色要拉开差异。 挑音色时优先选音区、语速感差别明显的组合,听感上的角色区分度会比音色本身的「好听程度」更重要。常用音色如 KorePuckCharon 等,完整清单以官方文档为准。

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.wavchunk_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 字的双人对话跑通全流程,再把手上的真实长稿喂进去。第一次跑通之后,剩下的都是参数微调

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。