【ESP32】制作 Wi-fi 音箱(HTTP + I2S 协议)

用 Wifi 来传输音频数据,会比蓝牙更好。使用蓝牙方式,不管你用什么协议,都会对数据重新编码,说人话就是有损音质,虽然不至于全损。而使用 Wifi 就可以将 PCM 数据直接传输,无需再编码和压缩。在 ESP32 开发板上可以通过 I2S(IIS)向功放芯片发出音频数据。

关于 i2s 的时序,老周就不啰嗦了,这种玩意儿,网上一搜一大把,老周写东西向来不喜欢抄的,所以,时序相关的就省略了。不过,有一点老周要说清楚:i2s 传输的是数字信号,不是模拟信号。这一点一定得记住,千万不要把 i2s 直接连接喇叭,没鸟用的。它要先给功放处理,放大后输出模拟信号,才能连接喇叭。所以说,i2s 是数字芯片之间通信用的。本质来说,也是 IO 接口的电平高低的变化,所以,i2s 不仅可以传输数字音频,还可以驱动 WS2812 彩灯。这种 RGB 彩灯也真是博大包容,几乎啥协议它们都受用。

先简单老周自己做的个人 WiFi 音响,功放芯片用的是 NS4168,对,M5Stack Atom Echo 开发套件用的就是这个芯片,这货虽然体积小巧,但是喇叭配得不怎么行,声音又尖又刺,还伴随严重的谐振,所以不要拿它来播放太嗨的电子舞曲(官方文档也说了,不要长时间播放重低音,嗯,他们还算有点自知之明)。老周用的是 3W/4Ω 扬声器,是从一台某科 DVD 机上拆下来的。前面用过 MaxXXXX 系列的芯片,发现杂音特严重,就跟二战时期的电报音差不多。

至于传输,这个就没限制,就是常规的网络通信。用 TCP、UDP、MTQQ(这个不太适合)都行,老周用的是 HTTP。音频不可能保存在 ESP 的 Flash 上的,不然就不叫 Wi Fi 音响了。在服务器上,老周用 ASP.NET Core 实现,做了三个页面:简单的密码验证(主要防熊孩子)、PCM 音频上传页,以及自定义播放列表页。播放列表是事先定义好,存放在 JSON 文件中。当我按一下连接到 ESP32 的按钮,就会向服务器发出请求,开始播放列表中的歌曲。

ESP 32 上面(客户端)本来计划用 .NET Nano Framework 来搞的,毕竟这个兼得了 .NET 的高效编程方式,同时性能也不太差。但很可惜,老周连试了三块开发板都不行。面向 Esp 32-Pico 的 Nano CLR 固件不带 i2s 本地代码,无法用;刷其他版本的固件无法启动 CLR。另一块 Esp32-S3 因为是高度封装版,没有引出太多的 IO,也干不了。然后,老周翻出尘封多年,当初 78 元买入,现在涨了四倍价格的乐鑫 LyraT 开发板。经测试还是不行。然后,又用某果云定制的 ESP32 板子测试,依然不行。

那玩不下了吗?不,千好万好还是原生 SDK 好,那就用 esp-idf 来弄吧。至于 .NET Nano 的,下次老周买一块 esp32-s3 的核心板再试。

 -------------------------------------------------------------------------------------------

WTF,不知不觉居然讲了那么F话,下面咱们开始。.NET 服务器端很好弄,所以留在后面说,先说 IDF 的。ESP32 最让人喜欢的就是有 Wifi,有蓝牙,还集成各种玩意儿,确实是性价比之王。但,乐鑫自己做的开发板就特别贵,当然做工会比20多元的好。esp32 客户端咱们要完成这几件事:

1、初始化网络接口。不管是用 Wifi-STA,Wifi-AP,或是用带以太网接口的,都要初始化 netif(Net Interface);

2、初始化 Wifi。这里咱们是要连接到路由器,然后访问服务器上的音频。故,很明显,是要选择 STA 模式(Station);

3、初始化 i2s 驱动(5.x 的 idf 是分开发送和接收通道的,发送是播放,接收是录音,比如麦克风);

4、初始化 HTTP 客户端参数;

5、发起 HTTP 请求。

 

一、初始化 Wifi

Wifi 的初始化过程是这样的:

A、调用 esp_netif_init 函数(esp_netif.h),这是初始化所有网络接口的驱动,并不只是无线网。

