# 开放词汇关键词唤醒

想让设备听到「你好小楠」就响应，或者换成任何你喜欢的词？这个示例让你**用自己的声音，现场注册自己的唤醒词**。
注册后，设备持续监听麦克风，听到其中任何一个词就立即唤醒，并告诉你是哪一个。

注册和检测全部在 K230 开发板上离线完成：不需要电脑重新训练模型，也不需要联网。

## 功能特点

| 特性 | 说明 |
|---|---|
| 唤醒词随你定 | 不限词表，录 3 遍就能注册；建议 4 个字以上，如「你好小楠」「小楠同学」 |
| 多个唤醒词 | 最多 8 个，同时监听并区分是哪一个。所有词共用一次神经网络推理，增加词数几乎不增加开销 |
| 分得清相似词 | 两道关卡判定：「小楠同学」和「小智同学」只差一个字，也不会互相误唤醒；注册时还会提前拦下太像的词 |
| 响应快 | 每 160 ms 处理一次音频，说完到唤醒约 0.3 s；神经网络在 KPU 上运行，其余计算在固件 C/C++ 模块中完成 |
| 录音省心 | 说完即自动停止录音；声音太大时自动调低麦克风音量，音量条变红提醒；没说话会超时提醒 |
| 唤醒提示 | 唤醒时扬声器播放提示音，屏幕以该词的颜色高亮显示 |
| 灵敏度可调 | 「灵敏 / 正常 / 保守」三档，同时调整两道关卡的松紧 |
| 灵活管理 | 可以单独删除某个唤醒词，也可以一键清空；注册过程中随时可以取消 |
| 多种屏幕 | 默认使用 IDE 虚拟屏，也支持开发板 LCD 触摸屏和 HDMI；界面按分辨率和横竖屏自动排版 |
| 离线、私密 | 录音与注册结果只保存在开发板 SD 卡的 `enroll/` 目录 |

---

## 快速上手

在 VS Code 的 **CanMV 插件**中运行 `/sdcard/examples/31-OPEN-KWS/open_kws_app.py`，打开虚拟屏幕预览，用鼠标点击界面上的按钮操作。

