doa

Example of the component jason-mao/av_processor v0.6.2
# 3-Mic DOA Example

这个示例是一个独立的 `esp-idf` 三麦声源方向估计工程,不依赖 AFE,也不走当前仓库里的 `audio_processor` 链路。

如果你想先在 PC 上离线跑文件、调麦克风坐标和参数,可以看旁边的 Linux 版本:

- [examples/doa_linux/README.md](/home/xutao/workspace-20/av_processor_v2/examples/doa_linux/README.md)

目标是先做出一个效果优先、结构清晰、方便继续调参和替换前端的参考实现。算法链路参考了 ODAS 里常见的 `PHAT + 阵列几何搜索` 思路,但这里做的是适合 MCU 先跑起来的轻量版本:

- `I2S/TDM` 采集 3 路麦克风
- 50% overlap 的短时窗分析
- 自适应噪声门限
- `GCC-PHAT`
- 频域相干性加权和语音频带边缘 taper
- 基于三麦阵列几何的 `SRP-PHAT` 方位扫描
- 相关核积分、角度曲线平滑
- 置信度筛选和时间平滑

## 默认假设

- 采样率:`16 kHz`
- 分析窗:`512` 点
- FFT:`1024` 点
- 阵列:3 麦等边三角形,边长 `6 cm`
- 采集接口:`I2S/TDM`
- 默认读取 `4` 个 slot,其中 `slot 0/1/2` 分别对应 `mic0/1/2`

## 你最先要改的地方

都集中在 [main.c](/home/xutao/workspace-20/av_processor_v2/examples/doa/main/main.c) 顶部:

- `DOA_I2S_BCLK`
- `DOA_I2S_WS`
- `DOA_I2S_DIN`
- `DOA_I2S_TOTAL_SLOTS`
- `DOA_I2S_SLOT_MASK`
- `DOA_MIC0_SLOT_INDEX`
- `DOA_MIC1_SLOT_INDEX`
- `DOA_MIC2_SLOT_INDEX`
- `s_mic_positions`
- `DOA_MICx_GAIN`
- `DOA_MICx_DELAY_SAMPLES`
- `DOA_NOISE_FLOOR_ALPHA`
- `DOA_ADAPTIVE_RMS_GATE_FACTOR`
- `DOA_COHERENCE_ALPHA`
- `DOA_COHERENCE_FLOOR`
- `DOA_LAG_INTEGRATION_RADIUS_SAMPLES`
- `Component config -> DOA Example Configuration -> Use ESP-DSP FFT backend`

其中 `s_mic_positions` 很关键,方向估计是否准,很大程度取决于这里和真实麦克风位置是否一致。坐标单位是米,`0 deg` 表示指向阵列坐标系的 `+X` 方向,角度逆时针增加。

## 三麦位置怎么填

这里直接填每个麦克风中心点的二维坐标:

```c
static const doa_mic_position_t s_mic_positions[DOA_MIC_COUNT] = {
    { x0, y0 },
    { x1, y1 },
    { x2, y2 },
};
```

程序会自动从坐标算出:

- 每一对麦的间距
- 最大传播时延
- 每个扫描角度下的理论 TDOA

所以你不需要另外再填一张“距离表”。

### 例子 1:等边三角形,边长 6 cm

```c
static const doa_mic_position_t s_mic_positions[DOA_MIC_COUNT] = {
    { -0.0300f, -0.0173f },
    {  0.0300f, -0.0173f },
    {  0.0000f,  0.0346f },
};
```

这就是当前示例的默认值,三点的两两距离都会接近 `0.06 m`。

### 例子 2:一字排开,左右间距都是 4 cm

```c
static const doa_mic_position_t s_mic_positions[DOA_MIC_COUNT] = {
    { -0.0400f, 0.0f },
    {  0.0000f, 0.0f },
    {  0.0400f, 0.0f },
};
```

这种线阵也能用,但前后方向更容易出现对称歧义,所以三角阵通常更稳。

### 如果你手上只有麦间距,没有坐标

最简单的做法是先自己选一个坐标系,再把距离换成坐标:

- 先把 `mic0` 放在 `(0, 0)`
- 再把 `mic1` 放在 `(d01, 0)`
- 最后根据 `mic2` 到前两个麦的距离,算出 `mic2 = (x2, y2)`

如果已知:

- `d01 = |mic1 - mic0|`
- `d02 = |mic2 - mic0|`
- `d12 = |mic2 - mic1|`

那么可以用:

```text
x2 = (d02^2 - d12^2 + d01^2) / (2 * d01)
y2 = sqrt(d02^2 - x2^2)
```

然后写成:

```c
static const doa_mic_position_t s_mic_positions[DOA_MIC_COUNT] = {
    { 0.0f, 0.0f },
    { d01, 0.0f },
    { x2,   y2   },
};
```

如果算出来 `y2` 很接近 `0`,说明三个麦基本共线。

## 算法文件

- [main.c](/home/xutao/workspace-20/av_processor_v2/examples/doa/main/main.c): I2S/TDM 初始化、采集和日志输出
- [doa_estimator.c](/home/xutao/workspace-20/av_processor_v2/examples/doa/main/doa_estimator.c): GCC-PHAT、相干性加权、SRP 扫描、置信度和平滑
- [fft.c](/home/xutao/workspace-20/av_processor_v2/examples/doa/main/fft.c): FFT 后端抽象,默认使用内置 radix-2 FFT,可切换到 `esp-dsp`

## 当前算法流程

按当前代码实现,单帧 DOA 处理流程是这样的:

1. `I2S/TDM` 连续采集 3 路麦信号,按 `512` 点窗、`256` 点 hop 形成 50% overlap 分析帧。
2. 每路先去掉直流分量,再计算整帧 `rms`;代码会在线跟踪背景噪声底,并计算 `adaptive_rms_gate`。
3. 如果 `rms` 低于 `adaptive_rms_gate`,这一帧直接判成 `inactive`;否则才进入后续 DOA 处理。
4. 对每路信号乘 Hann 窗、应用 `DOA_MICx_GAIN`,再做 `1024` 点 FFT;如果配置了 `DOA_MICx_DELAY_SAMPLES`,会在频域做固定延时补偿。
5. 在 `DOA_BAND_LOW_HZ ~ DOA_BAND_HIGH_HZ` 频带内做 `GCC-PHAT`,同时对频带边缘做平滑 taper,避免硬切带带来的频域突变。
6. 对每对麦的互功率谱做跨帧平滑,估计频域相干性;相干性高的频点权重大,相干性低的频点会被压低。
7. 对每对麦做 IFFT,得到时延相关曲线;随后提取主峰、次峰、峰值突出度,并得到这一对麦当前帧的 `pair_weight`。
8. 对 `0~360 deg` 所有扫描角度,用阵列几何计算理论时延,在三对麦相关曲线上取值并加权求和,形成整圈角度得分曲线。
9. 角度打分不是只取单点相关值,而是对理论时延附近做一个小范围相关核积分;之后再对整圈角度曲线做一次轻量平滑,降低孤立误峰。
10. 取最高峰作为当前角度候选,并用左右邻点做抛物线插值得到 `raw_angle_deg`;再找远离主峰的第二强峰用于计算主峰分离度。
11. 最终 `confidence` 由主峰分离度、三对麦几何一致性和麦对质量联合得到;只有分数和置信度都过门限,才认为 `direction_valid=true`。
12. 有效方向会再做方向向量平滑,输出更稳定的 `smoothed_angle_deg`。

如果只看一句话,可以把它理解成:

```text
采集 -> 去直流/自适应RMS门限 -> FFT -> GCC-PHAT -> 频域相干性加权 -> 三麦几何扫描 -> 主峰筛选 -> 置信度判断 -> 向量平滑
```

## 新增参数说明

- `DOA_NOISE_FLOOR_ALPHA`
  背景噪声底平滑系数。越大,噪声底变化越慢;越小,环境噪声变化时门限跟得越快。

- `DOA_ADAPTIVE_RMS_GATE_FACTOR`
  自适应语音门限倍率。实际门限约等于 `max(DOA_RMS_GATE, noise_floor_rms * DOA_ADAPTIVE_RMS_GATE_FACTOR)`。

- `DOA_COHERENCE_ALPHA`
  频域相干性平滑系数。越大越偏向历史帧,更稳、更抗混响,但响应也会更慢。

- `DOA_COHERENCE_FLOOR`
  相干性权重下限。越小,低相干频点抑制越强;越大,算法更保守,不容易把弱语音频点压得太狠。

- `DOA_LAG_INTEGRATION_RADIUS_SAMPLES`
  角度扫描时,对理论时延附近做积分的半径。越大越抗小误差和混响,但太大可能让角度峰变钝。

## 构建

```bash
cd examples/doa
idf.py set-target esp32s3
idf.py build
idf.py flash monitor
```

如果你想切到 `esp-dsp` 的 FFT 后端:

```bash
idf.py menuconfig
```

