语音识别技能汇总

语音识别技能汇总

常见问题汇总

import warnings
warnings.filterwarnings('ignore')

基础知识

Attention-注意力机制

原理:人在说话的时候或者读取文字的时候,是根据某个关键字或者多个关键字来判断某些句子或者说话内容的含义的。即通过对上下文的内容增加不同的权重,可以实现这样对局部内容关注更多。

常用语音识别工具

相关包的安装
pip install pygame
SpeechRecognition
playsound
librosa

读取音频数据

speech_recognition
import speech_recognition as sr
print(sr.__version__)
r = sr.Recognizer()
harvard = sr.AudioFile('audio_files\harvard.wav')
with harvard as source:
   audio = r.record(source)
# 调用api识别
text = r.recognize_google(audio)
librosa

详细教程:https://minux.blog.csdn.net/article/details/108684589?spm=1001.2101.3001.6650.5&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7ERate-5.pc_relevant_default&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7ERate-5.pc_relevant_default&utm_relevant_index=10

'''

>> LibROSA - 是用於 音樂和音頻 分析的 python 軟件包
>> LibROSA - is a python package for music and audio analysis
    >> pip install librosa
    >> https://librosa.github.io/librosa/


>> librosa.feature.mfcc
    >> https://librosa.github.io/librosa/generated/librosa.feature.mfcc.html

'''

librosa不好用,不支持mp3格式的音频读取

import IPython.display as ipd
import librosa
# 返回数组数据和采样率
# 返回的是一个numpy的数组 采样率
y1, sr1 = librosa.load('audio_files\eng.mp3') # 报错

# 播放语音
ipd.Audio(y1, rate=sr1)

播放音频

Playsound
'''

>> Playsound - 播放音频的工具包 playsound
>> playsound - contains only one thing - the function (also named) playsound
    >> pip install playsound
    >> https://github.com/TaylorSMarks/playsound

>> 播放音頻 - Playing Audio
    >> IPython.display.Audio-播放音頻
    >> IPython.display.Audio-play the audio
        >> 支持 mp3 或 WMA 格式
        >> support mp3 or a WMA format

'''

推荐使用这个播放音频比较好

import playsound
from playsound import playsound
playsound('audio_files\eng.mp3')
'''

>> gtts 
    >> pip install gTTS==1.1.8
    >> Create an mp3 file from spoken text via the Google TTS (Text-to-Speech) API
    >> https://pypi.org/project/gTTS/
    
>> pygame
    >> pip install pygame
    >> Pygame is a Python wrapper module for the SDL multimedia library.
        >> It contains python functions and classes 
        >> that will allow you to use SDL’s support for playing cdroms, audio and video output
        >> and keyboard, mouse and joystick input.
    >> https://pypi.org/project/pygame/

'''
pygame
from pygame import mixer
mixer.init()
mixer.music.load('Audio課程3\hello1.mp3')
mixer.music.play()
TTS

text to speach

pip install gtts

可视化音频

显示波形图
'''

>> 可視化音頻 - Visualizing Audio File 
    >> 波形 - Waveform
    >> librosa.display.waveplot - 繪製音頻數組 -  plot the audio array

    >> 波形的幅度
        >> Amplitude of a Waveform
'''
# %matplotlib inline
import sklearn
import matplotlib.pyplot as plt
import librosa.display

plt.figure(figsize=(14, 5))
librosa.display.waveshow(x, sr=sr) # librosa=0.6.0
显示频谱图
'''
>> 频谱图 - Spectrogram
    >> librosa.display.specshow - Display Spectrogram - 显示频谱图
'''
# 短时傅里叶变换
X = librosa.stft(x)
Xdb = librosa.amplitude_to_db(abs(X))
plt.figure(figsize=(14, 5))
librosa.display.specshow(Xdb, sr=sr, x_axis='time', y_axis='hz')
plt.colorbar()
显示对数频谱图
  • 可以使低频率的数据显示的更清楚
'''
>> 對數頻率軸 - Log Frequency axis
'''
librosa.display.specshow(Xdb, sr=sr, x_axis='time', y_axis='log')
plt.colorbar()

CMVN
# 特征缩放
# 	每个新书的均值
# 	单位方差为0
mfccs = sklearn.preprocessing.scale(mfccs, axis=1)
print(mfccs.mean(axis=1))
print(mfccs.var(axis=1))
librosa.display.specshow(mfccs, sr=sr, x_axis='time')

色度频率
'''

>> 色度频率 - Chroma Frequencies

'''
# Loadign the file
x, sr = librosa.load('Audio/simple_loop.wav')
ipd.Audio(x, rate=sr)
hop_length = 512
chromagram = librosa.feature.chroma_stft(x, sr=sr, hop_length=hop_length)
plt.figure(figsize=(15, 5))
librosa.display.specshow(chromagram, x_axis='time', y_axis='chroma', hop_length=hop_length, cmap='coolwarm')

绘制频谱

可视化-计算时间 变量

'''

>> 光谱质心 - Spectral Centroid
    >> librosa.feature.spectral_centroid
    
'''
spectral_centroids = librosa.feature.spectral_centroid(x, sr=sr)[0]
spectral_centroids.shape
frames = range(len(spectral_centroids))
t = librosa.frames_to_time(frames)

  • 归一化
def normalize(x, axis=0):
    return sklearn.preprocessing.minmax_scale(x, axis=axis)

质心-波形

谱质心(Spectral Centroid)是描述音色属性的重要物理参数之一,是频率成分的重心,是在一定频率范围内通过能量加权平均的频率,其单位是Hz。它是声音信号的频率分布和能量分布的重要信息。在主观感知领域,谱质心描述了声音的明亮度,具有阴暗、低沉品质的声音倾向有较多低频内容,谱质心相对较低,具有明亮、欢快品质的多数集中在高频,谱质心相对较高。该参数常用于对乐器声色的分析研究。


# 质心 - 波形 - Plotting the Spectral Centroid - waveform
librosa.display.waveplot(x, sr=sr, alpha=0.4)
plt.plot(t, normalize(spectral_centroids), color='r')

频谱滚降
# 滚降
spectral_rolloff = librosa.feature.spectral_rolloff(x+0.01, sr=sr)[0]
# 绘制频谱 波形
librosa.display.waveplot(x, sr=sr, alpha=0.4)
plt.plot(t, normalize(spectral_rolloff), color='r')
plt.grid()

提取音频特征
'''
>> 梅尔频率倒谱系数
    >> MFCC - Mel Frequency Cepstral Coefficents
'''
x, fs = librosa.load('Audio/simple_loop.wav')
librosa.display.waveplot(x, sr=sr)

mfccs = librosa.feature.mfcc(x, sr=sr)
print(mfccs.shape)

librosa.display.specshow(mfccs, sr=sr, x_axis='time')


放大波形图

# 加载音频
x, sr = librosa.load('Audio/T08-violin.wav')

# 播放音频
ipd.Audio(x, rate=sr)

# 绘制信号
plt.figure(figsize=(14, 5))
librosa.display.waveplot(x, sr=sr)

# 放大 - Zooming in
n0 = 9000
n1 = 9100
plt.figure(figsize=(14, 5))
plt.plot(x[n0:n1])
plt.grid() 
# 


创建音频信号

'''

>> 创建音频信号-  audio signal
    >>创建一个 220Hz的 音频信号
        >> 音频信号 audio signal
            >> 是一个 -Numpy 数组 - Numpy array,

'''
import numpy as np

sr = 22050 # 采样率 -sample rate
T = 2.5    # 秒 - seconds
t = np.linspace(0, T, int(T*sr), endpoint=False) # 时间变量 time variable
x = 0.5*np.sin(2*np.pi*220*t)# 正弦波 - sine wave - 220 Hz
ipd.Audio(x, rate=sr) # load a NumPy array

保存音频数据

librosa.output.write_wav('Audio/tone_440.wav', x, sr)

录制语音数据

'''

>> 錄製語音檔 - Audio File 
    >> Audio Recorder

    >> pyaudio
        >> 跨平台音頻輸入/輸出 函式庫
        >> cross-platform audio input/output stream library
        >> https://pypi.org/project/PyAudio/
        >> pip install pyaudio
        
    
    >> Wave
        >> https://pypi.org/project/Wave/    
        >> pip install Wave    
        
'''

import pyaudio
import wave

CHUNK = 1024 
FORMAT = pyaudio.paInt16  # paInt8
CHANNELS = 2 
RATE = 44100  #採樣率 -sample rate
RECORD_SECONDS = 4
WAVE_OUTPUT_FILENAME = "Audio/output10.wav"

p = pyaudio.PyAudio()

stream = p.open(format=FORMAT,
                channels=CHANNELS,
                rate=RATE,
                input=True,
                frames_per_buffer=CHUNK) # buffer

print("* recording")

frames = []

for i in range(0, int(RATE / CHUNK * RECORD_SECONDS)):
    data = stream.read(CHUNK)
    frames.append(data) # 2 bytes(16 bits) per channel

print("* done recording")

stream.stop_stream()
stream.close()
p.terminate()