B、调用 esp_netif_create_default_wifi_sta 函数(esp_wifi_default.h)。这个函数会用默认的配置初始化 Wifi 驱动,并创建表示网络接口的 esp_netif_t,类型当然是指针的。我们用的是STA模式,所以……,如果是AP模式,可以调用 esp_netif_create_default_wifi_ap 函数。其实,C语言的指针不是你想的那么恐怖,只是很多教程压根没告诉你指针怎么用。因为返回的这个 esp_netif_t 对象,后面在调用其他函数时会用到,也就是说在其他地方要引用这个对象,所以你想想,用什么合适?那当然是指针了。毕竟大伙都知道,指针是保存地址的,正因为这样,才能保存你把它传给其他代码后,它引用的仍然是同一个对象。直接用类型声明的话,你在传递时它会自我复制,这会导致其他代码引用的不是这个对象了,而是复制体。

另外,不要看到指针类型就以为一定是堆上分配内存,看到一般变量声明就说是栈分配内存。指针类型与堆分配并没什么关系,它只是保存某对象的内存地址罢了,如果你代码这样写,那么,指针类型也可以保存栈内存的地址:

int x;
x = 999;
int* px = &x;     /* 存入了x的地址,x是栈上分配的 */

堆分配是用 new 关键字,或 malloc 函数,或 calloc 函数分配的,在不需要时可以 delete 或 free。堆上分配的是动态的内存空间,所以得到的肯定是指针类型的值,因为有了指针,就有其地址,就能访问。所以,很多有良好编码习惯的人,都会在 delete / free 之后,把指针类型的变量设置为 NULL:px = NULL。

这啥呢,虽然你把那片内存毙了,但指针变量里还是存着那个地址,此时它指向的是那片被清理了的内存。那里很乱的,所以人们也叫它“脏内存”,里面全是些没用的随机字节,污染严重,故很脏。

esp_netif_create_default_wifi_ap 或 esp_netif_create_default_wifi_sta 函数实际上调用了宏—— ESP_NETIF_DEFAULT_WIFI_AP、ESP_NETIF_DEFAULT_WIFI_STA,用默认的值配置后,用 esp_netif_new 函数创建 esp_netif_t;然后调用 esp_netif_attach_wifi_station 或 esp_netif_attach_wifi_ap 函数,把驱动关联到接口。最后用 esp_wifi_set_default_wifi_ap_handlers 或 esp_wifi_set_default_wifi_sta_handlers 注册默认的事件回调用函数。

ESP 的事件由两个值来描述:1、esp_event_base_t 类型的是事件基础值,可以理解为一组事件中的组标识。比如,咱们 Wifi 相关的事件,其 event base 就是 WIFI_EVENT;2、事件 ID,指代具体的事件,比如,属于 WIFI_EVENT 下的事件有:

WIFI_EVENT_STA_START:STA模式已启动;

WIFI_EVENT_AP_START:AP模式已启动;(AP模式,就是 wifi 热点,你可以理解为 esp32 当作路由器来用,其他机器连接到 esp32)

WIFI_EVENT_STA_CONNECTED:esp32 成功连上 Wifi 后发生;

WIFI_EVENT_STA_DISCONNECTED:掉线后发生,此时可以重新连接。

……

C、调用 esp_netif_set_hostname 函数为 esp32 板子设置主机名。这一步是可选的,如果不设置,默认是“espressif”;

D、调用 esp_wifi_init 函数初始化 Wifi;

E、调用 esp_wifi_set_config 函数配置 Wifi。如你路由器的 SSID,密码等。它的参数是内联类型——即共享内存的类型。说简单的就是 STA 模式和 AP 模式的配置信息占用相同的内存。

typedef union {
    wifi_ap_config_t  ap;  /**< configuration of AP */
    wifi_sta_config_t sta; /**< configuration of STA */
    wifi_nan_config_t nan; /**< configuration of NAN */
} wifi_config_t;

当你用的是STA模式,就配置 sta 成员,类型是 wifi_sta_config_t 结构体;同理,用AP模式时只配置 ap 成员就可以了;用 NAN 模式时,只配置 nan 成员。nan 也是个好用的东西,Network Awareness,网络感知。它是端对端联机,就是你不用连接路由器,不用上网,而是网卡之间直接可以连接,esp32 板子之间可以直接通信。

F、一切就绪,调用 esp_wifi_start 启动 Wifi。这时,esp 会自动连接路由器,连接成功后会发生 WIFI_EVENT_STA_CONNECTED 事件。

 

二、初始化 I2S

A、调用 i2s_new_channel 函数创建 I2S 通道,包括发送(TX)和接收(RX)通道。创建的通道用 i2s_chan_handle_t 表示。如果只用发送(播放音乐,不录音)不用接收,调用函数时,接收通道可以传递 NULL。

B、通道创建后,还无法使用,还要初始化它。因为 I2S 用发送和接收两个方向,有 PDM、STD、TDM 等模式。PDM一般是麦克风用,播放音频需要用 STD(标准模式)。为了方便配置,IDF 也提供了一组宏,可以直接用,只要指定采样率(Hz)即可,其他参数保持默认。如 I2S_STD_CLK_DEFAULT_CONFIG 宏可直接配置标准 I2S。配置参数传给 i2s_channel_init_std_mode 函数进行初始化。

