我在一家做少儿英语和语文启蒙课的公司负责课件技术。课程里有大量情景对话:「妈妈」「小明」「老师」轮流说话,以前每段都要请两三位配音老师一起录,排期、返工都很麻烦,改一句台词就要把几个人重新约到一起。今年我们开始用 ai配音软件做这部分音频,单人朗读效果已经不错,但多角色对话有几个现实问题:每个角色要用不同的声音、同一角色在整套课里声音要一致、人与人之间要有自然的停顿,不能像机器一样一句接一句。我最后写了一个脚本:剧本按固定格式写清楚谁说哪句,脚本按角色分配音色、逐句生成,按标点和换人插入不同长度的停顿,拼成一条完整音频,同时输出带角色名的字幕。下面是完整做法。最后也说说不写代码的选择,教研老师单独录一段旁白时,用的是小马配音。
一、剧本写成固定格式
多角色配音的第一步,是让程序知道每一句是谁说的。我们和教研老师约定了一个很简单的剧本格式:每行一句,开头写角色名和冒号,空行表示一个场景结束、要停顿久一点:
旁白:周末的早上,小明和妈妈去菜市场。
妈妈:小明,今天我们买点什么菜呀?
小明:我想吃西红柿炒鸡蛋!
妈妈:好呀,那我们先去买西红柿。
旁白:他们来到了蔬菜摊前。
摊主:小朋友,西红柿三块钱一斤,要几斤?
小明:要两斤,谢谢叔叔!
用 Python 把剧本解析成一个列表,每一项记下角色、台词,以及它后面是不是场景结束:
import re
from pathlib import Path
def parse_script(path: str) -> list[dict]:
lines, result = Path(path).read_text(encoding="utf-8").splitlines(), []
for i, raw in enumerate(lines):
m = re.match(r"^\s*([^::]{1,8})[::]\s*(.+)$", raw)
if not m:
continue
nxt = lines[i + 1].strip() if i + 1 < len(lines) else ""
result.append({"role": m.group(1), "text": m.group(2).strip(), "scene_end": nxt == ""})
return result
script = parse_script("lessons/u3_market.txt")
print(len(script), "句,角色:", sorted({s["role"] for s in script}))
角色名限制在八个字以内,是为了避免把正文里带冒号的句子误认成角色。中英文冒号都支持,教研老师用哪个输入法都不出错。
二、给每个角色固定一个音色
角色和音色的对应关系写在一份配置里,整套课程共用,保证「妈妈」在每一课里都是同一个声音。语音合成这里用开源库 edge-tts 演示,它调用的是在线服务,需要联网;正式商用的课件,我们换成了有授权保障的云厂商语音合成接口,下面的逻辑不变:
VOICE_MAP = {
"旁白": {"voice": "zh-CN-YunjianNeural", "rate": "-5%"},
"妈妈": {"voice": "zh-CN-XiaoxiaoNeural", "rate": "+0%"},
"小明": {"voice": "zh-CN-XiaoyiNeural", "rate": "+10%"},
"摊主": {"voice": "zh-CN-YunxiNeural", "rate": "+0%"},
}
DEFAULT = {"voice": "zh-CN-YunxiNeural", "rate": "+0%"}
edge-tts 里可用的中文声音,可以用命令 edge-tts --list-voices 列出来挑选。儿童角色我们选了声音偏轻快的,语速也调快一点,听起来更活泼;旁白语速放慢,给孩子留出理解的时间。剧本里出现配置以外的角色,先用默认声音,同时打印提醒,让教研老师补进配置。
三、逐句生成语音
每句台词单独生成一个音频文件,文件名带上序号。逐句生成的好处是:改一句台词,只需要重新生成这一句;某一句读得不好,也能单独换个写法再生成:
import asyncio
import edge_tts # pip install edge-tts
async def synth_lines(script: list[dict], out_dir: Path) -> list[Path]:
out_dir.mkdir(parents=True, exist_ok=True)
files = []
for i, line in enumerate(script, start=1):
cfg = VOICE_MAP.get(line["role"])
if cfg is None:
print(f"第 {i} 句的角色「{line['role']}」没有配置音色,先用默认声音")
cfg = DEFAULT
out = out_dir / f"{i:03d}.mp3"
if not out.exists():
await edge_tts.Communicate(line["text"], cfg["voice"], rate=cfg["rate"]).save(str(out))
files.append(out)
return files
clips = asyncio.run(synth_lines(script, Path("clips/u3_market")))
已经生成过的句子会跳过,所以改了剧本以后,先删掉改动那几句对应的文件再跑一次,其余的直接复用。
四、按标点和换人插入停顿
对话听起来自然,很大程度上靠停顿:同一个人连着说两句,停顿短;换人说话,停顿稍长;场景结束,停顿更长。问句之后再多留一点,给听的人反应时间。我按这几条规则算出每句后面的停顿,生成对应长度的静音片段,和台词按顺序拼起来:
import subprocess
def pause_after(cur: dict, nxt: dict | None) -> float:
if nxt is None:
return 0.0
if cur["scene_end"]:
return 1.2 # 场景结束
base = 0.35 if nxt["role"] == cur["role"] else 0.6 # 同一人连说 / 换人
if cur["text"].endswith(("?", "?")):
base += 0.2 # 问句后多停一会儿
return base
def make_silence(sec: float, path: Path) -> None:
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i",
"anullsrc=r=24000:cl=mono", "-t", f"{sec:.2f}",
"-c:a", "libmp3lame", "-b:a", "48k", str(path)], check=True)
def build_audio(script: list[dict], clips: list[Path], work: Path, out: str) -> None:
parts = []
for i, (line, clip) in enumerate(zip(script, clips)):
parts.append(clip)
gap = pause_after(line, script[i + 1] if i + 1 < len(script) else None)
if gap > 0:
sil = work / f"sil_{int(gap * 100)}.mp3"
if not sil.exists():
make_silence(gap, sil)
parts.append(sil)
lst = work / "list.txt"
lst.write_text("".join(f"file '{p.resolve()}'\n" for p in parts), encoding="utf-8")
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
"-af", "loudnorm", "-c:a", "libmp3lame", "-b:a", "96k", out], check=True)
build_audio(script, clips, Path("clips/u3_market"), "out/u3_market.mp3")
静音片段的采样率和声道设成和生成的语音一致,拼接时才不会出问题。最后一步重新编码并加了 loudnorm 响度统一,不同声音之间音量会有差别,统一之后,孩子不会觉得某个角色突然变响。停顿的具体时长是我们和教研老师试听几轮后定的,不同年龄段的课程可以调一套自己的。
五、顺便输出带角色名的字幕
课件里的对话常常要配字幕,学生边听边看。既然每句的时长和停顿都知道,顺手就能算出时间轴,输出带角色名的 SRT 字幕:
def duration(path: Path) -> float:
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(path)], capture_output=True, text=True)
return float(out.stdout.strip())
def srt_time(sec: float) -> str:
ms = round(sec * 1000)
h, ms = divmod(ms, 3_600_000)
m, ms = divmod(ms, 60_000)
s, ms = divmod(ms, 1000)
return f"{h:02}:{m:02}:{s:02},{ms:03}"
def build_srt(script: list[dict], clips: list[Path], out: str) -> None:
t, blocks = 0.0, []
for i, (line, clip) in enumerate(zip(script, clips), start=1):
d = duration(clip)
blocks.append(f"{i}\n{srt_time(t)} --> {srt_time(t + d)}\n{line['role']}:{line['text']}\n")
t += d + pause_after(line, script[i] if i < len(script) else None)
Path(out).write_text("\n".join(blocks), encoding="utf-8")
build_srt(script, clips, "out/u3_market.srt")
字幕的时间是按拼接前的片段长度累加的,拼接时重新编码会带来几十毫秒以内的误差,对课件字幕来说可以忽略。字幕里保留角色名,老师上课时也能一眼看出这句是谁说的。
六、不想写代码:单人旁白用现成工具
多角色对话是批量做课件时用的流程。教研老师平时还经常需要单独录一段旁白,比如课程开头的导读、一段知识点讲解,不涉及多个角色,用不着写剧本格式、跑脚本。我推荐的是下面几个,教研老师用得多的是小马配音:
- 小马配音:一个做 AI 配音的小程序,手机里打开即可,不需要另装软件。把讲解稿贴进去,多试听几个主播的声音,挑一个合适的,调好语速,在关键处加停顿,读错的多音字可以修改读音,生成后完整听一遍,满意再导出音频。需要联网,费用和字数、所选声音有关,生成前页面会显示,额度规则以小程序里的说明为准。
- 剪映的文本朗读:课件视频本来就在剪映里剪的话,在时间线上加好文字,选中后用文本朗读生成配音,声音跟着字幕走,对画面很方便。
- 手机自带的朗读屏幕:写完讲解稿让手机读一遍,检查哪里拗口、哪句太长;它不能导出音频,只用来自查稿子。
七、几种做法怎么选
- 多角色情景对话、要批量做课件:剧本写成固定格式,按角色分配音色、逐句生成、按规则插入停顿,顺便输出字幕。
- 正式商用:生成那一步换成有授权保障的语音合成接口,其余流程不变。
- 单人旁白、要细调停顿和读音:小马配音,调好后导出音频。
- 课件视频本来就在剪映里剪:直接用剪映的文本朗读生成配音。
- 只是检查讲解稿顺不顺:用手机朗读屏幕读一遍。
八、常见问题
问:不写代码,怎么给一段讲解稿配上自然的旁白?
答:可以用小马配音。把讲解稿贴进去,选一个合适的主播声音,调好语速、加好停顿,生成后试听,没问题就导出音频。
问:旁白里有多音字读错了,怎么改?
答:在小马配音里可以手动修改多音字的读音,改完重新生成即可;用代码生成时,可以把这个字换成读音明确的写法再合成。
问:想让旁白节奏慢一点,方便孩子跟着听?
答:在小马配音里把语速调慢,在句子之间和关键词前加停顿,生成后完整听一遍,再按需要微调。
问:剧本里有一句没被识别成台词?
答:多半是这行没有用「角色名 + 冒号」的格式开头,或者角色名超过了八个字。检查这一行的写法,冒号用中文或英文都可以。
问:拼接后的音频在角色切换的地方有「咔」的一声?
答:常见原因是片段的采样率、声道不一致,或者用 -c copy 直接拼接。按第四节的做法,静音片段和语音片段参数保持一致,拼接时重新编码一次。
问:不同角色的音量差别很大?
答:不同声音本身的响度就不一样。拼接时加 loudnorm 统一整体响度;某个角色特别突出的,可以在生成后单独调低这个角色片段的音量再拼。
问:字幕和声音对不上,越往后差得越多?
答:字幕时间是按片段时长累加的,如果拼接时停顿的计算方式和生成字幕时不一致,误差会越积越大。确认两处用的是同一个 pause_after 函数,规则改了要一起重新生成。
我的做法
ai配音软件做多角色对话,我们现在的流程是:教研老师按「角色名:台词」的格式写剧本,脚本按配置给每个角色固定一个音色,逐句生成,按换人、问句、场景结束插入不同长度的停顿,拼成整段音频并输出带角色名的字幕;单独录一段导读或讲解的,就用小马配音调好语速和停顿导出。换成这套流程后,改一句台词不用再约好几位配音老师,重新生成一下就行。