wf = wave.open(WAVE_OUTPUT_FILENAME, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(p.get_sample_size(FORMAT))
wf.setframerate(RATE)
wf.writeframes(b''.join(frames))
wf.close()

去除静音

aa ,bb = librosa.effects.trim(samples, top_db=30)

语音识别工具包

'''

    >> adjust_for_ambient_noise()方法
        >> 讀取文件流的第一秒並將識別器校準到音頻的噪聲水平
            >>  file stream and calibrates 
        >> 因此,在調用record()捕獲數據之前
            >> 將消耗該部分流 stream

    >> adjust_for_ambient_noise()
        >> 使用duration- keyword argument 關鍵字 參數
            >> 調整用於分析的時間範圍
        >> 此參數採用以秒為單位的數值,默認設置為1
            >> 嘗試將此值降低到0.5


'''

import librosa
import IPython.display as ipd

y1, sr1 = librosa.load('audio_files\jackhammer.wav')
ipd.Audio(y1, rate=sr1)

with jackhammer as source:
    r.adjust_for_ambient_noise(source, duration=0.5)
    audio = r.record(source)

r.recognize_google(audio)

'''

>> 嘈雜的文件時,查看實際的API響應會很有幫助
    >> show_all關鍵字參數設置為True
    >> 來執行此recognize_google()操作

    >>  noisy files, it can be helpful to see the actual API response. Most APIs return a JSON string containing many possible transcriptions 
    >> recognize_google() 返回一個字典,其中的鍵
        >> recognize_google() returns a dictionary with the key 

'''

示例代码

import time
import librosa
# 计算音频之间的距离
from dtw import dtw
import librosa.display
from scipy.spatial.distance import cdist
import numpy 
print(numpy.__version__)
import IPython.display as ipd
import matplotlib.pyplot as plt


x, sr = librosa.load('./sounds/10.wav')
ipd.Audio(x, rate=sr)

# 显示声谱图
plt.figure(figsize=(15, 5))
librosa.display.waveshow(x,sr,alpha=0.8)

# 显示频谱图,得到mfcc
plt.subplot(1, 2, 1)
mfcc1 = librosa.feature.mfcc(x, sr)
librosa.display.specshow(mfcc1)

语音处理阶段

获取音频数据

kaldi框架中内置的训练样本

小型数据,TIMIT

中型数据,WSJ

大型数据,Librispeech

获取音频数据对应的文本数据:OpenSLR网站下载数据

查看音频

查看音频

import librosa
audio_path = 'Audio/T08-violin.wav'
x , sr = librosa.load(audio_path)
# x
array([-0.00202265, -0.00320533, -0.00137628, ...,  0.00089052,
        0.00087279,  0.0009666 ], dtype=float32)
# sr 采样点个数
22050

播放音频

import IPython.display as ipd
ipd.Audio(audio_path)

数据预处理

去除噪音

去除静音

提取特征

MFCC

FBank

Linear

CMVN

数据增强

噪声增强

使用噪声音频文件,添加到原音频文件中,使得音频文件有噪声背景。

偏移增强

对音频的前端部分或后端部分进行裁剪补0,类似图片随机裁剪。

语速增强

通过对音频的语速调整来达到增强的效果。

SpecAugment

一种新提出的针对音频特征做数据增强的方法,有效提高了模型的鲁棒性。

音量增强

通过改变音频的语速来达到增强的效果。

数据分集

  • 训练数据
    • 用于训练模型的参数
  • 开发数据
    • 用于直到训练配置参数和调节解码配置参数,以便优化模型训练过程和配置解码器
  • 测试数据
    • 测试数据则用于测试模型的性能

一般会按照8 1 1 或者 8.5 1 0.5 来划分

数据质量比较高:初始化

数据质量比较差:更接近实际应用,适合模型调优

数据下载和解压

# 设置保存原始数据的位置
data=/path/to/data/storage

# 定义数据下载的网址,默认使用OpenSLR的官网
data_url=http://www.openslr.org/12
lm_url=http://www.openslr.org/11

# 下载之前要判断是否已经存在下载好的资源
# 如果存在则判断资源是否完整
# 否则,才开始下载
# 下载指定dev-clean子集
local/download_and_untar.sh ${data} ${data_url} dev-clean





数据预处理

作用:将原始数据转为kaldi通用脚本可以处理的格式。

因为格式不同的数据库原始格式不同,所以要给每个数据库单独写预处理脚本

# 将原始数据转为kaldi数据文件夹
# 遍历每个数据文件夹:dev test train
for part in dev-clean test-clean dev-other test-other train-clean-100;do
	# 开始处理每个文件
	# todo 参数:数据下载的路径  数据处理后文件存储的目录:数据文件夹,符合Kaldi通用脚本规范
		
done

环境检查
# 在处理之前要检查必要的工具是否安装、目标文件和文件夹是否存在,依赖文件是否存在
if !which flac >&/dev/null; then
	echo "Please install 'flac' on All worker nodes! "
	exit 1
fi

[ ! -d $src ] && echo "$0: no such directory $src " && exit 1;
[ ! -d $spc_file ] && echo "$0: no such directory $spc_file " && exit 1;

生成表单文件

标准文件

text
utt2spk
spk2utt
wav.scp
# 还有更多的
spk2gender
utt2dur

数据检查
utils/validate_data_dir.sh

语言文件准备

exp # 生成声学模型的文件 HCLG.fst
mfcc # 经过cmvn后的,生成声学特征的文件夹

dev  # 生成声学模型需要的文件
lang  # 生成L.fst
lang_test  # 生成G.fst的文件夹
local  # 存放本地需要准备的文件,保存dict,以及kaldi标准文件,语言模型需要准备的文件
test  # 生成声学模型需要的文件
train # 生成声学模型需要的文件

# local文件夹下
├── dict # 音素词典文件夹
│   ├── extra_questions.txt
│   ├── lexiconp.txt
│   ├── lexicon.txt
│   ├── nonsilence_phones.txt
│   ├── optional_silence.txt
│   └── silence_phones.txt
├── lang # 
│   ├── align_lexicon.txt
│   ├── lexiconp_disambig.txt
│   ├── lexiconp.txt
│   ├── lex_ndisambig
│   └── phone_map.txt
├── lm # 语言模型
│   ├── 3gram-mincount
│   │   ├── config.0
│   │   ├── ...
│   │   ├── config.diff_1
│   │   ├── ...
│   │   ├── config.get_ngrams
│   │   ├── configs
│   │   │   ├── config.1.0.0
│   │   │   ├── config.1.-0.25
│   │   │   ├── config.1.0.35
│   │   │   ├── ...
│   │   ├── heldout_ngrams.gz
│   │   ├── lm_unpruned.gz # 语言模型
│   │   ├── ngrams_disc.gz
│   │   ├── ngrams.gz
│   │   ├── perplexities
│   │   │   ├── 1.0.0
│   │   │   ├── ...
│   │   │   ├── alpha.1
│   │   │   ├── ...
│   │   ├── perplexity
│   │   └── tmpdir
│   │       ├── 391220.2.gz
│   │       └── 391220.3.gz
│   ├── text.no_oov
│   ├── train.gz
│   ├── unigram.counts
│   ├── word.counts
│   ├── wordlist.mapped
│   └── word_map
└── train # kaldi标准文件夹
    ├── spk2utt
    ├── text
    ├── transcripts.txt
    ├── utt2spk
    ├── utt2spk_all
    ├── utt.list
    ├── wav.flist
    ├── wav.scp
    └── wav.scp_all


生成音素图L.fst

准备dict文件夹
├── extra_questions.txt
├── lexiconp.txt
├── lexicon.txt
├── nonsilence_phones.txt
├── optional_silence.txt
└── silence_phones.txt

需要脚本

local/aishell_prepare_dict.sh ./wavdata/resource_aishell/lexicon.txt

生成L.fst
utils/prepare_lang.sh data/local/dict "<SPOKEN_NOISE>" data/local/lang data/lang

├── L_disambig.fst
├── L.fst # 音素图 或者 phone图
├── oov.int # 集外词索引
├── oov.txt # 集外词
├── phones
│   ├── align_lexicon.int
│   ├── align_lexicon.txt
│   ├── context_indep.csl
│   ├── context_indep.int
│   ├── context_indep.txt
│   ├── disambig.csl
│   ├── disambig.int
│   ├── disambig.txt
│   ├── extra_questions.int
│   ├── extra_questions.txt
│   ├── nonsilence.csl
│   ├── nonsilence.int
│   ├── nonsilence.txt
│   ├── optional_silence.csl
│   ├── optional_silence.int
│   ├── optional_silence.txt
│   ├── roots.int
│   ├── roots.txt
│   ├── sets.int
│   ├── sets.txt
│   ├── silence.csl
│   ├── silence.int
│   ├── silence.txt
│   ├── wdisambig_phones.int
│   ├── wdisambig.txt
│   ├── wdisambig_words.int
│   ├── word_boundary.int
│   └── word_boundary.txt
├── phones.txt
├── topo
└── words.txt


生成单词图G.fst

准备环境

# 安装srilm
ngram
# 安装kaldi_lm
/root/data/kaldi/tools/kaldi_lm/train_lm.sh
# 需要把kaldi_lm添加到系统环境中,可以修改env.sh文件进行配置。

准备文件

text=data/local/train/text
lexicon=data/local/dict/lexicon.txt
# 通过 local/aishell_data_prep.sh ./wavdata/data_aishell/wav  ./wavdata/data_aishell/transcript 生成

生成文件

lm_unpruned.gz

需要脚本

local/aishell_train_lms.sh

生成G.fst
utils/format_lm.sh data/lang data/local/lm/3gram-mincount/lm_unpruned.gz \
    data/local/dict/lexicon.txt data/lang_test

├── G.fst
├── L_disambig.fst
├── L.fst
├── oov.int
├── oov.txt
├── phones
│   ├── align_lexicon.int
│   ├── align_lexicon.txt
│   ├── context_indep.csl
│   ├── context_indep.int
│   ├── context_indep.txt
│   ├── disambig.csl
│   ├── disambig.int
│   ├── disambig.txt
│   ├── extra_questions.int
│   ├── extra_questions.txt
│   ├── nonsilence.csl
│   ├── nonsilence.int
│   ├── nonsilence.txt
│   ├── optional_silence.csl
│   ├── optional_silence.int
│   ├── optional_silence.txt
│   ├── roots.int
│   ├── roots.txt
│   ├── sets.int
│   ├── sets.txt
│   ├── silence.csl
│   ├── silence.int
│   ├── silence.txt
│   ├── wdisambig_phones.int
│   ├── wdisambig.txt
│   ├── wdisambig_words.int
│   ├── word_boundary.int
│   └── word_boundary.txt
├── phones.txt
├── topo
└── words.txt


生成上下文相关图C.fst

合成CLG.fst

生成HMM图H.fst

准备文件
utt2spk
spk2utt 
wav.scp
text

需要脚本,生成kaldi标准文件夹

local/aishell_data_prep.sh ./wavdata/data_aishell/wav \
	./wavdata/data_aishell/transcript

提取声学特征

特征类型有mfcc、fbank,增加基频声学特征提取的mfcc_pitch、fbank_pitch

# 需要conf文件夹
# 需要脚本 
# 在steps/文件夹下
steps/make_fbank_pitch.sh.sh data/train

CMVN倒谱均值方差归一化

需要脚本

steps/compute_cmvn_stats.sh data/train exp/make_mfcc/train mfcc

# 检测文件
utils/fix_data_dir.sh data/train

合成HCLG.fst

需要脚本


模型训练阶段

解码器

ctc_greedy(贪心解码策略)
贪婪搜索为CTC解码算法中,最简单的一种解码方式。贪心解码策略是在每一步选择概率最大的输出值,然后删除连续相同的字符,这样就可以得到最终解码的输出序列,接着使用词汇表查找字符,把序列转换为字符,得到最终语音识别结果。

ctc_ beam_search(集束搜索解码)

贪心解码策略的性能非常受限,这种方法忽略了一个输出可能对应多个结果,只能找出概率最大的路径。通常情况下,有些读音相近的错误字符,却得到最大的概率值。这种情况下的话,集束解码策略利用概率相近的字符,求出多个候选解码路径,后续可以利用语言模型来进一步优化搜索的结果,最终得到一个更优的识别结果。.

环境部署阶段

关键词搜索与唤醒

常用框架-kaldi

kaldi环境配置

下载

git clone --depth 1 https://github.com/kaldi-asr/kaldi.git
git clone  https://github.com/kaldi-asr/kaldi.git

安装编译依赖库

cd kaldi
tools/extras/check_dependencies.sh

注意:根据提示安装相关依赖工具

make -j 10

安装第三方工具

  • OpenFst:
    • kaldi使用FST作为状态图的表现形式,期待吗依赖OpenFst中定义的FST结构及一些基本操作,因此OpenFst对于Kaldi的编译是不可或缺的,安装方法如下
    • 需要g++ 11
cd tools
make openfst		
  • cub:
    • cub是NVIDIA官方提供的CUDA核函数开发库,是目前Kaldi编译的必选工具,安装方法如下
cd tools
make cub		
  • Sph2pipe:
    • 这个工具是用来对SPH音频格式进行转换的,使用LDC数据的示例都要用到这个工具
cd tools
make sph2pipe	
  • ITSTLM/SRILM/Kaldi_lm:
    • 这是三个不同的语言模型工具,不同的示例使用不同的语音模型工具
cd tools
extras/install_irstlm.sh		
extras/install_srilm.sh
extras/install_kaldi_lm.sh

  • 其中安装SRILM时有两点需要注意:

    • 第一,SRILM用于商业用途不是免费的,需要到SRILM网站注册、接收许可协议,并需要命名为srilm.tgz,放到tools文件夹下

    • 第二,STILM的安装依赖lbfgs库,这个库的安装方法是

    • cd tools
      extras/install_liblbfgs.sh
      
  • OpenBLAS/MKL

    • kaldi的最新版本已经选用MKL作为默认的矩阵运算库,如果需要手工安装OpenBLAS或者MKL,方法如下

    • cd tools
      extras/install_openblas.sh
      

编译kaldi

cd src
./configure --help # 查看相关配置

# 如果编译目的实在服务器上搭建训练环境,推荐使用编译方式
./configure --shared
make #单线程编译
make -j 4 # 多线程编译

# 如果只有cpu运算,则需要在配置时加入如下选项
./configure --share --use-cuda=no
# 如果ARMv8交叉编译,则使用如下编译方式,前提是armv-8-rpi3-linux-gnueabihf工具链是可用的,同时要求OpenFst和ATLAS使用armv8-rpi3-linux-gnueabihf工具链编译并安装到/opt/cross/armv8hf
./configure --static --fst-root=/opt/cross/armv8hf --atlas-root=/opt/cross/armv8hf -host=armv8-rpi3-linux-gnueabihf
# 如果为ARM架构的Android编译,则需要加上--android-includes这个选项,因为Android NDK提供的工具链可能没有吧C++的stdlib头文件加入交叉编译路径中
./configure --static --openblas-root=/opt/cross/arm-linux-androideabi --fst-root=/opt/cross/arm-linux-androideabi --fst-version=1.4.1 --android-incdir=/opt/cross/arm-linux-androideabi/sysroot/usr/include --host=arm-linux-androideabi

运行配置工具会在src文件夹在生成kaldi.mk文件,这个文件在编译过程中会被各个子目录的编译文件引用。

使用GPU要指定cuda的版本所在路径

./configure --shared  --cudatk-dir=/usr/local/cuda-11.6
 make -j clean depend; make -j <NCPU>

测试编译是否成功

# 如果kaldi代码做了修改,则可以使用如下选项来确定代码能够运行:
make test # 运行测试代码
make valgrind # 运行测试代码,检查内存泄漏
make cudavalgrinda # 运行GPU矩阵和测试代码,检查内存泄漏

重新编译

make clean
make depend
make

kaldi并没有提供类型make install 的方式把所有的编译结果复制到同一个指定地点,编译结束之后,生成的可执行文件都存放在各自的代码目录下,如:bin、featbin,可以在环境变量PATH中添加这些目录以方便调用Kaldi工具

配置并行环境

脚本工具

utils/run.pl

这个Perl脚本的作用是多任务地执行某个程序,这是一个非常方便的工具,可以独立于kaldi使用

utils/run.pl JOB=1:8 ./tmp/log.JOB.text echo "this is the job JOB"

kaldi流程

准备数据

四个标准文件

wav.scp

utt2spk

spk2utt

text

  • 训练集和测试集所在路径-data/test_yesno和data/train_yesno

  • test_yesno

    • spk2utt

      • 记录说话人说的每个ID

      • 说话人id->语音id

      • global 0_0_0_0_1_1_1_1 0_0_0_1_0_0_0_1 0_0_0_1_0_1_1_0 0_0_1_0_0_0_1_0 0_0_1_0_0_1_1_0 0_0_1_0_0_1_1_1 0_0_1_0_1_0_0_0 0_0_1_0_1_0_0_1 0_0_1_0_1_0_1_1 0_0_1_1_0_0_0_1 0_0_1_1_0_1_0_0 0_0_1_1_0_1_1_0 0_0_1_1_0_1_1_1 0_0_1_1_1_0_0_0 0_0_1_1_1_0_0_1 0_0_1_1_1_1_0_0 0_0_1_1_1_1_1_0 0_1_0_0_0_1_0_0 0_1_0_0_0_1_1_0 0_1_0_0_1_0_1_0 0_1_0_0_1_0_1_1 0_1_0_1_0_0_0_0 0_1_0_1_1_0_1_0 0_1_0_1_1_1_0_0 0_1_1_0_0_1_1_0 0_1_1_0_0_1_1_1 0_1_1_1_0_0_0_0 0_1_1_1_0_0_1_0 0_1_1_1_0_1_0_1 0_1_1_1_1_0_1_0 0_1_1_1_1_1_1_1
        
        
    • text

      • 记录每个ID的文本内容

      • 语音id->语音的内容

        0_0_0_0_1_1_1_1 NO NO NO NO YES YES YES YES
        0_0_0_1_0_0_0_1 NO NO NO YES NO NO NO YES
        0_0_0_1_0_1_1_0 NO NO NO YES NO YES YES NO
        
        
    • utt2spk

      • 记录每个ID的说话人信息

      • 语音id -> 说话人id

      • 0_0_0_0_1_1_1_1 global
        0_0_0_1_0_0_0_1 global
        0_0_0_1_0_1_1_0 global
        
        
    • wav.scp

      • 记录每个ID的音频文件路径

      • 语音id -> 语音id所对应的文件路径

      • 0_0_0_0_1_1_1_1 waves_yesno/0_0_0_0_1_1_1_1.wav
        0_0_0_1_0_0_0_1 waves_yesno/0_0_0_1_0_0_0_1.wav
        0_0_0_1_0_1_1_0 waves_yesno/0_0_0_1_0_1_1_0.wav
        
        
  • train_yesno

    • spk2utt
    • text
    • utt2spk
    • wav.scp

生成的这两个目录使用的是Kaldi的标准数据文件夹格式,每个句子都没有指定了一个唯一的id

kaldi输入输出机制

表单

  • 经过local文件夹中的预处理脚本的处理,原始数据文件被处理成kaldi的标准格式——表单(table)
  • 表单的本质是若干元素的集合,每个元素有一个索引
    • 索引必须是一个不包含空格的非空字符串
    • 而元素的类型取决于创建表单时的定义
      • 例如:摇窗机一个音频表单,那么元素的内容就是音频文件名:aduio1 /音频/audio1.wav
      • audio1 就是索引,后面的路径就是表单元素
  • 在kaldi中,所有的数据文件都是以表单形式存储的,比如文本、音频特征、特征变换矩阵
  • 表单可以存储在磁盘上,也可以存储在内存中[以管道的形式]
  • 表单有两种
    • 列表(Script-file) 表单
    • 存档(Archive)表单
  • 一套特有的输入输出机制

列表表单

  • 作用

    • 列表表单 用于索引存储于磁盘或内存中的文件

    • 在Kaldi通用脚本中,这类表单默认以.scp为扩展名,但对于Kaldi可执行程序来说并没有扩展名的限制

    • file1_index /path/to/file1
      file2_index /path/to/file2
      
      • 空格之前的字符串是表单索引,空格之后的内容是文件定位符,用于定位文件
    • 文件定位符
      • 可以是磁盘中的物理地址

      • 也可以是以管道形式的内存地址

      • file1_index gunzip -c /path/to/file1.gz |
        file2_index gunzip -c /path/to/file2.gz |
        
        • 上面的示例中,第一个空格之后的内容表示wav格式的音频文件的压缩包酱油gunzip进行解压并传输到内存管道中
        • 而kaldi的可执行文件将从管道中读取解压之后的文件内容并执行后续操作
        • 这样做可以节省磁盘空间
      • 偏移定位符
        • 如果文件定位符执行的是二进制的kaldi存档文件,则还可以增加偏移定位符
        • 用于指向该二进制文件中从某一个字节开始的内容
        • 扩展偏移定位符:
          • 通过切片操作指定读取的行和列的范围
  • 从管道文件和偏移定位符可以看出,文件定位符定义的“文件”,本质是上一个存储地址,这个地址可能是一个外部磁盘的物理地址,也可能是管道指向的内存地址,还可能是从一个磁盘文件中的某个字节开始的地址。

  • 无论哪种形式,列表表单的元素一定是“文件”

存档表单

  • 存档表单用于存储数据,数据可以是文本数据,也可以是二进制数据

  • 这类表单通常默认以.ark为扩展名,但没有严格限制

  • 存档表单没有行的概念,存档表单的元素直接没有间隔符,对于文本类型的存档文件来说,需要保证每个元素都以换行符结尾

    • text_index1 this is first text\text_index2 this is second text\n
      
二进制类型存档表单中

索引以每个字符对于的ASCII值存储,然后是一个空格,接下来是“\0B”,这个标志位是区别文本和二进制内容 的重要标识

紧接着是二进制的表单元素,直至下一个索引

可以通过内容本身判断这个元素占用的空间大小,这个信息保存在一段文件头中

binary_index1 \0B<header><content>binary_index2 \0B<header><content>

<header>中可以包含特征的帧数,维度,声学特征类型,占用字节数和释放压缩等信息

读写声明符

  • 读声明符和谐声明符定义了可执行程序处理输入表单文件和输出表单文件的方式,他们都是有两部分组成

    • 表单属性(specifier option)
      • scp:列表表单
      • ark,t ; ark :存档表单
    • 表单文件名 ( xfilename)
      • path/file1
    • 这两部分都冒号组合在一起

    • 他们可以接受的表单文件名如下:

      • 磁盘路径
        • 对于读声明符,指定一个存在于磁盘的文件路径
        • 对于写声明符,制定一个希望输出的文件路径
      • 标准输入
        • 对于读声明符和写声明符,如果指定 “-” 为表单文件名,则意味着要从标准输入获取文件内容,或者将输出打印到标准输入
      • 管道符号
        • 如果在某个可执行程序后边加上管道符号,则意味着要将输出送入管道,由管道后边的可执行程序接收
        • 如果在某个可执行程序前面加上管道符号,则意味着要从管道中获取输入
      • 磁盘路径夹偏移定位符
        • 这种方式只能用于读声明符,用户告知可执行程序从文件的某个字节开始读取
# 参数1:
# 	读声明符 
#		表单属性:	 	scp:
#		表单文件名:		path/file1
# 参数2:写声明符 ark,t:path/utt2dur
cmd scp:path/file1 ark,t:path/utt2dur

表单属性

写属性
  • 表单类型:标识符为scp或ark,这个属性定义了输出表单文件的类型

    • scp是列表表单

    • ark是存档表单

    • 同时输出一个存档表单和一个列表表单,必须ark在前scp在后

    • ark,scp:/path/archiver.ark,/path/archive.scp
      
  • 二进制模式:标识符为b,表示将输出表单保存为二进制文件,只对输出存档表单生效

  • 文本模式:标识符为t,表示输出的表单保存为文本文件,只对输出存档表单生效

  • 刷新模式:标识符为f,表示刷新,标识符为nf,表示不刷新,用于确定在每次写操作后是否刷新数据流,默认是刷新

  • 宽容模式:标识符为p,只对输出列表生效。在同时输出存档表单和列表表单时,如果表单的某个元素对应的存档内容无法获取,那么在列表表单中直接跳过这个元素,不提示错误

读属性
  • 表单类型:标识符为scp或ark,输入表单文件的类型,无法在输入时同时定义一个存档表单和列表表单,只能输入一个表单文件,当同时输入多个表单时,可以通过多个读声明符实现

  • 单次访问:标识符为o,标识符no为多次访问,告知可执行程序在读入表单中每个索引值出现一次,不会出现多个元素使用同一个索引的情况

  • 有序表单:标识符为s,告知可执行程序元素的索引是有序的,ns是无序的

  • 有序访问:标识符是cs或ncs,字面含义与有序表单属性的含义类似。这个属性的含义是,告知可执行程序表单中的元素将被顺序访问

  • 二进制模式:标识符为b,表示将输出表单保存为二进制文件,只对输出存档表单生效

  • 文本模式:标识符为t,表示输出的表单保存为文本文件,只对输出存档表单生效

  • 刷新模式:标识符为f,表示刷新,标识符为nf,表示不刷新,用于确定在每次写操作后是否刷新数据流,默认是刷新

  • 宽容模式:标识符为p,只对输出列表生效。在同时输出存档表单和列表表单时,如果表单的某个元素对应的存档内容无法获取,那么在列表表单中直接跳过这个元素,不提示错误

使用方法

可以把命令输出到管道,通过管道作为表单文件

# scp echo 'utt1 data/103-1240-0000.wav |' 读声明符
# echo 'utt1 data/103-1240-0000.wav' 输出一个表单
# 表单组成: "scp:[磁盘路径、标准输入-、管道符号|、磁盘路径夹偏移定位符]"  
# 表单组成: "ark:[磁盘路径、标准输入-、管道符号|、磁盘路径夹偏移定位符]" 
wav-to-duration "scp:echo 'utt1 data/103-1240-0000.wav' |" ark,t:-

多个读入文件,和多个输出文件,读入文件只能是单个类型的表单,输出可以是多种类型的表单

# 读声明符1 "ark:compute-mfcc scp:wav1.scp ark:- |",
# 读声明符2 "ark:compute-pitch scp:wav2.scp ark:- |"
# 写声明符:输输出多个文件feats.ark,feats.scp:ark,scp:feats.ark,feats.scp
paste-feats "ark:compute-mfcc scp:wav1.scp ark:- |" "ark:compute-pitch scp:wav2.scp ark:- |" ark,scp:feats.ark,feats.scp

数据文件

给出了声学模型训练数据的描述,其中文本标注是以词为单位的

列表类数据表单

  • 句子音频表
    • 句子音频表单的文件名为wav.scp
    • 表单元素为音频文件或者音频处理工具输出的管道,每个元素可以表示一个切分后的句子,也可以表示包含多个句子的为切分整段音频
    • 例如:说话人1录制的一段阅读段落
      • 这种未切分的,为分段的音频表单需要配合切分表单Segments使用
  • 声学特征表单
    • 声学特征表单的文件名为feats.scp
    • 表单元素保存的是声学特征,每个元素表示一个句子。
  • 普特征归一化表单
    • 文件名称:cmvn.scp
    • 通过声学特征处理脚本提取的谱归一化系数文件,其归一化可以以句子为单位,也可以以说话人为单位
  • VAD信息表单
    • vad.scp
    • 表单元素为用Kaldi的compute-vad工具提取的vad信息文件。
    • 这个表单有提取vad的通用脚本生成的,以句子为单位

存档类型数据表单

  • 说话人映射表单

    • 文件名为:utt2spk、spk2utt

    • 存放的是文本内容,一个句子到说话的映射,以及说话人到句子的映射

    • 103-1240-0000 103-1240
      103-1240-0001 103-1240
      103-1240-0002 103-1240
      103-1240-0003 103-1240
      
      ...
      
  • 标注文本表单

    • 标注文本表单的文件名:text
    • 其内容是每一句音频的标注内容,通常保存为一个文本类型的存档表单
    • 该文件保存的应当是文本归一化之后的内容,所谓的归一化,就是保证文本中的词都在发音字典和语言模型的此表中,而未出现的词都将被当做未知词。对于英语,通常要将所有字母统一成大写和小写。对于中文,最基本的要求是完成文本分词。
  • 切分信息表单

    • 切分信息表单文件名为:segments

    • kaldi处理的数据是以句子为单位,如果音频文件没有按句切分,就需要将音频中的每一句的起止时间记录在segments文件中。

    • 103-1240-0000 103-1240 2.81 6.41
      103-1240-0001 103-1240 9.74 12.62
      103-1240-0003 103-1240 15.27 24.23
      ...
      
      • 后两部分表示句子的起始时间和结束时间,以秒为单位
  • VTLN相关系数表单

    • VTLN是一种说话人自适应技术
    • 在Kaldi的数据文件中,有三个文本类型的存档文件与此相关,分别是:
      • 说话人性别映射(spk2gender) 索引是说话人,内容是性别标识f:女性,m男性
      • 说人话卷曲因子映射(spk2warp) 索引是说话人,内容是卷曲因子,用一个0.5~1.5的浮点数表示,
      • 句子卷曲映射(utt2warp) 索引是句子,内容与spk2warp内容相同
  • 句子时长表单

    • 文件名为:utt2dur,表单可以由一个通用脚本生成,
    • 句子为索引,内容是每个句子的时长,以秒为单位

数据文件夹处理脚本

在kaldi的数据文件夹中常见的表单内容,其中需要自行准备,保存wav.scp、text和utt2spk,其它的文件都可以通过kaldi通用脚本生成

脚本名称功能简介
combine-data.sh将多个数据文件夹合并为一个,并合并对应的表单
combine_short_segments.sh合并原来文件夹的短句,创建一个新的数据文件夹
copy_data_dir.sh复制原文件夹,创建一个新的数据文件夹,可以指定说话人或句子的前缀。后缀,复制一部分数据
extract_wav_segments_data_dir.sh利用原文件夹中的分段信息,切分音频文件,并保存为一个新的 数据文件夹
fix_data_dir.sh为原文件夹保留一个备份,删除没有同时出现在多个表单中的句子,并修正排序
get_frame_shift.sh获取数据文件夹的帧移信息,打印到屏幕
get_num_frames.sh获取数据文件夹的总帧移信息,打印到屏幕
get_segments_for_data.sh获取音频时长信息,转为segments文件
get_utt2dur.sh获取音频时长信息,生成 utt2dur 文件
limit feature dim.sh根据原数据文件夾中的 feats. scp,取其部分维度的声学特征,保存到新创建的数据文件夹中
modify_speaker _info.sh修改原数据文件夹中的说话人索引,构造“伪说话人”,保存到新创建的数据文件夹中
perturb_ data_ dir _speed.sh为原数据文件夹创建一个速度扰动的副本
perturb data dir volume.sh修改数据文件夹中的 wav.scp 文件,添加音量扰动效果
remove_ dup_utts.sh刪除原数据文件夹中文本内容重复超过指定次数的句子,保存到新创建的数据文件夹中
resample data dir.sh修改数据文件夹中的 wav.scp 文件,修改音频采样率
shift feats.sh根据原数据文件夹中的 feats.scp 进行特征偏移,保存到新创建的数据文件夹中
split data.sh将数据文件夹分成指定数目的多个子集,保存在原数据文件夹中以 split 开头的目录下
subsegment data dir.sh根据一个额外提供的切分信息文件,将原数据文件夹重新切分,创建一个重切分的数据文件夹
subset data dir.sh根据指定的方法,创建一个原数据文件夹的子集,保存为新创建的数据文件夹
validate data dir.sh检查给定数据文件夹的内容,包括排序是否正确、 元素索引是否对应等

表单索引一致性

  • 表单索引分为三类:句子、音频、说话人
    • 音频索引
      • 的作用是定位数据集中的音频文件,音频wav.scp一定是以音频为索引的。在kaldi的帮助文件中,音频索引被称为Recording identifier。这个索引对应的是一个录音文件,如果这个录音文件已经被切分为句子,则音频索引等同于句子索引。
    • 句子索引
      • 在kaldi的帮助文件中被称为Utterance identifier,它定义了kaldi处理的数据的基本单元。大部分表单时以句子为索引的,其中最重要的就是text、utt2spk和feats.scp.在完成声学特征提取之后,音频索引就不再被使用了,这个声学模型训练过程都是使用上述三个表单完成的,因此这些表单的索引需要保持一一致
    • 说话人索引
      • 这个索引并不一定对应一个真正的录音人,事实上,在kaldi的语音识别示例中,大部分都没有使用录音人作为说话人。
      • 以说话人为索引的 表单包括spk2utt和cmvn.scp
  • 说话人信息在自适应声学建模中使用,用来增强识别系统对不同说话人的适应能力,例如倒谱归一化(CMVN)。对CMVN系数估计和使用,kaldi的可执行程序有两种模式,一种是每句估计一套归一化系数,另一种是一个说话人使用一套归一化系数。在官方给出的训练脚本中,cmvn.scp默认安装spk2utt给出的映射统计每个说话人的归一化系数。

语言模型相关文件

在开始训练声学模型之前,需要定义发音词典、音素集和HMM的结构

在进行音素上下文聚类的时候,还可以通过制定聚类问题的方式融入先验知识。

生成词典文件夹

包括了发音词典与音素集,一般保存文件名为:dict

在下载数据阶段,还下载了预先整理好的发音词典和语言模型,以及语言模型的训练数据,

用于生成L.fst,,发音词典的fst:四个文件

lexiconp.txt、nonsilence_phones.txt、optional_silence.txt、silence_phones.txt

# 生成dict文件夹
# lexiconp.txt 概率音素词典 
# lexicon.txt  音素词典
# lexicon_words.txt  音素词典
# nonsilence_phones.txt  非静音音素
# optional_silence.txt  可选音素 sil
# silence_phones.txt 静音音素 sil
local/prepare_dict.sh
  • lexicon.txt

     <SIL> SIL
     !SIL SIL 表示静音,其发音是静音音素
     <UNK> SPN 表示噪声和集外词,其发音都是SPN
     <SPOKEN_NOISE> SPN
     YES Y
     NO N
    
    给出了YES、NO和<SIL>这三个单词的音素序列,其中、<SIL>是一个特殊单词,表示静音 
    
  • lexicon_nosil.txt

    • 和lexicon.txt文件相同,只是去掉了<SIL>行

    • YES Y
      NO N	
      
  • phones.txt

    • 给出了音素集

    • SIL
      Y
      N
      
  • silence_phones.txt

    • 所有可以用来表示无效语音内容的音素

    • SIL
      SPN # 表示有声音但是无法识别的声音片段
      
  • optional_silence.txt

    • 用于填充词间静音的音素,选择用SIL这个音素表示词间静音。

生成语言文件夹

通过词典文件夹,生成语言文件夹,L.fst

L_disambig.fst # 增加消歧之后的发音词典生成的FST
L.fst # 增加消歧之前的发音词典生成的FST
oov.int  # 集外词
oov.txt # 集外词
phones # 定义了关于音素的各种属性,音素上下文无关、聚类时共享根节点
phones.txt # 音素索引 
topo # HMM拓扑结构
words.txt # 词索引

phones.txt和words.txt,分别定义了音素索引和词索引

集外词:无法被识别的

  • 静音词、噪声词

  • !SIL SIL 表示静音,其发音是静音音素
    <UNK> SPN 表示噪声和集外词,其发音都是SPN
    <SPOKEN_NOISE> SPN
    

数据文件夹生成后,就可以根据其中的文本信息,以及事先准备好的发音词典等文件,生成语言模型文件夹

# 生成L.fst
utils/prepare_lang.sh --position-dependent-phones false data/local/dict “<SIL>” data/local/lang data/lang 

生成语言模型

通过语料text,每句话的标注文本文件,生成语言模型,即3-ngram

  • task.arpabo

    • 是语音模型

    • 可以通过第三方工具和语料直接得到

    • \data\
      ngram 1=4
      
      \1-grams:
      -1      NO
      -1      YES
      -99 <s>
      -1 </s>
      
      

通过语言模型生成G.fst

准备文件

text

BAC009S0002W0122 而 对 楼市 成交 抑制 作用 最 大 的 限 购
BAC009S0002W0123 也 成为 地方 政府 的 眼中 钉
BAC009S0002W0124 自 六月 底 呼和浩特 市 率先 宣布 取消 限 购 后
BAC009S0002W0125 各地 政府 便 纷纷 跟进
BAC009S0002W0126 仅 一 个 多 月 的 时间 里
BAC009S0002W0127 除了 北京 上海 广州 深圳 四 个 一 线 城市 和 三亚 之外
BAC009S0002W0128 四十六 个 限 购 城市 当中
BAC009S0002W0129 四十一 个 已 正式 取消 或 变相 放松 了 限 购
BAC009S0002W0130 财政 金融 政策 紧随 其后 而来
BAC009S0002W0131 显示 出 了 极 强 的 威力
BAC009S0002W0132 放松 了 与 自 往 需求 密切 相关 的 房贷 政策
BAC009S0002W0133 其中 包括 对 拥有 一 套住 房 并 已 结清 相应 购房 贷款 的 家庭
BAC009S0002W0134 为 改善 居住 条件 再次 申请 贷款 购买 普通 商品 住房
BAC009S0002W0135 银行 业金 融机 构 执行 首套 房贷 款 政策
...

lexicon.txt

SIL sil
<SPOKEN_NOISE> sil
啊 aa a1
啊 aa a2
啊 aa a4
啊 aa a5
啊啊啊 aa a2 aa a2 aa a2
啊啊啊 aa a5 aa a5 aa a5
阿 aa a1
阿 ee e1
阿尔 aa a1 ee er3
阿根廷 aa a1 g en1 t ing2
阿九 aa a1 j iu3
阿克 aa a1 k e4
阿拉伯数字 aa a1 l a1 b o2 sh u4 z iy4
阿拉法特 aa a1 l a1 f a3 t e4
阿拉木图 aa a1 l a1 m u4 t u2
阿婆 aa a1 p o2
...

脚本

# 生成LM
local/prepare_lm.sh

声学分的固有分,即下一个单词出现的概率

通过L.fst和G.fst可以合成LG.fst,音素到词的fst,即输入是音素,输出是词的wfst——加权有限状态机

音素与音素之间也有概率转移,lexconp.txt文件

词与词之间也有概率转移

概率转移即使加权

声学模型相关文件

特征提取

事实上,我们人类的听觉器是通过频域而不是波形来辨别声音的,把声音进行短时傅里叶变换(STFT),就得到了声音的频谱。因此我们以帧为单位,依据听觉感知机理,按需调整声音片段频谱中各个成分的幅值,并将其参数化,得到适合表示语音信号特性的向量,这就是声学特征(Acoustic Feature)

声学特征

把波形分成若干离散的帧,整个波形可以看做是一个矩阵。

波形被分为了很多帧,每一帧都用一个12维的向量表示,色块的颜色深浅表示向量值的大小。

常见声学特征

梅尔频率倒谱系数(MFCCs)是最常见的声学特征

compute-mfcc-feats  # 提取mfcc的脚本

FilterBank也叫FBank,是不做DCT的MFCCs,保留了特征维间的相关性,再用卷积神经网络作为声学模型时,通常选用FBank作为特征

compute-fbank-feats  # 提取fbank的脚本

PLP特征提取字线性预测系数(Linear Prediction Coefficient,LPC)

compute-plp-feats  # 提取plp的脚本

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WUHDTLsZ-1649435769714)(assets/v2-1150511699482f0b4b2bd255bcd024f2_r.png)]