C、调用 i2s_channel_enable 函数启用通道。如果不传输数据了,也可以调用 i2s_channel_disable 函数禁用通道。

D、此时,可以向功放芯片发送数据了。发送数据调用 i2s_channel_write 函数,接收数据调用 i2s_channel_read 函数。

E、不再使用 I2S 时可以调用 i2s_del_channel 函数删除通道,释放驱动。

 

三、初始化 HTTP 客户端

 A、用 esp_http_client_config_t 结构体初始化 HTTP 客户端,如请求的 URL,请求方式(GET、POST 等),随后用 esp_http_client_init 函数初始化,会返回 esp_http_client_handle_t 类型的句柄,它就是个符号,后面调用的 HTTP 有关的函数需要用到它。

B、esp_http_client_open 函数打开连接;

C、esp_http_client_write 函数向服务器发数据。POST 的时候需要,GET 的时候不需要,可以不调用。

D、esp_http_client_fetch_headers 函数获取服务器响应的 HTTP 头。注意,获取的是消息头,不是正文。

E、esp_http_client_read 函数读数据。这时候读的才是 HTTP 正文(Body)。

F、esp_http_client_close 函数,调用它关闭连接。

G、如果不再发出 HTTP 请求了可以调用 esp_http_client_cleanup 清理资源;如果后面还要向服务器发请求,那先不要调用。

从步聚B到F,其实可以用一个 esp_http_client_perform 函数一步到位。它会自动调用 从open,到 fetch,到 write、read,到 close 等方法。

不过,咱们这里向服务器请求的是 PCM 音频流,数据较长,不能一次就读完,咱们要读一点,然后发到 I2S 播放,然后再读后面的。所以就不能用 esp_http_client_perform 函数了。

 

-----------------------------------------------------------------------------------------------------------------

有了上面的流程印象,接下来咱们编码就好弄很多了。其实 C 语言没有你想的那么复杂,应该说复杂的是 C++。某些编程语言,如 Rust 拼命宣传自己这样那样比C语言好,而实际上根本不是。Rust 在设计上出发点就是错的,反人类语法多,还加入了各种莫名其妙的东西。想想那么多硬件设备程序都是用汇编、C语言写的,也不见得人家那么多故障。更多时候,无操作系统裸机跑的程序才是最稳定,或者用一些内核简单的系统做复杂任务调度(如 esp 用的 RTOS)。设备一旦有了操作系统,问题就多起来。

1、编写 init_i2s 函数,初始化 i2s 接口。

// I2S通道句柄
static i2s_chan_handle_t iis_tx_ch;

static void init_i2s()
{
    // 1、创建通道
    i2s_chan_config_t chcfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
    ESP_ERROR_CHECK(i2s_new_channel(&chcfg, &iis_tx_ch, NULL));
    // 2、配置通道
    i2s_std_config_t stdcfg = {
        // 时钟源,调用默认宏设置就行了
        .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
        // slot其实就是声道数
        .slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
        // 下面配置IO引脚号
        .gpio_cfg = {
            .dout = I2S_DATA,    // 数据线
            .bclk = I2S_BIT_CLK, // 位时钟线
            .ws = I2S_LR_CLK,    // 左右声道选择线
            // 下面这几个是说,引脚电平是否反转,通常不要反转,否则信号全错了
            .invert_flags = {
                .mclk_inv = false,
                .bclk_inv = false,
                .ws_inv = false}}};
    // 初始化函数
    ESP_ERROR_CHECK(i2s_channel_init_std_mode(iis_tx_ch, &stdcfg));
    // 3、使能通道,不然通不了
    ESP_ERROR_CHECK(i2s_channel_enable(iis_tx_ch));
}

i2s_chan_handle_t 类型的变量要声明为全局变量,因为待会儿在读取 HTTP 流并发送数据时要用到。

i2s_chan_config_t 对象咱们不必自己设置,用 I2S_CHANNEL_DEFAULT_CONFIG 宏就行了。

I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER)

i2s_new_channel 后两个参数分别是发送和接收通道的句柄,但这里咱们不用接收,所以直接给它 NULL。

I2S_NUM_0 指的是 i2s 总线号,ESP32 通常有两路 i2s 可用,第一路就是0,如果是 I2S_NUM_1 就表示选择用第二路。注意,这个只是逻辑上的总线号,不绑定硬件的,所以,IO脚编号你可以选不同组合。I2S_ROLE_MASTER 表示主机模式,因为是开发板发音频数据给功放芯片的,所以开发板当然是主机了。如果开发板作为从机,比如 esp 成为功放设备,电脑向 esp 发数据,那可以选从机角色(I2S_ROLE_SLAVE)。