| 横屏 800×480 · 检测中 | 竖屏 480×800 · 注册中 |
|:---:|:---:|
| ![横屏检测界面](https://www.kendryte.com/api/imagecdn/zh/sdk/canmv_k230_sdk/attachments/ui_landscape.png) | ![竖屏注册界面](https://www.kendryte.com/api/imagecdn/zh/sdk/canmv_k230_sdk/attachments/ui_portrait.png) |
| 说出唤醒词后曲线越过红线，弹出「唤醒！kw_0」并播放提示音 | 正在录第 2 段；注册中「注册新词」变为「取消注册」 |

| 区域 | 内容 |
|---|---|
| 顶部 | 标题与灵敏度切换（「灵敏 / 正常 / 保守」，当前档位高亮）；下方一行显示操作提示和结果 |
| 得分卡片 | 曲线为所有唤醒词中「得分 − 阈值」的最大值，越过红线并通过第二道关卡即唤醒；下方有实时音量条、语音指示灯和处理耗时 |
| 唤醒词卡片 | 已注册的词：颜色、编号、阈值、解码出的声韵母，每行带「删除」按钮；词多时可上下滑动 |
| 底部按钮 | 注册新词（注册过程中变为「取消注册」）、录音、开始/停止检测、清空全部 |

### 显示方式

默认使用 IDE 虚拟屏（800×480）。要在实体屏上运行，修改 `open_kws_app.py` 开头「屏幕」配置中的 `DISPLAY_MODE`：

| 取值 | 显示 | 操作 |
|---|---|---|
| `"VIRT"`（默认） | IDE 虚拟屏 | 在 CanMV 插件的预览中用鼠标点击 |
| `"LCD"` | 开发板 MIPI 屏（如 01studio 的 ST7701） | 屏幕自带触摸 |
| `"HDMI"` | HDMI 显示器 | 在 IDE 预览中用鼠标点击 |
| `"AUTO"` | 能打开触摸屏就用开发板 LCD，否则用 IDE 虚拟屏 | 同上 |

- 使用 LCD 时，分辨率根据固件支持的屏幕列表（`Display.list_modes()`）和触摸屏报告的尺寸自动选择，也可以用 `DISPLAY_SIZE` 指定，如 `(480, 800)` 竖屏。
- 界面的字体、间距、按钮按屏幕尺寸缩放：横屏时左右分栏，竖屏时上下排列；大于 1280×720 的屏幕，界面居中显示。

### ① 注册唤醒词

1. 点 **注册新词**。
2. 点 **录第1段**，然后清楚地说一遍唤醒词。
   - 说完停顿约 0.6 秒，录音自动结束（最长 4 秒）；5 秒内没有说话会提示重录。
   - 程序自动截取有声音的部分，并检查录音质量。
   - 录音前可以先看音量条：正常为绿色，变红说明声音过大，请离远一点或小声一些。声音过大导致录音不合格时，程序会自动调低麦克风音量并提示重录，调整后的音量会保存下来，之后的注册和检测都使用它。
3. 照提示录第 2、3 段。某一段不合格时，只需重录这一段。
4. 3 段都合格后自动计算，并检查与已注册的词是否太像，状态栏显示进度。完成后右侧列表出现新词，即注册成功。

注册过程中随时可以点 **取消注册** 放弃本次注册，已注册的词不受影响。
重复以上步骤可以继续添加，最多 8 个。注册结果保存在 `enroll/` 目录，下次启动时自动加载。

### ② 唤醒

1. 点 **开始检测**，对着麦克风说任一已注册的词。
2. 唤醒时扬声器播放提示音，屏幕以该词的颜色显示「唤醒！kw_n」，串口打印：

   ```text
   >>> WAKE kw_0 score=-0.707 th=-2.987 @12.56s
   ```

3. 再点一次（按钮变为 **停止检测**）即停止。

提示音默认为内置的「叮咚」声，可以通过 `WAKE_SOUND` 换成自己的 wav 文件（16 kHz 单声道），如 `"/sdcard/examples/utils/wozai.wav"`，或设为 `None` 关闭。

### ③ 灵敏度

点右上角的「灵敏 / 正常 / 保守」切换，立即生效，对所有词有效：

| 档位 | 适合场景 |
|---|---|
| 灵敏 | 安静环境、说话较轻，希望更容易唤醒 |
| 正常（默认） | 大多数场景，兼顾唤醒率和抗误唤醒 |
| 保守 | 注册了几个相近的词，或周围人声多，希望尽量不误唤醒 |

### ④ 删除唤醒词

- **删除单个**：点该词所在行的 **删除**，按钮变为 **确认删除**，3 秒内再点一次即删除这个词及其录音。检测过程中也可以删除。
- **一键清空**：点 **清空全部**，按钮变为 **确认清空?**，3 秒内再点一次即删除全部唤醒词和录音；清空后新词从 `kw_0` 重新编号。

两种操作 3 秒内不再点击都会自动取消。

### 注册时可能遇到的提示

| 提示 | 原因 | 处理 |
|---|---|---|
| 录音太短 / 太长 | 截取后的语音不在 0.6–2.5 s 之间 | 完整说一遍唤醒词，不要多说其他话 |
| 音量过低 | 峰值太小 | 靠近麦克风，30–100 cm 较合适 |
| 音量过大（削波） | 波形被截顶的采样过多，且麦克风音量已调到最低 | 离远一点或小声一些 |
| 没有检测到说话声 | 5 秒内音量一直没有明显高于环境底噪 | 靠近麦克风、说话大声一些 |
| 环境太吵 | 语音比环境底噪高不到 10 dB | 换到安静的地方，或靠近麦克风说话 |
| 唤醒词太短 | 音节数少于 4 | 换一个 4 个字以上的词 |
| 几段录音差异太大 | 算出的阈值高于最差的那段录音 | 三遍保持相同的语速和音量 |
| 与已注册的 kw_x 太相似 | 新词会唤醒 kw_x | 换一个读音区别更明显的词 |
| 说 kw_x 时也会唤醒新词 | 新词会被 kw_x 唤醒，例如新词是旧词的一部分（「来了在的」⊂「我来了在的」） | 换一个词 |

---

## 实现原理

### 整体结构

计算全部在固件内置的 `open_kws` 模块（C/C++）中完成，示例脚本只负责界面、录音和文件管理。

```{mermaid}
flowchart LR
    subgraph APP["open_kws_app.py（MicroPython）"]
        UI["LVGL 界面"]
        SM["状态机"]
        MIC["麦克风录音<br/>16 kHz / 单声道"]
        FS["enroll/ 注册文件"]
    end
    subgraph MOD["open_kws 模块（C/C++，固件内置）"]
        FE["特征提取<br/>log-Mel"]
        ENC["滑窗推理"]
        DET["检测器<br/>VAD + 两阶段判定"]
        ENR["注册器<br/>解码 + 阈值标定"]
    end
    KPU[("KPU<br/>kws.kmodel")]
    MIC -- "每 160 ms 一块" --> DET
    MIC -- "注册录音" --> ENR
    DET --> FE --> ENC
    ENR --> FE
    ENC <--> KPU
    DET -- "唤醒事件" --> SM
    ENR -- "注册结果" --> SM
    SM <--> UI
    SM <--> FS
```

**声学模型的作用**：模型不直接识别某个词，而是把声音转换成 59 类「拼音单元」的概率，分别是 21 个声母、35 个韵母，以及空白、静音和未知。
任意一个唤醒词都可以写成一串声母韵母，例如「小楠同学」= `x iao n an t ong x ve`。所以不用重新训练模型，就能支持任意的词。

### 流式检测

```{mermaid}
flowchart TD
    A["麦克风 PCM<br/>每块 2560 采样 = 160 ms"] --> B{"能量 VAD<br/>有人说话?"}
    B -- "否" --> X["跳过推理，节省算力"]
    B -- "是" --> C["log-Mel 特征<br/>每 10 ms 一帧，40 维"]
    C --> D["KPU 推理<br/>输入最近 0.96 s 的特征"]
    D --> E["输出声母、韵母概率<br/>每 40 ms 一帧"]
    E --> F["每个唤醒词计算得分"]
    F --> G{"两阶段判定"}
    G -- "通过" --> H["唤醒事件"]
    G -- "未通过" --> I["等待下一块"]
```

| 环节 | 实现要点 |
|---|---|
| 特征提取 | 512 点 FFT、10 ms 帧移、40 维梅尔滤波并做归一化，与模型训练时完全一致 |
| 滑窗推理 | 推理窗 96 帧（0.96 s），每 160 ms 滑动一次并重算；模型输出 24 帧，只取中间几帧，避开窗口边缘的不准确区域 |
| 打分 | CTC 前向算法：计算「最近这段声音按这串声母韵母念出来」的概率。允许从任意时刻开始，并按单元数归一化，长词短词可以用同一套标准 |
| 多唤醒词 | 所有词共用同一次 KPU 推理，每个词只多一次打分计算 |
| 触发 | 连续 2 帧满足条件才触发；同一个词触发后冷却 1.5 s，避免重复唤醒 |
| 延迟 | 约 0.3 s（模型右侧需要等待 80 ms + 一个 160 ms 的处理步长 + 计算时间） |

### 两阶段判定：为什么能分清相似词

第一阶段的得分是整个词的平均水平。「小智同学」和「小楠同学」有 3 个字完全相同，平均下来差距很小，单靠它分不开。所以触发前再加一道逐字核对：

```{mermaid}
flowchart LR
    S["第一阶段<br/>整词得分 > 阈值<br/>且连续 2 帧"] --> V["强制对齐<br/>把注册的声母韵母<br/>逐个对到最近 1.6 s 的声音上"]
    V --> W["逐个音节打分<br/>取最弱的那个音节"]
    W --> T{"最弱音节<br/>够不够好?"}
    T -- "够" --> Y["唤醒"]
    T -- "不够" --> N["拦下"]
```

- 真正说的是这个词时，每个音节的得分都接近 0（对数概率）。
- 只差一个字的相似词，那个不同的音节会低到 −5 ~ −10，因此被拦下。
- 判定规则：`最弱音节得分 + α × (整词得分 − 阈值) > −3.0 + δ`。
  - 整词得分超出阈值越多，对最弱音节的要求越宽松，减少真词被误拦。
  - 注册时会解码出多条可能的声母韵母序列，逐字核对时用其中表现最好的一条，避免某一条在个别音节上解码不准导致漏唤醒。

灵敏度档位同时调整两道关卡：

| 档位 | 第一关阈值偏移 | 逐字核对用的序列条数 | α | δ |
|---|---|---|---|---|
| 灵敏 | −0.15 | 8 | 0 | 0 |
| 正常 | 0 | 8 | 2.0 | +1.25 |
| 保守 | +0.15 | 3 | 2.0 | +1.25 |

### 注册：从 3 段录音学会一个词

```{mermaid}
flowchart TD
    R["按键后开始录音<br/>说完停顿 0.6 s 自动结束"] --> TR["端点检测<br/>按能量截取语音，前后各留 200 ms"]
    TR --> QC{"质量检查<br/>时长 / 音量 / 削波 / 信噪比"}
    QC -- "不合格" --> R
    QC -- "合格" --> DEC["束搜索解码<br/>得到几种最可能的声母韵母序列"]
    DEC --> MORE{"录满 3 段?"}
    MORE -- "否" --> R
    MORE -- "是" --> MG["合并 3 段的结果<br/>保留前 8 条序列并加权"]
    MG --> CAL["标定这个词的阈值"]
    CAL --> SIM{"与已注册的词<br/>双向相似度检查"}
    SIM -- "太像" --> F["提示换词"]
    SIM -- "可区分" --> OK["保存 kw_n.json<br/>立即加入检测"]
```

- **录音与质量检查**：
  - 空闲时持续估计环境底噪；录音中音量明显高于底噪即判定开始说话，说话后约 0.6 s 回到底噪附近即结束。
  - 信噪比 = 语音能量 ÷ 环境底噪，两者都先去除麦克风的直流偏置。
  - 削波过多时自动降低麦克风音量并保存，注册和检测始终使用同一音量。
- **阈值标定**：每个词的阈值单独计算，不用一个固定值。
  - **正例**：用其中 2 段录音得到的序列，去给剩下那段打分，轮流 3 次（留一法），代表「真的说了这个词」的得分。
  - **负例**：把每段录音切成 3 份，打乱顺序重新拼接，共 5 种排列。音色、音素都一样但顺序不对，代表「听起来像但不是」的得分。
  - **阈值**：取在两者之间、更靠近负例的位置，即 `0.38 × 正例均值 + 0.62 × 负例均值`。
- **双向相似度检查**：使用当前灵敏度档位的两阶段规则，并留有余量。
  - **正向**：已注册的词在新录音上会不会被唤醒？
  - **反向**：新词在已注册词的保存录音上会不会被唤醒？
- **更多检查**：音节数不少于 4，以及最差的一段录音也能超过阈值。

### 应用流程

```{mermaid}
stateDiagram-v2
    state "空闲" as IDLE
    state "准备注册" as READY
    state "录音中" as RECORDING
    state "计算中" as COMPUTING
    state "检测中" as DETECTING
    [*] --> IDLE: 启动，加载 enroll/
    IDLE --> READY: 注册新词
    READY --> RECORDING: 录第 N 段
    RECORDING --> READY: 说完自动结束，截取并质检
    READY --> COMPUTING: 3 段均合格
    COMPUTING --> IDLE: 保存成功或提示失败
    READY --> IDLE: 取消注册
    RECORDING --> IDLE: 取消注册
    COMPUTING --> IDLE: 取消注册
    IDLE --> DETECTING: 开始检测
    DETECTING --> IDLE: 停止检测
```

- **单线程主循环**：每轮取一块音频，按当前状态处理，然后刷新 LVGL 界面并读取触摸；按钮回调只记录动作，实际工作放在主循环里执行。
- **注册计算分步执行**：阈值标定和相似度检查逐步完成，每步之间刷新界面并显示进度，计算期间界面保持响应。
- **出错不退出**：模型或文件出错时提示原因，取消当前注册或停止检测，应用继续可用。

---

## 开发接口

`open_kws` 是固件内置模块，直接 `import open_kws` 即可使用。模块由 menuconfig 中的 `CanMV Micropython Components Configurations → Enable open-vocabulary KWS (open_kws) module`（`CONFIG_ENABLE_MODULE_OPEN_KWS`）控制，默认开启；关闭后固件不包含该模块，SD 卡镜像也不再带本示例。

最小用法：

```python
import open_kws, json

ROOT = "/sdcard/examples/31-OPEN-KWS/"
eng = open_kws.Engine(ROOT + "model/kws.kmodel", ROOT + "assets")

with open(ROOT + "enroll/kw_0.json") as f:      # 加载已注册的唤醒词
    eng.add_keyword(json.load(f))
eng.set_sensitivity("normal")                   # 第一关：阈值偏移
eng.set_verify(n_hyps=8, alpha=2.0, delta=1.25) # 第二关：与示例「正常」档一致

# 麦克风：16 kHz、单声道、16 bit，每块 2560 个采样（open_kws.CHUNK）
events = eng.push(pcm)
for kw_id, score, threshold, start_ms, end_ms in events:
    print("唤醒:", kw_id)                       # 在这里接你自己的动作
```

常用接口：

| 接口 | 作用 |
|---|---|
| `Engine(kmodel, assets)` | 加载模型与特征参数 |
| `add_keyword(rec)` / `set_keywords([...])` / `clear_keywords()` / `keywords()` | 管理唤醒词，`rec` 为注册结果 dict |
| `push(pcm)` | 送入一块音频，返回本块的唤醒事件 `(kw_id, score, threshold, start_ms, end_ms)` |
| `set_sensitivity(level)` | 第一关阈值偏移：`"sensitive"` / `"normal"` / `"conservative"` 或浮点数 |
| `set_verify(n_hyps, alpha, delta)` | 第二关规则（见上文判定规则） |
| `stats()` | 处理耗时、音量、VAD 状态，以及每个词的当前得分 |
| `trim(pcm)` / `check_audio(pcm)` / `level(pcm)` | 注册用：截取语音段、质量检查、计算一块音频的音量与峰值 |
| `set_quality(clip_ratio, min_snr)` | 注册录音的质量要求：削波采样比例上限、最低信噪比 |
| `enroll_begin()` → `enroll_add(seg, noise)` ×3 → `enroll_step()` 循环 → `enroll_result(kw_id)` → `enroll_end()` | 注册流程；`noise` 为录音前的环境底噪，作为信噪比的参考 |
| `enroll_confusion()` / `probe(rec, pcm)` | 注册时的相似度检查 |

完整的注册与界面流程可以参考 `open_kws_app.py`。

### 注册结果文件

每个唤醒词保存为一个 `enroll/kw_<n>.json`，主要字段：

```json
{
  "kw_id": "kw_0",
  "hypotheses": [
    {"units": [21, 38, 14, 26, 20, 45, 21, 57], "weight": 0.195},
    {"units": [21, 38, 13, 26, 20, 45, 21, 57], "weight": 0.123}
  ],
  "threshold": -3.1259,
  "verify_threshold": -3.0,
  "cooldown_ms": 1500,
  "confirm_frames": 2
}
```

| 字段 | 说明 |
|---|---|
| `hypotheses` | 注册解码出的声母韵母序列（数字对应 `units.txt` 的行号）及权重 |
| `threshold` | 第一关阈值，每个词单独标定 |
| `verify_threshold` | 第二关基准阈值 |
| `cooldown_ms` / `confirm_frames` | 触发冷却时间、连续确认帧数 |

---

## 文件说明

| 文件 | 说明 |
|---|---|
| `open_kws_app.py` | 示例程序：LVGL 界面、状态机、录音与提示音、注册文件管理 |
| `model/kws.kmodel` | 声学模型：输入 40×96 特征，输出 24 帧 × 59 类拼音单元概率 |
| `assets/` | 特征提取参数（窗函数、梅尔滤波器、归一化参数），须与模型匹配，请勿修改 |
| `units.txt` | 59 个拼音单元列表 |
| `enroll/` | 运行时生成：`kw_<n>.json` 和录音 `kw_<n>_clip_<k>.wav`（反向相似度检查会用到），以及保存麦克风音量的 `settings.json` |

## 可调参数

所有可调参数都集中在 `open_kws_app.py` 开头的「可配置参数」区，按用途分组，修改一处即可生效。

**屏幕**

| 参数 | 默认值 | 说明 |
|---|---|---|
| `DISPLAY_MODE` | `"VIRT"` | 显示方式：`"VIRT"` / `"LCD"` / `"HDMI"` / `"AUTO"` |
| `DISPLAY_SIZE` | None | 指定分辨率，如 `(480, 800)`；None 为自动选择或默认分辨率 |
| `TOUCH_ROTATE` | None | LCD 触摸旋转（屏幕与触摸方向不一致时设置） |
| `VIRT_SIZE` | (800, 480) | IDE 虚拟屏的分辨率 |
| `UI_MAX` | (1280, 720) | 界面最大分辨率，更大的屏幕居中显示 |

**音频**

| 参数 | 默认值 | 说明 |
|---|---|---|
| `MIC_VOLUME` | 100 | 初始麦克风音量 |
| `AUTO_MIC_GAIN` | True | 录音削波时自动调低麦克风音量并保存 |
| `MIC_VOLUME_STEP` / `MIC_VOLUME_MIN` | 10 / 40 | 自动调低的步长和下限 |
| `WAKE_SOUND` | `"chime"` | 唤醒提示音：`"chime"` 内置提示音 / 16 kHz 单声道 wav 路径 / None 关闭 |
| `SPEAKER_VOLUME` | 85 | 提示音音量 |

**检测与灵敏度**

| 参数 | 默认值 | 说明 |
|---|---|---|
| `SENS_TIERS` | 灵敏 / 正常 / 保守 | 每档为 (名称, 第一关阈值偏移, n_hyps, α, δ)，右上角的档位按钮按此生成 |
| `SENS_DEFAULT` | 1（正常） | 启动时的档位 |
| `WAKE_SHOW_MS` | 1500 | 唤醒提示显示时长（ms） |

**注册与录音**

| 参数 | 默认值 | 说明 |
|---|---|---|
| `N_CLIPS` | 3 | 每个词的注册录音段数 |
| `REC_END_SILENCE` / `REC_MAX_CHUNKS` / `REC_WAIT_CHUNKS` | 4 / 25 / 31 | 录音自动结束：说完后的静音时长（0.64 s）、最长录音（4 s）、等待说话的超时（5 s），单位为 160 ms 一块 |
| `SPEECH_RATIO` / `SPEECH_ABS` | 2.5 / 0.03 | 判定为说话的音量：高于底噪的倍数，或超过绝对音量 |
| `CLIP_RATIO_MAX` / `MIN_SNR_DB` | 0.005 / 10 | 录音质量要求：允许的削波采样比例、最低信噪比 |
| `SAVE_CLIPS` | True | 保存注册录音；关闭后不做反向相似度检查 |
| `CONFUSION_MARGIN` / `VERIFY_CONFUSION_MARGIN` | −0.15 / −0.5 | 注册相似度检查的余量，越小越严格 |

界面配色、字体、得分曲线范围、各种二次确认时间等也在同一配置区，可按需调整。

## 使用建议

- **注册环境与使用环境尽量一致**：同一个麦克风、差不多的距离和音量。
- **选词**：4 个字以上，不是日常口头禅，各个词之间读音区别明显。
- **易混音**：只靠模型本身容易混淆的音来区分的词（如 lan/nan、in/ing、ai/an），两道关卡也难以分辨，建议避开。
- **调灵敏度**：漏唤醒多就切「灵敏」，误唤醒多就切「保守」。
- **备份**：重新烧录固件会清空 `enroll/`，需要保留的唤醒词请先备份。
