【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 調用起來更容易,只是老周自己還沒弄成功,所以先不寫。老周分享的這些破玩意兒,向來都要親自驗證過才寫的。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章