《语音识别原理与应用》洪青阳 第15章 工程应用实践————动态库封装

动态库封装

《语音识别原理与技术》洪青阳 工业应用实践 书、PPT 15.1

  • 函数接口
  • 动态库编译
  • 动态库调用

函数接口

1
2
3
4
5
6
7
8
9
10
11
12
13
/*************************************************************************************************************************/
/*
基于语音缓冲区的语音识别(非特定人)
@ handle : 线路资源标识(必须是已打开) 用来区分不同线程,防止共享资源同时修改某个地址的内容造成混乱 每个线程对应一个句柄号
@ buffer : 待识别语音缓冲区 short型 因为采样点一般用两个字节的short来保存
@ length : 待识别语音缓冲区长度 无符号的长整型 unsigned int 是buffer的长度 比如16000个采样点数 表示长度
@ text : 识别的文本内容 字符串 是一个指针,因此结果即使已经有一部分输出了,也是可以随时修改前面的,输出变换后的内容
@ scoreASR : 语音识别得分 一般用置信度
@ return : 成功识别返回SUCCESS
*/
/*************************************************************************************************************************/
return_ASR_Code ASR_recSpeechBuf(Handle handle, short* buffer, unsigned long length, char* text, float &scoreASR);

根据ASR_recSpeechBuf函数的输入和输出参数,我们==改写==了Kaldi的在线解码程序,包括以下函数:

  • ASR_recSpeechBuf函数:与外面调用程序交互,首先判断分配到的句柄handle是否空闲,如果忙则返回ASR_STATE_ERROR,表示已被占用;如果检查通过,则调用KaldiDecode函数进行解码,并把词序列索引转化为文本内容,保存到输出参数text,即为识别后的句子。
  • KaldiDecode函数:实现从语音缓冲buffer到识别结果的具体解码过程,首先完成输入buffer到SubVector wave_part的转化过程,然后调用feature_pipeline.AcceptWaveform(samp_freq, wave_part)进行声学特征提取,注意声学特征一般有做倒谱均值减(CMN),因此在函数内部还要加上这步操作,接着调用decoder.AdvanceDecoding()进行分片段识别,得到中间解码结果,保存在Lattice里,随后采用decoder.FinalizeDecoding()进行Lattice解码,修正中间部分结果。最后调用GetDiagnosticsAndPrintOutput函数得到解码后的词序列索引。
  • GetDiagnosticsAndPrintOutput函数:根据输入的CompactLattice进行Lattice最优路径搜索并返回得到词序列和基于最小贝叶斯风险算出来的置信度,分别存放在输出参数words和words_conf(confidence置信度)。

改写后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// 根据输入的CompactLattice进行Lattice最优路径搜索
void GetDiagnosticsAndPrintOutput(const fst::SymbolTable *word_syms,
const CompactLattice &clat,
int64 *tot_num_frames,
double *tot_like,
std::vector<int32> &words,
BaseFloat &words_conf) {
if (clat.NumStates() == 0) {
KALDI_WARN << "Empty lattice.";
return;
}
CompactLattice best_path_clat;
CompactLatticeShortestPath(clat, &best_path_clat);

Lattice best_path_lat;
ConvertLattice(best_path_clat, &best_path_lat);

double likelihood;
LatticeWeight weight;
int32 num_frames;
std::vector<int32> alignment;
GetLinearSymbolSequence(best_path_lat, &alignment, &words, &weight);
num_frames = alignment.size();
likelihood = -(weight.Value1() + weight.Value2());
*tot_num_frames += num_frames;
*tot_like += likelihood;
KALDI_VLOG(2) << "Likelihood per frame for utterance " << utt << " is "
<< (likelihood / num_frames) << " over " << num_frames
<< " frames.";

bool decode_mbr = true;
MinimumBayesRisk *mbr = NULL;
mbr = new MinimumBayesRisk(clat, words);

const std::vector<BaseFloat> &conf = mbr->GetOneBestConfidences();

words_conf = 0.0;

if (word_syms != NULL)
std::cerr << utt << ' ';
for (size_t i = 0; i < words.size(); i++) {
std::string s = word_syms->Find(words[i]);
if (s == ""){
KALDI_ERR << "Word-id " << words[i] << " not in symbol table.";
words_conf += conf[i];
}
if (words.size > 0) words_conf /= words.size();
}

if (mbr != NULL) delete mbr;
}

