会议录音实时转写、Fun-ASR-Realtime 边推流边返回中间结果

文章导读
会议一开就是一小时起步,实时转写里真正麻烦的往往不是模型本身,而是三件事:会话能不能一直保持住、没人说话的那几十分钟要不要继续推流、识别出来的文本按什么粒度落库。Fun-ASR-Realtime 这类边推流边返回中间结果的接口,中间结果本身是不稳定的——同一句话可能被反复改写,所以它更适合送屏幕显示,不适合直接写进数据库。
📋 目录
  1. A 把会议音频按固定分片持续推入同一条会话
  2. B 用音量或静音检测判断是否继续推
  3. C 中间结果只送屏幕,终稿才落库
  4. D 把同一发言人相邻的终稿按时间戳拼成段落
  5. E 用回放录音比对转写结果,标出错漏位置
A A

会议一开就是一小时起步,实时转写里真正麻烦的往往不是模型本身,而是三件事:会话能不能一直保持住、没人说话的那几十分钟要不要继续推流、识别出来的文本按什么粒度落库。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 或应用层心跳维持,心跳间隔要短于服务端的空闲超时,具体数值需要结合服务端配置确认。建议把连接建立、心跳、断开、重连的时刻都写进日志,事后判断“某段文字缺失”是识别问题还是断连造成的,靠的就是这些时间点。

用音量或静音检测判断是否继续推

全程推流最省事,但会议室没人说话的时段会产生大量无意义请求,也容易让模型在底噪上生成幻觉文本。常见做法是在客户端按帧计算能量,低于阈值就标记为静音,暂停推流。

会议录音实时转写、Fun-ASR-Realtime 边推流边返回中间结果

判定写法可以很简单:对每帧 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 标志的才进入落库队列。中间结果不建议落任何持久化介质,连缓存表也不建议,它的生命周期就是几秒。

会议录音实时转写、Fun-ASR-Realtime 边推流边返回中间结果
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 是为了将来能回到原始句级别校对,否则拼接之后就再也追不回某句话来自哪一次回调。

会议录音实时转写、Fun-ASR-Realtime 边推流边返回中间结果

用回放录音比对转写结果,标出错漏位置

实时链路的结果没法靠“读起来挺顺”判断质量,可行的方法是拿回放录音做抽样对照:导出段落表,对着录音逐段听,把差异记在一张固定结构的记录表里。建议至少覆盖会议开头、中间、结尾各一段,先确认有没有整段丢失,再逐句看错漏。

  • 段落 id、start_ms、end_ms
  • 转写原文
  • 听音实际内容,只记有差异的部分
  • 错漏类型:同音、串音、截断、漏句、数字或标点
  • 位置:句中偏移,或缺失部分的起止时间
  • 是否影响理解,人工判断的粗略等级
  • 处理动作:加词表、调静音阈值、回捞音频重跑

常见类型里,同音字错误(“施行/实行”“权利/权力”)多半靠热词或后处理词表解决;串音指把会议室另一个人的话或远处的声音识别进了当前说话人,通常和麦克风摆位、说话人分离参数有关;截断是句中或句尾少字,最常见的原因是静音判定过度或连接被断开,这类要回头查分片和保活日志,而不是先动识别参数。

检查记录存在单独的表或文件里,与转写结果用段落 id 关联,下次复测时可以直接比对同一位置的错漏是否消失。这个流程不产出量化结论,它的作用是定位问题属于音频采集、会话保持还是后处理,从而知道该改哪一层。