安卓唤醒词:12 维 MFCC 加 DTW,阈值 1.8

10 阅读6分钟

Andee 是跑在 Android 上的 agent:没有主界面,靠无障碍服务读屏上元素树,自己点。唤醒词是他的语音入口——省掉「点一下悬浮球」这个动作。整条识别链在设备内闭合,一共六个文件:core/ 里 Mfcc.kt、Dtw.kt、VoiceSegmenter.kt 是纯 Kotlin(不带 Android 依赖,能在 JVM 上跑测试),app/ 里 WakeWord.kt、WakeMic.kt、WakeTemplates.kt 管麦克风、模板和开关。

结论先给:这条链上最难的不是 MFCC 也不是 DTW,是阈值。它原来是 2.8,而 60 次实测判断里,57 句非唤醒语音全部落在 2.21–2.59 —— 全都在阈值下面。一个把全部负样本都放进来的参数,不该被读成「保守」,它只是失效了。

下面按四段拆:分段器在防什么、特征为什么是 12 维、DTW 的两个限制为什么是承重的、阈值怎么从三遍录音里算出来;最后是这套做法的硬边界。

1. 分段:只回答「话在哪儿」

VoiceSegmenter 是能量 VAD,配一条自适应底噪。它不做识别,只切边界 —— 让 Mfcc 和 Dtw 去判断内容。

START_FRAMES = 8      // 80 ms,历史值 3(30 ms)
PREROLL_FRAMES = 30   // 300 ms 预录环
END_FRAMES = 30       // 300 ms 静音收尾
MIN_FRAMES = 50       // 500 ms,短于此丢弃
MAX_FRAMES = 195      // 1.95 s,长于此整段丢弃
START_MULT = 3.5f; END_MULT = 2.0f; ABS_MIN = 350f

底噪只在空闲帧更新,而且方向不对称:低于本底按 0.9/0.1 混合,高于按 0.995/0.005。风扇启动最终会被当静音,而一句一秒的话几乎没有机会把地板抬起来 —— 说话时也学习的检测器会追着自己的声音往上爬,然后把句子切断。

START_FRAMES 从 3 改到 8 的理由是实测的:3 帧只有 30 ms,一个敲击、一次 startRecording 后的首读都能开出片段;早开的片段会拉到超过 MAX_FRAMES,然后被静默丢弃,调用方的 latch 不会减一,卡片一直停在「听着…」。「要试三次才能录进去,但等三秒一次就够」和「什么都能把他叫醒」两个互不相干的抱怨,是同一个缺陷的两张脸。

预录环是让「慢决定」免费的那个装置:判定延迟 80 ms 不损失音节,因为那 80 ms 还在环里。它补上的正好是辅音 —— 「嘿」的 h —— 也是唤醒短语区别于其他短句的主要信息。

配套的 Stats(frames / loud / firstLoud / peak / floor / segments / droppedShort / droppedLong)是纯 Kotlin 值类。它存在的原因是「一次采集窗口没产出片段」有四条静默路径,日志里一条都不提;没有这几个计数,「说了没反应」和「从没说话」在外部完全无法区分。

2. 特征:每 10 ms 十二个数

25 ms 窗、10 ms 步长、26 个 mel 带(300 Hz–8 kHz)、512 点 FFT、预加重 0.97,保留 c1..c12。两个决定值得点名:

  • 丢 c0。 它是整体响度,留着等于把音量当音色。
  • 整段 CMVN。 减均值去信道(房间/距离/麦克风),除标准差把所有录音放到同一尺度,固定阈值才有意义。缺了后半步,阈值就得按环境重学。

不引第三方库的原因不是体积,是一致性:录入和比对必须用同一个滤波器组。两套滤波器之间存模板、读模板,代码不会抛异常,只会静默地错。

3. 对齐:限制比算法重要

DTW 解决语速;真正决定误接受率的是两个限制:Sakoe-Chiba 带 BAND_FRAC = 0.25(另设 MIN_BAND = 4),以及长度闸 MAX_RATIO = 1.7,超了直接返回 NO_MATCH 而不是硬拉齐。无带宽 DTW 可以靠在一根轴上原地不动,把一个音节对到一整句上。

代价最后除以 n + m 归一化成每帧均值 —— 注释里写明了这是「不追踪路径也能算准」的那个便宜选择。可比的代价,才让一个存下来的标量阈值能覆盖所有长度。

4. 阈值:从三遍里学,不从经验里猜

thresholdFor 取最近的一对模板距离 × SLACK = 1.25,再 coerceIn(1.0f, 1.8f)。

「取最近而不是最远」是个改正:最远那对看起来才像「这个人有多不稳定」,但 bestDistance 返回最近模板,门宽要回答的是「一次真实尝试离最近那遍能有多远」,证据只能来自落得近的样本。

锚点三遍互距阈值
最远对(旧)1.82 / 1.692.28 / 2.11
最近对(现行)1.40 / 1.301.75 / 1.62

同一张嘴真实唤醒的三次是 1.16 / 1.38 / 1.46,比最紧的一对录入还近 —— 唤醒是有意动作,录入是照卡片念、手还在找按钮。负样本侧:57 句全在 2.21–2.59,60 次判断里 16 次离 2.28 不到 0.06。所以旧阈值不是分界线,是穿过其中一个分布的一条切线。

MAX_THRESHOLD 因此 2.8 → 1.8:2.8 当初的定位是「还叫得上阈值的最大值」,从未测量;1.8 是同一批数据里既接受三次真实尝试、又拒绝那 57 句的最宽值。夹逼区间的偏向写在注释里:紧 → 用户改点球,有现成绕路;松 → 一句无关的话打开麦克风并把音频送去服务商,是意外也是账单。拿不准就聋。

5. 麦克风归谁

WakeWord 一个守护线程每 POLL_MS = 100 毫秒问一次 gate,running 为假就 mic.stop();设备拒绝麦克风时退避 RETRY_MS = 2000;REFRACTORY_MS = 2500 内不重复唤醒,避免一句话开两个回合。WakeMic 与对话用的 AudioIO 是两个独立 recorder,两者绝不同时开:唤醒先放麦克风,再开 ASR —— 回合永远优先于监听,不能反过来。

三个「不开麦」的条件同样是设计:没录模板不开(未配置的安装不该占着麦克风,也不该让系统的绿点点亮);TTS 在说话时关(否则会听见自己);gate 说不行就立刻关。

6. 边界

  • 这套识别认的是这个用户说这句话,不是这句话本身:换人、感冒、距离变远都可能叫不醒,退路是点球。
  • 三遍录得太一致 → 阈值被夹到很紧 → 平时那样喊反而进不来。这不是 bug,是学出来的门太窄。
  • 证据量是「一个人、一个房间、一句话、60 次判断」。代码注释的原话是「比被替换的数字更有依据,不是正确」。
  • 唤醒离线 ≠ 语音离线:喊醒之后的 ASR 走你配置的服务商(默认火山)。
  • 锁屏监听默认开启,代价是麦克风常开 + Android 12 以上常亮绿点;锁屏能听能答,但碰屏幕、开 App 那几类动作会被直接拒掉,不会谎报已经做了。

一句话收尾:这套实现的取舍是「把失败成本压到点一下球」,换掉模型文件、网络请求和按次计费。它不适合做远场、也不适合做多说话人 —— 真要那些,得换成另一条技术路线,而不是调这几个常数。