// 实现从语音缓冲 buffer 到识别结果的具体解码过程
static int KaldiDecode(short* buffer, unsigned long length, std::vector<int32> &words, float &scoreASR)
{
try{
double tot_like = 0.0;
int64 num_frames = 0;

OnlineNnet2FeaturePipelineInfo feature_info(feature_opots);
if (stricmp (asr_feature_type.c_str(), "mfcc") == 0)
samp_freq = feature_info.mfcc_opts.frame_opts.samp_freq;
else if (stricmp (asx_feature_type.c_str(), "plp") == 0 )
samp_freq = feature_info.plp_opts.frame_opts.samp_freq;
else //default: fbank

samp_freq = feature_info.fbank_opts.frame_opts.samp_freq;

OnlineNnet2FeaturePipeline feature_pipeline (feature_info);

Online IvectorExtractorAdaptationstate adaptation_state (feature_info.ivector_extractor_info);
feature_pipeline. SetAdaptationstate (adaptation_state) ;

OnlineSilenceWeighting silence_weighting(
trans_model, feature_info.silence weighting_config,
decodable_opts .frame_subsampling_factor);

SingleUtteranceNnet3Decoder decoder(decoder_opts, trans_model, *decode_fst, &feature_pipeline);

// read data from buffer
Matrix<BaseFloat> data_(1, length);
BaseFloat *data_ptr = data_.Data();
for (int32 i=0 ; i < length; i++ ){
data_ptr[i] = buffer[i];
}

Subvector<Baserloat> data(data_, 0);

int32 chunk_length;
if (chunk_length_secs >0 )
{
chunk_length = int32 (samp_freq * chunk_length_secs);
if (chunk_length ==0 ) chunk_length = 1;
}
else {
chunk_length = std::numeric_limits<int32>::max();
}
int32 samp_offset = 0;
std::vector<std::pair<int32, BaseFloat> > delta_weights;

while (samp_offset < data.Dim())
{
int32 samp_remaining = data.Dim() - samp_offset;
int32 num_samp = chunk_length < samp_remaining ? chunk_length : samp_remaining;

Subvector<BaseFloat> wave_part (data, samp_offset, num_samp);
feature_pipeline.AcceptWaveform(samp_freq, wave_part);

samp_offset += num_samp;
if (samp_offset == data.Dim()){
// no more input. flush out last frames
feature_pipeline.InputFinished();
}

if (silence_weighting.Active() && feature_pipeline.IvectorFeature () != NULL)
{
silence_weighting.ComputeCurrentTraceback (decoder.Decoder());
silence_weighting.GetDeltaWeights (feature_pipeline.NumFramesReady (), &delta_weights);
feature_pipeline.IvectorFeature () -> UpdateFrameWeights (delta_weights);
}

decoder.AdvanceDecoding ();

if (do_endpointing && decoder.EndpointDetected(endpoint_opts))
break;
}
decoder.FinalizeDecoding ();

Compactlattice clat;
bool end_of_utterance = true;
decoder. GetLattice(end_of_utterance, &clat);

BaseFloat words_conf = 0.0;
GetDiagnosticsAndPrintoutput(word_syms, clat, &num_frames, &tot_like, words, words_conf);

scoreASR = words_conf;

return 0;
catch(const std::exception& e)
{
std: : cerr < e. what ();
return -1;
}
}
}

// 用来和外面调用程序交互
return_ASR_Code ASR_recSpeechBuf (Handle handle, short* buffer, unsigned long length, char* text, float &scoreASR)
{
if (!asr_bInited) return ASR_STATE_ERROR;

if (handle <=0 || handle>asr_engine_thread_nb) return ASR_HANDLE_ERROR;
ASR_P() ;
if (asr_state [handle] != TSASR_IDLE)
{
ASR_V();
return ASR_STATE_ERROR;
}
asr_state[handle] = TSASR_BUSY;
ASR_V();

std::vector<int32> words;

Kaldidecode (buffer, length, words, scoreASR);

string recText = "" ;

for(int32 i=0 ; i< words.size(); i++){
recText += word_syms->Find (words[i]);
}
recText +="\0";

ASR_P();
if(asr_state[handle] == TSASR_BUSY) asr_state [handle] = TSASR_IDLE;
ASR_V();

return ASR_SUCCEEDED_OK;
}

函数接口——返回值

函数返回结果是return_ASR_code类型,它包含运行中可能出现的各种情况,用枚举类型定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum return_ASR_Code
{
ASR_SUCCEEDED_OK=0, // 0:操作成功
ASR_WORKINGDIR_NOT_FIND, // 1:工作目录不存在
ASR_CONFIG_FILE_NOT_FOUND, // 2:配置文件未找到
ASR_MODEL_FILE_NOT_FOUND, // 3:模型文件未找到
ASR_LICENSE_ERROR, // 4:授权有误
ASR_HANDLE_ERROR, // 5:句柄标识有误
ASR_STATE_ERROR, // 6:句柄状态有误
ASR_TOO_SHORT_BUFFER, // 7:语音太短
ASR_EXTRACT_FEAT_ERROR, // 8:特征提取出错
ASR_MODEL_LOAD_ERROR, // 9:模型加载出错
ASR_MODEL_SAVE_ERROR, // 10:模型保存出错
TSASR_BUSY, // 11:线路正忙
TSASR_IDLE, // 12:线路空闲
TSASR_CLOSE, // 13:线路关闭
ASR_OTHER_ERROR // 14:其他错误
};

