首次上传

This commit is contained in:
zzwang committed 2026-09-29 22:41:29 +08:00
1 parent 133fe739ed
commit 07e0cac3ac
71 files changed
+23545

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
CompileFlags:
Remove: [-f*, -m*]
+64
View File
@@ -0,0 +1,64 @@
# ESP-IDF build outputs
/build/
/build-*/
/managed_components/
# Local ESP-IDF configuration (keep sdkconfig.defaults in Git)
/sdkconfig
/sdkconfig.old
/sdkconfig.old.*
# Local scratch and generated files
/tmp/
/dist/
/out/
# Generated microphone calibration recordings
/PC_SoftWare/calibration/data/real/
/PC_SoftWare/calibration/data/sim/
# IDE/editor state containing machine-specific paths and ports
/.vscode/
/.idea/
*.code-workspace
*.swp
*.swo
*~
# Python caches and virtual environments
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.coverage.*
coverage.xml
htmlcov/
.tox/
.nox/
.hypothesis/
.venv/
venv/
# CMake/Ninja files accidentally generated outside build directories
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
compile_commands.json
.ninja_*
# Logs, crash dumps, and temporary files
*.log
*.tmp
*.temp
*.bak
*.dmp
core
core.*
# Operating-system metadata
.DS_Store
Thumbs.db
Desktop.ini
$RECYCLE.BIN/
+7
View File
@@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.16)
set(IDF_TARGET "esp32p4")
set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/src")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32p4_usb_audio_hs)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
# PCB 打板注意事项
!!**特别注意板子厚度**
EashEDA/LED_ring_v0.1 ----------- 灯环板 板厚0.8mm
EashEDA/MainBoard_v0.3 ----------- 主板 板厚1.2mm
EashEDA/MicCell_v0.2 ------------- 咪板 板厚1.2mm
## 物料购买链接推荐
本制作需要的个别物料比较冷门,以下是我购买的渠道,省去大家选购烦恼:
[ESP32P4](https://item.taobao.com/item.htm?id=1069648375438&mi_id=0000Bty9AoDk8Pm7osqsQpSyEjnrG0gd3J2TxzlnfYYkg9k&spm=tbpc.boughtlist.suborder_itemtitle.1.3b022e8dl4WFlU)
[PCMD3180](https://item.taobao.com/item.htm?id=925148836133&mi_id=0000tLo6H694iLw_67OtnppISD4evxnzwUPfeShn5z4tfi4&spm=tbpc.boughtlist.suborder_itemtitle.1.3b022e8dl4WFlU)
[数字硅麦](https://item.taobao.com/item.htm?id=657891737326&mi_id=0000F52byEd69URltpPhxIZJAkliFo8_sBrmgKc14Bk8eLE&spm=tbpc.boughtlist.suborder_itemtitle.1.3b022e8dl4WFlU)
[板对板连接器](https://item.taobao.com/item.htm?id=1040176945284&mi_id=0000N8Fwsg3L-xKHa2momL1XCa3c24ZhiaalgcqPLpUbHfQ&spm=tbpc.boughtlist.suborder_itemtitle.1.3b022e8dl4WFlU)
[TypeC弯头数据线](https://detail.tmall.com/item.htm?id=1067254708680&maskChannel=bybtrs&pisk=hao-Rn1Pwc2vAkpD6x4TwzZ3lKAMAD2QdvkFd7ZW8MtIsvj3KURFvqHZ97vezu0Y9jGYNM4HzJILGbInrYcHvWMagkDuZWc0JjMtZXVH4HeIsjIuRDYmUY8G-IY-vzwbc8kfN8aW3nKBHkOMsHQchzRoZnZqWU_bhSydVMZIOowbIRrQd8ZBHSwLQw1SOk9xhSyhNJw7PiZbKJ5QAzZClswLBM17AM_fhJPbOkNIOx9YKSNQAkGIhaM_PhT93WbFLqfGzYNAXxoMyz_rEQIdAm3Yk5sgMQYJpb4jLynFLM3-FVlUCb1W9Woxh0gs1IbuNVeKI2IEbDvfRw873CQ9LFbzLGT3H5v7Pw7UWmyYsKpA8wz73-FMFg_F8PnV.&spm=tbpc.boughtlist.suborder_itemtitle.1.67002e8dkLCuOH&u_channel=bybtqdyh&umpChannel=bybtqdyh)
其他物料均为常见物料,自行选择即可
所有PCB和原理图均为可编辑的文件,如需要替换硅麦器件,自行替换对应封装。
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.python-version
# Upstream dependency cloned separately from the official repository
/pyroomacoustics/
+4
View File
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
project(esp32_8mic LANGUAGES CXX)
add_subdirectory(cpp)
+211
View File
@@ -0,0 +1,211 @@
# ESP32 八麦克风 DOA 监视器
本文件夹的程序运行在PC端上位机,是纯python程序 windows/Linux/MaxOS均可运行
从 USB 音频设备采集八路同步音频,使用 Pyroomacoustics 的SRP-PHAT 或 NormMUSIC
算法估计单个远场声源的水平方位,并根据估计方向引导频域 MVDR 波束形成器。
界面会同时显示声源方位、各通道电平和波束形成后的输出电平。
## 阵列与角度约定
- 圆形阵列半径为 56 mm。
- USB 通道必须按照 CH1 至 CH8 的顺序排列。
- 各通道的数学方位角通过 `src/doa_estimator.py` 中的
`MIC_AZIMUTHS_DEG` 配置。
- 界面中的 0° 指向右侧(`+x`),角度沿逆时针方向增大。
- 当前通道布局中 CH1 位于 157.5°,其余通道沿逆时针方向以 45° 间隔均匀分布。
如果实际 USB 通道顺序或阵列参考方向不同,请修改 `MIC_AZIMUTHS_DEG`。
即使所有通道完全同步,错误的通道顺序仍可能造成方位镜像或其他定位错误。
## Pyroomacoustics 源码
`pyroomacoustics/` 是独立的上游仓库,不纳入本项目的 Git 管理。首次检出本项目后,
请从官方仓库克隆(当前项目已验证的上游提交为 `ff7d61f`):
```powershell
git clone https://github.com/LCAV/pyroomacoustics.git pyroomacoustics
git -C pyroomacoustics checkout ff7d61f219e4eb41489963c4bb5f57bea5bc2c69
```
项目要求上游源码位于以下路径:
```text
./pyroomacoustics/pyroomacoustics/doa/srp.py
./pyroomacoustics/pyroomacoustics/doa/music.py
./pyroomacoustics/pyroomacoustics/doa/normmusic.py
```
程序只加载上游纯 Python 实现的 SRP-PHAT 和 NormMUSIC 算法。NormMUSIC
会先对各频率的 MUSIC 伪谱进行归一化,再沿频率轴融合,相比普通 MUSIC 对宽带
信号更加稳健。实时 DOA 估计不需要房间仿真使用的原生 `libroom` 扩展。
## 运行程序
安装或同步依赖,然后列出可用的音频输入设备:
```powershell
uv sync
uv run python src/main.py --list-devices
```
自动选择 USB 音频设备并启动:
```powershell
uv run python src/main.py
```
启动时终端会显示本次实际打开的设备编号、设备名称、驱动接口(Host API)、
使用/可用输入通道数、实际采样率和设备默认采样率。如果无法打开兼容的八通道
输入设备,终端会显示进入模拟预览模式的具体原因。
默认不再强制使用 48 kHz。程序会优先选择能够更准确报告端点格式的 Windows
WASAPI/WDM-KS 设备,读取其 `default_samplerate`,再以音频流实际报告的采样率
统一配置采集、SRP-PHAT、NormMUSIC 和 MVDR。顶部的 `DEVICE RATE` 显示本次音频
流实际采用的采样率。`--list-devices` 的输出同时包含每个端点的默认采样率和
Host API,便于区分同一硬件的 MME、DirectSound、WASAPI 和 WDM-KS 条目。
也可以使用 PortAudio 设备序号或设备名称中不区分大小写的部分文本来选择设备:
```powershell
uv run python src/main.py --device 3
uv run python src/main.py --device "USB Audio"
```
设备编号可能因重启、重新插拔或增加其他音频设备而改变。运行前可随时重新查询
当前可用的设备编号:
```powershell
uv run python src/main.py --list-devices
```
如需覆盖自动检测结果,可以显式指定采样率:
```powershell
uv run python src/main.py --sample-rate 24000
```
PortAudio 能查询的是操作系统音频端点的默认采样率和音频流协商后的实际采样率,
无法对所有驱动直接读取 ADC 内部时钟。Windows 上的旧式 MME/DirectSound
端点可能报告经过共享混音器转换后的 44.1/48 kHz,因此自动选择会优先使用
WASAPI,其次使用 WDM-KS。显式使用数字设备序号时,程序会严格使用指定端点。
估计器使用 500 Hz 至 4 kHz 的频段和 1° 间隔的方位搜索网格。STFT 参数会
根据设备采样率自动缩放:48 kHz 使用 `nfft=512、hop=256、window=8192`,
24 kHz 使用 `nfft=256、hop=128、window=4096`。两套配置的频率间隔均为
93.75 Hz,STFT 帧长均约为 10.67 ms,帧重叠率均为 50%,滚动窗口时长均约
为 171 ms。因此 24 kHz 模式在保持原有时频分辨率和处理延迟的同时,减少了
FFT 与后续矩阵计算量。
当音频低于 -58 dBFS 时,DOA 区域显示 `LOW SIGNAL`,不再用噪声刷新方位。
如果此前已经获得过有效 DOA,MVDR 会冻结并继续使用最后一次有效导向角,同时
使用当前音频窗口更新协方差和流式输出,监听不会因低信号而断流;MVDR 卡片显示
`HOLD` 和实际保持的 `STEER` 角度。程序启动后尚未得到过有效方向时,MVDR 才会
等待首次定位。切换 SRP-PHAT/NormMUSIC 会清除保持角并重新定位。
上述参数均可在 `DoaConfig` 中配置,其中 MVDR 对角加载系数由
`mvdr_diagonal_loading` 设置。程序会检查 4 kHz 分析上限必须低于当前采样率
的奈奎斯特频率。
使用右侧面板中的 `SRP-PHAT / NormMUSIC` 分段开关选择 DOA 算法,也可以按
`M` 键切换。切换算法不会中断当前 USB 音频采集流,但会重新开始方位估计。
如果没有兼容的八通道输入设备,界面会进入带有明确标识的模拟预览模式。
## DOA 引导的 MVDR 波束形成
每个有效的 DOA 结果都会用于引导一个八输入、单输出的频域 MVDR 波束形成器。
在 500 Hz 至 4 kHz 的分析频段内,算法根据当前滚动窗口估计空间协方差矩阵,
执行对角加载,并在检测到的声源方向上施加单位增益的无失真约束。该频段之外
使用更稳定的延迟求和权重。MVDR 与 SRP-PHAT/NormMUSIC 共用麦克风坐标、声速、
STFT 参数和角度约定。每个处理周期只执行一次多通道去直流、加窗和 FFT;DOA
与 MVDR 直接复用同一份 `snapshots × frequencies × channels` 频谱,不再重复
计算 STFT。
界面采用三栏布局:左侧为阵列方向图,中间为 DOA 结果、算法选择和八通道电平,
最右侧为独立的 MVDR 专用栏。该栏顶部的 `MVDR OUTPUT` 卡片在有最新结果时显示
`LIVE` 状态、单通道输出电平、输入电平、引导角度和输出电平条。
`LiveAudioSource.read_beamformed_audio()` 可返回最新单通道窗口的结果副本:
24 kHz 时为 4096 点,48 kHz 时为 8192 点。该数据是滚动窗口快照,相邻结果
之间存在重叠,因此不能直接连续拼接成录音。
卡片中的 `MVDR IN` 不代表某一个麦克风通道,而是当前窗口内八个通道的整体
RMS 输入电平。程序先对所有通道、所有采样点的平方值求平均,再开平方得到
整体 RMS,最后转换为 dBFS:
```text
MVDR IN = 20 × log10(sqrt(sum(x[m,n]²) / (8 × N)))
```
其中 `m` 为通道编号,`n` 为采样点编号,`N` 为每个通道的采样点数。因此它
等价于先对八个通道的线性功率求平均后再转换为 dBFS,并不是八个通道 dBFS
读数的算术平均。若八个通道的电平接近,该数值会接近任意单通道的 RMS 电平。
MVDR 专用栏还提供 `MANUAL STEERING` 角度输入框和开关。输入采用与 DOA 相同的
角度约定:0° 指向右侧,逆时针为正;提交时会归一化到 0°~359.9°。开关显示
`MANUAL ON` 时,MVDR 使用该角度而不改变 DOA 算法的检测结果,即使当前为低信号
也会继续按手动方向处理;输出卡片状态显示 `MANUAL`。阵列方向图使用浅蓝色箭头
显示手动方向。切回 `AUTO DOA` 后,浅蓝色箭头消失,MVDR 恢复使用最新有效 DOA。
## MVDR 实时监听
最右侧 MVDR 专用栏按照 `MANUAL STEERING`、`MVDR OUTPUT`、`MONITOR`、
`OUTPUT DEVICE`、`GAIN` 的顺序纵向排列引导设置、结果与监听控件:
- `MONITOR` 开关用于启动或停止监听,键盘 `L` 键也可以切换。
- `OUTPUT DEVICE` 下拉列表显示支持当前采样率、单通道 Float32 输出的设备,
设备名称后同时标注 Host API。
- `GAIN` 滑动条范围为 -10 dB~+40 dB,默认值为 0 dB。
- 增益按照 `10^(dB/20)` 应用于 MVDR 单通道输出,超过 `[-1, 1]` 的采样会被
限幅,此时监听状态显示 `CLIPPING`。
监听默认关闭,以免扬声器输出重新进入麦克风阵列并产生强声反馈。启用前应优先
选择耳机,或确保监听扬声器与麦克风阵列之间具有足够的声学隔离。
MVDR 完整滚动窗口的左右边缘缺少相邻 STFT 帧的重叠贡献,不能直接用于连续
播放。监听路径使用有状态的流式 WOLA:只处理全局采样序号中尚未合成的新频谱
帧,保存当前帧的后半个 Hann 窗,等下一帧到达后与其前半窗重叠相加,再按窗
平方和归一化。只有获得完整前后帧贡献的 `hop` 才会送往输出设备,因此不会播放
有限窗口中归一化条件很差的末端。24 kHz 配置会增加一个 128 点、约 5.33 ms
的合成等待时间。
空间协方差矩阵同时采用系数 0.70 的跨窗口指数平滑,相邻批次使用不同 MVDR
权重时,Hann 重叠也会对时域输出自然交叉渐变。已完成的 WOLA 音频块进入最多
八项的有界播放队列;监听启动时先等待第一个完整块,再打开 PortAudio 输出,
避免初始欠载。播放使用独立的阻塞输出线程,不占用 Pygame 界面线程和 USB
输入线程。界面中的 `U` 表示输出欠载次数,`D` 表示队列溢出或源采样序号不连续
的次数。
录音模块或网络发送模块仍可单独使用上述快照接口。
Pyroomacoustics 中考虑房间响应的 rake-MVDR 实现需要已知房间脉冲响应以及
声源和干扰源的位置。实时 DOA 链路无法提供这些输入,因此本项目直接根据实测
协方差矩阵实现等效的宽带 MVDR 无失真约束。
## 音频采集架构
USB 输入在独立的普通优先级线程中使用 PortAudio 阻塞读取模式。音频流设置为
`blocksize=0`,由主机音频 API 选择稳定的原生块大小,采集线程每次请求读取
1024 帧。RMS 计算和 DOA 数据提交均不会在 PortAudio 实时回调中执行。
DOA 输入保存在预分配的定长八通道环形缓冲区中,24 kHz 时为 4096×8,
48 kHz 时为 8192×8。新音频会覆盖最旧数据,因此即使估计过程延迟,也不会
造成队列或处理延迟持续增长。声源方向面板中的 `OVF n` 表示 PortAudio 检测到
的输入溢出次数。`AUDIO STALE` 或 `AUDIO ERROR` 表示采集线程已经停止提供
新的音频块。
## 测试
```powershell
uv run pytest -q
```
测试会在多个方位合成远场平面波,验证从 STFT 到 Pyroomacoustics 的两条完整
算法链路,包括 0°/360° 边界、运行时算法切换、有界环形缓冲区和 USB 阻塞采集。
测试还会验证 MVDR 对目标方向的单位响应、对轴外干扰的抑制能力,以及后台线程
能否正确发布波束形成音频,并检查 DOA 与 MVDR 在每个处理周期只执行一次共享
STFT。监听测试还会检查有状态 WOLA 输出、输出设备参数、增益换算和削波保护。
连续合成测试会额外验证 WOLA 块的全局采样区间首尾相接,并确保有限窗口边缘
尖峰不会进入监听音频。
## 实际麦克风增益校准
'calibration/' 中是麦克风增益一致性校准使用的脚本,具体极少查看 'calibration/README.md'
+148
View File
@@ -0,0 +1,148 @@
# 离线麦克风增益校准
`src/calibrate_from_recordings.py` 按 `Gain Self-Calibration Procedure for
Microphone Arrays.pdf` 的公式 (5)--(9) 处理已经录制的 8 路同步音频。由于
实验角度已知,目录名直接提供相对 CH0 的 DOA,不运行 DOA 估计器。
## 生成模拟数据
需要 `pyroomacoustics`(项目依赖已经声明)。从项目根目录执行:
```powershell
uv run python calibration/src/generate_sim_data.py
```
默认在 `calibration/data/sim/` 下生成 `degree0`、`degree15`、...、
`degree345` 共 24 组数据。每个目录包含:
```text
ch0.wav ... ch7.wav # 24 kHz,同步,单通道 float32 WAV
metadata.json # 阵列、声源和注入增益信息
```
`degree15` 表示声源 DOA 比 CH0 逆时针旋转 15°,不是绝对坐标系中的 15°。
生成器默认注入一组通道增益,用来验证校准结果;可以用
`--channel-gain-db` 替换。
## 运行校准
```bash
uv run python calibration/src/calibrate_from_recordings.py \
--data calibration/data/sim \
--iterations 5 \
--alpha 0.5 \
--log calibration/data/calibration.log \
--output calibration/data/calibration_result.json
```
日志对每个方向输出拟合斜率、截距、RMSE、相对误差、实测/预测能量和该方向
的候选修正量;最后输出跨所有方向汇总的 8 个线性校正系数和 dB 校正量。
脚本输出的系数是直接乘到音频样本上的补偿系数:
```python
corrected[:, channel] = raw[:, channel] * correction[channel]
```
校准程序使用几何平均跨方向汇总增益,并在离线迭代中使用 `alpha` 平滑更新。
校正系数会归一化为平均值 1,因此只改变通道匹配,不改变整体音量。
## `calibration_result.json` 字段说明
`calibration_result.json` 是校准程序生成的 JSON 结果文件。它保存了每个声源
方向的拟合信息、最终的 8 通道补偿系数以及迭代过程,**不是新的音频文件**。
顶层字段如下:
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `directions` | 数组 | 使用最终收敛的补偿系数,对每个 `degreeXX` 方向重新计算的拟合结果。 |
| `correction_linear` | 长度为 8 的数组 | 最终线性补偿系数,对应 `ch0`~`ch7`;运行时直接乘到对应通道音频上。 |
| `correction_db` | 长度为 8 的数组 | 与 `correction_linear` 对应的 dB 补偿量,计算方式为 `20 * log10(correction_linear)`。 |
| `iterations` | 整数 | 离线迭代次数,由命令行参数 `--iterations` 指定。 |
| `alpha` | 浮点数 | 每轮迭代的更新权重,由 `--alpha` 指定。数值越大,更新越快,也越容易受单次拟合误差影响。 |
| `history` | 数组 | 每一轮、每一个方向的中间拟合记录,用于检查收敛过程。 |
### `correction_linear` 和 `correction_db`
这两个数组是最重要的运行时结果,数组下标就是通道号:
```text
correction_linear[0] / correction_db[0] -> ch0
correction_linear[1] / correction_db[1] -> ch1
...
correction_linear[7] / correction_db[7] -> ch7
```
例如:
```python
from scipy.io import wavfile
import numpy as np
result = ... # 读取 calibration_result.json
audio = ... # shape: [samples, 8]
correction = np.asarray(result["correction_linear"], dtype=float)
corrected_audio = audio * correction[np.newaxis, :]
```
补偿系数的平均值会被归一化为 1,所以它只负责平衡通道间增益,不负责改变
整体音量。若要查看某个通道需要增加还是衰减,可直接看 `correction_db`:
- 正值:该通道需要放大;
- 负值:该通道需要衰减;
- 0 dB:该通道接近整体平均水平。
### `directions` 中的字段
`directions` 数组每一项对应一个方向,例如 `degree90`:
| 字段 | 含义 |
| --- | --- |
| `folder` | 参与计算的数据目录名,例如 `degree90`。 |
| `angle_deg` | 相对于 CH0 的声源角度,单位为度;正方向为逆时针。 |
| `slope` | 能量拟合直线的斜率 (a_1)。 |
| `intercept` | 能量拟合直线的截距 (a_0)。 |
| `rmse` | 实测通道能量与拟合能量之间的均方根误差,越小通常表示拟合越好。 |
| `relative_error` | 相对拟合误差,程序内部以小数保存;例如 `0.04` 表示约 `4%`。 |
拟合直线来自论文中的公式:
```text
E_predicted(d) = slope * d + intercept
```
其中 `d` 是麦克风坐标在已知 DOA 方向上的投影,`slope` 和 `intercept`
的数值单位取决于输入音频的幅值标度,因此主要用于比较拟合质量,不建议跨不同
录音音量直接比较其绝对大小。
### `history` 中的字段
`history` 保存每次迭代、每个方向的中间结果。它的条目数量通常等于:
```text
iterations × 方向数量
```
例如使用 5 轮、24 个方向时共有 120 条记录。除 `iteration` 外,其余字段与
`directions` 基本相同:
| 字段 | 含义 |
| --- | --- |
| `iteration` | 当前是第几轮迭代,从 1 开始。 |
| `angle_deg` | 当前方向,相对于 CH0 的角度。 |
| `slope`、`intercept` | 当前轮的能量直线拟合参数。 |
| `rmse` | 当前轮的拟合均方根误差。 |
| `relative_error` | 当前轮的相对拟合误差。 |
通常可以观察到:随着迭代进行,`relative_error` 和 `rmse` 整体下降,最终的
`correction_linear` 或 `correction_db` 逐渐稳定。如果某几个方向的误差明显高于
其他方向,应检查对应录音是否存在噪声、削波、混响、声源移动或通道异常。
### 使用结果时的注意事项
1. `correction_linear` 的顺序必须与录音文件的 `ch0.wav`~`ch7.wav` 顺序一致。
2. 这些系数是针对当前麦克风、前置放大器、采样增益和采样率条件得到的;更换硬件
或改变模拟前端后应重新校准。
3. 模拟数据中的 `correction_db` 主要用于验证算法是否找回生成时注入的通道增益,
实际录音时应以真实校准结果为准。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
import os
import shutil
import re
# ===== 配置路径 =====
src_dir = r"d:\Users\zzwang\Desktop\Record\未命名混音项目 4\未命名混音项目 4_Recorded" # 原始目录
dst_root = r"E:\esp32_8mic\calibration\data\real" # 目标根目录
# 匹配 _001 ~ _024
pattern = re.compile(r'_(\d{3})')
for filename in os.listdir(src_dir):
src_path = os.path.join(src_dir, filename)
# 跳过文件夹,只处理文件
if not os.path.isfile(src_path):
continue
match = pattern.search(filename)
if not match:
continue
x = int(match.group(1))
# 只处理 _001 ~ _024
if not 1 <= x <= 24:
continue
degree = (x - 1) * 15
# 例如 degree0、degree15、degree30...
dst_dir = os.path.join(dst_root, f"degree{degree}")
# 如果目录不存在则自动创建
os.makedirs(dst_dir, exist_ok=True)
# 去掉角度编号,将轨道 1 / 轨道_1 等格式改为 ch0 ~ ch7
dst_filename = filename[:match.start()] + filename[match.end():]
dst_filename = re.sub(
r'轨道[_\s]+([1-8])(?!\d)',
lambda m: f"ch{int(m.group(1)) - 1}",
dst_filename,
)
dst_path = os.path.join(dst_dir, dst_filename)
shutil.copy2(src_path, dst_path)
print(f"{filename} -> degree{degree}/{dst_filename}")
print("复制完成")
@@ -0,0 +1,167 @@
"""Offline gain self-calibration using known DOA values from folder names.
The implementation follows equations (5)--(9) of the supplied paper. Since
the coefficients returned here are applied directly to audio samples, the
correction ratio is sqrt(E_predicted / E_measured), i.e. the inverse of the
paper's path-gain estimate.
"""
from __future__ import annotations
import argparse
import json
import logging
from pathlib import Path
import sys
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from calibration.src.calibration_common import ( # noqa: E402
CHANNEL_COUNT,
FitResult,
channel_energies,
fit_energy_line,
folder_angle_deg,
load_folder_audio,
projection_coordinates,
)
LOG = logging.getLogger("calibration")
def recording_folders(data_root: Path) -> list[Path]:
folders = []
for path in data_root.iterdir():
if path.is_dir() and path.name.lower().startswith("degree"):
folder_angle_deg(path) # validate the name early
folders.append(path)
return sorted(folders, key=folder_angle_deg)
def format_vector(values: np.ndarray, unit: str = "") -> str:
return "[" + ", ".join(f"{value:+.4f}{unit}" for value in values) + "]"
def fit_one_folder(folder: Path, compensation: np.ndarray) -> tuple[FitResult, np.ndarray, np.ndarray]:
angle = folder_angle_deg(folder)
audio = load_folder_audio(folder)
energy = channel_energies(audio, compensation)
result = fit_energy_line(projection_coordinates(angle), energy)
candidate = compensation * np.sqrt(result.predicted_energy / energy)
candidate /= np.mean(candidate)
return result, candidate, energy
def log_folder_fit(folder: Path, result: FitResult, candidate: np.ndarray, energy: np.ndarray, prefix: str) -> None:
angle = folder_angle_deg(folder)
LOG.info(
"%s %s: slope=%+.6e intercept=%.6e RMSE=%.6e relative_error=%.3f%%",
prefix,
folder.name,
result.slope,
result.intercept,
result.rmse,
100.0 * result.relative_error,
)
LOG.info(" measured_energy=%s", format_vector(energy))
LOG.info(" predicted_energy=%s", format_vector(result.predicted_energy))
LOG.info(" candidate_correction_db=%s", format_vector(20.0 * np.log10(candidate), " dB"))
def calibrate(data_root: Path, iterations: int, alpha: float) -> tuple[np.ndarray, list[dict]]:
folders = recording_folders(data_root)
if not folders:
raise FileNotFoundError(f"no degree<angle> folders found under {data_root}")
compensation = np.ones(CHANNEL_COUNT, dtype=float)
history: list[dict] = []
for iteration in range(1, iterations + 1):
candidates = []
LOG.info("===== calibration iteration %d/%d; current_correction_db=%s =====", iteration, iterations, format_vector(20.0 * np.log10(compensation), " dB"))
for folder in folders:
result, candidate, energy = fit_one_folder(folder, compensation)
log_folder_fit(folder, result, candidate, energy, f"iteration {iteration}")
candidates.append(candidate)
history.append({
"iteration": iteration,
"angle_deg": folder_angle_deg(folder),
"slope": result.slope,
"intercept": result.intercept,
"rmse": result.rmse,
"relative_error": result.relative_error,
})
# Geometric mean is appropriate for multiplicative gains and is less
# sensitive to one bad direction than an arithmetic mean.
aggregate = np.exp(np.mean(np.log(np.vstack(candidates)), axis=0))
aggregate /= np.mean(aggregate)
compensation = (1.0 - alpha) * compensation + alpha * aggregate
compensation /= np.mean(compensation)
LOG.info("iteration %d aggregate_correction_db=%s", iteration, format_vector(20.0 * np.log10(compensation), " dB"))
return compensation, history
def final_report(data_root: Path, compensation: np.ndarray) -> dict:
folders = recording_folders(data_root)
reports = []
candidates = []
LOG.info("===== final fit using converged correction =====")
for folder in folders:
result, candidate, energy = fit_one_folder(folder, compensation)
log_folder_fit(folder, result, candidate, energy, "final")
candidates.append(candidate)
reports.append({
"folder": folder.name,
"angle_deg": folder_angle_deg(folder),
"slope": result.slope,
"intercept": result.intercept,
"rmse": result.rmse,
"relative_error": result.relative_error,
})
aggregate = np.exp(np.mean(np.log(np.vstack(candidates)), axis=0))
aggregate /= np.mean(aggregate)
LOG.info("===== final result across all directions =====")
LOG.info("correction_linear=%s", format_vector(compensation))
LOG.info("correction_db=%s", format_vector(20.0 * np.log10(compensation), " dB"))
LOG.info("mean_final_per_direction_candidate_db=%s", format_vector(20.0 * np.log10(aggregate), " dB"))
LOG.info("final_direction_relative_error_mean=%.3f%%", 100.0 * np.mean([r["relative_error"] for r in reports]))
return {"directions": reports, "correction_linear": compensation.tolist(), "correction_db": (20.0 * np.log10(compensation)).tolist()}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data", default=ROOT / "calibration" / "data" / "sim")
parser.add_argument("--iterations", type=int, default=5)
parser.add_argument("--alpha", type=float, default=0.5, help="offline update weight per iteration")
parser.add_argument("--output", default=ROOT / "calibration" / "data" / "calibration_result.json")
parser.add_argument("--log", default=None, help="optional log file; stdout is always logged")
return parser.parse_args()
def main() -> None:
args = parse_args()
if not 1 <= args.iterations:
raise ValueError("iterations must be positive")
if not 0.0 < args.alpha <= 1.0:
raise ValueError("alpha must be in (0, 1]")
handlers: list[logging.Handler] = [logging.StreamHandler()]
if args.log:
handlers.append(logging.FileHandler(args.log, encoding="utf-8"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=handlers)
data_root = Path(args.data).resolve()
compensation, history = calibrate(data_root, args.iterations, args.alpha)
report = final_report(data_root, compensation)
report["iterations"] = args.iterations
report["alpha"] = args.alpha
report["history"] = history
output = Path(args.output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2), encoding="utf-8")
LOG.info("saved calibration result to %s", output)
if __name__ == "__main__":
main()
@@ -0,0 +1,114 @@
"""Shared geometry, WAV and fitting helpers for microphone calibration."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
import numpy as np
from scipy.io import wavfile
SAMPLE_RATE = 24_000
CHANNEL_COUNT = 8
ARRAY_RADIUS_M = 0.056
# This is the channel order used by src/doa_estimator.py.
MIC_AZIMUTHS_DEG = np.array(
[157.5, 202.5, 247.5, 292.5, 337.5, 22.5, 67.5, 112.5], dtype=float
)
CH0_AZIMUTH_DEG = float(MIC_AZIMUTHS_DEG[0])
@dataclass(frozen=True)
class FitResult:
slope: float
intercept: float
predicted_energy: np.ndarray
rmse: float
relative_error: float
def microphone_positions() -> np.ndarray:
"""Return positions as a 2 x 8 matrix in the project's channel order."""
angles = np.deg2rad(MIC_AZIMUTHS_DEG)
return ARRAY_RADIUS_M * np.vstack((np.cos(angles), np.sin(angles)))
def projection_coordinates(relative_doa_deg: float) -> np.ndarray:
"""Project each microphone onto the known DOA line.
Folder angles are measured from CH0. Adding the CH0 absolute azimuth and
using the absolute microphone coordinates is equivalent to using this
relative expression, but the latter makes the folder convention explicit.
"""
relative_mic_angles = np.deg2rad(MIC_AZIMUTHS_DEG - CH0_AZIMUTH_DEG)
doa = np.deg2rad(relative_doa_deg)
return ARRAY_RADIUS_M * np.cos(doa - relative_mic_angles)
def read_mono_wav(path: Path, expected_rate: int = SAMPLE_RATE) -> np.ndarray:
rate, data = wavfile.read(path)
if rate != expected_rate:
raise ValueError(f"{path}: expected {expected_rate} Hz, got {rate} Hz")
if data.ndim != 1:
raise ValueError(f"{path}: expected one channel per file, got shape {data.shape}")
if np.issubdtype(data.dtype, np.integer):
info = np.iinfo(data.dtype)
scale = max(abs(info.min), info.max)
result = data.astype(np.float64) / scale
else:
result = data.astype(np.float64)
if not np.all(np.isfinite(result)):
raise ValueError(f"{path}: audio contains non-finite samples")
return result
def fit_energy_line(d: np.ndarray, energy: np.ndarray) -> FitResult:
"""Fit E~=a1*d+a0 using the MMSE line from the paper."""
if d.shape != energy.shape or d.ndim != 1 or d.size < 2:
raise ValueError("projection and energy arrays must be one-dimensional and equal")
if not np.all(np.isfinite(energy)) or np.any(energy <= 0.0):
raise ValueError("energies must be finite and positive")
slope, intercept = np.polyfit(d, energy, 1)
predicted = slope * d + intercept
if np.any(predicted <= 0.0):
raise ValueError("linear energy fit produced a non-positive prediction")
residual = predicted - energy
rmse = float(np.sqrt(np.mean(residual * residual)))
relative_error = float(rmse / max(abs(intercept), np.finfo(float).eps))
return FitResult(slope, intercept, predicted, rmse, relative_error)
def folder_angle_deg(folder: Path) -> float:
match = re.fullmatch(r"degree(-?\d+(?:\.\d+)?)", folder.name, re.IGNORECASE)
if not match:
raise ValueError(f"folder must be named degree<angle>, got {folder.name!r}")
return float(match.group(1))
def channel_paths(folder: Path) -> list[Path]:
paths = [folder / f"ch{channel}.wav" for channel in range(CHANNEL_COUNT)]
missing = [path for path in paths if not path.is_file()]
if missing:
raise FileNotFoundError("missing channel files: " + ", ".join(str(p) for p in missing))
return paths
def load_folder_audio(folder: Path) -> np.ndarray:
signals = [read_mono_wav(path) for path in channel_paths(folder)]
length = min(signal.size for signal in signals)
if length < 16:
raise ValueError(f"{folder}: audio is too short")
return np.column_stack([signal[:length] for signal in signals])
def channel_energies(audio: np.ndarray, compensation: np.ndarray) -> np.ndarray:
"""Return mean-square energies after applying direct correction C[m]."""
corrected = audio * compensation[np.newaxis, :]
centered = corrected - np.mean(corrected, axis=0, keepdims=True)
return np.mean(centered * centered, axis=0)
@@ -0,0 +1,119 @@
"""Generate 8-channel Pyroomacoustics calibration recordings.
Angles in directory names are measured counter-clockwise from CH0. Thus
``degree0`` puts the source on the CH0 ray, and ``degree15`` rotates it 15°.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
import numpy as np
from scipy.io import wavfile
import pyroomacoustics as pra
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from calibration.src.calibration_common import ( # noqa: E402
ARRAY_RADIUS_M,
CH0_AZIMUTH_DEG,
CHANNEL_COUNT,
MIC_AZIMUTHS_DEG,
SAMPLE_RATE,
microphone_positions,
)
def build_source_signal(sample_rate: int, duration_s: float, seed: int) -> np.ndarray:
rng = np.random.default_rng(seed)
count = int(round(sample_rate * duration_s))
# A broadband deterministic signal gives the energy estimator many
# independent samples while remaining repeatable across directions.
signal = rng.standard_normal(count)
signal -= np.mean(signal)
signal /= max(np.std(signal), np.finfo(float).eps)
return 0.08 * signal.astype(np.float64)
def generate(args: argparse.Namespace) -> None:
output_root = Path(args.output).resolve()
output_root.mkdir(parents=True, exist_ok=True)
positions = microphone_positions()
source_signal = build_source_signal(args.sample_rate, args.duration, args.seed)
channel_gains_db = np.asarray(args.channel_gain_db, dtype=float)
if channel_gains_db.size != CHANNEL_COUNT:
raise ValueError("exactly eight channel_gain_db values are required")
channel_gains = 10.0 ** (channel_gains_db / 20.0)
metadata = {
"sample_rate": args.sample_rate,
"duration_s": args.duration,
"array_radius_m": ARRAY_RADIUS_M,
"microphone_azimuths_deg": MIC_AZIMUTHS_DEG.tolist(),
"ch0_azimuth_deg": CH0_AZIMUTH_DEG,
"source_distance_m": args.distance,
"channel_gain_db": channel_gains_db.tolist(),
"channel_gain_linear": channel_gains.tolist(),
"angle_convention": "folder degree is source DOA minus CH0 azimuth, CCW",
}
for relative_angle in range(args.start_angle, args.stop_angle + 1, args.step):
absolute_angle = CH0_AZIMUTH_DEG + relative_angle
theta = np.deg2rad(absolute_angle)
source = args.distance * np.array([np.cos(theta), np.sin(theta)])
room = pra.AnechoicRoom(dim=2, fs=args.sample_rate, sigma2_awgn=args.noise_variance)
room.add_source(source, signal=source_signal)
room.add_microphone_array(pra.MicrophoneArray(positions, fs=args.sample_rate))
room.simulate()
signals = np.asarray(room.mic_array.signals, dtype=np.float64)
signals = signals[:CHANNEL_COUNT, : source_signal.size]
signals *= channel_gains[:, np.newaxis]
peak = float(np.max(np.abs(signals)))
if peak >= 0.98:
signals *= 0.9 / peak
folder = output_root / f"degree{relative_angle}"
folder.mkdir(parents=True, exist_ok=True)
for channel in range(CHANNEL_COUNT):
# Float32 WAV preserves relative levels without an avoidable
# quantization floor; the calibration reader also accepts PCM WAV.
wavfile.write(folder / f"ch{channel}.wav", args.sample_rate, signals[channel].astype(np.float32))
(folder / "metadata.json").write_text(
json.dumps(
{**metadata, "relative_doa_deg": relative_angle, "absolute_doa_deg": absolute_angle},
indent=2,
),
encoding="utf-8",
)
print(f"generated {folder.name}: relative DOA={relative_angle:.1f}°, absolute={absolute_angle:.1f}°")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", default=ROOT / "calibration" / "data" / "sim")
parser.add_argument("--sample-rate", type=int, default=SAMPLE_RATE)
parser.add_argument("--duration", type=float, default=4.0)
parser.add_argument("--distance", type=float, default=2.0)
parser.add_argument("--noise-variance", type=float, default=1e-8)
parser.add_argument("--seed", type=int, default=20260922)
parser.add_argument("--start-angle", type=int, default=0)
parser.add_argument("--stop-angle", type=int, default=345)
parser.add_argument("--step", type=int, default=15)
parser.add_argument(
"--channel-gain-db",
type=float,
nargs=CHANNEL_COUNT,
default=[2.0, -1.5, 0.7, -2.2, 1.1, -0.4, 2.8, -1.0],
metavar="DB",
help="eight injected channel gains used to validate calibration",
)
return parser.parse_args()
if __name__ == "__main__":
generate(parse_args())
+17
View File
@@ -0,0 +1,17 @@
cmake_minimum_required(VERSION 3.16)
project(esp32_doa LANGUAGES CXX)
add_library(esp32_doa doa_algorithms.cpp)
target_compile_features(esp32_doa PUBLIC cxx_std_17)
target_include_directories(esp32_doa PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(doa_cli doa_cli.cpp)
target_link_libraries(doa_cli PRIVATE esp32_doa)
if (MSVC)
target_compile_options(esp32_doa PRIVATE /W4 /permissive-)
target_compile_options(doa_cli PRIVATE /W4 /permissive-)
else()
target_compile_options(esp32_doa PRIVATE -Wall -Wextra -Wpedantic)
target_compile_options(doa_cli PRIVATE -Wall -Wextra -Wpedantic)
endif()
+36
View File
@@ -0,0 +1,36 @@
# Standalone C++ DOA and MVDR
This directory contains a dependency-free C++17 implementation of SRP-PHAT
(the historical `SRP-PATH` spelling is accepted as an alias), NormMUSIC, and
frequency-domain MVDR. It includes its own radix-2 FFT, complex Hermitian
Jacobi eigensolver, and Gaussian elimination. The resulting binary does not
require Eigen, FFTW, BLAS, Python, or Pyroomacoustics at runtime.
Build from the repository root:
```powershell
cmake -S . -B build
cmake --build build --config Release
```
The CLI input format is a whitespace-separated matrix. The first line is
`<sample_count> <channel_count>`, followed by one row per sample:
```powershell
build\cpp\Release\doa_cli.exe srp input.txt 48000 512
build\cpp\Release\doa_cli.exe normmusic input.txt 48000 512
build\cpp\Release\doa_cli.exe mvdr input.txt 73 48000 512 > output.txt
```
`compare_with_pyroomacoustics.py` generates a deterministic plane wave and
compares both DOA angles and MVDR samples against the repository's Python
reference. Run it after building with:
```powershell
uv run python cpp/compare_with_pyroomacoustics.py
```
The implementation uses the same microphone coordinates, Hann window,
frequency-bin convention (`500 <= f < 4000 Hz`), steering-vector sign,
frequency-normalized NormMUSIC spectrum, diagonal loading, and overlap-add
normalization as the Python path.
@@ -0,0 +1,82 @@
"""Numerical parity check between the standalone C++ CLI and the Python path.
Build first with ``cmake -S . -B build && cmake --build build --config Release``.
The script intentionally uses only the repository's existing NumPy and
Pyroomacoustics source tree for the reference calculation.
"""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.doa_estimator import DoaConfig, DoaEstimator # noqa: E402
def plane_wave(config: DoaConfig, angle: float) -> np.ndarray:
rng = np.random.default_rng(1234)
source = rng.standard_normal(config.window_samples)
frequencies = np.fft.rfftfreq(config.window_samples, 1.0 / config.sample_rate)
source_spectrum = np.fft.rfft(source)
azimuths = np.deg2rad(config.microphone_azimuths_deg)
positions = config.radius_m * np.vstack((np.cos(azimuths), np.sin(azimuths)))
direction = np.array([np.cos(np.deg2rad(angle)), np.sin(np.deg2rad(angle))])
delays = direction @ positions / config.sound_speed_m_s
return np.stack(
[np.fft.irfft(source_spectrum * np.exp(2j * np.pi * frequencies * delay),
n=config.window_samples) for delay in delays], axis=1
).astype(np.float64) * 0.05
def main() -> int:
config = DoaConfig()
audio = plane_wave(config, 73.0)
build = ROOT / "build" / "cpp" / "doa_cli"
if sys.platform == "win32":
build = build.with_suffix(".exe")
candidate = ROOT / "build" / "cpp" / "Release" / "doa_cli.exe"
if candidate.exists():
build = candidate
if not build.exists():
raise SystemExit(f"CLI not found: {build}; build it with CMake first")
with tempfile.TemporaryDirectory() as directory:
matrix = Path(directory) / "audio.txt"
with matrix.open("w", encoding="ascii") as output:
output.write(f"{audio.shape[0]} {audio.shape[1]}\n")
np.savetxt(output, audio, fmt="%.17g")
estimator = DoaEstimator(config=config, start_worker=False)
try:
for algorithm, command in (("SRP-PHAT", "srp"), ("NormMUSIC", "normmusic")):
reference = estimator.estimate(audio, algorithm=algorithm)
raw = subprocess.check_output(
[str(build), command, str(matrix), str(config.sample_rate), str(config.nfft)],
text=True,
)
result = json.loads(raw)
error = abs((result["angle_degrees"] - reference.angle_degrees + 180) % 360 - 180)
print(f"{algorithm}: Python={reference.angle_degrees:.3f} C++={result['angle_degrees']:.3f} error={error:.3f} deg")
if error > 1.0:
return 1
reference_mvdr = estimator._mvdr.process(audio, 73.0).audio
raw = subprocess.check_output(
[str(build), "mvdr", str(matrix), "73", str(config.sample_rate), str(config.nfft)],
text=True,
)
actual_mvdr = np.fromstring(raw, sep="\n")
error = np.max(np.abs(reference_mvdr.astype(float) - actual_mvdr))
print(f"MVDR: max absolute sample error={error:.3e}")
return int(error > 1e-5)
finally:
estimator.close()
if __name__ == "__main__":
raise SystemExit(main())
+407
View File
@@ -0,0 +1,407 @@
#include "doa_algorithms.hpp"
#include <algorithm>
#include <cmath>
#include <complex>
#include <limits>
#include <stdexcept>
#include <utility>
namespace esp32_doa {
namespace {
constexpr double kPi = 3.141592653589793238462643383279502884;
constexpr double kTolerance = 1e-14;
std::size_t index(const Stft &s, int snapshot, int frequency, int channel) {
return (static_cast<std::size_t>(snapshot) * s.bins + frequency) *
s.channels + channel;
}
void fft(std::vector<Complex> &x, bool inverse) {
const std::size_t n = x.size();
if (n == 0 || (n & (n - 1)) != 0) {
throw std::invalid_argument("nfft must be a power of two");
}
for (std::size_t i = 1, j = 0; i < n; ++i) {
std::size_t bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) std::swap(x[i], x[j]);
}
for (std::size_t len = 2; len <= n; len <<= 1) {
const double sign = inverse ? 1.0 : -1.0;
const Complex root = std::polar(1.0, sign * 2.0 * kPi / len);
for (std::size_t start = 0; start < n; start += len) {
Complex w(1.0, 0.0);
const std::size_t half = len >> 1;
for (std::size_t j = 0; j < half; ++j) {
const Complex u = x[start + j];
const Complex v = x[start + j + half] * w;
x[start + j] = u + v;
x[start + j + half] = u - v;
w *= root;
}
}
}
if (inverse) {
for (Complex &v : x) v /= static_cast<double>(n);
}
}
double hann(int n, int length) {
if (length <= 1) return 1.0;
return 0.5 - 0.5 * std::cos(2.0 * kPi * n / (length - 1));
}
std::vector<int> frequency_bins(const Config &c) {
const int first = static_cast<int>(std::floor(
c.min_frequency_hz * c.nfft / c.sample_rate + 0.5));
const int last = static_cast<int>(std::floor(
c.max_frequency_hz * c.nfft / c.sample_rate + 0.5));
std::vector<int> bins;
for (int k = first; k < last && k <= c.nfft / 2; ++k)
if (k >= 0) bins.push_back(k);
return bins;
}
void validate(const Config &c) {
if (c.sample_rate <= 0 || c.channels <= 0 || c.nfft <= 0 ||
(c.nfft & (c.nfft - 1)) != 0 || c.hop <= 0 || c.hop > c.nfft)
throw std::invalid_argument("invalid STFT configuration");
if (static_cast<int>(c.microphone_azimuths_deg.size()) != c.channels)
throw std::invalid_argument("one microphone azimuth is required per channel");
if (!(c.min_frequency_hz >= 0.0 && c.min_frequency_hz < c.max_frequency_hz &&
c.max_frequency_hz < c.sample_rate / 2.0))
throw std::invalid_argument("invalid frequency range");
}
std::vector<std::pair<double, double>> microphone_positions(const Config &c) {
std::vector<std::pair<double, double>> p(c.channels);
for (int m = 0; m < c.channels; ++m) {
const double a = c.microphone_azimuths_deg[m] * kPi / 180.0;
p[m] = {c.radius_m * std::cos(a), c.radius_m * std::sin(a)};
}
return p;
}
double wrap_degrees(double angle) {
angle = std::fmod(angle, 360.0);
return angle < 0.0 ? angle + 360.0 : angle;
}
std::vector<Complex> mode_vector(const Config &c, int frequency,
double angle_degrees) {
const auto positions = microphone_positions(c);
const double a = angle_degrees * kPi / 180.0;
const double dx = std::cos(a), dy = std::sin(a);
const double omega = 2.0 * kPi * c.sample_rate * frequency / c.nfft;
std::vector<Complex> mode(c.channels);
for (int m = 0; m < c.channels; ++m) {
const double tau = (dx * positions[m].first + dy * positions[m].second) /
c.sound_speed_m_s;
mode[m] = std::polar(1.0, omega * tau);
}
return mode;
}
std::vector<Complex> solve_linear(std::vector<Complex> a, std::vector<Complex> b,
int n) {
for (int col = 0; col < n; ++col) {
int pivot = col;
double best = std::abs(a[col * n + col]);
for (int row = col + 1; row < n; ++row) {
const double value = std::abs(a[row * n + col]);
if (value > best) best = value, pivot = row;
}
if (best < 1e-20) throw std::runtime_error("singular covariance matrix");
if (pivot != col) {
for (int j = col; j < n; ++j)
std::swap(a[col * n + j], a[pivot * n + j]);
std::swap(b[col], b[pivot]);
}
const Complex diagonal = a[col * n + col];
for (int j = col; j < n; ++j) a[col * n + j] /= diagonal;
b[col] /= diagonal;
for (int row = 0; row < n; ++row) {
if (row == col) continue;
const Complex factor = a[row * n + col];
if (std::abs(factor) == 0.0) continue;
for (int j = col; j < n; ++j) a[row * n + j] -= factor * a[col * n + j];
b[row] -= factor * b[col];
}
}
return b;
}
struct EigenResult {
std::vector<double> values;
std::vector<Complex> vectors; // column-major eigenvectors
};
// Complex Hermitian Jacobi eigensolver. This small implementation is enough
// for the fixed 8-microphone covariance matrices and avoids BLAS/LAPACK.
EigenResult hermitian_eigen(const std::vector<Complex> &input, int n) {
std::vector<Complex> a = input;
std::vector<Complex> v(static_cast<std::size_t>(n) * n, Complex(0.0));
for (int i = 0; i < n; ++i) v[i * n + i] = 1.0;
for (int iteration = 0; iteration < 80 * n * n; ++iteration) {
int p = 0, q = 1;
double largest = 0.0;
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
if (std::abs(a[i * n + j]) > largest)
largest = std::abs(a[i * n + j]), p = i, q = j;
if (largest < 1e-12) break;
const Complex b = a[p * n + q];
const double phase = std::arg(b);
const double magnitude = std::abs(b);
const double theta = 0.5 * std::atan2(2.0 * magnitude,
a[q * n + q].real() - a[p * n + p].real());
const double cs = std::cos(theta), sn = std::sin(theta);
const Complex ph = std::polar(1.0, phase);
const double app = a[p * n + p].real(), aqq = a[q * n + q].real();
for (int k = 0; k < n; ++k) {
if (k == p || k == q) continue;
const Complex akp = a[k * n + p], akq = a[k * n + q];
a[k * n + p] = akp * ph * cs - akq * sn;
a[p * n + k] = std::conj(a[k * n + p]);
a[k * n + q] = akp * ph * sn + akq * cs;
a[q * n + k] = std::conj(a[k * n + q]);
}
a[p * n + p] = app * cs * cs + aqq * sn * sn - 2.0 * magnitude * cs * sn;
a[q * n + q] = app * sn * sn + aqq * cs * cs + 2.0 * magnitude * cs * sn;
a[p * n + q] = a[q * n + p] = Complex(0.0);
for (int k = 0; k < n; ++k) {
const Complex vkp = v[k * n + p], vkq = v[k * n + q];
v[k * n + p] = vkp * ph * cs - vkq * sn;
v[k * n + q] = vkp * ph * sn + vkq * cs;
}
}
std::vector<int> order(n);
for (int i = 0; i < n; ++i) order[i] = i;
std::sort(order.begin(), order.end(), [&](int x, int y) {
return a[x * n + x].real() < a[y * n + y].real();
});
EigenResult result;
result.values.resize(n);
result.vectors.resize(static_cast<std::size_t>(n) * n);
for (int col = 0; col < n; ++col) {
result.values[col] = a[order[col] * n + order[col]].real();
for (int row = 0; row < n; ++row)
result.vectors[row * n + col] = v[row * n + order[col]];
}
return result;
}
double spectrum_sharpness(const std::vector<double> &spectrum) {
if (spectrum.empty()) return 0.0;
std::vector<double> sorted = spectrum;
std::sort(sorted.begin(), sorted.end());
const double peak = sorted.back();
const double floor = sorted[sorted.size() / 2];
const double spread = peak - sorted.front();
return std::clamp((peak - floor) / (spread + 1e-12), 0.0, 1.0);
}
DoaResult finish_spectrum(std::vector<double> spectrum) {
auto peak = std::max_element(spectrum.begin(), spectrum.end());
const int index = static_cast<int>(peak - spectrum.begin());
DoaResult result;
result.angle_degrees = wrap_degrees(360.0 * index / spectrum.size());
result.peak_sharpness = spectrum_sharpness(spectrum);
result.spectrum = std::move(spectrum);
return result;
}
} // namespace
Complex &Stft::at(int snapshot, int frequency, int channel) {
return spectra[index(*this, snapshot, frequency, channel)];
}
const Complex &Stft::at(int snapshot, int frequency, int channel) const {
return spectra[index(*this, snapshot, frequency, channel)];
}
Stft analyze_stft(const std::vector<double> &audio, int sample_count,
const Config &config) {
validate(config);
if (sample_count < config.nfft ||
static_cast<std::size_t>(sample_count) * config.channels > audio.size())
throw std::invalid_argument("audio does not contain enough samples");
Stft result;
result.sample_count = sample_count;
result.snapshots = (sample_count - config.nfft) / config.hop + 1;
result.bins = config.nfft / 2 + 1;
result.channels = config.channels;
result.spectra.assign(static_cast<std::size_t>(result.snapshots) * result.bins *
result.channels,
Complex(0.0));
std::vector<double> means(config.channels, 0.0);
for (int n = 0; n < sample_count; ++n)
for (int m = 0; m < config.channels; ++m)
means[m] += audio[static_cast<std::size_t>(n) * config.channels + m] /
sample_count;
for (int s = 0; s < result.snapshots; ++s) {
const int start = s * config.hop;
for (int m = 0; m < config.channels; ++m) {
std::vector<Complex> frame(config.nfft);
for (int n = 0; n < config.nfft; ++n)
frame[n] = (audio[static_cast<std::size_t>(start + n) * config.channels + m] -
means[m]) * hann(n, config.nfft);
fft(frame, false);
for (int k = 0; k < result.bins; ++k) result.at(s, k, m) = frame[k];
}
}
return result;
}
DoaResult srp_phat(const Stft &stft, const Config &config) {
validate(config);
const std::vector<int> bins = frequency_bins(config);
const int pairs = config.channels * (config.channels - 1) / 2;
std::vector<double> response(config.grid_points, 0.0);
const auto positions = microphone_positions(config);
for (int g = 0; g < config.grid_points; ++g) {
const double angle = 360.0 * g / config.grid_points * kPi / 180.0;
const double dx = std::cos(angle), dy = std::sin(angle);
double score = 0.0;
for (int k : bins) {
std::vector<std::vector<Complex>> cc(config.channels,
std::vector<Complex>(config.channels));
for (int i = 0; i < config.channels; ++i)
for (int j = 0; j < config.channels; ++j)
for (int s = 0; s < stft.snapshots; ++s) {
Complex xi = stft.at(s, k, i), xj = stft.at(s, k, j);
const double ai = std::max(std::abs(xi), kTolerance);
const double aj = std::max(std::abs(xj), kTolerance);
cc[i][j] += (xi / ai) * std::conj(xj / aj);
}
const double omega = 2.0 * kPi * config.sample_rate * k / config.nfft;
for (int i = 0; i < config.channels; ++i) {
const double ti = (dx * positions[i].first + dy * positions[i].second) /
config.sound_speed_m_s;
for (int j = i + 1; j < config.channels; ++j) {
const double tj = (dx * positions[j].first + dy * positions[j].second) /
config.sound_speed_m_s;
const Complex mode = std::polar(1.0, omega * (tj - ti));
score += 2.0 * std::real(cc[i][j] * mode);
}
}
}
const double dc = static_cast<double>(stft.snapshots * config.channels * bins.size());
response[g] = (score + dc) /
(stft.snapshots * bins.size() * static_cast<double>(pairs));
}
return finish_spectrum(std::move(response));
}
DoaResult norm_music(const Stft &stft, const Config &config) {
validate(config);
const std::vector<int> bins = frequency_bins(config);
std::vector<double> per_frequency(static_cast<std::size_t>(bins.size()) *
config.grid_points, 0.0);
for (std::size_t frequency_index = 0; frequency_index < bins.size(); ++frequency_index) {
const int k = bins[frequency_index];
std::vector<Complex> covariance(static_cast<std::size_t>(config.channels) * config.channels,
Complex(0.0));
for (int i = 0; i < config.channels; ++i)
for (int j = 0; j < config.channels; ++j)
for (int s = 0; s < stft.snapshots; ++s)
covariance[i * config.channels + j] +=
stft.at(s, k, i) * std::conj(stft.at(s, k, j)) / stft.snapshots;
const EigenResult eig = hermitian_eigen(covariance, config.channels);
for (int g = 0; g < config.grid_points; ++g) {
const auto mode = mode_vector(config, k, 360.0 * g / config.grid_points);
Complex denom(0.0);
for (int i = 0; i < config.channels; ++i) {
for (int j = 0; j < config.channels; ++j) {
Complex cross = (i == j ? Complex(1.0) : Complex(0.0));
cross -= eig.vectors[i * config.channels + config.channels - 1] *
std::conj(eig.vectors[j * config.channels + config.channels - 1]);
denom += std::conj(mode[i]) * cross * mode[j];
}
}
per_frequency[frequency_index * config.grid_points + g] =
1.0 / std::max(std::abs(denom), 1e-20);
}
}
std::vector<double> response(config.grid_points, 0.0);
// Pyroomacoustics' NormMUSIC normalizes along the frequency axis for
// every candidate direction (Pssl / max(Pssl, axis=0)).
for (int g = 0; g < config.grid_points; ++g) {
double maximum = 0.0;
for (std::size_t f = 0; f < bins.size(); ++f)
maximum = std::max(maximum, per_frequency[f * config.grid_points + g]);
for (std::size_t f = 0; f < bins.size(); ++f)
response[g] += per_frequency[f * config.grid_points + g] /
std::max(maximum, 1e-20);
response[g] /= bins.size();
}
return finish_spectrum(std::move(response));
}
MvdrResult mvdr(const Stft &stft, const Config &config, double angle_degrees) {
validate(config);
const std::vector<int> bins = frequency_bins(config);
const int n = config.channels;
std::vector<Complex> weights(static_cast<std::size_t>(stft.bins) * n);
for (int k = 0; k < stft.bins; ++k) {
const auto steering = mode_vector(config, k, angle_degrees);
for (int m = 0; m < n; ++m) weights[k * n + m] = steering[m] / n;
}
for (int k : bins) {
std::vector<Complex> covariance(static_cast<std::size_t>(n) * n, Complex(0.0));
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
for (int s = 0; s < stft.snapshots; ++s)
covariance[i * n + j] += stft.at(s, k, i) * std::conj(stft.at(s, k, j)) /
stft.snapshots;
double power = 0.0;
for (int i = 0; i < n; ++i) power += covariance[i * n + i].real();
const double loading = config.diagonal_loading * std::max(power / n, 1e-12);
for (int i = 0; i < n; ++i) covariance[i * n + i] += loading;
std::vector<Complex> solved = solve_linear(covariance, steering, n);
Complex denominator(0.0);
for (int m = 0; m < n; ++m) denominator += std::conj(steering[m]) * solved[m];
if (std::abs(denominator) < 1e-12) denominator = Complex(1e-12);
for (int m = 0; m < n; ++m) weights[k * n + m] = solved[m] / denominator;
}
std::vector<double> output(stft.sample_count, 0.0), normalization(stft.sample_count, 0.0);
for (int s = 0; s < stft.snapshots; ++s) {
std::vector<Complex> spectrum(config.nfft, Complex(0.0));
for (int k = 0; k < stft.bins; ++k) {
Complex value(0.0);
for (int m = 0; m < n; ++m) value += std::conj(weights[k * n + m]) * stft.at(s, k, m);
spectrum[k] = value;
if (k > 0 && k < config.nfft / 2) spectrum[config.nfft - k] = std::conj(value);
}
fft(spectrum, true);
const int start = s * config.hop;
for (int i = 0; i < config.nfft; ++i) {
const double w = hann(i, config.nfft);
output[start + i] += spectrum[i].real() * w;
normalization[start + i] += w * w;
}
}
for (int i = 0; i < stft.sample_count; ++i)
output[i] = normalization[i] > 1e-12 ? output[i] / normalization[i] : 0.0;
return {std::move(output)};
}
DoaResult estimate(const std::vector<double> &audio, int sample_count,
const Config &config, const std::string &algorithm) {
const Stft stft = analyze_stft(audio, sample_count, config);
if (algorithm == "SRP-PHAT" || algorithm == "SRP" || algorithm == "SRP-PATH")
return srp_phat(stft, config);
if (algorithm == "NormMUSIC" || algorithm == "MUSIC" || algorithm == "NORMMUSIC")
return norm_music(stft, config);
throw std::invalid_argument("unknown algorithm: " + algorithm);
}
MvdrResult beamform(const std::vector<double> &audio, int sample_count,
const Config &config, double angle_degrees) {
return mvdr(analyze_stft(audio, sample_count, config), config, angle_degrees);
}
} // namespace esp32_doa
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <complex>
#include <cstddef>
#include <string>
#include <utility>
#include <vector>
namespace esp32_doa {
using Complex = std::complex<double>;
struct Config {
int sample_rate = 48000;
int channels = 8;
int nfft = 512;
int hop = 256;
double sound_speed_m_s = 343.0;
double radius_m = 0.056;
double min_frequency_hz = 500.0;
double max_frequency_hz = 4000.0;
int grid_points = 360;
double diagonal_loading = 1e-2;
std::vector<double> microphone_azimuths_deg = {
157.5, 202.5, 247.5, 292.5, 337.5, 22.5, 67.5, 112.5};
};
struct Stft {
int sample_count = 0;
int snapshots = 0;
int bins = 0;
int channels = 0;
// Layout is [snapshot][frequency bin][channel].
std::vector<Complex> spectra;
Complex &at(int snapshot, int frequency, int channel);
const Complex &at(int snapshot, int frequency, int channel) const;
};
struct DoaResult {
double angle_degrees = 0.0;
double peak_sharpness = 0.0;
std::vector<double> spectrum;
};
struct MvdrResult {
std::vector<double> audio;
};
Stft analyze_stft(const std::vector<double> &audio, int sample_count,
const Config &config);
DoaResult srp_phat(const Stft &stft, const Config &config);
DoaResult norm_music(const Stft &stft, const Config &config);
MvdrResult mvdr(const Stft &stft, const Config &config, double angle_degrees);
// Convenience functions for callers that already have samples in row-major
// samples x channels form.
DoaResult estimate(const std::vector<double> &audio, int sample_count,
const Config &config, const std::string &algorithm);
MvdrResult beamform(const std::vector<double> &audio, int sample_count,
const Config &config, double angle_degrees);
} // namespace esp32_doa
+50
View File
@@ -0,0 +1,50 @@
#include "doa_algorithms.hpp"
#include <fstream>
#include <iomanip>
#include <iostream>
#include <string>
using namespace esp32_doa;
int main(int argc, char **argv) {
if (argc < 3 || argc > 6) {
std::cerr << "usage: doa_cli <srp|normmusic> <matrix.txt> [sample_rate] [nfft]\n"
" doa_cli mvdr <matrix.txt> <angle> [sample_rate] [nfft]\n";
return 2;
}
try {
std::ifstream input(argv[2]);
if (!input) throw std::runtime_error("cannot open input matrix");
int samples = 0, channels = 0;
input >> samples >> channels;
Config config;
config.channels = channels;
const std::string command = argv[1];
if (command == "mvdr") {
if (argc < 4) throw std::runtime_error("mvdr requires an angle");
if (argc >= 5) config.sample_rate = std::stoi(argv[4]);
if (argc >= 6) config.nfft = std::stoi(argv[5]);
} else {
if (argc >= 4) config.sample_rate = std::stoi(argv[3]);
if (argc >= 5) config.nfft = std::stoi(argv[4]);
}
config.hop = config.nfft / 2;
std::vector<double> audio(static_cast<std::size_t>(samples) * channels);
for (double &value : audio)
if (!(input >> value)) throw std::runtime_error("matrix has too few values");
std::cout << std::setprecision(17);
if (command == "mvdr") {
const auto result = beamform(audio, samples, config, std::stod(argv[3]));
for (double value : result.audio) std::cout << value << '\n';
} else {
const auto result = estimate(audio, samples, config, command);
std::cout << "{\"angle_degrees\":" << result.angle_degrees
<< ",\"peak_sharpness\":" << result.peak_sharpness << "}\n";
}
} catch (const std::exception &error) {
std::cerr << "error: " << error.what() << '\n';
return 1;
}
return 0;
}
+36
View File
@@ -0,0 +1,36 @@
[project]
name = "esp32_8mic"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"pygame>=2.6.1",
"sounddevice>=0.5.2",
"beautifulsoup4>=4.15.0",
"fastapi>=0.139.2",
"flask>=3.1.3",
"gradio>=6.20.0",
"librosa>=0.11,<0.12",
"matplotlib>=3.11.1",
"numba>=0.66.0",
"numpy>=2.0,<3",
"openpyxl>=3.1.5",
"pandas>=3.0.3",
"pillow>=12.3.0",
"pyroomacoustics>=0.10.0",
"pytest>=9.1.1",
"requests>=2.34.2",
"scikit-learn>=1.9.0",
"scipy>=1.13.0",
"tensorboard>=2.21.0",
"torch>=2.13.0",
"tqdm>=4.69.0",
]
[tool.uv]
index-url = "https://pypi.tuna.tsinghua.edu.cn/simple"
extra-index-url = ["https://mirrors.aliyun.com/pypi/simple"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+325
View File
@@ -0,0 +1,325 @@
"""Non-blocking playback of the newest non-overlapping MVDR output."""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Callable
import numpy as np
try:
from .mvdr_beamformer import MvdrResult
except ImportError: # Support direct execution from the src directory.
from mvdr_beamformer import MvdrResult
@dataclass(frozen=True)
class MonitorDevice:
index: int
name: str
host_api: str
@property
def label(self) -> str:
return f"{self.name} [{self.host_api}]" if self.host_api else self.name
class MvdrAudioMonitor:
"""Play new tails of rolling MVDR windows on a blocking output thread."""
MIN_GAIN_DB = -10.0
MAX_GAIN_DB = 40.0
def __init__(
self,
sample_rate: int,
result_provider: Callable[[], MvdrResult | None],
reset_provider: Callable[[], None] | None = None,
) -> None:
import sounddevice as sd
self.sample_rate = sample_rate
self._provider = result_provider
self._reset_provider = reset_provider
self._sd = sd
discovery_error = None
try:
self.devices = self._find_compatible_devices(sd, sample_rate)
default_output = sd.default.device[1]
except Exception as exc:
self.devices = []
default_output = -1
discovery_error = str(exc)
selected = next(
(i for i, device in enumerate(self.devices) if device.index == default_output),
0,
)
self._selected = selected if self.devices else -1
self._gain_db = 0.0
self._enabled = False
self._status = "ERROR" if discovery_error else "OFF"
self._error: str | None = discovery_error
self._underflows = 0
self._discontinuities = 0
self._condition = threading.Condition()
self._stop = False
self._configuration = 0
self._stream = None
self._thread = threading.Thread(
target=self._worker,
name="mvdr-audio-monitor",
daemon=True,
)
self._thread.start()
@staticmethod
def _hostapi_name(sd, info) -> str:
try:
return str(sd.query_hostapis(info["hostapi"])["name"])
except Exception:
return ""
@classmethod
def _find_compatible_devices(cls, sd, sample_rate: int) -> list[MonitorDevice]:
compatible = []
for index, info in enumerate(sd.query_devices()):
if int(info["max_output_channels"]) < 1:
continue
try:
sd.check_output_settings(
device=index,
channels=1,
samplerate=sample_rate,
dtype="float32",
)
except Exception:
continue
compatible.append(
MonitorDevice(
index=index,
name=str(info["name"]),
host_api=cls._hostapi_name(sd, info),
)
)
return compatible
@property
def enabled(self) -> bool:
with self._condition:
return self._enabled
@property
def gain_db(self) -> float:
with self._condition:
return self._gain_db
@property
def selected_device(self) -> MonitorDevice | None:
with self._condition:
if not 0 <= self._selected < len(self.devices):
return None
return self.devices[self._selected]
@property
def selected_index(self) -> int:
with self._condition:
return self._selected
@property
def state(self) -> tuple[str, str | None, int, int]:
with self._condition:
return (
self._status,
self._error,
self._underflows,
self._discontinuities,
)
def set_enabled(self, enabled: bool) -> None:
stream = None
with self._condition:
if enabled and not self.devices:
self._error = "No compatible output device"
self._status = "ERROR"
return
if enabled == self._enabled:
return
self._enabled = enabled
self._status = "STARTING" if enabled else "OFF"
self._error = None
self._configuration += 1
if not enabled:
stream = self._stream
self._condition.notify_all()
if stream is not None:
try:
stream.abort()
except Exception:
pass
def toggle(self) -> None:
self.set_enabled(not self.enabled)
def select_device(self, list_index: int) -> None:
stream = None
with self._condition:
if not 0 <= list_index < len(self.devices):
return
if list_index == self._selected:
return
self._selected = list_index
self._configuration += 1
self._error = None
if self._enabled:
self._status = "STARTING"
stream = self._stream
self._condition.notify_all()
if stream is not None:
try:
stream.abort()
except Exception:
pass
def set_gain_db(self, gain_db: float) -> float:
value = max(self.MIN_GAIN_DB, min(self.MAX_GAIN_DB, float(gain_db)))
with self._condition:
self._gain_db = value
return value
@staticmethod
def apply_gain(audio: np.ndarray, gain_db: float) -> tuple[np.ndarray, bool]:
factor = 10.0 ** (gain_db / 20.0)
amplified = np.asarray(audio, dtype=np.float32) * factor
clipped = bool(np.any(np.abs(amplified) > 1.0))
return np.clip(amplified, -1.0, 1.0).astype(np.float32), clipped
def _worker(self) -> None:
while True:
with self._condition:
self._condition.wait_for(lambda: self._stop or self._enabled)
if self._stop:
return
configuration = self._configuration
device = self.devices[self._selected]
stream = None
try:
if self._reset_provider is not None:
self._reset_provider()
with self._condition:
if self._enabled and configuration == self._configuration:
self._status = "WAITING"
self._error = None
# Do not start PortAudio until one finalized WOLA block is
# ready; otherwise the device begins in an avoidable underflow.
pending_result = None
while pending_result is None:
with self._condition:
if (
self._stop
or not self._enabled
or configuration != self._configuration
):
break
candidate = self._provider()
if (
candidate is not None
and candidate.stream_audio is not None
and candidate.stream_audio.size
):
pending_result = candidate
break
time.sleep(0.01)
if pending_result is None:
continue
stream = self._sd.OutputStream(
device=device.index,
channels=1,
samplerate=self.sample_rate,
blocksize=0,
dtype="float32",
)
with self._condition:
self._stream = stream
stream.start()
last_timestamp = -1.0
last_stream_end: int | None = None
while True:
with self._condition:
if (
self._stop
or not self._enabled
or configuration != self._configuration
):
break
gain_db = self._gain_db
result = pending_result
pending_result = None
if result is None:
result = self._provider()
if result is None or result.timestamp == last_timestamp:
time.sleep(0.01)
continue
stream_audio = result.stream_audio
if stream_audio is None or stream_audio.size == 0:
last_timestamp = result.timestamp
continue
if (
last_stream_end is not None
and result.stream_sample_start is not None
and result.stream_sample_start != last_stream_end
):
with self._condition:
self._discontinuities += 1
chunk, clipped = self.apply_gain(stream_audio, gain_db)
underflowed = bool(stream.write(chunk[:, None]))
last_timestamp = result.timestamp
last_stream_end = result.stream_sample_end
with self._condition:
self._status = "CLIPPING" if clipped else "LIVE"
if underflowed:
self._underflows += 1
except Exception as exc:
with self._condition:
if self._enabled and configuration == self._configuration:
self._status = "ERROR"
self._error = str(exc)
self._enabled = False
finally:
if stream is not None:
try:
stream.abort()
except Exception:
pass
try:
stream.close()
except Exception:
pass
with self._condition:
if self._stream is stream:
self._stream = None
if not self._enabled and self._status != "ERROR":
self._status = "OFF"
def close(self) -> None:
stream = None
with self._condition:
self._stop = True
self._enabled = False
stream = self._stream
self._condition.notify_all()
if stream is not None:
try:
stream.abort()
except Exception:
pass
if self._thread is not threading.current_thread():
self._thread.join(timeout=2.0)
+507
View File
@@ -0,0 +1,507 @@
"""Real-time SRP-PHAT and NormMUSIC direction-of-arrival estimation.
The algorithms are loaded directly from the cloned Pyroomacoustics source
tree. This avoids importing the package-level room simulator, whose native
``libroom`` extension is unrelated to DOA and is not required here.
"""
from __future__ import annotations
import importlib
import math
import sys
import threading
import time
import types
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
try:
from .mvdr_beamformer import MvdrBeamformer, MvdrConfig, MvdrResult
from .stft_analysis import StftAnalysis, analyze_multichannel_stft
except ImportError: # Support direct execution from the src directory.
from mvdr_beamformer import MvdrBeamformer, MvdrConfig, MvdrResult
from stft_analysis import StftAnalysis, analyze_multichannel_stft
# Channel coordinates must match the USB channel order. Angles use the
# mathematical convention: 0 degrees points right (+x), positive is CCW (+y).
# These values reproduce the channel locations drawn by src/main.py.
MIC_AZIMUTHS_DEG = (157.5, 202.5, 247.5, 292.5, 337.5, 22.5, 67.5, 112.5)
DOA_ALGORITHMS = ("SRP-PHAT", "NormMUSIC")
def _load_pyroomacoustics_doa_class(module_name: str, class_name: str) -> type[Any]:
"""Load an upstream DOA class without importing the native room simulator."""
doa_source = (
Path(__file__).resolve().parents[1]
/ "pyroomacoustics"
/ "pyroomacoustics"
/ "doa"
)
algorithm_source = doa_source / f"{module_name}.py"
if not algorithm_source.is_file():
raise RuntimeError(
"Pyroomacoustics source was not found. Expected "
f"{algorithm_source}. Clone https://github.com/LCAV/pyroomacoustics.git "
"into the project root."
)
# Give the source directory a private package name so relative imports in
# algorithm modules (doa.py, grid.py, utils.py, ...) continue to work. Do
# not execute pyroomacoustics/__init__.py, which imports the native libroom.
package_name = "_esp32_8mic_pyroom_doa"
if package_name not in sys.modules:
package = types.ModuleType(package_name)
package.__path__ = [str(doa_source)] # type: ignore[attr-defined]
package.__package__ = package_name
sys.modules[package_name] = package
module = importlib.import_module(f"{package_name}.{module_name}")
return getattr(module, class_name)
def load_pyroomacoustics_srp() -> type[Any]:
return _load_pyroomacoustics_doa_class("srp", "SRP")
def load_pyroomacoustics_normmusic() -> type[Any]:
return _load_pyroomacoustics_doa_class("normmusic", "NormMUSIC")
def normalize_algorithm(algorithm: str) -> str:
normalized = algorithm.strip().upper().replace("_", "-")
if normalized == "SRP":
normalized = "SRP-PHAT"
elif normalized in ("MUSIC", "NORMMUSIC", "NORM-MUSIC"):
# MUSIC is retained as an input alias for saved settings and callers,
# but always selects Pyroomacoustics' frequency-normalized variant.
normalized = "NormMUSIC"
if normalized not in DOA_ALGORITHMS:
choices = ", ".join(DOA_ALGORITHMS)
raise ValueError(f"Unknown DOA algorithm {algorithm!r}; expected {choices}")
return normalized
@dataclass(frozen=True)
class DoaConfig:
sample_rate: int = 48_000
channels: int = 8
radius_m: float = 0.056
sound_speed_m_s: float = 343.0
# Zero selects duration-equivalent defaults derived from sample_rate.
nfft: int = 0
hop: int = 0
window_samples: int = 0
update_interval_s: float = 0.10
min_frequency_hz: float = 500.0
max_frequency_hz: float = 4_000.0
activity_threshold_dbfs: float = -58.0
grid_points: int = 360
smoothing: float = 0.35
mvdr_diagonal_loading: float = 1e-2
microphone_azimuths_deg: tuple[float, ...] = MIC_AZIMUTHS_DEG
def __post_init__(self) -> None:
nfft = self.nfft
if nfft == 0:
# Preserve the 512-point/48 kHz frame duration while selecting a
# radix-2 FFT. This resolves to 256 points at 24 kHz.
target = max(128.0, 512.0 * self.sample_rate / 48_000.0)
exponent = int(round(math.log2(target)))
nfft = max(128, min(4_096, 1 << exponent))
object.__setattr__(self, "nfft", nfft)
if self.hop == 0:
object.__setattr__(self, "hop", max(1, nfft // 2))
if self.window_samples == 0:
# Sixteen FFT lengths gives 8192 samples at 48 kHz and 4096 at
# 24 kHz, preserving the approximately 171 ms rolling duration.
object.__setattr__(self, "window_samples", max(nfft, nfft * 16))
@dataclass(frozen=True)
class DoaEstimate:
angle_degrees: float | None
level_dbfs: float
peak_sharpness: float
timestamp: float
algorithm: str
class DoaEstimator:
"""Estimate one horizontal far-field source with SRP-PHAT or NormMUSIC."""
def __init__(
self,
config: DoaConfig | None = None,
*,
algorithm: str = "SRP-PHAT",
srp_class: type[Any] | None = None,
normmusic_class: type[Any] | None = None,
start_worker: bool = True,
) -> None:
self.config = config or DoaConfig()
self._validate_config()
angles = np.deg2rad(np.asarray(self.config.microphone_azimuths_deg))
self.microphone_positions = self.config.radius_m * np.vstack(
(np.cos(angles), np.sin(angles))
)
common_args = (self.microphone_positions, self.config.sample_rate, self.config.nfft)
common_kwargs = {
"c": self.config.sound_speed_m_s,
"num_src": 1,
"mode": "far",
"n_grid": self.config.grid_points,
"dim": 2,
}
srp_type = srp_class or load_pyroomacoustics_srp()
normmusic_type = normmusic_class or load_pyroomacoustics_normmusic()
self._engines = {
"SRP-PHAT": srp_type(*common_args, **common_kwargs),
"NormMUSIC": normmusic_type(
*common_args,
frequency_normalization=True,
**common_kwargs,
),
}
self._algorithm = normalize_algorithm(algorithm)
self._analysis_window = np.hanning(self.config.nfft).astype(np.float32)
self._mvdr = MvdrBeamformer(
self.microphone_positions,
MvdrConfig(
sample_rate=self.config.sample_rate,
nfft=self.config.nfft,
hop=self.config.hop,
sound_speed_m_s=self.config.sound_speed_m_s,
min_frequency_hz=self.config.min_frequency_hz,
max_frequency_hz=self.config.max_frequency_hz,
diagonal_loading=self.config.mvdr_diagonal_loading,
),
)
self._lock = threading.Lock()
self._latest: DoaEstimate | None = None
self._latest_mvdr: MvdrResult | None = None
self._error: str | None = None
self._mvdr_error: str | None = None
self._monitor_results: deque[MvdrResult] = deque(maxlen=8)
self._reset_mvdr_stream = False
self._last_valid_angle_degrees: float | None = None
self._manual_mvdr_angle_degrees: float | None = None
self._smoothed_angle_radians: float | None = None
# A fixed single-producer/single-consumer ring keeps the newest DOA
# window. It cannot grow or build latency when estimation falls behind.
self._ring = np.zeros(
(self.config.window_samples, self.config.channels), dtype=np.float32
)
self._ring_write = 0
self._ring_count = 0
self._ring_generation = 0
self._samples_submitted = 0
self._ring_condition = threading.Condition()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
if start_worker:
self._thread = threading.Thread(
target=self._worker,
name="realtime-doa",
daemon=True,
)
self._thread.start()
def _validate_config(self) -> None:
c = self.config
if c.channels != len(c.microphone_azimuths_deg):
raise ValueError("One microphone angle is required for every channel")
if c.nfft <= 0 or c.nfft % 2:
raise ValueError("nfft must be a positive even integer")
if not 0 < c.hop <= c.nfft:
raise ValueError("hop must be in the range 1..nfft")
if c.window_samples < c.nfft:
raise ValueError("window_samples must be at least nfft")
if c.sample_rate <= 0:
raise ValueError("sample_rate must be positive")
if not 0.0 <= c.min_frequency_hz < c.max_frequency_hz:
raise ValueError("DOA frequency range is invalid")
if c.max_frequency_hz >= c.sample_rate / 2.0:
raise ValueError("max_frequency_hz must be below the Nyquist frequency")
if not 0.0 < c.smoothing <= 1.0:
raise ValueError("smoothing must be in the range (0, 1]")
if c.mvdr_diagonal_loading <= 0.0:
raise ValueError("mvdr_diagonal_loading must be positive")
@property
def latest(self) -> DoaEstimate | None:
with self._lock:
return self._latest
@property
def error(self) -> str | None:
with self._lock:
return self._error
@property
def latest_mvdr(self) -> MvdrResult | None:
with self._lock:
return self._latest_mvdr
@property
def mvdr_error(self) -> str | None:
with self._lock:
return self._mvdr_error
@property
def algorithm(self) -> str:
with self._lock:
return self._algorithm
def set_algorithm(self, algorithm: str) -> str:
selected = normalize_algorithm(algorithm)
with self._lock:
if selected != self._algorithm:
self._algorithm = selected
self._latest = None
self._latest_mvdr = None
self._error = None
self._mvdr_error = None
self._monitor_results.clear()
self._reset_mvdr_stream = True
self._last_valid_angle_degrees = None
self._smoothed_angle_radians = None
return selected
def toggle_algorithm(self) -> str:
current = self.algorithm
index = DOA_ALGORITHMS.index(current)
return self.set_algorithm(DOA_ALGORITHMS[(index + 1) % len(DOA_ALGORITHMS)])
@property
def mvdr_steering_angle_override(self) -> float | None:
"""Return the manual MVDR steering angle, or ``None`` for DOA steering."""
with self._lock:
return self._manual_mvdr_angle_degrees
def set_mvdr_steering_angle(self, angle_degrees: float | None) -> float | None:
"""Override the MVDR bearing without changing the DOA estimator output."""
if angle_degrees is None:
normalized = None
else:
value = float(angle_degrees)
if not math.isfinite(value):
raise ValueError("MVDR steering angle must be finite")
normalized = value % 360.0
with self._lock:
self._manual_mvdr_angle_degrees = normalized
return normalized
def pop_monitor_result(self) -> MvdrResult | None:
"""Pop the oldest finalized streaming-WOLA block for playback."""
with self._lock:
return self._monitor_results.popleft() if self._monitor_results else None
def clear_monitor_results(self) -> None:
with self._lock:
self._monitor_results.clear()
def submit(self, audio: np.ndarray) -> None:
"""Copy an ``samples x channels`` block into the fixed audio ring."""
block = np.asarray(audio, dtype=np.float32)
if block.ndim != 2 or block.shape[1] != self.config.channels:
return
capacity = self.config.window_samples
submitted_count = block.shape[0]
if block.shape[0] >= capacity:
block = block[-capacity:]
count = block.shape[0]
with self._ring_condition:
first = min(count, capacity - self._ring_write)
self._ring[self._ring_write : self._ring_write + first] = block[:first]
remaining = count - first
if remaining:
self._ring[:remaining] = block[first:]
self._ring_write = (self._ring_write + count) % capacity
self._ring_count = min(capacity, self._ring_count + count)
self._samples_submitted += submitted_count
self._ring_generation += 1
self._ring_condition.notify()
@property
def buffered_samples(self) -> int:
with self._ring_condition:
return self._ring_count
def estimate(self, audio: np.ndarray, algorithm: str | None = None) -> DoaEstimate:
"""Synchronously estimate DOA from a calibrated multichannel window."""
analysis = self.analyze_stft(audio)
return self.estimate_stft(analysis, algorithm=algorithm)
def analyze_stft(self, audio: np.ndarray) -> StftAnalysis:
"""Compute the STFT shared by DOA and MVDR for one audio window."""
return analyze_multichannel_stft(
audio,
channels=self.config.channels,
nfft=self.config.nfft,
hop=self.config.hop,
window=self._analysis_window,
)
def estimate_stft(
self,
analysis: StftAnalysis,
algorithm: str | None = None,
) -> DoaEstimate:
"""Estimate DOA from a previously computed multichannel STFT."""
selected = normalize_algorithm(algorithm) if algorithm else self.algorithm
engine = self._engines[selected]
level_dbfs = analysis.level_dbfs
now = time.monotonic()
if level_dbfs < self.config.activity_threshold_dbfs:
return DoaEstimate(None, level_dbfs, 0.0, now, selected)
# Pyroomacoustics expects channels x frequencies x snapshots.
snapshots = np.transpose(analysis.spectra, (2, 1, 0))
engine.locate_sources(
snapshots,
freq_range=[
self.config.min_frequency_hz,
self.config.max_frequency_hz,
],
)
angle = float(np.degrees(engine.azimuth_recon[0]) % 360.0)
values = np.asarray(engine.grid.values, dtype=float)
peak = float(np.max(values))
floor = float(np.median(values))
spread = peak - float(np.min(values))
sharpness = float(np.clip((peak - floor) / (spread + 1e-12), 0.0, 1.0))
return DoaEstimate(angle, level_dbfs, sharpness, now, selected)
def _smooth_angle(self, angle_degrees: float) -> float:
current = math.radians(angle_degrees)
previous = self._smoothed_angle_radians
if previous is None:
smoothed = current
else:
alpha = self.config.smoothing
x = (1.0 - alpha) * math.cos(previous) + alpha * math.cos(current)
y = (1.0 - alpha) * math.sin(previous) + alpha * math.sin(current)
smoothed = math.atan2(y, x)
self._smoothed_angle_radians = smoothed
return math.degrees(smoothed) % 360.0
def _worker(self) -> None:
buffer = np.empty(
(self.config.window_samples, self.config.channels), dtype=np.float32
)
last_generation = -1
next_update = 0.0
while not self._stop.is_set():
with self._ring_condition:
self._ring_condition.wait_for(
lambda: self._stop.is_set()
or self._ring_generation != last_generation,
timeout=0.2,
)
if self._stop.is_set():
break
last_generation = self._ring_generation
source_sample_end = self._samples_submitted
if self._ring_count < self.config.window_samples:
continue
# Once full, _ring_write points at the oldest sample. Copy a
# coherent chronological snapshot into a reusable work buffer.
split = self.config.window_samples - self._ring_write
buffer[:split] = self._ring[self._ring_write :]
if self._ring_write:
buffer[split:] = self._ring[: self._ring_write]
now = time.monotonic()
if now < next_update:
continue
next_update = now + self.config.update_interval_s
selected = self.algorithm
try:
with self._lock:
reset_mvdr_stream = self._reset_mvdr_stream
self._reset_mvdr_stream = False
if reset_mvdr_stream:
self._mvdr.reset_stream()
# One DC-removal/window/FFT pass feeds both the selected DOA
# engine and MVDR; the beamformer does not recompute an STFT.
analysis = self.analyze_stft(buffer)
result = self.estimate_stft(analysis, algorithm=selected)
with self._lock:
if result.algorithm != self._algorithm:
continue
if result.angle_degrees is not None:
result = DoaEstimate(
self._smooth_angle(result.angle_degrees),
result.level_dbfs,
result.peak_sharpness,
result.timestamp,
result.algorithm,
)
self._last_valid_angle_degrees = result.angle_degrees
beamforming_angle = (
self._manual_mvdr_angle_degrees
if self._manual_mvdr_angle_degrees is not None
else self._last_valid_angle_degrees
)
mvdr_result = None
mvdr_error = None
if beamforming_angle is not None:
try:
mvdr_result = self._mvdr.process_stft(
analysis,
beamforming_angle,
source_sample_end=source_sample_end,
)
except Exception as exc:
mvdr_error = str(exc)
with self._lock:
if result.algorithm != self._algorithm:
continue
self._latest = result
self._latest_mvdr = mvdr_result
self._error = None
self._mvdr_error = mvdr_error
if (
mvdr_result is not None
and mvdr_result.stream_audio is not None
and mvdr_result.stream_audio.size
):
self._monitor_results.append(mvdr_result)
except Exception as exc:
with self._lock:
if selected == self._algorithm:
self._error = str(exc)
def close(self) -> None:
self._stop.set()
with self._ring_condition:
self._ring_condition.notify_all()
if self._thread is not None:
self._thread.join(timeout=1.0)
# Backward-compatible name used by the initial SRP-only UI and external code.
SrpPhatConfig = DoaConfig
SrpPhatEstimator = DoaEstimator
+13
View File
@@ -0,0 +1,13 @@
{
"common_base_gain_db": 15.0,
"channel_trim_gain_db": [
-1.8288826681343429,
1.3977249439146622,
-0.8386426154471731,
1.9970350585161052,
-1.073561535307002,
0.536631836656505,
-2.456214123248102,
1.196345532707942
]
}
+77
View File
@@ -0,0 +1,77 @@
"""Input gain calibration for the multichannel microphone stream."""
from __future__ import annotations
import json
import math
from dataclasses import dataclass
from pathlib import Path
import numpy as np
DEFAULT_GAIN_CALIBRATION_PATH = Path(__file__).with_name("gain_cali.json")
@dataclass(frozen=True)
class GainCalibration:
"""Common and per-channel amplitude gains loaded from a JSON file."""
common_base_gain_db: float
channel_trim_gain_db: tuple[float, ...]
linear_gain: np.ndarray
@classmethod
def load(
cls,
path: str | Path = DEFAULT_GAIN_CALIBRATION_PATH,
*,
channels: int = 8,
) -> GainCalibration:
config_path = Path(path)
with config_path.open("r", encoding="utf-8") as config_file:
config = json.load(config_file)
if not isinstance(config, dict):
raise ValueError(f"Gain calibration in {config_path} must be a JSON object")
common_gain = cls._finite_number(
config.get("common_base_gain_db"),
"common_base_gain_db",
config_path,
)
raw_trim = config.get("channel_trim_gain_db")
if not isinstance(raw_trim, list) or len(raw_trim) != channels:
raise ValueError(
f"channel_trim_gain_db in {config_path} must contain "
f"exactly {channels} values"
)
trim = tuple(
cls._finite_number(value, f"channel_trim_gain_db[{index}]", config_path)
for index, value in enumerate(raw_trim)
)
total_gain_db = np.asarray(trim, dtype=np.float64) + common_gain
linear_gain = np.power(10.0, total_gain_db / 20.0).astype(np.float32)
linear_gain.setflags(write=False)
return cls(common_gain, trim, linear_gain)
@staticmethod
def _finite_number(value: object, name: str, path: Path) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{name} in {path} must be a number in dB")
result = float(value)
if not math.isfinite(result):
raise ValueError(f"{name} in {path} must be finite")
return result
def apply(self, samples: np.ndarray) -> np.ndarray:
"""Apply amplitude gains to frames shaped ``(samples, channels)``."""
audio = np.asarray(samples, dtype=np.float32)
if audio.ndim != 2 or audio.shape[1] != len(self.channel_trim_gain_db):
raise ValueError(
"Audio must be a two-dimensional array with "
f"{len(self.channel_trim_gain_db)} channels"
)
return audio * self.linear_gain
File diff suppressed because it is too large. Load diff
+289
View File
@@ -0,0 +1,289 @@
"""Frequency-domain MVDR beamforming steered by a DOA azimuth."""
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
try:
from .stft_analysis import StftAnalysis, analyze_multichannel_stft
except ImportError: # Support direct execution from the src directory.
from stft_analysis import StftAnalysis, analyze_multichannel_stft
@dataclass(frozen=True)
class MvdrConfig:
sample_rate: int = 48_000
nfft: int = 512
hop: int = 256
sound_speed_m_s: float = 343.0
min_frequency_hz: float = 500.0
max_frequency_hz: float = 4_000.0
diagonal_loading: float = 1e-2
covariance_smoothing: float = 0.70
@dataclass(frozen=True)
class MvdrResult:
audio: np.ndarray
level_dbfs: float
input_level_dbfs: float
angle_degrees: float
timestamp: float
source_sample_end: int | None = None
stream_audio: np.ndarray | None = None
stream_sample_start: int | None = None
stream_sample_end: int | None = None
class MvdrBeamformer:
"""Apply broadband MVDR with delay-and-sum outside the adaptive band."""
def __init__(
self,
microphone_positions: np.ndarray,
config: MvdrConfig | None = None,
) -> None:
self.config = config or MvdrConfig()
positions = np.asarray(microphone_positions, dtype=np.float64)
if positions.ndim != 2 or positions.shape[0] != 2:
raise ValueError("microphone_positions must have shape (2, channels)")
if self.config.nfft <= 0 or self.config.nfft % 2:
raise ValueError("nfft must be a positive even integer")
if self.config.sample_rate <= 0:
raise ValueError("sample_rate must be positive")
if not 0.0 <= self.config.min_frequency_hz < self.config.max_frequency_hz:
raise ValueError("MVDR frequency range is invalid")
if self.config.max_frequency_hz >= self.config.sample_rate / 2.0:
raise ValueError("max_frequency_hz must be below the Nyquist frequency")
if not 0 < self.config.hop <= self.config.nfft:
raise ValueError("hop must be in the range 1..nfft")
if self.config.diagonal_loading <= 0:
raise ValueError("diagonal_loading must be positive")
if not 0.0 <= self.config.covariance_smoothing < 1.0:
raise ValueError("covariance_smoothing must be in the range [0, 1)")
self.microphone_positions = positions
self.channels = positions.shape[1]
self._window = np.hanning(self.config.nfft).astype(np.float64)
self._frequencies = np.fft.rfftfreq(
self.config.nfft, 1.0 / self.config.sample_rate
)
self._adaptive_bins = np.flatnonzero(
(self._frequencies >= self.config.min_frequency_hz)
& (self._frequencies <= self.config.max_frequency_hz)
)
self._covariance_state: np.ndarray | None = None
self._stream_tail: np.ndarray | None = None
self._stream_tail_normalization: np.ndarray | None = None
self._stream_last_frame_start: int | None = None
def reset_stream(self) -> None:
"""Reset adaptive smoothing and the stateful WOLA synthesis tail."""
self._covariance_state = None
self._stream_tail = None
self._stream_tail_normalization = None
self._stream_last_frame_start = None
def steering_vector(self, angle_degrees: float) -> np.ndarray:
"""Return ``frequency x channels`` far-field steering vectors."""
angle = np.deg2rad(angle_degrees)
direction = np.array([np.cos(angle), np.sin(angle)])
relative_times = (
direction @ self.microphone_positions / self.config.sound_speed_m_s
)
return np.exp(
2j
* np.pi
* self._frequencies[:, None]
* relative_times[None, :]
)
def process(self, audio: np.ndarray, angle_degrees: float) -> MvdrResult:
"""Beamform a ``samples x channels`` calibrated audio window."""
analysis = analyze_multichannel_stft(
audio,
channels=self.channels,
nfft=self.config.nfft,
hop=self.config.hop,
window=self._window,
)
return self.process_stft(analysis, angle_degrees)
def process_stft(
self,
analysis: StftAnalysis,
angle_degrees: float,
*,
source_sample_end: int | None = None,
) -> MvdrResult:
"""Beamform a shared STFT already computed by the DOA front end."""
spectra = np.asarray(analysis.spectra)
expected_bins = self.config.nfft // 2 + 1
if spectra.ndim != 3 or spectra.shape[1:] != (
expected_bins,
self.channels,
):
raise ValueError(
"STFT spectra must have shape "
f"(snapshots, {expected_bins}, {self.channels})"
)
starts = np.arange(
0,
analysis.sample_count - self.config.nfft + 1,
self.config.hop,
)
if spectra.shape[0] != starts.size:
raise ValueError("STFT snapshot count does not match sample_count")
steering = self.steering_vector(angle_degrees)
# Delay-and-sum is the stable fallback. MVDR replaces weights only in
# the calibrated, spatially unaliased DOA band.
weights = steering / self.channels
bins = self._adaptive_bins
selected = spectra[:, bins, :]
instantaneous_covariance = np.einsum(
"sfm,sfn->fmn", selected, np.conj(selected), optimize=True
) / selected.shape[0]
if (
self._covariance_state is None
or self._covariance_state.shape != instantaneous_covariance.shape
):
covariance = instantaneous_covariance
else:
smoothing = self.config.covariance_smoothing
covariance = (
smoothing * self._covariance_state
+ (1.0 - smoothing) * instantaneous_covariance
)
self._covariance_state = covariance.copy()
mean_power = np.trace(covariance, axis1=1, axis2=2).real / self.channels
loading = self.config.diagonal_loading * np.maximum(mean_power, 1e-12)
covariance += loading[:, None, None] * np.eye(self.channels)[None, :, :]
desired = steering[bins]
inverse_steering = np.linalg.solve(covariance, desired[..., None])[..., 0]
denominator = np.einsum(
"fm,fm->f", np.conj(desired), inverse_steering, optimize=True
)
denominator = np.where(np.abs(denominator) < 1e-12, 1e-12, denominator)
weights[bins] = inverse_steering / denominator[:, None]
output_spectrum = np.einsum(
"fm,sfm->sf", np.conj(weights), spectra, optimize=True
)
output_frames = np.fft.irfft(
output_spectrum, n=self.config.nfft, axis=1
)
stream_audio, stream_start, stream_end = self._stream_synthesize(
output_frames,
starts,
analysis.sample_count,
source_sample_end,
)
output = np.zeros(analysis.sample_count, dtype=np.float64)
normalization = np.zeros(analysis.sample_count, dtype=np.float64)
window_squared = np.square(self._window)
for frame, start in zip(output_frames, starts):
stop = start + self.config.nfft
output[start:stop] += frame * self._window
normalization[start:stop] += window_squared
valid = normalization > 1e-6
output[valid] /= normalization[valid]
output[~valid] = 0.0
# Ignore the poorly conditioned overlap-add edges when reporting level.
level_mask = normalization > 0.25 * float(np.max(normalization))
output_rms = float(
np.sqrt(np.mean(np.square(output[level_mask]), dtype=np.float64) + 1e-12)
)
output_audio = output.astype(np.float32)
output_audio.setflags(write=False)
return MvdrResult(
audio=output_audio,
level_dbfs=float(20.0 * np.log10(output_rms)),
input_level_dbfs=analysis.input_level_dbfs,
angle_degrees=float(angle_degrees % 360.0),
timestamp=time.monotonic(),
source_sample_end=source_sample_end,
stream_audio=stream_audio,
stream_sample_start=stream_start,
stream_sample_end=stream_end,
)
def _stream_synthesize(
self,
output_frames: np.ndarray,
relative_starts: np.ndarray,
sample_count: int,
source_sample_end: int | None,
) -> tuple[np.ndarray | None, int | None, int | None]:
"""Synthesize only finalized 50%-overlapped hops for monitoring."""
if source_sample_end is None:
return None, None, None
if self.config.nfft != 2 * self.config.hop:
raise ValueError("Streaming WOLA requires 50% overlap")
window_start = source_sample_end - sample_count
frame_starts = window_start + relative_starts
if self._stream_last_frame_start is None:
first_new = 0
else:
new_indices = np.flatnonzero(frame_starts > self._stream_last_frame_start)
if new_indices.size == 0:
empty = np.empty(0, dtype=np.float32)
empty.setflags(write=False)
return empty, None, None
first_new = int(new_indices[0])
hop = self.config.hop
window = self._window
window_squared = np.square(window)
chunks: list[np.ndarray] = []
output_start: int | None = None
for frame, frame_start in zip(
output_frames[first_new:], frame_starts[first_new:]
):
frame_start = int(frame_start)
if (
self._stream_last_frame_start is not None
and frame_start != self._stream_last_frame_start + hop
):
# A skipped source interval cannot be reconstructed. Drop the
# old tail and warm up again instead of splicing unrelated data.
self._stream_tail = None
self._stream_tail_normalization = None
synthesized = frame * window
if self._stream_tail is not None:
normalization = (
self._stream_tail_normalization + window_squared[:hop]
)
stable = self._stream_tail + synthesized[:hop]
valid = normalization > 1e-6
stable[valid] /= normalization[valid]
stable[~valid] = 0.0
if output_start is None:
output_start = frame_start
chunks.append(stable.astype(np.float32))
self._stream_tail = synthesized[hop:].copy()
self._stream_tail_normalization = window_squared[hop:].copy()
self._stream_last_frame_start = frame_start
if not chunks or output_start is None:
empty = np.empty(0, dtype=np.float32)
empty.setflags(write=False)
return empty, None, None
audio = np.concatenate(chunks)
audio.setflags(write=False)
return audio, output_start, output_start + audio.size
+58
View File
@@ -0,0 +1,58 @@
"""Shared multichannel STFT front end for DOA and beamforming."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class StftAnalysis:
"""One window of channel-aligned spectra and its input levels."""
spectra: np.ndarray
sample_count: int
level_dbfs: float
input_level_dbfs: float
def analyze_multichannel_stft(
audio: np.ndarray,
*,
channels: int,
nfft: int,
hop: int,
window: np.ndarray,
) -> StftAnalysis:
"""Remove per-channel DC and compute one reusable windowed STFT."""
samples = np.asarray(audio, dtype=np.float32)
if samples.ndim != 2 or samples.shape[1] != channels:
raise ValueError(f"audio must have shape (samples, {channels})")
if samples.shape[0] < nfft:
raise ValueError(f"at least {nfft} samples are required")
if window.shape != (nfft,):
raise ValueError(f"window must have shape ({nfft},)")
input_rms = float(
np.sqrt(np.mean(np.square(samples), dtype=np.float64) + 1e-12)
)
centered = samples - np.mean(samples, axis=0, keepdims=True)
analysis_rms = float(
np.sqrt(np.mean(np.square(centered), dtype=np.float64) + 1e-12)
)
starts = range(0, samples.shape[0] - nfft + 1, hop)
frames = np.stack(
[centered[start : start + nfft] for start in starts],
axis=0,
)
frames *= np.asarray(window, dtype=np.float32)[None, :, None]
spectra = np.fft.rfft(frames, n=nfft, axis=1)
spectra.setflags(write=False)
return StftAnalysis(
spectra=spectra,
sample_count=samples.shape[0],
level_dbfs=float(20.0 * np.log10(analysis_rms)),
input_level_dbfs=float(20.0 * np.log10(input_rms)),
)
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import sys
import threading
import time
import types
import numpy as np
from src.main import LiveAudioSource
class FakeInputStream:
def __init__(self, **kwargs) -> None:
self.kwargs = kwargs
self.channels = kwargs["channels"]
self.samplerate = kwargs["samplerate"]
self.aborted = False
self.closed = False
self._wake = threading.Event()
def start(self) -> None:
pass
def read(self, frames: int):
self._wake.wait(0.005)
if self.aborted:
raise RuntimeError("aborted")
return np.full((frames, self.channels), 0.01, dtype=np.float32), False
def abort(self) -> None:
self.aborted = True
self._wake.set()
def close(self) -> None:
self.closed = True
def test_live_capture_uses_blocking_reads_without_callback(monkeypatch) -> None:
created: list[FakeInputStream] = []
def input_stream(**kwargs):
stream = FakeInputStream(**kwargs)
created.append(stream)
return stream
devices = [
{
"name": "Fake USB 8ch",
"max_input_channels": 8,
"hostapi": 0,
"default_samplerate": 44_100.0,
},
{
"name": "Fake USB 8ch",
"max_input_channels": 8,
"hostapi": 1,
"default_samplerate": 24_000.0,
},
]
hostapis = [{"name": "MME"}, {"name": "Windows WASAPI"}]
def query_devices(index=None):
return devices if index is None else devices[index]
fake_sounddevice = types.SimpleNamespace(
default=types.SimpleNamespace(device=(0, -1)),
query_devices=query_devices,
query_hostapis=lambda index: hostapis[index],
InputStream=input_stream,
)
monkeypatch.setitem(sys.modules, "sounddevice", fake_sounddevice)
source = LiveAudioSource(blocksize=128)
try:
deadline = time.monotonic() + 1.0
while source.frames_captured == 0 and time.monotonic() < deadline:
time.sleep(0.01)
assert source.frames_captured > 0
assert created[0].kwargs["device"] == 1
assert created[0].kwargs["samplerate"] == 24_000
assert created[0].kwargs["blocksize"] == 0
assert "callback" not in created[0].kwargs
assert source.doa is not None
assert source.sample_rate == 24_000
assert source.doa.config.sample_rate == 24_000
assert source.doa.buffered_samples > 0
assert max(source.read_levels(0.0)) > -41.0
assert source.console_status() == (
"[audio] Input stream opened\n"
" Device: #1 Fake USB 8ch\n"
" Driver interface (Host API): Windows WASAPI\n"
" Input channels: 8 used / 8 available\n"
" Sample rate: 24000 Hz (device default: 24000 Hz)"
)
finally:
source.close()
assert created[0].aborted
assert created[0].closed
def test_console_status_explains_missing_live_input(monkeypatch) -> None:
fake_sounddevice = types.SimpleNamespace(
default=types.SimpleNamespace(device=(-1, -1)),
query_devices=lambda: [],
)
monkeypatch.setitem(sys.modules, "sounddevice", fake_sounddevice)
source = LiveAudioSource()
assert not source.available
assert source.console_status() == (
"[audio] Live input unavailable; using simulated preview\n"
" Reason: No audio input device with at least 8 channels was found"
)
def test_explicit_sample_rate_overrides_detected_rate(monkeypatch) -> None:
created: list[FakeInputStream] = []
device = {
"name": "Fake USB 8ch",
"max_input_channels": 8,
"hostapi": 0,
"default_samplerate": 24_000.0,
}
fake_sounddevice = types.SimpleNamespace(
default=types.SimpleNamespace(device=(0, -1)),
query_devices=lambda index=None: [device] if index is None else device,
query_hostapis=lambda _index: {"name": "Windows WASAPI"},
InputStream=lambda **kwargs: created.append(FakeInputStream(**kwargs))
or created[-1],
)
monkeypatch.setitem(sys.modules, "sounddevice", fake_sounddevice)
source = LiveAudioSource(sample_rate=32_000, blocksize=128)
try:
assert created[0].kwargs["samplerate"] == 32_000
assert source.sample_rate == 32_000
assert source.doa is not None
assert source.doa.config.sample_rate == 32_000
finally:
source.close()
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import sys
import time
import types
import numpy as np
from src.audio_monitor import MvdrAudioMonitor
from src.mvdr_beamformer import MvdrResult
class FakeOutputStream:
def __init__(self, writes: list[np.ndarray], **kwargs) -> None:
self.kwargs = kwargs
self.writes = writes
self.aborted = False
self.closed = False
def start(self) -> None:
pass
def write(self, audio: np.ndarray) -> bool:
self.writes.append(audio.copy())
return False
def abort(self) -> None:
self.aborted = True
def close(self) -> None:
self.closed = True
def result(
timestamp: float,
sample_end: int,
value: float = 0.01,
stream_samples: int = 100,
) -> MvdrResult:
audio = np.full(100, value, dtype=np.float32)
audio.setflags(write=False)
stream_audio = np.full(stream_samples, value, dtype=np.float32)
stream_audio.setflags(write=False)
return MvdrResult(
audio,
-40.0,
-30.0,
45.0,
timestamp,
sample_end,
stream_audio,
sample_end - stream_samples,
sample_end,
)
def test_monitor_plays_only_new_mvdr_tail_with_gain(monkeypatch) -> None:
writes: list[np.ndarray] = []
streams: list[FakeOutputStream] = []
devices = [
{
"name": "Fake speakers",
"max_output_channels": 2,
"hostapi": 0,
}
]
def output_stream(**kwargs):
stream = FakeOutputStream(writes, **kwargs)
streams.append(stream)
return stream
fake_sounddevice = types.SimpleNamespace(
default=types.SimpleNamespace(device=(-1, 0)),
query_devices=lambda: devices,
query_hostapis=lambda _index: {"name": "Fake API"},
check_output_settings=lambda **_kwargs: None,
OutputStream=output_stream,
)
monkeypatch.setitem(sys.modules, "sounddevice", fake_sounddevice)
current = [result(1.0, 100)]
monitor = MvdrAudioMonitor(24_000, lambda: current[0])
monitor.set_gain_db(20.0)
monitor.set_enabled(True)
try:
deadline = time.monotonic() + 1.0
while len(writes) < 1 and time.monotonic() < deadline:
time.sleep(0.01)
assert writes[0].shape == (100, 1)
np.testing.assert_allclose(writes[0][:, 0], 0.1, atol=1e-6)
current[0] = result(2.0, 140, value=0.02, stream_samples=40)
deadline = time.monotonic() + 1.0
while len(writes) < 2 and time.monotonic() < deadline:
time.sleep(0.01)
assert writes[1].shape == (40, 1)
np.testing.assert_allclose(writes[1][:, 0], 0.2, atol=1e-6)
finally:
monitor.close()
assert streams[0].kwargs["device"] == 0
assert streams[0].kwargs["samplerate"] == 24_000
assert streams[0].aborted
assert streams[0].closed
def test_monitor_gain_range_and_clipping(monkeypatch) -> None:
fake_sounddevice = types.SimpleNamespace(
default=types.SimpleNamespace(device=(-1, -1)),
query_devices=lambda: [],
)
monkeypatch.setitem(sys.modules, "sounddevice", fake_sounddevice)
monitor = MvdrAudioMonitor(24_000, lambda: None)
try:
assert monitor.set_gain_db(-50.0) == -10.0
assert monitor.set_gain_db(80.0) == 40.0
amplified, clipped = monitor.apply_gain(
np.array([0.02, -0.02], dtype=np.float32), 40.0
)
np.testing.assert_array_equal(amplified, np.array([1.0, -1.0]))
assert clipped
finally:
monitor.close()
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import time
import numpy as np
import pytest
from src.doa_estimator import (
DOA_ALGORITHMS,
DoaConfig,
DoaEstimator,
load_pyroomacoustics_normmusic,
load_pyroomacoustics_srp,
)
def circular_error_degrees(actual: float, expected: float) -> float:
return abs((actual - expected + 180.0) % 360.0 - 180.0)
def synthesize_plane_wave(
estimator: DoaEstimator,
angle_degrees: float,
) -> np.ndarray:
config = estimator.config
rng = np.random.default_rng(8)
source = rng.standard_normal(config.window_samples)
frequencies = np.fft.rfftfreq(config.window_samples, 1.0 / config.sample_rate)
source_spectrum = np.fft.rfft(source)
angle = np.deg2rad(angle_degrees)
direction = np.array([np.cos(angle), np.sin(angle)])
relative_times = (
direction @ estimator.microphone_positions / config.sound_speed_m_s
)
channels = [
np.fft.irfft(
source_spectrum * np.exp(2j * np.pi * frequencies * relative_time),
n=config.window_samples,
)
for relative_time in relative_times
]
return (0.05 * np.stack(channels, axis=1)).astype(np.float32)
def test_loads_srp_from_cloned_source() -> None:
srp = load_pyroomacoustics_srp()
assert srp.__name__ == "SRP"
assert srp.__module__.endswith(".srp")
def test_loads_normmusic_from_cloned_source() -> None:
normmusic = load_pyroomacoustics_normmusic()
assert normmusic.__name__ == "NormMUSIC"
assert normmusic.__module__.endswith(".normmusic")
@pytest.mark.parametrize("algorithm", DOA_ALGORITHMS)
@pytest.mark.parametrize("expected", [0.0, 47.0, 123.0, 275.0, 359.0])
def test_plane_wave_direction(algorithm: str, expected: float) -> None:
estimator = DoaEstimator(algorithm=algorithm, start_worker=False)
try:
result = estimator.estimate(synthesize_plane_wave(estimator, expected))
finally:
estimator.close()
assert result.angle_degrees is not None
assert result.algorithm == algorithm
assert circular_error_degrees(result.angle_degrees, expected) <= 1.0
assert result.peak_sharpness > 0.8
def test_runtime_algorithm_switch_clears_previous_result() -> None:
estimator = DoaEstimator(start_worker=False)
try:
assert estimator.algorithm == "SRP-PHAT"
assert estimator.toggle_algorithm() == "NormMUSIC"
assert estimator.algorithm == "NormMUSIC"
assert estimator._engines["NormMUSIC"].frequency_normalization is True
assert estimator.set_algorithm("MUSIC") == "NormMUSIC"
assert estimator.set_algorithm("SRP") == "SRP-PHAT"
finally:
estimator.close()
def test_manual_mvdr_steering_angle_is_normalized_and_optional() -> None:
estimator = DoaEstimator(start_worker=False)
try:
assert estimator.mvdr_steering_angle_override is None
assert estimator.set_mvdr_steering_angle(370.0) == 10.0
assert estimator.mvdr_steering_angle_override == 10.0
assert estimator.set_mvdr_steering_angle(-15.0) == 345.0
assert estimator.set_mvdr_steering_angle(None) is None
assert estimator.mvdr_steering_angle_override is None
with pytest.raises(ValueError):
estimator.set_mvdr_steering_angle(float("nan"))
finally:
estimator.close()
def test_stft_defaults_preserve_48khz_time_and_frequency_resolution() -> None:
config_48k = DoaConfig(sample_rate=48_000)
config_24k = DoaConfig(sample_rate=24_000)
assert (config_48k.nfft, config_48k.hop, config_48k.window_samples) == (
512,
256,
8_192,
)
assert (config_24k.nfft, config_24k.hop, config_24k.window_samples) == (
256,
128,
4_096,
)
assert config_48k.sample_rate / config_48k.nfft == (
config_24k.sample_rate / config_24k.nfft
)
assert config_48k.window_samples / config_48k.sample_rate == (
config_24k.window_samples / config_24k.sample_rate
)
@pytest.mark.parametrize("algorithm", DOA_ALGORITHMS)
def test_doa_and_mvdr_use_detected_24khz_rate(algorithm: str) -> None:
config = DoaConfig(sample_rate=24_000)
estimator = DoaEstimator(config=config, algorithm=algorithm, start_worker=False)
try:
audio = synthesize_plane_wave(estimator, 68.0)
doa = estimator.estimate(audio)
assert doa.angle_degrees is not None
assert circular_error_degrees(doa.angle_degrees, 68.0) <= 1.0
mvdr = estimator._mvdr.process(audio, doa.angle_degrees)
assert estimator._mvdr.config.sample_rate == 24_000
assert mvdr.audio.shape == (config.window_samples,)
assert np.isfinite(mvdr.audio).all()
finally:
estimator.close()
def test_audio_ring_is_strictly_bounded() -> None:
config = DoaConfig(nfft=512, window_samples=1_024)
estimator = DoaEstimator(config=config, start_worker=False)
try:
oversized = np.arange(3_000 * config.channels, dtype=np.float32).reshape(
3_000, config.channels
)
estimator.submit(oversized)
assert estimator.buffered_samples == config.window_samples
np.testing.assert_array_equal(estimator._ring, oversized[-1_024:])
finally:
estimator.close()
def test_worker_publishes_doa_steered_mvdr_audio_with_one_shared_stft() -> None:
config = DoaConfig(update_interval_s=0.01, smoothing=1.0)
estimator = DoaEstimator(config=config)
try:
stft_calls = 0
analyze_stft = estimator.analyze_stft
def counted_analyze_stft(audio):
nonlocal stft_calls
stft_calls += 1
return analyze_stft(audio)
estimator.analyze_stft = counted_analyze_stft
estimator.submit(synthesize_plane_wave(estimator, 68.0))
deadline = time.monotonic() + 2.0
while estimator.latest_mvdr is None and time.monotonic() < deadline:
time.sleep(0.01)
doa = estimator.latest
mvdr = estimator.latest_mvdr
assert doa is not None
assert doa.angle_degrees is not None
assert circular_error_degrees(doa.angle_degrees, 68.0) <= 1.0
assert mvdr is not None
assert mvdr.audio.shape == (config.window_samples,)
assert circular_error_degrees(mvdr.angle_degrees, 68.0) <= 1.0
assert mvdr.source_sample_end == config.window_samples
queued = estimator.pop_monitor_result()
assert queued is mvdr
assert queued.stream_audio is not None
assert queued.stream_audio.size == config.window_samples - 2 * config.hop
assert stft_calls == 1
finally:
estimator.close()
def test_low_signal_keeps_mvdr_streaming_at_last_valid_direction() -> None:
config = DoaConfig(update_interval_s=0.0, smoothing=1.0)
estimator = DoaEstimator(config=config)
try:
estimator.submit(synthesize_plane_wave(estimator, 68.0))
deadline = time.monotonic() + 2.0
while estimator.latest_mvdr is None and time.monotonic() < deadline:
time.sleep(0.01)
first = estimator.latest_mvdr
assert first is not None
first_timestamp = first.timestamp
held_angle = first.angle_degrees
estimator.submit(
np.zeros((config.window_samples, config.channels), dtype=np.float32)
)
deadline = time.monotonic() + 2.0
while (
estimator.latest_mvdr is None
or estimator.latest_mvdr.timestamp == first_timestamp
) and time.monotonic() < deadline:
time.sleep(0.01)
doa = estimator.latest
held = estimator.latest_mvdr
assert doa is not None
assert doa.angle_degrees is None
assert doa.level_dbfs < config.activity_threshold_dbfs
assert held is not None
assert held.timestamp != first_timestamp
assert circular_error_degrees(held.angle_degrees, held_angle) < 1e-6
assert held.stream_audio is not None
assert held.stream_audio.size > 0
finally:
estimator.close()
def test_manual_steering_runs_mvdr_without_a_valid_doa() -> None:
config = DoaConfig(update_interval_s=0.0, smoothing=1.0)
estimator = DoaEstimator(config=config)
try:
estimator.set_mvdr_steering_angle(123.0)
estimator.submit(
np.zeros((config.window_samples, config.channels), dtype=np.float32)
)
deadline = time.monotonic() + 2.0
while estimator.latest_mvdr is None and time.monotonic() < deadline:
time.sleep(0.01)
doa = estimator.latest
mvdr = estimator.latest_mvdr
assert doa is not None
assert doa.angle_degrees is None
assert mvdr is not None
assert circular_error_degrees(mvdr.angle_degrees, 123.0) < 1e-6
assert mvdr.stream_audio is not None
assert mvdr.stream_audio.size > 0
finally:
estimator.close()
@@ -0,0 +1,64 @@
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from src.gain_calibration import GainCalibration
REPOSITORY_ROOT = Path(__file__).parents[1]
def write_config(tmp_path, common_gain: float, trim: list[float]):
path = tmp_path / "gain.json"
path.write_text(
json.dumps(
{
"common_base_gain_db": common_gain,
"channel_trim_gain_db": trim,
}
),
encoding="utf-8",
)
return path
def test_applies_common_and_channel_gain_in_db(tmp_path) -> None:
path = write_config(tmp_path, 6.0, [0.0, -6.0])
calibration = GainCalibration.load(path, channels=2)
calibrated = calibration.apply(np.ones((3, 2), dtype=np.float32))
expected = np.array([10.0 ** (6.0 / 20.0), 1.0], dtype=np.float32)
np.testing.assert_allclose(calibrated, np.tile(expected, (3, 1)), rtol=1e-6)
def test_runtime_channel_trims_match_calibration_result() -> None:
runtime_config = json.loads(
(REPOSITORY_ROOT / "src" / "gain_cali.json").read_text(encoding="utf-8")
)
calibration_result = json.loads(
(REPOSITORY_ROOT / "calibration" / "data" / "calibration_result.json").read_text(
encoding="utf-8"
)
)
assert runtime_config["channel_trim_gain_db"] == calibration_result["correction_db"]
@pytest.mark.parametrize(
"common_gain, trim",
[
(0.0, [0.0]),
(float("nan"), [0.0, 0.0]),
(0.0, [0.0, float("inf")]),
],
)
def test_rejects_invalid_gain_configuration(tmp_path, common_gain, trim) -> None:
path = write_config(tmp_path, common_gain, trim)
with pytest.raises(ValueError):
GainCalibration.load(path, channels=2)
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
import numpy as np
from src.doa_estimator import MIC_AZIMUTHS_DEG
from src.mvdr_beamformer import MvdrBeamformer, MvdrConfig
from src.stft_analysis import analyze_multichannel_stft
SAMPLE_RATE = 48_000
SAMPLES = 8_192
def microphone_positions() -> np.ndarray:
angles = np.deg2rad(np.asarray(MIC_AZIMUTHS_DEG))
return 0.056 * np.vstack((np.cos(angles), np.sin(angles)))
def band_limited_noise(rng: np.random.Generator) -> np.ndarray:
source = rng.standard_normal(SAMPLES)
frequencies = np.fft.rfftfreq(SAMPLES, 1.0 / SAMPLE_RATE)
spectrum = np.fft.rfft(source)
spectrum[(frequencies < 700.0) | (frequencies > 3_000.0)] = 0.0
filtered = np.fft.irfft(spectrum, n=SAMPLES)
return filtered / np.std(filtered)
def plane_wave(source: np.ndarray, angle_degrees: float) -> np.ndarray:
positions = microphone_positions()
frequencies = np.fft.rfftfreq(source.size, 1.0 / SAMPLE_RATE)
angle = np.deg2rad(angle_degrees)
direction = np.array([np.cos(angle), np.sin(angle)])
relative_times = direction @ positions / 343.0
spectrum = np.fft.rfft(source)
return np.stack(
[
np.fft.irfft(
spectrum * np.exp(2j * np.pi * frequencies * delay),
n=source.size,
)
for delay in relative_times
],
axis=1,
)
def test_mvdr_preserves_signal_from_steered_direction() -> None:
rng = np.random.default_rng(21)
source = band_limited_noise(rng)
audio = 0.05 * plane_wave(source, 73.0)
beamformer = MvdrBeamformer(microphone_positions(), MvdrConfig())
result = beamformer.process(audio, 73.0)
valid = slice(512, -512)
gain = np.linalg.lstsq(
source[valid, None], result.audio[valid], rcond=None
)[0][0]
assert np.isfinite(result.audio).all()
assert result.audio.shape == (SAMPLES,)
assert not result.audio.flags.writeable
assert result.angle_degrees == 73.0
assert 0.045 < gain < 0.055
def test_mvdr_suppresses_off_axis_interference() -> None:
rng = np.random.default_rng(17)
target = band_limited_noise(rng)
interference = band_limited_noise(rng)
mixture = 0.05 * (
plane_wave(target, 40.0) + plane_wave(interference, 145.0)
)
beamformer = MvdrBeamformer(microphone_positions(), MvdrConfig())
output = beamformer.process(mixture, 40.0).audio
valid = slice(512, -512)
references = np.column_stack((target[valid], interference[valid]))
input_coefficients = np.linalg.lstsq(
references, mixture[valid, 0], rcond=None
)[0]
output_coefficients = np.linalg.lstsq(
references, output[valid], rcond=None
)[0]
input_sir = 20.0 * np.log10(
abs(input_coefficients[0] / input_coefficients[1])
)
output_sir = 20.0 * np.log10(
abs(output_coefficients[0] / output_coefficients[1])
)
assert output_sir >= input_sir + 10.0
def test_streaming_wola_excludes_unstable_edges_and_stays_continuous() -> None:
sample_rate = 24_000
nfft = 256
hop = 128
window_samples = 4_096
step = 3_072
total = window_samples + 4 * step
rng = np.random.default_rng(123)
frequencies = np.fft.rfftfreq(total, 1.0 / sample_rate)
passband = (frequencies >= 600.0) & (frequencies <= 3_800.0)
def noise() -> np.ndarray:
spectrum = np.fft.rfft(rng.standard_normal(total))
spectrum[~passband] = 0.0
signal = np.fft.irfft(spectrum, total)
return signal / np.std(signal)
positions = microphone_positions()
def wave(source: np.ndarray, angle_degrees: float) -> np.ndarray:
angle = np.deg2rad(angle_degrees)
direction = np.array([np.cos(angle), np.sin(angle)])
delays = direction @ positions / 343.0
spectrum = np.fft.rfft(source)
return np.stack(
[
np.fft.irfft(
spectrum * np.exp(2j * np.pi * frequencies * delay),
total,
)
for delay in delays
],
axis=1,
)
audio = 0.04 * (wave(noise(), 40.0) + wave(noise(), 145.0))
config = MvdrConfig(sample_rate=sample_rate, nfft=nfft, hop=hop)
beamformer = MvdrBeamformer(positions, config)
window = np.hanning(nfft).astype(np.float32)
stream_blocks = []
previous_end = None
for start in range(0, total - window_samples + 1, step):
analysis = analyze_multichannel_stft(
audio[start : start + window_samples],
channels=8,
nfft=nfft,
hop=hop,
window=window,
)
result = beamformer.process_stft(
analysis,
40.0,
source_sample_end=start + window_samples,
)
assert result.stream_audio is not None
assert result.stream_audio.size > 0
if previous_end is not None:
assert result.stream_sample_start == previous_end
assert result.stream_audio.size == step
previous_end = result.stream_sample_end
stream_blocks.append(result.stream_audio)
joined = np.concatenate(stream_blocks)
assert stream_blocks[0].size == window_samples - 2 * hop
assert np.max(np.abs(joined)) < 0.5
assert np.isfinite(joined).all()
+2562
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
# Macmini M4/M6 磁吸叠放AI阵列麦克风
## 项目介绍
![成品外观](./images/preview.PNG)
本项目是一款为 Mac mini M4/M6 设计的磁吸叠放式八通道 AI 阵列麦克风,用于补足
主机缺少内置麦克风的问题,为听写、语音交互和 AI 辅助编程提供高质量音频输入。
设备由 ESP32-P4、PCMD3180、八颗数字硅麦及 WS2812 灯环组成,通过 USB 2.0
High-Speed 向电脑传输多通道音频,支持声源定位、MVDR 波束形成、降噪和自动增益等
扩展。8 mm CNC 铝合金外壳可磁吸固定在 Mac mini 顶部,中央玻璃窗口兼顾苹果标志
展示与灯效。项目开放固件、上位机、PCB/原理图和结构模型,便于复刻并扩展会议记录、
实时翻译及语音机器人等应用。
完整 ESP-IDF 工程。应用源码和 menuconfig 定义位于 [`src`](src/README.md)。
## 项目目录
```text
Mac_mini_8micArray/
├── PC_SoftWare/ # PC 端八通道音频采集、DOA 定位、MVDR 波束形成及校准工具
├── PCB_SCH/ # 主板、麦克风板和 LED 灯环板的 PCB、原理图及打板资料
├── SHELL_STEP/ # CNC 铝制外壳、玻璃窗、底盖等结构设计与加工资料
└── src/ # ESP32-P4 UAC 2.0 固件、音频驱动、USB 描述符及 LED 控制代码
```
各目录均提供了更详细的说明,请分别参阅:
- [`PC_SoftWare/README.md`](PC_SoftWare/README.md)
- [`PCB_SCH/README.md`](PCB_SCH/README.md)
- [`SHELL_STEP/README.md`](SHELL_STEP/README.md)
- [`src/README.md`](src/README.md)
编译前请运行 `idf.py menuconfig` 配置实际板级 I2S GPIO;未配置时固件会安全停止。
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
# 外壳制作注意事项
## 改进注意事项
- CNC铝壳体为了尽量符合嘉立创优惠条件,螺丝孔没有攻螺纹,USB开孔没有使用侧面特征。如您预算充足,可以直接编辑step文件把这些加上去。
- 喷砂选择如果可以选择,尽量选择高目数喷砂,光泽会更好。
- 彩色阳极氧化不建议尝试,和Macmini 搭配起来视觉效果很奇怪。
- 中央玻璃可以使用亚克力替代,但是成本并不会差多少,建议使用玻璃
- 底盖是可选项,不安装底盖不影响功能和美观
- 螺丝安装孔、玻璃窗安装孔、磁铁安装孔都使用间隙配合,安装时候需要使用些AB胶
## 冷门物料购买链接
[防尘网-银色](https://item.taobao.com/item.htm?id=991287626434&mi_id=0000MWlLTD0B9qUh7QNOCLdP-8aqg2Med4XmxzD8oB5FGq4&spm=tbpc.boughtlist.suborder_itemtitle.1.67002e8dkLCuOH)
[M2铜柱3mm\*4 M2铜柱4mm\*6](https://detail.tmall.com/item.htm?id=520010420898&mi_id=0000FAzSvyZ15wBuFyr55BddZXGQAmrDSwo1xUA7ZJ2c_24&spm=tbpc.boughtlist.suborder_itemtitle.1.3b022e8dl4WFlU)
[M2螺丝3mm\*4 M2螺丝4mm\*6](https://detail.tmall.com/item.htm?id=643512747051&mi_id=0000eZqmYQaG_N9qvKxcEfvHcJf2Uu8JZGzvpVRsHmBE4b8&spm=tbpc.boughtlist.suborder_itemtitle.1.3b022e8dl4WFlU)
[磁铁 直径10mm厚度5mm*4颗](https://detail.tmall.com/item.htm?id=670224869282&mi_id=0000qwxNHelRFOowcJTOYNSwjPLVlNjDwktnjOvE8b0st9g&spm=tbpc.boughtlist.suborder_itemtitle.1.3b022e8dl4WFlU)
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
dependencies:
espressif/tinyusb:
component_hash: a40b7f67df6ac254a4afd96c6401cc56f2059ae747d6d7e66b568bca53ad0edc
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
targets:
- esp32s2
- esp32s3
- esp32p4
- esp32h4
- esp32s31
version: 0.19.0~3
idf:
source:
type: idf
version: 6.1.0
direct_dependencies:
- espressif/tinyusb
- idf
manifest_hash: 70a580c5cb23e0d6dc849c95262d836bbff03d87beccb61d5b9dadb1a8931346
target: esp32p4
version: 3.0.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

+43
View File
@@ -0,0 +1,43 @@
CONFIG_IDF_TARGET="esp32p4"
# Match the known-good ESP32-P4 hardware configuration in ../helloworld.
CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y
CONFIG_ESP32P4_REV_MIN_100=y
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_360=y
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_UART_NUM=0
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# The module fitted to the target board has a 16 MiB SPI flash.
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
# Audio tasks use 2 ms and 5 ms waits, so the 100 Hz hello-world tick cannot
# be copied safely. Keep a 1 ms RTOS tick for deterministic audio scheduling.
CONFIG_FREERTOS_HZ=1000
# ESP32-P4-Function-EV-Board J1 external audio interfaces.
# These GPIOs avoid the board's USB, boot-strap, UART, ESP32-C6 SDIO,
# LCD-control and on-board ES8311 audio connections.
# RX: external 8-channel TDM input, ESP32-P4 is the clock master.
CONFIG_USB_AUDIO_RX_I2S_PORT=0
CONFIG_USB_AUDIO_RX_MCLK_GPIO=20
CONFIG_USB_AUDIO_RX_BCLK_GPIO=21
CONFIG_USB_AUDIO_RX_WS_GPIO=22
CONFIG_USB_AUDIO_RX_DIN_GPIO=23
# TX: external stereo I2S output, ESP32-P4 is the clock master.
CONFIG_USB_AUDIO_TX_I2S_PORT=1
CONFIG_USB_AUDIO_TX_MCLK_GPIO=32
CONFIG_USB_AUDIO_TX_BCLK_GPIO=33
CONFIG_USB_AUDIO_TX_WS_GPIO=46
CONFIG_USB_AUDIO_TX_DOUT_GPIO=47
# 50-pixel WS2812B chain driven by the ESP32-P4 RMT TX peripheral and GDMA.
CONFIG_WS2812_LED_RING_ENABLE=y
CONFIG_WS2812_GPIO=26
CONFIG_WS2812_LED_COUNT=50
CONFIG_WS2812_LED_RING_FPS=100
CONFIG_WS2812_LED_RING_BRIGHTNESS=20
CONFIG_WS2812_LED_RING_DEFAULT_BREATHING=y
CONFIG_WS2812_LED_RING_TASK_PRIORITY=3
CONFIG_WS2812_LED_RING_TASK_STACK_SIZE=4096
+54
View File
@@ -0,0 +1,54 @@
if(${IDF_VERSION_MAJOR} LESS 6)
set(priv_requires esp_timer driver hal usb)
else()
set(priv_requires
esp_timer
esp_driver_gpio
esp_driver_i2c
esp_driver_i2s
esp_driver_rmt
esp_driver_uart
esp_hal_usb
esp_hw_support
vfs
)
endif()
set(app_sources
"main.c"
"audio_backend.c"
"pcmd3180/pcmd3180.c"
"usb_driver/usb_audio.c"
"usb_driver/usb_descriptors.c"
"usb_driver/usb_hid_keyboard.c"
)
if(CONFIG_WS2812_LED_RING_ENABLE)
list(APPEND app_sources
"ws2812/ws2812.c"
"ws2812/led_ring_effects.c"
"ws2812/led_ring.c"
)
endif()
idf_component_register(
SRCS ${app_sources}
INCLUDE_DIRS
"."
"pcmd3180"
"usb_driver"
"ws2812"
PRIV_REQUIRES ${priv_requires}
WHOLE_ARCHIVE
)
# The managed TinyUSB component must see this component's tusb_config.h while
# TinyUSB itself is being compiled.
idf_build_get_property(build_components BUILD_COMPONENTS)
if(tinyusb IN_LIST build_components)
set(tinyusb_component tinyusb)
else()
set(tinyusb_component espressif__tinyusb)
endif()
idf_component_get_property(tinyusb_lib ${tinyusb_component} COMPONENT_LIB)
target_include_directories(${tinyusb_lib} PUBLIC "${COMPONENT_DIR}/usb_driver")
+148
View File
@@ -0,0 +1,148 @@
menu "ESP32-P4 USB audio"
config USB_AUDIO_RX_I2S_PORT
int "8-channel TDM input I2S port"
range 0 2
default 0
config USB_AUDIO_RX_SLAVE
bool "TDM input is an I2S slave"
default n
config USB_AUDIO_RX_MCLK_GPIO
int "TDM input MCLK GPIO (-1 unused)"
range -1 63
default -1
config USB_AUDIO_RX_BCLK_GPIO
int "TDM input BCLK GPIO"
range -1 63
default -1
config USB_AUDIO_RX_WS_GPIO
int "TDM input WS GPIO"
range -1 63
default -1
config USB_AUDIO_RX_DIN_GPIO
int "TDM input data GPIO"
range -1 63
default -1
config USB_AUDIO_TX_I2S_PORT
int "Stereo output I2S port"
range 0 2
default 1
config USB_AUDIO_TX_SLAVE
bool "Stereo output is an I2S slave"
default n
config USB_AUDIO_TX_MCLK_GPIO
int "Stereo output MCLK GPIO (-1 unused)"
range -1 63
default -1
config USB_AUDIO_TX_BCLK_GPIO
int "Stereo output BCLK GPIO"
range -1 63
default -1
config USB_AUDIO_TX_WS_GPIO
int "Stereo output WS GPIO"
range -1 63
default -1
config USB_AUDIO_TX_DOUT_GPIO
int "Stereo output data GPIO"
range -1 63
default -1
config USB_AUDIO_USB_TASK_PRIORITY
int "TinyUSB task priority"
range 1 15
default 6
config USB_AUDIO_CAPTURE_TASK_PRIORITY
int "Capture task priority"
range 1 15
default 7
config USB_AUDIO_PLAYBACK_TASK_PRIORITY
int "Playback task priority"
range 1 15
default 7
endmenu
menu "WS2812B LED ring"
config WS2812_LED_RING_ENABLE
bool "Enable WS2812B LED ring"
default y
if WS2812_LED_RING_ENABLE
config WS2812_GPIO
int "RMT data output GPIO"
range 0 54
default 26
help
GPIO24 and GPIO25 are reserved for the planned internal
Full-Speed USB PHY D-/D+ connection, so GPIO26 is used by
default for the LED ring data signal.
config WS2812_LED_COUNT
int "Number of LEDs"
range 1 512
default 50
help
This board uses one physical chain of 50 pixels. Eight pixels
belong to group A and the remaining 42 form logical ring B.
config WS2812_LED_RING_FPS
int "Effect refresh rate (frames/s)"
range 1 100
default 100
config WS2812_LED_RING_BRIGHTNESS
int "Global brightness percent"
range 1 100
default 20
help
Limits RGB values before transmission. A conservative default
reduces the peak current of a 50-LED chain.
choice WS2812_LED_RING_DEFAULT_EFFECT
prompt "Default LED effect"
default WS2812_LED_RING_DEFAULT_BREATHING
config WS2812_LED_RING_DEFAULT_OFF
bool "Off"
config WS2812_LED_RING_DEFAULT_RAINBOW
bool "Rainbow"
config WS2812_LED_RING_DEFAULT_BREATHING
bool "Breathing"
config WS2812_LED_RING_DEFAULT_DOT
bool "Dot chase"
config WS2812_LED_RING_DEFAULT_BLINK
bool "Blink"
endchoice
config WS2812_LED_RING_TASK_PRIORITY
int "LED effect task priority"
range 1 15
default 3
config WS2812_LED_RING_TASK_STACK_SIZE
int "LED effect task stack size"
range 2048 8192
default 4096
endif
endmenu
+131
View File
@@ -0,0 +1,131 @@
# ESP32-P4 UAC 2.0 高速 USB 声卡
本目录是工程的 ESP-IDF 应用组件,功能如下:
- 原录音设备:24 kHz、16-bit PCM、8 通道,`ch0..ch7` 交错排列;
- 第二录音设备:24 kHz、16-bit PCM、单通道,数据取自原录音设备的通道 1;
- 放音:24 kHz、16-bit PCM、2 通道,左右声道交错排列;
- UAC 2.0 High-Speed,异步播放反馈端点;
- 同一 USB 复合设备还枚举一个 HID 键盘;
- 扬声器主通道静音及 -50 dB 到 0 dB 音量控制;
- 8-slot MSB TDM 录音输入和标准双声道 Philips I2S 放音输出;
- 录放音任务各使用 20 ms 环形缓冲。
## USB 端口
固件使用 ESP32-P4 的 High-Speed DWC 控制器(TinyUSB RHPORT1)和内部 UTMI
PHY。音频设备连接到板上的 USB 2.0 High-Speed 端口。
独立的 Full-Speed DWC 控制器(TinyUSB RHPORT0)不由本应用初始化,FS PHY
选择寄存器也不修改。由于本应用将 GPIO27 配置为 PCMD3180 的 I2C SCL,运行时
不能同时启用包含 GPIO27 的 FS 备用引脚映射;这不影响使用内部 UTMI PHY 的
High-Speed USB 音频接口。USB Serial/JTAG 调试接口不会被音频应用交换到另一组
FS 引脚。
## 外设 GPIO 分配
| 外设 | 控制器及工作模式 | 信号 | GPIO | ESP32-P4 方向 |
| --- | --- | --- | ---: | --- |
| PCMD3180 录音 | I2S0,TDM 主机,24 kHz、8 slots、16-bit | MCLK | 20 | 输出 |
| PCMD3180 录音 | I2S0,TDM 主机 | BCLK | 21 | 输出 |
| PCMD3180 录音 | I2S0,TDM 主机 | FSYNC/WS,50% 占空比 | 22 | 输出 |
| PCMD3180 录音 | I2S0,TDM 主机 | SDIN | 23 | 输入 |
| PCMD3180 控制 | I2C0 主机,100 kHz,7-bit 地址 `0x4C` | SCL | 27 | 双向、开漏 |
| PCMD3180 控制 | I2C0 主机,100 kHz,7-bit 地址 `0x4C` | SDA | 28 | 双向、开漏 |
| 立体声播放 | I2S1 主机,24 kHz、16-bit | MCLK | 32 | 输出 |
| 立体声播放 | I2S1 主机 | BCLK | 33 | 输出 |
| 立体声播放 | I2S1 主机 | LRCK/WS | 46 | 输出 |
| 立体声播放 | I2S1 主机 | DOUT | 47 | 输出 |
| WS2812 灯链 | RMT TX + GDMA,800 kbit/s | DIN(从物理编号 0 注入) | 26 | 输出 |
| 灯效切换按键 | 内部上拉,按下接地,40 ms 去抖 | KEY | 35 | 输入 |
| 调试控制台 | UART0,115200 bit/s | TX | 37 | 输出 |
| 调试控制台 | UART0,115200 bit/s | RX | 38 | 输入 |
| USB Audio HS | DWC High-Speed + 内部 UTMI PHY | USB D+/D- | 不占用普通 GPIO | 双向 |
PCMD3180 的 SCL 和 SDA 在软件中启用了内部弱上拉,硬件设计仍建议分别配置合适的
外部上拉电阻。当前 VS Code Monitor 使用板载 CH343 USB-UART 对应的 `COM5`,
通过 UART0 的 GPIO37/38 输出和接收控制台数据。GPIO24、GPIO25 当前未由本应用
分配;GPIO26 用于 WS2812 数据输出。
## LED 灯链驱动与逻辑分组
50 颗 WS2812 按物理编号 `0..49` 串联,数据从编号 0 的 `DIN` 注入。物理传输仍然
每帧依次发送全部 50 颗灯的数据,软件仅在帧缓冲填充阶段做以下逻辑分组:
- A 组共 8 颗:`0、1、11、17、23、29、35、41`。A 组不参与圆环动画;
- B 组共 42 颗:除 A 组以外的物理灯珠。灯效模块以连续逻辑编号 `0..41` 计算动画,
发送前再映射到对应物理编号;
- 彩虹、呼吸、跑点、闪烁和关闭模式下,A 组均明确写为黑色;A 组测试模式下,A 组
写为红色且 B 组明确写为黑色,以检查两组之间是否存在串扰;
- 所有颜色(包括 A 组测试红色)均受 `CONFIG_WS2812_LED_RING_BRIGHTNESS` 全局亮度
限制。默认值为 20%。
底层使用 ESP32-P4 RMT TX + GDMA,在 GPIO26 输出 GRB 数据,默认 100 FPS。工程要求
`CONFIG_WS2812_LED_COUNT=50`,编译期会校验该值,避免物理映射与帧长度不一致。
上电默认进入 B 组绿色呼吸模式,A 组保持关闭。
## LED 控制操作
GPIO35 按键启用内部上拉,按键另一端应接 GND。每次有效按下按以下顺序循环,松开
不会切换:
```text
B组彩虹 -> B组呼吸 -> B组跑点 -> B组闪烁 -> A组全红测试 -> 全部关闭 -> B组彩虹
```
B 组跑点模式仅使用一组 dot,颜色为 `RGB(220, 15, 231)`;呼吸模式颜色为
`RGB(0, 255, 0)`;闪烁模式颜色为 `RGB(207, 166, 16)`,完整亮灭周期为 100 ms,
即 10 Hz(50 ms 亮、50 ms 灭)。上述 RGB 值在发送前仍会乘以全局亮度。
呼吸亮度系数为 `0.04 + 0.96 * (1 - |period/2 - t| / (period/2))^3`,其中 `t` 是当前
周期内的时间,默认 `period=6 s`;周期起止为 0.08,周期中点为 1。
## 窗口控制台
UART0 控制台为 115200 bit/s,直接发送单个字符即可切换,无需回车:
| 字符 | 操作 |
| --- | --- |
| `1` / `r` / `R` | B 组彩虹 |
| `2` / `p` / `P` | B 组呼吸 |
| `3` / `d` / `D` | B 组跑点 |
| `4` / `b` / `B` | B 组闪烁 |
| `5` / `a` / `A` | 仅点亮 A 组 8 颗红灯,B 组关闭(串扰测试) |
| `0` / `o` / `O` / `c` / `C` | 全部关闭 |
| `?` / `h` / `H` | 重新打印帮助 |
按键或串口切换成功后,控制台会输出当前模式。串口和按键共享同一模式状态,所以从
串口选定某模式后,再按按键会从该模式的下一档继续循环;从“全部关闭”状态按键则
进入 B 组彩虹。
## 配置与编译
工程已经包含顶层 `CMakeLists.txt`、`sdkconfig.defaults`、依赖锁文件和 VS Code
ESP-IDF 设置。先在 `ESP32-P4 USB audio` 菜单填写板级 I2S 引脚和主从模式:
```text
idf.py set-target esp32p4
idf.py menuconfig
idf.py build
```
Kconfig 中的 I2S GPIO 初始默认值为 `-1`,本工程的 `sdkconfig.defaults` 已按上表
设置板级引脚;若删除这些设置,程序会在启动阶段报告配置错误。已使用 ESP-IDF v6.0.2 和
`espressif/tinyusb` 0.19.0~3 完成编译。
## 音频硬件假设
当前采集后端匹配 PCMD3180:ESP32-P4 输出 24 kHz TDM 时钟,PCMD3180 作为
从机输出 MSB 对齐、16-bit、8 个连续时隙的数据;播放 DAC/codec 接受 Philips
I2S、24 kHz、16-bit 双声道。录音和放音使用不同 I2S 控制器,分别配置为主机。
当前 `USB_AUDIO_MIC_TEST_TONE_ENABLED=0`,8 通道录音设备使用 PCMD3180 的 TDM
数据,单通道录音设备从中提取通道 1。若将该宏改为 `1`,每个录音端点会生成
独立的 1 kHz 测试正弦波。
USB 数据方向以主机为视角:8 路录音是设备到主机(IN),2 路放音是主机到设备
(OUT)。上板后仍需验证 USB 枚举、TDM 时隙顺序、I2S 极性以及长时间时钟漂移。
串口控制台键盘测试命令(115200 baud):
- `6`:发送 `Ctrl+C`(包含按键释放报告)
- `7`:发送 `Ctrl+V`(包含按键释放报告)
- `8`:发送字符 `a`(包含按键释放报告)
+164
View File
@@ -0,0 +1,164 @@
#include "audio_backend.h"
#include <math.h>
#include <string.h>
#include "driver/i2s_std.h"
#include "driver/i2s_tdm.h"
#include "esp_check.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "sdkconfig.h"
#include "usb_audio_config.h"
static const char *TAG = "audio_hw";
static i2s_chan_handle_t s_rx_chan;
static i2s_chan_handle_t s_tx_chan;
static volatile bool s_mute;
static volatile int32_t s_gain_q15 = 32767;
static esp_err_t validate_config(void)
{
ESP_RETURN_ON_FALSE(CONFIG_USB_AUDIO_RX_I2S_PORT != CONFIG_USB_AUDIO_TX_I2S_PORT,
ESP_ERR_INVALID_ARG, TAG,
"RX and TX use different formats and must use different I2S ports");
ESP_RETURN_ON_FALSE(CONFIG_USB_AUDIO_RX_BCLK_GPIO >= 0 &&
CONFIG_USB_AUDIO_RX_WS_GPIO >= 0 &&
CONFIG_USB_AUDIO_RX_DIN_GPIO >= 0,
ESP_ERR_INVALID_ARG, TAG,
"configure RX BCLK/WS/DIN GPIOs in USB audio menu");
ESP_RETURN_ON_FALSE(CONFIG_USB_AUDIO_TX_BCLK_GPIO >= 0 &&
CONFIG_USB_AUDIO_TX_WS_GPIO >= 0 &&
CONFIG_USB_AUDIO_TX_DOUT_GPIO >= 0,
ESP_ERR_INVALID_ARG, TAG,
"configure TX BCLK/WS/DOUT GPIOs in USB audio menu");
return ESP_OK;
}
esp_err_t audio_backend_init(void)
{
ESP_RETURN_ON_ERROR(validate_config(), TAG, "invalid I2S configuration");
#if CONFIG_USB_AUDIO_RX_SLAVE
#define USB_AUDIO_RX_ROLE I2S_ROLE_SLAVE
#else
#define USB_AUDIO_RX_ROLE I2S_ROLE_MASTER
#endif
#if CONFIG_USB_AUDIO_TX_SLAVE
#define USB_AUDIO_TX_ROLE I2S_ROLE_SLAVE
#else
#define USB_AUDIO_TX_ROLE I2S_ROLE_MASTER
#endif
i2s_chan_config_t rx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(
CONFIG_USB_AUDIO_RX_I2S_PORT, USB_AUDIO_RX_ROLE);
rx_chan_cfg.dma_desc_num = 8;
rx_chan_cfg.dma_frame_num = 240;
ESP_RETURN_ON_ERROR(i2s_new_channel(&rx_chan_cfg, NULL, &s_rx_chan),
TAG, "create TDM RX channel");
i2s_tdm_config_t rx_cfg = {
.clk_cfg = I2S_TDM_CLK_DEFAULT_CONFIG(USB_AUDIO_MIC_SAMPLE_RATE_HZ),
.slot_cfg = I2S_TDM_MSB_SLOT_DEFAULT_CONFIG(
I2S_DATA_BIT_WIDTH_16BIT,
I2S_SLOT_MODE_STEREO,
I2S_TDM_SLOT0 | I2S_TDM_SLOT1 | I2S_TDM_SLOT2 | I2S_TDM_SLOT3 |
I2S_TDM_SLOT4 | I2S_TDM_SLOT5 | I2S_TDM_SLOT6 | I2S_TDM_SLOT7),
.gpio_cfg = {
.mclk = (gpio_num_t)CONFIG_USB_AUDIO_RX_MCLK_GPIO,
.bclk = (gpio_num_t)CONFIG_USB_AUDIO_RX_BCLK_GPIO,
.ws = (gpio_num_t)CONFIG_USB_AUDIO_RX_WS_GPIO,
.dout = I2S_GPIO_UNUSED,
.din = (gpio_num_t)CONFIG_USB_AUDIO_RX_DIN_GPIO,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
rx_cfg.clk_cfg.mclk_multiple = I2S_MCLK_MULTIPLE_512;
/* Keep the slot-0 MSB aligned to the active FSYNC edge, but drive WCLK
* high for half of the 8 x 16-bit frame to obtain a 50% duty cycle. */
rx_cfg.slot_cfg.ws_width =
(USB_AUDIO_MIC_CHANNELS * I2S_DATA_BIT_WIDTH_16BIT) / 2U;
rx_cfg.slot_cfg.ws_pol = true;
rx_cfg.slot_cfg.total_slot = USB_AUDIO_MIC_CHANNELS;
ESP_RETURN_ON_ERROR(i2s_channel_init_tdm_mode(s_rx_chan, &rx_cfg),
TAG, "configure TDM RX channel");
i2s_chan_config_t tx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(
CONFIG_USB_AUDIO_TX_I2S_PORT, USB_AUDIO_TX_ROLE);
tx_chan_cfg.dma_desc_num = 8;
tx_chan_cfg.dma_frame_num = 240;
tx_chan_cfg.auto_clear_after_cb = true;
ESP_RETURN_ON_ERROR(i2s_new_channel(&tx_chan_cfg, &s_tx_chan, NULL),
TAG, "create stereo TX channel");
i2s_std_config_t tx_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(USB_AUDIO_SPK_SAMPLE_RATE_HZ),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
.gpio_cfg = {
.mclk = (gpio_num_t)CONFIG_USB_AUDIO_TX_MCLK_GPIO,
.bclk = (gpio_num_t)CONFIG_USB_AUDIO_TX_BCLK_GPIO,
.ws = (gpio_num_t)CONFIG_USB_AUDIO_TX_WS_GPIO,
.dout = (gpio_num_t)CONFIG_USB_AUDIO_TX_DOUT_GPIO,
.din = I2S_GPIO_UNUSED,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
ESP_RETURN_ON_ERROR(i2s_channel_init_std_mode(s_tx_chan, &tx_cfg),
TAG, "configure stereo TX channel");
ESP_RETURN_ON_ERROR(i2s_channel_enable(s_rx_chan), TAG, "enable RX");
ESP_RETURN_ON_ERROR(i2s_channel_enable(s_tx_chan), TAG, "enable TX");
ESP_LOGI(TAG, "I2S ready: RX 8ch MSB TDM %u Hz, TX 2ch %u Hz, 16-bit",
USB_AUDIO_MIC_SAMPLE_RATE_HZ, USB_AUDIO_SPK_SAMPLE_RATE_HZ);
return ESP_OK;
}
esp_err_t audio_backend_read(void *dst, size_t bytes, size_t *bytes_read,
uint32_t timeout_ms)
{
return i2s_channel_read(s_rx_chan, dst, bytes, bytes_read,
pdMS_TO_TICKS(timeout_ms));
}
esp_err_t audio_backend_write(const void *src, size_t bytes,
size_t *bytes_written, uint32_t timeout_ms)
{
/* One USB millisecond is the largest block passed by usb_audio.c. */
int16_t scaled[USB_AUDIO_SPK_MS_BYTES / sizeof(int16_t)];
if (bytes > sizeof(scaled) || (bytes % sizeof(int16_t)) != 0U) {
return ESP_ERR_INVALID_SIZE;
}
const int16_t *in = (const int16_t *)src;
const size_t samples = bytes / sizeof(*in);
const int32_t gain = s_mute ? 0 : s_gain_q15;
for (size_t i = 0; i < samples; ++i) {
scaled[i] = (int16_t)(((int32_t)in[i] * gain) >> 15);
}
return i2s_channel_write(s_tx_chan, scaled, bytes, bytes_written,
pdMS_TO_TICKS(timeout_ms));
}
void audio_backend_set_mute(bool mute)
{
s_mute = mute;
}
void audio_backend_set_volume(int16_t volume_db256)
{
float db = (float)volume_db256 / 256.0f;
if (db > 0.0f) {
db = 0.0f;
} else if (db < -50.0f) {
db = -50.0f;
}
s_gain_q15 = (int32_t)(powf(10.0f, db / 20.0f) * 32767.0f);
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/** Initialize 8-channel capture and 2-channel playback hardware. */
esp_err_t audio_backend_init(void);
/** Read interleaved signed 16-bit PCM: ch0, ch1, ... ch7, next frame. */
esp_err_t audio_backend_read(void *dst, size_t bytes, size_t *bytes_read,
uint32_t timeout_ms);
/** Write interleaved signed 16-bit PCM: left, right, next frame. */
esp_err_t audio_backend_write(const void *src, size_t bytes,
size_t *bytes_written, uint32_t timeout_ms);
/** Apply USB speaker controls. Volume uses signed 1/256 dB units. */
void audio_backend_set_mute(bool mute);
void audio_backend_set_volume(int16_t volume_db256);
#ifdef __cplusplus
}
#endif
+7
View File
@@ -0,0 +1,7 @@
version: "0.1.0"
targets:
- esp32p4
dependencies:
idf: ">=5.3"
espressif/tinyusb: "^0.19.0~3"
+24
View File
@@ -0,0 +1,24 @@
#include "audio_backend.h"
#include "esp_check.h"
#include "esp_log.h"
#include "pcmd3180.h"
#include "usb_audio.h"
#include "sdkconfig.h"
#if CONFIG_WS2812_LED_RING_ENABLE
#include "led_ring.h"
#endif
static const char *TAG = "main";
void app_main(void)
{
/* PCMD3180 is an ASI slave, so start the ESP32-P4 TDM clocks first. */
ESP_ERROR_CHECK(audio_backend_init());
ESP_ERROR_CHECK(pcmd3180_init(NULL));
ESP_ERROR_CHECK(usb_audio_init());
#if CONFIG_WS2812_LED_RING_ENABLE
ESP_ERROR_CHECK(led_ring_start());
#endif
ESP_LOGI(TAG, "ESP32-P4 USB audio and PCMD3180 started");
}
+259
View File
@@ -0,0 +1,259 @@
#include "pcmd3180.h"
#include <stddef.h>
#include "driver/i2c_master.h"
#include "esp_check.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "pcmd3180";
#define PCMD3180_I2C_TIMEOUT_MS 100
#define PCMD3180_REG_PAGE_CFG 0x00U
#define PCMD3180_REG_SW_RESET 0x01U
#define PCMD3180_REG_SLEEP_CFG 0x02U
#define PCMD3180_REG_ASI_CFG0 0x07U
#define PCMD3180_REG_ASI_CFG1 0x08U
#define PCMD3180_REG_ASI_CFG2 0x09U
#define PCMD3180_REG_ASI_CH1 0x0BU
#define PCMD3180_REG_MST_CFG0 0x13U
#define PCMD3180_REG_MST_CFG1 0x14U
#define PCMD3180_REG_ASI_STS 0x15U
#define PCMD3180_REG_CLK_SRC 0x16U
#define PCMD3180_REG_PDMCLK_CFG 0x1FU
#define PCMD3180_REG_PDMIN_CFG 0x20U
#define PCMD3180_REG_GPO_CFG0 0x22U
#define PCMD3180_REG_GPI_CFG0 0x2BU
#define PCMD3180_REG_GPI_CFG1 0x2CU
#define PCMD3180_REG_CH1_CFG0 0x3CU
#define PCMD3180_REG_DSP_CFG0 0x6BU
#define PCMD3180_REG_DSP_CFG1 0x6CU
#define PCMD3180_REG_IN_CH_EN 0x73U
#define PCMD3180_REG_ASI_OUT_CH_EN 0x74U
#define PCMD3180_REG_PWR_CFG 0x75U
#define PCMD3180_REG_DEV_STS0 0x76U
#define PCMD3180_REG_DEV_STS1 0x77U
#define PCMD3180_SLEEP_ENZ 0x01U
#define PCMD3180_SLEEP_AREG_SELECT 0x80U
#define PCMD3180_CH_CFG_STRIDE 5U
#define PCMD3180_CHANNEL_COUNT 8U
static i2c_master_bus_handle_t s_i2c_bus;
static i2c_master_dev_handle_t s_i2c_device;
static esp_err_t raw_write(uint8_t reg, uint8_t value)
{
ESP_RETURN_ON_FALSE(s_i2c_device != NULL, ESP_ERR_INVALID_STATE, TAG,
"I2C device is not initialized");
const uint8_t data[] = {reg, value};
return i2c_master_transmit(s_i2c_device, data, sizeof(data),
PCMD3180_I2C_TIMEOUT_MS);
}
static esp_err_t raw_read(uint8_t reg, uint8_t *value)
{
ESP_RETURN_ON_FALSE(s_i2c_device != NULL, ESP_ERR_INVALID_STATE, TAG,
"I2C device is not initialized");
ESP_RETURN_ON_FALSE(value != NULL, ESP_ERR_INVALID_ARG, TAG,
"read destination is NULL");
return i2c_master_transmit_receive(s_i2c_device, &reg, sizeof(reg), value,
sizeof(*value), PCMD3180_I2C_TIMEOUT_MS);
}
static esp_err_t select_page0(void)
{
return raw_write(PCMD3180_REG_PAGE_CFG, 0x00U);
}
esp_err_t pcmd3180_write_reg(uint8_t reg, uint8_t value)
{
ESP_RETURN_ON_ERROR(select_page0(), TAG, "select register page 0");
return raw_write(reg, value);
}
esp_err_t pcmd3180_read_reg(uint8_t reg, uint8_t *value)
{
ESP_RETURN_ON_ERROR(select_page0(), TAG, "select register page 0");
return raw_read(reg, value);
}
static esp_err_t wake(bool use_internal_areg)
{
uint8_t value = PCMD3180_SLEEP_ENZ;
if (use_internal_areg) {
value |= PCMD3180_SLEEP_AREG_SELECT;
}
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_SLEEP_CFG, value), TAG,
"wake device");
vTaskDelay(pdMS_TO_TICKS(2));
return ESP_OK;
}
static esp_err_t configure_tdm(void)
{
/* TDM format, 16-bit words, normal BCLK/FSYNC polarity. */
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_ASI_CFG0, 0x00U), TAG,
"configure TDM word format");
/* TX_OFFSET=0: channel 1 MSB is aligned to the rising edge of FSYNC. */
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_ASI_CFG1, 0x00U), TAG,
"configure TDM timing");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_ASI_CFG2, 0x00U), TAG,
"configure TDM bus");
for (uint8_t slot = 0; slot < PCMD3180_CHANNEL_COUNT; ++slot) {
ESP_RETURN_ON_ERROR(raw_write((uint8_t)(PCMD3180_REG_ASI_CH1 + slot), slot),
TAG, "map channel %u to TDM slot %u",
(unsigned)(slot + 1U), (unsigned)slot);
}
return ESP_OK;
}
static esp_err_t configure_clocks(void)
{
/* Slave mode, automatic clock configuration and PLL enabled. */
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_MST_CFG0, 0x01U), TAG,
"configure clock slave mode");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_MST_CFG1, 0x48U), TAG,
"configure clock dividers");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_CLK_SRC, 0x10U), TAG,
"configure clock source");
/* 24-kHz audio uses the 1.4112/1.536-MHz PDM clock range. */
return raw_write(PCMD3180_REG_PDMCLK_CFG, 0x41U);
}
static esp_err_t configure_channels(void)
{
/* GPO1..GPO4 output the four PDM clock pairs. */
for (uint8_t output = 0; output < 4U; ++output) {
ESP_RETURN_ON_ERROR(raw_write((uint8_t)(PCMD3180_REG_GPO_CFG0 + output),
0x41U),
TAG, "configure PDM clock output %u",
(unsigned)(output + 1U));
}
/* GPI1..GPI4 map to PDM input pairs 1..4, matching the STM32 example. */
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_GPI_CFG0, 0x65U), TAG,
"map PDM inputs 1 and 2");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_GPI_CFG1, 0x47U), TAG,
"map PDM inputs 3 and 4");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_PDMIN_CFG, 0xf0U), TAG,
"configure PDM data edges");
for (uint8_t channel = 0; channel < PCMD3180_CHANNEL_COUNT; ++channel) {
const uint8_t base = (uint8_t)(PCMD3180_REG_CH1_CFG0 +
channel * PCMD3180_CH_CFG_STRIDE);
ESP_RETURN_ON_ERROR(raw_write(base, 0x40U), TAG,
"select PDM input for channel %u",
(unsigned)(channel + 1U));
ESP_RETURN_ON_ERROR(raw_write((uint8_t)(base + 2U), 0xC9U), TAG,
"set channel %u digital volume",
(unsigned)(channel + 1U));
ESP_RETURN_ON_ERROR(raw_write((uint8_t)(base + 3U), 0x80U), TAG,
"set channel %u gain calibration",
(unsigned)(channel + 1U));
ESP_RETURN_ON_ERROR(raw_write((uint8_t)(base + 4U), 0x00U), TAG,
"set channel %u phase calibration",
(unsigned)(channel + 1U));
}
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_DSP_CFG0, 0x01U), TAG,
"configure channel DSP");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_DSP_CFG1, 0x00U), TAG,
"configure volume DSP");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_IN_CH_EN, 0xFFU), TAG,
"enable all input channels");
return raw_write(PCMD3180_REG_ASI_OUT_CH_EN, 0xFFU);
}
static esp_err_t log_status(void)
{
uint8_t asi_cfg0;
uint8_t asi_cfg1;
uint8_t asi_status;
uint8_t dev_status0;
uint8_t dev_status1;
ESP_RETURN_ON_ERROR(raw_read(PCMD3180_REG_ASI_CFG0, &asi_cfg0), TAG,
"read ASI_CFG0");
ESP_RETURN_ON_ERROR(raw_read(PCMD3180_REG_ASI_CFG1, &asi_cfg1), TAG,
"read ASI_CFG1");
ESP_RETURN_ON_ERROR(raw_read(PCMD3180_REG_ASI_STS, &asi_status), TAG,
"read ASI_STS");
ESP_RETURN_ON_ERROR(raw_read(PCMD3180_REG_DEV_STS0, &dev_status0), TAG,
"read DEV_STS0");
ESP_RETURN_ON_ERROR(raw_read(PCMD3180_REG_DEV_STS1, &dev_status1), TAG,
"read DEV_STS1");
ESP_LOGI(TAG,
"ready: TDM slave 8 slots x 16-bit, ASI_CFG0=0x%02x "
"ASI_CFG1=0x%02x ASI_STS=0x%02x DEV_STS0=0x%02x DEV_STS1=0x%02x",
asi_cfg0, asi_cfg1, asi_status, dev_status0, dev_status1);
return ESP_OK;
}
esp_err_t pcmd3180_init(const pcmd3180_config_t *config)
{
const pcmd3180_config_t default_config = PCMD3180_CONFIG_DEFAULT();
const pcmd3180_config_t *cfg = config != NULL ? config : &default_config;
ESP_RETURN_ON_FALSE(s_i2c_bus == NULL && s_i2c_device == NULL,
ESP_ERR_INVALID_STATE, TAG, "already initialized");
ESP_RETURN_ON_FALSE(cfg->i2c_address <= 0x7FU &&
GPIO_IS_VALID_OUTPUT_GPIO(cfg->scl_gpio) &&
GPIO_IS_VALID_OUTPUT_GPIO(cfg->sda_gpio) &&
cfg->scl_gpio != cfg->sda_gpio &&
cfg->i2c_frequency_hz > 0U,
ESP_ERR_INVALID_ARG, TAG, "invalid I2C configuration");
const i2c_master_bus_config_t bus_config = {
.i2c_port = cfg->i2c_port,
.sda_io_num = cfg->sda_gpio,
.scl_io_num = cfg->scl_gpio,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.intr_priority = 0,
.trans_queue_depth = 0,
.flags.enable_internal_pullup = true,
};
ESP_RETURN_ON_ERROR(i2c_new_master_bus(&bus_config, &s_i2c_bus), TAG,
"create I2C master bus");
const i2c_device_config_t device_config = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = cfg->i2c_address,
.scl_speed_hz = cfg->i2c_frequency_hz,
.scl_wait_us = 0,
};
esp_err_t err = i2c_master_bus_add_device(s_i2c_bus, &device_config,
&s_i2c_device);
if (err != ESP_OK) {
i2c_del_master_bus(s_i2c_bus);
s_i2c_bus = NULL;
return err;
}
ESP_LOGI(TAG, "I2C port %d, SCL GPIO%d, SDA GPIO%d, address 0x%02x",
(int)cfg->i2c_port, (int)cfg->scl_gpio, (int)cfg->sda_gpio,
cfg->i2c_address);
ESP_RETURN_ON_ERROR(wake(cfg->use_internal_areg), TAG, "initial wake");
ESP_RETURN_ON_ERROR(select_page0(), TAG, "select register page 0");
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_SW_RESET, 0x01U), TAG,
"software reset");
vTaskDelay(pdMS_TO_TICKS(3));
ESP_RETURN_ON_ERROR(wake(cfg->use_internal_areg), TAG, "wake after reset");
ESP_RETURN_ON_ERROR(select_page0(), TAG, "select register page 0");
ESP_RETURN_ON_ERROR(configure_tdm(), TAG, "configure TDM interface");
ESP_RETURN_ON_ERROR(configure_clocks(), TAG, "configure clocks");
ESP_RETURN_ON_ERROR(configure_channels(), TAG, "configure PDM channels");
/* Same all-channel PDM/PLL power-up value used by the STM32 reference. */
ESP_RETURN_ON_ERROR(raw_write(PCMD3180_REG_PWR_CFG, 0x7CU), TAG,
"power up PDM and PLL");
vTaskDelay(pdMS_TO_TICKS(2));
return log_status();
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "driver/i2c_types.h"
#include "esp_err.h"
#include "hal/gpio_types.h"
#ifdef __cplusplus
extern "C" {
#endif
#define PCMD3180_I2C_ADDRESS_DEFAULT 0x4CU
#define PCMD3180_I2C_SCL_DEFAULT GPIO_NUM_27
#define PCMD3180_I2C_SDA_DEFAULT GPIO_NUM_28
typedef struct {
i2c_port_num_t i2c_port;
gpio_num_t scl_gpio;
gpio_num_t sda_gpio;
uint32_t i2c_frequency_hz;
uint8_t i2c_address;
bool use_internal_areg;
} pcmd3180_config_t;
#define PCMD3180_CONFIG_DEFAULT() { \
.i2c_port = I2C_NUM_0, \
.scl_gpio = PCMD3180_I2C_SCL_DEFAULT, \
.sda_gpio = PCMD3180_I2C_SDA_DEFAULT, \
.i2c_frequency_hz = 100000U, \
.i2c_address = PCMD3180_I2C_ADDRESS_DEFAULT, \
.use_internal_areg = true, \
}
/**
* @brief Initialize PCMD3180 as an 8-slot, 16-bit TDM slave.
*
* ESP32-P4 must already be driving the TDM BCLK and FSYNC clocks before this
* function is called. Passing NULL selects PCMD3180_CONFIG_DEFAULT().
*/
esp_err_t pcmd3180_init(const pcmd3180_config_t *config);
/** Read or write one page-0 register after initialization. */
esp_err_t pcmd3180_write_reg(uint8_t reg, uint8_t value);
esp_err_t pcmd3180_read_reg(uint8_t reg, uint8_t *value);
#ifdef __cplusplus
}
#endif
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "sdkconfig.h"
#include "usb_audio_descriptors.h"
/* ESP32-P4 USB 2.0 High-Speed DWC controller is TinyUSB root hub port 1. */
#define CFG_TUSB_RHPORT1_MODE (OPT_MODE_DEVICE | OPT_MODE_HIGH_SPEED)
#define CFG_TUSB_OS OPT_OS_FREERTOS
#define CFG_TUD_ENABLED 1
#define CFG_TUD_MAX_SPEED OPT_MODE_HIGH_SPEED
#define CFG_TUSB_DEBUG 0
#define CFG_TUSB_MEM_ALIGN __attribute__((aligned(4)))
#define CFG_TUD_ENDPOINT0_SIZE 64
#define CFG_TUD_CDC 0
#define CFG_TUD_MSC 0
#define CFG_TUD_HID 1
#define CFG_TUD_HID_EP_BUFSIZE 8
#define CFG_TUD_MIDI 0
#define CFG_TUD_VENDOR 0
#define CFG_TUD_AUDIO 2
#define CFG_TUD_AUDIO_ENABLE_FEEDBACK_EP 1
#define CFG_TUD_AUDIO_FUNC_1_DESC_LEN USB_AUDIO_DESC_LEN
#define CFG_TUD_AUDIO_FUNC_1_N_FORMATS 1
#define CFG_TUD_AUDIO_FUNC_1_MAX_SAMPLE_RATE USB_AUDIO_MAX_SAMPLE_RATE_HZ
#define CFG_TUD_AUDIO_FUNC_1_N_CHANNELS_TX USB_AUDIO_MIC_CHANNELS
#define CFG_TUD_AUDIO_FUNC_1_N_CHANNELS_RX USB_AUDIO_SPK_CHANNELS
#define CFG_TUD_AUDIO_FUNC_1_FORMAT_1_N_BYTES_PER_SAMPLE_TX USB_AUDIO_BYTES_PER_SAMPLE
#define CFG_TUD_AUDIO_FUNC_1_FORMAT_1_RESOLUTION_TX USB_AUDIO_SAMPLE_RESOLUTION
#define CFG_TUD_AUDIO_FUNC_1_FORMAT_1_N_BYTES_PER_SAMPLE_RX USB_AUDIO_BYTES_PER_SAMPLE
#define CFG_TUD_AUDIO_FUNC_1_FORMAT_1_RESOLUTION_RX USB_AUDIO_SAMPLE_RESOLUTION
#define CFG_TUD_AUDIO_ENABLE_EP_IN 1
#define CFG_TUD_AUDIO_ENABLE_EP_OUT 1
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX USB_AUDIO_MIC_EP_SIZE
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX USB_AUDIO_SPK_EP_SIZE
#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SW_BUF_SZ 2048
#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ (32U * USB_AUDIO_SPK_EP_SIZE)
#define CFG_TUD_AUDIO_FUNC_1_N_AS_INT 1
#define CFG_TUD_AUDIO_FUNC_1_CTRL_BUF_SZ 64
#define CFG_TUD_AUDIO_FUNC_2_DESC_LEN USB_AUDIO2_DESC_LEN
#define CFG_TUD_AUDIO_FUNC_2_EP_IN_SZ_MAX USB_AUDIO_MIC2_EP_SIZE
#define CFG_TUD_AUDIO_FUNC_2_EP_OUT_SZ_MAX 0
#define CFG_TUD_AUDIO_FUNC_2_EP_IN_SW_BUF_SZ 512
#define CFG_TUD_AUDIO_FUNC_2_EP_OUT_SW_BUF_SZ 0
#define CFG_TUD_AUDIO_FUNC_2_CTRL_BUF_SZ 64
+522
View File
@@ -0,0 +1,522 @@
#include "usb_audio.h"
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include "audio_backend.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_private/usb_phy.h"
#include "freertos/FreeRTOS.h"
#include "freertos/stream_buffer.h"
#include "freertos/task.h"
#include "tusb.h"
#include "usb_audio_config.h"
#include "usb_audio_descriptors.h"
#include "usb_hid_keyboard.h"
static const char *TAG = "usb_audio";
#define USB_AUDIO_FUNCTION_COUNT 2U
#define USB_AUDIO_PRIMARY_FUNC 0U
#define USB_AUDIO_SECONDARY_FUNC 1U
static usb_phy_handle_t s_phy;
#if !USB_AUDIO_MIC_TEST_TONE_ENABLED
static StreamBufferHandle_t s_mic_stream[USB_AUDIO_FUNCTION_COUNT];
#endif
static StreamBufferHandle_t s_spk_stream;
static volatile bool s_mic_active[USB_AUDIO_FUNCTION_COUNT];
static volatile bool s_spk_active;
static volatile uint32_t s_spk_usb_packets;
static volatile uint32_t s_spk_usb_bytes;
static volatile uint32_t s_spk_fifo_bytes;
static volatile uint32_t s_spk_empty_callbacks;
static volatile uint32_t s_spk_dropped_bytes;
static volatile uint32_t s_spk_i2s_bytes;
static volatile uint32_t s_spk_underflows;
static volatile uint32_t s_spk_i2s_errors;
static int16_t s_volume[USB_AUDIO_SPK_CHANNELS + 1U];
static uint8_t s_mute[USB_AUDIO_SPK_CHANNELS + 1U];
#if USB_AUDIO_MIC_TEST_TONE_ENABLED
#define MIC_TEST_TONE_LUT_SIZE 24U
/* One 1-kHz period at 24 kHz, amplitude 16000 (about -6.2 dBFS). */
static const int16_t s_mic_test_tone_lut[MIC_TEST_TONE_LUT_SIZE] = {
0, 4141, 8000, 11314, 13856, 15455,
16000, 15455, 13856, 11314, 8000, 4141,
0, -4141, -8000, -11314, -13856, -15455,
-16000, -15455, -13856, -11314, -8000, -4141,
};
static uint32_t s_mic_test_tone_phase[USB_AUDIO_FUNCTION_COUNT];
_Static_assert(USB_AUDIO_MIC_SAMPLE_RATE_HZ ==
USB_AUDIO_MIC_TEST_TONE_HZ * MIC_TEST_TONE_LUT_SIZE,
"1-kHz test-tone LUT requires a 24-kHz capture rate");
static void fill_mic_test_tone(uint8_t func_id, int16_t *samples,
size_t frame_count, size_t channel_count)
{
for (size_t frame = 0; frame < frame_count; ++frame) {
const int16_t positive =
s_mic_test_tone_lut[s_mic_test_tone_phase[func_id]];
for (size_t channel = 0; channel < channel_count; ++channel) {
/* Human CH1/3/5/7 are positive; CH2/4/6/8 are 180 degrees inverted. */
samples[frame * channel_count + channel] =
(channel & 1U) == 0U ? positive : (int16_t)-positive;
}
s_mic_test_tone_phase[func_id] =
(s_mic_test_tone_phase[func_id] + 1U) % MIC_TEST_TONE_LUT_SIZE;
}
}
#endif
#if !USB_AUDIO_MIC_TEST_TONE_ENABLED
static size_t mic_usb_payload_bytes(uint8_t func_id)
{
return func_id == USB_AUDIO_PRIMARY_FUNC ? USB_AUDIO_MIC_MS_BYTES
: USB_AUDIO_MIC2_MS_BYTES;
}
#endif
static void fill_mic_fifo(uint8_t func_id)
{
if (func_id >= USB_AUDIO_FUNCTION_COUNT) {
return;
}
#if USB_AUDIO_MIC_TEST_TONE_ENABLED
int16_t packet[USB_AUDIO_MIC_MS_BYTES / sizeof(int16_t)];
const size_t channel_count = func_id == USB_AUDIO_PRIMARY_FUNC
? USB_AUDIO_MIC_CHANNELS
: USB_AUDIO_MIC2_CHANNELS;
const size_t wanted = func_id == USB_AUDIO_PRIMARY_FUNC
? USB_AUDIO_MIC_MS_BYTES
: USB_AUDIO_MIC2_MS_BYTES;
fill_mic_test_tone(func_id, packet,
USB_AUDIO_MIC_SAMPLE_RATE_HZ / 1000U,
channel_count);
(void)tud_audio_n_write(func_id, packet, (uint16_t)wanted);
#else
uint8_t packet[USB_AUDIO_MIC_MS_BYTES];
const size_t wanted = mic_usb_payload_bytes(func_id);
size_t got = xStreamBufferReceive(s_mic_stream[func_id], packet, wanted, 0);
if (got < wanted) {
memset(packet + got, 0, wanted - got);
}
(void)tud_audio_n_write(func_id, packet, (uint16_t)wanted);
#endif
}
static void tinyusb_task(void *arg)
{
(void)arg;
while (true) {
tud_task();
}
}
#if !USB_AUDIO_MIC_TEST_TONE_ENABLED
static void capture_task(void *arg)
{
(void)arg;
int16_t block[USB_AUDIO_MIC_MS_BYTES / sizeof(int16_t)];
int16_t mono_ch1[USB_AUDIO_MIC2_MS_BYTES / sizeof(int16_t)];
while (true) {
size_t bytes_read = 0;
esp_err_t err = audio_backend_read(block, sizeof(block), &bytes_read, 20);
if (err != ESP_OK || bytes_read == 0) {
continue;
}
if (s_mic_active[USB_AUDIO_PRIMARY_FUNC]) {
/* Dropping newest data on overflow bounds latency at 20 ms. */
(void)xStreamBufferSend(s_mic_stream[USB_AUDIO_PRIMARY_FUNC],
block, bytes_read, 0);
}
if (s_mic_active[USB_AUDIO_SECONDARY_FUNC]) {
const size_t frames = bytes_read / USB_AUDIO_MIC_FRAME_BYTES;
for (size_t frame = 0; frame < frames; ++frame) {
/* USB channel 1 is the first sample in every TDM frame. */
mono_ch1[frame] = block[frame * USB_AUDIO_MIC_CHANNELS];
}
(void)xStreamBufferSend(s_mic_stream[USB_AUDIO_SECONDARY_FUNC],
mono_ch1,
frames * USB_AUDIO_MIC2_FRAME_BYTES, 0);
}
}
}
#endif
static void playback_task(void *arg)
{
(void)arg;
uint8_t block[USB_AUDIO_SPK_MS_BYTES];
TickType_t last_report = xTaskGetTickCount();
while (true) {
if (!s_spk_active) {
vTaskDelay(pdMS_TO_TICKS(5));
last_report = xTaskGetTickCount();
continue;
}
size_t got = xStreamBufferReceive(s_spk_stream, block, sizeof(block),
pdMS_TO_TICKS(2));
if (got < sizeof(block)) {
/* Output silence when the USB playback stream has insufficient data. */
memset(block + got, 0, sizeof(block) - got);
++s_spk_underflows;
}
size_t written = 0;
esp_err_t err = audio_backend_write(block, sizeof(block), &written, 20);
if (err != ESP_OK || written != sizeof(block)) {
++s_spk_i2s_errors;
}
s_spk_i2s_bytes += written;
TickType_t now = xTaskGetTickCount();
if ((now - last_report) >= pdMS_TO_TICKS(1000)) {
ESP_LOGI(TAG, "playback USB packets=%" PRIu32
" reported_bytes=%" PRIu32
" FIFO_bytes=%" PRIu32
" empty_cb=%" PRIu32 " dropped=%" PRIu32
", I2S bytes=%" PRIu32 " underflows=%" PRIu32
" errors=%" PRIu32,
s_spk_usb_packets, s_spk_usb_bytes,
s_spk_fifo_bytes, s_spk_empty_callbacks,
s_spk_dropped_bytes, s_spk_i2s_bytes,
s_spk_underflows, s_spk_i2s_errors);
last_report = now;
}
}
}
void tud_mount_cb(void)
{
ESP_LOGI(TAG, "USB mounted");
}
void tud_umount_cb(void)
{
memset((void *)s_mic_active, 0, sizeof(s_mic_active));
s_spk_active = false;
ESP_LOGI(TAG, "USB unmounted");
}
void tud_suspend_cb(bool remote_wakeup_en)
{
(void)remote_wakeup_en;
memset((void *)s_mic_active, 0, sizeof(s_mic_active));
s_spk_active = false;
}
void tud_resume_cb(void)
{
ESP_LOGI(TAG, "USB resumed");
}
void tud_audio_feedback_params_cb(uint8_t func_id, uint8_t alt_itf,
audio_feedback_params_t *params)
{
if (func_id != USB_AUDIO_PRIMARY_FUNC) {
params->method = AUDIO_FEEDBACK_METHOD_DISABLED;
return;
}
(void)alt_itf;
params->method = AUDIO_FEEDBACK_METHOD_FIFO_COUNT;
params->sample_freq = USB_AUDIO_SPK_SAMPLE_RATE_HZ;
}
bool tud_audio_set_itf_cb(uint8_t rhport,
const tusb_control_request_t *request)
{
(void)rhport;
uint8_t itf = tu_u16_low(tu_le16toh(request->wIndex));
uint8_t alt = tu_u16_low(tu_le16toh(request->wValue));
if (itf == ITF_NUM_AUDIO_STREAMING_SPK) {
xStreamBufferReset(s_spk_stream);
s_spk_active = alt != 0;
if (s_spk_active) {
s_spk_usb_packets = 0;
s_spk_usb_bytes = 0;
s_spk_fifo_bytes = 0;
s_spk_empty_callbacks = 0;
s_spk_dropped_bytes = 0;
s_spk_i2s_bytes = 0;
s_spk_underflows = 0;
s_spk_i2s_errors = 0;
}
ESP_LOGI(TAG, "speaker stream %s", s_spk_active ? "started" : "stopped");
} else if (itf == ITF_NUM_AUDIO_STREAMING_MIC ||
itf == ITF_NUM_AUDIO2_STREAMING_MIC) {
const uint8_t func_id = itf == ITF_NUM_AUDIO_STREAMING_MIC
? USB_AUDIO_PRIMARY_FUNC
: USB_AUDIO_SECONDARY_FUNC;
#if USB_AUDIO_MIC_TEST_TONE_ENABLED
s_mic_test_tone_phase[func_id] = 0;
#else
xStreamBufferReset(s_mic_stream[func_id]);
#endif
s_mic_active[func_id] = alt != 0;
if (s_mic_active[func_id]) {
fill_mic_fifo(func_id);
}
ESP_LOGI(TAG, "microphone %u stream %s", (unsigned)(func_id + 1U),
s_mic_active[func_id] ? "started" : "stopped");
}
return true;
}
bool tud_audio_set_itf_close_ep_cb(uint8_t rhport,
const tusb_control_request_t *request)
{
return tud_audio_set_itf_cb(rhport, request);
}
bool tud_audio_rx_done_isr(uint8_t rhport, uint16_t n_bytes_received,
uint8_t func_id, uint8_t ep_out,
uint8_t cur_alt_setting)
{
(void)rhport;
(void)ep_out;
(void)cur_alt_setting;
++s_spk_usb_packets;
s_spk_usb_bytes += n_bytes_received;
uint8_t packet[USB_AUDIO_SPK_EP_SIZE];
size_t callback_fifo_bytes = 0;
while (tud_audio_n_available(func_id) != 0U) {
uint16_t count = tud_audio_n_read(func_id, packet, sizeof(packet));
if (count == 0U) {
break;
}
size_t queued = xStreamBufferSend(s_spk_stream, packet, count, 0);
callback_fifo_bytes += count;
s_spk_fifo_bytes += count;
s_spk_dropped_bytes += count - queued;
}
if (callback_fifo_bytes == 0U) {
++s_spk_empty_callbacks;
}
return true;
}
bool tud_audio_tx_done_isr(uint8_t rhport, uint16_t n_bytes_sent,
uint8_t func_id, uint8_t ep_in,
uint8_t cur_alt_setting)
{
(void)rhport;
(void)n_bytes_sent;
(void)ep_in;
(void)cur_alt_setting;
if (func_id < USB_AUDIO_FUNCTION_COUNT && s_mic_active[func_id]) {
fill_mic_fifo(func_id);
}
return true;
}
static bool clock_get_request(uint8_t rhport,
const audio_control_request_t *request,
uint32_t sample_rate_hz)
{
if (request->bControlSelector == AUDIO_CS_CTRL_SAM_FREQ) {
if (request->bRequest == AUDIO_CS_REQ_CUR) {
audio_control_cur_4_t current = {
.bCur = (int32_t)tu_htole32(sample_rate_hz),
};
return tud_audio_buffer_and_schedule_control_xfer(
rhport, (const tusb_control_request_t *)request,
&current, sizeof(current));
}
if (request->bRequest == AUDIO_CS_REQ_RANGE) {
audio_control_range_4_n_t(1) range = {
.wNumSubRanges = tu_htole16(1),
.subrange[0] = {
.bMin = sample_rate_hz,
.bMax = sample_rate_hz,
.bRes = 0,
},
};
return tud_audio_buffer_and_schedule_control_xfer(
rhport, (const tusb_control_request_t *)request,
&range, sizeof(range));
}
} else if (request->bControlSelector == AUDIO_CS_CTRL_CLK_VALID &&
request->bRequest == AUDIO_CS_REQ_CUR) {
audio_control_cur_1_t valid = {.bCur = 1};
return tud_audio_buffer_and_schedule_control_xfer(
rhport, (const tusb_control_request_t *)request,
&valid, sizeof(valid));
}
return false;
}
static bool feature_get_request(uint8_t rhport,
const audio_control_request_t *request)
{
uint8_t channel = request->bChannelNumber;
if (channel > USB_AUDIO_SPK_CHANNELS) {
return false;
}
if (request->bControlSelector == AUDIO_FU_CTRL_MUTE &&
request->bRequest == AUDIO_CS_REQ_CUR) {
audio_control_cur_1_t current = {.bCur = s_mute[channel]};
return tud_audio_buffer_and_schedule_control_xfer(
rhport, (const tusb_control_request_t *)request,
&current, sizeof(current));
}
if (request->bControlSelector == AUDIO_FU_CTRL_VOLUME) {
if (request->bRequest == AUDIO_CS_REQ_CUR) {
audio_control_cur_2_t current = {
.bCur = (int16_t)tu_htole16((uint16_t)s_volume[channel]),
};
return tud_audio_buffer_and_schedule_control_xfer(
rhport, (const tusb_control_request_t *)request,
&current, sizeof(current));
}
if (request->bRequest == AUDIO_CS_REQ_RANGE) {
audio_control_range_2_n_t(1) range = {
.wNumSubRanges = tu_htole16(1),
.subrange[0] = {
.bMin = (int16_t)tu_htole16((uint16_t)(-50 * 256)),
.bMax = 0,
.bRes = (int16_t)tu_htole16(256),
},
};
return tud_audio_buffer_and_schedule_control_xfer(
rhport, (const tusb_control_request_t *)request,
&range, sizeof(range));
}
}
return false;
}
bool tud_audio_get_req_entity_cb(uint8_t rhport,
const tusb_control_request_t *request)
{
const audio_control_request_t *audio_req =
(const audio_control_request_t *)request;
if (audio_req->bEntityID == UAC2_ENTITY_SPK_CLOCK) {
return clock_get_request(rhport, audio_req,
USB_AUDIO_SPK_SAMPLE_RATE_HZ);
}
if (audio_req->bEntityID == UAC2_ENTITY_MIC_CLOCK) {
return clock_get_request(rhport, audio_req,
USB_AUDIO_MIC_SAMPLE_RATE_HZ);
}
if (audio_req->bEntityID == UAC2_ENTITY_MIC2_CLOCK) {
return clock_get_request(rhport, audio_req,
USB_AUDIO_MIC_SAMPLE_RATE_HZ);
}
if (audio_req->bEntityID == UAC2_ENTITY_SPK_FEATURE_UNIT) {
return feature_get_request(rhport, audio_req);
}
return false;
}
bool tud_audio_set_req_entity_cb(uint8_t rhport,
const tusb_control_request_t *request,
uint8_t *buffer)
{
(void)rhport;
const audio_control_request_t *audio_req =
(const audio_control_request_t *)request;
if ((audio_req->bEntityID == UAC2_ENTITY_SPK_CLOCK ||
audio_req->bEntityID == UAC2_ENTITY_MIC_CLOCK ||
audio_req->bEntityID == UAC2_ENTITY_MIC2_CLOCK) &&
audio_req->bControlSelector == AUDIO_CS_CTRL_SAM_FREQ &&
audio_req->bRequest == AUDIO_CS_REQ_CUR &&
request->wLength == sizeof(audio_control_cur_4_t)) {
uint32_t rate = tu_le32toh(
(uint32_t)((audio_control_cur_4_t *)buffer)->bCur);
uint32_t expected = audio_req->bEntityID == UAC2_ENTITY_SPK_CLOCK
? USB_AUDIO_SPK_SAMPLE_RATE_HZ
: USB_AUDIO_MIC_SAMPLE_RATE_HZ;
return rate == expected;
}
if (audio_req->bEntityID != UAC2_ENTITY_SPK_FEATURE_UNIT ||
audio_req->bChannelNumber > USB_AUDIO_SPK_CHANNELS ||
audio_req->bRequest != AUDIO_CS_REQ_CUR) {
return false;
}
uint8_t channel = audio_req->bChannelNumber;
if (audio_req->bControlSelector == AUDIO_FU_CTRL_MUTE &&
request->wLength == sizeof(audio_control_cur_1_t)) {
s_mute[channel] = ((audio_control_cur_1_t *)buffer)->bCur;
if (channel == 0U) {
audio_backend_set_mute(s_mute[channel] != 0U);
}
return true;
}
if (audio_req->bControlSelector == AUDIO_FU_CTRL_VOLUME &&
request->wLength == sizeof(audio_control_cur_2_t)) {
s_volume[channel] = (int16_t)tu_le16toh(
(uint16_t)((audio_control_cur_2_t *)buffer)->bCur);
if (channel == 0U) {
audio_backend_set_volume(s_volume[channel]);
}
return true;
}
return false;
}
esp_err_t usb_audio_init(void)
{
#if !USB_AUDIO_MIC_TEST_TONE_ENABLED
for (uint8_t func_id = 0; func_id < USB_AUDIO_FUNCTION_COUNT; ++func_id) {
const size_t ring_bytes = func_id == USB_AUDIO_PRIMARY_FUNC
? USB_AUDIO_MIC_RING_BYTES
: USB_AUDIO_MIC2_RING_BYTES;
const size_t frame_bytes = func_id == USB_AUDIO_PRIMARY_FUNC
? USB_AUDIO_MIC_FRAME_BYTES
: USB_AUDIO_MIC2_FRAME_BYTES;
s_mic_stream[func_id] = xStreamBufferCreate(
ring_bytes, frame_bytes);
ESP_RETURN_ON_FALSE(s_mic_stream[func_id] != NULL, ESP_ERR_NO_MEM,
TAG, "allocate capture stream buffer %u",
(unsigned)(func_id + 1U));
}
#endif
s_spk_stream = xStreamBufferCreate(USB_AUDIO_SPK_RING_BYTES,
USB_AUDIO_SPK_FRAME_BYTES);
ESP_RETURN_ON_FALSE(s_spk_stream != NULL, ESP_ERR_NO_MEM,
TAG, "allocate playback stream buffer");
usb_phy_config_t phy_config = {
.controller = USB_PHY_CTRL_OTG,
.otg_mode = USB_OTG_MODE_DEVICE,
.target = USB_PHY_TARGET_UTMI,
.otg_speed = USB_PHY_SPEED_HIGH,
};
ESP_RETURN_ON_ERROR(usb_new_phy(&phy_config, &s_phy), TAG,
"initialize USB HS UTMI PHY");
ESP_RETURN_ON_FALSE(tusb_init(), ESP_FAIL, TAG,
"initialize TinyUSB device stack");
BaseType_t ok = xTaskCreatePinnedToCore(tinyusb_task, "tinyusb", 4096,
NULL, CONFIG_USB_AUDIO_USB_TASK_PRIORITY, NULL, tskNO_AFFINITY);
ESP_RETURN_ON_FALSE(ok == pdPASS, ESP_FAIL, TAG, "create TinyUSB task");
ESP_RETURN_ON_ERROR(usb_hid_keyboard_init(), TAG,
"initialize HID keyboard");
#if !USB_AUDIO_MIC_TEST_TONE_ENABLED
ok = xTaskCreatePinnedToCore(capture_task, "audio_capture", 4096,
NULL, CONFIG_USB_AUDIO_CAPTURE_TASK_PRIORITY, NULL, tskNO_AFFINITY);
ESP_RETURN_ON_FALSE(ok == pdPASS, ESP_FAIL, TAG, "create capture task");
#endif
ok = xTaskCreatePinnedToCore(playback_task, "audio_playback", 4096,
NULL, CONFIG_USB_AUDIO_PLAYBACK_TASK_PRIORITY, NULL, tskNO_AFFINITY);
ESP_RETURN_ON_FALSE(ok == pdPASS, ESP_FAIL, TAG, "create playback task");
ESP_LOGI(TAG,
"USB composite ready: capture 1=8ch, capture 2=mono CH1, %u Hz; "
"playback 2ch %u Hz/16-bit; HID keyboard",
USB_AUDIO_MIC_SAMPLE_RATE_HZ, USB_AUDIO_SPK_SAMPLE_RATE_HZ);
#if USB_AUDIO_MIC_TEST_TONE_ENABLED
ESP_LOGI(TAG,
"capture source: 1-kHz LUT tone, CH1/3/5/7 positive, "
"CH2/4/6/8 inverted; TDM RX bypassed");
#else
ESP_LOGI(TAG, "capture source: I2S0 TDM RX");
#endif
return ESP_OK;
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t usb_audio_init(void);
#ifdef __cplusplus
}
#endif
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#define USB_AUDIO_MIC_SAMPLE_RATE_HZ 24000U
#define USB_AUDIO_SPK_SAMPLE_RATE_HZ 24000U
#define USB_AUDIO_MAX_SAMPLE_RATE_HZ USB_AUDIO_SPK_SAMPLE_RATE_HZ
#define USB_AUDIO_BYTES_PER_SAMPLE 2U
#define USB_AUDIO_SAMPLE_RESOLUTION 16U
#define USB_AUDIO_MIC_CHANNELS 8U
#define USB_AUDIO_MIC2_CHANNELS 1U
#define USB_AUDIO_SPK_CHANNELS 2U
/* Diagnostic capture source: 1 uses the internal LUT tone, 0 uses TDM RX. */
#define USB_AUDIO_MIC_TEST_TONE_ENABLED 0
#define USB_AUDIO_MIC_TEST_TONE_HZ 1000U
#define USB_AUDIO_MIC_FRAME_BYTES \
(USB_AUDIO_MIC_CHANNELS * USB_AUDIO_BYTES_PER_SAMPLE)
#define USB_AUDIO_SPK_FRAME_BYTES \
(USB_AUDIO_SPK_CHANNELS * USB_AUDIO_BYTES_PER_SAMPLE)
#define USB_AUDIO_MIC2_FRAME_BYTES \
(USB_AUDIO_MIC2_CHANNELS * USB_AUDIO_BYTES_PER_SAMPLE)
/* One millisecond of interleaved PCM data. */
#define USB_AUDIO_MIC_MS_BYTES \
((USB_AUDIO_MIC_SAMPLE_RATE_HZ / 1000U) * USB_AUDIO_MIC_FRAME_BYTES)
#define USB_AUDIO_SPK_MS_BYTES \
((USB_AUDIO_SPK_SAMPLE_RATE_HZ / 1000U) * USB_AUDIO_SPK_FRAME_BYTES)
#define USB_AUDIO_MIC2_MS_BYTES \
((USB_AUDIO_MIC_SAMPLE_RATE_HZ / 1000U) * USB_AUDIO_MIC2_FRAME_BYTES)
/* One millisecond of PCM plus one frame for clock correction. */
#define USB_AUDIO_MIC_EP_SIZE \
((((USB_AUDIO_MIC_SAMPLE_RATE_HZ + 999U) / 1000U) + 1U) * USB_AUDIO_MIC_FRAME_BYTES)
#define USB_AUDIO_SPK_EP_SIZE \
((((USB_AUDIO_SPK_SAMPLE_RATE_HZ + 999U) / 1000U) + 1U) * USB_AUDIO_SPK_FRAME_BYTES)
#define USB_AUDIO_MIC2_EP_SIZE \
((((USB_AUDIO_MIC_SAMPLE_RATE_HZ + 999U) / 1000U) + 1U) * USB_AUDIO_MIC2_FRAME_BYTES)
#define USB_AUDIO_MIC_RING_BYTES (USB_AUDIO_MIC_MS_BYTES * 20U)
#define USB_AUDIO_SPK_RING_BYTES (USB_AUDIO_SPK_MS_BYTES * 20U)
#define USB_AUDIO_MIC2_RING_BYTES (USB_AUDIO_MIC2_MS_BYTES * 20U)
_Static_assert(USB_AUDIO_MIC_SAMPLE_RATE_HZ % 1000U == 0U &&
USB_AUDIO_SPK_SAMPLE_RATE_HZ % 1000U == 0U,
"Audio tasks require an integer number of samples per ms");
_Static_assert(USB_AUDIO_MIC_EP_SIZE <= 1023U,
"Microphone stream exceeds the isochronous endpoint limit");
_Static_assert(USB_AUDIO_MIC2_EP_SIZE <= 1023U,
"Second microphone stream exceeds the endpoint limit");
+145
View File
@@ -0,0 +1,145 @@
#pragma once
#include "usb_audio_config.h"
enum {
ITF_NUM_AUDIO_CONTROL = 0,
ITF_NUM_AUDIO_STREAMING_SPK,
ITF_NUM_AUDIO_STREAMING_MIC,
ITF_NUM_AUDIO2_CONTROL,
ITF_NUM_AUDIO2_STREAMING_MIC,
ITF_NUM_HID_KEYBOARD,
ITF_NUM_TOTAL,
};
#define UAC2_ENTITY_SPK_INPUT_TERMINAL 0x01
#define UAC2_ENTITY_SPK_FEATURE_UNIT 0x02
#define UAC2_ENTITY_SPK_OUTPUT_TERMINAL 0x03
#define UAC2_ENTITY_SPK_CLOCK 0x04
#define UAC2_ENTITY_MIC_INPUT_TERMINAL 0x11
#define UAC2_ENTITY_MIC_OUTPUT_TERMINAL 0x13
#define UAC2_ENTITY_MIC_CLOCK 0x14
#define UAC2_ENTITY_MIC2_INPUT_TERMINAL 0x21
#define UAC2_ENTITY_MIC2_OUTPUT_TERMINAL 0x23
#define UAC2_ENTITY_MIC2_CLOCK 0x24
#define USB_AUDIO_SPK_FU_LEN \
(6U + (USB_AUDIO_SPK_CHANNELS + 1U) * 4U)
#define USB_AUDIO_CS_AC_TOTAL_LEN \
((2U * TUD_AUDIO_DESC_CLK_SRC_LEN) + USB_AUDIO_SPK_FU_LEN + \
(2U * TUD_AUDIO_DESC_INPUT_TERM_LEN) + \
(2U * TUD_AUDIO_DESC_OUTPUT_TERM_LEN))
#define USB_AUDIO_DESC_LEN \
(TUD_AUDIO_DESC_IAD_LEN + TUD_AUDIO_DESC_STD_AC_LEN + \
TUD_AUDIO_DESC_CS_AC_LEN + USB_AUDIO_CS_AC_TOTAL_LEN + \
/* Speaker alt 0 + alt 1 + AS + format + data EP + CS EP + feedback EP */ \
(2U * TUD_AUDIO_DESC_STD_AS_INT_LEN) + TUD_AUDIO_DESC_CS_AS_INT_LEN + \
TUD_AUDIO_DESC_TYPE_I_FORMAT_LEN + TUD_AUDIO_DESC_STD_AS_ISO_EP_LEN + \
TUD_AUDIO_DESC_CS_AS_ISO_EP_LEN + TUD_AUDIO_DESC_STD_AS_ISO_FB_EP_LEN + \
/* Microphone alt 0 + alt 1 + AS + format + data EP + CS EP */ \
(2U * TUD_AUDIO_DESC_STD_AS_INT_LEN) + TUD_AUDIO_DESC_CS_AS_INT_LEN + \
TUD_AUDIO_DESC_TYPE_I_FORMAT_LEN + TUD_AUDIO_DESC_STD_AS_ISO_EP_LEN + \
TUD_AUDIO_DESC_CS_AS_ISO_EP_LEN)
/* A second, independent mono UAC2 capture function shown as another recording
* device by the host. Its samples are sourced from channel 1 of capture 1. */
#define USB_AUDIO2_CS_AC_TOTAL_LEN \
(TUD_AUDIO_DESC_CLK_SRC_LEN + TUD_AUDIO_DESC_INPUT_TERM_LEN + \
TUD_AUDIO_DESC_OUTPUT_TERM_LEN)
#define USB_AUDIO2_DESC_LEN \
(TUD_AUDIO_DESC_IAD_LEN + TUD_AUDIO_DESC_STD_AC_LEN + \
TUD_AUDIO_DESC_CS_AC_LEN + USB_AUDIO2_CS_AC_TOTAL_LEN + \
(2U * TUD_AUDIO_DESC_STD_AS_INT_LEN) + TUD_AUDIO_DESC_CS_AS_INT_LEN + \
TUD_AUDIO_DESC_TYPE_I_FORMAT_LEN + TUD_AUDIO_DESC_STD_AS_ISO_EP_LEN + \
TUD_AUDIO_DESC_CS_AS_ISO_EP_LEN)
#define USB_AUDIO_CTRL_RW \
U32_TO_U8S_LE((AUDIO_CTRL_RW << AUDIO_FEATURE_UNIT_CTRL_MUTE_POS) | \
(AUDIO_CTRL_RW << AUDIO_FEATURE_UNIT_CTRL_VOLUME_POS))
#define USB_AUDIO_SPK_FEATURE_DESCRIPTOR \
USB_AUDIO_SPK_FU_LEN, TUSB_DESC_CS_INTERFACE, \
AUDIO_CS_AC_INTERFACE_FEATURE_UNIT, UAC2_ENTITY_SPK_FEATURE_UNIT, \
UAC2_ENTITY_SPK_INPUT_TERMINAL, \
/* Master control is implemented; per-channel controls are not exposed. */ \
USB_AUDIO_CTRL_RW, 0x00, 0x00, 0x00, 0x00, \
0x00, 0x00, 0x00, 0x00, 0x00
#define USB_AUDIO_UAC2_DESCRIPTOR(_ep_out, _ep_in, _ep_fb, _out_size, _in_size, \
_out_interval, _in_interval, _fb_interval) \
TUD_AUDIO_DESC_IAD(ITF_NUM_AUDIO_CONTROL, 3, 0), \
TUD_AUDIO_DESC_STD_AC(ITF_NUM_AUDIO_CONTROL, 0, 4), \
TUD_AUDIO_DESC_CS_AC(0x0200, AUDIO_FUNC_HEADSET, USB_AUDIO_CS_AC_TOTAL_LEN, 0), \
TUD_AUDIO_DESC_CLK_SRC(UAC2_ENTITY_SPK_CLOCK, 3, 7, 0, 0), \
TUD_AUDIO_DESC_CLK_SRC(UAC2_ENTITY_MIC_CLOCK, 3, 7, 0, 0), \
TUD_AUDIO_DESC_INPUT_TERM(UAC2_ENTITY_SPK_INPUT_TERMINAL, \
AUDIO_TERM_TYPE_USB_STREAMING, 0, UAC2_ENTITY_SPK_CLOCK, \
USB_AUDIO_SPK_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0, \
(AUDIO_CTRL_R << AUDIO_IN_TERM_CTRL_CONNECTOR_POS), 0), \
USB_AUDIO_SPK_FEATURE_DESCRIPTOR, \
TUD_AUDIO_DESC_OUTPUT_TERM(UAC2_ENTITY_SPK_OUTPUT_TERMINAL, \
AUDIO_TERM_TYPE_OUT_GENERIC_SPEAKER, 0, UAC2_ENTITY_SPK_FEATURE_UNIT, \
UAC2_ENTITY_SPK_CLOCK, 0, 0), \
TUD_AUDIO_DESC_INPUT_TERM(UAC2_ENTITY_MIC_INPUT_TERMINAL, \
AUDIO_TERM_TYPE_IN_GENERIC_MIC, UAC2_ENTITY_MIC_OUTPUT_TERMINAL, \
UAC2_ENTITY_MIC_CLOCK, USB_AUDIO_MIC_CHANNELS, \
AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0, \
(AUDIO_CTRL_R << AUDIO_IN_TERM_CTRL_CONNECTOR_POS), 0), \
TUD_AUDIO_DESC_OUTPUT_TERM(UAC2_ENTITY_MIC_OUTPUT_TERMINAL, \
AUDIO_TERM_TYPE_USB_STREAMING, 0, UAC2_ENTITY_MIC_INPUT_TERMINAL, \
UAC2_ENTITY_MIC_CLOCK, 0, 0), \
TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_SPK, 0, 0, 5), \
TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_SPK, 1, 2, 5), \
TUD_AUDIO_DESC_CS_AS_INT(UAC2_ENTITY_SPK_INPUT_TERMINAL, AUDIO_CTRL_NONE, \
AUDIO_FORMAT_TYPE_I, AUDIO_DATA_FORMAT_TYPE_I_PCM, \
USB_AUDIO_SPK_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0), \
TUD_AUDIO_DESC_TYPE_I_FORMAT(USB_AUDIO_BYTES_PER_SAMPLE, USB_AUDIO_SAMPLE_RESOLUTION), \
TUD_AUDIO_DESC_STD_AS_ISO_EP(_ep_out, \
(TUSB_XFER_ISOCHRONOUS | TUSB_ISO_EP_ATT_ASYNCHRONOUS | TUSB_ISO_EP_ATT_DATA), \
_out_size, _out_interval), \
TUD_AUDIO_DESC_CS_AS_ISO_EP(AUDIO_CS_AS_ISO_DATA_EP_ATT_NON_MAX_PACKETS_OK, \
AUDIO_CTRL_NONE, AUDIO_CS_AS_ISO_DATA_EP_LOCK_DELAY_UNIT_MILLISEC, 1), \
TUD_AUDIO_DESC_STD_AS_ISO_FB_EP(_ep_fb, 4, _fb_interval), \
TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_MIC, 0, 0, 6), \
TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_MIC, 1, 1, 6), \
TUD_AUDIO_DESC_CS_AS_INT(UAC2_ENTITY_MIC_OUTPUT_TERMINAL, AUDIO_CTRL_NONE, \
AUDIO_FORMAT_TYPE_I, AUDIO_DATA_FORMAT_TYPE_I_PCM, \
USB_AUDIO_MIC_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0), \
TUD_AUDIO_DESC_TYPE_I_FORMAT(USB_AUDIO_BYTES_PER_SAMPLE, USB_AUDIO_SAMPLE_RESOLUTION), \
TUD_AUDIO_DESC_STD_AS_ISO_EP(_ep_in, \
(TUSB_XFER_ISOCHRONOUS | TUSB_ISO_EP_ATT_ASYNCHRONOUS | TUSB_ISO_EP_ATT_DATA), \
_in_size, _in_interval), \
TUD_AUDIO_DESC_CS_AS_ISO_EP(AUDIO_CS_AS_ISO_DATA_EP_ATT_NON_MAX_PACKETS_OK, \
AUDIO_CTRL_NONE, AUDIO_CS_AS_ISO_DATA_EP_LOCK_DELAY_UNIT_UNDEFINED, 0)
#define USB_AUDIO2_UAC2_CAPTURE_DESCRIPTOR(_ep_in, _in_size, _in_interval) \
TUD_AUDIO_DESC_IAD(ITF_NUM_AUDIO2_CONTROL, 2, 7), \
TUD_AUDIO_DESC_STD_AC(ITF_NUM_AUDIO2_CONTROL, 0, 7), \
TUD_AUDIO_DESC_CS_AC(0x0200, AUDIO_FUNC_MICROPHONE, \
USB_AUDIO2_CS_AC_TOTAL_LEN, 0), \
TUD_AUDIO_DESC_CLK_SRC(UAC2_ENTITY_MIC2_CLOCK, 3, 7, 0, 0), \
TUD_AUDIO_DESC_INPUT_TERM(UAC2_ENTITY_MIC2_INPUT_TERMINAL, \
AUDIO_TERM_TYPE_IN_GENERIC_MIC, UAC2_ENTITY_MIC2_OUTPUT_TERMINAL, \
UAC2_ENTITY_MIC2_CLOCK, USB_AUDIO_MIC2_CHANNELS, \
AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0, \
(AUDIO_CTRL_R << AUDIO_IN_TERM_CTRL_CONNECTOR_POS), 0), \
TUD_AUDIO_DESC_OUTPUT_TERM(UAC2_ENTITY_MIC2_OUTPUT_TERMINAL, \
AUDIO_TERM_TYPE_USB_STREAMING, 0, UAC2_ENTITY_MIC2_INPUT_TERMINAL, \
UAC2_ENTITY_MIC2_CLOCK, 0, 0), \
TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO2_STREAMING_MIC, 0, 0, 8), \
TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO2_STREAMING_MIC, 1, 1, 8), \
TUD_AUDIO_DESC_CS_AS_INT(UAC2_ENTITY_MIC2_OUTPUT_TERMINAL, AUDIO_CTRL_NONE, \
AUDIO_FORMAT_TYPE_I, AUDIO_DATA_FORMAT_TYPE_I_PCM, \
USB_AUDIO_MIC2_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0), \
TUD_AUDIO_DESC_TYPE_I_FORMAT(USB_AUDIO_BYTES_PER_SAMPLE, \
USB_AUDIO_SAMPLE_RESOLUTION), \
TUD_AUDIO_DESC_STD_AS_ISO_EP(_ep_in, \
(TUSB_XFER_ISOCHRONOUS | TUSB_ISO_EP_ATT_ASYNCHRONOUS | \
TUSB_ISO_EP_ATT_DATA), \
_in_size, _in_interval), \
TUD_AUDIO_DESC_CS_AS_ISO_EP(AUDIO_CS_AS_ISO_DATA_EP_ATT_NON_MAX_PACKETS_OK, \
AUDIO_CTRL_NONE, AUDIO_CS_AS_ISO_DATA_EP_LOCK_DELAY_UNIT_UNDEFINED, 0)
+156
View File
@@ -0,0 +1,156 @@
#include <string.h>
#include "tusb.h"
#include "usb_audio_descriptors.h"
#define USB_VID 0x303A
#define USB_PID 0x8001
#define EPNUM_AUDIO_OUT 0x01
#define EPNUM_AUDIO_FB 0x81
#define EPNUM_AUDIO_IN 0x82
#define EPNUM_AUDIO2_IN 0x83
#define EPNUM_HID_IN 0x84
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + USB_AUDIO_DESC_LEN + \
USB_AUDIO2_DESC_LEN + TUD_HID_DESC_LEN)
static const uint8_t s_hid_report_descriptor[] = {
TUD_HID_REPORT_DESC_KEYBOARD()
};
static const tusb_desc_device_t s_device_descriptor = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
.bDeviceClass = TUSB_CLASS_MISC,
.bDeviceSubClass = MISC_SUBCLASS_COMMON,
.bDeviceProtocol = MISC_PROTOCOL_IAD,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = USB_VID,
.idProduct = USB_PID,
.bcdDevice = 0x0101,
.iManufacturer = 1,
.iProduct = 2,
.iSerialNumber = 3,
.bNumConfigurations = 1,
};
static const uint8_t s_fs_configuration[] = {
TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100),
USB_AUDIO_UAC2_DESCRIPTOR(EPNUM_AUDIO_OUT, EPNUM_AUDIO_IN, EPNUM_AUDIO_FB,
USB_AUDIO_SPK_EP_SIZE,
USB_AUDIO_MIC_EP_SIZE, 1, 1, 1),
USB_AUDIO2_UAC2_CAPTURE_DESCRIPTOR(EPNUM_AUDIO2_IN,
USB_AUDIO_MIC2_EP_SIZE, 1),
TUD_HID_DESCRIPTOR(ITF_NUM_HID_KEYBOARD, 9, HID_ITF_PROTOCOL_KEYBOARD,
sizeof(s_hid_report_descriptor), EPNUM_HID_IN,
CFG_TUD_HID_EP_BUFSIZE, 10),
};
TU_VERIFY_STATIC(sizeof(s_fs_configuration) == CONFIG_TOTAL_LEN,
"incorrect FS configuration descriptor size");
/*
* At High Speed, poll speaker OUT every microframe. Keep capture and the
* explicit feedback endpoint at 1 ms (bInterval 4). This matches TinyUSB's
* HS asynchronous-speaker scheduling and prevents the host OUT pipe from
* remaining idle after alt setting 1 is selected.
*/
static const uint8_t s_hs_configuration[] = {
TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100),
USB_AUDIO_UAC2_DESCRIPTOR(EPNUM_AUDIO_OUT, EPNUM_AUDIO_IN, EPNUM_AUDIO_FB,
USB_AUDIO_SPK_EP_SIZE,
USB_AUDIO_MIC_EP_SIZE, 1, 4, 4),
USB_AUDIO2_UAC2_CAPTURE_DESCRIPTOR(EPNUM_AUDIO2_IN,
USB_AUDIO_MIC2_EP_SIZE, 4),
TUD_HID_DESCRIPTOR(ITF_NUM_HID_KEYBOARD, 9, HID_ITF_PROTOCOL_KEYBOARD,
sizeof(s_hid_report_descriptor), EPNUM_HID_IN,
CFG_TUD_HID_EP_BUFSIZE, 7),
};
TU_VERIFY_STATIC(sizeof(s_hs_configuration) == CONFIG_TOTAL_LEN,
"incorrect HS configuration descriptor size");
static const tusb_desc_device_qualifier_t s_device_qualifier = {
.bLength = sizeof(tusb_desc_device_qualifier_t),
.bDescriptorType = TUSB_DESC_DEVICE_QUALIFIER,
.bcdUSB = 0x0200,
.bDeviceClass = TUSB_CLASS_MISC,
.bDeviceSubClass = MISC_SUBCLASS_COMMON,
.bDeviceProtocol = MISC_PROTOCOL_IAD,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.bNumConfigurations = 1,
.bReserved = 0,
};
static uint8_t s_other_speed_configuration[CONFIG_TOTAL_LEN];
uint8_t const *tud_descriptor_device_cb(void)
{
return (const uint8_t *)&s_device_descriptor;
}
uint8_t const *tud_descriptor_configuration_cb(uint8_t index)
{
(void)index;
return tud_speed_get() == TUSB_SPEED_HIGH ? s_hs_configuration
: s_fs_configuration;
}
uint8_t const *tud_descriptor_device_qualifier_cb(void)
{
return (const uint8_t *)&s_device_qualifier;
}
uint8_t const *tud_descriptor_other_speed_configuration_cb(uint8_t index)
{
(void)index;
const uint8_t *configuration = tud_speed_get() == TUSB_SPEED_HIGH
? s_fs_configuration
: s_hs_configuration;
memcpy(s_other_speed_configuration, configuration, CONFIG_TOTAL_LEN);
s_other_speed_configuration[1] = TUSB_DESC_OTHER_SPEED_CONFIG;
return s_other_speed_configuration;
}
uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance)
{
(void)instance;
return s_hid_report_descriptor;
}
static const char *const s_strings[] = {
(const char[]){0x09, 0x04},
"Espressif",
"ESP32-P4 Audio + Keyboard",
"ESP32P4-UAC2-001",
"USB Audio",
"Stereo Playback",
"8 Channel Capture",
"USB Audio Capture 2",
"Mono Capture (CH1)",
"USB HID Keyboard",
};
static uint16_t s_string_descriptor[32];
uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
{
(void)langid;
size_t count;
if (index == 0) {
memcpy(&s_string_descriptor[1], s_strings[0], 2);
count = 1;
} else {
if (index >= TU_ARRAY_SIZE(s_strings)) {
return NULL;
}
count = strlen(s_strings[index]);
if (count > TU_ARRAY_SIZE(s_string_descriptor) - 1U) {
count = TU_ARRAY_SIZE(s_string_descriptor) - 1U;
}
for (size_t i = 0; i < count; ++i) {
s_string_descriptor[i + 1U] = (uint8_t)s_strings[index][i];
}
}
s_string_descriptor[0] = (uint16_t)((TUSB_DESC_STRING << 8) |
(2U * count + 2U));
return s_string_descriptor;
}
+151
View File
@@ -0,0 +1,151 @@
#include "usb_hid_keyboard.h"
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include "esp_check.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "tusb.h"
#define HID_QUEUE_LENGTH 8U
#define HID_TASK_STACK_SIZE 3072U
#define HID_READY_TIMEOUT_MS 2000U
#define HID_READY_POLL_MS 2U
static const char *TAG = "usb_keyboard";
static QueueHandle_t s_action_queue;
static TaskHandle_t s_hid_task;
static const char *action_name(usb_hid_keyboard_action_t action)
{
switch (action) {
case USB_HID_KEYBOARD_CTRL_C:
return "Ctrl+C";
case USB_HID_KEYBOARD_CTRL_V:
return "Ctrl+V";
case USB_HID_KEYBOARD_A:
return "a";
default:
return "unknown";
}
}
static bool wait_until_ready(uint32_t timeout_ms)
{
const TickType_t timeout = pdMS_TO_TICKS(timeout_ms);
const TickType_t started = xTaskGetTickCount();
do {
if (tud_hid_ready()) {
return true;
}
vTaskDelay(pdMS_TO_TICKS(HID_READY_POLL_MS));
} while ((xTaskGetTickCount() - started) < timeout);
return false;
}
static void keyboard_task(void *arg)
{
(void)arg;
while (true) {
usb_hid_keyboard_action_t action;
if (xQueueReceive(s_action_queue, &action, portMAX_DELAY) != pdTRUE) {
continue;
}
uint8_t modifier = 0;
uint8_t keycodes[6] = {0};
switch (action) {
case USB_HID_KEYBOARD_CTRL_C:
modifier = KEYBOARD_MODIFIER_LEFTCTRL;
keycodes[0] = HID_KEY_C;
break;
case USB_HID_KEYBOARD_CTRL_V:
modifier = KEYBOARD_MODIFIER_LEFTCTRL;
keycodes[0] = HID_KEY_V;
break;
case USB_HID_KEYBOARD_A:
keycodes[0] = HID_KEY_A;
break;
default:
continue;
}
if (!wait_until_ready(HID_READY_TIMEOUT_MS) ||
!tud_hid_keyboard_report(0, modifier, keycodes)) {
ESP_LOGW(TAG, "%s not sent: HID host is not ready",
action_name(action));
continue;
}
/* Do not leave a modifier or key held: wait for the press transfer to
* finish, then submit an all-zero keyboard release report. */
if (!wait_until_ready(HID_READY_TIMEOUT_MS) ||
!tud_hid_keyboard_report(0, 0, NULL)) {
ESP_LOGW(TAG, "%s release report was not sent",
action_name(action));
continue;
}
ESP_LOGI(TAG, "sent %s", action_name(action));
}
}
esp_err_t usb_hid_keyboard_init(void)
{
ESP_RETURN_ON_FALSE(s_action_queue == NULL && s_hid_task == NULL,
ESP_ERR_INVALID_STATE, TAG,
"HID keyboard already initialized");
s_action_queue = xQueueCreate(HID_QUEUE_LENGTH,
sizeof(usb_hid_keyboard_action_t));
ESP_RETURN_ON_FALSE(s_action_queue != NULL, ESP_ERR_NO_MEM, TAG,
"allocate HID action queue");
BaseType_t created = xTaskCreatePinnedToCore(
keyboard_task, "usb_keyboard", HID_TASK_STACK_SIZE, NULL,
CONFIG_USB_AUDIO_USB_TASK_PRIORITY - 1, &s_hid_task,
tskNO_AFFINITY);
if (created != pdPASS) {
vQueueDelete(s_action_queue);
s_action_queue = NULL;
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
esp_err_t usb_hid_keyboard_send(usb_hid_keyboard_action_t action)
{
ESP_RETURN_ON_FALSE(action >= USB_HID_KEYBOARD_CTRL_C &&
action <= USB_HID_KEYBOARD_A,
ESP_ERR_INVALID_ARG, TAG,
"invalid keyboard action");
ESP_RETURN_ON_FALSE(s_action_queue != NULL, ESP_ERR_INVALID_STATE, TAG,
"HID keyboard is not initialized");
return xQueueSend(s_action_queue, &action, 0) == pdTRUE
? ESP_OK
: ESP_ERR_TIMEOUT;
}
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type,
uint8_t *buffer, uint16_t reqlen)
{
(void)instance;
(void)report_id;
(void)report_type;
const uint16_t report_len = reqlen < 8U ? reqlen : 8U;
memset(buffer, 0, report_len);
return report_len;
}
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type,
const uint8_t *buffer, uint16_t bufsize)
{
(void)instance;
(void)report_id;
(void)report_type;
(void)buffer;
(void)bufsize;
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "esp_err.h"
typedef enum {
USB_HID_KEYBOARD_CTRL_C = 0,
USB_HID_KEYBOARD_CTRL_V,
USB_HID_KEYBOARD_A,
} usb_hid_keyboard_action_t;
/** Start the HID keyboard report worker. TinyUSB must already be initialized. */
esp_err_t usb_hid_keyboard_init(void);
/** Queue one complete key press/release sequence without blocking the caller. */
esp_err_t usb_hid_keyboard_send(usb_hid_keyboard_action_t action);
+41
View File
@@ -0,0 +1,41 @@
# ESP32-P4 WS2812B 灯环
本目录由 `WS2812B_LEDring` 示例迁移而来,保留彩虹、呼吸、跑点、闪烁和关闭模式,并增加 A 组红色测试模式。底层不再使用 STM32 的 TIM3+DMA,而是使用 ESP32-P4 的 RMT TX 硬件单元;ESP32-P4 支持 RMT DMA,因此驱动创建通道时启用 GDMA。灯效计算运行在独立低优先级 FreeRTOS 任务中,不用软件延时翻转 GPIO。
## 默认配置
- 数据输出:GPIO26。GPIO24/25 留给计划中的 Full-Speed USB D-/D+。
- 灯珠数量:物理串联 50 颗,颜色线序:GRB。A 组物理编号为 0、1、11、17、23、29、35、41,其余 42 颗组成 B 组逻辑圆环。
- 刷新率:100 FPS。
- 全局亮度:20%。
- 启动效果:B 组绿色呼吸,A 组关闭。
- 跑点:一组 dot,RGB(220, 15, 231);呼吸:RGB(0, 255, 0);闪烁:RGB(207, 166, 16),10 Hz。
- 呼吸亮度:`0.08 + 0.92 * (1 - |period/2 - t| / (period/2))^3`,默认周期 6 s,最低亮度系数为 0.08。
- RMT 分辨率:20 MHz;WS2812B 数据速率:800 kbit/s;帧尾由 RMT 生成 80 us 低电平复位码。
运行 `idf.py menuconfig` 后在 `WS2812B LED ring` 菜单中可以修改 GPIO、灯珠数量、刷新率、默认效果、亮度和任务参数,也可以整体关闭该功能。
## 接口
应用启动时由 `main.c` 调用:
```c
ESP_ERROR_CHECK(led_ring_start());
```
运行期间可从其他 FreeRTOS 任务切换效果:
```c
ESP_ERROR_CHECK(led_ring_set_mode(LED_RING_MODE_BREATHING));
```
可选模式为 `LED_RING_MODE_OFF`、`LED_RING_MODE_RAINBOW`、`LED_RING_MODE_BREATHING`、`LED_RING_MODE_DOT`、`LED_RING_MODE_BLINK` 和 `LED_RING_MODE_A_GROUP_TEST`。最后一种模式仅将 A 组显示为红色并关闭 B 组。
`ws2812.h` 还提供单像素、整环填充和 RGB 帧发送接口,适合绕过内置灯效直接显示状态。
## 接线与供电
- GPIO26 接灯环 `DIN`,ESP32-P4 与灯环电源必须共地。
- WS2812B 通常由 5 V 供电。线较长或 3.3 V 数据电平不稳定时,应增加 3.3 V 到 5 V 的高速逻辑电平转换器,并在数据线靠近灯环处串联合适的小电阻。
- 50 颗灯珠全白时电流可能达到数安培,必须使用独立且容量足够的 5 V 电源。20% 软件亮度只降低正常显示负载,不能代替供电限流、保险和电源去耦。
- 若通过 menuconfig 改 GPIO,需同时避开 TDM、I2S、I2C 以及 USB 所使用的引脚。
+389
View File
@@ -0,0 +1,389 @@
#include "led_ring.h"
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include "driver/gpio.h"
#include "driver/uart.h"
#include "driver/uart_vfs.h"
#include "esp_check.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "sdkconfig.h"
#include "ws2812.h"
#include "usb_hid_keyboard.h"
#define WS2812_TX_TIMEOUT_MS 100U
#define LED_RING_BUTTON_GPIO GPIO_NUM_35
#define LED_RING_BUTTON_POLL_MS 10U
#define LED_RING_BUTTON_DEBOUNCE_MS 40U
#define LED_RING_INPUT_TASK_STACK 3072U
#define LED_RING_GROUP_A_COUNT 8U
_Static_assert(LED_RING_PHYSICAL_LED_COUNT == 50,
"the A/B LED mapping requires CONFIG_WS2812_LED_COUNT=50");
_Static_assert(LED_RING_EFFECT_LED_COUNT + LED_RING_GROUP_A_COUNT ==
LED_RING_PHYSICAL_LED_COUNT,
"logical LED groups must cover the physical chain");
static const char *TAG = "led_ring";
static const uint8_t s_group_a_indices[LED_RING_GROUP_A_COUNT] = {
0U, 1U, 11U, 17U, 23U, 29U, 35U, 41U,
};
static TaskHandle_t s_led_task;
static TaskHandle_t s_input_task;
static volatile led_ring_mode_t s_current_mode;
static ws2812_color_t s_frame[LED_RING_PHYSICAL_LED_COUNT];
static led_ring_mode_t default_mode(void)
{
#if CONFIG_WS2812_LED_RING_DEFAULT_OFF
return LED_RING_MODE_OFF;
#elif CONFIG_WS2812_LED_RING_DEFAULT_BREATHING
return LED_RING_MODE_BREATHING;
#elif CONFIG_WS2812_LED_RING_DEFAULT_DOT
return LED_RING_MODE_DOT;
#elif CONFIG_WS2812_LED_RING_DEFAULT_BLINK
return LED_RING_MODE_BLINK;
#else
return LED_RING_MODE_RAINBOW;
#endif
}
static const char *mode_name(led_ring_mode_t mode)
{
switch (mode) {
case LED_RING_MODE_OFF:
return "off";
case LED_RING_MODE_RAINBOW:
return "B rainbow";
case LED_RING_MODE_BREATHING:
return "B breathing";
case LED_RING_MODE_DOT:
return "B dot chase";
case LED_RING_MODE_BLINK:
return "B blink";
case LED_RING_MODE_A_GROUP_TEST:
return "A red test";
default:
return "unknown";
}
}
static bool is_group_a(size_t physical_index)
{
switch (physical_index) {
case 0U:
case 1U:
case 11U:
case 17U:
case 23U:
case 29U:
case 35U:
case 41U:
return true;
default:
return false;
}
}
static void apply_effect_mode(led_ring_mode_t mode)
{
const led_ring_control_t control = {
.command = LED_RING_CMD_SET_MODE,
.mode = mode,
};
led_ring_control(&control);
}
static uint8_t configured_brightness(void)
{
return (uint8_t)((CONFIG_WS2812_LED_RING_BRIGHTNESS * 255U + 50U) /
100U);
}
static void build_physical_frame(led_ring_mode_t mode)
{
memset(s_frame, 0, sizeof(s_frame));
if (mode == LED_RING_MODE_A_GROUP_TEST) {
for (size_t index = 0; index < LED_RING_GROUP_A_COUNT; ++index) {
s_frame[s_group_a_indices[index]].r = 255U;
}
return;
}
led_ring_control(NULL);
size_t logical_index = 0U;
for (size_t physical_index = 0U;
physical_index < LED_RING_PHYSICAL_LED_COUNT; ++physical_index) {
if (is_group_a(physical_index)) {
continue;
}
s_frame[physical_index] = (ws2812_color_t) {
.r = led_ring_rgb[logical_index].r,
.g = led_ring_rgb[logical_index].g,
.b = led_ring_rgb[logical_index].b,
};
++logical_index;
}
}
static void led_effect_task(void *arg)
{
(void)arg;
TickType_t last_wake = xTaskGetTickCount();
const TickType_t frame_period =
pdMS_TO_TICKS(LED_RING_FRAME_PERIOD_MS) > 0
? pdMS_TO_TICKS(LED_RING_FRAME_PERIOD_MS)
: 1;
const uint8_t brightness = configured_brightness();
led_ring_mode_t mode = default_mode();
s_current_mode = mode;
apply_effect_mode(mode);
while (true) {
uint32_t requested_mode;
if (xTaskNotifyWait(0, UINT32_MAX, &requested_mode, 0) == pdTRUE &&
requested_mode <= LED_RING_MODE_A_GROUP_TEST) {
mode = (led_ring_mode_t)requested_mode;
if (mode != LED_RING_MODE_A_GROUP_TEST) {
apply_effect_mode(mode);
}
s_current_mode = mode;
}
build_physical_frame(mode);
esp_err_t err = ws2812_show(s_frame, LED_RING_PHYSICAL_LED_COUNT,
brightness, WS2812_TX_TIMEOUT_MS);
if (err != ESP_OK) {
ESP_LOGE(TAG, "RMT frame failed: %s", esp_err_to_name(err));
}
xTaskDelayUntil(&last_wake, frame_period);
}
}
static void print_console_help(void)
{
ESP_LOGI(TAG, "LED commands: 1/r=rainbow, 2/p=breathing, 3/d=dot");
ESP_LOGI(TAG, "LED commands: 4/b=blink, 5/a=A-group red test, 0/o/c=off, ?/h=help");
ESP_LOGI(TAG, "Keyboard tests: 6=Ctrl+C, 7=Ctrl+V, 8=character a");
}
static void send_keyboard_test(usb_hid_keyboard_action_t action,
const char *name)
{
esp_err_t err = usb_hid_keyboard_send(action);
if (err == ESP_OK) {
ESP_LOGI(TAG, "keyboard test queued: %s", name);
} else {
ESP_LOGE(TAG, "keyboard test %s failed: %s", name,
esp_err_to_name(err));
}
}
static void set_mode_from_input(led_ring_mode_t mode, const char *source)
{
esp_err_t err = led_ring_set_mode(mode);
if (err == ESP_OK) {
ESP_LOGI(TAG, "%s selected: %s", source, mode_name(mode));
} else {
ESP_LOGE(TAG, "%s mode change failed: %s", source,
esp_err_to_name(err));
}
}
static void handle_console_command(uint8_t command)
{
switch (command) {
case '1':
case 'r':
case 'R':
set_mode_from_input(LED_RING_MODE_RAINBOW, "console");
break;
case '2':
case 'p':
case 'P':
set_mode_from_input(LED_RING_MODE_BREATHING, "console");
break;
case '3':
case 'd':
case 'D':
set_mode_from_input(LED_RING_MODE_DOT, "console");
break;
case '4':
case 'b':
case 'B':
set_mode_from_input(LED_RING_MODE_BLINK, "console");
break;
case '5':
case 'a':
case 'A':
set_mode_from_input(LED_RING_MODE_A_GROUP_TEST, "console");
break;
case '6':
send_keyboard_test(USB_HID_KEYBOARD_CTRL_C, "Ctrl+C");
break;
case '7':
send_keyboard_test(USB_HID_KEYBOARD_CTRL_V, "Ctrl+V");
break;
case '8':
send_keyboard_test(USB_HID_KEYBOARD_A, "a");
break;
case '0':
case 'o':
case 'O':
case 'c':
case 'C':
set_mode_from_input(LED_RING_MODE_OFF, "console");
break;
case '?':
case 'h':
case 'H':
print_console_help();
break;
case '\r':
case '\n':
case ' ':
case '\t':
break;
default:
ESP_LOGW(TAG, "unknown LED command '%c'; send ? for help", command);
break;
}
}
static led_ring_mode_t next_button_mode(led_ring_mode_t mode)
{
if (mode >= LED_RING_MODE_RAINBOW && mode < LED_RING_MODE_A_GROUP_TEST) {
return (led_ring_mode_t)(mode + 1);
}
if (mode == LED_RING_MODE_A_GROUP_TEST) {
return LED_RING_MODE_OFF;
}
return LED_RING_MODE_RAINBOW;
}
static void led_input_task(void *arg)
{
(void)arg;
int sampled_level = gpio_get_level(LED_RING_BUTTON_GPIO);
int stable_level = sampled_level;
TickType_t level_changed_at = xTaskGetTickCount();
const TickType_t debounce_ticks =
pdMS_TO_TICKS(LED_RING_BUTTON_DEBOUNCE_MS);
print_console_help();
while (true) {
uint8_t command;
while (uart_read_bytes(CONFIG_ESP_CONSOLE_UART_NUM, &command, 1U, 0) ==
1) {
handle_console_command(command);
}
const TickType_t now = xTaskGetTickCount();
const int level = gpio_get_level(LED_RING_BUTTON_GPIO);
if (level != sampled_level) {
sampled_level = level;
level_changed_at = now;
} else if (level != stable_level &&
now - level_changed_at >= debounce_ticks) {
stable_level = level;
if (stable_level == 0) {
set_mode_from_input(next_button_mode(led_ring_get_mode()),
"GPIO35 button");
}
}
vTaskDelay(pdMS_TO_TICKS(LED_RING_BUTTON_POLL_MS));
}
}
static esp_err_t init_inputs(void)
{
const gpio_config_t button_config = {
.pin_bit_mask = 1ULL << LED_RING_BUTTON_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
ESP_RETURN_ON_ERROR(gpio_config(&button_config), TAG,
"configure GPIO35 button");
if (!uart_is_driver_installed(CONFIG_ESP_CONSOLE_UART_NUM)) {
ESP_RETURN_ON_ERROR(
uart_driver_install(CONFIG_ESP_CONSOLE_UART_NUM, 256, 0, 0, NULL, 0),
TAG, "install console UART driver");
}
uart_vfs_dev_use_driver(CONFIG_ESP_CONSOLE_UART_NUM);
return ESP_OK;
}
esp_err_t led_ring_start(void)
{
ESP_RETURN_ON_FALSE(!s_led_task && !s_input_task, ESP_ERR_INVALID_STATE,
TAG, "LED tasks already started");
esp_err_t err = ws2812_init();
ESP_RETURN_ON_ERROR(err, TAG, "initialize RMT WS2812 driver");
err = ws2812_fill(0, 0, 0);
if (err == ESP_OK) {
err = ws2812_refresh(WS2812_TX_TIMEOUT_MS);
}
if (err == ESP_OK) {
err = init_inputs();
}
if (err != ESP_OK) {
(void)ws2812_deinit();
ESP_LOGE(TAG, "initialize LED ring: %s", esp_err_to_name(err));
return err;
}
BaseType_t created = xTaskCreatePinnedToCore(
led_effect_task, "led_ring", CONFIG_WS2812_LED_RING_TASK_STACK_SIZE,
NULL, CONFIG_WS2812_LED_RING_TASK_PRIORITY, &s_led_task,
tskNO_AFFINITY);
if (created != pdPASS) {
s_led_task = NULL;
(void)ws2812_deinit();
return ESP_ERR_NO_MEM;
}
created = xTaskCreatePinnedToCore(
led_input_task, "led_input", LED_RING_INPUT_TASK_STACK, NULL,
CONFIG_WS2812_LED_RING_TASK_PRIORITY, &s_input_task, tskNO_AFFINITY);
if (created != pdPASS) {
s_input_task = NULL;
vTaskDelete(s_led_task);
s_led_task = NULL;
(void)ws2812_deinit();
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG,
"started: 50 physical LEDs (A=8, B=42), %d FPS, brightness %d%%, mode %s",
CONFIG_WS2812_LED_RING_FPS,
CONFIG_WS2812_LED_RING_BRIGHTNESS, mode_name(default_mode()));
return ESP_OK;
}
esp_err_t led_ring_set_mode(led_ring_mode_t mode)
{
ESP_RETURN_ON_FALSE(mode >= LED_RING_MODE_OFF &&
mode <= LED_RING_MODE_A_GROUP_TEST,
ESP_ERR_INVALID_ARG, TAG, "invalid effect mode");
ESP_RETURN_ON_FALSE(s_led_task, ESP_ERR_INVALID_STATE, TAG,
"LED task not started");
if (xTaskNotify(s_led_task, (uint32_t)mode, eSetValueWithOverwrite) !=
pdPASS) {
return ESP_FAIL;
}
s_current_mode = mode;
return ESP_OK;
}
led_ring_mode_t led_ring_get_mode(void)
{
return s_current_mode;
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include "esp_err.h"
#include "led_ring_effects.h"
#ifdef __cplusplus
extern "C" {
#endif
/** Initialize the RMT driver and start the periodic LED effect task. */
esp_err_t led_ring_start(void);
/** Request an effect mode change from any FreeRTOS task. */
esp_err_t led_ring_set_mode(led_ring_mode_t mode);
/** Return the mode most recently accepted by led_ring_set_mode(). */
led_ring_mode_t led_ring_get_mode(void);
#ifdef __cplusplus
}
#endif
+276
View File
@@ -0,0 +1,276 @@
#include "led_ring_effects.h"
#include <math.h>
#include <stddef.h>
#define LED_RING_BLINK_CYCLE_S 0.1f
static const led_ring_rgb_t DOT_COLOR = {220U, 15U, 231U};
static const led_ring_rgb_t BREATHING_COLOR = {0U, 255U, 0U};
static const led_ring_rgb_t BLINK_COLOR = {207U, 166U, 16U};
typedef struct {
led_ring_mode_t mode;
float time_s;
led_ring_global_params_t global;
led_ring_dot_params_t dots[LED_RING_MAX_DOTS];
} led_ring_state_t;
led_ring_rgb_t led_ring_rgb[LED_RING_EFFECT_LED_COUNT];
static led_ring_state_t s_state;
static uint8_t s_state_ready;
static float clamp_float(float value, float minimum, float maximum)
{
if (value < minimum) {
return minimum;
}
if (value > maximum) {
return maximum;
}
return value;
}
static float wrap_float(float value, float period)
{
float wrapped = fmodf(value, period);
return wrapped < 0.0f ? wrapped + period : wrapped;
}
static uint8_t clamp_rgb_float(float value)
{
if (value <= 0.0f) {
return 0U;
}
if (value >= 255.0f) {
return 255U;
}
return (uint8_t)(value + 0.5f);
}
static float sanitize_cycle(float cycle_s)
{
return cycle_s > 0.001f ? cycle_s : 0.001f;
}
static led_ring_rgb_t hsv_to_rgb255(float hue, float saturation, float value)
{
const float h = wrap_float(hue, 1.0f);
const float s = clamp_float(saturation, 0.0f, 1.0f);
const float v = clamp_float(value, 0.0f, 1.0f);
const float scaled = h * 6.0f;
const int sector = (int)floorf(scaled);
const float fraction = scaled - (float)sector;
const float p = v * (1.0f - s);
const float q = v * (1.0f - fraction * s);
const float t = v * (1.0f - (1.0f - fraction) * s);
float red = 0.0f;
float green = 0.0f;
float blue = 0.0f;
switch (sector % 6) {
case 0:
red = v;
green = t;
blue = p;
break;
case 1:
red = q;
green = v;
blue = p;
break;
case 2:
red = p;
green = v;
blue = t;
break;
case 3:
red = p;
green = q;
blue = v;
break;
case 4:
red = t;
green = p;
blue = v;
break;
default:
red = v;
green = p;
blue = q;
break;
}
return (led_ring_rgb_t) {
.r = clamp_rgb_float(red * 255.0f),
.g = clamp_rgb_float(green * 255.0f),
.b = clamp_rgb_float(blue * 255.0f),
};
}
static led_ring_rgb_t rgb_off(void)
{
return (led_ring_rgb_t) {0U, 0U, 0U};
}
static led_ring_rgb_t scale_rgb(led_ring_rgb_t color, float brightness)
{
return (led_ring_rgb_t) {
.r = clamp_rgb_float((float)color.r * brightness),
.g = clamp_rgb_float((float)color.g * brightness),
.b = clamp_rgb_float((float)color.b * brightness),
};
}
static void reset_state(void)
{
s_state.mode = LED_RING_MODE_RAINBOW;
s_state.time_s = 0.0f;
s_state.global.color_hue_deg = 30.0f;
s_state.global.cycle_s = 6.0f;
for (uint8_t index = 0U; index < LED_RING_MAX_DOTS; ++index) {
s_state.dots[index] = (led_ring_dot_params_t) {
.enabled = index == 0U,
.color_hue_deg = 30.0f,
.cycle_s = 3.0f,
.phase_deg = (float)index * 72.0f,
.clockwise = 1U,
.tail_mode = 1U,
};
}
}
static void ensure_state_ready(void)
{
if (!s_state_ready) {
reset_state();
s_state_ready = 1U;
}
}
static led_ring_rgb_t rainbow_chase_pixel(uint16_t index)
{
float hue = (float)index / (float)LED_RING_EFFECT_LED_COUNT;
hue += s_state.time_s / sanitize_cycle(s_state.global.cycle_s);
return hsv_to_rgb255(hue, 1.0f, 0.85f);
}
static led_ring_rgb_t breathing_pixel(void)
{
const float cycle_s = sanitize_cycle(s_state.global.cycle_s);
const float t = wrap_float(s_state.time_s, cycle_s);
const float half_cycle_s = cycle_s / 2.0f;
const float brightness = 0.04f + 0.96f *
powf(1.0f - fabsf(half_cycle_s - t) / half_cycle_s, 3.0f);
return scale_rgb(BREATHING_COLOR, brightness);
}
static led_ring_rgb_t blink_pixel(void)
{
const float phase = wrap_float(s_state.time_s, LED_RING_BLINK_CYCLE_S) /
LED_RING_BLINK_CYCLE_S;
return phase >= 0.5f ? rgb_off() : BLINK_COLOR;
}
static led_ring_rgb_t dot_pixel(uint16_t led_index)
{
const led_ring_dot_params_t *dot = &s_state.dots[0];
if (!dot->enabled) {
return rgb_off();
}
float trail_length = (float)LED_RING_EFFECT_LED_COUNT * 0.72f;
if (trail_length > 13.0f) {
trail_length = 13.0f;
}
const float direction = dot->clockwise ? 1.0f : -1.0f;
float head = dot->phase_deg / 360.0f +
direction * s_state.time_s / sanitize_cycle(dot->cycle_s);
head = wrap_float(head * (float)LED_RING_EFFECT_LED_COUNT,
(float)LED_RING_EFFECT_LED_COUNT);
float distance;
if (dot->tail_mode) {
distance = dot->clockwise
? wrap_float(head - (float)led_index,
(float)LED_RING_EFFECT_LED_COUNT)
: wrap_float((float)led_index - head,
(float)LED_RING_EFFECT_LED_COUNT);
} else {
distance = fabsf((float)led_index - head);
if (distance > (float)LED_RING_EFFECT_LED_COUNT - distance) {
distance = (float)LED_RING_EFFECT_LED_COUNT - distance;
}
}
if (distance <= trail_length) {
const float brightness =
powf(1.0f - distance / trail_length, 2.3f);
return scale_rgb(DOT_COLOR, brightness);
}
return rgb_off();
}
static void render_frame(void)
{
for (uint16_t index = 0U; index < LED_RING_EFFECT_LED_COUNT; ++index) {
switch (s_state.mode) {
case LED_RING_MODE_RAINBOW:
led_ring_rgb[index] = rainbow_chase_pixel(index);
break;
case LED_RING_MODE_BREATHING:
led_ring_rgb[index] = breathing_pixel();
break;
case LED_RING_MODE_DOT:
led_ring_rgb[index] = dot_pixel(index);
break;
case LED_RING_MODE_BLINK:
led_ring_rgb[index] = blink_pixel();
break;
case LED_RING_MODE_OFF:
case LED_RING_MODE_A_GROUP_TEST:
default:
led_ring_rgb[index] = rgb_off();
break;
}
}
}
void led_ring_control(const led_ring_control_t *control)
{
ensure_state_ready();
if (!control || control->command == LED_RING_CMD_REFRESH) {
render_frame();
s_state.time_s += LED_RING_FRAME_DT_S;
return;
}
switch (control->command) {
case LED_RING_CMD_SET_MODE:
s_state.mode = control->mode;
s_state.time_s = 0.0f;
break;
case LED_RING_CMD_SET_GLOBAL_PARAMS:
s_state.global.color_hue_deg = control->global.color_hue_deg;
s_state.global.cycle_s = sanitize_cycle(control->global.cycle_s);
break;
case LED_RING_CMD_SET_DOT_PARAMS:
if (control->dot_index < LED_RING_MAX_DOTS) {
s_state.dots[control->dot_index] = control->dot;
s_state.dots[control->dot_index].cycle_s =
sanitize_cycle(control->dot.cycle_s);
}
break;
case LED_RING_CMD_RESET_STATE:
reset_state();
break;
case LED_RING_CMD_REFRESH:
default:
break;
}
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <stdint.h>
#include "sdkconfig.h"
#ifdef __cplusplus
extern "C" {
#endif
#define LED_RING_PHYSICAL_LED_COUNT CONFIG_WS2812_LED_COUNT
#define LED_RING_EFFECT_LED_COUNT 42U
#define LED_RING_FPS CONFIG_WS2812_LED_RING_FPS
#define LED_RING_MAX_DOTS 5U
#define LED_RING_FRAME_DT_S (1.0f / (float)LED_RING_FPS)
#define LED_RING_FRAME_PERIOD_MS ((1000U + LED_RING_FPS - 1U) / LED_RING_FPS)
typedef struct {
uint8_t r;
uint8_t g;
uint8_t b;
} led_ring_rgb_t;
typedef enum {
LED_RING_MODE_OFF = 0,
LED_RING_MODE_RAINBOW,
LED_RING_MODE_BREATHING,
LED_RING_MODE_DOT,
LED_RING_MODE_BLINK,
LED_RING_MODE_A_GROUP_TEST,
} led_ring_mode_t;
typedef enum {
LED_RING_CMD_REFRESH = 0,
LED_RING_CMD_SET_MODE,
LED_RING_CMD_SET_GLOBAL_PARAMS,
LED_RING_CMD_SET_DOT_PARAMS,
LED_RING_CMD_RESET_STATE,
} led_ring_command_t;
typedef struct {
float color_hue_deg;
float cycle_s;
} led_ring_global_params_t;
typedef struct {
uint8_t enabled;
float color_hue_deg;
float cycle_s;
float phase_deg;
uint8_t clockwise;
uint8_t tail_mode;
} led_ring_dot_params_t;
typedef struct {
led_ring_command_t command;
led_ring_mode_t mode;
led_ring_global_params_t global;
uint8_t dot_index;
led_ring_dot_params_t dot;
} led_ring_control_t;
extern led_ring_rgb_t led_ring_rgb[LED_RING_EFFECT_LED_COUNT];
/** Update effect state or render the next frame when control is NULL. */
void led_ring_control(const led_ring_control_t *control);
#ifdef __cplusplus
}
#endif
+333
View File
@@ -0,0 +1,333 @@
#include "ws2812.h"
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include <sys/cdefs.h>
#include "driver/rmt_encoder.h"
#include "driver/rmt_tx.h"
#include "esp_check.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "sdkconfig.h"
#include "soc/soc_caps.h"
#define WS2812_RMT_RESOLUTION_HZ 20000000U
#define WS2812_RESET_US 80U
#define WS2812_BYTES_PER_PIXEL 3U
typedef struct {
rmt_encoder_t base;
rmt_encoder_t *bytes_encoder;
rmt_encoder_t *copy_encoder;
rmt_symbol_word_t reset_code;
int state;
} ws2812_rmt_encoder_t;
static const char *TAG = "ws2812";
static rmt_channel_handle_t s_tx_channel;
static rmt_encoder_handle_t s_encoder;
static SemaphoreHandle_t s_lock;
static uint8_t s_grb[CONFIG_WS2812_LED_COUNT * WS2812_BYTES_PER_PIXEL];
RMT_ENCODER_FUNC_ATTR
static size_t encode_ws2812(rmt_encoder_t *encoder,
rmt_channel_handle_t channel,
const void *primary_data, size_t data_size,
rmt_encode_state_t *ret_state)
{
ws2812_rmt_encoder_t *ws_encoder =
__containerof(encoder, ws2812_rmt_encoder_t, base);
rmt_encode_state_t session_state = RMT_ENCODING_RESET;
rmt_encode_state_t state = RMT_ENCODING_RESET;
size_t encoded_symbols = 0;
switch (ws_encoder->state) {
case 0:
encoded_symbols += ws_encoder->bytes_encoder->encode(
ws_encoder->bytes_encoder, channel, primary_data, data_size,
&session_state);
if (session_state & RMT_ENCODING_COMPLETE) {
ws_encoder->state = 1;
}
if (session_state & RMT_ENCODING_MEM_FULL) {
state |= RMT_ENCODING_MEM_FULL;
goto out;
}
__attribute__((fallthrough));
case 1:
encoded_symbols += ws_encoder->copy_encoder->encode(
ws_encoder->copy_encoder, channel, &ws_encoder->reset_code,
sizeof(ws_encoder->reset_code), &session_state);
if (session_state & RMT_ENCODING_COMPLETE) {
ws_encoder->state = 0;
state |= RMT_ENCODING_COMPLETE;
}
if (session_state & RMT_ENCODING_MEM_FULL) {
state |= RMT_ENCODING_MEM_FULL;
}
break;
default:
ws_encoder->state = 0;
state |= RMT_ENCODING_COMPLETE;
break;
}
out:
*ret_state = state;
return encoded_symbols;
}
static esp_err_t reset_ws2812_encoder(rmt_encoder_t *encoder)
{
ws2812_rmt_encoder_t *ws_encoder =
__containerof(encoder, ws2812_rmt_encoder_t, base);
ESP_RETURN_ON_ERROR(rmt_encoder_reset(ws_encoder->bytes_encoder), TAG,
"reset bytes encoder");
ESP_RETURN_ON_ERROR(rmt_encoder_reset(ws_encoder->copy_encoder), TAG,
"reset copy encoder");
ws_encoder->state = 0;
return ESP_OK;
}
static esp_err_t delete_ws2812_encoder(rmt_encoder_t *encoder)
{
ws2812_rmt_encoder_t *ws_encoder =
__containerof(encoder, ws2812_rmt_encoder_t, base);
esp_err_t first_error = ESP_OK;
esp_err_t err = rmt_del_encoder(ws_encoder->bytes_encoder);
if (err != ESP_OK) {
first_error = err;
}
err = rmt_del_encoder(ws_encoder->copy_encoder);
if (first_error == ESP_OK && err != ESP_OK) {
first_error = err;
}
free(ws_encoder);
return first_error;
}
static esp_err_t new_ws2812_encoder(rmt_encoder_handle_t *ret_encoder)
{
ESP_RETURN_ON_FALSE(ret_encoder, ESP_ERR_INVALID_ARG, TAG,
"encoder output is NULL");
ws2812_rmt_encoder_t *ws_encoder =
rmt_alloc_encoder_mem(sizeof(ws2812_rmt_encoder_t));
ESP_RETURN_ON_FALSE(ws_encoder, ESP_ERR_NO_MEM, TAG,
"allocate RMT encoder");
memset(ws_encoder, 0, sizeof(*ws_encoder));
ws_encoder->base.encode = encode_ws2812;
ws_encoder->base.reset = reset_ws2812_encoder;
ws_encoder->base.del = delete_ws2812_encoder;
/* 20 MHz gives 50 ns ticks and preserves the original 0.35/0.70 us timing. */
const rmt_bytes_encoder_config_t bytes_config = {
.bit0 = {
.level0 = 1,
.duration0 = 7,
.level1 = 0,
.duration1 = 18,
},
.bit1 = {
.level0 = 1,
.duration0 = 14,
.level1 = 0,
.duration1 = 11,
},
.flags.msb_first = 1,
};
esp_err_t err = rmt_new_bytes_encoder(&bytes_config,
&ws_encoder->bytes_encoder);
if (err != ESP_OK) {
free(ws_encoder);
return err;
}
const rmt_copy_encoder_config_t copy_config = {};
err = rmt_new_copy_encoder(&copy_config, &ws_encoder->copy_encoder);
if (err != ESP_OK) {
rmt_del_encoder(ws_encoder->bytes_encoder);
free(ws_encoder);
return err;
}
const uint32_t half_reset_ticks =
(WS2812_RMT_RESOLUTION_HZ / 1000000U) * WS2812_RESET_US / 2U;
ws_encoder->reset_code = (rmt_symbol_word_t) {
.level0 = 0,
.duration0 = half_reset_ticks,
.level1 = 0,
.duration1 = half_reset_ticks,
};
*ret_encoder = &ws_encoder->base;
return ESP_OK;
}
static uint8_t scale_channel(uint8_t value, uint8_t brightness)
{
return (uint8_t)(((uint16_t)value * brightness + 127U) / 255U);
}
static esp_err_t transmit_locked(uint32_t timeout_ms)
{
const rmt_transmit_config_t tx_config = {
.loop_count = 0,
.flags.eot_level = 0,
.flags.queue_nonblocking = 0,
};
ESP_RETURN_ON_ERROR(rmt_transmit(s_tx_channel, s_encoder, s_grb,
sizeof(s_grb), &tx_config),
TAG, "queue RMT frame");
return rmt_tx_wait_all_done(s_tx_channel, (int)timeout_ms);
}
esp_err_t ws2812_init(void)
{
ESP_RETURN_ON_FALSE(!s_tx_channel, ESP_ERR_INVALID_STATE, TAG,
"driver already initialized");
s_lock = xSemaphoreCreateMutex();
ESP_RETURN_ON_FALSE(s_lock, ESP_ERR_NO_MEM, TAG, "create framebuffer lock");
const rmt_tx_channel_config_t channel_config = {
.gpio_num = CONFIG_WS2812_GPIO,
.clk_src = RMT_CLK_SRC_DEFAULT,
.resolution_hz = WS2812_RMT_RESOLUTION_HZ,
.mem_block_symbols = 64,
.trans_queue_depth = 2,
.intr_priority = 0,
.flags.invert_out = 0,
#if SOC_RMT_SUPPORT_DMA
.flags.with_dma = 1,
#else
.flags.with_dma = 0,
#endif
.flags.allow_pd = 0,
.flags.init_level = 0,
};
esp_err_t err = rmt_new_tx_channel(&channel_config, &s_tx_channel);
if (err != ESP_OK) {
vSemaphoreDelete(s_lock);
s_lock = NULL;
return err;
}
err = new_ws2812_encoder(&s_encoder);
if (err != ESP_OK) {
rmt_del_channel(s_tx_channel);
s_tx_channel = NULL;
vSemaphoreDelete(s_lock);
s_lock = NULL;
return err;
}
err = rmt_enable(s_tx_channel);
if (err != ESP_OK) {
rmt_del_encoder(s_encoder);
s_encoder = NULL;
rmt_del_channel(s_tx_channel);
s_tx_channel = NULL;
vSemaphoreDelete(s_lock);
s_lock = NULL;
return err;
}
memset(s_grb, 0, sizeof(s_grb));
ESP_LOGI(TAG, "RMT TX ready: GPIO%d, %u pixels, 20 MHz, DMA=%d",
CONFIG_WS2812_GPIO, CONFIG_WS2812_LED_COUNT,
(int)SOC_RMT_SUPPORT_DMA);
return ESP_OK;
}
esp_err_t ws2812_deinit(void)
{
ESP_RETURN_ON_FALSE(s_tx_channel && s_encoder && s_lock,
ESP_ERR_INVALID_STATE, TAG, "driver not initialized");
ESP_RETURN_ON_FALSE(xSemaphoreTake(s_lock, portMAX_DELAY) == pdTRUE,
ESP_FAIL, TAG, "lock framebuffer");
esp_err_t err = rmt_disable(s_tx_channel);
esp_err_t encoder_err = rmt_del_encoder(s_encoder);
esp_err_t channel_err = rmt_del_channel(s_tx_channel);
s_encoder = NULL;
s_tx_channel = NULL;
xSemaphoreGive(s_lock);
vSemaphoreDelete(s_lock);
s_lock = NULL;
if (err != ESP_OK) {
return err;
}
return encoder_err != ESP_OK ? encoder_err : channel_err;
}
size_t ws2812_pixel_count(void)
{
return CONFIG_WS2812_LED_COUNT;
}
esp_err_t ws2812_set_pixel(size_t index, uint8_t red, uint8_t green,
uint8_t blue)
{
ESP_RETURN_ON_FALSE(s_lock, ESP_ERR_INVALID_STATE, TAG,
"driver not initialized");
ESP_RETURN_ON_FALSE(index < CONFIG_WS2812_LED_COUNT,
ESP_ERR_INVALID_ARG, TAG, "pixel index out of range");
ESP_RETURN_ON_FALSE(xSemaphoreTake(s_lock, portMAX_DELAY) == pdTRUE,
ESP_FAIL, TAG, "lock framebuffer");
s_grb[index * 3U + 0U] = green;
s_grb[index * 3U + 1U] = red;
s_grb[index * 3U + 2U] = blue;
xSemaphoreGive(s_lock);
return ESP_OK;
}
esp_err_t ws2812_fill(uint8_t red, uint8_t green, uint8_t blue)
{
ESP_RETURN_ON_FALSE(s_lock, ESP_ERR_INVALID_STATE, TAG,
"driver not initialized");
ESP_RETURN_ON_FALSE(xSemaphoreTake(s_lock, portMAX_DELAY) == pdTRUE,
ESP_FAIL, TAG, "lock framebuffer");
for (size_t index = 0; index < CONFIG_WS2812_LED_COUNT; ++index) {
s_grb[index * 3U + 0U] = green;
s_grb[index * 3U + 1U] = red;
s_grb[index * 3U + 2U] = blue;
}
xSemaphoreGive(s_lock);
return ESP_OK;
}
esp_err_t ws2812_refresh(uint32_t timeout_ms)
{
ESP_RETURN_ON_FALSE(s_lock, ESP_ERR_INVALID_STATE, TAG,
"driver not initialized");
ESP_RETURN_ON_FALSE(xSemaphoreTake(s_lock, portMAX_DELAY) == pdTRUE,
ESP_FAIL, TAG, "lock framebuffer");
esp_err_t err = transmit_locked(timeout_ms);
xSemaphoreGive(s_lock);
return err;
}
esp_err_t ws2812_show(const ws2812_color_t *pixels, size_t count,
uint8_t brightness, uint32_t timeout_ms)
{
ESP_RETURN_ON_FALSE(pixels && count == CONFIG_WS2812_LED_COUNT,
ESP_ERR_INVALID_ARG, TAG, "invalid RGB frame");
ESP_RETURN_ON_FALSE(s_lock, ESP_ERR_INVALID_STATE, TAG,
"driver not initialized");
ESP_RETURN_ON_FALSE(xSemaphoreTake(s_lock, portMAX_DELAY) == pdTRUE,
ESP_FAIL, TAG, "lock framebuffer");
for (size_t index = 0; index < count; ++index) {
s_grb[index * 3U + 0U] = scale_channel(pixels[index].g, brightness);
s_grb[index * 3U + 1U] = scale_channel(pixels[index].r, brightness);
s_grb[index * 3U + 2U] = scale_channel(pixels[index].b, brightness);
}
esp_err_t err = transmit_locked(timeout_ms);
xSemaphoreGive(s_lock);
return err;
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
uint8_t r;
uint8_t g;
uint8_t b;
} ws2812_color_t;
/** Initialize the ESP32-P4 RMT TX + GDMA WS2812B driver. */
esp_err_t ws2812_init(void);
/** Disable the RMT channel and release driver resources. */
esp_err_t ws2812_deinit(void);
/** Return the number of pixels configured by menuconfig. */
size_t ws2812_pixel_count(void);
/** Update one pixel in the driver's local framebuffer. */
esp_err_t ws2812_set_pixel(size_t index, uint8_t red, uint8_t green, uint8_t blue);
/** Update every pixel in the driver's local framebuffer. */
esp_err_t ws2812_fill(uint8_t red, uint8_t green, uint8_t blue);
/** Send the local framebuffer through RMT and wait for completion. */
esp_err_t ws2812_refresh(uint32_t timeout_ms);
/**
* Scale and transmit an RGB frame in one locked operation.
* brightness is 0..255 and count must match ws2812_pixel_count().
*/
esp_err_t ws2812_show(const ws2812_color_t *pixels, size_t count,
uint8_t brightness, uint32_t timeout_ms);
#ifdef __cplusplus
}
#endif