抢话先别急着调打断阈值。这套实时对话链路里,抢话来自两个完全不同的地方:一是采集通道,麦克风把扬声器正在播放的声音又收了回去,被语音活动检测当成用户开口;二是打断判定,用户确实说话了,但判定的门槛太松,系统在用户只说了半个字的时候就停止输出。这两种表现看起来一样,改法却相反。先把采集和播放当成两条独立通道分别观察,看是谁先“开口”,就知道该改哪一侧。
建议把抢话问题拆成采集通道与播放通道两条链路分别打点:先确认回采(扬声器声音被麦克风重新收进去)是否存在,再看打断判定是否灵敏。外放接法下,采集通道录到系统自身输出,基本可以判定为回采;耳机下仍抢话,才去收紧语音活动检测的起始帧数、最短语音时长和打断冷却。所有结论都应以日志里的时间戳和帧计数为依据,不要凭主观感受调整参数。
把采集和播放拆成两条独立通道来观察
动手之前先确认一件事:抢话的那个声音,是用户发出来的,还是系统自己播出来又被收回来的。下面的排查顺序表可以先用来定位问题范围,再决定要不要往下读代码。
| 现象 | 先看哪一侧 | 验证动作 | 成立后改哪里 |
|---|---|---|---|
| 系统说到一半自己停 | 采集通道 | 外放接法下播放一段只有系统会说出的内容,看采集录音里是否出现这段内容 | 先解决回采,不要改动打断阈值 |
| 用户刚出声就被抢 | 打断判定 | 记录用户开口时刻与系统停止输出时刻的先后 | 收紧语音活动检测和打断条件 |
| 没人说话也触发打断 | 采集通道 | 静音房间里跑一段,看语音活动检测是否误触发 | 检查设备底噪与增益 |
观察的前提是采集、播放各自绑定明确的设备句柄,不要都走系统默认设备。下面是通用接入骨架,函数名按你用的音频库替换,设备索引通过枚举后填入。
import sounddevice as sd
def list_devices():
for idx, dev in enumerate(sd.query_devices()):
in_ch, out_ch = dev['max_input_channels'], dev['max_output_channels']
if in_ch < 1 and out_ch < 1:
continue
print(idx, 'in=%d out=%d' % (in_ch, out_ch), dev['name'])
# 枚举后手动指定,不要用 default 混着走
CAP_DEV = 3 # 麦克风索引
PLAY_DEV = 5 # 扬声器或耳机索引
cap_stream = sd.InputStream(device=CAP_DEV, channels=1, samplerate=16000,
blocksize=320, callback=on_capture)
play_stream = sd.OutputStream(device=PLAY_DEV, channels=1, samplerate=24000,
blocksize=480, callback=on_play)两条通道分别累计帧数,每秒打印一次。这个动作的目的不是测性能,而是证明两条流在应用层是分开的。
cap_frames = 0
play_frames = 0
def on_capture(indata, frames, t, status):
global cap_frames
cap_frames += frames
def on_play(outdata, frames, t, status):
global play_frames
play_frames += frames
# 另起一个循环每秒输出一次
print('cap=%d play=%d' % (cap_frames, play_frames))验证方式:开始播放、同时保持麦克风采集,两个计数应各自独立单调增长;把播放端静音但不断流,play 计数继续涨而采集内容应该只剩环境噪声。如果一停播放,采集计数就明显变慢甚至归零,说明设备或系统层对两条流做了联动处理,这种情况需要换设备或在系统声音设置里确认没有被“通信设备自动静音”之类的开关干预。这一步只做观察,不改打断参数。
确认是否因为外放导致回采
外放是自问自答最常见的来源:扬声器放出来的声音经过空气和机壳回到麦克风,延迟通常在一小段缓冲到几十毫秒之间,语音活动检测分不清这是用户还是系统。判断方法很直接,不需要仪器。
准备一段只有系统会说的内容,比如让语音合成念一串不常见的口令词,人不要说话,只播放。然后回听或转写采集通道这一路录到的音频:如果里面出现了刚刚播放的口令词,就是回采;如果只有底噪,就是干净的。同一个人、同一段话术,在外放和耳机两种接法下各记录一次,填进下面这张表。
| 接法 | 播放期间采集通道录到的内容 | 是否含系统输出 | 备注 |
|---|---|---|---|
| 外放 | 填你实际听到或转写出的内容 | 是或否 | 记录音量档位、麦克风与扬声器距离 |
| 耳机(麦克风在耳机线上或独立麦克风) | 同上 | 是或否 | 同样的音量档位,便于对照 |
外放时录到系统输出,基本可以确定抢话是回采引起的,接下来该做的是回声消除、降低外放音量、调整麦克风指向或改用耳机,而不是把打断阈值调高——调高阈值只会让用户正常插话也插不进去。如果客户端已经开了回声消除,可以临时关掉再录一次作对照,确认该模块是否真的在起作用的通路上。需要结合具体设备和系统音效设置确认,不同机器的表现差别不小。
在客户端加时间戳看谁先开口
回采排除之后,剩下的问题就是打断判定是否提前触发。这时要在同一时钟下记录三个时刻,建议用单调时钟,避免系统时间跳变影响判断。
import time
t_cap_onset = None # 采集侧首次判到有语音
t_cap_stable = None # 连续若干帧都判到语音,视为用户真的开口
t_play_stop = None # 播放通道实际停止输出(缓冲已排空)
def on_capture(indata, frames, t, status):
global t_cap_onset, t_cap_stable
if vad.is_speech(indata):
if t_cap_onset is None:
t_cap_onset = time.monotonic()
if t_cap_stable is None and vad.hit_frames >= VAD_MIN_FRAMES:
t_cap_stable = time.monotonic()
def on_play(outdata, frames, t, status):
global t_play_stop
if is_silence(outdata) and t_play_stop is None:
t_play_stop = time.monotonic()判定规则可以先用这三条:用户开口时刻明显早于系统停止输出时刻,差值与播放端缓冲加设备延迟相当或更大,倾向于用户真的插话;两个时刻几乎重合,或采集侧开口总在播放开始后的一个固定短窗口内出现,倾向于回采或播放尾音自触发;人耳复听确认没人说话但采集侧仍有开口事件,那一定是采集侧问题,不要去改打断条件。播放端缓冲与设备延迟的估算可以这样来做:播放一个短促的标记音,同时录下采集通道,量一量标记音从播放到被录到的间隔,这个量级就是判定时要扣掉的余量。日志里把三个时间戳和当时的播放状态一起落盘,复现时才有依据。
用耳机复测同一段对话
把设备因素排除,用耳机复测同一段话术,人还是那个人,语速和插话位置尽量保持一样,并且重复几轮,不要只看一次。复测记录里写清楚:这一轮系统是否仍然自己中断、中断发生在第几句、采集侧的开口时刻与播放停止时刻的差值是多少。耳机下不再抢话,说明之前的问题在外放回采这一侧,把外放场景的回声消除或物理隔离做好即可。
耳机下仍然抢话,才轮到收紧打断判定。可以先从这几项试:语音活动检测要求连续命中更多帧才允许打断,例如从两三帧提高到五到八帧;加一个最短语音时长门槛,低于门槛的短促噪声不触发打断;把触发打断的能量阈值改成相对当前噪声底的浮动值,而不是固定值;系统开始输出后设置一个短暂的打断冷却窗口,窗口内只记录不响应;要求打断内容至少包含若干个音节。每一项都只改一个,改完用同一段话术再测,对照日志里开口与停止的差值判断是否调过头——差值长期远大于实际插话延迟,就说明阈值收得太紧,用户正常插话会变得迟钝。具体取值需要结合麦克风增益、房间混响和播放缓冲确认,建议先小步调整再固定。