生成声学特征

这是训练声学模型的前提,特征提取需要读取配置文件,默认的配置文件路径是当前调用路径下的conf/mfcc.conf,也可以通过–mfcc-config选项来指定

for x in train_yesno test_yesno;do
	# mfcc 提取音频特征 
	steps/make_mfcc.sh --nj 1 data/$x exp/make_mfcc/$x mfcc
	steps/compute_cmvn_stats.sh data/$x exp/make_mfcc/$x mfcc
	utils/fix_data_dir.sh data/$x
done


特征提取的输出就是声学特征表单和用于保存声学特征的二进制文档

倒谱均值方差归一化

生成cmvn(Cepstral Mean and Variance Normalization,CMVN)

steps/compute_cmvn_stats.sh data/$x exp/make_mfcc/$x mfcc

该表单的元素以说话人为索引,每个方括号内是其对应的倒谱均值方差归一化系数,一个均值归一化,一个是方差归一化,以使得模型的输入特征趋于正态分布,这对于与说话人无关的声学模型建模非常重要。

查看CMVN
copy-matrix ark:mfcc/cmvn_train.ark ark,t:- # 查看cmvn 倒谱均值方差归一化

使用特征

特征提取完成之后,可以通过数据文件夹中的声学特征表单feats.scp和倒谱均值方差归一化系数表单cmvn.scp获取归一化的特征。在训练声学模型时,通常还要对特征做更多的扩展,例如kaldi的单音子模型训练,在谱归一化的基础上做了差分系数(Delta)扩展

