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("对话框后台任务链路校验全部通过")
+363
View File
@@ -0,0 +1,363 @@
"""界面回归检查(开发/验收用):布局几何 + 像素颜色区域校验 + 截图。
AI 无法直接查看截图,所以这里用两种可判定的方式验证界面确实渲染正确:
1. 遍历控件树,检查是否有零尺寸 / 越界 / 被横向裁掉的控件,
并用字体度量核对关键文本(大号计时器、状态栏)不会被截断;
2. 读取渲染结果的像素,按区域统计颜色,确认电平条、录音按钮、
示波器、整段总览等自绘控件确实画出了预期颜色。
还会真实录音 2.6 秒,验证「开始录音 → 界面刷新 → 停止 → 仪表冻结」链路。
用法::
python dev/gui_check.py # 离屏运行,不需要显示器
python dev/gui_check.py --shots # 额外把截图存到 dev/shots/
"""
import os, sys, time, collections
SHOT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
SAVE_SHOTS = "--shots" in sys.argv
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt5.QtCore import Qt, QRect
from PyQt5.QtWidgets import (QApplication, QWidget, QComboBox, QLabel, QPushButton,
QGroupBox, QScrollArea, QSpinBox, QDoubleSpinBox,
QLineEdit, QCheckBox, QSlider)
from PyQt5.QtGui import QFont, QImage
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
app = QApplication([])
from recorder.gui import QSS
app.setStyleSheet(QSS)
app.setFont(QFont("Microsoft YaHei UI", 9))
from recorder.window import MainWindow
from recorder import engine
from recorder.widgets import ACCENT, REC, OK, WARN
fails = []
def check(name, ok, detail=""):
print((" ✓ " if ok else " ✗ ") + name + ((" " + detail) if detail else ""))
if not ok:
fails.append(name)
def walk(w, depth=0, out=None):
out = [] if out is None else out
out.append((depth, w))
for c in w.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
walk(c, depth + 1, out)
return out
print("── 布局几何检查 " + "─" * 50)
win = MainWindow()
win.resize(1280, 900)
out_dir = SHOT_DIR
os.makedirs(out_dir, exist_ok=True)
win.dir_edit.setText(os.path.join(out_dir, "rec"))
win.auto_report_check.setChecked(False)
win.show()
for _ in range(40):
app.processEvents()
time.sleep(0.01)
if SAVE_SHOTS:
win.grab().save(os.path.join(SHOT_DIR, "01_idle.png"))
nodes = walk(win)
print(f"控件总数:{len(nodes)}")
zero = [w for _d, w in nodes
if w.isVisible() and (w.width() <= 1 or w.height() <= 1)
and not isinstance(w, (QScrollArea,))]
check("没有零尺寸的可见控件", not zero,
"、".join(f"{type(w).__name__}" for w in zero[:6]))
# 关键控件必须可见且有合理尺寸
for name, w, min_w, min_h in (
("录音按钮", win.rec_button, 60, 60),
("电平表", win.meter, 300, 60),
("示波器", win.scope, 300, 120),
("响度条", win.loudness, 300, 40),
("总览图", win.history, 300, 60),
("定时显示", win.time_label, 120, 30),
):
check(f"{name}尺寸合理", w.isVisible() and w.width() >= min_w and w.height() >= min_h,
f"{w.width()}×{w.height()}")
# 控件不得超出窗口(滚动区内的控件按内容坐标滚动,单独检查其水平适配)
over = []
for _d, w in nodes:
if not w.isVisible() or w is win:
continue
anc, in_scroll = w.parent(), False
while anc is not None:
if isinstance(anc, QScrollArea):
in_scroll = True
break
anc = anc.parent()
if in_scroll:
continue
tl = w.mapTo(win, w.rect().topLeft() - w.rect().topLeft())
if tl.x() + w.width() > win.width() + 2 or tl.y() + w.height() > win.height() + 2 \
or tl.x() < -2 or tl.y() < -2:
over.append(f"{type(w).__name__}@{tl.x()},{tl.y()} {w.width()}×{w.height()}")
check("没有控件越出窗口边界", not over, ";".join(over[:5]))
# 关键文本不得被截断(用字体度量判断)
for name, w, sample in (("定时显示", win.time_label, "10:59:59"),
("状态栏左侧", win.status_left, "正在录音:D:\\Recordings")):
need = w.fontMetrics().horizontalAdvance(sample)
check(f"{name}能完整显示最长文本", w.width() >= need,
f"需要 {need}px,实得 {w.width()}px")
# 左面板不该被横向裁掉(滚动区内容宽度 <= 视口宽度 + 容差)
scroll = win.findChild(QScrollArea)
if scroll is not None:
inner = scroll.widget()
check("设置面板内容宽度适配视口(无横向裁切)",
inner.minimumSizeHint().width() <= scroll.viewport().width() + 2,
f"内容最小 {inner.minimumSizeHint().width()} / 视口 {scroll.viewport().width()}")
def region_colors(pixmap, rect, targets, tol=40):
"""统计 rect 区域内与各目标颜色接近的像素比例。"""
img = pixmap.toImage().convertToFormat(QImage.Format_RGB32)
counts = collections.Counter()
total = 0
for y in range(rect.top(), min(rect.bottom(), img.height()), 2):
for x in range(rect.left(), min(rect.right(), img.width()), 2):
c = img.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
total += 1
for name, (tr, tg, tb) in targets.items():
if abs(r - tr) < tol and abs(g - tg) < tol and abs(b - tb) < tol:
counts[name] += 1
break
return {k: v / max(1, total) for k, v in counts.items()}, total
def to_rgb(hexstr):
hexstr = hexstr.lstrip("#")
return tuple(int(hexstr[i:i + 2], 16) for i in (0, 2, 4))
print("\n── 空闲状态像素校验 " + "─" * 46)
pm = win.grab()
targets = {"面板": to_rgb("#161A22"), "背景": to_rgb("#0F1218"),
"强调蓝": to_rgb(ACCENT), "录音红": to_rgb(REC)}
win_rect = QRect(0, 0, win.width(), win.height())
ratios, total = region_colors(pm, win_rect, targets)
print(f" 采样 {total} 点:面板 {ratios.get('面板', 0):.1%},"
f"背景 {ratios.get('背景', 0):.1%},录音红 {ratios.get('录音红', 0):.1%}")
check("界面不是一片黑(面板/背景色占比正常)",
ratios.get("面板", 0) + ratios.get("背景", 0) > 0.4)
br, br_total = region_colors(
pm, QRect(win.rec_button.mapTo(win, win.rec_button.rect().topLeft()),
win.rec_button.size()), {"红": to_rgb(REC)})
check("空闲时录音按钮显示红色圆点", br.get("红", 0) > 0.05,
f"按钮区域内红色占比 {br.get('红', 0):.1%}")
print("\n── 录音状态像素校验 " + "─" * 46)
for i in range(win.device_combo.count()):
d = win.device_combo.itemData(i)
if d is not None:
dev = [x for x in win._devices if x.index == d]
if dev and dev[0].quality_rank == 0:
win.device_combo.setCurrentIndex(i)
break
win.gain_slider.setValue(150)
win.start_record()
t0 = time.time()
while time.time() - t0 < 2.2:
app.processEvents()
time.sleep(0.02)
win.add_marker()
while time.time() - t0 < 2.6:
app.processEvents()
time.sleep(0.02)
pm2 = win.grab()
if SAVE_SHOTS:
pm2.save(os.path.join(SHOT_DIR, "02_recording.png"))
def _is_meter_fill(r, g, b) -> bool:
"""电平条填充色:饱和度高的绿/黄/红/深蓝;排除灰白文字与白色峰值线。"""
if max(r, g, b) < 110 or min(r, g, b) > 150:
return False
return (max(r, g, b) - min(r, g, b)) > 45
def meter_fill_extent(lane: int = 0) -> float:
"""返回某个通道电平条"填充到最右边的位置"占表宽的百分比。"""
img = win.meter.grab().toImage().convertToFormat(QImage.Format_RGB32)
w, h = img.width(), img.height()
channels = win.meter.channels
top, footer = 4, 20
per = (h - top - footer) / channels
y0 = max(0, int(top + lane * per))
y1 = min(h - 1, int(top + (lane + 1) * per) - 1)
best = 0
for y in range(y0, y1):
for x in range(w - 1, -1, -1):
c = img.pixel(x, y)
if _is_meter_fill((c >> 16) & 255, (c >> 8) & 255, c & 255):
best = max(best, x)
break
return best / w
# 确定性校验 dB -> 像素映射:注入已知电平,比较填充长度
# (现场麦克风可能接近静音,直接看真实输入会让这个检查随环境漂移。
# 同时必须暂停界面刷新定时器,否则注入的值会被真实电平覆盖。)
from recorder import dsp as _dsp # noqa: E402
win.timer.stop()
def inject(peak_db, rms_db=None, clipped=False):
rms_db = peak_db if rms_db is None else rms_db
win.meter.update_levels(_dsp.MeterSnapshot(
rms_db=list(rms_db), peak_db=list(peak_db), hold_db=list(peak_db),
true_peak_db=list(peak_db), clipped=[clipped] * len(peak_db)))
app.processEvents()
inject([-3.0] * 2)
loud = meter_fill_extent(0)
inject([-30.0] * 2)
quiet = meter_fill_extent(0)
inject([-60.0] * 2)
floor = meter_fill_extent(0)
check("电平表按 dB 线性映射为像素长度",
loud > quiet + 0.15 and quiet > floor + 0.15 and loud > 0.85,
f"-3 dB → {loud:.0%},-30 dB → {quiet:.0%},-60 dB → {floor:.0%} 表宽")
inject([0.0, 0.0], clipped=True)
img = win.meter.grab().toImage().convertToFormat(QImage.Format_RGB32)
red = 0
tot = 0
for y in range(0, img.height(), 2):
for x in range(0, img.width(), 2):
c = img.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
tot += 1
if r > 200 and g < 130 and b < 140:
red += 1
check("削波锁存时电平表显示红色警示", red / max(1, tot) > 0.002,
f"红色占比 {red / max(1, tot):.2%}")
inject([-14.0, -14.0], rms_db=[-20.0, -20.0]) # 恢复成接近真实输入的样子
win.timer.start()
app.processEvents()
pix = pm2.toImage().convertToFormat(QImage.Format_RGB32)
scope_rect = QRect(win.scope.mapTo(win, win.scope.rect().topLeft()), win.scope.size())
sc_lit = 0
sc_total = 0
for y in range(scope_rect.top(), scope_rect.bottom(), 2):
for x in range(scope_rect.left(), scope_rect.right(), 2):
c = pix.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
sc_total += 1
if b > 150 and b > r + 40:
sc_lit += 1
check("示波器画出了波形(蓝色像素)", sc_lit / max(1, sc_total) > 0.001,
f"{sc_lit}/{sc_total} = {sc_lit/max(1,sc_total):.2%}")
hist_rect = QRect(win.history.mapTo(win, win.history.rect().topLeft()),
win.history.size())
h_lit = 0
h_total = 0
for y in range(hist_rect.top(), hist_rect.bottom(), 2):
for x in range(hist_rect.left(), hist_rect.right(), 2):
c = pix.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
h_total += 1
if b > 150:
h_lit += 1
check("整段总览图有波形", h_lit > 0, f"{h_lit} 个采样点")
snap = win.recorder.live()
check("录音统计在更新", snap.elapsed > 1.5 and snap.bytes_written > 0,
f"{snap.elapsed:.2f}s,{snap.bytes_written} 字节,"
f"峰值 {snap.peak_dbfs:+.1f} dBFS")
check("定时器显示已刷新", win.time_label.text() != "00:00.0", win.time_label.text())
check("状态标签显示文件名", "wav" in win.pill_file.text, win.pill_file.text)
check("录音按钮切换为录音态", win.rec_button.state == "recording", win.rec_button.state)
check("参数控件在录音中被禁用", not win.device_combo.isEnabled()
and not win.rate_combo.isEnabled())
check("传输按钮在录音中可用", win.stop_btn.isEnabled() and win.marker_btn.isEnabled()
and win.pause_btn.isEnabled())
win.stop_record()
for _ in range(20):
app.processEvents()
time.sleep(0.01)
check("停止后回到空闲态", win.recorder is None and win.rec_button.state == "idle")
check("停止后有录音结果", win.result is not None and bool(win.result.files),
f"{len(win.result.files) if win.result else 0} 个文件")
check("停止后电平表冻结显示最后一次读数(便于回看峰值)",
win.meter._peak[0] > -60, f"{win.meter._peak[0]:.1f} dBFS")
check("停止后定时显示保留最终时长", win.time_label.text() != "00:00.0",
win.time_label.text())
check("暂停/标记/分段按钮停止后禁用", not win.stop_btn.isEnabled())
# 重新开始录音时仪表盘必须清零
win.start_record()
for _ in range(10):
app.processEvents()
time.sleep(0.01)
check("重新开始时电平表已复位", win.meter._clip == [False] * win.meter.channels,
f"削波锁存 {win.meter._clip}")
win.stop_record()
for _ in range(10):
app.processEvents()
time.sleep(0.01)
if SAVE_SHOTS:
win.grab().save(os.path.join(SHOT_DIR, "03_after.png"))
from recorder.window import ReportDialog
if win.result:
dlg = ReportDialog(win.result, win)
dlg.resize(760, 620)
dlg.show()
for _ in range(10):
app.processEvents()
dlg.grab().save(os.path.join(SHOT_DIR, "04_report.png"))
dlg.close()
print(f"截图已保存到 {SHOT_DIR}")
print("\n── 静音自动停止 → 界面自动收尾 " + "─" * 33)
win.auto_stop.setValue(0.6)
win.silence_thr.setValue(-20.0) # 阈值故意设高,保证当前环境一定触发
win.start_record()
check("自动停止测试已开始录音", win.recorder is not None)
t0 = time.time()
while time.time() - t0 < 8:
app.processEvents()
time.sleep(0.02)
if win.recorder is None:
break
check("引擎自动停止后界面自动收尾(不再停在「录音中」)",
win.recorder is None and win.rec_button.state == "idle",
f"用时 {time.time() - t0:.2f} 秒")
check("自动停止也产生了可用的录音结果",
win.result is not None and bool(win.result.files),
f"{win.result.duration:.2f}s,{len(win.result.files)} 个文件"
if win.result else "无结果")
check("自动停止后传输按钮已禁用", not win.stop_btn.isEnabled())
win.auto_stop.setValue(0.0)
win.silence_thr.setValue(-50.0)
print("\n" + "=" * 62)
if fails:
print(f"失败 {len(fails)} 项:" + "、".join(fails))
sys.exit(1)
print("界面程序化校验全部通过")
+55
View File
@@ -0,0 +1,55 @@
"""静态检查(无第三方 linter 时的替代):未使用导入、裸 except、过长函数。
用法:``python dev/lint_scan.py``
`from __future__ import annotations` 会被误判(它确实没有显式引用),已忽略。
"""
import ast
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TARGETS = [os.path.join(ROOT, "recorder"), ROOT]
IGNORE = {"annotations"}
MAX_FUNC_LINES = 130
issues: list[str] = []
for target in TARGETS:
files = ([os.path.join(target, f) for f in sorted(os.listdir(target))
if f.endswith(".py")]
if os.path.isdir(target) else [target])
for path in files:
rel = os.path.relpath(path, ROOT)
with open(path, encoding="utf-8") as fh:
src = fh.read()
lines = src.splitlines()
tree = ast.parse(src, path)
def has_noqa(lineno: int) -> bool:
return 0 < lineno <= len(lines) and "noqa" in lines[lineno - 1]
imported: dict[str, int] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
imported[(a.asname or a.name).split(".")[0]] = node.lineno
elif isinstance(node, ast.ImportFrom):
for a in node.names:
if a.name != "*":
imported[a.asname or a.name] = node.lineno
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
for name, line in sorted(imported.items(), key=lambda kv: kv[1]):
if name in used or name in IGNORE or has_noqa(line):
continue
if f'"{name}"' in src or f"'{name}'" in src:
continue
issues.append(f"{rel}:{line} 未使用的导入 {name}")
for node in ast.walk(tree):
if isinstance(node, ast.ExceptHandler) and node.type is None \
and not has_noqa(node.lineno):
issues.append(f"{rel}:{node.lineno} 裸 except")
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
length = (node.end_lineno or node.lineno) - node.lineno
if length > MAX_FUNC_LINES and not has_noqa(node.lineno):
issues.append(f"{rel}:{node.lineno} 函数 {node.name} 过长({length} 行)")
print("\n".join(issues) if issues else "未发现明显问题")
print(f"\n共 {len(issues)} 条提示")
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.