主机和从机角色有啥不同呢?咱们先了解一下 IIS 的引脚就知道了。

1、MCLK:主时钟源,这个现在 99.996% 的芯片是不用连接的。这个是在功放芯片自己没有时钟源时才需要(比如无振荡器),没有时钟就不能产生电平高低变化了,那还通信个妖。

2、LRCLK:选择左右声道用的。就是上面代码 gpio_cfg 的 ws 成员,叫法不一样罢了。

3、BCLK:位时钟线,就是每个跳变周期你得发送/接收一个二进制位,这个好懂吧,就跟 i2c 的 SCL 差不多。

4、DATA:可能一根线,可能两根线(输入/输出)。就是传数据用的。

当你的 I2S 是主机时,LRCLK、BCLK 等时钟线是输出状态,时钟快慢,电平高低由你来决定,你是西楚霸王你说了算。当 I2S 是从机时,这些时钟线是输入状态,你必须听从别人的命令干活,人家发一个时钟周期你就要传一个二进制位。电平高低是别人说了算

此处咱们是向功放发数据,所以数据线只配置 dout 就行了。引脚编号基本可以随便选。

i2s_std_config_t 的 clk_cfg 成员是配置时钟源,用 I2S_STD_CLK_DEFAULT_CONFIG 宏设置默认的就行,免得自己配置错了还要计算分频。参数是采样率,如 44100 Hz。

slot_cfg 成员其实指的是声道,同理,用 I2S_STD_PCM_SLOT_DEFAULT_CONFIG 宏解决。因为咱这里是用 PCM 数据,所以要用针对 PCM 的配置,参数是位宽和声道数。当然,如果用飞利浦标准的话,就用 I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG 宏。常见的无损音频多是 16 位,这也是CD的标准;第二个参数 I2S_SLOT_MODE_STEREO 表示立体声(不是单纯的左右双声通道,而是有混合的);如果想用单声道,可以取值 I2S_SLOT_MODE_MONO。

注意,初始化通道后记得调用 i2s_channel_enable 函数启用通道,这一步容易忘记

 -------------------------------------------------------------------------------------------------------------------------

编写 init_wifi 函数,初始化 Wifi。既然要无线传输了,当然得连路由器啦。这个过程一般配合事件队列来弄,可以在不同条件下触发不同的行为。当然了,你嫌麻烦也可以不用事件的,在启动 Wifi STA 后 delay 200 毫秒,在连接 Wifi 时 delay 3 秒。用延时等待的方式也不是不行,只是要等多久不太好确定,控制不够精准,所以还是用事件的好。

按流程走就不会错,连 Wifi 的流程时:接口初始化(加载驱动)--> WIFI 初始化--> 配置 STA-->启动WIFI-->连接WIFI。

static void init_wifi()
{
    // 1、初始化网络接口
    esp_netif_init();
    // 2、加载无线网络接口
    esp_netif_t *interface = esp_netif_create_default_wifi_sta();
    // 设置主机名(可选)
    esp_netif_set_hostname(interface, "WaWaZ");
    // 3、初始化wifi
    wifi_init_config_t wfcfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&wfcfg));
    // 这个可选
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    // 4、配置STA模式
    wifi_config_t cfg =
        {
            .sta = {
                .ssid = MY_SSID,
                .password = MY_PWD,
                .bssid_set = false,
                .threshold.authmode = WIFI_AUTH_WPA_WPA2_PSK}};
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
    // 设置wifi密码保存在Flash上(nvs分区)
    esp_wifi_set_storage(WIFI_STORAGE_FLASH);
    // 启动wifi
    ESP_ERROR_CHECK(esp_wifi_start());
}

顺便补充一点,返回 esp_error_t 类型的函数都可以把返回传给 ESP_ERROR_CHECK 宏,这个宏是当有错误时输出在哪个代码文件哪一行,帮助你找到错误。

esp_netif_init 函数必须在所有网络相关的初始化之前调用。也就是说,不管你用无线还是有线(有些板子有以太网口),只要是和网络有关的,你都要先调用它。esp_netif_create_default_wifi_sta 是为STA模式的无线网络接口分配资源(加载驱动等),返回 esp_netif_t 实例,引用它可以调用其他相关函数。

esp_wifi_init 函数初始化的是接口层面上的配置,不是用来设置 SSID、连接密码的。一般用 WIFI_INIT_CONFIG_DEFAULT 宏获取默认值就可以了。这个是设置硬件参数的,自己设置如果弄不好,可能连接不了网络。甚至包括加解密的算法,除非你的路由器是自己做的,加密算法是自己写的,否则你不需要更改默认配置。

