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
+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("界面程序化校验全部通过")