作者 | 哈猪猪
前言
在文章2中,我们简单分析了 HTTPClient 组件的源码,发现其并不能实现“multipart/form-data”类型内容的 POST 请求。因此在本文中,我们将手动实现这一功能,并且最终完成整个云语音识别项目。以下为全部开发流程:
项目源码:https://github.com/ha-zhuzhu/ch2601_speech_recognizer
实现效果:请见演示视频
RVB2601
首先,我们结合之前所作的工作,重新梳理云语音识别项目的目标与思路。
目标:按下板载按钮,RVB2601 开始录音,上传云端语音识别后返回识别结果至串口。
思路:实现的程序流程与所需要的关键组件如下:
- gpio_pin:设置按键中断。按下按钮后设置“开始录音标志位”为1。
- rhino:注册监控任务。监控“开始录音标志位”,若为1则:
- codec:进行录音。
- HTTPClient :上传录音数据,接收并返回识别结果。
- 清空标志位
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 不够用了……
服务器
服务器端搭建在我的个人网站上,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 的云语音识别应用。
总结
首先我想谈谈使用 RVB2601 的开发体验。正如前文所述,RVB2601 板载资源丰富,能够实现大部分物联网场景应用。并且剑池 CDK 也和我们熟悉的其它单片机开发 IDE 类似,很容易就能上手。RVB2601 的几个示例程序也能让我们很快熟悉板子的开发步骤。RVB2601 有 RST 键,下载后不需要反复插拔。工作人员也非常热心认真,在我发现组件网络资源有点问题的时候几分钟就回应并修复了。
最重要的是,YOC 功能非常强大。还有太多的 API 与组件我还没有尝试,但我相信如果能熟练运用,我们很容易就能基于 YOC 支持芯片上完成一个完整并强大的物联网系统。
然而囿于时间和技术水平,基于目前有限的示例程序、源码注释和文档上,我没能尝试更多有趣的组件和功能,希望以后有时间更深入研究。
最后再次感谢平头哥与电子发烧友平台提供的这次测评与学习的机会。由于技术水平有限,本项目还有许多值得改进的地方,包括代码规范、通用性与识别效率等,希望能和大家共同交流学习!也祝主办方的技术生态越来越好!
本文源自:平头哥芯片开放社区
欢迎关注公众号:芯片开放社区(ID:OCC_THEAD),查看更多应用实战文章。