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