用几个不同的大模型接力,我写了个原生的 macOS 版 WO Mic
Mac 平台一直缺少官方的 WO Mic 客户端,市面上也很难找到足够轻量、好用的替代方案。平时如果临时想把手机当成 Mac 的无线麦克风,往往需要复杂的配置或忍受高延迟的网页中转。
既然没有现成的工具,不如自己动手写一个。我用纯 Swift 和 SwiftUI 开发了原生客户端 MacMic,体积小、冷启动快、无须冗余的后台驻留服务。
原本以为这只是个简单的网络接收与音频解码工具——SwiftUI 界面和 Opus 解码器很快就搭建完毕。但随后在虚拟音频路由和闭源私有通信协议上踩到的深坑,远超最初的预期。整个排查与解决过程,经历了几个不同大模型的接力才得以彻底破局。
第一轮:Gemini 3.7 Flash 的驱动弯路与协议假死
最初我使用 Gemini 3.7 Flash 来搭建骨架、实现基础网络通信和音频输入管线。
在音频输入这一步,Gemini 3.7 Flash 首先建议自研一套虚拟音频 HAL(Hardware Abstraction Layer)驱动,并生成了对应的 C/C++ 驱动源码。然而在 macOS Sequoia 与 Apple Silicon 架构下,系统对内核扩展及 DriverKit / CoreAudio 插件的签名与公证有着极其严格的要求。未签名的驱动会被 coreaudiod 直接拒载,甚至可能导致系统音频服务假死。为了避免陷入驱动签名的泥潭,我果断放弃了自研驱动路线,改为调用 macOS 开源社区成熟且拥有官方公证的环回音频驱动 BlackHole 2ch。
解决了音频路由后,真正的硬骨头落在了网络协议上。
WO Mic 官方从未公开过通信协议规范。按照常规网络编程逻辑,Gemini 3.7 Flash 编写了标准的 TCP Socket 通信代码。然而每次连接 iPhone 时,程序都会卡死在握手阶段,或者刚发出数据就收到手机端的 Connection reset by peer。
通过 Wireshark 抓包确认,手机端的 8125 端口处于正常监听状态,但只要客户端发包,连接就会立刻被对端重置。调整重试机制、超时时间与包头格式均无济于事。面对无任何公开文档的非标准私有协议,常规的参数推演容易陷入反复试错的循环。
第二轮:Opus 4.6 逆向 Windows 二进制与发现分段时序
为了突破瓶颈,我提取了官方 Windows 客户端 WOMicClient.exe,转由擅长逆向分析的 Opus 4.6 处理,借助 Python 的 pefile 与 capstone 库反汇编其二进制文件。
通过逆向分析,摸清了 Windows 客户端的基础握手帧格式:
- 握手包结构:
[1 字节 cmd] [4 字节大端 length] [payload 内容]
但直接将完整数据包(例如 11 字节的 0x65 版本校验包)一次性发送至 iPhone 时,iOS 端依然会立即切断连接。Opus 在反复比对汇编逻辑与抓包数据后,捕捉到了一个关键细节:iOS 端的底层 Socket 读取采用分步阻塞机制。其逻辑是先 read(1) 读取 1 字节命令,再 read(4) 读取 4 字节长度,最后根据长度读取对应 Payload。
在 TCP 传输层面,若客户端调用单次 sendall() 将整包直接推入缓冲区,iOS 端的读取时序就会错乱,进而触发 RST 错误。
针对这一特性的解决方案十分明确:必须将单个控制包强制拆分为三次独立发送。
s.sendall(bytes([cmd_byte])) # 1. 发送 1 字节命令 |
改用分段发送后,握手流程顺利走通:
0x65(CheckVersion)返回版本校验通过。0x66(SetCodec)返回0x00确认。0x67(StartCapture)返回0x00确认。
然而握手虽然通过,控制通道上却迟迟没有连续的音频流传入。此时第一天的调用额度耗尽,Opus 将所有反汇编笔记与端口分析结果整理后交付。
第三轮:Gemini 3.1 Pro 的推论假象与排查误区
我将已有的逆向笔记整理后交由 Gemini 3.1 Pro 接力分析。
Gemini 3.1 Pro 分析后给出了一个推断:iOS 端在处理完 0x67 StartCapture 后会主动关闭 TCP,认为 TCP 仅为一次性信令通道,音频流必定由 UDP 传输;而 UDP 未收到数据,则是由于 macOS 系统防火墙静默拦截了终端 Python 脚本的网络访问。它建议将代码迁移进 Swift 并打包为 .app 应用,以触发系统的本地网络权限弹窗。
按照该思路完成 Swift 移植并打包运行后,应用确实弹出了系统网络权限请求,但实际测试中暴露出更多底层问题:
- iOS 端建立连接后,准时在第 5 秒发生断联。
- Android 端始终显示已连接,但 Mac 客户端完全接收不到音频数据。
- 手机端主动断开后,Mac 端界面依然停留在“已连接”状态。
事实表明,“一次性信令通道”和“防火墙拦截”的猜测并未触及核心问题。
第四轮:Opus 4.6 反编译 APK 源码彻底破局
推测无法替代事实。为了彻底搞清服务端逻辑,我将 Windows 安装包与 Android 官方 APK(app-release-4_8.apk)一同导入项目,交由恢复额度的 Opus 4.6 展开深度反编译。
借助 jadx 反编译出服务端的 Java 源码后,所有谜团迎刃而解:
1. 音频无声之谜:被误传的 UDP 端口
在反编译文件 d2/d.java 中,处理 0x66(配置编码)的逻辑揭示了协议真实定义:
this.f12091q = dataInputStream.readByte(); // 编码类型 (0x02 为 Opus) |
此前根据常规认知,最后 4 字节被当成了采样率数值 48000(0x0000BB80)发送。手机收到后,误认为 Mac 客户端的 UDP 接收端口为 48000,便将所有音频包发往 Mac 的 48000 端口,而客户端一直在 8125 端口监听等待。
将最后 4 字节修正为客户端真实的 UDP 监听端口(8125,十六进制 0x00001FBD)后,UDP 音频数据包瞬间恢复正常接收。
同时,反编译源码进一步确认了 UDP 音频包的 11 字节头部格式:[协议版本 (2 字节大端 0x0400)] [长度 (2 字节)] [序号 (2 字节)] [时间戳 (4 字节)] [标志 (1 字节)],第 11 字节之后即为标准的 Opus 裸音频流。
2. 五秒断联真相:指令混淆与保活机制
在 V1/l.java 与 A/g.java 中,服务端在收到 0x67 开始采集指令后,会在后台启动一个 5000ms 的超时定时器 TimerTask(V1/k.java):若 5 秒内未收到心跳保活包,服务端将判定客户端断开,并主动销毁音频采集线程。
更关键的是指令定义的混淆:
0x68(104):在源码中实际对应CMD_STOP_CAPTURE(停止采集)。0x69(105):才是真正的CMD_HEARTBEAT(重置 5 秒倒计时的保活心跳)。
此前代码在轮询线程中每 3 秒发送一次 0x68,无异于持续要求手机停止采集;而在 Swift 客户端中仅发送了 UDP ping,未在 TCP 控制通道发送 0x69 心跳,导致服务端在第 5 秒准时触发超时自毁。
源码同时表明,服务端不会对 0x69 心跳包返回任何应答,客户端只需每隔 2 秒单向发送一次空的 0x69 控制帧即可保持连接。
3. 连接状态与断联同步
在捕获到 TCP 连接断开及心跳异常事件后,完善状态机的通知流转机制。一旦手机端挂断或异常离线,Mac 客户端能够即时感知并自动切换回待连接状态。
落地效果与验证
将上述协议逻辑完整实现于 Swift 原生客户端后,整体通信链路恢复稳定:
- 音频传输:音频流以 50 包/秒(每帧 20ms)稳定接收并解码,长时间运行无断流与卡顿。
- 实时监测:SwiftUI 界面上的 VU 电平表随输入声音实时动态响应,感知延迟极低。
- 增益控制:支持 0% 至 300% 的音量平滑增益调节与一键静音切换。
- 系统集成:音频输出目标设为 BlackHole 2ch 后,微信、飞书、腾讯会议等各类通讯软件均可直接将其选为系统默认输入麦克风。
总结与项目地址
完整项目源码与构建说明已开源在 GitHub:
👉 https://github.com/luanyufei/MacMic
在缺乏文档的私有协议逆向过程中,推断往往伴随着盲区。无论是对防火墙拦截的猜想,还是对参数含义的惯性假设,都会耗费大量的调试成本,而实际逻辑始终隐藏在服务端的二进制字节中。
在多模型协作的分工上:
- Gemini 3.7 Flash 擅长快速搭建 UI 框架、组织模块架构与编写通用胶水代码;
- Opus 4.6 则在二进制反汇编、反编译源码逻辑推导及协议逆向分析中表现出色,能够精准定位异常时序与字段定义。
当提供真实的代码上下文与反编译目标后,大模型基于代码语义与时序逻辑的精细排查,能够大幅提升逆向工程与协议还原的效率。