Files

662 lines
30 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自检测试:数学正确性 + 格式往返 + 硬件端到端。
用法::
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="<i2").reshape(-1, 2)
ref = np.clip(sig, -1, 1) * 32767
err = float(np.max(np.abs(decoded.astype(np.float64) - ref))) / 32767
r.check("ffmpeg 独立解码 24 位文件内容正确", decoded.shape[0] == 48000 and err < 1e-4,
f"最大偏差 {err:.2e},{decoded.shape[0]} 帧")
else:
r.check("ffmpeg 可用(跳过交叉解码验证)", True, "未检测到 ffmpeg,已跳过")
# RF64
pr = os.path.join(tmp, "rt_rf64.wav")
write_wav(pr, sig, 48000, bit_depth="24", rf64=True)
with WavReader(pr) as rd:
back = rd.read()
r.check("RF64 容器读写往返", rd.rf64 and back.shape == sig.shape,
f"rf64={rd.rf64},{back.shape[0]} 帧")
# 损坏/边界输入
pbad = os.path.join(tmp, "bad.wav")
with open(pbad, "wb") as fh:
fh.write(b"not a wav file")
try:
WavReader(pbad)
r.check("非法文件被拒绝", False, "竟然没有报错")
except ValueError:
r.check("非法文件被拒绝", True)
# 空录音
pe = os.path.join(tmp, "empty.wav")
write_wav(pe, np.zeros((0, 2), np.float32), 48000)
with WavReader(pe) as rd:
r.check("零长度录音可被正常写出与读取",
rd.frames == 0 and rd.duration == 0.0)
def test_dither(r: Runner) -> 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())