6.语音板使用科大讯飞离线命令词识别
0x00 离线命令词识别简介
语音识别技术(Auto Speech Recognize,简称ASR),就是把人的自然语言音频数据转换成文本数据的技术。理论上在线ASR是可以把所有的语音转换成对应的文本,但是我们这里只是介绍离线ASR的应用,就是只在本地识别个数受限制的语音。这里的离线ASR识别就是离线的命令词识别,又可以叫语法识别,它是基于语法规则,可以将与语法一致的自然语言音频转换为文本输出的技术。语法识别的结果值域只在语法文件所列出的规则里,故有很好的匹配率。另外,语法识别结果携带了结果的置信度,应用可以根据置信分数,决定这个结果是否有效。语法识别多用于要更准确结果且有限说法的语音控制,如在家庭环境中空调、电视、电灯的控制。因为这些设备的只有几个确定的常用固定功能。所以做出离线的识别就非常适合,这里在使用离线语法识别时,需要先编写一个语法文件,然后通过调用QISRBuildGrammar接口编译本地语法文件,以及获得语法ID,并在会话时,传入语法ID,以使用该语法,在之后的会话中,继续使用此语法进行识别,无需再次构建。
0x01 下载离线命令词识别SDK
大家可以去讯飞开放平台上,登录自己账户后就可以下载对应的离线命令词SDK了,网址如下:
https://www.xfyun.cn/sdk/dispatcher
当下载好SDK后,我们就可以将其发送到树莓派板子上,大家可以使用如下的命令进行操作:
scp Linux_aitalk1226_5d5b***.zip corvin@192.168.*.*:~/
这里需要注意的是发送的文件名和树莓派板的IP地址,大家需要根据自己的情况来做相应修改即可,如下图所示:
0x02 编译离线命令词识别源码
在树莓派上我们首先需要将离线命令词识别的SDK压缩包解压,完整的解压命令如下:
unzip -q Linux_aitalk1226_5d5b9efd.zip -d xf_aitalk/
接下来就是替换离线命令词识别的动态库了,由于默认的SDK不提供树莓派版本的动态库。这里使用的动态库和第5篇文章中的离线唤醒词识别是一个动态库,如果你已经在上一篇中下载了该动态库,那么这里就不用再下载了。另外,这个动态库其实和后面文章要介绍的离线语音合成功能用的也是一样的。就是说下载这一个动态库,就可以在树莓派上实现:离线唤醒、离线命令词识别、离线语音合成这三个功能了。
当下载好该离线命令词识别库后,就可以将其放到树莓派中来使用了,这里下载后该库是可以永久使用的,没有时间限制:
接下来就可以来修改编译相关的脚本和makefile文件了,首先来修改一下bash脚本,修改过程如下:
接下来就是修改一下Makefile文件了,因为在make.sh中我们修改了编译时加载库的路径。主要就是修改13行的LDFLAGS,最终修改后的Makefile文件如下:
#common makefile header
DIR_INC = ../../include
DIR_BIN = ../../bin
DIR_LIB = ../../libs
TARGET = asr_offline_record_sample
BIN_TARGET = $(DIR_BIN)/$(TARGET)
CROSS_COMPILE =
CFLAGS = -g -Wall -I$(DIR_INC)
LDFLAGS := -L$(DIR_LIB)/
LDFLAGS += -lmsc -lrt -ldl -lpthread -lasound -lstdc++
OBJECTS := $(patsubst %.c,%.o,$(wildcard *.c))
$(BIN_TARGET) : $(OBJECTS)
$(CROSS_COMPILE)gcc $(CFLAGS) $^ -o $@ $(LDFLAGS)
%.o : %.c
$(CROSS_COMPILE)gcc -c $(CFLAGS) $< -o $@
clean:
@rm -f *.o $(BIN_TARGET)
.PHONY:clean
#common makefile foot
在修改好make.sh和Makefile文件后,我们就可以来开始编译程序了,编译过程也很简单,我们直接在源码目录下执行make.sh脚本就可以了:
0x03 运行测试程序
当我们执行完make.sh编译脚本后,就可以在bin目录下生成可执行文件asr_offline_record_sample。但是在执行之前,我们需要认识一下这个巴科斯范式格式的语法文件,因为这个语法文件就是我们后面要离线识别的命令词了。如下图所示:
这里的call.bnf就是使用巴科斯范式实现的语法文件,最终上述语法文件将检测的是[<want>]<dial>这样的语音。也就是说<want>规则中的:我想、我要、请、帮我...,这些可以说也可以不说。但是<dial>这个规则必须要说,而<dial>规则是由<dialpre><contact>[<dialsuf>]组成的。这个dialpre是必须要说的,它将检测用户有没有说出来:打电话给、打给、拨打、呼叫....这些语音。后面的<contact>就是检测的联系人,这里的联系人只有一个"丁伟"。最后的这个<dialsuf>是可说可不说,这是因为我们平时要给某人打电话的语法就是:“打电话给丁伟”这样的语法,当然还可以换另外一种表达方式,那就是“拨打丁伟的电话”,所以这里是两种语法规则都支持的。
在了解了语法文件后,我们还需要查看下示例源码,因为这里的语法文件中的规则,我们是可以动态的更新的。就是说我们不用重新启动程序,就可以实时的修改检测的语法规则,更新语法规则的API如下:
那接下来看一下在asr_offline_record_sample.c源码中是如何更新本次语法词典文件的,调用该API的代码如下:
int update_lex_cb(int ecode, const char *info, void *udata)
{
UserData *lex_data = (UserData *)udata;
if (NULL != lex_data) {
lex_data->update_fini = 1;
lex_data->errcode = ecode;
}
if (MSP_SUCCESS == ecode)
printf("更新词典成功!\n");
else
printf("更新词典失败!%d\n", ecode);
return 0;
}
int update_lexicon(UserData *udata)
{
const char *lex_content = "丁伟\n黄辣椒";
unsigned int lex_cnt_len = strlen(lex_content);
char update_lex_params[MAX_PARAMS_LEN] = {NULL};
snprintf(update_lex_params, MAX_PARAMS_LEN - 1,
"engine_type = local, text_encoding = UTF-8, \
asr_res_path = %s, sample_rate = %d, \
grm_build_path = %s, grammar_list = %s, ",
ASR_RES_PATH,
SAMPLE_RATE_16K,
GRM_BUILD_PATH,
udata->grammar_id);
return QISRUpdateLexicon(LEX_NAME, lex_content, lex_cnt_len, update_lex_params, update_lex_cb, udata);
}
可以看到在update_lexicon()函数中,将lex_content变量赋值为“丁伟\n黄辣椒”,那就是将在call.bnf语法文件中的<contact>规则,从“丁伟”更新到了“丁伟\n黄辣椒”。意思是说,我们在第一次识别的时候只能识别到“打电话给丁伟”这样的句子,如果没有更新参数之前说“打电话给黄辣椒”是无法识别的,因为默认的call.bnf语法文件中只有“丁伟”。但是当我们在update_lexicon()函数中调用了QISRUpdateLexicon()函数后,我们就可以将<contact>变量更新为“丁伟\n黄辣椒”了。
那在了解整个示例程序的流程后,我们就可以来测试离线命令词识别的效果了,如下视频所示:
0x04 实现自定义的命令词识别
前面的一节介绍的是科大讯飞提供的示例代码,在了解背后的原理后,我们就可以来自己修改源码,实现自己想要的功能了。那我们在这里可以利用离线命令词识别这个功能,实现一个家庭里电灯的控制。由于我们的语音板上自带有一个可以编程控制的LED灯,那么我们就把这颗LED灯当做家里的电灯来使用。这样我们利用离线命令词识别,就可以实现一个电灯控制的示例了。那首先第一件事,我们就是要创建我们自己的语法文件了。在编写语法文件之前,我们还是要先来学习一下这种巴科斯范式的语法规则。大家可以在SDK源码目录下的docs中找到该完整的手册,我这里给大家简单介绍下需要的知识点,如果要想学习完整的语法编写规则还是要好好的学习下文档。
语音识别的语法定义了语音识别所支持的命令词的集合,Aitalk 4.0产品利用巴科斯范式(BNF)描述语音识别的语法。语法文档被编译成识别网络后,将被送往语音识别器。语音识别器提取输入语音的特征信息并在识别网络上进行路径匹配,最终识别出用户说话的内容。因此语法是语音识别系统的输入之一,它是现阶段语音识别得以应用的必要条件。下面是一个精简版本的打电话的识别语法文件:
该语法使识别引擎支持的用法如下:“找一下张三”、“打电话给张三”、“找一下李四”、“打电话给李四”。凡是用户说出这个范围中的任意一句话,均可以被识别系统识别。如果用户说的话不在上述范围之内,那识别系统将拒绝识别。可见语法使用一种结构描述了用户可能说出的语言范围和构成模式。
1.记号:对应英文为 Token,其描述了用户语音所对应的文本内容,类似于“说法“,如“中 国|美国” 表示支持中国|美国两个记号的并列。
2.语义:对应英文为 Sementic,表示用户说法所对应的用户所关心的内容,在应用开发中, 可以将部分语义内置于语法文本中,以方便应用程序的处理。如在语音拨号的场景中,记号对应为“火警”,而语义为“ 119”。通过在语法中定义“火警”的语义,当用户说法为 “火警”时,识别引擎将“ 119”返回给应用程序,而无须在应用程序内部再进行文本的解释工作,大大方便了应用程序的开发。
3.规则:规则定义了一系列记号及其相互关系的集合,且可以包含其它子规则。通过指定规则的唯一的名称,使其它的规则可以通过名称引用该规则。
4.槽:槽是一种特殊的规则。 槽描述了一系列记号的并列关系,且不包含任何子规则。 利用 Aitalk SDK,用户可以在程序运行过程中实时增减槽中的记号。借用此功能,用户通过定义槽,描述语法中的频繁变化的内容,如通讯录中的人名,而无须更改语法文件。
在这里我们编写一个自己的语法识别文件,我们命名为control.bnf,具体的内容如下:
#BNF+IAT 1.0 UTF-8;
!grammar control;
!slot <devices>;
!slot <operate>;
!start <cmdstart>;
<cmdstart>:<operate><devices>;
<operate>:打开!id(1)|关闭!id(2);
<devices>:灯!id(3)|风扇!id(4);
可以看出来,我们这里打算控制两个设备。分别是灯和风扇,那这里的语法就可以识别:“打开灯”、“打开风扇”、“关闭灯”、“关闭风扇”四个命令了。这里需要注意一下!id()的作用,它其实就是起到一个字符串的标识的作用,当id=1的时候就知道是“打开”,当id=2的时候就知道是“关闭”。那我们接下来就可以来修改下asr_offline_record_sample.c这个源码文件了,修改后的具体代码如下:
/*
* 语音听写(iFly Auto Transform)技术能够实时地将语音转换成对应的文字。
*/
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <wiringPi.h>
#include "../../include/qisr.h"
#include "../../include/msp_cmn.h"
#include "../../include/msp_errors.h"
#include "speech_recognizer.h"
#define FRAME_LEN 640
#define BUFFER_SIZE 4096
#define SAMPLE_RATE_16K (16000)
#define SAMPLE_RATE_8K (8000)
#define MAX_GRAMMARID_LEN (32)
#define MAX_PARAMS_LEN (1024)
#define LED_PIN 26
#define FAN_PIN 25
#define ON 1
#define OFF 2
#define LED_DEVICE 3
#define FAN_DEVICE 4
static int recEndFlag = 0; //是否识别出结果的标志
const char * ASR_RES_PATH = "fo|res/asr/common.jet"; //离线语法识别资源路径
const char * GRM_BUILD_PATH = "res/asr/GrmBuilld"; //构建离线语法识别网络生成数据保存路径
const char * GRM_FILE = "control.bnf"; //构建离线识别语法网络所用的语法文件
const char * LEX_NAME = "contact"; //更新离线识别语法的contact槽(语法文件为此示例中使用的call.bnf)
const char * led_str = "灯";
const char * fan_str = "风扇";
const char * open_str = "打开";
const char * close_str = "关闭";
typedef struct _UserData {
int build_fini; //标识语法构建是否完成
int update_fini; //标识更新词典是否完成
int errcode; //记录语法构建或更新词典回调错误码
char grammar_id[MAX_GRAMMARID_LEN]; //保存语法构建返回的语法ID
}UserData;
int build_grammar(UserData *udata); //构建离线识别语法网络
int run_asr(UserData *udata); //进行离线语法识别
void controlDevice(int deviceID, int flag)
{
if(LED_DEVICE == deviceID) //control led light
{
if(ON == flag) //light on
{
digitalWrite(LED_PIN, LOW);
}
else //light off
{
digitalWrite(LED_PIN, HIGH);
}
}
else //control fan device
{
if(ON == flag) //fan on
{
digitalWrite(FAN_PIN, HIGH);
}
else //fan off
{
digitalWrite(FAN_PIN, LOW);
}
}
}
int build_grm_cb(int ecode, const char *info, void *udata)
{
UserData *grm_data = (UserData *)udata;
if (NULL != grm_data) {
grm_data->build_fini = 1;
grm_data->errcode = ecode;
}
if (MSP_SUCCESS == ecode && NULL != info) {
printf("构建语法成功! 语法ID:%s\n", info);
if (NULL != grm_data)
snprintf(grm_data->grammar_id, MAX_GRAMMARID_LEN - 1, info);
}
else
printf("构建语法失败!%d\n", ecode);
return 0;
}
int build_grammar(UserData *udata)
{
FILE *grm_file = NULL;
char *grm_content = NULL;
unsigned int grm_cnt_len = 0;
char grm_build_params[MAX_PARAMS_LEN] = {NULL};
int ret = 0;
grm_file = fopen(GRM_FILE, "rb");
if(NULL == grm_file) {
printf("打开\"%s\"文件失败![%s]\n", GRM_FILE, strerror(errno));
return -1;
}
fseek(grm_file, 0, SEEK_END);
grm_cnt_len = ftell(grm_file);
fseek(grm_file, 0, SEEK_SET);
grm_content = (char *)malloc(grm_cnt_len + 1);
if (NULL == grm_content)
{
printf("内存分配失败!\n");
fclose(grm_file);
grm_file = NULL;
return -1;
}
fread((void*)grm_content, 1, grm_cnt_len, grm_file);
grm_content[grm_cnt_len] = '\0';
fclose(grm_file);
grm_file = NULL;
snprintf(grm_build_params, MAX_PARAMS_LEN - 1,
"engine_type = local, \
asr_res_path = %s, sample_rate = %d, \
grm_build_path = %s, ",
ASR_RES_PATH,
SAMPLE_RATE_16K,
GRM_BUILD_PATH
);
ret = QISRBuildGrammar("bnf", grm_content, grm_cnt_len, grm_build_params, build_grm_cb, udata);
free(grm_content);
grm_content = NULL;
return ret;
}
static void show_result(char *string, char is_over)
{
char *check = NULL;
int device_id = 0;
int operate_id = 0;
printf("\rResult: [ %s ]", string);
if(is_over)
putchar('\n');
check = strstr(string, led_str);
if(check != NULL)
{
device_id = 3;
}
check = strstr(string, fan_str);
if(check != NULL)
{
device_id = 4;
}
check = strstr(string, open_str);
if(check != NULL)
{
operate_id = 1;
}
check = strstr(string, close_str);
if(check != NULL)
{
operate_id = 2;
}
if((device_id != 0) && (operate_id != 0))
{
controlDevice(device_id, operate_id);
}
}
static char *g_result = NULL;
static unsigned int g_buffersize = BUFFER_SIZE;
void on_result(const char *result, char is_last)
{
if (result) {
size_t left = g_buffersize - 1 - strlen(g_result);
size_t size = strlen(result);
if (left < size) {
g_result = (char*)realloc(g_result, g_buffersize + BUFFER_SIZE);
if (g_result)
g_buffersize += BUFFER_SIZE;
else {
printf("mem alloc failed\n");
return;
}
}
strncat(g_result, result, size);
show_result(g_result, is_last);
}
}
void on_speech_begin()
{
if (g_result)
{
free(g_result);
}
g_result = (char*)malloc(BUFFER_SIZE);
g_buffersize = BUFFER_SIZE;
memset(g_result, 0, g_buffersize);
printf("Start Listening...\n");
}
void on_speech_end(int reason)
{
recEndFlag = 1;
if (reason == END_REASON_VAD_DETECT)
printf("\nSpeaking done \n");
else
printf("\nRecognizer error %d\n", reason);
}
/* demo recognize the audio from microphone */
static void demo_mic(const char* session_begin_params)
{
int errcode;
struct speech_rec iat;
struct speech_rec_notifier recnotifier = {
on_result,
on_speech_begin,
on_speech_end
};
recEndFlag = 0;
errcode = sr_init(&iat, session_begin_params, SR_MIC, &recnotifier);
if (errcode) {
printf("speech recognizer init failed\n");
return;
}
errcode = sr_start_listening(&iat);
if (errcode) {
printf("start listen failed %d\n", errcode);
}
/*mic always recording */
while(1)
{
printf("listening ...\n");
if(recEndFlag)
{
break;
}
sleep(1);
}
errcode = sr_stop_listening(&iat);
if (errcode) {
printf("stop listening failed %d\n", errcode);
}
sr_uninit(&iat);
}
int run_asr(UserData *udata)
{
char asr_params[MAX_PARAMS_LEN] = {NULL};
const char *rec_rslt = NULL;
const char *session_id = NULL;
const char *asr_audiof = NULL;
FILE *f_pcm = NULL;
char *pcm_data = NULL;
long pcm_count = 0;
long pcm_size = 0;
int last_audio = 0;
int aud_stat = MSP_AUDIO_SAMPLE_CONTINUE;
int ep_status = MSP_EP_LOOKING_FOR_SPEECH;
int rec_status = MSP_REC_STATUS_INCOMPLETE;
int rss_status = MSP_REC_STATUS_INCOMPLETE;
int errcode = -1;
int aud_src = 0;
//离线语法识别参数设置
snprintf(asr_params, MAX_PARAMS_LEN - 1,
"engine_type = local, \
asr_res_path = %s, sample_rate = %d, \
grm_build_path = %s, local_grammar = %s, \
result_type = xml, result_encoding = UTF-8, ",
ASR_RES_PATH,
SAMPLE_RATE_16K,
GRM_BUILD_PATH,
udata->grammar_id
);
demo_mic(asr_params);
return 0;
}
int main(int argc, char* argv[])
{
const char *login_config = "appid = 5d5b9efd"; //登录参数
UserData asr_data;
int ret = 0 ;
wiringPiSetup();
pinMode(LED_PIN, OUTPUT);
pinMode(FAN_PIN, OUTPUT);
ret = MSPLogin(NULL, NULL, login_config); //第一个参数为用户名,第二个参数为密码,传NULL即可,第三个参数是登录参数
if (MSP_SUCCESS != ret) {
printf("登录失败:%d\n", ret);
goto exit;
}
memset(&asr_data, 0, sizeof(UserData));
printf("构建离线识别语法网络...\n");
ret = build_grammar(&asr_data); //第一次使用某语法进行识别,需要先构建语法网络,获取语法ID,之后使用此语法进行识别,无需再次构建
if (MSP_SUCCESS != ret) {
printf("构建语法调用失败!\n");
goto exit;
}
while (1 != asr_data.build_fini)
usleep(300 * 1000);
if (MSP_SUCCESS != asr_data.errcode)
goto exit;
printf("离线识别语法网络构建完成,开始识别...\n");
while(1)
{
ret = run_asr(&asr_data);
if (MSP_SUCCESS != ret) {
printf("离线语法识别出错: %d \n", ret);
goto exit;
}
}
exit:
MSPLogout();
printf("请按任意键退出...\n");
getchar();
return 0;
}
从上面代码可以得知,我们加载的是自己创建的语法文件control.bnf。在开始进行语音识别的时候,就会根据我们的语法文件来进行识别了。那我们接下来就可以来编译,这里由于使用了wiringPi的库来实现对树莓派上的IO口控制,所以在makefile中gcc的编译参数中需要加上lwiringPi的编译选项就可以了。
最后我们来测试一下,看看我们自己的语法文件能否来控制灯和风扇,在这里的风扇控制我是外接了一个继电器模块,通过树莓派上的GPIO.25来控制继电器的通断,从而可以控制风扇的通电、断电。可以先看一下测试设备,如下图所示:
下面来看视频演示效果:
在这里需要注意的是离线语音识别返回的结果格式,这里返回的是xml格式的结果。其实还可以修改为json格式,大家可以根据自己的编程习惯来选择对应的来解析。我这里默认选择的是xml格式,那反馈的xml格式结果就是如下图所示这样:
如果你想要反馈json格式的结果该怎么修改代码呢?这里就需要修改下run_asr()函数中调用语音识别时候的参数了,如下图所示:
假如修改为json格式,那反馈的结果是什么样子呢?如下所示:
corvin@raspberrypi:~/xf_aitalk/bin $ ./asr_offline_record_sample
构建离线识别语法网络...
构建语法成功! 语法ID:control
离线识别语法网络构建完成,开始识别...
Start Listening...
listening ...
listening ...
listening ...
listening ...
listening ...
Result: [ {
"sn":1,
"ls":true,
"bg":0,
"ed":0,
"ws":[{
"bg":0,
"slot":"<operate>",
"cw":[{
"w":"关闭",
"sc":100,
"id":2,
"gm":0
}]
},{
"bg":0,
"slot":"<devices>",
"cw":[{
"w":"风扇",
"sc":94,
"id":4,
"gm":0
}]
}],
"sc":98
} ]
Speaking done
listening ...
Not started or already stopped.
最后就是对反馈的结果来进行解析了,我们反馈的xml格式和json格式结果其实都有对应的库来帮助我们解析各字段的值。但是在这里我使用的是一种比较low的方式,就是从反馈的结果中直接查找有没有对应的字符串。我这里只是给大家做演示而已,在正式的代码中不能使用这么low的方式啊。下面就是我从反馈结果中检测各字段的方法,就是使用了strstr()这个函数,在字符串中检测是否存在子字符串,仅供参考:
static void show_result(char *string, char is_over)
{
char *check = NULL;
int device_id = 0;
int operate_id = 0;
printf("\rResult: [ %s ]", string);
if(is_over)
putchar('\n');
check = strstr(string, led_str);
if(check != NULL)
{
device_id = 3;
}
check = strstr(string, fan_str);
if(check != NULL)
{
device_id = 4;
}
check = strstr(string, open_str);
if(check != NULL)
{
operate_id = 1;
}
check = strstr(string, close_str);
if(check != NULL)
{
operate_id = 2;
}
if((device_id != 0) && (operate_id != 0))
{
controlDevice(device_id, operate_id);
}
}
最后,就是控制对应设备的开关了,这个实现就比较简单了。就是控制对应的GPIO高低即可,具体实现函数如下:
void controlDevice(int deviceID, int flag)
{
if(LED_DEVICE == deviceID) //control led light
{
if(ON == flag) //light on
{
digitalWrite(LED_PIN, LOW);
}
else //light off
{
digitalWrite(LED_PIN, HIGH);
}
}
else //control fan device
{
if(ON == flag) //fan on
{
digitalWrite(FAN_PIN, HIGH);
}
else //fan off
{
digitalWrite(FAN_PIN, LOW);
}
}
}
0x05 测试源码下载
我已经将所有的测试代码都上传到AIVoiceSystem这个代码仓库中,大家可以从以下链接中找到对应的代码,所有这些测试代码都是放在example目录下:
https://code.corvin.cn/corvin_zhang/AIVoiceSystem
当前在代码仓库中有两个分支,一个是master分支,主要是为非树莓派4板子使用的。另外一个分支是pi4-devel,这个是专门为树莓派4新开的分支代码。如果大家使用的是ROS小课堂发布的树莓派4的ROS系统镜像的话,那就可以使用这个分支的代码。
0x06 参考资料
[1].科大讯飞离线命令词识别Linux SDK文档. https://www.xfyun.cn/doc/asr/commandWord/Linux-SDK.html
[2].科大讯飞qisr.h文件参考手册. http://mscdoc.xfyun.cn/windows/api/iFlytekMSCReferenceManual/qisr_8h.html#a4be034ad09a1d6768fd0d47d06bb22f6
[3].科大讯飞离线语法编写指南. http://bbs.xfyun.cn/forum.php?mod=viewthread&tid=7595
0x07 问题反馈
大家在按照教程操作过程中有任何问题,可以直接在文章末尾给我留言,或者关注ROS小课堂的官方微信公众号,在公众号中给我发消息反馈问题也行。我基本上每天都会处理公众号中的留言!当然如果你要是顺便给ROS小课堂打个赏,我也会感激不尽的,打赏30块还会被邀请进ROS小课堂的微信群,与更多志同道合的小伙伴一起学习和交流!
[wshop_reward]