这一节讲的内容又是语音识别系统非常重要的一环——veterbi解码,前面我们经过了配置文件,处理音频数据,处理标注文本数据、通过Baum-Welch(前向-后向)算法评估模型参数等多个环节,目的都是为了在这一步通过已知的模型来把音频解码成对应的文字,实现对语音的识别功能。

这篇如何通俗地讲解 viterbi 算法讲的比较入门,一看就懂,viterbi的实质也的确如此。现在就是要看它在HTK中是如何应用的,因为实际系统中涉及到很多细节,比如语法、剪枝优化、词的边界处理等等,会增加理解的复杂度,但是细心加耐心肯定都能搞明白的。

其实,维特比解码算法涉及的关键步骤在前向算法中已经有所体现了。不同点在于,前向算法计算\alpha_{t}(i)的意义是,说在t时刻,状态为i情况下,观察向量为O_{1} O_{2} ... O_{t}的概率,它的实质是求和;那么,在veterbi解码算法中,类似的步骤\Phi_{t}(j)是求最大似然值。记\Phi_{t}(j) = \underset{i}{max}\left \{ \Phi_{t-1}(i)\alpha_{ij} \right \}b_{j}(O_{t}),,表示从t-1时刻各个状态到t时刻j状态下,概率最大的值,记录下那个对应的上一个状态i。当考虑连续语音识别时,要考虑的情况就比较多了,例如语法问题、多音字、以及模型的区分度等。

执行解码命令如下:

HVite -H  .\hmms\hmm7\macros  -H  .\hmms\hmm7\hmmdefs  -S  test.scp  -l  * -i recout_step7.mlf -w wdnet -p 0.0 -s 5.0 dict2 monophones1

 现在调试代码看看HVite是如何解码操作的。

根据-H指定HMM模型定义,这里包括两个一个是宏定义macros,里面floorvar1的模型参数,作为训练时模型的协方差的最小值;还有hmmdefs,里面定义了各子词(sub-word或phone)的模型参数,是解码操作的最重要的依据。-S test.scp包含了多个测试文件提取位置,test.scp里的每个文件都是以mfc作为扩展名。-i recout_step7.mlf,指定输出文件名recout_step7.mlf,且为mlf文件格式来存储识别结果。-w wdnet指明识别过程所依赖的词网络。-p -s设置语言模型的参数。dict2位发音字典,monophones1是音子模型列表。

现在进入HVite源码的main函数里,看看大体的思路:

int main(int argc, char *argv[])
{

    //函数声明、一系列初始化等等
   ...


   if (!InfoPrinted() && NumArgs() == 0)
      ReportUsage();
   if (NumArgs() == 0) Exit(0);

   SetConfParms();
   CreateHeap(&modelHeap, "Model heap",  MSTAK, 1, 0.0, 100000, 800000 );
   CreateHMMSet(&hset,&modelHeap,TRUE); 


   // 参数处理
  while (NextArg() == SWITCHARG) {
      s = GetSwtArg();
      if (strlen(s)!=1) 
         HError(3219,"HVite: Bad switch %s; must be single letter",s);
      switch(s[0]){
      case 'a':
         loadLabels=TRUE; break;

   ....


// 数据处理

   if (wdNetFn==NULL)
      DoAlignment();     // 强制对齐
   else
      DoRecognition();   // 识别的处理函数

   /* Free up and we are done */

   if (trace & T_MEM) {
      printf("Memory State on Completion\n");
      PrintAllHeapStats();
   }


// 程序占有的资源释放

   DeleteVRecInfo(vri);
   ResetHeap(&netHeap);
   FreePSetInfo(psi);
   ...
   Exit(0);
   return (0);           

main函数的开头和前面介绍的几个工具一样,声明变量和将用到的函数,然后就是初始化,例如内存、网络、模型等等准备工作,然后就是命令行参数的处理。核心方法就是 DoRecognition()。

// 通过识别网络来识别一个个文件
void DoRecognition(void)
{

   ...

   if ( (nf = FOpen(wdNetFn,NetFilter,&isPipe)) == NULL)
      HError(3210,"DoRecognition: Cannot open Word Net file %s",wdNetFn);
   
   // wdNet为词级网络对象
   if((wdNet = ReadLattice(nf,&ansHeap,&vocab,TRUE,FALSE))==NULL)
      HError(3210,"DoAlignment: ReadLattice failed");

   // net为由词级网络扩展后的hmm模型网络
   net = ExpandWordNet(&netHeap,wdNet,&vocab,&hset);


   /* 如果没有输入待识别问句,就直接从音频输入设备读取语音数据 */
   if (NumArgs()==0) {      
      while(TRUE){
         printf("\nREADY[%d]>\n",++n); fflush(stdout);
         ProcessFile(NULL,net,n,genBeam, FALSE);
         if (update > 0 && n%update == 0) {

         ...
         }
      }
   }
   else {              /* 识别指定的音频文件 */
      while (NumArgs()>0) {
         if (NextArg()!=STRINGARG)
            HError(3219,"DoRecognition: Data file name expected");
         datFN = GetStrArg();
         if (trace&T_TOP) {
            printf("File: %s\n",datFN); fflush(stdout);
         }
         ....
         // 识别过程
         ProcessFile(datFN,net,n++,genBeam,FALSE);
         .....
      }
   }
}

而这里的ProcessFile是核心函数,参数有文件名,识别网络和束宽大小。看看这个函数里的代码结构。

Boolean ProcessFile(char *fn, Network *net, int utterNum, LogDouble currGenBeam, Boolean restartable)
{
   ...
   // 初始化识别过程中一些数据结构,比如net
   StartRecognition(vri,net,lmScale,wordPen,prScale);
   // 设置剪枝参数
   SetPruningLevels(vri,maxActive,currGenBeam,wordBeam,nBeam,tmBeam);
 
   tact=0;nFrames=0;
   StartBuffer(pbuf);
   // 读取文件中的帧数据到obj中

   // 处理某一帧的语音特征向量,结果保存在vri相关的数据项中
   ProcessObservation(vri,&obs,-1,xfInfo.inXForm);      

   nFrames++;
   tact+=vri->nact;

   lat=CompleteRecognition(vri,pbinfo.tgtSampRate/10000000.0,&ansHeap);   

   lat->utterance=thisFN;
   lat->net=wdNetFn;
   lat->vocab=dictFn;
   
   /* accumulate stats for online unsupervised adaptation only if a token survived */
   if ((lat != NULL) &&  (!vri->noTokenSurvived) && ((update > 0) || (xfInfo.useOutXForm)))
      DoOnlineAdaptation(lat, pbuf, nFrames);
   ...

   Dispose(&ansHeap,lat);
   CloseBuffer(pbuf);
}

函数ProcessObservation()才是核心中的核心。它真正实现了维特比算法,也就是token passing model algorithm。这个函数总共大概100行,下面就来详细了解它是如何一步步实现解码算法的,并以此为枢纽,连接多个数据结构。

涉及到的比较重要的结构体有,与识别网络有关的:Network、NetNode、NetNodeType、NetLink、NetInst;还有与网格有关的Lattice、LNode、LArc;与识别过程有关的:PRecInfo、VRecInfo、Token、Path、

 

 

 

 

点击阅读全文
Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