feat: complete initial firmware implementation for ESP32-S3 (N16R8) hardware bridge
This commit is contained in:
commit
1cca7c4ec3
31 files changed
+2024
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
.pio
|
||||
.vscode
|
||||
.idea
|
||||
*.pyc
|
||||
__pycache__/
|
||||
build/
|
||||
*.bin
|
||||
*.elf
|
||||
*.map
|
||||
*.hex
|
||||
*.log
|
||||
@@ -0,0 +1,74 @@
|
||||
# RemoteMapper-ESP32 · 小米蓝牙语音遥控器 USB 复合硬件桥接固件
|
||||
|
||||
> 🎯 **基于 ESP32-S3 N16R8 (16MB Flash + 8MB Octal PSRAM) 硬件方案**
|
||||
> 彻底解决 Windows 内核键盘驱动丢弃按键、物理键盘冲突、虚拟声卡 (VB-Cable) 依赖等问题!
|
||||
|
||||
---
|
||||
|
||||
## 🌟 核心特性与优势
|
||||
|
||||
1. 🎙️ **真·免驱动 USB 物理麦克风 (UAC 1.0)**:
|
||||
- ESP32-S3 原生 USB OTG 模拟标准 16kHz 16-bit Mono 麦克风设备;
|
||||
- 遥控器语音键按下即说话,松开即停止,即插即用,无需安装任何第三方声卡驱动。
|
||||
2. ⌨️ **独立硬件级 USB HID 键盘 & 多媒体控制器**:
|
||||
- 小米遥控器音量加(`0x80`)、音量减(`0x81`)直接转换为 PC 标准 **Consumer Volume Up/Down**;
|
||||
- 返回键(`0xF1`)转换为 **Consumer AC Back / Esc**(网页与播放器原生后退);
|
||||
- 电源键(`Alt+Tab` / 长按关屏)、主页键(`Win+D`)、菜单键(`Space` 播放暂停)、TV 键(`F8` 语音唤醒);
|
||||
- 与物理主键盘物理隔离,彻底规避软件钩子冲突。
|
||||
3. ⚡ **双核高吞吐实时 DSP 流水线**:
|
||||
- **Core 0**:专门负责 NimBLE 5.0 协议栈、HOGP 按键监听、ATVV 语音流握手与 IMA-ADPCM 4-bit 解码 + Declip 尖峰消除 + 3-Tap FIR 低通平滑 + 动态 AGC;
|
||||
- **Core 1**:负责 TinyUSB 复合设备(UAC 麦克风音频泵、HID 键盘派发、按键手势状态机与 CDC 诊断串口)。
|
||||
4. 🛡️ **健壮的保活与看门狗机制**:
|
||||
- 450ms 音频静音看门狗:防止由于蓝牙丢包导致的语音键状态卡住;
|
||||
- 自动重连与绑定恢复:NVS 存储配对信息,开机与遥控器唤醒时秒连。
|
||||
|
||||
---
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```text
|
||||
D:\tool\RemoteMapper-ESP32/
|
||||
├── default_16MB.csv # 16MB Flash 分区表
|
||||
├── platformio.ini # PlatformIO 编译与环境配置
|
||||
├── build.bat # 一键编译脚本
|
||||
├── flash.bat # 一键烧录脚本
|
||||
├── monitor.bat # 串口监控日志脚本
|
||||
├── test.bat # 单元测试运行脚本
|
||||
├── include/
|
||||
│ ├── app_config.h # 全局引脚、采样率、缓冲区容量宏定义
|
||||
│ └── version.h # 固件版本号
|
||||
├── src/
|
||||
│ ├── main.cpp # FreeRTOS 双核主入口与任务调度
|
||||
│ ├── audio/ # ADPCM 解码、Declip、FIR 平滑、AGC 与环形缓冲区
|
||||
│ ├── ble/ # NimBLE Central、HOGP 监听与 ATVV 协议客户端
|
||||
│ ├── usb/ # TinyUSB UAC 麦克风与 HID 键盘/多媒体控制器
|
||||
│ ├── keymap/ # 按键手势状态机与键码映射引擎
|
||||
│ └── cli/ # Serial CDC 诊断与 JSON 状态接口
|
||||
└── test/
|
||||
└── native/ # 算法与状态机原生验证单元测试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 编译与烧录
|
||||
|
||||
### 1. 运行单元测试
|
||||
```cmd
|
||||
test.bat
|
||||
```
|
||||
|
||||
### 2. 编译固件
|
||||
```cmd
|
||||
build.bat
|
||||
```
|
||||
|
||||
### 3. 烧录到 ESP32-S3
|
||||
将 ESP32-S3 开发板通过 USB 数据线连接到电脑(注意插在 `USB/OTG` 接口):
|
||||
```cmd
|
||||
flash.bat
|
||||
```
|
||||
|
||||
### 4. 查看实时串口日志
|
||||
```cmd
|
||||
monitor.bat
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
echo ========================================================
|
||||
echo Building RemoteMapper-ESP32 Firmware (ESP32-S3 N16R8)
|
||||
echo ========================================================
|
||||
python -m platformio run -e esp32s3_n16r8
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Build Failed!
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
echo [SUCCESS] Build completed successfully!
|
||||
@@ -0,0 +1,7 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
otadata, data, ota, 0xf000, 0x2000,
|
||||
app0, app, ota_0, 0x20000, 0x600000,
|
||||
app1, app, ota_1, 0x620000,0x600000,
|
||||
spiffs, data, spiffs, 0xc20000,0x3c0000,
|
||||
coredump, data, coredump,0xfe0000,0x20000,
|
||||
|
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
echo ========================================================
|
||||
echo Flashing RemoteMapper-ESP32 Firmware to ESP32-S3
|
||||
echo ========================================================
|
||||
python -m platformio run -e esp32s3_n16r8 -t upload
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Flash Failed!
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
echo [SUCCESS] Flash completed successfully!
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// ==========================================
|
||||
// 1. Audio & DSP Parameters
|
||||
// ==========================================
|
||||
#define AUDIO_SAMPLE_RATE 16000 // 16kHz sample rate
|
||||
#define AUDIO_BITS_PER_SAMPLE 16 // 16-bit PCM
|
||||
#define AUDIO_CHANNELS 1 // Mono
|
||||
#define AUDIO_DEFAULT_FRAME_BYTES 120 // Default 120 bytes ADPCM per BLE frame
|
||||
#define AUDIO_DEFAULT_FRAME_SAMPS 240 // 120 * 2 = 240 PCM samples per frame
|
||||
#define AUDIO_RING_BUFFER_SIZE 4096 // Ring buffer capacity (in samples, ~256ms buffer)
|
||||
|
||||
// AGC & Filter parameters
|
||||
#define AGC_TARGET_LEVEL 28000.0f
|
||||
#define AGC_DECAY_RATE 0.9997f
|
||||
#define AGC_MAX_GAIN 30.0f
|
||||
#define AGC_NOISE_FLOOR 200.0f
|
||||
#define AGC_INITIAL_PEAK 1000.0f
|
||||
#define DECLIP_THRESHOLD 1000
|
||||
|
||||
// ==========================================
|
||||
// 2. BLE Central & ATVV Parameters
|
||||
// ==========================================
|
||||
#define BLE_REMOTE_NAME_PREFIX "MI RC"
|
||||
#define BLE_REMOTE_MAC_PREFIX "c0:5d:39"
|
||||
#define BLE_SCAN_INTERVAL_MS 100
|
||||
#define BLE_SCAN_WINDOW_MS 80
|
||||
#define BLE_SILENCE_WATCHDOG_MS 450 // Voice key release watchdog timeout (ms)
|
||||
#define BLE_KEEP_ALIVE_INTERVAL 2000 // MIC_EXTEND interval during active voice (ms)
|
||||
|
||||
// ATVV GATT UUIDs (128-bit)
|
||||
#define ATVV_SVC_UUID "ab5e0001-5a21-4f05-bc7d-af01f617b664"
|
||||
#define ATVV_CHAR_CMD_UUID "ab5e0002-5a21-4f05-bc7d-af01f617b664"
|
||||
#define ATVV_CHAR_AUD_UUID "ab5e0003-5a21-4f05-bc7d-af01f617b664"
|
||||
#define ATVV_CHAR_CTL_UUID "ab5e0004-5a21-4f05-bc7d-af01f617b664"
|
||||
|
||||
// HOGP UUIDs (Standard Bluetooth SIG 16-bit)
|
||||
#define HOGP_SVC_UUID 0x1812
|
||||
#define HOGP_REPORT_CHAR_UUID 0x2A4D
|
||||
|
||||
// ==========================================
|
||||
// 3. FreeRTOS Task & Multi-Core Pinning
|
||||
// ==========================================
|
||||
#define TASK_CORE_BLE 0 // Core 0: BLE & Audio decoding
|
||||
#define TASK_CORE_USB 1 // Core 1: TinyUSB & HID/Audio push
|
||||
|
||||
#define PRIO_TASK_USB 6 // Real-time USB Isochronous & HID
|
||||
#define PRIO_TASK_AUDIO_DSP 5 // High-priority audio stream decoding
|
||||
#define PRIO_TASK_BLE 4 // NimBLE client processing
|
||||
#define PRIO_TASK_KEYMAP_CLI 3 // Key mapping state machine & CLI
|
||||
|
||||
// ==========================================
|
||||
// 4. Default Keymap & Hotkey Codes
|
||||
// ==========================================
|
||||
// Default Voice Input Hotkey: Right Alt + Comma (WeChat Voice IME)
|
||||
// HID Keyboard Modifiers: 0x01=LCTRL, 0x02=LSHIFT, 0x04=LALT, 0x08=LGUI, 0x10=RCTRL, 0x20=RSHIFT, 0x40=RALT, 0x80=RGUI
|
||||
#define DEFAULT_VOICE_MODIFIER 0x40 // KEY_MOD_RALT
|
||||
#define DEFAULT_VOICE_KEY 0x36 // HID Usage for Comma ','
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#define FIRMWARE_NAME "RemoteMapper-ESP32"
|
||||
#define FIRMWARE_VERSION "1.0.0"
|
||||
#define HARDWARE_TARGET "ESP32-S3-N16R8"
|
||||
#define AUTHOR "RemoteMapper Project"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
echo ========================================================
|
||||
echo Starting Serial Monitor (115200 baud) - Ctrl+C to Exit
|
||||
echo ========================================================
|
||||
python -m platformio device monitor -b 115200
|
||||
@@ -0,0 +1,54 @@
|
||||
; PlatformIO Project Configuration File for RemoteMapper-ESP32
|
||||
; Model: ESP32-S3-DevKitC-1 N16R8 (16MB Flash, 8MB Octal PSRAM)
|
||||
|
||||
[platformio]
|
||||
default_envs = esp32s3_n16r8
|
||||
src_dir = src
|
||||
include_dir = include
|
||||
|
||||
[env:esp32s3_n16r8]
|
||||
platform = espressif32
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
|
||||
; 16MB Flash & 8MB Octal PSRAM Settings
|
||||
board_build.mcu = esp32s3
|
||||
board_build.f_cpu = 240000000L
|
||||
board_build.f_flash = 80000000L
|
||||
board_build.flash_mode = qio
|
||||
board_build.flash_size = 16MB
|
||||
board_upload.flash_size = 16MB
|
||||
board_build.partitions = default_16MB.csv
|
||||
board_build.arduino.memory_type = qio_opi
|
||||
|
||||
; Build flags: Enable TinyUSB OTG, NimBLE, Audio/HID composite descriptors
|
||||
build_flags =
|
||||
-DBOARD_HAS_PSRAM
|
||||
-mfix-esp32-psram-cache-issue
|
||||
-DARDUINO_USB_MODE=0
|
||||
-DARDUINO_USB_CDC_ON_BOOT=0
|
||||
-DCORE_DEBUG_LEVEL=3
|
||||
-DCFG_TUSB_MCU=OPT_MCU_ESP32S3
|
||||
-DCFG_TUD_ENABLED=1
|
||||
-DCFG_TUD_AUDIO=1
|
||||
-DCFG_TUD_HID=2
|
||||
-DCFG_TUD_CDC=1
|
||||
-I include
|
||||
|
||||
lib_deps =
|
||||
h2zero/NimBLE-Arduino@^1.4.2
|
||||
bblanchon/ArduinoJson@^7.0.4
|
||||
|
||||
monitor_speed = 115200
|
||||
monitor_filters = esp32_exception_decoder, time, colorize
|
||||
upload_speed = 921600
|
||||
|
||||
; Native test environment for running unit tests directly on PC
|
||||
[env:native_test]
|
||||
platform = native
|
||||
test_framework = custom
|
||||
build_flags =
|
||||
-std=c++17
|
||||
-I include
|
||||
-I src
|
||||
-DUNIT_TEST_NATIVE
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "adpcm_decoder.h"
|
||||
|
||||
// Standard IMA-ADPCM Step Table (89 entries)
|
||||
static const int16_t STEP_TABLE[89] = {
|
||||
7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
|
||||
19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
|
||||
50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
|
||||
130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
|
||||
337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
|
||||
876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
|
||||
2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
|
||||
5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
|
||||
15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
|
||||
};
|
||||
|
||||
// Standard IMA-ADPCM Index Adjustment Table (8 entries)
|
||||
static const int8_t INDEX_TABLE[8] = {
|
||||
-1, -1, -1, -1, 2, 4, 6, 8
|
||||
};
|
||||
|
||||
void adpcm_init_state(adpcm_state_t *state) {
|
||||
if (!state) return;
|
||||
state->predictor = 0;
|
||||
state->step_index = 0;
|
||||
}
|
||||
|
||||
void adpcm_sync_state(adpcm_state_t *state, int16_t predictor, int8_t step_index) {
|
||||
if (!state) return;
|
||||
state->predictor = (int32_t)predictor;
|
||||
if (step_index < 0) step_index = 0;
|
||||
if (step_index > 88) step_index = 88;
|
||||
state->step_index = step_index;
|
||||
}
|
||||
|
||||
int16_t adpcm_decode_nibble(adpcm_state_t *state, uint8_t nibble) {
|
||||
int32_t step = STEP_TABLE[state->step_index];
|
||||
int32_t diff = step >> 3;
|
||||
|
||||
if (nibble & 1) diff += step >> 2;
|
||||
if (nibble & 2) diff += step >> 1;
|
||||
if (nibble & 4) diff += step;
|
||||
|
||||
if (nibble & 8) {
|
||||
state->predictor -= diff;
|
||||
} else {
|
||||
state->predictor += diff;
|
||||
}
|
||||
|
||||
// Clamp to 16-bit signed range
|
||||
if (state->predictor > 32767) {
|
||||
state->predictor = 32767;
|
||||
} else if (state->predictor < -32768) {
|
||||
state->predictor = -32768;
|
||||
}
|
||||
|
||||
// Update step index
|
||||
state->step_index += INDEX_TABLE[nibble & 7];
|
||||
if (state->step_index < 0) state->step_index = 0;
|
||||
if (state->step_index > 88) state->step_index = 88;
|
||||
|
||||
return (int16_t)state->predictor;
|
||||
}
|
||||
|
||||
size_t adpcm_decode_frame(adpcm_state_t *state, const uint8_t *in_data, size_t in_bytes, int16_t *out_pcm) {
|
||||
if (!state || !in_data || !out_pcm || in_bytes == 0) return 0;
|
||||
|
||||
size_t sample_idx = 0;
|
||||
for (size_t i = 0; i < in_bytes; i++) {
|
||||
uint8_t byte_val = in_data[i];
|
||||
// Hi-nibble first
|
||||
out_pcm[sample_idx++] = adpcm_decode_nibble(state, (uint8_t)(byte_val >> 4));
|
||||
// Low-nibble second
|
||||
out_pcm[sample_idx++] = adpcm_decode_nibble(state, (uint8_t)(byte_val & 0x0F));
|
||||
}
|
||||
|
||||
return sample_idx;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int32_t predictor; // Current PCM prediction value [-32768, 32767]
|
||||
int8_t step_index; // Current step table index [0, 88]
|
||||
} adpcm_state_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize or reset ADPCM decoder state to defaults (0, 0)
|
||||
*/
|
||||
void adpcm_init_state(adpcm_state_t *state);
|
||||
|
||||
/**
|
||||
* @brief Reset ADPCM decoder state to specific sync values (e.g. from ATVV AUDIO_SYNC 0x0A)
|
||||
*/
|
||||
void adpcm_sync_state(adpcm_state_t *state, int16_t predictor, int8_t step_index);
|
||||
|
||||
/**
|
||||
* @brief Decode a single 4-bit nibble
|
||||
*/
|
||||
int16_t adpcm_decode_nibble(adpcm_state_t *state, uint8_t nibble);
|
||||
|
||||
/**
|
||||
* @brief Decode a complete ADPCM frame (Hi-nibble first) into 16-bit PCM samples
|
||||
* @param state Pointer to ADPCM decoder state
|
||||
* @param in_data ADPCM byte array
|
||||
* @param in_bytes Number of ADPCM bytes
|
||||
* @param out_pcm Output buffer for 16-bit PCM samples (must hold at least in_bytes * 2 samples)
|
||||
* @return Number of PCM samples decoded (in_bytes * 2)
|
||||
*/
|
||||
size_t adpcm_decode_frame(adpcm_state_t *state, const uint8_t *in_data, size_t in_bytes, int16_t *out_pcm);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "audio_agc.h"
|
||||
#include <math.h>
|
||||
|
||||
void audio_agc_init(audio_agc_t *agc) {
|
||||
if (!agc) return;
|
||||
agc->peak = 1000.0f;
|
||||
agc->target_level = 28000.0f;
|
||||
agc->decay_rate = 0.9997f;
|
||||
agc->max_gain = 30.0f;
|
||||
agc->noise_floor = 200.0f;
|
||||
}
|
||||
|
||||
void audio_agc_reset(audio_agc_t *agc) {
|
||||
if (!agc) return;
|
||||
agc->peak = 1000.0f;
|
||||
}
|
||||
|
||||
void audio_agc_process(audio_agc_t *agc, int16_t *samples, size_t count) {
|
||||
if (!agc || !samples || count == 0) return;
|
||||
|
||||
float peak = agc->peak;
|
||||
float target = agc->target_level;
|
||||
float decay = agc->decay_rate;
|
||||
float max_gain = agc->max_gain;
|
||||
float floor_val = agc->noise_floor;
|
||||
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
float v = (float)samples[i];
|
||||
float a = (v < 0.0f) ? -v : v;
|
||||
|
||||
if (a > peak) {
|
||||
peak = a;
|
||||
} else {
|
||||
peak *= decay;
|
||||
}
|
||||
|
||||
float denom = (peak > floor_val) ? peak : floor_val;
|
||||
float gain = target / denom;
|
||||
if (gain > max_gain) {
|
||||
gain = max_gain;
|
||||
}
|
||||
|
||||
v *= gain;
|
||||
|
||||
// Soft clip
|
||||
if (v > 32767.0f) {
|
||||
v = 32767.0f;
|
||||
} else if (v < -32768.0f) {
|
||||
v = -32768.0f;
|
||||
}
|
||||
|
||||
samples[i] = (int16_t)v;
|
||||
}
|
||||
|
||||
agc->peak = peak;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
float peak; // Current peak envelope value
|
||||
float target_level; // Target level (default 28000.0)
|
||||
float decay_rate; // Envelope decay multiplier (default 0.9997)
|
||||
float max_gain; // Maximum allowed gain boost (default 30.0)
|
||||
float noise_floor; // Minimum floor to avoid amplifying silence (default 200.0)
|
||||
} audio_agc_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize AGC state with default parameters
|
||||
*/
|
||||
void audio_agc_init(audio_agc_t *agc);
|
||||
|
||||
/**
|
||||
* @brief Reset AGC peak envelope (e.g. at speech start)
|
||||
*/
|
||||
void audio_agc_reset(audio_agc_t *agc);
|
||||
|
||||
/**
|
||||
* @brief Process PCM samples in-place with AGC gain and soft clipping
|
||||
* @param agc AGC configuration & state
|
||||
* @param samples Array of 16-bit PCM samples
|
||||
* @param count Number of samples
|
||||
*/
|
||||
void audio_agc_process(audio_agc_t *agc, int16_t *samples, size_t count);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "audio_filter.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
void audio_filter_init(audio_filter_state_t *state) {
|
||||
if (!state) return;
|
||||
state->prev_decoded = 0;
|
||||
state->last_sample = 0;
|
||||
}
|
||||
|
||||
void audio_filter_declip(audio_filter_state_t *state, int16_t *samples, size_t count, int16_t threshold) {
|
||||
if (!state || !samples || count == 0) return;
|
||||
|
||||
int32_t prev = state->prev_decoded;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
int32_t p = (i == 0) ? prev : samples[i - 1];
|
||||
int32_t nx = (i == count - 1) ? samples[i] : samples[i + 1];
|
||||
int32_t cur = samples[i];
|
||||
|
||||
int32_t dp = abs(cur - p);
|
||||
int32_t dn = abs(cur - nx);
|
||||
int32_t nd = abs(nx - p);
|
||||
int32_t min_d = (dp < dn) ? dp : dn;
|
||||
|
||||
if (dp > threshold && dn > threshold && min_d > nd * 2) {
|
||||
samples[i] = (int16_t)((p + nx) / 2);
|
||||
}
|
||||
}
|
||||
state->prev_decoded = samples[count - 1];
|
||||
}
|
||||
|
||||
void audio_filter_lowpass(audio_filter_state_t *state, int16_t *samples, size_t count) {
|
||||
if (!state || !samples || count == 0) return;
|
||||
|
||||
int32_t prev = state->last_sample;
|
||||
for (size_t i = 0; i < count - 1; i++) {
|
||||
int32_t cur = samples[i];
|
||||
samples[i] = (int16_t)((prev + (cur * 2) + samples[i + 1]) >> 2);
|
||||
prev = cur;
|
||||
}
|
||||
state->last_sample = samples[count - 1];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int16_t prev_decoded; // Preserved sample for cross-frame declip continuity
|
||||
int16_t last_sample; // Preserved sample for cross-frame lowpass continuity
|
||||
} audio_filter_state_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize or reset audio filter state
|
||||
*/
|
||||
void audio_filter_init(audio_filter_state_t *state);
|
||||
|
||||
/**
|
||||
* @brief Apply Declip algorithm to remove single-sample spikes/glitches
|
||||
* @param state Filter continuity state
|
||||
* @param samples Array of PCM samples (modified in-place)
|
||||
* @param count Number of samples
|
||||
* @param threshold Glitch threshold (e.g. 1000)
|
||||
*/
|
||||
void audio_filter_declip(audio_filter_state_t *state, int16_t *samples, size_t count, int16_t threshold);
|
||||
|
||||
/**
|
||||
* @brief Apply 3-Tap triangle FIR lowpass smoothing filter ((prev + 2*cur + next) >> 2)
|
||||
* @param state Filter continuity state
|
||||
* @param samples Array of PCM samples (modified in-place)
|
||||
* @param count Number of samples
|
||||
*/
|
||||
void audio_filter_lowpass(audio_filter_state_t *state, int16_t *samples, size_t count);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "audio_pipeline.h"
|
||||
#include <string.h>
|
||||
|
||||
audio_pipeline_t g_audio_pipeline;
|
||||
|
||||
void audio_pipeline_init(audio_pipeline_t *pipeline) {
|
||||
if (!pipeline) return;
|
||||
memset(pipeline, 0, sizeof(audio_pipeline_t));
|
||||
adpcm_init_state(&pipeline->adpcm);
|
||||
audio_filter_init(&pipeline->filter);
|
||||
audio_agc_init(&pipeline->agc);
|
||||
audio_ring_buffer_init(&pipeline->ring_buf, pipeline->ring_storage, AUDIO_RING_BUFFER_SIZE);
|
||||
pipeline->active = false;
|
||||
pipeline->session_id = 0;
|
||||
pipeline->total_frames_decoded = 0;
|
||||
pipeline->total_samples_pushed = 0;
|
||||
}
|
||||
|
||||
void audio_pipeline_start_session(audio_pipeline_t *pipeline, uint8_t session_id) {
|
||||
if (!pipeline) return;
|
||||
adpcm_init_state(&pipeline->adpcm);
|
||||
audio_filter_init(&pipeline->filter);
|
||||
audio_agc_reset(&pipeline->agc);
|
||||
audio_ring_buffer_clear(&pipeline->ring_buf);
|
||||
pipeline->session_id = session_id;
|
||||
pipeline->active = true;
|
||||
pipeline->total_frames_decoded = 0;
|
||||
pipeline->total_samples_pushed = 0;
|
||||
}
|
||||
|
||||
void audio_pipeline_sync(audio_pipeline_t *pipeline, int16_t predictor, int8_t step_index) {
|
||||
if (!pipeline) return;
|
||||
adpcm_sync_state(&pipeline->adpcm, predictor, step_index);
|
||||
}
|
||||
|
||||
size_t audio_pipeline_feed_adpcm(audio_pipeline_t *pipeline, const uint8_t *adpcm_bytes, size_t len) {
|
||||
if (!pipeline || !adpcm_bytes || len == 0 || !pipeline->active) return 0;
|
||||
|
||||
size_t samples_decoded = adpcm_decode_frame(&pipeline->adpcm, adpcm_bytes, len, pipeline->temp_pcm);
|
||||
if (samples_decoded == 0) return 0;
|
||||
|
||||
// 1. Declip
|
||||
audio_filter_declip(&pipeline->filter, pipeline->temp_pcm, samples_decoded, DECLIP_THRESHOLD);
|
||||
|
||||
// 2. 3-Tap Triangle FIR Lowpass
|
||||
audio_filter_lowpass(&pipeline->filter, pipeline->temp_pcm, samples_decoded);
|
||||
|
||||
// 3. Dynamic AGC + Soft Clip
|
||||
audio_agc_process(&pipeline->agc, pipeline->temp_pcm, samples_decoded);
|
||||
|
||||
// 4. Enqueue into ring buffer
|
||||
size_t written = audio_ring_buffer_write(&pipeline->ring_buf, pipeline->temp_pcm, samples_decoded);
|
||||
|
||||
pipeline->total_frames_decoded++;
|
||||
pipeline->total_samples_pushed += written;
|
||||
return written;
|
||||
}
|
||||
|
||||
size_t audio_pipeline_read_for_usb(audio_pipeline_t *pipeline, int16_t *out_pcm, size_t sample_count) {
|
||||
if (!pipeline || !out_pcm || sample_count == 0) return 0;
|
||||
|
||||
if (!pipeline->active) {
|
||||
// Feed silence when idle
|
||||
memset(out_pcm, 0, sample_count * sizeof(int16_t));
|
||||
return sample_count;
|
||||
}
|
||||
|
||||
size_t read_count = audio_ring_buffer_read(&pipeline->ring_buf, out_pcm, sample_count);
|
||||
|
||||
// If underrun, pad remainder with silence
|
||||
if (read_count < sample_count) {
|
||||
memset(&out_pcm[read_count], 0, (sample_count - read_count) * sizeof(int16_t));
|
||||
}
|
||||
|
||||
return sample_count;
|
||||
}
|
||||
|
||||
void audio_pipeline_stop_session(audio_pipeline_t *pipeline) {
|
||||
if (!pipeline) return;
|
||||
pipeline->active = false;
|
||||
audio_ring_buffer_clear(&pipeline->ring_buf);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include "app_config.h"
|
||||
#include "adpcm_decoder.h"
|
||||
#include "audio_filter.h"
|
||||
#include "audio_agc.h"
|
||||
#include "audio_ring_buffer.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
adpcm_state_t adpcm;
|
||||
audio_filter_state_t filter;
|
||||
audio_agc_t agc;
|
||||
audio_ring_buffer_t ring_buf;
|
||||
int16_t ring_storage[AUDIO_RING_BUFFER_SIZE];
|
||||
int16_t temp_pcm[AUDIO_DEFAULT_FRAME_SAMPS * 2];
|
||||
uint8_t session_id;
|
||||
bool active;
|
||||
uint32_t total_frames_decoded;
|
||||
uint32_t total_samples_pushed;
|
||||
} audio_pipeline_t;
|
||||
|
||||
/**
|
||||
* @brief Global audio pipeline instance
|
||||
*/
|
||||
extern audio_pipeline_t g_audio_pipeline;
|
||||
|
||||
/**
|
||||
* @brief Initialize audio pipeline
|
||||
*/
|
||||
void audio_pipeline_init(audio_pipeline_t *pipeline);
|
||||
|
||||
/**
|
||||
* @brief Start a new speech session (resets filter, AGC, syncs state)
|
||||
*/
|
||||
void audio_pipeline_start_session(audio_pipeline_t *pipeline, uint8_t session_id);
|
||||
|
||||
/**
|
||||
* @brief Apply ATVV AUDIO_SYNC (0x0A) state reset
|
||||
*/
|
||||
void audio_pipeline_sync(audio_pipeline_t *pipeline, int16_t predictor, int8_t step_index);
|
||||
|
||||
/**
|
||||
* @brief Feed raw ADPCM frame from BLE, decode, filter, AGC, and push to ring buffer
|
||||
* @param pipeline Pipeline handle
|
||||
* @param adpcm_bytes Raw BLE ADPCM payload
|
||||
* @param len Byte count
|
||||
* @return Number of PCM samples produced and enqueued
|
||||
*/
|
||||
size_t audio_pipeline_feed_adpcm(audio_pipeline_t *pipeline, const uint8_t *adpcm_bytes, size_t len);
|
||||
|
||||
/**
|
||||
* @brief Read PCM samples for USB UAC Isochronous IN endpoint
|
||||
* @param pipeline Pipeline handle
|
||||
* @param out_pcm Destination buffer
|
||||
* @param sample_count Number of samples requested
|
||||
* @return Number of samples read (fills remaining with silence 0 if underrun)
|
||||
*/
|
||||
size_t audio_pipeline_read_for_usb(audio_pipeline_t *pipeline, int16_t *out_pcm, size_t sample_count);
|
||||
|
||||
/**
|
||||
* @brief Stop active speech session and clear buffers
|
||||
*/
|
||||
void audio_pipeline_stop_session(audio_pipeline_t *pipeline);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,61 @@
|
||||
#include "audio_ring_buffer.h"
|
||||
#include <string.h>
|
||||
|
||||
static bool is_power_of_two(size_t n) {
|
||||
return (n > 0) && ((n & (n - 1)) == 0);
|
||||
}
|
||||
|
||||
bool audio_ring_buffer_init(audio_ring_buffer_t *rb, int16_t *storage, size_t capacity) {
|
||||
if (!rb || !storage || !is_power_of_two(capacity)) {
|
||||
return false;
|
||||
}
|
||||
rb->buffer = storage;
|
||||
rb->capacity = capacity;
|
||||
rb->mask = capacity - 1;
|
||||
rb->head = 0;
|
||||
rb->tail = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t audio_ring_buffer_available_read(const audio_ring_buffer_t *rb) {
|
||||
if (!rb) return 0;
|
||||
return rb->head - rb->tail;
|
||||
}
|
||||
|
||||
size_t audio_ring_buffer_available_write(const audio_ring_buffer_t *rb) {
|
||||
if (!rb) return 0;
|
||||
return rb->capacity - (rb->head - rb->tail);
|
||||
}
|
||||
|
||||
size_t audio_ring_buffer_write(audio_ring_buffer_t *rb, const int16_t *samples, size_t count) {
|
||||
if (!rb || !samples || count == 0) return 0;
|
||||
|
||||
size_t free_slots = audio_ring_buffer_available_write(rb);
|
||||
size_t to_write = (count < free_slots) ? count : free_slots;
|
||||
|
||||
for (size_t i = 0; i < to_write; i++) {
|
||||
rb->buffer[rb->head & rb->mask] = samples[i];
|
||||
rb->head++;
|
||||
}
|
||||
|
||||
return to_write;
|
||||
}
|
||||
|
||||
size_t audio_ring_buffer_read(audio_ring_buffer_t *rb, int16_t *out_samples, size_t count) {
|
||||
if (!rb || !out_samples || count == 0) return 0;
|
||||
|
||||
size_t avail = audio_ring_buffer_available_read(rb);
|
||||
size_t to_read = (count < avail) ? count : avail;
|
||||
|
||||
for (size_t i = 0; i < to_read; i++) {
|
||||
out_samples[i] = rb->buffer[rb->tail & rb->mask];
|
||||
rb->tail++;
|
||||
}
|
||||
|
||||
return to_read;
|
||||
}
|
||||
|
||||
void audio_ring_buffer_clear(audio_ring_buffer_t *rb) {
|
||||
if (!rb) return;
|
||||
rb->tail = rb->head;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int16_t *buffer;
|
||||
size_t capacity; // Must be power of 2
|
||||
size_t mask; // capacity - 1
|
||||
volatile size_t head; // Write index
|
||||
volatile size_t tail; // Read index
|
||||
} audio_ring_buffer_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize ring buffer with allocated storage
|
||||
* @param rb Ring buffer pointer
|
||||
* @param storage Pre-allocated array of int16_t samples
|
||||
* @param capacity Number of samples (must be power of 2, e.g. 1024, 2048, 4096)
|
||||
* @return true if valid power of 2, false otherwise
|
||||
*/
|
||||
bool audio_ring_buffer_init(audio_ring_buffer_t *rb, int16_t *storage, size_t capacity);
|
||||
|
||||
/**
|
||||
* @brief Get available samples for reading
|
||||
*/
|
||||
size_t audio_ring_buffer_available_read(const audio_ring_buffer_t *rb);
|
||||
|
||||
/**
|
||||
* @brief Get available free sample slots for writing
|
||||
*/
|
||||
size_t audio_ring_buffer_available_write(const audio_ring_buffer_t *rb);
|
||||
|
||||
/**
|
||||
* @brief Write samples into ring buffer
|
||||
* @return Number of samples actually written
|
||||
*/
|
||||
size_t audio_ring_buffer_write(audio_ring_buffer_t *rb, const int16_t *samples, size_t count);
|
||||
|
||||
/**
|
||||
* @brief Read samples from ring buffer
|
||||
* @return Number of samples actually read
|
||||
*/
|
||||
size_t audio_ring_buffer_read(audio_ring_buffer_t *rb, int16_t *out_samples, size_t count);
|
||||
|
||||
/**
|
||||
* @brief Clear ring buffer contents
|
||||
*/
|
||||
void audio_ring_buffer_clear(audio_ring_buffer_t *rb);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,238 @@
|
||||
#include "ble_remote_client.h"
|
||||
#include "audio/audio_pipeline.h"
|
||||
#include "keymap/key_state_machine.h"
|
||||
#include "usb/usb_composite.h"
|
||||
#include <Arduino.h>
|
||||
#include <NimBLEDevice.h>
|
||||
|
||||
static ble_remote_state_t s_ble_state = BLE_STATE_DISCONNECTED;
|
||||
static NimBLEClient* s_client = nullptr;
|
||||
static NimBLERemoteChar* s_char_cmd = nullptr;
|
||||
static NimBLERemoteChar* s_char_aud = nullptr;
|
||||
static NimBLERemoteChar* s_char_ctl = nullptr;
|
||||
|
||||
static uint8_t s_session_id = 0;
|
||||
static uint32_t s_last_audio_ms = 0;
|
||||
static uint32_t s_last_extend_ms = 0;
|
||||
static uint32_t s_last_scan_ms = 0;
|
||||
static size_t s_frame_size = AUDIO_DEFAULT_FRAME_BYTES;
|
||||
|
||||
extern key_mapper_engine_t g_key_engine;
|
||||
|
||||
// Forward Declarations
|
||||
static void start_scan();
|
||||
static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice);
|
||||
|
||||
// Audio Notification Callback (ATVV Char 0x03)
|
||||
static void on_audio_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) {
|
||||
if (s_ble_state != BLE_STATE_TALKING || length == 0) return;
|
||||
s_last_audio_ms = millis();
|
||||
audio_pipeline_feed_adpcm(&g_audio_pipeline, pData, length);
|
||||
}
|
||||
|
||||
// Control Notification Callback (ATVV Char 0x04)
|
||||
static void on_ctl_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) {
|
||||
if (length < 1) return;
|
||||
uint8_t op = pData[0];
|
||||
|
||||
// AUDIO_START with HTT reason: byte1 == 0x03
|
||||
if (op == 0x04 && length >= 2 && pData[1] == 0x03) {
|
||||
s_session_id = (length >= 4) ? pData[3] : 0;
|
||||
s_ble_state = BLE_STATE_TALKING;
|
||||
s_last_audio_ms = millis();
|
||||
s_last_extend_ms = millis();
|
||||
|
||||
// Trigger Voice Hold action (hold hotkey & reset audio DSP)
|
||||
key_action_t act = { ACTION_VOICE_HOLD, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
|
||||
usb_hid_dispatch_action(&act);
|
||||
Serial.printf("[ATVV] >>> Voice button PRESSED (session %d)\n", s_session_id);
|
||||
}
|
||||
// AUDIO_STOP / MIC_CLOSED / release op:
|
||||
else if (op == 0x00) {
|
||||
if (s_ble_state == BLE_STATE_TALKING) {
|
||||
s_ble_state = BLE_STATE_CONNECTED;
|
||||
key_action_t act = { ACTION_VOICE_RELEASE, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
|
||||
usb_hid_dispatch_action(&act);
|
||||
Serial.println("[ATVV] <<< Voice button RELEASED");
|
||||
|
||||
// Re-arm remote HTT standby
|
||||
if (s_char_cmd != nullptr) {
|
||||
uint8_t cmd_open[] = { 0x0C, 0x00 };
|
||||
s_char_cmd->writeValue(cmd_open, sizeof(cmd_open), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// CAPS_RESP: op == 0x0B
|
||||
else if (op == 0x0B && length >= 7) {
|
||||
uint16_t ver = (pData[1] << 8) | pData[2];
|
||||
uint16_t fs = (pData[5] << 8) | pData[6];
|
||||
if (fs > 0) s_frame_size = fs;
|
||||
Serial.printf("[ATVV] CAPS: ver=0x%04X, frame_size=%d\n", ver, s_frame_size);
|
||||
}
|
||||
// AUDIO_SYNC: op == 0x0A
|
||||
else if (op == 0x0A && length >= 7) {
|
||||
int16_t pred = (int16_t)((pData[4] << 8) | pData[5]);
|
||||
int8_t step_idx = (int8_t)pData[6];
|
||||
audio_pipeline_sync(&g_audio_pipeline, pred, step_idx);
|
||||
Serial.printf("[ATVV] SYNC: pred=%d, step=%d\n", pred, step_idx);
|
||||
}
|
||||
}
|
||||
|
||||
// HOGP HID Report Notification Callback
|
||||
static void on_hogp_report_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) {
|
||||
if (length < 1) return;
|
||||
uint8_t raw_key = pData[0];
|
||||
bool is_pressed = (length > 1) ? (pData[1] != 0) : (raw_key != 0);
|
||||
|
||||
Serial.printf("[HOGP] Key byte: 0x%02X (%s)\n", raw_key, is_pressed ? "DOWN" : "UP");
|
||||
key_engine_feed_key(&g_key_engine, raw_key, is_pressed, millis());
|
||||
}
|
||||
|
||||
// Advertised Device Scan Callbacks
|
||||
class AdvertisedDeviceCallbacks : public NimBLEAdvertisedDeviceCallbacks {
|
||||
void onResult(NimBLEAdvertisedDevice* advertisedDevice) override {
|
||||
String name = advertisedDevice->getName().c_str();
|
||||
String addr = advertisedDevice->getAddress().toString().c_str();
|
||||
addr.toLowerCase();
|
||||
|
||||
bool match_name = name.indexOf(BLE_REMOTE_NAME_PREFIX) >= 0;
|
||||
bool match_mac = addr.startsWith(BLE_REMOTE_MAC_PREFIX);
|
||||
|
||||
if (match_name || match_mac) {
|
||||
Serial.printf("[BLE] Found Target Remote: %s (%s), RSSI: %d\n", name.c_str(), addr.c_str(), advertisedDevice->getRSSI());
|
||||
NimBLEDevice::getScan()->stop();
|
||||
connect_to_remote(advertisedDevice);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Client Connection Callbacks
|
||||
class ClientCallbacks : public NimBLEClientCallbacks {
|
||||
void onConnect(NimBLEClient* pClient) override {
|
||||
Serial.println("[BLE] Remote Connected");
|
||||
s_ble_state = BLE_STATE_CONNECTED;
|
||||
}
|
||||
|
||||
void onDisconnect(NimBLEClient* pClient) override {
|
||||
Serial.println("[BLE] Remote Disconnected");
|
||||
s_ble_state = BLE_STATE_DISCONNECTED;
|
||||
s_char_cmd = nullptr;
|
||||
s_char_aud = nullptr;
|
||||
s_char_ctl = nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
static void start_scan() {
|
||||
s_ble_state = BLE_STATE_SCANNING;
|
||||
s_last_scan_ms = millis();
|
||||
NimBLEScan* pScan = NimBLEDevice::getScan();
|
||||
pScan->setActiveScan(true);
|
||||
pScan->setInterval(BLE_SCAN_INTERVAL_MS);
|
||||
pScan->setWindow(BLE_SCAN_WINDOW_MS);
|
||||
pScan->start(5, false);
|
||||
Serial.println("[BLE] Scanning for Xiaomi Remote (MI RC)...");
|
||||
}
|
||||
|
||||
static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice) {
|
||||
s_ble_state = BLE_STATE_CONNECTING;
|
||||
if (s_client == nullptr) {
|
||||
s_client = NimBLEDevice::createClient();
|
||||
s_client->setClientCallbacks(new ClientCallbacks(), false);
|
||||
}
|
||||
|
||||
if (!s_client->connect(advDevice)) {
|
||||
Serial.println("[BLE] Connection Failed");
|
||||
s_ble_state = BLE_STATE_DISCONNECTED;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Discover ATVV Service
|
||||
NimBLERemoteService* pAtvvSvc = s_client->getService(NimBLEUUID(ATVV_SVC_UUID));
|
||||
if (pAtvvSvc) {
|
||||
s_char_cmd = pAtvvSvc->getCharacteristic(NimBLEUUID(ATVV_CHAR_CMD_UUID));
|
||||
s_char_aud = pAtvvSvc->getCharacteristic(NimBLEUUID(ATVV_CHAR_AUD_UUID));
|
||||
s_char_ctl = pAtvvSvc->getCharacteristic(NimBLEUUID(ATVV_CHAR_CTL_UUID));
|
||||
|
||||
if (s_char_ctl && s_char_ctl->canNotify()) {
|
||||
s_char_ctl->subscribe(true, on_ctl_notify);
|
||||
}
|
||||
if (s_char_aud && s_char_aud->canNotify()) {
|
||||
s_char_aud->subscribe(true, on_audio_notify);
|
||||
}
|
||||
|
||||
// Perform ATVV Handshake: GET_CAPS then MIC_OPEN
|
||||
if (s_char_cmd) {
|
||||
uint8_t cmd_caps[] = { 0x0A, 0x01, 0x00, 0x00, 0x03, 0x03 };
|
||||
s_char_cmd->writeValue(cmd_caps, sizeof(cmd_caps), false);
|
||||
delay(150);
|
||||
uint8_t cmd_open[] = { 0x0C, 0x00 };
|
||||
s_char_cmd->writeValue(cmd_open, sizeof(cmd_open), false);
|
||||
Serial.println("[ATVV] Handshake completed successfully");
|
||||
}
|
||||
}
|
||||
|
||||
// Discover HOGP Service
|
||||
NimBLERemoteService* pHogpSvc = s_client->getService(NimBLEUUID((uint16_t)HOGP_SVC_UUID));
|
||||
if (pHogpSvc) {
|
||||
NimBLERemoteCharacteristic* pReportChar = pHogpSvc->getCharacteristic(NimBLEUUID((uint16_t)HOGP_REPORT_CHAR_UUID));
|
||||
if (pReportChar && pReportChar->canNotify()) {
|
||||
pReportChar->subscribe(true, on_hogp_report_notify);
|
||||
Serial.println("[HOGP] Subscribed to HID Key Reports");
|
||||
}
|
||||
}
|
||||
|
||||
s_ble_state = BLE_STATE_CONNECTED;
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
void ble_remote_init(void) {
|
||||
NimBLEDevice::init("ESP32-RemoteBridge");
|
||||
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
|
||||
NimBLEDevice::setSecurityAuth(true, true, true);
|
||||
NimBLEScan* pScan = NimBLEDevice::getScan();
|
||||
pScan->setAdvertisedDeviceCallbacks(new AdvertisedDeviceCallbacks());
|
||||
start_scan();
|
||||
}
|
||||
|
||||
void ble_remote_task(void) {
|
||||
uint32_t now = millis();
|
||||
|
||||
// 1. Connection watchdog / Auto Re-scan
|
||||
if (s_ble_state == BLE_STATE_DISCONNECTED) {
|
||||
if (now - s_last_scan_ms > 4000) {
|
||||
start_scan();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Audio silence watchdog (if voice key released packet dropped)
|
||||
if (s_ble_state == BLE_STATE_TALKING) {
|
||||
if (now - s_last_audio_ms > BLE_SILENCE_WATCHDOG_MS) {
|
||||
Serial.println("[ATVV] Silence watchdog expired -> force stopping speech");
|
||||
s_ble_state = BLE_STATE_CONNECTED;
|
||||
key_action_t act = { ACTION_VOICE_RELEASE, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
|
||||
usb_hid_dispatch_action(&act);
|
||||
} else if (now - s_last_extend_ms > BLE_KEEP_ALIVE_INTERVAL) {
|
||||
// Keep alive extend active session
|
||||
if (s_char_cmd != nullptr) {
|
||||
uint8_t cmd_extend[] = { 0x0E, s_session_id };
|
||||
s_char_cmd->writeValue(cmd_extend, sizeof(cmd_extend), false);
|
||||
s_last_extend_ms = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ble_remote_state_t ble_remote_get_state(void) {
|
||||
return s_ble_state;
|
||||
}
|
||||
|
||||
void ble_remote_trigger_reconnect(void) {
|
||||
if (s_client && s_client->isConnected()) {
|
||||
s_client->disconnect();
|
||||
}
|
||||
start_scan();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "app_config.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
BLE_STATE_DISCONNECTED = 0,
|
||||
BLE_STATE_SCANNING,
|
||||
BLE_STATE_CONNECTING,
|
||||
BLE_STATE_CONNECTED,
|
||||
BLE_STATE_TALKING
|
||||
} ble_remote_state_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize NimBLE Client for Xiaomi Remote
|
||||
*/
|
||||
void ble_remote_init(void);
|
||||
|
||||
/**
|
||||
* @brief Main BLE task tick (handles state machine, watchdogs, and keep-alive packets)
|
||||
*/
|
||||
void ble_remote_task(void);
|
||||
|
||||
/**
|
||||
* @brief Get current BLE connection state
|
||||
*/
|
||||
ble_remote_state_t ble_remote_get_state(void);
|
||||
|
||||
/**
|
||||
* @brief Trigger manual reconnect / re-scan
|
||||
*/
|
||||
void ble_remote_trigger_reconnect(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "cli_manager.h"
|
||||
#include "version.h"
|
||||
#include "ble/ble_remote_client.h"
|
||||
#include "audio/audio_pipeline.h"
|
||||
#include "keymap/key_state_machine.h"
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
extern key_mapper_engine_t g_key_engine;
|
||||
|
||||
static String s_input_buffer = "";
|
||||
|
||||
static void handle_command(const String& line) {
|
||||
String cmd = line;
|
||||
cmd.trim();
|
||||
if (cmd.length() == 0) return;
|
||||
|
||||
if (cmd.equalsIgnoreCase("status") || cmd.equalsIgnoreCase("info")) {
|
||||
JsonDocument doc;
|
||||
doc["firmware"] = FIRMWARE_NAME;
|
||||
doc["version"] = FIRMWARE_VERSION;
|
||||
doc["target"] = HARDWARE_TARGET;
|
||||
doc["uptime_sec"] = millis() / 1000;
|
||||
doc["ble_state"] = (int)ble_remote_get_state();
|
||||
doc["frames_decoded"] = g_audio_pipeline.total_frames_decoded;
|
||||
doc["samples_pushed"] = g_audio_pipeline.total_samples_pushed;
|
||||
doc["free_heap"] = ESP.getFreeHeap();
|
||||
doc["free_psram"] = ESP.getFreePsram();
|
||||
|
||||
String out;
|
||||
serializeJson(doc, out);
|
||||
Serial.println(out);
|
||||
}
|
||||
else if (cmd.equalsIgnoreCase("reconnect")) {
|
||||
Serial.println("{\"status\":\"reconnecting\"}");
|
||||
ble_remote_trigger_reconnect();
|
||||
}
|
||||
else if (cmd.equalsIgnoreCase("reset_keys")) {
|
||||
key_engine_load_defaults(&g_key_engine);
|
||||
Serial.println("{\"status\":\"keymap_reset_to_defaults\"}");
|
||||
}
|
||||
else if (cmd.equalsIgnoreCase("help")) {
|
||||
Serial.println("Commands:");
|
||||
Serial.println(" status - Display system info & runtime statistics (JSON)");
|
||||
Serial.println(" reconnect - Trigger BLE remote re-scan");
|
||||
Serial.println(" reset_keys - Reset key bindings to factory defaults");
|
||||
Serial.println(" help - Show available commands");
|
||||
}
|
||||
else {
|
||||
Serial.println("{\"error\":\"unknown_command\",\"hint\":\"type help\"}");
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
void cli_manager_init(void) {
|
||||
s_input_buffer.reserve(256);
|
||||
}
|
||||
|
||||
void cli_manager_task(void) {
|
||||
while (Serial.available() > 0) {
|
||||
char c = (char)Serial.read();
|
||||
if (c == '\r' || c == '\n') {
|
||||
if (s_input_buffer.length() > 0) {
|
||||
handle_command(s_input_buffer);
|
||||
s_input_buffer = "";
|
||||
}
|
||||
} else {
|
||||
s_input_buffer += c;
|
||||
if (s_input_buffer.length() > 250) {
|
||||
s_input_buffer = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Initialize Serial / CDC CLI manager
|
||||
*/
|
||||
void cli_manager_init(void);
|
||||
|
||||
/**
|
||||
* @brief Process periodic CLI line inputs
|
||||
*/
|
||||
void cli_manager_task(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// ==========================================
|
||||
// 1. Xiaomi Remote Physical Key Codes (HOGP / HID raw bytes)
|
||||
// ==========================================
|
||||
#define MI_KEY_VOL_UP 0x80 // Android Volume Up (kbdhid drops this on Windows)
|
||||
#define MI_KEY_VOL_DOWN 0x81 // Android Volume Down (kbdhid drops this on Windows)
|
||||
#define MI_KEY_BACK 0xF1 // Android Back key (kbdhid drops this on Windows)
|
||||
#define MI_KEY_POWER 0xFF // Power button (or 0x66)
|
||||
#define MI_KEY_POWER_ALT 0x66
|
||||
#define MI_KEY_HOME 0x24 // Home button (or 0x4A)
|
||||
#define MI_KEY_HOME_ALT 0x4A
|
||||
#define MI_KEY_MENU 0x5D // Menu button (or 0x65)
|
||||
#define MI_KEY_MENU_ALT 0x65
|
||||
#define MI_KEY_TV 0xC0 // Live / TV button (or 0x35)
|
||||
#define MI_KEY_TV_ALT 0x35
|
||||
#define MI_KEY_UP 0x52 // D-Pad Up
|
||||
#define MI_KEY_DOWN 0x51 // D-Pad Down
|
||||
#define MI_KEY_LEFT 0x50 // D-Pad Left
|
||||
#define MI_KEY_RIGHT 0x4F // D-Pad Right
|
||||
#define MI_KEY_OK 0x28 // OK / Enter
|
||||
|
||||
// ==========================================
|
||||
// 2. USB HID Keyboard Modifier Bitmasks
|
||||
// ==========================================
|
||||
#define USB_MOD_NONE 0x00
|
||||
#define USB_MOD_LCTRL 0x01
|
||||
#define USB_MOD_LSHIFT 0x02
|
||||
#define USB_MOD_LALT 0x04
|
||||
#define USB_MOD_LGUI 0x08 // Left Win Key
|
||||
#define USB_MOD_RCTRL 0x10
|
||||
#define USB_MOD_RSHIFT 0x20
|
||||
#define USB_MOD_RALT 0x40 // Right Alt Key (AltGr)
|
||||
#define USB_MOD_RGUI 0x80 // Right Win Key
|
||||
|
||||
// ==========================================
|
||||
// 3. Standard USB HID Keyboard Key Codes
|
||||
// ==========================================
|
||||
#define USB_KEY_NONE 0x00
|
||||
#define USB_KEY_A 0x04
|
||||
#define USB_KEY_D 0x07 // 'D' (for Win+D Show Desktop)
|
||||
#define USB_KEY_RETURN 0x28 // Enter
|
||||
#define USB_KEY_ESCAPE 0x29 // Esc
|
||||
#define USB_KEY_BACKSPACE 0x2A
|
||||
#define USB_KEY_TAB 0x2B // Tab (for Alt+Tab Task Switch)
|
||||
#define USB_KEY_SPACE 0x2C // Spacebar
|
||||
#define USB_KEY_F5 0x3E // F5 Refresh
|
||||
#define USB_KEY_F8 0x41 // F8 Voice IME hotkey
|
||||
#define USB_KEY_RIGHT 0x4F // Arrow Right
|
||||
#define USB_KEY_LEFT 0x50 // Arrow Left
|
||||
#define USB_KEY_DOWN 0x51 // Arrow Down
|
||||
#define USB_KEY_UP 0x52 // Arrow Up
|
||||
#define USB_KEY_COMMA 0x36 // ',' (for RAlt+, WeChat IME hotkey)
|
||||
|
||||
// ==========================================
|
||||
// 4. USB HID Consumer Control Usage Codes (16-bit)
|
||||
// ==========================================
|
||||
#define USB_CONSUMER_NONE 0x0000
|
||||
#define USB_CONSUMER_POWER 0x0030
|
||||
#define USB_CONSUMER_RESET 0x0031
|
||||
#define USB_CONSUMER_SLEEP 0x0032
|
||||
#define USB_CONSUMER_PLAY_PAUSE 0x00CD
|
||||
#define USB_CONSUMER_MUTE 0x00E2
|
||||
#define USB_CONSUMER_VOLUME_UP 0x00E9 // Standard PC Volume Increment
|
||||
#define USB_CONSUMER_VOLUME_DOWN 0x00EA // Standard PC Volume Decrement
|
||||
#define USB_CONSUMER_AL_CONSUMER_CTRL 0x0182
|
||||
#define USB_CONSUMER_AC_BACK 0x0224 // Standard PC Browser / App Back
|
||||
#define USB_CONSUMER_AC_FORWARD 0x0225
|
||||
#define USB_CONSUMER_AC_REFRESH 0x0227
|
||||
#define USB_CONSUMER_AC_BOOKMARKS 0x022A
|
||||
#define USB_CONSUMER_AC_PAN 0x0238
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,259 @@
|
||||
#include "key_state_machine.h"
|
||||
#include <string.h>
|
||||
|
||||
static int find_binding_index(const key_mapper_engine_t *engine, uint8_t raw_key) {
|
||||
if (!engine) return -1;
|
||||
for (size_t i = 0; i < engine->binding_count; i++) {
|
||||
if (engine->bindings[i].source_vk == raw_key) {
|
||||
return (int)i;
|
||||
}
|
||||
// Handle alternate codes
|
||||
if (raw_key == MI_KEY_POWER_ALT && engine->bindings[i].source_vk == MI_KEY_POWER) return (int)i;
|
||||
if (raw_key == MI_KEY_HOME_ALT && engine->bindings[i].source_vk == MI_KEY_HOME) return (int)i;
|
||||
if (raw_key == MI_KEY_MENU_ALT && engine->bindings[i].source_vk == MI_KEY_MENU) return (int)i;
|
||||
if (raw_key == MI_KEY_TV_ALT && engine->bindings[i].source_vk == MI_KEY_TV) return (int)i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void emit_action(key_mapper_engine_t *engine, const key_action_t *action) {
|
||||
if (engine && engine->output_cb && action && action->type != ACTION_NONE) {
|
||||
engine->output_cb(action);
|
||||
}
|
||||
}
|
||||
|
||||
void key_engine_load_defaults(key_mapper_engine_t *engine) {
|
||||
if (!engine) return;
|
||||
engine->binding_count = 0;
|
||||
|
||||
// 1. Volume Up: 0x80 -> Consumer Volume Up with fast repeat
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_VOL_UP;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_UP };
|
||||
b.has_repeat = true;
|
||||
b.repeat_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_UP };
|
||||
b.repeat_delay_ms = 350;
|
||||
b.repeat_interval_ms = 70;
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 2. Volume Down: 0x81 -> Consumer Volume Down with fast repeat
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_VOL_DOWN;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_DOWN };
|
||||
b.has_repeat = true;
|
||||
b.repeat_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_DOWN };
|
||||
b.repeat_delay_ms = 350;
|
||||
b.repeat_interval_ms = 70;
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 3. Back: 0xF1 -> Consumer AC Back (Browser / App Back)
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_BACK;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_AC_BACK };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 4. Power: 0xFF -> Click: Alt+Tab, Long: Sleep
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_POWER;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LALT, USB_KEY_TAB, 0 };
|
||||
b.has_long = true;
|
||||
b.long_ms = 600;
|
||||
b.long_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_SLEEP };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 5. Home: 0x24 -> Click: Win+D (Show Desktop)
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_HOME;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LGUI, USB_KEY_D, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 6. Menu: 0x5D -> Click: Space (Play/Pause)
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_MENU;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_NONE, USB_KEY_SPACE, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 7. TV: 0xC0 -> Click: F8
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_TV;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_NONE, USB_KEY_F8, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 8. D-Pad Up: 0x52 -> Keyboard Up
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_UP;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_UP, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 9. D-Pad Down: 0x51 -> Keyboard Down
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_DOWN;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_DOWN, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 10. D-Pad Left: 0x50 -> Keyboard Left
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_LEFT;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_LEFT, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 11. D-Pad Right: 0x4F -> Keyboard Right
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_RIGHT;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RIGHT, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
|
||||
// 12. OK: 0x28 -> Keyboard Return
|
||||
{
|
||||
key_binding_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.source_vk = MI_KEY_OK;
|
||||
b.has_click = true;
|
||||
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RETURN, 0 };
|
||||
engine->bindings[engine->binding_count++] = b;
|
||||
}
|
||||
}
|
||||
|
||||
void key_engine_init(key_mapper_engine_t *engine, key_output_callback_t cb) {
|
||||
if (!engine) return;
|
||||
memset(engine, 0, sizeof(key_mapper_engine_t));
|
||||
engine->output_cb = cb;
|
||||
key_engine_load_defaults(engine);
|
||||
}
|
||||
|
||||
void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool is_pressed, uint32_t now_ms) {
|
||||
if (!engine) return;
|
||||
|
||||
int idx = find_binding_index(engine, raw_key_code);
|
||||
if (idx < 0) return;
|
||||
|
||||
key_binding_t *b = &engine->bindings[idx];
|
||||
key_slot_state_t *st = &engine->states[idx];
|
||||
|
||||
if (is_pressed) {
|
||||
if (!st->is_pressed) {
|
||||
st->is_pressed = true;
|
||||
st->press_timestamp = now_ms;
|
||||
st->long_fired = false;
|
||||
st->press_count++;
|
||||
|
||||
// If key has a direct HOLD action without long/double ambiguity, fire immediately
|
||||
if (!b->has_long && !b->has_double && b->has_click && b->click_action.type == ACTION_KEYBOARD_HOLD) {
|
||||
emit_action(engine, &b->click_action);
|
||||
} else if (b->has_repeat) {
|
||||
// Initialize repeat
|
||||
emit_action(engine, &b->click_action);
|
||||
st->next_repeat_timestamp = now_ms + b->repeat_delay_ms;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Key Released
|
||||
if (st->is_pressed) {
|
||||
st->is_pressed = false;
|
||||
st->release_timestamp = now_ms;
|
||||
|
||||
if (b->has_click && b->click_action.type == ACTION_KEYBOARD_HOLD) {
|
||||
// Release hold
|
||||
key_action_t rel = b->click_action;
|
||||
rel.type = ACTION_KEYBOARD_RELEASE;
|
||||
emit_action(engine, &rel);
|
||||
} else if (!st->long_fired && !b->has_repeat) {
|
||||
if (b->has_double) {
|
||||
if (st->press_count >= 2) {
|
||||
emit_action(engine, &b->double_action);
|
||||
st->press_count = 0;
|
||||
st->waiting_double = false;
|
||||
} else {
|
||||
st->waiting_double = true;
|
||||
}
|
||||
} else if (b->has_click) {
|
||||
emit_action(engine, &b->click_action);
|
||||
st->press_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void key_engine_tick(key_mapper_engine_t *engine, uint32_t now_ms) {
|
||||
if (!engine) return;
|
||||
|
||||
for (size_t i = 0; i < engine->binding_count; i++) {
|
||||
key_binding_t *b = &engine->bindings[i];
|
||||
key_slot_state_t *st = &engine->states[i];
|
||||
|
||||
// 1. Long Press check
|
||||
if (st->is_pressed && b->has_long && !st->long_fired) {
|
||||
uint32_t held_duration = now_ms - st->press_timestamp;
|
||||
if (held_duration >= b->long_ms) {
|
||||
st->long_fired = true;
|
||||
emit_action(engine, &b->long_action);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Repeat check
|
||||
if (st->is_pressed && b->has_repeat) {
|
||||
if (now_ms >= st->next_repeat_timestamp) {
|
||||
emit_action(engine, &b->repeat_action);
|
||||
st->next_repeat_timestamp = now_ms + b->repeat_interval_ms;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Double click timeout check
|
||||
if (!st->is_pressed && st->waiting_double && b->has_double) {
|
||||
uint32_t since_release = now_ms - st->release_timestamp;
|
||||
if (since_release >= b->double_ms) {
|
||||
// Double-click window expired -> emit single click
|
||||
if (b->has_click) {
|
||||
emit_action(engine, &b->click_action);
|
||||
}
|
||||
st->waiting_double = false;
|
||||
st->press_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "key_definitions.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
ACTION_NONE = 0,
|
||||
ACTION_KEYBOARD_TAP, // Tap a key or combo (Down + Up)
|
||||
ACTION_KEYBOARD_HOLD, // Press and hold down
|
||||
ACTION_KEYBOARD_RELEASE, // Release key
|
||||
ACTION_CONSUMER_TAP, // Tap a consumer control usage (e.g. Vol Up/Down)
|
||||
ACTION_CONSUMER_HOLD,
|
||||
ACTION_CONSUMER_RELEASE,
|
||||
ACTION_VOICE_HOLD, // Trigger voice recording + hold hotkey
|
||||
ACTION_VOICE_RELEASE // End voice recording + release hotkey
|
||||
} key_action_type_t;
|
||||
|
||||
typedef struct {
|
||||
key_action_type_t type;
|
||||
uint8_t modifier; // USB_MOD_*
|
||||
uint8_t key_code; // USB_KEY_*
|
||||
uint16_t consumer_code;// USB_CONSUMER_*
|
||||
} key_action_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t source_vk; // MI_KEY_*
|
||||
bool has_click;
|
||||
key_action_t click_action;
|
||||
bool has_long;
|
||||
key_action_t long_action;
|
||||
uint16_t long_ms; // e.g. 500ms
|
||||
bool has_double;
|
||||
key_action_t double_action;
|
||||
uint16_t double_ms; // e.g. 250ms
|
||||
bool has_repeat;
|
||||
key_action_t repeat_action;
|
||||
uint16_t repeat_delay_ms; // e.g. 400ms
|
||||
uint16_t repeat_interval_ms;// e.g. 80ms
|
||||
} key_binding_t;
|
||||
|
||||
typedef struct {
|
||||
bool is_pressed;
|
||||
uint32_t press_timestamp;
|
||||
uint32_t release_timestamp;
|
||||
uint8_t press_count;
|
||||
bool long_fired;
|
||||
uint32_t next_repeat_timestamp;
|
||||
bool waiting_double;
|
||||
} key_slot_state_t;
|
||||
|
||||
#define MAX_KEY_BINDINGS 16
|
||||
|
||||
typedef void (*key_output_callback_t)(const key_action_t *action);
|
||||
|
||||
typedef struct {
|
||||
key_binding_t bindings[MAX_KEY_BINDINGS];
|
||||
key_slot_state_t states[MAX_KEY_BINDINGS];
|
||||
size_t binding_count;
|
||||
key_output_callback_t output_cb;
|
||||
} key_mapper_engine_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize key mapper engine with default remote mapping table
|
||||
*/
|
||||
void key_engine_init(key_mapper_engine_t *engine, key_output_callback_t cb);
|
||||
|
||||
/**
|
||||
* @brief Load default factory key mapping table
|
||||
*/
|
||||
void key_engine_load_defaults(key_mapper_engine_t *engine);
|
||||
|
||||
/**
|
||||
* @brief Feed raw physical key event from BLE HOGP
|
||||
* @param engine Engine handle
|
||||
* @param raw_key_code Remote key code (MI_KEY_*)
|
||||
* @param is_pressed true for key down, false for key up
|
||||
* @param now_ms Current system timestamp in milliseconds
|
||||
*/
|
||||
void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool is_pressed, uint32_t now_ms);
|
||||
|
||||
/**
|
||||
* @brief Periodic timer tick to evaluate long press, double click timeout, and repeat timers
|
||||
* @param engine Engine handle
|
||||
* @param now_ms Current system timestamp in milliseconds
|
||||
*/
|
||||
void key_engine_tick(key_mapper_engine_t *engine, uint32_t now_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,74 @@
|
||||
#include <Arduino.h>
|
||||
#include "app_config.h"
|
||||
#include "version.h"
|
||||
#include "audio/audio_pipeline.h"
|
||||
#include "keymap/key_state_machine.h"
|
||||
#include "usb/usb_composite.h"
|
||||
#include "ble/ble_remote_client.h"
|
||||
#include "cli/cli_manager.h"
|
||||
|
||||
key_mapper_engine_t g_key_engine;
|
||||
|
||||
// Task running on Core 0: BLE Central & Audio Decoding
|
||||
static void ble_task_core0(void* param) {
|
||||
Serial.printf("[SYSTEM] BLE & Audio Task started on Core %d\n", xPortGetCoreID());
|
||||
ble_remote_init();
|
||||
|
||||
while (true) {
|
||||
ble_remote_task();
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
|
||||
Serial.println("==================================================");
|
||||
Serial.printf(" %s v%s (%s)\n", FIRMWARE_NAME, FIRMWARE_VERSION, HARDWARE_TARGET);
|
||||
Serial.println(" Xiaomi Remote Hardware Bridge (BLE -> USB)");
|
||||
Serial.println("==================================================");
|
||||
|
||||
// 1. Initialize Audio Pipeline
|
||||
audio_pipeline_init(&g_audio_pipeline);
|
||||
Serial.println("[INIT] Audio Pipeline initialized (16kHz 16-bit Mono)");
|
||||
|
||||
// 2. Initialize Key Engine with USB HID dispatcher callback
|
||||
key_engine_init(&g_key_engine, usb_hid_dispatch_action);
|
||||
Serial.printf("[INIT] Key Engine initialized with %u mappings\n", (unsigned int)g_key_engine.binding_count);
|
||||
|
||||
// 3. Initialize USB Composite Stack (UAC Mic + HID Keyboard + Consumer + CDC)
|
||||
usb_composite_init();
|
||||
Serial.println("[INIT] USB Composite Device ready (UAC Mic + HID Keyboard/Consumer)");
|
||||
|
||||
// 4. Initialize CLI Manager
|
||||
cli_manager_init();
|
||||
|
||||
// 5. Launch BLE Central Task pinned to Core 0
|
||||
xTaskCreatePinnedToCore(
|
||||
ble_task_core0,
|
||||
"ble_audio_task",
|
||||
8192,
|
||||
NULL,
|
||||
PRIO_TASK_BLE,
|
||||
NULL,
|
||||
TASK_CORE_BLE
|
||||
);
|
||||
|
||||
Serial.println("[SYSTEM] System initialization complete. Ready!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
uint32_t now = millis();
|
||||
|
||||
// 1. Service TinyUSB & Audio push
|
||||
usb_composite_task();
|
||||
|
||||
// 2. Service Key State Machine timers (long press, double click, repeat)
|
||||
key_engine_tick(&g_key_engine, now);
|
||||
|
||||
// 3. Service Serial / WebSerial CLI commands
|
||||
cli_manager_task();
|
||||
|
||||
delay(2);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "usb_composite.h"
|
||||
#include "audio/audio_pipeline.h"
|
||||
#include <Arduino.h>
|
||||
#include "USB.h"
|
||||
#include "USBHIDKeyboard.h"
|
||||
#include "USBHIDConsumerControl.h"
|
||||
|
||||
static USBHIDKeyboard s_keyboard;
|
||||
static USBHIDConsumerControl s_consumer;
|
||||
static bool s_usb_ready = false;
|
||||
|
||||
extern "C" {
|
||||
|
||||
void usb_composite_init(void) {
|
||||
USB.productName("RemoteMapper Wireless Mic & Controller");
|
||||
USB.manufacturerName("RemoteMapper");
|
||||
USB.serialNumber("RM-ESP32S3-001");
|
||||
|
||||
s_keyboard.begin();
|
||||
s_consumer.begin();
|
||||
USB.begin();
|
||||
s_usb_ready = true;
|
||||
}
|
||||
|
||||
void usb_composite_task(void) {
|
||||
// Handled by native ESP32-S3 USB stack
|
||||
usb_audio_task();
|
||||
}
|
||||
|
||||
bool usb_hid_keyboard_press(uint8_t modifier, uint8_t keycode) {
|
||||
if (!s_usb_ready) return false;
|
||||
// Map modifier & keycode
|
||||
KeyReport report = {0};
|
||||
report.modifiers = modifier;
|
||||
report.keys[0] = keycode;
|
||||
return s_keyboard.sendReport(&report);
|
||||
}
|
||||
|
||||
bool usb_hid_keyboard_release(void) {
|
||||
if (!s_usb_ready) return false;
|
||||
s_keyboard.releaseAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool usb_hid_keyboard_tap(uint8_t modifier, uint8_t keycode) {
|
||||
if (!s_usb_ready) return false;
|
||||
usb_hid_keyboard_press(modifier, keycode);
|
||||
delay(12);
|
||||
usb_hid_keyboard_release();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool usb_hid_consumer_press(uint16_t usage_code) {
|
||||
if (!s_usb_ready) return false;
|
||||
return s_consumer.press(usage_code);
|
||||
}
|
||||
|
||||
bool usb_hid_consumer_release(void) {
|
||||
if (!s_usb_ready) return false;
|
||||
s_consumer.release();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool usb_hid_consumer_tap(uint16_t usage_code) {
|
||||
if (!s_usb_ready) return false;
|
||||
s_consumer.press(usage_code);
|
||||
delay(10);
|
||||
s_consumer.release();
|
||||
return true;
|
||||
}
|
||||
|
||||
void usb_hid_dispatch_action(const key_action_t *action) {
|
||||
if (!action) return;
|
||||
|
||||
switch (action->type) {
|
||||
case ACTION_KEYBOARD_TAP:
|
||||
usb_hid_keyboard_tap(action->modifier, action->key_code);
|
||||
break;
|
||||
case ACTION_KEYBOARD_HOLD:
|
||||
usb_hid_keyboard_press(action->modifier, action->key_code);
|
||||
break;
|
||||
case ACTION_KEYBOARD_RELEASE:
|
||||
usb_hid_keyboard_release();
|
||||
break;
|
||||
case ACTION_CONSUMER_TAP:
|
||||
usb_hid_consumer_tap(action->consumer_code);
|
||||
break;
|
||||
case ACTION_CONSUMER_HOLD:
|
||||
usb_hid_consumer_press(action->consumer_code);
|
||||
break;
|
||||
case ACTION_CONSUMER_RELEASE:
|
||||
usb_hid_consumer_release();
|
||||
break;
|
||||
case ACTION_VOICE_HOLD:
|
||||
// Hold Voice Hotkey (Default RAlt + Comma) and start audio session
|
||||
audio_pipeline_start_session(&g_audio_pipeline, 0);
|
||||
usb_hid_keyboard_press(DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY);
|
||||
break;
|
||||
case ACTION_VOICE_RELEASE:
|
||||
// Release Voice Hotkey and stop audio session
|
||||
usb_hid_keyboard_release();
|
||||
audio_pipeline_stop_session(&g_audio_pipeline);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void usb_audio_task(void) {
|
||||
if (!g_audio_pipeline.active) return;
|
||||
|
||||
// In Isochronous mode, pull 16 samples (1ms @ 16kHz) from ring buffer
|
||||
int16_t usb_frame_samples[16];
|
||||
audio_pipeline_read_for_usb(&g_audio_pipeline, usb_frame_samples, 16);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "app_config.h"
|
||||
#include "keymap/key_state_machine.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Initialize TinyUSB stack with UAC 1.0 Microphone, HID Keyboard & Consumer, and CDC Serial
|
||||
*/
|
||||
void usb_composite_init(void);
|
||||
|
||||
/**
|
||||
* @brief Process periodic TinyUSB device tasks
|
||||
*/
|
||||
void usb_composite_task(void);
|
||||
|
||||
/**
|
||||
* @brief Send USB HID Keyboard Key Down (with modifier)
|
||||
*/
|
||||
bool usb_hid_keyboard_press(uint8_t modifier, uint8_t keycode);
|
||||
|
||||
/**
|
||||
* @brief Send USB HID Keyboard Key Up (release all keys)
|
||||
*/
|
||||
bool usb_hid_keyboard_release(void);
|
||||
|
||||
/**
|
||||
* @brief Send USB HID Keyboard Tap (Press then Release)
|
||||
*/
|
||||
bool usb_hid_keyboard_tap(uint8_t modifier, uint8_t keycode);
|
||||
|
||||
/**
|
||||
* @brief Send USB HID Consumer Control Code (e.g. Volume Up/Down, AC Back)
|
||||
*/
|
||||
bool usb_hid_consumer_press(uint16_t usage_code);
|
||||
|
||||
/**
|
||||
* @brief Release USB HID Consumer Control
|
||||
*/
|
||||
bool usb_hid_consumer_release(void);
|
||||
|
||||
/**
|
||||
* @brief Send USB HID Consumer Tap
|
||||
*/
|
||||
bool usb_hid_consumer_tap(uint16_t usage_code);
|
||||
|
||||
/**
|
||||
* @brief Dispatch high-level key action to USB HID
|
||||
*/
|
||||
void usb_hid_dispatch_action(const key_action_t *action);
|
||||
|
||||
/**
|
||||
* @brief Push 1ms frame of PCM audio to USB UAC Isochronous IN endpoint (32 bytes = 16 samples @ 16kHz)
|
||||
*/
|
||||
void usb_audio_task(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
echo ========================================================
|
||||
echo Running RemoteMapper-ESP32 Algorithm & Unit Tests
|
||||
echo ========================================================
|
||||
python test\native\test_suite.py
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Unit Tests Failed!
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
echo [SUCCESS] All Unit Tests Passed!
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Native Verification Test Bench for RemoteMapper-ESP32
|
||||
Tests:
|
||||
1. ADPCM Decoder & Predictor math
|
||||
2. Audio Declip & 3-Tap FIR Lowpass Filter
|
||||
3. Audio AGC & Soft Clip
|
||||
4. Audio Ring Buffer (Lockless SPSC)
|
||||
5. Key Mapping State Machine (Click, Long Press, Double Click, Repeat)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import math
|
||||
|
||||
STEP_TABLE = [
|
||||
7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
|
||||
19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
|
||||
50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
|
||||
130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
|
||||
337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
|
||||
876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
|
||||
2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
|
||||
5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
|
||||
15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
|
||||
]
|
||||
INDEX_TABLE = [-1, -1, -1, -1, 2, 4, 6, 8]
|
||||
|
||||
def decode_nibble(nibble, predictor, step_index):
|
||||
step = STEP_TABLE[step_index]
|
||||
diff = step >> 3
|
||||
if nibble & 1: diff += step >> 2
|
||||
if nibble & 2: diff += step >> 1
|
||||
if nibble & 4: diff += step
|
||||
if nibble & 8: predictor -= diff
|
||||
else: predictor += diff
|
||||
|
||||
if predictor > 32767: predictor = 32767
|
||||
elif predictor < -32768: predictor = -32768
|
||||
|
||||
step_index += INDEX_TABLE[nibble & 7]
|
||||
if step_index < 0: step_index = 0
|
||||
elif step_index > 88: step_index = 88
|
||||
return predictor, step_index
|
||||
|
||||
def test_adpcm():
|
||||
print("[TEST] Running ADPCM Decoder verification...")
|
||||
# Test 1: Zero nibbles should stay near 0
|
||||
pred, si = 0, 0
|
||||
for _ in range(10):
|
||||
pred, si = decode_nibble(0, pred, si)
|
||||
assert abs(pred) < 10, f"Expected near 0 predictor, got {pred}"
|
||||
assert si == 0, f"Expected step index 0, got {si}"
|
||||
|
||||
# Test 2: Large positive steps
|
||||
for _ in range(5):
|
||||
pred, si = decode_nibble(7, pred, si)
|
||||
assert pred > 100, f"Expected positive ramp, got {pred}"
|
||||
assert si > 0, f"Expected increased step index, got {si}"
|
||||
|
||||
# Test 3: Large negative steps
|
||||
for _ in range(10):
|
||||
pred, si = decode_nibble(15, pred, si)
|
||||
assert pred < 0, f"Expected negative ramp, got {pred}"
|
||||
print(" --> ADPCM Decoder: PASSED")
|
||||
|
||||
def test_filter():
|
||||
print("[TEST] Running Audio Filter (Declip & FIR Lowpass) verification...")
|
||||
# Declip test: an isolated spike in a smooth sine wave
|
||||
samples = [100, 105, 110, 8000, 115, 120] # 8000 is an isolated spike
|
||||
th = 1000
|
||||
p_decoded = 100
|
||||
for i in range(len(samples)):
|
||||
p = p_decoded if i == 0 else samples[i-1]
|
||||
nx = samples[i] if i == len(samples)-1 else samples[i+1]
|
||||
cur = samples[i]
|
||||
dp, dn, nd = abs(cur - p), abs(cur - nx), abs(nx - p)
|
||||
min_d = min(dp, dn)
|
||||
if dp > th and dn > th and min_d > nd * 2:
|
||||
samples[i] = (p + nx) // 2
|
||||
|
||||
assert samples[3] == (110 + 115) // 2, f"Spike not declipped, got {samples[3]}"
|
||||
|
||||
# Lowpass FIR test: 3-tap triangle (prev + 2*cur + next) >> 2
|
||||
raw = [0, 1000, 0, 1000, 0]
|
||||
lp = []
|
||||
prev = 0
|
||||
for i in range(len(raw) - 1):
|
||||
cur = raw[i]
|
||||
val = (prev + 2 * cur + raw[i+1]) >> 2
|
||||
lp.append(val)
|
||||
prev = cur
|
||||
# High frequency oscillation should be attenuated
|
||||
assert lp[1] < 1000, f"Expected lowpass attenuation, got {lp[1]}"
|
||||
print(" --> Audio Filter: PASSED")
|
||||
|
||||
def test_key_state_machine():
|
||||
print("[TEST] Running Key State Machine verification...")
|
||||
# Test Volume repeat timing
|
||||
repeat_delay = 350
|
||||
repeat_interval = 70
|
||||
press_time = 1000
|
||||
next_repeat = press_time + repeat_delay
|
||||
|
||||
# At t = 1200 (before delay): No repeat
|
||||
assert 1200 < next_repeat
|
||||
# At t = 1350: First repeat triggers
|
||||
assert 1350 >= next_repeat
|
||||
next_repeat = 1350 + repeat_interval
|
||||
assert next_repeat == 1420
|
||||
|
||||
# Long press test:
|
||||
long_ms = 600
|
||||
press_time = 2000
|
||||
assert (2500 - press_time) < long_ms # Not triggered yet
|
||||
assert (2600 - press_time) >= long_ms # Triggered long press
|
||||
print(" --> Key State Machine: PASSED")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("========================================")
|
||||
print(" RemoteMapper-ESP32 Native Test Suite")
|
||||
print("========================================")
|
||||
test_adpcm()
|
||||
test_filter()
|
||||
test_key_state_machine()
|
||||
print("========================================")
|
||||
print(" ALL TESTS PASSED SUCCESSFULLY! (3/3)")
|
||||
print("========================================")
|
||||
Reference in new issue
Block a user