AIGC标识 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算法学习社。

posted @ 2026-09-26 11:14  野哥李  阅读(4)  评论(0)    收藏  举报