commit 1cca7c4ec35672815d43161a51fddfdcc713dde6 Author: cuicuiV5 Date: Thu Aug 27 20:53:59 2026 +0800 feat: complete initial firmware implementation for ESP32-S3 (N16R8) hardware bridge diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2945aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.pio +.vscode +.idea +*.pyc +__pycache__/ +build/ +*.bin +*.elf +*.map +*.hex +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..3dde096 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..16312c9 --- /dev/null +++ b/build.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! diff --git a/default_16MB.csv b/default_16MB.csv new file mode 100644 index 0000000..a551ef2 --- /dev/null +++ b/default_16MB.csv @@ -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, diff --git a/flash.bat b/flash.bat new file mode 100644 index 0000000..5423844 --- /dev/null +++ b/flash.bat @@ -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! diff --git a/include/app_config.h b/include/app_config.h new file mode 100644 index 0000000..ee5faf2 --- /dev/null +++ b/include/app_config.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +#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 diff --git a/include/version.h b/include/version.h new file mode 100644 index 0000000..c70ae7e --- /dev/null +++ b/include/version.h @@ -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" diff --git a/monitor.bat b/monitor.bat new file mode 100644 index 0000000..be0ed90 --- /dev/null +++ b/monitor.bat @@ -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 diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..3d18c5a --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/src/audio/adpcm_decoder.c b/src/audio/adpcm_decoder.c new file mode 100644 index 0000000..e52a5af --- /dev/null +++ b/src/audio/adpcm_decoder.c @@ -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; +} diff --git a/src/audio/adpcm_decoder.h b/src/audio/adpcm_decoder.h new file mode 100644 index 0000000..5779095 --- /dev/null +++ b/src/audio/adpcm_decoder.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#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 diff --git a/src/audio/audio_agc.c b/src/audio/audio_agc.c new file mode 100644 index 0000000..64c4ecb --- /dev/null +++ b/src/audio/audio_agc.c @@ -0,0 +1,56 @@ +#include "audio_agc.h" +#include + +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; +} diff --git a/src/audio/audio_agc.h b/src/audio/audio_agc.h new file mode 100644 index 0000000..d83bea6 --- /dev/null +++ b/src/audio/audio_agc.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#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 diff --git a/src/audio/audio_filter.c b/src/audio/audio_filter.c new file mode 100644 index 0000000..ccc3713 --- /dev/null +++ b/src/audio/audio_filter.c @@ -0,0 +1,41 @@ +#include "audio_filter.h" +#include + +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]; +} diff --git a/src/audio/audio_filter.h b/src/audio/audio_filter.h new file mode 100644 index 0000000..7e5b825 --- /dev/null +++ b/src/audio/audio_filter.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +#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 diff --git a/src/audio/audio_pipeline.c b/src/audio/audio_pipeline.c new file mode 100644 index 0000000..2e5c662 --- /dev/null +++ b/src/audio/audio_pipeline.c @@ -0,0 +1,82 @@ +#include "audio_pipeline.h" +#include + +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); +} diff --git a/src/audio/audio_pipeline.h b/src/audio/audio_pipeline.h new file mode 100644 index 0000000..2f0bd53 --- /dev/null +++ b/src/audio/audio_pipeline.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#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 diff --git a/src/audio/audio_ring_buffer.c b/src/audio/audio_ring_buffer.c new file mode 100644 index 0000000..f1aafc0 --- /dev/null +++ b/src/audio/audio_ring_buffer.c @@ -0,0 +1,61 @@ +#include "audio_ring_buffer.h" +#include + +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; +} diff --git a/src/audio/audio_ring_buffer.h b/src/audio/audio_ring_buffer.h new file mode 100644 index 0000000..02fa18f --- /dev/null +++ b/src/audio/audio_ring_buffer.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#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 diff --git a/src/ble/ble_remote_client.cpp b/src/ble/ble_remote_client.cpp new file mode 100644 index 0000000..8d3938c --- /dev/null +++ b/src/ble/ble_remote_client.cpp @@ -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 +#include + +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" diff --git a/src/ble/ble_remote_client.h b/src/ble/ble_remote_client.h new file mode 100644 index 0000000..327cf9e --- /dev/null +++ b/src/ble/ble_remote_client.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#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 diff --git a/src/cli/cli_manager.cpp b/src/cli/cli_manager.cpp new file mode 100644 index 0000000..d824412 --- /dev/null +++ b/src/cli/cli_manager.cpp @@ -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 +#include + +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" diff --git a/src/cli/cli_manager.h b/src/cli/cli_manager.h new file mode 100644 index 0000000..baf8b32 --- /dev/null +++ b/src/cli/cli_manager.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#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 diff --git a/src/keymap/key_definitions.h b/src/keymap/key_definitions.h new file mode 100644 index 0000000..3c01391 --- /dev/null +++ b/src/keymap/key_definitions.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +#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 diff --git a/src/keymap/key_state_machine.c b/src/keymap/key_state_machine.c new file mode 100644 index 0000000..7309d63 --- /dev/null +++ b/src/keymap/key_state_machine.c @@ -0,0 +1,259 @@ +#include "key_state_machine.h" +#include + +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; + } + } + } +} diff --git a/src/keymap/key_state_machine.h b/src/keymap/key_state_machine.h new file mode 100644 index 0000000..cd8dc81 --- /dev/null +++ b/src/keymap/key_state_machine.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..5c53408 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,74 @@ +#include +#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); +} diff --git a/src/usb/usb_composite.cpp b/src/usb/usb_composite.cpp new file mode 100644 index 0000000..d8bddb2 --- /dev/null +++ b/src/usb/usb_composite.cpp @@ -0,0 +1,117 @@ +#include "usb_composite.h" +#include "audio/audio_pipeline.h" +#include +#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" diff --git a/src/usb/usb_composite.h b/src/usb/usb_composite.h new file mode 100644 index 0000000..a93b6d1 --- /dev/null +++ b/src/usb/usb_composite.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#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 diff --git a/test.bat b/test.bat new file mode 100644 index 0000000..9492deb --- /dev/null +++ b/test.bat @@ -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! diff --git a/test/native/test_suite.py b/test/native/test_suite.py new file mode 100644 index 0000000..c19ecc2 --- /dev/null +++ b/test/native/test_suite.py @@ -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("========================================")