把 48 kHz 原始音频变成 16 kHz 帧 — 测量并阻止混叠
目标
把 48 kHz 立体声录音转换成语音模型接收的形态(16 kHz · 单声道 · 16 位 · 20 ms 帧),同时用数字确认不经滤波的重采样所产生的折叠声,以及 dBFS 和 SNR。
为什么重要
语音流水线中最常见的事故,出在声音的形态上,而不是模型。采样率、声道、字节序即使错位,也不会报错,只是识别率悄悄崩塌。原始文件(/opt/lab/fixtures/voice/audio/caller_48k.wav)中特意混入了 12 kHz 的导频音——降到 16 kHz 时如果不滤波,这个音调就会折叠成在 4 kHz 处听到的声音。评分器会重新读取你保存的文件,亲自测量 4 kHz 成分的强度、每一帧的 dBFS、实际的 SNR 和字节排列。
步骤
- 读取
/opt/lab/fixtures/voice/audio/caller_48k.wav的头部,把sample_rate、channels、sample_width_bytes、frames、duration_s写入/root/voice/audio/info.json。 - 用两个声道的平均值生成单声道,保持 48 kHz,保存到
/root/voice/audio/mono48k.wav。 - 不滤波,每三个取一个(
x[::3]),保存到/root/voice/audio/naive16k.wav(16000 Hz)。 - 先施加只让 8 kHz 以下通过的低通滤波器,再每三个取一个,保存到
/root/voice/audio/mono16k.wav(16000 Hz)。 - 把
mono16k.wav切成 20 ms(320 个采样点)的帧,把每一帧的 RMS dBFS 写入/root/voice/audio/levels.csv(index,start_s,dbfs)。 - 把低于 -40 dBFS 并持续 200 ms(10 帧)以上的区间,以
[{"start_s":…,"end_s":…}]的形式写入/root/voice/audio/silence.json。 - 把种子为 7 的标准正态噪声(
np.random.default_rng(7).standard_normal(n))混入,使 SNR 为 10 dB,保存到/root/voice/audio/noisy10.wav,并把用保存的文件重新测得的值写入/root/voice/audio/snr.json。 - 生成把
mono16k.wav连续写成 s16le 20 ms 帧(640 字节)的/root/voice/audio/stream.pcm,以及写有格式的/root/voice/audio/stream.json。最后一帧不足的部分用 0 补齐。
参考
- 读写:标准库
wave和numpy就够了。镜像中自带的辅助函数from voicekit import read_wav, write_wav, to_int16, dbfs也可以使用(它们会做单声道平均和 16 位转换)。 - 16 位采样点用
np.frombuffer(raw, dtype="<i2")读取,再除以 32768。立体声要 reshape 成(프레임, 채널)(占位符依次为帧数、声道数)。 - 常见错误:把时长算成
frames / sample_rate / channels(frames本身已经是按声道打包的数量),只把两个声道相加(会溢出),把 SNR 按振幅比来对齐(10 dB 会变成 5 dB)。 - 概念:Nyquist–Shannon sampling theorem · ITU-T G.711 · Python wave
先读取头部
用 wave 打开 /opt/lab/fixtures/voice/audio/caller_48k.wav,把 sample_rate、channels、sample_width_bytes、frames、duration_s(秒,保留三位小数)写入 /root/voice/audio/info.json。
wave.open(...).getframerate()、getnchannels()、getsampwidth()、getnframes() 会给出这四个值。frames 是“每个声道各取一个采样点打成一组”的数量,所以时长是 frames ÷ 采样率。
把两个声道取平均,变成单声道
把原始文件的两个声道取平均,生成单声道,保持 48000 Hz,保存到 /root/voice/audio/mono48k.wav(16 位)。
对(帧,声道)数组在 axis=1 上取平均。如果只是相加,在大声处会超过 1.0 而被截断。写入时要乘以 32768 并四舍五入,再限制在 -32768 到 32767 之间。
试试不滤波直接抽取
从 mono48k.wav 中每三个取一个(x[::3]),保存到 /root/voice/audio/naive16k.wav(16000 Hz 单声道)。这是故意做错的方法。
48000 ÷ 3 = 16000,所以只要抽取,采样率就对了。可是原始文件中的 12 kHz 音调去哪了呢?评分信息会告诉你 4 kHz 成分的强度。
滤波之后再降采样
对 mono48k.wav 施加截止频率低于新奈奎斯特频率(8 kHz)的低通滤波器,然后每三个取一个,保存到 /root/voice/audio/mono16k.wav(16000 Hz 单声道)。4 kHz 折叠成分必须比直接抽取时小 30 dB 以上。
加窗的 sinc 是最简单的低通滤波器:h = 2·fc·sinc(2·fc·n)·hamming,fc 是截止频率 ÷ 48000。除以 h 的和,把增益调整为 1,再用 np.convolve(x, h, mode='same') 施加。抽头数越多,截止越陡。
20 ms 帧的强度(dBFS)
把 mono16k.wav 切成 320 个采样点(20 ms)的帧(末尾不足的片段舍弃),对每一帧把 index,start_s,dbfs 写入 /root/voice/audio/levels.csv。dBFS 是 20·log10(RMS),完全静音是 -120。
RMS 是平方的平均值的平方根。必须在除以 32768 之后的实数上测量,0 dBFS 才是最大值。如果使用 10·log10,值会减半(因为这是振幅的 RMS 而不是功率,所以要用 20)。
找出静音区间
把 levels.csv 中低于 -40 dBFS 并持续 10 帧(200 ms)以上的区间,以 [{"start_s": …, "end_s": …}] 的形式写入 /root/voice/audio/silence.json。结束时间是“第一个越过阈值的帧的开始”,如果一直持续到文件末尾,则是最后一帧的结束。
记住进入阈值以下的帧号,在越过阈值的那一刻查看长度。如果在列表末尾追加一个很大的值,最后一个区间也能用同一段代码收尾。
以 SNR 10 dB 混入噪声
把与 mono16k.wav 长度相同的 np.random.default_rng(7).standard_normal(n) 噪声,调整缩放比例使 SNR 为 10 dB(功率比)后叠加,保存到 /root/voice/audio/noisy10.wav,并把重新读取保存的文件测得的 SNR 写入 /root/voice/audio/snr.json 的 measured_db(target_db 是 10)。
SNR = 10·log10(P_信号 / P_噪声),P 是平方平均值。乘在噪声上的缩放比例是 sqrt(P_信号 / 10^(10/10) / P_噪声)。保存时会四舍五入为 16 位,所以重新读取后用 noisy − clean 来测量才准确。
WebSocket 会把我切成 640 字节的帧
把 mono16k.wav 的采样点以 s16le(16 位,小端优先)每 20 ms(320 个采样点 = 640 字节)连续写出,生成 /root/voice/audio/stream.pcm(最后一帧用 0 补齐),并把 format("s16le")、sample_rate、channels、frame_ms、frame_bytes、frames 写入 /root/voice/audio/stream.json。
不带 WAV 头,只写采样点。np.int16 数组的 .tobytes() 遵循机器的字节序,所以请把 dtype 固定为 '