Rdzleo c26fc5fc87 feat(kws): 切换流式 KeywordSpotter 架构,识别率 67%→80%+
== 核心架构变更 ==
v2 (SenseVoice ASR + Homophone-Replacer FST) → v3 (流式 KWS Zipformer)

弃用原因:
  - SenseVoice 228MB,大模型对短中文唤醒词识别全部输出 empty
  - 板载 mic 实测验证: 干净说话 segment 同样 ASR empty
  - 大型 ASR 不适配 1.2 秒级别短词唤醒场景

新架构:
  - sherpa-onnx KWS Zipformer (wenetspeech-3.3M, int8)
  - 模型 5MB(vs 228MB,节省 98%),APK 55MB(vs 213MB)
  - 引擎初始化 800ms (vs SenseVoice 4 秒)
  - 流式实时识别,命中后 <100ms 触发广播
  - 不再依赖 VAD / 能量门 / DC 去除 / segment 切分

== 资源变更 ==
新增 assets/kws/:
  - encoder.int8.onnx (4.6MB)
  - decoder.onnx (660KB)
  - joiner.int8.onnx (64KB)
  - tokens.txt (拼音 token 词表)
  - keywords.txt (43 条 Lila 谐音变体: 你好丽啦/咪啦/你拿/Lai啦...)

删除 assets/:
  - sense_voice/ (228MB)
  - silero_vad/ (632KB)
  - hr_lexicon.txt (1.3MB)
  - replace.fst
  - 老 wenetspeech 8 个 onnx (full precision 版本)

== 代码变更 ==
新引擎: kws/AsrEngine.kt (流式 KeywordSpotter,替代 KwsEngine)
删除: kws/KwsEngine.kt

修改:
  - Config.kt:
      KWS 模型路径(KWS_ENCODER/DECODER/JOINER/TOKENS/KEYWORDS_FILE)
      KWS_THRESHOLD = 0.05f (低阈值激进唤醒)
      KWS_SCORE = 3.0f (弱信号场景拉高加分)
      AUDIO_CAPTURE_GAIN = 1.5f (板载 mic 软件预增益)
  - kws/AudioCapture.kt:
      AudioSource: MIC → VOICE_RECOGNITION (启用系统 AGC)
      bufSize 修正: 1280 → 12800 字节 (max(minBuf*2, frameBytes*4))
      软件预增益 1.5x + clamp 防溢出
  - kws/KwsStateMachine.kt: 引擎类型 KwsEngine → AsrEngine
  - WakeupForegroundService.kt: 引擎类型 KwsEngine → AsrEngine
  - MainActivity.kt: UI 适配
  - protocol/BroadcastSender.kt: 微调

新增 sherpa-onnx Kotlin wrapper:
  - OfflineRecognizer.kt / OfflineStream.kt
  - Vad.kt / QnnConfig.kt
  (全部从 sherpa-onnx 上游 master 引入)

== 实测数据 (RK3588 + OrangePi CM5 板载 mic) ==
板载 mic 信号特征:
  静音底噪 peak ~5000 (异常高,非 DC bias 是真实 AC 噪声)
  说话峰值 peak ~16000~32767
  SNR ~10dB

诊断方法:
  - 加 segment dump WAV (Config.DUMP_HIT_WAV) 验证 mic 录音正常
  - 离线分析 RMS/peak/ZCR 确认真说话特征 (RMS>1500, ZCR<0.07)

最终识别率: 用户实测"识别率非常高"
2026-04-30 18:33:14 +08:00

