会议一开就是一小时起步,实时转写里真正麻烦的往往不是模型本身,而是三件事:会话能不能一直保持住、没人说话的那几十分钟要不要继续推流、识别出来的文本按什么粒度落库。Fun-ASR-Realtime 这类边推流边返回中间结果的接口,中间结果本身是不稳定的——同一句话可能被反复改写,所以它更适合送屏幕显示,不适合直接写进数据库。
一小时以上的会议录音,建议整场只建立一条长会话并持续推流,音频按固定分片顺序喂入,空闲时用心跳或静音帧保活,避免被服务端按空闲超时断开。无人说话的片段可以按帧能量判定后停止推流,阈值要结合会场底噪用日志试错确定。中间结果只渲染到界面,终稿才写库;同一发言人相邻终稿按时间间隔和句末标点合并成段,最后用回放录音逐段比对,标出错漏位置。
把会议音频按固定分片持续推入同一条会话
会议场景不要按“一段话一个连接”来设计。每切一次连接,服务端就要重新初始化一次解码状态,上下文也会断掉。做法是整场会议只建一条会话,音频从采集端按固定时长切片后顺序推入同一条连接。
分片可以取 100ms 左右的 PCM 小块,太小会产生大量小包,太大则中间结果返回的节奏变慢。采样率、位深、声道数按接口要求统一,通常 16kHz 单声道 16bit 就够用。下面是一段通用接入骨架,函数名按实际 SDK 或 HTTP 接口替换。
session = asr.open_session(sample_rate=16000, channels=1, interim=True)
def on_audio_frame(frame: bytes):
session.push(frame) # 固定分片顺序推入,不新建会话
def on_interim(text, seg_id):
ui.render_interim(seg_id, text) # 只更新屏幕
def on_final(text, seg_id, start_ms, end_ms):
db_queue.put({...}) # 终稿入队,落库走另一条路径
def keepalive():
while session.alive and not mic_paused:
time.sleep(HB_SEC)
session.ping() # 或推一段静音帧
空闲保活要分两种情况。一类是采集端仍在录音但内容静音,此时继续推静音帧,服务端不会认为连接空闲;另一类是采集端被暂停(麦克风切换、设备重连),这时用 WebSocket ping/pong 或应用层心跳维持,心跳间隔要短于服务端的空闲超时,具体数值需要结合服务端配置确认。建议把连接建立、心跳、断开、重连的时刻都写进日志,事后判断“某段文字缺失”是识别问题还是断连造成的,靠的就是这些时间点。
用音量或静音检测判断是否继续推
全程推流最省事,但会议室没人说话的时段会产生大量无意义请求,也容易让模型在底噪上生成幻觉文本。常见做法是在客户端按帧计算能量,低于阈值就标记为静音,暂停推流。
判定写法可以很简单:对每帧 PCM 求 RMS,换算成 dBFS 后和阈值比较。帧长建议 20–30ms,但不要用单帧决定,而是连续若干帧都低于阈值才认为进入静音,比如 5–8 帧,约 150–250ms,这样不容易吞掉说话人的起音。
import audioop, math
def frame_dbfs(frame: bytes, width: int = 2) -> float:
rms = audioop.rms(frame, width)
if rms == 0:
return -96.0
return 20 * math.log10(rms / 32768.0)
SILENCE_DBFS = -45.0 # 起点值,按会场底噪调整
HANG_FRAMES = 6 # 连续多少帧才算静音
silent_run = 0
def should_push(frame):
global silent_run
if frame_dbfs(frame) < SILENCE_DBFS:
silent_run += 1
else:
silent_run = 0
return silent_run < HANG_FRAMES
阈值怎么试:会议开始前先录十秒现场环境音,把每帧 dBFS 打出来看底噪落在哪个区间,阈值取在底噪之上、正常说话之下。如果日志里反复出现“刚判静音就接上一句话开头”,说明阈值偏高或 hang 帧数偏小,往上调;如果静音段一直切不掉,说明阈值偏低。空调、投影风扇、多人远程接入都会影响底噪,这些取值需要结合具体会场确认,不建议一套参数在所有会议室复用。
中间结果只送屏幕,终稿才落库
边推流边返回的中间结果会被改写:同一句话可能先后出现“这个方案”和“这个方案我们”,如果每次都写库,库里会堆满半成品,事后也分不清哪条是最终版本。分流逻辑就是按回调类型走两条路径:带 interim/partial 标志的只更新前端状态,带 final/is_final 标志的才进入落库队列。中间结果不建议落任何持久化介质,连缓存表也不建议,它的生命周期就是几秒。
def on_result(msg):
if msg.get("type") == "interim":
screen.update(seg_id=msg["seg_id"], text=msg["text"], ts=now_ms())
return # 屏幕路径到此结束,不落库
if msg.get("type") == "final":
db_queue.put({
"meeting_id": meeting_id,
"seg_id": msg["seg_id"],
"speaker": msg.get("speaker"),
"text": msg["text"],
"start_ms": msg["start_ms"],
"end_ms": msg["end_ms"],
"is_final": True,
})
落库时用 (meeting_id, seg_id) 做唯一键并做 upsert,可以在重连或重放的情况下避免重复行。终稿写入建议先入本地队列,再由单独的写库线程批量提交,避免网络抖动把回调线程卡住,进而影响推流节奏。
把同一发言人相邻的终稿按时间戳拼成段落
逐句落库的结果读起来很碎,会议记录通常按段呈现,所以需要做一次拼接。合并条件至少有两条:时间上相邻,即下一句 start_ms 减上一句 end_ms 小于一个间隔阈值,比如 800–1500ms;以及上一句结尾没有句末标点(。!?等),表示话没说完。如果开了说话人分离,还要加一条 speaker 相同。
不要把标点缺失当成唯一条件。中文转写经常整段不加标点,只按标点拼会把整场会议拼成一段。间隔阈值和最大段长都要设上限,比如单段不超过 60 秒或 500 字,超出就强制断开,否则一段横跨半小时,后续校对无法定位。
MAX_GAP_MS = 1200
MAX_SEG_MS = 60000
END_PUNCT = "。!?!?"
def can_merge(prev, cur):
if prev["speaker"] != cur["speaker"]:
return False
if cur["start_ms"] - prev["end_ms"] > MAX_GAP_MS:
return False
if cur["start_ms"] - prev["start_ms"] > MAX_SEG_MS:
return False
return prev["text"][-1:] not in END_PUNCT
def merge(prev, cur):
prev["text"] = prev["text"] + cur["text"]
prev["end_ms"] = cur["end_ms"]
prev["seg_ids"].append(cur["seg_id"])
return prev
拼接后的段落结构建议保留这些字段:meeting_id、para_id、speaker、start_ms、end_ms、text、seg_ids(原始句 id 数组)、created_at。保留 seg_ids 是为了将来能回到原始句级别校对,否则拼接之后就再也追不回某句话来自哪一次回调。
用回放录音比对转写结果,标出错漏位置
实时链路的结果没法靠“读起来挺顺”判断质量,可行的方法是拿回放录音做抽样对照:导出段落表,对着录音逐段听,把差异记在一张固定结构的记录表里。建议至少覆盖会议开头、中间、结尾各一段,先确认有没有整段丢失,再逐句看错漏。
- 段落 id、start_ms、end_ms
- 转写原文
- 听音实际内容,只记有差异的部分
- 错漏类型:同音、串音、截断、漏句、数字或标点
- 位置:句中偏移,或缺失部分的起止时间
- 是否影响理解,人工判断的粗略等级
- 处理动作:加词表、调静音阈值、回捞音频重跑
常见类型里,同音字错误(“施行/实行”“权利/权力”)多半靠热词或后处理词表解决;串音指把会议室另一个人的话或远处的声音识别进了当前说话人,通常和麦克风摆位、说话人分离参数有关;截断是句中或句尾少字,最常见的原因是静音判定过度或连接被断开,这类要回头查分片和保活日志,而不是先动识别参数。
检查记录存在单独的表或文件里,与转写结果用段落 id 关联,下次复测时可以直接比对同一位置的错漏是否消失。这个流程不产出量化结论,它的作用是定位问题属于音频采集、会话保持还是后处理,从而知道该改哪一层。