mfcc->cmvn->delta

变换技术

无监督特征变换

无监督特征变换:差分(Delta)、拼帧(Splicing) 和归一化(Normalize)

差分:即在一定的窗长内,计算前后帧的差分特征,补充到当前特征上。

src/featbin/add-deltas scp:data/train/feats.ark \
	ark,scp:data/ train/feats_delta.ark,data/train/feats_delta.scp

拼帧:即在一定的窗长内,将前后若干帧拼接成一帧特征

sec/featbin/splice-feats scp:data/feats.ark \
	ark,scp:data/feats_splice.ark,data/teats_splice.scp

归一化:通常被称为倒谱均值方差归一化,使其符合正太分布。

#估计CMVN系数
src/featbin/compute-cmvn0stats scp:data/train/feats.ark \
	ark,scp:data/train/cmvn.ark,data/train/cmvn.scp
# 应用CMVN进行特征变换
src/featbin/apply-cmvn scp:data/train/cmvn.scp scp:data/train/train/feats.ark \
	ark,scp:data/train/feats_cmvn.ark,data/trian/feats_cmvn.scp

有监督特征变换

有监督特征变换:有监督特征变换借助标注信息,估计一组变换系数,增强输入特征的表征能力,有助于提升声学模型的建模能力。

在语音识别中特征变换矩阵的估计方法主要分为两大类,线性判别分析(LDA)和最大似然线性变换(MLLT)。

