从零打造虚拟小智:用浏览器模拟 IoT 设备的实践之路 欢迎点赞 收藏 ⭐留言 如有错误敬请指正赐人玫瑰手留余香本文作者由webmote 原创作者格言2025年一个巨大的转折点开启自由职业技术栈.NET、VUE、嵌入式C、大量低价接私活中欢迎dddd…作者勋章古法写作非遗继承人、手敲写作非遗传承人前言小智 AI 硬件是一款基于 ESP32 的开源语音对话设备通过 WebSocket /MQTT 与后端 AI 服务实时通信。在调试 WebSocket 协议和 AI 响应的过程中我们一直有个痛点每次测试都需要拿着真实硬件场景受限效率低下。于是我开始思考——能不能让浏览器本身成为一台虚拟小智这篇文章记录了虚拟小智模拟器SimDevice从构想到落地的全过程包括协议分析、音频编解码方案选型、实时通信架构设计以及在浏览器里假装是一台 ESP32 的种种技巧。在 ASP.NET Core 里写了一个 WebSocket 桥SimXiaozhi让浏览器通过它与小智服务器通信前端用 Web Codecs API 解码 Opus 音频用 ScriptProcessor 采集麦克风并编码上传整个链路完全跑在浏览器里无需任何原生插件。1、为什么要造这个轮子小智调试工具的核心场景是监听真实设备但开发者在以下场景会遇到麻烦只有电脑没带硬件设备想测试 AI 服务是否正常但不想等设备重启需要批量回归测试某个 TTS 音色或唤醒词识别给非硬件团队的人演示 AI 对话效果理想状态是打开一个网页点击连接就能像一台真实小智设备一样与 AI 服务对话——有情感表情变化、有语音识别、有 TTS 播放还能随时打断。做完之后发现这个需求不仅是调试工具的延伸本身就是一个完整的产品功能任何没有购买硬件的用户都可以通过它体验小智 AI 对话。2、架构设计为什么需要服务端桥最直觉的方案是浏览器直连小智服务器但这条路被浏览器的安全策略封死了小智服务器要求客户端在 HTTP Header 里携带Device-Id、Client-Id、Authorization等自定义 Header而浏览器的 WebSocket API 不允许设置自定义请求 Header。此外OTA 接入流程需要先通过 HTTP POST 请求获取 WebSocket 地址和 Token再用 Token 建立 WebSocket 连接。这些步骤如果放在浏览器里做会因为 CORS 跨域限制而全部失败。因此我们设计了一个三层架构桥接层SimDeviceBridge负责代替浏览器执行 OTA HTTP 请求携带所有必要 Header用ClientWebSocket连接到小智服务器在浏览器 WebSocket ↔ 小智 WebSocket 之间转发消息处理激活码轮询、重连逻辑等状态机组件职责SimDeviceBridgeC# 服务端桥解决跨域和自定义 Header 问题ScriptProcessor浏览器麦克风采集PCM → Opus 编码上传Web Codecs API浏览器原生 AudioDecoderOpus 帧实时解码播放21 种情感 GIF来自 noto-emoji由服务器 emotion 字段驱动3、OTA 协议与实现小智设备上电后第一件事是请求 OTA 接口。通过抓包分析这个接口是一个 HTTP POSTRequest Body 是一个描述设备硬件信息的 JSONResponse 返回 WebSocket 地址和认证 Token。有意思的地方在于激活流程如果设备未绑定用户OTA 接口会在响应中返回activation对象包含一个 6 位激活码。设备需要展示这个激活码让用户在 App 里输入完成绑定。绑定期间需要每隔一段时间轮询/activate端点直到激活成功。// 轮询激活状态每 12 秒检查一次最多 5 次boolactivatedfalse;for(inti1;i5!ct.IsCancellationRequested;i){awaitBrSendTextAsync(JsonSerializer.Serialize(new{typeactivating,attempti}));awaitTask.Delay(12000,ct);activatedawaitCheckActivatedAsync(activateUrl,ct);if(activated)break;}if(!activated){// 5 次未激活重新走 OTA 流程获取新激活码awaitBrSendTextAsync({\type\:\retry_ota\});continue;}激活码在浏览器端会通过Web Speech API语音播报就像真实硬件一样说出激活码functionspeakCode(code){if(!window.speechSynthesis||!code)return;window.speechSynthesis.cancel();vartext激活码是 (code).split().join();varutternewSpeechSynthesisUtterance(text);utter.langzh-CN;utter.rate0.75;window.speechSynthesis.speak(utter);}4、音频管道Opus 上行与下行小智协议使用 Opus 编码16kHz 单声道60ms/帧。这是整个模拟器里技术含量最高的部分。4.1 上行麦克风 → Opus → 服务器浏览器通过getUserMedia采集麦克风 PCM再用ScriptProcessor每次 2048 samples将 Float32 转为 Int16发送给 Bridge。Bridge 用 ConcentusC# 版 Opus 编码器将 PCM 帧编码为 Opus 二进制再通过 WebSocket 发给小智服务器。scriptProcessor.onaudioprocessfunction(ev){if(!isCapturing)return;varf32ev.inputBuffer.getChannelData(0);// Float32 → Int16 PCMvarint16newInt16Array(f32.length);for(vari0;if32.length;i)int16[i]Math.max(-32768,Math.min(32767,f32[i]*32768));wsSend(int16.buffer);// 发给 Bridge由服务端编码为 Opus};服务端积累 PCM 数据凑满一帧960 samples 60ms × 16kHz再编码privatebyte[]EncodeOneFrame(){varsamplesnewshort[UpFrameSamples];// 960 samplesBuffer.BlockCopy(_pcmBuf,0,samples,0,UpFrameSamples*2);varoutBufnewbyte[1276];intn_enc.Encode(samples,0,UpFrameSamples,outBuf,0,outBuf.Length);returnoutBuf[..n];}4.2 下行Opus 帧 → Web Codecs → 扬声器早期方案用 Concentus.jsOpus 的 WebAssembly 编译版在浏览器里解码但延迟高、内存占用大。后来发现 Chromium 已经原生支持Web Codecs APIAudioDecoder可以直接硬件加速解码 Opus延迟降低了一个数量级。audioDecodernewAudioDecoder({output:function(audioData){// 把解码后的 PCM 塞进 Web Audio 调度队列varbufplayCtx.createBuffer(1,audioData.numberOfFrames,audioData.sampleRate);varf32newFloat32Array(audioData.numberOfFrames);audioData.copyTo(f32,{planeIndex:0,format:f32});buf.copyToChannel(f32,0);varsrcplayCtx.createBufferSource();src.bufferbuf;src.connect(playGain);// 精确调度nextPlayTime 确保帧与帧无缝拼接varstartMath.max(playCtx.currentTime,nextPlayTime);src.start(start);nextPlayTimestartbuf.duration;audioData.close();},error:function(e){console.warn(AudioDecoder error:,e);}});audioDecoder.configure({codec:opus,sampleRate:24000,numberOfChannels:1});关键细节是nextPlayTime调度机制——每帧在上一帧结束时刻入队避免了帧间空隙和重叠听感平滑无撕裂。5、实时模式与 AEC普通模式下服务器发 TTS 时设备端会暂停上传麦克风数据防止录到扬声器回声。但真实的语音对话体验应该允许用户随时打断 AI 说话——这需要在 TTS 播放时同时上报麦克风音频由服务端的 AEC回声消除来剔除扬声器输出的部分。我们用_listenMode变量区分两种工作状态casetts:varttsStatenode?[state]?.ToString();// realtime 模式AEC 已开启保持上行音频支持打断检测if(ttsStatestart_listenMode!realtime){_listeningfalse;// 非 realtime 才停止上报_pcmPos0;}awaitBrSendTextAsync(json);break;前端对应的逻辑TTS 开始时realtime 模式不停止采集TTS 结束后状态从说话中恢复到聆听中而不是空闲}elseif(msg.statestop){// realtime 模式 TTS 结束后恢复聆听否则回空闲setDeviceState(realtimeActive?listening:idle);setEmotion(lastLlmEmotion);if(realtimeActive!isCapturing)startCapture();}6、情感系统21 种 GIF 动图小智服务器的llm消息里会携带emotion字段例如emotion: thinking。我们从noto-emoji 字体库中提取了 21 种情感的 128px GIF 动图存放在wwwroot/images/emotions/。情感图片在 TTS 说话期间保持不变不切换到说话图标TTS 结束后恢复到最近一次 LLM 情感——这样 AI 在说话时脸上的表情依然是高兴或思考而不是一个无聊的扬声器图标。casellm:if(msg.emotion){lastLlmEmotionmsg.emotion;// 保存TTS 结束后恢复setEmotion(msg.emotion);// 立即更新图标}break;casetts:if(msg.statestart){setDeviceState(speaking);// 注意不调用 setEmotion保持 LLM 情感不变}elseif(msg.statestop){setEmotion(lastLlmEmotion);// TTS 结束恢复 LLM 情感}setEmotion的实现只在 src 真正变化时才赋值——避免 GIF 动画因 src 重赋值而重播functionsetEmotion(name){vareemotions[name]||emotions.neutral;varimgdocument.getElementById(emotion-icon);varnewSrc/images/emotions/e.gif.gif;if(img.src!newSrc)img.srcnewSrc;// 只有真正变化才赋值}7、唤醒词打断真实小智设备支持说你好小智来打断当前对话。在浏览器里我们用Web Speech Recognition APIwebkitSpeechRecognition做实时唤醒词检测监听到你好小智后立即发送abort消息并停止 TTS 播放wakeRecog.onresultfunction(e){for(varie.resultIndex;ie.results.length;i){varte.results[i][0].transcript;if(t.indexOf(你好小智)!-1){stopTtsPlayback();wsSend(JSON.stringify({type:abort,reason:wake_word_detected}));addSysMsg(检测到唤醒词你好小智);break;}}};stopTtsPlayback会关闭AudioDecoder并重置 Web Audio 的播放队列确保 TTS 立即停止没有残余音。8、踩过的坑ScriptProcessor 即将废弃ScriptProcessor是旧 API现代推荐AudioWorklet。但 AudioWorklet 与主线程通信的序列化开销在低端机上会引入延迟。由于我们只需要 PCM → Bridge 这个单向流ScriptProcessor 依然是最简单的选择等 AudioWorklet 的使用成本下降后再迁移。Web Codecs 的 timestamp 必须严格递增AudioDecoder.decode()要求每个EncodedAudioChunk的timestamp严格递增单位微秒。我们维护一个opusTimestampUs每帧加frameDurationMs × 1000解码器关闭重建时必须归零否则 Chrome 会抛出EncodingError。WebSocket 自定义 Header 无解浏览器的new WebSocket(url)不支持设置请求 Header这是 W3C 规范的刻意限制。网上流传的各种 hack 方案要么只在特定浏览器有效要么需要 Service Worker 拦截引入大量复杂度。最终决定用服务端 Bridge 代理反而让架构更清晰。GIF 重播问题同一个 GIF 文件反复赋给img.src会导致动画从头开始播放。加一行if (img.src ! newSrc)判断即可避免但要注意浏览器会把相对路径转换成绝对路径存储在img.src里比较时需要用完整 URL 或用img.getAttribute(src)获取原始值。我们改用完整路径赋值彻底规避了这个问题。双重 disconnected 消息XzReceiveLoopAsync最初在 Close 帧处理里直接return导致finally块又发了一次disconnected浏览器端触发两次状态重置。修复方法是在 Close 分支去掉主动发送统一交由finally块处理。9、后续计划AudioWorklet 迁移用 Worklet 替换 ScriptProcessor降低在移动端的功耗本地 AEC 实现目前依赖服务端 AEC如果能在浏览器端完成回声消除可以进一步降低延迟多语言唤醒词扩展 Speech Recognition 支持英文和方言调试日志联动虚拟小智产生的会话自动显示在调试工具日志面板里移动端优化iOS Safari 的 Web Codecs 支持不完整需要降级到 Concentus.js 兜底10、写在最后整个项目大约花了两周的业余时间代码量不大但涉及的技术点异常分散从 C# Concentus 编码到 Web Codecs 解码从小智私有协议到 Web Speech Recognition从 SignalR 到 requestAnimationFrame ticker——每个点都得踩一遍才知道边界在哪里。最让我满意的一个细节是当 GIF 表情跟着 AI 的情感变化说话中依然保持高兴而不是一个无聊的扬声器图标的时候感觉这个虚拟小智真的有了一点灵魂。不过这个需要用户付费才能体验不说开发费用仅仅服务器费用都不容易啊觉得有用赞助一下吧。地址 https://qa360.net好了你学废了码