会议录音怎么转写成带时间轴和说话人的文本?长录音怎么分段处理?
简化版
会议转写要产出的不是一整段文字,而是一条时间轴:每句话有开始时间、结束时间、说话人标签和文本。链路分五步:先把录音或视频统一成识别模型需要的格式(去掉画面、单声道、16kHz);长录音按固定时长切段;每段提交识别并打开说话人分离;把各段结果拼回一条时间轴;逐句落库。
切段带来两个必须处理的问题。第一,每段的时间戳都从 0 开始,拼接时要加上前面各段的累计时长;第二,每段的说话人编号也各自从 0 开始,第一段的 0 号和第二段的 0 号未必是同一个人,标签要带上段号区分,不能直接合并。
详细版
录音 / 视频 -> 预处理(去画面、单声道、16kHz、压缩)-> 按时长切段
-> 每段:上传 -> 提交识别(打开说话人分离)-> 等结果
-> 句子列表(起止时间、说话人编号、文本)
-> 加时间偏移、加段号前缀、全局连续编号 -> 一句一行落库
| 步骤 | 要解决的问题 | 常见做法 |
|---|---|---|
| 预处理 | 格式五花八门,视频体积大 | 抽出音轨,统一采样率、声道和编码 |
| 切段 | 单次请求有时长和体积限制,失败要整段重来 | 按固定时长切,如每段 20 分钟 |
| 识别 | 长音频识别耗时长,同步等待会超时 | 异步任务:提交拿任务 ID,轮询取结果 |
| 说话人分离 | 区分哪几句是同一个人说的 | 识别时打开分离参数,每句带说话人编号 |
| 拼接 | 各段的时间和说话人编号都从 0 开始 | 时间加偏移,标签带段号,序号跨段连续 |
落地要点:
- 时间统一存成毫秒整数,显示时再格式化成「时:分:秒」。
- 偏移量按每段的实际时长累加,不按切段的设定值累加,最后一段通常更短。
- 说话人编号 0 是有效值,判断「没有编号」要判空值,不能判真假。
- 所有分段都没有有效文本时按失败处理,不落一条空结果。
完整版教学
一、转写的产出是一条时间轴
纪要要归纳「谁说了什么」,检索要定位「这句话在第几分钟」,回放要从某句话开始播放,这三件事都要求转写结果带时间和说话人。所以转写的目标数据结构是一张句子表,而不是一段长文本:
| 字段 | 含义 | 例子 |
|---|---|---|
| 序号 | 整场会议里的连续编号 | 57 |
| 开始、结束 | 在整段音频里的绝对位置,单位毫秒 | 1201000、1206500 |
| 说话人标签 | 分离模型给的编号 | 02-0 |
| 文本 | 这一句的转写结果 | 下周三之前把接口联调完 |
整段文本可以由句子表按序号拼出来,反过来却拆不回去。先存句子,再按需要拼全文,是转写落库的基本顺序。
二、预处理:先把格式统一
会议资料可能是手机录的 m4a、会议软件导出的 mp4、录音笔的 wav。识别模型真正需要的只有人声,画面对识别没有任何帮助,还占体积。预处理通常做三件事:丢掉视频轨,混成单声道,重采样到 16kHz。
16kHz 是语音识别最常用的采样率。按采样定理,16kHz 能还原 8kHz 以下的频率,人声的主要能量集中在这个范围内,再高的采样率只会增加体积。算一笔一小时录音的账:
44.1kHz 双声道 16 位 WAV:44100 × 2 字节 × 2 声道 × 3600 秒 ≈ 635 MB
16kHz 单声道 16 位 WAV:16000 × 2 字节 × 1 声道 × 3600 秒 ≈ 115 MB
16kHz 单声道 64kbps MP3:64000 ÷ 8 × 3600 秒 ≈ 28.8 MB
体积降到原来的二十分之一左右,上传时间和存储成本跟着下降。统一格式还有一个工程上的好处:预处理之后,录音和视频是同一种文件,后面的上传、识别、解析代码不用再区分来源。
三、长音频为什么要切段、切多长
一场会议动辄一两个小时,整段提交有三个问题:识别接口往往有单文件时长或体积上限;一次请求失败就要整段重来;整段处理期间没有任何进度可看。所以长录音要先切成若干段,逐段识别。
段长是个取舍:
| 段长 | 好处 | 代价 |
|---|---|---|
| 太短(如 1 分钟) | 单段失败代价小,进度细 | 请求次数多;切点更容易落在句子中间;每段里说话人少,分离依据不足 |
| 太长(如 2 小时) | 请求少,说话人上下文完整 | 容易撞上接口上限;失败重来代价大;进度粗 |
| 适中(如 20 分钟) | 一段里通常有多个说话人的多轮发言,失败只损失一段 | 仍然可能切断一句话 |
按 20 分钟切,一场 2 小时的会议是 6 段;一场 50 分钟的会议是 20、20、10 分钟三段。固定时长切段实现最简单,代价是切点可能正好落在一句话中间;要求更高时可以在设定时长附近找静音点下刀。
四、同步接口与异步任务接口
短音频可以用同步接口,上传后等结果返回。长音频的识别时间可能是几分钟到十几分钟,同步等待会撞上 HTTP 超时,所以录音文件识别一般是异步任务:
提交任务(音频地址 + 参数)-> 立刻返回任务 ID
按间隔查询任务状态 -> 排队中 / 运行中 / 成功 / 失败
成功后取结果 -> 一份 JSON:整段文本、句子列表、音频时长
很多服务的异步接口只接受一个可访问的音频地址,不接受把文件放在请求体里,所以提交之前还要先把音频传到对象存储,拿到地址再提交。轮询要同时设间隔和次数上限:间隔太短浪费请求,没有上限的话,远端任务卡住时本地任务会永远等下去,超过上限应该按超时失败处理。
说话人分离通常是识别请求里的一个开关。打开之后,结果里的每一句会多一个说话人编号;不打开,就只有文字和时间。
五、时间偏移:把各段拼回一条时间轴
每一段送去识别的是一个独立文件,结果里的时间都从这一段的 0 秒算起。拼回整场会议的时间轴,要给每一句加上偏移量:
绝对开始时间 = 段内开始时间 + 前面所有分段的实际时长之和
按 20 分钟切段,第 2 段里 00:00:01 开始的一句,在整场会议里是 1200 + 1 = 1201 秒,也就是 00:20:01。第 3 段的偏移是前两段时长之和,约 2400 秒。
偏移量要用每段的实际时长累加。识别结果一般会返回音频时长;没有返回或者返回值偏小时,可以用这一段里最大的结束时间兜底,保证下一段的偏移不会比上一段的最后一句还早。序号也要跨段连续:第 1 段有 180 句,第 2 段的第一句就是第 181 句,否则按序号排序时两段会交错在一起。
六、说话人编号:每段各自从 0 数
说话人分离是在一次请求的音频内部做聚类:把声音特征相近的句子归成一类,按出现顺序编号 0、1、2。编号只在这一段里有意义,两段之间没有任何对应关系:
| 分段 | 编号 0 实际是 | 编号 1 实际是 |
|---|---|---|
| 第 1 段 | 张三(主持人,先开口) | 李四 |
| 第 2 段 | 李四(这段先开口) | 张三 |
如果拼接时直接沿用编号,第 2 段李四的发言会被算到张三头上。所以标签要带上段号,写成 01-0、02-0,保证不同段的编号不会被误合并;标签到底是谁,交给后续的说话人匹配去定,见「转写出来的说话人标签怎么对应到真人?转写错字怎么人工修订?」。
还有一个很隐蔽的坑:编号 0 是合法值。用 speaker or "A" 这类写法给缺失的编号补默认值时,0 在 Python 和 JavaScript 里都是假值,会被当成「没有编号」换成默认值,0 号说话人的句子就挂到了另一个标签上。这种错误不会报错,只是结果悄悄不对。正确的写法是只在字段缺失(None)时才补默认值。
易错点:切段之后,「时间」和「说话人编号」都变成了段内局部坐标。拼接时时间要加偏移,编号要加段号,两件事缺一件,时间轴就是错的。
七、异常与兜底
转写链路长、依赖外部服务,要把失败变成明确的状态:
- 没有识别出任何文字:所有分段都没有有效文本时,任务按失败处理,不写一条空结果进库,下游的纪要生成也就不会拿空文本去调模型。
- 单段失败:最简单的做法是整个任务失败、整体重试;更细的做法是已成功分段的结果先落库,重试只跑失败的分段,代价是要多维护一层分段状态。
- 每次识别都留记录:一个分段的一次识别对应一条调用日志,记下耗时、请求大小和失败原因,某一段反复失败时能直接定位到是哪一段。
时长和进度也要可见:预处理完成、正在识别第几段、全部完成,每个阶段写一次进度,页面才能告诉用户「还要等多久」。
八、常见误区与追问
- 误区:视频文件直接送去识别就行。 画面对识别没用,只会增加上传体积和耗时,先抽音轨再送。
- 误区:采样率越高识别越准。 16kHz 已经覆盖人声的主要频段,更高的采样率只增加体积,不会明显提高准确率。
- 误区:每段的偏移量按切段设定的时长累加。 最后一段通常更短,切出来的实际时长也可能和设定值有出入,要按每段的实际时长累加。
- 误区:不同分段里编号相同的说话人就是同一个人。 编号是每次请求内部的聚类结果,跨段没有对应关系,要带上段号区分,再由匹配环节合并。
- 误区:说话人编号缺失时写成
speaker or "A"补默认值。 0 号说话人会被当成缺失,要判断是否为空值,而不是判断真假。 - 追问:切点正好落在一句话中间怎么办? 固定时长切段会有这个问题,可以在设定时长附近找静音点下刀,或让相邻两段重叠几秒、拼接时去重;对纪要这类下游任务,偶尔断开一句影响有限。
- 追问:为什么不把整场录音一次提交? 容易撞上接口的时长或体积上限,失败要整段重来,处理期间也没有进度可看。
九、加强记忆
会议转写的产出是一张句子表:序号、起止毫秒、说话人标签、文本,全文由它拼出来。链路记五步:预处理(去画面、单声道、16kHz)、切段(20 分钟左右)、异步识别(先上传拿地址、提交拿任务 ID、有上限地轮询)、拼接、落库。切段之后时间和编号都变成段内坐标,拼接两件事:时间加上前面各段实际时长之和,第 2 段的第 1 秒是第 1201 秒;标签加段号,01-0 和 02-0 不是一个人。最后记住两个兜底:编号 0 是有效值,判空不判假;一个字都没识别出来算失败,不写空结果。
音频帧和文字怎么对齐、时间戳怎么定义,见「音频文本对齐在多模态中解决什么问题?」;转写出来的几万字怎么生成纪要,见「大模型处理长文本摘要时应该如何切分、压缩和校验?」。
项目实战落地
项目里怎么做的
《AI Agent智能会议纪要辅助系统》从会议资料页的录音或视频发起转写,后台任务按下面几步执行:
1 预处理 FFmpeg 用 -vn 丢掉画面,转成单声道、16kHz、64kbps 的 MP3;
-f segment -segment_time 1200 按 20 分钟切段,
-reset_timestamps 1 让每段的时间戳从 0 开始
2 逐段识别 申请上传凭证,把分段传到百炼的临时空间,换回 oss:// 地址;
提交异步识别任务,参数打开 diarization_enabled,页面选了语言时带上 language_hints;
轮询到成功后下载结果 JSON,把每句的起止时间和说话人编号转成内部结构
3 拼接 每句加上前面各段的累计时长,序号跨段连续,说话人标签带上段号
4 落库 一句一行写进 transcript_segment
拼接在解析函数里完成:
# 百炼的说话人编号从 0 开始,0 是有效编号,只有字段缺失时才用 A 代替
speaker = item.get("speaker")
parsed.append({
# 序号按已解析出的条数递增,保证连续
"segment_no": start_no + len(parsed),
# 加上偏移后是整段音频里的绝对时间
"start_ms": offset_ms + start_ms,
"end_ms": offset_ms + end_ms,
# 加分段前缀,每个分段的说话人编号都从 0 开始,01-0 和 02-0 不会被当成同一个人
"speaker_label": speaker_prefix + "-" + (str(speaker) if speaker is not None else "A"),
# 原始文本和可修订文本先写成一样
"original_text": content,
"content": content,
})
下一段的偏移等于前面所有分段的时长之和。每段时长优先用识别结果返回的时长,没有返回或返回值偏小时,用这一段里最大的结束时间兜底。所有分段都没有有效文本时,任务按失败处理,不写空结果。每个分段的一次识别写一行 AI 调用日志,请求大小记的是这个分段 MP3 的字节数。
为什么这样取舍
- 先统一规格。 录音和视频都被转成同一种 MP3 分段,后面的上传、识别、解析代码不再区分资料是录音还是视频。
- 按段提交。 长会议不一次性提交,单段失败也好定位。
- 0 号说话人要单独当心。 最早解析时写的是
item.get("speaker") or "A",0 是假值,0 号说话人被当成「没有编号」改成了 A,同一段录音里出现01-A和01-1两种写法;后来改成只在字段缺失时才用 A,并补了一条测试专门覆盖 0 号说话人。这类问题不报错,只是结果悄悄不对,只能靠测试盯住边界值。
转写出来的标签怎么对上参会人员、错字怎么修订,见「转写出来的说话人标签怎么对应到真人?转写错字怎么人工修订?」。
面试官还会追问
- FFmpeg 为什么放进线程里用同步方式执行,而不直接用 asyncio 的子进程接口?
- 识别结果里没有逐句的时间和说话人、只有一整段文字时,时间轴怎么处理?
- 单个分段提交识别之后,项目最多等多长时间?
学完《AI Agent智能会议纪要辅助系统》,上面这些追问你都会迎刃而解。