LDA

LDA的目的是通过变换来减少同类特征间的方差,增加不同类特征之间 方差,这里的类指的是声学模型的状态。

MLLT

是一类变换技术的统称,

均值最大线性自然回归(MeanMLLR),方差最大线性自然回归(VarMLLR),针对模型参数进行变换

报错半绑定协方差(STC),和特征最大似然线性回归(FMLLR),针对特征进行变换的技术

steps/train_lda_mllt.sh 
steps/train_sat.sh

常用特征类型

在中文语音识别中还常用基频

脚本名作用配置文件(conf文件夹下)
make_mfcc.sh提取mfcc加基频特征mfcc.conf
make_mfcc_pitch.sh提取mfcc加基频特征mfcc.conf pitch.conf
make_mfcc_pitch_online.sh提取mfcc加在线基频特征mfcc.conf,pitch_online.conf
make_fbank.sh提取fbank特征fbank.conf
make_fbank_pitch.sh提取fbank加基频特征fbank.conf,pitch.conf
make_plp.sh提取plp特征plp.conf
make_plp_pitch.sh提取plt加基频特征plp.conf,pitch.conf

在训练时候的特征 和 预测时候的特征是有偏差的,采用GMM-HMM的声学模型,没有NN-HMM的模型泛化能力强。

单音子模型的训练

做好了前面的各项准备工作,就可以开始训练声学模型(Acoustic Model,AM)

基本的模型结构:使用高斯混合模型(GMM)描述单因子(Monophone)发音转台的概率分布函数(PDF)的HMM模型

声学模型基本概念

一个声学模型就是一组HMM,一个HMM的参数是有初始概率,转移概率,观察概率三部分构成。

对于语音识别框架中的声学模型的每一个HMM,都应当定义该HMM中有多少个状态,以及各个状态起始的马尔科夫链的初始化概率,个状态间的转移概率以及每个状态的概率分布函数。

  • 初始概率
    • 一般零初始化概率恒为1
  • 转移概率
    • 预设为固定值,不再训练中更新转移概率
  • 观察概率
    • 声学模型包含的信息主要是状态定义和个状态的观察概率分布
    • 如果用混合高斯模型对观察概率分布建模,那么就是GMM-HMM模型
    • 如果使用神经网络模型对观察概率分布建模,那么就是NN-HMM模型
声学分

根据声学模型,可以计算某一帧声学特征在某一个状态上的声学分(AM score)

指的是该 帧声学特征 对于该 状态的 对数观察概率, 或者成为对数似然值(log-likelihood):

A m S c o r e ( t , i ) = l o g P ( o t ∣ s i ) AmScore(t,i) = logP(o_t|s_i) AmScore(t,i)=logP(otsi)

在上式子中,是第t帧语音声学特征 o t o_t ot在状态 s i s_i si上的声学分

GMM建模

用于GMM建模观察概率分布的函数如下:

l o g P ( o t ∣ s i ) = l o g ( ∑ m = 1 M c i , m e x p ( − 1 2 ( o t − u i , m ) T ( ∑ i , m − 1 ) ( o t − u i , m ) ) ( 2 π ) D 2 ∣ ∑ i , m ∣ 1 2 ) logP(o_t|s_i)=log(\sum^{M}_{m=1}\frac{c_i,_mexp(-\frac{1}{2}(o_t-u_i,_m)^T(\sum^{-1}_{i,m})(o_t-u_i,_m))}{(2\pi)^{\frac{D}{2}}|\sum{}_{i,m}|^{\frac{1}{2}}}) logP(otsi)=log(m=1M(2π)2Di,m21ci,mexp(21(otui,m)T(i,m1)(otui,m)))

一个GMM-HMM模型存储的主要参数为各状态和高斯分类的 u i , m 、 ρ i , m u_{i,m}、\rho_{i,m} ui,mρi,m c i , m c_{i,m} ci,m

查看声学模型
gmm-copy --binary=false final.mdl final.mdl.txt
将声学模型用于语音识别

识别的过程就是语音的特征的序列特征取匹配一个状态图,搜索最优路径。

状态图中有无数条路径,每条路径代表一种可能的识别结果,且都有一个分数,该分数表示语音和该识别结果的匹配程度。

判断标准

判断两条路径的优劣就是比较这两条路径的的分数,分数高的路径更有,即高分路径上的识别结果和声音更匹配。

  • 分数
    • 声学分
      • 声学分则是在识别过程中根据声学模型和待识别语音匹配关系动态计算的,声学模型在语音识别过程中的最主要的就是计算声学分。
    • 图固有分(Graph score)
      • 图固有分主要来源于语言模型概率,同时来源于发音词典的多音词选择概率和HMM模型的转移概率。
      • 这些概率在状态图构建过程中就固定在了状态图中,和待识别的语音无关,因此我们称它为图固有分
模型初始化

这个基础模型的每个状态只有一个高斯分类,在后续的训练过程中,会进行单高斯分量到混合多高斯分量的分裂。

# HMM topo结构
# 声学特征维数
# 初始化声学模型
gmm-init-mono topo 39 mono.mdl mono.tree
对齐

获取帧级别的标注,通过下面的工具

compile-train-graphs # 输出一个状态图
gmm-align # 内部调用了FasterDecoder,解码器来完成对齐
gmm-align-compiled # 对训练数据进行反复对齐
transition模型

transition模型存储于kaldi声学模型的头部

<TransitionModel> 
<TopologyEntry>
# 第一部分
</TopologyEntry>
<Triples>
# 第二部分
<音素索引,HMM状态索引,PDF索引>
</Triples>
</TransitionModel> 
查看transition-state

transition-state对这些状态从0开始编号,

这样就得到了transition-index,把(transition-state,transition-index)作为一个二元组并从1开始编号,该编号就被称为transition-id

$ show-transitions phones.txt mono.mdl

Transition-state 1:phone = a hmm-state=0 pdf=0
Transition-id=1 p=0.75 [self-loop]
Transition-id=2 p=0.25 [0>1]
Transition-state 2:phone = a hmm-state=1 pdf=1
Transition-id=3 p=0.75 [self-loop]
Transition-id=4 p=0.25 [1>2]
Transition-state 3:phone = a hmm-state=2 pdf=2
Transition-id=5 p=0.75 [self-loop]
Transition-id=6 p=0.25 [2>3]

Transition-state 4:phone = a hmm-state=0 pdf=3
Transition-id=7 p=0.75 [self-loop]
Transition-id=8 p=0.25 [0>1]
Transition-state 5:phone = b hmm-state=1 pdf=4
Transition-id=9 p=0.75 [self-loop]
Transition-id=10 p=0.25 [1>2]
Transition-state 6:phone = a hmm-state=2 pdf=5
Transition-id=11 p=0.75 [self-loop]
Transition-id=12 p=0.25 [2>3]

transition-state:可以理解为是fst图的状态节点

transition-id:可以理解为fst的弧

设计transition-id的原因

相比 transition-id, pdfid 似乎是表示 HMM 状态更直观的方式,为什么 Kaldi要定义这样烦琐的编号方式呢?这是考虑到 paf-id 不能唯一地映射成音素,而transition id 可以。如果直接使用 paf-id 构建状态图,固然可以正常解码并得到 pdf-id序列作为状态级解码结果,但难以从解码结果中得知各个pdf-id 对应哪个音素,也就无法得到音素级的识别结果了,因此 Kaldi 使用 transition-id 表示对齐的结果。

GMM模型迭代

声学模型训练需要对齐结果,而对齐过程又需要声学模型,这看起来是一个鸡生蛋蛋生鸡的问题

Kaldi采取了一种更加简单粗暴的方式进行首次对齐,即直接把训练样本按该句的状态个数平均分段,认为每段对应相应的状态

align-equal-compiled # 对齐结果

对齐结果作为gmm-acc-stats-ali的输入。

# 输入一个初始模型:gmm-init-mono得到、
# 训练数据、
# 对齐结果
# 输出用于GMM模型参数更新的ACC文件
gmm-acc-stats-ali 1.mdl scp:train.scp ark:1.ali 1.acc
ACC文件

acc文件存储了GMM在EM训练中所需要的统计量。

生成ACC文件后,可以使用gmm-est工具来更新GMM模型参数

gmm-est

每次模型参数的迭代都需要成对使用这两个工具

gmm-acc-stats-ali
gmm-est

三音子模型训练

单音子作为建模单元的语音识别模型机器训练,在实际使用中,单音子模型过于简单,往往不能达到最好识别性能。

上下文相关的声学模型

Content Dependent Acoustic Model

三音子

描述的是一个音素模型实例取决于实例中心音素、左相邻音素和右相邻音素,共三个音素。

和HMM三状态要区分清楚,一个音素模型实例内部有三个HMM状态组成,在概念上不同的HMM状态用来分别捕捉该音素发音时启动、平滑、衰落等动态变化。

无论是单音子还是三音子,通常使用三状态HMM结构来建模

三音子聚类裁剪

单音子模型到三音子模型的扩展,虽然解决了语言学中协同发音等上下文的问题。但也带来了另一个问题,模型参数数据“爆炸”。

解决办法

将所有的三音子模型放到一起进行相似性聚类,发音相似的三音子被聚类到同一个模型,共享参数,通过人为控制聚类算法最终的类的个数,可以有效的减少整个系统中实际的模型个数,同时又兼顾解决了单音子假设无效的问题。

具体实现:通过决策树算法,将所有需要建模的三音子的HMM状态放到决策树的根节点中,作为基类。

Kaldi中的三音子模型训练流程

和单音子训练流程一样,训练之后用生成的模型对训练数据重新进行对齐,作为后续系统的基础

三音子训练模型的脚本功能又train_deltas.sh完成

steps/train_deltas.sh <num-leaves叶子数量> <tot-gauss高斯数量> <data-dir训练数据> <lang-dir语言词典等资源> <alignment-dir单音子模型产生的对齐文件> <exp-dir生成训练的的三音子模型>

steps/train_deltas.sh 2000 10000 data/train data/lang exp/mono_ali exp/tri

音素聚类

问题集:通过yes、no的形式进行提问

特征

区分性训练思想

语音识别的过程是在解码空间中衡量和评估所有的路径,将打分最高的路径代表的识别结果作为最终的识别结果。传统的最大似然训练是使正确路径的分数尽可能高,而区分性训练,则着眼于加大这些路径直接的打分差异,不仅要使正确路径的分数尽可能的高,还要使错误路径,尤其是易混淆路径的分数尽可能的低,这就是区分性训练的核心思想。

