前言
本项目旨在实现基于 T-HEAD RVB2601 的云语音识别,主要工作在于拓展官方网络库 HTTPClient,将音频数据上传至 HTTP 服务器。
目标:按下板载按钮,RVB2601 开始录音,上传云端语音识别后返回识别结果至串口。
思路:实现的程序流程与所需要的关键组件如下:
- gpio_pin:设置按键中断。按下按钮后设置“开始录音标志位”为 1。
- rhino:注册监控任务。监控“开始录音标志位”,若为 1 则:
- codec:进行录音。
- HTTPClient :上传录音数据,接收并返回识别结果。
- 清空标志位。
下文为完整的项目开发流程,主要分为三部分:
- 设计思路,HTTPClient 组件测试。
- HTTPClient 组件源码解析。
- 项目的完整实现,包括:手动实现“multipart/form-data”类型 POST 请求;完成板端与服务器端开发。
设计思路
在官方提供的 RVB2601 示例程序中:ch2601_ft_demo 实现了麦克风录音并由扬声器回放的功能;ch2601_webplayer_demo 实现了从网络上下载音乐并播放的功能。因此我们有理由相信借助于现有的组件就能够实现一个基于 Http 的云语音识别。
然而,ch2601_webplayer_demo 中使用的网络库并不提供完整的 HTTP 功能,因此我们需要寻找支持 RVB2601 的其它网络库。
通过上网搜寻,找到了 平头哥YOC文档,里面列举了众多 API 以及模块组件;以及对应的 YOC github 源码。
其中,YOC 提供一个网络组件:HTTPClient,它“为http/https客户端组件,为用户提供一组简洁的调用接口。”并且看其接口列表它好像还实现了 HTTP 的各种请求(年轻了)。
因此我们选择使用 HTTPClient 组件来帮助我们与服务器通信,上传录音文件并接收识别结果。
HTTPClient 组件测试
由于这是一个没有出现在 RVB2601 示例程序中的组件,我们还不清楚能否使用以及如何使用。所幸源码中提供了 HTTPClient 的测试程序:http_examples。因此我们可以先尝试测试该组件。
项目工程建立在 ch2601_webplayer_demo 上。以下为操作流程:
项目配置
首先从 YOC github 源码 下载所需组件,除了 HTTPClient 外还需要 transport 组件。
进入 CDK 工程,右键 Packages 并新建一个普通包;设置包名以及存储路径;接着向刚刚新建的 Package 里添加下载的源码;最后对 Package 进行配置:在 Compiler 选项卡中设置 Include,以及在 Base 选项卡中编辑 Description。
源码修改
我们直接将 http_examples.c 的内容加入到 player_demo.c 中,并向 CLI 控制台注册一个新命令 http_test:
/* player_demo.c */
static void cmd_http_func(char *wbuf, int wbuf_len, int argc, char **argv)
{
if (argc == 1 && strcmp(argv[0], "http_test") == 0) {
test_https();
}
else
printf("\thttp_test\n");
}
int cli_reg_cmd_player(void)
{
...
static const struct cli_command http_cmd_info= {
"http_test",
"http_test",
cmd_http_func,
};
aos_cli_register_command(&cmd_info);
...
return 0;
}
这样,当控制台接收到 http_test 命令后就会直接调用 http_examples.c 中的测试函数 test_https()。
测试后没有什么问题,因此我们能够在 RVB2601 上调用 HTTPClient 提供的接口,来帮助我们与服务器通信。接下来我们需要理解该组件提供的功能,并实现文件对服务器的上传。
源码解析
首先,根据 HTTPClient 组件的示例程序 http_examples.c,我们得知了一个完整的 HTTP 请求需要调用的接口流程,大致如下。以 POST 请求为例,并直接设置 url 而非 host&path:
http_client_config_t config = {
.url = "http://httpbin.org/get",
.event_handler = _http_event_handler,
};
http_client_handle_t client = http_client_init(&config);
const char *post_data = "field1=value1&field2=value2";
http_client_set_method(client, HTTP_METHOD_POST);
http_client_set_post_field(client, post_data, strlen(post_data));
err = http_client_perform(client);
if (err == HTTP_CLI_OK) {
LOGI(TAG, "HTTP POST Status = %d, content_length = %d \r\n",
http_client_get_status_code(client),
http_client_get_content_length(client));
} else {
LOGE(TAG, "HTTP POST request failed: 0x%x @#@@@@@@", (err));
e_count ++;
}
整个流程主要涉及到以下几个函数:
http_client_init() 主要是基于 config 对 client 结构的各种变量赋初值,包括在请求中会用的到的各种缓存空间,以及 header 中的基本信息。其中 config 未设置的会赋缺省值。也可以用库提供的各种 set_xxx 函数来对变量赋值。
http_client_set_post_field():对于有请求体的 POST,我们需要调用这个函数将发送数据的指针以及长度赋予 client:
web_err_t http_client_set_post_field(http_client_handle_t client, const char *data, int len)
{
web_err_t err = WEB_OK;
client->post_data = (char *)data; // 待发送数据指针
client->post_len = len; // 待发送数据长度
LOGD(TAG, "set post file length = %d", len);
// 如果此前没有设置 Content-Type,则设置为 application/x-www-form-urlencoded
if (client->post_data)
{
char *value = NULL;
if ((err = http_client_get_header(client, "Content-Type", &value)) != WEB_OK)
{
return err;
}
if (value == NULL)
{
err = http_client_set_header(client, "Content-Type", "application/x-www-form-urlencoded");
}
}
else
{
client->post_len = 0;
err = http_client_set_header(client, "Content-Type", NULL);
}
return err;
}
阅读源码可见,库默认支持“Content-Type”为“application/x-www-form-urlencoded”的 POST 请求,而我们要上传音频文件是“multipart/form-data”类型的。然而浏览了整个 HTTPClient 组件发现它就支持这一种类型……POST 的具体实现也只支持这一种。我们接着往下看就知道了……
http_client_perform():无论是什么 HTTP 请求,在设置完 client 之后最后都是经由这一个函数来具体实现。该函数将根据当前所处的 HTTP 请求流程,调用对应的函数,而这些函数最终会调用 transport 组件即在传输层上读写,最终实现整个 HTTP 请求。http_client_perform() 依次主要调用了以下几个函数:
http_client_prepare();
http_client_connect();
http_client_request_send();
http_client_send_post_data();
http_client_fetch_headers();
http_check_response();
http_client_get_data();
http_client_close();
实现的功能与其函数名一致,也是我们熟知的 HTTP 请求流程。其中我们最关心的当然是http_client_send_post_data():
static web_err_t http_client_send_post_data(http_client_handle_t client)
{
// 此时请求头应发送完毕
if (client->state != HTTP_STATE_REQ_COMPLETE_HEADER)
{
LOGE(TAG, "Invalid state");
return WEB_ERR_INVALID_STATE;
}
// 没有要发送的 post_data 直接返回(包括其它请求)
if (!(client->post_data && client->post_len))
{
goto success;
}
// 发送 post_data
int wret = http_client_write(client, client->post_data + client->data_written_index, client->data_write_left);
if (wret < 0)
{
return wret;
}
// 在 request send 里面 data_write_left 最后被赋值为 post_len
client->data_write_left -= wret;
// 在 request send 里面 data_written_index 最后被赋值为 0
client->data_written_index += wret;
// 发送完毕
if (client->data_write_left <= 0)
{
goto success;
}
else
{
return ERR_HTTP_WRITE_DATA;
}
success:
// 更新状态
client->state = HTTP_STATE_REQ_COMPLETE_DATA;
return WEB_OK;
}
可见,整个 POST 实现只会发送一次 post_data,它指向我们最初在 http_client_set_post_field() 中填入的待发送数据。然而,我们知道要发送“multipart/form-data”类型的数据,需要按照格式用 boundary 对请求体进行封装,并在请求头中提前告知 boundary。当然,HTTPClient 组件是没有实现这些的……因此如果我们要利用 HTTPClient 发送音频数据,就必须手动实现“multipart/form-data”类型请求体的封装与发送。
当我终于大致理解了 HTTPClient 组件后,我在浏览 YOC 源码时发现了一个就叫“http”的组件,它并没有出现在 YOC 文档里,然而它已经实现了“multipart/form-data”类型的 POST……囿于我有限的技术水平,源码缺少注释,感觉学习成本过高,遂放弃。
事实上针对本项目要实现的云语音识别,YOC 还有很多更适合的组件:麦克风服务,云音交互(AUI Cloud)……甚至后者本身就是为语音识别等应用构建的。然而由于源码没有注释,只有接口介绍文档,以及其它板子上的相关例程,感觉“移植”组件难度过大了……最终都没有使用,选择了基于 HTTPClient 手动实现音频数据的发送。
RVB2601 开发
HTTPClient 发送录音数据
首先从最复杂的,手动发送音频数据开始。
借鉴另一个组件 http 的思路,先针对我们的应用场景设置好 boundary 和请求体的头部、尾部:
// 请求体格式
static const char *boundary = "----WebKitFormBoundarypNjgoVtFRlzPquKE";
#define MY_FORMAT_START "------WebKitFormBoundarypNjgoVtFRlzPquKE\r\nContent-Disposition: %s; name=\"%s\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n"
#define MY_FORMAT_END "\r\n------WebKitFormBoundarypNjgoVtFRlzPquKE--\r\n"
由于我们的音频数据较大,且一次流程只发送一个文件,因此发送 POST 请求体时可以分三部分发送:头部、音频数据、尾部。所以在进行音频数据传输时,相比于原来的流程,我们要先设置“Content-Type”,接着基于模板制备请求体头部尾部,设置 Content-Length,并将 POST 相关变量统一存放在一个结构体中,方便进行 POST 请求时调用。以下是音频发送函数:
static void post_audio()
{
http_client_config_t config = {
.url = "http://upload.hazhuzhu.com/myasr.php",
.method = HTTP_METHOD_POST,
.event_handler = _http_event_handler,
};
http_client_handle_t client = http_client_init(&config);
// 设置 Content-Type
http_client_set_header(client, "Content-Type", "multipart/form-data; boundary=----WebKitFormBoundarypNjgoVtFRlzPquKE");
const unsigned char *post_content = repeater_data_addr; // POST 请求体的内容为录音数据
// 请求体格式相关变量
const char *content_disposition = "form-data";
const char *name = "file";
const char *filename = "raw_recording";
const char *content_type = "application/octet-stream";
int boundary_len = strlen(boundary);
// 制备请求体头部
int post_start_len = strlen(MY_FORMAT_START) - 8 + strlen(content_disposition) + strlen(name) + strlen(filename) + strlen(content_type) + 1;
char *post_start = (char *)malloc(post_start_len + 1);
memset(post_start, 0, sizeof(post_start_len));
snprintf(post_start, post_start_len, MY_FORMAT_START, content_disposition, name, filename, content_type);
const char *post_end = MY_FORMAT_END;
// 设置 Content-Length
http_client_set_post_len(client, strlen(post_start) + PCM_LEN + strlen(post_end));
// 设置请求体相关变量
my_post_data_t post_data = {
.start = post_start,
.end = post_end,
.content = post_content,
.start_len = strlen(post_start),
.end_len = strlen(post_end),
.content_len = PCM_LEN,
};
// 进行一次 POST 请求
http_errors_t err = http_client_myperform(client, &post_data);
if (err == HTTP_CLI_OK)
{
LOGI(TAG, "HTTP POST Status = %d, content_length = %d \r\n",
http_client_get_status_code(client),
http_client_get_content_length(client));
char *raw_data_p;
result_len = http_client_get_response_raw_data(client, &raw_data_p);
// 输出 response
if (result_len)
{
char *result = (char *)malloc(result_len + 1);
memcpy(result, raw_data_p, result_len);
memcpy(result + result_len, "\0", 1);
printf("%d\r\n", result_len);
printf("%s\r\n", result);
result_len = 0;
free(result);
}
}
else
{
LOGE(TAG, "HTTP POST request failed: 0x%x @#@@@@@@", (err));
e_count++;
}
http_client_cleanup(client);
}
其中,http_client_myperform() 是将 http_client_perform() 中的 http_client_send_post_data() 替换为 http_client_mysend_post_data():
/* http_client.c */
#define MY_BODY_SIZE 1000
static web_err_t http_client_mysend_post_data(http_client_handle_t client, my_post_data_t *post_data)
{
// 此时请求头应发送完毕
if (client->state != HTTP_STATE_REQ_COMPLETE_HEADER)
{
LOGE(TAG, "Invalid state");
return WEB_ERR_INVALID_STATE;
}
// 没有要发送的 post_data 直接返回(包括其它请求)
if (!(post_data->content && client->post_len))
{
goto success;
}
// 发送请求体头部
int wret = http_client_write(client, post_data->start, post_data->start_len);
if (wret < 0)
{
return wret;
}
client->data_write_left -= wret;
// 发送音频数据,可能发送多次
int content_idx = 0;
for (int i = 0; i < post_data->content_len / MY_BODY_SIZE; ++i)
{
wret = http_client_write(client, post_data->content + content_idx, MY_BODY_SIZE);
if (wret < 0)
{
return wret;
}
client->data_write_left -= wret;
content_idx += MY_BODY_SIZE;
}
wret = http_client_write(client, post_data->content + content_idx, post_data->content_len - content_idx);
if (wret < 0)
{
return wret;
}
client->data_write_left -= wret;
// 发送请求体尾部
wret = http_client_write(client, post_data->end, post_data->end_len);
if (wret < 0)
{
return wret;
}
client->data_write_left -= wret;
// 发送完毕
if (client->data_write_left <= 0)
{
goto success;
}
else
{
return ERR_HTTP_WRITE_DATA;
}
success:
// 更新状态
client->state = HTTP_STATE_REQ_COMPLETE_DATA;
return WEB_OK;
}
相比于原来只发送一次数据,现在分三部分发送。并且,由于网络库提供的 http_client_write() 函数没有考虑 TCP 数据包大小限制,我们手动将音频数据每次 MY_BODY_SIZE 大小,多次发送:
http_client_write() 调用流程:
- transport_write()
- _write()
- select()
整个流程都没有考虑数据包大小,若一次发送数据过大会阻塞,因此需要手动分包。
http_client_myperform() 的其它流程都不需要修改,接收到 response 后库函数会自动解析。阅读源码后发现最后会将服务器端发回的原始数据放在 client->response->buffer->raw_data 里。我们写一个函数提取出来:
/* http_client.c */
int http_client_get_response_raw_data(http_client_handle_t client, char **raw_data)
{
int raw_len = client->response->buffer->raw_len;
if (raw_len)
{
*raw_data = client->response->buffer->raw_data;
return raw_len;
}
return 0;
}
在 post_audio() 的最后我们会调用这个函数,并将识别结果打印出来。
codec 录制音频
录音部分仿照 ch2601_ft_demo 写。由于 RVB2601 存储资源有限,我们只开辟 49152 字节的录音缓存(不能初始化赋值,否则 flash 装不下)。设置采样率 8000Hz,位深 16bit(更清晰方便识别)。这样大概能录制 1.536s,对于我们演示一些简短的口令来说也够用了。
事实上我们后面要调用的语音识别 api 要求音频文件是单声道的,我们后面还需要进行转换。所以这里其实存在一定的存储空间浪费,但我没有在库里找到只录制单声道音频的方法……默认就是双声道的……
/* 录音 */
static void cmd_mic_handler()
{
csi_error_t ret;
csi_codec_input_config_t input_config;
ret = csi_codec_init(&codec, 0);
if (ret != CSI_OK)
{
printf("csi_codec_init error\n");
return;
}
codec_input_ch.ring_buf = &input_ring_buffer;
csi_codec_input_open(&codec, &codec_input_ch, 0);
/* input ch config */
csi_codec_input_attach_callback(&codec_input_ch, codec_input_event_cb_fun, NULL);
input_config.bit_width = 16;
input_config.sample_rate = 8000;
input_config.buffer = input_buf;
input_config.buffer_size = INPUT_BUF_SIZE;
input_config.period = 1024;
input_config.mode = CODEC_INPUT_DIFFERENCE;
csi_codec_input_config(&codec_input_ch, &input_config);
csi_codec_input_analog_gain(&codec_input_ch, 0xbf);
csi_codec_input_link_dma(&codec_input_ch, &dma_ch_input_handle);
printf("start recoder\n");
csi_codec_input_start(&codec_input_ch);
// 麦克风录音写入数据 48x1024=49152
while (new_data_flag < 48)
{
if (cb_input_transfer_flag)
{
csi_codec_input_read_async(&codec_input_ch, repeater_data_addr + (new_data_flag * 1024), 1024);
cb_input_transfer_flag = 0U; // 回调函数将其置 1
new_data_flag++;
}
}
new_data_flag = 0;
printf("stop recoder\n");
csi_codec_input_stop(&codec_input_ch);
csi_codec_input_link_dma(&codec_input_ch, NULL);
csi_codec_input_detach_callback(&codec_input_ch);
csi_codec_uninit(&codec);
return;
}
按键中断
初始化时设置按键中断,并在回调函数里设置标志位:
static void gpio_pin_callback(csi_gpio_pin_t *pin, void *arg)
{
start_to_record = 1; // 标志位置 1 通知 mic 任务开始录音并上传
}
/* Key1 初始化 */
int btn_init()
{
// key 1
memset(&g_handle, 0, sizeof(g_handle));
csi_pin_set_mux(PA11, PIN_FUNC_GPIO);
csi_gpio_pin_init(&g_handle, PA11);
csi_gpio_pin_dir(&g_handle, GPIO_DIRECTION_INPUT);
csi_gpio_pin_mode(&g_handle, GPIO_MODE_PULLUP);
csi_gpio_pin_debounce(&g_handle, true);
csi_gpio_pin_attach_callback(&g_handle, gpio_pin_callback, &g_handle);
csi_gpio_pin_irq_mode(&g_handle, GPIO_IRQ_MODE_FALLING_EDGE);
csi_gpio_pin_irq_enable(&g_handle, true);
return 0;
}
监控任务
编写监控任务并注册。我也写了 cli 命令,可以通过串口控制:
// 监控任务
static void mic_task(void *arg)
{
while (1)
{
// 按下按钮后录制并上传
if (start_to_record == 1)
{
cmd_mic_handler();
post_audio();
start_to_record = 0;
}
aos_msleep(100);
}
}
static void cmd_http_func(char *wbuf, int wbuf_len, int argc, char **argv)
{
if (argc == 2 && strcmp(argv[1], "post") == 0)
{
post_audio();
}
else
{
printf("\thttp post\n");
}
}
static void cmd_mic_func(char *wbuf, int wbuf_len, int argc, char **argv)
{
if (argc == 2 && strcmp(argv[1], "record") == 0)
{
cmd_mic_handler();
}
else
{
printf("\tmic record\n");
}
}
int cli_reg_cmd_asr(void)
{
char url[128];
// POST 录音
static const struct cli_command http_cmd_info = {
"http",
"http post",
cmd_http_func,
};
// 录音命令
static const struct cli_command mic_cmd_info = {
"mic",
"mic record",
cmd_mic_func,
};
// 注册命令
aos_cli_register_command(&http_cmd_info);
aos_cli_register_command(&mic_cmd_info);
// 新建麦克风任务
aos_task_new("mic", mic_task, NULL, 10 * 1024);
return 0;
}
修改链接文件
如果编译中遇到 SRAM overflowed 的问题,可以修改 configs/gcc_flash.ld:
- REGION_ALIAS("REGION_BSS", SRAM);
+ REGION_ALIAS("REGION_BSS", DSRAM);
将 BSS 段存到 DSRAM。ch2601_webplayer_demo 工程中的 linker file 和默认的不一样,不知道为什么……
这样,板端就基本开发完毕了。本来打算将识别结果显示在 oled 上的,然而 flash 不够用了……
服务器开发
服务器端就搭建在 hazhuzhu.com 上,nginx+PHP架构,使用腾讯云提供的语音识别 api。假设板端将请求提交到 http://asr.hazhuzhu.com/myasr.php
nginx
server
{
listen 80;
server_name asr.hazhuzhu.com;
client_max_body_size 128m;
root /home/wwwroot/asr;
index index.html index.htm index.php;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include fastcgi.conf;
fastcgi_pass unix:/tmp/php-cgi.sock;
fastcgi_keep_conn on;
}
}
PHP
后端负责存储音频文件并调用“一句话识别” api(需要使用腾讯云相关 SDK),最后返回识别结果。api 调用部分腾讯云提供了相关文档和代码生成工具,比较方便。
由于 RVB2601 codec 库存储的录音数据是 PCM 编码,2通道。而 api 只接收 wav/mp3 ,1通道的音频文件。而且,由于我们在 http_client_mysend_post_data() 中手动发送音频时没有考虑大小端的问题,所以服务器接收到我们 16bit 音频还是大端存储的。所以在调用 api 前,还需要修正大小端、通道数和文件格式。处于效率,直接调用 ffmepg 来实现……
<?php
// 调用腾讯云 SDK 省略
$uploads_dir = 'ch2601_recordings';
if ($_FILES['file']['error'] == UPLOAD_ERR_OK)
{
$tmp_name = $_FILES['file']['tmp_name'];
// $name = $_FILES['file']['name'];
$date_str=date('YmdHis');
move_uploaded_file($tmp_name, "$uploads_dir/$date_str".'-b2.pcm');
// 用 ffmpeg 转码,先转大小端并压缩为 1 通道,再转为 wav 格式
exec('ffmpeg -f s16be -ar 8000 -ac 2 -i '."$uploads_dir/$date_str".'-b2.pcm'.' -f s16le -ar 8000 -ac 1 '."$uploads_dir/$date_str".'-l1.pcm');
exec('ffmpeg -f s16le -ar 8000 -ac 1 -i '."$uploads_dir/$date_str".'-l1.pcm '."$uploads_dir/$date_str".'-l1.wav');
try {
// api 调用部分,省略……
$resp = $client->SentenceRecognition($req);
$result_str=$resp->getResult();
$result_file=fopen("$uploads_dir/$date_str".'.txt',"a");
fwrite($result_file,$result_str);
fclose($result_file);
echo $result_str;
}
catch(TencentCloudSDKException $e) {
echo $e;
}
}
至此我们完成了一个完整的基于 RVB2601 的云语音识别应用。
总结
YOC 功能很强大,针对物联网应用场景的各种组件都有,感觉如果能熟练运用可以开发很多项目。
然而感觉文档写的太简单了,源码也不写注释,或者东写一点西写一点。一个接口什么作用,什么参数源码里面不写,还得翻它的 readme 或者文档才知道。但看了也不一定明白要具体怎么使用,例程极少……就连文档和源码都是我自己不知道从哪里搜出来的……
总之开发地很心累,花了整整5天。像 STM32 的 LL 库虽然例程不如 HAL 库的多,但人家至少文档和源码注释是巴巴适适的……哎,痛苦。
感谢博主,按照这个文章,修改了esp32 的post 过程,完成了文件上传,非常感谢