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
+131
View File
@@ -0,0 +1,131 @@
"""界面回归检查之二:对话框的后台任务链路(QThread + 信号)真跑一遍。
自检覆盖了 process_file / export_audio 这些纯函数,但没有覆盖
"Qt 工作线程 → 信号 → 界面回填"这段管线。这里直接构造对话框、
触发 _run()、泵事件循环直到完成,再检查产物是否真的生成。
"""
import os
import sys
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
app = QApplication([])
from recorder.gui import QSS # noqa: E402
app.setStyleSheet(QSS)
from recorder import dsp # noqa: E402
from recorder.wavfile import WavReader, write_wav # noqa: E402
from recorder.window import ExportDialog, ProcessDialog # noqa: E402
fails = []
def check(name, ok, detail=""):
print((" ✓ " if ok else " ✗ ") + name + ((" " + detail) if detail else ""))
if not ok:
fails.append(name)
def pump(seconds=1.0, until=None):
"""泵事件循环,直到 until() 为真或超时。"""
t0 = time.time()
while time.time() - t0 < seconds:
app.processEvents()
time.sleep(0.02)
if until is not None and until():
return True
return until() if until else True
work = os.path.join(ROOT, "dev", "shots", "dlg")
os.makedirs(work, exist_ok=True)
# 造一段"两头静音 + 中间正弦 + 轻微直流偏置"的测试素材
sr = 48000
rng = np.random.default_rng(3)
sil = rng.standard_normal((sr // 2, 2)) * dsp.db_to_lin(-85) + 0.001
tone = np.stack([np.sin(2 * np.pi * 440 * np.arange(sr) / sr) * 0.25] * 2, axis=1)
src = os.path.join(work, "dialog_src.wav")
write_wav(src, np.concatenate([sil, tone, sil]).astype(np.float32), sr, bit_depth="24")
print("── 后期处理对话框 " + "─" * 48)
pd = ProcessDialog(src, None)
pd.show()
app.processEvents()
pd.trim.setChecked(True)
pd.norm.setCurrentIndex(1) # 峰值归一化
pd.depth.setCurrentIndex(pd.depth.findData("16")) # 16 位输出
pd._run()
ok = pump(60, until=lambda: pd.progress.value() == 100 and "完成" in pd.log.toPlainText())
out = os.path.splitext(src)[0] + "_processed.wav"
check("后期处理任务完成并回填界面", ok and pd.run_btn.isEnabled(),
pd.log.toPlainText().strip().splitlines()[-1] if pd.log.toPlainText() else "")
check("生成了处理后的文件", os.path.exists(out),
f"{os.path.getsize(out) if os.path.exists(out) else 0} 字节")
check("处理完成后「打开输出目录」按钮可用", pd.open_btn.isEnabled())
if os.path.exists(out):
with WavReader(out) as r:
info = r.info()
data = r.read()
check("输出为 16 位且用时变短(首尾静音已裁)",
info["bits"] == 16 and 1.1 < info["duration"] < 1.5,
f"{info['format']},{info['duration']:.3f}s(原始 2.000s)")
check("峰值被归一化到 -1 dBFS 附近",
abs(float(dsp.dbfs(dsp.peak(data, axis=0))[0]) + 1.0) < 0.3,
f"{float(dsp.dbfs(dsp.peak(data, axis=0))[0]):+.2f} dBFS")
check("直流偏移已去除", abs(float(np.mean(data))) < 1e-3,
f"{float(np.mean(data)):.2e}")
pd.close()
print("\n── 导出对话框 " + "─" * 50)
ed = ExportDialog(out, None)
ed.show()
app.processEvents()
check("导出预设列表完整", ed.preset.count() >= 8,
"、".join(ed.preset.itemData(i) for i in range(ed.preset.count())))
ed.preset.setCurrentIndex(1) # WAV 24 位
ed._run()
done = pump(60, until=lambda: "成功" in ed.log.toPlainText()
or "失败" in ed.log.toPlainText())
check("导出任务完成并回填界面", done and "成功" in ed.log.toPlainText(),
ed.log.toPlainText().strip())
exported = None
for i in range(ed.preset.count()):
from recorder import post
p = post.default_export_path(out, ed.preset.itemData(i))
if os.path.exists(p):
exported = p
check("导出文件已生成且不与源文件同名",
exported is not None and os.path.abspath(exported) != os.path.abspath(out),
os.path.basename(exported) if exported else "无")
# 找不到 ffmpeg 的场景:必须有清晰提示而不是崩溃
from recorder import post as _post
if _post.find_ffmpeg() is None:
ed.preset.setCurrentIndex(ed.preset.findData("flac"))
ed._run()
pump(20, until=lambda: "失败" in ed.log.toPlainText())
check("缺少 ffmpeg 时导出失败并给出提示",
"ffmpeg" in ed.log.toPlainText(), ed.log.toPlainText().strip()[-60:])
else:
ed.preset.setCurrentIndex(ed.preset.findData("flac"))
ed._run()
flac_ok = pump(120, until=lambda: "成功" in ed.log.toPlainText()
or "失败" in ed.log.toPlainText())
check("导出 FLAC 成功", flac_ok and "成功" in ed.log.toPlainText(),
ed.log.toPlainText().strip()[-60:])
ed.close()
print("\n" + "=" * 62)
if fails:
print(f"失败 {len(fails)} 项:" + "、".join(fails))
sys.exit(1)
print("对话框后台任务链路校验全部通过")