构图与解码

N元文法语言模型:ARPA

从语言模型构建G

词图

词与词之间的跳转,权重是语言模型

# 对APRA格式的语言模型文件解压后,直接输入到arpa2fst程序中,就得到目标G.fst
gunzip -c n.arpa.gz | arpa2fst --disambig-symbol=#0 \
--read-symbol-table=words.txt - G.fst

从发音词典构建L

音素图

单音子与词之间的跳转,权重是音素词典概率

prepare_lang.sh

WFST的复合运算

Compose

生成LG.fst

音素到单词的转录机

LG图对上下文展开

得到C之后,将C和LG复合,就得到了CLG。CLG把音素上下文序列转录为单词序列

fstmakecontextfst ilabels.sym <LG.fst> CLG.fst

实际上,并不是任意单音子的组合都是有意义的,在kaldi的实现中,并不去真正地构建完整的C,而是根据LG一边动态构建局部C,一边和LG复合,避免不必要地生成C的全部状态和跳转。

C的输入标签:是状态

输出标签是:音素对应的id

权重是:左侧音素和右侧音素

用WFST表示HMM拓扑结构

在生成从HMM状态到单词的转录机,之前需要有 从上下文音素到单词的转录机。

首先把HMM模型的拓扑结构以及转移概率构成的WFST,这个WFST习惯上被简称为H

输入标签是HMM状态号

输入出标签是C中的ilabel

跳转权重是转移概率

# 构建H的工具
make-h-transducer

kaldi构建HCLG的主要流程为

## 构造G
arpa2fst --natural-base=false lm.arpa |\
fstprint | esp2disambig.pl | s2eps.pl |\
fstcompile -isymbols=map_word --osymbols=map_word \
--keep_isymbols=false --keep_osymbols=false |\
fstrmepsilon > G.fst

## 构造L

make_lexicon_fst.pl lexicon_disambig 0.5 sil | \
fstcompile --isymbols=map_phone --psymbols=map_word \
--keep_isymbols=false --keep_osymbols=false |\
fstarcsort --sort_type=olabel > L.fst

## 构造LG = L * G
fsttablecompose L.fst G.fst | fstdeterminizestar --use-log=true | \
fstminimizeencoded | fstpushspecial > LG.fst

## 动态奶生成C,并组合到LG,得到CLG
fstcomposecontext --context-size=3 --central-position=1 \
--read-disambig-syms=list_disambig \
--write-disambig-syms=ilabels_disambig \
ilabels LG.fst > CLG.fst

## 构造H
make-h-transducer --disambig-syms-out=tid_disambig \
ilabels tree final.mdl >H.fst

## 最终得到HCLG
fsttablecompose H.fst CLG.fst | \ # 复合
fstdeterminizestar --use-log=true | \ # 确定化
fstrmsymbols tid_disambig | fstrmepslocal | fstminimizeencoded | \ # 移除消歧符 最小化
add-self-loops --self-loop-scale=0.1 --reorder=true \ # 增加自跳转
model_final.mdl > HCLG.fst


解码部分

基于令牌传递的维特比搜索

构建了HCLG后,我们希望在图中找到一条最优路径,该路径上输出标签所代表的的HMM状态在待识别语音上的代价要尽可能的低。这条路径上取出静音音素后的 输出标签就是单词级别的识别结果,这个过程就是解码。

维特比搜索

通常建立一个$T \times X 矩阵, 矩阵, 矩阵,T 为帧数, 为帧数, 为帧数,S$为HMM状态总数,对声学特征按帧遍历,对于每一帧的每个状态,把前一帧各个状态的累计代价和当前帧状态下的代价累加,选择使当前帧代价最低的前置状态作为当前路径的前置状态。现实中,并不需要始终存储整个矩阵信息,而只保留当前帧及上一帧信息即可

N-best

有时候我们也希望找到最优的多条路径,每条路径都对应一个识别结果,这个识别结果的列表被称为最优N个

Token

令牌传递算:该算法的基本思路就是把令牌进行传递。这里所说的令牌实际上是历史路径的记录,对每个令牌,都可以读取或回溯出全部的历史路径信息。令牌上还存储该路径的累计代价,用于评估该路径的优劣。

代价越低路径越优

剪枝

每个状态只保留一个令牌的方法,可以大幅度减少计算量,但令牌的数量仍然会快速增长,因此需要采用其他方法进一步限制解码器的计算量。

常见的方法是制定一套规则,比如全局最多令牌个数,当前令牌个数和最优令牌的最大差分等一系列条件,每传递指定的帧数,就把不满足这些条件的令牌删除,称为剪枝(Prune)

控制剪枝能力beam

当Decode()函数执行完毕后,解码的主体流程实际上就已经结束了,接下来需要执行一些步骤来取出识别结果。

simpledecode解码器提供了一个函数:ReachedFinal(),用于检测是否解码到最后一帧。

通常来说如果模型训练较好,解码时都可以到达最后一帧。

使用beam的情况

如果声学模型或语言模型和待测音频不匹配,则有可能所有的令牌在传递过程中都被剪掉,这时,就无法解码到最后一帧了。出现这种情况时,就是可以尝试设置更大的beam值

beam值越大,剪枝能力越弱

如果还是无法解码到最后,就需要分析声音,考虑重新训练声学模型和语言模型了。

Simp0leDecoder

src/gmmbin/gmm-decode-simple GMM模型 HCLG解码图 声学特征 输出单词级解码结果


  • 声学模型:exp/tri1/final.mdl

  • 状态图:exp/tri1/graph/HCLG.fst

  • 声学特征:data/test.feats.scp

    • 但需要对声学特征进行CMVN以及Delta处理

    • apply-cmvn --utt2spk=ark:utt2spk scp:cmvn.scp\
      	scp:feats.scp ark:- | add-deltas ark:- ark:feats_cmvn_delta.ark
      
      

以上就是解码所需要的全部输入,可以使用gmm-decode-simple工具解码

gmm-decode-simple final.mdl hclg.fst ark:feats_cmvn_delta.ark ark,t:result.txt

识别结果保存在result.txt文件中

带词网格生成的解码-词格

解码的更常见做法不是只输出一个最佳路径,而是输出一个词网格(word Lattice)。词网格没有一个统一的定义,在Kaldi中,词网格被定义为一个特殊的WFST,该WFST的每个跳转的权重有两个值构成,不是一个标准WFST的一个值。这两个值分别代表声学分数和语言分数,和HCLG一样,词网格的输入标签和输出标签分别是transition-id和word-id

  • 特点:
    • 所有解码分数或负代价大于某阈值的输出标签(单词)序列,都可以在词网格中找到对应的路径
    • 词网格中每条路径的分数和输入标签序列都能在HCLG中找到对应的路径
    • 对于任意输出标签序列,最多只能在词网格中找到一条路径

词格:包含了最佳路径也包含了其它可能路径

LatticeDecoder
lattice-to-nbest # 
lattice-best-path # 得到文本方式表示的最佳路径单词序列

用语言模型重打分提升识别率

在构建HCLG时,如果语言模型非常大,则会构建出很大的G.fst,而HCLG.fst 的大小有事G.fst的若干倍,以至于HCLG。fst达到无法载入。

所以通过会采用语言模型裁剪等方法来控制HCLG的规模

ngram-count -prune # 参数提供了裁剪功能

重打分

裁剪后的语言模型或多或少会减少损失识别率。基于WFST的解码方法对这个问题的解决策略是使用一个较小的语言模型来构造G,进而构造G,进而构造HCLG。使用这个HCLG解码后,对得到的词格的语言模型使用大的语言模型进行修正,这样就在内存有限的情况下较好的利用大语言模型的信息。

固有分

语言分和HMM转移概率、多音字特定发音概率混在一起共同够了固有分

语言模型重打分调整的知识语言分,因此需要首先想办法去掉原固有分中的旧语言模型分数,然后应用新的语言模型分数

# 去掉旧语言模型分数
lattice-lmrescore --lm-scale=-.10 ark:in.lats G_old.fst ark:nolm.lats
# 应用新的语言模型分数
lattice-lmrescore --lm-scale=1.0 ark:nolm.lats G_new.fst ark:out.lats

构建大语言模型

构建大语言模型,无需构建HCLG,只需要构建G,使用arpa-to-const-arpa工具把ARPA文件转成CONST ARPA

arpa-to-const-arpa --bos-symbol=$bos \
 --eos-symbol=$eos --unk0symbol-$unk \
 lm.arpa G.carpa

和G 不同,CONSTARPA 是一种树结构,可以快速第查找到某一个单词的语言分,而不需要构建庞大的WFST,构建CONST ARPA后,就可以使用lattice-lmrescore-const-arpa工具进行重打分,他可以支持非常巨大的语言模型

# 去掉旧语言模型分数
lattice-lmrescore --lm-scale=-.10 ark:in.lats G_old.fst ark:nolm.lats
# 用CARPA应用新的语言模型分数
lattice-lmresocre-const-arpa --lm-scale=1.0 ark:nolm.lats \
	G.carpa ark:out.lats

aishell数据集-从数据准备到解码

首先要下载aishell数据集:直通车

# 准备dict

. ./path.sh


# lexicon.txt文件夹
echo ">>>lexicon.txt "
res_dir=study
dict_dir=study/dict
mkdir -p $dict_dir
# 准备文件lexicon.txt
cp $res_dir/lexicon.txt $dict_dir

cat $dict_dir/lexicon.txt | awk '{ for(n=2;n<=NF;n++){ phones[$n] = 1; }} END{for (p in phones) print p;}'| \
  perl -e 'while(<>){ chomp($_); $phone = $_; next if ($phone eq "sil");
    m:^([^\d]+)(\d*)$: || die "Bad phone $_"; $q{$1} .= "$phone "; }
    foreach $l (values %q) {print "$l\n";}
  ' | sort -k1 > $dict_dir/nonsilence_phones.txt  || exit 1;

echo sil > $dict_dir/silence_phones.txt
echo sil > $dict_dir/optional_silence.txt

# No "extra questions" in the input to this setup, as we don't
# have stress or tone

cat $dict_dir/silence_phones.txt| awk '{printf("%s ", $1);} END{printf "\n";}' > $dict_dir/extra_questions.txt || exit 1;
cat $dict_dir/nonsilence_phones.txt | perl -e 'while(<>){ foreach $p (split(" ", $_)) {
  $p =~ m:^([^\d]+)(\d*)$: || die "Bad phone $_"; $q{$2} .= "$p "; } } foreach $l (values %q) {print "$l\n";}' \
 >> $dict_dir/extra_questions.txt || exit 1;

echo ">>>字典准备完成 "

# 准备数据
echo ">>>准备wav数据,生成 "


aishell_audio_dir=$res_dir/wav
aishell_text=$res_dir/text/aishell_transcript_v0.8.txt
# 前期数据
data=$res_dir/data

mkdir -p $data

# find wav audio file for train, dev and test resp.
find $aishell_audio_dir -iname "*.wav" > $data/wav.flist
n=`cat $data/wav.flist | wc -l`
[ $n -ne 141925 ] && \
  echo Warning: expected 141925 data data files, found $n

dir=$data
# Transcriptions preparation
echo Preparing $dir transcriptions
sed -e 's/\.wav//' $dir/wav.flist | awk -F '/' '{print $NF}' > $dir/utt.list
sed -e 's/\.wav//' $dir/wav.flist | awk -F '/' '{i=NF-1;printf("%s %s\n",$NF,$i)}' > $dir/utt2spk_all
paste -d' ' $dir/utt.list $dir/wav.flist > $dir/wav.scp_all
utils/filter_scp.pl -f 1 $dir/utt.list $aishell_text > $dir/transcripts.txt
awk '{print $1}' $dir/transcripts.txt > $dir/utt.list
utils/filter_scp.pl -f 1 $dir/utt.list $dir/utt2spk_all | sort -u > $dir/utt2spk
utils/filter_scp.pl -f 1 $dir/utt.list $dir/wav.scp_all | sort -u > $dir/wav.scp
sort -u $dir/transcripts.txt > $dir/text
utils/utt2spk_to_spk2utt.pl $dir/utt2spk > $dir/spk2utt

# kaldi_file标准文件目录
kaldi_file=$data/kaldi_file
mkdir -p $kaldi_file
for f in spk2utt utt2spk wav.scp text; do
  cp $data/$f $kaldi_file/$f || exit 1;
