部分音频处理是格式要求(必做),部分是主观增强(可能反而降低识别率);给出了 16kHz 重采样、声道分离、16-bit PCM 等具体处理规范及验证方法。
有些音频预处理是格式要求,始终是正确的。有些是增强处理,对人类听起来更好,但可能让识别效果变差。没有人能告诉你哪些适用于你的音频,所以这篇文章的后半部分是一个测试框架,用来找出答案。
这些不是改进,而是合约履行。搞错其中一个会产生静默降级的输出,而不是报错。
# The canonical conversion. soxr is a high-quality resampler.
ffmpeg -i input.m4a \
-ar 16000 -ac 1 -c:a pcm_s16le \
-af "aresample=resampler=soxr:precision=28,highpass=f=60" \
output.wav
# Check what actually you have, before and after:
ffprobe -v error -show_entries \
stream=sample_rate,channels,bits_per_raw_sample,codec_name \
-of default=nw=1 output.wav
在任何操作之前还要检查是否削波。一个采样值固定在满幅的录音已经丢失了无法通过任何处理恢复的信息,而且它引入的失真是宽频的——恰恰是频谱图前端处理最差的那种损坏。
# Fraction of samples at or near full scale. Anything above a
# fraction of a percent means the recording chain needs fixing,
# not the file.
ffmpeg -i input.wav -af astats=metadata=1 -f null - 2>&1 \
| grep -E "Peak level|Flat factor|Number of clipped"
下面所有处理都能改善人类听到的音频音质。但是否能改善识别效果,取决于模型和你的音频,而且方向确实不明确。
响度标准化。将每个文件统一到一致的积分响度——EBU R128 标准,由 ffmpeg 的 loudnorm 滤波器实现——消除了一种模型可能没有训练过要去处理的变差来源。这是所有增强处理中最安全的,因为它接近线性增益变化。两遍形式先测量再应用,避免了单遍版本的动态行为。
降噪。真正有争议的一项。频谱减法及其神经后继者通过衰减被判定为噪声的时频箱来去除噪声,它们必然也会同时衰减那些箱子里的语音。残留的是音乐噪声和模糊的辅音——一个听起来更干净但信息量更少的信号。现代 ASR 编码器是在大量嘈杂的真实世界音频上训练的,通常对原始噪声比对其去除后的伪影更具鲁棒性。把任何"降噪有帮助"的声称当作关于你数据的假设来对待,并测试它。
去混响。混响确实会损害识别,因为它模糊了模型需要的时间结构,而且每次反射都是信号的延迟复制。与降噪不同,去混响针对的是前端无法忽略的失真。它是最有可能帮助房间录音的增强处理,对电话通话毫无作用。
静音修剪。去除长静音直接减少音频时长计费,并消除触发静音幻听的输入条件。在每个语音区域周围保留几百毫秒的填充;紧贴 VAD 边界修剪会截断单词起始音节,为了节省几千字节而损失准确度。
带宽扩展。为电话音频发明缺失高频段的神经上采样。这是生成式的——发明的内容是似是而非的而非真实的——将发明的频谱细节提供给识别器是一种赌博,而线性重采样不是。如果你要尝试,它属于测试框架,而不是流水线。
这是一个晚间工作,就能解决本应反复争论多年的问题。
# sweep.py -- score preprocessing variants on your own audio.
#
# Layout:
# audio/clip001.wav ... audio/clipNNN.wav original recordings
# refs/clip001.txt ... refs/clipNNN.txt hand-corrected truth
#
# Needs: ffmpeg on PATH, and wer.py from the word error rate page
# in the same directory.
import subprocess, pathlib, statistics, csv, sys
from wer import score, normalise
VARIANTS = {
# name : ffmpeg -af filter chain (empty = format conversion only)
"baseline" : "",
"highpass" : "highpass=f=60",
"loudnorm" : "loudnorm=I=-16:TP=-1.5:LRA=11",
"hp_loudnorm" : "highpass=f=60,loudnorm=I=-16:TP=-1.5:LRA=11",
"denoise" : "afftdn=nr=12:nf=-25",
"denoise_hard": "afftdn=nr=24:nf=-20",
"trim" : "silenceremove=start_periods=1:start_silence=0.2:"
"start_threshold=-40dB:stop_periods=-1:"
"stop_silence=0.4:stop_threshold=-40dB",
}
def prepare(src: pathlib.Path, dst: pathlib.Path, chain: str) -> None:
af = "aresample=resampler=soxr:precision=28"
if chain:
af = af + "," + chain
subprocess.run(
["ffmpeg", "-nostdin", "-y", "-v", "error", "-i", str(src),
"-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le",
"-af", af, str(dst)],
check=True,
)
def transcribe(path: pathlib.Path) -> str:
"""REPLACE ME.
Call whatever recogniser you are evaluating and return its text.
This is the only vendor-specific line in the file. Keep every
decoding option fixed across variants -- if you change the model
or its settings between runs, the comparison means nothing.
"""
raise NotImplementedError
def main(audio_dir="audio", ref_dir="refs", work="work"):
audio = sorted(pathlib.Path(audio_dir).glob("*.wav"))
if not audio:
sys.exit("no audio found")
pathlib.Path(work).mkdir(exist_ok=True)
rows = []
for name, chain in VARIANTS.items():
wers, subs, dels, inss = [], 0, 0, 0
for src in audio:
ref_path = pathlib.Path(ref_dir) / (src.stem + ".txt")
if not ref_path.exists():
continue
dst = pathlib.Path(work) / f"{name}-{src.stem}.wav"
prepare(src, dst, chain)
hyp = transcribe(dst)
ref = ref_path.read_text(encoding="utf-8")
r = score(normalise(ref), normalise(hyp))
wers.append(r.wer)
subs += r.substitutions
dels += r.deletions
inss += r.insertions
rows.append({
"variant": name,
"files": len(wers),
"mean_wer": round(statistics.mean(wers) * 100, 2),
"median_wer": round(statistics.median(wers) * 100, 2),
"worst_wer": round(max(wers) * 100, 2),
"S": subs, "D": dels, "I": inss,
})
with open("sweep.csv", "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
for row in sorted(rows, key=lambda r: r["median_wer"]):
print(row)
if __name__ == "__main__":
main()
所有供应商相关的逻辑都在一个函数里。其他一切——转换、评分、归一化——都是你可以阅读的代码。
看中位数,不要看平均值。一个识别器什么都没输出的文件会把平均 WER 拖到任何有意义的数值之外。两者都报告,并单独看 worst_wer:中位数有帮助但摧毁最坏情况的变体,通常不是生产环境的正确选择。
分别看 S、D 和 I。激进的降噪特征性地提高删除数——它和噪声一起移除了轻声词汇。如果 denoise_hard 降低了 WER 但提高了 D,它没有在帮助,它只是让识别器少说了。
不要轻信小的收益。三十个文件,相差半个百分点的差异就是噪声。如果想要一个能站得住脚的决定,用 bootstrap:用替换对文件级 WER 重采样几千次,看两个变体之间的差异是否保持了符号。
按录音条件分组。正确答案通常对手机通话、耳麦通话和房间录音各不相同。一套流水线应对所有场景是对三种情况中的两种做出错误决定的决定。
当模型变化时重新运行。这是(模型、音频)这个pair的属性,不是音频本身的属性。新的识别器版本可能颠覆排序顺序,而这正是这个测试框架比任何结论都更有价值的原因。
本文没有断言任何结果,是故意的。哪个变体获胜取决于你的录音和你运行的识别器,任何印在页面上的获胜者表格都是关于别人音频的声称。测试框架存在是因为这是诚实的答案形式。
预处理是修补工作。以下每一项都比任何滤波器链更有价值:
使用可用的最高质量链路。宽带或全频段流比 8 kHz 电话音频的优势超过任何处理能挽回的。如果呼叫者在浏览器中,不要让栈中的任何东西为了与电话路径保持一致而将他们降采样到窄带。一致性不值得一个准确度天花板;参见音频路径中的编解码器表。
避免二次转码。每次有损重编码都会叠加。询问你的提供商录音是用什么编码的,以及它在传输过程中是否被转码过。
分参与者轨道录制。比你之后能买到的任何人声分离都更便宜和准确。
给人们提供耳麦。这是呼叫中心可用的最大质量改进,而且它还能让插话正常工作。
从一开始就以 16 kHz 或更高采样率采集。如果你控制客户端,没有理由先录窄带再上采样。
How Speech Recognition Works Now
Word Error Rate: Measuring Transcription Properly
Accents, Code-Switching and Multilingual Calls