# pocket-mic **Repository Path**: izqutcom/pocket-mic ## Basic Information - **Project Name**: pocket-mic - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-25 - **Last Updated**: 2026-09-27 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 口袋麦 PocketMic **把手机变成电脑的麦克风。** 手机浏览器按住说话 → 音频经中继服务器送到电脑 → 电脑写进虚拟声卡 → 豆包语音输入法识别 → 文字直接落在你正在打字的地方。 不用写驱动、不用装 App、不用改输入法。手机端就是**一个网页**,打开就能用。 ``` 手机(浏览器,按住说话)──ws──▶ 中继服务器(Rust,纯内存转发)──ws──▶ 电脑(Tauri 桌面端) │ ▼ 写入虚拟声卡 豆包语音输入法(ASR) │ ▼ 模拟语音热键 当前焦点窗口上屏 ``` --- ## 目录 - [它解决什么问题](#它解决什么问题) - [它是怎么工作的](#它是怎么工作的) - [快速开始](#快速开始) - [目录结构](#目录结构) - [虚拟声卡怎么接](#虚拟声卡怎么接) - [热键](#热键) - [协议速查](#协议速查) - [部署到公网](#部署到公网) - [常见问题](#常见问题) - [开发](#开发) - [当前验证状态](#当前验证状态) - [许可](#许可) --- ## 它解决什么问题 在电脑前想用语音输入,但: - 台式机没麦克风,或者麦克风插在机箱后面、离嘴一米远; - 笔记本麦克风收音糊,风扇声比人声大; - 戴着耳机开会,声音进不了电脑。 手机就在手边,麦克风还比电脑好。**口袋麦**把手机当成电脑的无线麦克风:手机上按住说,文字直接出现在电脑上你正在打字的位置。 **设计目标**:端到端 300~700 ms,体感「说完即出字」;不落盘、不存音频、不解析内容。 --- ## 它是怎么工作的 | 环节 | 实现 | 为什么 | |---|---|---| | 采集 | 手机浏览器 `getUserMedia` + `AudioWorklet`,16 kHz / 16 bit / 单声道 | 打开即用,免安装免上架 | | 传输 | WebSocket,定长二进制帧,**一帧一条消息** | 20 ms 一帧是延迟与开销的甜点 | | 中继 | Rust + axum,**只读第 0 字节就转发** | 无 GC 停顿、无锁转发,单二进制部署 | | 播放 | Tauri v2 + cpal,重采样后写入虚拟声卡 | 8~15 MB 安装包,直调音频与按键 | | 识别 | 豆包语音输入法(系统里已有的) | 零 ASR 成本,不碰模型 | 三个关键设计(改代码前请先读 [`PocketMic 技术方案.md`](PocketMic%20技术方案.md)): 1. **协议常量只有一份** —— `shared/src/lib.rs`。帧长 641 字节、尾音 200 ms 这类数字如果三端各写一份,改协议时必然出现「只改了两处」的线上事故。 2. **服务器绝不解析音频** —— 转发路径只看第 0 字节,不 `JSON.parse`、不写日志、不落盘。 3. **宁丢帧,不增延迟** —— 有界队列满了丢帧,抖动缓冲满了丢新帧,绝不缓存等对端。 --- ## 快速开始 ### 前置准备 - **Rust ≥ 1.77**(服务端与桌面端都用它) ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` - **Tauri 的构建依赖**:macOS 装 Xcode Command Line Tools(`xcode-select --install`);Windows 需要 WebView2(Win11 自带)与 MSVC 生成工具;Linux 需要 `webkit2gtk-4.1` 等,见 [Tauri 文档](https://v2.tauri.app/start/prerequisites/)。 - **一个虚拟声卡**(下面第 0 步装,两分钟的事)。 - **豆包语音输入法**已装好并设好快捷键。 ### 第 0 步 · 装虚拟声卡 | 系统 | 装什么 | |---|---| | Windows | [VB-CABLE](https://vb-audio.com/Cable/),装完**重启**,系统里多出 `CABLE Input` / `CABLE Output` | | macOS | [BlackHole](https://existential.audio/blackhole/)(开源 `.pkg`),装完系统「声音 → 输入」里出现 `BlackHole 2ch` | > macOS 建议再建一个「多输出设备」(BlackHole + 扬声器),否则装完电脑会没声音。 ### 第 1 步 · 起中继服务器 ```bash git clone https://gitee.com/izqutcom/pocket-mic.git cd pocket-mic cargo run --release -p voice-relay-server ``` 默认监听 `0.0.0.0:9000`。换端口用环境变量: ```bash RELAY_ADDR=0.0.0.0:8080 cargo run --release -p voice-relay-server ``` 起来后会打印: ```text 中继已就绪 → ws://0.0.0.0:9000/ws 模拟器 http://0.0.0.0:9000/test 指标 http://0.0.0.0:9000/health ``` | 路径 | 用途 | |---|---| | `/` | 采集端页面 —— **手机浏览器打开这个地址就行** | | `/test` | 同一个页面(保留的别名,已装到手机主屏的图标还指着它) | | `/ws` | WebSocket 接入(手机与桌面端都连这里) | | `/health` | 运行指标 JSON(转发帧数、丢帧率、房间数) | | `/manifest.webmanifest`、`/app-icon.png` | 「添加到主屏幕」用的清单与图标 | ### 第 2 步 · 起桌面端 ```bash cd desktop-tauri cargo tauri dev # 调试运行 # cargo tauri build # 打包安装包 ``` 窗口里依次做三件事: 1. **房间配对** —— 应用会自动生成一个 6 位房间码,**之后一直用它**。中继地址默认 `ws://127.0.0.1:9000/ws`,本机测试不用改;连别的机器就改成 `ws://<那台机器的IP>:9000/ws`。点「连接」。 2. **输出到虚拟声卡** —— 在下拉框里选 `CABLE Input`(Windows)或 `BlackHole 2ch`(macOS)。 3. **语音热键** —— 默认 `CtrlShiftV`。macOS 需要**辅助功能(Accessibility)权限**,没有它模拟按键会直接失效,界面里会提示。 > **填过一次就不用再填了。** 中继地址、房间码、输出设备、热键都会存到本地,下次打开自动恢复; > 勾着「启动时自动连接」(默认勾着)时,连「连接」都不用点。存在哪、怎么清掉见[常见问题](#常见问题)。 > macOS:**每次重新编译 / 重新安装之后**,系统都可能要求重新授权辅助功能权限 —— 授权绑在代码签名上,而 adhoc 签名的程序每构建一次签名就变一次,系统会**静默作废**旧授权(设置里那个开关看着还是开的)。提示框里有个**「修复授权」**按钮,点它一次再打开开关即可,机理见[常见问题](#常见问题)。 然后打开豆包输入法,把它的**麦克风**设为 `CABLE Output` / `BlackHole 2ch`。 ### 第 3 步 · 手机打开页面 手机连**同一个 Wi-Fi**,浏览器打开 `http://<电脑的局域网IP>:9000/`,填入桌面端上那 6 位房间码,按住「按住说话」(或按空格)开始说,松手出字。 地址、房间码、音源同样会被记住,下次打开自动填回来;勾着「打开页面即连接上次的房间码」(默认勾着)就直接连上,一次都不用点。 页面可以**添加到主屏幕**:Android Chrome 菜单或 iOS Safari 分享里选「添加到主屏幕」,之后点图标即用,没有地址栏。**iOS 上建议一定这么做** —— Safari 会清掉超过 7 天没访问过的站点的本地存储([WebKit 的 ITP 策略](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/)),而加到主屏幕的 Web App 不受这条限制;不然隔一周回来,房间码就得重输一次。 > ⚠️ **局域网 http 不是安全上下文**,部分安卓 Chrome / iOS Safari 会**直接拒绝** `getUserMedia`(麦克风拿不到)。要稳定使用请上 HTTPS,见 [部署到公网](#部署到公网)。本机用 `localhost` 测试不受影响。 --- ## 目录结构 ``` pocket-mic/ ├── Cargo.toml workspace:三端统一依赖版本(成员写成同一行,Dockerfile 要 sed 摘掉桌面端) ├── Cargo.lock 应用仓库,提交锁文件 ├── PocketMic 技术方案.md ★ 唯一权威设计文档:架构、延迟预算、部署、踩坑清单 │ ├── shared/ 三端共用协议与 DSP(crate 名:voice-protocol) │ └── src/{lib,dsp}.rs 帧常量 / 打包解析 / 重采样 / 舒适噪声 │ ├── server-rust/ 中继服务端(crate 名:voice-relay-server) │ ├── src/{main,lib,room,ws}.rs │ ├── static/ ★ 整个站点:既内嵌进二进制,也直接当 Nginx 的 web root │ │ ├── index.html 采集端页面(手机打开的就是它) │ │ ├── manifest.webmanifest │ │ └── app-icon.png │ ├── tests/e2e.rs 真实端口的端到端测试,不 mock │ ├── relay.conf Nginx:同域名下静态站点 + wss 反代(含 systemd 单元) │ ├── Dockerfile 多阶段构建,产物 < 20 MB │ └── verify.sh 编译 → 测试 → 真实服务烟测,一条龙 │ ├── desktop-tauri/ 桌面端(crate 名:desktop-tauri,Tauri v2) │ ├── src/ 前端免构建:index.html / main.js / style.css,直接调 window.__TAURI__ │ ├── app-icon.png 1024×1024 主图 │ └── src-tauri/ │ ├── src/lib.rs State + 命令 │ ├── src/config.rs ★ 配置落盘(地址 / 房间码 / 设备 / 热键),坏文件不拖垮启动 │ ├── src/audio.rs cpal 输出到虚拟声卡(专用线程) │ ├── src/ring.rs 无锁 SPSC 抖动缓冲 │ ├── src/hotkey.rs SendInput / CGEvent 模拟按键 │ └── src/relay.rs WebSocket 接收 + 热键时序 │ └── tools/ ├── make_icons.py 生成全套图标(纯标准库,不联网、不装 Pillow) └── check-config-persistence.mjs 前端"填过一次下次不用再填"的回归检查(纯 Node,不装依赖) ``` --- ## 虚拟声卡怎么接 理解这张图,剩下的都是配置: ``` 手机 ──▶ 中继 ──▶ 口袋麦桌面端 ──▶ [虚拟声卡的"输入"端] │ ▼ 虚拟声卡在系统里表现为一个麦克风 豆包输入法(麦克风选它) │ ▼ 识别出的文字 → 当前焦点窗口 ``` | 系统 | 口袋麦的输出选 | 豆包输入法的麦克风 | |---|---|---| | Windows | `CABLE Input` | 手工选 `CABLE Output`(一次就行) | | macOS | `BlackHole 2ch` | **什么都不用改,保持「自动检测」** | **macOS 上为什么不用改**:豆包的语音输入跟的是**系统默认输入设备**(它设置里的「自动检测」)。口袋麦会在你按住说话的那一刻把系统默认输入临时切到 `BlackHole`、松手后自动还回去 —— 就是界面 ② 里那个开关,默认开着。所以豆包保持「自动检测」就是对的,也不影响其它 App 用麦克风。 **Windows 上没有写默认输入设备的公开 API**,所以那边仍需手工把豆包的麦克风选到 `CABLE Output`(界面里会直接告诉你这一点)。 > ⚠ **"装完能出字"不等于这条链通了。** > 如果你坐在电脑前说话能出字,换个房间按住说话却一个字都没有,那多半是豆包还在听笔记本自带麦克风 —— > 人在电脑前时它**直接听见了你的声音**,口袋麦那条链一帧都没参与。确认办法见下面「走远了就不出字」。 --- ## 热键 桌面端模拟按下语音热键来触发豆包录音,可选项如下(界面里只列出本平台真的能模拟的那些): | 热键 | Windows | macOS | |---|---|---| | `Ctrl + Shift + V` | ✅ 默认 | ✅ 默认 | | `Ctrl + Shift + D` | ✅ | ✅ | | `左 Ctrl + Fn` | ❌ | ✅ | | `F13` / `F14` | ✅ | ✅ | **为什么 `左 Ctrl + Fn` 只有 macOS 有**:Fn 键没有标准扫描码,产生不出物理 Fn。但 macOS 的 `CGEventFlagSecondaryFn` 与 `NSEventModifierFlagFunction` 是同一位,所以可以「声明」Fn 按下;Windows 上没有这个口子。细节见 `desktop-tauri/src-tauri/src/hotkey.rs` 模块头注释。 > 建议把豆包的语音快捷键设成 `CtrlShiftV`,与默认值一致,开箱即用。 松手后桌面端会**等「缓冲剩余 + 250 ms」再松开热键** —— 这一步不能省,否则会稳定吞掉你说的最后一个字。 --- ## 协议速查 **控制帧**(WebSocket 文本帧,JSON): ```json { "t": "join", "role": "mobile", "room": "123456", "sr": 16000 } ``` `t` 取值:`join` / `start` / `stop` / `ping`;`role` 取值:`mobile` / `desktop`。 **音频帧**(WebSocket 二进制帧,定长 641 字节): ``` ┌────────┬──────────────────────────────┐ │ 0x01 │ PCM S16LE little-endian │ │ 1 byte │ 640 bytes(= 20 ms @ 16 kHz)│ └────────┴──────────────────────────────┘ ``` 服务器只看第 0 字节就转发,绝不解析内容。 > ⚠️ **一条 WebSocket 消息只能装一帧**。服务端单条消息上限是 `641 × 8 = 5128` 字节,超限会被当作协议错误**断开连接**(而不是截断)。把 10 帧拼成一条大消息的后果是「音频通了,但每说一句掉一次线」。尾音和缓冲 flush 最容易犯这个错。 --- ## 部署到公网 **为什么必须上 HTTPS**:局域网 http **不是安全上下文**,浏览器会直接拒绝麦克风;而且 HTTPS 页面里的明文 `ws://` 会被当作混合内容掐掉。所以**页面和 wss 必须挂在同一个域名下**。 全部配置在 [`server-rust/relay.conf`](server-rust/relay.conf) 里,含 systemd 单元示例,三步: ```bash # ① 放页面(就是仓库里 server-rust/static/ 那三个文件,一个都不用改) sudo mkdir -p /var/www/voice sudo cp server-rust/static/{index.html,app-icon.png,manifest.webmanifest} /var/www/voice/ # ② 起中继,只监听回环,对外只留 Nginx 一个入口 RELAY_ADDR=127.0.0.1:9000 ./voice-relay-server # ③ 证书 + 生效 sudo cp server-rust/relay.conf /etc/nginx/conf.d/voice.conf sudo certbot --nginx -d relay.example.com sudo nginx -t && sudo nginx -s reload ``` 页面里的连接地址是**现推**的(`https` 页面自动用 `wss://<当前 host>/ws`),所以换域名、换 IP 都不用改代码。 **Docker 部署**(构建上下文必须是仓库根): ```bash docker build -f server-rust/Dockerfile -t voice-relay:0.1 . docker run --rm -p 9000:9000 -e RUST_LOG=voice_relay_server=info voice-relay:0.1 ``` --- ## 常见问题 **手机页面点「按住说话」没反应 / 提示要权限** 局域网 http 下部分浏览器直接禁用麦克风。换 HTTPS,或用 `localhost` 在本机试。服务端日志里会记 UA,方便判断是哪个浏览器。 **桌面端连不上** 按这个顺序排查,比读代码快:① 看服务端日志有没有对端入房;② 用 `curl` 打 `/health` 确认服务端活着;③ 确认地址是 `ws://` 开头且端口对(默认 9000)。 **说了没反应 / 第一个字丢** 检查豆包输入法的麦克风是否选了虚拟声卡的输出端,以及是否拿到了辅助功能权限(macOS)。**如果系统设置里明明勾上了还提示未授权**,见下面「辅助功能里明明是勾上的…」那条。 **人坐在电脑前能出字,走远了就一个字都没有** 这是这个项目最会骗人的一个坑,**不是"声音没传过来"**。先按这个顺序排除: 1. **中继侧一帧不丢** → 那声音就到桌面端了。看服务端 `/health` 的 `forwardedFrames` 是否随说话精确上涨(`droppedFrames` 应当一直是 0)。它在涨而屏幕上没字,就直接跳到第 3 步。 2. **别用"人坐在电脑前"当验收** → 那种情况下笔记本自带麦克风会直接听见你,看着像能用。要么走远再试,要么用仓库里的伪造采集端: ```bash node tools/loopback-inject.mjs --room <桌面端显示的 6 位房间码> ``` 它会合成一句话按协议灌进房间,桌面端照常自己按热键。**看屏幕上是"不是那句话"**:出的是那句话 → 通了;出的是不相干的内容 → 输入法还在听真麦克风;什么都不出 → 去查热键/权限。 3. **确认输入法在听哪只麦克风** → 见上一条「虚拟声卡怎么接」。macOS 上把界面 ② 的开关打开(默认就是开的),豆包保持「自动检测」;Windows 上手工把豆包的麦克风选到 `CABLE Output`。 界面上那行小字会直接写出「按住说话时会临时切到『BlackHole 2ch』」—— 如果它写的是"还没确定要切到哪块输入设备",先把 ② 里的输出选到虚拟声卡。 **「每说一句就掉线」** 几乎一定是把多帧拼成了一条 WebSocket 消息,撞上了单条消息上限。参见[协议速查](#协议速查)的警告。 **macOS 装完 BlackHole 电脑没声音了** 需要自己建一个「多输出设备」,把 BlackHole 和扬声器都加进去。 **延迟高** 最大的一项是桌面端播放缓冲,可在 `desktop-tauri/src-tauri/src/audio.rs` 调小;其次是把中继部署在离你最近的地域。 **连上了,但一直"等桌面端进来" / 手机和电脑配不上** 十有八九是两边的房间码不是一个。看桌面端界面上那 6 位数字,和手机输入框里的是否一致 —— 桌面端点过「换一个码」的话,手机上的旧码就作废了。 **把项目目录改名或挪了位置之后,`cargo tauri build` 报 `failed to read plugin permissions`** Tauri 的构建脚本会把**项目的绝对路径**写进 `target/` 里的产物。目录一挪,这些记录仍指向旧位置,于是它去找一个不存在的文件(实际的文件都在,只是路径前缀过期了)。删掉受影响的构建目录让它重新生成: ```bash # release(cargo tauri build 报错时) rm -rf target/release/build/{tauri-*,tauri-runtime*,desktop-tauri-*} # debug(cargo tauri dev / cargo test 报错时) rm -rf target/debug/build/{tauri-*,tauri-runtime*,desktop-tauri-*} ``` 不用改任何代码。想图省事就直接 `cargo clean` 重建,只是会慢一些。 **打 DMG 时报 `error running bundle_dmg.sh`,或 `hdiutil: couldn't unmount ... 资源忙`** DMG 打包脚本会让 Finder 摆好图标位置,然后立刻卸载镜像;部分 macOS 版本上 Finder 还没松手,卸载就报「资源忙」。脚本的失败重试只认退出码 16,而 hdiutil 这里返回的是 2,所以它不会重试,直接失败。跳过这段「美化」即可: ```bash CI=true cargo tauri build # 完整打包,DMG 里的图标用默认位置 cargo tauri build --bundles app # 只要 .app,完全绕开 DMG ``` `.app` 和 DMG 里的 App 是同一个,功能不受影响。 ### 辅助功能里明明是勾上的,桌面端还说「未授权」 **这不是你勾错了,是 macOS 已经把那份授权作废了,只是没告诉你。** 辅助功能授权是**绑在代码签名上**的。本项目的 `.app` 没有开发者证书,用的是链接器自动生成的 adhoc 签名 —— **每重新编译(或重新安装)一次,签名的 cdhash 就变一次**。macOS 一比发现"签名和当初批准的不是同一个",于是**静默**作废旧授权;但系统设置里那一行还留在列表上、开关也还是开的,所以看起来一切正常。表现就是:界面提示未授权、按热键毫无反应、豆包输入法收不到信号。 macOS 15 起这个行为越来越严,26/27 上更明显。判断方法:桌面端点「测试热键」,如果没反应、且提示区写着未授权,基本就是它。 **修法(用界面上的按钮,一步到位):** 1. 点提示框里的**「修复授权」** —— 它会清掉本应用在辅助功能里的旧记录,再重新发起申请; 2. 在弹出的系统设置里,把「PocketMic」右边的开关打开; 3. 不用重启应用:界面每 5 秒复核一次,勾上之后提示会自己消失。 手动等价于 `tccutil reset Accessibility com.pocketmic.desktop` —— **必须带上 bundle id**:不带参数的 `tccutil reset Accessibility` 会清掉**所有应用**的辅助功能授权。如果执行完那一行还在列表里,就选中它按左下角的「−」删掉。 **还有第二个坑:同名但不同路径的两份拷贝。** `target/release/bundle/macos/PocketMic.app` 与 `/Applications/PocketMic.app` 名字一样、bundle id 也一样,但在系统眼里是两个文件、两条彼此独立的授权记录。勾了 A 却在跑 B,症状与上面一模一样。所以: - 认准一份用(建议 `/Applications/PocketMic.app`),别一会儿从 Finder 双击、一会儿从编译目录里跑; - 提示框里会直接写出**要授权的是这一份**的路径,照着它在列表里核对; - 确认列表里没有多余的、指向旧位置的 PocketMic 行。 - 另外,`cargo tauri dev` 跑的是**裸可执行文件**(不在 `.app` 里),macOS 26 起系统选择器会**静默拒绝**这类文件 —— 想验证热键,请用打包出来的 `.app`。 **想彻底不再遇到**:给应用一个**稳定的签名**(自签名证书即可),权限就能跨重新编译保留。大致做法是用 `openssl` 生成一张带 `codeSigning` 扩展的自签名证书、`security import` 进登录钥匙串、在「钥匙串访问」里把它的「代码签名」设为**始终信任**,然后每次构建后 `codesign --force --deep --sign "<证书名>" PocketMic.app`。没有稳定签名时,每次重新编译都要重按一次「修复授权」—— 这是 macOS 的设计,不是本项目的 bug。 ### 每次启动都要重填地址 / 房间码 不该发生 —— 两端都会记住你填过的东西: | 端 | 存哪 | 存了什么 | |---|---|---| | 桌面端 | `config.json`,位于 `app_config_dir()`:macOS `~/Library/Application Support/com.pocketmic.desktop/config.json`;Windows `%APPDATA%\com.pocketmic.desktop\config.json` | 中继地址、**房间码**、输出设备、热键、自动连接开关 | | 手机端 | 浏览器本地存储(键名前缀 `hyime.`) | 地址、房间码、音源、自动连接开关 | **房间码也在里面**,所以桌面端重启后还是原来那个码 —— 手机上的「打开页面即连接上次的房间码」才配得上。只有点「换一个码」才会变;它会**先断开**(免得手机还挂在旧房间上),换了之后手机端要重输一次(界面会提醒)。界面上按 `123 456` 三位一组显示,读和敲都轻松些;点一下那串数字可以复制。 想清零:删掉那个 `config.json`、或清掉该站点的浏览器数据,下次启动自动重建。 真还在重填,多半是**没写进去**:配置目录只读、磁盘满,或手机在无痕模式(iOS 无痕下 `localStorage` 一写就抛异常)。这两种情况都**只影响记忆、不影响使用** —— 桌面端日志里会出现 `配置写不进去`;手机端在 Safari 标签页里隔 7 天不用也会被清掉,那种情况请「添加到主屏幕」,见[第 3 步](#第-3-步--手机打开页面)。 ### 房间码为什么不做好记一点(比如 `112233`) 因为房间码是这个系统**唯一的口令**:服务端只认它,谁报出同一个码就占住那个房间位(`server-rust/src/room.rs` 的 `Registry::join`)。猜中它就能把音频灌进你的虚拟声卡 —— 那等于替你在光标所在的位置打字。 而"一眼能记住"的形状(`112233`、`474747`、`123123`)会把可能性从 90 万压到 1000 上下。90 万穷举要按小时算,1000 是**几十秒**的事。换句话说,把码变简单换来的不是"好记",是"能被猜中"。 所以生成器只在**读起来不乱**上让步,强度一分不降: - 首位非 0 —— `012345` 的前导零最容易在口述/手输时丢掉,丢掉之后直接被判非法,用户只会看到"房间码必须是 6 位数字",对着屏幕数三遍也对不上; - 不出现三个及以上相同数字 —— `111222` 数不清几个 1,而且是最先被试的弱码; - 不出现 4 位及以上连号 —— `123456`、`654321` 同属最弱的码。 想真的既不用记也不用输,正确做法是**扫码配对**(桌面端把「地址 + 房间码」显示成二维码,手机扫一下直接带 `?room=` 打开页面并自动连上)—— 那既不动强度,也不用输。手机端已经支持 `?url=` / `?room=` 参数并被记住,缺的只是桌面端那个二维码。 **「换一个码」点了像是没反应?** 早期版本有个真 bug:状态快照每 200ms 推一次,界面被它反向覆盖,刚换的码下一拍就被打回旧的。现在界面上的房间码只来自配置,快照跟它不一致只会**告警**,不会再改回去。如果你看到日志里出现 `注意:这次连接用的是房间码 …`,说明确实有两个不同的码在打架,去手机端重输一次即可。 --- ## 开发 ```bash # 编译 + 测试 + 真实服务烟测(推荐,一条龙) bash server-rust/verify.sh # 单跑测试 cargo test -p voice-protocol -p voice-relay-server # 只要服务端 cargo run --release -p voice-relay-server # 桌面端调试 cd desktop-tauri && cargo tauri dev # 前端「填过一次下次不用再填」的回归检查(纯 Node,不用装任何依赖) node tools/check-config-persistence.mjs ``` **改代码前必读 [`PocketMic 技术方案.md`](PocketMic%20技术方案.md)**,尤其是「铁律」与「踩坑清单」两节 —— 里面每一条都对应一个真实发生过的线上问题。 几个容易踩的坑先说在前面: - **页面是编译期内嵌的**。改了 `server-rust/static/index.html` 必须重新 `cargo build`,否则二进制里还是旧页面。 - **改完必须 `cargo test`,不能只 `cargo check`**。有些 bug(重采样漂移、噪声幅度算错)编译期完全看不出来,只有测试能抓。 - **别并行跑两个 cargo 构建**,会互相抢核;`cargo install` 中途打断会全部白费。 - **配置只有一份来源**:桌面端的地址 / 房间码 / 设备 / 热键由 `desktop-tauri/src-tauri/src/config.rs` 落盘,前端只是它的视图;手机端的在页面自己的 `localStorage` 里。动这几个字段的持久化时,前端和 Rust 两边都要一起改,改完跑一下 `tools/check-config-persistence.mjs`。 - **状态快照不是配置**。`Snapshot::room` 说的是"这次连接用了哪个码",界面上的房间码只能来自配置。拿快照反过来覆盖界面,会让用户刚点的「换一个码」在 200ms 内被打回旧码 —— 看着就是"按钮没反应"。 - **房间码有两级校验,别合并**:`is_valid_room_code`(线协议 + 存量数据,只认"6 位数字",**永远不能收紧**)与 `is_readable_room_code`(只描述生成器的偏好)。把后者加进前者,会让用户存着的 `111222` 被判非法然后被换掉,手机端存的还是旧码 —— 两边从此配不上且没有报错。 --- ## 当前验证状态 诚实说明,免得你踩坑: | 部分 | 状态 | |---|---| | 服务端(Rust) | ✅ 本机 macOS 实测通过,含端到端测试与真实端口烟测 | | 采集端页面 | ✅ 浏览器实测通过(按住说话、尾音、重连) | | 桌面端(macOS) | ✅ 本机开发与运行通过 | | 桌面端(Windows) | ⚠️ 代码路径已实现(`SendInput`),**未在真机验证** | | Linux 桌面端 | ⚠️ 未验证(Tauri 依赖与按键模拟都还没适配) | | 公网 HTTPS 部署 | ⚠️ 配置已就绪,未在真实域名下跑过完整闭环 | | ARM64 开发板 | ⚠️ 交叉编译方式已确认,板上运行未验证 | --- ## 许可 [MIT](LICENSE)