用返回值来诊断引擎出现的问题。

为了提高可读性, 还需对识别后的句子进行加标点等后处理。加标点的操作可采用语言模型,该模型通过对规范的文本语料训练得到。

由于声学模型和 WFST 解码用的 HCLG 文件很大, 通用版本大小一般有 5GB 以上, 故只能在系统初始化阶段加载, 并且只需要加载一次。其相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 加载声学模型
bool binary;
Input ki (nnet3_rxfilename, &binary);
trans_model.Read (ki.stream (), binary);
am_nnet.Read (ki.Stream (), binary);
SetBatchnormTestMode (true, &(am_nnet.GetNnet () ));
SetDropoutTestMode (true, &(am_nnet.GetNnet ()));
nnet3::CollapseModel (nnet3::CollapseModelConfig(), &(am_nnet.GetNnet()));

// 加载 HCLG
decode_fst = ReadFstKaldiGeneric(fst_rxfilename);

//加载词典
if (word_syms_rxfilename != "'")
if (! (word_syms = fst::SymbolTable::ReadText (word_syms_rxfilename))
KALDI_ERR << "Could not read symbol table from file "
<< word_syms_rxfilename;

//解码参数初始化
decodable_info = new nnet3::DecodableNnetSimpleLoopedinfo(decodable_opts, &am_nnet);

函数接口—引擎初始化和关闭

对于引擎初始化,我们专门定义一个函数ASR_Init。

ASR_Init 函数除了进行引擎文件的加载外, 同时也检查线路授权, 分配能同时并发的路数, 以支持多路调用。调用 ASR_Init 函数后, 如果返回函数值为 ASR_SUCCEEDED_OK, 则表示初始化成功。如果要关闭语音识别服务, 则需要调用 ASR_Release 函数释放相关资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*************************************************************************************************************************/
/*
初始化引擎
@ working_dir : 工作目录下有引擎工作所必需的文件
@ max_lines : 引擎支持最大线数
@ return : 成功初始化返回SUCCESS
*/
/*************************************************************************************************************************/
return_ASR_Code ASR_Init(const char* config_file,int max_lines);

/*************************************************************************************************************************/
/*
关闭引擎:改变各线路状态为CLOSE
*/
/*************************************************************************************************************************/
return_ASR_Code ASR_Release();

函数接口—句柄打开和关闭

由于涉及多线程调用, 所以需要为每个线程分配专门的句柄, 因此还需要 ASR_Open 和 ASR_Close 两个函数。调用 ASR_Open 函数分配到句柄后,执行完相关操作也要及时调用 ASR_Close 函数释放句柄。这两个函数属于标准化的操作, 这里不再细述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*************************************************************************************************************************/
/*
打开线路:h是线路资源标识
*/
/*************************************************************************************************************************/
return_ASR_Code ASR_Open(Handle &outh);


*************************************************************************************************************************/
/*
关闭线路:h是线路资源标识
*/
/*************************************************************************************************************************/
return_ASR_Code ASR_Close(Handle &h);

动态库编译

完成函数接口定义后,需要把引擎代码编译成动态库。接下来介绍Linux环境下so的编译过程。

动态库编译—Linux环境

Linux环境不方便修改及调试代码,为便于操作,我们建议采用跨平台工具,开发环境可采用 ==CodeBlocks==,读者可下载最新版本并安装到Windows系统。以CodeBlocks16.01版本为例,一旦安装完成后,在文件菜单里选择新建->工程->动态库(New -> Project -> DLL),打开codeblock动态库创建窗口,如图所示。

图片2

然后根据提示一步步创建,选择存放的目录,输入工程名,直到工程环境创建成功,如图所示。这时在工程目录会生成一个.cbp的工程文件,如asr.cbp。根据Kaldi函数调用关系,我们需要把在线解码需要的源程序全部加载到工程,并加入必要的外部支撑文件,用来读取配置文件,输出日志信息等。

图片1

工程配置保存完,把整个工程目录传到Linux环境,Linux的编译需要Makefile配置文件。为提高效率,可采用cbp2make工具(可网上下载)把asr.cbp工程文件转化为Makefile文件。有了Makefile文件,即可==在Linux环境进行make编译==。

我理解的流程是:先在windows新建工程(codeblock),把所以依赖文件放进来,然后把windows上的这个工程,转成makefile,再放到linux平台上编译,再根据报错情况,再在win平台上的codeblock工程进行修改,然后再转成makefile放到linux里进行编译,直到编译通过,生成.so文件(动态库)。

用了哪些库,比如数学的库,就要把这些库的头文件的路径在makefile里包含、引用进来。

由于kaldi需要调用第三方提供的数学加速库,如Atlas或MKL,因此需要在Makefile中进行如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[采用Atlas库]
INC = -Isrc -Iopenfst/include -I/home/kaldi/tools/ATLAS/include -I/usr/local/cuda/include/ -I/home/kaldi/tools/portaudio/include -I/home/kaldi/tools/portaudio/install/include -I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include -I/usr/include/libxml2/
CFLAGS = -std=c++11 -DHAVE_ATLAS -DHAVE_POSIX_MEMALIGN
RESINC =
LIBDIR =
LIB = lib/libatlas.so lib/liblapack.so
LDFLAGS = -ldl

[采用 MKL 库]
INC = -Isrc -Iopenfst/include
-I/opt/intel/compilers_and_libraries_2019.2.187/linux/mkl/include/
-I/usr/local/cuda/include/-I/usr/lib/x86_64-1inux-gnu/glib-2.0/include
-I/usr/include/libxml2/
CFLAGS = -std=c++11 -DHAVE_MKL_-DHAVE_POSIX_MEMALIGN
RESINC =
LIBDIR =
LIB = lib_mkl/libiomp5.so lib_mkl/libmkl_core.so 11b_mkl/libmkl_intel_lp64.so
LDFLAGS = -ldl -lpthread

Linux环境差异大,包括centos,ubuntu等不同版本,安装库的位置也可能不同,需要根据实际环境,修改makefile里面关于这些库的访问路径配置。服务器一般采用centos。

由于Kaldi代码众多,包含很多子模块,函数互相之间关联度较强,加载的文件可能存在冗余或冲突,导致各种编译错误,读者需要根据报错信息一一修正,直至编译成功,最后生成so动态库文件。

这个so动态库需配套相应的头文件,包含可调用的函数接口及参数说明,供外部调用参照。

.so有release版本和debug版本,我们要用release版本,速度快。

动态库编译—Windows环境

Windows环境编译的是dll动态库,主要采用Visual Studio开发工具。由于Kaldi代码采用C++ 11标准,需要安装Visual Studio 2015或更新的版本。另外,Windows环境的加速库只能采用Intel MKL或OpenBLAS。MKL集成相对容易,但需要安装Intel的配套工具,安装完MKL与Visual Studio 2015集成环境如图所示。

图片3

由于 Kaldi 默认在 Linux 环境下编译, 对 Windows 的支持不是很到位, 因此 部分变量和代码在编译时会出现问题, 需要修改, 例如在 kaldi-math.h 文件中需要补充针对_MSC_VER (Visual Studio 开发环境) 的定义。

1
2
3
4
5
6
7
8
9
10
11
12
#ifdef MSC_VER
inline int isnan(double x) { return x != x;}
inline int isinf(double x ) { return !isnan(x) && isnan(x-x); }
#define isfinite finite
#define KALDI_ISNAN isnan
#define KALDI_ISINF isinf
#define KALDI_ISFINITE(x) isfinite(x)
#elif
#define KALDI_ISNAN std:: isnan
#define KALDI_ISINF std:: isinf
#define KALDI_ISFINITE(x) std::isfinite(x)
#endif

动态库调用

外部程序调用编译好的动态库,要先集成到工程里,如Linux在Makefile里配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
CC = gcc
CXX = g++
AR = ar
LD = g++
WINDRES = windres

INC =
CFLAGS = -Wall -fexceptions
RESINC =
LIBDIR =
LIB = lib/libatlas.so lib/libtsASR.so lib/liblapack.so
LDFLAGS =

程序要调用时,先初始化引擎,然后分配句柄,再调用相关的识别函数,识别完关闭句柄。程序到最后还要关闭引擎,释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//初始化引擎
if(ASR_SUCCEEDED_OK == ASR_Init(config_asr_file.c_str(),3))
{
cout<<"ASR init success!"<<endl;
}
else
{
cout<<"ASR init fails!"<<endl;
return -1;
}

//打开句柄
Handle tsASR;
ASR_Open(tsASR);

float scoreASR;
char rec_text[10240];

//语音识别
if(ASR_SUCCEEDED_OK == ASR_recSpeechBuf(tsASR,pWavBuffer,length,rec_text,scoreASR))
{
cout<<"Recognized text: "<<rec_text<<endl;
cout<<"scoreASR: "<<scoreASR<<endl;
}
else
{
cout<<"Recognize "<<wave_full_file.c_str()<<"error!"<<endl;
}

//关闭句柄
ASR_Close(tsASR);

//关闭引擎
ASR_Release();