esp_wifi_set_config 函数才是用来设置 SSID、连接密码的,使用 wifi_config_t 结构体来配置。咱们这里用的是 STA 模式,所以只配置 sta 成员就好了。STA 模式下要把 bssid_set 成员设置为 false。ssid和 password 成员就不用介绍,字面意思都能知道是啥玩意。threshold.authmode 是指定路由器的加密措施,可以看路由器配置,也可以逐个试。常见是 WIFI_AUTH_WPA_WPA2_PSK 、WIFI_AUTH_WPA2_PSK。

esp_wifi_set_storage 函数是设置 wifi 配置的保存地方,就是你设置的 SSID、密码保存在哪,这样下次连 Wifi 时不用再设置了。配网的时候就经常这样弄。不过老周这里是直接把 SSID 硬编码了,为了简单。此处指定 WIFI_STORAGE_FLASH 就是把配置存到 Flash上。你看看 esp 的分区表,是不是有个叫 nvs 的。对,这个分区就是用来存放配置的,以字典(key / value)方式读写数据。正因为要用到 nvs 分区,所以在初始化 wifi 前,就要初始化 nvs,这个咱们把代码放到 app_main 函数里写。

esp_wifi_start 函数调用完毕后,如果不出事故,wifi 已经可用了。连接 WIFI 调用 esp_wifi_connect 函数,断开 Wifi 调用 esp_wifi_disconnect 函数。不过,前面说了,咱们既然用到事件队列,连接 Wifi 的操作自然要放在事件回调函数中。

static void network_event_cb(
    void *ev_arg,
    esp_event_base_t evtbase,
    int32_t evt_id,
    void *evt_data)
{
    if (evtbase == WIFI_EVENT)
    {
        switch (evt_id)
        {
        case WIFI_EVENT_STA_CONNECTED:
            // 连接成功,发送一个事件位标志
            xEventGroupSetBits(evt_grp_hd, EVG_WIFI_CONNECTED_BIT);
            break;
        case WIFI_EVENT_STA_DISCONNECTED:
            // 断线了自动连接
            esp_wifi_connect();
            break;
        case WIFI_EVENT_STA_START:
            // STA 模式启动了,连接路由器
            esp_wifi_connect();
            break;
        default:
            break;
        }
    }

    if (evtbase == IP_EVENT)
    {
        // 获取到IP地址
        if (evt_id == IP_EVENT_STA_GOT_IP)
        {
            // 发送一个事件位标志
            xEventGroupSetBits(evt_grp_hd, EVG_NETIF_GOTIP_BIT);
        }
    }
}

事件回调用函数的声明是这样的:

void         (*esp_event_handler_t)(void* event_handler_arg,
                                        esp_event_base_t event_base,
                                        int32_t event_id,
                                        void* event_data);

没错,这货是一个函数指针,event_handler_arg 参数是指向 void 的指针,在注册事件回调时由你自己指定,等于是一个上下文对象,不用的话,直接给 NULL 就行;event_base 就是事件基础标识,前面介绍过,你可以认为它是一个事件发组的标识,这里用到 WIFI_EVENT,表明我后面处理的事件是和 Wifi 有关的;event_data 是事件相关的数据,不同事件的数据不同,所以它的类型是 void 指针。vadw oid 可以表示万能类型。

例如,WIFI_EVENT_STA_CONNECTED 事件表示 Wifi 连接成功,它对应的事件数据是 wifi_event_sta_connected_t。包括 SSID,连接使用的频道等信息。

注册事件在 app_main 函数中完成,待会再扯,下面看HTTP客户端初始化。写到一个函数里面,在app_main中会创建一个新任务,让它在新任务上运行。

static void http_req_task(void *arg)
{
    esp_http_client_config_t cfg =
        {
            .url = HTTP_SERVER_ADDR,
            .buffer_size = 89120,
            .method = HTTP_METHOD_GET};
    esp_http_client_handle_t httpHandle;
    // 初始化客户端
    httpHandle = esp_http_client_init(&cfg);
    // 缓冲区
    const uint16_t bufSize = 98000;
    uint8_t *buffer = (uint8_t *)malloc(bufSize);
    memset(buffer, 0, bufSize);
    while (1)
    {
        // 1、打开连接
        err_t res = esp_http_client_open(httpHandle, 0);
        if (res != ESP_OK)
        {
            vTaskDelay(pdMS_TO_TICKS(5000));
            continue;
        }
        // 2、获取流大小
        int64_t contentLen = esp_http_client_fetch_headers(httpHandle);
        if (contentLen <= 0)
        {
            vTaskDelay(pdMS_TO_TICKS(5000));
            continue;
        }
        // 3、读取内容
        int readLen = 0;
        readLen = esp_http_client_read(httpHandle, (char *)buffer, bufSize);
        // 4、把数据发送到 i2s
        while (readLen > 0)
        {
            i2s_channel_write(iis_tx_ch, (void *)buffer, readLen, NULL, 100);
            // 继续读
            readLen = esp_http_client_read(httpHandle, (char *)buffer, bufSize);
        }
        // 5、关闭连接
        esp_http_client_close(httpHandle);
        vTaskDelay(pdMS_TO_TICKS(2000));
    }
    // 清理
    free(buffer);
}

