LabHub
开始
学习 学习路径 课程

语音 AI 智能体 — 会听、会查、会说的流水线

把 48 kHz 原始音频变成 16 kHz 帧 — 测量并阻止混叠

在 LabHub 中继续学习

目标

把 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 和字节排列。

步骤

  1. 读取 /opt/lab/fixtures/voice/audio/caller_48k.wav 的头部,把 sample_rate、channels、sample_width_bytes、frames、duration_s 写入 /root/voice/audio/info.json。
  2. 用两个声道的平均值生成单声道,保持 48 kHz,保存到 /root/voice/audio/mono48k.wav。
  3. 不滤波,每三个取一个(x[::3]),保存到 /root/voice/audio/naive16k.wav(16000 Hz)。
  4. 先施加只让 8 kHz 以下通过的低通滤波器,再每三个取一个,保存到 /root/voice/audio/mono16k.wav(16000 Hz)。
  5. 把 mono16k.wav 切成 20 ms(320 个采样点)的帧,把每一帧的 RMS dBFS 写入 /root/voice/audio/levels.csv(index,start_s,dbfs)。
  6. 把低于 -40 dBFS 并持续 200 ms(10 帧)以上的区间,以 [{"start_s":…,"end_s":…}] 的形式写入 /root/voice/audio/silence.json。
  7. 把种子为 7 的标准正态噪声(np.random.default_rng(7).standard_normal(n))混入,使 SNR 为 10 dB,保存到 /root/voice/audio/noisy10.wav,并把用保存的文件重新测得的值写入 /root/voice/audio/snr.json。
  8. 生成把 mono16k.wav 连续写成 s16le 20 ms 帧(640 字节)的 /root/voice/audio/stream.pcm,以及写有格式的 /root/voice/audio/stream.json。最后一帧不足的部分用 0 补齐。

参考

先读取头部

用 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 固定为 '