done

echo ">>>准备spk2utt utt2spk wav.scp text数据,生成kaldi_file标准文件格式 "
lang=${res_dir}/lang
# 生成L.fst
utils/prepare_lang.sh --position-dependent-phones false $dict_dir  "<SPOKEN_NOISE>" $res_dir/lang_tmp $lang || exit 1;
echo ">>>spk2utt utt2spk wav.scp text数据准备完成 "
echo ">>>准备语言模型,LM "

echo `pwd`
# LM training
study/train_code/aishell_train_lms.sh || exit 1;


echo ">>>关键的一步,开始生成G.fst>>>"
G=${res_dir}/G
# 生成G.fst
utils/format_lm.sh ${res_dir}/lang ${res_dir}/lm/3gram-mincount/lm_unpruned.gz \
    ${dict_dir}/lexicon.txt $G || exit 1;
echo ">>>恭喜,生成G.fst完成 "


# 生成声学模型,H.fst
echo ">>>关键的一步,声学模型,开始生成H.fst "
echo ">>>提取音频特征MFCC "
train_cmd=run.pl
mfccdir= ${dict_dir}/mfcc
exp=${res_dir}/exp
steps/make_mfcc_pitch.sh --cmd "$train_cmd" --nj 8 $kaldi_file $exp/make_mfcc $mfccdir || exit 1;
echo ">>>提取完成"
steps/compute_cmvn_stats.sh $kaldi_file exp/make_mfcc $mfccdir || exit 1;
utils/fix_data_dir.sh $kaldi_file || exit 1;
echo ">>>CMVN完成"

echo ">>>开始训练单音素"
# steps/train_mono.sh --cmd "run.pl"  --nj 8 data/train data/lang exp/mono
steps/train_mono.sh --cmd "$train_cmd" --nj 8 $kaldi_file $lang $exp/mono || exit 1;
echo ">>>恭喜,生成H.fst完成"


# 生成HCLG.fst
# Monophone decoding
# 合成HCLG
# # 解码
utils/mkgraph.sh $G $exp/mono $exp/mono/graph || exit 1;
steps/decode.sh --cmd "run.pl" --config conf/decode.config --nj 8 \
  $exp/mono/graph $kaldi_file $exp/mono/decode

语音识别系统评价

评价指标

  • 英文词错率 WER
    • 计算方法
      • 将识别结果错误词的累计个数除以标注中的总词数,结果表示为一个百分数。
      • 对错误词有以下三种定义
        • 插入错误(Insertion)
        • 删除错误(Deletion)
        • 替换错误(Substitiute)
  • 中文字错率 CER
  • 正确率ACC来评价
    • 测试句子的正确识别次数和全部标注文本词数

深度学习声学模型建模技术

基于神经网络的声学模型

为了捕捉发音单元的变换,通常将单音子(MonoPhone)扩展为上下文相关的三音子(Triphone),其副作用是模型参数急剧扩大,导致数据系数,训练效率降低,为了解决这个问题,建模过程引入了基于聚类方法的上下文决策树,以期在建模精度和数据量之间达到平滑。基于决策树的声学模型中,决策树的叶子节点的观察概率分布用GMM拟合,即似然度。在NN-HMM框架中,使用神经网络的输出表示每个叶子节点的分类概率,即后验概率。为了不影响声学模型训练和识别过程中的得分幅值,将后验概率除以对应叶子节点的先验概率,得到似然度。因此NN-HMM中 的NN是发音状态分类模型,输入是声学特征,输出是分类概率。

词表的扩展

背景

我们前面介绍过,语音识别是一个封闭词表的任务,通常来说一旦构建就词表就以固定。但实际应用中总会出现各种各样的新词汇,有时我们还需要删除词表中的一些完全无用的垃圾词。name,我们想对词表进行增补或者删除时,是否需要重新构建整个系统呢?

为了回答这个问题,这里需要明确一个概念:语音识别系统训练过程中的词表(词典)与解码时的词表可以完全独立的。

在Kaldi的很多方法中只涉及一个词典,因此体现不明显,但开发者需要了解一下

  • 训练词典:

    • 其作用在于覆盖训练文本中出现的词汇,一旦将训练数据的文本转换为声学建模单元(入音素、音节等),接下来的声学模型训练就与词典无关了。
  • 解码词典:

    • 其作用在于覆盖实际应用可能出现的所有词汇

    • 一方面,当面对狭窄的应用领域时,其词表可能比声学模型训练阶段的词表少很多

    • 另一方面,当面对专业词的应用时,其中也可以包含许多训练阶段中没有出现的词汇

解决方法

因此,我们在应用阶段对词表进行变更时,无关训练,只需变更解码词典,并对解码空间进行离线重构。具体来说,在Kaldi中的HCLG的WFST框架下,整体的解码空间为HCLG,对于词表的变更,我们只需要参数Kaldi中的HCLG的相关流程,将其中的L及G进行更新,并与原声学模型搭配即可。

构建HCLG

构建G

构建G的方法1

echo "》》》关键的一步,开始生成G.fst===================================="
G=${res_dir}/G
# 生成G.fst
utils/format_lm.sh ${res_dir}/lang ${res_dir}/lm/3gram-mincount/lm_unpruned.gz \
    ${dict_dir}/lexicon.txt $G || exit 1;
echo "》》》恭喜,生成G.fst完成=========================================="

构建G的方法2

## 构造G
arpa2fst --natural-base=false lm.arpa |\
fstprint | esp2disambig.pl | s2eps.pl |\
fstcompile -isymbols=map_word --osymbols=map_word \
--keep_isymbols=false --keep_osymbols=false |\
fstrmepsilon > G.fst
构建L
## 构造L

make_lexicon_fst.pl lexicon_disambig 0.5 sil | \
fstcompile --isymbols=map_phone --psymbols=map_word \
--keep_isymbols=false --keep_osymbols=false |\
fstarcsort --sort_type=olabel > L.fst
构建CLG
## 构造LG = L * G
fsttablecompose L.fst G.fst | fstdeterminizestar --use-log=true | \
fstminimizeencoded | fstpushspecial > LG.fst

## 动态生成C,并组合到LG,得到CLG
fstcomposecontext --context-size=3 --central-position=1 \
--read-disambig-syms=list_disambig \
--write-disambig-syms=ilabels_disambig \
ilabels LG.fst > CLG.fst
构造H
make-h-transducer --disambig-syms-out=tid_disambig \
ilabels tree final.mdl >H.fst
构建HCLG
## 最终得到HCLG
fsttablecompose H.fst CLG.fst | \ # 复合
fstdeterminizestar --use-log=true | \ # 确定化
fstrmsymbols tid_disambig | fstrmepslocal | fstminimizeencoded | \ # 移除消歧符 最小化
add-self-loops --self-loop-scale=0.1 --reorder=true \ # 增加自跳转
model_final.mdl > HCLG.fst
封装构建HCLG

再有L和G的基础上

# 生成HCLG.fst
# Monophone decoding
utils/mkgraph.sh $G $exp/mono $exp/mono/graph || exit 1;

解码

需要HCLG.fst

# 参数 
# 	解码配置文件
# 	HCLG所在目录
# 	生成解码的文件识别的结果文本txt放在 
# 		这个目录:..exp/tri1/decode_test/scoring_kaldi/penalty_1.0
steps/decode.sh --cmd "run.pl" --config conf/decode.config --nj 8 \
  $exp/mono/graph $kaldi_file $exp/mono/decode
 # 内部使用的是gmm-latgen-faster解码器 

重打分

在Librispeech示例中使用的是 faster-rnnlm方案,重打分的脚本是steps/rnnlmrescore.sh.这个脚本使用了RNN LM 和N元文法LM混合的重打分方案,其中 RNN LM的语言分计算由脚本utils/rnnlm_compute_scores.sh完成,并使计算出的分数修改词格。

构建大语言模型
# 去掉旧语言模型分数
lattice-lmrescore --lm-scale=-.10 ark:in.lats G_old.fst ark:nolm.lats
# 应用新的语言模型分数
lattice-lmrescore --lm-scale=1.0 ark:nolm.lats G_new.fst ark:out.lats

构建大语言模型,无需构建HCLG,只需要构建G,使用arpa-to-const-arpa工具把ARPA文件转成CONST ARPA

arpa-to-const-arpa --bos-symbol=$bos \
 --eos-symbol=$eos --unk0symbol-$unk \
 lm.arpa G.carpa

和G 不同,CONSTARPA 是一种树结构,可以快速第查找到某一个单词的语言分,而不需要构建庞大的WFST,构建CONST ARPA后,就可以使用lattice-lmrescore-const-arpa工具进行重打分,他可以支持非常巨大的语言模型

# 去掉旧语言模型分数
lattice-lmrescore --lm-scale=-.10 ark:in.lats G_old.fst ark:nolm.lats
# 用CARPA应用新的语言模型分数
lattice-lmresocre-const-arpa --lm-scale=1.0 ark:nolm.lats \
	G.carpa ark:out.lats

kaldi常见问题汇总

g++版本问题

语言模型工具安装问题

常用框架-espnet

ESPnet 是一个端到端的语音处理工具包,涵盖了端到端的语音识别、文本到语音、语音翻译、语音增强、说话者分类、口语理解等。ESPnet 使用pytorch作为深度学习引擎,还遵循Kaldi风格的数据处理、特征提取/格式和配方,为各种语音处理实验提供完整的设置。

github直通车

克隆

git clone --depth 1 https://github.com/espnet/espnet

官网文档

安装ESPnet

使用官网安装的过程会很慢,下载限速

手动下载相关包

cd <espnet-root>/tools
make CPU_ONLY=0
官网安装
$ cd <espnet-root>/tools
$ make

Makefile 尝试安装 ESPnet 和所有依赖项,包括 PyTorch。您还可以指定 PyTorch 版本,例如:

$ cd <espnet-root>/tools
$ make TH_VERSION=1.10.1

请注意,CUDA 版本是从nvcc命令派生的。如果您想指定其他 CUDA 版本,您需要提供CUDA_VERSION.

$ cd <espnet-root>/tools
$ make TH_VERSION=1.10.1 CUDA_VERSION=11.3

如果您没有nvcc命令,则默认情况下会为 CPU 模式安装软件包。如果您要手动打开它,请提供CPU_ONLY选项。

$ cd <espnet-root>/tools
$ make CPU_ONLY=0

检测安装了哪些包

cd <espnet-root>/tools
. ./activate_python.sh; python3 check_install.py

自定义工具安装

某些仅用于特定任务的软件包,例如 Transducer ASR、日语 TTS 等,默认情况下未安装,因此如果您在运行这些配方时遇到一些安装错误,您需要选择安装它们。

例如

  • 安装 Warp CTC

    cd <espnet-root>/tools
    . activate_python.sh
    . ./setup_cuda_env.sh <cuda-root>  # e.g. <cuda-root> = /usr/local/cuda
    ./installers/install_warp-ctc.sh
    
  • 安装翘曲传感器

    cd <espnet-root>/tools
    . activate_python.sh
    . ./setup_cuda_env.sh <cuda-root>  # e.g. <cuda-root> = /usr/local/cuda
    ./installers/install_warp-transducer.sh
    
  • 安装 PyOpenJTalk

    cd <espnet-root>/tools
    . activate_python.sh
    ./installers/install_pyopenjtalk.sh
    
    
  • 使用 pip 安装模块:例如安装 ipython

    cd <espnet-root>/tools
    . activate_python.sh
    pip install ipython
    

必须安装的工具

安装语言模型工具:kenlm

官网安装教程

安装之前要安装相关依赖

sudo apt-get install build-essential libboost-all-dev cmake zlib1g-dev libbz2-dev liblzma-dev
wget -O - https://kheafield.com/code/kenlm.tar.gz |tar xz
mkdir kenlm/build
cd kenlm/build
cmake ..
make -j2

用法

espnet/              # Python modules
utils/               # Utility scripts of ESPnet
test/                # Unit test
test_utils/          # Unit test for executable scripts
egs/                 # The complete recipe for each corpora
    an4/             # AN4 is tiny corpus and can be obtained freely, so it might be suitable for tutorial
      asr1/          # ASR recipe
          - run.sh   # Executable script
          - cmd.sh   # To select the backend for job scheduler
          - path.sh  # Setup script for environment variables
          - conf/    # Containing Configuration files
          - steps/   # The steps scripts from Kaldi
          - utils/   # The utils scripts from Kaldi
      tts1/          # TTS recipe
    ...

