现在因为工作方向转到了语音方向,所以需要从头开始学习语音相关的算法以及工程。语音中用得比较多的工具箱就是kaldi了,kaldi的初步学习是从某个模型的脚本开始,单步运行。可以参考一下网上的一些博客,例如这篇,还是比较容易跑通第一个aishell v1的demo的。不过虽然跑通了,但是因为kaldi的一些shell脚本语法可能比较难看懂。其实一些shell脚本看不懂的地方是因为不太了解kaldi的i/o机制。好在kaldi的i/o机制在官方文档中有比较详细的说明。这里做一个翻译,这一篇翻译主要是翻译的代码级io机制,下一篇翻译一下命令行级的io机制。
本页概述了Kaldi的输入输出机制。
本文档的这一部分面向I/O的代码级机制;有关更多面向命令行的文档,请参阅Kaldi I/O from a command-line perspective.
Kaldi中定义的类具有统一的I/O接口。标准接口如下所示:
class SomeKaldiClass {
public:
void Read(std::istream &is, bool binary);
void Write(std::ostream &os, bool binary) const;
};
注意这些函数的返回值都是void。执行中的错误通过exception来指出(参考Kaldi logging and error-reporting。参数“binary”指定读写的对象是否按照二进制文件读写或者普通的text文件读写。调用读写函数的代码必须知道要进行读写的对象是按照哪种方式进行读写的(参考How Kaldi objects are stored in files查看这些代码是怎么知道这些对象是以什么形式存储的)。请注意,这个“binary”变量的形式不一定与windows上以“binary”或者“text”模式打开文件的形式相同(参看How the binary/text mode relates to the file open mode)。Read和Write函数可能有其他可选参数。一个例子:
class SomeKaldiClass {
public:
void Read(std::istream &is, bool binary, bool add = false);
};
如果add == true,则Read函数会将磁盘上的内容(例如统计信息)添加到当前类的内容中(如果该类当前不为空)。
有关此功能所涉及的功能列表,请参见“低级I/O功能”。kaldi提供了这些函数,使其更容易读写基本类型;它们主要来自Kaldi类的读写功能。 Kaldi类没有必要使用这些函数,它们只需要确保自己的Read函数可以读取自己的Write函数产生的数据即可。
此类别中最重要的函数是ReadBasicType()和WriteBasicType();这些是涵盖bool,float,double和integer类型的模板。在Read和Write函数中使用它们的一个例子如下:
// we suppose that class_member_ is of type int32.
void SomeKaldiClass::Read(std::istream &is, bool binary) {
ReadBasicType(is, binary, &class_member_);
}
void SomeKaldiClass::Write(std::ostream &os, bool binary) const {
WriteBasicType(os, binary, class_member_);
}
我们假设class_member_的类型为int32,这是一种已知大小的类型。在这些函数中使用int这样的类型并不安全。在二进制模式下,这些函数实际上会编入一个字符来编码整数类型的大小和符号,如果读取时不匹配则将失败。kaldi没有自动转换它们。目前,必须在I/O中使用kaldi中已知大小的整数类型(建议将int32用于"通用"类型)。另一方面,浮点类型会自动转换。这是为了便于调试,因此您可以使用-DKALDI_DOUBLE_PRECISION进行编译,并仍然可以读取没有该选项的二进制文件。我们的I / O例程没有字节交换;如果这对您来说是个问题,请使用文本格式。
还有WriteIntegerVector()和ReadIntegerVector()模板化函数。它们与WriteBasicType()和ReadBasicType()函数具有相同的样式,但适用于std :: vector ,其中I表示整数类型(同样,它的大小应该在编译时知道,例如int32)。其他一些重要的低级I / O函数比如:
void ReadToken(std::istream &is, bool binary, std::string *token);
void WriteToken(std::ostream &os, bool binary, const std::string & token);
token必须不为空,且不能有空格,通常来讲在实践中是一个看起来像XML的字符串,如“
// in writing code:
WriteToken(os, binary, "");
// in reading code:
ExpectToken(is, binary, "");
// or, if a class has multiple forms:
std::string token;
ReadToken(is, binary, &token);
if(token == "") { ... }
else if(token == "") { ... }
...
还有WritePretty()和ExpectPretty()函数。它们的使用频率较低,它们的行为与相应的Token函数相似,只是它们实际上只在文本模式下读写,并且它们接受任意字符串(即它们允许空格); ReadPretty函数还接受预期不同的有空格的输入。 Kaldi类中的Read函数永远不会检查文件的结尾,而是会读到Write函数写入的结尾之前(在文本模式下,留下一些未读的空白并不重要)。这样可以将多个Kaldi对象放在同一个文件中,并且还允许归档概念(请参阅Kaldi归档格式)。
上面说过,Kaldi读取代码需要知道它是以文本还是二进制模式读取,并且kaldi不希望用户必须跟踪给定文件是文本还是二进制。因此,包含Kaldi对象的文件需要声明它们是否包含二进制或文本数据。二进制Kaldi文件将以字符串“\0B”开头;由于文本文件不能包含“\0”,因此它们不需要标志头。如果您使用标准C++机制打开文件(但是通常不要这样做,请参阅如何下一节在Kaldi中打开文件),在执行任何操作之前,您必须先处理标志头。您可以使用函数InitKaldiOutputStream()(这也会设置流精度)和InitKaldiInputStream()来完成此操作。
假设想要从/向磁盘加载或保存Kaldi对象,并假设它是类似于语音模型这样(数量只有一个)的对象,(不是数目很多的对象,比如语音特征文件的对象,这种大量数据文件的打开方式请参阅kaldi的表格文件概念。通常kaldi用户需要使用Input和Output类。一个例子是:
{ // input.
bool binary_in;
Input ki(some_rxfilename, &binary_in);
my_object.Read(ki.Stream(), binary_in);
// you can have more than one object in a file:
my_other_object.Read(ki.Stream(), binary_in);
}
// output. note, "binary" is probably a command-line option.
{
Output ko(some_wxfilename, binary);
my_object.Write(ko.Stream(), binary);
}
括号里面的代码目的是让Input和Output对象在读写完成后立即"超出范围"从而可以马上关闭被读写的文件。这么做似乎看起来意义不大(为什么不使用标准c++的stream呢?)这么做的目的是一来可以支持更多扩展类型的文件名,而且它还使处理错误更容易一些(输入和输出类将打印信息性错误消息并在出错时抛出异常)。请注意,文件名中包含“rxfilename”和“wxfilename”。我们经常使用这样的名称,它们是用来提醒编程人员这些是kaldi的扩展文件名。我们将在下一节中描述rxfile和wxfile这类实体。
Input类和Output类的接口比上面的示例更丰富一点,你也可以调用Open()来打开文件或者使用Close()来关闭文件而不是让文件超出读写范围。这些函数返回布尔状态值,而不是像构造函数和析构函数那样抛出异常。当然也可以调用Open()函数(和构造函数)来读写非kaldi的文件,这样他们不会处理kaldi二进制header。当然你可能不需要任何这些额外的功能。
查看流式打开类和下一节的rxfilenames和wxfilenames获取更多Input类和Output类的信息。
rxfilenames和wxfilenames并不是类,他们是一系列的以各种类型名称出现的输入和输出描述符,包含着以下含义:
wxfilename的类型包含以下几种:
表格是指的kaldi的一种概念,并不是指的一个c++类型。它由一些已知类型的对象集合组成,由字符串索引,这些字符串必须是一个token(所谓token是指非空的、不含空白分隔符的字符串),表格文件的典型示例如:
std::string feature_rspecifier = "scp:/tmp/my_orig_features.scp",
transform_rspecifier = "ark:/tmp/transforms.ark",
feature_wspecifier = "ark,t:/tmp/new_features.ark";
// there are actually more convenient typedefs for the types below,
// e.g. BaseFloatMatrixWriter, SequentialBaseFloatMatrixReader, etc.
TableWriter feature_writer(feature_wspecifier);
SequentialTableReader feature_reader(feature_rspecifier);
RandomAccessTableReader transform_reader(transform_rspecifier);
for(; !feature_reader.Done(); feature_reader.Next()) {
std::string utt = feature_reader.Key();
if(transform_reader.HasKey(utt)) {
Matrix new_feats(feature_reader.Value());
ApplyFmllrTransform(new_feats, transform_reader.Value(utt));
feature_writer.Write(utt, new_feats);
}
}
这种设置的好处是访问表的代码可以将它们视为通用映射或列表。读取过程的数据格式和其他方面(例如,其容错率error tolerance)可以通过rspecifiers和wspecifiers中的选项来控制,而不必由调用代码处理; 在上面的例子中,选项“,t”告诉它以文本形式写入数据。
表格的理想情况可能是从字符串到对象的映射。但是,只要我们不对特定表进行随机访问,如果代码包含特定字符串的重复条目(即对于写入和顺序访问,它的行为更像是元素为pair的列表),代码也可以正常运行。
有关要读取和写入特定类型的Table类型的typedef列表,请参阅“特定表类型”。
脚本文件(这个名字可能略有误导)是一个文本文件,其中每行通常包含以下内容:
some_string_identifier /some/filename
脚本文件中的另一种有效行是:
utt_id_01002 gunzip -c /usr/data/file_010001.wav.gz |
总之,通用格式是
我们还允许在rxfilename之后出现一个可选的“范围说明符”; 这对于表示矩阵的部分(例如行范围)很有用。目前只有矩阵类型的数据支持。例如,我们可以表示矩阵的行范围如下:
utt_id_01002 foo.ark:89142[0:51]
这意味着矩阵的行0到51(包括)。行和列范围都可以表示,例如
utt_id_01002 foo.ark:89142[0:51,89:100]
如果您只想表示列范围,可以将行范围留空,如下所示:
utt_id_01002 foo.ark:89142 [,89:100]
当读取一行脚本文件时,Kaldi将修剪行前和行后的空白字符,然后在第一个“空格”上分割该行。第一部分成为表中的键(例如,utt id,在本例中为“utt_id_01001”),第二部分(在剥离可选范围说明符之后)成为xfilename(指的是wxfilename或rxfilename,在这种情况下“gunzip -c /usr/data/file_010001.wav.gz |”)。不允许使用空行或空xfilename。脚本文件可能对读取或写入有效,或两者兼有,具体取决于xfilenames是有效的rxfilenames还是wxfilenames,或两者兼而有之。
注意:一旦删除了可选范围,scp file行上出现的(r,x)filenames通常可以像给出文件名一样给予任何Kaldi程序。包含字节偏移的rspecifiers也是如此,例如foo.ark:8432。字节偏移将指向对象数据的开头(而不是指向archive文件中,数据之前的key-value)。对于二进制数据,字节偏移指向对象之前的“\0B”; 这允许读取代码在读取对象之前确定数据是二进制的。
Kaldi归档格式非常简单。首先回想一下,token被定义为无空白字符串。存档格式可以描述为:
token1 [something]token2 [something]token3 [something] ....
我们可以将其描述为零或多次重复:(一个token;然后是一个空格字符;然后是调用Holder的Write函数的结果)。回想一下,Holder是一个告诉Table代码如何读取或写入内容的对象。
在编写Kaldi对象时,Holder编写的[something]将构成二进制模式头(如果是二进制文件),然后是调用对象的Write函数写入结果。在编写简单的非Kaldi对象(如int32或float或vector )时,我们编写的Holder类通常确保在文本格式中,[something]是一个以换行符结尾的字符串。这样,存档有一个很好的一行一行格式,看起来像一个脚本文件,例如:
utt_id_1 5
utt_id_2 7
...
是我们用于存储整数的文本存档格式。
ark格式使您可以将ark文件连接在一起,它们仍然是有效的ark(假设它们具有相同类型的对象)。该格式设计为pipe-friendly,即您可以将ark放在管道中,读取它的程序不必等到管道结束才能处理数据。为了高效随机访问ark,可以同时将ark与包含存档偏移的scp一起写入磁盘。为此,请参阅下一节。
Table类需要一个传递给构造函数或Open方法的字符串。如果传递给TableWriter类,则此字符串称为wspecifier;如果传递给RandomAccessTableReader或SequentialTableReader类,则称为rspecifier 。有效的rspecifiers和wspecifiers的示例包括:
std::string rspecifier1 = "scp:data/train.scp"; // script file.
std::string rspecifier2 = "ark:-"; // archive read from stdin.
// write to a gzipped text archive.
std::string wspecifier1 = "ark,t:| gzip -c > /some/dir/foo.ark.gz";
std::string wspecifier2 = "ark,scp:data/my.ark,data/my.scp";
通常,rspecifier或wspecifier由逗号分隔的、无序的一个或两个字母的选项列表和一个字符串“ark”和“scp”组成,后跟一个冒号,再跟一个rxfilename或wxfilename。冒号前的选项顺序无关紧要。
wspecifiers有一个特殊情况:它们可以在冒号之前“ark,scp”,冒号之后是用于编写ark文件的wxfilename,然后是逗号,然后是用于scp文件的wxfilename。例如,
"ark,scp:/some/dir/foo.ark,/some/dir/foo.scp"
这将编写一个ark文件,以及一个scp文件,其中包含“utt_id /somedir/foo.ark:1234”等行,用于指定存档中的偏移量,以实现更高效的随机访问。然后,您可以使用scp文件执行任何您喜欢的操作,包括将其分解为段,并且它将像任何其他scp文件一样运行。请注意,虽然冒号前的选项顺序通常不重要,但在这种特殊情况下,“ark”必须位于“scp”之前; 这是为了防止在冒号之后混淆两个wxfilenames的顺序(ark始终是第一个)。指定存档的wxfilename应该是普通文件名,否则写入的scp文件将无法由Kaldi直接读取,但代码不会强制执行此操作。
允许的wspecifier选项是:
"ark,t,f:data/my.ark"
"ark,scp,t,f:data/my.ark,|gzip -c > data/my.scp.gz"
在阅读下面的选项时,请记住,如果ark实际上是一个管道(并且经常是管道),那么读取ark的代码永远不会在ark中寻找。如果RandomAccessTableReader正在读取ark,则读取代码可能必须在内存中存储许多对象,以防以后再次请求它们;或者就需要在ark文件中从头到尾的去查找key,即使这个key并不存在于ark文件中。下面的一些选项代表了防止这种情况的方法。
重要的rspecifier选项是:
典型的rspecifiers的例子:
"ark:o,s,cs:-"
"scp,p:data/my.scp"
如前所述,Table类,即TableWriter,RandomAccessTableReader和SequentialTableReader,都是在Holder类上进行模板化的。Holder不是实际的类或基类,而是描述了一系列类,并且这些类的名称以Holder结尾,例如TokenHolder或KaldiObjectHolder。(KaldiObjectHolder是一个通用的Holder,可以在满足Kaldi类的输入/输出样式中描述的Kaldi I / O样式的任何类中进行模板化)。我们编写了模板类GenericHolder以便记录Holder类必须满足的属性,但一般不使用GenericHolder。
Holder类“held”的类型是typedef Holder :: T(其中Holder是所讨论的实际Holder类的名称)。可在“Holder types”中找到可用持有人类型的列表。
本节仅适用于Windows平台。一般规则是,在写入时,文件模式将始终匹配Write函数的“binary”参数; 在读取二进制数据时,文件模式将始终为二进制,但在读取文本数据时,文件模式可能是二进制或文本(因此文本模式读取功能必须始终接受Windows插入的额外“\ r”字符)。这是因为我们并不总会知道,其内容是二进制还是文本直到我们打开文件,所以当不确定时,我们以二进制模式打开。
当Table代码以随机访问模式读取大型ark文件时,存在内存占用过多的可能性。只要使用RandomAccessTableReader 类型的对象读入ark,就可能发生这种情况。编写Table 代码是为了首先确保正确性,所以当以随机访问模式读取ark时,除非你给Table的读取代码一些额外的信息(我们将在下面讨论),它永远不会丢弃它拥有的任何对象阅读,以防你再次请求它。这里一个显而易见的问题是:为什么Table 代码不track到每个对象在文件中的开始位置,并在需要时fseek()到该位置?我们还没有实现这个,原因如下:你可以fseek()的唯一情况是当读取的ark是一个实际文件时(即不是管道命令或标准输入)。如果ark是磁盘上的实际文件,您可以将附加的scp文件写入文件中(使用“ark,scp:”前缀,请参阅同时前面的部分),然后将该scp文件提供给需要读取存档的程序。这几乎与直接读取ark一样节省时间,因为读取scp文件的代码足够智能,可以避免在不需要时重新打开文件并不必要地调用fseek()。因此,将ark视为特殊情况并将偏移缓存到文件中并不能解决任何问题。
当您以随机访问模式读取ark时,可能会发生两个不同的问题; 如果你只使用“ark:”前缀而没有其他选项,这些都会发生。
关于第二个问题(在以后需要的情况下被迫将object保存在内存中),有两种解决方案。
如果提供“o”选项,则Table可以在访问对象后释放它们。但是,只有当您的ark文件完美同步且没有间隙或缺少元素时,这才有效。例如,假设您执行以下命令:
some-program ark:somedir/some.ark "ark,o:some command|"
程序“some-program”将首先在存档“somedir / some.ark”上顺序迭代,然后对于它遇到的每个key,通过随机访问访问第二个ark。请注意,命令行参数的顺序不是任意的:我们已经尝试采用这样的约定:将按顺序访问的rspecifiers出现在将通过随机访问访问的那些之前。
假设两个档案大多是同步的,但可能有间隙(即缺少key,例如由于特征提取失败,数据对齐等)。只要第一个ark中存在间隙,程序就必须从第二个ark中缓存关联的对象,因为它不知道以后不会调用它(它只能在你有一个对象访问过后才释放它))。第二个存档中的间隙会更严重,因为如果存在一个元素的间隙,当程序要求该键时,它必须一直读到第二个ark的末尾以查找它,并且必须缓存沿途的所有对象。
some-program ark:somedir/some.ark "ark,s,cs:some command|"
我们假设两个ark都按顺序排序好,并且程序对第一个归档进行顺序访问,对第二个归档进行随机访问。现在,这种方式对于档案中的“间隙”处理会更有效。首先想象第一个档案中存在间隙(例如,其key是001,002,003,081,082 …)。当在key 003之后立即搜索第二个存档key 081时,读取第二个ark的代码可能遇到键004,005等,但它可以丢弃关联的对象,因为它知道在081之前不会有任何key被访问(感谢“cs”选项)。如果第二个存档中存在间隙,则可以使用第二个ark已经排序的事实,以避免搜索直到文件末尾(这是“s”选项的功劳)。
为了压缩在许多程序中重复出现的特定代码模式,我们引入了模板类型RandomAccessTableReaderMapped。与RandomAccessTableReader不同,这需要两个初始化参数,例如:
std::string rspecifier, utt2spk_map_rspecifier; // get these from somewhere.
RandomAccessTableReaderMapped transform_reader(rspecifier,
utt2spk_map_rspecifier);
如果utt2spk_map_rspecifier是空字符串,则其行为就像常规的RandomAccessTableReader一样。如果它是非空的,例如ark:data/train/utt2spk,它将从该位置读取utt 到 speaker(说话人) 的映射,并且每当查询特定字符串(例如utt1)时,它将使用该映射将话语id转换为一个 speaker-id(例如spk1)并使用它作为查询从rspecifier读取的Table的键。utterance-to-speaker的map也是一个ark,因为使用Table相关的代码是读取这种map最简单的方式。
scp文件不包含具体的数据(或者对象),只包含数据对应的路径
ark文件则包括具体的数据(或者对象)