0755-82922363
AD16N_1.4.1__TF卡和Flash共用引脚会导致程序在PC_mode下死机
AD16N_1.4.1__TF卡和Flash共用引脚会导致程序在PC_mode下死机TF卡和Flash共用SPI:怎么接电脑当U盘项目里用了杰理AD16N(一颗跑音频+蓝牙的小芯片,SOP16封装只有16个脚),T卡和外挂Flash得共用同一组SPI引脚。插上USB线进PC模式,电脑要同时看到T卡和Flash两个盘。三根线、两个设备、一条USB——其实跟独木桥一个道理:桥就一条,两辆车得轮着过,谁过桥谁就得把对面拦住。下面就按这个思路捋一遍。整体思路Plain Text// TF卡+Flash共用SPI → USB接电脑的完整链路板级配置(PA09/PA10/PA11)│├── 注册设备:sd0(T卡) + ext_flsh(Flash)│├── 进PC模式 → usb_slave_app()│├── dev_open("ext_flsh") → SPI仲裁 → Flash初始化│├── 预热读(sector 0、1)│└── usb_start() → USB枚举│├── 电脑发SCSI命令(READ_10 / WRITE_10)│├── MSD层拿到LUN号 → 找到对应设备│├── IOCTL_CMD_RESUME → SPI仲裁抢桥│├── Flash读写(带4KB cache)│└── IOCTL_CMD_SUSPEND → SPI仲裁还桥│└── 退出PC模式 → dev_close() → 释放SPI翻译:芯片上电先把两个设备都注册好,插USB进PC模式后电脑看到两个盘,每次读写Flash前先把T卡的SPI线挂起来(拦桥),读写完再还回去(放行),来来回回就这么切。一、硬件怎么共的——三根线两个人用AD16N的SOP16封装脚不够用,T卡和Flash只能共用PA09、PA10、PA11这三根线。两个设备看这三根线的角色不一样:引脚T卡视角(SD模式)Flash视角(SPI模式)PA09CLK(时钟)CS(片选)PA10DAT0(数据)CLK(时钟)PA11CMD(命令)DO/DI(数据出入)Plain Text// 共线拓扑——独木桥结构PA09 PA10 PA11│ │ │┌─────┴────────┴────────┴─────┐│ SPI / SD 总线 │└─────┬──────────────┬────────┘│ │┌─────┴─────┐ ┌─────┴────────┐│ T卡(SD) │ │ Flash(SPI) ││ CLK/DAT │ │ CS/CLK/DO ││ /CMD │ │ /DI │└───────────┘ └──────────────┘三根线就是那条独木桥——同一时刻只能有一个设备在上面跑数据,另一个得让路。整体架构放一张图:看一下SDK里怎么配的:Plain Text// app_config.h —— SPI引脚和复用开关#define TFG_SPI_UNIDIR_MODE_EN ENABLE //外挂Flash单线模式#define TFG_SPI_WORK_MODE SPI_MODE_UNIDIR_1BIT#define TFG_SPI_CS_PORT_SEL IO_PORTA_09#define TFG_SPI_CLK_PORT_SEL IO_PORTA_10#define TFG_SPI_DO_PORT_SEL IO_PORTA_11#define TFG_SPI_DI_PORT_SEL IO_PORTA_11 //单线模式DO和DI共用PA11#define SPI_SD_IO_REUSE TFG_SD_EN //SD启用时才开IO复用协调再看T卡那边的引脚定义:Plain Text// sd_port.h —— T卡引脚(和Flash完全重叠)#define SDMMC_CMD_IO IO_PORTA_11 //CMD → PA11#define SDMMC_CLK_IO IO_PORTA_09 //CLK → PA09#define SDMMC_DAT_IO IO_PORTA_10 //DAT → PA10两段配置对着看:PA09/PA10/PA11一个不差,Flash的SPI和T卡的SD用的就是同一组脚。SPI_SD_IO_REUSE这个宏就是"独木桥交通灯"的总开关——T卡启用了,复用协调才会生效。二、软件怎么仲裁的——独木桥上的交通灯硬件共线了,软件得管好"谁在用桥"。SDK里搞了一套信号量+SPI切换的机制,每次Flash要用SPI之前,先把T卡的IO挂起来,用完再还回去。抢桥:norflash_reuse_enter()Plain Text// norflash.c —— Flash抢占SPI总线static int norflash_reuse_enter(){if (norflash_reuse_keep) { //已经占着桥了,直接过return 0;}if (sd_io_reuse_suspend() != 0) { //叫T卡让路,让不了就失败return -1;}spi_busy = 1; //标记:桥上有人了spi_flash_io_resume(); //把SPI控制器重新配好给Flash用return 0;}还桥:norflash_reuse_exit()Plain Text// norflash.c —— Flash归还SPI总线static void norflash_reuse_exit(){if (norflash_reuse_keep) { //有"霸桥"标记,不还return;}if (!spi_busy) { //本来就没占,不用还return;}spi_flash_io_suspend(); //SPI引脚释放(IO设高阻)spi_busy = 0; //标记清除sd_io_reuse_resume(); //把T卡的IO还回去}T卡怎么让路:sd_io_reuse_suspend()这是最底层的仲裁——逐线挂起T卡的三个IO(CMD、CLK、DAT),每根线对应一把信号量:Plain Text// norflash.c —— 逐线挂起T卡IOint sd_io_reuse_suspend(){u8 sd_io_suspend_status = 0;u16 retry_cnt = 0;_retry:OS_ENTER_CRITICAL(); //关中断,进临界区if (0 != sd_io_suspend(0, 0)) { //挂CMD线goto _exit;}sd_io_suspend_status |= BIT(0);if (0 != sd_io_suspend(0, 1)) { //挂CLK线goto _exit;}sd_io_suspend_status |= BIT(1);if (0 != sd_io_suspend(0, 2)) { //挂DAT线goto _exit;}OS_EXIT_CRITICAL();return 0; //三根线全挂起,成功_exit://中途失败,已挂的线得还回去if (sd_io_suspend_status & BIT(0)) sd_io_resume(0, 0);if (sd_io_suspend_status & BIT(1)) sd_io_resume(0, 1);OS_EXIT_CRITICAL();if (retry_cnt++ < 10000) { //最多重试10000次,每次等10μsudelay(10);goto _retry;}return -1; //100ms还没拿到,彻底失败}独木桥上的交通灯工作流程:Flash要过桥 → 先按住T卡三根信号线的"红灯"(CMD→CLK→DAT逐个锁) → Flash过桥干活 → 干完把红灯放开 → T卡可以走了。整个过程在临界区里做,中断都关了,不会被打断。SPI底层切换做了什么"挂起"和"恢复"不只是改个标志位,SPI控制器得真正切换:Plain Text// spi.c —— SPI挂起(释放IO给T卡)void spi_suspend(hw_spi_dev spi){u8 *port = spix_p_data_cache[spi].port;spi_io_port_uninit(port[0]); //CLK → 高阻spi_io_port_uninit(port[1]); //DO → 高阻spi_io_port_uninit(port[2]); //DI → 高阻spi_io_crossbar_uninit(spi); //断开IO矩阵映射spi_disable(spi_regs[spi]); //关SPI外设}Plain Text// spi.c —— SPI恢复(IO重新配给Flash)void spi_resume(hw_spi_dev spi){spi_set_bit_mode(spi, spi_info->mode); //重配单线/双线模式spi_cs_dis(spi_regs[spi]); //CS手动控制spi_clk_idle_sel(...); //时钟极性spi_set_baud(spi, spi_info->clk); //波特率spi_enable(spi_regs[spi]); //开SPI外设}挂起=把IO全放成高阻+关SPI外设+断IO矩阵,恢复=重配寄存器+重连IO矩阵+开SPI。不是软件层面的"暂停",是硬件层面的"拆线重接"。整个仲裁时序放一张图,看起来更直观:拿一张表汇总一下整个仲裁流程的状态变化:阶段spi_busyT卡IO状态SPI控制器Flash可访问空闲0正常(T卡用)关闭❌Flash抢桥中0→1逐线挂起恢复❌→✅Flash使用中1挂起开启✅Flash还桥中1→0逐线恢复关闭✅→❌三、怎么接电脑的——USB MSD把Flash当U盘独木桥的仲裁搞定了,接下来看怎么让电脑认出这两个盘。USB Mass Storage用的是Bulk-Only Transport协议——电脑发SCSI命令,芯片收到后读写对应的存储设备,再把结果传回去。注册两个盘SDK里把T卡和Flash分别注册成USB MSD的两个LUN(逻辑单元号):Plain Text// msd.c —— 注册T卡和Flash为两个U盘_msd_var.max_lun = 2;_msd_var.inquiry[0] = SCSIInquiryData; //LUN0: T卡_msd_var.inquiry[1] = EX_SCSIInquiryData; //LUN1: Flashmsd_register_disk("sd0", NULL); //T卡msd_register_disk("ext_flsh", NULL); //外挂FlashPlain Text// device_list.c —— 设备注册表//U盘模式用norflash_dev_ops(512字节块单位,自带擦写管理){.name = "ext_flsh", .ops = &norflash_dev_ops, .priv_data = &norflash_data},//T卡用sd_dev_ops{.name = "sd0", .ops = &sd_dev_ops, .priv_data = &sd0_data},电脑插上USB看到两个盘:一个是T卡(LUN0),一个是Flash(LUN1)。电脑不知道它们共用SPI,它只管发SCSI命令,芯片内部自己协调。进PC模式的完整流程Plain Text// usb_slave_mode.c —— PC模式入口void usb_slave_app(void){usb_device_mode(0, 0); //配置USB为从机模式void *device = dev_open("ext_flsh", 0); //打开Flash设备if (device != NULL) {//开启4KB读缓存 + 关闭缓存同步的临界区保护dev_ioctl(device, IOCTL_SET_READ_USE_CACHE, 1);dev_ioctl(device, IOCTL_SET_CACHE_SYNC_ISR_EN, 0);//预热:读sector 0和1,把Flash叫醒+填充cachememset(usb_slave_flash_warmup_buf, 0, sizeof(usb_slave_flash_warmup_buf));dev_bulk_read(device, usb_slave_flash_warmup_buf, 0, 1);dev_bulk_read(device, usb_slave_flash_warmup_buf, 1, 1);}usb_start(); //启动USB枚举,电脑开始识别//... 消息循环处理SCSI命令 ...}进PC模式就四步:配USB从机 → 打开Flash(触发一次SPI仲裁) → 预热读两个扇区 → 启动USB。预热是为了把Flash从休眠里叫醒,顺便填好cache。电脑读写Flash的数据通路电脑发一个SCSI READ_10命令,芯片这边的处理路径(同步模式):Plain Text// msd.c —— SCSI READ_10处理(同步路径)static void read_10(const struct usb_device_t *usb_device){u32 lba = read_32(msd_var->cbw.lba); //起始扇区号u16 lba_num = (cbw.LengthH << 8) | cbw.LengthL; //扇区数while (lba_num) {num = lba_num > MSD_BLOCK_SIZE ? MSD_BLOCK_SIZE : lba_num;dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0); //抢桥err = dev_bulk_read(dev_fd, msd_buf, lba, num); //读Flashdev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0); //还桥msd_mcu2usb(usb_device, msd_buf, num * 512); //数据传给电脑lba += num;lba_num -= num;}}写操作(WRITE_10)也是同样的套路,多了一个flush:Plain Text// msd.c —— SCSI WRITE_10处理(同步路径,简化)while (lba_num) {msd_usb2mcu_64byte_fast(..., msd_buf, num * 512); //从USB收数据dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0); //抢桥dev_bulk_write(dev_fd, msd_buf, lba, num); //写Flashdev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0); //还桥}if (need_flush) {dev_ioctl(dev_fd, IOCTL_CMD_RESUME, 0);dev_ioctl(dev_fd, IOCTL_FLUSH, 0); //把cache里的脏数据刷到Flashdev_ioctl(dev_fd, IOCTL_CMD_SUSPEND, 0);}每次读写Flash的套路都是"抢桥→干活→还桥"三板斧。写完还得flush一下,把cache里攒着的脏数据真正写进Flash。电脑那边发 SYNCHRONIZE_CACHE命令时也会触发flush。四、Flash cache怎么工作的——桥上的临时仓库Flash写入有个特点:得先擦后写,而且擦除是按4KB扇区整块来的。如果每写512字节就擦一次4KB,速度慢得离谱,Flash寿命也撑不住。SDK的做法是在RAM里开一个4KB缓冲区当"临时仓库"——数据先往仓库里攒,攒满一个扇区或者被催了(flush)才真正擦写一次。cache的三个状态变量Plain Text// norflash.c —— Flash cache核心变量static u8 *flash_cache_buf; //4KB缓冲区(RAM里)static u32 flash_cache_addr; //当前缓存的是哪个4KB扇区static u8 flash_cache_is_dirty; //脏标记:缓冲区内容和Flash上不一样了static u8 flash_cache_timer; //脏计时:攒了多久没写写入流程:攒着写Plain Text// norflash.c —— _norflash_write() 写入逻辑(简化)//1. 算出写入地址落在哪个4KB扇区u32 align_addr = addr / 4096 * 4096;//2. 如果和当前缓存的扇区不一样,先把旧脏数据刷掉if (align_addr != flash_cache_addr) {if (flash_cache_is_dirty) {_norflash_eraser(FLASH_SECTOR_ERASER, flash_cache_addr); //擦旧扇区_norflash_write_pages(flash_cache_addr, flash_cache_buf, 4096); //写回flash_cache_is_dirty = 0;}_norflash_read(align_addr, flash_cache_buf, 4096, 0); //读新扇区到缓存flash_cache_addr = align_addr;}//3. 在缓冲区里改数据memcpy(flash_cache_buf + (addr - align_addr), w_buf, align_len);//4. 判断是否凑满一整个扇区if ((addr + align_len) % 4096) {flash_cache_is_dirty = 1; //没满,标脏,先不写flash_cache_timer = 1; //开始计时} else {//满了,立刻擦写_norflash_eraser(FLASH_SECTOR_ERASER, align_addr);_norflash_write_pages(align_addr, flash_cache_buf, 4096);flash_cache_is_dirty = 0;}写入逻辑就是"先攒后刷":数据塞进RAM缓冲区,凑满4KB整扇区就立刻擦写,没凑满就标个脏等着。跟你往快递箱里塞包裹一样——塞满了叫快递员来收,没满就先攒着。完整的写入流程放一张图:定时同步:别攒太久如果一直没凑满怎么办?SDK有个定时器 _norflash_cache_sync_timer(),隔一段时间检查一次——有脏数据就强制刷盘:Plain Text// norflash.c —— 定时同步脏缓存(简化)void _norflash_cache_sync_timer(u32 sync_step){if (flash_cache_is_dirty) {//同步也得抢桥!if (norflash_reuse_enter() == 0) {_norflash_eraser(FLASH_SECTOR_ERASER, flash_cache_addr);_norflash_write_pages(flash_cache_addr, flash_cache_buf, 4096);flash_cache_is_dirty = 0;norflash_reuse_exit(); //用完还桥}}}定时同步也得走独木桥仲裁——抢桥→擦写→还桥。所以就算Flash自己在后台刷缓存,T卡的SPI也会被短暂挂起。五、会出什么问题——桥上堵车的几种情况独木桥方案能跑,但有几个容易出问题的地方。1:仲裁超时死机sd_io_reuse_suspend()重试10000次×10μs = 100ms。T卡如果在做大块读写,100ms内还没释放IO信号量,Flash这边就返回-1。上层拿到-1后的处理方式各不相同——有的直接返回0(读写失败但不崩),有的地方没做错误处理就直接空指针炸了。查了半天发现根因有两层:•T卡在做连续多扇区读写时,一个命令周期可能超过100ms•Flash驱动的 norflash_bulk_read拿到-1后返回0,MSD层以为读了0个扇区,后续指针计算就错了修法:1.在 sd_io_reuse_suspend()里把重试上限从10000调到更大的值(或者改成带超时的信号量等待)2.在 norflash_bulk_read/norflash_bulk_write失败路径加明确的错误码返回,MSD层收到错误码后回 MEDIUM_ERROR给电脑,不继续读写Plain Text// norflash.c —— 仲裁失败时的日志(排查用)//抢桥失败会打印:log_error("nf reuse enter fail keep=%d busy=%d", norflash_reuse_keep, spi_busy);//等Flash BUSY超时会打印:log_error("norflash_wait_ok timeout cmd=%x off=%x len=%x wr=%d sr1=%x busy=%d keep=%d",norflash_dbg_last_cmd, norflash_dbg_last_offset, norflash_dbg_last_len,norflash_dbg_last_is_write, reg_1, spi_busy, norflash_reuse_keep);看到这两条log就知道是独木桥堵车了——要么T卡太忙让不了路,要么Flash操作太慢桥上堵住了。2:异步MSD时序冲突SDK里 msd.c有个 USB_MSD_BULK_DEV_USE_ASYNC宏控制异步写入。异步模式下USB收数据和Flash写数据是重叠的(双缓冲乒乓),但SPI仲裁不是线程安全的——如果USB中断里触发了T卡检测,同时Flash又在写,两边同时操作SPI就会数据错乱。Plain Text// msd.c —— 异步开关#define USB_MSD_BULK_DEV_USE_ASYNC 0 //关闭异步MSD,走同步读写路径这个宏设成0就是走同步路径:收完USB数据再写Flash,写完再收下一包。慢一点但安全。共用SPI的场景下别开异步,时序冲突太难排查。3:写入慢(flush太频繁)电脑往Flash盘拷文件时,每写几个扇区Windows就发一次 SYNCHRONIZE_CACHE命令。每次flush都要走"抢桥→擦写4KB→还桥",而Flash擦除一个4KB扇区要几十ms。如果flush太频繁,写入速度就被擦除时间拖慢。调了一下发现:•不设缓存,每512字节都擦写一次:写入极慢,Flash寿命也撑不住•缓存开了但 CACHE_SYNC_ISR_EN没关,定时器在中断里也会抢桥flush:写入时被打断,速度波动大•IOCTL_SET_CACHE_SYNC_ISR_EN设成0(关闭中断内同步),只在SCSI的 SYNCHRONIZE_CACHE和手动flush时才刷缓存:写入稳定,速度刚好Plain Text// usb_slave_mode.c —— PC模式下的缓存策略dev_ioctl(device, IOCTL_SET_READ_USE_CACHE, 1); //读缓存开dev_ioctl(device, IOCTL_SET_CACHE_SYNC_ISR_EN, 0); //关中断内自动同步PC模式下得关掉中断内自动同步,不然后台定时器会抢桥flush,和前台的SCSI读写撞在一起。让flush只在SCSI命令触发时走,独木桥的通行顺序就可控了。六、串联回顾——完整走一遍从板子上电到电脑看到两个盘并读写Flash,整条链路用树枝图串起来:Plain Text// 完整数据链路——从配置到读写板级配置├── PA09/PA10/PA11 三脚共用(独木桥)├── SPI_SD_IO_REUSE = 1(交通灯开关)│设备注册├── "sd0" → sd_dev_ops(T卡驱动)├── "ext_flsh" → norflash_dev_ops(Flash驱动,512B块模式+cache)│进PC模式 usb_slave_app()├── ① USB配从机模式├── ② dev_open("ext_flsh") → norflash_reuse_enter()(第一次抢桥)│└── sd_io_reuse_suspend() → spi_flash_io_resume()├── ③ 预热读sector 0、1(Flash叫醒+cache填充)├── ④ usb_start()(USB枚举,电脑识别到两个盘)│电脑发SCSI命令├── READ_10 / WRITE_10│├── IOCTL_CMD_RESUME → norflash_reuse_enter()(抢桥)│├── dev_bulk_read/write → SPI读写Flash(桥上干活)││└── 写入走4KB cache:攒满擦写 / 标脏等flush│└── IOCTL_CMD_SUSPEND → norflash_reuse_exit()(还桥)├── SYNCHRONIZE_CACHE → IOCTL_FLUSH → 强制刷脏缓存│退出PC模式└── dev_close("ext_flsh") → norflash_reuse_exit()(最后一次还桥)回扣一下独木桥的类比:现实中代码里独木桥(一条路)PA09/PA10/PA11(三根共用线)交通灯(红绿灯)sd_io_reuse_suspend()/ resume()车辆通行证spi_busy+ norflash_reuse_keep桥上施工(读写Flash)spi_flash_io_resume()+ SPI命令临时仓库(桥头堆货)flash_cache_buf(4KB RAM缓冲)催收员(定时来收货)_norflash_cache_sync_timer()收费站(电脑USB口)USB MSD Bulk-Only Transport整条链路就这些——配置共脚、注册设备、仲裁抢桥、cache攒着写、USB传数据。搞明白"独木桥怎么轮着走"这一件事,剩下的都是工程细节。感受SPI共线本身不复杂,跟I2C多从机一个思路,分时复用嘛。SDK把仲裁封装成 norflash_reuse_enter/exit也算清晰,抢桥还桥看代码就能明白。USB MSD协议更不用说了,SCSI命令集翻一翻就上手。卡住我比较久的是仲裁时序——T卡和Flash的SPI切换牵扯IO矩阵、信号量、临界区,出了问题条件都凑不齐,很难复现。cache flush策略也绕了一阵,什么时候刷、在哪个上下文刷、会不会和前台SCSI命令撞车,翻了好几遍代码才理顺。最意外的是MSD异步双缓冲,宏定义看着挺美,放到共用SPI场景下直接没法用,这块的问题还是得等待杰理的AD小组来完善共脚、仲裁、cache、MSD——搞明白这四个词,这套方案就算摸透了。
2026.05.18了解详情
蓝牙PAN协议——不用WiFi,蓝牙也能上网?
蓝牙PAN协议——不用WiFi,蓝牙也能上网?最近刷YouTube的时候看到一个东西:蓝牙PAN。感觉挺有意思的,跟大家分享一下——嵌入式设备没有WiFi模块(或者WiFi挂了),但有经典蓝牙,能不能借手机的网络上网?PAN就是干这个的。整个链路大概是这样的:蓝牙配对 → SDP发现NAP服务 → BNEP连接 → DHCP拿IP → 正常跑TCP/IP。做个记录分析一下。一、PAN是什么——蓝牙版的"网络共享"PAN全称Personal Area Networking Profile,是 经典蓝牙(BR/EDR)的一个Profile。核心能力就一句话:通过蓝牙链路传输以太网帧,让蓝牙设备像接了网线一样上网。日常最常见的场景:手机开蓝牙网络共享,平板/手表/嵌入式设备连上去就能上网。跟WiFi热点比,PAN功耗更低、不占WiFi通道,但带宽也低——经典蓝牙EDR理论3Mbps,实际1~2Mbps(受环境干扰波动大,WiFi共存时可能只有700Kbps)。发个HTTP请求、跑个MQTT够用,传大文件就别想了。和前面搞过的WiFi+Socket那套路完全不同:WiFi走的是802.11无线局域网,PAN走的是蓝牙射频。但上了IP层之后,TCP/UDP/HTTP该怎么跑还怎么跑——对应用层来说是透明的。二、三个角色——谁提供网,谁用网PAN里定义了三个角色:角色全称干什么典型设备PANUPAN User上网的一方(客户端)嵌入式设备、平板NAPNetwork Access Point提供网络接入(像路由器)手机、笔记本GNGroup Ad-hoc Network自组网,设备之间互连临时会议、传感器组网几个关键区别:NAP能接外网,GN不能——GN只管自己组里的设备互相通信,不提供互联网NAP和GN都能转发包,PANU不转发NAP和GN不能互连——一个设备要么做NAP要么做GN,不能同时是两者嵌入式设备99%的场景是做PANU——连上手机(NAP),借手机的4G/5G/WiFi上网。三、协议栈——BNEP是关键的"桥"PAN的协议栈长这样:BNEP(Bluetooth Network Encapsulation Protocol)是PAN的核心。它干的事说白了就是:把以太网帧的头扒掉,换上BNEP自己的头,然后塞进L2CAP通道传出去。对面收到后再把BNEP头扒掉,还原成以太网帧丢给IP层处理。对比旧方案在PAN之前,蓝牙设备上网走的是PPP over RFCOMM——IP层到L2CAP之间得经过PPP和RFCOMM两层协议。BNEP直接桥接IP层到L2CAP,省掉了PPP和RFCOMM两层开销,效率高不少。BNEP的五种包类型类型携带的地址适合场景GENERAL_ETHERNET自身MAC + 对端MAC多连接场景,通用COMPRESSED_ETHERNET不带地址一对一,PANU↔PANUCOMPRESSED_DEST_ONLY只带对端MAC一对多,省自身地址COMPRESSED_SOURCE_ONLY只带自身MAC一对多,省对端地址CONTROL—控制命令(连接/过滤器)一对一连接的时候用COMPRESSED模式可以省掉地址字段,包更小。BNEP的MTU最小得1691字节,L2CAP配置阶段如果给的MTU比这小,通道直接建不起来。四、连接流程——从配对到上网整个PAN从蓝牙配对到能上网,走了六步:一步步拆开看:ACL连接——标准蓝牙配对,建立底层链路SDP服务发现——PANU去查NAP有没有PAN服务。关键信息包括:Service Class ID(标识是NAP还是GN)、BNEP版本(v1.0)、支持的以太网类型(IPv4 0x0800、ARP 0x0806)、PSM = 0x000FL2CAP通道——基于PSM 0x000F建L2CAP连接,MTU得 ≥ 1691BNEP Setup——PANU发 SETUP_CONNECTION_REQUEST,NAP回 SETUP_CONNECTION_RESPONSE(响应码0x0000表示成功)。到这一步BNEP通道就通了DHCP——走标准DHCP四步(DISCOVER → OFFER → REQUEST → ACK),NAP给PANU分配IP地址ARP——MAC地址和IP地址互相绑定,之后就可以直接用IP地址通信了DHCP和ARP跑完,PANU就有了自己的IP地址。后面该怎么发HTTP、怎么跑MQTT,跟WiFi环境下一模一样——应用层完全感知不到底下走的是蓝牙。五、数据收发——以太网帧怎么走蓝牙数据走PAN的路径:接收就反过来:蓝牙射频收到 → L2CAP重组 → BNEP解析还原成以太网帧 → 丢给IP层。BNEP帧格式Plain Text// BNEP帧结构┌──────────┬──────────────┬──────────────┬──────────────┬──────────┐│ BNEP Type│ Dest Addr │ Src Addr │ Protocol Type│ Payload ││ (1 byte) │ (6B, 可选) │ (6B, 可选) │ (2 bytes) │ (变长) │└──────────┴──────────────┴──────────────┴──────────────┴──────────┘BNEP Type决定了后面有没有地址字段。GENERAL_ETHERNET带全部地址,COMPRESSED模式省掉一个或两个。Protocol Type标识上层是IPv4(0x0800)还是ARP(0x0806)还是IPv6(0x86DD)。六、嵌入式视角——MCU上怎么跑PANTCP/IP栈的选择MCU上跑PAN,蓝牙协议栈管到BNEP这一层,但BNEP上面的IP/TCP/UDP得有个TCP/IP栈来处理。两个常见选项:TCP/IP栈RAM占用适合uIP~几KB极度内存受限、低带宽场景lwIP~几十KB有RTOS、功能更全uIP比lwIP更轻量,适合资源紧张的场景。泰凌的BT SDK就是用uIP跑PAN的。虚拟网卡——用环形队列模拟PAN走的是蓝牙射频,不是真的网卡。但TCP/IP栈(不管是uIP还是lwIP)都认为自己在跟一张网卡打交道。所以得用软件模拟一张虚拟网卡:Plain Text// 虚拟网卡架构┌─────────────┐│ uIP/lwIP ││ TCP/IP栈 │└──────┬──────┘读取↓ ↑写入┌──────────┐ ┌──────────┐│ RX FIFO │ │ TX FIFO ││(环形队列)│ │(环形队列)│└────┬─────┘ └─────┬────┘写入↓ ↑读取┌──────────────────────┐│ BNEP收发层 ││ (蓝牙协议栈) │└──────────────────────┘收方向:蓝牙收到BNEP帧 → 解析成以太网帧格式 → 写进RX FIFO → TCP/IP栈从RX FIFO取出处理发方向:TCP/IP栈把以太网帧写进TX FIFO → BNEP层从TX FIFO取出 → 封装成BNEP帧 → 蓝牙发出两个环形队列就是这张"虚拟网卡"的驱动。main loop不停地在两个FIFO之间搬数据。带宽和适用场景经典蓝牙EDR理论带宽3Mbps,减去协议开销实际也就~2Mbps。跑轻量级的HTTP请求、MQTT消息收发完全够用。但流媒体、大文件下载就别指望了。什么时候考虑PAN:设备没有WiFi模块,但有经典蓝牙——PAN是唯一的IP联网途径WiFi环境不可靠,蓝牙做备选通道——BT+WiFi双链路容灾不想额外开WiFi,省功耗——蓝牙本身就开着(比如在跑A2DP播歌),顺带走PAN上网七、PAN vs 其他蓝牙联网方案对比项PAN(BNEP)SPP+PPPBLE IPSPWiFi热点协议栈IP→BNEP→L2CAPIP→PPP→RFCOMM→L2CAPIP→6LoWPAN→L2CAP(BLE)标准WiFi蓝牙类型经典BT(BR/EDR)经典BTBLE 4.1+不走蓝牙带宽~2Mbps~1-2Mbps(RFCOMM开销略高)200Kbps~1Mbps(取决于BLE版本和PHY)几十Mbps功耗中中低高层级开销少(3层)多(4层)少—适合通用IP通信老设备兼容BLE低功耗场景高带宽SPP+PPP是老方案,多了RFCOMM和PPP两层,带宽也不如PAN。BLE IPSP走的是低功耗蓝牙,功耗最低但带宽也最低,适合传感器那种数据量小的场景。WiFi热点带宽碾压,但功耗也最高。八、PAN的硬伤——为什么实际产品里用的人不多协议栈是完整的,技术上能跑通。但翻了一圈论坛和实测数据,PAN在蓝牙协议族里一直属于"有但没人用"的那一类。原因很现实:带宽天花板太低经典蓝牙EDR理论最大3Mbps,但那是射频层的峰值。走完L2CAP → BNEP → IP这一串协议开销之后,实际到应用层的吞吐量:场景实测网速备注BT PAN共享(WiFi同时开着)~700 KbpsXDA论坛多人复现,很普遍BT PAN共享(WiFi关闭)~1.2-1.5 Mbps关WiFi后明显提速BT PAN共享(理想环境)~2 Mbps 封顶协议理论上限700Kbps这个数字很多人都碰到了。原因是蓝牙和WiFi共用2.4GHz频段,WiFi开着的时候蓝牙得让路(共存机制coexistence),吞吐量直接砍一半。关WiFi之后能跑到1.5Mbps左右,但也就到头了。对比一下:WiFi热点轻松几十Mbps,USB共享跑满手机上行。PAN的带宽只够发HTTP请求、跑MQTT、传个小文件。延迟高且抖动大蓝牙PAN的实测ping在 100~200ms量级(来源:XDA/SuperUser论坛多位用户实测,非官方数据),而且 抖动很大——有人测出来稳定200ms,有人在100~400ms之间跳。蓝牙的跳频机制和重传策略本身就不是为低延迟设计的,再叠上BNEP封装和IP栈处理,延迟没法控。对比:WiFi局域网1~5ms,4G网络20~50ms。蓝牙PAN比4G还慢。2.4GHz干扰敏感蓝牙工作在2.4GHz ISM频段,跟WiFi、微波炉、无线鼠标键盘全挤在一起。WiFi密集的环境(办公室、展会)里,蓝牙跳频虽然能一定程度避开干扰,但丢包率和重传都会上去,吞吐进一步下降。多设备共享带宽见底PAN本质是点对点连接。一个NAP理论上能接7个PANU(piconet上限),但实际同时活跃2~3个就很吃力了——带宽是共享的,3个设备一起跑就每个只剩几百Kbps。操作系统支持在退化这才是最致命的。iOS不开放蓝牙PAN的PANU角色——iPhone只能做NAP给别人共享网络,不能作为PANU通过别人的蓝牙上网(不是硬件不支持,是Apple没开放这个接口)。Android虽然支持,但从Android 10开始不少厂商的ROM把蓝牙共享优先级降了,部分机型直接砍掉了PAN选项。Windows倒是一直支持,但配对流程比WiFi麻烦得多。整个行业的趋势:WiFi Direct / WiFi Aware拿走了高带宽场景,BLE拿走了低功耗场景,PAN卡在中间两头不靠。九、PAN能不能跑WebRTC/AI语音?——结合AI玩具场景聊聊最近AI玩具(AI语音对话玩具、故事机、陪伴机器人)特别火,这类产品的核心链路都差不多:Plain Text// AI玩具的典型语音交互链路MIC录音 → 音频上传云端 → ASR语音识别 → LLM大模型推理→ TTS语音合成 → 音频下载播放绝大多数AI玩具走的是WiFi联网。但WiFi有个痛点:配网体验差。让小朋友或者不懂技术的家长在一个没屏幕的玩具上输WiFi密码,是很头疼的事情。蓝牙配对就简单多了——扫一下、点一下就连上了。所以一个很自然的想法是:能不能用蓝牙PAN代替WiFi,让AI玩具通过蓝牙借手机的网络上网?省掉WiFi模块和配网流程,成本也能降一点。答案是:看场景,大部分情况不行。WebRTC实时语音通话:不行现在不少AI语音方案(比如百度BRTC、火山引擎RTC)走的是WebRTC协议,音频通过RTP实时双向传输。WebRTC对网络的要求:指标WebRTC要求蓝牙PAN能提供带宽(纯语音)40~80 Kbps双向✅ 够带宽(语音+视频)1~5 Mbps双向❌ 远不够单向延迟< 150ms❌ 蓝牙PAN自身就100~200ms端到端延迟< 300ms❌ PAN 100~200ms + 互联网 50~100ms > 300ms抖动越小越好❌ 100~400ms不可控带宽跑纯语音是够的,但延迟是硬伤。蓝牙PAN自身就吃掉100~200ms,加上STUN/TURN服务器中转和对方网络延迟,端到端很容易超过300ms。超过300ms的语音通话体验就很差了——说话跟对讲机似的,得等对方说完才能接话。而且WebRTC的拥塞控制算法(GCC)会根据延迟和丢包动态调码率。蓝牙PAN的高抖动理论上会让GCC反复误判网络状况,码率忽高忽低,音频断断续续(这是合理推测,目前没找到有人在蓝牙PAN上实测WebRTC的报告)。HTTP/WebSocket方式的AI语音:勉强能用不是所有AI语音方案都走WebRTC。有些方案是这样的:Plain Text// HTTP/WS方式的AI语音交互录完一段话 → HTTP POST音频到云端 → 等云端返回TTS音频 → 播放// 或者录音 → WebSocket实时流上传 → 云端流式返回TTS → 边收边播这种方式对延迟没那么敏感——用户说完一句话,等个1~2秒出结果是可以接受的(像ChatGPT那样)。蓝牙PAN跑这种场景:带宽:一段5秒的Opus音频也就几十KB,上传没压力延迟:多了100~200ms的蓝牙传输延迟,用户感知不明显(本来就要等LLM推理)TTS下载:流式TTS边生成边播,每个chunk几KB,700Kbps也够用所以非实时的AI语音交互,蓝牙PAN理论上是可以跑的。但有个前提:手机得一直开着蓝牙共享,而且不能离太远(蓝牙有效距离10米左右)。AI玩具到底该怎么选方案带宽延迟配网体验成本适合WiFi直连几十Mbps低差(要输密码)要WiFi模块WebRTC实时对话、视频BLE配网+WiFi几十Mbps低好(BLE引导配网)要WiFi+BLE主流AI玩具方案蓝牙PAN~1Mbps高好(蓝牙配对)省WiFi模块非实时HTTP/MQTT4G模组几Mbps中免配网(插卡即用)模组+流量费户外/无WiFi场景现在主流AI玩具的做法是BLE配网+WiFi通信——用BLE把WiFi密码传给设备,设备再连WiFi跑业务。这样既解决了配网体验问题,又有WiFi的带宽和低延迟。蓝牙PAN只在一个很窄的场景下有价值:设备没有WiFi模块,只有经典蓝牙,而且业务不需要实时音视频。比如一个只跑MQTT上报传感器数据的小设备,或者一个只做HTTP请求查天气/查百科的简单终端。但说实话,现在WiFi+BT combo芯片(比如杰理AC791N、乐鑫ESP32)已经很便宜了,没什么理由非要省掉WiFi走PAN。PAN更多是一个备用通道或者技术好奇心的存在。总的来说PAN是个完整的技术方案,协议栈设计得很工整——BNEP做桥、SDP做发现、lwIP做TCP/IP、DHCP做地址分配,每一层该有的都有。但带宽低、延迟高、抖动大、OS支持退化这四个硬伤决定了它在实际产品里用不起来。能跑的场景:HTTP请求、MQTT消息、REST API、小文件传输——"不着急"的IP通信。跑不了的场景:WebRTC音视频、实时语音对话、流媒体——任何对延迟敏感的东西。对AI玩具来说,WiFi仍然是不可替代的。PAN最多做个WiFi挂了之后的降级备份,或者在纯蓝牙芯片上做个轻量联网的备选——但这种场景在WiFi combo芯片白菜价的今天,越来越少了。
2026.05.12了解详情
杰理AC6951C双蓝牙方案——两颗芯片拼出"A蓝牙+B蓝牙+外音"三合一音频系统
杰理AC6951C双蓝牙方案——两颗芯片拼出"A蓝牙+B蓝牙+外音"三合一音频系统最近做了一个双蓝牙音箱项目:一台音箱能同时接两部手机的蓝牙音乐,还能接外部LINE_IN音源。单颗AC695x只有一路A2DP,不够用,于是用了两颗AC6951C,A芯片管一路蓝牙,B芯片管另一路蓝牙+外音+IIS输出,两颗芯片之间用UART传状态、模拟开关切音频。这篇文章重点讲两件事:硬件上两颗芯片怎么连的,以及软件状态机怎么决定谁出声。一、为啥要搞这个组合先过一下AC6951C的底子:32-bit RISC CPU @ 120MHz(杰理自研pi32指令集)蓝牙5.0,BLE + EDR,A2DP / AVRCP / HFP / SPP128KB SRAM,外挂 SPI NOR Flash(最大16MB)24-bit Audio DAC,差分/单端输出,SNR > 95dBLADC 2通道,支持 LINE_IN 模拟采样I2S/IIS Master/Slave,最高 48kHz 立体声3×UART,10-bit SAR ADC(ADKEY),内置充电管理封装 QFN32 / QFN48这颗芯片天生就是做蓝牙音箱的料——内置完整蓝牙协议栈,A2DP解码后直接DAC推功放,EQ/DRC/虚拟低音全片内跑,SDK成熟开箱即用。但它单颗只支持一路A2DP,不能同时连两部手机播歌。客户要三路音源自由切换:手机A播歌、手机B播歌、外部LINE_IN。所以方案就是两颗AC6951C各跑一套蓝牙协议栈,硬件上用模拟开关合到一条音频通路:A芯片:负责一路蓝牙,DAC模拟输出B芯片:负责另一路蓝牙 + LINEIN采集 + DAC/IIS双输出两者之间:UART传蓝牙状态,模拟开关切音频来源二、硬件怎么接的┌───────────────────────┐UART1(AT指令)┌───────────────────────┐│ A 芯片 │ ───────────────────────────── → │ B 芯片 ││(AC6951C A_SDK) ││(AC6951C B_SDK) │││DAC模拟输出│││手机A ── BT A2DP ──→ DAC ──→──┐ │││││ │手机B ── BT A2DP│└───────────────────────┘│ │││┌──────────┐ ││└──→ │ 模拟开关│ ──→ LADC ──→ DAC ──→ 喇叭│ 外部LINE_IN ────────→ │(io控制) │ │ ↓ │└──────────┘ │IIS ──→ 外部DAC │└───────────────────────┘A芯片的DAC模拟输出和外部LINE_IN都接到一个模拟开关上,B芯片用PA9控制选通哪路,选通后经LADC采样,走DAC和IIS双路输出。B芯片还用了几组GPIO做硬件控制:PB11控制功放静音(切换音源时先mute再unmute避免pop声),PC2/PC1/PC0三根线组合控制功放前端的音频路由(000=B蓝牙→喇叭,100=A蓝牙→喇叭,010=外音→喇叭),PA12做密码复位检测(长按10秒低电平恢复默认PIN码)。IIS方面,B芯片配置成DAC+IIS双输出,IIS跑Master模式、48kHz,同时给外部DAC芯片送数字音频。三条音频通路一目了然:A蓝牙播放:手机A → A芯片BT解码 → A DAC → 模拟开关(PA9=LOW) → B LADC → B DAC+IIS → 喇叭B蓝牙播放:手机B → B芯片BT解码 → B DAC+IIS → 喇叭外部LINE_IN:外部音源 → 模拟开关(PA9=HIGH) → B LADC → B DAC+IIS → 喇叭三、软件状态机怎么跑的A芯片很简单,就干两件事:正常当蓝牙音箱(连手机、收A2DP、DAC输出),以及把自己的蓝牙状态通过UART1用AT指令告诉B(AT+STATUS=CONNECTED/DISCONNECTED/PLAY/PAUSE)。A上电后主动把音量拉满,不依赖手机端音量同步。B芯片是整个系统的"大脑"。它在UART1中断里收A的状态,通过系统事件投递到任务上下文解析,然后所有决策都围绕四个状态变量展开:A是否连接、A是否在播、B是否连接、B是否在播。决策规则很直观:模拟开关(PA9):A或B任一在播,或A/B同时连接 → PA9拉低选通A的DAC;否则PA9拉高选通外部LINE_IN。切换时先静音等30ms再恢复,防pop声。功放路由(PA-SHDN):A在播→A蓝牙出声,B在播→B蓝牙出声,都没播→外音出声。任务切换:B在播歌就跑BT任务,B没播歌就切回LINEIN任务。另外还做了两层保护:暂停10秒超时——A或B暂停后不立即判定停止,等10秒没恢复才切走,避免切歌时音源乱跳;后台A2DP回切拦截——B刚切走LINEIN的短窗口内,蓝牙底层试图自动抢回BT任务会被拦住。四、踩过的坑LINEIN播久了有规律POPO声:播几分钟后出现pop声,串口伴随 W 字符。查下来是LADC环形缓冲区溢出丢数据——LADC采样时钟和DAC回放时钟有微小频差,SDK原有的采样率补偿力度不够,加上双蓝牙后台+UART+IIS的额外CPU负载,缓冲区水位逐渐漂移直到溢出(对应杰理官方问题 154)。修法是把采样率补偿从单级改成三级(水位越偏离中心补偿越猛),同时把缓冲区从 *6(约17ms)加大到 *24(约70ms)。蓝牙音量同步干扰:有些手机默认不开音量同步,上一个用户关了音量,下一个连上就没声音。修法是A和B都关掉音量同步(BT_SUPPORT_MUSIC_VOL_SYNC = 0),各自上电主动设最大音量。一句话总结:两颗AC6951C,A只管连手机播歌并把状态告诉B;B是中枢,根据四个状态变量决定模拟开关接谁、功放路由怎么走、当前任务跑BT还是LINEIN。
2026.04.24了解详情
杰理AC791 AI对话 TTS音频下行链路分析
LLM 文本 → TTS 语音 → OPUS 编解码 → 播放:完整技术链路1. 文档目标系统讲清楚以下链路涉及的业务流程、技术原理、背景知识以及每个参数为什么这样选:LLM 文本 → TTS 语音合成 → OPUS 编码 → 网络传输 → OPUS 解码 → 扬声器播放本文不涉及具体代码实现,只讲业务流、技术原理和参数设计。2. 整体业务流全景2.1 完整链路图flowchart TD%% 云端服务模块subgraph 云端服务A["用户语音 → STT 识别为文本"]B["LLM 大语言模型生成回复文本"]C["TTS 文本转语音引擎"]D{"TTS 输出格式"}D1["原始 PCM"]D2["MP3 / WAV"]D3["解码为 PCM"]E["PCM 重采样 + 转单声道 + 按帧切分"]F["OPUS 编码器压缩"]end%% 网络传输模块subgraph 网络传输G["JSON 控制消息:字幕/状态"]H["二进制 OPUS 音频包"]end%% 设备端模块subgraph 设备端I["接收 JSON → 更新字幕和设备状态"]J["接收 OPUS 包 → 放入解码队列"]K["OPUS 解码 → 得到 PCM"]L{"设备采样率 = 服务端采样率?"}M["重采样适配"]N["直接使用"]O["PCM 送入播放队列"]P["音频硬件驱动输出"]Q["扬声器发声"]end%% 云端服务流程连线A --> B B --> C C --> DD -->|直接输出 PCM| D1 --> ED -->|输出 MP3/WAV| D2 --> D3 --> EE --> FF --> HB -.文本字幕.-> G%% 网络传输 → 设备端流程连线G --> IH --> J J --> K K --> LL -->|不一致| M --> OL -->|一致| N --> OO --> P P --> Q2.2 核心要点LLM 只负责生成文本,不产出任何音频TTS 引擎在云端运行,把文本合成为语音OPUS 编码在云端完成,设备端只做解码和播放设备同时收到两路数据:JSON 文本消息:用于屏幕字幕显示、控制设备状态机OPUS 二进制音频包:用于扬声器真正发声这两路是并行的,不是串行的3. 每个环节的技术原理3.1 LLM 大语言模型输入:用户的语音识别结果(文本)输出:自然语言回复(纯文本字符串),例如 "今天天气晴,最高温度 26 度。"关键特性:LLM 是流式输出的,一边生成 token 一边往下游推送TTS 不需要等整句话生成完才开始合成流式能力直接决定了整条链路的首包延迟(用户说完话到听到回复的等待时间)3.2 TTS 文本转语音做什么把 LLM 输出的文本转换成人声语音波形。常见 TTS 输出格式对比格式本质特点PCM未压缩原始采样体积大,无需解码,零延迟WAVPCM + 文件头本质还是 PCM,多了 44 字节头MP3有损压缩广泛兼容,但编码延迟大(~100ms+)OGG/Opus低延迟有损压缩专为实时通信设计AAC有损压缩苹果生态常用,需要授权费为什么不直接把 TTS 原始输出发给设备PCM/WAV 太大:16kHz/16bit/mono PCM,每秒 32KB,10 秒对话 320KB,对嵌入式设备和窄带网络负担沉重MP3 延迟高:MP3 编码器帧延迟较大,不适合逐句流式场景需要统一格式:设备上行(麦克风)和下行(语音回复)需要统一协议,避免维护两套编解码器结论:无论 TTS 引擎输出什么格式,都在云端统一转换为 OPUS 再发给设备。3.3 OPUS 编解码器背景OPUS 是由 IETF 标准化的开放音频编码格式(RFC 6716),专为实时互联网通信设计。WebRTC、Discord、Zoom、微信语音通话等产品的底层都使用 OPUS。OPUS vs 其他编码对比特性OPUSMP3AACPCM算法延迟2.5~60ms 可调~100ms+~20ms+0ms码率范围6~510 kbps32~320 kbps8~256 kbps固定语音专项优化✅ SILK 内核❌❌不适用帧长灵活性2.5~120ms 多档固定 26ms固定 ~21ms任意丢包容忍内置 FEC + PLC差差极差流式友好天生流式需缓冲需缓冲天生流式嵌入式适用复杂度可调(0~10)解码较重解码较重无需解码开源免版税✅专利已到期❌ 需授权不适用OPUS 的内部结构OPUS 是两个编码器的混合体:┌──────────────────────────────────────┐│OPUS 编码器││││┌──────────┐┌───────────────┐│││ SILK││ CELT││││(语音) ││(音乐/通用)│││└──────────┘└───────────────┘││ ↑↑││窄带/宽带语音 全频带音频││(人声为主)(音乐/混合)│└──────────────────────────────────────┘SILK 内核:源自 Skype,针对人声优化,低码率下语音清晰度极佳CELT 内核:源自 Xiph.org,针对全频带通用音频OPUS 会根据输入内容自动切换或混合两个内核为什么 OPUS 特别适合 AI 语音助手低延迟:对话要求自然流畅,OPUS 算法延迟远低于 MP3低码率音质好:16kbps OPUS 语音质量 ≈ 64kbps MP3帧长可调:可根据嵌入式设备处理能力选择合适帧长复杂度可调:嵌入式 MCU 可以把复杂度设到最低,省 CPU天然流式:收到一帧解码一帧,不需要缓冲整段音频上下行统一:麦克风上传和语音下发共用同一套编解码器3.4 网络传输并行双通道设备和服务端之间同时维持两个逻辑通道:通道内容作用控制通道JSON 文本消息状态切换、字幕显示音频通道OPUS 二进制包真正的语音数据两种传输方案方案 A:WebSocket 全双工设备 ◄══════ WebSocket ══════► 服务端文本帧 (JSON)◄─────►二进制帧 (OPUS) ◄─────►一条连接承载 JSON + 音频基于 TCP,可靠传输实现简单,延迟略高方案 B:MQTT 控制 + UDP 音频设备 ◄── MQTT/TLS ──► 服务端(JSON 控制,可靠)设备 ◄── UDP/AES ───► 服务端(OPUS 音频,低延迟)控制走 MQTT/TLS:可靠、有 QoS音频走 UDP + AES-128-CTR 加密:更低延迟需要自行处理丢包和序列号校验比 WebSocket 方案延迟更低,但实现更复杂握手协商设备连接时发送 hello 消息声明音频参数:{"type": "hello","audio_params": {"format": "opus","sample_rate": 16000,"channels": 1,"frame_duration": 60}}服务端回复确认最终参数(可能微调采样率等),双方协商一致后音频通道正式开启。一次完整的 TTS 消息时序时间 ─────────────────────────────────────────────────────► ① {"type":"tts", "state":"start"}设备进入"播放"状态 ② {"type":"tts", "state":"sentence_start", 屏幕显示字幕 "text":"今天天气晴"} ③ [OPUS包][OPUS包][OPUS包]... 扬声器播放这句话 ④ {"type":"tts", "state":"sentence_start", 下一句字幕 "text":"最高温度 26 度"} ⑤ [OPUS包][OPUS包][OPUS包]... 播放下一句 ⑥ {"type":"tts", "state":"stop"} 设备回到"空闲/聆听"文本消息和音频包是交替并行发送的,不是先发完所有文本再发音频。3.5 设备端解码与播放解码流程OPUS 音频包到达 │ ▼检查采样率和帧长 → 必要时重新配置解码器 │ ▼OPUS 解码 → 得到 PCM(16bit 有符号整数) │ ▼采样率匹配检查 │ ├── 不一致 → 重采样适配硬件 └── 一致 → 直接使用 │ ▼送入播放队列 │ ▼音频硬件驱动(I2S / Codec 芯片) │ ▼扬声器发声为什么需要重采样服务端 TTS 可能输出 24kHz(高质量 TTS 常见)设备硬件 Codec 可能只支持 16kHz 或 48kHz重采样器作为兜底,让协议层保持灵活,不强绑固定采样率三任务并行模型设备端音频系统拆分为三个独立实时任务:┌───────────────┐ ┌────────────────┐ ┌───────────────┐│音频输入任务 │ │ 编解码任务│ │音频输出任务 ││(最高优先级) │ │(中等优先级) │ │(较高优先级) ││ │ ││ │ ││麦克风采集│ │上行:│ │从播放队列取 ││唤醒词检测│ │ PCM → OPUS │ │PCM 数据││语音前端处理 │ │下行:│ │写入音频硬件 ││ │ │ OPUS → PCM │ │驱动扬声器│└───────┬───────┘ └────────┬───────┘ └───────┬───────┘│││▼▼▼编码队列 ──►解码/发送队列 ◄── 播放队列为什么这样拆分:音频输入 优先级最高:麦克风必须实时采集,否则丢帧编解码 单独任务:CPU 密集操作,不能阻塞 I/O音频输出 独立运行:保证播放连续平滑,不被解码或网络抖动打断为什么队列里存 OPUS 包而不是 PCM同一个 60ms 音频帧的大小对比:格式大小PCM(16kHz/16bit/mono)960 采样 × 2 字节 =1920 字节OPUS 压缩后通常40~120 字节OPUS 包体积约为 PCM 的 1/15 ~ 1/50。在只有几百 KB 可用 RAM 的 MCU 上:用更少内存缓冲更长时间的音频减少内存分配压力降低队列阻塞风险3.6 本地提示音:另一条辅助链路除了在线语音回复,设备还有本地提示音(如开机音、错误提示音)。内置 OGG 音频资源(封装的也是 Opus) │ ▼OGG 解封装 → 提取出 Opus 帧 │ ▼送入同一条解码队列 │ ▼复用完全相同的 OPUS 解码 → 播放链设计好处:在线语音和本地提示音共用同一套播放基础设施,不需要额外维护 MP3/WAV 解码器。4. 参数选择详解4.1 参数总览参数值类别编码格式OPUS传输格式采样率16000 Hz音频质量通道数1(单声道)音频质量帧长60 ms延迟与效率码率自动(Auto)压缩策略复杂度0(最低)CPU 负载前向纠错 FEC关闭抗丢包不连续传输 DTX开启省电省带宽可变码率 VBR开启压缩效率队列缓冲上限~2400 ms流畅度4.2 采样率:16000 Hz背景采样率决定可表达的最高频率(奈奎斯特定理:最高频率 = 采样率 / 2)。采样率最高频率典型用途8000 Hz4 kHz电话语音(窄带)16000 Hz8 kHz宽带语音、语音助手24000 Hz12 kHz高质量 TTS48000 Hz24 kHz专业音频/音乐选择原因语音频带完整覆盖:人声基频 85~255 Hz,共振峰 300~3400 Hz,辅音能量到 ~8 kHz。16kHz 完整覆盖语音模型标准:绝大多数 STT / TTS 模型以 16kHz 为标准资源与质量甜点:比 8kHz 明显更清晰;比 24kHz/48kHz 节省一半以上带宽和处理量嵌入式友好:ESP32 级 MCU 处理 16kHz 游刃有余,48kHz 就需要更大缓冲和更多 CPU每秒数据量对比采样率每秒 PCM(16bit mono)每分钟8 kHz16 KB960 KB16 kHz32 KB1.9 MB24 kHz48 KB2.8 MB48 kHz96 KB5.6 MB4.3 通道数:1(单声道)语音助手不需要空间定位:不像音乐需要立体声数据量减半:单声道 = 双声道的 50%编解码负载减半嵌入式设备通常单扬声器队列内存减半:对 RAM 紧张的 MCU 至关重要4.4 帧长:60ms背景OPUS 支持的帧长:2.5 / 5 / 10 / 20 / 40 / 60 / 80 / 100 / 120 ms帧长决定了每次编解码处理的音频时长、每个网络包的大小、端到端延迟的下限。帧长的权衡短帧 (5~20ms)长帧 (60~120ms) ◄──────────────────────────────────────────────► 低延迟高延迟 高包率(网络开销大)低包率(网络开销小) 低压缩效率 高压缩效率 高CPU中断频率 低CPU中断频率 适合实时通话适合嵌入式/低功耗选择 60ms 的原因维度60ms 的表现压缩效率帧内统计冗余多,压缩率比 20ms 更高网络包率每秒 ~17 个包(vs 20ms 的 50 个包),减少包头开销CPU 调度编解码每秒触发 ~17 次(vs 50 次),更省 CPU队列管理同样缓冲区存放更长时间音频延迟60ms 帧延迟 + 网络 + 处理 ≈ 150~300ms,对话场景可接受帧长对帧大小的影响60ms,16kHz,mono:PCM 帧:16000 × 0.06 = 960 采样 × 2 字节 = 1920 字节OPUS 帧:压缩后约 40~120 字节队列缓冲设计项目将队列缓冲上限设为 ~2400ms,即约 2400 / 60 = 40 个 OPUS 包。这个设计保证:有足够缓冲应对网络抖动不会因为缓冲过多导致延迟过大内存占用可控(40 个 OPUS 包 ≈ 40 × 100 = 4KB,远小于 40 帧 PCM 的 77KB)4.5 码率:自动(Auto)什么是自动码率让 OPUS 编码器根据当前帧的音频内容自动决定分配多少比特:安静段 → 低码率复杂语音段 → 高码率过渡段 → 中等码率为什么不手动指定固定码率固定码率无法适应语音内容的动态变化指定过高浪费带宽,指定过低损害音质自动模式是 OPUS 官方推荐的语音场景默认值对于语音助手这种内容不可预测的场景,自动模式最稳妥4.6 复杂度:0(最低)背景OPUS 的 complexity 参数范围 0~10,控制编码器搜索最优压缩方案的努力程度:复杂度CPU 占用压缩效率适用场景0最低稍低嵌入式设备5中等较好普通手机/PC10最高最优服务器端/离线处理选择 0 的原因实时优先:MCU 必须在一帧时长(60ms)内完成编码,复杂度高可能超时低功耗:智能硬件长期在线运行,省 CPU = 省电可接受的质量差异:complexity 0 vs 10 在语音场景下的主观音质差异很小(OPUS 本身在语音上已经很好)只影响编码端:设备端上行编码用 0,服务端下行编码可以用更高值4.7 前向纠错 FEC:关闭什么是 FECForward Error Correction,编码时在当前包中嵌入前一包的冗余信息。如果前一包丢失,解码器可以从当前包中恢复它。开启 FEC 的代价码率增加约 50%编码计算量增加引入额外延迟为什么关闭WebSocket 方案基于 TCP:TCP 本身保证可靠传输,不会丢包UDP 方案更依赖轻量低延迟:靠序列号检测丢包,接受偶尔丢帧,而不是增加冗余开销语音对话场景容忍偶尔丢帧:人耳对短暂(60ms)的语音缺失不太敏感,OPUS 解码器内置 PLC(包丢失隐藏)可以平滑过渡4.8 不连续传输 DTX:开启什么是 DTXDiscontinuous Transmission,当检测到输入是静音或背景噪声时,编码器停止发送或只发极小的静音描述包。开启的好处省带宽:对话中大量时间是"无人说话"的静默段省电:不编码不发送 = CPU 空闲 + 无线模块休眠减轻服务端压力:无效音频不传输长期在线设备尤其受益:语音助手 90% 以上的时间处于待命/静音状态注意事项DTX 只影响上行(麦克风 → 服务端),下行 TTS 语音通常无大段静默恢复说话时有极短的首帧延迟(通常 <20ms),对对话体验无感知影响4.9 可变码率 VBR:开启什么是 VBRVariable Bit Rate,每一帧根据内容复杂度动态分配不同的码率。与 CBR(Constant Bit Rate,固定码率)对比:模式特点VBR安静段省码率,复杂段多给码率,整体更高效CBR每帧码率固定,简单可预测,但压缩效率较低选择 VBR 的原因语音的动态范围大:元音能量高,辅音能量低,静默无能量VBR 能把码率花在刀刃上OPUS 官方推荐语音场景使用 VBR配合 DTX,静默段几乎零码率4.10 解码端重采样为什么存在服务端 TTS 输出的采样率和设备硬件 Codec 的工作采样率不一定相同。例如:服务端 TTS 输出 24kHz(高质量 TTS 常见)设备端 Codec 芯片运行在 16kHz工作方式设备在握手阶段收到服务端确认的采样率如果与本机硬件采样率不一致,启动重采样器重采样在 OPUS 解码之后、写入硬件之前执行设计好处协议层保持灵活,不硬编码某一个采样率板级硬件差异被隔离在最后一环未来服务端升级 TTS(如从 16kHz 升到 24kHz)不需要改设备固件5. 上行链路:为什么也要了解虽然主题是"文本到播放"(下行),但理解上行链路有助于理解参数为什么这样统一设计。上行业务流麦克风采集 → 语音前端处理(降噪/回声消除) → PCM 分帧 → OPUS 编码 → 网络发送 → 服务端 STT上下行参数统一的好处协议统一:上下行用同一种音频格式,简化协议设计编解码器复用:设备只维护一套 OPUS 编解码器队列结构复用:帧长一致,队列管理逻辑通用内存可预测:统一帧大小,内存分配可预算服务端处理简化:收发都是 OPUS,无需多格式适配6. MP3/WAV 与当前架构的关系6.1 当前架构的真实情况在线音频协议声明的格式是 opus,不是 MP3 也不是 WAV设备端只包含 OPUS 编解码器,没有 MP3/WAV 解码器本地提示音也是 OGG(Opus) 封装,不是 MP3/WAV结论:当前项目在线语音回复链路不涉及 MP3/WAV。6.2 如果上游 TTS 输出 MP3/WAV 怎么办推荐做法:在服务端完成转换TTS 输出 MP3/WAV │ ▼ (服务端)解码为 PCM → 重采样到 16kHz/mono → 按 60ms 分帧 → OPUS 编码 │ ▼ (网络)发给设备 → 设备端正常 OPUS 解码 → 播放为什么不在设备端解码 MP3/WAV:因素服务端转换设备端解码CPU 负载服务端资源充裕MCU 负担重内存占用不影响设备需额外解码器内存网络带宽OPUS 传输,带宽最优MP3/WAV 直传,带宽浪费实时性OPUS 天生流式MP3 需要缓冲维护成本设备端无需改动需新增解码器和适配架构一致性保持统一引入异构路径7. 完整参数速查表参数值选择原因格式OPUS低延迟、低码率语音好、流式、免版税、嵌入式友好采样率16kHz语音频带甜点、STT/TTS 标准、嵌入式可承受通道Mono语音不需立体声、省一半资源帧长60ms压缩效率与延迟的折中、嵌入式省 CPU 省内存码率Auto自适应内容、OPUS 推荐默认值复杂度0MCU 实时约束、省电、语音下音质差异小FEC关闭TCP 已可靠、UDP 接受偶尔丢帧、省码率DTX开启静默段省电省带宽、长期在线设备必备VBR开启语音动态范围大、码率花在刀刃上队列缓冲~2400ms抗网络抖动、不过度延迟、内存可控8. 一句话总结云端 LLM 生成文本 → 云端 TTS 合成语音 → 云端 OPUS 编码压缩 → 网络传输(JSON 字幕 + OPUS 音频并行)→ 设备端 OPUS 解码为 PCM → 必要时重采样 → 音频硬件驱动 → 扬声器发声。全链路以 OPUS 16kHz/mono/60ms 为核心,在延迟、码率、音质、嵌入式资源之间取得最优平衡。
2026.04.24了解详情
杰理AC791 AI对话 ASR音频上行链路分析
语音识别(ASR)上行链路:从麦克风到文字的完整技术链路1. 文档目标系统讲清楚 ASR(Automatic Speech Recognition,自动语音识别)上行链路涉及的业务流程、技术原理、背景知识以及每个参数和设计决策的原因:麦克风采集 → 音频前端处理(AEC/NS/VAD) → OPUS 编码 → 网络传输 → 服务端 STT → 识别文字返回设备本文不涉及具体代码实现,只讲业务流、技术原理和参数设计。与本文配套的下行链路文档:tts-opus-playback-pipeline.md2. 整体业务流全景2.1 完整链路图‍flowchart TDsubgraph 设备端A[麦克风硬件采集]B{"硬件采样率 = 16kHz?"}B1["重采样到 16kHz"]B2[直接使用]C[唤醒词检测引擎]D{"检测到唤醒词?"}D1[触发唤醒事件]D2[继续检测]E[音频前端处理 AFE]E1[回声消除 AEC]E2[噪声抑制 NS]E3[语音活动检测 VAD]F[处理后的干净 PCM]G["按 60ms 分帧"]H[OPUS 编码压缩]I[送入发送队列]endsubgraph 网络传输J[WebSocket 二进制帧 或 UDP 加密包]K[JSON 控制消息]endsubgraph 云端服务L[接收 OPUS 音频流]M[OPUS 解码为 PCM]N[STT 语音识别引擎]O[输出识别文字]P[返回 stt JSON 消息给设备]endA --> BB -->|不一致| B1 --> CB -->|一致| B2 --> CC --> DD -->|否| D2 --> CD -->|是| D1D1 -."打开音频通道".-> KD1 -."可选:发送唤醒词音频".-> JC -."切换到聆听模式".-> EE --> E1 --> E2 --> E3E3 --> F --> G --> H --> I --> JK -."listen.start".-> LJ --> L --> M --> N --> O --> PP -."stt.text".-> K2.2 核心要点语音识别在云端完成,设备端不做 STT设备端负责:麦克风采集 → 音频前端处理 → OPUS 编码 → 上传整条上行链路有两个阶段:唤醒阶段:设备端本地运行唤醒词检测模型,不联网聆听阶段:音频经前端处理后编码上传,服务端做 STT唤醒词检测和音频前端处理是两个独立模块,可以单独启用/禁用设备同时发送两路数据给服务端:JSON 控制消息:listen.start、listen.stop、listen.detectOPUS 二进制音频包:真正的语音数据3. 每个环节的技术原理3.1 麦克风采集做什么通过 I2S 接口或板级音频 Codec 芯片,从麦克风持续采集原始 PCM 音频数据。采集参数参数典型值说明硬件采样率因板而异(16kHz / 48kHz 等)Codec 芯片的原生采样率目标采样率16000 Hz统一重采样到 16kHz位深16 bit(有符号整数)标准语音处理位深通道数1 或 2单麦/双麦,部分板子还有参考通道每次读取10ms(160 采样点)喂给唤醒词和音频处理器的最小粒度为什么统一重采样到 16kHz唤醒词模型和语音识别模型都以 16kHz 为标准输入OPUS 编码器配置为 16kHz统一采样率简化整条链路的缓冲区管理如果硬件采样率不是 16kHz,在采集后立即做重采样转换通道处理单麦克风:直接使用双通道(单麦 + 参考通道):麦克风通道用于语音采集,参考通道(来自扬声器回采)用于回声消除 AEC双麦克风:用于波束成形等高级前端处理最终喂给唤醒词和编码器的都是单声道 16kHz PCM3.2 唤醒词检测背景语音助手需要一个低功耗的本地触发机制。用户说出唤醒词(如"小智同学"),设备才开始联网进行语音识别。为什么在本地做唤醒词检测隐私:不联网就不上传任何音频低延迟:本地检测比云端快几百毫秒省电:不需要一直维持网络连接省带宽:只有唤醒后才开始上传三种唤醒词引擎本项目支持三种唤醒词检测方案,适配不同硬件能力:引擎适用芯片特点AFE 唤醒词ESP32-S3 / ESP32-P4集成音频前端(AEC + NS),唤醒检测与音频前端处理合一自定义唤醒词ESP32-S3 / ESP32-P4基于 MultiNet 命令词模型,支持自定义唤醒词和命令词轻量唤醒词ESP32 等较弱芯片仅做唤醒词检测,无音频前端处理,资源占用最小唤醒词检测流程麦克风 PCM(10ms 一帧) │ ▼送入唤醒词引擎缓冲区 │ ▼累积到引擎所需的块大小(如 30ms / 512 采样) │ ▼执行检测推理 │ ├── 未检测到 → 继续累积下一帧 │ └── 检测到唤醒词!│├── 回调通知应用层├── 可选:编码最近 ~2 秒的音频为 OPUS 发给服务端└── 应用层开始建立连接 + 切换到聆听模式唤醒词音频回传(可选)检测到唤醒词时,引擎会保留最近约 2 秒的音频数据。这些数据可以被编码为 OPUS 后发给服务端,好处是:服务端可以做说话人识别(是谁在叫唤醒词)服务端可以做唤醒词确认(减少误唤醒)服务端可以直接用于 STT(用户可能唤醒词后紧跟着说了指令)3.3 音频前端处理(AFE)背景真实环境中的麦克风采集到的不是干净的人声,而是混合了:回声:扬声器播放的声音被麦克风重新采集环境噪声:风扇、空调、电视等背景声混响:房间墙壁反射的声音这些干扰会严重影响语音识别准确率。音频前端处理(Audio Front-End, AFE)的目标是在发送给 STT 之前清理音频。三大核心模块┌──────────────────────────────────────────────────┐│ 音频前端处理 (AFE)││││┌────────────┐┌────────────┐┌─────────────┐ │││AEC ││NS││VAD│ │││回声消除 │→│噪声抑制 │→│语音活动检测 │ ││└────────────┘└────────────┘└─────────────┘ ││ ↑ ││参考信号(扬声器回采)│└──────────────────────────────────────────────────┘3.3.1 AEC 回声消除问题:当设备一边播放 AI 回复一边监听用户语音时(实时对话模式),扬声器声音会被麦克风采到,服务端 STT 会把 AI 自己说的话也识别出来。原理:AEC 利用扬声器的输出信号作为"参考",从麦克风信号中估计并减去回声成分,只保留用户的真实语音。两种 AEC 方案:方案执行位置工作方式优点缺点设备端 AEC设备端 AFE用扬声器参考通道做本地回声消除延迟最低,不依赖网络需要硬件支持参考通道,对板级设计要求高服务端 AEC云端设备上传音频时带 timestamp,服务端用回放时间戳对齐做消除对硬件无要求依赖网络延迟稳定,效果不如设备端不使用 AEC 时:设备在播放 AI 回复时不监听麦克风播放结束后才切回聆听模式对话节奏是严格的"一问一答"轮替,不能打断3.3.2 NS 噪声抑制问题:环境噪声降低 STT 识别准确率。两种方案:方案说明传统 NS基于频谱减法等信号处理算法,轻量快速神经网络 NS基于 NSNet 等深度学习模型,效果更好但 CPU 开销更大项目在 AFE 中优先使用神经网络 NS(如果模型存在),否则关闭 NS。3.3.3 VAD 语音活动检测做什么:判断当前音频帧是"有人在说话"还是"静默/噪声"。输出:二值状态 —— SPEECH(说话中)或 SILENCE(静默)。作用:自动停止模式下:VAD 检测到持续静默后,设备自动结束聆听,触发服务端 STT 处理LED 反馈:聆听时根据 VAD 状态变化更新 LED 指示灯服务端辅助:服务端也可以用 VAD 信息决定何时开始/结束识别AFE 的两种运行场景场景AFE 类型用途唤醒检测阶段AFE_TYPE_SR(语音识别型)集成唤醒词检测,需要运行 WakeNet 模型聆听/通信阶段AFE_TYPE_VC(语音通信型)专注于 AEC + NS + VAD,输出干净音频给编码器两个阶段使用不同的 AFE 实例,因为:唤醒阶段需要跑唤醒词模型,聆听阶段不需要聆听阶段需要输出固定帧长的 PCM(60ms),唤醒阶段的帧长由模型决定分开管理可以独立启停,不互相干扰无 AFE 的回退方案如果设备不支持 AFE(资源不足),系统退化为直通模式:麦克风 PCM 直接分帧,不做任何前端处理不支持 AEC(不能在播放时同时听)不支持 VAD(不能自动停止)不支持 NS(噪声直接上传)只做立体声转单声道和帧长对齐3.4 OPUS 编码做什么将 AFE 处理后的干净 PCM 音频帧压缩为 OPUS 格式,减小网络传输数据量。编码流程AFE 输出干净 PCM(60ms = 960 采样点) │ ▼送入编码队列(最多 2 个任务排队) │ ▼编解码任务取出 PCM 帧 │ ▼OPUS 编码器压缩 │ ▼OPUS 包(约 40~120 字节)送入发送队列 │ ▼主循环触发 → 协议层发送编码参数(与下行完全一致)参数值原因采样率16kHz语音频带甜点,STT 模型标准通道Mono语音不需立体声帧长60ms压缩效率与嵌入式资源的折中码率Auto自适应内容复杂度复杂度0MCU 实时约束,省 CPU 省电FEC关闭WebSocket/TCP 已可靠,UDP 接受偶尔丢帧DTX开启静默段不发数据,省带宽省电VBR开启语音动态范围大,码率花在刀刃上上下行参数完全统一的好处:编解码器复用、队列结构复用、协议一致、内存可预测。详细参数解释见 tts-opus-playback-pipeline.md 第 4 章。为什么编码队列最多只排 2 个任务编码是 CPU 密集操作,如果排队太多说明编码速度跟不上采集速度限制为 2 个可以背压到音频处理器,避免内存无限增长同时保证网络抖动时有 1 帧的缓冲余量3.5 时间戳与服务端 AEC背景在服务端 AEC 方案中,服务端需要知道每个上行音频帧对应的设备端时刻,才能与同一时刻下发的回放音频做对齐消除。工作方式设备端:收到服务端下行音频包 → 记录 timestamp → 放入时间戳队列编码上行音频时 → 从时间戳队列取出 timestamp → 附加到 OPUS 包服务端:收到上行音频包的 timestamp查找同一 timestamp 的下行音频执行回声消除这样服务端就能知道"设备在播放哪段音频的同时录到了这段麦克风信号",从而正确消除回声。3.6 网络发送发送队列编码后的 OPUS 包进入发送队列,队列上限约 2400ms(约 40 个 60ms 的包)。主循环检测到队列有数据后,逐包通过协议层发送。两种传输方案WebSocket 方案:OPUS 包 → 加上协议头(版本/类型/时间戳/大小)→ WebSocket 二进制帧发送V1:裸 OPUS payloadV2:带 timestamp 和 payload_size 头(用于服务端 AEC)V3:轻量头,只带 type 和 payload_sizeMQTT + UDP 方案:OPUS 包 → AES-128-CTR 加密 → 加上 UDP 包头(类型/标志/SSRC/时间戳/序列号)→ UDP 发送控制消息走 MQTT/TLS音频数据走 UDP,更低延迟加密防窃听,序列号防重放3.7 服务端 STT 语音识别做什么服务端接收 OPUS 音频流,解码为 PCM,送入 STT 引擎转换为文字。STT 的工作模式服务端 STT 通常支持流式识别:不需要等整段话说完一边收到音频一边输出中间结果(partial results)用户说完后输出最终结果(final result)识别结果返回服务端将识别出的文字通过 JSON 消息返回设备:{"type": "stt", "text": "今天天气怎么样"}设备收到后在屏幕上显示为用户发言的字幕。4. 聆听模式详解4.1 三种聆听模式设备进入聆听状态时,需要告诉服务端采用哪种聆听模式:模式名称JSON 值触发结束的方式需要 AEC自动停止AutoStop"auto"VAD 检测到持续静默后自动结束❌手动停止ManualStop"manual"用户按键/再次唤醒才结束❌实时对话Realtime"realtime"不主动结束,持续双向通信✅4.2 模式选择逻辑AEC 已开启?│├── 是 → 默认使用 Realtime 模式(全双工对话)│└── 否 → 默认使用 AutoStop 模式(半双工轮替)AutoStop 模式(最常用)用户说话 ──► VAD=SPEECH ──► 用户停顿 ──► VAD=SILENCE 持续一段时间│▼设备自动结束聆听服务端输出最终识别结果进入 LLM 处理 → TTS 回复Realtime 模式(需要 AEC)用户说话 ──────────────────────────────────────────►AI 回复◄────────────────────────────────────────── (可以随时打断,双向同时进行)设备在 AI 播放回复的同时仍然监听麦克风AEC 消除掉扬声器回声,只保留用户语音用户可以随时打断 AI4.3 聆听相关的 JSON 消息协议设备 → 服务端消息含义{"type":"listen", "state":"detect", "text":"小智同学"}唤醒词检测到,附带唤醒词文本{"type":"listen", "state":"start", "mode":"auto"}开始聆听,告知模式{"type":"listen", "state":"stop"}停止聆听{"type":"abort", "reason":"wake_word_detected"}打断当前 AI 回复服务端 → 设备消息含义{"type":"stt", "text":"用户说的话"}STT 识别结果,设备显示为用户字幕5. 设备状态机与 ASR 的关系5.1 状态流转图stateDiagram-v2[*] --> 空闲Idle空闲Idle --> 连接中Connecting : 唤醒词检测到连接中Connecting --> 聆听中Listening : 音频通道打开成功聆听中Listening --> 回复中Speaking : 收到 tts.start回复中Speaking --> 聆听中Listening : 收到 tts.stop(非手动模式)回复中Speaking --> 空闲Idle : 收到 tts.stop(手动模式)聆听中Listening --> 空闲Idle : 用户停止聆听回复中Speaking --> 聆听中Listening : 唤醒词打断聆听中Listening --> 聆听中Listening : 唤醒词重新触发5.2 各状态下的音频模块启停设备状态唤醒词检测音频前端处理OPUS 编码上传OPUS 解码播放空闲✅ 运行❌ 停止❌ 停止❌ 停止连接中❌ 停止❌ 停止❌ 停止❌ 停止聆听中视配置✅ 运行✅ 运行❌ 停止回复中视配置视模式视模式✅ 运行聆听中:唤醒词检测默认关闭(避免自己说的话触发唤醒),但如果使用 AFE 唤醒词引擎可以配置为同时运行回复中 + Realtime 模式:音频前端处理和编码上传保持运行(全双工)回复中 + 非 Realtime 模式:音频前端处理和编码上传停止(半双工)6. AEC 回声消除深入6.1 为什么 AEC 是 ASR 链路中最复杂的部分在语音助手场景中,最理想的体验是随时可以打断 AI。但这要求设备在播放 AI 回复的同时监听麦克风,而此时:扬声器声音 >> 用户声音(扬声器就在麦克风旁边)不消除回声的话,STT 会把 AI 自己说的话识别出来甚至会形成"AI 自己触发自己"的死循环AEC 的基本原理 参考信号(扬声器输出)│▼┌──────────────────┐│ 自适应滤波器 │ ← 估计回声路径└──────────────────┘│▼ 估计的回声麦克风信号 ─────⊖──────► 残差信号(≈ 纯用户语音) 减去估计回声用扬声器的输出信号作为参考自适应滤波器学习"扬声器到麦克风"的传递路径用学到的路径预测麦克风会采到的回声从麦克风信号中减去预测的回声残差就是用户的真实语音6.2 设备端 AEC vs 服务端 AEC维度设备端 AEC服务端 AEC执行位置设备端 AFE 模块云端参考信号来源硬件回采(I2S 回环/Codec 回采)服务端下行音频的时间戳对齐延迟极低(本地处理)受网络延迟影响消除效果好(参考信号精确对齐)一般(网络抖动导致对齐误差)硬件要求需要 Codec 支持参考通道输出无特殊要求稳定性成熟标注为不稳定(Unstable)6.3 不使用 AEC 时的对话模式用户说话 → 设备上传 → 服务端 STT → LLM → TTS ↓用户等待 ← 设备播放 AI 回复 ← OPUS 下发 ↓播放结束 ↓设备重新进入聆听 → 用户可以继续说话这是半双工轮替模式:一方说话时另一方必须等待。用户体验不如全双工自然,但实现简单可靠。7. 完整的一次对话时序7.1 从唤醒到收到识别结果时间 ─────────────────────────────────────────────────────────────────────►设备端:① 麦克风持续采集,喂给唤醒词引擎② 用户说:"小智同学"③ 唤醒词引擎检测到 → 触发唤醒事件④ 编码最近 ~2秒 唤醒词音频为 OPUS(可选)⑤ 打开音频通道(WebSocket 连接 / MQTT+UDP 建立)⑥ 发送 hello 握手,协商音频参数⑦ 发送唤醒词 OPUS 数据(可选)⑧ 发送 {"type":"listen", "state":"detect", "text":"小智同学"}⑨ 切换到聆听状态⑩ 启动音频前端处理(AFE)⑪ 发送 {"type":"listen", "state":"start", "mode":"auto"}⑫ 用户说:"今天天气怎么样"⑬ AFE 处理 → OPUS 编码 → 连续发送 OPUS 包⑭ VAD 检测到静默 → 自动结束服务端:⑮ 收到音频流 → OPUS 解码 → STT 识别⑯ 返回 {"type":"stt", "text":"今天天气怎么样"}⑰ 送入 LLM 生成回复⑱ TTS 合成 → OPUS 编码 → 下发(进入下行链路)设备端:⑲ 收到 stt 消息 → 屏幕显示用户字幕⑳ 收到 tts.start → 切换到回复中状态㉑ 收到 OPUS 音频包 → 解码播放(下行链路)7.2 打断场景(Realtime 模式)① AI 正在播放回复(回复中状态)② 用户突然说话③ AEC 消除扬声器回声,提取用户语音④ 唤醒词引擎检测到唤醒词(或 VAD 检测到语音)⑤ 发送 {"type":"abort", "reason":"wake_word_detected"}⑥ 设备停止播放,切换到聆听⑦ 开始新一轮语音上传8. 关键设计决策总结8.1 为什么唤醒词检测和音频前端处理是两个独立模块维度唤醒词检测音频前端处理运行时机空闲时一直运行只在聆听时运行AFE 类型SR(语音识别型,集成 WakeNet)VC(语音通信型,集成 AEC/NS/VAD)输出检测事件(是/否)干净的 PCM 音频帧帧长由模型决定(~30ms)由编码器决定(60ms)CPU 占用适中较高(特别是开启 AEC + NS)分开设计可以:空闲时只跑轻量的唤醒词检测,省电聆听时才启动重量级的 AFE 处理独立启停,互不干扰切换时重置重采样器,避免缓冲区残留8.2 为什么麦克风采集用 10ms 粒度而编码用 60ms 粒度10ms 采集:满足唤醒词模型和 AFE 引擎的喂入需求(它们通常需要更细粒度的输入)60ms 编码:OPUS 编码器的帧长设置,60ms 是压缩效率和嵌入式资源的最佳折中AFE 处理器内部做帧长对齐:累积 AFE 输出的小帧,攒够 60ms(960 采样)后一次性输出给编码器8.3 为什么发送队列上限是 ~2400ms与解码队列对称:上下行队列使用相同的设计应对网络抖动:短暂的网络卡顿不会丢失音频内存可控:40 个 OPUS 包 ≈ 4KB,远小于同等时长的 PCM(77KB)不过度缓冲:超过 2.4 秒说明网络严重卡顿,继续缓冲意义不大8.4 为什么编码队列只允许 2 个任务编码队列存的是 PCM 帧(1920 字节/帧),比 OPUS 包大得多限制为 2 个任务控制内存峰值背压机制:如果编码器处理不过来,会阻塞 AFE 输出,形成流量控制正常情况下编码速度远快于实时(60ms 音频编码只需几毫秒),队列几乎不会积压9. AFE 参数选择详解9.1 音频前端处理(聆听阶段)参数参数值原因AFE 类型VC(语音通信)聆听阶段不需要唤醒词检测,专注于音频清理AEC 模式VOIP_HIGH_PERF为 VoIP 场景优化的高性能回声消除VAD 模式VAD_MODE_0最灵敏的 VAD 设置,不漏掉轻声说话VAD 最小噪声时长100ms低于 100ms 的短暂噪声不触发 VAD 状态变化NS 模式优先使用 NSNet(神经网络),否则关闭神经网络降噪效果远好于传统方法AGC关闭自动增益控制可能引入失真,当前场景不需要内存分配优先使用 PSRAMAFE 模型较大,放在外部 PSRAM 节省内部 SRAM9.2 唤醒词检测(空闲阶段)参数参数值原因AFE 类型SR(语音识别)集成唤醒词检测功能AEC 模式SR_HIGH_PERF为语音识别场景优化优先核心Core 1AFE 任务固定在 Core 1,避免与其他关键任务争抢 Core 0内存分配优先使用 PSRAM模型数据放 PSRAM9.3 重采样器参数参数值原因复杂度2速度优先,嵌入式场景不追求极致音质性能类型SPEED明确告知算法优先速度而非质量位深16bit与整条链路一致10. 参数速查表上行链路核心参数参数值选择原因采集采样率16kHz(重采样后)语音模型标准、OPUS 编码器配置采集位深16bit 有符号整数标准语音处理位深采集通道1~2(输出为 mono)最终编码为单声道采集粒度10ms(160 采样点)满足唤醒词和 AFE 引擎的喂入需求编码帧长60ms(960 采样点)压缩效率与嵌入式资源折中编码格式OPUS低延迟、低码率、流式、上下行统一编码码率Auto自适应内容编码复杂度0MCU 实时约束DTX开启静默段省电省带宽VBR开启语音动态范围大FEC关闭TCP 已可靠 / UDP 接受偶尔丢帧编码队列上限2 个 PCM 帧控制内存,背压流控发送队列上限~2400ms(~40 个 OPUS 包)抗网络抖动,内存可控唤醒词音频保留~2 秒供服务端做说话人识别/唤醒确认11. 一句话总结设备端麦克风采集 16kHz PCM → 本地唤醒词引擎检测触发 → 音频前端处理(AEC 消除回声 + NS 降噪 + VAD 检测语音活动)→ 干净 PCM 按 60ms 分帧 → OPUS 编码压缩 → 网络上传(WebSocket/UDP)→ 云端 OPUS 解码 → STT 语音识别引擎 → 识别文字返回设备显示。全链路以 OPUS 16kHz/mono/60ms 为核心编码参数,与下行链路完全对称统一;唤醒词检测和音频前端处理作为两个独立模块分阶段运行,在低功耗待机和高质量语音上传之间取得平衡。
2026.04.23了解详情
杰理AC791 AI对话 音频Opus格式用于WebRTC实时对话
音频转 Opus 格式用于 WebRTC1. 什么是 OpusOpus 是一种开放、免版税的音频编码格式,由 IETF(互联网工程任务组)标准化(RFC 6716)。它专为互联网上的实时音频传输而设计,是目前 WebRTC 的默认音频编解码器。Opus 的核心特点:混合编码架构:结合 SILK(针对语音)和 CELT(针对音乐)两种编码器,能根据音频内容自动切换超宽频率范围:支持 6Hz ~ 51.2kHz 的采样率,覆盖人耳可感知的全部范围灵活的码率:支持 6kbps ~ 510kbps,可根据网络带宽动态调整低延迟:帧时长最低 2.5ms,端到端延迟可低至 5ms抗丢包:内置前向纠错(FEC)和丢包隐藏(PLC)机制2. 为什么 WebRTC 使用 OpusWebRTC(Web Real-Time Communication)是浏览器原生支持的实时通信协议。Opus 被选为默认音频编解码器的原因:特性Opus对比方案码率范围6~510 kbpsG.711 固定 64kbps采样率8kHz ~ 48kHzG.711 仅 8kHz延迟最低 2.5ms/帧MP3 至少 26ms抗丢包内建 FEC + PLCAAC 无内建机制开放性免费开源部分编码器需授权3. Opus 编码核心概念3.1 采样率(Sample Rate)每秒对模拟信号采样的次数,单位 Hz。采样率音质等级典型场景8000 Hz窄带语音电话通话16000 Hz宽带语音VoIP、语音消息24000 Hz超宽带语音高清语音通话48000 Hz全带音频音乐、高保真WebRTC 中最常用 16000Hz(语音)和 48000Hz(音乐/高清通话)。3.2 声道数(Channels)单声道(Mono):1 个声道,语音场景标准配置立体声(Stereo):2 个声道,音乐或空间音频场景语音场景使用单声道即可,立体声会使数据量翻倍但对语音清晰度无明显提升。3.3 采样宽度(Bit Depth)每个采样点用多少位表示。16bit(2 字节):CD 音质标准,范围 -32768 ~ 327678bit:电话音质,动态范围小24bit / 32bit:专业音频,Opus 编码前会量化回 16bitOpus 编码器的输入固定为 16bit 有符号整数。3.4 帧时长(Frame Duration)Opus 将音频切成固定时长的帧逐帧编码。帧时长延迟压缩效率适用场景2.5ms极低最低超低延迟交互10ms低较低实时游戏语音20ms中中等WebRTC 默认值40ms较高较高语音消息60ms高最高文件存储、非实时传输帧时长越短,延迟越低,但压缩率也越低。 WebRTC 标准推荐 20ms,非实时场景可用 60ms 以获得更高压缩率。3.5 编码应用场景(Application Mode)Opus 编码器根据使用场景选择不同的优化策略:模式值优化方向适用场景APPLICATION_VOIP2048低延迟、语音清晰度实时通话APPLICATION_AUDIO2049音质优先语音消息、音乐APPLICATION_RESTRICTED_LOWDELAY2051最低延迟极端实时场景4. PCM 数据与帧切割4.1 什么是 PCMPCM(Pulse Code Modulation,脉冲编码调制)是最基本的数字音频表示方式。它将模拟信号按固定采样率采样,每个采样点用固定位数的整数表示。PCM 数据 = 原始音频,未经任何压缩。4.2 PCM 数据的内存布局以 16kHz、单声道、16bit 为例:字节位置:[0] [1] [2] [3] [4] [5] [6] [7] ...含义:采样1 采样2 采样3 采样4 ... 低 高 低 高 低 高 低 高字节序:小端序(Little-Endian),低字节在前立体声时采用交错排列:字节位置:[0] [1] [2] [3] [4] [5] ...含义:左1右1左2右2左3右3 ...4.3 帧切割计算以 16kHz、单声道、16bit、60ms 帧时长为例:每帧采样点数 = 采样率 / 1000 × 帧时长 = 16000 / 1000 × 60 = 960 个采样点每帧字节数 = 采样点数 × 声道数 × 采样宽度 = 960 × 1 × 2 = 1920 字节一段 5 秒的音频:总采样点数 = 16000 × 5 = 80000总字节数 = 80000 × 2 = 160000 字节总帧数 = 80000 / 960 ≈ 84 帧4.4 最后一帧的处理如果音频总长度不是帧大小的整数倍,最后一帧会不足。处理方式是用 0x00 字节填充(等效于添加静音),因为 Opus 编码器要求每帧输入必须是固定长度。5. 编码流程总结音频文件(mp3/wav/flac/...)│▼[1] 识别文件格式(扩展名)│▼[2] 使用 ffmpeg + pydub 解码为 PCM│▼[3] 统一参数:单声道 + 16kHz + 16bit│▼[4] 按帧切割 PCM 数据(每帧 1920 字节)│▼[5] 逐帧调用 Opus 编码器│▼[6] 输出 Opus 帧列表│├──→ 实时传输:逐帧通过 WebRTC DataChannel 发送└──→ 文件存储:封装到 Ogg/WebM 容器6. WebRTC 中的 Opus 使用6.1 实时通话场景发送端:麦克风 → PCM → Opus 编码 → RTP 打包 → 网络发送接收端:网络接收 → RTP 解包 → Opus 解码 → PCM → 扬声器WebRTC 内部自动处理编码和解码,开发者通常不需要手动操作。但在以下场景需要手动处理:从文件播放音频到 WebRTC 通话中将通话中的音频录制为文件使用自定义音频源(如 TTS 合成语音)6.2 语音消息场景用户录制语音 → 编码为 Opus → 上传服务器 → 其他用户下载 → 解码播放。帧时长可选择 40ms 或 60ms 以提高压缩率,减少存储和传输成本。6.3 关键参数对比场景采样率帧时长Application典型码率实时通话4800020msVOIP24~64 kbps语音消息1600060msAUDIO16~32 kbps音乐流4800020msAUDIO96~256 kbps低带宽语音800020msVOIP6~12 kbps7. 依赖环境7.1 ffmpegpydub 依赖 ffmpeg 解码 mp3、flac 等压缩格式。Windows:从 ffmpeg.org 下载,添加到 PATHmacOS:brew install ffmpegLinux:apt install ffmpeg 或 yum install ffmpeg验证安装:ffmpeg -version7.2 Python 依赖pip install pydub opuslib-next numpy库用途pydub音频加载、格式转换、PCM 提取opuslib_nextOpus 编解码器的 Python 封装numpyPCM 数据的字节序和内存布局处理8. 常见问题Q: 为什么用 16kHz 而不是 48kHz?16kHz 已能满足语音场景的清晰度需求(覆盖 8kHz 带宽,足够表现人声),同时数据量仅为 48kHz 的 1/3,节省带宽和存储。48kHz 更适合音乐或高保真场景。Q: 帧时长选多少合适?实时通话:20ms(WebRTC 标准,延迟和压缩率平衡)语音消息:40~60ms(非实时,追求更高压缩率)超低延迟:2.5~10ms(游戏语音等)Q: Opus 和 AAC、MP3 有什么区别?Opus 是为实时传输设计的,延迟极低(最低 2.5ms),而 AAC 编码延迟通常 >20ms,MP3 更高(>26ms)。Opus 还内置抗丢包机制,适合网络不稳定的场景。MP3/AAC 更适合离线文件存储。Q: 填充静音会影响音质吗?不会。最后一帧不足的部分通常是音频末尾的极短片段(< 60ms),填充的静音在播放时表现为音频末尾多了不到 60ms 的静音,人耳无法察觉。9. C 语言中使用 Opus(libopus)Python 适合快速原型开发,但在性能敏感的场景(嵌入式设备、实时音频处理、游戏引擎等)中,直接使用 C 语言调用 libopus 是更优的选择。libopus 是 Opus 官方提供的 C 实现,也是所有其他语言绑定(Python、Go、Rust 等)的底层基础。9.1 libopus 简介libopus 是 Opus 编解码器的参考实现,由 Xiph.Org 基金会维护。开源协议:BSD 许可证,可自由用于商业项目仓库地址:https://github.com/xiph/opus核心功能:提供 Opus 编码和解码的完整 C API性能特点:高度优化的 C 代码,支持 SIMD 指令集加速libopus 只负责编解码,不负责:文件读写(需要自行处理或使用 libopusenc/libopusfile)容器封装(Ogg/WebM 容器需要额外库)网络传输(需要自行实现或结合 WebRTC 库)9.2 各平台安装Linux(Debian/Ubuntu)# 安装开发库sudo apt install libopus-dev# 安装后可使用的文件:# 头文件:/usr/include/opus/opus.h# 库文件:/usr/lib/x86_64-linux-gnu/libopus.soLinux(CentOS/RHEL/Fedora)sudo yum install opus-devel# 或sudo dnf install opus-develmacOSbrew install opus# 安装后头文件位置:# /opt/homebrew/include/opus/opus.h# 库文件位置:# /opt/homebrew/lib/libopus.dylibWindows方式一:使用 vcpkg(推荐)vcpkg install opus:x64-windows方式二:手动编译从 https://opus-codec.org/downloads/ 下载源码使用 CMake 或 Visual Studio 编译将生成的 opus.lib 和 opus.dll 配置到项目中方式三:使用 MSYS2pacman -S mingw-w64-x86_64-opus验证安装# Linux/macOS:检查头文件是否存在ls /usr/include/opus/opus.h# 或pkg-config --cflags --libs opus9.3 核心 API 详解libopus 的 API 设计简洁,核心函数只有以下几个:opus_encoder_create — 创建编码器OpusEncoder *opus_encoder_create(opus_int32 Fs,// 采样率:8000, 12000, 16000, 24000, 48000int channels, // 声道数:1(单声道)或 2(立体声)int application,// 应用场景:OPUS_APPLICATION_VOIP / AUDIO / RESTRICTED_LOWDELAYint *error// 输出参数,返回错误码(OPUS_OK 表示成功));返回值:成功返回 OpusEncoder*,失败返回 NULL(此时 *error 包含错误原因)。opus_encode — 编码一帧(int16 输入)opus_int32 opus_encode(OpusEncoder *st,// 编码器状态const opus_int16 *pcm,// PCM 输入数据(int16 有符号整数)int frame_size, // 每声道的采样点数(不是字节数)unsigned char *data,// 输出缓冲区,存放编码后的 Opus 数据opus_int32 max_data_bytes // 输出缓冲区最大字节数);返回值:> 0 表示编码成功,返回写入的字节数;< 0 表示失败(返回错误码)。frame_size 的合法值取决于采样率:采样率合法 frame_size(采样点数/声道)48000120, 240, 480, 960, 1920, 288024000120, 240, 480, 96016000120, 240, 480, 96012000120, 240, 480, 9608000120, 240, 480, 960对应关系:frame_size48kHz 时长16kHz 时长1202.5ms7.5ms2405ms15ms48010ms30ms96020ms60msopus_encode_float — 编码一帧(float 输入)opus_int32 opus_encode_float(OpusEncoder *st,const float *pcm, // PCM 输入数据(float,范围 -1.0 ~ 1.0)int frame_size,unsigned char *data,opus_int32 max_data_bytes);与 opus_encode 功能相同,区别在于输入数据类型。float 版本适合与音频处理库(如 PortAudio、JUCE)对接,这些库通常使用 float 格式。opus_encoder_ctl — 动态调整编码参数// 设置码率opus_encoder_ctl(enc, OPUS_SET_BITRATE(32000));// 设置复杂度(0~10,10 最高但最慢)opus_encoder_ctl(enc, OPUS_SET_COMPLEXITY(10));// 设置信号类型(语音/音乐/未知)opus_encoder_ctl(enc, OPUS_SET_SIGNAL(OPUS_SIGNAL_VOICE));// 设置带宽限制opus_encoder_ctl(enc, OPUS_SET_MAX_BANDWIDTH(OPUS_BANDWIDTH_WIDEBAND));// 启用/禁用 VBRopus_encoder_ctl(enc, OPUS_SET_VBR(1));// 启用 DTX(静音检测传输,静音时降低码率)opus_encoder_ctl(enc, OPUS_SET_DTX(1));// 读取当前码率opus_int32 current_bitrate;opus_encoder_ctl(enc, OPUS_GET_BITRATE(&current_bitrate));opus_encoder_destroy — 销毁编码器void opus_encoder_destroy(OpusEncoder *st);释放编码器占用的所有资源。编码器不再使用时必须调用,否则会内存泄漏。9.4 完整编码示例以下示例将一个原始 PCM 文件编码为 Opus 帧数据并写入文件:/* * opus_encode_example.c * 将 16bit 小端序 PCM 文件编码为 Opus 帧序列 * * 编译命令: * Linux/macOS: gcc -o opus_encode_example opus_encode_example.c -lopus * Windows: gcc -o opus_encode_example opus_encode_example.c -lopus -lm */#include <stdio.h>#include <stdlib.h>#include <string.h>#include <opus.h>/* ============ 编码参数 ============ */#define SAMPLE_RATE 16000 /* 采样率 16kHz */#define CHANNELS1 /* 单声道 */#define APPLICATION OPUS_APPLICATION_AUDIO/* 应用场景:音频优化 */#define FRAME_DURATION60/* 帧时长 60ms */#define BITRATE 32000 /* 目标码率 32kbps */#define COMPLEXITY10/* 编码复杂度 0~10 *//* ============ 计算 derived 常量 ============ *//* 每帧采样点数 = 采样率 * 帧时长 / 1000 */#define FRAME_SIZE(SAMPLE_RATE * FRAME_DURATION / 1000)/* 16000 * 60 / 1000 = 960 *//* 每帧 PCM 字节数 = 采样点数 * 声道数 * 2字节(16bit) */#define FRAME_BYTES (FRAME_SIZE * CHANNELS * 2)/* 960 * 1 * 2 = 1920 *//* Opus 输出缓冲区大小(官方建议最大 4000 字节) */#define MAX_PACKET_SIZE 4000int main(int argc, char *argv[]){/* ---------- 1. 参数检查 ---------- */if (argc != 3) {fprintf(stderr, "用法: %s <输入.pcm> <输出.opus>", argv[0]);fprintf(stderr, "输入文件必须是 16bit 小端序有符号 PCM 格式");fprintf(stderr, "生成测试 PCM 文件的方法:");fprintf(stderr, "ffmpeg -i input.wav -f s16le -acodec pcm_s16le -ar 16000 -ac 1 output.pcm");return EXIT_FAILURE;}const char *input_path= argv[1];const char *output_path = argv[2];/* ---------- 2. 打开文件 ---------- */FILE *fin = fopen(input_path, "rb");if (!fin) {fprintf(stderr, "错误: 无法打开输入文件 %s", input_path);return EXIT_FAILURE;}FILE *fout = fopen(output_path, "wb");if (!fout) {fprintf(stderr, "错误: 无法打开输出文件 %s", output_path);fclose(fin);return EXIT_FAILURE;}/* ---------- 3. 创建 Opus 编码器 ---------- */int error;OpusEncoder *encoder = opus_encoder_create(SAMPLE_RATE,/* 16000 Hz */CHANNELS, /* 1(单声道) */APPLICATION,/* OPUS_APPLICATION_AUDIO */&error/* 错误码输出 */);if (error != OPUS_OK || encoder == NULL) {fprintf(stderr, "错误: 创建编码器失败: %s", opus_strerror(error));fclose(fin);fclose(fout);return EXIT_FAILURE;}/* ---------- 4. 配置编码器参数 ---------- *//* 设置目标码率 */error = opus_encoder_ctl(encoder, OPUS_SET_BITRATE(BITRATE));if (error != OPUS_OK) {fprintf(stderr, "警告: 设置码率失败: %s", opus_strerror(error));}/* 设置编码复杂度(10 为最高质量,CPU 消耗也最大) */error = opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(COMPLEXITY));if (error != OPUS_OK) {fprintf(stderr, "警告: 设置复杂度失败: %s", opus_strerror(error));}/* 设置信号类型为语音(Opus 会针对语音内容优化) */error = opus_encoder_ctl(encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_VOICE));if (error != OPUS_OK) {fprintf(stderr, "警告: 设置信号类型失败: %s", opus_strerror(error));}/* 启用 VBR(可变码率),在保证质量的前提下节省带宽 */error = opus_encoder_ctl(encoder, OPUS_SET_VBR(1));if (error != OPUS_OK) {fprintf(stderr, "警告: 设置 VBR 失败: %s", opus_strerror(error));}printf("编码器配置完成:");printf("采样率: %d Hz", SAMPLE_RATE);printf("声道数: %d", CHANNELS);printf("帧时长: %d ms", FRAME_DURATION);printf("每帧采样点: %d", FRAME_SIZE);printf("每帧字节数: %d", FRAME_BYTES);printf("目标码率: %d bps", BITRATE);printf("复杂度: %d", COMPLEXITY);/* ---------- 5. 逐帧编码 ---------- */opus_int16 pcm_buffer[FRAME_SIZE * CHANNELS];/* PCM 输入缓冲区 */unsigned char opus_buffer[MAX_PACKET_SIZE]; /* Opus 输出缓冲区 */int frame_count = 0;int total_bytes = 0;while (1) {/* 从文件读取一帧 PCM 数据 */size_t read_count = fread(pcm_buffer,sizeof(opus_int16), /* 每个采样 2 字节 */FRAME_SIZE * CHANNELS,/* 要读取的采样点总数 */fin);/* 文件读完或出错 */if (read_count == 0) {break;}/* 如果读取的数据不足一帧,用 0(静音)填充剩余部分 */if (read_count < (size_t)(FRAME_SIZE * CHANNELS)) {memset((unsigned char *)pcm_buffer + read_count * sizeof(opus_int16),0,(FRAME_SIZE * CHANNELS - read_count) * sizeof(opus_int16));}/* 调用 Opus 编码器编码一帧 */int nbBytes = opus_encode(encoder,/* 编码器状态 */pcm_buffer, /* PCM 输入数据 */FRAME_SIZE, /* 每声道采样点数 */opus_buffer,/* 输出缓冲区 */MAX_PACKET_SIZE /* 输出缓冲区最大字节数 */);/* 检查编码是否成功 */if (nbBytes < 0) {fprintf(stderr, "错误: 编码失败: %s", opus_strerror(nbBytes));break;}/* 将编码后的帧写入输出文件 *//* 格式:先写 4 字节的帧长度(小端序),再写帧数据 *//* 这种格式方便后续读取时逐帧解析 */uint32_t frame_len = (uint32_t)nbBytes;fwrite(&frame_len, sizeof(uint32_t), 1, fout);fwrite(opus_buffer, 1, nbBytes, fout);frame_count++;total_bytes += nbBytes;}/* ---------- 6. 输出统计信息 ---------- */printf("编码完成:");printf("总帧数: %d", frame_count);printf("Opus 总字节数: %d", total_bytes);if (frame_count > 0) {double duration_sec = (double)frame_count * FRAME_DURATION / 1000.0;double avg_bitrate = (double)total_bytes * 8.0 / duration_sec / 1000.0;printf("音频时长: %.2f 秒", duration_sec);printf("平均码率: %.2f kbps", avg_bitrate);double compression_ratio = (double)total_bytes / (double)(frame_count * FRAME_BYTES) * 100.0;printf("压缩率: %.1f%%", compression_ratio);}/* ---------- 7. 清理资源 ---------- */opus_encoder_destroy(encoder);fclose(fin);fclose(fout);return EXIT_SUCCESS;}9.5 编译与运行Linux / macOS# 编译gcc -o opus_encode_example opus_encode_example.c -lopus# 准备测试音频(将任意音频转为 16kHz 单声道 16bit PCM)ffmpeg -i test.wav -f s16le -acodec pcm_s16le -ar 16000 -ac 1 test.pcm# 运行./opus_encode_example test.pcm test.opusWindows(MinGW)gcc -o opus_encode_example opus_encode_example.c -lopus -I"C:pathoopusinclude" -L"C:pathoopuslib"CMake 项目cmake_minimum_required(VERSION 3.10)project(opus_encode_example C)# 查找 libopusfind_package(PkgConfig)pkg_check_modules(OPUS REQUIRED opus)add_executable(opus_encode_example opus_encode_example.c)target_include_directories(opus_encode_example PRIVATE ${OPUS_INCLUDE_DIRS})target_link_libraries(opus_encode_example ${OPUS_LIBRARIES})9.6 Python 与 C 的 API 对应关系操作Python (opuslib_next)C (libopus)导入库import opuslib_next#include <opus.h>创建编码器enc = opuslib_next.Encoder(sr, ch, app)enc = opus_encoder_create(sr, ch, app, &err)设置码率enc.set_bitrate(32000)opus_encoder_ctl(enc, OPUS_SET_BITRATE(32000))编码一帧enc.encode(pcm_bytes, frame_size)opus_encode(enc, pcm, frame_size, buf, max)销毁编码器自动 GCopus_encoder_destroy(enc)错误处理抛出异常返回负数错误码,用 opus_strerror() 转文本9.7 libopus 进阶用法9.7.1 编码浮点 PCM如果音频数据是 float 格式(范围 -1.0 ~ 1.0),使用 opus_encode_float:float pcm_float[960];/* float PCM 数据 */unsigned char packet[4000];int nbBytes = opus_encode_float(encoder,pcm_float,/* float 类型输入 */960,/* 采样点数 */packet,4000);9.7.2 静音检测(DTX)DTX(Discontinuous Transmission)在检测到静音时自动降低码率:/* 启用 DTX */opus_encoder_ctl(encoder, OPUS_SET_DTX(1));/* 同时启用 VBR 才能发挥 DTX 的效果 */opus_encoder_ctl(encoder, OPUS_SET_VBR(1));静音帧通常只有 1~2 字节,大幅节省带宽。适用于通话中长时间静音的场景。9.7.3 前向纠错(FEC)FEC(Forward Error Correction)在当前帧中包含下一帧的冗余信息,接收端可以在丢帧时恢复:/* 发送端:启用 in-band FEC */opus_encoder_ctl(encoder, OPUS_SET_INBAND_FEC(1));/* 接收端解码时启用 FEC 恢复 */opus_int32 lost_flag = 1;/* 1 表示当前帧丢失 */opus_decode(decoder, NULL, 0, pcm_out, 960, lost_flag);9.7.4 多声道编码立体声编码:OpusEncoder *enc = opus_encoder_create(48000, 2, OPUS_APPLICATION_AUDIO, &err);/* PCM 数据交错排列:左1, 右1, 左2, 右2, ... */opus_int16 pcm_stereo[960 * 2];/* 960 采样点 * 2 声道 */int nbBytes = opus_encode(enc, pcm_stereo, 960, packet, 4000);9.7.5 写入 Ogg/Opus 文件libopus 只输出裸的 Opus 帧,如果需要生成可播放的 .opus 文件,需要使用 libopusenc 封装到 Ogg 容器中:# 安装sudo apt install libopusenc-dev # Linuxbrew install libopusenc# macOS#include <opusenc.h>int error;OggOpusEnc *enc = ope_encoder_create_file("output.opus",/* 输出文件路径 */NULL, /* 默认注释 */48000,/* 采样率 */1,/* 声道数 */0,/* family(0=Vorbis mapping) */&error/* 错误码 */);/* 写入 PCM 数据(float 格式) */ope_encoder_write_float(enc, float_pcm_array, frame_count_per_channel);/* 刷新缓冲区并关闭 */ope_encoder_drain(enc);ope_encoder_destroy(enc);编译时需要链接 libopusenc:gcc -o example example.c -lopus -lopusenc9.8 libopus 解码 API虽然本文重点是编码,但解码 API 同样重要,用于接收端还原音频:/* 创建解码器 */OpusDecoder *decoder = opus_decoder_create(16000, 1, &error);/* 解码一帧 */opus_int16 pcm_out[960];int frame_size = opus_decode(decoder,/* 解码器状态 */opus_packet,/* Opus 压缩数据 */packet_size,/* 数据字节数 */pcm_out,/* 输出 PCM 缓冲区 */960,/* 最大输出采样点数 */0 /* 0=正常解码,1=丢帧时用 FEC 恢复 */);/* 销毁解码器 */opus_decoder_destroy(decoder);9.9 常见错误码错误码常量名含义-1OPUS_BAD_ARG参数错误(如采样率不合法)-2OPUS_BUFFER_TOO_SMALL输出缓冲区太小-3OPUS_INTERNAL_ERROR内部错误-4OPUS_INVALID_PACKET解码时数据包无效-5OPUS_UNIMPLEMENTED未实现的功能-6OPUS_INVALID_STATE编码器/解码器状态无效-7OPUS_ALLOC_FAIL内存分配失败使用 opus_strerror(error_code) 可将错误码转为可读文本。9.10 Python 与 C 的选择建议因素PythonC开发速度快,几行代码即可慢,需要手动管理内存和编译运行性能较慢,受 GIL 和解释器开销影响最快,直接调用底层库内存控制自动 GC手动分配/释放部署复杂度需要 Python 环境 + pip 包编译为单个二进制文件适用场景原型开发、脚本、数据处理嵌入式、实时系统、高性能服务WebRTC 集成通过 aiortc 等库通过 libwebrtc 或 Pion 等原生库建议:快速验证逻辑 → 用 Python性能敏感的生产环境 → 用 CWebRTC 前端(浏览器)→ 无需手动编码,浏览器自动处理WebRTC 后端(C/C++ 服务)→ 直接使用 libopus
2026.04.22了解详情
< 12345··· >
联系方式

地址:深圳市龙华区观湖街道观乐路5号多彩科创园B栋801

邮箱:steven@yunthinker.com