HTTP 是协议层的,初始化时不用加载硬件驱动,所以它的仪式感就没那么强了。esp_http_client_config_t 结构体用于配置 HTTP 请求相关的信息。url 成员指定你要请求的URL,buffer_size 是esp处理传输数据的缓冲大小,不是你写代码时用的字节数组的大小。method 成员指定请求方式,如 GET、POST 等。

调用 esp_http_client_init 函数后,返回 esp_http_client_handle_t 句柄,后面调用其他 HTTP 函数时用得到。这样就完工了,然后就是通信了。此处由于要使用流操作,不使用 esp_http_client_perform 函数,而是分步完成。esp_http_client_fetch_headers 函数读取服务器响应的 HTTP 头,并且该函数返回的值就是 Content-Length。这样咱们就知道音频 PCM 有多大了。

剩下的就是不断用 esp_http_client_read 从流中读数据,再用 i2s_channel_write 函数发数据。在上述代码中,代码写在一个死循环中,所以,会向同一 URL 不断发出请求,单曲循环(当然了,服务器可以选择返回不同的曲子)。

 

最后就是主任务—— app_main 函数了。

void app_main(void)
{
    // 初始化nvs存储
    err_t res = nvs_flash_init();
    if (res != ESP_OK)
    {
        // 不管你大爷是什么原因导致初始化失败
        // 一律格(杀)式(勿)化(论)
        nvs_flash_erase();
        // 再试一次
        res = nvs_flash_init();
    }
    if (res != ESP_OK)
    {
        ESP_LOGI("nvs", "真的无法初始化NVS了,请自我检讨");
        return;
    }
    /*------------------------------------------------------------------------*/
    // 创建默认的事件队列
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    // 创建事件组
    evt_grp_hd = xEventGroupCreate();
    // 注册事件
    ESP_ERROR_CHECK(esp_event_handler_register(
        WIFI_EVENT,
        WIFI_EVENT_STA_START,
        network_event_cb,
        NULL));
    ESP_ERROR_CHECK(esp_event_handler_register(
        WIFI_EVENT,
        WIFI_EVENT_STA_CONNECTED,
        network_event_cb,
        NULL));
    ESP_ERROR_CHECK(esp_event_handler_register(
        WIFI_EVENT,
        WIFI_EVENT_STA_DISCONNECTED,
        network_event_cb,
        NULL));
    ESP_ERROR_CHECK(esp_event_handler_register(
        IP_EVENT,
        IP_EVENT_STA_GOT_IP,
        network_event_cb,
        NULL));
    /*-----------------------------------------------------------------------*/
    // 初始化WIFI
    init_wifi();
    // 初始化IIS
    init_i2s();
    /*------------------------------------------------------------------------*/
    // 等待事件组设置二进制位
    EventBits_t evbits = xEventGroupWaitBits(
        evt_grp_hd, // 事件组句柄
        // 要等待的二进制位
        EVG_WIFI_CONNECTED_BIT | EVG_NETIF_GOTIP_BIT,
        pdTRUE,       // 自动清除二进制位
        pdTRUE,       // 等待所有位同时有效
        portMAX_DELAY // 一直等待
    );
    if (evbits & EVG_WIFI_CONNECTED_BIT)
    {
        ESP_LOGI("wifi", "wifi已连接");
    }
    if (evbits & EVG_NETIF_GOTIP_BIT)
    {
        ESP_LOGI("wifi", "已获取IP地址");
    }
    // 创建用于发起HTTP请求的任务
    xTaskCreate(
        http_req_task,
        "mytask", // 任务名称
        4096,     // 任务栈大小
        NULL,     // 用户参数,这里无参数
        2,        // 任务优先级
        NULL      // 任务句柄,这里不用存储
    );
    /*
        主任务是允许退出的
    */
}

idf 隐藏了 main 函数,应用程序编写的入口改为 app_main 函数,它实际上是 RTOS 的主任务调用的。可以看看 idf 是如何调用 app_main 的。

