给一台落地式数字人加「有人靠近就欢迎」的功能,代码量不到一百行,但真正上线后,客户提的投诉往往只有两句:「它一直重复说同一句话」和「我想让它停,它不听」。
这两个症状看起来像一个毛病,其实是三个互相独立的问题叠在一起:一个状态位写错了复位条件,一个布尔字段被复用成了两种含义,一组语音打断门槛按「正常对话」标定、却没考虑「四十秒的整段播报」。下面按现场真实排查顺序拆开讲,参数与改法都可直接照抄。
触发逻辑本身很朴素:前端每 600ms 抓一帧摄像头画面,用帧差判断画面里有没有人;判定有人,就向语音接口发一次播报请求。
问题出在「防重复」的那个状态位上。旧写法用的是一面旗子,语义是「现在允许触发吗」:
let armed = true; // 允许触发
let lastTrigger = 0;
const COOLDOWN = 30000; // 30 秒冷却
// 每 600ms 一帧
if (!hasPerson) { armed = true; return; } // ← 病灶:无人一帧就复位
if (armed && Date.now() - lastTrigger > COOLDOWN) {
armed = false;
lastTrigger = Date.now();
speak(WELCOME_TEXT);
}
这行 armed = true 是致命的。摄像头帧差检测在光照变化、人影晃动、压缩噪声下本来就会偶尔漏一帧,漏一帧就等于「人走了」,旗子被重新举起;而 COOLDOWN 只有 30 秒,比一段完整欢迎词还短。
于是形成闭环:播到第 28 秒,检测抖了一帧 → 旗子复位 → 冷却早已过 → 再次触发 → 从头再播。人不动,它就一直播。
不要用时间冷却来描述「去过重来」,要用到访这个语义。三个变量足够:
let welcomed = false; // 本次到访是否已欢迎过
let leftSince = null; // 最近一次「无人」的时间戳
const LEAVE_HOLD = 8000; // 连续无人满 8 秒,才算真的走了
if (hasPerson) {
leftSince = null; // 有人:清空离开计时
if (!welcomed && Date.now() - lastTrigger > COOLDOWN) {
welcomed = true; lastTrigger = Date.now(); speak(WELCOME_TEXT);
}
} else {
if (leftSince === null) leftSince = Date.now();
if (Date.now() - leftSince > LEAVE_HOLD) welcomed = false; // 只有久走才重置
}
关键差别是复位条件从「一帧」变成「连续 8 秒」。抖动最多丢几百毫秒的检测,够不到 8 秒;而一个真实的访客离开再回来(去看展板、接个电话)通常超过 8 秒,仍会被重新欢迎。这个 8 秒不是拍脑袋——它是「最短真实离场时间」与「最长连续漏检时间」之间的中位数,实测在展厅光照下稳定。
第二个问题更隐蔽,因为打断功能本身是好的——正常对话时用户插话能立刻切断回复。唯独欢迎词不行。
顺着链路查下去,卡在一个布尔字段上。后端播报接口向所有客户端广播一段 JSON,其中 noBarge 表示「本条播报禁止打断」。当时为了区分「欢迎词要用专门的欢迎视频」,代码把两个含义塞进了同一个字段:
// 旧:一个字段干两件事
{ type: "audio", url: "...", noBarge: welcome } // welcome=true → noBarge=true
前端拿到之后:
// display.js
const item = { ..., welcome: !!data.noBarge }; // 用 noBarge 判断「是不是欢迎词」
if (!data.noBarge) this.setMicActive(true); // 而 noBarge 又决定「开不开麦」
结果就是:欢迎词一播,麦克风被关掉。麦克风关着,语音活动检测(VAD)拿不到任何输入,用户说破喉咙也不会触发打断——这不是打断逻辑坏了,是输入被物理掐断了。
// 后端:解耦
broadcast({ type: "audio", url, noBarge: false, welcome: welcome });
// 前端:各取所需
const item = { ..., welcome: !!data.welcome, noBarge: !!data.noBarge };
if (!data.noBarge) this.setMicActive(true); // 欢迎期间麦克风保持开启
改动只有两行,但性质完全不同:欢迎词仍然用欢迎专用视频(视觉不乱),同时麦克风保持开启(可以被打断)。这类「一个字段两种语义」的债,越早拆越便宜——它不会报错,只会在某个特定场景下表现为「功能不存在」。
解耦之后依然反应迟钝,于是要看打断判定本身。VAD 打断的判据是三个参数的组合:
| 参数 | 含义 | 正常回复取值 | 欢迎词场景为何失效 |
|---|---|---|---|
thresh | 音量阈值 | 5.0 | 整段 mp3 连续播放,环境噪声底噪被自身回声抬高,正常人声突破不了 5.0 |
needFrames | 需连续满足的帧数 | 12(约 1.2 秒) | 用户开口第一两个字常被当成噪声丢掉,累计不到 12 帧 |
noiseFloor | 自适应噪声底 | alpha = 0.3 | 跟随太快,播报回声把底噪顶上去,阈值水涨船高 |
这套参数是为「正常回复」标定的——那种场景下播报短、间歇多,底噪有机会回落。欢迎词是四十到六十秒不间断的整段音频,底噪一直高位,等于自己把自己的耳朵堵住了。
const _welcomePl = this.audioPlaying && this.currentItem && this.currentItem.welcome;
const thresh = _welcomePl ? 2.0 : 5.0; // 5.0 → 2.0
const minFrames = _welcomePl ? 2 : 3; // 3 → 2
const needFrames= _welcomePl ? 6 : 12; // 12 → 6(约 0.6 秒)
为什么只改欢迎态?因为正常回复如果也放宽到 2.0,自己的回声就会把自己打断,出现「说半句就停」的新毛病。已有的回声抑制是三件套:自适应回声系数 _echoCoeff、首音 800ms 保护窗、连续真人才计数的帧门控。放宽门槛必须限定在整段播报场景,这是边界。
语音打断再调,也总有失效场景:环境嘈杂、访客声音小、离麦克风远、方言口音被 VAD 判成噪声。展厅里最不该出现的情况是「客户想让它停,只能等它说完」。
所以加了一层不依赖任何语音判定的兜底:播报中点击屏幕任意位置即打断。
document.addEventListener('click', (e) => {
if (this.audioPlaying) this.bargeIn(); // 只判是否在播,不限定欢迎词
});
注意条件里不要写 && this.currentItem.welcome。写成只判欢迎词,正常回复播放中点屏就停不下来,提示语「轻触屏幕可随时打断讲解」就成了假话。而界面上的控制按钮自带 stopPropagation,点它们不会误触这层兜底。
这层最容易被工程师忽略。功能上线当天,现场反馈是「还是停不下来」——因为没人知道屏幕能点。
解决办法是在访客视线自然落点处写明。这台设备的右下角有一个二维码盒子(「扫码用手机对话」+ 副标题「嘈杂环境也听清」),就在二维码图正下方加一行金色虚线分隔的提示:
<div style="border-top:1px dashed #ffd479;color:#ffd479;margin-top:8px;padding-top:6px">
👆 轻触屏幕任意处<br>可随时打断讲解
</div>
用金色 #ffd479 与原有蓝灰副标题区分开,是因为它不是说明文字,是操作指引。这条改动没动一行逻辑,却是当天体验提升最明显的一次——产品能力的可发现性,和产品能力本身同等重要。
curl -sk https://127.0.0.1:8002/healthz # 正确
curl -s http://127.0.0.1:8002/healthz # 报 Empty reply from server,误判
健康检查返回里还要看 mode: real(真实引擎)而不是 mock,否则你以为在验收真机,其实跑的是占位实现。
入口页引脚本时带 ?v=:display.js?v=20260918tapstop。每次改 display.js 或 welcome.js 都要把这个串换掉,否则浏览器继续用缓存里的旧文件,服务端代码是新的、用户跑的是旧的,排查会怀疑人生。
这是最贵的一条教训:批量替换后,编辑工具返回成功,但远端文件内容未变(跨行匹配的替换尤其容易假成功)。如果直接重启服务,你会把旧代码当新代码上线,然后疑惑「改了怎么没效果」。
铁律:改完先在本地 grep 确认新符号存在,再上传;上传后再在服务端 grep 一次。两次命中才算闭环。
另外两个现场细节:改完 systemctl restart 后要等冷启动(加载推理框架约 20~40 秒)再探活,太早访问必然失败;而展厅那台独立展示机除了重启服务,还要在屏幕上手动刷新一次页面,否则页面 JS 不会重新拉取,服务端已是新版、屏幕还是旧版。
| # | 验收项 | 通过判据 |
|---|---|---|
| 1 | 单次到访只欢迎一次 | 站在摄像头前 3 分钟、期间正常小幅走动,播报只发生 1 次 |
| 2 | 抖动不复位 | 用手在镜头边缘快速晃过(制造单帧漏检),不触发重播 |
| 3 | 真实离场可重欢迎 | 离开画面超过 8 秒后返回,重新欢迎 1 次 |
| 4 | 语音可打断欢迎词 | 播报中说一句完整短句,600ms 内停播并进入聆听 |
| 5 | 正常回复不被误断 | 静默不发声时,回复不出现中途自停 |
| 6 | 触控可打断 | 播报中点击屏幕空白处,立即停播 |
| 7 | 按钮不误触 | 播放中点音量/关闭按钮,不触发打断逻辑 |
| 8 | 提示可见 | 屏幕右下角可见「轻触屏幕任意处 / 可随时打断讲解」 |
回头看,三个病灶有一个共同特征:它们都不会报错。状态位复位太激进,只是「多说了几遍」;字段语义复用,只是「某个场景功能不存在」;门槛标定不适配,只是「反应慢一点」。没有任何一条会进日志、会亮红灯。
所以做现场类交互产品,判断标准不能是「代码跑通了」,而必须是「把症状翻译成状态,再给每个状态一个可观测的判据」:
这四句翻译做完,验收清单自己就长出来了。
展厅场景建议 15~25 秒。超过 30 秒,访客大概率听不完就会走开或试图打断,而打不断的体验比不欢迎更差。如果业务一定要讲长内容,正确做法是拆成「短欢迎 + 按需触发的分段讲解」,而不是一段四十秒的整段播报。
冷却只能表达「多久之后可以再说」,无法表达「这个人是不是新来的」。一旦冷却时长比播报时长短,检测抖动就会造成循环重播;如果把冷却拉到很长,又会出现「同一人离开十分钟回来不被欢迎」。用「到访」状态机才能同时满足两个方向的需求。
防回声应该由回声抑制算法做,而不是靠关麦。关麦的代价是彻底丧失打断能力——用户说什么系统都听不见。正确姿势是麦克风常开 + 自适应回声系数 + 首音保护窗,让系统能区分「自己的声音」和「用户的声音」。
要。两者失效场景不重叠:语音打断在嘈杂、远距离、小声时失效;触控打断在用户双手拿着东西、或不知道能点时失效。展厅这类公共场所,触控是保底,语音是体验上限,缺一层就会收到「停不下来」的投诉。
不要看部署日志,要看设备行为。最短的验证路径是三步:改完在服务端 grep 到新符号、探活返回 real 引擎、然后站在镜头前做一次「晃动—离开 8 秒—返回」的完整动作。三步都过才算上线。