Initial commit: RecorderStudio:PyQt5 高保真录音软件,无损 WAV / WASAPI 独占、BS.1770 响度与 ffmpeg 交叉验证
This commit is contained in:
@@ -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("对话框后台任务链路校验全部通过")
|
||||
@@ -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("界面程序化校验全部通过")
|
||||
@@ -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.
Reference in New Issue
Block a user