硬件介绍:ESP32-S3-BOX-Lite 是乐鑫发布的新一代 AIoT 开发平台,ESP32-S3-BOX-Lite 开发套件配备了一块 2.4 寸 LCD 显示屏、双麦克风、一个扬声器、两个用于硬件拓展的 Pmod™ 兼容接口和3个独立按键,可构建多样的 HMI 人机交互应用。开发板可实现离线语音唤醒和命令词识别,支持乐鑫自研的高性能声学前端算法构建语音交互系统。开发者可利用开源的 SDK轻松构建在线离线语音助手、智能语音设备、HMI 人机交互设备、多协议网关等多样的应用。这个ESP32-S3板卡,最显著的特点就是能够支持离线的语音识别、语音合成。使用官方的例程测试,离线语音识别效果非常不错。
任务选择:拿到板子后,很开心的了解到ESP32-S3支持esp-idf、arduino、micropython编程。从官方提供的机构简图可以看见,这个板子上的传感器并不多,但是对语音支持特别好!有扬声器+双麦克风,板子的主打应该就是语音方面的AI。自己对三种编程语言分别尝试了一下,很可惜,板子太新了,语音识别和语音合成功能在arduino、micropython都没能尝试成功。所以这次项目只能选用最不熟悉的esp-idf了。项目选择就选择了任务一:使用ESP32的WiFi和TTS功能,实现一个语音播报系统,如联网获取粉丝数并播报或者获取天气并播报。
任务实现:
定下了任务,需要使用到的外设有按键、扬声器、wifi、tts。接下来就是逐一搞定各种外设的驱动和使用。在micropython上尝试驱动I2S,但是没能成功,声音放弃了。在Arduino上语音转换tts部分只能通过联网处理,再加上扬声器驱动也有问题,所以最后只好选择esp-idf来进行开发了。
首先是esp-idf的安装,这块网络上的详细步骤挺多的,按照网络上的步骤,一步步地操作,就能完成。这里我使用的是win10+vscode+esp-idf4.4.4。
不得不说乐鑫官方提供的esp-idf功能超级强大,例程也是相当的丰富。不过,也正因为esp-idf的功能强大,感觉很难掌握!针对ESP32-S3-BOX-Lite,官方提供了例程。这里因为自己搭建的esp-idf环境是4.4的所以需要切换版本进行下载。
git clone -b v0.3.0 --recursive https://github.com/espressif/esp-box.git
官方的例程提供了wifi联网、TFT屏幕的Lvgl的显示、语音命令的识别、音频的播放等功能。经过几天艰难的努力,终于成功编译完成了官方例程。所以这时的思路,就是使用官方的例程,消减其功能模块,以实现自己项目的需求。但是尝试解读esp-box的代码,发现自己基本无法读懂(*>﹏<*)。努力了几次,各种报错,实在是不熟悉,不知道如何解决,只能放弃了。
然后在乐鑫的官网又找到一个tts的例程,拉下来编译到时很顺利,可是烧录到开发板后,板子不工作。经过仔细查看,发现这个例程只是支持了ESP32-S3-BOX。而ESP32-S3-BOX的扬声器驱动部分和ESP32-S3-BOX-Lite的驱动部分并不一样,导致这个例程在Lite上并不适用。经过群里的老师帮助,修改了esp-skainet的例程,使其能够驱动起ES856的扬声器。有了这个能正常使用的例程,就改变完成项目的思路,选择esp-skainet中的chinese_tts例子,在例子中添加对应的功能,来完成项目。
首先添加wifi功能,想获取天气预报、B站粉丝数量,都需要互联网功能。这里套用了esp-idf例程中的http_request的联网和获取网页的例子。来连接wifi,wifi的sid和密码在sdkconfig中进行配置即可。
static void event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START)
{
esp_wifi_connect();
}
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
{
esp_wifi_connect();
}
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
{
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip));
obtain_time();
// parse_weather_json(xinzhi_task(parse_city_json(city_task())));
// xinzhi_task();
}
}
/* Initialize Wi-Fi as sta and set scan method */
static void fast_scan(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, NULL));
// Initialize default station as network interface instance (esp-netif)
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
assert(sta_netif);
// Initialize and start WiFi
wifi_config_t wifi_config = {
.sta = {
.ssid = CONFIG_EXAMPLE_WIFI_SSID,
.password = CONFIG_EXAMPLE_WIFI_PASSWORD,
// .scan_method = DEFAULT_SCAN_METHOD,
// .sort_method = DEFAULT_SORT_METHOD,
.threshold.rssi = CONFIG_EXAMPLE_WIFI_SCAN_RSSI_THRESHOLD,
// .threshold.authmode = DEFAULT_AUTHMODE,
},
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
}
上电后通过wifi联网,联网成功后就自动去和互联网校正时间,并将获得的时间写到esp32s3里去。之后需要使用时间,就可以直接从esp32s3里获取了。
static void initialize_sntp(void)
{
ESP_LOGI(TAG, "Initializing SNTP");
sntp_setoperatingmode(SNTP_OPMODE_POLL);
sntp_setservername(0, "pool.ntp.org");
// sntp_set_time_sync_notification_cb(time_sync_notification_cb);
sntp_init();
}
static void obtain_time(void)
{
/**
* NTP server address could be aquired via DHCP,
* see LWIP_DHCP_GET_NTP_SRV menuconfig option
*/
#ifdef LWIP_DHCP_GET_NTP_SRV
sntp_servermode_dhcp(1);
#endif
initialize_sntp();
// wait for time to be set
time_t now = 0;
struct tm timeinfo = {0};
int retry = 0;
const int retry_count = 10;
while (sntp_get_sync_status() == SNTP_SYNC_STATUS_RESET && ++retry < retry_count)
{
ESP_LOGI(TAG, "Waiting for system time to be set... (%d/%d)", retry, retry_count);
vTaskDelay(2000 / portTICK_PERIOD_MS);
}
time(&now);
char strftime_buf[64];
// Set timezone to Eastern Standard Time and print local time
setenv("TZ", "EST5EDT,M3.2.0/2,M11.1.0", 1);
tzset();
localtime_r(&now, &timeinfo);
strftime(strftime_buf, sizeof(strftime_buf), "%c", &timeinfo);
ESP_LOGI(TAG, "The current date/time in New York is: %s", strftime_buf);
// Set timezone to China Standard Time
setenv("TZ", "CST-8", 1);
tzset();
localtime_r(&now, &timeinfo);
strftime(strftime_buf, sizeof(strftime_buf), "%c", &timeinfo);
ESP_LOGI(TAG, "The current date/time in Shanghai is: %s", strftime_buf);
}
还需要获取天气预报和B站粉丝数量。天气预报使用心知天气,获取当天和明天两天的天气情况。通过get方法访问web页面,获得的内容是一串json字符串。然后对json字符串进行解析。
esp_err_t _http_event_handler(esp_http_client_event_t *evt)
{
switch (evt->event_id)
{
case HTTP_EVENT_ERROR:
ESP_LOGD(TAG, "HTTP_EVENT_ERROR");
break;
case HTTP_EVENT_ON_CONNECTED:
ESP_LOGD(TAG, "HTTP_EVENT_ON_CONNECTED");
break;
case HTTP_EVENT_HEADER_SENT:
ESP_LOGD(TAG, "HTTP_EVENT_HEADER_SENT");
break;
case HTTP_EVENT_ON_HEADER:
ESP_LOGD(TAG, "HTTP_EVENT_ON_HEADER, key=%s, value=%s", evt->header_key, evt->header_value);
break;
case HTTP_EVENT_ON_DATA:
ESP_LOGD(TAG, "HTTP_EVENT_ON_DATA, len=%d", evt->data_len);
break;
case HTTP_EVENT_ON_FINISH:
ESP_LOGD(TAG, "HTTP_EVENT_ON_FINISH");
break;
case HTTP_EVENT_DISCONNECTED:
ESP_LOGI(TAG, "HTTP_EVENT_DISCONNECTED");
break;
}
return ESP_OK;
}
// 获取心知天气预报 返回json字符串
char *webget_task(char *weburl)
{
int8_t return_res = 1;
char *weather_buffer = NULL;
int content_length = 0;
esp_http_client_config_t config =
{
.event_handler = _http_event_handler,
.url = weburl,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
if (client == NULL)
{
return NULL;
}
// GET Request
esp_http_client_set_method(client, HTTP_METHOD_GET);
esp_err_t err = esp_http_client_open(client, 0);
if (return_res && err != ESP_OK)
{
ESP_LOGE(TAG, "Failed to open HTTP connection: %s", esp_err_to_name(err));
return_res = 0;
}
else
{
content_length = esp_http_client_fetch_headers(client);
if (content_length < 0)
{
ESP_LOGE(TAG, "HTTP client fetch headers failed");
return_res = 0;
}
else
{
weather_buffer = malloc(content_length + 1);
memset(weather_buffer, 0, content_length + 1);
if (weather_buffer == NULL)
{
return_res = 0;
}
else
{
int data_read = esp_http_client_read_response(client, weather_buffer, content_length);
if (data_read >= 0)
{
ESP_LOGI(TAG, "HTTP GET Status = %d, content_length = %d, data_read = %d",
esp_http_client_get_status_code(client),
esp_http_client_get_content_length(client),
data_read);
// ESP_LOG_BUFFER_HEX(TAG, weather_buffer, data_read);
ESP_LOGI(TAG, "Data %s \r\n", weather_buffer);
}
else
{
ESP_LOGE(TAG, "Failed to read response");
return_res = 0;
}
}
}
}
esp_http_client_close(client);
if (!return_res)
{
free(weather_buffer);
weather_buffer = NULL;
}
return weather_buffer;
}
//将天气预报的信息,构建需要播报的语音字符串
static bool parse_weather_json(char *analysis_buf, char *tts_str)
{
if (analysis_buf == NULL)
return false;
cJSON *json_root = cJSON_Parse(analysis_buf);
if (json_root != NULL)
{
cJSON *cjson_arr = cJSON_GetObjectItem(json_root, "results");
cJSON *cjson_location = cJSON_GetObjectItem(cJSON_GetArrayItem(cjson_arr, 0), "location");
ESP_LOGI(TAG, "城市 -> %s", cJSON_GetObjectItem(cjson_location, "name")->valuestring);
sprintf(tts_str, "%s%s", tts_str, cJSON_GetObjectItem(cjson_location, "name")->valuestring);
cJSON *cjson_days = cJSON_GetObjectItem(cJSON_GetArrayItem(cjson_arr, 0), "daily");
cJSON *cjson_today = cJSON_GetArrayItem(cjson_days, 0);
ESP_LOGI(TAG, "今天天气 -> %s", cJSON_GetObjectItem(cjson_today, "text_day")->valuestring);
ESP_LOGI(TAG, "最高温度 -> %s", cJSON_GetObjectItem(cjson_today, "high")->valuestring);
ESP_LOGI(TAG, "最低温度 -> %s", cJSON_GetObjectItem(cjson_today, "low")->valuestring);
sprintf(tts_str, "%s今天天气%s;温度%s到%s摄氏度;湿度:百分之%s。", tts_str,
cJSON_GetObjectItem(cjson_today, "text_day")->valuestring,
cJSON_GetObjectItem(cjson_today, "low")->valuestring,
cJSON_GetObjectItem(cjson_today, "high")->valuestring,
cJSON_GetObjectItem(cjson_today, "humidity")->valuestring);
cJSON *cjson_tomorrow = cJSON_GetArrayItem(cjson_days, 0);
ESP_LOGI(TAG, "明天天气 -> %s", cJSON_GetObjectItem(cjson_tomorrow, "text_day")->valuestring);
ESP_LOGI(TAG, "最高温度 -> %s", cJSON_GetObjectItem(cjson_tomorrow, "high")->valuestring);
ESP_LOGI(TAG, "最低温度 -> %s", cJSON_GetObjectItem(cjson_tomorrow, "low")->valuestring);
sprintf(tts_str, "%s明天天气%s;温度%s到%s摄氏度;湿度:百分之%s。", tts_str,
cJSON_GetObjectItem(cjson_today, "text_day")->valuestring,
cJSON_GetObjectItem(cjson_today, "low")->valuestring,
cJSON_GetObjectItem(cjson_today, "high")->valuestring,
cJSON_GetObjectItem(cjson_today, "humidity")->valuestring);
ESP_LOGI(TAG, "%s", tts_str);
cJSON_Delete(json_root);
}
free(analysis_buf);
return true;
}
//将bilibili的信息,构建需要播报的语音字符串 {"code":0,"message":"0","ttl":1,"data":{"mid":596836443,"following":53,"whisper":0,"black":0,"follower":28}}
static bool parse_bilibili_json(char *analysis_buf, char *tts_str)
{
if (analysis_buf == NULL)
return false;
cJSON *json_root = cJSON_Parse(analysis_buf);
if (json_root != NULL)
{
cJSON *cjson_data = cJSON_GetObjectItem(json_root, "data");
ESP_LOGI(TAG, "粉丝数 -> %d", cJSON_GetObjectItem(cjson_data, "follower")->valueint);
sprintf(tts_str, "%s当前粉丝数量%d位。", tts_str, cJSON_GetObjectItem(cjson_data, "follower")->valueint);
ESP_LOGI(TAG, "%s", tts_str);
cJSON_Delete(json_root);
}
free(analysis_buf);
return true;
}
//获得当前时间,并形成字符串
static void parse_currtime(char *tts_str)
{
time_t now_t;
struct tm *area;
setenv("TZ", "CST-8", 1);
tzset();
now_t = time(NULL);
area = localtime(&now_t);
// printf("Local time is: %s", asctime(area));
strftime(tts_str, 100, "现在时间是%H点%M分。", area);
ESP_LOGI(TAG, "%s", tts_str);
// printf("The number of seconds since January 1, 1970 is %ld\r\n", now_t);
}
这里有两个问题需要格外注意。问题1:在添加wifi模块后,main函数引入了wifi相关的头文件,然后编译就总是报错,说找不到对应的头文件,这时需要修改main函数所在文件同级目录下的CMakeLists.txt文件,在编译文件时添加上对应的模块。
set(srcs
main.c
)
set(include_dirs
include
)
set(requires
esp-sr
hardware_driver
sr_ringbuf
player
)
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS ${include_dirs} "."
REQUIRES ${requires}
PRIV_REQUIRES nvs_flash esp_http_client esp_adc_cal )
add_definitions(-w)
问题2:这里获取心知天气和B站信息,都是走的https协议,这个协议需要证书,esp-idf提供的默认证书,貌似对心知天气网站可以用,但是B站就会报错。解决方案有两个,一个是提供相应的证书。一个是忽略证书。我这里使用后一个方法,忽略证书。
获得了需要的字符串(天气、时间、B站粉丝数),剩下的就是交给esp-idf提供的tts方法来生成语音了,生成语音后,交给扬声器进行播报!
tts_flag=false;
if (esp_tts_parse_chinese(tts_handle, tts_str)) // 文字解析成拼音
{
int len[1] = {0};
do
{
short *pcm_data = esp_tts_stream_play(tts_handle, len, 2); // 拼音转换成pcm音频
esp_audio_play(pcm_data, len[0] * 2, portMAX_DELAY); //播放音频
} while (len[0] > 0);
}
esp_tts_stream_reset(tts_handle); // 重置 tts 流并清除 TTS 实例的所有缓存
再添加一个键盘选择,通过三个按键选择是播放天气预报、时间或B站粉丝数量。这里的按键是使用ADC方式获取键盘的输入的,所以通过获得的AD值,来判断输入。
// ADC初始化
u8_t key = 4; //按键值 0 右键 2 中间键 3 左键 4 无按键
static esp_adc_cal_characteristics_t adc1_chars;
#define ADC1_EXAMPLE_CHAN0 ADC1_CHANNEL_0 //使用GPOI1作为AD按键的输入
static bool adc_calibration_init(void)
{
esp_err_t ret;
bool cali_enable = false;
ret = esp_adc_cal_check_efuse(ESP_ADC_CAL_VAL_EFUSE_TP_FIT);
if (ret == ESP_ERR_NOT_SUPPORTED)
{
ESP_LOGW(TAG, "Calibration scheme not supported, skip software calibration");
}
else if (ret == ESP_ERR_INVALID_VERSION)
{
ESP_LOGW(TAG, "eFuse not burnt, skip software calibration");
}
else if (ret == ESP_OK)
{
cali_enable = true;
esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_DEFAULT, 0, &adc1_chars);
}
else
{
ESP_LOGE(TAG, "Invalid arg");
}
return cali_enable;
}
void listen_key_task(void *pvPar)
{
adc_calibration_init(); //初始化ADC
// ADC1 config
ESP_ERROR_CHECK(adc1_config_width(ADC_WIDTH_BIT_DEFAULT));
ESP_ERROR_CHECK(adc1_config_channel_atten(ADC1_EXAMPLE_CHAN0, ADC_ATTEN_DB_11));
while (1)
{
u16_t adcval = adc1_get_raw(ADC1_EXAMPLE_CHAN0) / 1000;
if (adcval != 4 && adcval != key)
{
ESP_LOGI(TAG, "Adc value key:%d", adcval);
key = adcval;
}
// printf("key %d\r\n", );
//使用此延时API可以将任务转入阻塞态,期间CPU继续运行其它任务
vTaskDelay(80 / portTICK_PERIOD_MS);
}
}
最后就是屏幕了,想添加个lvgl的功能,尝试从esp-box的例程中剥离出lvgl部分,尝试剥离了几次,都失败了,感觉例程中模块耦合度有些高,各种报错,实在无力解决。最终放弃了屏幕显示的功能。
心得体会:感谢funpack带来的这期活动。接触到了如此优秀的板卡,如此小巧的板子上,能实现离线的语音命令字的识别,语音合成功能。esp-idf功能也是超级强大,就是过于复杂,感觉上手困难,期待更多的学习机会,能够掌握esp-idf的强大功能。
代码大小超出网站限制:链接:https://pan.baidu.com/s/1ytPenMRSA3bJKbEj-c4KQA 提取码:8888