然后打开:

```text
Component config
  -> DOA Example Configuration
    -> Use ESP-DSP FFT backend
```

说明:

- 这个开关默认关闭,默认仍然使用示例里自带的 radix-2 FFT
- 打开后会改用 `esp-dsp` 的 `dsps_fft2r_fc32 + dsps_bit_rev_fc32`
- 当前工程会优先使用本机 `DOA_ESP_DSP_COMPONENT_DIR` 环境变量指定的 `esp-dsp` 组件路径
- 如果没有设置环境变量,会回退到当前机器上的一个本地 `esp-dsp` 副本路径
- 如果你把工程拷到别的机器,建议显式设置 `DOA_ESP_DSP_COMPONENT_DIR`

## 日志含义

- `inactive`: 当前窗的能量低于自适应门限 `adaptive_rms_gate`
- `unstable`: 有声音,但方向峰值不够突出,`confidence` 低
- `angle`: 当前窗方向稳定,输出原始角度和滤波后的平滑角度

## `doa_estimator_result_t` 说明

结果结构体定义在 [doa_estimator.h](/home/xutao/workspace-20/av_processor_v2/examples/doa/main/doa_estimator.h),每处理一帧分析窗,就会返回一份当前状态:

```c
typedef struct {
    bool voice_active;
    bool direction_valid;
    float raw_angle_deg;
    float smoothed_angle_deg;
    float confidence;
    float rms;
    float adaptive_rms_gate;
    float noise_floor_rms;
    float best_score;
    float second_score;
    float pair_quality;
    float pair_consistency;
} doa_estimator_result_t;
```

### 字段含义

- `voice_active`
  表示这一帧是否达到语音活动门限。它主要由 `rms` 和 `adaptive_rms_gate` 比较得到。

- `direction_valid`
  表示这一帧的方向结果是否可靠。当前实现要求 `best_score` 足够大,并且 `confidence >= DOA_CONFIDENCE_GATE`。

- `raw_angle_deg`
  当前帧直接估出来的原始角度,单位是度,范围是 `[0, 360)`。它响应快,但更容易抖动。

- `smoothed_angle_deg`
  对方向向量做时间平滑后得到的角度,单位也是度。它更稳定,更适合上层使用。

- `confidence`
  当前结果的综合置信度,范围大致在 `[0, 1]`。它不是单一指标,而是 3 个因素的加权组合:
  - 主峰与次峰的分离度
  - 三对麦之间的几何一致性
  - 每对麦互相关峰本身的质量

- `rms`
  当前分析窗的均方根能量,用来判断这一帧有没有足够强的声音。

- `adaptive_rms_gate`
  当前帧实际使用的语音活动门限。它不会低于 `DOA_RMS_GATE`,但会随着背景噪声底升高而自动抬高。

- `noise_floor_rms`
  在线估计的背景噪声 RMS。它主要在 `inactive` 帧,以及部分低能量 `unstable` 帧里更新。

- `best_score`
  扫描所有角度后,最优方向对应的总分。它是内部评分,不是标准化物理量,通常结合 `second_score` 一起看更有意义。

- `second_score`
  除主峰附近范围以外,第二强候选角度的得分。它越接近 `best_score`,说明方向越不明确。

- `pair_quality`
  3 对麦质量的平均值。当前实现里它综合了两部分:
  - 互相关主峰是否突出
  - 这一对麦在频域上是否具有稳定相干性

  越高说明各对麦的相关结果越干净,也更像稳定直达声。

- `pair_consistency`
  3 对麦观测到的时延和当前最终角度的理论时延是否一致。越接近 `1`,说明几何上越自洽。

### 这些字段怎么一起看

- `voice_active == false`
  优先看 `rms`。此时说明声音太弱或者没有有效声源,方向结果不应该被当作有效输出。

- `voice_active == true` 且 `direction_valid == false`
  这是调参阶段最有价值的状态。重点看:
  - `confidence` 是否偏低
  - `pair_quality` 是否偏低
  - `pair_consistency` 是否偏低

- `direction_valid == true`
  表示这次结果可用:
  - 看 `raw_angle_deg` 获取瞬时方向
  - 看 `smoothed_angle_deg` 获取稳定输出

### 常见诊断经验

- `rms` 很低
  声音太弱,或者 `DOA_RMS_GATE` 设得太高。

- `best_score` 和 `second_score` 很接近
  说明主峰不突出,常见于混响、噪声大、多个候选方向同时存在。

