PCM、WAV与RIFF:语音模型输入格式检查记录
PCM、WAV与RIFF:语音模型输入格式检查记录
语音算法工程师学习路线 · 音频基础 01
语音推理链路出现异常时,音频格式检查应当早于模型排查。文件能够被播放器正常播放,并不能证明它满足模型的输入契约。
本文记录 PCM、WAV 和 RIFF 的关系,并给出一套不依赖第三方库的检查方法。
1. 三个概念的关系
PCM 是采样值的数字表示方式。裸 PCM 通常不携带采样率、声道、位宽等自描述信息,接收方必须从协议或配置中取得这些参数。
WAV 是文件容器。常见 WAV 基于 RIFF,以 chunk 形式保存格式描述和音频数据。
RIFF 是组织文件数据的结构。chunk 使用四字符标识,WAVE 文件中常见:
RIFF/WAVE
├── fmt
├── 可选 chunk
└── data
由此可以得到:WAV 可以承载 PCM,但 PCM 与 WAV 不是同义词,.wav 后缀也不能替代格式校验。
2. 格式字段
fmt chunk 中的常见字段包括编码标识、声道数、采样率、平均字节率、块对齐和位宽。
对常见整数 PCM:
block_align = channels * bits_per_sample / 8
byte_rate = sample_rate * block_align
duration = data_size / byte_rate
例如:
| 格式 | block_align | 每秒数据量 |
|---|---|---|
| 16 kHz / mono / 16-bit | 2 bytes | 32000 bytes |
| 16 kHz / stereo / 16-bit | 4 bytes | 64000 bytes |
这里的音频帧是同一时刻全部声道采样的集合,不是特征提取时按若干毫秒划分的分析帧。
3. 不应假设WAV头固定为44字节
经典 PCM WAV 常见 44 字节头,但 RIFF 可以包含其他 chunk,fmt 的长度也并非永远固定。直接对文件执行 [44:] 会在部分文件上定位错误。
读取逻辑应遍历:
chunk_id(4 bytes) + chunk_size(4 bytes) + chunk_data
并根据 chunk 大小移动文件指针。chunk 数据还需要处理偶数字节边界的 padding。
4. 完整检查程序
下面的代码只依赖 Python 标准库,使用 Python 3.10 或以上版本。复制保存为 inspect_wav.py 后,既可以生成固定测试音频,也可以检查自备 WAV。
它先遍历 RIFF chunk,再用 wave 交叉读取参数;本文的目标规格是 16 kHz、单声道、16-bit 整数 PCM。此工具用于常见 PCM 文件的格式与参数排查,不是覆盖所有 WAV 扩展和损坏方式的通用修复工具。
#!/usr/bin/env python3
"""Inspect a RIFF/WAVE file and validate common speech-model input constraints.
Only the Python standard library is required. The script intentionally parses
RIFF chunks instead of assuming that PCM samples always begin at byte 44.
"""
from __future__ import annotations
import argparse
import json
import math
import struct
import wave
from pathlib import Path
FORMAT_NAMES = {
0x0001: "PCM integer",
0x0003: "IEEE float",
0x0006: "A-law",
0x0007: "mu-law",
0x0011: "IMA ADPCM",
0xFFFE: "WAVE_FORMAT_EXTENSIBLE",
}
def parse_riff_wave(path: Path) -> dict:
"""Parse top-level RIFF/WAVE chunks and return format metadata."""
with path.open("rb") as file:
header = file.read(12)
if len(header) != 12:
raise ValueError("文件不足 12 字节,不是完整的 RIFF/WAVE 文件")
riff_id, riff_size, wave_id = struct.unpack("<4sI4s", header)
if riff_id != b"RIFF" or wave_id != b"WAVE":
raise ValueError("文件头不是 RIFF/WAVE;不能只依据 .wav 后缀判断格式")
chunks: list[dict] = []
fmt: dict | None = None
data_size: int | None = None
while True:
chunk_header = file.read(8)
if not chunk_header:
break
if len(chunk_header) != 8:
raise ValueError("chunk 头不完整,文件可能损坏")
chunk_id, chunk_size = struct.unpack("<4sI", chunk_header)
chunk_name = chunk_id.decode("ascii", errors="replace")
chunk_data_offset = file.tell()
chunks.append(
{"id": chunk_name, "size": chunk_size, "offset": chunk_data_offset}
)
if chunk_id == b"fmt ":
raw = file.read(chunk_size)
if len(raw) < 16:
raise ValueError("fmt chunk 少于 16 字节")
(
format_tag,
channels,
sample_rate,
byte_rate,
block_align,
bits_per_sample,
) = struct.unpack("<HHIIHH", raw[:16])
fmt = {
"format_tag": format_tag,
"format_name": FORMAT_NAMES.get(format_tag, "unknown"),
"channels": channels,
"sample_rate": sample_rate,
"byte_rate": byte_rate,
"block_align": block_align,
"bits_per_sample": bits_per_sample,
}
elif chunk_id == b"data":
data_size = chunk_size
file.seek(chunk_size, 1)
else:
file.seek(chunk_size, 1)
# RIFF chunks are padded to an even-byte boundary.
if chunk_size & 1:
file.seek(1, 1)
if fmt is None:
raise ValueError("未找到 fmt chunk")
if data_size is None:
raise ValueError("未找到 data chunk")
expected_block_align = fmt["channels"] * fmt["bits_per_sample"] // 8
expected_byte_rate = fmt["sample_rate"] * fmt["block_align"]
return {
"path": str(path.resolve()),
"file_size": path.stat().st_size,
"riff_declared_size": riff_size + 8,
**fmt,
"data_size": data_size,
"duration_seconds": (
data_size / fmt["byte_rate"] if fmt["byte_rate"] else None
),
"expected_block_align": expected_block_align,
"expected_byte_rate": expected_byte_rate,
"chunks": chunks,
}
def validate(metadata: dict, sample_rate: int, channels: int, bits: int) -> list[str]:
warnings: list[str] = []
if metadata["format_tag"] != 0x0001:
warnings.append(
f"编码不是整数 PCM:format_tag={metadata['format_tag']} "
f"({metadata['format_name']})"
)
if metadata["sample_rate"] != sample_rate:
warnings.append(
f"采样率不匹配:实际 {metadata['sample_rate']} Hz,期望 {sample_rate} Hz"
)
if metadata["channels"] != channels:
warnings.append(
f"声道数不匹配:实际 {metadata['channels']},期望 {channels}"
)
if metadata["bits_per_sample"] != bits:
warnings.append(
f"位宽不匹配:实际 {metadata['bits_per_sample']} bit,期望 {bits} bit"
)
if metadata["block_align"] != metadata["expected_block_align"]:
warnings.append(
"block_align 与 channels × bits_per_sample / 8 不一致"
)
if metadata["byte_rate"] != metadata["expected_byte_rate"]:
warnings.append("byte_rate 与 sample_rate × block_align 不一致")
if metadata["data_size"] % max(metadata["block_align"], 1) != 0:
warnings.append("data chunk 大小不是 block_align 的整数倍")
if metadata["riff_declared_size"] != metadata["file_size"]:
warnings.append(
f"RIFF 声明大小 {metadata['riff_declared_size']} 与文件大小 "
f"{metadata['file_size']} 不一致(也可能是尾部附加数据)"
)
return warnings
def verify_with_wave(path: Path) -> dict:
"""Cross-check metadata through Python's standard-library wave module."""
with wave.open(str(path), "rb") as reader:
return {
"channels": reader.getnchannels(),
"sample_width_bytes": reader.getsampwidth(),
"sample_rate": reader.getframerate(),
"frames": reader.getnframes(),
"compression_type": reader.getcomptype(),
"duration_seconds": reader.getnframes() / reader.getframerate(),
}
def create_demo(path: Path, sample_rate: int = 16000, seconds: float = 1.0) -> None:
"""Create a mono, 16-bit PCM sine-wave WAV for a reproducible demo."""
path.parent.mkdir(parents=True, exist_ok=True)
frame_count = int(sample_rate * seconds)
amplitude = int(0.2 * 32767)
frames = bytearray()
for index in range(frame_count):
value = int(amplitude * math.sin(2.0 * math.pi * 440.0 * index / sample_rate))
frames.extend(struct.pack("<h", value))
with wave.open(str(path), "wb") as writer:
writer.setnchannels(1)
writer.setsampwidth(2)
writer.setframerate(sample_rate)
writer.writeframes(frames)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("wav", nargs="?", type=Path, help="待检查的 WAV 文件")
parser.add_argument("--expect-rate", type=int, default=16000)
parser.add_argument("--expect-channels", type=int, default=1)
parser.add_argument("--expect-bits", type=int, default=16)
parser.add_argument("--json", action="store_true", help="以 JSON 输出")
parser.add_argument("--create-demo", type=Path, help="创建 16 kHz/mono/16-bit PCM 示例")
args = parser.parse_args()
if args.create_demo:
create_demo(args.create_demo)
print(f"已创建示例:{args.create_demo.resolve()}")
if args.wav is None:
args.wav = args.create_demo
if args.wav is None:
parser.error("请提供 WAV 文件,或使用 --create-demo")
metadata = parse_riff_wave(args.wav)
wave_metadata = verify_with_wave(args.wav)
warnings = validate(
metadata, args.expect_rate, args.expect_channels, args.expect_bits
)
result = {
"riff": metadata,
"python_wave": wave_metadata,
"expected": {
"sample_rate": args.expect_rate,
"channels": args.expect_channels,
"bits_per_sample": args.expect_bits,
},
"status": "PASS" if not warnings else "WARNING",
"warnings": warnings,
}
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(f"文件:{metadata['path']}")
print(
f"编码:{metadata['format_name']} (tag={metadata['format_tag']})\n"
f"采样率:{metadata['sample_rate']} Hz\n"
f"声道:{metadata['channels']}\n"
f"位宽:{metadata['bits_per_sample']} bit\n"
f"时长:{metadata['duration_seconds']:.3f} s\n"
f"data 大小:{metadata['data_size']} bytes\n"
f"chunks:{', '.join(chunk['id'] for chunk in metadata['chunks'])}"
)
if warnings:
print("\n检查结果:WARNING")
for warning in warnings:
print(f"- {warning}")
else:
print("\n检查结果:PASS,符合目标输入规格")
return 0 if not warnings else 1
if __name__ == "__main__":
raise SystemExit(main())
5. 运行与结果
在脚本所在目录运行下列命令,它会生成 1 秒、440 Hz 的合成正弦音频,不需要另行下载示例文件:
python -B inspect_wav.py --create-demo demo_16k_mono_s16.wav
输出中的主要字段:
编码:PCM integer (tag=1)
采样率:16000 Hz
声道:1
位宽:16 bit
时长:1.000 s
data 大小:32000 bytes
chunks:fmt , data
检查结果:PASS,符合目标输入规格
自备文件时,将实际路径作为参数;例如将文件命名为 input.wav 并放在同一目录:
python -B inspect_wav.py input.wav
使用生成的文件,把目标采样率设为 8000 Hz,可以观察不匹配分支和状态码 1:
python -B inspect_wav.py demo_16k_mono_s16.wav --expect-rate 8000
检查结果:WARNING
- 采样率不匹配:实际 16000 Hz,期望 8000 Hz
6. 排错要点
6.1 修改字段不是重采样
修改采样率字段只会改变数据的解释方式。正确重采样需要实际处理采样序列。
6.2 声道不能靠删字节转换
立体声下混需要明确策略。直接把交错数据当作单声道,会破坏时间关系。
6.3 参数自洽不代表符合模型
WAV 头可以完全合法,但采样率仍与模型不同。因此需要同时进行文件合法性检查和业务输入规格检查。
6.4 播放正常不代表数值一致
播放器可能自动解码、重采样或下混。推理链路未必执行相同处理。排查时应固定同一音频,逐层对齐 PCM、特征和模型输出。
7. 输入契约建议
建议把模型输入写成明确配置,例如:
audio_input:
encoding: pcm_s16le
sample_rate: 16000
channels: 1
normalization: int16_div_32768
数据入库、训练和部署都读取同一份约定,并通过脚本自动验证。
8. 结论
PCM 解决采样值如何表示的问题,WAV 解决音频数据与格式信息如何放入文件的问题。语音模型不应信任文件后缀,而应校验编码、采样率、声道、位宽、帧对齐及归一化规则。
下一步将在此基础上继续记录分帧、加窗、STFT 和 Fbank 的实现与张量检查。
参考
作者:野哥李;公众号:AI算法学习社。

浙公网安备 33010602011771号