【ESP32】製作 Wi-fi 音響(HTTP + I2S 協議)

东邪独孤發表於2024-05-26

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

相關文章