资讯详情

ESP32 AI玩偶全双工音频链路重构:从对讲机到连续对话

📅 2026/9/12 15:06:06 | 华诺云谱 👁 阅读
ESP32 AI玩偶全双工音频链路重构:从对讲机到连续对话
做这行最怕听到一句话“你家玩偶怎么跟对讲机一样”我们的 ESP32 AI 玩偶第一版上线后用户反馈里高频出现三个字要按键。孩子想问下一句得再按一次问快了还会被“正在播放中”拦下来。这个体验说实话很劝退。于是我们启动了 WebSocket 二进制音频链路重构目标很明确让玩偶从“录一段、传一段、答一段”的能对话模式升级成边说边传、随时打断、无感切换的连续对话体验。这篇文章就把当时踩过的坑、趟过的路完整拆出来包括协议帧设计、ESP32 端音频管线改造、服务端流式 ASR 协作、以及断线重连这些平时文档里不会细讲的环节给正准备做类似项目的朋友做个参考。先说结论整个重构的收益不是延迟从 2 秒降到 1 秒这么简单而是交互模型变了——设备始终在听服务端始终在等下一句用户不再需要“开始”这个动作。能做到这一点的前提是把音频链路做成真正的全双工而全双工的第一步是先搞清楚旧架构为什么只能做到一问一答。1. 从“对讲机”到“连续对话”旧架构的问题到底出在哪1.1 旧架构的模型半双工的 PTT 模式第一版玩偶的思路特别直白按下按键 → 麦克风录音到内存 → 松开按键 → 把这整段音频通过 HTTP POST 上传 → 服务端做语音识别 → 拿到文本后调大模型 → 再把 TTS 音频整体下载回来播放。流程图写出来很短但实际体验环节全是坑。首先是录音阶段玩偶就像一个录音笔孩子说话必须等录音完全结束才能进入处理。而录音结束的判定是“按键松开”这对低龄用户来说非常反直觉——小孩说话有停顿停一下他们以为系统应该明白了但实际录音还在继续。就算改成长按说话也避免不了第二个问题整段上传的等待时间。一段 3 秒的音频按 16kHz 16bit 单声道算大小约 96KB走 HTTP POST 加上服务端排队、识别、生成用户等到第一个字响起来普遍要 3 秒以上。对成人还能忍对小朋友来说这个间隔足够让他们觉得“它是不是坏了”。但最致命的还不是延迟而是通道方向。HTTP 上传完成之后整个链路就断开了服务端不知道设备还在不在听。玩偶在播放回答的整个过程中麦克风是闲置的用户就算中途插话系统也收不到。这就是典型的半双工 PTTPush-to-Talk模式本质上和用对讲机聊天没有区别。1.2 连续对话的本质不是“多传几次”而是全双工连续对话要解决的是四个问题第一音频要以流的方式持续上报而不是攒成一整包第二服务端要能边收边识别在用户还没说完的时候就给出中间结果第三设备播放回答的同时麦克风仍然在监听用户随时可以打断第四整个交互要维护一个会话状态而不是每次交互都从零开始。这四个问题叠在一起HTTP 协议基本无从下手——它就是为请求-响应模型设计的。我们需要的是一个长连接、双向、低开销的传输通道WebSocket 是当下最合适的选择。而 WebSocket 的文本帧和二进制帧之间我们毫不犹豫选了二进制。这背后的原因值得展开说说因为很多项目在这里栽了跟头。2. 二进制帧的可设计性音频协议头与分包策略2.1 为什么放弃 JSON 文本帧体积、解析开销和嵌入式友好度最早我们内部也讨论过用 JSON Base64 的文本帧方案。毕竟 WebSocket 天然支持文本帧调试工具一抓包就能看懂内容看起来很美。但算了一笔账之后就果断放弃了。对比项JSON 文本帧Base64 承载音频二进制帧音频负载膨胀约 33%Base64 编码开销无膨胀解析开销JSON 解析 Base64 解码ESP32 上耗时明显结构体直接读取几乎零开销头部附加信息需要手动拼字段JSON 序列化也有成本固定结构体头12 字节内存拷贝多次拼接、编码、拷贝一次 memcpy抓包可读性直观需要按协议解析ESP32 这类资源受限设备跑 JSON 解析和 Base64 编解码不是不行但在音频流场景下每 20ms 就要处理一帧CPU 开销是实打实的。实测在 Arduino-ESP32 环境下一帧 640 字节的音频做 Base64 编码大约要多耗时 300~500 微秒看似不大但乘以每秒 50 帧就是 15~25ms 的额外 CPU 时间。更重要的是文本帧的语义是“给你一段文本”而二进制帧的语义是“给你一段结构化数据”对音频这种定时产生的流式数据后者才是正解。2.2 自定义帧结构12 字节的头里放了什么我们最终定义的帧头是一个固定 12 字节的结构体用 C 描述如下typedef struct __attribute__((packed)) { uint8_t magic; // 固定 0xAA用于快速校验 uint8_t version; // 协议版本当前 0x01 uint8_t type; // 帧类型音频/文本/心跳/事件 uint8_t flags; // 位标记START/END/PARTIAL/INTERRUPT uint16_t seq; // 序号用于检测丢帧和乱序 uint16_t payload_len; // 负载长度0~1024 uint32_t timestamp; // 采集时间戳毫秒 } audio_frame_header_t; // 总长度 4 2 2 4 12 字节这里每个字段都有它的用途不是为了凑数。magic 是首道校验避免把乱入数据当成有效帧type 区分音频、事件、心跳让同一条连接承载多种语义flags 里最关键的是 START 和 END 标记——因为音频是流式的服务端需要知道一句话从哪里开始、到哪里结束才能正确切片交给 ASRseq 用于接收端发现丢帧尤其在 WiFi 不稳定的环境下timestamp 则是为了后续做端到端延迟分析没有时间戳调优寸步难行。音频负载本身我们初期直接用裸 PCM16kHz 采样率、16bit 量化、单声道每 20ms 产生 640 字节负载。之所以选 20ms 一包是因为主流流式 ASR 的分片窗口通常在 20~40ms这也让单帧大小适中即使丢一帧也只损失 20ms 语音人耳几乎无感知。2.3 粘包与半包TCP 字节流的经典问题WebSocket 本身有帧边界到了应用层不会出现 HTTP 那种粘包问题——这是选它的一个重要理由。但如果你和我一样在服务端直接用 WebSocket 库接收二进制消息每个消息对应一帧那这点基本不用操心。真正的坑出现在 ESP32 客户端库和部分网络中间层上它们可能把多个小包合并成一个大包或者把一个逻辑帧拆成多次收到。所以在解析层必须自己做完整的“读包头-校验-读负载-组装”流程。服务端 Python 端的解析我贴一段核心逻辑import struct HEADER_FMT BBBBHHI HEADER_SIZE struct.calcsize(HEADER_FMT) def parse_frames(buffer: bytes): 从字节流中解析出完整帧返回 (frames, remaining_buffer) frames [] offset 0 while len(buffer) - offset HEADER_SIZE: magic, version, msg_type, flags, seq, payload_len, ts \ struct.unpack_from(HEADER_FMT, buffer, offset) if magic ! 0xAA: # 数据错位丢弃一个字节继续找 offset 1 continue frame_total HEADER_SIZE payload_len if len(buffer) - offset frame_total: break # 半包等下一个数据块 payload buffer[offset HEADER_SIZE: offset frame_total] frames.append({ type: msg_type, flags: flags, seq: seq, ts: ts, payload: payload }) offset frame_total return frames, buffer[offset:]这段代码看起来简单但有一个细节很重要当数据错位时不要盲目丢弃整个包而是逐字节前进找 magic。因为 WiFi 环境下的偶发坏帧可能只破坏开头一两个字节错位后内容仍然是完整的逐字节扫描能救回大部分帧。我们上线初期大概 5% 的帧会经历一次这样的“自愈”如果直接丢弃用户听到的就是偶尔的丢字。3. ESP32 端音频管线改造从“录一段”到“实时流”3.1 采集侧I2S DMA 与环形缓冲的配合ESP32 上的音频采集走的是 I2S 外设接 PDM 或模拟麦克风。我建议用 ESP32-S3 I2S 标准模式接模拟 MEMS 麦克风或者直接用内置 PDM 的板载麦克风。无论哪种核心都是靠 DMA 把 I2S 数据搬到内存再由应用层定时读取。采样的关键参数如下// ESP-IDF / Arduino-ESP32 下的 I2S 配置要点 // 采样率 16000位宽 16bit单声道 i2s_config_t i2s_config { .sample_rate 16000, .bits_per_sample I2S_BITS_PER_SAMPLE_16BIT, .channel_format I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format I2S_COMM_FORMAT_STAND_I2S, .dma_buf_count 8, .dma_buf_len 320, // 每个 DMA 缓冲 320 个样本 20ms 16kHz .use_apll false, .intr_alloc_flags ESP_INTR_FLAG_LEVEL1 };DMA 缓冲数设为 8、每个缓冲 20ms意味着底层可以缓冲 160ms 的音频。这个配置比较保守但好处是抗抖动即使主控被 WiFi 任务卡住一小会音频数据也不会丢。代价是增加了约 160ms 的“隐性延迟”——录音时刻到真正发出时刻的间隔。在实际测试中这个延迟完全可以通过后续的流式识别弥补因为 ASR 不需要等整句话结束。读取侧的逻辑就一句话每隔 20ms从 I2S 读出一帧 PCM打入发送队列。// 录音任务持续读取 I2S 并投递音频帧 const size_t SAMPLES_PER_FRAME 320; // 20ms 16kHz int16_t pcm_buf[SAMPLES_PER_FRAME]; size_t bytes_read 0; while (running) { esp_err_t err i2s_read(I2S_NUM_0, pcm_buf, sizeof(pcm_buf), bytes_read, portMAX_DELAY); if (err ESP_OK bytes_read sizeof(pcm_buf)) { // 投递到发送队列由独立任务负责 WebSocket 写入 xQueueSend(audio_tx_queue, pcm_buf, pdMS_TO_TICKS(10)); } }这里有个容易犯的错误直接在 i2s_read 返回后调 WebSocket 发送。音频采集是硬实时任务而网络发送可能因为 WiFi 拥塞阻塞几十毫秒一旦阻塞I2S 的 DMA 缓冲就会溢出丢录音。正确做法是拆两个任务录音任务只负责往队列丢数据网络发送任务负责从队列取数据并写入 WebSocket。队列深度至少 16 帧也就是 320ms 的余量。3.2 设备端 VAD全时上传的流量优化与判停辅助全双工意味着设备会源源不断地上传音频但如果环境里有电视声、空调声这些背景音没必要全量送进 ASR。我们在设备端加了一个轻量能量 VADVoice Activity Detection本质就是一个均方根能量计算uint32_t calc_rms(const int16_t *samples, size_t n) { uint64_t sum 0; for (size_t i 0; i n; i) { sum (uint32_t)samples[i] * (uint32_t)samples[i]; } return (uint32_t)(sum / n); }然后设定两个阈值低于静音阈值时帧标记为静音仍然发送但带一个 SILENCE 标志超过语音阈值后进入说话状态。这样服务端可以根据标志跳过完全无声的段落节省 ASR 的计算量。实测在安静的房间里静音帧占比能到 60% 以上去掉这些空转的帧服务端 ASR 的压力小很多。但这里必须提一个经验VAD 只能用于“省流量、省算力”不能依赖它判断一句话是否结束。原因很简单——人说话时语气词、呼吸、短暂停顿产生的能量波动非常复杂单纯靠能量阈值判停要么在用户思考时误判结束要么在真正停顿时迟迟不响应。我们最终的判停逻辑放在服务端综合了静音超时、ASR 端点检测和语义完整度三重信号。3.3 播放侧小缓冲抗抖动与打断机制播放链路相对简单服务端推回来的 TTS 音频帧同样是二进制帧type 标记为 AUDIO先入播放队列播放任务逐个解帧并写入 I2S 输出。这里唯一的参数取舍是播放缓冲深度。音频播放对网络抖动非常敏感。如果我们完全实时播放一个网络抖动 100ms用户听到的就是声音卡一下非常难受。我们的做法是在播放队列里预缓冲 4~6 帧约 200~300ms换取播放的连续性。代价是响应延迟增加了大约 200ms。在听感实验中用户对“晚 200ms 开始说话”的容忍度远高于“说话过程中卡顿”的容忍度。这一笔交易非常划算。打断机制的难点在于全双工冲突设备正在播放 TTS 时用户的麦克风也在录音。如果播放出来的声音被麦克风采进去服务端就会误判为“用户在说话”从而触发打断。这就是回环啸叫的根源。我们第一版没有做 AEC回声消除采用了一个折中策略播放 TTS 期间设备端把麦克风增益降低 6dB同时把 VAD 语音阈值临时提高一倍这样只有真正近距离的人声才能触发打断扬声器出来的声音基本被忽略。实测这个方案对正常对话场景够用但如果是大音量播放音乐或是房间里环境音嘈杂误触发率会上升。要彻底解决得上带 AEC 的音频编解码芯片比如 ES8311或者用 ESP32 的 AEC 算法库这是后续迭代的重点。3.4 任务调度与内存两个核怎么分ESP32-S3 是双核任务分配直接影响音频流的稳定性。我实测下来比较稳的组合Core 0录音任务高优先级 播放任务中优先级Core 1WebSocket 网络发送/接收任务 WiFi 协议栈这样做的理由是i2s_read 必须及时响应否则 DMA 溢出而网络任务和 WiFi 栈天然在一核上可以减少锁竞争。如果反过来把网络任务放 Core 0WiFi 的周期性中断会抢占录音任务的 CPU直接后果就是录音偶发缺帧表现出来就是 ASR 识别率下降。内存方面ESP32-S3 有 512KB SRAM但可用空间没想象中充裕。WiFi 缓冲、TLS、JSON 解析、TTS 播放队列、音频发送队列加起来峰值占用大约 120KB 左右。我们的经验是发送队列每帧 640 字节 12 字节头16 帧深度约 10KB播放队列同样 16 帧深度约 10KB两块内存加起来 20KB完全可接受不需要外扩 PSRAM 就能跑。4. 服务端流式 ASR 的拼接、断句与轮次管理4.1 音频网关的整体设计服务端我选了 Python FastAPI 做 WebSocket 网关原因很直接异步模型适合长连接场景生态里对 ASR/TTS 的 SDK 支持最全。整体结构是这样每个设备建立连接后服务端维护一个 DeviceSession 对象里面有一个 asyncio.Queue 用于缓冲从设备收到的音频帧还有一个流式 ASR 客户端、会话状态、以及一个“是否正在 TTS 播放”的标记。class DeviceSession: def __init__(self, ws: WebSocket, device_id: str): self.ws ws self.device_id device_id self.audio_queue asyncio.Queue(maxsize128) self.asr None # 流式 ASR 会话 self.context [] # 会话历史 self.tts_playing False # 是否正在播放回答 self.speaking False # 是否处于用户说话状态 app.websocket(/audio) async def audio_endpoint(ws: WebSocket): await ws.accept() device_id ws.query_params.get(device_id) session DeviceSession(ws, device_id) consumer asyncio.create_task(consume_audio(session)) try: while True: data await ws.receive_bytes() await session.audio_queue.put(data) except WebSocketDisconnect: consumer.cancel()这个网关最重要的职责不是转发数据而是维护“这一句话到没到可以回答的程度”。判断逻辑在 consume_audio 里。4.2 一句话什么时候算说完了三层信号综合前面提到不能只用静音超时判停这里展开讲我们最终落地的三层判停机制。第一层是设备端 VAD 标志。设备会持续上报 SILENCE 和 SPEECH服务端统计当前这包数据的能量状态。第二层是流式 ASR 自身给出的端点检测主流的云 ASR 服务在流式模式里通常会有 mid-result 和 final-result 两类回调final 意味着它认为用户这句话已经说完。第三层是一个兜底静音计时器收到最后一个 SPEECH 帧后如果 1200ms 内没有新的语音帧或者 15 秒的硬性超时就强制截断。拿“孩子说话停顿”的场景举例小朋友说“妈妈我想”然后停了一下ASR 的 mid-result 会先返回“妈妈我想”静音计时器到 800ms 时我们不会触发结束而是等待。如果他在 1200ms 内接着说了“买那个玩具”ASR 的 final-result 会返回完整句“妈妈我想买那个玩具”这时才触发回答。这就是三层信号各自的价值VAD 告诉我们物理上有没有说话ASR 告诉我们语义上是不是一个完整体静音超时保证系统不会无限等下去。4.3 流式 ASR 的分片喂入与中间结果ASR 的调优里有个细节不是每收到一帧就立刻喂给 ASR而是攒成 60ms 的块再喂。原因是大多数 ASR 服务的流式接口内部也有自己的缓存窗口喂太碎的包反而增加协议开销每 60ms 一个块实测识别效果和 20ms 喂入几乎一样但 CPU 占用低了很多。喂入的同时我们把 ASR 的 mid-result 缓存起来但并不立即触发回答。这样做是为了覆盖“用户说了半句然后改口”的情况比如孩子先说“我要吃”然后马上改口“我要玩积木”。如果服务端在“我要吃”的 mid-result 阶段就拿着去问大模型回答必然是跑偏的。正确做法是只有 ASR 触发了 final-result或者强制超时截断才把完整文本送入大模型。mid-result 的作用只是给系统一个“它已经在说话了”的状态提示可以提前初始化大模型连接、预热 TTS缩短后续响应时间。4.4 打断与轮次状态机全双工服务的核心状态机一共四个状态IDLE空闲、LISTENING监听用户说话、PROCESSING识别生成回答、SPEAKING播放 TTS。IDLE设备已连接VAD 处于静音等待语音激活。LISTENING检测到用户开始说话音频帧持续喂入 ASR。PROCESSING一句话收尾开始调用大模型生成回答。此时设备端仍在录音但不喂 ASR。SPEAKINGTTS 音频流推送给设备播放。如果此时设备端 VAD 又检测到高强度人声设备会主动发一帧 INTERRUPT 事件服务端收到后立刻停止当前 TTS 流把状态机拉回 LISTENING并且把之前的大模型上下文保留在 session.context 里。这个状态机的价值在于它让“连续对话”有了可维护的上下文。孩子上一句问“狗为什么会叫”下一句接着说“那猫呢”服务端知道“那猫呢”是承接上一句的指代而不是要求从头聊。这在老版本 HTTP 架构里几乎没法做每次都是无状态会话用户必须把问题问完整。5. 弱网与断线WebSocket 1006 和“假死连接”的排查5.1 1006 到底在说什么连续对话对连接稳定性的要求比普通 WebSocket 应用高得多。我们灰度期间收到最多的报警就是 WebSocket onclose code 1006。这个错误码的准确含义是“abnormal closure”——连接在没有正常 close 握手的情况下就断了。常见的根因有几类根因现象出现场景NAT 超时回收长时间无数据运营商/云厂商网关回收连接用户说完一句话后静默超过 60 秒WiFi 休眠ESP32 modem sleep 导致 TCP keepalive 超时设备电池模式常开待机一段时间后服务端主动断开网关空闲超时配置过短未做心跳时云网关默认 60s 回收空闲连接网络切换IP 地址变化TCP 连接无效手机热点切 WiFi或路由器重启最隐蔽的是第二种ESP32 开启 modem sleep 后WiFi 模块会在无数据时进入休眠接收唤醒需要等 DTIM 周期短则几十毫秒长则几百毫秒。如果恰好心跳包在这个窗口期内到达设备没有及时响应服务端或中间网关就可能判定超时。我们的解决方法是连续对话场景下音频是持续流动的本身足够保活一旦进入长时间静默就把 modem sleep 的 DTIM 间隔调大或者干脆暂时关掉 modem sleep换 30~50mA 的电流消耗来换连接稳定。5.2 应用层心跳与半开连接检测WebSocket 协议自带 Ping/Pong 帧但服务端库对 Ping 的支持不一客户端库更是参差。我们直接采用应用层二进制心跳帧type 为 HEARTBEAT每隔 15 秒由设备发一帧服务端必须回一帧 HEARTBEAT_ACK。这里的 15 秒不是拍脑袋定的。云厂商的 NAT 空闲回收普遍在 60 秒左右我们要在 3 个周期内完成检测——也就是 45 秒内设备必须发出至少 3 次心跳因此 15 秒是安全裕量。设备端连续 2 次心跳无响应就判定连接假死主动关闭并触发重连。实测这个策略能把“连接看似还在、实际发啥都收不到”的半开连接时间控制在 30 秒以内。5.3 断开重连指数退避与会话恢复重连策略上最忌讳的是设备一断就连、一连就断、再断再连形成风暴。我们用的是标准的指数退避加抖动第 1 次重连1 秒延迟第 2 次2 秒第 3 次4 秒第 4 次8 秒第 5 次及以后16 秒封顶每 5 次随机加 0~3 秒抖动重连成功后设备需要重新发送一次 HELLO 握手帧带上 device_id。服务端根据 device_id 把旧会话上下文从缓存里捞出来恢复到断线之前的状态。这意味着如果用户上一句刚问完“今天天气怎么样”这时候 WiFi 闪断重连恢复后他接着说“那明天呢”服务端依然能理解“明天”指的是明天的天气。6. 调优记录与实测数据一步步抠出来的体验优化6.1 端到端延迟拆解连续对话的体验指标里最核心的是“首字延迟”——从用户说完最后一个字到玩偶发出第一个音节中间隔了多久。我们把它拆成四段采集与上行设备 VAD 判停到服务端收到最后一帧约 20~40msASR 识别流式识别最终结果返回约 200~400ms大模型生成 TTS 首包大模型流式吐字 TTS 流式合成约 400~700ms这部分取决于模型速度是最大的大头下行播放预缓冲 200ms四段加起来约 900ms~1.4s实际体验比较自然。重构前整段上传模式下首字延迟是 2.5~3.5 秒这个体感差异是革命性的。6.2 关键调优点记录第一个调优点是“TTS 首包抢跑”。最初我们是等大模型完整输出一句话后才让 TTS 开始合成后来改成大模型每输出 10~20 个字就增量喂给 TTSTTS 每产出 200ms 音频就立即下发。这样用户听到的声音是“边生成边说”而不是“先生成完再说”首字延迟直接少了 400ms。第二个调优点是播放缓冲大小。刚开始我们预缓冲 100ms网络好的时候流畅度不错但一旦 WiFi 信号波动就会出现明显的“一顿一顿”。改成 250ms 后20% 以内的网络抖动基本听不出来体验反而更好。慢 100ms 换稳定这笔账太值了。第三个点比较偏门ESP32 的 WiFi 发送突发特性。由于 WiFi 是共享信道发送任务经常出现“要么不发要么一连发好几包”的突发行为导致服务端收到音频帧的间隔不均。我们在服务端解析时不对帧到达时间做严格假设完全以帧头的 timestamp 为准来重建时间轴避免因为网络到达抖动而错误切分语音段。6.3 上线后的问题清单与对策现象根因解决方案播放 TTS 时偶发自我打断扬声器声音串到麦克风播放时麦克风增益降 6dB VAD 阈值翻倍连续对话时偶尔丢一个词WiFi 发送任务抢占导致录音缺帧录音任务绑 Core 0 高优先级发送任务绑 Core 1静默 30 秒后连接假死ESP32 modem sleep 导致心跳延迟长静默期暂时关闭 modem sleep或缩短 DTIM同一句话被识别成两次设备 VAD 与服务端静音判定重复触发服务端对同一 seq 段做去重ASR final 后 300ms 内不再启动新段路由器重启后玩偶恢复太慢重连退避时间过长检测到 WiFi 断开的瞬间主动触发重连不等 TCP 超时这四个对策里最耗我们精力的是第一个——自我打断。这个问题一度让用户以为玩偶“疯了”自己说话自己打断自己。后面通过日志确认确实是 TTS 功放的声音到达麦克风后能量超过了 VAD 语音阈值。降增益和抬高阈值的组合拳效果明显但这是软方案如果想支持大音量播放同时保持灵敏的打断还是得上真正的 AEC 算法。6.4 重构后的真实体感变化量化指标之外我更想分享一个没法用数字体现的变化用户不再把玩偶当成“会说话的玩具”而是当成“能聊天的伙伴”。重构后玩偶一直处于待机倾听状态孩子跟它说话不需要先叫名字或按按钮说错了可以立刻改口说一半停下来想一会儿再接着说系统都能接得住。这种无感交互才是连续对话的核心体验。如果你们也在做类似的设备端语音交互项目我的建议是先别急着上复杂的语义理解把音频链路的全双工和稳定性做扎实——协议帧设计、设备端任务分配、服务端状态机、断线恢复这四块是地基。地基稳了后续加离线唤醒词、声纹识别、多模态理解都是水到渠成的事。这次重构我们踩过不少坑但也确实验证了一件事用 WebSocket 二进制音频链路承载流式语音交互配合 ESP32 这颗芯片的能力做一个体验合格的 AI 玩偶完全可行。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。