AISHELL-3语料库格式详解:从目录结构到训练数据转换 中文语音方向的小伙伴应该对AISHELL-3语料库不陌生。AISHELL-3是目前比较难得的、面向多说话人中文语音合成TTS而设计的开源语料和AISHELL-1/2常用的语音识别场景不太一样。很多朋友下载完压缩包解压一看wav、txt、label铺了一地立刻陷入“接下来怎么办”的迷茫。这篇文章就专门聊AISHELL-3的格式从目录结构到每一行标注的含义再到怎么把它转成FastSpeech2、VITS这些训练框架能直接消费的filelist顺便把我在实际项目里踩过的坑也一并交代清楚。适合刚入门的同学也适合已经在跑模型但被数据折腾得怀疑人生的工程师。1. 不只是WAV和文本AISHELL-3的整体设计思路1.1 数据规模与说话人分布决定了它适合做什么先看几个关键数字AISHELL-3合计约85小时88035条音频218位说话人平均每位说话人的录音在40到60分钟之间单句时长大多集中在3到10秒。和AISHELL-1/2牵着大量新闻朗读不同AISHELL-3在选句上刻意覆盖新闻、小说、科普、日常对话等多样化文本目的就是让多说话人模型既能学到稳定的音色表征又能覆盖足够丰富的韵律变化。这个设计直接影响你的实验方案。比如做few-shot TTS你不能指望每个说话人都有大篇幅数据来支撑微调必须依靠多说话人共享的声学空间来迁移。做音色建模时218个人分布在性别、年龄上都有差异也为训练说话人嵌入提供了足够多的样例。但也要注意官方没有像LibriTTS那样给出标准train/dev/test子集划分所以“怎么切”完全取决于你自己的任务需求。如果做多说话人TTS建议按照说话人划分而非按句子划分否则同一说话人既出现在训练集又出现在测试集模型的记忆效应会严重高估效果。1.2 音频参数与音质高采样率并不等于拿来就能用AISHELL-3音频质量不错公开版本里是WAV封装采样率常见为44.1kHz、16bit、单声道。从我接触到的几个镜像来看你实际下载到的包可能略有差异有些是16kHz有些是44.1kHz所以拿到数据的第一件事不是急着写训练代码而是先用工具统计所有音频的采样率、通道数、时长分布。为什么TTS语料要保留44.1kHz而不是直接给16kHz因为语音合成要生成完整波形高频细节对齿音、鼻音、摩擦音的自然度影响很大。过低的采样率会把这些信息削掉最终合成的音质会明显发闷。但在实际训练时你未必需要44.1kHz。很多声学模型和声码器在16k或22.05k下已经能工作得很好而且计算开销更小。我的习惯是先downsample到22.05kHz做FastSpeech2训练VITS时也保持22.05k这样预处理速度快显存也友好。踩过的一个坑是网上某些转换脚本默认把目标采样率写成16k结果音频时长没变但频谱维度和模型预设对不上损失函数一路飘红排查了很久才发现是采样率问题。2. 目录与文件格式先弄清语料库长什么样再动手2.1 解压后典型的目录结构AISHELL-3压缩包解压后数据根目录大概率长这样data_aishell3/ ├── speaker.info ├── wav/ │ ├── SSB0001/ │ │ ├── SSB00010001.wav │ │ ├── SSB00010002.wav │ │ └── ... │ ├── SSB0002/ │ │ ├── SSB00020001.wav │ │ └── ... ├── transcript/ │ └── aishell3_transcript.txt └── label/ └── aishell3_label.txt不同镜像站可能把transcript和label直接放在根目录或合并成一个metadata.csv但核心结构基本一致。wav文件夹下第一层是说话人ID第二层才放具体音频命名通常是“说话人ID句子序号”。比如SSB0001的第三句话就是SSB00010003.wav。这种组织方式对文件系统很友好按目录遍历时不会一次性读到几万个文件导致卡顿按说话人切分实验时也能顺理成章地只操作某个子目录。2.2 speaker.info里有什么speaker.info文件通常保存每个说话人的基础信息最常见的字段是说话人ID、性别、年龄区间。我见过类似下面的内容SSB0001 F 20-30 SSB0002 M 30-40别小看这个文件。在实验里做性别控制、年龄条件生成甚至筛选特定说话人做主观评测都需要靠它。如果某天你拿到的版本没有speaker.info也可以通过基频均值粗略按性别聚类但稳定性差很多尤其遇到女性低音或者男性高音会分得乱七八糟。所以我会在预处理阶段顺手把speaker.info解析成字典后面生成说话人列表时直接引用。2.3 transcript与label文本标注和韵律标注是两码事AISHELL-3里最容易混淆的就是transcript和label两类文件。transcript记录的是“音频对应的中文汉字文本”每行基本上只有两列音频ID和文本。label文件则更复杂通常包含带声调拼音、韵律边界标记甚至可能包含分词信息。我习惯把这两者区别开transcript用于训练语言模型、做文本前端标准化、算字错误率label则负责给声学模型提供音素级别输入和对齐。如果只用transcript而忽略label也能训练一个最简单的TTS但韵律效果会非常生硬。下面是两个文件可能各行的样子# transcript SSB00010001 他说今天天气很不错。 SSB00010002 生活里总有一些小确幸。# label SSB00010001 ta1 shuo1 jin1 tian1 tian1 qi4 hen3 bu2 cuo4 #0 #0 #1 #0 #2 #4 SSB00010002 sheng1 huo2 li3 zong3 you3 yi1 xie1 xiao3 que4 xing4 #0 #0 #0 #1 #4label行比transcript长很多每一段用空格隔开音频ID后面的每个token基本上对应一个音节或一个音素再后面跟着韵律边界标记。第一次看到这种数据的朋友建议不要急着改格式先挑几行人工比对一下哪个拼音对应哪个汉字体感会直观很多。3. 格式细节拆解从一行标注到能用的训练样本3.1 句子ID要当成字符串而非数字处理AISHELL-3的句子ID看起来是一长串数字但如果你直接转成int来遍历前面的0会全部被丢掉。比如SSB00010001如果拆出数字00010001再转int就变成10001回头拼wav路径时文件名对应的却是SSB00010001.wav对不上就开始数量对不上。我见过一个团队因为这个bug生成的filelist里有接近3%的路径是空的一开始还以为是数据没下载完整。最稳妥的做法是全程把utterance ID当作字符串处理只在拼接路径时用格式化补位。示例speaker_id SSB0001 utt_num 1 utt_id f{speaker_id}{utt_num:04d} # 结果就是 SSB00010001这个小细节在写任何解析脚本时都要注意否则后面做duration对齐、韵律标签匹配时都会连环出错。3.2 文本字段需要标准化和清洗转录文本里除了汉字还包含中文标点比如逗号、句号、感叹号。这个要不要保留取决于你的任务。做韵律预测时可以保留标点本身就是天然的停顿先验做语音识别或纯文本前端时标点常常先去掉免得模型把标点也当成必须预测的字符。如果训练TTS我一般保留常见标点并映射成特定token比如逗号对应短停顿句号对应长停顿。另一个坑是全角空格和连续空白。transcript里不同字段之间可能混入全角空格直接用line.split( )会被坑。正确做法是先按第一个空格切分出句ID和剩余部分再把剩余部分里的全角空格、制表符统一转成半角空格最后做正则清理。另外还会遇到繁体字和异体字比如“裏”和“里”如果不做规划模型字典里没有这个字就会变成未知字符训练直接失败。所以处理文本时最好加一步unicode标准化再用简繁映射表把所有字符转成常用简体。3.3 拼音与声调的处理方式label里给出的拼音一般带声调数字比如ni3 hao3。这个格式很直观但不同框架接受的形式不一样。FastSpeech2很多实现希望输入的是不带数字的音素声调单独作为一维特征另一些端到端模型则直接把拼音数字当成一个token甚至把“带声调拼音”的组合作为一个token。从AISHELL-3的拼音序列转到音素序列时最容易踩坑的是变调字。“一”和“不”在语流中经常发生变调但离线标注时通常只会标原调或者标实际读音这取决于原始标注策略。我遇到过一次label里标bu2但音频里听起来更像是bu4这就很尴尬。后来我的处理方式是不强行改label而是让模型自己从上下文去学绝大多数情况下模型学得比规整后的规则更好。3.4 韵律标记的设计意图再说说label里那一串#0到#4。在不同版本的AISHELL-3中这些标记的含义可能略有差异常见划分是音节内边界、韵律词边界、韵律短语边界、语调短语边界、句末。简单理解它们就是告诉模型“这里该换气”“这里该停顿”“这里语调要往下降”。FastSpeech2这类模型在预测duration时往往会把韵律标记作为一个辅助输入或预测目标。有了AISHELL-3自带的韵律标注做韵律建模实验会省很多事。如果你拿到的版本没有注释说明可以自己通过统计标记出现的位置然后对比同句文本的标点来反推每个数字的含义。实践表明#4基本出现在句尾标点附近#2和#3多出现在逗号前后这个规律在大多数中文TTS语料里都是通用的。4. 实操把AISHELL-3转成常见训练框架需要的格式4.1 通用处理流程五分钟搭一个解析脚本下面这个Python脚本是我处理AISHELL-3时经常用到的起点逻辑很简单先扫描wav目录建立“音频ID到路径”的映射再读transcript建立“音频ID到文本”的映射最后合并成数据集。import os from pathlib import Path wav_root Path(data_aishell3/wav) transcript_file Path(data_aishell3/transcript/aishell3_transcript.txt) utt2spk {} utt2wav {} utt2text {} # 1. 扫描所有wav for speaker_dir in wav_root.iterdir(): if not speaker_dir.is_dir(): continue speaker_id speaker_dir.name for wav_path in speaker_dir.glob(*.wav): utt_id wav_path.stem utt2spk[utt_id] speaker_id utt2wav[utt_id] str(wav_path) # 2. 读取transcript with open(transcript_file, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue parts line.split( , 1) if len(parts) 2: continue utt_id parts[0] text parts[1].strip() utt2text[utt_id] text这里有两个容易出问题的细节一是line.split( , 1)可以保证即使文本内部含有空格也不会被误切二是遇到缺失信息时提前过滤掉而不是在后续遍历时报错。实际项目里我还会把“有wav无text”和“有text无wav”的ID分别打印成两份日志方便回溯是解压问题还是文件命名问题。4.2 生成Kaldi风格的data目录很多语音工具箱仍沿用Kaldi风格的data目录包括wav.scp、text、utt2spk、spk2utt四个文件。ESPnet和WeNet也支持这种格式做数据准备。生成代码不复杂with open(data/wav.scp, w, encodingutf-8) as f: for utt_id, wav_path in utt2wav.items(): f.write(f{utt_id} {wav_path}\n) with open(data/text, w, encodingutf-8) as f: for utt_id, text in utt2text.items(): f.write(f{utt_id} {text}\n) with open(data/utt2spk, w, encodingutf-8) as f: for utt_id, spk in utt2spk.items(): f.write(f{utt_id} {spk}\n) with open(data/spk2utt, w, encodingutf-8) as f: spk2utt {} for utt, spk in utt2spk.items(): spk2utt.setdefault(spk, []).append(utt) for spk, utts in spk2utt.items(): f.write(f{spk} { .join(utts)}\n)注意wav.scp里第二列可以是绝对路径也可以是相对路径但建议写绝对路径免得后续训练脚本切换工作目录时找不到文件。utt2spk里的说话人ID必须和speaker.info严格一致大小写也不能错否则训练说话人嵌入时类别数量会莫名多出几个。生成完之后最好用Kaldi自带的utils/validate_data_dir.sh验证一下它会帮你检查ID重复、路径缺失、字段顺序等问题。4.3 分割train/dev/test别把同一个说话人分到多个集合多说话人实验最忌讳的就是训练集和测试集里出现同一个说话人。如果测试集包含训练过的说话人模型更多是在记忆音色而不是学习泛化主观听感得分会虚高。所以切分时一定要以说话人为单位而不是以句子为单位。我一般的做法是先把说话人列表打乱再按照85%训练、10%验证、5%测试的比例分配。AISHELL-3有218个人粗略算就是185人训练、22人验证、11人测试。如果想更精细一点可以统计每个说话人的总时长按总时长排序后再依次分配到三个集合保证每个集合的时长分布比较接近。import random speaker_list list(utt2spk.values()) speaker_list list(set(speaker_list)) random.seed(42) random.shuffle(speaker_list) n len(speaker_list) train_spks set(speaker_list[:int(n * 0.85)]) dev_spks set(speaker_list[int(n * 0.85):int(n * 0.95)]) test_spks set(speaker_list[int(n * 0.95):]) split_utts {train: [], dev: [], test: []} for utt, spk in utt2spk.items(): if spk in train_spks: split_utts[train].append(utt) elif spk in dev_spks: split_utts[dev].append(utt) elif spk in test_spks: split_utts[test].append(utt)这样切分后模型在训练时完全没见过测试说话人才能公平地测试多说话人生成能力。4.4 转换成ESPnet/FastSpeech2的filelist格式FastSpeech2和很多中文TTS项目使用filelist文件每行一条数据。常见格式是音频路径|说话人ID|文本。如果还用到拼音可以扩展成路径|说话人|文本|拼音序列。转换时要注意文件编码Windows下默认编码可能是GBK所以写文件时一定要指定encodingutf-8。def preprocess_text(text): # 统一全角转半角然后去掉多余空格 text text.replace(, ,).replace(。, .).strip() text .join(text.split()) return text for split in [train, dev, test]: with open(ffilelists/{split}.txt, w, encodingutf-8) as f: for utt_id in split_utts[split]: wav utt2wav[utt_id] spk utt2spk[utt_id] text preprocess_text(utt2text[utt_id]) f.write(f{wav}|{spk}|{text}\n)如果框架支持音素级输入还可以把label里的拼音序列解析后一并写入。比如VITS的很多中文实现需要text里直接放带声调拼音的token序列这时你就要在生成filelist之前先做一次“汉字文本到拼音token”的映射并构建一个字典保存所有出现过的音素。这一步建议单独写一个build_dict.py把所有音素、标点、韵律标记都统计出来生成dict.txt方便后续模型读取。5. 语料库处理中的常见问题与排错实录5.1 文件编码不对明明打开是乱码AISHELL-3的文本文件绝大多数是UTF-8但如果你在Windows下用默认的解压工具或记事本打开有时会看到乱码。这通常是解压软件用本地编码解析了文件不是文件本身损坏。我在读取时统一用UTF-8并加上errorsreplace来标记异常字符这样至少不会让程序崩溃。如果怀疑某个文件是GBK编码可以用chardet探测一下import chardet with open(file, rb) as f: raw f.read() enc chardet.detect(raw)[encoding] print(enc)一旦发现是GBK再单独用encodinggbk重新读取转换后另存为UTF-8。处理完所有文本文件后再跑一遍统计脚本确保每个ID都能匹配上。5.2 wav文件损坏或者静音段过多一个85小时的大型语料库难免出现个别录音损坏、波形截断或者整段静音。我的排查方法是先遍历所有wav文件用wave模块或librosa.load读一下统计时长和最大幅度。如果某条音频时长明显小于1秒或者幅度最大值接近0就要多加小心。还有一种情况是文件头正常但解码到一半出错这类文件用torchaudio.load会直接抛异常。我习惯在预处理阶段统一做“音频健康检查”import librosa import os bad_utts [] for utt_id, wav_path in utt2wav.items(): try: y, sr librosa.load(wav_path, srNone, monoTrue) if len(y) / sr 0.5: bad_utts.append((utt_id, too_short)) elif max(abs(y)) 1e-4: bad_utts.append((utt_id, silence)) except Exception as e: bad_utts.append((utt_id, str(e)))检查出问题后把这些ID从训练集中剔除。宁可在数据量上损失几十条也不要让一条坏音频把整个训练过程带偏。5.3 说话人ID大小写不统一有些版本里说话人ID是SSB0001有些可能变成ssb0001甚至混合出现。Linux下路径大小写敏感如果脚本里使用小写ID去拼接大写目录就会报“文件不存在”。这种情况在多人协作时尤其常见你负责写代码队友负责转数据他解压时顺手把文件夹重命名成小写所有ID立刻对不上。解决办法是进入预处理阶段第一步就做ID归一化统一转成大写或统一转成小写并同步更新wav路径、utt2spk、speaker.info。归一化之后再做融合和验证。我在一个项目里吃过这个亏训练到一半才发现某些说话人的loss一直很高查到最后就是大小写不一致导致模型把同一个说话人当成了两个类别。5.4 标注和音频数量对不上AISHELL-3解压后偶尔会出现音频数量比transcript行数多或少的情况原因无非是压缩包没解压全、下载中断、或者某些平台上传时把软链接丢了。处理方式不是强行补全而是把所有ID求一个交集只保留音频和文本两侧都存在的句子。在做交集时还要注意重复ID。万一出现两个不同文件夹下同名wav但内容不同utt2wav这个字典会静默覆盖这是非常危险的事。我一般会在扫描wav时单独记录那些重复IDseen set() duplicated [] for wav_path in wav_root.rglob(*.wav): utt_id wav_path.stem if utt_id in seen: duplicated.append(wav_path) seen.add(utt_id)如果重复ID数量较多说明这份数据源本身有问题建议更换下载源重新解压。如果只有个位数才考虑直接从两个里面挑一个保留。5.5 时长统计和帧数不匹配很多声学模型在训练前都会把音频预处理成频谱特征如果不同音频的采样率不一致同一段语音算出来的帧数会和标注对不上后面做forced alignment时就会崩。所以拿到AISHELL-3后第一步就是统一采样率而且在配置文件里写死目标值。我的做法是先把所有音频downsample到22.05kHz再统一存成预处理后的npy或者wav之后的训练脚本只会读取预处理结果不会在DataLoader里临时做resample。理由很简单虽然PyTorch和librosa都支持即时重采样但每次读取时计算的重采样结果会有微小差异这会增加训练的不确定性。离线统一处理一次既保证了可复现性也加快了训练速度。6. 从格式到模型一些可以少走弯路的经验6.1 先做一个小语料跑通流程我每次拿到新语料库都不会直接全量上训练。第一次用AISHELL-3训练VITS时我直接灌了88k条数据结果预处理花了快一下午训练到第2个epoch就爆显存最后还要回头调数据管道。后来我养成了一个习惯先取20个说话人、每个说话人50条音频做一个小型测试集把预处理、训练、合成全流程跑通。这个方法能快速暴露格式解析问题也能验证模型超参是否合理。小流程跑通后再扩建到全量几乎没有什么额外成本。如果你连小语料上都跑不通那问题大概率不是算力不够而是前面的格式转换有bug这个时候全量跑只会浪费时间。6.2 找一个现成工具先做基准除了自己写脚本用现成工具验证数据格式也很重要。Kaldi自带的utils/validate_data_dir.sh可以检查data目录字段是否齐全、ID是否重复、路径是否存在。如果你想用ESPnet它里面也封装了一套数据准备工具可以把Kaldi风格目录转成ESPnet需要的json格式。我的建议是“先跑现成工具再写自己的脚本”。因为现成工具的报错信息已经处理得很好能直接告诉你第几个字段出错了。我之前用ESPnet跑AISHELL-3时就是靠validate_data_dir.sh发现speaker.info里有一个说话人的性别字段漏了导致预处理阶段性别标签维度少了一个。这类错误不看工具输出真不太好找。6.3 语料库格式后续往多任务方向扩展最后聊聊格式可能带来的扩展空间。AISHELL-3由于自带说话人、性别、年龄、韵律标注完全可以用来做不止TTS一个任务。比如把speaker.info里的性别和年龄作为输入条件可以训练条件语音生成模型把label里的韵律边界当作监督信号可以训练一个独立的韵律预测器甚至用它的speaker embedding来做语音风格迁移。这些扩展玩法都建立在你对格式足够熟悉的基础上。如果你连transcript和label都还没分清后面每个环节都会多花时间调试。反过来你把它当成一个标准化的多说话人数据源去研究后面迁移到其他中文TTS语料时也会发现它们多多少少都借鉴了类似的设计思路。就我个人在实际项目中的体会来说AISHELL-3最让人纠结的往往不是模型结构而是数据格式的“隐性规则”。很多坑都不是文档里写着的而是你跑到一半才发现标注和音频对不上、某个音素字典漏了字、或者说话人ID大小写不一致。花半小时把这些格式细节整理清楚比后面debug时逐条排查要划算得多。如果再给我一次机会我会在写模型代码之前先把所有音频参数、文本编码、说话人ID、韵律标记全部做一次自动化统计生成一份报告然后再开始训练。最后再分享一个小技巧把AISHELL-3和另外一些开源中文朗读语料混合使用前一定要统一采样率、文本规范化和说话人ID规则否则模型容易学到不同语料的领域差异而不是学习泛化能力。