引入
每次做语音处理的项目时,总会遇到数据集如何处理的问题。今天试着总结一下。
我们先假设数据集是干净的,即不考虑数据集的清洗问题。对于语音数据集我们一般需要考虑以下几件事:
- 读取:把波形文件或其他格式的文件读取为 numpy 数组,进而转换成 torch 张量。
- 重采样:把不同采样率的音频文件统一到使用采样率。
- 分段:每次取一小片分段。
- 补零:对不满足固定长度的音频片段进行补零,保证每个批次的张量形状一致。
- 增强:对音频片段进行增强,例如加噪、加混响等。
- 混合:对不同数据集进行混合采样,保证数据的多样性和均衡性。
读取
我们先忽略 深度学习的数据集加载问题 中提到的加载速度问题,先假设数据集总是以 .wav 文件的形式存在于硬盘上,且不考虑缓存进内存的情况。因此我们实际上只需要保存一个文件路径列表懒加载。只有真正到__getitem__的时候才读取文件。
分段
soundfile 支持在读取 wav 时直接指定起始和结束位置来实现分段,这样就节省了读取整个音频文件再切片的开销,如果我们不缓存音频文件的话,在读取时就分段是最优的选择。如果我们缓存了音频文件,那么也许先读取整个音频文件再切片可能更合适。
补零
略
重采样
同样的如果我们不缓存音频文件,那么就在读取后重采样,显然读取几次就需要重采样几次。除非我们在数据集准备的时候就统一重采样。
增强
必然在读取后进行。问题在于是逐条增强还是对整个批次统一增强,后者并行度更高,但前提是增强方式可以并行化。
混合
这一步理应在读取之前就计划好,相当于我们需要根据数据集的元数据(即每个样本的时长或者整个数据集的时长)来决定每轮需要不同数据集的采样比例。
根据这些步骤,我们需要设计相对应的 Dataset 类来实现不同功能,可能需要抽象出特定的包装类来实现不同层次的功能。例如为每个特定的数据集设计一个专门的 Dataset 类来实现读取(因为文件夹结构和文件格式不同),然后再设计一个通用的 Dataset 类来实现分段、补零、重采样、增强和混合等统一功能。
Lhotse
我们发现,原来社区早已开发出一套完善的语音数据集处理工具 Lhotse
下面我们来学习一下 Lhotse 的设计理念和使用方法。
清单 Manifest
两大基本清单:录音 recording 和标注 supervision
传统的语音数据集包含了一个清单文件(manifest),其中包含了数据集中所有音频文件 recording 的路径、采样率、时长等元信息,以及每个音频文件对应的标注信息 supervision。
部分数据集可能并不显式提供清单文件,但是这些信息总是可以从数据集本身获得,无非多一步预处理。
整个数据集的 manifest 包含了 RecordingSet 和 SupervisionSet 两个部分。而每个部分又由每条音频对应的 Recording 和 Supervision 组成,这两者是数据条目层级的 manifest。
我们可以通过 prepare_<dataset> 方法,来生成某个数据集的 manifest。一个具体的数据集的 manifest 可能由不同子集的 manifest 组成,表示成字典格式: 例如 libri=prepare_librispeech(dataset_parts="mini_librispeech") 返回的 manifest 是:
{
'train-clean-5': {
'recordings': RecordingSet(len=1519),
'supervisions': SupervisionSet(len=1519)
},
'dev-clean-2': {
'recordings': RecordingSet(len=1089),
'supervisions': SupervisionSet(len=1089)
}
}如果 lhotse 没有提供某个数据集的
prepare_<dataset>方法,就需要自己实现。
RecordingSet 和 SupervisionSet 既可以用数字索引也可以用键索引访问。我们选择训练集中的一条样本来看:
>>> train_set = libri['train-clean-5']
>>> recordings = train_set['recordings']
>>> supervisions = train_set['supervisions']
>>> recordings[0] # or recordings['1898-145715-0000']
Recording(id='1898-145715-0000', sources=[AudioSource(type='file', channels=[0], source='data/LibriSpeech/train-clean-5/1898/145715/1898-145715-0000.flac')], sampling_rate=16000, num_samples=227760, duration=14.235, channel_ids=[0], transforms=None)
>>> supervisions[0] # or supervisions['1898-145715-0000']
SupervisionSegment(id='1898-145715-0000', recording_id='1898-145715-0000', start=0.0, duration=14.235, channel=0, text='ONCE THERE LIVED A KING WHO HAD NO CHILDREN FOR MANY YEARS AFTER HIS MARRIAGE AT LENGTH HEAVEN GRANTED HIM A DAUGHTER OF SUCH REMARKABLE BEAUTY THAT HE COULD THINK OF NO NAME SO APPROPRIATE FOR HER AS FAIRER THAN A FAIRY', language='English', speaker='1898', gender=None, custom=None, alignment=None)可以看到,Recording 和 SupervisionSegment 都是一个 dataclass,其中 Recording 包含了音频文件的路径、采样率、时长等信息,而 SupervisionSegment 包含了标注信息,如文本、说话人等。
注意区分 Recording 和 AudioSource,前者是一个清单对象,仅包含音频文件的元信息,而后者是一个清单对象的属性,包含了音频文件的来源信息,如文件路径、通道等。
为简便起见,以下我们统称 Recording 和 SupervisionSegment 为清单类而 RecordingSet 和 SupervisionSet 为清单集合类。
加载
清单类可以使用 from_file方法从音频文件或标注文件生成清单对象。 清单集合类可以使用 from_dir 批量加载某个目录下的所有音频文件,生成一个对象。也可以使用 from_jsonl 等方法从保存的 jsonl 文件中加载清单集合对象。
保存
清单类可以使用 to_dict 方法将一个清单对象转换为字典格式。清单集合类可以使用 to_dicts 方法将整个清单集合转换为字典列表格式。 清单集合类可以使用 to_jsonl 方法将整个清单集合保存为 jsonl 文件。
处理
清单集合类提供了以下方法来处理清单集合中的清单对象:
filter(func):过滤清单集合中的清单对象。subset(): 从清单集合中选择一个子集。split(): 将清单集合拆分为多个子集。shuffle(): 打乱清单集合中的清单对象。
增强
RecordingSet 提供了惰性数据增强或变换功能:
perturb_speed()perturb_volume()reverb_rir()resample()
特征清单
在语音处理任务中,我们经常需要提取各种音频特征。提取的特征除了传统的 Mel 谱、MFCC 以外,还包括各种深度学习模型提取的特征,而后者往往耗费大量计算资源,在多轮训练中反复提取显然是一种浪费,因此需要提前提取并保存这些特征。那么这些特征就类似于数据集中的音频文件和标注信息一样,需要提供一个清单类来管理这些特征。
Lhotse 的官方文档中对这部分的介绍不够详细,以下内容参照源码的 docstring。
将音频和标注信息组合起来
Cut 和 CutSet
以上提到的清单类并没有涉及到它们之间的相互作用。在实际应用中,我们需要将音频文件和标注信息组合起来,形成一个完整的数据样本。这对应着 Cut。Cut 是 Lhotse 进行数据处理的核心类,它是一个清单类的包装类,包含了一个 Recording 和若干个 SupervisionSegment,表示一个音频文件和它的若干标注信息。Cut 的设计理念是将音频文件和标注信息绑定在一起。类似的 Cut 可以挂载不同的特征清单类。
例如:
>>> rec = Recording(id='rec1', duration=10.0, sampling_rate=8000, num_samples=80000, sources=[...])
>>> sups = [
SupervisionSegment(id='sup1', recording_id='rec1', start=0, duration=3.37, text='Hey, Matt!'),
SupervisionSegment(id='sup2', recording_id='rec1', start=4.5, duration=0.9, text='Yes?'),
SupervisionSegment(id='sup3', recording_id='rec1', start=6.9, duration=2.9, text='Oh, nothing'),
]
>>> cut = MonoCut(id='rec1-cut1', start=0.0, duration=6.0, channel=0, recording=rec,
supervisions=[sups[0], sups[1]])这里,cut 的 start 和 duration 表示了这个 Cut 在底层 Recording 中的起始时间和持续时间。
在给定了清单以后,上述的 Cut 是可以通过 CutSet 自动生成的。一个 CutSet 可以从清单中自动生成:
>>> cuts = CutSet.from_manifests(recordings=my_recording_set)
>>> cuts2 = CutSet.from_manifests(features=my_feature_set)
>>> cuts3 = CutSet.from_manifests(
recordings=my_recording_set,
features=my_feature_set,
supervisions=my_supervision_set,
)注意 CutSet 的创建可以是惰性的或立即执行的。
从清单中自动生成的 CutSet 中的 cuts 默认与传入的录音或特征相同,这并不适合训练。我们可以将这些片段转换为更短的片段,例如:
>>> cuts_A = cuts.trim_to_supervisions()
>>> cuts_B = cuts.cut_into_windows(duration=5.0)
>>> cuts_C = cuts.trim_to_unsupervised_segments()处理
Cut 提供了以下方法来处理 Cut 对象,注意每种变换都会返回一个新的 Cut 对象,而不会修改原来的 Cut 对象,也不会修改底层的音频、特征、标注。这些变换总是在load_audio或load_features时按需进行的。
cut_2_to_4s = cut.truncate(offset=2, duration=2)
cut_padded = cut.pad(duration=10.0)
cut_extended = cut.extend_by(duration=5.0, direction='both')
cut_mixed = cut.mix(other_cut, offset_other_by=5.0, snr=20)
cut_append = cut.append(other_cut)
cut_24k = cut.resample(24000)
cut_sp = cut.perturb_speed(1.1)
cut_vp = cut.perturb_volume(2.)
cut_rvb = cut.reverb_rir(rir_recording)CutSet 也提供了类似的方法,同样它们也不会修改原来的 CutSet 对象,而是返回一个新的 CutSet 对象。
longer_than_5s = cuts.filter(lambda c: c.duration > 5)
first_100 = cuts.subset(first=100)
split_into_4 = cuts.split(num_splits=4)
shuffled = cuts.shuffle()
random_sample = cuts.sample(n_cuts=10)
new_ids = cuts.modify_ids(lambda c: c.id + '-newid')以下是数据增强的方法:
cuts_sp = cuts.perturb_speed(factor=1.1)
cuts_vp = cuts.perturb_volume(factor=2.)
cuts_24k = cuts.resample(24000)
cuts_rvb = cuts.reverb_rir(rir_recordings)注意如果
CutSet包含了特征清单,那么它们会在数据增强后自动分离。
以下变换可以批量进行:
cuts = cuts.pad(num_frames=300) # or duration=30.0
cuts = cuts.truncate(max_duration=30.0, offset_type='start') # truncate from start to 30.0s
cuts = cuts.mix(other_cuts, snr=[10, 30], mix_prob=0.5)