首次上传
This commit is contained in:
1 parent
133fe739ed
commit
07e0cac3ac
71 files changed
+23545
No files matched your search
+64
@@ -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/
|
||||
@@ -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.
@@ -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.
@@ -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/
|
||||
@@ -0,0 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(esp32_8mic LANGUAGES CXX)
|
||||
|
||||
add_subdirectory(cpp)
|
||||
@@ -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'
|
||||
Binary file not shown.
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Generated
+2562
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
# Macmini M4/M6 磁吸叠放AI阵列麦克风
|
||||
|
||||
## 项目介绍
|
||||

|
||||
|
||||
本项目是一款为 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
@@ -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)
|
||||
+4366
File diff suppressed because it is too large.
Load diff
@@ -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 |
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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`(包含按键释放报告)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
version: "0.1.0"
|
||||
targets:
|
||||
- esp32p4
|
||||
dependencies:
|
||||
idf: ">=5.3"
|
||||
espressif/tinyusb: "^0.19.0~3"
|
||||
|
||||
+24
@@ -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");
|
||||
}
|
||||
@@ -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, ®, 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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
¤t, 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,
|
||||
¤t, 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,
|
||||
¤t, 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 所使用的引脚。
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(©_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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user