- `pair_quality` 很低
  说明某些麦对的互相关峰不清晰,常见原因包括:
  - slot 映射不对
  - 某一路增益明显异常
  - 背景噪声或混响太重

- `pair_consistency` 很低
  说明三对麦算出来的时延彼此对不上。常见原因包括:
  - 麦克风坐标填错
  - 某一路固定延时没有校正
  - 阵列近共线导致几何歧义

### 和日志的对应关系

- `inactive rms=... gate=... noise=...`
  主要反映 `voice_active == false`

- `unstable raw=... conf=... pair_q=... pair_c=... rms=... gate=... noise=... score=...`
  说明 `voice_active == true`,但 `direction_valid == false`

- `angle raw=... smooth=... conf=... pair_q=... pair_c=... rms=... gate=... noise=... score=...`
  说明当前帧方向结果已经通过有效性判断

## 调参建议

- 人声弱、总是 `inactive`:降低 `DOA_RMS_GATE`
- 环境噪声大、总是误触发:提高 `DOA_ADAPTIVE_RMS_GATE_FACTOR`
- 噪声环境变化快:降低 `DOA_NOISE_FLOOR_ALPHA`
- 角度跳动大:提高 `DOA_SMOOTH_ALPHA`
- 总是 `unstable`:降低 `DOA_CONFIDENCE_GATE`,或者先核对麦位和 slot 映射
- 低频噪声大:提高 `DOA_BAND_LOW_HZ`
- 高频啸叫或尖锐噪声影响大:降低 `DOA_BAND_HIGH_HZ`
- 某一路麦明显更大或更小:调整 `DOA_MICx_GAIN`
- 某一路总是存在固定相位偏差:微调 `DOA_MICx_DELAY_SAMPLES`
- 混响环境下主峰不稳定:适当提高 `DOA_COHERENCE_ALPHA`
- 方向偶发跳点:适当提高 `DOA_LAG_INTEGRATION_RADIUS_SAMPLES`
- 有效结果太少:适当降低 `DOA_COHERENCE_FLOOR`

一个比较实用的调参顺序是:

1. 先把 `s_mic_positions`、slot 映射、`DOA_MICx_DELAY_SAMPLES` 校准到基本正确。
2. 再看 `rms/gate/noise` 三个日志值,先调 `DOA_RMS_GATE` 和 `DOA_ADAPTIVE_RMS_GATE_FACTOR`,保证静音时是 `inactive`,正常说话时大多数帧能进入处理。
3. 接着看 `pair_quality` 和 `pair_consistency`,确认几何和前端没有明显问题。
4. 最后才去调 `DOA_COHERENCE_ALPHA`、`DOA_COHERENCE_FLOOR` 和 `DOA_LAG_INTEGRATION_RADIUS_SAMPLES`,处理混响和偶发跳点。

## 这一版比初版多了什么

- 每对麦先做峰值和峰值突出度分析
- 增加了背景噪声底跟踪和自适应 RMS 语音门限
- 扫描时按 pair 质量做加权,而不是三对麦一视同仁
- 对每个频点增加了跨帧相干性权重,更偏向稳定的直达声频点
- 对语音分析频带边缘做了平滑 taper,减少硬切带带来的频域突变
- 角度扫描时不只取单点相关值,而是做一个小范围相关核积分
- 对整圈角度得分曲线做了轻量平滑,降低孤立误峰
- 最终置信度由 3 部分组成:
  - 主峰与次峰的分离度
  - 三对麦的几何一致性
  - 三对麦各自相关峰的质量
- 预留了麦克风增益和固定延时标定入口

## 注意

这是一个“先把效果做出来”的版本,不是最终优化版。当前实现还没有做:

- 麦克风增益标定
- 多源分离
- 更完整的混响环境鲁棒增强
- 更高效的 FFT / SIMD / PSRAM 优化
- 板级 codec 自动探测

不过和最早那版相比,现在已经补上了几项比较关键的稳态优化:

- 频域相干性加权
- 自适应噪声门限
- 语音频带边缘 taper
- 相关核积分
- 角度曲线轻量平滑

所以它已经不再是最原始的“裸 GCC-PHAT 扫角度”版本,而是一个更适合直接上板调试的参考实现。

如果你的硬件输出不是 `16-bit Philips TDM`,需要同步调整 [main.c](/home/xutao/workspace-20/av_processor_v2/examples/doa/main/main.c) 里的 `slot_cfg` 和读数解析方式。

To create a project from this example, run:

idf.py create-project-from-example "jason-mao/av_processor=0.6.2:doa"

or download archive (~17.51 KB)