- 名称: 小米蓝牙语音遥控器
- BLE MAC 前缀: C0:5D:39 (Xiaomi OUI;完整 MAC 属个人设备标识,已从仓库历史中脱敏)
- 适配器: 本机蓝牙适配器 MAC(未公开)
- BLE Device Id:
BluetoothLE#BluetoothLE<适配器MAC>-c0:5d:39:<剩余>,按厂商前缀匹配 - HID 层 VID/PID: 2717 / 32B8
| Service UUID | 身份 | 备注 |
|---|---|---|
| 1812 (HID) | 键盘/按键 | Windows HID 栈占用 (AccessDenied)。9 个普通键可见;返回/音量±因非标准 Keyboard Page usage 被 kbdhid.sys 丢弃,最终由设备专属 lower filter 修复 |
| ab5e0001 | ATVV 语音 | 核心目标,完全可访问 |
| 8a7a0001 | 小米私有 | 备用 |
| 180f/180a/1800/1801 | 电池/设备信息/通用访问/通用属性 | 标准 |
| fe59 | Nordic DFU | 固件升级,无关 |
| Char | UUID 末段 | 属性 | 用途 |
|---|---|---|---|
| TX/CMD | ab5e0002 | Write | 主机→遥控器 命令 |
| RX/AUDIO | ab5e0003 | Notify | 遥控器→主机 ADPCM 音频流 |
| CTL | ab5e0004 | Notify | 遥控器→主机 控制信号 |
- 订阅 ab5e0003 + ab5e0004 的 notify
- Write ab5e0002 =
0A 01 00 00 03 03(GET_CAPS v1.0) → 收 CTL:0B 01 00 00 03 00 78 00 00- 版本 1.0
- codecs 0x0003 (支持 8kHz + 16kHz)
- frame_size = 0x0078 = 120 字节
- Write ab5e0002 =
0C 00(MIC_OPEN v1.0) → 收 CTL:04 00 02 00(AUDIO_START, codec=0x02=16kHz ADPCM) - 按住语音键 → 收 CTL
04 03 02 01(AUDIO_START, reason=HTT) → 开始推 120B 音频帧 - 松开 → 收 CTL
00 02(AUDIO_STOP, reason=HTT release) - Write ab5e0002 =
0D(MIC_CLOSE) → 收 CTL00 00
TX (ab5e0002 Write, 主机→遥控器):
- 0x0A GET_CAPS
- 0x0C MIC_OPEN
- 0x0D MIC_CLOSE
- 0x0E MIC_EXTEND (keepalive, v1.0)
CTL (ab5e0004 Notify, 遥控器→主机):
- 0x00 AUDIO_STOP
- 0x04 AUDIO_START
- 0x08 START_SEARCH
- 0x0A AUDIO_SYNC
- 0x0B CAPS_RESP
- 0x0C MIC_OPEN_ERROR
- 编码: IMA/DVI ADPCM, 4 bit/sample
- Nibble 顺序: 高 nibble 在前 (hi-first)
- 采样率: 16 kHz (16k 播放语速音高完全正确;8k 会变慢变低)
- 声道: mono
- 帧长: 120 字节/帧 = 240 samples/帧 ≈ 15ms
- 帧格式: HEADERLESS (无帧头!)
- 不同于标准 ATVV v0.4 的 6 字节头 (seq|pad|pred|step)
- ByteUnique 分析证明: 全部 120 字节多样性一致 (~100 唯一值/40%),无任何常数字节
- 整帧 120 字节全是 ADPCM 数据
- 解码状态: 跨帧连续 (不重置 predictor/step_index)
- 初始 predictor=0, step_index=0
- 正确解码签名: RMS≈349, 削波 0% (错误的重置/skip 解码 RMS≈18400 削波 62%)
- Declip (尖峰替换): 单点尖峰 (邻居差 >1000 且 >2× 邻居间差) → 用邻居均值替换。消除帧边界 click
- Lowpass (3-tap 三角 FIR):
out[i] = (prev + 2*cur + next) >> 4... 实为>>2,即 [0.25,0.5,0.25]。去除高频量化噪声 - 归一化: p95 百分位抗尖峰归一化 (target = 0.5×32767)
- ❌ 标准 ATVV v0.4 6字节头 (pred@3-4BE step@5): ProbeHdr matchErr≈19000(随机水平),ByteUnique 无常数字节 → 此遥控器无标准头
- ❌ per-frame 重置 predictor/step: 产生帧边界 click (70Hz 嗡嗡),RMS 偏高
- ❌ skip N 字节连续解码: 丢失数据+错位;skip5 的 pitch=0.973 是 DC 漂移伪周期,非真语音
- ❌ lo-nibble-first: RMS=1239 (hi=349 的 3.5×),非正确序
- ✅ headerless + 连续状态 + hi-first: 唯一通过 RMS/削波/人耳三重验证
- Phase 1: 握手验证 (GET_CAPS/MIC_OPEN/音频流) ✅
- Phase 2: IMA ADPCM 解码 → 16bit PCM ✅ (headerless 连续解码, CleanDecode.cs)
- Phase 3: 实时解码管道 (BLE notify → ADPCM 解码 → VB-Cable) ✅ (src\RemoteMic.cs)
- Phase 4: 联动语音输入法 (按住录入) ✅
- Phase 5: HID 设备专用键隔离(音量±/返回/主页/菜单/直播/电源 -> F13-F19)✅
- Phase 6: 通用 KeyMapper(配置文件驱动的全局组合键映射)✅
单个程序 RemoteMic.exe 同时做三件事:
- BLE ATVV 连接 — 握手 + CTL 监听 (HTT 按下/松开) + AUDIO notify 实时解码
- 音频推流 — 解码后 PCM → AGC → 写入 VB-CABLE Input (winmm waveOut, 8×双缓冲)
- 热键联动 — 按住语音键 → 注入 [右Alt+逗号] down + 开始推流;松开 → keyup + 停止推流
- IMA ADPCM headerless 连续解码 (hi-nibble-first, predictor/step 跨帧连续)
- Declip (单点尖峰 → 邻居均值, 跨帧连续)
- Lowpass 3-tap
[0.25,0.5,0.25](跨帧连续) - AGC (实时自适应增益): 跟踪峰值
agcPeak, 衰减系数 0.9997, 增益 = min(30, 28000/max(agcPeak,200)), 零削波。实测 RMS=6020 max=28008
- 早期使用
Start-Process -RedirectStandardOutput调试时曾出现 SendInput 无效,因此一度误判为“必须前台运行” - 最终验证:窗口可见性不影响钩子或 SendInput;关键是进程运行在当前交互用户会话,并由内部 pump 线程维护消息循环
- 当前方案:前台调试用
debug.bat;后台常驻用start.vbs隐藏窗口启动,日志由程序自身写入RemoteMic.log,不要用 PowerShell 重定向 stdout 代替
- 怀疑过 WinRT 回调线程上下文问题, 实测可排除
- 最终采用独立 keyworker 线程 (BlockingCollection 传信号), GATT 回调只入队
- 发现工具:
KeySniffer.cs(WH_KEYBOARD_LL 全局钩子) +KeyStateCheck.cs(注入对照) - 现象: 遥控器按键触发注入后 WeType 不弹;但 (1) 物理键 [RAlt+逗号] 正常 (2) 无钩子进程注入正常 (3) 进程内无 F5 流时注入也正常
- 根因 (最终确认): 遥控器语音键被 Windows HID 栈映射为 F5 键 (VK_0x74),按住时持续重发 F5。
WH_KEYBOARD_LL钩子在线时,所有输入(包括 SendInput 注入的键)都会被 marshaling 到安装钩子的线程处理;F5 高频流量拖垮了注入的 RAlt/Comma 时序 → WeType 检测不到干净热键组合 - 解决方案:
KeySim.HoldCombo/ReleaseCombo注入前F5Blocker.Suspend()(卸钩子,跨线程安全)→ 强制释放 F5+所有修饰键 → 注入 RAlt+Comma →F5Blocker.Resume() - ★ 关键坑1: Resume 必须在 pump 线程执行 —
SetWindowsHookEx必须在跑消息泵的线程调用。用PostThreadMessage(pumpTid, WM_APP_REHOOK)让 pump 线程在GetMessage循环里重挂钩子,这样重挂的是活钩子(F5 count 持续增长验证)。在 keyworker 线程挂钩 = 死钩子(count 不涨) - ★ 关键坑2: UnhookWindowsHookEx 跨线程安全 — Suspend 从 keyworker 线程调用
UnhookWindowsHookEx(hhk)是安全的(任何线程都能卸钩子) - 证据: Suspend/Resume 版本 F5 count 持续增长 (1→75→144→260→350) 且 WeType 每次触发;钩子全程在线版 count 也不涨且不触发
- 教训: (1) 任何「注入热键不生效但物理键正常」第一步看设备发了什么 HID (2) WH_KEYBOARD_LL 钩子会改变整个进程的输入处理路径,高频 HID 设备 + 同时注入热键 = 灾难,注入期间必须卸钩子
SendInput+keybd_event双保险- 带 scan code (
KEYEVENTF_SCANCODE) + 扩展键标志 (KEYEVENTF_EXTENDEDKEYfor RAlt) - HoldCombo: 先
F5Blocker.Suspend()卸钩子 → 强制释放 F5+RAlt+逗号 → Send RAlt down → Send 逗号 down →F5Blocker.Resume() - ReleaseCombo: 先 Suspend → Send 逗号 up → Send RAlt up → Resume
- ★ Suspend/Resume 是注入生效的唯一关键(不是强制释放 F5 本身)
- VB-Cable: 播放端
CABLE Input(winmm waveOut), 录音端CABLE Output - 微信输入法 (WeType) 只能用系统默认录音设备
- 自动切换方案 (已实现): 按住语音键 →
DeviceSwitch.SwitchToCable()把系统默认录音切到 CABLE Output;松开 →Restore()切回原设备。通过IPolicyConfigCOM 接口的SetDefaultEndpoint实现 - COM 调用在 STA 线程执行 (OnSta 包装)
- 只在遥控器触发时切换 (ACT_HOLD/RELEASE 走 HTT 通道), 手动操作不受影响
- CLSID
870af99c-171d-4f9e-af0d-e63df40c2bc9(CPolicyConfigClient), 接口f8679f50-850a-41cf-9c72-430f290290c8 - 关键: SetDefaultEndpoint 是第 11 个方法 (前 10 个占位: GetMixFormat...SetPropertyValue)
- llm 给的 21 个占位是错的! (混淆了接口版本) → AccessViolation
- 正确数法见 web-research 结果
new CPolicyConfigClient()然后SetDefaultEndpoint(id, role)三个 role 都调 (eConsole/eMultimedia/eCommunications)- 设备枚举/取名用
IMMDeviceEnumerator+IPropertyStore, friendly name (VT_LPWSTR) 读 offset 8 的 IntPtr, 读后PropVariantClear - COM 调用应在 STA 线程, 本实现用 OnSta 包装 (SetApartmentState + Join)
REMOTEMIC_HOTKEY=1 # 默认开启热键
REMOTEMIC_DUMP=1 # 可选: 保存实时解码 WAV 到 rt_dump_*.wav
REMOTEMIC_KEYDIAG=1 # 可选: 打印注入时前台窗口
RemoteMic.exe # 前台调试用;后台用 start.vbs(隐藏窗口,钩子靠内部 pump 线程,无需可见窗口)build.bat脚本会编译 RemoteMic.cs、KeyMapConfig.cs、KeyMapEngine.cs、KeyMapper.cs 和 KeyComboSender.cs,并写出 RemoteMic.exe。
遥控器把三个特殊键放在 HID Keyboard Page:
音量加 usage 0x80
音量减 usage 0x81
返回 usage 0xF1
Android getevent 能看到它们,但 Windows 用户态的低级键盘钩子、Raw Input、APPCOMMAND 和 HID 设备读取均没有事件。根因是 kbdhid.sys 不为这些 Keyboard Page usage 生成扫描码/VK。
使用只读 preparsed metadata 工具确认:
- Top-level collection:
0x0001/0x0006(Keyboard) InputReportByteLength = 121- Keyboard Report ID:
0x01
KMDF lower filter 的一次性内核诊断确认真实报告为:
方向上 01 00 00 52 00 ...
音量加 01 00 00 80 00 ...
音量减 01 00 00 81 00 ...
返回 01 00 00 F1 00 ...
usage 位于 report[3],不是 HID parser 合成报告时看起来的 report[1]。这一差异是调试中的关键结论。
driver/MiRemoteHidFilter 是精确绑定 VID/PID/REV 的 KMDF device lower filter:
kbdclass -> kbdhid -> MiRemoteHidFilter -> mshidumdf
它只拦截 IRP_MJ_READ 的完成路径,并等长修改 report[3]:
0x80 -> 0x68 (F13 / VK 0x7C) 音量加
0x81 -> 0x69 (F14 / VK 0x7D) 音量减
0xF1 -> 0x6A (F15 / VK 0x7E) 返回
0x4A -> 0x6B (F16 / VK 0x7F) 主页
0x65 -> 0x6C (F17 / VK 0x80) 菜单
0x35 -> 0x6D (F18 / VK 0x81) 直播
0x66 -> 0x6E (F19 / VK 0x82) 电源
前三个 usage 是 kbdhid.sys 原本不生成 VK 的特殊 usage。后四个本可生成 Home / Apps / OEM_3 / Power,但 WH_KEYBOARD_LL 只给 VK、不提供 HID 来源设备 ID:直接把主页 VK_HOME=0x24 映射为 Win+Tab 时,物理键盘 Home 也会被吞掉。故将需要组合键映射的遥控器普通键也隔离为 F16-F19。物理 Home/Apps/反引号/Power 保持原行为。
实机验收工具检查方向上与 F13-F19 共八键;设备状态 CM_PROB_NONE,HVCI/内存完整性保持开启。当前包是 WDK 测试签名包,需要 TESTSIGNING。安装、回滚和正式签名限制见 driver/MiRemoteHidFilter/README.md。
RemoteMic.exe 启动时读取根目录 keymap.txt。配置解析、按键状态机和 Windows hook/SendInput 分离:
KeyMapConfig:解析源 VK 与目标组合,支持 A-Z、0-9、F1-F24、修饰键、方向键和常用 OEM 键KeyMapEngine:跳过空映射和同键映射;维护 held set,吞掉按住重复但只注入一次 down;up 时生成一次释放动作;忽略 injected event 防递归F5Blocker:原有全局低级钩子先吞 F5,再把其他物理键交给 KeyMapperkeyworker:hook 回调只入队;worker 用单次SendInput按顺序按下组合、逆序释放
当前配置:
电源键 (F19) 短按 -> LALT+X
电源键 (F19) 长按 800ms -> 立即点按 LALT+F4
返回键 (F15) 短按 -> LCTRL+Z
返回键 (F15) 长按 800ms -> LCTRL+LSHIFT+Z
主页键 (F16) 短按 -> LALT+TAB
主页键 (F16) 长按 800ms -> TASKVIEW
菜单键 (F17) -> 不映射
直播键 (F18) -> ESC
四个方向键配置为自身,状态机自动放行。音量±目前只由驱动修复为 F13/F14,目标映射留空。所有组合键映射都应以 filter 生成的 F13-F24 为源;不要把 Home、Apps、OEM_3、Enter 或方向键等共享 VK 直接写成映射源。
普通映射保持目标组合直到源键抬起;TAP 映射在源键抬起后原子点按。HOLD <ms> 由 hook pump 的 25ms Win32 timer 判定:到阈值立即触发一次 long action,继续按住不重复,松开不补发;阈值前松开则执行 short action。定时器使用 SetTimer(NULL, ...) 返回的实际 ID(Windows 不保证保留请求 ID)。
主页长按不能注入 Win:F16 仍由 HID 物理保持时,Windows 会识别保留快捷键 Win+F16 并显示“滑动以关闭电脑”。即使先通过 SendInput 注入 F16 key-up,也不能可靠取消底层物理状态。因此配置支持 TASKVIEW 系统动作,由 worker 执行 explorer.exe shell:::{3080F90E-D7AD-11D9-BD98-0000947B0257},完全不发送 Win 键。
自动测试覆盖:配置解析、TASKVIEW 系统动作、短按/长按阈值、长按只触发一次、长按后下一次短按恢复、同键放行、重复 down 去重、up 释放、注入事件放行、物理 Home/Apps/反引号/Power 放行,以及真实 SendInput 的组合键按下/释放顺序。
- 编译: .NET Framework 4.8 csc.exe (无 .NET SDK, 有 .NET 8 runtime)
- WinRT 引用: C:\Windows\System32\WinMetadata*.winmd + GAC System.Runtime + System.Runtime.InteropServices.WindowsRuntime
- 关键坑:
- .NET Framework 4.8 csc 对新 WinMetadata 的 WinRT 事件投影失效 → 用反射 HookEvent 调 add_ValueChanged
- WinRT async awaiter 版本不匹配 → 用 TaskCompletionSource 手动包装 IAsyncOperation (AsT helper)
- PowerShell 5.1 无法订阅 WinRT 事件 → 必须 C#
- BLE Device Id 不含 VID/PID,按 MAC c0:5d:39 匹配
- 现象: RemoteMic 运行期间物理键盘 F5 完全失效(浏览器刷新、IDE 调试等全部无响应)。
- 根因: 问题3 的 F5Blocker 钩子无条件吞掉所有 VK 0x74 (F5)——遥控器语音键的 HID F5 刷屏与物理键盘 F5 在 WH_KEYBOARD_LL 层完全无法区分,只能全吞。
- 修复: 下沉到驱动层解决——
MiRemoteHidFilter把语音键 HID usage 0x3E (F5) 改写成 0x6F (F20)。本方案将 F20 保留为遥控器专用语音键(本机物理键盘不产生),因此:VoiceKeyBlocker(原 F5Blocker)改吞 VK_F20 (0x83),语音键刷屏依旧不落前台,物理 F5 完全放行;KeySim.HoldCombo注入前的强制释放键从 F5 改为 F20;- 驱动 INF 版本 1.0.1.0;
RemoteKeyTest九键测试增加语音键 F20。
- 部署: 重新编译驱动 →
install-driver.ps1(需 TESTSIGNING,装完重启)→ 重启 RemoteMic →verify-keys.bat验证。
参考 HD838A/remote-mic-app(GPL-3.0,仅看协议行为、未复制源码),在不改变现有行为的前提下增强 ATVV 音频链路的健壮性。所有改动在正常路径下与原版完全一致,只在边缘情况下提供额外保护:
-
解析 CAPS 响应:原版发
GET_CAPS后固定等待 600ms 直接MIC_OPEN,忽略响应内容。现在解析 opcode0x0B响应,提取 version / codec / frameSize,存入frameSize变量。实测本机遥控器返回 v256 (0x0100)、codec=0x02 (16kHz)、frame=120,与硬编码默认值一致,行为不变。 -
处理 AUDIO_SYNC:原版完全忽略 opcode
0x0A。现在解析其中的 predictor (bytes 4-5, 大端有符号) 和 stepIndex (byte 6),在下一帧解码前重置 ADPCM 解码器状态。若 BLE 传输中丢包导致解码器偏差,同步包可重新校准;若设备不发同步包(当前情况),则无任何影响。 -
帧累积器 (FrameAccumulator):原版假设每次 BLE audio notification 恰好 120 bytes。现在用
List<byte>缓冲,每满frameSize取一帧解码。当 notification 被分片或合并时仍能正确解码。正常路径(恰好 120 bytes、无 pending)走快速路径,零额外开销。 -
真实 session ID:原版
MIC_EXTEND固定用 session ID 0。现在从AUDIO_START(opcode 0x04) 的 byte[3] 提取真实 session ID,用于 keepalive 的MIC_EXTEND。本机遥控器 session ID 为 0 时行为不变。 -
会话边界清理:在
AUDIO_START、AUDIO_STOP、MIC_CLOSED时清空帧累积器和同步标志,防止上一会话残留数据泄漏到新会话。
改动文件:src/RemoteMic.cs(新增字段 + DecodeFrame 方法、重构 MakeAudioHandler、增强 MakeCtlHandler)。
参考 HD838A/remote-mic-app 的手势状态机设计,为 keymap.txt 增加 DOUBLE 和 REPEAT 手势类型。不改变任何现有映射的行为——只有显式配置了 DOUBLE 或 REPEAT 的键才会激活新逻辑。
# 单击 + 双击 + 长按
菜单键 = 0x80 -> TAP X | DOUBLE 300 -> TAP Y | HOLD 550 -> TAP Z
# 单击 + 按住连发 (与 HOLD 互斥)
音量加 = 0x7C -> TAP BACK | REPEAT 350 100 -> TAP BACK
- DOUBLE :松开后等待 ms 毫秒,若无第二次按下则触发单击;有第二次按下则触发双击动作。单击动作因此被延迟。
- REPEAT :按住 delay 毫秒后触发第一次,之后每隔 interval 毫秒重复触发。松开即停。与 HOLD 互斥(解析器会报错)。
- HOLD :与现有行为一致,到阈值立即触发一次。
- DOUBLE 可以与 HOLD 或 REPEAT 共存。
KeyMapEngine 从三个并行集合 (held/downTimes/longFired) 重构为 per-key KeyState 对象,跟踪:
IsHeld/PressTime/Fired/NextRepeatWaitingDouble/DoubleDeadline/PressCount
正常路径(无 DOUBLE/REPEAT 的键)行为与旧版完全一致:
- 非 delayed 键 → 立即 down/up(方向键、ESC 等)
- TAP 键 → 松开时原子点按
- HOLD 键 → 到阈值触发一次
KeyMapConfig:解析器拆分|段,按关键字分类(DOUBLE/HOLD/REPEAT),验证互斥KeyMapEngine:HandleTimed处理 down/up 边沿,TakeDueActions轮询长按/连发/双击超时- 自动测试覆盖:DOUBLE 解析与检测、REPEAT 解析与定时、互斥校验、双击+长按共存、连发快速松开回退为单击、所有现有场景回归