static void main_task(void* args)
{
    ESP_LOGI(MAIN_TAG, "Started on CPU%d", (int)xPortGetCoreID());
#if !CONFIG_FREERTOS_UNICORE
    // Wait for FreeRTOS initialization to finish on other core, before replacing its startup stack
    esp_register_freertos_idle_hook_for_cpu(other_cpu_startup_idle_hook_cb, !xPortGetCoreID());
    while (!s_other_cpu_startup_done) {
        ;
    }
    esp_deregister_freertos_idle_hook_for_cpu(other_cpu_startup_idle_hook_cb, !xPortGetCoreID());
#endif

    // [refactor-todo] check if there is a way to move the following block to esp_system startup
    heap_caps_enable_nonos_stack_heaps();

    // Now we have startup stack RAM available for heap, enable any DMA pool memory
#if CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL
    if (esp_psram_is_initialized()) {
        esp_err_t r = esp_psram_extram_reserve_dma_pool(CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL);
        if (r != ESP_OK) {
            ESP_LOGE(MAIN_TAG, "Could not reserve internal/DMA pool (error 0x%x)", r);
            abort();
        }
    }
#endif

    // Initialize TWDT if configured to do so
#if CONFIG_ESP_TASK_WDT_INIT
    esp_task_wdt_config_t twdt_config = {
        .timeout_ms = CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000,
        .idle_core_mask = 0,
#if CONFIG_ESP_TASK_WDT_PANIC
        .trigger_panic = true,
#endif
    };
#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0
    twdt_config.idle_core_mask |= (1 << 0);
#endif
#if CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1
    twdt_config.idle_core_mask |= (1 << 1);
#endif
    ESP_ERROR_CHECK(esp_task_wdt_init(&twdt_config));
#endif // CONFIG_ESP_TASK_WDT

    /*
    Note: Be careful when changing the "Calling app_main()" log below as multiple pytest scripts expect this log as a
    start-of-application marker.
    */
    ESP_LOGI(MAIN_TAG, "Calling app_main()");
    extern void app_main(void);
    app_main();
    ESP_LOGI(MAIN_TAG, "Returned from app_main()");
    vTaskDelete(NULL);
}

看到否?app_main 用 extern 修饰,把它声明为由外部其他代码实现的函数,idf 自身不实现,只负责调用。整初始化过程包括 CPU 两个核的初始化,接着是任务看门狗,最后调用 app_main。做完这些后 vTaskDelete(NULL) 表示该任务自杀。从这里也能知道,app_main 函数内是不需要死循环的,当你安排好程序的其他执行任务后,app_main 函数是可以返回的。

看门狗其实是利用定时器,在那里无休止地数咩咩,数着数着它就饿了。你的代码必须在看门狗饿疯之前喂它。看门狗的三观很简单,有得吃就是快乐。如果你的代码不喂狗,看门狗数咩咩数到一定数值(Time out)就会受不了,然后它会强制让开发板重启。看门狗的作用是防止你的程序死机,当开发板过一定时间后没反应,就重启。

任务看门狗就是监听任务队列,所有任务都是抢占 CPU 时间片的(和咱们常说的多线程差不多),当你的任务长时间不让出 CPU 时间片,任务看门狗就认为你这主人可能死机了,这么久不喂狗。由于 idf 默认已配置了一个任务看门狗,所以,你在任务代码是不用刻意去喂狗的,只要你每隔一段时间(没有 Time out 前,这个超时值可以在 SDK 选项中改)让出一下 CPU 时间片,就会自动喂狗了。开发板就不会重启了,最简单的方法就是调用一下 vTaskDelay() 做一下延时,不管延时多长,这个过程都会让出 CPU 时间片。

好,说回 app_main 函数。在这个函数里,咱们做了这几件事:

1、初始化 nvs,前面说了,用来保存配置的。

nvs_flash_init

这里为什么会做两次调用呢,因为这个 nvs 分区一般比较小,有时候存的数据满了(或者是以前的固件存的,现在你的新应用不需要这些垃圾数据),所以,如果初始化不成功,可尝试将 nvs 分区擦除(就像你格式化硬盘分区),这样就有空间来存放新数据了。

2、创建事件队列,前面说了嘛,Wifi 操作使用事件,如果不创建事件队列,那是收不到事件通知的,回调用函数永远无法运行。esp_event_loop_create_default 表示创建默认队列,无需保存变量,因为它由 idf 自动管理。当然,手动创建也可以的,还能选择动态分配或使用静态内存。你看,用 C 语言写就有这好处,灵活,你用 MicroPython、Arduino、.NET Nano 等封装过的框架,是没有这么细节的配置的。

3、xEventGroupCreate 函数创建一个事件分组,这个实际上就是给定一组由二进制位 OR 运算组合的标志。这些标志全是你自己定义,爱怎么定义都行,只要你保证每个标志只占一个二进制位。比如,

【吃饭】 = 0001

那么,接下来定义【啃树皮】就不能用第一位了,只能用2、3、4位任选一。

【啃树皮】 = 0100