一套流程

在学习一个案例之前一定要把这个跑通才可以,否则无法看懂这些都是做了什么

# 运行前conda的环境要切换到安装espnet时候的环境,否则会报错
./run.sh

这个脚本里面包含了所有的步骤,数据下载,数据准备,特征提取,模型训练,解码和评分

脚本介绍

脚本名称参数功能
local/data.sh=数据路径数据预处理
scripts/utils/perturb_data_dir_speed.sh=扰动因子 =训练数据路径 =生成数据路径生成数据增强的目录
utils/combine_data.sh=数据增强的目录 =生成后的目录数据增强
utils/copy_data_dir.sh复制数据目录
scripts/audio/format_wav_scp.sh格式化wav.scp
steps/make_fbank_pitch.sh特征提取fbank_pitch
scripts/feats/feat_to_shape.sh获取特征维度
pyscripts/feats/feat-to-shape.py写入特征维度文件
utils/filter_scp.pl清理短句子
utils/fix_data_dir.shkaldi中的文件,用于检测win.scp和text文件排序是否正确,去掉句子没有的特征

常用框架-wenet

环境配置

https://github.com/wenet-e2e/wenet

git clone --depth 1 https://github.com/wenet-e2e/wenet.git # 克隆源码

安装教程

conda create -n wenet python=3.8
conda activate wenet
pip install -r requirements.txt -i 镜像
conda install pytorch=1.10.0 torchvision torchaudio=0.10.0 cudatoolkit=11.1 -c pytorch -c conda-forge

AIShell 教程

我们提供了example/aishell/s0/run.sh关于 aishell-1 数据的配方

配方很简单,我们建议您手动逐个运行每个阶段并检查结果以了解整个过程。

cd example/aishell/s0
bash run.sh --stage -1 --stop-stage -1
bash run.sh --stage 0 --stop-stage 0
bash run.sh --stage 1 --stop-stage 1
bash run.sh --stage 2 --stop-stage 2
bash run.sh --stage 3 --stop-stage 3
bash run.sh --stage 4 --stop-stage 4
bash run.sh --stage 5 --stop-stage 5
bash run.sh --stage 6 --stop-stage 6

您也可以只运行整个脚本

bash run.sh --stage -1 --stop-stage 6

阶段-1:下载数据

此阶段将 aishell-1 数据下载到本地路径$data。这可能需要几个小时。

如果您已经下载了数据,请更改$data变量run.sh并从.--stage 0

阶段 0:准备训练数据

# 准备训练数据
if [ ${stage} -le 0 ] && [ ${stop_stage} -ge 0 ]; then
  # Data preparation
  local/aishell_data_prep.sh ${data}/data_aishell/wav \
    ${data}/data_aishell/transcript
fi

在这个阶段,local/aishell_data_prep.sh将原始的 aishell-1 数据组织成两个文件:

  • wav.scp每行记录两个制表符分隔的列:wav_idwav_path
  • text 每行记录两个制表符分隔的列: wav_idtext_label

wav.scp

BAC009S0002W0122 /export/data/asr-data/OpenSLR/33/data_aishell/wav/train/S0002/BAC009S0002W0122.wav
BAC009S0002W0123 /export/data/asr-data/OpenSLR/33/data_aishell/wav/train/S0002/BAC009S0002W0123.wav
BAC009S0002W0124 /export/data/asr-data/OpenSLR/33/data_aishell/wav/train/S0002/BAC009S0002W0124.wav
BAC009S0002W0125 /export/data/asr-data/OpenSLR/33/data_aishell/wav/train/S0002/BAC009S0002W0125.wav
...

text

BAC009S0002W0122 而对楼市成交抑制作用最大的限购
BAC009S0002W0123 也成为地方政府的眼中钉
BAC009S0002W0124 自六月底呼和浩特市率先宣布取消限购后
BAC009S0002W0125 各地政府便纷纷跟进
...

如果您想使用自定义数据进行训练,只需将数据组织成两个文件wav.scptext,然后从.stage 1

第 1 阶段:提取可选 cmvn 特征

example/aishell/s0使用原始 wav 作为输入,使用TorchAudio在数据加载器中实时提取特征。所以在这一步中,我们只需将训练 wav.scp 和文本文件复制到raw_wav/train/目录中。

tools/compute_cmvn_stats.py用于提取全局 cmvn(倒谱均值和方差归一化)统计信息。这些统计数据将用于标准化声学特征。设置cmvn=false将跳过此步骤。

# 提取可选 cmvn 特征
if [ ${stage} -le 1 ] && [ ${stop_stage} -ge 1 ]; then
  # remove the space between the text labels for Mandarin dataset
  for x in train dev test; do
    cp data/${x}/text data/${x}/text.org
    paste -d " " <(cut -f 1 -d" " data/${x}/text.org) \
      <(cut -f 2- -d" " data/${x}/text.org | tr -d " ") \
      > data/${x}/text
    rm data/${x}/text.org
  done

  tools/compute_cmvn_stats.py --num_workers 8 --train_config $train_config \
    --in_scp data/${train_set}/wav.scp \
    --out_cmvn data/$train_set/global_cmvn
fi

第 2 阶段:生成标签令牌字典

# 生成标签令牌字典
if [ ${stage} -le 2 ] && [ ${stop_stage} -ge 2 ]; then
  echo "Make a dictionary"
  mkdir -p $(dirname $dict)
  echo "<blank> 0" > ${dict}  # 0 is for "blank" in CTC
  echo "<unk> 1"  >> ${dict}  # <unk> must be 1
  tools/text2token.py -s 1 -n 1 data/train/text | cut -f 2- -d" " \
    | tr " " "\n" | sort | uniq | grep -a -v -e '^\s*$' | \
    awk '{print $0 " " NR+1}' >> ${dict}
  num_token=$(cat $dict | wc -l)
  echo "<sos/eos> $num_token" >> $dict
fi

dict 是标签标记(我们为 Aishell-1 使用字符)和整数索引之间的映射。

一个示例字典如下

<blank> 0
<unk> 1
一 2
丁 3
...
龚 4230
龟 4231
<sos/eos> 4232

  • <blank>表示 CTC 的空白符号。
  • <unk>表示未知标记,任何词汇表外的标记都将映射到其中。
  • <sos/eos>表示用于基于注意力的编码器解码器训练的语音开始和语音结束符号,并且它们共享相同的 id。

第 3 阶段:准备 WeNet 数据格式

此阶段生成 WeNet 所需的格式文件data.list。中的每一行data.list都是 json 格式,其中包含以下字段。

  1. key: 话语的关键
  2. wav: 话语的音频文件路径
  3. txt:话语的标准化转录,转录将在训练阶段即时标记为模型单元。

这是一个示例data.list,请参阅生成的训练特征文件data/train/data.list

{"key": "BAC009S0002W0122", "wav": "/export/data/asr-data/OpenSLR/33//data_aishell/wav/train/S0002/BAC009S0002W0122.wav", "txt": "而对楼市成交抑制作用最大的限购"}
{"key": "BAC009S0002W0123", "wav": "/export/data/asr-data/OpenSLR/33//data_aishell/wav/train/S0002/BAC009S0002W0123.wav", "txt": "也成为地方政府的眼中钉"}
{"key": "BAC009S0002W0124", "wav": "/export/data/asr-data/OpenSLR/33//data_aishell/wav/train/S0002/BAC009S0002W0124.wav", "txt": "自六月底呼和浩特市率先宣布取消限购后"}

我们还设计了另一种data.list命名格式,shard用于大数据训练。如果您想在大数据集(超过 5k)上应用 WeNet,请参阅gigaspeech(10k 小时)或 wenetspeech(10k 小时),了解如何使用shard样式data.list

第 4 阶段:神经网络训练

NN 模型在此步骤中进行训练。

  • 多 GPU 模式

如果对多 GPU 使用 DDP 模式,我们建议使用dist_backend="nccl". 如果 NCCL 不起作用,请尝试使用gloo或使用torch==1.6.0 Set the GPU ids in CUDA_VISIBLE_DEVICES。例如,设置为使用卡 0,1,2,3,6,7。export CUDA_VISIBLE_DEVICES="0,1,2,3,6,7"

  • 恢复培训

如果您的实验在运行几个 epoch 后由于某些原因(例如 GPU 被其他人意外使用并且内存不足)而终止,您可以从检查点模型继续训练。只需找出完成的 epoch exp/your_exp/,设置 checkpoint=exp/your_exp/$n.pt并运行. 然后训练将从 $n+1.pt 继续run.sh --stage 4

  • 配置

神经网络结构、优化参数、损失参数和数据集的配置可以在 YAML 格式文件中设置。

conf/中,我们提供了几种模型,例如变压器和构象器。见conf/train_conformer.yaml参考。

  • 使用张量板

培训需要几个小时。实际时间取决于 GPU 卡的数量和类型。在一台 8 卡 2080 Ti 机器中,50 个 epoch 大约需要不到一天的时间。您可以使用 tensorboard 来监控损失。

tensorboard --logdir tensorboard/$your_exp_name/ --port 12598 --bind_all

dir=exp/conformer
cmvn_opts="--cmvn ${dir}/global_cmvn"
train_config=conf/train_conformer.yaml
data_type=raw
dict=data/dict/lang_char.txt
train_set=train

python3 train.py \
	--config $train_config \
	--data_type $data_type \
	--symbol_table $dict \
	--train_data data/$train_set/data.list \
	--model_dir $dir \
	--cv_data data/dev/data.list \
	--num_workers 1 \
	$cmvn_opts \
	--pin_memory

第 5 阶段:使用经过训练的模型识别 wav

需要文件:

dict词典文件:words.txt
model:final.pt
训练模型用的配置文件:/train.yaml
cmvn文件:在配置文件里面配置路径

待识别语言列表:data.list # 格式{key,wavscp,text}


解码过程

dir=/root/data/aizm/wenet/pre_modle/20210618_u2pp_conformer_exp

data_type=raw
dict=${dir}/words.txt
decode_checkpoint=${dir}/final.pt

decoding_chunk_size=
ctc_weight=0.5
reverse_weight=0.0

test_dir=$dir/test_attention_rescoring
# 测试的语音内容{key,wavscp,text}
list_name=vad_test
data_list_dir=${list_name}.list
mkdir -p $test_dir
python recognize.py \
  --mode "attention_rescoring" \
  --config $dir/train.yaml \
  --data_type $data_type \
  --test_data ${data_list_dir} \
  --checkpoint $decode_checkpoint \
  --beam_size 10 \
  --batch_size 1 \
  --penalty 0.0 \
  --dict $dict \
  --ctc_weight $ctc_weight \
  --reverse_weight $reverse_weight \
  --result_file $test_dir/text_${list_name} \
  ${decoding_chunk_size:+--decoding_chunk_size $decoding_chunk_size}
python tools/compute-wer.py --char=1 --v=1 \
  data/test/text $test_dir/text > $test_dir/wer_${list_name}

这个阶段展示了如何将一组 wav 识别为文本。它还展示了如何进行模型平均。

  • 平均模型

如果${average_checkpoint}设置为true,则交叉验证集上的最佳${average_num}模型将被平均以生成增强模型并用于识别。

  • 解码

识别也称为解码或推理。NN的功能将应用于输入的声学特征序列以输出文本序列。

WeNet 中提供了四种解码方法:

  • ctc_greedy_search: encoder + CTC 贪婪搜索
  • ctc_prefix_beam_search:encoder + CTC 前缀波束搜索
  • attention`:encoder + attention-based decoder 解码
  • attention_rescoring:在基于注意力的解码器上使用编码器输出从 ctc 前缀波束搜索中重新评估 ctc 候选者。

一般来说,attention_rescoring 是最好的方法。有关这些算法的详细信息,请参阅U2 论文

--beam_size是一个可调参数,较大的光束尺寸可能会获得更好的结果,但也会导致更高的计算成本。

--batch_size“ctc_greedy_search”和“attention”解码模式可以大于1,“ctc_prefix_beam_search”和“attention_rescoring”解码模式必须为1。

  • wer评价

tools/compute-wer.py将计算结果的单词(或字符)错误率。如果您在没有任何更改的情况下运行配方,您可能会得到 WER ~= 5%。

第 6 阶段:导出训练好的模型

wenet/bin/export_jit.py将使用 Libtorch 导出经过训练的模型。导出的模型文件可轻松用于其他编程语言(如 C++)的推理。

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

语音不识别

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值