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

来源:https://www.cnblogs.com/tcjiaan/p/18212730
-Advertisement-
Play Games

用 Wifi 來傳輸音頻數據,會比藍牙更好。使用藍牙方式,不管你用什麼協議,都會對數據重新編碼,說人話就是有損音質,雖然不至於全損。而使用 Wifi 就可以將 PCM 數據直接傳輸,無需再編碼和壓縮。在 ESP32 開發板上可以通過 I2S(IIS)向功放晶元發出音頻數據。 關於 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 調用起來更容易,只是老周自己還沒弄成功,所以先不寫。老周分享的這些破玩意兒,向來都要親自驗證過才寫的。

 


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • @DateTimeFormat 和 @JsonFormat 是 Spring 和 Jackson 中用於處理日期時間格式的註解,它們有不同的作用: @DateTimeFormat @DateTimeFormat 是 Spring 框架提供的註解,用於指定字元串如何轉換為日期時間類型,以及如何格式化日 ...
  • OpenAPI 規範是用於描述 HTTP API 的標準。該標準允許開發人員定義 API 的形狀,這些 API 可以插入到客戶端生成器、伺服器生成器、測試工具、文檔等中。儘管該標準具有普遍性和普遍性,但 ASP.NET Core 在框架內預設不提供對 OpenAPI 的支持。 當前 ASP.NET ...
  • 不廢話,直接代碼 private Stack<Action> actionStack = new Stack<Action>(); private void SetCellValues() { var worksheet = Globals.ThisAddIn.Application.ActiveS ...
  • 大家好,我是痞子衡,是正經搞技術的痞子。今天痞子衡給大家介紹的是不同J-Link版本對於i.MXRT1170連接複位後處理行為。 痞子衡之前寫過一篇舊文 《i.MXRT1170上用J-Link連接複位後PC總是停在0x223104的原因》,這篇文章詳細解釋了 RT1170 BootROM 代碼里軟體 ...
  • 目錄題目思路代碼展示進程A進程B結果展示 題目 要求進程A創建一條消息隊列之後向進程B發送SIGUSR1信號,進程B收到該信號之後打開消息隊列並寫入一段信息作為消息寫入到消息隊列中,要求進程B在寫入消息之後,發SIGUSR2信號給進程A,進程A收到該信號則從消息隊列中讀取消息並輸出消息正文的內容。 ...
  • 目錄一、創建百萬級小文件1、單核CPU情況2、多核CPU情況3、執行效率對比3.1、單核的順序執行3.2、多核的併發執行二、如何列出/瀏覽這些文件1、查看目錄下文件的數量2、列出?3、ls -f(關閉排序功能)3.1、執行效率對比4、通過重定嚮導入到文件中瀏覽對應的文件名三、如何快速刪除目錄下所有文 ...
  • Spawning Process 有了文件系統了,我們終於可以方便地讀取磁碟中的文件了。到目前為止,我們創建進程的方法一直都是在編譯內核的時候將程式鏈接到數據段,在 i386_init 通過 ENV_CREATE 巨集創建。 現在我們應該考慮通過文件系統直接將用戶程式從硬碟中讀取出來,spawn 就是 ...
  • 痞子衡嵌入式半月刊: 第 101 期 這裡分享嵌入式領域有用有趣的項目/工具以及一些熱點新聞,農曆年分二十四節氣,希望在每個交節之日準時發佈一期。 本期刊是開源項目(GitHub: JayHeng/pzh-mcu-bi-weekly),歡迎提交 issue,投稿或推薦你知道的嵌入式那些事兒。 上期回 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...