在语音识别系统中,发音字典是必备的元素之一。系统通过发音字典文件在内存中构建一个字典对象,供后续的训练、解码使用。
本期blog就来跟踪下这个Vocab的细节。
首先贴一下这个Vocab的struct代码,大家有个直观的印象。
typedef struct {
int nwords; /* total number of words */
int nprons; /* total number of prons */
Word nullWord; /* dummy null word/node */
Word subLatWord; /* special word for HNet subLats */
Word *wtab; /* hash table for DictEntry's */
MemHeap heap; /* storage for dictionary */
MemHeap wordHeap; /* for DictEntry structs */
MemHeap pronHeap; /* for WordPron structs */
MemHeap phonesHeap; /* for arrays of phones */
} Vocab;
通过它可以了解[【字典】对象的全貌:有多少个词,有多少中发音(是否包含多音字,从这里可以看出来),以及相关的内存分配器(word/pron/phone)。其中有个不起眼的指针容易被忽略的,就是Word* wtab。它是真正存放发音信息的地方。一个【字典】有成千上万个【单词】(Word),每个单词至少有一个【发音】(pronunciation),它必定需要一大块空间来存储,而且是可高效访问的。HTK就是利用hash table来实现存储和访问的。
再来贴一个【字典】初始化的函数,结合这个函数来讲解如何初始化一个空的字典。
/* EXPORT->InitVocab: Initialise voc data structure */
void InitVocab(Vocab *voc)
{
int i;
CreateHeap(&voc->wordHeap,"Word Heap",MHEAP,sizeof(DictEntry),
0.4,200,2000);
CreateHeap(&voc->pronHeap,"Pron Heap",MHEAP,sizeof(WordPron),
0.4,200,2000);
CreateHeap(&voc->phonesHeap,"Phones Heap",MSTAK,1,0.4,400,4000);
voc->wtab = (Word*) New(&voc->phonesHeap,sizeof(Word)*VHASHSIZE);
for (i=0; i<VHASHSIZE; i++)
voc->wtab[i] = NULL;
voc->nullWord = GetWord(voc, GetLabId("!NULL",TRUE), TRUE);
voc->subLatWord = GetWord(voc, GetLabId("!SUBLATID",TRUE), TRUE);
voc->nwords = voc->nprons = 0;
}
这个函数的作用是给wtab分配空间,大小为sizeof(Word)*VHASHSIZE;然后初始化为NULL;后续会调用 ReadDict(char *dictFn, Vocab *voc)函数,它根据给定的字典文件信息来填充对应的hash表槽位值,也就是使得wtab中元素指向各DictEntry对象。
Word nullWord; /* dummy null word/node */
Word subLatWord; /* special word for HNet subLats */
比较费解是这个两个数据项有什么作用。其实可以暂时放一放,待后续了解了更多知识后回过头来再理解它们的作用。
在这个初始化一个空Vocab对象的过程中,另一个出现最多次数的名称就是Word,比如在分配内存时,就是以sizeof(Word)为元素大小的。这个hash table是分配在phonesHeap上的,Word是一个指针,它指向DictEntry,而DictEntry对象存储在wordHeap中,DictEntry中又有指针Pron,它指向发音信息对象WordPron。这个WordPron结构体保存了发音的个数(多音字时大于1,通过next访问下一个)、音子的个数(当前发音的)、音子序列、对应的Word、以及这个发音的输出符号。
下面这个图能反应Vocab、wtab、Word、DictEntry、Pron、WordPron之间的关系,其中wtab可以理解为指针(word)数组,word是指向DictEntry的指针。在DictEntry结构内包含了指针Pron,它指向该DictEntry的发音信息WordPron对象。
InitVocab函数给Vocab对象分配空间,并且wtab所有槽值都为空,也就这个【字典】对象里一个词(Word)信息都没有,下一步就是通过ReadDict(char* dictFn, Vocab* voc)函数从字典文件dictFn中读取发音信息,然后给voc对象赋值。
/* EXPORT->ReadDict: read and store a dictionary definition */
ReturnStatus ReadDict(char *dictFn, Vocab *voc)
{
LabId labels[MAXPHONES+4];
Source src;
Word word;
float prob;
int nphones;
ReturnStatus ret;
// src即为字典文件句柄
if((ret=ReadDictWord(&src,labels,&prob, &nphones))<SUCCESS){
CloseSource(&src);
HRError(8013,"ReadDict: Dict format error in first entry");
return(FAIL);
}
while(nphones>=0){
word = GetWord(voc,labels[0],TRUE);
if (labels[1]==NULL) labels[1]=labels[0];
if (labels[1]->name[0]==0) labels[1]=NULL;
NewPron(voc,word,nphones,labels+2,labels[1],prob);
if((ret=ReadDictWord(&src,labels,&prob, &nphones))<SUCCESS){
HRError(8013,"ReadDict: Dict format error");
return(FAIL);
}
}
return(SUCCESS);
}
主要涉及ReadDictWord、GetWord和NewPron函数。
1)ReadDictWord从字典文件中读取一行,“word phone1 phone2 phone3” 或者 “word2 [xxx] phone1 phone2 phone3 phone3 phone4”,并且把读到的信息保存在label[]、prob=1、nphones(有几个phone)中。如读到上述两行,那么label[] = ["word1" "word1" "phone1" "phone2" "phone3"], nphones值为3;或者label[] = ["word2" "xxx" "phone1" "phone2" "phone3" "phone4"],nphone4值为4.而label[1]就是当识别出这个发音序列时输出的字符。这里面隐藏了一个小细节,如果希望这个单词不输出/输出为空时,该怎么处理?也就是[xxx]里面没有元素,此时label[1]为NULL。
2)GetWord,是在上一步得到label[0]指示的单词后,查看当前词典中是否已经保存了相关信息。如果已经存在,就返回指向Word指针,否则添加进去再返回该指针。
到这里,我们有了什么呢?
voc中Word的位置,从发音字典中读取的Word对应的信息:label[] = ["word2" "xxx" "phone1" "phone2" "phone3" "phone4"],nphone4值为4,prob为1(通常情况)。
3)接下来看NewPron做了什么。
/* EXPORT->NewPron: add a pron to a given word - pron stored in phones */
void NewPron(Vocab *voc, Word wid, int nphones, LabId *phones, LabId outSym, float prob)
{
WordPron *pron, **p;
int i;
pron = (WordPron *) New(&voc->pronHeap, sizeof(WordPron));
if (nphones>0)
pron->phones = (LabId *) New(&voc->phonesHeap,nphones*sizeof(LabId));
else
pron->phones = NULL;
pron->nphones = nphones;
for (i=0; i<nphones; i++)
pron->phones[i] = phones[i];
pron->outSym = outSym;
pron->word = wid;
if (prob>=MINPRONPROB && prob<=1.0)
pron->prob = log(prob);
else if (prob>=0.0 && prob<MINPRONPROB)
pron->prob=LZERO;
else
pron->prob = 0.0;
for (p=&(wid->pron), i=0; *p!=NULL; p=&((*p)->next), i++);
pron->next = NULL;
pron->pnum = i+1;
*p=pron;
wid->nprons++;
voc->nprons++;
}
不难看出,首先在内存块pronHeap上分配一个WordPron对象,然后又在phoneHeap分了nphones个LabId对象,将传递进去的phones序列信息保存在这上面。还将Word和WordPron互相关联。比如PronWord的有一个word的指针,它表示说这个发音对象对应的单词是哪一个。分配的WordPron对象要追加到传递进去的Word(DictEntry)的发音序列后面(由Next指定)。
这三个函数执行完,整体的效果是,在Vocab中添加/追加了对应的Word的发音(PronWord)信息。该信息保存在pronHeap中,它包含的音子对象保存在phonesHeap中。这样phonesHeap、WordHeap、pronHeap三个内存分配器之间的关系就如下图所示。
它们之间的关系像是三角债,但是逻辑还是比较清晰的。
4)接着再重新读取字典文件,循环处理,直到文件结尾。