本文共 19101 字,大约阅读时间需要 63 分钟。
如果你也有文章愿意分享并希望获得官方的写作指导,可以发送文章/联系方式邮件至邮箱:xuqianqian@rt-thread.com
今天主要是讲解音频虚拟驱动来分析驱动的编写。但是这篇文章并不会讲解关于 RT-Thread IO Device 框架相关内容,如果有对这部分不太熟悉的人请先看这个链接了解基本概念,RT-Thread I/O 设备模型:https://www.rt-thread.org/document/site/programming-manual/device/device
(请复制以上链接至外部浏览器打开)
1. RT-Thread 音频框架图
RT-Thread的音频分成了4个部分,但是我们只要关心上层提供的api和底层驱动需要实现的ops接口就可以了。
2. 如何使用 Audio 驱动
在写驱动之前,我们首先得知道如何测试自己的驱动,所以我们首先需要了解下 RT-Thread 系统中是如何播放音乐的!
1#include2#include 3#include 4 5#define BUFSZ 1024 6#define SOUND_DEVICE_NAME "sound0" /* Audio 设备名称 */ 7static rt_device_t snd_dev; /* Audio 设备句柄 */ 8 9struct RIFF_HEADER_DEF 10{ 11 char riff_id[4]; // 'R','I','F','F' 12 uint32_t riff_size; 13 char riff_format[4]; // 'W','A','V','E' 14}; 15 16struct WAVE_FORMAT_DEF 17{ 18 uint16_t FormatTag; 19 uint16_t Channels; 20 uint32_t SamplesPerSec; 21 uint32_t AvgBytesPerSec; 22 uint16_t BlockAlign; 23 uint16_t BitsPerSample; 24}; 25 26struct FMT_BLOCK_DEF 27{ 28 char fmt_id[4]; // 'f','m','t',' ' 29 uint32_t fmt_size; 30 struct WAVE_FORMAT_DEF wav_format; 31}; 32 33struct DATA_BLOCK_DEF 34{ 35 char data_id[4]; // 'R','I','F','F' 36 uint32_t data_size; 37}; 38 39struct wav_info 40{ 41 struct RIFF_HEADER_DEF header; 42 struct FMT_BLOCK_DEF fmt_block; 43 struct DATA_BLOCK_DEF data_block; 44}; 45 46int wavplay_sample(int argc, char **argv) 47{ 48 int fd = -1; 49 uint8_t *buffer = NULL; 50 struct wav_info *info = NULL; 51 struct rt_audio_caps caps = {0}; 52 53 if (argc != 2) 54 { 55 rt_kprintf("Usage:\n"); 56 rt_kprintf("wavplay_sample song.wav\n"); 57 return 0; 58 } 59 60 fd = open(argv[1], O_WRONLY); 61 if (fd < 0) 62 { 63 rt_kprintf("open file failed!\n"); 64 goto __exit; 65 } 66 67 buffer = rt_malloc(BUFSZ); 68 if (buffer == RT_NULL) 69 goto __exit; 70 71 info = (struct wav_info *) rt_malloc(sizeof * info); 72 if (info == RT_NULL) 73 goto __exit; 74 75 if (read(fd, &(info->header), sizeof(struct RIFF_HEADER_DEF)) <= 0) 76 goto __exit; 77 if (read(fd, &(info->fmt_block), sizeof(struct FMT_BLOCK_DEF)) <= 0) 78 goto __exit; 79 if (read(fd, &(info->data_block), sizeof(struct DATA_BLOCK_DEF)) <= 0) 80 goto __exit; 81 82 rt_kprintf("wav information:\n"); 83 rt_kprintf("samplerate %d\n", info->fmt_block.wav_format.SamplesPerSec); 84 rt_kprintf("channel %d\n", info->fmt_block.wav_format.Channels); 85 86 /* 根据设备名称查找 Audio 设备,获取设备句柄 */ 87 snd_dev = rt_device_find(SOUND_DEVICE_NAME); 88 89 /* 以只写方式打开 Audio 播放设备 */ 90 rt_device_open(snd_dev, RT_DEVICE_OFLAG_WRONLY); 91 92 /* 设置采样率、通道、采样位数等音频参数信息 */ 93 caps.main_type = AUDIO_TYPE_OUTPUT; /* 输出类型(播放设备 )*/ 94 caps.sub_type = AUDIO_DSP_PARAM; /* 设置所有音频参数信息 */ 95 caps.udata.config.samplerate = info->fmt_block.wav_format.SamplesPerSec; /* 采样率 */ 96 caps.udata.config.channels = info->fmt_block.wav_format.Channels; /* 采样通道 */ 97 caps.udata.config.samplebits = 16; /* 采样位数 */ 98 rt_device_control(snd_dev, AUDIO_CTL_CONFIGURE, &caps); 99 100 while (1) 101 { 102 int length; 103 104 /* 从文件系统读取 wav 文件的音频数据 */ 105 length = read(fd, buffer, BUFSZ); 106 107 if (length <= 0) 108 break; 109 110 /* 向 Audio 设备写入音频数据 */ 111 rt_device_write(snd_dev, 0, buffer, length); 112 } 113 114 /* 关闭 Audio 设备 */ 115 rt_device_close(snd_dev); 116 117__exit: 118 119 if (fd >= 0) 120 close(fd); 121 122 if (buffer) 123 rt_free(buffer); 124 125 if (info) 126 rt_free(info); 127 128 return 0; 129} 130MSH_CMD_EXPORT(wavplay_sample, play wav file);
这段代码主要是播放 wav(pcm) 的音频。那么我们来分析下上面一段代码,这段播放一段音频数据的主要步骤如下:
1、#define SOUND_DEVICE_NAME "sound0"
: 首先定义播放的驱动
2、fd = open(argv[1], O_WRONLY);
: 用于打开音频文件,这个没什么分析的3、snd_dev = rt_device_find(SOUND_DEVICE_NAME);
: 首先查找 Audio 设备获取设备句柄
4、rt_device_open(snd_dev, RT_DEVICE_OFLAG_WRONLY);
: 以只写方式打开 Audio 设备,也就是打开放音设备
5、rt_device_control(snd_dev, AUDIO_CTL_CONFIGURE, &caps);
: 置音频参数信息(采样率、通道等)
6、length = read(fd, buffer, BUFSZ);
: 读取音频文件的数据
7、rt_device_write(snd_dev, 0, buffer, length);
: 向驱动写入音频文件数据,写入后就会出声音,写入的数据为pcm数据,音频相关格式是步骤5中配置的参数 8、rt_device_close(snd_dev);
: 播放完成,关闭设备
这样看起来是不是非常简单,将这段代码添加到你的代码中进行编译下载,就可以了放音乐了,当然只能播放wav格式的音频。
这个时候肯定有大佬已经反应过来了,我bsp连个audio驱动都没有,脑补音乐吗!大佬不要心急,小弟这就给你把驱动慢慢道来~
3. 编写音频虚拟驱动
上来废话不多说,直接上干货:
1#include "drv_sound.h" 2#include "drv_tina.h" 3#include "drivers/audio.h" 4 5#define DBG_TAG "drv_sound" 6#define DBG_LVL DBG_LOG 7#define DBG_COLOR 8#include9 10#define TX_DMA_FIFO_SIZE (2048) 11 12struct temp_sound 13{ 14 struct rt_audio_device device; 15 struct rt_audio_configure replay_config; 16 int volume; 17 rt_uint8_t *tx_fifo; 18}; 19 20static rt_err_t getcaps(struct rt_audio_device *audio, struct rt_audio_caps *caps) 21{ 22 struct temp_sound *sound = RT_NULL; 23 24 RT_ASSERT(audio != RT_NULL); 25 sound = (struct temp_sound *)audio->parent.user_data; (void)sound; 26 27 return RT_EOK; 28} 29 30static rt_err_t configure(struct rt_audio_device *audio, struct rt_audio_caps *caps) 31{ 32 struct temp_sound *sound = RT_NULL; 33 34 RT_ASSERT(audio != RT_NULL); 35 sound = (struct temp_sound *)audio->parent.user_data; (void)sound; 36 37 return RT_EOK; 38} 39 40static rt_err_t init(struct rt_audio_device *audio) 41{ 42 struct temp_sound *sound = RT_NULL; 43 44 RT_ASSERT(audio != RT_NULL); 45 sound = (struct temp_sound *)audio->parent.user_data; (void)sound; 46 47 return RT_EOK; 48} 49 50static rt_err_t start(struct rt_audio_device *audio, int stream) 51{ 52 struct temp_sound *sound = RT_NULL; 53 54 RT_ASSERT(audio != RT_NULL); 55 sound = (struct temp_sound *)audio->parent.user_data; (void)sound; 56 57 return RT_EOK; 58} 59 60static rt_err_t stop(struct rt_audio_device *audio, int stream) 61{ 62 struct temp_sound *sound = RT_NULL; 63 64 RT_ASSERT(audio != RT_NULL); 65 sound = (struct temp_sound *)audio->parent.user_data; (void)sound; 66 67 return RT_EOK; 68} 69 70rt_size_t transmit(struct rt_audio_device *audio, const void *writeBuf, void *readBuf, rt_size_t size) 71{ 72 struct temp_sound *sound = RT_NULL; 73 74 RT_ASSERT(audio != RT_NULL); 75 sound = (struct temp_sound *)audio->parent.user_data; (void)sound; 76 77 return size; 78} 79 80static void buffer_info(struct rt_audio_device *audio, struct rt_audio_buf_info *info) 81{ 82 struct temp_sound *sound = RT_NULL; 83 84 RT_ASSERT(audio != RT_NULL); 85 sound = (struct temp_sound *)audio->parent.user_data; 86 87 /** 88 * TX_FIFO 89 * +----------------+----------------+ 90 * | block1 | block2 | 91 * +----------------+----------------+ 92 * \ block_size / 93 */ 94 info->buffer = sound->tx_fifo; 95 info->total_size = TX_DMA_FIFO_SIZE; 96 info->block_size = TX_DMA_FIFO_SIZE / 2; 97 info->block_count = 2; 98} 99 100static struct rt_audio_ops ops = 101{ 102 .getcaps = getcaps, 103 .configure = configure, 104 .init = init, 105 .start = start, 106 .stop = stop, 107 .transmit = transmit, 108 .buffer_info = buffer_info, 109}; 110 111static int rt_hw_sound_init(void) 112{ 113 rt_uint8_t *tx_fifo = RT_NULL; 114 static struct temp_sound sound = {0}; 115 116 /* 分配 DMA 搬运 buffer */ 117 tx_fifo = rt_calloc(1, TX_DMA_FIFO_SIZE); 118 if(tx_fifo == RT_NULL) 119 { 120 return -RT_ENOMEM; 121 } 122 123 sound.tx_fifo = tx_fifo; 124 125 /* 注册声卡放音驱动 */ 126 sound.device.ops = &ops; 127 rt_audio_register(&sound.device, "sound0", RT_DEVICE_FLAG_WRONLY, &sound); 128 129 return RT_EOK; 130} 131INIT_DEVICE_EXPORT(rt_hw_sound_init);
上面是整个audio驱动的架子,没有如何和硬件相关的代码,但是添加到项目中,是可以在shell中使用list_device命令看到 sound0 驱动的。如果我们将第一章中的代码配合的话是可以播放 wav 音频,当然由于没有硬件相关代码是不会出声音的。
我们先来分析下这段代码:
1、rt_hw_sound_init 函数是驱动的入口,用于注册audio框架,在这个里面,我们分配了 audio dma 需要的buffer,并将 实现的音频相关的ops注册到sound0音频设备中。调用这个函数后就可以在list_device中看到sound0驱动了。
2、那么接下来有疑问了struct rt_audio_ops ops这个结构体中的几个函数分别是干什么的如何编写。那么笔者给大家慢慢道来!
3、由于 audio 相关的配置和设置的参数比较多,所以这里我们将配置和获取参数分别分成了2个 ops 函数来实现,分别为 getcaps 和 configure。getcaps 用于获取 audio 的能力,例如硬件通道数,当前采样率,采样深度,音量,configure 函数用于实现设置通道数,当前采样率,采样深度,音量。
4、init ops函数,主要用于实现 芯片的 i2s(与外部codec进行音频数据通信) i2c(控制外部codec的采样率,mute脚,当然部分codec内置的是不需要这个的,还有部分比较低端一点的codec也是不会有i2c控制的,这个根据大家外部接的芯片来确定),当然还需要配置 dma 和 dma 中端。还有控制 mute 的gpio引脚。
5、start ops 函数主要是用于启动 dma 和 关mute 相关的处理的。
6、stop ops 函数主要是用于关闭 dma 和 开mute 相关的处理的。
7、transmit 主要是用于触发数据的搬运,为什么说是触发搬运呢?其实上层代码向音频设备写入音频数据并不会直接写入到驱动中,也就是不会直接调用transmit这个底层函数用于将缓冲区的数据传递到 dma 的buffer中,那么transmit会在什么时候调用呢?上面的驱动并不会触发驱动的搬运也就是这个函数,其实我们可以看到 audio 框架中有一个函数 rt_audio_tx_complete(&sound->device); 这个函数就是用于通知搬运的,那么我们再来梳理下这个段逻辑:
●上层应用调用 rt_device_write 函数向 audio 写入数据,框架层会将写入的数据缓存到内部的一个buffer(静态内存池中的一个节点,默认配置为2k数据)
●上层写入超过2k的数据会阻塞等待
●第一次使用 rt_device_write 会调用 start ops函数启动 dma搬运,在i2s的dma中断(半空和满中断服务函数中)调用 rt_audio_tx_complete 函数
●rt_audio_tx_complete 表示 dma的 数据搬运完毕了,需要填充下一次的音频数据,这个函数会调用 transmit ops,但是如果是i2s dma循环搬运的数据,dma会自动搬运数据,所以并不需要使用 transmit ops来将音频缓冲区的数据 copy 到驱动的dma中,那么transmit 有什么用呢?第一在部分没有dma循环搬运的芯片上我们可以利用这个函数触发下一个dma搬运或者是cpu搬运,第二这个地方可以用来刷cache的!
8、buffer_info 用于告诉audio框架你的音频驱动缓冲区有多大,有几块,这样上层通过 transmit ops函数的时候就知道给你多少字节数据了!
看了上面的分析我相信你应该了解了基本原理了,和编写方法了。但是这个驱动还是不能出声音,那么我们得想办法实现一个驱动,由于笔者的硬件和大家都不一样,那么小弟想了一个办法。
那就是将音频缓存到文件中,这里我们来做一个虚拟音频驱动,这个驱动并不会出声音,但是会将数据保存层pcm文件。pcm的相关参数和你播放的wav一样这样我们可以用电脑来播放了。这样就避免硬件的差异化。
4. 音频虚拟驱动编写
还是废话不多说,直接上代码。
1/* 2* File: drv_virtual.c 3* 4* COPYRIGHT (C) 2012-2019, Shanghai Real-Thread Technology Co., Ltd 5*/ 6 7#include "drv_virtual.h" 8#include "dfs.h" 9#include "dfs_posix.h" 10 11#define DBG_TAG "drv_virtual" 12#define DBG_LVL DBG_LOG 13#define DBG_COLOR 14#include15 16#define TX_DMA_FIFO_SIZE (2048) 17 18struct tina_sound 19{ 20 struct rt_audio_device device; 21 struct rt_audio_configure replay_config; 22 int volume; 23 rt_uint8_t *tx_fifo; 24 int fd; 25 struct rt_thread thread; 26 int endflag; 27}; 28 29static rt_err_t getcaps(struct rt_audio_device *audio, struct rt_audio_caps *caps) 30{ 31 rt_err_t ret = RT_EOK; 32 struct tina_sound *sound = RT_NULL; 33 34 RT_ASSERT(audio != RT_NULL); 35 sound = (struct tina_sound *)audio->parent.user_data; (void)sound; 36 37 switch(caps->main_type) 38 { 39 case AUDIO_TYPE_QUERY: 40 { 41 switch (caps->sub_type) 42 { 43 case AUDIO_TYPE_QUERY: 44 caps->udata.mask = AUDIO_TYPE_OUTPUT | AUDIO_TYPE_MIXER; 45 break; 46 47 default: 48 ret = -RT_ERROR; 49 break; 50 } 51 52 break; 53 } 54 55 case AUDIO_TYPE_OUTPUT: 56 { 57 switch(caps->sub_type) 58 { 59 case AUDIO_DSP_PARAM: 60 caps->udata.config.channels = sound->replay_config.channels; 61 caps->udata.config.samplebits = sound->replay_config.samplebits; 62 caps->udata.config.samplerate = sound->replay_config.samplerate; 63 break; 64 65 default: 66 ret = -RT_ERROR; 67 break; 68 } 69 70 break; 71 } 72 73 case AUDIO_TYPE_MIXER: 74 { 75 switch (caps->sub_type) 76 { 77 case AUDIO_MIXER_QUERY: 78 caps->udata.mask = AUDIO_MIXER_VOLUME | AUDIO_MIXER_LINE; 79 break; 80 81 case AUDIO_MIXER_VOLUME: 82 caps->udata.value = sound->volume; 83 break; 84 85 case AUDIO_MIXER_LINE: 86 break; 87 88 default: 89 ret = -RT_ERROR; 90 break; 91 } 92 93 break; 94 } 95 96 default: 97 ret = -RT_ERROR; 98 break; 99 } 100 101 return ret; 102} 103 104static rt_err_t configure(struct rt_audio_device *audio, struct rt_audio_caps *caps) 105{ 106 rt_err_t ret = RT_EOK; 107 struct tina_sound *sound = RT_NULL; 108 109 RT_ASSERT(audio != RT_NULL); 110 sound = (struct tina_sound *)audio->parent.user_data; (void)sound; 111 112 switch(caps->main_type) 113 { 114 case AUDIO_TYPE_MIXER: 115 { 116 switch(caps->sub_type) 117 { 118 case AUDIO_MIXER_VOLUME: 119 { 120 int volume = caps->udata.value; 121 sound->volume = volume; 122 break; 123 } 124 125 default: 126 ret = -RT_ERROR; 127 break; 128 } 129 130 break; 131 } 132 133 case AUDIO_TYPE_OUTPUT: 134 { 135 switch(caps->sub_type) 136 { 137 case AUDIO_DSP_PARAM: 138 { 139 int samplerate; 140 141 samplerate = caps->udata.config.samplerate; 142 sound->replay_config.samplerate = samplerate; 143 LOG_I("set samplerate = %d", samplerate); 144 break; 145 } 146 147 case AUDIO_DSP_SAMPLERATE: 148 { 149 int samplerate; 150 151 samplerate = caps->udata.config.samplerate; 152 sound->replay_config.samplerate = samplerate; 153 LOG_I("set samplerate = %d", samplerate); 154 break; 155 } 156 157 case AUDIO_DSP_CHANNELS: 158 { 159 break; 160 } 161 162 default: 163 break; 164 } 165 166 break; 167 } 168 169 default: 170 break; 171 } 172 173 return ret; 174} 175 176static void virtualplay(void *p) 177{ 178 struct tina_sound *sound = (struct tina_sound *)p; (void)sound; 179 180 while(1) 181 { 182 /* tick = TX_DMA_FIFO_SIZE/2 * 1000ms / 44100 / 4 ≈ 5.8 */ 183 rt_thread_mdelay(6); 184 rt_audio_tx_complete(&sound->device); 185 186 if(sound->endflag == 1) 187 { 188 break; 189 } 190 } 191} 192 193static int thread_stack[1024] = { 0}; 194 195static rt_err_t init(struct rt_audio_device *audio) 196{ 197 struct tina_sound *sound = RT_NULL; 198 199 RT_ASSERT(audio != RT_NULL); 200 sound = (struct tina_sound *)audio->parent.user_data; (void)sound; 201 202 LOG_I("sound init"); 203 204 return RT_EOK; 205} 206 207static rt_err_t start(struct rt_audio_device *audio, int stream) 208{ 209 struct tina_sound *sound = RT_NULL; 210 rt_err_t ret = RT_EOK; 211 212 RT_ASSERT(audio != RT_NULL); 213 sound = (struct tina_sound *)audio->parent.user_data; (void)sound; 214 215 LOG_I("sound start"); 216 217 ret = rt_thread_init(&sound->thread, "virtual", virtualplay, sound, &thread_stack, sizeof(thread_stack), 1, 10); 218 if(ret != RT_EOK) 219 { 220 LOG_E("virtual play thread init failed"); 221 return (-RT_ERROR); 222 } 223 rt_thread_startup(&sound->thread); 224 225 sound->endflag = 0; 226 227 sound->fd = open("/tmp/virtual.pcm", O_CREAT | O_RDWR, 0666); 228 229 return RT_EOK; 230} 231 232static rt_err_t stop(struct rt_audio_device *audio, int stream) 233{ 234 struct tina_sound *sound = RT_NULL; 235 236 RT_ASSERT(audio != RT_NULL); 237 sound = (struct tina_sound *)audio->parent.user_data; (void)sound; 238 239 LOG_I("sound stop"); 240 241 sound->endflag = 1; 242 243 close(sound->fd); 244 sound->fd = -1; 245 246 return RT_EOK; 247} 248 249rt_size_t transmit(struct rt_audio_device *audio, const void *wb, void *rb, rt_size_t size) 250{ 251 struct tina_sound *sound = RT_NULL; 252 253 RT_ASSERT(audio != RT_NULL); 254 sound = (struct tina_sound *)audio->parent.user_data; (void)sound; 255 256 return write(sound->fd, wb, size); 257} 258 259static void buffer_info(struct rt_audio_device *audio, struct rt_audio_buf_info *info) 260{ 261 struct tina_sound *sound = RT_NULL; 262 263 RT_ASSERT(audio != RT_NULL); 264 sound = (struct tina_sound *)audio->parent.user_data; 265 266 /** 267 * TX_FIFO 268 * +----------------+----------------+ 269 * | block1 | block2 | 270 * +----------------+----------------+ 271 * \ block_size / 272 */ 273 info->buffer = sound->tx_fifo; 274 info->total_size = TX_DMA_FIFO_SIZE; 275 info->block_size = TX_DMA_FIFO_SIZE / 2; 276 info->block_count = 2; 277} 278 279static struct rt_audio_ops ops = 280{ 281 .getcaps = getcaps, 282 .configure = configure, 283 .init = init, 284 .start = start, 285 .stop = stop, 286 .transmit = transmit, 287 .buffer_info = buffer_info, 288}; 289 290static int rt_hw_sound_init(void) 291{ 292 rt_uint8_t *tx_fifo = RT_NULL; 293 static struct tina_sound sound = {0}; 294 295 /* 分配 DMA 搬运 buffer */ 296 tx_fifo = rt_calloc(1, TX_DMA_FIFO_SIZE); 297 if(tx_fifo == RT_NULL) 298 { 299 return -RT_ENOMEM; 300 } 301 302 sound.tx_fifo = tx_fifo; 303 304 /* 配置 DSP 参数 */ 305 { 306 sound.replay_config.samplerate = 44100; 307 sound.replay_config.channels = 2; 308 sound.replay_config.samplebits = 16; 309 sound.volume = 60; 310 sound.fd = -1; 311 sound.endflag = 0; 312 } 313 314 /* 注册声卡放音驱动 */ 315 sound.device.ops = &ops; 316 rt_audio_register(&sound.device, "sound0", RT_DEVICE_FLAG_WRONLY, &sound); 317 318 return RT_EOK; 319} 320INIT_DEVICE_EXPORT(rt_hw_sound_init);
根据第二部分的分析,相信你也能看懂这部分代码,这个驱动的根本思想是利用 virtualplay 线程模拟 i2s dma进行数据的自动搬运!
最终文件会保存到 /tmp/virtual.pcm
中,注意这里有点是 virtualplay 函数延时了6ms是为了模拟dma buffer中 1k 数据搬运(播放)需要消耗的时间,tick = TX_DMA_FIFO_SIZE/2 * 1000ms / 44100 / 4 ≈ 5.8ms
所以我们得要求文件写入比较快,这里笔者利用了ramfs来实现文件系统,经过实际测试如果写入sd卡或者flash会非常的慢,所以还是建议使用 ramfs 保证 20Mbytes 以上的大小,当然可以使用 qemu 来测试~
那么小弟就分析到这里,更加多的信息请加入 qq 群: 690181735 讨论,有更多更专业RT-Thread audio相关资料等着你!
RT-Thread线上/下活动
1、【RT-Thread开发者大会报名】深圳站马上开始!2019年RT-Thread开发者大会已经登入了成都、上海,马上将去到我们最后一站深圳,大会内容包含:RT-Thread在中高端智能领域的应用、一站式RTT开发工具、打造IoT极速开发模式等干货演讲,期待您的参与!
立即报名
2、【RT-Thread音频大会】12月14日,LiveVideoStack联合RT-Thread在LiveVideoStackCon音视频技术大会推出嵌入式与音频开发专题会议,本专题将讨论音频设备开发方案、实战经验,涉及智能音箱、TWS耳机等产品的技术实践。
长按识别二维码扫码报名
报名链接:http://sz2019.livevideostack.com/track/106
#题外话# 喜欢RT-Thread不要忘了在GitHub上留下你的STAR哦,你的star对我们来说非常重要!链接地址:https://github.com/RT-Thread/rt-thread
你可以添加微信17775982065为好友,注明:公司+姓名,拉进 RT-Thread 官方微信交流群
RT-Thread
让物联网终端的开发变得简单、快速,芯片的价值得到最大化发挥。Apache2.0协议,可免费在商业产品中使用,不需要公布源码,无潜在商业风险。长按二维码,关注我们
点击“阅读原文”报名开发者大会
转载地址:http://ranef.baihongyu.com/