662 lines
30 KiB
Python
662 lines
30 KiB
Python
"""自检测试:数学正确性 + 格式往返 + 硬件端到端。
|
||
|
||
用法::
|
||
|
||
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())
|