用TTS生成真实考试听力:多音色、停顿与时间戳全解析

用TTS和ffmpeg将听力脚本自动构建为带时间戳的考试音频,实现逐行文本与实时高亮。
本文介绍了如何为德语B1/B2考试平台ExamReady构建听力练习功能。作者将录音描述为结构化片段(语音、静音、章节),使用ElevenLabs的TTS逐片段生成语音,再用ffmpeg拼接成完整音频。关键设计包括:静音必须显式定义以模拟考试读题时间;时间戳从生成音频的实际时长测量而非文本估算,确保逐行文本和实时高亮准确同步;脚本作为唯一真相来源,音频是构建产物。文章还提醒TTS音频与真实考试录音存在差异,需人工检查数字和日期发音。
用TTS生成真实考试听力:多音色、停顿与时间戳全解析
语言考试的听力部分最残酷的地方在于:录音不会等你。它按自己的节奏播放,你答完一题,下一题紧接着开始。正因如此,给学习者做练习材料时,一个干巴巴的音频文件远远不够——它必须像真实考试那样,包含指令、读题时间以及第二遍播放。
ExamReady 是一个面向德语 B1/B2 备考的平台,我在这上面从零搭建了听力练习功能。这篇文章聊聊我如何用 TTS 生成语音、用 ffmpeg 拼接音频,并在构建过程中提取时间戳,让 App 能在播放时显示逐行文本并高亮当前朗读的内容。
(注:ExamReady 为独立项目,未获得 ÖSD、ÖIF 或歌德学院的官方认可。)

