"""自检测试:数学正确性 + 格式往返 + 硬件端到端。 用法:: python -m recorder.selftest # 全部(含真实硬件录音,约 20 秒) python -m recorder.selftest --no-hw # 只跑离线数学/格式测试(无需麦克风) python -m recorder.selftest --quick # 硬件测试缩短到 1 秒 """ from __future__ import annotations import argparse import math import os import sys import time import numpy as np from . import dsp, engine, post from .wavfile import WavReader, _TpdfDither, float_to_pcm, pcm_to_float, write_wav class Runner: def __init__(self, quiet: bool = False): self.passed = 0 self.failed: list[tuple[str, str]] = [] self.quiet = quiet self.current = "" def section(self, name: str) -> None: self.current = name if not self.quiet: print(f"\n── {name} " + "─" * max(0, 56 - len(name))) def check(self, name: str, ok: bool, detail: str = "") -> bool: if ok: self.passed += 1 if not self.quiet: print(f" ✓ {name}" + (f" {detail}" if detail else "")) else: self.failed.append((f"{self.current} / {name}", detail)) print(f" ✗ {name} {detail}", file=sys.stderr) return bool(ok) def near(self, name: str, got: float, expect: float, tol: float, unit: str = "") -> bool: ok = abs(float(got) - float(expect)) <= tol return self.check(name, ok, f"实测 {got:.4f}{unit},期望 {expect:.4f}±{tol:g}{unit}") @property def ok(self) -> bool: return not self.failed # ------------------------------------------------------------- 1. 响度与滤波 def test_loudness(r: Runner) -> None: r.section("ITU-R BS.1770 响度标准符合性") fb1, fa1 = dsp._itu_highshelf(48000) fb2, fa2 = dsp._itu_highpass(48000) itu_b1 = np.array([1.53512485958697, -2.69169618940638, 1.19839281085285]) itu_a1 = np.array([1.0, -1.69065929318241, 0.73248077421585]) itu_b2 = np.array([1.0, -2.0, 1.0]) itu_a2 = np.array([1.0, -1.99004745483398, 0.99007225036621]) r.check("高频搁架设计系数与 ITU 原文一致", np.max(np.abs(fb1 - itu_b1)) < 1e-12 and np.max(np.abs(fa1 - itu_a1)) < 1e-12, f"最大偏差 {max(np.max(np.abs(fb1 - itu_b1)), np.max(np.abs(fa1 - itu_a1))):.2e}") r.check("二阶高通设计系数与 ITU 原文一致", np.max(np.abs(fb2 - itu_b2)) < 1e-12 and np.max(np.abs(fa2 - itu_a2)) < 1e-12, f"最大偏差 {max(np.max(np.abs(fb2 - itu_b2)), np.max(np.abs(fa2 - itu_a2))):.2e}") for sr in (22050, 44100, 96000): for b, a in dsp.kweighting_coeffs(sr): r.check(f"{sr} Hz K 加权滤波器稳定", float(np.max(np.abs(np.roots(a)))) < 1.0) wref = 2 * math.pi * 997.0 / 48000 gain = 10 * math.log10(float(( dsp.biquad_response_sq(fb1, fa1, np.array([wref])) * dsp.biquad_response_sq(fb2, fa2, np.array([wref])))[0])) r.near("K 加权在 997 Hz 的增益 = +0.691 dB(标准偏移量的来源)", gain, 0.691, 0.01, " dB") sr = 48000 t = np.arange(sr * 5) / sr sine = 0.999 * np.sin(2 * np.pi * 997 * t) st = np.stack([sine, sine], axis=1) def loud(data, ch): m = dsp.LoudnessMeter(sr, ch) for i in range(0, data.shape[0], 4096): m.push(data[i:i + 4096]) m.flush() return m.integrated # 期望值已用 ffmpeg 的 ebur128 滤波器独立验证过 r.near("满量程立体声 997 Hz 正弦 = 0.0 LUFS(ffmpeg 实测 -0.0)", loud(st, 2), 0.0, 0.1, " LUFS") r.near("满量程单声道 997 Hz 正弦 = -3.01 LUFS(ffmpeg 实测 -3.0)", loud(sine[:, None], 1), -3.010, 0.1, " LUFS") r.near("电平 -20 dB 时响度同步下降 20 LU(ffmpeg 实测 -20.0)", loud(st * 0.1, 2), -20.0, 0.1, " LUFS") rng = np.random.default_rng(7) noise = rng.standard_normal((sr * 6, 2)) * 0.1 got = loud(noise, 2) r.near("宽带白噪声响度(ffmpeg 实测 -13.8 LUFS)", got, -13.84, 0.15, " LUFS") def test_filters(r: Runner) -> None: r.section("线性相位滤波器") h = dsp.design_highpass_fir(80.0, 48000) k = np.arange(h.size) def resp(f): w = 2 * np.pi * f / 48000 return float(np.abs(np.sum(h * np.exp(-1j * w * k)))) r.check("直流增益 < -60 dB", 20 * math.log10(max(resp(0.0), 1e-12)) < -60, f"{20 * math.log10(max(resp(0.0), 1e-12)):.1f} dB") r.check("1 kHz 通带增益 ≈ 0 dB", abs(20 * math.log10(resp(1000.0))) < 0.02, f"{20 * math.log10(resp(1000.0)):+.4f} dB") r.check("100 Hz 通带起伏 < 0.35 dB", abs(20 * math.log10(resp(100.0))) < 0.35, f"{20 * math.log10(resp(100.0)):+.3f} dB") r.check("20 Hz 抑制 > 40 dB", 20 * math.log10(resp(20.0)) < -40, f"{20 * math.log10(resp(20.0)):.1f} dB,{h.size} 抽头") r.check("系数严格对称(线性相位)", np.allclose(h, h[::-1], atol=1e-15)) # 群延迟对齐:首尾各补一段直流,滤波后不应丢样本 x = np.concatenate([np.zeros((5000, 1)), np.ones((5000, 1)) * 0.5, np.zeros((5000, 1))]) y = dsp.highpass_offline(x, 48000, 80.0) r.check("离线低切保持样本数(群延迟已补偿)", y.shape[0] == x.shape[0] - (h.size - 1) // 2, f"{x.shape[0]} → {y.shape[0]}") def test_true_peak(r: Runner) -> None: r.section("真峰值(4 倍过采样)") sr = 48000 t = np.arange(sr) / sr aligned = np.sin(2 * np.pi * 12000 * t) * 0.5 r.near("采样点对齐时真峰值 = 采样峰值 0.5", float(dsp.true_peak(aligned[:, None])[0]), 0.5, 0.005) shifted = np.sin(2 * np.pi * 12000 * t + np.pi / 4) * 0.5 sp = float(np.max(np.abs(shifted))) tp = float(dsp.true_peak(shifted[:, None])[0]) r.check("能捕捉采样点之间的过冲(这是 dBTP 的意义)", tp > sp * 1.2 and abs(tp - 0.5) < 0.01, f"采样峰值 {sp:.4f} → 真峰值 {tp:.4f}(理论 0.5)") mono = np.zeros((0, 1)) r.check("空输入不崩溃", dsp.true_peak(mono).shape == (1,)) def test_meters(r: Runner) -> None: r.section("电平表与响度计(流式)") sr = 48000 m = dsp.LevelMeter(sr, 2) x = np.stack([np.ones(1024) * 0.5, np.ones(1024) * 0.25], axis=1).astype(np.float32) for _ in range(20): m.process(x) snap = m.snapshot() r.near("RMS 计算正确(0.5 → -6.02 dBFS)", snap.rms_db[0], -6.02, 0.02, " dB") r.near("峰值计算正确(0.25 → -12.04 dBFS)", snap.peak_db[1], -12.04, 0.02, " dB") m.process(np.ones((256, 2), dtype=np.float32)) r.check("削波锁存生效", m.snapshot().clipped[0] and m.snapshot().clip_count > 0) m.reset_clip() r.check("削波锁存可复位", not m.snapshot().clipped[0]) lm = dsp.LoudnessMeter(sr, 2, keep_hops=False) t2 = np.arange(sr) / sr sine = np.sin(2 * np.pi * 997 * t2) * 0.1 for i in range(0, sr, 4800): lm.push(np.stack([sine[i:i + 4800]] * 2, axis=1)) r.check("不保留历史时整体响度返回 -inf 而不是乱码", not math.isfinite(lm.integrated)) r.near("瞬时响度读数正确(-20 dBFS 立体声正弦 ≈ -20 LUFS)", lm.momentary, -20.0, 0.3, " LUFS") dc_meter = dsp.LoudnessMeter(sr, 2) dc_meter.push(np.ones((sr, 2)) * 0.1) dc_meter.flush() r.check("纯直流被 K 加权高通滤除(响度极低)", dc_meter.momentary < -60, f"{dc_meter.momentary:.1f} LUFS") # ------------------------------------------------------- 2. WAV 格式与量化 def test_wav(r: Runner, tmp: str) -> None: r.section("WAV 编解码与量化") rng = np.random.default_rng(11) sig = ((rng.random((48000, 2)) * 2 - 1) * 0.5).astype(np.float32) cases = [("16", 1.6 / 32768), ("24", 2.1 / 8388608), ("32", 1e-7), ("float32", 1e-7)] for depth, tol in cases: p = os.path.join(tmp, f"rt_{depth}.wav") stats = write_wav(p, sig, 48000, bit_depth=depth, dither=(depth != "float32")) with WavReader(p) as rd: back = rd.read() info = rd.info() err = float(np.max(np.abs(back - sig))) r.check(f"{depth} 位往返误差在量化极限内", back.shape == sig.shape and err <= tol, f"最大误差 {err:.2e}(容差 {tol:.1e}),{info['format']}") # 头部正确性:用 Python 标准库 wave 模块独立复核 16 位文件 import wave as pywave p16 = os.path.join(tmp, "rt_16.wav") with pywave.open(p16, "rb") as w: r.check("标准库 wave 能读我们的 16 位文件", w.getnchannels() == 2 and w.getframerate() == 48000 and w.getsampwidth() == 2 and w.getnframes() == 48000, f"{w.getnchannels()}ch {w.getframerate()}Hz {w.getsampwidth()*8}bit " f"{w.getnframes()}帧") # 24 位文件用 ffmpeg 独立解码核对 if post.find_ffmpeg(): import subprocess p24 = os.path.join(tmp, "rt_24.wav") proc = subprocess.run( [post.find_ffmpeg(), "-hide_banner", "-v", "error", "-i", p24, "-f", "s16le", "-ac", "2", "-ar", "48000", "-"], capture_output=True) decoded = np.frombuffer(proc.stdout, dtype=" None: r.section("TPDF 抖动") sr = 48000 t = np.arange(sr * 2) / sr lo = (np.sin(2 * np.pi * 300 * t) * dsp.db_to_lin(-80)).astype(np.float64) def qerr(x, use_dither): d = _TpdfDither(4242) if use_dither else None raw = float_to_pcm(x[:, None], "pcm", 16, d) return pcm_to_float(raw, "pcm", 16) - x e_plain = qerr(lo, False) e_dith = qerr(lo, True) c_plain = abs(float(np.corrcoef(e_plain, lo)[0, 1])) c_dith = abs(float(np.corrcoef(e_dith, lo)[0, 1])) r.check("未抖动时量化误差与信号强相关(会产生非线性失真)", c_plain > 0.05, f"相关系数 {c_plain:.4f}") r.check("加抖动后误差与信号去相关", c_dith < 0.05 and c_dith < c_plain, f"相关系数 {c_dith:.4f}") lsb = 1.0 / 32767 r.near("抖动总误差方差 = 1/4 LSB²(1/6 抖动 + 1/12 量化)", float(np.var(e_dith)) / lsb ** 2, 0.25, 0.02) r.near("未抖动误差方差 = 1/12 LSB²", float(np.var(e_plain)) / lsb ** 2, 1 / 12, 0.02) def test_bitdepth(r: Runner) -> None: r.section("位深与满量程处理") edge = np.array([[1.0], [-1.0], [0.0], [1.5], [-1.5], [0.99999]], dtype=np.float64) for depth in ("16", "24", "32"): raw = float_to_pcm(edge, "pcm", int(depth), None) back = pcm_to_float(raw, "pcm", int(depth)) r.check(f"{depth} 位满量程对称且不越界", abs(back[0] - 1.0) < 1e-6 and abs(back[1] + 1.0) < 1e-6 and abs(back[2]) < 1e-9, f"+1→{back[0]:.6f} −1→{back[1]:.6f} 0→{back[2]:.1e}") f = float_to_pcm(edge, "float", 32, None) r.check("32 位浮点保留超量程信息(不做削波,便于后期)", len(f) == edge.size * 4) # ------------------------------------------------------------ 3. 离线处理 def test_processing(r: Runner, tmp: str) -> None: r.section("离线处理与导出") sr = 48000 rng = np.random.default_rng(5) silence = rng.standard_normal((sr, 2)) * dsp.db_to_lin(-80) tone = np.stack([np.sin(2 * np.pi * 440 * np.arange(sr * 2) / sr) * 0.2] * 2, axis=1) x = np.concatenate([silence, tone, silence], axis=0) x += 0.01 # 人为加入直流偏移 opts = post.ProcessOptions(trim_silence=True, remove_dc=True, lowcut_hz=60.0, normalize="peak", normalize_target_dbfs=-1.0, fade_in=0.01, fade_out=0.05, bit_depth="24") y, report = post.process_array(x, sr, opts) r.check("后期处理链执行成功", y.shape[0] > 0 and len(report["steps"]) >= 4, "、".join(report["steps"])) r.check("裁剪掉了首尾静音", y.shape[0] < x.shape[0] - sr, f"{x.shape[0]} → {y.shape[0]} 帧") r.near("直流偏移被消除", float(np.mean(y)), 0.0, 1e-6) r.near("峰值归一化到目标 -1 dBFS", float(report["output_peak_dbfs"][0]), -1.0, 0.15, " dBFS") r.near("淡入淡出生效(首样本接近 0)", float(abs(y[0, 0])), 0.0, 0.02) src = os.path.join(tmp, "proc_src.wav") write_wav(src, x.astype(np.float32), sr, bit_depth="24") rep = post.process_file(src, opts) r.check("处理结果写到新文件,原文件保持不变", os.path.exists(rep["output"]) and os.path.exists(src) and rep["output"] != src, os.path.basename(rep["output"])) ok, msg = post.export_audio(rep["output"], os.path.join(tmp, "out16.wav"), "wav_16") r.check("导出 16 位 WAV", ok, msg) # 关键安全约束:导出目标与源文件同名时绝不覆盖母版 same = post.default_export_path(rep["output"], "wav_16") r.check("导出目标与源同名时自动改名,母版不会被覆盖", os.path.abspath(same) != os.path.abspath(rep["output"]), os.path.basename(same)) with WavReader(rep["output"]) as before: frames_before = before.frames bits_before = before.bits ok, _msg = post.export_audio(rep["output"], rep["output"], "wav_16") with WavReader(rep["output"]) as after: r.check("即使显式传入源路径,源文件位深/长度也不被改动", ok and after.frames == frames_before and after.bits == bits_before, f"{after.bits} 位,{after.frames} 帧") if post.find_ffmpeg(): ok, msg = post.export_audio(rep["output"], os.path.join(tmp, "out.flac"), "flac") r.check("导出 FLAC", ok, msg) else: r.check("缺少 ffmpeg 时给出清晰提示", not post.export_audio(rep["output"], os.path.join(tmp, "x.mp3"), "mp3_320")[0]) png = post.write_waveform_png(os.path.join(tmp, "wave.png"), y, sr) with open(png, "rb") as fh: head = fh.read(8) r.check("纯 numpy 生成的 PNG 波形图有效", head == b"\x89PNG\r\n\x1a\n" and os.path.getsize(png) > 1000, f"{os.path.getsize(png)} 字节") # 分析 & 报告 an = dsp.analyze_file(rep["output"]) r.check("文件体检字段齐全", all(k in an for k in ("peak_dbfs", "true_peak_dbtp", "rms_dbfs", "integrated_lufs", "loudness_range_lu", "dc_offset", "noise_floor_dbfs", "clipped_total")), f"峰值 {an['peak_dbfs']},真峰值 {an['true_peak_dbtp']}," f"响度 {an['integrated_lufs']} LUFS") r.check("体检报告可渲染", "RecorderStudio 文件体检" in post.regenerate_report(rep["output"])) # 多分段合并统计 a = {"duration": 10.0, "peak_dbfs": [-3.0], "true_peak_dbtp": [-2.8], "integrated_lufs": -20.0, "clipped_total": 0} b = {"duration": 30.0, "peak_dbfs": [-1.0], "true_peak_dbtp": [-0.9], "integrated_lufs": -14.0, "clipped_total": 3} m = engine._merge_analysis([a, b]) r.check("分段合并取最严值", m["peak_dbfs"] == [-1.0] and m["true_peak_dbtp"] == [-0.9] and m["clipped_total"] == 3 and abs(m["duration"] - 40.0) < 1e-6, f"峰值 {m['peak_dbfs']},削波 {m['clipped_total']},时长 {m['duration']}") r.check("响度按能量加权合并(应介于两段之间)", -20.0 < m["integrated_lufs"] < -14.0, f"{m['integrated_lufs']} LUFS") def test_metadata(r: Runner, tmp: str) -> None: r.section("元数据与报告") sr = 48000 tone = np.sin(2 * np.pi * 440 * np.arange(sr) / sr) * 0.3 path = os.path.join(tmp, "meta_test.wav") write_wav(path, np.stack([tone, tone], axis=1).astype(np.float32), sr, bit_depth="24") result = engine.TakeResult( files=[path], duration=1.0, frames=sr, bytes_written=os.path.getsize(path), peak_dbfs=-10.4, markers=[engine.Marker("测试标记", os.path.basename(path), 0.5, "2024-01-01T00:00:00")], config=engine.RecordConfig(samplerate=sr, bit_depth="24", channels=2), started_at="2024-01-01T00:00:00", ended_at="2024-01-01T00:00:01", device_label="测试设备 [WASAPI]", format_label="48000 Hz / 24-bit PCM / 2 声道", analysis=dsp.analyze_file(path)) files = post.write_metadata(result) import json with open(files[0], encoding="utf-8") as fh: meta = json.load(fh) r.check("元数据 JSON 含关键字段", meta["bit_depth"] == "24" and meta["sample_rate"] == sr and meta["markers"][0]["label"] == "测试标记" and meta["analysis"]["peak_dbfs"] is not None, "、".join(k for k in ("format", "markers", "analysis"))) r.check("文本报告含标记与体检", "测试标记" in post.render_report(result) and "整体响度" in post.render_report(result)) # ---------------------------------------------------------- 4. 引擎(离线) def test_engine_offline(r: Runner, tmp: str) -> None: r.section("引擎(离线部分)") cfg = engine.RecordConfig(samplerate=48000, channels=2, bit_depth="24") r.check("配置清洗:非法采样率/声道被夹紧", engine.RecordConfig(samplerate=1, channels=99, gain_db=999, bit_depth="24").sanitized().channels <= 32) r.near("24 位 48 kHz 立体声码率 = 288 kB/s", cfg.wav_format().bytes_per_second, 288000.0, 1.0, " B/s") r.check("小时体积估算正确", abs(cfg.estimate_bytes_per_hour() - 288000 * 3600) < 1, engine.format_bytes(cfg.estimate_bytes_per_hour())) r.check("时长格式化", engine.format_duration(3725.4) == "1:02:05.4", engine.format_duration(3725.4)) r.check("体积格式化", engine.format_bytes(1024 * 1024 * 3) == "3.0 MB", engine.format_bytes(1024 * 1024 * 3)) r.check("磁盘空间可查", engine.free_space_bytes(tmp) > 0, engine.format_bytes(engine.free_space_bytes(tmp))) lc = engine.AlignedLowCut(80.0, 48000, 2) r.check("低切滤波器抽头数与群延迟合理", lc.taps > 1000 and lc.latency > 0, f"{lc.taps} 抽头,群延迟 {lc.latency / 48.0:.1f} ms") total_in = 0 total_out = 0 rng = np.random.default_rng(3) for _ in range(20): blk = rng.standard_normal((1024, 2)).astype(np.float32) * 0.1 total_in += blk.shape[0] total_out += lc.process(blk).shape[0] total_out += lc.flush().shape[0] r.check("低切流式处理后样本数严格守恒(不丢头不掉尾)", total_in == total_out, f"输入 {total_in} 帧 → 输出 {total_out} 帧") # 文件名模板 rec = engine.Recorder(cfg) rec._out_dir = tmp # noqa: SLF001 rec.config.name_template = "{date}_{time}_{sr}_{bits}_{ch}_{seq}" p = rec._next_path() # noqa: SLF001 r.check("命名模板变量全部替换且无非法字符", "{" not in p and "}" not in p and p.endswith(".wav") and "48000" in p and "24" in p and "2ch" in p, os.path.basename(p)) # 设备枚举(不要求存在设备) devs = engine.list_input_devices() r.check("设备枚举可调用", isinstance(devs, list), f"发现 {len(devs)} 个输入设备" if devs else "当前机器无输入设备") if devs: r.check("设备按宿主 API 保真度排序", all(devs[i].quality_rank <= devs[i + 1].quality_rank for i in range(len(devs) - 1)), " → ".join(dict.fromkeys(d.hostapi for d in devs))) # ---------------------------------------------------------- 5. 硬件端到端 def test_hardware(r: Runner, tmp: str, seconds: float = 1.5) -> None: r.section("硬件端到端录音") if engine.sd is None: r.check("音频后端可用", False, f"sounddevice 不可用:{engine.SD_IMPORT_ERROR}") return devs = [d for d in engine.list_input_devices() if d.max_input_channels > 0] if not devs: r.check("存在可用输入设备", False, "本机没有录音输入设备,已跳过硬件测试") return # 优先选 WASAPI 设备 dev = next((d for d in devs if d.quality_rank == 0), devs[0]) r.check("选中设备", True, f"[{dev.index}] {dev.name} [{dev.hostapi}]") rate = int(dev.default_samplerate) ch = min(2, dev.max_input_channels) is_wasapi = "wasapi" in dev.hostapi.lower() for depth, lowcut in (("24", 0.0), ("float32", 80.0)): cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch, bit_depth=depth, exclusive=is_wasapi, lowcut_hz=lowcut, output_dir=tmp, name_template=f"hw_{depth}_{int(lowcut)}", split_seconds=0.0) rec = engine.Recorder(cfg) try: rec.start() except Exception as exc: r.check(f"{depth} 位开流", False, str(exc).splitlines()[0]) continue time.sleep(seconds) marker = rec.add_marker("自检测试标记") snap = rec.live() time.sleep(0.2) res = rec.stop() expect = res.duration r.check(f"{depth} 位录音时长接近请求值", abs(expect - (seconds + 0.2)) < 0.6, f"实得 {expect:.3f}s(请求 {seconds + 0.2:.1f}s)") r.check(f"{depth} 位文件存在且可解析", bool(res.files) and all(os.path.exists(f) for f in res.files), "、".join(os.path.basename(f) for f in res.files)) if res.files: with WavReader(res.files[0]) as rd: info = rd.info() frames_ok = abs(info["duration"] - res.duration) < 0.05 r.check(f"{depth} 位头部元数据自洽", frames_ok and info["samplerate"] == rate, f"{info['format']},{info['frames']} 帧") r.check(f"{depth} 位格式符合请求", (info["bits"] == int(depth)) if depth != "float32" else info["encoding"] == "float", info["format"]) r.check(f"{depth} 位无驱动溢出/xrun", res.xruns == 0 and res.overflow_blocks == 0, f"xrun {res.xruns},溢出块 {res.overflow_blocks}") r.check(f"{depth} 位标记已记录", marker is not None and len(res.markers) == 1, f"{len(res.markers)} 个标记") r.check(f"{depth} 位体检报告生成", bool(res.analysis), f"峰值 {res.peak_dbfs:+.1f} dBFS," f"响度 {(res.analysis or {}).get('integrated_lufs')} LUFS") if lowcut > 0 and res.files: # 低切路径同样要做到样本数守恒(对齐正确) expected_frames = int(round(res.duration * rate)) with WavReader(res.files[0]) as rd: r.check("启用低切后样本数与时长一致(对齐正确)", abs(rd.frames - expected_frames) <= 2, f"{rd.frames} 帧 vs 期望约 {expected_frames} 帧") # 分段能力 cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch, bit_depth="24", exclusive=is_wasapi, output_dir=tmp, name_template="hw_split", split_seconds=0.5) rec = engine.Recorder(cfg) rec.start() time.sleep(1.6) res = rec.stop() r.check("按时长自动分段生效", len(res.files) >= 3, f"{len(res.files)} 个文件:" + "、".join(f"{WavReader(f).duration:.2f}s" for f in res.files if os.path.exists(f))) if res.files: total = 0.0 for f in res.files: with WavReader(f) as rd: total += rd.duration r.check("分段后总时长不丢数据", abs(total - res.duration) < 0.05, f"文件合计 {total:.3f}s vs 统计 {res.duration:.3f}s") test_hardware_auto_stop(r, tmp, dev, rate, ch, is_wasapi) def test_hardware_auto_stop(r: Runner, tmp: str, dev, rate: int, ch: int, is_wasapi: bool) -> None: """手动分段与静音自动停止(这两条路径最容易出"文件没收尾/流没关"的问题)。""" # 手动分段 cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch, bit_depth="24", exclusive=is_wasapi, output_dir=tmp, name_template="hw_manual_split") rec = engine.Recorder(cfg) rec.start() time.sleep(0.6) rec.split_now() time.sleep(0.7) res = rec.stop() r.check("手动分段立即切换新文件", len(res.files) == 2, "、".join(f"{WavReader(f).duration:.2f}s" for f in res.files if os.path.exists(f))) # 静音自动停止(阈值故意设得很高,保证当前环境一定能触发) cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch, bit_depth="24", exclusive=is_wasapi, output_dir=tmp, name_template="hw_autostop", silence_threshold_dbfs=-20.0, auto_stop_silence_seconds=0.6) rec = engine.Recorder(cfg) rec.start() t0 = time.time() stopped_at = None while time.time() - t0 < 6.0: if rec.live().state == engine.RecorderState.IDLE.value: stopped_at = time.time() - t0 break time.sleep(0.05) r.check("静音自动停止被触发", stopped_at is not None, f"{stopped_at:.2f} 秒后停止" if stopped_at else "6 秒内未触发") # 关键:引擎自己停止时必须同时关闭 PortAudio 流,否则回调会持续灌满队列 time.sleep(1.2) backlog_after = rec.live().queue_backlog overflow_after = rec.live().overflow_blocks r.check("自动停止后音频流已关闭(队列不再增长、无溢出)", backlog_after <= 1 and overflow_after == 0, f"静置 1.2 秒后队列 {backlog_after},溢出块 {overflow_after}") res = rec.stop() r.check("自动停止后文件已正确收尾", bool(res.files) and res.duration > 0.3 and all(os.path.exists(f) for f in res.files), f"{res.duration:.2f}s,{len(res.files)} 个文件") r.check("自动停止的原因写进了运行日志", any("自动停止" in n for n in res.notes), res.notes[-1] if res.notes else "无日志") # ---------------------------------------------------------------- 入口 def run_all(*, include_hardware: bool = True, quick: bool = False, quiet: bool = False) -> Runner: r = Runner(quiet=quiet) tmp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_build", "selftest") os.makedirs(tmp, exist_ok=True) print(f"RecorderStudio 自检 Python {sys.version.split()[0]} " f"numpy {np.__version__} ffmpeg " f"{'已找到' if post.find_ffmpeg() else '未找到'}") print(f"临时目录:{tmp}") t0 = time.time() test_loudness(r) test_filters(r) test_true_peak(r) test_meters(r) test_wav(r, tmp) test_dither(r) test_bitdepth(r) test_processing(r, tmp) test_metadata(r, tmp) test_engine_offline(r, tmp) if include_hardware: test_hardware(r, tmp, seconds=0.8 if quick else 1.5) print("\n" + "=" * 62) elapsed = time.time() - t0 if r.ok: print(f"全部通过:{r.passed} 项检查,用时 {elapsed:.1f} 秒。") else: print(f"通过 {r.passed} 项,失败 {len(r.failed)} 项,用时 {elapsed:.1f} 秒:") for name, detail in r.failed: print(f" ✗ {name} {detail}") return r def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description="RecorderStudio 自检") p.add_argument("--no-hw", action="store_true", help="跳过需要真实硬件的测试") p.add_argument("--quick", action="store_true", help="硬件测试缩短到 1 秒") p.add_argument("--quiet", action="store_true", help="只输出失败项") args = p.parse_args(argv) r = run_all(include_hardware=not args.no_hw, quick=args.quick, quiet=args.quiet) return 0 if r.ok else 1 if __name__ == "__main__": sys.exit(main())