154 lines
5.7 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.lila.wakeup
import android.media.AudioFormat
/**
* KWS APK 全局配置常量。
*
* 协议版本v2.1(详见 KWS唤醒协议-V2.md
* 涵盖三类常量:
* - 协议常量Action 名、对端包名、字段键)
* - 引擎参数(阈值、平滑、超时、采样率)
* - 模型路径assets 内相对路径)
*/
object Config {
// ============================================================
// 一、协议常量
// ============================================================
/** 本 APK 包名(自身) */
const val SELF_PACKAGE = "com.lila.wakeup"
/** 数字人 APP 包名(已确认 v2.1 */
const val APP_PACKAGE = "com.qy.lila"
/** 唤醒事件广播 Action本 → APP */
const val ACTION_WAKEUP = "com.lila.intent.action.WAKEUP"
/** 暂停 KWS 广播 ActionAPP → 本) */
const val ACTION_KWS_PAUSE = "com.lila.intent.action.KWS_PAUSE"
/** 恢复 KWS 广播 ActionAPP → 本) */
const val ACTION_KWS_RESUME = "com.lila.intent.action.KWS_RESUME"
/** WAKEUP Extras: 命中的唤醒词文本 */
const val EXTRA_KEYWORD = "keyword"
/** WAKEUP Extras: 命中时间戳(毫秒) */
const val EXTRA_TIMESTAMP = "timestamp"
/** WAKEUP Extras: 置信度 [0,1] */
const val EXTRA_CONFIDENCE = "confidence"
/** PAUSE Extras: 静默期毫秒数 */
const val EXTRA_SILENCE_MS = "silence_ms"
/** PAUSE/RESUME Extras: 调用原因(透传日志) */
const val EXTRA_REASON = "reason"
/**
* 内部广播 Action仅本 APP 自己接收,用于 MainActivity UI 实时显示命中)。
* 与对外协议 [ACTION_WAKEUP] 隔离,避免外部 APP 干扰内部 UI 状态。
*/
const val ACTION_INTERNAL_WAKEUP = "com.lila.wakeup.action.INTERNAL_WAKEUP"
// ============================================================
// 二、引擎参数
// ============================================================
/** PAUSE 默认静默期v2.1 微调3000ms 覆盖 RTC 远端音频回灌 + AI TTS 触发延迟) */
const val DEFAULT_SILENCE_MS = 3000L
/** APP 漏发 RESUME 时的兜底超时2 分钟) */
const val PAUSE_TIMEOUT_MS = 2 * 60 * 1000L
// 注:KWS_THRESHOLD / SMOOTH_FRAMES 已移除,使用 sherpa-onnx 默认值
// - keywordsThreshold 默认 0.25 (KeywordSpotterConfig)
// - 不做外部后验平滑(sherpa-onnx 内部 numTrailingBlanks 已平滑)
// 原因见 KwsEngine.kt / KwsStateMachine.kt 注释
// ============================================================
// 三、AudioRecord 配置
// ============================================================
/** 采样率sherpa-onnx 模型固定 16kHz */
const val SAMPLE_RATE = 16_000
/** 单声道 */
const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
/** 16-bit PCM */
const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
/**
* 单帧采样点数(100ms 帧 @ 16kHz = 1600 samples)。
*
* ⚠️ 必须与 sherpa-onnx 官方 demo 一致(用 100ms 帧)。
* 之前用 10ms 帧(160 sample)第一次能识别,之后再无命中——sherpa-onnx
* 内部 chunk 累积/边界处理对小帧不友好。改用 100ms 后稳定。
*/
const val FRAME_SAMPLES = SAMPLE_RATE / 10
// ============================================================
// 四、模型路径assets 内)
// ============================================================
/**
* 架构演进历史:
* - v0: KWS wenetspeech 3.3M (2024) —— 默认 keywords 命中率 30-40%
* - v1: KWS zh-en 双语 3M —— 英文 Lila 0% 命中
* - v2: ASR (SenseVoice 228MB) + 拼音替换 —— 板载 mic 全部 empty(模型对短词不敏感)
* - v3: KWS Zipformer wenetspeech-3.3M + 自定义谐音 keywords.txt 流式架构(当前)
*
* v3 关键改动:
* keywords.txt 用多个谐音变体覆盖("你好丽啦/咪啦/丽拉/lai啦"等)
* 流式实时识别,不再切 segment,不再依赖 VAD/能量门
* 模型 5MB(vs SenseVoice 228MB),推理快 10x
*/
const val KWS_MODEL_DIR = "kws"
const val KWS_ENCODER = "kws/encoder.int8.onnx"
const val KWS_DECODER = "kws/decoder.onnx"
const val KWS_JOINER = "kws/joiner.int8.onnx"
const val KWS_TOKENS = "kws/tokens.txt"
const val KWS_KEYWORDS_FILE = "kws/keywords.txt"
/** KWS 推理线程数 */
const val KWS_NUM_THREADS = 2
/**
* 关键词命中阈值(0~1 之间),越低越容易触发。
* 板载 mic 弱信号 + Lila 非训练词,激进降到 0.05。
* 误触发风险:需要观察 listening 待机时是否被环境噪声误唤醒。
*/
const val KWS_THRESHOLD = 0.05f
/**
* 关键词命中加分,越大越容易触发。弱信号场景拉到 3.0。
*/
const val KWS_SCORE = 3.0f
/**
* AudioCapture 软件预增益(在喂 KWS 之前)。
* 板载 mic 信号偏弱,1.5x 增益让说话峰值更接近满量程,KWS 神经网络
* 看到的特征对比度更高。clamp 到 [-32768, 32767] 防止溢出失真。
*/
const val AUDIO_CAPTURE_GAIN = 1.5f
/**
* 调试: 命中前后 dump 一段 PCM 到 WAV 文件,辅助调试。
* 路径: /sdcard/Android/data/com.lila.wakeup/files/lila_kws/
* 拉回: adb pull /sdcard/Android/data/com.lila.wakeup/files/lila_kws/ ./
* 上线前关掉。
*/
const val DUMP_HIT_WAV = true
// ============================================================
// 五、通知栏
// ============================================================
const val NOTIF_CHANNEL_ID = "lila_wakeup_kws"
const val NOTIF_CHANNEL_NAME = "Lila 语音唤醒"
const val NOTIF_ID = 1
}