问题拆解:听力练习到底需要什么
我需要一套能同时满足以下条件的听力练习:
- 多位说话人,且音色要有明显区分
- 包含考试框架:指令播报、读题时间、第二遍播放
- 学习者作答后可以查看逐行文本,最好还能实时高亮当前朗读句
我不想为每段录音在 DAW(数字音频工作站)里手动剪辑。脚本一改,就得重新导出,而且很难追踪每句话在音频里的位置。我希望音频是一个构建产物——从文本脚本自动生成,而不是手工拼接的结果。
这个思路转变很关键:把音频当作代码来管理。脚本是唯一的真相来源,音频只是它的编译输出。这样一来,修改一句话只需要改脚本、重新构建,所有关联数据(时间戳、文本对齐)同步更新。
第一步:把录音描述成数据
每段录音由一系列「片段」组成。一个片段要么是语音(指定音色和文本),要么是静音(指定秒数),要么是章节标记。
type Segment =
| { kind: "speech"; voice: string; text: string }
| { kind: "silence"; seconds: number }
| { kind: "chapter"; title: string };
const recording: Segment[] = [
{ kind: "chapter", title: "Instructions" },
{ kind: "speech", voice: "narrator", text: "You will hear a short conversation twice." },
{ kind: "silence", seconds: 5 }, // 示例值
{ kind: "chapter", title: "Conversation" },
{ kind: "speech", voice: "speakerA", text: "..." },
{ kind: "speech", voice: "speakerB", text: "..." },
];
这里最重要的设计是:学习者听到的一切都在这个列表里,包括静音。静音不是交给 TTS 引擎随意决定的,如果考试给了读题时间,那段时间就是一个显式的片段。
音色只是映射到 TTS 语音配置的名称。一段两人对话使用两个不同的音色,旁白交给第三个音色。这种结构让「谁在说话」变成数据的一部分,而不是后期剪辑时才考虑的事情。
TTS(Text-to-Speech,文本转语音)是将书面文本自动转换为自然语音的技术。现代 TTS 基于深度学习,能够生成接近真人的发音、语调和节奏。在语言学习场景中,TTS 的优势在于:可按需生成任意文本的语音,支持多音色(如不同性别、口音),并且输出格式统一、可控。不过,TTS 的朗读节奏由模型内部决定,通常无法在句子之间插入精确到秒的静音——这正是文章强调静音必须作为独立片段处理的原因。此外,TTS 对数字、日期、专有名词的发音可能不符合预期,需要人工试听校对。
第二步:逐片段生成语音
语音部分我使用 ElevenLabs 的 TTS。每个语音片段生成独立的音频片段,构建步骤的伪代码如下:
clips = []
for seg in script.segments:
if seg.kind == "speech":
clips.append(tts(voice=VOICES[seg.voice], text=seg.text))
elif seg.kind == "silence":
clips.append(make_silence(seg.seconds))
# chapters produce no audio, only a marker
逐片段生成而不是一次性请求整段对话,有个实际的好处:我能精确控制每个音色的起止点,并且可以在任意两句话之间插入静音。如果整段请求,TTS 引擎的停顿节奏由模型决定,你很难插入精确到秒的读题时间。
代价是片段多了之后构建时间变长,而且需要保证所有片段格式一致(后面会提到)。但换来的控制力值得。
第三步:ffmpeg 拼接与时间戳生成
拿到所有片段后,ffmpeg 将它们拼接成单一文件。真正关键的部分是:构建过程还会输出每个片段的起始时间。
做法是测量每个片段的真实时长,并维护一个累计时间轴:
t = 0.0
timeline = []
for seg, clip in zip(script.segments, clips):
timeline.append({"segment": seg.id, "start": round(t, 2)})
t += duration_of(clip) # measured from the real audio, not estimated
核心细节在注释里:时长从真实音频中测量,而不是估算。如果根据文本长度猜测时长,时间戳会漂移,高亮在几句之后就会错位。读取每个生成片段的实际时长,才能让时间轴保持准确。
拼接本身是一次 ffmpeg 调用,按顺序读取片段列表:
ffmpeg -f concat -safe 0 -i clips.txt -c copy listening.mp3
使用流拷贝(stream copy)的前提是所有片段的格式和参数一致,所以让静音片段匹配语音片段的参数很重要。如果参数不一致,重新编码是更稳妥的退路。
ffmpeg 是一个开源的跨平台音视频处理工具,广泛用于格式转换、剪辑、拼接、流处理等。其中的 -f concat 模式允许将多个媒体文件按列表顺序拼接成一个文件,而 -c copy(流拷贝)表示不重新编码,直接复制原始音视频流,因此速度极快、无质量损失。但流拷贝要求所有输入片段的编码格式、采样率、声道数等参数完全一致;如果参数不同,拼接会失败或产生异常,此时需要去掉 -c copy 让 ffmpeg 重新编码。文章中使用 ffmpeg 正是看中了它的高效与脚本化能力,使音频构建可以完全自动化。
时间戳的两大用途
构建输出的起始时间成为附加在录音上的数据,App 在两个场景中使用它们:
- 逐行文本:考试式播放结束后,学习者可以逐行阅读文本,每一行都链接到音频中对应的位置。
- 实时高亮:播放时,App 将当前播放时间与起始时间比对,高亮匹配的文本。
因为时间轴来自生成音频的同一份脚本,文本和音频不会彼此漂移。修改一行文本并重新构建,所有内容会一起重新生成。这种「单一数据源」的设计消除了手工对齐最常见的错误来源。
时间戳(timestamp)在这里指每个语音片段在最终音频文件中的起始播放时刻,通常以秒或毫秒为单位。有了精确的时间戳,应用程序就能将音频播放进度与文本行对应起来:当播放到某一时间点时,高亮显示对应的文本行;或者允许用户点击某行文本跳转到音频的相应位置。时间戳的准确性直接决定了高亮同步的质量。由于 TTS 引擎生成的每个片段时长是未知的,必须从实际生成的音频文件中读取时长(例如通过 ffprobe 或音频库),再累加得到时间轴。任何基于文本长度或语速的估算都会引入误差,并且误差会随着片段增多而累积,最终导致高亮严重错位。
把考试框架烤进文件里
我把考试框架直接放进了文件本身:指令播报、读题停顿、第二遍播放。这是一个刻意的选择。学习者在练习时应该体验与考试相同的节奏,而不是一个节奏由自己掌控的干净录音。
代价是文件不够灵活。如果想要一个没有停顿的版本,就得构建第二个文件。对考试练习来说,我认为这是正确的权衡;而且因为一切都从脚本生成,第二次构建的成本很低。
需要注意的几个坑
以下是几点提醒,而非血泪教训:
- TTS 音色不是真实考试音频。它们清晰一致,对练习有好处,但真实录音可能有不同口音、语速和背景噪音。我不会声称生成音频与考试当天听到的完全一致。
- 检查发音。人名、数字、日期和时间在听力题中非常重要,TTS 可能读出你意想不到的发音。值得亲耳听一遍输出,而不是盲目信任。
- 脚本是唯一真相来源。不要手工修补音频。如果哪里不对,修脚本并重新构建,否则时间戳将不再匹配。
核心要点
- 把录音描述为数据:带音色和文本的语音片段、静音、章节。
- 让静音显式化。它们是考试的一部分,不该交给引擎决定。
- 从生成音频中测量片段时长,绝不用文本估算。
- 从构建输出时间轴。逐行文本和实时高亮几乎零成本获得。
- 亲耳听输出。流水线跑到无报错,仍可能把数字读错。
如果你也做过类似的事情——无论是考试还是任何需要与文本保持同步的音频——我很想知道你如何管理时间轴。
背景补充
DAW(Digital Audio Workstation,数字音频工作站)是用于录制、编辑、混音和制作音频的专业软件,如 Audition、Pro Tools、Logic Pro 等。在 DAW 中,用户可以手动切割、移动、拼接音频片段,并精确调整静音和音量。传统上,制作考试听力音频往往需要在 DAW 中逐句剪辑、插入停顿,过程繁琐且难以复用。文章提出将音频视为“构建产物”,用代码脚本描述片段并自动生成音频,实际上是用编程替代了 DAW 中的手工操作,从而获得可重复、可版本控制的流水线。这种方式特别适合需要频繁修改文本或调整节奏的场景。
相关推荐

M&M's斥资数百万换掉人工蓝色素,为什么这么难?
玛氏公司投入数百万美元,试图用螺旋藻天然色素替代M&M豆中的人工蓝色素,却因物料浓稠堵塞管道、残留物引发霉菌风险而被迫改造整条产线。本文解析这场配方迁移背后的工程难题、消费者与监管推力,以及"无人工色素不等于健康"的清醒判断。

Meta全球AI开发者黑客松组队需求:计算机视觉与医疗AI方向
Reddit开发者发帖为Meta Global AI Developer Hackathon招募队友,方向聚焦计算机视觉与医疗AI。本文分析这一组队信号背后的赛道逻辑、技能互补需求与黑客松选题策略。

Claude 多个模型报错:Fable 5.1、Sonnet 5.5、Haiku 5.5 故障复盘
Claude Fable 5.1、Sonnet 5.5、Haiku 5.5 及 Opus 5.5 fast mode 出现错误率升高,官方 17 分钟内完成定位与恢复。本文梳理故障时间线、多模型连带故障的技术原因,以及对开发者重试与降级策略的实际启示。