如果做 【吃饭】|【啃树皮】运算,那么结果就是 0101,这就能看出,两件事同时发生了。设置二进制位可调用 xEventGroupSetBits 函数(请看前面 Wifi 事件回调函数);而我们的代码可以调用 xEventGroupWaitBits,当你需要的二进制位被设置了,这个函数就会返回。这就类似于线程信号灯,一个点灯,一个等灯。

4、注册事件回调函数。尽管你创建了事件队列,如果不注册回调函数,那么回调函数也不会被触发的。注册回调函数就是告诉事件队列:我对哪些事件感兴趣,并且这些事件发生时你帮我调用 XXX 函数;其他事件我没兴趣,别打扰我

注册事件回调,可以用 esp_event_handler_register 函数,或者 esp_event_handler_instance_register 函数。两者有啥区别?

A、esp_event_handler_register 是旧版函数,但在新版中也兼容的;esp_event_handler_instance_register 是新版本函数,提供给你,但你也可以不用;

B、esp_event_handler_register 函数注册后只告诉你个结果——有没有成功,但不给你任务句柄变量,后面要干吗你无法引用我;而 esp_event_handler_instance_register 函数在注册后会留一个 esp_event_handler_instance_t 类型的变量,后面你想调用其他函数时,可以用这个变量来引用。

这里我用到了两组事件,WIFI_EVENT 是和 wifi 有关的事件,IP_EVENT 是和 IP 地址有关的,因为我要用到 IP_EVENT_STA_GOT_IP 事件。此事件在 ESP 32 连上路由器并获取到 IP 地址后发生。响应此事件可以明确知道:我能上网啦,可以发出 HTTP 请求了。

当所有初始化工作完成后,用 xTaskCreate 创建一个任务,这个任务执行前面写的 http_req_task 函数,不断地接收 PCM 数据,并传给 i2s 接口播放。

    xTaskCreate(
        http_req_task,
        "mytask", // 任务名称
        4096,     // 任务栈大小
        NULL,     // 用户参数,这里无参数
        2,        // 任务优先级
        NULL      // 任务句柄,这里不用存储
    );

 

-------------------------------------------------------------------------------------------------------------------------------------

客户端竣工,现在来搓 HTTP 服务器。服务器直接建一个空白的 ASP.NET Core 项目。

代码很简单,Mini-API 即可胜任。

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Map("/", () => "洋癫疯音乐服务平台");

app.Map("/song", (IWebHostEnvironment env) =>
{
    // 获取应用程序所在目录
    IFileProvider rootDir = env.ContentRootFileProvider;
    // 从目录下获取PCM音频文件
    var pcmFile = rootDir.GetFileInfo("song.pcm");
    if(pcmFile.Exists)
    {
        // 直接把文件内容以流的形式返回
        return Results.Stream(pcmFile.CreateReadStream(), "application/octet-stream");
    }
    return Results.NotFound();
});

app.Run("http://192.168.1.10:80");

以 IWebHostEnvironment 类型为 API 方法的参数,它会自动注入。然后,用 ContentRootFileProvider 属性就得到了当前 Web 应用程序所在目录,再调用 GetFileInfo 方法就能获取到音频文件了。因为老周把 PCM 文件放在项目目录下。实际使用时,可以在服务器上建一个专用目录,存放文件。

PCM 数据怎么来呢?其实,WAV 文件除去文件头,剩下的就是 PCM 数据了。所以说,WAV 格式的音乐才叫无损。老周找了一首清新女神的歌进行演示,用 FFmpeg 来提取 PCM 数据。

ffmpeg -i "E:\音乐\王韵婵\王韵婵 - 勇敢高飞不寂寞.wav" -f s16le d:\out.pcm

-f 用在 input 之前设置的输入文件的格式,但这里用在输出路径之前,所以设置的是输出文件的格式。s16 表示有符号的 16 整数,le 表示小端。也就是说,咱们提取的 PCM 数据是 Uint16 类型数值,并且低地址存放低字节,高地址存放高字节。如果是大端,就是 s16be。但是,建议使用小端,因为这个比较通用,be 很多时候会出问题。

 

因为在这个例子中,ESP 32 一运行就发出 HTTP 请求的,所以,先运行服务器,然后再给 ESP 上电。老周这里的请求地址是 http://192.168.1.10:80/song,即 http://192.168.1.10/song 就行了。你需要根据实际情况改地址,确保服务器和客户端的地址匹配。

好了,今天就水到这儿了,改天等老周用 .NET Nano framework 做成功了,再写一文来介绍。其实,.NET 封装后的 I2S 调用起来更容易,只是老周自己还没弄成功,所以先不写。老周分享的这些破玩意儿,向来都要亲自验证过才写的。

 

posted @ 2024-05-26 16:27  东邪独孤  阅读(2484)  评论(3编辑  收藏  举报