"""无损 WAV 读写核心。 设计要点(面向"高质量、高清晰度"): * 支持 16 / 24 / 32 位整数 PCM 与 32 位浮点(IEEE float)四种载荷; * 24 位采用真正的 3 字节打包,不做 16 位截断; * 多声道、24 位、浮点载荷统一使用 ``WAVE_FORMAT_EXTENSIBLE``(含通道掩码), 避免播放器把 24 位误判为 16 位、把浮点误判为整数; * 高位深写入时可选 **TPDF(三角概率密度)抖动**,把量化失真从 "与信号相关的非线性失真" 变成 "与信号无关的宽带白噪",这是后期降位深的 标准做法,听感明显更干净; * 录音过程中定期回写文件头(``checkpoint``),即使程序崩溃/断电, 已落盘的数据也能被播放器正常识别; * 超过 4 GiB 时可选 RF64(EBU Tech 3306)容器,避免 RIFF 32 位长度溢出。 本模块不依赖 numpy 以外的任何第三方库。 """ from __future__ import annotations import os import struct from dataclasses import dataclass from typing import BinaryIO, Iterator import numpy as np WAVE_FORMAT_PCM = 0x0001 WAVE_FORMAT_IEEE_FLOAT = 0x0003 WAVE_FORMAT_EXTENSIBLE = 0xFFFE # KSDATAFORMAT_SUBTYPE_* _SUBTYPE_PCM = bytes.fromhex("0100000000001000800000aa00389b71") _SUBTYPE_FLOAT = bytes.fromhex("0300000000001000800000aa00389b71") # 声道掩码(dwChannelMask),用于 EXTENSIBLE 头部 _CHANNEL_MASKS = { 1: 0x4, # SPEAKER_FRONT_CENTER -> 单声道习惯上用 CENTER,但很多软件写 FRONT_LEFT 2: 0x3, # FRONT_LEFT | FRONT_RIGHT 4: 0x33, # FL FR BL BR 6: 0x3F, 8: 0x63F, } # 1 GiB 保留量:RIFF 数据块上限 4 GiB-1,留出余量给头部与安全边界 RIFF_DATA_LIMIT = 0xFFF00000 - 64 __all__ = [ "WavFormat", "WavWriter", "WavReader", "wav_info", "read_wav", "write_wav", "RIFF_DATA_LIMIT", "SUPPORTED_BIT_DEPTHS", ] # (名称, 位深, 采样格式) —— 直接对应界面上的"位深"下拉框 SUPPORTED_BIT_DEPTHS = ("16", "24", "32", "float32") @dataclass(frozen=True) class WavFormat: """描述一个 WAV 载荷格式。""" samplerate: int channels: int encoding: str # 'pcm' | 'float' bits: int # 每个采样的有效位宽(float 为 32) @property def bytes_per_sample(self) -> int: return self.bits // 8 @property def frame_bytes(self) -> int: return self.bytes_per_sample * self.channels @property def bytes_per_second(self) -> float: return float(self.frame_bytes) * self.samplerate @property def block_align(self) -> int: return self.frame_bytes @property def label(self) -> str: if self.encoding == "float": return "32-bit float" return f"{self.bits}-bit PCM" def describe(self) -> str: return ( f"{self.samplerate} Hz / {self.label} / " f"{'单声道' if self.channels == 1 else f'{self.channels} 声道'}" ) def _fmt_for_bitdepth(bit_depth: int | str) -> tuple[str, int]: """把界面上的位深选项翻译成 (encoding, bits)。""" if isinstance(bit_depth, str): s = bit_depth.strip().lower() if s in ("float", "float32", "f32", "32f"): return "float", 32 s = s.rstrip("bit").rstrip("-").strip() bit_depth = int(s) bit_depth = int(bit_depth) if bit_depth == 16: return "pcm", 16 if bit_depth == 24: return "pcm", 24 if bit_depth == 32: return "pcm", 32 if bit_depth == 64: raise ValueError("不支持 64 位整数 PCM;如需更高精度请使用 32-bit float") raise ValueError(f"不支持的位深: {bit_depth}") def _safe_scale(bits: int) -> float: """整数满量程对应的浮点值。 采用 ``2**(bits-1) - 1``(对称满量程),这样 +1.0 与 -1.0 都能被精确表示, 不会出现 "正半周先削顶、负半周仍有余量" 的非对称削波。 """ return float((1 << (bits - 1)) - 1) class _TpdfDither: """TPDF 抖动噪声源。 三角分布 = 两个独立均匀分布之和,峰峰值恰好 1 LSB, 这是业界公认的"无调制噪声"抖动,优于矩形(RPDF)抖动。 """ def __init__(self, seed: int | None = None): self._rng = np.random.Generator(np.random.PCG64(seed)) def generate(self, size: int | tuple[int, ...], lsb: float = 1.0) -> np.ndarray: u1 = self._rng.random(size) u2 = self._rng.random(size) return (u1 - u2) * lsb def reseed(self, seed: int) -> None: self._rng = np.random.Generator(np.random.PCG64(seed)) def float_to_pcm(data: np.ndarray, encoding: str, bits: int, dither: _TpdfDither | None = None) -> bytes: """把 float32/float64 的 [-1, 1] 数据编码为 WAV 载荷字节。""" x = np.asarray(data) if encoding == "float": return np.ascontiguousarray(x, dtype=" np.ndarray: """WAV 载荷字节 -> float64 数组([-1, 1] 标度)。""" if encoding == "float": return np.frombuffer(raw, dtype=" 16 or fmt.channels > 2 ) self._fh: BinaryIO = open(self.path, "wb", buffering=1024 * 1024) hdr = self._build_header() self._header = hdr self._data_size_offset = hdr.data_offset - 4 self._fh.write(hdr.bytes) self._fh.flush() # ---------------------------------------------------------------- header def _build_header(self) -> "_Header": f = self.fmt if f.encoding == "float": fmt_tag = WAVE_FORMAT_IEEE_FLOAT if not self._extensible else WAVE_FORMAT_EXTENSIBLE subtype = _SUBTYPE_FLOAT else: fmt_tag = WAVE_FORMAT_PCM if not self._extensible else WAVE_FORMAT_EXTENSIBLE subtype = _SUBTYPE_PCM if self._extensible: mask = _CHANNEL_MASKS.get(f.channels, 0) fmt_body = struct.pack( " int: """写入一块浮点音频(形状 (n,) 或 (n, channels)),返回写入的采样帧数。""" if self.closed: raise ValueError("写入器已关闭") x = np.asarray(data, dtype=np.float32) if x.ndim == 1: x = x.reshape(-1, 1) if x.shape[1] != self.fmt.channels: raise ValueError( f"通道数不匹配:收到 {x.shape[1]},期望 {self.fmt.channels}") n = int(x.shape[0]) if n == 0: return 0 # 统计(在抖动/削波之前,反映真实输入) blk_peak = float(np.max(np.abs(x))) if n else 0.0 if blk_peak > self.peak: self.peak = blk_peak if blk_peak > 1.0: self.clipped_samples += int(np.count_nonzero(np.abs(x) > 1.0)) payload = float_to_pcm(x, self.fmt.encoding, self.fmt.bits, self._dither) # 先判断是否越界,再落盘:这样调用方可以无损地切到下一个文件 if not self.rf64 and self.bytes_written + len(payload) > RIFF_DATA_LIMIT: raise _RiffOverflow( f"RIFF 容器已达 4 GiB 上限(已写 {self.bytes_written} 字节)," "请启用自动分段或 RF64 模式") self._fh.write(payload) self.frames_written += n self.bytes_written += len(payload) return n def checkpoint(self, now: float | None = None) -> None: """定期回写长度字段,保证异常退出后文件仍可播放。""" if self.closed: return now = _monotonic() if now is None else now if now - self._last_checkpoint < self._checkpoint_interval: return self._last_checkpoint = now self._patch_sizes(final=False) def _patch_sizes(self, *, final: bool) -> None: f = self.fmt data_bytes = self.frames_written * f.frame_bytes pos = self._fh.tell() if self.rf64: body_len = len(self._header.bytes) - 8 + data_bytes if final: self._fh.seek(4) self._fh.write(struct.pack(" dict: if self.closed: return self.stats() self._patch_sizes(final=True) self._fh.flush() try: os.fsync(self._fh.fileno()) except OSError: pass self._fh.close() self.closed = True return self.stats() def stats(self) -> dict: f = self.fmt duration = self.frames_written / f.samplerate if f.samplerate else 0.0 return { "path": self.path, "frames": self.frames_written, "duration": duration, "bytes": self.bytes_written, "peak": self.peak, "peak_dbfs": 20 * np.log10(self.peak) if self.peak > 0 else float("-inf"), "clipped_samples": self.clipped_samples, "format": f.describe(), "dither": self._dither is not None, "rf64": self.rf64, } def abort(self) -> None: """放弃写入:关闭句柄但不回写头部(用于创建后立即失败的场景)。""" if not self.closed: try: self._fh.close() finally: self.closed = True def __enter__(self) -> "WavWriter": return self def __exit__(self, *exc) -> None: if exc and exc[0] is not None: self.abort() else: self.close() @dataclass class _Header: bytes: bytes data_offset: int riff_overhead: int ds64_offset: int | None = None class _RiffOverflow(Exception): """RIFF 4 GiB 上限。引擎捕获后自动分段。""" def _monotonic() -> float: import time return time.monotonic() class WavReader: """轻量 WAV 读取器,支持 PCM 16/24/32、float32、EXTENSIBLE、RF64。""" def __init__(self, path: str): self.path = os.fspath(path) self.fh = open(self.path, "rb") self._parse() def _parse(self) -> None: fh = self.fh hdr = fh.read(12) if len(hdr) < 12: raise ValueError("不是有效的 WAV 文件(文件过短)") riff, _size, wave = hdr[:4], hdr[4:8], hdr[8:12] if riff == b"RF64": self.rf64 = True elif riff == b"RIFF": self.rf64 = False else: raise ValueError("不是有效的 WAV 文件(缺少 RIFF/RF64 标记)") if wave != b"WAVE": raise ValueError("不是有效的 WAV 文件(缺少 WAVE 标记)") self.fmt_tag = None self.channels = self.samplerate = self.bits = 0 self.encoding = "pcm" self.data_offset = self.data_size = 0 self.frames = 0 self._ds64_data_size = None self._ds64_frames = None self._fact_frames = None while True: cid = fh.read(4) if len(cid) < 4: break (csize,) = struct.unpack("= 40: (bits,) = struct.unpack("= 4: (self._fact_frames,) = struct.unpack(" WavFormat: return WavFormat(self.samplerate, self.channels, self.encoding, self.bits) @property def duration(self) -> float: return self.frames / self.samplerate if self.samplerate else 0.0 def read(self, start_frame: int = 0, num_frames: int | None = None) -> np.ndarray: """读取为 float64 数组,形状 (frames, channels),范围 [-1, 1]。""" bps = self.bits // 8 if num_frames is None: num_frames = max(0, self.frames - start_frame) num_frames = min(num_frames, max(0, self.frames - start_frame)) if num_frames <= 0: return np.zeros((0, self.channels), dtype=np.float64) self.fh.seek(self.data_offset + start_frame * self.channels * bps) raw = self.fh.read(num_frames * self.channels * bps) flat = pcm_to_float(raw, self.encoding, self.bits) usable = (flat.size // self.channels) * self.channels return flat[:usable].reshape(-1, self.channels) def iter_blocks(self, block_frames: int = 1 << 16) -> Iterator[np.ndarray]: """分块迭代读取,便于处理超长录音而不占内存。""" pos = 0 while pos < self.frames: block = self.read(pos, block_frames) if block.size == 0: break yield block pos += block.shape[0] def close(self) -> None: if not self.fh.closed: self.fh.close() def __enter__(self) -> "WavReader": return self def __exit__(self, *exc) -> None: self.close() def info(self) -> dict: return { "path": self.path, "samplerate": self.samplerate, "channels": self.channels, "encoding": self.encoding, "bits": self.bits, "frames": self.frames, "duration": self.duration, "data_bytes": self.data_size, "rf64": self.rf64, "format": self.fmt.describe(), } def wav_info(path: str) -> dict: with WavReader(path) as r: return r.info() def read_wav(path: str, *, max_seconds: float | None = None) -> tuple[np.ndarray, int]: """读取整个 WAV,返回 ``(data, samplerate)``;``max_seconds`` 可只读前段。""" with WavReader(path) as r: n = None if max_seconds is None else int(max_seconds * r.samplerate) return r.read(0, n), r.samplerate def write_wav(path: str, data: np.ndarray, samplerate: int, *, bit_depth: int | str = 24, dither: bool = True, rf64: bool = False) -> dict: """一次性写出 WAV(离线后期处理用)。""" x = np.asarray(data, dtype=np.float32) if x.ndim == 1: x = x[:, None] encoding, bits = _fmt_for_bitdepth(bit_depth) fmt = WavFormat(int(samplerate), int(x.shape[1]), encoding, bits) w = WavWriter(path, fmt, dither=dither, rf64=rf64) try: # 分块写,避免超大数组一次性编码造成内存峰值 step = 1 << 18 for i in range(0, x.shape[0], step): w.write(x[i:i + step]) finally: stats = w.close() stats["format"] = fmt.describe() return stats