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