Initial commit: RecorderStudio:PyQt5 高保真录音软件,无损 WAV / WASAPI 独占、BS.1770 响度与 ffmpeg 交叉验证

This commit is contained in:
WpyQwq
2026-09-19 11:56:01 +08:00
commit b7f9098ccd
62 changed files with 10796 additions and 0 deletions
+661
View File
@@ -0,0 +1,661 @@
"""自检测试:数学正确性 + 格式往返 + 硬件端到端。
用法::
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())